Compare commits

...

2 commits

Author SHA1 Message Date
tapframe
c2196cb0c9 revert perf branch 2026-02-26 08:32:13 +05:30
tapframe
5adc071969 Revert "Merge branch 'performance' into dev"
This reverts commit 4d1d0d9547, reversing
changes made to 4e972340be.
2026-02-26 07:32:26 +05:30
16 changed files with 265 additions and 347 deletions

View file

@ -138,9 +138,10 @@ configurations.all {
} }
dependencies { dependencies {
val composeBom = platform("androidx.compose:compose-bom:2026.01.01")
// baselineProfile(project(":benchmark")) // TODO: create benchmark module later val composeBom = platform("androidx.compose:compose-bom:2025.02.00")
baselineProfile(project(":benchmark"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.core:core-splashscreen:1.0.1")
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
@ -153,7 +154,7 @@ dependencies {
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.tv:tv-material:1.0.0") implementation("androidx.tv:tv-material:1.1.0-alpha01")
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation("androidx.activity:activity-compose:1.11.0") implementation("androidx.activity:activity-compose:1.11.0")
@ -242,10 +243,6 @@ dependencies {
// Kotlinx Serialization // Kotlinx Serialization
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
// Performance profiling
implementation("androidx.metrics:metrics-performance:1.0.0-beta01") // JankStats
implementation("androidx.compose.runtime:runtime-tracing") // Compose function names in Perfetto
// Bundle real crypto-js (JS) for QuickJS plugins // Bundle real crypto-js (JS) for QuickJS plugins
implementation("org.webjars.npm:crypto-js:4.2.0") implementation("org.webjars.npm:crypto-js:4.2.0")

View file

@ -3,10 +3,6 @@ package com.nuvio.tv
import android.os.Bundle import android.os.Bundle
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.util.Log
import androidx.compose.ui.platform.LocalView
import androidx.metrics.performance.JankStats
import androidx.metrics.performance.PerformanceMetricsState
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@ -23,10 +19,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -54,7 +47,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
@ -83,7 +75,6 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@ -110,7 +101,6 @@ import com.nuvio.tv.core.sync.ProfileSyncService
import com.nuvio.tv.core.sync.StartupSyncService import com.nuvio.tv.core.sync.StartupSyncService
import com.nuvio.tv.ui.navigation.NuvioNavHost import com.nuvio.tv.ui.navigation.NuvioNavHost
import com.nuvio.tv.ui.navigation.Screen import com.nuvio.tv.ui.navigation.Screen
import com.nuvio.tv.ui.components.NuvioScrollDefaults
import com.nuvio.tv.ui.components.ProfileAvatarCircle import com.nuvio.tv.ui.components.ProfileAvatarCircle
import com.nuvio.tv.ui.screens.account.AuthQrSignInScreen import com.nuvio.tv.ui.screens.account.AuthQrSignInScreen
import com.nuvio.tv.ui.screens.profile.ProfileSelectionScreen import com.nuvio.tv.ui.screens.profile.ProfileSelectionScreen
@ -121,6 +111,7 @@ import com.nuvio.tv.updater.ui.UpdatePromptDialog
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -174,9 +165,7 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var appOnboardingDataStore: AppOnboardingDataStore lateinit var appOnboardingDataStore: AppOnboardingDataStore
private lateinit var jankStats: JankStats @OptIn(ExperimentalTvMaterial3Api::class)
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
override fun attachBaseContext(newBase: Context) { override fun attachBaseContext(newBase: Context) {
val tag = newBase.getSharedPreferences("app_locale", Context.MODE_PRIVATE) val tag = newBase.getSharedPreferences("app_locale", Context.MODE_PRIVATE)
.getString("locale_tag", null) .getString("locale_tag", null)
@ -191,7 +180,6 @@ class MainActivity : ComponentActivity() {
} }
} }
@OptIn(ExperimentalFoundationApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -238,9 +226,6 @@ class MainActivity : ComponentActivity() {
val mainUiPrefs by mainUiPrefsFlow.collectAsState(initial = MainUiPrefs(hasChosenLayout = null)) val mainUiPrefs by mainUiPrefsFlow.collectAsState(initial = MainUiPrefs(hasChosenLayout = null))
NuvioTheme(appTheme = mainUiPrefs.theme) { NuvioTheme(appTheme = mainUiPrefs.theme) {
CompositionLocalProvider(
LocalBringIntoViewSpec provides NuvioScrollDefaults.smoothScrollSpec
) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
shape = RectangleShape shape = RectangleShape
@ -338,14 +323,6 @@ class MainActivity : ComponentActivity() {
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route
val view = LocalView.current
LaunchedEffect(currentRoute) {
val holder = PerformanceMetricsState.getHolderForHierarchy(view)
if (currentRoute != null) {
holder.state?.putState("Screen", currentRoute)
}
}
val rootRoutes = remember { val rootRoutes = remember {
setOf( setOf(
Screen.Home.route, Screen.Home.route,
@ -451,27 +428,7 @@ class MainActivity : ComponentActivity() {
) )
} }
} }
}
} }
jankStats = JankStats.createAndTrack(window) { frameData ->
if (frameData.isJank) {
Log.w(
"JankStats",
"JANK: ${frameData.frameDurationUiNanos / 1_000_000}ms | states: ${frameData.states}"
)
}
}
}
override fun onResume() {
super.onResume()
if (::jankStats.isInitialized) jankStats.isTrackingEnabled = true
}
override fun onPause() {
super.onPause()
if (::jankStats.isInitialized) jankStats.isTrackingEnabled = false
} }
override fun onStart() { override fun onStart() {
@ -869,8 +826,11 @@ private fun ModernSidebarScaffold(
animationSpec = tween(durationMillis = 135, easing = FastOutSlowInEasing), animationSpec = tween(durationMillis = 135, easing = FastOutSlowInEasing),
label = "sidebarSurfaceAlpha" label = "sidebarSurfaceAlpha"
) )
val shouldApplySidebarHaze = showSidebar && modernSidebarBlurEnabled && ( val shouldApplySidebarHaze = showSidebar && (
isSidebarExpanded || sidebarCollapsePending sidebarVisible ||
isSidebarExpanded ||
sidebarCollapsePending ||
sidebarWidth > 0.dp
) )
val sidebarTransition = updateTransition( val sidebarTransition = updateTransition(
targetState = isSidebarExpanded, targetState = isSidebarExpanded,
@ -900,12 +860,6 @@ private fun ModernSidebarScaffold(
) { expanded -> ) { expanded ->
if (expanded) 1f else 0f if (expanded) 1f else 0f
} }
// derivedStateOf prevents per-frame recomposition — only triggers when the boolean crosses the threshold
val sidebarBlocksContentKeys by remember { derivedStateOf { sidebarExpandProgress > 0.2f } }
val sidebarShowExpandedPanel by remember { derivedStateOf { sidebarExpandProgress > 0.01f } }
val sidebarShowCollapsedPill by remember { derivedStateOf { sidebarExpandProgress < 0.98f } }
val sidebarIconScale by sidebarTransition.animateFloat( val sidebarIconScale by sidebarTransition.animateFloat(
transitionSpec = { tween(durationMillis = 145, easing = FastOutSlowInEasing) }, transitionSpec = { tween(durationMillis = 145, easing = FastOutSlowInEasing) },
label = "sidebarIconScale" label = "sidebarIconScale"
@ -990,7 +944,7 @@ private fun ModernSidebarScaffold(
if ( if (
isSidebarExpanded && isSidebarExpanded &&
!sidebarCollapsePending && !sidebarCollapsePending &&
sidebarBlocksContentKeys && sidebarExpandProgress > 0.2f &&
keyEvent.type == KeyEventType.KeyDown && keyEvent.type == KeyEventType.KeyDown &&
isBlockedContentKey(keyEvent.key) isBlockedContentKey(keyEvent.key)
) { ) {
@ -1034,25 +988,20 @@ private fun ModernSidebarScaffold(
if (showSidebar && (sidebarVisible || sidebarWidth > 0.dp)) { if (showSidebar && (sidebarVisible || sidebarWidth > 0.dp)) {
val panelShape = RoundedCornerShape(30.dp) val panelShape = RoundedCornerShape(30.dp)
val showExpandedPanel = isSidebarExpanded || sidebarShowExpandedPanel val showExpandedPanel = isSidebarExpanded || sidebarExpandProgress > 0.01f
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.width(sidebarWidth) .width(sidebarWidth)
.padding(start = 14.dp, top = 16.dp, bottom = 12.dp, end = 8.dp) .padding(start = 14.dp, top = 16.dp, bottom = 12.dp, end = 8.dp)
.offset { .offset(x = sidebarSlideX + sidebarDeflateOffsetX, y = sidebarDeflateOffsetY)
IntOffset( .graphicsLayer(
(sidebarSlideX + sidebarDeflateOffsetX).roundToPx(), alpha = sidebarSurfaceAlpha,
sidebarDeflateOffsetY.roundToPx() scaleX = sidebarBloomScale,
) scaleY = sidebarBloomScale,
}
.graphicsLayer {
alpha = sidebarSurfaceAlpha
scaleX = sidebarBloomScale
scaleY = sidebarBloomScale
transformOrigin = TransformOrigin(0f, 0f) transformOrigin = TransformOrigin(0f, 0f)
} )
.selectableGroup() .selectableGroup()
.onPreviewKeyEvent { keyEvent -> .onPreviewKeyEvent { keyEvent ->
if (!isSidebarExpanded || keyEvent.type != KeyEventType.KeyDown) { if (!isSidebarExpanded || keyEvent.type != KeyEventType.KeyDown) {
@ -1112,7 +1061,7 @@ private fun ModernSidebarScaffold(
if ( if (
!sidebarCollapsed && !sidebarCollapsed &&
sidebarShowCollapsedPill && sidebarExpandProgress < 0.98f &&
selectedDrawerRoute != Screen.Search.route selectedDrawerRoute != Screen.Search.route
) { ) {
CollapsedSidebarPill( CollapsedSidebarPill(
@ -1120,23 +1069,20 @@ private fun ModernSidebarScaffold(
iconRes = selectedDrawerItem.iconRes, iconRes = selectedDrawerItem.iconRes,
icon = selectedDrawerItem.icon, icon = selectedDrawerItem.icon,
iconOnly = isFloatingPillIconOnly && !keepFloatingPillExpanded, iconOnly = isFloatingPillIconOnly && !keepFloatingPillExpanded,
hazeState = sidebarHazeState,
blurEnabled = modernSidebarBlurEnabled, blurEnabled = modernSidebarBlurEnabled,
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.offset { .offset(
IntOffset( x = 40.dp + sidebarSlideX + sidebarDeflateOffsetX,
(40.dp + sidebarSlideX + sidebarDeflateOffsetX).roundToPx(), y = 16.dp + sidebarDeflateOffsetY
(16.dp + sidebarDeflateOffsetY).roundToPx() )
) .graphicsLayer(
} alpha = 1f - sidebarExpandProgress,
.graphicsLayer { scaleX = 0.9f + (0.1f * (1f - sidebarExpandProgress)),
val progress = sidebarExpandProgress scaleY = 0.9f + (0.1f * (1f - sidebarExpandProgress)),
alpha = 1f - progress
val s = 0.9f + (0.1f * (1f - progress))
scaleX = s
scaleY = s
transformOrigin = TransformOrigin(0f, 0f) transformOrigin = TransformOrigin(0f, 0f)
}, ),
onExpand = { onExpand = {
isSidebarExpanded = true isSidebarExpanded = true
sidebarCollapsePending = false sidebarCollapsePending = false
@ -1154,24 +1100,13 @@ private fun CollapsedSidebarPill(
iconRes: Int?, iconRes: Int?,
icon: ImageVector?, icon: ImageVector?,
iconOnly: Boolean, iconOnly: Boolean,
hazeState: HazeState,
blurEnabled: Boolean, blurEnabled: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onExpand: () -> Unit onExpand: () -> Unit
) { ) {
val pillShape = RoundedCornerShape(999.dp) val pillShape = RoundedCornerShape(999.dp)
val bgElevated = NuvioColors.BackgroundElevated val innerBlurShape = RoundedCornerShape(999.dp)
val bgCard = NuvioColors.BackgroundCard
val borderBase = NuvioColors.Border
val pillBackgroundBrush = remember(blurEnabled, bgElevated, bgCard) {
if (blurEnabled) {
Brush.verticalGradient(listOf(Color(0xD1424851), Color(0xC73B4149)))
} else {
Brush.verticalGradient(listOf(bgElevated, bgCard))
}
}
val pillBorderColor = remember(blurEnabled, borderBase) {
if (blurEnabled) Color.White.copy(alpha = 0.14f) else borderBase.copy(alpha = 0.9f)
}
Row( Row(
modifier = modifier modifier = modifier
@ -1202,9 +1137,59 @@ private fun CollapsedSidebarPill(
compositingStrategy = CompositingStrategy.Offscreen compositingStrategy = CompositingStrategy.Offscreen
} }
.clip(pillShape) .clip(pillShape)
.background(brush = pillBackgroundBrush, shape = pillShape) .background(
.border(width = 1.dp, color = pillBorderColor, shape = pillShape) brush = if (blurEnabled) {
Brush.verticalGradient(
colors = listOf(
Color(0xD1424851),
Color(0xC73B4149)
)
)
} else {
Brush.verticalGradient(
colors = listOf(
NuvioColors.BackgroundElevated,
NuvioColors.BackgroundCard
)
)
},
shape = pillShape
)
.border(
width = 1.dp,
color = if (blurEnabled) {
Color.White.copy(alpha = 0.14f)
} else {
NuvioColors.Border.copy(alpha = 0.9f)
},
shape = pillShape
)
) { ) {
Box(
modifier = Modifier
.matchParentSize()
.padding(start = 2.25.dp, top = 2.25.dp, end = 5.dp, bottom = 2.25.dp)
.graphicsLayer {
shape = innerBlurShape
clip = true
compositingStrategy = CompositingStrategy.Offscreen
}
.clip(innerBlurShape)
.then(
if (blurEnabled) {
Modifier.hazeChild(
state = hazeState,
shape = innerBlurShape,
tint = Color.Unspecified,
blurRadius = 3.dp,
noiseFactor = 0f
)
} else {
Modifier
}
)
)
Row( Row(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterStart) .align(Alignment.CenterStart)

View file

@ -89,39 +89,50 @@ internal fun ModernSidebarBlurPanel(
} else { } else {
Modifier Modifier
} }
val bgElevated = NuvioColors.BackgroundElevated
val bgCard = NuvioColors.BackgroundCard
val borderBase = NuvioColors.Border
val panelBackgroundBrush = remember(blurEnabled, bgElevated, bgCard) {
if (blurEnabled) {
Brush.verticalGradient(listOf(Color(0xD64A4F59), Color(0xCC3F454F), Color(0xC640474F)))
} else {
Brush.verticalGradient(listOf(bgElevated, bgCard))
}
}
val panelBorderColor = remember(blurEnabled, borderBase) {
if (blurEnabled) Color.White.copy(alpha = 0.14f) else borderBase.copy(alpha = 0.9f)
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxHeight() .fillMaxHeight()
.graphicsLayer { .graphicsLayer(
val p = sidebarExpandProgress alpha = sidebarExpandProgress,
alpha = p scaleX = 0.97f + (0.03f * sidebarExpandProgress),
val s = 0.97f + (0.03f * p) scaleY = 0.97f + (0.03f * sidebarExpandProgress),
scaleX = s
scaleY = s
transformOrigin = TransformOrigin(0f, 0f) transformOrigin = TransformOrigin(0f, 0f)
} )
.then(expandedPanelBlurModifier) .then(expandedPanelBlurModifier)
.graphicsLayer { .graphicsLayer {
shape = panelShape shape = panelShape
clip = true clip = true
} }
.clip(panelShape) .clip(panelShape)
.background(brush = panelBackgroundBrush, shape = panelShape) .background(
.border(width = 1.dp, color = panelBorderColor, shape = panelShape) brush = if (blurEnabled) {
Brush.verticalGradient(
colors = listOf(
Color(0xD64A4F59),
Color(0xCC3F454F),
Color(0xC640474F)
)
)
} else {
Brush.verticalGradient(
colors = listOf(
NuvioColors.BackgroundElevated,
NuvioColors.BackgroundCard
)
)
},
shape = panelShape
)
.border(
width = 1.dp,
color = if (blurEnabled) {
Color.White.copy(alpha = 0.14f)
} else {
NuvioColors.Border.copy(alpha = 0.9f)
},
shape = panelShape
)
.padding(horizontal = 12.dp, vertical = 14.dp) .padding(horizontal = 12.dp, vertical = 14.dp)
) { ) {
val headerLogoRes = if (isSidebarExpanded) R.drawable.app_logo_wordmark else R.drawable.app_logo_mark val headerLogoRes = if (isSidebarExpanded) R.drawable.app_logo_wordmark else R.drawable.app_logo_mark
@ -251,10 +262,10 @@ private fun SidebarNavigationItem(
.clip(CircleShape) .clip(CircleShape)
.background(iconCircleColor) .background(iconCircleColor)
.padding(6.dp) .padding(6.dp)
.graphicsLayer { .graphicsLayer(
scaleX = iconScale scaleX = iconScale,
scaleY = iconScale scaleY = iconScale
}, ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
when { when {
@ -280,7 +291,7 @@ private fun SidebarNavigationItem(
color = contentColor, color = contentColor,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.graphicsLayer { alpha = labelAlpha }, .graphicsLayer(alpha = labelAlpha),
style = androidx.tv.material3.MaterialTheme.typography.titleLarge, style = androidx.tv.material3.MaterialTheme.typography.titleLarge,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
@ -338,7 +349,7 @@ private fun SidebarProfileItem(
text = profileName, text = profileName,
color = Color.White, color = Color.White,
modifier = Modifier modifier = Modifier
.graphicsLayer { alpha = labelAlpha }, .graphicsLayer(alpha = labelAlpha),
style = androidx.tv.material3.MaterialTheme.typography.titleLarge, style = androidx.tv.material3.MaterialTheme.typography.titleLarge,
maxLines = 1 maxLines = 1
) )

View file

@ -4,6 +4,7 @@ import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -28,6 +29,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
@ -39,7 +41,11 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import com.nuvio.tv.R import com.nuvio.tv.R
@ -81,7 +87,7 @@ fun ContentCard(
onLongPress: (() -> Unit)? = null, onLongPress: (() -> Unit)? = null,
onClick: () -> Unit = {} onClick: () -> Unit = {}
) { ) {
val cardShape = remember(posterCardStyle.cornerRadius) { RoundedCornerShape(posterCardStyle.cornerRadius) } val cardShape = RoundedCornerShape(posterCardStyle.cornerRadius)
val baseCardWidth = when (item.posterShape) { val baseCardWidth = when (item.posterShape) {
PosterShape.POSTER -> posterCardStyle.width PosterShape.POSTER -> posterCardStyle.width
PosterShape.LANDSCAPE -> 260.dp PosterShape.LANDSCAPE -> 260.dp
@ -145,16 +151,12 @@ fun ContentCard(
} }
} }
// Only pay the animation cost on the card that is actually focused/expanding. val animatedCardWidth = if (focusedPosterBackdropExpandEnabled) {
// Unfocused cards snap directly to baseCardWidth — no animation state overhead. val targetCardWidth = if (isBackdropExpanded) expandedCardWidth else baseCardWidth
val animatedCardWidth = when { val width by animateDpAsState(targetValue = targetCardWidth, label = "contentCardWidth")
!focusedPosterBackdropExpandEnabled -> baseCardWidth width
!isFocused && !isBackdropExpanded -> baseCardWidth } else {
else -> { baseCardWidth
val targetCardWidth = if (isBackdropExpanded) expandedCardWidth else baseCardWidth
val width by animateDpAsState(targetValue = targetCardWidth, label = "contentCardWidth")
width
}
} }
val metaTokens = if (isBackdropExpanded) { val metaTokens = if (isBackdropExpanded) {
remember(item.type, item.genres, item.releaseInfo, item.imdbRating) { remember(item.type, item.genres, item.releaseInfo, item.imdbRating) {
@ -196,23 +198,21 @@ fun ContentCard(
} else { } else {
item.poster item.poster
} }
val imageModel = remember(imageUrl, requestWidthPx, requestHeightPx) { val imageModel = remember(context, imageUrl, requestWidthPx, requestHeightPx) {
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(imageUrl) .data(imageUrl)
.crossfade(false) .crossfade(false)
.memoryCacheKey("${imageUrl}_${requestWidthPx}x${requestHeightPx}")
.size(width = requestWidthPx, height = requestHeightPx) .size(width = requestWidthPx, height = requestHeightPx)
.build() .build()
} }
val logoRequestHeightPx = remember(density) { val logoRequestHeightPx = remember(density) {
with(density) { 48.dp.roundToPx() } with(density) { 48.dp.roundToPx() }
} }
val logoModel = remember(item.logo, requestWidthPx, logoRequestHeightPx) { val logoModel = remember(context, item.logo, requestWidthPx, logoRequestHeightPx) {
item.logo?.let { logoUrl -> item.logo?.let { logoUrl ->
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(logoUrl) .data(logoUrl)
.crossfade(false) .crossfade(false)
.memoryCacheKey("${logoUrl}_${requestWidthPx}x${logoRequestHeightPx}")
.size(width = requestWidthPx, height = logoRequestHeightPx) .size(width = requestWidthPx, height = logoRequestHeightPx)
.build() .build()
} }
@ -321,10 +321,9 @@ fun ContentCard(
} }
} }
// Only allocate animation state when trailer is actually playing. val trailerCoverAlpha = if (focusedPosterBackdropTrailerEnabled) {
val trailerCoverAlpha = if (shouldPlayTrailerPreview) {
val alpha by animateFloatAsState( val alpha by animateFloatAsState(
targetValue = if (!trailerFirstFrameRendered) 1f else 0f, targetValue = if (shouldPlayTrailerPreview && !trailerFirstFrameRendered) 1f else 0f,
animationSpec = tween(durationMillis = 250), animationSpec = tween(durationMillis = 250),
label = "trailerCoverAlpha" label = "trailerCoverAlpha"
) )
@ -461,28 +460,43 @@ fun ContentCard(
} }
if (showLabels && !isBackdropExpanded) { if (showLabels && !isBackdropExpanded) {
Column( val textMeasurer = rememberTextMeasurer()
val titleStyle = MaterialTheme.typography.titleMedium.copy(color = NuvioColors.TextPrimary)
val subtitleStyle = MaterialTheme.typography.labelMedium.copy(color = NuvioTheme.extendedColors.textSecondary)
val releaseInfo = item.releaseInfo
val itemName = item.name
val labelHeight = if (releaseInfo != null) 52.dp else 32.dp
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 8.dp) .padding(top = 8.dp)
) { .height(labelHeight)
Text( .drawWithCache {
text = item.name, val widthPx = size.width.toInt()
style = MaterialTheme.typography.titleMedium, val titleLayout = textMeasurer.measure(
color = NuvioColors.TextPrimary, text = itemName,
maxLines = 1, style = titleStyle,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
) maxLines = 1,
item.releaseInfo?.let { info -> constraints = Constraints(maxWidth = widthPx)
Text( )
text = info, val subtitleLayout = if (releaseInfo != null) {
style = MaterialTheme.typography.labelMedium, textMeasurer.measure(
color = NuvioTheme.extendedColors.textSecondary, text = releaseInfo,
maxLines = 1, style = subtitleStyle,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
) maxLines = 1,
} constraints = Constraints(maxWidth = widthPx)
} )
} else null
onDrawBehind {
drawText(titleLayout)
if (subtitleLayout != null) {
drawText(subtitleLayout, topLeft = androidx.compose.ui.geometry.Offset(0f, titleLayout.size.height + 4.dp.toPx()))
}
}
}
)
} }
} }
} }

View file

@ -278,11 +278,10 @@ fun ContinueWatchingCard(
val requestHeightPx = remember(imageHeight, density) { val requestHeightPx = remember(imageHeight, density) {
with(density) { imageHeight.roundToPx() } with(density) { imageHeight.roundToPx() }
} }
val imageRequest = remember(imageModel, requestWidthPx, requestHeightPx) { val imageRequest = remember(context, imageModel, requestWidthPx, requestHeightPx) {
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(imageModel) .data(imageModel)
.crossfade(false) .crossfade(false)
.memoryCacheKey("${imageModel}_${requestWidthPx}x${requestHeightPx}")
.size(width = requestWidthPx, height = requestHeightPx) .size(width = requestWidthPx, height = requestHeightPx)
.build() .build()
} }

View file

@ -21,6 +21,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
@ -30,7 +31,10 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import com.nuvio.tv.R import com.nuvio.tv.R
@ -40,7 +44,6 @@ import androidx.tv.material3.CardDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.nuvio.tv.domain.model.MetaPreview import com.nuvio.tv.domain.model.MetaPreview
import com.nuvio.tv.ui.theme.NuvioColors import com.nuvio.tv.ui.theme.NuvioColors
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -62,7 +65,7 @@ fun GridContentCard(
onLongPress: (() -> Unit)? = null, onLongPress: (() -> Unit)? = null,
onFocused: () -> Unit = {} onFocused: () -> Unit = {}
) { ) {
val cardShape = remember(posterCardStyle.cornerRadius) { RoundedCornerShape(posterCardStyle.cornerRadius) } val cardShape = RoundedCornerShape(posterCardStyle.cornerRadius)
val density = LocalDensity.current val density = LocalDensity.current
val requestWidthPx = remember(density, posterCardStyle.width) { with(density) { posterCardStyle.width.roundToPx() } } val requestWidthPx = remember(density, posterCardStyle.width) { with(density) { posterCardStyle.width.roundToPx() } }
val requestHeightPx = remember(density, posterCardStyle.height) { with(density) { posterCardStyle.height.roundToPx() } } val requestHeightPx = remember(density, posterCardStyle.height) { with(density) { posterCardStyle.height.roundToPx() } }
@ -145,7 +148,7 @@ fun GridContentCard(
.clip(cardShape) .clip(cardShape)
) { ) {
val context = LocalContext.current val context = LocalContext.current
val imageModel = remember(item.poster, requestWidthPx, requestHeightPx) { val imageModel = remember(context, item.poster, requestWidthPx, requestHeightPx) {
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(item.poster) .data(item.poster)
.crossfade(imageCrossfade) .crossfade(imageCrossfade)
@ -190,15 +193,24 @@ fun GridContentCard(
} }
if (showLabel) { if (showLabel) {
Text( val textMeasurer = rememberTextMeasurer()
text = item.name, val titleStyle = MaterialTheme.typography.titleMedium.copy(color = NuvioColors.TextPrimary)
style = MaterialTheme.typography.titleMedium, val itemName = item.name
color = NuvioColors.TextPrimary, Box(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier modifier = Modifier
.width(posterCardStyle.width) .width(posterCardStyle.width)
.padding(top = 8.dp, start = 2.dp, end = 2.dp) .padding(top = 8.dp, start = 2.dp, end = 2.dp)
.height(24.dp)
.drawWithCache {
val layout = textMeasurer.measure(
text = itemName,
style = titleStyle,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
constraints = Constraints(maxWidth = size.width.toInt())
)
onDrawBehind { drawText(layout) }
}
) )
} }
} }

View file

@ -52,7 +52,7 @@ import com.nuvio.tv.domain.model.MetaPreview
import com.nuvio.tv.ui.theme.NuvioColors import com.nuvio.tv.ui.theme.NuvioColors
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
private const val AUTO_ADVANCE_INTERVAL_MS = 10000L private const val AUTO_ADVANCE_INTERVAL_MS = 5000L
private val YEAR_REGEX = Regex("""\b\d{4}\b""") private val YEAR_REGEX = Regex("""\b\d{4}\b""")
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@ -74,10 +74,9 @@ fun HeroCarousel(
items.getOrNull(activeIndex)?.let { onItemFocus(it) } items.getOrNull(activeIndex)?.let { onItemFocus(it) }
} }
// Auto-advance when not focused — delay first advance to 20s so initial GPU load settles // Auto-advance when not focused
LaunchedEffect(isFocused, items.size) { LaunchedEffect(isFocused, items.size) {
if (items.size <= 1) return@LaunchedEffect if (items.size <= 1) return@LaunchedEffect
delay(AUTO_ADVANCE_INTERVAL_MS * 2) // 20s before first advance
while (true) { while (true) {
delay(AUTO_ADVANCE_INTERVAL_MS) delay(AUTO_ADVANCE_INTERVAL_MS)
if (!isFocused) { if (!isFocused) {
@ -123,18 +122,14 @@ fun HeroCarousel(
// Crossfade between slides // Crossfade between slides
Crossfade( Crossfade(
targetState = activeIndex, targetState = activeIndex,
animationSpec = tween(300), animationSpec = tween(500),
label = "heroSlide" label = "heroSlide"
) { index -> ) { index ->
val item = items.getOrNull(index) ?: return@Crossfade val item = items.getOrNull(index) ?: return@Crossfade
HeroCarouselSlide(item = item) HeroCarouselSlide(item = item)
} }
// Indicator dots — pre-compute colors + shape to avoid reallocation per dot // Indicator dots
val focusRing = NuvioColors.FocusRing
val dotColorFocusedInactive = remember(focusRing) { focusRing.copy(alpha = 0.4f) }
val dotColorUnfocusedInactive = remember { Color.White.copy(alpha = 0.3f) }
val dotShape = remember { RoundedCornerShape(3.dp) }
Row( Row(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
@ -153,13 +148,13 @@ fun HeroCarousel(
modifier = Modifier modifier = Modifier
.width(dotWidth) .width(dotWidth)
.height(dotHeight) .height(dotHeight)
.clip(dotShape) .clip(RoundedCornerShape(3.dp))
.background( .background(
when { when {
isFocused && isActive -> focusRing isFocused && isActive -> NuvioColors.FocusRing
isFocused -> dotColorFocusedInactive isFocused -> NuvioColors.FocusRing.copy(alpha = 0.4f)
isActive -> focusRing isActive -> NuvioColors.FocusRing
else -> dotColorUnfocusedInactive else -> Color.White.copy(alpha = 0.3f)
} }
) )
) )
@ -226,6 +221,7 @@ private fun HeroCarouselSlide(
Box( Box(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
// Background image
// Background image // Background image
AsyncImage( AsyncImage(
model = backgroundModel, model = backgroundModel,

View file

@ -1,28 +0,0 @@
package com.nuvio.tv.ui.components
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.spring
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.BringIntoViewSpec
@OptIn(ExperimentalFoundationApi::class)
object NuvioScrollDefaults {
val smoothScrollSpec = object : BringIntoViewSpec {
@Suppress("DEPRECATION")
override val scrollAnimationSpec: AnimationSpec<Float> = spring(
dampingRatio = 0.83f,
stiffness = 150f
)
override fun calculateScrollDistance(
offset: Float,
size: Float,
containerSize: Float
): Float {
if (containerSize <= 0f || size <= 0f) return 0f
val itemCenter = offset + size / 2f
val viewportTarget = containerSize * 0.42f
return itemCenter - viewportTarget
}
}
}

View file

@ -1,12 +1,9 @@
package com.nuvio.tv.ui.screens.home package com.nuvio.tv.ui.screens.home
import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListPrefetchStrategy
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -20,7 +17,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import com.nuvio.tv.domain.model.MetaPreview import com.nuvio.tv.domain.model.MetaPreview
@ -29,15 +25,12 @@ import com.nuvio.tv.ui.components.ContinueWatchingSection
import com.nuvio.tv.ui.components.HeroCarousel import com.nuvio.tv.ui.components.HeroCarousel
import com.nuvio.tv.ui.components.PosterCardStyle import com.nuvio.tv.ui.components.PosterCardStyle
/** Minimum interval between processed key repeat events to prevent HWUI overload. */
private const val KEY_REPEAT_THROTTLE_MS = 80L
private class FocusSnapshot( private class FocusSnapshot(
var rowIndex: Int, var rowIndex: Int,
var itemIndex: Int var itemIndex: Int
) )
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun ClassicHomeContent( fun ClassicHomeContent(
uiState: HomeUiState, uiState: HomeUiState,
@ -55,15 +48,9 @@ fun ClassicHomeContent(
onSaveFocusState: (Int, Int, Int, Int, Map<String, Int>) -> Unit onSaveFocusState: (Int, Int, Int, Int, Map<String, Int>) -> Unit
) { ) {
// Nested prefetch: when LazyColumn prefetches a row ahead of scrolling,
// pre-compose up to 2 ContentCards in its nested LazyRow across multiple frames.
// This spreads the composition work and prevents frame spikes when a new row scrolls in.
val nestedPrefetchStrategy = remember { LazyListPrefetchStrategy(nestedPrefetchItemCount = 2) }
val columnListState = rememberLazyListState( val columnListState = rememberLazyListState(
initialFirstVisibleItemIndex = focusState.verticalScrollIndex, initialFirstVisibleItemIndex = focusState.verticalScrollIndex,
initialFirstVisibleItemScrollOffset = focusState.verticalScrollOffset, initialFirstVisibleItemScrollOffset = focusState.verticalScrollOffset
prefetchStrategy = nestedPrefetchStrategy
) )
LaunchedEffect(focusState.verticalScrollIndex, focusState.verticalScrollOffset) { LaunchedEffect(focusState.verticalScrollIndex, focusState.verticalScrollOffset) {
@ -135,24 +122,9 @@ fun ClassicHomeContent(
} }
} }
// Throttle D-pad key repeats to prevent HWUI overload when a key is held down.
var lastKeyRepeatTime by remember { mutableStateOf(0L) }
LazyColumn( LazyColumn(
state = columnListState, state = columnListState,
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
if (native.action == AndroidKeyEvent.ACTION_DOWN && native.repeatCount > 0) {
val now = System.currentTimeMillis()
if (now - lastKeyRepeatTime < KEY_REPEAT_THROTTLE_MS) {
return@onPreviewKeyEvent true // consume — too fast
}
lastKeyRepeatTime = now
}
false
},
contentPadding = PaddingValues(top = if (heroVisible) 0.dp else 24.dp, bottom = 24.dp), contentPadding = PaddingValues(top = if (heroVisible) 0.dp else 24.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(32.dp) verticalArrangement = Arrangement.spacedBy(32.dp)
) { ) {
@ -280,6 +252,8 @@ fun ClassicHomeContent(
if (restoringFocus) restoringFocus = false if (restoringFocus) restoringFocus = false
currentFocusSnapshot.rowIndex = index currentFocusSnapshot.rowIndex = index
currentFocusSnapshot.itemIndex = itemIndex currentFocusSnapshot.itemIndex = itemIndex
// Update the state as well, though getOrPut handles creation
// rowStates[catalogKey] already holds the live state object
} }
) )
} }

View file

@ -1,11 +1,9 @@
package com.nuvio.tv.ui.screens.home package com.nuvio.tv.ui.screens.home
import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -33,7 +31,6 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.nuvio.tv.R import com.nuvio.tv.R
@ -57,10 +54,7 @@ import com.nuvio.tv.ui.components.PosterCardDefaults
import com.nuvio.tv.ui.components.PosterCardStyle import com.nuvio.tv.ui.components.PosterCardStyle
import com.nuvio.tv.ui.theme.NuvioColors import com.nuvio.tv.ui.theme.NuvioColors
/** Minimum interval between processed key repeat events to prevent HWUI overload. */ @OptIn(ExperimentalTvMaterial3Api::class)
private const val KEY_REPEAT_THROTTLE_MS = 80L
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun GridHomeContent( fun GridHomeContent(
uiState: HomeUiState, uiState: HomeUiState,
@ -154,26 +148,11 @@ fun GridHomeContent(
} }
} }
// Throttle D-pad key repeats to prevent HWUI overload when a key is held down.
val lastKeyRepeatTime = remember { longArrayOf(0L) }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
LazyVerticalGrid( LazyVerticalGrid(
state = gridState, state = gridState,
columns = GridCells.Adaptive(minSize = posterCardStyle.width), columns = GridCells.Adaptive(minSize = posterCardStyle.width),
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
if (native.action == AndroidKeyEvent.ACTION_DOWN && native.repeatCount > 0) {
val now = System.currentTimeMillis()
if (now - lastKeyRepeatTime[0] < KEY_REPEAT_THROTTLE_MS) {
return@onPreviewKeyEvent true
}
lastKeyRepeatTime[0] = now
}
false
},
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = 24.dp, start = 24.dp,
end = 24.dp, end = 24.dp,

View file

@ -95,12 +95,8 @@ fun HomeScreen(
.fillMaxSize() .fillMaxSize()
.background(NuvioColors.Background) .background(NuvioColors.Background)
) { ) {
val hasAnyContent = uiState.catalogRows.isNotEmpty() ||
uiState.continueWatchingItems.isNotEmpty() ||
uiState.heroItems.isNotEmpty()
when { when {
uiState.isLoading && !hasAnyContent -> { uiState.isLoading && uiState.catalogRows.isEmpty() -> {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center

View file

@ -41,7 +41,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -63,7 +62,6 @@ import java.time.format.DateTimeFormatter
import javax.inject.Inject import javax.inject.Inject
import java.util.Locale import java.util.Locale
@OptIn(kotlinx.coroutines.FlowPreview::class)
@HiltViewModel @HiltViewModel
class HomeViewModel @Inject constructor( class HomeViewModel @Inject constructor(
private val addonRepository: AddonRepository, private val addonRepository: AddonRepository,
@ -123,7 +121,6 @@ class HomeViewModel @Inject constructor(
private var activeTrailerPreviewItemId: String? = null private var activeTrailerPreviewItemId: String? = null
private var trailerPreviewRequestVersion: Long = 0L private var trailerPreviewRequestVersion: Long = 0L
private var currentTmdbSettings: TmdbSettings = TmdbSettings() private var currentTmdbSettings: TmdbSettings = TmdbSettings()
private var heroEnrichmentJob: Job? = null
private var lastHeroEnrichmentSignature: String? = null private var lastHeroEnrichmentSignature: String? = null
private var lastHeroEnrichedItems: List<MetaPreview> = emptyList() private var lastHeroEnrichedItems: List<MetaPreview> = emptyList()
private val prefetchedExternalMetaIds = Collections.synchronizedSet(mutableSetOf<String>()) private val prefetchedExternalMetaIds = Collections.synchronizedSet(mutableSetOf<String>())
@ -134,8 +131,6 @@ class HomeViewModel @Inject constructor(
private val movieWatchedObserverJobs = mutableMapOf<String, Job>() private val movieWatchedObserverJobs = mutableMapOf<String, Job>()
@Volatile @Volatile
private var externalMetaPrefetchEnabled: Boolean = false private var externalMetaPrefetchEnabled: Boolean = false
@Volatile
private var startupGracePeriodActive: Boolean = true
val trailerPreviewUrls: Map<String, String> val trailerPreviewUrls: Map<String, String>
get() = trailerPreviewUrlsState get() = trailerPreviewUrlsState
@ -147,10 +142,6 @@ class HomeViewModel @Inject constructor(
observeTmdbSettings() observeTmdbSettings()
loadContinueWatching() loadContinueWatching()
observeInstalledAddons() observeInstalledAddons()
viewModelScope.launch {
delay(3000)
startupGracePeriodActive = false
}
} }
private fun observeLayoutPreferences() { private fun observeLayoutPreferences() {
@ -230,7 +221,6 @@ class HomeViewModel @Inject constructor(
) )
} }
.distinctUntilChanged() .distinctUntilChanged()
.debounce(100)
.collectLatest { prefs -> .collectLatest { prefs ->
val effectivePosterLabelsEnabled = if (prefs.layout == HomeLayout.MODERN) { val effectivePosterLabelsEnabled = if (prefs.layout == HomeLayout.MODERN) {
false false
@ -334,7 +324,6 @@ class HomeViewModel @Inject constructor(
releaseInfo: String?, releaseInfo: String?,
apiType: String apiType: String
) { ) {
if (startupGracePeriodActive) return
if (activeTrailerPreviewItemId != itemId) { if (activeTrailerPreviewItemId != itemId) {
activeTrailerPreviewItemId = itemId activeTrailerPreviewItemId = itemId
trailerPreviewRequestVersion++ trailerPreviewRequestVersion++
@ -374,7 +363,6 @@ class HomeViewModel @Inject constructor(
} }
fun onItemFocus(item: MetaPreview) { fun onItemFocus(item: MetaPreview) {
if (startupGracePeriodActive) return
if (!externalMetaPrefetchEnabled) return if (!externalMetaPrefetchEnabled) return
if (item.id in prefetchedExternalMetaIds) return if (item.id in prefetchedExternalMetaIds) return
if (pendingExternalMetaPrefetchItemId == item.id) return if (pendingExternalMetaPrefetchItemId == item.id) return
@ -1335,17 +1323,29 @@ class HomeViewModel @Inject constructor(
private fun scheduleUpdateCatalogRows() { private fun scheduleUpdateCatalogRows() {
catalogUpdateJob?.cancel() catalogUpdateJob?.cancel()
catalogUpdateJob = viewModelScope.launch { catalogUpdateJob = viewModelScope.launch {
val debounceMs = when { val currentLayout = _uiState.value.homeLayout
// First render: use minimal debounce to show content ASAP while still val preferSmootherBatching = currentLayout == HomeLayout.CLASSIC || currentLayout == HomeLayout.GRID
// batching near-simultaneous arrivals. val loadedContentRowCount = catalogsMap.values.count { it.items.isNotEmpty() }
!hasRenderedFirstCatalog && catalogsMap.isNotEmpty() -> {
if (!hasRenderedFirstCatalog && catalogsMap.isNotEmpty()) {
val shouldHoldFirstRenderForBatch =
preferSmootherBatching &&
pendingCatalogLoads > 0 &&
loadedContentRowCount in 0..2
if (!shouldHoldFirstRenderForBatch) {
hasRenderedFirstCatalog = true hasRenderedFirstCatalog = true
50L updateCatalogRows()
return@launch
} }
pendingCatalogLoads > 8 -> 200L }
pendingCatalogLoads > 3 -> 150L val debounceMs = when {
pendingCatalogLoads > 0 -> 100L preferSmootherBatching && pendingCatalogLoads > 5 -> 650L
else -> 50L preferSmootherBatching && pendingCatalogLoads > 0 -> 380L
pendingCatalogLoads > 5 -> 450L
pendingCatalogLoads > 0 -> 250L
else -> 100L
} }
delay(debounceMs) delay(debounceMs)
updateCatalogRows() updateCatalogRows()
@ -1471,9 +1471,28 @@ class HomeViewModel @Inject constructor(
if (rows == fullRows) rows else fullRows if (rows == fullRows) rows else fullRows
} }
// Emit un-enriched hero items immediately so the UI renders without waiting for TMDB val tmdbSettings = currentTmdbSettings
val shouldUseEnrichedHeroItems = tmdbSettings.enabled &&
(tmdbSettings.useArtwork || tmdbSettings.useBasicInfo || tmdbSettings.useDetails)
val resolvedHeroItems = if (shouldUseEnrichedHeroItems) {
val enrichmentSignature = heroEnrichmentSignature(baseHeroItems, tmdbSettings)
if (lastHeroEnrichmentSignature == enrichmentSignature) {
lastHeroEnrichedItems
} else {
val enrichedItems = enrichHeroItems(baseHeroItems, tmdbSettings)
lastHeroEnrichmentSignature = enrichmentSignature
lastHeroEnrichedItems = enrichedItems
enrichedItems
}
} else {
lastHeroEnrichmentSignature = null
lastHeroEnrichedItems = emptyList()
baseHeroItems
}
val nextGridItems = if (currentLayout == HomeLayout.GRID) { val nextGridItems = if (currentLayout == HomeLayout.GRID) {
replaceGridHeroItems(baseGridItems, baseHeroItems) replaceGridHeroItems(baseGridItems, resolvedHeroItems)
} else { } else {
baseGridItems baseGridItems
} }
@ -1482,53 +1501,12 @@ class HomeViewModel @Inject constructor(
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
catalogRows = if (state.catalogRows == displayRows) state.catalogRows else displayRows, catalogRows = if (state.catalogRows == displayRows) state.catalogRows else displayRows,
heroItems = if (state.heroItems == baseHeroItems) state.heroItems else baseHeroItems, heroItems = if (state.heroItems == resolvedHeroItems) state.heroItems else resolvedHeroItems,
gridItems = if (state.gridItems == nextGridItems) state.gridItems else nextGridItems, gridItems = if (state.gridItems == nextGridItems) state.gridItems else nextGridItems,
isLoading = false isLoading = false
) )
} }
// Launch hero enrichment in the background — updates heroItems when done
val tmdbSettings = currentTmdbSettings
val shouldUseEnrichedHeroItems = tmdbSettings.enabled &&
(tmdbSettings.useArtwork || tmdbSettings.useBasicInfo || tmdbSettings.useDetails)
if (shouldUseEnrichedHeroItems && baseHeroItems.isNotEmpty()) {
heroEnrichmentJob?.cancel()
heroEnrichmentJob = viewModelScope.launch {
val enrichmentSignature = heroEnrichmentSignature(baseHeroItems, tmdbSettings)
if (lastHeroEnrichmentSignature == enrichmentSignature) {
// Already enriched with same signature — apply cached result
val cached = lastHeroEnrichedItems
_uiState.update { state ->
state.copy(
heroItems = if (state.heroItems == cached) state.heroItems else cached,
gridItems = if (currentLayout == HomeLayout.GRID) {
val enrichedGrid = replaceGridHeroItems(state.gridItems, cached)
if (state.gridItems == enrichedGrid) state.gridItems else enrichedGrid
} else state.gridItems
)
}
} else {
val enrichedItems = enrichHeroItems(baseHeroItems, tmdbSettings)
lastHeroEnrichmentSignature = enrichmentSignature
lastHeroEnrichedItems = enrichedItems
_uiState.update { state ->
state.copy(
heroItems = if (state.heroItems == enrichedItems) state.heroItems else enrichedItems,
gridItems = if (currentLayout == HomeLayout.GRID) {
val enrichedGrid = replaceGridHeroItems(state.gridItems, enrichedItems)
if (state.gridItems == enrichedGrid) state.gridItems else enrichedGrid
} else state.gridItems
)
}
}
}
} else {
lastHeroEnrichmentSignature = null
lastHeroEnrichedItems = emptyList()
}
reconcilePosterStatusObservers(displayRows) reconcilePosterStatusObservers(displayRows)
} }

View file

@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width

View file

@ -334,6 +334,10 @@ fun SettingsScreen(
.fillMaxHeight() .fillMaxHeight()
.onKeyEvent { event -> .onKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionLeft) { if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionLeft) {
val movedWithinContent = focusManager.moveFocus(FocusDirection.Left)
if (movedWithinContent) {
return@onKeyEvent true
}
allowDetailAutofocus = false allowDetailAutofocus = false
val requested = railFocusRequesters[selectedCategory]?.let { requester -> val requested = railFocusRequesters[selectedCategory]?.let { requester ->
runCatching { requester.requestFocus() }.isSuccess runCatching { requester.requestFocus() }.isSuccess

View file

@ -4,11 +4,11 @@ cryptoJs = "4.2.0"
kotlin = "2.3.0" kotlin = "2.3.0"
coreKtx = "1.15.0" coreKtx = "1.15.0"
appcompat = "1.6.1" appcompat = "1.6.1"
composeBom = "2026.01.01" composeBom = "2025.01.01"
tvFoundation = "1.0.0-alpha11" tvFoundation = "1.0.0-alpha11"
tvMaterial = "1.0.0" tvMaterial = "1.1.0-alpha01"
lifecycleRuntimeKtx = "2.8.7" lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.11.0" activityCompose = "1.10.1"
# New dependencies # New dependencies
hilt = "2.58" hilt = "2.58"

View file

@ -21,5 +21,5 @@ dependencyResolutionManagement {
rootProject.name = "My Application" rootProject.name = "My Application"
include(":app") include(":app")
// include(":benchmark") // TODO: create when ready include(":benchmark")