diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index f29b531..170edeb 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -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): Map { val result = linkedMapOf() @@ -850,6 +901,27 @@ data class IptvEpg( val schedule: List = 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 = 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 = emptyList(), +) + private fun JSONArray?.toItems(): List { if (this == null) return emptyList() return buildList { diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 416f787..ed39549 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -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, diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvGuideScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvGuideScreen.kt new file mode 100644 index 0000000..f627697 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvGuideScreen.kt @@ -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 = emptySet(), + onToggleFavorite: (Int) -> Unit = {}, + focusContentNonce: Int = 0, + onAtTopBack: () -> Unit, + onPlayChannel: (streamId: Int) -> Unit, + onOpenLiveTv: (streamId: Int) -> Unit = {}, + onContentFocused: () -> Unit = {}, +) { + var categories by remember { mutableStateOf>(emptyList()) } + var channels by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var loadingGuide by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var focusInGrid by remember { mutableStateOf(false) } + var selected by remember { mutableStateOf(null) } + var clock by remember { mutableStateOf(System.currentTimeMillis()) } + var jumpToNowToken by remember { mutableStateOf(0) } + + val categoryRowState = rememberLazyListState() + val channelListState = rememberLazyListState() + val categoryFocus = remember { mutableStateMapOf() } + + 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, + selectedId: String?, + listState: LazyListState, + categoryFocus: MutableMap, + 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) +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt index 0805543..719e563 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt @@ -72,6 +72,7 @@ enum class MainTab(val label: String) { Movies("Films"), Series("Series"), Tv("TV"), + TvGuide("TV Gids"), Search("Zoeken"), Settings("Instellingen"), } diff --git a/apps/master-api/src/viewer/iptv-client.ts b/apps/master-api/src/viewer/iptv-client.ts index c02a90b..9e3b060 100644 --- a/apps/master-api/src/viewer/iptv-client.ts +++ b/apps/master-api/src/viewer/iptv-client.ts @@ -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(); + 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, diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 6f0b2f4..0a4fa61 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -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 };