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 {
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("androidx.core:core-splashscreen:1.0.1")
implementation(libs.androidx.appcompat)
@ -153,7 +154,7 @@ dependencies {
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.foundation:foundation")
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("androidx.activity:activity-compose:1.11.0")
@ -242,10 +243,6 @@ dependencies {
// Kotlinx Serialization
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
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.content.Context
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.compose.BackHandler
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.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
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.border
import androidx.compose.foundation.clickable
@ -54,7 +47,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.ui.navigation.NuvioNavHost
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.screens.account.AuthQrSignInScreen
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 dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import javax.inject.Inject
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
@ -174,9 +165,7 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var appOnboardingDataStore: AppOnboardingDataStore
private lateinit var jankStats: JankStats
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
@OptIn(ExperimentalTvMaterial3Api::class)
override fun attachBaseContext(newBase: Context) {
val tag = newBase.getSharedPreferences("app_locale", Context.MODE_PRIVATE)
.getString("locale_tag", null)
@ -191,7 +180,6 @@ class MainActivity : ComponentActivity() {
}
}
@OptIn(ExperimentalFoundationApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
@ -238,9 +226,6 @@ class MainActivity : ComponentActivity() {
val mainUiPrefs by mainUiPrefsFlow.collectAsState(initial = MainUiPrefs(hasChosenLayout = null))
NuvioTheme(appTheme = mainUiPrefs.theme) {
CompositionLocalProvider(
LocalBringIntoViewSpec provides NuvioScrollDefaults.smoothScrollSpec
) {
Surface(
modifier = Modifier.fillMaxSize(),
shape = RectangleShape
@ -338,14 +323,6 @@ class MainActivity : ComponentActivity() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
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 {
setOf(
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() {
@ -869,8 +826,11 @@ private fun ModernSidebarScaffold(
animationSpec = tween(durationMillis = 135, easing = FastOutSlowInEasing),
label = "sidebarSurfaceAlpha"
)
val shouldApplySidebarHaze = showSidebar && modernSidebarBlurEnabled && (
isSidebarExpanded || sidebarCollapsePending
val shouldApplySidebarHaze = showSidebar && (
sidebarVisible ||
isSidebarExpanded ||
sidebarCollapsePending ||
sidebarWidth > 0.dp
)
val sidebarTransition = updateTransition(
targetState = isSidebarExpanded,
@ -900,12 +860,6 @@ private fun ModernSidebarScaffold(
) { expanded ->
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(
transitionSpec = { tween(durationMillis = 145, easing = FastOutSlowInEasing) },
label = "sidebarIconScale"
@ -990,7 +944,7 @@ private fun ModernSidebarScaffold(
if (
isSidebarExpanded &&
!sidebarCollapsePending &&
sidebarBlocksContentKeys &&
sidebarExpandProgress > 0.2f &&
keyEvent.type == KeyEventType.KeyDown &&
isBlockedContentKey(keyEvent.key)
) {
@ -1034,25 +988,20 @@ private fun ModernSidebarScaffold(
if (showSidebar && (sidebarVisible || sidebarWidth > 0.dp)) {
val panelShape = RoundedCornerShape(30.dp)
val showExpandedPanel = isSidebarExpanded || sidebarShowExpandedPanel
val showExpandedPanel = isSidebarExpanded || sidebarExpandProgress > 0.01f
Box(
modifier = Modifier
.align(Alignment.TopStart)
.width(sidebarWidth)
.padding(start = 14.dp, top = 16.dp, bottom = 12.dp, end = 8.dp)
.offset {
IntOffset(
(sidebarSlideX + sidebarDeflateOffsetX).roundToPx(),
sidebarDeflateOffsetY.roundToPx()
)
}
.graphicsLayer {
alpha = sidebarSurfaceAlpha
scaleX = sidebarBloomScale
scaleY = sidebarBloomScale
.offset(x = sidebarSlideX + sidebarDeflateOffsetX, y = sidebarDeflateOffsetY)
.graphicsLayer(
alpha = sidebarSurfaceAlpha,
scaleX = sidebarBloomScale,
scaleY = sidebarBloomScale,
transformOrigin = TransformOrigin(0f, 0f)
}
)
.selectableGroup()
.onPreviewKeyEvent { keyEvent ->
if (!isSidebarExpanded || keyEvent.type != KeyEventType.KeyDown) {
@ -1112,7 +1061,7 @@ private fun ModernSidebarScaffold(
if (
!sidebarCollapsed &&
sidebarShowCollapsedPill &&
sidebarExpandProgress < 0.98f &&
selectedDrawerRoute != Screen.Search.route
) {
CollapsedSidebarPill(
@ -1120,23 +1069,20 @@ private fun ModernSidebarScaffold(
iconRes = selectedDrawerItem.iconRes,
icon = selectedDrawerItem.icon,
iconOnly = isFloatingPillIconOnly && !keepFloatingPillExpanded,
hazeState = sidebarHazeState,
blurEnabled = modernSidebarBlurEnabled,
modifier = Modifier
.align(Alignment.TopStart)
.offset {
IntOffset(
(40.dp + sidebarSlideX + sidebarDeflateOffsetX).roundToPx(),
(16.dp + sidebarDeflateOffsetY).roundToPx()
)
}
.graphicsLayer {
val progress = sidebarExpandProgress
alpha = 1f - progress
val s = 0.9f + (0.1f * (1f - progress))
scaleX = s
scaleY = s
.offset(
x = 40.dp + sidebarSlideX + sidebarDeflateOffsetX,
y = 16.dp + sidebarDeflateOffsetY
)
.graphicsLayer(
alpha = 1f - sidebarExpandProgress,
scaleX = 0.9f + (0.1f * (1f - sidebarExpandProgress)),
scaleY = 0.9f + (0.1f * (1f - sidebarExpandProgress)),
transformOrigin = TransformOrigin(0f, 0f)
},
),
onExpand = {
isSidebarExpanded = true
sidebarCollapsePending = false
@ -1154,24 +1100,13 @@ private fun CollapsedSidebarPill(
iconRes: Int?,
icon: ImageVector?,
iconOnly: Boolean,
hazeState: HazeState,
blurEnabled: Boolean,
modifier: Modifier = Modifier,
onExpand: () -> Unit
) {
val pillShape = RoundedCornerShape(999.dp)
val bgElevated = NuvioColors.BackgroundElevated
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)
}
val innerBlurShape = RoundedCornerShape(999.dp)
Row(
modifier = modifier
@ -1202,9 +1137,59 @@ private fun CollapsedSidebarPill(
compositingStrategy = CompositingStrategy.Offscreen
}
.clip(pillShape)
.background(brush = pillBackgroundBrush, shape = pillShape)
.border(width = 1.dp, color = pillBorderColor, shape = pillShape)
.background(
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(
modifier = Modifier
.align(Alignment.CenterStart)

View file

@ -89,39 +89,50 @@ internal fun ModernSidebarBlurPanel(
} else {
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(
modifier = Modifier
.fillMaxHeight()
.graphicsLayer {
val p = sidebarExpandProgress
alpha = p
val s = 0.97f + (0.03f * p)
scaleX = s
scaleY = s
.graphicsLayer(
alpha = sidebarExpandProgress,
scaleX = 0.97f + (0.03f * sidebarExpandProgress),
scaleY = 0.97f + (0.03f * sidebarExpandProgress),
transformOrigin = TransformOrigin(0f, 0f)
}
)
.then(expandedPanelBlurModifier)
.graphicsLayer {
shape = panelShape
clip = true
}
.clip(panelShape)
.background(brush = panelBackgroundBrush, shape = panelShape)
.border(width = 1.dp, color = panelBorderColor, shape = panelShape)
.background(
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)
) {
val headerLogoRes = if (isSidebarExpanded) R.drawable.app_logo_wordmark else R.drawable.app_logo_mark
@ -251,10 +262,10 @@ private fun SidebarNavigationItem(
.clip(CircleShape)
.background(iconCircleColor)
.padding(6.dp)
.graphicsLayer {
scaleX = iconScale
.graphicsLayer(
scaleX = iconScale,
scaleY = iconScale
},
),
contentAlignment = Alignment.Center
) {
when {
@ -280,7 +291,7 @@ private fun SidebarNavigationItem(
color = contentColor,
modifier = Modifier
.weight(1f)
.graphicsLayer { alpha = labelAlpha },
.graphicsLayer(alpha = labelAlpha),
style = androidx.tv.material3.MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@ -338,7 +349,7 @@ private fun SidebarProfileItem(
text = profileName,
color = Color.White,
modifier = Modifier
.graphicsLayer { alpha = labelAlpha },
.graphicsLayer(alpha = labelAlpha),
style = androidx.tv.material3.MaterialTheme.typography.titleLarge,
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.filled.CheckCircle
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
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.Modifier
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.onFocusChanged
@ -39,7 +41,11 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalContext
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.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.nuvio.tv.R
@ -81,7 +87,7 @@ fun ContentCard(
onLongPress: (() -> Unit)? = null,
onClick: () -> Unit = {}
) {
val cardShape = remember(posterCardStyle.cornerRadius) { RoundedCornerShape(posterCardStyle.cornerRadius) }
val cardShape = RoundedCornerShape(posterCardStyle.cornerRadius)
val baseCardWidth = when (item.posterShape) {
PosterShape.POSTER -> posterCardStyle.width
PosterShape.LANDSCAPE -> 260.dp
@ -145,16 +151,12 @@ fun ContentCard(
}
}
// Only pay the animation cost on the card that is actually focused/expanding.
// Unfocused cards snap directly to baseCardWidth — no animation state overhead.
val animatedCardWidth = when {
!focusedPosterBackdropExpandEnabled -> baseCardWidth
!isFocused && !isBackdropExpanded -> baseCardWidth
else -> {
val targetCardWidth = if (isBackdropExpanded) expandedCardWidth else baseCardWidth
val width by animateDpAsState(targetValue = targetCardWidth, label = "contentCardWidth")
width
}
val animatedCardWidth = if (focusedPosterBackdropExpandEnabled) {
val targetCardWidth = if (isBackdropExpanded) expandedCardWidth else baseCardWidth
val width by animateDpAsState(targetValue = targetCardWidth, label = "contentCardWidth")
width
} else {
baseCardWidth
}
val metaTokens = if (isBackdropExpanded) {
remember(item.type, item.genres, item.releaseInfo, item.imdbRating) {
@ -196,23 +198,21 @@ fun ContentCard(
} else {
item.poster
}
val imageModel = remember(imageUrl, requestWidthPx, requestHeightPx) {
val imageModel = remember(context, imageUrl, requestWidthPx, requestHeightPx) {
ImageRequest.Builder(context)
.data(imageUrl)
.crossfade(false)
.memoryCacheKey("${imageUrl}_${requestWidthPx}x${requestHeightPx}")
.size(width = requestWidthPx, height = requestHeightPx)
.build()
}
val logoRequestHeightPx = remember(density) {
with(density) { 48.dp.roundToPx() }
}
val logoModel = remember(item.logo, requestWidthPx, logoRequestHeightPx) {
val logoModel = remember(context, item.logo, requestWidthPx, logoRequestHeightPx) {
item.logo?.let { logoUrl ->
ImageRequest.Builder(context)
.data(logoUrl)
.crossfade(false)
.memoryCacheKey("${logoUrl}_${requestWidthPx}x${logoRequestHeightPx}")
.size(width = requestWidthPx, height = logoRequestHeightPx)
.build()
}
@ -321,10 +321,9 @@ fun ContentCard(
}
}
// Only allocate animation state when trailer is actually playing.
val trailerCoverAlpha = if (shouldPlayTrailerPreview) {
val trailerCoverAlpha = if (focusedPosterBackdropTrailerEnabled) {
val alpha by animateFloatAsState(
targetValue = if (!trailerFirstFrameRendered) 1f else 0f,
targetValue = if (shouldPlayTrailerPreview && !trailerFirstFrameRendered) 1f else 0f,
animationSpec = tween(durationMillis = 250),
label = "trailerCoverAlpha"
)
@ -461,28 +460,43 @@ fun ContentCard(
}
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
.fillMaxWidth()
.padding(top = 8.dp)
) {
Text(
text = item.name,
style = MaterialTheme.typography.titleMedium,
color = NuvioColors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
item.releaseInfo?.let { info ->
Text(
text = info,
style = MaterialTheme.typography.labelMedium,
color = NuvioTheme.extendedColors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
.height(labelHeight)
.drawWithCache {
val widthPx = size.width.toInt()
val titleLayout = textMeasurer.measure(
text = itemName,
style = titleStyle,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
constraints = Constraints(maxWidth = widthPx)
)
val subtitleLayout = if (releaseInfo != null) {
textMeasurer.measure(
text = releaseInfo,
style = subtitleStyle,
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) {
with(density) { imageHeight.roundToPx() }
}
val imageRequest = remember(imageModel, requestWidthPx, requestHeightPx) {
val imageRequest = remember(context, imageModel, requestWidthPx, requestHeightPx) {
ImageRequest.Builder(context)
.data(imageModel)
.crossfade(false)
.memoryCacheKey("${imageModel}_${requestWidthPx}x${requestHeightPx}")
.size(width = requestWidthPx, height = requestHeightPx)
.build()
}

View file

@ -21,6 +21,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
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.platform.LocalDensity
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.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.nuvio.tv.R
@ -40,7 +44,6 @@ import androidx.tv.material3.CardDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.nuvio.tv.domain.model.MetaPreview
import com.nuvio.tv.ui.theme.NuvioColors
import androidx.compose.ui.platform.LocalContext
@ -62,7 +65,7 @@ fun GridContentCard(
onLongPress: (() -> Unit)? = null,
onFocused: () -> Unit = {}
) {
val cardShape = remember(posterCardStyle.cornerRadius) { RoundedCornerShape(posterCardStyle.cornerRadius) }
val cardShape = RoundedCornerShape(posterCardStyle.cornerRadius)
val density = LocalDensity.current
val requestWidthPx = remember(density, posterCardStyle.width) { with(density) { posterCardStyle.width.roundToPx() } }
val requestHeightPx = remember(density, posterCardStyle.height) { with(density) { posterCardStyle.height.roundToPx() } }
@ -145,7 +148,7 @@ fun GridContentCard(
.clip(cardShape)
) {
val context = LocalContext.current
val imageModel = remember(item.poster, requestWidthPx, requestHeightPx) {
val imageModel = remember(context, item.poster, requestWidthPx, requestHeightPx) {
ImageRequest.Builder(context)
.data(item.poster)
.crossfade(imageCrossfade)
@ -190,15 +193,24 @@ fun GridContentCard(
}
if (showLabel) {
Text(
text = item.name,
style = MaterialTheme.typography.titleMedium,
color = NuvioColors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
val textMeasurer = rememberTextMeasurer()
val titleStyle = MaterialTheme.typography.titleMedium.copy(color = NuvioColors.TextPrimary)
val itemName = item.name
Box(
modifier = Modifier
.width(posterCardStyle.width)
.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 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""")
@OptIn(ExperimentalTvMaterial3Api::class)
@ -74,10 +74,9 @@ fun HeroCarousel(
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) {
if (items.size <= 1) return@LaunchedEffect
delay(AUTO_ADVANCE_INTERVAL_MS * 2) // 20s before first advance
while (true) {
delay(AUTO_ADVANCE_INTERVAL_MS)
if (!isFocused) {
@ -123,18 +122,14 @@ fun HeroCarousel(
// Crossfade between slides
Crossfade(
targetState = activeIndex,
animationSpec = tween(300),
animationSpec = tween(500),
label = "heroSlide"
) { index ->
val item = items.getOrNull(index) ?: return@Crossfade
HeroCarouselSlide(item = item)
}
// Indicator dots — pre-compute colors + shape to avoid reallocation per dot
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) }
// Indicator dots
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
@ -153,13 +148,13 @@ fun HeroCarousel(
modifier = Modifier
.width(dotWidth)
.height(dotHeight)
.clip(dotShape)
.clip(RoundedCornerShape(3.dp))
.background(
when {
isFocused && isActive -> focusRing
isFocused -> dotColorFocusedInactive
isActive -> focusRing
else -> dotColorUnfocusedInactive
isFocused && isActive -> NuvioColors.FocusRing
isFocused -> NuvioColors.FocusRing.copy(alpha = 0.4f)
isActive -> NuvioColors.FocusRing
else -> Color.White.copy(alpha = 0.3f)
}
)
)
@ -226,6 +221,7 @@ private fun HeroCarouselSlide(
Box(
modifier = Modifier.fillMaxSize()
) {
// Background image
// Background image
AsyncImage(
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
import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListPrefetchStrategy
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@ -20,7 +17,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ExperimentalTvMaterial3Api
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.PosterCardStyle
/** Minimum interval between processed key repeat events to prevent HWUI overload. */
private const val KEY_REPEAT_THROTTLE_MS = 80L
private class FocusSnapshot(
var rowIndex: Int,
var itemIndex: Int
)
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun ClassicHomeContent(
uiState: HomeUiState,
@ -55,15 +48,9 @@ fun ClassicHomeContent(
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(
initialFirstVisibleItemIndex = focusState.verticalScrollIndex,
initialFirstVisibleItemScrollOffset = focusState.verticalScrollOffset,
prefetchStrategy = nestedPrefetchStrategy
initialFirstVisibleItemScrollOffset = 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(
state = columnListState,
modifier = Modifier
.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
},
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = if (heroVisible) 0.dp else 24.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(32.dp)
) {
@ -280,6 +252,8 @@ fun ClassicHomeContent(
if (restoringFocus) restoringFocus = false
currentFocusSnapshot.rowIndex = index
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
import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
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.theme.NuvioColors
/** Minimum interval between processed key repeat events to prevent HWUI overload. */
private const val KEY_REPEAT_THROTTLE_MS = 80L
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun GridHomeContent(
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()) {
LazyVerticalGrid(
state = gridState,
columns = GridCells.Adaptive(minSize = posterCardStyle.width),
modifier = Modifier
.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
},
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 24.dp,
end = 24.dp,

View file

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

View file

@ -41,7 +41,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
@ -63,7 +62,6 @@ import java.time.format.DateTimeFormatter
import javax.inject.Inject
import java.util.Locale
@OptIn(kotlinx.coroutines.FlowPreview::class)
@HiltViewModel
class HomeViewModel @Inject constructor(
private val addonRepository: AddonRepository,
@ -123,7 +121,6 @@ class HomeViewModel @Inject constructor(
private var activeTrailerPreviewItemId: String? = null
private var trailerPreviewRequestVersion: Long = 0L
private var currentTmdbSettings: TmdbSettings = TmdbSettings()
private var heroEnrichmentJob: Job? = null
private var lastHeroEnrichmentSignature: String? = null
private var lastHeroEnrichedItems: List<MetaPreview> = emptyList()
private val prefetchedExternalMetaIds = Collections.synchronizedSet(mutableSetOf<String>())
@ -134,8 +131,6 @@ class HomeViewModel @Inject constructor(
private val movieWatchedObserverJobs = mutableMapOf<String, Job>()
@Volatile
private var externalMetaPrefetchEnabled: Boolean = false
@Volatile
private var startupGracePeriodActive: Boolean = true
val trailerPreviewUrls: Map<String, String>
get() = trailerPreviewUrlsState
@ -147,10 +142,6 @@ class HomeViewModel @Inject constructor(
observeTmdbSettings()
loadContinueWatching()
observeInstalledAddons()
viewModelScope.launch {
delay(3000)
startupGracePeriodActive = false
}
}
private fun observeLayoutPreferences() {
@ -230,7 +221,6 @@ class HomeViewModel @Inject constructor(
)
}
.distinctUntilChanged()
.debounce(100)
.collectLatest { prefs ->
val effectivePosterLabelsEnabled = if (prefs.layout == HomeLayout.MODERN) {
false
@ -334,7 +324,6 @@ class HomeViewModel @Inject constructor(
releaseInfo: String?,
apiType: String
) {
if (startupGracePeriodActive) return
if (activeTrailerPreviewItemId != itemId) {
activeTrailerPreviewItemId = itemId
trailerPreviewRequestVersion++
@ -374,7 +363,6 @@ class HomeViewModel @Inject constructor(
}
fun onItemFocus(item: MetaPreview) {
if (startupGracePeriodActive) return
if (!externalMetaPrefetchEnabled) return
if (item.id in prefetchedExternalMetaIds) return
if (pendingExternalMetaPrefetchItemId == item.id) return
@ -1335,17 +1323,29 @@ class HomeViewModel @Inject constructor(
private fun scheduleUpdateCatalogRows() {
catalogUpdateJob?.cancel()
catalogUpdateJob = viewModelScope.launch {
val debounceMs = when {
// First render: use minimal debounce to show content ASAP while still
// batching near-simultaneous arrivals.
!hasRenderedFirstCatalog && catalogsMap.isNotEmpty() -> {
val currentLayout = _uiState.value.homeLayout
val preferSmootherBatching = currentLayout == HomeLayout.CLASSIC || currentLayout == HomeLayout.GRID
val loadedContentRowCount = catalogsMap.values.count { it.items.isNotEmpty() }
if (!hasRenderedFirstCatalog && catalogsMap.isNotEmpty()) {
val shouldHoldFirstRenderForBatch =
preferSmootherBatching &&
pendingCatalogLoads > 0 &&
loadedContentRowCount in 0..2
if (!shouldHoldFirstRenderForBatch) {
hasRenderedFirstCatalog = true
50L
updateCatalogRows()
return@launch
}
pendingCatalogLoads > 8 -> 200L
pendingCatalogLoads > 3 -> 150L
pendingCatalogLoads > 0 -> 100L
else -> 50L
}
val debounceMs = when {
preferSmootherBatching && pendingCatalogLoads > 5 -> 650L
preferSmootherBatching && pendingCatalogLoads > 0 -> 380L
pendingCatalogLoads > 5 -> 450L
pendingCatalogLoads > 0 -> 250L
else -> 100L
}
delay(debounceMs)
updateCatalogRows()
@ -1471,9 +1471,28 @@ class HomeViewModel @Inject constructor(
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) {
replaceGridHeroItems(baseGridItems, baseHeroItems)
replaceGridHeroItems(baseGridItems, resolvedHeroItems)
} else {
baseGridItems
}
@ -1482,53 +1501,12 @@ class HomeViewModel @Inject constructor(
_uiState.update { state ->
state.copy(
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,
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)
}

View file

@ -12,6 +12,7 @@ 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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width

View file

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

View file

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

View file

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