fix Events focus oscillating between hero and card on back

This commit is contained in:
Jos Vooges | STH 2026-09-19 01:34:20 +02:00
parent 0099f9079c
commit d549609dbe
3 changed files with 75 additions and 27 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 = 122 versionCode = 123
versionName = "0.14.59" versionName = "0.14.60"
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

@ -73,6 +73,7 @@ import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.ScheduleEvent import nl.vonas.mediacluster.tv.data.ScheduleEvent
import nl.vonas.mediacluster.tv.ui.components.HomeChrome import nl.vonas.mediacluster.tv.ui.components.HomeChrome
@ -133,6 +134,8 @@ fun EventsScreen(
var restoreCardReady by remember { mutableStateOf(false) } var restoreCardReady by remember { mutableStateOf(false) }
var heroCtaFocused by remember { mutableStateOf(false) } var heroCtaFocused by remember { mutableStateOf(false) }
var placeholderHadFocus by remember { mutableStateOf(false) } var placeholderHadFocus by remember { mutableStateOf(false) }
// Houd carousel stil tijdens + kort na restore zodat Crossfade geen focus steelt.
var pauseCarouselAfterRestore by remember { mutableStateOf(false) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val chipsFocus = remember { FocusRequester() } val chipsFocus = remember { FocusRequester() }
val firstRailFocus = remember { FocusRequester() } val firstRailFocus = remember { FocusRequester() }
@ -335,7 +338,8 @@ fun EventsScreen(
else -> railFocusReady else -> railFocusReady
} }
// Detail → back: card-restore als we vanaf een rail openden; anders hero CTA (veilig). // Detail → back: één restore-owner. Card eerst; hero-fallback alleen als card
// nog niet gelukt is (timeout-job wordt bij succes gecanceld).
LaunchedEffect( LaunchedEffect(
pendingFocusRestore, pendingFocusRestore,
focusRestoreNonce, focusRestoreNonce,
@ -345,53 +349,89 @@ fun EventsScreen(
restoreIsFirstOfFirstRail, restoreIsFirstOfFirstRail,
) { ) {
if (!pendingFocusRestore || !hasContent) return@LaunchedEffect if (!pendingFocusRestore || !hasContent) return@LaunchedEffect
pauseCarouselAfterRestore = true
delay(60) delay(60)
onContentFocused() onContentFocused()
var focusedCard = false var restoredSuccessfully = false
if (canRestoreCard && restoreListIndex >= 0 && activeRestoreFocus != null) {
runCatching {
listState.scrollToItem(restoreListIndex)
delay(50)
val ready = if (restoreIsFirstOfFirstRail) {
awaitReady({ railFocusReady }, timeoutMs = 500)
} else {
awaitReady({ restoreCardReady }, timeoutMs = 500)
}
if (ready) {
activeRestoreFocus.requestFocus()
focusedCard = true
}
}
}
if (!focusedCard) { suspend fun focusHeroFallback() {
// Hero/entry fallback — scroll naar top alleen als we géén card restoren. if (restoredSuccessfully) return
val entryOk = awaitReady({ val entryOk = awaitReady({
when { when {
showHero -> heroFocusReady showHero -> heroFocusReady
showProviderChips -> chipsFocusReady showProviderChips -> chipsFocusReady
else -> railFocusReady else -> railFocusReady
} }
}, timeoutMs = 500) }, timeoutMs = 400)
if (restoredSuccessfully) return
if (entryOk) { if (entryOk) {
runCatching { listState.scrollToItem(0) } runCatching { listState.scrollToItem(0) }
delay(40) delay(40)
if (restoredSuccessfully) return
runCatching { contentFocusRequester.requestFocus() } runCatching { contentFocusRequester.requestFocus() }
} }
} }
onFocusRestoreConsumed()
// Timeout → hero alleen als card-restore nog niet slaagde.
val heroTimeoutJob = launch {
delay(550)
if (!restoredSuccessfully) {
focusHeroFallback()
if (!restoredSuccessfully) onFocusRestoreConsumed()
}
}
if (canRestoreCard && restoreListIndex >= 0 && activeRestoreFocus != null) {
runCatching {
listState.scrollToItem(restoreListIndex)
delay(50)
yield()
val ready = if (restoreIsFirstOfFirstRail) {
awaitReady({ railFocusReady }, timeoutMs = 450)
} else {
awaitReady({ restoreCardReady }, timeoutMs = 450)
}
if (ready) {
activeRestoreFocus.requestFocus()
restoredSuccessfully = true
heroTimeoutJob.cancel()
// Clear pending IMMEDIATELY zodat late effects niets meer doen.
onFocusRestoreConsumed()
}
}
} else {
// Geen card-target (hero-open of onbekend id): meteen hero, cancel timeout.
heroTimeoutJob.cancel()
focusHeroFallback()
onFocusRestoreConsumed()
restoredSuccessfully = true
}
if (!restoredSuccessfully) {
// Card-poging faalde vóór timeout — wacht op fallback-job.
if (heroTimeoutJob.isActive) heroTimeoutJob.join()
else {
focusHeroFallback()
onFocusRestoreConsumed()
}
}
// Carousel nog ~2s stil na restore (Crossfade mag focus niet stelen).
delay(2_000)
pauseCarouselAfterRestore = false
} }
LaunchedEffect(entryReady, hasContent) { LaunchedEffect(entryReady, hasContent, pendingFocusRestore) {
if (!entryReady || !hasContent || !placeholderHadFocus) return@LaunchedEffect if (!entryReady || !hasContent || !placeholderHadFocus) return@LaunchedEffect
if (pendingFocusRestore) return@LaunchedEffect
delay(40) delay(40)
onContentFocused() onContentFocused()
runCatching { contentFocusRequester.requestFocus() } runCatching { contentFocusRequester.requestFocus() }
placeholderHadFocus = false placeholderHadFocus = false
} }
val pauseCarousel = pendingFocusRestore || heroCtaFocused val pauseCarousel = pendingFocusRestore || pauseCarouselAfterRestore || heroCtaFocused
// NOOIT dual-bind: placeholder alleen als entry-target NIET in de tree hangt. // NOOIT dual-bind: placeholder alleen als entry-target NIET in de tree hangt.
val entryInTree = hasContent && (showHero || showProviderChips || hasRails) val entryInTree = hasContent && (showHero || showProviderChips || hasRails)
@ -447,13 +487,18 @@ fun EventsScreen(
onFocusReady = { heroFocusReady = it }, onFocusReady = { heroFocusReady = it },
onFocus = { onFocus = {
onContentFocused() onContentFocused()
expandHeroFully() // Niet tijdens card-restore: scroll/BringIntoView trekt focus terug naar hero.
if (!pendingFocusRestore && !pauseCarouselAfterRestore) {
expandHeroFully()
}
}, },
onCtaFocusChanged = { focused -> onCtaFocusChanged = { focused ->
heroCtaFocused = focused heroCtaFocused = focused
if (focused) { if (focused) {
onContentFocused() onContentFocused()
expandHeroFully() if (!pendingFocusRestore && !pauseCarouselAfterRestore) {
expandHeroFully()
}
} }
}, },
onOpen = { id -> onOpen = { id ->

View file

@ -720,9 +720,12 @@ private fun MainHub(
// Terug van EventDetail/Player: MainHub is opnieuw gemount — forceer content-focus // Terug van EventDetail/Player: MainHub is opnieuw gemount — forceer content-focus
// zodat TopNav niet “nav-only” blijft en D-pad down niet doodloopt. // zodat TopNav niet “nav-only” blijft en D-pad down niet doodloopt.
// Bij pending restore is EventsScreen de enige owner (card of hero-fallback) —
// géén parallel hero requestFocus (race: hero→card→hero).
LaunchedEffect(eventsReturnNonce) { LaunchedEffect(eventsReturnNonce) {
if (eventsReturnNonce <= 0 || tab != MainTab.Events) return@LaunchedEffect if (eventsReturnNonce <= 0 || tab != MainTab.Events) return@LaunchedEffect
navFocused = false navFocused = false
if (pendingEventsFocusRestore) return@LaunchedEffect
delay(100) delay(100)
runCatching { eventsContentFocus.requestFocus() } runCatching { eventsContentFocus.requestFocus() }
delay(180) delay(180)