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" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 126 versionCode = 127
versionName = "0.14.63" versionName = "0.14.64"
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

@ -31,9 +31,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@ -80,7 +80,7 @@ private val DayTimeFmt: DateTimeFormatter =
DateTimeFormatter.ofPattern("EEE d MMM · HH:mm", Locale("nl", "NL")) DateTimeFormatter.ofPattern("EEE d MMM · HH:mm", Locale("nl", "NL"))
private val StreamLabelRegex = Regex("""^stream\s*\d+""", RegexOption.IGNORE_CASE) private val StreamLabelRegex = Regex("""^stream\s*\d+""", RegexOption.IGNORE_CASE)
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @Composable
fun EventsScreen( fun EventsScreen(
api: ApiClient, api: ApiClient,
@ -107,7 +107,6 @@ fun EventsScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val firstRailFocus = remember { FocusRequester() } val firstRailFocus = remember { FocusRequester() }
val restoreCardFocus = remember { FocusRequester() } val restoreCardFocus = remember { FocusRequester() }
val scope = rememberCoroutineScope()
suspend fun refresh() { suspend fun refresh() {
runCatching { api.listScheduleEvents() } runCatching { api.listScheduleEvents() }
@ -162,12 +161,18 @@ fun EventsScreen(
} }
// Focus: TopNav → chips (optioneel) → first rail card. // Focus: TopNav → chips (optioneel) → first rail card.
// Nooit focusProperties.down op firstRailFocus (stale/uninitialized na filter) — // Films/Home-patroon: focusProperties.down alleen naar een gebonden FocusRequester
// chips gebruiken altijd onFocusDownFallback → focusFirstRail(). // (of Cancel bij lege filter) — nooit naar uninitialized firstRailFocus.
val firstRailUpTarget = when { val firstRailUpTarget = when {
showProviderChips && chipsFocusReady -> contentFocusRequester showProviderChips && chipsFocusReady -> contentFocusRequester
else -> navFocusRequester 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) { fun claimRailFocus(owner: String, ready: Boolean) {
if (ready) { 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) { LaunchedEffect(providerFilter) {
railFocusOwner = null railFocusOwner = null
runCatching { listState.scrollToItem(0) } runCatching { listState.scrollToItem(0) }
@ -248,22 +254,18 @@ fun EventsScreen(
return ready() 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 { val entryReady = when {
showProviderChips -> chipsFocusReady showProviderChips -> chipsFocusReady
else -> railFocusReady 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. // Detail → back: card restore; fallback = chips of eerste rail.
LaunchedEffect( LaunchedEffect(
@ -347,10 +349,9 @@ fun EventsScreen(
placeholderHadFocus = false 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)) { Box(Modifier.fillMaxSize().background(McColors.Bg)) {
if (!entryInTree) { if (needContentSink) {
Box( Box(
Modifier Modifier
.size(1.dp) .size(1.dp)
@ -374,18 +375,14 @@ fun EventsScreen(
Text(error!!, color = McColors.Danger, fontSize = 16.sp) 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 -> { else -> {
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 48.dp), 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) { if (showProviderChips) {
item(key = "providers") { item(key = "providers") {
ProviderChips( ProviderChips(
@ -394,13 +391,33 @@ fun EventsScreen(
onSelect = { providerFilter = it }, onSelect = { providerFilter = it },
firstFocusRequester = contentFocusRequester, firstFocusRequester = contentFocusRequester,
focusUp = navFocusRequester, focusUp = navFocusRequester,
// Alleen linken als rail gebonden is; Cancel bij geen rails.
focusDown = chipFocusDown,
onFocusReady = { chipsFocusReady = it }, onFocusReady = { chipsFocusReady = it },
onFocus = onContentFocused, onFocus = onContentFocused,
// Altijd expliciet requestFocus — nooit stale focusProperties.down.
onFocusDownFallback = { focusFirstRail() },
) )
} }
} }
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,
)
}
}
} else {
if (live.isNotEmpty()) { if (live.isNotEmpty()) {
item(key = "live") { item(key = "live") {
EventRail( EventRail(
@ -463,6 +480,7 @@ fun EventsScreen(
} }
} }
} }
}
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -782,6 +800,7 @@ private fun EventRail(
onOpen = { onOpen(ev.id) }, onOpen = { onOpen(ev.id) },
onFocus = onFocus, onFocus = onFocus,
focusRequester = cardRequester, focusRequester = cardRequester,
// Alleen up-linken als target gezet is (caller gated op chipsFocusReady).
focusUp = focusUpTo.takeIf { isFirst || isRestore }, focusUp = focusUpTo.takeIf { isFirst || isRestore },
) )
} }
@ -903,9 +922,9 @@ 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,
) { ) {
// Mirror TopNav subItems (Films/Series): EdgeInset + vertical 4.dp, bottom 8.dp. // Mirror TopNav subItems (Films/Series): EdgeInset + vertical 4.dp, bottom 8.dp.
LazyRow( LazyRow(
@ -921,9 +940,9 @@ 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,
onClick = { onSelect(null) }, onClick = { onSelect(null) },
) )
} }
@ -932,8 +951,8 @@ 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,
onClick = { onSelect(p) }, onClick = { onSelect(p) },
) )
} }
@ -951,7 +970,6 @@ private fun Chip(
focusDown: FocusRequester? = null, focusDown: FocusRequester? = null,
onFocusReady: ((Boolean) -> Unit)? = null, onFocusReady: ((Boolean) -> Unit)? = null,
onFocus: (() -> Unit)? = null, onFocus: (() -> Unit)? = null,
onFocusDownFallback: (() -> Unit)? = null,
) { ) {
if (focusRequester != null) { if (focusRequester != null) {
DisposableEffect(focusRequester) { DisposableEffect(focusRequester) {
@ -959,30 +977,16 @@ private fun Chip(
onDispose { onFocusReady?.invoke(false) } 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 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)) { if (focusUp != null || focusDown != null) {
Modifier.focusProperties { Modifier.focusProperties {
focusUp?.let { up = it } focusUp?.let { up = it }
if (onFocusDownFallback == null) {
focusDown?.let { down = it } 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
}
}
} else { } else {
Modifier Modifier
}, },