diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 7a65a91..64a41d1 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -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") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt index 28dd261..0a9ab54 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt @@ -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>(emptyList()) } @@ -128,32 +98,14 @@ fun EventsScreen( var error by remember { mutableStateOf(null) } var providerFilter by remember { mutableStateOf(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,93 +354,31 @@ 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, - focusDown = chipsDownTarget, - onFocusReady = { chipsFocusReady = it }, - // Alleen content-focus melden — géén expand/scroll (hero jump). - onFocus = onContentFocused, - onFocusDownFallback = if (chipsDownTarget == null) { - ({ focusFirstRail() }) - } else { - null - }, - ) - } + ProviderChips( + providers = providers, + selected = providerFilter, + onSelect = { providerFilter = it }, + firstFocusRequester = contentFocusRequester, + focusUp = navFocusRequester, + focusDown = chipsDownTarget, + onFocusReady = { chipsFocusReady = it }, + onFocus = onContentFocused, + onFocusDownFallback = if (chipsDownTarget == null) { + ({ focusFirstRail() }) + } else { + null + }, + ) } } if (live.isNotEmpty()) { @@ -561,14 +387,12 @@ fun EventsScreen( 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, - 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" diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 4109897..9228c70 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -130,7 +130,6 @@ fun McNav() { var tvFocusNonce by remember { mutableIntStateOf(0) } var eventsReturnNonce by remember { mutableIntStateOf(0) } var lastOpenedEventId by rememberSaveable { mutableStateOf(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(