restore Events focus to opened card without jumping to hero

This commit is contained in:
Jos Vooges | STH 2026-09-19 01:28:10 +02:00
parent 929a2b1137
commit 0099f9079c
3 changed files with 163 additions and 17 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 = 121 versionCode = 122
versionName = "0.14.58" versionName = "0.14.59"
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

@ -113,11 +113,13 @@ fun EventsScreen(
contentFocusRequester: FocusRequester, contentFocusRequester: FocusRequester,
navFocusRequester: FocusRequester, navFocusRequester: FocusRequester,
focusRestoreNonce: Int = 0, focusRestoreNonce: Int = 0,
@Suppress("UNUSED_PARAMETER") restoreEventId: String? = null, restoreEventId: String? = null,
restoreFromHero: Boolean = false,
pendingFocusRestore: Boolean = false, pendingFocusRestore: Boolean = false,
onFocusRestoreConsumed: () -> Unit = {}, onFocusRestoreConsumed: () -> Unit = {},
onOpenEvent: (String) -> Unit, onOpenEvent: (String) -> Unit,
onPlayEvent: (String) -> Unit, onPlayEvent: (String) -> Unit,
onSetOpenedFromHero: (Boolean) -> Unit = {},
onContentFocused: () -> Unit = {}, onContentFocused: () -> Unit = {},
) { ) {
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) } var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
@ -128,11 +130,13 @@ fun EventsScreen(
var heroFocusReady by remember { mutableStateOf(false) } var heroFocusReady by remember { mutableStateOf(false) }
var chipsFocusReady by remember { mutableStateOf(false) } var chipsFocusReady by remember { mutableStateOf(false) }
var railFocusReady by remember { mutableStateOf(false) } var railFocusReady 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) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val chipsFocus = remember { FocusRequester() } val chipsFocus = remember { FocusRequester() }
val firstRailFocus = remember { FocusRequester() } val firstRailFocus = remember { FocusRequester() }
val restoreCardFocus = remember { FocusRequester() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val density = LocalDensity.current val density = LocalDensity.current
val screenH = LocalConfiguration.current.screenHeightDp.dp val screenH = LocalConfiguration.current.screenHeightDp.dp
@ -217,6 +221,58 @@ fun EventsScreen(
idx idx
} }
val restoreInLive = remember(live, restoreEventId, restoreFromHero) {
!restoreFromHero &&
!restoreEventId.isNullOrBlank() &&
live.any { it.id == restoreEventId }
}
val restoreUpcomingIndex = remember(upcomingRails, restoreEventId, restoreFromHero, restoreInLive) {
if (restoreFromHero || restoreInLive || restoreEventId.isNullOrBlank()) return@remember -1
upcomingRails.indexOfFirst { (_, day) -> day.any { it.id == restoreEventId } }
}
val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0
val restoreListIndex = remember(
showProviderChips,
live,
upcomingRails,
restoreInLive,
restoreUpcomingIndex,
) {
if (!canRestoreCard) return@remember -1
var idx = 1 // after spacer
if (showProviderChips) idx++
if (restoreInLive) return@remember idx
if (live.isNotEmpty()) idx++
if (restoreUpcomingIndex >= 0) return@remember idx + restoreUpcomingIndex
-1
}
// Eerste card van de eerste rail deelt firstRailFocus — géén tweede FocusRequester (dual-bind hang).
val restoreIsFirstOfFirstRail = remember(live, upcomingRails, restoreEventId, restoreInLive) {
if (restoreEventId.isNullOrBlank()) return@remember false
val firstRail = when {
live.isNotEmpty() -> live
upcomingRails.isNotEmpty() -> upcomingRails.first().second
else -> return@remember false
}
val isFirstCard = firstRail.firstOrNull()?.id == restoreEventId
isFirstCard && (
(restoreInLive && live.isNotEmpty()) ||
(!restoreInLive && live.isEmpty())
)
}
val activeRestoreFocus = when {
!canRestoreCard -> null
restoreIsFirstOfFirstRail -> when {
showHero || showProviderChips -> firstRailFocus
else -> contentFocusRequester
}
else -> restoreCardFocus
}
// Alleen tijdens pending restore binden — en nooit dual-bind met firstRailFocus.
val bindRestoreCardId = restoreEventId.takeIf {
pendingFocusRestore && canRestoreCard && !restoreIsFirstOfFirstRail
}
fun expandHeroFully() { fun expandHeroFully() {
if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) { if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
scope.launch { listState.animateScrollToItem(0) } scope.launch { listState.animateScrollToItem(0) }
@ -279,20 +335,51 @@ fun EventsScreen(
else -> railFocusReady else -> railFocusReady
} }
// Detail → back / tab restore: expand + focus entry (hero CTA or chips/rail). // Detail → back: card-restore als we vanaf een rail openden; anders hero CTA (veilig).
LaunchedEffect( LaunchedEffect(
pendingFocusRestore, pendingFocusRestore,
focusRestoreNonce, focusRestoreNonce,
hasContent, hasContent,
entryReady, canRestoreCard,
entryIsHero, restoreListIndex,
restoreIsFirstOfFirstRail,
) { ) {
if (!pendingFocusRestore || !hasContent || !entryReady) return@LaunchedEffect if (!pendingFocusRestore || !hasContent) return@LaunchedEffect
delay(60) delay(60)
onContentFocused() onContentFocused()
runCatching { listState.scrollToItem(0) }
delay(40) var focusedCard = false
runCatching { contentFocusRequester.requestFocus() } 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) {
// Hero/entry fallback — scroll naar top alleen als we géén card restoren.
val entryOk = awaitReady({
when {
showHero -> heroFocusReady
showProviderChips -> chipsFocusReady
else -> railFocusReady
}
}, timeoutMs = 500)
if (entryOk) {
runCatching { listState.scrollToItem(0) }
delay(40)
runCatching { contentFocusRequester.requestFocus() }
}
}
onFocusRestoreConsumed() onFocusRestoreConsumed()
} }
@ -369,8 +456,14 @@ fun EventsScreen(
expandHeroFully() expandHeroFully()
} }
}, },
onOpen = { id -> onOpenEvent(id) }, onOpen = { id ->
onPlay = { id -> onPlayEvent(id) }, onSetOpenedFromHero(true)
onOpenEvent(id)
},
onPlay = { id ->
onSetOpenedFromHero(true)
onPlayEvent(id)
},
onRequestNextFocus = { focusAfterHero() }, onRequestNextFocus = { focusAfterHero() },
) )
} }
@ -423,12 +516,20 @@ fun EventsScreen(
title = "Nu live", title = "Nu live",
events = live, events = live,
nowMs = nowMs, nowMs = nowMs,
onOpen = onOpenEvent, onOpen = { id ->
onSetOpenedFromHero(false)
onOpenEvent(id)
},
onFocus = onContentFocused, onFocus = onContentFocused,
firstItemFocusRequester = when { firstItemFocusRequester = when {
showHero || showProviderChips -> firstRailFocus showHero || showProviderChips -> firstRailFocus
else -> contentFocusRequester else -> contentFocusRequester
}, },
restoreEventId = bindRestoreCardId.takeIf { restoreInLive },
restoreFocusRequester = restoreCardFocus.takeIf {
bindRestoreCardId != null && restoreInLive
},
onRestoreFocusReady = { restoreCardReady = it },
focusUpTo = firstRailUpTarget, focusUpTo = firstRailUpTarget,
onFirstFocusReady = { railFocusReady = it }, onFirstFocusReady = { railFocusReady = it },
) )
@ -436,18 +537,29 @@ 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 = bindRestoreCardId != null &&
!restoreInLive &&
dayEvents.any { it.id == bindRestoreCardId }
item(key = "day-$label") { item(key = "day-$label") {
EventRail( EventRail(
title = label, title = label,
events = dayEvents, events = dayEvents,
nowMs = nowMs, nowMs = nowMs,
onOpen = onOpenEvent, onOpen = { id ->
onSetOpenedFromHero(false)
onOpenEvent(id)
},
onFocus = onContentFocused, onFocus = onContentFocused,
firstItemFocusRequester = when { firstItemFocusRequester = when {
!isFirstUpcoming -> null !isFirstUpcoming -> null
showHero || showProviderChips -> firstRailFocus showHero || showProviderChips -> firstRailFocus
else -> contentFocusRequester else -> contentFocusRequester
}, },
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
restoreFocusRequester = restoreCardFocus.takeIf { railHasRestore },
onRestoreFocusReady = { ready ->
if (railHasRestore) restoreCardReady = ready
},
focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming }, focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming },
onFirstFocusReady = { ready -> onFirstFocusReady = { ready ->
if (isFirstUpcoming) railFocusReady = ready if (isFirstUpcoming) railFocusReady = ready
@ -924,10 +1036,23 @@ 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,
onRestoreFocusReady: ((Boolean) -> Unit)? = 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,
@ -938,24 +1063,38 @@ 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
if (isFirst && firstItemFocusRequester != null) { val isRestore = restoreEventId != null && ev.id == restoreEventId
// Één FocusRequester per card — nooit dual-bind (extraFocusRequester hang).
val cardRequester = when {
isRestore && restoreFocusRequester != null -> restoreFocusRequester
isFirst -> firstItemFocusRequester
else -> null
}
if (isFirst && firstItemFocusRequester != null && cardRequester === firstItemFocusRequester) {
DisposableEffect(firstItemFocusRequester) { DisposableEffect(firstItemFocusRequester) {
onFirstFocusReady?.invoke(true) onFirstFocusReady?.invoke(true)
onDispose { onFirstFocusReady?.invoke(false) } onDispose { onFirstFocusReady?.invoke(false) }
} }
} }
if (isRestore && restoreFocusRequester != null && cardRequester === restoreFocusRequester) {
DisposableEffect(restoreFocusRequester) {
onRestoreFocusReady?.invoke(true)
onDispose { onRestoreFocusReady?.invoke(false) }
}
}
EventCard( EventCard(
event = ev, event = ev,
nowMs = nowMs, nowMs = nowMs,
onOpen = { onOpen(ev.id) }, onOpen = { onOpen(ev.id) },
onFocus = onFocus, onFocus = onFocus,
focusRequester = firstItemFocusRequester.takeIf { isFirst }, focusRequester = cardRequester,
focusUp = focusUpTo.takeIf { isFirst }, focusUp = focusUpTo.takeIf { isFirst || isRestore },
) )
} }
} }

View file

@ -130,6 +130,7 @@ fun McNav() {
var tvFocusNonce by remember { mutableIntStateOf(0) } var tvFocusNonce by remember { mutableIntStateOf(0) }
var eventsReturnNonce by remember { mutableIntStateOf(0) } var eventsReturnNonce by remember { mutableIntStateOf(0) }
var lastOpenedEventId by rememberSaveable { mutableStateOf<String?>(null) } var lastOpenedEventId by rememberSaveable { mutableStateOf<String?>(null) }
var lastOpenedFromHero by rememberSaveable { mutableStateOf(false) }
var pendingEventsFocusRestore by remember { mutableStateOf(false) } var pendingEventsFocusRestore by remember { mutableStateOf(false) }
val favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet()) val favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet())
val hubTab = remember(hubTabName) { val hubTab = remember(hubTabName) {
@ -326,8 +327,10 @@ fun McNav() {
onTvFocusContent = { tvFocusNonce++ }, onTvFocusContent = { tvFocusNonce++ },
eventsReturnNonce = eventsReturnNonce, eventsReturnNonce = eventsReturnNonce,
restoreEventId = lastOpenedEventId, restoreEventId = lastOpenedEventId,
restoreFromHero = lastOpenedFromHero,
pendingEventsFocusRestore = pendingEventsFocusRestore, pendingEventsFocusRestore = pendingEventsFocusRestore,
onEventsFocusRestoreConsumed = { pendingEventsFocusRestore = false }, onEventsFocusRestoreConsumed = { pendingEventsFocusRestore = false },
onSetOpenedFromHero = { lastOpenedFromHero = it },
profileAvatarId = profileAvatarId, profileAvatarId = profileAvatarId,
profileShowAvatar = profileShowAvatar, profileShowAvatar = profileShowAvatar,
profileAvatarUrl = profileAvatarUrl, profileAvatarUrl = profileAvatarUrl,
@ -547,8 +550,10 @@ private fun MainHub(
onTvFocusContent: () -> Unit, onTvFocusContent: () -> Unit,
eventsReturnNonce: Int = 0, eventsReturnNonce: Int = 0,
restoreEventId: String? = null, restoreEventId: String? = null,
restoreFromHero: Boolean = false,
pendingEventsFocusRestore: Boolean = false, pendingEventsFocusRestore: Boolean = false,
onEventsFocusRestoreConsumed: () -> Unit = {}, onEventsFocusRestoreConsumed: () -> Unit = {},
onSetOpenedFromHero: (Boolean) -> Unit = {},
profileAvatarId: String?, profileAvatarId: String?,
profileShowAvatar: Boolean = true, profileShowAvatar: Boolean = true,
profileAvatarUrl: String? = null, profileAvatarUrl: String? = null,
@ -931,10 +936,12 @@ private fun MainHub(
navFocusRequester = navFocus, navFocusRequester = navFocus,
focusRestoreNonce = eventsReturnNonce, focusRestoreNonce = eventsReturnNonce,
restoreEventId = restoreEventId, restoreEventId = restoreEventId,
restoreFromHero = restoreFromHero,
pendingFocusRestore = pendingEventsFocusRestore, pendingFocusRestore = pendingEventsFocusRestore,
onFocusRestoreConsumed = onEventsFocusRestoreConsumed, onFocusRestoreConsumed = onEventsFocusRestoreConsumed,
onOpenEvent = onOpenEvent, onOpenEvent = onOpenEvent,
onPlayEvent = onPlayEvent, onPlayEvent = onPlayEvent,
onSetOpenedFromHero = onSetOpenedFromHero,
onContentFocused = { navFocused = false }, onContentFocused = { navFocused = false },
) )
MainTab.Search -> SearchScreen( MainTab.Search -> SearchScreen(