Add Viaplay sport hub rails with catch-up and VOD play.

Hub mirrors the official sport page shelves; clips/docs use play.viaplay.com/byguid, and ended events stay playable for ~48h.
This commit is contained in:
Jos Vooges | STH 2026-09-23 02:18:47 +02:00
parent 244a8c97ed
commit 8b39ce06ee
11 changed files with 907 additions and 94 deletions

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24
targetSdk = 36
versionCode = 145
versionName = "0.14.82"
versionCode = 147
versionName = "0.14.84"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -741,18 +741,65 @@ class ApiClient(
)
}
}
val railsArr = o.optJSONArray("rails") ?: JSONArray()
val rails = buildList {
for (i in 0 until railsArr.length()) {
val r = railsArr.optJSONObject(i) ?: continue
val rid = r.optClean("id") ?: continue
val itemsArr = r.optJSONArray("items") ?: JSONArray()
val items = buildList {
for (j in 0 until itemsArr.length()) {
val it = itemsArr.optJSONObject(j) ?: continue
val iid = it.optClean("id") ?: continue
add(
ViaplayHomeItem(
id = iid,
guid = it.optClean("guid") ?: iid,
title = it.optClean("title") ?: iid,
subtitle = it.optClean("subtitle"),
imageUrl = it.optClean("imageUrl"),
kind = it.optClean("kind") ?: "other",
openAs = it.optClean("openAs") ?: "content",
playable = it.optBoolean("playable", true),
durationLabel = it.optClean("durationLabel"),
start = it.optClean("start"),
badge = it.optClean("badge"),
),
)
}
}
add(
ViaplayHomeRail(
id = rid,
title = r.optClean("title") ?: rid,
items = items,
),
)
}
}
ViaplayHomePayload(
generatedAt = o.optClean("generatedAt"),
live = eventsOf("live"),
today = eventsOf("today"),
weekend = eventsOf("weekend"),
later = eventsOf("later"),
catchup = eventsOf("catchup"),
sports = sports,
events = eventsOf("events"),
rails = rails,
)
}
}
/** Viaplay VOD/clip play (play.viaplay.com/byguid). */
suspend fun viaplayContentPlay(guid: String): IptvPlayInfo = withContext(Dispatchers.IO) {
val enc = android.net.Uri.encode(guid)
val req = authed("$baseUrl/api/v1/client/viaplay/content/$enc/play").get().build()
parseObject(execute(req)) { o ->
o.toIptvPlayInfo(fallbackStreamId = "viaplay-content:$guid", defaultFormat = "dash")
}
}
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()
@ -1354,14 +1401,36 @@ data class ViaplaySportChip(
val count: Int = 0,
)
data class ViaplayHomeItem(
val id: String,
val guid: String,
val title: String,
val subtitle: String? = null,
val imageUrl: String? = null,
val kind: String = "other",
val openAs: String = "content",
val playable: Boolean = true,
val durationLabel: String? = null,
val start: String? = null,
val badge: String? = null,
)
data class ViaplayHomeRail(
val id: String,
val title: String,
val items: List<ViaplayHomeItem> = emptyList(),
)
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 catchup: List<ScheduleEvent> = emptyList(),
val sports: List<ViaplaySportChip> = emptyList(),
val events: List<ScheduleEvent> = emptyList(),
val rails: List<ViaplayHomeRail> = emptyList(),
)
data class F1tvMeetingDetail(

View file

@ -146,13 +146,18 @@ fun EventPlayerScreen(
audioTracks = emptyList()
textTracks = emptyList()
try {
val play = if (hubPlay) {
val play = when {
hubPlay && eventId.startsWith("viaplay-content:", ignoreCase = true) -> {
val guid = eventId.removePrefix("viaplay-content:").removePrefix("VIAPLAY-CONTENT:")
api.viaplayContentPlay(guid)
}
hubPlay -> {
val contentId = Regex("""(?i)^f1tv:(\d+)$""").find(eventId.trim())
?.groupValues?.getOrNull(1)
?: eventId.trim().removePrefix("f1tv:").removePrefix("F1TV:")
api.f1tvContentPlay(contentId)
} else {
api.scheduleEventPlay(eventId)
}
else -> api.scheduleEventPlay(eventId)
}
if (!isActive) return@LaunchedEffect
title = play.name

View file

@ -142,7 +142,6 @@ fun EventsScreen(
val listState = rememberLazyListState()
val firstRailFocus = remember { FocusRequester() }
val restoreCardFocus = remember { FocusRequester() }
val hubRailFocus = remember { FocusRequester() }
suspend fun refresh() {
runCatching { api.listScheduleEvents() }
@ -158,7 +157,7 @@ fun EventsScreen(
add(EventHubInfo("f1", "Formule 1", "Agenda & terugkijken"))
}
if (onOpenViaplayHub != null) {
add(EventHubInfo("viaplay", "Viaplay", "Live & agenda"))
add(EventHubInfo("viaplay", "Viaplay", "Sport · live · terugkijken"))
}
}
}
@ -255,7 +254,7 @@ fun EventsScreen(
val filterBarFocus = remember { FocusRequester() }
val firstRailUpTarget = when {
showFilterBar && filterBarFocusReady -> filterBarFocus
hasHubRail && hubRailFocusReady -> hubRailFocus
hasHubRail && hubRailFocusReady -> contentFocusRequester
else -> navFocusRequester
}
val filterFocusDown: FocusRequester? = when {
@ -348,11 +347,12 @@ fun EventsScreen(
showFilterBar -> filterBarFocusReady
else -> railFocusReady
}
// contentFocusRequester zit sticky op hub-rail (buiten LazyColumn) of filter/sink.
// Nooit: binden → unbinden → opnieuw (dat gooit focus terug naar TopNav).
val needContentSink = when {
loading && events.isEmpty() -> true
error != null && events.isEmpty() -> true
// contentFocusRequester mag nooit "los" staan (TopNav.down → crash)
hasHubRail -> !hubRailFocusReady
hasHubRail -> false
showFilterBar -> !filterBarFocusReady
!hasRails -> true
else -> !railFocusReady
@ -499,20 +499,13 @@ fun EventsScreen(
}
}
else -> {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 56.dp),
) {
Column(Modifier.fillMaxSize()) {
// Sticky hub-rail BUITEN LazyColumn: contentFocusRequester blijft stabiel gebonden
// (LazyColumn-item unbindt bij scroll/recompose → focus terug naar TopNav).
if (hasHubRail) {
item(key = "hub_rail") {
// Zolang TopNav-sink contentFocusRequester vasthoudt → hubRailFocus op tile.
// Zodra ready → contentFocusRequester op eerste tile.
val hubEntryFocus =
if (hubRailFocusReady) contentFocusRequester else hubRailFocus
EventsHubRail(
hubs = hubs,
focusRequester = hubEntryFocus,
focusRequester = contentFocusRequester,
focusUp = navFocusRequester,
onOpenF1 = onOpenF1Hub,
onOpenViaplay = onOpenViaplayHub,
@ -520,7 +513,13 @@ fun EventsScreen(
onFocus = onContentFocused,
)
}
}
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentPadding = PaddingValues(bottom = 56.dp),
) {
// Eén filterrij (sport + bron) — altijd aanwezig bij data zodat focus stabiel blijft.
if (showFilterBar) {
item(key = "filters") {
@ -535,11 +534,7 @@ fun EventsScreen(
liveCount = live.size,
totalCount = filtered.size,
firstFocusRequester = if (hasHubRail) filterBarFocus else contentFocusRequester,
focusUp = when {
hasHubRail && hubRailFocusReady -> contentFocusRequester
hasHubRail -> null
else -> navFocusRequester
},
focusUp = if (hasHubRail) contentFocusRequester else navFocusRequester,
focusDown = filterFocusDown,
onFocusReady = { filterBarFocusReady = it },
onFocus = onContentFocused,
@ -663,6 +658,7 @@ fun EventsScreen(
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
@ -1127,43 +1123,70 @@ private fun EventsHubRail(
onFocusReady: ((Boolean) -> Unit)? = null,
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) {
if (hubs.isEmpty()) return
fun accentFor(id: String) = when (id) {
"f1" -> Color(0xFFE10600)
"viaplay" -> Color(0xFF8B5CF6)
else -> McColors.Accent
}
val badge = when (hub.id) {
fun badgeFor(hub: EventHubInfo) = when (hub.id) {
"f1" -> "F1"
"viaplay" -> "VP"
else -> hub.label.take(2).uppercase()
}
val onOpen = when (hub.id) {
fun openFor(id: String): (() -> Unit)? = when (id) {
"f1" -> onOpenF1
"viaplay" -> onOpenViaplay
else -> null
} ?: return@itemsIndexed
}
// Eerste hub sticky BUITEN LazyRow: contentFocusRequester mag niet unbinden bij scroll.
val first = hubs.first()
val firstOpen = openFor(first.id)
val rest = hubs.drop(1)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp, bottom = 4.dp)
.padding(horizontal = HomeChrome.EdgeInset),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (firstOpen != null) {
HubRailTile(
badge = badge,
badge = badgeFor(first),
label = first.label,
subtitle = first.subtitle,
accent = accentFor(first.id),
focusRequester = focusRequester,
focusUp = focusUp,
onFocusReady = onFocusReady,
onOpen = firstOpen,
onFocus = onFocus,
)
}
if (rest.isNotEmpty()) {
LazyRow(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
itemsIndexed(rest, key = { _, h -> h.id }) { _, hub ->
val onOpen = openFor(hub.id) ?: return@itemsIndexed
HubRailTile(
badge = badgeFor(hub),
label = hub.label,
subtitle = hub.subtitle,
accent = accent,
focusRequester = if (index == 0) focusRequester else null,
accent = accentFor(hub.id),
focusRequester = null,
focusUp = focusUp,
onFocusReady = if (index == 0) onFocusReady else null,
onFocusReady = null,
onOpen = onOpen,
onFocus = onFocus,
)
}
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable

View file

@ -601,6 +601,31 @@ fun McNav() {
launchSingleTop = true
}
},
onPlayContent = { guid ->
nav.navigate("viaplay_play/${android.net.Uri.encode(guid)}") {
launchSingleTop = true
}
},
)
}
composable(
"viaplay_play/{guid}",
arguments = listOf(navArgument("guid") { type = NavType.StringType }),
) { entry ->
val guid = entry.arguments?.getString("guid")
?.let { android.net.Uri.decode(it) }
?: return@composable
DisposableEffect(Unit) {
onDispose {
pendingEventsFocusRestore = true
eventsReturnNonce++
}
}
EventPlayerScreen(
api = api,
eventId = "viaplay-content:$guid",
hubPlay = true,
onBack = { nav.popBackStack() },
)
}
composable(

View file

@ -13,7 +13,6 @@ 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
@ -32,7 +31,6 @@ 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
@ -52,6 +50,7 @@ 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.ViaplayHomeItem
import nl.vonas.mediacluster.tv.data.ViaplayHomePayload
import nl.vonas.mediacluster.tv.ui.components.HomeChrome
import nl.vonas.mediacluster.tv.ui.theme.McColors
@ -83,6 +82,7 @@ fun ViaplayHubScreen(
api: ApiClient,
onBack: () -> Unit,
onOpenEvent: (String) -> Unit,
onPlayContent: (String) -> Unit = {},
) {
var home by remember { mutableStateOf<ViaplayHomePayload?>(null) }
var loading by remember { mutableStateOf(true) }
@ -128,6 +128,14 @@ fun ViaplayHubScreen(
return filter { it.sportBucket.equals(f, ignoreCase = true) }
}
fun onItem(item: ViaplayHomeItem) {
if (!item.playable) return
when (item.openAs.lowercase(Locale.US)) {
"event" -> onOpenEvent(item.id)
else -> onPlayContent(item.guid)
}
}
Box(
Modifier
.fillMaxSize()
@ -170,9 +178,12 @@ fun ViaplayHubScreen(
val today = payload.today.sportFiltered()
val weekend = payload.weekend.sportFiltered()
val later = payload.later.sportFiltered()
val catchup = payload.catchup.sportFiltered()
val sports = payload.sports
val hasAny = live.isNotEmpty() || today.isNotEmpty() ||
weekend.isNotEmpty() || later.isNotEmpty()
val rails = payload.rails
val hasSchedule = live.isNotEmpty() || today.isNotEmpty() ||
weekend.isNotEmpty() || later.isNotEmpty() || catchup.isNotEmpty()
val hasAny = hasSchedule || rails.isNotEmpty()
LazyColumn(
modifier = Modifier.fillMaxSize(),
@ -190,13 +201,13 @@ fun ViaplayHubScreen(
VpChip("← Events", focusRequester = firstFocus, onClick = onBack)
Column(Modifier.weight(1f)) {
Text(
"Viaplay",
"Viaplay Sport",
color = VpLook.White,
fontSize = 28.sp,
fontWeight = FontWeight.Black,
)
Text(
"Live · vandaag · weekend",
"Live · agenda · samenvattingen · docs",
color = VpLook.Mist,
fontSize = 14.sp,
)
@ -242,7 +253,7 @@ fun ViaplayHubScreen(
contentAlignment = Alignment.Center,
) {
Text(
"Geen Viaplay-events in deze periode",
"Geen Viaplay-content beschikbaar",
color = VpLook.Mist,
fontSize = 16.sp,
)
@ -252,7 +263,7 @@ fun ViaplayHubScreen(
if (live.isNotEmpty()) {
item(key = "live") {
VpRail(
VpEventRail(
title = "Nu live",
accent = VpLook.Live,
events = live,
@ -263,7 +274,7 @@ fun ViaplayHubScreen(
}
if (today.isNotEmpty()) {
item(key = "today") {
VpRail(
VpEventRail(
title = "Vandaag",
accent = VpLook.Accent,
events = today,
@ -274,7 +285,7 @@ fun ViaplayHubScreen(
}
if (weekend.isNotEmpty()) {
item(key = "weekend") {
VpRail(
VpEventRail(
title = "Weekend",
accent = VpLook.Accent,
events = weekend,
@ -285,7 +296,7 @@ fun ViaplayHubScreen(
}
if (later.isNotEmpty()) {
item(key = "later") {
VpRail(
VpEventRail(
title = "Later",
accent = VpLook.Mist,
events = later,
@ -294,6 +305,29 @@ fun ViaplayHubScreen(
)
}
}
if (catchup.isNotEmpty()) {
item(key = "catchup") {
VpEventRail(
title = "Terugkijken",
accent = VpLook.Accent,
events = catchup,
nowMs = nowMs,
onOpen = onOpenEvent,
)
}
}
// Officiële sport-sectie planken (HAR: /sport)
rails.forEach { rail ->
item(key = "rail-${rail.id}") {
VpCatalogRail(
title = rail.title,
accent = VpLook.Accent,
items = rail.items,
onOpen = ::onItem,
)
}
}
}
}
}
@ -302,7 +336,7 @@ fun ViaplayHubScreen(
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
private fun VpRail(
private fun VpEventRail(
title: String,
accent: Color,
events: List<ScheduleEvent>,
@ -329,6 +363,34 @@ private fun VpRail(
}
}
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
private fun VpCatalogRail(
title: String,
accent: Color,
items: List<ViaplayHomeItem>,
onOpen: (ViaplayHomeItem) -> 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(items, key = { _, e -> e.id }) { _, item ->
VpCatalogCard(item = item, onOpen = { onOpen(item) })
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
private fun VpEventCard(
@ -338,6 +400,7 @@ private fun VpEventCard(
) {
var focused by remember { mutableStateOf(false) }
val isLive = event.phase.equals("live", ignoreCase = true) || event.isLive
val isCatchup = event.phase.equals("ended", ignoreCase = true)
Surface(
onClick = onOpen,
modifier = Modifier
@ -377,9 +440,14 @@ private fun VpEventCard(
modifier = Modifier.fillMaxSize(),
)
}
if (isLive) {
val badge = when {
isLive -> "LIVE"
isCatchup -> "TERUGKIJKEN"
else -> null
}
if (badge != null) {
Text(
"LIVE",
badge,
color = Color.White,
fontSize = 11.sp,
fontWeight = FontWeight.Black,
@ -387,7 +455,7 @@ private fun VpEventCard(
.align(Alignment.TopStart)
.padding(10.dp)
.clip(RoundedCornerShape(4.dp))
.background(VpLook.Live)
.background(if (isLive) VpLook.Live else VpLook.AccentDeep)
.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
@ -417,6 +485,95 @@ private fun VpEventCard(
}
}
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
private fun VpCatalogCard(
item: ViaplayHomeItem,
onOpen: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
Surface(
onClick = onOpen,
enabled = item.playable,
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)),
) {
if (!item.imageUrl.isNullOrBlank()) {
AsyncImage(
model = item.imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
val badge = item.badge
if (!badge.isNullOrBlank()) {
Text(
badge,
color = Color.White,
fontSize = 11.sp,
fontWeight = FontWeight.Black,
modifier = Modifier
.align(Alignment.TopStart)
.padding(10.dp)
.clip(RoundedCornerShape(4.dp))
.background(
when {
badge.equals("LIVE", true) -> VpLook.Live
else -> VpLook.AccentDeep
},
)
.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
Column(Modifier.padding(12.dp)) {
Text(
item.title,
color = VpLook.White,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(4.dp))
Text(
listOfNotNull(item.subtitle, item.durationLabel).joinToString(" · "),
color = VpLook.Mist,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun VpChip(

View file

@ -307,7 +307,16 @@ function pickImage(content: Record<string, unknown> | null): string | null {
if (!content) return null;
const images = asRecord(content.images);
if (!images) return null;
for (const key of ["landscape", "boxart", "cover", "hero"]) {
for (const key of [
"landscape",
"coverart169",
"hero169",
"boxart",
"coverart23",
"cover",
"hero",
"event169",
]) {
const img = asRecord(images[key]);
const url = typeof img?.url === "string" ? img.url : null;
if (url) return url;
@ -384,6 +393,226 @@ export async function fetchSportHighlights(
return extractProducts(res.json);
}
/** Catalog-item van de Viaplay sport-sectie (plank/rail). */
export type ViaplaySportRailItem = {
guid: string;
title: string;
subtitle: string | null;
imageUrl: string | null;
/** sport | clip | series | other */
kind: "sport" | "clip" | "series" | "other";
/** Speelbaar via play-live (sport/EPG) of play.viaplay.com/byguid (clip/series). */
playMode: "live" | "vod" | null;
durationLabel: string | null;
startMs: number | null;
endMs: number | null;
pageUrl: string | null;
};
export type ViaplaySportRail = {
id: string;
title: string;
items: ViaplaySportRailItem[];
};
function streamHref(product: Record<string, unknown>): string | null {
const links = asRecord(product._links);
const stream = asRecord(links?.["viaplay:stream"]);
const href = typeof stream?.href === "string" ? stream.href : null;
return href?.trim() || null;
}
function parseDurationLabel(content: Record<string, unknown> | null): string | null {
const duration = asRecord(content?.duration);
if (typeof duration?.readable === "string" && duration.readable.trim()) {
return duration.readable.trim();
}
const ms = Number(duration?.milliseconds);
if (!Number.isFinite(ms) || ms <= 0) return null;
const totalSec = Math.round(ms / 1000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
if (m >= 60) {
const h = Math.floor(m / 60);
return `${h}:${String(m % 60).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
return `${m}:${String(s).padStart(2, "0")}`;
}
function productTitle(content: Record<string, unknown> | null, guid: string): string {
if (!content) return guid;
if (typeof content.title === "string" && content.title.trim()) return content.title.trim();
const series = asRecord(content.series);
if (typeof series?.title === "string" && series.title.trim()) return series.title.trim();
if (typeof content.originalTitle === "string" && content.originalTitle.trim()) {
return content.originalTitle.trim();
}
return guid;
}
function productToRailItem(node: Record<string, unknown>): ViaplaySportRailItem | null {
const system = asRecord(node.system);
const content = asRecord(node.content);
const guid = String(system?.guid ?? "").trim();
if (!guid) return null;
const type = String(node.type ?? "").trim().toLowerCase();
const epg = asRecord(node.epg);
const startMs = epg ? parseIsoMs(epg.start ?? epg.startTime) : null;
const endMs = epg ? parseIsoMs(epg.end ?? epg.endTime ?? epg.stop) : null;
const href = streamHref(node);
const links = asRecord(node._links);
const page =
(typeof asRecord(links?.["viaplay:page"])?.href === "string"
? String(asRecord(links!["viaplay:page"])!.href)
: null) ||
(typeof asRecord(links?.self)?.href === "string"
? String(asRecord(links!.self)!.href)
: null);
let kind: ViaplaySportRailItem["kind"] = "other";
if (type === "sport") kind = "sport";
else if (type === "clip") kind = "clip";
else if (type === "series" || type === "sportseries" || type === "episode") kind = "series";
else if (type === "movie" || type === "film") kind = "series"; // docs/specials → VOD byguid
let playMode: ViaplaySportRailItem["playMode"] = null;
if (href?.includes("play-live.viaplay.com") || (kind === "sport" && startMs != null)) {
playMode = "live";
} else if (
href?.includes("play.viaplay.com/api/stream/byguid") ||
kind === "clip" ||
(kind === "series" && type !== "sportseries")
) {
// sportSeries is vaak een categorie-pagina zonder directe stream
playMode = "vod";
} else if (type === "movie" || type === "film") {
playMode = "vod";
}
const title = productTitle(content, guid);
const subtitle =
String(
content?.originalTitle ?? content?.synopsis ?? content?.description ?? ""
).trim() || null;
return {
guid,
title,
subtitle: subtitle === title ? null : subtitle,
imageUrl: pickImage(content),
kind,
playMode,
durationLabel: parseDurationLabel(content),
startMs,
endMs,
pageUrl: page,
};
}
function blocksFromPage(json: unknown): Array<Record<string, unknown>> {
const root = asRecord(json);
const embedded = asRecord(root?._embedded);
const blocks = embedded?.["viaplay:blocks"];
if (!Array.isArray(blocks)) return [];
const out: Array<Record<string, unknown>> = [];
for (const b of blocks) {
const rec = asRecord(b);
if (rec) out.push(rec);
}
return out;
}
function productsFromBlock(block: Record<string, unknown>): ViaplaySportRailItem[] {
const embedded = asRecord(block._embedded);
const products = embedded?.["viaplay:products"];
if (!Array.isArray(products)) return [];
const out: ViaplaySportRailItem[] = [];
const seen = new Set<string>();
for (const raw of products) {
const node = asRecord(raw);
if (!node) continue;
const item = productToRailItem(node);
if (!item || seen.has(item.guid)) continue;
seen.add(item.guid);
out.push(item);
}
return out;
}
async function fetchSportBlockPage(
deviceKey: string,
blockId: string,
pageNumber: number,
timeoutMs: number,
creds?: ViaplayAccountCreds | null
): Promise<ViaplaySportRailItem[]> {
const u = new URL(`https://content.viaplay.com/${deviceKey}/sport`);
u.searchParams.set("blockId", blockId);
u.searchParams.set("partial", "1");
u.searchParams.set("pageNumber", String(pageNumber));
u.searchParams.set("language", "nl");
const headers: Record<string, string> = {
Accept: "*/*",
"User-Agent": VIAPLAY_USER_AGENT,
Referer: "https://viaplay.com/sport",
};
if (creds) Object.assign(headers, authHeaders(creds));
const res = await fetchJson(u.toString(), { headers, timeoutMs });
if (res.status !== 200) return [];
const blocks = blocksFromPage(res.json);
const block = blocks[0] || asRecord(res.json);
return block ? productsFromBlock(block) : [];
}
/**
* Officiële Viaplay sport-sectie als hub-planken (HAR: /pcdash-nl/sport).
* Lege list-blocks worden 1× bijgeladen via blockId&partial=1.
*/
export async function fetchSportRails(
deviceKey: string,
timeoutMs: number,
creds?: ViaplayAccountCreds | null
): Promise<ViaplaySportRail[]> {
const url = `https://content.viaplay.com/${deviceKey}/sport?language=nl`;
const headers: Record<string, string> = {
Accept: "*/*",
"User-Agent": VIAPLAY_USER_AGENT,
Referer: "https://viaplay.com/sport",
};
if (creds) Object.assign(headers, authHeaders(creds));
const res = await fetchJson(url, { headers, timeoutMs });
if (res.status !== 200) {
throw new Error(`Viaplay sport-sectie mislukt (${res.status})`);
}
const skipTitles = /^(favorites|schema|featurebox)/i;
const rails: ViaplaySportRail[] = [];
for (const block of blocksFromPage(res.json)) {
const type = String(block.type ?? "").trim().toLowerCase();
if (type !== "list" && type !== "singleproductpromo") continue;
const id = String(block.id ?? "").trim();
const title = String(block.title ?? block.profileBlockTitle ?? "").trim();
if (!id || !title || skipTitles.test(title)) continue;
let items = productsFromBlock(block);
if (!items.length) {
try {
items = await fetchSportBlockPage(deviceKey, id, 1, timeoutMs, creds);
} catch {
items = [];
}
}
// Promo zonder embedded products: skip
if (!items.length) continue;
// Alleen speelbare items (geen categorie-tegels zoals sportSeries)
const playable = items.filter((it) => it.playMode != null).slice(0, 40);
if (!playable.length) continue;
rails.push({ id, title, items: playable });
}
return rails;
}
function extractProducts(json: unknown): ViaplayEpgProduct[] {
const out: ViaplayEpgProduct[] = [];
const seen = new Set<string>();
@ -449,6 +678,13 @@ export async function playByMediaGuid(
`play-live mislukt (${res.status})`;
throw new Error(msg);
}
return parsePlayStreamBody(body, mediaGuid);
}
function parsePlayStreamBody(
body: Record<string, unknown>,
id: string
): ViaplayPlayResult {
const links = asRecord(body._links);
const media = asRecord(links?.["viaplay:media"]);
const license =
@ -458,7 +694,7 @@ export async function playByMediaGuid(
const product = asRecord(body.product);
const channel = asRecord(body.channel) || asRecord(asRecord(body.streamingSession)?.channel);
return {
mediaGuid,
mediaGuid: id,
mpdUrl,
licenseUrl: typeof license?.href === "string" ? license.href : null,
licenseAuth: typeof license?.auth === "string" ? license.auth : null,
@ -471,6 +707,41 @@ export async function playByMediaGuid(
};
}
/**
* VOD / clips / samenvattingen / docs — HAR: play.viaplay.com/api/stream/byguid
* (live sport blijft play-live …/bymediaguid).
*/
export async function playByGuid(
creds: ViaplayAccountCreds,
guidRaw: string,
opts: { deviceKey: string; timeoutMs: number }
): Promise<ViaplayPlayResult> {
const guid = String(guidRaw || "").trim();
if (!guid) throw new Error("Geen Viaplay guid");
const u = new URL("https://play.viaplay.com/api/stream/byguid");
u.searchParams.set("deviceId", creds.deviceId || randomUUID());
u.searchParams.set("deviceName", "web");
u.searchParams.set("deviceType", "pc");
u.searchParams.set("userAgent", VIAPLAY_USER_AGENT);
u.searchParams.set("deviceKey", opts.deviceKey);
u.searchParams.set("guid", guid);
u.searchParams.set("defaultAvailabilityContext", "svod");
if (creds.profileId) u.searchParams.set("profileId", creds.profileId);
const res = await fetchJson(u.toString(), {
headers: authHeaders(creds),
timeoutMs: opts.timeoutMs,
});
const body = asRecord(res.json);
if (res.status !== 200 || !body) {
const msg =
(typeof body?.message === "string" && body.message) ||
`play-byguid mislukt (${res.status})`;
throw new Error(msg);
}
return parsePlayStreamBody(body, guid);
}
/** Bouw MPD-URL met EPG start/stop kap (seconden). */
export function kapMpdUrl(
mpdUrl: string,

View file

@ -1,6 +1,17 @@
import type { ScheduleEventPublic } from "../viewer/schedule-events";
import { loadViaplayConfigOnly } from "./settings";
import {
fetchSportRails,
type ViaplaySportRail,
type ViaplaySportRailItem,
} from "./client";
import {
loadViaplayConfigOnly,
} from "./settings";
import type { ViaplayEventSnapshot } from "./tweaks";
import { toMediaGuid } from "./accounts";
/** Officieel Viaplay-terugkijken venster (~48u na start/einde). */
export const VIAPLAY_CATCHUP_MS = 48 * 60 * 60_000;
function phaseFor(startMs: number, endMs: number, nowMs: number): "live" | "upcoming" | "ended" {
if (nowMs < startMs) return "upcoming";
@ -22,6 +33,7 @@ export function eventToPublic(
nowMs: number
): ScheduleEventPublic {
const phase = phaseFor(event.startMs, event.endMs, nowMs);
const catchupOk = phase === "ended" && nowMs <= event.endMs + VIAPLAY_CATCHUP_MS;
return {
id: toEventId(event.mediaGuid),
name: event.title,
@ -34,7 +46,7 @@ export function eventToPublic(
network: "Viaplay",
isLive: phase === "live",
phase,
playable: phase === "live",
playable: phase === "live" || catchupOk,
status: phase,
start: new Date(event.startMs).toISOString(),
stop: new Date(event.endMs).toISOString(),
@ -60,6 +72,7 @@ export async function listViaplayScheduleEvents(
const horizon = nowMs + aheadMs;
const out: ScheduleEventPublic[] = [];
for (const event of config.events) {
// Hub/Events: live + upcoming; catch-up zit in Viaplay-hub rails / detail play.
if (event.endMs < nowMs) continue;
if (event.startMs > horizon) continue;
const pub = eventToPublic(event, nowMs);
@ -89,6 +102,27 @@ export type ViaplaySportChip = {
count: number;
};
export type ViaplayHomeItem = {
id: string;
guid: string;
title: string;
subtitle: string | null;
imageUrl: string | null;
kind: ViaplaySportRailItem["kind"];
/** event = schedule detail; content = VOD/clip play */
openAs: "event" | "content";
playable: boolean;
durationLabel: string | null;
start: string | null;
badge: string | null;
};
export type ViaplayHomeRail = {
id: string;
title: string;
items: ViaplayHomeItem[];
};
export type ViaplayHomePayload = {
generatedAt: string;
/** Nu live */
@ -99,10 +133,14 @@ export type ViaplayHomePayload = {
weekend: ScheduleEventPublic[];
/** Upcoming daarna binnen sync-horizon */
later: ScheduleEventPublic[];
/** Recent afgelopen binnen catch-up venster */
catchup: ScheduleEventPublic[];
/** Sport-chips aanwezig in de payload */
sports: ViaplaySportChip[];
/** Alle niet-ended events (voor client-side sportfilter) */
events: ScheduleEventPublic[];
/** Officiële sport-sectie planken (samenvattingen, docs, …) */
rails: ViaplayHomeRail[];
};
const ZONE_NL = "Europe/Amsterdam";
@ -139,7 +177,66 @@ function isWeekendLocal(ms: number): boolean {
return d === 5 || d === 6 || d === 0; // vr za zo
}
/** Hub: tijd-rails + sport-meta. Live blijft ook in Events; hier voor dieper browsen. */
function railItemToHome(item: ViaplaySportRailItem, nowMs: number): ViaplayHomeItem {
const isEvent = item.kind === "sport" && item.playMode === "live";
const openAs: ViaplayHomeItem["openAs"] = isEvent ? "event" : "content";
const id =
openAs === "event" ? toEventId(toMediaGuid(item.guid)) : `viaplay-content:${item.guid}`;
let badge: string | null = null;
if (item.startMs != null && item.endMs != null) {
const phase = phaseFor(item.startMs, item.endMs, nowMs);
if (phase === "live") badge = "LIVE";
else if (phase === "ended") badge = "TERUGKIJKEN";
} else if (item.kind === "clip") {
badge = item.durationLabel || "CLIP";
} else if (item.kind === "series") {
badge = "SERIE";
}
return {
id,
guid: item.guid,
title: item.title,
subtitle: item.subtitle,
imageUrl: item.imageUrl,
kind: item.kind,
openAs,
playable: item.playMode != null,
durationLabel: item.durationLabel,
start: item.startMs != null ? new Date(item.startMs).toISOString() : null,
badge,
};
}
function mapSportRails(rails: ViaplaySportRail[], nowMs: number): ViaplayHomeRail[] {
return rails.map((rail) => ({
id: rail.id,
title: rail.title,
items: rail.items.map((item) => railItemToHome(item, nowMs)),
}));
}
let sportRailsCache: { at: number; rails: ViaplayHomeRail[] } | null = null;
const SPORT_RAILS_TTL_MS = 5 * 60_000;
async function loadSportRailsHome(nowMs: number): Promise<ViaplayHomeRail[]> {
if (sportRailsCache && nowMs - sportRailsCache.at < SPORT_RAILS_TTL_MS) {
return sportRailsCache.rails;
}
try {
const config = await loadViaplayConfigOnly();
if (!config) return sportRailsCache?.rails ?? [];
const raw = await fetchSportRails(config.deviceKey, config.httpTimeoutMs);
const rails = mapSportRails(raw, nowMs);
sportRailsCache = { at: nowMs, rails };
return rails;
} catch {
return sportRailsCache?.rails ?? [];
}
}
/** Hub: tijd-rails + officiële sport-planken (samenvattingen/docs). */
export async function getViaplayHome(nowMs = Date.now()): Promise<ViaplayHomePayload> {
const config = await loadViaplayConfigOnly();
const empty: ViaplayHomePayload = {
@ -148,25 +245,32 @@ export async function getViaplayHome(nowMs = Date.now()): Promise<ViaplayHomePay
today: [],
weekend: [],
later: [],
catchup: [],
sports: [],
events: [],
rails: [],
};
if (!config?.events?.length) return empty;
if (!config) 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;
const catchup: ScheduleEventPublic[] = [];
for (const event of config.events || []) {
if (event.startMs > horizon) continue;
const pub = eventToPublic(event, nowMs);
if (pub.phase === "ended") continue;
if (pub.phase === "ended") {
if (pub.playable) catchup.push(pub);
continue;
}
if (event.endMs < nowMs) continue;
all.push(pub);
}
all.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
catchup.sort((a, b) => Date.parse(b.start) - Date.parse(a.start));
const live = all.filter((e) => e.phase === "live");
const upcoming = all.filter((e) => e.phase === "upcoming");
@ -193,7 +297,7 @@ export async function getViaplayHome(nowMs = Date.now()): Promise<ViaplayHomePay
}
const sportCounts = new Map<string, { label: string; count: number }>();
for (const e of all) {
for (const e of [...all, ...catchup.slice(0, 40)]) {
const id = e.sportBucket || "other";
const label = e.sportLabel || id;
const cur = sportCounts.get(id) ?? { label, count: 0 };
@ -205,13 +309,17 @@ export async function getViaplayHome(nowMs = Date.now()): Promise<ViaplayHomePay
.map(([id, v]) => ({ id, label: v.label, count: v.count }))
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, "nl"));
const rails = await loadSportRailsHome(nowMs);
return {
generatedAt: new Date(nowMs).toISOString(),
live,
today,
weekend,
later,
catchup: catchup.slice(0, 40),
sports,
events: all,
rails,
};
}

View file

@ -7,8 +7,11 @@ import {
} from "./accounts";
import { extractViaplayKeys } from "./cdm";
import {
playByGuid,
playByMediaGuid,
resolveKapMpd,
fetchMpdXml,
extractPsshFromMpd,
VIAPLAY_USER_AGENT,
} from "./client";
import {
@ -17,6 +20,7 @@ import {
} from "./settings";
import { findViaplayEvent } from "./sync";
import { isMpdCacheFresh, type ViaplayEventSnapshot } from "./tweaks";
import { VIAPLAY_CATCHUP_MS } from "./events";
function parseKey(value: string): { kid: string; key: string } | null {
const [kid, key] = value.trim().toLowerCase().split(":", 2);
@ -249,9 +253,102 @@ export async function resolveViaplayPlay(
if (!event) throw new AppError("NOT_FOUND", "Viaplay-event niet gevonden", 404);
const now = Date.now();
if (now > event.endMs + 15 * 60_000) {
// Catch-up: officieel ~48u; live-pad (play-live + KAP) blijft na einde bruikbaar.
if (now > event.endMs + VIAPLAY_CATCHUP_MS) {
throw new AppError("GONE", "Dit event is afgelopen", 410);
}
// Pre-live: wel MPD/keys toestaan binnen preLiveMinutes; viewer play blijft live-only
return ensureViaplayEventReady(sessionSecret, event);
}
/**
* VOD/clip/doc play via play.viaplay.com/byguid (HAR sport-samenvattingen).
*/
export async function resolveViaplayGuidPlay(
sessionSecret: string,
guidRaw: string
): Promise<ResolveViaplayPlayResult> {
const guid = String(guidRaw || "").trim();
if (!guid) throw new AppError("BAD_REQUEST", "Geen Viaplay guid", 400);
const settings = await loadViaplaySettings(sessionSecret);
const tweaks = settings.config;
const accounts = settings.accounts;
if (!accounts.length) {
throw new AppError("FAILED_PRECONDITION", "Geen Viaplay-account", 400);
}
let cachedKeys = await keysForMediaGuid(sessionSecret, guid);
let lastError: Error | null = null;
for (const account of accounts) {
try {
const play = await playByGuid(account, guid, {
deviceKey: tweaks.deviceKey,
timeoutMs: tweaks.httpTimeoutMs,
});
let keys = cachedKeys;
const wantKeys = tweaks.fetchKeysIfMissing;
let pssh: string | null = null;
let mpdUrl = play.mpdUrl;
try {
const mpd = await fetchMpdXml(play.mpdUrl, tweaks.httpTimeoutMs);
if (mpd.status === 200 && /<MPD[\s>]/i.test(mpd.xml)) {
pssh = extractPsshFromMpd(mpd.xml);
mpdUrl = mpd.url;
}
} catch {
/* speel base MPD */
}
if (wantKeys && pssh && play.licenseUrl && !keys.length) {
const extracted = await extractViaplayKeys({
wvdPath: settings.wvdPath,
pssh,
licUrl: play.licenseUrl,
licAuth: play.licenseAuth,
userAgent: VIAPLAY_USER_AGENT,
timeoutMs: tweaks.cdmTimeoutMs,
});
const parsed = extracted
.map(parseKey)
.filter((k): k is { kid: string; key: string } => !!k);
if (parsed.length) {
keys = parsed;
cachedKeys = parsed;
const { upsertDrmKey } = await import("../drm/keys");
for (const k of parsed) {
try {
await upsertDrmKey(prisma, sessionSecret, {
kid: k.kid,
key: k.key,
mpdUrl,
eventName: play.title || guid,
channelName: play.channelName,
source: "viaplay",
externalId: guid,
mediaGuid: guid,
});
} catch {
/* ignore */
}
}
}
}
return {
streamUrl: mpdUrl,
format: "dash",
keys,
fromCache: false,
mediaGuid: guid,
accountId: account.id,
};
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
}
}
throw lastError || new AppError("UPSTREAM", "Viaplay VOD-play mislukt", 502);
}

View file

@ -133,8 +133,8 @@ export async function syncViaplayCatalog(_sessionSecret?: string) {
events.push(toSnapshot(product, prev, owners));
}
// Drop ended events older than 6h; also drop further than sync horizon+1d
const cutoff = now - 6 * 3_600_000;
// Bewaar catch-up tot ~48u na einde (officieel Viaplay-venster)
const cutoff = now - 48 * 3_600_000;
const horizon = now + config.syncDaysAhead * 86_400_000 + 86_400_000;
const kept = events.filter((e) => e.endMs >= cutoff && e.startMs <= horizon);
kept.sort((a, b) => a.startMs - b.startMs);

View file

@ -964,7 +964,7 @@ export function registerViewerRoutes(
hubs.push({
id: "viaplay",
label: "Viaplay",
subtitle: "Live & agenda",
subtitle: "Sport · live · terugkijken",
});
}
} catch {
@ -1032,6 +1032,64 @@ export function registerViewerRoutes(
};
});
app.get("/api/v1/client/viaplay/content/:guid/play", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const { guid: rawGuid } = request.params as { guid: string };
const guid = decodeURIComponent(rawGuid || "").trim();
if (!guid) throw new AppError("INVALID_REQUEST", "Ongeldige Viaplay guid", 400);
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);
if (s.liveEventsDisabledProviders.map((p) => p.toLowerCase()).includes("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 { resolveViaplayGuidPlay } = await import("../viaplay/play");
const played = await resolveViaplayGuidPlay(config.SESSION_SECRET, guid);
let watchSessionId: string | null = null;
try {
const { touchClientWatch } = await import("./client-watches");
const watch = touchClientWatch({
viewerUserId: auth.viewerId,
kind: "event",
resourceId: `viaplay-content:${guid}`,
title: guid,
provider: "viaplay",
});
watchSessionId = watch.id;
} catch {
/* best-effort */
}
return {
eventId: `viaplay-content:${guid}`,
name: guid,
logoUrl: null,
streamUrl: played.streamUrl,
format: played.format,
fallbackStreamUrl: null,
fallbackFormat: null,
drm: played.keys.length
? {
type: "clearkey",
keys: played.keys.map((k) => ({ kid: k.kid, key: k.key })),
}
: null,
watchSessionId,
};
});
/**
* Lichte presence-heartbeat voor Live Events / custom live-lists.
* Fire-and-forget vanaf de client; failures mogen genegeerd worden.