diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index ad5cc54..1521b33 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 = 117 - versionName = "0.14.54" + versionCode = 118 + versionName = "0.14.55" 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/EventsScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt index b5c48a6..b2bd3c7 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt @@ -104,9 +104,9 @@ fun EventsScreen( var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) } var heroFocusReady by remember { mutableStateOf(false) } var railFocusReady by remember { mutableStateOf(false) } + var heroCtaFocused by remember { mutableStateOf(false) } val listState = rememberLazyListState() val firstRailFocus = remember { FocusRequester() } - val restoreCardFocus = remember { FocusRequester() } val scope = rememberCoroutineScope() suspend fun refresh() { @@ -153,31 +153,16 @@ fun EventsScreen( if (upcoming.isEmpty()) null else label to upcoming } } - val restoreInLive = remember(live, restoreEventId) { - !restoreEventId.isNullOrBlank() && live.any { it.id == restoreEventId } - } - val restoreInUpcoming = remember(upcomingRails, restoreEventId) { - !restoreEventId.isNullOrBlank() && - upcomingRails.any { (_, day) -> day.any { it.id == restoreEventId } } - } - val canRestoreCard = restoreInLive || restoreInUpcoming - + val hasHeroContent = filtered.isNotEmpty() + val heroItems = liveHeroItems.ifEmpty { listOfNotNull(filtered.firstOrNull()) } + val heroHeight = 480.dp + // Rails starten onder de hero met lichte overlap (Home-achtig). + val railStart = 420.dp val firstRailListIndex = remember(providers, live, upcomingRails) { - var idx = 1 // after hero + var idx = 1 // after spacer if (providers.size > 1) idx++ if (live.isNotEmpty() || upcomingRails.isNotEmpty()) idx else -1 } - val restoreListIndex = remember(providers, live, upcomingRails, restoreEventId, restoreInLive) { - if (restoreEventId.isNullOrBlank()) return@remember -1 - var idx = 1 - if (providers.size > 1) idx++ - if (restoreInLive) return@remember idx - if (live.isNotEmpty()) idx++ - upcomingRails.forEachIndexed { i, (_, day) -> - if (day.any { it.id == restoreEventId }) return@remember idx + i - } - -1 - } suspend fun awaitRailReady(timeoutMs: Long = 900): Boolean { val deadline = System.currentTimeMillis() + timeoutMs @@ -200,56 +185,38 @@ fun EventsScreen( } } - // Na terugkeer van detail/player: focus één keer voorspelbaar herstellen. + // Stabiele restore: altijd hero CTA — geen card-FocusRequester races. LaunchedEffect( pendingFocusRestore, focusRestoreNonce, - filtered.isNotEmpty(), + hasHeroContent, heroFocusReady, - railFocusReady, - canRestoreCard, ) { - if (!pendingFocusRestore || filtered.isEmpty() || !heroFocusReady) return@LaunchedEffect - if (canRestoreCard && !railFocusReady) return@LaunchedEffect - delay(80) + if (!pendingFocusRestore || !hasHeroContent || !heroFocusReady) return@LaunchedEffect + delay(60) onContentFocused() - val scrolled = listState.firstVisibleItemIndex > 0 || - listState.firstVisibleItemScrollOffset > 40 - if (scrolled) { - runCatching { listState.scrollToItem(0) } - delay(40) - } - var focused = false - if (canRestoreCard) { - focused = runCatching { - val scrollTo = restoreListIndex.takeIf { it >= 0 } ?: firstRailListIndex - if (scrollTo >= 0) { - listState.animateScrollToItem(scrollTo) - delay(50) - } - restoreCardFocus.requestFocus() - }.isSuccess - } - if (!focused) { - delay(40) - runCatching { contentFocusRequester.requestFocus() } - } + runCatching { listState.scrollToItem(0) } + delay(40) + runCatching { contentFocusRequester.requestFocus() } onFocusRestoreConsumed() } - // Placeholder→hero: als TopNav-down op de 1dp-box landde vóór de CTA klaar was. + // Placeholder had focus tijdens laden → doorzetten naar hero CTA zodra klaar. var placeholderHadFocus by remember { mutableStateOf(false) } - LaunchedEffect(heroFocusReady) { - if (!heroFocusReady || !placeholderHadFocus) return@LaunchedEffect + LaunchedEffect(heroFocusReady, hasHeroContent) { + if (!heroFocusReady || !hasHeroContent || !placeholderHadFocus) return@LaunchedEffect delay(40) onContentFocused() runCatching { contentFocusRequester.requestFocus() } placeholderHadFocus = false } + val pauseCarousel = pendingFocusRestore || heroCtaFocused + Box(Modifier.fillMaxSize().background(McColors.Bg)) { - // TopNav "down" moet altijd een geldig doel hebben (ook tijdens laden). - if (!heroFocusReady) { + // NOOIT dual-bind: placeholder alleen als hero NIET in de tree hangt. + // (Home had dezelfde race: placeholder + CTA tegelijk → Shield hang.) + if (!hasHeroContent) { Box( Modifier .size(1.dp) @@ -273,34 +240,44 @@ fun EventsScreen( Text(error!!, color = McColors.Danger, fontSize = 16.sp) } } - filtered.isEmpty() -> { + !hasHeroContent -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Geen events in de komende dagen", color = McColors.TextDim, fontSize = 18.sp) } } else -> { + // Hero BUITEN LazyColumn — zelfde stabiele patroon als Home SpotlightHero. + Box( + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(heroHeight), + ) { + EventHero( + events = heroItems, + nowMs = nowMs, + focusRequester = contentFocusRequester, + navFocusRequester = navFocusRequester, + railFocusRequester = firstRailFocus.takeIf { railFocusReady }, + pauseCarousel = pauseCarousel, + onFocusReady = { heroFocusReady = it }, + onFocus = onContentFocused, + onCtaFocusChanged = { focused -> + heroCtaFocused = focused + if (focused) onContentFocused() + }, + onOpen = { id -> onOpenEvent(id) }, + onPlay = { id -> onPlayEvent(id) }, + onRequestRailFocus = { focusFirstRail() }, + ) + } LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 48.dp), ) { - item(key = "hero") { - if (liveHeroItems.isNotEmpty() || filtered.isNotEmpty()) { - EventHero( - events = liveHeroItems.ifEmpty { - listOfNotNull(filtered.firstOrNull()) - }, - nowMs = nowMs, - focusRequester = contentFocusRequester, - navFocusRequester = navFocusRequester, - railFocusRequester = firstRailFocus.takeIf { railFocusReady }, - onFocusReady = { heroFocusReady = it }, - onFocus = onContentFocused, - onOpen = { id -> onOpenEvent(id) }, - onPlay = { id -> onPlayEvent(id) }, - onRequestRailFocus = { focusFirstRail() }, - ) - } + item(key = "hero-spacer") { + Spacer(Modifier.height(railStart)) } if (providers.size > 1) { item(key = "providers") { @@ -318,12 +295,8 @@ fun EventsScreen( events = live, nowMs = nowMs, onOpen = onOpenEvent, - onFocus = { - onContentFocused() - }, + onFocus = onContentFocused, firstItemFocusRequester = firstRailFocus.takeIf { live.isNotEmpty() }, - restoreEventId = restoreEventId.takeIf { restoreInLive }, - restoreFocusRequester = restoreCardFocus.takeIf { restoreInLive }, focusUpTo = contentFocusRequester.takeIf { heroFocusReady }, onFirstFocusReady = { ready -> if (live.isNotEmpty()) railFocusReady = ready @@ -333,9 +306,6 @@ fun EventsScreen( } upcomingRails.forEachIndexed { index, (label, dayEvents) -> val isFirstUpcoming = live.isEmpty() && index == 0 - val railHasRestore = !restoreInLive && - restoreEventId != null && - dayEvents.any { it.id == restoreEventId } item(key = "day-$label") { EventRail( title = label, @@ -344,8 +314,6 @@ fun EventsScreen( onOpen = onOpenEvent, onFocus = onContentFocused, firstItemFocusRequester = firstRailFocus.takeIf { isFirstUpcoming }, - restoreEventId = restoreEventId.takeIf { railHasRestore }, - restoreFocusRequester = restoreCardFocus.takeIf { railHasRestore }, focusUpTo = contentFocusRequester.takeIf { heroFocusReady && isFirstUpcoming }, onFirstFocusReady = { ready -> if (isFirstUpcoming) railFocusReady = ready @@ -707,8 +675,10 @@ private fun EventHero( focusRequester: FocusRequester, navFocusRequester: FocusRequester, railFocusRequester: FocusRequester?, + pauseCarousel: Boolean = false, onFocusReady: (Boolean) -> Unit, onFocus: () -> Unit, + onCtaFocusChanged: (Boolean) -> Unit = {}, onOpen: (String) -> Unit, onPlay: (String) -> Unit, onRequestRailFocus: () -> Unit = {}, @@ -717,8 +687,8 @@ private fun EventHero( var heroIndex by remember { mutableIntStateOf(0) } val idsKey = remember(events) { events.joinToString("|") { it.id } } - LaunchedEffect(idsKey) { - heroIndex = 0 + LaunchedEffect(idsKey, pauseCarousel) { + if (pauseCarousel) return@LaunchedEffect if (events.size <= 1) return@LaunchedEffect while (true) { delay(HeroRotateMs) @@ -819,27 +789,30 @@ private fun EventHero( Spacer(Modifier.height(16.dp)) - // CTAs buiten Crossfade → FocusRequester blijft stabiel tijdens rotate + // Eén stabiele primary CTA (geen playable if/else remount) → FocusRequester blijft bound. Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { DisposableEffect(focusRequester) { onFocusReady(true) onDispose { onFocusReady(false) } } + McDetailButton( + if (current.playable) "Kijk live" else ctaLabel(current, nowMs), + primary = true, + focusRequester = focusRequester, + focusUp = navFocusRequester, + focusDown = railFocusRequester, + onFocus = onFocus, + onFocusChanged = onCtaFocusChanged, + onFocusDownFallback = if (railFocusRequester == null) { + ({ onRequestRailFocus() }) + } else { + null + }, + onClick = { + if (current.playable) onPlay(current.id) else onOpen(current.id) + }, + ) if (current.playable) { - McDetailButton( - "Kijk live", - primary = true, - focusRequester = focusRequester, - focusUp = navFocusRequester, - focusDown = railFocusRequester, - onFocus = onFocus, - onFocusDownFallback = if (railFocusRequester == null) { - ({ onRequestRailFocus() }) - } else { - null - }, - onClick = { onPlay(current.id) }, - ) McDetailButton( "Meer info", focusUp = navFocusRequester, @@ -852,21 +825,6 @@ private fun EventHero( }, onClick = { onOpen(current.id) }, ) - } else { - McDetailButton( - ctaLabel(current, nowMs), - primary = true, - focusRequester = focusRequester, - focusUp = navFocusRequester, - focusDown = railFocusRequester, - onFocus = onFocus, - onFocusDownFallback = if (railFocusRequester == null) { - ({ onRequestRailFocus() }) - } else { - null - }, - onClick = { onOpen(current.id) }, - ) } } @@ -920,22 +878,10 @@ private fun EventRail( onOpen: (String) -> Unit, onFocus: () -> Unit, firstItemFocusRequester: FocusRequester? = null, - restoreEventId: String? = null, - restoreFocusRequester: FocusRequester? = null, focusUpTo: FocusRequester? = null, onFirstFocusReady: ((Boolean) -> Unit)? = null, ) { if (events.isEmpty()) return - val rowState = rememberLazyListState() - val restoreIndex = remember(events, restoreEventId) { - if (restoreEventId.isNullOrBlank()) -1 - else events.indexOfFirst { it.id == restoreEventId } - } - LaunchedEffect(restoreIndex) { - if (restoreIndex > 0) { - runCatching { rowState.scrollToItem(restoreIndex) } - } - } Column(Modifier.padding(top = 20.dp)) { Text( title, @@ -946,37 +892,24 @@ private fun EventRail( ) Spacer(Modifier.height(12.dp)) LazyRow( - state = rowState, contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset), horizontalArrangement = Arrangement.spacedBy(14.dp), ) { items(events, key = { it.id }) { ev -> val isFirst = ev.id == events.first().id - val isRestore = restoreEventId != null && ev.id == restoreEventId if (isFirst && firstItemFocusRequester != null) { DisposableEffect(firstItemFocusRequester) { onFirstFocusReady?.invoke(true) onDispose { onFirstFocusReady?.invoke(false) } } } - val cardRequester = when { - isRestore && restoreFocusRequester != null -> restoreFocusRequester - isFirst -> firstItemFocusRequester - else -> null - } EventCard( event = ev, nowMs = nowMs, onOpen = { onOpen(ev.id) }, onFocus = onFocus, - focusRequester = cardRequester, - extraFocusRequester = when { - isRestore && isFirst && firstItemFocusRequester != null && - restoreFocusRequester != null && - firstItemFocusRequester !== restoreFocusRequester -> firstItemFocusRequester - else -> null - }, - focusUp = focusUpTo.takeIf { isFirst || isRestore }, + focusRequester = firstItemFocusRequester.takeIf { isFirst }, + focusUp = focusUpTo.takeIf { isFirst }, ) } } @@ -991,7 +924,6 @@ private fun EventCard( onOpen: () -> Unit, onFocus: () -> Unit, focusRequester: FocusRequester? = null, - extraFocusRequester: FocusRequester? = null, focusUp: FocusRequester? = null, ) { var focused by remember { mutableStateOf(false) } @@ -1000,7 +932,6 @@ private fun EventCard( modifier = Modifier .width(280.dp) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) - .then(if (extraFocusRequester != null) Modifier.focusRequester(extraFocusRequester) else Modifier) .then( if (focusUp != null) { Modifier.focusProperties { up = focusUp } @@ -1166,6 +1097,7 @@ private fun McDetailButton( focusUp: FocusRequester? = null, focusDown: FocusRequester? = null, onFocus: (() -> Unit)? = null, + onFocusChanged: ((Boolean) -> Unit)? = null, /** When gallery/rail is off-screen, scroll+focus before default D-pad move. */ onFocusDownFallback: (() -> Unit)? = null, ) { @@ -1198,6 +1130,7 @@ private fun McDetailButton( }, ) .onFocusChanged { + onFocusChanged?.invoke(it.isFocused) if (it.isFocused) onFocus?.invoke() } .heightIn(min = 36.dp, max = 40.dp) 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 bbcb9e5..8cbb62d 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 @@ -713,6 +713,17 @@ private fun MainHub( } } + // Terug van EventDetail/Player: MainHub is opnieuw gemount — forceer content-focus + // zodat TopNav niet “nav-only” blijft en D-pad down niet doodloopt. + LaunchedEffect(eventsReturnNonce) { + if (eventsReturnNonce <= 0 || tab != MainTab.Events) return@LaunchedEffect + navFocused = false + delay(100) + runCatching { eventsContentFocus.requestFocus() } + delay(180) + runCatching { eventsContentFocus.requestFocus() } + } + // Reset scroll alleen bij echte tab-wissel naar Home (niet bij terugkeer uit detail/player) LaunchedEffect(tab) { val name = tab.name @@ -772,6 +783,9 @@ private fun MainHub( navFocused = false onTvFocusContent() hubScope.launch { + // Wacht tot EventsScreen placeholder of hero CTA de requester bindt. + delay(160) + runCatching { eventsContentFocus.requestFocus() } delay(120) runCatching { eventsContentFocus.requestFocus() } }