mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
refactor(core/series): improve data loading reliability with concurrent initialization
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<Episode>) -> 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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user