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" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 124 versionCode = 125
versionName = "0.14.61" versionName = "0.14.62"
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

@ -1,13 +1,8 @@
package nl.vonas.mediacluster.tv.ui package nl.vonas.mediacluster.tv.ui
import androidx.activity.compose.BackHandler 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.background
import androidx.compose.foundation.focusable 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf 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
@ -50,16 +42,12 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged 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.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
@ -91,23 +79,8 @@ private val TimeFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
private val DayTimeFmt: DateTimeFormatter = private val DayTimeFmt: DateTimeFormatter =
DateTimeFormatter.ofPattern("EEE d MMM · HH:mm", Locale("nl", "NL")) DateTimeFormatter.ofPattern("EEE d MMM · HH:mm", Locale("nl", "NL"))
private val StreamLabelRegex = Regex("""^stream\s*\d+""", RegexOption.IGNORE_CASE) private val StreamLabelRegex = Regex("""^stream\s*\d+""", RegexOption.IGNORE_CASE)
private const val HeroRotateMs = 7_000L
private const val HeroFadeMs = 850
/** @OptIn(ExperimentalTvMaterial3Api::class)
* 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)
@Composable @Composable
fun EventsScreen( fun EventsScreen(
api: ApiClient, api: ApiClient,
@ -115,12 +88,9 @@ fun EventsScreen(
navFocusRequester: FocusRequester, navFocusRequester: FocusRequester,
focusRestoreNonce: Int = 0, focusRestoreNonce: Int = 0,
restoreEventId: String? = null, restoreEventId: String? = null,
restoreFromHero: Boolean = false,
pendingFocusRestore: Boolean = false, pendingFocusRestore: Boolean = false,
onFocusRestoreConsumed: () -> Unit = {}, onFocusRestoreConsumed: () -> Unit = {},
onOpenEvent: (String) -> Unit, onOpenEvent: (String) -> Unit,
onPlayEvent: (String) -> Unit,
onSetOpenedFromHero: (Boolean) -> Unit = {},
onContentFocused: () -> Unit = {}, onContentFocused: () -> Unit = {},
) { ) {
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) } var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
@ -128,32 +98,14 @@ fun EventsScreen(
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
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 chipsFocusReady by remember { mutableStateOf(false) } var chipsFocusReady by remember { mutableStateOf(false) }
var railFocusReady by remember { mutableStateOf(false) } var railFocusReady by remember { mutableStateOf(false) }
var restoreCardReady by remember { mutableStateOf(false) } var restoreCardReady by remember { mutableStateOf(false) }
var heroCtaFocused by remember { mutableStateOf(false) }
var placeholderHadFocus 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 listState = rememberLazyListState()
val chipsFocus = remember { FocusRequester() }
val firstRailFocus = remember { FocusRequester() } val firstRailFocus = remember { FocusRequester() }
val restoreCardFocus = remember { FocusRequester() } val restoreCardFocus = 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() }
@ -184,10 +136,6 @@ fun EventsScreen(
else events.filter { it.provider.equals(providerFilter, ignoreCase = true) } else events.filter { it.provider.equals(providerFilter, ignoreCase = true) }
} }
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 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()
@ -200,37 +148,27 @@ fun EventsScreen(
} }
} }
val hasContent = filtered.isNotEmpty() val hasContent = filtered.isNotEmpty()
val showHero = liveHeroItems.isNotEmpty()
val hasRails = live.isNotEmpty() || upcomingRails.isNotEmpty() val hasRails = live.isNotEmpty() || upcomingRails.isNotEmpty()
// Focus chain: Hero CTA → ProviderChips → first rail card // Focus: TopNav → chips (optioneel) → 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 chipsDownTarget = firstRailFocus.takeIf { railFocusReady }
val firstRailUpTarget = when { val firstRailUpTarget = when {
showProviderChips && chipsFocusReady -> chipsFocus showProviderChips && chipsFocusReady -> contentFocusRequester
showHero && heroFocusReady -> contentFocusRequester else -> navFocusRequester
else -> null
} }
val firstRailListIndex = remember(showProviderChips, live, upcomingRails) { val firstRailListIndex = remember(showProviderChips, live, upcomingRails) {
if (live.isEmpty() && upcomingRails.isEmpty()) return@remember -1 if (live.isEmpty() && upcomingRails.isEmpty()) return@remember -1
var idx = 1 // after spacer var idx = 1 // after top spacer
if (showProviderChips) idx++ if (showProviderChips) idx++
idx idx
} }
val restoreInLive = remember(live, restoreEventId, restoreFromHero) { val restoreInLive = remember(live, restoreEventId) {
!restoreFromHero && !restoreEventId.isNullOrBlank() && live.any { it.id == restoreEventId }
!restoreEventId.isNullOrBlank() &&
live.any { it.id == restoreEventId }
} }
val restoreUpcomingIndex = remember(upcomingRails, restoreEventId, restoreFromHero, restoreInLive) { val restoreUpcomingIndex = remember(upcomingRails, restoreEventId, restoreInLive) {
if (restoreFromHero || restoreInLive || restoreEventId.isNullOrBlank()) return@remember -1 if (restoreInLive || restoreEventId.isNullOrBlank()) return@remember -1
upcomingRails.indexOfFirst { (_, day) -> day.any { it.id == restoreEventId } } upcomingRails.indexOfFirst { (_, day) -> day.any { it.id == restoreEventId } }
} }
val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0 val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0
@ -242,7 +180,7 @@ fun EventsScreen(
restoreUpcomingIndex, restoreUpcomingIndex,
) { ) {
if (!canRestoreCard) return@remember -1 if (!canRestoreCard) return@remember -1
var idx = 1 // after spacer var idx = 1 // after top spacer
if (showProviderChips) idx++ if (showProviderChips) idx++
if (restoreInLive) return@remember idx if (restoreInLive) return@remember idx
if (live.isNotEmpty()) idx++ if (live.isNotEmpty()) idx++
@ -266,36 +204,15 @@ fun EventsScreen(
val activeRestoreFocus = when { val activeRestoreFocus = when {
!canRestoreCard -> null !canRestoreCard -> null
restoreIsFirstOfFirstRail -> when { restoreIsFirstOfFirstRail -> when {
showHero || showProviderChips -> firstRailFocus showProviderChips -> firstRailFocus
else -> contentFocusRequester else -> contentFocusRequester
} }
else -> restoreCardFocus else -> restoreCardFocus
} }
// Alleen tijdens pending restore binden — en nooit dual-bind met firstRailFocus.
val bindRestoreCardId = restoreEventId.takeIf { val bindRestoreCardId = restoreEventId.takeIf {
pendingFocusRestore && canRestoreCard && !restoreIsFirstOfFirstRail 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 { suspend fun awaitReady(ready: () -> Boolean, timeoutMs: Long = 900): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs val deadline = System.currentTimeMillis() + timeoutMs
while (!ready() && System.currentTimeMillis() < deadline) { while (!ready() && System.currentTimeMillis() < deadline) {
@ -304,15 +221,6 @@ fun EventsScreen(
return ready() 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() { fun focusFirstRail() {
scope.launch { scope.launch {
if (!awaitReady({ railFocusReady })) return@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 { val entryReady = when {
entryIsHero -> heroFocusReady
showProviderChips -> chipsFocusReady showProviderChips -> chipsFocusReady
else -> railFocusReady else -> railFocusReady
} }
// Detail → back: één restore-owner. Card eerst; hero-fallback alleen als card // Detail → back: card restore; fallback = chips of eerste rail.
// nog niet gelukt is (timeout-job wordt bij succes gecanceld).
LaunchedEffect( LaunchedEffect(
pendingFocusRestore, pendingFocusRestore,
focusRestoreNonce, focusRestoreNonce,
@ -349,17 +249,15 @@ fun EventsScreen(
restoreIsFirstOfFirstRail, restoreIsFirstOfFirstRail,
) { ) {
if (!pendingFocusRestore || !hasContent) return@LaunchedEffect if (!pendingFocusRestore || !hasContent) return@LaunchedEffect
pauseCarouselAfterRestore = true
delay(60) delay(60)
onContentFocused() onContentFocused()
var restoredSuccessfully = false var restoredSuccessfully = false
suspend fun focusHeroFallback() { suspend fun focusEntryFallback() {
if (restoredSuccessfully) return if (restoredSuccessfully) return
val entryOk = awaitReady({ val entryOk = awaitReady({
when { when {
showHero -> heroFocusReady
showProviderChips -> chipsFocusReady showProviderChips -> chipsFocusReady
else -> railFocusReady else -> railFocusReady
} }
@ -373,11 +271,10 @@ fun EventsScreen(
} }
} }
// Timeout → hero alleen als card-restore nog niet slaagde. val fallbackTimeoutJob = launch {
val heroTimeoutJob = launch {
delay(550) delay(550)
if (!restoredSuccessfully) { if (!restoredSuccessfully) {
focusHeroFallback() focusEntryFallback()
if (!restoredSuccessfully) onFocusRestoreConsumed() if (!restoredSuccessfully) onFocusRestoreConsumed()
} }
} }
@ -395,31 +292,24 @@ fun EventsScreen(
if (ready) { if (ready) {
activeRestoreFocus.requestFocus() activeRestoreFocus.requestFocus()
restoredSuccessfully = true restoredSuccessfully = true
heroTimeoutJob.cancel() fallbackTimeoutJob.cancel()
// Clear pending IMMEDIATELY zodat late effects niets meer doen.
onFocusRestoreConsumed() onFocusRestoreConsumed()
} }
} }
} else { } else {
// Geen card-target (hero-open of onbekend id): meteen hero, cancel timeout. fallbackTimeoutJob.cancel()
heroTimeoutJob.cancel() focusEntryFallback()
focusHeroFallback()
onFocusRestoreConsumed() onFocusRestoreConsumed()
restoredSuccessfully = true restoredSuccessfully = true
} }
if (!restoredSuccessfully) { if (!restoredSuccessfully) {
// Card-poging faalde vóór timeout — wacht op fallback-job. if (fallbackTimeoutJob.isActive) fallbackTimeoutJob.join()
if (heroTimeoutJob.isActive) heroTimeoutJob.join()
else { else {
focusHeroFallback() focusEntryFallback()
onFocusRestoreConsumed() onFocusRestoreConsumed()
} }
} }
// Carousel nog ~2s stil na restore (Crossfade mag focus niet stelen).
delay(2_000)
pauseCarouselAfterRestore = false
} }
LaunchedEffect(entryReady, hasContent, pendingFocusRestore) { LaunchedEffect(entryReady, hasContent, pendingFocusRestore) {
@ -431,9 +321,7 @@ fun EventsScreen(
placeholderHadFocus = false placeholderHadFocus = false
} }
val pauseCarousel = pendingFocusRestore || pauseCarouselAfterRestore || heroCtaFocused val entryInTree = hasContent && (showProviderChips || hasRails)
// 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)) {
if (!entryInTree) { if (!entryInTree) {
@ -466,93 +354,31 @@ fun EventsScreen(
} }
} }
else -> { 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( 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 = "top-spacer") {
// With hero: spacer drives collapse. Without: clear TopNav. Spacer(Modifier.height(HomeChrome.BadgeTop + 24.dp))
Spacer(
Modifier.height(
if (showHero) shelfStart else HomeChrome.BadgeTop + 24.dp,
),
)
} }
if (showProviderChips) { if (showProviderChips) {
item(key = "providers") { item(key = "providers") {
CompositionLocalProvider( ProviderChips(
LocalBringIntoViewSpec provides ChipsNoScrollBringIntoViewSpec, providers = providers,
) { selected = providerFilter,
ProviderChips( onSelect = { providerFilter = it },
providers = providers, firstFocusRequester = contentFocusRequester,
selected = providerFilter, focusUp = navFocusRequester,
onSelect = { providerFilter = it }, focusDown = chipsDownTarget,
firstFocusRequester = if (showHero) { onFocusReady = { chipsFocusReady = it },
chipsFocus onFocus = onContentFocused,
} else { onFocusDownFallback = if (chipsDownTarget == null) {
contentFocusRequester ({ focusFirstRail() })
}, } else {
focusUp = chipsUpTarget, null
focusDown = chipsDownTarget, },
onFocusReady = { chipsFocusReady = it }, )
// Alleen content-focus melden — géén expand/scroll (hero jump).
onFocus = onContentFocused,
onFocusDownFallback = if (chipsDownTarget == null) {
({ focusFirstRail() })
} else {
null
},
)
}
} }
} }
if (live.isNotEmpty()) { if (live.isNotEmpty()) {
@ -561,14 +387,12 @@ fun EventsScreen(
title = "Nu live", title = "Nu live",
events = live, events = live,
nowMs = nowMs, nowMs = nowMs,
onOpen = { id -> onOpen = onOpenEvent,
onSetOpenedFromHero(false)
onOpenEvent(id)
},
onFocus = onContentFocused, onFocus = onContentFocused,
firstItemFocusRequester = when { firstItemFocusRequester = if (showProviderChips) {
showHero || showProviderChips -> firstRailFocus firstRailFocus
else -> contentFocusRequester } else {
contentFocusRequester
}, },
restoreEventId = bindRestoreCardId.takeIf { restoreInLive }, restoreEventId = bindRestoreCardId.takeIf { restoreInLive },
restoreFocusRequester = restoreCardFocus.takeIf { restoreFocusRequester = restoreCardFocus.takeIf {
@ -590,14 +414,11 @@ fun EventsScreen(
title = label, title = label,
events = dayEvents, events = dayEvents,
nowMs = nowMs, nowMs = nowMs,
onOpen = { id -> onOpen = onOpenEvent,
onSetOpenedFromHero(false)
onOpenEvent(id)
},
onFocus = onContentFocused, onFocus = onContentFocused,
firstItemFocusRequester = when { firstItemFocusRequester = when {
!isFirstUpcoming -> null !isFirstUpcoming -> null
showHero || showProviderChips -> firstRailFocus showProviderChips -> firstRailFocus
else -> contentFocusRequester else -> contentFocusRequester
}, },
restoreEventId = bindRestoreCardId.takeIf { railHasRestore }, restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
@ -683,7 +504,6 @@ fun EventDetailScreen(
val showDescription = !ev.description.isNullOrBlank() val showDescription = !ev.description.isNullOrBlank()
val hasFocusableBody = showSubtitle || showDescription val hasFocusableBody = showSubtitle || showDescription
// Full-bleed landscape backdrop
AsyncImage( AsyncImage(
model = eventBackdropUrl(ev), model = eventBackdropUrl(ev),
contentDescription = ev.name, contentDescription = ev.name,
@ -691,7 +511,6 @@ fun EventDetailScreen(
alignment = Alignment.TopCenter, alignment = Alignment.TopCenter,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
// Strong horizontal + vertical scrims (SpotlightHero / MovieDetail)
Box( Box(
Modifier.fillMaxSize().background( Modifier.fillMaxSize().background(
Brush.horizontalGradient( 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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun EventRail( private fun EventRail(
@ -1115,7 +729,6 @@ private fun EventRail(
items(events, key = { it.id }) { ev -> items(events, key = { it.id }) { ev ->
val isFirst = ev.id == events.first().id val isFirst = ev.id == events.first().id
val isRestore = restoreEventId != null && ev.id == restoreEventId val isRestore = restoreEventId != null && ev.id == restoreEventId
// Één FocusRequester per card — nooit dual-bind (extraFocusRequester hang).
val cardRequester = when { val cardRequester = when {
isRestore && restoreFocusRequester != null -> restoreFocusRequester isRestore && restoreFocusRequester != null -> restoreFocusRequester
isFirst -> firstItemFocusRequester isFirst -> firstItemFocusRequester
@ -1400,7 +1013,6 @@ private fun McDetailButton(
focusDown: FocusRequester? = null, focusDown: FocusRequester? = null,
onFocus: (() -> Unit)? = null, onFocus: (() -> Unit)? = null,
onFocusChanged: ((Boolean) -> Unit)? = null, onFocusChanged: ((Boolean) -> Unit)? = null,
/** When next down target is off-screen, scroll+focus before default D-pad move. */
onFocusDownFallback: (() -> Unit)? = null, onFocusDownFallback: (() -> Unit)? = null,
) { ) {
val mod = Modifier 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 { private fun detailTimeWindowLabel(event: ScheduleEvent, nowMs: Long): String {
val start = parseZoned(event.startLocal, event.start) ?: return "" val start = parseZoned(event.startLocal, event.start) ?: return ""
val stop = parseZoned(event.stopLocal, event.stop) val stop = parseZoned(event.stopLocal, event.stop)
@ -1552,14 +1156,6 @@ private fun displayChannel(event: ScheduleEvent): String? {
return raw?.takeIf { !isStreamLabel(it) } 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 = private fun detailMetaLine(event: ScheduleEvent): String =
listOfNotNull( listOfNotNull(
prettyProvider(event.provider), prettyProvider(event.provider),
@ -1576,13 +1172,12 @@ private fun eventArtUrl(event: ScheduleEvent): String? =
?: event.imagePortrait?.takeIf { it.isNotBlank() } ?: event.imagePortrait?.takeIf { it.isNotBlank() }
?: event.images.firstOrNull { it.isNotBlank() && !it.contains("assets/channels", ignoreCase = true) } ?: 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? = private fun eventBackdropUrl(event: ScheduleEvent): String? =
event.imageBackdrop?.takeIf { it.isNotBlank() } event.imageBackdrop?.takeIf { it.isNotBlank() }
?: event.images.firstOrNull { it.contains("hero-16x9", ignoreCase = true) } ?: event.images.firstOrNull { it.contains("hero-16x9", ignoreCase = true) }
?: eventArtUrl(event) ?: eventArtUrl(event)
/** True when artwork is suitable for a full-bleed hero (no bare logos / channel PNGs). */
private fun looksLikeArtwork(url: String): Boolean { private fun looksLikeArtwork(url: String): Boolean {
val u = url.trim() val u = url.trim()
if (u.isEmpty()) return false 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("channels-logo")) return false
if (lower.contains("/logos/") && lower.endsWith(".png")) return false if (lower.contains("/logos/") && lower.endsWith(".png")) return false
if (lower.contains("/logo") || lower.contains("logo.") || lower.contains("logo_")) { 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.contains("boxart") && !lower.contains("hero-")) return false
} }
if (lower.endsWith(".svg")) return false if (lower.endsWith(".svg")) return false
return true 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()) { private fun prettyProvider(raw: String): String = when (raw.lowercase()) {
"viaplay" -> "Viaplay" "viaplay" -> "Viaplay"
"ziggosport" -> "Ziggo Sport" "ziggosport" -> "Ziggo Sport"

View file

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