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"
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
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) }
|
runCatching { listState.scrollToItem(0) }
|
||||||
delay(40)
|
delay(40)
|
||||||
runCatching { contentFocusRequester.requestFocus() }
|
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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue