mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
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:
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user