mirror of
https://github.com/bbara04/Purefin.git
synced 2026-04-01 01:30:08 +02:00
feat: add movie download support using Media3 DownloadService
Implement media downloading with foreground notification showing progress percentage and speed. Uses Media3's DownloadManager + SimpleCache with OkHttp datasource for authenticated Jellyfin downloads. Downloaded movies are saved to the offline database and the player reads from cache automatically via CacheDataSource.
This commit is contained in:
@@ -72,6 +72,7 @@ dependencies {
|
|||||||
implementation(libs.medi3.exoplayer)
|
implementation(libs.medi3.exoplayer)
|
||||||
implementation(libs.medi3.ui.compose)
|
implementation(libs.medi3.ui.compose)
|
||||||
implementation(libs.medi3.ffmpeg.decoder)
|
implementation(libs.medi3.ffmpeg.decoder)
|
||||||
|
implementation(libs.media3.datasource.okhttp)
|
||||||
implementation(libs.androidx.navigation3.runtime)
|
implementation(libs.androidx.navigation3.runtime)
|
||||||
implementation(libs.androidx.navigation3.ui)
|
implementation(libs.androidx.navigation3.ui)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".PurefinApplication"
|
android:name=".PurefinApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -28,6 +32,10 @@
|
|||||||
android:screenOrientation="landscape"
|
android:screenOrientation="landscape"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:theme="@style/Theme.Purefin" />
|
android:theme="@style/Theme.Purefin" />
|
||||||
|
<service
|
||||||
|
android:name=".download.PurefinDownloadService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.outlined.Add
|
import androidx.compose.material.icons.outlined.Add
|
||||||
import androidx.compose.material.icons.outlined.ArrowBack
|
import androidx.compose.material.icons.outlined.ArrowBack
|
||||||
import androidx.compose.material.icons.outlined.Cast
|
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.Download
|
||||||
|
import androidx.compose.material.icons.outlined.DownloadDone
|
||||||
import androidx.compose.material.icons.outlined.MoreVert
|
import androidx.compose.material.icons.outlined.MoreVert
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
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.MediaActionButton
|
||||||
import hu.bbara.purefin.common.ui.components.MediaPlayButton
|
import hu.bbara.purefin.common.ui.components.MediaPlayButton
|
||||||
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
|
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
|
||||||
|
import hu.bbara.purefin.download.DownloadState
|
||||||
import hu.bbara.purefin.player.PlayerActivity
|
import hu.bbara.purefin.player.PlayerActivity
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -68,6 +71,8 @@ internal fun MovieTopBar(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun MovieDetails(
|
internal fun MovieDetails(
|
||||||
movie: MovieUiModel,
|
movie: MovieUiModel,
|
||||||
|
downloadState: DownloadState,
|
||||||
|
onDownloadClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
@@ -136,8 +141,14 @@ internal fun MovieDetails(
|
|||||||
MediaActionButton(
|
MediaActionButton(
|
||||||
backgroundColor = MaterialTheme.colorScheme.secondary,
|
backgroundColor = MaterialTheme.colorScheme.secondary,
|
||||||
iconColor = MaterialTheme.colorScheme.onSecondary,
|
iconColor = MaterialTheme.colorScheme.onSecondary,
|
||||||
icon = Icons.Outlined.Download,
|
icon = when (downloadState) {
|
||||||
height = 48.dp
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package hu.bbara.purefin.app.content.movie
|
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.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.app.content.ContentMockData
|
||||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||||
import hu.bbara.purefin.common.ui.components.MediaHero
|
import hu.bbara.purefin.common.ui.components.MediaHero
|
||||||
|
import hu.bbara.purefin.download.DownloadState
|
||||||
import hu.bbara.purefin.navigation.MovieDto
|
import hu.bbara.purefin.navigation.MovieDto
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -29,10 +34,30 @@ fun MovieScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val movieItem = viewModel.movie.collectAsState()
|
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) {
|
if (movieItem.value != null) {
|
||||||
MovieScreenInternal(
|
MovieScreenInternal(
|
||||||
movie = movieItem.value!!,
|
movie = movieItem.value!!,
|
||||||
|
downloadState = downloadState.value,
|
||||||
|
onDownloadClick = onDownloadClick,
|
||||||
onBack = viewModel::onBack,
|
onBack = viewModel::onBack,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
@@ -44,6 +69,8 @@ fun MovieScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MovieScreenInternal(
|
private fun MovieScreenInternal(
|
||||||
movie: MovieUiModel,
|
movie: MovieUiModel,
|
||||||
|
downloadState: DownloadState = DownloadState.NotDownloaded,
|
||||||
|
onDownloadClick: () -> Unit = {},
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -70,6 +97,8 @@ private fun MovieScreenInternal(
|
|||||||
)
|
)
|
||||||
MovieDetails(
|
MovieDetails(
|
||||||
movie = movie,
|
movie = movie,
|
||||||
|
downloadState = downloadState,
|
||||||
|
onDownloadClick = onDownloadClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import hu.bbara.purefin.client.JellyfinApiClient
|
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.image.JellyfinImageHelper
|
||||||
import hu.bbara.purefin.navigation.NavigationManager
|
import hu.bbara.purefin.navigation.NavigationManager
|
||||||
import hu.bbara.purefin.navigation.Route
|
import hu.bbara.purefin.navigation.Route
|
||||||
import hu.bbara.purefin.session.UserSessionRepository
|
import hu.bbara.purefin.session.UserSessionRepository
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -23,12 +26,16 @@ import javax.inject.Inject
|
|||||||
class MovieScreenViewModel @Inject constructor(
|
class MovieScreenViewModel @Inject constructor(
|
||||||
private val jellyfinApiClient: JellyfinApiClient,
|
private val jellyfinApiClient: JellyfinApiClient,
|
||||||
private val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
private val userSessionRepository: UserSessionRepository
|
private val userSessionRepository: UserSessionRepository,
|
||||||
|
private val mediaDownloadManager: MediaDownloadManager
|
||||||
): ViewModel() {
|
): ViewModel() {
|
||||||
|
|
||||||
private val _movie = MutableStateFlow<MovieUiModel?>(null)
|
private val _movie = MutableStateFlow<MovieUiModel?>(null)
|
||||||
val movie = _movie.asStateFlow()
|
val movie = _movie.asStateFlow()
|
||||||
|
|
||||||
|
private val _downloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
|
||||||
|
val downloadState: StateFlow<DownloadState> = _downloadState.asStateFlow()
|
||||||
|
|
||||||
fun onBack() {
|
fun onBack() {
|
||||||
navigationManager.pop()
|
navigationManager.pop()
|
||||||
}
|
}
|
||||||
@@ -49,6 +56,29 @@ class MovieScreenViewModel @Inject constructor(
|
|||||||
"https://jellyfin.bbara.hu"
|
"https://jellyfin.bbara.hu"
|
||||||
}
|
}
|
||||||
_movie.value = movieInfo.toUiModel(serverUrl)
|
_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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ fun MediaActionButton(
|
|||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
height: Dp,
|
height: Dp,
|
||||||
|
onClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(height)
|
.size(height)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(backgroundColor.copy(alpha = 0.6f))
|
.background(backgroundColor.copy(alpha = 0.6f))
|
||||||
.clickable { },
|
.clickable { onClick() },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(imageVector = icon, contentDescription = null, tint = iconColor)
|
Icon(imageVector = icon, contentDescription = null, tint = iconColor)
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ interface MovieDao {
|
|||||||
@Query("UPDATE movies SET progress = :progress, watched = :watched WHERE id = :id")
|
@Query("UPDATE movies SET progress = :progress, watched = :watched WHERE id = :id")
|
||||||
suspend fun updateProgress(id: UUID, progress: Double?, watched: Boolean)
|
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")
|
@Query("DELETE FROM movies")
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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<String, MutableStateFlow<DownloadState>>()
|
||||||
|
|
||||||
|
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<DownloadState> {
|
||||||
|
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<DownloadState> {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Download>,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,12 @@ import androidx.media3.common.AudioAttributes
|
|||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.datasource.cache.CacheDataSource
|
||||||
import androidx.media3.exoplayer.DefaultLoadControl
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.SeekParameters
|
import androidx.media3.exoplayer.SeekParameters
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@@ -24,7 +26,7 @@ object VideoPlayerModule {
|
|||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@Provides
|
@Provides
|
||||||
@ViewModelScoped
|
@ViewModelScoped
|
||||||
fun provideVideoPlayer(application: Application): Player {
|
fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player {
|
||||||
val trackSelector = DefaultTrackSelector(application)
|
val trackSelector = DefaultTrackSelector(application)
|
||||||
val audioAttributes =
|
val audioAttributes =
|
||||||
AudioAttributes.Builder()
|
AudioAttributes.Builder()
|
||||||
@@ -57,7 +59,10 @@ object VideoPlayerModule {
|
|||||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||||
.setEnableDecoderFallback(true)
|
.setEnableDecoderFallback(true)
|
||||||
|
|
||||||
|
val mediaSourceFactory = DefaultMediaSourceFactory(cacheDataSourceFactory)
|
||||||
|
|
||||||
return ExoPlayer.Builder(application, renderersFactory)
|
return ExoPlayer.Builder(application, renderersFactory)
|
||||||
|
.setMediaSourceFactory(mediaSourceFactory)
|
||||||
.setTrackSelector(trackSelector)
|
.setTrackSelector(trackSelector)
|
||||||
.setPauseAtEndOfMediaItems(true)
|
.setPauseAtEndOfMediaItems(true)
|
||||||
.setLoadControl(loadControl)
|
.setLoadControl(loadControl)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Purefin</string>
|
<string name="app_name">Purefin</string>
|
||||||
|
<string name="download_channel_name">Downloads</string>
|
||||||
|
<string name="download_channel_description">Media download progress</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -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 = { group = "androidx.media3", name = "media3-ui", version.ref = "media3"}
|
||||||
medi3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose-material3", 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"}
|
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-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
|
||||||
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", 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" }
|
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||||
|
|||||||
Reference in New Issue
Block a user