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 c7c556c5..8e860fa3 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,6 +10,13 @@ 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?,
diff --git a/composeApp/src/commonMain/composeResources/values-cs/strings.xml b/composeApp/src/commonMain/composeResources/values-cs/strings.xml
index 5e186e5f..298527b0 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 54479812..0fc34298 100644
--- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml
@@ -530,7 +530,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 e442e971..35a923d9 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 c7273958..d96e8195 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 253a32cc..ad7cea8c 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 e8091e03..89b367d0 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-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml
index 9d54c44b..1340bde9 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 58cf290a..e86bea0b 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -540,7 +540,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 42fb5aaf..6c95ee56 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -106,6 +106,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
@@ -159,6 +160,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
@@ -733,6 +735,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
@@ -785,6 +788,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) }
@@ -816,6 +823,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) {
@@ -823,16 +852,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)
}
@@ -942,7 +975,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 activePlaybackProfileId = profileState.activeProfile?.profileIndex ?: ProfileRepository.activeProfileId
@@ -1434,9 +1466,16 @@ 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
profileSwitchLoading = true
+ NativeTabBridge.publishTabBarVisible(false)
selectedTab = AppScreenTab.Home
ProfileRepository.selectProfile(profile.profileIndex)
com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex)
@@ -1589,7 +1628,26 @@ 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()
+ .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 aa426d02..2b9d4816 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,6 +89,13 @@ 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?,
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 5e812b28..5e9b4795 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
@@ -140,8 +142,8 @@ fun ProfileSwitcherTab(
if (profile.pinEnabled) {
pinProfile = profile
} else {
- onProfileSelected(profile)
showPopup = false
+ onProfileSelected(profile)
}
}
@@ -336,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 tokens = MaterialTheme.nuvio
+ 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) {
+ 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) { -NuvioTokens.Space.s64.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(tokens.elevation.overlay, tokens.shapes.sheet)
+ .background(
+ tokens.colors.surfaceSheet,
+ tokens.shapes.sheet,
+ )
+ .padding(tokens.spacing.sheetPadding),
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(tokens.spacing.cardPadding),
+ 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 1b72da7c..6a3c7e7f 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,6 +9,10 @@ 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"
@@ -38,6 +42,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?,
diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift
index 14f5664a..c8fb0338 100644
--- a/iosApp/iosApp/ContentView.swift
+++ b/iosApp/iosApp/ContentView.swift
@@ -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
@@ -305,10 +318,13 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
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?
@@ -363,10 +379,14 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
updateTabBarHeight()
}
+ override func viewDidLayoutSubviews() {
+ super.viewDidLayoutSubviews()
+ updateProfileTabTouchOverlayFrame()
+ }
+
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)
+ selectNativeTab(tab)
}
override var childForHomeIndicatorAutoHidden: UIViewController? {
@@ -441,7 +461,7 @@ 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
)
@@ -454,6 +474,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
tabBar.isHidden = true
view.addSubview(tabBar)
+ configureProfileTabTouchOverlay()
let heightConstraint = tabBar.heightAnchor.constraint(equalToConstant: tabBarHeight)
tabBarHeightConstraint = heightConstraint
NSLayoutConstraint.activate([
@@ -488,6 +509,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
private func updateTabBarHeight() {
tabBarHeightConstraint?.constant = tabBarHeight
+ updateProfileTabTouchOverlayFrame()
}
private func syncNativeTabChrome(animated: Bool) {
@@ -499,15 +521,18 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
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 {
@@ -525,9 +550,118 @@ 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 }
+
+ 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
- let selectedTab = NativeTab(rawValue: rawValue) ?? .home
- tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == selectedTab.tag })
+ 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() {
@@ -535,6 +669,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 +701,14 @@ 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()
+ }
+ profileTabTouchOverlay.accessibilityLabel = NativeTab.settings.localizedTitle()
+ }
+
private func nativeTabImage(for tab: NativeTab, selected: Bool, accent: UIColor) -> UIImage {
guard tab == .settings else {
return tab.iconImage
@@ -630,6 +773,12 @@ 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