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.
This commit is contained in:
Jos Vooges | STH 2026-09-23 01:31:33 +02:00
parent fafe97e729
commit 42e521da25
7 changed files with 906 additions and 50 deletions

View file

@ -696,6 +696,63 @@ class ApiClient(
}
}
suspend fun listEventHubs(): List<EventHubInfo> = 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<ScheduleEvent> {
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<ScheduleEvent> = emptyList(),
val today: List<ScheduleEvent> = emptyList(),
val weekend: List<ScheduleEvent> = emptyList(),
val later: List<ScheduleEvent> = emptyList(),
val sports: List<ViaplaySportChip> = emptyList(),
val events: List<ScheduleEvent> = emptyList(),
)
data class F1tvMeetingDetail(
val meeting: F1tvMeeting? = null,
val sessions: List<ScheduleEvent> = emptyList(),

View file

@ -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<List<ScheduleEvent>>(emptyList()) }
var hubs by remember { mutableStateOf<List<EventHubInfo>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var sportFilter by remember { mutableStateOf<String?>(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<EventHubInfo>,
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,
)
}
}
}

View file

@ -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(

View file

@ -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<ViaplayHomePayload?>(null) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var sportFilter by remember { mutableStateOf<String?>(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<ScheduleEvent>.sportFiltered(): List<ScheduleEvent> {
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<ScheduleEvent>,
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),
)
}
}

View file

@ -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<string, number> = {
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<ViaplayHomePayload> {
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<string, { label: string; count: number }>();
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,
};
}

View file

@ -481,6 +481,22 @@ export async function loadViaplayConfigOnly(): Promise<ViaplayConfig | null> {
return parseConfig(row.configJson);
}
export async function isViaplayIntegrationEnabled(): Promise<boolean> {
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<string, unknown>,
data: {

View file

@ -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 };