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:
parent
4ba32a2327
commit
e061d23569
2 changed files with 305 additions and 394 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue