From 3a87ecce0f64102588cf9d2f94f35e3059aedeb1 Mon Sep 17 00:00:00 2001 From: WhiteGiso Date: Sat, 6 Jun 2026 00:04:26 +0200 Subject: [PATCH 1/2] feat(ios): rapid profile switch for liquid glass --- composeApp/proguard-rules.pro | 4 + .../app/core/ui/NativeTabBridge.android.kt | 9 + .../composeResources/values-cs/strings.xml | 2 +- .../composeResources/values-fr/strings.xml | 2 +- .../composeResources/values-id/strings.xml | 2 +- .../composeResources/values-it/strings.xml | 2 +- .../composeResources/values-nb/strings.xml | 2 +- .../composeResources/values-pl/strings.xml | 2 +- .../composeResources/values-pt/strings.xml | 2 +- .../composeResources/values-tr/strings.xml | 2 +- .../composeResources/values/strings.xml | 2 +- .../commonMain/kotlin/com/nuvio/app/App.kt | 54 ++++- .../com/nuvio/app/core/ui/NativeTabBridge.kt | 28 +++ .../features/profiles/ProfileSwitcherTab.kt | 185 +++++++++++++++++- .../nuvio/app/core/ui/NativeTabBridge.ios.kt | 25 +++ iosApp/Configuration/Version.xcconfig | 4 +- iosApp/iosApp/ContentView.swift | 151 +++++++++++++- iosApp/iosApp/Player/MPVPlayerBridge.swift | 6 + 18 files changed, 463 insertions(+), 21 deletions(-) diff --git a/composeApp/proguard-rules.pro b/composeApp/proguard-rules.pro index bbe05c0c8..29e2b9b63 100644 --- a/composeApp/proguard-rules.pro +++ b/composeApp/proguard-rules.pro @@ -29,6 +29,10 @@ -keep class com.nuvio.app.features.streams.StreamsScreenKt { *; } -keep class com.nuvio.app.features.streams.StreamsScreenKt$* { *; } +# Avoid R8 producing verifier-invalid bytecode for the large player composable. +-keep class com.nuvio.app.features.player.PlayerScreenKt { *; } +-keep class com.nuvio.app.features.player.PlayerScreenKt$* { *; } + # QuickJS plugin runtime is dynamic; keep runtime and app plugin classes. -keep class com.dokar.quickjs.** { *; } -keep class com.nuvio.app.features.plugins.** { *; } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt index c7c556c50..900c3489b 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt @@ -10,9 +10,18 @@ internal actual fun publishNativeSelectedTab(tabName: String) = Unit internal actual fun publishNativeTabAccentColor(hexColor: String) = Unit +internal actual fun publishNativeTabTitles( + home: String, + search: String, + library: String, + profile: String, +) = Unit + internal actual fun publishNativeProfileTabIcon( name: String?, avatarColorHex: String?, avatarImageUrl: String?, avatarBackgroundColorHex: String?, ) = Unit + +internal actual fun notifyNativeProfileSwitcherPopupDismissed() = Unit diff --git a/composeApp/src/commonMain/composeResources/values-cs/strings.xml b/composeApp/src/commonMain/composeResources/values-cs/strings.xml index 5e186e5f4..298527b02 100644 --- a/composeApp/src/commonMain/composeResources/values-cs/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-cs/strings.xml @@ -453,7 +453,7 @@ Vyberte jazyk Nastavení pro sekci Pokračovat ve sledování. Tekuté sklo (Liquid Glass) - Použít nativní lištu panelů na iPhonu v iOS 26 a novějším. Okamžité přepínání profilů z lišty panelů není při zapnutí dostupné. + Použít nativní lištu panelů na iPhonu v iOS 26 a novějším. Vyladit šířku karty a poloměr rohů. ZOBRAZENÍ DOMŮ diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index a09f31754..95e83d86a 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -506,7 +506,7 @@ Choisir la langue Afficher, masquer et ajuster le bandeau Continuer à regarder. Liquid Glass - Utilise la barre d’onglets native iPhone sur iOS 26 et versions ultérieures. Le changement instantané de profil depuis la barre d’onglets n’est pas disponible quand cette option est activée. + Utilise la barre d’onglets native iPhone sur iOS 26 et versions ultérieures. Ajuste la largeur des cartes et le rayon des coins. AFFICHAGE ACCUEIL diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index e442e971d..35a923d9f 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -508,7 +508,7 @@ Pilih Bahasa Pengaturan untuk bagian Lanjutkan Menonton. Liquid Glass - Gunakan tab bar iPhone asli di iOS 26 dan yang lebih baru. Pergantian profil instan dari tab bar tidak tersedia saat ini aktif. + Gunakan tab bar iPhone asli di iOS 26 dan yang lebih baru. Sesuaikan lebar kartu dan radius sudut. TAMPILAN BERANDA diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index c72739585..d96e8195f 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -1240,7 +1240,7 @@ Utilizzato per la riproduzione sulle build Android. Rilasciato sotto la licenza Apache License, Versione 2.0. Liquid Glass - Usa la barra dei pannelli nativa dell'iPhone su iOS 26 e versioni successive. Il cambio rapido del profilo dalla barra dei pannelli non è disponibile quando questa opzione è attiva. + Usa la barra dei pannelli nativa dell'iPhone su iOS 26 e versioni successive. Nascondi contenuti non rilasciati Nascondi i film e le serie TV che non sono ancora stati rilasciati. Nascondi sottolineatura catalogo diff --git a/composeApp/src/commonMain/composeResources/values-nb/strings.xml b/composeApp/src/commonMain/composeResources/values-nb/strings.xml index 253a32cc2..ad7cea8c3 100644 --- a/composeApp/src/commonMain/composeResources/values-nb/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nb/strings.xml @@ -492,7 +492,7 @@ Velg språk Innstillinger for Fortsett å se-seksjonen. Liquid Glass - Bruk den innebygde iPhone-fanen i iOS 26 og nyere. Umiddelbar profilbytte fra fanelinjen er ikke tilgjengelig mens dette er på. + Bruk den innebygde iPhone-fanen i iOS 26 og nyere. Juster kortbredde og hjørneradius. VISNING HJEM diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 3e487c29c..1374aebbe 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -508,7 +508,7 @@ Wybierz język Pokaż, ukryj i stylizuj półkę Kontynuuj oglądanie. Liquid Glass - Użyj natywnego paska kart iPhone na iOS 26 i nowszych. Szybkie przełączanie profili z paska kart jest niedostępne, gdy ta opcja jest włączona. + Użyj natywnego paska kart iPhone na iOS 26 i nowszych. Dostosuj szerokość i zaokrąglenie rogów kart plakatów. WYŚWIETLANIE EKRAN GŁÓWNY diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index 725e8cc2e..74c572aee 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -498,7 +498,7 @@ Escolher idioma Definições para a secção Continuar a Ver. Liquid Glass - Usa a barra de separadores nativa do iPhone no iOS 26 e posterior. A mudança instantânea de perfil a partir da barra de separadores fica indisponível enquanto isto estiver ativo. + Usa a barra de separadores nativa do iPhone no iOS 26 e posterior. Ajusta a largura dos cartões e o raio dos cantos. ECRÃ ECRÃ INICIAL diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 9d54c44b1..1340bde98 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -498,7 +498,7 @@ Dil seç İzlemeye Devam Et rafını göster, gizle ve stilini ayarla. Liquid Glass - iOS 26 ve sonrasında yerel iPhone sekme çubuğunu kullan. Bu özellik açıkken sekme çubuğundan anlık profil geçişi yapılamaz. + iOS 26 ve sonrasında yerel iPhone sekme çubuğunu kullan. Uygulama genelindeki poster kartlarının ortak genişliğini ve köşe yuvarlaklığını ayarla. EKRAN ANA SAYFA diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index fd8226bfc..5eb9014a5 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -526,7 +526,7 @@ Choose Language Settings for the Continue Watching section. Liquid Glass - Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on. + Use the native iPhone tab bar on iOS 26 and later. Tune card width and corner radius. DISPLAY HOME diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 444573b93..36fd8e3a4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -158,6 +158,7 @@ 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 @@ -592,6 +593,7 @@ private fun MainAppContent( 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 liquidGlassNativeTabBarEnabled by remember { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled @@ -644,6 +646,10 @@ private fun MainAppContent( val cloudLibraryPlayFailedText = stringResource(Res.string.cloud_library_play_failed) val cloudLibraryPlayDisabledText = stringResource(Res.string.cloud_library_play_disabled) val cloudLibraryPlayNotConnectedText = stringResource(Res.string.cloud_library_play_not_connected) + val nativeTabHomeTitle = stringResource(Res.string.compose_nav_home) + 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 isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT var initialHomeReady by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } @@ -675,6 +681,28 @@ private fun MainAppContent( } } + LaunchedEffect(liquidGlassNativeTabBarSupported, liquidGlassNativeTabBarEnabled) { + NativeTabBridge.profileTabLongPresses.collectLatest { + if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) { + nativeProfileSwitcherVisible = true + } + } + } + + LaunchedEffect( + nativeTabHomeTitle, + nativeTabSearchTitle, + nativeTabLibraryTitle, + nativeTabProfileTitle, + ) { + NativeTabBridge.publishTabTitles( + home = nativeTabHomeTitle, + search = nativeTabSearchTitle, + library = nativeTabLibraryTitle, + profile = nativeTabProfileTitle, + ) + } + LaunchedEffect(selectedTab) { NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab()) if (selectedTab != AppScreenTab.Search) { @@ -682,16 +710,20 @@ private fun MainAppContent( } } + var profileSwitchLoading by remember { mutableStateOf(false) } + DisposableEffect( navController, liquidGlassNativeTabBarSupported, liquidGlassNativeTabBarEnabled, initialHomeReady, + profileSwitchLoading, ) { fun publishNativeTabVisibilityForCurrentRoute() { val visible = liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady && + !profileSwitchLoading && navController.currentDestination?.hasRoute() == true NativeTabBridge.publishTabBarVisible(visible) } @@ -801,7 +833,6 @@ private fun MainAppContent( SyncManager.requestForegroundPull(activeProfileId, force = true) } } - var profileSwitchLoading by remember { mutableStateOf(false) } var resumePromptItem by remember { mutableStateOf(null) } var lastExternalPlayerLaunch by remember { mutableStateOf(null) } val streamLaunchIdsPreservedForPlayerReturn = remember { mutableSetOf() } @@ -1283,7 +1314,9 @@ private fun MainAppContent( liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute() == true val onProfileSelected: (NuvioProfile) -> Unit = { profile -> + nativeProfileSwitcherVisible = false profileSwitchLoading = true + NativeTabBridge.publishTabBarVisible(false) selectedTab = AppScreenTab.Home ProfileRepository.selectProfile(profile.profileIndex) com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex) @@ -1436,7 +1469,24 @@ private fun MainAppContent( selectedTab = selectedTab, onTabSelected = ::handleRootTabClick, onProfileSelected = onProfileSelected, - onAddProfileRequested = onSwitchProfile, + 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(), ) } } 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 aa426d022..18299c1fa 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 @@ -20,11 +20,17 @@ 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) } @@ -41,6 +47,15 @@ internal object NativeTabBridge { publishNativeTabAccentColor(hexColor) } + fun publishTabTitles( + home: String, + search: String, + library: String, + profile: String, + ) { + publishNativeTabTitles(home, search, library, profile) + } + fun publishProfileTabIcon( name: String?, avatarColorHex: String?, @@ -60,6 +75,10 @@ fun nativeTabSelect(tabName: String) { NativeTabBridge.requestTab(tabName) } +fun nativeProfileTabLongPress() { + NativeTabBridge.requestProfileTabLongPress() +} + internal expect fun isLiquidGlassNativeTabBarSupported(): Boolean internal expect fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) @@ -70,9 +89,18 @@ internal expect fun publishNativeSelectedTab(tabName: String) internal expect fun publishNativeTabAccentColor(hexColor: String) +internal expect fun publishNativeTabTitles( + home: String, + search: String, + library: String, + profile: String, +) + internal expect fun publishNativeProfileTabIcon( name: String?, avatarColorHex: String?, avatarImageUrl: String?, avatarBackgroundColorHex: String?, ) + +internal expect fun notifyNativeProfileSwitcherPopupDismissed() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index a399c8220..cf477d8e8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -18,8 +18,10 @@ import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -70,6 +72,7 @@ import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.nuvio.app.isIos +import com.nuvio.app.core.ui.notifyNativeProfileSwitcherPopupDismissed import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* @@ -139,8 +142,8 @@ fun ProfileSwitcherTab( if (profile.pinEnabled) { pinProfile = profile } else { - onProfileSelected(profile) showPopup = false + onProfileSelected(profile) } } @@ -335,6 +338,186 @@ fun ProfileSwitcherTab( } } +@Composable +fun NativeProfileSwitcherPopup( + visible: Boolean, + isSwitchingProfile: Boolean, + onDismissRequest: () -> Unit, + onProfileSelected: (NuvioProfile) -> Unit, + onAddProfileRequested: () -> Unit, + modifier: Modifier = Modifier, +) { + val profileState by ProfileRepository.state.collectAsStateWithLifecycle() + val activeProfile = profileState.activeProfile + val profiles = profileState.profiles + val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle() + val haptic = LocalHapticFeedback.current + val density = LocalDensity.current + + var showPopup by remember { mutableStateOf(false) } + var popupVisible by remember { mutableStateOf(false) } + var pinProfile by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + AvatarRepository.fetchAvatars() + AvatarRepository.refreshAvatars() + } + + LaunchedEffect(visible, isSwitchingProfile, profiles.isNotEmpty()) { + if (visible && !isSwitchingProfile) { + if (profiles.isNotEmpty()) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + showPopup = true + } else { + onDismissRequest() + showPopup = false + } + } else { + showPopup = false + } + } + + fun chooseProfile(profile: NuvioProfile) { + if (profile.pinEnabled) { + pinProfile = profile + } else { + showPopup = false + onDismissRequest() + onProfileSelected(profile) + } + } + + val popupAlpha = remember { Animatable(0f) } + val popupScale = remember { Animatable(0.5f) } + val popupTranslateY = remember { Animatable(40f) } + + LaunchedEffect(showPopup) { + if (showPopup) { + popupVisible = true + launch { popupAlpha.animateTo(1f, tween(220, easing = FastOutSlowInEasing)) } + launch { + popupScale.animateTo( + 1f, + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + ) + } + launch { + popupTranslateY.animateTo( + 0f, + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + ) + } + } else if (popupVisible) { + notifyNativeProfileSwitcherPopupDismissed() + launch { popupAlpha.animateTo(0f, tween(180, easing = FastOutSlowInEasing)) } + launch { popupScale.animateTo(0.85f, tween(200, easing = FastOutSlowInEasing)) } + launch { + popupTranslateY.animateTo(30f, tween(200, easing = FastOutSlowInEasing)) + popupVisible = false + pinProfile = null + } + } + } + + BoxWithConstraints(modifier = modifier) { + val anchorWidth = maxWidth / 4 + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .fillMaxHeight() + .width(anchorWidth), + ) { + if (popupVisible && profiles.isNotEmpty() && !isSwitchingProfile) { + Popup( + alignment = Alignment.BottomCenter, + offset = IntOffset(0, with(density) { -84.dp.roundToPx() }), + properties = PopupProperties(focusable = true), + onDismissRequest = onDismissRequest, + ) { + Box( + modifier = Modifier + .imePadding() + .graphicsLayer { + alpha = popupAlpha.value + scaleX = popupScale.value + scaleY = popupScale.value + translationY = popupTranslateY.value + } + .shadow(16.dp, RoundedCornerShape(28.dp)) + .background( + MaterialTheme.colorScheme.surfaceContainerHigh, + RoundedCornerShape(28.dp), + ) + .padding(16.dp), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top, + ) { + profiles.forEachIndexed { index, profile -> + PopupProfileBubble( + profile = profile, + avatars = avatars, + isActive = profile.profileIndex == activeProfile?.profileIndex, + isSelected = pinProfile?.profileIndex == profile.profileIndex, + delayMs = index * 50, + onBoundsChanged = {}, + onClick = { chooseProfile(profile) }, + ) + } + + if (profiles.size < 4) { + PopupAddProfileBubble( + delayMs = profiles.size * 50, + onClick = { + showPopup = false + onDismissRequest() + onAddProfileRequested() + }, + ) + } + } + + AnimatedVisibility( + visible = pinProfile != null, + enter = expandVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ) + fadeIn(tween(200)), + exit = shrinkVertically(tween(150)) + fadeOut(tween(100)), + ) { + pinProfile?.let { profile -> + InlinePinEntry( + profileName = profile.name, + onVerified = { + showPopup = false + onDismissRequest() + onProfileSelected(profile) + }, + onCancel = { pinProfile = null }, + verifyPin = { pin -> + ProfileRepository.verifyPin(profile.profileIndex, pin) + }, + ) + } + } + } + } + } + } + } + } +} + @Composable private fun PopupAddProfileBubble( delayMs: Int, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt index 1b72da7c2..5fad29aab 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt @@ -9,10 +9,15 @@ private const val liquidGlassNativeTabBarEnabledKey = "NuvioLiquidGlassNativeTab private const val nativeTabBarVisibleKey = "NuvioNativeTabBarVisible" private const val nativeSelectedTabKey = "NuvioNativeSelectedTab" private const val nativeTabAccentColorKey = "NuvioNativeTabAccentColor" +private const val nativeTabTitleHomeKey = "NuvioNativeTabTitleHome" +private const val nativeTabTitleSearchKey = "NuvioNativeTabTitleSearch" +private const val nativeTabTitleLibraryKey = "NuvioNativeTabTitleLibrary" +private const val nativeTabTitleProfileKey = "NuvioNativeTabTitleProfile" private const val nativeProfileNameKey = "NuvioNativeProfileName" private const val nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor" private const val nativeProfileAvatarUrlKey = "NuvioNativeProfileAvatarURL" private const val nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor" +private const val nativeProfileSwitcherPopupDismissedNotification = "NuvioNativeProfileSwitcherPopupDismissed" private const val nativeTabChromeDidChangeNotification = "NuvioNativeTabChromeDidChange" internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean { @@ -38,6 +43,19 @@ internal actual fun publishNativeTabAccentColor(hexColor: String) { notifyNativeTabChromeChanged() } +internal actual fun publishNativeTabTitles( + home: String, + search: String, + library: String, + profile: String, +) { + publishString(nativeTabTitleHomeKey, home) + publishString(nativeTabTitleSearchKey, search) + publishString(nativeTabTitleLibraryKey, library) + publishString(nativeTabTitleProfileKey, profile) + notifyNativeTabChromeChanged() +} + internal actual fun publishNativeProfileTabIcon( name: String?, avatarColorHex: String?, @@ -51,6 +69,13 @@ internal actual fun publishNativeProfileTabIcon( notifyNativeTabChromeChanged() } +internal actual fun notifyNativeProfileSwitcherPopupDismissed() { + NSNotificationCenter.defaultCenter.postNotificationName( + nativeProfileSwitcherPopupDismissedNotification, + null, + ) +} + private fun publishBool(key: String, value: Boolean) { NSUserDefaults.standardUserDefaults.setBool(value, forKey = key) notifyNativeTabChromeChanged() diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 1a9a5661d..92fd04ca2 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=73 -MARKETING_VERSION=0.2.2 +CURRENT_PROJECT_VERSION=74 +MARKETING_VERSION=0.2.3 diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 14f5664a0..ac5db8145 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -253,7 +253,7 @@ private enum NuvioNativeTabIcon { } } -final class RootComposeViewController: UIViewController, UITabBarDelegate { +final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGestureRecognizerDelegate { private enum NativeTab: String, CaseIterable { case home = "Home" case search = "Search" @@ -269,7 +269,16 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { } } - var title: String { + var titleKey: String { + switch self { + case .home: return "NuvioNativeTabTitleHome" + case .search: return "NuvioNativeTabTitleSearch" + case .library: return "NuvioNativeTabTitleLibrary" + case .settings: return "NuvioNativeTabTitleProfile" + } + } + + var fallbackTitle: String { switch self { case .home: return "Home" case .search: return "Search" @@ -278,6 +287,10 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { } } + func localizedTitle(defaults: UserDefaults = .standard) -> String { + defaults.string(forKey: titleKey)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? fallbackTitle + } + var iconImage: UIImage { switch self { case .home: return NuvioNativeTabIcon.home @@ -301,6 +314,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { private static let nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor" private static let nativeProfileAvatarURLKey = "NuvioNativeProfileAvatarURL" private static let nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor" + private static let nativeProfileSwitcherPopupDismissedNotification = Notification.Name("NuvioNativeProfileSwitcherPopupDismissed") private static let nativeTabChromeDidChangeNotification = Notification.Name("NuvioNativeTabChromeDidChange") private let contentController: UIViewController @@ -309,6 +323,9 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { private var tabBarHeightConstraint: NSLayoutConstraint? private var userDefaultsObserver: NSObjectProtocol? private var tabChromeObserver: NSObjectProtocol? + private var profileSwitcherPopupObserver: NSObjectProtocol? + private var profileLongPressRecognizer: UILongPressGestureRecognizer? + private var suppressNextProfileSelection = false private var profileAvatarImageURL: String? private var profileAvatarImageTask: URLSessionDataTask? private var profileAvatarImage: UIImage? @@ -355,6 +372,9 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { if let tabChromeObserver { NotificationCenter.default.removeObserver(tabChromeObserver) } + if let profileSwitcherPopupObserver { + NotificationCenter.default.removeObserver(profileSwitcherPopupObserver) + } profileAvatarImageTask?.cancel() } @@ -365,8 +385,26 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { guard let tab = NativeTab(tag: item.tag) else { return } - UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey) - NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue) + if tab == .settings && suppressNextProfileSelection { + suppressNextProfileSelection = false + restoreNativeTabFocus(to: currentNativeSelectedTab) + return + } + selectNativeTab(tab) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + gestureRecognizer === profileLongPressRecognizer + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + if gestureRecognizer === profileLongPressRecognizer { + return nativeTab(at: touch.location(in: tabBar)) == .settings + } + return true } override var childForHomeIndicatorAutoHidden: UIViewController? { @@ -441,13 +479,19 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { tabBar.translatesAutoresizingMaskIntoConstraints = false tabBar.items = NativeTab.allCases.map { tab in let item = UITabBarItem( - title: tab.title, + title: tab.localizedTitle(), image: tab.iconImage, selectedImage: tab.iconImage ) item.tag = tab.tag return item } + let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleNativeProfileTabLongPress(_:))) + longPressRecognizer.delegate = self + longPressRecognizer.minimumPressDuration = 0.45 + longPressRecognizer.cancelsTouchesInView = true + tabBar.addGestureRecognizer(longPressRecognizer) + profileLongPressRecognizer = longPressRecognizer tabBar.selectedItem = tabBar.items?.first applyNativeTabBarAppearance() tabBar.alpha = 0 @@ -480,6 +524,14 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { ) { [weak self] _ in self?.syncNativeTabChrome(animated: true) } + + profileSwitcherPopupObserver = NotificationCenter.default.addObserver( + forName: Self.nativeProfileSwitcherPopupDismissedNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.suppressNextProfileSelection = false + } } private var tabBarHeight: CGFloat { @@ -525,9 +577,70 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { } 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 } + + suppressNextProfileSelection = true + let tabToRestore = currentNativeSelectedTab + cancelNativeTabTracking() + DispatchQueue.main.async { [weak self] in + self?.restoreNativeTabFocus(to: tabToRestore) + NativeTabBridgeKt.nativeProfileTabLongPress() + } + } + + private var currentNativeSelectedTab: NativeTab { let rawValue = UserDefaults.standard.string(forKey: Self.nativeSelectedTabKey) ?? NativeTab.home.rawValue - let selectedTab = NativeTab(rawValue: rawValue) ?? .home - tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == selectedTab.tag }) + return NativeTab(rawValue: rawValue) ?? .home + } + + private func nativeTab(at point: CGPoint) -> NativeTab? { + guard tabBar.bounds.contains(point), tabBar.bounds.width > 0 else { return nil } + let tabs = NativeTab.allCases + let rawIndex = Int((point.x / tabBar.bounds.width) * CGFloat(tabs.count)) + let clampedIndex = min(max(rawIndex, 0), tabs.count - 1) + return tabs[clampedIndex] + } + + 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 restoreNativeTabFocus(to tab: NativeTab) { + guard let item = tabBar.items?.first(where: { $0.tag == tab.tag }) else { return } + UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey) + NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue) + + tabBar.cancelControlTrackingRecursively() + + let restoreDuration: TimeInterval = 0.24 + UIView.animate( + withDuration: restoreDuration, + delay: 0, + options: [.beginFromCurrentState, .allowUserInteraction, .curveEaseInOut] + ) { + self.tabBar.selectedItem = item + self.tabBar.layoutIfNeeded() + } + } + + private func cancelNativeTabTracking() { + tabBar.cancelControlTrackingRecursively() + tabBar.gestureRecognizers?.forEach { recognizer in + guard recognizer !== profileLongPressRecognizer else { return } + recognizer.isEnabled = false + recognizer.isEnabled = true + } + tabBar.isUserInteractionEnabled = false + DispatchQueue.main.async { [weak self] in + self?.tabBar.cancelControlTrackingRecursively() + self?.tabBar.isUserInteractionEnabled = true + } } private func applyNativeTabBarAppearance() { @@ -535,6 +648,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { 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) @@ -566,6 +680,13 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { } } + private func updateNativeTabTitles() { + tabBar.items?.forEach { item in + guard let tab = NativeTab(tag: item.tag) else { return } + item.title = tab.localizedTitle() + } + } + private func nativeTabImage(for tab: NativeTab, selected: Bool, accent: UIColor) -> UIImage { guard tab == .settings else { return tab.iconImage @@ -630,6 +751,22 @@ private extension UIColor { } } +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } +} + +private extension UIView { + func cancelControlTrackingRecursively() { + if let control = self as? UIControl { + control.cancelTracking(with: nil) + control.isHighlighted = false + } + subviews.forEach { $0.cancelControlTrackingRecursively() } + } +} + struct ComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { // Register MPV player bridge before Compose initializes diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 41d492574..563a692e4 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -60,6 +60,7 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { ) } func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) } + func setMuted(muted: Bool) { playerVC?.setMuted(muted) } func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) } // Audio tracks @@ -517,6 +518,11 @@ final class MPVPlayerViewController: UIViewController { mpv_set_property(mpv, "speed", MPV_FORMAT_DOUBLE, &s) } + func setMuted(_ muted: Bool) { + guard mpv != nil else { return } + setFlag("mute", muted) + } + func setResize(_ mode: Int) { guard mpv != nil else { return } switch mode { From 18d004ffe0232e7c6028d2cd11039136e7dc59de Mon Sep 17 00:00:00 2001 From: WhiteGiso Date: Thu, 18 Jun 2026 14:59:22 +0200 Subject: [PATCH 2/2] Add native liquid glass profile tab handling --- .../app/core/ui/NativeTabBridge.android.kt | 2 - .../commonMain/kotlin/com/nuvio/app/App.kt | 10 +- .../com/nuvio/app/core/ui/NativeTabBridge.kt | 2 - .../features/profiles/ProfileSwitcherTab.kt | 15 +- .../nuvio/app/core/ui/NativeTabBridge.ios.kt | 8 - iosApp/iosApp/ContentView.swift | 188 ++++++++++-------- 6 files changed, 116 insertions(+), 109 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt index 900c3489b..8e860fa35 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt @@ -23,5 +23,3 @@ internal actual fun publishNativeProfileTabIcon( avatarImageUrl: String?, avatarBackgroundColorHex: String?, ) = Unit - -internal actual fun notifyNativeProfileSwitcherPopupDismissed() = Unit diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 755ffd9cd..21b046131 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -103,6 +103,7 @@ 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.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository @@ -1422,6 +1423,11 @@ private fun MainAppContent( val isTabletLayout = maxWidth >= 768.dp val useNativeBottomTabs = liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady + val nativeTabSafeBottomPadding = nuvioBottomNavigationBarInsets() + .asPaddingValues() + .calculateBottomPadding() + val nativeProfileTabAnchorBottomPadding = + nativeTabSafeBottomPadding + NuvioTokens.Space.s10 val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute() == true val onProfileSelected: (NuvioProfile) -> Unit = { profile -> nativeProfileSwitcherVisible = false @@ -1596,7 +1602,9 @@ private fun MainAppContent( nativeProfileSwitcherVisible = false onSwitchProfile() }, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .padding(bottom = nativeProfileTabAnchorBottomPadding), ) } } 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 18299c1fa..2b9d48160 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 @@ -102,5 +102,3 @@ internal expect fun publishNativeProfileTabIcon( avatarImageUrl: String?, avatarBackgroundColorHex: String?, ) - -internal expect fun notifyNativeProfileSwitcherPopupDismissed() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index ba179788b..5e9b47952 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -72,7 +72,6 @@ import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio import com.nuvio.app.isIos -import com.nuvio.app.core.ui.notifyNativeProfileSwitcherPopupDismissed import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* @@ -352,6 +351,7 @@ fun NativeProfileSwitcherPopup( val activeProfile = profileState.activeProfile val profiles = profileState.profiles val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle() + val tokens = MaterialTheme.nuvio val haptic = LocalHapticFeedback.current val density = LocalDensity.current @@ -415,7 +415,6 @@ fun NativeProfileSwitcherPopup( ) } } else if (popupVisible) { - notifyNativeProfileSwitcherPopupDismissed() launch { popupAlpha.animateTo(0f, tween(180, easing = FastOutSlowInEasing)) } launch { popupScale.animateTo(0.85f, tween(200, easing = FastOutSlowInEasing)) } launch { @@ -437,7 +436,7 @@ fun NativeProfileSwitcherPopup( if (popupVisible && profiles.isNotEmpty() && !isSwitchingProfile) { Popup( alignment = Alignment.BottomCenter, - offset = IntOffset(0, with(density) { -84.dp.roundToPx() }), + offset = IntOffset(0, with(density) { -NuvioTokens.Space.s64.roundToPx() }), properties = PopupProperties(focusable = true), onDismissRequest = onDismissRequest, ) { @@ -450,16 +449,16 @@ fun NativeProfileSwitcherPopup( scaleY = popupScale.value translationY = popupTranslateY.value } - .shadow(16.dp, RoundedCornerShape(28.dp)) + .shadow(tokens.elevation.overlay, tokens.shapes.sheet) .background( - MaterialTheme.colorScheme.surfaceContainerHigh, - RoundedCornerShape(28.dp), + tokens.colors.surfaceSheet, + tokens.shapes.sheet, ) - .padding(16.dp), + .padding(tokens.spacing.sheetPadding), ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.cardPadding), verticalAlignment = Alignment.Top, ) { profiles.forEachIndexed { index, profile -> diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt index 5fad29aab..6a3c7e7f6 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt @@ -17,7 +17,6 @@ private const val nativeProfileNameKey = "NuvioNativeProfileName" private const val nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor" private const val nativeProfileAvatarUrlKey = "NuvioNativeProfileAvatarURL" private const val nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor" -private const val nativeProfileSwitcherPopupDismissedNotification = "NuvioNativeProfileSwitcherPopupDismissed" private const val nativeTabChromeDidChangeNotification = "NuvioNativeTabChromeDidChange" internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean { @@ -69,13 +68,6 @@ internal actual fun publishNativeProfileTabIcon( notifyNativeTabChromeChanged() } -internal actual fun notifyNativeProfileSwitcherPopupDismissed() { - NSNotificationCenter.defaultCenter.postNotificationName( - nativeProfileSwitcherPopupDismissedNotification, - null, - ) -} - private fun publishBool(key: String, value: Boolean) { NSUserDefaults.standardUserDefaults.setBool(value, forKey = key) notifyNativeTabChromeChanged() diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index ac5db8145..c8fb0338b 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -253,7 +253,7 @@ private enum NuvioNativeTabIcon { } } -final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGestureRecognizerDelegate { +final class RootComposeViewController: UIViewController, UITabBarDelegate { private enum NativeTab: String, CaseIterable { case home = "Home" case search = "Search" @@ -314,18 +314,17 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes private static let nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor" private static let nativeProfileAvatarURLKey = "NuvioNativeProfileAvatarURL" private static let nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor" - private static let nativeProfileSwitcherPopupDismissedNotification = Notification.Name("NuvioNativeProfileSwitcherPopupDismissed") private static let nativeTabChromeDidChangeNotification = Notification.Name("NuvioNativeTabChromeDidChange") 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 profileSwitcherPopupObserver: NSObjectProtocol? - private var profileLongPressRecognizer: UILongPressGestureRecognizer? - private var suppressNextProfileSelection = false + private var profileTouchRestoreTab: NativeTab? + private var profileLongPressHandled = false private var profileAvatarImageURL: String? private var profileAvatarImageTask: URLSessionDataTask? private var profileAvatarImage: UIImage? @@ -372,9 +371,6 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes if let tabChromeObserver { NotificationCenter.default.removeObserver(tabChromeObserver) } - if let profileSwitcherPopupObserver { - NotificationCenter.default.removeObserver(profileSwitcherPopupObserver) - } profileAvatarImageTask?.cancel() } @@ -383,30 +379,16 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes updateTabBarHeight() } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + updateProfileTabTouchOverlayFrame() + } + func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { guard let tab = NativeTab(tag: item.tag) else { return } - if tab == .settings && suppressNextProfileSelection { - suppressNextProfileSelection = false - restoreNativeTabFocus(to: currentNativeSelectedTab) - return - } selectNativeTab(tab) } - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - gestureRecognizer === profileLongPressRecognizer - } - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - if gestureRecognizer === profileLongPressRecognizer { - return nativeTab(at: touch.location(in: tabBar)) == .settings - } - return true - } - override var childForHomeIndicatorAutoHidden: UIViewController? { immersiveController(in: contentController) ?? contentController } @@ -486,18 +468,13 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes item.tag = tab.tag return item } - let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleNativeProfileTabLongPress(_:))) - longPressRecognizer.delegate = self - longPressRecognizer.minimumPressDuration = 0.45 - longPressRecognizer.cancelsTouchesInView = true - tabBar.addGestureRecognizer(longPressRecognizer) - profileLongPressRecognizer = longPressRecognizer 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([ @@ -524,14 +501,6 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes ) { [weak self] _ in self?.syncNativeTabChrome(animated: true) } - - profileSwitcherPopupObserver = NotificationCenter.default.addObserver( - forName: Self.nativeProfileSwitcherPopupDismissedNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.suppressNextProfileSelection = false - } } private var tabBarHeight: CGFloat { @@ -540,6 +509,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes private func updateTabBarHeight() { tabBarHeightConstraint?.constant = tabBarHeight + updateProfileTabTouchOverlayFrame() } private func syncNativeTabChrome(animated: Bool) { @@ -551,15 +521,18 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes 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 { @@ -583,13 +556,33 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes @objc private func handleNativeProfileTabLongPress(_ recognizer: UILongPressGestureRecognizer) { guard recognizer.state == .began else { return } - suppressNextProfileSelection = true - let tabToRestore = currentNativeSelectedTab - cancelNativeTabTracking() - DispatchQueue.main.async { [weak self] in - self?.restoreNativeTabFocus(to: tabToRestore) + 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 { @@ -597,50 +590,78 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes return NativeTab(rawValue: rawValue) ?? .home } - private func nativeTab(at point: CGPoint) -> NativeTab? { - guard tabBar.bounds.contains(point), tabBar.bounds.width > 0 else { return nil } - let tabs = NativeTab.allCases - let rawIndex = Int((point.x / tabBar.bounds.width) * CGFloat(tabs.count)) - let clampedIndex = min(max(rawIndex, 0), tabs.count - 1) - return tabs[clampedIndex] - } - 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 restoreNativeTabFocus(to tab: NativeTab) { - guard let item = tabBar.items?.first(where: { $0.tag == tab.tag }) else { return } - 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] + ) - tabBar.cancelControlTrackingRecursively() + let longPressRecognizer = UILongPressGestureRecognizer( + target: self, + action: #selector(handleNativeProfileTabLongPress(_:)) + ) + longPressRecognizer.minimumPressDuration = 0.45 + longPressRecognizer.cancelsTouchesInView = true + profileTabTouchOverlay.addGestureRecognizer(longPressRecognizer) - let restoreDuration: TimeInterval = 0.24 - UIView.animate( - withDuration: restoreDuration, - delay: 0, - options: [.beginFromCurrentState, .allowUserInteraction, .curveEaseInOut] - ) { - self.tabBar.selectedItem = item - self.tabBar.layoutIfNeeded() - } + profileTabTouchOverlay.alpha = 0 + profileTabTouchOverlay.isHidden = true + view.addSubview(profileTabTouchOverlay) + updateProfileTabTouchOverlayFrame() } - private func cancelNativeTabTracking() { - tabBar.cancelControlTrackingRecursively() - tabBar.gestureRecognizers?.forEach { recognizer in - guard recognizer !== profileLongPressRecognizer else { return } - recognizer.isEnabled = false - recognizer.isEnabled = true + 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 } - tabBar.isUserInteractionEnabled = false - DispatchQueue.main.async { [weak self] in - self?.tabBar.cancelControlTrackingRecursively() - self?.tabBar.isUserInteractionEnabled = true + + 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() { @@ -685,6 +706,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes 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 { @@ -757,16 +779,6 @@ private extension String { } } -private extension UIView { - func cancelControlTrackingRecursively() { - if let control = self as? UIControl { - control.cancelTracking(with: nil) - control.isHighlighted = false - } - subviews.forEach { $0.cancelControlTrackingRecursively() } - } -} - struct ComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { // Register MPV player bridge before Compose initializes