diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 1e00358..aa4f403 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 = 120 - versionName = "0.14.57" + versionCode = 121 + versionName = "0.14.58" 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 80a87fc..2535bca 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 @@ -3,13 +3,18 @@ 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 import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -20,10 +25,12 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +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 @@ -37,7 +44,6 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.foundation.focusable import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester @@ -87,7 +93,20 @@ private val StreamLabelRegex = Regex("""^stream\s*\d+""", RegexOption.IGNORE_CAS 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 fun EventsScreen( api: ApiClient, @@ -197,9 +216,6 @@ fun EventsScreen( if (showProviderChips) idx++ idx } - val chipsListIndex = remember(showProviderChips) { - if (!showProviderChips) -1 else 1 // after spacer - } fun expandHeroFully() { if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) { @@ -233,13 +249,8 @@ fun EventsScreen( scope.launch { if (!showProviderChips) return@launch if (!awaitReady({ chipsFocusReady })) return@launch - runCatching { - if (chipsListIndex >= 0) { - listState.animateScrollToItem(chipsListIndex) - delay(40) - } - chipsFocus.requestFocus() - } + // Geen animateScrollToItem: dat collapst de hero via collapsePx. + runCatching { chipsFocus.requestFocus() } } } @@ -380,28 +391,30 @@ fun EventsScreen( } 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 - }, - ) + 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 + }, + ) + } } } if (live.isNotEmpty()) { @@ -462,9 +475,8 @@ fun EventDetailScreen( var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) } val playFocus = remember { FocusRequester() } val backFocus = remember { FocusRequester() } - val galleryFocus = remember { FocusRequester() } - val listState = rememberLazyListState() - val scope = rememberCoroutineScope() + val bodyFocus = remember { FocusRequester() } + val bodyScroll = rememberScrollState() LaunchedEffect(eventId) { runCatching { @@ -492,34 +504,6 @@ fun EventDetailScreen( BackHandler(onBack = onBack) val ev = event - val gallery = remember(ev) { - ev?.images?.distinct()?.filter { - it != ev.imageLandscape && it != ev.logoUrl - }?.take(12).orEmpty() - } - val hasGallery = gallery.isNotEmpty() - // Lazy items: 0=hero, 1=cta, 2?=desc, 3?=audio, last=gallery - val galleryListIndex = remember(ev, hasGallery) { - if (!hasGallery || ev == null) -1 - else { - var idx = 2 // after hero + cta - if (!ev.description.isNullOrBlank() || !ev.subtitle.isNullOrBlank()) idx++ - if (ev.audioLanguages.isNotEmpty()) idx++ - idx - } - } - - fun scrollToGallery() { - if (galleryListIndex < 0) return - scope.launch { - runCatching { - listState.animateScrollToItem(galleryListIndex) - delay(60) - galleryFocus.requestFocus() - } - } - } - Box(Modifier.fillMaxSize().background(McColors.Bg)) { when { ev == null && error == null -> { @@ -537,246 +521,188 @@ fun EventDetailScreen( } } else -> { - LazyColumn( - state = listState, + val showSubtitle = !ev.subtitle.isNullOrBlank() && + !ev.subtitle.equals(ev.name, ignoreCase = true) + val showDescription = !ev.description.isNullOrBlank() + val hasFocusableBody = showSubtitle || showDescription + + // Full-bleed landscape backdrop + AsyncImage( + model = eventArtUrl(ev), + contentDescription = ev.name, + contentScale = ContentScale.Crop, + alignment = Alignment.TopCenter, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 40.dp), + ) + // Strong horizontal + vertical scrims (SpotlightHero / MovieDetail) + Box( + Modifier.fillMaxSize().background( + Brush.horizontalGradient( + 0.0f to Color(0xF20B0B0B), + 0.40f to Color(0xCC0B0B0B), + 0.72f to Color(0x550B0B0B), + 1.0f to Color(0x180B0B0B), + ), + ), + ) + Box( + Modifier.fillMaxSize().background( + Brush.verticalGradient( + 0.0f to Color(0x66000000), + 0.12f to Color.Transparent, + 0.45f to Color.Transparent, + 0.72f to Color(0xAA0B0B0B), + 1.0f to Color(0xFF0B0B0B), + ), + ), + ) + + ev.logoUrl?.takeIf { it.isNotBlank() }?.let { logo -> + AsyncImage( + model = logo, + contentDescription = null, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = HomeChrome.BadgeTop, end = HomeChrome.EdgeInset) + .height(48.dp) + .width(120.dp), + contentScale = ContentScale.Fit, + alignment = Alignment.CenterEnd, + ) + } + + Column( + Modifier + .fillMaxHeight() + .fillMaxWidth(0.62f) + .padding(top = HomeChrome.BadgeTop) + .padding( + start = HomeChrome.EdgeInset, + end = HomeChrome.EdgeInset, + bottom = 36.dp, + ) + .verticalScroll(bodyScroll), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - item(key = "hero") { - Box( - Modifier - .fillMaxWidth() - .height(420.dp) - .clip(RectangleShape), - ) { - AsyncImage( - model = eventArtUrl(ev), - contentDescription = ev.name, - contentScale = ContentScale.Crop, - alignment = Alignment.TopCenter, - modifier = Modifier.fillMaxSize(), - ) - Box( - Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - listOf( - Color(0x44000000), - Color.Transparent, - Color(0x880B0B0B), - McColors.Bg, - ), - ), - ), - ) - Column( - Modifier - .align(Alignment.BottomStart) - .padding(horizontal = HomeChrome.EdgeInset, vertical = 28.dp) - .fillMaxWidth(0.72f), - ) { - StatusBadge(ev, nowMs) - Spacer(Modifier.height(10.dp)) - Text( - ev.name, - color = McColors.Text, - fontSize = 34.sp, - fontWeight = FontWeight.Bold, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - lineHeight = 40.sp, - ) - Spacer(Modifier.height(8.dp)) - val meta = metaLine(ev) - if (meta.isNotBlank()) { - Text( - meta, - color = McColors.TextDim, - fontSize = 15.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(6.dp)) - } - Text( - timeWindowLabel(ev), - color = McColors.Text, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - ) - } - ev.logoUrl?.let { logo -> - AsyncImage( - model = logo, - contentDescription = null, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 24.dp, end = HomeChrome.EdgeInset) - .height(44.dp) - .width(110.dp), - contentScale = ContentScale.Fit, - ) - } - } + StatusBadge(ev, nowMs) + Text( + ev.name, + color = Color.White, + fontSize = 40.sp, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + lineHeight = 46.sp, + ) + val meta = detailMetaLine(ev) + if (meta.isNotBlank()) { + Text( + meta, + color = Color(0xFFCFCFCF), + fontSize = 15.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) } - item(key = "cta") { - Row( - Modifier.padding(horizontal = HomeChrome.EdgeInset), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (ev.playable) { - McDetailButton( - "Kijk live", - primary = true, - focusRequester = playFocus, - onFocusDownFallback = if (hasGallery) ({ scrollToGallery() }) else null, - onClick = { onPlay(ev.id) }, - ) - } else { - McDetailButton( - ctaLabel(ev, nowMs), - primary = false, - enabled = false, - focusRequester = playFocus, - onFocusDownFallback = if (hasGallery) ({ scrollToGallery() }) else null, - onClick = {}, - ) - } + val window = detailTimeWindowLabel(ev, nowMs) + if (window.isNotBlank()) { + Text( + window, + color = McColors.Text, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + ) + } + + Spacer(Modifier.height(6.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (ev.playable) { McDetailButton( - "Terug", - focusRequester = backFocus, - onFocusDownFallback = if (hasGallery) ({ scrollToGallery() }) else null, - onClick = onBack, + "Kijk live", + primary = true, + focusRequester = playFocus, + focusDown = bodyFocus.takeIf { hasFocusableBody }, + onClick = { onPlay(ev.id) }, + ) + } else { + McDetailButton( + ctaLabel(ev, nowMs), + primary = false, + enabled = false, + focusRequester = playFocus, + focusDown = bodyFocus.takeIf { hasFocusableBody }, + onClick = {}, ) } + McDetailButton( + "Terug", + focusRequester = backFocus, + focusDown = bodyFocus.takeIf { hasFocusableBody }, + onClick = onBack, + ) } - if (!ev.description.isNullOrBlank() || !ev.subtitle.isNullOrBlank()) { - item(key = "desc") { - Column( - Modifier - .padding(HomeChrome.EdgeInset) - .padding(top = 22.dp), - ) { - if (!ev.eventType.isNullOrBlank() || !ev.subtitle.isNullOrBlank()) { - Text( - listOfNotNull(ev.eventType, ev.subtitle) - .joinToString(" · "), - color = McColors.TextDim, - fontSize = 15.sp, - ) - Spacer(Modifier.height(10.dp)) - } - if (!ev.description.isNullOrBlank()) { - Text( - ev.description!!, - color = McColors.Text, - fontSize = 16.sp, - lineHeight = 24.sp, - ) - } - } - } - } - if (ev.audioLanguages.isNotEmpty()) { - item(key = "audio") { - Column(Modifier.padding(HomeChrome.EdgeInset).padding(top = 18.dp)) { - Text("Audio", color = McColors.TextDim, fontSize = 13.sp) - Spacer(Modifier.height(8.dp)) - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(ev.audioLanguages) { lang -> - Box( - Modifier - .clip(RoundedCornerShape(6.dp)) - .background(McColors.Surface) - .padding(horizontal = 10.dp, vertical = 6.dp), - ) { - Text(lang.uppercase(), color = McColors.Text, fontSize = 13.sp) - } - } - } - } - } - } - if (hasGallery) { - item(key = "gallery") { - Column(Modifier.padding(top = 28.dp)) { + + if (hasFocusableBody) { + Column( + Modifier + .focusRequester(bodyFocus) + .focusable() + .focusProperties { up = playFocus } + .padding(top = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (showSubtitle) { Text( - "Beelden", - color = McColors.Text, - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = HomeChrome.EdgeInset), + ev.subtitle!!, + color = McColors.TextDim, + fontSize = 14.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (showDescription) { + Text( + ev.description!!, + color = Color(0xFFE8E8E8), + fontSize = 16.sp, + lineHeight = 26.sp, + maxLines = 8, + overflow = TextOverflow.Ellipsis, ) - Spacer(Modifier.height(12.dp)) - LazyRow( - contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - itemsIndexed(gallery) { index, url -> - var focused by remember { mutableStateOf(false) } - Surface( - onClick = {}, - modifier = Modifier - .width(220.dp) - .height(124.dp) - .then( - if (index == 0) { - Modifier.focusRequester(galleryFocus) - } else { - Modifier - }, - ) - .focusProperties { - up = playFocus - } - .onFocusChanged { - focused = it.isFocused - if (it.isFocused) { - scope.launch { - runCatching { - listState.animateScrollToItem( - galleryListIndex.coerceAtLeast(0), - ) - } - } - } - }, - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), - glow = ClickableSurfaceDefaults.glow( - focusedGlow = Glow( - elevationColor = McColors.Accent, - elevation = 6.dp, - ), - ), - colors = ClickableSurfaceDefaults.colors( - containerColor = McColors.Surface, - focusedContainerColor = McColors.Surface, - ), - shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)), - ) { - AsyncImage( - model = url, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - if (focused) { - Box( - Modifier - .fillMaxSize() - .background(Color(0x22000000)), - ) - } - } - } - } - Spacer(Modifier.height(40.dp)) } } - } else { - item(key = "spacer") { Spacer(Modifier.height(40.dp)) } + } + + if (ev.audioLanguages.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Text( + "Audio", + color = Color(0xFF8F8F8F), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.6.sp, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ev.audioLanguages.take(8).forEach { lang -> + Box( + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(Color(0x33FFFFFF)) + .padding(horizontal = 10.dp, vertical = 5.dp), + ) { + Text( + lang.uppercase(), + color = Color(0xFFDDDDDD), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } + } + } } } } @@ -1290,7 +1216,7 @@ private fun McDetailButton( focusDown: FocusRequester? = null, onFocus: (() -> Unit)? = null, onFocusChanged: ((Boolean) -> Unit)? = null, - /** When gallery/rail is off-screen, scroll+focus before default D-pad move. */ + /** When next down target is off-screen, scroll+focus before default D-pad move. */ onFocusDownFallback: (() -> Unit)? = null, ) { val mod = Modifier @@ -1412,6 +1338,25 @@ private fun timeWindowLabel(event: ScheduleEvent): String { 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) + val range = if (stop != null) { + "${start.format(TimeFmt)} – ${stop.format(TimeFmt)}" + } else { + start.format(TimeFmt) + } + if (event.phase == "live" || event.isLive) return range + val today = Instant.ofEpochMilli(nowMs).atZone(ZoneNl).toLocalDate() + val day = start.toLocalDate() + return when { + day == today -> "Vandaag · $range" + day == today.plusDays(1) -> "Morgen · $range" + else -> "${start.format(DayTimeFmt)}${stop?.let { " – ${it.format(TimeFmt)}" } ?: ""}" + } +} + private fun isStreamLabel(value: String?): Boolean { val s = value?.trim().orEmpty() if (s.isEmpty()) return false @@ -1430,6 +1375,15 @@ private fun metaLine(event: ScheduleEvent): String = displayChannel(event), ).joinToString(" · ") +/** Detail meta: provider · category · type — nooit Stream N. */ +private fun detailMetaLine(event: ScheduleEvent): String = + listOfNotNull( + prettyProvider(event.provider), + event.category?.takeIf { it.isNotBlank() }, + event.eventType?.takeIf { it.isNotBlank() }, + displayChannel(event), + ).distinct().joinToString(" · ") + private fun eventArtUrl(event: ScheduleEvent): String? = event.imageLandscape?.takeIf { it.isNotBlank() } ?: event.images.firstOrNull { looksLikeArtwork(it) }