package com.nuvio.app import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory import coil3.request.CachePolicy import coil3.request.crossfade import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.deeplink.AppDeepLink import com.nuvio.app.core.deeplink.AppDeepLinkRepository import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.sync.AppForegroundMonitor import com.nuvio.app.core.sync.ProfileSettingsSync import com.nuvio.app.core.sync.SyncManager import com.nuvio.app.core.ui.NuvioNavigationBar import com.nuvio.app.core.ui.NuvioContinueWatchingActionSheet import com.nuvio.app.core.ui.NuvioPosterActionSheet import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.PlatformBackHandler import com.nuvio.app.core.ui.platformExitApp import com.nuvio.app.core.ui.configurePlatformImageLoader import com.nuvio.app.core.ui.NuvioToastHost import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.NuvioFloatingPrompt import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.NuvioTheme import com.nuvio.app.features.auth.AuthScreen import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogScreen import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL import com.nuvio.app.features.downloads.DownloadsRepository import com.nuvio.app.features.downloads.DownloadsScreen import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsScreen import com.nuvio.app.features.details.MetaPerson import com.nuvio.app.features.details.PersonDetailScreen import com.nuvio.app.features.details.TmdbEntityBrowseScreen import com.nuvio.app.features.tmdb.TmdbEntityKind import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.HomeScreen import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.LibrarySection import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.library.LibraryScreen import com.nuvio.app.features.library.toLibraryItem import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository import com.nuvio.app.features.player.PlayerLaunch import com.nuvio.app.features.player.PlayerLaunchStore import com.nuvio.app.features.player.PlayerRoute import com.nuvio.app.features.player.PlayerScreen import com.nuvio.app.features.player.sanitizePlaybackHeaders import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders import com.nuvio.app.features.profiles.NuvioProfile import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileSelectionScreen import com.nuvio.app.features.profiles.ProfileSwitcherTab import com.nuvio.app.features.search.SearchScreen import com.nuvio.app.features.settings.SettingsScreen import com.nuvio.app.features.settings.HomescreenSettingsScreen import com.nuvio.app.features.settings.MetaScreenSettingsScreen import com.nuvio.app.features.settings.ContinueWatchingSettingsScreen import com.nuvio.app.features.settings.AddonsSettingsScreen import com.nuvio.app.features.settings.PluginsSettingsScreen import com.nuvio.app.features.settings.AccountSettingsScreen 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.CollectionSyncService import com.nuvio.app.features.home.HomeCatalogSettingsSyncService import com.nuvio.app.features.collection.FolderDetailScreen import com.nuvio.app.features.collection.FolderDetailRepository import com.nuvio.app.features.streams.StreamAutoPlayPolicy import com.nuvio.app.features.streams.StreamLaunch import com.nuvio.app.features.streams.StreamLaunchStore import com.nuvio.app.features.streams.StreamLinkCacheRepository import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsScreen import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.TraktListTab import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ResumePromptRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.app_logo_wordmark import nuvio.composeapp.generated.resources.sidebar_library import nuvio.composeapp.generated.resources.sidebar_search import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource @Serializable object TabsRoute @Serializable data class DetailRoute(val type: String, val id: String) @Serializable data class PersonDetailRoute( val personId: Int, val personName: String, val personPhoto: String? = null, val castAvatarTransitionKey: String? = null, val preferCrew: Boolean = false, ) @Serializable data class EntityBrowseRoute( val entityKind: String, val entityId: Int, val entityName: String, val sourceType: String = "tv", ) @Serializable object HomescreenSettingsRoute @Serializable object MetaScreenSettingsRoute @Serializable object ContinueWatchingSettingsRoute @Serializable object DownloadsSettingsRoute @Serializable object AddonsSettingsRoute @Serializable object PluginsSettingsRoute @Serializable object AccountSettingsRoute @Serializable object CollectionsRoute @Serializable data class CollectionEditorRoute(val collectionId: String? = null) @Serializable data class FolderDetailRoute(val collectionId: String, val folderId: String) @Serializable data class StreamRoute( val launchId: Long, ) @Serializable data class CatalogRoute( val title: String, val subtitle: String, val manifestUrl: String, val type: String, val catalogId: String, val supportsPagination: Boolean = false, val genre: String? = null, ) enum class AppScreenTab { Home, Search, Library, Settings, } private enum class AppGateScreen { Loading, Auth, ProfileSelection, ProfileEdit, Main, } @OptIn(ExperimentalMaterial3Api::class) @Composable @Preview fun App() { setSingletonImageLoaderFactory { context -> ImageLoader.Builder(context) .crossfade(true) .diskCachePolicy(CachePolicy.ENABLED) .memoryCachePolicy(CachePolicy.ENABLED) .configurePlatformImageLoader() .build() } val selectedTheme by remember { ThemeSettingsRepository.ensureLoaded() ThemeSettingsRepository.selectedTheme }.collectAsStateWithLifecycle() val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle() NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) { LaunchedEffect(Unit) { AuthRepository.initialize() } val authState by AuthRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle() var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) } var editingProfile by remember { mutableStateOf(null) } var isNewProfile by remember { mutableStateOf(false) } var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) } LaunchedEffect(authState) { when (authState) { is AuthState.Loading -> gateScreen = AppGateScreen.Loading.name is AuthState.Unauthenticated -> { ProfileRepository.clearInMemory() gateScreen = AppGateScreen.Auth.name } is AuthState.Authenticated -> { val authenticatedState = authState as AuthState.Authenticated ProfileRepository.ensureLoaded(authenticatedState.userId) if (gateScreen == AppGateScreen.Loading.name || gateScreen == AppGateScreen.Auth.name) { autoSkipProfileSelection = true val cachedProfiles = ProfileRepository.state.value.profiles if (cachedProfiles.size == 1) { val onlyProfile = cachedProfiles.first() ProfileRepository.selectProfile(onlyProfile.profileIndex) SyncManager.pullAllForProfile(onlyProfile.profileIndex) gateScreen = AppGateScreen.Main.name autoSkipProfileSelection = false } else { gateScreen = AppGateScreen.ProfileSelection.name } } } } } LaunchedEffect(gateScreen, autoSkipProfileSelection, profileState.profiles) { if ( autoSkipProfileSelection && gateScreen == AppGateScreen.ProfileSelection.name && profileState.profiles.size == 1 ) { val onlyProfile = profileState.profiles.first() ProfileRepository.selectProfile(onlyProfile.profileIndex) SyncManager.pullAllForProfile(onlyProfile.profileIndex) gateScreen = AppGateScreen.Main.name autoSkipProfileSelection = false } } AnimatedContent( targetState = gateScreen, label = "app_gate", transitionSpec = { (fadeIn(tween(400)) + scaleIn(tween(400), initialScale = 0.94f)) .togetherWith(fadeOut(tween(250))) }, ) { currentGate -> when (currentGate) { AppGateScreen.Loading.name -> { Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background), contentAlignment = Alignment.Center, ) { CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) } } AppGateScreen.Auth.name -> { AuthScreen(modifier = Modifier.fillMaxSize()) } AppGateScreen.ProfileSelection.name -> { ProfileSelectionScreen( onProfileSelected = { profile -> ProfileRepository.selectProfile(profile.profileIndex) SyncManager.pullAllForProfile(profile.profileIndex) gateScreen = AppGateScreen.Main.name }, onEditProfile = { profile -> editingProfile = profile isNewProfile = false gateScreen = AppGateScreen.ProfileEdit.name }, onAddProfile = { editingProfile = null isNewProfile = true gateScreen = AppGateScreen.ProfileEdit.name }, modifier = Modifier.fillMaxSize(), ) } AppGateScreen.ProfileEdit.name -> { ProfileEditScreen( profile = editingProfile, onBack = { gateScreen = AppGateScreen.ProfileSelection.name }, onSaved = { gateScreen = AppGateScreen.ProfileSelection.name }, modifier = Modifier.fillMaxSize(), ) } AppGateScreen.Main.name -> { MainAppContent( onSwitchProfile = { autoSkipProfileSelection = false gateScreen = AppGateScreen.ProfileSelection.name }, ) } } } } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable private fun MainAppContent( onSwitchProfile: () -> Unit = {}, ) { val navController = rememberNavController() 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) } var showExitConfirmation by rememberSaveable { mutableStateOf(false) } var selectedPosterForActions by remember { mutableStateOf(null) } var selectedContinueWatchingForActions by remember { mutableStateOf(null) } var showLibraryListPicker by remember { mutableStateOf(false) } var pickerItem by remember { mutableStateOf(null) } var pickerTitle by remember { mutableStateOf("") } var pickerTabs by remember { mutableStateOf>(emptyList()) } 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 traktAuthUiState by remember { TraktAuthRepository.ensureLoaded() TraktAuthRepository.uiState }.collectAsStateWithLifecycle() val authState by AuthRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val playerSettingsUiState by remember { PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.uiState }.collectAsStateWithLifecycle() val watchedUiState by remember { WatchedRepository.ensureLoaded() WatchedRepository.uiState }.collectAsStateWithLifecycle() val downloadsUiState by remember { DownloadsRepository.ensureLoaded() DownloadsRepository.uiState }.collectAsStateWithLifecycle() val networkStatusUiState by remember { NetworkStatusRepository.uiState }.collectAsStateWithLifecycle() val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED var initialHomeReady by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) } var lastNetworkToastCondition by rememberSaveable { mutableStateOf(NetworkCondition.Unknown.name) } val addonProbeTargets = remember(addonsUiState.addons) { addonsUiState.addons .mapNotNull { it.manifest?.transportUrl } .distinct() .sorted() } LaunchedEffect(Unit) { NetworkStatusRepository.ensureStarted() EpisodeReleaseNotificationsRepository.refreshAsync() kotlinx.coroutines.delay(5_000) initialHomeReady = true } LaunchedEffect(addonProbeTargets) { NetworkStatusRepository.updateAddonProbeTargets(addonProbeTargets) } LaunchedEffect(Unit) { AppForegroundMonitor.events().collect { NetworkStatusRepository.requestRefresh(force = true) } } LaunchedEffect(networkStatusUiState.condition) { val condition = networkStatusUiState.condition if (!networkToastBaselineReady) { networkToastBaselineReady = true lastNetworkToastCondition = condition.name return@LaunchedEffect } val previousConditionName = lastNetworkToastCondition if (previousConditionName == condition.name) return@LaunchedEffect when (condition) { NetworkCondition.NoInternet -> { NuvioToastController.show("No internet connection") } NetworkCondition.ServersUnreachable -> { NuvioToastController.show("Cannot reach servers") } NetworkCondition.Online -> { if ( previousConditionName == NetworkCondition.NoInternet.name || previousConditionName == NetworkCondition.ServersUnreachable.name ) { NuvioToastController.show("Back online") } } NetworkCondition.Unknown, NetworkCondition.Checking, -> Unit } lastNetworkToastCondition = condition.name } LaunchedEffect( initialHomeReady, offlineLaunchRouteHandled, networkStatusUiState.condition, downloadsUiState.completedItems, ) { if (!initialHomeReady || offlineLaunchRouteHandled) return@LaunchedEffect when (networkStatusUiState.condition) { NetworkCondition.Unknown, NetworkCondition.Checking, -> return@LaunchedEffect NetworkCondition.Online -> { offlineLaunchRouteHandled = true } NetworkCondition.NoInternet, NetworkCondition.ServersUnreachable, -> { offlineLaunchRouteHandled = true val hasPlayableDownload = downloadsUiState.completedItems.any { it.isPlayable } if (hasPlayableDownload) { selectedTab = AppScreenTab.Settings navController.navigate(DownloadsSettingsRoute) { launchSingleTop = true } } } } } LaunchedEffect(authState, profileState.activeProfile?.profileIndex) { val authenticatedState = authState as? AuthState.Authenticated ?: return@LaunchedEffect if (authenticatedState.isAnonymous) return@LaunchedEffect val activeProfileId = profileState.activeProfile?.profileIndex ?: return@LaunchedEffect AppForegroundMonitor.events().collect { SyncManager.requestForegroundPull(activeProfileId) } } var profileSwitchLoading by remember { mutableStateOf(false) } var resumePromptItem by remember { mutableStateOf(null) } val continueWatchingPreferencesUiState by remember { ContinueWatchingPreferencesRepository.ensureLoaded() ContinueWatchingPreferencesRepository.uiState }.collectAsStateWithLifecycle() LaunchedEffect( initialHomeReady, profileSwitchLoading, profileState.activeProfile?.profileIndex, continueWatchingPreferencesUiState.showResumePromptOnLaunch, ) { if (!initialHomeReady || profileSwitchLoading) return@LaunchedEffect if (resumePromptItem != null) return@LaunchedEffect if (continueWatchingPreferencesUiState.showResumePromptOnLaunch) { resumePromptItem = ResumePromptRepository.consumeResumePrompt() } } LaunchedEffect(navController) { AppDeepLinkRepository.pendingDeepLink.collectLatest { deepLink -> when (deepLink) { is AppDeepLink.Meta -> { selectedTab = AppScreenTab.Home navController.navigate(DetailRoute(type = deepLink.type, id = deepLink.id)) { launchSingleTop = true } AppDeepLinkRepository.markConsumed(deepLink) } AppDeepLink.Downloads -> { selectedTab = AppScreenTab.Settings navController.navigate(DownloadsSettingsRoute) { launchSingleTop = true } AppDeepLinkRepository.markConsumed(deepLink) } null -> Unit } } } fun launchPlaybackWithDownloadPreference( type: String, videoId: String, parentMetaId: String, parentMetaType: String, title: String, logo: String?, poster: String?, background: String?, seasonNumber: Int?, episodeNumber: Int?, episodeTitle: String?, episodeThumbnail: String?, pauseDescription: String?, resumePositionMs: Long?, resumeProgressFraction: Float?, manualSelection: Boolean, startFromBeginning: Boolean, ) { val targetResumePositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L) val targetResumeProgressFraction = if (startFromBeginning) null else resumeProgressFraction if (!manualSelection) { val downloadedItem = DownloadsRepository.findPlayableDownload( parentMetaId = parentMetaId, seasonNumber = seasonNumber, episodeNumber = episodeNumber, videoId = videoId, ) val localSourceUrl = downloadedItem?.localFileUri if (!localSourceUrl.isNullOrBlank()) { val launchId = PlayerLaunchStore.put( PlayerLaunch( title = title, sourceUrl = localSourceUrl, sourceHeaders = emptyMap(), sourceResponseHeaders = emptyMap(), logo = logo, poster = poster, background = background, seasonNumber = seasonNumber, episodeNumber = episodeNumber, episodeTitle = episodeTitle, episodeThumbnail = episodeThumbnail, streamTitle = downloadedItem.streamTitle.ifBlank { title }, streamSubtitle = downloadedItem.streamSubtitle, pauseDescription = pauseDescription, providerName = downloadedItem.providerName.ifBlank { "Downloaded" }, providerAddonId = downloadedItem.providerAddonId, contentType = type, videoId = videoId, parentMetaId = parentMetaId, parentMetaType = parentMetaType, initialPositionMs = targetResumePositionMs, initialProgressFraction = targetResumeProgressFraction, ), ) navController.navigate(PlayerRoute(launchId = launchId)) return } } val streamLaunchId = StreamLaunchStore.put( StreamLaunch( type = type, videoId = videoId, parentMetaId = parentMetaId, parentMetaType = parentMetaType, title = title, logo = logo, poster = poster, background = background, seasonNumber = seasonNumber, episodeNumber = episodeNumber, episodeTitle = episodeTitle, episodeThumbnail = episodeThumbnail, pauseDescription = pauseDescription, resumePositionMs = if (startFromBeginning) 0L else resumePositionMs, resumeProgressFraction = targetResumeProgressFraction, manualSelection = manualSelection, startFromBeginning = startFromBeginning, ), ) navController.navigate( StreamRoute(launchId = streamLaunchId), ) } val onPlay: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit = { type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs -> launchPlaybackWithDownloadPreference( type = type, videoId = videoId, parentMetaId = parentMetaId, parentMetaType = parentMetaType, title = title, logo = logo, poster = poster, background = background, seasonNumber = seasonNumber, episodeNumber = episodeNumber, episodeTitle = episodeTitle, episodeThumbnail = episodeThumbnail, pauseDescription = pauseDescription, resumePositionMs = resumePositionMs, resumeProgressFraction = null, manualSelection = false, startFromBeginning = false, ) } val onPlayManually: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit = { type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs -> launchPlaybackWithDownloadPreference( type = type, videoId = videoId, parentMetaId = parentMetaId, parentMetaType = parentMetaType, title = title, logo = logo, poster = poster, background = background, seasonNumber = seasonNumber, episodeNumber = episodeNumber, episodeTitle = episodeTitle, episodeThumbnail = episodeThumbnail, pauseDescription = pauseDescription, resumePositionMs = resumePositionMs, resumeProgressFraction = null, manualSelection = true, startFromBeginning = false, ) } val onCatalogClick: (HomeCatalogSection) -> Unit = { section -> navController.navigate( CatalogRoute( title = section.title, subtitle = section.subtitle, manifestUrl = section.manifestUrl, type = section.type, catalogId = section.catalogId, supportsPagination = section.supportsPagination, ), ) } val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section -> navController.navigate( CatalogRoute( title = section.displayTitle, subtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) { "Trakt Library" } else { "Library" }, manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL, type = section.items.firstOrNull()?.type ?: "movie", catalogId = section.type, supportsPagination = false, ), ) } val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning -> launchPlaybackWithDownloadPreference( type = item.parentMetaType, videoId = item.videoId, parentMetaId = item.parentMetaId, parentMetaType = item.parentMetaType, title = item.title, logo = item.logo, poster = item.poster, background = item.background, seasonNumber = item.seasonNumber, episodeNumber = item.episodeNumber, episodeTitle = item.episodeTitle, episodeThumbnail = item.episodeThumbnail, pauseDescription = item.pauseDescription, resumePositionMs = item.resumePositionMs, resumeProgressFraction = item.resumeProgressFraction, manualSelection = manualSelection, startFromBeginning = startFromBeginning, ) } val onContinueWatchingClick: (ContinueWatchingItem) -> Unit = { item -> openContinueWatching(item, false, false) } val onContinueWatchingStartFromBeginning: (ContinueWatchingItem) -> Unit = { item -> openContinueWatching(item, false, true) } val onContinueWatchingPlayManually: (ContinueWatchingItem) -> Unit = { item -> openContinueWatching(item, true, false) } val onContinueWatchingLongPress: (ContinueWatchingItem) -> Unit = { item -> hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) selectedContinueWatchingForActions = item } Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { SharedTransitionLayout { NavHost( navController = navController, startDestination = TabsRoute, modifier = Modifier.fillMaxSize(), ) { composable { PlatformBackHandler( enabled = true, onBack = { if (selectedTab != AppScreenTab.Home) { selectedTab = AppScreenTab.Home } else { showExitConfirmation = !showExitConfirmation } }, ) BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val isTabletLayout = maxWidth >= 768.dp val onProfileSelected: (NuvioProfile) -> Unit = { profile -> profileSwitchLoading = true selectedTab = AppScreenTab.Home ProfileRepository.selectProfile(profile.profileIndex) com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex) } Scaffold( modifier = Modifier .fillMaxSize() .alpha(if (initialHomeReady) 1f else 0f), containerColor = Color.Transparent, contentWindowInsets = WindowInsets(0), bottomBar = { if (!isTabletLayout) { NuvioNavigationBar { NavItem( selected = selectedTab == AppScreenTab.Home, onClick = { selectedTab = AppScreenTab.Home }, icon = Icons.Filled.Home, contentDescription = "Home", ) NavItem( selected = selectedTab == AppScreenTab.Search, onClick = { selectedTab = AppScreenTab.Search }, icon = Res.drawable.sidebar_search, contentDescription = "Search", ) NavItem( selected = selectedTab == AppScreenTab.Library, onClick = { selectedTab = AppScreenTab.Library }, icon = Res.drawable.sidebar_library, contentDescription = "Library", ) NavItem( selected = selectedTab == AppScreenTab.Settings, onClick = { selectedTab = AppScreenTab.Settings }, ) { ProfileSwitcherTab( selected = selectedTab == AppScreenTab.Settings, onClick = { selectedTab = AppScreenTab.Settings }, onProfileSelected = onProfileSelected, onAddProfileRequested = onSwitchProfile, ) } } } }, ) { innerPadding -> Box(modifier = Modifier.fillMaxSize()) { AppTabHost( modifier = Modifier .fillMaxSize() .padding(innerPadding), selectedTab = selectedTab, onCatalogClick = onCatalogClick, onPosterClick = { meta -> navController.navigate(DetailRoute(type = meta.type, id = meta.id)) }, onPosterLongClick = { meta -> hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) selectedPosterForActions = meta }, onLibraryPosterClick = { item -> navController.navigate(DetailRoute(type = item.type, id = item.id)) }, onLibrarySectionViewAllClick = onLibrarySectionViewAllClick, onContinueWatchingClick = onContinueWatchingClick, onContinueWatchingLongPress = onContinueWatchingLongPress, onSwitchProfile = onSwitchProfile, onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) }, onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) }, onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) }, onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) }, onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) }, onPluginsSettingsClick = { if (AppFeaturePolicy.pluginsEnabled) { navController.navigate(PluginsSettingsRoute) } }, onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) }, onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) }, onFolderClick = { collectionId, folderId -> navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId)) }, onInitialHomeContentRendered = { initialHomeReady = true }, ) if (isTabletLayout) { TabletFloatingTopBar( selectedTab = selectedTab, onTabSelected = { selectedTab = it }, onProfileSelected = onProfileSelected, onAddProfileRequested = onSwitchProfile, ) } } } } } composable { backStackEntry -> val route = backStackEntry.toRoute() MetaDetailsScreen( type = route.type, id = route.id, onBack = { navController.popBackStack() }, onPlay = onPlay, onPlayManually = onPlayManually, onOpenMeta = { preview -> coroutineScope.launch { val resolvedId = if (preview.id.startsWith("tmdb:")) { val tmdbId = preview.id.removePrefix("tmdb:").toIntOrNull() tmdbId?.let { TmdbService.tmdbToImdb( tmdbId = it, mediaType = preview.type, ) } ?: preview.id } else { preview.id } navController.navigate( DetailRoute( type = preview.type, id = resolvedId, ), ) } }, onCastClick = { person, avatarTransitionKey -> val tmdbId = person.tmdbId if (tmdbId != null && tmdbId > 0) { navController.navigate( PersonDetailRoute( personId = tmdbId, personName = person.name, personPhoto = person.photo, castAvatarTransitionKey = avatarTransitionKey, preferCrew = person.role?.let { it.equals("Director", ignoreCase = true) || it.equals("Writer", ignoreCase = true) || it.equals("Creator", ignoreCase = true) } ?: false, ), ) } }, onCompanyClick = { company, entityKind -> val tmdbId = company.tmdbId if (tmdbId != null && tmdbId > 0) { navController.navigate( EntityBrowseRoute( entityKind = entityKind, entityId = tmdbId, entityName = company.name, sourceType = route.type, ), ) } }, sharedTransitionScope = this@SharedTransitionLayout, animatedVisibilityScope = this, modifier = Modifier.fillMaxSize(), ) } composable { backStackEntry -> val route = backStackEntry.toRoute() PersonDetailScreen( personId = route.personId, personName = route.personName, initialProfilePhoto = route.personPhoto, avatarTransitionKey = route.castAvatarTransitionKey, preferCrew = route.preferCrew, onBack = { navController.popBackStack() }, onOpenMeta = { preview -> coroutineScope.launch { val resolvedId = if (preview.id.startsWith("tmdb:")) { val tmdbId = preview.id.removePrefix("tmdb:").toIntOrNull() tmdbId?.let { TmdbService.tmdbToImdb( tmdbId = it, mediaType = preview.type, ) } ?: preview.id } else { preview.id } navController.navigate( DetailRoute( type = preview.type, id = resolvedId, ), ) } }, sharedTransitionScope = this@SharedTransitionLayout, animatedVisibilityScope = this, modifier = Modifier.fillMaxSize(), ) } composable { backStackEntry -> val route = backStackEntry.toRoute() TmdbEntityBrowseScreen( entityKind = TmdbEntityKind.fromRouteValue(route.entityKind), entityId = route.entityId, entityName = route.entityName, sourceType = route.sourceType, onBack = { navController.popBackStack() }, onOpenMeta = { preview -> coroutineScope.launch { val resolvedId = if (preview.id.startsWith("tmdb:")) { val tmdbId = preview.id.removePrefix("tmdb:").toIntOrNull() tmdbId?.let { TmdbService.tmdbToImdb( tmdbId = it, mediaType = preview.type, ) } ?: preview.id } else { preview.id } navController.navigate( DetailRoute( type = preview.type, id = resolvedId, ), ) } }, modifier = Modifier.fillMaxSize(), ) } composable { backStackEntry -> val route = backStackEntry.toRoute() val launch = remember(route.launchId) { StreamLaunchStore.get(route.launchId) } if (launch == null) { LaunchedEffect(route.launchId) { StreamsRepository.clear() navController.popBackStack() } return@composable } val pauseDescription = launch.pauseDescription val lifecycleOwner = backStackEntry DisposableEffect(lifecycleOwner, route.launchId) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_DESTROY) { StreamLaunchStore.remove(route.launchId) } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } val shouldResolveEpisodeVideoId = launch.parentMetaId != null && launch.seasonNumber != null && launch.episodeNumber != null var effectiveVideoId by rememberSaveable( launch.videoId, launch.parentMetaId, launch.seasonNumber, launch.episodeNumber, ) { mutableStateOf(launch.videoId) } var hasResolvedVideoId by rememberSaveable( launch.videoId, launch.parentMetaId, launch.seasonNumber, launch.episodeNumber, ) { mutableStateOf(!shouldResolveEpisodeVideoId) } LaunchedEffect( launch.videoId, launch.parentMetaId, launch.parentMetaType, launch.type, launch.seasonNumber, launch.episodeNumber, ) { effectiveVideoId = launch.videoId if (!shouldResolveEpisodeVideoId) { hasResolvedVideoId = true return@LaunchedEffect } hasResolvedVideoId = false val metaType = launch.parentMetaType ?: launch.type val metaId = launch.parentMetaId ?: return@LaunchedEffect val resolvedVideoId = runCatching { MetaDetailsRepository.fetch(metaType, metaId) }.getOrNull() ?.videos ?.firstOrNull { video -> video.season == launch.seasonNumber && video.episode == launch.episodeNumber } ?.id ?.takeIf { it.isNotBlank() } effectiveVideoId = resolvedVideoId ?: launch.videoId hasResolvedVideoId = true } val playerSettings by remember { PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.uiState }.collectAsStateWithLifecycle() // Reuse Last Link: auto-play from cache if enabled (only on first entry) var reuseHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) } var reuseNavigated by remember { mutableStateOf(false) } LaunchedEffect(effectiveVideoId, hasResolvedVideoId, playerSettings.streamReuseLastLinkEnabled, launch.manualSelection) { if (!hasResolvedVideoId) return@LaunchedEffect if (reuseHandled) return@LaunchedEffect reuseHandled = true if (launch.manualSelection) return@LaunchedEffect if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId) val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs) if (cached != null) { reuseNavigated = true StreamsRepository.clear() val launchId = PlayerLaunchStore.put( PlayerLaunch( title = launch.title, sourceUrl = cached.url, sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders), sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders), logo = launch.logo, poster = launch.poster, background = launch.background, seasonNumber = launch.seasonNumber, episodeNumber = launch.episodeNumber, episodeTitle = launch.episodeTitle, episodeThumbnail = launch.episodeThumbnail, streamTitle = cached.streamName, streamSubtitle = null, bingeGroup = cached.bingeGroup, pauseDescription = pauseDescription, providerName = cached.addonName, providerAddonId = cached.addonId, contentType = launch.type, videoId = effectiveVideoId, parentMetaId = launch.parentMetaId ?: effectiveVideoId, parentMetaType = launch.parentMetaType ?: launch.type, initialPositionMs = launch.resumePositionMs ?: 0L, initialProgressFraction = launch.resumeProgressFraction, ) ) navController.navigate(PlayerRoute(launchId = launchId)) { popUpTo { inclusive = true } } } } val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle() var autoPlayHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) } LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled, launch.manualSelection) { if (!reuseHandled) return@LaunchedEffect if (launch.manualSelection) return@LaunchedEffect if (reuseNavigated) return@LaunchedEffect if (autoPlayHandled) return@LaunchedEffect val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect autoPlayHandled = true if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId) StreamLinkCacheRepository.save( contentKey = cacheKey, url = sourceUrl, streamName = stream.streamLabel, addonName = stream.addonName, addonId = stream.addonId, requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, bingeGroup = stream.behaviorHints.bingeGroup, ) } val launchId = PlayerLaunchStore.put( PlayerLaunch( title = launch.title, sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), logo = launch.logo, poster = launch.poster, background = launch.background, seasonNumber = launch.seasonNumber, episodeNumber = launch.episodeNumber, episodeTitle = launch.episodeTitle, episodeThumbnail = launch.episodeThumbnail, streamTitle = stream.streamLabel, streamSubtitle = stream.streamSubtitle, bingeGroup = stream.behaviorHints.bingeGroup, pauseDescription = pauseDescription, providerName = stream.addonName, providerAddonId = stream.addonId, contentType = launch.type, videoId = effectiveVideoId, parentMetaId = launch.parentMetaId ?: effectiveVideoId, parentMetaType = launch.parentMetaType ?: launch.type, initialPositionMs = launch.resumePositionMs ?: 0L, initialProgressFraction = launch.resumeProgressFraction, ) ) StreamsRepository.consumeAutoPlay() navController.navigate(PlayerRoute(launchId = launchId)) { popUpTo { inclusive = true } } } if (!hasResolvedVideoId) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) } return@composable } StreamsScreen( type = launch.type, videoId = effectiveVideoId, parentMetaId = launch.parentMetaId ?: effectiveVideoId, parentMetaType = launch.parentMetaType ?: launch.type, title = launch.title, logo = launch.logo, poster = launch.poster, background = launch.background, seasonNumber = launch.seasonNumber, episodeNumber = launch.episodeNumber, episodeTitle = launch.episodeTitle, episodeThumbnail = launch.episodeThumbnail, resumePositionMs = launch.resumePositionMs, resumeProgressFraction = launch.resumeProgressFraction, manualSelection = launch.manualSelection, startFromBeginning = launch.startFromBeginning, onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction -> val sourceUrl = stream.directPlaybackUrl if (sourceUrl != null) { // Persist for Reuse Last Link if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId) StreamLinkCacheRepository.save( contentKey = cacheKey, url = sourceUrl, streamName = stream.streamLabel, addonName = stream.addonName, addonId = stream.addonId, requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, bingeGroup = stream.behaviorHints.bingeGroup, ) } val launchId = PlayerLaunchStore.put( PlayerLaunch( title = launch.title, sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), logo = launch.logo, poster = launch.poster, background = launch.background, seasonNumber = launch.seasonNumber, episodeNumber = launch.episodeNumber, episodeTitle = launch.episodeTitle, episodeThumbnail = launch.episodeThumbnail, streamTitle = stream.streamLabel, streamSubtitle = stream.streamSubtitle, bingeGroup = stream.behaviorHints.bingeGroup, pauseDescription = pauseDescription, providerName = stream.addonName, providerAddonId = stream.addonId, contentType = launch.type, videoId = effectiveVideoId, parentMetaId = launch.parentMetaId ?: effectiveVideoId, parentMetaType = launch.parentMetaType ?: launch.type, initialPositionMs = resolvedResumePositionMs ?: 0L, initialProgressFraction = resolvedResumeProgressFraction, ) ) navController.navigate( PlayerRoute(launchId = launchId) ) } }, onBack = { StreamsRepository.clear() navController.popBackStack() }, modifier = Modifier.fillMaxSize(), ) } composable( enterTransition = { if (isIos) fadeIn(animationSpec = tween(220)) else null }, exitTransition = { if (isIos) fadeOut(animationSpec = tween(220)) else null }, popEnterTransition = { if (isIos) fadeIn(animationSpec = tween(220)) else null }, popExitTransition = { if (isIos) fadeOut(animationSpec = tween(220)) else null }, ) { backStackEntry -> val route = backStackEntry.toRoute() val launch = remember(route.launchId) { PlayerLaunchStore.get(route.launchId) } if (launch == null) { LaunchedEffect(route.launchId) { navController.popBackStack() } Box(modifier = Modifier.fillMaxSize()) return@composable } LaunchedEffect(launch.videoId) { launch.videoId?.let { ResumePromptRepository.markPlayerEntered(it) } } PlayerScreen( title = launch.title, sourceUrl = launch.sourceUrl, sourceAudioUrl = launch.sourceAudioUrl, sourceHeaders = launch.sourceHeaders, sourceResponseHeaders = launch.sourceResponseHeaders, logo = launch.logo, poster = launch.poster, background = launch.background, seasonNumber = launch.seasonNumber, episodeNumber = launch.episodeNumber, episodeTitle = launch.episodeTitle, episodeThumbnail = launch.episodeThumbnail, streamTitle = launch.streamTitle, streamSubtitle = launch.streamSubtitle, initialBingeGroup = launch.bingeGroup, pauseDescription = launch.pauseDescription, providerName = launch.providerName, providerAddonId = launch.providerAddonId, contentType = launch.contentType, videoId = launch.videoId, parentMetaId = launch.parentMetaId, parentMetaType = launch.parentMetaType, initialPositionMs = launch.initialPositionMs, initialProgressFraction = launch.initialProgressFraction, onBack = { ResumePromptRepository.markPlayerExitedNormally() PlayerLaunchStore.remove(route.launchId) navController.popBackStack() }, modifier = Modifier.fillMaxSize(), ) } composable { backStackEntry -> val route = backStackEntry.toRoute() CatalogScreen( title = route.title, subtitle = route.subtitle, manifestUrl = route.manifestUrl, type = route.type, catalogId = route.catalogId, supportsPagination = route.supportsPagination, genre = route.genre, onBack = { CatalogRepository.clear() navController.popBackStack() }, onPosterClick = { meta -> navController.navigate(DetailRoute(type = meta.type, id = meta.id)) }, modifier = Modifier.fillMaxSize(), ) } composable { val onBack = rememberGuardedPopBackStack( navController = navController, backStackEntry = it, ) HomescreenSettingsScreen( onBack = onBack, ) } composable { backStackEntry -> val onBack = rememberGuardedPopBackStack( navController = navController, backStackEntry = backStackEntry, ) MetaScreenSettingsScreen( onBack = onBack, ) } composable { backStackEntry -> val onBack = rememberGuardedPopBackStack( navController = navController, backStackEntry = backStackEntry, ) ContinueWatchingSettingsScreen( onBack = onBack, ) } composable { backStackEntry -> val onBack = rememberGuardedPopBackStack( navController = navController, backStackEntry = backStackEntry, ) DownloadsScreen( onBack = onBack, onOpenDownload = { item -> val sourceUrl = item.localFileUri ?: return@DownloadsScreen val resumeEntry = item.videoId .takeIf { it.isNotBlank() } ?.let(WatchProgressRepository::progressForVideo) ?.takeIf { it.isResumable } val launchId = PlayerLaunchStore.put( PlayerLaunch( title = item.title, sourceUrl = sourceUrl, sourceHeaders = emptyMap(), sourceResponseHeaders = emptyMap(), logo = item.logo, poster = item.poster, background = item.background, seasonNumber = item.seasonNumber, episodeNumber = item.episodeNumber, episodeTitle = item.episodeTitle, episodeThumbnail = item.episodeThumbnail, streamTitle = item.streamTitle, streamSubtitle = item.streamSubtitle, providerName = item.providerName, providerAddonId = item.providerAddonId, contentType = item.contentType, videoId = item.videoId, parentMetaId = item.parentMetaId, parentMetaType = item.parentMetaType, initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L, initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f }, ), ) navController.navigate(PlayerRoute(launchId = launchId)) }, ) } composable { backStackEntry -> val onBack = rememberGuardedPopBackStack( navController = navController, backStackEntry = backStackEntry, ) AddonsSettingsScreen( onBack = onBack, ) } if (AppFeaturePolicy.pluginsEnabled) { composable { backStackEntry -> val onBack = rememberGuardedPopBackStack( navController = navController, backStackEntry = backStackEntry, ) PluginsSettingsScreen( onBack = onBack, ) } } composable { backStackEntry -> val onBack = rememberGuardedPopBackStack( navController = navController, backStackEntry = backStackEntry, ) AccountSettingsScreen( onBack = onBack, ) } composable { backStackEntry -> val onBack = rememberGuardedPopBackStack( navController = navController, backStackEntry = backStackEntry, ) CollectionManagementScreen( onBack = onBack, onNavigateToEditor = { collectionId -> navController.navigate(CollectionEditorRoute(collectionId = collectionId)) }, ) } composable { backStackEntry -> val route = backStackEntry.toRoute() CollectionEditorScreen( collectionId = route.collectionId, onBack = { CollectionEditorRepository.clear() navController.popBackStack() }, ) } composable { backStackEntry -> val route = backStackEntry.toRoute() LaunchedEffect(route.collectionId, route.folderId) { FolderDetailRepository.initialize(route.collectionId, route.folderId) } FolderDetailScreen( onBack = { FolderDetailRepository.clear() navController.popBackStack() }, onCatalogClick = onCatalogClick, onPosterClick = { meta -> navController.navigate(DetailRoute(type = meta.type, id = meta.id)) }, ) } } } NuvioPosterActionSheet( item = selectedPosterForActions, isSaved = selectedPosterForActions?.let { preview -> LibraryRepository.isSaved(preview.id, preview.type) } == true, isWatched = selectedPosterForActions?.let { preview -> WatchingState.isPosterWatched( watchedKeys = watchedUiState.watchedKeys, item = preview, ) } == true, onDismiss = { selectedPosterForActions = null }, onToggleLibrary = { selectedPosterForActions?.let { preview -> val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L) if (!isTraktConnected) { LibraryRepository.toggleSaved(libraryItem) } else { pickerItem = libraryItem pickerTitle = preview.name pickerTabs = LibraryRepository.traktListTabs() pickerMembership = pickerTabs.associate { it.key to false } pickerPending = true pickerError = null showLibraryListPicker = true coroutineScope.launch { runCatching { val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) val tabs = LibraryRepository.traktListTabs() pickerTabs = tabs pickerMembership = tabs.associate { tab -> tab.key to (snapshot[tab.key] == true) } }.onFailure { error -> pickerError = error.message ?: "Failed to load Trakt lists" } pickerPending = false } } } }, onToggleWatched = { selectedPosterForActions?.let { preview -> coroutineScope.launch { WatchingActions.togglePosterWatched(preview) } } }, ) NuvioContinueWatchingActionSheet( item = selectedContinueWatchingForActions, showManualPlayOption = StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState), onDismiss = { selectedContinueWatchingForActions = null }, onOpenDetails = { selectedContinueWatchingForActions?.let { item -> navController.navigate( DetailRoute( type = item.parentMetaType, id = item.parentMetaId, ), ) } }, onStartFromBeginning = selectedContinueWatchingForActions ?.takeIf { !it.isNextUp } ?.let { item -> { onContinueWatchingStartFromBeginning(item) } }, onPlayManually = selectedContinueWatchingForActions ?.let { item -> { onContinueWatchingPlayManually(item) } }, onRemove = { selectedContinueWatchingForActions?.let { item -> if (item.isNextUp) { ContinueWatchingPreferencesRepository.addDismissedNextUpKey( nextUpDismissKey( item.parentMetaId, item.nextUpSeedSeasonNumber, item.nextUpSeedEpisodeNumber, ), ) } else { WatchProgressRepository.removeProgress(contentId = item.parentMetaId) } } }, ) TraktListPickerDialog( visible = showLibraryListPicker, title = pickerTitle, tabs = pickerTabs, membership = pickerMembership, isPending = pickerPending, errorMessage = pickerError, onToggle = { listKey -> pickerMembership = pickerMembership.toMutableMap().apply { this[listKey] = !(this[listKey] == true) } }, onDismiss = { if (!pickerPending) { showLibraryListPicker = false pickerItem = null pickerError = null } }, onSave = { val item = pickerItem ?: return@TraktListPickerDialog coroutineScope.launch { pickerPending = true pickerError = null runCatching { LibraryRepository.applyMembershipChanges( item = item, desiredMembership = pickerMembership, ) }.onSuccess { showLibraryListPicker = false pickerItem = null pickerError = null }.onFailure { error -> pickerError = error.message ?: "Failed to update Trakt lists" } pickerPending = false } }, ) NuvioStatusModal( title = "Exit app", message = "Do you want to exit the app?", isVisible = showExitConfirmation, confirmText = "Yes", dismissText = "No", onConfirm = { showExitConfirmation = false platformExitApp() }, onDismiss = { showExitConfirmation = false }, ) androidx.compose.animation.AnimatedVisibility( visible = !initialHomeReady || profileSwitchLoading, enter = fadeIn(), exit = fadeOut(androidx.compose.animation.core.tween(400)), ) { 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, title = resumePromptItem?.title.orEmpty(), subtitle = resumePromptItem?.subtitle.orEmpty(), progressFraction = resumePromptItem?.progressFraction ?: 0f, actionLabel = "Resume", onAction = { val item = resumePromptItem ?: return@NuvioFloatingPrompt resumePromptItem = null openContinueWatching(item, false, false) }, onDismiss = { resumePromptItem = null }, modifier = Modifier .align(Alignment.BottomCenter) .zIndex(15f), ) NuvioToastHost( modifier = Modifier .align(Alignment.TopCenter) .zIndex(20f), ) } } @Composable private fun rememberGuardedPopBackStack( navController: NavHostController, backStackEntry: NavBackStackEntry, beforePop: () -> Unit = {}, ): () -> Unit { val currentBackStackEntry by navController.currentBackStackEntryAsState() var popHandled by remember(backStackEntry) { mutableStateOf(false) } return remember(navController, backStackEntry, currentBackStackEntry, popHandled, beforePop) { { if (!popHandled && currentBackStackEntry == backStackEntry) { popHandled = true beforePop() navController.popBackStack() } } } } @Composable private fun AppTabHost( selectedTab: AppScreenTab, modifier: Modifier = Modifier, onCatalogClick: ((HomeCatalogSection) -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, onLibraryPosterClick: ((LibraryItem) -> Unit)? = null, onLibrarySectionViewAllClick: ((LibrarySection) -> Unit)? = null, onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null, onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null, onSwitchProfile: (() -> Unit)? = null, onHomescreenSettingsClick: () -> Unit = {}, onMetaScreenSettingsClick: () -> Unit = {}, onContinueWatchingSettingsClick: () -> Unit = {}, onDownloadsSettingsClick: () -> Unit = {}, onAddonsSettingsClick: () -> Unit = {}, onPluginsSettingsClick: () -> Unit = {}, onAccountSettingsClick: () -> Unit = {}, onCollectionsSettingsClick: () -> Unit = {}, onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null, onInitialHomeContentRendered: () -> Unit = {}, ) { val tabStateHolder = rememberSaveableStateHolder() Box(modifier = modifier.fillMaxSize()) { tabStateHolder.SaveableStateProvider(selectedTab.name) { when (selectedTab) { AppScreenTab.Home -> { HomeScreen( modifier = Modifier.fillMaxSize(), onCatalogClick = onCatalogClick, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, onContinueWatchingClick = onContinueWatchingClick, onContinueWatchingLongPress = onContinueWatchingLongPress, onFolderClick = onFolderClick, onFirstCatalogRendered = onInitialHomeContentRendered, ) } AppScreenTab.Search -> { SearchScreen( modifier = Modifier.fillMaxSize(), onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, ) } AppScreenTab.Library -> { LibraryScreen( modifier = Modifier.fillMaxSize(), onPosterClick = onLibraryPosterClick, onSectionViewAllClick = onLibrarySectionViewAllClick, ) } AppScreenTab.Settings -> { SettingsScreen( modifier = Modifier.fillMaxSize(), onSwitchProfile = onSwitchProfile, onHomescreenClick = onHomescreenSettingsClick, onMetaScreenClick = onMetaScreenSettingsClick, onContinueWatchingClick = onContinueWatchingSettingsClick, onDownloadsClick = onDownloadsSettingsClick, onAddonsClick = onAddonsSettingsClick, onPluginsClick = onPluginsSettingsClick, onAccountClick = onAccountSettingsClick, onCollectionsClick = onCollectionsSettingsClick, ) } } } } } @Composable private fun TabletFloatingTopBar( selectedTab: AppScreenTab, onTabSelected: (AppScreenTab) -> Unit, onProfileSelected: (NuvioProfile) -> Unit, onAddProfileRequested: () -> Unit, modifier: Modifier = Modifier, ) { val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() Box( modifier = modifier .fillMaxWidth() .padding(top = statusBarPadding + 10.dp, bottom = 8.dp), contentAlignment = Alignment.TopCenter, ) { Surface( color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), shape = RoundedCornerShape(999.dp), tonalElevation = 4.dp, shadowElevation = 10.dp, ) { Row( modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { TabletTopPillItem( label = "Home", selected = selectedTab == AppScreenTab.Home, onClick = { onTabSelected(AppScreenTab.Home) }, icon = { Icon( imageVector = Icons.Filled.Home, contentDescription = "Home", modifier = Modifier.size(18.dp), tint = if (selectedTab == AppScreenTab.Home) { MaterialTheme.colorScheme.onPrimaryContainer } else { MaterialTheme.colorScheme.onSurfaceVariant }, ) }, ) TabletTopPillItem( label = "Search", selected = selectedTab == AppScreenTab.Search, onClick = { onTabSelected(AppScreenTab.Search) }, icon = { Icon( painter = painterResource(Res.drawable.sidebar_search), contentDescription = "Search", modifier = Modifier.size(18.dp), tint = if (selectedTab == AppScreenTab.Search) { MaterialTheme.colorScheme.onPrimaryContainer } else { MaterialTheme.colorScheme.onSurfaceVariant }, ) }, ) TabletTopPillItem( label = "Library", selected = selectedTab == AppScreenTab.Library, onClick = { onTabSelected(AppScreenTab.Library) }, icon = { Icon( painter = painterResource(Res.drawable.sidebar_library), contentDescription = "Library", modifier = Modifier.size(18.dp), tint = if (selectedTab == AppScreenTab.Library) { MaterialTheme.colorScheme.onPrimaryContainer } else { MaterialTheme.colorScheme.onSurfaceVariant }, ) }, ) Surface( color = if (selectedTab == AppScreenTab.Settings) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surface }, shape = RoundedCornerShape(999.dp), ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { ProfileSwitcherTab( selected = selectedTab == AppScreenTab.Settings, onClick = { onTabSelected(AppScreenTab.Settings) }, onProfileSelected = onProfileSelected, onAddProfileRequested = onAddProfileRequested, ) Text( text = "Profile", modifier = Modifier.clickable { onTabSelected(AppScreenTab.Settings) }, style = MaterialTheme.typography.labelLarge, color = if (selectedTab == AppScreenTab.Settings) { MaterialTheme.colorScheme.onPrimaryContainer } else { MaterialTheme.colorScheme.onSurfaceVariant }, ) } } } } } } @Composable private fun TabletTopPillItem( label: String, selected: Boolean, onClick: () -> Unit, icon: @Composable () -> Unit, ) { Surface( color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(999.dp), tonalElevation = if (selected) 2.dp else 0.dp, modifier = Modifier.clickable(onClick = onClick), ) { Row( modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { icon() Text( text = label, style = MaterialTheme.typography.labelLarge, color = if (selected) { MaterialTheme.colorScheme.onPrimaryContainer } else { MaterialTheme.colorScheme.onSurfaceVariant }, ) } } } @Composable private fun AppLaunchOverlay( modifier: Modifier = Modifier, ) { Box( modifier = modifier .background(MaterialTheme.colorScheme.background) .zIndex(10f), contentAlignment = Alignment.Center, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, ) { Image( painter = painterResource(Res.drawable.app_logo_wordmark), contentDescription = "Nuvio", modifier = Modifier .fillMaxWidth(0.48f) .height(44.dp), contentScale = ContentScale.Fit, ) Spacer(modifier = Modifier.height(24.dp)) CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) } } }