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
@Singleton
class InMemoryMediaRepository @Inject constructor(
class InMemoryAppContentRepository @Inject constructor(
val userSessionRepository: UserSessionRepository,
val jellyfinApiClient: JellyfinApiClient,
private val homeCacheDataStore: DataStore<HomeCache>
) : MediaRepository {
) : AppContentRepository {
private val ready = CompletableDeferred<Unit>()
private val readyMutex = Mutex()

View File

@@ -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<List<Library>>
val movies: StateFlow<Map<UUID, Movie>>
val series: StateFlow<Map<UUID, Series>>
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?>
suspend fun ensureReady()
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 {
@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
class OfflineMediaRepository @Inject constructor(
private val localDataSource: OfflineRoomMediaLocalDataSource
) {
) : MediaRepository {
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())
val series: StateFlow<Map<UUID, Series>> = localDataSource.seriesFlow
override val series: StateFlow<Map<UUID, Series>> = localDataSource.seriesFlow
.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())
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
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

View File

@@ -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()

View File

@@ -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()
}

View File

@@ -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() {

View File

@@ -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) {

View File

@@ -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<UUID?>(null)
val contents: StateFlow<List<PosterItem>> = combine(selectedLibrary, mediaRepository.libraries) {
val contents: StateFlow<List<PosterItem>> = 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) {