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" }