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"
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -146,13 +146,18 @@ fun EventPlayerScreen(
|
||||||
audioTracks = emptyList()
|
audioTracks = emptyList()
|
||||||
textTracks = emptyList()
|
textTracks = emptyList()
|
||||||
try {
|
try {
|
||||||
val play = if (hubPlay) {
|
val play = when {
|
||||||
val contentId = Regex("""(?i)^f1tv:(\d+)$""").find(eventId.trim())
|
hubPlay && eventId.startsWith("viaplay-content:", ignoreCase = true) -> {
|
||||||
?.groupValues?.getOrNull(1)
|
val guid = eventId.removePrefix("viaplay-content:").removePrefix("VIAPLAY-CONTENT:")
|
||||||
?: eventId.trim().removePrefix("f1tv:").removePrefix("F1TV:")
|
api.viaplayContentPlay(guid)
|
||||||
api.f1tvContentPlay(contentId)
|
}
|
||||||
} else {
|
hubPlay -> {
|
||||||
api.scheduleEventPlay(eventId)
|
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)
|
||||||
}
|
}
|
||||||
if (!isActive) return@LaunchedEffect
|
if (!isActive) return@LaunchedEffect
|
||||||
title = play.name
|
title = play.name
|
||||||
|
|
|
||||||
|
|
@ -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,28 +499,27 @@ 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") {
|
EventsHubRail(
|
||||||
// Zolang TopNav-sink contentFocusRequester vasthoudt → hubRailFocus op tile.
|
hubs = hubs,
|
||||||
// Zodra ready → contentFocusRequester op eerste tile.
|
focusRequester = contentFocusRequester,
|
||||||
val hubEntryFocus =
|
focusUp = navFocusRequester,
|
||||||
if (hubRailFocusReady) contentFocusRequester else hubRailFocus
|
onOpenF1 = onOpenF1Hub,
|
||||||
EventsHubRail(
|
onOpenViaplay = onOpenViaplayHub,
|
||||||
hubs = hubs,
|
onFocusReady = { hubRailFocusReady = it },
|
||||||
focusRequester = hubEntryFocus,
|
onFocus = onContentFocused,
|
||||||
focusUp = navFocusRequester,
|
)
|
||||||
onOpenF1 = onOpenF1Hub,
|
|
||||||
onOpenViaplay = onOpenViaplayHub,
|
|
||||||
onFocusReady = { hubRailFocusReady = it },
|
|
||||||
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,
|
||||||
|
|
@ -658,6 +653,7 @@ fun EventsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1127,41 +1123,68 @@ private fun EventsHubRail(
|
||||||
onFocusReady: ((Boolean) -> Unit)? = null,
|
onFocusReady: ((Boolean) -> Unit)? = null,
|
||||||
onFocus: () -> Unit,
|
onFocus: () -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyRow(
|
if (hubs.isEmpty()) return
|
||||||
|
fun accentFor(id: String) = when (id) {
|
||||||
|
"f1" -> Color(0xFFE10600)
|
||||||
|
"viaplay" -> Color(0xFF8B5CF6)
|
||||||
|
else -> McColors.Accent
|
||||||
|
}
|
||||||
|
fun badgeFor(hub: EventHubInfo) = when (hub.id) {
|
||||||
|
"f1" -> "F1"
|
||||||
|
"viaplay" -> "VP"
|
||||||
|
else -> hub.label.take(2).uppercase()
|
||||||
|
}
|
||||||
|
fun openFor(id: String): (() -> Unit)? = when (id) {
|
||||||
|
"f1" -> onOpenF1
|
||||||
|
"viaplay" -> onOpenViaplay
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
// 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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 6.dp, bottom = 4.dp),
|
.padding(top = 6.dp, bottom = 4.dp)
|
||||||
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset),
|
.padding(horizontal = HomeChrome.EdgeInset),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
itemsIndexed(hubs, key = { _, h -> h.id }) { index, hub ->
|
if (firstOpen != null) {
|
||||||
val accent = when (hub.id) {
|
|
||||||
"f1" -> Color(0xFFE10600)
|
|
||||||
"viaplay" -> Color(0xFF8B5CF6)
|
|
||||||
else -> McColors.Accent
|
|
||||||
}
|
|
||||||
val badge = when (hub.id) {
|
|
||||||
"f1" -> "F1"
|
|
||||||
"viaplay" -> "VP"
|
|
||||||
else -> hub.label.take(2).uppercase()
|
|
||||||
}
|
|
||||||
val onOpen = when (hub.id) {
|
|
||||||
"f1" -> onOpenF1
|
|
||||||
"viaplay" -> onOpenViaplay
|
|
||||||
else -> null
|
|
||||||
} ?: return@itemsIndexed
|
|
||||||
HubRailTile(
|
HubRailTile(
|
||||||
badge = badge,
|
badge = badgeFor(first),
|
||||||
label = hub.label,
|
label = first.label,
|
||||||
subtitle = hub.subtitle,
|
subtitle = first.subtitle,
|
||||||
accent = accent,
|
accent = accentFor(first.id),
|
||||||
focusRequester = if (index == 0) focusRequester else null,
|
focusRequester = focusRequester,
|
||||||
focusUp = focusUp,
|
focusUp = focusUp,
|
||||||
onFocusReady = if (index == 0) onFocusReady else null,
|
onFocusReady = onFocusReady,
|
||||||
onOpen = onOpen,
|
onOpen = firstOpen,
|
||||||
onFocus = onFocus,
|
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 = accentFor(hub.id),
|
||||||
|
focusRequester = null,
|
||||||
|
focusUp = focusUp,
|
||||||
|
onFocusReady = null,
|
||||||
|
onOpen = onOpen,
|
||||||
|
onFocus = onFocus,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue