Fix Android TV Events focus hang after tab switch and detail back.

Simplify Events focus to the Home hero pattern: no dual-bound FocusRequester, hero outside LazyColumn, and stable CTA restore so D-pad stays usable.
This commit is contained in:
Jos Vooges | STH 2026-09-19 00:51:43 +02:00
parent cbacf1af41
commit 3c356001b2
3 changed files with 93 additions and 146 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 = 117 versionCode = 118
versionName = "0.14.54" versionName = "0.14.55"
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

@ -104,9 +104,9 @@ fun EventsScreen(
var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) } var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) }
var heroFocusReady by remember { mutableStateOf(false) } var heroFocusReady by remember { mutableStateOf(false) }
var railFocusReady by remember { mutableStateOf(false) } var railFocusReady by remember { mutableStateOf(false) }
var heroCtaFocused by remember { mutableStateOf(false) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val firstRailFocus = remember { FocusRequester() } val firstRailFocus = remember { FocusRequester() }
val restoreCardFocus = remember { FocusRequester() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
suspend fun refresh() { suspend fun refresh() {
@ -153,31 +153,16 @@ fun EventsScreen(
if (upcoming.isEmpty()) null else label to upcoming if (upcoming.isEmpty()) null else label to upcoming
} }
} }
val restoreInLive = remember(live, restoreEventId) { val hasHeroContent = filtered.isNotEmpty()
!restoreEventId.isNullOrBlank() && live.any { it.id == restoreEventId } val heroItems = liveHeroItems.ifEmpty { listOfNotNull(filtered.firstOrNull()) }
} val heroHeight = 480.dp
val restoreInUpcoming = remember(upcomingRails, restoreEventId) { // Rails starten onder de hero met lichte overlap (Home-achtig).
!restoreEventId.isNullOrBlank() && val railStart = 420.dp
upcomingRails.any { (_, day) -> day.any { it.id == restoreEventId } }
}
val canRestoreCard = restoreInLive || restoreInUpcoming
val firstRailListIndex = remember(providers, live, upcomingRails) { val firstRailListIndex = remember(providers, live, upcomingRails) {
var idx = 1 // after hero var idx = 1 // after spacer
if (providers.size > 1) idx++ if (providers.size > 1) idx++
if (live.isNotEmpty() || upcomingRails.isNotEmpty()) idx else -1 if (live.isNotEmpty() || upcomingRails.isNotEmpty()) idx else -1
} }
val restoreListIndex = remember(providers, live, upcomingRails, restoreEventId, restoreInLive) {
if (restoreEventId.isNullOrBlank()) return@remember -1
var idx = 1
if (providers.size > 1) idx++
if (restoreInLive) return@remember idx
if (live.isNotEmpty()) idx++
upcomingRails.forEachIndexed { i, (_, day) ->
if (day.any { it.id == restoreEventId }) return@remember idx + i
}
-1
}
suspend fun awaitRailReady(timeoutMs: Long = 900): Boolean { suspend fun awaitRailReady(timeoutMs: Long = 900): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs val deadline = System.currentTimeMillis() + timeoutMs
@ -200,56 +185,38 @@ fun EventsScreen(
} }
} }
// Na terugkeer van detail/player: focus één keer voorspelbaar herstellen. // Stabiele restore: altijd hero CTA — geen card-FocusRequester races.
LaunchedEffect( LaunchedEffect(
pendingFocusRestore, pendingFocusRestore,
focusRestoreNonce, focusRestoreNonce,
filtered.isNotEmpty(), hasHeroContent,
heroFocusReady, heroFocusReady,
railFocusReady,
canRestoreCard,
) { ) {
if (!pendingFocusRestore || filtered.isEmpty() || !heroFocusReady) return@LaunchedEffect if (!pendingFocusRestore || !hasHeroContent || !heroFocusReady) return@LaunchedEffect
if (canRestoreCard && !railFocusReady) return@LaunchedEffect delay(60)
delay(80)
onContentFocused() onContentFocused()
val scrolled = listState.firstVisibleItemIndex > 0 || runCatching { listState.scrollToItem(0) }
listState.firstVisibleItemScrollOffset > 40 delay(40)
if (scrolled) { runCatching { contentFocusRequester.requestFocus() }
runCatching { listState.scrollToItem(0) }
delay(40)
}
var focused = false
if (canRestoreCard) {
focused = runCatching {
val scrollTo = restoreListIndex.takeIf { it >= 0 } ?: firstRailListIndex
if (scrollTo >= 0) {
listState.animateScrollToItem(scrollTo)
delay(50)
}
restoreCardFocus.requestFocus()
}.isSuccess
}
if (!focused) {
delay(40)
runCatching { contentFocusRequester.requestFocus() }
}
onFocusRestoreConsumed() onFocusRestoreConsumed()
} }
// Placeholder→hero: als TopNav-down op de 1dp-box landde vóór de CTA klaar was. // Placeholder had focus tijdens laden → doorzetten naar hero CTA zodra klaar.
var placeholderHadFocus by remember { mutableStateOf(false) } var placeholderHadFocus by remember { mutableStateOf(false) }
LaunchedEffect(heroFocusReady) { LaunchedEffect(heroFocusReady, hasHeroContent) {
if (!heroFocusReady || !placeholderHadFocus) return@LaunchedEffect if (!heroFocusReady || !hasHeroContent || !placeholderHadFocus) return@LaunchedEffect
delay(40) delay(40)
onContentFocused() onContentFocused()
runCatching { contentFocusRequester.requestFocus() } runCatching { contentFocusRequester.requestFocus() }
placeholderHadFocus = false placeholderHadFocus = false
} }
val pauseCarousel = pendingFocusRestore || heroCtaFocused
Box(Modifier.fillMaxSize().background(McColors.Bg)) { Box(Modifier.fillMaxSize().background(McColors.Bg)) {
// TopNav "down" moet altijd een geldig doel hebben (ook tijdens laden). // NOOIT dual-bind: placeholder alleen als hero NIET in de tree hangt.
if (!heroFocusReady) { // (Home had dezelfde race: placeholder + CTA tegelijk → Shield hang.)
if (!hasHeroContent) {
Box( Box(
Modifier Modifier
.size(1.dp) .size(1.dp)
@ -273,34 +240,44 @@ fun EventsScreen(
Text(error!!, color = McColors.Danger, fontSize = 16.sp) Text(error!!, color = McColors.Danger, fontSize = 16.sp)
} }
} }
filtered.isEmpty() -> { !hasHeroContent -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Geen events in de komende dagen", color = McColors.TextDim, fontSize = 18.sp) Text("Geen events in de komende dagen", color = McColors.TextDim, fontSize = 18.sp)
} }
} }
else -> { else -> {
// Hero BUITEN LazyColumn — zelfde stabiele patroon als Home SpotlightHero.
Box(
Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.height(heroHeight),
) {
EventHero(
events = heroItems,
nowMs = nowMs,
focusRequester = contentFocusRequester,
navFocusRequester = navFocusRequester,
railFocusRequester = firstRailFocus.takeIf { railFocusReady },
pauseCarousel = pauseCarousel,
onFocusReady = { heroFocusReady = it },
onFocus = onContentFocused,
onCtaFocusChanged = { focused ->
heroCtaFocused = focused
if (focused) onContentFocused()
},
onOpen = { id -> onOpenEvent(id) },
onPlay = { id -> onPlayEvent(id) },
onRequestRailFocus = { focusFirstRail() },
)
}
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 48.dp), contentPadding = PaddingValues(bottom = 48.dp),
) { ) {
item(key = "hero") { item(key = "hero-spacer") {
if (liveHeroItems.isNotEmpty() || filtered.isNotEmpty()) { Spacer(Modifier.height(railStart))
EventHero(
events = liveHeroItems.ifEmpty {
listOfNotNull(filtered.firstOrNull())
},
nowMs = nowMs,
focusRequester = contentFocusRequester,
navFocusRequester = navFocusRequester,
railFocusRequester = firstRailFocus.takeIf { railFocusReady },
onFocusReady = { heroFocusReady = it },
onFocus = onContentFocused,
onOpen = { id -> onOpenEvent(id) },
onPlay = { id -> onPlayEvent(id) },
onRequestRailFocus = { focusFirstRail() },
)
}
} }
if (providers.size > 1) { if (providers.size > 1) {
item(key = "providers") { item(key = "providers") {
@ -318,12 +295,8 @@ fun EventsScreen(
events = live, events = live,
nowMs = nowMs, nowMs = nowMs,
onOpen = onOpenEvent, onOpen = onOpenEvent,
onFocus = { onFocus = onContentFocused,
onContentFocused()
},
firstItemFocusRequester = firstRailFocus.takeIf { live.isNotEmpty() }, firstItemFocusRequester = firstRailFocus.takeIf { live.isNotEmpty() },
restoreEventId = restoreEventId.takeIf { restoreInLive },
restoreFocusRequester = restoreCardFocus.takeIf { restoreInLive },
focusUpTo = contentFocusRequester.takeIf { heroFocusReady }, focusUpTo = contentFocusRequester.takeIf { heroFocusReady },
onFirstFocusReady = { ready -> onFirstFocusReady = { ready ->
if (live.isNotEmpty()) railFocusReady = ready if (live.isNotEmpty()) railFocusReady = ready
@ -333,9 +306,6 @@ fun EventsScreen(
} }
upcomingRails.forEachIndexed { index, (label, dayEvents) -> upcomingRails.forEachIndexed { index, (label, dayEvents) ->
val isFirstUpcoming = live.isEmpty() && index == 0 val isFirstUpcoming = live.isEmpty() && index == 0
val railHasRestore = !restoreInLive &&
restoreEventId != null &&
dayEvents.any { it.id == restoreEventId }
item(key = "day-$label") { item(key = "day-$label") {
EventRail( EventRail(
title = label, title = label,
@ -344,8 +314,6 @@ fun EventsScreen(
onOpen = onOpenEvent, onOpen = onOpenEvent,
onFocus = onContentFocused, onFocus = onContentFocused,
firstItemFocusRequester = firstRailFocus.takeIf { isFirstUpcoming }, firstItemFocusRequester = firstRailFocus.takeIf { isFirstUpcoming },
restoreEventId = restoreEventId.takeIf { railHasRestore },
restoreFocusRequester = restoreCardFocus.takeIf { railHasRestore },
focusUpTo = contentFocusRequester.takeIf { heroFocusReady && isFirstUpcoming }, focusUpTo = contentFocusRequester.takeIf { heroFocusReady && isFirstUpcoming },
onFirstFocusReady = { ready -> onFirstFocusReady = { ready ->
if (isFirstUpcoming) railFocusReady = ready if (isFirstUpcoming) railFocusReady = ready
@ -707,8 +675,10 @@ private fun EventHero(
focusRequester: FocusRequester, focusRequester: FocusRequester,
navFocusRequester: FocusRequester, navFocusRequester: FocusRequester,
railFocusRequester: FocusRequester?, railFocusRequester: FocusRequester?,
pauseCarousel: Boolean = false,
onFocusReady: (Boolean) -> Unit, onFocusReady: (Boolean) -> Unit,
onFocus: () -> Unit, onFocus: () -> Unit,
onCtaFocusChanged: (Boolean) -> Unit = {},
onOpen: (String) -> Unit, onOpen: (String) -> Unit,
onPlay: (String) -> Unit, onPlay: (String) -> Unit,
onRequestRailFocus: () -> Unit = {}, onRequestRailFocus: () -> Unit = {},
@ -717,8 +687,8 @@ private fun EventHero(
var heroIndex by remember { mutableIntStateOf(0) } var heroIndex by remember { mutableIntStateOf(0) }
val idsKey = remember(events) { events.joinToString("|") { it.id } } val idsKey = remember(events) { events.joinToString("|") { it.id } }
LaunchedEffect(idsKey) { LaunchedEffect(idsKey, pauseCarousel) {
heroIndex = 0 if (pauseCarousel) return@LaunchedEffect
if (events.size <= 1) return@LaunchedEffect if (events.size <= 1) return@LaunchedEffect
while (true) { while (true) {
delay(HeroRotateMs) delay(HeroRotateMs)
@ -819,27 +789,30 @@ private fun EventHero(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// CTAs buiten Crossfade → FocusRequester blijft stabiel tijdens rotate // Eén stabiele primary CTA (geen playable if/else remount) → FocusRequester blijft bound.
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
DisposableEffect(focusRequester) { DisposableEffect(focusRequester) {
onFocusReady(true) onFocusReady(true)
onDispose { onFocusReady(false) } onDispose { onFocusReady(false) }
} }
McDetailButton(
if (current.playable) "Kijk live" else ctaLabel(current, nowMs),
primary = true,
focusRequester = focusRequester,
focusUp = navFocusRequester,
focusDown = railFocusRequester,
onFocus = onFocus,
onFocusChanged = onCtaFocusChanged,
onFocusDownFallback = if (railFocusRequester == null) {
({ onRequestRailFocus() })
} else {
null
},
onClick = {
if (current.playable) onPlay(current.id) else onOpen(current.id)
},
)
if (current.playable) { if (current.playable) {
McDetailButton(
"Kijk live",
primary = true,
focusRequester = focusRequester,
focusUp = navFocusRequester,
focusDown = railFocusRequester,
onFocus = onFocus,
onFocusDownFallback = if (railFocusRequester == null) {
({ onRequestRailFocus() })
} else {
null
},
onClick = { onPlay(current.id) },
)
McDetailButton( McDetailButton(
"Meer info", "Meer info",
focusUp = navFocusRequester, focusUp = navFocusRequester,
@ -852,21 +825,6 @@ private fun EventHero(
}, },
onClick = { onOpen(current.id) }, onClick = { onOpen(current.id) },
) )
} else {
McDetailButton(
ctaLabel(current, nowMs),
primary = true,
focusRequester = focusRequester,
focusUp = navFocusRequester,
focusDown = railFocusRequester,
onFocus = onFocus,
onFocusDownFallback = if (railFocusRequester == null) {
({ onRequestRailFocus() })
} else {
null
},
onClick = { onOpen(current.id) },
)
} }
} }
@ -920,22 +878,10 @@ private fun EventRail(
onOpen: (String) -> Unit, onOpen: (String) -> Unit,
onFocus: () -> Unit, onFocus: () -> Unit,
firstItemFocusRequester: FocusRequester? = null, firstItemFocusRequester: FocusRequester? = null,
restoreEventId: String? = null,
restoreFocusRequester: FocusRequester? = null,
focusUpTo: FocusRequester? = null, focusUpTo: FocusRequester? = null,
onFirstFocusReady: ((Boolean) -> Unit)? = null, onFirstFocusReady: ((Boolean) -> Unit)? = null,
) { ) {
if (events.isEmpty()) return if (events.isEmpty()) return
val rowState = rememberLazyListState()
val restoreIndex = remember(events, restoreEventId) {
if (restoreEventId.isNullOrBlank()) -1
else events.indexOfFirst { it.id == restoreEventId }
}
LaunchedEffect(restoreIndex) {
if (restoreIndex > 0) {
runCatching { rowState.scrollToItem(restoreIndex) }
}
}
Column(Modifier.padding(top = 20.dp)) { Column(Modifier.padding(top = 20.dp)) {
Text( Text(
title, title,
@ -946,37 +892,24 @@ private fun EventRail(
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
LazyRow( LazyRow(
state = rowState,
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset), contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset),
horizontalArrangement = Arrangement.spacedBy(14.dp), horizontalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
items(events, key = { it.id }) { ev -> items(events, key = { it.id }) { ev ->
val isFirst = ev.id == events.first().id val isFirst = ev.id == events.first().id
val isRestore = restoreEventId != null && ev.id == restoreEventId
if (isFirst && firstItemFocusRequester != null) { if (isFirst && firstItemFocusRequester != null) {
DisposableEffect(firstItemFocusRequester) { DisposableEffect(firstItemFocusRequester) {
onFirstFocusReady?.invoke(true) onFirstFocusReady?.invoke(true)
onDispose { onFirstFocusReady?.invoke(false) } onDispose { onFirstFocusReady?.invoke(false) }
} }
} }
val cardRequester = when {
isRestore && restoreFocusRequester != null -> restoreFocusRequester
isFirst -> firstItemFocusRequester
else -> null
}
EventCard( EventCard(
event = ev, event = ev,
nowMs = nowMs, nowMs = nowMs,
onOpen = { onOpen(ev.id) }, onOpen = { onOpen(ev.id) },
onFocus = onFocus, onFocus = onFocus,
focusRequester = cardRequester, focusRequester = firstItemFocusRequester.takeIf { isFirst },
extraFocusRequester = when { focusUp = focusUpTo.takeIf { isFirst },
isRestore && isFirst && firstItemFocusRequester != null &&
restoreFocusRequester != null &&
firstItemFocusRequester !== restoreFocusRequester -> firstItemFocusRequester
else -> null
},
focusUp = focusUpTo.takeIf { isFirst || isRestore },
) )
} }
} }
@ -991,7 +924,6 @@ private fun EventCard(
onOpen: () -> Unit, onOpen: () -> Unit,
onFocus: () -> Unit, onFocus: () -> Unit,
focusRequester: FocusRequester? = null, focusRequester: FocusRequester? = null,
extraFocusRequester: FocusRequester? = null,
focusUp: FocusRequester? = null, focusUp: FocusRequester? = null,
) { ) {
var focused by remember { mutableStateOf(false) } var focused by remember { mutableStateOf(false) }
@ -1000,7 +932,6 @@ private fun EventCard(
modifier = Modifier modifier = Modifier
.width(280.dp) .width(280.dp)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.then(if (extraFocusRequester != null) Modifier.focusRequester(extraFocusRequester) else Modifier)
.then( .then(
if (focusUp != null) { if (focusUp != null) {
Modifier.focusProperties { up = focusUp } Modifier.focusProperties { up = focusUp }
@ -1166,6 +1097,7 @@ private fun McDetailButton(
focusUp: FocusRequester? = null, focusUp: FocusRequester? = null,
focusDown: FocusRequester? = null, focusDown: FocusRequester? = null,
onFocus: (() -> Unit)? = null, onFocus: (() -> Unit)? = null,
onFocusChanged: ((Boolean) -> Unit)? = null,
/** When gallery/rail is off-screen, scroll+focus before default D-pad move. */ /** When gallery/rail is off-screen, scroll+focus before default D-pad move. */
onFocusDownFallback: (() -> Unit)? = null, onFocusDownFallback: (() -> Unit)? = null,
) { ) {
@ -1198,6 +1130,7 @@ private fun McDetailButton(
}, },
) )
.onFocusChanged { .onFocusChanged {
onFocusChanged?.invoke(it.isFocused)
if (it.isFocused) onFocus?.invoke() if (it.isFocused) onFocus?.invoke()
} }
.heightIn(min = 36.dp, max = 40.dp) .heightIn(min = 36.dp, max = 40.dp)

View file

@ -713,6 +713,17 @@ private fun MainHub(
} }
} }
// Terug van EventDetail/Player: MainHub is opnieuw gemount — forceer content-focus
// zodat TopNav niet “nav-only” blijft en D-pad down niet doodloopt.
LaunchedEffect(eventsReturnNonce) {
if (eventsReturnNonce <= 0 || tab != MainTab.Events) return@LaunchedEffect
navFocused = false
delay(100)
runCatching { eventsContentFocus.requestFocus() }
delay(180)
runCatching { eventsContentFocus.requestFocus() }
}
// Reset scroll alleen bij echte tab-wissel naar Home (niet bij terugkeer uit detail/player) // Reset scroll alleen bij echte tab-wissel naar Home (niet bij terugkeer uit detail/player)
LaunchedEffect(tab) { LaunchedEffect(tab) {
val name = tab.name val name = tab.name
@ -772,6 +783,9 @@ private fun MainHub(
navFocused = false navFocused = false
onTvFocusContent() onTvFocusContent()
hubScope.launch { hubScope.launch {
// Wacht tot EventsScreen placeholder of hero CTA de requester bindt.
delay(160)
runCatching { eventsContentFocus.requestFocus() }
delay(120) delay(120)
runCatching { eventsContentFocus.requestFocus() } runCatching { eventsContentFocus.requestFocus() }
} }