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) { suspend fun getF1tvMeeting(meetingKey: String): F1tvMeetingDetail = withContext(Dispatchers.IO) {
val enc = android.net.Uri.encode(meetingKey) val enc = android.net.Uri.encode(meetingKey)
val req = authed("$baseUrl/api/v1/client/f1/meetings/$enc").get().build() val req = authed("$baseUrl/api/v1/client/f1/meetings/$enc").get().build()
@ -1285,6 +1342,28 @@ data class F1tvHomePayload(
val nextMeeting: F1tvMeeting? = null, 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( data class F1tvMeetingDetail(
val meeting: F1tvMeeting? = null, val meeting: F1tvMeeting? = null,
val sessions: List<ScheduleEvent> = emptyList(), 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.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@ -75,6 +76,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import nl.vonas.mediacluster.tv.data.ApiClient 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.ScheduleEvent
import nl.vonas.mediacluster.tv.data.SportBucketOrder import nl.vonas.mediacluster.tv.data.SportBucketOrder
import nl.vonas.mediacluster.tv.data.sportLabelForBucket import nl.vonas.mediacluster.tv.data.sportLabelForBucket
@ -120,9 +122,11 @@ fun EventsScreen(
onFocusRestoreConsumed: () -> Unit = {}, onFocusRestoreConsumed: () -> Unit = {},
onOpenEvent: (String) -> Unit, onOpenEvent: (String) -> Unit,
onOpenF1Hub: (() -> Unit)? = null, onOpenF1Hub: (() -> Unit)? = null,
onOpenViaplayHub: (() -> Unit)? = null,
onContentFocused: () -> Unit = {}, onContentFocused: () -> Unit = {},
) { ) {
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) } var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
var hubs by remember { mutableStateOf<List<EventHubInfo>>(emptyList()) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var sportFilter by remember { mutableStateOf<String?>(null) } var sportFilter by remember { mutableStateOf<String?>(null) }
@ -145,6 +149,24 @@ fun EventsScreen(
error = null error = null
} }
.onFailure { error = it.message ?: "Kon events niet laden" } .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 loading = false
} }
@ -225,13 +247,13 @@ fun EventsScreen(
else -> null else -> null
} }
// Focus: TopNav → F1-tile (optioneel) → filterrij → first rail. // Focus: TopNav → hub-rail (optioneel) → filterrij → first rail.
val hasF1Entry = onOpenF1Hub != null val hasHubRail = hubs.isNotEmpty()
val showFilterBar = showSportChips || showProviderChips val showFilterBar = showSportChips || showProviderChips
val filterBarFocus = remember { FocusRequester() } val filterBarFocus = remember { FocusRequester() }
val firstRailUpTarget = when { val firstRailUpTarget = when {
showFilterBar && filterBarFocusReady -> filterBarFocus showFilterBar && filterBarFocusReady -> filterBarFocus
hasF1Entry -> contentFocusRequester hasHubRail -> contentFocusRequester
else -> navFocusRequester else -> navFocusRequester
} }
val filterFocusDown: FocusRequester? = when { val filterFocusDown: FocusRequester? = when {
@ -239,7 +261,7 @@ fun EventsScreen(
railFocusReady -> firstRailFocus railFocusReady -> firstRailFocus
else -> null else -> null
} }
val railUsesDedicatedFocus = hasF1Entry || showFilterBar val railUsesDedicatedFocus = hasHubRail || showFilterBar
fun claimRailFocus(owner: String, ready: Boolean) { fun claimRailFocus(owner: String, ready: Boolean) {
if (ready) { if (ready) {
@ -270,7 +292,7 @@ fun EventsScreen(
} }
val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0 val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0
val restoreListIndex = remember( val restoreListIndex = remember(
hasF1Entry, hasHubRail,
showFilterBar, showFilterBar,
live, live,
upcomingRails, upcomingRails,
@ -279,7 +301,7 @@ fun EventsScreen(
) { ) {
if (!canRestoreCard) return@remember -1 if (!canRestoreCard) return@remember -1
var idx = 0 var idx = 0
if (hasF1Entry) idx++ if (hasHubRail) idx++
if (showFilterBar) idx++ if (showFilterBar) idx++
if (restoreInLive) return@remember idx if (restoreInLive) return@remember idx
if (live.isNotEmpty()) idx++ if (live.isNotEmpty()) idx++
@ -320,14 +342,14 @@ fun EventsScreen(
} }
val entryReady = when { val entryReady = when {
hasF1Entry -> true hasHubRail -> true
showFilterBar -> filterBarFocusReady showFilterBar -> filterBarFocusReady
else -> railFocusReady else -> railFocusReady
} }
val needContentSink = when { val needContentSink = when {
loading && events.isEmpty() -> true loading && events.isEmpty() -> true
error != null && events.isEmpty() -> true error != null && events.isEmpty() -> true
hasF1Entry -> false hasHubRail -> false
showFilterBar -> !filterBarFocusReady showFilterBar -> !filterBarFocusReady
!hasRails -> true !hasRails -> true
else -> !railFocusReady else -> !railFocusReady
@ -352,7 +374,7 @@ fun EventsScreen(
if (restoredSuccessfully) return if (restoredSuccessfully) return
val entryOk = awaitReady({ val entryOk = awaitReady({
when { when {
hasF1Entry -> true hasHubRail -> true
showFilterBar -> filterBarFocusReady showFilterBar -> filterBarFocusReady
else -> railFocusReady else -> railFocusReady
} }
@ -475,12 +497,14 @@ fun EventsScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 56.dp), contentPadding = PaddingValues(bottom = 56.dp),
) { ) {
if (hasF1Entry) { if (hasHubRail) {
item(key = "f1_hub_entry") { item(key = "hub_rail") {
F1HubEntryTile( EventsHubRail(
hubs = hubs,
focusRequester = contentFocusRequester, focusRequester = contentFocusRequester,
focusUp = navFocusRequester, focusUp = navFocusRequester,
onOpen = onOpenF1Hub!!, onOpenF1 = onOpenF1Hub,
onOpenViaplay = onOpenViaplayHub,
onFocus = onContentFocused, onFocus = onContentFocused,
) )
} }
@ -498,8 +522,8 @@ fun EventsScreen(
onProviderSelect = { providerFilter = it }, onProviderSelect = { providerFilter = it },
liveCount = live.size, liveCount = live.size,
totalCount = filtered.size, totalCount = filtered.size,
firstFocusRequester = if (hasF1Entry) filterBarFocus else contentFocusRequester, firstFocusRequester = if (hasHubRail) filterBarFocus else contentFocusRequester,
focusUp = if (hasF1Entry) contentFocusRequester else navFocusRequester, focusUp = if (hasHubRail) contentFocusRequester else navFocusRequester,
focusDown = filterFocusDown, focusDown = filterFocusDown,
onFocusReady = { filterBarFocusReady = it }, onFocusReady = { filterBarFocusReady = it },
onFocus = onContentFocused, onFocus = onContentFocused,
@ -1078,21 +1102,69 @@ private fun PulsingLiveDot(size: androidx.compose.ui.unit.Dp = 10.dp) {
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @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?, focusRequester: FocusRequester?,
focusUp: FocusRequester?, focusUp: FocusRequester?,
onOpen: () -> Unit, onOpen: () -> Unit,
onFocus: () -> Unit, onFocus: () -> Unit,
) { ) {
var focused by remember { mutableStateOf(false) } var focused by remember { mutableStateOf(false) }
val f1Red = Color(0xFFE10600)
Surface( Surface(
onClick = onOpen, onClick = onOpen,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .height(52.dp)
.padding(horizontal = HomeChrome.EdgeInset) .widthIn(min = 168.dp, max = 220.dp)
.padding(top = 8.dp, bottom = 10.dp)
.height(72.dp)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.then( .then(
if (focusUp != null) { if (focusUp != null) {
@ -1105,63 +1177,63 @@ private fun F1HubEntryTile(
focused = it.isFocused focused = it.isFocused
if (it.isFocused) onFocus() if (it.isFocused) onFocus()
}, },
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
glow = ClickableSurfaceDefaults.glow( glow = ClickableSurfaceDefaults.glow(
focusedGlow = Glow(elevationColor = f1Red, elevation = 14.dp), focusedGlow = Glow(elevationColor = accent, elevation = 8.dp),
), ),
border = ClickableSurfaceDefaults.border( border = ClickableSurfaceDefaults.border(
focusedBorder = Border( focusedBorder = Border(
border = BorderStroke(2.5.dp, f1Red), border = BorderStroke(2.dp, accent),
inset = 4.dp, inset = 2.dp,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(12.dp),
), ),
), ),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF141414), containerColor = Color(0xFF14141A),
focusedContainerColor = Color(0xFF1E1212), focusedContainerColor = Color(0xFF1A1A22),
), ),
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)), shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
) { ) {
Row( Row(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 20.dp), .padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Box( Box(
Modifier Modifier
.size(36.dp) .size(28.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(7.dp))
.background(f1Red), .background(accent),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
"F1", badge,
color = Color.White, color = Color.White,
fontSize = 13.sp, fontSize = 11.sp,
fontWeight = FontWeight.Black, fontWeight = FontWeight.Black,
) )
} }
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text( Text(
"Formule 1", label,
color = Color.White, color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
)
Text(
"Agenda & terugkijken",
color = McColors.TextDim,
fontSize = 13.sp,
)
}
Text(
"Open →",
color = if (focused) f1Red else McColors.TextDim,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
if (subtitle.isNotBlank()) {
Text(
subtitle,
color = McColors.TextDim,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
} }
} }
} }

View file

@ -370,6 +370,11 @@ fun McNav() {
launchSingleTop = true launchSingleTop = true
} }
}, },
onOpenViaplayHub = {
nav.navigate("viaplay_hub") {
launchSingleTop = true
}
},
onSwitchProfile = { onSwitchProfile = {
scope.launch { scope.launch {
runCatching { api.clearProfile() } 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( composable(
"f1_meeting/{meetingKey}", "f1_meeting/{meetingKey}",
arguments = listOf(navArgument("meetingKey") { type = NavType.StringType }), arguments = listOf(navArgument("meetingKey") { type = NavType.StringType }),
@ -666,6 +689,7 @@ private fun MainHub(
onPlayLive: (String) -> Unit, onPlayLive: (String) -> Unit,
onOpenEvent: (String) -> Unit, onOpenEvent: (String) -> Unit,
onOpenF1Hub: () -> Unit = {}, onOpenF1Hub: () -> Unit = {},
onOpenViaplayHub: () -> Unit = {},
onSwitchProfile: () -> Unit, onSwitchProfile: () -> Unit,
onManageProfiles: () -> Unit, onManageProfiles: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
@ -1064,6 +1088,7 @@ private fun MainHub(
onFocusRestoreConsumed = onEventsFocusRestoreConsumed, onFocusRestoreConsumed = onEventsFocusRestoreConsumed,
onOpenEvent = onOpenEvent, onOpenEvent = onOpenEvent,
onOpenF1Hub = onOpenF1Hub, onOpenF1Hub = onOpenF1Hub,
onOpenViaplayHub = onOpenViaplayHub,
onContentFocused = { navFocused = false }, onContentFocused = { navFocused = false },
) )
MainTab.Search -> SearchScreen( 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; if (!event) return null;
return eventToPublic(event, nowMs); 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); 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( export async function saveViaplaySyncResult(
summary: Record<string, unknown>, summary: Record<string, unknown>,
data: { data: {

View file

@ -901,6 +901,78 @@ export function registerViewerRoutes(
return getF1tvHome(); 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) => { app.get("/api/v1/client/f1/meetings/:meetingKey", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization); const auth = await viewers.authFromBearer(request.headers.authorization);
const { meetingKey } = request.params as { meetingKey: string }; const { meetingKey } = request.params as { meetingKey: string };