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.Add
import androidx.compose.material.icons.outlined.ArrowBack import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast 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.Download
import androidx.compose.material.icons.outlined.DownloadDone
import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material3.Icon 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.player.PlayerActivity
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.common.ui.components.WatchStateIndicator 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.CastMember
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Season
@@ -110,7 +113,12 @@ internal fun SeriesMetaChips(series: Series) {
} }
@Composable @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 context = LocalContext.current
val episodeId = nextUpEpisode?.id val episodeId = nextUpEpisode?.id
val playAction = remember(episodeId) { val playAction = remember(episodeId) {
@@ -142,8 +150,14 @@ internal fun SeriesActionButtons(nextUpEpisode: Episode?, modifier: Modifier = M
MediaActionButton( MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.secondary, backgroundColor = MaterialTheme.colorScheme.secondary,
iconColor = MaterialTheme.colorScheme.onSecondary, iconColor = MaterialTheme.colorScheme.onSecondary,
icon = Icons.Outlined.Download, icon = when (downloadState) {
height = 32.dp 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( internal fun SeasonTabs(
seasons: List<Season>, seasons: List<Season>,
selectedSeason: Season?, selectedSeason: Season?,
seasonDownloadState: DownloadState,
onSeasonDownloadClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onSelect: (Season) -> Unit onSelect: (Season) -> Unit
) { ) {
@@ -159,7 +175,8 @@ internal fun SeasonTabs(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.horizontalScroll(rememberScrollState()), .horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(20.dp) horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
seasons.forEach { season -> seasons.forEach { season ->
SeasonTab( SeasonTab(
@@ -168,6 +185,18 @@ internal fun SeasonTabs(
modifier = Modifier.clickable { onSelect(season) } 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.data.navigation.SeriesDto
import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
@Composable @Composable
@@ -43,8 +45,12 @@ fun SeriesScreen(
val seriesData = series.value val seriesData = series.value
if (seriesData != null && seriesData.seasons.isNotEmpty()) { if (seriesData != null && seriesData.seasons.isNotEmpty()) {
LaunchedEffect(seriesData) {
viewModel.observeSeriesDownloadState(seriesData)
}
SeriesScreenInternal( SeriesScreenInternal(
series = seriesData, series = seriesData,
viewModel = viewModel,
onBack = viewModel::onBack, onBack = viewModel::onBack,
modifier = modifier modifier = modifier
) )
@@ -56,6 +62,7 @@ fun SeriesScreen(
@Composable @Composable
private fun SeriesScreenInternal( private fun SeriesScreenInternal(
series: Series, series: Series,
viewModel: SeriesViewModel,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -80,6 +87,13 @@ private fun SeriesScreenInternal(
} ?: series.seasons.firstOrNull()?.episodes?.firstOrNull() } ?: 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( Scaffold(
modifier = modifier, modifier = modifier,
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
@@ -117,7 +131,11 @@ private fun SeriesScreenInternal(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
SeriesMetaChips(series = series) SeriesMetaChips(series = series)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
SeriesActionButtons(nextUpEpisode = nextUpEpisode) SeriesActionButtons(
nextUpEpisode = nextUpEpisode,
downloadState = seriesDownloadState,
onDownloadClick = { viewModel.downloadSeries(series) }
)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
MediaSynopsis( MediaSynopsis(
synopsis = series.synopsis, synopsis = series.synopsis,
@@ -130,6 +148,8 @@ private fun SeriesScreenInternal(
SeasonTabs( SeasonTabs(
seasons = series.seasons, seasons = series.seasons,
selectedSeason = selectedSeason.value, selectedSeason = selectedSeason.value,
seasonDownloadState = seasonDownloadState,
onSeasonDownloadClick = { viewModel.downloadSeason(selectedSeason.value.episodes) },
onSelect = { selectedSeason.value = it } onSelect = { selectedSeason.value = it }
) )
EpisodeCarousel( 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.Season
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow 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) { suspend fun cancelEpisodeDownload(episodeId: UUID) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
PurefinDownloadService.sendRemoveDownload(context, episodeId.toString()) 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.EpisodeDto
import hu.bbara.purefin.core.data.navigation.NavigationManager import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route 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.core.model.Series
import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.feature.download.MediaDownloadManager
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -22,6 +27,7 @@ import javax.inject.Inject
class SeriesViewModel @Inject constructor( class SeriesViewModel @Inject constructor(
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadManager,
) : ViewModel() { ) : ViewModel() {
private val _seriesId = MutableStateFlow<UUID?>(null) private val _seriesId = MutableStateFlow<UUID?>(null)
@@ -33,6 +39,65 @@ class SeriesViewModel @Inject constructor(
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) .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) { fun onSelectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
navigationManager.navigate(Route.EpisodeRoute( navigationManager.navigate(Route.EpisodeRoute(
EpisodeDto( EpisodeDto(