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) +}