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"
minSdk = 24
targetSdk = 35
versionCode = 36
versionName = "0.11.4"
versionCode = 37
versionName = "0.11.5"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
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.Text
import coil.compose.AsyncImage
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
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.IptvEpg
import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
@ -80,6 +84,7 @@ fun LiveTvPlayerScreen(
api: ApiClient,
streamId: Int,
categoryId: String?,
onCategoryChange: (String) -> Unit,
onStreamChange: (Int) -> Unit,
onBack: () -> Unit,
) {
@ -101,8 +106,12 @@ fun LiveTvPlayerScreen(
var triedFallback by remember { mutableStateOf(false) }
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 menuLoading by remember { mutableStateOf(false) }
var menuEpgLoading by remember { mutableStateOf(false) }
val menuEpgNow = remember { mutableStateMapOf<Int, String>() }
val menuListState = rememberLazyListState()
val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
@ -150,6 +159,10 @@ fun LiveTvPlayerScreen(
}
}
LaunchedEffect(categoryId) {
menuCategoryId = categoryId
}
LaunchedEffect(streamId) {
currentStreamId = streamId
}
@ -164,14 +177,22 @@ fun LiveTvPlayerScreen(
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
menuLoading = true
menuEpgNow.clear()
try {
val cats = runCatching { api.iptvCategories() }.getOrDefault(emptyList())
menuCategoryName = cats.firstOrNull { it.id == categoryId }?.name ?: "Zenders"
menuChannels = if (!categoryId.isNullOrBlank()) {
api.iptvChannels(categoryId)
val catId = menuCategoryId
menuCategoryName = menuCategories.firstOrNull { it.id == catId }?.name ?: "Zenders"
menuChannels = if (!catId.isNullOrBlank()) {
api.iptvChannels(catId)
} else {
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) {
if (!channelMenuOpen || menuChannels.isEmpty()) return@LaunchedEffect
val idx = menuChannels.indexOfFirst { it.streamId == currentStreamId }.coerceAtLeast(0)
@ -380,13 +433,18 @@ fun LiveTvPlayerScreen(
)
LiveChannelSwitcher(
categoryName = menuCategoryName,
categoryIndex = menuCategories.indexOfFirst { it.id == menuCategoryId }.coerceAtLeast(0),
categoryCount = menuCategories.size,
channels = menuChannels,
epgNow = menuEpgNow,
epgLoading = menuEpgLoading,
currentStreamId = currentStreamId,
loading = menuLoading,
switching = switching,
listState = menuListState,
channelFocus = menuChannelFocus,
onSelect = { selectChannel(it) },
onCategoryShift = { shiftMenuCategory(it) },
modifier = Modifier.align(Alignment.CenterEnd),
)
}
@ -419,21 +477,40 @@ fun LiveTvPlayerScreen(
@Composable
private fun LiveChannelSwitcher(
categoryName: String,
categoryIndex: Int,
categoryCount: Int,
channels: List<IptvChannel>,
epgNow: Map<Int, String>,
epgLoading: Boolean,
currentStreamId: Int,
loading: Boolean,
switching: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState,
channelFocus: MutableMap<Int, FocusRequester>,
onSelect: (Int) -> Unit,
onCategoryShift: (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)
.width(380.dp)
.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)
.background(
Brush.horizontalGradient(
@ -452,25 +529,45 @@ private fun LiveChannelSwitcher(
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 16.dp),
.padding(horizontal = 18.dp, vertical = 14.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,
)
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(
"Zenders",
color = McColors.TextDim,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 1.sp,
)
Text(
categoryName,
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
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) {
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(
@ -518,14 +615,14 @@ private fun LiveChannelSwitcher(
Row(
Modifier
.fillMaxWidth()
.height(48.dp)
.height(54.dp)
.padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.width(3.dp)
.height(24.dp)
.height(28.dp)
.clip(RoundedCornerShape(2.dp))
.background(if (isCurrent) McColors.Brand else Color.Transparent),
)
@ -548,24 +645,35 @@ private fun LiveChannelSwitcher(
)
}
}
Spacer(Modifier.width(10.dp))
ch.number?.let { num ->
Spacer(Modifier.width(8.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
ch.number?.let { num ->
Text(
"$num",
color = McColors.TextDim,
fontSize = 12.sp,
modifier = Modifier.width(30.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),
)
}
Text(
"$num",
epgNow[ch.streamId] ?: if (epgLoading) "…" else "",
color = McColors.TextDim,
fontSize = 12.sp,
modifier = Modifier.width(32.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
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),
)
}
}
}

View file

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