mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10: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.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)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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
|
||||
android:name=".PurefinApplication"
|
||||
android:allowBackup="true"
|
||||
@@ -28,6 +32,10 @@
|
||||
android:screenOrientation="landscape"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Purefin" />
|
||||
<service
|
||||
android:name=".download.PurefinDownloadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<MovieUiModel?>(null)
|
||||
val movie = _movie.asStateFlow()
|
||||
|
||||
private val _downloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
|
||||
val downloadState: StateFlow<DownloadState> = _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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.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)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">Purefin</string>
|
||||
<string name="download_channel_name">Downloads</string>
|
||||
<string name="download_channel_description">Media download progress</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user