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 <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-01 17:31:05 +02:00
parent 159cd48692
commit 5524b3269d
12 changed files with 1111 additions and 5 deletions

View file

@ -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() {
<span />
</button>
</div>
<div className="toggle-row">
<div>
<strong>Live TV (IPTV)</strong>
<span className="muted">TV-tab in de Android-app</span>
</div>
<button
type="button"
className={`switch ${v.iptvAccess ? "on" : ""}`}
disabled={actionId === v.id || !v.appAccess}
onClick={() => void toggle(v, "iptvAccess")}
aria-pressed={v.iptvAccess}
>
<span />
</button>
</div>
</div>
</section>

View file

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

View file

@ -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<IptvCategory> = 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<IptvChannel> = 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<MediaItem> {
if (this == null) return emptyList()
return buildList {

View file

@ -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<String?>(null) }
var channelName by remember { mutableStateOf("") }
var logoUrl by remember { mutableStateOf<String?>(null) }
var epg by remember { mutableStateOf<IptvEpg?>(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)
}
}
}
}
}

View file

@ -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,

View file

@ -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<List<IptvCategory>>(emptyList()) }
var channels by remember { mutableStateOf<List<IptvChannel>>(emptyList()) }
var selectedCategoryId by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
var loadingChannels by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) }
var epgJob by remember { mutableStateOf<Job?>(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<IptvCategory>,
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)
}
}
}
}
}

View file

@ -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> = MainTab.entries,
subItems: List<SubNavItem> = 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),

View file

@ -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 $$;

View file

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

View file

@ -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<string | null> {
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<string, number>();
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,
};
}

View file

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

View file

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