From 69fda1fef22383db7cc35acd817badcf555e4b9f Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 22 Feb 2026 14:17:58 +0100 Subject: [PATCH] refactor: add CompositeMediaRepository to switch between offline/online based on user session Extract AppContentRepository interface from MediaRepository for home/library-specific features. CompositeMediaRepository delegates to OfflineMediaRepository or AppContentRepository based on UserSessionRepository.isOfflineMode, allowing content ViewModels to use a single MediaRepository interface that automatically switches data source. --- .../purefin/core/data/AppContentRepository.kt | 22 +++++++ .../core/data/CompositeMediaRepository.kt | 62 +++++++++++++++++++ ...ory.kt => InMemoryAppContentRepository.kt} | 4 +- .../purefin/core/data/MediaRepository.kt | 17 +---- .../core/data/MediaRepositoryModule.kt | 5 +- .../core/data/OfflineMediaRepository.kt | 12 ++-- .../domain/usecase/RefreshHomeDataUseCase.kt | 4 +- .../content/episode/EpisodeScreenViewModel.kt | 5 -- .../shared/content/series/SeriesViewModel.kt | 23 +++---- .../feature/shared/home/HomePageViewModel.kt | 30 ++++----- .../shared/library/LibraryViewModel.kt | 8 +-- 11 files changed, 126 insertions(+), 66 deletions(-) create mode 100644 core/data/src/main/java/hu/bbara/purefin/core/data/AppContentRepository.kt create mode 100644 core/data/src/main/java/hu/bbara/purefin/core/data/CompositeMediaRepository.kt rename core/data/src/main/java/hu/bbara/purefin/core/data/{InMemoryMediaRepository.kt => InMemoryAppContentRepository.kt} (99%) diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/AppContentRepository.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/AppContentRepository.kt new file mode 100644 index 0000000..7113930 --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/AppContentRepository.kt @@ -0,0 +1,22 @@ +package hu.bbara.purefin.core.data + +import hu.bbara.purefin.core.model.Episode +import hu.bbara.purefin.core.model.Library +import hu.bbara.purefin.core.model.Media +import hu.bbara.purefin.core.model.MediaRepositoryState +import hu.bbara.purefin.core.model.Movie +import hu.bbara.purefin.core.model.Series +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import java.util.UUID + +interface AppContentRepository : MediaRepository { + + val libraries: StateFlow> + val state: StateFlow + val continueWatching: StateFlow> + val nextUp: StateFlow> + val latestLibraryContent: StateFlow>> + suspend fun ensureReady() + suspend fun refreshHomeData() +} diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/CompositeMediaRepository.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/CompositeMediaRepository.kt new file mode 100644 index 0000000..7bf1091 --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/CompositeMediaRepository.kt @@ -0,0 +1,62 @@ +package hu.bbara.purefin.core.data + +import hu.bbara.purefin.core.data.session.UserSessionRepository +import hu.bbara.purefin.core.model.Episode +import hu.bbara.purefin.core.model.Movie +import hu.bbara.purefin.core.model.Series +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Switches between [OfflineMediaRepository] and [AppContentRepository] based on + * [UserSessionRepository.isOfflineMode]. When offline mode is enabled, all reads + * and writes go through the offline (downloaded) repository; otherwise the online + * repository is used. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Singleton +class CompositeMediaRepository @Inject constructor( + private val offlineRepository: OfflineMediaRepository, + private val onlineRepository: AppContentRepository, + private val userSessionRepository: UserSessionRepository, +) : MediaRepository { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val activeRepository: Flow = + userSessionRepository.isOfflineMode.flatMapLatest { offline -> + kotlinx.coroutines.flow.flowOf(if (offline) offlineRepository else onlineRepository) + } + + override val movies: StateFlow> = activeRepository + .flatMapLatest { it.movies } + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + override val series: StateFlow> = activeRepository + .flatMapLatest { it.series } + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + override val episodes: StateFlow> = activeRepository + .flatMapLatest { it.episodes } + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + override fun observeSeriesWithContent(seriesId: UUID): Flow { + return activeRepository.flatMapLatest { it.observeSeriesWithContent(seriesId) } + } + + override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) { + val isOffline = userSessionRepository.isOfflineMode.stateIn(scope).value + val repo = if (isOffline) offlineRepository else onlineRepository + repo.updateWatchProgress(mediaId, positionMs, durationMs) + } +} diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryMediaRepository.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt similarity index 99% rename from core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryMediaRepository.kt rename to core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt index 68cc656..c47ce06 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryMediaRepository.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt @@ -39,11 +39,11 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class InMemoryMediaRepository @Inject constructor( +class InMemoryAppContentRepository @Inject constructor( val userSessionRepository: UserSessionRepository, val jellyfinApiClient: JellyfinApiClient, private val homeCacheDataStore: DataStore -) : MediaRepository { +) : AppContentRepository { private val ready = CompletableDeferred() private val readyMutex = Mutex() diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/MediaRepository.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/MediaRepository.kt index bbae58c..4033b72 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/MediaRepository.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/MediaRepository.kt @@ -1,9 +1,6 @@ package hu.bbara.purefin.core.data import hu.bbara.purefin.core.model.Episode -import hu.bbara.purefin.core.model.Library -import hu.bbara.purefin.core.model.Media -import hu.bbara.purefin.core.model.MediaRepositoryState import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Series import kotlinx.coroutines.flow.Flow @@ -11,21 +8,9 @@ import kotlinx.coroutines.flow.StateFlow import java.util.UUID interface MediaRepository { - - val libraries: StateFlow> val movies: StateFlow> val series: StateFlow> val episodes: StateFlow> - val state: StateFlow - - val continueWatching: StateFlow> - val nextUp: StateFlow> - val latestLibraryContent: StateFlow>> - fun observeSeriesWithContent(seriesId: UUID): Flow - - suspend fun ensureReady() - suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) - suspend fun refreshHomeData() -} +} \ No newline at end of file diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/MediaRepositoryModule.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/MediaRepositoryModule.kt index e219da8..249bb1b 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/MediaRepositoryModule.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/MediaRepositoryModule.kt @@ -10,6 +10,9 @@ import dagger.hilt.components.SingletonComponent abstract class MediaRepositoryModule { @Binds - abstract fun bindInMemoryMediaRepository(impl: InMemoryMediaRepository): MediaRepository + abstract fun bindAppContentRepository(impl: InMemoryAppContentRepository): AppContentRepository + + @Binds + abstract fun bindMediaRepository(impl: CompositeMediaRepository): MediaRepository } diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/OfflineMediaRepository.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/OfflineMediaRepository.kt index eb46d2a..38c755e 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/OfflineMediaRepository.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/OfflineMediaRepository.kt @@ -22,23 +22,23 @@ import javax.inject.Singleton @Singleton class OfflineMediaRepository @Inject constructor( private val localDataSource: OfflineRoomMediaLocalDataSource -) { +) : MediaRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - val movies: StateFlow> = localDataSource.moviesFlow + override val movies: StateFlow> = localDataSource.moviesFlow .stateIn(scope, SharingStarted.Eagerly, emptyMap()) - val series: StateFlow> = localDataSource.seriesFlow + override val series: StateFlow> = localDataSource.seriesFlow .stateIn(scope, SharingStarted.Eagerly, emptyMap()) - val episodes: StateFlow> = localDataSource.episodesFlow + override val episodes: StateFlow> = localDataSource.episodesFlow .stateIn(scope, SharingStarted.Eagerly, emptyMap()) - fun observeSeriesWithContent(seriesId: UUID): Flow { + override fun observeSeriesWithContent(seriesId: UUID): Flow { return localDataSource.observeSeriesWithContent(seriesId) } - suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) { + override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) { if (durationMs <= 0) return val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0 val watched = progressPercent >= 90.0 diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/domain/usecase/RefreshHomeDataUseCase.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/domain/usecase/RefreshHomeDataUseCase.kt index b9e78bd..f0be3d9 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/domain/usecase/RefreshHomeDataUseCase.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/domain/usecase/RefreshHomeDataUseCase.kt @@ -1,10 +1,10 @@ package hu.bbara.purefin.core.data.domain.usecase -import hu.bbara.purefin.core.data.MediaRepository +import hu.bbara.purefin.core.data.AppContentRepository import javax.inject.Inject class RefreshHomeDataUseCase @Inject constructor( - private val repository: MediaRepository + private val repository: AppContentRepository ) { suspend operator fun invoke() { repository.refreshHomeData() diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/episode/EpisodeScreenViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/episode/EpisodeScreenViewModel.kt index 5c3ee99..3a1fccc 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/episode/EpisodeScreenViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/episode/EpisodeScreenViewModel.kt @@ -12,7 +12,6 @@ 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 @@ -31,10 +30,6 @@ class EpisodeScreenViewModel @Inject constructor( id?.let { episodesMap[it] } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - init { - viewModelScope.launch { mediaRepository.ensureReady() } - } - fun onBack() { navigationManager.pop() } diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/series/SeriesViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/series/SeriesViewModel.kt index 4101a06..a36a2a8 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/series/SeriesViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/series/SeriesViewModel.kt @@ -15,7 +15,6 @@ 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 @@ -34,20 +33,14 @@ class SeriesViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - init { - viewModelScope.launch { mediaRepository.ensureReady() } - } - - fun onSelectEpisode(seriesId: UUID, seasonId:UUID, episodeId: UUID) { - viewModelScope.launch { - navigationManager.navigate(Route.EpisodeRoute( - EpisodeDto( - id = episodeId, - seasonId = seasonId, - seriesId = seriesId - ) - )) - } + fun onSelectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) { + navigationManager.navigate(Route.EpisodeRoute( + EpisodeDto( + id = episodeId, + seasonId = seasonId, + seriesId = seriesId + ) + )) } fun onBack() { diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt index 2f9750a..d8ad389 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt @@ -3,7 +3,7 @@ package hu.bbara.purefin.feature.shared.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import hu.bbara.purefin.core.data.MediaRepository +import hu.bbara.purefin.core.data.AppContentRepository import hu.bbara.purefin.core.data.domain.usecase.RefreshHomeDataUseCase import hu.bbara.purefin.core.data.navigation.EpisodeDto import hu.bbara.purefin.core.data.navigation.LibraryDto @@ -25,7 +25,7 @@ import javax.inject.Inject @HiltViewModel class HomePageViewModel @Inject constructor( - private val mediaRepository: MediaRepository, + private val appContentRepository: AppContentRepository, private val userSessionRepository: UserSessionRepository, private val navigationManager: NavigationManager, private val refreshHomeDataUseCase: RefreshHomeDataUseCase @@ -37,7 +37,7 @@ class HomePageViewModel @Inject constructor( initialValue = "" ) - val libraries = mediaRepository.libraries.map { libraries -> + val libraries = appContentRepository.libraries.map { libraries -> libraries.map { LibraryItem( id = it.id, @@ -45,8 +45,8 @@ class HomePageViewModel @Inject constructor( type = it.type, posterUrl = it.posterUrl, isEmpty = when(it.type) { - CollectionType.MOVIES -> mediaRepository.movies.value.isEmpty() - CollectionType.TVSHOWS -> mediaRepository.series.value.isEmpty() + CollectionType.MOVIES -> appContentRepository.movies.value.isEmpty() + CollectionType.TVSHOWS -> appContentRepository.series.value.isEmpty() else -> true } ) @@ -60,9 +60,9 @@ class HomePageViewModel @Inject constructor( ) val continueWatching = combine( - mediaRepository.continueWatching, - mediaRepository.movies, - mediaRepository.episodes + appContentRepository.continueWatching, + appContentRepository.movies, + appContentRepository.episodes ) { list, moviesMap, episodesMap -> list.mapNotNull { media -> when (media) { @@ -82,8 +82,8 @@ class HomePageViewModel @Inject constructor( ) val nextUp = combine( - mediaRepository.nextUp, - mediaRepository.episodes + appContentRepository.nextUp, + appContentRepository.episodes ) { list, episodesMap -> list.mapNotNull { media -> when (media) { @@ -100,10 +100,10 @@ class HomePageViewModel @Inject constructor( ) val latestLibraryContent = combine( - mediaRepository.latestLibraryContent, - mediaRepository.movies, - mediaRepository.series, - mediaRepository.episodes + appContentRepository.latestLibraryContent, + appContentRepository.movies, + appContentRepository.series, + appContentRepository.episodes ) { libraryMap, moviesMap, seriesMap, episodesMap -> libraryMap.mapValues { (_, items) -> items.mapNotNull { media -> @@ -131,7 +131,7 @@ class HomePageViewModel @Inject constructor( ) init { - viewModelScope.launch { mediaRepository.ensureReady() } + viewModelScope.launch { appContentRepository.ensureReady() } } fun onLibrarySelected(id: UUID, name: String) { diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/library/LibraryViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/library/LibraryViewModel.kt index aa746a9..335e1a6 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/library/LibraryViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/library/LibraryViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import hu.bbara.purefin.feature.shared.home.PosterItem -import hu.bbara.purefin.core.data.MediaRepository +import hu.bbara.purefin.core.data.AppContentRepository import hu.bbara.purefin.core.data.navigation.MovieDto import hu.bbara.purefin.core.data.navigation.NavigationManager import hu.bbara.purefin.core.data.navigation.Route @@ -22,13 +22,13 @@ import javax.inject.Inject @HiltViewModel class LibraryViewModel @Inject constructor( - private val mediaRepository: MediaRepository, + private val appContentRepository: AppContentRepository, private val navigationManager: NavigationManager ) : ViewModel() { private val selectedLibrary = MutableStateFlow(null) - val contents: StateFlow> = combine(selectedLibrary, mediaRepository.libraries) { + val contents: StateFlow> = combine(selectedLibrary, appContentRepository.libraries) { libraryId, libraries -> if (libraryId == null) { return@combine emptyList() @@ -46,7 +46,7 @@ class LibraryViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) init { - viewModelScope.launch { mediaRepository.ensureReady() } + viewModelScope.launch { appContentRepository.ensureReady() } } fun onMovieSelected(movieId: UUID) {