diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 50b8898..8c4dd43 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -72,6 +72,7 @@ dependencies {
implementation(libs.medi3.exoplayer)
implementation(libs.medi3.ui.compose)
implementation(libs.medi3.ffmpeg.decoder)
+ implementation(libs.media3.datasource.okhttp)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.room.ktx)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c676294..833dee3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,6 +1,10 @@
+
+
+
+
+
diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt
index 9168ee9..7491167 100644
--- a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt
+++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt
@@ -16,7 +16,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast
+import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Download
+import androidx.compose.material.icons.outlined.DownloadDone
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -36,6 +38,7 @@ import hu.bbara.purefin.common.ui.components.GhostIconButton
import hu.bbara.purefin.common.ui.components.MediaActionButton
import hu.bbara.purefin.common.ui.components.MediaPlayButton
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
+import hu.bbara.purefin.download.DownloadState
import hu.bbara.purefin.player.PlayerActivity
@Composable
@@ -68,6 +71,8 @@ internal fun MovieTopBar(
@Composable
internal fun MovieDetails(
movie: MovieUiModel,
+ downloadState: DownloadState,
+ onDownloadClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
@@ -136,8 +141,14 @@ internal fun MovieDetails(
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.secondary,
iconColor = MaterialTheme.colorScheme.onSecondary,
- icon = Icons.Outlined.Download,
- height = 48.dp
+ icon = when (downloadState) {
+ is DownloadState.NotDownloaded -> Icons.Outlined.Download
+ is DownloadState.Downloading -> Icons.Outlined.Close
+ is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
+ is DownloadState.Failed -> Icons.Outlined.Download
+ },
+ height = 48.dp,
+ onClick = onDownloadClick
)
}
}
diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt
index 8d87ea4..565cdb1 100644
--- a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt
+++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt
@@ -1,5 +1,9 @@
package hu.bbara.purefin.app.content.movie
+import android.Manifest
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -18,6 +22,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.content.ContentMockData
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaHero
+import hu.bbara.purefin.download.DownloadState
import hu.bbara.purefin.navigation.MovieDto
@Composable
@@ -29,10 +34,30 @@ fun MovieScreen(
}
val movieItem = viewModel.movie.collectAsState()
+ val downloadState = viewModel.downloadState.collectAsState()
+
+ val notificationPermissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { granted ->
+ // Proceed with download regardless — notification is nice-to-have
+ viewModel.onDownloadClick()
+ }
+
+ val onDownloadClick = {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+ && downloadState.value is DownloadState.NotDownloaded
+ ) {
+ notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ viewModel.onDownloadClick()
+ }
+ }
if (movieItem.value != null) {
MovieScreenInternal(
movie = movieItem.value!!,
+ downloadState = downloadState.value,
+ onDownloadClick = onDownloadClick,
onBack = viewModel::onBack,
modifier = modifier
)
@@ -44,6 +69,8 @@ fun MovieScreen(
@Composable
private fun MovieScreenInternal(
movie: MovieUiModel,
+ downloadState: DownloadState = DownloadState.NotDownloaded,
+ onDownloadClick: () -> Unit = {},
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -70,6 +97,8 @@ private fun MovieScreenInternal(
)
MovieDetails(
movie = movie,
+ downloadState = downloadState,
+ onDownloadClick = onDownloadClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenViewModel.kt
index a55459e..a754408 100644
--- a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenViewModel.kt
+++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenViewModel.kt
@@ -4,11 +4,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.client.JellyfinApiClient
+import hu.bbara.purefin.download.DownloadState
+import hu.bbara.purefin.download.MediaDownloadManager
import hu.bbara.purefin.image.JellyfinImageHelper
import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -23,12 +26,16 @@ import javax.inject.Inject
class MovieScreenViewModel @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val navigationManager: NavigationManager,
- private val userSessionRepository: UserSessionRepository
+ private val userSessionRepository: UserSessionRepository,
+ private val mediaDownloadManager: MediaDownloadManager
): ViewModel() {
private val _movie = MutableStateFlow(null)
val movie = _movie.asStateFlow()
+ private val _downloadState = MutableStateFlow(DownloadState.NotDownloaded)
+ val downloadState: StateFlow = _downloadState.asStateFlow()
+
fun onBack() {
navigationManager.pop()
}
@@ -49,6 +56,29 @@ class MovieScreenViewModel @Inject constructor(
"https://jellyfin.bbara.hu"
}
_movie.value = movieInfo.toUiModel(serverUrl)
+
+ launch {
+ mediaDownloadManager.observeDownloadState(movieId.toString()).collect {
+ _downloadState.value = it
+ }
+ }
+ }
+ }
+
+ fun onDownloadClick() {
+ val movieId = _movie.value?.id ?: return
+ viewModelScope.launch {
+ when (_downloadState.value) {
+ is DownloadState.NotDownloaded, is DownloadState.Failed -> {
+ mediaDownloadManager.downloadMovie(movieId)
+ }
+ is DownloadState.Downloading -> {
+ mediaDownloadManager.cancelDownload(movieId)
+ }
+ is DownloadState.Downloaded -> {
+ mediaDownloadManager.cancelDownload(movieId)
+ }
+ }
}
}
diff --git a/app/src/main/java/hu/bbara/purefin/common/ui/components/MediaActionButton.kt b/app/src/main/java/hu/bbara/purefin/common/ui/components/MediaActionButton.kt
index c6bc2fa..8bcf97d 100644
--- a/app/src/main/java/hu/bbara/purefin/common/ui/components/MediaActionButton.kt
+++ b/app/src/main/java/hu/bbara/purefin/common/ui/components/MediaActionButton.kt
@@ -21,13 +21,14 @@ fun MediaActionButton(
icon: ImageVector,
modifier: Modifier = Modifier,
height: Dp,
+ onClick: () -> Unit = {},
) {
Box(
modifier = modifier
.size(height)
.clip(CircleShape)
.background(backgroundColor.copy(alpha = 0.6f))
- .clickable { },
+ .clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(imageVector = icon, contentDescription = null, tint = iconColor)
diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/MovieDao.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/MovieDao.kt
index 7e79f42..ee07a4b 100644
--- a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/MovieDao.kt
+++ b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/MovieDao.kt
@@ -27,6 +27,9 @@ interface MovieDao {
@Query("UPDATE movies SET progress = :progress, watched = :watched WHERE id = :id")
suspend fun updateProgress(id: UUID, progress: Double?, watched: Boolean)
+ @Query("DELETE FROM movies WHERE id = :id")
+ suspend fun deleteById(id: UUID)
+
@Query("DELETE FROM movies")
suspend fun clear()
}
diff --git a/app/src/main/java/hu/bbara/purefin/download/DownloadModule.kt b/app/src/main/java/hu/bbara/purefin/download/DownloadModule.kt
new file mode 100644
index 0000000..f2884ab
--- /dev/null
+++ b/app/src/main/java/hu/bbara/purefin/download/DownloadModule.kt
@@ -0,0 +1,75 @@
+package hu.bbara.purefin.download
+
+import android.content.Context
+import androidx.annotation.OptIn
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.database.StandaloneDatabaseProvider
+import androidx.media3.datasource.cache.CacheDataSource
+import androidx.media3.datasource.cache.NoOpCacheEvictor
+import androidx.media3.datasource.cache.SimpleCache
+import androidx.media3.datasource.okhttp.OkHttpDataSource
+import androidx.media3.exoplayer.offline.DownloadManager
+import androidx.media3.exoplayer.offline.DownloadNotificationHelper
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import okhttp3.OkHttpClient
+import java.io.File
+import java.util.concurrent.Executors
+import javax.inject.Singleton
+
+@OptIn(UnstableApi::class)
+@Module
+@InstallIn(SingletonComponent::class)
+object DownloadModule {
+
+ private const val DOWNLOAD_CHANNEL_ID = "purefin_downloads"
+
+ @Provides
+ @Singleton
+ fun provideDownloadCache(@ApplicationContext context: Context): SimpleCache {
+ val downloadDir = File(context.getExternalFilesDir(null), "downloads")
+ return SimpleCache(downloadDir, NoOpCacheEvictor(), StandaloneDatabaseProvider(context))
+ }
+
+ @Provides
+ @Singleton
+ fun provideOkHttpDataSourceFactory(okHttpClient: OkHttpClient): OkHttpDataSource.Factory {
+ return OkHttpDataSource.Factory(okHttpClient)
+ }
+
+ @Provides
+ @Singleton
+ fun provideDownloadNotificationHelper(@ApplicationContext context: Context): DownloadNotificationHelper {
+ return DownloadNotificationHelper(context, DOWNLOAD_CHANNEL_ID)
+ }
+
+ @Provides
+ @Singleton
+ fun provideDownloadManager(
+ @ApplicationContext context: Context,
+ cache: SimpleCache,
+ okHttpDataSourceFactory: OkHttpDataSource.Factory
+ ): DownloadManager {
+ return DownloadManager(
+ context,
+ StandaloneDatabaseProvider(context),
+ cache,
+ okHttpDataSourceFactory,
+ Executors.newFixedThreadPool(2)
+ )
+ }
+
+ @Provides
+ @Singleton
+ fun provideCacheDataSourceFactory(
+ cache: SimpleCache,
+ okHttpDataSourceFactory: OkHttpDataSource.Factory
+ ): CacheDataSource.Factory {
+ return CacheDataSource.Factory()
+ .setCache(cache)
+ .setUpstreamDataSourceFactory(okHttpDataSourceFactory)
+ }
+}
diff --git a/app/src/main/java/hu/bbara/purefin/download/DownloadState.kt b/app/src/main/java/hu/bbara/purefin/download/DownloadState.kt
new file mode 100644
index 0000000..4ec0e7d
--- /dev/null
+++ b/app/src/main/java/hu/bbara/purefin/download/DownloadState.kt
@@ -0,0 +1,8 @@
+package hu.bbara.purefin.download
+
+sealed class DownloadState {
+ data object NotDownloaded : DownloadState()
+ data class Downloading(val progressPercent: Float) : DownloadState()
+ data object Downloaded : DownloadState()
+ data object Failed : DownloadState()
+}
diff --git a/app/src/main/java/hu/bbara/purefin/download/MediaDownloadManager.kt b/app/src/main/java/hu/bbara/purefin/download/MediaDownloadManager.kt
new file mode 100644
index 0000000..bd3709c
--- /dev/null
+++ b/app/src/main/java/hu/bbara/purefin/download/MediaDownloadManager.kt
@@ -0,0 +1,172 @@
+package hu.bbara.purefin.download
+
+import android.content.Context
+import android.util.Log
+import androidx.annotation.OptIn
+import androidx.core.net.toUri
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.offline.Download
+import androidx.media3.exoplayer.offline.DownloadManager
+import androidx.media3.exoplayer.offline.DownloadRequest
+import dagger.hilt.android.qualifiers.ApplicationContext
+import hu.bbara.purefin.client.JellyfinApiClient
+import hu.bbara.purefin.data.local.room.OfflineRoomMediaLocalDataSource
+import hu.bbara.purefin.data.local.room.OfflineDatabase
+import hu.bbara.purefin.data.local.room.dao.MovieDao
+import hu.bbara.purefin.data.model.Movie
+import hu.bbara.purefin.image.JellyfinImageHelper
+import hu.bbara.purefin.session.UserSessionRepository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withContext
+import org.jellyfin.sdk.model.api.ImageType
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@OptIn(UnstableApi::class)
+@Singleton
+class MediaDownloadManager @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val downloadManager: DownloadManager,
+ private val jellyfinApiClient: JellyfinApiClient,
+ @OfflineDatabase private val offlineDataSource: OfflineRoomMediaLocalDataSource,
+ @OfflineDatabase private val movieDao: MovieDao,
+ private val userSessionRepository: UserSessionRepository
+) {
+
+ private val stateFlows = ConcurrentHashMap>()
+
+ init {
+ downloadManager.resumeDownloads()
+
+ downloadManager.addListener(object : DownloadManager.Listener {
+ override fun onDownloadChanged(
+ manager: DownloadManager,
+ download: Download,
+ finalException: Exception?
+ ) {
+ val contentId = download.request.id
+ val state = download.toDownloadState()
+ Log.d(TAG, "Download changed: $contentId -> $state (${download.percentDownloaded}%)")
+ if (finalException != null) {
+ Log.e(TAG, "Download exception for $contentId", finalException)
+ }
+ getOrCreateStateFlow(contentId).value = state
+ }
+
+ override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
+ val contentId = download.request.id
+ Log.d(TAG, "Download removed: $contentId")
+ getOrCreateStateFlow(contentId).value = DownloadState.NotDownloaded
+ }
+ })
+ }
+
+ fun observeDownloadState(contentId: String): StateFlow {
+ val flow = getOrCreateStateFlow(contentId)
+ // Initialize from current download index
+ val download = downloadManager.downloadIndex.getDownload(contentId)
+ flow.value = download?.toDownloadState() ?: DownloadState.NotDownloaded
+ return flow
+ }
+
+ fun isDownloaded(contentId: String): Boolean {
+ return downloadManager.downloadIndex.getDownload(contentId)?.state == Download.STATE_COMPLETED
+ }
+
+ suspend fun downloadMovie(movieId: UUID) {
+ withContext(Dispatchers.IO) {
+ try {
+ val sources = jellyfinApiClient.getMediaSources(movieId)
+ val source = sources.firstOrNull() ?: run {
+ Log.e(TAG, "No media sources for $movieId")
+ return@withContext
+ }
+
+ val url = jellyfinApiClient.getMediaPlaybackUrl(movieId, source) ?: run {
+ Log.e(TAG, "No playback URL for $movieId")
+ return@withContext
+ }
+
+ val itemInfo = jellyfinApiClient.getItemInfo(movieId) ?: run {
+ Log.e(TAG, "No item info for $movieId")
+ return@withContext
+ }
+
+ val serverUrl = userSessionRepository.serverUrl.first().trim()
+ val movie = Movie(
+ id = itemInfo.id,
+ libraryId = itemInfo.parentId ?: UUID.randomUUID(),
+ title = itemInfo.name ?: "Unknown title",
+ progress = itemInfo.userData?.playedPercentage,
+ watched = itemInfo.userData?.played ?: false,
+ year = itemInfo.productionYear?.toString()
+ ?: itemInfo.premiereDate?.year?.toString().orEmpty(),
+ rating = itemInfo.officialRating ?: "NR",
+ runtime = formatRuntime(itemInfo.runTimeTicks),
+ synopsis = itemInfo.overview ?: "No synopsis available",
+ format = itemInfo.container?.uppercase() ?: "VIDEO",
+ heroImageUrl = JellyfinImageHelper.toImageUrl(
+ url = serverUrl,
+ itemId = itemInfo.id,
+ type = ImageType.PRIMARY
+ ),
+ subtitles = "ENG",
+ audioTrack = "ENG",
+ cast = emptyList()
+ )
+
+ offlineDataSource.saveMovies(listOf(movie))
+
+ Log.d(TAG, "Starting download for '${movie.title}' from: $url")
+ val request = DownloadRequest.Builder(movieId.toString(), url.toUri()).build()
+ PurefinDownloadService.sendAddDownload(context, request)
+ Log.d(TAG, "Download request sent for $movieId")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start download for $movieId", e)
+ getOrCreateStateFlow(movieId.toString()).value = DownloadState.Failed
+ }
+ }
+ }
+
+ suspend fun cancelDownload(movieId: UUID) {
+ withContext(Dispatchers.IO) {
+ PurefinDownloadService.sendRemoveDownload(context, movieId.toString())
+ try {
+ movieDao.deleteById(movieId)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to remove movie from offline DB", e)
+ }
+ }
+ }
+
+ private fun getOrCreateStateFlow(contentId: String): MutableStateFlow {
+ return stateFlows.getOrPut(contentId) { MutableStateFlow(DownloadState.NotDownloaded) }
+ }
+
+ private fun Download.toDownloadState(): DownloadState = when (state) {
+ Download.STATE_COMPLETED -> DownloadState.Downloaded
+ Download.STATE_DOWNLOADING -> DownloadState.Downloading(percentDownloaded)
+ Download.STATE_QUEUED, Download.STATE_RESTARTING -> DownloadState.Downloading(0f)
+ Download.STATE_FAILED -> DownloadState.Failed
+ Download.STATE_REMOVING -> DownloadState.NotDownloaded
+ Download.STATE_STOPPED -> DownloadState.NotDownloaded
+ else -> DownloadState.NotDownloaded
+ }
+
+ private fun formatRuntime(ticks: Long?): String {
+ if (ticks == null || ticks <= 0) return "—"
+ val totalSeconds = ticks / 10_000_000
+ val hours = java.util.concurrent.TimeUnit.SECONDS.toHours(totalSeconds)
+ val minutes = java.util.concurrent.TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
+ return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
+ }
+
+ companion object {
+ private const val TAG = "MediaDownloadManager"
+ }
+}
diff --git a/app/src/main/java/hu/bbara/purefin/download/PurefinDownloadService.kt b/app/src/main/java/hu/bbara/purefin/download/PurefinDownloadService.kt
new file mode 100644
index 0000000..1976a95
--- /dev/null
+++ b/app/src/main/java/hu/bbara/purefin/download/PurefinDownloadService.kt
@@ -0,0 +1,140 @@
+package hu.bbara.purefin.download
+
+import android.app.Notification
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.annotation.OptIn
+import androidx.core.app.NotificationCompat
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.offline.Download
+import androidx.media3.exoplayer.offline.DownloadManager
+import androidx.media3.exoplayer.offline.DownloadNotificationHelper
+import androidx.media3.exoplayer.offline.DownloadRequest
+import androidx.media3.exoplayer.offline.DownloadService
+import androidx.media3.exoplayer.scheduler.Scheduler
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import hu.bbara.purefin.R
+
+@OptIn(UnstableApi::class)
+class PurefinDownloadService : DownloadService(
+ FOREGROUND_NOTIFICATION_ID,
+ DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
+ DOWNLOAD_CHANNEL_ID,
+ R.string.download_channel_name,
+ R.string.download_channel_description
+) {
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface DownloadServiceEntryPoint {
+ fun downloadManager(): DownloadManager
+ fun downloadNotificationHelper(): DownloadNotificationHelper
+ }
+
+ private val entryPoint: DownloadServiceEntryPoint by lazy {
+ EntryPointAccessors.fromApplication(applicationContext, DownloadServiceEntryPoint::class.java)
+ }
+
+ private var lastBytesDownloaded: Long = 0L
+ private var lastUpdateTimeMs: Long = 0L
+
+ override fun getDownloadManager(): DownloadManager = entryPoint.downloadManager()
+
+ override fun getForegroundNotification(
+ downloads: MutableList,
+ notMetRequirements: Int
+ ): Notification {
+ val activeDownloads = downloads.filter { it.state == Download.STATE_DOWNLOADING }
+
+ if (activeDownloads.isEmpty()) {
+ return entryPoint.downloadNotificationHelper().buildProgressNotification(
+ this,
+ R.drawable.ic_launcher_foreground,
+ null,
+ null,
+ downloads,
+ notMetRequirements
+ )
+ }
+
+ val totalBytes = activeDownloads.sumOf { it.bytesDownloaded }
+ val now = System.currentTimeMillis()
+
+ val speedText = if (lastUpdateTimeMs > 0L) {
+ val elapsed = (now - lastUpdateTimeMs).coerceAtLeast(1)
+ val bytesPerSec = (totalBytes - lastBytesDownloaded) * 1000L / elapsed
+ formatSpeed(bytesPerSec)
+ } else {
+ ""
+ }
+
+ lastBytesDownloaded = totalBytes
+ lastUpdateTimeMs = now
+
+ val percent = if (activeDownloads.size == 1) {
+ activeDownloads[0].percentDownloaded
+ } else {
+ activeDownloads.map { it.percentDownloaded }.average().toFloat()
+ }
+
+ val title = if (activeDownloads.size == 1) {
+ "Downloading"
+ } else {
+ "Downloading ${activeDownloads.size} files"
+ }
+
+ val contentText = buildString {
+ append("${percent.toInt()}%")
+ if (speedText.isNotEmpty()) {
+ append(" · $speedText")
+ }
+ }
+
+ return NotificationCompat.Builder(this, DOWNLOAD_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle(title)
+ .setContentText(contentText)
+ .setProgress(100, percent.toInt(), false)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .build()
+ }
+
+ override fun getScheduler(): Scheduler? = null
+
+ private fun formatSpeed(bytesPerSec: Long): String {
+ if (bytesPerSec <= 0) return ""
+ return when {
+ bytesPerSec >= 1_000_000 -> String.format("%.1f MB/s", bytesPerSec / 1_000_000.0)
+ bytesPerSec >= 1_000 -> String.format("%.0f KB/s", bytesPerSec / 1_000.0)
+ else -> "$bytesPerSec B/s"
+ }
+ }
+
+ companion object {
+ private const val FOREGROUND_NOTIFICATION_ID = 1
+ private const val DOWNLOAD_CHANNEL_ID = "purefin_downloads"
+
+ fun sendAddDownload(context: Context, request: DownloadRequest) {
+ sendAddDownload(
+ context,
+ PurefinDownloadService::class.java,
+ request,
+ false
+ )
+ }
+
+ fun sendRemoveDownload(context: Context, contentId: String) {
+ sendRemoveDownload(
+ context,
+ PurefinDownloadService::class.java,
+ contentId,
+ false
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/hu/bbara/purefin/player/module/VideoPlayerModule.kt b/app/src/main/java/hu/bbara/purefin/player/module/VideoPlayerModule.kt
index 4a342ac..613d256 100644
--- a/app/src/main/java/hu/bbara/purefin/player/module/VideoPlayerModule.kt
+++ b/app/src/main/java/hu/bbara/purefin/player/module/VideoPlayerModule.kt
@@ -6,10 +6,12 @@ import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.SeekParameters
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import dagger.Module
import dagger.Provides
@@ -24,7 +26,7 @@ object VideoPlayerModule {
@OptIn(UnstableApi::class)
@Provides
@ViewModelScoped
- fun provideVideoPlayer(application: Application): Player {
+ fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player {
val trackSelector = DefaultTrackSelector(application)
val audioAttributes =
AudioAttributes.Builder()
@@ -57,7 +59,10 @@ object VideoPlayerModule {
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
.setEnableDecoderFallback(true)
+ val mediaSourceFactory = DefaultMediaSourceFactory(cacheDataSourceFactory)
+
return ExoPlayer.Builder(application, renderersFactory)
+ .setMediaSourceFactory(mediaSourceFactory)
.setTrackSelector(trackSelector)
.setPauseAtEndOfMediaItems(true)
.setLoadControl(loadControl)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b6076e1..4ac9aed 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,5 @@
Purefin
+ Downloads
+ Media download progress
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e637c7f..b664bce 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -55,6 +55,7 @@ medi3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", versio
medi3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3"}
medi3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3"}
medi3-ffmpeg-decoder = { group = "org.jellyfin.media3", name = "media3-ffmpeg-decoder", version.ref = "media3FfmpegDecoder"}
+media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }