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

View file

@ -113,11 +113,13 @@ fun EventsScreen(
contentFocusRequester: FocusRequester,
navFocusRequester: FocusRequester,
focusRestoreNonce: Int = 0,
@Suppress("UNUSED_PARAMETER") restoreEventId: String? = null,
restoreEventId: String? = null,
restoreFromHero: Boolean = false,
pendingFocusRestore: Boolean = false,
onFocusRestoreConsumed: () -> Unit = {},
onOpenEvent: (String) -> Unit,
onPlayEvent: (String) -> Unit,
onSetOpenedFromHero: (Boolean) -> Unit = {},
onContentFocused: () -> Unit = {},
) {
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
@ -128,11 +130,13 @@ fun EventsScreen(
var heroFocusReady by remember { mutableStateOf(false) }
var chipsFocusReady by remember { mutableStateOf(false) }
var railFocusReady by remember { mutableStateOf(false) }
var restoreCardReady by remember { mutableStateOf(false) }
var heroCtaFocused by remember { mutableStateOf(false) }
var placeholderHadFocus by remember { mutableStateOf(false) }
val listState = rememberLazyListState()
val chipsFocus = remember { FocusRequester() }
val firstRailFocus = remember { FocusRequester() }
val restoreCardFocus = remember { FocusRequester() }
val scope = rememberCoroutineScope()
val density = LocalDensity.current
val screenH = LocalConfiguration.current.screenHeightDp.dp
@ -217,6 +221,58 @@ fun EventsScreen(
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() {
if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
scope.launch { listState.animateScrollToItem(0) }
@ -279,20 +335,51 @@ fun EventsScreen(
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(
pendingFocusRestore,
focusRestoreNonce,
hasContent,
entryReady,
entryIsHero,
canRestoreCard,
restoreListIndex,
restoreIsFirstOfFirstRail,
) {
if (!pendingFocusRestore || !hasContent || !entryReady) return@LaunchedEffect
if (!pendingFocusRestore || !hasContent) return@LaunchedEffect
delay(60)
onContentFocused()
runCatching { listState.scrollToItem(0) }
delay(40)
runCatching { contentFocusRequester.requestFocus() }
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
}
}
}
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()
}
@ -369,8 +456,14 @@ fun EventsScreen(
expandHeroFully()
}
},
onOpen = { id -> onOpenEvent(id) },
onPlay = { id -> onPlayEvent(id) },
onOpen = { id ->
onSetOpenedFromHero(true)
onOpenEvent(id)
},
onPlay = { id ->
onSetOpenedFromHero(true)
onPlayEvent(id)
},
onRequestNextFocus = { focusAfterHero() },
)
}
@ -423,12 +516,20 @@ fun EventsScreen(
title = "Nu live",
events = live,
nowMs = nowMs,
onOpen = onOpenEvent,
onOpen = { id ->
onSetOpenedFromHero(false)
onOpenEvent(id)
},
onFocus = onContentFocused,
firstItemFocusRequester = when {
showHero || showProviderChips -> firstRailFocus
else -> contentFocusRequester
},
restoreEventId = bindRestoreCardId.takeIf { restoreInLive },
restoreFocusRequester = restoreCardFocus.takeIf {
bindRestoreCardId != null && restoreInLive
},
onRestoreFocusReady = { restoreCardReady = it },
focusUpTo = firstRailUpTarget,
onFirstFocusReady = { railFocusReady = it },
)
@ -436,18 +537,29 @@ fun EventsScreen(
}
upcomingRails.forEachIndexed { index, (label, dayEvents) ->
val isFirstUpcoming = live.isEmpty() && index == 0
val railHasRestore = bindRestoreCardId != null &&
!restoreInLive &&
dayEvents.any { it.id == bindRestoreCardId }
item(key = "day-$label") {
EventRail(
title = label,
events = dayEvents,
nowMs = nowMs,
onOpen = onOpenEvent,
onOpen = { id ->
onSetOpenedFromHero(false)
onOpenEvent(id)
},
onFocus = onContentFocused,
firstItemFocusRequester = when {
!isFirstUpcoming -> null
showHero || showProviderChips -> firstRailFocus
else -> contentFocusRequester
},
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
restoreFocusRequester = restoreCardFocus.takeIf { railHasRestore },
onRestoreFocusReady = { ready ->
if (railHasRestore) restoreCardReady = ready
},
focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming },
onFirstFocusReady = { ready ->
if (isFirstUpcoming) railFocusReady = ready
@ -924,10 +1036,23 @@ private fun EventRail(
onOpen: (String) -> Unit,
onFocus: () -> Unit,
firstItemFocusRequester: FocusRequester? = null,
restoreEventId: String? = null,
restoreFocusRequester: FocusRequester? = null,
onRestoreFocusReady: ((Boolean) -> Unit)? = 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,
@ -938,24 +1063,38 @@ 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
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) {
onFirstFocusReady?.invoke(true)
onDispose { onFirstFocusReady?.invoke(false) }
}
}
if (isRestore && restoreFocusRequester != null && cardRequester === restoreFocusRequester) {
DisposableEffect(restoreFocusRequester) {
onRestoreFocusReady?.invoke(true)
onDispose { onRestoreFocusReady?.invoke(false) }
}
}
EventCard(
event = ev,
nowMs = nowMs,
onOpen = { onOpen(ev.id) },
onFocus = onFocus,
focusRequester = firstItemFocusRequester.takeIf { isFirst },
focusUp = focusUpTo.takeIf { isFirst },
focusRequester = cardRequester,
focusUp = focusUpTo.takeIf { isFirst || isRestore },
)
}
}

View file

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