fix Events provider chip focus crash on filter change

This commit is contained in:
Jos Vooges | STH 2026-09-19 02:47:07 +02:00
parent ccb42db2a1
commit 340480e14f
2 changed files with 111 additions and 107 deletions

View file

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

View file

@ -31,9 +31,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
@ -80,7 +80,7 @@ private val DayTimeFmt: DateTimeFormatter =
DateTimeFormatter.ofPattern("EEE d MMM · HH:mm", Locale("nl", "NL"))
private val StreamLabelRegex = Regex("""^stream\s*\d+""", RegexOption.IGNORE_CASE)
@OptIn(ExperimentalTvMaterial3Api::class)
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun EventsScreen(
api: ApiClient,
@ -107,7 +107,6 @@ fun EventsScreen(
val listState = rememberLazyListState()
val firstRailFocus = remember { FocusRequester() }
val restoreCardFocus = remember { FocusRequester() }
val scope = rememberCoroutineScope()
suspend fun refresh() {
runCatching { api.listScheduleEvents() }
@ -162,12 +161,18 @@ fun EventsScreen(
}
// Focus: TopNav → chips (optioneel) → first rail card.
// Nooit focusProperties.down op firstRailFocus (stale/uninitialized na filter) —
// chips gebruiken altijd onFocusDownFallback → focusFirstRail().
// Films/Home-patroon: focusProperties.down alleen naar een gebonden FocusRequester
// (of Cancel bij lege filter) — nooit naar uninitialized firstRailFocus.
val firstRailUpTarget = when {
showProviderChips && chipsFocusReady -> contentFocusRequester
else -> navFocusRequester
}
// Chip/TopNav down-target: ready rail, anders Cancel (geen spatial crash) of weglaten.
val chipFocusDown: FocusRequester? = when {
!hasRails -> FocusRequester.Cancel
railFocusReady -> firstRailFocus
else -> null
}
fun claimRailFocus(owner: String, ready: Boolean) {
if (ready) {
@ -177,7 +182,8 @@ fun EventsScreen(
}
}
// Filter-wissel: scroll omhoog (Nu live zichtbaar) + forceer re-bind first rail.
// Filter-wissel: scroll omhoog + clear owner zodat down pas opnieuw linkt na re-bind.
// Focus blijft op chip; géén force requestFocus hier.
LaunchedEffect(providerFilter) {
railFocusOwner = null
runCatching { listState.scrollToItem(0) }
@ -248,22 +254,18 @@ fun EventsScreen(
return ready()
}
fun focusFirstRail() {
scope.launch {
runCatching { listState.scrollToItem(0) }
if (firstRailListIndex > 0) {
runCatching { listState.scrollToItem(firstRailListIndex) }
}
if (!awaitReady({ railFocusReady }, timeoutMs = 1200)) return@launch
delay(40)
runCatching { firstRailFocus.requestFocus() }
}
}
val entryReady = when {
showProviderChips -> chipsFocusReady
else -> railFocusReady
}
// TopNav.down moet altijd een gebonden target hebben (Home-patroon: sink tot owner klaar).
val needContentSink = when {
loading && events.isEmpty() -> true
error != null && events.isEmpty() -> true
showProviderChips -> !chipsFocusReady
!hasRails -> true
else -> !railFocusReady
}
// Detail → back: card restore; fallback = chips of eerste rail.
LaunchedEffect(
@ -347,10 +349,9 @@ fun EventsScreen(
placeholderHadFocus = false
}
val entryInTree = hasContent && (showProviderChips || hasRails)
// Sink houdt contentFocusRequester gebonden voor TopNav.down wanneer chips/rails weg zijn.
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
if (!entryInTree) {
if (needContentSink) {
Box(
Modifier
.size(1.dp)
@ -374,18 +375,14 @@ fun EventsScreen(
Text(error!!, color = McColors.Danger, fontSize = 16.sp)
}
}
!hasContent -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Geen events in de komende dagen", color = McColors.TextDim, fontSize = 18.sp)
}
}
else -> {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 48.dp),
) {
// TopNav reserveert al ruimte (niet-overlay) — geen BadgeTop-spacer.
// Chips blijven altijd staan (ook bij lege filter) — anders unbindt
// contentFocusRequester en crasht TopNav/D-pad focusSearch.
if (showProviderChips) {
item(key = "providers") {
ProviderChips(
@ -394,68 +391,89 @@ fun EventsScreen(
onSelect = { providerFilter = it },
firstFocusRequester = contentFocusRequester,
focusUp = navFocusRequester,
// Alleen linken als rail gebonden is; Cancel bij geen rails.
focusDown = chipFocusDown,
onFocusReady = { chipsFocusReady = it },
onFocus = onContentFocused,
// Altijd expliciet requestFocus — nooit stale focusProperties.down.
onFocusDownFallback = { focusFirstRail() },
)
}
}
if (live.isNotEmpty()) {
item(key = "live") {
EventRail(
title = "Nu live",
events = live,
nowMs = nowMs,
onOpen = onOpenEvent,
onFocus = onContentFocused,
firstItemFocusRequester = if (showProviderChips) {
firstRailFocus
} else {
contentFocusRequester
},
restoreEventId = bindRestoreCardId.takeIf { restoreInLive },
restoreFocusRequester = restoreCardFocus.takeIf {
bindRestoreCardId != null && restoreInLive
},
onRestoreFocusReady = { restoreCardReady = it },
focusUpTo = firstRailUpTarget,
onFirstFocusReady = { ready ->
claimRailFocus("live", ready)
},
)
if (!hasContent) {
item(key = "empty") {
Box(
Modifier
.fillMaxWidth()
.padding(top = 48.dp),
contentAlignment = Alignment.Center,
) {
Text(
if (providerFilter != null) {
"Geen events voor ${prettyProvider(providerFilter!!)}"
} else {
"Geen events in de komende dagen"
},
color = McColors.TextDim,
fontSize = 18.sp,
)
}
}
}
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 }
item(key = "day-$label") {
EventRail(
title = label,
events = dayEvents,
nowMs = nowMs,
onOpen = onOpenEvent,
onFocus = onContentFocused,
firstItemFocusRequester = when {
!isFirstUpcoming -> null
showProviderChips -> firstRailFocus
else -> contentFocusRequester
},
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
restoreFocusRequester = restoreCardFocus.takeIf { railHasRestore },
onRestoreFocusReady = { ready ->
if (railHasRestore) restoreCardReady = ready
},
focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming },
onFirstFocusReady = if (isFirstUpcoming) {
{ ready -> claimRailFocus(ownerId, ready) }
} else {
null
},
)
} else {
if (live.isNotEmpty()) {
item(key = "live") {
EventRail(
title = "Nu live",
events = live,
nowMs = nowMs,
onOpen = onOpenEvent,
onFocus = onContentFocused,
firstItemFocusRequester = if (showProviderChips) {
firstRailFocus
} else {
contentFocusRequester
},
restoreEventId = bindRestoreCardId.takeIf { restoreInLive },
restoreFocusRequester = restoreCardFocus.takeIf {
bindRestoreCardId != null && restoreInLive
},
onRestoreFocusReady = { restoreCardReady = it },
focusUpTo = firstRailUpTarget,
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 }
item(key = "day-$label") {
EventRail(
title = label,
events = dayEvents,
nowMs = nowMs,
onOpen = onOpenEvent,
onFocus = onContentFocused,
firstItemFocusRequester = when {
!isFirstUpcoming -> null
showProviderChips -> firstRailFocus
else -> contentFocusRequester
},
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
restoreFocusRequester = restoreCardFocus.takeIf { railHasRestore },
onRestoreFocusReady = { ready ->
if (railHasRestore) restoreCardReady = ready
},
focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming },
onFirstFocusReady = if (isFirstUpcoming) {
{ ready -> claimRailFocus(ownerId, ready) }
} else {
null
},
)
}
}
}
}
@ -782,6 +800,7 @@ private fun EventRail(
onOpen = { onOpen(ev.id) },
onFocus = onFocus,
focusRequester = cardRequester,
// Alleen up-linken als target gezet is (caller gated op chipsFocusReady).
focusUp = focusUpTo.takeIf { isFirst || isRestore },
)
}
@ -903,9 +922,9 @@ 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(
@ -921,9 +940,9 @@ private fun ProviderChips(
active = selected == null,
focusRequester = firstFocusRequester,
focusUp = focusUp,
focusDown = focusDown,
onFocusReady = onFocusReady,
onFocus = onFocus,
onFocusDownFallback = onFocusDownFallback,
onClick = { onSelect(null) },
)
}
@ -932,8 +951,8 @@ private fun ProviderChips(
label = prettyProvider(p),
active = selected.equals(p, ignoreCase = true),
focusUp = focusUp,
focusDown = focusDown,
onFocus = onFocus,
onFocusDownFallback = onFocusDownFallback,
onClick = { onSelect(p) },
)
}
@ -951,7 +970,6 @@ private fun Chip(
focusDown: FocusRequester? = null,
onFocusReady: ((Boolean) -> Unit)? = null,
onFocus: (() -> Unit)? = null,
onFocusDownFallback: (() -> Unit)? = null,
) {
if (focusRequester != null) {
DisposableEffect(focusRequester) {
@ -959,29 +977,15 @@ private fun Chip(
onDispose { onFocusReady?.invoke(false) }
}
}
// Films/Home-patroon: down alleen zetten als caller een veilige target gaf
// (gebonden rail, of FocusRequester.Cancel bij lege filter). Null = spatial nav.
val mod = Modifier
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.then(
if (focusUp != null || (focusDown != null && onFocusDownFallback == null)) {
if (focusUp != null || focusDown != null) {
Modifier.focusProperties {
focusUp?.let { up = it }
if (onFocusDownFallback == null) {
focusDown?.let { down = it }
}
}
} else {
Modifier
},
)
.then(
if (onFocusDownFallback != null) {
Modifier.onPreviewKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown && ev.key == Key.DirectionDown) {
onFocusDownFallback()
true
} else {
false
}
focusDown?.let { down = it }
}
} else {
Modifier