diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 3cb0f2a..1e00358 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 = 119 - versionName = "0.14.56" + versionCode = 120 + versionName = "0.14.57" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") 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 b2bd3c7..80a87fc 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 @@ -33,6 +33,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,12 +45,15 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -90,7 +94,7 @@ fun EventsScreen( contentFocusRequester: FocusRequester, navFocusRequester: FocusRequester, focusRestoreNonce: Int = 0, - restoreEventId: String? = null, + @Suppress("UNUSED_PARAMETER") restoreEventId: String? = null, pendingFocusRestore: Boolean = false, onFocusRestoreConsumed: () -> Unit = {}, onOpenEvent: (String) -> Unit, @@ -103,11 +107,27 @@ fun EventsScreen( var providerFilter by remember { mutableStateOf(null) } var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) } var heroFocusReady by remember { mutableStateOf(false) } + var chipsFocusReady by remember { mutableStateOf(false) } var railFocusReady by remember { mutableStateOf(false) } var heroCtaFocused by remember { mutableStateOf(false) } + var placeholderHadFocus by remember { mutableStateOf(false) } val listState = rememberLazyListState() + val chipsFocus = remember { FocusRequester() } val firstRailFocus = remember { FocusRequester() } val scope = rememberCoroutineScope() + val density = LocalDensity.current + val screenH = LocalConfiguration.current.screenHeightDp.dp + + // Mirror Home: full-bleed overlay hero + spacer-driven collapse (no title stuck between rails). + val heroH = remember(screenH) { (screenH.value * 0.78f).dp.coerceIn(420.dp, screenH) } + val shelfStart = remember(screenH) { (screenH.value * 0.52f).dp.coerceIn(300.dp, screenH * 0.58f) } + val heroHpx = with(density) { heroH.toPx() } + val shelfStartPx = with(density) { shelfStart.toPx() } + val collapsePx = if (listState.firstVisibleItemIndex == 0) { + listState.firstVisibleItemScrollOffset.toFloat().coerceIn(0f, shelfStartPx) + } else { + shelfStartPx + } suspend fun refresh() { runCatching { api.listScheduleEvents() } @@ -119,9 +139,7 @@ fun EventsScreen( loading = false } - LaunchedEffect(Unit) { - refresh() - } + LaunchedEffect(Unit) { refresh() } LaunchedEffect(Unit) { while (true) { delay(75_000) @@ -141,40 +159,93 @@ fun EventsScreen( } val live = remember(filtered) { filtered.filter { it.phase == "live" || it.isLive } } val liveHeroItems = remember(live) { - live.filter { it.playable }.ifEmpty { live }.take(8) + val withArt = live.filter { hasUsableHeroArt(it) } + withArt.filter { it.playable }.ifEmpty { withArt }.take(8) } val byDay = remember(filtered, nowMs) { groupEventsByDay(filtered) } val providers = remember(events) { events.map { it.provider }.distinct().sorted() } + val showProviderChips = providers.size > 1 val upcomingRails = remember(byDay) { byDay.mapNotNull { (label, dayEvents) -> val upcoming = dayEvents.filter { it.phase != "live" && !it.isLive } if (upcoming.isEmpty()) null else label to upcoming } } - val hasHeroContent = filtered.isNotEmpty() - val heroItems = liveHeroItems.ifEmpty { listOfNotNull(filtered.firstOrNull()) } - val heroHeight = 480.dp - // Rails starten onder de hero met lichte overlap (Home-achtig). - val railStart = 420.dp - val firstRailListIndex = remember(providers, live, upcomingRails) { - var idx = 1 // after spacer - if (providers.size > 1) idx++ - if (live.isNotEmpty() || upcomingRails.isNotEmpty()) idx else -1 + val hasContent = filtered.isNotEmpty() + val showHero = liveHeroItems.isNotEmpty() + val hasRails = live.isNotEmpty() || upcomingRails.isNotEmpty() + + // Focus chain: Hero CTA → ProviderChips → first rail card + val heroDownTarget = when { + showProviderChips && chipsFocusReady -> chipsFocus + railFocusReady -> firstRailFocus + else -> null + } + val chipsUpTarget = contentFocusRequester.takeIf { showHero && heroFocusReady } + val chipsDownTarget = firstRailFocus.takeIf { railFocusReady } + val firstRailUpTarget = when { + showProviderChips && chipsFocusReady -> chipsFocus + showHero && heroFocusReady -> contentFocusRequester + else -> null } - suspend fun awaitRailReady(timeoutMs: Long = 900): Boolean { + val firstRailListIndex = remember(showProviderChips, live, upcomingRails) { + if (live.isEmpty() && upcomingRails.isEmpty()) return@remember -1 + var idx = 1 // after spacer + if (showProviderChips) idx++ + idx + } + val chipsListIndex = remember(showProviderChips) { + if (!showProviderChips) -1 else 1 // after spacer + } + + fun expandHeroFully() { + if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) { + scope.launch { listState.animateScrollToItem(0) } + } + } + + LaunchedEffect(listState) { + snapshotFlow { + Triple( + listState.firstVisibleItemIndex, + listState.firstVisibleItemScrollOffset, + listState.isScrollInProgress, + ) + }.collect { (index, offset, scrolling) -> + if (!scrolling && index == 0 && offset in 1..240) { + listState.animateScrollToItem(0) + } + } + } + + suspend fun awaitReady(ready: () -> Boolean, timeoutMs: Long = 900): Boolean { val deadline = System.currentTimeMillis() + timeoutMs - while (!railFocusReady && System.currentTimeMillis() < deadline) { + while (!ready() && System.currentTimeMillis() < deadline) { delay(40) } - return railFocusReady + return ready() + } + + fun focusProviderChips() { + scope.launch { + if (!showProviderChips) return@launch + if (!awaitReady({ chipsFocusReady })) return@launch + runCatching { + if (chipsListIndex >= 0) { + listState.animateScrollToItem(chipsListIndex) + delay(40) + } + chipsFocus.requestFocus() + } + } } fun focusFirstRail() { scope.launch { - if (!awaitRailReady()) return@launch + if (!awaitReady({ railFocusReady })) return@launch runCatching { if (firstRailListIndex >= 0) { listState.animateScrollToItem(firstRailListIndex) @@ -185,14 +256,27 @@ fun EventsScreen( } } - // Stabiele restore: altijd hero CTA — geen card-FocusRequester races. + fun focusAfterHero() { + if (showProviderChips) focusProviderChips() else focusFirstRail() + } + + // Entry focus target when there is no hero: chips or first rail bind contentFocusRequester. + val entryIsHero = showHero + val entryReady = when { + entryIsHero -> heroFocusReady + showProviderChips -> chipsFocusReady + else -> railFocusReady + } + + // Detail → back / tab restore: expand + focus entry (hero CTA or chips/rail). LaunchedEffect( pendingFocusRestore, focusRestoreNonce, - hasHeroContent, - heroFocusReady, + hasContent, + entryReady, + entryIsHero, ) { - if (!pendingFocusRestore || !hasHeroContent || !heroFocusReady) return@LaunchedEffect + if (!pendingFocusRestore || !hasContent || !entryReady) return@LaunchedEffect delay(60) onContentFocused() runCatching { listState.scrollToItem(0) } @@ -201,10 +285,8 @@ fun EventsScreen( onFocusRestoreConsumed() } - // Placeholder had focus tijdens laden → doorzetten naar hero CTA zodra klaar. - var placeholderHadFocus by remember { mutableStateOf(false) } - LaunchedEffect(heroFocusReady, hasHeroContent) { - if (!heroFocusReady || !hasHeroContent || !placeholderHadFocus) return@LaunchedEffect + LaunchedEffect(entryReady, hasContent) { + if (!entryReady || !hasContent || !placeholderHadFocus) return@LaunchedEffect delay(40) onContentFocused() runCatching { contentFocusRequester.requestFocus() } @@ -212,11 +294,11 @@ fun EventsScreen( } val pauseCarousel = pendingFocusRestore || heroCtaFocused + // NOOIT dual-bind: placeholder alleen als entry-target NIET in de tree hangt. + val entryInTree = hasContent && (showHero || showProviderChips || hasRails) Box(Modifier.fillMaxSize().background(McColors.Bg)) { - // NOOIT dual-bind: placeholder alleen als hero NIET in de tree hangt. - // (Home had dezelfde race: placeholder + CTA tegelijk → Shield hang.) - if (!hasHeroContent) { + if (!entryInTree) { Box( Modifier .size(1.dp) @@ -240,51 +322,85 @@ fun EventsScreen( Text(error!!, color = McColors.Danger, fontSize = 16.sp) } } - !hasHeroContent -> { + !hasContent -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Geen events in de komende dagen", color = McColors.TextDim, fontSize = 18.sp) } } else -> { - // Hero BUITEN LazyColumn — zelfde stabiele patroon als Home SpotlightHero. - Box( - Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(heroHeight), - ) { - EventHero( - events = heroItems, - nowMs = nowMs, - focusRequester = contentFocusRequester, - navFocusRequester = navFocusRequester, - railFocusRequester = firstRailFocus.takeIf { railFocusReady }, - pauseCarousel = pauseCarousel, - onFocusReady = { heroFocusReady = it }, - onFocus = onContentFocused, - onCtaFocusChanged = { focused -> - heroCtaFocused = focused - if (focused) onContentFocused() - }, - onOpen = { id -> onOpenEvent(id) }, - onPlay = { id -> onPlayEvent(id) }, - onRequestRailFocus = { focusFirstRail() }, - ) + 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() + expandHeroFully() + }, + onCtaFocusChanged = { focused -> + heroCtaFocused = focused + if (focused) { + onContentFocused() + expandHeroFully() + } + }, + onOpen = { id -> onOpenEvent(id) }, + onPlay = { id -> onPlayEvent(id) }, + onRequestNextFocus = { focusAfterHero() }, + ) + } } + LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 48.dp), ) { item(key = "hero-spacer") { - Spacer(Modifier.height(railStart)) + // With hero: spacer drives collapse. Without: clear TopNav. + Spacer( + Modifier.height( + if (showHero) shelfStart else HomeChrome.BadgeTop + 24.dp, + ), + ) } - if (providers.size > 1) { + if (showProviderChips) { item(key = "providers") { ProviderChips( providers = providers, selected = providerFilter, onSelect = { providerFilter = it }, + firstFocusRequester = if (showHero) { + chipsFocus + } else { + contentFocusRequester + }, + focusUp = chipsUpTarget, + focusDown = chipsDownTarget, + onFocusReady = { chipsFocusReady = it }, + onFocus = { + onContentFocused() + if (showHero) expandHeroFully() + }, + onFocusDownFallback = if (chipsDownTarget == null) { + ({ focusFirstRail() }) + } else { + null + }, ) } } @@ -296,11 +412,12 @@ fun EventsScreen( nowMs = nowMs, onOpen = onOpenEvent, onFocus = onContentFocused, - firstItemFocusRequester = firstRailFocus.takeIf { live.isNotEmpty() }, - focusUpTo = contentFocusRequester.takeIf { heroFocusReady }, - onFirstFocusReady = { ready -> - if (live.isNotEmpty()) railFocusReady = ready + firstItemFocusRequester = when { + showHero || showProviderChips -> firstRailFocus + else -> contentFocusRequester }, + focusUpTo = firstRailUpTarget, + onFirstFocusReady = { railFocusReady = it }, ) } } @@ -313,8 +430,12 @@ fun EventsScreen( nowMs = nowMs, onOpen = onOpenEvent, onFocus = onContentFocused, - firstItemFocusRequester = firstRailFocus.takeIf { isFirstUpcoming }, - focusUpTo = contentFocusRequester.takeIf { heroFocusReady && isFirstUpcoming }, + firstItemFocusRequester = when { + !isFirstUpcoming -> null + showHero || showProviderChips -> firstRailFocus + else -> contentFocusRequester + }, + focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming }, onFirstFocusReady = { ready -> if (isFirstUpcoming) railFocusReady = ready }, @@ -674,14 +795,14 @@ private fun EventHero( nowMs: Long, focusRequester: FocusRequester, navFocusRequester: FocusRequester, - railFocusRequester: FocusRequester?, + nextDownFocusRequester: FocusRequester?, pauseCarousel: Boolean = false, onFocusReady: (Boolean) -> Unit, onFocus: () -> Unit, onCtaFocusChanged: (Boolean) -> Unit = {}, onOpen: (String) -> Unit, onPlay: (String) -> Unit, - onRequestRailFocus: () -> Unit = {}, + onRequestNextFocus: () -> Unit = {}, ) { if (events.isEmpty()) return var heroIndex by remember { mutableIntStateOf(0) } @@ -701,8 +822,7 @@ private fun EventHero( Box( Modifier - .fillMaxWidth() - .height(480.dp) + .fillMaxSize() .clip(RectangleShape) .background(Color(0xFF0B0B0B)), ) { @@ -800,11 +920,11 @@ private fun EventHero( primary = true, focusRequester = focusRequester, focusUp = navFocusRequester, - focusDown = railFocusRequester, + focusDown = nextDownFocusRequester, onFocus = onFocus, onFocusChanged = onCtaFocusChanged, - onFocusDownFallback = if (railFocusRequester == null) { - ({ onRequestRailFocus() }) + onFocusDownFallback = if (nextDownFocusRequester == null) { + ({ onRequestNextFocus() }) } else { null }, @@ -816,10 +936,10 @@ private fun EventHero( McDetailButton( "Meer info", focusUp = navFocusRequester, - focusDown = railFocusRequester, + focusDown = nextDownFocusRequester, onFocus = onFocus, - onFocusDownFallback = if (railFocusRequester == null) { - ({ onRequestRailFocus() }) + onFocusDownFallback = if (nextDownFocusRequester == null) { + ({ onRequestNextFocus() }) } else { null }, @@ -1028,25 +1148,97 @@ private fun ProviderChips( providers: List, selected: String?, onSelect: (String?) -> Unit, + firstFocusRequester: FocusRequester? = null, + focusUp: FocusRequester? = null, + focusDown: FocusRequester? = null, + onFocusReady: ((Boolean) -> Unit)? = null, + onFocus: () -> Unit = {}, + onFocusDownFallback: (() -> Unit)? = null, ) { LazyRow( contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 14.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { item { - Chip("Alles", selected == null) { onSelect(null) } + Chip( + label = "Alles", + active = selected == null, + focusRequester = firstFocusRequester, + focusUp = focusUp, + focusDown = focusDown, + onFocusReady = onFocusReady, + onFocus = onFocus, + onFocusDownFallback = onFocusDownFallback, + onClick = { onSelect(null) }, + ) } items(providers) { p -> - Chip(prettyProvider(p), selected.equals(p, ignoreCase = true)) { onSelect(p) } + Chip( + label = prettyProvider(p), + active = selected.equals(p, ignoreCase = true), + focusUp = focusUp, + focusDown = focusDown, + onFocus = onFocus, + onFocusDownFallback = onFocusDownFallback, + onClick = { onSelect(p) }, + ) } } } @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun Chip(label: String, active: Boolean, onClick: () -> Unit) { +private fun Chip( + label: String, + active: Boolean, + onClick: () -> Unit, + focusRequester: FocusRequester? = null, + focusUp: FocusRequester? = null, + focusDown: FocusRequester? = null, + onFocusReady: ((Boolean) -> Unit)? = null, + onFocus: (() -> Unit)? = null, + onFocusDownFallback: (() -> Unit)? = null, +) { + if (focusRequester != null) { + DisposableEffect(focusRequester) { + onFocusReady?.invoke(true) + onDispose { onFocusReady?.invoke(false) } + } + } + val mod = Modifier + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .then( + if (focusUp != null || (focusDown != null && onFocusDownFallback == null)) { + Modifier.focusProperties { + focusUp?.let { up = it } + if (onFocusDownFallback == null) { + focusDown?.let { down = it } + } + } + } else { + Modifier + }, + ) + .then( + if (onFocusDownFallback != null) { + Modifier.onPreviewKeyEvent { ev -> + if (ev.type == KeyEventType.KeyDown && ev.key == Key.DirectionDown) { + onFocusDownFallback() + true + } else { + false + } + } + } else { + Modifier + }, + ) + .onFocusChanged { + if (it.isFocused) onFocus?.invoke() + } Surface( onClick = onClick, + modifier = mod, colors = ClickableSurfaceDefaults.colors( containerColor = if (active) McColors.Accent else McColors.Surface, focusedContainerColor = if (active) McColors.Accent else McColors.SurfaceFocus, @@ -1240,9 +1432,28 @@ private fun metaLine(event: ScheduleEvent): String = private fun eventArtUrl(event: ScheduleEvent): String? = event.imageLandscape?.takeIf { it.isNotBlank() } - ?: event.images.firstOrNull { it.isNotBlank() && !it.contains("assets/channels", ignoreCase = true) } + ?: event.images.firstOrNull { looksLikeArtwork(it) } ?: event.imagePortrait?.takeIf { it.isNotBlank() } - ?: event.images.firstOrNull() + ?: event.images.firstOrNull { it.isNotBlank() && !it.contains("assets/channels", ignoreCase = true) } + +/** True when artwork is suitable for a full-bleed hero (no bare logos / channel PNGs). */ +private fun looksLikeArtwork(url: String): Boolean { + val u = url.trim() + if (u.isEmpty()) return false + val lower = u.lowercase() + if (lower.contains("assets/channels")) return false + if (lower.contains("/logo") || lower.contains("logo.") || lower.contains("logo_")) return false + if (lower.endsWith(".svg")) return false + return true +} + +private fun hasUsableHeroArt(event: ScheduleEvent): Boolean { + if (!event.imageLandscape.isNullOrBlank()) return true + if (event.images.any { looksLikeArtwork(it) }) return true + // Portrait can still fill a hero better than a black void — only if not a channel logo path. + val portrait = event.imagePortrait?.takeIf { it.isNotBlank() } ?: return false + return looksLikeArtwork(portrait) +} private fun prettyProvider(raw: String): String = when (raw.lowercase()) { "viaplay" -> "Viaplay"