From 3b3d7964527a07059d962cad231a23866b3628f3 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 2 Sep 2026 02:19:03 +0200 Subject: [PATCH] Make live channel popup category switching instant. Load channel lists without EPG first, cache categories, and prefetch neighbors so left/right navigation feels snappy while EPG fills in on the background. --- apps/android-tv/app/build.gradle.kts | 4 +- .../mediacluster/tv/ui/LiveTvPlayerScreen.kt | 92 ++++++++++++++++--- apps/android-tv/gradle.properties | 8 +- 3 files changed, 87 insertions(+), 17 deletions(-) diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index cf874ca..c7e0f2c 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 = 40 - versionName = "0.11.8" + versionCode = 41 + versionName = "0.11.9" 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/ui/LiveTvPlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt index 600873e..abe2ed8 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 @@ -37,6 +37,7 @@ import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -67,6 +68,7 @@ import androidx.tv.material3.Surface import androidx.tv.material3.Text import coil.compose.AsyncImage 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 @@ -76,6 +78,14 @@ import nl.vonas.mediacluster.tv.playback.ensureAudioSelected import nl.vonas.mediacluster.tv.playback.liveMediaItem import nl.vonas.mediacluster.tv.ui.theme.McColors +private data class MenuCategoryCache( + val channels: List, + val epgTitles: Map, + val loadedAt: Long, +) + +private const val MENU_CACHE_TTL_MS = 90_000L + @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun LiveTvPlayerScreen( @@ -89,6 +99,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) } @@ -110,6 +121,7 @@ fun LiveTvPlayerScreen( var menuRefreshing by remember { mutableStateOf(false) } var lastCategoryShiftAt by remember { mutableLongStateOf(0L) } var menuLoadGen by remember { mutableIntStateOf(0) } + val menuCache = remember { mutableStateMapOf() } val menuEpgNow = remember { mutableStateMapOf() } val menuListState = rememberLazyListState() val menuChannelFocus = remember { mutableStateMapOf() } @@ -183,35 +195,93 @@ fun LiveTvPlayerScreen( } } - fun applyMenuEpg(channels: List, titles: Map) { + fun applyMenuEpg(titles: Map) { for ((id, title) in titles) { menuEpgNow[id] = title } } + fun cacheKey(catId: String?) = catId?.takeIf { it.isNotBlank() } ?: "__all__" + + suspend fun loadMenuEpgInBackground(catId: String?, gen: Int) { + val key = cacheKey(catId) + try { + val withEpg = if (!catId.isNullOrBlank()) { + api.iptvChannels(catId, includeEpg = true) + } else { + api.iptvChannels(includeEpg = true) + } + if (gen != menuLoadGen) return + val titles = linkedMapOf() + withEpg.forEach { ch -> ch.nowTitle?.let { titles[ch.streamId] = it } } + applyMenuEpg(titles) + menuCache[key] = MenuCategoryCache(withEpg, titles, System.currentTimeMillis()) + } catch (_: Exception) { + // zenderlijst blijft bruikbaar zonder EPG + } + } + + fun prefetchAdjacentCategories(centerCatId: String?) { + val cats = menuCategories + if (cats.isEmpty()) return + val idx = cats.indexOfFirst { it.id == centerCatId }.let { if (it < 0) 0 else it } + for (offset in listOf(-1, 1)) { + val cat = cats[(idx + offset + cats.size) % cats.size] + val key = cacheKey(cat.id) + if (menuCache.containsKey(key)) continue + menuScope.launch { + runCatching { + val loaded = api.iptvChannels(cat.id, includeEpg = false) + menuCache[key] = MenuCategoryCache(loaded, emptyMap(), System.currentTimeMillis()) + } + } + } + } + LaunchedEffect(channelMenuOpen, menuCategoryId) { if (!channelMenuOpen) return@LaunchedEffect menuLoadGen++ val gen = menuLoadGen - menuRefreshing = true + val catId = menuCategoryId + val key = cacheKey(catId) + menuCategoryName = menuCategories.firstOrNull { it.id == catId }?.name ?: "Zenders" + + val cached = menuCache[key] + val cacheFresh = cached != null && System.currentTimeMillis() - cached.loadedAt < MENU_CACHE_TTL_MS + if (cached != null) { + menuChannels = cached.channels + applyMenuEpg(cached.epgTitles) + menuRefreshing = false + if (cacheFresh) { + prefetchAdjacentCategories(catId) + if (cached.epgTitles.isNotEmpty()) return@LaunchedEffect + menuScope.launch { loadMenuEpgInBackground(catId, gen) } + return@LaunchedEffect + } + } + + menuRefreshing = menuChannels.isEmpty() try { - val catId = menuCategoryId - menuCategoryName = menuCategories.firstOrNull { it.id == catId }?.name ?: "Zenders" val loaded = if (!catId.isNullOrBlank()) { - api.iptvChannels(catId, includeEpg = true) + api.iptvChannels(catId, includeEpg = false) } else { - api.iptvChannels(includeEpg = true) + api.iptvChannels(includeEpg = false) } if (gen != menuLoadGen) return@LaunchedEffect - val titles = api.fetchMissingNowTitles(loaded) - if (gen != menuLoadGen) return@LaunchedEffect menuChannels = loaded - applyMenuEpg(loaded, titles) + val staleTitles = cached?.epgTitles.orEmpty() + if (staleTitles.isNotEmpty()) applyMenuEpg(staleTitles) + menuCache[key] = MenuCategoryCache(loaded, staleTitles, System.currentTimeMillis()) } catch (_: Exception) { if (gen == menuLoadGen) menuChannels = emptyList() } finally { if (gen == menuLoadGen) menuRefreshing = false } + + menuScope.launch { + loadMenuEpgInBackground(catId, gen) + if (gen == menuLoadGen) prefetchAdjacentCategories(catId) + } } fun shiftMenuCategory(delta: Int) { @@ -558,8 +628,8 @@ private fun LiveChannelSwitcher( } if (switching) { Text("Wisselen…", color = McColors.Brand, fontSize = 12.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)) + } else if (refreshing && channels.isEmpty()) { + Text("Laden…", color = Color(0xFF666666), fontSize = 11.sp, modifier = Modifier.padding(top = 4.dp)) } } Box( diff --git a/apps/android-tv/gradle.properties b/apps/android-tv/gradle.properties index 5f41684..8600c3a 100644 --- a/apps/android-tv/gradle.properties +++ b/apps/android-tv/gradle.properties @@ -1,10 +1,10 @@ -org.gradle.jvmargs=-Xmx1024m -XX:MaxMetaspaceSize=256m -XX:+UseG1GC -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx768m -XX:MaxMetaspaceSize=256m -XX:+UseG1GC -XX:CICompilerCount=1 -Dfile.encoding=UTF-8 org.gradle.parallel=false org.gradle.workers.max=1 -org.gradle.caching=false -org.gradle.daemon=false +org.gradle.caching=true +org.gradle.daemon=true android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true kotlin.compiler.execution.strategy=in-process -kotlin.incremental=false +kotlin.incremental=true