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"
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")

View file

@ -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<IptvChannel>,
val epgTitles: Map<Int, String>,
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<String, MenuCategoryCache>() }
val menuEpgNow = remember { mutableStateMapOf<Int, String>() }
val menuListState = rememberLazyListState()
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) {
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) {
if (!channelMenuOpen) return@LaunchedEffect
menuLoadGen++
val gen = menuLoadGen
menuRefreshing = true
try {
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 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(

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.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