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:
parent
fe4b1210ac
commit
4ba32a2327
3 changed files with 410 additions and 113 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 = 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")
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue