feat: refactor SeriesScreen and ViewModel for improved season episode loading and download handling

This commit is contained in:
2026-05-08 18:42:52 +02:00
parent dddf6e4cfa
commit 9d7c166bc8
9 changed files with 131 additions and 88 deletions

View File

@@ -78,13 +78,14 @@ fun SeriesScreen(
onDownloadOptionSelected = { option, selectedSeason ->
when (option) {
SeriesDownloadOption.SEASON ->
viewModel.downloadSeason(selectedSeason.episodes)
viewModel.downloadSeason(seriesData.id, selectedSeason.id)
SeriesDownloadOption.SERIES ->
viewModel.downloadSeries(seriesData)
SeriesDownloadOption.SMART ->
viewModel.enableSmartDownload(seriesData.id)
}
},
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState,
onBack = viewModel::onGoHome,
modifier = modifier
@@ -100,6 +101,7 @@ private fun SeriesScreenInternal(
seriesDownloadState: DownloadState,
seasonDownloadState: DownloadState,
onDownloadOptionSelected: (SeriesDownloadOption, Season) -> Unit,
onLoadSeasonEpisodes: (UUID, UUID) -> Unit,
onObserveSeasonDownloadState: (List<Episode>) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
@@ -107,23 +109,17 @@ private fun SeriesScreenInternal(
val scheme = MaterialTheme.colorScheme
val isHomeMediaTransitionActive = isHomeMediaSharedBoundsTransitionActive()
fun getDefaultSeason() : Season {
for (season in series.seasons) {
val firstUnwatchedEpisode = season.episodes.firstOrNull {
it.watched.not()
}
if (firstUnwatchedEpisode != null) {
return season
}
}
return series.seasons.first()
fun getDefaultSeason(): Season {
return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.first()
}
var selectedSeasonId by remember(series.id) { mutableStateOf(getDefaultSeason().id) }
val selectedSeason = series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason()
val nextUpEpisode = remember(series) {
series.seasons.firstNotNullOfOrNull { season ->
season.episodes.firstOrNull { !it.watched }
} ?: series.seasons.firstOrNull()?.episodes?.firstOrNull()
val nextUpEpisode = selectedSeason.episodes.firstOrNull { !it.watched }
?: selectedSeason.episodes.firstOrNull()
LaunchedEffect(series.id, selectedSeason.id) {
onLoadSeasonEpisodes(series.id, selectedSeason.id)
}
LaunchedEffect(selectedSeason.id, selectedSeason.episodes) {
@@ -260,6 +256,7 @@ private fun SeriesScreenPreview() {
seriesDownloadState = DownloadState.Downloading(progressPercent = 0.58f),
seasonDownloadState = DownloadState.NotDownloaded,
onDownloadOptionSelected = { _, _ -> },
onLoadSeasonEpisodes = { _, _ -> },
onObserveSeasonDownloadState = {},
onBack = {}
)

View File

@@ -141,15 +141,13 @@ internal fun SeriesActionButtons(
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (playAction != null && nextUpEpisode != null) {
MediaResumeButton(
text = mediaPlayButtonText(nextUpEpisode.progress, nextUpEpisode.watched),
progress = mediaPlaybackProgress(nextUpEpisode.progress),
onClick = playAction,
text = mediaPlayButtonText(nextUpEpisode?.progress, nextUpEpisode?.watched),
progress = mediaPlaybackProgress(nextUpEpisode?.progress),
onClick = playAction ?: {},
modifier = Modifier.sizeIn(maxWidth = 200.dp)
)
Spacer(modifier = Modifier.width(12.dp))
}
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = MaterialTheme.colorScheme.onSurface,

View File

@@ -1,7 +1,7 @@
package hu.bbara.purefin.ui.common.media
fun mediaPlayButtonText(progressPercent: Double?, watched: Boolean): String {
return if ((progressPercent ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
fun mediaPlayButtonText(progressPercent: Double?, watched: Boolean?): String {
return if ((progressPercent ?: 0.0) > 0.0 && watched?.not() == true) "Resume" else "Play"
}
fun mediaPlaybackProgress(progressPercent: Double?): Float {

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
@@ -58,8 +59,12 @@ class CompositeLocalMediaRepository @Inject constructor(
.flatMapLatest { it.getEpisode(id) }
}
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
return activeRepository.flatMapLatest { it.observeSeriesWithContent(seriesId) }
override suspend fun loadSeasons(seriesId: UUID) {
activeRepository.first().loadSeasons(seriesId)
}
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
activeRepository.first().loadSeasonEpisodes(seriesId, seasonId)
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {

View File

@@ -14,5 +14,6 @@ interface LocalMediaRepository : LocalMediaUpdater {
suspend fun getMovie(id: UUID): Flow<Movie?>
suspend fun getSeries(id: UUID): Flow<Series?>
suspend fun getEpisode(id: UUID): Flow<Episode?>
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?>
suspend fun loadSeasons(seriesId: UUID)
suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID)
}

View File

@@ -11,16 +11,16 @@ import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Series
import java.util.UUID
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
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.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
@@ -33,11 +33,12 @@ class SeriesViewModel @Inject constructor(
private val _seriesId = MutableStateFlow<UUID?>(null)
@OptIn(ExperimentalCoroutinesApi::class)
val series: StateFlow<Series?> = _seriesId
.flatMapLatest { id ->
if (id != null) mediaCatalogReader.observeSeriesWithContent(id) else flowOf(null)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val series: StateFlow<Series?> = combine(
_seriesId,
mediaCatalogReader.series
) { seriesId, seriesMap ->
seriesId?.let { seriesMap[it] }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val _seriesDownloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
val seriesDownloadState: StateFlow<DownloadState> = _seriesDownloadState
@@ -45,8 +46,12 @@ class SeriesViewModel @Inject constructor(
private val _seasonDownloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
val seasonDownloadState: StateFlow<DownloadState> = _seasonDownloadState
private var seasonDownloadStateJob: Job? = null
private var seriesDownloadStateJob: Job? = null
fun observeSeasonDownloadState(episodes: List<Episode>) {
viewModelScope.launch {
seasonDownloadStateJob?.cancel()
seasonDownloadStateJob = viewModelScope.launch {
if (episodes.isEmpty()) {
_seasonDownloadState.value = DownloadState.NotDownloaded
return@launch
@@ -58,9 +63,11 @@ class SeriesViewModel @Inject constructor(
}
fun observeSeriesDownloadState(series: Series) {
viewModelScope.launch {
seriesDownloadStateJob?.cancel()
seriesDownloadStateJob = viewModelScope.launch {
val allEpisodes = series.seasons.flatMap { it.episodes }
if (allEpisodes.isEmpty()) {
val hasUnloadedSeasons = series.seasons.any { it.episodes.isEmpty() && it.episodeCount > 0 }
if (allEpisodes.isEmpty() || hasUnloadedSeasons) {
_seriesDownloadState.value = DownloadState.NotDownloaded
return@launch
}
@@ -70,8 +77,21 @@ class SeriesViewModel @Inject constructor(
}
}
fun downloadSeason(episodes: List<Episode>) {
fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
viewModelScope.launch {
mediaCatalogReader.loadSeasonEpisodes(seriesId, seasonId)
}
}
fun downloadSeason(seriesId: UUID, seasonId: UUID) {
viewModelScope.launch {
mediaCatalogReader.loadSeasonEpisodes(seriesId, seasonId)
val episodes = mediaCatalogReader.getSeries(seriesId)
.first()
?.seasons
?.firstOrNull { it.id == seasonId }
?.episodes
.orEmpty()
mediaDownloadManager.downloadEpisodes(episodes.map { it.id })
}
}
@@ -82,11 +102,16 @@ class SeriesViewModel @Inject constructor(
}
}
fun downloadSeries(series: Series) {
fun downloadSeries(seriesData: Series) {
viewModelScope.launch {
val allEpisodeIds = series.seasons.flatMap { season ->
season.episodes.map { it.id }
seriesData.seasons.forEach { season ->
mediaCatalogReader.loadSeasonEpisodes(seriesData.id, season.id)
}
val allEpisodeIds = mediaCatalogReader.getSeries(seriesData.id)
.first()
?.seasons
.orEmpty()
.flatMap { season -> season.episodes.map { it.id } }
mediaDownloadManager.downloadEpisodes(allEpisodeIds)
}
}
@@ -129,5 +154,8 @@ class SeriesViewModel @Inject constructor(
fun selectSeries(seriesId: UUID) {
_seriesId.value = seriesId
viewModelScope.launch {
mediaCatalogReader.loadSeasons(seriesId)
}
}
}

View File

@@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
import javax.inject.Inject
@@ -88,7 +87,7 @@ class InMemoryLocalMediaRepository @Inject constructor(
}
}
val episode = episodesState.value[id] ?: return flowOf(null)
observeSeriesWithContent(seriesId = episode.seriesId)
loadSeasons(seriesId = episode.seriesId)
return episodesState.map { it[id] }
}
@@ -104,10 +103,16 @@ class InMemoryLocalMediaRepository @Inject constructor(
episodesState.update { current -> current + episodes.associateBy { it.id } }
}
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
scope.launch {
override suspend fun loadSeasons(seriesId: UUID) {
try {
ensureSeriesContentLoaded(seriesId)
seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found")
val updatedSeries = series.copy(
seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
)
seriesState.update { it + (updatedSeries.id to updatedSeries) }
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
@@ -115,7 +120,33 @@ class InMemoryLocalMediaRepository @Inject constructor(
throw error
}
}
return seriesState.map { it[seriesId] }
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
loadSeasons(seriesId)
val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found")
val season = series.seasons.firstOrNull { it.id == seasonId } ?: return
if (season.episodes.isNotEmpty() || season.episodeCount == 0) {
return
}
val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
.map { it.toEpisode(serverUrl) }
seriesState.update { current ->
val currentSeries = current[seriesId] ?: return@update current
val updatedSeries = currentSeries.copy(
seasons = currentSeries.seasons.map {
if (it.id == seasonId && it.episodes.isEmpty()) {
it.copy(episodes = episodes)
} else {
it
}
}
)
current + (updatedSeries.id to updatedSeries)
}
episodesState.update { current -> current + episodes.associateBy { it.id } }
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
@@ -153,23 +184,4 @@ class InMemoryLocalMediaRepository @Inject constructor(
}
}
}
private suspend fun ensureSeriesContentLoaded(seriesId: UUID) {
seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found")
val serverUrl = userSessionRepository.serverUrl.first()
val emptySeasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
val filledSeasons = emptySeasons.map { season ->
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, season.id).map { it.toEpisode(serverUrl) }
season.copy(episodes = episodes)
}
val updatedSeries = series.copy(seasons = filledSeasons)
seriesState.update { it + (updatedSeries.id to updatedSeries) }
val allEpisodes = filledSeasons.flatMap { it.episodes }
episodesState.update { current -> current + allEpisodes.associateBy { it.id } }
}
}

View File

@@ -44,8 +44,12 @@ class OfflineLocalMediaRepository @Inject constructor(
return episodes.map { it[id] }
}
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
return localDataSource.observeSeriesWithContent(seriesId)
override suspend fun loadSeasons(seriesId: UUID) {
// Offline series content is already emitted with its saved seasons.
}
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
// Offline series content is already emitted with its saved episodes.
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {

View File

@@ -63,13 +63,11 @@ class JellyfinApiClient @Inject constructor(
private val api = jellyfin.createApi()
private val itemFields =
private val defaultItemFields =
listOf(
ItemFields.CHILD_COUNT,
ItemFields.PARENT_ID,
ItemFields.DATE_LAST_REFRESHED,
ItemFields.OVERVIEW,
ItemFields.SEASON_USER_DATA,
// ItemFields.SEASON_USER_DATA,
)
private suspend fun getUserId(): UUID? = userSessionRepository.userId.first()
@@ -163,7 +161,7 @@ class JellyfinApiClient @Inject constructor(
userId = getUserId(),
enableImages = true,
parentId = libraryId,
fields = itemFields,
fields = defaultItemFields + ItemFields.OVERVIEW,
enableUserData = true,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
recursive = true,
@@ -200,7 +198,7 @@ class JellyfinApiClient @Inject constructor(
val userId = getUserId() ?: return@logApiFailure emptyList()
val getResumeItemsRequest = GetResumeItemsRequest(
userId = userId,
fields = itemFields,
fields = defaultItemFields + ItemFields.OVERVIEW,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
enableUserData = true,
startIndex = 0,
@@ -218,7 +216,7 @@ class JellyfinApiClient @Inject constructor(
}
val getNextUpRequest = GetNextUpRequest(
userId = getUserId(),
fields = itemFields,
fields = defaultItemFields + ItemFields.OVERVIEW,
enableResumable = false,
)
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
@@ -235,7 +233,7 @@ class JellyfinApiClient @Inject constructor(
val response = api.userLibraryApi.getLatestMedia(
userId = getUserId(),
parentId = libraryId,
fields = itemFields,
fields = defaultItemFields + ItemFields.OVERVIEW,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
limit = 10,
)
@@ -263,7 +261,7 @@ class JellyfinApiClient @Inject constructor(
val result = api.tvShowsApi.getSeasons(
userId = getUserId(),
seriesId = seriesId,
fields = itemFields,
fields = defaultItemFields,
enableUserData = true,
)
Log.d("getSeasons", result.content.toString())
@@ -280,7 +278,7 @@ class JellyfinApiClient @Inject constructor(
userId = getUserId(),
seriesId = seriesId,
seasonId = seasonId,
fields = itemFields,
fields = defaultItemFields + ItemFields.OVERVIEW,
enableUserData = true,
)
Log.d("getEpisodesInSeason", result.content.toString())