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"
minSdk = 24
targetSdk = 35
versionCode = 39
versionName = "0.11.7"
versionCode = 40
versionName = "0.11.8"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -1,6 +1,9 @@
package nl.vonas.mediacluster.tv.data
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
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? {
val title = optString("title").ifBlank { return null }
return EpgProgram(

View file

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

View file

@ -118,10 +118,17 @@ fun TvScreen(
loadingChannels = true
focusedEpg = null
focusedStreamId = null
epgNowTitles.clear()
try {
channels = api.iptvChannels(catId, includeEpg = true)
for (ch in channels) {
ch.nowTitle?.let { epgNowTitles[ch.streamId] = it }
val loaded = api.iptvChannels(catId, includeEpg = true)
val titles = api.fetchMissingNowTitles(loaded)
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) {
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) {
if (loading || categories.isEmpty()) return@LaunchedEffect
delay(120)
@ -201,7 +220,10 @@ fun TvScreen(
selectedId = selectedCategoryId,
listState = categoryRowState,
categoryFocus = categoryFocus,
onSelect = { onCategoryChange(it) },
onSelect = {
onCategoryChange(it)
focusInList = true
},
onFocused = {
focusInList = false
onContentFocused()
@ -231,7 +253,7 @@ fun TvScreen(
when {
loadingChannels -> {
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() -> {
@ -250,7 +272,7 @@ fun TvScreen(
val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
ChannelRow(
channel = ch,
nowTitle = ch.nowTitle ?: epgNowTitles[ch.streamId],
nowTitle = epgNowTitles[ch.streamId] ?: ch.nowTitle,
isFocused = focusedStreamId == ch.streamId,
isLastWatched = ch.streamId == lastStreamId,
modifier = Modifier.focusRequester(fr),