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 <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-02 00:56:45 +02:00
parent 4ba32a2327
commit e061d23569
2 changed files with 305 additions and 394 deletions

View file

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

View file

@ -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<String?>(null) }
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) }
var focusedStreamId by remember { mutableStateOf<Int?>(null) }
var epgJob by remember { mutableStateOf<Job?>(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<String, FocusRequester>() }
val channelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
val epgNowTitles = remember { mutableStateMapOf<Int, String>() }
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<IptvCategory>,
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
}
}