feat: add online/offline mode toggle to HomeScreen

Implements a toggle switch in the HomeScreen top bar (next to Menu) that allows users to switch between online mode (fetching from Jellyfin server) and offline mode (using local database only). The preference persists across app restarts via Proto DataStore.

Key changes:
- Added ActiveMediaRepository that delegates to online/offline repositories based on user preference
- Extended MediaRepository interface with continueWatching, nextUp, and latestLibraryContent
- Added isOfflineMode state to UserSession with reactive Flow
- Added Cloud/CloudOff icon toggle button to HomeTopBar
- Updated ViewModels to use MediaRepository interface for better abstraction
This commit is contained in:
2026-02-16 16:01:23 +01:00
parent c2a0eb60c8
commit 98042b97ed
14 changed files with 151 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<MediaRepository> =
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<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 val state: StateFlow<MediaRepositoryState> =
activeRepository.flatMapLatest { it.state }
.stateIn(scope, SharingStarted.Eagerly, MediaRepositoryState.Loading)
override val continueWatching: StateFlow<List<Media>> =
activeRepository.flatMapLatest { it.continueWatching }
.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val nextUp: StateFlow<List<Media>> =
activeRepository.flatMapLatest { it.nextUp }
.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> =
activeRepository.flatMapLatest { it.latestLibraryContent }
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> =
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()
}
}

View File

@@ -65,13 +65,13 @@ class InMemoryMediaRepository @Inject constructor(
}
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow()
override val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow()
private val _nextUp: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
val nextUp: StateFlow<List<Media>> = _nextUp.asStateFlow()
override val nextUp: StateFlow<List<Media>> = _nextUp.asStateFlow()
private val _latestLibraryContent: MutableStateFlow<Map<UUID, List<Media>>> = MutableStateFlow(emptyMap())
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = _latestLibraryContent.asStateFlow()
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = _latestLibraryContent.asStateFlow()
init {
scope.launch {

View File

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

View File

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

View File

@@ -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<Map<UUID, Episode>> = localDataSource.episodesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
// Offline mode doesn't support these server-side features
override val continueWatching: StateFlow<List<Media>> = MutableStateFlow(emptyList())
override val nextUp: StateFlow<List<Media>> = MutableStateFlow(emptyList())
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = MutableStateFlow(emptyMap())
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
return localDataSource.observeSeriesWithContent(seriesId)
}

View File

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

View File

@@ -49,4 +49,12 @@ class UserSessionRepository @Inject constructor(
it.copy(loggedIn = isLoggedIn)
}
}
val isOfflineMode: Flow<Boolean> = session.map { it.isOfflineMode }.distinctUntilChanged()
suspend fun setOfflineMode(isOffline: Boolean) {
userSessionDataStore.updateData {
it.copy(isOfflineMode = isOffline)
}
}
}

View File

@@ -9,7 +9,7 @@ import java.io.OutputStream
object UserSessionSerializer : Serializer<UserSession> {
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 {