remove Events tab hero for simpler browsing

This commit is contained in:
Jos Vooges | STH 2026-09-19 02:14:28 +02:00
parent 7ace6ab214
commit 55710da15b
3 changed files with 50 additions and 481 deletions

View file

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

View file

@ -1,13 +1,8 @@
package nl.vonas.mediacluster.tv.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.BringIntoViewSpec
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -30,17 +25,14 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
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
@ -50,16 +42,12 @@ import androidx.compose.ui.focus.focusRequester
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
@ -91,23 +79,8 @@ private val TimeFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
private val DayTimeFmt: DateTimeFormatter =
DateTimeFormatter.ofPattern("EEE d MMM · HH:mm", Locale("nl", "NL"))
private val StreamLabelRegex = Regex("""^stream\s*\d+""", RegexOption.IGNORE_CASE)
private const val HeroRotateMs = 7_000L
private const val HeroFadeMs = 850
/**
* Provider chips zitten onder de hero-spacer. Standaard BringIntoView zou de LazyColumn
* scrollen → collapsePx springt. We willen focus op chips zonder hero-beweging.
*/
@OptIn(ExperimentalFoundationApi::class)
private val ChipsNoScrollBringIntoViewSpec = object : BringIntoViewSpec {
override fun calculateScrollDistance(
offset: Float,
size: Float,
containerSize: Float,
): Float = 0f
}
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun EventsScreen(
api: ApiClient,
@ -115,12 +88,9 @@ fun EventsScreen(
navFocusRequester: FocusRequester,
focusRestoreNonce: Int = 0,
restoreEventId: String? = null,
restoreFromHero: Boolean = false,
pendingFocusRestore: Boolean = false,
onFocusRestoreConsumed: () -> Unit = {},
onOpenEvent: (String) -> Unit,
onPlayEvent: (String) -> Unit,
onSetOpenedFromHero: (Boolean) -> Unit = {},
onContentFocused: () -> Unit = {},
) {
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
@ -128,32 +98,14 @@ fun EventsScreen(
var error by remember { mutableStateOf<String?>(null) }
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 restoreCardReady by remember { mutableStateOf(false) }
var heroCtaFocused by remember { mutableStateOf(false) }
var placeholderHadFocus by remember { mutableStateOf(false) }
// Houd carousel stil tijdens + kort na restore zodat Crossfade geen focus steelt.
var pauseCarouselAfterRestore by remember { mutableStateOf(false) }
val listState = rememberLazyListState()
val chipsFocus = remember { FocusRequester() }
val firstRailFocus = remember { FocusRequester() }
val restoreCardFocus = 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() }
@ -184,10 +136,6 @@ fun EventsScreen(
else events.filter { it.provider.equals(providerFilter, ignoreCase = true) }
}
val live = remember(filtered) { filtered.filter { it.phase == "live" || it.isLive } }
val liveHeroItems = remember(live) {
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()
@ -200,37 +148,27 @@ fun EventsScreen(
}
}
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 }
// Focus: TopNav → chips (optioneel) → first rail card
val chipsDownTarget = firstRailFocus.takeIf { railFocusReady }
val firstRailUpTarget = when {
showProviderChips && chipsFocusReady -> chipsFocus
showHero && heroFocusReady -> contentFocusRequester
else -> null
showProviderChips && chipsFocusReady -> contentFocusRequester
else -> navFocusRequester
}
val firstRailListIndex = remember(showProviderChips, live, upcomingRails) {
if (live.isEmpty() && upcomingRails.isEmpty()) return@remember -1
var idx = 1 // after spacer
var idx = 1 // after top spacer
if (showProviderChips) idx++
idx
}
val restoreInLive = remember(live, restoreEventId, restoreFromHero) {
!restoreFromHero &&
!restoreEventId.isNullOrBlank() &&
live.any { it.id == restoreEventId }
val restoreInLive = remember(live, restoreEventId) {
!restoreEventId.isNullOrBlank() && live.any { it.id == restoreEventId }
}
val restoreUpcomingIndex = remember(upcomingRails, restoreEventId, restoreFromHero, restoreInLive) {
if (restoreFromHero || restoreInLive || restoreEventId.isNullOrBlank()) return@remember -1
val restoreUpcomingIndex = remember(upcomingRails, restoreEventId, restoreInLive) {
if (restoreInLive || restoreEventId.isNullOrBlank()) return@remember -1
upcomingRails.indexOfFirst { (_, day) -> day.any { it.id == restoreEventId } }
}
val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0
@ -242,7 +180,7 @@ fun EventsScreen(
restoreUpcomingIndex,
) {
if (!canRestoreCard) return@remember -1
var idx = 1 // after spacer
var idx = 1 // after top spacer
if (showProviderChips) idx++
if (restoreInLive) return@remember idx
if (live.isNotEmpty()) idx++
@ -266,36 +204,15 @@ fun EventsScreen(
val activeRestoreFocus = when {
!canRestoreCard -> null
restoreIsFirstOfFirstRail -> when {
showHero || showProviderChips -> firstRailFocus
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() {
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 (!ready() && System.currentTimeMillis() < deadline) {
@ -304,15 +221,6 @@ fun EventsScreen(
return ready()
}
fun focusProviderChips() {
scope.launch {
if (!showProviderChips) return@launch
if (!awaitReady({ chipsFocusReady })) return@launch
// Geen animateScrollToItem: dat collapst de hero via collapsePx.
runCatching { chipsFocus.requestFocus() }
}
}
fun focusFirstRail() {
scope.launch {
if (!awaitReady({ railFocusReady })) return@launch
@ -326,20 +234,12 @@ fun EventsScreen(
}
}
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: één restore-owner. Card eerst; hero-fallback alleen als card
// nog niet gelukt is (timeout-job wordt bij succes gecanceld).
// Detail → back: card restore; fallback = chips of eerste rail.
LaunchedEffect(
pendingFocusRestore,
focusRestoreNonce,
@ -349,17 +249,15 @@ fun EventsScreen(
restoreIsFirstOfFirstRail,
) {
if (!pendingFocusRestore || !hasContent) return@LaunchedEffect
pauseCarouselAfterRestore = true
delay(60)
onContentFocused()
var restoredSuccessfully = false
suspend fun focusHeroFallback() {
suspend fun focusEntryFallback() {
if (restoredSuccessfully) return
val entryOk = awaitReady({
when {
showHero -> heroFocusReady
showProviderChips -> chipsFocusReady
else -> railFocusReady
}
@ -373,11 +271,10 @@ fun EventsScreen(
}
}
// Timeout → hero alleen als card-restore nog niet slaagde.
val heroTimeoutJob = launch {
val fallbackTimeoutJob = launch {
delay(550)
if (!restoredSuccessfully) {
focusHeroFallback()
focusEntryFallback()
if (!restoredSuccessfully) onFocusRestoreConsumed()
}
}
@ -395,31 +292,24 @@ fun EventsScreen(
if (ready) {
activeRestoreFocus.requestFocus()
restoredSuccessfully = true
heroTimeoutJob.cancel()
// Clear pending IMMEDIATELY zodat late effects niets meer doen.
fallbackTimeoutJob.cancel()
onFocusRestoreConsumed()
}
}
} else {
// Geen card-target (hero-open of onbekend id): meteen hero, cancel timeout.
heroTimeoutJob.cancel()
focusHeroFallback()
fallbackTimeoutJob.cancel()
focusEntryFallback()
onFocusRestoreConsumed()
restoredSuccessfully = true
}
if (!restoredSuccessfully) {
// Card-poging faalde vóór timeout — wacht op fallback-job.
if (heroTimeoutJob.isActive) heroTimeoutJob.join()
if (fallbackTimeoutJob.isActive) fallbackTimeoutJob.join()
else {
focusHeroFallback()
focusEntryFallback()
onFocusRestoreConsumed()
}
}
// Carousel nog ~2s stil na restore (Crossfade mag focus niet stelen).
delay(2_000)
pauseCarouselAfterRestore = false
}
LaunchedEffect(entryReady, hasContent, pendingFocusRestore) {
@ -431,9 +321,7 @@ fun EventsScreen(
placeholderHadFocus = false
}
val pauseCarousel = pendingFocusRestore || pauseCarouselAfterRestore || heroCtaFocused
// NOOIT dual-bind: placeholder alleen als entry-target NIET in de tree hangt.
val entryInTree = hasContent && (showHero || showProviderChips || hasRails)
val entryInTree = hasContent && (showProviderChips || hasRails)
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
if (!entryInTree) {
@ -466,85 +354,24 @@ fun EventsScreen(
}
}
else -> {
if (showHero) {
Box(
Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.height(heroH)
.graphicsLayer {
translationY = -collapsePx
alpha = (1f - (collapsePx / heroHpx).coerceIn(0f, 1f) * 0.85f)
},
) {
EventHero(
events = liveHeroItems,
nowMs = nowMs,
focusRequester = contentFocusRequester,
navFocusRequester = navFocusRequester,
nextDownFocusRequester = heroDownTarget,
pauseCarousel = pauseCarousel,
onFocusReady = { heroFocusReady = it },
onFocus = {
onContentFocused()
// Niet tijdens card-restore: scroll/BringIntoView trekt focus terug naar hero.
if (!pendingFocusRestore && !pauseCarouselAfterRestore) {
expandHeroFully()
}
},
onCtaFocusChanged = { focused ->
heroCtaFocused = focused
if (focused) {
onContentFocused()
if (!pendingFocusRestore && !pauseCarouselAfterRestore) {
expandHeroFully()
}
}
},
onOpen = { id ->
onSetOpenedFromHero(true)
onOpenEvent(id)
},
onPlay = { id ->
onSetOpenedFromHero(true)
onPlayEvent(id)
},
onRequestNextFocus = { focusAfterHero() },
)
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 48.dp),
) {
item(key = "hero-spacer") {
// With hero: spacer drives collapse. Without: clear TopNav.
Spacer(
Modifier.height(
if (showHero) shelfStart else HomeChrome.BadgeTop + 24.dp,
),
)
item(key = "top-spacer") {
Spacer(Modifier.height(HomeChrome.BadgeTop + 24.dp))
}
if (showProviderChips) {
item(key = "providers") {
CompositionLocalProvider(
LocalBringIntoViewSpec provides ChipsNoScrollBringIntoViewSpec,
) {
ProviderChips(
providers = providers,
selected = providerFilter,
onSelect = { providerFilter = it },
firstFocusRequester = if (showHero) {
chipsFocus
} else {
contentFocusRequester
},
focusUp = chipsUpTarget,
firstFocusRequester = contentFocusRequester,
focusUp = navFocusRequester,
focusDown = chipsDownTarget,
onFocusReady = { chipsFocusReady = it },
// Alleen content-focus melden — géén expand/scroll (hero jump).
onFocus = onContentFocused,
onFocusDownFallback = if (chipsDownTarget == null) {
({ focusFirstRail() })
@ -554,21 +381,18 @@ fun EventsScreen(
)
}
}
}
if (live.isNotEmpty()) {
item(key = "live") {
EventRail(
title = "Nu live",
events = live,
nowMs = nowMs,
onOpen = { id ->
onSetOpenedFromHero(false)
onOpenEvent(id)
},
onOpen = onOpenEvent,
onFocus = onContentFocused,
firstItemFocusRequester = when {
showHero || showProviderChips -> firstRailFocus
else -> contentFocusRequester
firstItemFocusRequester = if (showProviderChips) {
firstRailFocus
} else {
contentFocusRequester
},
restoreEventId = bindRestoreCardId.takeIf { restoreInLive },
restoreFocusRequester = restoreCardFocus.takeIf {
@ -590,14 +414,11 @@ fun EventsScreen(
title = label,
events = dayEvents,
nowMs = nowMs,
onOpen = { id ->
onSetOpenedFromHero(false)
onOpenEvent(id)
},
onOpen = onOpenEvent,
onFocus = onContentFocused,
firstItemFocusRequester = when {
!isFirstUpcoming -> null
showHero || showProviderChips -> firstRailFocus
showProviderChips -> firstRailFocus
else -> contentFocusRequester
},
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
@ -683,7 +504,6 @@ fun EventDetailScreen(
val showDescription = !ev.description.isNullOrBlank()
val hasFocusableBody = showSubtitle || showDescription
// Full-bleed landscape backdrop
AsyncImage(
model = eventBackdropUrl(ev),
contentDescription = ev.name,
@ -691,7 +511,6 @@ fun EventDetailScreen(
alignment = Alignment.TopCenter,
modifier = Modifier.fillMaxSize(),
)
// Strong horizontal + vertical scrims (SpotlightHero / MovieDetail)
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
@ -867,211 +686,6 @@ fun EventDetailScreen(
}
}
/**
* Full-bleed artwork under semi-transparant TopNav; badge/title/meta start at [HomeChrome.BadgeTop].
* Auto-rotates live heroes with a soft Crossfade; CTA focus stays stable outside the fade key.
*/
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun EventHero(
events: List<ScheduleEvent>,
nowMs: Long,
focusRequester: FocusRequester,
navFocusRequester: FocusRequester,
nextDownFocusRequester: FocusRequester?,
pauseCarousel: Boolean = false,
onFocusReady: (Boolean) -> Unit,
onFocus: () -> Unit,
onCtaFocusChanged: (Boolean) -> Unit = {},
onOpen: (String) -> Unit,
onPlay: (String) -> Unit,
onRequestNextFocus: () -> Unit = {},
) {
if (events.isEmpty()) return
var heroIndex by remember { mutableIntStateOf(0) }
val idsKey = remember(events) { events.joinToString("|") { it.id } }
LaunchedEffect(idsKey, pauseCarousel) {
if (pauseCarousel) return@LaunchedEffect
if (events.size <= 1) return@LaunchedEffect
while (true) {
delay(HeroRotateMs)
heroIndex = (heroIndex + 1) % events.size
}
}
val safeIndex = heroIndex.coerceIn(0, events.lastIndex)
val current = events[safeIndex]
Box(
Modifier
.fillMaxSize()
.clip(RectangleShape)
.background(Color(0xFF0B0B0B)),
) {
Crossfade(
targetState = current.id,
animationSpec = tween(HeroFadeMs),
label = "event-hero-art",
) { id ->
val event = events.find { it.id == id } ?: current
Box(Modifier.fillMaxSize()) {
AsyncImage(
model = eventBackdropUrl(event),
contentDescription = event.name,
contentScale = ContentScale.Crop,
alignment = Alignment.TopCenter,
modifier = Modifier.fillMaxSize(),
)
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0.0f to Color(0xCC0B0B0B),
0.42f to Color(0x660B0B0B),
0.78f to Color(0x180B0B0B),
1.0f to Color.Transparent,
),
),
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0.0f to Color(0x44000000),
0.12f to Color.Transparent,
0.48f to Color.Transparent,
0.78f to Color(0x990B0B0B),
1.0f to Color(0xFF0B0B0B),
),
),
)
}
}
Column(
Modifier
.fillMaxSize()
.padding(top = HomeChrome.BadgeTop)
.padding(start = HomeChrome.EdgeInset, end = HomeChrome.EdgeInset, bottom = 32.dp),
verticalArrangement = Arrangement.Top,
) {
Crossfade(
targetState = current.id,
animationSpec = tween(HeroFadeMs),
label = "event-hero-meta",
modifier = Modifier.fillMaxWidth(0.62f),
) { id ->
val event = events.find { it.id == id } ?: current
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
StatusBadge(event, nowMs)
Text(
event.name,
color = Color.White,
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
lineHeight = 42.sp,
)
val meta = metaLine(event)
if (meta.isNotBlank()) {
Text(
meta,
color = Color(0xFFCFCFCF),
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
timeWindowLabel(event),
color = McColors.Text,
fontSize = 15.sp,
)
}
}
Spacer(Modifier.height(16.dp))
// Eén stabiele primary CTA (geen playable if/else remount) → FocusRequester blijft bound.
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
DisposableEffect(focusRequester) {
onFocusReady(true)
onDispose { onFocusReady(false) }
}
McDetailButton(
if (current.playable) "Kijk live" else ctaLabel(current, nowMs),
primary = true,
focusRequester = focusRequester,
focusUp = navFocusRequester,
focusDown = nextDownFocusRequester,
onFocus = onFocus,
onFocusChanged = onCtaFocusChanged,
onFocusDownFallback = if (nextDownFocusRequester == null) {
({ onRequestNextFocus() })
} else {
null
},
onClick = {
if (current.playable) onPlay(current.id) else onOpen(current.id)
},
)
if (current.playable) {
McDetailButton(
"Meer info",
focusUp = navFocusRequester,
focusDown = nextDownFocusRequester,
onFocus = onFocus,
onFocusDownFallback = if (nextDownFocusRequester == null) {
({ onRequestNextFocus() })
} else {
null
},
onClick = { onOpen(current.id) },
)
}
}
if (events.size > 1) {
Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
events.forEachIndexed { i, _ ->
Box(
Modifier
.width(if (i == safeIndex) 18.dp else 8.dp)
.height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(
if (i == safeIndex) Color.White else Color(0x55FFFFFF),
),
)
}
}
}
}
Crossfade(
targetState = current.logoUrl,
animationSpec = tween(HeroFadeMs),
label = "event-hero-logo",
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = HomeChrome.BadgeTop, end = HomeChrome.EdgeInset),
) { logo ->
if (!logo.isNullOrBlank()) {
AsyncImage(
model = logo,
contentDescription = null,
modifier = Modifier
.height(48.dp)
.width(120.dp),
contentScale = ContentScale.Fit,
alignment = Alignment.CenterEnd,
)
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun EventRail(
@ -1115,7 +729,6 @@ private fun EventRail(
items(events, key = { it.id }) { ev ->
val isFirst = ev.id == events.first().id
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
@ -1400,7 +1013,6 @@ private fun McDetailButton(
focusDown: FocusRequester? = null,
onFocus: (() -> Unit)? = null,
onFocusChanged: ((Boolean) -> Unit)? = null,
/** When next down target is off-screen, scroll+focus before default D-pad move. */
onFocusDownFallback: (() -> Unit)? = null,
) {
val mod = Modifier
@ -1515,14 +1127,6 @@ private fun shortTimeLabel(event: ScheduleEvent, nowMs: Long): String {
}
}
private fun timeWindowLabel(event: ScheduleEvent): String {
val start = parseZoned(event.startLocal, event.start)
val stop = parseZoned(event.stopLocal, event.stop)
if (start == null || stop == null) return ""
return "${start.format(TimeFmt)} – ${stop.format(TimeFmt)}"
}
/** Detail: HH:mm–HH:mm met Vandaag/Morgen als het zinvol is. */
private fun detailTimeWindowLabel(event: ScheduleEvent, nowMs: Long): String {
val start = parseZoned(event.startLocal, event.start) ?: return ""
val stop = parseZoned(event.stopLocal, event.stop)
@ -1552,14 +1156,6 @@ private fun displayChannel(event: ScheduleEvent): String? {
return raw?.takeIf { !isStreamLabel(it) }
}
private fun metaLine(event: ScheduleEvent): String =
listOfNotNull(
prettyProvider(event.provider),
event.eventType,
displayChannel(event),
).joinToString(" · ")
/** Detail meta: provider · category · type — nooit Stream N. */
private fun detailMetaLine(event: ScheduleEvent): String =
listOfNotNull(
prettyProvider(event.provider),
@ -1576,13 +1172,12 @@ private fun eventArtUrl(event: ScheduleEvent): String? =
?: event.imagePortrait?.takeIf { it.isNotBlank() }
?: event.images.firstOrNull { it.isNotBlank() && !it.contains("assets/channels", ignoreCase = true) }
/** Detail/hero full-bleed — prefer HERO backdrop, then card landscape. */
/** Detail full-bleed — prefer HERO backdrop, then card landscape. */
private fun eventBackdropUrl(event: ScheduleEvent): String? =
event.imageBackdrop?.takeIf { it.isNotBlank() }
?: event.images.firstOrNull { it.contains("hero-16x9", ignoreCase = true) }
?: eventArtUrl(event)
/** 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
@ -1591,22 +1186,12 @@ private fun looksLikeArtwork(url: String): Boolean {
if (lower.contains("channels-logo")) return false
if (lower.contains("/logos/") && lower.endsWith(".png")) return false
if (lower.contains("/logo") || lower.contains("logo.") || lower.contains("logo_")) {
// Composite BOXART URLs embed logo overlay paths — still real artwork.
if (!lower.contains("boxart") && !lower.contains("hero-")) return false
}
if (lower.endsWith(".svg")) return false
return true
}
private fun hasUsableHeroArt(event: ScheduleEvent): Boolean {
if (!event.imageBackdrop.isNullOrBlank()) return true
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"
"ziggosport" -> "Ziggo Sport"

View file

@ -130,7 +130,6 @@ fun McNav() {
var tvFocusNonce by remember { mutableIntStateOf(0) }
var eventsReturnNonce by remember { mutableIntStateOf(0) }
var lastOpenedEventId by rememberSaveable { mutableStateOf<String?>(null) }
var lastOpenedFromHero by rememberSaveable { mutableStateOf(false) }
var pendingEventsFocusRestore by remember { mutableStateOf(false) }
val favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet())
val hubTab = remember(hubTabName) {
@ -327,10 +326,8 @@ fun McNav() {
onTvFocusContent = { tvFocusNonce++ },
eventsReturnNonce = eventsReturnNonce,
restoreEventId = lastOpenedEventId,
restoreFromHero = lastOpenedFromHero,
pendingEventsFocusRestore = pendingEventsFocusRestore,
onEventsFocusRestoreConsumed = { pendingEventsFocusRestore = false },
onSetOpenedFromHero = { lastOpenedFromHero = it },
profileAvatarId = profileAvatarId,
profileShowAvatar = profileShowAvatar,
profileAvatarUrl = profileAvatarUrl,
@ -355,12 +352,6 @@ fun McNav() {
lastOpenedEventId = eventId
nav.navigate("event/${android.net.Uri.encode(eventId)}")
},
onPlayEvent = { eventId ->
lastOpenedEventId = eventId
nav.navigate("event_play/${android.net.Uri.encode(eventId)}") {
launchSingleTop = true
}
},
onSwitchProfile = {
scope.launch {
runCatching { api.clearProfile() }
@ -550,10 +541,8 @@ private fun MainHub(
onTvFocusContent: () -> Unit,
eventsReturnNonce: Int = 0,
restoreEventId: String? = null,
restoreFromHero: Boolean = false,
pendingEventsFocusRestore: Boolean = false,
onEventsFocusRestoreConsumed: () -> Unit = {},
onSetOpenedFromHero: (Boolean) -> Unit = {},
profileAvatarId: String?,
profileShowAvatar: Boolean = true,
profileAvatarUrl: String? = null,
@ -564,7 +553,6 @@ private fun MainHub(
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
onPlayLive: (String) -> Unit,
onOpenEvent: (String) -> Unit,
onPlayEvent: (String) -> Unit,
onSwitchProfile: () -> Unit,
onManageProfiles: () -> Unit,
onLogout: () -> Unit,
@ -720,8 +708,7 @@ private fun MainHub(
// Terug van EventDetail/Player: MainHub is opnieuw gemount — forceer content-focus
// zodat TopNav niet “nav-only” blijft en D-pad down niet doodloopt.
// Bij pending restore is EventsScreen de enige owner (card of hero-fallback) —
// géén parallel hero requestFocus (race: hero→card→hero).
// Bij pending restore is EventsScreen de enige owner (card of entry-fallback).
LaunchedEffect(eventsReturnNonce) {
if (eventsReturnNonce <= 0 || tab != MainTab.Events) return@LaunchedEffect
navFocused = false
@ -791,7 +778,7 @@ private fun MainHub(
navFocused = false
onTvFocusContent()
hubScope.launch {
// Wacht tot EventsScreen placeholder of hero CTA de requester bindt.
// Wacht tot EventsScreen placeholder of eerste content de requester bindt.
delay(160)
runCatching { eventsContentFocus.requestFocus() }
delay(120)
@ -939,12 +926,9 @@ private fun MainHub(
navFocusRequester = navFocus,
focusRestoreNonce = eventsReturnNonce,
restoreEventId = restoreEventId,
restoreFromHero = restoreFromHero,
pendingFocusRestore = pendingEventsFocusRestore,
onFocusRestoreConsumed = onEventsFocusRestoreConsumed,
onOpenEvent = onOpenEvent,
onPlayEvent = onPlayEvent,
onSetOpenedFromHero = onSetOpenedFromHero,
onContentFocused = { navFocused = false },
)
MainTab.Search -> SearchScreen(