diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 0a1500173..02d3a58db 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -423,7 +423,7 @@ kotlin { implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.atomicfu) implementation(libs.kmpalette.core) - implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation3.ui) implementation(libs.kermit) implementation(libs.supabase.postgrest) implementation(libs.supabase.auth) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.android.kt index a1820c50c..d9e4e7b44 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.android.kt @@ -1,7 +1,9 @@ package com.nuvio.app.core.ui import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBars import androidx.compose.runtime.Composable import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -11,3 +13,7 @@ internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp internal actual val nuvioBottomNavigationExtraVerticalPadding: Dp = 6.dp @Composable internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets = WindowInsets.navigationBars + +@Composable +internal actual fun platformPhysicalTopInset(): Dp = + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 35c3eccd6..df77e9c03 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -3,6 +3,7 @@ package com.nuvio.app import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -60,18 +61,13 @@ import androidx.compose.ui.platform.LocalUriHandler 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.NavController -import androidx.navigation.NavDestination.Companion.hasRoute -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 androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.LocalNavAnimatedContentScope +import androidx.navigation3.ui.NavDisplay +import androidx.savedstate.serialization.SavedStateConfiguration import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory import coil3.request.CachePolicy @@ -105,11 +101,11 @@ import com.nuvio.app.core.ui.NuvioTheme import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding import com.nuvio.app.core.ui.NativeNavigationTab +import com.nuvio.app.core.ui.NativeProfileSwitcherController import com.nuvio.app.core.ui.NativeTabBridge import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle import com.nuvio.app.core.ui.nuvio -import com.nuvio.app.core.ui.nuvioBottomNavigationBarInsets import com.nuvio.app.features.auth.AuthScreen import com.nuvio.app.features.addons.AddAddonResult import com.nuvio.app.features.addons.AddonRepository @@ -129,6 +125,7 @@ import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver import com.nuvio.app.features.debrid.toastMessage import com.nuvio.app.features.downloads.DownloadsRepository import com.nuvio.app.features.downloads.DownloadsScreen +import com.nuvio.app.features.downloads.DownloadItem import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsScreen import com.nuvio.app.features.details.MetaPerson @@ -150,7 +147,6 @@ import com.nuvio.app.features.p2p.P2pConsentDialog import com.nuvio.app.features.p2p.P2pSettingsRepository 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.PlayerPlaybackSnapshot import com.nuvio.app.features.player.ExternalPlayerIntentResult @@ -163,7 +159,6 @@ import com.nuvio.app.features.player.sanitizePlaybackHeaders import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders import com.nuvio.app.features.profiles.AvatarRepository import com.nuvio.app.features.profiles.NuvioProfile -import com.nuvio.app.features.profiles.NativeProfileSwitcherPopup import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileSelectionScreen @@ -184,7 +179,10 @@ 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.CollectionEditorPage import com.nuvio.app.features.collection.CollectionSyncService +import com.nuvio.app.features.collection.CollectionRepository +import com.nuvio.app.features.collection.disposeCollectionEditorPage import com.nuvio.app.features.collection.FolderDetailScreen import com.nuvio.app.features.collection.FolderDetailRepository import com.nuvio.app.features.streams.StreamAutoPlayPolicy @@ -218,7 +216,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass +import com.nuvio.app.navigation.* import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.app_logo_wordmark import nuvio.composeapp.generated.resources.compose_catalog_subtitle_library @@ -234,28 +235,34 @@ import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource -@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", -) +private val navigationSavedStateConfiguration = SavedStateConfiguration { + serializersModule = SerializersModule { + polymorphic(NavKey::class) { + subclass(TabsRoute::class, TabsRoute.serializer()) + subclass(DetailRoute::class, DetailRoute.serializer()) + subclass(PersonDetailRoute::class, PersonDetailRoute.serializer()) + subclass(EntityBrowseRoute::class, EntityBrowseRoute.serializer()) + subclass(SettingsPageRoute::class, SettingsPageRoute.serializer()) + subclass(HomescreenSettingsRoute::class, HomescreenSettingsRoute.serializer()) + subclass(MetaScreenSettingsRoute::class, MetaScreenSettingsRoute.serializer()) + subclass(ContinueWatchingSettingsRoute::class, ContinueWatchingSettingsRoute.serializer()) + subclass(DownloadsSettingsRoute::class, DownloadsSettingsRoute.serializer()) + subclass(DownloadShowRoute::class, DownloadShowRoute.serializer()) + subclass(AddonsSettingsRoute::class, AddonsSettingsRoute.serializer()) + subclass(PluginsSettingsRoute::class, PluginsSettingsRoute.serializer()) + subclass(AccountSettingsRoute::class, AccountSettingsRoute.serializer()) + subclass(SupportersContributorsSettingsRoute::class, SupportersContributorsSettingsRoute.serializer()) + subclass(LicensesAttributionsSettingsRoute::class, LicensesAttributionsSettingsRoute.serializer()) + subclass(CollectionsRoute::class, CollectionsRoute.serializer()) + subclass(CollectionEditorRoute::class, CollectionEditorRoute.serializer()) + subclass(CollectionEditorPageRoute::class, CollectionEditorPageRoute.serializer()) + subclass(FolderDetailRoute::class, FolderDetailRoute.serializer()) + subclass(StreamRoute::class, StreamRoute.serializer()) + subclass(CatalogRoute::class, CatalogRoute.serializer()) + subclass(PlayerRoute::class, PlayerRoute.serializer()) + } + } +} private data class PendingP2pStreamOpen( val stream: StreamItem, @@ -266,52 +273,6 @@ private data class PendingP2pStreamOpen( val isAutoPlay: Boolean, ) -@Serializable -object HomescreenSettingsRoute - -@Serializable -object MetaScreenSettingsRoute - -@Serializable -object ContinueWatchingSettingsRoute - -@Serializable -object DownloadsSettingsRoute - -@Serializable -object AddonsSettingsRoute - -@Serializable -object PluginsSettingsRoute - -@Serializable -object AccountSettingsRoute - -@Serializable -object SupportersContributorsSettingsRoute - -@Serializable -object LicensesAttributionsSettingsRoute - -@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 launchId: Long, -) - private data class CatalogLaunch( val title: String, val subtitle: String, @@ -335,6 +296,35 @@ private object CatalogLaunchStore { } } +/** Idempotent cleanup used by both Navigation 3 and SwiftUI interactive-pop handling. */ +fun disposeRoute(route: AppRoute) { + when (route) { + is StreamRoute -> { + StreamsRepository.clear() + StreamLaunchStore.remove(route.launchId) + } + + is PlayerRoute -> { + ResumePromptRepository.markPlayerExitedNormally() + PlayerLaunchStore.remove(route.launchId) + } + + is CatalogRoute -> { + CatalogRepository.clear() + CatalogLaunchStore.remove(route.launchId) + } + + is CollectionEditorRoute -> CollectionEditorRepository.clear() + is CollectionEditorPageRoute -> { + runCatching { CollectionEditorPage.valueOf(route.pageName) } + .getOrNull() + ?.let(::disposeCollectionEditorPage) + } + is FolderDetailRoute -> FolderDetailRepository.clear() + else -> Unit + } +} + private data class PosterActionTarget( val preview: MetaPreview, val libraryItem: LibraryItem? = null, @@ -346,6 +336,12 @@ enum class AppScreenTab { Search, Library, Settings, + ; + + companion object { + fun fromName(name: String): AppScreenTab = + entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: Home + } } private fun AppScreenTab.toNativeNavigationTab(): NativeNavigationTab = when (this) { @@ -382,10 +378,33 @@ private enum class AppGateScreen { Main, } +private object NativeAppGateRequests { + val profileSelection = MutableSharedFlow(extraBufferCapacity = 1) + + fun requestProfileSelection() { + profileSelection.tryEmit(Unit) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable @Preview -fun App() { +fun App( + initialTab: AppScreenTab = AppScreenTab.Home, + initialRoute: AppRoute = TabsRoute, + useNativeNavigation: Boolean = false, + useNativeTabBar: Boolean = false, + useTabletFloatingTabBar: Boolean = false, + ownsAppRuntime: Boolean = true, + bypassAppGate: Boolean = false, + onNavigate: ((AppRoute, launchSingleTop: Boolean) -> Unit)? = null, + onGoBack: (() -> Unit)? = null, + onReplace: ((AppRoute) -> Unit)? = null, + onActivate: ((AppScreenTab) -> Unit)? = null, + onAppReady: ((Boolean) -> Unit)? = null, + onTabTitles: ((home: String, search: String, library: String, profile: String) -> Unit)? = null, + nativeProfileSwitcherController: NativeProfileSwitcherController? = null, +) { setSingletonImageLoaderFactory { context -> ImageLoader.Builder(context) .crossfade(true) @@ -403,11 +422,35 @@ fun App() { }.collectAsStateWithLifecycle() val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle() NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) { + if (bypassAppGate) { + MainAppContent( + initialTab = initialTab, + initialRoute = initialRoute, + useNativeNavigation = useNativeNavigation, + useNativeTabBar = useNativeTabBar, + useTabletFloatingTabBar = useTabletFloatingTabBar, + ownsAppRuntime = false, + onNavigate = onNavigate, + onGoBack = onGoBack, + onReplace = onReplace, + onActivate = onActivate, + onTabTitles = onTabTitles, + nativeProfileSwitcherController = nativeProfileSwitcherController, + onSwitchProfile = { + onActivate?.invoke(AppScreenTab.Home) + NativeAppGateRequests.requestProfileSelection() + }, + ) + return@NuvioTheme + } + LaunchedEffect(Unit) { + if (!ownsAppRuntime) return@LaunchedEffect AuthRepository.initialize() } LaunchedEffect(Unit) { + if (!ownsAppRuntime) return@LaunchedEffect NetworkStatusRepository.ensureStarted() ProfileRepository.loadCachedProfiles() AvatarRepository.fetchAvatars() @@ -445,6 +488,20 @@ fun App() { var isNewProfile by remember { mutableStateOf(false) } var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(gateScreen, onAppReady) { + if (gateScreen != AppGateScreen.Main.name) { + onAppReady?.invoke(false) + } + } + + LaunchedEffect(useNativeNavigation, ownsAppRuntime) { + if (!useNativeNavigation || !ownsAppRuntime) return@LaunchedEffect + NativeAppGateRequests.profileSelection.collect { + autoSkipProfileSelection = false + gateScreen = AppGateScreen.ProfileSelection.name + } + } + fun rememberedStartupProfile(profiles: List): NuvioProfile? { val currentProfileState = ProfileRepository.state.value if ( @@ -633,6 +690,23 @@ fun App() { } AppGateScreen.Main.name -> { MainAppContent( + initialTab = initialTab, + initialRoute = initialRoute, + useNativeNavigation = useNativeNavigation, + useNativeTabBar = useNativeTabBar, + useTabletFloatingTabBar = useTabletFloatingTabBar, + ownsAppRuntime = ownsAppRuntime, + onNavigate = onNavigate, + onGoBack = onGoBack, + onReplace = onReplace, + onActivate = onActivate, + onTabTitles = onTabTitles, + nativeProfileSwitcherController = nativeProfileSwitcherController, + onRootContentReady = { ready -> + onAppReady?.invoke( + ready && gateScreen == AppGateScreen.Main.name, + ) + }, onSwitchProfile = { autoSkipProfileSelection = false gateScreen = AppGateScreen.ProfileSelection.name @@ -647,31 +721,54 @@ fun App() { @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable private fun MainAppContent( + initialTab: AppScreenTab = AppScreenTab.Home, + initialRoute: AppRoute = TabsRoute, + useNativeNavigation: Boolean = false, + useNativeTabBar: Boolean = false, + useTabletFloatingTabBar: Boolean = false, + ownsAppRuntime: Boolean = true, + onNavigate: ((AppRoute, launchSingleTop: Boolean) -> Unit)? = null, + onGoBack: (() -> Unit)? = null, + onReplace: ((AppRoute) -> Unit)? = null, + onActivate: ((AppScreenTab) -> Unit)? = null, + onTabTitles: ((home: String, search: String, library: String, profile: String) -> Unit)? = null, + nativeProfileSwitcherController: NativeProfileSwitcherController? = null, + onRootContentReady: ((Boolean) -> Unit)? = null, onSwitchProfile: () -> Unit = {}, ) { - val navController = rememberNavController() + val navBackStack = rememberNavBackStack(navigationSavedStateConfiguration, initialRoute) + val navController = remember(navBackStack, onNavigate, onGoBack, onReplace) { + NuvioNavigator( + backStack = navBackStack, + onExternalNavigate = onNavigate, + onExternalBack = onGoBack, + onExternalReplace = onReplace, + onRouteRemoved = ::disposeRoute, + ) + } val appUpdaterController = rememberAppUpdaterController() - remember { - EpisodeReleaseNotificationsRepository.ensureLoaded() - } - remember { - CollectionSyncService.startObserving() - } - remember { - ProfileSettingsSync.startObserving() + if (ownsAppRuntime) { + remember { + EpisodeReleaseNotificationsRepository.ensureLoaded() + } + remember { + CollectionSyncService.startObserving() + } + remember { + ProfileSettingsSync.startObserving() + } } val hapticFeedback = LocalHapticFeedback.current val focusManager = LocalFocusManager.current val uriHandler = LocalUriHandler.current val coroutineScope = rememberCoroutineScope() - var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } + var selectedTab by rememberSaveable(initialTab) { mutableStateOf(initialTab) } var searchFocusRequestCount by remember { mutableStateOf(0) } val homeScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val searchScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val libraryScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val settingsRootActionRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } - var nativeProfileSwitcherVisible by remember { mutableStateOf(false) } - val currentBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStack.lastOrNull() as? AppRoute val liquidGlassNativeTabBarEnabled by remember { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled }.collectAsStateWithLifecycle() @@ -740,16 +837,39 @@ private fun MainAppContent( val nativeTabSearchTitle = stringResource(Res.string.compose_nav_search) val nativeTabLibraryTitle = stringResource(Res.string.compose_nav_library) val nativeTabProfileTitle = stringResource(Res.string.compose_nav_profile) + val homescreenSettingsTitle = stringResource(Res.string.compose_settings_page_homescreen) + val metaScreenSettingsTitle = stringResource(Res.string.compose_settings_page_meta_screen) + val continueWatchingSettingsTitle = stringResource(Res.string.compose_settings_page_continue_watching) + val debridSettingsTitle = stringResource(Res.string.compose_settings_page_debrid) + val downloadsSettingsTitle = stringResource(Res.string.compose_settings_root_downloads_title) + val addonsSettingsTitle = stringResource(Res.string.compose_settings_page_addons) + val pluginsSettingsTitle = stringResource(Res.string.compose_settings_page_plugins) + val accountSettingsTitle = stringResource(Res.string.compose_settings_page_account) + val supportersSettingsTitle = stringResource(Res.string.compose_settings_page_supporters_contributors) + val licensesSettingsTitle = stringResource(Res.string.compose_settings_page_licenses_attributions) + val collectionsTitle = stringResource(Res.string.collections_header) + val newCollectionTitle = stringResource(Res.string.collections_new) + val detailsFallbackTitle = stringResource(Res.string.meta_section_details_title) val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT - var initialHomeReady by rememberSaveable { mutableStateOf(false) } + var initialHomeReady by rememberSaveable(ownsAppRuntime) { + mutableStateOf(!ownsAppRuntime) + } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) } var lastNetworkToastCondition by rememberSaveable { mutableStateOf(NetworkCondition.Unknown.name) } var watchSourceReconnectPending by remember { mutableStateOf(false) } + fun activateTab(tab: AppScreenTab) { + if (useNativeNavigation && onActivate != null) { + onActivate(tab) + } else { + selectedTab = tab + } + } + fun handleRootTabClick(tab: AppScreenTab) { if (selectedTab != tab) { - selectedTab = tab + activateTab(tab) return } @@ -764,18 +884,27 @@ private fun MainAppContent( } } - LaunchedEffect(liquidGlassNativeTabBarSupported, liquidGlassNativeTabBarEnabled) { + LaunchedEffect( + liquidGlassNativeTabBarSupported, + liquidGlassNativeTabBarEnabled, + useNativeNavigation, + currentRoute, + selectedTab, + ) { NativeTabBridge.requestedTabs.collectLatest { requestedTab -> - if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) { - handleRootTabClick(requestedTab.toAppScreenTab()) - } - } - } - - LaunchedEffect(liquidGlassNativeTabBarSupported, liquidGlassNativeTabBarEnabled) { - NativeTabBridge.profileTabLongPresses.collectLatest { - if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) { - nativeProfileSwitcherVisible = true + val requestedAppTab = requestedTab.toAppScreenTab() + if ( + useNativeNavigation && + currentRoute is TabsRoute && + requestedAppTab == selectedTab + ) { + handleRootTabClick(requestedAppTab) + } else if ( + !useNativeNavigation && + liquidGlassNativeTabBarSupported && + liquidGlassNativeTabBarEnabled + ) { + handleRootTabClick(requestedAppTab) } } } @@ -785,6 +914,7 @@ private fun MainAppContent( nativeTabSearchTitle, nativeTabLibraryTitle, nativeTabProfileTitle, + onTabTitles, ) { NativeTabBridge.publishTabTitles( home = nativeTabHomeTitle, @@ -792,6 +922,12 @@ private fun MainAppContent( library = nativeTabLibraryTitle, profile = nativeTabProfileTitle, ) + onTabTitles?.invoke( + nativeTabHomeTitle, + nativeTabSearchTitle, + nativeTabLibraryTitle, + nativeTabProfileTitle, + ) } LaunchedEffect(selectedTab) { @@ -803,35 +939,69 @@ private fun MainAppContent( var profileSwitchLoading by remember { mutableStateOf(false) } - DisposableEffect( - navController, + LaunchedEffect(nativeProfileSwitcherController, ownsAppRuntime) { + if (!ownsAppRuntime) return@LaunchedEffect + nativeProfileSwitcherController?.selectedProfileIndices?.collectLatest { profileIndex -> + val profile = ProfileRepository.state.value.profiles + .firstOrNull { it.profileIndex == profileIndex } + ?: return@collectLatest + profileSwitchLoading = true + activateTab(AppScreenTab.Home) + ProfileRepository.selectProfile(profile.profileIndex) + SyncManager.pullAllForProfile(profile.profileIndex) + } + } + + LaunchedEffect(nativeProfileSwitcherController, ownsAppRuntime, onSwitchProfile) { + if (!ownsAppRuntime) return@LaunchedEffect + nativeProfileSwitcherController?.requestedManageProfiles?.collectLatest { + activateTab(AppScreenTab.Home) + onSwitchProfile() + } + } + val launchOverlayState = remember(ownsAppRuntime) { + MutableTransitionState( + ownsAppRuntime && (!initialHomeReady || profileSwitchLoading), + ) + } + launchOverlayState.targetState = + ownsAppRuntime && (!initialHomeReady || profileSwitchLoading) + + LaunchedEffect( + launchOverlayState.targetState, + ownsAppRuntime, + onRootContentReady, + ) { + if (ownsAppRuntime) { + onRootContentReady?.invoke(!launchOverlayState.targetState) + } + } + + LaunchedEffect( + currentRoute, liquidGlassNativeTabBarSupported, liquidGlassNativeTabBarEnabled, initialHomeReady, profileSwitchLoading, + useNativeNavigation, ) { - fun publishNativeTabVisibilityForCurrentRoute() { - val visible = liquidGlassNativeTabBarSupported && - liquidGlassNativeTabBarEnabled && - initialHomeReady && - !profileSwitchLoading && - navController.currentDestination?.hasRoute() == true - NativeTabBridge.publishTabBarVisible(visible) - } + val visible = !useNativeNavigation && + liquidGlassNativeTabBarSupported && + liquidGlassNativeTabBarEnabled && + initialHomeReady && + !profileSwitchLoading && + currentRoute is TabsRoute + NativeTabBridge.publishTabBarVisible(visible) + } - val destinationChangedListener = NavController.OnDestinationChangedListener { _, _, _ -> - publishNativeTabVisibilityForCurrentRoute() - } - - publishNativeTabVisibilityForCurrentRoute() - navController.addOnDestinationChangedListener(destinationChangedListener) + DisposableEffect(Unit) { onDispose { - navController.removeOnDestinationChangedListener(destinationChangedListener) NativeTabBridge.publishTabBarVisible(false) } } LaunchedEffect(Unit) { + if (!ownsAppRuntime) return@LaunchedEffect NetworkStatusRepository.ensureStarted() EpisodeReleaseNotificationsRepository.refreshAsync() kotlinx.coroutines.delay(5_000) @@ -839,12 +1009,14 @@ private fun MainAppContent( } LaunchedEffect(Unit) { + if (!ownsAppRuntime) return@LaunchedEffect AppForegroundMonitor.events().collect { NetworkStatusRepository.requestForegroundRefresh() } } LaunchedEffect(networkStatusUiState.condition) { + if (!ownsAppRuntime) return@LaunchedEffect val condition = networkStatusUiState.condition if (!networkToastBaselineReady) { networkToastBaselineReady = true @@ -886,6 +1058,7 @@ private fun MainAppContent( (authState as? AuthState.Authenticated)?.userId, profileState.activeProfile?.profileIndex, ) { + if (!ownsAppRuntime) return@LaunchedEffect when (networkStatusUiState.condition) { NetworkCondition.NoInternet, NetworkCondition.ServersUnreachable, @@ -923,6 +1096,7 @@ private fun MainAppContent( networkStatusUiState.condition, downloadsUiState.completedItems, ) { + if (!ownsAppRuntime) return@LaunchedEffect if (!initialHomeReady || offlineLaunchRouteHandled) return@LaunchedEffect when (networkStatusUiState.condition) { @@ -942,8 +1116,8 @@ private fun MainAppContent( DownloadsRepository.playableLocalFileUri(it) != null } if (hasPlayableDownload) { - selectedTab = AppScreenTab.Settings - navController.navigate(DownloadsSettingsRoute) { + activateTab(AppScreenTab.Settings) + navController.navigate(DownloadsSettingsRoute(downloadsSettingsTitle)) { launchSingleTop = true } } @@ -952,6 +1126,7 @@ private fun MainAppContent( } LaunchedEffect(authState, profileState.activeProfile?.profileIndex) { + if (!ownsAppRuntime) return@LaunchedEffect if (!RealtimeSyncConfig.ENABLED) { RealtimeSyncInvalidationService.stop() return@LaunchedEffect @@ -969,33 +1144,34 @@ private fun MainAppContent( DisposableEffect(authState, profileState.activeProfile?.profileIndex) { val authenticatedState = authState as? AuthState.Authenticated - if ( + if (ownsAppRuntime && ( !RealtimeSyncConfig.ENABLED || authenticatedState == null || authenticatedState.isAnonymous || profileState.activeProfile == null - ) { + )) { RealtimeSyncInvalidationService.stop() } onDispose { - RealtimeSyncInvalidationService.stop() + if (ownsAppRuntime) RealtimeSyncInvalidationService.stop() } } DisposableEffect(authState, profileState.activeProfile?.profileIndex) { val authenticatedState = authState as? AuthState.Authenticated val activeProfileId = profileState.activeProfile?.profileIndex - if (authenticatedState != null && !authenticatedState.isAnonymous && activeProfileId != null) { + if (ownsAppRuntime && authenticatedState != null && !authenticatedState.isAnonymous && activeProfileId != null) { SyncManager.startPeriodicNuvioSyncPull(activeProfileId) - } else { + } else if (ownsAppRuntime) { SyncManager.stopPeriodicNuvioSyncPull() } onDispose { - SyncManager.stopPeriodicNuvioSyncPull() + if (ownsAppRuntime) SyncManager.stopPeriodicNuvioSyncPull() } } LaunchedEffect(authState, profileState.activeProfile?.profileIndex) { + if (!ownsAppRuntime) return@LaunchedEffect val authenticatedState = authState as? AuthState.Authenticated ?: return@LaunchedEffect if (authenticatedState.isAnonymous) return@LaunchedEffect @@ -1084,6 +1260,7 @@ private fun MainAppContent( profileState.activeProfile?.profileIndex, continueWatchingPreferencesUiState.showResumePromptOnLaunch, ) { + if (!ownsAppRuntime) return@LaunchedEffect if (!initialHomeReady || profileSwitchLoading) return@LaunchedEffect if (resumePromptItem != null) return@LaunchedEffect if (continueWatchingPreferencesUiState.showResumePromptOnLaunch) { @@ -1091,28 +1268,37 @@ private fun MainAppContent( } } - LaunchedEffect(currentBackStackEntry?.destination) { - val inPlaybackFlow = currentBackStackEntry?.destination?.hasRoute() == true || - currentBackStackEntry?.destination?.hasRoute() == true + LaunchedEffect(currentRoute) { + val inPlaybackFlow = currentRoute is StreamRoute || currentRoute is PlayerRoute if (inPlaybackFlow) { resumePromptItem = null } } LaunchedEffect(navController) { + if (!ownsAppRuntime) return@LaunchedEffect AppDeepLinkRepository.pendingDeepLink.collectLatest { deepLink -> when (deepLink) { is AppDeepLink.Meta -> { - selectedTab = AppScreenTab.Home - navController.navigate(DetailRoute(type = deepLink.type, id = deepLink.id)) { + activateTab(AppScreenTab.Home) + val routeTitle = runCatching { + MetaDetailsRepository.fetch(deepLink.type, deepLink.id)?.name + }.getOrNull().orEmpty().ifBlank { detailsFallbackTitle } + navController.navigate( + DetailRoute( + type = deepLink.type, + id = deepLink.id, + title = routeTitle, + ) + ) { launchSingleTop = true } AppDeepLinkRepository.markConsumed(deepLink) } is AppDeepLink.AddonInstall -> { - selectedTab = AppScreenTab.Settings - navController.navigate(AddonsSettingsRoute) { + activateTab(AppScreenTab.Settings) + navController.navigate(AddonsSettingsRoute(addonsSettingsTitle)) { launchSingleTop = true } NuvioToastController.show(getString(Res.string.addons_modal_checking_title)) @@ -1132,8 +1318,8 @@ private fun MainAppContent( } AppDeepLink.Downloads -> { - selectedTab = AppScreenTab.Settings - navController.navigate(DownloadsSettingsRoute) { + activateTab(AppScreenTab.Settings) + navController.navigate(DownloadsSettingsRoute(downloadsSettingsTitle)) { launchSingleTop = true } AppDeepLinkRepository.markConsumed(deepLink) @@ -1197,6 +1383,47 @@ private fun MainAppContent( } } + fun openDownloadedItem(item: DownloadItem) { + val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return + val resumeEntry = item.videoId + .takeIf { it.isNotBlank() } + ?.let(WatchProgressRepository::progressForVideo) + ?.takeIf { it.isResumable } + + val playerLaunch = PlayerLaunch( + profileId = activePlaybackProfileId, + title = item.title, + sourceUrl = sourceUrl, + sourceHeaders = emptyMap(), + sourceResponseHeaders = emptyMap(), + externalSubtitles = emptyList(), + streamType = null, + 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 }, + ) + if (playerSettingsUiState.externalPlayerEnabled) { + coroutineScope.launch { openExternalPlayback(playerLaunch) } + return + } + val launchId = PlayerLaunchStore.put(playerLaunch) + navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title)) + } + fun openExternalStreamUrl(url: String): Boolean { val opened = runCatching { uriHandler.openUri(url) @@ -1245,7 +1472,7 @@ private fun MainAppContent( true } else { val launchId = PlayerLaunchStore.put(playerLaunch) - navController.navigate(PlayerRoute(launchId = launchId)) + navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title)) true } } @@ -1316,7 +1543,7 @@ private fun MainAppContent( return } val launchId = PlayerLaunchStore.put(playerLaunch) - navController.navigate(PlayerRoute(launchId = launchId)) + navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title)) return } } @@ -1344,7 +1571,7 @@ private fun MainAppContent( ), ) navController.navigate( - StreamRoute(launchId = streamLaunchId), + StreamRoute(launchId = streamLaunchId, title = title), ) } @@ -1405,6 +1632,8 @@ private fun MainAppContent( navController.navigate( CatalogRoute( launchId = launchId, + title = section.title, + subtitle = section.subtitle, ), ) } @@ -1429,6 +1658,8 @@ private fun MainAppContent( navController.navigate( CatalogRoute( launchId = launchId, + title = section.displayTitle, + subtitle = librarySectionSubtitle, ), ) } @@ -1521,17 +1752,22 @@ private fun MainAppContent( .background(MaterialTheme.nuvio.colors.background), ) { SharedTransitionLayout { - NavHost( - navController = navController, - startDestination = TabsRoute, - modifier = Modifier.fillMaxSize(), + CompositionLocalProvider( + LocalUseNativeNavigation provides useNativeNavigation, + LocalNativeNavigationBarHidden provides (currentRoute?.hidesNavigationBar == true), ) { - composable { + NavDisplay( + backStack = navBackStack, + modifier = Modifier.fillMaxSize(), + onBack = { navController.popBackStack() }, + sharedTransitionScope = this@SharedTransitionLayout, + entryProvider = entryProvider { + entry { PlatformBackHandler( enabled = true, onBack = { if (selectedTab != AppScreenTab.Home) { - selectedTab = AppScreenTab.Home + activateTab(AppScreenTab.Home) } else { showExitConfirmation = !showExitConfirmation } @@ -1539,20 +1775,17 @@ private fun MainAppContent( ) BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val isTabletLayout = maxWidth >= 768.dp - val useNativeBottomTabs = + val isTabletLayout = useTabletFloatingTabBar || maxWidth >= 768.dp + val useNativeBottomTabs = if (useNativeNavigation) { + useNativeTabBar + } else { liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady - val nativeTabSafeBottomPadding = nuvioBottomNavigationBarInsets() - .asPaddingValues() - .calculateBottomPadding() - val nativeProfileTabAnchorBottomPadding = - nativeTabSafeBottomPadding + NuvioTokens.Space.s10 - val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute() == true + } + val tabsRouteActive = currentRoute is TabsRoute val onProfileSelected: (NuvioProfile) -> Unit = { profile -> - nativeProfileSwitcherVisible = false profileSwitchLoading = true NativeTabBridge.publishTabBarVisible(false) - selectedTab = AppScreenTab.Home + activateTab(AppScreenTab.Home) ProfileRepository.selectProfile(profile.profileIndex) com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex) } @@ -1617,13 +1850,13 @@ private fun MainAppContent( animateHomeCollectionGifs = tabsRouteActive, onCatalogClick = onCatalogClick, onPosterClick = { meta -> - navController.navigate(DetailRoute(type = meta.type, id = meta.id)) + navController.navigate(DetailRoute(type = meta.type, id = meta.id, title = meta.name)) }, onPosterLongClick = { meta -> openPosterActions(PosterActionTarget(preview = meta)) }, onLibraryPosterClick = { item -> - navController.navigate(DetailRoute(type = item.type, id = item.id)) + navController.navigate(DetailRoute(type = item.type, id = item.id, title = item.name)) }, onLibraryPosterLongClick = { item, section -> openPosterActions( @@ -1654,30 +1887,47 @@ private fun MainAppContent( } }, onConnectCloudClick = { - requestedSettingsPageName = "Debrid" - selectedTab = AppScreenTab.Settings + if (useNativeNavigation && !isTabletLayout) { + activateTab(AppScreenTab.Settings) + navController.navigate( + SettingsPageRoute( + pageName = "Debrid", + title = debridSettingsTitle, + ) + ) + } else { + requestedSettingsPageName = "Debrid" + activateTab(AppScreenTab.Settings) + } }, 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) }, + onSettingsPageClick = if (useNativeNavigation && !isTabletLayout) { + { pageName, title -> + navController.navigate(SettingsPageRoute(pageName, title)) + } + } else { + null + }, + onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute(homescreenSettingsTitle)) }, + onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute(metaScreenSettingsTitle)) }, + onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute(continueWatchingSettingsTitle)) }, + onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute(downloadsSettingsTitle)) }, + onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute(addonsSettingsTitle)) }, onPluginsSettingsClick = { if (AppFeaturePolicy.pluginsEnabled) { - navController.navigate(PluginsSettingsRoute) + navController.navigate(PluginsSettingsRoute(pluginsSettingsTitle)) } }, - onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) }, + onAccountSettingsClick = { navController.navigate(AccountSettingsRoute(accountSettingsTitle)) }, onSupportersContributorsSettingsClick = { if (AppFeaturePolicy.supportersContributorsPageEnabled) { - navController.navigate(SupportersContributorsSettingsRoute) + navController.navigate(SupportersContributorsSettingsRoute(supportersSettingsTitle)) } }, onLicensesAttributionsSettingsClick = { - navController.navigate(LicensesAttributionsSettingsRoute) + navController.navigate(LicensesAttributionsSettingsRoute(licensesSettingsTitle)) }, onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) { { @@ -1689,9 +1939,21 @@ private fun MainAppContent( } else { null }, - onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) }, + onCollectionsSettingsClick = { navController.navigate(CollectionsRoute(collectionsTitle)) }, onFolderClick = { collectionId, folderId -> - navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId)) + val folderTitle = CollectionRepository.collections.value + .firstOrNull { it.id == collectionId } + ?.folders + ?.firstOrNull { it.id == folderId } + ?.title + .orEmpty() + navController.navigate( + FolderDetailRoute( + collectionId = collectionId, + folderId = folderId, + title = folderTitle.ifBlank { collectionsTitle }, + ) + ) }, requestedSettingsPageName = requestedSettingsPageName, onRequestedSettingsPageConsumed = { @@ -1706,43 +1968,23 @@ private fun MainAppContent( selectedTab = selectedTab, onTabSelected = ::handleRootTabClick, onProfileSelected = onProfileSelected, - onAddProfileRequested = { - nativeProfileSwitcherVisible = false - onSwitchProfile() - }, - ) - } - - if (!isTabletLayout && useNativeBottomTabs && tabsRouteActive) { - NativeProfileSwitcherPopup( - visible = nativeProfileSwitcherVisible, - isSwitchingProfile = profileSwitchLoading, - onDismissRequest = { nativeProfileSwitcherVisible = false }, - onProfileSelected = onProfileSelected, - onAddProfileRequested = { - nativeProfileSwitcherVisible = false - onSwitchProfile() - }, - modifier = Modifier - .fillMaxSize() - .padding(bottom = nativeProfileTabAnchorBottomPadding), + onAddProfileRequested = onSwitchProfile, ) } } } } } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + entry { route -> + val onBack = rememberGuardedPopBackStack(navController, route) + val animatedVisibilityScope = LocalNavAnimatedContentScope.current val directorRole = stringResource(Res.string.person_role_director) val writerRole = stringResource(Res.string.person_role_writer) val creatorRole = stringResource(Res.string.person_role_creator) MetaDetailsScreen( type = route.type, id = route.id, - onBack = { - navController.popBackStack() - }, + onBack = onBack, onPlay = onPlay, onPlayManually = onPlayManually, onOpenMeta = { preview -> @@ -1762,6 +2004,7 @@ private fun MainAppContent( DetailRoute( type = preview.type, id = resolvedId, + title = preview.name, ), ) } @@ -1801,19 +2044,20 @@ private fun MainAppContent( } }, sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, + animatedVisibilityScope = animatedVisibilityScope, modifier = Modifier.fillMaxSize(), ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + entry { route -> + val onBack = rememberGuardedPopBackStack(navController, route) + val animatedVisibilityScope = LocalNavAnimatedContentScope.current PersonDetailScreen( personId = route.personId, personName = route.personName, initialProfilePhoto = route.personPhoto, avatarTransitionKey = route.castAvatarTransitionKey, preferCrew = route.preferCrew, - onBack = { navController.popBackStack() }, + onBack = onBack, onOpenMeta = { preview -> coroutineScope.launch { val resolvedId = if (preview.id.startsWith("tmdb:")) { @@ -1831,23 +2075,24 @@ private fun MainAppContent( DetailRoute( type = preview.type, id = resolvedId, + title = preview.name, ), ) } }, sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, + animatedVisibilityScope = animatedVisibilityScope, modifier = Modifier.fillMaxSize(), ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + entry { route -> + val onBack = rememberGuardedPopBackStack(navController, route) TmdbEntityBrowseScreen( entityKind = TmdbEntityKind.fromRouteValue(route.entityKind), entityId = route.entityId, entityName = route.entityName, sourceType = route.sourceType, - onBack = { navController.popBackStack() }, + onBack = onBack, onOpenMeta = { preview -> coroutineScope.launch { val resolvedId = if (preview.id.startsWith("tmdb:")) { @@ -1865,6 +2110,7 @@ private fun MainAppContent( DetailRoute( type = preview.type, id = resolvedId, + title = preview.name, ), ) } @@ -1872,34 +2118,21 @@ private fun MainAppContent( modifier = Modifier.fillMaxSize(), ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + entry { route -> + val onBack = rememberGuardedPopBackStack(navController, route) val launch = remember(route.launchId) { StreamLaunchStore.get(route.launchId) } if (launch == null) { LaunchedEffect(route.launchId) { - StreamsRepository.clear() - navController.popBackStack() + onBack() } - return@composable + return@entry } val pauseDescription = launch.pauseDescription val streamRouteScope = rememberCoroutineScope() var resolvingDebridStream by rememberSaveable(route.launchId) { mutableStateOf(false) } var pendingP2pStreamOpen by remember { mutableStateOf(null) } - 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 && @@ -2027,7 +2260,7 @@ private fun MainAppContent( val launchId = PlayerLaunchStore.put(playerLaunch) StreamsRepository.cancelLoading() - navController.navigate(PlayerRoute(launchId = launchId)) { + navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title)) { if (replaceStreamRoute) { popUpTo { inclusive = true } } @@ -2152,7 +2385,7 @@ private fun MainAppContent( StreamsRepository.clear() reuseNavigated = true val launchId = PlayerLaunchStore.put(playerLaunch) - navController.navigate(PlayerRoute(launchId = launchId)) { + navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title)) { popUpTo { inclusive = true } } } @@ -2288,7 +2521,7 @@ private fun MainAppContent( StreamsRepository.consumeAutoPlay() StreamsRepository.cancelLoading() val launchId = PlayerLaunchStore.put(playerLaunch) - navController.navigate(PlayerRoute(launchId = launchId)) { + navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title)) { popUpTo { inclusive = true } } } @@ -2300,7 +2533,7 @@ private fun MainAppContent( ) { NuvioLoadingIndicator(color = MaterialTheme.nuvio.colors.accent) } - return@composable + return@entry } fun openSelectedStream( @@ -2426,7 +2659,7 @@ private fun MainAppContent( val launchId = PlayerLaunchStore.put(playerLaunch) StreamsRepository.cancelLoading() navController.navigate( - PlayerRoute(launchId = launchId) + PlayerRoute(launchId = launchId, title = playerLaunch.title) ) } @@ -2473,10 +2706,7 @@ private fun MainAppContent( forceInternal = !openExternally, ) }, - onBack = { - StreamsRepository.clear() - navController.popBackStack() - }, + onBack = onBack, modifier = Modifier.fillMaxSize(), ) pendingP2pStreamOpen?.let { pending -> @@ -2522,28 +2752,27 @@ private fun MainAppContent( } } } - composable( - enterTransition = { - if (isIos) fadeIn(animationSpec = tween(220)) else null + entry( + metadata = if (isIos) { + NavDisplay.transitionSpec { + fadeIn(animationSpec = tween(220)) togetherWith + fadeOut(animationSpec = tween(220)) + } + NavDisplay.popTransitionSpec { + fadeIn(animationSpec = tween(220)) togetherWith + fadeOut(animationSpec = tween(220)) + } + } else { + emptyMap() }, - 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() + ) { route -> + val onBack = rememberGuardedPopBackStack(navController, route) val launch = remember(route.launchId) { PlayerLaunchStore.get(route.launchId) } if (launch == null) { LaunchedEffect(route.launchId) { - navController.popBackStack() + onBack() } Box(modifier = Modifier.fillMaxSize()) - return@composable + return@entry } LaunchedEffect(launch.videoId) { launch.videoId?.let { ResumePromptRepository.markPlayerEntered(it) } @@ -2581,11 +2810,7 @@ private fun MainAppContent( initialPositionMs = launch.initialPositionMs, initialProgressFraction = launch.initialProgressFraction, contentLanguage = launch.contentLanguage, - onBack = { - ResumePromptRepository.markPlayerExitedNormally() - PlayerLaunchStore.remove(route.launchId) - navController.popBackStack() - }, + onBack = onBack, onOpenInExternalPlayer = { request -> val playerLaunch = PlayerLaunch( profileId = launch.profileId, @@ -2637,27 +2862,23 @@ private fun MainAppContent( modifier = Modifier.fillMaxSize(), ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + entry { route -> + val onBack = rememberGuardedPopBackStack(navController, route) val launch = remember(route.launchId) { CatalogLaunchStore.get(route.launchId) } if (launch == null) { LaunchedEffect(route.launchId) { - navController.popBackStack() + onBack() } - return@composable + return@entry } val target = launch.target CatalogScreen( title = launch.title, subtitle = launch.subtitle, target = target, - onBack = { - CatalogRepository.clear() - CatalogLaunchStore.remove(route.launchId) - navController.popBackStack() - }, + onBack = onBack, onPosterClick = { meta -> - navController.navigate(DetailRoute(type = meta.type, id = meta.id)) + navController.navigate(DetailRoute(type = meta.type, id = meta.id, title = meta.name)) }, onPosterLongClick = { meta -> openPosterActions( @@ -2675,115 +2896,127 @@ private fun MainAppContent( modifier = Modifier.fillMaxSize(), ) } - composable { + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = it, + route = route, ) HomescreenSettingsScreen( onBack = onBack, ) } - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, ) MetaScreenSettingsScreen( onBack = onBack, ) } - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, ) ContinueWatchingSettingsScreen( onBack = onBack, ) } - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, ) - DownloadsScreen( - onBack = onBack, - onOpenDownload = { item -> - val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen - val resumeEntry = item.videoId - .takeIf { it.isNotBlank() } - ?.let(WatchProgressRepository::progressForVideo) - ?.takeIf { it.isResumable } - - val playerLaunch = PlayerLaunch( - profileId = activePlaybackProfileId, - title = item.title, - sourceUrl = sourceUrl, - sourceHeaders = emptyMap(), - sourceResponseHeaders = emptyMap(), - externalSubtitles = emptyList(), - streamType = null, - 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 }, - ) - if (playerSettingsUiState.externalPlayerEnabled) { - coroutineScope.launch { openExternalPlayback(playerLaunch) } - return@DownloadsScreen + SettingsScreen( + modifier = Modifier.fillMaxSize(), + initialPageName = route.pageName, + rootActionsEnabled = false, + onNavigatePage = { pageName, title -> + navController.navigate(SettingsPageRoute(pageName, title)) + }, + onExternalBack = onBack, + showInternalHeader = !useNativeNavigation, + onDownloadsClick = { + navController.navigate(DownloadsSettingsRoute(downloadsSettingsTitle)) + }, + onCollectionsClick = { + navController.navigate(CollectionsRoute(collectionsTitle)) + }, + onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) { + { + appUpdaterController.checkForUpdates( + force = true, + showNoUpdateFeedback = true, + ) } - val launchId = PlayerLaunchStore.put(playerLaunch) - navController.navigate(PlayerRoute(launchId = launchId)) + } else { + null }, ) } - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, + ) + DownloadsScreen( + onBack = onBack, + onOpenDownload = ::openDownloadedItem, + onNavigateToShow = if (useNativeNavigation) { + { showId, title -> + navController.navigate(DownloadShowRoute(showId, title)) + } + } else { + null + }, + ) + } + entry { route -> + val onBack = rememberGuardedPopBackStack( + navController = navController, + route = route, + ) + DownloadsScreen( + onBack = onBack, + onOpenDownload = ::openDownloadedItem, + initialShowId = route.showId, + onBackFromShow = onBack, + ) + } + entry { route -> + val onBack = rememberGuardedPopBackStack( + navController = navController, + route = route, ) AddonsSettingsScreen( onBack = onBack, ) } if (AppFeaturePolicy.pluginsEnabled) { - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, ) PluginsSettingsScreen( onBack = onBack, ) } } - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, ) AccountSettingsScreen( onBack = onBack, ) } - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, ) if (AppFeaturePolicy.supportersContributorsPageEnabled) { SupportersContributorsSettingsScreen( @@ -2795,53 +3028,104 @@ private fun MainAppContent( } } } - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, ) LicensesAttributionsSettingsScreen( onBack = onBack, ) } - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, ) CollectionManagementScreen( onBack = onBack, onNavigateToEditor = { collectionId -> - navController.navigate(CollectionEditorRoute(collectionId = collectionId)) + val editorTitle = collectionId + ?.let { id -> + CollectionRepository.collections.value.firstOrNull { it.id == id }?.title + } + .orEmpty() + navController.navigate( + CollectionEditorRoute( + collectionId = collectionId, + title = editorTitle.ifBlank { newCollectionTitle }, + ) + ) }, ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + entry { route -> + val onBack = rememberGuardedPopBackStack( + navController = navController, + route = route, + ) CollectionEditorScreen( collectionId = route.collectionId, - onBack = { - CollectionEditorRepository.clear() - navController.popBackStack() + onBack = onBack, + initialPage = if (useNativeNavigation) CollectionEditorPage.Root else null, + onNavigateToPage = if (useNativeNavigation) { + { page, title -> + navController.navigate( + CollectionEditorPageRoute( + collectionId = route.collectionId, + pageName = page.name, + title = title, + ) + ) + } + } else { + null }, ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + entry { route -> + val page = remember(route.pageName) { + runCatching { CollectionEditorPage.valueOf(route.pageName) }.getOrNull() + } + val onBack = rememberGuardedPopBackStack( + navController = navController, + route = route, + ) + if (page == null || page == CollectionEditorPage.Root) { + LaunchedEffect(route) { onBack() } + return@entry + } + CollectionEditorScreen( + collectionId = route.collectionId, + initialPage = page, + initializeRepository = false, + onBack = onBack, + onNavigateToPage = { nextPage, title -> + navController.navigate( + CollectionEditorPageRoute( + collectionId = route.collectionId, + pageName = nextPage.name, + title = title, + ) + ) + }, + ) + } + entry { route -> + val onBack = rememberGuardedPopBackStack(navController, route) LaunchedEffect(route.collectionId, route.folderId) { FolderDetailRepository.initialize(route.collectionId, route.folderId) } FolderDetailScreen( - onBack = { - FolderDetailRepository.clear() - navController.popBackStack() - }, + onBack = onBack, onCatalogClick = onCatalogClick, onPosterClick = { meta -> - navController.navigate(DetailRoute(type = meta.type, id = meta.id)) + navController.navigate(DetailRoute(type = meta.type, id = meta.id, title = meta.name)) }, ) } + }, + ) } } @@ -2932,6 +3216,7 @@ private fun MainAppContent( DetailRoute( type = item.parentMetaType, id = item.parentMetaId, + title = item.title, ), ) } @@ -3015,7 +3300,7 @@ private fun MainAppContent( ) androidx.compose.animation.AnimatedVisibility( - visible = !initialHomeReady || profileSwitchLoading, + visibleState = launchOverlayState, enter = fadeIn(), exit = fadeOut(androidx.compose.animation.core.tween(400)), ) { @@ -3069,19 +3354,18 @@ private fun MainAppContent( @Composable private fun rememberGuardedPopBackStack( - navController: NavHostController, - backStackEntry: NavBackStackEntry, + navController: NuvioNavigator, + route: AppRoute, beforePop: () -> Unit = {}, ): () -> Unit { - val currentBackStackEntry by navController.currentBackStackEntryAsState() - var popHandled by remember(backStackEntry) { mutableStateOf(false) } + var popHandled by remember(route) { mutableStateOf(false) } - return remember(navController, backStackEntry, currentBackStackEntry, popHandled, beforePop) { + return remember(navController, route, popHandled, beforePop) { { - if (!popHandled && currentBackStackEntry == backStackEntry) { + if (!popHandled && navController.currentRoute == route) { popHandled = true beforePop() - navController.popBackStack() + navController.popBackStack(expectedRoute = route) } } } @@ -3109,6 +3393,7 @@ private fun AppTabHost( onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null, onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null, onSwitchProfile: (() -> Unit)? = null, + onSettingsPageClick: ((pageName: String, title: String) -> Unit)? = null, onHomescreenSettingsClick: () -> Unit = {}, onMetaScreenSettingsClick: () -> Unit = {}, onContinueWatchingSettingsClick: () -> Unit = {}, @@ -3174,6 +3459,7 @@ private fun AppTabHost( requestedPageName = requestedSettingsPageName, onRequestedPageConsumed = onRequestedSettingsPageConsumed, rootActionsEnabled = rootActionsEnabled, + onNavigatePage = onSettingsPageClick, onSwitchProfile = onSwitchProfile, onHomescreenClick = onHomescreenSettingsClick, onMetaScreenClick = onMetaScreenSettingsClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/Components.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/Components.kt index 3f5eefd5a..dca8c7e80 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/Components.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/Components.kt @@ -72,6 +72,8 @@ import org.jetbrains.compose.resources.stringResource import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import com.nuvio.app.navigation.LocalNativeNavigationBarHidden +import com.nuvio.app.navigation.LocalUseNativeNavigation @Composable fun NuvioScreen( @@ -142,6 +144,20 @@ fun NuvioScreenHeader( ) { val tokens = MaterialTheme.nuvio val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val nativeDetailNavigation = LocalUseNativeNavigation.current && + !LocalNativeNavigationBarHidden.current && + onBack != null + if (nativeDetailNavigation) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(bottom = NuvioTokens.Space.s4), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + content = actions, + ) + return + } val resolvedTopPadding = topPadding ?: if (includeStatusBarPadding) statusBarTop else NuvioTokens.Space.none Box( modifier = modifier.fillMaxWidth(), @@ -263,6 +279,8 @@ fun NuvioBackButton( iconSize: Dp = NuvioTokens.Icon.md, contentDescription: String = stringResource(Res.string.action_back), ) { + if (LocalUseNativeNavigation.current && !LocalNativeNavigationBarHidden.current) return + Box( modifier = modifier .size(buttonSize) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt index 2b9d48160..d8de062c8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt @@ -1,8 +1,23 @@ package com.nuvio.app.core.ui +import com.nuvio.app.features.profiles.AvatarRepository +import com.nuvio.app.features.profiles.AvatarCatalogItem +import com.nuvio.app.features.profiles.MAX_PROFILES +import com.nuvio.app.features.profiles.NuvioProfile +import com.nuvio.app.features.profiles.PinVerifyResult +import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.profiles.profileAvatarImageUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch internal enum class NativeNavigationTab { Home, @@ -20,17 +35,11 @@ internal enum class NativeNavigationTab { internal object NativeTabBridge { private val _requestedTabs = MutableSharedFlow(extraBufferCapacity = 1) val requestedTabs: SharedFlow = _requestedTabs.asSharedFlow() - private val _profileTabLongPresses = MutableSharedFlow(extraBufferCapacity = 1) - val profileTabLongPresses: SharedFlow = _profileTabLongPresses.asSharedFlow() fun requestTab(tabName: String) { _requestedTabs.tryEmit(NativeNavigationTab.fromName(tabName)) } - fun requestProfileTabLongPress() { - _profileTabLongPresses.tryEmit(Unit) - } - fun publishSelectedTab(tab: NativeNavigationTab) { publishNativeSelectedTab(tab.name) } @@ -71,12 +80,113 @@ internal object NativeTabBridge { } } -fun nativeTabSelect(tabName: String) { - NativeTabBridge.requestTab(tabName) +data class NativeProfileOption( + val profileIndex: Int, + val name: String, + val avatarColorHex: String, + val avatarImageUrl: String?, + val avatarBackgroundColorHex: String?, + val pinEnabled: Boolean, + val active: Boolean, +) + +data class NativeProfileSwitcherState( + val profiles: List, + val isLoaded: Boolean, + val canAddProfile: Boolean, +) + +class NativeProfileSwitcherController { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val profileSelections = Channel(Channel.BUFFERED) + private val manageProfileRequests = Channel(Channel.BUFFERED) + private var observationJob: Job? = null + + internal val selectedProfileIndices = profileSelections.receiveAsFlow() + internal val requestedManageProfiles = manageProfileRequests.receiveAsFlow() + + fun currentState(): NativeProfileSwitcherState = nativeState( + profilesLoaded = ProfileRepository.state.value.isLoaded, + profiles = ProfileRepository.state.value.profiles, + activeProfileIndex = ProfileRepository.state.value.activeProfile?.profileIndex, + avatarsById = AvatarRepository.avatars.value.associateBy { it.id }, + ) + + fun observeState(callback: (NativeProfileSwitcherState) -> Unit) { + observationJob?.cancel() + observationJob = scope.launch { + combine(ProfileRepository.state, AvatarRepository.avatars) { state, avatars -> + nativeState( + profilesLoaded = state.isLoaded, + profiles = state.profiles, + activeProfileIndex = state.activeProfile?.profileIndex, + avatarsById = avatars.associateBy { it.id }, + ) + }.collect { callback(it) } + } + } + + fun stopObserving() { + observationJob?.cancel() + observationJob = null + } + + private fun nativeState( + profilesLoaded: Boolean, + profiles: List, + activeProfileIndex: Int?, + avatarsById: Map, + ): NativeProfileSwitcherState { + val options = profiles.map { profile -> + val avatar = profile.avatarId?.let(avatarsById::get) + NativeProfileOption( + profileIndex = profile.profileIndex, + name = profile.name, + avatarColorHex = profile.avatarColorHex, + avatarImageUrl = profileAvatarImageUrl(profile, avatar), + avatarBackgroundColorHex = avatar?.bgColor, + pinEnabled = profile.pinEnabled, + active = profile.profileIndex == activeProfileIndex, + ) + } + return NativeProfileSwitcherState( + profiles = options, + isLoaded = profilesLoaded, + canAddProfile = profiles.size < MAX_PROFILES, + ) + } + + fun chooseProfile( + profileIndex: Int, + pin: String?, + completion: (PinVerifyResult) -> Unit, + ) { + scope.launch { + val profile = ProfileRepository.state.value.profiles + .firstOrNull { it.profileIndex == profileIndex } + if (profile == null) { + completion(PinVerifyResult(message = null)) + return@launch + } + val result = if (profile.pinEnabled) { + ProfileRepository.verifyPin(profileIndex, pin.orEmpty()) + } else { + PinVerifyResult(unlocked = true) + } + if (result.unlocked) { + profileSelections.trySend(profileIndex) + } + completion(result) + } + } + + fun requestManageProfiles() { + manageProfileRequests.trySend(Unit) + } } -fun nativeProfileTabLongPress() { - NativeTabBridge.requestProfileTabLongPress() +fun nativeTabSelect(tabName: String) { + NativeTabBridge.requestTab(tabName) } internal expect fun isLiquidGlassNativeTabBarSupported(): Boolean diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt index 2fc73a4f4..bd4c58f07 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt @@ -13,6 +13,10 @@ internal expect val nuvioBottomNavigationExtraVerticalPadding: Dp @Composable internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets +/** Physical display-safe top inset, excluding any enclosing native toolbar. */ +@Composable +internal expect fun platformPhysicalTopInset(): Dp + internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp } @Composable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index 87cb0f0fd..0ee13d15f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -7,12 +7,15 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.aspectRatio 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.layout.windowInsetsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.GridItemSpan @@ -58,6 +61,7 @@ import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watching.application.WatchingState +import com.nuvio.app.navigation.LocalUseNativeNavigation import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map @@ -235,6 +239,16 @@ private fun CatalogHeader( onBack: () -> Unit, modifier: Modifier = Modifier, ) { + if (LocalUseNativeNavigation.current) { + Box( + modifier = modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.statusBars) + .height(44.dp), + ) + return + } + Column( modifier = modifier .fillMaxWidth() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt index e4cec4b55..b2ab5b298 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt @@ -71,6 +71,7 @@ data class CollectionEditorUiState( val traktTrendingResults: List = emptyList(), val traktPopularResults: List = emptyList(), val traktSearchError: String? = null, + val sourcePickerCompletionGeneration: Long = 0L, ) enum class TmdbBuilderMode { @@ -725,6 +726,7 @@ object CollectionEditorRepository { tmdbCompanyResults = emptyList(), tmdbCollectionResults = emptyList(), tmdbSearchError = null, + sourcePickerCompletionGeneration = _uiState.value.sourcePickerCompletionGeneration + 1L, ) } @@ -833,6 +835,7 @@ object CollectionEditorRepository { traktTitleInput = "", traktSearchResults = emptyList(), traktSearchError = null, + sourcePickerCompletionGeneration = state.sourcePickerCompletionGeneration + 1L, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt index 01c9ec619..3e80ef131 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -75,47 +76,87 @@ import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState +enum class CollectionEditorPage { + Root, + FolderEditor, + CatalogPicker, + TmdbSourcePicker, + TraktSourcePicker, +} + +fun disposeCollectionEditorPage(page: CollectionEditorPage) { + when (page) { + CollectionEditorPage.Root -> Unit + CollectionEditorPage.FolderEditor -> CollectionEditorRepository.cancelFolderEdit() + CollectionEditorPage.CatalogPicker -> CollectionEditorRepository.hideCatalogPicker() + CollectionEditorPage.TmdbSourcePicker -> CollectionEditorRepository.hideTmdbSourcePicker() + CollectionEditorPage.TraktSourcePicker -> CollectionEditorRepository.hideTraktSourcePicker() + } +} + +private val autoDismissedPickerPages = setOf( + CollectionEditorPage.TmdbSourcePicker, + CollectionEditorPage.TraktSourcePicker, +) + +private fun CollectionEditorUiState.activeEditorPage(): CollectionEditorPage = when { + showCatalogPicker -> CollectionEditorPage.CatalogPicker + showTmdbSourcePicker -> CollectionEditorPage.TmdbSourcePicker + showTraktSourcePicker -> CollectionEditorPage.TraktSourcePicker + showFolderEditor && editingFolder != null -> CollectionEditorPage.FolderEditor + else -> CollectionEditorPage.Root +} + @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun CollectionEditorScreen( collectionId: String?, onBack: () -> Unit, + initialPage: CollectionEditorPage? = null, + initializeRepository: Boolean = true, + onNavigateToPage: ((page: CollectionEditorPage, title: String) -> Unit)? = null, ) { val state by CollectionEditorRepository.uiState.collectAsState() val bottomInset = nuvioSafeBottomPadding() - LaunchedEffect(collectionId) { - CollectionEditorRepository.initialize(collectionId) + LaunchedEffect(collectionId, initializeRepository) { + if (initializeRepository) { + CollectionEditorRepository.initialize(collectionId) + } + } + + val page = initialPage ?: state.activeEditorPage() + val initialPickerCompletionGeneration = remember(initialPage) { + state.sourcePickerCompletionGeneration + } + + LaunchedEffect(initialPage, state.sourcePickerCompletionGeneration) { + if ( + initialPage in autoDismissedPickerPages && + state.sourcePickerCompletionGeneration != initialPickerCompletionGeneration + ) { + onBack() + } + } + + if ( + initialPage != null && + page != CollectionEditorPage.Root && + state.editingFolder == null + ) { + LaunchedEffect(initialPage) { onBack() } + return + } + + fun closePage(pageToClose: CollectionEditorPage, close: () -> Unit) { + close() + if (initialPage == pageToClose) { + onBack() + } } val editingFolder = state.editingFolder - if (state.showFolderEditor && editingFolder != null) { - if (state.showCatalogPicker) { - CatalogPickerScreen( - availableCatalogs = state.availableCatalogs, - selectedSources = editingFolder.resolvedCatalogSources, - onToggle = { CollectionEditorRepository.toggleCatalogSource(it) }, - onBack = { CollectionEditorRepository.hideCatalogPicker() }, - ) - return - } - - if (state.showTmdbSourcePicker) { - TmdbSourcePickerScreen( - state = state, - onBack = { CollectionEditorRepository.hideTmdbSourcePicker() }, - ) - return - } - - if (state.showTraktSourcePicker) { - TraktSourcePickerScreen( - state = state, - onBack = { CollectionEditorRepository.hideTraktSourcePicker() }, - ) - return - } - + if (page == CollectionEditorPage.FolderEditor && editingFolder != null) { val genrePickerIndex = state.genrePickerSourceIndex val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) } val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource() @@ -125,7 +166,18 @@ fun CollectionEditorScreen( FolderEditorPage( state = state, - onBack = { CollectionEditorRepository.cancelFolderEdit() }, + onBack = { + closePage(CollectionEditorPage.FolderEditor) { + CollectionEditorRepository.cancelFolderEdit() + } + }, + onNavigateToPage = onNavigateToPage, + onSave = { + CollectionEditorRepository.saveFolderEdit() + if (initialPage == CollectionEditorPage.FolderEditor) { + onBack() + } + }, ) if ( @@ -149,28 +201,40 @@ fun CollectionEditorScreen( return } - if (state.showCatalogPicker) { + if (page == CollectionEditorPage.CatalogPicker) { CatalogPickerScreen( availableCatalogs = state.availableCatalogs, selectedSources = state.editingFolder?.resolvedCatalogSources.orEmpty(), onToggle = { CollectionEditorRepository.toggleCatalogSource(it) }, - onBack = { CollectionEditorRepository.hideCatalogPicker() }, + onBack = { + closePage(CollectionEditorPage.CatalogPicker) { + CollectionEditorRepository.hideCatalogPicker() + } + }, ) return } - if (state.showTmdbSourcePicker) { + if (page == CollectionEditorPage.TmdbSourcePicker) { TmdbSourcePickerScreen( state = state, - onBack = { CollectionEditorRepository.hideTmdbSourcePicker() }, + onBack = { + closePage(CollectionEditorPage.TmdbSourcePicker) { + CollectionEditorRepository.hideTmdbSourcePicker() + } + }, ) return } - if (state.showTraktSourcePicker) { + if (page == CollectionEditorPage.TraktSourcePicker) { TraktSourcePickerScreen( state = state, - onBack = { CollectionEditorRepository.hideTraktSourcePicker() }, + onBack = { + closePage(CollectionEditorPage.TraktSourcePicker) { + CollectionEditorRepository.hideTraktSourcePicker() + } + }, ) return } @@ -335,7 +399,10 @@ fun CollectionEditorScreen( ) { NuvioSectionLabel(text = stringResource(Res.string.collections_editor_folders)) TextButton( - onClick = { CollectionEditorRepository.addFolder(newFolderTitle) }, + onClick = { + CollectionEditorRepository.addFolder(newFolderTitle) + onNavigateToPage?.invoke(CollectionEditorPage.FolderEditor, newFolderTitle) + }, ) { Icon( imageVector = Icons.Rounded.Add, @@ -351,9 +418,13 @@ fun CollectionEditorScreen( // Folder Items if (state.folders.isNotEmpty()) { item { + val editFolderTitle = stringResource(Res.string.collections_editor_edit_folder) FolderReorderableList( folders = state.folders, - onEdit = { CollectionEditorRepository.editFolder(it) }, + onEdit = { + CollectionEditorRepository.editFolder(it) + onNavigateToPage?.invoke(CollectionEditorPage.FolderEditor, editFolderTitle) + }, onDelete = { CollectionEditorRepository.removeFolder(it) }, ) } @@ -558,9 +629,15 @@ private fun FolderListItem( private fun FolderEditorPage( state: CollectionEditorUiState, onBack: () -> Unit, + onNavigateToPage: ((page: CollectionEditorPage, title: String) -> Unit)?, + onSave: () -> Unit, ) { val folder = state.editingFolder ?: return val bottomInset = nuvioSafeBottomPadding() + val catalogPickerTitle = stringResource(Res.string.collections_editor_select_catalogs) + val tmdbSourcePickerTitle = stringResource(Res.string.collections_editor_tmdb_sources) + val traktSourcePickerTitle = stringResource(Res.string.collections_editor_trakt_sources) + val editTraktSourcePickerTitle = stringResource(Res.string.collections_editor_edit_trakt_source) PlatformBackHandler(enabled = true) { onBack() @@ -725,7 +802,15 @@ private fun FolderEditorPage( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - TextButton(onClick = { CollectionEditorRepository.showTmdbSourcePicker() }) { + TextButton( + onClick = { + CollectionEditorRepository.showTmdbSourcePicker() + onNavigateToPage?.invoke( + CollectionEditorPage.TmdbSourcePicker, + tmdbSourcePickerTitle, + ) + }, + ) { Icon( imageVector = Icons.Rounded.Add, contentDescription = null, @@ -734,7 +819,15 @@ private fun FolderEditorPage( Spacer(modifier = Modifier.width(4.dp)) Text(stringResource(Res.string.source_tmdb)) } - TextButton(onClick = { CollectionEditorRepository.showTraktSourcePicker() }) { + TextButton( + onClick = { + CollectionEditorRepository.showTraktSourcePicker() + onNavigateToPage?.invoke( + CollectionEditorPage.TraktSourcePicker, + traktSourcePickerTitle, + ) + }, + ) { Icon( imageVector = Icons.Rounded.Add, contentDescription = null, @@ -743,7 +836,15 @@ private fun FolderEditorPage( Spacer(modifier = Modifier.width(4.dp)) Text(stringResource(Res.string.collections_editor_add_trakt_source)) } - TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) { + TextButton( + onClick = { + CollectionEditorRepository.showCatalogPicker() + onNavigateToPage?.invoke( + CollectionEditorPage.CatalogPicker, + catalogPickerTitle, + ) + }, + ) { Icon( imageVector = Icons.Rounded.Add, contentDescription = null, @@ -784,7 +885,13 @@ private fun FolderEditorPage( } else if (source.isTrakt) { FolderTraktSourceCard( source = source, - onEdit = { CollectionEditorRepository.editTraktSource(index) }, + onEdit = { + CollectionEditorRepository.editTraktSource(index) + onNavigateToPage?.invoke( + CollectionEditorPage.TraktSourcePicker, + editTraktSourcePickerTitle, + ) + }, onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, ) } else if (addonSource != null) { @@ -823,7 +930,7 @@ private fun FolderEditorPage( NuvioPrimaryButton( text = stringResource(Res.string.collections_editor_save), enabled = folder.title.isNotBlank(), - onClick = { CollectionEditorRepository.saveFolderEdit() }, + onClick = onSave, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt index 179f94b04..87da802c6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt @@ -73,6 +73,7 @@ import com.nuvio.app.features.watchprogress.CurrentDateProvider import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource +import com.nuvio.app.navigation.LocalUseNativeNavigation private sealed interface PersonDetailUiState { data object Loading : PersonDetailUiState @@ -146,19 +147,20 @@ fun PersonDetailScreen( ) } - // Back button overlaid on top - IconButton( - onClick = onBack, - modifier = Modifier - .windowInsetsPadding(WindowInsets.statusBars) - .padding(start = 4.dp, top = 4.dp) - .align(Alignment.TopStart), - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(Res.string.action_back), - tint = MaterialTheme.colorScheme.onSurface, - ) + if (!LocalUseNativeNavigation.current) { + IconButton( + onClick = onBack, + modifier = Modifier + .windowInsetsPadding(WindowInsets.statusBars) + .padding(start = 4.dp, top = 4.dp) + .align(Alignment.TopStart), + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(Res.string.action_back), + tint = MaterialTheme.colorScheme.onSurface, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt index 5d6316fa9..1f4ceef72 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt @@ -62,6 +62,7 @@ import com.nuvio.app.features.tmdb.TmdbEntityMediaType import com.nuvio.app.features.tmdb.TmdbEntityRailType import com.nuvio.app.features.tmdb.TmdbMetadataService import com.nuvio.app.features.watched.WatchedRepository +import com.nuvio.app.navigation.LocalUseNativeNavigation private sealed interface EntityBrowseUiState { data object Loading : EntityBrowseUiState @@ -126,18 +127,20 @@ fun TmdbEntityBrowseScreen( } } - IconButton( - onClick = onBack, - modifier = Modifier - .windowInsetsPadding(WindowInsets.statusBars) - .padding(start = 4.dp, top = 4.dp) - .align(Alignment.TopStart), - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(Res.string.action_back), - tint = MaterialTheme.colorScheme.onSurface, - ) + if (!LocalUseNativeNavigation.current) { + IconButton( + onClick = onBack, + modifier = Modifier + .windowInsetsPadding(WindowInsets.statusBars) + .padding(start = 4.dp, top = 4.dp) + .align(Alignment.TopStart), + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(Res.string.action_back), + tint = MaterialTheme.colorScheme.onSurface, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt index 89aecce61..b5cec37a7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt @@ -39,8 +39,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.platformPhysicalTopInset import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.isIos +import com.nuvio.app.navigation.LocalUseNativeNavigation import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -54,7 +56,12 @@ fun DetailFloatingHeader( onToggleSaved: () -> Unit, modifier: Modifier = Modifier, ) { - val safeAreaTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val useNativeNavigation = LocalUseNativeNavigation.current + val safeAreaTop = if (useNativeNavigation) { + platformPhysicalTopInset() + } else { + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + } val headerTopPadding = (safeAreaTop - 6.dp).coerceAtLeast(safeAreaTop * 0.8f) val interactive = progress > 0.05f val surfaceColor = backgroundColor ?: if (isIos) { @@ -93,7 +100,7 @@ fun DetailFloatingHeader( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - if (interactive) { + if (interactive && !useNativeNavigation) { NuvioBackButton( onClick = onBack, modifier = Modifier.size(40.dp), @@ -103,6 +110,9 @@ fun DetailFloatingHeader( iconSize = 24.dp, ) } else { + // Native iOS navigation owns the back button, but retaining + // this slot keeps the Compose logo centered as the floating + // header replaces the hero while scrolling. Box(modifier = Modifier.size(40.dp)) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt index de8ba9ad3..ef6a1eb2c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt @@ -47,13 +47,16 @@ import org.jetbrains.compose.resources.stringResource fun DownloadsScreen( onBack: () -> Unit, onOpenDownload: (DownloadItem) -> Unit, + initialShowId: String? = null, + onNavigateToShow: ((showId: String, title: String) -> Unit)? = null, + onBackFromShow: (() -> Unit)? = null, ) { val uiState by remember { DownloadsRepository.ensureLoaded() DownloadsRepository.uiState }.collectAsStateWithLifecycle() - var selectedShowId by rememberSaveable { mutableStateOf(null) } + var selectedShowId by rememberSaveable(initialShowId) { mutableStateOf(initialShowId) } val openDownloadsDirectoryFailedText = stringResource(Res.string.downloads_open_directory_failed) val completedEpisodes = remember(uiState.items) { @@ -78,7 +81,7 @@ fun DownloadsScreen( }, onBack = { if (selectedShowId != null) { - selectedShowId = null + onBackFromShow?.invoke() ?: run { selectedShowId = null } } else { onBack() } @@ -104,7 +107,9 @@ fun DownloadsScreen( downloadsRootContent( uiState = uiState, onOpenDownload = onOpenDownload, - onOpenShow = { showId -> selectedShowId = showId }, + onOpenShow = { showId, title -> + onNavigateToShow?.invoke(showId, title) ?: run { selectedShowId = showId } + }, ) } else { downloadsShowContent( @@ -119,7 +124,7 @@ fun DownloadsScreen( private fun LazyListScope.downloadsRootContent( uiState: DownloadsUiState, onOpenDownload: (DownloadItem) -> Unit, - onOpenShow: (String) -> Unit, + onOpenShow: (showId: String, title: String) -> Unit, ) { val activeItems = uiState.activeItems val completedMovies = uiState.completedItems.filterNot(DownloadItem::isEpisode) @@ -183,7 +188,7 @@ private fun LazyListScope.downloadsRootContent( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 6.dp) - .clickable { onOpenShow(item.parentMetaId) }, + .clickable { onOpenShow(item.parentMetaId, item.title) }, shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainer, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index e1d3b7b2a..09d2bd08b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -1,7 +1,6 @@ package com.nuvio.app.features.player import androidx.compose.runtime.Composable -import kotlinx.serialization.Serializable import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.player_ios_hardware_decoder_off import nuvio.composeapp.generated.resources.player_ios_preset_compatibility_desc @@ -14,11 +13,6 @@ import nuvio.composeapp.generated.resources.player_ios_preset_sdr_tone_mapped_de import nuvio.composeapp.generated.resources.player_ios_preset_sdr_tone_mapped_label import org.jetbrains.compose.resources.stringResource -@Serializable -data class PlayerRoute( - val launchId: Long, -) - data class PlayerLaunch( val profileId: Int, val title: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 351bcc081..6f42f03ef 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -80,6 +80,7 @@ import com.nuvio.app.features.tmdb.TmdbSettings import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState +import com.nuvio.app.navigation.LocalUseNativeNavigation import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_settings_page_root import kotlinx.coroutines.delay @@ -99,13 +100,26 @@ private fun SettingsPage.isEnabledByPolicy(): Boolean = else -> true } +@Composable +private fun settingsPageTitles(): Map { + val titles = mutableMapOf() + for (page in SettingsPage.entries) { + titles[page] = stringResource(page.titleRes) + } + return titles +} + @Composable fun SettingsScreen( modifier: Modifier = Modifier, rootActionRequests: Flow = emptyFlow(), + initialPageName: String = SettingsPage.Root.name, requestedPageName: String? = null, onRequestedPageConsumed: () -> Unit = {}, rootActionsEnabled: Boolean = true, + onNavigatePage: ((pageName: String, title: String) -> Unit)? = null, + onExternalBack: (() -> Unit)? = null, + showInternalHeader: Boolean = true, onSwitchProfile: (() -> Unit)? = null, onHomescreenClick: () -> Unit = {}, onMetaScreenClick: () -> Unit = {}, @@ -135,7 +149,10 @@ fun SettingsScreen( val liquidGlassNativeTabBarEnabled by remember { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled }.collectAsStateWithLifecycle() - val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() } + val useNativeNavigation = LocalUseNativeNavigation.current + val liquidGlassNativeTabBarSupported = remember(useNativeNavigation) { + !useNativeNavigation && isLiquidGlassNativeTabBarSupported() + } val selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle() val tmdbSettings by remember { TmdbSettingsRepository.ensureLoaded() @@ -219,8 +236,15 @@ fun SettingsScreen( HomeCatalogSettingsRepository.syncCollections(collections) } - var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) } + val initialPage = remember(initialPageName) { + runCatching { SettingsPage.valueOf(initialPageName) } + .getOrDefault(SettingsPage.Root) + .takeIf { it.isEnabledByPolicy() } + ?: SettingsPage.Root + } + var currentPage by rememberSaveable(initialPageName) { mutableStateOf(initialPage.name) } val scrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } + val pageTitles = settingsPageTitles() val page = remember(currentPage) { runCatching { SettingsPage.valueOf(currentPage) } .getOrDefault(SettingsPage.Root) @@ -229,6 +253,73 @@ fun SettingsScreen( } val previousPage = page.previousPage() + fun openPage(targetPage: SettingsPage) { + if (!targetPage.isEnabledByPolicy()) return + val externalNavigator = onNavigatePage + if (externalNavigator == null) { + currentPage = targetPage.name + return + } + if (targetPage == SettingsPage.Root && onExternalBack != null) { + onExternalBack() + return + } + externalNavigator( + targetPage.name, + pageTitles.getValue(targetPage), + ) + } + + fun navigateBack() { + val parentPage = previousPage ?: return + if (onNavigatePage != null && onExternalBack != null) { + onExternalBack() + } else { + currentPage = parentPage.name + } + } + + val openHomescreen = if (onNavigatePage != null) { + { openPage(SettingsPage.Homescreen) } + } else { + onHomescreenClick + } + val openMetaScreen = if (onNavigatePage != null) { + { openPage(SettingsPage.MetaScreen) } + } else { + onMetaScreenClick + } + val openContinueWatching = if (onNavigatePage != null) { + { openPage(SettingsPage.ContinueWatching) } + } else { + onContinueWatchingClick + } + val openAddons = if (onNavigatePage != null) { + { openPage(SettingsPage.Addons) } + } else { + onAddonsClick + } + val openPlugins = if (onNavigatePage != null) { + { openPage(SettingsPage.Plugins) } + } else { + onPluginsClick + } + val openAccount = if (onNavigatePage != null) { + { openPage(SettingsPage.Account) } + } else { + onAccountClick + } + val openSupportersContributors = if (onNavigatePage != null) { + { openPage(SettingsPage.SupportersContributors) } + } else { + onSupportersContributorsClick + } + val openLicensesAttributions = if (onNavigatePage != null) { + { openPage(SettingsPage.LicensesAttributions) } + } else { + onLicensesAttributionsClick + } + LaunchedEffect(page, currentPage) { if (page.name != currentPage) { currentPage = page.name @@ -240,7 +331,7 @@ fun SettingsScreen( if (!rootActionsEnabled) return@collect val pageToOpen = page.previousPage() if (pageToOpen != null) { - currentPage = pageToOpen.name + navigateBack() } else { scrollToTopRequests.tryEmit(Unit) } @@ -255,20 +346,22 @@ fun SettingsScreen( return@LaunchedEffect } if (!rootActionsEnabled) return@LaunchedEffect - currentPage = targetPage.name + openPage(targetPage) onRequestedPageConsumed() } PlatformBackHandler( - enabled = rootActionsEnabled && previousPage != null, - onBack = { previousPage?.let { currentPage = it.name } }, + enabled = previousPage != null && (rootActionsEnabled || onExternalBack != null), + onBack = ::navigateBack, ) if (maxWidth >= 768.dp) { TabletSettingsScreen( page = page, scrollToTopRequests = scrollToTopRequests, - onPageChange = { currentPage = it.name }, + onPageChange = ::openPage, + onNavigateBack = ::navigateBack, + showInternalHeader = showInternalHeader, showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled, holdToSpeedValue = playerSettingsUiState.holdToSpeedValue, @@ -314,8 +407,8 @@ fun SettingsScreen( posterCardStyleUiState = posterCardStyleUiState, onSwitchProfile = onSwitchProfile, onDownloadsClick = onDownloadsClick, - onSupportersContributorsClick = onSupportersContributorsClick, - onLicensesAttributionsClick = onLicensesAttributionsClick, + onSupportersContributorsClick = openSupportersContributors, + onLicensesAttributionsClick = openLicensesAttributions, onCheckForUpdatesClick = onCheckForUpdatesClick, onCollectionsClick = onCollectionsClick, ) @@ -323,7 +416,9 @@ fun SettingsScreen( MobileSettingsScreen( page = page, scrollToTopRequests = scrollToTopRequests, - onPageChange = { currentPage = it.name }, + onPageChange = ::openPage, + onNavigateBack = ::navigateBack, + showInternalHeader = showInternalHeader, showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled, holdToSpeedValue = playerSettingsUiState.holdToSpeedValue, @@ -368,15 +463,15 @@ fun SettingsScreen( continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, posterCardStyleUiState = posterCardStyleUiState, onSwitchProfile = onSwitchProfile, - onHomescreenClick = onHomescreenClick, - onMetaScreenClick = onMetaScreenClick, - onContinueWatchingClick = onContinueWatchingClick, - onAddonsClick = onAddonsClick, - onPluginsClick = onPluginsClick, + onHomescreenClick = openHomescreen, + onMetaScreenClick = openMetaScreen, + onContinueWatchingClick = openContinueWatching, + onAddonsClick = openAddons, + onPluginsClick = openPlugins, onDownloadsClick = onDownloadsClick, - onAccountClick = onAccountClick, - onSupportersContributorsClick = onSupportersContributorsClick, - onLicensesAttributionsClick = onLicensesAttributionsClick, + onAccountClick = openAccount, + onSupportersContributorsClick = openSupportersContributors, + onLicensesAttributionsClick = openLicensesAttributions, onCheckForUpdatesClick = onCheckForUpdatesClick, onCollectionsClick = onCollectionsClick, ) @@ -389,6 +484,8 @@ private fun MobileSettingsScreen( page: SettingsPage, scrollToTopRequests: Flow, onPageChange: (SettingsPage) -> Unit, + onNavigateBack: () -> Unit, + showInternalHeader: Boolean, showLoadingOverlay: Boolean, holdToSpeedEnabled: Boolean, holdToSpeedValue: Float, @@ -521,12 +618,16 @@ private fun MobileSettingsScreen( modifier = Modifier.nestedScroll(rootSearchRevealConnection), listState = listState, ) { - stickyHeader { - val previousPage = page.previousPage() - NuvioScreenHeader( - title = stringResource(page.titleRes), - onBack = previousPage?.let { { onPageChange(it) } }, - ) + if (showInternalHeader) { + stickyHeader { + val previousPage = page.previousPage() + NuvioScreenHeader( + title = stringResource(page.titleRes), + onBack = previousPage?.let { { onNavigateBack() } }, + ) + } + } else { + item { Spacer(modifier = Modifier.height(44.dp)) } } when (page) { @@ -733,6 +834,8 @@ private fun TabletSettingsScreen( page: SettingsPage, scrollToTopRequests: Flow, onPageChange: (SettingsPage) -> Unit, + onNavigateBack: () -> Unit, + showInternalHeader: Boolean, showLoadingOverlay: Boolean, holdToSpeedEnabled: Boolean, holdToSpeedValue: Float, @@ -911,21 +1014,23 @@ private fun TabletSettingsScreen( ), verticalArrangement = Arrangement.spacedBy(18.dp), ) { - item { - val previousPage = page.previousPage() - TabletPageHeader( - title = if (page == SettingsPage.Root) { - if (settingsSearchQuery.isBlank()) { - stringResource(activeCategory.labelRes) + if (showInternalHeader) { + item { + val previousPage = page.previousPage() + TabletPageHeader( + title = if (page == SettingsPage.Root) { + if (settingsSearchQuery.isBlank()) { + stringResource(activeCategory.labelRes) + } else { + stringResource(Res.string.compose_settings_page_root) + } } else { - stringResource(Res.string.compose_settings_page_root) - } - } else { - stringResource(page.titleRes) - }, - showBack = previousPage != null, - onBack = { previousPage?.let(onPageChange) }, - ) + stringResource(page.titleRes) + }, + showBack = previousPage != null, + onBack = onNavigateBack, + ) + } } when (page) { SettingsPage.Root -> { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index f7176ef77..0d3e16772 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -35,7 +35,6 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.OpenInNew @@ -61,6 +60,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -90,6 +90,7 @@ import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver import com.nuvio.app.features.debrid.toastMessage import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository +import com.nuvio.app.navigation.LocalUseNativeNavigation import kotlinx.coroutines.launch import kotlin.math.roundToInt import nuvio.composeapp.generated.resources.* @@ -127,6 +128,7 @@ fun StreamsScreen( onBack: () -> Unit, modifier: Modifier = Modifier, ) { + val useNativeNavigation = LocalUseNativeNavigation.current val uiState by StreamsRepository.uiState.collectAsStateWithLifecycle() val playerSettings by remember { PlayerSettingsRepository.ensureLoaded() @@ -205,6 +207,16 @@ fun StreamsScreen( } else { background ?: poster } + val reloadStreams: () -> Unit = { + StreamsRepository.reload( + type = type, + videoId = videoId, + parentMetaId = parentMetaId, + season = seasonNumber, + episode = episodeNumber, + manualSelection = manualSelection, + ) + } BoxWithConstraints( modifier = modifier @@ -233,6 +245,7 @@ fun StreamsScreen( onStreamSelected(stream, positionMs, progressFraction) }, onStreamLongPress = { stream -> streamActionsTarget = stream }, + onRefresh = reloadStreams, ) } else { MobileStreamsLayout( @@ -252,6 +265,7 @@ fun StreamsScreen( onStreamSelected(stream, positionMs, progressFraction) }, onStreamLongPress = { stream -> streamActionsTarget = stream }, + onRefresh = reloadStreams, ) } @@ -259,7 +273,7 @@ fun StreamsScreen( modifier = Modifier .align(Alignment.TopStart) .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)) - .padding(start = 12.dp, top = 8.dp), + .padding(start = 12.dp, top = if (useNativeNavigation) 52.dp else 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { NuvioBackButton( @@ -269,35 +283,6 @@ fun StreamsScreen( containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.45f), contentColor = MaterialTheme.colorScheme.onBackground, ) - - Box( - modifier = Modifier - .size(40.dp) - .background( - color = MaterialTheme.colorScheme.background.copy(alpha = 0.45f), - shape = CircleShape, - ) - .clickable( - onClick = { - StreamsRepository.reload( - type = type, - videoId = videoId, - parentMetaId = parentMetaId, - season = seasonNumber, - episode = episodeNumber, - manualSelection = manualSelection, - ) - }, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.Refresh, - contentDescription = stringResource(Res.string.streams_refresh), - tint = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.size(20.dp), - ) - } } AnimatedVisibility( @@ -470,6 +455,7 @@ private fun MobileStreamsLayout( resumeProgressFraction: Float?, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, + onRefresh: () -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize()) { @@ -542,6 +528,7 @@ private fun MobileStreamsLayout( groups = uiState.groups, selectedFilter = uiState.selectedFilter, onFilterSelected = { addonId -> StreamsRepository.selectFilter(addonId) }, + onRefresh = onRefresh, ) StreamList( @@ -751,10 +738,10 @@ internal fun ProviderFilterRow( groups: List, selectedFilter: String?, onFilterSelected: (String?) -> Unit, + onRefresh: () -> Unit, modifier: Modifier = Modifier, ) { val addonGroups = groups.filter { it.streams.isNotEmpty() || it.isLoading } - if (addonGroups.isEmpty()) return Row( modifier = modifier @@ -763,6 +750,12 @@ internal fun ProviderFilterRow( .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { + FilterChip( + icon = Icons.Rounded.Refresh, + contentDescription = stringResource(Res.string.streams_refresh), + isSelected = false, + onClick = onRefresh, + ) // "All" chip FilterChip( label = stringResource(Res.string.collections_tab_all), @@ -781,7 +774,9 @@ internal fun ProviderFilterRow( @Composable private fun FilterChip( - label: String, + label: String? = null, + icon: ImageVector? = null, + contentDescription: String? = null, isSelected: Boolean, onClick: () -> Unit, ) { @@ -816,6 +811,7 @@ private fun FilterChip( scaleX = scale scaleY = scale } + .height(36.dp) .clip(RoundedCornerShape(16.dp)) .background(containerColor) .clickable( @@ -823,18 +819,34 @@ private fun FilterChip( indication = null, onClick = onClick, ) - .padding(horizontal = 14.dp, vertical = 8.dp), + .padding(horizontal = 14.dp), + contentAlignment = Alignment.Center, ) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium.copy( - fontSize = 14.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold, - letterSpacing = 0.1.sp, - ), - color = contentColor, - maxLines = 1, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = contentColor, + modifier = Modifier.size(20.dp), + ) + } + if (label != null) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 14.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold, + letterSpacing = 0.1.sp, + ), + color = contentColor, + maxLines = 1, + ) + } + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt index 8b7750842..1091ba7f9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt @@ -67,6 +67,7 @@ internal fun TabletStreamsLayout( resumeProgressFraction: Float?, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, + onRefresh: () -> Unit, modifier: Modifier = Modifier, ) { val hazeState = rememberHazeState() @@ -196,6 +197,7 @@ internal fun TabletStreamsLayout( groups = uiState.groups, selectedFilter = uiState.selectedFilter, onFilterSelected = { addonId -> StreamsRepository.selectFilter(addonId) }, + onRefresh = onRefresh, ) ActiveScrapersStatusBlock( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NativeNavigation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NativeNavigation.kt new file mode 100644 index 000000000..133e29cf7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NativeNavigation.kt @@ -0,0 +1,9 @@ +package com.nuvio.app.navigation + +import androidx.compose.runtime.staticCompositionLocalOf + +/** True when SwiftUI owns the current iOS tab and navigation stack. */ +val LocalUseNativeNavigation = staticCompositionLocalOf { false } + +/** True for immersive routes that intentionally keep their Compose-owned exit controls. */ +val LocalNativeNavigationBarHidden = staticCompositionLocalOf { false } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NuvioNavigator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NuvioNavigator.kt new file mode 100644 index 000000000..7320a800f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NuvioNavigator.kt @@ -0,0 +1,79 @@ +package com.nuvio.app.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import kotlin.reflect.KClass + +internal class NuvioNavigator( + private val backStack: NavBackStack, + private val onExternalNavigate: ((AppRoute, launchSingleTop: Boolean) -> Unit)? = null, + private val onExternalBack: (() -> Unit)? = null, + private val onExternalReplace: ((AppRoute) -> Unit)? = null, + private val onRouteRemoved: (AppRoute) -> Unit = {}, +) { + val currentRoute: AppRoute? + get() = backStack.lastOrNull() as? AppRoute + + val routes: List + get() = backStack.filterIsInstance() + + fun navigate(route: AppRoute, options: NuvioNavigateOptions.() -> Unit = {}) { + val resolvedOptions = NuvioNavigateOptions().apply(options) + + if (resolvedOptions.launchSingleTop && currentRoute == route) return + + val popUpToRoute = resolvedOptions.popUpToRoute + if (popUpToRoute != null) { + val targetIndex = backStack.indexOfLast { popUpToRoute.isInstance(it) } + if (targetIndex >= 0) { + if ( + onExternalReplace != null && + resolvedOptions.popUpToInclusive && + targetIndex == backStack.lastIndex + ) { + onExternalReplace(route) + return + } + + val firstRemovedIndex = if (resolvedOptions.popUpToInclusive) targetIndex else targetIndex + 1 + if (firstRemovedIndex <= backStack.lastIndex) { + val removedRoutes = backStack + .subList(firstRemovedIndex, backStack.size) + .filterIsInstance() + .toList() + backStack.subList(firstRemovedIndex, backStack.size).clear() + removedRoutes.forEach(onRouteRemoved) + } + } + } + + if (resolvedOptions.launchSingleTop && currentRoute == route) return + onExternalNavigate?.invoke(route, resolvedOptions.launchSingleTop) ?: backStack.add(route) + } + + fun popBackStack(expectedRoute: AppRoute? = null): Boolean { + if (expectedRoute != null && currentRoute != expectedRoute) return false + if (backStack.size > 1) { + (backStack.removeAt(backStack.lastIndex) as? AppRoute)?.let(onRouteRemoved) + return true + } + onExternalBack?.invoke() + return onExternalBack != null + } +} + +internal class NuvioNavigateOptions { + var launchSingleTop: Boolean = false + internal var popUpToRoute: KClass? = null + internal var popUpToInclusive: Boolean = false + + inline fun popUpTo(noinline options: NuvioPopUpToOptions.() -> Unit = {}) { + val resolved = NuvioPopUpToOptions().apply(options) + popUpToRoute = T::class + popUpToInclusive = resolved.inclusive + } +} + +internal class NuvioPopUpToOptions { + var inclusive: Boolean = false +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/Routes.kt new file mode 100644 index 000000000..df0980632 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/Routes.kt @@ -0,0 +1,149 @@ +package com.nuvio.app.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +sealed interface AppRoute : NavKey { + val title: String? + get() = null + + val subtitle: String? + get() = null + + /** Full-screen destinations such as the video player keep native navigation chrome hidden. */ + val hidesNavigationBar: Boolean + get() = false + + /** Stable enough to apply Navigation 3 launchSingleTop semantics in SwiftUI. */ + val navigationIdentity: String + get() = toString() + + /** Lets an explicitly cross-tab route select its native SwiftUI stack. */ + val preferredTabName: String? + get() = null +} + +@Serializable +sealed interface SettingsDestinationRoute : AppRoute { + override val preferredTabName: String + get() = "Settings" +} + +@Serializable +data object TabsRoute : AppRoute + +@Serializable +data class DetailRoute( + val type: String, + val id: String, + override val title: String? = null, +) : AppRoute + +@Serializable +data class PersonDetailRoute( + val personId: Int, + val personName: String, + val personPhoto: String? = null, + val castAvatarTransitionKey: String? = null, + val preferCrew: Boolean = false, +) : AppRoute { + override val title: String + get() = personName +} + +@Serializable +data class EntityBrowseRoute( + val entityKind: String, + val entityId: Int, + val entityName: String, + val sourceType: String = "tv", +) : AppRoute { + override val title: String + get() = entityName +} + +/** A settings leaf promoted from the former in-screen page state machine. */ +@Serializable +data class SettingsPageRoute( + val pageName: String, + override val title: String, +) : SettingsDestinationRoute + +@Serializable +data class HomescreenSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class MetaScreenSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class ContinueWatchingSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class DownloadsSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class DownloadShowRoute( + val showId: String, + override val title: String, +) : AppRoute + +@Serializable +data class AddonsSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class PluginsSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class AccountSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class SupportersContributorsSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class LicensesAttributionsSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class CollectionsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class CollectionEditorRoute( + val collectionId: String? = null, + override val title: String = "", +) : AppRoute + +@Serializable +data class CollectionEditorPageRoute( + val collectionId: String? = null, + val pageName: String, + override val title: String, +) : AppRoute + +@Serializable +data class FolderDetailRoute( + val collectionId: String, + val folderId: String, + override val title: String = "", +) : AppRoute + +@Serializable +data class StreamRoute( + val launchId: Long, + override val title: String = "", +) : AppRoute + +@Serializable +data class CatalogRoute( + val launchId: Long, + override val title: String = "", + override val subtitle: String? = null, +) : AppRoute + +@Serializable +data class PlayerRoute( + val launchId: Long, + override val title: String = "", +) : AppRoute { + override val hidesNavigationBar: Boolean + get() = true +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt index 18e9d4169..d38655a54 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt @@ -1,12 +1,77 @@ package com.nuvio.app +import androidx.compose.ui.uikit.OnFocusBehavior import androidx.compose.ui.window.ComposeUIViewController +import com.nuvio.app.core.ui.NativeProfileSwitcherController +import com.nuvio.app.navigation.AppRoute import platform.UIKit.UIColor +import platform.UIKit.UIViewController private val nuvioBackgroundColor = UIColor(red = 0.051, green = 0.051, blue = 0.051, alpha = 1.0) -fun MainViewController() = ComposeUIViewController { +@Suppress("unused") +fun MainViewController(): UIViewController = nuvioComposeViewController { App() -}.apply { +} + +@Suppress("unused") +fun MainViewController( + initialTabName: String, + useNativeTabBar: Boolean, + useTabletFloatingTabBar: Boolean, + onNavigate: (AppRoute, Boolean) -> Unit, + onGoBack: () -> Unit, + onReplace: (AppRoute) -> Unit, + onActivate: (String) -> Unit, + onAppReady: (Boolean) -> Unit, + onTabTitles: (String, String, String, String) -> Unit, + nativeProfileSwitcherController: NativeProfileSwitcherController, +): UIViewController { + val initialTab = AppScreenTab.fromName(initialTabName) + return nuvioComposeViewController { + App( + initialTab = initialTab, + useNativeNavigation = true, + useNativeTabBar = useNativeTabBar, + useTabletFloatingTabBar = useTabletFloatingTabBar, + ownsAppRuntime = initialTab == AppScreenTab.Home, + bypassAppGate = initialTab != AppScreenTab.Home, + onNavigate = onNavigate, + onGoBack = onGoBack, + onReplace = onReplace, + onActivate = { tab -> onActivate(tab.name) }, + onAppReady = onAppReady, + onTabTitles = onTabTitles, + nativeProfileSwitcherController = nativeProfileSwitcherController, + ) + } +} + +@Suppress("unused") +fun ScreenViewController( + route: AppRoute, + onNavigate: (AppRoute, Boolean) -> Unit, + onGoBack: () -> Unit, + onReplace: (AppRoute) -> Unit, + onActivate: (String) -> Unit, +): UIViewController = nuvioComposeViewController { + App( + initialRoute = route, + useNativeNavigation = true, + ownsAppRuntime = false, + bypassAppGate = true, + onNavigate = onNavigate, + onGoBack = onGoBack, + onReplace = onReplace, + onActivate = { tab -> onActivate(tab.name) }, + ) +} + +private fun nuvioComposeViewController( + content: @androidx.compose.runtime.Composable () -> Unit, +): UIViewController = ComposeUIViewController( + configure = { onFocusBehavior = OnFocusBehavior.DoNothing }, + content = content, +).apply { view.backgroundColor = nuvioBackgroundColor } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.ios.kt index 5042b4a43..590e17cd8 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.ios.kt @@ -2,11 +2,16 @@ package com.nuvio.app.core.ui import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.statusBars import androidx.compose.runtime.Composable +import androidx.compose.ui.uikit.LocalUIViewController import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents internal actual val nuvioPlatformExtraTopPadding: Dp = 0.dp internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp @@ -15,3 +20,14 @@ internal actual val nuvioBottomNavigationExtraVerticalPadding: Dp = 0.dp @Composable internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom) + +@OptIn(ExperimentalForeignApi::class) +@Composable +internal actual fun platformPhysicalTopInset(): Dp { + val physicalTop = LocalUIViewController.current.view.window + ?.safeAreaInsets + ?.useContents { top.toFloat() } + + return physicalTop?.dp + ?: WindowInsets.statusBars.asPaddingValues().calculateTopPadding() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2515c3f50..2c931ad83 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ android-compileSdkMinor = "0" android-minSdk = "24" android-targetSdk = "36" androidx-activity = "1.12.2" -androidx-navigation = "2.9.2" +androidx-navigation3 = "1.1.1" androidx-appcompat = "1.7.1" androidx-core = "1.17.0" androidx-core-splashscreen = "1.0.1" @@ -44,7 +44,7 @@ androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "an androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } -androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "androidx-navigation3" } compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } diff --git a/iosApp/iosApp/Assets.xcassets/NuvioTabHome.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/NuvioTabHome.imageset/Contents.json new file mode 100644 index 000000000..e0c34a2d1 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/NuvioTabHome.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "nuvio-tab-home.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/iosApp/iosApp/Assets.xcassets/NuvioTabHome.imageset/nuvio-tab-home.svg b/iosApp/iosApp/Assets.xcassets/NuvioTabHome.imageset/nuvio-tab-home.svg new file mode 100644 index 000000000..567f6bf47 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/NuvioTabHome.imageset/nuvio-tab-home.svg @@ -0,0 +1,3 @@ + + + diff --git a/iosApp/iosApp/Assets.xcassets/NuvioTabLibrary.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/NuvioTabLibrary.imageset/Contents.json new file mode 100644 index 000000000..b0dc699fa --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/NuvioTabLibrary.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "nuvio-tab-library.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/iosApp/iosApp/Assets.xcassets/NuvioTabLibrary.imageset/nuvio-tab-library.svg b/iosApp/iosApp/Assets.xcassets/NuvioTabLibrary.imageset/nuvio-tab-library.svg new file mode 100644 index 000000000..bc6aeb096 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/NuvioTabLibrary.imageset/nuvio-tab-library.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/iosApp/iosApp/Assets.xcassets/NuvioTabProfile.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/NuvioTabProfile.imageset/Contents.json new file mode 100644 index 000000000..e00731b52 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/NuvioTabProfile.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "nuvio-tab-profile.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/iosApp/iosApp/Assets.xcassets/NuvioTabProfile.imageset/nuvio-tab-profile.svg b/iosApp/iosApp/Assets.xcassets/NuvioTabProfile.imageset/nuvio-tab-profile.svg new file mode 100644 index 000000000..ff59b1f25 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/NuvioTabProfile.imageset/nuvio-tab-profile.svg @@ -0,0 +1,3 @@ + + + diff --git a/iosApp/iosApp/Assets.xcassets/NuvioTabSearch.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/NuvioTabSearch.imageset/Contents.json new file mode 100644 index 000000000..4bdce622a --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/NuvioTabSearch.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "nuvio-tab-search.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/iosApp/iosApp/Assets.xcassets/NuvioTabSearch.imageset/nuvio-tab-search.svg b/iosApp/iosApp/Assets.xcassets/NuvioTabSearch.imageset/nuvio-tab-search.svg new file mode 100644 index 000000000..b43ab612c --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/NuvioTabSearch.imageset/nuvio-tab-search.svg @@ -0,0 +1,3 @@ + + + diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index c8fb0338b..07d45432a 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -1,336 +1,51 @@ -import UIKit +import Combine import SwiftUI +import UIKit import ComposeApp -private enum NuvioNativeTabIcon { - static let home = vectorIcon( - viewport: CGSize(width: 24, height: 24), - paths: [ - "M10,20V14H14V20H19V12H22L12,3L2,12H5V20Z", - ] - ) +private let nuvioBackgroundColor = UIColor( + red: 0.051, + green: 0.051, + blue: 0.051, + alpha: 1.0 +) - static let search = drawnIcon { context, rect in - drawInViewport(context: context, rect: rect, viewport: CGSize(width: 20, height: 20)) { - context.setStrokeColor(UIColor.black.cgColor) - context.setLineWidth(2) - context.setLineCap(.round) - context.strokeEllipse(in: CGRect(x: 3, y: 3, width: 12, height: 12)) - context.move(to: CGPoint(x: 13.6, y: 13.6)) - context.addLine(to: CGPoint(x: 17, y: 17)) - context.strokePath() - } - } +private enum NuvioComposeHost { + static let registerPlayerBridge: Void = { + NuvioPlayerRegistration.register() + }() - static let library = vectorIcon( - viewport: CGSize(width: 24, height: 24), - paths: [ - "M8.50989,2.00001H15.49C15.7225,1.99995 15.9007,1.99991 16.0565,2.01515C17.1643,2.12352 18.0711,2.78958 18.4556,3.68678H5.54428C5.92879,2.78958 6.83555,2.12352 7.94337,2.01515C8.09917,1.99991 8.27741,1.99995 8.50989,2.00001Z", - "M6.31052,4.72312C4.91989,4.72312 3.77963,5.56287 3.3991,6.67691C3.39117,6.70013 3.38356,6.72348 3.37629,6.74693C3.77444,6.62636 4.18881,6.54759 4.60827,6.49382C5.68865,6.35531 7.05399,6.35538 8.64002,6.35547L8.75846,6.35547L15.5321,6.35547C17.1181,6.35538 18.4835,6.35531 19.5639,6.49382C19.9833,6.54759 20.3977,6.62636 20.7958,6.74693C20.7886,6.72348 20.781,6.70013 20.773,6.67691C20.3925,5.56287 19.2522,4.72312 17.8616,4.72312H6.31052Z", - "M8.67239,7.54204H15.3276C18.7024,7.54204 20.3898,7.54204 21.3377,8.52887C22.2855,9.5157 22.0625,11.0403 21.6165,14.0896L21.1935,16.9811C20.8437,19.3724 20.6689,20.568 19.7717,21.284C18.8745,22 17.5512,22 14.9046,22H9.09536C6.44881,22 5.12553,22 4.22834,21.284C3.33115,20.568 3.15626,19.3724 2.80648,16.9811L2.38351,14.0896C1.93748,11.0403 1.71447,9.5157 2.66232,8.52887C3.61017,7.54204 5.29758,7.54204 8.67239,7.54204ZM8,18.0001C8,17.5859 8.3731,17.2501 8.83333,17.2501H15.1667C15.6269,17.2501 16,17.5859 16,18.0001C16,18.4144 15.6269,18.7502 15.1667,18.7502H8.83333C8.3731,18.7502 8,18.4144 8,18.0001Z", - ] - ) - - static let profileFallback = vectorIcon( - viewport: CGSize(width: 24, height: 24), - paths: [ - "M12,12C14.21,12 16,10.21 16,8C16,5.79 14.21,4 12,4C9.79,4 8,5.79 8,8C8,10.21 9.79,12 12,12ZM12,14C9.33,14 4,15.34 4,18V19C4,19.55 4.45,20 5,20H19C19.55,20 20,19.55 20,19V18C20,15.34 14.67,14 12,14Z", - ] - ) - - static func profileAvatar( - name: String?, - avatarColor: UIColor?, - backgroundColor: UIColor?, - avatarImage: UIImage?, - selected: Bool, - accent: UIColor - ) -> UIImage { - guard name != nil || avatarColor != nil || avatarImage != nil else { - return profileFallback - } - - let size = CGSize(width: 28, height: 28) - let baseColor = avatarColor ?? UIColor(red: 30.0 / 255.0, green: 136.0 / 255.0, blue: 229.0 / 255.0, alpha: 1) - let fillColor = backgroundColor ?? baseColor.withAlphaComponent(0.15) - let borderColor = selected ? accent : baseColor.withAlphaComponent(0.5) - let initial = name? - .trimmingCharacters(in: .whitespacesAndNewlines) - .prefix(1) - .uppercased() ?? "" - - return UIGraphicsImageRenderer(size: size).image { _ in - let rect = CGRect(origin: .zero, size: size).insetBy(dx: 1, dy: 1) - fillColor.setFill() - UIBezierPath(ovalIn: rect).fill() - - if let avatarImage { - UIBezierPath(ovalIn: rect).addClip() - drawAspectFill(image: avatarImage, in: rect) - } else if !initial.isEmpty { - let font = UIFont.systemFont(ofSize: size.height * 0.45, weight: .bold) - let attributes: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: baseColor, - ] - let textSize = initial.size(withAttributes: attributes) - initial.draw( - at: CGPoint( - x: rect.midX - textSize.width / 2, - y: rect.midY - textSize.height / 2 - ), - withAttributes: attributes - ) - } else { - profileFallback - .withTintColor(baseColor, renderingMode: .alwaysOriginal) - .draw(in: rect.insetBy(dx: 5.5, dy: 5.5)) - } - - borderColor.setStroke() - let borderPath = UIBezierPath(ovalIn: rect.insetBy(dx: 0.75, dy: 0.75)) - borderPath.lineWidth = 1.5 - borderPath.stroke() - }.withRenderingMode(.alwaysOriginal) - } - - private static func drawInViewport( - context: CGContext, - rect: CGRect, - viewport: CGSize, - draw: () -> Void - ) { - let scale = min(rect.width / viewport.width, rect.height / viewport.height) - let x = rect.midX - viewport.width * scale / 2 - let y = rect.midY - viewport.height * scale / 2 - context.saveGState() - context.translateBy(x: x, y: y) - context.scaleBy(x: scale, y: scale) - draw() - context.restoreGState() - } - - private static func vectorIcon(viewport: CGSize, paths: [String], size: CGSize = CGSize(width: 25, height: 25)) -> UIImage { - drawnIcon(size: size) { context, rect in - drawInViewport(context: context, rect: rect, viewport: viewport) { - context.setFillColor(UIColor.black.cgColor) - paths.compactMap { SVGPath(data: $0).cgPath }.forEach { path in - context.addPath(path) - context.fillPath(using: .evenOdd) - } - } - } - } - - private static func drawnIcon( - size: CGSize = CGSize(width: 25, height: 25), - draw: @escaping (CGContext, CGRect) -> Void - ) -> UIImage { - UIGraphicsImageRenderer(size: size).image { rendererContext in - draw(rendererContext.cgContext, CGRect(origin: .zero, size: size)) - }.withRenderingMode(.alwaysTemplate) - } - - private static func drawAspectFill(image: UIImage, in rect: CGRect) { - guard image.size.width > 0, image.size.height > 0 else { return } - let scale = max(rect.width / image.size.width, rect.height / image.size.height) - let drawSize = CGSize(width: image.size.width * scale, height: image.size.height * scale) - let drawRect = CGRect( - x: rect.midX - drawSize.width / 2, - y: rect.midY - drawSize.height / 2, - width: drawSize.width, - height: drawSize.height + static func wrap( + _ contentController: UIViewController, + disablesInteractiveContentPopGesture: Bool = false, + onTabBarAvailable: ((UITabBar) -> Void)? = nil + ) -> RootComposeViewController { + _ = registerPlayerBridge + contentController.view.backgroundColor = nuvioBackgroundColor + return RootComposeViewController( + contentController: contentController, + disablesInteractiveContentPopGesture: disablesInteractiveContentPopGesture, + onTabBarAvailable: onTabBarAvailable ) - image.draw(in: drawRect) - } - - private struct SVGPath { - private enum Token { - case command(Character) - case number(CGFloat) - } - - let data: String - - var cgPath: CGPath? { - let tokens = Self.tokens(from: data) - var index = 0 - var command: Character? - var current = CGPoint.zero - var subpathStart = CGPoint.zero - let path = CGMutablePath() - - func hasNumber() -> Bool { - guard index < tokens.count else { return false } - if case .number = tokens[index] { return true } - return false - } - - func readNumber() -> CGFloat? { - guard index < tokens.count else { return nil } - guard case let .number(value) = tokens[index] else { return nil } - index += 1 - return value - } - - func readPoint(relative: Bool) -> CGPoint? { - guard let x = readNumber(), let y = readNumber() else { return nil } - let point = CGPoint(x: x, y: y) - return relative ? CGPoint(x: current.x + point.x, y: current.y + point.y) : point - } - - while index < tokens.count { - if case let .command(value) = tokens[index] { - command = value - index += 1 - } - - guard let activeCommand = command else { return nil } - let relative = activeCommand.isLowercase - - switch activeCommand.uppercased() { - case "M": - guard let point = readPoint(relative: relative) else { return nil } - path.move(to: point) - current = point - subpathStart = point - command = relative ? "l" : "L" - case "L": - while hasNumber() { - guard let point = readPoint(relative: relative) else { return nil } - path.addLine(to: point) - current = point - } - case "H": - while hasNumber() { - guard let x = readNumber() else { return nil } - let point = CGPoint(x: relative ? current.x + x : x, y: current.y) - path.addLine(to: point) - current = point - } - case "V": - while hasNumber() { - guard let y = readNumber() else { return nil } - let point = CGPoint(x: current.x, y: relative ? current.y + y : y) - path.addLine(to: point) - current = point - } - case "C": - while hasNumber() { - guard - let c1 = readPoint(relative: relative), - let c2 = readPoint(relative: relative), - let end = readPoint(relative: relative) - else { return nil } - path.addCurve(to: end, control1: c1, control2: c2) - current = end - } - case "Z": - path.closeSubpath() - current = subpathStart - default: - return nil - } - } - - return path - } - - private static func tokens(from data: String) -> [Token] { - let pattern = "[MmLlHhVvCcZz]|[-+]?(?:\\d*\\.\\d+|\\d+\\.?)(?:[eE][-+]?\\d+)?" - guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } - let range = NSRange(data.startIndex.. String { - defaults.string(forKey: titleKey)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? fallbackTitle - } - - var iconImage: UIImage { - switch self { - case .home: return NuvioNativeTabIcon.home - case .search: return NuvioNativeTabIcon.search - case .library: return NuvioNativeTabIcon.library - case .settings: return NuvioNativeTabIcon.profileFallback - } - } - - init?(tag: Int) { - guard let tab = Self.allCases.first(where: { $0.tag == tag }) else { return nil } - self = tab - } - } - - private static let liquidGlassEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled" - private static let nativeTabBarVisibleKey = "NuvioNativeTabBarVisible" - private static let nativeSelectedTabKey = "NuvioNativeSelectedTab" - private static let nativeTabAccentColorKey = "NuvioNativeTabAccentColor" - private static let nativeProfileNameKey = "NuvioNativeProfileName" - private static let nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor" - private static let nativeProfileAvatarURLKey = "NuvioNativeProfileAvatarURL" - private static let nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor" - private static let nativeTabChromeDidChangeNotification = Notification.Name("NuvioNativeTabChromeDidChange") - +/// A navigation-neutral container for Compose. The MPV player is nested below the +/// Compose controller, so UIKit's immersive-system-UI queries need to be forwarded +/// to the deepest child that requests them. +final class RootComposeViewController: UIViewController { private let contentController: UIViewController - private let tabBar = UITabBar() - private let profileTabTouchOverlay = UIControl() - private var contentBottomToViewBottom: NSLayoutConstraint? - private var tabBarHeightConstraint: NSLayoutConstraint? - private var userDefaultsObserver: NSObjectProtocol? - private var tabChromeObserver: NSObjectProtocol? - private var profileTouchRestoreTab: NativeTab? - private var profileLongPressHandled = false - private var profileAvatarImageURL: String? - private var profileAvatarImageTask: URLSessionDataTask? - private var profileAvatarImage: UIImage? + private let disablesInteractiveContentPopGesture: Bool + private let onTabBarAvailable: ((UITabBar) -> Void)? - init(contentController: UIViewController) { + init( + contentController: UIViewController, + disablesInteractiveContentPopGesture: Bool, + onTabBarAvailable: ((UITabBar) -> Void)? + ) { self.contentController = contentController + self.disablesInteractiveContentPopGesture = disablesInteractiveContentPopGesture + self.onTabBarAvailable = onTabBarAvailable super.init(nibName: nil, bundle: nil) } @@ -342,51 +57,19 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .black - contentController.view.backgroundColor = .black - UserDefaults.standard.set(false, forKey: Self.nativeTabBarVisibleKey) + view.backgroundColor = nuvioBackgroundColor + contentController.view.backgroundColor = nuvioBackgroundColor addChild(contentController) view.addSubview(contentController.view) contentController.view.translatesAutoresizingMaskIntoConstraints = false - let bottomToViewBottom = contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) - self.contentBottomToViewBottom = bottomToViewBottom NSLayoutConstraint.activate([ contentController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), contentController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), contentController.view.topAnchor.constraint(equalTo: view.topAnchor), - bottomToViewBottom, + contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) contentController.didMove(toParent: self) - - configureNativeTabBar() - installNativeTabObservers() - syncNativeTabChrome(animated: false) - } - - deinit { - if let userDefaultsObserver { - NotificationCenter.default.removeObserver(userDefaultsObserver) - } - if let tabChromeObserver { - NotificationCenter.default.removeObserver(tabChromeObserver) - } - profileAvatarImageTask?.cancel() - } - - override func viewSafeAreaInsetsDidChange() { - super.viewSafeAreaInsetsDidChange() - updateTabBarHeight() - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - updateProfileTabTouchOverlayFrame() - } - - func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { - guard let tab = NativeTab(tag: item.tag) else { return } - selectNativeTab(tab) } override var childForHomeIndicatorAutoHidden: UIViewController? { @@ -417,12 +100,37 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { .fade } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + setInteractiveContentPopGestureEnabled(false) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + setInteractiveContentPopGestureEnabled(false) + if let tabBar = tabBarController?.tabBar { + onTabBarAvailable?(tabBar) + } + } + + override func viewWillDisappear(_ animated: Bool) { + setInteractiveContentPopGestureEnabled(true) + super.viewWillDisappear(animated) + } + func refreshImmersiveSystemUI() { setNeedsUpdateOfHomeIndicatorAutoHidden() setNeedsUpdateOfScreenEdgesDeferringSystemGestures() setNeedsStatusBarAppearanceUpdate() } + private func setInteractiveContentPopGestureEnabled(_ enabled: Bool) { + guard disablesInteractiveContentPopGesture else { return } + if #available(iOS 26.0, *) { + navigationController?.interactiveContentPopGestureRecognizer?.isEnabled = enabled + } + } + private func immersiveController(in controller: UIViewController?) -> UIViewController? { guard let controller else { return nil } @@ -444,318 +152,1123 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { return nil } +} - private var nativeTabsSupported: Bool { - UIDevice.current.userInterfaceIdiom == .phone && - ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 +// MARK: - UIKit fallback + +struct ComposeView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + NuvioComposeHost.wrap(MainViewControllerKt.MainViewController()) } - private var shouldShowNativeTabBar: Bool { - nativeTabsSupported && - UserDefaults.standard.bool(forKey: Self.liquidGlassEnabledKey) && - UserDefaults.standard.bool(forKey: Self.nativeTabBarVisibleKey) + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +// MARK: - Native iOS navigation + +@available(iOS 16.0, *) +struct RouteWrapper: Hashable, Identifiable { + let id = UUID() + let route: AppRoute + + static func == (lhs: RouteWrapper, rhs: RouteWrapper) -> Bool { + lhs.id == rhs.id } - private func configureNativeTabBar() { - tabBar.delegate = self - tabBar.translatesAutoresizingMaskIntoConstraints = false - tabBar.items = NativeTab.allCases.map { tab in - let item = UITabBarItem( - title: tab.localizedTitle(), - image: tab.iconImage, - selectedImage: tab.iconImage - ) - item.tag = tab.tag - return item + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +@available(iOS 16.0, *) +@MainActor +final class TabNavigationCoordinator: ObservableObject { + @Published var path: [RouteWrapper] = [] + + func push(_ route: AppRoute, launchSingleTop: Bool) { + if launchSingleTop, + path.last?.route.navigationIdentity == route.navigationIdentity { + AppKt.disposeRoute(route: route) + return } - tabBar.selectedItem = tabBar.items?.first - applyNativeTabBarAppearance() - tabBar.alpha = 0 - tabBar.isHidden = true - - view.addSubview(tabBar) - configureProfileTabTouchOverlay() - let heightConstraint = tabBar.heightAnchor.constraint(equalToConstant: tabBarHeight) - tabBarHeightConstraint = heightConstraint - NSLayoutConstraint.activate([ - tabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tabBar.bottomAnchor.constraint(equalTo: view.bottomAnchor), - heightConstraint, - ]) + path.append(RouteWrapper(route: route)) } - private func installNativeTabObservers() { - userDefaultsObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, + func pop() { + guard !path.isEmpty else { return } + var updatedPath = path + updatedPath.removeLast() + setPath(updatedPath) + } + + func replace(_ route: AppRoute) { + var updatedPath = path + if updatedPath.isEmpty { + updatedPath.append(RouteWrapper(route: route)) + } else { + updatedPath[updatedPath.index(before: updatedPath.endIndex)] = RouteWrapper(route: route) + } + setPath(updatedPath) + } + + func popToRoot() { + setPath([]) + } + + /// Used by NavigationStack's path binding so interactive swipe-back and + /// programmatic mutations share the same Kotlin route-disposal behavior. + func setPath(_ newPath: [RouteWrapper]) { + let retainedIDs = Set(newPath.map(\.id)) + let removedRoutes = path + .filter { !retainedIDs.contains($0.id) } + .map(\.route) + + path = newPath + removedRoutes.forEach { AppKt.disposeRoute(route: $0) } + } +} + +@available(iOS 16.0, *) +enum NuvioAppTab: String, CaseIterable, Hashable { + case home = "Home" + case search = "Search" + case library = "Library" + case settings = "Settings" + + var fallbackTitle: String { + String(localized: String.LocalizationValue(rawValue)) + } + + static func from(kotlinName: String?) -> NuvioAppTab? { + switch kotlinName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "home": return .home + case "search": return .search + case "library": return .library + case "settings", "profile": return .settings + default: return nil + } + } + + var iconAssetName: String { + switch self { + case .home: return "NuvioTabHome" + case .search: return "NuvioTabSearch" + case .library: return "NuvioTabLibrary" + case .settings: return "NuvioTabProfile" + } + } + + var fallbackSystemImage: String { + switch self { + case .home: return "house.fill" + case .search: return "magnifyingglass" + case .library: return "rectangle.stack.fill" + case .settings: return "person.crop.circle.fill" + } + } +} + +private enum NuvioNativeTabIcon { + private static let legacyStaticIconSize = CGSize(width: 25, height: 25) + + static func image(for tab: NuvioAppTab) -> UIImage { + if let asset = UIImage(named: tab.iconAssetName) { + return UIGraphicsImageRenderer(size: legacyStaticIconSize).image { _ in + asset + .withRenderingMode(.alwaysOriginal) + .draw(in: CGRect(origin: .zero, size: legacyStaticIconSize)) + }.withRenderingMode(.alwaysTemplate) + } + + return (UIImage(systemName: tab.fallbackSystemImage) ?? UIImage()) + .withRenderingMode(.alwaysTemplate) + } + + static func profileAvatar( + name: String?, + avatarColor: UIColor?, + backgroundColor: UIColor?, + avatarImage: UIImage?, + selected: Bool, + accent: UIColor + ) -> UIImage { + guard name != nil || avatarColor != nil || avatarImage != nil else { + return image(for: .settings) + } + + let size = CGSize(width: 28, height: 28) + let baseColor = avatarColor + ?? UIColor(red: 30 / 255, green: 136 / 255, blue: 229 / 255, alpha: 1) + let fillColor = backgroundColor ?? baseColor.withAlphaComponent(0.15) + let borderColor = selected ? accent : baseColor.withAlphaComponent(0.5) + let initial = name? + .trimmingCharacters(in: .whitespacesAndNewlines) + .prefix(1) + .uppercased() ?? "" + + return UIGraphicsImageRenderer(size: size).image { _ in + let rect = CGRect(origin: .zero, size: size).insetBy(dx: 1, dy: 1) + fillColor.setFill() + UIBezierPath(ovalIn: rect).fill() + + if let avatarImage { + UIBezierPath(ovalIn: rect).addClip() + drawAspectFill(image: avatarImage, in: rect) + } else if !initial.isEmpty { + let font = UIFont.systemFont(ofSize: size.height * 0.45, weight: .bold) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: baseColor, + ] + let textSize = initial.size(withAttributes: attributes) + initial.draw( + at: CGPoint( + x: rect.midX - textSize.width / 2, + y: rect.midY - textSize.height / 2 + ), + withAttributes: attributes + ) + } else { + image(for: .settings) + .withTintColor(baseColor, renderingMode: .alwaysOriginal) + .draw(in: rect.insetBy(dx: 5.5, dy: 5.5)) + } + + borderColor.setStroke() + let borderPath = UIBezierPath(ovalIn: rect.insetBy(dx: 0.75, dy: 0.75)) + borderPath.lineWidth = 1.5 + borderPath.stroke() + }.withRenderingMode(.alwaysOriginal) + } + + private static func drawAspectFill(image: UIImage, in rect: CGRect) { + guard image.size.width > 0, image.size.height > 0 else { return } + let scale = max(rect.width / image.size.width, rect.height / image.size.height) + let drawSize = CGSize(width: image.size.width * scale, height: image.size.height * scale) + image.draw( + in: CGRect( + x: rect.midX - drawSize.width / 2, + y: rect.midY - drawSize.height / 2, + width: drawSize.width, + height: drawSize.height + ) + ) + } +} + +@available(iOS 16.0, *) +final class NativeTabIconStore: ObservableObject { + private static let chromeDidChange = Notification.Name("NuvioNativeTabChromeDidChange") + private static let accentKey = "NuvioNativeTabAccentColor" + private static let profileNameKey = "NuvioNativeProfileName" + private static let profileColorKey = "NuvioNativeProfileAvatarColor" + private static let profileURLKey = "NuvioNativeProfileAvatarURL" + private static let profileBackgroundKey = "NuvioNativeProfileAvatarBackgroundColor" + + @Published private(set) var revision = 0 + @Published private(set) var accentColor = UIColor( + red: 0.96, + green: 0.96, + blue: 0.96, + alpha: 1 + ) + + private var observer: NSObjectProtocol? + private var profileAvatarURL: String? + private var profileAvatarImage: UIImage? + private var profileAvatarTask: URLSessionDataTask? + + init() { + UITabBar.appearance().unselectedItemTintColor = UIColor( + red: 150 / 255, + green: 156 / 255, + blue: 163 / 255, + alpha: 1 + ) + observer = NotificationCenter.default.addObserver( + forName: Self.chromeDidChange, object: nil, queue: .main ) { [weak self] _ in - self?.syncNativeTabChrome(animated: true) + self?.reload() } + reload() + } - tabChromeObserver = NotificationCenter.default.addObserver( - forName: Self.nativeTabChromeDidChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.syncNativeTabChrome(animated: true) + deinit { + if let observer { + NotificationCenter.default.removeObserver(observer) } + profileAvatarTask?.cancel() } - private var tabBarHeight: CGFloat { - 49 + view.safeAreaInsets.bottom - } - - private func updateTabBarHeight() { - tabBarHeightConstraint?.constant = tabBarHeight - updateProfileTabTouchOverlayFrame() - } - - private func syncNativeTabChrome(animated: Bool) { - updateTabBarHeight() - applyNativeTabBarAppearance() - syncSelectedNativeTab() - - let visible = shouldShowNativeTabBar - contentBottomToViewBottom?.isActive = true - if visible { - tabBar.isHidden = false - profileTabTouchOverlay.isHidden = false - } - - let changes = { - self.tabBar.alpha = visible ? 1 : 0 - self.profileTabTouchOverlay.alpha = visible ? 1 : 0 - self.view.layoutIfNeeded() - } - - let completion: (Bool) -> Void = { _ in - self.tabBar.isHidden = !visible - self.profileTabTouchOverlay.isHidden = !visible - } - - if animated && view.window != nil { - UIView.animate( - withDuration: 0.22, - delay: 0, - options: [.beginFromCurrentState, .curveEaseInOut], - animations: changes, - completion: completion - ) - } else { - changes() - completion(true) - } - } - - private func syncSelectedNativeTab() { - tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == currentNativeSelectedTab.tag }) - } - - @objc private func handleNativeProfileTabLongPress(_ recognizer: UILongPressGestureRecognizer) { - guard recognizer.state == .began else { return } - - profileLongPressHandled = true - DispatchQueue.main.async { - NativeTabBridgeKt.nativeProfileTabLongPress() - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in - self?.restoreProfileTabTouchIfNeeded() - } - } - - @objc private func handleNativeProfileTabTouchDown() { - profileTouchRestoreTab = currentNativeSelectedTab - profileLongPressHandled = false - } - - @objc private func handleNativeProfileTabTap() { - if profileLongPressHandled { - profileLongPressHandled = false - restoreProfileTabTouchIfNeeded() - return - } - profileTouchRestoreTab = nil - selectNativeTab(.settings) - } - - @objc private func handleNativeProfileTabTouchCancel() { - profileLongPressHandled = false - restoreProfileTabTouchIfNeeded() - } - - private var currentNativeSelectedTab: NativeTab { - let rawValue = UserDefaults.standard.string(forKey: Self.nativeSelectedTabKey) ?? NativeTab.home.rawValue - return NativeTab(rawValue: rawValue) ?? .home - } - - private func selectNativeTab(_ tab: NativeTab) { - tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == tab.tag }) - UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey) - NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue) - } - - private func configureProfileTabTouchOverlay() { - profileTabTouchOverlay.backgroundColor = .clear - profileTabTouchOverlay.isOpaque = false - profileTabTouchOverlay.isExclusiveTouch = true - profileTabTouchOverlay.accessibilityLabel = NativeTab.settings.localizedTitle() - profileTabTouchOverlay.accessibilityTraits = .button - profileTabTouchOverlay.addTarget( - self, - action: #selector(handleNativeProfileTabTouchDown), - for: .touchDown - ) - profileTabTouchOverlay.addTarget( - self, - action: #selector(handleNativeProfileTabTap), - for: .touchUpInside - ) - profileTabTouchOverlay.addTarget( - self, - action: #selector(handleNativeProfileTabTouchCancel), - for: [.touchCancel, .touchUpOutside] - ) - - let longPressRecognizer = UILongPressGestureRecognizer( - target: self, - action: #selector(handleNativeProfileTabLongPress(_:)) - ) - longPressRecognizer.minimumPressDuration = 0.45 - longPressRecognizer.cancelsTouchesInView = true - profileTabTouchOverlay.addGestureRecognizer(longPressRecognizer) - - profileTabTouchOverlay.alpha = 0 - profileTabTouchOverlay.isHidden = true - view.addSubview(profileTabTouchOverlay) - updateProfileTabTouchOverlayFrame() - } - - private func restoreProfileTabTouchIfNeeded() { - let tab = profileTouchRestoreTab ?? currentNativeSelectedTab - tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == tab.tag }) - profileTouchRestoreTab = nil - } - - private func updateProfileTabTouchOverlayFrame() { - let tabCount = CGFloat(NativeTab.allCases.count) - guard tabCount > 0, tabBar.bounds.width > 0 else { - profileTabTouchOverlay.frame = .zero - return - } - - let itemWidth = tabBar.bounds.width / tabCount - let settingsIndex = CGFloat(NativeTab.settings.tag) - let visualIndex: CGFloat - if tabBar.effectiveUserInterfaceLayoutDirection == .rightToLeft { - visualIndex = tabCount - 1 - settingsIndex - } else { - visualIndex = settingsIndex - } - let overlayFrameInTabBar = CGRect( - x: itemWidth * visualIndex, - y: 0, - width: itemWidth, - height: tabBar.bounds.height - ) - profileTabTouchOverlay.frame = tabBar.convert(overlayFrameInTabBar, to: view) - profileTabTouchOverlay.alpha = tabBar.alpha - view.bringSubviewToFront(profileTabTouchOverlay) - } - - private func applyNativeTabBarAppearance() { - let accent = UIColor(hexString: UserDefaults.standard.string(forKey: Self.nativeTabAccentColorKey)) ?? - UIColor(red: 0.96, green: 0.96, blue: 0.96, alpha: 1) - let unselected = UIColor(red: 150 / 255, green: 156 / 255, blue: 163 / 255, alpha: 1) - - updateNativeTabTitles() - refreshProfileAvatarImageIfNeeded() - updateNativeTabImages(accent: accent) - - tabBar.tintColor = accent - tabBar.unselectedItemTintColor = unselected - - let appearance = tabBar.standardAppearance.copy() as! UITabBarAppearance - appearance.stackedLayoutAppearance.normal.iconColor = unselected - appearance.stackedLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected] - appearance.stackedLayoutAppearance.selected.iconColor = accent - appearance.stackedLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent] - appearance.inlineLayoutAppearance.normal.iconColor = unselected - appearance.inlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected] - appearance.inlineLayoutAppearance.selected.iconColor = accent - appearance.inlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent] - appearance.compactInlineLayoutAppearance.normal.iconColor = unselected - appearance.compactInlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected] - appearance.compactInlineLayoutAppearance.selected.iconColor = accent - appearance.compactInlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent] - tabBar.standardAppearance = appearance - tabBar.scrollEdgeAppearance = appearance - } - - private func updateNativeTabImages(accent: UIColor) { - tabBar.items?.forEach { item in - guard let tab = NativeTab(tag: item.tag) else { return } - item.image = nativeTabImage(for: tab, selected: false, accent: accent) - item.selectedImage = nativeTabImage(for: tab, selected: true, accent: accent) - } - } - - private func updateNativeTabTitles() { - tabBar.items?.forEach { item in - guard let tab = NativeTab(tag: item.tag) else { return } - item.title = tab.localizedTitle() - } - profileTabTouchOverlay.accessibilityLabel = NativeTab.settings.localizedTitle() - } - - private func nativeTabImage(for tab: NativeTab, selected: Bool, accent: UIColor) -> UIImage { + func image(for tab: NuvioAppTab, selected: Bool) -> UIImage { guard tab == .settings else { - return tab.iconImage + return NuvioNativeTabIcon.image(for: tab) } let defaults = UserDefaults.standard return NuvioNativeTabIcon.profileAvatar( - name: defaults.string(forKey: Self.nativeProfileNameKey), - avatarColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarColorKey)), - backgroundColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarBackgroundColorKey)), + name: defaults.string(forKey: Self.profileNameKey), + avatarColor: UIColor(hexString: defaults.string(forKey: Self.profileColorKey)), + backgroundColor: UIColor(hexString: defaults.string(forKey: Self.profileBackgroundKey)), avatarImage: profileAvatarImage, selected: selected, - accent: accent + accent: accentColor ) } - private func refreshProfileAvatarImageIfNeeded() { - let urlString = UserDefaults.standard.string(forKey: Self.nativeProfileAvatarURLKey) - guard urlString != profileAvatarImageURL else { return } + private func reload() { + let defaults = UserDefaults.standard + accentColor = UIColor(hexString: defaults.string(forKey: Self.accentKey)) + ?? UIColor(red: 0.96, green: 0.96, blue: 0.96, alpha: 1) - profileAvatarImageTask?.cancel() - profileAvatarImageTask = nil - profileAvatarImageURL = urlString + let nextURL = defaults.string(forKey: Self.profileURLKey) + guard nextURL != profileAvatarURL else { + revision &+= 1 + return + } + + profileAvatarTask?.cancel() + profileAvatarTask = nil + profileAvatarURL = nextURL profileAvatarImage = nil + revision &+= 1 - guard let urlString, let url = URL(string: urlString) else { return } - - profileAvatarImageTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in - guard - let self, - let data, - let image = UIImage(data: data) - else { return } - + guard let nextURL, let url = URL(string: nextURL) else { return } + profileAvatarTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard let data, let image = UIImage(data: data) else { return } DispatchQueue.main.async { - guard self.profileAvatarImageURL == urlString else { return } + guard let self, self.profileAvatarURL == nextURL else { return } self.profileAvatarImage = image - self.applyNativeTabBarAppearance() + self.revision &+= 1 } } - profileAvatarImageTask?.resume() + profileAvatarTask?.resume() + } +} + +@available(iOS 16.0, *) +@MainActor +final class NativeProfileTabInteractionCoordinator: NSObject, UIGestureRecognizerDelegate { + var onLongPress: (() -> Void)? + private(set) var isHandlingLongPress = false + private(set) var suppressesProfileSelection = false + private weak var tabBar: UITabBar? + private var resetWorkItem: DispatchWorkItem? + private lazy var recognizer: UILongPressGestureRecognizer = { + let recognizer = UILongPressGestureRecognizer( + target: self, + action: #selector(handleLongPress(_:)) + ) + recognizer.minimumPressDuration = 0.45 + recognizer.cancelsTouchesInView = true + recognizer.delegate = self + return recognizer + }() + + func attach(to tabBar: UITabBar) { + guard self.tabBar !== tabBar else { return } + self.tabBar?.removeGestureRecognizer(recognizer) + tabBar.addGestureRecognizer(recognizer) + self.tabBar = tabBar + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + guard gestureRecognizer === recognizer, + let tabBar, + let profileItem = tabBar.items?.last else { + return false + } + guard #available(iOS 17.0, *), + let profileFrame = profileItem.frame(in: tabBar) else { return false } + return profileFrame.contains(touch.location(in: tabBar)) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + gestureRecognizer === recognizer || otherGestureRecognizer === recognizer + } + + @objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) { + switch recognizer.state { + case .began: + resetWorkItem?.cancel() + isHandlingLongPress = true + suppressesProfileSelection = true + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + onLongPress?() + case .ended, .cancelled, .failed: + let workItem = DispatchWorkItem { [weak self] in + self?.isHandlingLongPress = false + self?.suppressesProfileSelection = false + } + resetWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.75, execute: workItem) + default: + break + } + } +} + +@available(iOS 16.0, *) +@MainActor +final class AppNavigationCoordinator: ObservableObject { + @Published var selectedTab: NuvioAppTab = .home + @Published private(set) var isAppReady = false + @Published private var localizedTabTitles: [NuvioAppTab: String] = [:] + @Published var isProfileSwitcherPresented = false + + let homeCoordinator = TabNavigationCoordinator() + let searchCoordinator = TabNavigationCoordinator() + let libraryCoordinator = TabNavigationCoordinator() + let settingsCoordinator = TabNavigationCoordinator() + let profileSwitcherController = NativeProfileSwitcherController() + let profileTabInteraction = NativeProfileTabInteractionCoordinator() + + init() { + profileTabInteraction.onLongPress = { [weak self] in + guard let self, self.isAppReady else { return } + self.isProfileSwitcherPresented = true + } + } + + private var allCoordinators: [TabNavigationCoordinator] { + [homeCoordinator, searchCoordinator, libraryCoordinator, settingsCoordinator] + } + + func coordinator(for tab: NuvioAppTab) -> TabNavigationCoordinator { + switch tab { + case .home: return homeCoordinator + case .search: return searchCoordinator + case .library: return libraryCoordinator + case .settings: return settingsCoordinator + } + } + + func activateTab(named tabName: String) { + guard let tab = NuvioAppTab.from(kotlinName: tabName) else { return } + if tab == .home || isAppReady { + selectedTab = tab + } + } + + func title(for tab: NuvioAppTab) -> String { + localizedTabTitles[tab] ?? tab.fallbackTitle + } + + func updateTabTitles(home: String, search: String, library: String, profile: String) { + localizedTabTitles = [ + .home: home, + .search: search, + .library: library, + .settings: profile, + ] + } + + func updateAppReady(_ ready: Bool) { + isAppReady = ready + if !ready { + isProfileSwitcherPresented = false + selectedTab = .home + allCoordinators.forEach { $0.popToRoot() } + } + } + + func openProfileManagement() { + isProfileSwitcherPresented = false + updateAppReady(false) + profileSwitcherController.requestManageProfiles() + } + + func tab(for target: TabNavigationCoordinator) -> NuvioAppTab? { + NuvioAppTab.allCases.first { coordinator(for: $0) === target } + } + + func push( + _ route: AppRoute, + from origin: TabNavigationCoordinator, + launchSingleTop: Bool + ) { + guard isAppReady else { + AppKt.disposeRoute(route: route) + return + } + let targetTab = NuvioAppTab.from(kotlinName: route.preferredTabName) + ?? tab(for: origin) + ?? selectedTab + let target = coordinator(for: targetTab) + selectedTab = targetTab + target.push(route, launchSingleTop: launchSingleTop) + } + + func replace(_ route: AppRoute, in target: TabNavigationCoordinator) { + guard isAppReady else { + AppKt.disposeRoute(route: route) + return + } + if let targetTab = tab(for: target) { + selectedTab = targetTab + } + target.replace(route) + } +} + +@available(iOS 16.0, *) +struct NativeNavComposeView: UIViewControllerRepresentable { + let tab: NuvioAppTab + let usesNativeTabBar: Bool + let usesTabletFloatingTabBar: Bool + let coordinator: TabNavigationCoordinator + let appCoordinator: AppNavigationCoordinator + + func makeUIViewController(context: Context) -> UIViewController { + let controller = MainViewControllerKt.MainViewController( + initialTabName: tab.rawValue, + useNativeTabBar: usesNativeTabBar, + useTabletFloatingTabBar: usesTabletFloatingTabBar, + onNavigate: { route, launchSingleTop in + appCoordinator.push( + route, + from: coordinator, + launchSingleTop: launchSingleTop.boolValue + ) + }, + onGoBack: { + coordinator.pop() + }, + onReplace: { route in + appCoordinator.replace(route, in: coordinator) + }, + onActivate: { tabName in + appCoordinator.activateTab(named: tabName) + }, + onAppReady: { ready in + appCoordinator.updateAppReady(ready.boolValue) + }, + onTabTitles: { home, search, library, profile in + appCoordinator.updateTabTitles( + home: home, + search: search, + library: library, + profile: profile + ) + }, + nativeProfileSwitcherController: appCoordinator.profileSwitcherController + ) + return NuvioComposeHost.wrap( + controller, + onTabBarAvailable: { tabBar in + appCoordinator.profileTabInteraction.attach(to: tabBar) + } + ) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +@available(iOS 16.0, *) +struct DetailComposeView: UIViewControllerRepresentable { + let route: AppRoute + let coordinator: TabNavigationCoordinator + let appCoordinator: AppNavigationCoordinator + + func makeUIViewController(context: Context) -> UIViewController { + let controller = MainViewControllerKt.ScreenViewController( + route: route, + onNavigate: { newRoute, launchSingleTop in + appCoordinator.push( + newRoute, + from: coordinator, + launchSingleTop: launchSingleTop.boolValue + ) + }, + onGoBack: { + coordinator.pop() + }, + onReplace: { newRoute in + appCoordinator.replace(newRoute, in: coordinator) + }, + onActivate: { tabName in + appCoordinator.activateTab(named: tabName) + } + ) + return NuvioComposeHost.wrap( + controller, + disablesInteractiveContentPopGesture: true + ) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +@available(iOS 16.0, *) +struct TabContentView: View { + let tab: NuvioAppTab + let usesNativeTabBar: Bool + let usesTabletFloatingTabBar: Bool + @ObservedObject var coordinator: TabNavigationCoordinator + @ObservedObject var appCoordinator: AppNavigationCoordinator + + var body: some View { + NavigationStack( + path: Binding( + get: { coordinator.path }, + set: { coordinator.setPath($0) } + ) + ) { + NativeNavComposeView( + tab: tab, + usesNativeTabBar: usesNativeTabBar, + usesTabletFloatingTabBar: usesTabletFloatingTabBar, + coordinator: coordinator, + appCoordinator: appCoordinator + ) + .ignoresSafeArea(.all) + .navigationTitle(appCoordinator.title(for: tab)) + .navigationBarHidden(true) + .navigationDestination(for: RouteWrapper.self) { wrapper in + if appCoordinator.selectedTab == tab { + DetailDestinationView( + wrapper: wrapper, + coordinator: coordinator, + appCoordinator: appCoordinator + ) + // A native replace keeps the same NavigationStack depth. + // Keying by the wrapper forces SwiftUI to replace the + // embedded Compose controller instead of reusing the old + // screen with the new route's toolbar preferences. + .id(wrapper.id) + } else { + Color.clear + } + } + } + // Tab-bar visibility is a preference emitted by the active navigation + // stack. Applying it here keeps the authentication/profile gate truly + // full-screen on iOS 26, where a modifier on TabView itself is ignored. + .toolbar( + usesNativeTabBar && appCoordinator.isAppReady && coordinator.path.isEmpty + ? Visibility.visible + : Visibility.hidden, + for: .tabBar + ) + } +} + +@available(iOS 16.0, *) +private struct NativeToolbarReadabilityFade: View { + var body: some View { + Rectangle() + .fill( + LinearGradient( + stops: [ + .init(color: Color(uiColor: nuvioBackgroundColor), location: 0), + .init(color: Color(uiColor: nuvioBackgroundColor).opacity(0.78), location: 0.55), + .init(color: Color(uiColor: nuvioBackgroundColor).opacity(0), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(height: 120) + .ignoresSafeArea(edges: .top) + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +@available(iOS 16.0, *) +private struct DetailDestinationView: View { + let wrapper: RouteWrapper + @ObservedObject var coordinator: TabNavigationCoordinator + @ObservedObject var appCoordinator: AppNavigationCoordinator + + private var usesComposeNavigationHeader: Bool { + wrapper.route is DetailRoute || wrapper.route is StreamRoute + } + + private var showsReadabilityFade: Bool { + !wrapper.route.hidesNavigationBar && !usesComposeNavigationHeader + } + + private var content: some View { + ZStack(alignment: .top) { + DetailComposeView( + route: wrapper.route, + coordinator: coordinator, + appCoordinator: appCoordinator + ) + .ignoresSafeArea(.all) + + if showsReadabilityFade { + NativeToolbarReadabilityFade() + } + } + .navigationTitle(wrapper.route.title ?? "") + .navigationBarTitleDisplayMode(.inline) + .toolbarRole(usesComposeNavigationHeader ? .editor : .automatic) + .toolbar { + if usesComposeNavigationHeader { + ToolbarItem(placement: .principal) { + Color.clear.frame(width: 1, height: 1) + } + } + } + .toolbar(.hidden, for: .tabBar) + .toolbar( + wrapper.route.hidesNavigationBar ? Visibility.hidden : Visibility.visible, + for: .navigationBar + ) + } + + @ViewBuilder + var body: some View { + if #available(iOS 26.0, *), !usesComposeNavigationHeader { + content.navigationSubtitle(wrapper.route.subtitle ?? "") + } else { + content + } + } +} + +@available(iOS 26.0, *) +private struct NativeProfileItem: Identifiable, Equatable { + let id: Int32 + let name: String + let avatarColor: UIColor + let avatarBackgroundColor: UIColor + let avatarURL: URL? + let pinEnabled: Bool + let active: Bool + + init(_ option: NativeProfileOption) { + id = option.profileIndex + name = option.name + avatarColor = UIColor(hexString: option.avatarColorHex) + ?? UIColor(red: 30 / 255, green: 136 / 255, blue: 229 / 255, alpha: 1) + avatarBackgroundColor = UIColor(hexString: option.avatarBackgroundColorHex) + ?? avatarColor.withAlphaComponent(0.16) + avatarURL = option.avatarImageUrl.flatMap(URL.init(string:)) + pinEnabled = option.pinEnabled + active = option.active + } +} + +@available(iOS 26.0, *) +@MainActor +private final class NativeProfileSwitcherViewModel: ObservableObject { + @Published private(set) var profiles: [NativeProfileItem] = [] + @Published private(set) var isLoaded = false + @Published private(set) var canAddProfile = false + @Published var lockedProfile: NativeProfileItem? + @Published var pin = "" + @Published var errorMessage: String? + @Published private(set) var isSubmitting = false + + private let controller: NativeProfileSwitcherController + + init(controller: NativeProfileSwitcherController) { + self.controller = controller + apply(controller.currentState()) + } + + func startObserving() { + controller.observeState { [weak self] state in + self?.apply(state) + } + } + + func stopObserving() { + controller.stopObserving() + } + + func choose(_ profile: NativeProfileItem, onComplete: @escaping () -> Void) { + if profile.pinEnabled { + lockedProfile = profile + pin = "" + errorMessage = nil + } else { + submit(profile, pin: nil, onComplete: onComplete) + } + } + + func updatePin(_ value: String) { + pin = String(value.filter(\.isNumber).prefix(4)) + errorMessage = nil + } + + func unlock(onComplete: @escaping () -> Void) { + guard let lockedProfile, pin.count == 4 else { return } + submit(lockedProfile, pin: pin, onComplete: onComplete) + } + + func cancelUnlock() { + lockedProfile = nil + pin = "" + errorMessage = nil + } + + private func apply(_ state: NativeProfileSwitcherState) { + profiles = state.profiles.map(NativeProfileItem.init) + isLoaded = state.isLoaded + canAddProfile = state.canAddProfile + } + + private func submit( + _ profile: NativeProfileItem, + pin: String?, + onComplete: @escaping () -> Void + ) { + guard !isSubmitting else { return } + isSubmitting = true + errorMessage = nil + controller.chooseProfile(profileIndex: profile.id, pin: pin) { [weak self] result in + Task { @MainActor [weak self] in + guard let self else { return } + self.isSubmitting = false + if result.unlocked { + onComplete() + } else if let message = result.message, !message.isEmpty { + self.errorMessage = message + } else if result.retryAfterSeconds > 0 { + self.errorMessage = "Try again in \(result.retryAfterSeconds) seconds." + } else { + self.errorMessage = "Incorrect PIN." + } + } + } + } +} + +@available(iOS 26.0, *) +private struct NativeProfileAvatarView: View { + let profile: NativeProfileItem + + var body: some View { + ZStack { + Circle().fill(Color(uiColor: profile.avatarBackgroundColor)) + if let avatarURL = profile.avatarURL { + AsyncImage(url: avatarURL) { phase in + if let image = phase.image { + image + .resizable() + .scaledToFill() + } else { + initial + } + } + } else { + initial + } + } + .clipShape(Circle()) + .overlay { + Circle().stroke( + Color(uiColor: profile.avatarColor).opacity(profile.active ? 1 : 0.45), + lineWidth: profile.active ? 2.5 : 1.5 + ) + } + } + + private var initial: some View { + Text(profile.name.trimmingCharacters(in: .whitespacesAndNewlines).prefix(1).uppercased()) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .foregroundStyle(Color(uiColor: profile.avatarColor)) + } +} + +@available(iOS 26.0, *) +private struct NativeProfileSwitcherView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var model: NativeProfileSwitcherViewModel + let onManageProfiles: () -> Void + + init( + controller: NativeProfileSwitcherController, + onManageProfiles: @escaping () -> Void + ) { + _model = StateObject( + wrappedValue: NativeProfileSwitcherViewModel(controller: controller) + ) + self.onManageProfiles = onManageProfiles + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Switch Profile") + .font(.headline) + + if model.isLoaded { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 14) { + ForEach(model.profiles) { profile in + Button { + model.choose(profile, onComplete: dismiss.callAsFunction) + } label: { + VStack(spacing: 6) { + NativeProfileAvatarView(profile: profile) + .frame(width: 52, height: 52) + .overlay(alignment: .bottomTrailing) { + if profile.pinEnabled { + Image(systemName: "lock.fill") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 18, height: 18) + .background(.black.opacity(0.72), in: Circle()) + } + } + + Text(profile.name) + .font(.caption) + .lineLimit(1) + .frame(width: 64) + } + } + .buttonStyle(.plain) + .disabled(model.isSubmitting) + } + + if model.canAddProfile { + Button { + onManageProfiles() + } label: { + VStack(spacing: 6) { + Image(systemName: "plus") + .font(.system(size: 19, weight: .semibold)) + .frame(width: 52, height: 52) + .background(.secondary.opacity(0.12), in: Circle()) + Text("Add") + .font(.caption) + .frame(width: 64) + } + } + .buttonStyle(.plain) + } + } + } + } else { + ProgressView() + .frame(maxWidth: .infinity) + } + + if let lockedProfile = model.lockedProfile { + Divider() + Text("Enter PIN for \(lockedProfile.name)") + .font(.subheadline.weight(.semibold)) + + SecureField("4-digit PIN", text: Binding( + get: { model.pin }, + set: model.updatePin + )) + .keyboardType(.numberPad) + .textContentType(.password) + .multilineTextAlignment(.center) + .font(.title3.monospacedDigit()) + .padding(.horizontal, 12) + .frame(height: 42) + .background(.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 12)) + + if let errorMessage = model.errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Button("Cancel", action: model.cancelUnlock) + Spacer() + Button("Unlock") { + model.unlock(onComplete: dismiss.callAsFunction) + } + .disabled(model.pin.count != 4 || model.isSubmitting) + } + } + } + .padding(18) + .frame(minWidth: 250, idealWidth: 330, maxWidth: 360) + .presentationCompactAdaptation(.popover) + .presentationSizing(.fitted) + .onAppear(perform: model.startObserving) + .onDisappear(perform: model.stopObserving) + } +} + +@available(iOS 16.0, *) +struct NativeNavContentView: View { + @StateObject private var appCoordinator = AppNavigationCoordinator() + @StateObject private var iconStore = NativeTabIconStore() + + private var usesNativeTabBar: Bool { + guard UIDevice.current.userInterfaceIdiom == .phone else { + return false + } + if #available(iOS 26.0, *) { + return true + } + return false + } + + private var usesTabletFloatingTabBar: Bool { + UIDevice.current.userInterfaceIdiom == .pad + } + + private var tabSelection: Binding { + Binding( + get: { appCoordinator.selectedTab }, + set: { newTab in + if newTab == .settings && + ( + appCoordinator.profileTabInteraction.suppressesProfileSelection || + appCoordinator.isProfileSwitcherPresented + ) { + return + } + if newTab == appCoordinator.selectedTab { + NativeTabBridgeKt.nativeTabSelect(tabName: newTab.rawValue) + return + } + if appCoordinator.isAppReady || newTab == .home { + appCoordinator.selectedTab = newTab + } + } + ) + } + + private var legacyTabs: some View { + TabView(selection: tabSelection) { + ForEach(NuvioAppTab.allCases, id: \.self) { tab in + TabContentView( + tab: tab, + usesNativeTabBar: usesNativeTabBar, + usesTabletFloatingTabBar: usesTabletFloatingTabBar, + coordinator: appCoordinator.coordinator(for: tab), + appCoordinator: appCoordinator + ) + .tabItem { + Label { + Text(appCoordinator.title(for: tab)) + } icon: { + Image( + uiImage: iconStore.image( + for: tab, + selected: appCoordinator.selectedTab == tab + ) + ) + .id( + "\(tab.rawValue)-\(iconStore.revision)-" + + "\(appCoordinator.selectedTab == tab)" + ) + } + } + .tag(tab) + } + } + .tint(Color(uiColor: iconStore.accentColor)) + } + + @available(iOS 26.0, *) + private var nativeTabs: some View { + TabView(selection: tabSelection) { + ForEach(NuvioAppTab.allCases, id: \.self) { tab in + if tab == .settings { + Tab(value: tab) { + TabContentView( + tab: tab, + usesNativeTabBar: usesNativeTabBar, + usesTabletFloatingTabBar: usesTabletFloatingTabBar, + coordinator: appCoordinator.coordinator(for: tab), + appCoordinator: appCoordinator + ) + } label: { + Label { + Text(appCoordinator.title(for: tab)) + } icon: { + Image( + uiImage: iconStore.image( + for: tab, + selected: appCoordinator.selectedTab == tab + ) + ) + .id( + "\(tab.rawValue)-\(iconStore.revision)-" + + "\(appCoordinator.selectedTab == tab)" + ) + } + } + .popover( + isPresented: $appCoordinator.isProfileSwitcherPresented, + attachmentAnchor: .rect(.bounds), + arrowEdge: .bottom + ) { + NativeProfileSwitcherView( + controller: appCoordinator.profileSwitcherController, + onManageProfiles: appCoordinator.openProfileManagement + ) + } + } else { + Tab(value: tab) { + TabContentView( + tab: tab, + usesNativeTabBar: usesNativeTabBar, + usesTabletFloatingTabBar: usesTabletFloatingTabBar, + coordinator: appCoordinator.coordinator(for: tab), + appCoordinator: appCoordinator + ) + } label: { + Label { + Text(appCoordinator.title(for: tab)) + } icon: { + Image( + uiImage: iconStore.image( + for: tab, + selected: appCoordinator.selectedTab == tab + ) + ) + .id( + "\(tab.rawValue)-\(iconStore.revision)-" + + "\(appCoordinator.selectedTab == tab)" + ) + } + } + } + } + } + .tint(Color(uiColor: iconStore.accentColor)) + .tabBarMinimizeBehavior(.automatic) + } + + @ViewBuilder + var body: some View { + if #available(iOS 26.0, *), usesNativeTabBar { + nativeTabs + } else { + legacyTabs + } + } +} + +struct ContentView: View { + var body: some View { + if #available(iOS 16.0, *) { + NativeNavContentView() + } else { + ComposeView() + .ignoresSafeArea(.all) + } } } private extension UIColor { convenience init?(hexString: String?) { - guard var value = hexString?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + guard var value = hexString?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return nil } if value.hasPrefix("#") { @@ -772,29 +1285,3 @@ private extension UIColor { ) } } - -private extension String { - var nonEmpty: String? { - isEmpty ? nil : self - } -} - -struct ComposeView: UIViewControllerRepresentable { - func makeUIViewController(context: Context) -> UIViewController { - // Register MPV player bridge before Compose initializes - NuvioPlayerRegistration.register() - - let controller = MainViewControllerKt.MainViewController() - controller.view.backgroundColor = UIColor(red: 0.008, green: 0.016, blue: 0.016, alpha: 1.0) - return RootComposeViewController(contentController: controller) - } - - func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} -} - -struct ContentView: View { - var body: some View { - ComposeView() - .ignoresSafeArea() - } -}