Merge pull request #1259 from WhiteGiso/pr/liquid-glass-profile-switch

feat(ios): improve liquid glass profile switching
This commit is contained in:
Nayif 2026-06-22 18:15:52 +05:30 committed by GitHub
commit 1441797d3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 456 additions and 17 deletions

View file

@ -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?,

View file

@ -453,7 +453,7 @@
<string name="settings_appearance_app_language_sheet_title">Vyberte jazyk</string>
<string name="settings_appearance_continue_watching_description">Nastavení pro sekci Pokračovat ve sledování.</string>
<string name="settings_appearance_liquid_glass">Tekuté sklo (Liquid Glass)</string>
<string name="settings_appearance_liquid_glass_description">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é.</string>
<string name="settings_appearance_liquid_glass_description">Použít nativní lištu panelů na iPhonu v iOS 26 a novějším.</string>
<string name="settings_appearance_poster_customization_description">Vyladit šířku karty a poloměr rohů.</string>
<string name="settings_appearance_section_display">ZOBRAZENÍ</string>
<string name="settings_appearance_section_home">DOMŮ</string>

View file

@ -530,7 +530,7 @@
<string name="settings_appearance_app_language_sheet_title">Choisir la langue</string>
<string name="settings_appearance_continue_watching_description">Afficher, masquer et ajuster le bandeau Continuer à regarder.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">Utilise la barre donglets native iPhone sur iOS 26 et versions ultérieures. Le changement instantané de profil depuis la barre donglets nest pas disponible quand cette option est activée.</string>
<string name="settings_appearance_liquid_glass_description">Utilise la barre donglets native iPhone sur iOS 26 et versions ultérieures.</string>
<string name="settings_appearance_poster_customization_description">Ajuste la largeur des cartes et le rayon des coins.</string>
<string name="settings_appearance_section_display">AFFICHAGE</string>
<string name="settings_appearance_section_home">ACCUEIL</string>

View file

@ -508,7 +508,7 @@
<string name="settings_appearance_app_language_sheet_title">Pilih Bahasa</string>
<string name="settings_appearance_continue_watching_description">Pengaturan untuk bagian Lanjutkan Menonton.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">Gunakan tab bar iPhone asli di iOS 26 dan yang lebih baru. Pergantian profil instan dari tab bar tidak tersedia saat ini aktif.</string>
<string name="settings_appearance_liquid_glass_description">Gunakan tab bar iPhone asli di iOS 26 dan yang lebih baru.</string>
<string name="settings_appearance_poster_customization_description">Sesuaikan lebar kartu dan radius sudut.</string>
<string name="settings_appearance_section_display">TAMPILAN</string>
<string name="settings_appearance_section_home">BERANDA</string>

View file

@ -1240,7 +1240,7 @@
<string name="settings_licenses_attributions_exoplayer_body">Utilizzato per la riproduzione sulle build Android.</string>
<string name="settings_licenses_attributions_exoplayer_license">Rilasciato sotto la licenza Apache License, Versione 2.0.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">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.</string>
<string name="settings_appearance_liquid_glass_description">Usa la barra dei pannelli nativa dell'iPhone su iOS 26 e versioni successive.</string>
<string name="layout_hide_unreleased">Nascondi contenuti non rilasciati</string>
<string name="layout_hide_unreleased_sub">Nascondi i film e le serie TV che non sono ancora stati rilasciati.</string>
<string name="settings_homescreen_hide_catalog_underline">Nascondi sottolineatura catalogo</string>

View file

@ -492,7 +492,7 @@
<string name="settings_appearance_app_language_sheet_title">Velg språk</string>
<string name="settings_appearance_continue_watching_description">Innstillinger for Fortsett å se-seksjonen.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">Bruk den innebygde iPhone-fanen i iOS 26 og nyere. Umiddelbar profilbytte fra fanelinjen er ikke tilgjengelig mens dette er på.</string>
<string name="settings_appearance_liquid_glass_description">Bruk den innebygde iPhone-fanen i iOS 26 og nyere.</string>
<string name="settings_appearance_poster_customization_description">Juster kortbredde og hjørneradius.</string>
<string name="settings_appearance_section_display">VISNING</string>
<string name="settings_appearance_section_home">HJEM</string>

View file

@ -508,7 +508,7 @@
<string name="settings_appearance_app_language_sheet_title">Wybierz język</string>
<string name="settings_appearance_continue_watching_description">Pokaż, ukryj i stylizuj półkę Kontynuuj oglądanie.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">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.</string>
<string name="settings_appearance_liquid_glass_description">Użyj natywnego paska kart iPhone na iOS 26 i nowszych.</string>
<string name="settings_appearance_poster_customization_description">Dostosuj szerokość i zaokrąglenie rogów kart plakatów.</string>
<string name="settings_appearance_section_display">WYŚWIETLANIE</string>
<string name="settings_appearance_section_home">EKRAN GŁÓWNY</string>

View file

@ -498,7 +498,7 @@
<string name="settings_appearance_app_language_sheet_title">Dil seç</string>
<string name="settings_appearance_continue_watching_description">İzlemeye Devam Et rafını göster, gizle ve stilini ayarla.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">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.</string>
<string name="settings_appearance_liquid_glass_description">iOS 26 ve sonrasında yerel iPhone sekme çubuğunu kullan.</string>
<string name="settings_appearance_poster_customization_description">Uygulama genelindeki poster kartlarının ortak genişliğini ve köşe yuvarlaklığını ayarla.</string>
<string name="settings_appearance_section_display">EKRAN</string>
<string name="settings_appearance_section_home">ANA SAYFA</string>

View file

@ -540,7 +540,7 @@
<string name="settings_appearance_app_language_sheet_title">Choose Language</string>
<string name="settings_appearance_continue_watching_description">Settings for the Continue Watching section.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on.</string>
<string name="settings_appearance_liquid_glass_description">Use the native iPhone tab bar on iOS 26 and later.</string>
<string name="settings_appearance_poster_customization_description">Tune card width and corner radius.</string>
<string name="settings_appearance_section_display">DISPLAY</string>
<string name="settings_appearance_section_home">HOME</string>

View file

@ -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<Unit>(extraBufferCapacity = 1) }
val libraryScrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
val settingsRootActionRequests = remember { MutableSharedFlow<Unit>(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<TabsRoute>() == 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<ContinueWatchingItem?>(null) }
var lastExternalPlayerLaunch by remember { mutableStateOf<PlayerLaunch?>(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<TabsRoute>() == 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),
)
}
}

View file

@ -20,11 +20,17 @@ internal enum class NativeNavigationTab {
internal object NativeTabBridge {
private val _requestedTabs = MutableSharedFlow<NativeNavigationTab>(extraBufferCapacity = 1)
val requestedTabs: SharedFlow<NativeNavigationTab> = _requestedTabs.asSharedFlow()
private val _profileTabLongPresses = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val profileTabLongPresses: SharedFlow<Unit> = _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?,

View file

@ -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<NuvioProfile?>(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,

View file

@ -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?,

View file

@ -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