From 9b6f8155d086dd01173d63e8f23f5977f6153188 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sat, 19 Sep 2026 03:40:16 +0200 Subject: [PATCH] Add Live Events tab to mobile app --- apps/android-tv/app-mobile/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 153 ++- .../mediacluster/tv/ui/EventPlayerScreen.kt | 274 ++++++ .../vonas/mediacluster/tv/ui/EventsScreen.kt | 879 ++++++++++++++++++ .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 59 +- .../mediacluster/tv/ui/components/TopNav.kt | 21 +- 6 files changed, 1377 insertions(+), 13 deletions(-) create mode 100644 apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt create mode 100644 apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt diff --git a/apps/android-tv/app-mobile/build.gradle.kts b/apps/android-tv/app-mobile/build.gradle.kts index 92ae9aa..0b4264d 100644 --- a/apps/android-tv/app-mobile/build.gradle.kts +++ b/apps/android-tv/app-mobile/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster" minSdk = 24 targetSdk = 36 - versionCode = 24 - versionName = "1.0.23" + versionCode = 25 + versionName = "1.0.24" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 5282559..c9d1c94 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -418,6 +418,7 @@ class ApiClient( parseObject(execute(req)) { o -> ClientMe( iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true, + eventsEnabled = o.optJSONObject("events")?.optBoolean("enabled") == true, profile = o.optJSONObject("profile")?.toProfile(), deviceId = o.optClean("deviceId"), ) @@ -562,7 +563,7 @@ class ApiClient( } /** - * Presence-heartbeat voor custom live-lists (best-effort). + * Presence-heartbeat voor custom live-lists / events (best-effort). */ suspend fun watchingHeartbeat( watchSessionId: String? = null, @@ -580,6 +581,123 @@ class ApiClient( Unit } + suspend fun listScheduleEvents(): ScheduleEventsPayload = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/events").get().build() + parseObject(execute(req)) { o -> + val arr = o.optJSONArray("events") ?: JSONArray() + val events = buildList { + for (i in 0 until arr.length()) { + arr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) } + } + } + val providersArr = o.optJSONArray("providers") + val providers = buildList { + if (providersArr != null) { + for (i in 0 until providersArr.length()) { + val p = providersArr.optString(i).trim() + if (p.isNotEmpty()) add(p) + } + } + } + ScheduleEventsPayload( + generatedAt = o.optClean("generatedAt"), + events = events, + providers = providers, + ) + } + } + + suspend fun getScheduleEvent(eventId: String): ScheduleEvent = withContext(Dispatchers.IO) { + val enc = android.net.Uri.encode(eventId) + val req = authed("$baseUrl/api/v1/client/events/$enc").get().build() + parseObject(execute(req)) { o -> + o.getJSONObject("event").toScheduleEvent() + ?: throw ApiException("Event niet gevonden") + } + } + + suspend fun scheduleEventPlay(eventId: String): IptvPlayInfo = withContext(Dispatchers.IO) { + val enc = android.net.Uri.encode(eventId) + val req = authed("$baseUrl/api/v1/client/events/$enc/play").get().build() + parseObject(execute(req)) { o -> + val drmObj = o.optJSONObject("drm") + val drm = if (drmObj != null) { + val keysArr = drmObj.optJSONArray("keys") ?: JSONArray() + val keys = buildList { + for (i in 0 until keysArr.length()) { + val k = keysArr.optJSONObject(i) ?: continue + val kid = k.optString("kid").ifBlank { null } ?: continue + val key = k.optString("key").ifBlank { null } ?: continue + add(ClearKeyEntry(kid = kid, key = key)) + } + } + DrmInfo( + type = drmObj.optString("type").ifBlank { "clearkey" }, + keys = keys, + ).takeIf { it.keys.isNotEmpty() } + } else null + IptvPlayInfo( + streamId = o.optString("eventId").ifBlank { eventId }, + name = o.getString("name"), + logoUrl = o.optString("logoUrl").ifBlank { null }, + streamUrl = o.getString("streamUrl"), + format = o.optString("format").ifBlank { "dash" }, + fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null }, + fallbackFormat = o.optString("fallbackFormat").ifBlank { null }, + drm = drm, + watchSessionId = o.optString("watchSessionId").ifBlank { null }, + ) + } + } + + private fun JSONObject.toScheduleEvent(): ScheduleEvent? { + val id = optClean("id") ?: return null + val name = optClean("name") ?: return null + val imagesArr = optJSONArray("images") + val images = buildList { + if (imagesArr != null) { + for (i in 0 until imagesArr.length()) { + val u = imagesArr.optString(i).trim() + if (u.isNotEmpty()) add(u) + } + } + } + val audioArr = optJSONArray("audioLanguages") + val audio = buildList { + if (audioArr != null) { + for (i in 0 until audioArr.length()) { + val a = audioArr.optString(i).trim() + if (a.isNotEmpty()) add(a) + } + } + } + return ScheduleEvent( + id = id, + name = name, + description = optClean("description"), + provider = optClean("provider") ?: "unknown", + category = optClean("category"), + eventType = optClean("eventType"), + subtitle = optClean("subtitle"), + channel = optClean("channel"), + network = optClean("network"), + isLive = optBoolean("isLive"), + phase = optClean("phase") ?: "upcoming", + playable = optBoolean("playable"), + status = optClean("status"), + start = optClean("start") ?: "", + stop = optClean("stop") ?: "", + startLocal = optClean("startLocal"), + stopLocal = optClean("stopLocal"), + imageLandscape = optClean("imageLandscape"), + imageBackdrop = optClean("imageBackdrop"), + imagePortrait = optClean("imagePortrait"), + logoUrl = optClean("logoUrl"), + images = images, + audioLanguages = audio, + ) + } + suspend fun iptvEpg(streamId: String): IptvEpg = withContext(Dispatchers.IO) { val enc = android.net.Uri.encode(streamId) val req = authed("$baseUrl/api/v1/client/iptv/channels/$enc/epg").get().build() @@ -881,10 +999,43 @@ data class ShelfPref( data class ClientMe( val iptvEnabled: Boolean, + val eventsEnabled: Boolean = false, val profile: ViewerProfile? = null, val deviceId: String? = null, ) +data class ScheduleEventsPayload( + val generatedAt: String? = null, + val events: List = emptyList(), + val providers: List = 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 = emptyList(), + val audioLanguages: List = emptyList(), +) + data class LinkedDevice( val id: String, val name: String, diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt new file mode 100644 index 0000000..c36930d --- /dev/null +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt @@ -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(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(null) } + var showChrome by remember { mutableStateOf(true) } + var watchSessionId by remember { mutableStateOf(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, + ) + } + } +} diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt new file mode 100644 index 0000000..eab41d5 --- /dev/null +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt @@ -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>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var refreshing by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var providerFilter by remember { mutableStateOf(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(null) } + var siblings by remember { mutableStateOf>(emptyList()) } + var error by remember { mutableStateOf(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, + 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 { + 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): List { + val donors = linkedMapOf() + 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, + donors: Map? = 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() + 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): List>> { + val today = LocalDate.now(ZoneNl) + val groups = linkedMapOf>() + 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) +} diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 1c640c9..8156f87 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -286,6 +286,10 @@ fun McNav( if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name nav.navigate("live/${android.net.Uri.encode(streamId)}") }, + onOpenEvent = { eventId -> + if (hubTabName != MainTab.Events.name) hubTabName = MainTab.Events.name + nav.navigate("event/${android.net.Uri.encode(eventId)}") + }, onSwitchProfile = { scope.launch { runCatching { api.clearProfile() } @@ -409,6 +413,35 @@ fun McNav( }, ) } + composable( + "event/{eventId}", + arguments = listOf(navArgument("eventId") { type = NavType.StringType }), + ) { entry -> + val eventId = entry.arguments?.getString("eventId") + ?.let { android.net.Uri.decode(it) } + ?: return@composable + EventDetailScreen( + api = api, + eventId = eventId, + onBack = { nav.popBackStack() }, + onPlay = { id -> + nav.navigate("event_play/${android.net.Uri.encode(id)}") + }, + ) + } + composable( + "event_play/{eventId}", + arguments = listOf(navArgument("eventId") { type = NavType.StringType }), + ) { entry -> + val eventId = entry.arguments?.getString("eventId") + ?.let { android.net.Uri.decode(it) } + ?: return@composable + EventPlayerScreen( + api = api, + eventId = eventId, + onBack = { nav.popBackStack() }, + ) + } composable( "vmc_remote/{deviceId}/{deviceName}", arguments = listOf( @@ -486,12 +519,14 @@ private fun MainHub( onBrowse: (catalogId: String, title: String) -> Unit, onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit, onPlayLive: (String) -> Unit, + onOpenEvent: (String) -> Unit, onSwitchProfile: () -> Unit, onManageProfiles: () -> Unit, onManageDevices: () -> Unit, onLogout: () -> Unit, ) { var iptvEnabled by remember { mutableStateOf(false) } + var eventsEnabled by remember { mutableStateOf(false) } var navFocused by remember { mutableStateOf(false) } var exitConfirm by remember { mutableStateOf(false) } val navFocus = remember { FocusRequester() } @@ -506,12 +541,18 @@ private fun MainHub( LaunchedEffect(Unit) { runCatching { shelfPrefs = api.getShelfPrefs().sortedBy { it.sortOrder } } - runCatching { iptvEnabled = api.clientMe().iptvEnabled } + runCatching { + val me = api.clientMe() + iptvEnabled = me.iptvEnabled + eventsEnabled = me.eventsEnabled + } } - val visibleTabs = remember(iptvEnabled) { + val visibleTabs = remember(iptvEnabled, eventsEnabled) { MainTab.entries.filter { - it != MainTab.Watchlist && (it != MainTab.Tv || iptvEnabled) + it != MainTab.Watchlist && + (it != MainTab.Tv || iptvEnabled) && + (it != MainTab.Events || eventsEnabled) } } @@ -589,8 +630,8 @@ private fun MainHub( onTabChange(MainTab.Home) focusTopNav() } - // TV: BrowseScreen-achtig — content scrollt terug, daarna top-nav - BackHandler(enabled = tab == MainTab.Tv && !navFocused && !exitConfirm) { + // TV / Events: content → top-nav + BackHandler(enabled = (tab == MainTab.Tv || tab == MainTab.Events) && !navFocused && !exitConfirm) { focusTopNav() } @@ -666,6 +707,11 @@ private fun MainHub( onPlayChannel = onPlayLive, onContentFocused = { navFocused = false }, ) + MainTab.Events -> EventsScreen( + api = api, + onOpenEvent = onOpenEvent, + onContentFocused = { navFocused = false }, + ) MainTab.Search -> SearchScreen( api = api, onOpenMovie = onOpenMovie, @@ -699,11 +745,14 @@ private fun MainHub( visibleTabs = visibleTabs, onSelect = { if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar + if (it == MainTab.Events && !eventsEnabled) return@TopNavBar onTabChange(it) exitConfirm = false if (it == MainTab.Tv) { navFocused = false onTvFocusContent() + } else if (it == MainTab.Events) { + navFocused = false } else { focusTopNav() } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt index 1ab7b00..8826e53 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt @@ -7,17 +7,17 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.PlayArrow @@ -45,6 +45,7 @@ enum class MainTab(val label: String) { Movies("Films"), Series("Series"), Tv("TV"), + Events("Events"), Search("Zoeken"), Settings("Instellingen"), } @@ -61,6 +62,7 @@ private fun MainTab.icon(): ImageVector = when (this) { MainTab.Movies -> Icons.Filled.PlayArrow MainTab.Series -> Icons.Filled.Menu MainTab.Tv -> Icons.Filled.Star + MainTab.Events -> Icons.Filled.DateRange MainTab.Search -> Icons.Filled.Search MainTab.Settings -> Icons.Filled.Settings } @@ -77,6 +79,7 @@ fun TopNavBar( onSelectSub: (SubNavItem) -> Unit = {}, modifier: Modifier = Modifier, ) { + val scrollTabs = visibleTabs.size > 6 Column( modifier .fillMaxWidth() @@ -114,8 +117,13 @@ fun TopNavBar( Row( Modifier .fillMaxWidth() + .then(if (scrollTabs) Modifier.horizontalScroll(rememberScrollState()) else Modifier) .padding(horizontal = 4.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.SpaceEvenly, + horizontalArrangement = if (scrollTabs) { + Arrangement.spacedBy(2.dp) + } else { + Arrangement.SpaceEvenly + }, verticalAlignment = Alignment.CenterVertically, ) { visibleTabs.forEach { tab -> @@ -126,10 +134,13 @@ fun TopNavBar( ) Column( Modifier - .weight(1f) + .then( + if (scrollTabs) Modifier.widthIn(min = 56.dp) + else Modifier.weight(1f), + ) .clip(RoundedCornerShape(10.dp)) .clickable { onSelect(tab) } - .padding(vertical = 6.dp), + .padding(vertical = 6.dp, horizontal = if (scrollTabs) 8.dp else 0.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Icon(