remove Events tab hero for simpler browsing
This commit is contained in:
parent
7ace6ab214
commit
55710da15b
3 changed files with 50 additions and 481 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,85 +354,24 @@ 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(
|
|
||||||
LocalBringIntoViewSpec provides ChipsNoScrollBringIntoViewSpec,
|
|
||||||
) {
|
|
||||||
ProviderChips(
|
ProviderChips(
|
||||||
providers = providers,
|
providers = providers,
|
||||||
selected = providerFilter,
|
selected = providerFilter,
|
||||||
onSelect = { providerFilter = it },
|
onSelect = { providerFilter = it },
|
||||||
firstFocusRequester = if (showHero) {
|
firstFocusRequester = contentFocusRequester,
|
||||||
chipsFocus
|
focusUp = navFocusRequester,
|
||||||
} else {
|
|
||||||
contentFocusRequester
|
|
||||||
},
|
|
||||||
focusUp = chipsUpTarget,
|
|
||||||
focusDown = chipsDownTarget,
|
focusDown = chipsDownTarget,
|
||||||
onFocusReady = { chipsFocusReady = it },
|
onFocusReady = { chipsFocusReady = it },
|
||||||
// Alleen content-focus melden — géén expand/scroll (hero jump).
|
|
||||||
onFocus = onContentFocused,
|
onFocus = onContentFocused,
|
||||||
onFocusDownFallback = if (chipsDownTarget == null) {
|
onFocusDownFallback = if (chipsDownTarget == null) {
|
||||||
({ focusFirstRail() })
|
({ focusFirstRail() })
|
||||||
|
|
@ -554,21 +381,18 @@ fun EventsScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (live.isNotEmpty()) {
|
if (live.isNotEmpty()) {
|
||||||
item(key = "live") {
|
item(key = "live") {
|
||||||
EventRail(
|
EventRail(
|
||||||
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"
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue