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 365548c..f5295fe 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 @@ -3,7 +3,7 @@ package hu.bbara.purefin.app.content.episode import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import hu.bbara.purefin.data.InMemoryMediaRepository +import hu.bbara.purefin.data.MediaRepository import hu.bbara.purefin.data.model.Episode import hu.bbara.purefin.navigation.NavigationManager import kotlinx.coroutines.flow.MutableStateFlow @@ -17,7 +17,7 @@ import javax.inject.Inject @HiltViewModel class EpisodeScreenViewModel @Inject constructor( - private val mediaRepository: InMemoryMediaRepository, + private val mediaRepository: MediaRepository, private val navigationManager: NavigationManager, ): ViewModel() { 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 ce34d1b..f5c8295 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 @@ -3,7 +3,7 @@ package hu.bbara.purefin.app.content.series import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import hu.bbara.purefin.data.InMemoryMediaRepository +import hu.bbara.purefin.data.MediaRepository import hu.bbara.purefin.data.model.Series import hu.bbara.purefin.navigation.EpisodeDto import hu.bbara.purefin.navigation.NavigationManager @@ -21,7 +21,7 @@ import javax.inject.Inject @HiltViewModel class SeriesViewModel @Inject constructor( - private val mediaRepository: InMemoryMediaRepository, + private val mediaRepository: MediaRepository, private val navigationManager: NavigationManager, ) : ViewModel() { diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt index 1118939..b54cd8f 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt @@ -37,6 +37,7 @@ fun HomePage( val coroutineScope = rememberCoroutineScope() val libraries = viewModel.libraries.collectAsState().value + val isOfflineMode = viewModel.isOfflineMode.collectAsState().value val libraryNavItems = libraries.map { HomeNavItem( id = it.id, @@ -85,7 +86,9 @@ fun HomePage( contentColor = MaterialTheme.colorScheme.onBackground, topBar = { HomeTopBar( - onMenuClick = { coroutineScope.launch { drawerState.open() } } + onMenuClick = { coroutineScope.launch { drawerState.open() } }, + isOfflineMode = isOfflineMode, + onToggleOfflineMode = viewModel::toggleOfflineMode ) } ) { innerPadding -> 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 2e9eba1..a387009 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 @@ -9,7 +9,7 @@ import hu.bbara.purefin.app.home.ui.LibraryItem import hu.bbara.purefin.app.home.ui.NextUpItem 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.MediaRepository import hu.bbara.purefin.data.model.Media import hu.bbara.purefin.domain.usecase.RefreshHomeDataUseCase import hu.bbara.purefin.image.JellyfinImageHelper @@ -34,7 +34,7 @@ import javax.inject.Inject @HiltViewModel class HomePageViewModel @Inject constructor( - private val mediaRepository: InMemoryMediaRepository, + private val mediaRepository: MediaRepository, private val userSessionRepository: UserSessionRepository, private val navigationManager: NavigationManager, private val jellyfinApiClient: JellyfinApiClient, @@ -50,6 +50,12 @@ class HomePageViewModel @Inject constructor( private val _libraries = MutableStateFlow>(emptyList()) val libraries = _libraries.asStateFlow() + val isOfflineMode = userSessionRepository.isOfflineMode.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = false + ) + init { viewModelScope.launch { loadLibraries() @@ -213,4 +219,10 @@ class HomePageViewModel @Inject constructor( } } + fun toggleOfflineMode() { + viewModelScope.launch { + userSessionRepository.setOfflineMode(!isOfflineMode.value) + } + } + } diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTopBar.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTopBar.kt index 2ec791f..dfce61d 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTopBar.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTopBar.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Cloud +import androidx.compose.material.icons.outlined.CloudOff import androidx.compose.material.icons.outlined.Menu import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -20,6 +22,8 @@ import hu.bbara.purefin.common.ui.components.PurefinIconButton @Composable fun HomeTopBar( onMenuClick: () -> Unit, + isOfflineMode: Boolean, + onToggleOfflineMode: () -> Unit, modifier: Modifier = Modifier, ) { val scheme = MaterialTheme.colorScheme @@ -45,6 +49,13 @@ fun HomeTopBar( onClick = onMenuClick ) } + Row(verticalAlignment = Alignment.CenterVertically) { + PurefinIconButton( + icon = if (isOfflineMode) Icons.Outlined.CloudOff else Icons.Outlined.Cloud, + contentDescription = if (isOfflineMode) "Switch to Online" else "Switch to Offline", + onClick = onToggleOfflineMode + ) + } } } } 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 b943f2b..7533593 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 @@ -5,7 +5,7 @@ import androidx.lifecycle.viewModelScope 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.MediaRepository import hu.bbara.purefin.data.model.Media import hu.bbara.purefin.image.JellyfinImageHelper import hu.bbara.purefin.navigation.MovieDto @@ -26,7 +26,7 @@ import javax.inject.Inject @HiltViewModel class LibraryViewModel @Inject constructor( - private val mediaRepository: InMemoryMediaRepository, + private val mediaRepository: MediaRepository, private val userSessionRepository: UserSessionRepository, private val jellyfinApiClient: JellyfinApiClient, private val navigationManager: NavigationManager diff --git a/app/src/main/java/hu/bbara/purefin/data/ActiveMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/ActiveMediaRepository.kt new file mode 100644 index 0000000..a30d28e --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/ActiveMediaRepository.kt @@ -0,0 +1,89 @@ +package hu.bbara.purefin.data + +import hu.bbara.purefin.data.local.room.OfflineRepository +import hu.bbara.purefin.data.local.room.OnlineRepository +import hu.bbara.purefin.data.model.Episode +import hu.bbara.purefin.data.model.Media +import hu.bbara.purefin.data.model.Movie +import hu.bbara.purefin.data.model.Series +import hu.bbara.purefin.session.UserSessionRepository +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.map +import kotlinx.coroutines.flow.stateIn +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Active media repository that delegates to either online or offline repository + * based on user preference. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Singleton +class ActiveMediaRepository @Inject constructor( + @OnlineRepository private val onlineRepository: MediaRepository, + @OfflineRepository private val offlineRepository: MediaRepository, + private val userSessionRepository: UserSessionRepository +) : MediaRepository { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + // Switch between repositories based on offline mode preference + private val activeRepository: StateFlow = + userSessionRepository.isOfflineMode + .map { isOffline -> + if (isOffline) offlineRepository else onlineRepository + } + .stateIn(scope, SharingStarted.Eagerly, onlineRepository) + + // Delegate all MediaRepository interface methods to the active repository + 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 val state: StateFlow = + activeRepository.flatMapLatest { it.state } + .stateIn(scope, SharingStarted.Eagerly, MediaRepositoryState.Loading) + + override val continueWatching: StateFlow> = + activeRepository.flatMapLatest { it.continueWatching } + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + override val nextUp: StateFlow> = + activeRepository.flatMapLatest { it.nextUp } + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + override val latestLibraryContent: StateFlow>> = + activeRepository.flatMapLatest { it.latestLibraryContent } + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + override fun observeSeriesWithContent(seriesId: UUID): Flow = + activeRepository.flatMapLatest { it.observeSeriesWithContent(seriesId) } + + override suspend fun ensureReady() { + activeRepository.value.ensureReady() + } + + override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) { + activeRepository.value.updateWatchProgress(mediaId, positionMs, durationMs) + } + + override suspend fun refreshHomeData() { + activeRepository.value.refreshHomeData() + } +} 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 ec934c0..ae163cd 100644 --- a/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt @@ -65,13 +65,13 @@ class InMemoryMediaRepository @Inject constructor( } private val _continueWatching: MutableStateFlow> = MutableStateFlow(emptyList()) - val continueWatching: StateFlow> = _continueWatching.asStateFlow() + override val continueWatching: StateFlow> = _continueWatching.asStateFlow() private val _nextUp: MutableStateFlow> = MutableStateFlow(emptyList()) - val nextUp: StateFlow> = _nextUp.asStateFlow() + override val nextUp: StateFlow> = _nextUp.asStateFlow() private val _latestLibraryContent: MutableStateFlow>> = MutableStateFlow(emptyMap()) - val latestLibraryContent: StateFlow>> = _latestLibraryContent.asStateFlow() + override val latestLibraryContent: StateFlow>> = _latestLibraryContent.asStateFlow() init { scope.launch { 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 d78f542..82b8b84 100644 --- a/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt @@ -1,6 +1,7 @@ package hu.bbara.purefin.data import hu.bbara.purefin.data.model.Episode +import hu.bbara.purefin.data.model.Media import hu.bbara.purefin.data.model.Movie import hu.bbara.purefin.data.model.Series import kotlinx.coroutines.flow.Flow @@ -14,6 +15,10 @@ interface MediaRepository { val episodes: StateFlow> val state: StateFlow + val continueWatching: StateFlow> + val nextUp: StateFlow> + val latestLibraryContent: StateFlow>> + fun observeSeriesWithContent(seriesId: UUID): Flow suspend fun ensureReady() diff --git a/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryModule.kt b/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryModule.kt index d526b99..dc1c5f9 100644 --- a/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryModule.kt +++ b/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryModule.kt @@ -19,7 +19,7 @@ abstract class MediaRepositoryModule { @OfflineRepository abstract fun bindOfflineMediaRepository(impl: OfflineMediaRepository): MediaRepository - // Default binding for backward compatibility (uses online repository) + // Default binding delegates to online/offline based on user preference @Binds - abstract fun bindDefaultMediaRepository(impl: InMemoryMediaRepository): MediaRepository + abstract fun bindDefaultMediaRepository(impl: ActiveMediaRepository): MediaRepository } diff --git a/app/src/main/java/hu/bbara/purefin/data/OfflineMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/OfflineMediaRepository.kt index 075a2b7..b677004 100644 --- a/app/src/main/java/hu/bbara/purefin/data/OfflineMediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/OfflineMediaRepository.kt @@ -3,6 +3,7 @@ package hu.bbara.purefin.data import hu.bbara.purefin.data.local.room.OfflineDatabase import hu.bbara.purefin.data.local.room.OfflineRoomMediaLocalDataSource import hu.bbara.purefin.data.model.Episode +import hu.bbara.purefin.data.model.Media import hu.bbara.purefin.data.model.Movie import hu.bbara.purefin.data.model.Series import kotlinx.coroutines.CoroutineScope @@ -42,6 +43,11 @@ class OfflineMediaRepository @Inject constructor( override val episodes: StateFlow> = localDataSource.episodesFlow .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + // Offline mode doesn't support these server-side features + override val continueWatching: StateFlow> = MutableStateFlow(emptyList()) + override val nextUp: StateFlow> = MutableStateFlow(emptyList()) + override val latestLibraryContent: StateFlow>> = MutableStateFlow(emptyMap()) + override fun observeSeriesWithContent(seriesId: UUID): Flow { return localDataSource.observeSeriesWithContent(seriesId) } diff --git a/app/src/main/java/hu/bbara/purefin/session/UserSession.kt b/app/src/main/java/hu/bbara/purefin/session/UserSession.kt index ffaf8a1..3622a00 100644 --- a/app/src/main/java/hu/bbara/purefin/session/UserSession.kt +++ b/app/src/main/java/hu/bbara/purefin/session/UserSession.kt @@ -10,5 +10,6 @@ data class UserSession( val url: String, @Serializable(with = UUIDSerializer::class) val userId: UUID?, - val loggedIn: Boolean + val loggedIn: Boolean, + val isOfflineMode: Boolean = false ) diff --git a/app/src/main/java/hu/bbara/purefin/session/UserSessionRepository.kt b/app/src/main/java/hu/bbara/purefin/session/UserSessionRepository.kt index 6f59128..de57eff 100644 --- a/app/src/main/java/hu/bbara/purefin/session/UserSessionRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/session/UserSessionRepository.kt @@ -49,4 +49,12 @@ class UserSessionRepository @Inject constructor( it.copy(loggedIn = isLoggedIn) } } + + val isOfflineMode: Flow = session.map { it.isOfflineMode }.distinctUntilChanged() + + suspend fun setOfflineMode(isOffline: Boolean) { + userSessionDataStore.updateData { + it.copy(isOfflineMode = isOffline) + } + } } diff --git a/app/src/main/java/hu/bbara/purefin/session/UserSessionSerializer.kt b/app/src/main/java/hu/bbara/purefin/session/UserSessionSerializer.kt index 8b779a3..35ac4fa 100644 --- a/app/src/main/java/hu/bbara/purefin/session/UserSessionSerializer.kt +++ b/app/src/main/java/hu/bbara/purefin/session/UserSessionSerializer.kt @@ -9,7 +9,7 @@ import java.io.OutputStream object UserSessionSerializer : Serializer { override val defaultValue: UserSession - get() = UserSession(accessToken = "", url = "", loggedIn = false, userId = null) + get() = UserSession(accessToken = "", url = "", loggedIn = false, userId = null, isOfflineMode = false) override suspend fun readFrom(input: InputStream): UserSession { try {