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) {
|
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()
|
||||||
|
|
@ -1285,6 +1342,28 @@ data class F1tvHomePayload(
|
||||||
val nextMeeting: F1tvMeeting? = null,
|
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(
|
data class F1tvMeetingDetail(
|
||||||
val meeting: F1tvMeeting? = null,
|
val meeting: F1tvMeeting? = null,
|
||||||
val sessions: List<ScheduleEvent> = emptyList(),
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
|
@ -75,6 +76,7 @@ import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
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.ScheduleEvent
|
||||||
import nl.vonas.mediacluster.tv.data.SportBucketOrder
|
import nl.vonas.mediacluster.tv.data.SportBucketOrder
|
||||||
import nl.vonas.mediacluster.tv.data.sportLabelForBucket
|
import nl.vonas.mediacluster.tv.data.sportLabelForBucket
|
||||||
|
|
@ -120,9 +122,11 @@ fun EventsScreen(
|
||||||
onFocusRestoreConsumed: () -> Unit = {},
|
onFocusRestoreConsumed: () -> Unit = {},
|
||||||
onOpenEvent: (String) -> Unit,
|
onOpenEvent: (String) -> Unit,
|
||||||
onOpenF1Hub: (() -> Unit)? = null,
|
onOpenF1Hub: (() -> Unit)? = null,
|
||||||
|
onOpenViaplayHub: (() -> Unit)? = null,
|
||||||
onContentFocused: () -> Unit = {},
|
onContentFocused: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
|
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
|
||||||
|
var hubs by remember { mutableStateOf<List<EventHubInfo>>(emptyList()) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var sportFilter by remember { mutableStateOf<String?>(null) }
|
var sportFilter by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
@ -145,6 +149,24 @@ fun EventsScreen(
|
||||||
error = null
|
error = null
|
||||||
}
|
}
|
||||||
.onFailure { error = it.message ?: "Kon events niet laden" }
|
.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
|
loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,13 +247,13 @@ fun EventsScreen(
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus: TopNav → F1-tile (optioneel) → filterrij → first rail.
|
// Focus: TopNav → hub-rail (optioneel) → filterrij → first rail.
|
||||||
val hasF1Entry = onOpenF1Hub != null
|
val hasHubRail = hubs.isNotEmpty()
|
||||||
val showFilterBar = showSportChips || showProviderChips
|
val showFilterBar = showSportChips || showProviderChips
|
||||||
val filterBarFocus = remember { FocusRequester() }
|
val filterBarFocus = remember { FocusRequester() }
|
||||||
val firstRailUpTarget = when {
|
val firstRailUpTarget = when {
|
||||||
showFilterBar && filterBarFocusReady -> filterBarFocus
|
showFilterBar && filterBarFocusReady -> filterBarFocus
|
||||||
hasF1Entry -> contentFocusRequester
|
hasHubRail -> contentFocusRequester
|
||||||
else -> navFocusRequester
|
else -> navFocusRequester
|
||||||
}
|
}
|
||||||
val filterFocusDown: FocusRequester? = when {
|
val filterFocusDown: FocusRequester? = when {
|
||||||
|
|
@ -239,7 +261,7 @@ fun EventsScreen(
|
||||||
railFocusReady -> firstRailFocus
|
railFocusReady -> firstRailFocus
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
val railUsesDedicatedFocus = hasF1Entry || showFilterBar
|
val railUsesDedicatedFocus = hasHubRail || showFilterBar
|
||||||
|
|
||||||
fun claimRailFocus(owner: String, ready: Boolean) {
|
fun claimRailFocus(owner: String, ready: Boolean) {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
|
|
@ -270,7 +292,7 @@ fun EventsScreen(
|
||||||
}
|
}
|
||||||
val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0
|
val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0
|
||||||
val restoreListIndex = remember(
|
val restoreListIndex = remember(
|
||||||
hasF1Entry,
|
hasHubRail,
|
||||||
showFilterBar,
|
showFilterBar,
|
||||||
live,
|
live,
|
||||||
upcomingRails,
|
upcomingRails,
|
||||||
|
|
@ -279,7 +301,7 @@ fun EventsScreen(
|
||||||
) {
|
) {
|
||||||
if (!canRestoreCard) return@remember -1
|
if (!canRestoreCard) return@remember -1
|
||||||
var idx = 0
|
var idx = 0
|
||||||
if (hasF1Entry) idx++
|
if (hasHubRail) idx++
|
||||||
if (showFilterBar) idx++
|
if (showFilterBar) idx++
|
||||||
if (restoreInLive) return@remember idx
|
if (restoreInLive) return@remember idx
|
||||||
if (live.isNotEmpty()) idx++
|
if (live.isNotEmpty()) idx++
|
||||||
|
|
@ -320,14 +342,14 @@ fun EventsScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
val entryReady = when {
|
val entryReady = when {
|
||||||
hasF1Entry -> true
|
hasHubRail -> true
|
||||||
showFilterBar -> filterBarFocusReady
|
showFilterBar -> filterBarFocusReady
|
||||||
else -> railFocusReady
|
else -> railFocusReady
|
||||||
}
|
}
|
||||||
val needContentSink = when {
|
val needContentSink = when {
|
||||||
loading && events.isEmpty() -> true
|
loading && events.isEmpty() -> true
|
||||||
error != null && events.isEmpty() -> true
|
error != null && events.isEmpty() -> true
|
||||||
hasF1Entry -> false
|
hasHubRail -> false
|
||||||
showFilterBar -> !filterBarFocusReady
|
showFilterBar -> !filterBarFocusReady
|
||||||
!hasRails -> true
|
!hasRails -> true
|
||||||
else -> !railFocusReady
|
else -> !railFocusReady
|
||||||
|
|
@ -352,7 +374,7 @@ fun EventsScreen(
|
||||||
if (restoredSuccessfully) return
|
if (restoredSuccessfully) return
|
||||||
val entryOk = awaitReady({
|
val entryOk = awaitReady({
|
||||||
when {
|
when {
|
||||||
hasF1Entry -> true
|
hasHubRail -> true
|
||||||
showFilterBar -> filterBarFocusReady
|
showFilterBar -> filterBarFocusReady
|
||||||
else -> railFocusReady
|
else -> railFocusReady
|
||||||
}
|
}
|
||||||
|
|
@ -475,12 +497,14 @@ fun EventsScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 56.dp),
|
contentPadding = PaddingValues(bottom = 56.dp),
|
||||||
) {
|
) {
|
||||||
if (hasF1Entry) {
|
if (hasHubRail) {
|
||||||
item(key = "f1_hub_entry") {
|
item(key = "hub_rail") {
|
||||||
F1HubEntryTile(
|
EventsHubRail(
|
||||||
|
hubs = hubs,
|
||||||
focusRequester = contentFocusRequester,
|
focusRequester = contentFocusRequester,
|
||||||
focusUp = navFocusRequester,
|
focusUp = navFocusRequester,
|
||||||
onOpen = onOpenF1Hub!!,
|
onOpenF1 = onOpenF1Hub,
|
||||||
|
onOpenViaplay = onOpenViaplayHub,
|
||||||
onFocus = onContentFocused,
|
onFocus = onContentFocused,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -498,8 +522,8 @@ fun EventsScreen(
|
||||||
onProviderSelect = { providerFilter = it },
|
onProviderSelect = { providerFilter = it },
|
||||||
liveCount = live.size,
|
liveCount = live.size,
|
||||||
totalCount = filtered.size,
|
totalCount = filtered.size,
|
||||||
firstFocusRequester = if (hasF1Entry) filterBarFocus else contentFocusRequester,
|
firstFocusRequester = if (hasHubRail) filterBarFocus else contentFocusRequester,
|
||||||
focusUp = if (hasF1Entry) contentFocusRequester else navFocusRequester,
|
focusUp = if (hasHubRail) contentFocusRequester else navFocusRequester,
|
||||||
focusDown = filterFocusDown,
|
focusDown = filterFocusDown,
|
||||||
onFocusReady = { filterBarFocusReady = it },
|
onFocusReady = { filterBarFocusReady = it },
|
||||||
onFocus = onContentFocused,
|
onFocus = onContentFocused,
|
||||||
|
|
@ -1078,21 +1102,69 @@ private fun PulsingLiveDot(size: androidx.compose.ui.unit.Dp = 10.dp) {
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@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?,
|
focusRequester: FocusRequester?,
|
||||||
focusUp: FocusRequester?,
|
focusUp: FocusRequester?,
|
||||||
onOpen: () -> Unit,
|
onOpen: () -> Unit,
|
||||||
onFocus: () -> Unit,
|
onFocus: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var focused by remember { mutableStateOf(false) }
|
var focused by remember { mutableStateOf(false) }
|
||||||
val f1Red = Color(0xFFE10600)
|
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onOpen,
|
onClick = onOpen,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.height(52.dp)
|
||||||
.padding(horizontal = HomeChrome.EdgeInset)
|
.widthIn(min = 168.dp, max = 220.dp)
|
||||||
.padding(top = 8.dp, bottom = 10.dp)
|
|
||||||
.height(72.dp)
|
|
||||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||||
.then(
|
.then(
|
||||||
if (focusUp != null) {
|
if (focusUp != null) {
|
||||||
|
|
@ -1105,63 +1177,63 @@ private fun F1HubEntryTile(
|
||||||
focused = it.isFocused
|
focused = it.isFocused
|
||||||
if (it.isFocused) onFocus()
|
if (it.isFocused) onFocus()
|
||||||
},
|
},
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
|
||||||
glow = ClickableSurfaceDefaults.glow(
|
glow = ClickableSurfaceDefaults.glow(
|
||||||
focusedGlow = Glow(elevationColor = f1Red, elevation = 14.dp),
|
focusedGlow = Glow(elevationColor = accent, elevation = 8.dp),
|
||||||
),
|
),
|
||||||
border = ClickableSurfaceDefaults.border(
|
border = ClickableSurfaceDefaults.border(
|
||||||
focusedBorder = Border(
|
focusedBorder = Border(
|
||||||
border = BorderStroke(2.5.dp, f1Red),
|
border = BorderStroke(2.dp, accent),
|
||||||
inset = 4.dp,
|
inset = 2.dp,
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = Color(0xFF141414),
|
containerColor = Color(0xFF14141A),
|
||||||
focusedContainerColor = Color(0xFF1E1212),
|
focusedContainerColor = Color(0xFF1A1A22),
|
||||||
),
|
),
|
||||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 20.dp),
|
.padding(horizontal = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.size(36.dp)
|
.size(28.dp)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(7.dp))
|
||||||
.background(f1Red),
|
.background(accent),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"F1",
|
badge,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 13.sp,
|
fontSize = 11.sp,
|
||||||
fontWeight = FontWeight.Black,
|
fontWeight = FontWeight.Black,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
"Formule 1",
|
label,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 18.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
maxLines = 1,
|
||||||
Text(
|
overflow = TextOverflow.Ellipsis,
|
||||||
"Agenda & terugkijken",
|
|
||||||
color = McColors.TextDim,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
)
|
)
|
||||||
|
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
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onOpenViaplayHub = {
|
||||||
|
nav.navigate("viaplay_hub") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
onSwitchProfile = {
|
onSwitchProfile = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { api.clearProfile() }
|
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(
|
composable(
|
||||||
"f1_meeting/{meetingKey}",
|
"f1_meeting/{meetingKey}",
|
||||||
arguments = listOf(navArgument("meetingKey") { type = NavType.StringType }),
|
arguments = listOf(navArgument("meetingKey") { type = NavType.StringType }),
|
||||||
|
|
@ -666,6 +689,7 @@ private fun MainHub(
|
||||||
onPlayLive: (String) -> Unit,
|
onPlayLive: (String) -> Unit,
|
||||||
onOpenEvent: (String) -> Unit,
|
onOpenEvent: (String) -> Unit,
|
||||||
onOpenF1Hub: () -> Unit = {},
|
onOpenF1Hub: () -> Unit = {},
|
||||||
|
onOpenViaplayHub: () -> Unit = {},
|
||||||
onSwitchProfile: () -> Unit,
|
onSwitchProfile: () -> Unit,
|
||||||
onManageProfiles: () -> Unit,
|
onManageProfiles: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
|
|
@ -1064,6 +1088,7 @@ private fun MainHub(
|
||||||
onFocusRestoreConsumed = onEventsFocusRestoreConsumed,
|
onFocusRestoreConsumed = onEventsFocusRestoreConsumed,
|
||||||
onOpenEvent = onOpenEvent,
|
onOpenEvent = onOpenEvent,
|
||||||
onOpenF1Hub = onOpenF1Hub,
|
onOpenF1Hub = onOpenF1Hub,
|
||||||
|
onOpenViaplayHub = onOpenViaplayHub,
|
||||||
onContentFocused = { navFocused = false },
|
onContentFocused = { navFocused = false },
|
||||||
)
|
)
|
||||||
MainTab.Search -> SearchScreen(
|
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;
|
if (!event) return null;
|
||||||
return eventToPublic(event, nowMs);
|
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);
|
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(
|
export async function saveViaplaySyncResult(
|
||||||
summary: Record<string, unknown>,
|
summary: Record<string, unknown>,
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
|
|
@ -901,6 +901,78 @@ export function registerViewerRoutes(
|
||||||
return getF1tvHome();
|
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) => {
|
app.get("/api/v1/client/f1/meetings/:meetingKey", async (request) => {
|
||||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
const { meetingKey } = request.params as { meetingKey: string };
|
const { meetingKey } = request.params as { meetingKey: string };
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue