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). */
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
Series("Series"),
|
||||
Tv("TV"),
|
||||
TvGuide("TV Gids"),
|
||||
Search("Zoeken"),
|
||||
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(
|
||||
viewerId: string,
|
||||
config: Config,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in a new issue