Add EPG and category browsing to live channel switcher.

Prefetch now-playing titles for menu rows and use left/right to switch IPTV categories while the in-play overlay stays open.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-02 01:26:51 +02:00
parent 8cfae01e4b
commit 7581c5b34a
3 changed files with 148 additions and 39 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 = 36 versionCode = 37
versionName = "0.11.4" versionName = "0.11.5"
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

@ -65,8 +65,12 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface 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.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
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.IptvCategory
import nl.vonas.mediacluster.tv.data.IptvChannel 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
@ -80,6 +84,7 @@ fun LiveTvPlayerScreen(
api: ApiClient, api: ApiClient,
streamId: Int, streamId: Int,
categoryId: String?, categoryId: String?,
onCategoryChange: (String) -> Unit,
onStreamChange: (Int) -> Unit, onStreamChange: (Int) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
@ -101,8 +106,12 @@ fun LiveTvPlayerScreen(
var triedFallback by remember { mutableStateOf(false) } var triedFallback by remember { mutableStateOf(false) }
var menuChannels by remember { mutableStateOf<List<IptvChannel>>(emptyList()) } var menuChannels by remember { mutableStateOf<List<IptvChannel>>(emptyList()) }
var menuCategories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) }
var menuCategoryId by remember { mutableStateOf(categoryId) }
var menuCategoryName by remember { mutableStateOf("Zenders") } var menuCategoryName by remember { mutableStateOf("Zenders") }
var menuLoading by remember { mutableStateOf(false) } var menuLoading by remember { mutableStateOf(false) }
var menuEpgLoading by remember { mutableStateOf(false) }
val menuEpgNow = remember { mutableStateMapOf<Int, String>() }
val menuListState = rememberLazyListState() val menuListState = rememberLazyListState()
val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() } val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
@ -150,6 +159,10 @@ fun LiveTvPlayerScreen(
} }
} }
LaunchedEffect(categoryId) {
menuCategoryId = categoryId
}
LaunchedEffect(streamId) { LaunchedEffect(streamId) {
currentStreamId = streamId currentStreamId = streamId
} }
@ -164,14 +177,22 @@ fun LiveTvPlayerScreen(
runCatching { epg = api.iptvEpg(currentStreamId) } runCatching { epg = api.iptvEpg(currentStreamId) }
} }
LaunchedEffect(channelMenuOpen, categoryId) { LaunchedEffect(channelMenuOpen) {
if (!channelMenuOpen) return@LaunchedEffect
if (menuCategories.isEmpty()) {
runCatching { menuCategories = api.iptvCategories() }
}
}
LaunchedEffect(channelMenuOpen, menuCategoryId) {
if (!channelMenuOpen) return@LaunchedEffect if (!channelMenuOpen) return@LaunchedEffect
menuLoading = true menuLoading = true
menuEpgNow.clear()
try { try {
val cats = runCatching { api.iptvCategories() }.getOrDefault(emptyList()) val catId = menuCategoryId
menuCategoryName = cats.firstOrNull { it.id == categoryId }?.name ?: "Zenders" menuCategoryName = menuCategories.firstOrNull { it.id == catId }?.name ?: "Zenders"
menuChannels = if (!categoryId.isNullOrBlank()) { menuChannels = if (!catId.isNullOrBlank()) {
api.iptvChannels(categoryId) api.iptvChannels(catId)
} else { } else {
api.iptvChannels() api.iptvChannels()
} }
@ -182,6 +203,38 @@ fun LiveTvPlayerScreen(
} }
} }
LaunchedEffect(channelMenuOpen, menuCategoryId, menuChannels) {
if (!channelMenuOpen || menuChannels.isEmpty()) return@LaunchedEffect
menuEpgLoading = true
try {
coroutineScope {
menuChannels.chunked(8).forEach { batch ->
batch.map { ch ->
async {
runCatching {
ch.streamId to api.iptvEpg(ch.streamId).now?.title
}.getOrNull()
}
}.awaitAll().forEach { result ->
val (id, title) = result ?: return@forEach
if (!title.isNullOrBlank()) menuEpgNow[id] = title
}
}
}
} finally {
menuEpgLoading = false
}
}
fun shiftMenuCategory(delta: Int) {
val cats = menuCategories
if (cats.isEmpty()) return
val idx = cats.indexOfFirst { it.id == menuCategoryId }.let { if (it < 0) 0 else it }
val next = cats[(idx + delta).mod(cats.size)]
menuCategoryId = next.id
onCategoryChange(next.id)
}
LaunchedEffect(channelMenuOpen, menuChannels, currentStreamId) { LaunchedEffect(channelMenuOpen, menuChannels, currentStreamId) {
if (!channelMenuOpen || menuChannels.isEmpty()) return@LaunchedEffect if (!channelMenuOpen || menuChannels.isEmpty()) return@LaunchedEffect
val idx = menuChannels.indexOfFirst { it.streamId == currentStreamId }.coerceAtLeast(0) val idx = menuChannels.indexOfFirst { it.streamId == currentStreamId }.coerceAtLeast(0)
@ -380,13 +433,18 @@ fun LiveTvPlayerScreen(
) )
LiveChannelSwitcher( LiveChannelSwitcher(
categoryName = menuCategoryName, categoryName = menuCategoryName,
categoryIndex = menuCategories.indexOfFirst { it.id == menuCategoryId }.coerceAtLeast(0),
categoryCount = menuCategories.size,
channels = menuChannels, channels = menuChannels,
epgNow = menuEpgNow,
epgLoading = menuEpgLoading,
currentStreamId = currentStreamId, currentStreamId = currentStreamId,
loading = menuLoading, loading = menuLoading,
switching = switching, switching = switching,
listState = menuListState, listState = menuListState,
channelFocus = menuChannelFocus, channelFocus = menuChannelFocus,
onSelect = { selectChannel(it) }, onSelect = { selectChannel(it) },
onCategoryShift = { shiftMenuCategory(it) },
modifier = Modifier.align(Alignment.CenterEnd), modifier = Modifier.align(Alignment.CenterEnd),
) )
} }
@ -419,21 +477,40 @@ fun LiveTvPlayerScreen(
@Composable @Composable
private fun LiveChannelSwitcher( private fun LiveChannelSwitcher(
categoryName: String, categoryName: String,
categoryIndex: Int,
categoryCount: Int,
channels: List<IptvChannel>, channels: List<IptvChannel>,
epgNow: Map<Int, String>,
epgLoading: Boolean,
currentStreamId: Int, currentStreamId: Int,
loading: Boolean, loading: Boolean,
switching: Boolean, switching: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState, listState: androidx.compose.foundation.lazy.LazyListState,
channelFocus: MutableMap<Int, FocusRequester>, channelFocus: MutableMap<Int, FocusRequester>,
onSelect: (Int) -> Unit, onSelect: (Int) -> Unit,
onCategoryShift: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val glassShape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp) val glassShape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp)
Box( Box(
modifier modifier
.padding(top = 28.dp, bottom = 28.dp, end = 0.dp) .padding(top = 28.dp, bottom = 28.dp, end = 0.dp)
.width(340.dp) .width(380.dp)
.fillMaxHeight() .fillMaxHeight()
.onPreviewKeyEvent { ev ->
if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (ev.key) {
Key.DirectionLeft -> {
onCategoryShift(-1)
true
}
Key.DirectionRight -> {
onCategoryShift(1)
true
}
else -> false
}
}
.clip(glassShape) .clip(glassShape)
.background( .background(
Brush.horizontalGradient( Brush.horizontalGradient(
@ -452,8 +529,15 @@ private fun LiveChannelSwitcher(
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 16.dp), .padding(horizontal = 18.dp, vertical = 14.dp),
) { ) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("‹", color = Color(0xFF666666), fontSize = 16.sp)
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) {
Text( Text(
"Zenders", "Zenders",
color = McColors.TextDim, color = McColors.TextDim,
@ -464,13 +548,26 @@ private fun LiveChannelSwitcher(
Text( Text(
categoryName, categoryName,
color = Color.White, color = Color.White,
fontSize = 17.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
if (categoryCount > 1) {
Text(
"${categoryIndex + 1} / $categoryCount · ← → categorie",
color = Color(0xFF666666),
fontSize = 10.sp,
modifier = Modifier.padding(top = 2.dp),
)
}
}
Text("›", color = Color(0xFF666666), fontSize = 16.sp)
}
if (switching) { if (switching) {
Text("Wisselen…", color = McColors.Brand, fontSize = 12.sp, modifier = Modifier.padding(top = 4.dp)) Text("Wisselen…", color = McColors.Brand, fontSize = 12.sp, modifier = Modifier.padding(top = 4.dp))
} else if (epgLoading) {
Text("EPG laden…", color = Color(0xFF666666), fontSize = 11.sp, modifier = Modifier.padding(top = 4.dp))
} }
} }
Box( Box(
@ -518,14 +615,14 @@ private fun LiveChannelSwitcher(
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(48.dp) .height(54.dp)
.padding(horizontal = 10.dp), .padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( Box(
Modifier Modifier
.width(3.dp) .width(3.dp)
.height(24.dp) .height(28.dp)
.clip(RoundedCornerShape(2.dp)) .clip(RoundedCornerShape(2.dp))
.background(if (isCurrent) McColors.Brand else Color.Transparent), .background(if (isCurrent) McColors.Brand else Color.Transparent),
) )
@ -548,13 +645,15 @@ private fun LiveChannelSwitcher(
) )
} }
} }
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(8.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
ch.number?.let { num -> ch.number?.let { num ->
Text( Text(
"$num", "$num",
color = McColors.TextDim, color = McColors.TextDim,
fontSize = 12.sp, fontSize = 12.sp,
modifier = Modifier.width(32.dp), modifier = Modifier.width(30.dp),
) )
} }
Text( Text(
@ -567,6 +666,15 @@ private fun LiveChannelSwitcher(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
Text(
epgNow[ch.streamId] ?: if (epgLoading) "…" else "",
color = McColors.TextDim,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
} }
} }
} }

View file

@ -226,6 +226,7 @@ fun McNav() {
api = api, api = api,
streamId = streamId, streamId = streamId,
categoryId = tvCategoryId, categoryId = tvCategoryId,
onCategoryChange = { tvCategoryId = it },
onStreamChange = { tvLastStreamId = it }, onStreamChange = { tvLastStreamId = it },
onBack = { nav.popBackStack() }, onBack = { nav.popBackStack() },
) )