fix Events focus oscillating between hero and card on back
This commit is contained in:
parent
0099f9079c
commit
d549609dbe
3 changed files with 75 additions and 27 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 = 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")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(entryReady, hasContent) {
|
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, 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,14 +487,19 @@ fun EventsScreen(
|
||||||
onFocusReady = { heroFocusReady = it },
|
onFocusReady = { heroFocusReady = it },
|
||||||
onFocus = {
|
onFocus = {
|
||||||
onContentFocused()
|
onContentFocused()
|
||||||
|
// Niet tijdens card-restore: scroll/BringIntoView trekt focus terug naar hero.
|
||||||
|
if (!pendingFocusRestore && !pauseCarouselAfterRestore) {
|
||||||
expandHeroFully()
|
expandHeroFully()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onCtaFocusChanged = { focused ->
|
onCtaFocusChanged = { focused ->
|
||||||
heroCtaFocused = focused
|
heroCtaFocused = focused
|
||||||
if (focused) {
|
if (focused) {
|
||||||
onContentFocused()
|
onContentFocused()
|
||||||
|
if (!pendingFocusRestore && !pauseCarouselAfterRestore) {
|
||||||
expandHeroFully()
|
expandHeroFully()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onOpen = { id ->
|
onOpen = { id ->
|
||||||
onSetOpenedFromHero(true)
|
onSetOpenedFromHero(true)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue