diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 1fbd755..5b66057 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 35 - versionCode = 37 - versionName = "0.11.5" + versionCode = 38 + versionName = "0.11.6" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt index f4baeb5..972dbf0 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt @@ -36,12 +36,15 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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.Color 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.Text import coil.compose.AsyncImage -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope 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 @@ -91,6 +92,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) } @@ -109,8 +111,9 @@ fun LiveTvPlayerScreen( var menuCategories by remember { mutableStateOf>(emptyList()) } var menuCategoryId by remember { mutableStateOf(categoryId) } var menuCategoryName by remember { mutableStateOf("Zenders") } - var menuLoading by remember { mutableStateOf(false) } - var menuEpgLoading by remember { mutableStateOf(false) } + var menuRefreshing by remember { mutableStateOf(false) } + var epgFetchGen by remember { mutableIntStateOf(0) } + var lastCategoryShiftAt by remember { mutableLongStateOf(0L) } val menuEpgNow = remember { mutableStateMapOf() } val menuListState = rememberLazyListState() val menuChannelFocus = remember { mutableStateMapOf() } @@ -177,6 +180,25 @@ fun LiveTvPlayerScreen( runCatching { epg = api.iptvEpg(currentStreamId) } } + suspend fun fetchEpgTitles(ids: List, 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) { + val gen = epgFetchGen + menuScope.launch { + fetchEpgTitles(ids, gen) + } + } + LaunchedEffect(channelMenuOpen) { if (!channelMenuOpen) return@LaunchedEffect if (menuCategories.isEmpty()) { @@ -186,47 +208,31 @@ fun LiveTvPlayerScreen( LaunchedEffect(channelMenuOpen, menuCategoryId) { if (!channelMenuOpen) return@LaunchedEffect - menuLoading = true - menuEpgNow.clear() + epgFetchGen++ + val gen = epgFetchGen + menuRefreshing = true try { val catId = menuCategoryId menuCategoryName = menuCategories.firstOrNull { it.id == catId }?.name ?: "Zenders" - menuChannels = if (!catId.isNullOrBlank()) { + val loaded = if (!catId.isNullOrBlank()) { api.iptvChannels(catId) } else { api.iptvChannels() } + if (gen != epgFetchGen) return@LaunchedEffect + menuChannels = loaded + prefetchMenuEpg(loaded.take(18).map { it.streamId }) } catch (_: Exception) { - menuChannels = emptyList() + if (gen == epgFetchGen) menuChannels = emptyList() } finally { - menuLoading = 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 + if (gen == epgFetchGen) menuRefreshing = false } } fun shiftMenuCategory(delta: Int) { + val now = System.currentTimeMillis() + if (now - lastCategoryShiftAt < 320) return + lastCategoryShiftAt = now val cats = menuCategories if (cats.isEmpty()) return val idx = cats.indexOfFirst { it.id == menuCategoryId }.let { if (it < 0) 0 else it } @@ -235,12 +241,21 @@ fun LiveTvPlayerScreen( 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 - 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) - delay(100) - menuChannelFocus[currentStreamId]?.let { runCatching { it.requestFocus() } } + delay(80) + menuChannelFocus[targetId]?.let { runCatching { it.requestFocus() } } } LaunchedEffect(infoOverlay) { @@ -437,14 +452,14 @@ fun LiveTvPlayerScreen( categoryCount = menuCategories.size, channels = menuChannels, epgNow = menuEpgNow, - epgLoading = menuEpgLoading, currentStreamId = currentStreamId, - loading = menuLoading, + refreshing = menuRefreshing, switching = switching, listState = menuListState, channelFocus = menuChannelFocus, onSelect = { selectChannel(it) }, onCategoryShift = { shiftMenuCategory(it) }, + onChannelFocused = { onMenuChannelFocused(it) }, modifier = Modifier.align(Alignment.CenterEnd), ) } @@ -481,14 +496,14 @@ private fun LiveChannelSwitcher( categoryCount: Int, channels: List, epgNow: Map, - epgLoading: Boolean, currentStreamId: Int, - loading: Boolean, + refreshing: Boolean, switching: Boolean, listState: androidx.compose.foundation.lazy.LazyListState, channelFocus: MutableMap, onSelect: (Int) -> Unit, onCategoryShift: (Int) -> Unit, + onChannelFocused: (Int) -> Unit, modifier: Modifier = Modifier, ) { val glassShape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp) @@ -566,8 +581,8 @@ private fun LiveChannelSwitcher( } if (switching) { Text("Wisselen…", color = McColors.Brand, fontSize = 12.sp, modifier = Modifier.padding(top = 4.dp)) - } else if (epgLoading) { - Text("EPG laden…", color = Color(0xFF666666), fontSize = 11.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)) } } Box( @@ -577,7 +592,7 @@ private fun LiveChannelSwitcher( .background(Color.White.copy(alpha = 0.08f)), ) when { - loading -> { + channels.isEmpty() && refreshing -> { Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Laden…", color = McColors.TextDim, fontSize = 14.sp) } @@ -594,7 +609,8 @@ private fun LiveChannelSwitcher( contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.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 fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() } Surface( @@ -602,7 +618,10 @@ private fun LiveChannelSwitcher( modifier = Modifier .fillMaxWidth() .padding(horizontal = 10.dp) - .focusRequester(fr), + .focusRequester(fr) + .onFocusChanged { state -> + if (state.isFocused) onChannelFocused(index) + }, shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), colors = ClickableSurfaceDefaults.colors( @@ -667,7 +686,7 @@ private fun LiveChannelSwitcher( ) } Text( - epgNow[ch.streamId] ?: if (epgLoading) "…" else "", + epgNow[ch.streamId].orEmpty(), color = McColors.TextDim, fontSize = 12.sp, maxLines = 1,