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:
parent
522cd00e83
commit
3b3d796452
3 changed files with 87 additions and 17 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue