diff --git a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt index 17108e9fe..114c8c797 100644 --- a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt +++ b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt @@ -16,26 +16,27 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.Image import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -50,20 +51,32 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import com.arflix.tv.ui.components.AppBottomBar import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import android.content.pm.ActivityInfo +import com.arflix.tv.data.repository.AuthRepository +import com.arflix.tv.data.repository.AuthState +import com.arflix.tv.data.repository.LauncherContinueWatchingRepository +import com.arflix.tv.data.repository.LauncherContinueWatchingRequest +import com.arflix.tv.data.repository.MediaRepository +import com.arflix.tv.data.repository.ProfileManager +import com.arflix.tv.data.repository.ProfileRepository +import com.arflix.tv.data.repository.TraktRepository +import com.arflix.tv.data.repository.WatchHistoryRepository +import com.arflix.tv.data.repository.WatchlistRepository +import com.arflix.tv.data.repository.toLauncherContinueWatchingRequest +import com.arflix.tv.navigation.AppNavigation +import com.arflix.tv.navigation.Screen +import com.arflix.tv.ui.components.AppBottomBar +import com.arflix.tv.ui.startup.StartupViewModel +import com.arflix.tv.ui.theme.ArflixTvTheme +import com.arflix.tv.ui.theme.appBackgroundDark import com.arflix.tv.util.DeviceType import com.arflix.tv.util.DEVICE_MODE_OVERRIDE_KEY import com.arflix.tv.util.SKIP_PROFILE_SELECTION_KEY @@ -77,62 +90,35 @@ import com.arflix.tv.util.detectDeviceType import com.arflix.tv.util.deviceHasTouchScreen import com.arflix.tv.util.findActivity import com.arflix.tv.util.settingsDataStore +import com.arflix.tv.worker.TraktSyncWorker import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.stringPreferencesKey -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.delay -import androidx.compose.runtime.CompositionLocalProvider -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.metrics.performance.JankStats import androidx.metrics.performance.PerformanceMetricsState import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Text import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import androidx.work.workDataOf -import com.arflix.tv.data.repository.AuthRepository -import com.arflix.tv.data.repository.AuthState -import com.arflix.tv.data.repository.LauncherContinueWatchingRepository -import com.arflix.tv.data.repository.LauncherContinueWatchingRequest -import com.arflix.tv.data.repository.MediaRepository -import com.arflix.tv.data.repository.ProfileManager -import com.arflix.tv.data.repository.ProfileRepository -import com.arflix.tv.data.repository.TraktRepository -import com.arflix.tv.data.repository.WatchHistoryRepository -import com.arflix.tv.data.repository.WatchlistRepository -import com.arflix.tv.data.repository.toLauncherContinueWatchingRequest -import com.arflix.tv.navigation.AppNavigation -import com.arflix.tv.navigation.Screen -import com.arflix.tv.ui.screens.login.LoginScreen -import com.arflix.tv.ui.startup.StartupViewModel -import com.arflix.tv.ui.theme.ArflixTvTheme -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.arflix.tv.ui.theme.appBackgroundDark -import com.arflix.tv.worker.TraktSyncWorker -import dagger.hilt.android.AndroidEntryPoint import dagger.Lazy +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject -import kotlin.math.PI -import kotlin.math.cos -import kotlin.math.sin private sealed interface ActiveProfileLoadState { data object Loading : ActiveProfileLoadState data class Loaded(val profile: com.arflix.tv.data.model.Profile?) : ActiveProfileLoadState } -/** - * Main Activity - Single activity architecture with Compose Navigation - * Uses Android 12+ Splash Screen API for instant launch feedback - */ @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -160,11 +146,6 @@ class MainActivity : ComponentActivity() { @Inject lateinit var mediaRepository: Lazy - // Prefetch IPTV early so the TV screen opens without a loading stall. - // IptvRepository is @Singleton; touching it at activity start warms the - // in-memory snapshot (and will trigger a disk-cache read + silent - // background refresh) so by the time the user navigates into the TV tab - // everything is already resident. @Inject lateinit var iptvRepository: Lazy @@ -172,7 +153,6 @@ class MainActivity : ComponentActivity() { private var pendingLauncherRequest by mutableStateOf(null) private var pendingInstallPackUrl by mutableStateOf(null) - // StartupViewModel for parallel loading during splash private val startupViewModel: StartupViewModel by viewModels() override fun attachBaseContext(newBase: Context) { @@ -190,14 +170,8 @@ class MainActivity : ComponentActivity() { } override fun onCreate(savedInstanceState: Bundle?) { - // Install splash screen BEFORE super.onCreate() - // Don't use setKeepOnScreenCondition - it causes black screen on some TV devices - // Instead, let the splash dismiss immediately and show our Compose loading screen installSplashScreen() - // Detect device type before super.onCreate(). - // The splash screen's postSplashScreenTheme is Theme.ArflixTV.Mobile (no fullscreen) - // which is correct for phones/tablets. On TV we override to the fullscreen Leanback theme. val initialDeviceType = detectDeviceType(this) if (initialDeviceType == DeviceType.TV) { setTheme(R.style.Theme_ArflixTV) @@ -226,7 +200,6 @@ class MainActivity : ComponentActivity() { startActivity(crashIntent) } - // Initialize Discord RPC Manager com.arflix.tv.ui.screens.details.discord.DiscordRpcManager.init(this) intent?.data?.let { uri -> android.util.Log.d("MainActivity", "Received intent data URI in onCreate: $uri") @@ -236,7 +209,6 @@ class MainActivity : ComponentActivity() { } } - // Set orientation based on device type requestedOrientation = when (initialDeviceType) { DeviceType.TV -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE DeviceType.TABLET -> ActivityInfo.SCREEN_ORIENTATION_FULL_USER @@ -254,7 +226,6 @@ class MainActivity : ComponentActivity() { statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) ) - // Clear any FLAG_FULLSCREEN the Leanback theme may have set @Suppress("DEPRECATION") window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) } @@ -264,7 +235,6 @@ class MainActivity : ComponentActivity() { } setContent { - // Observe device mode override changes live from DataStore val deviceModeOverride by remember { this@MainActivity.settingsDataStore.data.map { it[DEVICE_MODE_OVERRIDE_KEY] } }.collectAsStateWithLifecycle(initialValue = null) @@ -315,12 +285,7 @@ class MainActivity : ComponentActivity() { else -> initialDeviceType } val hasTouchScreen = remember { deviceHasTouchScreen(this@MainActivity) } - // If no touchscreen, force TV mode regardless of override setting - // (prevents tablet/phone UI on devices with only D-pad input) val effectiveDeviceType = if (!hasTouchScreen && deviceType != DeviceType.TV) DeviceType.TV else deviceType - // Wrap the Activity as a ContextWrapper that only overrides getResources() with - // localized resources. Hilt traverses ContextWrapper chains to find the Activity, - // so hiltViewModel() still works correctly. val localizedContext = remember(appLanguage) { val locale = com.arflix.tv.util.appLocale(appLanguage) java.util.Locale.setDefault(locale) @@ -341,8 +306,8 @@ class MainActivity : ComponentActivity() { LocalDeviceType provides effectiveDeviceType, LocalHasTouchScreen provides hasTouchScreen, androidx.compose.ui.platform.LocalLayoutDirection provides - if (isRtl) androidx.compose.ui.unit.LayoutDirection.Rtl - else androidx.compose.ui.unit.LayoutDirection.Ltr + if (isRtl) androidx.compose.ui.unit.LayoutDirection.Rtl + else androidx.compose.ui.unit.LayoutDirection.Ltr ) { ArflixTvTheme( oledBlackBackground = oledBlackBackground, @@ -415,8 +380,6 @@ class MainActivity : ComponentActivity() { override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) if (hasFocus) { - // Re-apply immersive mode only for TV when window regains focus. - // Mobile fullscreen is managed per-screen (e.g. player). val currentDeviceType = detectDeviceType(this) if (currentDeviceType == DeviceType.TV) { WindowInsetsControllerCompat(window, window.decorView).apply { @@ -461,9 +424,6 @@ private fun ComponentActivity.runAfterFirstDraw(block: () -> Unit) { }) } -/** - * Simple ARVIO loading screen - app logo + spinner - */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun ArvioLoadingScreen() { @@ -552,9 +512,6 @@ fun ArvioLoadingScreen() { } } -/** - * Root composable for the ARVIO app - */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun ArflixApp( @@ -592,8 +549,8 @@ fun ArflixApp( } val activeProfile = (activeProfileState as? ActiveProfileLoadState.Loaded)?.profile val startupReady = skipProfileSelection != null && - activeProfileState is ActiveProfileLoadState.Loaded && - authState !is AuthState.Loading + activeProfileState is ActiveProfileLoadState.Loaded && + authState !is AuthState.Loading if (!startupReady || !startupIntroComplete) { ArvioLoadingScreen() @@ -602,12 +559,8 @@ fun ArflixApp( val navController = rememberNavController() val appCoroutineScope = androidx.compose.runtime.rememberCoroutineScope() - var lastAddonsSyncKey by remember { mutableStateOf(null) } LaunchedEffect(authState, activeProfile?.id) { - if (authState is AuthState.NotAuthenticated) { - lastAddonsSyncKey = null - } if (activeProfile != null) { launcherContinueWatchingRepository.refreshForCurrentProfile() } else { @@ -631,15 +584,13 @@ fun ArflixApp( iptvFullscreen = false } } - // Hide bottom bar on player, profile selection, and login screens. - // TV route shows the bottom bar on mobile (touch devices) for easy navigation; - // the fullscreen IPTV player uses BackHandler to return to the guide. + val showBottomBar = isMobile && activeProfile != null && - currentRoute != null && - !iptvFullscreen && - !currentRoute.contains("player") && - !currentRoute.contains("profile") && - !currentRoute.contains("login") + currentRoute != null && + !iptvFullscreen && + !currentRoute.contains("player") && + !currentRoute.contains("profile") && + !currentRoute.contains("login") val isPlayerRoute = iptvFullscreen || currentRoute?.contains("player") == true @@ -660,10 +611,9 @@ fun ArflixApp( } } - Column( + Box( modifier = Modifier .fillMaxSize() - // Background fills edge-to-edge (including behind transparent bars). .background( brush = if (oledBlackBackground) { Brush.linearGradient(colors = listOf(Color.Black, Color.Black)) @@ -677,58 +627,55 @@ fun ArflixApp( ) } ) - // On mobile, push content below the status bar (except player). - // Applied AFTER background so the gradient fills behind the bars. - // statusBarsPadding() reads live WindowInsets, so it automatically - // becomes 0 when the player hides the bars. - .then(if (isMobile && !isPlayerRoute) Modifier.statusBarsPadding() else Modifier) ) { - Box(modifier = Modifier.weight(1f)) { - AppNavigation( - navController = navController, - startDestination = startDestination, - preloadedCategories = preloadedCategories, - preloadedHeroItem = preloadedHeroItem, - preloadedHeroLogoUrl = preloadedHeroLogoUrl, - preloadedLogoCache = preloadedLogoCache, - currentProfile = activeProfile, - isCloudConnected = authState is AuthState.Authenticated, - onSwitchProfile = { - appCoroutineScope.launch { - traktRepository.clearAllProfileCaches() - watchHistoryRepository.clearProfileCaches() - watchlistRepository.clearWatchlistCache() - iptvRepository.invalidateCache() - profileManager.setCurrentProfileId("default") - profileManager.setCurrentProfileName("default") - profileRepository.clearActiveProfile() - } - }, - onTvFullscreenChanged = { fullscreen -> - iptvFullscreen = fullscreen - }, - onExitApp = onExitApp - ) - } + AppNavigation( + navController = navController, + startDestination = startDestination, + preloadedCategories = preloadedCategories, + preloadedHeroItem = preloadedHeroItem, + preloadedHeroLogoUrl = preloadedHeroLogoUrl, + preloadedLogoCache = preloadedLogoCache, + currentProfile = activeProfile, + isCloudConnected = authState is AuthState.Authenticated, + onSwitchProfile = { + appCoroutineScope.launch { + traktRepository.clearAllProfileCaches() + watchHistoryRepository.clearProfileCaches() + watchlistRepository.clearWatchlistCache() + iptvRepository.invalidateCache() + profileManager.setCurrentProfileId("default") + profileManager.setCurrentProfileName("default") + profileRepository.clearActiveProfile() + } + }, + onTvFullscreenChanged = { fullscreen -> + iptvFullscreen = fullscreen + }, + onExitApp = onExitApp + ) if (isMobile && !isPlayerRoute) { - val bottomBarAlpha by androidx.compose.animation.core.animateFloatAsState( + val bottomBarAlpha by animateFloatAsState( targetValue = if (showBottomBar) 1f else 0f, - animationSpec = androidx.compose.animation.core.tween(250), + animationSpec = tween(250), label = "bottom_bar_alpha" ) + AppBottomBar( currentRoute = currentRoute, onNavigate = { route -> if (showBottomBar) { navController.navigate(route) { - popUpTo("home") { inclusive = false } + popUpTo(Screen.Home.route) { saveState = true } launchSingleTop = true + restoreState = true } } }, modifier = Modifier - .fillMaxWidth() + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 14.dp) .graphicsLayer { alpha = bottomBarAlpha } @@ -781,4 +728,4 @@ private fun enqueueFullTraktSync(context: android.content.Context) { ExistingWorkPolicy.REPLACE, request ) -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt index 077c3fbbe..e45da2dd2 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt @@ -1,56 +1,73 @@ package com.arflix.tv.ui.components +import androidx.annotation.StringRes +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.LiveTv import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.unit.dp -import androidx.annotation.StringRes +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Icon -import androidx.tv.material3.Text import com.arflix.tv.R import com.arflix.tv.ui.theme.ArflixTypography -import com.arflix.tv.ui.theme.appBackgroundDark import com.arflix.tv.ui.theme.TextPrimary import com.arflix.tv.ui.theme.TextSecondary import com.arflix.tv.util.LocalDeviceType @@ -113,7 +130,7 @@ internal fun appBottomBarSpec(mode: AppBottomBarMode): AppBottomBarSpec = when ( } data class BottomBarItem( - @StringRes val labelRes: Int, + @param:StringRes val labelRes: Int, val icon: ImageVector, val route: String ) @@ -126,7 +143,6 @@ val bottomBarItems = listOf( BottomBarItem(R.string.settings, Icons.Default.Settings, "settings") ) -@OptIn(ExperimentalTvMaterial3Api::class) @Composable fun AppBottomBar( currentRoute: String?, @@ -134,6 +150,7 @@ fun AppBottomBar( modifier: Modifier = Modifier ) { val configuration = LocalConfiguration.current + val density = LocalDensity.current val mode = appBottomBarMode( isTouchDevice = LocalDeviceType.current.isTouchDevice(), smallestScreenWidthDp = configuration.smallestScreenWidthDp, @@ -142,110 +159,223 @@ fun AppBottomBar( ) val spec = appBottomBarSpec(mode) - Column( + var activeRoute by remember(currentRoute) { mutableStateOf(currentRoute ?: "home") } + + val selectedIndex = remember(activeRoute) { + when { + activeRoute.startsWith("search") -> 1 + activeRoute.startsWith("watchlist") -> 2 + activeRoute.startsWith("tv") -> 3 + activeRoute.startsWith("settings") -> 4 + else -> 0 + } + } + + val itemPositions = remember { mutableStateMapOf() } + val itemWidths = remember { mutableStateMapOf() } + val itemHeights = remember { mutableStateMapOf() } + val indicatorOffset = remember { Animatable(0f) } + val indicatorWidth = remember { Animatable(0f) } + + val fastSpringSpec = remember { + spring(dampingRatio = 0.82f, stiffness = 900f) + } + + var isAnyItemPressed by remember { mutableStateOf(false) } + + val wholeBarScale by animateFloatAsState( + targetValue = if (isAnyItemPressed) 1.05f else 1.0f, + animationSpec = fastSpringSpec, + label = "whole_bar_zoom" + ) + + LaunchedEffect(selectedIndex, itemPositions[selectedIndex], itemWidths[selectedIndex]) { + val targetX = itemPositions[selectedIndex] ?: 0f + val targetW = itemWidths[selectedIndex] ?: 0f + if (targetW > 0f) { + if (indicatorWidth.value == 0f) { + indicatorOffset.snapTo(targetX) + indicatorWidth.snapTo(targetW) + } else { + indicatorOffset.animateTo(targetX, fastSpringSpec) + indicatorWidth.animateTo(targetW, fastSpringSpec) + } + } + } + + Box( modifier = modifier - .fillMaxWidth() - .background(appBackgroundDark().copy(alpha = 0.95f)) - .navigationBarsPadding() + .wrapContentSize() + .graphicsLayer { + scaleX = wholeBarScale + scaleY = wholeBarScale + } + .shadow( + elevation = 20.dp, + shape = CircleShape, + ambientColor = Color.Black.copy(alpha = 0.65f), + spotColor = Color(0xFF00F0D0).copy(alpha = 0.25f) + ) + .clip(CircleShape) + .background( + Brush.verticalGradient( + colors = listOf( + Color(0xFF252836).copy(alpha = 0.65f), + Color(0xFF111319).copy(alpha = 0.78f) + ) + ) + ) + .border( + width = 1.2.dp, + brush = Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = 0.35f), + Color.White.copy(alpha = 0.06f) + ) + ), + shape = CircleShape + ) + .padding(horizontal = 8.dp, vertical = spec.rowVerticalPaddingDp.dp) ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(Color.White.copy(alpha = 0.08f)) - ) + if (indicatorWidth.value > 0f) { + val targetHeightDp = with(density) { + ((itemHeights[selectedIndex] ?: 0f) + 4.dp.toPx()).toDp() + } + val targetWidthDp = with(density) { + ((indicatorWidth.value) + 6.dp.toPx()).toDp() + } + val extraXOffsetPx = with(density) { (-3.dp).toPx() } + val extraYOffsetPx = with(density) { (-1.dp).toPx() } + + if (targetHeightDp > 0.dp) { + Box( + modifier = Modifier + .offset { + IntOffset( + x = (indicatorOffset.value + extraXOffsetPx).toInt(), + y = extraYOffsetPx.toInt() + ) + } + .width(targetWidthDp) + .height(targetHeightDp) + .clip(CircleShape) + .background( + Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.24f), + Color.White.copy(alpha = 0.09f) + ) + ) + ) + .border( + 1.dp, + Brush.verticalGradient( + listOf( + Color.White.copy(alpha = 0.45f), + Color.Transparent + ) + ), + CircleShape + ) + ) + } + } Row( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = spec.rowVerticalPaddingDp.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically + .wrapContentWidth() + .wrapContentHeight(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spec.itemSpacingDp.dp) ) { - bottomBarItems.forEach { item -> - val isSelected = currentRoute?.contains(item.route, ignoreCase = true) == true + bottomBarItems.forEachIndexed { index, item -> + val isSelected = selectedIndex == index var isFocused by remember { mutableStateOf(false) } val label = stringResource(item.labelRes) + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + LaunchedEffect(isPressed) { + if (isPressed) isAnyItemPressed = true + } + + val itemZoomScale by animateFloatAsState( + targetValue = if (isSelected || isFocused) 1.10f else 1.0f, + animationSpec = fastSpringSpec, + label = "bottom_item_zoom" + ) + + val contentColor by animateColorAsState( + targetValue = when { + isFocused -> Color.White + isSelected -> Color(0xFF00F0D0) + else -> Color.White.copy(alpha = 0.60f) + }, + animationSpec = tween(120), + label = "bottom_tint" + ) Column( modifier = Modifier - .weight(1f) - .then( - spec.itemHeightDp?.let { heightDp -> Modifier.heightIn(min = heightDp.dp) } - ?: Modifier - ) - .clip(RoundedCornerShape(8.dp)) - .then( - if (isFocused) Modifier.border(2.dp, Color.White.copy(alpha = 0.7f), RoundedCornerShape(8.dp)) - else Modifier - ) - .background(if (isFocused) Color.White.copy(alpha = 0.1f) else Color.Transparent) + .wrapContentSize() + .onGloballyPositioned { coordinates -> + val x = coordinates.positionInParent().x + val w = coordinates.size.width.toFloat() + val h = coordinates.size.height.toFloat() + itemPositions[index] = x + itemWidths[index] = w + itemHeights[index] = h + } + .clip(CircleShape) .focusable() .onFocusChanged { isFocused = it.isFocused } .onKeyEvent { event -> if (event.type == KeyEventType.KeyDown && (event.key == Key.Enter || event.key == Key.DirectionCenter)) { + activeRoute = item.route onNavigate(item.route) true } else false } - .clickable { onNavigate(item.route) } - .padding(vertical = spec.itemVerticalPaddingDp.dp), + .clickable( + interactionSource = interactionSource, + indication = null + ) { + activeRoute = item.route + onNavigate(item.route) + } + .padding( + horizontal = spec.iconHorizontalPaddingDp.dp, + vertical = spec.itemVerticalPaddingDp.dp + 2.dp + ) + .graphicsLayer { + scaleX = itemZoomScale + scaleY = itemZoomScale + }, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy( space = spec.itemSpacingDp.dp, - alignment = Alignment.CenterVertically, + alignment = Alignment.CenterVertically ) ) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .background( - when { - isFocused -> Color.White.copy(alpha = 0.18f) - isSelected -> Color.White.copy(alpha = 0.12f) - else -> Color.Transparent - } - ) - .padding( - horizontal = spec.iconHorizontalPaddingDp.dp, - vertical = spec.iconVerticalPaddingDp.dp, - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = item.icon, - contentDescription = label, - tint = when { - isFocused -> Color.White - isSelected -> TextPrimary - else -> TextSecondary.copy(alpha = 0.6f) - }, - modifier = Modifier.size(spec.iconSizeDp.dp) - ) - } - if (isSelected) { - Box( - modifier = Modifier - .size(spec.indicatorSizeDp.dp) - .clip(CircleShape) - .background(if (isFocused) Color.White else TextPrimary) - ) - } else { - Spacer(modifier = Modifier.size(spec.indicatorSizeDp.dp)) - } + Icon( + imageVector = item.icon, + contentDescription = label, + tint = contentColor, + modifier = Modifier.size(spec.iconSizeDp.dp) + ) + Text( text = label, - style = ArflixTypography.caption.copy(fontSize = spec.labelFontSizeSp.sp), - color = when { - isFocused -> Color.White - isSelected -> TextPrimary - else -> TextSecondary.copy(alpha = 0.6f) - }, + style = ArflixTypography.caption.copy( + fontSize = spec.labelFontSizeSp.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium + ), + color = contentColor, maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis ) } } } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt index c7a5946c9..010ee2d42 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt @@ -1,10 +1,12 @@ package com.arflix.tv.ui.components import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -15,10 +17,16 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.LiveTv +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -29,23 +37,26 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.data.model.Profile -import com.arflix.tv.ui.skin.ArvioSkin import com.arflix.tv.ui.skin.resolveAccentColor import com.arflix.tv.ui.theme.AnimationConstants import com.arflix.tv.ui.theme.ArflixTypography -import androidx.compose.ui.res.stringResource -import com.arflix.tv.R import com.arflix.tv.util.settingsDataStore import java.text.SimpleDateFormat import java.util.Date @@ -56,36 +67,52 @@ import kotlinx.coroutines.flow.first val AppTopBarHeight = 82.dp val AppTopBarTopPadding = 0.dp val AppTopBarContentTopInset = 98.dp -/** On mobile/tablet where the topbar is hidden, use a small status-bar-like inset instead. */ val MobileContentTopInset = 16.dp val AppTopBarHorizontalPadding = 28.dp -// Navigation items that appear CENTERED in the top bar (Search, Home, Watchlist, TV). -// Settings is NOT in this list — it's rendered as a standalone gear icon on the right. -private val NAV_ITEMS = SidebarItem.entries.filter { it != SidebarItem.SETTINGS } +// Uniform, fixed item dimensions for zero-delay mathematical alignment +private val TvChipWidth = 106.dp +private val TvChipHeight = 36.dp +private val TvChipSpacing = 4.dp + +private val ALL_NAV_ITEMS = listOf( + SidebarItem.SEARCH, + SidebarItem.HOME, + SidebarItem.TV, + SidebarItem.WATCHLIST, + SidebarItem.SETTINGS +) + +private fun sidebarItemIcon(item: SidebarItem): ImageVector = when (item) { + SidebarItem.SEARCH -> Icons.Default.Search + SidebarItem.HOME -> Icons.Default.Home + SidebarItem.TV -> Icons.Default.LiveTv + SidebarItem.WATCHLIST -> Icons.Default.Bookmark + SidebarItem.SETTINGS -> Icons.Default.Settings +} + +private fun sidebarItemLabelRes(item: SidebarItem): Int = when (item) { + SidebarItem.SEARCH -> R.string.search + SidebarItem.HOME -> R.string.home + SidebarItem.TV -> R.string.topbar_tv + SidebarItem.WATCHLIST -> R.string.nav_library + SidebarItem.SETTINGS -> R.string.settings +} fun topBarMaxIndex(hasProfile: Boolean): Int { - // Profile (0 if shown) + nav items + settings gear (last index) - val navCount = NAV_ITEMS.size - return if (hasProfile) navCount + 1 else navCount // +1 for settings gear at the end + return if (hasProfile) ALL_NAV_ITEMS.size else ALL_NAV_ITEMS.size - 1 } fun topBarSelectedIndex(selectedItem: SidebarItem, hasProfile: Boolean): Int { - if (selectedItem == SidebarItem.SETTINGS) { - // Settings is the last focusable item - return topBarMaxIndex(hasProfile) - } - val base = NAV_ITEMS.indexOf(selectedItem) + val base = ALL_NAV_ITEMS.indexOf(selectedItem) if (base < 0) return -1 return if (hasProfile) base + 1 else base } fun topBarFocusedItem(focusedIndex: Int, hasProfile: Boolean): SidebarItem? { - if (hasProfile && focusedIndex == 0) return null // profile avatar focused + if (hasProfile && focusedIndex == 0) return null val itemIndex = if (hasProfile) focusedIndex - 1 else focusedIndex - // If it's the settings gear (last index after nav items) - if (itemIndex == NAV_ITEMS.size) return SidebarItem.SETTINGS - return NAV_ITEMS.getOrNull(itemIndex) + return ALL_NAV_ITEMS.getOrNull(itemIndex) } @OptIn(ExperimentalTvMaterial3Api::class) @@ -101,86 +128,164 @@ fun AppTopBar( hasUpdateBadge: Boolean = false, modifier: Modifier = Modifier ) { - // Always show the profile avatar when a profile exists — it's clickable - // and opens the profile switcher. The name text was removed per the mockup - // (avatar-only, no label). val showProfile = profile != null val hasProfile = showProfile val currentTime = rememberTopBarTime(clockFormat) - val selectedIndex = remember(selectedItem, hasProfile) { topBarSelectedIndex(selectedItem, hasProfile) } - // Settings gear is always the last focusable index - val settingsIndex = topBarMaxIndex(hasProfile) - val settingsFocused = isFocused && focusedIndex == settingsIndex - val settingsSelected = selectedItem == SidebarItem.SETTINGS + val density = LocalDensity.current + + val fastSpringFloat = remember { + spring(dampingRatio = 0.85f, stiffness = 850f) + } + + val barScale by animateFloatAsState( + targetValue = if (isFocused) 1.05f else 1.0f, + animationSpec = fastSpringFloat, + label = "tv_topbar_zoom" + ) + + val activeNavIndex = if (hasProfile) focusedIndex - 1 else focusedIndex + val targetHighlightIndex = if (isFocused) { + if (hasProfile && focusedIndex == 0) -1 else activeNavIndex + } else { + ALL_NAV_ITEMS.indexOf(selectedItem) + } + + val indicatorOffset = remember { Animatable(0f) } + val indicatorAlpha = remember { Animatable(1f) } + + // Dp arithmetic fixed: multiply Dp by Int + val targetXPx = remember(targetHighlightIndex, density) { + if (targetHighlightIndex >= 0) { + with(density) { ((TvChipWidth + TvChipSpacing) * targetHighlightIndex).toPx() } + } else { + 0f + } + } + + LaunchedEffect(targetHighlightIndex, isFocused) { + if (targetHighlightIndex >= 0) { + indicatorAlpha.animateTo(1f, tween(100)) + indicatorOffset.animateTo(targetXPx, fastSpringFloat) + } else { + indicatorAlpha.animateTo(0f, tween(100)) + } + } + + // Always centered container Box( modifier = modifier .fillMaxWidth() - .height(AppTopBarContentTopInset) - .background( - Brush.verticalGradient( - colors = listOf( - Color.Black.copy(alpha = 0.72f), - Color.Black.copy(alpha = 0.36f), - Color.Transparent - ) - ) - ) + .padding(top = 16.dp), + contentAlignment = Alignment.TopCenter ) { - Row( + Box( modifier = Modifier - .fillMaxWidth() - .height(AppTopBarHeight) - .padding(start = AppTopBarHorizontalPadding, end = AppTopBarHorizontalPadding, top = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // ── LEFT: Profile avatar (only if multiple profiles) ── - if (showProfile && profile != null) { - TopBarProfileAvatar( - profile = profile, - isFocused = isFocused && focusedIndex == 0 + .wrapContentSize() + .graphicsLayer { + scaleX = barScale + scaleY = barScale + } + .shadow( + elevation = if (isFocused) 26.dp else 16.dp, + shape = CircleShape, + ambientColor = Color.Black.copy(alpha = 0.75f), + spotColor = Color(0xFF00F0D0).copy(alpha = if (isFocused) 0.35f else 0.12f) ) - Spacer(modifier = Modifier.width(16.dp)) - } - - // ── CENTER: Navigation chips (Search, Home, Watchlist, TV) ── + .clip(CircleShape) + .background( + Brush.verticalGradient( + colors = listOf( + Color(0xFF222530).copy(alpha = if (isFocused) 0.65f else 0.48f), + Color(0xFF101217).copy(alpha = if (isFocused) 0.78f else 0.62f) + ) + ) + ) + .border( + width = if (isFocused) 1.5.dp else 1.dp, + brush = Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = if (isFocused) 0.45f else 0.22f), + Color.White.copy(alpha = 0.05f) + ) + ), + shape = CircleShape + ) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { Row( - modifier = Modifier.weight(1f), + modifier = Modifier.wrapContentSize(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) + if (showProfile) { + TopBarProfileAvatar( + profile = profile, + isFocused = isFocused && focusedIndex == 0 + ) + } + + // Center Navigation Bar with Deterministic Sliding Capsule + Box( + modifier = Modifier.wrapContentSize(), + contentAlignment = Alignment.CenterStart ) { - NAV_ITEMS.forEachIndexed { index, item -> - val itemFocusIndex = if (hasProfile) index + 1 else index - TopBarNavChip( - item = item, - isFocused = isFocused && focusedIndex == itemFocusIndex, - isSelected = selectedIndex == itemFocusIndex + if (targetHighlightIndex >= 0) { + Box( + modifier = Modifier + .offset { IntOffset(x = indicatorOffset.value.toInt(), y = 0) } + .width(TvChipWidth) + .height(TvChipHeight) + .graphicsLayer { alpha = indicatorAlpha.value } + .clip(CircleShape) + .background( + Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = if (isFocused) 0.28f else 0.14f), + Color.White.copy(alpha = 0.06f) + ) + ) + ) + .border( + 1.dp, + Brush.verticalGradient( + listOf( + Color.White.copy(alpha = 0.50f), + Color.White.copy(alpha = 0.10f) + ) + ), + CircleShape + ) ) } - } - } - // ── RIGHT: Settings gear + clock ── - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(14.dp) - ) { - // Settings gear icon (no text label) - TopBarSettingsGear( - isFocused = settingsFocused, - isSelected = settingsSelected, - hasBadge = hasUpdateBadge - ) + Row( + modifier = Modifier.wrapContentSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TvChipSpacing) + ) { + ALL_NAV_ITEMS.forEachIndexed { index, item -> + val itemFocusIndex = if (hasProfile) index + 1 else index + val isItemFocused = isFocused && focusedIndex == itemFocusIndex + val isItemCurrentScreen = selectedItem == item + val showActiveState = isItemFocused || (!isFocused && isItemCurrentScreen) + + TopBarNavChip( + item = item, + isFocused = isItemFocused, + isSelected = showActiveState, + hasBadge = item == SidebarItem.SETTINGS && hasUpdateBadge + ) + } + } + } Text( text = currentTime, - fontSize = 15.sp, - fontWeight = FontWeight.Normal, - color = Color.White.copy(alpha = 0.55f) + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = Color.White.copy(alpha = 0.65f), + modifier = Modifier.padding(start = 4.dp, end = 6.dp) ) } } @@ -192,70 +297,88 @@ fun AppTopBar( private fun TopBarNavChip( item: SidebarItem, isFocused: Boolean, - isSelected: Boolean + isSelected: Boolean, + hasBadge: Boolean = false, + modifier: Modifier = Modifier ) { - val accent = resolveAccentColor(fallback = Color.White) + val accent = resolveAccentColor(fallback = Color(0xFF00F0D0)) - val containerColor by animateColorAsState( + val fastSpringFloat = remember { + spring(dampingRatio = 0.85f, stiffness = 850f) + } + + val zoomScale by animateFloatAsState( targetValue = when { - isFocused -> Color.White.copy(alpha = 0.2f) - isSelected -> Color.White.copy(alpha = 0.1f) - else -> Color.Transparent + isFocused -> 1.08f + isSelected -> 1.02f + else -> 1.0f }, - animationSpec = tween(AnimationConstants.DURATION_FAST), - label = "topbar_chip_bg" + animationSpec = fastSpringFloat, + label = "tv_chip_zoom" ) + val iconColor by animateColorAsState( targetValue = when { - isFocused -> Color.White // focused icon stays white (wins over selected) - isSelected -> accent // selected icon gets accent - else -> Color.White.copy(alpha = 0.62f) + isFocused -> Color.White + isSelected -> accent + else -> Color.White.copy(alpha = 0.65f) }, - animationSpec = tween(AnimationConstants.DURATION_FAST), + animationSpec = tween(120), label = "topbar_icon_color" ) + val textColor by animateColorAsState( targetValue = when { - isFocused -> Color.White // focused text stays white (wins over selected) - isSelected -> accent // selected text gets accent - else -> Color.White.copy(alpha = 0.68f) + isFocused -> Color.White + isSelected -> accent + else -> Color.White.copy(alpha = 0.70f) }, - animationSpec = tween(AnimationConstants.DURATION_FAST), + animationSpec = tween(120), label = "topbar_text_color" ) - val scale by animateFloatAsState( - targetValue = if (isFocused) 1.05f else 1f, - animationSpec = spring(dampingRatio = 0.75f, stiffness = 400f), - label = "topbar_scale" - ) - val label = if (item == SidebarItem.TV) { - stringResource(R.string.topbar_tv) - } else { - stringResource(item.labelRes) - } + + val label = stringResource(sidebarItemLabelRes(item)) + val icon = sidebarItemIcon(item) Row( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .background(containerColor) + modifier = modifier + .width(TvChipWidth) + .height(TvChipHeight) + .clip(CircleShape) .graphicsLayer { - scaleX = scale - scaleY = scale + scaleX = zoomScale + scaleY = zoomScale } - .padding(horizontal = 14.dp, vertical = 9.dp), + .padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.Center ) { - Icon( - imageVector = item.icon, - contentDescription = label, - tint = iconColor, - modifier = Modifier.size(18.dp) - ) + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = label, + tint = iconColor, + modifier = Modifier.size(17.dp) + ) + + if (hasBadge) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 2.dp, y = (-2).dp) + .size(6.dp) + .clip(CircleShape) + .background(com.arflix.tv.ui.theme.AccentRed) + ) + } + } + + Spacer(modifier = Modifier.width(6.dp)) + Text( text = label, - fontSize = 14.sp, - fontWeight = if (isFocused || isSelected) FontWeight.SemiBold else FontWeight.Medium, + fontSize = 12.5.sp, + fontWeight = if (isFocused) FontWeight.Bold else FontWeight.SemiBold, color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -263,98 +386,30 @@ private fun TopBarNavChip( } } -/** - * Settings gear icon — no text label, just the icon. Placed on the far right - * of the top bar per the mockup. Receives focus/selection state for D-pad nav. - */ -@Composable -private fun TopBarSettingsGear( - isFocused: Boolean, - isSelected: Boolean, - hasBadge: Boolean = false -) { - val accent = resolveAccentColor(fallback = Color.White) - - val iconColor by animateColorAsState( - targetValue = when { - isFocused -> Color.White // focused stays white (wins over selected) - isSelected -> accent // selected settings gear gets accent - else -> Color.White.copy(alpha = 0.5f) - }, - animationSpec = tween(AnimationConstants.DURATION_FAST), - label = "topbar_settings_color" - ) - val containerColor by animateColorAsState( - targetValue = when { - isFocused -> Color.White.copy(alpha = 0.2f) - isSelected -> Color.White.copy(alpha = 0.1f) - else -> Color.Transparent - }, - animationSpec = tween(AnimationConstants.DURATION_FAST), - label = "topbar_settings_bg" - ) - val scale by animateFloatAsState( - targetValue = if (isFocused) 1.08f else 1f, - animationSpec = spring(dampingRatio = 0.75f, stiffness = 400f), - label = "topbar_settings_scale" - ) - - Box( - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(containerColor) - .graphicsLayer { - scaleX = scale - scaleY = scale - }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = stringResource(R.string.settings), - tint = iconColor, - modifier = Modifier.size(20.dp) - ) - - // Update Badge - if (hasBadge) { - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = 2.dp, y = (-2).dp) - .size(8.dp) - .clip(CircleShape) - .background(com.arflix.tv.ui.theme.AccentRed) - ) - } - } -} - -/** - * Profile avatar only — no name text. Just the circular avatar with gradient/icon. - * Shown only when multiple profiles exist. - */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun TopBarProfileAvatar( profile: Profile, isFocused: Boolean ) { + val fastSpringFloat = remember { + spring(dampingRatio = 0.85f, stiffness = 850f) + } + val containerColor by animateColorAsState( - targetValue = if (isFocused) Color.White.copy(alpha = 0.2f) else Color.Transparent, - animationSpec = tween(AnimationConstants.DURATION_FAST), + targetValue = if (isFocused) Color.White.copy(alpha = 0.28f) else Color.Transparent, + animationSpec = tween(120), label = "topbar_profile_bg" ) val scale by animateFloatAsState( - targetValue = if (isFocused) 1.08f else 1f, - animationSpec = spring(dampingRatio = 0.75f, stiffness = 400f), + targetValue = if (isFocused) 1.18f else 1.0f, + animationSpec = fastSpringFloat, label = "topbar_profile_scale" ) Box( modifier = Modifier - .size(40.dp) + .size(36.dp) .clip(CircleShape) .background(containerColor) .graphicsLayer { @@ -365,15 +420,15 @@ private fun TopBarProfileAvatar( ) { Box( modifier = Modifier - .size(34.dp) + .size(30.dp) .clip(CircleShape) .background(Color.Transparent), contentAlignment = Alignment.Center ) { ProfileAvatarVisual( profile = profile, - letterFontSize = 13.sp, - iconPadding = 4.dp + letterFontSize = 12.sp, + iconPadding = 3.dp ) } } @@ -385,8 +440,6 @@ private fun rememberTopBarTime(clockFormat: String): String { var resolvedFormat by remember(clockFormat) { mutableStateOf(clockFormat) } var currentTime by remember(resolvedFormat) { mutableStateOf(topBarCurrentTime(resolvedFormat)) } - // AppTopBar is used on multiple screens that don't all have SettingsUiState. - // Read the persisted clock format directly so the clock updates app-wide. LaunchedEffect(context, clockFormat) { runCatching { val prefs = context.settingsDataStore.data.first() @@ -415,4 +468,4 @@ private fun topBarCurrentTime(clockFormat: String): String { } val sdf = SimpleDateFormat(pattern, Locale.getDefault()) return sdf.format(Date()) -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index 8ec5d5fab..0c629ec8c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition -import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.background @@ -207,8 +206,6 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.ui.res.stringResource - - private object HomeRegexes { val HTML_TAG = Regex("<[^>]*>") val NON_BREAKING_SPACE = Regex("[\u00A0\u2007\u202F]") @@ -226,7 +223,6 @@ private fun cleanOverviewText(value: String): String { .ifBlank { "No description available." } } -// Genre ID to name mapping (TMDB standard) private val movieGenres = mapOf( 28 to "Action", 12 to "Adventure", 16 to "Animation", 35 to "Comedy", 80 to "Crime", 99 to "Documentary", 18 to "Drama", 10751 to "Family", @@ -255,22 +251,10 @@ private class HomeFocusState( var currentItemIndex by mutableIntStateOf(initialItemIndex) var lastNavEventTime by mutableLongStateOf(0L) var userHasNavigated by mutableStateOf(false) - // Per-row item indices — when pressing D-pad Down, we save the current item - // index for the current row so pressing Up later returns to the same position. - // Netflix preserves horizontal scroll position across rows; without this, - // every Down press resets to item 0 which is jarring. - // Catalog IDs remain stable while background loads insert or reorder rows. val rowItemIndicesByCategoryId = mutableMapOf() - // Keep the exact focused title as well as its index. Catalog refreshes can - // insert or reorder items, so the numeric index alone is not a stable anchor. val rowItemKeysByCategoryId = mutableMapOf() companion object { - // `userHasNavigated` is saved as the 4th element (0/1). Without it, - // returning from a Collection/Details screen caused the "preferred - // start row" reset to fire (since the field defaulted back to false), - // which snapped the scroll position to Trending Movies — losing the - // user's place in Franchises or wherever they were. val Saver: androidx.compose.runtime.saveable.Saver> = androidx.compose.runtime.saveable.Saver( save = { @@ -389,8 +373,6 @@ internal fun resolveHomeItemIndex( val safeFallback = fallbackIndex.coerceAtLeast(0) if (itemKeys.isEmpty() || safeFallback <= itemKeys.lastIndex) return safeFallback - // A paged row can temporarily contain fewer items while it is refreshing. - // Preserve the intended index until the page arrives instead of snapping left. return if (hasMore) safeFallback else itemKeys.lastIndex } @@ -487,8 +469,6 @@ private suspend fun androidx.compose.foundation.lazy.LazyListState.animateHomeSc } } - - @Composable private fun HomeBackdropCrossfade( backdropUrl: String?, @@ -510,20 +490,17 @@ private fun HomeBackdropCrossfade( pendingBackdropReady = false pendingAlpha.snapTo(0f) } - displayedBackdropUrl == null -> { displayedBackdropUrl = backdropUrl pendingBackdropUrl = null pendingBackdropReady = false pendingAlpha.snapTo(0f) } - displayedBackdropUrl == backdropUrl -> { pendingBackdropUrl = null pendingBackdropReady = false pendingAlpha.snapTo(0f) } - else -> { pendingBackdropUrl = backdropUrl pendingBackdropReady = false @@ -548,15 +525,15 @@ private fun HomeBackdropCrossfade( fun buildBackdropRequest(url: String): ImageRequest = "$url|${backdropWidthPx}x$backdropHeightPx".let { cacheKey -> - ImageRequest.Builder(context) - .data(url) - .size(backdropWidthPx, backdropHeightPx) - .precision(Precision.INEXACT) - .allowHardware(true) - .memoryCacheKey(cacheKey) - .placeholderMemoryCacheKey(cacheKey) - .crossfade(false) - .build() + ImageRequest.Builder(context) + .data(url) + .size(backdropWidthPx, backdropHeightPx) + .precision(Precision.INEXACT) + .allowHardware(true) + .memoryCacheKey(cacheKey) + .placeholderMemoryCacheKey(cacheKey) + .crossfade(false) + .build() } Box(modifier = modifier) { @@ -589,12 +566,6 @@ private fun HomeBackdropCrossfade( } } -/** - * Home screen matching webapp design exactly: - * - Large hero with logo image - * - Single visible content row with large cards - * - Slim sidebar on left - */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun HomeScreen( @@ -616,7 +587,6 @@ fun HomeScreen( ) { val isMobile = LocalDeviceType.current.isTouchDevice() - // Use preloaded data from StartupViewModel if available LaunchedEffect(preloadedCategories, preloadedHeroItem, preloadedHeroLogoUrl, preloadedLogoCache) { if (preloadedCategories.isNotEmpty()) { viewModel.setPreloadedData( @@ -627,12 +597,8 @@ fun HomeScreen( ) } } - // Lifecycle-aware: stops collecting when HomeScreen is off-screen so the - // ViewModel's TMDB/Trakt refresh pushes don't drive recompositions behind - // an invisible UI. + val uiState by viewModel.uiState.collectAsStateWithLifecycle() - // Per-card logo reads now come from a stable snapshotStateMap so a single - // logo arriving no longer recomposes the full home surface. val cardLogoUrls = viewModel.cardLogoUrls val cardImdbRatings = viewModel.cardImdbRatings val profileCount = if (currentProfile != null) 1 else 0 @@ -658,21 +624,13 @@ fun HomeScreen( } LaunchedEffect(Unit) { - // Prevent stale select key events from previous screen from reopening details. suppressSelectUntilMs = SystemClock.elapsedRealtime() + 150L } DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { - // Keep the profile-to-Home transition local-first. A forced remote - // refresh here cancelled the cache fast path on every app launch. viewModel.refreshContinueWatchingOnly(force = false) - // Pull the full cloud state (addons, catalogs, settings) on resume. - // This catches any changes pushed by another device while this one - // was backgrounded — the WebSocket may have been killed by Android, - // so we can't rely on realtime alone. Throttled internally to avoid - // redundant pulls on rapid activity transitions. viewModel.pullCloudStateOnResume() suppressSelectUntilMs = SystemClock.elapsedRealtime() + 150L } @@ -696,14 +654,11 @@ fun HomeScreen( deduplicateHomeCategories(rawDisplayCategories) } val displayHeroItem = uiState.heroItem ?: preloadedHeroItem - ?: if (uiState.categories.isEmpty()) { - // Only fall through to first-row hero while the ViewModel is still - // publishing its initial categories. Once categories are populated, - // the hero-update LaunchedEffect drives heroItem from focused cards. - displayCategories.firstOrNull()?.items?.firstOrNull() - } else { - null - } + ?: if (uiState.categories.isEmpty()) { + displayCategories.firstOrNull()?.items?.firstOrNull() + } else { + null + } val displayHeroLogo = uiState.heroLogoUrl ?: preloadedHeroLogoUrl val displayHeroOverview = uiState.heroOverviewOverride val latestDisplayCategories by rememberUpdatedState(displayCategories) @@ -735,7 +690,6 @@ fun HomeScreen( } val contentStartPadding = if (isMobile) 16.dp else 36.dp - // Use rememberSaveable to persist focus position across navigation (back from details page) val focusState = rememberSaveable(saver = HomeFocusState.Saver) { HomeFocusState() } val fastScrollThresholdMs = 650L val heroVideoIdleThresholdMs = 6_000L @@ -780,7 +734,6 @@ fun HomeScreen( } } - // Context menu state (Menu button only, no long-press) var showContextMenu by remember { mutableStateOf(false) } var contextMenuItem by remember { mutableStateOf(null) } var contextMenuIsContinueWatching by remember { mutableStateOf(false) } @@ -792,7 +745,6 @@ fun HomeScreen( contextMenuIsContinueWatching = false } - // Preload artwork for the focused row and the next rows soon after DPAD settles. LaunchedEffect(allowHomeBackgroundWork) { if (!allowHomeBackgroundWork) return@LaunchedEffect val rowPreloadIdleMs = 320L @@ -822,7 +774,6 @@ fun HomeScreen( } } - // Update hero based on focused item with adaptive idle delay to avoid heavy churn while scrolling LaunchedEffect(allowHomeBackgroundWork) { if (!allowHomeBackgroundWork) return@LaunchedEffect snapshotFlow { @@ -833,13 +784,7 @@ fun HomeScreen( HomeFocusedHeroSnapshot( rowIndex = focusState.currentRowIndex, itemIndex = focusState.currentItemIndex, - // Include the exact focused item key so async row updates, especially - // Continue Watching reloads, cannot leave the hero bound to an old - // first-row fallback while the visual focus is on another card. focusedItemKey = focusedItem?.let { homeRowItemKey(it) }.orEmpty(), - // Also include the current hero key. Background home/CW refreshes can - // republish the initial row-0 hero without changing focus indices; this - // forces the watcher to restore the actually focused card. heroItemKey = latestDisplayHeroItem?.let { homeRowItemKey(it) }.orEmpty() ) } @@ -879,7 +824,6 @@ fun HomeScreen( } } - // Infinite row pagination: keep initial Home fast, then append as user reaches row end. LaunchedEffect(allowHomeBackgroundWork) { if (!allowHomeBackgroundWork) return@LaunchedEffect snapshotFlow { @@ -910,13 +854,8 @@ fun HomeScreen( } } - // ── IPTV + service-collection hero player state ── val isHeroIptv = displayHeroItem != null && viewModel.isIptvItem(displayHeroItem) val isHeroCollection = displayHeroItem != null && viewModel.isCollectionItem(displayHeroItem) - // Track service-collection "played once" — after the video ends we stop - // re-spawning the player until the user focuses a *different* service. - // Keyed on the focused collection id so re-entering the card after - // moving elsewhere replays it. var collectionVideoFinishedId by remember { mutableStateOf(null) } val heroVideoAllowed = true val serviceHeroVideoUrl = displayHeroItem @@ -925,8 +864,6 @@ fun HomeScreen( val heroVideoUrl = when { !isMobile && !focusState.userHasNavigated -> null !heroVideoAllowed -> null - // Service collection MP4s should start as soon as the card becomes the hero. - // Keep the idle gate for heavier IPTV/live playback, but do not delay MP4 previews. serviceHeroVideoUrl != null -> serviceHeroVideoUrl suppressHeroVideoPlayback -> null isHeroIptv -> displayHeroItem?.let { viewModel.getIptvStreamUrl(it.id) } @@ -958,9 +895,6 @@ fun HomeScreen( } } - // Service-collection video lifecycle: play once on focus, with sound, - // then mark the card "played" so subsequent focus returns fall back to - // the stock image. IPTV live streams bypass this (they loop naturally). val heroVideoFadeDurationMs = if (isMobile) 420 else 0 val focusedCollectionId = displayHeroItem?.id?.takeIf { isHeroCollection } val latestFocusedCollectionId by rememberUpdatedState(focusedCollectionId) @@ -1008,9 +942,6 @@ fun HomeScreen( player?.prepare() preparedHeroVideoUrl = heroVideoUrl } - // Service videos play once with sound and stop; IPTV live streams - // naturally don't loop (they're live) so REPEAT_MODE_OFF is safe - // for both paths. player?.repeatMode = androidx.media3.common.Player.REPEAT_MODE_OFF player?.volume = 1f player?.playWhenReady = true @@ -1023,7 +954,7 @@ fun HomeScreen( modifier = Modifier .fillMaxSize() .background(appBackgroundDark()) - ) { + ) { val currentBackdrop = displayHeroItem?.let { item -> if (viewModel.isCollectionItem(item)) { viewModel.getCollectionHeroImageUrl(item) ?: item.image @@ -1053,8 +984,7 @@ fun HomeScreen( settledBackdrop = currentBackdrop } } - // On mobile, the hero backdrop is rendered inline inside MobileHomeRowsLayer — skip the fixed backdrop. - // On TV, fill the entire screen with the backdrop. + if (!isMobile) { val backdropModifier = Modifier.fillMaxSize() Box(modifier = backdropModifier) { @@ -1104,7 +1034,6 @@ fun HomeScreen( ) } - // YouTube trailer auto-play — on TV, trailer plays inside the focused card instead if ((isMobile || !uiState.trailerInCards) && heroVideoUrl == null && uiState.trailerAutoPlay && uiState.heroTrailerKey != null && !trailerSuppressed && !heroRowIsContinueWatching) { TrailerPlayer( youtubeKey = uiState.heroTrailerKey!!, @@ -1115,7 +1044,6 @@ fun HomeScreen( ) } - // === SCRIM SYSTEM === Box( modifier = Modifier .fillMaxSize() @@ -1175,107 +1103,106 @@ fun HomeScreen( } ) } - } // end if (!isMobile) backdrop + } Box(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = trailerOverlayAlpha.value }) { - HomeInputLayer( - categories = displayCategories, - cardLogoUrls = cardLogoUrls, - cardImdbRatings = cardImdbRatings, - onPreloadHeroImdbRatings = viewModel::preloadImdbRatingsForHeroItems, - focusState = focusState, - limitRowsDuringStartup = limitRowsDuringStartup, - suppressSelectUntilMs = suppressSelectUntilMs, - contentStartPadding = contentStartPadding, - fastScrollThresholdMs = fastScrollThresholdMs, - usePosterCards = usePosterCards, - isContextMenuOpen = showContextMenu, - trailerIsPlaying = isTrailerPlaying, - onTrailerStop = { trailerSuppressed = true }, - isMobile = isMobile, - heroItem = displayHeroItem, - heroOverviewOverride = displayHeroOverview, - onPlay = { - displayHeroItem?.let { item -> - if (viewModel.isSportsHomeItem(item)) { - openSportsHomeItem(item) - } else if (viewModel.isIptvItem(item)) { - onNavigateToTv(viewModel.getIptvChannelId(item), viewModel.getIptvStreamUrl(item.id)) - } else if (viewModel.isCollectionItem(item)) { - onNavigateToCollection(item.status?.removePrefix("collection:").orEmpty()) - } else { - navigateToDetailsWithCache(item.mediaType, item.id, item.nextEpisode?.seasonNumber, item.nextEpisode?.episodeNumber) + HomeInputLayer( + categories = displayCategories, + cardLogoUrls = cardLogoUrls, + cardImdbRatings = cardImdbRatings, + onPreloadHeroImdbRatings = viewModel::preloadImdbRatingsForHeroItems, + focusState = focusState, + limitRowsDuringStartup = limitRowsDuringStartup, + suppressSelectUntilMs = suppressSelectUntilMs, + contentStartPadding = contentStartPadding, + fastScrollThresholdMs = fastScrollThresholdMs, + usePosterCards = usePosterCards, + isContextMenuOpen = showContextMenu, + trailerIsPlaying = isTrailerPlaying, + onTrailerStop = { trailerSuppressed = true }, + isMobile = isMobile, + heroItem = displayHeroItem, + heroOverviewOverride = displayHeroOverview, + onPlay = { + displayHeroItem?.let { item -> + if (viewModel.isSportsHomeItem(item)) { + openSportsHomeItem(item) + } else if (viewModel.isIptvItem(item)) { + onNavigateToTv(viewModel.getIptvChannelId(item), viewModel.getIptvStreamUrl(item.id)) + } else if (viewModel.isCollectionItem(item)) { + onNavigateToCollection(item.status?.removePrefix("collection:").orEmpty()) + } else { + navigateToDetailsWithCache(item.mediaType, item.id, item.nextEpisode?.seasonNumber, item.nextEpisode?.episodeNumber) + } } - } - }, - onDetails = { - displayHeroItem?.let { item -> - if (viewModel.isSportsHomeItem(item)) { - openSportsHomeItem(item) - } else if (viewModel.isIptvItem(item)) { - onNavigateToTv(viewModel.getIptvChannelId(item), viewModel.getIptvStreamUrl(item.id)) - } else if (viewModel.isCollectionItem(item)) { - onNavigateToCollection(item.status?.removePrefix("collection:").orEmpty()) - } else { - navigateToDetailsWithCache(item.mediaType, item.id, null, null) + }, + onDetails = { + displayHeroItem?.let { item -> + if (viewModel.isSportsHomeItem(item)) { + openSportsHomeItem(item) + } else if (viewModel.isIptvItem(item)) { + onNavigateToTv(viewModel.getIptvChannelId(item), viewModel.getIptvStreamUrl(item.id)) + } else if (viewModel.isCollectionItem(item)) { + onNavigateToCollection(item.status?.removePrefix("collection:").orEmpty()) + } else { + navigateToDetailsWithCache(item.mediaType, item.id, null, null) + } } + }, + currentProfile = currentProfile, + profileCount = profileCount, + clockFormat = uiState.clockFormat, + syncStatus = uiState.syncStatus, + hasUpdateBadge = uiState.hasUpdateBadge, + categoryHasMoreMap = uiState.categoryHasMoreMap, + smoothScrolling = uiState.smoothScrolling, + isSlowLoading = uiState.isMobileSlowLoading, + onRetry = { viewModel.retryMobileHomeLoading() }, + onLoadMoreCategory = { viewModel.loadNextPageForCategory(it) }, + onItemFocusedPrefetch = {}, + onMobileCategoryVisiblePosition = { categoryId, lastVisibleItemIndex -> + viewModel.onMobileCategoryVisiblePosition(categoryId, lastVisibleItemIndex) + }, + onNavigateToDetails = navigateToDetailsWithCache, + onNavigateToCollection = onNavigateToCollection, + onNavigateToSearch = onNavigateToSearch, + onNavigateToWatchlist = onNavigateToWatchlist, + onNavigateToTv = onNavigateToTv, + getIptvStreamUrl = { itemId -> viewModel.getIptvStreamUrl(itemId) }, + isSportsHomeItem = { item -> viewModel.isSportsHomeItem(item) }, + onSportsHomeItemClick = openSportsHomeItem, + onNavigateToSettings = onNavigateToSettings, + onSwitchProfile = onSwitchProfile, + onExitApp = onExitApp, + featuredTrailerKey = if (!isMobile && uiState.trailerInCards && uiState.trailerAutoPlay && !trailerSuppressed && !heroRowIsContinueWatching) uiState.heroTrailerKey else null, + featuredTrailerDelayMs = uiState.trailerDelaySeconds * 1000L, + featuredTrailerVolume = if (uiState.trailerSoundEnabled) 1f else 0f, + onOpenContextMenu = { item, isContinue -> + contextMenuItem = item + contextMenuIsContinueWatching = isContinue + showContextMenu = true } - }, - currentProfile = currentProfile, - profileCount = profileCount, - clockFormat = uiState.clockFormat, - syncStatus = uiState.syncStatus, - hasUpdateBadge = uiState.hasUpdateBadge, - categoryHasMoreMap = uiState.categoryHasMoreMap, - smoothScrolling = uiState.smoothScrolling, - isSlowLoading = uiState.isMobileSlowLoading, - onRetry = { viewModel.retryMobileHomeLoading() }, - onLoadMoreCategory = { viewModel.loadNextPageForCategory(it) }, - onItemFocusedPrefetch = {}, - onMobileCategoryVisiblePosition = { categoryId, lastVisibleItemIndex -> - viewModel.onMobileCategoryVisiblePosition(categoryId, lastVisibleItemIndex) - }, - onNavigateToDetails = navigateToDetailsWithCache, - onNavigateToCollection = onNavigateToCollection, - onNavigateToSearch = onNavigateToSearch, - onNavigateToWatchlist = onNavigateToWatchlist, - onNavigateToTv = onNavigateToTv, - getIptvStreamUrl = { itemId -> viewModel.getIptvStreamUrl(itemId) }, - isSportsHomeItem = { item -> viewModel.isSportsHomeItem(item) }, - onSportsHomeItemClick = openSportsHomeItem, - onNavigateToSettings = onNavigateToSettings, - onSwitchProfile = onSwitchProfile, - onExitApp = onExitApp, - featuredTrailerKey = if (!isMobile && uiState.trailerInCards && uiState.trailerAutoPlay && !trailerSuppressed && !heroRowIsContinueWatching) uiState.heroTrailerKey else null, - featuredTrailerDelayMs = uiState.trailerDelaySeconds * 1000L, - featuredTrailerVolume = if (uiState.trailerSoundEnabled) 1f else 0f, - onOpenContextMenu = { item, isContinue -> - contextMenuItem = item - contextMenuIsContinueWatching = isContinue - showContextMenu = true - } - ) - } // end trailer-dim wrapper + ) + } if (showCinematicHomeLayer) { Box(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = trailerOverlayAlpha.value }) { - HomeHeroLayer( - heroItem = displayHeroItem, - heroLogoUrl = displayHeroLogo, - heroOverviewOverride = displayHeroOverview, - contentStartPadding = contentStartPadding, - isMobile = isMobile, - showBudget = uiState.showBudget, - onNavigateToDetails = navigateToDetailsWithCache, - onNavigateToTv = { channelId, streamUrl -> onNavigateToTv(channelId, streamUrl) }, - isIptvItem = { item -> viewModel.isIptvItem(item) }, - getIptvChannelId = { item -> viewModel.getIptvChannelId(item) }, - getIptvStreamUrl = { itemId -> viewModel.getIptvStreamUrl(itemId) } - ) - } // end trailer-dim wrapper + HomeHeroLayer( + heroItem = displayHeroItem, + heroLogoUrl = displayHeroLogo, + heroOverviewOverride = displayHeroOverview, + contentStartPadding = contentStartPadding, + isMobile = isMobile, + showBudget = uiState.showBudget, + onNavigateToDetails = navigateToDetailsWithCache, + onNavigateToTv = { channelId, streamUrl -> onNavigateToTv(channelId, streamUrl) }, + isIptvItem = { item -> viewModel.isIptvItem(item) }, + getIptvChannelId = { item -> viewModel.getIptvChannelId(item) }, + getIptvStreamUrl = { itemId -> viewModel.getIptvStreamUrl(itemId) } + ) + } } - // Error state - show message when loading failed and no content if (!uiState.isLoading && displayCategories.isEmpty() && uiState.error != null) { Box( modifier = Modifier @@ -1308,7 +1235,6 @@ fun HomeScreen( } } - // Context menu contextMenuItem?.let { item -> Box( modifier = Modifier @@ -1357,8 +1283,6 @@ fun HomeScreen( } } - - // Toast notification uiState.toastMessage?.let { message -> Toast( message = message, @@ -1372,7 +1296,6 @@ fun HomeScreen( ) } - // App Update Modal if (uiState.showAppUpdateDialog) { com.arflix.tv.ui.components.AppUpdateModal( status = uiState.updateStatus, @@ -1392,10 +1315,6 @@ private fun HeroSection( item: MediaItem, logoUrl: String?, overviewOverride: String? = null, - // Hide the Budget line on the hero metadata row when false. Plumbed from - // HomeUiState.showBudget, which is loaded from the per-profile - // `show_budget_on_home` DataStore key and defaults to true so existing - // users see no behavior change. Issue #72. showBudget: Boolean = true, modifier: Modifier = Modifier ) { @@ -1408,20 +1327,11 @@ private fun HeroSection( widthPx.coerceAtLeast(1) to heightPx.coerceAtLeast(1) } - // === PREMIUM LAYERED TEXT SHADOWS === - // Multiple shadows create depth and ensure readability on any background val textShadowPrimary = Shadow( color = Color.Black.copy(alpha = 0.9f), offset = Offset(0f, 2f), - blurRadius = 8f // Soft spread shadow - ) - val textShadowSecondary = Shadow( - color = Color.Black.copy(alpha = 0.7f), - offset = Offset(1f, 3f), - blurRadius = 4f // Medium shadow + blurRadius = 8f ) - // Use primary shadow for text (Compose only supports one shadow per text) - // But the frosted pill provides additional protection val textShadow = textShadowPrimary val heroTextWidth = 360.dp @@ -1429,7 +1339,6 @@ private fun HeroSection( modifier = modifier, verticalArrangement = Arrangement.Bottom ) { - // Performance: Instant logo transition, no animation overhead key(logoUrl, item.id) { val currentLogoUrl = logoUrl val currentItem = item @@ -1472,7 +1381,6 @@ private fun HeroSection( .width(320.dp) ) } else { - // Fallback to title text Text( text = currentItem.title.uppercase(), style = ArflixTypography.heroTitle.copy( @@ -1506,15 +1414,13 @@ private fun HeroSection( } } - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(4.dp)) - // Performance: Use key instead of AnimatedContent for faster transitions key(item.id) { val currentItem = item val isIptvHero = currentItem.status?.startsWith("iptv:") == true Column { if (isIptvHero) { - // IPTV hero: LIVE badge + channel group Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically @@ -1546,7 +1452,6 @@ private fun HeroSection( } } } else { - // Get actual genre names from genre IDs (memoized to avoid list allocations per recomposition) val genreText = remember(currentItem.id, currentItem.genreIds) { val genreMap = if (currentItem.mediaType == MediaType.TV) tvGenres else movieGenres currentItem.genreIds.mapNotNull { genreMap[it] }.take(2).joinToString(" / ") @@ -1568,8 +1473,8 @@ private fun HeroSection( val hasRatingMetadata = ratingValue > 0f val hasBudgetMetadata = showBudget && !budgetText.isNullOrBlank() val hasSecondaryMetadata = primaryNetworkLogo != null || - hasRatingMetadata || - hasBudgetMetadata + hasRatingMetadata || + hasBudgetMetadata Column( modifier = Modifier.width(heroTextWidth), @@ -1722,7 +1627,6 @@ private fun HeroSection( Spacer(modifier = Modifier.height(6.dp)) - // Overview text (EPG data for IPTV, synopsis for movies/shows) val displayOverview = remember(overviewOverride, currentItem.overview) { cleanOverviewText(overviewOverride ?: currentItem.overview) } @@ -1793,8 +1697,6 @@ private fun TopRankRibbon( val width = if (compact) 30.dp else 38.dp val context = LocalContext.current val density = LocalDensity.current - // Decode only the pixels we'll actually draw — the source PNGs are 3334×3334 but - // the ribbon is displayed at 30–38dp. Full-size decode was ~44 MB per card × 10 cards. val targetPx = remember(compact, density) { with(density) { (if (compact) 60.dp else 76.dp).roundToPx() } } @@ -1827,10 +1729,7 @@ private fun HomeHeroLayer( getIptvChannelId: (MediaItem) -> String? = { null }, getIptvStreamUrl: (Int) -> String? = { null } ) { - if (isMobile) { - // Mobile hero is rendered inline inside MobileHomeRowsLayer's LazyColumn — no fixed overlay needed. - } else { - // TV hero: full-screen overlay with clearlogo + if (!isMobile) { val configuration = LocalConfiguration.current val contentRowHeight = (configuration.screenHeightDp * 0.34f).dp.coerceIn(240.dp, 320.dp) val contentRowBottomPadding = 12.dp @@ -1865,7 +1764,6 @@ private fun HomeHeroLayer( } } -/** Compact mobile hero overlay with gradient, title, metadata, description, and action buttons. */ @Composable private fun MobileHeroOverlay( item: MediaItem, @@ -1912,7 +1810,6 @@ private fun MobileHeroOverlay( .fillMaxHeight(0.42f) .zIndex(3f) ) { - // Bottom gradient over the backdrop Box( modifier = Modifier .fillMaxWidth() @@ -1921,13 +1818,11 @@ private fun MobileHeroOverlay( .background(mobileHeroGradient) ) - // Content at the bottom Column( modifier = Modifier .align(Alignment.BottomStart) .padding(start = contentStartPadding, end = contentStartPadding, bottom = 12.dp) ) { - // Title Text( text = item.title, style = ArflixTypography.heroTitle.copy( @@ -2013,9 +1908,7 @@ private fun MobileHeroOverlay( Spacer(modifier = Modifier.height(10.dp)) - // Action buttons Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - // Play button Box( modifier = Modifier .background(AccentRed, RoundedCornerShape(8.dp)) @@ -2043,7 +1936,6 @@ private fun MobileHeroOverlay( } } - // Details button Box( modifier = Modifier .background(Color.White.copy(alpha = 0.15f), RoundedCornerShape(8.dp)) @@ -2075,7 +1967,6 @@ private fun MobileHeroOverlay( } } -/** Netflix-style mobile hero carousel: card-based banner pager with profile/search overlay. */ @OptIn(ExperimentalFoundationApi::class) @Composable private fun MobileHeroCarousel( @@ -2091,9 +1982,9 @@ private fun MobileHeroCarousel( val heroItems = remember(categories) { val eligibleRows = categories.filter { it.id != "continue_watching" && - !it.id.startsWith("collection_row_") && - it.id != SportsAddonCapabilities.SPORTS_CATEGORY_ROW_ID && - it.id != SportsAddonCapabilities.POPULAR_LIVE_TV_ROW_ID + !it.id.startsWith("collection_row_") && + it.id != SportsAddonCapabilities.SPORTS_CATEGORY_ROW_ID && + it.id != SportsAddonCapabilities.POPULAR_LIVE_TV_ROW_ID } val firstCat = eligibleRows.getOrNull(0) ?.items?.filter { !it.isPlaceholder && it.id > 0 && !SportsAddonCapabilities.isSportsHomeStatus(it.status) && !SportsAddonCapabilities.isSportsLockedStatus(it.status) }?.take(5) @@ -2101,7 +1992,6 @@ private fun MobileHeroCarousel( val secondCat = eligibleRows.getOrNull(1) ?.items?.filter { !it.isPlaceholder && it.id > 0 && !SportsAddonCapabilities.isSportsHomeStatus(it.status) && !SportsAddonCapabilities.isSportsLockedStatus(it.status) }?.take(5) .orEmpty() - // Interleave: first[0], second[0], first[1], second[1], … buildList { val maxLen = maxOf(firstCat.size, secondCat.size) for (i in 0 until maxLen) { @@ -2119,7 +2009,6 @@ private fun MobileHeroCarousel( if (heroItems.isEmpty()) { Column(modifier = Modifier.fillMaxWidth()) { - // Profile avatar + search icon row — above the pager, respects status bar Row( modifier = Modifier .fillMaxWidth() @@ -2165,8 +2054,6 @@ private fun MobileHeroCarousel( return } - // Circular paging: use a large virtual page count that's a multiple of heroItems.size - // so page % heroItems.size always maps correctly and starts at item[0]. val virtualPageCount = heroItems.size * 1000 val initialPage = heroItems.size * 500 val pagerState = rememberPagerState( @@ -2174,9 +2061,6 @@ private fun MobileHeroCarousel( pageCount = { virtualPageCount } ) - // Restart the 10s countdown whenever the pager settles on a new page, - // whether from a user swipe or the previous auto-advance. This gives - // the user a full 10s after any manual interaction before the next advance. LaunchedEffect(pagerState.settledPage, heroItems.size) { if (heroItems.size <= 1) return@LaunchedEffect delay(10000L) @@ -2187,7 +2071,6 @@ private fun MobileHeroCarousel( } Column(modifier = Modifier.fillMaxWidth()) { - // Profile avatar + search icon row — above the pager, respects status bar Row( modifier = Modifier .fillMaxWidth() @@ -2222,7 +2105,6 @@ private fun MobileHeroCarousel( ) } - // Banner card pager — circular, peeks at adjacent cards on both sides HorizontalPager( state = pagerState, contentPadding = PaddingValues(horizontal = 64.dp), @@ -2235,7 +2117,6 @@ private fun MobileHeroCarousel( val genreMap = if (item.mediaType == MediaType.TV) tvGenres else movieGenres item.genreIds.mapNotNull { genreMap[it] }.take(3) } - // releaseDate is stored as "d MMM yyyy" by MediaRepository.formatDate() val year = remember(item.id, item.releaseDate, item.year) { val rd = item.releaseDate if (!rd.isNullOrBlank()) { @@ -2257,7 +2138,6 @@ private fun MobileHeroCarousel( } val logoUrl = remember(item.id) { cardLogoUrls["${item.mediaType}_${item.id}"] } - // Scale down cards that aren't in the center; animate smoothly as they scroll in/out val scale by remember(page) { derivedStateOf { val offset = abs( @@ -2282,7 +2162,6 @@ private fun MobileHeroCarousel( ) } - // Animated pill indicators — centered below the pager if (heroItems.size > 1) { val currentIndex = pagerState.currentPage % heroItems.size Row( @@ -2374,8 +2253,6 @@ private fun HomeInputLayer( horizontalMinRepeatIntervalMs = 80L, verticalMinRepeatIntervalMs = 112L ) - // Profile avatar is always shown when a profile exists (clickable, opens - // profile switcher). Focus navigation includes it as the first focusable item. val hasProfile = currentProfile != null val maxSidebarIndex = topBarMaxIndex(hasProfile) @@ -2393,25 +2270,10 @@ private fun HomeInputLayer( if (hasProfile) focusState.sidebarFocusIndex = 2 } - // Row insertion/reordering must not replace the focused catalog identity. LaunchedEffect(focusState.currentRowIndex) { preferredCategoryId = categories.getOrNull(focusState.currentRowIndex)?.id } - // Clamp focus indices when the category list structurally changes (rows added - // or removed). This uses only the category IDs as the key — NOT item counts — - // so it only fires when rows themselves appear/disappear, not when items within - // a row change (which happens 8-14 times during cold start as skeletons are - // replaced by real data, logos load, badges update, etc.). - // - // The previous implementation used item counts in the key and also contained - // a "fallback to first non-empty row" path that aggressively reset focus - // indices, plus a requestFocus() call that fought with the existing focused - // card. Both of those caused the visible "trip" on startup where focus - // disappeared until the user pressed Up/Down. - // - // The new approach is purely defensive: only clamp out-of-bounds indices, - // never jump to a different row, never re-request focus. val categoryIds = remember(categories) { categories.map { it.id } } LaunchedEffect(categoryIds) { if (categories.isEmpty()) return@LaunchedEffect @@ -2425,8 +2287,6 @@ private fun HomeInputLayer( } } - // If the preferred category still exists, restore the row index to it. - // Otherwise keep the current index but clamp to valid range. focusState.currentRowIndex = resolveHomeCategoryIndex( categoryIds = categoryIds, preferredCategoryId = preferredCategoryId, @@ -2457,8 +2317,6 @@ private fun HomeInputLayer( } val focusedRowHasMore = focusedCategoryId?.let { categoryHasMoreMap[it] == true } == true - // Restore the same title when a row is reordered or refreshed. Empty and - // partial paged results keep the pending index instead of resetting to zero. LaunchedEffect(focusedCategoryId, focusedItemKeys, focusedRowHasMore) { val categoryId = focusedCategoryId ?: return@LaunchedEffect val resolvedIndex = resolveHomeItemIndex( @@ -2473,8 +2331,6 @@ private fun HomeInputLayer( focusState.rowItemIndicesByCategoryId[categoryId] = resolvedIndex } - // User navigation updates the stable identity anchor. This effect is not - // keyed on the item list, so a background reorder cannot overwrite it first. LaunchedEffect(focusedCategoryId, focusState.currentItemIndex) { val categoryId = focusedCategoryId ?: return@LaunchedEffect val itemIndex = focusState.currentItemIndex @@ -2485,7 +2341,7 @@ private fun HomeInputLayer( } val keyEventModifier = if (isMobile) { - Modifier // No D-pad key handling on mobile + Modifier } else { Modifier.onPreviewKeyEvent { event -> if (isContextMenuOpen) { @@ -2550,9 +2406,6 @@ private fun HomeInputLayer( when (event.type) { KeyEventType.KeyDown -> when (event.key) { Key.Enter, Key.DirectionCenter -> { - // Track KeyDown time for long-press detection. - // Sidebar actions fire immediately; content items wait for KeyUp - // to distinguish tap (navigate) from long-press (context menu). if (focusState.isSidebarFocused) { if (hasProfile && focusState.sidebarFocusIndex == 0) { onSwitchProfile() @@ -2594,12 +2447,10 @@ private fun HomeInputLayer( if (focusState.isSidebarFocused) { true } else if (focusState.currentRowIndex > 0) { - // Save current item position before leaving this row categories.getOrNull(focusState.currentRowIndex)?.id?.let { categoryId -> focusState.rowItemIndicesByCategoryId[categoryId] = focusState.currentItemIndex } focusState.currentRowIndex-- - // Restore saved position for the target row (or 0 if never visited) val targetCategoryId = categories.getOrNull(focusState.currentRowIndex)?.id focusState.currentItemIndex = targetCategoryId ?.let(focusState.rowItemIndicesByCategoryId::get) @@ -2627,12 +2478,10 @@ private fun HomeInputLayer( focusState.lastNavEventTime = SystemClock.elapsedRealtime() true } else if (!focusState.isSidebarFocused && focusState.currentRowIndex < categories.size - 1) { - // Save current item position before leaving this row categories.getOrNull(focusState.currentRowIndex)?.id?.let { categoryId -> focusState.rowItemIndicesByCategoryId[categoryId] = focusState.currentItemIndex } focusState.currentRowIndex++ - // Restore saved position for the target row (or 0 if never visited) val targetCategoryId = categories.getOrNull(focusState.currentRowIndex)?.id focusState.currentItemIndex = targetCategoryId ?.let(focusState.rowItemIndicesByCategoryId::get) @@ -2643,90 +2492,84 @@ private fun HomeInputLayer( true } } - Key.Back, Key.Escape -> { - selectPressedInHome = false - selectDownAtMs = 0L - if (focusState.isSidebarFocused) { - onExitApp() - } else { - categories.getOrNull(focusState.currentRowIndex)?.id?.let { categoryId -> - focusState.rowItemIndicesByCategoryId[categoryId] = focusState.currentItemIndex - } - focusState.isSidebarFocused = true + Key.Back, Key.Escape -> { + selectPressedInHome = false + selectDownAtMs = 0L + if (focusState.isSidebarFocused) { + onExitApp() + } else { + categories.getOrNull(focusState.currentRowIndex)?.id?.let { categoryId -> + focusState.rowItemIndicesByCategoryId[categoryId] = focusState.currentItemIndex } - true + focusState.isSidebarFocused = true } - Key.Menu, Key.Info -> { - selectPressedInHome = false - selectDownAtMs = 0L - if (!focusState.isSidebarFocused) { - val currentItem = getFocusedItem( - categories, - focusState.currentRowIndex, - focusState.currentItemIndex - ) - currentItem?.takeIf { isActionableHomeItem(it) }?.let { item -> - if (isSportsHomeItem(item)) { - onSportsHomeItemClick(item) - } else { - val currentCategory = categories.getOrNull(focusState.currentRowIndex) - val isContinue = currentCategory?.id == "continue_watching" - onOpenContextMenu(item, isContinue) - } + true + } + Key.Menu, Key.Info -> { + selectPressedInHome = false + selectDownAtMs = 0L + if (!focusState.isSidebarFocused) { + val currentItem = getFocusedItem( + categories, + focusState.currentRowIndex, + focusState.currentItemIndex + ) + currentItem?.takeIf { isActionableHomeItem(it) }?.let { item -> + if (isSportsHomeItem(item)) { + onSportsHomeItemClick(item) + } else { + val currentCategory = categories.getOrNull(focusState.currentRowIndex) + val isContinue = currentCategory?.id == "continue_watching" + onOpenContextMenu(item, isContinue) } } - true } - else -> false + true } - KeyEventType.KeyUp -> when (event.key) { - Key.Enter, Key.DirectionCenter -> { - if (selectPressedInHome && !focusState.isSidebarFocused) { - val holdMs = SystemClock.elapsedRealtime() - selectDownAtMs - val currentItem = getFocusedItem( - categories, - focusState.currentRowIndex, - focusState.currentItemIndex - ) - currentItem?.takeIf { isActionableHomeItem(it) }?.let { item -> - if (isSportsHomeItem(item)) { - onSportsHomeItemClick(item) - return@let - } - if (holdMs >= 500L) { - // Long-press: open context menu - val currentCategory = categories.getOrNull(focusState.currentRowIndex) - val isContinue = currentCategory?.id == "continue_watching" - onOpenContextMenu(item, isContinue) + else -> false + } + KeyEventType.KeyUp -> when (event.key) { + Key.Enter, Key.DirectionCenter -> { + if (selectPressedInHome && !focusState.isSidebarFocused) { + val holdMs = SystemClock.elapsedRealtime() - selectDownAtMs + val currentItem = getFocusedItem( + categories, + focusState.currentRowIndex, + focusState.currentItemIndex + ) + currentItem?.takeIf { isActionableHomeItem(it) }?.let { item -> + if (isSportsHomeItem(item)) { + onSportsHomeItemClick(item) + return@let + } + if (holdMs >= 500L) { + val currentCategory = categories.getOrNull(focusState.currentRowIndex) + val isContinue = currentCategory?.id == "continue_watching" + onOpenContextMenu(item, isContinue) + } else { + val iptvId = item.status?.removePrefix("iptv:") + ?.takeIf { item.status?.startsWith("iptv:") == true && it.isNotBlank() } + val collectionId = item.status?.removePrefix("collection:") + ?.takeIf { item.status?.startsWith("collection:") == true && it.isNotBlank() } + if (iptvId != null) { + onNavigateToTv(iptvId, getIptvStreamUrl(item.id)) + } else if (collectionId != null) { + onNavigateToCollection(collectionId) } else { - // Short press: navigate. Must check collection: - // BEFORE falling through to Details — D-pad SELECT - // on a service tile (Netflix, HBO, ...) was hitting - // DetailsScreen with the synthetic hash id and - // spamming TMDB 404s instead of opening the catalog. - val iptvId = item.status?.removePrefix("iptv:") - ?.takeIf { item.status?.startsWith("iptv:") == true && it.isNotBlank() } - val collectionId = item.status?.removePrefix("collection:") - ?.takeIf { item.status?.startsWith("collection:") == true && it.isNotBlank() } - if (iptvId != null) { - onNavigateToTv(iptvId, getIptvStreamUrl(item.id)) - } else if (collectionId != null) { - onNavigateToCollection(collectionId) - } else { - onNavigateToDetails(item.mediaType, item.id, item.nextEpisode?.seasonNumber, item.nextEpisode?.episodeNumber) - } + onNavigateToDetails(item.mediaType, item.id, item.nextEpisode?.seasonNumber, item.nextEpisode?.episodeNumber) } } } - selectPressedInHome = false - selectDownAtMs = 0L - true } - else -> false + selectPressedInHome = false + selectDownAtMs = 0L + true } else -> false } + else -> false } + } } Box( @@ -2743,6 +2586,7 @@ private fun HomeInputLayer( .focusable() .then(keyEventModifier) ) { + // Floating Translucent Top Capsule on TV if (!isMobile) { AppTopBar( selectedItem = SidebarItem.HOME, @@ -2751,7 +2595,12 @@ private fun HomeInputLayer( profile = currentProfile, profileCount = profileCount, clockFormat = clockFormat, - hasUpdateBadge = hasUpdateBadge + hasUpdateBadge = hasUpdateBadge, + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(top = 16.dp) + .zIndex(10f) ) } @@ -2897,7 +2746,7 @@ private fun HomeRowsLayer( } } -/** Mobile-optimized rows: free-scrolling LazyColumn with smaller cards, no viewport constraint. */ +/** Mobile-optimized rows: free-scrolling LazyColumn with floating nav bottom clearance. */ @Composable private fun MobileHomeRowsLayer( categories: List, @@ -2922,10 +2771,10 @@ private fun MobileHomeRowsLayer( LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 16.dp), + // 96dp bottom padding ensures content scrolls completely above the floating bottom capsule + contentPadding = PaddingValues(bottom = 96.dp), verticalArrangement = Arrangement.spacedBy(20.dp) ) { - // Hero carousel — profile/search row + banner card pager item(key = "mobile_hero", contentType = "mobile_hero") { MobileHeroCarousel( categories = categories, @@ -2966,7 +2815,6 @@ private fun MobileHomeRowsLayer( } Column(modifier = Modifier.padding(bottom = 0.dp)) { - // Section title Row( modifier = Modifier.padding( start = contentStartPadding, @@ -2999,7 +2847,6 @@ private fun MobileHomeRowsLayer( val isRowSkeleton = category.items.isEmpty() || category.items.all { it.isPlaceholder } if (isRowSkeleton) { - // Render structured skeleton cards while category metadata is loading LazyRow( state = rowState, modifier = Modifier.arvioDpadFocusGroup(), @@ -3027,7 +2874,6 @@ private fun MobileHomeRowsLayer( stableHomeRowItemKeys(category.id, realItems) } - // Horizontal card row with touch scrolling LazyRow( state = rowState, modifier = Modifier.arvioDpadFocusGroup(), @@ -3156,18 +3002,7 @@ private fun TvHomeRowsLayer( featuredTrailerVolume: Float = 0f, onItemClick: (MediaItem) -> Unit ) { - // ── Focus-row stabilizer ── - // Track the focused row by its category ID (stable) rather than integer - // index. When new catalogs are inserted above the focused row (e.g., - // "Favorite TV" or custom Trakt lists loading), the integer index of the - // focused row shifts but its ID stays the same. Without this correction - // the LazyColumn would scroll to the wrong row and the focus highlight - // would visually "trip" to a different catalog until the user presses - // Up/Down to re-establish focus. This was the root cause of the startup - // focus/catalog glitch. var focusedCategoryId by remember { mutableStateOf(null) } - // Sync: when the user moves focus (currentRowIndex changes from D-pad), - // update the tracked category ID. LaunchedEffect(focusState.currentRowIndex) { val id = categories.getOrNull(focusState.currentRowIndex)?.id if (id != null) focusedCategoryId = id @@ -3250,7 +3085,7 @@ private fun TvHomeRowsLayer( } val recentUserNav = focusState.lastNavEventTime > 0L && - (SystemClock.elapsedRealtime() - focusState.lastNavEventTime) <= fastScrollThresholdMs + (SystemClock.elapsedRealtime() - focusState.lastNavEventTime) <= fastScrollThresholdMs if (!initialPlacement && !recentUserNav) return@LaunchedEffect val jumpDistance = abs(targetIndex - currentIndex) @@ -3291,8 +3126,7 @@ private fun TvHomeRowsLayer( } lastAppliedTargetIndex = targetIndex } - // Keep rows in the lower portion of the screen so hero metadata has dedicated space, - // matching the separation used on Details. + Box( modifier = Modifier .align(Alignment.BottomStart) @@ -3339,10 +3173,10 @@ private fun TvHomeRowsLayer( } } Box( - modifier = Modifier - .fillMaxWidth() - .height(rowHeight) - .clipToBounds() + modifier = Modifier + .fillMaxWidth() + .height(rowHeight) + .clipToBounds() ) { ContentRow( category = category, @@ -3414,9 +3248,7 @@ private fun ArcticFuseRatingBadge( @Composable private fun PrimeLogo(modifier: Modifier = Modifier) { - // Simple text-based logo for now, but blue "prime" with smile curve Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - // "prime" text Text( text = "prime", style = TextStyle( @@ -3426,7 +3258,6 @@ private fun PrimeLogo(modifier: Modifier = Modifier) { letterSpacing = (-0.5).sp ) ) - // Smile curve path could be drawn here, but text is sufficient for now } } @@ -3442,7 +3273,7 @@ private fun IncludedWithPrimeBadge() { tint = PrimeBlue, modifier = Modifier .size(16.dp) - .background(Color.Transparent) // No circle bg in screenshot, just check + .background(Color.Transparent) ) Text( text = stringResource(R.string.included_with_prime), @@ -3524,11 +3355,10 @@ private fun ImdbSvgRatingBadge( @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun ImdbBadge(rating: String) { - // Kept for compatibility but not strictly in new hero design Box( modifier = Modifier .background( - color = Color(0xFFF5C518), // IMDb yellow + color = Color(0xFFF5C518), shape = RoundedCornerShape(4.dp) ) .padding(horizontal = 8.dp, vertical = 5.dp) @@ -3582,10 +3412,6 @@ private fun ContentRow( val rowState = rememberLazyListState() val density = LocalDensity.current val isContinueWatching = category.id == "continue_watching" - // Poster rows felt too tight vertically when focused. Instead of adding more - // row spacing (which made the section layout feel loose), slightly reduce the - // poster card width so the 1.05x focus zoom has more breathing room inside the - // existing row spacing. ~5% smaller than before. val effectivePosterMode = if (isCollectionRow) { category.items.firstOrNull()?.collectionTileShape == CollectionTileShape.POSTER } else { @@ -3630,16 +3456,9 @@ private fun ContentRow( with(density) { (itemWidth + itemSpacing).toPx().coerceAtLeast(1f) } } val hasFeaturedCard = !effectivePosterMode && featuredTrailerKey != null - // Tracks which item index has held focus long enough to expand. - // Using an index (not a boolean) means the derived `featuredExpanded` - // evaluates to false immediately in the same composition frame when - // focusedItemIndex changes — no async LaunchedEffect reset needed. - // Without this, the new card briefly saw featuredExpanded=true - // (stale from the previous card) and rendered at 380dp, causing a - // layout overshoot in the LazyRow before snapping back. var featuredExpandedForIndex by remember { mutableIntStateOf(-1) } val featuredExpanded = hasFeaturedCard && isCurrentRow && - featuredExpandedForIndex == focusedItemIndex && focusedItemIndex >= 0 + featuredExpandedForIndex == focusedItemIndex && focusedItemIndex >= 0 val context = LocalContext.current val trailerExtractor = remember { EntryPointAccessors.fromApplication( @@ -3647,8 +3466,7 @@ private fun ContentRow( TrailerPlayerEntryPoint::class.java ).inAppYouTubeExtractor() } - // Pre-warm the URL cache the moment a card gets focus — races ahead of the - // expansion delay so the cache is populated by the time the card expands. + LaunchedEffect(focusedItemIndex, featuredTrailerKey) { val key = featuredTrailerKey ?: return@LaunchedEffect if (!hasFeaturedCard || !isCurrentRow || focusedItemIndex < 0) return@LaunchedEffect @@ -3665,10 +3483,10 @@ private fun ContentRow( } } val railFocusOverlayActive = isCurrentRow && isScrollable && focusedItemIndex >= 0 && totalItems > 0 && - !hasFeaturedCard && - focusedItemIndex <= maxFirstIndex && - focusedItemIndex == rowState.firstVisibleItemIndex && - rowState.firstVisibleItemScrollOffset == 0 + !hasFeaturedCard && + focusedItemIndex <= maxFirstIndex && + focusedItemIndex == rowState.firstVisibleItemIndex && + rowState.firstVisibleItemScrollOffset == 0 val focusedCardIndex = if (railFocusOverlayActive) { -1 } else { @@ -3682,8 +3500,7 @@ private fun ContentRow( ) val latestOnItemClick = rememberUpdatedState(onItemClick) val latestOnItemFocused = rememberUpdatedState(onItemFocused) - // Keep focused card anchored by scrolling the row on every focus change. - // Use smooth scroll (animated) for D-pad moves to avoid abrupt jumps. + var lastScrollIndex by remember { mutableIntStateOf(-1) } var lastScrollOffset by remember { mutableIntStateOf(-1) } LaunchedEffect(isCurrentRow, category.id) { @@ -3695,9 +3512,6 @@ private fun ContentRow( val currentFirstIndex = rowState.firstVisibleItemIndex.coerceAtMost(maxFirstIndex) val currentFirstOffset = rowState.firstVisibleItemScrollOffset - // TV rows should behave like a stable focus rail: the focused tile stays - // in the first visible slot while D-pad Right moves the row underneath it. - // Allowing a leading comfort item made focus sit on the second tile. val scrollTargetIndex = when { !isScrollable || lastScrollIndex == -1 -> focusedItemIndex.coerceAtMost(maxFirstIndex) focusedItemIndex != currentFirstIndex -> focusedItemIndex.coerceAtLeast(0) @@ -3712,7 +3526,6 @@ private fun ContentRow( lastScrollOffset = extraOffset if (isFirstScroll) { - // First time we jump directly to the correct position (no animation) rowState.scrollToItem(index = scrollTargetIndex, scrollOffset = extraOffset) return@LaunchedEffect } @@ -3741,9 +3554,9 @@ private fun ContentRow( ) if ( !isFastScrolling && ( - rowState.firstVisibleItemIndex != scrollTargetIndex || - abs(rowState.firstVisibleItemScrollOffset - extraOffset) > 6 - ) + rowState.firstVisibleItemIndex != scrollTargetIndex || + abs(rowState.firstVisibleItemScrollOffset - extraOffset) > 6 + ) ) { rowState.scrollToItem(index = scrollTargetIndex, scrollOffset = extraOffset) } @@ -3759,7 +3572,6 @@ private fun ContentRow( modifier = Modifier .padding(bottom = 12.dp) ) { - // Section title - clean white text, aligned with cards Row( modifier = Modifier.padding(start = startPadding, bottom = 6.dp), verticalAlignment = Alignment.CenterVertically, @@ -3780,7 +3592,6 @@ private fun ContentRow( ) } - // Cards row - clipped to hide previous items when scrolling val clipModifier = if (isContinueWatching) Modifier else Modifier.clipToBounds() Box( modifier = Modifier @@ -3812,60 +3623,101 @@ private fun ContentRow( } } ) { index, item -> - if (item.isPlaceholder) { - LaunchedEffect(item.id) { - onLoadMore() + if (item.isPlaceholder) { + LaunchedEffect(item.id) { + onLoadMore() + } + } else if (effectiveCategoryHasMore && index >= category.items.size - 5) { + LaunchedEffect(category.items.size) { + onLoadMore() + } } - } else if (effectiveCategoryHasMore && index >= category.items.size - 5) { - LaunchedEffect(category.items.size) { - onLoadMore() + val itemIsFocused = isCurrentRow && index == focusedCardIndex + val currentItem = rememberUpdatedState(item) + val onCardFocused = remember(index) { + { latestOnItemFocused.value(currentItem.value, index) } } - } - val itemIsFocused = isCurrentRow && index == focusedCardIndex - val currentItem = rememberUpdatedState(item) - val onCardFocused = remember(index) { - { latestOnItemFocused.value(currentItem.value, index) } - } - val onCardClick = remember { - { latestOnItemClick.value(currentItem.value) } - } - if (isRanked && index < 10) { - val cardLogoUrl = if (isCollectionRow) null else cardLogoUrls["${item.mediaType}_${item.id}"] - val rankedExpanded = hasFeaturedCard && itemIsFocused && featuredExpanded - if (rankedExpanded) { - // Expanded: fresh Animatable starting at itemWidth so the expansion - // animates in from the card's resting size. This branch is only entered - // after the 500ms focus-settle delay, so the Animatable is always new. - val expandAnim = remember { Animatable(itemWidth.value) } - LaunchedEffect(Unit) { - expandAnim.animateTo(380f, spring()) + val onCardClick = remember { + { latestOnItemClick.value(currentItem.value) } + } + if (isRanked && index < 10) { + val cardLogoUrl = if (isCollectionRow) null else cardLogoUrls["${item.mediaType}_${item.id}"] + val rankedExpanded = hasFeaturedCard && itemIsFocused && featuredExpanded + if (rankedExpanded) { + val expandAnim = remember { Animatable(itemWidth.value) } + LaunchedEffect(Unit) { + expandAnim.animateTo(380f, spring()) + } + val expandedWidth = expandAnim.value.dp + Box(modifier = Modifier.width(expandedWidth)) { + FeaturedMediaCard( + item = item, + width = expandedWidth, + height = 146.dp, + trailerKey = featuredTrailerKey, + trailerDelayMs = 0L, + trailerVolume = featuredTrailerVolume, + onClick = onCardClick, + ) + TopRankRibbon( + rank = index + 1, + isFocused = itemIsFocused, + compact = !effectivePosterMode, + modifier = Modifier + .align(Alignment.TopStart) + .zIndex(2f) + .padding(start = 8.dp) + ) + } + } else { + Box(modifier = Modifier.width(itemWidth)) { + ArvioMediaCard( + item = item, + width = itemWidth, + isLandscape = !effectivePosterMode, + logoImageUrl = cardLogoUrl, + showLogoImage = true, + raiseOnFocus = !isFastScrolling, + showProgress = false, + showTitle = isCollectionRow && !item.collectionHideTitle, + isFocusedOverride = itemIsFocused && !railFocusOverlayActive, + focusedScale = 1f, + enableFocusedImageSwap = !isCollectionRow && !isFastScrolling, + animateFocus = false, + enableSystemFocus = false, + onFocused = onCardFocused, + onClick = onCardClick, + ) + TopRankRibbon( + rank = index + 1, + isFocused = itemIsFocused, + compact = !effectivePosterMode, + modifier = Modifier + .align(Alignment.TopStart) + .zIndex(2f) + .padding(start = 8.dp) + ) + } } - val expandedWidth = expandAnim.value.dp - Box(modifier = Modifier.width(expandedWidth)) { + } else { + val cardLogoUrl = if (isCollectionRow) null else cardLogoUrls["${item.mediaType}_${item.id}"] + val cardExpanded = hasFeaturedCard && itemIsFocused && featuredExpanded + val animatedCardWidth by animateDpAsState( + targetValue = if (cardExpanded) 380.dp else itemWidth, + animationSpec = if (cardExpanded) spring() else snap(), + label = "featuredCardWidth" + ) + if (cardExpanded) { FeaturedMediaCard( item = item, - width = expandedWidth, + width = animatedCardWidth, height = 146.dp, trailerKey = featuredTrailerKey, trailerDelayMs = 0L, trailerVolume = featuredTrailerVolume, onClick = onCardClick, ) - TopRankRibbon( - rank = index + 1, - isFocused = itemIsFocused, - compact = !effectivePosterMode, - modifier = Modifier - .align(Alignment.TopStart) - .zIndex(2f) - .padding(start = 8.dp) - ) - } - } else { - // Collapsed: plain constant width — no animation state, no frame delay. - // The LazyRow item is immediately itemWidth, same as non-ranked cards, - // so the scroll delta is always computed against the correct layout. - Box(modifier = Modifier.width(itemWidth)) { + } else { ArvioMediaCard( item = item, width = itemWidth, @@ -3873,7 +3725,7 @@ private fun ContentRow( logoImageUrl = cardLogoUrl, showLogoImage = true, raiseOnFocus = !isFastScrolling, - showProgress = false, + showProgress = isContinueWatching, showTitle = isCollectionRow && !item.collectionHideTitle, isFocusedOverride = itemIsFocused && !railFocusOverlayActive, focusedScale = 1f, @@ -3883,56 +3735,8 @@ private fun ContentRow( onFocused = onCardFocused, onClick = onCardClick, ) - TopRankRibbon( - rank = index + 1, - isFocused = itemIsFocused, - compact = !effectivePosterMode, - modifier = Modifier - .align(Alignment.TopStart) - .zIndex(2f) - .padding(start = 8.dp) - ) } } - } else { - val cardLogoUrl = if (isCollectionRow) null else cardLogoUrls["${item.mediaType}_${item.id}"] - val cardExpanded = hasFeaturedCard && itemIsFocused && featuredExpanded - val animatedCardWidth by animateDpAsState( - targetValue = if (cardExpanded) 380.dp else itemWidth, - animationSpec = if (cardExpanded) spring() else snap(), - label = "featuredCardWidth" - ) - if (cardExpanded) { - FeaturedMediaCard( - item = item, - width = animatedCardWidth, - height = 146.dp, - trailerKey = featuredTrailerKey, - trailerDelayMs = 0L, - trailerVolume = featuredTrailerVolume, - onClick = onCardClick, - ) - } else { - // Normal card — not focused, not yet expanded, or hasFeaturedCard off - ArvioMediaCard( - item = item, - width = itemWidth, - isLandscape = !effectivePosterMode, - logoImageUrl = cardLogoUrl, - showLogoImage = true, - raiseOnFocus = !isFastScrolling, - showProgress = isContinueWatching, - showTitle = isCollectionRow && !item.collectionHideTitle, - isFocusedOverride = itemIsFocused && !railFocusOverlayActive, - focusedScale = 1f, - enableFocusedImageSwap = !isCollectionRow && !isFastScrolling, - animateFocus = false, - enableSystemFocus = false, - onFocused = onCardFocused, - onClick = onCardClick, - ) - } - } } } if (railFocusOverlayActive) { @@ -3952,10 +3756,8 @@ private fun ContentRow( enableSystemFocus = false, isFocusedOverride = true ) { - // Empty by design: this keeps the D-pad focus ring anchored to - // the first rail slot while the selected item scrolls under it. } } - } // Close Box - } // Close Column -} + } + } +} \ No newline at end of file