diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 4c344e3..1fbd755 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 = 36 - versionName = "0.11.4" + versionCode = 37 + versionName = "0.11.5" 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 a3af85f..f4baeb5 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 @@ -65,8 +65,12 @@ 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 nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.data.IptvCategory import nl.vonas.mediacluster.tv.data.IptvChannel import nl.vonas.mediacluster.tv.data.IptvEpg import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer @@ -80,6 +84,7 @@ fun LiveTvPlayerScreen( api: ApiClient, streamId: Int, categoryId: String?, + onCategoryChange: (String) -> Unit, onStreamChange: (Int) -> Unit, onBack: () -> Unit, ) { @@ -101,8 +106,12 @@ fun LiveTvPlayerScreen( var triedFallback by remember { mutableStateOf(false) } var menuChannels by remember { mutableStateOf>(emptyList()) } + 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) } + val menuEpgNow = remember { mutableStateMapOf() } val menuListState = rememberLazyListState() val menuChannelFocus = remember { mutableStateMapOf() } @@ -150,6 +159,10 @@ fun LiveTvPlayerScreen( } } + LaunchedEffect(categoryId) { + menuCategoryId = categoryId + } + LaunchedEffect(streamId) { currentStreamId = streamId } @@ -164,14 +177,22 @@ fun LiveTvPlayerScreen( runCatching { epg = api.iptvEpg(currentStreamId) } } - LaunchedEffect(channelMenuOpen, categoryId) { + LaunchedEffect(channelMenuOpen) { + if (!channelMenuOpen) return@LaunchedEffect + if (menuCategories.isEmpty()) { + runCatching { menuCategories = api.iptvCategories() } + } + } + + LaunchedEffect(channelMenuOpen, menuCategoryId) { if (!channelMenuOpen) return@LaunchedEffect menuLoading = true + menuEpgNow.clear() try { - val cats = runCatching { api.iptvCategories() }.getOrDefault(emptyList()) - menuCategoryName = cats.firstOrNull { it.id == categoryId }?.name ?: "Zenders" - menuChannels = if (!categoryId.isNullOrBlank()) { - api.iptvChannels(categoryId) + val catId = menuCategoryId + menuCategoryName = menuCategories.firstOrNull { it.id == catId }?.name ?: "Zenders" + menuChannels = if (!catId.isNullOrBlank()) { + api.iptvChannels(catId) } else { api.iptvChannels() } @@ -182,6 +203,38 @@ fun LiveTvPlayerScreen( } } + 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) { + val cats = menuCategories + if (cats.isEmpty()) return + val idx = cats.indexOfFirst { it.id == menuCategoryId }.let { if (it < 0) 0 else it } + val next = cats[(idx + delta).mod(cats.size)] + menuCategoryId = next.id + onCategoryChange(next.id) + } + LaunchedEffect(channelMenuOpen, menuChannels, currentStreamId) { if (!channelMenuOpen || menuChannels.isEmpty()) return@LaunchedEffect val idx = menuChannels.indexOfFirst { it.streamId == currentStreamId }.coerceAtLeast(0) @@ -380,13 +433,18 @@ fun LiveTvPlayerScreen( ) LiveChannelSwitcher( categoryName = menuCategoryName, + categoryIndex = menuCategories.indexOfFirst { it.id == menuCategoryId }.coerceAtLeast(0), + categoryCount = menuCategories.size, channels = menuChannels, + epgNow = menuEpgNow, + epgLoading = menuEpgLoading, currentStreamId = currentStreamId, loading = menuLoading, switching = switching, listState = menuListState, channelFocus = menuChannelFocus, onSelect = { selectChannel(it) }, + onCategoryShift = { shiftMenuCategory(it) }, modifier = Modifier.align(Alignment.CenterEnd), ) } @@ -419,21 +477,40 @@ fun LiveTvPlayerScreen( @Composable private fun LiveChannelSwitcher( categoryName: String, + categoryIndex: Int, + categoryCount: Int, channels: List, + epgNow: Map, + epgLoading: Boolean, currentStreamId: Int, loading: Boolean, switching: Boolean, listState: androidx.compose.foundation.lazy.LazyListState, channelFocus: MutableMap, onSelect: (Int) -> Unit, + onCategoryShift: (Int) -> Unit, modifier: Modifier = Modifier, ) { val glassShape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp) Box( modifier .padding(top = 28.dp, bottom = 28.dp, end = 0.dp) - .width(340.dp) + .width(380.dp) .fillMaxHeight() + .onPreviewKeyEvent { ev -> + if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (ev.key) { + Key.DirectionLeft -> { + onCategoryShift(-1) + true + } + Key.DirectionRight -> { + onCategoryShift(1) + true + } + else -> false + } + } .clip(glassShape) .background( Brush.horizontalGradient( @@ -452,25 +529,45 @@ private fun LiveChannelSwitcher( Column( Modifier .fillMaxWidth() - .padding(horizontal = 18.dp, vertical = 16.dp), + .padding(horizontal = 18.dp, vertical = 14.dp), ) { - Text( - "Zenders", - color = McColors.TextDim, - fontSize = 11.sp, - fontWeight = FontWeight.Medium, - letterSpacing = 1.sp, - ) - Text( - categoryName, - color = Color.White, - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("‹", color = Color(0xFF666666), fontSize = 16.sp) + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) { + Text( + "Zenders", + color = McColors.TextDim, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 1.sp, + ) + Text( + categoryName, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (categoryCount > 1) { + Text( + "${categoryIndex + 1} / $categoryCount · ← → categorie", + color = Color(0xFF666666), + fontSize = 10.sp, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + Text("›", color = Color(0xFF666666), fontSize = 16.sp) + } 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)) } } Box( @@ -518,14 +615,14 @@ private fun LiveChannelSwitcher( Row( Modifier .fillMaxWidth() - .height(48.dp) + .height(54.dp) .padding(horizontal = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( Modifier .width(3.dp) - .height(24.dp) + .height(28.dp) .clip(RoundedCornerShape(2.dp)) .background(if (isCurrent) McColors.Brand else Color.Transparent), ) @@ -548,24 +645,35 @@ private fun LiveChannelSwitcher( ) } } - Spacer(Modifier.width(10.dp)) - ch.number?.let { num -> + Spacer(Modifier.width(8.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + ch.number?.let { num -> + Text( + "$num", + color = McColors.TextDim, + fontSize = 12.sp, + modifier = Modifier.width(30.dp), + ) + } + Text( + ch.name, + color = if (isCurrent) Color.White else Color(0xFFDDDDDD), + fontSize = 14.sp, + fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } Text( - "$num", + epgNow[ch.streamId] ?: if (epgLoading) "…" else "", color = McColors.TextDim, fontSize = 12.sp, - modifier = Modifier.width(32.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } - Text( - ch.name, - color = if (isCurrent) Color.White else Color(0xFFDDDDDD), - fontSize = 14.sp, - fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index de8a215..107ddd3 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -226,6 +226,7 @@ fun McNav() { api = api, streamId = streamId, categoryId = tvCategoryId, + onCategoryChange = { tvCategoryId = it }, onStreamChange = { tvLastStreamId = it }, onBack = { nav.popBackStack() }, )