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"
|
applicationId = "nl.vonas.mediacluster"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 24
|
versionCode = 25
|
||||||
versionName = "1.0.23"
|
versionName = "1.0.24"
|
||||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
|
|
||||||
|
|
@ -418,6 +418,7 @@ class ApiClient(
|
||||||
parseObject(execute(req)) { o ->
|
parseObject(execute(req)) { o ->
|
||||||
ClientMe(
|
ClientMe(
|
||||||
iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true,
|
iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true,
|
||||||
|
eventsEnabled = o.optJSONObject("events")?.optBoolean("enabled") == true,
|
||||||
profile = o.optJSONObject("profile")?.toProfile(),
|
profile = o.optJSONObject("profile")?.toProfile(),
|
||||||
deviceId = o.optClean("deviceId"),
|
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(
|
suspend fun watchingHeartbeat(
|
||||||
watchSessionId: String? = null,
|
watchSessionId: String? = null,
|
||||||
|
|
@ -580,6 +581,123 @@ class ApiClient(
|
||||||
Unit
|
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) {
|
suspend fun iptvEpg(streamId: String): IptvEpg = withContext(Dispatchers.IO) {
|
||||||
val enc = android.net.Uri.encode(streamId)
|
val enc = android.net.Uri.encode(streamId)
|
||||||
val req = authed("$baseUrl/api/v1/client/iptv/channels/$enc/epg").get().build()
|
val req = authed("$baseUrl/api/v1/client/iptv/channels/$enc/epg").get().build()
|
||||||
|
|
@ -881,10 +999,43 @@ data class ShelfPref(
|
||||||
|
|
||||||
data class ClientMe(
|
data class ClientMe(
|
||||||
val iptvEnabled: Boolean,
|
val iptvEnabled: Boolean,
|
||||||
|
val eventsEnabled: Boolean = false,
|
||||||
val profile: ViewerProfile? = null,
|
val profile: ViewerProfile? = null,
|
||||||
val deviceId: String? = 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(
|
data class LinkedDevice(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: 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
|
if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name
|
||||||
nav.navigate("live/${android.net.Uri.encode(streamId)}")
|
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 = {
|
onSwitchProfile = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { api.clearProfile() }
|
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(
|
composable(
|
||||||
"vmc_remote/{deviceId}/{deviceName}",
|
"vmc_remote/{deviceId}/{deviceName}",
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
|
|
@ -486,12 +519,14 @@ private fun MainHub(
|
||||||
onBrowse: (catalogId: String, title: String) -> Unit,
|
onBrowse: (catalogId: String, title: String) -> Unit,
|
||||||
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
|
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
|
||||||
onPlayLive: (String) -> Unit,
|
onPlayLive: (String) -> Unit,
|
||||||
|
onOpenEvent: (String) -> Unit,
|
||||||
onSwitchProfile: () -> Unit,
|
onSwitchProfile: () -> Unit,
|
||||||
onManageProfiles: () -> Unit,
|
onManageProfiles: () -> Unit,
|
||||||
onManageDevices: () -> Unit,
|
onManageDevices: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var iptvEnabled by remember { mutableStateOf(false) }
|
var iptvEnabled by remember { mutableStateOf(false) }
|
||||||
|
var eventsEnabled by remember { mutableStateOf(false) }
|
||||||
var navFocused by remember { mutableStateOf(false) }
|
var navFocused by remember { mutableStateOf(false) }
|
||||||
var exitConfirm by remember { mutableStateOf(false) }
|
var exitConfirm by remember { mutableStateOf(false) }
|
||||||
val navFocus = remember { FocusRequester() }
|
val navFocus = remember { FocusRequester() }
|
||||||
|
|
@ -506,12 +541,18 @@ private fun MainHub(
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
runCatching { shelfPrefs = api.getShelfPrefs().sortedBy { it.sortOrder } }
|
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 {
|
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)
|
onTabChange(MainTab.Home)
|
||||||
focusTopNav()
|
focusTopNav()
|
||||||
}
|
}
|
||||||
// TV: BrowseScreen-achtig — content scrollt terug, daarna top-nav
|
// TV / Events: content → top-nav
|
||||||
BackHandler(enabled = tab == MainTab.Tv && !navFocused && !exitConfirm) {
|
BackHandler(enabled = (tab == MainTab.Tv || tab == MainTab.Events) && !navFocused && !exitConfirm) {
|
||||||
focusTopNav()
|
focusTopNav()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -666,6 +707,11 @@ private fun MainHub(
|
||||||
onPlayChannel = onPlayLive,
|
onPlayChannel = onPlayLive,
|
||||||
onContentFocused = { navFocused = false },
|
onContentFocused = { navFocused = false },
|
||||||
)
|
)
|
||||||
|
MainTab.Events -> EventsScreen(
|
||||||
|
api = api,
|
||||||
|
onOpenEvent = onOpenEvent,
|
||||||
|
onContentFocused = { navFocused = false },
|
||||||
|
)
|
||||||
MainTab.Search -> SearchScreen(
|
MainTab.Search -> SearchScreen(
|
||||||
api = api,
|
api = api,
|
||||||
onOpenMovie = onOpenMovie,
|
onOpenMovie = onOpenMovie,
|
||||||
|
|
@ -699,11 +745,14 @@ private fun MainHub(
|
||||||
visibleTabs = visibleTabs,
|
visibleTabs = visibleTabs,
|
||||||
onSelect = {
|
onSelect = {
|
||||||
if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar
|
if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar
|
||||||
|
if (it == MainTab.Events && !eventsEnabled) return@TopNavBar
|
||||||
onTabChange(it)
|
onTabChange(it)
|
||||||
exitConfirm = false
|
exitConfirm = false
|
||||||
if (it == MainTab.Tv) {
|
if (it == MainTab.Tv) {
|
||||||
navFocused = false
|
navFocused = false
|
||||||
onTvFocusContent()
|
onTvFocusContent()
|
||||||
|
} else if (it == MainTab.Events) {
|
||||||
|
navFocused = false
|
||||||
} else {
|
} else {
|
||||||
focusTopNav()
|
focusTopNav()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,17 @@ import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.Home
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
|
@ -45,6 +45,7 @@ enum class MainTab(val label: String) {
|
||||||
Movies("Films"),
|
Movies("Films"),
|
||||||
Series("Series"),
|
Series("Series"),
|
||||||
Tv("TV"),
|
Tv("TV"),
|
||||||
|
Events("Events"),
|
||||||
Search("Zoeken"),
|
Search("Zoeken"),
|
||||||
Settings("Instellingen"),
|
Settings("Instellingen"),
|
||||||
}
|
}
|
||||||
|
|
@ -61,6 +62,7 @@ private fun MainTab.icon(): ImageVector = when (this) {
|
||||||
MainTab.Movies -> Icons.Filled.PlayArrow
|
MainTab.Movies -> Icons.Filled.PlayArrow
|
||||||
MainTab.Series -> Icons.Filled.Menu
|
MainTab.Series -> Icons.Filled.Menu
|
||||||
MainTab.Tv -> Icons.Filled.Star
|
MainTab.Tv -> Icons.Filled.Star
|
||||||
|
MainTab.Events -> Icons.Filled.DateRange
|
||||||
MainTab.Search -> Icons.Filled.Search
|
MainTab.Search -> Icons.Filled.Search
|
||||||
MainTab.Settings -> Icons.Filled.Settings
|
MainTab.Settings -> Icons.Filled.Settings
|
||||||
}
|
}
|
||||||
|
|
@ -77,6 +79,7 @@ fun TopNavBar(
|
||||||
onSelectSub: (SubNavItem) -> Unit = {},
|
onSelectSub: (SubNavItem) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val scrollTabs = visibleTabs.size > 6
|
||||||
Column(
|
Column(
|
||||||
modifier
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -114,8 +117,13 @@ fun TopNavBar(
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.then(if (scrollTabs) Modifier.horizontalScroll(rememberScrollState()) else Modifier)
|
||||||
.padding(horizontal = 4.dp, vertical = 6.dp),
|
.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = if (scrollTabs) {
|
||||||
|
Arrangement.spacedBy(2.dp)
|
||||||
|
} else {
|
||||||
|
Arrangement.SpaceEvenly
|
||||||
|
},
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
visibleTabs.forEach { tab ->
|
visibleTabs.forEach { tab ->
|
||||||
|
|
@ -126,10 +134,13 @@ fun TopNavBar(
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.then(
|
||||||
|
if (scrollTabs) Modifier.widthIn(min = 56.dp)
|
||||||
|
else Modifier.weight(1f),
|
||||||
|
)
|
||||||
.clip(RoundedCornerShape(10.dp))
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.clickable { onSelect(tab) }
|
.clickable { onSelect(tab) }
|
||||||
.padding(vertical = 6.dp),
|
.padding(vertical = 6.dp, horizontal = if (scrollTabs) 8.dp else 0.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue