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" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 33 versionCode = 34
versionName = "0.11.1" versionName = "0.11.2"
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

@ -26,10 +26,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -82,6 +84,7 @@ fun McNav() {
var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) } var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
var tvCategoryId by rememberSaveable { mutableStateOf<String?>(null) } var tvCategoryId by rememberSaveable { mutableStateOf<String?>(null) }
var tvLastStreamId by rememberSaveable { mutableStateOf<Int?>(null) } var tvLastStreamId by rememberSaveable { mutableStateOf<Int?>(null) }
var tvFocusNonce by remember { mutableIntStateOf(0) }
val hubTab = remember(hubTabName) { val hubTab = remember(hubTabName) {
MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home
} }
@ -115,6 +118,8 @@ fun McNav() {
onTvCategoryChange = { tvCategoryId = it }, onTvCategoryChange = { tvCategoryId = it },
tvLastStreamId = tvLastStreamId, tvLastStreamId = tvLastStreamId,
onTvLastStreamChange = { tvLastStreamId = it }, onTvLastStreamChange = { tvLastStreamId = it },
tvFocusNonce = tvFocusNonce,
onTvFocusContent = { tvFocusNonce++ },
onOpenMovie = { nav.navigate("movie/$it") }, onOpenMovie = { nav.navigate("movie/$it") },
onOpenSeries = { nav.navigate("series/$it") }, onOpenSeries = { nav.navigate("series/$it") },
onBrowse = { catalogId, _title -> onBrowse = { catalogId, _title ->
@ -214,6 +219,9 @@ fun McNav() {
arguments = listOf(navArgument("streamId") { type = NavType.IntType }), arguments = listOf(navArgument("streamId") { type = NavType.IntType }),
) { entry -> ) { entry ->
val streamId = entry.arguments?.getInt("streamId") ?: return@composable val streamId = entry.arguments?.getInt("streamId") ?: return@composable
DisposableEffect(Unit) {
onDispose { tvFocusNonce++ }
}
LiveTvPlayerScreen( LiveTvPlayerScreen(
api = api, api = api,
streamId = streamId, streamId = streamId,
@ -232,6 +240,8 @@ private fun MainHub(
onTvCategoryChange: (String?) -> Unit, onTvCategoryChange: (String?) -> Unit,
tvLastStreamId: Int?, tvLastStreamId: Int?,
onTvLastStreamChange: (Int?) -> Unit, onTvLastStreamChange: (Int?) -> Unit,
tvFocusNonce: Int,
onTvFocusContent: () -> Unit,
onOpenMovie: (String) -> Unit, onOpenMovie: (String) -> Unit,
onOpenSeries: (String) -> Unit, onOpenSeries: (String) -> Unit,
onBrowse: (catalogId: String, title: 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)) { Box(Modifier.fillMaxSize().background(McColors.Bg)) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
Box(Modifier.focusRequester(navFocus)) { Box(Modifier.focusRequester(navFocus)) {
@ -357,7 +373,12 @@ private fun MainHub(
if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar
onTabChange(it) onTabChange(it)
exitConfirm = false exitConfirm = false
focusTopNav() if (it == MainTab.Tv) {
navFocused = false
onTvFocusContent()
} else {
focusTopNav()
}
when (it) { when (it) {
MainTab.Movies -> { MainTab.Movies -> {
if (movieSubId == "__search__") { if (movieSubId == "__search__") {
@ -428,6 +449,7 @@ private fun MainHub(
onCategoryChange = onTvCategoryChange, onCategoryChange = onTvCategoryChange,
lastStreamId = tvLastStreamId, lastStreamId = tvLastStreamId,
onLastStreamChange = onTvLastStreamChange, onLastStreamChange = onTvLastStreamChange,
focusContentNonce = tvFocusNonce,
onAtTopBack = { focusTopNav() }, onAtTopBack = { focusTopNav() },
onPlayChannel = onPlayLive, onPlayChannel = onPlayLive,
onContentFocused = { navFocused = false }, onContentFocused = { navFocused = false },

View file

@ -1,11 +1,15 @@
package nl.vonas.mediacluster.tv.ui 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.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
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState 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.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
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
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.saveable.rememberSaveable 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
import androidx.compose.ui.draw.clip 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.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
@ -39,20 +50,23 @@ 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
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
import nl.vonas.mediacluster.tv.data.ApiClient 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.IptvCategory
import nl.vonas.mediacluster.tv.data.IptvChannel 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
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -62,6 +76,7 @@ fun TvScreen(
onCategoryChange: (String?) -> Unit, onCategoryChange: (String?) -> Unit,
lastStreamId: Int? = null, lastStreamId: Int? = null,
onLastStreamChange: (Int?) -> Unit = {}, onLastStreamChange: (Int?) -> Unit = {},
focusContentNonce: Int = 0,
onAtTopBack: () -> Unit, onAtTopBack: () -> Unit,
onPlayChannel: (streamId: Int) -> Unit, onPlayChannel: (streamId: Int) -> Unit,
onContentFocused: () -> Unit = {}, onContentFocused: () -> Unit = {},
@ -73,8 +88,12 @@ fun TvScreen(
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 epgJob by remember { mutableStateOf<Job?>(null) } var epgJob by remember { mutableStateOf<Job?>(null) }
var focusInGrid by remember { mutableStateOf(false) }
val gridState = rememberSaveable(saver = LazyGridState.Saver) { LazyGridState() } val gridState = rememberSaveable(saver = LazyGridState.Saver) { LazyGridState() }
val categoryListState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
val channelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
loading = true loading = true
@ -98,7 +117,6 @@ fun TvScreen(
LaunchedEffect(selectedCategoryId) { LaunchedEffect(selectedCategoryId) {
val catId = selectedCategoryId ?: return@LaunchedEffect val catId = selectedCategoryId ?: return@LaunchedEffect
loadingChannels = true loadingChannels = true
focusedEpg = null
try { try {
channels = api.iptvChannels(catId) channels = api.iptvChannels(catId)
} catch (e: Exception) { } 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) { fun loadEpg(streamId: Int) {
epgJob?.cancel() epgJob?.cancel()
epgJob = scope.launch { epgJob = scope.launch {
@ -128,7 +175,15 @@ fun TvScreen(
val selectedCategory = categories.firstOrNull { it.id == selectedCategoryId } 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) {
@ -137,7 +192,10 @@ fun TvScreen(
} }
error != null && categories.isEmpty() -> { error != null && categories.isEmpty() -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 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("TV niet beschikbaar", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
Text(error!!, color = McColors.Danger, fontSize = 15.sp) Text(error!!, color = McColors.Danger, fontSize = 15.sp)
} }
@ -148,37 +206,25 @@ fun TvScreen(
CategorySidebar( CategorySidebar(
categories = categories, categories = categories,
selectedId = selectedCategoryId, selectedId = selectedCategoryId,
listState = categoryListState,
categoryFocus = categoryFocus,
onSelect = { onCategoryChange(it) }, onSelect = { onCategoryChange(it) },
onFocused = onContentFocused, onFocused = {
focusInGrid = false
onContentFocused()
},
) )
Column( Column(
Modifier Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .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( TvHeader(
Modifier.fillMaxWidth().padding(bottom = 12.dp), categoryName = selectedCategory?.name ?: "Live TV",
horizontalArrangement = Arrangement.SpaceBetween, channelCount = channels.size,
verticalAlignment = Alignment.CenterVertically, loading = loadingChannels,
) { )
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,
)
}
}
if (channels.isEmpty() && !loadingChannels) { if (channels.isEmpty() && !loadingChannels) {
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
@ -186,21 +232,25 @@ fun TvScreen(
} }
} else { } else {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(5), columns = GridCells.Fixed(4),
state = gridState, state = gridState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalArrangement = Arrangement.spacedBy(12.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() }
ChannelTile( ChannelTile(
channel = ch, channel = ch,
isLastWatched = ch.streamId == lastStreamId,
modifier = Modifier.focusRequester(fr),
onClick = { onClick = {
onLastStreamChange(ch.streamId) onLastStreamChange(ch.streamId)
onPlayChannel(ch.streamId) onPlayChannel(ch.streamId)
}, },
onFocused = { onFocused = {
focusInGrid = true
onContentFocused() onContentFocused()
onLastStreamChange(ch.streamId) onLastStreamChange(ch.streamId)
loadEpg(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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun CategorySidebar( private fun CategorySidebar(
categories: List<IptvCategory>, categories: List<IptvCategory>,
selectedId: String?, selectedId: String?,
listState: androidx.compose.foundation.lazy.LazyListState,
categoryFocus: MutableMap<String, FocusRequester>,
onSelect: (String) -> Unit, onSelect: (String) -> Unit,
onFocused: () -> Unit, onFocused: () -> Unit,
) { ) {
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier modifier = Modifier
.width(260.dp) .width(280.dp)
.fillMaxHeight() .fillMaxHeight()
.background(Color(0xFF121212)) .background(
.padding(vertical = 12.dp), Brush.verticalGradient(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), listOf(Color(0xFF0C0C10), Color(0xFF101014)),
verticalArrangement = Arrangement.spacedBy(6.dp), ),
)
.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 { 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( Text(
"Categorieën", "Categorieën",
color = McColors.TextDim, color = McColors.TextDim,
fontSize = 12.sp, fontSize = 13.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 8.dp, bottom = 8.dp), 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 bgColor by animateColorAsState(
when {
active -> Color(0x33E50914)
else -> Color.Transparent
},
label = "catBg",
)
Surface( Surface(
onClick = { onSelect(cat.id) }, onClick = { onSelect(cat.id) },
modifier = Modifier.onFocusChanged { if (it.isFocused) onFocused() }, modifier = Modifier
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), .focusRequester(fr)
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), .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( colors = ClickableSurfaceDefaults.colors(
containerColor = if (active) Color(0x33FFFFFF) else Color.Transparent, containerColor = bgColor,
focusedContainerColor = Color(0x44FFFFFF), focusedContainerColor = Color(0x28FFFFFF),
contentColor = if (active) Color.White else Color(0xFFB0B0B0), contentColor = if (active) Color.White else Color(0xFFB8B8B8),
focusedContentColor = Color.White, focusedContentColor = Color.White,
), ),
) { ) {
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp), .padding(horizontal = 12.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text( Box(
cat.name, Modifier
color = Color.Unspecified, .width(3.dp)
fontSize = 15.sp, .height(32.dp)
fontWeight = if (active) FontWeight.Bold else FontWeight.Medium, .clip(RoundedCornerShape(2.dp))
maxLines = 2, .background(if (active) McColors.Brand else Color(0x33FFFFFF)),
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
"${cat.channelCount}",
color = McColors.TextDim,
fontSize = 12.sp,
) )
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 @Composable
private fun ChannelTile( private fun ChannelTile(
channel: IptvChannel, channel: IptvChannel,
isLastWatched: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
onFocused: () -> Unit, onFocused: () -> Unit,
) { ) {
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = Modifier.onFocusChanged { if (it.isFocused) onFocused() }, modifier = modifier.onFocusChanged { if (it.isFocused) onFocused() },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), 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( colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1A1A1A), containerColor = Color(0xFF16161C),
focusedContainerColor = Color(0xFF2A2A2A), focusedContainerColor = Color(0xFF222228),
contentColor = Color.White, contentColor = Color.White,
focusedContentColor = Color.White, focusedContentColor = Color.White,
), ),
@ -307,45 +488,83 @@ private fun ChannelTile(
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp), .background(
Brush.verticalGradient(
listOf(Color(0xFF1C1C24), Color(0xFF121218)),
),
)
.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Box( Box(
Modifier Modifier
.size(72.dp) .size(88.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(12.dp))
.background(Color(0xFF0D0D0D)), .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, contentAlignment = Alignment.Center,
) { ) {
if (!channel.logoUrl.isNullOrBlank()) { if (!channel.logoUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = channel.logoUrl, model = channel.logoUrl,
contentDescription = channel.name, contentDescription = channel.name,
modifier = Modifier.fillMaxSize().padding(6.dp), modifier = Modifier
.fillMaxSize()
.padding(10.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.Brand,
fontSize = 22.sp, fontSize = 26.sp,
fontWeight = FontWeight.Bold, 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( Text(
channel.name, channel.name,
color = Color.White, color = Color.White,
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@ -358,53 +577,109 @@ private fun ChannelTile(
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun EpgBar(epg: IptvEpg?) { private fun EpgBar(epg: IptvEpg?) {
Row( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(72.dp) .clip(RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(10.dp)) .background(
.background(Color(0xFF161616)) Brush.horizontalGradient(
.padding(horizontal = 20.dp, vertical = 12.dp), listOf(Color(0xFF16161C), Color(0xFF101014)),
verticalAlignment = Alignment.CenterVertically, ),
horizontalArrangement = Arrangement.spacedBy(24.dp), )
.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) Text("Selecteer een zender voor programma-info", color = McColors.TextDim, fontSize = 14.sp)
return return
} }
if (!epg.logoUrl.isNullOrBlank()) {
AsyncImage( Row(
model = epg.logoUrl, verticalAlignment = Alignment.CenterVertically,
contentDescription = null, horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.size(40.dp), ) {
contentScale = ContentScale.Fit, if (!epg.logoUrl.isNullOrBlank()) {
) AsyncImage(
} model = epg.logoUrl,
Column(Modifier.weight(1f)) { contentDescription = null,
Text(epg.channelName, color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1) modifier = Modifier
Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) { .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 -> epg.now?.let { now ->
Text( Row(
"Nu: ${now.title}", horizontalArrangement = Arrangement.spacedBy(8.dp),
color = McColors.Brand, verticalAlignment = Alignment.CenterVertically,
fontSize = 14.sp, ) {
maxLines = 1, Box(
overflow = TextOverflow.Ellipsis, 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 -> epg.next?.let { next ->
Text( Text(
"Straks: ${next.title}", "Straks · ${next.title}",
color = McColors.TextDim, color = McColors.TextDim,
fontSize = 14.sp, fontSize = 13.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
if (epg.now == null && epg.next == null) { 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)
}