mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.tv.material3.MaterialTheme
|
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.feature.content.series.SeriesViewModel
|
||||||
|
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||||
import hu.bbara.purefin.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
import hu.bbara.purefin.model.Season
|
import hu.bbara.purefin.model.Season
|
||||||
import hu.bbara.purefin.model.Series
|
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.MediaDetailHorizontalPadding
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
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.series.components.TvSeriesHeroSection
|
||||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import androidx.tv.material3.Text as TvText
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvSeriesScreen(
|
fun TvSeriesScreen(
|
||||||
@@ -54,8 +54,8 @@ fun TvSeriesScreen(
|
|||||||
if (seriesData != null) {
|
if (seriesData != null) {
|
||||||
TvSeriesScreenContent(
|
TvSeriesScreenContent(
|
||||||
series = seriesData,
|
series = seriesData,
|
||||||
|
selectSeason = viewModel::selectSeason,
|
||||||
onPlayEpisode = viewModel::onPlayEpisode,
|
onPlayEpisode = viewModel::onPlayEpisode,
|
||||||
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
|
|
||||||
focusedSeasonId = focusedSeasonId,
|
focusedSeasonId = focusedSeasonId,
|
||||||
focusedEpisodeId = focusedEpisodeId,
|
focusedEpisodeId = focusedEpisodeId,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -68,8 +68,8 @@ fun TvSeriesScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun TvSeriesScreenContent(
|
internal fun TvSeriesScreenContent(
|
||||||
series: Series,
|
series: Series,
|
||||||
|
selectSeason: (UUID, UUID) -> Unit,
|
||||||
onPlayEpisode: (UUID) -> Unit,
|
onPlayEpisode: (UUID) -> Unit,
|
||||||
onLoadSeasonEpisodes: (UUID, UUID) -> Unit = { _, _ -> },
|
|
||||||
focusedSeasonId: UUID? = null,
|
focusedSeasonId: UUID? = null,
|
||||||
focusedEpisodeId: UUID? = null,
|
focusedEpisodeId: UUID? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -96,7 +96,7 @@ internal fun TvSeriesScreenContent(
|
|||||||
initialFocusSeason.episodeCount > 0
|
initialFocusSeason.episodeCount > 0
|
||||||
|
|
||||||
LaunchedEffect(series.id, selectedSeason?.id) {
|
LaunchedEffect(series.id, selectedSeason?.id) {
|
||||||
selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) }
|
selectedSeason?.let { selectSeason(series.id, it.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
TvMediaDetailScaffold(
|
TvMediaDetailScaffold(
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ fun SeriesScreen(
|
|||||||
|
|
||||||
SeriesScreenInternal(
|
SeriesScreenInternal(
|
||||||
series = seriesData,
|
series = seriesData,
|
||||||
|
selectSeason = viewModel::selectSeason,
|
||||||
seriesDownloadState = seriesDownloadState,
|
seriesDownloadState = seriesDownloadState,
|
||||||
seasonDownloadState = seasonDownloadState,
|
seasonDownloadState = seasonDownloadState,
|
||||||
isSmartDownloadEnabled = isSmartDownloadEnabled,
|
isSmartDownloadEnabled = isSmartDownloadEnabled,
|
||||||
@@ -113,7 +114,6 @@ fun SeriesScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
|
|
||||||
onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState,
|
onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState,
|
||||||
onBack = viewModel::onGoHome,
|
onBack = viewModel::onGoHome,
|
||||||
onMarkAsWatched = viewModel::markAsWatched,
|
onMarkAsWatched = viewModel::markAsWatched,
|
||||||
@@ -129,11 +129,11 @@ fun SeriesScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SeriesScreenInternal(
|
private fun SeriesScreenInternal(
|
||||||
series: Series,
|
series: Series,
|
||||||
|
selectSeason: (UUID, UUID) -> Unit,
|
||||||
seriesDownloadState: DownloadState,
|
seriesDownloadState: DownloadState,
|
||||||
seasonDownloadState: DownloadState,
|
seasonDownloadState: DownloadState,
|
||||||
isSmartDownloadEnabled: Boolean,
|
isSmartDownloadEnabled: Boolean,
|
||||||
onDownloadOptionSelected: (SeriesDownloadOption, Season) -> Unit,
|
onDownloadOptionSelected: (SeriesDownloadOption, Season) -> Unit,
|
||||||
onLoadSeasonEpisodes: (UUID, UUID) -> Unit,
|
|
||||||
onObserveSeasonDownloadState: (List<Episode>) -> Unit,
|
onObserveSeasonDownloadState: (List<Episode>) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onMarkAsWatched: (Boolean) -> Unit = {},
|
onMarkAsWatched: (Boolean) -> Unit = {},
|
||||||
@@ -175,7 +175,7 @@ private fun SeriesScreenInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(series.id, selectedSeason?.id) {
|
LaunchedEffect(series.id, selectedSeason?.id) {
|
||||||
selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) }
|
selectedSeason?.let { selectSeason(series.id, it.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(selectedSeason?.id, selectedSeason?.episodes) {
|
LaunchedEffect(selectedSeason?.id, selectedSeason?.episodes) {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import hu.bbara.purefin.core.Offline
|
import hu.bbara.purefin.core.Offline
|
||||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
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.DownloadState
|
||||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
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.EpisodeDto
|
||||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||||
import hu.bbara.purefin.core.navigation.Route
|
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) {
|
fun downloadSeason(seriesId: UUID, seasonId: UUID) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val mediaCatalogReader = selectedMediaCatalogReader()
|
val mediaCatalogReader = selectedMediaCatalogReader()
|
||||||
@@ -196,8 +190,14 @@ class SeriesViewModel @Inject constructor(
|
|||||||
_series.value = series
|
_series.value = series
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val mediaCatalogReader = mediaCatalogReader(series.offline)
|
val mediaCatalogReader = mediaCatalogReader(series.offline)
|
||||||
mediaCatalogReader.loadSeries(series.id)
|
launch { mediaCatalogReader.loadSeries(series.id) }
|
||||||
mediaCatalogReader.loadSeasons(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) {
|
private suspend fun loadSeasonsInternal(seriesId: UUID) {
|
||||||
seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
|
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(
|
// Await the parallel loadSeries to populate the series entry.
|
||||||
seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
|
// Precondition: seriesId refers to a real series (selectSeries always uses a
|
||||||
)
|
// SeriesDto from a real series list). If loadSeries returns without populating
|
||||||
seriesState.update { it + (updatedSeries.id to updatedSeries) }
|
// 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) {
|
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
|
val season = series.seasons.firstOrNull { it.id == seasonId } ?: return
|
||||||
if (season.episodes.isNotEmpty() || season.episodeCount == 0) {
|
if (season.episodes.isNotEmpty() || season.episodeCount == 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val serverUrl = userSessionRepository.serverUrl.first()
|
|
||||||
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
|
|
||||||
.map { it.toEpisode(serverUrl) }
|
|
||||||
seriesState.update { current ->
|
seriesState.update { current ->
|
||||||
val currentSeries = current[seriesId] ?: return@update current
|
val currentSeries = current[seriesId] ?: return@update current
|
||||||
val updatedSeries = currentSeries.copy(
|
val updatedSeries = currentSeries.copy(
|
||||||
|
|||||||
Reference in New Issue
Block a user