From 522cd00e833013c0d1b2a6153af254810f339bfd Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 2 Sep 2026 01:57:49 +0200 Subject: [PATCH] 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 --- apps/android-tv/app/build.gradle.kts | 4 +-- .../vonas/mediacluster/tv/data/ApiClient.kt | 28 +++++++++++++++ .../mediacluster/tv/ui/LiveTvPlayerScreen.kt | 12 ++++--- .../nl/vonas/mediacluster/tv/ui/TvScreen.kt | 34 +++++++++++++++---- 4 files changed, 65 insertions(+), 13 deletions(-) diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 4b94386..cf874ca 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -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") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 1046b39..8c2f81a 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -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): Map { + val result = linkedMapOf() + 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( diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt index 928d08b..600873e 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt @@ -183,9 +183,9 @@ fun LiveTvPlayerScreen( } } - fun applyMenuEpg(channels: List) { - for (ch in channels) { - ch.nowTitle?.let { menuEpgNow[ch.streamId] = it } + fun applyMenuEpg(channels: List, titles: Map) { + 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, diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt index 6a6a4ee..13e2f54 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt @@ -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),