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"
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")

View file

@ -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,6 +391,9 @@ fun EventsScreen(
}
if (showProviderChips) {
item(key = "providers") {
CompositionLocalProvider(
LocalBringIntoViewSpec provides ChipsNoScrollBringIntoViewSpec,
) {
ProviderChips(
providers = providers,
selected = providerFilter,
@ -392,10 +406,8 @@ fun EventsScreen(
focusUp = chipsUpTarget,
focusDown = chipsDownTarget,
onFocusReady = { chipsFocusReady = it },
onFocus = {
onContentFocused()
if (showHero) expandHeroFully()
},
// Alleen content-focus melden — géén expand/scroll (hero jump).
onFocus = onContentFocused,
onFocusDownFallback = if (chipsDownTarget == null) {
({ focusFirstRail() })
} else {
@ -404,6 +416,7 @@ fun EventsScreen(
)
}
}
}
if (live.isNotEmpty()) {
item(key = "live") {
EventRail(
@ -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,18 +521,12 @@ fun EventDetailScreen(
}
}
else -> {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 40.dp),
) {
item(key = "hero") {
Box(
Modifier
.fillMaxWidth()
.height(420.dp)
.clip(RectangleShape),
) {
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,
@ -556,74 +534,90 @@ fun EventDetailScreen(
alignment = Alignment.TopCenter,
modifier = Modifier.fillMaxSize(),
)
// Strong horizontal + vertical scrims (SpotlightHero / MovieDetail)
Box(
Modifier
.fillMaxSize()
.background(
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(
listOf(
Color(0x44000000),
Color.Transparent,
Color(0x880B0B0B),
McColors.Bg,
),
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),
),
),
)
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 ->
ev.logoUrl?.takeIf { it.isNotBlank() }?.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),
.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),
) {
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,
)
}
val window = detailTimeWindowLabel(ev, nowMs)
if (window.isNotBlank()) {
Text(
window,
color = McColors.Text,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
)
}
item(key = "cta") {
Spacer(Modifier.height(6.dp))
Row(
Modifier.padding(horizontal = HomeChrome.EdgeInset),
horizontalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (ev.playable) {
@ -631,7 +625,7 @@ fun EventDetailScreen(
"Kijk live",
primary = true,
focusRequester = playFocus,
onFocusDownFallback = if (hasGallery) ({ scrollToGallery() }) else null,
focusDown = bodyFocus.takeIf { hasFocusableBody },
onClick = { onPlay(ev.id) },
)
} else {
@ -640,144 +634,76 @@ fun EventDetailScreen(
primary = false,
enabled = false,
focusRequester = playFocus,
onFocusDownFallback = if (hasGallery) ({ scrollToGallery() }) else null,
focusDown = bodyFocus.takeIf { hasFocusableBody },
onClick = {},
)
}
McDetailButton(
"Terug",
focusRequester = backFocus,
onFocusDownFallback = if (hasGallery) ({ scrollToGallery() }) else null,
focusDown = bodyFocus.takeIf { hasFocusableBody },
onClick = onBack,
)
}
}
if (!ev.description.isNullOrBlank() || !ev.subtitle.isNullOrBlank()) {
item(key = "desc") {
if (hasFocusableBody) {
Column(
Modifier
.padding(HomeChrome.EdgeInset)
.padding(top = 22.dp),
.focusRequester(bodyFocus)
.focusable()
.focusProperties { up = playFocus }
.padding(top = 4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (!ev.eventType.isNullOrBlank() || !ev.subtitle.isNullOrBlank()) {
if (showSubtitle) {
Text(
listOfNotNull(ev.eventType, ev.subtitle)
.joinToString(" · "),
ev.subtitle!!,
color = McColors.TextDim,
fontSize = 15.sp,
fontSize = 14.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(10.dp))
}
if (!ev.description.isNullOrBlank()) {
if (showDescription) {
Text(
ev.description!!,
color = McColors.Text,
color = Color(0xFFE8E8E8),
fontSize = 16.sp,
lineHeight = 24.sp,
lineHeight = 26.sp,
maxLines = 8,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
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)) {
Spacer(Modifier.height(4.dp))
Text(
"Beelden",
color = McColors.Text,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = HomeChrome.EdgeInset),
"Audio",
color = Color(0xFF8F8F8F),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 0.6.sp,
)
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) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ev.audioLanguages.take(8).forEach { lang ->
Box(
Modifier
.fillMaxSize()
.background(Color(0x22000000)),
.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,
)
}
}
}
}
Spacer(Modifier.height(40.dp))
}
}
} else {
item(key = "spacer") { Spacer(Modifier.height(40.dp)) }
}
}
}
}
@ -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) }