polish Events detail and stop hero jump on provider chips

This commit is contained in:
Jos Vooges | STH 2026-09-19 01:19:56 +02:00
parent 5115682427
commit 929a2b1137
2 changed files with 252 additions and 298 deletions

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 120 versionCode = 121
versionName = "0.14.57" versionName = "0.14.58"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -3,13 +3,18 @@ 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.Crossfade
import androidx.compose.animation.core.tween 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.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
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
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.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
@ -37,7 +44,6 @@ 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
import androidx.compose.foundation.focusable
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester 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 HeroRotateMs = 7_000L
private const val HeroFadeMs = 850 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,
@ -197,9 +216,6 @@ fun EventsScreen(
if (showProviderChips) idx++ if (showProviderChips) idx++
idx idx
} }
val chipsListIndex = remember(showProviderChips) {
if (!showProviderChips) -1 else 1 // after spacer
}
fun expandHeroFully() { fun expandHeroFully() {
if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) { if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
@ -233,13 +249,8 @@ fun EventsScreen(
scope.launch { scope.launch {
if (!showProviderChips) return@launch if (!showProviderChips) return@launch
if (!awaitReady({ chipsFocusReady })) return@launch if (!awaitReady({ chipsFocusReady })) return@launch
runCatching { // Geen animateScrollToItem: dat collapst de hero via collapsePx.
if (chipsListIndex >= 0) { runCatching { chipsFocus.requestFocus() }
listState.animateScrollToItem(chipsListIndex)
delay(40)
}
chipsFocus.requestFocus()
}
} }
} }
@ -380,28 +391,30 @@ fun EventsScreen(
} }
if (showProviderChips) { if (showProviderChips) {
item(key = "providers") { item(key = "providers") {
ProviderChips( CompositionLocalProvider(
providers = providers, LocalBringIntoViewSpec provides ChipsNoScrollBringIntoViewSpec,
selected = providerFilter, ) {
onSelect = { providerFilter = it }, ProviderChips(
firstFocusRequester = if (showHero) { providers = providers,
chipsFocus selected = providerFilter,
} else { onSelect = { providerFilter = it },
contentFocusRequester firstFocusRequester = if (showHero) {
}, chipsFocus
focusUp = chipsUpTarget, } else {
focusDown = chipsDownTarget, contentFocusRequester
onFocusReady = { chipsFocusReady = it }, },
onFocus = { focusUp = chipsUpTarget,
onContentFocused() focusDown = chipsDownTarget,
if (showHero) expandHeroFully() onFocusReady = { chipsFocusReady = it },
}, // Alleen content-focus melden — géén expand/scroll (hero jump).
onFocusDownFallback = if (chipsDownTarget == null) { onFocus = onContentFocused,
({ focusFirstRail() }) onFocusDownFallback = if (chipsDownTarget == null) {
} else { ({ focusFirstRail() })
null } else {
}, null
) },
)
}
} }
} }
if (live.isNotEmpty()) { if (live.isNotEmpty()) {
@ -462,9 +475,8 @@ fun EventDetailScreen(
var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) } var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) }
val playFocus = remember { FocusRequester() } val playFocus = remember { FocusRequester() }
val backFocus = remember { FocusRequester() } val backFocus = remember { FocusRequester() }
val galleryFocus = remember { FocusRequester() } val bodyFocus = remember { FocusRequester() }
val listState = rememberLazyListState() val bodyScroll = rememberScrollState()
val scope = rememberCoroutineScope()
LaunchedEffect(eventId) { LaunchedEffect(eventId) {
runCatching { runCatching {
@ -492,34 +504,6 @@ fun EventDetailScreen(
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
val ev = event 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)) { Box(Modifier.fillMaxSize().background(McColors.Bg)) {
when { when {
ev == null && error == null -> { ev == null && error == null -> {
@ -537,246 +521,188 @@ fun EventDetailScreen(
} }
} }
else -> { else -> {
LazyColumn( val showSubtitle = !ev.subtitle.isNullOrBlank() &&
state = listState, !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(), 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") { StatusBadge(ev, nowMs)
Box( Text(
Modifier ev.name,
.fillMaxWidth() color = Color.White,
.height(420.dp) fontSize = 40.sp,
.clip(RectangleShape), fontWeight = FontWeight.Bold,
) { maxLines = 3,
AsyncImage( overflow = TextOverflow.Ellipsis,
model = eventArtUrl(ev), lineHeight = 46.sp,
contentDescription = ev.name, )
contentScale = ContentScale.Crop, val meta = detailMetaLine(ev)
alignment = Alignment.TopCenter, if (meta.isNotBlank()) {
modifier = Modifier.fillMaxSize(), Text(
) meta,
Box( color = Color(0xFFCFCFCF),
Modifier fontSize = 15.sp,
.fillMaxSize() maxLines = 2,
.background( overflow = TextOverflow.Ellipsis,
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,
)
}
}
} }
item(key = "cta") { val window = detailTimeWindowLabel(ev, nowMs)
Row( if (window.isNotBlank()) {
Modifier.padding(horizontal = HomeChrome.EdgeInset), Text(
horizontalArrangement = Arrangement.spacedBy(12.dp), window,
verticalAlignment = Alignment.CenterVertically, color = McColors.Text,
) { fontSize = 16.sp,
if (ev.playable) { fontWeight = FontWeight.Medium,
McDetailButton( )
"Kijk live", }
primary = true,
focusRequester = playFocus, Spacer(Modifier.height(6.dp))
onFocusDownFallback = if (hasGallery) ({ scrollToGallery() }) else null,
onClick = { onPlay(ev.id) }, Row(
) horizontalArrangement = Arrangement.spacedBy(10.dp),
} else { verticalAlignment = Alignment.CenterVertically,
McDetailButton( ) {
ctaLabel(ev, nowMs), if (ev.playable) {
primary = false,
enabled = false,
focusRequester = playFocus,
onFocusDownFallback = if (hasGallery) ({ scrollToGallery() }) else null,
onClick = {},
)
}
McDetailButton( McDetailButton(
"Terug", "Kijk live",
focusRequester = backFocus, primary = true,
onFocusDownFallback = if (hasGallery) ({ scrollToGallery() }) else null, focusRequester = playFocus,
onClick = onBack, 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") { if (hasFocusableBody) {
Column( Column(
Modifier Modifier
.padding(HomeChrome.EdgeInset) .focusRequester(bodyFocus)
.padding(top = 22.dp), .focusable()
) { .focusProperties { up = playFocus }
if (!ev.eventType.isNullOrBlank() || !ev.subtitle.isNullOrBlank()) { .padding(top = 4.dp),
Text( verticalArrangement = Arrangement.spacedBy(10.dp),
listOfNotNull(ev.eventType, ev.subtitle) ) {
.joinToString(" · "), if (showSubtitle) {
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)) {
Text( Text(
"Beelden", ev.subtitle!!,
color = McColors.Text, color = McColors.TextDim,
fontSize = 20.sp, fontSize = 14.sp,
fontWeight = FontWeight.SemiBold, maxLines = 2,
modifier = Modifier.padding(horizontal = HomeChrome.EdgeInset), 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, focusDown: FocusRequester? = null,
onFocus: (() -> Unit)? = null, onFocus: (() -> Unit)? = null,
onFocusChanged: ((Boolean) -> 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, onFocusDownFallback: (() -> Unit)? = null,
) { ) {
val mod = Modifier val mod = Modifier
@ -1412,6 +1338,25 @@ private fun timeWindowLabel(event: ScheduleEvent): String {
return "${start.format(TimeFmt)} – ${stop.format(TimeFmt)}" 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 { private fun isStreamLabel(value: String?): Boolean {
val s = value?.trim().orEmpty() val s = value?.trim().orEmpty()
if (s.isEmpty()) return false if (s.isEmpty()) return false
@ -1430,6 +1375,15 @@ private fun metaLine(event: ScheduleEvent): String =
displayChannel(event), displayChannel(event),
).joinToString(" · ") ).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? = private fun eventArtUrl(event: ScheduleEvent): String? =
event.imageLandscape?.takeIf { it.isNotBlank() } event.imageLandscape?.takeIf { it.isNotBlank() }
?: event.images.firstOrNull { looksLikeArtwork(it) } ?: event.images.firstOrNull { looksLikeArtwork(it) }