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:
Jos Vooges | STH 2026-09-15 21:09:48 +02:00
parent bbb57baf81
commit b566454f8a
6 changed files with 1051 additions and 6 deletions

View file

@ -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). */
suspend fun fetchMissingNowTitles(channels: List<IptvChannel>): Map<Int, String> {
val result = linkedMapOf<Int, String>()
@ -850,6 +901,27 @@ data class IptvEpg(
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> {
if (this == null) return emptyList()
return buildList {

View file

@ -502,7 +502,8 @@ private fun MainHub(
val visibleTabs = remember(iptvEnabled) {
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)
focusTopNav()
}
// TV: BrowseScreen-achtig — content scrollt terug, daarna top-nav
BackHandler(enabled = tab == MainTab.Tv && !navFocused && !exitConfirm) {
// TV / TV Gids: content scrollt terug, daarna top-nav
BackHandler(enabled = (tab == MainTab.Tv || tab == MainTab.TvGuide) && !navFocused && !exitConfirm) {
focusTopNav()
}
LaunchedEffect(tvFocusNonce) {
if (tab == MainTab.Tv) {
if (tab == MainTab.Tv || tab == MainTab.TvGuide) {
navFocused = false
}
}
@ -650,12 +651,12 @@ private fun MainHub(
focusTopNav()
},
onSelect = {
if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar
if ((it == MainTab.Tv || it == MainTab.TvGuide) && !iptvEnabled) return@TopNavBar
onTabChange(it)
exitConfirm = false
homeChromeCompact = false
when (it) {
MainTab.Tv -> {
MainTab.Tv, MainTab.TvGuide -> {
navFocused = false
onTvFocusContent()
}
@ -775,6 +776,21 @@ private fun MainHub(
onPlayChannel = onPlayLive,
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(
api = api,
onOpenMovie = onOpenMovie,

View file

@ -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)
}

View file

@ -72,6 +72,7 @@ enum class MainTab(val label: String) {
Movies("Films"),
Series("Series"),
Tv("TV"),
TvGuide("TV Gids"),
Search("Zoeken"),
Settings("Instellingen"),
}

View file

@ -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(
viewerId: string,
config: Config,

View file

@ -806,6 +806,18 @@ export function registerViewerRoutes(
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) => {
await viewers.authFromBearer(request.headers.authorization);
const q = request.query as { mediaFileId?: string; languages?: string };