From cc9a82a4cff74ed8a764630266e1106428ed7d6f Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 8 Feb 2026 17:56:40 +0100 Subject: [PATCH] feat: refactor media retrieval to use combined flows for episodes and continue watching --- .../content/episode/EpisodeScreenViewModel.kt | 23 +-- .../app/content/series/SeriesScreen.kt | 5 +- .../app/content/series/SeriesViewModel.kt | 4 - .../purefin/app/home/HomePageViewModel.kt | 143 ++++++------------ .../purefin/app/library/LibraryViewModel.kt | 44 +++--- .../purefin/data/InMemoryMediaRepository.kt | 92 ++--------- .../hu/bbara/purefin/data/MediaRepository.kt | 12 +- .../local/room/RoomMediaLocalDataSource.kt | 3 + .../purefin/data/local/room/dao/EpisodeDao.kt | 4 + 9 files changed, 113 insertions(+), 217 deletions(-) diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt index b4490ea..365548c 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt @@ -7,7 +7,10 @@ import hu.bbara.purefin.data.InMemoryMediaRepository import hu.bbara.purefin.data.model.Episode import hu.bbara.purefin.navigation.NavigationManager import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.jellyfin.sdk.model.UUID import javax.inject.Inject @@ -18,8 +21,14 @@ class EpisodeScreenViewModel @Inject constructor( private val navigationManager: NavigationManager, ): ViewModel() { - private val _episode = MutableStateFlow(null) - val episode = _episode.asStateFlow() + private val _episodeId = MutableStateFlow(null) + + val episode: StateFlow = combine( + _episodeId, + mediaRepository.episodes + ) { id, episodesMap -> + id?.let { episodesMap[it] } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) init { viewModelScope.launch { mediaRepository.ensureReady() } @@ -30,13 +39,7 @@ class EpisodeScreenViewModel @Inject constructor( } fun selectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) { - viewModelScope.launch { - _episode.value = mediaRepository.getEpisode( - seriesId = seriesId, - seasonId = seasonId, - episodeId = episodeId, - ) - } + _episodeId.value = episodeId } } diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt index bba6214..aa0a34d 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt @@ -40,9 +40,10 @@ fun SeriesScreen( val series = viewModel.series.collectAsState() - if (series.value != null) { + val seriesData = series.value + if (seriesData != null && seriesData.seasons.isNotEmpty()) { SeriesScreenInternal( - series = series.value!!, + series = seriesData, onBack = viewModel::onBack, modifier = modifier ) diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt index ae912db..ce34d1b 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt @@ -60,9 +60,5 @@ class SeriesViewModel @Inject constructor( fun selectSeries(seriesId: UUID) { _seriesId.value = seriesId - // Ensure content is loaded from API if not cached - viewModelScope.launch { - mediaRepository.getSeriesWithContent(seriesId) - } } } diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt index 668c434..29de039 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt @@ -19,16 +19,12 @@ import hu.bbara.purefin.navigation.NavigationManager import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.navigation.SeriesDto import hu.bbara.purefin.session.UserSessionRepository -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind @@ -59,97 +55,58 @@ class HomePageViewModel @Inject constructor( } } - val continueWatching = mediaRepository.continueWatching - .mapLatest { list -> - withContext(Dispatchers.IO) { - list.map { media -> - when (media) { - is Media.MovieMedia -> { - val movie = mediaRepository.getMovie(media.movieId) - ContinueWatchingItem( - type = BaseItemKind.MOVIE, - movie = movie - ) - } - - is Media.EpisodeMedia -> { - val episode = mediaRepository.getEpisode( - seriesId = media.seriesId, - episodeId = media.episodeId - ) - ContinueWatchingItem( - type = BaseItemKind.EPISODE, - episode = episode - ) - } - - else -> throw UnsupportedOperationException("Unsupported item type: $media") - } - }.distinctBy { it.id } - } - } - .distinctUntilChanged() - .flowOn(Dispatchers.IO) - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList() - ) - - val latestLibraryContent = mediaRepository.latestLibraryContent - .mapLatest { libraryMap -> - withContext(Dispatchers.IO) { - libraryMap.mapValues { (_, items) -> - items.map { media -> - when (media) { - is Media.MovieMedia -> { - val movie = mediaRepository.getMovie(media.movieId) - PosterItem( - type = BaseItemKind.MOVIE, - movie = movie - ) - } - - is Media.EpisodeMedia -> { - val episode = mediaRepository.getEpisode( - seriesId = media.seriesId, - episodeId = media.episodeId - ) - PosterItem( - type = BaseItemKind.EPISODE, - episode = episode - ) - } - - is Media.SeriesMedia -> { - val series = mediaRepository.getSeries(media.id) - PosterItem( - type = BaseItemKind.SERIES, - series = series - ) - } - - is Media.SeasonMedia -> { - val series = mediaRepository.getSeries(media.seriesId) - PosterItem( - type = BaseItemKind.SERIES, - series = series - ) - } - - else -> throw UnsupportedOperationException("Unsupported item type: $media") - } - }.distinctBy { it.id } + val continueWatching = combine( + mediaRepository.continueWatching, + mediaRepository.movies, + mediaRepository.episodes + ) { list, moviesMap, episodesMap -> + list.mapNotNull { media -> + when (media) { + is Media.MovieMedia -> moviesMap[media.movieId]?.let { + ContinueWatchingItem(type = BaseItemKind.MOVIE, movie = it) } + is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let { + ContinueWatchingItem(type = BaseItemKind.EPISODE, episode = it) + } + else -> null } + }.distinctBy { it.id } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList() + ) + + val latestLibraryContent = combine( + mediaRepository.latestLibraryContent, + mediaRepository.movies, + mediaRepository.series, + mediaRepository.episodes + ) { libraryMap, moviesMap, seriesMap, episodesMap -> + libraryMap.mapValues { (_, items) -> + items.mapNotNull { media -> + when (media) { + is Media.MovieMedia -> moviesMap[media.movieId]?.let { + PosterItem(type = BaseItemKind.MOVIE, movie = it) + } + is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let { + PosterItem(type = BaseItemKind.EPISODE, episode = it) + } + is Media.SeriesMedia -> seriesMap[media.seriesId]?.let { + PosterItem(type = BaseItemKind.SERIES, series = it) + } + is Media.SeasonMedia -> seriesMap[media.seriesId]?.let { + PosterItem(type = BaseItemKind.SERIES, series = it) + } + else -> null + } + }.distinctBy { it.id } } - .distinctUntilChanged() - .flowOn(Dispatchers.IO) - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyMap() - ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyMap() + ) init { viewModelScope.launch { mediaRepository.ensureReady() } diff --git a/app/src/main/java/hu/bbara/purefin/app/library/LibraryViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/library/LibraryViewModel.kt index ac28a95..b943f2b 100644 --- a/app/src/main/java/hu/bbara/purefin/app/library/LibraryViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/library/LibraryViewModel.kt @@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import hu.bbara.purefin.app.home.ui.PosterItem import hu.bbara.purefin.client.JellyfinApiClient import hu.bbara.purefin.data.InMemoryMediaRepository +import hu.bbara.purefin.data.model.Media import hu.bbara.purefin.image.JellyfinImageHelper import hu.bbara.purefin.navigation.MovieDto import hu.bbara.purefin.navigation.NavigationManager @@ -14,7 +15,8 @@ import hu.bbara.purefin.navigation.SeriesDto import hu.bbara.purefin.session.UserSessionRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.jellyfin.sdk.model.UUID @@ -35,8 +37,26 @@ class LibraryViewModel @Inject constructor( started = SharingStarted.Eagerly, initialValue = "" ) - private val _contents = MutableStateFlow>(emptyList()) - val contents = _contents.asStateFlow() + + private val _libraryItems = MutableStateFlow>(emptyList()) + + val contents: StateFlow> = combine( + _libraryItems, + mediaRepository.movies, + mediaRepository.series + ) { items, moviesMap, seriesMap -> + items.mapNotNull { media -> + when (media) { + is Media.MovieMedia -> moviesMap[media.movieId]?.let { + PosterItem(type = BaseItemKind.MOVIE, movie = it) + } + is Media.SeriesMedia -> seriesMap[media.seriesId]?.let { + PosterItem(type = BaseItemKind.SERIES, series = it) + } + else -> null + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) init { viewModelScope.launch { mediaRepository.ensureReady() } @@ -67,22 +87,10 @@ class LibraryViewModel @Inject constructor( fun selectLibrary(libraryId: UUID) { viewModelScope.launch { val libraryItems = jellyfinApiClient.getLibraryContent(libraryId) - _contents.value = libraryItems.map { + _libraryItems.value = libraryItems.map { when (it.type) { - BaseItemKind.MOVIE -> { - val movie = mediaRepository.getMovie(it.id) - PosterItem( - type = BaseItemKind.MOVIE, - movie = movie - ) - } - BaseItemKind.SERIES -> { - val series = mediaRepository.getSeries(it.id) - PosterItem( - type = BaseItemKind.SERIES, - series = series - ) - } + BaseItemKind.MOVIE -> Media.MovieMedia(movieId = it.id) + BaseItemKind.SERIES -> Media.SeriesMedia(seriesId = it.id) else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}") } } diff --git a/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt index 58d3d7b..95821ee 100644 --- a/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt @@ -53,8 +53,16 @@ class InMemoryMediaRepository @Inject constructor( override val series: StateFlow> = localDataSource.seriesFlow .stateIn(scope, SharingStarted.Eagerly, emptyMap()) - override fun observeSeriesWithContent(seriesId: UUID): Flow = - localDataSource.observeSeriesWithContent(seriesId) + override val episodes: StateFlow> = localDataSource.episodesFlow + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + override fun observeSeriesWithContent(seriesId: UUID): Flow { + scope.launch { + awaitReady() + ensureSeriesContentLoaded(seriesId) + } + return localDataSource.observeSeriesWithContent(seriesId) + } private val _continueWatching: MutableStateFlow> = MutableStateFlow(emptyList()) val continueWatching: StateFlow> = _continueWatching.asStateFlow() @@ -203,23 +211,11 @@ class InMemoryMediaRepository @Inject constructor( //TODO Load seasons and episodes, other types are already loaded at this point. } - override suspend fun getMovie(movieId: UUID): Movie { + private suspend fun ensureSeriesContentLoaded(seriesId: UUID) { awaitReady() - return localDataSource.getMovie(movieId) - ?: throw RuntimeException("Movie not found") - } - - override suspend fun getSeries(seriesId: UUID): Series { - awaitReady() - return localDataSource.getSeriesBasic(seriesId) - ?: throw RuntimeException("Series not found") - } - - override suspend fun getSeriesWithContent(seriesId: UUID): Series { - awaitReady() - // Use cached content if available + // Skip if content is already cached in Room localDataSource.getSeriesWithContent(seriesId)?.takeIf { it.seasons.isNotEmpty() }?.let { - return it + return } val series = this.series.value[seriesId] ?: throw RuntimeException("Series not found") @@ -234,68 +230,6 @@ class InMemoryMediaRepository @Inject constructor( val updatedSeries = series.copy(seasons = filledSeasons) localDataSource.saveSeries(listOf(updatedSeries)) localDataSource.saveSeriesContent(updatedSeries) - return updatedSeries - } - - override suspend fun getSeason( - seriesId: UUID, - seasonId: UUID, - ): Season { - awaitReady() - localDataSource.getSeason(seriesId, seasonId)?.let { return it } - // Fallback: ensure series content is loaded, then retry - val series = getSeriesWithContent(seriesId) - return series.seasons.find { it.id == seasonId }?: throw RuntimeException("Season not found") - } - - override suspend fun getSeasons( - seriesId: UUID, - ): List { - awaitReady() - val seasons = localDataSource.getSeasons(seriesId) - if (seasons.isNotEmpty()) return seasons - val series = getSeriesWithContent(seriesId) - return series.seasons - } - - override suspend fun getEpisode( - seriesId: UUID, - episodeId: UUID - ) : Episode { - awaitReady() - localDataSource.getEpisodeById(episodeId)?.let { return it } - val series = getSeriesWithContent(seriesId) - return series.seasons.flatMap { it.episodes }.find { it.id == episodeId }?: throw RuntimeException("Episode not found") - } - - override suspend fun getEpisode( - seriesId: UUID, - seasonId: UUID, - episodeId: UUID - ): Episode { - awaitReady() - localDataSource.getEpisode(seriesId, seasonId, episodeId)?.let { return it } - val series = getSeriesWithContent(seriesId) - return series.seasons.find { it.id == seasonId }?.episodes?.find { it.id == episodeId } ?: throw RuntimeException("Episode not found") - } - - override suspend fun getEpisodes( - seriesId: UUID, - seasonId: UUID - ): List { - awaitReady() - val episodes = localDataSource.getSeason(seriesId, seasonId)?.episodes - if (episodes != null && episodes.isNotEmpty()) return episodes - val series = getSeriesWithContent(seriesId) - return series.seasons.find { it.id == seasonId }?.episodes ?: throw RuntimeException("Season not found") - } - - override suspend fun getEpisodes(seriesId: UUID): List { - awaitReady() - val episodes = localDataSource.getEpisodesBySeries(seriesId) - if (episodes.isNotEmpty()) return episodes - val series = getSeriesWithContent(seriesId) - return series.seasons.flatMap { it.episodes } } override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) { diff --git a/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt index 28c8ac6..d78f542 100644 --- a/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt @@ -2,7 +2,6 @@ package hu.bbara.purefin.data import hu.bbara.purefin.data.model.Episode import hu.bbara.purefin.data.model.Movie -import hu.bbara.purefin.data.model.Season import hu.bbara.purefin.data.model.Series import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -12,22 +11,13 @@ interface MediaRepository { val movies: StateFlow> val series: StateFlow> + val episodes: StateFlow> val state: StateFlow fun observeSeriesWithContent(seriesId: UUID): Flow suspend fun ensureReady() - suspend fun getMovie(movieId: UUID) : Movie - suspend fun getSeries(seriesId: UUID) : Series - suspend fun getSeriesWithContent(seriesId: UUID) : Series - suspend fun getSeasons(seriesId: UUID) : List - suspend fun getSeason(seriesId: UUID, seasonId: UUID) : Season - suspend fun getEpisodes(seriesId: UUID) : List - suspend fun getEpisodes(seriesId: UUID, seasonId: UUID) : List - suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) : Episode - suspend fun getEpisode(seriesId: UUID, episodeId: UUID) : Episode - suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) suspend fun refreshHomeData() } diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/RoomMediaLocalDataSource.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/RoomMediaLocalDataSource.kt index fd6604d..b3473e1 100644 --- a/app/src/main/java/hu/bbara/purefin/data/local/room/RoomMediaLocalDataSource.kt +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/RoomMediaLocalDataSource.kt @@ -36,6 +36,9 @@ class RoomMediaLocalDataSource @Inject constructor( entities.associate { it.id to it.toDomain(seasons = emptyList(), cast = emptyList()) } } + val episodesFlow: Flow> = episodeDao.observeAll() + .map { entities -> entities.associate { it.id to it.toDomain(cast = emptyList()) } } + // Full content Flow for series detail screen (scoped to one series) fun observeSeriesWithContent(seriesId: UUID): Flow = seriesDao.observeWithContent(seriesId).map { relation -> diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/EpisodeDao.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/EpisodeDao.kt index b5dde53..cfdd003 100644 --- a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/EpisodeDao.kt +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/EpisodeDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Query import androidx.room.Upsert import hu.bbara.purefin.data.local.room.EpisodeEntity +import kotlinx.coroutines.flow.Flow import java.util.UUID @Dao @@ -20,6 +21,9 @@ interface EpisodeDao { @Query("SELECT * FROM episodes WHERE seasonId = :seasonId") suspend fun getBySeasonId(seasonId: UUID): List + @Query("SELECT * FROM episodes") + fun observeAll(): Flow> + @Query("SELECT * FROM episodes WHERE id = :id") suspend fun getById(id: UUID): EpisodeEntity?