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.
This commit is contained in:
2026-02-22 14:17:58 +01:00
parent d5c0bbded6
commit 69fda1fef2
11 changed files with 126 additions and 66 deletions

View File

@@ -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<List<Library>>
val state: StateFlow<MediaRepositoryState>
val continueWatching: StateFlow<List<Media>>
val nextUp: StateFlow<List<Media>>
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>
suspend fun ensureReady()
suspend fun refreshHomeData()
}

View File

@@ -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<MediaRepository> =
userSessionRepository.isOfflineMode.flatMapLatest { offline ->
kotlinx.coroutines.flow.flowOf(if (offline) offlineRepository else onlineRepository)
}
override val movies: StateFlow<Map<UUID, Movie>> = activeRepository
.flatMapLatest { it.movies }
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override val series: StateFlow<Map<UUID, Series>> = activeRepository
.flatMapLatest { it.series }
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> = activeRepository
.flatMapLatest { it.episodes }
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
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)
}
}

View File

@@ -39,11 +39,11 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class InMemoryMediaRepository @Inject constructor( class InMemoryAppContentRepository @Inject constructor(
val userSessionRepository: UserSessionRepository, val userSessionRepository: UserSessionRepository,
val jellyfinApiClient: JellyfinApiClient, val jellyfinApiClient: JellyfinApiClient,
private val homeCacheDataStore: DataStore<HomeCache> private val homeCacheDataStore: DataStore<HomeCache>
) : MediaRepository { ) : AppContentRepository {
private val ready = CompletableDeferred<Unit>() private val ready = CompletableDeferred<Unit>()
private val readyMutex = Mutex() private val readyMutex = Mutex()

View File

@@ -1,9 +1,6 @@
package hu.bbara.purefin.core.data package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.model.Episode 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.Movie
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -11,21 +8,9 @@ import kotlinx.coroutines.flow.StateFlow
import java.util.UUID import java.util.UUID
interface MediaRepository { interface MediaRepository {
val libraries: StateFlow<List<Library>>
val movies: StateFlow<Map<UUID, Movie>> val movies: StateFlow<Map<UUID, Movie>>
val series: StateFlow<Map<UUID, Series>> val series: StateFlow<Map<UUID, Series>>
val episodes: StateFlow<Map<UUID, Episode>> val episodes: StateFlow<Map<UUID, Episode>>
val state: StateFlow<MediaRepositoryState>
val continueWatching: StateFlow<List<Media>>
val nextUp: StateFlow<List<Media>>
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> fun observeSeriesWithContent(seriesId: UUID): Flow<Series?>
suspend fun ensureReady()
suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long)
suspend fun refreshHomeData()
} }

View File

@@ -10,6 +10,9 @@ import dagger.hilt.components.SingletonComponent
abstract class MediaRepositoryModule { abstract class MediaRepositoryModule {
@Binds @Binds
abstract fun bindInMemoryMediaRepository(impl: InMemoryMediaRepository): MediaRepository abstract fun bindAppContentRepository(impl: InMemoryAppContentRepository): AppContentRepository
@Binds
abstract fun bindMediaRepository(impl: CompositeMediaRepository): MediaRepository
} }

View File

@@ -22,23 +22,23 @@ import javax.inject.Singleton
@Singleton @Singleton
class OfflineMediaRepository @Inject constructor( class OfflineMediaRepository @Inject constructor(
private val localDataSource: OfflineRoomMediaLocalDataSource private val localDataSource: OfflineRoomMediaLocalDataSource
) { ) : MediaRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val movies: StateFlow<Map<UUID, Movie>> = localDataSource.moviesFlow override val movies: StateFlow<Map<UUID, Movie>> = localDataSource.moviesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap()) .stateIn(scope, SharingStarted.Eagerly, emptyMap())
val series: StateFlow<Map<UUID, Series>> = localDataSource.seriesFlow override val series: StateFlow<Map<UUID, Series>> = localDataSource.seriesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap()) .stateIn(scope, SharingStarted.Eagerly, emptyMap())
val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap()) .stateIn(scope, SharingStarted.Eagerly, emptyMap())
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> { override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
return localDataSource.observeSeriesWithContent(seriesId) 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 if (durationMs <= 0) return
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0 val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
val watched = progressPercent >= 90.0 val watched = progressPercent >= 90.0

View File

@@ -1,10 +1,10 @@
package hu.bbara.purefin.core.data.domain.usecase 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 import javax.inject.Inject
class RefreshHomeDataUseCase @Inject constructor( class RefreshHomeDataUseCase @Inject constructor(
private val repository: MediaRepository private val repository: AppContentRepository
) { ) {
suspend operator fun invoke() { suspend operator fun invoke() {
repository.refreshHomeData() repository.refreshHomeData()

View File

@@ -12,7 +12,6 @@ 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.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -31,10 +30,6 @@ class EpisodeScreenViewModel @Inject constructor(
id?.let { episodesMap[it] } id?.let { episodesMap[it] }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
init {
viewModelScope.launch { mediaRepository.ensureReady() }
}
fun onBack() { fun onBack() {
navigationManager.pop() navigationManager.pop()
} }

View File

@@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.StateFlow
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
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -34,20 +33,14 @@ class SeriesViewModel @Inject constructor(
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
init { fun onSelectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
viewModelScope.launch { mediaRepository.ensureReady() } navigationManager.navigate(Route.EpisodeRoute(
} EpisodeDto(
id = episodeId,
fun onSelectEpisode(seriesId: UUID, seasonId:UUID, episodeId: UUID) { seasonId = seasonId,
viewModelScope.launch { seriesId = seriesId
navigationManager.navigate(Route.EpisodeRoute( )
EpisodeDto( ))
id = episodeId,
seasonId = seasonId,
seriesId = seriesId
)
))
}
} }
fun onBack() { fun onBack() {

View File

@@ -3,7 +3,7 @@ package hu.bbara.purefin.feature.shared.home
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.domain.usecase.RefreshHomeDataUseCase
import hu.bbara.purefin.core.data.navigation.EpisodeDto import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.data.navigation.LibraryDto import hu.bbara.purefin.core.data.navigation.LibraryDto
@@ -25,7 +25,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class HomePageViewModel @Inject constructor( class HomePageViewModel @Inject constructor(
private val mediaRepository: MediaRepository, private val appContentRepository: AppContentRepository,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val refreshHomeDataUseCase: RefreshHomeDataUseCase private val refreshHomeDataUseCase: RefreshHomeDataUseCase
@@ -37,7 +37,7 @@ class HomePageViewModel @Inject constructor(
initialValue = "" initialValue = ""
) )
val libraries = mediaRepository.libraries.map { libraries -> val libraries = appContentRepository.libraries.map { libraries ->
libraries.map { libraries.map {
LibraryItem( LibraryItem(
id = it.id, id = it.id,
@@ -45,8 +45,8 @@ class HomePageViewModel @Inject constructor(
type = it.type, type = it.type,
posterUrl = it.posterUrl, posterUrl = it.posterUrl,
isEmpty = when(it.type) { isEmpty = when(it.type) {
CollectionType.MOVIES -> mediaRepository.movies.value.isEmpty() CollectionType.MOVIES -> appContentRepository.movies.value.isEmpty()
CollectionType.TVSHOWS -> mediaRepository.series.value.isEmpty() CollectionType.TVSHOWS -> appContentRepository.series.value.isEmpty()
else -> true else -> true
} }
) )
@@ -60,9 +60,9 @@ class HomePageViewModel @Inject constructor(
) )
val continueWatching = combine( val continueWatching = combine(
mediaRepository.continueWatching, appContentRepository.continueWatching,
mediaRepository.movies, appContentRepository.movies,
mediaRepository.episodes appContentRepository.episodes
) { list, moviesMap, episodesMap -> ) { list, moviesMap, episodesMap ->
list.mapNotNull { media -> list.mapNotNull { media ->
when (media) { when (media) {
@@ -82,8 +82,8 @@ class HomePageViewModel @Inject constructor(
) )
val nextUp = combine( val nextUp = combine(
mediaRepository.nextUp, appContentRepository.nextUp,
mediaRepository.episodes appContentRepository.episodes
) { list, episodesMap -> ) { list, episodesMap ->
list.mapNotNull { media -> list.mapNotNull { media ->
when (media) { when (media) {
@@ -100,10 +100,10 @@ class HomePageViewModel @Inject constructor(
) )
val latestLibraryContent = combine( val latestLibraryContent = combine(
mediaRepository.latestLibraryContent, appContentRepository.latestLibraryContent,
mediaRepository.movies, appContentRepository.movies,
mediaRepository.series, appContentRepository.series,
mediaRepository.episodes appContentRepository.episodes
) { libraryMap, moviesMap, seriesMap, episodesMap -> ) { libraryMap, moviesMap, seriesMap, episodesMap ->
libraryMap.mapValues { (_, items) -> libraryMap.mapValues { (_, items) ->
items.mapNotNull { media -> items.mapNotNull { media ->
@@ -131,7 +131,7 @@ class HomePageViewModel @Inject constructor(
) )
init { init {
viewModelScope.launch { mediaRepository.ensureReady() } viewModelScope.launch { appContentRepository.ensureReady() }
} }
fun onLibrarySelected(id: UUID, name: String) { fun onLibrarySelected(id: UUID, name: String) {

View File

@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.feature.shared.home.PosterItem 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.MovieDto
import hu.bbara.purefin.core.data.navigation.NavigationManager import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.Route
@@ -22,13 +22,13 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LibraryViewModel @Inject constructor( class LibraryViewModel @Inject constructor(
private val mediaRepository: MediaRepository, private val appContentRepository: AppContentRepository,
private val navigationManager: NavigationManager private val navigationManager: NavigationManager
) : ViewModel() { ) : ViewModel() {
private val selectedLibrary = MutableStateFlow<UUID?>(null) private val selectedLibrary = MutableStateFlow<UUID?>(null)
val contents: StateFlow<List<PosterItem>> = combine(selectedLibrary, mediaRepository.libraries) { val contents: StateFlow<List<PosterItem>> = combine(selectedLibrary, appContentRepository.libraries) {
libraryId, libraries -> libraryId, libraries ->
if (libraryId == null) { if (libraryId == null) {
return@combine emptyList() return@combine emptyList()
@@ -46,7 +46,7 @@ class LibraryViewModel @Inject constructor(
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
init { init {
viewModelScope.launch { mediaRepository.ensureReady() } viewModelScope.launch { appContentRepository.ensureReady() }
} }
fun onMovieSelected(movieId: UUID) { fun onMovieSelected(movieId: UUID) {