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 {
|
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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue