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" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 119 versionCode = 120
versionName = "0.14.56" versionName = "0.14.57"
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")

View file

@ -33,6 +33,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape 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.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -90,7 +94,7 @@ fun EventsScreen(
contentFocusRequester: FocusRequester, contentFocusRequester: FocusRequester,
navFocusRequester: FocusRequester, navFocusRequester: FocusRequester,
focusRestoreNonce: Int = 0, focusRestoreNonce: Int = 0,
restoreEventId: String? = null, @Suppress("UNUSED_PARAMETER") restoreEventId: String? = null,
pendingFocusRestore: Boolean = false, pendingFocusRestore: Boolean = false,
onFocusRestoreConsumed: () -> Unit = {}, onFocusRestoreConsumed: () -> Unit = {},
onOpenEvent: (String) -> Unit, onOpenEvent: (String) -> Unit,
@ -103,11 +107,27 @@ fun EventsScreen(
var providerFilter by remember { mutableStateOf<String?>(null) } var providerFilter by remember { mutableStateOf<String?>(null) }
var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) } var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) }
var heroFocusReady by remember { mutableStateOf(false) } var heroFocusReady by remember { mutableStateOf(false) }
var chipsFocusReady by remember { mutableStateOf(false) }
var railFocusReady by remember { mutableStateOf(false) } var railFocusReady by remember { mutableStateOf(false) }
var heroCtaFocused by remember { mutableStateOf(false) } var heroCtaFocused by remember { mutableStateOf(false) }
var placeholderHadFocus by remember { mutableStateOf(false) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val chipsFocus = remember { FocusRequester() }
val firstRailFocus = remember { FocusRequester() } val firstRailFocus = remember { FocusRequester() }
val scope = rememberCoroutineScope() 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() { suspend fun refresh() {
runCatching { api.listScheduleEvents() } runCatching { api.listScheduleEvents() }
@ -119,9 +139,7 @@ fun EventsScreen(
loading = false loading = false
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) { refresh() }
refresh()
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (true) { while (true) {
delay(75_000) delay(75_000)
@ -141,40 +159,93 @@ fun EventsScreen(
} }
val live = remember(filtered) { filtered.filter { it.phase == "live" || it.isLive } } val live = remember(filtered) { filtered.filter { it.phase == "live" || it.isLive } }
val liveHeroItems = remember(live) { 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 byDay = remember(filtered, nowMs) { groupEventsByDay(filtered) }
val providers = remember(events) { val providers = remember(events) {
events.map { it.provider }.distinct().sorted() events.map { it.provider }.distinct().sorted()
} }
val showProviderChips = providers.size > 1
val upcomingRails = remember(byDay) { val upcomingRails = remember(byDay) {
byDay.mapNotNull { (label, dayEvents) -> byDay.mapNotNull { (label, dayEvents) ->
val upcoming = dayEvents.filter { it.phase != "live" && !it.isLive } val upcoming = dayEvents.filter { it.phase != "live" && !it.isLive }
if (upcoming.isEmpty()) null else label to upcoming if (upcoming.isEmpty()) null else label to upcoming
} }
} }
val hasHeroContent = filtered.isNotEmpty() val hasContent = filtered.isNotEmpty()
val heroItems = liveHeroItems.ifEmpty { listOfNotNull(filtered.firstOrNull()) } val showHero = liveHeroItems.isNotEmpty()
val heroHeight = 480.dp val hasRails = live.isNotEmpty() || upcomingRails.isNotEmpty()
// Rails starten onder de hero met lichte overlap (Home-achtig).
val railStart = 420.dp // Focus chain: Hero CTA → ProviderChips → first rail card
val firstRailListIndex = remember(providers, live, upcomingRails) { val heroDownTarget = when {
var idx = 1 // after spacer showProviderChips && chipsFocusReady -> chipsFocus
if (providers.size > 1) idx++ railFocusReady -> firstRailFocus
if (live.isNotEmpty() || upcomingRails.isNotEmpty()) idx else -1 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 val deadline = System.currentTimeMillis() + timeoutMs
while (!railFocusReady && System.currentTimeMillis() < deadline) { while (!ready() && System.currentTimeMillis() < deadline) {
delay(40) 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() { fun focusFirstRail() {
scope.launch { scope.launch {
if (!awaitRailReady()) return@launch if (!awaitReady({ railFocusReady })) return@launch
runCatching { runCatching {
if (firstRailListIndex >= 0) { if (firstRailListIndex >= 0) {
listState.animateScrollToItem(firstRailListIndex) 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( LaunchedEffect(
pendingFocusRestore, pendingFocusRestore,
focusRestoreNonce, focusRestoreNonce,
hasHeroContent, hasContent,
heroFocusReady, entryReady,
entryIsHero,
) { ) {
if (!pendingFocusRestore || !hasHeroContent || !heroFocusReady) return@LaunchedEffect if (!pendingFocusRestore || !hasContent || !entryReady) return@LaunchedEffect
delay(60) delay(60)
onContentFocused() onContentFocused()
runCatching { listState.scrollToItem(0) } runCatching { listState.scrollToItem(0) }
@ -201,10 +285,8 @@ fun EventsScreen(
onFocusRestoreConsumed() onFocusRestoreConsumed()
} }
// Placeholder had focus tijdens laden → doorzetten naar hero CTA zodra klaar. LaunchedEffect(entryReady, hasContent) {
var placeholderHadFocus by remember { mutableStateOf(false) } if (!entryReady || !hasContent || !placeholderHadFocus) return@LaunchedEffect
LaunchedEffect(heroFocusReady, hasHeroContent) {
if (!heroFocusReady || !hasHeroContent || !placeholderHadFocus) return@LaunchedEffect
delay(40) delay(40)
onContentFocused() onContentFocused()
runCatching { contentFocusRequester.requestFocus() } runCatching { contentFocusRequester.requestFocus() }
@ -212,11 +294,11 @@ fun EventsScreen(
} }
val pauseCarousel = pendingFocusRestore || heroCtaFocused 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)) { Box(Modifier.fillMaxSize().background(McColors.Bg)) {
// NOOIT dual-bind: placeholder alleen als hero NIET in de tree hangt. if (!entryInTree) {
// (Home had dezelfde race: placeholder + CTA tegelijk → Shield hang.)
if (!hasHeroContent) {
Box( Box(
Modifier Modifier
.size(1.dp) .size(1.dp)
@ -240,51 +322,85 @@ fun EventsScreen(
Text(error!!, color = McColors.Danger, fontSize = 16.sp) Text(error!!, color = McColors.Danger, fontSize = 16.sp)
} }
} }
!hasHeroContent -> { !hasContent -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Geen events in de komende dagen", color = McColors.TextDim, fontSize = 18.sp) Text("Geen events in de komende dagen", color = McColors.TextDim, fontSize = 18.sp)
} }
} }
else -> { else -> {
// Hero BUITEN LazyColumn — zelfde stabiele patroon als Home SpotlightHero. if (showHero) {
Box( Box(
Modifier Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.fillMaxWidth() .fillMaxWidth()
.height(heroHeight), .height(heroH)
) { .graphicsLayer {
EventHero( translationY = -collapsePx
events = heroItems, alpha = (1f - (collapsePx / heroHpx).coerceIn(0f, 1f) * 0.85f)
nowMs = nowMs, },
focusRequester = contentFocusRequester, ) {
navFocusRequester = navFocusRequester, EventHero(
railFocusRequester = firstRailFocus.takeIf { railFocusReady }, events = liveHeroItems,
pauseCarousel = pauseCarousel, nowMs = nowMs,
onFocusReady = { heroFocusReady = it }, focusRequester = contentFocusRequester,
onFocus = onContentFocused, navFocusRequester = navFocusRequester,
onCtaFocusChanged = { focused -> nextDownFocusRequester = heroDownTarget,
heroCtaFocused = focused pauseCarousel = pauseCarousel,
if (focused) onContentFocused() onFocusReady = { heroFocusReady = it },
}, onFocus = {
onOpen = { id -> onOpenEvent(id) }, onContentFocused()
onPlay = { id -> onPlayEvent(id) }, expandHeroFully()
onRequestRailFocus = { focusFirstRail() }, },
) onCtaFocusChanged = { focused ->
heroCtaFocused = focused
if (focused) {
onContentFocused()
expandHeroFully()
}
},
onOpen = { id -> onOpenEvent(id) },
onPlay = { id -> onPlayEvent(id) },
onRequestNextFocus = { focusAfterHero() },
)
}
} }
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 48.dp), contentPadding = PaddingValues(bottom = 48.dp),
) { ) {
item(key = "hero-spacer") { 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") { item(key = "providers") {
ProviderChips( ProviderChips(
providers = providers, providers = providers,
selected = providerFilter, selected = providerFilter,
onSelect = { providerFilter = it }, 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, nowMs = nowMs,
onOpen = onOpenEvent, onOpen = onOpenEvent,
onFocus = onContentFocused, onFocus = onContentFocused,
firstItemFocusRequester = firstRailFocus.takeIf { live.isNotEmpty() }, firstItemFocusRequester = when {
focusUpTo = contentFocusRequester.takeIf { heroFocusReady }, showHero || showProviderChips -> firstRailFocus
onFirstFocusReady = { ready -> else -> contentFocusRequester
if (live.isNotEmpty()) railFocusReady = ready
}, },
focusUpTo = firstRailUpTarget,
onFirstFocusReady = { railFocusReady = it },
) )
} }
} }
@ -313,8 +430,12 @@ fun EventsScreen(
nowMs = nowMs, nowMs = nowMs,
onOpen = onOpenEvent, onOpen = onOpenEvent,
onFocus = onContentFocused, onFocus = onContentFocused,
firstItemFocusRequester = firstRailFocus.takeIf { isFirstUpcoming }, firstItemFocusRequester = when {
focusUpTo = contentFocusRequester.takeIf { heroFocusReady && isFirstUpcoming }, !isFirstUpcoming -> null
showHero || showProviderChips -> firstRailFocus
else -> contentFocusRequester
},
focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming },
onFirstFocusReady = { ready -> onFirstFocusReady = { ready ->
if (isFirstUpcoming) railFocusReady = ready if (isFirstUpcoming) railFocusReady = ready
}, },
@ -674,14 +795,14 @@ private fun EventHero(
nowMs: Long, nowMs: Long,
focusRequester: FocusRequester, focusRequester: FocusRequester,
navFocusRequester: FocusRequester, navFocusRequester: FocusRequester,
railFocusRequester: FocusRequester?, nextDownFocusRequester: FocusRequester?,
pauseCarousel: Boolean = false, pauseCarousel: Boolean = false,
onFocusReady: (Boolean) -> Unit, onFocusReady: (Boolean) -> Unit,
onFocus: () -> Unit, onFocus: () -> Unit,
onCtaFocusChanged: (Boolean) -> Unit = {}, onCtaFocusChanged: (Boolean) -> Unit = {},
onOpen: (String) -> Unit, onOpen: (String) -> Unit,
onPlay: (String) -> Unit, onPlay: (String) -> Unit,
onRequestRailFocus: () -> Unit = {}, onRequestNextFocus: () -> Unit = {},
) { ) {
if (events.isEmpty()) return if (events.isEmpty()) return
var heroIndex by remember { mutableIntStateOf(0) } var heroIndex by remember { mutableIntStateOf(0) }
@ -701,8 +822,7 @@ private fun EventHero(
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxSize()
.height(480.dp)
.clip(RectangleShape) .clip(RectangleShape)
.background(Color(0xFF0B0B0B)), .background(Color(0xFF0B0B0B)),
) { ) {
@ -800,11 +920,11 @@ private fun EventHero(
primary = true, primary = true,
focusRequester = focusRequester, focusRequester = focusRequester,
focusUp = navFocusRequester, focusUp = navFocusRequester,
focusDown = railFocusRequester, focusDown = nextDownFocusRequester,
onFocus = onFocus, onFocus = onFocus,
onFocusChanged = onCtaFocusChanged, onFocusChanged = onCtaFocusChanged,
onFocusDownFallback = if (railFocusRequester == null) { onFocusDownFallback = if (nextDownFocusRequester == null) {
({ onRequestRailFocus() }) ({ onRequestNextFocus() })
} else { } else {
null null
}, },
@ -816,10 +936,10 @@ private fun EventHero(
McDetailButton( McDetailButton(
"Meer info", "Meer info",
focusUp = navFocusRequester, focusUp = navFocusRequester,
focusDown = railFocusRequester, focusDown = nextDownFocusRequester,
onFocus = onFocus, onFocus = onFocus,
onFocusDownFallback = if (railFocusRequester == null) { onFocusDownFallback = if (nextDownFocusRequester == null) {
({ onRequestRailFocus() }) ({ onRequestNextFocus() })
} else { } else {
null null
}, },
@ -1028,25 +1148,97 @@ private fun ProviderChips(
providers: List<String>, providers: List<String>,
selected: String?, selected: String?,
onSelect: (String?) -> Unit, onSelect: (String?) -> Unit,
firstFocusRequester: FocusRequester? = null,
focusUp: FocusRequester? = null,
focusDown: FocusRequester? = null,
onFocusReady: ((Boolean) -> Unit)? = null,
onFocus: () -> Unit = {},
onFocusDownFallback: (() -> Unit)? = null,
) { ) {
LazyRow( LazyRow(
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 14.dp), contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
item { 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 -> 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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @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( Surface(
onClick = onClick, onClick = onClick,
modifier = mod,
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (active) McColors.Accent else McColors.Surface, containerColor = if (active) McColors.Accent else McColors.Surface,
focusedContainerColor = if (active) McColors.Accent else McColors.SurfaceFocus, focusedContainerColor = if (active) McColors.Accent else McColors.SurfaceFocus,
@ -1240,9 +1432,28 @@ private fun metaLine(event: ScheduleEvent): String =
private fun eventArtUrl(event: ScheduleEvent): String? = private fun eventArtUrl(event: ScheduleEvent): String? =
event.imageLandscape?.takeIf { it.isNotBlank() } 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.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()) { private fun prettyProvider(raw: String): String = when (raw.lowercase()) {
"viaplay" -> "Viaplay" "viaplay" -> "Viaplay"