Load all channel EPG titles before showing the TV channel list.

Prefetch now-playing titles for every channel when a category opens so program info is visible immediately, not only on row focus.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-02 01:57:49 +02:00
parent 9377c9fa0e
commit 522cd00e83
4 changed files with 65 additions and 13 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 = 39 versionCode = 40
versionName = "0.11.7" versionName = "0.11.8"
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

@ -1,6 +1,9 @@
package nl.vonas.mediacluster.tv.data package nl.vonas.mediacluster.tv.data
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -338,6 +341,31 @@ class ApiClient(
} }
} }
/** Vul ontbrekende now-titels aan (server-batch of parallel per zender). */
suspend fun fetchMissingNowTitles(channels: List<IptvChannel>): Map<Int, String> {
val result = linkedMapOf<Int, String>()
channels.forEach { ch ->
ch.nowTitle?.takeIf { it.isNotBlank() }?.let { result[ch.streamId] = it }
}
val missing = channels.filter { !result.containsKey(it.streamId) }
if (missing.isEmpty()) return result
coroutineScope {
missing.chunked(10).forEach { batch ->
batch.map { ch ->
async(Dispatchers.IO) {
runCatching {
iptvEpg(ch.streamId).now?.title?.takeIf { it.isNotBlank() }
}.getOrNull()?.let { ch.streamId to it }
}
}.awaitAll().forEach { pair ->
pair?.let { (id, title) -> result[id] = title }
}
}
}
return result
}
private fun JSONObject.toEpgProgram(): EpgProgram? { private fun JSONObject.toEpgProgram(): EpgProgram? {
val title = optString("title").ifBlank { return null } val title = optString("title").ifBlank { return null }
return EpgProgram( return EpgProgram(

View file

@ -183,9 +183,9 @@ fun LiveTvPlayerScreen(
} }
} }
fun applyMenuEpg(channels: List<IptvChannel>) { fun applyMenuEpg(channels: List<IptvChannel>, titles: Map<Int, String>) {
for (ch in channels) { for ((id, title) in titles) {
ch.nowTitle?.let { menuEpgNow[ch.streamId] = it } menuEpgNow[id] = title
} }
} }
@ -203,8 +203,10 @@ fun LiveTvPlayerScreen(
api.iptvChannels(includeEpg = true) api.iptvChannels(includeEpg = true)
} }
if (gen != menuLoadGen) return@LaunchedEffect if (gen != menuLoadGen) return@LaunchedEffect
val titles = api.fetchMissingNowTitles(loaded)
if (gen != menuLoadGen) return@LaunchedEffect
menuChannels = loaded menuChannels = loaded
applyMenuEpg(loaded) applyMenuEpg(loaded, titles)
} catch (_: Exception) { } catch (_: Exception) {
if (gen == menuLoadGen) menuChannels = emptyList() if (gen == menuLoadGen) menuChannels = emptyList()
} finally { } finally {
@ -658,7 +660,7 @@ private fun LiveChannelSwitcher(
) )
} }
Text( Text(
epgNow[ch.streamId].orEmpty(), epgNow[ch.streamId] ?: ch.nowTitle.orEmpty(),
color = McColors.TextDim, color = McColors.TextDim,
fontSize = 12.sp, fontSize = 12.sp,
maxLines = 1, maxLines = 1,

View file

@ -118,10 +118,17 @@ fun TvScreen(
loadingChannels = true loadingChannels = true
focusedEpg = null focusedEpg = null
focusedStreamId = null focusedStreamId = null
epgNowTitles.clear()
try { try {
channels = api.iptvChannels(catId, includeEpg = true) val loaded = api.iptvChannels(catId, includeEpg = true)
for (ch in channels) { val titles = api.fetchMissingNowTitles(loaded)
ch.nowTitle?.let { epgNowTitles[ch.streamId] = it } channels = loaded
titles.forEach { (id, title) -> epgNowTitles[id] = title }
val focusId = lastStreamId?.takeIf { id -> loaded.any { it.streamId == id } }
?: loaded.firstOrNull()?.streamId
focusId?.let { id ->
focusedStreamId = id
runCatching { focusedEpg = api.iptvEpg(id) }
} }
} catch (e: Exception) { } catch (e: Exception) {
error = e.message error = e.message
@ -131,6 +138,18 @@ fun TvScreen(
} }
} }
LaunchedEffect(selectedCategoryId, channels, loadingChannels, focusInList) {
if (!focusInList || loadingChannels || channels.isEmpty()) return@LaunchedEffect
delay(100)
val targetId = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } }
?: channels.first().streamId
val idx = channels.indexOfFirst { it.streamId == targetId }.coerceAtLeast(0)
channelListState.scrollToItem(idx)
delay(80)
channelFocus[targetId]?.let { runCatching { it.requestFocus() } }
focusedStreamId = targetId
}
LaunchedEffect(focusContentNonce, loading, categories, channels, selectedCategoryId, lastStreamId) { LaunchedEffect(focusContentNonce, loading, categories, channels, selectedCategoryId, lastStreamId) {
if (loading || categories.isEmpty()) return@LaunchedEffect if (loading || categories.isEmpty()) return@LaunchedEffect
delay(120) delay(120)
@ -201,7 +220,10 @@ fun TvScreen(
selectedId = selectedCategoryId, selectedId = selectedCategoryId,
listState = categoryRowState, listState = categoryRowState,
categoryFocus = categoryFocus, categoryFocus = categoryFocus,
onSelect = { onCategoryChange(it) }, onSelect = {
onCategoryChange(it)
focusInList = true
},
onFocused = { onFocused = {
focusInList = false focusInList = false
onContentFocused() onContentFocused()
@ -231,7 +253,7 @@ fun TvScreen(
when { when {
loadingChannels -> { loadingChannels -> {
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
Text("Zenders laden…", color = McColors.TextDim, fontSize = 14.sp) Text("Zenders en gids laden…", color = McColors.TextDim, fontSize = 14.sp)
} }
} }
channels.isEmpty() -> { channels.isEmpty() -> {
@ -250,7 +272,7 @@ fun TvScreen(
val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() } val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
ChannelRow( ChannelRow(
channel = ch, channel = ch,
nowTitle = ch.nowTitle ?: epgNowTitles[ch.streamId], nowTitle = epgNowTitles[ch.streamId] ?: ch.nowTitle,
isFocused = focusedStreamId == ch.streamId, isFocused = focusedStreamId == ch.streamId,
isLastWatched = ch.streamId == lastStreamId, isLastWatched = ch.streamId == lastStreamId,
modifier = Modifier.focusRequester(fr), modifier = Modifier.focusRequester(fr),