From 2d278bd3481b0c1be3ebf31ae673712a8d5741c3 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Tue, 3 Mar 2026 21:50:12 +0100 Subject: [PATCH] feat: add batch download for seasons and entire series Wire up the existing download button on the Series screen to download all episodes, and add a per-season download button next to the season tabs. Episode metadata is fetched in parallel for faster queuing. Co-Authored-By: Claude Opus 4.6 --- .../app/content/series/SeriesComponents.kt | 37 +++++++++-- .../app/content/series/SeriesScreen.kt | 22 ++++++- .../feature/download/MediaDownloadManager.kt | 10 +++ .../shared/content/series/SeriesViewModel.kt | 65 +++++++++++++++++++ 4 files changed, 129 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt index a09f412..6f38403 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt @@ -30,7 +30,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.material.icons.outlined.PlayCircle import androidx.compose.material3.Icon @@ -59,6 +61,7 @@ import hu.bbara.purefin.common.ui.components.MediaResumeButton import hu.bbara.purefin.player.PlayerActivity import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.common.ui.components.WatchStateIndicator +import hu.bbara.purefin.feature.download.DownloadState import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Season @@ -110,7 +113,12 @@ internal fun SeriesMetaChips(series: Series) { } @Composable -internal fun SeriesActionButtons(nextUpEpisode: Episode?, modifier: Modifier = Modifier) { +internal fun SeriesActionButtons( + nextUpEpisode: Episode?, + downloadState: DownloadState, + onDownloadClick: () -> Unit, + modifier: Modifier = Modifier, +) { val context = LocalContext.current val episodeId = nextUpEpisode?.id val playAction = remember(episodeId) { @@ -142,8 +150,14 @@ internal fun SeriesActionButtons(nextUpEpisode: Episode?, modifier: Modifier = M MediaActionButton( backgroundColor = MaterialTheme.colorScheme.secondary, iconColor = MaterialTheme.colorScheme.onSecondary, - icon = Icons.Outlined.Download, - height = 32.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 = 32.dp, + onClick = onDownloadClick ) } } @@ -152,6 +166,8 @@ internal fun SeriesActionButtons(nextUpEpisode: Episode?, modifier: Modifier = M internal fun SeasonTabs( seasons: List, selectedSeason: Season?, + seasonDownloadState: DownloadState, + onSeasonDownloadClick: () -> Unit, modifier: Modifier = Modifier, onSelect: (Season) -> Unit ) { @@ -159,7 +175,8 @@ internal fun SeasonTabs( modifier = modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(20.dp) + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalAlignment = Alignment.CenterVertically ) { seasons.forEach { season -> SeasonTab( @@ -168,6 +185,18 @@ internal fun SeasonTabs( modifier = Modifier.clickable { onSelect(season) } ) } + MediaActionButton( + backgroundColor = MaterialTheme.colorScheme.secondary, + iconColor = MaterialTheme.colorScheme.onSecondary, + icon = when (seasonDownloadState) { + 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 = 28.dp, + onClick = onSeasonDownloadClick + ) } } diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt index 4b2b47c..1ec5116 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -27,6 +28,7 @@ import hu.bbara.purefin.common.ui.components.MediaHero import hu.bbara.purefin.core.data.navigation.SeriesDto import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Series +import hu.bbara.purefin.feature.download.DownloadState import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel @Composable @@ -43,8 +45,12 @@ fun SeriesScreen( val seriesData = series.value if (seriesData != null && seriesData.seasons.isNotEmpty()) { + LaunchedEffect(seriesData) { + viewModel.observeSeriesDownloadState(seriesData) + } SeriesScreenInternal( series = seriesData, + viewModel = viewModel, onBack = viewModel::onBack, modifier = modifier ) @@ -56,6 +62,7 @@ fun SeriesScreen( @Composable private fun SeriesScreenInternal( series: Series, + viewModel: SeriesViewModel, onBack: () -> Unit, modifier: Modifier = Modifier, ) { @@ -80,6 +87,13 @@ private fun SeriesScreenInternal( } ?: series.seasons.firstOrNull()?.episodes?.firstOrNull() } + val seriesDownloadState by viewModel.seriesDownloadState.collectAsState() + val seasonDownloadState by viewModel.seasonDownloadState.collectAsState() + + LaunchedEffect(selectedSeason.value) { + viewModel.observeSeasonDownloadState(selectedSeason.value.episodes) + } + Scaffold( modifier = modifier, containerColor = MaterialTheme.colorScheme.background, @@ -117,7 +131,11 @@ private fun SeriesScreenInternal( Spacer(modifier = Modifier.height(16.dp)) SeriesMetaChips(series = series) Spacer(modifier = Modifier.height(24.dp)) - SeriesActionButtons(nextUpEpisode = nextUpEpisode) + SeriesActionButtons( + nextUpEpisode = nextUpEpisode, + downloadState = seriesDownloadState, + onDownloadClick = { viewModel.downloadSeries(series) } + ) Spacer(modifier = Modifier.height(24.dp)) MediaSynopsis( synopsis = series.synopsis, @@ -130,6 +148,8 @@ private fun SeriesScreenInternal( SeasonTabs( seasons = series.seasons, selectedSeason = selectedSeason.value, + seasonDownloadState = seasonDownloadState, + onSeasonDownloadClick = { viewModel.downloadSeason(selectedSeason.value.episodes) }, onSelect = { selectedSeason.value = it } ) EpisodeCarousel( diff --git a/feature/download/src/main/java/hu/bbara/purefin/feature/download/MediaDownloadManager.kt b/feature/download/src/main/java/hu/bbara/purefin/feature/download/MediaDownloadManager.kt index a68f9b5..073e379 100644 --- a/feature/download/src/main/java/hu/bbara/purefin/feature/download/MediaDownloadManager.kt +++ b/feature/download/src/main/java/hu/bbara/purefin/feature/download/MediaDownloadManager.kt @@ -20,6 +20,8 @@ import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Series import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -240,6 +242,14 @@ class MediaDownloadManager @Inject constructor( } } + suspend fun downloadEpisodes(episodeIds: List) { + coroutineScope { + for (episodeId in episodeIds) { + launch { downloadEpisode(episodeId) } + } + } + } + suspend fun cancelEpisodeDownload(episodeId: UUID) { withContext(Dispatchers.IO) { PurefinDownloadService.sendRemoveDownload(context, episodeId.toString()) diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/series/SeriesViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/series/SeriesViewModel.kt index a36a2a8..c23bf40 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/series/SeriesViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/series/SeriesViewModel.kt @@ -7,14 +7,19 @@ import hu.bbara.purefin.core.data.MediaRepository import hu.bbara.purefin.core.data.navigation.EpisodeDto import hu.bbara.purefin.core.data.navigation.NavigationManager import hu.bbara.purefin.core.data.navigation.Route +import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Series +import hu.bbara.purefin.feature.download.DownloadState +import hu.bbara.purefin.feature.download.MediaDownloadManager import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import org.jellyfin.sdk.model.UUID import javax.inject.Inject @@ -22,6 +27,7 @@ import javax.inject.Inject class SeriesViewModel @Inject constructor( private val mediaRepository: MediaRepository, private val navigationManager: NavigationManager, + private val mediaDownloadManager: MediaDownloadManager, ) : ViewModel() { private val _seriesId = MutableStateFlow(null) @@ -33,6 +39,65 @@ class SeriesViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + private val _seriesDownloadState = MutableStateFlow(DownloadState.NotDownloaded) + val seriesDownloadState: StateFlow = _seriesDownloadState + + private val _seasonDownloadState = MutableStateFlow(DownloadState.NotDownloaded) + val seasonDownloadState: StateFlow = _seasonDownloadState + + fun observeSeasonDownloadState(episodes: List) { + viewModelScope.launch { + if (episodes.isEmpty()) { + _seasonDownloadState.value = DownloadState.NotDownloaded + return@launch + } + val flows = episodes.map { mediaDownloadManager.observeDownloadState(it.id.toString()) } + combine(flows) { states -> aggregateDownloadStates(states.toList()) } + .collect { _seasonDownloadState.value = it } + } + } + + fun observeSeriesDownloadState(series: Series) { + viewModelScope.launch { + val allEpisodes = series.seasons.flatMap { it.episodes } + if (allEpisodes.isEmpty()) { + _seriesDownloadState.value = DownloadState.NotDownloaded + return@launch + } + val flows = allEpisodes.map { mediaDownloadManager.observeDownloadState(it.id.toString()) } + combine(flows) { states -> aggregateDownloadStates(states.toList()) } + .collect { _seriesDownloadState.value = it } + } + } + + fun downloadSeason(episodes: List) { + viewModelScope.launch { + mediaDownloadManager.downloadEpisodes(episodes.map { it.id }) + } + } + + fun downloadSeries(series: Series) { + viewModelScope.launch { + val allEpisodeIds = series.seasons.flatMap { season -> + season.episodes.map { it.id } + } + mediaDownloadManager.downloadEpisodes(allEpisodeIds) + } + } + + private fun aggregateDownloadStates(states: List): DownloadState { + if (states.isEmpty()) return DownloadState.NotDownloaded + if (states.all { it is DownloadState.Downloaded }) return DownloadState.Downloaded + if (states.any { it is DownloadState.Downloading }) { + val avg = states.filterIsInstance() + .map { it.progressPercent } + .average() + .toFloat() + return DownloadState.Downloading(avg) + } + return DownloadState.NotDownloaded + } + fun onSelectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) { navigationManager.navigate(Route.EpisodeRoute( EpisodeDto(