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"
minSdk = 24
targetSdk = 35
versionCode = 37
versionName = "0.11.5"
versionCode = 38
versionName = "0.11.6"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.Color
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.Text
import coil.compose.AsyncImage
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.IptvCategory
import nl.vonas.mediacluster.tv.data.IptvChannel
@ -91,6 +92,7 @@ fun LiveTvPlayerScreen(
val context = LocalContext.current
val player = remember { buildLiveTvPlayer(context) }
val playerFocus = remember { FocusRequester() }
val menuScope = rememberCoroutineScope()
var currentStreamId by remember { mutableIntStateOf(streamId) }
var loading by remember { mutableStateOf(true) }
@ -109,8 +111,9 @@ fun LiveTvPlayerScreen(
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) }
var menuRefreshing by remember { mutableStateOf(false) }
var epgFetchGen by remember { mutableIntStateOf(0) }
var lastCategoryShiftAt by remember { mutableLongStateOf(0L) }
val menuEpgNow = remember { mutableStateMapOf<Int, String>() }
val menuListState = rememberLazyListState()
val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
@ -177,6 +180,25 @@ fun LiveTvPlayerScreen(
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) {
if (!channelMenuOpen) return@LaunchedEffect
if (menuCategories.isEmpty()) {
@ -186,47 +208,31 @@ fun LiveTvPlayerScreen(
LaunchedEffect(channelMenuOpen, menuCategoryId) {
if (!channelMenuOpen) return@LaunchedEffect
menuLoading = true
menuEpgNow.clear()
epgFetchGen++
val gen = epgFetchGen
menuRefreshing = true
try {
val catId = menuCategoryId
menuCategoryName = menuCategories.firstOrNull { it.id == catId }?.name ?: "Zenders"
menuChannels = if (!catId.isNullOrBlank()) {
val loaded = if (!catId.isNullOrBlank()) {
api.iptvChannels(catId)
} else {
api.iptvChannels()
}
if (gen != epgFetchGen) return@LaunchedEffect
menuChannels = loaded
prefetchMenuEpg(loaded.take(18).map { it.streamId })
} catch (_: Exception) {
menuChannels = emptyList()
if (gen == epgFetchGen) menuChannels = emptyList()
} finally {
menuLoading = 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
if (gen == epgFetchGen) menuRefreshing = false
}
}
fun shiftMenuCategory(delta: Int) {
val now = System.currentTimeMillis()
if (now - lastCategoryShiftAt < 320) return
lastCategoryShiftAt = now
val cats = menuCategories
if (cats.isEmpty()) return
val idx = cats.indexOfFirst { it.id == menuCategoryId }.let { if (it < 0) 0 else it }
@ -235,12 +241,21 @@ fun LiveTvPlayerScreen(
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
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)
delay(100)
menuChannelFocus[currentStreamId]?.let { runCatching { it.requestFocus() } }
delay(80)
menuChannelFocus[targetId]?.let { runCatching { it.requestFocus() } }
}
LaunchedEffect(infoOverlay) {
@ -437,14 +452,14 @@ fun LiveTvPlayerScreen(
categoryCount = menuCategories.size,
channels = menuChannels,
epgNow = menuEpgNow,
epgLoading = menuEpgLoading,
currentStreamId = currentStreamId,
loading = menuLoading,
refreshing = menuRefreshing,
switching = switching,
listState = menuListState,
channelFocus = menuChannelFocus,
onSelect = { selectChannel(it) },
onCategoryShift = { shiftMenuCategory(it) },
onChannelFocused = { onMenuChannelFocused(it) },
modifier = Modifier.align(Alignment.CenterEnd),
)
}
@ -481,14 +496,14 @@ private fun LiveChannelSwitcher(
categoryCount: Int,
channels: List<IptvChannel>,
epgNow: Map<Int, String>,
epgLoading: Boolean,
currentStreamId: Int,
loading: Boolean,
refreshing: Boolean,
switching: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState,
channelFocus: MutableMap<Int, FocusRequester>,
onSelect: (Int) -> Unit,
onCategoryShift: (Int) -> Unit,
onChannelFocused: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val glassShape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp)
@ -566,8 +581,8 @@ private fun LiveChannelSwitcher(
}
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))
} else if (refreshing) {
Text("Categorie laden…", color = Color(0xFF666666), fontSize = 11.sp, modifier = Modifier.padding(top = 4.dp))
}
}
Box(
@ -577,7 +592,7 @@ private fun LiveChannelSwitcher(
.background(Color.White.copy(alpha = 0.08f)),
)
when {
loading -> {
channels.isEmpty() && refreshing -> {
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
Text("Laden…", color = McColors.TextDim, fontSize = 14.sp)
}
@ -594,7 +609,8 @@ private fun LiveChannelSwitcher(
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.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 fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
Surface(
@ -602,7 +618,10 @@ private fun LiveChannelSwitcher(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp)
.focusRequester(fr),
.focusRequester(fr)
.onFocusChanged { state ->
if (state.isFocused) onChannelFocused(index)
},
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
@ -667,7 +686,7 @@ private fun LiveChannelSwitcher(
)
}
Text(
epgNow[ch.streamId] ?: if (epgLoading) "…" else "",
epgNow[ch.streamId].orEmpty(),
color = McColors.TextDim,
fontSize = 12.sp,
maxLines = 1,