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"
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() },
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue