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(

View File

@@ -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<UUID>) {
coroutineScope {
for (episodeId in episodeIds) {
launch { downloadEpisode(episodeId) }
}
}
}
suspend fun cancelEpisodeDownload(episodeId: UUID) {
withContext(Dispatchers.IO) {
PurefinDownloadService.sendRemoveDownload(context, episodeId.toString())

View File

@@ -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<UUID?>(null)
@@ -33,6 +39,65 @@ class SeriesViewModel @Inject constructor(
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val _seriesDownloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
val seriesDownloadState: StateFlow<DownloadState> = _seriesDownloadState
private val _seasonDownloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
val seasonDownloadState: StateFlow<DownloadState> = _seasonDownloadState
fun observeSeasonDownloadState(episodes: List<Episode>) {
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<Episode>) {
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>): 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<DownloadState.Downloading>()
.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(