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"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 33
|
||||
versionName = "0.11.1"
|
||||
versionCode = 34
|
||||
versionName = "0.11.2"
|
||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
|
|
|
|||
|
|
@ -26,10 +26,12 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
|
@ -82,6 +84,7 @@ fun McNav() {
|
|||
var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
|
||||
var tvCategoryId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var tvLastStreamId by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
var tvFocusNonce by remember { mutableIntStateOf(0) }
|
||||
val hubTab = remember(hubTabName) {
|
||||
MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home
|
||||
}
|
||||
|
|
@ -115,6 +118,8 @@ fun McNav() {
|
|||
onTvCategoryChange = { tvCategoryId = it },
|
||||
tvLastStreamId = tvLastStreamId,
|
||||
onTvLastStreamChange = { tvLastStreamId = it },
|
||||
tvFocusNonce = tvFocusNonce,
|
||||
onTvFocusContent = { tvFocusNonce++ },
|
||||
onOpenMovie = { nav.navigate("movie/$it") },
|
||||
onOpenSeries = { nav.navigate("series/$it") },
|
||||
onBrowse = { catalogId, _title ->
|
||||
|
|
@ -214,6 +219,9 @@ fun McNav() {
|
|||
arguments = listOf(navArgument("streamId") { type = NavType.IntType }),
|
||||
) { entry ->
|
||||
val streamId = entry.arguments?.getInt("streamId") ?: return@composable
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { tvFocusNonce++ }
|
||||
}
|
||||
LiveTvPlayerScreen(
|
||||
api = api,
|
||||
streamId = streamId,
|
||||
|
|
@ -232,6 +240,8 @@ private fun MainHub(
|
|||
onTvCategoryChange: (String?) -> Unit,
|
||||
tvLastStreamId: Int?,
|
||||
onTvLastStreamChange: (Int?) -> Unit,
|
||||
tvFocusNonce: Int,
|
||||
onTvFocusContent: () -> Unit,
|
||||
onOpenMovie: (String) -> Unit,
|
||||
onOpenSeries: (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)) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.focusRequester(navFocus)) {
|
||||
|
|
@ -357,7 +373,12 @@ private fun MainHub(
|
|||
if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar
|
||||
onTabChange(it)
|
||||
exitConfirm = false
|
||||
if (it == MainTab.Tv) {
|
||||
navFocused = false
|
||||
onTvFocusContent()
|
||||
} else {
|
||||
focusTopNav()
|
||||
}
|
||||
when (it) {
|
||||
MainTab.Movies -> {
|
||||
if (movieSubId == "__search__") {
|
||||
|
|
@ -428,6 +449,7 @@ private fun MainHub(
|
|||
onCategoryChange = onTvCategoryChange,
|
||||
lastStreamId = tvLastStreamId,
|
||||
onLastStreamChange = onTvLastStreamChange,
|
||||
focusContentNonce = tvFocusNonce,
|
||||
onAtTopBack = { focusTopNav() },
|
||||
onPlayChannel = onPlayLive,
|
||||
onContentFocused = { navFocused = false },
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
package nl.vonas.mediacluster.tv.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
|
@ -39,20 +50,23 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Glow
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.IptvChannel
|
||||
import nl.vonas.mediacluster.tv.data.IptvEpg
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
import java.time.Instant
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -62,6 +76,7 @@ fun TvScreen(
|
|||
onCategoryChange: (String?) -> Unit,
|
||||
lastStreamId: Int? = null,
|
||||
onLastStreamChange: (Int?) -> Unit = {},
|
||||
focusContentNonce: Int = 0,
|
||||
onAtTopBack: () -> Unit,
|
||||
onPlayChannel: (streamId: Int) -> Unit,
|
||||
onContentFocused: () -> Unit = {},
|
||||
|
|
@ -73,8 +88,12 @@ fun TvScreen(
|
|||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) }
|
||||
var epgJob by remember { mutableStateOf<Job?>(null) }
|
||||
var focusInGrid by remember { mutableStateOf(false) }
|
||||
val gridState = rememberSaveable(saver = LazyGridState.Saver) { LazyGridState() }
|
||||
val categoryListState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
|
||||
val channelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
loading = true
|
||||
|
|
@ -98,7 +117,6 @@ fun TvScreen(
|
|||
LaunchedEffect(selectedCategoryId) {
|
||||
val catId = selectedCategoryId ?: return@LaunchedEffect
|
||||
loadingChannels = true
|
||||
focusedEpg = null
|
||||
try {
|
||||
channels = api.iptvChannels(catId)
|
||||
} 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) {
|
||||
epgJob?.cancel()
|
||||
epgJob = scope.launch {
|
||||
|
|
@ -128,7 +175,15 @@ fun TvScreen(
|
|||
|
||||
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 {
|
||||
loading -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
|
|
@ -137,7 +192,10 @@ fun TvScreen(
|
|||
}
|
||||
error != null && categories.isEmpty() -> {
|
||||
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(error!!, color = McColors.Danger, fontSize = 15.sp)
|
||||
}
|
||||
|
|
@ -148,37 +206,25 @@ fun TvScreen(
|
|||
CategorySidebar(
|
||||
categories = categories,
|
||||
selectedId = selectedCategoryId,
|
||||
listState = categoryListState,
|
||||
categoryFocus = categoryFocus,
|
||||
onSelect = { onCategoryChange(it) },
|
||||
onFocused = onContentFocused,
|
||||
onFocused = {
|
||||
focusInGrid = false
|
||||
onContentFocused()
|
||||
},
|
||||
)
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.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(
|
||||
Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
selectedCategory?.name ?: "Live TV",
|
||||
color = Color.White,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
TvHeader(
|
||||
categoryName = selectedCategory?.name ?: "Live TV",
|
||||
channelCount = channels.size,
|
||||
loading = loadingChannels,
|
||||
)
|
||||
Text(
|
||||
when {
|
||||
loadingChannels -> "Zenders laden…"
|
||||
else -> "${channels.size} zenders"
|
||||
},
|
||||
color = McColors.TextDim,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (channels.isEmpty() && !loadingChannels) {
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||
|
|
@ -186,21 +232,25 @@ fun TvScreen(
|
|||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(5),
|
||||
columns = GridCells.Fixed(4),
|
||||
state = gridState,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
items(channels, key = { it.streamId }) { ch ->
|
||||
val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
|
||||
ChannelTile(
|
||||
channel = ch,
|
||||
isLastWatched = ch.streamId == lastStreamId,
|
||||
modifier = Modifier.focusRequester(fr),
|
||||
onClick = {
|
||||
onLastStreamChange(ch.streamId)
|
||||
onPlayChannel(ch.streamId)
|
||||
},
|
||||
onFocused = {
|
||||
focusInGrid = true
|
||||
onContentFocused()
|
||||
onLastStreamChange(ch.streamId)
|
||||
loadEpg(ch.streamId)
|
||||
|
|
@ -218,53 +268,164 @@ fun TvScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvHeader(
|
||||
categoryName: String,
|
||||
channelCount: Int,
|
||||
loading: Boolean,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp, top = 8.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(4.dp)
|
||||
.height(28.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(McColors.Brand),
|
||||
)
|
||||
Text(
|
||||
categoryName,
|
||||
color = Color.White,
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color(0x22FFFFFF))
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
loading -> "Laden…"
|
||||
else -> "$channelCount zenders"
|
||||
},
|
||||
color = McColors.TextDim,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(McColors.Brand.copy(alpha = 0.5f), Color(0x22FFFFFF), Color.Transparent),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CategorySidebar(
|
||||
categories: List<IptvCategory>,
|
||||
selectedId: String?,
|
||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||
categoryFocus: MutableMap<String, FocusRequester>,
|
||||
onSelect: (String) -> Unit,
|
||||
onFocused: () -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.width(260.dp)
|
||||
.width(280.dp)
|
||||
.fillMaxHeight()
|
||||
.background(Color(0xFF121212))
|
||||
.padding(vertical = 12.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
.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 = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 8.dp, bottom = 8.dp),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 10.dp, bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
items(categories, key = { it.id }) { cat ->
|
||||
val active = cat.id == selectedId
|
||||
val fr = categoryFocus.getOrPut(cat.id) { FocusRequester() }
|
||||
val bgColor by animateColorAsState(
|
||||
when {
|
||||
active -> Color(0x33E50914)
|
||||
else -> Color.Transparent
|
||||
},
|
||||
label = "catBg",
|
||||
)
|
||||
Surface(
|
||||
onClick = { onSelect(cat.id) },
|
||||
modifier = Modifier.onFocusChanged { if (it.isFocused) onFocused() },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
|
||||
modifier = Modifier
|
||||
.focusRequester(fr)
|
||||
.onFocusChanged { if (it.isFocused) onFocused() },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.03f),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = androidx.compose.foundation.BorderStroke(2.dp, Color.White.copy(alpha = 0.85f)),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
),
|
||||
),
|
||||
glow = ClickableSurfaceDefaults.glow(
|
||||
focusedGlow = Glow(elevationColor = McColors.Brand.copy(alpha = 0.35f), elevation = 10.dp),
|
||||
),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (active) Color(0x33FFFFFF) else Color.Transparent,
|
||||
focusedContainerColor = Color(0x44FFFFFF),
|
||||
contentColor = if (active) Color.White else Color(0xFFB0B0B0),
|
||||
containerColor = bgColor,
|
||||
focusedContainerColor = Color(0x28FFFFFF),
|
||||
contentColor = if (active) Color.White else Color(0xFFB8B8B8),
|
||||
focusedContentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
.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(
|
||||
cat.name,
|
||||
color = Color.Unspecified,
|
||||
|
|
@ -272,34 +433,54 @@ private fun CategorySidebar(
|
|||
fontWeight = if (active) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
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 = 12.sp,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ChannelTile(
|
||||
channel: IptvChannel,
|
||||
isLastWatched: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
onFocused: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.onFocusChanged { if (it.isFocused) onFocused() },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
|
||||
modifier = modifier.onFocusChanged { if (it.isFocused) onFocused() },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = androidx.compose.foundation.BorderStroke(2.5.dp, Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
),
|
||||
),
|
||||
glow = ClickableSurfaceDefaults.glow(
|
||||
focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.2f), elevation = 8.dp),
|
||||
),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color(0xFF1A1A1A),
|
||||
focusedContainerColor = Color(0xFF2A2A2A),
|
||||
containerColor = Color(0xFF16161C),
|
||||
focusedContainerColor = Color(0xFF222228),
|
||||
contentColor = Color.White,
|
||||
focusedContentColor = Color.White,
|
||||
),
|
||||
|
|
@ -307,45 +488,83 @@ private fun ChannelTile(
|
|||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFF1C1C24), Color(0xFF121218)),
|
||||
),
|
||||
)
|
||||
.padding(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(72.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFF0D0D0D)),
|
||||
.size(88.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
listOf(Color(0xFF252530), Color(0xFF0E0E12)),
|
||||
),
|
||||
)
|
||||
.then(
|
||||
if (isLastWatched) {
|
||||
Modifier.border(2.dp, McColors.Brand.copy(alpha = 0.7f), RoundedCornerShape(12.dp))
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (!channel.logoUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = channel.logoUrl,
|
||||
contentDescription = channel.name,
|
||||
modifier = Modifier.fillMaxSize().padding(6.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(10.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
channel.name.take(2).uppercase(),
|
||||
color = McColors.Brand,
|
||||
fontSize = 22.sp,
|
||||
fontSize = 26.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
)
|
||||
}
|
||||
channel.number?.let { num ->
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(6.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color(0xCC000000))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
"$num",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
channel.number?.let { num ->
|
||||
Text(
|
||||
"$num",
|
||||
color = McColors.TextDim,
|
||||
fontSize = 11.sp,
|
||||
if (isLastWatched) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(6.dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(McColors.Brand),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
channel.name,
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
|
|
@ -358,53 +577,109 @@ private fun ChannelTile(
|
|||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EpgBar(epg: IptvEpg?) {
|
||||
Row(
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(72.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color(0xFF161616))
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(Color(0xFF16161C), Color(0xFF101014)),
|
||||
),
|
||||
)
|
||||
.border(1.dp, Color(0x18FFFFFF), RoundedCornerShape(14.dp))
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
) {
|
||||
if (epg == null) {
|
||||
Text("Selecteer een zender voor programma-info", color = McColors.TextDim, fontSize = 14.sp)
|
||||
return
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
if (!epg.logoUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = epg.logoUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFF0A0A0A)),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(epg.channelName, color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
epg.now?.let { now ->
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
"Nu: ${now.title}",
|
||||
color = McColors.Brand,
|
||||
fontSize = 14.sp,
|
||||
epg.channelName,
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
epg.now?.let { now ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(McColors.Brand)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text("NU", color = Color.White, fontSize = 10.sp, fontWeight = FontWeight.Black)
|
||||
}
|
||||
Text(
|
||||
now.title,
|
||||
color = Color(0xFFE8E8E8),
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
}
|
||||
val progress = epgProgress(now)
|
||||
if (progress > 0f) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(3.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(Color(0x33FFFFFF)),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(progress)
|
||||
.height(3.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(McColors.Brand),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
epg.next?.let { next ->
|
||||
Text(
|
||||
"Straks: ${next.title}",
|
||||
"Straks · ${next.title}",
|
||||
color = McColors.TextDim,
|
||||
fontSize = 14.sp,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
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