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"
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")

View file

@ -99,7 +99,9 @@ fun EventsScreen(
var providerFilter by remember { mutableStateOf<String?>(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<String?>(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<String, Color> {
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<String, Color>
}
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

View file

@ -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) {