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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -58,8 +59,12 @@ class CompositeLocalMediaRepository @Inject constructor(
.flatMapLatest { it.getEpisode(id) } .flatMapLatest { it.getEpisode(id) }
} }
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> { override suspend fun loadSeasons(seriesId: UUID) {
return activeRepository.flatMapLatest { it.observeSeriesWithContent(seriesId) } 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) { 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 getMovie(id: UUID): Flow<Movie?>
suspend fun getSeries(id: UUID): Flow<Series?> suspend fun getSeries(id: UUID): Flow<Series?>
suspend fun getEpisode(id: UUID): Flow<Episode?> 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.core.navigation.Route
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Series import hu.bbara.purefin.model.Series
import java.util.UUID
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -33,11 +33,12 @@ class SeriesViewModel @Inject constructor(
private val _seriesId = MutableStateFlow<UUID?>(null) private val _seriesId = MutableStateFlow<UUID?>(null)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val series: StateFlow<Series?> = _seriesId val series: StateFlow<Series?> = combine(
.flatMapLatest { id -> _seriesId,
if (id != null) mediaCatalogReader.observeSeriesWithContent(id) else flowOf(null) mediaCatalogReader.series
} ) { seriesId, seriesMap ->
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) seriesId?.let { seriesMap[it] }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val _seriesDownloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded) private val _seriesDownloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
val seriesDownloadState: StateFlow<DownloadState> = _seriesDownloadState val seriesDownloadState: StateFlow<DownloadState> = _seriesDownloadState
@@ -45,8 +46,12 @@ class SeriesViewModel @Inject constructor(
private val _seasonDownloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded) private val _seasonDownloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
val seasonDownloadState: StateFlow<DownloadState> = _seasonDownloadState val seasonDownloadState: StateFlow<DownloadState> = _seasonDownloadState
private var seasonDownloadStateJob: Job? = null
private var seriesDownloadStateJob: Job? = null
fun observeSeasonDownloadState(episodes: List<Episode>) { fun observeSeasonDownloadState(episodes: List<Episode>) {
viewModelScope.launch { seasonDownloadStateJob?.cancel()
seasonDownloadStateJob = viewModelScope.launch {
if (episodes.isEmpty()) { if (episodes.isEmpty()) {
_seasonDownloadState.value = DownloadState.NotDownloaded _seasonDownloadState.value = DownloadState.NotDownloaded
return@launch return@launch
@@ -58,9 +63,11 @@ class SeriesViewModel @Inject constructor(
} }
fun observeSeriesDownloadState(series: Series) { fun observeSeriesDownloadState(series: Series) {
viewModelScope.launch { seriesDownloadStateJob?.cancel()
seriesDownloadStateJob = viewModelScope.launch {
val allEpisodes = series.seasons.flatMap { it.episodes } 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 _seriesDownloadState.value = DownloadState.NotDownloaded
return@launch return@launch
} }
@@ -70,8 +77,21 @@ class SeriesViewModel @Inject constructor(
} }
} }
fun downloadSeason(episodes: List<Episode>) { fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
viewModelScope.launch { 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 }) mediaDownloadManager.downloadEpisodes(episodes.map { it.id })
} }
} }
@@ -82,11 +102,16 @@ class SeriesViewModel @Inject constructor(
} }
} }
fun downloadSeries(series: Series) { fun downloadSeries(seriesData: Series) {
viewModelScope.launch { viewModelScope.launch {
val allEpisodeIds = series.seasons.flatMap { season -> seriesData.seasons.forEach { season ->
season.episodes.map { it.id } 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) mediaDownloadManager.downloadEpisodes(allEpisodeIds)
} }
} }
@@ -129,5 +154,8 @@ class SeriesViewModel @Inject constructor(
fun selectSeries(seriesId: UUID) { fun selectSeries(seriesId: UUID) {
_seriesId.value = seriesId _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.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -88,7 +87,7 @@ class InMemoryLocalMediaRepository @Inject constructor(
} }
} }
val episode = episodesState.value[id] ?: return flowOf(null) val episode = episodesState.value[id] ?: return flowOf(null)
observeSeriesWithContent(seriesId = episode.seriesId) loadSeasons(seriesId = episode.seriesId)
return episodesState.map { it[id] } return episodesState.map { it[id] }
} }
@@ -104,18 +103,50 @@ class InMemoryLocalMediaRepository @Inject constructor(
episodesState.update { current -> current + episodes.associateBy { it.id } } episodesState.update { current -> current + episodes.associateBy { it.id } }
} }
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> { override suspend fun loadSeasons(seriesId: UUID) {
scope.launch { try {
try { seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
ensureSeriesContentLoaded(seriesId)
} catch (error: CancellationException) { val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found")
throw error
} catch (error: Exception) { val updatedSeries = series.copy(
Log.e("InMemoryMediaRepository", "Failed to load content for series $seriesId", error) seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
throw error )
} 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) { 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] } return episodes.map { it[id] }
} }
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> { override suspend fun loadSeasons(seriesId: UUID) {
return localDataSource.observeSeriesWithContent(seriesId) // 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) { 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 api = jellyfin.createApi()
private val itemFields = private val defaultItemFields =
listOf( listOf(
ItemFields.CHILD_COUNT, ItemFields.CHILD_COUNT,
ItemFields.PARENT_ID, ItemFields.PARENT_ID,
ItemFields.DATE_LAST_REFRESHED, // ItemFields.SEASON_USER_DATA,
ItemFields.OVERVIEW,
ItemFields.SEASON_USER_DATA,
) )
private suspend fun getUserId(): UUID? = userSessionRepository.userId.first() private suspend fun getUserId(): UUID? = userSessionRepository.userId.first()
@@ -163,7 +161,7 @@ class JellyfinApiClient @Inject constructor(
userId = getUserId(), userId = getUserId(),
enableImages = true, enableImages = true,
parentId = libraryId, parentId = libraryId,
fields = itemFields, fields = defaultItemFields + ItemFields.OVERVIEW,
enableUserData = true, enableUserData = true,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
recursive = true, recursive = true,
@@ -200,7 +198,7 @@ class JellyfinApiClient @Inject constructor(
val userId = getUserId() ?: return@logApiFailure emptyList() val userId = getUserId() ?: return@logApiFailure emptyList()
val getResumeItemsRequest = GetResumeItemsRequest( val getResumeItemsRequest = GetResumeItemsRequest(
userId = userId, userId = userId,
fields = itemFields, fields = defaultItemFields + ItemFields.OVERVIEW,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE), includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
enableUserData = true, enableUserData = true,
startIndex = 0, startIndex = 0,
@@ -218,7 +216,7 @@ class JellyfinApiClient @Inject constructor(
} }
val getNextUpRequest = GetNextUpRequest( val getNextUpRequest = GetNextUpRequest(
userId = getUserId(), userId = getUserId(),
fields = itemFields, fields = defaultItemFields + ItemFields.OVERVIEW,
enableResumable = false, enableResumable = false,
) )
val result = api.tvShowsApi.getNextUp(getNextUpRequest) val result = api.tvShowsApi.getNextUp(getNextUpRequest)
@@ -235,7 +233,7 @@ class JellyfinApiClient @Inject constructor(
val response = api.userLibraryApi.getLatestMedia( val response = api.userLibraryApi.getLatestMedia(
userId = getUserId(), userId = getUserId(),
parentId = libraryId, parentId = libraryId,
fields = itemFields, fields = defaultItemFields + ItemFields.OVERVIEW,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON), includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
limit = 10, limit = 10,
) )
@@ -263,7 +261,7 @@ class JellyfinApiClient @Inject constructor(
val result = api.tvShowsApi.getSeasons( val result = api.tvShowsApi.getSeasons(
userId = getUserId(), userId = getUserId(),
seriesId = seriesId, seriesId = seriesId,
fields = itemFields, fields = defaultItemFields,
enableUserData = true, enableUserData = true,
) )
Log.d("getSeasons", result.content.toString()) Log.d("getSeasons", result.content.toString())
@@ -280,7 +278,7 @@ class JellyfinApiClient @Inject constructor(
userId = getUserId(), userId = getUserId(),
seriesId = seriesId, seriesId = seriesId,
seasonId = seasonId, seasonId = seasonId,
fields = itemFields, fields = defaultItemFields + ItemFields.OVERVIEW,
enableUserData = true, enableUserData = true,
) )
Log.d("getEpisodesInSeason", result.content.toString()) Log.d("getEpisodesInSeason", result.content.toString())