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:
parent
244a8c97ed
commit
8b39ce06ee
11 changed files with 907 additions and 94 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -662,6 +657,7 @@ fun EventsScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
|
|
@ -1127,42 +1123,69 @@ 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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue