From 8cfae01e4bb1680fd64ea12d7d96adf039329892 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 2 Sep 2026 01:16:53 +0200 Subject: [PATCH] Add in-play channel switcher overlay for live TV. Press up during playback to open a glass-style side panel with the current category channels; switch zenders without closing the menu until back is pressed. Co-authored-by: Cursor --- apps/android-tv/app/build.gradle.kts | 4 +- .../mediacluster/tv/ui/LiveTvPlayerScreen.kt | 322 ++++++++++++++++-- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 2 + 3 files changed, 307 insertions(+), 21 deletions(-) diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 9f8b540..4c344e3 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 35 - versionCode = 35 - versionName = "0.11.3" + versionCode = 36 + versionName = "0.11.4" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt index 2b2f0a7..a3af85f 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt @@ -4,22 +4,36 @@ import android.view.ViewGroup import android.widget.FrameLayout import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -46,11 +60,14 @@ import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView +import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Surface import androidx.tv.material3.Text import coil.compose.AsyncImage import kotlinx.coroutines.delay import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.data.IptvChannel import nl.vonas.mediacluster.tv.data.IptvEpg import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer import nl.vonas.mediacluster.tv.playback.ensureAudioSelected @@ -62,38 +79,47 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors fun LiveTvPlayerScreen( api: ApiClient, streamId: Int, + categoryId: String?, + onStreamChange: (Int) -> Unit, onBack: () -> Unit, ) { val context = LocalContext.current val player = remember { buildLiveTvPlayer(context) } - val focus = remember { FocusRequester() } + val playerFocus = remember { FocusRequester() } + var currentStreamId by remember { mutableIntStateOf(streamId) } var loading by remember { mutableStateOf(true) } + var switching by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } var channelName by remember { mutableStateOf("") } var logoUrl by remember { mutableStateOf(null) } var epg by remember { mutableStateOf(null) } - var overlay by remember { mutableStateOf(true) } + var infoOverlay by remember { mutableStateOf(true) } + var channelMenuOpen by remember { mutableStateOf(false) } var fallbackUrl by remember { mutableStateOf(null) } var fallbackFormat by remember { mutableStateOf(null) } var triedFallback by remember { mutableStateOf(false) } + var menuChannels by remember { mutableStateOf>(emptyList()) } + var menuCategoryName by remember { mutableStateOf("Zenders") } + var menuLoading by remember { mutableStateOf(false) } + val menuListState = rememberLazyListState() + val menuChannelFocus = remember { mutableStateMapOf() } + fun startStream(url: String, format: String) { player.setMediaItem(liveMediaItem(url, format)) player.prepare() player.playWhenReady = true } - LaunchedEffect(streamId) { - loading = true + suspend fun loadAndPlay(streamIdToPlay: Int, isSwitch: Boolean) { + if (isSwitch) switching = true else loading = true error = null triedFallback = false - epg = null try { - val play = api.iptvPlayUrl(streamId) + val play = api.iptvPlayUrl(streamIdToPlay) channelName = play.name logoUrl = play.logoUrl - // API levert classic TS eerst; lokaal nog TS forceren als fallback in response zit val tsUrl = when { play.format.equals("ts", ignoreCase = true) -> play.streamUrl play.fallbackFormat?.equals("ts", ignoreCase = true) == true -> play.fallbackStreamUrl @@ -115,22 +141,59 @@ fun LiveTvPlayerScreen( fallbackFormat = play.fallbackFormat startStream(play.streamUrl, play.format) } + onStreamChange(streamIdToPlay) } catch (e: Exception) { error = e.message ?: "Kon zender niet starten" } finally { loading = false + switching = false } } LaunchedEffect(streamId) { - delay(2_500) - runCatching { epg = api.iptvEpg(streamId) } + currentStreamId = streamId } - LaunchedEffect(overlay) { - if (overlay) { + LaunchedEffect(currentStreamId) { + epg = null + loadAndPlay(currentStreamId, isSwitch = currentStreamId != streamId) + } + + LaunchedEffect(currentStreamId) { + delay(2_500) + runCatching { epg = api.iptvEpg(currentStreamId) } + } + + LaunchedEffect(channelMenuOpen, categoryId) { + if (!channelMenuOpen) return@LaunchedEffect + menuLoading = true + try { + val cats = runCatching { api.iptvCategories() }.getOrDefault(emptyList()) + menuCategoryName = cats.firstOrNull { it.id == categoryId }?.name ?: "Zenders" + menuChannels = if (!categoryId.isNullOrBlank()) { + api.iptvChannels(categoryId) + } else { + api.iptvChannels() + } + } catch (_: Exception) { + menuChannels = emptyList() + } finally { + menuLoading = false + } + } + + LaunchedEffect(channelMenuOpen, menuChannels, currentStreamId) { + if (!channelMenuOpen || menuChannels.isEmpty()) return@LaunchedEffect + val idx = menuChannels.indexOfFirst { it.streamId == currentStreamId }.coerceAtLeast(0) + menuListState.scrollToItem(idx) + delay(100) + menuChannelFocus[currentStreamId]?.let { runCatching { it.requestFocus() } } + } + + LaunchedEffect(infoOverlay) { + if (infoOverlay && !channelMenuOpen) { delay(5000) - overlay = false + infoOverlay = false } } @@ -159,27 +222,43 @@ fun LiveTvPlayerScreen( } } - BackHandler { onBack() } + BackHandler(enabled = channelMenuOpen) { + channelMenuOpen = false + } + BackHandler(enabled = !channelMenuOpen) { + onBack() + } + + fun selectChannel(newStreamId: Int) { + if (newStreamId == currentStreamId) return + currentStreamId = newStreamId + infoOverlay = true + } Box( Modifier .fillMaxSize() .background(Color.Black) - .focusRequester(focus) + .focusRequester(playerFocus) .focusable() .onPreviewKeyEvent { ev -> + if (channelMenuOpen) return@onPreviewKeyEvent false if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false when (ev.key) { Key.Back, Key.Escape -> { onBack() true } + Key.DirectionUp -> { + channelMenuOpen = true + true + } Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { - overlay = !overlay + infoOverlay = !infoOverlay true } else -> { - overlay = true + infoOverlay = true false } } @@ -203,11 +282,18 @@ fun LiveTvPlayerScreen( LaunchedEffect(Unit) { delay(200) - runCatching { focus.requestFocus() } + runCatching { playerFocus.requestFocus() } + } + + LaunchedEffect(channelMenuOpen) { + if (!channelMenuOpen) { + delay(80) + runCatching { playerFocus.requestFocus() } + } } AnimatedVisibility( - visible = overlay && error == null, + visible = infoOverlay && !channelMenuOpen && error == null, enter = fadeIn(), exit = fadeOut(), ) { @@ -281,7 +367,32 @@ fun LiveTvPlayerScreen( } } - if (loading) { + AnimatedVisibility( + visible = channelMenuOpen, + enter = fadeIn(tween(180)) + slideInHorizontally(tween(220)) { it }, + exit = fadeOut(tween(150)) + slideOutHorizontally(tween(200)) { it }, + ) { + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f)), + ) + LiveChannelSwitcher( + categoryName = menuCategoryName, + channels = menuChannels, + currentStreamId = currentStreamId, + loading = menuLoading, + switching = switching, + listState = menuListState, + channelFocus = menuChannelFocus, + onSelect = { selectChannel(it) }, + modifier = Modifier.align(Alignment.CenterEnd), + ) + } + } + + if (loading && !channelMenuOpen) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Zender laden…", color = Color.White, fontSize = 18.sp) } @@ -303,3 +414,176 @@ fun LiveTvPlayerScreen( } } } + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun LiveChannelSwitcher( + categoryName: String, + channels: List, + currentStreamId: Int, + loading: Boolean, + switching: Boolean, + listState: androidx.compose.foundation.lazy.LazyListState, + channelFocus: MutableMap, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val glassShape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp) + Box( + modifier + .padding(top = 28.dp, bottom = 28.dp, end = 0.dp) + .width(340.dp) + .fillMaxHeight() + .clip(glassShape) + .background( + Brush.horizontalGradient( + listOf(Color(0xF0181820), Color(0xE8101018)), + ), + ) + .border( + width = 1.dp, + brush = Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.18f), Color.White.copy(alpha = 0.06f)), + ), + shape = glassShape, + ), + ) { + Column(Modifier.fillMaxSize()) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 16.dp), + ) { + Text( + "Zenders", + color = McColors.TextDim, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 1.sp, + ) + Text( + categoryName, + color = Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (switching) { + Text("Wisselen…", color = McColors.Brand, fontSize = 12.sp, modifier = Modifier.padding(top = 4.dp)) + } + } + Box( + Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color.White.copy(alpha = 0.08f)), + ) + when { + loading -> { + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text("Laden…", color = McColors.TextDim, fontSize = 14.sp) + } + } + channels.isEmpty() -> { + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text("Geen zenders", color = McColors.TextDim, fontSize = 14.sp) + } + } + else -> { + LazyColumn( + state = listState, + modifier = Modifier.weight(1f), + contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + items(channels, key = { it.streamId }) { ch -> + val isCurrent = ch.streamId == currentStreamId + val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() } + Surface( + onClick = { onSelect(ch.streamId) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp) + .focusRequester(fr), + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + colors = ClickableSurfaceDefaults.colors( + containerColor = if (isCurrent) Color(0x33E50914) else Color.Transparent, + focusedContainerColor = Color(0x28FFFFFF), + contentColor = Color.White, + focusedContentColor = Color.White, + ), + ) { + Row( + Modifier + .fillMaxWidth() + .height(48.dp) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .width(3.dp) + .height(24.dp) + .clip(RoundedCornerShape(2.dp)) + .background(if (isCurrent) McColors.Brand else Color.Transparent), + ) + Spacer(Modifier.width(8.dp)) + Box( + Modifier + .size(32.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xFF0A0A0A)), + contentAlignment = Alignment.Center, + ) { + if (!ch.logoUrl.isNullOrBlank()) { + AsyncImage( + model = ch.logoUrl, + contentDescription = ch.name, + modifier = Modifier + .fillMaxSize() + .padding(3.dp), + contentScale = ContentScale.Fit, + ) + } + } + Spacer(Modifier.width(10.dp)) + ch.number?.let { num -> + Text( + "$num", + color = McColors.TextDim, + fontSize = 12.sp, + modifier = Modifier.width(32.dp), + ) + } + Text( + ch.name, + color = if (isCurrent) Color.White else Color(0xFFDDDDDD), + fontSize = 14.sp, + fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } + } + Box( + Modifier + .fillMaxWidth() + .padding(14.dp), + ) { + Text( + "Terug om te sluiten", + color = Color(0xFF666666), + fontSize = 11.sp, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 9ca9c4d..de8a215 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -225,6 +225,8 @@ fun McNav() { LiveTvPlayerScreen( api = api, streamId = streamId, + categoryId = tvCategoryId, + onStreamChange = { tvLastStreamId = it }, onBack = { nav.popBackStack() }, ) }