Compare commits

...

2 commits

Author SHA1 Message Date
Jos Vooges | STH
85da6f59ad Ship F1 hub on Android TV and harden iOS live/ClearKey playback.
Add the WOW-style F1 hub navigation/rails and improve event playback paths on TV/mobile plus iOS AVLive/ClearKey proxy handling.
2026-09-22 21:52:23 +02:00
Jos Vooges | STH
4b1f9eb763 Add searchable DRM key database in admin for reused ClearKeys.
Persist kids/keys with channel/event/MPD metadata, expose /drm search UI, and record keys from Odido sync/play for cache-first reuse.
2026-09-22 21:50:50 +02:00
25 changed files with 5728 additions and 434 deletions

View file

@ -0,0 +1,325 @@
"use client";
import { FormEvent, useCallback, useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
type DrmKeyRow = {
id: string;
kid: string;
keyMasked: string;
hasKey: boolean;
mpdUrl: string | null;
channelName: string | null;
eventName: string | null;
source: string;
externalId: string | null;
mediaGuid: string | null;
lastSeenAt: string;
createdAt: string;
updatedAt: string;
};
function shortUrl(url: string | null, max = 48): string {
if (!url) return "—";
const s = url.trim();
if (s.length <= max) return s;
try {
const u = new URL(s);
const path = u.pathname.split("/").filter(Boolean).pop() ?? "";
const host = u.host.replace(/^www\./, "");
const tip = path ? `${host}/…/${path}` : host;
return tip.length <= max ? tip : `${tip.slice(0, max - 1)}…`;
} catch {
return `${s.slice(0, max - 1)}…`;
}
}
function fmtWhen(iso: string): string {
try {
return new Date(iso).toLocaleString("nl-NL", {
dateStyle: "short",
timeStyle: "short",
});
} catch {
return iso;
}
}
export default function DrmPage() {
useAuth();
const [search, setSearch] = useState("");
const [source, setSource] = useState("");
const [keys, setKeys] = useState<DrmKeyRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [revealed, setRevealed] = useState<Record<string, string>>({});
const [busyId, setBusyId] = useState<string | null>(null);
const load = useCallback(async (q: string, src: string) => {
setLoading(true);
setErr(null);
try {
const params = new URLSearchParams();
if (q.trim()) params.set("search", q.trim());
if (src.trim()) params.set("source", src.trim());
params.set("limit", "200");
const r = await fetch(`/api/v1/admin/drm-keys?${params}`, {
credentials: "include",
});
const d = await r.json();
if (!r.ok) throw new Error(d.message || d.error || "Laden mislukt");
setKeys(d.keys ?? []);
setTotal(d.total ?? 0);
if (d.requiresQuery) {
setMsg("Typ minstens 2 tekens om te zoeken, of laat leeg voor recente keys.");
} else {
setMsg(null);
}
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load("", "");
}, [load]);
function onSearch(e: FormEvent) {
e.preventDefault();
void load(search, source);
}
async function reveal(id: string) {
setBusyId(id);
setErr(null);
try {
const r = await fetch(`/api/v1/admin/drm-keys/${id}/reveal`, {
credentials: "include",
});
const d = await r.json();
if (!r.ok) throw new Error(d.message || "Reveal mislukt");
setRevealed((prev) => ({ ...prev, [id]: d.key as string }));
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
}
async function copyText(label: string, text: string) {
try {
await navigator.clipboard.writeText(text);
setMsg(`${label} gekopieerd`);
} catch {
setErr("Clipboard niet beschikbaar");
}
}
async function backfill() {
setBusyId("backfill");
setErr(null);
try {
const r = await fetch("/api/v1/admin/drm-keys/backfill", {
method: "POST",
credentials: "include",
});
const d = await r.json();
if (!r.ok) throw new Error(d.message || "Backfill mislukt");
setMsg(
`Backfill: ${d.imported ?? 0} nieuw, ${d.skipped ?? 0} overgeslagen, ${d.failed ?? 0} mislukt`
);
await load(search, source);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
}
async function remove(id: string) {
if (!confirm("Deze DRM-key verwijderen uit de database?")) return;
setBusyId(id);
try {
const r = await fetch(`/api/v1/admin/drm-keys/${id}`, {
method: "DELETE",
credentials: "include",
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.message || "Verwijderen mislukt");
}
setKeys((prev) => prev.filter((k) => k.id !== id));
setMsg("Key verwijderd");
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
}
return (
<div className="page">
<Nav />
<main className="container">
<header className="page-header">
<div>
<p className="page-kicker">Beveiliging</p>
<h1>DRM keys</h1>
<p className="page-lead">
Alle ooit opgehaalde ClearKey/Widevine-keys. Zoek op KID, KEY, MPD, zender of
event. Altijd eerst cache — alleen ophalen bij miss.
</p>
</div>
<div className="page-actions">
<button
type="button"
className="btn"
disabled={busyId === "backfill"}
onClick={() => void backfill()}
>
{busyId === "backfill" ? "Bezig…" : "Import uit live-lijsten"}
</button>
</div>
</header>
<form className="card" onSubmit={onSearch} style={{ marginBottom: "1rem" }}>
<div className="form-row" style={{ gap: "0.75rem", flexWrap: "wrap" }}>
<input
type="search"
placeholder="Zoek kid, key, mpd, zender, event…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ flex: "1 1 240px" }}
/>
<select
value={source}
onChange={(e) => setSource(e.target.value)}
style={{ flex: "0 0 140px" }}
>
<option value="">Alle bronnen</option>
<option value="odido">odido</option>
<option value="viaplay">viaplay</option>
<option value="manual">manual</option>
<option value="live-channel">live-channel</option>
<option value="event">event</option>
</select>
<button type="submit" className="btn" disabled={loading}>
{loading ? "Zoeken…" : "Zoeken"}
</button>
</div>
</form>
{msg && <p className="ok-msg">{msg}</p>}
{err && <p className="err-msg">{err}</p>}
<div className="card">
<div className="card-head">
<h2>Keys</h2>
<span className="muted">{total} totaal · {keys.length} getoond</span>
</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>KID</th>
<th>KEY</th>
<th>Zender / event</th>
<th>MPD</th>
<th>Bron</th>
<th>Laatst gezien</th>
<th />
</tr>
</thead>
<tbody>
{keys.length === 0 && (
<tr>
<td colSpan={7} className="muted">
Geen keys. Gebruik “Import uit live-lijsten” of wacht tot sync keys
ophaalt.
</td>
</tr>
)}
{keys.map((k) => {
const fullKey = revealed[k.id];
return (
<tr key={k.id}>
<td>
<code
title={k.kid}
style={{ cursor: "pointer" }}
onClick={() => void copyText("KID", k.kid)}
>
{k.kid.length > 20 ? `${k.kid.slice(0, 16)}…` : k.kid}
</code>
</td>
<td>
{fullKey ? (
<code
style={{ cursor: "pointer" }}
onClick={() => void copyText("KEY", fullKey)}
>
{fullKey.length > 20 ? `${fullKey.slice(0, 16)}…` : fullKey}
</code>
) : (
<span className="muted">{k.hasKey ? "••••••••" : "—"}</span>
)}
</td>
<td>
<div>{k.channelName || "—"}</div>
{k.eventName && (
<div className="muted" style={{ fontSize: "0.85em" }}>
{k.eventName}
</div>
)}
</td>
<td title={k.mpdUrl ?? undefined}>
<span className="muted">{shortUrl(k.mpdUrl)}</span>
</td>
<td>
<code>{k.source}</code>
</td>
<td className="muted">{fmtWhen(k.lastSeenAt)}</td>
<td style={{ whiteSpace: "nowrap" }}>
{!fullKey && k.hasKey && (
<button
type="button"
className="btn btn-sm"
disabled={busyId === k.id}
onClick={() => void reveal(k.id)}
>
Toon
</button>
)}
{k.mpdUrl && (
<button
type="button"
className="btn btn-sm"
onClick={() => void copyText("MPD", k.mpdUrl!)}
>
MPD
</button>
)}
<button
type="button"
className="btn btn-sm btn-danger"
disabled={busyId === k.id}
onClick={() => void remove(k.id)}
>
×
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</main>
</div>
);
}

View file

@ -14,6 +14,7 @@ const desktopLinks = [
{ href: "/downloads", label: "Downloads" },
{ href: "/streams", label: "Streams" },
{ href: "/live-lists", label: "Live-lijsten" },
{ href: "/drm", label: "DRM" },
{ href: "/scripts", label: "Scripts" },
{ href: "/viewers", label: "Gebruikers" },
{ href: "/settings", label: "Instellingen" },

View file

@ -299,11 +299,12 @@ private class InjectingClearKeyDrmSessionManager(
}
private fun formatWithClearKeyScheme(format: Format): Format {
if (keyIds.isEmpty()) return format
val existing = format.drmInitData
val hasClearKey = existing != null && (0 until existing.schemeDataCount).any {
existing.get(it).matches(C.CLEARKEY_UUID)
}
if (hasClearKey || keyIds.isEmpty()) return format
val onlyClearKey = existing != null &&
existing.schemeDataCount > 0 &&
(0 until existing.schemeDataCount).all { existing.get(it).matches(C.CLEARKEY_UUID) }
if (onlyClearKey) return format
val keyUuids = keyIds.map { bytesToUuid(it) }.toTypedArray()
val pssh = PsshAtomUtil.buildPsshAtom(C.CLEARKEY_UUID, keyUuids, /* data= */ null)
@ -312,7 +313,10 @@ private class InjectingClearKeyDrmSessionManager(
MimeTypes.VIDEO_MP4,
pssh,
)
Log.i(TAG, "Injecting ClearKey PSSH with ${keyIds.size} key id(s)")
Log.i(
TAG,
"Forcing ClearKey PSSH kids=${keyIds.size} wasSchemes=${existing?.schemeDataCount ?: 0}",
)
return format.buildUpon()
.setDrmInitData(DrmInitData(schemeData))
.build()
@ -331,10 +335,11 @@ fun liveMediaItem(
format: String,
drm: DrmInfo? = null,
): MediaItem {
val isDash = format.equals("dash", ignoreCase = true) ||
url.contains(".mpd", ignoreCase = true)
val isHls = !isDash && (
format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true)
// URL wint: ESPN/FAAB levert .m3u8 terwijl de API soms format=dash hardcodet.
val isHls = url.contains("m3u8", ignoreCase = true) ||
format.equals("hls", ignoreCase = true)
val isDash = !isHls && (
format.equals("dash", ignoreCase = true) || url.contains(".mpd", ignoreCase = true)
)
val mime = when {
isDash -> MimeTypes.APPLICATION_MPD

View file

@ -3,14 +3,32 @@ package nl.vonas.mediacluster.tv.ui
import android.view.ViewGroup
import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@ -20,35 +38,51 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
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.C
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.playback.PlayerTrack
import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
import nl.vonas.mediacluster.tv.playback.disableTrackType
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
import nl.vonas.mediacluster.tv.playback.listTracks
import nl.vonas.mediacluster.tv.playback.liveMediaItem
import nl.vonas.mediacluster.tv.playback.selectTrack
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
private const val ReadyTimeoutMs = 20_000L
private const val ChromeHideMs = 5_000L
/** Stadium-energy palette — event-player (zelfde taal als TV Events). */
private object EventPlayerLook {
val Neon = Color(0xFF00E8A0)
val NeonSoft = Color(0x3300E8A0)
val NeonGlow = Color(0x6600E8A0)
val Live = Color(0xFFFF2D55)
val Panel = Color(0xFF121A18)
val PanelHot = Color(0xFF1A2824)
val Scrim = Color(0xCC050A08)
}
@OptIn(UnstableApi::class)
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun EventPlayerScreen(
api: ApiClient,
@ -66,9 +100,30 @@ fun EventPlayerScreen(
var error by remember { mutableStateOf<String?>(null) }
var showChrome by remember { mutableStateOf(true) }
var watchSessionId by remember { mutableStateOf<String?>(null) }
var audioTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
var selectedAudioKey by remember { mutableStateOf<String?>(null) }
var selectedTextKey by remember { mutableStateOf<String?>(null) }
var textDisabled by remember { mutableStateOf(true) }
var menu by remember { mutableStateOf<String?>(null) } // audio | text
var chromeBump by remember { mutableStateOf(0) }
RememberPlayerImmersiveMode()
fun refreshTracks() {
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
selectedAudioKey = player.activeTrackKey(C.TRACK_TYPE_AUDIO)
selectedTextKey = player.activeTrackKey(C.TRACK_TYPE_TEXT)
textDisabled = player.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT) ||
selectedTextKey == null
}
fun revealChrome() {
showChrome = true
chromeBump++
}
fun safeStopAndClear() {
runCatching {
player.playWhenReady = false
@ -83,6 +138,9 @@ fun EventPlayerScreen(
watchSessionId = null
reachedReady.set(false)
showChrome = true
menu = null
audioTracks = emptyList()
textTracks = emptyList()
try {
val play = api.scheduleEventPlay(eventId)
if (!isActive) return@LaunchedEffect
@ -146,9 +204,9 @@ fun EventPlayerScreen(
}
}
LaunchedEffect(showChrome, error, loading) {
if (showChrome && error == null && !loading) {
delay(4_000)
LaunchedEffect(showChrome, menu, error, loading, chromeBump) {
if (showChrome && menu == null && error == null && !loading) {
delay(ChromeHideMs)
showChrome = false
}
}
@ -159,12 +217,22 @@ fun EventPlayerScreen(
when (state) {
Player.STATE_READY -> {
reachedReady.set(true)
runCatching { player.ensureAudioSelected() }
runCatching {
player.ensureAudioSelected()
refreshTracks()
}
}
else -> Unit
}
}
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
runCatching {
player.ensureAudioSelected()
refreshTracks()
}
}
override fun onPlayerError(e: PlaybackException) {
val cause = generateSequence(e as Throwable?) { it.cause }
.mapNotNull { it.message?.takeIf { m -> m.isNotBlank() } }
@ -190,7 +258,13 @@ fun EventPlayerScreen(
}
}
BackHandler(onBack = onBack)
BackHandler {
when {
menu != null -> menu = null
showChrome -> showChrome = false
else -> onBack()
}
}
Box(
Modifier
@ -200,7 +274,13 @@ fun EventPlayerScreen(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
if (error == null && !loading) showChrome = !showChrome
if (error != null || loading) return@clickable
if (menu != null) {
menu = null
} else {
showChrome = !showChrome
if (showChrome) chromeBump++
}
},
) {
if (error == null) {
@ -249,26 +329,324 @@ fun EventPlayerScreen(
)
}
}
} else if (showChrome) {
} else {
AnimatedVisibility(
visible = showChrome || menu != null,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(
EventPlayerLook.Scrim,
Color.Transparent,
Color.Transparent,
EventPlayerLook.Scrim,
),
),
),
)
Column(
Modifier
.align(Alignment.TopStart)
.statusBarsPadding()
.padding(horizontal = 20.dp, vertical = 12.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.background(EventPlayerLook.Live, RoundedCornerShape(4.dp))
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(
"LIVE",
color = Color.White,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
)
}
Box(
Modifier
.size(width = 3.dp, height = 12.dp)
.background(EventPlayerLook.Neon, RoundedCornerShape(2.dp)),
)
Text(
"EVENT",
color = EventPlayerLook.Neon,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
)
}
McButton("Terug", compact = true, onClick = onBack)
}
Text(
title,
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Column(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 20.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MobileEventChip(
label = "Audio",
subtitle = when {
audioTracks.isEmpty() -> "—"
else -> audioTracks.firstOrNull {
trackKey(it) == selectedAudioKey
}?.label ?: audioTracks.first().label
},
modifier = Modifier.weight(1f),
onClick = {
revealChrome()
menu = "audio"
},
)
MobileEventChip(
label = "Ondertiteling",
subtitle = when {
textDisabled || textTracks.isEmpty() -> "Uit"
else -> textTracks.firstOrNull {
trackKey(it) == selectedTextKey
}?.label ?: "Aan"
},
modifier = Modifier.weight(1f),
onClick = {
revealChrome()
menu = "text"
},
)
}
}
}
}
AnimatedVisibility(
visible = menu != null,
enter = fadeIn() + slideInVertically { it / 2 },
exit = fadeOut() + slideOutVertically { it / 2 },
) {
val isAudio = menu == "audio"
val tracks = if (isAudio) audioTracks else textTracks
Box(
Modifier
.fillMaxSize()
.background(Color(0x99050A08))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { menu = null },
contentAlignment = Alignment.BottomCenter,
) {
Column(
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.background(
EventPlayerLook.Panel,
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
)
.border(
width = 1.dp,
brush = Brush.horizontalGradient(
listOf(EventPlayerLook.NeonGlow, Color.Transparent, EventPlayerLook.NeonSoft),
),
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { /* consume */ }
.padding(horizontal = 20.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
Modifier
.align(Alignment.CenterHorizontally)
.width(40.dp)
.height(4.dp)
.background(Color(0x44FFFFFF), RoundedCornerShape(2.dp)),
)
Text(
if (isAudio) "Audio" else "Ondertiteling",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
Box(
Modifier
.width(36.dp)
.height(3.dp)
.background(EventPlayerLook.Neon, RoundedCornerShape(2.dp)),
)
if (!isAudio) {
MobileEventTrackRow(
label = "Uit",
selected = textDisabled || tracks.isEmpty(),
onClick = {
player.disableTrackType(C.TRACK_TYPE_TEXT)
textDisabled = true
selectedTextKey = null
menu = null
revealChrome()
},
)
}
if (tracks.isEmpty()) {
Text(
if (isAudio) "Geen audiosporen in deze stream"
else "Geen ondertitels in deze stream",
color = McColors.TextDim,
fontSize = 14.sp,
modifier = Modifier.padding(vertical = 8.dp),
)
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.height((tracks.size.coerceAtMost(6) * 56).dp.coerceAtLeast(56.dp)),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(tracks, key = { trackKey(it) }) { track ->
val selected = if (isAudio) {
trackKey(track) == selectedAudioKey
} else {
!textDisabled && trackKey(track) == selectedTextKey
}
MobileEventTrackRow(
label = track.label,
selected = selected,
onClick = {
player.selectTrack(track)
if (isAudio) {
selectedAudioKey = trackKey(track)
} else {
textDisabled = false
selectedTextKey = trackKey(track)
}
menu = null
revealChrome()
},
)
}
}
Spacer(Modifier.height(4.dp))
MobileEventChip(
label = "Sluiten",
subtitle = null,
modifier = Modifier.fillMaxWidth(),
onClick = { menu = null },
)
}
}
}
}
}
}
private fun trackKey(track: PlayerTrack): String = "${track.groupIndex}:${track.trackIndex}"
private fun ExoPlayer.activeTrackKey(type: Int): String? {
val groups = currentTracks.groups
for (gi in 0 until groups.size) {
val group = groups[gi]
if (group.type != type || !group.isSelected) continue
for (ti in 0 until group.length) {
if (group.isTrackSelected(ti)) return "$gi:$ti"
}
}
return null
}
@Composable
private fun MobileEventChip(
label: String,
subtitle: String?,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Column(
modifier
.background(EventPlayerLook.PanelHot, RoundedCornerShape(12.dp))
.border(1.dp, EventPlayerLook.NeonSoft, RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
label,
color = EventPlayerLook.Neon,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
)
if (subtitle != null) {
Text(
title,
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.align(Alignment.TopStart)
.statusBarsPadding()
.padding(20.dp),
)
McButton(
"Terug",
compact = true,
modifier = Modifier
.align(Alignment.TopEnd)
.statusBarsPadding()
.padding(16.dp),
onClick = onBack,
subtitle,
color = McColors.TextDim,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun MobileEventTrackRow(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.background(
if (selected) EventPlayerLook.NeonSoft else Color.Transparent,
RoundedCornerShape(10.dp),
)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
if (selected) "●" else "○",
color = if (selected) EventPlayerLook.Neon else Color(0x55FFFFFF),
fontSize = 12.sp,
)
Text(
label,
color = Color.White,
fontSize = 15.sp,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
)
}
}

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24
targetSdk = 36
versionCode = 135
versionName = "0.14.72"
versionCode = 143
versionName = "0.14.80"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -636,37 +636,152 @@ class ApiClient(
suspend fun scheduleEventPlay(eventId: String): IptvPlayInfo = withContext(Dispatchers.IO) {
val enc = android.net.Uri.encode(eventId)
val req = authed("$baseUrl/api/v1/client/events/$enc/play").get().build()
parseObject(execute(req)) { o -> o.toIptvPlayInfo(fallbackStreamId = eventId, defaultFormat = "dash") }
}
suspend fun getF1tvHome(): F1tvHomePayload = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/f1/home").get().build()
parseObject(execute(req)) { o ->
val drmObj = o.optJSONObject("drm")
val drm = if (drmObj != null) {
val keysArr = drmObj.optJSONArray("keys") ?: JSONArray()
val keys = buildList {
for (i in 0 until keysArr.length()) {
val k = keysArr.optJSONObject(i) ?: continue
val kid = k.optString("kid").ifBlank { null } ?: continue
val key = k.optString("key").ifBlank { null } ?: continue
add(ClearKeyEntry(kid = kid, key = key))
fun eventsOf(key: String): List<ScheduleEvent> {
val arr = o.optJSONArray(key) ?: JSONArray()
return buildList {
for (i in 0 until arr.length()) {
arr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) }
}
}
DrmInfo(
type = drmObj.optString("type").ifBlank { "clearkey" },
keys = keys,
).takeIf { it.keys.isNotEmpty() }
} else null
IptvPlayInfo(
streamId = o.optString("eventId").ifBlank { eventId },
name = o.getString("name"),
logoUrl = o.optString("logoUrl").ifBlank { null },
streamUrl = o.getString("streamUrl"),
format = o.optString("format").ifBlank { "dash" },
fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null },
fallbackFormat = o.optString("fallbackFormat").ifBlank { null },
drm = drm,
watchSessionId = o.optString("watchSessionId").ifBlank { null },
}
fun meetingsOf(key: String): List<F1tvMeeting> {
val arr = o.optJSONArray(key) ?: JSONArray()
return buildList {
for (i in 0 until arr.length()) {
arr.optJSONObject(i)?.toF1tvMeeting()?.let { add(it) }
}
}
}
fun replayGroupsOf(key: String): List<F1tvReplayGroup> {
val arr = o.optJSONArray(key) ?: JSONArray()
return buildList {
for (i in 0 until arr.length()) {
val g = arr.optJSONObject(i) ?: continue
val sessionsArr = g.optJSONArray("sessions") ?: JSONArray()
val sessions = buildList {
for (si in 0 until sessionsArr.length()) {
sessionsArr.optJSONObject(si)?.toScheduleEvent()?.let { add(it) }
}
}
val meetingKey = g.optClean("meetingKey") ?: continue
add(
F1tvReplayGroup(
meetingKey = meetingKey,
title = g.optClean("title") ?: meetingKey,
location = g.optClean("location"),
meetingNumber = g.optInt("meetingNumber").takeIf { it > 0 },
imageUrl = g.optClean("imageUrl"),
latestStart = g.optClean("latestStart") ?: "",
sessions = sessions,
),
)
}
}
}
F1tvHomePayload(
generatedAt = o.optClean("generatedAt"),
live = eventsOf("live"),
upcoming = eventsOf("upcoming"),
recentReplays = eventsOf("recentReplays"),
replayGroups = replayGroupsOf("replayGroups"),
meetings = meetingsOf("meetings"),
nextMeeting = o.optJSONObject("nextMeeting")?.toF1tvMeeting(),
)
}
}
suspend fun getF1tvMeeting(meetingKey: String): F1tvMeetingDetail = withContext(Dispatchers.IO) {
val enc = android.net.Uri.encode(meetingKey)
val req = authed("$baseUrl/api/v1/client/f1/meetings/$enc").get().build()
parseObject(execute(req)) { o ->
val sessionsArr = o.optJSONArray("sessions") ?: JSONArray()
val sessions = buildList {
for (i in 0 until sessionsArr.length()) {
sessionsArr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) }
}
}
fun eventsOf(key: String): List<ScheduleEvent> {
val arr = o.optJSONArray(key) ?: JSONArray()
return buildList {
for (i in 0 until arr.length()) {
arr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) }
}
}
}
F1tvMeetingDetail(
meeting = o.optJSONObject("meeting")?.toF1tvMeeting(),
sessions = sessions,
replays = eventsOf("replays"),
upcoming = eventsOf("upcoming"),
live = eventsOf("live"),
)
}
}
/** Hub play (live + replay). */
suspend fun f1tvContentPlay(contentId: String): IptvPlayInfo = withContext(Dispatchers.IO) {
val enc = android.net.Uri.encode(contentId)
val req = authed("$baseUrl/api/v1/client/f1/content/$enc/play").get().build()
parseObject(execute(req)) { o ->
o.toIptvPlayInfo(fallbackStreamId = "f1tv:$contentId", defaultFormat = "hls")
}
}
private fun JSONObject.toIptvPlayInfo(
fallbackStreamId: String,
defaultFormat: String,
): IptvPlayInfo {
val drmObj = optJSONObject("drm")
val drm = if (drmObj != null) {
val keysArr = drmObj.optJSONArray("keys") ?: JSONArray()
val keys = buildList {
for (i in 0 until keysArr.length()) {
val k = keysArr.optJSONObject(i) ?: continue
val kid = k.optString("kid").ifBlank { null } ?: continue
val key = k.optString("key").ifBlank { null } ?: continue
add(ClearKeyEntry(kid = kid, key = key))
}
}
DrmInfo(
type = drmObj.optString("type").ifBlank { "clearkey" },
keys = keys,
).takeIf { it.keys.isNotEmpty() }
} else null
return IptvPlayInfo(
streamId = optString("eventId").ifBlank { fallbackStreamId },
name = getString("name"),
logoUrl = optString("logoUrl").ifBlank { null },
streamUrl = getString("streamUrl"),
format = optString("format").ifBlank { defaultFormat },
fallbackStreamUrl = optString("fallbackStreamUrl").ifBlank { null },
fallbackFormat = optString("fallbackFormat").ifBlank { null },
drm = drm,
watchSessionId = optString("watchSessionId").ifBlank { null },
)
}
private fun JSONObject.toF1tvMeeting(): F1tvMeeting? {
val meetingKey = optClean("meetingKey") ?: return null
return F1tvMeeting(
meetingKey = meetingKey,
contentId = optLong("contentId").takeIf { it > 0 },
title = optClean("title") ?: "Meeting $meetingKey",
pageId = optLong("pageId").takeIf { it > 0 },
pageUri = optClean("pageUri"),
meetingNumber = optInt("meetingNumber").takeIf { it > 0 },
location = optClean("location"),
startMs = optLong("startMs").takeIf { it > 0 },
endMs = optLong("endMs").takeIf { it > 0 },
imageUrl = optClean("imageUrl"),
)
}
/**
* Presence-heartbeat voor events / custom live-lists.
* Best-effort: never throws to callers that ignore failures.
@ -1137,6 +1252,47 @@ data class ScheduleEvent(
val audioLanguages: List<String> = emptyList(),
)
data class F1tvMeeting(
val meetingKey: String,
val contentId: Long? = null,
val title: String,
val pageId: Long? = null,
val pageUri: String? = null,
val meetingNumber: Int? = null,
val location: String? = null,
val startMs: Long? = null,
val endMs: Long? = null,
val imageUrl: String? = null,
)
data class F1tvReplayGroup(
val meetingKey: String,
val title: String,
val location: String? = null,
val meetingNumber: Int? = null,
val imageUrl: String? = null,
val latestStart: String = "",
val sessions: List<ScheduleEvent> = emptyList(),
)
data class F1tvHomePayload(
val generatedAt: String? = null,
val live: List<ScheduleEvent> = emptyList(),
val upcoming: List<ScheduleEvent> = emptyList(),
val recentReplays: List<ScheduleEvent> = emptyList(),
val replayGroups: List<F1tvReplayGroup> = emptyList(),
val meetings: List<F1tvMeeting> = emptyList(),
val nextMeeting: F1tvMeeting? = null,
)
data class F1tvMeetingDetail(
val meeting: F1tvMeeting? = null,
val sessions: List<ScheduleEvent> = emptyList(),
val replays: List<ScheduleEvent> = emptyList(),
val upcoming: List<ScheduleEvent> = emptyList(),
val live: List<ScheduleEvent> = emptyList(),
)
/** Canonical shelf/chip order (Overig last). Keep in sync with master-api. */
val SportBucketOrder = listOf(
"football", "racing", "tennis", "golf", "darts", "snooker",

View file

@ -309,11 +309,14 @@ private class InjectingClearKeyDrmSessionManager(
}
private fun formatWithClearKeyScheme(format: Format): Format {
if (keyIds.isEmpty()) return format
val existing = format.drmInitData
val hasClearKey = existing != null && (0 until existing.schemeDataCount).any {
existing.get(it).matches(C.CLEARKEY_UUID)
}
if (hasClearKey || keyIds.isEmpty()) return format
// Alleen ClearKey → laten staan. Anders forceren (ESPN HLS heeft Widevine/PlayReady
// SESSION-KEY; wij hebben ClearKey-kids uit de feed).
val onlyClearKey = existing != null &&
existing.schemeDataCount > 0 &&
(0 until existing.schemeDataCount).all { existing.get(it).matches(C.CLEARKEY_UUID) }
if (onlyClearKey) return format
val keyUuids = keyIds.map { bytesToUuid(it) }.toTypedArray()
val pssh = PsshAtomUtil.buildPsshAtom(C.CLEARKEY_UUID, keyUuids, /* data= */ null)
@ -322,7 +325,10 @@ private class InjectingClearKeyDrmSessionManager(
MimeTypes.VIDEO_MP4,
pssh,
)
Log.i(TAG, "Injecting ClearKey PSSH with ${keyIds.size} key id(s)")
Log.i(
TAG,
"Forcing ClearKey PSSH kids=${keyIds.size} wasSchemes=${existing?.schemeDataCount ?: 0}",
)
return format.buildUpon()
.setDrmInitData(DrmInitData(schemeData))
.build()
@ -341,10 +347,11 @@ fun liveMediaItem(
format: String,
drm: DrmInfo? = null,
): MediaItem {
val isDash = format.equals("dash", ignoreCase = true) ||
url.contains(".mpd", ignoreCase = true)
val isHls = !isDash && (
format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true)
// URL wint: ESPN/FAAB levert .m3u8 terwijl de API soms format=dash hardcodet.
val isHls = url.contains("m3u8", ignoreCase = true) ||
format.equals("hls", ignoreCase = true)
val isDash = !isHls && (
format.equals("dash", ignoreCase = true) || url.contains(".mpd", ignoreCase = true)
)
val mime = when {
isDash -> MimeTypes.APPLICATION_MPD

View file

@ -1,12 +1,20 @@
package nl.vonas.mediacluster.tv.ui
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.view.KeyEvent as AndroidKeyEvent
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -26,11 +34,13 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -39,24 +49,23 @@ 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.window.Dialog
import androidx.compose.ui.window.DialogProperties
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.components.HomeChrome
import nl.vonas.mediacluster.tv.ui.components.formatEndsAt
import nl.vonas.mediacluster.tv.ui.components.rememberClockTickMs
import nl.vonas.mediacluster.tv.ui.components.remainingMsFromRuntimeMinutes
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEventType
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.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
@ -67,10 +76,15 @@ import coil.compose.AsyncImage
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.ui.components.HomeChrome
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.components.MediaVersion
import nl.vonas.mediacluster.tv.ui.components.SelectedQualityLine
import nl.vonas.mediacluster.tv.ui.components.VersionQualityMark
import nl.vonas.mediacluster.tv.ui.components.formatEndsAt
import nl.vonas.mediacluster.tv.ui.components.parseMediaVersion
import nl.vonas.mediacluster.tv.ui.components.rememberClockTickMs
import nl.vonas.mediacluster.tv.ui.components.remainingMsFromRuntimeMinutes
import nl.vonas.mediacluster.tv.ui.theme.McColors
import org.json.JSONArray
import org.json.JSONObject
@ -101,36 +115,197 @@ private fun formatRuntime(minutes: Int?): String? {
}
}
private fun openYoutubeTrailer(context: android.content.Context, key: String) {
val appPackages = listOf(
"com.google.android.youtube.tv",
"com.google.android.youtube.googletv",
"com.google.android.youtube",
)
val appIntent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:$key")).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun TrailerPlayerOverlay(
youtubeKey: String,
onDismiss: () -> Unit,
) {
var loading by remember { mutableStateOf(true) }
var paused by remember { mutableStateOf(false) }
var webView by remember { mutableStateOf<WebView?>(null) }
val catcherFocus = remember { FocusRequester() }
val mainHandler = remember { Handler(Looper.getMainLooper()) }
BackHandler(onBack = onDismiss)
LaunchedEffect(Unit) {
delay(50)
catcherFocus.requestFocus()
}
for (pkg in appPackages) {
try {
appIntent.setPackage(pkg)
context.startActivity(appIntent)
return
} catch (_: ActivityNotFoundException) {
// try next
} catch (_: Exception) {
// try next
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
),
) {
Box(
Modifier
.fillMaxSize()
.background(Color.Black),
) {
AndroidView(
factory = { ctx ->
WebView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
setBackgroundColor(android.graphics.Color.BLACK)
isFocusable = false
isFocusableInTouchMode = false
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
webChromeClient = WebChromeClient()
webViewClient = WebViewClient()
addJavascriptInterface(
object {
@JavascriptInterface
fun onPlaying() {
mainHandler.post {
loading = false
paused = false
}
}
@JavascriptInterface
fun onPaused() {
mainHandler.post { paused = true }
}
@JavascriptInterface
fun onEnded() {
mainHandler.post(onDismiss)
}
},
"VmcTrailer",
)
// Cinema mode: scaled/cropped iframe + pointer-events none hides
// YouTube chrome/watermark and blocks "Watch on YouTube" taps.
val html = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="referrer" content="strict-origin-when-cross-origin">
<style>
html,body{margin:0;height:100%;background:#000;overflow:hidden}
#stage{position:fixed;inset:0;overflow:hidden;background:#000}
#player{position:absolute;top:50%;left:50%;width:128vw;height:128vh;
transform:translate(-50%,-50%);pointer-events:none}
#player iframe{width:100%!important;height:100%!important;border:0;
pointer-events:none}
</style>
</head>
<body>
<div id="stage"><div id="player"></div></div>
<script>
var tag=document.createElement('script');
tag.src='https://www.youtube.com/iframe_api';
document.head.appendChild(tag);
var player;
function onYouTubeIframeAPIReady(){
player=new YT.Player('player',{
height:'100%',width:'100%',
videoId:'$youtubeKey',
host:'https://www.youtube-nocookie.com',
playerVars:{
autoplay:1,controls:0,disablekb:1,fs:0,rel:0,
iv_load_policy:3,playsinline:1,cc_load_policy:0,
modestbranding:1,showinfo:0,
origin:'https://nl.vonas.mediacluster.tv'
},
events:{
onReady:function(e){
try{e.target.setPlaybackQuality('hd1080');}catch(_){}
e.target.playVideo();
},
onStateChange:function(e){
if(e.data===1) VmcTrailer.onPlaying();
else if(e.data===2) VmcTrailer.onPaused();
else if(e.data===0) VmcTrailer.onEnded();
},
onError:function(){VmcTrailer.onEnded();}
}
});
}
function vmcToggle(){
if(!player||!player.getPlayerState) return;
if(player.getPlayerState()===1) player.pauseVideo();
else player.playVideo();
}
</script>
</body>
</html>
""".trimIndent()
loadDataWithBaseURL(
"https://nl.vonas.mediacluster.tv/",
html,
"text/html",
"UTF-8",
null,
)
webView = this
}
},
modifier = Modifier.fillMaxSize(),
onRelease = {
it.destroy()
webView = null
},
)
// Catch remote input so YouTube UI never receives focus/clicks.
Box(
Modifier
.fillMaxSize()
.focusRequester(catcherFocus)
.focusable()
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false
when (event.nativeKeyEvent.keyCode) {
AndroidKeyEvent.KEYCODE_DPAD_CENTER,
AndroidKeyEvent.KEYCODE_ENTER,
AndroidKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
AndroidKeyEvent.KEYCODE_MEDIA_PLAY,
AndroidKeyEvent.KEYCODE_MEDIA_PAUSE,
-> {
webView?.evaluateJavascript("vmcToggle();", null)
true
}
else -> false
}
},
)
if (loading) {
Text(
"Bezig met laden…",
color = Color(0xFFB0B8C8),
fontSize = 18.sp,
modifier = Modifier.align(Alignment.Center),
)
}
if (paused && !loading) {
Box(
Modifier
.align(Alignment.Center)
.size(72.dp)
.clip(CircleShape)
.background(Color(0x99000000)),
contentAlignment = Alignment.Center,
) {
Text("❚❚", color = Color.White, fontSize = 22.sp)
}
}
}
}
try {
context.startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse("https://www.youtube.com/watch?v=$key"),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
} catch (_: Exception) {
// ignore
}
}
@Composable
@ -142,25 +317,28 @@ private fun DetailBackdrop(url: String?) {
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
alignment = Alignment.TopCenter,
)
}
// Events-achtige cinematic scrims (VMC zwart)
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0f to Color(0xF20B0B0B),
0.42f to Color(0xCC0B0B0B),
0.72f to Color(0x660B0B0B),
1f to Color(0x220B0B0B),
0.0f to Color(0xF20B0B0B),
0.40f to Color(0xCC0B0B0B),
0.72f to Color(0x550B0B0B),
1.0f to Color(0x180B0B0B),
),
),
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0f to Color(0x660B0B0B),
0.35f to Color.Transparent,
0.7f to Color(0xAA0B0B0B),
1f to Color(0xFF0B0B0B),
0.0f to Color(0x66000000),
0.12f to Color.Transparent,
0.45f to Color.Transparent,
0.70f to Color(0xAA0B0B0B),
1.0f to Color(0xFF0B0B0B),
),
),
)
@ -168,15 +346,25 @@ private fun DetailBackdrop(url: String?) {
}
@Composable
private fun DetailLogoOrTitle(logoUrl: String?, title: String) {
private fun DetailLogoOrTitle(
logoUrl: String?,
title: String,
compact: Boolean = false,
) {
if (!logoUrl.isNullOrBlank()) {
AsyncImage(
model = logoUrl,
contentDescription = title,
modifier = Modifier
.widthIn(max = 420.dp)
.heightIn(max = 120.dp)
.fillMaxWidth(0.85f),
modifier = if (compact) {
Modifier
.heightIn(max = 56.dp)
.widthIn(max = 280.dp)
} else {
Modifier
.widthIn(max = 420.dp)
.heightIn(max = 100.dp)
.fillMaxWidth(0.85f)
},
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
)
@ -184,15 +372,43 @@ private fun DetailLogoOrTitle(logoUrl: String?, title: String) {
Text(
title,
color = Color.White,
fontSize = 40.sp,
fontSize = if (compact) 32.sp else 40.sp,
fontWeight = FontWeight.Bold,
maxLines = 3,
maxLines = if (compact) 2 else 3,
overflow = TextOverflow.Ellipsis,
lineHeight = 46.sp,
lineHeight = if (compact) 36.sp else 46.sp,
)
}
}
@Composable
private fun GenrePills(genres: List<String>) {
if (genres.isEmpty()) return
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
genres.take(4).forEach { genre ->
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(McColors.AccentSoft)
.border(BorderStroke(1.dp, Color(0x55E50914)), RoundedCornerShape(999.dp))
.padding(horizontal = 12.dp, vertical = 5.dp),
) {
Text(
genre.uppercase(),
color = Color(0xFFFFCDD2),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.8.sp,
maxLines = 1,
)
}
}
}
}
@Composable
private fun MetaChips(parts: List<String>) {
if (parts.isEmpty()) return
@ -207,8 +423,8 @@ private fun MetaChips(parts: List<String>) {
}
Text(
part,
color = if (part.startsWith("★")) Color(0xFFFFD54F) else McColors.TextDim,
fontSize = 14.sp,
color = if (part.startsWith("★")) Color(0xFFFFD54F) else Color(0xFFCFCFCF),
fontSize = 15.sp,
fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium,
)
}
@ -231,7 +447,7 @@ private fun DetailBackButton(onClick: () -> Unit) {
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White),
border = BorderStroke(1.dp, Color.White),
shape = RoundedCornerShape(4.dp),
),
),
@ -254,38 +470,43 @@ private fun DetailActionButton(
focusRequester: FocusRequester? = null,
onClick: () -> Unit,
) {
val bg = Color(0xE6181818)
val bgFocus = Color.White
val fg = Color.White
val shape = RoundedCornerShape(6.dp)
val idleBg = if (primary) McColors.Accent else Color(0xE6181818)
val focusBg = if (primary) Color.White else Color.White
val idleFg = Color.White
val focusFg = if (primary) McColors.Accent else Color.Black
Surface(
onClick = onClick,
modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
shape = ClickableSurfaceDefaults.shape(shape = shape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = bg,
focusedContainerColor = bgFocus,
contentColor = fg,
focusedContentColor = Color.Black,
containerColor = idleBg,
focusedContainerColor = focusBg,
contentColor = idleFg,
focusedContentColor = focusFg,
),
border = ClickableSurfaceDefaults.border(
border = Border(
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x55FFFFFF)),
shape = RoundedCornerShape(4.dp),
border = BorderStroke(
if (primary) 0.dp else 1.dp,
if (primary) Color.Transparent else Color(0x55FFFFFF),
),
shape = shape,
),
focusedBorder = Border(
border = androidx.compose.foundation.BorderStroke(2.dp, Color.White),
shape = RoundedCornerShape(4.dp),
border = BorderStroke(2.dp, if (primary) McColors.Accent else Color.White),
shape = shape,
),
),
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
color = Color.Unspecified,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
@ -345,7 +566,6 @@ fun MovieDetailScreen(
onBack: () -> Unit,
onPlay: (mediaFileId: String, title: String, startMs: Long) -> Unit,
) {
val context = LocalContext.current
var title by remember { mutableStateOf("…") }
var overview by remember { mutableStateOf("") }
var backdrop by remember { mutableStateOf<String?>(null) }
@ -358,13 +578,13 @@ fun MovieDetailScreen(
var cast by remember { mutableStateOf<List<String>>(emptyList()) }
var director by remember { mutableStateOf<List<String>>(emptyList()) }
var trailerKey by remember { mutableStateOf<String?>(null) }
var showTrailer by remember { mutableStateOf(false) }
var versions by remember { mutableStateOf<List<MediaVersion>>(emptyList()) }
var selectedVersionId by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var onWatchlist by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val playFocus = remember { FocusRequester() }
val scroll = rememberScrollState()
LaunchedEffect(movieId) {
try {
@ -426,115 +646,130 @@ fun MovieDetailScreen(
rating?.let { add("★ $it") }
}
val playLabel = selected?.let { "Afspelen · ${it.shortLabel}" } ?: "Afspelen"
val creditLine = buildList {
if (director.isNotEmpty()) add(director.first())
cast.take(3).forEach(::add)
}.joinToString(" · ")
BackHandler(onBack = onBack)
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
DetailBackdrop(backdrop)
Column(
// Fullscreen detail (geen topnav) → content start bovenaan, volle hoogte benutten
Row(
Modifier
.fillMaxHeight()
.fillMaxWidth(0.58f)
.padding(start = HomeChrome.EdgeInset, end = HomeChrome.EdgeInset, top = 24.dp, bottom = 24.dp)
.verticalScroll(scroll),
verticalArrangement = Arrangement.spacedBy(8.dp),
.fillMaxSize()
.padding(
start = HomeChrome.EdgeInset,
end = HomeChrome.EdgeInset,
top = 28.dp,
bottom = 32.dp,
),
horizontalArrangement = Arrangement.spacedBy(36.dp),
) {
DetailBackButton(onClick = onBack)
Spacer(Modifier.height(4.dp))
DetailLogoOrTitle(logoUrl, title)
MetaChips(metaParts)
if (endsAtLabel != null) {
Text(
endsAtLabel,
color = Color(0xFF9A9AA8),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
}
if (versions.size > 1) {
SelectedQualityLine(selected)
}
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
selected?.let { ver ->
DetailActionButton(playLabel, primary = true, focusRequester = playFocus) {
onPlay(ver.id, title, 0)
}
Column(
Modifier
.weight(1f)
.fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
DetailBackButton(onClick = onBack)
Spacer(Modifier.height(16.dp))
DetailLogoOrTitle(logoUrl, title, compact = true)
Spacer(Modifier.height(10.dp))
MetaChips(metaParts)
if (endsAtLabel != null) {
Spacer(Modifier.height(4.dp))
Text(
endsAtLabel,
color = Color(0xFFB8B8B8),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
}
if (!trailerKey.isNullOrBlank()) {
DetailActionButton("Trailer") {
openYoutubeTrailer(context, trailerKey!!)
}
if (overview.isNotBlank()) {
Spacer(Modifier.height(12.dp))
Text(
overview,
color = Color(0xFFD6D6D6),
fontSize = 15.sp,
lineHeight = 22.sp,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
scope.launch {
runCatching { onWatchlist = api.toggleFavorite("movie", movieId) }
}
if (creditLine.isNotBlank()) {
Spacer(Modifier.height(8.dp))
Text(
creditLine,
color = Color(0xFF8A8A8A),
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
DetailActionButton("Markeer als gezien") {
scope.launch {
runCatching { api.markWatched(movieId = movieId) }
onBack()
}
if (error != null) {
Spacer(Modifier.height(6.dp))
Text(error!!, color = McColors.Danger, fontSize = 13.sp, maxLines = 1)
}
}
if (overview.isNotBlank()) {
ExpandableOverview(overview)
}
if (director.isNotEmpty()) {
Text(
"Regie · ${director.take(2).joinToString(", ")}",
color = Color(0xFF9A9A9A),
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (cast.isNotEmpty()) {
Text(
"Met · ${cast.take(4).joinToString(", ")}",
color = Color(0xFF8A8A8A),
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (error != null) Text(error!!, color = McColors.Danger)
if (versions.size > 1) {
Spacer(Modifier.height(2.dp))
Text(
"Kwaliteit",
color = Color(0xFF8F8F8F),
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 0.6.sp,
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(end = 8.dp, bottom = 10.dp),
Spacer(Modifier.height(18.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
items(versions, key = { it.id }) { ver ->
VersionPickChip(
version = ver,
selected = ver.id == selected?.id,
onClick = { selectedVersionId = ver.id },
)
selected?.let { ver ->
DetailActionButton(playLabel, primary = true, focusRequester = playFocus) {
onPlay(ver.id, title, 0)
}
}
if (!trailerKey.isNullOrBlank()) {
DetailActionButton("Trailer") { showTrailer = true }
}
DetailActionButton(if (onWatchlist) "✓ Lijst" else "+ Lijst") {
scope.launch {
runCatching { onWatchlist = api.toggleFavorite("movie", movieId) }
}
}
}
if (versions.size > 1) {
Spacer(Modifier.height(12.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(versions, key = { it.id }) { ver ->
VersionPickChip(
version = ver,
selected = ver.id == selected?.id,
onClick = { selectedVersionId = ver.id },
)
}
}
}
}
if (!poster.isNullOrBlank()) {
AsyncImage(
model = poster,
contentDescription = null,
modifier = Modifier
.align(Alignment.Top)
.padding(top = 40.dp)
.width(180.dp)
.height(270.dp)
.shadow(22.dp, RoundedCornerShape(10.dp))
.clip(RoundedCornerShape(10.dp))
.border(BorderStroke(1.dp, Color(0x33FFFFFF)), RoundedCornerShape(10.dp))
.background(Color(0xFF1A1A1A)),
contentScale = ContentScale.Crop,
)
}
}
if (!poster.isNullOrBlank()) {
AsyncImage(
model = poster,
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 56.dp)
.width(200.dp)
.height(300.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF1A1A1A)),
contentScale = ContentScale.Crop,
if (showTrailer && !trailerKey.isNullOrBlank()) {
TrailerPlayerOverlay(
youtubeKey = trailerKey!!,
onDismiss = { showTrailer = false },
)
}
}
@ -585,7 +820,6 @@ fun SeriesDetailScreen(
onBack: () -> Unit,
onPlay: (mediaFileId: String, episodeId: String, title: String, startMs: Long) -> Unit,
) {
val context = LocalContext.current
var title by remember { mutableStateOf("…") }
var overview by remember { mutableStateOf("") }
var backdrop by remember { mutableStateOf<String?>(null) }
@ -594,6 +828,7 @@ fun SeriesDetailScreen(
var rating by remember { mutableStateOf<String?>(null) }
var genres by remember { mutableStateOf<List<String>>(emptyList()) }
var trailerKey by remember { mutableStateOf<String?>(null) }
var showTrailer by remember { mutableStateOf(false) }
var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) }
var selectedSeason by rememberSaveable(seriesId) { mutableStateOf<Int?>(null) }
var error by remember { mutableStateOf<String?>(null) }
@ -698,7 +933,6 @@ fun SeriesDetailScreen(
val metaParts = buildList {
year?.let { add(it.toString()) }
genres.take(3).forEach(::add)
rating?.let { add("★ $it") }
}
val playEpPreview = remember(episodes) {
@ -708,21 +942,22 @@ fun SeriesDetailScreen(
}
BackHandler(onBack = onBack)
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
DetailBackdrop(backdrop)
LazyColumn(
contentPadding = PaddingValues(
start = HomeChrome.EdgeInset,
end = HomeChrome.EdgeInset,
top = 28.dp,
top = HomeChrome.BadgeTop,
bottom = 40.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
DetailBackButton(onClick = onBack)
Spacer(Modifier.height(10.dp))
Spacer(Modifier.height(8.dp))
DetailLogoOrTitle(logoUrl, title)
GenrePills(genres)
MetaChips(metaParts)
if (overview.isNotBlank()) {
ExpandableOverview(
@ -732,7 +967,7 @@ fun SeriesDetailScreen(
}
if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
playEpPreview?.let { ep ->
val resume = !ep.completed && ep.positionMs > 30_000L
DetailActionButton(
@ -749,7 +984,7 @@ fun SeriesDetailScreen(
}
}
if (!trailerKey.isNullOrBlank()) {
DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) }
DetailActionButton("Trailer") { showTrailer = true }
}
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
scope.launch {
@ -759,7 +994,7 @@ fun SeriesDetailScreen(
}
if (seasons.size > 1) {
Spacer(Modifier.height(8.dp))
Text("Seizoenen", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Text("SEIZOENEN", color = Color(0xFF8F8F8F), fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
Spacer(Modifier.height(4.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(seasons) { s ->
@ -791,6 +1026,13 @@ fun SeriesDetailScreen(
}
}
if (showTrailer && !trailerKey.isNullOrBlank()) {
TrailerPlayerOverlay(
youtubeKey = trailerKey!!,
onDismiss = { showTrailer = false },
)
}
val menuEp = episodeMenu
if (menuEp != null) {
BackHandler { episodeMenu = null }

View file

@ -3,12 +3,29 @@ package nl.vonas.mediacluster.tv.ui
import android.view.ViewGroup
import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@ -20,6 +37,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
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
@ -31,25 +50,34 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.C
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Glow
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.playback.PlayerTrack
import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
import nl.vonas.mediacluster.tv.playback.disableTrackType
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
import nl.vonas.mediacluster.tv.playback.listTracks
import nl.vonas.mediacluster.tv.playback.liveMediaItem
import nl.vonas.mediacluster.tv.playback.selectTrack
import nl.vonas.mediacluster.tv.ui.theme.McColors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
private const val ReadyTimeoutMs = 20_000L
private const val ChromeHideMs = 5_000L
@OptIn(UnstableApi::class)
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
@ -58,6 +86,8 @@ fun EventPlayerScreen(
api: ApiClient,
eventId: String,
onBack: () -> Unit,
/** Hub-play (live + replay via /client/f1/content/…/play). */
hubPlay: Boolean = false,
) {
val context = LocalContext.current
val playerRef = remember { AtomicReference<ExoPlayer?>(null) }
@ -65,12 +95,38 @@ fun EventPlayerScreen(
buildLiveTvPlayer(context).also { playerRef.set(it) }
}
val rootFocus = remember { FocusRequester() }
val audioBtnFocus = remember { FocusRequester() }
val panelFocus = remember { FocusRequester() }
val reachedReady = remember { AtomicBoolean(false) }
var title by remember { mutableStateOf("Live event") }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var showChrome by remember { mutableStateOf(true) }
var watchSessionId by remember { mutableStateOf<String?>(null) }
var audioTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
var selectedAudioKey by remember { mutableStateOf<String?>(null) }
var selectedTextKey by remember { mutableStateOf<String?>(null) }
var textDisabled by remember { mutableStateOf(true) }
var menu by remember { mutableStateOf<String?>(null) } // audio | text
var chromeBump by remember { mutableStateOf(0) }
fun refreshTracks() {
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
selectedAudioKey = player.activeTrackKey(C.TRACK_TYPE_AUDIO)
selectedTextKey = player.activeTrackKey(C.TRACK_TYPE_TEXT)
textDisabled = player.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT) ||
selectedTextKey == null
}
fun revealChrome(focusControls: Boolean = false) {
showChrome = true
chromeBump++
if (focusControls && menu == null) {
runCatching { audioBtnFocus.requestFocus() }
}
}
fun safeStopAndClear() {
runCatching {
@ -80,14 +136,24 @@ fun EventPlayerScreen(
}
}
LaunchedEffect(eventId) {
LaunchedEffect(eventId, hubPlay) {
loading = true
error = null
watchSessionId = null
reachedReady.set(false)
showChrome = true
menu = null
audioTracks = emptyList()
textTracks = emptyList()
try {
val play = api.scheduleEventPlay(eventId)
val play = if (hubPlay) {
val contentId = Regex("""(?i)^f1tv:(\d+)$""").find(eventId.trim())
?.groupValues?.getOrNull(1)
?: eventId.trim().removePrefix("f1tv:").removePrefix("F1TV:")
api.f1tvContentPlay(contentId)
} else {
api.scheduleEventPlay(eventId)
}
if (!isActive) return@LaunchedEffect
title = play.name
watchSessionId = play.watchSessionId
@ -96,14 +162,11 @@ fun EventPlayerScreen(
error = "Geen stream-URL beschikbaar"
return@LaunchedEffect
}
if (play.drm == null || play.drm.keys.isEmpty()) {
error = "Encryptiesleutels ontbreken — stream start binnenkort"
return@LaunchedEffect
}
// ClearKey optioneel: F1TV plain HLS heeft drm=null.
val item = try {
liveMediaItem(url, play.format, play.drm)
} catch (e: Exception) {
error = e.message ?: "Ongeldige DRM-sleutels"
error = e.message ?: "Kon mediastream niet openen"
return@LaunchedEffect
}
if (!isActive) return@LaunchedEffect
@ -151,13 +214,27 @@ fun EventPlayerScreen(
}
}
LaunchedEffect(showChrome, error, loading) {
if (showChrome && error == null && !loading) {
delay(4_000)
LaunchedEffect(showChrome, menu, error, loading, chromeBump) {
if (showChrome && menu == null && error == null && !loading) {
delay(ChromeHideMs)
showChrome = false
}
}
LaunchedEffect(menu) {
if (menu != null) {
delay(60)
runCatching { panelFocus.requestFocus() }
} else {
delay(60)
if (showChrome) {
runCatching { audioBtnFocus.requestFocus() }
} else {
runCatching { rootFocus.requestFocus() }
}
}
}
LaunchedEffect(Unit) {
delay(80)
runCatching { rootFocus.requestFocus() }
@ -169,12 +246,22 @@ fun EventPlayerScreen(
when (state) {
Player.STATE_READY -> {
reachedReady.set(true)
runCatching { player.ensureAudioSelected() }
runCatching {
player.ensureAudioSelected()
refreshTracks()
}
}
else -> Unit
}
}
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
runCatching {
player.ensureAudioSelected()
refreshTracks()
}
}
override fun onPlayerError(e: PlaybackException) {
val cause = generateSequence(e as Throwable?) { it.cause }
.mapNotNull { it.message?.takeIf { m -> m.isNotBlank() } }
@ -200,7 +287,13 @@ fun EventPlayerScreen(
}
}
BackHandler(onBack = onBack)
BackHandler {
when {
menu != null -> menu = null
showChrome -> showChrome = false
else -> onBack()
}
}
Box(
Modifier
@ -209,13 +302,37 @@ fun EventPlayerScreen(
.focusRequester(rootFocus)
.focusable()
.onPreviewKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown &&
(ev.key == Key.Back || ev.key == Key.Escape)
) {
onBack()
true
} else {
false
if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when {
ev.key == Key.Back || ev.key == Key.Escape -> {
when {
menu != null -> {
menu = null
true
}
showChrome -> {
showChrome = false
true
}
else -> {
onBack()
true
}
}
}
menu != null -> false
ev.key == Key.Menu || ev.key == Key.Guide -> {
revealChrome(focusControls = true)
true
}
!showChrome -> {
revealChrome(focusControls = true)
true
}
else -> {
revealChrome()
false
}
}
},
) {
@ -266,15 +383,353 @@ fun EventPlayerScreen(
)
}
}
} else if (showChrome) {
} else {
AnimatedVisibility(
visible = showChrome || menu != null,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(
EventsLook.Scrim,
Color.Transparent,
Color.Transparent,
EventsLook.Scrim,
),
),
),
)
Column(
Modifier
.align(Alignment.TopStart)
.padding(horizontal = 32.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
Modifier
.background(EventsLook.Live, RoundedCornerShape(4.dp))
.padding(horizontal = 10.dp, vertical = 4.dp),
) {
Text(
"LIVE",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
)
}
Box(
Modifier
.size(width = 3.dp, height = 14.dp)
.background(EventsLook.Neon, RoundedCornerShape(2.dp)),
)
Text(
"EVENT",
color = EventsLook.Neon,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
)
}
Text(
title,
color = Color.White,
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
)
}
Column(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
EventPlayerChip(
label = "Audio",
subtitle = when {
audioTracks.isEmpty() -> "—"
else -> audioTracks.firstOrNull {
trackKey(it) == selectedAudioKey
}?.label ?: audioTracks.first().label
},
focusRequester = audioBtnFocus,
onClick = {
revealChrome()
menu = "audio"
},
)
EventPlayerChip(
label = "Ondertiteling",
subtitle = when {
textDisabled || textTracks.isEmpty() -> "Uit"
else -> textTracks.firstOrNull {
trackKey(it) == selectedTextKey
}?.label ?: "Aan"
},
onClick = {
revealChrome()
menu = "text"
},
)
}
Text(
"OK / Menu · audio & ondertiteling · Terug · sluiten",
color = Color(0x88FFFFFF),
fontSize = 12.sp,
)
}
}
}
AnimatedVisibility(
visible = menu != null,
enter = fadeIn() + slideInHorizontally { it / 3 },
exit = fadeOut() + slideOutHorizontally { it / 3 },
) {
val isAudio = menu == "audio"
val tracks = if (isAudio) audioTracks else textTracks
Box(
Modifier
.fillMaxSize()
.background(Color(0x99050A08)),
contentAlignment = Alignment.CenterEnd,
) {
Column(
Modifier
.fillMaxHeight()
.width(420.dp)
.background(EventsLook.Panel)
.border(
width = 1.dp,
brush = Brush.verticalGradient(
listOf(EventsLook.NeonGlow, Color.Transparent, EventsLook.NeonSoft),
),
shape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp),
)
.padding(horizontal = 24.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
if (isAudio) "Audio" else "Ondertiteling",
color = Color.White,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
Box(
Modifier
.padding(top = 2.dp, bottom = 10.dp)
.width(40.dp)
.height(3.dp)
.background(EventsLook.Neon, RoundedCornerShape(2.dp)),
)
if (!isAudio) {
EventTrackRow(
label = "Uit",
selected = textDisabled || tracks.isEmpty(),
focusRequester = panelFocus,
onClick = {
player.disableTrackType(C.TRACK_TYPE_TEXT)
textDisabled = true
selectedTextKey = null
menu = null
revealChrome()
},
)
}
if (tracks.isEmpty()) {
Text(
if (isAudio) "Geen audiosporen in deze stream" else "Geen ondertitels in deze stream",
color = McColors.TextDim,
fontSize = 14.sp,
modifier = Modifier.padding(vertical = 8.dp),
)
}
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(tracks, key = { trackKey(it) }) { track ->
val selected = if (isAudio) {
trackKey(track) == selectedAudioKey
} else {
!textDisabled && trackKey(track) == selectedTextKey
}
EventTrackRow(
label = track.label,
selected = selected,
focusRequester = if (isAudio && tracks.firstOrNull() == track) {
panelFocus
} else {
null
},
onClick = {
player.selectTrack(track)
if (isAudio) {
selectedAudioKey = trackKey(track)
} else {
textDisabled = false
selectedTextKey = trackKey(track)
}
menu = null
revealChrome()
},
)
}
}
Spacer(Modifier.height(8.dp))
EventPlayerChip(
label = "Sluiten",
subtitle = null,
compact = true,
onClick = { menu = null },
)
}
}
}
}
}
}
private fun trackKey(track: PlayerTrack): String = "${track.groupIndex}:${track.trackIndex}"
private fun ExoPlayer.activeTrackKey(type: Int): String? {
val groups = currentTracks.groups
for (gi in 0 until groups.size) {
val group = groups[gi]
if (group.type != type || !group.isSelected) continue
for (ti in 0 until group.length) {
if (group.isTrackSelected(ti)) return "$gi:$ti"
}
}
return null
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun EventPlayerChip(
label: String,
subtitle: String?,
focusRequester: FocusRequester? = null,
compact: Boolean = false,
onClick: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
val mod = Modifier
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged { focused = it.isFocused }
Surface(
onClick = onClick,
modifier = mod,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
glow = ClickableSurfaceDefaults.glow(
focusedGlow = Glow(
elevationColor = EventsLook.Neon,
elevation = if (focused) 12.dp else 0.dp,
),
),
border = ClickableSurfaceDefaults.border(
focusedBorder = androidx.tv.material3.Border(
border = androidx.compose.foundation.BorderStroke(2.dp, EventsLook.Neon),
shape = RoundedCornerShape(12.dp),
),
),
colors = ClickableSurfaceDefaults.colors(
containerColor = EventsLook.PanelHot,
focusedContainerColor = EventsLook.NeonSoft,
contentColor = Color.White,
focusedContentColor = Color.White,
),
) {
Column(
Modifier.padding(
horizontal = if (compact) 18.dp else 20.dp,
vertical = if (compact) 12.dp else 14.dp,
),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
title,
color = Color.White,
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.align(Alignment.TopStart)
.padding(28.dp),
label,
color = if (focused) EventsLook.Neon else Color.White,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
)
if (subtitle != null) {
Text(
subtitle,
color = if (focused) Color(0xCCFFFFFF) else McColors.TextDim,
fontSize = 12.sp,
maxLines = 1,
)
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun EventTrackRow(
label: String,
selected: Boolean,
focusRequester: FocusRequester? = null,
onClick: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
val mod = Modifier
.fillMaxWidth()
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged { focused = it.isFocused }
Surface(
onClick = onClick,
modifier = mod,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
colors = ClickableSurfaceDefaults.colors(
containerColor = when {
selected -> EventsLook.NeonSoft
else -> Color.Transparent
},
focusedContainerColor = EventsLook.PanelHot,
contentColor = Color.White,
focusedContentColor = Color.White,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = androidx.tv.material3.Border(
border = androidx.compose.foundation.BorderStroke(1.5.dp, EventsLook.Neon),
shape = RoundedCornerShape(10.dp),
),
),
) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
if (selected) "●" else "○",
color = if (selected || focused) EventsLook.Neon else Color(0x55FFFFFF),
fontSize = 12.sp,
)
Text(
label,
color = Color.Unspecified,
fontSize = 15.sp,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
)
}
}

View file

@ -119,6 +119,7 @@ fun EventsScreen(
pendingFocusRestore: Boolean = false,
onFocusRestoreConsumed: () -> Unit = {},
onOpenEvent: (String) -> Unit,
onOpenF1Hub: (() -> Unit)? = null,
onContentFocused: () -> Unit = {},
) {
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
@ -224,10 +225,13 @@ fun EventsScreen(
else -> null
}
// Focus: TopNav → één filterrij (sport + bron) → first rail.
// Focus: TopNav → F1-tile (optioneel) → filterrij → first rail.
val hasF1Entry = onOpenF1Hub != null
val showFilterBar = showSportChips || showProviderChips
val filterBarFocus = remember { FocusRequester() }
val firstRailUpTarget = when {
showFilterBar && filterBarFocusReady -> contentFocusRequester
showFilterBar && filterBarFocusReady -> filterBarFocus
hasF1Entry -> contentFocusRequester
else -> navFocusRequester
}
val filterFocusDown: FocusRequester? = when {
@ -235,6 +239,7 @@ fun EventsScreen(
railFocusReady -> firstRailFocus
else -> null
}
val railUsesDedicatedFocus = hasF1Entry || showFilterBar
fun claimRailFocus(owner: String, ready: Boolean) {
if (ready) {
@ -265,6 +270,7 @@ fun EventsScreen(
}
val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0
val restoreListIndex = remember(
hasF1Entry,
showFilterBar,
live,
upcomingRails,
@ -273,6 +279,7 @@ fun EventsScreen(
) {
if (!canRestoreCard) return@remember -1
var idx = 0
if (hasF1Entry) idx++
if (showFilterBar) idx++
if (restoreInLive) return@remember idx
if (live.isNotEmpty()) idx++
@ -295,7 +302,7 @@ fun EventsScreen(
val activeRestoreFocus = when {
!canRestoreCard -> null
restoreIsFirstOfFirstRail -> when {
showFilterBar -> firstRailFocus
railUsesDedicatedFocus -> firstRailFocus
else -> contentFocusRequester
}
else -> restoreCardFocus
@ -313,12 +320,14 @@ fun EventsScreen(
}
val entryReady = when {
hasF1Entry -> true
showFilterBar -> filterBarFocusReady
else -> railFocusReady
}
val needContentSink = when {
loading && events.isEmpty() -> true
error != null && events.isEmpty() -> true
hasF1Entry -> false
showFilterBar -> !filterBarFocusReady
!hasRails -> true
else -> !railFocusReady
@ -343,6 +352,7 @@ fun EventsScreen(
if (restoredSuccessfully) return
val entryOk = awaitReady({
when {
hasF1Entry -> true
showFilterBar -> filterBarFocusReady
else -> railFocusReady
}
@ -465,6 +475,16 @@ fun EventsScreen(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 56.dp),
) {
if (hasF1Entry) {
item(key = "f1_hub_entry") {
F1HubEntryTile(
focusRequester = contentFocusRequester,
focusUp = navFocusRequester,
onOpen = onOpenF1Hub!!,
onFocus = onContentFocused,
)
}
}
// Eén filterrij (sport + bron) — altijd aanwezig bij data zodat focus stabiel blijft.
if (showFilterBar) {
item(key = "filters") {
@ -478,8 +498,8 @@ fun EventsScreen(
onProviderSelect = { providerFilter = it },
liveCount = live.size,
totalCount = filtered.size,
firstFocusRequester = contentFocusRequester,
focusUp = navFocusRequester,
firstFocusRequester = if (hasF1Entry) filterBarFocus else contentFocusRequester,
focusUp = if (hasF1Entry) contentFocusRequester else navFocusRequester,
focusDown = filterFocusDown,
onFocusReady = { filterBarFocusReady = it },
onFocus = onContentFocused,
@ -511,7 +531,7 @@ fun EventsScreen(
style = EventRailStyle.Live,
onOpen = onOpenEvent,
onFocus = onContentFocused,
firstItemFocusRequester = if (showFilterBar) {
firstItemFocusRequester = if (railUsesDedicatedFocus) {
firstRailFocus
} else {
contentFocusRequester
@ -545,7 +565,7 @@ fun EventsScreen(
onFocus = onContentFocused,
firstItemFocusRequester = when {
!isFirstUpcoming -> null
showFilterBar -> firstRailFocus
railUsesDedicatedFocus -> firstRailFocus
else -> contentFocusRequester
},
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
@ -579,7 +599,7 @@ fun EventsScreen(
onFocus = onContentFocused,
firstItemFocusRequester = when {
!isFirstUpcoming -> null
showFilterBar -> firstRailFocus
railUsesDedicatedFocus -> firstRailFocus
else -> contentFocusRequester
},
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
@ -1056,6 +1076,96 @@ private fun PulsingLiveDot(size: androidx.compose.ui.unit.Dp = 10.dp) {
}
}
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
private fun F1HubEntryTile(
focusRequester: FocusRequester?,
focusUp: FocusRequester?,
onOpen: () -> Unit,
onFocus: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
val f1Red = Color(0xFFE10600)
Surface(
onClick = onOpen,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = HomeChrome.EdgeInset)
.padding(top = 8.dp, bottom = 10.dp)
.height(72.dp)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.then(
if (focusUp != null) {
Modifier.focusProperties { up = focusUp }
} else {
Modifier
},
)
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) onFocus()
},
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
glow = ClickableSurfaceDefaults.glow(
focusedGlow = Glow(elevationColor = f1Red, elevation = 14.dp),
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.5.dp, f1Red),
inset = 4.dp,
shape = RoundedCornerShape(16.dp),
),
),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF141414),
focusedContainerColor = Color(0xFF1E1212),
),
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
) {
Row(
Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.background(f1Red),
contentAlignment = Alignment.Center,
) {
Text(
"F1",
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.Black,
)
}
Column(Modifier.weight(1f)) {
Text(
"Formule 1",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
)
Text(
"Agenda & terugkijken",
color = McColors.TextDim,
fontSize = 13.sp,
)
}
Text(
"Open →",
color = if (focused) f1Red else McColors.TextDim,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
private fun EventCard(

File diff suppressed because it is too large Load diff

View file

@ -131,6 +131,10 @@ fun McNav() {
var eventsReturnNonce by remember { mutableIntStateOf(0) }
var lastOpenedEventId by rememberSaveable { mutableStateOf<String?>(null) }
var pendingEventsFocusRestore by remember { mutableStateOf(false) }
var homeReturnNonce by remember { mutableIntStateOf(0) }
var lastOpenedHomeKey by rememberSaveable { mutableStateOf<String?>(null) }
var pendingHomeFocusRestore by remember { mutableStateOf(false) }
var homeDetailFromHome by remember { mutableStateOf(false) }
val favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet())
val hubTab = remember(hubTabName) {
MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home
@ -328,12 +332,21 @@ fun McNav() {
restoreEventId = lastOpenedEventId,
pendingEventsFocusRestore = pendingEventsFocusRestore,
onEventsFocusRestoreConsumed = { pendingEventsFocusRestore = false },
homeReturnNonce = homeReturnNonce,
restoreHomeKey = lastOpenedHomeKey,
pendingHomeFocusRestore = pendingHomeFocusRestore,
onHomeFocusRestoreConsumed = { pendingHomeFocusRestore = false },
onHomeItemOpened = { key ->
lastOpenedHomeKey = key
homeDetailFromHome = true
},
profileAvatarId = profileAvatarId,
profileShowAvatar = profileShowAvatar,
profileAvatarUrl = profileAvatarUrl,
profileName = profileName,
onOpenMovie = { nav.navigate("movie/$it") },
onOpenSeries = { nav.navigate("series/$it") },
onLeaveHomeDetailOrigin = { homeDetailFromHome = false },
onBrowse = { catalogId, _title ->
nav.navigate("browse/$catalogId")
},
@ -352,6 +365,11 @@ fun McNav() {
lastOpenedEventId = eventId
nav.navigate("event/${android.net.Uri.encode(eventId)}")
},
onOpenF1Hub = {
nav.navigate("f1_hub") {
launchSingleTop = true
}
},
onSwitchProfile = {
scope.launch {
runCatching { api.clearProfile() }
@ -389,8 +407,14 @@ fun McNav() {
api = api,
catalogId = catalogId,
onBack = { nav.popBackStack() },
onOpenMovie = { nav.navigate("movie/$it") },
onOpenSeries = { nav.navigate("series/$it") },
onOpenMovie = {
homeDetailFromHome = false
nav.navigate("movie/$it")
},
onOpenSeries = {
homeDetailFromHome = false
nav.navigate("series/$it")
},
onPlayContinue = if (catalogId == "continue-watching") {
{ mediaFileId, movieId, episodeId, seriesId, type, startMs ->
val m = movieId?.ifBlank { null } ?: "-"
@ -405,6 +429,14 @@ fun McNav() {
}
composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
val id = entry.arguments?.getString("id") ?: return@composable
DisposableEffect(Unit) {
onDispose {
if (homeDetailFromHome) {
pendingHomeFocusRestore = true
homeReturnNonce++
}
}
}
MovieDetailScreen(
api = api,
movieId = id,
@ -416,6 +448,14 @@ fun McNav() {
}
composable("series/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
val id = entry.arguments?.getString("id") ?: return@composable
DisposableEffect(Unit) {
onDispose {
if (homeDetailFromHome) {
pendingHomeFocusRestore = true
homeReturnNonce++
}
}
}
SeriesDetailScreen(
api = api,
seriesId = id,
@ -518,6 +558,72 @@ fun McNav() {
onBack = { nav.popBackStack() },
)
}
composable("f1_hub") {
DisposableEffect(Unit) {
onDispose {
pendingEventsFocusRestore = true
eventsReturnNonce++
}
}
F1HubScreen(
api = api,
onBack = { nav.popBackStack() },
onOpenMeeting = { key ->
nav.navigate("f1_meeting/${android.net.Uri.encode(key)}") {
launchSingleTop = true
}
},
onPlay = { contentId ->
nav.navigate("f1_play/${android.net.Uri.encode(contentId)}") {
launchSingleTop = true
}
},
)
}
composable(
"f1_meeting/{meetingKey}",
arguments = listOf(navArgument("meetingKey") { type = NavType.StringType }),
) { entry ->
val meetingKey = entry.arguments?.getString("meetingKey")
?.let { android.net.Uri.decode(it) }
?: return@composable
DisposableEffect(Unit) {
onDispose {
pendingEventsFocusRestore = true
eventsReturnNonce++
}
}
F1MeetingScreen(
api = api,
meetingKey = meetingKey,
onBack = { nav.popBackStack() },
onPlay = { contentId ->
nav.navigate("f1_play/${android.net.Uri.encode(contentId)}") {
launchSingleTop = true
}
},
)
}
composable(
"f1_play/{contentId}",
arguments = listOf(navArgument("contentId") { type = NavType.StringType }),
) { entry ->
val contentId = entry.arguments?.getString("contentId")
?.let { android.net.Uri.decode(it) }
?: return@composable
DisposableEffect(Unit) {
onDispose {
pendingEventsFocusRestore = true
eventsReturnNonce++
}
}
EventPlayerScreen(
api = api,
eventId = "f1tv:$contentId",
hubPlay = true,
onBack = { nav.popBackStack() },
)
}
}
}
@ -543,6 +649,12 @@ private fun MainHub(
restoreEventId: String? = null,
pendingEventsFocusRestore: Boolean = false,
onEventsFocusRestoreConsumed: () -> Unit = {},
homeReturnNonce: Int = 0,
restoreHomeKey: String? = null,
pendingHomeFocusRestore: Boolean = false,
onHomeFocusRestoreConsumed: () -> Unit = {},
onHomeItemOpened: (String) -> Unit = {},
onLeaveHomeDetailOrigin: () -> Unit = {},
profileAvatarId: String?,
profileShowAvatar: Boolean = true,
profileAvatarUrl: String? = null,
@ -553,6 +665,7 @@ private fun MainHub(
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
onPlayLive: (String) -> Unit,
onOpenEvent: (String) -> Unit,
onOpenF1Hub: () -> Unit = {},
onSwitchProfile: () -> Unit,
onManageProfiles: () -> Unit,
onLogout: () -> Unit,
@ -719,6 +832,15 @@ private fun MainHub(
runCatching { eventsContentFocus.requestFocus() }
}
// Terug van Movie/Series detail vanaf Home: content-focus, card-restore in HomeScreen.
LaunchedEffect(homeReturnNonce) {
if (homeReturnNonce <= 0 || tab != MainTab.Home) return@LaunchedEffect
navFocused = false
if (pendingHomeFocusRestore) return@LaunchedEffect
delay(100)
runCatching { homeContentFocus.requestFocus() }
}
// Reset scroll alleen bij echte tab-wissel naar Home (niet bij terugkeer uit detail/player)
LaunchedEffect(tab) {
val name = tab.name
@ -862,10 +984,20 @@ private fun MainHub(
listState = homeListState,
contentFocusRequester = homeContentFocus,
navFocusRequester = navFocus,
focusRestoreNonce = homeReturnNonce,
restoreHomeKey = restoreHomeKey,
pendingFocusRestore = pendingHomeFocusRestore,
onFocusRestoreConsumed = onHomeFocusRestoreConsumed,
onContentFocused = { navFocused = false },
onChromeCompact = { homeChromeCompact = it },
onOpenMovie = onOpenMovie,
onOpenSeries = onOpenSeries,
onOpenMovie = { id ->
onHomeItemOpened("movie:$id")
onOpenMovie(id)
},
onOpenSeries = { id ->
onHomeItemOpened("series:$id")
onOpenSeries(id)
},
onSeeAll = onBrowse,
onPlay = onPlay,
)
@ -873,8 +1005,8 @@ private fun MainHub(
api = api,
gridState = watchlistGridState,
onAtTopBack = { focusTopNav() },
onOpenMovie = onOpenMovie,
onOpenSeries = onOpenSeries,
onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) },
onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) },
)
MainTab.Movies -> BrowseScreen(
api = api,
@ -882,8 +1014,8 @@ private fun MainHub(
titleHint = movieSubs.firstOrNull { it.id == movieSubId }?.label ?: "Films",
gridState = moviesGridState,
onAtTopBack = { focusTopNav() },
onOpenMovie = onOpenMovie,
onOpenSeries = onOpenSeries,
onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) },
onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) },
)
MainTab.Series -> BrowseScreen(
api = api,
@ -891,8 +1023,8 @@ private fun MainHub(
titleHint = seriesSubs.firstOrNull { it.id == seriesSubId }?.label ?: "Series",
gridState = seriesGridState,
onAtTopBack = { focusTopNav() },
onOpenMovie = onOpenMovie,
onOpenSeries = onOpenSeries,
onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) },
onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) },
)
MainTab.Tv -> TvScreen(
api = api,
@ -931,12 +1063,13 @@ private fun MainHub(
pendingFocusRestore = pendingEventsFocusRestore,
onFocusRestoreConsumed = onEventsFocusRestoreConsumed,
onOpenEvent = onOpenEvent,
onOpenF1Hub = onOpenF1Hub,
onContentFocused = { navFocused = false },
)
MainTab.Search -> SearchScreen(
api = api,
onOpenMovie = onOpenMovie,
onOpenSeries = onOpenSeries,
onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) },
onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) },
onAtTopBack = { focusTopNav() },
)
MainTab.Settings -> SettingsScreen(
@ -1163,6 +1296,10 @@ private fun HomeScreen(
listState: androidx.compose.foundation.lazy.LazyListState,
contentFocusRequester: FocusRequester,
navFocusRequester: FocusRequester,
focusRestoreNonce: Int = 0,
restoreHomeKey: String? = null,
pendingFocusRestore: Boolean = false,
onFocusRestoreConsumed: () -> Unit = {},
onContentFocused: () -> Unit,
onChromeCompact: (Boolean) -> Unit = {},
onOpenMovie: (String) -> Unit,
@ -1177,12 +1314,14 @@ private fun HomeScreen(
var menuReady by remember { mutableStateOf(false) }
var shelfFocusReady by remember { mutableStateOf(false) }
var playFocusReady by remember { mutableStateOf(false) }
var restoreCardReady by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val menuFocus = remember { FocusRequester() }
val context = LocalContext.current
val store = remember { SessionStore(context) }
val density = LocalDensity.current
val firstShelfFocus = remember { FocusRequester() }
val restoreCardFocus = remember { FocusRequester() }
suspend fun reloadHome() {
val fresh = api.home()
@ -1219,25 +1358,102 @@ private fun HomeScreen(
}
}
// Bij terugkeer van detail: geen jump naar Afspelen/top. Alleen content-focus als we gescrolld waren.
LaunchedEffect(Unit) {
delay(80)
val scrolled = listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 40
if (scrolled) {
onContentFocused()
val deadline = System.currentTimeMillis() + 700
while (!shelfFocusReady && System.currentTimeMillis() < deadline) {
val continueItems = home?.continueWatching.orEmpty()
val visibleShelves = remember(home?.shelves) {
home?.shelves.orEmpty().filter { it.items.size >= 3 }
}
data class HomeRestoreTarget(
val listIndex: Int,
val shelfId: String?,
val itemIndex: Int,
val isFirstOfFirstShelf: Boolean,
)
fun MediaItem.detailKey(): String = when {
type.equals("movie", true) -> "movie:$id"
type.equals("episode", true) && !seriesId.isNullOrBlank() -> "series:$seriesId"
else -> "series:$id"
}
val restoreTarget = remember(home, continueItems, visibleShelves, restoreHomeKey, error) {
if (restoreHomeKey.isNullOrBlank()) return@remember null
var listIndex = 1 // hero-spacer
if (error != null) listIndex++
if (continueItems.isNotEmpty()) {
val preview = continueItems.take(5)
val itemIndex = preview.indexOfFirst { it.detailKey() == restoreHomeKey }
if (itemIndex >= 0) {
return@remember HomeRestoreTarget(
listIndex = listIndex,
shelfId = "__continue__",
itemIndex = itemIndex,
isFirstOfFirstShelf = itemIndex == 0,
)
}
listIndex++
}
visibleShelves.forEachIndexed { shelfPos, shelf ->
val itemIndex = shelf.items.indexOfFirst { it.detailKey() == restoreHomeKey }
if (itemIndex >= 0) {
val isFirstShelf = continueItems.isEmpty() && shelfPos == 0
return@remember HomeRestoreTarget(
listIndex = listIndex + shelfPos,
shelfId = shelf.id,
itemIndex = itemIndex,
isFirstOfFirstShelf = isFirstShelf && itemIndex == 0,
)
}
}
null
}
val bindRestoreKey = restoreHomeKey.takeIf {
pendingFocusRestore && restoreTarget != null && restoreTarget?.isFirstOfFirstShelf != true
}
// Bij terugkeer van detail: focus op de geopende card (of eerste plank als fallback).
LaunchedEffect(
pendingFocusRestore,
focusRestoreNonce,
restoreTarget,
home != null,
) {
if (!pendingFocusRestore || home == null) return@LaunchedEffect
delay(60)
onContentFocused()
val target = restoreTarget
if (target != null) {
runCatching { listState.scrollToItem(target.listIndex) }
delay(40)
val focusTarget = if (target.isFirstOfFirstShelf) firstShelfFocus else restoreCardFocus
val ready = if (target.isFirstOfFirstShelf) {
{ shelfFocusReady }
} else {
{ restoreCardReady }
}
val deadline = System.currentTimeMillis() + 900
while (!ready() && System.currentTimeMillis() < deadline) {
delay(40)
}
delay(40)
runCatching { firstShelfFocus.requestFocus() }
if (runCatching { focusTarget.requestFocus() }.isSuccess) {
onFocusRestoreConsumed()
return@LaunchedEffect
}
}
// Fallback: eerste zichtbare plank
val deadline = System.currentTimeMillis() + 500
while (!shelfFocusReady && System.currentTimeMillis() < deadline) {
delay(40)
}
runCatching { firstShelfFocus.requestFocus() }
onFocusRestoreConsumed()
}
val spotlight = home?.spotlight.orEmpty().ifEmpty {
home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty()
}
val continueItems = home?.continueWatching.orEmpty()
val screenH = LocalConfiguration.current.screenHeightDp.dp
// Hero vult ~78% scherm; planken starten op ~52% → overlappen de fade
@ -1387,6 +1603,9 @@ private fun HomeScreen(
firstItemFocusRequester = firstShelfFocus,
focusUpTo = contentFocusRequester.takeIf { playFocusReady },
onFirstFocusTargetReady = { shelfFocusReady = it },
restoreItemKey = bindRestoreKey.takeIf { restoreTarget?.shelfId == "__continue__" },
restoreFocusRequester = restoreCardFocus.takeIf { restoreTarget?.shelfId == "__continue__" },
onRestoreFocusReady = { restoreCardReady = it },
onRowFocused = {
onContentFocused()
expandHeroFully()
@ -1417,16 +1636,16 @@ private fun HomeScreen(
}
}
items(
home?.shelves.orEmpty().filter { it.items.size >= 3 },
visibleShelves,
key = { it.id },
) { shelf ->
val catalogId = when (shelf.slug) {
"recent-movies", "recent-series", "4k-movies" -> shelf.slug
else -> shelf.slug.ifBlank { shelf.id }
}
val firstVisibleShelfId = home?.shelves.orEmpty()
.firstOrNull { it.items.size >= 3 }?.id
val firstVisibleShelfId = visibleShelves.firstOrNull()?.id
val isFirstShelf = continueItems.isEmpty() && shelf.id == firstVisibleShelfId
val shelfHasRestore = restoreTarget?.shelfId == shelf.id
ShelfBlock(
title = shelf.title,
items = shelf.items,
@ -1437,6 +1656,13 @@ private fun HomeScreen(
} else {
null
},
restoreItemKey = bindRestoreKey.takeIf { shelfHasRestore },
restoreFocusRequester = restoreCardFocus.takeIf { shelfHasRestore },
onRestoreFocusReady = if (shelfHasRestore) {
{ restoreCardReady = it }
} else {
null
},
onRowFocused = {
onContentFocused()
if (isFirstShelf) expandHeroFully()
@ -1708,9 +1934,29 @@ private fun ShelfBlock(
firstItemFocusRequester: FocusRequester? = null,
focusUpTo: FocusRequester? = null,
onFirstFocusTargetReady: ((Boolean) -> Unit)? = null,
restoreItemKey: String? = null,
restoreFocusRequester: FocusRequester? = null,
onRestoreFocusReady: ((Boolean) -> Unit)? = null,
modifier: Modifier = Modifier,
) {
var rowFocused by remember { mutableStateOf(false) }
val rowState = rememberLazyListState()
val restoreIndex = remember(items, restoreItemKey) {
if (restoreItemKey.isNullOrBlank()) -1
else items.indexOfFirst { item ->
val key = when {
item.type.equals("movie", true) -> "movie:${item.id}"
item.type.equals("episode", true) && !item.seriesId.isNullOrBlank() -> "series:${item.seriesId}"
else -> "series:${item.id}"
}
key == restoreItemKey
}
}
LaunchedEffect(restoreIndex) {
if (restoreIndex > 0) {
runCatching { rowState.scrollToItem(restoreIndex) }
}
}
Column(
modifier
.onFocusChanged {
@ -1757,6 +2003,7 @@ private fun ShelfBlock(
}
}
LazyRow(
state = rowState,
horizontalArrangement = Arrangement.spacedBy(12.dp),
// Extra ruimte zodat focusedScale (1.10) posters niet afknipt
contentPadding = PaddingValues(
@ -1767,23 +2014,40 @@ private fun ShelfBlock(
),
) {
itemsIndexed(items, key = { _, it -> "${it.type}:${it.id}:${it.progressId}" }) { index, item ->
if (index == 0 && firstItemFocusRequester != null) {
val itemKey = when {
item.type.equals("movie", true) -> "movie:${item.id}"
item.type.equals("episode", true) && !item.seriesId.isNullOrBlank() -> "series:${item.seriesId}"
else -> "series:${item.id}"
}
val isRestore = restoreItemKey != null && itemKey == restoreItemKey
val cardRequester = when {
isRestore && restoreFocusRequester != null -> restoreFocusRequester
index == 0 -> firstItemFocusRequester
else -> null
}
if (index == 0 && firstItemFocusRequester != null && cardRequester === firstItemFocusRequester) {
DisposableEffect(firstItemFocusRequester) {
onFirstFocusTargetReady?.invoke(true)
onDispose { onFirstFocusTargetReady?.invoke(false) }
}
}
if (isRestore && restoreFocusRequester != null && cardRequester === restoreFocusRequester) {
DisposableEffect(restoreFocusRequester) {
onRestoreFocusReady?.invoke(true)
onDispose { onRestoreFocusReady?.invoke(false) }
}
}
PosterCard(
item = item,
onClick = { onClick(item) },
onLongClick = onLongClick?.let { handler -> { handler(item) } },
showCaption = showCaptions,
modifier = Modifier.then(
if (index == 0 && firstItemFocusRequester != null) {
if (cardRequester != null) {
Modifier
.focusRequester(firstItemFocusRequester)
.focusRequester(cardRequester)
.then(
if (focusUpTo != null) {
if (focusUpTo != null && (index == 0 || isRestore)) {
Modifier.focusProperties { up = focusUpTo }
} else {
Modifier

View file

@ -20,6 +20,7 @@
A11C1EAF0000000000000002 /* AesCtr.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000002 /* AesCtr.swift */; };
A11C1EAF0000000000000003 /* CencMp4Decryptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */; };
A11C1EAF0000000000000004 /* ClearKeyDashProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */; };
A11C1EAF0000000000000005 /* AVLivePlayerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000005 /* AVLivePlayerController.swift */; };
F8AE29FF63524E7D8C036C7E /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 522137D80C53480E95FF600F /* RootView.swift */; };
D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5E450343884C11B0920888 /* MainShell.swift */; };
1EF110B9F82E440F999ECE37 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B20E94E6B4034B1EF282B /* Components.swift */; };
@ -55,6 +56,7 @@
B11C1EAF0000000000000002 /* AesCtr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AesCtr.swift; sourceTree = "<group>"; };
B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CencMp4Decryptor.swift; sourceTree = "<group>"; };
B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearKeyDashProxy.swift; sourceTree = "<group>"; };
B11C1EAF0000000000000005 /* AVLivePlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVLivePlayerController.swift; sourceTree = "<group>"; };
522137D80C53480E95FF600F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
BF5E450343884C11B0920888 /* MainShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainShell.swift; sourceTree = "<group>"; };
314B20E94E6B4034B1EF282B /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
@ -163,6 +165,7 @@
B11C1EAF0000000000000002 /* AesCtr.swift */,
B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */,
B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */,
B11C1EAF0000000000000005 /* AVLivePlayerController.swift */,
);
path = Playback;
sourceTree = "<group>";
@ -338,7 +341,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 49;
CURRENT_PROJECT_VERSION = 52;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@ -353,7 +356,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
MARKETING_VERSION = 0.5.36;
MARKETING_VERSION = 0.5.39;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -371,7 +374,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 49;
CURRENT_PROJECT_VERSION = 52;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@ -386,7 +389,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
MARKETING_VERSION = 0.5.36;
MARKETING_VERSION = 0.5.39;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -0,0 +1,417 @@
import AVFoundation
import AVKit
import Combine
import SwiftUI
struct AVMediaOptionItem: Identifiable, Hashable {
let id: String
let name: String
}
/// Native AVPlayer voor ClearKey-live via lokale clear-HLS proxy (on-device decrypt).
@MainActor
final class AVLivePlayerController: NSObject, ObservableObject {
@Published var isPlaying = false
@Published var stateText = "Laden…"
@Published var hasError = false
@Published var errorMessage: String?
/// True als AVPlayer minstens één video-track / presentatiegrootte heeft.
@Published var hasVideo = false
@Published var audioOptions: [AVMediaOptionItem] = []
@Published var subtitleOptions: [AVMediaOptionItem] = []
@Published var currentAudioId: String?
@Published var currentSubtitleId: String?
let player = AVPlayer()
private var proxySession = 0
private var proxyActive = false
private var timeControlObs: NSKeyValueObservation?
private var itemStatusObs: NSKeyValueObservation?
private var endObs: NSObjectProtocol?
private var errorLogObs: NSObjectProtocol?
private var stopping = false
private var generation = 0
private var audioGroup: AVMediaSelectionGroup?
private var subtitleGroup: AVMediaSelectionGroup?
private var audioOptionById: [String: AVMediaSelectionOption] = [:]
private var subtitleOptionById: [String: AVMediaSelectionOption] = [:]
override init() {
super.init()
configureAudioSession()
}
func play(
url: URL,
fallback: URL? = nil,
format: String? = nil,
clearKeys: [ClearKeyEntry] = []
) async {
generation &+= 1
let gen = generation
stopping = false
hasError = false
errorMessage = nil
hasVideo = false
audioOptions = []
subtitleOptions = []
currentAudioId = nil
currentSubtitleId = nil
audioGroup = nil
subtitleGroup = nil
audioOptionById = [:]
subtitleOptionById = [:]
stateText = "Live laden…"
stopProxyIfNeeded()
player.pause()
player.replaceCurrentItem(with: nil)
var playURL = url
if !clearKeys.isEmpty {
do {
stateText = "ClearKey laden…"
let started = try await ClearKeyDashProxy.shared.start(
manifestURL: url,
keys: clearKeys,
preferHlsForAVPlayer: true
)
guard gen == generation, !stopping else { return }
playURL = started.url
proxySession = started.session
proxyActive = true
proxyLogState("proxy ok \(playURL.absoluteString)")
} catch {
hasError = true
errorMessage = "ClearKey-proxy: \(error.localizedDescription)"
stateText = "Fout"
return
}
} else {
let fmt = (format ?? "").lowercased()
let isHls = fmt == "hls" || ClearKeyDashProxy.isHlsURL(url)
if !isHls, let fallback, ClearKeyDashProxy.isHlsURL(fallback) {
playURL = fallback
} else if !isHls {
hasError = true
errorMessage = "AVPlayer vereist HLS voor deze stream."
stateText = "Fout"
return
}
}
guard gen == generation, !stopping else { return }
await startItem(url: playURL, gen: gen)
}
func stop() {
guard !stopping else { return }
stopping = true
generation &+= 1
clearObservers()
player.pause()
// Eerst item loskoppelen, daarna proxy — voorkomt crash op half-open sockets.
player.replaceCurrentItem(with: nil)
stopProxyIfNeeded()
isPlaying = false
hasVideo = false
audioOptions = []
subtitleOptions = []
stateText = "Gestopt"
}
func togglePlay() {
if player.timeControlStatus == .playing {
player.pause()
} else {
player.play()
}
}
func refreshMediaOptions() {
guard let item = player.currentItem else { return }
Task { await loadMediaOptions(for: item) }
}
func selectAudio(id: String) {
guard let item = player.currentItem, let group = audioGroup,
let option = audioOptionById[id] else { return }
item.select(option, in: group)
currentAudioId = id
}
func selectSubtitle(id: String?) {
guard let item = player.currentItem, let group = subtitleGroup else { return }
if let id, let option = subtitleOptionById[id] {
item.select(option, in: group)
currentSubtitleId = id
} else {
item.select(nil, in: group)
currentSubtitleId = nil
}
}
// MARK: - Private
private func startItem(url: URL, gen: Int) async {
stateText = "Openen…"
let asset = AVURLAsset(url: url, options: [
AVURLAssetAllowsCellularAccessKey: true,
AVURLAssetAllowsExpensiveNetworkAccessKey: true,
AVURLAssetAllowsConstrainedNetworkAccessKey: true,
])
let item = AVPlayerItem(asset: asset)
item.preferredForwardBufferDuration = 8
clearObservers()
itemStatusObs = item.observe(\.status, options: [.new, .initial]) { [weak self] item, _ in
Task { @MainActor in
guard let self, self.generation == gen, !self.stopping else { return }
switch item.status {
case .readyToPlay:
self.stateText = "Afspelen"
self.hasError = false
self.refreshHasVideo(from: item)
await self.loadMediaOptions(for: item)
self.player.play()
case .failed:
self.fail(from: item.error, gen: gen)
default:
break
}
}
}
timeControlObs = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
Task { @MainActor in
guard let self, self.generation == gen, !self.stopping else { return }
switch player.timeControlStatus {
case .playing:
self.isPlaying = true
self.stateText = "Afspelen"
self.hasError = false
if let item = player.currentItem {
self.refreshHasVideo(from: item)
}
case .waitingToPlayAtSpecifiedRate:
self.isPlaying = false
self.stateText = "Bufferen…"
case .paused:
self.isPlaying = false
if !self.hasError { self.stateText = "Pauze" }
@unknown default:
break
}
}
}
endObs = NotificationCenter.default.addObserver(
forName: .AVPlayerItemFailedToPlayToEndTime,
object: item,
queue: .main
) { [weak self] note in
Task { @MainActor in
guard let self, self.generation == gen, !self.stopping else { return }
let err = note.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error
self.fail(from: err, gen: gen)
}
}
errorLogObs = NotificationCenter.default.addObserver(
forName: .AVPlayerItemNewErrorLogEntry,
object: item,
queue: .main
) { [weak self] _ in
Task { @MainActor in
guard let self, self.generation == gen else { return }
if let events = item.errorLog()?.events, let last = events.last {
let comment = last.errorComment ?? last.errorDomain
self.proxyLogState("AV errorLog: \(last.errorStatusCode) \(comment ?? "")")
}
}
}
player.replaceCurrentItem(with: item)
player.automaticallyWaitsToMinimizeStalling = true
player.play()
// Timeout alleen als er NOG NIETS speelt — nooit een werkende stream afbreken.
Task { @MainActor in
try? await Task.sleep(nanoseconds: 15_000_000_000)
guard self.generation == gen, !self.stopping else { return }
if let item = self.player.currentItem {
self.refreshHasVideo(from: item)
}
if !self.isPlaying && !self.hasError {
if let err = item.error {
self.fail(from: err, gen: gen)
} else {
self.hasError = true
self.errorMessage = self.bestErrorMessage(for: item)
?? "Stream opende niet binnen 15s (ClearKey/HLS)."
self.stateText = "Fout"
}
}
}
}
private func refreshHasVideo(from item: AVPlayerItem) {
if item.presentationSize.height > 1 {
hasVideo = true
return
}
let video = item.tracks.contains { track in
track.assetTrack?.mediaType == .video
}
hasVideo = video
}
private func loadMediaOptions(for item: AVPlayerItem) async {
let asset = item.asset
do {
let audible = try await asset.loadMediaSelectionGroup(for: .audible)
audioGroup = audible
if let audible {
var opts: [AVMediaOptionItem] = []
var map: [String: AVMediaSelectionOption] = [:]
for (i, opt) in audible.options.enumerated() {
let id = "a-\(i)-\(opt.displayName)"
let name = opt.displayName.isEmpty ? "Audio \(i + 1)" : opt.displayName
opts.append(AVMediaOptionItem(id: id, name: name))
map[id] = opt
}
audioOptions = opts
audioOptionById = map
if let selected = item.selectedMediaOption(in: audible),
let match = map.first(where: { $0.value == selected })?.key {
currentAudioId = match
} else {
currentAudioId = opts.first?.id
}
} else {
audioOptions = []
audioOptionById = [:]
}
let legible = try await asset.loadMediaSelectionGroup(for: .legible)
subtitleGroup = legible
if let legible {
var opts: [AVMediaOptionItem] = [AVMediaOptionItem(id: "off", name: "Uit")]
var map: [String: AVMediaSelectionOption] = [:]
for (i, opt) in legible.options.enumerated() where !opt.hasMediaCharacteristic(.containsOnlyForcedSubtitles) {
let id = "s-\(i)-\(opt.displayName)"
let name = opt.displayName.isEmpty ? "Ondertitel \(i + 1)" : opt.displayName
opts.append(AVMediaOptionItem(id: id, name: name))
map[id] = opt
}
subtitleOptions = opts
subtitleOptionById = map
if let selected = item.selectedMediaOption(in: legible),
let match = map.first(where: { $0.value == selected })?.key {
currentSubtitleId = match
} else {
currentSubtitleId = "off"
}
} else {
subtitleOptions = [AVMediaOptionItem(id: "off", name: "Uit")]
subtitleOptionById = [:]
currentSubtitleId = "off"
}
} catch {
proxyLogState("media options: \(error.localizedDescription)")
}
}
private func fail(from error: Error?, gen: Int) {
guard generation == gen, !stopping else { return }
hasError = true
if let error {
errorMessage = dutchAVError(error)
} else if let item = player.currentItem {
errorMessage = bestErrorMessage(for: item) ?? "Kon stream niet openen"
} else {
errorMessage = "Kon stream niet openen"
}
stateText = "Fout"
isPlaying = false
}
private func bestErrorMessage(for item: AVPlayerItem) -> String? {
if let err = item.error { return dutchAVError(err) }
if let events = item.errorLog()?.events, let last = events.last {
let code = last.errorStatusCode
let comment = last.errorComment ?? last.errorDomain
return "Afspeelfout (\(code)): \(comment)"
}
return nil
}
private func dutchAVError(_ error: Error) -> String {
let ns = error as NSError
if ns.domain == "CoreMediaErrorDomain" {
return "Mediafout (\(ns.code)). Vaak ongeldige HLS of decrypt. Probeer opnieuw."
}
return error.localizedDescription
}
private func stopProxyIfNeeded() {
if proxyActive {
let session = proxySession
proxyActive = false
proxySession = 0
ClearKeyDashProxy.shared.stop(session: session)
}
}
private func clearObservers() {
timeControlObs?.invalidate()
timeControlObs = nil
itemStatusObs?.invalidate()
itemStatusObs = nil
if let endObs {
NotificationCenter.default.removeObserver(endObs)
self.endObs = nil
}
if let errorLogObs {
NotificationCenter.default.removeObserver(errorLogObs)
self.errorLogObs = nil
}
}
private func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playback, mode: .moviePlayback, options: [])
try session.setActive(true)
} catch {}
}
private func proxyLogState(_ msg: String) {
#if DEBUG
print("[AVLive] \(msg)")
#endif
}
}
struct AVLivePlayerView: UIViewControllerRepresentable {
let controller: AVLivePlayerController
func makeUIViewController(context: Context) -> AVPlayerViewController {
let vc = AVPlayerViewController()
vc.player = controller.player
vc.showsPlaybackControls = false
vc.videoGravity = .resizeAspect
vc.allowsPictureInPicturePlayback = true
if #available(iOS 14.2, *) {
vc.canStartPictureInPictureAutomaticallyFromInline = true
}
return vc
}
func updateUIViewController(_ vc: AVPlayerViewController, context: Context) {
if vc.player !== controller.player {
vc.player = controller.player
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -69,7 +69,9 @@ struct PlayerScreen: View {
let request: PlayRequest
@StateObject private var player = VLCPlayerController()
/// ClearKey live → AVPlayer + lokale decrypt-HLS. FTA live + VOD → VLC (PiP intact).
@StateObject private var vlc = VLCPlayerController()
@StateObject private var av = AVLivePlayerController()
@State private var controlsVisible = true
@State private var loading = true
@State private var error: String?
@ -81,23 +83,48 @@ struct PlayerScreen: View {
@State private var subtitleHits: [SubtitleHit] = []
@State private var subsLoading = false
@State private var subsError: String?
@State private var pendingStop: Task<Void, Never>?
private var isLive: Bool { request.isLive }
private var useClearKeyAV: Bool { request.isLive && request.needsClearKey }
private var stateText: String { useClearKeyAV ? av.stateText : vlc.stateText }
private var isPlaying: Bool { useClearKeyAV ? av.isPlaying : vlc.isPlaying }
private var playerHasError: Bool { useClearKeyAV ? av.hasError : vlc.hasError }
private var playerErrorMessage: String? { useClearKeyAV ? av.errorMessage : vlc.errorMessage }
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VLCVideoView(controller: player)
.ignoresSafeArea()
.allowsHitTesting(false)
// VLC eet touches op — vang tikken altijd zelf af.
if useClearKeyAV {
AVLivePlayerView(controller: av)
.ignoresSafeArea()
.allowsHitTesting(false)
} else if isLive {
VLCVideoView(controller: vlc)
.ignoresSafeArea()
.allowsHitTesting(false)
} else {
GeometryReader { geo in
let fitted = Self.fittedVideoSize(in: geo.size, aspect: 16.0 / 9.0)
VLCVideoView(controller: vlc)
.frame(width: fitted.width, height: fitted.height)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.allowsHitTesting(false)
}
.ignoresSafeArea()
}
// Speler eet touches op — vang tikken altijd zelf af.
Color.clear
.contentShape(Rectangle())
.ignoresSafeArea()
.onTapGesture { toggleControls() }
.allowsHitTesting(!controlsVisible)
if loading && !player.isPlaying {
ProgressView(player.stateText)
if loading && !isPlaying {
ProgressView(stateText)
.tint(.white)
.foregroundStyle(.white)
.allowsHitTesting(false)
@ -105,8 +132,8 @@ struct PlayerScreen: View {
if let error {
errorOverlay(error)
} else if player.hasError {
errorOverlay(player.errorMessage ?? "Afspeelfout")
} else if playerHasError {
errorOverlay(playerErrorMessage ?? "Afspeelfout")
}
if controlsVisible {
@ -116,7 +143,11 @@ struct PlayerScreen: View {
}
.statusBarHidden(true)
.persistentSystemOverlays(.hidden)
.onAppear { app.enterPlayback() }
.onAppear {
pendingStop?.cancel()
pendingStop = nil
app.enterPlayback()
}
.task {
await start()
scheduleHide()
@ -127,8 +158,19 @@ struct PlayerScreen: View {
hideTask?.cancel()
progressTask?.cancel()
heartbeatTask?.cancel()
Task { await saveProgress() }
player.stop()
if !isLive {
let pos = vlc.currentPositionMs()
let dur = vlc.durationMs()
Task { await saveProgress(positionMs: pos, durationMs: dur) }
}
pendingStop?.cancel()
pendingStop = Task {
try? await Task.sleep(nanoseconds: 350_000_000)
guard !Task.isCancelled else { return }
await MainActor.run {
if useClearKeyAV { av.stop() } else { vlc.stop() }
}
}
}
.sheet(isPresented: $showTracks) {
tracksSheet
@ -154,17 +196,17 @@ struct PlayerScreen: View {
.font(McFonts.headline)
.foregroundStyle(.white)
.lineLimit(1)
Text(player.stateText)
Text(stateText)
.font(McFonts.caption)
.foregroundStyle(.white.opacity(0.7))
}
Spacer()
if player.pipPossible {
if !useClearKeyAV && vlc.pipPossible {
Button {
player.togglePictureInPicture()
vlc.togglePictureInPicture()
scheduleHide()
} label: {
Image(systemName: player.pipActive
Image(systemName: vlc.pipActive
? "pip.exit"
: "pip.enter")
.font(.title3)
@ -198,9 +240,9 @@ struct PlayerScreen: View {
if !request.isLive {
Slider(
value: Binding(
get: { Double(player.position) },
get: { Double(vlc.position) },
set: {
player.seek(ratio: Float($0))
vlc.seek(ratio: Float($0))
scheduleHide()
}
),
@ -209,33 +251,33 @@ struct PlayerScreen: View {
.tint(McColors.accent)
HStack {
Text(player.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
Text(vlc.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
Spacer()
Text(player.lengthText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
Text(vlc.lengthText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
}
}
HStack(spacing: 36) {
if !request.isLive {
Button {
player.seek(toMs: max(player.currentPositionMs() - 10_000, 0))
vlc.seek(toMs: max(vlc.currentPositionMs() - 10_000, 0))
scheduleHide()
} label: {
Image(systemName: "gobackward.10").font(.title).foregroundStyle(.white)
}
}
Button {
player.togglePlay()
if useClearKeyAV { av.togglePlay() } else { vlc.togglePlay() }
scheduleHide()
} label: {
Image(systemName: player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.white)
.shadow(radius: 8)
}
if !request.isLive {
Button {
player.seek(toMs: player.currentPositionMs() + 10_000)
vlc.seek(toMs: vlc.currentPositionMs() + 10_000)
scheduleHide()
} label: {
Image(systemName: "goforward.10").font(.title).foregroundStyle(.white)
@ -263,16 +305,16 @@ struct PlayerScreen: View {
if trackTab == 0 {
List {
ForEach(player.subtitleTracks) { track in
ForEach(vlc.subtitleTracks) { track in
Button {
player.selectSubtitle(id: track.id)
vlc.selectSubtitle(id: track.id)
showTracks = false
scheduleHide()
} label: {
HStack {
Text(track.name).foregroundStyle(McColors.text)
Spacer()
if track.id == player.currentSubtitleId {
if track.id == vlc.currentSubtitleId {
Image(systemName: "checkmark").foregroundStyle(McColors.accent)
}
}
@ -315,16 +357,16 @@ struct PlayerScreen: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List {
ForEach(player.audioTracks) { track in
ForEach(vlc.audioTracks) { track in
Button {
player.selectAudio(id: track.id)
vlc.selectAudio(id: track.id)
showTracks = false
scheduleHide()
} label: {
HStack {
Text(track.name).foregroundStyle(McColors.text)
Spacer()
if track.id == player.currentAudioId {
if track.id == vlc.currentAudioId {
Image(systemName: "checkmark").foregroundStyle(McColors.accent)
}
}
@ -343,7 +385,7 @@ struct PlayerScreen: View {
Button("Sluiten") { showTracks = false }
}
}
.onAppear { player.refreshTracks() }
.onAppear { vlc.refreshTracks() }
}
}
@ -399,32 +441,51 @@ struct PlayerScreen: View {
private func start() async {
loading = true
error = nil
if request.isLive {
let keys = request.needsClearKey ? (request.drm?.keys ?? []) : []
await player.playLive(
if useClearKeyAV {
let keys = request.drm?.keys ?? []
await av.play(
url: request.url,
fallback: request.fallbackUrl,
format: request.format,
clearKeys: keys
)
// ClearKey/DASH: proxy-warmup + init/media + eventuele retry.
let waitNs: UInt64 = request.needsClearKey ? 12_000_000_000 : 2_500_000_000
try? await Task.sleep(nanoseconds: waitNs)
if request.needsClearKey && (player.hasError || !player.isPlaying) {
try? await Task.sleep(nanoseconds: 6_000_000_000)
if player.hasError || !player.isPlaying {
error = player.errorMessage
?? "Kon deze ClearKey-livezender niet starten. Het DASH-manifest of de sleutels zijn mogelijk ongeldig."
}
let maxWaitNs: UInt64 = 18_000_000_000
let stepNs: UInt64 = 200_000_000
var waited: UInt64 = 0
while waited < maxWaitNs && !av.hasError {
if av.isPlaying { break }
try? await Task.sleep(nanoseconds: stepNs)
waited += stepNs
}
if !av.isPlaying || av.hasError {
error = av.errorMessage
?? "Kon deze ClearKey-livestream niet starten."
}
} else if isLive {
await vlc.playLive(
url: request.url,
fallback: request.fallbackUrl,
format: request.format,
clearKeys: []
)
let maxWaitNs: UInt64 = 4_000_000_000
let stepNs: UInt64 = 200_000_000
var waited: UInt64 = 0
while waited < maxWaitNs && !vlc.isPlaying && !vlc.hasError {
try? await Task.sleep(nanoseconds: stepNs)
waited += stepNs
}
if vlc.hasError {
error = vlc.errorMessage ?? "Kon deze livestream niet starten."
}
} else {
player.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL)
vlc.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL)
progressTask = Task { await progressLoop() }
}
try? await Task.sleep(nanoseconds: 400_000_000)
try? await Task.sleep(nanoseconds: 250_000_000)
loading = false
if !player.hasError {
player.refreshTracks()
if !useClearKeyAV && !vlc.hasError {
vlc.refreshTracks()
}
}
@ -450,10 +511,10 @@ struct PlayerScreen: View {
}
}
private func saveProgress() async {
private func saveProgress(positionMs: Int64? = nil, durationMs: Int64? = nil) async {
guard !request.isLive else { return }
let pos = player.currentPositionMs()
let dur = player.durationMs()
let pos = positionMs ?? vlc.currentPositionMs()
let dur = durationMs ?? vlc.durationMs()
guard pos > 0 else { return }
try? await app.api.postProgress(
mediaType: request.mediaType,
@ -492,13 +553,25 @@ struct PlayerScreen: View {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("vmc-sub-\(hit.fileId).\(ext)")
try data.write(to: url, options: .atomic)
player.addExternalSubtitle(url: url)
vlc.addExternalSubtitle(url: url)
showTracks = false
scheduleHide()
} catch {
subsError = error.localizedDescription
}
}
private static func fittedVideoSize(in container: CGSize, aspect: CGFloat) -> CGSize {
guard container.width > 0, container.height > 0, aspect > 0 else { return container }
let containerAspect = container.width / container.height
if containerAspect > aspect {
let h = container.height
return CGSize(width: h * aspect, height: h)
} else {
let w = container.width
return CGSize(width: w, height: w / aspect)
}
}
}
struct PlayLauncher: View {

View file

@ -216,7 +216,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
stateText = "ClearKey laden…"
// Async: blokkeer de main thread niet (watchdog-crash).
// HLS (ESPN) én DASH (Prime) via dezelfde proxy.
playURL = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
let started = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
playURL = started.url
playFormat = ClearKeyDashProxy.isHlsURL(url) ? "hls" : "dash"
clearKeyProxyActive = true
} catch {
@ -820,7 +821,6 @@ struct VLCVideoView: UIViewRepresentable {
}
func updateUIView(_ uiView: UIView, context: Context) {
// Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout).
controller.attach(to: uiView)
// Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout / audio-only).
}
}

View file

@ -96,6 +96,9 @@ struct BrowseScreen: View {
.toolbarColorScheme(.dark, for: .navigationBar)
.task {
if catalogId.isEmpty { catalogId = defaultCatalog }
// NavigationStack re-runs .task after detail pop; don't wipe the grid
// or scroll position (same pattern as HomeScreen).
guard items.isEmpty else { return }
await bootstrap()
}
.refreshable { await reload(reset: true) }
@ -271,6 +274,7 @@ struct BrowseScreen: View {
if reset {
loading = true
error = nil
loadingMore = false
}
let id = catalogId.isEmpty ? defaultCatalog : catalogId
let sortParam: String? = showSortTools ? sort.rawValue : nil

View file

@ -0,0 +1,36 @@
-- CreateTable
CREATE TABLE "drm_keys" (
"id" TEXT NOT NULL,
"kid" TEXT NOT NULL,
"key_enc" TEXT NOT NULL,
"key_hash" TEXT NOT NULL,
"mpd_url" TEXT,
"channel_name" TEXT,
"event_name" TEXT,
"source" TEXT NOT NULL DEFAULT 'manual',
"external_id" TEXT,
"media_guid" TEXT,
"last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "drm_keys_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "drm_keys_kid_key" ON "drm_keys"("kid");
-- CreateIndex
CREATE INDEX "drm_keys_key_hash_idx" ON "drm_keys"("key_hash");
-- CreateIndex
CREATE INDEX "drm_keys_last_seen_at_idx" ON "drm_keys"("last_seen_at");
-- CreateIndex
CREATE INDEX "drm_keys_source_idx" ON "drm_keys"("source");
-- CreateIndex
CREATE INDEX "drm_keys_channel_name_idx" ON "drm_keys"("channel_name");
-- CreateIndex
CREATE INDEX "drm_keys_event_name_idx" ON "drm_keys"("event_name");

View file

@ -790,6 +790,37 @@ model LibrarySubtitle {
@@map("library_subtitles")
}
/**
* Globale ClearKey/Widevine-key cache (admin DRM-pagina).
* KID plaintext (staat vaak in MPD); KEY encrypted. Uniek op genormaliseerde KID.
*/
model DrmKey {
id String @id @default(uuid())
/** Genormaliseerde KID hex (zonder dashes), uniek */
kid String @unique
/** AES-GCM ciphertext van de KEY hex */
keyEnc String @map("key_enc") @db.Text
/** SHA-256 van genormaliseerde KEY hex — exacte key-search zonder decrypt */
keyHash String @map("key_hash")
mpdUrl String? @map("mpd_url") @db.Text
channelName String? @map("channel_name")
eventName String? @map("event_name")
/** odido | viaplay | manual | event | … */
source String @default("manual")
externalId String? @map("external_id")
mediaGuid String? @map("media_guid")
lastSeenAt DateTime @default(now()) @map("last_seen_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([keyHash])
@@index([lastSeenAt])
@@index([source])
@@index([channelName])
@@index([eventName])
@@map("drm_keys")
}
/** Audit trail voor Google Play group-lidmaatschap (geen secrets). */
model GooglePlayAccessAuditLog {
id String @id @default(uuid())

View file

@ -1810,6 +1810,95 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
return syncF1tvCatalog(config.SESSION_SECRET);
});
// --- DRM key database ---
app.get("/api/v1/admin/drm-keys", { preHandler: requireAdmin }, async (request) => {
const q = request.query as { search?: string; source?: string; limit?: string };
const { searchDrmKeys } = await import("../drm/keys");
return searchDrmKeys(prisma, {
search: q.search,
source: q.source,
limit: q.limit ? Number(q.limit) : undefined,
});
});
app.get("/api/v1/admin/drm-keys/:id/reveal", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const { revealDrmKey } = await import("../drm/keys");
try {
return await revealDrmKey(prisma, config.SESSION_SECRET, id);
} catch {
throw new AppError("NOT_FOUND", "DRM-key niet gevonden", 404);
}
});
app.patch("/api/v1/admin/drm-keys/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const body = request.body as {
channelName?: string | null;
eventName?: string | null;
mpdUrl?: string | null;
source?: string;
};
const { updateDrmKeyMeta } = await import("../drm/keys");
try {
return { key: await updateDrmKeyMeta(prisma, id, body) };
} catch {
throw new AppError("NOT_FOUND", "DRM-key niet gevonden", 404);
}
});
app.delete("/api/v1/admin/drm-keys/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const { deleteDrmKey } = await import("../drm/keys");
try {
await deleteDrmKey(prisma, id);
return { ok: true };
} catch {
throw new AppError("NOT_FOUND", "DRM-key niet gevonden", 404);
}
});
app.post("/api/v1/admin/drm-keys/backfill", { preHandler: requireAdmin }, async () => {
const { backfillDrmKeysFromLiveChannels } = await import("../drm/keys");
return backfillDrmKeysFromLiveChannels(prisma, config.SESSION_SECRET);
});
app.post("/api/v1/admin/drm-keys", { preHandler: requireAdmin }, async (request) => {
const body = request.body as {
kid?: string;
key?: string;
mpdUrl?: string;
channelName?: string;
eventName?: string;
source?: string;
externalId?: string;
mediaGuid?: string;
};
if (!body.kid?.trim() || !body.key?.trim()) {
throw new AppError("INVALID_REQUEST", "kid en key verplicht", 400);
}
const { upsertDrmKey } = await import("../drm/keys");
try {
const result = await upsertDrmKey(prisma, config.SESSION_SECRET, {
kid: body.kid,
key: body.key,
mpdUrl: body.mpdUrl,
channelName: body.channelName,
eventName: body.eventName,
source: body.source || "manual",
externalId: body.externalId,
mediaGuid: body.mediaGuid,
});
return result;
} catch (e) {
throw new AppError(
"INVALID_REQUEST",
e instanceof Error ? e.message : "Ongeldige key",
400
);
}
});
// --- Custom ClearKey/DASH live channel lists ---
app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => {
const { listLiveChannelListsAdmin } = await import("../viewer/live-channels");

View file

@ -0,0 +1,294 @@
import { createHash } from "crypto";
import type { PrismaClient } from "@prisma/client";
import { decryptSecret, encryptSecret } from "../security/crypto";
export type DrmKeyUpsertInput = {
kid: string;
key: string;
mpdUrl?: string | null;
channelName?: string | null;
eventName?: string | null;
source?: string;
externalId?: string | null;
mediaGuid?: string | null;
};
export type DrmKeyPublic = {
id: string;
kid: string;
keyMasked: string;
hasKey: boolean;
mpdUrl: string | null;
channelName: string | null;
eventName: string | null;
source: string;
externalId: string | null;
mediaGuid: string | null;
lastSeenAt: string;
createdAt: string;
updatedAt: string;
};
function normalizeHex(value: string): string {
return value.trim().toLowerCase().replace(/[^0-9a-f]/g, "");
}
export function normalizeKid(kid: string): string {
return normalizeHex(kid);
}
export function normalizeKey(key: string): string {
return normalizeHex(key);
}
export function hashKey(key: string): string {
return createHash("sha256").update(normalizeKey(key), "utf8").digest("hex");
}
function maskKey(key: string | null | undefined): string {
if (!key) return "";
if (key.length <= 8) return "********";
return `${key.slice(0, 4)}…${key.slice(-4)}`;
}
function toPublic(row: {
id: string;
kid: string;
keyEnc: string;
mpdUrl: string | null;
channelName: string | null;
eventName: string | null;
source: string;
externalId: string | null;
mediaGuid: string | null;
lastSeenAt: Date;
createdAt: Date;
updatedAt: Date;
}): DrmKeyPublic {
return {
id: row.id,
kid: row.kid,
keyMasked: "********",
hasKey: Boolean(row.keyEnc),
mpdUrl: row.mpdUrl,
channelName: row.channelName,
eventName: row.eventName,
source: row.source,
externalId: row.externalId,
mediaGuid: row.mediaGuid,
lastSeenAt: row.lastSeenAt.toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
/** Upsert by KID; refreshes lastSeen + metadata. Never downgrades an existing key. */
export async function upsertDrmKey(
prisma: PrismaClient,
sessionSecret: string,
input: DrmKeyUpsertInput
): Promise<{ id: string; kid: string; created: boolean }> {
const kid = normalizeKid(input.kid);
const key = normalizeKey(input.key);
if (kid.length < 16 || key.length < 16) {
throw new Error("Ongeldige kid/key");
}
const keyEnc = encryptSecret(key, sessionSecret);
const keyHash = hashKey(key);
const now = new Date();
const existing = await prisma.drmKey.findUnique({ where: { kid } });
if (existing) {
await prisma.drmKey.update({
where: { kid },
data: {
keyEnc,
keyHash,
lastSeenAt: now,
...(input.mpdUrl != null && input.mpdUrl !== ""
? { mpdUrl: input.mpdUrl }
: {}),
...(input.channelName != null && input.channelName !== ""
? { channelName: input.channelName }
: {}),
...(input.eventName != null && input.eventName !== ""
? { eventName: input.eventName }
: {}),
...(input.source ? { source: input.source } : {}),
...(input.externalId != null ? { externalId: input.externalId } : {}),
...(input.mediaGuid != null ? { mediaGuid: input.mediaGuid } : {}),
},
});
return { id: existing.id, kid, created: false };
}
const created = await prisma.drmKey.create({
data: {
kid,
keyEnc,
keyHash,
mpdUrl: input.mpdUrl?.trim() || null,
channelName: input.channelName?.trim() || null,
eventName: input.eventName?.trim() || null,
source: input.source?.trim() || "manual",
externalId: input.externalId?.trim() || null,
mediaGuid: input.mediaGuid?.trim() || null,
lastSeenAt: now,
},
});
return { id: created.id, kid, created: true };
}
export async function lookupDrmKeyByKid(
prisma: PrismaClient,
sessionSecret: string,
kidRaw: string
): Promise<{ kid: string; key: string; mpdUrl: string | null } | null> {
const kid = normalizeKid(kidRaw);
if (!kid) return null;
const row = await prisma.drmKey.findUnique({ where: { kid } });
if (!row) return null;
return {
kid: row.kid,
key: decryptSecret(row.keyEnc, sessionSecret),
mpdUrl: row.mpdUrl,
};
}
export async function searchDrmKeys(
prisma: PrismaClient,
opts: { search?: string; source?: string; limit?: number }
): Promise<{ keys: DrmKeyPublic[]; total: number; requiresQuery: boolean }> {
const limit = Math.min(Math.max(opts.limit ?? 100, 1), 500);
const q = (opts.search || "").trim();
const source = opts.source?.trim() || undefined;
if (q.length > 0 && q.length < 2) {
return { keys: [], total: 0, requiresQuery: true };
}
const where: Record<string, unknown> = {};
if (source) where.source = source;
if (q.length >= 2) {
const hex = normalizeHex(q);
const or: Record<string, unknown>[] = [
{ kid: { contains: hex.length >= 2 ? hex : q, mode: "insensitive" } },
{ mpdUrl: { contains: q, mode: "insensitive" } },
{ channelName: { contains: q, mode: "insensitive" } },
{ eventName: { contains: q, mode: "insensitive" } },
{ externalId: { contains: q, mode: "insensitive" } },
{ mediaGuid: { contains: q, mode: "insensitive" } },
{ source: { contains: q, mode: "insensitive" } },
];
// Exact key search via hash when query looks like a key (32+ hex)
if (hex.length >= 32) {
or.push({ keyHash: hashKey(hex) });
or.push({ kid: hex });
}
where.OR = or;
}
const [total, rows] = await Promise.all([
prisma.drmKey.count({ where }),
prisma.drmKey.findMany({
where,
orderBy: { lastSeenAt: "desc" },
take: limit,
}),
]);
return {
keys: rows.map(toPublic),
total,
requiresQuery: false,
};
}
export async function revealDrmKey(
prisma: PrismaClient,
sessionSecret: string,
id: string
): Promise<{ id: string; kid: string; key: string; mpdUrl: string | null }> {
const row = await prisma.drmKey.findUnique({ where: { id } });
if (!row) throw new Error("DRM-key niet gevonden");
return {
id: row.id,
kid: row.kid,
key: decryptSecret(row.keyEnc, sessionSecret),
mpdUrl: row.mpdUrl,
};
}
export async function updateDrmKeyMeta(
prisma: PrismaClient,
id: string,
patch: {
channelName?: string | null;
eventName?: string | null;
mpdUrl?: string | null;
source?: string;
}
): Promise<DrmKeyPublic> {
const row = await prisma.drmKey.update({
where: { id },
data: {
...(patch.channelName !== undefined
? { channelName: patch.channelName?.trim() || null }
: {}),
...(patch.eventName !== undefined
? { eventName: patch.eventName?.trim() || null }
: {}),
...(patch.mpdUrl !== undefined ? { mpdUrl: patch.mpdUrl?.trim() || null } : {}),
...(patch.source !== undefined ? { source: patch.source.trim() || "manual" } : {}),
},
});
return toPublic(row);
}
export async function deleteDrmKey(prisma: PrismaClient, id: string): Promise<void> {
await prisma.drmKey.delete({ where: { id } });
}
/** One-time / on-demand: import keys from live_channels into drm_keys. */
export async function backfillDrmKeysFromLiveChannels(
prisma: PrismaClient,
sessionSecret: string
): Promise<{ imported: number; skipped: number; failed: number }> {
const channels = await prisma.liveChannel.findMany({
where: { kidEnc: { not: null }, keyEnc: { not: null } },
select: {
name: true,
mpdUrl: true,
streamUrlCache: true,
kidEnc: true,
keyEnc: true,
externalId: true,
list: { select: { provider: true } },
},
});
let imported = 0;
let skipped = 0;
let failed = 0;
for (const ch of channels) {
if (!ch.kidEnc || !ch.keyEnc) {
skipped += 1;
continue;
}
try {
const kid = decryptSecret(ch.kidEnc, sessionSecret);
const key = decryptSecret(ch.keyEnc, sessionSecret);
const result = await upsertDrmKey(prisma, sessionSecret, {
kid,
key,
mpdUrl: ch.streamUrlCache || ch.mpdUrl,
channelName: ch.name,
source: ch.list.provider || "live-channel",
externalId: ch.externalId,
});
if (result.created) imported += 1;
else skipped += 1;
} catch {
failed += 1;
}
}
return { imported, skipped, failed };
}

View file

@ -19,6 +19,7 @@ import {
export type OdidoChannelPlayState = {
id: string;
name?: string | null;
externalId: string | null;
mediaId: string | null;
contentId: string | null;
@ -74,7 +75,8 @@ async function fetchKeysIfNeeded(
sessionSecret: string,
existingKid: string | null,
existingKey: string | null,
allowFetch: boolean
allowFetch: boolean,
meta?: { channelName?: string | null; externalId?: string | null }
): Promise<{ kidEnc: string | null; keyEnc: string | null }> {
if (existingKid && existingKey) return { kidEnc: existingKid, keyEnc: existingKey };
if (!allowFetch || !play.licenseUrl || !play.licenseToken) {
@ -92,6 +94,19 @@ async function fetchKeysIfNeeded(
});
const key = keys.map(parseKey).find((item) => item !== null);
if (!key) throw new Error("Geen bruikbare ClearKey ontvangen");
try {
const { upsertDrmKey } = await import("../drm/keys");
await upsertDrmKey(prisma, sessionSecret, {
kid: key.kid,
key: key.key,
mpdUrl: play.playUrl,
channelName: meta?.channelName,
source: "odido",
externalId: meta?.externalId,
});
} catch {
/* DRM-db mag play niet breken */
}
return {
kidEnc: encryptSecret(key.kid, sessionSecret),
keyEnc: encryptSecret(key.key, sessionSecret),
@ -159,7 +174,8 @@ export async function refreshChannelMpd(
sessionSecret,
channel.kidEnc,
channel.keyEnc,
opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay
opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay,
{ channelName: channel.name, externalId }
);
const now = new Date();

View file

@ -314,6 +314,24 @@ export async function syncOdidoList(
if (!key) throw new Error("Geen bruikbare ClearKey ontvangen");
kidEnc = encryptSecret(key.kid, sessionSecret);
keyEnc = encryptSecret(key.key, sessionSecret);
try {
const { upsertDrmKey } = await import("../drm/keys");
const chName =
entry.channel.name?.trim() ||
entry.channel.channelName?.trim() ||
physical?.mediaName?.trim() ||
externalId;
await upsertDrmKey(prisma, sessionSecret, {
kid: key.kid,
key: key.key,
mpdUrl: entry.playUrl,
channelName: chName,
source: "odido",
externalId,
});
} catch {
/* ignore drm db errors */
}
} catch {
counts.failed += 1;
}

View file

@ -446,6 +446,7 @@ export async function getCustomPlayUrl(
const resolved = await resolveOdidoPlay(
{
id: channel.id,
name: channel.name,
externalId,
mediaId,
contentId: channel.contentId,