diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 2649c7996..bc5fbbb9e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index a29815322..a17b2c6f3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -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.", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index a4eccf17c..ecd44cb14 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index e58888c56..3563bcf63 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -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() + + val oldLocal = entriesByVideoId.toMap() + val newMap = mutableMapOf() + 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,