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" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 145 versionCode = 147
versionName = "0.14.82" versionName = "0.14.84"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") 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( ViaplayHomePayload(
generatedAt = o.optClean("generatedAt"), generatedAt = o.optClean("generatedAt"),
live = eventsOf("live"), live = eventsOf("live"),
today = eventsOf("today"), today = eventsOf("today"),
weekend = eventsOf("weekend"), weekend = eventsOf("weekend"),
later = eventsOf("later"), later = eventsOf("later"),
catchup = eventsOf("catchup"),
sports = sports, sports = sports,
events = eventsOf("events"), 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) { 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()
@ -1354,14 +1401,36 @@ data class ViaplaySportChip(
val count: Int = 0, 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( data class ViaplayHomePayload(
val generatedAt: String? = null, val generatedAt: String? = null,
val live: List<ScheduleEvent> = emptyList(), val live: List<ScheduleEvent> = emptyList(),
val today: List<ScheduleEvent> = emptyList(), val today: List<ScheduleEvent> = emptyList(),
val weekend: List<ScheduleEvent> = emptyList(), val weekend: List<ScheduleEvent> = emptyList(),
val later: List<ScheduleEvent> = emptyList(), val later: List<ScheduleEvent> = emptyList(),
val catchup: List<ScheduleEvent> = emptyList(),
val sports: List<ViaplaySportChip> = emptyList(), val sports: List<ViaplaySportChip> = emptyList(),
val events: List<ScheduleEvent> = emptyList(), val events: List<ScheduleEvent> = emptyList(),
val rails: List<ViaplayHomeRail> = emptyList(),
) )
data class F1tvMeetingDetail( data class F1tvMeetingDetail(

View file

@ -146,13 +146,18 @@ fun EventPlayerScreen(
audioTracks = emptyList() audioTracks = emptyList()
textTracks = emptyList() textTracks = emptyList()
try { 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()) val contentId = Regex("""(?i)^f1tv:(\d+)$""").find(eventId.trim())
?.groupValues?.getOrNull(1) ?.groupValues?.getOrNull(1)
?: eventId.trim().removePrefix("f1tv:").removePrefix("F1TV:") ?: eventId.trim().removePrefix("f1tv:").removePrefix("F1TV:")
api.f1tvContentPlay(contentId) api.f1tvContentPlay(contentId)
} else { }
api.scheduleEventPlay(eventId) else -> api.scheduleEventPlay(eventId)
} }
if (!isActive) return@LaunchedEffect if (!isActive) return@LaunchedEffect
title = play.name title = play.name

View file

@ -142,7 +142,6 @@ fun EventsScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val firstRailFocus = remember { FocusRequester() } val firstRailFocus = remember { FocusRequester() }
val restoreCardFocus = remember { FocusRequester() } val restoreCardFocus = remember { FocusRequester() }
val hubRailFocus = remember { FocusRequester() }
suspend fun refresh() { suspend fun refresh() {
runCatching { api.listScheduleEvents() } runCatching { api.listScheduleEvents() }
@ -158,7 +157,7 @@ fun EventsScreen(
add(EventHubInfo("f1", "Formule 1", "Agenda & terugkijken")) add(EventHubInfo("f1", "Formule 1", "Agenda & terugkijken"))
} }
if (onOpenViaplayHub != null) { 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 filterBarFocus = remember { FocusRequester() }
val firstRailUpTarget = when { val firstRailUpTarget = when {
showFilterBar && filterBarFocusReady -> filterBarFocus showFilterBar && filterBarFocusReady -> filterBarFocus
hasHubRail && hubRailFocusReady -> hubRailFocus hasHubRail && hubRailFocusReady -> contentFocusRequester
else -> navFocusRequester else -> navFocusRequester
} }
val filterFocusDown: FocusRequester? = when { val filterFocusDown: FocusRequester? = when {
@ -348,11 +347,12 @@ fun EventsScreen(
showFilterBar -> filterBarFocusReady showFilterBar -> filterBarFocusReady
else -> railFocusReady 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 { val needContentSink = when {
loading && events.isEmpty() -> true loading && events.isEmpty() -> true
error != null && events.isEmpty() -> true error != null && events.isEmpty() -> true
// contentFocusRequester mag nooit "los" staan (TopNav.down → crash) hasHubRail -> false
hasHubRail -> !hubRailFocusReady
showFilterBar -> !filterBarFocusReady showFilterBar -> !filterBarFocusReady
!hasRails -> true !hasRails -> true
else -> !railFocusReady else -> !railFocusReady
@ -499,20 +499,13 @@ fun EventsScreen(
} }
} }
else -> { else -> {
LazyColumn( Column(Modifier.fillMaxSize()) {
state = listState, // Sticky hub-rail BUITEN LazyColumn: contentFocusRequester blijft stabiel gebonden
modifier = Modifier.fillMaxSize(), // (LazyColumn-item unbindt bij scroll/recompose → focus terug naar TopNav).
contentPadding = PaddingValues(bottom = 56.dp),
) {
if (hasHubRail) { 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( EventsHubRail(
hubs = hubs, hubs = hubs,
focusRequester = hubEntryFocus, focusRequester = contentFocusRequester,
focusUp = navFocusRequester, focusUp = navFocusRequester,
onOpenF1 = onOpenF1Hub, onOpenF1 = onOpenF1Hub,
onOpenViaplay = onOpenViaplayHub, onOpenViaplay = onOpenViaplayHub,
@ -520,7 +513,13 @@ fun EventsScreen(
onFocus = onContentFocused, 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. // Eén filterrij (sport + bron) — altijd aanwezig bij data zodat focus stabiel blijft.
if (showFilterBar) { if (showFilterBar) {
item(key = "filters") { item(key = "filters") {
@ -535,11 +534,7 @@ fun EventsScreen(
liveCount = live.size, liveCount = live.size,
totalCount = filtered.size, totalCount = filtered.size,
firstFocusRequester = if (hasHubRail) filterBarFocus else contentFocusRequester, firstFocusRequester = if (hasHubRail) filterBarFocus else contentFocusRequester,
focusUp = when { focusUp = if (hasHubRail) contentFocusRequester else navFocusRequester,
hasHubRail && hubRailFocusReady -> contentFocusRequester
hasHubRail -> null
else -> navFocusRequester
},
focusDown = filterFocusDown, focusDown = filterFocusDown,
onFocusReady = { filterBarFocusReady = it }, onFocusReady = { filterBarFocusReady = it },
onFocus = onContentFocused, onFocus = onContentFocused,
@ -663,6 +658,7 @@ fun EventsScreen(
} }
} }
} }
}
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -1127,43 +1123,70 @@ private fun EventsHubRail(
onFocusReady: ((Boolean) -> Unit)? = null, onFocusReady: ((Boolean) -> Unit)? = null,
onFocus: () -> Unit, onFocus: () -> Unit,
) { ) {
LazyRow( if (hubs.isEmpty()) return
modifier = Modifier fun accentFor(id: String) = when (id) {
.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) "f1" -> Color(0xFFE10600)
"viaplay" -> Color(0xFF8B5CF6) "viaplay" -> Color(0xFF8B5CF6)
else -> McColors.Accent else -> McColors.Accent
} }
val badge = when (hub.id) { fun badgeFor(hub: EventHubInfo) = when (hub.id) {
"f1" -> "F1" "f1" -> "F1"
"viaplay" -> "VP" "viaplay" -> "VP"
else -> hub.label.take(2).uppercase() else -> hub.label.take(2).uppercase()
} }
val onOpen = when (hub.id) { fun openFor(id: String): (() -> Unit)? = when (id) {
"f1" -> onOpenF1 "f1" -> onOpenF1
"viaplay" -> onOpenViaplay "viaplay" -> onOpenViaplay
else -> null 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( 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, label = hub.label,
subtitle = hub.subtitle, subtitle = hub.subtitle,
accent = accent, accent = accentFor(hub.id),
focusRequester = if (index == 0) focusRequester else null, focusRequester = null,
focusUp = focusUp, focusUp = focusUp,
onFocusReady = if (index == 0) onFocusReady else null, onFocusReady = null,
onOpen = onOpen, onOpen = onOpen,
onFocus = onFocus, onFocus = onFocus,
) )
} }
} }
} }
}
}
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @Composable

View file

@ -601,6 +601,31 @@ fun McNav() {
launchSingleTop = true 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( composable(

View file

@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
@ -32,7 +31,6 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
@ -52,6 +50,7 @@ import coil.compose.AsyncImage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.ScheduleEvent 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.data.ViaplayHomePayload
import nl.vonas.mediacluster.tv.ui.components.HomeChrome import nl.vonas.mediacluster.tv.ui.components.HomeChrome
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
@ -83,6 +82,7 @@ fun ViaplayHubScreen(
api: ApiClient, api: ApiClient,
onBack: () -> Unit, onBack: () -> Unit,
onOpenEvent: (String) -> Unit, onOpenEvent: (String) -> Unit,
onPlayContent: (String) -> Unit = {},
) { ) {
var home by remember { mutableStateOf<ViaplayHomePayload?>(null) } var home by remember { mutableStateOf<ViaplayHomePayload?>(null) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
@ -128,6 +128,14 @@ fun ViaplayHubScreen(
return filter { it.sportBucket.equals(f, ignoreCase = true) } 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( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@ -170,9 +178,12 @@ fun ViaplayHubScreen(
val today = payload.today.sportFiltered() val today = payload.today.sportFiltered()
val weekend = payload.weekend.sportFiltered() val weekend = payload.weekend.sportFiltered()
val later = payload.later.sportFiltered() val later = payload.later.sportFiltered()
val catchup = payload.catchup.sportFiltered()
val sports = payload.sports val sports = payload.sports
val hasAny = live.isNotEmpty() || today.isNotEmpty() || val rails = payload.rails
weekend.isNotEmpty() || later.isNotEmpty() val hasSchedule = live.isNotEmpty() || today.isNotEmpty() ||
weekend.isNotEmpty() || later.isNotEmpty() || catchup.isNotEmpty()
val hasAny = hasSchedule || rails.isNotEmpty()
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -190,13 +201,13 @@ fun ViaplayHubScreen(
VpChip("← Events", focusRequester = firstFocus, onClick = onBack) VpChip("← Events", focusRequester = firstFocus, onClick = onBack)
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text( Text(
"Viaplay", "Viaplay Sport",
color = VpLook.White, color = VpLook.White,
fontSize = 28.sp, fontSize = 28.sp,
fontWeight = FontWeight.Black, fontWeight = FontWeight.Black,
) )
Text( Text(
"Live · vandaag · weekend", "Live · agenda · samenvattingen · docs",
color = VpLook.Mist, color = VpLook.Mist,
fontSize = 14.sp, fontSize = 14.sp,
) )
@ -242,7 +253,7 @@ fun ViaplayHubScreen(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
"Geen Viaplay-events in deze periode", "Geen Viaplay-content beschikbaar",
color = VpLook.Mist, color = VpLook.Mist,
fontSize = 16.sp, fontSize = 16.sp,
) )
@ -252,7 +263,7 @@ fun ViaplayHubScreen(
if (live.isNotEmpty()) { if (live.isNotEmpty()) {
item(key = "live") { item(key = "live") {
VpRail( VpEventRail(
title = "Nu live", title = "Nu live",
accent = VpLook.Live, accent = VpLook.Live,
events = live, events = live,
@ -263,7 +274,7 @@ fun ViaplayHubScreen(
} }
if (today.isNotEmpty()) { if (today.isNotEmpty()) {
item(key = "today") { item(key = "today") {
VpRail( VpEventRail(
title = "Vandaag", title = "Vandaag",
accent = VpLook.Accent, accent = VpLook.Accent,
events = today, events = today,
@ -274,7 +285,7 @@ fun ViaplayHubScreen(
} }
if (weekend.isNotEmpty()) { if (weekend.isNotEmpty()) {
item(key = "weekend") { item(key = "weekend") {
VpRail( VpEventRail(
title = "Weekend", title = "Weekend",
accent = VpLook.Accent, accent = VpLook.Accent,
events = weekend, events = weekend,
@ -285,7 +296,7 @@ fun ViaplayHubScreen(
} }
if (later.isNotEmpty()) { if (later.isNotEmpty()) {
item(key = "later") { item(key = "later") {
VpRail( VpEventRail(
title = "Later", title = "Later",
accent = VpLook.Mist, accent = VpLook.Mist,
events = later, 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) @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @Composable
private fun VpRail( private fun VpEventRail(
title: String, title: String,
accent: Color, accent: Color,
events: List<ScheduleEvent>, 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) @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @Composable
private fun VpEventCard( private fun VpEventCard(
@ -338,6 +400,7 @@ private fun VpEventCard(
) { ) {
var focused by remember { mutableStateOf(false) } var focused by remember { mutableStateOf(false) }
val isLive = event.phase.equals("live", ignoreCase = true) || event.isLive val isLive = event.phase.equals("live", ignoreCase = true) || event.isLive
val isCatchup = event.phase.equals("ended", ignoreCase = true)
Surface( Surface(
onClick = onOpen, onClick = onOpen,
modifier = Modifier modifier = Modifier
@ -377,9 +440,14 @@ private fun VpEventCard(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
if (isLive) { val badge = when {
isLive -> "LIVE"
isCatchup -> "TERUGKIJKEN"
else -> null
}
if (badge != null) {
Text( Text(
"LIVE", badge,
color = Color.White, color = Color.White,
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.Black, fontWeight = FontWeight.Black,
@ -387,7 +455,7 @@ private fun VpEventCard(
.align(Alignment.TopStart) .align(Alignment.TopStart)
.padding(10.dp) .padding(10.dp)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background(VpLook.Live) .background(if (isLive) VpLook.Live else VpLook.AccentDeep)
.padding(horizontal = 8.dp, vertical = 3.dp), .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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun VpChip( private fun VpChip(

View file

@ -307,7 +307,16 @@ function pickImage(content: Record<string, unknown> | null): string | null {
if (!content) return null; if (!content) return null;
const images = asRecord(content.images); const images = asRecord(content.images);
if (!images) return null; 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 img = asRecord(images[key]);
const url = typeof img?.url === "string" ? img.url : null; const url = typeof img?.url === "string" ? img.url : null;
if (url) return url; if (url) return url;
@ -384,6 +393,226 @@ export async function fetchSportHighlights(
return extractProducts(res.json); 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[] { function extractProducts(json: unknown): ViaplayEpgProduct[] {
const out: ViaplayEpgProduct[] = []; const out: ViaplayEpgProduct[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@ -449,6 +678,13 @@ export async function playByMediaGuid(
`play-live mislukt (${res.status})`; `play-live mislukt (${res.status})`;
throw new Error(msg); throw new Error(msg);
} }
return parsePlayStreamBody(body, mediaGuid);
}
function parsePlayStreamBody(
body: Record<string, unknown>,
id: string
): ViaplayPlayResult {
const links = asRecord(body._links); const links = asRecord(body._links);
const media = asRecord(links?.["viaplay:media"]); const media = asRecord(links?.["viaplay:media"]);
const license = const license =
@ -458,7 +694,7 @@ export async function playByMediaGuid(
const product = asRecord(body.product); const product = asRecord(body.product);
const channel = asRecord(body.channel) || asRecord(asRecord(body.streamingSession)?.channel); const channel = asRecord(body.channel) || asRecord(asRecord(body.streamingSession)?.channel);
return { return {
mediaGuid, mediaGuid: id,
mpdUrl, mpdUrl,
licenseUrl: typeof license?.href === "string" ? license.href : null, licenseUrl: typeof license?.href === "string" ? license.href : null,
licenseAuth: typeof license?.auth === "string" ? license.auth : 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). */ /** Bouw MPD-URL met EPG start/stop kap (seconden). */
export function kapMpdUrl( export function kapMpdUrl(
mpdUrl: string, mpdUrl: string,

View file

@ -1,6 +1,17 @@
import type { ScheduleEventPublic } from "../viewer/schedule-events"; 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 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" { function phaseFor(startMs: number, endMs: number, nowMs: number): "live" | "upcoming" | "ended" {
if (nowMs < startMs) return "upcoming"; if (nowMs < startMs) return "upcoming";
@ -22,6 +33,7 @@ export function eventToPublic(
nowMs: number nowMs: number
): ScheduleEventPublic { ): ScheduleEventPublic {
const phase = phaseFor(event.startMs, event.endMs, nowMs); const phase = phaseFor(event.startMs, event.endMs, nowMs);
const catchupOk = phase === "ended" && nowMs <= event.endMs + VIAPLAY_CATCHUP_MS;
return { return {
id: toEventId(event.mediaGuid), id: toEventId(event.mediaGuid),
name: event.title, name: event.title,
@ -34,7 +46,7 @@ export function eventToPublic(
network: "Viaplay", network: "Viaplay",
isLive: phase === "live", isLive: phase === "live",
phase, phase,
playable: phase === "live", playable: phase === "live" || catchupOk,
status: phase, status: phase,
start: new Date(event.startMs).toISOString(), start: new Date(event.startMs).toISOString(),
stop: new Date(event.endMs).toISOString(), stop: new Date(event.endMs).toISOString(),
@ -60,6 +72,7 @@ export async function listViaplayScheduleEvents(
const horizon = nowMs + aheadMs; const horizon = nowMs + aheadMs;
const out: ScheduleEventPublic[] = []; const out: ScheduleEventPublic[] = [];
for (const event of config.events) { 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.endMs < nowMs) continue;
if (event.startMs > horizon) continue; if (event.startMs > horizon) continue;
const pub = eventToPublic(event, nowMs); const pub = eventToPublic(event, nowMs);
@ -89,6 +102,27 @@ export type ViaplaySportChip = {
count: number; 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 = { export type ViaplayHomePayload = {
generatedAt: string; generatedAt: string;
/** Nu live */ /** Nu live */
@ -99,10 +133,14 @@ export type ViaplayHomePayload = {
weekend: ScheduleEventPublic[]; weekend: ScheduleEventPublic[];
/** Upcoming daarna binnen sync-horizon */ /** Upcoming daarna binnen sync-horizon */
later: ScheduleEventPublic[]; later: ScheduleEventPublic[];
/** Recent afgelopen binnen catch-up venster */
catchup: ScheduleEventPublic[];
/** Sport-chips aanwezig in de payload */ /** Sport-chips aanwezig in de payload */
sports: ViaplaySportChip[]; sports: ViaplaySportChip[];
/** Alle niet-ended events (voor client-side sportfilter) */ /** Alle niet-ended events (voor client-side sportfilter) */
events: ScheduleEventPublic[]; events: ScheduleEventPublic[];
/** Officiële sport-sectie planken (samenvattingen, docs, …) */
rails: ViaplayHomeRail[];
}; };
const ZONE_NL = "Europe/Amsterdam"; const ZONE_NL = "Europe/Amsterdam";
@ -139,7 +177,66 @@ function isWeekendLocal(ms: number): boolean {
return d === 5 || d === 6 || d === 0; // vr za zo 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> { export async function getViaplayHome(nowMs = Date.now()): Promise<ViaplayHomePayload> {
const config = await loadViaplayConfigOnly(); const config = await loadViaplayConfigOnly();
const empty: ViaplayHomePayload = { const empty: ViaplayHomePayload = {
@ -148,25 +245,32 @@ export async function getViaplayHome(nowMs = Date.now()): Promise<ViaplayHomePay
today: [], today: [],
weekend: [], weekend: [],
later: [], later: [],
catchup: [],
sports: [], sports: [],
events: [], events: [],
rails: [],
}; };
if (!config?.events?.length) return empty; if (!config) return empty;
const aheadMs = Math.max(1, config.syncDaysAhead || 7) * 86_400_000; const aheadMs = Math.max(1, config.syncDaysAhead || 7) * 86_400_000;
const horizon = nowMs + aheadMs; const horizon = nowMs + aheadMs;
const todayYmd = localYmd(nowMs); const todayYmd = localYmd(nowMs);
const all: ScheduleEventPublic[] = []; const all: ScheduleEventPublic[] = [];
for (const event of config.events) { const catchup: ScheduleEventPublic[] = [];
if (event.endMs < nowMs) continue; for (const event of config.events || []) {
if (event.startMs > horizon) continue; if (event.startMs > horizon) continue;
const pub = eventToPublic(event, nowMs); 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.push(pub);
} }
all.sort((a, b) => Date.parse(a.start) - Date.parse(b.start)); 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 live = all.filter((e) => e.phase === "live");
const upcoming = all.filter((e) => e.phase === "upcoming"); 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 }>(); 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 id = e.sportBucket || "other";
const label = e.sportLabel || id; const label = e.sportLabel || id;
const cur = sportCounts.get(id) ?? { label, count: 0 }; 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 })) .map(([id, v]) => ({ id, label: v.label, count: v.count }))
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, "nl")); .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, "nl"));
const rails = await loadSportRailsHome(nowMs);
return { return {
generatedAt: new Date(nowMs).toISOString(), generatedAt: new Date(nowMs).toISOString(),
live, live,
today, today,
weekend, weekend,
later, later,
catchup: catchup.slice(0, 40),
sports, sports,
events: all, events: all,
rails,
}; };
} }

View file

@ -7,8 +7,11 @@ import {
} from "./accounts"; } from "./accounts";
import { extractViaplayKeys } from "./cdm"; import { extractViaplayKeys } from "./cdm";
import { import {
playByGuid,
playByMediaGuid, playByMediaGuid,
resolveKapMpd, resolveKapMpd,
fetchMpdXml,
extractPsshFromMpd,
VIAPLAY_USER_AGENT, VIAPLAY_USER_AGENT,
} from "./client"; } from "./client";
import { import {
@ -17,6 +20,7 @@ import {
} from "./settings"; } from "./settings";
import { findViaplayEvent } from "./sync"; import { findViaplayEvent } from "./sync";
import { isMpdCacheFresh, type ViaplayEventSnapshot } from "./tweaks"; import { isMpdCacheFresh, type ViaplayEventSnapshot } from "./tweaks";
import { VIAPLAY_CATCHUP_MS } from "./events";
function parseKey(value: string): { kid: string; key: string } | null { function parseKey(value: string): { kid: string; key: string } | null {
const [kid, key] = value.trim().toLowerCase().split(":", 2); 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); if (!event) throw new AppError("NOT_FOUND", "Viaplay-event niet gevonden", 404);
const now = Date.now(); 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); throw new AppError("GONE", "Dit event is afgelopen", 410);
} }
// Pre-live: wel MPD/keys toestaan binnen preLiveMinutes; viewer play blijft live-only // Pre-live: wel MPD/keys toestaan binnen preLiveMinutes; viewer play blijft live-only
return ensureViaplayEventReady(sessionSecret, event); 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)); events.push(toSnapshot(product, prev, owners));
} }
// Drop ended events older than 6h; also drop further than sync horizon+1d // Bewaar catch-up tot ~48u na einde (officieel Viaplay-venster)
const cutoff = now - 6 * 3_600_000; const cutoff = now - 48 * 3_600_000;
const horizon = now + config.syncDaysAhead * 86_400_000 + 86_400_000; const horizon = now + config.syncDaysAhead * 86_400_000 + 86_400_000;
const kept = events.filter((e) => e.endMs >= cutoff && e.startMs <= horizon); const kept = events.filter((e) => e.endMs >= cutoff && e.startMs <= horizon);
kept.sort((a, b) => a.startMs - b.startMs); kept.sort((a, b) => a.startMs - b.startMs);

View file

@ -964,7 +964,7 @@ export function registerViewerRoutes(
hubs.push({ hubs.push({
id: "viaplay", id: "viaplay",
label: "Viaplay", label: "Viaplay",
subtitle: "Live & agenda", subtitle: "Sport · live · terugkijken",
}); });
} }
} catch { } 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. * Lichte presence-heartbeat voor Live Events / custom live-lists.
* Fire-and-forget vanaf de client; failures mogen genegeerd worden. * Fire-and-forget vanaf de client; failures mogen genegeerd worden.