Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "interactive"
}
76 changes: 76 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Basic Video Player with PiP

A simple video player Android application that demonstrates proper MVVM architecture implementation with Picture-in-Picture (PiP) functionality and dynamic video source loading from a JSON file.

## Features

- **Picture-in-Picture Mode**: Continue watching videos in a small floating window when you navigate away from the app
- **Dynamic Video Source**: Video URL is loaded from a local JSON file
- **MVVM Architecture**: Clean separation of concerns with Model-View-ViewModel pattern
- **Error Handling**: Graceful fallback to local video when remote loading fails
- **Dependency Injection**: Uses Hilt for clean dependency management
## Screenshots

| Full Screen Playback | Picture-in-Picture Mode |
|:---:|:---:|
| <img src="https://github.com/user-attachments/assets/7dc2418d-8553-48ae-a404-f95c4aadf805" width="300"/> | <img src="https://github.com/user-attachments/assets/06dd6619-01e4-4b90-b65b-d796e4288241" width="300"/> |



## Implementation Details

### Architecture Components

- **Model**: Handles data operations through JsonDataSource and VideoRepository
- **View**: UI components in VideoPlayerActivity with lifecycle-aware playback
- **ViewModel**: Manages business logic and UI state in VideoPlayerViewModel

### Libraries Used

- ExoPlayer for video playback
- Hilt for dependency injection
- Gson for JSON parsing
- Android Lifecycle components for MVVM implementation
- Kotlin Coroutines for asynchronous operations

## Getting Started

1. Clone the repository
2. Open the project in Android Studio
3. Run the app on an emulator or physical device running Android 8.0 (API level 26) or higher for full PiP support

## How It Works

The app reads a video URL from a JSON file in the assets folder and plays it using ExoPlayer. When you press back or navigate away from the app, it automatically enters Picture-in-Picture mode, allowing you to continue watching the video while using other apps.

If there's any issue loading the video from the remote URL, the app will automatically fall back to a local video resource.

### Architecture Flow

1. **Data Loading**: When the app starts, the `JsonDataSource` reads the video URL from the assets folder and passes it to the `VideoRepository`.

2. **Repository Layer**: The `VideoRepository` processes the data and handles any potential errors, ensuring a valid URL is always provided.

3. **ViewModel Processing**: The `VideoPlayerViewModel` requests the URL from the repository and exposes it to the UI through LiveData, along with loading states.

4. **UI Rendering**: The `VideoPlayerActivity` observes the ViewModel's LiveData and updates the UI accordingly, loading the video when the URL is available.

5. **Video Playback**: ExoPlayer handles the actual video playback, with proper lifecycle management to prevent memory leaks.

6. **PiP Functionality**: The `PipHelper` class manages transitions to and from Picture-in-Picture mode, maintaining the playback state during transitions.

### Error Handling

The app implements multiple layers of error handling to ensure a smooth user experience:

- JSON parsing errors are caught in the data source
- Network errors are handled when loading the video
- Playback errors trigger fallback to local video
- UI states reflect loading progress and success states

This multi-layered approach ensures that users always have a working video player experience, even when offline or when the remote video source is unavailable.

## Requirements

- Android 8.0 (API level 26) or higher for PiP functionality
- Android Studio Arctic Fox or newer
34 changes: 34 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
id("com.google.dagger.hilt.android")
id("kotlin-kapt")
}

android {
Expand Down Expand Up @@ -33,13 +35,45 @@ android {
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
viewBinding = true
}
// For Hilt
kapt {
correctErrorTypes = true
}
}

dependencies {

implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.exoplayer)

// Hilt for dependency injection
implementation("com.google.dagger:hilt-android:2.50")
implementation(libs.androidx.tracing.perfetto.handshake)
kapt("com.google.dagger:hilt-compiler:2.50")

// ViewModel and LiveData
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.hilt:hilt-navigation-fragment:1.1.0")

// Retrofit for network requests
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")

// OkHttp for networking
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")

// Gson for JSON parsing
implementation("com.google.code.gson:gson:2.10.1")

// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
Expand Down
14 changes: 13 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<application
android:name=".VideoPlayerApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
Expand All @@ -14,13 +18,21 @@
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:supportsPictureInPicture="true"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|screenLayout|uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<activity
android:name=".ui.player.VideoPlayerActivity"
android:exported="false"
android:supportsPictureInPicture="true"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|screenLayout|uiMode" />
</application>

</manifest>
3 changes: 3 additions & 0 deletions app/src/main/assets/video_url.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"url": "https://storage.googleapis.com/test-gg-1/Sample%20Video.mp4"
}
44 changes: 16 additions & 28 deletions app/src/main/java/com/pubscale/basicvideoplayer/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,40 +1,28 @@
package com.pubscale.basicvideoplayer

import android.net.Uri
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import com.pubscale.basicvideoplayer.ui.player.VideoPlayerActivity
import dagger.hilt.android.AndroidEntryPoint

/**
* Entry point of the application.
* Simply launches the VideoPlayerActivity and finishes.
*/
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private var player: ExoPlayer? = null
private var playerView: PlayerView? = null


override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setupExoPLayer()
}

private fun setupExoPLayer() {
playerView = findViewById(R.id.player_view)
player = ExoPlayer.Builder(this).build()
playerView?.player = player
val videoUri = Uri.parse("android.resource://" + packageName + "/" + R.raw.sample_video)
val mediaItem = MediaItem.fromUri(videoUri)
player?.setMediaItem(mediaItem)
player?.prepare()
player?.playWhenReady = true
startVideoPlayer()
}

override fun onRestart() {
super.onRestart()
player?.play()
}

override fun onStop() {
super.onStop()
player?.pause()

// Launch the video player activity
private fun startVideoPlayer() {
val intent = Intent(this, VideoPlayerActivity::class.java)
startActivity(intent)
finish()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.pubscale.basicvideoplayer

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class VideoPlayerApplication : Application()
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.pubscale.basicvideoplayer.data.api

import android.content.Context
import com.google.gson.Gson
import com.pubscale.basicvideoplayer.data.model.VideoResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject

/**
* Data source for reading video information from the JSON file.
* Uses app context to access assets folder.
*/
class JsonDataSource @Inject constructor(
private val context: Context,
private val gson: Gson
) {

companion object {
private const val JSON_FILE_NAME = "video_url.json"
}

/**
* Reads the JSON file from assets and converts it to VideoResponse.
* Uses IO dispatcher to avoid blocking the main thread.
*/
suspend fun getVideoUrl(): VideoResponse {
return withContext(Dispatchers.IO) {
val jsonString = context.assets.open(JSON_FILE_NAME).bufferedReader().use { it.readText() }
gson.fromJson(jsonString, VideoResponse::class.java)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.pubscale.basicvideoplayer.data.model

/**
* Data class that represents the video information from JSON.
* Contains the URL of the video to be played.
*/
data class VideoResponse(
val url: String
) {
companion object {
// Fallback URL to use if JSON reading fails or returns empty URL
const val FALLBACK_VIDEO_URL = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.pubscale.basicvideoplayer.data.repository

import com.pubscale.basicvideoplayer.data.api.JsonDataSource
import com.pubscale.basicvideoplayer.data.model.VideoResponse
import javax.inject.Inject

/**
* Repository that provides video URL data.
* Handles fetching from JSON and provides fallback if needed.
*/
class VideoRepository @Inject constructor(
private val jsonDataSource: JsonDataSource
) {

/**
* Gets the video URL from local JSON file or returns fallback URL.
* Ensures a valid URL is always returned.
*/
suspend fun getVideoUrl(): String {
return try {
val response = jsonDataSource.getVideoUrl()

// Return URL if valid, otherwise use fallback
response.url.takeIf { it.isNotEmpty() }
?: VideoResponse.FALLBACK_VIDEO_URL
} catch (e: Exception) {
// On any error, return the fallback URL
VideoResponse.FALLBACK_VIDEO_URL
}
}
}
46 changes: 46 additions & 0 deletions app/src/main/java/com/pubscale/basicvideoplayer/di/AppModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.pubscale.basicvideoplayer.di

import android.content.Context
import com.google.gson.Gson
import com.pubscale.basicvideoplayer.data.api.JsonDataSource
import com.pubscale.basicvideoplayer.data.repository.VideoRepository
import com.pubscale.basicvideoplayer.ui.player.PipHelper
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

/**
* Hilt module that provides dependencies for the application.
* All dependencies are provided as singletons.
*/
@Module
@InstallIn(SingletonComponent::class)
object AppModule {

// Provides the PiP helper
@Provides
@Singleton
fun providePipHelper() = PipHelper()

// Provides Gson for JSON parsing
@Provides
@Singleton
fun provideGson() = Gson()

// Provides the data source for JSON operations
@Provides
@Singleton
fun provideJsonDataSource(
@ApplicationContext context: Context,
gson: Gson
) = JsonDataSource(context, gson)

// Provides the repository to access video data
@Provides
@Singleton
fun provideVideoRepository(jsonDataSource: JsonDataSource) =
VideoRepository(jsonDataSource)
}
Loading