From 4ba32a23271294e228b11a5b531822ccecdf2d4b Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 2 Sep 2026 00:49:44 +0200 Subject: [PATCH] Improve live TV focus restoration and modernize channel guide UI. Focus lands on categories or last watched channel when opening TV or returning from playback, and refresh the category sidebar, channel grid, and EPG bar styling. Co-authored-by: Cursor --- apps/android-tv/app/build.gradle.kts | 4 +- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 24 +- .../nl/vonas/mediacluster/tv/ui/TvScreen.kt | 495 ++++++++++++++---- 3 files changed, 410 insertions(+), 113 deletions(-) diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index e0f049f..f755b5a 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 = 33 - versionName = "0.11.1" + versionCode = 34 + versionName = "0.11.2" 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/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 27c2fce..9ca9c4d 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 @@ -26,10 +26,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -82,6 +84,7 @@ fun McNav() { var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) } var tvCategoryId by rememberSaveable { mutableStateOf(null) } var tvLastStreamId by rememberSaveable { mutableStateOf(null) } + var tvFocusNonce by remember { mutableIntStateOf(0) } val hubTab = remember(hubTabName) { MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home } @@ -115,6 +118,8 @@ fun McNav() { onTvCategoryChange = { tvCategoryId = it }, tvLastStreamId = tvLastStreamId, onTvLastStreamChange = { tvLastStreamId = it }, + tvFocusNonce = tvFocusNonce, + onTvFocusContent = { tvFocusNonce++ }, onOpenMovie = { nav.navigate("movie/$it") }, onOpenSeries = { nav.navigate("series/$it") }, onBrowse = { catalogId, _title -> @@ -214,6 +219,9 @@ fun McNav() { arguments = listOf(navArgument("streamId") { type = NavType.IntType }), ) { entry -> val streamId = entry.arguments?.getInt("streamId") ?: return@composable + DisposableEffect(Unit) { + onDispose { tvFocusNonce++ } + } LiveTvPlayerScreen( api = api, streamId = streamId, @@ -232,6 +240,8 @@ private fun MainHub( onTvCategoryChange: (String?) -> Unit, tvLastStreamId: Int?, onTvLastStreamChange: (Int?) -> Unit, + tvFocusNonce: Int, + onTvFocusContent: () -> Unit, onOpenMovie: (String) -> Unit, onOpenSeries: (String) -> Unit, onBrowse: (catalogId: String, title: String) -> Unit, @@ -347,6 +357,12 @@ private fun MainHub( } } + LaunchedEffect(tvFocusNonce) { + if (tab == MainTab.Tv) { + navFocused = false + } + } + Box(Modifier.fillMaxSize().background(McColors.Bg)) { Column(Modifier.fillMaxSize()) { Box(Modifier.focusRequester(navFocus)) { @@ -357,7 +373,12 @@ private fun MainHub( if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar onTabChange(it) exitConfirm = false - focusTopNav() + if (it == MainTab.Tv) { + navFocused = false + onTvFocusContent() + } else { + focusTopNav() + } when (it) { MainTab.Movies -> { if (movieSubId == "__search__") { @@ -428,6 +449,7 @@ private fun MainHub( onCategoryChange = onTvCategoryChange, lastStreamId = tvLastStreamId, onLastStreamChange = onTvLastStreamChange, + focusContentNonce = tvFocusNonce, onAtTopBack = { focusTopNav() }, onPlayChannel = onPlayLive, onContentFocused = { navFocused = false }, 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 51c73bc..0172374 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 @@ -1,11 +1,15 @@ 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 import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -20,18 +24,25 @@ 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.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 import androidx.compose.runtime.getValue +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 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 @@ -39,20 +50,23 @@ 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 import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import androidx.compose.runtime.rememberCoroutineScope import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.data.EpgProgram 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.ui.theme.McColors +import java.time.Instant @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -62,6 +76,7 @@ fun TvScreen( onCategoryChange: (String?) -> Unit, lastStreamId: Int? = null, onLastStreamChange: (Int?) -> Unit = {}, + focusContentNonce: Int = 0, onAtTopBack: () -> Unit, onPlayChannel: (streamId: Int) -> Unit, onContentFocused: () -> Unit = {}, @@ -73,8 +88,12 @@ fun TvScreen( var error by remember { mutableStateOf(null) } var focusedEpg 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() val scope = rememberCoroutineScope() + val categoryFocus = remember { mutableStateMapOf() } + val channelFocus = remember { mutableStateMapOf() } LaunchedEffect(Unit) { loading = true @@ -98,7 +117,6 @@ fun TvScreen( LaunchedEffect(selectedCategoryId) { val catId = selectedCategoryId ?: return@LaunchedEffect loadingChannels = true - focusedEpg = null try { channels = api.iptvChannels(catId) } catch (e: Exception) { @@ -118,6 +136,35 @@ fun TvScreen( } } + LaunchedEffect(focusContentNonce, loading, categories, channels, selectedCategoryId, lastStreamId) { + if (loading || categories.isEmpty()) return@LaunchedEffect + delay(140) + 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) + channelFocus[channelTarget]?.let { runCatching { it.requestFocus() } } + focusInGrid = true + } else { + val catId = selectedCategoryId ?: categories.firstOrNull()?.id ?: return@LaunchedEffect + val catIdx = categories.indexOfFirst { it.id == catId }.coerceAtLeast(0) + categoryListState.scrollToItem(catIdx) + categoryFocus[catId]?.let { runCatching { it.requestFocus() } } + focusInGrid = false + } + } + + BackHandler(enabled = focusInGrid) { + val catId = selectedCategoryId ?: categories.firstOrNull()?.id + if (catId != null) { + categoryFocus[catId]?.let { runCatching { it.requestFocus() } } + focusInGrid = false + } + } + BackHandler(enabled = !focusInGrid) { + onAtTopBack() + } + fun loadEpg(streamId: Int) { epgJob?.cancel() epgJob = scope.launch { @@ -128,7 +175,15 @@ fun TvScreen( val selectedCategory = categories.firstOrNull { it.id == selectedCategoryId } - Box(Modifier.fillMaxSize().background(McColors.Bg)) { + Box( + Modifier + .fillMaxSize() + .background( + Brush.horizontalGradient( + listOf(Color(0xFF08080A), McColors.Bg, McColors.Bg), + ), + ), + ) { when { loading -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -137,7 +192,10 @@ fun TvScreen( } error != null && categories.isEmpty() -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + 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) } @@ -148,37 +206,25 @@ fun TvScreen( CategorySidebar( categories = categories, selectedId = selectedCategoryId, + listState = categoryListState, + categoryFocus = categoryFocus, onSelect = { onCategoryChange(it) }, - onFocused = onContentFocused, + onFocused = { + focusInGrid = false + onContentFocused() + }, ) Column( Modifier .weight(1f) .fillMaxHeight() - .padding(start = 8.dp, end = 40.dp, top = 8.dp, bottom = 16.dp), + .padding(start = 4.dp, end = 40.dp, top = 4.dp, bottom = 16.dp), ) { - Row( - Modifier.fillMaxWidth().padding(bottom = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text( - selectedCategory?.name ?: "Live TV", - color = Color.White, - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - ) - Text( - when { - loadingChannels -> "Zenders laden…" - else -> "${channels.size} zenders" - }, - color = McColors.TextDim, - fontSize = 14.sp, - ) - } - } + TvHeader( + categoryName = selectedCategory?.name ?: "Live TV", + channelCount = channels.size, + loading = loadingChannels, + ) if (channels.isEmpty() && !loadingChannels) { Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { @@ -186,21 +232,25 @@ fun TvScreen( } } else { LazyVerticalGrid( - columns = GridCells.Fixed(5), + columns = GridCells.Fixed(4), state = gridState, modifier = Modifier.weight(1f), - contentPadding = PaddingValues(vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { 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) @@ -218,67 +268,187 @@ 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( categories: List, selectedId: String?, + listState: androidx.compose.foundation.lazy.LazyListState, + categoryFocus: MutableMap, onSelect: (String) -> Unit, onFocused: () -> Unit, ) { LazyColumn( + state = listState, modifier = Modifier - .width(260.dp) + .width(280.dp) .fillMaxHeight() - .background(Color(0xFF121212)) - .padding(vertical = 12.dp), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + .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), ) { 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 = 12.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 8.dp, bottom = 8.dp), + 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", + ) Surface( onClick = { onSelect(cat.id) }, - modifier = Modifier.onFocusChanged { if (it.isFocused) onFocused() }, - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), + 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), + ), colors = ClickableSurfaceDefaults.colors( - containerColor = if (active) Color(0x33FFFFFF) else Color.Transparent, - focusedContainerColor = Color(0x44FFFFFF), - contentColor = if (active) Color.White else Color(0xFFB0B0B0), + containerColor = bgColor, + focusedContainerColor = Color(0x28FFFFFF), + contentColor = if (active) Color.White else Color(0xFFB8B8B8), focusedContentColor = Color.White, ), ) { Row( Modifier .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, + .padding(horizontal = 12.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Text( - cat.name, - color = Color.Unspecified, - fontSize = 15.sp, - fontWeight = if (active) FontWeight.Bold else FontWeight.Medium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Text( - "${cat.channelCount}", - color = McColors.TextDim, - fontSize = 12.sp, + Box( + Modifier + .width(3.dp) + .height(32.dp) + .clip(RoundedCornerShape(2.dp)) + .background(if (active) McColors.Brand else Color(0x33FFFFFF)), ) + 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, + ) + } + 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, + ) + } } } } @@ -289,17 +459,28 @@ private fun CategorySidebar( @Composable private fun ChannelTile( channel: IptvChannel, + isLastWatched: Boolean, + modifier: Modifier = Modifier, onClick: () -> Unit, onFocused: () -> Unit, ) { Surface( onClick = onClick, - modifier = Modifier.onFocusChanged { if (it.isFocused) onFocused() }, - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), + 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), + ), colors = ClickableSurfaceDefaults.colors( - containerColor = Color(0xFF1A1A1A), - focusedContainerColor = Color(0xFF2A2A2A), + containerColor = Color(0xFF16161C), + focusedContainerColor = Color(0xFF222228), contentColor = Color.White, focusedContentColor = Color.White, ), @@ -307,45 +488,83 @@ private fun ChannelTile( Column( Modifier .fillMaxWidth() - .padding(12.dp), + .background( + Brush.verticalGradient( + listOf(Color(0xFF1C1C24), Color(0xFF121218)), + ), + ) + .padding(14.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { Box( Modifier - .size(72.dp) - .clip(RoundedCornerShape(8.dp)) - .background(Color(0xFF0D0D0D)), + .size(88.dp) + .clip(RoundedCornerShape(12.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 + }, + ), contentAlignment = Alignment.Center, ) { if (!channel.logoUrl.isNullOrBlank()) { AsyncImage( model = channel.logoUrl, contentDescription = channel.name, - modifier = Modifier.fillMaxSize().padding(6.dp), + modifier = Modifier + .fillMaxSize() + .padding(10.dp), contentScale = ContentScale.Fit, ) } else { Text( channel.name.take(2).uppercase(), color = McColors.Brand, - fontSize = 22.sp, - fontWeight = FontWeight.Bold, + 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), ) } - } - channel.number?.let { num -> - Text( - "$num", - color = McColors.TextDim, - fontSize = 11.sp, - ) } Text( channel.name, color = Color.White, fontSize = 13.sp, - fontWeight = FontWeight.Medium, + fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, @@ -358,53 +577,109 @@ private fun ChannelTile( @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun EpgBar(epg: IptvEpg?) { - Row( + Column( Modifier .fillMaxWidth() - .height(72.dp) - .clip(RoundedCornerShape(10.dp)) - .background(Color(0xFF161616)) - .padding(horizontal = 20.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(24.dp), + .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), ) { if (epg == null) { Text("Selecteer een zender voor programma-info", color = McColors.TextDim, fontSize = 14.sp) return } - if (!epg.logoUrl.isNullOrBlank()) { - AsyncImage( - model = epg.logoUrl, - contentDescription = null, - modifier = Modifier.size(40.dp), - contentScale = ContentScale.Fit, - ) - } - Column(Modifier.weight(1f)) { - Text(epg.channelName, color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1) - Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) { + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (!epg.logoUrl.isNullOrBlank()) { + AsyncImage( + model = epg.logoUrl, + contentDescription = null, + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF0A0A0A)), + contentScale = ContentScale.Fit, + ) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + epg.channelName, + color = Color.White, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) epg.now?.let { now -> - Text( - "Nu: ${now.title}", - color = McColors.Brand, - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + 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}", + "Straks · ${next.title}", color = McColors.TextDim, - fontSize = 14.sp, + fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } if (epg.now == null && epg.next == null) { - Text("Geen programma-info", color = McColors.TextDim, fontSize = 14.sp) + Text("Geen programma-info", color = McColors.TextDim, fontSize = 13.sp) } } } } } + +private fun epgProgress(program: EpgProgram): Float { + val start = program.start?.let { runCatching { Instant.parse(it).toEpochMilli() }.getOrNull() } ?: return 0f + val end = program.end?.let { runCatching { Instant.parse(it).toEpochMilli() }.getOrNull() } ?: return 0f + if (end <= start) return 0f + val now = System.currentTimeMillis() + return ((now - start).toFloat() / (end - start).toFloat()).coerceIn(0f, 1f) +}