From 9d7c166bc80dbb8eb4811de5a2d8aafdbdea02ae Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Fri, 8 May 2026 18:42:52 +0200 Subject: [PATCH] feat: refactor SeriesScreen and ViewModel for improved season episode loading and download handling --- .../purefin/ui/screen/series/SeriesScreen.kt | 27 +++---- .../series/components/SeriesComponents.kt | 16 ++-- .../ui/common/media/MediaPlayButtonLabel.kt | 4 +- .../data/CompositeLocalMediaRepository.kt | 9 ++- .../purefin/core/data/LocalMediaRepository.kt | 3 +- .../feature/content/series/SeriesViewModel.kt | 58 ++++++++++---- .../catalog/InMemoryLocalMediaRepository.kt | 76 +++++++++++-------- .../catalog/OfflineLocalMediaRepository.kt | 8 +- .../data/jellyfin/client/JellyfinApiClient.kt | 18 ++--- 9 files changed, 131 insertions(+), 88 deletions(-) diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/series/SeriesScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/series/SeriesScreen.kt index 022b07ff..b130d7de 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/series/SeriesScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/series/SeriesScreen.kt @@ -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) -> 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 = {} ) diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/series/components/SeriesComponents.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/series/components/SeriesComponents.kt index 4f46ce16..e2e1d4a5 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/series/components/SeriesComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/series/components/SeriesComponents.kt @@ -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, - modifier = Modifier.sizeIn(maxWidth = 200.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - } + MediaResumeButton( + 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, diff --git a/core-ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlayButtonLabel.kt b/core-ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlayButtonLabel.kt index d23cbb14..f0ee82ad 100644 --- a/core-ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlayButtonLabel.kt +++ b/core-ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlayButtonLabel.kt @@ -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 { diff --git a/core/src/main/java/hu/bbara/purefin/core/data/CompositeLocalMediaRepository.kt b/core/src/main/java/hu/bbara/purefin/core/data/CompositeLocalMediaRepository.kt index e1947a33..70024284 100644 --- a/core/src/main/java/hu/bbara/purefin/core/data/CompositeLocalMediaRepository.kt +++ b/core/src/main/java/hu/bbara/purefin/core/data/CompositeLocalMediaRepository.kt @@ -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 { - 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) { diff --git a/core/src/main/java/hu/bbara/purefin/core/data/LocalMediaRepository.kt b/core/src/main/java/hu/bbara/purefin/core/data/LocalMediaRepository.kt index fbdc33c3..0c7a07bb 100644 --- a/core/src/main/java/hu/bbara/purefin/core/data/LocalMediaRepository.kt +++ b/core/src/main/java/hu/bbara/purefin/core/data/LocalMediaRepository.kt @@ -14,5 +14,6 @@ interface LocalMediaRepository : LocalMediaUpdater { suspend fun getMovie(id: UUID): Flow suspend fun getSeries(id: UUID): Flow suspend fun getEpisode(id: UUID): Flow - fun observeSeriesWithContent(seriesId: UUID): Flow + suspend fun loadSeasons(seriesId: UUID) + suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) } diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/content/series/SeriesViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/feature/content/series/SeriesViewModel.kt index 09a5eab6..685b79cf 100644 --- a/core/src/main/java/hu/bbara/purefin/core/feature/content/series/SeriesViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/feature/content/series/SeriesViewModel.kt @@ -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(null) @OptIn(ExperimentalCoroutinesApi::class) - val series: StateFlow = _seriesId - .flatMapLatest { id -> - if (id != null) mediaCatalogReader.observeSeriesWithContent(id) else flowOf(null) - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + val series: StateFlow = combine( + _seriesId, + mediaCatalogReader.series + ) { seriesId, seriesMap -> + seriesId?.let { seriesMap[it] } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) private val _seriesDownloadState = MutableStateFlow(DownloadState.NotDownloaded) val seriesDownloadState: StateFlow = _seriesDownloadState @@ -45,8 +46,12 @@ class SeriesViewModel @Inject constructor( private val _seasonDownloadState = MutableStateFlow(DownloadState.NotDownloaded) val seasonDownloadState: StateFlow = _seasonDownloadState + private var seasonDownloadStateJob: Job? = null + private var seriesDownloadStateJob: Job? = null + fun observeSeasonDownloadState(episodes: List) { - 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) { + 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) + } } } diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt index 1262d959..3aa663f6 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt @@ -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,18 +103,50 @@ class InMemoryLocalMediaRepository @Inject constructor( episodesState.update { current -> current + episodes.associateBy { it.id } } } - override fun observeSeriesWithContent(seriesId: UUID): Flow { - scope.launch { - try { - ensureSeriesContentLoaded(seriesId) - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - Log.e("InMemoryMediaRepository", "Failed to load content for series $seriesId", error) - throw error - } + override suspend fun loadSeasons(seriesId: UUID) { + try { + 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) { + Log.e("InMemoryMediaRepository", "Failed to load content for series $seriesId", error) + 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 } } - } } diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/OfflineLocalMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/OfflineLocalMediaRepository.kt index b13ec614..5b995bb1 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/OfflineLocalMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/OfflineLocalMediaRepository.kt @@ -44,8 +44,12 @@ class OfflineLocalMediaRepository @Inject constructor( return episodes.map { it[id] } } - override fun observeSeriesWithContent(seriesId: UUID): Flow { - 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) { diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt index e565f82c..6d8c5637 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt @@ -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())