fix Events hero overlay/focus chain and skip imageless heroes
This commit is contained in:
parent
79f15719a3
commit
5115682427
2 changed files with 290 additions and 79 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue