From 5524b3269d7d88cb6754ce0ac4a3a9ae7ac4a8c6 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 1 Sep 2026 17:31:05 +0200 Subject: [PATCH] Add live IPTV TV tab to Android app with per-viewer access. Expose client IPTV endpoints and admin toggle so viewers with IPTV access get a setup-box-style channel guide and live player in the TV app. Co-authored-by: Cursor --- apps/admin-ui/src/app/viewers/page.tsx | 19 +- apps/android-tv/app/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 125 ++++++ .../mediacluster/tv/ui/LiveTvPlayerScreen.kt | 274 +++++++++++++ .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 31 ++ .../nl/vonas/mediacluster/tv/ui/TvScreen.kt | 384 ++++++++++++++++++ .../mediacluster/tv/ui/components/TopNav.kt | 4 +- .../migration.sql | 11 + apps/master-api/prisma/schema.prisma | 5 + apps/master-api/src/viewer/iptv-client.ts | 200 +++++++++ apps/master-api/src/viewer/routes.ts | 47 ++- apps/master-api/src/viewer/service.ts | 12 + 12 files changed, 1111 insertions(+), 5 deletions(-) create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt create mode 100644 apps/master-api/prisma/migrations/20260901180000_viewer_iptv_access/migration.sql create mode 100644 apps/master-api/src/viewer/iptv-client.ts diff --git a/apps/admin-ui/src/app/viewers/page.tsx b/apps/admin-ui/src/app/viewers/page.tsx index 9f7bc4f..e7b184f 100644 --- a/apps/admin-ui/src/app/viewers/page.tsx +++ b/apps/admin-ui/src/app/viewers/page.tsx @@ -26,6 +26,8 @@ type Viewer = { name: string | null; enabled: boolean; appAccess: boolean; + iptvAccess: boolean; + iptvAddonTokenId: string | null; googlePlayEmail: string | null; googlePlayAccess: boolean; googlePlayMembershipId: string | null; @@ -143,7 +145,7 @@ export default function ViewersPage() { } } - async function toggle(v: Viewer, field: "enabled" | "appAccess") { + async function toggle(v: Viewer, field: "enabled" | "appAccess" | "iptvAccess") { setActionId(v.id); setErr(null); try { @@ -481,6 +483,21 @@ export default function ViewersPage() { +
+
+ Live TV (IPTV) + TV-tab in de Android-app +
+ +
diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 5e037ab..b861739 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 35 - versionCode = 21 - versionName = "0.8.4" + versionCode = 22 + versionName = "0.9.0" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") 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 4b91f2b..394d0ef 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 @@ -255,6 +255,91 @@ class ApiClient( Unit } + suspend fun clientMe(): ClientMe = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/me").get().build() + parseObject(execute(req)) { o -> + ClientMe( + iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true, + ) + } + } + + suspend fun iptvCategories(): List = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/iptv/categories").get().build() + parseObject(execute(req)) { o -> + val arr = o.optJSONArray("categories") ?: JSONArray() + buildList { + for (i in 0 until arr.length()) { + val c = arr.getJSONObject(i) + add( + IptvCategory( + id = c.getString("id"), + name = c.getString("name"), + channelCount = c.optInt("channelCount"), + ) + ) + } + } + } + } + + suspend fun iptvChannels(categoryId: String? = null): List = withContext(Dispatchers.IO) { + val q = categoryId?.let { "?categoryId=${java.net.URLEncoder.encode(it, "UTF-8")}" }.orEmpty() + val req = authed("$baseUrl/api/v1/client/iptv/channels$q").get().build() + parseObject(execute(req)) { o -> + val arr = o.optJSONArray("channels") ?: JSONArray() + buildList { + for (i in 0 until arr.length()) { + val c = arr.getJSONObject(i) + add( + IptvChannel( + streamId = c.getInt("streamId"), + name = c.getString("name"), + logoUrl = c.optString("logoUrl").ifBlank { null }, + number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") }, + categoryId = c.optString("categoryId").ifBlank { null }, + ) + ) + } + } + } + } + + suspend fun iptvPlayUrl(streamId: Int): IptvPlayInfo = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/play").get().build() + parseObject(execute(req)) { o -> + IptvPlayInfo( + streamId = o.getInt("streamId"), + name = o.getString("name"), + logoUrl = o.optString("logoUrl").ifBlank { null }, + streamUrl = o.getString("streamUrl"), + format = o.optString("format").ifBlank { "hls" }, + ) + } + } + + suspend fun iptvEpg(streamId: Int): IptvEpg = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/epg").get().build() + parseObject(execute(req)) { o -> + IptvEpg( + streamId = o.getInt("streamId"), + channelName = o.getString("channelName"), + logoUrl = o.optString("logoUrl").ifBlank { null }, + now = o.optJSONObject("now")?.toEpgProgram(), + next = o.optJSONObject("next")?.toEpgProgram(), + ) + } + } + + private fun JSONObject.toEpgProgram(): EpgProgram? { + val title = optString("title").ifBlank { return null } + return EpgProgram( + title = title, + start = optString("start").ifBlank { null }, + end = optString("end").ifBlank { null }, + ) + } + private fun authed(url: String): Request.Builder { val b = Request.Builder().url(url) val t = token @@ -378,6 +463,46 @@ data class ShelfPref( val sortOrder: Int, ) +data class ClientMe( + val iptvEnabled: Boolean, +) + +data class IptvCategory( + val id: String, + val name: String, + val channelCount: Int, +) + +data class IptvChannel( + val streamId: Int, + val name: String, + val logoUrl: String? = null, + val number: Int? = null, + val categoryId: String? = null, +) + +data class IptvPlayInfo( + val streamId: Int, + val name: String, + val logoUrl: String? = null, + val streamUrl: String, + val format: String, +) + +data class EpgProgram( + val title: String, + val start: String? = null, + val end: String? = null, +) + +data class IptvEpg( + val streamId: Int, + val channelName: String, + val logoUrl: String? = null, + val now: EpgProgram? = null, + val next: EpgProgram? = null, +) + 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/LiveTvPlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt new file mode 100644 index 0000000..ea02bec --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt @@ -0,0 +1,274 @@ +package nl.vonas.mediacluster.tv.ui + +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +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.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import androidx.tv.material3.ExperimentalTvMaterial3Api +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.IptvEpg +import nl.vonas.mediacluster.tv.playback.buildTvPlayer +import nl.vonas.mediacluster.tv.playback.ensureAudioSelected +import nl.vonas.mediacluster.tv.ui.theme.McColors + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun LiveTvPlayerScreen( + api: ApiClient, + streamId: Int, + onBack: () -> Unit, +) { + val context = LocalContext.current + val player = remember { buildTvPlayer(context) } + val focus = remember { FocusRequester() } + + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var channelName by remember { mutableStateOf("") } + var logoUrl by remember { mutableStateOf(null) } + var epg by remember { mutableStateOf(null) } + var overlay by remember { mutableStateOf(true) } + + LaunchedEffect(streamId) { + loading = true + error = null + try { + val play = api.iptvPlayUrl(streamId) + channelName = play.name + logoUrl = play.logoUrl + val mime = when { + play.format.equals("hls", true) || play.streamUrl.contains(".m3u8", true) -> + MimeTypes.APPLICATION_M3U8 + else -> MimeTypes.VIDEO_MP2T + } + player.setMediaItem( + MediaItem.Builder() + .setUri(play.streamUrl) + .setMimeType(mime) + .build(), + ) + player.prepare() + player.playWhenReady = true + runCatching { epg = api.iptvEpg(streamId) } + } catch (e: Exception) { + error = e.message ?: "Kon zender niet starten" + } finally { + loading = false + } + } + + LaunchedEffect(overlay) { + if (overlay) { + delay(5000) + overlay = false + } + } + + DisposableEffect(Unit) { + val listener = object : Player.Listener { + override fun onPlaybackStateChanged(state: Int) { + if (state == Player.STATE_READY) player.ensureAudioSelected() + } + + override fun onPlayerError(e: PlaybackException) { + error = e.message ?: "Afspeelfout" + } + } + player.addListener(listener) + onDispose { + player.removeListener(listener) + player.release() + } + } + + BackHandler { onBack() } + + Box( + Modifier + .fillMaxSize() + .background(Color.Black) + .focusRequester(focus) + .focusable() + .onPreviewKeyEvent { ev -> + if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (ev.key) { + Key.Back, Key.Escape -> { + onBack() + true + } + Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { + overlay = !overlay + true + } + else -> { + overlay = true + false + } + } + }, + ) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + useController = false + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + this.player = player + } + }, + update = { it.player = player }, + modifier = Modifier.fillMaxSize(), + ) + + LaunchedEffect(Unit) { + delay(200) + runCatching { focus.requestFocus() } + } + + AnimatedVisibility( + visible = overlay && error == null, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + listOf(Color(0xCC000000), Color.Transparent, Color(0xCC000000)), + ), + ), + ) { + Row( + Modifier + .align(Alignment.TopStart) + .padding(32.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (!logoUrl.isNullOrBlank()) { + AsyncImage( + model = logoUrl, + contentDescription = null, + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0x22FFFFFF)), + contentScale = ContentScale.Fit, + ) + } + Column { + Text("LIVE", color = McColors.Danger, fontSize = 12.sp, fontWeight = FontWeight.Bold) + Text( + channelName.ifBlank { "Live TV" }, + color = Color.White, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + + epg?.let { info -> + Column( + Modifier + .align(Alignment.BottomStart) + .padding(32.dp), + ) { + info.now?.let { now -> + Text( + "Nu · ${now.title}", + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + info.next?.let { next -> + Text( + "Straks · ${next.title}", + color = McColors.TextDim, + fontSize = 15.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + + if (loading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Zender laden…", color = Color.White, fontSize = 18.sp) + } + } + + if (error != null) { + Box( + Modifier + .fillMaxSize() + .background(Color(0xEE000000)), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Kan zender niet afspelen", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold) + Text(error!!, color = McColors.Danger, fontSize = 15.sp) + Text("Druk op Terug om terug te gaan", color = McColors.TextDim, fontSize = 14.sp) + } + } + } + } +} 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 c22232a..c989193 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 @@ -112,6 +112,7 @@ fun McNav() { val s = seriesId?.ifBlank { null } ?: "-" nav.navigate("player/$mediaFileId/$type/$m/$e/$s/$startMs") }, + onPlayLive = { streamId -> nav.navigate("live/$streamId") }, onLogout = { scope.launch { store.setToken(null) @@ -190,6 +191,17 @@ fun McNav() { onBack = { nav.popBackStack() }, ) } + composable( + "live/{streamId}", + arguments = listOf(navArgument("streamId") { type = NavType.IntType }), + ) { entry -> + val streamId = entry.arguments?.getInt("streamId") ?: return@composable + LiveTvPlayerScreen( + api = api, + streamId = streamId, + onBack = { nav.popBackStack() }, + ) + } } } @@ -200,9 +212,11 @@ private fun MainHub( onOpenSeries: (String) -> Unit, onBrowse: (catalogId: String, title: String) -> Unit, onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit, + onPlayLive: (Int) -> Unit, onLogout: () -> Unit, ) { var tab by remember { mutableStateOf(MainTab.Home) } + var iptvEnabled by remember { mutableStateOf(false) } var navFocused by remember { mutableStateOf(false) } var exitConfirm by remember { mutableStateOf(false) } val navFocus = remember { FocusRequester() } @@ -217,6 +231,11 @@ private fun MainHub( LaunchedEffect(Unit) { runCatching { shelfPrefs = api.getShelfPrefs().sortedBy { it.sortOrder } } + runCatching { iptvEnabled = api.clientMe().iptvEnabled } + } + + val visibleTabs = remember(iptvEnabled) { + MainTab.entries.filter { it != MainTab.Tv || iptvEnabled } } val movieSubs = remember(shelfPrefs) { @@ -293,6 +312,10 @@ private fun MainHub( tab = MainTab.Home focusTopNav() } + // TV: BrowseScreen-achtig — content scrollt terug, daarna top-nav + BackHandler(enabled = tab == MainTab.Tv && !navFocused && !exitConfirm) { + focusTopNav() + } LaunchedEffect(navFocused) { if (navFocused) { @@ -306,7 +329,9 @@ private fun MainHub( Box(Modifier.focusRequester(navFocus)) { TopNavBar( selected = tab, + visibleTabs = visibleTabs, onSelect = { + if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar tab = it exitConfirm = false focusTopNav() @@ -374,6 +399,12 @@ private fun MainHub( onOpenMovie = onOpenMovie, onOpenSeries = onOpenSeries, ) + MainTab.Tv -> TvScreen( + api = api, + onAtTopBack = { focusTopNav() }, + onPlayChannel = onPlayLive, + 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/TvScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt new file mode 100644 index 0000000..f01a9e8 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt @@ -0,0 +1,384 @@ +package nl.vonas.mediacluster.tv.ui + +import androidx.compose.foundation.background +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.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.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +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.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.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.TextAlign +import androidx.compose.ui.text.style.TextOverflow +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.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import androidx.compose.runtime.rememberCoroutineScope +import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.data.IptvCategory +import nl.vonas.mediacluster.tv.data.IptvChannel +import nl.vonas.mediacluster.tv.data.IptvEpg +import nl.vonas.mediacluster.tv.ui.theme.McColors + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun TvScreen( + api: ApiClient, + onAtTopBack: () -> Unit, + onPlayChannel: (streamId: Int) -> Unit, + onContentFocused: () -> Unit = {}, +) { + var categories by remember { mutableStateOf>(emptyList()) } + var channels by remember { mutableStateOf>(emptyList()) } + var selectedCategoryId by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + var loadingChannels by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var focusedEpg by remember { mutableStateOf(null) } + var epgJob by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + loading = true + error = null + try { + val cats = api.iptvCategories() + categories = cats + selectedCategoryId = cats.firstOrNull()?.id + } catch (e: Exception) { + error = e.message ?: "Kon TV niet laden" + } finally { + loading = false + } + } + + LaunchedEffect(selectedCategoryId) { + val catId = selectedCategoryId ?: return@LaunchedEffect + loadingChannels = true + focusedEpg = null + try { + channels = api.iptvChannels(catId) + } catch (e: Exception) { + error = e.message + channels = emptyList() + } finally { + loadingChannels = false + } + } + + fun loadEpg(streamId: Int) { + epgJob?.cancel() + epgJob = scope.launch { + delay(280) + runCatching { focusedEpg = api.iptvEpg(streamId) } + } + } + + val selectedCategory = categories.firstOrNull { it.id == selectedCategoryId } + + Box(Modifier.fillMaxSize().background(McColors.Bg)) { + when { + loading -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("TV laden…", color = McColors.TextDim, fontSize = 18.sp) + } + } + error != null && categories.isEmpty() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("TV niet beschikbaar", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold) + Text(error!!, color = McColors.Danger, fontSize = 15.sp) + } + } + } + else -> { + Row(Modifier.fillMaxSize()) { + CategorySidebar( + categories = categories, + selectedId = selectedCategoryId, + onSelect = { selectedCategoryId = it }, + onFocused = onContentFocused, + ) + Column( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(start = 8.dp, end = 40.dp, top = 8.dp, bottom = 16.dp), + ) { + Row( + Modifier.fillMaxWidth().padding(bottom = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + selectedCategory?.name ?: "Live TV", + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ) + Text( + when { + loadingChannels -> "Zenders laden…" + else -> "${channels.size} zenders" + }, + color = McColors.TextDim, + fontSize = 14.sp, + ) + } + } + + if (channels.isEmpty() && !loadingChannels) { + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text("Geen zenders in deze categorie", color = McColors.TextDim, fontSize = 16.sp) + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(5), + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(channels, key = { it.streamId }) { ch -> + ChannelTile( + channel = ch, + onClick = { onPlayChannel(ch.streamId) }, + onFocused = { + onContentFocused() + loadEpg(ch.streamId) + }, + ) + } + } + } + + EpgBar(epg = focusedEpg) + } + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun CategorySidebar( + categories: List, + selectedId: String?, + onSelect: (String) -> Unit, + onFocused: () -> Unit, +) { + LazyColumn( + modifier = Modifier + .width(260.dp) + .fillMaxHeight() + .background(Color(0xFF121212)) + .padding(vertical = 12.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + item { + Text( + "Categorieën", + color = McColors.TextDim, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp, bottom = 8.dp), + ) + } + items(categories, key = { it.id }) { cat -> + val active = cat.id == selectedId + Surface( + onClick = { onSelect(cat.id) }, + modifier = Modifier.onFocusChanged { if (it.isFocused) onFocused() }, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), + colors = ClickableSurfaceDefaults.colors( + containerColor = if (active) Color(0x33FFFFFF) else Color.Transparent, + focusedContainerColor = Color(0x44FFFFFF), + contentColor = if (active) Color.White else Color(0xFFB0B0B0), + focusedContentColor = Color.White, + ), + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + cat.name, + color = Color.Unspecified, + fontSize = 15.sp, + fontWeight = if (active) FontWeight.Bold else FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + "${cat.channelCount}", + color = McColors.TextDim, + fontSize = 12.sp, + ) + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun ChannelTile( + channel: IptvChannel, + onClick: () -> Unit, + onFocused: () -> Unit, +) { + Surface( + onClick = onClick, + modifier = Modifier.onFocusChanged { if (it.isFocused) onFocused() }, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color(0xFF1A1A1A), + focusedContainerColor = Color(0xFF2A2A2A), + contentColor = Color.White, + focusedContentColor = Color.White, + ), + ) { + Column( + Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + Modifier + .size(72.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF0D0D0D)), + contentAlignment = Alignment.Center, + ) { + if (!channel.logoUrl.isNullOrBlank()) { + AsyncImage( + model = channel.logoUrl, + contentDescription = channel.name, + modifier = Modifier.fillMaxSize().padding(6.dp), + contentScale = ContentScale.Fit, + ) + } else { + Text( + channel.name.take(2).uppercase(), + color = McColors.Brand, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + ) + } + } + channel.number?.let { num -> + Text( + "$num", + color = McColors.TextDim, + fontSize = 11.sp, + ) + } + Text( + channel.name, + color = Color.White, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + lineHeight = 16.sp, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpgBar(epg: IptvEpg?) { + Row( + Modifier + .fillMaxWidth() + .height(72.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFF161616)) + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + if (epg == null) { + Text("Selecteer een zender voor programma-info", color = McColors.TextDim, fontSize = 14.sp) + return + } + if (!epg.logoUrl.isNullOrBlank()) { + AsyncImage( + model = epg.logoUrl, + contentDescription = null, + modifier = Modifier.size(40.dp), + contentScale = ContentScale.Fit, + ) + } + Column(Modifier.weight(1f)) { + Text(epg.channelName, color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1) + Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) { + epg.now?.let { now -> + Text( + "Nu: ${now.title}", + color = McColors.Brand, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + epg.next?.let { next -> + Text( + "Straks: ${next.title}", + color = McColors.TextDim, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (epg.now == null && epg.next == null) { + Text("Geen programma-info", color = McColors.TextDim, fontSize = 14.sp) + } + } + } + } +} 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 609f7c4..7360f14 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 @@ -34,6 +34,7 @@ enum class MainTab(val label: String) { Home("Home"), Movies("Films"), Series("Series"), + Tv("TV"), Search("Zoeken"), Settings("Instellingen"), } @@ -50,6 +51,7 @@ data class SubNavItem( fun TopNavBar( selected: MainTab, onSelect: (MainTab) -> Unit, + visibleTabs: List = MainTab.entries, subItems: List = emptyList(), selectedSubId: String? = null, onSelectSub: (SubNavItem) -> Unit = {}, @@ -70,7 +72,7 @@ fun TopNavBar( fontWeight = FontWeight.Black, modifier = Modifier.padding(end = 16.dp), ) - MainTab.entries.forEach { tab -> + visibleTabs.forEach { tab -> val active = selected == tab val color by animateColorAsState( if (active) Color.White else Color(0xFF808080), diff --git a/apps/master-api/prisma/migrations/20260901180000_viewer_iptv_access/migration.sql b/apps/master-api/prisma/migrations/20260901180000_viewer_iptv_access/migration.sql new file mode 100644 index 0000000..83541bb --- /dev/null +++ b/apps/master-api/prisma/migrations/20260901180000_viewer_iptv_access/migration.sql @@ -0,0 +1,11 @@ +ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "iptv_access" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "iptv_addon_token_id" TEXT; + +DO $$ BEGIN + ALTER TABLE "viewer_users" + ADD CONSTRAINT "viewer_users_iptv_addon_token_id_fkey" + FOREIGN KEY ("iptv_addon_token_id") REFERENCES "addon_tokens"("id") + ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index a1995be..cb185c4 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -74,6 +74,9 @@ model ViewerUser { name String? enabled Boolean @default(true) appAccess Boolean @default(true) @map("app_access") + /** Live TV in Android-app (Xtream via gekoppelde addon-token). */ + iptvAccess Boolean @default(false) @map("iptv_access") + iptvAddonTokenId String? @map("iptv_addon_token_id") /** Google-account voor Play Closed Testing; fallback = email */ googlePlayEmail String? @map("google_play_email") /** Gewenste Play-distributietoegang (sync met Google Group) */ @@ -90,6 +93,7 @@ model ViewerUser { shelfPrefs ViewerShelfPreference[] linkCodes DeviceLinkCode[] googlePlayAuditLogs GooglePlayAccessAuditLog[] + iptvAddonToken AddonToken? @relation(fields: [iptvAddonTokenId], references: [id], onDelete: SetNull) @@map("viewer_users") } @@ -197,6 +201,7 @@ model AddonToken { user User @relation(fields: [userId], references: [id], onDelete: Cascade) iptvLine IptvLine? playbackSessions PlaybackSession[] + iptvViewers ViewerUser[] @@map("addon_tokens") } diff --git a/apps/master-api/src/viewer/iptv-client.ts b/apps/master-api/src/viewer/iptv-client.ts new file mode 100644 index 0000000..aa8ef00 --- /dev/null +++ b/apps/master-api/src/viewer/iptv-client.ts @@ -0,0 +1,200 @@ +import { prisma } from "../database/client"; +import type { Config } from "../config"; +import { AppError } from "../security/errors"; +import { loadXtreamForToken } from "../iptv/service"; +import { + decodeEpgText, + epgToIso, + type XtreamLiveStream, +} from "../iptv/xtream"; + +/** Maak Xtream-categorienamen leesbaarder voor TV-gebruikers. */ +export function friendlyCategoryName(raw: string): string { + let s = raw.trim(); + s = s.replace(/^[A-Z]{2,3}\s*[|·:]\s*/i, ""); + s = s.replace(/^[\s★☆●▪•\-–—]+/, ""); + return s.trim() || raw.trim() || "Overig"; +} + +export async function resolveViewerIptvToken(viewerId: string): Promise { + const viewer = await prisma.viewerUser.findUnique({ + where: { id: viewerId }, + select: { iptvAccess: true, iptvAddonTokenId: true, enabled: true, appAccess: true }, + }); + if (!viewer?.enabled || !viewer.appAccess || !viewer.iptvAccess) return null; + + if (viewer.iptvAddonTokenId) { + const linked = await prisma.addonToken.findFirst({ + where: { + id: viewer.iptvAddonTokenId, + revoked: false, + iptvLine: { enableLive: true, lastError: null }, + }, + select: { id: true }, + }); + if (linked) return linked.id; + } + + const fallback = await prisma.iptvLine.findFirst({ + where: { + enableLive: true, + lastError: null, + addonToken: { revoked: false }, + }, + select: { addonTokenId: true }, + orderBy: { updatedAt: "desc" }, + }); + return fallback?.addonTokenId ?? null; +} + +export async function getViewerIptvStatus(viewerId: string, config: Config) { + const tokenId = await resolveViewerIptvToken(viewerId); + if (!tokenId) return { enabled: false as const }; + + const xt = await loadXtreamForToken(tokenId, config); + if (!xt?.line.enableLive) return { enabled: false as const }; + + const cats = await xt.client.getLiveCategories().catch(() => []); + return { + enabled: true as const, + categoryCount: cats.length, + }; +} + +export async function getViewerIptvCategories(viewerId: string, config: Config) { + const tokenId = await resolveViewerIptvToken(viewerId); + if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403); + + const xt = await loadXtreamForToken(tokenId, config); + if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403); + + const cats = await xt.client.getLiveCategories().catch(() => []); + const streams = await xt.client.getLiveStreams().catch(() => []); + + const counts = new Map(); + for (const s of streams) { + const key = String(s.category_id ?? ""); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + + const categories = cats + .map((c) => ({ + id: String(c.category_id), + name: friendlyCategoryName(c.category_name), + rawName: c.category_name, + channelCount: counts.get(String(c.category_id)) ?? 0, + })) + .filter((c) => c.channelCount > 0) + .sort((a, b) => a.name.localeCompare(b.name, "nl")); + + if (streams.length > 0) { + categories.unshift({ + id: "__all__", + name: "Alle zenders", + rawName: "Alle zenders", + channelCount: streams.length, + }); + } + + return { categories }; +} + +function sortChannels(streams: XtreamLiveStream[]): XtreamLiveStream[] { + return [...streams].sort((a, b) => (a.num ?? a.stream_id) - (b.num ?? b.stream_id)); +} + +export async function getViewerIptvChannels( + viewerId: string, + config: Config, + categoryId?: string +) { + const tokenId = await resolveViewerIptvToken(viewerId); + if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403); + + const xt = await loadXtreamForToken(tokenId, config); + if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403); + + let streams = + !categoryId || categoryId === "__all__" + ? await xt.client.getLiveStreams() + : await xt.client.getLiveStreams(categoryId); + streams = sortChannels(streams); + + return { + channels: streams.map((s) => ({ + 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, + })), + }; +} + +export async function getViewerIptvPlayUrl( + viewerId: string, + config: Config, + streamId: number +) { + const tokenId = await resolveViewerIptvToken(viewerId); + if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403); + + const xt = await loadXtreamForToken(tokenId, config); + if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403); + + const streams = await xt.client.getLiveStreams(); + const ch = streams.find((s) => s.stream_id === streamId); + if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404); + + const resolved = await xt.client.resolveLivePlayUrls(streamId); + const url = resolved.hls || resolved.ts || resolved.classic[0]; + if (!url) throw new AppError("UNAVAILABLE", "Geen afspeel-URL voor deze zender", 503); + + return { + streamId, + name: ch.name, + logoUrl: ch.stream_icon || null, + streamUrl: url, + format: url.includes(".m3u8") || url.includes("m3u8") ? "hls" : "ts", + }; +} + +export async function getViewerIptvEpg(viewerId: string, config: Config, streamId: number) { + const tokenId = await resolveViewerIptvToken(viewerId); + if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403); + + const xt = await loadXtreamForToken(tokenId, config); + if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403); + + const streams = await xt.client.getLiveStreams(); + const ch = streams.find((s) => s.stream_id === streamId); + if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404); + + const epg = await xt.client.getShortEpg(streamId, 8); + const now = Date.now(); + let nowProgram: { title: string; start: string; end: string | null } | null = null; + let nextProgram: { title: string; start: string; end: string | null } | null = null; + + for (const listing of epg) { + const start = epgToIso(listing, "start"); + const end = epgToIso(listing, "end"); + if (!start) continue; + const title = decodeEpgText(listing.title) || "Programma"; + const startMs = Date.parse(start); + const endMs = end ? Date.parse(end) : startMs + 30 * 60_000; + + if (startMs <= now && now < endMs) { + nowProgram = { title, start, end: end ?? null }; + } else if (startMs > now && !nextProgram) { + nextProgram = { title, start, end: end ?? null }; + } + } + + return { + streamId, + channelName: ch.name, + logoUrl: ch.stream_icon || null, + now: nowProgram, + next: nextProgram, + }; +} diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 8e3cf88..2c002b5 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -44,6 +44,8 @@ export function registerViewerRoutes( name?: string | null; enabled?: boolean; appAccess?: boolean; + iptvAccess?: boolean; + iptvAddonTokenId?: string | null; password?: string; }; const viewer = await viewers.updateViewer(id, body); @@ -118,7 +120,13 @@ export function registerViewerRoutes( // ——— Authenticated client ——— app.get("/api/v1/client/me", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); - return { viewer: { id: auth.viewerId, email: auth.email, name: auth.name }, deviceId: auth.deviceId }; + const { getViewerIptvStatus } = await import("./iptv-client"); + const iptv = await getViewerIptvStatus(auth.viewerId, config); + return { + viewer: { id: auth.viewerId, email: auth.email, name: auth.name }, + deviceId: auth.deviceId, + iptv: { enabled: iptv.enabled }, + }; }); app.get("/api/v1/client/home", async (request) => { @@ -218,6 +226,43 @@ export function registerViewerRoutes( return { shelves: await viewers.setShelfPrefs(auth.viewerId, body.shelves) }; }); + app.get("/api/v1/client/iptv/status", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const { getViewerIptvStatus } = await import("./iptv-client"); + return getViewerIptvStatus(auth.viewerId, config); + }); + + app.get("/api/v1/client/iptv/categories", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const { getViewerIptvCategories } = await import("./iptv-client"); + return getViewerIptvCategories(auth.viewerId, config); + }); + + app.get("/api/v1/client/iptv/channels", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const q = request.query as { categoryId?: string }; + const { getViewerIptvChannels } = await import("./iptv-client"); + return getViewerIptvChannels(auth.viewerId, config, q.categoryId); + }); + + app.get("/api/v1/client/iptv/channels/:streamId/play", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const { streamId } = request.params as { streamId: string }; + const id = parseInt(streamId, 10); + if (!Number.isFinite(id)) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400); + const { getViewerIptvPlayUrl } = await import("./iptv-client"); + return getViewerIptvPlayUrl(auth.viewerId, config, id); + }); + + app.get("/api/v1/client/iptv/channels/:streamId/epg", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const { streamId } = request.params as { streamId: string }; + const id = parseInt(streamId, 10); + if (!Number.isFinite(id)) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400); + const { getViewerIptvEpg } = await import("./iptv-client"); + return getViewerIptvEpg(auth.viewerId, config, id); + }); + app.get("/api/v1/client/subtitles/search", async (request) => { await viewers.authFromBearer(request.headers.authorization); const q = request.query as { mediaFileId?: string; languages?: string }; diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index f7a6f6a..04afa44 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -102,6 +102,8 @@ export class ViewerService { name: v.name, enabled: v.enabled, appAccess: v.appAccess, + iptvAccess: v.iptvAccess, + iptvAddonTokenId: v.iptvAddonTokenId, googlePlayEmail: v.googlePlayEmail, googlePlayAccess: v.googlePlayAccess, googlePlayMembershipId: v.googlePlayMembershipId, @@ -121,6 +123,8 @@ export class ViewerService { name?: string | null; enabled?: boolean; appAccess?: boolean; + iptvAccess?: boolean; + iptvAddonTokenId?: string | null; password?: string; } ) { @@ -128,11 +132,17 @@ export class ViewerService { name?: string | null; enabled?: boolean; appAccess?: boolean; + iptvAccess?: boolean; + iptvAddonTokenId?: string | null; passwordHash?: string; } = {}; if (data.name !== undefined) patch.name = data.name?.trim() || null; if (data.enabled !== undefined) patch.enabled = data.enabled; if (data.appAccess !== undefined) patch.appAccess = data.appAccess; + if (data.iptvAccess !== undefined) patch.iptvAccess = data.iptvAccess; + if (data.iptvAddonTokenId !== undefined) { + patch.iptvAddonTokenId = data.iptvAddonTokenId?.trim() || null; + } if (data.password?.trim()) { if (data.password.length < 8) { throw new AppError("INVALID_REQUEST", "Wachtwoord minstens 8 tekens", 400); @@ -148,6 +158,8 @@ export class ViewerService { name: true, enabled: true, appAccess: true, + iptvAccess: true, + iptvAddonTokenId: true, createdAt: true, }, });