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"
minSdk = 24
targetSdk = 36
versionCode = 122
versionName = "0.14.59"
versionCode = 123
versionName = "0.14.60"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -73,6 +73,7 @@ import androidx.tv.material3.Text
import coil.compose.AsyncImage
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.ScheduleEvent
import nl.vonas.mediacluster.tv.ui.components.HomeChrome
@ -133,6 +134,8 @@ fun EventsScreen(
var restoreCardReady by remember { mutableStateOf(false) }
var heroCtaFocused 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 chipsFocus = remember { FocusRequester() }
val firstRailFocus = remember { FocusRequester() }
@ -335,7 +338,8 @@ fun EventsScreen(
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(
pendingFocusRestore,
focusRestoreNonce,
@ -345,53 +349,89 @@ fun EventsScreen(
restoreIsFirstOfFirstRail,
) {
if (!pendingFocusRestore || !hasContent) return@LaunchedEffect
pauseCarouselAfterRestore = true
delay(60)
onContentFocused()
var focusedCard = 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
}
}
}
var restoredSuccessfully = false
if (!focusedCard) {
// Hero/entry fallback — scroll naar top alleen als we géén card restoren.
suspend fun focusHeroFallback() {
if (restoredSuccessfully) return
val entryOk = awaitReady({
when {
showHero -> heroFocusReady
showProviderChips -> chipsFocusReady
else -> railFocusReady
}
}, timeoutMs = 500)
}, timeoutMs = 400)
if (restoredSuccessfully) return
if (entryOk) {
runCatching { listState.scrollToItem(0) }
delay(40)
if (restoredSuccessfully) return
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 (pendingFocusRestore) return@LaunchedEffect
delay(40)
onContentFocused()
runCatching { contentFocusRequester.requestFocus() }
placeholderHadFocus = false
}
val pauseCarousel = pendingFocusRestore || heroCtaFocused
val pauseCarousel = pendingFocusRestore || pauseCarouselAfterRestore || heroCtaFocused
// NOOIT dual-bind: placeholder alleen als entry-target NIET in de tree hangt.
val entryInTree = hasContent && (showHero || showProviderChips || hasRails)
@ -447,13 +487,18 @@ fun EventsScreen(
onFocusReady = { heroFocusReady = it },
onFocus = {
onContentFocused()
expandHeroFully()
// Niet tijdens card-restore: scroll/BringIntoView trekt focus terug naar hero.
if (!pendingFocusRestore && !pauseCarouselAfterRestore) {
expandHeroFully()
}
},
onCtaFocusChanged = { focused ->
heroCtaFocused = focused
if (focused) {
onContentFocused()
expandHeroFully()
if (!pendingFocusRestore && !pauseCarouselAfterRestore) {
expandHeroFully()
}
}
},
onOpen = { id ->

View file

@ -720,9 +720,12 @@ 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.
// Bij pending restore is EventsScreen de enige owner (card of hero-fallback) —
// géén parallel hero requestFocus (race: hero→card→hero).
LaunchedEffect(eventsReturnNonce) {
if (eventsReturnNonce <= 0 || tab != MainTab.Events) return@LaunchedEffect
navFocused = false
if (pendingEventsFocusRestore) return@LaunchedEffect
delay(100)
runCatching { eventsContentFocus.requestFocus() }
delay(180)