Add Live Events tab to mobile app

This commit is contained in:
Jos Vooges | STH 2026-09-19 03:40:16 +02:00
parent 340480e14f
commit 9b6f8155d0
6 changed files with 1377 additions and 13 deletions

View file

@ -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")

View file

@ -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,

View file

@ -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,
)
}
}
}

View file

@ -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)
}

View file

@ -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()
} }

View file

@ -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(