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 <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-02 00:49:44 +02:00
parent fe4b1210ac
commit 4ba32a2327
3 changed files with 410 additions and 113 deletions

View file

@ -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")

View file

@ -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<String?>(null) }
var tvLastStreamId by rememberSaveable { mutableStateOf<Int?>(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
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 },

View file

@ -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<String?>(null) }
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) }
var epgJob by remember { mutableStateOf<Job?>(null) }
var focusInGrid by remember { mutableStateOf(false) }
val gridState = rememberSaveable(saver = LazyGridState.Saver) { LazyGridState() }
val categoryListState = rememberLazyListState()
val scope = rememberCoroutineScope()
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
val channelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
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,
TvHeader(
categoryName = selectedCategory?.name ?: "Live TV",
channelCount = channels.size,
loading = loadingChannels,
)
Text(
when {
loadingChannels -> "Zenders laden…"
else -> "${channels.size} zenders"
},
color = McColors.TextDim,
fontSize = 14.sp,
)
}
}
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,53 +268,164 @@ 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<IptvCategory>,
selectedId: String?,
listState: androidx.compose.foundation.lazy.LazyListState,
categoryFocus: MutableMap<String, FocusRequester>,
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),
) {
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,
@ -272,34 +433,54 @@ private fun CategorySidebar(
fontWeight = if (active) FontWeight.Bold else FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
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 = 12.sp,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@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,
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,
)
}
}
channel.number?.let { num ->
Text(
"$num",
color = McColors.TextDim,
fontSize = 11.sp,
if (isLastWatched) {
Box(
Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
.size(8.dp)
.clip(CircleShape)
.background(McColors.Brand),
)
}
}
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
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
if (!epg.logoUrl.isNullOrBlank()) {
AsyncImage(
model = epg.logoUrl,
contentDescription = null,
modifier = Modifier.size(40.dp),
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF0A0A0A)),
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)) {
epg.now?.let { now ->
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
"Nu: ${now.title}",
color = McColors.Brand,
fontSize = 14.sp,
epg.channelName,
color = Color.White,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
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}",
"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)
}