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" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 35 versionCode = 36
versionName = "0.11.3" versionName = "0.11.4"
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

@ -4,22 +4,36 @@ import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
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.Row 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -46,11 +60,14 @@ import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import nl.vonas.mediacluster.tv.data.ApiClient 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.data.IptvEpg
import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
@ -62,38 +79,47 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors
fun LiveTvPlayerScreen( fun LiveTvPlayerScreen(
api: ApiClient, api: ApiClient,
streamId: Int, streamId: Int,
categoryId: String?,
onStreamChange: (Int) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val player = remember { buildLiveTvPlayer(context) } 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 loading by remember { mutableStateOf(true) }
var switching by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var channelName by remember { mutableStateOf("") } var channelName by remember { mutableStateOf("") }
var logoUrl by remember { mutableStateOf<String?>(null) } var logoUrl by remember { mutableStateOf<String?>(null) }
var epg by remember { mutableStateOf<IptvEpg?>(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 fallbackUrl by remember { mutableStateOf<String?>(null) }
var fallbackFormat by remember { mutableStateOf<String?>(null) } var fallbackFormat by remember { mutableStateOf<String?>(null) }
var triedFallback by remember { mutableStateOf(false) } 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) { fun startStream(url: String, format: String) {
player.setMediaItem(liveMediaItem(url, format)) player.setMediaItem(liveMediaItem(url, format))
player.prepare() player.prepare()
player.playWhenReady = true player.playWhenReady = true
} }
LaunchedEffect(streamId) { suspend fun loadAndPlay(streamIdToPlay: Int, isSwitch: Boolean) {
loading = true if (isSwitch) switching = true else loading = true
error = null error = null
triedFallback = false triedFallback = false
epg = null
try { try {
val play = api.iptvPlayUrl(streamId) val play = api.iptvPlayUrl(streamIdToPlay)
channelName = play.name channelName = play.name
logoUrl = play.logoUrl logoUrl = play.logoUrl
// API levert classic TS eerst; lokaal nog TS forceren als fallback in response zit
val tsUrl = when { val tsUrl = when {
play.format.equals("ts", ignoreCase = true) -> play.streamUrl play.format.equals("ts", ignoreCase = true) -> play.streamUrl
play.fallbackFormat?.equals("ts", ignoreCase = true) == true -> play.fallbackStreamUrl play.fallbackFormat?.equals("ts", ignoreCase = true) == true -> play.fallbackStreamUrl
@ -115,22 +141,59 @@ fun LiveTvPlayerScreen(
fallbackFormat = play.fallbackFormat fallbackFormat = play.fallbackFormat
startStream(play.streamUrl, play.format) startStream(play.streamUrl, play.format)
} }
onStreamChange(streamIdToPlay)
} catch (e: Exception) { } catch (e: Exception) {
error = e.message ?: "Kon zender niet starten" error = e.message ?: "Kon zender niet starten"
} finally { } finally {
loading = false loading = false
switching = false
} }
} }
LaunchedEffect(streamId) { LaunchedEffect(streamId) {
delay(2_500) currentStreamId = streamId
runCatching { epg = api.iptvEpg(streamId) }
} }
LaunchedEffect(overlay) { LaunchedEffect(currentStreamId) {
if (overlay) { 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) 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( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
.focusRequester(focus) .focusRequester(playerFocus)
.focusable() .focusable()
.onPreviewKeyEvent { ev -> .onPreviewKeyEvent { ev ->
if (channelMenuOpen) return@onPreviewKeyEvent false
if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (ev.key) { when (ev.key) {
Key.Back, Key.Escape -> { Key.Back, Key.Escape -> {
onBack() onBack()
true true
} }
Key.DirectionUp -> {
channelMenuOpen = true
true
}
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
overlay = !overlay infoOverlay = !infoOverlay
true true
} }
else -> { else -> {
overlay = true infoOverlay = true
false false
} }
} }
@ -203,11 +282,18 @@ fun LiveTvPlayerScreen(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(200) delay(200)
runCatching { focus.requestFocus() } runCatching { playerFocus.requestFocus() }
}
LaunchedEffect(channelMenuOpen) {
if (!channelMenuOpen) {
delay(80)
runCatching { playerFocus.requestFocus() }
}
} }
AnimatedVisibility( AnimatedVisibility(
visible = overlay && error == null, visible = infoOverlay && !channelMenuOpen && error == null,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), 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) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Zender laden…", color = Color.White, fontSize = 18.sp) 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( LiveTvPlayerScreen(
api = api, api = api,
streamId = streamId, streamId = streamId,
categoryId = tvCategoryId,
onStreamChange = { tvLastStreamId = it },
onBack = { nav.popBackStack() }, onBack = { nav.popBackStack() },
) )
} }