polish Events detail and stop hero jump on provider chips
This commit is contained in:
parent
5115682427
commit
929a2b1137
2 changed files with 252 additions and 298 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,6 +391,9 @@ fun EventsScreen(
|
||||||
}
|
}
|
||||||
if (showProviderChips) {
|
if (showProviderChips) {
|
||||||
item(key = "providers") {
|
item(key = "providers") {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalBringIntoViewSpec provides ChipsNoScrollBringIntoViewSpec,
|
||||||
|
) {
|
||||||
ProviderChips(
|
ProviderChips(
|
||||||
providers = providers,
|
providers = providers,
|
||||||
selected = providerFilter,
|
selected = providerFilter,
|
||||||
|
|
@ -392,10 +406,8 @@ fun EventsScreen(
|
||||||
focusUp = chipsUpTarget,
|
focusUp = chipsUpTarget,
|
||||||
focusDown = chipsDownTarget,
|
focusDown = chipsDownTarget,
|
||||||
onFocusReady = { chipsFocusReady = it },
|
onFocusReady = { chipsFocusReady = it },
|
||||||
onFocus = {
|
// Alleen content-focus melden — géén expand/scroll (hero jump).
|
||||||
onContentFocused()
|
onFocus = onContentFocused,
|
||||||
if (showHero) expandHeroFully()
|
|
||||||
},
|
|
||||||
onFocusDownFallback = if (chipsDownTarget == null) {
|
onFocusDownFallback = if (chipsDownTarget == null) {
|
||||||
({ focusFirstRail() })
|
({ focusFirstRail() })
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -404,6 +416,7 @@ fun EventsScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (live.isNotEmpty()) {
|
if (live.isNotEmpty()) {
|
||||||
item(key = "live") {
|
item(key = "live") {
|
||||||
EventRail(
|
EventRail(
|
||||||
|
|
@ -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,18 +521,12 @@ fun EventDetailScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
val showSubtitle = !ev.subtitle.isNullOrBlank() &&
|
||||||
state = listState,
|
!ev.subtitle.equals(ev.name, ignoreCase = true)
|
||||||
modifier = Modifier.fillMaxSize(),
|
val showDescription = !ev.description.isNullOrBlank()
|
||||||
contentPadding = PaddingValues(bottom = 40.dp),
|
val hasFocusableBody = showSubtitle || showDescription
|
||||||
) {
|
|
||||||
item(key = "hero") {
|
// Full-bleed landscape backdrop
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(420.dp)
|
|
||||||
.clip(RectangleShape),
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = eventArtUrl(ev),
|
model = eventArtUrl(ev),
|
||||||
contentDescription = ev.name,
|
contentDescription = ev.name,
|
||||||
|
|
@ -556,74 +534,90 @@ fun EventDetailScreen(
|
||||||
alignment = Alignment.TopCenter,
|
alignment = Alignment.TopCenter,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
// Strong horizontal + vertical scrims (SpotlightHero / MovieDetail)
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier.fillMaxSize().background(
|
||||||
.fillMaxSize()
|
Brush.horizontalGradient(
|
||||||
.background(
|
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(
|
Brush.verticalGradient(
|
||||||
listOf(
|
0.0f to Color(0x66000000),
|
||||||
Color(0x44000000),
|
0.12f to Color.Transparent,
|
||||||
Color.Transparent,
|
0.45f to Color.Transparent,
|
||||||
Color(0x880B0B0B),
|
0.72f to Color(0xAA0B0B0B),
|
||||||
McColors.Bg,
|
1.0f to Color(0xFF0B0B0B),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Column(
|
|
||||||
Modifier
|
ev.logoUrl?.takeIf { it.isNotBlank() }?.let { logo ->
|
||||||
.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(
|
AsyncImage(
|
||||||
model = logo,
|
model = logo,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.padding(top = 24.dp, end = HomeChrome.EdgeInset)
|
.padding(top = HomeChrome.BadgeTop, end = HomeChrome.EdgeInset)
|
||||||
.height(44.dp)
|
.height(48.dp)
|
||||||
.width(110.dp),
|
.width(120.dp),
|
||||||
contentScale = ContentScale.Fit,
|
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(
|
Row(
|
||||||
Modifier.padding(horizontal = HomeChrome.EdgeInset),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (ev.playable) {
|
if (ev.playable) {
|
||||||
|
|
@ -631,7 +625,7 @@ fun EventDetailScreen(
|
||||||
"Kijk live",
|
"Kijk live",
|
||||||
primary = true,
|
primary = true,
|
||||||
focusRequester = playFocus,
|
focusRequester = playFocus,
|
||||||
onFocusDownFallback = if (hasGallery) ({ scrollToGallery() }) else null,
|
focusDown = bodyFocus.takeIf { hasFocusableBody },
|
||||||
onClick = { onPlay(ev.id) },
|
onClick = { onPlay(ev.id) },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -640,144 +634,76 @@ fun EventDetailScreen(
|
||||||
primary = false,
|
primary = false,
|
||||||
enabled = false,
|
enabled = false,
|
||||||
focusRequester = playFocus,
|
focusRequester = playFocus,
|
||||||
onFocusDownFallback = if (hasGallery) ({ scrollToGallery() }) else null,
|
focusDown = bodyFocus.takeIf { hasFocusableBody },
|
||||||
onClick = {},
|
onClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
McDetailButton(
|
McDetailButton(
|
||||||
"Terug",
|
"Terug",
|
||||||
focusRequester = backFocus,
|
focusRequester = backFocus,
|
||||||
onFocusDownFallback = if (hasGallery) ({ scrollToGallery() }) else null,
|
focusDown = bodyFocus.takeIf { hasFocusableBody },
|
||||||
onClick = onBack,
|
onClick = onBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (!ev.description.isNullOrBlank() || !ev.subtitle.isNullOrBlank()) {
|
if (hasFocusableBody) {
|
||||||
item(key = "desc") {
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.padding(HomeChrome.EdgeInset)
|
.focusRequester(bodyFocus)
|
||||||
.padding(top = 22.dp),
|
.focusable()
|
||||||
|
.focusProperties { up = playFocus }
|
||||||
|
.padding(top = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
if (!ev.eventType.isNullOrBlank() || !ev.subtitle.isNullOrBlank()) {
|
if (showSubtitle) {
|
||||||
Text(
|
Text(
|
||||||
listOfNotNull(ev.eventType, ev.subtitle)
|
ev.subtitle!!,
|
||||||
.joinToString(" · "),
|
|
||||||
color = McColors.TextDim,
|
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(
|
Text(
|
||||||
ev.description!!,
|
ev.description!!,
|
||||||
color = McColors.Text,
|
color = Color(0xFFE8E8E8),
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
lineHeight = 24.sp,
|
lineHeight = 26.sp,
|
||||||
|
maxLines = 8,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (ev.audioLanguages.isNotEmpty()) {
|
if (ev.audioLanguages.isNotEmpty()) {
|
||||||
item(key = "audio") {
|
Spacer(Modifier.height(4.dp))
|
||||||
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",
|
"Audio",
|
||||||
color = McColors.Text,
|
color = Color(0xFF8F8F8F),
|
||||||
fontSize = 20.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.Medium,
|
||||||
modifier = Modifier.padding(horizontal = HomeChrome.EdgeInset),
|
letterSpacing = 0.6.sp,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
LazyRow(
|
ev.audioLanguages.take(8).forEach { lang ->
|
||||||
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(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(Color(0x22000000)),
|
.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,
|
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) }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue