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 <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-02 01:16:53 +02:00
parent e061d23569
commit 8cfae01e4b
3 changed files with 307 additions and 21 deletions

View file

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

View file

@ -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<String?>(null) }
var channelName by remember { mutableStateOf("") }
var logoUrl by remember { mutableStateOf<String?>(null) }
var epg by remember { mutableStateOf<IptvEpg?>(null) }
var overlay by remember { mutableStateOf(true) }
var infoOverlay by remember { mutableStateOf(true) }
var channelMenuOpen by remember { mutableStateOf(false) }
var fallbackUrl by remember { mutableStateOf<String?>(null) }
var fallbackFormat by remember { mutableStateOf<String?>(null) }
var triedFallback by remember { mutableStateOf(false) }
var menuChannels by remember { mutableStateOf<List<IptvChannel>>(emptyList()) }
var menuCategoryName by remember { mutableStateOf("Zenders") }
var menuLoading by remember { mutableStateOf(false) }
val menuListState = rememberLazyListState()
val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
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<IptvChannel>,
currentStreamId: Int,
loading: Boolean,
switching: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState,
channelFocus: MutableMap<Int, FocusRequester>,
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),
)
}
}
}
}

View file

@ -225,6 +225,8 @@ fun McNav() {
LiveTvPlayerScreen(
api = api,
streamId = streamId,
categoryId = tvCategoryId,
onStreamChange = { tvLastStreamId = it },
onBack = { nav.popBackStack() },
)
}