Add Viaplay hub and replace F1 banner with shared hub rail on TV.
Events keep live/upcoming; hubs load via /client/hubs and Viaplay home is time+sport based.
This commit is contained in:
parent
fafe97e729
commit
42e521da25
7 changed files with 906 additions and 50 deletions
|
|
@ -696,6 +696,63 @@ class ApiClient(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun listEventHubs(): List<EventHubInfo> = withContext(Dispatchers.IO) {
|
||||
val req = authed("$baseUrl/api/v1/client/hubs").get().build()
|
||||
parseObject(execute(req)) { o ->
|
||||
val arr = o.optJSONArray("hubs") ?: JSONArray()
|
||||
buildList {
|
||||
for (i in 0 until arr.length()) {
|
||||
val h = arr.optJSONObject(i) ?: continue
|
||||
val id = h.optClean("id") ?: continue
|
||||
add(
|
||||
EventHubInfo(
|
||||
id = id,
|
||||
label = h.optClean("label") ?: id,
|
||||
subtitle = h.optClean("subtitle") ?: "",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getViaplayHome(): ViaplayHomePayload = withContext(Dispatchers.IO) {
|
||||
val req = authed("$baseUrl/api/v1/client/viaplay/home").get().build()
|
||||
parseObject(execute(req)) { o ->
|
||||
fun eventsOf(key: String): List<ScheduleEvent> {
|
||||
val arr = o.optJSONArray(key) ?: JSONArray()
|
||||
return buildList {
|
||||
for (i in 0 until arr.length()) {
|
||||
arr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val sportsArr = o.optJSONArray("sports") ?: JSONArray()
|
||||
val sports = buildList {
|
||||
for (i in 0 until sportsArr.length()) {
|
||||
val s = sportsArr.optJSONObject(i) ?: continue
|
||||
val id = s.optClean("id") ?: continue
|
||||
add(
|
||||
ViaplaySportChip(
|
||||
id = id,
|
||||
label = s.optClean("label") ?: id,
|
||||
count = s.optInt("count"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
ViaplayHomePayload(
|
||||
generatedAt = o.optClean("generatedAt"),
|
||||
live = eventsOf("live"),
|
||||
today = eventsOf("today"),
|
||||
weekend = eventsOf("weekend"),
|
||||
later = eventsOf("later"),
|
||||
sports = sports,
|
||||
events = eventsOf("events"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getF1tvMeeting(meetingKey: String): F1tvMeetingDetail = withContext(Dispatchers.IO) {
|
||||
val enc = android.net.Uri.encode(meetingKey)
|
||||
val req = authed("$baseUrl/api/v1/client/f1/meetings/$enc").get().build()
|
||||
|
|
@ -1285,6 +1342,28 @@ data class F1tvHomePayload(
|
|||
val nextMeeting: F1tvMeeting? = null,
|
||||
)
|
||||
|
||||
data class EventHubInfo(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val subtitle: String = "",
|
||||
)
|
||||
|
||||
data class ViaplaySportChip(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val count: Int = 0,
|
||||
)
|
||||
|
||||
data class ViaplayHomePayload(
|
||||
val generatedAt: String? = null,
|
||||
val live: List<ScheduleEvent> = emptyList(),
|
||||
val today: List<ScheduleEvent> = emptyList(),
|
||||
val weekend: List<ScheduleEvent> = emptyList(),
|
||||
val later: List<ScheduleEvent> = emptyList(),
|
||||
val sports: List<ViaplaySportChip> = emptyList(),
|
||||
val events: List<ScheduleEvent> = emptyList(),
|
||||
)
|
||||
|
||||
data class F1tvMeetingDetail(
|
||||
val meeting: F1tvMeeting? = null,
|
||||
val sessions: List<ScheduleEvent> = emptyList(),
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.widthIn
|
|||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
|
|
@ -75,6 +76,7 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
import nl.vonas.mediacluster.tv.data.EventHubInfo
|
||||
import nl.vonas.mediacluster.tv.data.ScheduleEvent
|
||||
import nl.vonas.mediacluster.tv.data.SportBucketOrder
|
||||
import nl.vonas.mediacluster.tv.data.sportLabelForBucket
|
||||
|
|
@ -120,9 +122,11 @@ fun EventsScreen(
|
|||
onFocusRestoreConsumed: () -> Unit = {},
|
||||
onOpenEvent: (String) -> Unit,
|
||||
onOpenF1Hub: (() -> Unit)? = null,
|
||||
onOpenViaplayHub: (() -> Unit)? = null,
|
||||
onContentFocused: () -> Unit = {},
|
||||
) {
|
||||
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
|
||||
var hubs by remember { mutableStateOf<List<EventHubInfo>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var sportFilter by remember { mutableStateOf<String?>(null) }
|
||||
|
|
@ -145,6 +149,24 @@ fun EventsScreen(
|
|||
error = null
|
||||
}
|
||||
.onFailure { error = it.message ?: "Kon events niet laden" }
|
||||
hubs = runCatching { api.listEventHubs() }
|
||||
.getOrElse {
|
||||
buildList {
|
||||
if (onOpenF1Hub != null) {
|
||||
add(EventHubInfo("f1", "Formule 1", "Agenda & terugkijken"))
|
||||
}
|
||||
if (onOpenViaplayHub != null) {
|
||||
add(EventHubInfo("viaplay", "Viaplay", "Live & agenda"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.filter { hub ->
|
||||
when (hub.id) {
|
||||
"f1" -> onOpenF1Hub != null
|
||||
"viaplay" -> onOpenViaplayHub != null
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
|
|
@ -225,13 +247,13 @@ fun EventsScreen(
|
|||
else -> null
|
||||
}
|
||||
|
||||
// Focus: TopNav → F1-tile (optioneel) → filterrij → first rail.
|
||||
val hasF1Entry = onOpenF1Hub != null
|
||||
// Focus: TopNav → hub-rail (optioneel) → filterrij → first rail.
|
||||
val hasHubRail = hubs.isNotEmpty()
|
||||
val showFilterBar = showSportChips || showProviderChips
|
||||
val filterBarFocus = remember { FocusRequester() }
|
||||
val firstRailUpTarget = when {
|
||||
showFilterBar && filterBarFocusReady -> filterBarFocus
|
||||
hasF1Entry -> contentFocusRequester
|
||||
hasHubRail -> contentFocusRequester
|
||||
else -> navFocusRequester
|
||||
}
|
||||
val filterFocusDown: FocusRequester? = when {
|
||||
|
|
@ -239,7 +261,7 @@ fun EventsScreen(
|
|||
railFocusReady -> firstRailFocus
|
||||
else -> null
|
||||
}
|
||||
val railUsesDedicatedFocus = hasF1Entry || showFilterBar
|
||||
val railUsesDedicatedFocus = hasHubRail || showFilterBar
|
||||
|
||||
fun claimRailFocus(owner: String, ready: Boolean) {
|
||||
if (ready) {
|
||||
|
|
@ -270,7 +292,7 @@ fun EventsScreen(
|
|||
}
|
||||
val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0
|
||||
val restoreListIndex = remember(
|
||||
hasF1Entry,
|
||||
hasHubRail,
|
||||
showFilterBar,
|
||||
live,
|
||||
upcomingRails,
|
||||
|
|
@ -279,7 +301,7 @@ fun EventsScreen(
|
|||
) {
|
||||
if (!canRestoreCard) return@remember -1
|
||||
var idx = 0
|
||||
if (hasF1Entry) idx++
|
||||
if (hasHubRail) idx++
|
||||
if (showFilterBar) idx++
|
||||
if (restoreInLive) return@remember idx
|
||||
if (live.isNotEmpty()) idx++
|
||||
|
|
@ -320,14 +342,14 @@ fun EventsScreen(
|
|||
}
|
||||
|
||||
val entryReady = when {
|
||||
hasF1Entry -> true
|
||||
hasHubRail -> true
|
||||
showFilterBar -> filterBarFocusReady
|
||||
else -> railFocusReady
|
||||
}
|
||||
val needContentSink = when {
|
||||
loading && events.isEmpty() -> true
|
||||
error != null && events.isEmpty() -> true
|
||||
hasF1Entry -> false
|
||||
hasHubRail -> false
|
||||
showFilterBar -> !filterBarFocusReady
|
||||
!hasRails -> true
|
||||
else -> !railFocusReady
|
||||
|
|
@ -352,7 +374,7 @@ fun EventsScreen(
|
|||
if (restoredSuccessfully) return
|
||||
val entryOk = awaitReady({
|
||||
when {
|
||||
hasF1Entry -> true
|
||||
hasHubRail -> true
|
||||
showFilterBar -> filterBarFocusReady
|
||||
else -> railFocusReady
|
||||
}
|
||||
|
|
@ -475,12 +497,14 @@ fun EventsScreen(
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 56.dp),
|
||||
) {
|
||||
if (hasF1Entry) {
|
||||
item(key = "f1_hub_entry") {
|
||||
F1HubEntryTile(
|
||||
if (hasHubRail) {
|
||||
item(key = "hub_rail") {
|
||||
EventsHubRail(
|
||||
hubs = hubs,
|
||||
focusRequester = contentFocusRequester,
|
||||
focusUp = navFocusRequester,
|
||||
onOpen = onOpenF1Hub!!,
|
||||
onOpenF1 = onOpenF1Hub,
|
||||
onOpenViaplay = onOpenViaplayHub,
|
||||
onFocus = onContentFocused,
|
||||
)
|
||||
}
|
||||
|
|
@ -498,8 +522,8 @@ fun EventsScreen(
|
|||
onProviderSelect = { providerFilter = it },
|
||||
liveCount = live.size,
|
||||
totalCount = filtered.size,
|
||||
firstFocusRequester = if (hasF1Entry) filterBarFocus else contentFocusRequester,
|
||||
focusUp = if (hasF1Entry) contentFocusRequester else navFocusRequester,
|
||||
firstFocusRequester = if (hasHubRail) filterBarFocus else contentFocusRequester,
|
||||
focusUp = if (hasHubRail) contentFocusRequester else navFocusRequester,
|
||||
focusDown = filterFocusDown,
|
||||
onFocusReady = { filterBarFocusReady = it },
|
||||
onFocus = onContentFocused,
|
||||
|
|
@ -1078,21 +1102,69 @@ private fun PulsingLiveDot(size: androidx.compose.ui.unit.Dp = 10.dp) {
|
|||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun F1HubEntryTile(
|
||||
private fun EventsHubRail(
|
||||
hubs: List<EventHubInfo>,
|
||||
focusRequester: FocusRequester?,
|
||||
focusUp: FocusRequester?,
|
||||
onOpenF1: (() -> Unit)?,
|
||||
onOpenViaplay: (() -> Unit)?,
|
||||
onFocus: () -> Unit,
|
||||
) {
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 6.dp, bottom = 4.dp),
|
||||
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
itemsIndexed(hubs, key = { _, h -> h.id }) { index, hub ->
|
||||
val accent = when (hub.id) {
|
||||
"f1" -> Color(0xFFE10600)
|
||||
"viaplay" -> Color(0xFF8B5CF6)
|
||||
else -> McColors.Accent
|
||||
}
|
||||
val badge = when (hub.id) {
|
||||
"f1" -> "F1"
|
||||
"viaplay" -> "VP"
|
||||
else -> hub.label.take(2).uppercase()
|
||||
}
|
||||
val onOpen = when (hub.id) {
|
||||
"f1" -> onOpenF1
|
||||
"viaplay" -> onOpenViaplay
|
||||
else -> null
|
||||
} ?: return@itemsIndexed
|
||||
HubRailTile(
|
||||
badge = badge,
|
||||
label = hub.label,
|
||||
subtitle = hub.subtitle,
|
||||
accent = accent,
|
||||
focusRequester = if (index == 0) focusRequester else null,
|
||||
focusUp = focusUp,
|
||||
onOpen = onOpen,
|
||||
onFocus = onFocus,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun HubRailTile(
|
||||
badge: String,
|
||||
label: String,
|
||||
subtitle: String,
|
||||
accent: Color,
|
||||
focusRequester: FocusRequester?,
|
||||
focusUp: FocusRequester?,
|
||||
onOpen: () -> Unit,
|
||||
onFocus: () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val f1Red = Color(0xFFE10600)
|
||||
Surface(
|
||||
onClick = onOpen,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = HomeChrome.EdgeInset)
|
||||
.padding(top = 8.dp, bottom = 10.dp)
|
||||
.height(72.dp)
|
||||
.height(52.dp)
|
||||
.widthIn(min = 168.dp, max = 220.dp)
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||
.then(
|
||||
if (focusUp != null) {
|
||||
|
|
@ -1105,63 +1177,63 @@ private fun F1HubEntryTile(
|
|||
focused = it.isFocused
|
||||
if (it.isFocused) onFocus()
|
||||
},
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
|
||||
glow = ClickableSurfaceDefaults.glow(
|
||||
focusedGlow = Glow(elevationColor = f1Red, elevation = 14.dp),
|
||||
focusedGlow = Glow(elevationColor = accent, elevation = 8.dp),
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(2.5.dp, f1Red),
|
||||
inset = 4.dp,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
border = BorderStroke(2.dp, accent),
|
||||
inset = 2.dp,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
),
|
||||
),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color(0xFF141414),
|
||||
focusedContainerColor = Color(0xFF1E1212),
|
||||
containerColor = Color(0xFF14141A),
|
||||
focusedContainerColor = Color(0xFF1A1A22),
|
||||
),
|
||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
|
||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 20.dp),
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(f1Red),
|
||||
.size(28.dp)
|
||||
.clip(RoundedCornerShape(7.dp))
|
||||
.background(accent),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"F1",
|
||||
badge,
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Formule 1",
|
||||
label,
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
"Agenda & terugkijken",
|
||||
color = McColors.TextDim,
|
||||
fontSize = 13.sp,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (subtitle.isNotBlank()) {
|
||||
Text(
|
||||
subtitle,
|
||||
color = McColors.TextDim,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Open →",
|
||||
color = if (focused) f1Red else McColors.TextDim,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -370,6 +370,11 @@ fun McNav() {
|
|||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onOpenViaplayHub = {
|
||||
nav.navigate("viaplay_hub") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onSwitchProfile = {
|
||||
scope.launch {
|
||||
runCatching { api.clearProfile() }
|
||||
|
|
@ -580,6 +585,24 @@ fun McNav() {
|
|||
},
|
||||
)
|
||||
}
|
||||
composable("viaplay_hub") {
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
pendingEventsFocusRestore = true
|
||||
eventsReturnNonce++
|
||||
}
|
||||
}
|
||||
ViaplayHubScreen(
|
||||
api = api,
|
||||
onBack = { nav.popBackStack() },
|
||||
onOpenEvent = { eventId ->
|
||||
lastOpenedEventId = eventId
|
||||
nav.navigate("event/${android.net.Uri.encode(eventId)}") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"f1_meeting/{meetingKey}",
|
||||
arguments = listOf(navArgument("meetingKey") { type = NavType.StringType }),
|
||||
|
|
@ -666,6 +689,7 @@ private fun MainHub(
|
|||
onPlayLive: (String) -> Unit,
|
||||
onOpenEvent: (String) -> Unit,
|
||||
onOpenF1Hub: () -> Unit = {},
|
||||
onOpenViaplayHub: () -> Unit = {},
|
||||
onSwitchProfile: () -> Unit,
|
||||
onManageProfiles: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
|
|
@ -1064,6 +1088,7 @@ private fun MainHub(
|
|||
onFocusRestoreConsumed = onEventsFocusRestoreConsumed,
|
||||
onOpenEvent = onOpenEvent,
|
||||
onOpenF1Hub = onOpenF1Hub,
|
||||
onOpenViaplayHub = onOpenViaplayHub,
|
||||
onContentFocused = { navFocused = false },
|
||||
)
|
||||
MainTab.Search -> SearchScreen(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,459 @@
|
|||
package nl.vonas.mediacluster.tv.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Glow
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.delay
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
import nl.vonas.mediacluster.tv.data.ScheduleEvent
|
||||
import nl.vonas.mediacluster.tv.data.ViaplayHomePayload
|
||||
import nl.vonas.mediacluster.tv.ui.components.HomeChrome
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/** Viaplay brand — koel violet, geen F1-rood. */
|
||||
private object VpLook {
|
||||
val Accent = Color(0xFF8B5CF6)
|
||||
val AccentDeep = Color(0xFF6D28D9)
|
||||
val AccentSoft = Color(0x448B5CF6)
|
||||
val Night = Color(0xFF0A0B12)
|
||||
val Panel = Color(0xFF14161F)
|
||||
val PanelHot = Color(0xFF1C1F2B)
|
||||
val Live = Color(0xFFFF2D55)
|
||||
val Mist = Color(0xFFA1A1AA)
|
||||
val White = Color(0xFFF4F4F5)
|
||||
}
|
||||
|
||||
private val ZoneNl: ZoneId = ZoneId.of("Europe/Amsterdam")
|
||||
private val DayTimeFmt: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("EEE HH:mm", Locale("nl", "NL"))
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun ViaplayHubScreen(
|
||||
api: ApiClient,
|
||||
onBack: () -> Unit,
|
||||
onOpenEvent: (String) -> Unit,
|
||||
) {
|
||||
var home by remember { mutableStateOf<ViaplayHomePayload?>(null) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var sportFilter by remember { mutableStateOf<String?>(null) }
|
||||
var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
suspend fun refresh() {
|
||||
runCatching { api.getViaplayHome() }
|
||||
.onSuccess {
|
||||
home = it
|
||||
error = null
|
||||
}
|
||||
.onFailure { error = it.message ?: "Kon Viaplay-hub niet laden" }
|
||||
loading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { refresh() }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(75_000)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(30_000)
|
||||
nowMs = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(home, loading, error) {
|
||||
if (!loading && error == null && home != null) {
|
||||
delay(80)
|
||||
runCatching { firstFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
fun List<ScheduleEvent>.sportFiltered(): List<ScheduleEvent> {
|
||||
val f = sportFilter ?: return this
|
||||
return filter { it.sportBucket.equals(f, ignoreCase = true) }
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(VpLook.Night),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth(0.5f)
|
||||
.height(280.dp)
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(VpLook.AccentSoft, Color.Transparent),
|
||||
),
|
||||
),
|
||||
)
|
||||
when {
|
||||
loading && home == null -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"VIAPLAY",
|
||||
color = VpLook.Accent,
|
||||
fontSize = 26.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
)
|
||||
}
|
||||
}
|
||||
error != null && home == null -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(error!!, color = McColors.Danger, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
VpChip("Terug", focusRequester = firstFocus, onClick = onBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val payload = home!!
|
||||
val live = payload.live.sportFiltered()
|
||||
val today = payload.today.sportFiltered()
|
||||
val weekend = payload.weekend.sportFiltered()
|
||||
val later = payload.later.sportFiltered()
|
||||
val sports = payload.sports
|
||||
val hasAny = live.isNotEmpty() || today.isNotEmpty() ||
|
||||
weekend.isNotEmpty() || later.isNotEmpty()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 64.dp),
|
||||
) {
|
||||
item(key = "header") {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = HomeChrome.EdgeInset)
|
||||
.padding(top = 28.dp, bottom = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
VpChip("← Events", focusRequester = firstFocus, onClick = onBack)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Viaplay",
|
||||
color = VpLook.White,
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
)
|
||||
Text(
|
||||
"Live · vandaag · weekend",
|
||||
color = VpLook.Mist,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sports.size > 1) {
|
||||
item(key = "sports") {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(
|
||||
horizontal = HomeChrome.EdgeInset,
|
||||
vertical = 6.dp,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item {
|
||||
VpChip(
|
||||
label = "Alles",
|
||||
selected = sportFilter == null,
|
||||
onClick = { sportFilter = null },
|
||||
)
|
||||
}
|
||||
items(sports, key = { it.id }) { s ->
|
||||
VpChip(
|
||||
label = "${s.label} (${s.count})",
|
||||
selected = sportFilter == s.id,
|
||||
onClick = {
|
||||
sportFilter = if (sportFilter == s.id) null else s.id
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAny) {
|
||||
item(key = "empty") {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 48.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"Geen Viaplay-events in deze periode",
|
||||
color = VpLook.Mist,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (live.isNotEmpty()) {
|
||||
item(key = "live") {
|
||||
VpRail(
|
||||
title = "Nu live",
|
||||
accent = VpLook.Live,
|
||||
events = live,
|
||||
nowMs = nowMs,
|
||||
onOpen = onOpenEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (today.isNotEmpty()) {
|
||||
item(key = "today") {
|
||||
VpRail(
|
||||
title = "Vandaag",
|
||||
accent = VpLook.Accent,
|
||||
events = today,
|
||||
nowMs = nowMs,
|
||||
onOpen = onOpenEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (weekend.isNotEmpty()) {
|
||||
item(key = "weekend") {
|
||||
VpRail(
|
||||
title = "Weekend",
|
||||
accent = VpLook.Accent,
|
||||
events = weekend,
|
||||
nowMs = nowMs,
|
||||
onOpen = onOpenEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (later.isNotEmpty()) {
|
||||
item(key = "later") {
|
||||
VpRail(
|
||||
title = "Later",
|
||||
accent = VpLook.Mist,
|
||||
events = later,
|
||||
nowMs = nowMs,
|
||||
onOpen = onOpenEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun VpRail(
|
||||
title: String,
|
||||
accent: Color,
|
||||
events: List<ScheduleEvent>,
|
||||
nowMs: Long,
|
||||
onOpen: (String) -> Unit,
|
||||
) {
|
||||
Column(Modifier.padding(top = 18.dp, bottom = 6.dp)) {
|
||||
Text(
|
||||
title.uppercase(Locale("nl", "NL")),
|
||||
color = accent,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.2.sp,
|
||||
modifier = Modifier.padding(horizontal = HomeChrome.EdgeInset, vertical = 6.dp),
|
||||
)
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
itemsIndexed(events, key = { _, e -> e.id }) { _, event ->
|
||||
VpEventCard(event = event, nowMs = nowMs, onOpen = { onOpen(event.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun VpEventCard(
|
||||
event: ScheduleEvent,
|
||||
nowMs: Long,
|
||||
onOpen: () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val isLive = event.phase.equals("live", ignoreCase = true) || event.isLive
|
||||
Surface(
|
||||
onClick = onOpen,
|
||||
modifier = Modifier
|
||||
.width(280.dp)
|
||||
.onFocusChanged { focused = it.isFocused },
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
|
||||
glow = ClickableSurfaceDefaults.glow(
|
||||
focusedGlow = Glow(elevationColor = VpLook.Accent, elevation = 10.dp),
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(2.dp, VpLook.Accent),
|
||||
inset = 3.dp,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
),
|
||||
),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = VpLook.Panel,
|
||||
focusedContainerColor = VpLook.PanelHot,
|
||||
),
|
||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
|
||||
) {
|
||||
Column {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(148.dp)
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||
.background(Color(0xFF0E1018)),
|
||||
) {
|
||||
val art = event.imageLandscape ?: event.imageBackdrop ?: event.images.firstOrNull()
|
||||
if (!art.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = art,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
if (isLive) {
|
||||
Text(
|
||||
"LIVE",
|
||||
color = Color.White,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(10.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(VpLook.Live)
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
event.name,
|
||||
color = VpLook.White,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val whenLabel = runCatching {
|
||||
Instant.parse(event.start).atZone(ZoneNl).format(DayTimeFmt)
|
||||
}.getOrNull() ?: event.start
|
||||
Text(
|
||||
listOfNotNull(event.sportLabel, whenLabel).joinToString(" · "),
|
||||
color = VpLook.Mist,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun VpChip(
|
||||
label: String,
|
||||
selected: Boolean = false,
|
||||
focusRequester: FocusRequester? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||
.onFocusChanged { focused = it.isFocused },
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = when {
|
||||
selected -> VpLook.AccentDeep
|
||||
focused -> VpLook.PanelHot
|
||||
else -> VpLook.Panel
|
||||
},
|
||||
focusedContainerColor = if (selected) VpLook.Accent else VpLook.PanelHot,
|
||||
),
|
||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(20.dp)),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(1.5.dp, VpLook.Accent),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = VpLook.White,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (selected || focused) FontWeight.Bold else FontWeight.Medium,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -82,3 +82,136 @@ export async function getViaplayScheduleEvent(
|
|||
if (!event) return null;
|
||||
return eventToPublic(event, nowMs);
|
||||
}
|
||||
|
||||
export type ViaplaySportChip = {
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type ViaplayHomePayload = {
|
||||
generatedAt: string;
|
||||
/** Nu live */
|
||||
live: ScheduleEventPublic[];
|
||||
/** Upcoming vandaag (lokale kalenderdag) */
|
||||
today: ScheduleEventPublic[];
|
||||
/** Upcoming dit weekend (vr–zo), niet al in today */
|
||||
weekend: ScheduleEventPublic[];
|
||||
/** Upcoming daarna binnen sync-horizon */
|
||||
later: ScheduleEventPublic[];
|
||||
/** Sport-chips aanwezig in de payload */
|
||||
sports: ViaplaySportChip[];
|
||||
/** Alle niet-ended events (voor client-side sportfilter) */
|
||||
events: ScheduleEventPublic[];
|
||||
};
|
||||
|
||||
const ZONE_NL = "Europe/Amsterdam";
|
||||
|
||||
function localYmd(ms: number, timeZone = ZONE_NL): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(new Date(ms));
|
||||
}
|
||||
|
||||
function localWeekday(ms: number, timeZone = ZONE_NL): number {
|
||||
// 0=zo … 6=za (en-US)
|
||||
const day = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
weekday: "short",
|
||||
}).format(new Date(ms));
|
||||
const map: Record<string, number> = {
|
||||
Sun: 0,
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
};
|
||||
return map[day] ?? 0;
|
||||
}
|
||||
|
||||
function isWeekendLocal(ms: number): boolean {
|
||||
const d = localWeekday(ms);
|
||||
return d === 5 || d === 6 || d === 0; // vr za zo
|
||||
}
|
||||
|
||||
/** Hub: tijd-rails + sport-meta. Live blijft ook in Events; hier voor dieper browsen. */
|
||||
export async function getViaplayHome(nowMs = Date.now()): Promise<ViaplayHomePayload> {
|
||||
const config = await loadViaplayConfigOnly();
|
||||
const empty: ViaplayHomePayload = {
|
||||
generatedAt: new Date(nowMs).toISOString(),
|
||||
live: [],
|
||||
today: [],
|
||||
weekend: [],
|
||||
later: [],
|
||||
sports: [],
|
||||
events: [],
|
||||
};
|
||||
if (!config?.events?.length) return empty;
|
||||
|
||||
const aheadMs = Math.max(1, config.syncDaysAhead || 7) * 86_400_000;
|
||||
const horizon = nowMs + aheadMs;
|
||||
const todayYmd = localYmd(nowMs);
|
||||
|
||||
const all: ScheduleEventPublic[] = [];
|
||||
for (const event of config.events) {
|
||||
if (event.endMs < nowMs) continue;
|
||||
if (event.startMs > horizon) continue;
|
||||
const pub = eventToPublic(event, nowMs);
|
||||
if (pub.phase === "ended") continue;
|
||||
all.push(pub);
|
||||
}
|
||||
|
||||
all.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
|
||||
|
||||
const live = all.filter((e) => e.phase === "live");
|
||||
const upcoming = all.filter((e) => e.phase === "upcoming");
|
||||
|
||||
const today: ScheduleEventPublic[] = [];
|
||||
const weekend: ScheduleEventPublic[] = [];
|
||||
const later: ScheduleEventPublic[] = [];
|
||||
|
||||
for (const e of upcoming) {
|
||||
const startMs = Date.parse(e.start);
|
||||
if (!Number.isFinite(startMs)) {
|
||||
later.push(e);
|
||||
continue;
|
||||
}
|
||||
if (localYmd(startMs) === todayYmd) {
|
||||
today.push(e);
|
||||
continue;
|
||||
}
|
||||
if (isWeekendLocal(startMs) && startMs - nowMs < 7 * 86_400_000) {
|
||||
weekend.push(e);
|
||||
continue;
|
||||
}
|
||||
later.push(e);
|
||||
}
|
||||
|
||||
const sportCounts = new Map<string, { label: string; count: number }>();
|
||||
for (const e of all) {
|
||||
const id = e.sportBucket || "other";
|
||||
const label = e.sportLabel || id;
|
||||
const cur = sportCounts.get(id) ?? { label, count: 0 };
|
||||
cur.count += 1;
|
||||
if (!cur.label) cur.label = label;
|
||||
sportCounts.set(id, cur);
|
||||
}
|
||||
const sports: ViaplaySportChip[] = [...sportCounts.entries()]
|
||||
.map(([id, v]) => ({ id, label: v.label, count: v.count }))
|
||||
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, "nl"));
|
||||
|
||||
return {
|
||||
generatedAt: new Date(nowMs).toISOString(),
|
||||
live,
|
||||
today,
|
||||
weekend,
|
||||
later,
|
||||
sports,
|
||||
events: all,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -481,6 +481,22 @@ export async function loadViaplayConfigOnly(): Promise<ViaplayConfig | null> {
|
|||
return parseConfig(row.configJson);
|
||||
}
|
||||
|
||||
export async function isViaplayIntegrationEnabled(): Promise<boolean> {
|
||||
const row = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } });
|
||||
if (!row?.enabled) return false;
|
||||
try {
|
||||
const config = parseConfig(row.configJson);
|
||||
return config.accounts.some(
|
||||
(a) =>
|
||||
a.enabled !== false &&
|
||||
!!a.email &&
|
||||
((!!a.sessionEnc && !!a.accessTokenEnc) || !!a.passwordEnc)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveViaplaySyncResult(
|
||||
summary: Record<string, unknown>,
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -901,6 +901,78 @@ export function registerViewerRoutes(
|
|||
return getF1tvHome();
|
||||
});
|
||||
|
||||
app.get("/api/v1/client/viaplay/home", async (request) => {
|
||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||
const { resolveViewerLiveEventsAccess } = await import("./schedule-events");
|
||||
const access = await resolveViewerLiveEventsAccess(auth.viewerId, config);
|
||||
if (!access.enabled) {
|
||||
throw new AppError("FORBIDDEN", "Live Events is uitgeschakeld voor dit account", 403);
|
||||
}
|
||||
const { getIptvSettings } = await import("../settings/iptv");
|
||||
const s = await getIptvSettings(config);
|
||||
const disabled = new Set(s.liveEventsDisabledProviders.map((p) => p.toLowerCase()));
|
||||
if (disabled.has("viaplay")) {
|
||||
throw new AppError("NOT_FOUND", "Viaplay is uitgeschakeld", 404);
|
||||
}
|
||||
if (
|
||||
access.allowedProviders &&
|
||||
access.allowedProviders.length > 0 &&
|
||||
!access.allowedProviders.includes("viaplay")
|
||||
) {
|
||||
throw new AppError("NOT_FOUND", "Viaplay is niet beschikbaar voor dit account", 404);
|
||||
}
|
||||
const { isViaplayIntegrationEnabled } = await import("../viaplay/settings");
|
||||
if (!(await isViaplayIntegrationEnabled())) {
|
||||
throw new AppError("NOT_FOUND", "Viaplay is niet geconfigureerd", 404);
|
||||
}
|
||||
const { getViaplayHome } = await import("../viaplay/events");
|
||||
return getViaplayHome();
|
||||
});
|
||||
|
||||
app.get("/api/v1/client/hubs", async (request) => {
|
||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||
const { resolveViewerLiveEventsAccess } = await import("./schedule-events");
|
||||
const access = await resolveViewerLiveEventsAccess(auth.viewerId, config);
|
||||
if (!access.enabled) {
|
||||
return { hubs: [] as Array<{ id: string; label: string; subtitle: string }> };
|
||||
}
|
||||
const { getIptvSettings } = await import("../settings/iptv");
|
||||
const s = await getIptvSettings(config);
|
||||
const disabled = new Set(s.liveEventsDisabledProviders.map((p) => p.toLowerCase()));
|
||||
const allow = access.allowedProviders;
|
||||
const ok = (id: string) => {
|
||||
if (disabled.has(id)) return false;
|
||||
if (allow && allow.length > 0 && !allow.includes(id)) return false;
|
||||
return true;
|
||||
};
|
||||
const hubs: Array<{ id: string; label: string; subtitle: string }> = [];
|
||||
try {
|
||||
const { isF1tvIntegrationEnabled } = await import("../f1tv/settings");
|
||||
if (ok("f1tv") && (await isF1tvIntegrationEnabled())) {
|
||||
hubs.push({
|
||||
id: "f1",
|
||||
label: "Formule 1",
|
||||
subtitle: "Agenda & terugkijken",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const { isViaplayIntegrationEnabled } = await import("../viaplay/settings");
|
||||
if (ok("viaplay") && (await isViaplayIntegrationEnabled())) {
|
||||
hubs.push({
|
||||
id: "viaplay",
|
||||
label: "Viaplay",
|
||||
subtitle: "Live & agenda",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { hubs };
|
||||
});
|
||||
|
||||
app.get("/api/v1/client/f1/meetings/:meetingKey", async (request) => {
|
||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||
const { meetingKey } = request.params as { meetingKey: string };
|
||||
|
|
|
|||
Loading…
Reference in a new issue