Fix live channel menu freeze when switching categories.
Stop bulk EPG prefetch from blocking the overlay; load program titles lazily on focus and keep the channel list visible while categories refresh. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7581c5b34a
commit
da6159059d
2 changed files with 68 additions and 49 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 = 37
|
versionCode = 38
|
||||||
versionName = "0.11.5"
|
versionName = "0.11.6"
|
||||||
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")
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,15 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||||
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.mutableLongStateOf
|
||||||
|
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
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
|
|
@ -65,10 +68,8 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Surface
|
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.async
|
|
||||||
import kotlinx.coroutines.awaitAll
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
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
|
||||||
|
|
@ -91,6 +92,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) }
|
||||||
|
|
@ -109,8 +111,9 @@ fun LiveTvPlayerScreen(
|
||||||
var menuCategories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) }
|
var menuCategories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) }
|
||||||
var menuCategoryId by remember { mutableStateOf(categoryId) }
|
var menuCategoryId by remember { mutableStateOf(categoryId) }
|
||||||
var menuCategoryName by remember { mutableStateOf("Zenders") }
|
var menuCategoryName by remember { mutableStateOf("Zenders") }
|
||||||
var menuLoading by remember { mutableStateOf(false) }
|
var menuRefreshing by remember { mutableStateOf(false) }
|
||||||
var menuEpgLoading by remember { mutableStateOf(false) }
|
var epgFetchGen by remember { mutableIntStateOf(0) }
|
||||||
|
var lastCategoryShiftAt by remember { mutableLongStateOf(0L) }
|
||||||
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>() }
|
||||||
|
|
@ -177,6 +180,25 @@ fun LiveTvPlayerScreen(
|
||||||
runCatching { epg = api.iptvEpg(currentStreamId) }
|
runCatching { epg = api.iptvEpg(currentStreamId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun fetchEpgTitles(ids: List<Int>, gen: Int) {
|
||||||
|
for (id in ids) {
|
||||||
|
if (gen != epgFetchGen) return
|
||||||
|
if (menuEpgNow.containsKey(id)) continue
|
||||||
|
runCatching {
|
||||||
|
api.iptvEpg(id).now?.title?.let { title ->
|
||||||
|
if (title.isNotBlank()) menuEpgNow[id] = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun prefetchMenuEpg(ids: List<Int>) {
|
||||||
|
val gen = epgFetchGen
|
||||||
|
menuScope.launch {
|
||||||
|
fetchEpgTitles(ids, gen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(channelMenuOpen) {
|
LaunchedEffect(channelMenuOpen) {
|
||||||
if (!channelMenuOpen) return@LaunchedEffect
|
if (!channelMenuOpen) return@LaunchedEffect
|
||||||
if (menuCategories.isEmpty()) {
|
if (menuCategories.isEmpty()) {
|
||||||
|
|
@ -186,47 +208,31 @@ fun LiveTvPlayerScreen(
|
||||||
|
|
||||||
LaunchedEffect(channelMenuOpen, menuCategoryId) {
|
LaunchedEffect(channelMenuOpen, menuCategoryId) {
|
||||||
if (!channelMenuOpen) return@LaunchedEffect
|
if (!channelMenuOpen) return@LaunchedEffect
|
||||||
menuLoading = true
|
epgFetchGen++
|
||||||
menuEpgNow.clear()
|
val gen = epgFetchGen
|
||||||
|
menuRefreshing = true
|
||||||
try {
|
try {
|
||||||
val catId = menuCategoryId
|
val catId = menuCategoryId
|
||||||
menuCategoryName = menuCategories.firstOrNull { it.id == catId }?.name ?: "Zenders"
|
menuCategoryName = menuCategories.firstOrNull { it.id == catId }?.name ?: "Zenders"
|
||||||
menuChannels = if (!catId.isNullOrBlank()) {
|
val loaded = if (!catId.isNullOrBlank()) {
|
||||||
api.iptvChannels(catId)
|
api.iptvChannels(catId)
|
||||||
} else {
|
} else {
|
||||||
api.iptvChannels()
|
api.iptvChannels()
|
||||||
}
|
}
|
||||||
|
if (gen != epgFetchGen) return@LaunchedEffect
|
||||||
|
menuChannels = loaded
|
||||||
|
prefetchMenuEpg(loaded.take(18).map { it.streamId })
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
menuChannels = emptyList()
|
if (gen == epgFetchGen) menuChannels = emptyList()
|
||||||
} finally {
|
} finally {
|
||||||
menuLoading = false
|
if (gen == epgFetchGen) menuRefreshing = false
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(channelMenuOpen, menuCategoryId, menuChannels) {
|
|
||||||
if (!channelMenuOpen || menuChannels.isEmpty()) return@LaunchedEffect
|
|
||||||
menuEpgLoading = true
|
|
||||||
try {
|
|
||||||
coroutineScope {
|
|
||||||
menuChannels.chunked(8).forEach { batch ->
|
|
||||||
batch.map { ch ->
|
|
||||||
async {
|
|
||||||
runCatching {
|
|
||||||
ch.streamId to api.iptvEpg(ch.streamId).now?.title
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
}.awaitAll().forEach { result ->
|
|
||||||
val (id, title) = result ?: return@forEach
|
|
||||||
if (!title.isNullOrBlank()) menuEpgNow[id] = title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
menuEpgLoading = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun shiftMenuCategory(delta: Int) {
|
fun shiftMenuCategory(delta: Int) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastCategoryShiftAt < 320) return
|
||||||
|
lastCategoryShiftAt = now
|
||||||
val cats = menuCategories
|
val cats = menuCategories
|
||||||
if (cats.isEmpty()) return
|
if (cats.isEmpty()) return
|
||||||
val idx = cats.indexOfFirst { it.id == menuCategoryId }.let { if (it < 0) 0 else it }
|
val idx = cats.indexOfFirst { it.id == menuCategoryId }.let { if (it < 0) 0 else it }
|
||||||
|
|
@ -235,12 +241,21 @@ fun LiveTvPlayerScreen(
|
||||||
onCategoryChange(next.id)
|
onCategoryChange(next.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(channelMenuOpen, menuChannels, currentStreamId) {
|
fun onMenuChannelFocused(index: Int) {
|
||||||
|
val start = (index - 2).coerceAtLeast(0)
|
||||||
|
val ids = menuChannels.drop(start).take(6).map { it.streamId }
|
||||||
|
prefetchMenuEpg(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(channelMenuOpen, menuCategoryId, menuChannels) {
|
||||||
if (!channelMenuOpen || menuChannels.isEmpty()) return@LaunchedEffect
|
if (!channelMenuOpen || menuChannels.isEmpty()) return@LaunchedEffect
|
||||||
val idx = menuChannels.indexOfFirst { it.streamId == currentStreamId }.coerceAtLeast(0)
|
delay(80)
|
||||||
|
val targetId = menuChannels.firstOrNull { it.streamId == currentStreamId }?.streamId
|
||||||
|
?: menuChannels.first().streamId
|
||||||
|
val idx = menuChannels.indexOfFirst { it.streamId == targetId }.coerceAtLeast(0)
|
||||||
menuListState.scrollToItem(idx)
|
menuListState.scrollToItem(idx)
|
||||||
delay(100)
|
delay(80)
|
||||||
menuChannelFocus[currentStreamId]?.let { runCatching { it.requestFocus() } }
|
menuChannelFocus[targetId]?.let { runCatching { it.requestFocus() } }
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(infoOverlay) {
|
LaunchedEffect(infoOverlay) {
|
||||||
|
|
@ -437,14 +452,14 @@ fun LiveTvPlayerScreen(
|
||||||
categoryCount = menuCategories.size,
|
categoryCount = menuCategories.size,
|
||||||
channels = menuChannels,
|
channels = menuChannels,
|
||||||
epgNow = menuEpgNow,
|
epgNow = menuEpgNow,
|
||||||
epgLoading = menuEpgLoading,
|
|
||||||
currentStreamId = currentStreamId,
|
currentStreamId = currentStreamId,
|
||||||
loading = menuLoading,
|
refreshing = menuRefreshing,
|
||||||
switching = switching,
|
switching = switching,
|
||||||
listState = menuListState,
|
listState = menuListState,
|
||||||
channelFocus = menuChannelFocus,
|
channelFocus = menuChannelFocus,
|
||||||
onSelect = { selectChannel(it) },
|
onSelect = { selectChannel(it) },
|
||||||
onCategoryShift = { shiftMenuCategory(it) },
|
onCategoryShift = { shiftMenuCategory(it) },
|
||||||
|
onChannelFocused = { onMenuChannelFocused(it) },
|
||||||
modifier = Modifier.align(Alignment.CenterEnd),
|
modifier = Modifier.align(Alignment.CenterEnd),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -481,14 +496,14 @@ private fun LiveChannelSwitcher(
|
||||||
categoryCount: Int,
|
categoryCount: Int,
|
||||||
channels: List<IptvChannel>,
|
channels: List<IptvChannel>,
|
||||||
epgNow: Map<Int, String>,
|
epgNow: Map<Int, String>,
|
||||||
epgLoading: Boolean,
|
|
||||||
currentStreamId: Int,
|
currentStreamId: Int,
|
||||||
loading: Boolean,
|
refreshing: Boolean,
|
||||||
switching: Boolean,
|
switching: Boolean,
|
||||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||||
channelFocus: MutableMap<Int, FocusRequester>,
|
channelFocus: MutableMap<Int, FocusRequester>,
|
||||||
onSelect: (Int) -> Unit,
|
onSelect: (Int) -> Unit,
|
||||||
onCategoryShift: (Int) -> Unit,
|
onCategoryShift: (Int) -> Unit,
|
||||||
|
onChannelFocused: (Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val glassShape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp)
|
val glassShape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp)
|
||||||
|
|
@ -566,8 +581,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 (epgLoading) {
|
} else if (refreshing) {
|
||||||
Text("EPG laden…", color = Color(0xFF666666), fontSize = 11.sp, modifier = Modifier.padding(top = 4.dp))
|
Text("Categorie laden…", color = Color(0xFF666666), fontSize = 11.sp, modifier = Modifier.padding(top = 4.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -577,7 +592,7 @@ private fun LiveChannelSwitcher(
|
||||||
.background(Color.White.copy(alpha = 0.08f)),
|
.background(Color.White.copy(alpha = 0.08f)),
|
||||||
)
|
)
|
||||||
when {
|
when {
|
||||||
loading -> {
|
channels.isEmpty() && refreshing -> {
|
||||||
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||||
Text("Laden…", color = McColors.TextDim, fontSize = 14.sp)
|
Text("Laden…", color = McColors.TextDim, fontSize = 14.sp)
|
||||||
}
|
}
|
||||||
|
|
@ -594,7 +609,8 @@ private fun LiveChannelSwitcher(
|
||||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp),
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
items(channels, key = { it.streamId }) { ch ->
|
items(channels.size, key = { channels[it].streamId }) { index ->
|
||||||
|
val ch = channels[index]
|
||||||
val isCurrent = ch.streamId == currentStreamId
|
val isCurrent = ch.streamId == currentStreamId
|
||||||
val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
|
val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
|
||||||
Surface(
|
Surface(
|
||||||
|
|
@ -602,7 +618,10 @@ private fun LiveChannelSwitcher(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 10.dp)
|
.padding(horizontal = 10.dp)
|
||||||
.focusRequester(fr),
|
.focusRequester(fr)
|
||||||
|
.onFocusChanged { state ->
|
||||||
|
if (state.isFocused) onChannelFocused(index)
|
||||||
|
},
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
|
@ -667,7 +686,7 @@ private fun LiveChannelSwitcher(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
epgNow[ch.streamId] ?: if (epgLoading) "…" else "",
|
epgNow[ch.streamId].orEmpty(),
|
||||||
color = McColors.TextDim,
|
color = McColors.TextDim,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue