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 dade6ce..5039a51 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 @@ -1,7 +1,15 @@ package nl.vonas.mediacluster.tv.ui import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -17,11 +25,13 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable @@ -35,6 +45,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties @@ -55,6 +66,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Border import androidx.tv.material3.Glow import androidx.tv.material3.Surface import androidx.tv.material3.Text @@ -82,6 +94,20 @@ private val DayTimeFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE d MMM · HH:mm", Locale("nl", "NL")) private val StreamLabelRegex = Regex("""^stream\s*\d+""", RegexOption.IGNORE_CASE) +/** Stadium-energy palette — Events UI + event-player, los van Netflix-home. */ +internal object EventsLook { + val Neon = Color(0xFF00E8A0) + val NeonSoft = Color(0x3300E8A0) + val NeonGlow = Color(0x6600E8A0) + val Live = Color(0xFFFF2D55) + val LiveSoft = Color(0x44FF2D55) + val Soon = Color(0xFFFFB020) + val Deep = Color(0xFF071410) + val Panel = Color(0xFF121A18) + val PanelHot = Color(0xFF1A2824) + val Scrim = Color(0xCC050A08) +} + @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun EventsScreen( @@ -101,8 +127,7 @@ fun EventsScreen( var sportFilter by remember { mutableStateOf(null) } var providerFilter by remember { mutableStateOf(null) } var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) } - var sportChipsFocusReady by remember { mutableStateOf(false) } - var providerChipsFocusReady by remember { mutableStateOf(false) } + var filterBarFocusReady by remember { mutableStateOf(false) } /** Owner-id van first-rail FocusRequester — voorkomt dispose-race live↔upcoming. */ var railFocusOwner by remember { mutableStateOf(null) } val railFocusReady = railFocusOwner != null @@ -111,7 +136,6 @@ fun EventsScreen( val listState = rememberLazyListState() val firstRailFocus = remember { FocusRequester() } val restoreCardFocus = remember { FocusRequester() } - val providerRowFocus = remember { FocusRequester() } suspend fun refresh() { runCatching { api.listScheduleEvents() } @@ -200,28 +224,17 @@ fun EventsScreen( else -> null } - // Focus: TopNav → sport chips → provider chips → first rail. - val showAnyChips = showSportChips || showProviderChips + // Focus: TopNav → één filterrij (sport + bron) → first rail. + val showFilterBar = showSportChips || showProviderChips val firstRailUpTarget = when { - showProviderChips && providerChipsFocusReady -> providerRowFocus - showSportChips && sportChipsFocusReady -> contentFocusRequester + showFilterBar && filterBarFocusReady -> contentFocusRequester else -> navFocusRequester } - val sportChipFocusDown: FocusRequester? = when { - showProviderChips -> if (providerChipsFocusReady) providerRowFocus else null + val filterFocusDown: FocusRequester? = when { !hasRails -> FocusRequester.Cancel railFocusReady -> firstRailFocus else -> null } - val providerChipFocusDown: FocusRequester? = when { - !hasRails -> FocusRequester.Cancel - railFocusReady -> firstRailFocus - else -> null - } - val providerChipFocusUp = when { - showSportChips && sportChipsFocusReady -> contentFocusRequester - else -> navFocusRequester - } fun claimRailFocus(owner: String, ready: Boolean) { if (ready) { @@ -252,8 +265,7 @@ fun EventsScreen( } val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0 val restoreListIndex = remember( - showSportChips, - showProviderChips, + showFilterBar, live, upcomingRails, restoreInLive, @@ -261,8 +273,7 @@ fun EventsScreen( ) { if (!canRestoreCard) return@remember -1 var idx = 0 - if (showSportChips) idx++ - if (showProviderChips) idx++ + if (showFilterBar) idx++ if (restoreInLive) return@remember idx if (live.isNotEmpty()) idx++ if (restoreUpcomingIndex >= 0) return@remember idx + restoreUpcomingIndex @@ -284,7 +295,7 @@ fun EventsScreen( val activeRestoreFocus = when { !canRestoreCard -> null restoreIsFirstOfFirstRail -> when { - showAnyChips -> firstRailFocus + showFilterBar -> firstRailFocus else -> contentFocusRequester } else -> restoreCardFocus @@ -302,15 +313,13 @@ fun EventsScreen( } val entryReady = when { - showSportChips -> sportChipsFocusReady - showProviderChips -> providerChipsFocusReady + showFilterBar -> filterBarFocusReady else -> railFocusReady } val needContentSink = when { loading && events.isEmpty() -> true error != null && events.isEmpty() -> true - showSportChips -> !sportChipsFocusReady - showProviderChips -> !providerChipsFocusReady + showFilterBar -> !filterBarFocusReady !hasRails -> true else -> !railFocusReady } @@ -334,8 +343,7 @@ fun EventsScreen( if (restoredSuccessfully) return val entryOk = awaitReady({ when { - showSportChips -> sportChipsFocusReady - showProviderChips -> providerChipsFocusReady + showFilterBar -> filterBarFocusReady else -> railFocusReady } }, timeoutMs = 400) @@ -400,6 +408,33 @@ fun EventsScreen( // Sink houdt contentFocusRequester gebonden voor TopNav.down wanneer chips/rails weg zijn. Box(Modifier.fillMaxSize().background(McColors.Bg)) { + // Stadium-atmosphere: diepgroene glow bovenin. + Box( + Modifier + .fillMaxWidth() + .height(320.dp) + .background( + Brush.verticalGradient( + listOf( + Color(0xFF0C2E24), + Color(0xFF0A1C18), + Color(0x000B0B0B), + ), + ), + ), + ) + Box( + Modifier + .align(Alignment.TopEnd) + .padding(top = 40.dp, end = 80.dp) + .size(280.dp) + .alpha(0.35f) + .background( + Brush.radialGradient( + colors = listOf(EventsLook.NeonSoft, Color.Transparent), + ), + ), + ) if (needContentSink) { Box( Modifier @@ -428,38 +463,25 @@ fun EventsScreen( LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 48.dp), + contentPadding = PaddingValues(bottom = 56.dp), ) { - // Chips blijven altijd staan (ook bij lege filter) — anders unbindt - // contentFocusRequester en crasht TopNav/D-pad focusSearch. - if (showSportChips) { - item(key = "sports") { - SportChips( + // Eén filterrij (sport + bron) — altijd aanwezig bij data zodat focus stabiel blijft. + if (showFilterBar) { + item(key = "filters") { + EventsFilterBar( sports = sportChips, - selected = sportFilter, - onSelect = { sportFilter = it }, + sportSelected = sportFilter, + onSportSelect = { sportFilter = it }, + providers = providers, + showProviders = showProviderChips, + providerSelected = providerFilter, + onProviderSelect = { providerFilter = it }, + liveCount = live.size, + totalCount = filtered.size, firstFocusRequester = contentFocusRequester, focusUp = navFocusRequester, - focusDown = sportChipFocusDown, - onFocusReady = { sportChipsFocusReady = it }, - onFocus = onContentFocused, - ) - } - } - if (showProviderChips) { - item(key = "providers") { - ProviderChips( - providers = providers, - selected = providerFilter, - onSelect = { providerFilter = it }, - firstFocusRequester = if (showSportChips) { - providerRowFocus - } else { - contentFocusRequester - }, - focusUp = providerChipFocusUp, - focusDown = providerChipFocusDown, - onFocusReady = { providerChipsFocusReady = it }, + focusDown = filterFocusDown, + onFocusReady = { filterBarFocusReady = it }, onFocus = onContentFocused, ) } @@ -469,7 +491,7 @@ fun EventsScreen( Box( Modifier .fillMaxWidth() - .padding(top = 48.dp), + .padding(top = 56.dp), contentAlignment = Alignment.Center, ) { Text( @@ -486,9 +508,10 @@ fun EventsScreen( title = "Nu live", events = live, nowMs = nowMs, + style = EventRailStyle.Live, onOpen = onOpenEvent, onFocus = onContentFocused, - firstItemFocusRequester = if (showAnyChips) { + firstItemFocusRequester = if (showFilterBar) { firstRailFocus } else { contentFocusRequester @@ -517,11 +540,12 @@ fun EventsScreen( title = label, events = items, nowMs = nowMs, + style = EventRailStyle.Sport(bucket), onOpen = onOpenEvent, onFocus = onContentFocused, firstItemFocusRequester = when { !isFirstUpcoming -> null - showAnyChips -> firstRailFocus + showFilterBar -> firstRailFocus else -> contentFocusRequester }, restoreEventId = bindRestoreCardId.takeIf { railHasRestore }, @@ -550,11 +574,12 @@ fun EventsScreen( title = label, events = dayEvents, nowMs = nowMs, + style = EventRailStyle.Day, onOpen = onOpenEvent, onFocus = onContentFocused, firstItemFocusRequester = when { !isFirstUpcoming -> null - showAnyChips -> firstRailFocus + showFilterBar -> firstRailFocus else -> contentFocusRequester }, restoreEventId = bindRestoreCardId.takeIf { railHasRestore }, @@ -837,6 +862,7 @@ private fun EventRail( title: String, events: List, nowMs: Long, + style: EventRailStyle = EventRailStyle.Day, onOpen: (String) -> Unit, onFocus: () -> Unit, firstItemFocusRequester: FocusRequester? = null, @@ -857,19 +883,14 @@ private fun EventRail( runCatching { rowState.scrollToItem(restoreIndex) } } } - Column(Modifier.padding(top = 20.dp)) { - Text( - title, - color = McColors.Text, - fontSize = 22.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = HomeChrome.EdgeInset), - ) - Spacer(Modifier.height(12.dp)) + val featured = style is EventRailStyle.Live + Column(Modifier.padding(top = if (featured) 8.dp else 18.dp)) { + EventRailHeader(title = title, count = events.size, style = style) + Spacer(Modifier.height(if (featured) 14.dp else 12.dp)) LazyRow( state = rowState, contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset), - horizontalArrangement = Arrangement.spacedBy(14.dp), + horizontalArrangement = Arrangement.spacedBy(if (featured) 18.dp else 14.dp), ) { items(events, key = { it.id }) { ev -> val isFirst = ev.id == events.first().id @@ -894,36 +915,181 @@ private fun EventRail( EventCard( event = ev, nowMs = nowMs, + featured = featured, onOpen = { onOpen(ev.id) }, onFocus = onFocus, focusRequester = cardRequester, - // Alleen up-linken als target gezet is (caller gated op chipsFocusReady). focusUp = focusUpTo.takeIf { isFirst || isRestore }, + blockLeftExit = isFirst, ) } } } } -@OptIn(ExperimentalTvMaterial3Api::class) +private sealed class EventRailStyle { + data object Live : EventRailStyle() + data class Sport(val bucket: String) : EventRailStyle() + data object Day : EventRailStyle() +} + +@Composable +private fun EventRailHeader(title: String, count: Int, style: EventRailStyle) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = HomeChrome.EdgeInset), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + when (style) { + is EventRailStyle.Live -> { + PulsingLiveDot(size = 12.dp) + Text( + "NU LIVE", + color = EventsLook.Live, + fontSize = 20.sp, + fontWeight = FontWeight.Black, + letterSpacing = 1.6.sp, + ) + } + is EventRailStyle.Sport -> { + Box( + Modifier + .width(4.dp) + .height(22.dp) + .clip(RoundedCornerShape(2.dp)) + .background(EventsLook.Neon), + ) + Text( + "${sportEmoji(style.bucket)} $title", + color = McColors.Text, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + ) + } + is EventRailStyle.Day -> { + Box( + Modifier + .width(4.dp) + .height(22.dp) + .clip(RoundedCornerShape(2.dp)) + .background(EventsLook.Soon), + ) + Text( + title, + color = McColors.Text, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + ) + } + } + Box( + Modifier + .clip(RoundedCornerShape(999.dp)) + .background( + when (style) { + is EventRailStyle.Live -> EventsLook.LiveSoft + else -> EventsLook.NeonSoft + }, + ) + .padding(horizontal = 10.dp, vertical = 3.dp), + ) { + Text( + "$count", + color = when (style) { + is EventRailStyle.Live -> EventsLook.Live + else -> EventsLook.Neon + }, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ) + } + Spacer(Modifier.weight(1f)) + if (style is EventRailStyle.Live) { + Text( + "DIRECT KIJKBAAR", + color = McColors.TextDim, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.2.sp, + ) + } + } +} + +@Composable +private fun PulsingLiveDot(size: androidx.compose.ui.unit.Dp = 10.dp) { + val pulse = rememberInfiniteTransition(label = "live-pulse") + val alpha by pulse.animateFloat( + initialValue = 1f, + targetValue = 0.25f, + animationSpec = infiniteRepeatable( + animation = tween(750, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "live-alpha", + ) + val scale by pulse.animateFloat( + initialValue = 1f, + targetValue = 1.55f, + animationSpec = infiniteRepeatable( + animation = tween(750, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "live-scale", + ) + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size * 1.8f)) { + Box( + Modifier + .size(size * scale) + .alpha(alpha * 0.45f) + .clip(CircleShape) + .background(EventsLook.Live), + ) + Box( + Modifier + .size(size) + .clip(CircleShape) + .background(EventsLook.Live), + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable private fun EventCard( event: ScheduleEvent, nowMs: Long, + featured: Boolean = false, onOpen: () -> Unit, onFocus: () -> Unit, focusRequester: FocusRequester? = null, focusUp: FocusRequester? = null, + blockLeftExit: Boolean = false, ) { var focused by remember { mutableStateOf(false) } + val isLive = eventIsLive(event, nowMs) + val cardW = if (featured) 340.dp else 288.dp + val artH = if (featured) 192.dp else 162.dp + val focusGlow = when { + isLive -> EventsLook.Live + else -> EventsLook.Neon + } + val corner = 14.dp + // TV Border.inset > 0 tekent BUITEN de Surface (negatieve drawScope-inset). + val outlineInset = 4.dp Surface( onClick = onOpen, modifier = Modifier - .width(280.dp) + .width(cardW) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .then( - if (focusUp != null) { - Modifier.focusProperties { up = focusUp } + if (focusUp != null || blockLeftExit) { + Modifier.focusProperties { + focusUp?.let { up = it } + if (blockLeftExit) left = FocusRequester.Cancel + } } else { Modifier }, @@ -932,22 +1098,30 @@ private fun EventCard( focused = it.isFocused if (it.isFocused) onFocus() }, - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), + // Geen scale: die groeit over een buitenrand heen en trekt de lijn visueel naar binnen. + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), glow = ClickableSurfaceDefaults.glow( - focusedGlow = Glow(elevationColor = McColors.Accent, elevation = 8.dp), + focusedGlow = Glow(elevationColor = focusGlow, elevation = if (featured) 16.dp else 12.dp), + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.5.dp, focusGlow), + inset = outlineInset, + shape = RoundedCornerShape(corner + outlineInset), + ), ), colors = ClickableSurfaceDefaults.colors( - containerColor = McColors.Surface, - focusedContainerColor = McColors.Surface, + containerColor = EventsLook.Panel, + focusedContainerColor = EventsLook.PanelHot, ), - shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(corner)), ) { Column { Box( Modifier .fillMaxWidth() - .height(158.dp) - .background(Color(0xFF1A1A1A)), + .height(artH) + .background(Color(0xFF0E1614)), ) { val art = eventArtUrl(event) if (art != null) { @@ -963,151 +1137,334 @@ private fun EventCard( .fillMaxSize() .background( Brush.linearGradient( - listOf(Color(0xFF252530), Color(0xFF121218)), + listOf(Color(0xFF163028), Color(0xFF0A1210)), ), ), contentAlignment = Alignment.Center, ) { Text( - prettyProvider(event.provider), - color = McColors.TextDim, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + sportEmoji(event.sportBucket), + fontSize = 36.sp, ) } } + // Provider / channel logo badge (fallback covers stay branded) + event.logoUrl?.takeIf { it.isNotBlank() }?.let { logo -> + AsyncImage( + model = logo, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .height(28.dp) + .widthIn(max = 88.dp) + .background(Color(0x99000000), RoundedCornerShape(6.dp)) + .padding(horizontal = 6.dp, vertical = 4.dp), + ) + } + // Cinematic bottom scrim + Box( + Modifier + .fillMaxWidth() + .height(72.dp) + .align(Alignment.BottomCenter) + .background( + Brush.verticalGradient( + listOf(Color.Transparent, EventsLook.Scrim), + ), + ), + ) + // Top accent strip for live + if (isLive) { + Box( + Modifier + .fillMaxWidth() + .height(3.dp) + .align(Alignment.TopCenter) + .background( + Brush.horizontalGradient( + listOf(EventsLook.Live, EventsLook.Neon, EventsLook.Live), + ), + ), + ) + } Box( Modifier .align(Alignment.TopStart) - .padding(8.dp), + .padding(10.dp), ) { StatusBadge(event, nowMs, compact = true) } + Box( + Modifier + .align(Alignment.BottomStart) + .padding(10.dp), + ) { + Text( + shortTimeLabel(event, nowMs), + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ) + } + if (featured && isLive) { + Box( + Modifier + .align(Alignment.BottomEnd) + .padding(10.dp) + .clip(RoundedCornerShape(6.dp)) + .background(EventsLook.Live) + .padding(horizontal = 10.dp, vertical = 5.dp), + ) { + Text( + "KIJK NU", + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Black, + letterSpacing = 0.8.sp, + ) + } + } } - Column(Modifier.padding(12.dp)) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 11.dp)) { Text( event.name, color = if (focused) Color.White else McColors.Text, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, + fontSize = if (featured) 16.sp else 15.sp, + fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis, - lineHeight = 18.sp, - modifier = Modifier.height(40.dp), - ) - Spacer(Modifier.height(4.dp)) - Text( - listOfNotNull( - prettyProvider(event.provider), - displayChannel(event), - shortTimeLabel(event, nowMs), - ).distinct().joinToString(" · "), - color = McColors.TextDim, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + lineHeight = 19.sp, + modifier = Modifier.height(42.dp), ) + Spacer(Modifier.height(6.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(EventsLook.NeonSoft) + .padding(horizontal = 7.dp, vertical = 2.dp), + ) { + Text( + sportLabelForBucket(event.sportBucket).uppercase(), + color = EventsLook.Neon, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.6.sp, + maxLines = 1, + ) + } + Text( + listOfNotNull( + prettyProvider(event.provider), + displayChannel(event), + ).distinct().joinToString(" · "), + color = McColors.TextDim, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } } } } } @Composable -private fun SportChips( +private fun EventsFilterBar( sports: List>, - selected: String?, - onSelect: (String?) -> Unit, - firstFocusRequester: FocusRequester? = null, - focusUp: FocusRequester? = null, - focusDown: FocusRequester? = null, - onFocusReady: ((Boolean) -> Unit)? = null, - onFocus: () -> Unit = {}, -) { - LazyRow( - contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 4.dp), - ) { - item { - Chip( - label = "Alles", - active = selected == null, - focusRequester = firstFocusRequester, - focusUp = focusUp, - focusDown = focusDown, - onFocusReady = onFocusReady, - onFocus = onFocus, - onClick = { onSelect(null) }, - ) - } - items(sports, key = { it.first }) { (bucket, label) -> - Chip( - label = label, - active = selected == bucket, - focusUp = focusUp, - focusDown = focusDown, - onFocus = onFocus, - onClick = { onSelect(bucket) }, - ) - } - } -} - -@Composable -private fun ProviderChips( + sportSelected: String?, + onSportSelect: (String?) -> Unit, providers: List, - selected: String?, - onSelect: (String?) -> Unit, + showProviders: Boolean, + providerSelected: String?, + onProviderSelect: (String?) -> Unit, + liveCount: Int, + totalCount: Int, firstFocusRequester: FocusRequester? = null, focusUp: FocusRequester? = null, focusDown: FocusRequester? = null, onFocusReady: ((Boolean) -> Unit)? = null, onFocus: () -> Unit = {}, ) { - // Mirror TopNav subItems (Films/Series): EdgeInset + vertical 4.dp, bottom 8.dp. - LazyRow( - contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier + Column( + Modifier .fillMaxWidth() - .padding(bottom = 8.dp), + .padding(top = 4.dp, bottom = 6.dp), ) { - item { - Chip( - label = "Alles", - active = selected == null, - focusRequester = firstFocusRequester, - focusUp = focusUp, - focusDown = focusDown, - onFocusReady = onFocusReady, - onFocus = onFocus, - onClick = { onSelect(null) }, - ) + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = HomeChrome.EdgeInset), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text( + "EVENTS", + color = EventsLook.Neon, + fontSize = 11.sp, + fontWeight = FontWeight.Black, + letterSpacing = 2.4.sp, + ) + Text( + "Sportagenda", + color = Color.White, + fontSize = 26.sp, + fontWeight = FontWeight.Black, + letterSpacing = (-0.4).sp, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (liveCount > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .background(EventsLook.LiveSoft) + .border(1.dp, EventsLook.Live.copy(alpha = 0.55f), RoundedCornerShape(999.dp)) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + PulsingLiveDot(size = 7.dp) + Text( + "$liveCount LIVE", + color = EventsLook.Live, + fontSize = 12.sp, + fontWeight = FontWeight.Black, + letterSpacing = 0.8.sp, + ) + } + } + Text( + "$totalCount events", + color = McColors.TextDim, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + ) + } } - items(providers) { p -> - Chip( - label = prettyProvider(p), - active = selected.equals(p, ignoreCase = true), - focusUp = focusUp, - focusDown = focusDown, - onFocus = onFocus, - onClick = { onSelect(p) }, - ) + Spacer(Modifier.height(14.dp)) + // Sticky entry-tab BUITEN LazyRow: contentFocusRequester mag nooit unbinden + // als de rij naar rechts scrollt (anders landt TopNav.down op de onzichtbare sink). + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = HomeChrome.EdgeInset), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + val stickyIsSport = sports.isNotEmpty() + if (stickyIsSport) { + SportTab( + emoji = "✦", + label = "Alles", + active = sportSelected == null, + focusRequester = firstFocusRequester, + focusUp = focusUp, + focusDown = focusDown, + blockLeftExit = true, + onFocusReady = onFocusReady, + onFocus = onFocus, + onClick = { onSportSelect(null) }, + ) + } else if (showProviders) { + ProviderTab( + label = "Alle", + active = providerSelected == null, + focusRequester = firstFocusRequester, + focusUp = focusUp, + focusDown = focusDown, + blockLeftExit = true, + onFocusReady = onFocusReady, + onFocus = onFocus, + onClick = { onProviderSelect(null) }, + ) + } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.weight(1f), + ) { + if (stickyIsSport) { + items(sports, key = { it.first }) { (bucket, label) -> + SportTab( + emoji = sportEmoji(bucket), + label = label, + active = sportSelected == bucket, + focusUp = focusUp, + focusDown = focusDown, + onFocus = onFocus, + onClick = { onSportSelect(bucket) }, + ) + } + } + if (showProviders) { + item { + Box( + Modifier + .padding(horizontal = 6.dp) + .width(1.dp) + .height(28.dp) + .background(Color(0x33FFFFFF)), + ) + } + item { + Text( + "BRON", + color = McColors.TextDim, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.4.sp, + modifier = Modifier.padding(end = 2.dp), + ) + } + if (stickyIsSport) { + item { + ProviderTab( + label = "Alle", + active = providerSelected == null, + focusUp = focusUp, + focusDown = focusDown, + onFocus = onFocus, + onClick = { onProviderSelect(null) }, + ) + } + } + items(providers) { p -> + ProviderTab( + label = prettyProvider(p), + active = providerSelected.equals(p, ignoreCase = true), + focusUp = focusUp, + focusDown = focusDown, + onFocus = onFocus, + onClick = { onProviderSelect(p) }, + ) + } + } + } } } } -@OptIn(ExperimentalTvMaterial3Api::class) +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable -private fun Chip( +private fun SportTab( + emoji: String, label: String, active: Boolean, onClick: () -> Unit, focusRequester: FocusRequester? = null, focusUp: FocusRequester? = null, focusDown: FocusRequester? = null, + blockLeftExit: Boolean = false, onFocusReady: ((Boolean) -> Unit)? = null, onFocus: (() -> Unit)? = null, ) { @@ -1117,15 +1474,90 @@ private fun Chip( onDispose { onFocusReady?.invoke(false) } } } - // Films/Home-patroon: down alleen zetten als caller een veilige target gaf - // (gebonden rail, of FocusRequester.Cancel bij lege filter). Null = spatial nav. + var focused by remember { mutableStateOf(false) } val mod = Modifier .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .then( - if (focusUp != null || focusDown != null) { + if (focusUp != null || focusDown != null || blockLeftExit) { Modifier.focusProperties { focusUp?.let { up = it } focusDown?.let { down = it } + if (blockLeftExit) left = FocusRequester.Cancel + } + } else { + Modifier + }, + ) + .onFocusChanged { + focused = it.isFocused + if (it.isFocused) onFocus?.invoke() + } + Surface( + onClick = onClick, + modifier = mod, + colors = ClickableSurfaceDefaults.colors( + containerColor = when { + active -> EventsLook.Neon + else -> Color(0x22FFFFFF) + }, + focusedContainerColor = when { + active -> EventsLook.Neon + else -> Color.White + }, + focusedContentColor = Color.Black, + contentColor = if (active) Color(0xFF042018) else Color.White, + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow( + elevationColor = EventsLook.Neon, + elevation = if (focused || active) 10.dp else 0.dp, + ), + ), + ) { + Row( + Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(emoji, fontSize = 15.sp) + Text( + label, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun ProviderTab( + label: String, + active: Boolean, + onClick: () -> Unit, + focusRequester: FocusRequester? = null, + focusUp: FocusRequester? = null, + focusDown: FocusRequester? = null, + blockLeftExit: Boolean = false, + onFocusReady: ((Boolean) -> Unit)? = null, + onFocus: (() -> 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 || blockLeftExit) { + Modifier.focusProperties { + focusUp?.let { up = it } + focusDown?.let { down = it } + if (blockLeftExit) left = FocusRequester.Cancel } } else { Modifier @@ -1134,48 +1566,77 @@ private fun Chip( .onFocusChanged { if (it.isFocused) onFocus?.invoke() } + .then( + if (active) { + Modifier.border(1.5.dp, EventsLook.Neon, RoundedCornerShape(999.dp)) + } else { + Modifier + }, + ) Surface( onClick = onClick, modifier = mod, colors = ClickableSurfaceDefaults.colors( - containerColor = if (active) McColors.Accent else McColors.Surface, - focusedContainerColor = if (active) McColors.Accent else McColors.SurfaceFocus, - focusedContentColor = if (active) Color.White else Color.Black, - contentColor = Color.White, + containerColor = if (active) EventsLook.NeonSoft else Color(0x14FFFFFF), + focusedContainerColor = Color.White, + focusedContentColor = Color.Black, + contentColor = if (active) EventsLook.Neon else McColors.TextDim, ), - shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(20.dp)), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), ) { Text( label, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, ) } } @Composable private fun StatusBadge(event: ScheduleEvent, nowMs: Long, compact: Boolean = false) { + val live = eventIsLive(event, nowMs) val (label, bg) = statusVisual(event, nowMs) - Box( + Row( Modifier - .clip(RoundedCornerShape(4.dp)) + .clip(RoundedCornerShape(6.dp)) .background(bg) .padding( horizontal = if (compact) 8.dp else 10.dp, - vertical = if (compact) 3.dp else 5.dp, + vertical = if (compact) 4.dp else 5.dp, ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), ) { + if (live) { + PulsingLiveDot(size = 6.dp) + } Text( label, color = Color.White, fontSize = if (compact) 11.sp else 13.sp, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Black, + letterSpacing = if (live) 0.8.sp else 0.sp, ) } } +private fun sportEmoji(bucket: String): String = when (bucket) { + "football" -> "⚽" + "racing" -> "🏎" + "tennis" -> "🎾" + "golf" -> "⛳" + "darts" -> "🎯" + "snooker" -> "🎱" + "basketball" -> "🏀" + "waterpolo" -> "🤽" + "sailing" -> "⛵" + "hockey" -> "🏒" + "cycling" -> "🚴" + else -> "📺" +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun McDetailButton( @@ -1270,12 +1731,12 @@ private fun McDetailButton( private fun statusVisual(event: ScheduleEvent, nowMs: Long): Pair { return when { - eventIsLive(event, nowMs) -> "LIVE" to McColors.Accent + eventIsLive(event, nowMs) -> "LIVE" to EventsLook.Live minutesUntilStart(event, nowMs)?.let { it in 0 until 60 } == true -> { val m = minutesUntilStart(event, nowMs)!! - "Straks · over ${m} min" to Color(0xFFE67E22) + "Straks · over ${m} min" to EventsLook.Soon } - else -> "Gepland" to Color(0xFF3A3A3A) + else -> "Gepland" to Color(0xFF2A3532) } } diff --git a/apps/ios/VMC/UI/EventsScreen.swift b/apps/ios/VMC/UI/EventsScreen.swift index 93639bf..754b380 100644 --- a/apps/ios/VMC/UI/EventsScreen.swift +++ b/apps/ios/VMC/UI/EventsScreen.swift @@ -439,6 +439,18 @@ private struct EventShelfCard: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } + if let logo = event.logoUrl?.nilIfBlank { + HStack { + Spacer(minLength: 0) + TitleLogo(url: logo, maxWidth: 72, maxHeight: 22) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(Color.black.opacity(0.55), in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .padding(8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + } + LinearGradient( colors: [ .clear, @@ -539,6 +551,8 @@ struct EventDetailScreen: View { @State private var playInfo: IptvPlayInfo? @State private var playError: String? @State private var pulse = false + @State private var appeared = false + @State private var playing = false private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect() @@ -547,10 +561,18 @@ struct EventDetailScreen: View { if let event { detailBody(event) } else if error != nil { - VStack(spacing: 12) { - Text(error ?? "Fout").foregroundStyle(McColors.danger) - Button("Terug") { dismiss() }.buttonStyle(McPrimaryButtonStyle()) + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 28)) + .foregroundStyle(EventsLook.soon) + Text(error ?? "Fout") + .font(McFonts.body) + .foregroundStyle(Color.white.opacity(0.8)) + .multilineTextAlignment(.center) + Button("Terug") { dismiss() } + .buttonStyle(McPrimaryButtonStyle()) } + .padding(24) } else { ProgressView("Laden…") .tint(EventsLook.neon) @@ -565,6 +587,9 @@ struct EventDetailScreen: View { withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) { pulse = true } + withAnimation(.spring(response: 0.55, dampingFraction: 0.86)) { + appeared = true + } } .onReceive(clock) { date in now = date @@ -584,7 +609,8 @@ struct EventDetailScreen: View { drm: item.info.drm, watchSessionId: item.info.watchSessionId, watchKind: "event", - watchId: eventId + watchId: eventId, + logoUrl: item.info.logoUrl ?? event?.logoUrl )) .environmentObject(app) } @@ -600,160 +626,319 @@ struct EventDetailScreen: View { } private func detailBody(_ ev: ScheduleEvent) -> some View { - VStack(spacing: 0) { - ScrollView { + GeometryReader { geo in + let w = geo.size.width + let heroH = min(geo.size.height * 0.48, 380) + + ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - hero(ev) - VStack(alignment: .leading, spacing: 12) { - Text(ev.name) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .foregroundStyle(.white) - .lineLimit(3) + cinematicHero(ev, width: w, height: heroH) - HStack(spacing: 8) { - Text(ev.resolvedSportLabel) - .font(.system(size: 12, weight: .bold, design: .rounded)) - .foregroundStyle(EventsLook.deep) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(EventsLook.neon, in: Capsule()) - EventStatusBadge(event: ev, now: now, pulse: pulse) - } - - let meta = EventHelpers.detailMeta(ev) - if !meta.isEmpty { - Text(meta) - .font(McFonts.body) - .foregroundStyle(Color.white.opacity(0.72)) - .lineLimit(2) - } - - let window = EventHelpers.detailTimeWindow(ev, now: now) - if !window.isEmpty { - Text(window) - .font(.system(size: 16, weight: .semibold, design: .rounded)) - .foregroundStyle(.white) - } - - if let subtitle = ev.subtitle, !subtitle.isEmpty, - subtitle.caseInsensitiveCompare(ev.name) != .orderedSame { - Text(subtitle) - .font(McFonts.body) - .foregroundStyle(Color.white.opacity(0.5)) - .lineLimit(3) - } - - if let description = ev.description, !description.isEmpty { - Text(description) - .font(McFonts.body) - .foregroundStyle(Color.white.opacity(0.88)) - .lineSpacing(4) - } - - if let langs = ev.audioLanguages, !langs.isEmpty { - Text("Audio") - .font(McFonts.caption) - .foregroundStyle(Color.white.opacity(0.4)) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(langs.prefix(8), id: \.self) { lang in - Text(lang.uppercased()) - .font(McFonts.caption) - .foregroundStyle(Color.white.opacity(0.86)) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(EventsLook.panelHot, in: RoundedRectangle(cornerRadius: 6)) - } - } - } - } + VStack(alignment: .leading, spacing: 18) { + metaStrip(ev) + timeBlock(ev) + descriptionBlock(ev) + audioBlock(ev) } - .padding(.horizontal, 16) - .padding(.vertical, 16) + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 24) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 18) } } - - stickyBar(ev) + .safeAreaInset(edge: .top, spacing: 0) { + detailTopBar(ev) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + bottomCTA(ev, bottomInset: max(geo.safeAreaInsets.bottom, 8)) + } + .frame(width: w, height: geo.size.height) } } - private func hero(_ ev: ScheduleEvent) -> some View { - ZStack(alignment: .bottomLeading) { - RemoteImage(url: EventHelpers.backdropUrl(ev), contentMode: .fill) - .frame(height: 280) - .frame(maxWidth: .infinity) - .clipped() - .overlay { - LinearGradient( - stops: [ - .init(color: .black.opacity(0.25), location: 0), - .init(color: .clear, location: 0.4), - .init(color: EventsLook.deep.opacity(0.98), location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - } - .overlay(alignment: .top) { - if EventHelpers.isLive(ev, now: now) { - LinearGradient( - colors: [EventsLook.live.opacity(0.5), .clear], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 48) - } - } - - HStack { - Button { dismiss() } label: { + private func detailTopBar(_ ev: ScheduleEvent) -> some View { + HStack(spacing: 12) { + Button { + dismiss() + } label: { + HStack(spacing: 6) { Image(systemName: "chevron.left") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.white) - .padding(10) - .background(.black.opacity(0.45), in: Circle()) - } - Spacer() - if let logo = ev.logoUrl, !logo.isEmpty { - RemoteImage(url: logo, contentMode: .fit, opaqueBackground: false) - .frame(width: 96, height: 36) + .font(.system(size: 14, weight: .bold)) + Text("Events") + .font(.system(size: 15, weight: .semibold, design: .rounded)) } + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().stroke(Color.white.opacity(0.18), lineWidth: 1)) } - .padding(14) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } - .frame(height: 280) - } + .buttonStyle(.plain) + .accessibilityLabel("Terug naar Events") - private func stickyBar(_ ev: ScheduleEvent) -> some View { - HStack(spacing: 10) { - if ev.isPlayable { - Button { - Task { await play(ev) } - } label: { - Label("Kijk live", systemImage: "play.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(EventsLiveButtonStyle()) + Spacer(minLength: 0) - Button("Terug") { dismiss() } - .buttonStyle(McSecondaryButtonStyle()) - } else { - Button {} label: { - Text(EventHelpers.ctaLabel(ev, now: now)) - .frame(maxWidth: .infinity) - } - .buttonStyle(McSecondaryButtonStyle()) - .disabled(true) - .opacity(0.55) - - Button("Terug") { dismiss() } - .buttonStyle(McPrimaryButtonStyle()) + if let provider = optionalProvider(ev) { + Text(provider) + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.9)) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(.black.opacity(0.35), in: Capsule()) + .overlay(Capsule().stroke(Color.white.opacity(0.14), lineWidth: 1)) } } .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(EventsLook.panel.opacity(0.96).ignoresSafeArea(edges: .bottom)) + .padding(.top, 6) + .padding(.bottom, 8) + .background( + LinearGradient( + colors: [EventsLook.deep.opacity(0.92), EventsLook.deep.opacity(0.55), .clear], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea(edges: .top) + .allowsHitTesting(false) + ) + } + + private func cinematicHero(_ ev: ScheduleEvent, width: CGFloat, height: CGFloat) -> some View { + ZStack(alignment: .bottomLeading) { + RemoteImage(url: EventHelpers.backdropUrl(ev), contentMode: .fill) + .frame(width: width, height: height) + .clipped() + .scaleEffect(appeared ? 1.0 : 1.08) + .animation(.easeOut(duration: 0.9), value: appeared) + + // Side vignette + bottom fade into content + LinearGradient( + stops: [ + .init(color: .black.opacity(0.35), location: 0), + .init(color: .clear, location: 0.28), + .init(color: EventsLook.deep.opacity(0.55), location: 0.72), + .init(color: EventsLook.deep, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(width: width, height: height) + + LinearGradient( + colors: [EventsLook.deep.opacity(0.55), .clear], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: width * 0.45, height: height) + .frame(maxWidth: .infinity, alignment: .leading) + + if EventHelpers.isLive(ev, now: now) { + LinearGradient( + colors: [EventsLook.live.opacity(0.45), .clear], + startPoint: .top, + endPoint: .bottom + ) + .frame(width: width, height: 90) + .frame(maxHeight: .infinity, alignment: .top) + .blendMode(.plusLighter) + } + + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + EventStatusBadge(event: ev, now: now, pulse: pulse) + Text(ev.resolvedSportLabel.uppercased()) + .font(.system(size: 11, weight: .heavy, design: .rounded)) + .tracking(1.1) + .foregroundStyle(EventsLook.deep) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(EventsLook.neon, in: Capsule()) + .shadow(color: EventsLook.neon.opacity(0.35), radius: 8, y: 2) + } + + if let logo = ev.logoUrl, !logo.isEmpty { + TitleLogo(url: logo, maxWidth: min(width - 48, 240), maxHeight: 56) + } else { + Text(ev.name) + .font(.system(size: 30, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.55), radius: 12, y: 4) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + + if let logo = ev.logoUrl, !logo.isEmpty { + Text(ev.name) + .font(.system(size: 17, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.88)) + .lineLimit(2) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 22) + .frame(width: width, alignment: .leading) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 24) + } + .frame(width: width, height: height) + .clipped() + } + + private func bottomCTA(_ ev: ScheduleEvent, bottomInset: CGFloat) -> some View { + VStack(spacing: 0) { + LinearGradient( + colors: [EventsLook.deep.opacity(0), EventsLook.deep.opacity(0.92), EventsLook.deep], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 28) + .allowsHitTesting(false) + + Group { + if ev.isPlayable { + Button { + Task { await play(ev) } + } label: { + HStack(spacing: 10) { + if playing { + ProgressView().tint(EventsLook.deep) + } else { + Image(systemName: "play.fill") + .font(.system(size: 16, weight: .bold)) + } + Text(EventHelpers.isLive(ev, now: now) ? "Kijk live" : "Nu kijken") + .font(.system(size: 17, weight: .bold, design: .rounded)) + } + .foregroundStyle(EventsLook.deep) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + LinearGradient( + colors: [EventsLook.neon, EventsLook.neon.opacity(0.82)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: RoundedRectangle(cornerRadius: 16, style: .continuous) + ) + .shadow(color: EventsLook.neon.opacity(0.45), radius: 18, y: 6) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.white.opacity(0.22), lineWidth: 1) + ) + } + .disabled(playing) + .scaleEffect(appeared ? 1 : 0.94) + .opacity(appeared ? 1 : 0) + } else { + Text(EventHelpers.ctaLabel(ev, now: now)) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.7)) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(EventsLook.panelHot, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + } + } + .padding(.horizontal, 20) + .padding(.bottom, max(bottomInset, 10)) + .background(EventsLook.deep) + } + } + + private func metaStrip(_ ev: ScheduleEvent) -> some View { + let meta = EventHelpers.detailMeta(ev) + return Group { + if !meta.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(meta.split(separator: "·").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }, id: \.self) { part in + Text(part) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.85)) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(EventsLook.panelHot, in: Capsule()) + .overlay(Capsule().stroke(Color.white.opacity(0.08), lineWidth: 1)) + } + } + } + } + } + } + + private func timeBlock(_ ev: ScheduleEvent) -> some View { + let window = EventHelpers.detailTimeWindow(ev, now: now) + return Group { + if !window.isEmpty { + HStack(spacing: 12) { + Image(systemName: EventHelpers.isLive(ev, now: now) ? "dot.radiowaves.left.and.right" : "calendar") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(EventHelpers.isLive(ev, now: now) ? EventsLook.live : EventsLook.neon) + .frame(width: 36, height: 36) + .background( + (EventHelpers.isLive(ev, now: now) ? EventsLook.liveSoft : EventsLook.neonSoft), + in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + VStack(alignment: .leading, spacing: 2) { + Text(EventHelpers.isLive(ev, now: now) ? "Nu bezig" : "Speeltijd") + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.45)) + Text(window) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + } + Spacer(minLength: 0) + } + .padding(14) + .background(EventsLook.panel.opacity(0.9), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.white.opacity(0.06), lineWidth: 1) + ) + } + } + } + + private func descriptionBlock(_ ev: ScheduleEvent) -> some View { + VStack(alignment: .leading, spacing: 10) { + if let subtitle = ev.subtitle, !subtitle.isEmpty, + subtitle.caseInsensitiveCompare(ev.name) != .orderedSame { + Text(subtitle) + .font(.system(size: 15, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(3) + } + if let description = ev.description, !description.isEmpty { + Text("Over dit event") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundStyle(EventsLook.neon.opacity(0.9)) + Text(description) + .font(McFonts.body) + .foregroundStyle(.white.opacity(0.88)) + .lineSpacing(5) + } + } + } + + private func audioBlock(_ ev: ScheduleEvent) -> some View { + Group { + if let langs = ev.audioLanguages, !langs.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text("Audio") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.4)) + FlowChips(items: langs.prefix(8).map { $0.uppercased() }) + } + } + } + } + + private func optionalProvider(_ ev: ScheduleEvent) -> String? { + let p = ev.provider.trimmingCharacters(in: .whitespacesAndNewlines) + return p.isEmpty ? nil : p.uppercased() } private func load() async { @@ -774,6 +959,8 @@ struct EventDetailScreen: View { } private func play(_ ev: ScheduleEvent) async { + playing = true + defer { playing = false } do { playInfo = try await app.api.scheduleEventPlay(eventId: ev.id) } catch { @@ -782,24 +969,49 @@ struct EventDetailScreen: View { } } -private struct EventsLiveButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .font(McFonts.headline) - .foregroundStyle(EventsLook.deep) - .padding(.horizontal, 22) - .padding(.vertical, 13) - .background( - LinearGradient( - colors: [EventsLook.neon, EventsLook.neon.opacity(0.82)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - .opacity(configuration.isPressed ? 0.8 : 1), - in: Capsule() - ) - .shadow(color: EventsLook.neon.opacity(0.35), radius: configuration.isPressed ? 2 : 10, y: 4) - .scaleEffect(configuration.isPressed ? 0.98 : 1) +private struct FlowChips: View { + let items: [String] + + var body: some View { + FlexibleChipRow(items: items) + } +} + +/// Eenvoudige wrapping chip-rij zonder externe dependency. +private struct FlexibleChipRow: View { + let items: [String] + + private var rows: [[String]] { + // Simpele wrap: max ~3 per rij op smalle schermen + var result: [[String]] = [] + var current: [String] = [] + for item in items { + current.append(item) + if current.count >= 3 { + result.append(current) + current = [] + } + } + if !current.isEmpty { result.append(current) } + return result + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + HStack(spacing: 8) { + ForEach(row, id: \.self) { item in + Text(item) + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.9)) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(EventsLook.panelHot, in: Capsule()) + .overlay(Capsule().stroke(EventsLook.neon.opacity(0.2), lineWidth: 1)) + } + } + } + } } } @@ -1007,7 +1219,7 @@ enum EventHelpers { var donors: [String: ScheduleEvent] = [:] for e in events { if e.imageLandscape == nil && (e.images ?? []).isEmpty && e.logoUrl == nil { continue } - for key in [e.channel, e.network].compactMap({ normalizeChannel($0) }) { + for key in donorKeys(for: e) { if let existing = donors[key], donorScore(existing) >= donorScore(e) { continue } donors[key] = e } @@ -1029,7 +1241,7 @@ enum EventHelpers { var built: [String: ScheduleEvent] = [:] for sib in siblings { if sib.imageLandscape == nil && (sib.images ?? []).isEmpty { continue } - for key in [sib.channel, sib.network].compactMap({ normalizeChannel($0) }) { + for key in donorKeys(for: sib) { if let existing = built[key], donorScore(existing) >= donorScore(sib) { continue } built[key] = sib } @@ -1037,8 +1249,10 @@ enum EventHelpers { return built }() var donor: ScheduleEvent? - for key in [e.channel, e.network].compactMap({ normalizeChannel($0) }) { - if let hit = map[key], hit.id != e.id { + for key in donorKeys(for: e) { + if let hit = map[key], hit.id != e.id, + hit.resolvedSportBucket == e.resolvedSportBucket + { donor = hit break } @@ -1057,6 +1271,16 @@ enum EventHelpers { ) } + /// Donor-key = sport + kanaal — voorkomt voetbal-art op Racing (zelfde Ziggo-kanaal). + private static func donorKeys(for e: ScheduleEvent) -> [String] { + let sport = e.resolvedSportBucket + var keys: [String] = [] + for ch in [e.channel, e.network].compactMap({ normalizeChannel($0) }) { + keys.append("\(sport)|\(ch)") + } + return keys + } + private static func donorScore(_ e: ScheduleEvent) -> Int { var s = 0 if e.imageLandscape != nil { s += 20 } diff --git a/apps/master-api/assets/event-fallbacks/basketball.jpg b/apps/master-api/assets/event-fallbacks/basketball.jpg new file mode 100644 index 0000000..452134f Binary files /dev/null and b/apps/master-api/assets/event-fallbacks/basketball.jpg differ diff --git a/apps/master-api/assets/event-fallbacks/cycling.jpg b/apps/master-api/assets/event-fallbacks/cycling.jpg new file mode 100644 index 0000000..e061fd4 Binary files /dev/null and b/apps/master-api/assets/event-fallbacks/cycling.jpg differ diff --git a/apps/master-api/assets/event-fallbacks/darts.jpg b/apps/master-api/assets/event-fallbacks/darts.jpg new file mode 100644 index 0000000..80b74bc Binary files /dev/null and b/apps/master-api/assets/event-fallbacks/darts.jpg differ diff --git a/apps/master-api/assets/event-fallbacks/football.jpg b/apps/master-api/assets/event-fallbacks/football.jpg new file mode 100644 index 0000000..88a3ac8 Binary files /dev/null and b/apps/master-api/assets/event-fallbacks/football.jpg differ diff --git a/apps/master-api/assets/event-fallbacks/golf.jpg b/apps/master-api/assets/event-fallbacks/golf.jpg new file mode 100644 index 0000000..2fdd8e3 Binary files /dev/null and b/apps/master-api/assets/event-fallbacks/golf.jpg differ diff --git a/apps/master-api/assets/event-fallbacks/hockey.jpg b/apps/master-api/assets/event-fallbacks/hockey.jpg new file mode 100644 index 0000000..14ab0cf Binary files /dev/null and b/apps/master-api/assets/event-fallbacks/hockey.jpg differ diff --git a/apps/master-api/assets/event-fallbacks/other.jpg b/apps/master-api/assets/event-fallbacks/other.jpg new file mode 100644 index 0000000..094ac49 Binary files /dev/null and b/apps/master-api/assets/event-fallbacks/other.jpg differ diff --git a/apps/master-api/assets/event-fallbacks/racing.jpg b/apps/master-api/assets/event-fallbacks/racing.jpg new file mode 100644 index 0000000..9d0b4db Binary files /dev/null and b/apps/master-api/assets/event-fallbacks/racing.jpg differ diff --git a/apps/master-api/assets/event-fallbacks/sailing.jpg b/apps/master-api/assets/event-fallbacks/sailing.jpg new file mode 100644 index 0000000..3235745 Binary files /dev/null and b/apps/master-api/assets/event-fallbacks/sailing.jpg differ diff --git a/apps/master-api/assets/event-fallbacks/snooker.jpg b/apps/master-api/assets/event-fallbacks/snooker.jpg new file mode 100644 index 0000000..c3027c3 Binary files /dev/null and b/apps/master-api/assets/event-fallbacks/snooker.jpg differ diff --git a/apps/master-api/assets/event-fallbacks/tennis.jpg b/apps/master-api/assets/event-fallbacks/tennis.jpg new file mode 100644 index 0000000..57ba212 Binary files /dev/null and b/apps/master-api/assets/event-fallbacks/tennis.jpg differ diff --git a/apps/master-api/assets/event-fallbacks/waterpolo.jpg b/apps/master-api/assets/event-fallbacks/waterpolo.jpg new file mode 100644 index 0000000..eae84f2 Binary files /dev/null and b/apps/master-api/assets/event-fallbacks/waterpolo.jpg differ diff --git a/apps/master-api/package.json b/apps/master-api/package.json index d30008b..bdb6b0b 100644 --- a/apps/master-api/package.json +++ b/apps/master-api/package.json @@ -6,7 +6,7 @@ "dev": "tsx watch src/app.ts", "build": "tsc", "start": "node dist/app.js", - "test": "tsx src/metadata/filename-parser.test.ts && tsx src/metadata/title-locale.test.ts && tsx src/google-play/service.test.ts && tsx src/viewer/iptv-categories.test.ts && tsx src/viewer/schedule-events-sport.test.ts && tsx src/viewer/ziggo-epg-art.test.ts && tsx src/viewer/espn-watch-art.test.ts", + "test": "tsx src/metadata/filename-parser.test.ts && tsx src/metadata/title-locale.test.ts && tsx src/google-play/service.test.ts && tsx src/viewer/iptv-categories.test.ts && tsx src/viewer/schedule-events-sport.test.ts && tsx src/viewer/ziggo-epg-art.test.ts && tsx src/viewer/espn-watch-art.test.ts && tsx src/viewer/event-fallbacks.test.ts", "db:generate": "prisma generate", "db:migrate": "prisma migrate deploy", "db:migrate:dev": "prisma migrate dev", diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index e1f8e21..c5c8c45 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -55,6 +55,25 @@ async function main() { }, }); + // Category event-art fallbacks (public, cacheable) + const { createReadStream, existsSync } = await import("fs"); + const pathMod = await import("path"); + const fallbacksDir = [ + pathMod.join(process.cwd(), "assets", "event-fallbacks"), + pathMod.join(__dirname, "..", "assets", "event-fallbacks"), + pathMod.join(__dirname, "assets", "event-fallbacks"), + ].find((d) => existsSync(d)); + app.get<{ Params: { file: string } }>("/assets/event-fallbacks/:file", async (request, reply) => { + const file = request.params.file || ""; + if (!/^[a-z]+\.jpg$/.test(file) || !fallbacksDir) { + return reply.code(404).send({ error: "NOT_FOUND" }); + } + const full = pathMod.join(fallbacksDir, file); + if (!existsSync(full)) return reply.code(404).send({ error: "NOT_FOUND" }); + reply.header("Cache-Control", "public, max-age=86400, immutable"); + return reply.type("image/jpeg").send(createReadStream(full)); + }); + app.addHook("onSend", async (request, reply, payload) => { if (!request.url.startsWith("/stremio/")) { reply.header("X-Content-Type-Options", "nosniff"); diff --git a/apps/master-api/src/viewer/event-fallbacks.test.ts b/apps/master-api/src/viewer/event-fallbacks.test.ts new file mode 100644 index 0000000..b8fb010 --- /dev/null +++ b/apps/master-api/src/viewer/event-fallbacks.test.ts @@ -0,0 +1,60 @@ +import { + applyCategoryFallbacks, + eventFallbackUrl, + fallbackFilenameForSport, + isCategoryFallbackUrl, + needsCategoryFallback, +} from "./event-fallbacks"; + +function assert(cond: unknown, msg: string) { + if (!cond) throw new Error(msg); +} + +assert(fallbackFilenameForSport("racing") === "racing.jpg", "racing file"); +assert(fallbackFilenameForSport("nope") === "other.jpg", "other file"); +assert( + eventFallbackUrl("https://master.vonas.nl", "racing") === + "https://master.vonas.nl/assets/event-fallbacks/racing.jpg", + "url" +); +assert(isCategoryFallbackUrl("https://x/assets/event-fallbacks/racing.jpg"), "detect"); +assert(needsCategoryFallback({ imageLandscape: null, images: [] }), "empty needs"); +assert( + needsCategoryFallback({ + imageLandscape: "https://go.ziggosporttotaal.nl/assets/channels/light/ziggo_sport_1.png", + images: [], + }), + "logo needs fallback" +); +assert( + !needsCategoryFallback({ + imageLandscape: "https://cdn.example/real-art.jpg", + images: [], + }), + "real art ok" +); + +const out = applyCategoryFallbacks( + [ + { + id: "1", + name: "Moto3 samenvatting", + channel: "Ziggo Sport 1", + network: "Ziggo Sport 1", + start: "2026-09-21T06:30:00Z", + stop: "2026-09-21T07:00:00Z", + imageLandscape: null, + imageBackdrop: null, + imagePortrait: null, + logoUrl: null, + images: ["https://go.ziggosporttotaal.nl/assets/channels/light/ziggo_sport_1.png"], + sportBucket: "racing", + provider: "ziggosport", + }, + ], + "https://master.vonas.nl" +); +assert(out[0].imageLandscape?.includes("/assets/event-fallbacks/racing.jpg"), "set racing"); +assert(out[0].logoUrl?.includes("ziggo_sport_1"), "keep ziggo logo"); + +console.log("event-fallbacks tests ok"); diff --git a/apps/master-api/src/viewer/event-fallbacks.ts b/apps/master-api/src/viewer/event-fallbacks.ts new file mode 100644 index 0000000..3d20950 --- /dev/null +++ b/apps/master-api/src/viewer/event-fallbacks.ts @@ -0,0 +1,91 @@ +/** + * Category backdrop fallbacks when an event has no real cover art. + * Served from /assets/event-fallbacks/{sport}.jpg on master-api. + */ + +import { isChannelLogoUrl, isLiveChannelThumb, type EnrichableEvent } from "./ziggo-epg-art"; + +const FALLBACK_FILE: Record = { + football: "football.jpg", + racing: "racing.jpg", + tennis: "tennis.jpg", + golf: "golf.jpg", + darts: "darts.jpg", + snooker: "snooker.jpg", + basketball: "basketball.jpg", + waterpolo: "waterpolo.jpg", + sailing: "sailing.jpg", + hockey: "hockey.jpg", + cycling: "cycling.jpg", + other: "other.jpg", +}; + +export function fallbackFilenameForSport(sport: string | null | undefined): string { + const s = (sport || "other").toLowerCase(); + return FALLBACK_FILE[s] ?? FALLBACK_FILE.other; +} + +export function eventFallbackUrl(publicBase: string, sport: string | null | undefined): string { + const base = publicBase.replace(/\/+$/, ""); + return `${base}/assets/event-fallbacks/${fallbackFilenameForSport(sport)}`; +} + +export function isCategoryFallbackUrl(url: string | null | undefined): boolean { + if (!url) return false; + return /\/assets\/event-fallbacks\/[a-z]+\.jpg(\?|$)/i.test(url); +} + +/** True when landscape is missing or only a logo / live thumb (not usable cover). */ +export function needsCategoryFallback(e: { + imageLandscape?: string | null; + images?: string[]; +}): boolean { + const land = e.imageLandscape; + if (!land) return true; + if (isLiveChannelThumb(land)) return true; + if (isChannelLogoUrl(land)) return true; + if (isCategoryFallbackUrl(land)) return false; + // Already has real art + return false; +} + +/** + * Fill empty/weak covers with sport-category backdrops and preserve channel logos in logoUrl. + */ +export function applyCategoryFallbacks( + events: T[], + publicBase: string +): T[] { + if (!publicBase) return events; + return events.map((e) => { + if (!needsCategoryFallback(e)) { + // Still ensure logoUrl when we only have channel logo in images + if (!e.logoUrl) { + const logo = e.images.find((u) => isChannelLogoUrl(u)); + if (logo) return { ...e, logoUrl: logo }; + } + return e; + } + + const fallback = eventFallbackUrl(publicBase, e.sportBucket); + const logoFromImages = e.images.find((u) => isChannelLogoUrl(u) && !isLiveChannelThumb(u)); + const logoFromLandscape = + e.imageLandscape && isChannelLogoUrl(e.imageLandscape) ? e.imageLandscape : null; + const logoUrl = e.logoUrl || logoFromLandscape || logoFromImages || null; + + const cleaned = e.images.filter( + (u) => !isLiveChannelThumb(u) && !isCategoryFallbackUrl(u) + ); + const images = [fallback, ...cleaned.filter((u) => u !== fallback)]; + + return { + ...e, + imageLandscape: fallback, + imageBackdrop: e.imageBackdrop && !isLiveChannelThumb(e.imageBackdrop) && !isChannelLogoUrl(e.imageBackdrop) + ? e.imageBackdrop + : fallback, + logoUrl, + images, + }; + }); +} diff --git a/apps/master-api/src/viewer/schedule-events.ts b/apps/master-api/src/viewer/schedule-events.ts index 9f5feb2..dd85f1d 100644 --- a/apps/master-api/src/viewer/schedule-events.ts +++ b/apps/master-api/src/viewer/schedule-events.ts @@ -4,6 +4,7 @@ import { getIptvSettings, normalizeProviderIdList } from "../settings/iptv"; import { prisma } from "../database/client"; import { enrichZiggoGoArt, invalidateZiggoEpgArtCache } from "./ziggo-epg-art"; import { enrichEspnWatchArt, invalidateEspnWatchArtCache } from "./espn-watch-art"; +import { applyCategoryFallbacks } from "./event-fallbacks"; const CACHE_TTL_MS = 60_000; const FETCH_TIMEOUT_MS = 20_000; @@ -824,9 +825,12 @@ export async function listScheduleEvents( return Date.parse(a.start) - Date.parse(b.start); }); const disabledGlobal = await globalDisabledProviders(config); - // Ziggo Gracenote + ESPN Watch art, then sport|channel gap-fill + // Ziggo Gracenote + ESPN Watch art → sport|channel gap-fill → category backdrops const enriched = await enrichEspnWatchArt(await enrichZiggoGoArt(mapped)); - const withArt = fillMissingImages(enriched); + const withArt = applyCategoryFallbacks( + fillMissingImages(enriched), + config.PUBLIC_URL + ); const events = filterByAccess(withArt, disabledGlobal, access); const providers = [...new Set(events.map((e) => e.provider.toLowerCase()))].sort((a, b) => diff --git a/deploy/docker/Dockerfile.master-api b/deploy/docker/Dockerfile.master-api index fd5f12e..4704e20 100644 --- a/deploy/docker/Dockerfile.master-api +++ b/deploy/docker/Dockerfile.master-api @@ -27,6 +27,8 @@ RUN mkdir -p /out \ && cp -R apps/master-api/dist /out/dist \ && cp -R apps/master-api/prisma /out/prisma \ && cp apps/master-api/package.json /out/package.json \ + && mkdir -p /out/assets \ + && cp -R apps/master-api/assets/. /out/assets/ \ && mkdir -p /out/local-packages \ && cp -R packages/shared-types /out/local-packages/shared-types \ && cp -R packages/protocol /out/local-packages/protocol @@ -39,6 +41,7 @@ RUN apk add --no-cache openssl wget python3 make g++ COPY --from=builder /out/dist ./dist COPY --from=builder /out/prisma ./prisma COPY --from=builder /out/package.json ./package.json +COPY --from=builder /out/assets ./assets COPY --from=builder /out/local-packages ./local-packages COPY --from=node-build /media-node-linux-amd64 /app/install/media-node-linux-amd64 COPY --from=node-build /media-node-linux-arm64 /app/install/media-node-linux-arm64