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"
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue