mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-03 18:16:15 +00:00
Merge pull request #1259 from WhiteGiso/pr/liquid-glass-profile-switch
feat(ios): improve liquid glass profile switching
This commit is contained in:
commit
1441797d3e
14 changed files with 456 additions and 17 deletions
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 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.</string>
|
||||
<string name="settings_appearance_liquid_glass_description">Utilise la barre d’onglets 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue