fix Events provider chip focus crash on filter change
This commit is contained in:
parent
ccb42db2a1
commit
340480e14f
2 changed files with 111 additions and 107 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue