fix Events hero overlay/focus chain and skip imageless heroes

This commit is contained in:
Jos Vooges | STH 2026-09-19 01:08:53 +02:00
parent 79f15719a3
commit 5115682427
2 changed files with 290 additions and 79 deletions

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24
targetSdk = 36
versionCode = 119
versionName = "0.14.56"
versionCode = 120
versionName = "0.14.57"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -33,6 +33,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -44,12 +45,15 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -90,7 +94,7 @@ fun EventsScreen(
contentFocusRequester: FocusRequester,
navFocusRequester: FocusRequester,
focusRestoreNonce: Int = 0,
restoreEventId: String? = null,
@Suppress("UNUSED_PARAMETER") restoreEventId: String? = null,
pendingFocusRestore: Boolean = false,
onFocusRestoreConsumed: () -> Unit = {},
onOpenEvent: (String) -> Unit,
@ -103,11 +107,27 @@ fun EventsScreen(
var providerFilter by remember { mutableStateOf<String?>(null) }
var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) }
var heroFocusReady by remember { mutableStateOf(false) }
var chipsFocusReady by remember { mutableStateOf(false) }
var railFocusReady 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 scope = rememberCoroutineScope()
val density = LocalDensity.current
val screenH = LocalConfiguration.current.screenHeightDp.dp
// Mirror Home: full-bleed overlay hero + spacer-driven collapse (no title stuck between rails).
val heroH = remember(screenH) { (screenH.value * 0.78f).dp.coerceIn(420.dp, screenH) }
val shelfStart = remember(screenH) { (screenH.value * 0.52f).dp.coerceIn(300.dp, screenH * 0.58f) }
val heroHpx = with(density) { heroH.toPx() }
val shelfStartPx = with(density) { shelfStart.toPx() }
val collapsePx = if (listState.firstVisibleItemIndex == 0) {
listState.firstVisibleItemScrollOffset.toFloat().coerceIn(0f, shelfStartPx)
} else {
shelfStartPx
}
suspend fun refresh() {
runCatching { api.listScheduleEvents() }
@ -119,9 +139,7 @@ fun EventsScreen(
loading = false
}
LaunchedEffect(Unit) {
refresh()
}
LaunchedEffect(Unit) { refresh() }
LaunchedEffect(Unit) {
while (true) {
delay(75_000)
@ -141,40 +159,93 @@ fun EventsScreen(
}
val live = remember(filtered) { filtered.filter { it.phase == "live" || it.isLive } }
val liveHeroItems = remember(live) {
live.filter { it.playable }.ifEmpty { live }.take(8)
val withArt = live.filter { hasUsableHeroArt(it) }
withArt.filter { it.playable }.ifEmpty { withArt }.take(8)
}
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) {
byDay.mapNotNull { (label, dayEvents) ->
val upcoming = dayEvents.filter { it.phase != "live" && !it.isLive }
if (upcoming.isEmpty()) null else label to upcoming
}
}
val hasHeroContent = filtered.isNotEmpty()
val heroItems = liveHeroItems.ifEmpty { listOfNotNull(filtered.firstOrNull()) }
val heroHeight = 480.dp
// Rails starten onder de hero met lichte overlap (Home-achtig).
val railStart = 420.dp
val firstRailListIndex = remember(providers, live, upcomingRails) {
var idx = 1 // after spacer
if (providers.size > 1) idx++
if (live.isNotEmpty() || upcomingRails.isNotEmpty()) idx else -1
val hasContent = filtered.isNotEmpty()
val showHero = liveHeroItems.isNotEmpty()
val hasRails = live.isNotEmpty() || upcomingRails.isNotEmpty()
// Focus chain: Hero CTA → ProviderChips → first rail card
val heroDownTarget = when {
showProviderChips && chipsFocusReady -> chipsFocus
railFocusReady -> firstRailFocus
else -> null
}
val chipsUpTarget = contentFocusRequester.takeIf { showHero && heroFocusReady }
val chipsDownTarget = firstRailFocus.takeIf { railFocusReady }
val firstRailUpTarget = when {
showProviderChips && chipsFocusReady -> chipsFocus
showHero && heroFocusReady -> contentFocusRequester
else -> null
}
suspend fun awaitRailReady(timeoutMs: Long = 900): Boolean {
val firstRailListIndex = remember(showProviderChips, live, upcomingRails) {
if (live.isEmpty() && upcomingRails.isEmpty()) return@remember -1
var idx = 1 // after spacer
if (showProviderChips) idx++
idx
}
val chipsListIndex = remember(showProviderChips) {
if (!showProviderChips) -1 else 1 // after spacer
}
fun expandHeroFully() {
if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
scope.launch { listState.animateScrollToItem(0) }
}
}
LaunchedEffect(listState) {
snapshotFlow {
Triple(
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset,
listState.isScrollInProgress,
)
}.collect { (index, offset, scrolling) ->
if (!scrolling && index == 0 && offset in 1..240) {
listState.animateScrollToItem(0)
}
}
}
suspend fun awaitReady(ready: () -> Boolean, timeoutMs: Long = 900): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (!railFocusReady && System.currentTimeMillis() < deadline) {
while (!ready() && System.currentTimeMillis() < deadline) {
delay(40)
}
return railFocusReady
return ready()
}
fun focusProviderChips() {
scope.launch {
if (!showProviderChips) return@launch
if (!awaitReady({ chipsFocusReady })) return@launch
runCatching {
if (chipsListIndex >= 0) {
listState.animateScrollToItem(chipsListIndex)
delay(40)
}
chipsFocus.requestFocus()
}
}
}
fun focusFirstRail() {
scope.launch {
if (!awaitRailReady()) return@launch
if (!awaitReady({ railFocusReady })) return@launch
runCatching {
if (firstRailListIndex >= 0) {
listState.animateScrollToItem(firstRailListIndex)
@ -185,14 +256,27 @@ fun EventsScreen(
}
}
// Stabiele restore: altijd hero CTA — geen card-FocusRequester races.
fun focusAfterHero() {
if (showProviderChips) focusProviderChips() else focusFirstRail()
}
// Entry focus target when there is no hero: chips or first rail bind contentFocusRequester.
val entryIsHero = showHero
val entryReady = when {
entryIsHero -> heroFocusReady
showProviderChips -> chipsFocusReady
else -> railFocusReady
}
// Detail → back / tab restore: expand + focus entry (hero CTA or chips/rail).
LaunchedEffect(
pendingFocusRestore,
focusRestoreNonce,
hasHeroContent,
heroFocusReady,
hasContent,
entryReady,
entryIsHero,
) {
if (!pendingFocusRestore || !hasHeroContent || !heroFocusReady) return@LaunchedEffect
if (!pendingFocusRestore || !hasContent || !entryReady) return@LaunchedEffect
delay(60)
onContentFocused()
runCatching { listState.scrollToItem(0) }
@ -201,10 +285,8 @@ fun EventsScreen(
onFocusRestoreConsumed()
}
// Placeholder had focus tijdens laden → doorzetten naar hero CTA zodra klaar.
var placeholderHadFocus by remember { mutableStateOf(false) }
LaunchedEffect(heroFocusReady, hasHeroContent) {
if (!heroFocusReady || !hasHeroContent || !placeholderHadFocus) return@LaunchedEffect
LaunchedEffect(entryReady, hasContent) {
if (!entryReady || !hasContent || !placeholderHadFocus) return@LaunchedEffect
delay(40)
onContentFocused()
runCatching { contentFocusRequester.requestFocus() }
@ -212,11 +294,11 @@ fun EventsScreen(
}
val pauseCarousel = pendingFocusRestore || heroCtaFocused
// NOOIT dual-bind: placeholder alleen als entry-target NIET in de tree hangt.
val entryInTree = hasContent && (showHero || showProviderChips || hasRails)
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
// NOOIT dual-bind: placeholder alleen als hero NIET in de tree hangt.
// (Home had dezelfde race: placeholder + CTA tegelijk → Shield hang.)
if (!hasHeroContent) {
if (!entryInTree) {
Box(
Modifier
.size(1.dp)
@ -240,51 +322,85 @@ fun EventsScreen(
Text(error!!, color = McColors.Danger, fontSize = 16.sp)
}
}
!hasHeroContent -> {
!hasContent -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Geen events in de komende dagen", color = McColors.TextDim, fontSize = 18.sp)
}
}
else -> {
// Hero BUITEN LazyColumn — zelfde stabiele patroon als Home SpotlightHero.
if (showHero) {
Box(
Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.height(heroHeight),
.height(heroH)
.graphicsLayer {
translationY = -collapsePx
alpha = (1f - (collapsePx / heroHpx).coerceIn(0f, 1f) * 0.85f)
},
) {
EventHero(
events = heroItems,
events = liveHeroItems,
nowMs = nowMs,
focusRequester = contentFocusRequester,
navFocusRequester = navFocusRequester,
railFocusRequester = firstRailFocus.takeIf { railFocusReady },
nextDownFocusRequester = heroDownTarget,
pauseCarousel = pauseCarousel,
onFocusReady = { heroFocusReady = it },
onFocus = onContentFocused,
onFocus = {
onContentFocused()
expandHeroFully()
},
onCtaFocusChanged = { focused ->
heroCtaFocused = focused
if (focused) onContentFocused()
if (focused) {
onContentFocused()
expandHeroFully()
}
},
onOpen = { id -> onOpenEvent(id) },
onPlay = { id -> onPlayEvent(id) },
onRequestRailFocus = { focusFirstRail() },
onRequestNextFocus = { focusAfterHero() },
)
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 48.dp),
) {
item(key = "hero-spacer") {
Spacer(Modifier.height(railStart))
// With hero: spacer drives collapse. Without: clear TopNav.
Spacer(
Modifier.height(
if (showHero) shelfStart else HomeChrome.BadgeTop + 24.dp,
),
)
}
if (providers.size > 1) {
if (showProviderChips) {
item(key = "providers") {
ProviderChips(
providers = providers,
selected = providerFilter,
onSelect = { providerFilter = it },
firstFocusRequester = if (showHero) {
chipsFocus
} else {
contentFocusRequester
},
focusUp = chipsUpTarget,
focusDown = chipsDownTarget,
onFocusReady = { chipsFocusReady = it },
onFocus = {
onContentFocused()
if (showHero) expandHeroFully()
},
onFocusDownFallback = if (chipsDownTarget == null) {
({ focusFirstRail() })
} else {
null
},
)
}
}
@ -296,11 +412,12 @@ fun EventsScreen(
nowMs = nowMs,
onOpen = onOpenEvent,
onFocus = onContentFocused,
firstItemFocusRequester = firstRailFocus.takeIf { live.isNotEmpty() },
focusUpTo = contentFocusRequester.takeIf { heroFocusReady },
onFirstFocusReady = { ready ->
if (live.isNotEmpty()) railFocusReady = ready
firstItemFocusRequester = when {
showHero || showProviderChips -> firstRailFocus
else -> contentFocusRequester
},
focusUpTo = firstRailUpTarget,
onFirstFocusReady = { railFocusReady = it },
)
}
}
@ -313,8 +430,12 @@ fun EventsScreen(
nowMs = nowMs,
onOpen = onOpenEvent,
onFocus = onContentFocused,
firstItemFocusRequester = firstRailFocus.takeIf { isFirstUpcoming },
focusUpTo = contentFocusRequester.takeIf { heroFocusReady && isFirstUpcoming },
firstItemFocusRequester = when {
!isFirstUpcoming -> null
showHero || showProviderChips -> firstRailFocus
else -> contentFocusRequester
},
focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming },
onFirstFocusReady = { ready ->
if (isFirstUpcoming) railFocusReady = ready
},
@ -674,14 +795,14 @@ private fun EventHero(
nowMs: Long,
focusRequester: FocusRequester,
navFocusRequester: FocusRequester,
railFocusRequester: FocusRequester?,
nextDownFocusRequester: FocusRequester?,
pauseCarousel: Boolean = false,
onFocusReady: (Boolean) -> Unit,
onFocus: () -> Unit,
onCtaFocusChanged: (Boolean) -> Unit = {},
onOpen: (String) -> Unit,
onPlay: (String) -> Unit,
onRequestRailFocus: () -> Unit = {},
onRequestNextFocus: () -> Unit = {},
) {
if (events.isEmpty()) return
var heroIndex by remember { mutableIntStateOf(0) }
@ -701,8 +822,7 @@ private fun EventHero(
Box(
Modifier
.fillMaxWidth()
.height(480.dp)
.fillMaxSize()
.clip(RectangleShape)
.background(Color(0xFF0B0B0B)),
) {
@ -800,11 +920,11 @@ private fun EventHero(
primary = true,
focusRequester = focusRequester,
focusUp = navFocusRequester,
focusDown = railFocusRequester,
focusDown = nextDownFocusRequester,
onFocus = onFocus,
onFocusChanged = onCtaFocusChanged,
onFocusDownFallback = if (railFocusRequester == null) {
({ onRequestRailFocus() })
onFocusDownFallback = if (nextDownFocusRequester == null) {
({ onRequestNextFocus() })
} else {
null
},
@ -816,10 +936,10 @@ private fun EventHero(
McDetailButton(
"Meer info",
focusUp = navFocusRequester,
focusDown = railFocusRequester,
focusDown = nextDownFocusRequester,
onFocus = onFocus,
onFocusDownFallback = if (railFocusRequester == null) {
({ onRequestRailFocus() })
onFocusDownFallback = if (nextDownFocusRequester == null) {
({ onRequestNextFocus() })
} else {
null
},
@ -1028,25 +1148,97 @@ private fun ProviderChips(
providers: List<String>,
selected: String?,
onSelect: (String?) -> Unit,
firstFocusRequester: FocusRequester? = null,
focusUp: FocusRequester? = null,
focusDown: FocusRequester? = null,
onFocusReady: ((Boolean) -> Unit)? = null,
onFocus: () -> Unit = {},
onFocusDownFallback: (() -> Unit)? = null,
) {
LazyRow(
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Chip("Alles", selected == null) { onSelect(null) }
Chip(
label = "Alles",
active = selected == null,
focusRequester = firstFocusRequester,
focusUp = focusUp,
focusDown = focusDown,
onFocusReady = onFocusReady,
onFocus = onFocus,
onFocusDownFallback = onFocusDownFallback,
onClick = { onSelect(null) },
)
}
items(providers) { p ->
Chip(prettyProvider(p), selected.equals(p, ignoreCase = true)) { onSelect(p) }
Chip(
label = prettyProvider(p),
active = selected.equals(p, ignoreCase = true),
focusUp = focusUp,
focusDown = focusDown,
onFocus = onFocus,
onFocusDownFallback = onFocusDownFallback,
onClick = { onSelect(p) },
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun Chip(label: String, active: Boolean, onClick: () -> Unit) {
private fun Chip(
label: String,
active: Boolean,
onClick: () -> Unit,
focusRequester: FocusRequester? = null,
focusUp: FocusRequester? = null,
focusDown: FocusRequester? = null,
onFocusReady: ((Boolean) -> Unit)? = null,
onFocus: (() -> Unit)? = null,
onFocusDownFallback: (() -> Unit)? = null,
) {
if (focusRequester != null) {
DisposableEffect(focusRequester) {
onFocusReady?.invoke(true)
onDispose { onFocusReady?.invoke(false) }
}
}
val mod = Modifier
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.then(
if (focusUp != null || (focusDown != null && onFocusDownFallback == null)) {
Modifier.focusProperties {
focusUp?.let { up = it }
if (onFocusDownFallback == null) {
focusDown?.let { down = it }
}
}
} else {
Modifier
},
)
.then(
if (onFocusDownFallback != null) {
Modifier.onPreviewKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown && ev.key == Key.DirectionDown) {
onFocusDownFallback()
true
} else {
false
}
}
} else {
Modifier
},
)
.onFocusChanged {
if (it.isFocused) onFocus?.invoke()
}
Surface(
onClick = onClick,
modifier = mod,
colors = ClickableSurfaceDefaults.colors(
containerColor = if (active) McColors.Accent else McColors.Surface,
focusedContainerColor = if (active) McColors.Accent else McColors.SurfaceFocus,
@ -1240,9 +1432,28 @@ private fun metaLine(event: ScheduleEvent): String =
private fun eventArtUrl(event: ScheduleEvent): String? =
event.imageLandscape?.takeIf { it.isNotBlank() }
?: event.images.firstOrNull { it.isNotBlank() && !it.contains("assets/channels", ignoreCase = true) }
?: event.images.firstOrNull { looksLikeArtwork(it) }
?: event.imagePortrait?.takeIf { it.isNotBlank() }
?: event.images.firstOrNull()
?: event.images.firstOrNull { it.isNotBlank() && !it.contains("assets/channels", ignoreCase = true) }
/** True when artwork is suitable for a full-bleed hero (no bare logos / channel PNGs). */
private fun looksLikeArtwork(url: String): Boolean {
val u = url.trim()
if (u.isEmpty()) return false
val lower = u.lowercase()
if (lower.contains("assets/channels")) return false
if (lower.contains("/logo") || lower.contains("logo.") || lower.contains("logo_")) return false
if (lower.endsWith(".svg")) return false
return true
}
private fun hasUsableHeroArt(event: ScheduleEvent): Boolean {
if (!event.imageLandscape.isNullOrBlank()) return true
if (event.images.any { looksLikeArtwork(it) }) return true
// Portrait can still fill a hero better than a black void — only if not a channel logo path.
val portrait = event.imagePortrait?.takeIf { it.isNotBlank() } ?: return false
return looksLikeArtwork(portrait)
}
private fun prettyProvider(raw: String): String = when (raw.lowercase()) {
"viaplay" -> "Viaplay"