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 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 21:50:12 +01:00
parent cf078c760e
commit 2d278bd348
4 changed files with 129 additions and 5 deletions

View File

@@ -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<Season>,
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
)
}
}

View File

@@ -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(