From 340480e14fe5bf54951f7e9816a42b08e871694b Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sat, 19 Sep 2026 02:47:07 +0200 Subject: [PATCH] fix Events provider chip focus crash on filter change --- apps/android-tv/app/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/ui/EventsScreen.kt | 214 +++++++++--------- 2 files changed, 111 insertions(+), 107 deletions(-) diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 8211451..340c2fa 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 = 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") 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 d091cdf..8264e7f 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 @@ -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