From 42e521da251943f61300b5ace63a60fb6b099c87 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 23 Sep 2026 01:31:33 +0200 Subject: [PATCH] Add Viaplay hub and replace F1 banner with shared hub rail on TV. Events keep live/upcoming; hubs load via /client/hubs and Viaplay home is time+sport based. --- .../vonas/mediacluster/tv/data/ApiClient.kt | 79 +++ .../vonas/mediacluster/tv/ui/EventsScreen.kt | 172 +++++-- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 25 + .../mediacluster/tv/ui/ViaplayHubScreen.kt | 459 ++++++++++++++++++ apps/master-api/src/viaplay/events.ts | 133 +++++ apps/master-api/src/viaplay/settings.ts | 16 + apps/master-api/src/viewer/routes.ts | 72 +++ 7 files changed, 906 insertions(+), 50 deletions(-) create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ViaplayHubScreen.kt diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 72a5f00..6473228 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -696,6 +696,63 @@ class ApiClient( } } + suspend fun listEventHubs(): List = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/hubs").get().build() + parseObject(execute(req)) { o -> + val arr = o.optJSONArray("hubs") ?: JSONArray() + buildList { + for (i in 0 until arr.length()) { + val h = arr.optJSONObject(i) ?: continue + val id = h.optClean("id") ?: continue + add( + EventHubInfo( + id = id, + label = h.optClean("label") ?: id, + subtitle = h.optClean("subtitle") ?: "", + ), + ) + } + } + } + } + + suspend fun getViaplayHome(): ViaplayHomePayload = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/viaplay/home").get().build() + parseObject(execute(req)) { o -> + fun eventsOf(key: String): List { + val arr = o.optJSONArray(key) ?: JSONArray() + return buildList { + for (i in 0 until arr.length()) { + arr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) } + } + } + } + val sportsArr = o.optJSONArray("sports") ?: JSONArray() + val sports = buildList { + for (i in 0 until sportsArr.length()) { + val s = sportsArr.optJSONObject(i) ?: continue + val id = s.optClean("id") ?: continue + add( + ViaplaySportChip( + id = id, + label = s.optClean("label") ?: id, + count = s.optInt("count"), + ), + ) + } + } + ViaplayHomePayload( + generatedAt = o.optClean("generatedAt"), + live = eventsOf("live"), + today = eventsOf("today"), + weekend = eventsOf("weekend"), + later = eventsOf("later"), + sports = sports, + events = eventsOf("events"), + ) + } + } + suspend fun getF1tvMeeting(meetingKey: String): F1tvMeetingDetail = withContext(Dispatchers.IO) { val enc = android.net.Uri.encode(meetingKey) val req = authed("$baseUrl/api/v1/client/f1/meetings/$enc").get().build() @@ -1285,6 +1342,28 @@ data class F1tvHomePayload( val nextMeeting: F1tvMeeting? = null, ) +data class EventHubInfo( + val id: String, + val label: String, + val subtitle: String = "", +) + +data class ViaplaySportChip( + val id: String, + val label: String, + val count: Int = 0, +) + +data class ViaplayHomePayload( + val generatedAt: String? = null, + val live: List = emptyList(), + val today: List = emptyList(), + val weekend: List = emptyList(), + val later: List = emptyList(), + val sports: List = emptyList(), + val events: List = emptyList(), +) + data class F1tvMeetingDetail( val meeting: F1tvMeeting? = null, val sessions: List = emptyList(), 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 eebbb29..980085b 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 @@ -29,6 +29,7 @@ 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.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -75,6 +76,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.yield import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.data.EventHubInfo import nl.vonas.mediacluster.tv.data.ScheduleEvent import nl.vonas.mediacluster.tv.data.SportBucketOrder import nl.vonas.mediacluster.tv.data.sportLabelForBucket @@ -120,9 +122,11 @@ fun EventsScreen( onFocusRestoreConsumed: () -> Unit = {}, onOpenEvent: (String) -> Unit, onOpenF1Hub: (() -> Unit)? = null, + onOpenViaplayHub: (() -> Unit)? = null, onContentFocused: () -> Unit = {}, ) { var events by remember { mutableStateOf>(emptyList()) } + var hubs by remember { mutableStateOf>(emptyList()) } var loading by remember { mutableStateOf(true) } var error by remember { mutableStateOf(null) } var sportFilter by remember { mutableStateOf(null) } @@ -145,6 +149,24 @@ fun EventsScreen( error = null } .onFailure { error = it.message ?: "Kon events niet laden" } + hubs = runCatching { api.listEventHubs() } + .getOrElse { + buildList { + if (onOpenF1Hub != null) { + add(EventHubInfo("f1", "Formule 1", "Agenda & terugkijken")) + } + if (onOpenViaplayHub != null) { + add(EventHubInfo("viaplay", "Viaplay", "Live & agenda")) + } + } + } + .filter { hub -> + when (hub.id) { + "f1" -> onOpenF1Hub != null + "viaplay" -> onOpenViaplayHub != null + else -> false + } + } loading = false } @@ -225,13 +247,13 @@ fun EventsScreen( else -> null } - // Focus: TopNav → F1-tile (optioneel) → filterrij → first rail. - val hasF1Entry = onOpenF1Hub != null + // Focus: TopNav → hub-rail (optioneel) → filterrij → first rail. + val hasHubRail = hubs.isNotEmpty() val showFilterBar = showSportChips || showProviderChips val filterBarFocus = remember { FocusRequester() } val firstRailUpTarget = when { showFilterBar && filterBarFocusReady -> filterBarFocus - hasF1Entry -> contentFocusRequester + hasHubRail -> contentFocusRequester else -> navFocusRequester } val filterFocusDown: FocusRequester? = when { @@ -239,7 +261,7 @@ fun EventsScreen( railFocusReady -> firstRailFocus else -> null } - val railUsesDedicatedFocus = hasF1Entry || showFilterBar + val railUsesDedicatedFocus = hasHubRail || showFilterBar fun claimRailFocus(owner: String, ready: Boolean) { if (ready) { @@ -270,7 +292,7 @@ fun EventsScreen( } val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0 val restoreListIndex = remember( - hasF1Entry, + hasHubRail, showFilterBar, live, upcomingRails, @@ -279,7 +301,7 @@ fun EventsScreen( ) { if (!canRestoreCard) return@remember -1 var idx = 0 - if (hasF1Entry) idx++ + if (hasHubRail) idx++ if (showFilterBar) idx++ if (restoreInLive) return@remember idx if (live.isNotEmpty()) idx++ @@ -320,14 +342,14 @@ fun EventsScreen( } val entryReady = when { - hasF1Entry -> true + hasHubRail -> true showFilterBar -> filterBarFocusReady else -> railFocusReady } val needContentSink = when { loading && events.isEmpty() -> true error != null && events.isEmpty() -> true - hasF1Entry -> false + hasHubRail -> false showFilterBar -> !filterBarFocusReady !hasRails -> true else -> !railFocusReady @@ -352,7 +374,7 @@ fun EventsScreen( if (restoredSuccessfully) return val entryOk = awaitReady({ when { - hasF1Entry -> true + hasHubRail -> true showFilterBar -> filterBarFocusReady else -> railFocusReady } @@ -475,12 +497,14 @@ fun EventsScreen( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 56.dp), ) { - if (hasF1Entry) { - item(key = "f1_hub_entry") { - F1HubEntryTile( + if (hasHubRail) { + item(key = "hub_rail") { + EventsHubRail( + hubs = hubs, focusRequester = contentFocusRequester, focusUp = navFocusRequester, - onOpen = onOpenF1Hub!!, + onOpenF1 = onOpenF1Hub, + onOpenViaplay = onOpenViaplayHub, onFocus = onContentFocused, ) } @@ -498,8 +522,8 @@ fun EventsScreen( onProviderSelect = { providerFilter = it }, liveCount = live.size, totalCount = filtered.size, - firstFocusRequester = if (hasF1Entry) filterBarFocus else contentFocusRequester, - focusUp = if (hasF1Entry) contentFocusRequester else navFocusRequester, + firstFocusRequester = if (hasHubRail) filterBarFocus else contentFocusRequester, + focusUp = if (hasHubRail) contentFocusRequester else navFocusRequester, focusDown = filterFocusDown, onFocusReady = { filterBarFocusReady = it }, onFocus = onContentFocused, @@ -1078,21 +1102,69 @@ private fun PulsingLiveDot(size: androidx.compose.ui.unit.Dp = 10.dp) { @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable -private fun F1HubEntryTile( +private fun EventsHubRail( + hubs: List, + focusRequester: FocusRequester?, + focusUp: FocusRequester?, + onOpenF1: (() -> Unit)?, + onOpenViaplay: (() -> Unit)?, + onFocus: () -> Unit, +) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp, bottom = 4.dp), + contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + itemsIndexed(hubs, key = { _, h -> h.id }) { index, hub -> + val accent = when (hub.id) { + "f1" -> Color(0xFFE10600) + "viaplay" -> Color(0xFF8B5CF6) + else -> McColors.Accent + } + val badge = when (hub.id) { + "f1" -> "F1" + "viaplay" -> "VP" + else -> hub.label.take(2).uppercase() + } + val onOpen = when (hub.id) { + "f1" -> onOpenF1 + "viaplay" -> onOpenViaplay + else -> null + } ?: return@itemsIndexed + HubRailTile( + badge = badge, + label = hub.label, + subtitle = hub.subtitle, + accent = accent, + focusRequester = if (index == 0) focusRequester else null, + focusUp = focusUp, + onOpen = onOpen, + onFocus = onFocus, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun HubRailTile( + badge: String, + label: String, + subtitle: String, + accent: Color, focusRequester: FocusRequester?, focusUp: FocusRequester?, onOpen: () -> Unit, onFocus: () -> Unit, ) { var focused by remember { mutableStateOf(false) } - val f1Red = Color(0xFFE10600) Surface( onClick = onOpen, modifier = Modifier - .fillMaxWidth() - .padding(horizontal = HomeChrome.EdgeInset) - .padding(top = 8.dp, bottom = 10.dp) - .height(72.dp) + .height(52.dp) + .widthIn(min = 168.dp, max = 220.dp) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .then( if (focusUp != null) { @@ -1105,63 +1177,63 @@ private fun F1HubEntryTile( focused = it.isFocused if (it.isFocused) onFocus() }, - scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), glow = ClickableSurfaceDefaults.glow( - focusedGlow = Glow(elevationColor = f1Red, elevation = 14.dp), + focusedGlow = Glow(elevationColor = accent, elevation = 8.dp), ), border = ClickableSurfaceDefaults.border( focusedBorder = Border( - border = BorderStroke(2.5.dp, f1Red), - inset = 4.dp, - shape = RoundedCornerShape(16.dp), + border = BorderStroke(2.dp, accent), + inset = 2.dp, + shape = RoundedCornerShape(12.dp), ), ), colors = ClickableSurfaceDefaults.colors( - containerColor = Color(0xFF141414), - focusedContainerColor = Color(0xFF1E1212), + containerColor = Color(0xFF14141A), + focusedContainerColor = Color(0xFF1A1A22), ), - shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)), ) { Row( Modifier .fillMaxSize() - .padding(horizontal = 20.dp), + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Box( Modifier - .size(36.dp) - .clip(RoundedCornerShape(8.dp)) - .background(f1Red), + .size(28.dp) + .clip(RoundedCornerShape(7.dp)) + .background(accent), contentAlignment = Alignment.Center, ) { Text( - "F1", + badge, color = Color.White, - fontSize = 13.sp, + fontSize = 11.sp, fontWeight = FontWeight.Black, ) } Column(Modifier.weight(1f)) { Text( - "Formule 1", + label, color = Color.White, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - ) - Text( - "Agenda & terugkijken", - color = McColors.TextDim, - fontSize = 13.sp, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) + if (subtitle.isNotBlank()) { + Text( + subtitle, + color = McColors.TextDim, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } - Text( - "Open →", - color = if (focused) f1Red else McColors.TextDim, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - ) } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index eeef685..2218d5e 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -370,6 +370,11 @@ fun McNav() { launchSingleTop = true } }, + onOpenViaplayHub = { + nav.navigate("viaplay_hub") { + launchSingleTop = true + } + }, onSwitchProfile = { scope.launch { runCatching { api.clearProfile() } @@ -580,6 +585,24 @@ fun McNav() { }, ) } + composable("viaplay_hub") { + DisposableEffect(Unit) { + onDispose { + pendingEventsFocusRestore = true + eventsReturnNonce++ + } + } + ViaplayHubScreen( + api = api, + onBack = { nav.popBackStack() }, + onOpenEvent = { eventId -> + lastOpenedEventId = eventId + nav.navigate("event/${android.net.Uri.encode(eventId)}") { + launchSingleTop = true + } + }, + ) + } composable( "f1_meeting/{meetingKey}", arguments = listOf(navArgument("meetingKey") { type = NavType.StringType }), @@ -666,6 +689,7 @@ private fun MainHub( onPlayLive: (String) -> Unit, onOpenEvent: (String) -> Unit, onOpenF1Hub: () -> Unit = {}, + onOpenViaplayHub: () -> Unit = {}, onSwitchProfile: () -> Unit, onManageProfiles: () -> Unit, onLogout: () -> Unit, @@ -1064,6 +1088,7 @@ private fun MainHub( onFocusRestoreConsumed = onEventsFocusRestoreConsumed, onOpenEvent = onOpenEvent, onOpenF1Hub = onOpenF1Hub, + onOpenViaplayHub = onOpenViaplayHub, onContentFocused = { navFocused = false }, ) MainTab.Search -> SearchScreen( diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ViaplayHubScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ViaplayHubScreen.kt new file mode 100644 index 0000000..0f068a7 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ViaplayHubScreen.kt @@ -0,0 +1,459 @@ +package nl.vonas.mediacluster.tv.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Glow +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import kotlinx.coroutines.delay +import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.data.ScheduleEvent +import nl.vonas.mediacluster.tv.data.ViaplayHomePayload +import nl.vonas.mediacluster.tv.ui.components.HomeChrome +import nl.vonas.mediacluster.tv.ui.theme.McColors +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** Viaplay brand — koel violet, geen F1-rood. */ +private object VpLook { + val Accent = Color(0xFF8B5CF6) + val AccentDeep = Color(0xFF6D28D9) + val AccentSoft = Color(0x448B5CF6) + val Night = Color(0xFF0A0B12) + val Panel = Color(0xFF14161F) + val PanelHot = Color(0xFF1C1F2B) + val Live = Color(0xFFFF2D55) + val Mist = Color(0xFFA1A1AA) + val White = Color(0xFFF4F4F5) +} + +private val ZoneNl: ZoneId = ZoneId.of("Europe/Amsterdam") +private val DayTimeFmt: DateTimeFormatter = + DateTimeFormatter.ofPattern("EEE HH:mm", Locale("nl", "NL")) + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +fun ViaplayHubScreen( + api: ApiClient, + onBack: () -> Unit, + onOpenEvent: (String) -> Unit, +) { + var home by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var sportFilter by remember { mutableStateOf(null) } + var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) } + val firstFocus = remember { FocusRequester() } + + BackHandler(onBack = onBack) + + suspend fun refresh() { + runCatching { api.getViaplayHome() } + .onSuccess { + home = it + error = null + } + .onFailure { error = it.message ?: "Kon Viaplay-hub niet laden" } + loading = false + } + + LaunchedEffect(Unit) { refresh() } + LaunchedEffect(Unit) { + while (true) { + delay(75_000) + refresh() + } + } + LaunchedEffect(Unit) { + while (true) { + delay(30_000) + nowMs = System.currentTimeMillis() + } + } + LaunchedEffect(home, loading, error) { + if (!loading && error == null && home != null) { + delay(80) + runCatching { firstFocus.requestFocus() } + } + } + + fun List.sportFiltered(): List { + val f = sportFilter ?: return this + return filter { it.sportBucket.equals(f, ignoreCase = true) } + } + + Box( + Modifier + .fillMaxSize() + .background(VpLook.Night), + ) { + Box( + Modifier + .align(Alignment.TopStart) + .fillMaxWidth(0.5f) + .height(280.dp) + .background( + Brush.radialGradient( + colors = listOf(VpLook.AccentSoft, Color.Transparent), + ), + ), + ) + when { + loading && home == null -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + "VIAPLAY", + color = VpLook.Accent, + fontSize = 26.sp, + fontWeight = FontWeight.Black, + ) + } + } + error != null && home == null -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(error!!, color = McColors.Danger, fontSize = 16.sp) + Spacer(Modifier.height(16.dp)) + VpChip("Terug", focusRequester = firstFocus, onClick = onBack) + } + } + } + else -> { + val payload = home!! + val live = payload.live.sportFiltered() + val today = payload.today.sportFiltered() + val weekend = payload.weekend.sportFiltered() + val later = payload.later.sportFiltered() + val sports = payload.sports + val hasAny = live.isNotEmpty() || today.isNotEmpty() || + weekend.isNotEmpty() || later.isNotEmpty() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 64.dp), + ) { + item(key = "header") { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = HomeChrome.EdgeInset) + .padding(top = 28.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + VpChip("← Events", focusRequester = firstFocus, onClick = onBack) + Column(Modifier.weight(1f)) { + Text( + "Viaplay", + color = VpLook.White, + fontSize = 28.sp, + fontWeight = FontWeight.Black, + ) + Text( + "Live · vandaag · weekend", + color = VpLook.Mist, + fontSize = 14.sp, + ) + } + } + } + + if (sports.size > 1) { + item(key = "sports") { + LazyRow( + contentPadding = PaddingValues( + horizontal = HomeChrome.EdgeInset, + vertical = 6.dp, + ), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + VpChip( + label = "Alles", + selected = sportFilter == null, + onClick = { sportFilter = null }, + ) + } + items(sports, key = { it.id }) { s -> + VpChip( + label = "${s.label} (${s.count})", + selected = sportFilter == s.id, + onClick = { + sportFilter = if (sportFilter == s.id) null else s.id + }, + ) + } + } + } + } + + if (!hasAny) { + item(key = "empty") { + Box( + Modifier + .fillMaxWidth() + .padding(top = 48.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "Geen Viaplay-events in deze periode", + color = VpLook.Mist, + fontSize = 16.sp, + ) + } + } + } + + if (live.isNotEmpty()) { + item(key = "live") { + VpRail( + title = "Nu live", + accent = VpLook.Live, + events = live, + nowMs = nowMs, + onOpen = onOpenEvent, + ) + } + } + if (today.isNotEmpty()) { + item(key = "today") { + VpRail( + title = "Vandaag", + accent = VpLook.Accent, + events = today, + nowMs = nowMs, + onOpen = onOpenEvent, + ) + } + } + if (weekend.isNotEmpty()) { + item(key = "weekend") { + VpRail( + title = "Weekend", + accent = VpLook.Accent, + events = weekend, + nowMs = nowMs, + onOpen = onOpenEvent, + ) + } + } + if (later.isNotEmpty()) { + item(key = "later") { + VpRail( + title = "Later", + accent = VpLook.Mist, + events = later, + nowMs = nowMs, + onOpen = onOpenEvent, + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun VpRail( + title: String, + accent: Color, + events: List, + nowMs: Long, + onOpen: (String) -> Unit, +) { + Column(Modifier.padding(top = 18.dp, bottom = 6.dp)) { + Text( + title.uppercase(Locale("nl", "NL")), + color = accent, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.2.sp, + modifier = Modifier.padding(horizontal = HomeChrome.EdgeInset, vertical = 6.dp), + ) + LazyRow( + contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + itemsIndexed(events, key = { _, e -> e.id }) { _, event -> + VpEventCard(event = event, nowMs = nowMs, onOpen = { onOpen(event.id) }) + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun VpEventCard( + event: ScheduleEvent, + nowMs: Long, + onOpen: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + val isLive = event.phase.equals("live", ignoreCase = true) || event.isLive + Surface( + onClick = onOpen, + modifier = Modifier + .width(280.dp) + .onFocusChanged { focused = it.isFocused }, + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = VpLook.Accent, elevation = 10.dp), + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, VpLook.Accent), + inset = 3.dp, + shape = RoundedCornerShape(14.dp), + ), + ), + colors = ClickableSurfaceDefaults.colors( + containerColor = VpLook.Panel, + focusedContainerColor = VpLook.PanelHot, + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)), + ) { + Column { + Box( + Modifier + .fillMaxWidth() + .height(148.dp) + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .background(Color(0xFF0E1018)), + ) { + val art = event.imageLandscape ?: event.imageBackdrop ?: event.images.firstOrNull() + if (!art.isNullOrBlank()) { + AsyncImage( + model = art, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + if (isLive) { + Text( + "LIVE", + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Black, + modifier = Modifier + .align(Alignment.TopStart) + .padding(10.dp) + .clip(RoundedCornerShape(4.dp)) + .background(VpLook.Live) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) + } + } + Column(Modifier.padding(12.dp)) { + Text( + event.name, + color = VpLook.White, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + val whenLabel = runCatching { + Instant.parse(event.start).atZone(ZoneNl).format(DayTimeFmt) + }.getOrNull() ?: event.start + Text( + listOfNotNull(event.sportLabel, whenLabel).joinToString(" · "), + color = VpLook.Mist, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun VpChip( + label: String, + selected: Boolean = false, + focusRequester: FocusRequester? = null, + onClick: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + Surface( + onClick = onClick, + modifier = Modifier + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .onFocusChanged { focused = it.isFocused }, + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + colors = ClickableSurfaceDefaults.colors( + containerColor = when { + selected -> VpLook.AccentDeep + focused -> VpLook.PanelHot + else -> VpLook.Panel + }, + focusedContainerColor = if (selected) VpLook.Accent else VpLook.PanelHot, + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(20.dp)), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(1.5.dp, VpLook.Accent), + shape = RoundedCornerShape(20.dp), + ), + ), + ) { + Text( + label, + color = VpLook.White, + fontSize = 13.sp, + fontWeight = if (selected || focused) FontWeight.Bold else FontWeight.Medium, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + ) + } +} diff --git a/apps/master-api/src/viaplay/events.ts b/apps/master-api/src/viaplay/events.ts index 75b0526..c9b2611 100644 --- a/apps/master-api/src/viaplay/events.ts +++ b/apps/master-api/src/viaplay/events.ts @@ -82,3 +82,136 @@ export async function getViaplayScheduleEvent( if (!event) return null; return eventToPublic(event, nowMs); } + +export type ViaplaySportChip = { + id: string; + label: string; + count: number; +}; + +export type ViaplayHomePayload = { + generatedAt: string; + /** Nu live */ + live: ScheduleEventPublic[]; + /** Upcoming vandaag (lokale kalenderdag) */ + today: ScheduleEventPublic[]; + /** Upcoming dit weekend (vr–zo), niet al in today */ + weekend: ScheduleEventPublic[]; + /** Upcoming daarna binnen sync-horizon */ + later: ScheduleEventPublic[]; + /** Sport-chips aanwezig in de payload */ + sports: ViaplaySportChip[]; + /** Alle niet-ended events (voor client-side sportfilter) */ + events: ScheduleEventPublic[]; +}; + +const ZONE_NL = "Europe/Amsterdam"; + +function localYmd(ms: number, timeZone = ZONE_NL): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date(ms)); +} + +function localWeekday(ms: number, timeZone = ZONE_NL): number { + // 0=zo … 6=za (en-US) + const day = new Intl.DateTimeFormat("en-US", { + timeZone, + weekday: "short", + }).format(new Date(ms)); + const map: Record = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, + }; + return map[day] ?? 0; +} + +function isWeekendLocal(ms: number): boolean { + const d = localWeekday(ms); + return d === 5 || d === 6 || d === 0; // vr za zo +} + +/** Hub: tijd-rails + sport-meta. Live blijft ook in Events; hier voor dieper browsen. */ +export async function getViaplayHome(nowMs = Date.now()): Promise { + const config = await loadViaplayConfigOnly(); + const empty: ViaplayHomePayload = { + generatedAt: new Date(nowMs).toISOString(), + live: [], + today: [], + weekend: [], + later: [], + sports: [], + events: [], + }; + if (!config?.events?.length) return empty; + + const aheadMs = Math.max(1, config.syncDaysAhead || 7) * 86_400_000; + const horizon = nowMs + aheadMs; + const todayYmd = localYmd(nowMs); + + const all: ScheduleEventPublic[] = []; + for (const event of config.events) { + if (event.endMs < nowMs) continue; + if (event.startMs > horizon) continue; + const pub = eventToPublic(event, nowMs); + if (pub.phase === "ended") continue; + all.push(pub); + } + + all.sort((a, b) => Date.parse(a.start) - Date.parse(b.start)); + + const live = all.filter((e) => e.phase === "live"); + const upcoming = all.filter((e) => e.phase === "upcoming"); + + const today: ScheduleEventPublic[] = []; + const weekend: ScheduleEventPublic[] = []; + const later: ScheduleEventPublic[] = []; + + for (const e of upcoming) { + const startMs = Date.parse(e.start); + if (!Number.isFinite(startMs)) { + later.push(e); + continue; + } + if (localYmd(startMs) === todayYmd) { + today.push(e); + continue; + } + if (isWeekendLocal(startMs) && startMs - nowMs < 7 * 86_400_000) { + weekend.push(e); + continue; + } + later.push(e); + } + + const sportCounts = new Map(); + for (const e of all) { + const id = e.sportBucket || "other"; + const label = e.sportLabel || id; + const cur = sportCounts.get(id) ?? { label, count: 0 }; + cur.count += 1; + if (!cur.label) cur.label = label; + sportCounts.set(id, cur); + } + const sports: ViaplaySportChip[] = [...sportCounts.entries()] + .map(([id, v]) => ({ id, label: v.label, count: v.count })) + .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, "nl")); + + return { + generatedAt: new Date(nowMs).toISOString(), + live, + today, + weekend, + later, + sports, + events: all, + }; +} diff --git a/apps/master-api/src/viaplay/settings.ts b/apps/master-api/src/viaplay/settings.ts index 3fc4fc2..e22d8d1 100644 --- a/apps/master-api/src/viaplay/settings.ts +++ b/apps/master-api/src/viaplay/settings.ts @@ -481,6 +481,22 @@ export async function loadViaplayConfigOnly(): Promise { return parseConfig(row.configJson); } +export async function isViaplayIntegrationEnabled(): Promise { + const row = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } }); + if (!row?.enabled) return false; + try { + const config = parseConfig(row.configJson); + return config.accounts.some( + (a) => + a.enabled !== false && + !!a.email && + ((!!a.sessionEnc && !!a.accessTokenEnc) || !!a.passwordEnc) + ); + } catch { + return false; + } +} + export async function saveViaplaySyncResult( summary: Record, data: { diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 1391ff4..d947b01 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -901,6 +901,78 @@ export function registerViewerRoutes( return getF1tvHome(); }); + app.get("/api/v1/client/viaplay/home", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const { resolveViewerLiveEventsAccess } = await import("./schedule-events"); + const access = await resolveViewerLiveEventsAccess(auth.viewerId, config); + if (!access.enabled) { + throw new AppError("FORBIDDEN", "Live Events is uitgeschakeld voor dit account", 403); + } + const { getIptvSettings } = await import("../settings/iptv"); + const s = await getIptvSettings(config); + const disabled = new Set(s.liveEventsDisabledProviders.map((p) => p.toLowerCase())); + if (disabled.has("viaplay")) { + throw new AppError("NOT_FOUND", "Viaplay is uitgeschakeld", 404); + } + if ( + access.allowedProviders && + access.allowedProviders.length > 0 && + !access.allowedProviders.includes("viaplay") + ) { + throw new AppError("NOT_FOUND", "Viaplay is niet beschikbaar voor dit account", 404); + } + const { isViaplayIntegrationEnabled } = await import("../viaplay/settings"); + if (!(await isViaplayIntegrationEnabled())) { + throw new AppError("NOT_FOUND", "Viaplay is niet geconfigureerd", 404); + } + const { getViaplayHome } = await import("../viaplay/events"); + return getViaplayHome(); + }); + + app.get("/api/v1/client/hubs", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const { resolveViewerLiveEventsAccess } = await import("./schedule-events"); + const access = await resolveViewerLiveEventsAccess(auth.viewerId, config); + if (!access.enabled) { + return { hubs: [] as Array<{ id: string; label: string; subtitle: string }> }; + } + const { getIptvSettings } = await import("../settings/iptv"); + const s = await getIptvSettings(config); + const disabled = new Set(s.liveEventsDisabledProviders.map((p) => p.toLowerCase())); + const allow = access.allowedProviders; + const ok = (id: string) => { + if (disabled.has(id)) return false; + if (allow && allow.length > 0 && !allow.includes(id)) return false; + return true; + }; + const hubs: Array<{ id: string; label: string; subtitle: string }> = []; + try { + const { isF1tvIntegrationEnabled } = await import("../f1tv/settings"); + if (ok("f1tv") && (await isF1tvIntegrationEnabled())) { + hubs.push({ + id: "f1", + label: "Formule 1", + subtitle: "Agenda & terugkijken", + }); + } + } catch { + /* ignore */ + } + try { + const { isViaplayIntegrationEnabled } = await import("../viaplay/settings"); + if (ok("viaplay") && (await isViaplayIntegrationEnabled())) { + hubs.push({ + id: "viaplay", + label: "Viaplay", + subtitle: "Live & agenda", + }); + } + } catch { + /* ignore */ + } + return { hubs }; + }); + app.get("/api/v1/client/f1/meetings/:meetingKey", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { meetingKey } = request.params as { meetingKey: string };