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:
parent
cbacf1af41
commit
3c356001b2
3 changed files with 93 additions and 146 deletions
|
|
@ -20,8 +20,8 @@ android {
|
|||
applicationId = "nl.vonas.mediacluster.tv"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 117
|
||||
versionName = "0.14.54"
|
||||
versionCode = 118
|
||||
versionName = "0.14.55"
|
||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
|
|
|
|||
|
|
@ -104,9 +104,9 @@ fun EventsScreen(
|
|||
var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
var heroFocusReady by remember { mutableStateOf(false) }
|
||||
var railFocusReady by remember { mutableStateOf(false) }
|
||||
var heroCtaFocused by remember { mutableStateOf(false) }
|
||||
val listState = rememberLazyListState()
|
||||
val firstRailFocus = remember { FocusRequester() }
|
||||
val restoreCardFocus = remember { FocusRequester() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
suspend fun refresh() {
|
||||
|
|
@ -153,31 +153,16 @@ fun EventsScreen(
|
|||
if (upcoming.isEmpty()) null else label to upcoming
|
||||
}
|
||||
}
|
||||
val restoreInLive = remember(live, restoreEventId) {
|
||||
!restoreEventId.isNullOrBlank() && live.any { it.id == restoreEventId }
|
||||
}
|
||||
val restoreInUpcoming = remember(upcomingRails, restoreEventId) {
|
||||
!restoreEventId.isNullOrBlank() &&
|
||||
upcomingRails.any { (_, day) -> day.any { it.id == restoreEventId } }
|
||||
}
|
||||
val canRestoreCard = restoreInLive || restoreInUpcoming
|
||||
|
||||
val hasHeroContent = filtered.isNotEmpty()
|
||||
val heroItems = liveHeroItems.ifEmpty { listOfNotNull(filtered.firstOrNull()) }
|
||||
val heroHeight = 480.dp
|
||||
// Rails starten onder de hero met lichte overlap (Home-achtig).
|
||||
val railStart = 420.dp
|
||||
val firstRailListIndex = remember(providers, live, upcomingRails) {
|
||||
var idx = 1 // after hero
|
||||
var idx = 1 // after spacer
|
||||
if (providers.size > 1) idx++
|
||||
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 {
|
||||
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(
|
||||
pendingFocusRestore,
|
||||
focusRestoreNonce,
|
||||
filtered.isNotEmpty(),
|
||||
hasHeroContent,
|
||||
heroFocusReady,
|
||||
railFocusReady,
|
||||
canRestoreCard,
|
||||
) {
|
||||
if (!pendingFocusRestore || filtered.isEmpty() || !heroFocusReady) return@LaunchedEffect
|
||||
if (canRestoreCard && !railFocusReady) return@LaunchedEffect
|
||||
delay(80)
|
||||
if (!pendingFocusRestore || !hasHeroContent || !heroFocusReady) return@LaunchedEffect
|
||||
delay(60)
|
||||
onContentFocused()
|
||||
val scrolled = listState.firstVisibleItemIndex > 0 ||
|
||||
listState.firstVisibleItemScrollOffset > 40
|
||||
if (scrolled) {
|
||||
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()
|
||||
}
|
||||
|
||||
// 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) }
|
||||
LaunchedEffect(heroFocusReady) {
|
||||
if (!heroFocusReady || !placeholderHadFocus) return@LaunchedEffect
|
||||
LaunchedEffect(heroFocusReady, hasHeroContent) {
|
||||
if (!heroFocusReady || !hasHeroContent || !placeholderHadFocus) return@LaunchedEffect
|
||||
delay(40)
|
||||
onContentFocused()
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
placeholderHadFocus = false
|
||||
}
|
||||
|
||||
val pauseCarousel = pendingFocusRestore || heroCtaFocused
|
||||
|
||||
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||
// TopNav "down" moet altijd een geldig doel hebben (ook tijdens laden).
|
||||
if (!heroFocusReady) {
|
||||
// NOOIT dual-bind: placeholder alleen als hero NIET in de tree hangt.
|
||||
// (Home had dezelfde race: placeholder + CTA tegelijk → Shield hang.)
|
||||
if (!hasHeroContent) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(1.dp)
|
||||
|
|
@ -273,34 +240,44 @@ fun EventsScreen(
|
|||
Text(error!!, color = McColors.Danger, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
filtered.isEmpty() -> {
|
||||
!hasHeroContent -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Geen events in de komende dagen", color = McColors.TextDim, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 48.dp),
|
||||
// Hero BUITEN LazyColumn — zelfde stabiele patroon als Home SpotlightHero.
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.height(heroHeight),
|
||||
) {
|
||||
item(key = "hero") {
|
||||
if (liveHeroItems.isNotEmpty() || filtered.isNotEmpty()) {
|
||||
EventHero(
|
||||
events = liveHeroItems.ifEmpty {
|
||||
listOfNotNull(filtered.firstOrNull())
|
||||
},
|
||||
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(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 48.dp),
|
||||
) {
|
||||
item(key = "hero-spacer") {
|
||||
Spacer(Modifier.height(railStart))
|
||||
}
|
||||
if (providers.size > 1) {
|
||||
item(key = "providers") {
|
||||
|
|
@ -318,12 +295,8 @@ fun EventsScreen(
|
|||
events = live,
|
||||
nowMs = nowMs,
|
||||
onOpen = onOpenEvent,
|
||||
onFocus = {
|
||||
onContentFocused()
|
||||
},
|
||||
onFocus = onContentFocused,
|
||||
firstItemFocusRequester = firstRailFocus.takeIf { live.isNotEmpty() },
|
||||
restoreEventId = restoreEventId.takeIf { restoreInLive },
|
||||
restoreFocusRequester = restoreCardFocus.takeIf { restoreInLive },
|
||||
focusUpTo = contentFocusRequester.takeIf { heroFocusReady },
|
||||
onFirstFocusReady = { ready ->
|
||||
if (live.isNotEmpty()) railFocusReady = ready
|
||||
|
|
@ -333,9 +306,6 @@ fun EventsScreen(
|
|||
}
|
||||
upcomingRails.forEachIndexed { index, (label, dayEvents) ->
|
||||
val isFirstUpcoming = live.isEmpty() && index == 0
|
||||
val railHasRestore = !restoreInLive &&
|
||||
restoreEventId != null &&
|
||||
dayEvents.any { it.id == restoreEventId }
|
||||
item(key = "day-$label") {
|
||||
EventRail(
|
||||
title = label,
|
||||
|
|
@ -344,8 +314,6 @@ fun EventsScreen(
|
|||
onOpen = onOpenEvent,
|
||||
onFocus = onContentFocused,
|
||||
firstItemFocusRequester = firstRailFocus.takeIf { isFirstUpcoming },
|
||||
restoreEventId = restoreEventId.takeIf { railHasRestore },
|
||||
restoreFocusRequester = restoreCardFocus.takeIf { railHasRestore },
|
||||
focusUpTo = contentFocusRequester.takeIf { heroFocusReady && isFirstUpcoming },
|
||||
onFirstFocusReady = { ready ->
|
||||
if (isFirstUpcoming) railFocusReady = ready
|
||||
|
|
@ -707,8 +675,10 @@ private fun EventHero(
|
|||
focusRequester: FocusRequester,
|
||||
navFocusRequester: FocusRequester,
|
||||
railFocusRequester: FocusRequester?,
|
||||
pauseCarousel: Boolean = false,
|
||||
onFocusReady: (Boolean) -> Unit,
|
||||
onFocus: () -> Unit,
|
||||
onCtaFocusChanged: (Boolean) -> Unit = {},
|
||||
onOpen: (String) -> Unit,
|
||||
onPlay: (String) -> Unit,
|
||||
onRequestRailFocus: () -> Unit = {},
|
||||
|
|
@ -717,8 +687,8 @@ private fun EventHero(
|
|||
var heroIndex by remember { mutableIntStateOf(0) }
|
||||
val idsKey = remember(events) { events.joinToString("|") { it.id } }
|
||||
|
||||
LaunchedEffect(idsKey) {
|
||||
heroIndex = 0
|
||||
LaunchedEffect(idsKey, pauseCarousel) {
|
||||
if (pauseCarousel) return@LaunchedEffect
|
||||
if (events.size <= 1) return@LaunchedEffect
|
||||
while (true) {
|
||||
delay(HeroRotateMs)
|
||||
|
|
@ -819,27 +789,30 @@ private fun EventHero(
|
|||
|
||||
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)) {
|
||||
DisposableEffect(focusRequester) {
|
||||
onFocusReady(true)
|
||||
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) {
|
||||
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(
|
||||
"Meer info",
|
||||
focusUp = navFocusRequester,
|
||||
|
|
@ -852,21 +825,6 @@ private fun EventHero(
|
|||
},
|
||||
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,
|
||||
onFocus: () -> Unit,
|
||||
firstItemFocusRequester: FocusRequester? = null,
|
||||
restoreEventId: String? = null,
|
||||
restoreFocusRequester: FocusRequester? = null,
|
||||
focusUpTo: FocusRequester? = null,
|
||||
onFirstFocusReady: ((Boolean) -> Unit)? = null,
|
||||
) {
|
||||
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)) {
|
||||
Text(
|
||||
title,
|
||||
|
|
@ -946,37 +892,24 @@ private fun EventRail(
|
|||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
LazyRow(
|
||||
state = rowState,
|
||||
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
items(events, key = { it.id }) { ev ->
|
||||
val isFirst = ev.id == events.first().id
|
||||
val isRestore = restoreEventId != null && ev.id == restoreEventId
|
||||
if (isFirst && firstItemFocusRequester != null) {
|
||||
DisposableEffect(firstItemFocusRequester) {
|
||||
onFirstFocusReady?.invoke(true)
|
||||
onDispose { onFirstFocusReady?.invoke(false) }
|
||||
}
|
||||
}
|
||||
val cardRequester = when {
|
||||
isRestore && restoreFocusRequester != null -> restoreFocusRequester
|
||||
isFirst -> firstItemFocusRequester
|
||||
else -> null
|
||||
}
|
||||
EventCard(
|
||||
event = ev,
|
||||
nowMs = nowMs,
|
||||
onOpen = { onOpen(ev.id) },
|
||||
onFocus = onFocus,
|
||||
focusRequester = cardRequester,
|
||||
extraFocusRequester = when {
|
||||
isRestore && isFirst && firstItemFocusRequester != null &&
|
||||
restoreFocusRequester != null &&
|
||||
firstItemFocusRequester !== restoreFocusRequester -> firstItemFocusRequester
|
||||
else -> null
|
||||
},
|
||||
focusUp = focusUpTo.takeIf { isFirst || isRestore },
|
||||
focusRequester = firstItemFocusRequester.takeIf { isFirst },
|
||||
focusUp = focusUpTo.takeIf { isFirst },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -991,7 +924,6 @@ private fun EventCard(
|
|||
onOpen: () -> Unit,
|
||||
onFocus: () -> Unit,
|
||||
focusRequester: FocusRequester? = null,
|
||||
extraFocusRequester: FocusRequester? = null,
|
||||
focusUp: FocusRequester? = null,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
|
|
@ -1000,7 +932,6 @@ private fun EventCard(
|
|||
modifier = Modifier
|
||||
.width(280.dp)
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||
.then(if (extraFocusRequester != null) Modifier.focusRequester(extraFocusRequester) else Modifier)
|
||||
.then(
|
||||
if (focusUp != null) {
|
||||
Modifier.focusProperties { up = focusUp }
|
||||
|
|
@ -1166,6 +1097,7 @@ private fun McDetailButton(
|
|||
focusUp: FocusRequester? = null,
|
||||
focusDown: FocusRequester? = null,
|
||||
onFocus: (() -> Unit)? = null,
|
||||
onFocusChanged: ((Boolean) -> Unit)? = null,
|
||||
/** When gallery/rail is off-screen, scroll+focus before default D-pad move. */
|
||||
onFocusDownFallback: (() -> Unit)? = null,
|
||||
) {
|
||||
|
|
@ -1198,6 +1130,7 @@ private fun McDetailButton(
|
|||
},
|
||||
)
|
||||
.onFocusChanged {
|
||||
onFocusChanged?.invoke(it.isFocused)
|
||||
if (it.isFocused) onFocus?.invoke()
|
||||
}
|
||||
.heightIn(min = 36.dp, max = 40.dp)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
LaunchedEffect(tab) {
|
||||
val name = tab.name
|
||||
|
|
@ -772,6 +783,9 @@ private fun MainHub(
|
|||
navFocused = false
|
||||
onTvFocusContent()
|
||||
hubScope.launch {
|
||||
// Wacht tot EventsScreen placeholder of hero CTA de requester bindt.
|
||||
delay(160)
|
||||
runCatching { eventsContentFocus.requestFocus() }
|
||||
delay(120)
|
||||
runCatching { eventsContentFocus.requestFocus() }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue