Compare commits
2 commits
dev
...
revert-per
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2196cb0c9 | ||
|
|
5adc071969 |
16 changed files with 265 additions and 347 deletions
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -21,5 +21,5 @@ dependencyResolutionManagement {
|
|||
|
||||
rootProject.name = "My Application"
|
||||
include(":app")
|
||||
// include(":benchmark") // TODO: create when ready
|
||||
include(":benchmark")
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue