Add Android TV guide tab with bulk EPG grid and live play actions.
Expose a cached multi-channel guide API and a TV Gids screen with timeline, programme details, favorites, and watch-when-live.
This commit is contained in:
parent
bbb57baf81
commit
b566454f8a
6 changed files with 1051 additions and 6 deletions
|
|
@ -577,6 +577,57 @@ class ApiClient(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun iptvGuide(
|
||||||
|
categoryId: String? = null,
|
||||||
|
limit: Int = 80,
|
||||||
|
offset: Int = 0,
|
||||||
|
): IptvGuide = withContext(Dispatchers.IO) {
|
||||||
|
val params = buildList {
|
||||||
|
categoryId?.let { add("categoryId=${java.net.URLEncoder.encode(it, "UTF-8")}") }
|
||||||
|
add("limit=$limit")
|
||||||
|
add("offset=$offset")
|
||||||
|
}
|
||||||
|
val q = "?${params.joinToString("&")}"
|
||||||
|
val req = authed("$baseUrl/api/v1/client/iptv/guide$q").get().build()
|
||||||
|
parseObject(execute(req)) { o ->
|
||||||
|
val arr = o.optJSONArray("channels") ?: JSONArray()
|
||||||
|
val channels = buildList {
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val c = arr.getJSONObject(i)
|
||||||
|
val scheduleArr = c.optJSONArray("schedule")
|
||||||
|
val schedule = buildList {
|
||||||
|
if (scheduleArr != null) {
|
||||||
|
for (j in 0 until scheduleArr.length()) {
|
||||||
|
scheduleArr.optJSONObject(j)?.toEpgProgram()?.let { add(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(
|
||||||
|
IptvGuideChannel(
|
||||||
|
streamId = c.getInt("streamId"),
|
||||||
|
name = c.getString("name"),
|
||||||
|
logoUrl = c.optClean("logoUrl"),
|
||||||
|
number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") },
|
||||||
|
categoryId = c.optClean("categoryId"),
|
||||||
|
now = c.optJSONObject("now")?.toEpgProgram(),
|
||||||
|
next = c.optJSONObject("next")?.toEpgProgram(),
|
||||||
|
schedule = schedule,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IptvGuide(
|
||||||
|
generatedAt = o.optClean("generatedAt"),
|
||||||
|
windowStart = o.optClean("windowStart"),
|
||||||
|
windowEnd = o.optClean("windowEnd"),
|
||||||
|
total = o.optInt("total"),
|
||||||
|
offset = o.optInt("offset"),
|
||||||
|
limit = o.optInt("limit"),
|
||||||
|
channels = channels,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Vul ontbrekende now-titels aan (server-batch of parallel per zender). */
|
/** Vul ontbrekende now-titels aan (server-batch of parallel per zender). */
|
||||||
suspend fun fetchMissingNowTitles(channels: List<IptvChannel>): Map<Int, String> {
|
suspend fun fetchMissingNowTitles(channels: List<IptvChannel>): Map<Int, String> {
|
||||||
val result = linkedMapOf<Int, String>()
|
val result = linkedMapOf<Int, String>()
|
||||||
|
|
@ -850,6 +901,27 @@ data class IptvEpg(
|
||||||
val schedule: List<EpgProgram> = emptyList(),
|
val schedule: List<EpgProgram> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class IptvGuide(
|
||||||
|
val generatedAt: String? = null,
|
||||||
|
val windowStart: String? = null,
|
||||||
|
val windowEnd: String? = null,
|
||||||
|
val total: Int = 0,
|
||||||
|
val offset: Int = 0,
|
||||||
|
val limit: Int = 0,
|
||||||
|
val channels: List<IptvGuideChannel> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class IptvGuideChannel(
|
||||||
|
val streamId: Int,
|
||||||
|
val name: String,
|
||||||
|
val logoUrl: String? = null,
|
||||||
|
val number: Int? = null,
|
||||||
|
val categoryId: String? = null,
|
||||||
|
val now: EpgProgram? = null,
|
||||||
|
val next: EpgProgram? = null,
|
||||||
|
val schedule: List<EpgProgram> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
private fun JSONArray?.toItems(): List<MediaItem> {
|
private fun JSONArray?.toItems(): List<MediaItem> {
|
||||||
if (this == null) return emptyList()
|
if (this == null) return emptyList()
|
||||||
return buildList {
|
return buildList {
|
||||||
|
|
|
||||||
|
|
@ -502,7 +502,8 @@ private fun MainHub(
|
||||||
|
|
||||||
val visibleTabs = remember(iptvEnabled) {
|
val visibleTabs = remember(iptvEnabled) {
|
||||||
MainTab.entries.filter {
|
MainTab.entries.filter {
|
||||||
it != MainTab.Settings && it != MainTab.Search && (it != MainTab.Tv || iptvEnabled)
|
it != MainTab.Settings && it != MainTab.Search &&
|
||||||
|
((it != MainTab.Tv && it != MainTab.TvGuide) || iptvEnabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -594,13 +595,13 @@ private fun MainHub(
|
||||||
onTabChange(MainTab.Home)
|
onTabChange(MainTab.Home)
|
||||||
focusTopNav()
|
focusTopNav()
|
||||||
}
|
}
|
||||||
// TV: BrowseScreen-achtig — content scrollt terug, daarna top-nav
|
// TV / TV Gids: content scrollt terug, daarna top-nav
|
||||||
BackHandler(enabled = tab == MainTab.Tv && !navFocused && !exitConfirm) {
|
BackHandler(enabled = (tab == MainTab.Tv || tab == MainTab.TvGuide) && !navFocused && !exitConfirm) {
|
||||||
focusTopNav()
|
focusTopNav()
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(tvFocusNonce) {
|
LaunchedEffect(tvFocusNonce) {
|
||||||
if (tab == MainTab.Tv) {
|
if (tab == MainTab.Tv || tab == MainTab.TvGuide) {
|
||||||
navFocused = false
|
navFocused = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -650,12 +651,12 @@ private fun MainHub(
|
||||||
focusTopNav()
|
focusTopNav()
|
||||||
},
|
},
|
||||||
onSelect = {
|
onSelect = {
|
||||||
if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar
|
if ((it == MainTab.Tv || it == MainTab.TvGuide) && !iptvEnabled) return@TopNavBar
|
||||||
onTabChange(it)
|
onTabChange(it)
|
||||||
exitConfirm = false
|
exitConfirm = false
|
||||||
homeChromeCompact = false
|
homeChromeCompact = false
|
||||||
when (it) {
|
when (it) {
|
||||||
MainTab.Tv -> {
|
MainTab.Tv, MainTab.TvGuide -> {
|
||||||
navFocused = false
|
navFocused = false
|
||||||
onTvFocusContent()
|
onTvFocusContent()
|
||||||
}
|
}
|
||||||
|
|
@ -775,6 +776,21 @@ private fun MainHub(
|
||||||
onPlayChannel = onPlayLive,
|
onPlayChannel = onPlayLive,
|
||||||
onContentFocused = { navFocused = false },
|
onContentFocused = { navFocused = false },
|
||||||
)
|
)
|
||||||
|
MainTab.TvGuide -> TvGuideScreen(
|
||||||
|
api = api,
|
||||||
|
selectedCategoryId = tvCategoryId,
|
||||||
|
onCategoryChange = onTvCategoryChange,
|
||||||
|
favoriteIds = favoriteIds,
|
||||||
|
onToggleFavorite = onToggleFavorite,
|
||||||
|
focusContentNonce = tvFocusNonce,
|
||||||
|
onAtTopBack = { focusTopNav() },
|
||||||
|
onPlayChannel = onPlayLive,
|
||||||
|
onOpenLiveTv = { streamId ->
|
||||||
|
onTvLastStreamChange(streamId)
|
||||||
|
onPlayLive(streamId)
|
||||||
|
},
|
||||||
|
onContentFocused = { navFocused = false },
|
||||||
|
)
|
||||||
MainTab.Search -> SearchScreen(
|
MainTab.Search -> SearchScreen(
|
||||||
api = api,
|
api = api,
|
||||||
onOpenMovie = onOpenMovie,
|
onOpenMovie = onOpenMovie,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,864 @@
|
||||||
|
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
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
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.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.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
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.tv.material3.ClickableSurfaceDefaults
|
||||||
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
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.IptvGuideChannel
|
||||||
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private val GuideBg = Color(0xFF0B0B0B)
|
||||||
|
private val GuidePanel = Color(0xFF121212)
|
||||||
|
private val GuideCell = Color(0xFF1A1A1A)
|
||||||
|
private val GuideCellLive = Color(0xFF2A1515)
|
||||||
|
private val GuideDivider = Color(0xFF2A2A2A)
|
||||||
|
private const val PX_PER_MIN = 4f
|
||||||
|
private const val GUIDE_HOURS = 6
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TvGuideScreen(
|
||||||
|
api: ApiClient,
|
||||||
|
selectedCategoryId: String?,
|
||||||
|
onCategoryChange: (String?) -> Unit,
|
||||||
|
favoriteIds: Set<Int> = emptySet(),
|
||||||
|
onToggleFavorite: (Int) -> Unit = {},
|
||||||
|
focusContentNonce: Int = 0,
|
||||||
|
onAtTopBack: () -> Unit,
|
||||||
|
onPlayChannel: (streamId: Int) -> Unit,
|
||||||
|
onOpenLiveTv: (streamId: Int) -> Unit = {},
|
||||||
|
onContentFocused: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
var categories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) }
|
||||||
|
var channels by remember { mutableStateOf<List<IptvGuideChannel>>(emptyList()) }
|
||||||
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
var loadingGuide by remember { mutableStateOf(false) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var focusInGrid by remember { mutableStateOf(false) }
|
||||||
|
var selected by remember { mutableStateOf<GuideSelection?>(null) }
|
||||||
|
var clock by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||||
|
var jumpToNowToken by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
val categoryRowState = rememberLazyListState()
|
||||||
|
val channelListState = rememberLazyListState()
|
||||||
|
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
|
||||||
|
|
||||||
|
val displayCategories = remember(categories, favoriteIds) {
|
||||||
|
listOf(IptvCategory(TV_FAV_CATEGORY_ID, "Favorieten", favoriteIds.size)) + categories
|
||||||
|
}
|
||||||
|
|
||||||
|
val visibleChannels = remember(channels, favoriteIds, selectedCategoryId) {
|
||||||
|
when (selectedCategoryId) {
|
||||||
|
TV_FAV_CATEGORY_ID -> channels.filter { favoriteIds.contains(it.streamId) }
|
||||||
|
else -> channels
|
||||||
|
}.let { list ->
|
||||||
|
if (selectedCategoryId != TV_FAV_CATEGORY_ID && favoriteIds.isNotEmpty()) {
|
||||||
|
list.sortedByDescending { favoriteIds.contains(it.streamId) }
|
||||||
|
} else list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
delay(30_000)
|
||||||
|
clock = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
try {
|
||||||
|
val cats = api.iptvCategories()
|
||||||
|
categories = cats
|
||||||
|
val restored = selectedCategoryId?.takeIf { id ->
|
||||||
|
id == TV_FAV_CATEGORY_ID || cats.any { it.id == id }
|
||||||
|
}
|
||||||
|
onCategoryChange(restored ?: cats.firstOrNull()?.id)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = e.message ?: "Kon gids niet laden"
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(selectedCategoryId, favoriteIds) {
|
||||||
|
val catId = selectedCategoryId ?: return@LaunchedEffect
|
||||||
|
loadingGuide = true
|
||||||
|
error = null
|
||||||
|
try {
|
||||||
|
val fetchId = if (catId == TV_FAV_CATEGORY_ID) "__all__" else catId
|
||||||
|
val guide = api.iptvGuide(categoryId = fetchId, limit = 120, offset = 0)
|
||||||
|
channels = guide.channels
|
||||||
|
if (selected == null || visibleChannels.none { it.streamId == selected?.channel?.streamId }) {
|
||||||
|
val first = when (catId) {
|
||||||
|
TV_FAV_CATEGORY_ID -> guide.channels.firstOrNull { favoriteIds.contains(it.streamId) }
|
||||||
|
else -> guide.channels.firstOrNull()
|
||||||
|
}
|
||||||
|
if (first != null) {
|
||||||
|
val prog = first.now
|
||||||
|
?: first.schedule.firstOrNull { isProgramLive(it, clock) }
|
||||||
|
?: first.schedule.firstOrNull()
|
||||||
|
if (prog != null) selected = GuideSelection(first, prog)
|
||||||
|
} else {
|
||||||
|
selected = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (channels.isEmpty()) error = e.message
|
||||||
|
} finally {
|
||||||
|
loadingGuide = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(focusContentNonce, loading, displayCategories, selectedCategoryId) {
|
||||||
|
if (loading || displayCategories.isEmpty()) return@LaunchedEffect
|
||||||
|
delay(100)
|
||||||
|
val catId = selectedCategoryId ?: displayCategories.first().id
|
||||||
|
val idx = displayCategories.indexOfFirst { it.id == catId }.coerceAtLeast(0)
|
||||||
|
categoryRowState.scrollToItem(idx)
|
||||||
|
categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
|
||||||
|
focusInGrid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = focusInGrid) {
|
||||||
|
val catId = selectedCategoryId ?: displayCategories.firstOrNull()?.id
|
||||||
|
if (catId != null) {
|
||||||
|
categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
|
||||||
|
focusInGrid = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BackHandler(enabled = !focusInGrid) { onAtTopBack() }
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize().background(GuideBg)) {
|
||||||
|
when {
|
||||||
|
loading -> {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("TV-gids laden…", color = McColors.TextDim, fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error != null && channels.isEmpty() -> {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text("Gids niet beschikbaar", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(error!!, color = McColors.Danger, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Column(Modifier.fillMaxSize().padding(horizontal = 28.dp, vertical = 8.dp)) {
|
||||||
|
GuideCategoryRow(
|
||||||
|
categories = displayCategories,
|
||||||
|
selectedId = selectedCategoryId,
|
||||||
|
listState = categoryRowState,
|
||||||
|
categoryFocus = categoryFocus,
|
||||||
|
onSelect = {
|
||||||
|
onCategoryChange(it)
|
||||||
|
focusInGrid = true
|
||||||
|
},
|
||||||
|
onFocused = {
|
||||||
|
focusInGrid = false
|
||||||
|
onContentFocused()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"TV-gids · ${visibleChannels.size} zenders · ${formatClock(clock)}",
|
||||||
|
color = McColors.TextDim,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
GuideChip(label = "Nu", onClick = { jumpToNowToken++ })
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(GuideDivider))
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
|
||||||
|
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
Column(Modifier.weight(0.62f).fillMaxHeight()) {
|
||||||
|
GuideTimeRuler(clock = clock)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
when {
|
||||||
|
loadingGuide && visibleChannels.isEmpty() -> {
|
||||||
|
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||||
|
Text("Programma's laden…", color = McColors.TextDim, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visibleChannels.isEmpty() -> {
|
||||||
|
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
if (selectedCategoryId == TV_FAV_CATEGORY_ID) {
|
||||||
|
"Nog geen favorieten"
|
||||||
|
} else {
|
||||||
|
"Geen zenders in deze categorie"
|
||||||
|
},
|
||||||
|
color = McColors.TextDim,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
state = channelListState,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
|
contentPadding = PaddingValues(bottom = 12.dp),
|
||||||
|
) {
|
||||||
|
items(visibleChannels, key = { it.streamId }) { ch ->
|
||||||
|
GuideChannelRow(
|
||||||
|
channel = ch,
|
||||||
|
clock = clock,
|
||||||
|
jumpToNowToken = jumpToNowToken,
|
||||||
|
isFavorite = favoriteIds.contains(ch.streamId),
|
||||||
|
selectedProgram = selected
|
||||||
|
?.takeIf { it.channel.streamId == ch.streamId }
|
||||||
|
?.program,
|
||||||
|
onProgramFocused = { prog ->
|
||||||
|
focusInGrid = true
|
||||||
|
onContentFocused()
|
||||||
|
selected = GuideSelection(ch, prog)
|
||||||
|
},
|
||||||
|
onProgramClick = { prog ->
|
||||||
|
selected = GuideSelection(ch, prog)
|
||||||
|
if (isProgramLive(prog, clock)) {
|
||||||
|
onPlayChannel(ch.streamId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(1.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.background(GuideDivider),
|
||||||
|
)
|
||||||
|
|
||||||
|
GuideDetailPanel(
|
||||||
|
selection = selected,
|
||||||
|
clock = clock,
|
||||||
|
isFavorite = selected?.channel?.streamId?.let { favoriteIds.contains(it) } == true,
|
||||||
|
onToggleFavorite = { selected?.channel?.streamId?.let(onToggleFavorite) },
|
||||||
|
onWatch = { selected?.channel?.streamId?.let(onPlayChannel) },
|
||||||
|
onOpenChannel = { selected?.channel?.streamId?.let(onOpenLiveTv) },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(0.38f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(start = 14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class GuideSelection(
|
||||||
|
val channel: IptvGuideChannel,
|
||||||
|
val program: EpgProgram,
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun GuideCategoryRow(
|
||||||
|
categories: List<IptvCategory>,
|
||||||
|
selectedId: String?,
|
||||||
|
listState: LazyListState,
|
||||||
|
categoryFocus: MutableMap<String, FocusRequester>,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
onFocused: () -> Unit,
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
state = listState,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
contentPadding = PaddingValues(vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
items(categories, key = { it.id }) { cat ->
|
||||||
|
val active = cat.id == selectedId
|
||||||
|
val fr = categoryFocus.getOrPut(cat.id) { FocusRequester() }
|
||||||
|
val textColor by animateColorAsState(
|
||||||
|
if (active) Color.White else Color(0xFF888888),
|
||||||
|
label = "guideTab",
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
onClick = { onSelect(cat.id) },
|
||||||
|
modifier = Modifier
|
||||||
|
.focusRequester(fr)
|
||||||
|
.onFocusChanged { if (it.isFocused) onFocused() },
|
||||||
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
focusedContainerColor = Color(0x18FFFFFF),
|
||||||
|
contentColor = textColor,
|
||||||
|
focusedContentColor = Color.White,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
cat.name,
|
||||||
|
color = Color.Unspecified,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.height(2.dp)
|
||||||
|
.width(if (active) 24.dp else 0.dp)
|
||||||
|
.clip(RoundedCornerShape(1.dp))
|
||||||
|
.background(if (active) McColors.Brand else Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun GuideChip(label: String, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = Color(0xFF1A1A1A),
|
||||||
|
focusedContainerColor = Color(0xFF2A2A2A),
|
||||||
|
contentColor = Color.White,
|
||||||
|
focusedContentColor = Color.White,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = Color.Unspecified,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GuideTimeRuler(clock: Long) {
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val fmt = DateTimeFormatter.ofPattern("HH:mm").withZone(zone)
|
||||||
|
val start = Instant.ofEpochMilli(clock - 15 * 60_000L)
|
||||||
|
Row(Modifier.fillMaxWidth().height(18.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Spacer(Modifier.width(148.dp))
|
||||||
|
Box(Modifier.weight(1f)) {
|
||||||
|
Row {
|
||||||
|
for (i in 0 until GUIDE_HOURS * 2) {
|
||||||
|
val t = start.plusSeconds(i * 30L * 60L)
|
||||||
|
Text(
|
||||||
|
fmt.format(t),
|
||||||
|
color = if (i == 0) McColors.Brand else McColors.TextDim,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
modifier = Modifier.width((30 * PX_PER_MIN).dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GuideChannelRow(
|
||||||
|
channel: IptvGuideChannel,
|
||||||
|
clock: Long,
|
||||||
|
jumpToNowToken: Int,
|
||||||
|
isFavorite: Boolean,
|
||||||
|
selectedProgram: EpgProgram?,
|
||||||
|
onProgramFocused: (EpgProgram) -> Unit,
|
||||||
|
onProgramClick: (EpgProgram) -> Unit,
|
||||||
|
) {
|
||||||
|
val rowState = rememberLazyListState()
|
||||||
|
val windowStart = clock - 15 * 60_000L
|
||||||
|
val windowEnd = windowStart + GUIDE_HOURS * 60L * 60_000L
|
||||||
|
val programs = remember(channel.schedule, channel.now, windowStart, windowEnd) {
|
||||||
|
val raw = when {
|
||||||
|
channel.schedule.isNotEmpty() -> channel.schedule
|
||||||
|
else -> listOfNotNull(channel.now, channel.next)
|
||||||
|
}
|
||||||
|
raw.filter { overlapsWindow(it, windowStart, windowEnd) }
|
||||||
|
.sortedBy { parseMs(it.start) ?: 0L }
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(jumpToNowToken, programs) {
|
||||||
|
val liveIdx = programs.indexOfFirst { isProgramLive(it, clock) }
|
||||||
|
if (liveIdx >= 0) rowState.animateScrollToItem(liveIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().height(52.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.width(148.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(GuidePanel)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(Color(0xFF0A0A0A)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (!channel.logoUrl.isNullOrBlank()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = channel.logoUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(3.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
channel.name,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
if (isFavorite) Text("★", color = McColors.Brand, fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
channel.number?.let {
|
||||||
|
Text("$it", color = Color(0xFF666666), fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
LazyRow(
|
||||||
|
state = rowState,
|
||||||
|
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
|
) {
|
||||||
|
if (programs.isEmpty()) {
|
||||||
|
item {
|
||||||
|
GuideProgramCell(
|
||||||
|
title = "Geen EPG",
|
||||||
|
subtitle = null,
|
||||||
|
width = 160.dp,
|
||||||
|
live = false,
|
||||||
|
selected = false,
|
||||||
|
onFocused = {},
|
||||||
|
onClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
itemsIndexed(programs, key = { _, p -> "${p.start}-${p.title}" }) { _, prog ->
|
||||||
|
val live = isProgramLive(prog, clock)
|
||||||
|
GuideProgramCell(
|
||||||
|
title = prog.title,
|
||||||
|
subtitle = listOfNotNull(
|
||||||
|
prog.episode?.takeUnless { it.equals("null", true) },
|
||||||
|
formatShortTime(prog.start),
|
||||||
|
).joinToString(" · ").ifBlank { null },
|
||||||
|
width = programWidthDp(prog, windowStart, windowEnd),
|
||||||
|
live = live,
|
||||||
|
selected = selectedProgram?.start == prog.start &&
|
||||||
|
selectedProgram?.title == prog.title,
|
||||||
|
onFocused = { onProgramFocused(prog) },
|
||||||
|
onClick = { onProgramClick(prog) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun GuideProgramCell(
|
||||||
|
title: String,
|
||||||
|
subtitle: String?,
|
||||||
|
width: Dp,
|
||||||
|
live: Boolean,
|
||||||
|
selected: Boolean,
|
||||||
|
onFocused: () -> Unit,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val bg by animateColorAsState(
|
||||||
|
when {
|
||||||
|
focused || selected -> if (live) Color(0xFF3A1A1A) else Color(0xFF2A2A2A)
|
||||||
|
live -> GuideCellLive
|
||||||
|
else -> GuideCell
|
||||||
|
},
|
||||||
|
label = "cellBg",
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(width.coerceAtLeast(72.dp))
|
||||||
|
.fillMaxHeight()
|
||||||
|
.onFocusChanged {
|
||||||
|
focused = it.isFocused
|
||||||
|
if (it.isFocused) onFocused()
|
||||||
|
}
|
||||||
|
.then(
|
||||||
|
if (live) {
|
||||||
|
Modifier.border(1.dp, McColors.Brand.copy(alpha = 0.55f), RoundedCornerShape(4.dp))
|
||||||
|
} else Modifier,
|
||||||
|
),
|
||||||
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = bg,
|
||||||
|
focusedContainerColor = bg,
|
||||||
|
contentColor = Color.White,
|
||||||
|
focusedContentColor = Color.White,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
|
) {
|
||||||
|
if (live) {
|
||||||
|
Text("LIVE", color = McColors.Brand, fontSize = 9.sp, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!subtitle.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
color = Color(0xFF888888),
|
||||||
|
fontSize = 10.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun GuideDetailPanel(
|
||||||
|
selection: GuideSelection?,
|
||||||
|
clock: Long,
|
||||||
|
isFavorite: Boolean,
|
||||||
|
onToggleFavorite: () -> Unit,
|
||||||
|
onWatch: () -> Unit,
|
||||||
|
onOpenChannel: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.background(GuidePanel)
|
||||||
|
.padding(14.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
if (selection == null) {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
"Selecteer een programma",
|
||||||
|
color = McColors.TextDim,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"OK = kijken als live · ★ favoriet · Nu = spring naar live",
|
||||||
|
color = Color(0xFF555555),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val ch = selection.channel
|
||||||
|
val prog = selection.program
|
||||||
|
val live = isProgramLive(prog, clock)
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (!ch.logoUrl.isNullOrBlank()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ch.logoUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(Color(0xFF0A0A0A)),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
ch.name,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (live) "Nu live" else "Gepland",
|
||||||
|
color = if (live) McColors.Brand else McColors.TextDim,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(GuideDivider))
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
val art = prog.imageUrl
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(120.dp)
|
||||||
|
.height(74.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(Color(0xFF0A0A0A)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (!art.isNullOrBlank()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = art,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
|
formatEpgRange(prog.start, prog.end)?.let {
|
||||||
|
Text(it, color = McColors.TextDim, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
prog.title,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
lineHeight = 19.sp,
|
||||||
|
)
|
||||||
|
listOfNotNull(
|
||||||
|
prog.episode?.takeUnless { it.equals("null", true) },
|
||||||
|
prog.subTitle?.takeUnless { it.isBlank() || it.equals("null", true) },
|
||||||
|
prog.category?.takeUnless { it.isBlank() || it.equals("null", true) },
|
||||||
|
).joinToString(" · ").takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(it, color = Color(0xFF999999), fontSize = 11.sp, maxLines = 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (live) {
|
||||||
|
val progress = epgProgressFraction(prog, clock)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(3.dp)
|
||||||
|
.clip(RoundedCornerShape(1.dp))
|
||||||
|
.background(Color(0xFF333333)),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(progress.coerceAtLeast(0.02f))
|
||||||
|
.height(3.dp)
|
||||||
|
.background(McColors.Brand),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prog.description?.takeIf { it.isNotBlank() && !it.equals("null", true) }?.let { desc ->
|
||||||
|
Text(
|
||||||
|
desc,
|
||||||
|
color = Color(0xFFAAAAAA),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
maxLines = 7,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
if (live) {
|
||||||
|
GuideActionButton("▶ Kijken", primary = true, onClick = onWatch)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"Dit programma is nog niet begonnen — je kunt wel naar de zender.",
|
||||||
|
color = Color(0xFF666666),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 15.sp,
|
||||||
|
)
|
||||||
|
GuideActionButton("Naar zender", primary = true, onClick = onOpenChannel)
|
||||||
|
}
|
||||||
|
GuideActionButton(
|
||||||
|
if (isFavorite) "★ Favoriet" else "☆ Favoriet",
|
||||||
|
primary = false,
|
||||||
|
onClick = onToggleFavorite,
|
||||||
|
)
|
||||||
|
if (live) {
|
||||||
|
GuideActionButton("Zender openen", primary = false, onClick = onOpenChannel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun GuideActionButton(label: String, primary: Boolean, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.03f),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = if (primary) McColors.Brand else Color(0xFF1A1A1A),
|
||||||
|
focusedContainerColor = if (primary) Color(0xFFFF2A2A) else Color(0xFF2A2A2A),
|
||||||
|
contentColor = Color.White,
|
||||||
|
focusedContentColor = Color.White,
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = Color.Unspecified,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseMs(iso: String?): Long? =
|
||||||
|
iso?.let { runCatching { Instant.parse(it).toEpochMilli() }.getOrNull() }
|
||||||
|
|
||||||
|
private fun isProgramLive(program: EpgProgram, clock: Long): Boolean {
|
||||||
|
val start = parseMs(program.start) ?: return false
|
||||||
|
val end = parseMs(program.end) ?: return false
|
||||||
|
return start <= clock && clock < end
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun overlapsWindow(program: EpgProgram, windowStart: Long, windowEnd: Long): Boolean {
|
||||||
|
val start = parseMs(program.start) ?: return false
|
||||||
|
val end = parseMs(program.end) ?: (start + 30 * 60_000L)
|
||||||
|
return end > windowStart && start < windowEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun programWidthDp(program: EpgProgram, windowStart: Long, windowEnd: Long): Dp {
|
||||||
|
val start = (parseMs(program.start) ?: windowStart).coerceIn(windowStart, windowEnd)
|
||||||
|
val end = (parseMs(program.end) ?: (start + 30 * 60_000L)).coerceIn(windowStart, windowEnd)
|
||||||
|
val mins = max(12.0, (end - start) / 60_000.0)
|
||||||
|
return (mins * PX_PER_MIN).roundToInt().dp
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatClock(clock: Long): String {
|
||||||
|
val fmt = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
|
||||||
|
return fmt.format(Instant.ofEpochMilli(clock))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatShortTime(iso: String?): String? {
|
||||||
|
val fmt = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
|
||||||
|
return iso?.let { runCatching { fmt.format(Instant.parse(it)) }.getOrNull() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatEpgRange(start: String?, end: String?): String? {
|
||||||
|
val s = formatShortTime(start)
|
||||||
|
val e = formatShortTime(end)
|
||||||
|
return when {
|
||||||
|
s != null && e != null -> "$s – $e"
|
||||||
|
s != null -> s
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun epgProgressFraction(program: EpgProgram, clock: Long): Float {
|
||||||
|
val start = parseMs(program.start) ?: return 0f
|
||||||
|
val end = parseMs(program.end) ?: return 0f
|
||||||
|
if (end <= start) return 0f
|
||||||
|
return ((clock - start).toFloat() / (end - start).toFloat()).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
@ -72,6 +72,7 @@ enum class MainTab(val label: String) {
|
||||||
Movies("Films"),
|
Movies("Films"),
|
||||||
Series("Series"),
|
Series("Series"),
|
||||||
Tv("TV"),
|
Tv("TV"),
|
||||||
|
TvGuide("TV Gids"),
|
||||||
Search("Zoeken"),
|
Search("Zoeken"),
|
||||||
Settings("Instellingen"),
|
Settings("Instellingen"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,86 @@ export async function getViewerIptvChannels(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk TV-gids: zenders + 24u schedule uit globale XMLTV-cache (snel).
|
||||||
|
*/
|
||||||
|
export async function getViewerIptvGuide(
|
||||||
|
viewerId: string,
|
||||||
|
config: Config,
|
||||||
|
categoryId?: string,
|
||||||
|
opts: { limit?: number; offset?: number } = {}
|
||||||
|
) {
|
||||||
|
const xt = await loadXtreamForViewer(viewerId, config);
|
||||||
|
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
||||||
|
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
|
||||||
|
|
||||||
|
await applyIptvEpgUrlsToRuntime(config);
|
||||||
|
|
||||||
|
let streams =
|
||||||
|
!categoryId || categoryId === "__all__" || categoryId === "__fav__"
|
||||||
|
? await xt.client.getLiveStreams()
|
||||||
|
: await xt.client.getLiveStreams(categoryId);
|
||||||
|
if (!Array.isArray(streams)) streams = [];
|
||||||
|
streams = sortChannels(streams);
|
||||||
|
|
||||||
|
const offset = Math.max(0, opts.offset ?? 0);
|
||||||
|
const limit = Math.min(Math.max(1, opts.limit ?? 80), 150);
|
||||||
|
const slice = streams.slice(offset, offset + limit);
|
||||||
|
|
||||||
|
let guides = new Map<string, XmltvChannelGuide>();
|
||||||
|
try {
|
||||||
|
guides = await xt.client.getXmltvGuidesByChannelId();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[iptv-guide] xmltv:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const channels = slice.map((s) => {
|
||||||
|
const epgId = s.epg_channel_id?.trim();
|
||||||
|
const hit = epgId
|
||||||
|
? guides.get(epgId) ??
|
||||||
|
guides.get(epgId.toLowerCase()) ??
|
||||||
|
guides.get(epgId.toUpperCase())
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let now = hit?.now ?? null;
|
||||||
|
let next = hit?.next ?? null;
|
||||||
|
let schedule = hit?.schedule ?? [];
|
||||||
|
|
||||||
|
if (!hit) {
|
||||||
|
const cached = getCachedStreamEpg(xt.cacheKey, s.stream_id);
|
||||||
|
if (cached) {
|
||||||
|
now = cached.now;
|
||||||
|
next = cached.next;
|
||||||
|
schedule = cached.schedule;
|
||||||
|
}
|
||||||
|
} else if (hit.now || hit.next || hit.schedule.length > 0) {
|
||||||
|
setCachedStreamEpg(xt.cacheKey, s.stream_id, hit.now, hit.next, hit.schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
streamId: s.stream_id,
|
||||||
|
name: s.name.trim(),
|
||||||
|
logoUrl: s.stream_icon || null,
|
||||||
|
number: s.num ?? null,
|
||||||
|
categoryId: s.category_id ? String(s.category_id) : null,
|
||||||
|
now,
|
||||||
|
next,
|
||||||
|
schedule,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: new Date(nowMs).toISOString(),
|
||||||
|
windowStart: new Date(nowMs - 2 * 60 * 60_000).toISOString(),
|
||||||
|
windowEnd: new Date(nowMs + 24 * 60 * 60_000).toISOString(),
|
||||||
|
total: streams.length,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
channels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getViewerIptvPlayUrl(
|
export async function getViewerIptvPlayUrl(
|
||||||
viewerId: string,
|
viewerId: string,
|
||||||
config: Config,
|
config: Config,
|
||||||
|
|
|
||||||
|
|
@ -806,6 +806,18 @@ export function registerViewerRoutes(
|
||||||
return getViewerIptvEpg(auth.viewerId, config, id);
|
return getViewerIptvEpg(auth.viewerId, config, id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/client/iptv/guide", async (request) => {
|
||||||
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
const q = request.query as { categoryId?: string; limit?: string; offset?: string };
|
||||||
|
const { getViewerIptvGuide } = await import("./iptv-client");
|
||||||
|
const limit = q.limit ? parseInt(q.limit, 10) : undefined;
|
||||||
|
const offset = q.offset ? parseInt(q.offset, 10) : undefined;
|
||||||
|
return getViewerIptvGuide(auth.viewerId, config, q.categoryId, {
|
||||||
|
limit: Number.isFinite(limit) ? limit : undefined,
|
||||||
|
offset: Number.isFinite(offset) ? offset : undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/v1/client/subtitles/search", async (request) => {
|
app.get("/api/v1/client/subtitles/search", async (request) => {
|
||||||
await viewers.authFromBearer(request.headers.authorization);
|
await viewers.authFromBearer(request.headers.authorization);
|
||||||
const q = request.query as { mediaFileId?: string; languages?: string };
|
const q = request.query as { mediaFileId?: string; languages?: string };
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue