restore Events focus to opened card without jumping to hero
This commit is contained in:
parent
929a2b1137
commit
0099f9079c
3 changed files with 163 additions and 17 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue