feat: Enhance profile management with profile switching functionality

- Updated MainAppContent to support profile switching.
- Added onSwitchProfile callback to SettingsScreen and related components.
- Introduced a new "Switch Profile" option in the settings menu for easier profile management.
- Improved watch progress synchronization by resolving remote metadata for entries.
This commit is contained in:
tapframe 2026-03-28 19:46:51 +05:30
parent b8f4afce5d
commit d9fd0e49d5
4 changed files with 89 additions and 5 deletions

View file

@ -204,7 +204,11 @@ fun App() {
)
}
AppGateScreen.Main.name -> {
MainAppContent()
MainAppContent(
onSwitchProfile = {
gateScreen = AppGateScreen.ProfileSelection.name
},
)
}
}
}
@ -213,7 +217,9 @@ fun App() {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MainAppContent() {
private fun MainAppContent(
onSwitchProfile: () -> Unit = {},
) {
val navController = rememberNavController()
val hapticFeedback = LocalHapticFeedback.current
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
@ -360,6 +366,7 @@ private fun MainAppContent() {
},
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
onSwitchProfile = onSwitchProfile,
)
}
@ -514,6 +521,7 @@ private fun AppTabHost(
onLibraryPosterClick: ((LibraryItem) -> Unit)? = null,
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null,
onSwitchProfile: (() -> Unit)? = null,
) {
Box(modifier = modifier.fillMaxSize()) {
keepAliveTab(
@ -550,6 +558,7 @@ private fun AppTabHost(
) {
SettingsScreen(
modifier = Modifier.fillMaxSize(),
onSwitchProfile = onSwitchProfile,
)
}
}

View file

@ -5,6 +5,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AccountCircle
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.People
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Sync
@ -15,6 +16,7 @@ internal fun LazyListScope.settingsRootContent(
onContentDiscoveryClick: () -> Unit,
onAccountClick: () -> Unit,
onSyncOverviewClick: () -> Unit,
onSwitchProfileClick: (() -> Unit)? = null,
) {
item {
SettingsSection(
@ -49,6 +51,15 @@ internal fun LazyListScope.settingsRootContent(
title = "ACCOUNT & SYNC",
isTablet = isTablet,
) {
if (onSwitchProfileClick != null) {
SettingsNavigationRow(
title = "Switch Profile",
description = "Change to a different profile.",
icon = Icons.Rounded.People,
isTablet = isTablet,
onClick = onSwitchProfileClick,
)
}
SettingsNavigationRow(
title = "Account",
description = "Manage your account, sign out, or delete.",

View file

@ -46,6 +46,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
@Composable
fun SettingsScreen(
modifier: Modifier = Modifier,
onSwitchProfile: (() -> Unit)? = null,
) {
BoxWithConstraints(
modifier = modifier
@ -89,6 +90,7 @@ fun SettingsScreen(
showLoadingOverlay = playerSettingsUiState.showLoadingOverlay,
continueWatchingVisible = continueWatchingPreferencesUiState.isVisible,
continueWatchingStyle = continueWatchingPreferencesUiState.style,
onSwitchProfile = onSwitchProfile,
)
} else {
MobileSettingsScreen(
@ -99,6 +101,7 @@ fun SettingsScreen(
showLoadingOverlay = playerSettingsUiState.showLoadingOverlay,
continueWatchingVisible = continueWatchingPreferencesUiState.isVisible,
continueWatchingStyle = continueWatchingPreferencesUiState.style,
onSwitchProfile = onSwitchProfile,
)
}
}
@ -113,6 +116,7 @@ private fun MobileSettingsScreen(
showLoadingOverlay: Boolean,
continueWatchingVisible: Boolean,
continueWatchingStyle: ContinueWatchingSectionStyle,
onSwitchProfile: (() -> Unit)? = null,
) {
NuvioScreen {
stickyHeader {
@ -131,6 +135,7 @@ private fun MobileSettingsScreen(
onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
onAccountClick = { onPageChange(SettingsPage.Account) },
onSyncOverviewClick = { onPageChange(SettingsPage.SyncOverview) },
onSwitchProfileClick = onSwitchProfile,
)
SettingsPage.Playback -> playbackSettingsContent(
isTablet = false,
@ -175,6 +180,7 @@ private fun TabletSettingsScreen(
showLoadingOverlay: Boolean,
continueWatchingVisible: Boolean,
continueWatchingStyle: ContinueWatchingSectionStyle,
onSwitchProfile: (() -> Unit)? = null,
) {
var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) }
val activeCategory = SettingsCategory.valueOf(selectedCategory)
@ -242,6 +248,7 @@ private fun TabletSettingsScreen(
onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
onAccountClick = { onPageChange(SettingsPage.Account) },
onSyncOverviewClick = { onPageChange(SettingsPage.SyncOverview) },
onSwitchProfileClick = onSwitchProfile,
)
SettingsPage.Playback -> playbackSettingsContent(
isTablet = true,

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.watchprogress
import co.touchlab.kermit.Logger
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.player.PlayerPlaybackSnapshot
import com.nuvio.app.features.profiles.ProfileRepository
import io.github.jan.supabase.postgrest.postgrest
@ -62,30 +63,86 @@ object WatchProgressRepository {
val params = buildJsonObject { put("p_profile_id", profileId) }
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params)
val serverEntries = result.decodeList<WatchProgressSyncEntry>()
val oldLocal = entriesByVideoId.toMap()
val newMap = mutableMapOf<String, WatchProgressEntry>()
serverEntries.forEach { entry ->
val videoId = entry.videoId
entriesByVideoId[videoId] = WatchProgressEntry(
val cached = oldLocal[videoId]
newMap[videoId] = WatchProgressEntry(
contentType = entry.contentType,
parentMetaId = entry.contentId,
parentMetaType = entry.contentType,
parentMetaType = cached?.parentMetaType ?: entry.contentType,
videoId = videoId,
title = "",
title = cached?.title?.takeIf { it.isNotBlank() } ?: entry.contentId,
logo = cached?.logo,
poster = cached?.poster,
background = cached?.background,
seasonNumber = entry.season,
episodeNumber = entry.episode,
episodeTitle = cached?.episodeTitle,
episodeThumbnail = cached?.episodeThumbnail,
lastPositionMs = entry.position,
durationMs = entry.duration,
lastUpdatedEpochMs = entry.lastWatched,
providerName = cached?.providerName,
providerAddonId = cached?.providerAddonId,
lastStreamTitle = cached?.lastStreamTitle,
lastStreamSubtitle = cached?.lastStreamSubtitle,
lastSourceUrl = cached?.lastSourceUrl,
isCompleted = entry.duration > 0 && entry.position >= entry.duration,
)
}
entriesByVideoId = newMap
hasLoaded = true
publish()
persist()
resolveRemoteMetadata()
}.onFailure { e ->
log.e(e) { "Failed to pull watch progress from server" }
}
}
private fun resolveRemoteMetadata() {
val needsResolution = entriesByVideoId.values
.filter { it.poster == null && it.background == null }
.groupBy { it.parentMetaId to it.contentType }
if (needsResolution.isEmpty()) return
syncScope.launch {
for ((key, entries) in needsResolution) {
val (metaId, metaType) = key
val meta = runCatching {
MetaDetailsRepository.fetch(metaType, metaId)
}.getOrNull() ?: continue
for (entry in entries) {
val episodeVideo = if (entry.seasonNumber != null && entry.episodeNumber != null) {
meta.videos.find { v ->
v.season == entry.seasonNumber && v.episode == entry.episodeNumber
}
} else null
entriesByVideoId[entry.videoId] = entry.copy(
title = meta.name,
poster = meta.poster,
background = meta.background,
logo = meta.logo,
episodeTitle = episodeVideo?.title ?: entry.episodeTitle,
episodeThumbnail = episodeVideo?.thumbnail ?: entry.episodeThumbnail,
)
}
publish()
}
persist()
}
}
fun upsertPlaybackProgress(
session: WatchProgressPlaybackSession,
snapshot: PlayerPlaybackSnapshot,