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 e385e00..ae912db 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 @@ -8,8 +8,13 @@ import hu.bbara.purefin.data.model.Series import hu.bbara.purefin.navigation.EpisodeDto import hu.bbara.purefin.navigation.NavigationManager import hu.bbara.purefin.navigation.Route +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.jellyfin.sdk.model.UUID import javax.inject.Inject @@ -20,8 +25,14 @@ class SeriesViewModel @Inject constructor( private val navigationManager: NavigationManager, ) : ViewModel() { - private val _series = MutableStateFlow(null) - val series = _series.asStateFlow() + private val _seriesId = MutableStateFlow(null) + + @OptIn(ExperimentalCoroutinesApi::class) + val series: StateFlow = _seriesId + .flatMapLatest { id -> + if (id != null) mediaRepository.observeSeriesWithContent(id) else flowOf(null) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) init { viewModelScope.launch { mediaRepository.ensureReady() } @@ -43,17 +54,15 @@ class SeriesViewModel @Inject constructor( navigationManager.pop() } - fun onGoHome() { navigationManager.replaceAll(Route.Home) } fun selectSeries(seriesId: UUID) { + _seriesId.value = seriesId + // Ensure content is loaded from API if not cached viewModelScope.launch { - val series = mediaRepository.getSeriesWithContent( - seriesId = seriesId - ) - _series.value = series + mediaRepository.getSeriesWithContent(seriesId) } } } 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 fcc79a9..58d3d7b 100644 --- a/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt @@ -14,10 +14,13 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind @@ -39,15 +42,19 @@ class InMemoryMediaRepository @Inject constructor( ) : MediaRepository { private val ready = CompletableDeferred() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val _state: MutableStateFlow = MutableStateFlow(MediaRepositoryState.Loading) override val state: StateFlow = _state.asStateFlow() - private val _movies : MutableStateFlow> = MutableStateFlow(emptyMap()) - override val movies: StateFlow> = _movies.asStateFlow() + override val movies: StateFlow> = localDataSource.moviesFlow + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) - private val _series : MutableStateFlow> = MutableStateFlow(emptyMap()) - override val series: StateFlow> = _series.asStateFlow() + override val series: StateFlow> = localDataSource.seriesFlow + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + override fun observeSeriesWithContent(seriesId: UUID): Flow = + localDataSource.observeSeriesWithContent(seriesId) private val _continueWatching: MutableStateFlow> = MutableStateFlow(emptyList()) val continueWatching: StateFlow> = _continueWatching.asStateFlow() @@ -55,8 +62,6 @@ class InMemoryMediaRepository @Inject constructor( private val _latestLibraryContent: MutableStateFlow>> = MutableStateFlow(emptyMap()) val latestLibraryContent: StateFlow>> = _latestLibraryContent.asStateFlow() - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - init { scope.launch { runCatching { ensureReady() } @@ -96,11 +101,9 @@ class InMemoryMediaRepository @Inject constructor( } val movies = filledLibraries.filter { it.type == CollectionType.MOVIES }.flatMap { it.movies.orEmpty() } localDataSource.saveMovies(movies) - _movies.value = localDataSource.getMovies().associateBy { it.id } val series = filledLibraries.filter { it.type == CollectionType.TVSHOWS }.flatMap { it.series.orEmpty() } localDataSource.saveSeries(series) - _series.value = localDataSource.getSeries().associateBy { it.id } } suspend fun loadLibrary(library: Library): Library { @@ -123,7 +126,6 @@ class InMemoryMediaRepository @Inject constructor( ?: throw RuntimeException("Movie not found") val updatedMovie = movieItem.toMovie(serverUrl(), movie.libraryId) localDataSource.saveMovies(listOf(updatedMovie)) - _movies.value += (movie.id to updatedMovie) return updatedMovie } @@ -132,7 +134,6 @@ class InMemoryMediaRepository @Inject constructor( ?: throw RuntimeException("Series not found") val updatedSeries = seriesItem.toSeries(serverUrl(), series.libraryId) localDataSource.saveSeries(listOf(updatedSeries)) - _series.value += (series.id to updatedSeries) return updatedSeries } @@ -204,31 +205,24 @@ class InMemoryMediaRepository @Inject constructor( override suspend fun getMovie(movieId: UUID): Movie { awaitReady() - localDataSource.getMovie(movieId)?.let { - _movies.value += (movieId to it) - return it - } - throw RuntimeException("Movie not found") + return localDataSource.getMovie(movieId) + ?: throw RuntimeException("Movie not found") } override suspend fun getSeries(seriesId: UUID): Series { awaitReady() - localDataSource.getSeriesBasic(seriesId)?.let { - _series.value += (seriesId to it) - return it - } - throw RuntimeException("Series not found") + return localDataSource.getSeriesBasic(seriesId) + ?: throw RuntimeException("Series not found") } override suspend fun getSeriesWithContent(seriesId: UUID): Series { awaitReady() // Use cached content if available localDataSource.getSeriesWithContent(seriesId)?.takeIf { it.seasons.isNotEmpty() }?.let { - _series.value += (seriesId to it) return it } - val series = _series.value[seriesId] ?: throw RuntimeException("Series not found") + val series = this.series.value[seriesId] ?: throw RuntimeException("Series not found") val emptySeasonsItem = jellyfinApiClient.getSeasons(seriesId) val emptySeasons = emptySeasonsItem.map { it.toSeason(serverUrl()) } @@ -240,7 +234,6 @@ class InMemoryMediaRepository @Inject constructor( val updatedSeries = series.copy(seasons = filledSeasons) localDataSource.saveSeries(listOf(updatedSeries)) localDataSource.saveSeriesContent(updatedSeries) - _series.value += (series.id to updatedSeries) return updatedSeries } @@ -309,30 +302,8 @@ class InMemoryMediaRepository @Inject constructor( if (durationMs <= 0) return val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0 val watched = progressPercent >= 90.0 - - val result = localDataSource.updateWatchProgress(mediaId, progressPercent, watched) ?: return - - if (result.isMovie) { - _movies.value[mediaId]?.let { movie -> - _movies.value += (mediaId to movie.copy(progress = progressPercent, watched = watched)) - } - } else { - val seriesId = result.seriesId ?: return - _series.value[seriesId]?.let { currentSeries -> - val updatedSeasons = currentSeries.seasons.map { season -> - if (season.id == result.seasonId) { - val updatedEpisodes = season.episodes.map { ep -> - if (ep.id == mediaId) ep.copy(progress = progressPercent, watched = watched) else ep - } - season.copy(unwatchedEpisodeCount = result.seasonUnwatchedCount, episodes = updatedEpisodes) - } else season - } - _series.value += (seriesId to currentSeries.copy( - unwatchedEpisodeCount = result.seriesUnwatchedCount, - seasons = updatedSeasons - )) - } - } + // Write to Room — the reactive Flows propagate changes to UI automatically + localDataSource.updateWatchProgress(mediaId, progressPercent, watched) } override suspend fun refreshHomeData() { 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 91e5dc6..28c8ac6 100644 --- a/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt @@ -4,6 +4,7 @@ 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 import java.util.UUID @@ -13,6 +14,8 @@ interface MediaRepository { val series: StateFlow> val state: StateFlow + fun observeSeriesWithContent(seriesId: UUID): Flow + suspend fun ensureReady() suspend fun getMovie(movieId: UUID) : Movie diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/EpisodeEntity.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/EpisodeEntity.kt index 0b57a1e..36596ab 100644 --- a/app/src/main/java/hu/bbara/purefin/data/local/room/EpisodeEntity.kt +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/EpisodeEntity.kt @@ -21,7 +21,7 @@ import java.util.UUID data class EpisodeEntity( @PrimaryKey val id: UUID, val seriesId: UUID, - val seasonId: UUID?, + val seasonId: UUID, val index: Int, val title: String, val synopsis: String, 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 f748609..fd6604d 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 @@ -11,6 +11,8 @@ 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.map import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -25,6 +27,30 @@ class RoomMediaLocalDataSource @Inject constructor( private val castMemberDao: CastMemberDao ) { + // Lightweight Flows for list screens (home, library) + val moviesFlow: Flow> = movieDao.observeAll() + .map { entities -> entities.associate { it.id to it.toDomain(cast = emptyList()) } } + + val seriesFlow: Flow> = seriesDao.observeAll() + .map { entities -> + entities.associate { it.id to it.toDomain(seasons = emptyList(), cast = emptyList()) } + } + + // Full content Flow for series detail screen (scoped to one series) + fun observeSeriesWithContent(seriesId: UUID): Flow = + seriesDao.observeWithContent(seriesId).map { relation -> + relation?.let { + it.series.toDomain( + seasons = it.seasons.map { swe -> + swe.season.toDomain( + episodes = swe.episodes.map { ep -> ep.toDomain(cast = emptyList()) } + ) + }, + cast = emptyList() + ) + } + } + suspend fun saveMovies(movies: List) { database.withTransaction { movieDao.upsertAll(movies.map { it.toEntity() }) @@ -131,39 +157,21 @@ class RoomMediaLocalDataSource @Inject constructor( return episodeEntity.toDomain(cast) } - - data class WatchProgressResult( - val isMovie: Boolean, - val seriesId: UUID? = null, - val seasonId: UUID? = null, - val seriesUnwatchedCount: Int = 0, - val seasonUnwatchedCount: Int = 0 - ) - - suspend fun updateWatchProgress(mediaId: UUID, progress: Double?, watched: Boolean): WatchProgressResult? { + suspend fun updateWatchProgress(mediaId: UUID, progress: Double?, watched: Boolean) { movieDao.getById(mediaId)?.let { movieDao.updateProgress(mediaId, progress, watched) - return WatchProgressResult(isMovie = true) + return } episodeDao.getById(mediaId)?.let { episode -> - return database.withTransaction { + database.withTransaction { episodeDao.updateProgress(mediaId, progress, watched) - val seasonUnwatched = episodeDao.countUnwatchedBySeason(episode.seasonId!!) + val seasonUnwatched = episodeDao.countUnwatchedBySeason(episode.seasonId) seasonDao.updateUnwatchedCount(episode.seasonId, seasonUnwatched) val seriesUnwatched = episodeDao.countUnwatchedBySeries(episode.seriesId) seriesDao.updateUnwatchedCount(episode.seriesId, seriesUnwatched) - WatchProgressResult( - isMovie = false, - seriesId = episode.seriesId, - seasonId = episode.seasonId, - seriesUnwatchedCount = seriesUnwatched, - seasonUnwatchedCount = seasonUnwatched - ) } } - - return null } suspend fun getEpisodesBySeries(seriesId: UUID): List { @@ -268,7 +276,7 @@ class RoomMediaLocalDataSource @Inject constructor( private fun EpisodeEntity.toDomain(cast: List) = Episode( id = id, seriesId = seriesId, - seasonId = seasonId ?: seriesId, // fallback to series when season is absent + seasonId = seasonId, index = index, title = title, synopsis = synopsis, diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/RoomRelations.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/RoomRelations.kt new file mode 100644 index 0000000..783e14d --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/RoomRelations.kt @@ -0,0 +1,23 @@ +package hu.bbara.purefin.data.local.room + +import androidx.room.Embedded +import androidx.room.Relation + +data class SeasonWithEpisodes( + @Embedded val season: SeasonEntity, + @Relation( + parentColumn = "id", + entityColumn = "seasonId" + ) + val episodes: List +) + +data class SeriesWithSeasonsAndEpisodes( + @Embedded val series: SeriesEntity, + @Relation( + entity = SeasonEntity::class, + parentColumn = "id", + entityColumn = "seriesId" + ) + val seasons: List +) diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/MovieDao.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/MovieDao.kt index b1fa019..7e79f42 100644 --- a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/MovieDao.kt +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/MovieDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Query import androidx.room.Upsert import hu.bbara.purefin.data.local.room.MovieEntity +import kotlinx.coroutines.flow.Flow import java.util.UUID @Dao @@ -17,6 +18,9 @@ interface MovieDao { @Query("SELECT * FROM movies") suspend fun getAll(): List + @Query("SELECT * FROM movies") + fun observeAll(): Flow> + @Query("SELECT * FROM movies WHERE id = :id") suspend fun getById(id: UUID): MovieEntity? diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeriesDao.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeriesDao.kt index ef8231c..25f9102 100644 --- a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeriesDao.kt +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeriesDao.kt @@ -2,8 +2,11 @@ package hu.bbara.purefin.data.local.room.dao import androidx.room.Dao import androidx.room.Query +import androidx.room.Transaction import androidx.room.Upsert import hu.bbara.purefin.data.local.room.SeriesEntity +import hu.bbara.purefin.data.local.room.SeriesWithSeasonsAndEpisodes +import kotlinx.coroutines.flow.Flow import java.util.UUID @Dao @@ -17,6 +20,13 @@ interface SeriesDao { @Query("SELECT * FROM series") suspend fun getAll(): List + @Query("SELECT * FROM series") + fun observeAll(): Flow> + + @Transaction + @Query("SELECT * FROM series WHERE id = :id") + fun observeWithContent(id: UUID): Flow + @Query("SELECT * FROM series WHERE id = :id") suspend fun getById(id: UUID): SeriesEntity?