fix Events chips focus, default Back on upcoming detail, restore Nu live rail
This commit is contained in:
parent
55710da15b
commit
ccb42db2a1
3 changed files with 99 additions and 55 deletions
|
|
@ -20,8 +20,8 @@ android {
|
|||
applicationId = "nl.vonas.mediacluster.tv"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 125
|
||||
versionName = "0.14.62"
|
||||
versionCode = 126
|
||||
versionName = "0.14.63"
|
||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
|
|
|
|||
|
|
@ -99,7 +99,9 @@ fun EventsScreen(
|
|||
var providerFilter by remember { mutableStateOf<String?>(null) }
|
||||
var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
var chipsFocusReady by remember { mutableStateOf(false) }
|
||||
var railFocusReady by remember { mutableStateOf(false) }
|
||||
/** Owner-id van first-rail FocusRequester — voorkomt dispose-race live↔upcoming. */
|
||||
var railFocusOwner by remember { mutableStateOf<String?>(null) }
|
||||
val railFocusReady = railFocusOwner != null
|
||||
var restoreCardReady by remember { mutableStateOf(false) }
|
||||
var placeholderHadFocus by remember { mutableStateOf(false) }
|
||||
val listState = rememberLazyListState()
|
||||
|
|
@ -135,31 +137,56 @@ fun EventsScreen(
|
|||
if (providerFilter.isNullOrBlank()) events
|
||||
else events.filter { it.provider.equals(providerFilter, ignoreCase = true) }
|
||||
}
|
||||
val live = remember(filtered) { filtered.filter { it.phase == "live" || it.isLive } }
|
||||
// Client-side time window + API flags — geen hero-art filter op rails.
|
||||
val live = remember(filtered, nowMs) {
|
||||
filtered.filter { eventIsLive(it, nowMs) }
|
||||
.sortedByDescending { it.playable }
|
||||
}
|
||||
val byDay = remember(filtered, nowMs) { groupEventsByDay(filtered) }
|
||||
val providers = remember(events) {
|
||||
events.map { it.provider }.distinct().sorted()
|
||||
}
|
||||
val showProviderChips = providers.size > 1
|
||||
val upcomingRails = remember(byDay) {
|
||||
val upcomingRails = remember(byDay, nowMs) {
|
||||
byDay.mapNotNull { (label, dayEvents) ->
|
||||
val upcoming = dayEvents.filter { it.phase != "live" && !it.isLive }
|
||||
val upcoming = dayEvents.filter { !eventIsLive(it, nowMs) }
|
||||
if (upcoming.isEmpty()) null else label to upcoming
|
||||
}
|
||||
}
|
||||
val hasContent = filtered.isNotEmpty()
|
||||
val hasRails = live.isNotEmpty() || upcomingRails.isNotEmpty()
|
||||
val firstRailOwnerId = when {
|
||||
live.isNotEmpty() -> "live"
|
||||
upcomingRails.isNotEmpty() -> "day-${upcomingRails.first().first}"
|
||||
else -> null
|
||||
}
|
||||
|
||||
// Focus: TopNav → chips (optioneel) → first rail card
|
||||
val chipsDownTarget = firstRailFocus.takeIf { railFocusReady }
|
||||
// Focus: TopNav → chips (optioneel) → first rail card.
|
||||
// Nooit focusProperties.down op firstRailFocus (stale/uninitialized na filter) —
|
||||
// chips gebruiken altijd onFocusDownFallback → focusFirstRail().
|
||||
val firstRailUpTarget = when {
|
||||
showProviderChips && chipsFocusReady -> contentFocusRequester
|
||||
else -> navFocusRequester
|
||||
}
|
||||
|
||||
fun claimRailFocus(owner: String, ready: Boolean) {
|
||||
if (ready) {
|
||||
if (owner == firstRailOwnerId) railFocusOwner = owner
|
||||
} else if (railFocusOwner == owner) {
|
||||
railFocusOwner = null
|
||||
}
|
||||
}
|
||||
|
||||
// Filter-wissel: scroll omhoog (Nu live zichtbaar) + forceer re-bind first rail.
|
||||
LaunchedEffect(providerFilter) {
|
||||
railFocusOwner = null
|
||||
runCatching { listState.scrollToItem(0) }
|
||||
}
|
||||
|
||||
val firstRailListIndex = remember(showProviderChips, live, upcomingRails) {
|
||||
if (live.isEmpty() && upcomingRails.isEmpty()) return@remember -1
|
||||
var idx = 1 // after top spacer
|
||||
// Geen hero/top-spacer meer: index 0 = chips of eerste rail.
|
||||
var idx = 0
|
||||
if (showProviderChips) idx++
|
||||
idx
|
||||
}
|
||||
|
|
@ -180,7 +207,7 @@ fun EventsScreen(
|
|||
restoreUpcomingIndex,
|
||||
) {
|
||||
if (!canRestoreCard) return@remember -1
|
||||
var idx = 1 // after top spacer
|
||||
var idx = 0
|
||||
if (showProviderChips) idx++
|
||||
if (restoreInLive) return@remember idx
|
||||
if (live.isNotEmpty()) idx++
|
||||
|
|
@ -223,14 +250,13 @@ fun EventsScreen(
|
|||
|
||||
fun focusFirstRail() {
|
||||
scope.launch {
|
||||
if (!awaitReady({ railFocusReady })) return@launch
|
||||
runCatching {
|
||||
if (firstRailListIndex >= 0) {
|
||||
listState.animateScrollToItem(firstRailListIndex)
|
||||
runCatching { listState.scrollToItem(0) }
|
||||
if (firstRailListIndex > 0) {
|
||||
runCatching { listState.scrollToItem(firstRailListIndex) }
|
||||
}
|
||||
if (!awaitReady({ railFocusReady }, timeoutMs = 1200)) return@launch
|
||||
delay(40)
|
||||
}
|
||||
firstRailFocus.requestFocus()
|
||||
}
|
||||
runCatching { firstRailFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -359,9 +385,7 @@ fun EventsScreen(
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 48.dp),
|
||||
) {
|
||||
item(key = "top-spacer") {
|
||||
Spacer(Modifier.height(HomeChrome.BadgeTop + 24.dp))
|
||||
}
|
||||
// TopNav reserveert al ruimte (niet-overlay) — geen BadgeTop-spacer.
|
||||
if (showProviderChips) {
|
||||
item(key = "providers") {
|
||||
ProviderChips(
|
||||
|
|
@ -370,14 +394,10 @@ fun EventsScreen(
|
|||
onSelect = { providerFilter = it },
|
||||
firstFocusRequester = contentFocusRequester,
|
||||
focusUp = navFocusRequester,
|
||||
focusDown = chipsDownTarget,
|
||||
onFocusReady = { chipsFocusReady = it },
|
||||
onFocus = onContentFocused,
|
||||
onFocusDownFallback = if (chipsDownTarget == null) {
|
||||
({ focusFirstRail() })
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// Altijd expliciet requestFocus — nooit stale focusProperties.down.
|
||||
onFocusDownFallback = { focusFirstRail() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -400,12 +420,15 @@ fun EventsScreen(
|
|||
},
|
||||
onRestoreFocusReady = { restoreCardReady = it },
|
||||
focusUpTo = firstRailUpTarget,
|
||||
onFirstFocusReady = { railFocusReady = it },
|
||||
onFirstFocusReady = { ready ->
|
||||
claimRailFocus("live", ready)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
upcomingRails.forEachIndexed { index, (label, dayEvents) ->
|
||||
val isFirstUpcoming = live.isEmpty() && index == 0
|
||||
val ownerId = "day-$label"
|
||||
val railHasRestore = bindRestoreCardId != null &&
|
||||
!restoreInLive &&
|
||||
dayEvents.any { it.id == bindRestoreCardId }
|
||||
|
|
@ -427,8 +450,10 @@ fun EventsScreen(
|
|||
if (railHasRestore) restoreCardReady = ready
|
||||
},
|
||||
focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming },
|
||||
onFirstFocusReady = { ready ->
|
||||
if (isFirstUpcoming) railFocusReady = ready
|
||||
onFirstFocusReady = if (isFirstUpcoming) {
|
||||
{ ready -> claimRailFocus(ownerId, ready) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -604,22 +629,27 @@ fun EventDetailScreen(
|
|||
focusDown = bodyFocus.takeIf { hasFocusableBody },
|
||||
onClick = { onPlay(ev.id) },
|
||||
)
|
||||
} else {
|
||||
McDetailButton(
|
||||
ctaLabel(ev, nowMs),
|
||||
primary = false,
|
||||
enabled = false,
|
||||
focusRequester = playFocus,
|
||||
focusDown = bodyFocus.takeIf { hasFocusableBody },
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
McDetailButton(
|
||||
"Terug",
|
||||
focusRequester = backFocus,
|
||||
focusDown = bodyFocus.takeIf { hasFocusableBody },
|
||||
onClick = onBack,
|
||||
)
|
||||
} else {
|
||||
// Disabled CTA links, buiten focus-order; default focus op Terug.
|
||||
McDetailButton(
|
||||
ctaLabel(ev, nowMs),
|
||||
primary = false,
|
||||
enabled = false,
|
||||
onClick = {},
|
||||
)
|
||||
McDetailButton(
|
||||
"Terug",
|
||||
focusRequester = playFocus,
|
||||
focusDown = bodyFocus.takeIf { hasFocusableBody },
|
||||
onClick = onBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFocusableBody) {
|
||||
|
|
@ -735,7 +765,7 @@ private fun EventRail(
|
|||
else -> null
|
||||
}
|
||||
if (isFirst && firstItemFocusRequester != null && cardRequester === firstItemFocusRequester) {
|
||||
DisposableEffect(firstItemFocusRequester) {
|
||||
DisposableEffect(firstItemFocusRequester, events.firstOrNull()?.id) {
|
||||
onFirstFocusReady?.invoke(true)
|
||||
onDispose { onFirstFocusReady?.invoke(false) }
|
||||
}
|
||||
|
|
@ -873,14 +903,17 @@ private fun ProviderChips(
|
|||
onSelect: (String?) -> Unit,
|
||||
firstFocusRequester: FocusRequester? = null,
|
||||
focusUp: FocusRequester? = null,
|
||||
focusDown: FocusRequester? = null,
|
||||
onFocusReady: ((Boolean) -> Unit)? = null,
|
||||
onFocus: () -> Unit = {},
|
||||
onFocusDownFallback: (() -> Unit)? = null,
|
||||
) {
|
||||
// Mirror TopNav subItems (Films/Series): EdgeInset + vertical 4.dp, bottom 8.dp.
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
) {
|
||||
item {
|
||||
Chip(
|
||||
|
|
@ -888,7 +921,6 @@ private fun ProviderChips(
|
|||
active = selected == null,
|
||||
focusRequester = firstFocusRequester,
|
||||
focusUp = focusUp,
|
||||
focusDown = focusDown,
|
||||
onFocusReady = onFocusReady,
|
||||
onFocus = onFocus,
|
||||
onFocusDownFallback = onFocusDownFallback,
|
||||
|
|
@ -900,7 +932,6 @@ private fun ProviderChips(
|
|||
label = prettyProvider(p),
|
||||
active = selected.equals(p, ignoreCase = true),
|
||||
focusUp = focusUp,
|
||||
focusDown = focusDown,
|
||||
onFocus = onFocus,
|
||||
onFocusDownFallback = onFocusDownFallback,
|
||||
onClick = { onSelect(p) },
|
||||
|
|
@ -1018,15 +1049,17 @@ private fun McDetailButton(
|
|||
val mod = Modifier
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||
.then(
|
||||
if (focusUp != null || (focusDown != null && onFocusDownFallback == null)) {
|
||||
when {
|
||||
!enabled -> Modifier.focusProperties { canFocus = false }
|
||||
focusUp != null || (focusDown != null && onFocusDownFallback == null) -> {
|
||||
Modifier.focusProperties {
|
||||
focusUp?.let { up = it }
|
||||
if (onFocusDownFallback == null) {
|
||||
focusDown?.let { down = it }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
else -> Modifier
|
||||
},
|
||||
)
|
||||
.then(
|
||||
|
|
@ -1093,7 +1126,7 @@ private fun McDetailButton(
|
|||
|
||||
private fun statusVisual(event: ScheduleEvent, nowMs: Long): Pair<String, Color> {
|
||||
return when {
|
||||
event.phase == "live" || event.isLive -> "LIVE" to McColors.Accent
|
||||
eventIsLive(event, nowMs) -> "LIVE" to McColors.Accent
|
||||
minutesUntilStart(event, nowMs)?.let { it in 0 until 60 } == true -> {
|
||||
val m = minutesUntilStart(event, nowMs)!!
|
||||
"Straks · over ${m} min" to Color(0xFFE67E22)
|
||||
|
|
@ -1103,7 +1136,7 @@ private fun statusVisual(event: ScheduleEvent, nowMs: Long): Pair<String, Color>
|
|||
}
|
||||
|
||||
private fun ctaLabel(event: ScheduleEvent, nowMs: Long): String {
|
||||
if (event.phase == "live" || event.isLive) return "Stream start binnenkort"
|
||||
if (eventIsLive(event, nowMs)) return "Stream start binnenkort"
|
||||
val mins = minutesUntilStart(event, nowMs)
|
||||
if (mins != null && mins in 0 until 60) return "Start over $mins min"
|
||||
val start = parseZoned(event.startLocal, event.start) ?: return "Meer info"
|
||||
|
|
@ -1111,7 +1144,7 @@ private fun ctaLabel(event: ScheduleEvent, nowMs: Long): String {
|
|||
}
|
||||
|
||||
private fun shortTimeLabel(event: ScheduleEvent, nowMs: Long): String {
|
||||
if (event.phase == "live" || event.isLive) {
|
||||
if (eventIsLive(event, nowMs)) {
|
||||
val left = minutesUntilEnd(event, nowMs)
|
||||
return if (left != null && left > 0) "Nog $left min" else "Nu"
|
||||
}
|
||||
|
|
@ -1135,7 +1168,7 @@ private fun detailTimeWindowLabel(event: ScheduleEvent, nowMs: Long): String {
|
|||
} else {
|
||||
start.format(TimeFmt)
|
||||
}
|
||||
if (event.phase == "live" || event.isLive) return range
|
||||
if (eventIsLive(event, nowMs)) return range
|
||||
val today = Instant.ofEpochMilli(nowMs).atZone(ZoneNl).toLocalDate()
|
||||
val day = start.toLocalDate()
|
||||
return when {
|
||||
|
|
@ -1145,6 +1178,15 @@ private fun detailTimeWindowLabel(event: ScheduleEvent, nowMs: Long): String {
|
|||
}
|
||||
}
|
||||
|
||||
/** API-flags + lokale start/stop-window (zelfde logica als master-api phaseFor). */
|
||||
private fun eventIsLive(event: ScheduleEvent, nowMs: Long): Boolean {
|
||||
if (event.phase.equals("live", ignoreCase = true) || event.isLive) return true
|
||||
val start = parseZoned(event.startLocal, event.start) ?: return false
|
||||
val stop = parseZoned(event.stopLocal, event.stop) ?: return false
|
||||
val now = Instant.ofEpochMilli(nowMs).atZone(ZoneNl)
|
||||
return !now.isBefore(start) && now.isBefore(stop)
|
||||
}
|
||||
|
||||
private fun isStreamLabel(value: String?): Boolean {
|
||||
val s = value?.trim().orEmpty()
|
||||
if (s.isEmpty()) return false
|
||||
|
|
|
|||
|
|
@ -730,7 +730,9 @@ private fun MainHub(
|
|||
prevTabName = name
|
||||
}
|
||||
|
||||
val homeOverlay = tab == MainTab.Home || tab == MainTab.Events
|
||||
// Events niet meer overlay: provider chips zitten in content met dezelfde
|
||||
// verticale start als Films/Series-categoriechips onder de vaste TopNav.
|
||||
val homeOverlay = tab == MainTab.Home
|
||||
val density = LocalDensity.current
|
||||
var topNavHeightPx by remember { mutableIntStateOf(0) }
|
||||
val topNavBlockHeight = with(density) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue