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" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 34 versionCode = 35
versionName = "0.11.2" versionName = "0.11.3"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") 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.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.LazyRow
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.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -34,7 +28,6 @@ import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Glow
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage 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.data.IptvEpg
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
import java.time.Instant 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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -87,13 +84,15 @@ fun TvScreen(
var loadingChannels by remember { mutableStateOf(false) } var loadingChannels by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) } var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) }
var focusedStreamId by remember { mutableStateOf<Int?>(null) }
var epgJob by remember { mutableStateOf<Job?>(null) } var epgJob by remember { mutableStateOf<Job?>(null) }
var focusInGrid by remember { mutableStateOf(false) } var focusInList by remember { mutableStateOf(false) }
val gridState = rememberSaveable(saver = LazyGridState.Saver) { LazyGridState() } val channelListState = rememberLazyListState()
val categoryListState = rememberLazyListState() val categoryRowState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() } val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
val channelFocus = remember { mutableStateMapOf<Int, FocusRequester>() } val channelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
val epgNowTitles = remember { mutableStateMapOf<Int, String>() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
loading = true loading = true
@ -117,6 +116,8 @@ fun TvScreen(
LaunchedEffect(selectedCategoryId) { LaunchedEffect(selectedCategoryId) {
val catId = selectedCategoryId ?: return@LaunchedEffect val catId = selectedCategoryId ?: return@LaunchedEffect
loadingChannels = true loadingChannels = true
focusedEpg = null
focusedStreamId = null
try { try {
channels = api.iptvChannels(catId) channels = api.iptvChannels(catId)
} catch (e: Exception) { } catch (e: Exception) {
@ -127,122 +128,127 @@ 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) { LaunchedEffect(focusContentNonce, loading, categories, channels, selectedCategoryId, lastStreamId) {
if (loading || categories.isEmpty()) return@LaunchedEffect if (loading || categories.isEmpty()) return@LaunchedEffect
delay(140) delay(120)
val channelTarget = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } } val channelTarget = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } }
if (channelTarget != null) { if (channelTarget != null) {
val idx = channels.indexOfFirst { it.streamId == channelTarget } 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() } } 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 { } else {
val catId = selectedCategoryId ?: categories.firstOrNull()?.id ?: return@LaunchedEffect val catId = selectedCategoryId ?: categories.firstOrNull()?.id ?: return@LaunchedEffect
val catIdx = categories.indexOfFirst { it.id == catId }.coerceAtLeast(0) val catIdx = categories.indexOfFirst { it.id == catId }.coerceAtLeast(0)
categoryListState.scrollToItem(catIdx) categoryRowState.scrollToItem(catIdx)
categoryFocus[catId]?.let { runCatching { it.requestFocus() } } categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
focusInGrid = false focusInList = false
} }
} }
BackHandler(enabled = focusInGrid) { BackHandler(enabled = focusInList) {
val catId = selectedCategoryId ?: categories.firstOrNull()?.id val catId = selectedCategoryId ?: categories.firstOrNull()?.id
if (catId != null) { if (catId != null) {
categoryFocus[catId]?.let { runCatching { it.requestFocus() } } categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
focusInGrid = false focusInList = false
} }
} }
BackHandler(enabled = !focusInGrid) { BackHandler(enabled = !focusInList) {
onAtTopBack() onAtTopBack()
} }
fun loadEpg(streamId: Int) { fun loadEpg(streamId: Int) {
focusedStreamId = streamId
epgJob?.cancel() epgJob?.cancel()
epgJob = scope.launch { epgJob = scope.launch {
delay(280) delay(200)
runCatching { focusedEpg = api.iptvEpg(streamId) } 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(McColors.Bg)) {
Box(
Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
listOf(Color(0xFF08080A), McColors.Bg, McColors.Bg),
),
),
) {
when { when {
loading -> { loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 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() -> { error != null && categories.isEmpty() -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column( Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
horizontalAlignment = Alignment.CenterHorizontally, Text("TV niet beschikbaar", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
verticalArrangement = Arrangement.spacedBy(8.dp), Text(error!!, color = McColors.Danger, fontSize = 14.sp)
) {
Text("TV niet beschikbaar", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
Text(error!!, color = McColors.Danger, fontSize = 15.sp)
} }
} }
} }
else -> { else -> {
Row(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 8.dp)) {
CategorySidebar( CategoryTabRow(
categories = categories, categories = categories,
selectedId = selectedCategoryId, selectedId = selectedCategoryId,
listState = categoryListState, listState = categoryRowState,
categoryFocus = categoryFocus, categoryFocus = categoryFocus,
onSelect = { onCategoryChange(it) }, onSelect = { onCategoryChange(it) },
onFocused = { onFocused = {
focusInGrid = false focusInList = false
onContentFocused() 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) { Spacer(Modifier.height(8.dp))
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Box(
Text("Geen zenders in deze categorie", color = McColors.TextDim, fontSize = 16.sp) 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,
) {
Text("Zender", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium)
Text("Nu op TV", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium)
} }
} else { when {
LazyVerticalGrid( loadingChannels -> {
columns = GridCells.Fixed(4), Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
state = gridState, 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), modifier = Modifier.weight(1f),
contentPadding = PaddingValues(vertical = 12.dp), contentPadding = PaddingValues(bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(1.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
items(channels, key = { it.streamId }) { ch -> items(channels, key = { it.streamId }) { ch ->
val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() } val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
ChannelTile( ChannelRow(
channel = ch, channel = ch,
nowTitle = epgNowTitles[ch.streamId],
isFocused = focusedStreamId == ch.streamId,
isLastWatched = ch.streamId == lastStreamId, isLastWatched = ch.streamId == lastStreamId,
modifier = Modifier.focusRequester(fr), modifier = Modifier.focusRequester(fr),
onClick = { onClick = {
@ -250,7 +256,7 @@ fun TvScreen(
onPlayChannel(ch.streamId) onPlayChannel(ch.streamId)
}, },
onFocused = { onFocused = {
focusInGrid = true focusInList = true
onContentFocused() onContentFocused()
onLastStreamChange(ch.streamId) onLastStreamChange(ch.streamId)
loadEpg(ch.streamId) loadEpg(ch.streamId)
@ -259,80 +265,34 @@ fun TvScreen(
} }
} }
} }
}
}
EpgBar(epg = focusedEpg) Box(
} Modifier
} .width(1.dp)
} .fillMaxHeight()
} .padding(vertical = 4.dp)
} .background(TvDivider),
} )
@Composable ChannelDetailPanel(
private fun TvHeader( epg = focusedEpg,
categoryName: String, modifier = Modifier
channelCount: Int, .weight(0.42f)
loading: Boolean, .fillMaxHeight()
) { .padding(start = 16.dp, end = 8.dp, top = 4.dp),
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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun CategorySidebar( private fun CategoryTabRow(
categories: List<IptvCategory>, categories: List<IptvCategory>,
selectedId: String?, selectedId: String?,
listState: androidx.compose.foundation.lazy.LazyListState, listState: androidx.compose.foundation.lazy.LazyListState,
@ -340,179 +300,117 @@ private fun CategorySidebar(
onSelect: (String) -> Unit, onSelect: (String) -> Unit,
onFocused: () -> Unit, onFocused: () -> Unit,
) { ) {
LazyColumn( LazyRow(
state = listState, state = listState,
modifier = Modifier horizontalArrangement = Arrangement.spacedBy(4.dp),
.width(280.dp) contentPadding = PaddingValues(vertical = 4.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),
) { ) {
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 -> items(categories, key = { it.id }) { cat ->
val active = cat.id == selectedId val active = cat.id == selectedId
val fr = categoryFocus.getOrPut(cat.id) { FocusRequester() } val fr = categoryFocus.getOrPut(cat.id) { FocusRequester() }
val bgColor by animateColorAsState( val textColor by animateColorAsState(
when { if (active) Color.White else Color(0xFF888888),
active -> Color(0x33E50914) label = "tabText",
else -> Color.Transparent
},
label = "catBg",
) )
Surface( Surface(
onClick = { onSelect(cat.id) }, onClick = { onSelect(cat.id) },
modifier = Modifier modifier = Modifier
.focusRequester(fr) .focusRequester(fr)
.onFocusChanged { if (it.isFocused) onFocused() }, .onFocusChanged { if (it.isFocused) onFocused() },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.03f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
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( colors = ClickableSurfaceDefaults.colors(
containerColor = bgColor, containerColor = Color.Transparent,
focusedContainerColor = Color(0x28FFFFFF), focusedContainerColor = Color(0x18FFFFFF),
contentColor = if (active) Color.White else Color(0xFFB8B8B8), contentColor = textColor,
focusedContentColor = Color.White, focusedContentColor = Color.White,
), ),
) { ) {
Row( Column(
Modifier Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
.fillMaxWidth() horizontalAlignment = Alignment.CenterHorizontally,
.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( Text(
cat.name, cat.name,
color = Color.Unspecified, color = Color.Unspecified,
fontSize = 15.sp, fontSize = 14.sp,
fontWeight = if (active) FontWeight.Bold else FontWeight.Medium, fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 2, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
lineHeight = 19.sp,
) )
} Spacer(Modifier.height(5.dp))
Box( Box(
Modifier Modifier
.clip(RoundedCornerShape(10.dp)) .height(2.dp)
.background(Color(0x22FFFFFF)) .width(if (active) 24.dp else 0.dp)
.padding(horizontal = 8.dp, vertical = 4.dp), .clip(RoundedCornerShape(1.dp))
) { .background(if (active) McColors.Brand else Color.Transparent),
Text(
"${cat.channelCount}",
color = McColors.TextDim,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
) )
} }
} }
} }
} }
}
} }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun ChannelTile( private fun ChannelRow(
channel: IptvChannel, channel: IptvChannel,
nowTitle: String?,
isFocused: Boolean,
isLastWatched: Boolean, isLastWatched: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
onFocused: () -> Unit, onFocused: () -> Unit,
) { ) {
val bg by animateColorAsState(
when {
isFocused -> TvRowFocus
isLastWatched -> TvRowActive
else -> TvRowBg
},
label = "rowBg",
)
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = modifier.onFocusChanged { if (it.isFocused) onFocused() }, modifier = modifier.onFocusChanged { state ->
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)), if (state.isFocused) onFocused()
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), },
border = ClickableSurfaceDefaults.border( shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
focusedBorder = Border( scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
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( colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF16161C), containerColor = bg,
focusedContainerColor = Color(0xFF222228), focusedContainerColor = TvRowFocus,
contentColor = Color.White, contentColor = Color.White,
focusedContentColor = Color.White, focusedContentColor = Color.White,
), ),
) { ) {
Column( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background( .height(52.dp)
Brush.verticalGradient( .padding(horizontal = 8.dp),
listOf(Color(0xFF1C1C24), Color(0xFF121218)), verticalAlignment = Alignment.CenterVertically,
),
)
.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Box( Box(
Modifier Modifier
.size(88.dp) .width(3.dp)
.clip(RoundedCornerShape(12.dp)) .height(28.dp)
.clip(RoundedCornerShape(2.dp))
.background( .background(
Brush.radialGradient( when {
listOf(Color(0xFF252530), Color(0xFF0E0E12)), isFocused -> McColors.Brand
), isLastWatched -> McColors.Brand.copy(alpha = 0.45f)
) else -> Color.Transparent
.then(
if (isLastWatched) {
Modifier.border(2.dp, McColors.Brand.copy(alpha = 0.7f), RoundedCornerShape(12.dp))
} else {
Modifier
}, },
), ),
)
Spacer(Modifier.width(6.dp))
Box(
Modifier
.size(36.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFF0A0A0A)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (!channel.logoUrl.isNullOrBlank()) { if (!channel.logoUrl.isNullOrBlank()) {
@ -521,158 +419,171 @@ private fun ChannelTile(
contentDescription = channel.name, contentDescription = channel.name,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(10.dp), .padding(4.dp),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
) )
} else { } else {
Text( Text(
channel.name.take(2).uppercase(), channel.name.take(2).uppercase(),
color = McColors.Brand, color = McColors.TextDim,
fontSize = 26.sp, fontSize = 11.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, fontWeight = FontWeight.Bold,
) )
} }
} }
if (isLastWatched) { Spacer(Modifier.width(10.dp))
Box( channel.number?.let { num ->
Modifier Text(
.align(Alignment.TopEnd) "$num",
.padding(6.dp) color = McColors.TextDim,
.size(8.dp) fontSize = 13.sp,
.clip(CircleShape) fontWeight = FontWeight.Medium,
.background(McColors.Brand), modifier = Modifier.width(36.dp),
) )
} }
}
Text( Text(
channel.name, channel.name,
color = Color.White, color = Color.White,
fontSize = 13.sp, fontSize = 14.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Medium,
maxLines = 2, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center, modifier = Modifier.width(140.dp),
lineHeight = 16.sp, )
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 @Composable
private fun EpgBar(epg: IptvEpg?) { private fun ChannelDetailPanel(
epg: IptvEpg?,
modifier: Modifier = Modifier,
) {
Column( Column(
Modifier modifier
.fillMaxWidth() .clip(RoundedCornerShape(6.dp))
.clip(RoundedCornerShape(14.dp)) .background(TvPanelBg)
.background( .padding(16.dp),
Brush.horizontalGradient( verticalArrangement = Arrangement.spacedBy(12.dp),
listOf(Color(0xFF16161C), Color(0xFF101014)),
),
)
.border(1.dp, Color(0x18FFFFFF), RoundedCornerShape(14.dp))
.padding(horizontal = 20.dp, vertical = 14.dp),
) { ) {
if (epg == null) { 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 return
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
if (!epg.logoUrl.isNullOrBlank()) { if (!epg.logoUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = epg.logoUrl, model = epg.logoUrl,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.size(44.dp) .size(48.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(4.dp))
.background(Color(0xFF0A0A0A)), .background(Color(0xFF0A0A0A)),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
) )
} }
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column {
Text( Text(
epg.channelName, epg.channelName,
color = Color.White, color = Color.White,
fontSize = 15.sp, fontSize = 16.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider))
epg.now?.let { now -> epg.now?.let { now ->
Row( EpgBlock(label = "Nu", program = now, highlight = true)
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) val progress = epgProgress(now)
if (progress > 0f) { if (progress > 0f) {
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(3.dp) .height(2.dp)
.clip(RoundedCornerShape(2.dp)) .clip(RoundedCornerShape(1.dp))
.background(Color(0x33FFFFFF)), .background(Color(0xFF333333)),
) { ) {
Box( Box(
Modifier Modifier
.fillMaxWidth(progress) .fillMaxWidth(progress)
.height(3.dp) .height(2.dp)
.clip(RoundedCornerShape(2.dp))
.background(McColors.Brand), .background(McColors.Brand),
) )
} }
} }
} }
epg.next?.let { next -> 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( Text(
"Straks · ${next.title}", label.uppercase(),
color = McColors.TextDim, color = if (highlight) McColors.Brand else McColors.TextDim,
fontSize = 13.sp, fontSize = 10.sp,
maxLines = 1, 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, overflow = TextOverflow.Ellipsis,
lineHeight = 20.sp,
) )
} }
if (epg.now == null && epg.next == null) { }
Text("Geen programma-info", color = McColors.TextDim, fontSize = 13.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
} }
} }