Compare commits

..

No commits in common. "85da6f59ad3fc0fe0b3568b5042327d099faeac6" and "d8a1c28283cf3e5d065b75c0420351006e11c836" have entirely different histories.

25 changed files with 432 additions and 5726 deletions

View file

@ -1,325 +0,0 @@
"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,7 +14,6 @@ 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,12 +299,11 @@ 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 onlyClearKey = existing != null && val hasClearKey = existing != null && (0 until existing.schemeDataCount).any {
existing.schemeDataCount > 0 && existing.get(it).matches(C.CLEARKEY_UUID)
(0 until existing.schemeDataCount).all { existing.get(it).matches(C.CLEARKEY_UUID) } }
if (onlyClearKey) return format if (hasClearKey || keyIds.isEmpty()) 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)
@ -313,10 +312,7 @@ private class InjectingClearKeyDrmSessionManager(
MimeTypes.VIDEO_MP4, MimeTypes.VIDEO_MP4,
pssh, pssh,
) )
Log.i( Log.i(TAG, "Injecting ClearKey PSSH with ${keyIds.size} key id(s)")
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()
@ -335,11 +331,10 @@ fun liveMediaItem(
format: String, format: String,
drm: DrmInfo? = null, drm: DrmInfo? = null,
): MediaItem { ): MediaItem {
// URL wint: ESPN/FAAB levert .m3u8 terwijl de API soms format=dash hardcodet. val isDash = format.equals("dash", ignoreCase = true) ||
val isHls = url.contains("m3u8", ignoreCase = true) || url.contains(".mpd", ignoreCase = true)
format.equals("hls", ignoreCase = true) val isHls = !isDash && (
val isDash = !isHls && ( format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true)
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,32 +3,14 @@ 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
@ -38,51 +20,35 @@ 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,
@ -100,30 +66,9 @@ 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
@ -138,9 +83,6 @@ 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
@ -204,9 +146,9 @@ fun EventPlayerScreen(
} }
} }
LaunchedEffect(showChrome, menu, error, loading, chromeBump) { LaunchedEffect(showChrome, error, loading) {
if (showChrome && menu == null && error == null && !loading) { if (showChrome && error == null && !loading) {
delay(ChromeHideMs) delay(4_000)
showChrome = false showChrome = false
} }
} }
@ -217,22 +159,12 @@ fun EventPlayerScreen(
when (state) { when (state) {
Player.STATE_READY -> { Player.STATE_READY -> {
reachedReady.set(true) reachedReady.set(true)
runCatching { runCatching { player.ensureAudioSelected() }
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() } }
@ -258,13 +190,7 @@ fun EventPlayerScreen(
} }
} }
BackHandler { BackHandler(onBack = onBack)
when {
menu != null -> menu = null
showChrome -> showChrome = false
else -> onBack()
}
}
Box( Box(
Modifier Modifier
@ -274,13 +200,7 @@ fun EventPlayerScreen(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null,
) { ) {
if (error != null || loading) return@clickable if (error == null && !loading) showChrome = !showChrome
if (menu != null) {
menu = null
} else {
showChrome = !showChrome
if (showChrome) chromeBump++
}
}, },
) { ) {
if (error == null) { if (error == null) {
@ -329,324 +249,26 @@ fun EventPlayerScreen(
) )
} }
} }
} else { } else if (showChrome) {
AnimatedVisibility(
visible = showChrome || menu != null,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(
EventPlayerLook.Scrim,
Color.Transparent,
Color.Transparent,
EventPlayerLook.Scrim,
),
),
),
)
Column(
Modifier
.align(Alignment.TopStart)
.statusBarsPadding()
.padding(horizontal = 20.dp, vertical = 12.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.background(EventPlayerLook.Live, RoundedCornerShape(4.dp))
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(
"LIVE",
color = Color.White,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
)
}
Box(
Modifier
.size(width = 3.dp, height = 12.dp)
.background(EventPlayerLook.Neon, RoundedCornerShape(2.dp)),
)
Text(
"EVENT",
color = EventPlayerLook.Neon,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
)
}
McButton("Terug", compact = true, onClick = onBack)
}
Text(
title,
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Column(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 20.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MobileEventChip(
label = "Audio",
subtitle = when {
audioTracks.isEmpty() -> "—"
else -> audioTracks.firstOrNull {
trackKey(it) == selectedAudioKey
}?.label ?: audioTracks.first().label
},
modifier = Modifier.weight(1f),
onClick = {
revealChrome()
menu = "audio"
},
)
MobileEventChip(
label = "Ondertiteling",
subtitle = when {
textDisabled || textTracks.isEmpty() -> "Uit"
else -> textTracks.firstOrNull {
trackKey(it) == selectedTextKey
}?.label ?: "Aan"
},
modifier = Modifier.weight(1f),
onClick = {
revealChrome()
menu = "text"
},
)
}
}
}
}
AnimatedVisibility(
visible = menu != null,
enter = fadeIn() + slideInVertically { it / 2 },
exit = fadeOut() + slideOutVertically { it / 2 },
) {
val isAudio = menu == "audio"
val tracks = if (isAudio) audioTracks else textTracks
Box(
Modifier
.fillMaxSize()
.background(Color(0x99050A08))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { menu = null },
contentAlignment = Alignment.BottomCenter,
) {
Column(
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.background(
EventPlayerLook.Panel,
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
)
.border(
width = 1.dp,
brush = Brush.horizontalGradient(
listOf(EventPlayerLook.NeonGlow, Color.Transparent, EventPlayerLook.NeonSoft),
),
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { /* consume */ }
.padding(horizontal = 20.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
Modifier
.align(Alignment.CenterHorizontally)
.width(40.dp)
.height(4.dp)
.background(Color(0x44FFFFFF), RoundedCornerShape(2.dp)),
)
Text(
if (isAudio) "Audio" else "Ondertiteling",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
Box(
Modifier
.width(36.dp)
.height(3.dp)
.background(EventPlayerLook.Neon, RoundedCornerShape(2.dp)),
)
if (!isAudio) {
MobileEventTrackRow(
label = "Uit",
selected = textDisabled || tracks.isEmpty(),
onClick = {
player.disableTrackType(C.TRACK_TYPE_TEXT)
textDisabled = true
selectedTextKey = null
menu = null
revealChrome()
},
)
}
if (tracks.isEmpty()) {
Text(
if (isAudio) "Geen audiosporen in deze stream"
else "Geen ondertitels in deze stream",
color = McColors.TextDim,
fontSize = 14.sp,
modifier = Modifier.padding(vertical = 8.dp),
)
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.height((tracks.size.coerceAtMost(6) * 56).dp.coerceAtLeast(56.dp)),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(tracks, key = { trackKey(it) }) { track ->
val selected = if (isAudio) {
trackKey(track) == selectedAudioKey
} else {
!textDisabled && trackKey(track) == selectedTextKey
}
MobileEventTrackRow(
label = track.label,
selected = selected,
onClick = {
player.selectTrack(track)
if (isAudio) {
selectedAudioKey = trackKey(track)
} else {
textDisabled = false
selectedTextKey = trackKey(track)
}
menu = null
revealChrome()
},
)
}
}
Spacer(Modifier.height(4.dp))
MobileEventChip(
label = "Sluiten",
subtitle = null,
modifier = Modifier.fillMaxWidth(),
onClick = { menu = null },
)
}
}
}
}
}
}
private fun trackKey(track: PlayerTrack): String = "${track.groupIndex}:${track.trackIndex}"
private fun ExoPlayer.activeTrackKey(type: Int): String? {
val groups = currentTracks.groups
for (gi in 0 until groups.size) {
val group = groups[gi]
if (group.type != type || !group.isSelected) continue
for (ti in 0 until group.length) {
if (group.isTrackSelected(ti)) return "$gi:$ti"
}
}
return null
}
@Composable
private fun MobileEventChip(
label: String,
subtitle: String?,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Column(
modifier
.background(EventPlayerLook.PanelHot, RoundedCornerShape(12.dp))
.border(1.dp, EventPlayerLook.NeonSoft, RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
label,
color = EventPlayerLook.Neon,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
)
if (subtitle != null) {
Text( Text(
subtitle, title,
color = McColors.TextDim, color = Color.White,
fontSize = 12.sp, fontSize = 18.sp,
maxLines = 1, fontWeight = FontWeight.SemiBold,
overflow = TextOverflow.Ellipsis, modifier = Modifier
.align(Alignment.TopStart)
.statusBarsPadding()
.padding(20.dp),
)
McButton(
"Terug",
compact = true,
modifier = Modifier
.align(Alignment.TopEnd)
.statusBarsPadding()
.padding(16.dp),
onClick = onBack,
) )
} }
} }
} }
@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 = 143 versionCode = 135
versionName = "0.14.80" versionName = "0.14.72"
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,152 +636,37 @@ 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 ->
fun eventsOf(key: String): List<ScheduleEvent> { val drmObj = o.optJSONObject("drm")
val arr = o.optJSONArray(key) ?: JSONArray() val drm = if (drmObj != null) {
return buildList { val keysArr = drmObj.optJSONArray("keys") ?: JSONArray()
for (i in 0 until arr.length()) { val keys = buildList {
arr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) } for (i in 0 until keysArr.length()) {
val k = keysArr.optJSONObject(i) ?: continue
val kid = k.optString("kid").ifBlank { null } ?: continue
val key = k.optString("key").ifBlank { null } ?: continue
add(ClearKeyEntry(kid = kid, key = key))
} }
} }
} DrmInfo(
fun meetingsOf(key: String): List<F1tvMeeting> { type = drmObj.optString("type").ifBlank { "clearkey" },
val arr = o.optJSONArray(key) ?: JSONArray() keys = keys,
return buildList { ).takeIf { it.keys.isNotEmpty() }
for (i in 0 until arr.length()) { } else null
arr.optJSONObject(i)?.toF1tvMeeting()?.let { add(it) } IptvPlayInfo(
} streamId = o.optString("eventId").ifBlank { eventId },
} name = o.getString("name"),
} logoUrl = o.optString("logoUrl").ifBlank { null },
fun replayGroupsOf(key: String): List<F1tvReplayGroup> { streamUrl = o.getString("streamUrl"),
val arr = o.optJSONArray(key) ?: JSONArray() format = o.optString("format").ifBlank { "dash" },
return buildList { fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null },
for (i in 0 until arr.length()) { fallbackFormat = o.optString("fallbackFormat").ifBlank { null },
val g = arr.optJSONObject(i) ?: continue drm = drm,
val sessionsArr = g.optJSONArray("sessions") ?: JSONArray() watchSessionId = o.optString("watchSessionId").ifBlank { null },
val sessions = buildList {
for (si in 0 until sessionsArr.length()) {
sessionsArr.optJSONObject(si)?.toScheduleEvent()?.let { add(it) }
}
}
val meetingKey = g.optClean("meetingKey") ?: continue
add(
F1tvReplayGroup(
meetingKey = meetingKey,
title = g.optClean("title") ?: meetingKey,
location = g.optClean("location"),
meetingNumber = g.optInt("meetingNumber").takeIf { it > 0 },
imageUrl = g.optClean("imageUrl"),
latestStart = g.optClean("latestStart") ?: "",
sessions = sessions,
),
)
}
}
}
F1tvHomePayload(
generatedAt = o.optClean("generatedAt"),
live = eventsOf("live"),
upcoming = eventsOf("upcoming"),
recentReplays = eventsOf("recentReplays"),
replayGroups = replayGroupsOf("replayGroups"),
meetings = meetingsOf("meetings"),
nextMeeting = o.optJSONObject("nextMeeting")?.toF1tvMeeting(),
) )
} }
} }
suspend fun getF1tvMeeting(meetingKey: String): F1tvMeetingDetail = withContext(Dispatchers.IO) {
val enc = android.net.Uri.encode(meetingKey)
val req = authed("$baseUrl/api/v1/client/f1/meetings/$enc").get().build()
parseObject(execute(req)) { o ->
val sessionsArr = o.optJSONArray("sessions") ?: JSONArray()
val sessions = buildList {
for (i in 0 until sessionsArr.length()) {
sessionsArr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) }
}
}
fun eventsOf(key: String): List<ScheduleEvent> {
val arr = o.optJSONArray(key) ?: JSONArray()
return buildList {
for (i in 0 until arr.length()) {
arr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) }
}
}
}
F1tvMeetingDetail(
meeting = o.optJSONObject("meeting")?.toF1tvMeeting(),
sessions = sessions,
replays = eventsOf("replays"),
upcoming = eventsOf("upcoming"),
live = eventsOf("live"),
)
}
}
/** Hub play (live + replay). */
suspend fun f1tvContentPlay(contentId: String): IptvPlayInfo = withContext(Dispatchers.IO) {
val enc = android.net.Uri.encode(contentId)
val req = authed("$baseUrl/api/v1/client/f1/content/$enc/play").get().build()
parseObject(execute(req)) { o ->
o.toIptvPlayInfo(fallbackStreamId = "f1tv:$contentId", defaultFormat = "hls")
}
}
private fun JSONObject.toIptvPlayInfo(
fallbackStreamId: String,
defaultFormat: String,
): IptvPlayInfo {
val drmObj = optJSONObject("drm")
val drm = if (drmObj != null) {
val keysArr = drmObj.optJSONArray("keys") ?: JSONArray()
val keys = buildList {
for (i in 0 until keysArr.length()) {
val k = keysArr.optJSONObject(i) ?: continue
val kid = k.optString("kid").ifBlank { null } ?: continue
val key = k.optString("key").ifBlank { null } ?: continue
add(ClearKeyEntry(kid = kid, key = key))
}
}
DrmInfo(
type = drmObj.optString("type").ifBlank { "clearkey" },
keys = keys,
).takeIf { it.keys.isNotEmpty() }
} else null
return IptvPlayInfo(
streamId = optString("eventId").ifBlank { fallbackStreamId },
name = getString("name"),
logoUrl = optString("logoUrl").ifBlank { null },
streamUrl = getString("streamUrl"),
format = optString("format").ifBlank { defaultFormat },
fallbackStreamUrl = optString("fallbackStreamUrl").ifBlank { null },
fallbackFormat = optString("fallbackFormat").ifBlank { null },
drm = drm,
watchSessionId = optString("watchSessionId").ifBlank { null },
)
}
private fun JSONObject.toF1tvMeeting(): F1tvMeeting? {
val meetingKey = optClean("meetingKey") ?: return null
return F1tvMeeting(
meetingKey = meetingKey,
contentId = optLong("contentId").takeIf { it > 0 },
title = optClean("title") ?: "Meeting $meetingKey",
pageId = optLong("pageId").takeIf { it > 0 },
pageUri = optClean("pageUri"),
meetingNumber = optInt("meetingNumber").takeIf { it > 0 },
location = optClean("location"),
startMs = optLong("startMs").takeIf { it > 0 },
endMs = optLong("endMs").takeIf { it > 0 },
imageUrl = optClean("imageUrl"),
)
}
/** /**
* Presence-heartbeat voor events / custom live-lists. * Presence-heartbeat voor events / custom live-lists.
* Best-effort: never throws to callers that ignore failures. * Best-effort: never throws to callers that ignore failures.
@ -1252,47 +1137,6 @@ 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,14 +309,11 @@ 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
// Alleen ClearKey → laten staan. Anders forceren (ESPN HLS heeft Widevine/PlayReady val hasClearKey = existing != null && (0 until existing.schemeDataCount).any {
// SESSION-KEY; wij hebben ClearKey-kids uit de feed). existing.get(it).matches(C.CLEARKEY_UUID)
val onlyClearKey = existing != null && }
existing.schemeDataCount > 0 && if (hasClearKey || keyIds.isEmpty()) return format
(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)
@ -325,10 +322,7 @@ private class InjectingClearKeyDrmSessionManager(
MimeTypes.VIDEO_MP4, MimeTypes.VIDEO_MP4,
pssh, pssh,
) )
Log.i( Log.i(TAG, "Injecting ClearKey PSSH with ${keyIds.size} key id(s)")
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()
@ -347,11 +341,10 @@ fun liveMediaItem(
format: String, format: String,
drm: DrmInfo? = null, drm: DrmInfo? = null,
): MediaItem { ): MediaItem {
// URL wint: ESPN/FAAB levert .m3u8 terwijl de API soms format=dash hardcodet. val isDash = format.equals("dash", ignoreCase = true) ||
val isHls = url.contains("m3u8", ignoreCase = true) || url.contains(".mpd", ignoreCase = true)
format.equals("hls", ignoreCase = true) val isHls = !isDash && (
val isDash = !isHls && ( format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true)
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,20 +1,12 @@
package nl.vonas.mediacluster.tv.ui package nl.vonas.mediacluster.tv.ui
import android.annotation.SuppressLint import android.content.ActivityNotFoundException
import android.os.Handler import android.content.Intent
import android.os.Looper import android.net.Uri
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.BorderStroke import androidx.compose.foundation.rememberScrollState
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
@ -34,13 +26,11 @@ 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
@ -49,23 +39,24 @@ 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.draw.shadow import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.components.HomeChrome
import nl.vonas.mediacluster.tv.ui.components.formatEndsAt
import nl.vonas.mediacluster.tv.ui.components.rememberClockTickMs
import nl.vonas.mediacluster.tv.ui.components.remainingMsFromRuntimeMinutes
import androidx.compose.ui.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
@ -76,15 +67,10 @@ 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
@ -115,197 +101,36 @@ private fun formatRuntime(minutes: Int?): String? {
} }
} }
@SuppressLint("SetJavaScriptEnabled") private fun openYoutubeTrailer(context: android.content.Context, key: String) {
@Composable val appPackages = listOf(
private fun TrailerPlayerOverlay( "com.google.android.youtube.tv",
youtubeKey: String, "com.google.android.youtube.googletv",
onDismiss: () -> Unit, "com.google.android.youtube",
) { )
var loading by remember { mutableStateOf(true) } val appIntent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:$key")).apply {
var paused by remember { mutableStateOf(false) } addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
var webView by remember { mutableStateOf<WebView?>(null) }
val catcherFocus = remember { FocusRequester() }
val mainHandler = remember { Handler(Looper.getMainLooper()) }
BackHandler(onBack = onDismiss)
LaunchedEffect(Unit) {
delay(50)
catcherFocus.requestFocus()
} }
for (pkg in appPackages) {
Dialog( try {
onDismissRequest = onDismiss, appIntent.setPackage(pkg)
properties = DialogProperties( context.startActivity(appIntent)
dismissOnBackPress = true, return
dismissOnClickOutside = false, } catch (_: ActivityNotFoundException) {
usePlatformDefaultWidth = false, // try next
), } catch (_: Exception) {
) { // try next
Box(
Modifier
.fillMaxSize()
.background(Color.Black),
) {
AndroidView(
factory = { ctx ->
WebView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
setBackgroundColor(android.graphics.Color.BLACK)
isFocusable = false
isFocusableInTouchMode = false
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
webChromeClient = WebChromeClient()
webViewClient = WebViewClient()
addJavascriptInterface(
object {
@JavascriptInterface
fun onPlaying() {
mainHandler.post {
loading = false
paused = false
}
}
@JavascriptInterface
fun onPaused() {
mainHandler.post { paused = true }
}
@JavascriptInterface
fun onEnded() {
mainHandler.post(onDismiss)
}
},
"VmcTrailer",
)
// Cinema mode: scaled/cropped iframe + pointer-events none hides
// YouTube chrome/watermark and blocks "Watch on YouTube" taps.
val html = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="referrer" content="strict-origin-when-cross-origin">
<style>
html,body{margin:0;height:100%;background:#000;overflow:hidden}
#stage{position:fixed;inset:0;overflow:hidden;background:#000}
#player{position:absolute;top:50%;left:50%;width:128vw;height:128vh;
transform:translate(-50%,-50%);pointer-events:none}
#player iframe{width:100%!important;height:100%!important;border:0;
pointer-events:none}
</style>
</head>
<body>
<div id="stage"><div id="player"></div></div>
<script>
var tag=document.createElement('script');
tag.src='https://www.youtube.com/iframe_api';
document.head.appendChild(tag);
var player;
function onYouTubeIframeAPIReady(){
player=new YT.Player('player',{
height:'100%',width:'100%',
videoId:'$youtubeKey',
host:'https://www.youtube-nocookie.com',
playerVars:{
autoplay:1,controls:0,disablekb:1,fs:0,rel:0,
iv_load_policy:3,playsinline:1,cc_load_policy:0,
modestbranding:1,showinfo:0,
origin:'https://nl.vonas.mediacluster.tv'
},
events:{
onReady:function(e){
try{e.target.setPlaybackQuality('hd1080');}catch(_){}
e.target.playVideo();
},
onStateChange:function(e){
if(e.data===1) VmcTrailer.onPlaying();
else if(e.data===2) VmcTrailer.onPaused();
else if(e.data===0) VmcTrailer.onEnded();
},
onError:function(){VmcTrailer.onEnded();}
}
});
}
function vmcToggle(){
if(!player||!player.getPlayerState) return;
if(player.getPlayerState()===1) player.pauseVideo();
else player.playVideo();
}
</script>
</body>
</html>
""".trimIndent()
loadDataWithBaseURL(
"https://nl.vonas.mediacluster.tv/",
html,
"text/html",
"UTF-8",
null,
)
webView = this
}
},
modifier = Modifier.fillMaxSize(),
onRelease = {
it.destroy()
webView = null
},
)
// Catch remote input so YouTube UI never receives focus/clicks.
Box(
Modifier
.fillMaxSize()
.focusRequester(catcherFocus)
.focusable()
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false
when (event.nativeKeyEvent.keyCode) {
AndroidKeyEvent.KEYCODE_DPAD_CENTER,
AndroidKeyEvent.KEYCODE_ENTER,
AndroidKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
AndroidKeyEvent.KEYCODE_MEDIA_PLAY,
AndroidKeyEvent.KEYCODE_MEDIA_PAUSE,
-> {
webView?.evaluateJavascript("vmcToggle();", null)
true
}
else -> false
}
},
)
if (loading) {
Text(
"Bezig met laden…",
color = Color(0xFFB0B8C8),
fontSize = 18.sp,
modifier = Modifier.align(Alignment.Center),
)
}
if (paused && !loading) {
Box(
Modifier
.align(Alignment.Center)
.size(72.dp)
.clip(CircleShape)
.background(Color(0x99000000)),
contentAlignment = Alignment.Center,
) {
Text("❚❚", color = Color.White, fontSize = 22.sp)
}
}
} }
} }
try {
context.startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse("https://www.youtube.com/watch?v=$key"),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
} catch (_: Exception) {
// ignore
}
} }
@Composable @Composable
@ -317,28 +142,25 @@ 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(
0.0f to Color(0xF20B0B0B), 0f to Color(0xF20B0B0B),
0.40f to Color(0xCC0B0B0B), 0.42f to Color(0xCC0B0B0B),
0.72f to Color(0x550B0B0B), 0.72f to Color(0x660B0B0B),
1.0f to Color(0x180B0B0B), 1f to Color(0x220B0B0B),
), ),
), ),
) )
Box( Box(
Modifier.fillMaxSize().background( Modifier.fillMaxSize().background(
Brush.verticalGradient( Brush.verticalGradient(
0.0f to Color(0x66000000), 0f to Color(0x660B0B0B),
0.12f to Color.Transparent, 0.35f to Color.Transparent,
0.45f to Color.Transparent, 0.7f to Color(0xAA0B0B0B),
0.70f to Color(0xAA0B0B0B), 1f to Color(0xFF0B0B0B),
1.0f to Color(0xFF0B0B0B),
), ),
), ),
) )
@ -346,25 +168,15 @@ private fun DetailBackdrop(url: String?) {
} }
@Composable @Composable
private fun DetailLogoOrTitle( private fun DetailLogoOrTitle(logoUrl: String?, title: String) {
logoUrl: String?,
title: String,
compact: Boolean = false,
) {
if (!logoUrl.isNullOrBlank()) { if (!logoUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = logoUrl, model = logoUrl,
contentDescription = title, contentDescription = title,
modifier = if (compact) { modifier = Modifier
Modifier .widthIn(max = 420.dp)
.heightIn(max = 56.dp) .heightIn(max = 120.dp)
.widthIn(max = 280.dp) .fillMaxWidth(0.85f),
} else {
Modifier
.widthIn(max = 420.dp)
.heightIn(max = 100.dp)
.fillMaxWidth(0.85f)
},
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart, alignment = Alignment.CenterStart,
) )
@ -372,43 +184,15 @@ private fun DetailLogoOrTitle(
Text( Text(
title, title,
color = Color.White, color = Color.White,
fontSize = if (compact) 32.sp else 40.sp, fontSize = 40.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = if (compact) 2 else 3, maxLines = 3,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
lineHeight = if (compact) 36.sp else 46.sp, lineHeight = 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
@ -423,8 +207,8 @@ private fun MetaChips(parts: List<String>) {
} }
Text( Text(
part, part,
color = if (part.startsWith("★")) Color(0xFFFFD54F) else Color(0xFFCFCFCF), color = if (part.startsWith("★")) Color(0xFFFFD54F) else McColors.TextDim,
fontSize = 15.sp, fontSize = 14.sp,
fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium, fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium,
) )
} }
@ -447,7 +231,7 @@ private fun DetailBackButton(onClick: () -> Unit) {
), ),
border = ClickableSurfaceDefaults.border( border = ClickableSurfaceDefaults.border(
focusedBorder = Border( focusedBorder = Border(
border = BorderStroke(1.dp, Color.White), border = androidx.compose.foundation.BorderStroke(1.dp, Color.White),
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
), ),
), ),
@ -470,43 +254,38 @@ private fun DetailActionButton(
focusRequester: FocusRequester? = null, focusRequester: FocusRequester? = null,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val shape = RoundedCornerShape(6.dp) val bg = Color(0xE6181818)
val idleBg = if (primary) McColors.Accent else Color(0xE6181818) val bgFocus = Color.White
val focusBg = if (primary) Color.White else Color.White val fg = 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 = shape), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
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 = idleBg, containerColor = bg,
focusedContainerColor = focusBg, focusedContainerColor = bgFocus,
contentColor = idleFg, contentColor = fg,
focusedContentColor = focusFg, focusedContentColor = Color.Black,
), ),
border = ClickableSurfaceDefaults.border( border = ClickableSurfaceDefaults.border(
border = Border( border = Border(
border = BorderStroke( border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x55FFFFFF)),
if (primary) 0.dp else 1.dp, shape = RoundedCornerShape(4.dp),
if (primary) Color.Transparent else Color(0x55FFFFFF),
),
shape = shape,
), ),
focusedBorder = Border( focusedBorder = Border(
border = BorderStroke(2.dp, if (primary) McColors.Accent else Color.White), border = androidx.compose.foundation.BorderStroke(2.dp, Color.White),
shape = shape, shape = RoundedCornerShape(4.dp),
), ),
), ),
) { ) {
Text( Text(
text = label, text = label,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
color = Color.Unspecified, color = Color.Unspecified,
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
) )
} }
@ -566,6 +345,7 @@ 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) }
@ -578,13 +358,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 {
@ -646,130 +426,115 @@ 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().background(McColors.Bg)) { Box(Modifier.fillMaxSize()) {
DetailBackdrop(backdrop) DetailBackdrop(backdrop)
Column(
// Fullscreen detail (geen topnav) → content start bovenaan, volle hoogte benutten
Row(
Modifier Modifier
.fillMaxSize() .fillMaxHeight()
.padding( .fillMaxWidth(0.58f)
start = HomeChrome.EdgeInset, .padding(start = HomeChrome.EdgeInset, end = HomeChrome.EdgeInset, top = 24.dp, bottom = 24.dp)
end = HomeChrome.EdgeInset, .verticalScroll(scroll),
top = 28.dp, verticalArrangement = Arrangement.spacedBy(8.dp),
bottom = 32.dp,
),
horizontalArrangement = Arrangement.spacedBy(36.dp),
) { ) {
Column( DetailBackButton(onClick = onBack)
Modifier Spacer(Modifier.height(4.dp))
.weight(1f) DetailLogoOrTitle(logoUrl, title)
.fillMaxHeight(), MetaChips(metaParts)
verticalArrangement = Arrangement.spacedBy(0.dp), if (endsAtLabel != null) {
) { Text(
DetailBackButton(onClick = onBack) endsAtLabel,
Spacer(Modifier.height(16.dp)) color = Color(0xFF9A9AA8),
DetailLogoOrTitle(logoUrl, title, compact = true) fontSize = 14.sp,
Spacer(Modifier.height(10.dp)) fontWeight = FontWeight.Medium,
MetaChips(metaParts) )
if (endsAtLabel != null) { }
Spacer(Modifier.height(4.dp)) if (versions.size > 1) {
Text( SelectedQualityLine(selected)
endsAtLabel, }
color = Color(0xFFB8B8B8), Spacer(Modifier.height(4.dp))
fontSize = 14.sp, Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
fontWeight = FontWeight.Medium, selected?.let { ver ->
maxLines = 1, DetailActionButton(playLabel, primary = true, focusRequester = playFocus) {
) onPlay(ver.id, title, 0)
}
if (overview.isNotBlank()) {
Spacer(Modifier.height(12.dp))
Text(
overview,
color = Color(0xFFD6D6D6),
fontSize = 15.sp,
lineHeight = 22.sp,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
if (creditLine.isNotBlank()) {
Spacer(Modifier.height(8.dp))
Text(
creditLine,
color = Color(0xFF8A8A8A),
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (error != null) {
Spacer(Modifier.height(6.dp))
Text(error!!, color = McColors.Danger, fontSize = 13.sp, maxLines = 1)
}
Spacer(Modifier.height(18.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
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 (!trailerKey.isNullOrBlank()) {
if (versions.size > 1) { DetailActionButton("Trailer") {
Spacer(Modifier.height(12.dp)) openYoutubeTrailer(context, trailerKey!!)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { }
items(versions, key = { it.id }) { ver -> }
VersionPickChip( DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
version = ver, scope.launch {
selected = ver.id == selected?.id, runCatching { onWatchlist = api.toggleFavorite("movie", movieId) }
onClick = { selectedVersionId = ver.id }, }
) }
} DetailActionButton("Markeer als gezien") {
scope.launch {
runCatching { api.markWatched(movieId = movieId) }
onBack()
} }
} }
} }
if (overview.isNotBlank()) {
if (!poster.isNullOrBlank()) { ExpandableOverview(overview)
AsyncImage( }
model = poster, if (director.isNotEmpty()) {
contentDescription = null, Text(
modifier = Modifier "Regie · ${director.take(2).joinToString(", ")}",
.align(Alignment.Top) color = Color(0xFF9A9A9A),
.padding(top = 40.dp) fontSize = 13.sp,
.width(180.dp) maxLines = 1,
.height(270.dp) overflow = TextOverflow.Ellipsis,
.shadow(22.dp, RoundedCornerShape(10.dp))
.clip(RoundedCornerShape(10.dp))
.border(BorderStroke(1.dp, Color(0x33FFFFFF)), RoundedCornerShape(10.dp))
.background(Color(0xFF1A1A1A)),
contentScale = ContentScale.Crop,
) )
} }
if (cast.isNotEmpty()) {
Text(
"Met · ${cast.take(4).joinToString(", ")}",
color = Color(0xFF8A8A8A),
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (error != null) Text(error!!, color = McColors.Danger)
if (versions.size > 1) {
Spacer(Modifier.height(2.dp))
Text(
"Kwaliteit",
color = Color(0xFF8F8F8F),
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 0.6.sp,
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(end = 8.dp, bottom = 10.dp),
) {
items(versions, key = { it.id }) { ver ->
VersionPickChip(
version = ver,
selected = ver.id == selected?.id,
onClick = { selectedVersionId = ver.id },
)
}
}
}
} }
if (showTrailer && !trailerKey.isNullOrBlank()) { if (!poster.isNullOrBlank()) {
TrailerPlayerOverlay( AsyncImage(
youtubeKey = trailerKey!!, model = poster,
onDismiss = { showTrailer = false }, contentDescription = null,
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 56.dp)
.width(200.dp)
.height(300.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF1A1A1A)),
contentScale = ContentScale.Crop,
) )
} }
} }
@ -820,6 +585,7 @@ 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) }
@ -828,7 +594,6 @@ 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) }
@ -933,6 +698,7 @@ 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) {
@ -942,22 +708,21 @@ fun SeriesDetailScreen(
} }
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
Box(Modifier.fillMaxSize().background(McColors.Bg)) { Box(Modifier.fillMaxSize()) {
DetailBackdrop(backdrop) DetailBackdrop(backdrop)
LazyColumn( LazyColumn(
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = HomeChrome.EdgeInset, start = HomeChrome.EdgeInset,
end = HomeChrome.EdgeInset, end = HomeChrome.EdgeInset,
top = HomeChrome.BadgeTop, top = 28.dp,
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(8.dp)) Spacer(Modifier.height(10.dp))
DetailLogoOrTitle(logoUrl, title) DetailLogoOrTitle(logoUrl, title)
GenrePills(genres)
MetaChips(metaParts) MetaChips(metaParts)
if (overview.isNotBlank()) { if (overview.isNotBlank()) {
ExpandableOverview( ExpandableOverview(
@ -967,7 +732,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(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.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(
@ -984,7 +749,7 @@ fun SeriesDetailScreen(
} }
} }
if (!trailerKey.isNullOrBlank()) { if (!trailerKey.isNullOrBlank()) {
DetailActionButton("Trailer") { showTrailer = true } DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) }
} }
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") { DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
scope.launch { scope.launch {
@ -994,7 +759,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(0xFF8F8F8F), fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp) Text("Seizoenen", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
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 ->
@ -1026,13 +791,6 @@ 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,29 +3,12 @@ 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
@ -37,8 +20,6 @@ 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
@ -50,34 +31,25 @@ 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)
@ -86,8 +58,6 @@ 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) }
@ -95,38 +65,12 @@ 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 {
@ -136,24 +80,14 @@ fun EventPlayerScreen(
} }
} }
LaunchedEffect(eventId, hubPlay) { LaunchedEffect(eventId) {
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 = if (hubPlay) { val play = api.scheduleEventPlay(eventId)
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
@ -162,11 +96,14 @@ fun EventPlayerScreen(
error = "Geen stream-URL beschikbaar" error = "Geen stream-URL beschikbaar"
return@LaunchedEffect return@LaunchedEffect
} }
// ClearKey optioneel: F1TV plain HLS heeft drm=null. if (play.drm == null || play.drm.keys.isEmpty()) {
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 ?: "Kon mediastream niet openen" error = e.message ?: "Ongeldige DRM-sleutels"
return@LaunchedEffect return@LaunchedEffect
} }
if (!isActive) return@LaunchedEffect if (!isActive) return@LaunchedEffect
@ -214,27 +151,13 @@ fun EventPlayerScreen(
} }
} }
LaunchedEffect(showChrome, menu, error, loading, chromeBump) { LaunchedEffect(showChrome, error, loading) {
if (showChrome && menu == null && error == null && !loading) { if (showChrome && error == null && !loading) {
delay(ChromeHideMs) delay(4_000)
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() }
@ -246,22 +169,12 @@ fun EventPlayerScreen(
when (state) { when (state) {
Player.STATE_READY -> { Player.STATE_READY -> {
reachedReady.set(true) reachedReady.set(true)
runCatching { runCatching { player.ensureAudioSelected() }
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() } }
@ -287,13 +200,7 @@ fun EventPlayerScreen(
} }
} }
BackHandler { BackHandler(onBack = onBack)
when {
menu != null -> menu = null
showChrome -> showChrome = false
else -> onBack()
}
}
Box( Box(
Modifier Modifier
@ -302,37 +209,13 @@ fun EventPlayerScreen(
.focusRequester(rootFocus) .focusRequester(rootFocus)
.focusable() .focusable()
.onPreviewKeyEvent { ev -> .onPreviewKeyEvent { ev ->
if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false if (ev.type == KeyEventType.KeyDown &&
when { (ev.key == Key.Back || ev.key == Key.Escape)
ev.key == Key.Back || ev.key == Key.Escape -> { ) {
when { onBack()
menu != null -> { true
menu = null } else {
true false
}
showChrome -> {
showChrome = false
true
}
else -> {
onBack()
true
}
}
}
menu != null -> false
ev.key == Key.Menu || ev.key == Key.Guide -> {
revealChrome(focusControls = true)
true
}
!showChrome -> {
revealChrome(focusControls = true)
true
}
else -> {
revealChrome()
false
}
} }
}, },
) { ) {
@ -383,353 +266,15 @@ fun EventPlayerScreen(
) )
} }
} }
} else { } else if (showChrome) {
AnimatedVisibility(
visible = showChrome || menu != null,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(
EventsLook.Scrim,
Color.Transparent,
Color.Transparent,
EventsLook.Scrim,
),
),
),
)
Column(
Modifier
.align(Alignment.TopStart)
.padding(horizontal = 32.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
Modifier
.background(EventsLook.Live, RoundedCornerShape(4.dp))
.padding(horizontal = 10.dp, vertical = 4.dp),
) {
Text(
"LIVE",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
)
}
Box(
Modifier
.size(width = 3.dp, height = 14.dp)
.background(EventsLook.Neon, RoundedCornerShape(2.dp)),
)
Text(
"EVENT",
color = EventsLook.Neon,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
)
}
Text(
title,
color = Color.White,
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
)
}
Column(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
EventPlayerChip(
label = "Audio",
subtitle = when {
audioTracks.isEmpty() -> "—"
else -> audioTracks.firstOrNull {
trackKey(it) == selectedAudioKey
}?.label ?: audioTracks.first().label
},
focusRequester = audioBtnFocus,
onClick = {
revealChrome()
menu = "audio"
},
)
EventPlayerChip(
label = "Ondertiteling",
subtitle = when {
textDisabled || textTracks.isEmpty() -> "Uit"
else -> textTracks.firstOrNull {
trackKey(it) == selectedTextKey
}?.label ?: "Aan"
},
onClick = {
revealChrome()
menu = "text"
},
)
}
Text(
"OK / Menu · audio & ondertiteling · Terug · sluiten",
color = Color(0x88FFFFFF),
fontSize = 12.sp,
)
}
}
}
AnimatedVisibility(
visible = menu != null,
enter = fadeIn() + slideInHorizontally { it / 3 },
exit = fadeOut() + slideOutHorizontally { it / 3 },
) {
val isAudio = menu == "audio"
val tracks = if (isAudio) audioTracks else textTracks
Box(
Modifier
.fillMaxSize()
.background(Color(0x99050A08)),
contentAlignment = Alignment.CenterEnd,
) {
Column(
Modifier
.fillMaxHeight()
.width(420.dp)
.background(EventsLook.Panel)
.border(
width = 1.dp,
brush = Brush.verticalGradient(
listOf(EventsLook.NeonGlow, Color.Transparent, EventsLook.NeonSoft),
),
shape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp),
)
.padding(horizontal = 24.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
if (isAudio) "Audio" else "Ondertiteling",
color = Color.White,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
Box(
Modifier
.padding(top = 2.dp, bottom = 10.dp)
.width(40.dp)
.height(3.dp)
.background(EventsLook.Neon, RoundedCornerShape(2.dp)),
)
if (!isAudio) {
EventTrackRow(
label = "Uit",
selected = textDisabled || tracks.isEmpty(),
focusRequester = panelFocus,
onClick = {
player.disableTrackType(C.TRACK_TYPE_TEXT)
textDisabled = true
selectedTextKey = null
menu = null
revealChrome()
},
)
}
if (tracks.isEmpty()) {
Text(
if (isAudio) "Geen audiosporen in deze stream" else "Geen ondertitels in deze stream",
color = McColors.TextDim,
fontSize = 14.sp,
modifier = Modifier.padding(vertical = 8.dp),
)
}
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(tracks, key = { trackKey(it) }) { track ->
val selected = if (isAudio) {
trackKey(track) == selectedAudioKey
} else {
!textDisabled && trackKey(track) == selectedTextKey
}
EventTrackRow(
label = track.label,
selected = selected,
focusRequester = if (isAudio && tracks.firstOrNull() == track) {
panelFocus
} else {
null
},
onClick = {
player.selectTrack(track)
if (isAudio) {
selectedAudioKey = trackKey(track)
} else {
textDisabled = false
selectedTextKey = trackKey(track)
}
menu = null
revealChrome()
},
)
}
}
Spacer(Modifier.height(8.dp))
EventPlayerChip(
label = "Sluiten",
subtitle = null,
compact = true,
onClick = { menu = null },
)
}
}
}
}
}
}
private fun trackKey(track: PlayerTrack): String = "${track.groupIndex}:${track.trackIndex}"
private fun ExoPlayer.activeTrackKey(type: Int): String? {
val groups = currentTracks.groups
for (gi in 0 until groups.size) {
val group = groups[gi]
if (group.type != type || !group.isSelected) continue
for (ti in 0 until group.length) {
if (group.isTrackSelected(ti)) return "$gi:$ti"
}
}
return null
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun EventPlayerChip(
label: String,
subtitle: String?,
focusRequester: FocusRequester? = null,
compact: Boolean = false,
onClick: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
val mod = Modifier
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged { focused = it.isFocused }
Surface(
onClick = onClick,
modifier = mod,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
glow = ClickableSurfaceDefaults.glow(
focusedGlow = Glow(
elevationColor = EventsLook.Neon,
elevation = if (focused) 12.dp else 0.dp,
),
),
border = ClickableSurfaceDefaults.border(
focusedBorder = androidx.tv.material3.Border(
border = androidx.compose.foundation.BorderStroke(2.dp, EventsLook.Neon),
shape = RoundedCornerShape(12.dp),
),
),
colors = ClickableSurfaceDefaults.colors(
containerColor = EventsLook.PanelHot,
focusedContainerColor = EventsLook.NeonSoft,
contentColor = Color.White,
focusedContentColor = Color.White,
),
) {
Column(
Modifier.padding(
horizontal = if (compact) 18.dp else 20.dp,
vertical = if (compact) 12.dp else 14.dp,
),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text( Text(
label, title,
color = if (focused) EventsLook.Neon else Color.White, color = Color.White,
fontSize = 15.sp, fontSize = 22.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.SemiBold,
) modifier = Modifier
if (subtitle != null) { .align(Alignment.TopStart)
Text( .padding(28.dp),
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,7 +119,6 @@ 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()) }
@ -225,13 +224,10 @@ fun EventsScreen(
else -> null else -> null
} }
// Focus: TopNav → F1-tile (optioneel) → filterrij → first rail. // Focus: TopNav → één filterrij (sport + bron) → 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 -> filterBarFocus showFilterBar && filterBarFocusReady -> contentFocusRequester
hasF1Entry -> contentFocusRequester
else -> navFocusRequester else -> navFocusRequester
} }
val filterFocusDown: FocusRequester? = when { val filterFocusDown: FocusRequester? = when {
@ -239,7 +235,6 @@ 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) {
@ -270,7 +265,6 @@ 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,
@ -279,7 +273,6 @@ 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++
@ -302,7 +295,7 @@ fun EventsScreen(
val activeRestoreFocus = when { val activeRestoreFocus = when {
!canRestoreCard -> null !canRestoreCard -> null
restoreIsFirstOfFirstRail -> when { restoreIsFirstOfFirstRail -> when {
railUsesDedicatedFocus -> firstRailFocus showFilterBar -> firstRailFocus
else -> contentFocusRequester else -> contentFocusRequester
} }
else -> restoreCardFocus else -> restoreCardFocus
@ -320,14 +313,12 @@ 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
@ -352,7 +343,6 @@ 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
} }
@ -475,16 +465,6 @@ 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") {
@ -498,8 +478,8 @@ fun EventsScreen(
onProviderSelect = { providerFilter = it }, onProviderSelect = { providerFilter = it },
liveCount = live.size, liveCount = live.size,
totalCount = filtered.size, totalCount = filtered.size,
firstFocusRequester = if (hasF1Entry) filterBarFocus else contentFocusRequester, firstFocusRequester = contentFocusRequester,
focusUp = if (hasF1Entry) contentFocusRequester else navFocusRequester, focusUp = navFocusRequester,
focusDown = filterFocusDown, focusDown = filterFocusDown,
onFocusReady = { filterBarFocusReady = it }, onFocusReady = { filterBarFocusReady = it },
onFocus = onContentFocused, onFocus = onContentFocused,
@ -531,7 +511,7 @@ fun EventsScreen(
style = EventRailStyle.Live, style = EventRailStyle.Live,
onOpen = onOpenEvent, onOpen = onOpenEvent,
onFocus = onContentFocused, onFocus = onContentFocused,
firstItemFocusRequester = if (railUsesDedicatedFocus) { firstItemFocusRequester = if (showFilterBar) {
firstRailFocus firstRailFocus
} else { } else {
contentFocusRequester contentFocusRequester
@ -565,7 +545,7 @@ fun EventsScreen(
onFocus = onContentFocused, onFocus = onContentFocused,
firstItemFocusRequester = when { firstItemFocusRequester = when {
!isFirstUpcoming -> null !isFirstUpcoming -> null
railUsesDedicatedFocus -> firstRailFocus showFilterBar -> firstRailFocus
else -> contentFocusRequester else -> contentFocusRequester
}, },
restoreEventId = bindRestoreCardId.takeIf { railHasRestore }, restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
@ -599,7 +579,7 @@ fun EventsScreen(
onFocus = onContentFocused, onFocus = onContentFocused,
firstItemFocusRequester = when { firstItemFocusRequester = when {
!isFirstUpcoming -> null !isFirstUpcoming -> null
railUsesDedicatedFocus -> firstRailFocus showFilterBar -> firstRailFocus
else -> contentFocusRequester else -> contentFocusRequester
}, },
restoreEventId = bindRestoreCardId.takeIf { railHasRestore }, restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
@ -1076,96 +1056,6 @@ 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(

View file

@ -131,10 +131,6 @@ 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
@ -332,21 +328,12 @@ 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")
}, },
@ -365,11 +352,6 @@ 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() }
@ -407,14 +389,8 @@ fun McNav() {
api = api, api = api,
catalogId = catalogId, catalogId = catalogId,
onBack = { nav.popBackStack() }, onBack = { nav.popBackStack() },
onOpenMovie = { onOpenMovie = { nav.navigate("movie/$it") },
homeDetailFromHome = false onOpenSeries = { nav.navigate("series/$it") },
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 } ?: "-"
@ -429,14 +405,6 @@ 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,
@ -448,14 +416,6 @@ 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,
@ -558,72 +518,6 @@ 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() },
)
}
} }
} }
@ -649,12 +543,6 @@ 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,
@ -665,7 +553,6 @@ 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,
@ -832,15 +719,6 @@ 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
@ -984,20 +862,10 @@ 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 = { id -> onOpenMovie = onOpenMovie,
onHomeItemOpened("movie:$id") onOpenSeries = onOpenSeries,
onOpenMovie(id)
},
onOpenSeries = { id ->
onHomeItemOpened("series:$id")
onOpenSeries(id)
},
onSeeAll = onBrowse, onSeeAll = onBrowse,
onPlay = onPlay, onPlay = onPlay,
) )
@ -1005,8 +873,8 @@ private fun MainHub(
api = api, api = api,
gridState = watchlistGridState, gridState = watchlistGridState,
onAtTopBack = { focusTopNav() }, onAtTopBack = { focusTopNav() },
onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) }, onOpenMovie = onOpenMovie,
onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) }, onOpenSeries = onOpenSeries,
) )
MainTab.Movies -> BrowseScreen( MainTab.Movies -> BrowseScreen(
api = api, api = api,
@ -1014,8 +882,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 = { onLeaveHomeDetailOrigin(); onOpenMovie(it) }, onOpenMovie = onOpenMovie,
onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) }, onOpenSeries = onOpenSeries,
) )
MainTab.Series -> BrowseScreen( MainTab.Series -> BrowseScreen(
api = api, api = api,
@ -1023,8 +891,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 = { onLeaveHomeDetailOrigin(); onOpenMovie(it) }, onOpenMovie = onOpenMovie,
onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) }, onOpenSeries = onOpenSeries,
) )
MainTab.Tv -> TvScreen( MainTab.Tv -> TvScreen(
api = api, api = api,
@ -1063,13 +931,12 @@ 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 = { onLeaveHomeDetailOrigin(); onOpenMovie(it) }, onOpenMovie = onOpenMovie,
onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) }, onOpenSeries = onOpenSeries,
onAtTopBack = { focusTopNav() }, onAtTopBack = { focusTopNav() },
) )
MainTab.Settings -> SettingsScreen( MainTab.Settings -> SettingsScreen(
@ -1296,10 +1163,6 @@ 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,
@ -1314,14 +1177,12 @@ 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()
@ -1358,102 +1219,25 @@ private fun HomeScreen(
} }
} }
val continueItems = home?.continueWatching.orEmpty() // Bij terugkeer van detail: geen jump naar Afspelen/top. Alleen content-focus als we gescrolld waren.
val visibleShelves = remember(home?.shelves) { LaunchedEffect(Unit) {
home?.shelves.orEmpty().filter { it.items.size >= 3 } delay(80)
} val scrolled = listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 40
if (scrolled) {
data class HomeRestoreTarget( onContentFocused()
val listIndex: Int, val deadline = System.currentTimeMillis() + 700
val shelfId: String?, while (!shelfFocusReady && System.currentTimeMillis() < deadline) {
val itemIndex: Int,
val isFirstOfFirstShelf: Boolean,
)
fun MediaItem.detailKey(): String = when {
type.equals("movie", true) -> "movie:$id"
type.equals("episode", true) && !seriesId.isNullOrBlank() -> "series:$seriesId"
else -> "series:$id"
}
val restoreTarget = remember(home, continueItems, visibleShelves, restoreHomeKey, error) {
if (restoreHomeKey.isNullOrBlank()) return@remember null
var listIndex = 1 // hero-spacer
if (error != null) listIndex++
if (continueItems.isNotEmpty()) {
val preview = continueItems.take(5)
val itemIndex = preview.indexOfFirst { it.detailKey() == restoreHomeKey }
if (itemIndex >= 0) {
return@remember HomeRestoreTarget(
listIndex = listIndex,
shelfId = "__continue__",
itemIndex = itemIndex,
isFirstOfFirstShelf = itemIndex == 0,
)
}
listIndex++
}
visibleShelves.forEachIndexed { shelfPos, shelf ->
val itemIndex = shelf.items.indexOfFirst { it.detailKey() == restoreHomeKey }
if (itemIndex >= 0) {
val isFirstShelf = continueItems.isEmpty() && shelfPos == 0
return@remember HomeRestoreTarget(
listIndex = listIndex + shelfPos,
shelfId = shelf.id,
itemIndex = itemIndex,
isFirstOfFirstShelf = isFirstShelf && itemIndex == 0,
)
}
}
null
}
val bindRestoreKey = restoreHomeKey.takeIf {
pendingFocusRestore && restoreTarget != null && restoreTarget?.isFirstOfFirstShelf != true
}
// Bij terugkeer van detail: focus op de geopende card (of eerste plank als fallback).
LaunchedEffect(
pendingFocusRestore,
focusRestoreNonce,
restoreTarget,
home != null,
) {
if (!pendingFocusRestore || home == null) return@LaunchedEffect
delay(60)
onContentFocused()
val target = restoreTarget
if (target != null) {
runCatching { listState.scrollToItem(target.listIndex) }
delay(40)
val focusTarget = if (target.isFirstOfFirstShelf) firstShelfFocus else restoreCardFocus
val ready = if (target.isFirstOfFirstShelf) {
{ shelfFocusReady }
} else {
{ restoreCardReady }
}
val deadline = System.currentTimeMillis() + 900
while (!ready() && System.currentTimeMillis() < deadline) {
delay(40) delay(40)
} }
delay(40) delay(40)
if (runCatching { focusTarget.requestFocus() }.isSuccess) { runCatching { firstShelfFocus.requestFocus() }
onFocusRestoreConsumed()
return@LaunchedEffect
}
} }
// Fallback: eerste zichtbare plank
val deadline = System.currentTimeMillis() + 500
while (!shelfFocusReady && System.currentTimeMillis() < deadline) {
delay(40)
}
runCatching { firstShelfFocus.requestFocus() }
onFocusRestoreConsumed()
} }
val spotlight = home?.spotlight.orEmpty().ifEmpty { 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
@ -1603,9 +1387,6 @@ 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()
@ -1636,16 +1417,16 @@ private fun HomeScreen(
} }
} }
items( items(
visibleShelves, home?.shelves.orEmpty().filter { it.items.size >= 3 },
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 = visibleShelves.firstOrNull()?.id val firstVisibleShelfId = home?.shelves.orEmpty()
.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,
@ -1656,13 +1437,6 @@ 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()
@ -1934,29 +1708,9 @@ 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 {
@ -2003,7 +1757,6 @@ 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(
@ -2014,40 +1767,23 @@ 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 ->
val itemKey = when { if (index == 0 && firstItemFocusRequester != null) {
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 (cardRequester != null) { if (index == 0 && firstItemFocusRequester != null) {
Modifier Modifier
.focusRequester(cardRequester) .focusRequester(firstItemFocusRequester)
.then( .then(
if (focusUpTo != null && (index == 0 || isRestore)) { if (focusUpTo != null) {
Modifier.focusProperties { up = focusUpTo } Modifier.focusProperties { up = focusUpTo }
} else { } else {
Modifier Modifier

View file

@ -20,7 +20,6 @@
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 */; };
@ -56,7 +55,6 @@
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>"; };
@ -165,7 +163,6 @@
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>";
@ -341,7 +338,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 = 52; CURRENT_PROJECT_VERSION = 49;
DEVELOPMENT_TEAM = X25NYX3K49; DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -356,7 +353,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks" "@executable_path/Frameworks"
); );
MARKETING_VERSION = 0.5.39; MARKETING_VERSION = 0.5.36;
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)";
@ -374,7 +371,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 = 52; CURRENT_PROJECT_VERSION = 49;
DEVELOPMENT_TEAM = X25NYX3K49; DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -389,7 +386,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks" "@executable_path/Frameworks"
); );
MARKETING_VERSION = 0.5.39; MARKETING_VERSION = 0.5.36;
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

@ -1,417 +0,0 @@
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,9 +69,7 @@ struct PlayerScreen: View {
let request: PlayRequest let request: PlayRequest
/// ClearKey live → AVPlayer + lokale decrypt-HLS. FTA live + VOD → VLC (PiP intact). @StateObject private var player = VLCPlayerController()
@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?
@ -83,48 +81,23 @@ 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()
.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() .ignoresSafeArea()
} .allowsHitTesting(false)
// Speler eet touches op — vang tikken altijd zelf af. // VLC 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 && !isPlaying { if loading && !player.isPlaying {
ProgressView(stateText) ProgressView(player.stateText)
.tint(.white) .tint(.white)
.foregroundStyle(.white) .foregroundStyle(.white)
.allowsHitTesting(false) .allowsHitTesting(false)
@ -132,8 +105,8 @@ struct PlayerScreen: View {
if let error { if let error {
errorOverlay(error) errorOverlay(error)
} else if playerHasError { } else if player.hasError {
errorOverlay(playerErrorMessage ?? "Afspeelfout") errorOverlay(player.errorMessage ?? "Afspeelfout")
} }
if controlsVisible { if controlsVisible {
@ -143,11 +116,7 @@ struct PlayerScreen: View {
} }
.statusBarHidden(true) .statusBarHidden(true)
.persistentSystemOverlays(.hidden) .persistentSystemOverlays(.hidden)
.onAppear { .onAppear { app.enterPlayback() }
pendingStop?.cancel()
pendingStop = nil
app.enterPlayback()
}
.task { .task {
await start() await start()
scheduleHide() scheduleHide()
@ -158,19 +127,8 @@ struct PlayerScreen: View {
hideTask?.cancel() hideTask?.cancel()
progressTask?.cancel() progressTask?.cancel()
heartbeatTask?.cancel() heartbeatTask?.cancel()
if !isLive { Task { await saveProgress() }
let pos = vlc.currentPositionMs() player.stop()
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
@ -196,17 +154,17 @@ struct PlayerScreen: View {
.font(McFonts.headline) .font(McFonts.headline)
.foregroundStyle(.white) .foregroundStyle(.white)
.lineLimit(1) .lineLimit(1)
Text(stateText) Text(player.stateText)
.font(McFonts.caption) .font(McFonts.caption)
.foregroundStyle(.white.opacity(0.7)) .foregroundStyle(.white.opacity(0.7))
} }
Spacer() Spacer()
if !useClearKeyAV && vlc.pipPossible { if player.pipPossible {
Button { Button {
vlc.togglePictureInPicture() player.togglePictureInPicture()
scheduleHide() scheduleHide()
} label: { } label: {
Image(systemName: vlc.pipActive Image(systemName: player.pipActive
? "pip.exit" ? "pip.exit"
: "pip.enter") : "pip.enter")
.font(.title3) .font(.title3)
@ -240,9 +198,9 @@ struct PlayerScreen: View {
if !request.isLive { if !request.isLive {
Slider( Slider(
value: Binding( value: Binding(
get: { Double(vlc.position) }, get: { Double(player.position) },
set: { set: {
vlc.seek(ratio: Float($0)) player.seek(ratio: Float($0))
scheduleHide() scheduleHide()
} }
), ),
@ -251,33 +209,33 @@ struct PlayerScreen: View {
.tint(McColors.accent) .tint(McColors.accent)
HStack { HStack {
Text(vlc.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85)) Text(player.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
Spacer() Spacer()
Text(vlc.lengthText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85)) Text(player.lengthText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
} }
} }
HStack(spacing: 36) { HStack(spacing: 36) {
if !request.isLive { if !request.isLive {
Button { Button {
vlc.seek(toMs: max(vlc.currentPositionMs() - 10_000, 0)) player.seek(toMs: max(player.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 {
if useClearKeyAV { av.togglePlay() } else { vlc.togglePlay() } player.togglePlay()
scheduleHide() scheduleHide()
} label: { } label: {
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill") Image(systemName: player.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 {
vlc.seek(toMs: vlc.currentPositionMs() + 10_000) player.seek(toMs: player.currentPositionMs() + 10_000)
scheduleHide() scheduleHide()
} label: { } label: {
Image(systemName: "goforward.10").font(.title).foregroundStyle(.white) Image(systemName: "goforward.10").font(.title).foregroundStyle(.white)
@ -305,16 +263,16 @@ struct PlayerScreen: View {
if trackTab == 0 { if trackTab == 0 {
List { List {
ForEach(vlc.subtitleTracks) { track in ForEach(player.subtitleTracks) { track in
Button { Button {
vlc.selectSubtitle(id: track.id) player.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 == vlc.currentSubtitleId { if track.id == player.currentSubtitleId {
Image(systemName: "checkmark").foregroundStyle(McColors.accent) Image(systemName: "checkmark").foregroundStyle(McColors.accent)
} }
} }
@ -357,16 +315,16 @@ struct PlayerScreen: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
List { List {
ForEach(vlc.audioTracks) { track in ForEach(player.audioTracks) { track in
Button { Button {
vlc.selectAudio(id: track.id) player.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 == vlc.currentAudioId { if track.id == player.currentAudioId {
Image(systemName: "checkmark").foregroundStyle(McColors.accent) Image(systemName: "checkmark").foregroundStyle(McColors.accent)
} }
} }
@ -385,7 +343,7 @@ struct PlayerScreen: View {
Button("Sluiten") { showTracks = false } Button("Sluiten") { showTracks = false }
} }
} }
.onAppear { vlc.refreshTracks() } .onAppear { player.refreshTracks() }
} }
} }
@ -441,51 +399,32 @@ struct PlayerScreen: View {
private func start() async { private func start() async {
loading = true loading = true
error = nil error = nil
if useClearKeyAV { if request.isLive {
let keys = request.drm?.keys ?? [] let keys = request.needsClearKey ? (request.drm?.keys ?? []) : []
await av.play( await player.playLive(
url: request.url, url: request.url,
fallback: request.fallbackUrl, fallback: request.fallbackUrl,
format: request.format, format: request.format,
clearKeys: keys clearKeys: keys
) )
let maxWaitNs: UInt64 = 18_000_000_000 // ClearKey/DASH: proxy-warmup + init/media + eventuele retry.
let stepNs: UInt64 = 200_000_000 let waitNs: UInt64 = request.needsClearKey ? 12_000_000_000 : 2_500_000_000
var waited: UInt64 = 0 try? await Task.sleep(nanoseconds: waitNs)
while waited < maxWaitNs && !av.hasError { if request.needsClearKey && (player.hasError || !player.isPlaying) {
if av.isPlaying { break } try? await Task.sleep(nanoseconds: 6_000_000_000)
try? await Task.sleep(nanoseconds: stepNs) if player.hasError || !player.isPlaying {
waited += stepNs error = player.errorMessage
} ?? "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 {
vlc.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL) player.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL)
progressTask = Task { await progressLoop() } progressTask = Task { await progressLoop() }
} }
try? await Task.sleep(nanoseconds: 250_000_000) try? await Task.sleep(nanoseconds: 400_000_000)
loading = false loading = false
if !useClearKeyAV && !vlc.hasError { if !player.hasError {
vlc.refreshTracks() player.refreshTracks()
} }
} }
@ -511,10 +450,10 @@ struct PlayerScreen: View {
} }
} }
private func saveProgress(positionMs: Int64? = nil, durationMs: Int64? = nil) async { private func saveProgress() async {
guard !request.isLive else { return } guard !request.isLive else { return }
let pos = positionMs ?? vlc.currentPositionMs() let pos = player.currentPositionMs()
let dur = durationMs ?? vlc.durationMs() let dur = player.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,
@ -553,25 +492,13 @@ 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)
vlc.addExternalSubtitle(url: url) player.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,8 +216,7 @@ 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.
let started = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys) playURL = 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 {
@ -821,6 +820,7 @@ 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 / audio-only). // Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout).
controller.attach(to: uiView)
} }
} }

View file

@ -96,9 +96,6 @@ 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) }
@ -274,7 +271,6 @@ 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

@ -1,36 +0,0 @@
-- 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,37 +790,6 @@ 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,95 +1810,6 @@ 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

@ -1,294 +0,0 @@
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,7 +19,6 @@ 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;
@ -75,8 +74,7 @@ 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) {
@ -94,19 +92,6 @@ 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),
@ -174,8 +159,7 @@ 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,24 +314,6 @@ 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,7 +446,6 @@ 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,