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"
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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,68 +391,89 @@ 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 (live.isNotEmpty()) {
|
if (!hasContent) {
|
||||||
item(key = "live") {
|
item(key = "empty") {
|
||||||
EventRail(
|
Box(
|
||||||
title = "Nu live",
|
Modifier
|
||||||
events = live,
|
.fillMaxWidth()
|
||||||
nowMs = nowMs,
|
.padding(top = 48.dp),
|
||||||
onOpen = onOpenEvent,
|
contentAlignment = Alignment.Center,
|
||||||
onFocus = onContentFocused,
|
) {
|
||||||
firstItemFocusRequester = if (showProviderChips) {
|
Text(
|
||||||
firstRailFocus
|
if (providerFilter != null) {
|
||||||
} else {
|
"Geen events voor ${prettyProvider(providerFilter!!)}"
|
||||||
contentFocusRequester
|
} else {
|
||||||
},
|
"Geen events in de komende dagen"
|
||||||
restoreEventId = bindRestoreCardId.takeIf { restoreInLive },
|
},
|
||||||
restoreFocusRequester = restoreCardFocus.takeIf {
|
color = McColors.TextDim,
|
||||||
bindRestoreCardId != null && restoreInLive
|
fontSize = 18.sp,
|
||||||
},
|
)
|
||||||
onRestoreFocusReady = { restoreCardReady = it },
|
}
|
||||||
focusUpTo = firstRailUpTarget,
|
|
||||||
onFirstFocusReady = { ready ->
|
|
||||||
claimRailFocus("live", ready)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
upcomingRails.forEachIndexed { index, (label, dayEvents) ->
|
if (live.isNotEmpty()) {
|
||||||
val isFirstUpcoming = live.isEmpty() && index == 0
|
item(key = "live") {
|
||||||
val ownerId = "day-$label"
|
EventRail(
|
||||||
val railHasRestore = bindRestoreCardId != null &&
|
title = "Nu live",
|
||||||
!restoreInLive &&
|
events = live,
|
||||||
dayEvents.any { it.id == bindRestoreCardId }
|
nowMs = nowMs,
|
||||||
item(key = "day-$label") {
|
onOpen = onOpenEvent,
|
||||||
EventRail(
|
onFocus = onContentFocused,
|
||||||
title = label,
|
firstItemFocusRequester = if (showProviderChips) {
|
||||||
events = dayEvents,
|
firstRailFocus
|
||||||
nowMs = nowMs,
|
} else {
|
||||||
onOpen = onOpenEvent,
|
contentFocusRequester
|
||||||
onFocus = onContentFocused,
|
},
|
||||||
firstItemFocusRequester = when {
|
restoreEventId = bindRestoreCardId.takeIf { restoreInLive },
|
||||||
!isFirstUpcoming -> null
|
restoreFocusRequester = restoreCardFocus.takeIf {
|
||||||
showProviderChips -> firstRailFocus
|
bindRestoreCardId != null && restoreInLive
|
||||||
else -> contentFocusRequester
|
},
|
||||||
},
|
onRestoreFocusReady = { restoreCardReady = it },
|
||||||
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
|
focusUpTo = firstRailUpTarget,
|
||||||
restoreFocusRequester = restoreCardFocus.takeIf { railHasRestore },
|
onFirstFocusReady = { ready ->
|
||||||
onRestoreFocusReady = { ready ->
|
claimRailFocus("live", ready)
|
||||||
if (railHasRestore) restoreCardReady = ready
|
},
|
||||||
},
|
)
|
||||||
focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming },
|
}
|
||||||
onFirstFocusReady = if (isFirstUpcoming) {
|
}
|
||||||
{ ready -> claimRailFocus(ownerId, ready) }
|
upcomingRails.forEachIndexed { index, (label, dayEvents) ->
|
||||||
} else {
|
val isFirstUpcoming = live.isEmpty() && index == 0
|
||||||
null
|
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) },
|
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,29 +977,15 @@ 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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue