fix Events chips focus, default Back on upcoming detail, restore Nu live rail

This commit is contained in:
Jos Vooges | STH 2026-09-19 02:38:14 +02:00
parent 55710da15b
commit ccb42db2a1
3 changed files with 99 additions and 55 deletions

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 125 versionCode = 126
versionName = "0.14.62" versionName = "0.14.63"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -99,7 +99,9 @@ fun EventsScreen(
var providerFilter by remember { mutableStateOf<String?>(null) } var providerFilter by remember { mutableStateOf<String?>(null) }
var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) } var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) }
var chipsFocusReady by remember { mutableStateOf(false) } 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<String?>(null) }
val railFocusReady = railFocusOwner != null
var restoreCardReady by remember { mutableStateOf(false) } var restoreCardReady by remember { mutableStateOf(false) }
var placeholderHadFocus by remember { mutableStateOf(false) } var placeholderHadFocus by remember { mutableStateOf(false) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
@ -135,31 +137,56 @@ fun EventsScreen(
if (providerFilter.isNullOrBlank()) events if (providerFilter.isNullOrBlank()) events
else events.filter { it.provider.equals(providerFilter, ignoreCase = true) } 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 byDay = remember(filtered, nowMs) { groupEventsByDay(filtered) }
val providers = remember(events) { val providers = remember(events) {
events.map { it.provider }.distinct().sorted() events.map { it.provider }.distinct().sorted()
} }
val showProviderChips = providers.size > 1 val showProviderChips = providers.size > 1
val upcomingRails = remember(byDay) { val upcomingRails = remember(byDay, nowMs) {
byDay.mapNotNull { (label, dayEvents) -> 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 if (upcoming.isEmpty()) null else label to upcoming
} }
} }
val hasContent = filtered.isNotEmpty() val hasContent = filtered.isNotEmpty()
val hasRails = live.isNotEmpty() || upcomingRails.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 // Focus: TopNav → chips (optioneel) → first rail card.
val chipsDownTarget = firstRailFocus.takeIf { railFocusReady } // Nooit focusProperties.down op firstRailFocus (stale/uninitialized na filter) —
// chips gebruiken altijd onFocusDownFallback → focusFirstRail().
val firstRailUpTarget = when { val firstRailUpTarget = when {
showProviderChips && chipsFocusReady -> contentFocusRequester showProviderChips && chipsFocusReady -> contentFocusRequester
else -> navFocusRequester 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) { val firstRailListIndex = remember(showProviderChips, live, upcomingRails) {
if (live.isEmpty() && upcomingRails.isEmpty()) return@remember -1 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++ if (showProviderChips) idx++
idx idx
} }
@ -180,7 +207,7 @@ fun EventsScreen(
restoreUpcomingIndex, restoreUpcomingIndex,
) { ) {
if (!canRestoreCard) return@remember -1 if (!canRestoreCard) return@remember -1
var idx = 1 // after top spacer var idx = 0
if (showProviderChips) idx++ if (showProviderChips) idx++
if (restoreInLive) return@remember idx if (restoreInLive) return@remember idx
if (live.isNotEmpty()) idx++ if (live.isNotEmpty()) idx++
@ -223,14 +250,13 @@ fun EventsScreen(
fun focusFirstRail() { fun focusFirstRail() {
scope.launch { scope.launch {
if (!awaitReady({ railFocusReady })) return@launch runCatching { listState.scrollToItem(0) }
runCatching { if (firstRailListIndex > 0) {
if (firstRailListIndex >= 0) { runCatching { listState.scrollToItem(firstRailListIndex) }
listState.animateScrollToItem(firstRailListIndex) }
if (!awaitReady({ railFocusReady }, timeoutMs = 1200)) return@launch
delay(40) delay(40)
} runCatching { firstRailFocus.requestFocus() }
firstRailFocus.requestFocus()
}
} }
} }
@ -359,9 +385,7 @@ fun EventsScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 48.dp), contentPadding = PaddingValues(bottom = 48.dp),
) { ) {
item(key = "top-spacer") { // TopNav reserveert al ruimte (niet-overlay) — geen BadgeTop-spacer.
Spacer(Modifier.height(HomeChrome.BadgeTop + 24.dp))
}
if (showProviderChips) { if (showProviderChips) {
item(key = "providers") { item(key = "providers") {
ProviderChips( ProviderChips(
@ -370,14 +394,10 @@ fun EventsScreen(
onSelect = { providerFilter = it }, onSelect = { providerFilter = it },
firstFocusRequester = contentFocusRequester, firstFocusRequester = contentFocusRequester,
focusUp = navFocusRequester, focusUp = navFocusRequester,
focusDown = chipsDownTarget,
onFocusReady = { chipsFocusReady = it }, onFocusReady = { chipsFocusReady = it },
onFocus = onContentFocused, onFocus = onContentFocused,
onFocusDownFallback = if (chipsDownTarget == null) { // Altijd expliciet requestFocus — nooit stale focusProperties.down.
({ focusFirstRail() }) onFocusDownFallback = { focusFirstRail() },
} else {
null
},
) )
} }
} }
@ -400,12 +420,15 @@ fun EventsScreen(
}, },
onRestoreFocusReady = { restoreCardReady = it }, onRestoreFocusReady = { restoreCardReady = it },
focusUpTo = firstRailUpTarget, focusUpTo = firstRailUpTarget,
onFirstFocusReady = { railFocusReady = it }, onFirstFocusReady = { ready ->
claimRailFocus("live", ready)
},
) )
} }
} }
upcomingRails.forEachIndexed { index, (label, dayEvents) -> upcomingRails.forEachIndexed { index, (label, dayEvents) ->
val isFirstUpcoming = live.isEmpty() && index == 0 val isFirstUpcoming = live.isEmpty() && index == 0
val ownerId = "day-$label"
val railHasRestore = bindRestoreCardId != null && val railHasRestore = bindRestoreCardId != null &&
!restoreInLive && !restoreInLive &&
dayEvents.any { it.id == bindRestoreCardId } dayEvents.any { it.id == bindRestoreCardId }
@ -427,8 +450,10 @@ fun EventsScreen(
if (railHasRestore) restoreCardReady = ready if (railHasRestore) restoreCardReady = ready
}, },
focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming }, focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming },
onFirstFocusReady = { ready -> onFirstFocusReady = if (isFirstUpcoming) {
if (isFirstUpcoming) railFocusReady = ready { ready -> claimRailFocus(ownerId, ready) }
} else {
null
}, },
) )
} }
@ -604,22 +629,27 @@ fun EventDetailScreen(
focusDown = bodyFocus.takeIf { hasFocusableBody }, focusDown = bodyFocus.takeIf { hasFocusableBody },
onClick = { onPlay(ev.id) }, onClick = { onPlay(ev.id) },
) )
} else {
McDetailButton(
ctaLabel(ev, nowMs),
primary = false,
enabled = false,
focusRequester = playFocus,
focusDown = bodyFocus.takeIf { hasFocusableBody },
onClick = {},
)
}
McDetailButton( McDetailButton(
"Terug", "Terug",
focusRequester = backFocus, focusRequester = backFocus,
focusDown = bodyFocus.takeIf { hasFocusableBody }, focusDown = bodyFocus.takeIf { hasFocusableBody },
onClick = onBack, onClick = onBack,
) )
} else {
// Disabled CTA links, buiten focus-order; default focus op Terug.
McDetailButton(
ctaLabel(ev, nowMs),
primary = false,
enabled = false,
onClick = {},
)
McDetailButton(
"Terug",
focusRequester = playFocus,
focusDown = bodyFocus.takeIf { hasFocusableBody },
onClick = onBack,
)
}
} }
if (hasFocusableBody) { if (hasFocusableBody) {
@ -735,7 +765,7 @@ private fun EventRail(
else -> null else -> null
} }
if (isFirst && firstItemFocusRequester != null && cardRequester === firstItemFocusRequester) { if (isFirst && firstItemFocusRequester != null && cardRequester === firstItemFocusRequester) {
DisposableEffect(firstItemFocusRequester) { DisposableEffect(firstItemFocusRequester, events.firstOrNull()?.id) {
onFirstFocusReady?.invoke(true) onFirstFocusReady?.invoke(true)
onDispose { onFirstFocusReady?.invoke(false) } onDispose { onFirstFocusReady?.invoke(false) }
} }
@ -873,14 +903,17 @@ private fun ProviderChips(
onSelect: (String?) -> Unit, onSelect: (String?) -> Unit,
firstFocusRequester: FocusRequester? = null, firstFocusRequester: FocusRequester? = null,
focusUp: FocusRequester? = null, focusUp: FocusRequester? = null,
focusDown: FocusRequester? = null,
onFocusReady: ((Boolean) -> Unit)? = null, onFocusReady: ((Boolean) -> Unit)? = null,
onFocus: () -> Unit = {}, onFocus: () -> Unit = {},
onFocusDownFallback: (() -> Unit)? = null, onFocusDownFallback: (() -> Unit)? = null,
) { ) {
// Mirror TopNav subItems (Films/Series): EdgeInset + vertical 4.dp, bottom 8.dp.
LazyRow( LazyRow(
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 14.dp), contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
) { ) {
item { item {
Chip( Chip(
@ -888,7 +921,6 @@ private fun ProviderChips(
active = selected == null, active = selected == null,
focusRequester = firstFocusRequester, focusRequester = firstFocusRequester,
focusUp = focusUp, focusUp = focusUp,
focusDown = focusDown,
onFocusReady = onFocusReady, onFocusReady = onFocusReady,
onFocus = onFocus, onFocus = onFocus,
onFocusDownFallback = onFocusDownFallback, onFocusDownFallback = onFocusDownFallback,
@ -900,7 +932,6 @@ private fun ProviderChips(
label = prettyProvider(p), label = prettyProvider(p),
active = selected.equals(p, ignoreCase = true), active = selected.equals(p, ignoreCase = true),
focusUp = focusUp, focusUp = focusUp,
focusDown = focusDown,
onFocus = onFocus, onFocus = onFocus,
onFocusDownFallback = onFocusDownFallback, onFocusDownFallback = onFocusDownFallback,
onClick = { onSelect(p) }, onClick = { onSelect(p) },
@ -1018,15 +1049,17 @@ private fun McDetailButton(
val mod = Modifier val mod = Modifier
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.then( .then(
if (focusUp != null || (focusDown != null && onFocusDownFallback == null)) { when {
!enabled -> Modifier.focusProperties { canFocus = false }
focusUp != null || (focusDown != null && onFocusDownFallback == null) -> {
Modifier.focusProperties { Modifier.focusProperties {
focusUp?.let { up = it } focusUp?.let { up = it }
if (onFocusDownFallback == null) { if (onFocusDownFallback == null) {
focusDown?.let { down = it } focusDown?.let { down = it }
} }
} }
} else { }
Modifier else -> Modifier
}, },
) )
.then( .then(
@ -1093,7 +1126,7 @@ private fun McDetailButton(
private fun statusVisual(event: ScheduleEvent, nowMs: Long): Pair<String, Color> { private fun statusVisual(event: ScheduleEvent, nowMs: Long): Pair<String, Color> {
return when { 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 -> { minutesUntilStart(event, nowMs)?.let { it in 0 until 60 } == true -> {
val m = minutesUntilStart(event, nowMs)!! val m = minutesUntilStart(event, nowMs)!!
"Straks · over ${m} min" to Color(0xFFE67E22) "Straks · over ${m} min" to Color(0xFFE67E22)
@ -1103,7 +1136,7 @@ private fun statusVisual(event: ScheduleEvent, nowMs: Long): Pair<String, Color>
} }
private fun ctaLabel(event: ScheduleEvent, nowMs: Long): String { 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) val mins = minutesUntilStart(event, nowMs)
if (mins != null && mins in 0 until 60) return "Start over $mins min" if (mins != null && mins in 0 until 60) return "Start over $mins min"
val start = parseZoned(event.startLocal, event.start) ?: return "Meer info" 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 { private fun shortTimeLabel(event: ScheduleEvent, nowMs: Long): String {
if (event.phase == "live" || event.isLive) { if (eventIsLive(event, nowMs)) {
val left = minutesUntilEnd(event, nowMs) val left = minutesUntilEnd(event, nowMs)
return if (left != null && left > 0) "Nog $left min" else "Nu" return if (left != null && left > 0) "Nog $left min" else "Nu"
} }
@ -1135,7 +1168,7 @@ private fun detailTimeWindowLabel(event: ScheduleEvent, nowMs: Long): String {
} else { } else {
start.format(TimeFmt) 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 today = Instant.ofEpochMilli(nowMs).atZone(ZoneNl).toLocalDate()
val day = start.toLocalDate() val day = start.toLocalDate()
return when { 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 { private fun isStreamLabel(value: String?): Boolean {
val s = value?.trim().orEmpty() val s = value?.trim().orEmpty()
if (s.isEmpty()) return false if (s.isEmpty()) return false

View file

@ -730,7 +730,9 @@ private fun MainHub(
prevTabName = name 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 val density = LocalDensity.current
var topNavHeightPx by remember { mutableIntStateOf(0) } var topNavHeightPx by remember { mutableIntStateOf(0) }
val topNavBlockHeight = with(density) { val topNavBlockHeight = with(density) {