From e061d235692ea3ae6a9036bfcc422296a3b6cf1f Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 2 Sep 2026 00:56:45 +0200 Subject: [PATCH] Redesign live TV guide with compact Ziggo-style list layout. Replace large channel tiles with dense rows, horizontal category tabs, and a right-side EPG detail panel while keeping focus restoration behavior. Co-authored-by: Cursor --- apps/android-tv/app/build.gradle.kts | 4 +- .../nl/vonas/mediacluster/tv/ui/TvScreen.kt | 695 ++++++++---------- 2 files changed, 305 insertions(+), 394 deletions(-) diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index f755b5a..9f8b540 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 = 34 - versionName = "0.11.2" + versionCode = 35 + versionName = "0.11.3" 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/TvScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt index 0172374..9827fd3 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt @@ -3,7 +3,6 @@ package nl.vonas.mediacluster.tv.ui import androidx.activity.compose.BackHandler import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -18,14 +17,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -34,7 +28,6 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,18 +35,14 @@ 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.layout.ContentScale import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Glow import androidx.tv.material3.Surface import androidx.tv.material3.Text import coil.compose.AsyncImage @@ -67,6 +56,14 @@ import nl.vonas.mediacluster.tv.data.IptvChannel import nl.vonas.mediacluster.tv.data.IptvEpg import nl.vonas.mediacluster.tv.ui.theme.McColors import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +private val TvRowBg = Color(0xFF141414) +private val TvRowFocus = Color(0xFF242424) +private val TvRowActive = Color(0xFF1C1C1C) +private val TvDivider = Color(0xFF2A2A2A) +private val TvPanelBg = Color(0xFF101010) @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -87,13 +84,15 @@ fun TvScreen( var loadingChannels by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } var focusedEpg by remember { mutableStateOf(null) } + var focusedStreamId by remember { mutableStateOf(null) } var epgJob by remember { mutableStateOf(null) } - var focusInGrid by remember { mutableStateOf(false) } - val gridState = rememberSaveable(saver = LazyGridState.Saver) { LazyGridState() } - val categoryListState = rememberLazyListState() + var focusInList by remember { mutableStateOf(false) } + val channelListState = rememberLazyListState() + val categoryRowState = rememberLazyListState() val scope = rememberCoroutineScope() val categoryFocus = remember { mutableStateMapOf() } val channelFocus = remember { mutableStateMapOf() } + val epgNowTitles = remember { mutableStateMapOf() } LaunchedEffect(Unit) { loading = true @@ -117,6 +116,8 @@ fun TvScreen( LaunchedEffect(selectedCategoryId) { val catId = selectedCategoryId ?: return@LaunchedEffect loadingChannels = true + focusedEpg = null + focusedStreamId = null try { channels = api.iptvChannels(catId) } catch (e: Exception) { @@ -127,140 +128,161 @@ fun TvScreen( } } - LaunchedEffect(channels, lastStreamId) { - val target = lastStreamId ?: return@LaunchedEffect - val idx = channels.indexOfFirst { it.streamId == target } - if (idx >= 0) { - gridState.scrollToItem(idx) - runCatching { focusedEpg = api.iptvEpg(target) } - } - } - LaunchedEffect(focusContentNonce, loading, categories, channels, selectedCategoryId, lastStreamId) { if (loading || categories.isEmpty()) return@LaunchedEffect - delay(140) + delay(120) val channelTarget = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } } if (channelTarget != null) { val idx = channels.indexOfFirst { it.streamId == channelTarget } - if (idx >= 0) gridState.scrollToItem(idx) + if (idx >= 0) channelListState.scrollToItem(idx) channelFocus[channelTarget]?.let { runCatching { it.requestFocus() } } - focusInGrid = true + focusInList = true + focusedStreamId = channelTarget + runCatching { + val epg = api.iptvEpg(channelTarget) + focusedEpg = epg + epg.now?.title?.let { epgNowTitles[channelTarget] = it } + } } else { val catId = selectedCategoryId ?: categories.firstOrNull()?.id ?: return@LaunchedEffect val catIdx = categories.indexOfFirst { it.id == catId }.coerceAtLeast(0) - categoryListState.scrollToItem(catIdx) + categoryRowState.scrollToItem(catIdx) categoryFocus[catId]?.let { runCatching { it.requestFocus() } } - focusInGrid = false + focusInList = false } } - BackHandler(enabled = focusInGrid) { + BackHandler(enabled = focusInList) { val catId = selectedCategoryId ?: categories.firstOrNull()?.id if (catId != null) { categoryFocus[catId]?.let { runCatching { it.requestFocus() } } - focusInGrid = false + focusInList = false } } - BackHandler(enabled = !focusInGrid) { + BackHandler(enabled = !focusInList) { onAtTopBack() } fun loadEpg(streamId: Int) { + focusedStreamId = streamId epgJob?.cancel() epgJob = scope.launch { - delay(280) - runCatching { focusedEpg = api.iptvEpg(streamId) } + delay(200) + runCatching { + val epg = api.iptvEpg(streamId) + focusedEpg = epg + epg.now?.title?.let { epgNowTitles[streamId] = it } + } } } - val selectedCategory = categories.firstOrNull { it.id == selectedCategoryId } - - Box( - Modifier - .fillMaxSize() - .background( - Brush.horizontalGradient( - listOf(Color(0xFF08080A), McColors.Bg, McColors.Bg), - ), - ), - ) { + Box(Modifier.fillMaxSize().background(McColors.Bg)) { when { loading -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("TV laden…", color = McColors.TextDim, fontSize = 18.sp) + Text("TV laden…", color = McColors.TextDim, fontSize = 15.sp) } } error != null && categories.isEmpty() -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text("TV niet beschikbaar", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold) - Text(error!!, color = McColors.Danger, fontSize = 15.sp) + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("TV niet beschikbaar", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + Text(error!!, color = McColors.Danger, fontSize = 14.sp) } } } else -> { - Row(Modifier.fillMaxSize()) { - CategorySidebar( + Column(Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 8.dp)) { + CategoryTabRow( categories = categories, selectedId = selectedCategoryId, - listState = categoryListState, + listState = categoryRowState, categoryFocus = categoryFocus, onSelect = { onCategoryChange(it) }, onFocused = { - focusInGrid = false + focusInList = false onContentFocused() }, ) - Column( - Modifier - .weight(1f) - .fillMaxHeight() - .padding(start = 4.dp, end = 40.dp, top = 4.dp, bottom = 16.dp), - ) { - TvHeader( - categoryName = selectedCategory?.name ?: "Live TV", - channelCount = channels.size, - loading = loadingChannels, - ) - if (channels.isEmpty() && !loadingChannels) { - Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { - Text("Geen zenders in deze categorie", color = McColors.TextDim, fontSize = 16.sp) - } - } else { - LazyVerticalGrid( - columns = GridCells.Fixed(4), - state = gridState, - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + Spacer(Modifier.height(8.dp)) + Box( + Modifier + .fillMaxWidth() + .height(1.dp) + .background(TvDivider), + ) + Spacer(Modifier.height(4.dp)) + + Row(Modifier.weight(1f).fillMaxWidth()) { + Column(Modifier.weight(0.58f).fillMaxHeight()) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, ) { - items(channels, key = { it.streamId }) { ch -> - val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() } - ChannelTile( - channel = ch, - isLastWatched = ch.streamId == lastStreamId, - modifier = Modifier.focusRequester(fr), - onClick = { - onLastStreamChange(ch.streamId) - onPlayChannel(ch.streamId) - }, - onFocused = { - focusInGrid = true - onContentFocused() - onLastStreamChange(ch.streamId) - loadEpg(ch.streamId) - }, - ) + Text("Zender", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium) + Text("Nu op TV", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium) + } + when { + loadingChannels -> { + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text("Zenders laden…", color = McColors.TextDim, fontSize = 14.sp) + } + } + channels.isEmpty() -> { + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text("Geen zenders", color = McColors.TextDim, fontSize = 14.sp) + } + } + else -> { + LazyColumn( + state = channelListState, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { + items(channels, key = { it.streamId }) { ch -> + val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() } + ChannelRow( + channel = ch, + nowTitle = epgNowTitles[ch.streamId], + isFocused = focusedStreamId == ch.streamId, + isLastWatched = ch.streamId == lastStreamId, + modifier = Modifier.focusRequester(fr), + onClick = { + onLastStreamChange(ch.streamId) + onPlayChannel(ch.streamId) + }, + onFocused = { + focusInList = true + onContentFocused() + onLastStreamChange(ch.streamId) + loadEpg(ch.streamId) + }, + ) + } + } } } } - EpgBar(epg = focusedEpg) + Box( + Modifier + .width(1.dp) + .fillMaxHeight() + .padding(vertical = 4.dp) + .background(TvDivider), + ) + + ChannelDetailPanel( + epg = focusedEpg, + modifier = Modifier + .weight(0.42f) + .fillMaxHeight() + .padding(start = 16.dp, end = 8.dp, top = 4.dp), + ) } } } @@ -268,71 +290,9 @@ fun TvScreen( } } -@Composable -private fun TvHeader( - categoryName: String, - channelCount: Int, - loading: Boolean, -) { - Column( - Modifier - .fillMaxWidth() - .padding(bottom = 16.dp, top = 8.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Box( - Modifier - .width(4.dp) - .height(28.dp) - .clip(RoundedCornerShape(2.dp)) - .background(McColors.Brand), - ) - Text( - categoryName, - color = Color.White, - fontSize = 28.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - Box( - Modifier - .clip(RoundedCornerShape(20.dp)) - .background(Color(0x22FFFFFF)) - .padding(horizontal = 12.dp, vertical = 6.dp), - ) { - Text( - when { - loading -> "Laden…" - else -> "$channelCount zenders" - }, - color = McColors.TextDim, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - ) - } - } - Spacer(Modifier.height(6.dp)) - Box( - Modifier - .fillMaxWidth() - .height(1.dp) - .background( - Brush.horizontalGradient( - listOf(McColors.Brand.copy(alpha = 0.5f), Color(0x22FFFFFF), Color.Transparent), - ), - ), - ) - } -} - @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun CategorySidebar( +private fun CategoryTabRow( categories: List, selectedId: String?, listState: androidx.compose.foundation.lazy.LazyListState, @@ -340,115 +300,52 @@ private fun CategorySidebar( onSelect: (String) -> Unit, onFocused: () -> Unit, ) { - LazyColumn( + LazyRow( state = listState, - modifier = Modifier - .width(280.dp) - .fillMaxHeight() - .background( - Brush.verticalGradient( - listOf(Color(0xFF0C0C10), Color(0xFF101014)), - ), - ) - .border( - width = 1.dp, - brush = Brush.verticalGradient( - listOf(Color(0x18FFFFFF), Color(0x08FFFFFF)), - ), - shape = RoundedCornerShape(topEnd = 16.dp, bottomEnd = 16.dp), - ) - .padding(vertical = 16.dp), - contentPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(vertical = 4.dp), ) { - item { - Text( - "LIVE TV", - color = McColors.Brand, - fontSize = 11.sp, - fontWeight = FontWeight.Black, - letterSpacing = 2.sp, - modifier = Modifier.padding(start = 10.dp, bottom = 6.dp, top = 4.dp), - ) - Text( - "Categorieën", - color = McColors.TextDim, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(start = 10.dp, bottom = 10.dp), - ) - } items(categories, key = { it.id }) { cat -> val active = cat.id == selectedId val fr = categoryFocus.getOrPut(cat.id) { FocusRequester() } - val bgColor by animateColorAsState( - when { - active -> Color(0x33E50914) - else -> Color.Transparent - }, - label = "catBg", + val textColor by animateColorAsState( + if (active) Color.White else Color(0xFF888888), + label = "tabText", ) Surface( onClick = { onSelect(cat.id) }, modifier = Modifier .focusRequester(fr) .onFocusChanged { if (it.isFocused) onFocused() }, - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.03f), - border = ClickableSurfaceDefaults.border( - focusedBorder = Border( - border = androidx.compose.foundation.BorderStroke(2.dp, Color.White.copy(alpha = 0.85f)), - shape = RoundedCornerShape(12.dp), - ), - ), - glow = ClickableSurfaceDefaults.glow( - focusedGlow = Glow(elevationColor = McColors.Brand.copy(alpha = 0.35f), elevation = 10.dp), - ), + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), colors = ClickableSurfaceDefaults.colors( - containerColor = bgColor, - focusedContainerColor = Color(0x28FFFFFF), - contentColor = if (active) Color.White else Color(0xFFB8B8B8), + containerColor = Color.Transparent, + focusedContainerColor = Color(0x18FFFFFF), + contentColor = textColor, focusedContentColor = Color.White, ), ) { - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), + Column( + Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Box( - Modifier - .width(3.dp) - .height(32.dp) - .clip(RoundedCornerShape(2.dp)) - .background(if (active) McColors.Brand else Color(0x33FFFFFF)), + Text( + cat.name, + color = Color.Unspecified, + fontSize = 14.sp, + fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - Column(Modifier.weight(1f)) { - Text( - cat.name, - color = Color.Unspecified, - fontSize = 15.sp, - fontWeight = if (active) FontWeight.Bold else FontWeight.Medium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - lineHeight = 19.sp, - ) - } + Spacer(Modifier.height(5.dp)) Box( Modifier - .clip(RoundedCornerShape(10.dp)) - .background(Color(0x22FFFFFF)) - .padding(horizontal = 8.dp, vertical = 4.dp), - ) { - Text( - "${cat.channelCount}", - color = McColors.TextDim, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - ) - } + .height(2.dp) + .width(if (active) 24.dp else 0.dp) + .clip(RoundedCornerShape(1.dp)) + .background(if (active) McColors.Brand else Color.Transparent), + ) } } } @@ -457,62 +354,63 @@ private fun CategorySidebar( @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun ChannelTile( +private fun ChannelRow( channel: IptvChannel, + nowTitle: String?, + isFocused: Boolean, isLastWatched: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit, onFocused: () -> Unit, ) { + val bg by animateColorAsState( + when { + isFocused -> TvRowFocus + isLastWatched -> TvRowActive + else -> TvRowBg + }, + label = "rowBg", + ) Surface( onClick = onClick, - modifier = modifier.onFocusChanged { if (it.isFocused) onFocused() }, - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), - border = ClickableSurfaceDefaults.border( - focusedBorder = Border( - border = androidx.compose.foundation.BorderStroke(2.5.dp, Color.White), - shape = RoundedCornerShape(14.dp), - ), - ), - glow = ClickableSurfaceDefaults.glow( - focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.2f), elevation = 8.dp), - ), + modifier = modifier.onFocusChanged { state -> + if (state.isFocused) onFocused() + }, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), colors = ClickableSurfaceDefaults.colors( - containerColor = Color(0xFF16161C), - focusedContainerColor = Color(0xFF222228), + containerColor = bg, + focusedContainerColor = TvRowFocus, contentColor = Color.White, focusedContentColor = Color.White, ), ) { - Column( + Row( Modifier .fillMaxWidth() - .background( - Brush.verticalGradient( - listOf(Color(0xFF1C1C24), Color(0xFF121218)), - ), - ) - .padding(14.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp), + .height(52.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { Box( Modifier - .size(88.dp) - .clip(RoundedCornerShape(12.dp)) + .width(3.dp) + .height(28.dp) + .clip(RoundedCornerShape(2.dp)) .background( - Brush.radialGradient( - listOf(Color(0xFF252530), Color(0xFF0E0E12)), - ), - ) - .then( - if (isLastWatched) { - Modifier.border(2.dp, McColors.Brand.copy(alpha = 0.7f), RoundedCornerShape(12.dp)) - } else { - Modifier + when { + isFocused -> McColors.Brand + isLastWatched -> McColors.Brand.copy(alpha = 0.45f) + else -> Color.Transparent }, ), + ) + Spacer(Modifier.width(6.dp)) + Box( + Modifier + .size(36.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xFF0A0A0A)), contentAlignment = Alignment.Center, ) { if (!channel.logoUrl.isNullOrBlank()) { @@ -521,158 +419,171 @@ private fun ChannelTile( contentDescription = channel.name, modifier = Modifier .fillMaxSize() - .padding(10.dp), + .padding(4.dp), contentScale = ContentScale.Fit, ) } else { Text( channel.name.take(2).uppercase(), - color = McColors.Brand, - fontSize = 26.sp, - fontWeight = FontWeight.Black, - ) - } - channel.number?.let { num -> - Box( - Modifier - .align(Alignment.TopStart) - .padding(6.dp) - .clip(RoundedCornerShape(6.dp)) - .background(Color(0xCC000000)) - .padding(horizontal = 6.dp, vertical = 2.dp), - ) { - Text( - "$num", - color = Color.White, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - ) - } - } - if (isLastWatched) { - Box( - Modifier - .align(Alignment.TopEnd) - .padding(6.dp) - .size(8.dp) - .clip(CircleShape) - .background(McColors.Brand), + color = McColors.TextDim, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, ) } } + Spacer(Modifier.width(10.dp)) + channel.number?.let { num -> + Text( + "$num", + color = McColors.TextDim, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.width(36.dp), + ) + } Text( channel.name, color = Color.White, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 2, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - lineHeight = 16.sp, + modifier = Modifier.width(140.dp), + ) + Text( + nowTitle ?: "", + color = if (isFocused) Color(0xFFCCCCCC) else McColors.TextDim, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), ) } } } -@OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun EpgBar(epg: IptvEpg?) { +private fun ChannelDetailPanel( + epg: IptvEpg?, + modifier: Modifier = Modifier, +) { Column( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background( - Brush.horizontalGradient( - listOf(Color(0xFF16161C), Color(0xFF101014)), - ), - ) - .border(1.dp, Color(0x18FFFFFF), RoundedCornerShape(14.dp)) - .padding(horizontal = 20.dp, vertical = 14.dp), + modifier + .clip(RoundedCornerShape(6.dp)) + .background(TvPanelBg) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { if (epg == null) { - Text("Selecteer een zender voor programma-info", color = McColors.TextDim, fontSize = 14.sp) + Spacer(Modifier.weight(1f)) + Text( + "Selecteer een zender", + color = McColors.TextDim, + fontSize = 14.sp, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(Modifier.weight(1f)) return } Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { if (!epg.logoUrl.isNullOrBlank()) { AsyncImage( model = epg.logoUrl, contentDescription = null, modifier = Modifier - .size(44.dp) - .clip(RoundedCornerShape(8.dp)) + .size(48.dp) + .clip(RoundedCornerShape(4.dp)) .background(Color(0xFF0A0A0A)), contentScale = ContentScale.Fit, ) } - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Column { Text( epg.channelName, color = Color.White, - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) - epg.now?.let { now -> - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - Modifier - .clip(RoundedCornerShape(4.dp)) - .background(McColors.Brand) - .padding(horizontal = 6.dp, vertical = 2.dp), - ) { - Text("NU", color = Color.White, fontSize = 10.sp, fontWeight = FontWeight.Black) - } - Text( - now.title, - color = Color(0xFFE8E8E8), - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - } - val progress = epgProgress(now) - if (progress > 0f) { - Box( - Modifier - .fillMaxWidth() - .height(3.dp) - .clip(RoundedCornerShape(2.dp)) - .background(Color(0x33FFFFFF)), - ) { - Box( - Modifier - .fillMaxWidth(progress) - .height(3.dp) - .clip(RoundedCornerShape(2.dp)) - .background(McColors.Brand), - ) - } - } - } - epg.next?.let { next -> - Text( - "Straks · ${next.title}", - color = McColors.TextDim, - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + } + } + + Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider)) + + epg.now?.let { now -> + EpgBlock(label = "Nu", program = now, highlight = true) + val progress = epgProgress(now) + if (progress > 0f) { + Box( + Modifier + .fillMaxWidth() + .height(2.dp) + .clip(RoundedCornerShape(1.dp)) + .background(Color(0xFF333333)), + ) { + Box( + Modifier + .fillMaxWidth(progress) + .height(2.dp) + .background(McColors.Brand), ) } - if (epg.now == null && epg.next == null) { - Text("Geen programma-info", color = McColors.TextDim, fontSize = 13.sp) - } } } + + epg.next?.let { next -> + EpgBlock(label = "Straks", program = next, highlight = false) + } + + if (epg.now == null && epg.next == null) { + Text("Geen programma-info beschikbaar", color = McColors.TextDim, fontSize = 13.sp) + } + + Spacer(Modifier.weight(1f)) + Text("OK om te kijken", color = Color(0xFF666666), fontSize = 12.sp) + } +} + +@Composable +private fun EpgBlock(label: String, program: EpgProgram, highlight: Boolean) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + label.uppercase(), + color = if (highlight) McColors.Brand else McColors.TextDim, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + ) + formatEpgTime(program.start, program.end)?.let { time -> + Text(time, color = McColors.TextDim, fontSize = 12.sp) + } + } + Text( + program.title, + color = if (highlight) Color.White else Color(0xFFBBBBBB), + fontSize = if (highlight) 15.sp else 14.sp, + fontWeight = if (highlight) FontWeight.Medium else FontWeight.Normal, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + lineHeight = 20.sp, + ) + } +} + +private fun formatEpgTime(start: String?, end: String?): String? { + val fmt = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault()) + val s = start?.let { runCatching { fmt.format(Instant.parse(it)) }.getOrNull() } + val e = end?.let { runCatching { fmt.format(Instant.parse(it)) }.getOrNull() } + return when { + s != null && e != null -> "$s – $e" + s != null -> s + else -> null } }