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:
parent
159cd48692
commit
5524b3269d
12 changed files with 1111 additions and 5 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 $$;
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
200
apps/master-api/src/viewer/iptv-client.ts
Normal file
200
apps/master-api/src/viewer/iptv-client.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue