Fix live channel menu freeze when switching categories.

Stop bulk EPG prefetch from blocking the overlay; load program titles lazily on focus and keep the channel list visible while categories refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-02 01:43:53 +02:00
parent 7581c5b34a
commit da6159059d
2 changed files with 68 additions and 49 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 = 37 versionCode = 38
versionName = "0.11.5" versionName = "0.11.6"
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

@ -36,12 +36,15 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
@ -65,10 +68,8 @@ 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 kotlinx.coroutines.launch
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.IptvCategory
import nl.vonas.mediacluster.tv.data.IptvChannel import nl.vonas.mediacluster.tv.data.IptvChannel
@ -91,6 +92,7 @@ fun LiveTvPlayerScreen(
val context = LocalContext.current val context = LocalContext.current
val player = remember { buildLiveTvPlayer(context) } val player = remember { buildLiveTvPlayer(context) }
val playerFocus = remember { FocusRequester() } val playerFocus = remember { FocusRequester() }
val menuScope = rememberCoroutineScope()
var currentStreamId by remember { mutableIntStateOf(streamId) } var currentStreamId by remember { mutableIntStateOf(streamId) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
@ -109,8 +111,9 @@ fun LiveTvPlayerScreen(
var menuCategories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) } var menuCategories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) }
var menuCategoryId by remember { mutableStateOf(categoryId) } 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 menuRefreshing by remember { mutableStateOf(false) }
var menuEpgLoading by remember { mutableStateOf(false) } var epgFetchGen by remember { mutableIntStateOf(0) }
var lastCategoryShiftAt by remember { mutableLongStateOf(0L) }
val menuEpgNow = remember { mutableStateMapOf<Int, String>() } val menuEpgNow = remember { mutableStateMapOf<Int, String>() }
val menuListState = rememberLazyListState() val menuListState = rememberLazyListState()
val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() } val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
@ -177,6 +180,25 @@ fun LiveTvPlayerScreen(
runCatching { epg = api.iptvEpg(currentStreamId) } runCatching { epg = api.iptvEpg(currentStreamId) }
} }
suspend fun fetchEpgTitles(ids: List<Int>, gen: Int) {
for (id in ids) {
if (gen != epgFetchGen) return
if (menuEpgNow.containsKey(id)) continue
runCatching {
api.iptvEpg(id).now?.title?.let { title ->
if (title.isNotBlank()) menuEpgNow[id] = title
}
}
}
}
fun prefetchMenuEpg(ids: List<Int>) {
val gen = epgFetchGen
menuScope.launch {
fetchEpgTitles(ids, gen)
}
}
LaunchedEffect(channelMenuOpen) { LaunchedEffect(channelMenuOpen) {
if (!channelMenuOpen) return@LaunchedEffect if (!channelMenuOpen) return@LaunchedEffect
if (menuCategories.isEmpty()) { if (menuCategories.isEmpty()) {
@ -186,47 +208,31 @@ fun LiveTvPlayerScreen(
LaunchedEffect(channelMenuOpen, menuCategoryId) { LaunchedEffect(channelMenuOpen, menuCategoryId) {
if (!channelMenuOpen) return@LaunchedEffect if (!channelMenuOpen) return@LaunchedEffect
menuLoading = true epgFetchGen++
menuEpgNow.clear() val gen = epgFetchGen
menuRefreshing = true
try { try {
val catId = menuCategoryId val catId = menuCategoryId
menuCategoryName = menuCategories.firstOrNull { it.id == catId }?.name ?: "Zenders" menuCategoryName = menuCategories.firstOrNull { it.id == catId }?.name ?: "Zenders"
menuChannels = if (!catId.isNullOrBlank()) { val loaded = if (!catId.isNullOrBlank()) {
api.iptvChannels(catId) api.iptvChannels(catId)
} else { } else {
api.iptvChannels() api.iptvChannels()
} }
if (gen != epgFetchGen) return@LaunchedEffect
menuChannels = loaded
prefetchMenuEpg(loaded.take(18).map { it.streamId })
} catch (_: Exception) { } catch (_: Exception) {
menuChannels = emptyList() if (gen == epgFetchGen) menuChannels = emptyList()
} finally { } finally {
menuLoading = false if (gen == epgFetchGen) menuRefreshing = false
}
}
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) { fun shiftMenuCategory(delta: Int) {
val now = System.currentTimeMillis()
if (now - lastCategoryShiftAt < 320) return
lastCategoryShiftAt = now
val cats = menuCategories val cats = menuCategories
if (cats.isEmpty()) return if (cats.isEmpty()) return
val idx = cats.indexOfFirst { it.id == menuCategoryId }.let { if (it < 0) 0 else it } val idx = cats.indexOfFirst { it.id == menuCategoryId }.let { if (it < 0) 0 else it }
@ -235,12 +241,21 @@ fun LiveTvPlayerScreen(
onCategoryChange(next.id) onCategoryChange(next.id)
} }
LaunchedEffect(channelMenuOpen, menuChannels, currentStreamId) { fun onMenuChannelFocused(index: Int) {
val start = (index - 2).coerceAtLeast(0)
val ids = menuChannels.drop(start).take(6).map { it.streamId }
prefetchMenuEpg(ids)
}
LaunchedEffect(channelMenuOpen, menuCategoryId, menuChannels) {
if (!channelMenuOpen || menuChannels.isEmpty()) return@LaunchedEffect if (!channelMenuOpen || menuChannels.isEmpty()) return@LaunchedEffect
val idx = menuChannels.indexOfFirst { it.streamId == currentStreamId }.coerceAtLeast(0) delay(80)
val targetId = menuChannels.firstOrNull { it.streamId == currentStreamId }?.streamId
?: menuChannels.first().streamId
val idx = menuChannels.indexOfFirst { it.streamId == targetId }.coerceAtLeast(0)
menuListState.scrollToItem(idx) menuListState.scrollToItem(idx)
delay(100) delay(80)
menuChannelFocus[currentStreamId]?.let { runCatching { it.requestFocus() } } menuChannelFocus[targetId]?.let { runCatching { it.requestFocus() } }
} }
LaunchedEffect(infoOverlay) { LaunchedEffect(infoOverlay) {
@ -437,14 +452,14 @@ fun LiveTvPlayerScreen(
categoryCount = menuCategories.size, categoryCount = menuCategories.size,
channels = menuChannels, channels = menuChannels,
epgNow = menuEpgNow, epgNow = menuEpgNow,
epgLoading = menuEpgLoading,
currentStreamId = currentStreamId, currentStreamId = currentStreamId,
loading = menuLoading, refreshing = menuRefreshing,
switching = switching, switching = switching,
listState = menuListState, listState = menuListState,
channelFocus = menuChannelFocus, channelFocus = menuChannelFocus,
onSelect = { selectChannel(it) }, onSelect = { selectChannel(it) },
onCategoryShift = { shiftMenuCategory(it) }, onCategoryShift = { shiftMenuCategory(it) },
onChannelFocused = { onMenuChannelFocused(it) },
modifier = Modifier.align(Alignment.CenterEnd), modifier = Modifier.align(Alignment.CenterEnd),
) )
} }
@ -481,14 +496,14 @@ private fun LiveChannelSwitcher(
categoryCount: Int, categoryCount: Int,
channels: List<IptvChannel>, channels: List<IptvChannel>,
epgNow: Map<Int, String>, epgNow: Map<Int, String>,
epgLoading: Boolean,
currentStreamId: Int, currentStreamId: Int,
loading: Boolean, refreshing: 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, onCategoryShift: (Int) -> Unit,
onChannelFocused: (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)
@ -566,8 +581,8 @@ private fun LiveChannelSwitcher(
} }
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) { } else if (refreshing) {
Text("EPG laden…", color = Color(0xFF666666), fontSize = 11.sp, modifier = Modifier.padding(top = 4.dp)) Text("Categorie laden…", color = Color(0xFF666666), fontSize = 11.sp, modifier = Modifier.padding(top = 4.dp))
} }
} }
Box( Box(
@ -577,7 +592,7 @@ private fun LiveChannelSwitcher(
.background(Color.White.copy(alpha = 0.08f)), .background(Color.White.copy(alpha = 0.08f)),
) )
when { when {
loading -> { channels.isEmpty() && refreshing -> {
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
Text("Laden…", color = McColors.TextDim, fontSize = 14.sp) Text("Laden…", color = McColors.TextDim, fontSize = 14.sp)
} }
@ -594,7 +609,8 @@ private fun LiveChannelSwitcher(
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp), contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
items(channels, key = { it.streamId }) { ch -> items(channels.size, key = { channels[it].streamId }) { index ->
val ch = channels[index]
val isCurrent = ch.streamId == currentStreamId val isCurrent = ch.streamId == currentStreamId
val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() } val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
Surface( Surface(
@ -602,7 +618,10 @@ private fun LiveChannelSwitcher(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 10.dp) .padding(horizontal = 10.dp)
.focusRequester(fr), .focusRequester(fr)
.onFocusChanged { state ->
if (state.isFocused) onChannelFocused(index)
},
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
@ -667,7 +686,7 @@ private fun LiveChannelSwitcher(
) )
} }
Text( Text(
epgNow[ch.streamId] ?: if (epgLoading) "…" else "", epgNow[ch.streamId].orEmpty(),
color = McColors.TextDim, color = McColors.TextDim,
fontSize = 12.sp, fontSize = 12.sp,
maxLines = 1, maxLines = 1,