From ccb42db2a1e41af1aea8c86f959867c27e6157f5 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sat, 19 Sep 2026 02:38:14 +0200 Subject: [PATCH] fix Events chips focus, default Back on upcoming detail, restore Nu live rail --- apps/android-tv/app/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/ui/EventsScreen.kt | 146 +++++++++++------- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 4 +- 3 files changed, 99 insertions(+), 55 deletions(-) diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 64a41d1..8211451 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 = 125 - versionName = "0.14.62" + versionCode = 126 + versionName = "0.14.63" 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 0a9ab54..d091cdf 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 @@ -99,7 +99,9 @@ fun EventsScreen( var providerFilter by remember { mutableStateOf(null) } var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) } var chipsFocusReady by remember { mutableStateOf(false) } - var railFocusReady by remember { mutableStateOf(false) } + /** Owner-id van first-rail FocusRequester — voorkomt dispose-race live↔upcoming. */ + var railFocusOwner by remember { mutableStateOf(null) } + val railFocusReady = railFocusOwner != null var restoreCardReady by remember { mutableStateOf(false) } var placeholderHadFocus by remember { mutableStateOf(false) } val listState = rememberLazyListState() @@ -135,31 +137,56 @@ fun EventsScreen( if (providerFilter.isNullOrBlank()) events else events.filter { it.provider.equals(providerFilter, ignoreCase = true) } } - val live = remember(filtered) { filtered.filter { it.phase == "live" || it.isLive } } + // Client-side time window + API flags — geen hero-art filter op rails. + val live = remember(filtered, nowMs) { + filtered.filter { eventIsLive(it, nowMs) } + .sortedByDescending { it.playable } + } val byDay = remember(filtered, nowMs) { groupEventsByDay(filtered) } val providers = remember(events) { events.map { it.provider }.distinct().sorted() } val showProviderChips = providers.size > 1 - val upcomingRails = remember(byDay) { + val upcomingRails = remember(byDay, nowMs) { byDay.mapNotNull { (label, dayEvents) -> - val upcoming = dayEvents.filter { it.phase != "live" && !it.isLive } + val upcoming = dayEvents.filter { !eventIsLive(it, nowMs) } if (upcoming.isEmpty()) null else label to upcoming } } val hasContent = filtered.isNotEmpty() val hasRails = live.isNotEmpty() || upcomingRails.isNotEmpty() + val firstRailOwnerId = when { + live.isNotEmpty() -> "live" + upcomingRails.isNotEmpty() -> "day-${upcomingRails.first().first}" + else -> null + } - // Focus: TopNav → chips (optioneel) → first rail card - val chipsDownTarget = firstRailFocus.takeIf { railFocusReady } + // Focus: TopNav → chips (optioneel) → first rail card. + // Nooit focusProperties.down op firstRailFocus (stale/uninitialized na filter) — + // chips gebruiken altijd onFocusDownFallback → focusFirstRail(). val firstRailUpTarget = when { showProviderChips && chipsFocusReady -> contentFocusRequester else -> navFocusRequester } + fun claimRailFocus(owner: String, ready: Boolean) { + if (ready) { + if (owner == firstRailOwnerId) railFocusOwner = owner + } else if (railFocusOwner == owner) { + railFocusOwner = null + } + } + + // Filter-wissel: scroll omhoog (Nu live zichtbaar) + forceer re-bind first rail. + LaunchedEffect(providerFilter) { + railFocusOwner = null + runCatching { listState.scrollToItem(0) } + } + val firstRailListIndex = remember(showProviderChips, live, upcomingRails) { if (live.isEmpty() && upcomingRails.isEmpty()) return@remember -1 - var idx = 1 // after top spacer + // Geen hero/top-spacer meer: index 0 = chips of eerste rail. + var idx = 0 if (showProviderChips) idx++ idx } @@ -180,7 +207,7 @@ fun EventsScreen( restoreUpcomingIndex, ) { if (!canRestoreCard) return@remember -1 - var idx = 1 // after top spacer + var idx = 0 if (showProviderChips) idx++ if (restoreInLive) return@remember idx if (live.isNotEmpty()) idx++ @@ -223,14 +250,13 @@ fun EventsScreen( fun focusFirstRail() { scope.launch { - if (!awaitReady({ railFocusReady })) return@launch - runCatching { - if (firstRailListIndex >= 0) { - listState.animateScrollToItem(firstRailListIndex) - delay(40) - } - firstRailFocus.requestFocus() + runCatching { listState.scrollToItem(0) } + if (firstRailListIndex > 0) { + runCatching { listState.scrollToItem(firstRailListIndex) } } + if (!awaitReady({ railFocusReady }, timeoutMs = 1200)) return@launch + delay(40) + runCatching { firstRailFocus.requestFocus() } } } @@ -359,9 +385,7 @@ fun EventsScreen( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 48.dp), ) { - item(key = "top-spacer") { - Spacer(Modifier.height(HomeChrome.BadgeTop + 24.dp)) - } + // TopNav reserveert al ruimte (niet-overlay) — geen BadgeTop-spacer. if (showProviderChips) { item(key = "providers") { ProviderChips( @@ -370,14 +394,10 @@ fun EventsScreen( onSelect = { providerFilter = it }, firstFocusRequester = contentFocusRequester, focusUp = navFocusRequester, - focusDown = chipsDownTarget, onFocusReady = { chipsFocusReady = it }, onFocus = onContentFocused, - onFocusDownFallback = if (chipsDownTarget == null) { - ({ focusFirstRail() }) - } else { - null - }, + // Altijd expliciet requestFocus — nooit stale focusProperties.down. + onFocusDownFallback = { focusFirstRail() }, ) } } @@ -400,12 +420,15 @@ fun EventsScreen( }, onRestoreFocusReady = { restoreCardReady = it }, focusUpTo = firstRailUpTarget, - onFirstFocusReady = { railFocusReady = it }, + onFirstFocusReady = { ready -> + claimRailFocus("live", ready) + }, ) } } upcomingRails.forEachIndexed { index, (label, dayEvents) -> val isFirstUpcoming = live.isEmpty() && index == 0 + val ownerId = "day-$label" val railHasRestore = bindRestoreCardId != null && !restoreInLive && dayEvents.any { it.id == bindRestoreCardId } @@ -427,8 +450,10 @@ fun EventsScreen( if (railHasRestore) restoreCardReady = ready }, focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming }, - onFirstFocusReady = { ready -> - if (isFirstUpcoming) railFocusReady = ready + onFirstFocusReady = if (isFirstUpcoming) { + { ready -> claimRailFocus(ownerId, ready) } + } else { + null }, ) } @@ -604,22 +629,27 @@ fun EventDetailScreen( focusDown = bodyFocus.takeIf { hasFocusableBody }, onClick = { onPlay(ev.id) }, ) + McDetailButton( + "Terug", + focusRequester = backFocus, + focusDown = bodyFocus.takeIf { hasFocusableBody }, + onClick = onBack, + ) } else { + // Disabled CTA links, buiten focus-order; default focus op Terug. McDetailButton( ctaLabel(ev, nowMs), primary = false, enabled = false, - focusRequester = playFocus, - focusDown = bodyFocus.takeIf { hasFocusableBody }, onClick = {}, ) + McDetailButton( + "Terug", + focusRequester = playFocus, + focusDown = bodyFocus.takeIf { hasFocusableBody }, + onClick = onBack, + ) } - McDetailButton( - "Terug", - focusRequester = backFocus, - focusDown = bodyFocus.takeIf { hasFocusableBody }, - onClick = onBack, - ) } if (hasFocusableBody) { @@ -735,7 +765,7 @@ private fun EventRail( else -> null } if (isFirst && firstItemFocusRequester != null && cardRequester === firstItemFocusRequester) { - DisposableEffect(firstItemFocusRequester) { + DisposableEffect(firstItemFocusRequester, events.firstOrNull()?.id) { onFirstFocusReady?.invoke(true) onDispose { onFirstFocusReady?.invoke(false) } } @@ -873,14 +903,17 @@ private fun ProviderChips( onSelect: (String?) -> Unit, firstFocusRequester: FocusRequester? = null, focusUp: FocusRequester? = null, - focusDown: FocusRequester? = null, onFocusReady: ((Boolean) -> Unit)? = null, onFocus: () -> Unit = {}, onFocusDownFallback: (() -> Unit)? = null, ) { + // Mirror TopNav subItems (Films/Series): EdgeInset + vertical 4.dp, bottom 8.dp. LazyRow( - contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 14.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), ) { item { Chip( @@ -888,7 +921,6 @@ private fun ProviderChips( active = selected == null, focusRequester = firstFocusRequester, focusUp = focusUp, - focusDown = focusDown, onFocusReady = onFocusReady, onFocus = onFocus, onFocusDownFallback = onFocusDownFallback, @@ -900,7 +932,6 @@ private fun ProviderChips( label = prettyProvider(p), active = selected.equals(p, ignoreCase = true), focusUp = focusUp, - focusDown = focusDown, onFocus = onFocus, onFocusDownFallback = onFocusDownFallback, onClick = { onSelect(p) }, @@ -1018,15 +1049,17 @@ private fun McDetailButton( val mod = Modifier .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .then( - if (focusUp != null || (focusDown != null && onFocusDownFallback == null)) { - Modifier.focusProperties { - focusUp?.let { up = it } - if (onFocusDownFallback == null) { - focusDown?.let { down = it } + when { + !enabled -> Modifier.focusProperties { canFocus = false } + focusUp != null || (focusDown != null && onFocusDownFallback == null) -> { + Modifier.focusProperties { + focusUp?.let { up = it } + if (onFocusDownFallback == null) { + focusDown?.let { down = it } + } } } - } else { - Modifier + else -> Modifier }, ) .then( @@ -1093,7 +1126,7 @@ private fun McDetailButton( private fun statusVisual(event: ScheduleEvent, nowMs: Long): Pair { return when { - event.phase == "live" || event.isLive -> "LIVE" to McColors.Accent + eventIsLive(event, nowMs) -> "LIVE" to McColors.Accent minutesUntilStart(event, nowMs)?.let { it in 0 until 60 } == true -> { val m = minutesUntilStart(event, nowMs)!! "Straks · over ${m} min" to Color(0xFFE67E22) @@ -1103,7 +1136,7 @@ private fun statusVisual(event: ScheduleEvent, nowMs: Long): Pair } private fun ctaLabel(event: ScheduleEvent, nowMs: Long): String { - if (event.phase == "live" || event.isLive) return "Stream start binnenkort" + if (eventIsLive(event, nowMs)) return "Stream start binnenkort" val mins = minutesUntilStart(event, nowMs) if (mins != null && mins in 0 until 60) return "Start over $mins min" val start = parseZoned(event.startLocal, event.start) ?: return "Meer info" @@ -1111,7 +1144,7 @@ private fun ctaLabel(event: ScheduleEvent, nowMs: Long): String { } private fun shortTimeLabel(event: ScheduleEvent, nowMs: Long): String { - if (event.phase == "live" || event.isLive) { + if (eventIsLive(event, nowMs)) { val left = minutesUntilEnd(event, nowMs) return if (left != null && left > 0) "Nog $left min" else "Nu" } @@ -1135,7 +1168,7 @@ private fun detailTimeWindowLabel(event: ScheduleEvent, nowMs: Long): String { } else { start.format(TimeFmt) } - if (event.phase == "live" || event.isLive) return range + if (eventIsLive(event, nowMs)) return range val today = Instant.ofEpochMilli(nowMs).atZone(ZoneNl).toLocalDate() val day = start.toLocalDate() return when { @@ -1145,6 +1178,15 @@ private fun detailTimeWindowLabel(event: ScheduleEvent, nowMs: Long): String { } } +/** API-flags + lokale start/stop-window (zelfde logica als master-api phaseFor). */ +private fun eventIsLive(event: ScheduleEvent, nowMs: Long): Boolean { + if (event.phase.equals("live", ignoreCase = true) || event.isLive) return true + val start = parseZoned(event.startLocal, event.start) ?: return false + val stop = parseZoned(event.stopLocal, event.stop) ?: return false + val now = Instant.ofEpochMilli(nowMs).atZone(ZoneNl) + return !now.isBefore(start) && now.isBefore(stop) +} + private fun isStreamLabel(value: String?): Boolean { val s = value?.trim().orEmpty() if (s.isEmpty()) return false 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 9228c70..4648648 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 @@ -730,7 +730,9 @@ private fun MainHub( prevTabName = name } - val homeOverlay = tab == MainTab.Home || tab == MainTab.Events + // Events niet meer overlay: provider chips zitten in content met dezelfde + // verticale start als Films/Series-categoriechips onder de vaste TopNav. + val homeOverlay = tab == MainTab.Home val density = LocalDensity.current var topNavHeightPx by remember { mutableIntStateOf(0) } val topNavBlockHeight = with(density) {