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