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: "/downloads", label: "Downloads" },
{ href: "/streams", label: "Streams" }, { href: "/streams", label: "Streams" },
{ href: "/live-lists", label: "Live-lijsten" }, { href: "/live-lists", label: "Live-lijsten" },
{ href: "/drm", label: "DRM" },
{ href: "/scripts", label: "Scripts" }, { href: "/scripts", label: "Scripts" },
{ href: "/viewers", label: "Gebruikers" }, { href: "/viewers", label: "Gebruikers" },
{ href: "/settings", label: "Instellingen" }, { href: "/settings", label: "Instellingen" },

View file

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

View file

@ -3,14 +3,32 @@ package nl.vonas.mediacluster.tv.ui
import android.view.ViewGroup import android.view.ViewGroup
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn 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.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding 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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -20,35 +38,51 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.C
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import nl.vonas.mediacluster.tv.data.ApiClient 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.buildLiveTvPlayer
import nl.vonas.mediacluster.tv.playback.disableTrackType
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected 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.liveMediaItem
import nl.vonas.mediacluster.tv.playback.selectTrack
import nl.vonas.mediacluster.tv.ui.components.McButton import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
private const val ReadyTimeoutMs = 20_000L 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) @OptIn(UnstableApi::class)
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun EventPlayerScreen( fun EventPlayerScreen(
api: ApiClient, api: ApiClient,
@ -66,9 +100,30 @@ fun EventPlayerScreen(
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var showChrome by remember { mutableStateOf(true) } var showChrome by remember { mutableStateOf(true) }
var watchSessionId by remember { mutableStateOf<String?>(null) } 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() 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() { fun safeStopAndClear() {
runCatching { runCatching {
player.playWhenReady = false player.playWhenReady = false
@ -83,6 +138,9 @@ fun EventPlayerScreen(
watchSessionId = null watchSessionId = null
reachedReady.set(false) reachedReady.set(false)
showChrome = true showChrome = true
menu = null
audioTracks = emptyList()
textTracks = emptyList()
try { try {
val play = api.scheduleEventPlay(eventId) val play = api.scheduleEventPlay(eventId)
if (!isActive) return@LaunchedEffect if (!isActive) return@LaunchedEffect
@ -146,9 +204,9 @@ fun EventPlayerScreen(
} }
} }
LaunchedEffect(showChrome, error, loading) { LaunchedEffect(showChrome, menu, error, loading, chromeBump) {
if (showChrome && error == null && !loading) { if (showChrome && menu == null && error == null && !loading) {
delay(4_000) delay(ChromeHideMs)
showChrome = false showChrome = false
} }
} }
@ -159,12 +217,22 @@ fun EventPlayerScreen(
when (state) { when (state) {
Player.STATE_READY -> { Player.STATE_READY -> {
reachedReady.set(true) reachedReady.set(true)
runCatching { player.ensureAudioSelected() } runCatching {
player.ensureAudioSelected()
refreshTracks()
}
} }
else -> Unit else -> Unit
} }
} }
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
runCatching {
player.ensureAudioSelected()
refreshTracks()
}
}
override fun onPlayerError(e: PlaybackException) { override fun onPlayerError(e: PlaybackException) {
val cause = generateSequence(e as Throwable?) { it.cause } val cause = generateSequence(e as Throwable?) { it.cause }
.mapNotNull { it.message?.takeIf { m -> m.isNotBlank() } } .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( Box(
Modifier Modifier
@ -200,7 +274,13 @@ fun EventPlayerScreen(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, 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) { 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( Text(
title, title,
color = Color.White, color = Color.White,
fontSize = 18.sp, fontSize = 20.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
modifier = Modifier maxLines = 2,
.align(Alignment.TopStart) overflow = TextOverflow.Ellipsis,
.statusBarsPadding()
.padding(20.dp),
) )
McButton( }
"Terug",
compact = true, 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 modifier = Modifier
.align(Alignment.TopEnd) .fillMaxWidth()
.statusBarsPadding() .height((tracks.size.coerceAtMost(6) * 56).dp.coerceAtLeast(56.dp)),
.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
onClick = onBack, ) {
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(
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" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 135 versionCode = 143
versionName = "0.14.72" versionName = "0.14.80"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -636,8 +636,108 @@ class ApiClient(
suspend fun scheduleEventPlay(eventId: String): IptvPlayInfo = withContext(Dispatchers.IO) { suspend fun scheduleEventPlay(eventId: String): IptvPlayInfo = withContext(Dispatchers.IO) {
val enc = android.net.Uri.encode(eventId) val enc = android.net.Uri.encode(eventId)
val req = authed("$baseUrl/api/v1/client/events/$enc/play").get().build() 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 -> parseObject(execute(req)) { o ->
val drmObj = o.optJSONObject("drm") 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) }
}
}
}
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 drm = if (drmObj != null) {
val keysArr = drmObj.optJSONArray("keys") ?: JSONArray() val keysArr = drmObj.optJSONArray("keys") ?: JSONArray()
val keys = buildList { val keys = buildList {
@ -653,18 +753,33 @@ class ApiClient(
keys = keys, keys = keys,
).takeIf { it.keys.isNotEmpty() } ).takeIf { it.keys.isNotEmpty() }
} else null } else null
IptvPlayInfo( return IptvPlayInfo(
streamId = o.optString("eventId").ifBlank { eventId }, streamId = optString("eventId").ifBlank { fallbackStreamId },
name = o.getString("name"), name = getString("name"),
logoUrl = o.optString("logoUrl").ifBlank { null }, logoUrl = optString("logoUrl").ifBlank { null },
streamUrl = o.getString("streamUrl"), streamUrl = getString("streamUrl"),
format = o.optString("format").ifBlank { "dash" }, format = optString("format").ifBlank { defaultFormat },
fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null }, fallbackStreamUrl = optString("fallbackStreamUrl").ifBlank { null },
fallbackFormat = o.optString("fallbackFormat").ifBlank { null }, fallbackFormat = optString("fallbackFormat").ifBlank { null },
drm = drm, drm = drm,
watchSessionId = o.optString("watchSessionId").ifBlank { null }, 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"),
)
} }
/** /**
@ -1137,6 +1252,47 @@ data class ScheduleEvent(
val audioLanguages: List<String> = emptyList(), 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. */ /** Canonical shelf/chip order (Overig last). Keep in sync with master-api. */
val SportBucketOrder = listOf( val SportBucketOrder = listOf(
"football", "racing", "tennis", "golf", "darts", "snooker", "football", "racing", "tennis", "golf", "darts", "snooker",

View file

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

View file

@ -1,12 +1,20 @@
package nl.vonas.mediacluster.tv.ui package nl.vonas.mediacluster.tv.ui
import android.content.ActivityNotFoundException import android.annotation.SuppressLint
import android.content.Intent import android.os.Handler
import android.net.Uri 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.activity.compose.BackHandler
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed 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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -39,24 +49,23 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.window.Dialog import androidx.compose.ui.draw.shadow
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.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.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.Border
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
@ -67,10 +76,15 @@ import coil.compose.AsyncImage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient 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.MediaVersion
import nl.vonas.mediacluster.tv.ui.components.SelectedQualityLine import nl.vonas.mediacluster.tv.ui.components.SelectedQualityLine
import nl.vonas.mediacluster.tv.ui.components.VersionQualityMark 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.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 nl.vonas.mediacluster.tv.ui.theme.McColors
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@ -101,35 +115,196 @@ private fun formatRuntime(minutes: Int?): String? {
} }
} }
private fun openYoutubeTrailer(context: android.content.Context, key: String) { @SuppressLint("SetJavaScriptEnabled")
val appPackages = listOf( @Composable
"com.google.android.youtube.tv", private fun TrailerPlayerOverlay(
"com.google.android.youtube.googletv", youtubeKey: String,
"com.google.android.youtube", 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()
}
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,
) )
val appIntent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:$key")).apply { setBackgroundColor(android.graphics.Color.BLACK)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) isFocusable = false
} isFocusableInTouchMode = false
for (pkg in appPackages) { settings.javaScriptEnabled = true
try { settings.domStorageEnabled = true
appIntent.setPackage(pkg) settings.mediaPlaybackRequiresUserGesture = false
context.startActivity(appIntent) settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
return webChromeClient = WebChromeClient()
} catch (_: ActivityNotFoundException) { webViewClient = WebViewClient()
// try next addJavascriptInterface(
} catch (_: Exception) { object {
// try next @JavascriptInterface
fun onPlaying() {
mainHandler.post {
loading = false
paused = false
} }
} }
try {
context.startActivity( @JavascriptInterface
Intent( fun onPaused() {
Intent.ACTION_VIEW, mainHandler.post { paused = true }
Uri.parse("https://www.youtube.com/watch?v=$key"), }
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@JavascriptInterface
fun onEnded() {
mainHandler.post(onDismiss)
}
},
"VmcTrailer",
) )
} catch (_: Exception) { // Cinema mode: scaled/cropped iframe + pointer-events none hides
// ignore // 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)
}
}
}
} }
} }
@ -142,25 +317,28 @@ private fun DetailBackdrop(url: String?) {
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
alignment = Alignment.TopCenter,
) )
} }
// Events-achtige cinematic scrims (VMC zwart)
Box( Box(
Modifier.fillMaxSize().background( Modifier.fillMaxSize().background(
Brush.horizontalGradient( Brush.horizontalGradient(
0f to Color(0xF20B0B0B), 0.0f to Color(0xF20B0B0B),
0.42f to Color(0xCC0B0B0B), 0.40f to Color(0xCC0B0B0B),
0.72f to Color(0x660B0B0B), 0.72f to Color(0x550B0B0B),
1f to Color(0x220B0B0B), 1.0f to Color(0x180B0B0B),
), ),
), ),
) )
Box( Box(
Modifier.fillMaxSize().background( Modifier.fillMaxSize().background(
Brush.verticalGradient( Brush.verticalGradient(
0f to Color(0x660B0B0B), 0.0f to Color(0x66000000),
0.35f to Color.Transparent, 0.12f to Color.Transparent,
0.7f to Color(0xAA0B0B0B), 0.45f to Color.Transparent,
1f to Color(0xFF0B0B0B), 0.70f to Color(0xAA0B0B0B),
1.0f to Color(0xFF0B0B0B),
), ),
), ),
) )
@ -168,15 +346,25 @@ private fun DetailBackdrop(url: String?) {
} }
@Composable @Composable
private fun DetailLogoOrTitle(logoUrl: String?, title: String) { private fun DetailLogoOrTitle(
logoUrl: String?,
title: String,
compact: Boolean = false,
) {
if (!logoUrl.isNullOrBlank()) { if (!logoUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = logoUrl, model = logoUrl,
contentDescription = title, contentDescription = title,
modifier = Modifier modifier = if (compact) {
Modifier
.heightIn(max = 56.dp)
.widthIn(max = 280.dp)
} else {
Modifier
.widthIn(max = 420.dp) .widthIn(max = 420.dp)
.heightIn(max = 120.dp) .heightIn(max = 100.dp)
.fillMaxWidth(0.85f), .fillMaxWidth(0.85f)
},
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart, alignment = Alignment.CenterStart,
) )
@ -184,15 +372,43 @@ private fun DetailLogoOrTitle(logoUrl: String?, title: String) {
Text( Text(
title, title,
color = Color.White, color = Color.White,
fontSize = 40.sp, fontSize = if (compact) 32.sp else 40.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 3, maxLines = if (compact) 2 else 3,
overflow = TextOverflow.Ellipsis, 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 @Composable
private fun MetaChips(parts: List<String>) { private fun MetaChips(parts: List<String>) {
if (parts.isEmpty()) return if (parts.isEmpty()) return
@ -207,8 +423,8 @@ private fun MetaChips(parts: List<String>) {
} }
Text( Text(
part, part,
color = if (part.startsWith("★")) Color(0xFFFFD54F) else McColors.TextDim, color = if (part.startsWith("★")) Color(0xFFFFD54F) else Color(0xFFCFCFCF),
fontSize = 14.sp, fontSize = 15.sp,
fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium, fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium,
) )
} }
@ -231,7 +447,7 @@ private fun DetailBackButton(onClick: () -> Unit) {
), ),
border = ClickableSurfaceDefaults.border( border = ClickableSurfaceDefaults.border(
focusedBorder = Border( focusedBorder = Border(
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White), border = BorderStroke(1.dp, Color.White),
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
), ),
), ),
@ -254,38 +470,43 @@ private fun DetailActionButton(
focusRequester: FocusRequester? = null, focusRequester: FocusRequester? = null,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val bg = Color(0xE6181818) val shape = RoundedCornerShape(6.dp)
val bgFocus = Color.White val idleBg = if (primary) McColors.Accent else Color(0xE6181818)
val fg = Color.White val focusBg = if (primary) Color.White else Color.White
val idleFg = Color.White
val focusFg = if (primary) McColors.Accent else Color.Black
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier, modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), shape = ClickableSurfaceDefaults.shape(shape = shape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)), glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = bg, containerColor = idleBg,
focusedContainerColor = bgFocus, focusedContainerColor = focusBg,
contentColor = fg, contentColor = idleFg,
focusedContentColor = Color.Black, focusedContentColor = focusFg,
), ),
border = ClickableSurfaceDefaults.border( border = ClickableSurfaceDefaults.border(
border = Border( border = Border(
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x55FFFFFF)), border = BorderStroke(
shape = RoundedCornerShape(4.dp), if (primary) 0.dp else 1.dp,
if (primary) Color.Transparent else Color(0x55FFFFFF),
),
shape = shape,
), ),
focusedBorder = Border( focusedBorder = Border(
border = androidx.compose.foundation.BorderStroke(2.dp, Color.White), border = BorderStroke(2.dp, if (primary) McColors.Accent else Color.White),
shape = RoundedCornerShape(4.dp), shape = shape,
), ),
), ),
) { ) {
Text( Text(
text = label, text = label,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
color = Color.Unspecified, color = Color.Unspecified,
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
maxLines = 1, maxLines = 1,
) )
} }
@ -345,7 +566,6 @@ fun MovieDetailScreen(
onBack: () -> Unit, onBack: () -> Unit,
onPlay: (mediaFileId: String, title: String, startMs: Long) -> Unit, onPlay: (mediaFileId: String, title: String, startMs: Long) -> Unit,
) { ) {
val context = LocalContext.current
var title by remember { mutableStateOf("…") } var title by remember { mutableStateOf("…") }
var overview by remember { mutableStateOf("") } var overview by remember { mutableStateOf("") }
var backdrop by remember { mutableStateOf<String?>(null) } var backdrop by remember { mutableStateOf<String?>(null) }
@ -358,13 +578,13 @@ fun MovieDetailScreen(
var cast by remember { mutableStateOf<List<String>>(emptyList()) } var cast by remember { mutableStateOf<List<String>>(emptyList()) }
var director by remember { mutableStateOf<List<String>>(emptyList()) } var director by remember { mutableStateOf<List<String>>(emptyList()) }
var trailerKey by remember { mutableStateOf<String?>(null) } var trailerKey by remember { mutableStateOf<String?>(null) }
var showTrailer by remember { mutableStateOf(false) }
var versions by remember { mutableStateOf<List<MediaVersion>>(emptyList()) } var versions by remember { mutableStateOf<List<MediaVersion>>(emptyList()) }
var selectedVersionId by remember { mutableStateOf<String?>(null) } var selectedVersionId by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var onWatchlist by remember { mutableStateOf(false) } var onWatchlist by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val playFocus = remember { FocusRequester() } val playFocus = remember { FocusRequester() }
val scroll = rememberScrollState()
LaunchedEffect(movieId) { LaunchedEffect(movieId) {
try { try {
@ -426,92 +646,97 @@ fun MovieDetailScreen(
rating?.let { add("★ $it") } rating?.let { add("★ $it") }
} }
val playLabel = selected?.let { "Afspelen · ${it.shortLabel}" } ?: "Afspelen" 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) BackHandler(onBack = onBack)
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize().background(McColors.Bg)) {
DetailBackdrop(backdrop) DetailBackdrop(backdrop)
// Fullscreen detail (geen topnav) → content start bovenaan, volle hoogte benutten
Row(
Modifier
.fillMaxSize()
.padding(
start = HomeChrome.EdgeInset,
end = HomeChrome.EdgeInset,
top = 28.dp,
bottom = 32.dp,
),
horizontalArrangement = Arrangement.spacedBy(36.dp),
) {
Column( Column(
Modifier Modifier
.fillMaxHeight() .weight(1f)
.fillMaxWidth(0.58f) .fillMaxHeight(),
.padding(start = HomeChrome.EdgeInset, end = HomeChrome.EdgeInset, top = 24.dp, bottom = 24.dp) verticalArrangement = Arrangement.spacedBy(0.dp),
.verticalScroll(scroll),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
DetailBackButton(onClick = onBack) DetailBackButton(onClick = onBack)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(16.dp))
DetailLogoOrTitle(logoUrl, title) DetailLogoOrTitle(logoUrl, title, compact = true)
Spacer(Modifier.height(10.dp))
MetaChips(metaParts) MetaChips(metaParts)
if (endsAtLabel != null) { if (endsAtLabel != null) {
Spacer(Modifier.height(4.dp))
Text( Text(
endsAtLabel, endsAtLabel,
color = Color(0xFF9A9AA8), color = Color(0xFFB8B8B8),
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
maxLines = 1,
) )
} }
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)
}
}
if (!trailerKey.isNullOrBlank()) {
DetailActionButton("Trailer") {
openYoutubeTrailer(context, trailerKey!!)
}
}
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
scope.launch {
runCatching { onWatchlist = api.toggleFavorite("movie", movieId) }
}
}
DetailActionButton("Markeer als gezien") {
scope.launch {
runCatching { api.markWatched(movieId = movieId) }
onBack()
}
}
}
if (overview.isNotBlank()) { if (overview.isNotBlank()) {
ExpandableOverview(overview) Spacer(Modifier.height(12.dp))
}
if (director.isNotEmpty()) {
Text( Text(
"Regie · ${director.take(2).joinToString(", ")}", overview,
color = Color(0xFF9A9A9A), color = Color(0xFFD6D6D6),
fontSize = 13.sp, fontSize = 15.sp,
maxLines = 1, lineHeight = 22.sp,
maxLines = 4,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
if (cast.isNotEmpty()) { if (creditLine.isNotBlank()) {
Spacer(Modifier.height(8.dp))
Text( Text(
"Met · ${cast.take(4).joinToString(", ")}", creditLine,
color = Color(0xFF8A8A8A), color = Color(0xFF8A8A8A),
fontSize = 13.sp, fontSize = 13.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
if (error != null) Text(error!!, color = McColors.Danger) if (error != null) {
if (versions.size > 1) { Spacer(Modifier.height(6.dp))
Spacer(Modifier.height(2.dp)) Text(error!!, color = McColors.Danger, fontSize = 13.sp, maxLines = 1)
Text( }
"Kwaliteit",
color = Color(0xFF8F8F8F), Spacer(Modifier.height(18.dp))
fontSize = 11.sp, Row(
fontWeight = FontWeight.Medium, horizontalArrangement = Arrangement.spacedBy(10.dp),
letterSpacing = 0.6.sp, verticalAlignment = Alignment.CenterVertically,
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(end = 8.dp, bottom = 10.dp),
) { ) {
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 -> items(versions, key = { it.id }) { ver ->
VersionPickChip( VersionPickChip(
version = ver, version = ver,
@ -528,16 +753,26 @@ fun MovieDetailScreen(
model = poster, model = poster,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.align(Alignment.CenterEnd) .align(Alignment.Top)
.padding(end = 56.dp) .padding(top = 40.dp)
.width(200.dp) .width(180.dp)
.height(300.dp) .height(270.dp)
.clip(RoundedCornerShape(8.dp)) .shadow(22.dp, RoundedCornerShape(10.dp))
.clip(RoundedCornerShape(10.dp))
.border(BorderStroke(1.dp, Color(0x33FFFFFF)), RoundedCornerShape(10.dp))
.background(Color(0xFF1A1A1A)), .background(Color(0xFF1A1A1A)),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} }
} }
if (showTrailer && !trailerKey.isNullOrBlank()) {
TrailerPlayerOverlay(
youtubeKey = trailerKey!!,
onDismiss = { showTrailer = false },
)
}
}
} }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@ -585,7 +820,6 @@ fun SeriesDetailScreen(
onBack: () -> Unit, onBack: () -> Unit,
onPlay: (mediaFileId: String, episodeId: String, title: String, startMs: Long) -> Unit, onPlay: (mediaFileId: String, episodeId: String, title: String, startMs: Long) -> Unit,
) { ) {
val context = LocalContext.current
var title by remember { mutableStateOf("…") } var title by remember { mutableStateOf("…") }
var overview by remember { mutableStateOf("") } var overview by remember { mutableStateOf("") }
var backdrop by remember { mutableStateOf<String?>(null) } var backdrop by remember { mutableStateOf<String?>(null) }
@ -594,6 +828,7 @@ fun SeriesDetailScreen(
var rating by remember { mutableStateOf<String?>(null) } var rating by remember { mutableStateOf<String?>(null) }
var genres by remember { mutableStateOf<List<String>>(emptyList()) } var genres by remember { mutableStateOf<List<String>>(emptyList()) }
var trailerKey by remember { mutableStateOf<String?>(null) } var trailerKey by remember { mutableStateOf<String?>(null) }
var showTrailer by remember { mutableStateOf(false) }
var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) } var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) }
var selectedSeason by rememberSaveable(seriesId) { mutableStateOf<Int?>(null) } var selectedSeason by rememberSaveable(seriesId) { mutableStateOf<Int?>(null) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
@ -698,7 +933,6 @@ fun SeriesDetailScreen(
val metaParts = buildList { val metaParts = buildList {
year?.let { add(it.toString()) } year?.let { add(it.toString()) }
genres.take(3).forEach(::add)
rating?.let { add("★ $it") } rating?.let { add("★ $it") }
} }
val playEpPreview = remember(episodes) { val playEpPreview = remember(episodes) {
@ -708,21 +942,22 @@ fun SeriesDetailScreen(
} }
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize().background(McColors.Bg)) {
DetailBackdrop(backdrop) DetailBackdrop(backdrop)
LazyColumn( LazyColumn(
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = HomeChrome.EdgeInset, start = HomeChrome.EdgeInset,
end = HomeChrome.EdgeInset, end = HomeChrome.EdgeInset,
top = 28.dp, top = HomeChrome.BadgeTop,
bottom = 40.dp, bottom = 40.dp,
), ),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
item { item {
DetailBackButton(onClick = onBack) DetailBackButton(onClick = onBack)
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(8.dp))
DetailLogoOrTitle(logoUrl, title) DetailLogoOrTitle(logoUrl, title)
GenrePills(genres)
MetaChips(metaParts) MetaChips(metaParts)
if (overview.isNotBlank()) { if (overview.isNotBlank()) {
ExpandableOverview( ExpandableOverview(
@ -732,7 +967,7 @@ fun SeriesDetailScreen(
} }
if (error != null) Text(error!!, color = McColors.Danger) if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
playEpPreview?.let { ep -> playEpPreview?.let { ep ->
val resume = !ep.completed && ep.positionMs > 30_000L val resume = !ep.completed && ep.positionMs > 30_000L
DetailActionButton( DetailActionButton(
@ -749,7 +984,7 @@ fun SeriesDetailScreen(
} }
} }
if (!trailerKey.isNullOrBlank()) { if (!trailerKey.isNullOrBlank()) {
DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) } DetailActionButton("Trailer") { showTrailer = true }
} }
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") { DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
scope.launch { scope.launch {
@ -759,7 +994,7 @@ fun SeriesDetailScreen(
} }
if (seasons.size > 1) { if (seasons.size > 1) {
Spacer(Modifier.height(8.dp)) 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)) Spacer(Modifier.height(4.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(seasons) { s -> items(seasons) { s ->
@ -791,6 +1026,13 @@ fun SeriesDetailScreen(
} }
} }
if (showTrailer && !trailerKey.isNullOrBlank()) {
TrailerPlayerOverlay(
youtubeKey = trailerKey!!,
onDismiss = { showTrailer = false },
)
}
val menuEp = episodeMenu val menuEp = episodeMenu
if (menuEp != null) { if (menuEp != null) {
BackHandler { episodeMenu = null } BackHandler { episodeMenu = null }

View file

@ -3,12 +3,29 @@ package nl.vonas.mediacluster.tv.ui
import android.view.ViewGroup import android.view.ViewGroup
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn 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.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -20,6 +37,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.graphics.Color
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.C
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Glow
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import nl.vonas.mediacluster.tv.data.ApiClient 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.buildLiveTvPlayer
import nl.vonas.mediacluster.tv.playback.disableTrackType
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected 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.liveMediaItem
import nl.vonas.mediacluster.tv.playback.selectTrack
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
private const val ReadyTimeoutMs = 20_000L private const val ReadyTimeoutMs = 20_000L
private const val ChromeHideMs = 5_000L
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@kotlin.OptIn(ExperimentalTvMaterial3Api::class) @kotlin.OptIn(ExperimentalTvMaterial3Api::class)
@ -58,6 +86,8 @@ fun EventPlayerScreen(
api: ApiClient, api: ApiClient,
eventId: String, eventId: String,
onBack: () -> Unit, onBack: () -> Unit,
/** Hub-play (live + replay via /client/f1/content/…/play). */
hubPlay: Boolean = false,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val playerRef = remember { AtomicReference<ExoPlayer?>(null) } val playerRef = remember { AtomicReference<ExoPlayer?>(null) }
@ -65,12 +95,38 @@ fun EventPlayerScreen(
buildLiveTvPlayer(context).also { playerRef.set(it) } buildLiveTvPlayer(context).also { playerRef.set(it) }
} }
val rootFocus = remember { FocusRequester() } val rootFocus = remember { FocusRequester() }
val audioBtnFocus = remember { FocusRequester() }
val panelFocus = remember { FocusRequester() }
val reachedReady = remember { AtomicBoolean(false) } val reachedReady = remember { AtomicBoolean(false) }
var title by remember { mutableStateOf("Live event") } var title by remember { mutableStateOf("Live event") }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var showChrome by remember { mutableStateOf(true) } var showChrome by remember { mutableStateOf(true) }
var watchSessionId by remember { mutableStateOf<String?>(null) } 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() { fun safeStopAndClear() {
runCatching { runCatching {
@ -80,14 +136,24 @@ fun EventPlayerScreen(
} }
} }
LaunchedEffect(eventId) { LaunchedEffect(eventId, hubPlay) {
loading = true loading = true
error = null error = null
watchSessionId = null watchSessionId = null
reachedReady.set(false) reachedReady.set(false)
showChrome = true showChrome = true
menu = null
audioTracks = emptyList()
textTracks = emptyList()
try { 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 if (!isActive) return@LaunchedEffect
title = play.name title = play.name
watchSessionId = play.watchSessionId watchSessionId = play.watchSessionId
@ -96,14 +162,11 @@ fun EventPlayerScreen(
error = "Geen stream-URL beschikbaar" error = "Geen stream-URL beschikbaar"
return@LaunchedEffect return@LaunchedEffect
} }
if (play.drm == null || play.drm.keys.isEmpty()) { // ClearKey optioneel: F1TV plain HLS heeft drm=null.
error = "Encryptiesleutels ontbreken — stream start binnenkort"
return@LaunchedEffect
}
val item = try { val item = try {
liveMediaItem(url, play.format, play.drm) liveMediaItem(url, play.format, play.drm)
} catch (e: Exception) { } catch (e: Exception) {
error = e.message ?: "Ongeldige DRM-sleutels" error = e.message ?: "Kon mediastream niet openen"
return@LaunchedEffect return@LaunchedEffect
} }
if (!isActive) return@LaunchedEffect if (!isActive) return@LaunchedEffect
@ -151,13 +214,27 @@ fun EventPlayerScreen(
} }
} }
LaunchedEffect(showChrome, error, loading) { LaunchedEffect(showChrome, menu, error, loading, chromeBump) {
if (showChrome && error == null && !loading) { if (showChrome && menu == null && error == null && !loading) {
delay(4_000) delay(ChromeHideMs)
showChrome = false 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) { LaunchedEffect(Unit) {
delay(80) delay(80)
runCatching { rootFocus.requestFocus() } runCatching { rootFocus.requestFocus() }
@ -169,12 +246,22 @@ fun EventPlayerScreen(
when (state) { when (state) {
Player.STATE_READY -> { Player.STATE_READY -> {
reachedReady.set(true) reachedReady.set(true)
runCatching { player.ensureAudioSelected() } runCatching {
player.ensureAudioSelected()
refreshTracks()
}
} }
else -> Unit else -> Unit
} }
} }
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
runCatching {
player.ensureAudioSelected()
refreshTracks()
}
}
override fun onPlayerError(e: PlaybackException) { override fun onPlayerError(e: PlaybackException) {
val cause = generateSequence(e as Throwable?) { it.cause } val cause = generateSequence(e as Throwable?) { it.cause }
.mapNotNull { it.message?.takeIf { m -> m.isNotBlank() } } .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( Box(
Modifier Modifier
@ -209,14 +302,38 @@ fun EventPlayerScreen(
.focusRequester(rootFocus) .focusRequester(rootFocus)
.focusable() .focusable()
.onPreviewKeyEvent { ev -> .onPreviewKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown && if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
(ev.key == Key.Back || ev.key == Key.Escape) when {
) { ev.key == Key.Back || ev.key == Key.Escape -> {
when {
menu != null -> {
menu = null
true
}
showChrome -> {
showChrome = false
true
}
else -> {
onBack() onBack()
true true
} else { }
}
}
menu != null -> false
ev.key == Key.Menu || ev.key == Key.Guide -> {
revealChrome(focusControls = true)
true
}
!showChrome -> {
revealChrome(focusControls = true)
true
}
else -> {
revealChrome()
false false
} }
}
}, },
) { ) {
if (error == null) { if (error == null) {
@ -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( Text(
title, title,
color = Color.White, 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, fontSize = 22.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
modifier = Modifier )
.align(Alignment.TopStart) Box(
.padding(28.dp), 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(
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, pendingFocusRestore: Boolean = false,
onFocusRestoreConsumed: () -> Unit = {}, onFocusRestoreConsumed: () -> Unit = {},
onOpenEvent: (String) -> Unit, onOpenEvent: (String) -> Unit,
onOpenF1Hub: (() -> Unit)? = null,
onContentFocused: () -> Unit = {}, onContentFocused: () -> Unit = {},
) { ) {
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) } var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
@ -224,10 +225,13 @@ fun EventsScreen(
else -> null 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 showFilterBar = showSportChips || showProviderChips
val filterBarFocus = remember { FocusRequester() }
val firstRailUpTarget = when { val firstRailUpTarget = when {
showFilterBar && filterBarFocusReady -> contentFocusRequester showFilterBar && filterBarFocusReady -> filterBarFocus
hasF1Entry -> contentFocusRequester
else -> navFocusRequester else -> navFocusRequester
} }
val filterFocusDown: FocusRequester? = when { val filterFocusDown: FocusRequester? = when {
@ -235,6 +239,7 @@ fun EventsScreen(
railFocusReady -> firstRailFocus railFocusReady -> firstRailFocus
else -> null else -> null
} }
val railUsesDedicatedFocus = hasF1Entry || showFilterBar
fun claimRailFocus(owner: String, ready: Boolean) { fun claimRailFocus(owner: String, ready: Boolean) {
if (ready) { if (ready) {
@ -265,6 +270,7 @@ fun EventsScreen(
} }
val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0 val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0
val restoreListIndex = remember( val restoreListIndex = remember(
hasF1Entry,
showFilterBar, showFilterBar,
live, live,
upcomingRails, upcomingRails,
@ -273,6 +279,7 @@ fun EventsScreen(
) { ) {
if (!canRestoreCard) return@remember -1 if (!canRestoreCard) return@remember -1
var idx = 0 var idx = 0
if (hasF1Entry) idx++
if (showFilterBar) idx++ if (showFilterBar) idx++
if (restoreInLive) return@remember idx if (restoreInLive) return@remember idx
if (live.isNotEmpty()) idx++ if (live.isNotEmpty()) idx++
@ -295,7 +302,7 @@ fun EventsScreen(
val activeRestoreFocus = when { val activeRestoreFocus = when {
!canRestoreCard -> null !canRestoreCard -> null
restoreIsFirstOfFirstRail -> when { restoreIsFirstOfFirstRail -> when {
showFilterBar -> firstRailFocus railUsesDedicatedFocus -> firstRailFocus
else -> contentFocusRequester else -> contentFocusRequester
} }
else -> restoreCardFocus else -> restoreCardFocus
@ -313,12 +320,14 @@ fun EventsScreen(
} }
val entryReady = when { val entryReady = when {
hasF1Entry -> true
showFilterBar -> filterBarFocusReady showFilterBar -> filterBarFocusReady
else -> railFocusReady else -> railFocusReady
} }
val needContentSink = when { val needContentSink = when {
loading && events.isEmpty() -> true loading && events.isEmpty() -> true
error != null && events.isEmpty() -> true error != null && events.isEmpty() -> true
hasF1Entry -> false
showFilterBar -> !filterBarFocusReady showFilterBar -> !filterBarFocusReady
!hasRails -> true !hasRails -> true
else -> !railFocusReady else -> !railFocusReady
@ -343,6 +352,7 @@ fun EventsScreen(
if (restoredSuccessfully) return if (restoredSuccessfully) return
val entryOk = awaitReady({ val entryOk = awaitReady({
when { when {
hasF1Entry -> true
showFilterBar -> filterBarFocusReady showFilterBar -> filterBarFocusReady
else -> railFocusReady else -> railFocusReady
} }
@ -465,6 +475,16 @@ fun EventsScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 56.dp), 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. // Eén filterrij (sport + bron) — altijd aanwezig bij data zodat focus stabiel blijft.
if (showFilterBar) { if (showFilterBar) {
item(key = "filters") { item(key = "filters") {
@ -478,8 +498,8 @@ fun EventsScreen(
onProviderSelect = { providerFilter = it }, onProviderSelect = { providerFilter = it },
liveCount = live.size, liveCount = live.size,
totalCount = filtered.size, totalCount = filtered.size,
firstFocusRequester = contentFocusRequester, firstFocusRequester = if (hasF1Entry) filterBarFocus else contentFocusRequester,
focusUp = navFocusRequester, focusUp = if (hasF1Entry) contentFocusRequester else navFocusRequester,
focusDown = filterFocusDown, focusDown = filterFocusDown,
onFocusReady = { filterBarFocusReady = it }, onFocusReady = { filterBarFocusReady = it },
onFocus = onContentFocused, onFocus = onContentFocused,
@ -511,7 +531,7 @@ fun EventsScreen(
style = EventRailStyle.Live, style = EventRailStyle.Live,
onOpen = onOpenEvent, onOpen = onOpenEvent,
onFocus = onContentFocused, onFocus = onContentFocused,
firstItemFocusRequester = if (showFilterBar) { firstItemFocusRequester = if (railUsesDedicatedFocus) {
firstRailFocus firstRailFocus
} else { } else {
contentFocusRequester contentFocusRequester
@ -545,7 +565,7 @@ fun EventsScreen(
onFocus = onContentFocused, onFocus = onContentFocused,
firstItemFocusRequester = when { firstItemFocusRequester = when {
!isFirstUpcoming -> null !isFirstUpcoming -> null
showFilterBar -> firstRailFocus railUsesDedicatedFocus -> firstRailFocus
else -> contentFocusRequester else -> contentFocusRequester
}, },
restoreEventId = bindRestoreCardId.takeIf { railHasRestore }, restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
@ -579,7 +599,7 @@ fun EventsScreen(
onFocus = onContentFocused, onFocus = onContentFocused,
firstItemFocusRequester = when { firstItemFocusRequester = when {
!isFirstUpcoming -> null !isFirstUpcoming -> null
showFilterBar -> firstRailFocus railUsesDedicatedFocus -> firstRailFocus
else -> contentFocusRequester else -> contentFocusRequester
}, },
restoreEventId = bindRestoreCardId.takeIf { railHasRestore }, 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) @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @Composable
private fun EventCard( 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 eventsReturnNonce by remember { mutableIntStateOf(0) }
var lastOpenedEventId by rememberSaveable { mutableStateOf<String?>(null) } var lastOpenedEventId by rememberSaveable { mutableStateOf<String?>(null) }
var pendingEventsFocusRestore by remember { mutableStateOf(false) } 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 favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet())
val hubTab = remember(hubTabName) { val hubTab = remember(hubTabName) {
MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home
@ -328,12 +332,21 @@ fun McNav() {
restoreEventId = lastOpenedEventId, restoreEventId = lastOpenedEventId,
pendingEventsFocusRestore = pendingEventsFocusRestore, pendingEventsFocusRestore = pendingEventsFocusRestore,
onEventsFocusRestoreConsumed = { pendingEventsFocusRestore = false }, onEventsFocusRestoreConsumed = { pendingEventsFocusRestore = false },
homeReturnNonce = homeReturnNonce,
restoreHomeKey = lastOpenedHomeKey,
pendingHomeFocusRestore = pendingHomeFocusRestore,
onHomeFocusRestoreConsumed = { pendingHomeFocusRestore = false },
onHomeItemOpened = { key ->
lastOpenedHomeKey = key
homeDetailFromHome = true
},
profileAvatarId = profileAvatarId, profileAvatarId = profileAvatarId,
profileShowAvatar = profileShowAvatar, profileShowAvatar = profileShowAvatar,
profileAvatarUrl = profileAvatarUrl, profileAvatarUrl = profileAvatarUrl,
profileName = profileName, profileName = profileName,
onOpenMovie = { nav.navigate("movie/$it") }, onOpenMovie = { nav.navigate("movie/$it") },
onOpenSeries = { nav.navigate("series/$it") }, onOpenSeries = { nav.navigate("series/$it") },
onLeaveHomeDetailOrigin = { homeDetailFromHome = false },
onBrowse = { catalogId, _title -> onBrowse = { catalogId, _title ->
nav.navigate("browse/$catalogId") nav.navigate("browse/$catalogId")
}, },
@ -352,6 +365,11 @@ fun McNav() {
lastOpenedEventId = eventId lastOpenedEventId = eventId
nav.navigate("event/${android.net.Uri.encode(eventId)}") nav.navigate("event/${android.net.Uri.encode(eventId)}")
}, },
onOpenF1Hub = {
nav.navigate("f1_hub") {
launchSingleTop = true
}
},
onSwitchProfile = { onSwitchProfile = {
scope.launch { scope.launch {
runCatching { api.clearProfile() } runCatching { api.clearProfile() }
@ -389,8 +407,14 @@ fun McNav() {
api = api, api = api,
catalogId = catalogId, catalogId = catalogId,
onBack = { nav.popBackStack() }, onBack = { nav.popBackStack() },
onOpenMovie = { nav.navigate("movie/$it") }, onOpenMovie = {
onOpenSeries = { nav.navigate("series/$it") }, homeDetailFromHome = false
nav.navigate("movie/$it")
},
onOpenSeries = {
homeDetailFromHome = false
nav.navigate("series/$it")
},
onPlayContinue = if (catalogId == "continue-watching") { onPlayContinue = if (catalogId == "continue-watching") {
{ mediaFileId, movieId, episodeId, seriesId, type, startMs -> { mediaFileId, movieId, episodeId, seriesId, type, startMs ->
val m = movieId?.ifBlank { null } ?: "-" val m = movieId?.ifBlank { null } ?: "-"
@ -405,6 +429,14 @@ fun McNav() {
} }
composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry -> composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
val id = entry.arguments?.getString("id") ?: return@composable val id = entry.arguments?.getString("id") ?: return@composable
DisposableEffect(Unit) {
onDispose {
if (homeDetailFromHome) {
pendingHomeFocusRestore = true
homeReturnNonce++
}
}
}
MovieDetailScreen( MovieDetailScreen(
api = api, api = api,
movieId = id, movieId = id,
@ -416,6 +448,14 @@ fun McNav() {
} }
composable("series/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry -> composable("series/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
val id = entry.arguments?.getString("id") ?: return@composable val id = entry.arguments?.getString("id") ?: return@composable
DisposableEffect(Unit) {
onDispose {
if (homeDetailFromHome) {
pendingHomeFocusRestore = true
homeReturnNonce++
}
}
}
SeriesDetailScreen( SeriesDetailScreen(
api = api, api = api,
seriesId = id, seriesId = id,
@ -518,6 +558,72 @@ fun McNav() {
onBack = { nav.popBackStack() }, 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, restoreEventId: String? = null,
pendingEventsFocusRestore: Boolean = false, pendingEventsFocusRestore: Boolean = false,
onEventsFocusRestoreConsumed: () -> Unit = {}, onEventsFocusRestoreConsumed: () -> Unit = {},
homeReturnNonce: Int = 0,
restoreHomeKey: String? = null,
pendingHomeFocusRestore: Boolean = false,
onHomeFocusRestoreConsumed: () -> Unit = {},
onHomeItemOpened: (String) -> Unit = {},
onLeaveHomeDetailOrigin: () -> Unit = {},
profileAvatarId: String?, profileAvatarId: String?,
profileShowAvatar: Boolean = true, profileShowAvatar: Boolean = true,
profileAvatarUrl: String? = null, profileAvatarUrl: String? = null,
@ -553,6 +665,7 @@ private fun MainHub(
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit, onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
onPlayLive: (String) -> Unit, onPlayLive: (String) -> Unit,
onOpenEvent: (String) -> Unit, onOpenEvent: (String) -> Unit,
onOpenF1Hub: () -> Unit = {},
onSwitchProfile: () -> Unit, onSwitchProfile: () -> Unit,
onManageProfiles: () -> Unit, onManageProfiles: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
@ -719,6 +832,15 @@ private fun MainHub(
runCatching { eventsContentFocus.requestFocus() } 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) // Reset scroll alleen bij echte tab-wissel naar Home (niet bij terugkeer uit detail/player)
LaunchedEffect(tab) { LaunchedEffect(tab) {
val name = tab.name val name = tab.name
@ -862,10 +984,20 @@ private fun MainHub(
listState = homeListState, listState = homeListState,
contentFocusRequester = homeContentFocus, contentFocusRequester = homeContentFocus,
navFocusRequester = navFocus, navFocusRequester = navFocus,
focusRestoreNonce = homeReturnNonce,
restoreHomeKey = restoreHomeKey,
pendingFocusRestore = pendingHomeFocusRestore,
onFocusRestoreConsumed = onHomeFocusRestoreConsumed,
onContentFocused = { navFocused = false }, onContentFocused = { navFocused = false },
onChromeCompact = { homeChromeCompact = it }, onChromeCompact = { homeChromeCompact = it },
onOpenMovie = onOpenMovie, onOpenMovie = { id ->
onOpenSeries = onOpenSeries, onHomeItemOpened("movie:$id")
onOpenMovie(id)
},
onOpenSeries = { id ->
onHomeItemOpened("series:$id")
onOpenSeries(id)
},
onSeeAll = onBrowse, onSeeAll = onBrowse,
onPlay = onPlay, onPlay = onPlay,
) )
@ -873,8 +1005,8 @@ private fun MainHub(
api = api, api = api,
gridState = watchlistGridState, gridState = watchlistGridState,
onAtTopBack = { focusTopNav() }, onAtTopBack = { focusTopNav() },
onOpenMovie = onOpenMovie, onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) },
onOpenSeries = onOpenSeries, onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) },
) )
MainTab.Movies -> BrowseScreen( MainTab.Movies -> BrowseScreen(
api = api, api = api,
@ -882,8 +1014,8 @@ private fun MainHub(
titleHint = movieSubs.firstOrNull { it.id == movieSubId }?.label ?: "Films", titleHint = movieSubs.firstOrNull { it.id == movieSubId }?.label ?: "Films",
gridState = moviesGridState, gridState = moviesGridState,
onAtTopBack = { focusTopNav() }, onAtTopBack = { focusTopNav() },
onOpenMovie = onOpenMovie, onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) },
onOpenSeries = onOpenSeries, onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) },
) )
MainTab.Series -> BrowseScreen( MainTab.Series -> BrowseScreen(
api = api, api = api,
@ -891,8 +1023,8 @@ private fun MainHub(
titleHint = seriesSubs.firstOrNull { it.id == seriesSubId }?.label ?: "Series", titleHint = seriesSubs.firstOrNull { it.id == seriesSubId }?.label ?: "Series",
gridState = seriesGridState, gridState = seriesGridState,
onAtTopBack = { focusTopNav() }, onAtTopBack = { focusTopNav() },
onOpenMovie = onOpenMovie, onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) },
onOpenSeries = onOpenSeries, onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) },
) )
MainTab.Tv -> TvScreen( MainTab.Tv -> TvScreen(
api = api, api = api,
@ -931,12 +1063,13 @@ private fun MainHub(
pendingFocusRestore = pendingEventsFocusRestore, pendingFocusRestore = pendingEventsFocusRestore,
onFocusRestoreConsumed = onEventsFocusRestoreConsumed, onFocusRestoreConsumed = onEventsFocusRestoreConsumed,
onOpenEvent = onOpenEvent, onOpenEvent = onOpenEvent,
onOpenF1Hub = onOpenF1Hub,
onContentFocused = { navFocused = false }, onContentFocused = { navFocused = false },
) )
MainTab.Search -> SearchScreen( MainTab.Search -> SearchScreen(
api = api, api = api,
onOpenMovie = onOpenMovie, onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) },
onOpenSeries = onOpenSeries, onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) },
onAtTopBack = { focusTopNav() }, onAtTopBack = { focusTopNav() },
) )
MainTab.Settings -> SettingsScreen( MainTab.Settings -> SettingsScreen(
@ -1163,6 +1296,10 @@ private fun HomeScreen(
listState: androidx.compose.foundation.lazy.LazyListState, listState: androidx.compose.foundation.lazy.LazyListState,
contentFocusRequester: FocusRequester, contentFocusRequester: FocusRequester,
navFocusRequester: FocusRequester, navFocusRequester: FocusRequester,
focusRestoreNonce: Int = 0,
restoreHomeKey: String? = null,
pendingFocusRestore: Boolean = false,
onFocusRestoreConsumed: () -> Unit = {},
onContentFocused: () -> Unit, onContentFocused: () -> Unit,
onChromeCompact: (Boolean) -> Unit = {}, onChromeCompact: (Boolean) -> Unit = {},
onOpenMovie: (String) -> Unit, onOpenMovie: (String) -> Unit,
@ -1177,12 +1314,14 @@ private fun HomeScreen(
var menuReady by remember { mutableStateOf(false) } var menuReady by remember { mutableStateOf(false) }
var shelfFocusReady by remember { mutableStateOf(false) } var shelfFocusReady by remember { mutableStateOf(false) }
var playFocusReady by remember { mutableStateOf(false) } var playFocusReady by remember { mutableStateOf(false) }
var restoreCardReady by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val menuFocus = remember { FocusRequester() } val menuFocus = remember { FocusRequester() }
val context = LocalContext.current val context = LocalContext.current
val store = remember { SessionStore(context) } val store = remember { SessionStore(context) }
val density = LocalDensity.current val density = LocalDensity.current
val firstShelfFocus = remember { FocusRequester() } val firstShelfFocus = remember { FocusRequester() }
val restoreCardFocus = remember { FocusRequester() }
suspend fun reloadHome() { suspend fun reloadHome() {
val fresh = api.home() 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. val continueItems = home?.continueWatching.orEmpty()
LaunchedEffect(Unit) { val visibleShelves = remember(home?.shelves) {
delay(80) home?.shelves.orEmpty().filter { it.items.size >= 3 }
val scrolled = listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 40 }
if (scrolled) {
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() onContentFocused()
val deadline = System.currentTimeMillis() + 700 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)
if (runCatching { focusTarget.requestFocus() }.isSuccess) {
onFocusRestoreConsumed()
return@LaunchedEffect
}
}
// Fallback: eerste zichtbare plank
val deadline = System.currentTimeMillis() + 500
while (!shelfFocusReady && System.currentTimeMillis() < deadline) { while (!shelfFocusReady && System.currentTimeMillis() < deadline) {
delay(40) delay(40)
} }
delay(40)
runCatching { firstShelfFocus.requestFocus() } runCatching { firstShelfFocus.requestFocus() }
} onFocusRestoreConsumed()
} }
val spotlight = home?.spotlight.orEmpty().ifEmpty { val spotlight = home?.spotlight.orEmpty().ifEmpty {
home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty() home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty()
} }
val continueItems = home?.continueWatching.orEmpty()
val screenH = LocalConfiguration.current.screenHeightDp.dp val screenH = LocalConfiguration.current.screenHeightDp.dp
// Hero vult ~78% scherm; planken starten op ~52% → overlappen de fade // Hero vult ~78% scherm; planken starten op ~52% → overlappen de fade
@ -1387,6 +1603,9 @@ private fun HomeScreen(
firstItemFocusRequester = firstShelfFocus, firstItemFocusRequester = firstShelfFocus,
focusUpTo = contentFocusRequester.takeIf { playFocusReady }, focusUpTo = contentFocusRequester.takeIf { playFocusReady },
onFirstFocusTargetReady = { shelfFocusReady = it }, onFirstFocusTargetReady = { shelfFocusReady = it },
restoreItemKey = bindRestoreKey.takeIf { restoreTarget?.shelfId == "__continue__" },
restoreFocusRequester = restoreCardFocus.takeIf { restoreTarget?.shelfId == "__continue__" },
onRestoreFocusReady = { restoreCardReady = it },
onRowFocused = { onRowFocused = {
onContentFocused() onContentFocused()
expandHeroFully() expandHeroFully()
@ -1417,16 +1636,16 @@ private fun HomeScreen(
} }
} }
items( items(
home?.shelves.orEmpty().filter { it.items.size >= 3 }, visibleShelves,
key = { it.id }, key = { it.id },
) { shelf -> ) { shelf ->
val catalogId = when (shelf.slug) { val catalogId = when (shelf.slug) {
"recent-movies", "recent-series", "4k-movies" -> shelf.slug "recent-movies", "recent-series", "4k-movies" -> shelf.slug
else -> shelf.slug.ifBlank { shelf.id } else -> shelf.slug.ifBlank { shelf.id }
} }
val firstVisibleShelfId = home?.shelves.orEmpty() val firstVisibleShelfId = visibleShelves.firstOrNull()?.id
.firstOrNull { it.items.size >= 3 }?.id
val isFirstShelf = continueItems.isEmpty() && shelf.id == firstVisibleShelfId val isFirstShelf = continueItems.isEmpty() && shelf.id == firstVisibleShelfId
val shelfHasRestore = restoreTarget?.shelfId == shelf.id
ShelfBlock( ShelfBlock(
title = shelf.title, title = shelf.title,
items = shelf.items, items = shelf.items,
@ -1437,6 +1656,13 @@ private fun HomeScreen(
} else { } else {
null null
}, },
restoreItemKey = bindRestoreKey.takeIf { shelfHasRestore },
restoreFocusRequester = restoreCardFocus.takeIf { shelfHasRestore },
onRestoreFocusReady = if (shelfHasRestore) {
{ restoreCardReady = it }
} else {
null
},
onRowFocused = { onRowFocused = {
onContentFocused() onContentFocused()
if (isFirstShelf) expandHeroFully() if (isFirstShelf) expandHeroFully()
@ -1708,9 +1934,29 @@ private fun ShelfBlock(
firstItemFocusRequester: FocusRequester? = null, firstItemFocusRequester: FocusRequester? = null,
focusUpTo: FocusRequester? = null, focusUpTo: FocusRequester? = null,
onFirstFocusTargetReady: ((Boolean) -> Unit)? = null, onFirstFocusTargetReady: ((Boolean) -> Unit)? = null,
restoreItemKey: String? = null,
restoreFocusRequester: FocusRequester? = null,
onRestoreFocusReady: ((Boolean) -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var rowFocused by remember { mutableStateOf(false) } 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( Column(
modifier modifier
.onFocusChanged { .onFocusChanged {
@ -1757,6 +2003,7 @@ private fun ShelfBlock(
} }
} }
LazyRow( LazyRow(
state = rowState,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
// Extra ruimte zodat focusedScale (1.10) posters niet afknipt // Extra ruimte zodat focusedScale (1.10) posters niet afknipt
contentPadding = PaddingValues( contentPadding = PaddingValues(
@ -1767,23 +2014,40 @@ private fun ShelfBlock(
), ),
) { ) {
itemsIndexed(items, key = { _, it -> "${it.type}:${it.id}:${it.progressId}" }) { index, item -> 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) { DisposableEffect(firstItemFocusRequester) {
onFirstFocusTargetReady?.invoke(true) onFirstFocusTargetReady?.invoke(true)
onDispose { onFirstFocusTargetReady?.invoke(false) } onDispose { onFirstFocusTargetReady?.invoke(false) }
} }
} }
if (isRestore && restoreFocusRequester != null && cardRequester === restoreFocusRequester) {
DisposableEffect(restoreFocusRequester) {
onRestoreFocusReady?.invoke(true)
onDispose { onRestoreFocusReady?.invoke(false) }
}
}
PosterCard( PosterCard(
item = item, item = item,
onClick = { onClick(item) }, onClick = { onClick(item) },
onLongClick = onLongClick?.let { handler -> { handler(item) } }, onLongClick = onLongClick?.let { handler -> { handler(item) } },
showCaption = showCaptions, showCaption = showCaptions,
modifier = Modifier.then( modifier = Modifier.then(
if (index == 0 && firstItemFocusRequester != null) { if (cardRequester != null) {
Modifier Modifier
.focusRequester(firstItemFocusRequester) .focusRequester(cardRequester)
.then( .then(
if (focusUpTo != null) { if (focusUpTo != null && (index == 0 || isRestore)) {
Modifier.focusProperties { up = focusUpTo } Modifier.focusProperties { up = focusUpTo }
} else { } else {
Modifier Modifier

View file

@ -20,6 +20,7 @@
A11C1EAF0000000000000002 /* AesCtr.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000002 /* AesCtr.swift */; }; A11C1EAF0000000000000002 /* AesCtr.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000002 /* AesCtr.swift */; };
A11C1EAF0000000000000003 /* CencMp4Decryptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */; }; A11C1EAF0000000000000003 /* CencMp4Decryptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */; };
A11C1EAF0000000000000004 /* ClearKeyDashProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000004 /* ClearKeyDashProxy.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 */; }; F8AE29FF63524E7D8C036C7E /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 522137D80C53480E95FF600F /* RootView.swift */; };
D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5E450343884C11B0920888 /* MainShell.swift */; }; D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5E450343884C11B0920888 /* MainShell.swift */; };
1EF110B9F82E440F999ECE37 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B20E94E6B4034B1EF282B /* Components.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 314B20E94E6B4034B1EF282B /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
@ -163,6 +165,7 @@
B11C1EAF0000000000000002 /* AesCtr.swift */, B11C1EAF0000000000000002 /* AesCtr.swift */,
B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */, B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */,
B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */, B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */,
B11C1EAF0000000000000005 /* AVLivePlayerController.swift */,
); );
path = Playback; path = Playback;
sourceTree = "<group>"; sourceTree = "<group>";
@ -338,7 +341,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 49; CURRENT_PROJECT_VERSION = 52;
DEVELOPMENT_TEAM = X25NYX3K49; DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -353,7 +356,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks" "@executable_path/Frameworks"
); );
MARKETING_VERSION = 0.5.36; MARKETING_VERSION = 0.5.39;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -371,7 +374,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 49; CURRENT_PROJECT_VERSION = 52;
DEVELOPMENT_TEAM = X25NYX3K49; DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -386,7 +389,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks" "@executable_path/Frameworks"
); );
MARKETING_VERSION = 0.5.36; MARKETING_VERSION = 0.5.39;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)"; 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 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 controlsVisible = true
@State private var loading = true @State private var loading = true
@State private var error: String? @State private var error: String?
@ -81,23 +83,48 @@ struct PlayerScreen: View {
@State private var subtitleHits: [SubtitleHit] = [] @State private var subtitleHits: [SubtitleHit] = []
@State private var subsLoading = false @State private var subsLoading = false
@State private var subsError: String? @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 { var body: some View {
ZStack { ZStack {
Color.black.ignoresSafeArea() Color.black.ignoresSafeArea()
VLCVideoView(controller: player)
if useClearKeyAV {
AVLivePlayerView(controller: av)
.ignoresSafeArea() .ignoresSafeArea()
.allowsHitTesting(false) .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()
}
// VLC eet touches op — vang tikken altijd zelf af. // Speler eet touches op — vang tikken altijd zelf af.
Color.clear Color.clear
.contentShape(Rectangle()) .contentShape(Rectangle())
.ignoresSafeArea() .ignoresSafeArea()
.onTapGesture { toggleControls() } .onTapGesture { toggleControls() }
.allowsHitTesting(!controlsVisible) .allowsHitTesting(!controlsVisible)
if loading && !player.isPlaying { if loading && !isPlaying {
ProgressView(player.stateText) ProgressView(stateText)
.tint(.white) .tint(.white)
.foregroundStyle(.white) .foregroundStyle(.white)
.allowsHitTesting(false) .allowsHitTesting(false)
@ -105,8 +132,8 @@ struct PlayerScreen: View {
if let error { if let error {
errorOverlay(error) errorOverlay(error)
} else if player.hasError { } else if playerHasError {
errorOverlay(player.errorMessage ?? "Afspeelfout") errorOverlay(playerErrorMessage ?? "Afspeelfout")
} }
if controlsVisible { if controlsVisible {
@ -116,7 +143,11 @@ struct PlayerScreen: View {
} }
.statusBarHidden(true) .statusBarHidden(true)
.persistentSystemOverlays(.hidden) .persistentSystemOverlays(.hidden)
.onAppear { app.enterPlayback() } .onAppear {
pendingStop?.cancel()
pendingStop = nil
app.enterPlayback()
}
.task { .task {
await start() await start()
scheduleHide() scheduleHide()
@ -127,8 +158,19 @@ struct PlayerScreen: View {
hideTask?.cancel() hideTask?.cancel()
progressTask?.cancel() progressTask?.cancel()
heartbeatTask?.cancel() heartbeatTask?.cancel()
Task { await saveProgress() } if !isLive {
player.stop() 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) { .sheet(isPresented: $showTracks) {
tracksSheet tracksSheet
@ -154,17 +196,17 @@ struct PlayerScreen: View {
.font(McFonts.headline) .font(McFonts.headline)
.foregroundStyle(.white) .foregroundStyle(.white)
.lineLimit(1) .lineLimit(1)
Text(player.stateText) Text(stateText)
.font(McFonts.caption) .font(McFonts.caption)
.foregroundStyle(.white.opacity(0.7)) .foregroundStyle(.white.opacity(0.7))
} }
Spacer() Spacer()
if player.pipPossible { if !useClearKeyAV && vlc.pipPossible {
Button { Button {
player.togglePictureInPicture() vlc.togglePictureInPicture()
scheduleHide() scheduleHide()
} label: { } label: {
Image(systemName: player.pipActive Image(systemName: vlc.pipActive
? "pip.exit" ? "pip.exit"
: "pip.enter") : "pip.enter")
.font(.title3) .font(.title3)
@ -198,9 +240,9 @@ struct PlayerScreen: View {
if !request.isLive { if !request.isLive {
Slider( Slider(
value: Binding( value: Binding(
get: { Double(player.position) }, get: { Double(vlc.position) },
set: { set: {
player.seek(ratio: Float($0)) vlc.seek(ratio: Float($0))
scheduleHide() scheduleHide()
} }
), ),
@ -209,33 +251,33 @@ struct PlayerScreen: View {
.tint(McColors.accent) .tint(McColors.accent)
HStack { HStack {
Text(player.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85)) Text(vlc.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
Spacer() 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) { HStack(spacing: 36) {
if !request.isLive { if !request.isLive {
Button { Button {
player.seek(toMs: max(player.currentPositionMs() - 10_000, 0)) vlc.seek(toMs: max(vlc.currentPositionMs() - 10_000, 0))
scheduleHide() scheduleHide()
} label: { } label: {
Image(systemName: "gobackward.10").font(.title).foregroundStyle(.white) Image(systemName: "gobackward.10").font(.title).foregroundStyle(.white)
} }
} }
Button { Button {
player.togglePlay() if useClearKeyAV { av.togglePlay() } else { vlc.togglePlay() }
scheduleHide() scheduleHide()
} label: { } label: {
Image(systemName: player.isPlaying ? "pause.circle.fill" : "play.circle.fill") Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 64)) .font(.system(size: 64))
.foregroundStyle(.white) .foregroundStyle(.white)
.shadow(radius: 8) .shadow(radius: 8)
} }
if !request.isLive { if !request.isLive {
Button { Button {
player.seek(toMs: player.currentPositionMs() + 10_000) vlc.seek(toMs: vlc.currentPositionMs() + 10_000)
scheduleHide() scheduleHide()
} label: { } label: {
Image(systemName: "goforward.10").font(.title).foregroundStyle(.white) Image(systemName: "goforward.10").font(.title).foregroundStyle(.white)
@ -263,16 +305,16 @@ struct PlayerScreen: View {
if trackTab == 0 { if trackTab == 0 {
List { List {
ForEach(player.subtitleTracks) { track in ForEach(vlc.subtitleTracks) { track in
Button { Button {
player.selectSubtitle(id: track.id) vlc.selectSubtitle(id: track.id)
showTracks = false showTracks = false
scheduleHide() scheduleHide()
} label: { } label: {
HStack { HStack {
Text(track.name).foregroundStyle(McColors.text) Text(track.name).foregroundStyle(McColors.text)
Spacer() Spacer()
if track.id == player.currentSubtitleId { if track.id == vlc.currentSubtitleId {
Image(systemName: "checkmark").foregroundStyle(McColors.accent) Image(systemName: "checkmark").foregroundStyle(McColors.accent)
} }
} }
@ -315,16 +357,16 @@ struct PlayerScreen: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
List { List {
ForEach(player.audioTracks) { track in ForEach(vlc.audioTracks) { track in
Button { Button {
player.selectAudio(id: track.id) vlc.selectAudio(id: track.id)
showTracks = false showTracks = false
scheduleHide() scheduleHide()
} label: { } label: {
HStack { HStack {
Text(track.name).foregroundStyle(McColors.text) Text(track.name).foregroundStyle(McColors.text)
Spacer() Spacer()
if track.id == player.currentAudioId { if track.id == vlc.currentAudioId {
Image(systemName: "checkmark").foregroundStyle(McColors.accent) Image(systemName: "checkmark").foregroundStyle(McColors.accent)
} }
} }
@ -343,7 +385,7 @@ struct PlayerScreen: View {
Button("Sluiten") { showTracks = false } Button("Sluiten") { showTracks = false }
} }
} }
.onAppear { player.refreshTracks() } .onAppear { vlc.refreshTracks() }
} }
} }
@ -399,32 +441,51 @@ struct PlayerScreen: View {
private func start() async { private func start() async {
loading = true loading = true
error = nil error = nil
if request.isLive { if useClearKeyAV {
let keys = request.needsClearKey ? (request.drm?.keys ?? []) : [] let keys = request.drm?.keys ?? []
await player.playLive( await av.play(
url: request.url, url: request.url,
fallback: request.fallbackUrl, fallback: request.fallbackUrl,
format: request.format, format: request.format,
clearKeys: keys clearKeys: keys
) )
// ClearKey/DASH: proxy-warmup + init/media + eventuele retry. let maxWaitNs: UInt64 = 18_000_000_000
let waitNs: UInt64 = request.needsClearKey ? 12_000_000_000 : 2_500_000_000 let stepNs: UInt64 = 200_000_000
try? await Task.sleep(nanoseconds: waitNs) var waited: UInt64 = 0
if request.needsClearKey && (player.hasError || !player.isPlaying) { while waited < maxWaitNs && !av.hasError {
try? await Task.sleep(nanoseconds: 6_000_000_000) if av.isPlaying { break }
if player.hasError || !player.isPlaying { try? await Task.sleep(nanoseconds: stepNs)
error = player.errorMessage waited += stepNs
?? "Kon deze ClearKey-livezender niet starten. Het DASH-manifest of de sleutels zijn mogelijk ongeldig."
} }
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 { } 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() } progressTask = Task { await progressLoop() }
} }
try? await Task.sleep(nanoseconds: 400_000_000) try? await Task.sleep(nanoseconds: 250_000_000)
loading = false loading = false
if !player.hasError { if !useClearKeyAV && !vlc.hasError {
player.refreshTracks() 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 } guard !request.isLive else { return }
let pos = player.currentPositionMs() let pos = positionMs ?? vlc.currentPositionMs()
let dur = player.durationMs() let dur = durationMs ?? vlc.durationMs()
guard pos > 0 else { return } guard pos > 0 else { return }
try? await app.api.postProgress( try? await app.api.postProgress(
mediaType: request.mediaType, mediaType: request.mediaType,
@ -492,13 +553,25 @@ struct PlayerScreen: View {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("vmc-sub-\(hit.fileId).\(ext)") .appendingPathComponent("vmc-sub-\(hit.fileId).\(ext)")
try data.write(to: url, options: .atomic) try data.write(to: url, options: .atomic)
player.addExternalSubtitle(url: url) vlc.addExternalSubtitle(url: url)
showTracks = false showTracks = false
scheduleHide() scheduleHide()
} catch { } catch {
subsError = error.localizedDescription 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 { struct PlayLauncher: View {

View file

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

View file

@ -96,6 +96,9 @@ struct BrowseScreen: View {
.toolbarColorScheme(.dark, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar)
.task { .task {
if catalogId.isEmpty { catalogId = defaultCatalog } 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() await bootstrap()
} }
.refreshable { await reload(reset: true) } .refreshable { await reload(reset: true) }
@ -271,6 +274,7 @@ struct BrowseScreen: View {
if reset { if reset {
loading = true loading = true
error = nil error = nil
loadingMore = false
} }
let id = catalogId.isEmpty ? defaultCatalog : catalogId let id = catalogId.isEmpty ? defaultCatalog : catalogId
let sortParam: String? = showSortTools ? sort.rawValue : nil 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") @@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). */ /** Audit trail voor Google Play group-lidmaatschap (geen secrets). */
model GooglePlayAccessAuditLog { model GooglePlayAccessAuditLog {
id String @id @default(uuid()) id String @id @default(uuid())

View file

@ -1810,6 +1810,95 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
return syncF1tvCatalog(config.SESSION_SECRET); 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 --- // --- Custom ClearKey/DASH live channel lists ---
app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => { app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => {
const { listLiveChannelListsAdmin } = await import("../viewer/live-channels"); 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 = { export type OdidoChannelPlayState = {
id: string; id: string;
name?: string | null;
externalId: string | null; externalId: string | null;
mediaId: string | null; mediaId: string | null;
contentId: string | null; contentId: string | null;
@ -74,7 +75,8 @@ async function fetchKeysIfNeeded(
sessionSecret: string, sessionSecret: string,
existingKid: string | null, existingKid: string | null,
existingKey: string | null, existingKey: string | null,
allowFetch: boolean allowFetch: boolean,
meta?: { channelName?: string | null; externalId?: string | null }
): Promise<{ kidEnc: string | null; keyEnc: string | null }> { ): Promise<{ kidEnc: string | null; keyEnc: string | null }> {
if (existingKid && existingKey) return { kidEnc: existingKid, keyEnc: existingKey }; if (existingKid && existingKey) return { kidEnc: existingKid, keyEnc: existingKey };
if (!allowFetch || !play.licenseUrl || !play.licenseToken) { if (!allowFetch || !play.licenseUrl || !play.licenseToken) {
@ -92,6 +94,19 @@ async function fetchKeysIfNeeded(
}); });
const key = keys.map(parseKey).find((item) => item !== null); const key = keys.map(parseKey).find((item) => item !== null);
if (!key) throw new Error("Geen bruikbare ClearKey ontvangen"); 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 { return {
kidEnc: encryptSecret(key.kid, sessionSecret), kidEnc: encryptSecret(key.kid, sessionSecret),
keyEnc: encryptSecret(key.key, sessionSecret), keyEnc: encryptSecret(key.key, sessionSecret),
@ -159,7 +174,8 @@ export async function refreshChannelMpd(
sessionSecret, sessionSecret,
channel.kidEnc, channel.kidEnc,
channel.keyEnc, channel.keyEnc,
opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay,
{ channelName: channel.name, externalId }
); );
const now = new Date(); const now = new Date();

View file

@ -314,6 +314,24 @@ export async function syncOdidoList(
if (!key) throw new Error("Geen bruikbare ClearKey ontvangen"); if (!key) throw new Error("Geen bruikbare ClearKey ontvangen");
kidEnc = encryptSecret(key.kid, sessionSecret); kidEnc = encryptSecret(key.kid, sessionSecret);
keyEnc = encryptSecret(key.key, 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 { } catch {
counts.failed += 1; counts.failed += 1;
} }

View file

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