diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 22ff2f30..6784c276 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -176,7 +176,9 @@ import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.collection.CollectionManagementScreen import com.nuvio.app.features.collection.CollectionEditorScreen import com.nuvio.app.features.collection.CollectionEditorRepository +import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.collection.CollectionSyncService +import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsSyncService import com.nuvio.app.features.collection.FolderDetailScreen import com.nuvio.app.features.collection.FolderDetailRepository @@ -194,6 +196,7 @@ import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktListTab import com.nuvio.app.features.trakt.TraktScrobbleRepository +import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.updater.AppUpdaterHost import com.nuvio.app.features.updater.rememberAppUpdaterController import com.nuvio.app.features.watched.WatchedRepository @@ -206,10 +209,13 @@ import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.app_logo_wordmark @@ -350,10 +356,37 @@ private enum class AppGateScreen { Loading, Auth, ProfileSelection, + ProfileSwitching, ProfileEdit, Main, } +private data class PendingProfileSwitch( + val profile: NuvioProfile, + val syncOnEnter: Boolean, +) + +private suspend fun warmProfileBoundRepositories() { + withContext(Dispatchers.Default) { + AddonRepository.initialize() + CollectionRepository.initialize() + ContinueWatchingPreferencesRepository.ensureLoaded() + DownloadsRepository.ensureLoaded() + EpisodeReleaseNotificationsRepository.ensureLoaded() + HomeCatalogSettingsRepository.snapshot() + LibraryRepository.ensureLoaded() + P2pSettingsRepository.ensureLoaded() + PlayerSettingsRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() + WatchedRepository.ensureLoaded() + WatchProgressRepository.ensureLoaded() + CollectionSyncService.startObserving() + HomeCatalogSettingsSyncService.startObserving() + ProfileSettingsSync.startObserving() + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable @Preview @@ -416,6 +449,7 @@ fun App() { var editingProfile by remember { mutableStateOf(null) } var isNewProfile by remember { mutableStateOf(false) } var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) } + var pendingProfileSwitch by remember { mutableStateOf(null) } fun rememberedStartupProfile(profiles: List): NuvioProfile? { val currentProfileState = ProfileRepository.state.value @@ -431,6 +465,12 @@ fun App() { ?.takeUnless { it.pinEnabled } } + fun requestProfileSwitch(profile: NuvioProfile, syncOnEnter: Boolean) { + autoSkipProfileSelection = false + pendingProfileSwitch = PendingProfileSwitch(profile, syncOnEnter) + gateScreen = AppGateScreen.ProfileSwitching.name + } + fun enterProfileGate(profiles: List, syncOnEnter: Boolean) { if (profiles.isEmpty()) { autoSkipProfileSelection = true @@ -439,12 +479,7 @@ fun App() { } rememberedStartupProfile(profiles)?.let { profile -> - ProfileRepository.selectProfile(profile.profileIndex) - if (syncOnEnter) { - SyncManager.pullAllForProfile(profile.profileIndex) - } - gateScreen = AppGateScreen.Main.name - autoSkipProfileSelection = false + requestProfileSwitch(profile, syncOnEnter) return } @@ -455,18 +490,40 @@ fun App() { gateScreen = AppGateScreen.ProfileSelection.name return } - ProfileRepository.selectProfile(onlyProfile.profileIndex) - if (syncOnEnter) { - SyncManager.pullAllForProfile(onlyProfile.profileIndex) - } - gateScreen = AppGateScreen.Main.name - autoSkipProfileSelection = false + requestProfileSwitch(onlyProfile, syncOnEnter) } else { gateScreen = AppGateScreen.ProfileSelection.name } } + LaunchedEffect(gateScreen, pendingProfileSwitch) { + if (gateScreen == AppGateScreen.ProfileSwitching.name && pendingProfileSwitch == null) { + gateScreen = AppGateScreen.Loading.name + } + } + + LaunchedEffect(pendingProfileSwitch) { + val request = pendingProfileSwitch ?: return@LaunchedEffect + runCatching { + ProfileRepository.switchToProfile(request.profile.profileIndex) + warmProfileBoundRepositories() + if (request.syncOnEnter) { + SyncManager.pullAllForProfile(request.profile.profileIndex) + } + }.onSuccess { + pendingProfileSwitch = null + autoSkipProfileSelection = false + gateScreen = AppGateScreen.Main.name + }.onFailure { + pendingProfileSwitch = null + autoSkipProfileSelection = false + gateScreen = AppGateScreen.ProfileSelection.name + } + } + LaunchedEffect(authState, networkStatusUiState.condition, profileState.profiles) { + if (gateScreen == AppGateScreen.ProfileSwitching.name) return@LaunchedEffect + val cachedProfiles = profileState.profiles val allowOfflineProfileAccess = cachedProfiles.isNotEmpty() && @@ -519,10 +576,10 @@ fun App() { gateScreen == AppGateScreen.ProfileSelection.name ) { rememberedStartupProfile(profileState.profiles)?.let { profile -> - ProfileRepository.selectProfile(profile.profileIndex) - SyncManager.pullAllForProfile(profile.profileIndex) - gateScreen = AppGateScreen.Main.name - autoSkipProfileSelection = false + requestProfileSwitch( + profile = profile, + syncOnEnter = authState is AuthState.Authenticated, + ) return@LaunchedEffect } @@ -531,10 +588,10 @@ fun App() { val onlyProfile = profileState.profiles.first() if (onlyProfile.pinEnabled) return@LaunchedEffect - ProfileRepository.selectProfile(onlyProfile.profileIndex) - SyncManager.pullAllForProfile(onlyProfile.profileIndex) - gateScreen = AppGateScreen.Main.name - autoSkipProfileSelection = false + requestProfileSwitch( + profile = onlyProfile, + syncOnEnter = authState is AuthState.Authenticated, + ) } } @@ -547,15 +604,9 @@ fun App() { }, ) { currentGate -> when (currentGate) { - AppGateScreen.Loading.name -> { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.nuvio.colors.background), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(color = MaterialTheme.nuvio.colors.accent) - } + AppGateScreen.Loading.name, + AppGateScreen.ProfileSwitching.name -> { + AppLaunchOverlay(modifier = Modifier.fillMaxSize()) } AppGateScreen.Auth.name -> { AuthScreen(modifier = Modifier.fillMaxSize()) @@ -568,11 +619,10 @@ fun App() { } ProfileSelectionScreen( onProfileSelected = { profile -> - ProfileRepository.selectProfile(profile.profileIndex) - if (authState is AuthState.Authenticated) { - SyncManager.pullAllForProfile(profile.profileIndex) - } - gateScreen = AppGateScreen.Main.name + requestProfileSwitch( + profile = profile, + syncOnEnter = authState is AuthState.Authenticated, + ) }, onEditProfile = { profile -> editingProfile = profile @@ -618,18 +668,6 @@ private fun MainAppContent( ) { val navController = rememberNavController() val appUpdaterController = rememberAppUpdaterController() - remember { - EpisodeReleaseNotificationsRepository.ensureLoaded() - } - remember { - CollectionSyncService.startObserving() - } - remember { - HomeCatalogSettingsSyncService.startObserving() - } - remember { - ProfileSettingsSync.startObserving() - } val hapticFeedback = LocalHapticFeedback.current val coroutineScope = rememberCoroutineScope() var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } @@ -638,6 +676,10 @@ private fun MainAppContent( val searchScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val libraryScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val settingsRootActionRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } + + LaunchedEffect(Unit) { + warmProfileBoundRepositories() + } val currentBackStackEntry by navController.currentBackStackEntryAsState() val liquidGlassNativeTabBarEnabled by remember { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled @@ -654,32 +696,14 @@ private fun MainAppContent( var pickerMembership by remember { mutableStateOf>(emptyMap()) } var pickerPending by remember { mutableStateOf(false) } var pickerError by remember { mutableStateOf(null) } - val addonsUiState by remember { - AddonRepository.initialize() - AddonRepository.uiState - }.collectAsStateWithLifecycle() - val libraryUiState by remember { - LibraryRepository.ensureLoaded() - LibraryRepository.uiState - }.collectAsStateWithLifecycle() + val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() + val libraryUiState by LibraryRepository.uiState.collectAsStateWithLifecycle() val authState by AuthRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle() - val playerSettingsUiState by remember { - PlayerSettingsRepository.ensureLoaded() - PlayerSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val p2pSettingsUiState by remember { - P2pSettingsRepository.ensureLoaded() - P2pSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val watchedUiState by remember { - WatchedRepository.ensureLoaded() - WatchedRepository.uiState - }.collectAsStateWithLifecycle() - val downloadsUiState by remember { - DownloadsRepository.ensureLoaded() - DownloadsRepository.uiState - }.collectAsStateWithLifecycle() + val playerSettingsUiState by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle() + val p2pSettingsUiState by P2pSettingsRepository.uiState.collectAsStateWithLifecycle() + val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() + val downloadsUiState by DownloadsRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by remember { NetworkStatusRepository.uiState }.collectAsStateWithLifecycle() @@ -913,10 +937,7 @@ private fun MainAppContent( } } } - val continueWatchingPreferencesUiState by remember { - ContinueWatchingPreferencesRepository.ensureLoaded() - ContinueWatchingPreferencesRepository.uiState - }.collectAsStateWithLifecycle() + val continueWatchingPreferencesUiState by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() LaunchedEffect( initialHomeReady, @@ -1339,8 +1360,16 @@ private fun MainAppContent( val onProfileSelected: (NuvioProfile) -> Unit = { profile -> profileSwitchLoading = true selectedTab = AppScreenTab.Home - ProfileRepository.selectProfile(profile.profileIndex) - com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex) + coroutineScope.launch { + try { + ProfileRepository.switchToProfile(profile.profileIndex) + warmProfileBoundRepositories() + SyncManager.pullAllForProfile(profile.profileIndex) + delay(300) + } finally { + profileSwitchLoading = false + } + } } Scaffold( @@ -1723,10 +1752,7 @@ private fun MainAppContent( hasResolvedVideoId = true } - val playerSettings by remember { - PlayerSettingsRepository.ensureLoaded() - PlayerSettingsRepository.uiState - }.collectAsStateWithLifecycle() + val playerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle() fun p2pSentinelUrl(infoHash: String, fileIdx: Int?): String = "torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}" @@ -2751,15 +2777,6 @@ private fun MainAppContent( AppLaunchOverlay(modifier = Modifier.fillMaxSize()) } - // Auto-dismiss profile switch overlay - if (profileSwitchLoading) { - LaunchedEffect(Unit) { - // Brief loading screen while home refreshes for the new profile - kotlinx.coroutines.delay(1200) - profileSwitchLoading = false - } - } - NuvioFloatingPrompt( visible = resumePromptItem != null, imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 0d90dfad..5668d2cc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -104,19 +104,21 @@ fun HomeScreen( onFirstCatalogRendered: (() -> Unit)? = null, ) { LaunchedEffect(Unit) { - AddonRepository.initialize() - CollectionRepository.initialize() - ContinueWatchingPreferencesRepository.ensureLoaded() - WatchedRepository.ensureLoaded() - WatchProgressRepository.ensureLoaded() + withContext(Dispatchers.Default) { + AddonRepository.initialize() + CollectionRepository.initialize() + ContinueWatchingPreferencesRepository.ensureLoaded() + HomeCatalogSettingsRepository.snapshot() + TraktSettingsRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded() + WatchedRepository.ensureLoaded() + WatchProgressRepository.ensureLoaded() + } } val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle() - val homeSettingsUiState by remember { - HomeCatalogSettingsRepository.snapshot() - HomeCatalogSettingsRepository.uiState - }.collectAsStateWithLifecycle() + val homeSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() val homeListState = rememberLazyListState() val collections by CollectionRepository.collections.collectAsStateWithLifecycle() val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() @@ -124,14 +126,8 @@ fun HomeScreen( val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle() val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() - val traktSettingsUiState by remember { - TraktSettingsRepository.ensureLoaded() - TraktSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val isTraktAuthenticated by remember { - TraktAuthRepository.ensureLoaded() - TraktAuthRepository.isAuthenticated - }.collectAsStateWithLifecycle() + val traktSettingsUiState by TraktSettingsRepository.uiState.collectAsStateWithLifecycle() + val isTraktAuthenticated by TraktAuthRepository.isAuthenticated.collectAsStateWithLifecycle() var observedOfflineState by remember { mutableStateOf(false) } LaunchedEffect(scrollToTopRequests) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index ab6b5b2b..c2033993 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -38,6 +38,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -62,6 +65,7 @@ object ProfileRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("ProfileRepository") private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private val profileSwitchMutex = Mutex() private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } private val _state = MutableStateFlow(ProfileState()) @@ -141,7 +145,15 @@ object ProfileRepository { } } - fun selectProfile(profileIndex: Int) { + suspend fun switchToProfile(profileIndex: Int) { + profileSwitchMutex.withLock { + withContext(Dispatchers.Default) { + selectProfile(profileIndex) + } + } + } + + private fun selectProfile(profileIndex: Int) { activeProfileIndex = profileIndex val selectedProfile = _state.value.profiles.find { it.profileIndex == profileIndex } _state.value = _state.value.copy( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt index 2dfdb2f4..da6c96b9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt @@ -176,7 +176,6 @@ fun ProfileSelectionScreen( } else if (profile.pinEnabled) { pinDialogProfile = profile } else { - ProfileRepository.selectProfile(profile.profileIndex) onProfileSelected(profile) } }, @@ -217,7 +216,6 @@ fun ProfileSelectionScreen( } else if (profile.pinEnabled) { pinDialogProfile = profile } else { - ProfileRepository.selectProfile(profile.profileIndex) onProfileSelected(profile) } }, @@ -282,7 +280,6 @@ fun ProfileSelectionScreen( onVerify = { pin -> ProfileRepository.verifyPin(profile.profileIndex, pin) }, onVerified = { pinDialogProfile = null - ProfileRepository.selectProfile(profile.profileIndex) onProfileSelected(profile) }, onDismiss = { pinDialogProfile = null }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt index 13e2ebd5..b1a681b5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt @@ -8,7 +8,6 @@ import com.nuvio.app.features.trakt.parseTraktIsoDateTimeToEpochMs import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource -import co.touchlab.kermit.Logger @Composable fun computeAirDateBadgeText( @@ -67,46 +66,32 @@ class ReleaseAlertState( val isNewSeasonRelease: Boolean, ) +private const val ReleaseAlertWindowMs = 60L * 24 * 60 * 60 * 1000 +private val NoReleaseAlertState = ReleaseAlertState(false, false) + fun calculateReleaseAlertState( seedLastUpdatedEpochMs: Long, seedSeasonNumber: Int?, nextSeasonNumber: Int?, releasedIso: String?, ): ReleaseAlertState { + if (releasedIso.isNullOrBlank()) return NoReleaseAlertState + val releaseEpoch = parseReleaseDateToEpochMs(releasedIso) + ?: return NoReleaseAlertState + val nowMs = WatchProgressClock.nowEpochMs() + if (nowMs < releaseEpoch) return NoReleaseAlertState + if (releaseEpoch <= seedLastUpdatedEpochMs) return NoReleaseAlertState + if (nowMs - releaseEpoch >= ReleaseAlertWindowMs) return NoReleaseAlertState - val log = Logger.withTag("ReleaseAlert") - log.d { - "calculateReleaseAlertState inputs: releasedIso=$releasedIso, " + - "releaseEpoch=$releaseEpoch, seedLastUpdatedEpochMs=$seedLastUpdatedEpochMs, " + - "seedSeasonNumber=$seedSeasonNumber, nextSeasonNumber=$nextSeasonNumber, nowMs=$nowMs" - } - - if (releaseEpoch == null) { - log.d { "calculateReleaseAlertState failed: releaseEpoch is null" } - return ReleaseAlertState(false, false) - } - - val hasAired = nowMs >= releaseEpoch - val sixtyDaysMs = 60L * 24 * 60 * 60 * 1000 - val isReleaseAlert = hasAired && - releaseEpoch > seedLastUpdatedEpochMs && - (nowMs - releaseEpoch) < sixtyDaysMs - - val isNewSeasonRelease = isReleaseAlert && + val isNewSeasonRelease = seedSeasonNumber != null && nextSeasonNumber != null && nextSeasonNumber != seedSeasonNumber - log.d { - "calculateReleaseAlertState result: isReleaseAlert=$isReleaseAlert (hasAired=$hasAired, " + - "epoch>seed=${releaseEpoch > seedLastUpdatedEpochMs}, ageMs=${nowMs - releaseEpoch}), " + - "isNewSeasonRelease=$isNewSeasonRelease" - } - return ReleaseAlertState( - isReleaseAlert = isReleaseAlert, + isReleaseAlert = true, isNewSeasonRelease = isNewSeasonRelease ) } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt index 6d99bed3..413e7441 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt @@ -2,10 +2,6 @@ package com.nuvio.app import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.window.Window @@ -48,15 +44,13 @@ fun main() { if (smokePlayerUrl == null) { App() } else { - var error by remember { mutableStateOf(null) } PlatformPlayerSurface( sourceUrl = smokePlayerUrl, modifier = Modifier.fillMaxSize(), onControllerReady = {}, onSnapshot = {}, - onError = { error = it }, + onError = {}, ) - error?.let { println("Nuvio desktop player smoke error: $it") } } } }