Add Live Events tab to mobile app
This commit is contained in:
parent
340480e14f
commit
9b6f8155d0
6 changed files with 1377 additions and 13 deletions
|
|
@ -20,8 +20,8 @@ android {
|
|||
applicationId = "nl.vonas.mediacluster"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 24
|
||||
versionName = "1.0.23"
|
||||
versionCode = 25
|
||||
versionName = "1.0.24"
|
||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
|
|
|
|||
|
|
@ -418,6 +418,7 @@ class ApiClient(
|
|||
parseObject(execute(req)) { o ->
|
||||
ClientMe(
|
||||
iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true,
|
||||
eventsEnabled = o.optJSONObject("events")?.optBoolean("enabled") == true,
|
||||
profile = o.optJSONObject("profile")?.toProfile(),
|
||||
deviceId = o.optClean("deviceId"),
|
||||
)
|
||||
|
|
@ -562,7 +563,7 @@ class ApiClient(
|
|||
}
|
||||
|
||||
/**
|
||||
* Presence-heartbeat voor custom live-lists (best-effort).
|
||||
* Presence-heartbeat voor custom live-lists / events (best-effort).
|
||||
*/
|
||||
suspend fun watchingHeartbeat(
|
||||
watchSessionId: String? = null,
|
||||
|
|
@ -580,6 +581,123 @@ class ApiClient(
|
|||
Unit
|
||||
}
|
||||
|
||||
suspend fun listScheduleEvents(): ScheduleEventsPayload = withContext(Dispatchers.IO) {
|
||||
val req = authed("$baseUrl/api/v1/client/events").get().build()
|
||||
parseObject(execute(req)) { o ->
|
||||
val arr = o.optJSONArray("events") ?: JSONArray()
|
||||
val events = buildList {
|
||||
for (i in 0 until arr.length()) {
|
||||
arr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) }
|
||||
}
|
||||
}
|
||||
val providersArr = o.optJSONArray("providers")
|
||||
val providers = buildList {
|
||||
if (providersArr != null) {
|
||||
for (i in 0 until providersArr.length()) {
|
||||
val p = providersArr.optString(i).trim()
|
||||
if (p.isNotEmpty()) add(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
ScheduleEventsPayload(
|
||||
generatedAt = o.optClean("generatedAt"),
|
||||
events = events,
|
||||
providers = providers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getScheduleEvent(eventId: String): ScheduleEvent = withContext(Dispatchers.IO) {
|
||||
val enc = android.net.Uri.encode(eventId)
|
||||
val req = authed("$baseUrl/api/v1/client/events/$enc").get().build()
|
||||
parseObject(execute(req)) { o ->
|
||||
o.getJSONObject("event").toScheduleEvent()
|
||||
?: throw ApiException("Event niet gevonden")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun scheduleEventPlay(eventId: String): IptvPlayInfo = withContext(Dispatchers.IO) {
|
||||
val enc = android.net.Uri.encode(eventId)
|
||||
val req = authed("$baseUrl/api/v1/client/events/$enc/play").get().build()
|
||||
parseObject(execute(req)) { o ->
|
||||
val drmObj = o.optJSONObject("drm")
|
||||
val drm = if (drmObj != null) {
|
||||
val keysArr = drmObj.optJSONArray("keys") ?: JSONArray()
|
||||
val keys = buildList {
|
||||
for (i in 0 until keysArr.length()) {
|
||||
val k = keysArr.optJSONObject(i) ?: continue
|
||||
val kid = k.optString("kid").ifBlank { null } ?: continue
|
||||
val key = k.optString("key").ifBlank { null } ?: continue
|
||||
add(ClearKeyEntry(kid = kid, key = key))
|
||||
}
|
||||
}
|
||||
DrmInfo(
|
||||
type = drmObj.optString("type").ifBlank { "clearkey" },
|
||||
keys = keys,
|
||||
).takeIf { it.keys.isNotEmpty() }
|
||||
} else null
|
||||
IptvPlayInfo(
|
||||
streamId = o.optString("eventId").ifBlank { eventId },
|
||||
name = o.getString("name"),
|
||||
logoUrl = o.optString("logoUrl").ifBlank { null },
|
||||
streamUrl = o.getString("streamUrl"),
|
||||
format = o.optString("format").ifBlank { "dash" },
|
||||
fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null },
|
||||
fallbackFormat = o.optString("fallbackFormat").ifBlank { null },
|
||||
drm = drm,
|
||||
watchSessionId = o.optString("watchSessionId").ifBlank { null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONObject.toScheduleEvent(): ScheduleEvent? {
|
||||
val id = optClean("id") ?: return null
|
||||
val name = optClean("name") ?: return null
|
||||
val imagesArr = optJSONArray("images")
|
||||
val images = buildList {
|
||||
if (imagesArr != null) {
|
||||
for (i in 0 until imagesArr.length()) {
|
||||
val u = imagesArr.optString(i).trim()
|
||||
if (u.isNotEmpty()) add(u)
|
||||
}
|
||||
}
|
||||
}
|
||||
val audioArr = optJSONArray("audioLanguages")
|
||||
val audio = buildList {
|
||||
if (audioArr != null) {
|
||||
for (i in 0 until audioArr.length()) {
|
||||
val a = audioArr.optString(i).trim()
|
||||
if (a.isNotEmpty()) add(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ScheduleEvent(
|
||||
id = id,
|
||||
name = name,
|
||||
description = optClean("description"),
|
||||
provider = optClean("provider") ?: "unknown",
|
||||
category = optClean("category"),
|
||||
eventType = optClean("eventType"),
|
||||
subtitle = optClean("subtitle"),
|
||||
channel = optClean("channel"),
|
||||
network = optClean("network"),
|
||||
isLive = optBoolean("isLive"),
|
||||
phase = optClean("phase") ?: "upcoming",
|
||||
playable = optBoolean("playable"),
|
||||
status = optClean("status"),
|
||||
start = optClean("start") ?: "",
|
||||
stop = optClean("stop") ?: "",
|
||||
startLocal = optClean("startLocal"),
|
||||
stopLocal = optClean("stopLocal"),
|
||||
imageLandscape = optClean("imageLandscape"),
|
||||
imageBackdrop = optClean("imageBackdrop"),
|
||||
imagePortrait = optClean("imagePortrait"),
|
||||
logoUrl = optClean("logoUrl"),
|
||||
images = images,
|
||||
audioLanguages = audio,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun iptvEpg(streamId: String): IptvEpg = withContext(Dispatchers.IO) {
|
||||
val enc = android.net.Uri.encode(streamId)
|
||||
val req = authed("$baseUrl/api/v1/client/iptv/channels/$enc/epg").get().build()
|
||||
|
|
@ -881,10 +999,43 @@ data class ShelfPref(
|
|||
|
||||
data class ClientMe(
|
||||
val iptvEnabled: Boolean,
|
||||
val eventsEnabled: Boolean = false,
|
||||
val profile: ViewerProfile? = null,
|
||||
val deviceId: String? = null,
|
||||
)
|
||||
|
||||
data class ScheduleEventsPayload(
|
||||
val generatedAt: String? = null,
|
||||
val events: List<ScheduleEvent> = emptyList(),
|
||||
val providers: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class ScheduleEvent(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val provider: String,
|
||||
val category: String? = null,
|
||||
val eventType: String? = null,
|
||||
val subtitle: String? = null,
|
||||
val channel: String? = null,
|
||||
val network: String? = null,
|
||||
val isLive: Boolean = false,
|
||||
val phase: String = "upcoming",
|
||||
val playable: Boolean = false,
|
||||
val status: String? = null,
|
||||
val start: String = "",
|
||||
val stop: String = "",
|
||||
val startLocal: String? = null,
|
||||
val stopLocal: String? = null,
|
||||
val imageLandscape: String? = null,
|
||||
val imageBackdrop: String? = null,
|
||||
val imagePortrait: String? = null,
|
||||
val logoUrl: String? = null,
|
||||
val images: List<String> = emptyList(),
|
||||
val audioLanguages: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class LinkedDevice(
|
||||
val id: String,
|
||||
val name: String,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,274 @@
|
|||
package nl.vonas.mediacluster.tv.ui
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
|
||||
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
|
||||
import nl.vonas.mediacluster.tv.playback.liveMediaItem
|
||||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private const val ReadyTimeoutMs = 20_000L
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun EventPlayerScreen(
|
||||
api: ApiClient,
|
||||
eventId: String,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val playerRef = remember { AtomicReference<ExoPlayer?>(null) }
|
||||
val player = remember {
|
||||
buildLiveTvPlayer(context).also { playerRef.set(it) }
|
||||
}
|
||||
val reachedReady = remember { AtomicBoolean(false) }
|
||||
var title by remember { mutableStateOf("Live event") }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var showChrome by remember { mutableStateOf(true) }
|
||||
var watchSessionId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
RememberPlayerImmersiveMode()
|
||||
|
||||
fun safeStopAndClear() {
|
||||
runCatching {
|
||||
player.playWhenReady = false
|
||||
player.stop()
|
||||
player.clearMediaItems()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(eventId) {
|
||||
loading = true
|
||||
error = null
|
||||
watchSessionId = null
|
||||
reachedReady.set(false)
|
||||
showChrome = true
|
||||
try {
|
||||
val play = api.scheduleEventPlay(eventId)
|
||||
if (!isActive) return@LaunchedEffect
|
||||
title = play.name
|
||||
watchSessionId = play.watchSessionId
|
||||
val url = play.streamUrl.trim()
|
||||
if (url.isEmpty()) {
|
||||
error = "Geen stream-URL beschikbaar"
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (play.drm == null || play.drm.keys.isEmpty()) {
|
||||
error = "Encryptiesleutels ontbreken — stream start binnenkort"
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val item = try {
|
||||
liveMediaItem(url, play.format, play.drm)
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: "Ongeldige DRM-sleutels"
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (!isActive) return@LaunchedEffect
|
||||
try {
|
||||
player.setMediaItem(item)
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: "Kon speler niet starten"
|
||||
safeStopAndClear()
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: "Kon event niet afspelen"
|
||||
safeStopAndClear()
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(watchSessionId, error, loading) {
|
||||
val session = watchSessionId ?: return@LaunchedEffect
|
||||
if (error != null || loading) return@LaunchedEffect
|
||||
while (isActive) {
|
||||
runCatching {
|
||||
api.watchingHeartbeat(
|
||||
watchSessionId = session,
|
||||
kind = "event",
|
||||
id = eventId,
|
||||
)
|
||||
}
|
||||
delay(25_000)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(eventId, error, loading) {
|
||||
if (error != null || loading) return@LaunchedEffect
|
||||
delay(ReadyTimeoutMs)
|
||||
if (!reachedReady.get() && error == null) {
|
||||
error = "Stream start niet (timeout). Tik op Terug."
|
||||
safeStopAndClear()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showChrome, error, loading) {
|
||||
if (showChrome && error == null && !loading) {
|
||||
delay(4_000)
|
||||
showChrome = false
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
when (state) {
|
||||
Player.STATE_READY -> {
|
||||
reachedReady.set(true)
|
||||
runCatching { player.ensureAudioSelected() }
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(e: PlaybackException) {
|
||||
val cause = generateSequence(e as Throwable?) { it.cause }
|
||||
.mapNotNull { it.message?.takeIf { m -> m.isNotBlank() } }
|
||||
.distinct()
|
||||
.take(3)
|
||||
.joinToString(" → ")
|
||||
error = listOfNotNull(e.errorCodeName, cause.takeIf { it.isNotBlank() })
|
||||
.joinToString(": ")
|
||||
.ifBlank { e.message ?: "Afspeelfout" }
|
||||
safeStopAndClear()
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
onDispose {
|
||||
player.removeListener(listener)
|
||||
runCatching {
|
||||
player.playWhenReady = false
|
||||
player.stop()
|
||||
player.clearMediaItems()
|
||||
}
|
||||
runCatching { player.release() }
|
||||
playerRef.compareAndSet(player, null)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {
|
||||
if (error == null && !loading) showChrome = !showChrome
|
||||
},
|
||||
) {
|
||||
if (error == null) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
useController = false
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
this.player = playerRef.get()
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
view.player = playerRef.get()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Stream starten…", color = Color.White, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
if (error != null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"Kan event niet afspelen",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
)
|
||||
Text(
|
||||
error!!,
|
||||
color = McColors.Danger,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp),
|
||||
)
|
||||
McButton(
|
||||
"Terug",
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
onClick = onBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (showChrome) {
|
||||
Text(
|
||||
title,
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.statusBarsPadding()
|
||||
.padding(20.dp),
|
||||
)
|
||||
McButton(
|
||||
"Terug",
|
||||
compact = true,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.statusBarsPadding()
|
||||
.padding(16.dp),
|
||||
onClick = onBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,879 @@
|
|||
package nl.vonas.mediacluster.tv.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
import nl.vonas.mediacluster.tv.data.ScheduleEvent
|
||||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Locale
|
||||
|
||||
private val ZoneNl: ZoneId = ZoneId.of("Europe/Amsterdam")
|
||||
private val TimeFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||
private val DayTimeFmt: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("EEE d MMM · HH:mm", Locale("nl", "NL"))
|
||||
private val StreamLabelRegex = Regex("""^stream\s*\d+""", RegexOption.IGNORE_CASE)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun EventsScreen(
|
||||
api: ApiClient,
|
||||
onOpenEvent: (String) -> Unit,
|
||||
onContentFocused: () -> Unit = {},
|
||||
) {
|
||||
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var providerFilter by remember { mutableStateOf<String?>(null) }
|
||||
var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
suspend fun refresh(isPull: Boolean = false) {
|
||||
if (isPull) refreshing = true
|
||||
runCatching { api.listScheduleEvents() }
|
||||
.onSuccess {
|
||||
events = enrichEventImages(it.events)
|
||||
error = null
|
||||
}
|
||||
.onFailure { error = it.message ?: "Kon events niet laden" }
|
||||
loading = false
|
||||
refreshing = false
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { refresh() }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(75_000)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(30_000)
|
||||
nowMs = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
val filtered = remember(events, providerFilter) {
|
||||
if (providerFilter.isNullOrBlank()) events
|
||||
else events.filter { it.provider.equals(providerFilter, ignoreCase = true) }
|
||||
}
|
||||
val live = remember(filtered, nowMs) {
|
||||
filtered.filter { eventIsLive(it, nowMs) }
|
||||
.sortedByDescending { it.playable }
|
||||
}
|
||||
val byDay = remember(filtered, nowMs) { groupEventsByDay(filtered) }
|
||||
val providers = remember(events) {
|
||||
events.map { it.provider }.distinct().sorted()
|
||||
}
|
||||
val showProviderChips = providers.size > 1
|
||||
val upcomingSections = remember(byDay, nowMs) {
|
||||
byDay.mapNotNull { (label, dayEvents) ->
|
||||
val upcoming = dayEvents.filter { !eventIsLive(it, nowMs) }
|
||||
if (upcoming.isEmpty()) null else label to upcoming
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(McColors.Bg)
|
||||
.statusBarsPadding(),
|
||||
) {
|
||||
Text(
|
||||
"Events",
|
||||
color = McColors.Text,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
|
||||
if (showProviderChips) {
|
||||
ProviderChips(
|
||||
providers = providers,
|
||||
selected = providerFilter,
|
||||
onSelect = {
|
||||
providerFilter = it
|
||||
onContentFocused()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
PullToRefreshBox(
|
||||
isRefreshing = refreshing,
|
||||
onRefresh = {
|
||||
scope.launch { refresh(isPull = true) }
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
when {
|
||||
loading && events.isEmpty() -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Events laden…", color = McColors.TextDim, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
error != null && events.isEmpty() -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(error!!, color = McColors.Danger, fontSize = 15.sp)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
McButton("Opnieuw", compact = true) {
|
||||
scope.launch { refresh() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
filtered.isEmpty() -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
if (providerFilter != null) {
|
||||
"Geen events voor ${prettyProvider(providerFilter!!)}"
|
||||
} else {
|
||||
"Geen events in de komende dagen"
|
||||
},
|
||||
color = McColors.TextDim,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
if (live.isNotEmpty()) {
|
||||
item(key = "live-header") {
|
||||
SectionHeader("Nu live")
|
||||
}
|
||||
items(live, key = { "live-${it.id}" }) { ev ->
|
||||
EventRow(
|
||||
event = ev,
|
||||
nowMs = nowMs,
|
||||
onClick = {
|
||||
onContentFocused()
|
||||
onOpenEvent(ev.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
upcomingSections.forEach { (label, dayEvents) ->
|
||||
item(key = "day-$label") {
|
||||
SectionHeader(label)
|
||||
}
|
||||
items(dayEvents, key = { "day-$label-${it.id}" }) { ev ->
|
||||
EventRow(
|
||||
event = ev,
|
||||
nowMs = nowMs,
|
||||
onClick = {
|
||||
onContentFocused()
|
||||
onOpenEvent(ev.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun EventDetailScreen(
|
||||
api: ApiClient,
|
||||
eventId: String,
|
||||
onBack: () -> Unit,
|
||||
onPlay: (String) -> Unit,
|
||||
) {
|
||||
var event by remember { mutableStateOf<ScheduleEvent?>(null) }
|
||||
var siblings by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
|
||||
LaunchedEffect(eventId) {
|
||||
runCatching {
|
||||
val list = api.listScheduleEvents()
|
||||
siblings = enrichEventImages(list.events)
|
||||
api.getScheduleEvent(eventId)
|
||||
}
|
||||
.onSuccess { raw ->
|
||||
event = enrichSingleEvent(raw, siblings)
|
||||
error = null
|
||||
}
|
||||
.onFailure { error = it.message ?: "Event niet gevonden" }
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(30_000)
|
||||
nowMs = System.currentTimeMillis()
|
||||
runCatching { api.getScheduleEvent(eventId) }
|
||||
.onSuccess { event = enrichSingleEvent(it, siblings) }
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
val ev = event
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(McColors.Bg)
|
||||
.statusBarsPadding(),
|
||||
) {
|
||||
when {
|
||||
ev == null && error == null -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Laden…", color = McColors.TextDim, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
ev == null -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(error ?: "Fout", color = McColors.Danger)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
McButton("Terug", onClick = onBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val showSubtitle = !ev.subtitle.isNullOrBlank() &&
|
||||
!ev.subtitle.equals(ev.name, ignoreCase = true)
|
||||
val showDescription = !ev.description.isNullOrBlank()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(220.dp)
|
||||
.background(Color(0xFF1A1A1A)),
|
||||
) {
|
||||
val art = eventBackdropUrl(ev)
|
||||
if (art != null) {
|
||||
AsyncImage(
|
||||
model = art,
|
||||
contentDescription = ev.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color(0x33000000),
|
||||
0.55f to Color.Transparent,
|
||||
1.0f to Color(0xEE0B0B0B),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(12.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onBack)
|
||||
.background(Color(0x66000000))
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text("Terug", color = Color.White, fontSize = 13.sp)
|
||||
}
|
||||
ev.logoUrl?.takeIf { it.isNotBlank() }?.let { logo ->
|
||||
AsyncImage(
|
||||
model = logo,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(12.dp)
|
||||
.height(36.dp)
|
||||
.width(96.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.CenterEnd,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
StatusBadge(ev, nowMs)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
ev.name,
|
||||
color = Color.White,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
lineHeight = 28.sp,
|
||||
)
|
||||
val meta = detailMetaLine(ev)
|
||||
if (meta.isNotBlank()) {
|
||||
Text(
|
||||
meta,
|
||||
color = Color(0xFFCFCFCF),
|
||||
fontSize = 14.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
val window = detailTimeWindowLabel(ev, nowMs)
|
||||
if (window.isNotBlank()) {
|
||||
Text(
|
||||
window,
|
||||
color = McColors.Text,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
if (showSubtitle) {
|
||||
Text(
|
||||
ev.subtitle!!,
|
||||
color = McColors.TextDim,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (showDescription) {
|
||||
Text(
|
||||
ev.description!!,
|
||||
color = Color(0xFFE8E8E8),
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 22.sp,
|
||||
)
|
||||
}
|
||||
if (ev.audioLanguages.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Audio",
|
||||
color = Color(0xFF8F8F8F),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
) {
|
||||
ev.audioLanguages.take(8).forEach { lang ->
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color(0x33FFFFFF))
|
||||
.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||
) {
|
||||
Text(
|
||||
lang.uppercase(),
|
||||
color = Color(0xFFDDDDDD),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(McColors.BgElevated)
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (ev.playable) {
|
||||
McButton(
|
||||
"Kijk live",
|
||||
primary = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { onPlay(ev.id) },
|
||||
)
|
||||
McButton("Terug", onClick = onBack)
|
||||
} else {
|
||||
McButton(
|
||||
ctaLabel(ev, nowMs),
|
||||
enabled = false,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {},
|
||||
)
|
||||
McButton("Terug", primary = true, onClick = onBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
title,
|
||||
color = McColors.Text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EventRow(
|
||||
event: ScheduleEvent,
|
||||
nowMs: Long,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(112.dp)
|
||||
.height(63.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFF1A1A1A)),
|
||||
) {
|
||||
val art = eventArtUrl(event)
|
||||
if (art != null) {
|
||||
AsyncImage(
|
||||
model = art,
|
||||
contentDescription = event.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
prettyProvider(event.provider),
|
||||
color = McColors.TextDim,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
StatusBadge(event, nowMs, compact = true)
|
||||
Text(
|
||||
shortTimeLabel(event, nowMs),
|
||||
color = McColors.TextDim,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
event.name,
|
||||
color = McColors.Text,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
lineHeight = 18.sp,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
listOfNotNull(
|
||||
prettyProvider(event.provider),
|
||||
displayChannel(event),
|
||||
).distinct().joinToString(" · "),
|
||||
color = McColors.TextDim,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ProviderChips(
|
||||
providers: List<String>,
|
||||
selected: String?,
|
||||
onSelect: (String?) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
.padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Chip(
|
||||
label = "Alles",
|
||||
active = selected == null,
|
||||
onClick = { onSelect(null) },
|
||||
)
|
||||
providers.forEach { p ->
|
||||
Chip(
|
||||
label = prettyProvider(p),
|
||||
active = selected.equals(p, ignoreCase = true),
|
||||
onClick = { onSelect(p) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun Chip(
|
||||
label: String,
|
||||
active: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(if (active) McColors.Accent else McColors.Surface)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun StatusBadge(event: ScheduleEvent, nowMs: Long, compact: Boolean = false) {
|
||||
val (label, bg) = statusVisual(event, nowMs)
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(bg)
|
||||
.padding(
|
||||
horizontal = if (compact) 8.dp else 10.dp,
|
||||
vertical = if (compact) 3.dp else 5.dp,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = Color.White,
|
||||
fontSize = if (compact) 11.sp else 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusVisual(event: ScheduleEvent, nowMs: Long): Pair<String, Color> {
|
||||
return when {
|
||||
eventIsLive(event, nowMs) -> "LIVE" to McColors.Accent
|
||||
minutesUntilStart(event, nowMs)?.let { it in 0 until 60 } == true -> {
|
||||
val m = minutesUntilStart(event, nowMs)!!
|
||||
"Straks · over ${m} min" to Color(0xFFE67E22)
|
||||
}
|
||||
else -> "Gepland" to Color(0xFF3A3A3A)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ctaLabel(event: ScheduleEvent, nowMs: Long): String {
|
||||
if (eventIsLive(event, nowMs)) return "Stream start binnenkort"
|
||||
val mins = minutesUntilStart(event, nowMs)
|
||||
if (mins != null && mins in 0 until 60) return "Start over $mins min"
|
||||
val start = parseZoned(event.startLocal, event.start) ?: return "Meer info"
|
||||
return "Start om ${start.format(TimeFmt)}"
|
||||
}
|
||||
|
||||
private fun shortTimeLabel(event: ScheduleEvent, nowMs: Long): String {
|
||||
if (eventIsLive(event, nowMs)) {
|
||||
val left = minutesUntilEnd(event, nowMs)
|
||||
return if (left != null && left > 0) "Nog $left min" else "Nu"
|
||||
}
|
||||
val mins = minutesUntilStart(event, nowMs)
|
||||
if (mins != null && mins in 0 until 60) return "Over $mins min"
|
||||
val start = parseZoned(event.startLocal, event.start) ?: return ""
|
||||
val today = LocalDate.now(ZoneNl)
|
||||
val day = start.toLocalDate()
|
||||
return when {
|
||||
day == today -> "Vandaag ${start.format(TimeFmt)}"
|
||||
day == today.plusDays(1) -> "Morgen ${start.format(TimeFmt)}"
|
||||
else -> start.format(DayTimeFmt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun detailTimeWindowLabel(event: ScheduleEvent, nowMs: Long): String {
|
||||
val start = parseZoned(event.startLocal, event.start) ?: return ""
|
||||
val stop = parseZoned(event.stopLocal, event.stop)
|
||||
val range = if (stop != null) {
|
||||
"${start.format(TimeFmt)} – ${stop.format(TimeFmt)}"
|
||||
} else {
|
||||
start.format(TimeFmt)
|
||||
}
|
||||
if (eventIsLive(event, nowMs)) return range
|
||||
val today = Instant.ofEpochMilli(nowMs).atZone(ZoneNl).toLocalDate()
|
||||
val day = start.toLocalDate()
|
||||
return when {
|
||||
day == today -> "Vandaag · $range"
|
||||
day == today.plusDays(1) -> "Morgen · $range"
|
||||
else -> "${start.format(DayTimeFmt)}${stop?.let { " – ${it.format(TimeFmt)}" } ?: ""}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun eventIsLive(event: ScheduleEvent, nowMs: Long): Boolean {
|
||||
if (event.phase.equals("live", ignoreCase = true) || event.isLive) return true
|
||||
val start = parseZoned(event.startLocal, event.start) ?: return false
|
||||
val stop = parseZoned(event.stopLocal, event.stop) ?: return false
|
||||
val now = Instant.ofEpochMilli(nowMs).atZone(ZoneNl)
|
||||
return !now.isBefore(start) && now.isBefore(stop)
|
||||
}
|
||||
|
||||
private fun isStreamLabel(value: String?): Boolean {
|
||||
val s = value?.trim().orEmpty()
|
||||
if (s.isEmpty()) return false
|
||||
return StreamLabelRegex.containsMatchIn(s)
|
||||
}
|
||||
|
||||
private fun displayChannel(event: ScheduleEvent): String? {
|
||||
val raw = event.channel?.takeIf { it.isNotBlank() } ?: event.network
|
||||
return raw?.takeIf { !isStreamLabel(it) }
|
||||
}
|
||||
|
||||
private fun detailMetaLine(event: ScheduleEvent): String =
|
||||
listOfNotNull(
|
||||
prettyProvider(event.provider),
|
||||
event.category?.takeIf { it.isNotBlank() },
|
||||
event.eventType?.takeIf { it.isNotBlank() },
|
||||
displayChannel(event),
|
||||
).distinct().joinToString(" · ")
|
||||
|
||||
private fun eventArtUrl(event: ScheduleEvent): String? =
|
||||
event.imageLandscape?.takeIf { it.isNotBlank() }
|
||||
?: event.images.firstOrNull { looksLikeArtwork(it) && !it.contains("hero-16x9", ignoreCase = true) }
|
||||
?: event.images.firstOrNull { looksLikeArtwork(it) }
|
||||
?: event.imagePortrait?.takeIf { it.isNotBlank() }
|
||||
?: event.images.firstOrNull { it.isNotBlank() && !it.contains("assets/channels", ignoreCase = true) }
|
||||
|
||||
private fun eventBackdropUrl(event: ScheduleEvent): String? =
|
||||
event.imageBackdrop?.takeIf { it.isNotBlank() }
|
||||
?: event.images.firstOrNull { it.contains("hero-16x9", ignoreCase = true) }
|
||||
?: eventArtUrl(event)
|
||||
|
||||
private fun looksLikeArtwork(url: String): Boolean {
|
||||
val u = url.trim()
|
||||
if (u.isEmpty()) return false
|
||||
val lower = u.lowercase()
|
||||
if (lower.contains("assets/channels")) return false
|
||||
if (lower.contains("channels-logo")) return false
|
||||
if (lower.contains("/logos/") && lower.endsWith(".png")) return false
|
||||
if (lower.contains("/logo") || lower.contains("logo.") || lower.contains("logo_")) {
|
||||
if (!lower.contains("boxart") && !lower.contains("hero-")) return false
|
||||
}
|
||||
if (lower.endsWith(".svg")) return false
|
||||
return true
|
||||
}
|
||||
|
||||
private fun prettyProvider(raw: String): String = when (raw.lowercase()) {
|
||||
"viaplay" -> "Viaplay"
|
||||
"ziggosport" -> "Ziggo Sport"
|
||||
"espn" -> "ESPN"
|
||||
"appletv" -> "Apple TV"
|
||||
"prime_hbo" -> "Prime / HBO"
|
||||
"prime" -> "Prime Video"
|
||||
"disneyplus" -> "Disney+"
|
||||
"eyecons" -> "Eyecons"
|
||||
"odido" -> "Odido"
|
||||
else -> raw.replace('_', ' ').replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
private fun normalizeChannelKey(value: String?): String =
|
||||
value?.trim()?.lowercase()?.replace(Regex("\\s+"), " ").orEmpty()
|
||||
|
||||
private fun donorImageScore(e: ScheduleEvent): Int {
|
||||
var s = 0
|
||||
if (!e.imageLandscape.isNullOrBlank()) s += 20
|
||||
if (!e.imageBackdrop.isNullOrBlank()) s += 10
|
||||
if (e.images.any { it.contains("thumb", true) || it.contains("akamaized.net/live", true) }) s += 30
|
||||
if (!e.logoUrl.isNullOrBlank()) s += 5
|
||||
s += e.images.size.coerceAtMost(5)
|
||||
if (e.provider.equals("ziggosport", true)) s += 8
|
||||
if (e.playable) s += 3
|
||||
return s
|
||||
}
|
||||
|
||||
private fun enrichEventImages(events: List<ScheduleEvent>): List<ScheduleEvent> {
|
||||
val donors = linkedMapOf<String, ScheduleEvent>()
|
||||
for (e in events) {
|
||||
if (e.imageLandscape.isNullOrBlank() && e.images.isEmpty() && e.logoUrl.isNullOrBlank()) continue
|
||||
for (key in listOf(e.channel, e.network).map { normalizeChannelKey(it) }.filter { it.isNotEmpty() }) {
|
||||
val existing = donors[key]
|
||||
if (existing == null || donorImageScore(e) > donorImageScore(existing)) {
|
||||
donors[key] = e
|
||||
}
|
||||
}
|
||||
}
|
||||
return events.map { e -> enrichSingleEvent(e, events, donors) }
|
||||
}
|
||||
|
||||
private fun enrichSingleEvent(
|
||||
e: ScheduleEvent,
|
||||
siblings: List<ScheduleEvent>,
|
||||
donors: Map<String, ScheduleEvent>? = null,
|
||||
): ScheduleEvent {
|
||||
if (!e.imageLandscape.isNullOrBlank() || e.images.isNotEmpty()) {
|
||||
return e.copy(
|
||||
channel = e.channel?.takeIf { !isStreamLabel(it) },
|
||||
network = e.network?.takeIf { !isStreamLabel(it) },
|
||||
)
|
||||
}
|
||||
val map = donors ?: run {
|
||||
val built = linkedMapOf<String, ScheduleEvent>()
|
||||
for (sib in siblings) {
|
||||
if (sib.imageLandscape.isNullOrBlank() && sib.images.isEmpty()) continue
|
||||
for (key in listOf(sib.channel, sib.network).map { normalizeChannelKey(it) }.filter { it.isNotEmpty() }) {
|
||||
val existing = built[key]
|
||||
if (existing == null || donorImageScore(sib) > donorImageScore(existing)) {
|
||||
built[key] = sib
|
||||
}
|
||||
}
|
||||
}
|
||||
built
|
||||
}
|
||||
var donor: ScheduleEvent? = null
|
||||
for (key in listOf(e.channel, e.network).map { normalizeChannelKey(it) }.filter { it.isNotEmpty() }) {
|
||||
val hit = map[key]
|
||||
if (hit != null && hit.id != e.id) {
|
||||
donor = hit
|
||||
break
|
||||
}
|
||||
}
|
||||
val cleaned = e.copy(
|
||||
channel = e.channel?.takeIf { !isStreamLabel(it) },
|
||||
network = e.network?.takeIf { !isStreamLabel(it) },
|
||||
)
|
||||
if (donor == null) return cleaned
|
||||
return cleaned.copy(
|
||||
imageLandscape = cleaned.imageLandscape ?: donor.imageLandscape,
|
||||
imageBackdrop = cleaned.imageBackdrop ?: donor.imageBackdrop,
|
||||
imagePortrait = cleaned.imagePortrait ?: donor.imagePortrait,
|
||||
logoUrl = cleaned.logoUrl ?: donor.logoUrl,
|
||||
images = cleaned.images.ifEmpty { donor.images },
|
||||
)
|
||||
}
|
||||
|
||||
private fun groupEventsByDay(events: List<ScheduleEvent>): List<Pair<String, List<ScheduleEvent>>> {
|
||||
val today = LocalDate.now(ZoneNl)
|
||||
val groups = linkedMapOf<String, MutableList<ScheduleEvent>>()
|
||||
for (e in events) {
|
||||
val start = parseZoned(e.startLocal, e.start) ?: continue
|
||||
val day = start.toLocalDate()
|
||||
val label = when {
|
||||
day == today -> "Vandaag"
|
||||
day == today.plusDays(1) -> "Morgen"
|
||||
day == today.plusDays(2) -> "Overmorgen"
|
||||
else -> day.format(DateTimeFormatter.ofPattern("EEEE d MMMM", Locale("nl", "NL")))
|
||||
}
|
||||
groups.getOrPut(label) { mutableListOf() }.add(e)
|
||||
}
|
||||
return groups.map { it.key to it.value }
|
||||
}
|
||||
|
||||
private fun parseZoned(local: String?, utc: String): ZonedDateTime? {
|
||||
val raw = local?.takeIf { it.isNotBlank() } ?: utc.takeIf { it.isNotBlank() } ?: return null
|
||||
return runCatching {
|
||||
if (raw.contains('T')) {
|
||||
val odt = java.time.OffsetDateTime.parse(raw)
|
||||
odt.atZoneSameInstant(ZoneNl)
|
||||
} else null
|
||||
}.getOrNull() ?: runCatching {
|
||||
Instant.parse(if (raw.endsWith("Z") || raw.contains('+')) raw else "${raw}Z")
|
||||
.atZone(ZoneNl)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun minutesUntilStart(event: ScheduleEvent, nowMs: Long): Long? {
|
||||
val start = parseZoned(event.startLocal, event.start) ?: return null
|
||||
return ChronoUnit.MINUTES.between(Instant.ofEpochMilli(nowMs).atZone(ZoneNl), start)
|
||||
}
|
||||
|
||||
private fun minutesUntilEnd(event: ScheduleEvent, nowMs: Long): Long? {
|
||||
val stop = parseZoned(event.stopLocal, event.stop) ?: return null
|
||||
return ChronoUnit.MINUTES.between(Instant.ofEpochMilli(nowMs).atZone(ZoneNl), stop)
|
||||
}
|
||||
|
|
@ -286,6 +286,10 @@ fun McNav(
|
|||
if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name
|
||||
nav.navigate("live/${android.net.Uri.encode(streamId)}")
|
||||
},
|
||||
onOpenEvent = { eventId ->
|
||||
if (hubTabName != MainTab.Events.name) hubTabName = MainTab.Events.name
|
||||
nav.navigate("event/${android.net.Uri.encode(eventId)}")
|
||||
},
|
||||
onSwitchProfile = {
|
||||
scope.launch {
|
||||
runCatching { api.clearProfile() }
|
||||
|
|
@ -409,6 +413,35 @@ fun McNav(
|
|||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"event/{eventId}",
|
||||
arguments = listOf(navArgument("eventId") { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
val eventId = entry.arguments?.getString("eventId")
|
||||
?.let { android.net.Uri.decode(it) }
|
||||
?: return@composable
|
||||
EventDetailScreen(
|
||||
api = api,
|
||||
eventId = eventId,
|
||||
onBack = { nav.popBackStack() },
|
||||
onPlay = { id ->
|
||||
nav.navigate("event_play/${android.net.Uri.encode(id)}")
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"event_play/{eventId}",
|
||||
arguments = listOf(navArgument("eventId") { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
val eventId = entry.arguments?.getString("eventId")
|
||||
?.let { android.net.Uri.decode(it) }
|
||||
?: return@composable
|
||||
EventPlayerScreen(
|
||||
api = api,
|
||||
eventId = eventId,
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"vmc_remote/{deviceId}/{deviceName}",
|
||||
arguments = listOf(
|
||||
|
|
@ -486,12 +519,14 @@ private fun MainHub(
|
|||
onBrowse: (catalogId: String, title: String) -> Unit,
|
||||
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
|
||||
onPlayLive: (String) -> Unit,
|
||||
onOpenEvent: (String) -> Unit,
|
||||
onSwitchProfile: () -> Unit,
|
||||
onManageProfiles: () -> Unit,
|
||||
onManageDevices: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
) {
|
||||
var iptvEnabled by remember { mutableStateOf(false) }
|
||||
var eventsEnabled by remember { mutableStateOf(false) }
|
||||
var navFocused by remember { mutableStateOf(false) }
|
||||
var exitConfirm by remember { mutableStateOf(false) }
|
||||
val navFocus = remember { FocusRequester() }
|
||||
|
|
@ -506,12 +541,18 @@ private fun MainHub(
|
|||
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { shelfPrefs = api.getShelfPrefs().sortedBy { it.sortOrder } }
|
||||
runCatching { iptvEnabled = api.clientMe().iptvEnabled }
|
||||
runCatching {
|
||||
val me = api.clientMe()
|
||||
iptvEnabled = me.iptvEnabled
|
||||
eventsEnabled = me.eventsEnabled
|
||||
}
|
||||
}
|
||||
|
||||
val visibleTabs = remember(iptvEnabled) {
|
||||
val visibleTabs = remember(iptvEnabled, eventsEnabled) {
|
||||
MainTab.entries.filter {
|
||||
it != MainTab.Watchlist && (it != MainTab.Tv || iptvEnabled)
|
||||
it != MainTab.Watchlist &&
|
||||
(it != MainTab.Tv || iptvEnabled) &&
|
||||
(it != MainTab.Events || eventsEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -589,8 +630,8 @@ private fun MainHub(
|
|||
onTabChange(MainTab.Home)
|
||||
focusTopNav()
|
||||
}
|
||||
// TV: BrowseScreen-achtig — content scrollt terug, daarna top-nav
|
||||
BackHandler(enabled = tab == MainTab.Tv && !navFocused && !exitConfirm) {
|
||||
// TV / Events: content → top-nav
|
||||
BackHandler(enabled = (tab == MainTab.Tv || tab == MainTab.Events) && !navFocused && !exitConfirm) {
|
||||
focusTopNav()
|
||||
}
|
||||
|
||||
|
|
@ -666,6 +707,11 @@ private fun MainHub(
|
|||
onPlayChannel = onPlayLive,
|
||||
onContentFocused = { navFocused = false },
|
||||
)
|
||||
MainTab.Events -> EventsScreen(
|
||||
api = api,
|
||||
onOpenEvent = onOpenEvent,
|
||||
onContentFocused = { navFocused = false },
|
||||
)
|
||||
MainTab.Search -> SearchScreen(
|
||||
api = api,
|
||||
onOpenMovie = onOpenMovie,
|
||||
|
|
@ -699,11 +745,14 @@ private fun MainHub(
|
|||
visibleTabs = visibleTabs,
|
||||
onSelect = {
|
||||
if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar
|
||||
if (it == MainTab.Events && !eventsEnabled) return@TopNavBar
|
||||
onTabChange(it)
|
||||
exitConfirm = false
|
||||
if (it == MainTab.Tv) {
|
||||
navFocused = false
|
||||
onTvFocusContent()
|
||||
} else if (it == MainTab.Events) {
|
||||
navFocused = false
|
||||
} else {
|
||||
focusTopNav()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,17 +7,17 @@ import androidx.compose.foundation.horizontalScroll
|
|||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
|
|
@ -45,6 +45,7 @@ enum class MainTab(val label: String) {
|
|||
Movies("Films"),
|
||||
Series("Series"),
|
||||
Tv("TV"),
|
||||
Events("Events"),
|
||||
Search("Zoeken"),
|
||||
Settings("Instellingen"),
|
||||
}
|
||||
|
|
@ -61,6 +62,7 @@ private fun MainTab.icon(): ImageVector = when (this) {
|
|||
MainTab.Movies -> Icons.Filled.PlayArrow
|
||||
MainTab.Series -> Icons.Filled.Menu
|
||||
MainTab.Tv -> Icons.Filled.Star
|
||||
MainTab.Events -> Icons.Filled.DateRange
|
||||
MainTab.Search -> Icons.Filled.Search
|
||||
MainTab.Settings -> Icons.Filled.Settings
|
||||
}
|
||||
|
|
@ -77,6 +79,7 @@ fun TopNavBar(
|
|||
onSelectSub: (SubNavItem) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrollTabs = visibleTabs.size > 6
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -114,8 +117,13 @@ fun TopNavBar(
|
|||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (scrollTabs) Modifier.horizontalScroll(rememberScrollState()) else Modifier)
|
||||
.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
horizontalArrangement = if (scrollTabs) {
|
||||
Arrangement.spacedBy(2.dp)
|
||||
} else {
|
||||
Arrangement.SpaceEvenly
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
visibleTabs.forEach { tab ->
|
||||
|
|
@ -126,10 +134,13 @@ fun TopNavBar(
|
|||
)
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.then(
|
||||
if (scrollTabs) Modifier.widthIn(min = 56.dp)
|
||||
else Modifier.weight(1f),
|
||||
)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable { onSelect(tab) }
|
||||
.padding(vertical = 6.dp),
|
||||
.padding(vertical = 6.dp, horizontal = if (scrollTabs) 8.dp else 0.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
|
|
|
|||
Loading…
Reference in a new issue