package com.nuvio.app import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.animateFloatAsState 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.BoxScope 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.rounded.Home import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.VideoLibrary 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.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.draw.alpha 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.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable 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.sync.SyncManager import com.nuvio.app.core.ui.nuvioBottomNavigationBarInsets import com.nuvio.app.core.ui.NuvioPosterActionSheet import com.nuvio.app.core.ui.PlatformBackHandler 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.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogScreen import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL 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.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.streams.StreamContext import com.nuvio.app.features.streams.StreamContextStore 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.WatchProgressRepository 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 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 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 AddonsSettingsRoute @Serializable object PluginsSettingsRoute @Serializable object AccountSettingsRoute @Serializable data class StreamRoute( val type: String, val videoId: String, val parentMetaId: String? = null, val parentMetaType: String? = null, val title: String, val logo: String? = null, val poster: String? = null, val background: String? = null, val seasonNumber: Int? = null, val episodeNumber: Int? = null, val episodeTitle: String? = null, val episodeThumbnail: String? = null, val streamContextId: Long? = null, val resumePositionMs: Long? = null, val resumeProgressFraction: Float? = null, ) @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) .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) @Composable private fun MainAppContent( onSwitchProfile: () -> Unit = {}, ) { val navController = rememberNavController() remember { EpisodeReleaseNotificationsRepository.ensureLoaded() } val hapticFeedback = LocalHapticFeedback.current val coroutineScope = rememberCoroutineScope() var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } var selectedPosterForActions 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 libraryUiState by remember { LibraryRepository.ensureLoaded() LibraryRepository.uiState }.collectAsStateWithLifecycle() val traktAuthUiState by remember { TraktAuthRepository.ensureLoaded() TraktAuthRepository.uiState }.collectAsStateWithLifecycle() val watchedUiState by remember { WatchedRepository.ensureLoaded() WatchedRepository.uiState }.collectAsStateWithLifecycle() val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED var initialHomeReady by rememberSaveable { mutableStateOf(false) } LaunchedEffect(Unit) { EpisodeReleaseNotificationsRepository.refreshAsync() kotlinx.coroutines.delay(5_000) initialHomeReady = true } var profileSwitchLoading by remember { mutableStateOf(false) } 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) } null -> Unit } } } 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 -> val streamContextId = pauseDescription ?.takeIf { it.isNotBlank() } ?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) } navController.navigate( StreamRoute( type = type, videoId = videoId, parentMetaId = parentMetaId, parentMetaType = parentMetaType, title = title, logo = logo, poster = poster, background = background, seasonNumber = seasonNumber, episodeNumber = episodeNumber, episodeTitle = episodeTitle, episodeThumbnail = episodeThumbnail, streamContextId = streamContextId, resumePositionMs = resumePositionMs, resumeProgressFraction = null, ) ) } 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 onContinueWatchingClick: (ContinueWatchingItem) -> Unit = { item -> val streamContextId = item.pauseDescription ?.takeIf { it.isNotBlank() } ?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) } navController.navigate( StreamRoute( 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, streamContextId = streamContextId, resumePositionMs = item.resumePositionMs, resumeProgressFraction = item.resumeProgressFraction, ), ) } val onContinueWatchingLongPress: (ContinueWatchingItem) -> Unit = { item -> navController.navigate( DetailRoute( type = item.parentMetaType, id = item.parentMetaId, ), ) } Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { NavHost( navController = navController, startDestination = TabsRoute, modifier = Modifier.fillMaxSize(), ) { composable { PlatformBackHandler( enabled = selectedTab != AppScreenTab.Home, onBack = { selectedTab = AppScreenTab.Home }, ) 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 = MaterialTheme.colorScheme.background, contentWindowInsets = WindowInsets(0), bottomBar = { if (!isTabletLayout) { val navigationItemColors = NavigationBarItemDefaults.colors( selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer, selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer, indicatorColor = MaterialTheme.colorScheme.primaryContainer, unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, ) NavigationBar( containerColor = MaterialTheme.colorScheme.surfaceVariant, windowInsets = nuvioBottomNavigationBarInsets(), ) { NavigationBarItem( selected = selectedTab == AppScreenTab.Home, onClick = { selectedTab = AppScreenTab.Home }, icon = { Icon(Icons.Rounded.Home, contentDescription = null) }, label = { Text("Home") }, colors = navigationItemColors, ) NavigationBarItem( selected = selectedTab == AppScreenTab.Search, onClick = { selectedTab = AppScreenTab.Search }, icon = { Icon(Icons.Rounded.Search, contentDescription = null) }, label = { Text("Search") }, colors = navigationItemColors, ) NavigationBarItem( selected = selectedTab == AppScreenTab.Library, onClick = { selectedTab = AppScreenTab.Library }, icon = { Icon(Icons.Rounded.VideoLibrary, contentDescription = null) }, label = { Text("Library") }, colors = navigationItemColors, ) NavigationBarItem( selected = selectedTab == AppScreenTab.Settings, onClick = { selectedTab = AppScreenTab.Settings }, icon = { ProfileSwitcherTab( selected = selectedTab == AppScreenTab.Settings, onClick = { selectedTab = AppScreenTab.Settings }, onProfileSelected = onProfileSelected, onAddProfileRequested = onSwitchProfile, ) }, label = { Text("Profile") }, colors = navigationItemColors, ) } } }, ) { 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) }, onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) }, onPluginsSettingsClick = { if (AppFeaturePolicy.pluginsEnabled) { navController.navigate(PluginsSettingsRoute) } }, onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) }, 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, 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 -> val tmdbId = person.tmdbId if (tmdbId != null && tmdbId > 0) { navController.navigate( PersonDetailRoute( personId = tmdbId, personName = person.name, 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, ), ) } }, modifier = Modifier.fillMaxSize(), ) } composable { backStackEntry -> val route = backStackEntry.toRoute() PersonDetailScreen( personId = route.personId, personName = route.personName, 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, ), ) } }, 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 pauseDescription = remember(route.streamContextId) { route.streamContextId?.let { contextId -> StreamContextStore.get(contextId)?.pauseDescription } } val shouldResolveEpisodeVideoId = route.type == "series" && route.parentMetaId != null && route.seasonNumber != null && route.episodeNumber != null var effectiveVideoId by rememberSaveable( route.videoId, route.parentMetaId, route.seasonNumber, route.episodeNumber, ) { mutableStateOf(route.videoId) } var hasResolvedVideoId by rememberSaveable( route.videoId, route.parentMetaId, route.seasonNumber, route.episodeNumber, ) { mutableStateOf(!shouldResolveEpisodeVideoId) } LaunchedEffect( route.videoId, route.parentMetaId, route.parentMetaType, route.type, route.seasonNumber, route.episodeNumber, ) { effectiveVideoId = route.videoId if (!shouldResolveEpisodeVideoId) { hasResolvedVideoId = true return@LaunchedEffect } hasResolvedVideoId = false val metaType = route.parentMetaType ?: route.type val metaId = route.parentMetaId ?: return@LaunchedEffect val resolvedVideoId = runCatching { MetaDetailsRepository.fetch(metaType, metaId) }.getOrNull() ?.videos ?.firstOrNull { video -> video.season == route.seasonNumber && video.episode == route.episodeNumber } ?.id ?.takeIf { it.isNotBlank() } effectiveVideoId = resolvedVideoId ?: route.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(route.videoId, effectiveVideoId) { mutableStateOf(false) } LaunchedEffect(effectiveVideoId, hasResolvedVideoId, playerSettings.streamReuseLastLinkEnabled) { if (!hasResolvedVideoId) return@LaunchedEffect if (reuseHandled) return@LaunchedEffect reuseHandled = true if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect val cacheKey = StreamLinkCacheRepository.contentKey(route.type, effectiveVideoId) val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs) if (cached != null) { val launchId = PlayerLaunchStore.put( PlayerLaunch( title = route.title, sourceUrl = cached.url, logo = route.logo, poster = route.poster, background = route.background, seasonNumber = route.seasonNumber, episodeNumber = route.episodeNumber, episodeTitle = route.episodeTitle, episodeThumbnail = route.episodeThumbnail, streamTitle = cached.streamName, streamSubtitle = null, pauseDescription = pauseDescription, providerName = cached.addonName, providerAddonId = cached.addonId, contentType = route.type, videoId = effectiveVideoId, parentMetaId = route.parentMetaId ?: effectiveVideoId, parentMetaType = route.parentMetaType ?: route.type, initialPositionMs = route.resumePositionMs ?: 0L, initialProgressFraction = route.resumeProgressFraction, ) ) route.streamContextId?.let(StreamContextStore::remove) navController.navigate(PlayerRoute(launchId = launchId)) { popUpTo { inclusive = true } } } } val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle() var autoPlayHandled by rememberSaveable(route.videoId, effectiveVideoId) { mutableStateOf(false) } LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled) { if (!reuseHandled) 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(route.type, effectiveVideoId) StreamLinkCacheRepository.save( contentKey = cacheKey, url = sourceUrl, streamName = stream.streamLabel, addonName = stream.addonName, addonId = stream.addonId, filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, ) } val launchId = PlayerLaunchStore.put( PlayerLaunch( title = route.title, sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), logo = route.logo, poster = route.poster, background = route.background, seasonNumber = route.seasonNumber, episodeNumber = route.episodeNumber, episodeTitle = route.episodeTitle, episodeThumbnail = route.episodeThumbnail, streamTitle = stream.streamLabel, streamSubtitle = stream.streamSubtitle, pauseDescription = pauseDescription, providerName = stream.addonName, providerAddonId = stream.addonId, contentType = route.type, videoId = effectiveVideoId, parentMetaId = route.parentMetaId ?: effectiveVideoId, parentMetaType = route.parentMetaType ?: route.type, initialPositionMs = route.resumePositionMs ?: 0L, initialProgressFraction = route.resumeProgressFraction, ) ) StreamsRepository.consumeAutoPlay() route.streamContextId?.let(StreamContextStore::remove) 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 = route.type, videoId = effectiveVideoId, parentMetaId = route.parentMetaId ?: effectiveVideoId, parentMetaType = route.parentMetaType ?: route.type, title = route.title, logo = route.logo, poster = route.poster, background = route.background, seasonNumber = route.seasonNumber, episodeNumber = route.episodeNumber, episodeTitle = route.episodeTitle, episodeThumbnail = route.episodeThumbnail, resumePositionMs = route.resumePositionMs, resumeProgressFraction = route.resumeProgressFraction, onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction -> val sourceUrl = stream.directPlaybackUrl if (sourceUrl != null) { // Persist for Reuse Last Link if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey(route.type, effectiveVideoId) StreamLinkCacheRepository.save( contentKey = cacheKey, url = sourceUrl, streamName = stream.streamLabel, addonName = stream.addonName, addonId = stream.addonId, filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, ) } val launchId = PlayerLaunchStore.put( PlayerLaunch( title = route.title, sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), logo = route.logo, poster = route.poster, background = route.background, seasonNumber = route.seasonNumber, episodeNumber = route.episodeNumber, episodeTitle = route.episodeTitle, episodeThumbnail = route.episodeThumbnail, streamTitle = stream.streamLabel, streamSubtitle = stream.streamSubtitle, pauseDescription = pauseDescription, providerName = stream.addonName, providerAddonId = stream.addonId, contentType = route.type, videoId = effectiveVideoId, parentMetaId = route.parentMetaId ?: effectiveVideoId, parentMetaType = route.parentMetaType ?: route.type, initialPositionMs = resolvedResumePositionMs ?: 0L, initialProgressFraction = resolvedResumeProgressFraction, ) ) route.streamContextId?.let(StreamContextStore::remove) navController.navigate( PlayerRoute(launchId = launchId) ) } }, onBack = { route.streamContextId?.let(StreamContextStore::remove) 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 } PlayerScreen( title = launch.title, sourceUrl = launch.sourceUrl, sourceAudioUrl = launch.sourceAudioUrl, sourceHeaders = launch.sourceHeaders, 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, 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 = { 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 { HomescreenSettingsScreen( onBack = { navController.popBackStack() }, ) } composable { MetaScreenSettingsScreen( onBack = { navController.popBackStack() }, ) } composable { ContinueWatchingSettingsScreen( onBack = { navController.popBackStack() }, ) } composable { AddonsSettingsScreen( onBack = { navController.popBackStack() }, ) } if (AppFeaturePolicy.pluginsEnabled) { composable { PluginsSettingsScreen( onBack = { navController.popBackStack() }, ) } } composable { AccountSettingsScreen( onBack = { navController.popBackStack() }, ) } } NuvioPosterActionSheet( item = selectedPosterForActions, isSaved = selectedPosterForActions?.let { preview -> LibraryRepository.isSaved(preview.id) } == 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 coroutineScope.launch { pickerPending = true pickerError = null runCatching { val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) val tabs = LibraryRepository.traktListTabs() pickerTabs = tabs pickerMembership = tabs.associate { tab -> tab.key to (snapshot[tab.key] == true) } showLibraryListPicker = true }.onFailure { error -> pickerError = error.message ?: "Failed to load Trakt lists" } pickerPending = false } } } }, onToggleWatched = { selectedPosterForActions?.let { preview -> coroutineScope.launch { WatchingActions.togglePosterWatched(preview) } } }, ) 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 } }, ) 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 } } } } @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 = {}, onAddonsSettingsClick: () -> Unit = {}, onPluginsSettingsClick: () -> Unit = {}, onAccountSettingsClick: () -> Unit = {}, onInitialHomeContentRendered: () -> Unit = {}, ) { Box(modifier = modifier.fillMaxSize()) { keepAliveTab( selected = selectedTab == AppScreenTab.Home, ) { HomeScreen( modifier = Modifier.fillMaxSize(), onCatalogClick = onCatalogClick, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, onContinueWatchingClick = onContinueWatchingClick, onContinueWatchingLongPress = onContinueWatchingLongPress, onFirstCatalogRendered = onInitialHomeContentRendered, ) } keepAliveTab( selected = selectedTab == AppScreenTab.Search, ) { SearchScreen( modifier = Modifier.fillMaxSize(), onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, ) } keepAliveTab( selected = selectedTab == AppScreenTab.Library, ) { LibraryScreen( modifier = Modifier.fillMaxSize(), onPosterClick = onLibraryPosterClick, onSectionViewAllClick = onLibrarySectionViewAllClick, ) } keepAliveTab( selected = selectedTab == AppScreenTab.Settings, ) { SettingsScreen( modifier = Modifier.fillMaxSize(), onSwitchProfile = onSwitchProfile, onHomescreenClick = onHomescreenSettingsClick, onMetaScreenClick = onMetaScreenSettingsClick, onContinueWatchingClick = onContinueWatchingSettingsClick, onAddonsClick = onAddonsSettingsClick, onPluginsClick = onPluginsSettingsClick, onAccountClick = onAccountSettingsClick, ) } } } @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.Rounded.Home, contentDescription = null, modifier = Modifier.size(18.dp), ) }, ) TabletTopPillItem( label = "Search", selected = selectedTab == AppScreenTab.Search, onClick = { onTabSelected(AppScreenTab.Search) }, icon = { Icon( imageVector = Icons.Rounded.Search, contentDescription = null, modifier = Modifier.size(18.dp), ) }, ) TabletTopPillItem( label = "Library", selected = selectedTab == AppScreenTab.Library, onClick = { onTabSelected(AppScreenTab.Library) }, icon = { Icon( imageVector = Icons.Rounded.VideoLibrary, contentDescription = null, modifier = Modifier.size(18.dp), ) }, ) 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) } } } @Composable private fun BoxScope.keepAliveTab( selected: Boolean, content: @Composable () -> Unit, ) { val contentAlpha by animateFloatAsState( targetValue = if (selected) 1f else 0f, animationSpec = tween(durationMillis = 220), label = "tab_content_alpha", ) Box( modifier = Modifier .fillMaxSize() .alpha(contentAlpha) .zIndex(if (selected) 1f else 0f), ) { content() } }