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.
This commit is contained in:
Jos Vooges | STH 2026-09-02 02:19:03 +02:00
parent 522cd00e83
commit 3b3d796452
3 changed files with 87 additions and 17 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 = 40 versionCode = 41
versionName = "0.11.8" versionName = "0.11.9"
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

@ -37,6 +37,7 @@ import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -67,6 +68,7 @@ import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.IptvCategory import nl.vonas.mediacluster.tv.data.IptvCategory
import nl.vonas.mediacluster.tv.data.IptvChannel 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.playback.liveMediaItem
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
private data class MenuCategoryCache(
val channels: List<IptvChannel>,
val epgTitles: Map<Int, String>,
val loadedAt: Long,
)
private const val MENU_CACHE_TTL_MS = 90_000L
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun LiveTvPlayerScreen( fun LiveTvPlayerScreen(
@ -89,6 +99,7 @@ fun LiveTvPlayerScreen(
val context = LocalContext.current val context = LocalContext.current
val player = remember { buildLiveTvPlayer(context) } val player = remember { buildLiveTvPlayer(context) }
val playerFocus = remember { FocusRequester() } val playerFocus = remember { FocusRequester() }
val menuScope = rememberCoroutineScope()
var currentStreamId by remember { mutableIntStateOf(streamId) } var currentStreamId by remember { mutableIntStateOf(streamId) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
@ -110,6 +121,7 @@ fun LiveTvPlayerScreen(
var menuRefreshing by remember { mutableStateOf(false) } var menuRefreshing by remember { mutableStateOf(false) }
var lastCategoryShiftAt by remember { mutableLongStateOf(0L) } var lastCategoryShiftAt by remember { mutableLongStateOf(0L) }
var menuLoadGen by remember { mutableIntStateOf(0) } var menuLoadGen by remember { mutableIntStateOf(0) }
val menuCache = remember { mutableStateMapOf<String, MenuCategoryCache>() }
val menuEpgNow = remember { mutableStateMapOf<Int, String>() } val menuEpgNow = remember { mutableStateMapOf<Int, String>() }
val menuListState = rememberLazyListState() val menuListState = rememberLazyListState()
val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() } val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
@ -183,35 +195,93 @@ fun LiveTvPlayerScreen(
} }
} }
fun applyMenuEpg(channels: List<IptvChannel>, titles: Map<Int, String>) { fun applyMenuEpg(titles: Map<Int, String>) {
for ((id, title) in titles) { for ((id, title) in titles) {
menuEpgNow[id] = title 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<Int, String>()
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) { LaunchedEffect(channelMenuOpen, menuCategoryId) {
if (!channelMenuOpen) return@LaunchedEffect if (!channelMenuOpen) return@LaunchedEffect
menuLoadGen++ menuLoadGen++
val gen = menuLoadGen val gen = menuLoadGen
menuRefreshing = true
try {
val catId = menuCategoryId val catId = menuCategoryId
val key = cacheKey(catId)
menuCategoryName = menuCategories.firstOrNull { it.id == catId }?.name ?: "Zenders" 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 loaded = if (!catId.isNullOrBlank()) { val loaded = if (!catId.isNullOrBlank()) {
api.iptvChannels(catId, includeEpg = true) api.iptvChannels(catId, includeEpg = false)
} else { } else {
api.iptvChannels(includeEpg = true) api.iptvChannels(includeEpg = false)
} }
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, titles) val staleTitles = cached?.epgTitles.orEmpty()
if (staleTitles.isNotEmpty()) applyMenuEpg(staleTitles)
menuCache[key] = MenuCategoryCache(loaded, staleTitles, System.currentTimeMillis())
} catch (_: Exception) { } catch (_: Exception) {
if (gen == menuLoadGen) menuChannels = emptyList() if (gen == menuLoadGen) menuChannels = emptyList()
} finally { } finally {
if (gen == menuLoadGen) menuRefreshing = false if (gen == menuLoadGen) menuRefreshing = false
} }
menuScope.launch {
loadMenuEpgInBackground(catId, gen)
if (gen == menuLoadGen) prefetchAdjacentCategories(catId)
}
} }
fun shiftMenuCategory(delta: Int) { fun shiftMenuCategory(delta: Int) {
@ -558,8 +628,8 @@ private fun LiveChannelSwitcher(
} }
if (switching) { if (switching) {
Text("Wisselen…", color = McColors.Brand, fontSize = 12.sp, modifier = Modifier.padding(top = 4.dp)) Text("Wisselen…", color = McColors.Brand, fontSize = 12.sp, modifier = Modifier.padding(top = 4.dp))
} else if (refreshing) { } else if (refreshing && channels.isEmpty()) {
Text("Categorie laden…", color = Color(0xFF666666), fontSize = 11.sp, modifier = Modifier.padding(top = 4.dp)) Text("Laden…", color = Color(0xFF666666), fontSize = 11.sp, modifier = Modifier.padding(top = 4.dp))
} }
} }
Box( Box(

View file

@ -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.parallel=false
org.gradle.workers.max=1 org.gradle.workers.max=1
org.gradle.caching=false org.gradle.caching=true
org.gradle.daemon=false org.gradle.daemon=true
android.useAndroidX=true android.useAndroidX=true
kotlin.code.style=official kotlin.code.style=official
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
kotlin.compiler.execution.strategy=in-process kotlin.compiler.execution.strategy=in-process
kotlin.incremental=false kotlin.incremental=true