diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/TvSeriesScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/TvSeriesScreen.kt index f384e877..0f4efc2a 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/TvSeriesScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/TvSeriesScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -18,13 +17,13 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text as TvText import hu.bbara.purefin.core.feature.content.series.SeriesViewModel +import hu.bbara.purefin.core.navigation.SeriesDto import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Season import hu.bbara.purefin.model.Series -import hu.bbara.purefin.core.navigation.SeriesDto import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold @@ -35,6 +34,7 @@ import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs import hu.bbara.purefin.ui.screen.series.components.TvSeriesHeroSection import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import java.util.UUID +import androidx.tv.material3.Text as TvText @Composable fun TvSeriesScreen( @@ -54,8 +54,8 @@ fun TvSeriesScreen( if (seriesData != null) { TvSeriesScreenContent( series = seriesData, + selectSeason = viewModel::selectSeason, onPlayEpisode = viewModel::onPlayEpisode, - onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes, focusedSeasonId = focusedSeasonId, focusedEpisodeId = focusedEpisodeId, modifier = modifier @@ -68,8 +68,8 @@ fun TvSeriesScreen( @Composable internal fun TvSeriesScreenContent( series: Series, + selectSeason: (UUID, UUID) -> Unit, onPlayEpisode: (UUID) -> Unit, - onLoadSeasonEpisodes: (UUID, UUID) -> Unit = { _, _ -> }, focusedSeasonId: UUID? = null, focusedEpisodeId: UUID? = null, modifier: Modifier = Modifier, @@ -96,7 +96,7 @@ internal fun TvSeriesScreenContent( initialFocusSeason.episodeCount > 0 LaunchedEffect(series.id, selectedSeason?.id) { - selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) } + selectedSeason?.let { selectSeason(series.id, it.id) } } TvMediaDetailScaffold( 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 f7d04910..eb7f766d 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 @@ -99,6 +99,7 @@ fun SeriesScreen( SeriesScreenInternal( series = seriesData, + selectSeason = viewModel::selectSeason, seriesDownloadState = seriesDownloadState, seasonDownloadState = seasonDownloadState, isSmartDownloadEnabled = isSmartDownloadEnabled, @@ -113,7 +114,6 @@ fun SeriesScreen( } } }, - onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes, onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState, onBack = viewModel::onGoHome, onMarkAsWatched = viewModel::markAsWatched, @@ -129,11 +129,11 @@ fun SeriesScreen( @Composable private fun SeriesScreenInternal( series: Series, + selectSeason: (UUID, UUID) -> Unit, seriesDownloadState: DownloadState, seasonDownloadState: DownloadState, isSmartDownloadEnabled: Boolean, onDownloadOptionSelected: (SeriesDownloadOption, Season) -> Unit, - onLoadSeasonEpisodes: (UUID, UUID) -> Unit, onObserveSeasonDownloadState: (List) -> Unit, onBack: () -> Unit, onMarkAsWatched: (Boolean) -> Unit = {}, @@ -175,7 +175,7 @@ private fun SeriesScreenInternal( } LaunchedEffect(series.id, selectedSeason?.id) { - selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) } + selectedSeason?.let { selectSeason(series.id, it.id) } } LaunchedEffect(selectedSeason?.id, selectedSeason?.episodes) { 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 ee0c05fe..deeca0c1 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 @@ -5,9 +5,9 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import hu.bbara.purefin.core.Offline import hu.bbara.purefin.core.data.LocalMediaRepository +import hu.bbara.purefin.core.data.MediaMetadataUpdater import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.MediaDownloadController -import hu.bbara.purefin.core.data.MediaMetadataUpdater import hu.bbara.purefin.core.navigation.EpisodeDto import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.navigation.Route @@ -98,12 +98,6 @@ class SeriesViewModel @Inject constructor( } } - fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) { - viewModelScope.launch { - selectedMediaCatalogReader().loadSeasonEpisodes(seriesId, seasonId) - } - } - fun downloadSeason(seriesId: UUID, seasonId: UUID) { viewModelScope.launch { val mediaCatalogReader = selectedMediaCatalogReader() @@ -196,8 +190,14 @@ class SeriesViewModel @Inject constructor( _series.value = series viewModelScope.launch { val mediaCatalogReader = mediaCatalogReader(series.offline) - mediaCatalogReader.loadSeries(series.id) - mediaCatalogReader.loadSeasons(series.id) + launch { mediaCatalogReader.loadSeries(series.id) } + launch { mediaCatalogReader.loadSeasons(series.id) } + } + } + + fun selectSeason(seriesId: UUID, seasonId: UUID) { + viewModelScope.launch { + selectedMediaCatalogReader().loadSeasonEpisodes(seriesId, seasonId) } } 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 d7d66d0d..7842af57 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 @@ -139,26 +139,46 @@ class InMemoryLocalMediaRepository @Inject constructor( private suspend fun loadSeasonsInternal(seriesId: UUID) { seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return } - val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found") + val seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() } - val updatedSeries = series.copy( - seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() } - ) - seriesState.update { it + (updatedSeries.id to updatedSeries) } + // Await the parallel loadSeries to populate the series entry. + // Precondition: seriesId refers to a real series (selectSeries always uses a + // SeriesDto from a real series list). If loadSeries returns without populating + // state (null item / wrong type) this suspends until cancelled by structured + // concurrency when a sibling load* call fails. + seriesState.first { it.containsKey(seriesId) } + + seriesState.update { current -> + val existing = current[seriesId] ?: return@update current + if (existing.seasons.isNotEmpty()) return@update current + current + (seriesId to existing.copy(seasons = seasons)) + } } override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) { - loadSeasons(seriesId) + // Fast path: season already cached with episodes or known empty — skip the fetch. + seriesState.value[seriesId]?.seasons?.firstOrNull { it.id == seasonId }?.let { cached -> + if (cached.episodes.isNotEmpty() || cached.episodeCount == 0) return + } - val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found") + // Fetch first so this runs concurrently with loadSeries/loadSeasons when launched + // from selectSeries. Precondition: seasons are pre-loaded by selectSeries and + // seasonId is always a valid season from a real EpisodeDto in this path. + val serverUrl = userSessionRepository.serverUrl.first() + val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId) + .map { it.toEpisode(serverUrl) } + + // Await the season to be present in state (immediate when pre-loaded; waits for + // the parallel loadSeasons otherwise). + seriesState.first { it[seriesId]?.seasons?.any { it.id == seasonId } == true } + + // Re-check guard after await: a concurrent call may have filled episodes already. + val series = seriesState.value[seriesId] ?: return 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(