From 94c04e0381625546d1b16928c7e3e5c3bb437688 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sun, 13 Sep 2026 00:52:44 +0200 Subject: [PATCH] Fix TV tab selection sticking, scroll restore, and browse header clip. Keep a single top-nav host so Films/Series stay selected, hoist list/grid state above the detail routes, and add focus padding under browse titles. --- apps/android-tv/app/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/ui/BrowseScreen.kt | 12 +++- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 62 ++++++++++++++----- .../mediacluster/tv/ui/components/TopNav.kt | 10 +-- 4 files changed, 63 insertions(+), 25 deletions(-) diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index c393be5..72cb70e 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 36 - versionCode = 90 - versionName = "0.14.27" + versionCode = 91 + versionName = "0.14.28" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt index cb329f4..3c0258a 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt @@ -62,6 +62,7 @@ fun BrowseScreen( api: ApiClient, catalogId: String, titleHint: String? = null, + gridState: androidx.compose.foundation.lazy.grid.LazyGridState = rememberLazyGridState(), onBack: (() -> Unit)? = null, onAtTopBack: (() -> Unit)? = null, onOpenMovie: (String) -> Unit, @@ -92,9 +93,9 @@ fun BrowseScreen( var continueMenu by remember { mutableStateOf(null) } var menuReady by remember { mutableStateOf(false) } val menuFocus = remember { FocusRequester() } - val gridState = rememberLazyGridState() val scope = rememberCoroutineScope() val showBrowseTools = !isContinue + var lastLoadKey by rememberSaveable { mutableStateOf(null) } suspend fun load(reset: Boolean, jumpLetter: String? = null) { if (reset) { @@ -133,8 +134,13 @@ fun BrowseScreen( } LaunchedEffect(catalogId, sort) { + val key = "$catalogId|$sort" + val catalogOrSortChanged = lastLoadKey != null && lastLoadKey != key load(reset = true, jumpLetter = null) - gridState.scrollToItem(0) + if (catalogOrSortChanged) { + gridState.scrollToItem(0) + } + lastLoadKey = key } LaunchedEffect(gridState, items.size, total, listOffset) { @@ -259,7 +265,7 @@ fun BrowseScreen( // ~124dp: dichter dan 140 (meer rijen), nog herkenbaar van de bank columns = GridCells.Adaptive(minSize = 124.dp), state = gridState, - contentPadding = PaddingValues(start = 4.dp, top = 4.dp, end = 4.dp, bottom = 28.dp), + contentPadding = PaddingValues(start = 4.dp, top = 28.dp, end = 4.dp, bottom = 28.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxSize(), diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index d9d4a90..42b425c 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -128,6 +129,11 @@ fun McNav() { val hubTab = remember(hubTabName) { MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home } + // Scroll-state boven NavHost: blijft behouden bij film/serie/player + val homeListState = rememberLazyListState() + val moviesGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState() + val seriesGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState() + val watchlistGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState() LaunchedEffect(Unit) { val base = store.apiBase.first() @@ -265,6 +271,10 @@ fun McNav() { api = api, tab = hubTab, onTabChange = { hubTabName = it.name }, + homeListState = homeListState, + moviesGridState = moviesGridState, + seriesGridState = seriesGridState, + watchlistGridState = watchlistGridState, tvCategoryId = tvCategoryId, onTvCategoryChange = { tvCategoryId = it }, tvLastStreamId = tvLastStreamId, @@ -419,6 +429,10 @@ private fun MainHub( api: ApiClient, tab: MainTab, onTabChange: (MainTab) -> Unit, + homeListState: androidx.compose.foundation.lazy.LazyListState, + moviesGridState: androidx.compose.foundation.lazy.grid.LazyGridState, + seriesGridState: androidx.compose.foundation.lazy.grid.LazyGridState, + watchlistGridState: androidx.compose.foundation.lazy.grid.LazyGridState, tvCategoryId: String?, onTvCategoryChange: (String?) -> Unit, tvLastStreamId: Int?, @@ -447,7 +461,6 @@ private fun MainHub( val homeContentFocus = remember { FocusRequester() } val exitFocus = remember { FocusRequester() } var exitReady by remember { mutableStateOf(false) } - val homeListState = rememberLazyListState() var prevTabName by rememberSaveable { mutableStateOf(null) } val hubScope = rememberCoroutineScope() val activity = LocalContext.current as? Activity @@ -541,8 +554,16 @@ private fun MainHub( focusTopNav() } } - // Films/Series/Zoeken: BrowseScreen handelt scroll→top af; hier alleen top-nav → Home - BackHandler(enabled = navFocused && tab != MainTab.Home && !exitConfirm) { + // Films/Series/…: Back in top-nav → Home (kort uitgeschakeld na tab-wissel tegen race) + var navBackToHomeArmed by remember { mutableStateOf(false) } + LaunchedEffect(tab, navFocused) { + navBackToHomeArmed = false + if (navFocused && tab != MainTab.Home && tab != MainTab.Settings) { + delay(400) + navBackToHomeArmed = true + } + } + BackHandler(enabled = navBackToHomeArmed && navFocused && tab != MainTab.Home && !exitConfirm) { onTabChange(MainTab.Home) focusTopNav() } @@ -581,6 +602,12 @@ private fun MainHub( } val homeOverlay = tab == MainTab.Home + val density = LocalDensity.current + var topNavHeightPx by remember { mutableIntStateOf(0) } + val topNavBlockHeight = with(density) { + if (topNavHeightPx > 0) topNavHeightPx.toDp() + else nl.vonas.mediacluster.tv.ui.components.HomeChrome.TopNavHeight + } @Composable fun HubTopNav(overlay: Boolean) { @@ -660,10 +687,9 @@ private fun MainHub( Box(Modifier.fillMaxSize().background(McColors.Bg)) { Column(Modifier.fillMaxSize()) { + // Vaste ruimte voor topnav (niet op Home: daar ligt nav als overlay) if (!homeOverlay) { - Box(Modifier.focusRequester(navFocus)) { - HubTopNav(overlay = false) - } + Spacer(Modifier.height(topNavBlockHeight)) } AnimatedContent( targetState = tab, @@ -688,6 +714,7 @@ private fun MainHub( ) MainTab.Watchlist -> WatchlistScreen( api = api, + gridState = watchlistGridState, onAtTopBack = { focusTopNav() }, onOpenMovie = onOpenMovie, onOpenSeries = onOpenSeries, @@ -696,6 +723,7 @@ private fun MainHub( api = api, catalogId = movieCatalog, titleHint = movieSubs.firstOrNull { it.id == movieSubId }?.label ?: "Films", + gridState = moviesGridState, onAtTopBack = { focusTopNav() }, onOpenMovie = onOpenMovie, onOpenSeries = onOpenSeries, @@ -704,6 +732,7 @@ private fun MainHub( api = api, catalogId = seriesCatalog, titleHint = seriesSubs.firstOrNull { it.id == seriesSubId }?.label ?: "Series", + gridState = seriesGridState, onAtTopBack = { focusTopNav() }, onOpenMovie = onOpenMovie, onOpenSeries = onOpenSeries, @@ -744,15 +773,15 @@ private fun MainHub( } } - if (homeOverlay) { - Box( - Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .focusRequester(navFocus), - ) { - HubTopNav(overlay = true) - } + // Eén vaste TopNav-host: blijft bestaan bij Home↔Films (voorkomt tab-reset) + Box( + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .focusRequester(navFocus) + .onSizeChanged { topNavHeightPx = it.height }, + ) { + HubTopNav(overlay = homeOverlay) } if (exitConfirm) { @@ -1824,6 +1853,8 @@ private fun SettingsScreen( @Composable private fun WatchlistScreen( api: ApiClient, + gridState: androidx.compose.foundation.lazy.grid.LazyGridState = + androidx.compose.foundation.lazy.grid.rememberLazyGridState(), onAtTopBack: () -> Unit, onOpenMovie: (String) -> Unit, onOpenSeries: (String) -> Unit, @@ -1871,6 +1902,7 @@ private fun WatchlistScreen( else -> { androidx.compose.foundation.lazy.grid.LazyVerticalGrid( columns = androidx.compose.foundation.lazy.grid.GridCells.Adaptive(minSize = 124.dp), + state = gridState, horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(14.dp), contentPadding = PaddingValues(top = 28.dp, bottom = 32.dp), diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt index 81ba729..98280fe 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt @@ -106,12 +106,12 @@ fun TopNavBar( Modifier }, ) - Column( + // Semi-transparant of vast: home overlay vs vaste tabs + Column( modifier .fillMaxWidth() .then( if (overlay) { - // Semi-transparant: hero zichtbaar, menu blijft leesbaar Modifier.background( Brush.verticalGradient( 0f to Color(0xB8000000), @@ -120,7 +120,7 @@ fun TopNavBar( ), ) } else { - Modifier + Modifier.background(McColors.Bg) }, ) .graphicsLayer { this.alpha = alpha }, @@ -232,9 +232,9 @@ fun TopNavBar( if (!profileLabel.isNullOrBlank()) { Text( profileLabel, - color = McColors.Brand, + color = Color.White, fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Medium, maxLines = 1, ) }