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:
parent
e061d23569
commit
8cfae01e4b
3 changed files with 307 additions and 21 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -225,6 +225,8 @@ fun McNav() {
|
|||
LiveTvPlayerScreen(
|
||||
api = api,
|
||||
streamId = streamId,
|
||||
categoryId = tvCategoryId,
|
||||
onStreamChange = { tvLastStreamId = it },
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue