package com.nuvio.app import androidx.compose.animation.AnimatedContent 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.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.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.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.PlatformBackHandler import com.nuvio.app.core.ui.configurePlatformImageLoader import com.nuvio.app.core.ui.NuvioToastHost 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.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.collection.FolderDetailScreen import com.nuvio.app.features.collection.FolderDetailRepository import com.nuvio.app.features.streams.StreamContext import com.nuvio.app.features.streams.StreamContextStore import com.nuvio.app.features.streams.StreamAutoPlayPolicy 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.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 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 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 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, val manualSelection: Boolean = false, val startFromBeginning: Boolean = false, ) @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) @Composable private fun MainAppContent( onSwitchProfile: () -> Unit = {}, ) { val navController = rememberNavController() remember { EpisodeReleaseNotificationsRepository.ensureLoaded() } remember { CollectionSyncService.startObserving() } val hapticFeedback = LocalHapticFeedback.current val coroutineScope = rememberCoroutineScope() var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } 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 libraryUiState by remember { LibraryRepository.ensureLoaded() LibraryRepository.uiState }.collectAsStateWithLifecycle() val traktAuthUiState by remember { TraktAuthRepository.ensureLoaded() TraktAuthRepository.uiState }.collectAsStateWithLifecycle() val playerSettingsUiState by remember { PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.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 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 -> 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, manualSelection = true, ) ) } 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 -> 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 = if (startFromBeginning) 0L else item.resumePositionMs, resumeProgressFraction = if (startFromBeginning) null else 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), ) { 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 = 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) }, 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 -> 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) } var reuseNavigated by remember { mutableStateOf(false) } LaunchedEffect(effectiveVideoId, hasResolvedVideoId, playerSettings.streamReuseLastLinkEnabled, route.manualSelection) { if (!hasResolvedVideoId) return@LaunchedEffect if (reuseHandled) return@LaunchedEffect reuseHandled = true if (route.manualSelection) return@LaunchedEffect 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) { reuseNavigated = true StreamsRepository.clear() val launchId = PlayerLaunchStore.put( PlayerLaunch( title = route.title, sourceUrl = cached.url, sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders), sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders), 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, bingeGroup = cached.bingeGroup, 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, route.manualSelection) { if (!reuseHandled) return@LaunchedEffect if (route.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(route.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 = route.title, sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), 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, bingeGroup = stream.behaviorHints.bingeGroup, 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, manualSelection = route.manualSelection, startFromBeginning = route.startFromBeginning, 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, 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 = route.title, sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), 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, bingeGroup = stream.behaviorHints.bingeGroup, 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, 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 = { 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, ) 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 } }, ) 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 } } 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 = {}, 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, 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) } } }