Compare commits
2 commits
d8a1c28283
...
85da6f59ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85da6f59ad | ||
|
|
4b1f9eb763 |
25 changed files with 5728 additions and 434 deletions
325
apps/admin-ui/src/app/drm/page.tsx
Normal file
325
apps/admin-ui/src/app/drm/page.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
"use client";
|
||||
|
||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import { Nav, useAuth } from "@/components/Nav";
|
||||
|
||||
type DrmKeyRow = {
|
||||
id: string;
|
||||
kid: string;
|
||||
keyMasked: string;
|
||||
hasKey: boolean;
|
||||
mpdUrl: string | null;
|
||||
channelName: string | null;
|
||||
eventName: string | null;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
mediaGuid: string | null;
|
||||
lastSeenAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function shortUrl(url: string | null, max = 48): string {
|
||||
if (!url) return "—";
|
||||
const s = url.trim();
|
||||
if (s.length <= max) return s;
|
||||
try {
|
||||
const u = new URL(s);
|
||||
const path = u.pathname.split("/").filter(Boolean).pop() ?? "";
|
||||
const host = u.host.replace(/^www\./, "");
|
||||
const tip = path ? `${host}/…/${path}` : host;
|
||||
return tip.length <= max ? tip : `${tip.slice(0, max - 1)}…`;
|
||||
} catch {
|
||||
return `${s.slice(0, max - 1)}…`;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtWhen(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString("nl-NL", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export default function DrmPage() {
|
||||
useAuth();
|
||||
const [search, setSearch] = useState("");
|
||||
const [source, setSource] = useState("");
|
||||
const [keys, setKeys] = useState<DrmKeyRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({});
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (q: string, src: string) => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (q.trim()) params.set("search", q.trim());
|
||||
if (src.trim()) params.set("source", src.trim());
|
||||
params.set("limit", "200");
|
||||
const r = await fetch(`/api/v1/admin/drm-keys?${params}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.message || d.error || "Laden mislukt");
|
||||
setKeys(d.keys ?? []);
|
||||
setTotal(d.total ?? 0);
|
||||
if (d.requiresQuery) {
|
||||
setMsg("Typ minstens 2 tekens om te zoeken, of laat leeg voor recente keys.");
|
||||
} else {
|
||||
setMsg(null);
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load("", "");
|
||||
}, [load]);
|
||||
|
||||
function onSearch(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
void load(search, source);
|
||||
}
|
||||
|
||||
async function reveal(id: string) {
|
||||
setBusyId(id);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await fetch(`/api/v1/admin/drm-keys/${id}/reveal`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.message || "Reveal mislukt");
|
||||
setRevealed((prev) => ({ ...prev, [id]: d.key as string }));
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(label: string, text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setMsg(`${label} gekopieerd`);
|
||||
} catch {
|
||||
setErr("Clipboard niet beschikbaar");
|
||||
}
|
||||
}
|
||||
|
||||
async function backfill() {
|
||||
setBusyId("backfill");
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await fetch("/api/v1/admin/drm-keys/backfill", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.message || "Backfill mislukt");
|
||||
setMsg(
|
||||
`Backfill: ${d.imported ?? 0} nieuw, ${d.skipped ?? 0} overgeslagen, ${d.failed ?? 0} mislukt`
|
||||
);
|
||||
await load(search, source);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm("Deze DRM-key verwijderen uit de database?")) return;
|
||||
setBusyId(id);
|
||||
try {
|
||||
const r = await fetch(`/api/v1/admin/drm-keys/${id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
throw new Error(d.message || "Verwijderen mislukt");
|
||||
}
|
||||
setKeys((prev) => prev.filter((k) => k.id !== id));
|
||||
setMsg("Key verwijderd");
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<Nav />
|
||||
<main className="container">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<p className="page-kicker">Beveiliging</p>
|
||||
<h1>DRM keys</h1>
|
||||
<p className="page-lead">
|
||||
Alle ooit opgehaalde ClearKey/Widevine-keys. Zoek op KID, KEY, MPD, zender of
|
||||
event. Altijd eerst cache — alleen ophalen bij miss.
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busyId === "backfill"}
|
||||
onClick={() => void backfill()}
|
||||
>
|
||||
{busyId === "backfill" ? "Bezig…" : "Import uit live-lijsten"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form className="card" onSubmit={onSearch} style={{ marginBottom: "1rem" }}>
|
||||
<div className="form-row" style={{ gap: "0.75rem", flexWrap: "wrap" }}>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Zoek kid, key, mpd, zender, event…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ flex: "1 1 240px" }}
|
||||
/>
|
||||
<select
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
style={{ flex: "0 0 140px" }}
|
||||
>
|
||||
<option value="">Alle bronnen</option>
|
||||
<option value="odido">odido</option>
|
||||
<option value="viaplay">viaplay</option>
|
||||
<option value="manual">manual</option>
|
||||
<option value="live-channel">live-channel</option>
|
||||
<option value="event">event</option>
|
||||
</select>
|
||||
<button type="submit" className="btn" disabled={loading}>
|
||||
{loading ? "Zoeken…" : "Zoeken"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{msg && <p className="ok-msg">{msg}</p>}
|
||||
{err && <p className="err-msg">{err}</p>}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<h2>Keys</h2>
|
||||
<span className="muted">{total} totaal · {keys.length} getoond</span>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>KID</th>
|
||||
<th>KEY</th>
|
||||
<th>Zender / event</th>
|
||||
<th>MPD</th>
|
||||
<th>Bron</th>
|
||||
<th>Laatst gezien</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="muted">
|
||||
Geen keys. Gebruik “Import uit live-lijsten” of wacht tot sync keys
|
||||
ophaalt.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{keys.map((k) => {
|
||||
const fullKey = revealed[k.id];
|
||||
return (
|
||||
<tr key={k.id}>
|
||||
<td>
|
||||
<code
|
||||
title={k.kid}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => void copyText("KID", k.kid)}
|
||||
>
|
||||
{k.kid.length > 20 ? `${k.kid.slice(0, 16)}…` : k.kid}
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
{fullKey ? (
|
||||
<code
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => void copyText("KEY", fullKey)}
|
||||
>
|
||||
{fullKey.length > 20 ? `${fullKey.slice(0, 16)}…` : fullKey}
|
||||
</code>
|
||||
) : (
|
||||
<span className="muted">{k.hasKey ? "••••••••" : "—"}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div>{k.channelName || "—"}</div>
|
||||
{k.eventName && (
|
||||
<div className="muted" style={{ fontSize: "0.85em" }}>
|
||||
{k.eventName}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td title={k.mpdUrl ?? undefined}>
|
||||
<span className="muted">{shortUrl(k.mpdUrl)}</span>
|
||||
</td>
|
||||
<td>
|
||||
<code>{k.source}</code>
|
||||
</td>
|
||||
<td className="muted">{fmtWhen(k.lastSeenAt)}</td>
|
||||
<td style={{ whiteSpace: "nowrap" }}>
|
||||
{!fullKey && k.hasKey && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={busyId === k.id}
|
||||
onClick={() => void reveal(k.id)}
|
||||
>
|
||||
Toon
|
||||
</button>
|
||||
)}
|
||||
{k.mpdUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => void copyText("MPD", k.mpdUrl!)}
|
||||
>
|
||||
MPD
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger"
|
||||
disabled={busyId === k.id}
|
||||
onClick={() => void remove(k.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ const desktopLinks = [
|
|||
{ href: "/downloads", label: "Downloads" },
|
||||
{ href: "/streams", label: "Streams" },
|
||||
{ href: "/live-lists", label: "Live-lijsten" },
|
||||
{ href: "/drm", label: "DRM" },
|
||||
{ href: "/scripts", label: "Scripts" },
|
||||
{ href: "/viewers", label: "Gebruikers" },
|
||||
{ href: "/settings", label: "Instellingen" },
|
||||
|
|
|
|||
|
|
@ -299,11 +299,12 @@ private class InjectingClearKeyDrmSessionManager(
|
|||
}
|
||||
|
||||
private fun formatWithClearKeyScheme(format: Format): Format {
|
||||
if (keyIds.isEmpty()) return format
|
||||
val existing = format.drmInitData
|
||||
val hasClearKey = existing != null && (0 until existing.schemeDataCount).any {
|
||||
existing.get(it).matches(C.CLEARKEY_UUID)
|
||||
}
|
||||
if (hasClearKey || keyIds.isEmpty()) return format
|
||||
val onlyClearKey = existing != null &&
|
||||
existing.schemeDataCount > 0 &&
|
||||
(0 until existing.schemeDataCount).all { existing.get(it).matches(C.CLEARKEY_UUID) }
|
||||
if (onlyClearKey) return format
|
||||
|
||||
val keyUuids = keyIds.map { bytesToUuid(it) }.toTypedArray()
|
||||
val pssh = PsshAtomUtil.buildPsshAtom(C.CLEARKEY_UUID, keyUuids, /* data= */ null)
|
||||
|
|
@ -312,7 +313,10 @@ private class InjectingClearKeyDrmSessionManager(
|
|||
MimeTypes.VIDEO_MP4,
|
||||
pssh,
|
||||
)
|
||||
Log.i(TAG, "Injecting ClearKey PSSH with ${keyIds.size} key id(s)")
|
||||
Log.i(
|
||||
TAG,
|
||||
"Forcing ClearKey PSSH kids=${keyIds.size} wasSchemes=${existing?.schemeDataCount ?: 0}",
|
||||
)
|
||||
return format.buildUpon()
|
||||
.setDrmInitData(DrmInitData(schemeData))
|
||||
.build()
|
||||
|
|
@ -331,10 +335,11 @@ fun liveMediaItem(
|
|||
format: String,
|
||||
drm: DrmInfo? = null,
|
||||
): MediaItem {
|
||||
val isDash = format.equals("dash", ignoreCase = true) ||
|
||||
url.contains(".mpd", ignoreCase = true)
|
||||
val isHls = !isDash && (
|
||||
format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true)
|
||||
// URL wint: ESPN/FAAB levert .m3u8 terwijl de API soms format=dash hardcodet.
|
||||
val isHls = url.contains("m3u8", ignoreCase = true) ||
|
||||
format.equals("hls", ignoreCase = true)
|
||||
val isDash = !isHls && (
|
||||
format.equals("dash", ignoreCase = true) || url.contains(".mpd", ignoreCase = true)
|
||||
)
|
||||
val mime = when {
|
||||
isDash -> MimeTypes.APPLICATION_MPD
|
||||
|
|
|
|||
|
|
@ -3,14 +3,32 @@ package nl.vonas.mediacluster.tv.ui
|
|||
import android.view.ViewGroup
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -20,35 +38,51 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
import nl.vonas.mediacluster.tv.playback.PlayerTrack
|
||||
import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
|
||||
import nl.vonas.mediacluster.tv.playback.disableTrackType
|
||||
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
|
||||
import nl.vonas.mediacluster.tv.playback.listTracks
|
||||
import nl.vonas.mediacluster.tv.playback.liveMediaItem
|
||||
import nl.vonas.mediacluster.tv.playback.selectTrack
|
||||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private const val ReadyTimeoutMs = 20_000L
|
||||
private const val ChromeHideMs = 5_000L
|
||||
|
||||
/** Stadium-energy palette — event-player (zelfde taal als TV Events). */
|
||||
private object EventPlayerLook {
|
||||
val Neon = Color(0xFF00E8A0)
|
||||
val NeonSoft = Color(0x3300E8A0)
|
||||
val NeonGlow = Color(0x6600E8A0)
|
||||
val Live = Color(0xFFFF2D55)
|
||||
val Panel = Color(0xFF121A18)
|
||||
val PanelHot = Color(0xFF1A2824)
|
||||
val Scrim = Color(0xCC050A08)
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun EventPlayerScreen(
|
||||
api: ApiClient,
|
||||
|
|
@ -66,9 +100,30 @@ fun EventPlayerScreen(
|
|||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var showChrome by remember { mutableStateOf(true) }
|
||||
var watchSessionId by remember { mutableStateOf<String?>(null) }
|
||||
var audioTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
|
||||
var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
|
||||
var selectedAudioKey by remember { mutableStateOf<String?>(null) }
|
||||
var selectedTextKey by remember { mutableStateOf<String?>(null) }
|
||||
var textDisabled by remember { mutableStateOf(true) }
|
||||
var menu by remember { mutableStateOf<String?>(null) } // audio | text
|
||||
var chromeBump by remember { mutableStateOf(0) }
|
||||
|
||||
RememberPlayerImmersiveMode()
|
||||
|
||||
fun refreshTracks() {
|
||||
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
|
||||
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
|
||||
selectedAudioKey = player.activeTrackKey(C.TRACK_TYPE_AUDIO)
|
||||
selectedTextKey = player.activeTrackKey(C.TRACK_TYPE_TEXT)
|
||||
textDisabled = player.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT) ||
|
||||
selectedTextKey == null
|
||||
}
|
||||
|
||||
fun revealChrome() {
|
||||
showChrome = true
|
||||
chromeBump++
|
||||
}
|
||||
|
||||
fun safeStopAndClear() {
|
||||
runCatching {
|
||||
player.playWhenReady = false
|
||||
|
|
@ -83,6 +138,9 @@ fun EventPlayerScreen(
|
|||
watchSessionId = null
|
||||
reachedReady.set(false)
|
||||
showChrome = true
|
||||
menu = null
|
||||
audioTracks = emptyList()
|
||||
textTracks = emptyList()
|
||||
try {
|
||||
val play = api.scheduleEventPlay(eventId)
|
||||
if (!isActive) return@LaunchedEffect
|
||||
|
|
@ -146,9 +204,9 @@ fun EventPlayerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showChrome, error, loading) {
|
||||
if (showChrome && error == null && !loading) {
|
||||
delay(4_000)
|
||||
LaunchedEffect(showChrome, menu, error, loading, chromeBump) {
|
||||
if (showChrome && menu == null && error == null && !loading) {
|
||||
delay(ChromeHideMs)
|
||||
showChrome = false
|
||||
}
|
||||
}
|
||||
|
|
@ -159,12 +217,22 @@ fun EventPlayerScreen(
|
|||
when (state) {
|
||||
Player.STATE_READY -> {
|
||||
reachedReady.set(true)
|
||||
runCatching { player.ensureAudioSelected() }
|
||||
runCatching {
|
||||
player.ensureAudioSelected()
|
||||
refreshTracks()
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
|
||||
runCatching {
|
||||
player.ensureAudioSelected()
|
||||
refreshTracks()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(e: PlaybackException) {
|
||||
val cause = generateSequence(e as Throwable?) { it.cause }
|
||||
.mapNotNull { it.message?.takeIf { m -> m.isNotBlank() } }
|
||||
|
|
@ -190,7 +258,13 @@ fun EventPlayerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
BackHandler {
|
||||
when {
|
||||
menu != null -> menu = null
|
||||
showChrome -> showChrome = false
|
||||
else -> onBack()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
|
|
@ -200,7 +274,13 @@ fun EventPlayerScreen(
|
|||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {
|
||||
if (error == null && !loading) showChrome = !showChrome
|
||||
if (error != null || loading) return@clickable
|
||||
if (menu != null) {
|
||||
menu = null
|
||||
} else {
|
||||
showChrome = !showChrome
|
||||
if (showChrome) chromeBump++
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (error == null) {
|
||||
|
|
@ -249,26 +329,324 @@ fun EventPlayerScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
} else if (showChrome) {
|
||||
} else {
|
||||
AnimatedVisibility(
|
||||
visible = showChrome || menu != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
EventPlayerLook.Scrim,
|
||||
Color.Transparent,
|
||||
Color.Transparent,
|
||||
EventPlayerLook.Scrim,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.background(EventPlayerLook.Live, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
) {
|
||||
Text(
|
||||
"LIVE",
|
||||
color = Color.White,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.size(width = 3.dp, height = 12.dp)
|
||||
.background(EventPlayerLook.Neon, RoundedCornerShape(2.dp)),
|
||||
)
|
||||
Text(
|
||||
"EVENT",
|
||||
color = EventPlayerLook.Neon,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
McButton("Terug", compact = true, onClick = onBack)
|
||||
}
|
||||
Text(
|
||||
title,
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.statusBarsPadding()
|
||||
.padding(20.dp),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
McButton(
|
||||
"Terug",
|
||||
compact = true,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.statusBarsPadding()
|
||||
.padding(16.dp),
|
||||
onClick = onBack,
|
||||
}
|
||||
|
||||
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(
|
||||
subtitle,
|
||||
color = McColors.TextDim,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MobileEventTrackRow(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
if (selected) EventPlayerLook.NeonSoft else Color.Transparent,
|
||||
RoundedCornerShape(10.dp),
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
if (selected) "●" else "○",
|
||||
color = if (selected) EventPlayerLook.Neon else Color(0x55FFFFFF),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
Text(
|
||||
label,
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ android {
|
|||
applicationId = "nl.vonas.mediacluster.tv"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 135
|
||||
versionName = "0.14.72"
|
||||
versionCode = 143
|
||||
versionName = "0.14.80"
|
||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
|
|
|
|||
|
|
@ -636,8 +636,108 @@ class ApiClient(
|
|||
suspend fun scheduleEventPlay(eventId: String): IptvPlayInfo = withContext(Dispatchers.IO) {
|
||||
val enc = android.net.Uri.encode(eventId)
|
||||
val req = authed("$baseUrl/api/v1/client/events/$enc/play").get().build()
|
||||
parseObject(execute(req)) { o -> o.toIptvPlayInfo(fallbackStreamId = eventId, defaultFormat = "dash") }
|
||||
}
|
||||
|
||||
suspend fun getF1tvHome(): F1tvHomePayload = withContext(Dispatchers.IO) {
|
||||
val req = authed("$baseUrl/api/v1/client/f1/home").get().build()
|
||||
parseObject(execute(req)) { o ->
|
||||
val drmObj = o.optJSONObject("drm")
|
||||
fun eventsOf(key: String): List<ScheduleEvent> {
|
||||
val arr = o.optJSONArray(key) ?: JSONArray()
|
||||
return buildList {
|
||||
for (i in 0 until arr.length()) {
|
||||
arr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
fun meetingsOf(key: String): List<F1tvMeeting> {
|
||||
val arr = o.optJSONArray(key) ?: JSONArray()
|
||||
return buildList {
|
||||
for (i in 0 until arr.length()) {
|
||||
arr.optJSONObject(i)?.toF1tvMeeting()?.let { add(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
fun replayGroupsOf(key: String): List<F1tvReplayGroup> {
|
||||
val arr = o.optJSONArray(key) ?: JSONArray()
|
||||
return buildList {
|
||||
for (i in 0 until arr.length()) {
|
||||
val g = arr.optJSONObject(i) ?: continue
|
||||
val sessionsArr = g.optJSONArray("sessions") ?: JSONArray()
|
||||
val sessions = buildList {
|
||||
for (si in 0 until sessionsArr.length()) {
|
||||
sessionsArr.optJSONObject(si)?.toScheduleEvent()?.let { add(it) }
|
||||
}
|
||||
}
|
||||
val meetingKey = g.optClean("meetingKey") ?: continue
|
||||
add(
|
||||
F1tvReplayGroup(
|
||||
meetingKey = meetingKey,
|
||||
title = g.optClean("title") ?: meetingKey,
|
||||
location = g.optClean("location"),
|
||||
meetingNumber = g.optInt("meetingNumber").takeIf { it > 0 },
|
||||
imageUrl = g.optClean("imageUrl"),
|
||||
latestStart = g.optClean("latestStart") ?: "",
|
||||
sessions = sessions,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
F1tvHomePayload(
|
||||
generatedAt = o.optClean("generatedAt"),
|
||||
live = eventsOf("live"),
|
||||
upcoming = eventsOf("upcoming"),
|
||||
recentReplays = eventsOf("recentReplays"),
|
||||
replayGroups = replayGroupsOf("replayGroups"),
|
||||
meetings = meetingsOf("meetings"),
|
||||
nextMeeting = o.optJSONObject("nextMeeting")?.toF1tvMeeting(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getF1tvMeeting(meetingKey: String): F1tvMeetingDetail = withContext(Dispatchers.IO) {
|
||||
val enc = android.net.Uri.encode(meetingKey)
|
||||
val req = authed("$baseUrl/api/v1/client/f1/meetings/$enc").get().build()
|
||||
parseObject(execute(req)) { o ->
|
||||
val sessionsArr = o.optJSONArray("sessions") ?: JSONArray()
|
||||
val sessions = buildList {
|
||||
for (i in 0 until sessionsArr.length()) {
|
||||
sessionsArr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) }
|
||||
}
|
||||
}
|
||||
fun eventsOf(key: String): List<ScheduleEvent> {
|
||||
val arr = o.optJSONArray(key) ?: JSONArray()
|
||||
return buildList {
|
||||
for (i in 0 until arr.length()) {
|
||||
arr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
F1tvMeetingDetail(
|
||||
meeting = o.optJSONObject("meeting")?.toF1tvMeeting(),
|
||||
sessions = sessions,
|
||||
replays = eventsOf("replays"),
|
||||
upcoming = eventsOf("upcoming"),
|
||||
live = eventsOf("live"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Hub play (live + replay). */
|
||||
suspend fun f1tvContentPlay(contentId: String): IptvPlayInfo = withContext(Dispatchers.IO) {
|
||||
val enc = android.net.Uri.encode(contentId)
|
||||
val req = authed("$baseUrl/api/v1/client/f1/content/$enc/play").get().build()
|
||||
parseObject(execute(req)) { o ->
|
||||
o.toIptvPlayInfo(fallbackStreamId = "f1tv:$contentId", defaultFormat = "hls")
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONObject.toIptvPlayInfo(
|
||||
fallbackStreamId: String,
|
||||
defaultFormat: String,
|
||||
): IptvPlayInfo {
|
||||
val drmObj = optJSONObject("drm")
|
||||
val drm = if (drmObj != null) {
|
||||
val keysArr = drmObj.optJSONArray("keys") ?: JSONArray()
|
||||
val keys = buildList {
|
||||
|
|
@ -653,18 +753,33 @@ class ApiClient(
|
|||
keys = keys,
|
||||
).takeIf { it.keys.isNotEmpty() }
|
||||
} else null
|
||||
IptvPlayInfo(
|
||||
streamId = o.optString("eventId").ifBlank { eventId },
|
||||
name = o.getString("name"),
|
||||
logoUrl = o.optString("logoUrl").ifBlank { null },
|
||||
streamUrl = o.getString("streamUrl"),
|
||||
format = o.optString("format").ifBlank { "dash" },
|
||||
fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null },
|
||||
fallbackFormat = o.optString("fallbackFormat").ifBlank { null },
|
||||
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 = o.optString("watchSessionId").ifBlank { null },
|
||||
watchSessionId = optString("watchSessionId").ifBlank { null },
|
||||
)
|
||||
}
|
||||
|
||||
private fun JSONObject.toF1tvMeeting(): F1tvMeeting? {
|
||||
val meetingKey = optClean("meetingKey") ?: return null
|
||||
return F1tvMeeting(
|
||||
meetingKey = meetingKey,
|
||||
contentId = optLong("contentId").takeIf { it > 0 },
|
||||
title = optClean("title") ?: "Meeting $meetingKey",
|
||||
pageId = optLong("pageId").takeIf { it > 0 },
|
||||
pageUri = optClean("pageUri"),
|
||||
meetingNumber = optInt("meetingNumber").takeIf { it > 0 },
|
||||
location = optClean("location"),
|
||||
startMs = optLong("startMs").takeIf { it > 0 },
|
||||
endMs = optLong("endMs").takeIf { it > 0 },
|
||||
imageUrl = optClean("imageUrl"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1137,6 +1252,47 @@ data class ScheduleEvent(
|
|||
val audioLanguages: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class F1tvMeeting(
|
||||
val meetingKey: String,
|
||||
val contentId: Long? = null,
|
||||
val title: String,
|
||||
val pageId: Long? = null,
|
||||
val pageUri: String? = null,
|
||||
val meetingNumber: Int? = null,
|
||||
val location: String? = null,
|
||||
val startMs: Long? = null,
|
||||
val endMs: Long? = null,
|
||||
val imageUrl: String? = null,
|
||||
)
|
||||
|
||||
data class F1tvReplayGroup(
|
||||
val meetingKey: String,
|
||||
val title: String,
|
||||
val location: String? = null,
|
||||
val meetingNumber: Int? = null,
|
||||
val imageUrl: String? = null,
|
||||
val latestStart: String = "",
|
||||
val sessions: List<ScheduleEvent> = emptyList(),
|
||||
)
|
||||
|
||||
data class F1tvHomePayload(
|
||||
val generatedAt: String? = null,
|
||||
val live: List<ScheduleEvent> = emptyList(),
|
||||
val upcoming: List<ScheduleEvent> = emptyList(),
|
||||
val recentReplays: List<ScheduleEvent> = emptyList(),
|
||||
val replayGroups: List<F1tvReplayGroup> = emptyList(),
|
||||
val meetings: List<F1tvMeeting> = emptyList(),
|
||||
val nextMeeting: F1tvMeeting? = null,
|
||||
)
|
||||
|
||||
data class F1tvMeetingDetail(
|
||||
val meeting: F1tvMeeting? = null,
|
||||
val sessions: List<ScheduleEvent> = emptyList(),
|
||||
val replays: List<ScheduleEvent> = emptyList(),
|
||||
val upcoming: List<ScheduleEvent> = emptyList(),
|
||||
val live: List<ScheduleEvent> = emptyList(),
|
||||
)
|
||||
|
||||
/** Canonical shelf/chip order (Overig last). Keep in sync with master-api. */
|
||||
val SportBucketOrder = listOf(
|
||||
"football", "racing", "tennis", "golf", "darts", "snooker",
|
||||
|
|
|
|||
|
|
@ -309,11 +309,14 @@ private class InjectingClearKeyDrmSessionManager(
|
|||
}
|
||||
|
||||
private fun formatWithClearKeyScheme(format: Format): Format {
|
||||
if (keyIds.isEmpty()) return format
|
||||
val existing = format.drmInitData
|
||||
val hasClearKey = existing != null && (0 until existing.schemeDataCount).any {
|
||||
existing.get(it).matches(C.CLEARKEY_UUID)
|
||||
}
|
||||
if (hasClearKey || keyIds.isEmpty()) return format
|
||||
// Alleen ClearKey → laten staan. Anders forceren (ESPN HLS heeft Widevine/PlayReady
|
||||
// SESSION-KEY; wij hebben ClearKey-kids uit de feed).
|
||||
val onlyClearKey = existing != null &&
|
||||
existing.schemeDataCount > 0 &&
|
||||
(0 until existing.schemeDataCount).all { existing.get(it).matches(C.CLEARKEY_UUID) }
|
||||
if (onlyClearKey) return format
|
||||
|
||||
val keyUuids = keyIds.map { bytesToUuid(it) }.toTypedArray()
|
||||
val pssh = PsshAtomUtil.buildPsshAtom(C.CLEARKEY_UUID, keyUuids, /* data= */ null)
|
||||
|
|
@ -322,7 +325,10 @@ private class InjectingClearKeyDrmSessionManager(
|
|||
MimeTypes.VIDEO_MP4,
|
||||
pssh,
|
||||
)
|
||||
Log.i(TAG, "Injecting ClearKey PSSH with ${keyIds.size} key id(s)")
|
||||
Log.i(
|
||||
TAG,
|
||||
"Forcing ClearKey PSSH kids=${keyIds.size} wasSchemes=${existing?.schemeDataCount ?: 0}",
|
||||
)
|
||||
return format.buildUpon()
|
||||
.setDrmInitData(DrmInitData(schemeData))
|
||||
.build()
|
||||
|
|
@ -341,10 +347,11 @@ fun liveMediaItem(
|
|||
format: String,
|
||||
drm: DrmInfo? = null,
|
||||
): MediaItem {
|
||||
val isDash = format.equals("dash", ignoreCase = true) ||
|
||||
url.contains(".mpd", ignoreCase = true)
|
||||
val isHls = !isDash && (
|
||||
format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true)
|
||||
// URL wint: ESPN/FAAB levert .m3u8 terwijl de API soms format=dash hardcodet.
|
||||
val isHls = url.contains("m3u8", ignoreCase = true) ||
|
||||
format.equals("hls", ignoreCase = true)
|
||||
val isDash = !isHls && (
|
||||
format.equals("dash", ignoreCase = true) || url.contains(".mpd", ignoreCase = true)
|
||||
)
|
||||
val mime = when {
|
||||
isDash -> MimeTypes.APPLICATION_MPD
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
package nl.vonas.mediacluster.tv.ui
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -26,11 +34,13 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -39,24 +49,23 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||
import nl.vonas.mediacluster.tv.ui.components.HomeChrome
|
||||
import nl.vonas.mediacluster.tv.ui.components.formatEndsAt
|
||||
import nl.vonas.mediacluster.tv.ui.components.rememberClockTickMs
|
||||
import nl.vonas.mediacluster.tv.ui.components.remainingMsFromRuntimeMinutes
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
|
|
@ -67,10 +76,15 @@ import coil.compose.AsyncImage
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
import nl.vonas.mediacluster.tv.ui.components.HomeChrome
|
||||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||
import nl.vonas.mediacluster.tv.ui.components.MediaVersion
|
||||
import nl.vonas.mediacluster.tv.ui.components.SelectedQualityLine
|
||||
import nl.vonas.mediacluster.tv.ui.components.VersionQualityMark
|
||||
import nl.vonas.mediacluster.tv.ui.components.formatEndsAt
|
||||
import nl.vonas.mediacluster.tv.ui.components.parseMediaVersion
|
||||
import nl.vonas.mediacluster.tv.ui.components.rememberClockTickMs
|
||||
import nl.vonas.mediacluster.tv.ui.components.remainingMsFromRuntimeMinutes
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
|
@ -101,35 +115,196 @@ private fun formatRuntime(minutes: Int?): String? {
|
|||
}
|
||||
}
|
||||
|
||||
private fun openYoutubeTrailer(context: android.content.Context, key: String) {
|
||||
val appPackages = listOf(
|
||||
"com.google.android.youtube.tv",
|
||||
"com.google.android.youtube.googletv",
|
||||
"com.google.android.youtube",
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
private fun TrailerPlayerOverlay(
|
||||
youtubeKey: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var paused by remember { mutableStateOf(false) }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
val catcherFocus = remember { FocusRequester() }
|
||||
val mainHandler = remember { Handler(Looper.getMainLooper()) }
|
||||
|
||||
BackHandler(onBack = onDismiss)
|
||||
LaunchedEffect(Unit) {
|
||||
delay(50)
|
||||
catcherFocus.requestFocus()
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
WebView(ctx).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
val appIntent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:$key")).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
for (pkg in appPackages) {
|
||||
try {
|
||||
appIntent.setPackage(pkg)
|
||||
context.startActivity(appIntent)
|
||||
return
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
// try next
|
||||
} catch (_: Exception) {
|
||||
// try next
|
||||
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
|
||||
}
|
||||
}
|
||||
try {
|
||||
context.startActivity(
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse("https://www.youtube.com/watch?v=$key"),
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
@JavascriptInterface
|
||||
fun onPaused() {
|
||||
mainHandler.post { paused = true }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun onEnded() {
|
||||
mainHandler.post(onDismiss)
|
||||
}
|
||||
},
|
||||
"VmcTrailer",
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// ignore
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,25 +317,28 @@ private fun DetailBackdrop(url: String?) {
|
|||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.TopCenter,
|
||||
)
|
||||
}
|
||||
// Events-achtige cinematic scrims (VMC zwart)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(
|
||||
0f to Color(0xF20B0B0B),
|
||||
0.42f to Color(0xCC0B0B0B),
|
||||
0.72f to Color(0x660B0B0B),
|
||||
1f to Color(0x220B0B0B),
|
||||
0.0f to Color(0xF20B0B0B),
|
||||
0.40f to Color(0xCC0B0B0B),
|
||||
0.72f to Color(0x550B0B0B),
|
||||
1.0f to Color(0x180B0B0B),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color(0x660B0B0B),
|
||||
0.35f to Color.Transparent,
|
||||
0.7f to Color(0xAA0B0B0B),
|
||||
1f to Color(0xFF0B0B0B),
|
||||
0.0f to Color(0x66000000),
|
||||
0.12f to Color.Transparent,
|
||||
0.45f to Color.Transparent,
|
||||
0.70f to Color(0xAA0B0B0B),
|
||||
1.0f to Color(0xFF0B0B0B),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -168,15 +346,25 @@ private fun DetailBackdrop(url: String?) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailLogoOrTitle(logoUrl: String?, title: String) {
|
||||
private fun DetailLogoOrTitle(
|
||||
logoUrl: String?,
|
||||
title: String,
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
if (!logoUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = logoUrl,
|
||||
contentDescription = title,
|
||||
modifier = Modifier
|
||||
modifier = if (compact) {
|
||||
Modifier
|
||||
.heightIn(max = 56.dp)
|
||||
.widthIn(max = 280.dp)
|
||||
} else {
|
||||
Modifier
|
||||
.widthIn(max = 420.dp)
|
||||
.heightIn(max = 120.dp)
|
||||
.fillMaxWidth(0.85f),
|
||||
.heightIn(max = 100.dp)
|
||||
.fillMaxWidth(0.85f)
|
||||
},
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.CenterStart,
|
||||
)
|
||||
|
|
@ -184,15 +372,43 @@ private fun DetailLogoOrTitle(logoUrl: String?, title: String) {
|
|||
Text(
|
||||
title,
|
||||
color = Color.White,
|
||||
fontSize = 40.sp,
|
||||
fontSize = if (compact) 32.sp else 40.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 3,
|
||||
maxLines = if (compact) 2 else 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
lineHeight = 46.sp,
|
||||
lineHeight = if (compact) 36.sp else 46.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenrePills(genres: List<String>) {
|
||||
if (genres.isEmpty()) return
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
genres.take(4).forEach { genre ->
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(McColors.AccentSoft)
|
||||
.border(BorderStroke(1.dp, Color(0x55E50914)), RoundedCornerShape(999.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||
) {
|
||||
Text(
|
||||
genre.uppercase(),
|
||||
color = Color(0xFFFFCDD2),
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.8.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaChips(parts: List<String>) {
|
||||
if (parts.isEmpty()) return
|
||||
|
|
@ -207,8 +423,8 @@ private fun MetaChips(parts: List<String>) {
|
|||
}
|
||||
Text(
|
||||
part,
|
||||
color = if (part.startsWith("★")) Color(0xFFFFD54F) else McColors.TextDim,
|
||||
fontSize = 14.sp,
|
||||
color = if (part.startsWith("★")) Color(0xFFFFD54F) else Color(0xFFCFCFCF),
|
||||
fontSize = 15.sp,
|
||||
fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
|
|
@ -231,7 +447,7 @@ private fun DetailBackButton(onClick: () -> Unit) {
|
|||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White),
|
||||
border = BorderStroke(1.dp, Color.White),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
),
|
||||
),
|
||||
|
|
@ -254,38 +470,43 @@ private fun DetailActionButton(
|
|||
focusRequester: FocusRequester? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val bg = Color(0xE6181818)
|
||||
val bgFocus = Color.White
|
||||
val fg = Color.White
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
val idleBg = if (primary) McColors.Accent else Color(0xE6181818)
|
||||
val focusBg = if (primary) Color.White else Color.White
|
||||
val idleFg = Color.White
|
||||
val focusFg = if (primary) McColors.Accent else Color.Black
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = shape),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
||||
glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = bg,
|
||||
focusedContainerColor = bgFocus,
|
||||
contentColor = fg,
|
||||
focusedContentColor = Color.Black,
|
||||
containerColor = idleBg,
|
||||
focusedContainerColor = focusBg,
|
||||
contentColor = idleFg,
|
||||
focusedContentColor = focusFg,
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
border = Border(
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x55FFFFFF)),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
border = BorderStroke(
|
||||
if (primary) 0.dp else 1.dp,
|
||||
if (primary) Color.Transparent else Color(0x55FFFFFF),
|
||||
),
|
||||
shape = shape,
|
||||
),
|
||||
focusedBorder = Border(
|
||||
border = androidx.compose.foundation.BorderStroke(2.dp, Color.White),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
border = BorderStroke(2.dp, if (primary) McColors.Accent else Color.White),
|
||||
shape = shape,
|
||||
),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
color = Color.Unspecified,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
|
@ -345,7 +566,6 @@ fun MovieDetailScreen(
|
|||
onBack: () -> Unit,
|
||||
onPlay: (mediaFileId: String, title: String, startMs: Long) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var title by remember { mutableStateOf("…") }
|
||||
var overview by remember { mutableStateOf("") }
|
||||
var backdrop by remember { mutableStateOf<String?>(null) }
|
||||
|
|
@ -358,13 +578,13 @@ fun MovieDetailScreen(
|
|||
var cast by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var director by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var trailerKey by remember { mutableStateOf<String?>(null) }
|
||||
var showTrailer by remember { mutableStateOf(false) }
|
||||
var versions by remember { mutableStateOf<List<MediaVersion>>(emptyList()) }
|
||||
var selectedVersionId by remember { mutableStateOf<String?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var onWatchlist by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val playFocus = remember { FocusRequester() }
|
||||
val scroll = rememberScrollState()
|
||||
|
||||
LaunchedEffect(movieId) {
|
||||
try {
|
||||
|
|
@ -426,92 +646,97 @@ fun MovieDetailScreen(
|
|||
rating?.let { add("★ $it") }
|
||||
}
|
||||
val playLabel = selected?.let { "Afspelen · ${it.shortLabel}" } ?: "Afspelen"
|
||||
val creditLine = buildList {
|
||||
if (director.isNotEmpty()) add(director.first())
|
||||
cast.take(3).forEach(::add)
|
||||
}.joinToString(" · ")
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||
DetailBackdrop(backdrop)
|
||||
|
||||
// Fullscreen detail (geen topnav) → content start bovenaan, volle hoogte benutten
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
start = HomeChrome.EdgeInset,
|
||||
end = HomeChrome.EdgeInset,
|
||||
top = 28.dp,
|
||||
bottom = 32.dp,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(36.dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(0.58f)
|
||||
.padding(start = HomeChrome.EdgeInset, end = HomeChrome.EdgeInset, top = 24.dp, bottom = 24.dp)
|
||||
.verticalScroll(scroll),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
DetailBackButton(onClick = onBack)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
DetailLogoOrTitle(logoUrl, title)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
DetailLogoOrTitle(logoUrl, title, compact = true)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
MetaChips(metaParts)
|
||||
if (endsAtLabel != null) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
endsAtLabel,
|
||||
color = Color(0xFF9A9AA8),
|
||||
color = Color(0xFFB8B8B8),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (versions.size > 1) {
|
||||
SelectedQualityLine(selected)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
selected?.let { ver ->
|
||||
DetailActionButton(playLabel, primary = true, focusRequester = playFocus) {
|
||||
onPlay(ver.id, title, 0)
|
||||
}
|
||||
}
|
||||
if (!trailerKey.isNullOrBlank()) {
|
||||
DetailActionButton("Trailer") {
|
||||
openYoutubeTrailer(context, trailerKey!!)
|
||||
}
|
||||
}
|
||||
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
|
||||
scope.launch {
|
||||
runCatching { onWatchlist = api.toggleFavorite("movie", movieId) }
|
||||
}
|
||||
}
|
||||
DetailActionButton("Markeer als gezien") {
|
||||
scope.launch {
|
||||
runCatching { api.markWatched(movieId = movieId) }
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (overview.isNotBlank()) {
|
||||
ExpandableOverview(overview)
|
||||
}
|
||||
if (director.isNotEmpty()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
"Regie · ${director.take(2).joinToString(", ")}",
|
||||
color = Color(0xFF9A9A9A),
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overview,
|
||||
color = Color(0xFFD6D6D6),
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 22.sp,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (cast.isNotEmpty()) {
|
||||
if (creditLine.isNotBlank()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Met · ${cast.take(4).joinToString(", ")}",
|
||||
creditLine,
|
||||
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),
|
||||
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 (versions.size > 1) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(versions, key = { it.id }) { ver ->
|
||||
VersionPickChip(
|
||||
version = ver,
|
||||
|
|
@ -528,16 +753,26 @@ fun MovieDetailScreen(
|
|||
model = poster,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 56.dp)
|
||||
.width(200.dp)
|
||||
.height(300.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.align(Alignment.Top)
|
||||
.padding(top = 40.dp)
|
||||
.width(180.dp)
|
||||
.height(270.dp)
|
||||
.shadow(22.dp, RoundedCornerShape(10.dp))
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.border(BorderStroke(1.dp, Color(0x33FFFFFF)), RoundedCornerShape(10.dp))
|
||||
.background(Color(0xFF1A1A1A)),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showTrailer && !trailerKey.isNullOrBlank()) {
|
||||
TrailerPlayerOverlay(
|
||||
youtubeKey = trailerKey!!,
|
||||
onDismiss = { showTrailer = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
|
|
@ -585,7 +820,6 @@ fun SeriesDetailScreen(
|
|||
onBack: () -> Unit,
|
||||
onPlay: (mediaFileId: String, episodeId: String, title: String, startMs: Long) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var title by remember { mutableStateOf("…") }
|
||||
var overview by remember { mutableStateOf("") }
|
||||
var backdrop by remember { mutableStateOf<String?>(null) }
|
||||
|
|
@ -594,6 +828,7 @@ fun SeriesDetailScreen(
|
|||
var rating by remember { mutableStateOf<String?>(null) }
|
||||
var genres by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var trailerKey by remember { mutableStateOf<String?>(null) }
|
||||
var showTrailer by remember { mutableStateOf(false) }
|
||||
var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) }
|
||||
var selectedSeason by rememberSaveable(seriesId) { mutableStateOf<Int?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
|
@ -698,7 +933,6 @@ fun SeriesDetailScreen(
|
|||
|
||||
val metaParts = buildList {
|
||||
year?.let { add(it.toString()) }
|
||||
genres.take(3).forEach(::add)
|
||||
rating?.let { add("★ $it") }
|
||||
}
|
||||
val playEpPreview = remember(episodes) {
|
||||
|
|
@ -708,21 +942,22 @@ fun SeriesDetailScreen(
|
|||
}
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||
DetailBackdrop(backdrop)
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
start = HomeChrome.EdgeInset,
|
||||
end = HomeChrome.EdgeInset,
|
||||
top = 28.dp,
|
||||
top = HomeChrome.BadgeTop,
|
||||
bottom = 40.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
DetailBackButton(onClick = onBack)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
DetailLogoOrTitle(logoUrl, title)
|
||||
GenrePills(genres)
|
||||
MetaChips(metaParts)
|
||||
if (overview.isNotBlank()) {
|
||||
ExpandableOverview(
|
||||
|
|
@ -732,7 +967,7 @@ fun SeriesDetailScreen(
|
|||
}
|
||||
if (error != null) Text(error!!, color = McColors.Danger)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
playEpPreview?.let { ep ->
|
||||
val resume = !ep.completed && ep.positionMs > 30_000L
|
||||
DetailActionButton(
|
||||
|
|
@ -749,7 +984,7 @@ fun SeriesDetailScreen(
|
|||
}
|
||||
}
|
||||
if (!trailerKey.isNullOrBlank()) {
|
||||
DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) }
|
||||
DetailActionButton("Trailer") { showTrailer = true }
|
||||
}
|
||||
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
|
||||
scope.launch {
|
||||
|
|
@ -759,7 +994,7 @@ fun SeriesDetailScreen(
|
|||
}
|
||||
if (seasons.size > 1) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Seizoenen", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text("SEIZOENEN", color = Color(0xFF8F8F8F), fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(seasons) { s ->
|
||||
|
|
@ -791,6 +1026,13 @@ fun SeriesDetailScreen(
|
|||
}
|
||||
}
|
||||
|
||||
if (showTrailer && !trailerKey.isNullOrBlank()) {
|
||||
TrailerPlayerOverlay(
|
||||
youtubeKey = trailerKey!!,
|
||||
onDismiss = { showTrailer = false },
|
||||
)
|
||||
}
|
||||
|
||||
val menuEp = episodeMenu
|
||||
if (menuEp != null) {
|
||||
BackHandler { episodeMenu = null }
|
||||
|
|
|
|||
|
|
@ -3,12 +3,29 @@ package nl.vonas.mediacluster.tv.ui
|
|||
import android.view.ViewGroup
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -20,6 +37,8 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
|
|
@ -31,25 +50,34 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Glow
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
import nl.vonas.mediacluster.tv.playback.PlayerTrack
|
||||
import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
|
||||
import nl.vonas.mediacluster.tv.playback.disableTrackType
|
||||
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
|
||||
import nl.vonas.mediacluster.tv.playback.listTracks
|
||||
import nl.vonas.mediacluster.tv.playback.liveMediaItem
|
||||
import nl.vonas.mediacluster.tv.playback.selectTrack
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private const val ReadyTimeoutMs = 20_000L
|
||||
private const val ChromeHideMs = 5_000L
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
|
||||
|
|
@ -58,6 +86,8 @@ fun EventPlayerScreen(
|
|||
api: ApiClient,
|
||||
eventId: String,
|
||||
onBack: () -> Unit,
|
||||
/** Hub-play (live + replay via /client/f1/content/…/play). */
|
||||
hubPlay: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val playerRef = remember { AtomicReference<ExoPlayer?>(null) }
|
||||
|
|
@ -65,12 +95,38 @@ fun EventPlayerScreen(
|
|||
buildLiveTvPlayer(context).also { playerRef.set(it) }
|
||||
}
|
||||
val rootFocus = remember { FocusRequester() }
|
||||
val audioBtnFocus = remember { FocusRequester() }
|
||||
val panelFocus = remember { FocusRequester() }
|
||||
val reachedReady = remember { AtomicBoolean(false) }
|
||||
var title by remember { mutableStateOf("Live event") }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var showChrome by remember { mutableStateOf(true) }
|
||||
var watchSessionId by remember { mutableStateOf<String?>(null) }
|
||||
var audioTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
|
||||
var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
|
||||
var selectedAudioKey by remember { mutableStateOf<String?>(null) }
|
||||
var selectedTextKey by remember { mutableStateOf<String?>(null) }
|
||||
var textDisabled by remember { mutableStateOf(true) }
|
||||
var menu by remember { mutableStateOf<String?>(null) } // audio | text
|
||||
var chromeBump by remember { mutableStateOf(0) }
|
||||
|
||||
fun refreshTracks() {
|
||||
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
|
||||
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
|
||||
selectedAudioKey = player.activeTrackKey(C.TRACK_TYPE_AUDIO)
|
||||
selectedTextKey = player.activeTrackKey(C.TRACK_TYPE_TEXT)
|
||||
textDisabled = player.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT) ||
|
||||
selectedTextKey == null
|
||||
}
|
||||
|
||||
fun revealChrome(focusControls: Boolean = false) {
|
||||
showChrome = true
|
||||
chromeBump++
|
||||
if (focusControls && menu == null) {
|
||||
runCatching { audioBtnFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
fun safeStopAndClear() {
|
||||
runCatching {
|
||||
|
|
@ -80,14 +136,24 @@ fun EventPlayerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(eventId) {
|
||||
LaunchedEffect(eventId, hubPlay) {
|
||||
loading = true
|
||||
error = null
|
||||
watchSessionId = null
|
||||
reachedReady.set(false)
|
||||
showChrome = true
|
||||
menu = null
|
||||
audioTracks = emptyList()
|
||||
textTracks = emptyList()
|
||||
try {
|
||||
val play = api.scheduleEventPlay(eventId)
|
||||
val play = if (hubPlay) {
|
||||
val contentId = Regex("""(?i)^f1tv:(\d+)$""").find(eventId.trim())
|
||||
?.groupValues?.getOrNull(1)
|
||||
?: eventId.trim().removePrefix("f1tv:").removePrefix("F1TV:")
|
||||
api.f1tvContentPlay(contentId)
|
||||
} else {
|
||||
api.scheduleEventPlay(eventId)
|
||||
}
|
||||
if (!isActive) return@LaunchedEffect
|
||||
title = play.name
|
||||
watchSessionId = play.watchSessionId
|
||||
|
|
@ -96,14 +162,11 @@ fun EventPlayerScreen(
|
|||
error = "Geen stream-URL beschikbaar"
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (play.drm == null || play.drm.keys.isEmpty()) {
|
||||
error = "Encryptiesleutels ontbreken — stream start binnenkort"
|
||||
return@LaunchedEffect
|
||||
}
|
||||
// ClearKey optioneel: F1TV plain HLS heeft drm=null.
|
||||
val item = try {
|
||||
liveMediaItem(url, play.format, play.drm)
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: "Ongeldige DRM-sleutels"
|
||||
error = e.message ?: "Kon mediastream niet openen"
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (!isActive) return@LaunchedEffect
|
||||
|
|
@ -151,13 +214,27 @@ fun EventPlayerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showChrome, error, loading) {
|
||||
if (showChrome && error == null && !loading) {
|
||||
delay(4_000)
|
||||
LaunchedEffect(showChrome, menu, error, loading, chromeBump) {
|
||||
if (showChrome && menu == null && error == null && !loading) {
|
||||
delay(ChromeHideMs)
|
||||
showChrome = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(menu) {
|
||||
if (menu != null) {
|
||||
delay(60)
|
||||
runCatching { panelFocus.requestFocus() }
|
||||
} else {
|
||||
delay(60)
|
||||
if (showChrome) {
|
||||
runCatching { audioBtnFocus.requestFocus() }
|
||||
} else {
|
||||
runCatching { rootFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(80)
|
||||
runCatching { rootFocus.requestFocus() }
|
||||
|
|
@ -169,12 +246,22 @@ fun EventPlayerScreen(
|
|||
when (state) {
|
||||
Player.STATE_READY -> {
|
||||
reachedReady.set(true)
|
||||
runCatching { player.ensureAudioSelected() }
|
||||
runCatching {
|
||||
player.ensureAudioSelected()
|
||||
refreshTracks()
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
|
||||
runCatching {
|
||||
player.ensureAudioSelected()
|
||||
refreshTracks()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(e: PlaybackException) {
|
||||
val cause = generateSequence(e as Throwable?) { it.cause }
|
||||
.mapNotNull { it.message?.takeIf { m -> m.isNotBlank() } }
|
||||
|
|
@ -200,7 +287,13 @@ fun EventPlayerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
BackHandler {
|
||||
when {
|
||||
menu != null -> menu = null
|
||||
showChrome -> showChrome = false
|
||||
else -> onBack()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
|
|
@ -209,14 +302,38 @@ fun EventPlayerScreen(
|
|||
.focusRequester(rootFocus)
|
||||
.focusable()
|
||||
.onPreviewKeyEvent { ev ->
|
||||
if (ev.type == KeyEventType.KeyDown &&
|
||||
(ev.key == Key.Back || ev.key == Key.Escape)
|
||||
) {
|
||||
if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
when {
|
||||
ev.key == Key.Back || ev.key == Key.Escape -> {
|
||||
when {
|
||||
menu != null -> {
|
||||
menu = null
|
||||
true
|
||||
}
|
||||
showChrome -> {
|
||||
showChrome = false
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
onBack()
|
||||
true
|
||||
} else {
|
||||
}
|
||||
}
|
||||
}
|
||||
menu != null -> false
|
||||
ev.key == Key.Menu || ev.key == Key.Guide -> {
|
||||
revealChrome(focusControls = true)
|
||||
true
|
||||
}
|
||||
!showChrome -> {
|
||||
revealChrome(focusControls = true)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
revealChrome()
|
||||
false
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (error == null) {
|
||||
|
|
@ -266,15 +383,353 @@ fun EventPlayerScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
} else if (showChrome) {
|
||||
} else {
|
||||
AnimatedVisibility(
|
||||
visible = showChrome || menu != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
EventsLook.Scrim,
|
||||
Color.Transparent,
|
||||
Color.Transparent,
|
||||
EventsLook.Scrim,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(horizontal = 32.dp, vertical = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.background(EventsLook.Live, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
"LIVE",
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.size(width = 3.dp, height = 14.dp)
|
||||
.background(EventsLook.Neon, RoundedCornerShape(2.dp)),
|
||||
)
|
||||
Text(
|
||||
"EVENT",
|
||||
color = EventsLook.Neon,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
title,
|
||||
color = Color.White,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(28.dp),
|
||||
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(
|
||||
label,
|
||||
color = if (focused) EventsLook.Neon else Color.White,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
subtitle,
|
||||
color = if (focused) Color(0xCCFFFFFF) else McColors.TextDim,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EventTrackRow(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
focusRequester: FocusRequester? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val mod = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = mod,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = when {
|
||||
selected -> EventsLook.NeonSoft
|
||||
else -> Color.Transparent
|
||||
},
|
||||
focusedContainerColor = EventsLook.PanelHot,
|
||||
contentColor = Color.White,
|
||||
focusedContentColor = Color.White,
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = androidx.tv.material3.Border(
|
||||
border = androidx.compose.foundation.BorderStroke(1.5.dp, EventsLook.Neon),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
if (selected) "●" else "○",
|
||||
color = if (selected || focused) EventsLook.Neon else Color(0x55FFFFFF),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
Text(
|
||||
label,
|
||||
color = Color.Unspecified,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ fun EventsScreen(
|
|||
pendingFocusRestore: Boolean = false,
|
||||
onFocusRestoreConsumed: () -> Unit = {},
|
||||
onOpenEvent: (String) -> Unit,
|
||||
onOpenF1Hub: (() -> Unit)? = null,
|
||||
onContentFocused: () -> Unit = {},
|
||||
) {
|
||||
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
|
||||
|
|
@ -224,10 +225,13 @@ fun EventsScreen(
|
|||
else -> null
|
||||
}
|
||||
|
||||
// Focus: TopNav → één filterrij (sport + bron) → first rail.
|
||||
// Focus: TopNav → F1-tile (optioneel) → filterrij → first rail.
|
||||
val hasF1Entry = onOpenF1Hub != null
|
||||
val showFilterBar = showSportChips || showProviderChips
|
||||
val filterBarFocus = remember { FocusRequester() }
|
||||
val firstRailUpTarget = when {
|
||||
showFilterBar && filterBarFocusReady -> contentFocusRequester
|
||||
showFilterBar && filterBarFocusReady -> filterBarFocus
|
||||
hasF1Entry -> contentFocusRequester
|
||||
else -> navFocusRequester
|
||||
}
|
||||
val filterFocusDown: FocusRequester? = when {
|
||||
|
|
@ -235,6 +239,7 @@ fun EventsScreen(
|
|||
railFocusReady -> firstRailFocus
|
||||
else -> null
|
||||
}
|
||||
val railUsesDedicatedFocus = hasF1Entry || showFilterBar
|
||||
|
||||
fun claimRailFocus(owner: String, ready: Boolean) {
|
||||
if (ready) {
|
||||
|
|
@ -265,6 +270,7 @@ fun EventsScreen(
|
|||
}
|
||||
val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0
|
||||
val restoreListIndex = remember(
|
||||
hasF1Entry,
|
||||
showFilterBar,
|
||||
live,
|
||||
upcomingRails,
|
||||
|
|
@ -273,6 +279,7 @@ fun EventsScreen(
|
|||
) {
|
||||
if (!canRestoreCard) return@remember -1
|
||||
var idx = 0
|
||||
if (hasF1Entry) idx++
|
||||
if (showFilterBar) idx++
|
||||
if (restoreInLive) return@remember idx
|
||||
if (live.isNotEmpty()) idx++
|
||||
|
|
@ -295,7 +302,7 @@ fun EventsScreen(
|
|||
val activeRestoreFocus = when {
|
||||
!canRestoreCard -> null
|
||||
restoreIsFirstOfFirstRail -> when {
|
||||
showFilterBar -> firstRailFocus
|
||||
railUsesDedicatedFocus -> firstRailFocus
|
||||
else -> contentFocusRequester
|
||||
}
|
||||
else -> restoreCardFocus
|
||||
|
|
@ -313,12 +320,14 @@ fun EventsScreen(
|
|||
}
|
||||
|
||||
val entryReady = when {
|
||||
hasF1Entry -> true
|
||||
showFilterBar -> filterBarFocusReady
|
||||
else -> railFocusReady
|
||||
}
|
||||
val needContentSink = when {
|
||||
loading && events.isEmpty() -> true
|
||||
error != null && events.isEmpty() -> true
|
||||
hasF1Entry -> false
|
||||
showFilterBar -> !filterBarFocusReady
|
||||
!hasRails -> true
|
||||
else -> !railFocusReady
|
||||
|
|
@ -343,6 +352,7 @@ fun EventsScreen(
|
|||
if (restoredSuccessfully) return
|
||||
val entryOk = awaitReady({
|
||||
when {
|
||||
hasF1Entry -> true
|
||||
showFilterBar -> filterBarFocusReady
|
||||
else -> railFocusReady
|
||||
}
|
||||
|
|
@ -465,6 +475,16 @@ fun EventsScreen(
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 56.dp),
|
||||
) {
|
||||
if (hasF1Entry) {
|
||||
item(key = "f1_hub_entry") {
|
||||
F1HubEntryTile(
|
||||
focusRequester = contentFocusRequester,
|
||||
focusUp = navFocusRequester,
|
||||
onOpen = onOpenF1Hub!!,
|
||||
onFocus = onContentFocused,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Eén filterrij (sport + bron) — altijd aanwezig bij data zodat focus stabiel blijft.
|
||||
if (showFilterBar) {
|
||||
item(key = "filters") {
|
||||
|
|
@ -478,8 +498,8 @@ fun EventsScreen(
|
|||
onProviderSelect = { providerFilter = it },
|
||||
liveCount = live.size,
|
||||
totalCount = filtered.size,
|
||||
firstFocusRequester = contentFocusRequester,
|
||||
focusUp = navFocusRequester,
|
||||
firstFocusRequester = if (hasF1Entry) filterBarFocus else contentFocusRequester,
|
||||
focusUp = if (hasF1Entry) contentFocusRequester else navFocusRequester,
|
||||
focusDown = filterFocusDown,
|
||||
onFocusReady = { filterBarFocusReady = it },
|
||||
onFocus = onContentFocused,
|
||||
|
|
@ -511,7 +531,7 @@ fun EventsScreen(
|
|||
style = EventRailStyle.Live,
|
||||
onOpen = onOpenEvent,
|
||||
onFocus = onContentFocused,
|
||||
firstItemFocusRequester = if (showFilterBar) {
|
||||
firstItemFocusRequester = if (railUsesDedicatedFocus) {
|
||||
firstRailFocus
|
||||
} else {
|
||||
contentFocusRequester
|
||||
|
|
@ -545,7 +565,7 @@ fun EventsScreen(
|
|||
onFocus = onContentFocused,
|
||||
firstItemFocusRequester = when {
|
||||
!isFirstUpcoming -> null
|
||||
showFilterBar -> firstRailFocus
|
||||
railUsesDedicatedFocus -> firstRailFocus
|
||||
else -> contentFocusRequester
|
||||
},
|
||||
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
|
||||
|
|
@ -579,7 +599,7 @@ fun EventsScreen(
|
|||
onFocus = onContentFocused,
|
||||
firstItemFocusRequester = when {
|
||||
!isFirstUpcoming -> null
|
||||
showFilterBar -> firstRailFocus
|
||||
railUsesDedicatedFocus -> firstRailFocus
|
||||
else -> contentFocusRequester
|
||||
},
|
||||
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
|
||||
|
|
@ -1056,6 +1076,96 @@ private fun PulsingLiveDot(size: androidx.compose.ui.unit.Dp = 10.dp) {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun F1HubEntryTile(
|
||||
focusRequester: FocusRequester?,
|
||||
focusUp: FocusRequester?,
|
||||
onOpen: () -> Unit,
|
||||
onFocus: () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val f1Red = Color(0xFFE10600)
|
||||
Surface(
|
||||
onClick = onOpen,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = HomeChrome.EdgeInset)
|
||||
.padding(top = 8.dp, bottom = 10.dp)
|
||||
.height(72.dp)
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||
.then(
|
||||
if (focusUp != null) {
|
||||
Modifier.focusProperties { up = focusUp }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.onFocusChanged {
|
||||
focused = it.isFocused
|
||||
if (it.isFocused) onFocus()
|
||||
},
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||
glow = ClickableSurfaceDefaults.glow(
|
||||
focusedGlow = Glow(elevationColor = f1Red, elevation = 14.dp),
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(2.5.dp, f1Red),
|
||||
inset = 4.dp,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color(0xFF141414),
|
||||
focusedContainerColor = Color(0xFF1E1212),
|
||||
),
|
||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(f1Red),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"F1",
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Formule 1",
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
"Agenda & terugkijken",
|
||||
color = McColors.TextDim,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Open →",
|
||||
color = if (focused) f1Red else McColors.TextDim,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun EventCard(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -131,6 +131,10 @@ fun McNav() {
|
|||
var eventsReturnNonce by remember { mutableIntStateOf(0) }
|
||||
var lastOpenedEventId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pendingEventsFocusRestore by remember { mutableStateOf(false) }
|
||||
var homeReturnNonce by remember { mutableIntStateOf(0) }
|
||||
var lastOpenedHomeKey by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pendingHomeFocusRestore by remember { mutableStateOf(false) }
|
||||
var homeDetailFromHome by remember { mutableStateOf(false) }
|
||||
val favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet())
|
||||
val hubTab = remember(hubTabName) {
|
||||
MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home
|
||||
|
|
@ -328,12 +332,21 @@ fun McNav() {
|
|||
restoreEventId = lastOpenedEventId,
|
||||
pendingEventsFocusRestore = pendingEventsFocusRestore,
|
||||
onEventsFocusRestoreConsumed = { pendingEventsFocusRestore = false },
|
||||
homeReturnNonce = homeReturnNonce,
|
||||
restoreHomeKey = lastOpenedHomeKey,
|
||||
pendingHomeFocusRestore = pendingHomeFocusRestore,
|
||||
onHomeFocusRestoreConsumed = { pendingHomeFocusRestore = false },
|
||||
onHomeItemOpened = { key ->
|
||||
lastOpenedHomeKey = key
|
||||
homeDetailFromHome = true
|
||||
},
|
||||
profileAvatarId = profileAvatarId,
|
||||
profileShowAvatar = profileShowAvatar,
|
||||
profileAvatarUrl = profileAvatarUrl,
|
||||
profileName = profileName,
|
||||
onOpenMovie = { nav.navigate("movie/$it") },
|
||||
onOpenSeries = { nav.navigate("series/$it") },
|
||||
onLeaveHomeDetailOrigin = { homeDetailFromHome = false },
|
||||
onBrowse = { catalogId, _title ->
|
||||
nav.navigate("browse/$catalogId")
|
||||
},
|
||||
|
|
@ -352,6 +365,11 @@ fun McNav() {
|
|||
lastOpenedEventId = eventId
|
||||
nav.navigate("event/${android.net.Uri.encode(eventId)}")
|
||||
},
|
||||
onOpenF1Hub = {
|
||||
nav.navigate("f1_hub") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onSwitchProfile = {
|
||||
scope.launch {
|
||||
runCatching { api.clearProfile() }
|
||||
|
|
@ -389,8 +407,14 @@ fun McNav() {
|
|||
api = api,
|
||||
catalogId = catalogId,
|
||||
onBack = { nav.popBackStack() },
|
||||
onOpenMovie = { nav.navigate("movie/$it") },
|
||||
onOpenSeries = { nav.navigate("series/$it") },
|
||||
onOpenMovie = {
|
||||
homeDetailFromHome = false
|
||||
nav.navigate("movie/$it")
|
||||
},
|
||||
onOpenSeries = {
|
||||
homeDetailFromHome = false
|
||||
nav.navigate("series/$it")
|
||||
},
|
||||
onPlayContinue = if (catalogId == "continue-watching") {
|
||||
{ mediaFileId, movieId, episodeId, seriesId, type, startMs ->
|
||||
val m = movieId?.ifBlank { null } ?: "-"
|
||||
|
|
@ -405,6 +429,14 @@ fun McNav() {
|
|||
}
|
||||
composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
|
||||
val id = entry.arguments?.getString("id") ?: return@composable
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
if (homeDetailFromHome) {
|
||||
pendingHomeFocusRestore = true
|
||||
homeReturnNonce++
|
||||
}
|
||||
}
|
||||
}
|
||||
MovieDetailScreen(
|
||||
api = api,
|
||||
movieId = id,
|
||||
|
|
@ -416,6 +448,14 @@ fun McNav() {
|
|||
}
|
||||
composable("series/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
|
||||
val id = entry.arguments?.getString("id") ?: return@composable
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
if (homeDetailFromHome) {
|
||||
pendingHomeFocusRestore = true
|
||||
homeReturnNonce++
|
||||
}
|
||||
}
|
||||
}
|
||||
SeriesDetailScreen(
|
||||
api = api,
|
||||
seriesId = id,
|
||||
|
|
@ -518,6 +558,72 @@ fun McNav() {
|
|||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable("f1_hub") {
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
pendingEventsFocusRestore = true
|
||||
eventsReturnNonce++
|
||||
}
|
||||
}
|
||||
F1HubScreen(
|
||||
api = api,
|
||||
onBack = { nav.popBackStack() },
|
||||
onOpenMeeting = { key ->
|
||||
nav.navigate("f1_meeting/${android.net.Uri.encode(key)}") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onPlay = { contentId ->
|
||||
nav.navigate("f1_play/${android.net.Uri.encode(contentId)}") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"f1_meeting/{meetingKey}",
|
||||
arguments = listOf(navArgument("meetingKey") { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
val meetingKey = entry.arguments?.getString("meetingKey")
|
||||
?.let { android.net.Uri.decode(it) }
|
||||
?: return@composable
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
pendingEventsFocusRestore = true
|
||||
eventsReturnNonce++
|
||||
}
|
||||
}
|
||||
F1MeetingScreen(
|
||||
api = api,
|
||||
meetingKey = meetingKey,
|
||||
onBack = { nav.popBackStack() },
|
||||
onPlay = { contentId ->
|
||||
nav.navigate("f1_play/${android.net.Uri.encode(contentId)}") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"f1_play/{contentId}",
|
||||
arguments = listOf(navArgument("contentId") { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
val contentId = entry.arguments?.getString("contentId")
|
||||
?.let { android.net.Uri.decode(it) }
|
||||
?: return@composable
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
pendingEventsFocusRestore = true
|
||||
eventsReturnNonce++
|
||||
}
|
||||
}
|
||||
EventPlayerScreen(
|
||||
api = api,
|
||||
eventId = "f1tv:$contentId",
|
||||
hubPlay = true,
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -543,6 +649,12 @@ private fun MainHub(
|
|||
restoreEventId: String? = null,
|
||||
pendingEventsFocusRestore: Boolean = false,
|
||||
onEventsFocusRestoreConsumed: () -> Unit = {},
|
||||
homeReturnNonce: Int = 0,
|
||||
restoreHomeKey: String? = null,
|
||||
pendingHomeFocusRestore: Boolean = false,
|
||||
onHomeFocusRestoreConsumed: () -> Unit = {},
|
||||
onHomeItemOpened: (String) -> Unit = {},
|
||||
onLeaveHomeDetailOrigin: () -> Unit = {},
|
||||
profileAvatarId: String?,
|
||||
profileShowAvatar: Boolean = true,
|
||||
profileAvatarUrl: String? = null,
|
||||
|
|
@ -553,6 +665,7 @@ private fun MainHub(
|
|||
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
|
||||
onPlayLive: (String) -> Unit,
|
||||
onOpenEvent: (String) -> Unit,
|
||||
onOpenF1Hub: () -> Unit = {},
|
||||
onSwitchProfile: () -> Unit,
|
||||
onManageProfiles: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
|
|
@ -719,6 +832,15 @@ private fun MainHub(
|
|||
runCatching { eventsContentFocus.requestFocus() }
|
||||
}
|
||||
|
||||
// Terug van Movie/Series detail vanaf Home: content-focus, card-restore in HomeScreen.
|
||||
LaunchedEffect(homeReturnNonce) {
|
||||
if (homeReturnNonce <= 0 || tab != MainTab.Home) return@LaunchedEffect
|
||||
navFocused = false
|
||||
if (pendingHomeFocusRestore) return@LaunchedEffect
|
||||
delay(100)
|
||||
runCatching { homeContentFocus.requestFocus() }
|
||||
}
|
||||
|
||||
// Reset scroll alleen bij echte tab-wissel naar Home (niet bij terugkeer uit detail/player)
|
||||
LaunchedEffect(tab) {
|
||||
val name = tab.name
|
||||
|
|
@ -862,10 +984,20 @@ private fun MainHub(
|
|||
listState = homeListState,
|
||||
contentFocusRequester = homeContentFocus,
|
||||
navFocusRequester = navFocus,
|
||||
focusRestoreNonce = homeReturnNonce,
|
||||
restoreHomeKey = restoreHomeKey,
|
||||
pendingFocusRestore = pendingHomeFocusRestore,
|
||||
onFocusRestoreConsumed = onHomeFocusRestoreConsumed,
|
||||
onContentFocused = { navFocused = false },
|
||||
onChromeCompact = { homeChromeCompact = it },
|
||||
onOpenMovie = onOpenMovie,
|
||||
onOpenSeries = onOpenSeries,
|
||||
onOpenMovie = { id ->
|
||||
onHomeItemOpened("movie:$id")
|
||||
onOpenMovie(id)
|
||||
},
|
||||
onOpenSeries = { id ->
|
||||
onHomeItemOpened("series:$id")
|
||||
onOpenSeries(id)
|
||||
},
|
||||
onSeeAll = onBrowse,
|
||||
onPlay = onPlay,
|
||||
)
|
||||
|
|
@ -873,8 +1005,8 @@ private fun MainHub(
|
|||
api = api,
|
||||
gridState = watchlistGridState,
|
||||
onAtTopBack = { focusTopNav() },
|
||||
onOpenMovie = onOpenMovie,
|
||||
onOpenSeries = onOpenSeries,
|
||||
onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) },
|
||||
onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) },
|
||||
)
|
||||
MainTab.Movies -> BrowseScreen(
|
||||
api = api,
|
||||
|
|
@ -882,8 +1014,8 @@ private fun MainHub(
|
|||
titleHint = movieSubs.firstOrNull { it.id == movieSubId }?.label ?: "Films",
|
||||
gridState = moviesGridState,
|
||||
onAtTopBack = { focusTopNav() },
|
||||
onOpenMovie = onOpenMovie,
|
||||
onOpenSeries = onOpenSeries,
|
||||
onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) },
|
||||
onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) },
|
||||
)
|
||||
MainTab.Series -> BrowseScreen(
|
||||
api = api,
|
||||
|
|
@ -891,8 +1023,8 @@ private fun MainHub(
|
|||
titleHint = seriesSubs.firstOrNull { it.id == seriesSubId }?.label ?: "Series",
|
||||
gridState = seriesGridState,
|
||||
onAtTopBack = { focusTopNav() },
|
||||
onOpenMovie = onOpenMovie,
|
||||
onOpenSeries = onOpenSeries,
|
||||
onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) },
|
||||
onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) },
|
||||
)
|
||||
MainTab.Tv -> TvScreen(
|
||||
api = api,
|
||||
|
|
@ -931,12 +1063,13 @@ private fun MainHub(
|
|||
pendingFocusRestore = pendingEventsFocusRestore,
|
||||
onFocusRestoreConsumed = onEventsFocusRestoreConsumed,
|
||||
onOpenEvent = onOpenEvent,
|
||||
onOpenF1Hub = onOpenF1Hub,
|
||||
onContentFocused = { navFocused = false },
|
||||
)
|
||||
MainTab.Search -> SearchScreen(
|
||||
api = api,
|
||||
onOpenMovie = onOpenMovie,
|
||||
onOpenSeries = onOpenSeries,
|
||||
onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) },
|
||||
onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) },
|
||||
onAtTopBack = { focusTopNav() },
|
||||
)
|
||||
MainTab.Settings -> SettingsScreen(
|
||||
|
|
@ -1163,6 +1296,10 @@ private fun HomeScreen(
|
|||
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||
contentFocusRequester: FocusRequester,
|
||||
navFocusRequester: FocusRequester,
|
||||
focusRestoreNonce: Int = 0,
|
||||
restoreHomeKey: String? = null,
|
||||
pendingFocusRestore: Boolean = false,
|
||||
onFocusRestoreConsumed: () -> Unit = {},
|
||||
onContentFocused: () -> Unit,
|
||||
onChromeCompact: (Boolean) -> Unit = {},
|
||||
onOpenMovie: (String) -> Unit,
|
||||
|
|
@ -1177,12 +1314,14 @@ private fun HomeScreen(
|
|||
var menuReady by remember { mutableStateOf(false) }
|
||||
var shelfFocusReady by remember { mutableStateOf(false) }
|
||||
var playFocusReady by remember { mutableStateOf(false) }
|
||||
var restoreCardReady by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val menuFocus = remember { FocusRequester() }
|
||||
val context = LocalContext.current
|
||||
val store = remember { SessionStore(context) }
|
||||
val density = LocalDensity.current
|
||||
val firstShelfFocus = remember { FocusRequester() }
|
||||
val restoreCardFocus = remember { FocusRequester() }
|
||||
|
||||
suspend fun reloadHome() {
|
||||
val fresh = api.home()
|
||||
|
|
@ -1219,25 +1358,102 @@ private fun HomeScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// Bij terugkeer van detail: geen jump naar Afspelen/top. Alleen content-focus als we gescrolld waren.
|
||||
LaunchedEffect(Unit) {
|
||||
delay(80)
|
||||
val scrolled = listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 40
|
||||
if (scrolled) {
|
||||
val continueItems = home?.continueWatching.orEmpty()
|
||||
val visibleShelves = remember(home?.shelves) {
|
||||
home?.shelves.orEmpty().filter { it.items.size >= 3 }
|
||||
}
|
||||
|
||||
data class HomeRestoreTarget(
|
||||
val listIndex: Int,
|
||||
val shelfId: String?,
|
||||
val itemIndex: Int,
|
||||
val isFirstOfFirstShelf: Boolean,
|
||||
)
|
||||
|
||||
fun MediaItem.detailKey(): String = when {
|
||||
type.equals("movie", true) -> "movie:$id"
|
||||
type.equals("episode", true) && !seriesId.isNullOrBlank() -> "series:$seriesId"
|
||||
else -> "series:$id"
|
||||
}
|
||||
|
||||
val restoreTarget = remember(home, continueItems, visibleShelves, restoreHomeKey, error) {
|
||||
if (restoreHomeKey.isNullOrBlank()) return@remember null
|
||||
var listIndex = 1 // hero-spacer
|
||||
if (error != null) listIndex++
|
||||
if (continueItems.isNotEmpty()) {
|
||||
val preview = continueItems.take(5)
|
||||
val itemIndex = preview.indexOfFirst { it.detailKey() == restoreHomeKey }
|
||||
if (itemIndex >= 0) {
|
||||
return@remember HomeRestoreTarget(
|
||||
listIndex = listIndex,
|
||||
shelfId = "__continue__",
|
||||
itemIndex = itemIndex,
|
||||
isFirstOfFirstShelf = itemIndex == 0,
|
||||
)
|
||||
}
|
||||
listIndex++
|
||||
}
|
||||
visibleShelves.forEachIndexed { shelfPos, shelf ->
|
||||
val itemIndex = shelf.items.indexOfFirst { it.detailKey() == restoreHomeKey }
|
||||
if (itemIndex >= 0) {
|
||||
val isFirstShelf = continueItems.isEmpty() && shelfPos == 0
|
||||
return@remember HomeRestoreTarget(
|
||||
listIndex = listIndex + shelfPos,
|
||||
shelfId = shelf.id,
|
||||
itemIndex = itemIndex,
|
||||
isFirstOfFirstShelf = isFirstShelf && itemIndex == 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
val bindRestoreKey = restoreHomeKey.takeIf {
|
||||
pendingFocusRestore && restoreTarget != null && restoreTarget?.isFirstOfFirstShelf != true
|
||||
}
|
||||
|
||||
// Bij terugkeer van detail: focus op de geopende card (of eerste plank als fallback).
|
||||
LaunchedEffect(
|
||||
pendingFocusRestore,
|
||||
focusRestoreNonce,
|
||||
restoreTarget,
|
||||
home != null,
|
||||
) {
|
||||
if (!pendingFocusRestore || home == null) return@LaunchedEffect
|
||||
delay(60)
|
||||
onContentFocused()
|
||||
val deadline = System.currentTimeMillis() + 700
|
||||
val target = restoreTarget
|
||||
if (target != null) {
|
||||
runCatching { listState.scrollToItem(target.listIndex) }
|
||||
delay(40)
|
||||
val focusTarget = if (target.isFirstOfFirstShelf) firstShelfFocus else restoreCardFocus
|
||||
val ready = if (target.isFirstOfFirstShelf) {
|
||||
{ shelfFocusReady }
|
||||
} else {
|
||||
{ restoreCardReady }
|
||||
}
|
||||
val deadline = System.currentTimeMillis() + 900
|
||||
while (!ready() && System.currentTimeMillis() < deadline) {
|
||||
delay(40)
|
||||
}
|
||||
delay(40)
|
||||
if (runCatching { focusTarget.requestFocus() }.isSuccess) {
|
||||
onFocusRestoreConsumed()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
// Fallback: eerste zichtbare plank
|
||||
val deadline = System.currentTimeMillis() + 500
|
||||
while (!shelfFocusReady && System.currentTimeMillis() < deadline) {
|
||||
delay(40)
|
||||
}
|
||||
delay(40)
|
||||
runCatching { firstShelfFocus.requestFocus() }
|
||||
}
|
||||
onFocusRestoreConsumed()
|
||||
}
|
||||
|
||||
val spotlight = home?.spotlight.orEmpty().ifEmpty {
|
||||
home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty()
|
||||
}
|
||||
val continueItems = home?.continueWatching.orEmpty()
|
||||
val screenH = LocalConfiguration.current.screenHeightDp.dp
|
||||
|
||||
// Hero vult ~78% scherm; planken starten op ~52% → overlappen de fade
|
||||
|
|
@ -1387,6 +1603,9 @@ private fun HomeScreen(
|
|||
firstItemFocusRequester = firstShelfFocus,
|
||||
focusUpTo = contentFocusRequester.takeIf { playFocusReady },
|
||||
onFirstFocusTargetReady = { shelfFocusReady = it },
|
||||
restoreItemKey = bindRestoreKey.takeIf { restoreTarget?.shelfId == "__continue__" },
|
||||
restoreFocusRequester = restoreCardFocus.takeIf { restoreTarget?.shelfId == "__continue__" },
|
||||
onRestoreFocusReady = { restoreCardReady = it },
|
||||
onRowFocused = {
|
||||
onContentFocused()
|
||||
expandHeroFully()
|
||||
|
|
@ -1417,16 +1636,16 @@ private fun HomeScreen(
|
|||
}
|
||||
}
|
||||
items(
|
||||
home?.shelves.orEmpty().filter { it.items.size >= 3 },
|
||||
visibleShelves,
|
||||
key = { it.id },
|
||||
) { shelf ->
|
||||
val catalogId = when (shelf.slug) {
|
||||
"recent-movies", "recent-series", "4k-movies" -> shelf.slug
|
||||
else -> shelf.slug.ifBlank { shelf.id }
|
||||
}
|
||||
val firstVisibleShelfId = home?.shelves.orEmpty()
|
||||
.firstOrNull { it.items.size >= 3 }?.id
|
||||
val firstVisibleShelfId = visibleShelves.firstOrNull()?.id
|
||||
val isFirstShelf = continueItems.isEmpty() && shelf.id == firstVisibleShelfId
|
||||
val shelfHasRestore = restoreTarget?.shelfId == shelf.id
|
||||
ShelfBlock(
|
||||
title = shelf.title,
|
||||
items = shelf.items,
|
||||
|
|
@ -1437,6 +1656,13 @@ private fun HomeScreen(
|
|||
} else {
|
||||
null
|
||||
},
|
||||
restoreItemKey = bindRestoreKey.takeIf { shelfHasRestore },
|
||||
restoreFocusRequester = restoreCardFocus.takeIf { shelfHasRestore },
|
||||
onRestoreFocusReady = if (shelfHasRestore) {
|
||||
{ restoreCardReady = it }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onRowFocused = {
|
||||
onContentFocused()
|
||||
if (isFirstShelf) expandHeroFully()
|
||||
|
|
@ -1708,9 +1934,29 @@ private fun ShelfBlock(
|
|||
firstItemFocusRequester: FocusRequester? = null,
|
||||
focusUpTo: FocusRequester? = null,
|
||||
onFirstFocusTargetReady: ((Boolean) -> Unit)? = null,
|
||||
restoreItemKey: String? = null,
|
||||
restoreFocusRequester: FocusRequester? = null,
|
||||
onRestoreFocusReady: ((Boolean) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var rowFocused by remember { mutableStateOf(false) }
|
||||
val rowState = rememberLazyListState()
|
||||
val restoreIndex = remember(items, restoreItemKey) {
|
||||
if (restoreItemKey.isNullOrBlank()) -1
|
||||
else items.indexOfFirst { item ->
|
||||
val key = when {
|
||||
item.type.equals("movie", true) -> "movie:${item.id}"
|
||||
item.type.equals("episode", true) && !item.seriesId.isNullOrBlank() -> "series:${item.seriesId}"
|
||||
else -> "series:${item.id}"
|
||||
}
|
||||
key == restoreItemKey
|
||||
}
|
||||
}
|
||||
LaunchedEffect(restoreIndex) {
|
||||
if (restoreIndex > 0) {
|
||||
runCatching { rowState.scrollToItem(restoreIndex) }
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier
|
||||
.onFocusChanged {
|
||||
|
|
@ -1757,6 +2003,7 @@ private fun ShelfBlock(
|
|||
}
|
||||
}
|
||||
LazyRow(
|
||||
state = rowState,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
// Extra ruimte zodat focusedScale (1.10) posters niet afknipt
|
||||
contentPadding = PaddingValues(
|
||||
|
|
@ -1767,23 +2014,40 @@ private fun ShelfBlock(
|
|||
),
|
||||
) {
|
||||
itemsIndexed(items, key = { _, it -> "${it.type}:${it.id}:${it.progressId}" }) { index, item ->
|
||||
if (index == 0 && firstItemFocusRequester != null) {
|
||||
val itemKey = when {
|
||||
item.type.equals("movie", true) -> "movie:${item.id}"
|
||||
item.type.equals("episode", true) && !item.seriesId.isNullOrBlank() -> "series:${item.seriesId}"
|
||||
else -> "series:${item.id}"
|
||||
}
|
||||
val isRestore = restoreItemKey != null && itemKey == restoreItemKey
|
||||
val cardRequester = when {
|
||||
isRestore && restoreFocusRequester != null -> restoreFocusRequester
|
||||
index == 0 -> firstItemFocusRequester
|
||||
else -> null
|
||||
}
|
||||
if (index == 0 && firstItemFocusRequester != null && cardRequester === firstItemFocusRequester) {
|
||||
DisposableEffect(firstItemFocusRequester) {
|
||||
onFirstFocusTargetReady?.invoke(true)
|
||||
onDispose { onFirstFocusTargetReady?.invoke(false) }
|
||||
}
|
||||
}
|
||||
if (isRestore && restoreFocusRequester != null && cardRequester === restoreFocusRequester) {
|
||||
DisposableEffect(restoreFocusRequester) {
|
||||
onRestoreFocusReady?.invoke(true)
|
||||
onDispose { onRestoreFocusReady?.invoke(false) }
|
||||
}
|
||||
}
|
||||
PosterCard(
|
||||
item = item,
|
||||
onClick = { onClick(item) },
|
||||
onLongClick = onLongClick?.let { handler -> { handler(item) } },
|
||||
showCaption = showCaptions,
|
||||
modifier = Modifier.then(
|
||||
if (index == 0 && firstItemFocusRequester != null) {
|
||||
if (cardRequester != null) {
|
||||
Modifier
|
||||
.focusRequester(firstItemFocusRequester)
|
||||
.focusRequester(cardRequester)
|
||||
.then(
|
||||
if (focusUpTo != null) {
|
||||
if (focusUpTo != null && (index == 0 || isRestore)) {
|
||||
Modifier.focusProperties { up = focusUpTo }
|
||||
} else {
|
||||
Modifier
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
A11C1EAF0000000000000002 /* AesCtr.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000002 /* AesCtr.swift */; };
|
||||
A11C1EAF0000000000000003 /* CencMp4Decryptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */; };
|
||||
A11C1EAF0000000000000004 /* ClearKeyDashProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */; };
|
||||
A11C1EAF0000000000000005 /* AVLivePlayerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000005 /* AVLivePlayerController.swift */; };
|
||||
F8AE29FF63524E7D8C036C7E /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 522137D80C53480E95FF600F /* RootView.swift */; };
|
||||
D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5E450343884C11B0920888 /* MainShell.swift */; };
|
||||
1EF110B9F82E440F999ECE37 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B20E94E6B4034B1EF282B /* Components.swift */; };
|
||||
|
|
@ -55,6 +56,7 @@
|
|||
B11C1EAF0000000000000002 /* AesCtr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AesCtr.swift; sourceTree = "<group>"; };
|
||||
B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CencMp4Decryptor.swift; sourceTree = "<group>"; };
|
||||
B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearKeyDashProxy.swift; sourceTree = "<group>"; };
|
||||
B11C1EAF0000000000000005 /* AVLivePlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVLivePlayerController.swift; sourceTree = "<group>"; };
|
||||
522137D80C53480E95FF600F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
||||
BF5E450343884C11B0920888 /* MainShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainShell.swift; sourceTree = "<group>"; };
|
||||
314B20E94E6B4034B1EF282B /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -163,6 +165,7 @@
|
|||
B11C1EAF0000000000000002 /* AesCtr.swift */,
|
||||
B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */,
|
||||
B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */,
|
||||
B11C1EAF0000000000000005 /* AVLivePlayerController.swift */,
|
||||
);
|
||||
path = Playback;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -338,7 +341,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
|
@ -353,7 +356,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks"
|
||||
);
|
||||
MARKETING_VERSION = 0.5.36;
|
||||
MARKETING_VERSION = 0.5.39;
|
||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -371,7 +374,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
|
@ -386,7 +389,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks"
|
||||
);
|
||||
MARKETING_VERSION = 0.5.36;
|
||||
MARKETING_VERSION = 0.5.39;
|
||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
|
|||
417
apps/ios/VMC/Playback/AVLivePlayerController.swift
Normal file
417
apps/ios/VMC/Playback/AVLivePlayerController.swift
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
import AVFoundation
|
||||
import AVKit
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct AVMediaOptionItem: Identifiable, Hashable {
|
||||
let id: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// Native AVPlayer voor ClearKey-live via lokale clear-HLS proxy (on-device decrypt).
|
||||
@MainActor
|
||||
final class AVLivePlayerController: NSObject, ObservableObject {
|
||||
@Published var isPlaying = false
|
||||
@Published var stateText = "Laden…"
|
||||
@Published var hasError = false
|
||||
@Published var errorMessage: String?
|
||||
/// True als AVPlayer minstens één video-track / presentatiegrootte heeft.
|
||||
@Published var hasVideo = false
|
||||
@Published var audioOptions: [AVMediaOptionItem] = []
|
||||
@Published var subtitleOptions: [AVMediaOptionItem] = []
|
||||
@Published var currentAudioId: String?
|
||||
@Published var currentSubtitleId: String?
|
||||
|
||||
let player = AVPlayer()
|
||||
private var proxySession = 0
|
||||
private var proxyActive = false
|
||||
private var timeControlObs: NSKeyValueObservation?
|
||||
private var itemStatusObs: NSKeyValueObservation?
|
||||
private var endObs: NSObjectProtocol?
|
||||
private var errorLogObs: NSObjectProtocol?
|
||||
private var stopping = false
|
||||
private var generation = 0
|
||||
private var audioGroup: AVMediaSelectionGroup?
|
||||
private var subtitleGroup: AVMediaSelectionGroup?
|
||||
private var audioOptionById: [String: AVMediaSelectionOption] = [:]
|
||||
private var subtitleOptionById: [String: AVMediaSelectionOption] = [:]
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
configureAudioSession()
|
||||
}
|
||||
|
||||
func play(
|
||||
url: URL,
|
||||
fallback: URL? = nil,
|
||||
format: String? = nil,
|
||||
clearKeys: [ClearKeyEntry] = []
|
||||
) async {
|
||||
generation &+= 1
|
||||
let gen = generation
|
||||
stopping = false
|
||||
hasError = false
|
||||
errorMessage = nil
|
||||
hasVideo = false
|
||||
audioOptions = []
|
||||
subtitleOptions = []
|
||||
currentAudioId = nil
|
||||
currentSubtitleId = nil
|
||||
audioGroup = nil
|
||||
subtitleGroup = nil
|
||||
audioOptionById = [:]
|
||||
subtitleOptionById = [:]
|
||||
stateText = "Live laden…"
|
||||
|
||||
stopProxyIfNeeded()
|
||||
player.pause()
|
||||
player.replaceCurrentItem(with: nil)
|
||||
|
||||
var playURL = url
|
||||
|
||||
if !clearKeys.isEmpty {
|
||||
do {
|
||||
stateText = "ClearKey laden…"
|
||||
let started = try await ClearKeyDashProxy.shared.start(
|
||||
manifestURL: url,
|
||||
keys: clearKeys,
|
||||
preferHlsForAVPlayer: true
|
||||
)
|
||||
guard gen == generation, !stopping else { return }
|
||||
playURL = started.url
|
||||
proxySession = started.session
|
||||
proxyActive = true
|
||||
proxyLogState("proxy ok \(playURL.absoluteString)")
|
||||
} catch {
|
||||
hasError = true
|
||||
errorMessage = "ClearKey-proxy: \(error.localizedDescription)"
|
||||
stateText = "Fout"
|
||||
return
|
||||
}
|
||||
} else {
|
||||
let fmt = (format ?? "").lowercased()
|
||||
let isHls = fmt == "hls" || ClearKeyDashProxy.isHlsURL(url)
|
||||
if !isHls, let fallback, ClearKeyDashProxy.isHlsURL(fallback) {
|
||||
playURL = fallback
|
||||
} else if !isHls {
|
||||
hasError = true
|
||||
errorMessage = "AVPlayer vereist HLS voor deze stream."
|
||||
stateText = "Fout"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard gen == generation, !stopping else { return }
|
||||
await startItem(url: playURL, gen: gen)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard !stopping else { return }
|
||||
stopping = true
|
||||
generation &+= 1
|
||||
clearObservers()
|
||||
player.pause()
|
||||
// Eerst item loskoppelen, daarna proxy — voorkomt crash op half-open sockets.
|
||||
player.replaceCurrentItem(with: nil)
|
||||
stopProxyIfNeeded()
|
||||
isPlaying = false
|
||||
hasVideo = false
|
||||
audioOptions = []
|
||||
subtitleOptions = []
|
||||
stateText = "Gestopt"
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
if player.timeControlStatus == .playing {
|
||||
player.pause()
|
||||
} else {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
func refreshMediaOptions() {
|
||||
guard let item = player.currentItem else { return }
|
||||
Task { await loadMediaOptions(for: item) }
|
||||
}
|
||||
|
||||
func selectAudio(id: String) {
|
||||
guard let item = player.currentItem, let group = audioGroup,
|
||||
let option = audioOptionById[id] else { return }
|
||||
item.select(option, in: group)
|
||||
currentAudioId = id
|
||||
}
|
||||
|
||||
func selectSubtitle(id: String?) {
|
||||
guard let item = player.currentItem, let group = subtitleGroup else { return }
|
||||
if let id, let option = subtitleOptionById[id] {
|
||||
item.select(option, in: group)
|
||||
currentSubtitleId = id
|
||||
} else {
|
||||
item.select(nil, in: group)
|
||||
currentSubtitleId = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func startItem(url: URL, gen: Int) async {
|
||||
stateText = "Openen…"
|
||||
let asset = AVURLAsset(url: url, options: [
|
||||
AVURLAssetAllowsCellularAccessKey: true,
|
||||
AVURLAssetAllowsExpensiveNetworkAccessKey: true,
|
||||
AVURLAssetAllowsConstrainedNetworkAccessKey: true,
|
||||
])
|
||||
let item = AVPlayerItem(asset: asset)
|
||||
item.preferredForwardBufferDuration = 8
|
||||
clearObservers()
|
||||
|
||||
itemStatusObs = item.observe(\.status, options: [.new, .initial]) { [weak self] item, _ in
|
||||
Task { @MainActor in
|
||||
guard let self, self.generation == gen, !self.stopping else { return }
|
||||
switch item.status {
|
||||
case .readyToPlay:
|
||||
self.stateText = "Afspelen"
|
||||
self.hasError = false
|
||||
self.refreshHasVideo(from: item)
|
||||
await self.loadMediaOptions(for: item)
|
||||
self.player.play()
|
||||
case .failed:
|
||||
self.fail(from: item.error, gen: gen)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timeControlObs = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
|
||||
Task { @MainActor in
|
||||
guard let self, self.generation == gen, !self.stopping else { return }
|
||||
switch player.timeControlStatus {
|
||||
case .playing:
|
||||
self.isPlaying = true
|
||||
self.stateText = "Afspelen"
|
||||
self.hasError = false
|
||||
if let item = player.currentItem {
|
||||
self.refreshHasVideo(from: item)
|
||||
}
|
||||
case .waitingToPlayAtSpecifiedRate:
|
||||
self.isPlaying = false
|
||||
self.stateText = "Bufferen…"
|
||||
case .paused:
|
||||
self.isPlaying = false
|
||||
if !self.hasError { self.stateText = "Pauze" }
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endObs = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemFailedToPlayToEndTime,
|
||||
object: item,
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
Task { @MainActor in
|
||||
guard let self, self.generation == gen, !self.stopping else { return }
|
||||
let err = note.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error
|
||||
self.fail(from: err, gen: gen)
|
||||
}
|
||||
}
|
||||
|
||||
errorLogObs = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemNewErrorLogEntry,
|
||||
object: item,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self, self.generation == gen else { return }
|
||||
if let events = item.errorLog()?.events, let last = events.last {
|
||||
let comment = last.errorComment ?? last.errorDomain
|
||||
self.proxyLogState("AV errorLog: \(last.errorStatusCode) \(comment ?? "")")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
player.replaceCurrentItem(with: item)
|
||||
player.automaticallyWaitsToMinimizeStalling = true
|
||||
player.play()
|
||||
|
||||
// Timeout alleen als er NOG NIETS speelt — nooit een werkende stream afbreken.
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 15_000_000_000)
|
||||
guard self.generation == gen, !self.stopping else { return }
|
||||
if let item = self.player.currentItem {
|
||||
self.refreshHasVideo(from: item)
|
||||
}
|
||||
if !self.isPlaying && !self.hasError {
|
||||
if let err = item.error {
|
||||
self.fail(from: err, gen: gen)
|
||||
} else {
|
||||
self.hasError = true
|
||||
self.errorMessage = self.bestErrorMessage(for: item)
|
||||
?? "Stream opende niet binnen 15s (ClearKey/HLS)."
|
||||
self.stateText = "Fout"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshHasVideo(from item: AVPlayerItem) {
|
||||
if item.presentationSize.height > 1 {
|
||||
hasVideo = true
|
||||
return
|
||||
}
|
||||
let video = item.tracks.contains { track in
|
||||
track.assetTrack?.mediaType == .video
|
||||
}
|
||||
hasVideo = video
|
||||
}
|
||||
|
||||
private func loadMediaOptions(for item: AVPlayerItem) async {
|
||||
let asset = item.asset
|
||||
do {
|
||||
let audible = try await asset.loadMediaSelectionGroup(for: .audible)
|
||||
audioGroup = audible
|
||||
if let audible {
|
||||
var opts: [AVMediaOptionItem] = []
|
||||
var map: [String: AVMediaSelectionOption] = [:]
|
||||
for (i, opt) in audible.options.enumerated() {
|
||||
let id = "a-\(i)-\(opt.displayName)"
|
||||
let name = opt.displayName.isEmpty ? "Audio \(i + 1)" : opt.displayName
|
||||
opts.append(AVMediaOptionItem(id: id, name: name))
|
||||
map[id] = opt
|
||||
}
|
||||
audioOptions = opts
|
||||
audioOptionById = map
|
||||
if let selected = item.selectedMediaOption(in: audible),
|
||||
let match = map.first(where: { $0.value == selected })?.key {
|
||||
currentAudioId = match
|
||||
} else {
|
||||
currentAudioId = opts.first?.id
|
||||
}
|
||||
} else {
|
||||
audioOptions = []
|
||||
audioOptionById = [:]
|
||||
}
|
||||
|
||||
let legible = try await asset.loadMediaSelectionGroup(for: .legible)
|
||||
subtitleGroup = legible
|
||||
if let legible {
|
||||
var opts: [AVMediaOptionItem] = [AVMediaOptionItem(id: "off", name: "Uit")]
|
||||
var map: [String: AVMediaSelectionOption] = [:]
|
||||
for (i, opt) in legible.options.enumerated() where !opt.hasMediaCharacteristic(.containsOnlyForcedSubtitles) {
|
||||
let id = "s-\(i)-\(opt.displayName)"
|
||||
let name = opt.displayName.isEmpty ? "Ondertitel \(i + 1)" : opt.displayName
|
||||
opts.append(AVMediaOptionItem(id: id, name: name))
|
||||
map[id] = opt
|
||||
}
|
||||
subtitleOptions = opts
|
||||
subtitleOptionById = map
|
||||
if let selected = item.selectedMediaOption(in: legible),
|
||||
let match = map.first(where: { $0.value == selected })?.key {
|
||||
currentSubtitleId = match
|
||||
} else {
|
||||
currentSubtitleId = "off"
|
||||
}
|
||||
} else {
|
||||
subtitleOptions = [AVMediaOptionItem(id: "off", name: "Uit")]
|
||||
subtitleOptionById = [:]
|
||||
currentSubtitleId = "off"
|
||||
}
|
||||
} catch {
|
||||
proxyLogState("media options: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func fail(from error: Error?, gen: Int) {
|
||||
guard generation == gen, !stopping else { return }
|
||||
hasError = true
|
||||
if let error {
|
||||
errorMessage = dutchAVError(error)
|
||||
} else if let item = player.currentItem {
|
||||
errorMessage = bestErrorMessage(for: item) ?? "Kon stream niet openen"
|
||||
} else {
|
||||
errorMessage = "Kon stream niet openen"
|
||||
}
|
||||
stateText = "Fout"
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
private func bestErrorMessage(for item: AVPlayerItem) -> String? {
|
||||
if let err = item.error { return dutchAVError(err) }
|
||||
if let events = item.errorLog()?.events, let last = events.last {
|
||||
let code = last.errorStatusCode
|
||||
let comment = last.errorComment ?? last.errorDomain
|
||||
return "Afspeelfout (\(code)): \(comment)"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func dutchAVError(_ error: Error) -> String {
|
||||
let ns = error as NSError
|
||||
if ns.domain == "CoreMediaErrorDomain" {
|
||||
return "Mediafout (\(ns.code)). Vaak ongeldige HLS of decrypt. Probeer opnieuw."
|
||||
}
|
||||
return error.localizedDescription
|
||||
}
|
||||
|
||||
private func stopProxyIfNeeded() {
|
||||
if proxyActive {
|
||||
let session = proxySession
|
||||
proxyActive = false
|
||||
proxySession = 0
|
||||
ClearKeyDashProxy.shared.stop(session: session)
|
||||
}
|
||||
}
|
||||
|
||||
private func clearObservers() {
|
||||
timeControlObs?.invalidate()
|
||||
timeControlObs = nil
|
||||
itemStatusObs?.invalidate()
|
||||
itemStatusObs = nil
|
||||
if let endObs {
|
||||
NotificationCenter.default.removeObserver(endObs)
|
||||
self.endObs = nil
|
||||
}
|
||||
if let errorLogObs {
|
||||
NotificationCenter.default.removeObserver(errorLogObs)
|
||||
self.errorLogObs = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func configureAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(.playback, mode: .moviePlayback, options: [])
|
||||
try session.setActive(true)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private func proxyLogState(_ msg: String) {
|
||||
#if DEBUG
|
||||
print("[AVLive] \(msg)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct AVLivePlayerView: UIViewControllerRepresentable {
|
||||
let controller: AVLivePlayerController
|
||||
|
||||
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
||||
let vc = AVPlayerViewController()
|
||||
vc.player = controller.player
|
||||
vc.showsPlaybackControls = false
|
||||
vc.videoGravity = .resizeAspect
|
||||
vc.allowsPictureInPicturePlayback = true
|
||||
if #available(iOS 14.2, *) {
|
||||
vc.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
}
|
||||
return vc
|
||||
}
|
||||
|
||||
func updateUIViewController(_ vc: AVPlayerViewController, context: Context) {
|
||||
if vc.player !== controller.player {
|
||||
vc.player = controller.player
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -69,7 +69,9 @@ struct PlayerScreen: View {
|
|||
|
||||
let request: PlayRequest
|
||||
|
||||
@StateObject private var player = VLCPlayerController()
|
||||
/// ClearKey live → AVPlayer + lokale decrypt-HLS. FTA live + VOD → VLC (PiP intact).
|
||||
@StateObject private var vlc = VLCPlayerController()
|
||||
@StateObject private var av = AVLivePlayerController()
|
||||
@State private var controlsVisible = true
|
||||
@State private var loading = true
|
||||
@State private var error: String?
|
||||
|
|
@ -81,23 +83,48 @@ struct PlayerScreen: View {
|
|||
@State private var subtitleHits: [SubtitleHit] = []
|
||||
@State private var subsLoading = false
|
||||
@State private var subsError: String?
|
||||
@State private var pendingStop: Task<Void, Never>?
|
||||
|
||||
private var isLive: Bool { request.isLive }
|
||||
private var useClearKeyAV: Bool { request.isLive && request.needsClearKey }
|
||||
|
||||
private var stateText: String { useClearKeyAV ? av.stateText : vlc.stateText }
|
||||
private var isPlaying: Bool { useClearKeyAV ? av.isPlaying : vlc.isPlaying }
|
||||
private var playerHasError: Bool { useClearKeyAV ? av.hasError : vlc.hasError }
|
||||
private var playerErrorMessage: String? { useClearKeyAV ? av.errorMessage : vlc.errorMessage }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
VLCVideoView(controller: player)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// VLC eet touches op — vang tikken altijd zelf af.
|
||||
// Speler eet touches op — vang tikken altijd zelf af.
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture { toggleControls() }
|
||||
.allowsHitTesting(!controlsVisible)
|
||||
|
||||
if loading && !player.isPlaying {
|
||||
ProgressView(player.stateText)
|
||||
if loading && !isPlaying {
|
||||
ProgressView(stateText)
|
||||
.tint(.white)
|
||||
.foregroundStyle(.white)
|
||||
.allowsHitTesting(false)
|
||||
|
|
@ -105,8 +132,8 @@ struct PlayerScreen: View {
|
|||
|
||||
if let error {
|
||||
errorOverlay(error)
|
||||
} else if player.hasError {
|
||||
errorOverlay(player.errorMessage ?? "Afspeelfout")
|
||||
} else if playerHasError {
|
||||
errorOverlay(playerErrorMessage ?? "Afspeelfout")
|
||||
}
|
||||
|
||||
if controlsVisible {
|
||||
|
|
@ -116,7 +143,11 @@ struct PlayerScreen: View {
|
|||
}
|
||||
.statusBarHidden(true)
|
||||
.persistentSystemOverlays(.hidden)
|
||||
.onAppear { app.enterPlayback() }
|
||||
.onAppear {
|
||||
pendingStop?.cancel()
|
||||
pendingStop = nil
|
||||
app.enterPlayback()
|
||||
}
|
||||
.task {
|
||||
await start()
|
||||
scheduleHide()
|
||||
|
|
@ -127,8 +158,19 @@ struct PlayerScreen: View {
|
|||
hideTask?.cancel()
|
||||
progressTask?.cancel()
|
||||
heartbeatTask?.cancel()
|
||||
Task { await saveProgress() }
|
||||
player.stop()
|
||||
if !isLive {
|
||||
let pos = vlc.currentPositionMs()
|
||||
let dur = vlc.durationMs()
|
||||
Task { await saveProgress(positionMs: pos, durationMs: dur) }
|
||||
}
|
||||
pendingStop?.cancel()
|
||||
pendingStop = Task {
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
if useClearKeyAV { av.stop() } else { vlc.stop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showTracks) {
|
||||
tracksSheet
|
||||
|
|
@ -154,17 +196,17 @@ struct PlayerScreen: View {
|
|||
.font(McFonts.headline)
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
Text(player.stateText)
|
||||
Text(stateText)
|
||||
.font(McFonts.caption)
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
}
|
||||
Spacer()
|
||||
if player.pipPossible {
|
||||
if !useClearKeyAV && vlc.pipPossible {
|
||||
Button {
|
||||
player.togglePictureInPicture()
|
||||
vlc.togglePictureInPicture()
|
||||
scheduleHide()
|
||||
} label: {
|
||||
Image(systemName: player.pipActive
|
||||
Image(systemName: vlc.pipActive
|
||||
? "pip.exit"
|
||||
: "pip.enter")
|
||||
.font(.title3)
|
||||
|
|
@ -198,9 +240,9 @@ struct PlayerScreen: View {
|
|||
if !request.isLive {
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(player.position) },
|
||||
get: { Double(vlc.position) },
|
||||
set: {
|
||||
player.seek(ratio: Float($0))
|
||||
vlc.seek(ratio: Float($0))
|
||||
scheduleHide()
|
||||
}
|
||||
),
|
||||
|
|
@ -209,33 +251,33 @@ struct PlayerScreen: View {
|
|||
.tint(McColors.accent)
|
||||
|
||||
HStack {
|
||||
Text(player.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
|
||||
Text(vlc.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
|
||||
Spacer()
|
||||
Text(player.lengthText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
|
||||
Text(vlc.lengthText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 36) {
|
||||
if !request.isLive {
|
||||
Button {
|
||||
player.seek(toMs: max(player.currentPositionMs() - 10_000, 0))
|
||||
vlc.seek(toMs: max(vlc.currentPositionMs() - 10_000, 0))
|
||||
scheduleHide()
|
||||
} label: {
|
||||
Image(systemName: "gobackward.10").font(.title).foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
Button {
|
||||
player.togglePlay()
|
||||
if useClearKeyAV { av.togglePlay() } else { vlc.togglePlay() }
|
||||
scheduleHide()
|
||||
} label: {
|
||||
Image(systemName: player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.white)
|
||||
.shadow(radius: 8)
|
||||
}
|
||||
if !request.isLive {
|
||||
Button {
|
||||
player.seek(toMs: player.currentPositionMs() + 10_000)
|
||||
vlc.seek(toMs: vlc.currentPositionMs() + 10_000)
|
||||
scheduleHide()
|
||||
} label: {
|
||||
Image(systemName: "goforward.10").font(.title).foregroundStyle(.white)
|
||||
|
|
@ -263,16 +305,16 @@ struct PlayerScreen: View {
|
|||
|
||||
if trackTab == 0 {
|
||||
List {
|
||||
ForEach(player.subtitleTracks) { track in
|
||||
ForEach(vlc.subtitleTracks) { track in
|
||||
Button {
|
||||
player.selectSubtitle(id: track.id)
|
||||
vlc.selectSubtitle(id: track.id)
|
||||
showTracks = false
|
||||
scheduleHide()
|
||||
} label: {
|
||||
HStack {
|
||||
Text(track.name).foregroundStyle(McColors.text)
|
||||
Spacer()
|
||||
if track.id == player.currentSubtitleId {
|
||||
if track.id == vlc.currentSubtitleId {
|
||||
Image(systemName: "checkmark").foregroundStyle(McColors.accent)
|
||||
}
|
||||
}
|
||||
|
|
@ -315,16 +357,16 @@ struct PlayerScreen: View {
|
|||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
List {
|
||||
ForEach(player.audioTracks) { track in
|
||||
ForEach(vlc.audioTracks) { track in
|
||||
Button {
|
||||
player.selectAudio(id: track.id)
|
||||
vlc.selectAudio(id: track.id)
|
||||
showTracks = false
|
||||
scheduleHide()
|
||||
} label: {
|
||||
HStack {
|
||||
Text(track.name).foregroundStyle(McColors.text)
|
||||
Spacer()
|
||||
if track.id == player.currentAudioId {
|
||||
if track.id == vlc.currentAudioId {
|
||||
Image(systemName: "checkmark").foregroundStyle(McColors.accent)
|
||||
}
|
||||
}
|
||||
|
|
@ -343,7 +385,7 @@ struct PlayerScreen: View {
|
|||
Button("Sluiten") { showTracks = false }
|
||||
}
|
||||
}
|
||||
.onAppear { player.refreshTracks() }
|
||||
.onAppear { vlc.refreshTracks() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -399,32 +441,51 @@ struct PlayerScreen: View {
|
|||
private func start() async {
|
||||
loading = true
|
||||
error = nil
|
||||
if request.isLive {
|
||||
let keys = request.needsClearKey ? (request.drm?.keys ?? []) : []
|
||||
await player.playLive(
|
||||
if useClearKeyAV {
|
||||
let keys = request.drm?.keys ?? []
|
||||
await av.play(
|
||||
url: request.url,
|
||||
fallback: request.fallbackUrl,
|
||||
format: request.format,
|
||||
clearKeys: keys
|
||||
)
|
||||
// ClearKey/DASH: proxy-warmup + init/media + eventuele retry.
|
||||
let waitNs: UInt64 = request.needsClearKey ? 12_000_000_000 : 2_500_000_000
|
||||
try? await Task.sleep(nanoseconds: waitNs)
|
||||
if request.needsClearKey && (player.hasError || !player.isPlaying) {
|
||||
try? await Task.sleep(nanoseconds: 6_000_000_000)
|
||||
if player.hasError || !player.isPlaying {
|
||||
error = player.errorMessage
|
||||
?? "Kon deze ClearKey-livezender niet starten. Het DASH-manifest of de sleutels zijn mogelijk ongeldig."
|
||||
let maxWaitNs: UInt64 = 18_000_000_000
|
||||
let stepNs: UInt64 = 200_000_000
|
||||
var waited: UInt64 = 0
|
||||
while waited < maxWaitNs && !av.hasError {
|
||||
if av.isPlaying { break }
|
||||
try? await Task.sleep(nanoseconds: stepNs)
|
||||
waited += stepNs
|
||||
}
|
||||
if !av.isPlaying || av.hasError {
|
||||
error = av.errorMessage
|
||||
?? "Kon deze ClearKey-livestream niet starten."
|
||||
}
|
||||
} else if isLive {
|
||||
await vlc.playLive(
|
||||
url: request.url,
|
||||
fallback: request.fallbackUrl,
|
||||
format: request.format,
|
||||
clearKeys: []
|
||||
)
|
||||
let maxWaitNs: UInt64 = 4_000_000_000
|
||||
let stepNs: UInt64 = 200_000_000
|
||||
var waited: UInt64 = 0
|
||||
while waited < maxWaitNs && !vlc.isPlaying && !vlc.hasError {
|
||||
try? await Task.sleep(nanoseconds: stepNs)
|
||||
waited += stepNs
|
||||
}
|
||||
if vlc.hasError {
|
||||
error = vlc.errorMessage ?? "Kon deze livestream niet starten."
|
||||
}
|
||||
} else {
|
||||
player.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL)
|
||||
vlc.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL)
|
||||
progressTask = Task { await progressLoop() }
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
loading = false
|
||||
if !player.hasError {
|
||||
player.refreshTracks()
|
||||
if !useClearKeyAV && !vlc.hasError {
|
||||
vlc.refreshTracks()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -450,10 +511,10 @@ struct PlayerScreen: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func saveProgress() async {
|
||||
private func saveProgress(positionMs: Int64? = nil, durationMs: Int64? = nil) async {
|
||||
guard !request.isLive else { return }
|
||||
let pos = player.currentPositionMs()
|
||||
let dur = player.durationMs()
|
||||
let pos = positionMs ?? vlc.currentPositionMs()
|
||||
let dur = durationMs ?? vlc.durationMs()
|
||||
guard pos > 0 else { return }
|
||||
try? await app.api.postProgress(
|
||||
mediaType: request.mediaType,
|
||||
|
|
@ -492,13 +553,25 @@ struct PlayerScreen: View {
|
|||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("vmc-sub-\(hit.fileId).\(ext)")
|
||||
try data.write(to: url, options: .atomic)
|
||||
player.addExternalSubtitle(url: url)
|
||||
vlc.addExternalSubtitle(url: url)
|
||||
showTracks = false
|
||||
scheduleHide()
|
||||
} catch {
|
||||
subsError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private static func fittedVideoSize(in container: CGSize, aspect: CGFloat) -> CGSize {
|
||||
guard container.width > 0, container.height > 0, aspect > 0 else { return container }
|
||||
let containerAspect = container.width / container.height
|
||||
if containerAspect > aspect {
|
||||
let h = container.height
|
||||
return CGSize(width: h * aspect, height: h)
|
||||
} else {
|
||||
let w = container.width
|
||||
return CGSize(width: w, height: w / aspect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayLauncher: View {
|
||||
|
|
|
|||
|
|
@ -216,7 +216,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
|||
stateText = "ClearKey laden…"
|
||||
// Async: blokkeer de main thread niet (watchdog-crash).
|
||||
// HLS (ESPN) én DASH (Prime) via dezelfde proxy.
|
||||
playURL = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
|
||||
let started = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
|
||||
playURL = started.url
|
||||
playFormat = ClearKeyDashProxy.isHlsURL(url) ? "hls" : "dash"
|
||||
clearKeyProxyActive = true
|
||||
} catch {
|
||||
|
|
@ -820,7 +821,6 @@ struct VLCVideoView: UIViewRepresentable {
|
|||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {
|
||||
// Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout).
|
||||
controller.attach(to: uiView)
|
||||
// Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout / audio-only).
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,9 @@ struct BrowseScreen: View {
|
|||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.task {
|
||||
if catalogId.isEmpty { catalogId = defaultCatalog }
|
||||
// NavigationStack re-runs .task after detail pop; don't wipe the grid
|
||||
// or scroll position (same pattern as HomeScreen).
|
||||
guard items.isEmpty else { return }
|
||||
await bootstrap()
|
||||
}
|
||||
.refreshable { await reload(reset: true) }
|
||||
|
|
@ -271,6 +274,7 @@ struct BrowseScreen: View {
|
|||
if reset {
|
||||
loading = true
|
||||
error = nil
|
||||
loadingMore = false
|
||||
}
|
||||
let id = catalogId.isEmpty ? defaultCatalog : catalogId
|
||||
let sortParam: String? = showSortTools ? sort.rawValue : nil
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "drm_keys" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kid" TEXT NOT NULL,
|
||||
"key_enc" TEXT NOT NULL,
|
||||
"key_hash" TEXT NOT NULL,
|
||||
"mpd_url" TEXT,
|
||||
"channel_name" TEXT,
|
||||
"event_name" TEXT,
|
||||
"source" TEXT NOT NULL DEFAULT 'manual',
|
||||
"external_id" TEXT,
|
||||
"media_guid" TEXT,
|
||||
"last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "drm_keys_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "drm_keys_kid_key" ON "drm_keys"("kid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "drm_keys_key_hash_idx" ON "drm_keys"("key_hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "drm_keys_last_seen_at_idx" ON "drm_keys"("last_seen_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "drm_keys_source_idx" ON "drm_keys"("source");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "drm_keys_channel_name_idx" ON "drm_keys"("channel_name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "drm_keys_event_name_idx" ON "drm_keys"("event_name");
|
||||
|
|
@ -790,6 +790,37 @@ model LibrarySubtitle {
|
|||
@@map("library_subtitles")
|
||||
}
|
||||
|
||||
/**
|
||||
* Globale ClearKey/Widevine-key cache (admin DRM-pagina).
|
||||
* KID plaintext (staat vaak in MPD); KEY encrypted. Uniek op genormaliseerde KID.
|
||||
*/
|
||||
model DrmKey {
|
||||
id String @id @default(uuid())
|
||||
/** Genormaliseerde KID hex (zonder dashes), uniek */
|
||||
kid String @unique
|
||||
/** AES-GCM ciphertext van de KEY hex */
|
||||
keyEnc String @map("key_enc") @db.Text
|
||||
/** SHA-256 van genormaliseerde KEY hex — exacte key-search zonder decrypt */
|
||||
keyHash String @map("key_hash")
|
||||
mpdUrl String? @map("mpd_url") @db.Text
|
||||
channelName String? @map("channel_name")
|
||||
eventName String? @map("event_name")
|
||||
/** odido | viaplay | manual | event | … */
|
||||
source String @default("manual")
|
||||
externalId String? @map("external_id")
|
||||
mediaGuid String? @map("media_guid")
|
||||
lastSeenAt DateTime @default(now()) @map("last_seen_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([keyHash])
|
||||
@@index([lastSeenAt])
|
||||
@@index([source])
|
||||
@@index([channelName])
|
||||
@@index([eventName])
|
||||
@@map("drm_keys")
|
||||
}
|
||||
|
||||
/** Audit trail voor Google Play group-lidmaatschap (geen secrets). */
|
||||
model GooglePlayAccessAuditLog {
|
||||
id String @id @default(uuid())
|
||||
|
|
|
|||
|
|
@ -1810,6 +1810,95 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
return syncF1tvCatalog(config.SESSION_SECRET);
|
||||
});
|
||||
|
||||
// --- DRM key database ---
|
||||
app.get("/api/v1/admin/drm-keys", { preHandler: requireAdmin }, async (request) => {
|
||||
const q = request.query as { search?: string; source?: string; limit?: string };
|
||||
const { searchDrmKeys } = await import("../drm/keys");
|
||||
return searchDrmKeys(prisma, {
|
||||
search: q.search,
|
||||
source: q.source,
|
||||
limit: q.limit ? Number(q.limit) : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/v1/admin/drm-keys/:id/reveal", { preHandler: requireAdmin }, async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { revealDrmKey } = await import("../drm/keys");
|
||||
try {
|
||||
return await revealDrmKey(prisma, config.SESSION_SECRET, id);
|
||||
} catch {
|
||||
throw new AppError("NOT_FOUND", "DRM-key niet gevonden", 404);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/v1/admin/drm-keys/:id", { preHandler: requireAdmin }, async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as {
|
||||
channelName?: string | null;
|
||||
eventName?: string | null;
|
||||
mpdUrl?: string | null;
|
||||
source?: string;
|
||||
};
|
||||
const { updateDrmKeyMeta } = await import("../drm/keys");
|
||||
try {
|
||||
return { key: await updateDrmKeyMeta(prisma, id, body) };
|
||||
} catch {
|
||||
throw new AppError("NOT_FOUND", "DRM-key niet gevonden", 404);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/v1/admin/drm-keys/:id", { preHandler: requireAdmin }, async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { deleteDrmKey } = await import("../drm/keys");
|
||||
try {
|
||||
await deleteDrmKey(prisma, id);
|
||||
return { ok: true };
|
||||
} catch {
|
||||
throw new AppError("NOT_FOUND", "DRM-key niet gevonden", 404);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/v1/admin/drm-keys/backfill", { preHandler: requireAdmin }, async () => {
|
||||
const { backfillDrmKeysFromLiveChannels } = await import("../drm/keys");
|
||||
return backfillDrmKeysFromLiveChannels(prisma, config.SESSION_SECRET);
|
||||
});
|
||||
|
||||
app.post("/api/v1/admin/drm-keys", { preHandler: requireAdmin }, async (request) => {
|
||||
const body = request.body as {
|
||||
kid?: string;
|
||||
key?: string;
|
||||
mpdUrl?: string;
|
||||
channelName?: string;
|
||||
eventName?: string;
|
||||
source?: string;
|
||||
externalId?: string;
|
||||
mediaGuid?: string;
|
||||
};
|
||||
if (!body.kid?.trim() || !body.key?.trim()) {
|
||||
throw new AppError("INVALID_REQUEST", "kid en key verplicht", 400);
|
||||
}
|
||||
const { upsertDrmKey } = await import("../drm/keys");
|
||||
try {
|
||||
const result = await upsertDrmKey(prisma, config.SESSION_SECRET, {
|
||||
kid: body.kid,
|
||||
key: body.key,
|
||||
mpdUrl: body.mpdUrl,
|
||||
channelName: body.channelName,
|
||||
eventName: body.eventName,
|
||||
source: body.source || "manual",
|
||||
externalId: body.externalId,
|
||||
mediaGuid: body.mediaGuid,
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
throw new AppError(
|
||||
"INVALID_REQUEST",
|
||||
e instanceof Error ? e.message : "Ongeldige key",
|
||||
400
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Custom ClearKey/DASH live channel lists ---
|
||||
app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => {
|
||||
const { listLiveChannelListsAdmin } = await import("../viewer/live-channels");
|
||||
|
|
|
|||
294
apps/master-api/src/drm/keys.ts
Normal file
294
apps/master-api/src/drm/keys.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import { createHash } from "crypto";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { decryptSecret, encryptSecret } from "../security/crypto";
|
||||
|
||||
export type DrmKeyUpsertInput = {
|
||||
kid: string;
|
||||
key: string;
|
||||
mpdUrl?: string | null;
|
||||
channelName?: string | null;
|
||||
eventName?: string | null;
|
||||
source?: string;
|
||||
externalId?: string | null;
|
||||
mediaGuid?: string | null;
|
||||
};
|
||||
|
||||
export type DrmKeyPublic = {
|
||||
id: string;
|
||||
kid: string;
|
||||
keyMasked: string;
|
||||
hasKey: boolean;
|
||||
mpdUrl: string | null;
|
||||
channelName: string | null;
|
||||
eventName: string | null;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
mediaGuid: string | null;
|
||||
lastSeenAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function normalizeHex(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[^0-9a-f]/g, "");
|
||||
}
|
||||
|
||||
export function normalizeKid(kid: string): string {
|
||||
return normalizeHex(kid);
|
||||
}
|
||||
|
||||
export function normalizeKey(key: string): string {
|
||||
return normalizeHex(key);
|
||||
}
|
||||
|
||||
export function hashKey(key: string): string {
|
||||
return createHash("sha256").update(normalizeKey(key), "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function maskKey(key: string | null | undefined): string {
|
||||
if (!key) return "";
|
||||
if (key.length <= 8) return "********";
|
||||
return `${key.slice(0, 4)}…${key.slice(-4)}`;
|
||||
}
|
||||
|
||||
function toPublic(row: {
|
||||
id: string;
|
||||
kid: string;
|
||||
keyEnc: string;
|
||||
mpdUrl: string | null;
|
||||
channelName: string | null;
|
||||
eventName: string | null;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
mediaGuid: string | null;
|
||||
lastSeenAt: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): DrmKeyPublic {
|
||||
return {
|
||||
id: row.id,
|
||||
kid: row.kid,
|
||||
keyMasked: "********",
|
||||
hasKey: Boolean(row.keyEnc),
|
||||
mpdUrl: row.mpdUrl,
|
||||
channelName: row.channelName,
|
||||
eventName: row.eventName,
|
||||
source: row.source,
|
||||
externalId: row.externalId,
|
||||
mediaGuid: row.mediaGuid,
|
||||
lastSeenAt: row.lastSeenAt.toISOString(),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Upsert by KID; refreshes lastSeen + metadata. Never downgrades an existing key. */
|
||||
export async function upsertDrmKey(
|
||||
prisma: PrismaClient,
|
||||
sessionSecret: string,
|
||||
input: DrmKeyUpsertInput
|
||||
): Promise<{ id: string; kid: string; created: boolean }> {
|
||||
const kid = normalizeKid(input.kid);
|
||||
const key = normalizeKey(input.key);
|
||||
if (kid.length < 16 || key.length < 16) {
|
||||
throw new Error("Ongeldige kid/key");
|
||||
}
|
||||
const keyEnc = encryptSecret(key, sessionSecret);
|
||||
const keyHash = hashKey(key);
|
||||
const now = new Date();
|
||||
const existing = await prisma.drmKey.findUnique({ where: { kid } });
|
||||
if (existing) {
|
||||
await prisma.drmKey.update({
|
||||
where: { kid },
|
||||
data: {
|
||||
keyEnc,
|
||||
keyHash,
|
||||
lastSeenAt: now,
|
||||
...(input.mpdUrl != null && input.mpdUrl !== ""
|
||||
? { mpdUrl: input.mpdUrl }
|
||||
: {}),
|
||||
...(input.channelName != null && input.channelName !== ""
|
||||
? { channelName: input.channelName }
|
||||
: {}),
|
||||
...(input.eventName != null && input.eventName !== ""
|
||||
? { eventName: input.eventName }
|
||||
: {}),
|
||||
...(input.source ? { source: input.source } : {}),
|
||||
...(input.externalId != null ? { externalId: input.externalId } : {}),
|
||||
...(input.mediaGuid != null ? { mediaGuid: input.mediaGuid } : {}),
|
||||
},
|
||||
});
|
||||
return { id: existing.id, kid, created: false };
|
||||
}
|
||||
const created = await prisma.drmKey.create({
|
||||
data: {
|
||||
kid,
|
||||
keyEnc,
|
||||
keyHash,
|
||||
mpdUrl: input.mpdUrl?.trim() || null,
|
||||
channelName: input.channelName?.trim() || null,
|
||||
eventName: input.eventName?.trim() || null,
|
||||
source: input.source?.trim() || "manual",
|
||||
externalId: input.externalId?.trim() || null,
|
||||
mediaGuid: input.mediaGuid?.trim() || null,
|
||||
lastSeenAt: now,
|
||||
},
|
||||
});
|
||||
return { id: created.id, kid, created: true };
|
||||
}
|
||||
|
||||
export async function lookupDrmKeyByKid(
|
||||
prisma: PrismaClient,
|
||||
sessionSecret: string,
|
||||
kidRaw: string
|
||||
): Promise<{ kid: string; key: string; mpdUrl: string | null } | null> {
|
||||
const kid = normalizeKid(kidRaw);
|
||||
if (!kid) return null;
|
||||
const row = await prisma.drmKey.findUnique({ where: { kid } });
|
||||
if (!row) return null;
|
||||
return {
|
||||
kid: row.kid,
|
||||
key: decryptSecret(row.keyEnc, sessionSecret),
|
||||
mpdUrl: row.mpdUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchDrmKeys(
|
||||
prisma: PrismaClient,
|
||||
opts: { search?: string; source?: string; limit?: number }
|
||||
): Promise<{ keys: DrmKeyPublic[]; total: number; requiresQuery: boolean }> {
|
||||
const limit = Math.min(Math.max(opts.limit ?? 100, 1), 500);
|
||||
const q = (opts.search || "").trim();
|
||||
const source = opts.source?.trim() || undefined;
|
||||
|
||||
if (q.length > 0 && q.length < 2) {
|
||||
return { keys: [], total: 0, requiresQuery: true };
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (source) where.source = source;
|
||||
|
||||
if (q.length >= 2) {
|
||||
const hex = normalizeHex(q);
|
||||
const or: Record<string, unknown>[] = [
|
||||
{ kid: { contains: hex.length >= 2 ? hex : q, mode: "insensitive" } },
|
||||
{ mpdUrl: { contains: q, mode: "insensitive" } },
|
||||
{ channelName: { contains: q, mode: "insensitive" } },
|
||||
{ eventName: { contains: q, mode: "insensitive" } },
|
||||
{ externalId: { contains: q, mode: "insensitive" } },
|
||||
{ mediaGuid: { contains: q, mode: "insensitive" } },
|
||||
{ source: { contains: q, mode: "insensitive" } },
|
||||
];
|
||||
// Exact key search via hash when query looks like a key (32+ hex)
|
||||
if (hex.length >= 32) {
|
||||
or.push({ keyHash: hashKey(hex) });
|
||||
or.push({ kid: hex });
|
||||
}
|
||||
where.OR = or;
|
||||
}
|
||||
|
||||
const [total, rows] = await Promise.all([
|
||||
prisma.drmKey.count({ where }),
|
||||
prisma.drmKey.findMany({
|
||||
where,
|
||||
orderBy: { lastSeenAt: "desc" },
|
||||
take: limit,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
keys: rows.map(toPublic),
|
||||
total,
|
||||
requiresQuery: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function revealDrmKey(
|
||||
prisma: PrismaClient,
|
||||
sessionSecret: string,
|
||||
id: string
|
||||
): Promise<{ id: string; kid: string; key: string; mpdUrl: string | null }> {
|
||||
const row = await prisma.drmKey.findUnique({ where: { id } });
|
||||
if (!row) throw new Error("DRM-key niet gevonden");
|
||||
return {
|
||||
id: row.id,
|
||||
kid: row.kid,
|
||||
key: decryptSecret(row.keyEnc, sessionSecret),
|
||||
mpdUrl: row.mpdUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateDrmKeyMeta(
|
||||
prisma: PrismaClient,
|
||||
id: string,
|
||||
patch: {
|
||||
channelName?: string | null;
|
||||
eventName?: string | null;
|
||||
mpdUrl?: string | null;
|
||||
source?: string;
|
||||
}
|
||||
): Promise<DrmKeyPublic> {
|
||||
const row = await prisma.drmKey.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(patch.channelName !== undefined
|
||||
? { channelName: patch.channelName?.trim() || null }
|
||||
: {}),
|
||||
...(patch.eventName !== undefined
|
||||
? { eventName: patch.eventName?.trim() || null }
|
||||
: {}),
|
||||
...(patch.mpdUrl !== undefined ? { mpdUrl: patch.mpdUrl?.trim() || null } : {}),
|
||||
...(patch.source !== undefined ? { source: patch.source.trim() || "manual" } : {}),
|
||||
},
|
||||
});
|
||||
return toPublic(row);
|
||||
}
|
||||
|
||||
export async function deleteDrmKey(prisma: PrismaClient, id: string): Promise<void> {
|
||||
await prisma.drmKey.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/** One-time / on-demand: import keys from live_channels into drm_keys. */
|
||||
export async function backfillDrmKeysFromLiveChannels(
|
||||
prisma: PrismaClient,
|
||||
sessionSecret: string
|
||||
): Promise<{ imported: number; skipped: number; failed: number }> {
|
||||
const channels = await prisma.liveChannel.findMany({
|
||||
where: { kidEnc: { not: null }, keyEnc: { not: null } },
|
||||
select: {
|
||||
name: true,
|
||||
mpdUrl: true,
|
||||
streamUrlCache: true,
|
||||
kidEnc: true,
|
||||
keyEnc: true,
|
||||
externalId: true,
|
||||
list: { select: { provider: true } },
|
||||
},
|
||||
});
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
for (const ch of channels) {
|
||||
if (!ch.kidEnc || !ch.keyEnc) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const kid = decryptSecret(ch.kidEnc, sessionSecret);
|
||||
const key = decryptSecret(ch.keyEnc, sessionSecret);
|
||||
const result = await upsertDrmKey(prisma, sessionSecret, {
|
||||
kid,
|
||||
key,
|
||||
mpdUrl: ch.streamUrlCache || ch.mpdUrl,
|
||||
channelName: ch.name,
|
||||
source: ch.list.provider || "live-channel",
|
||||
externalId: ch.externalId,
|
||||
});
|
||||
if (result.created) imported += 1;
|
||||
else skipped += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return { imported, skipped, failed };
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
|
||||
export type OdidoChannelPlayState = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
externalId: string | null;
|
||||
mediaId: string | null;
|
||||
contentId: string | null;
|
||||
|
|
@ -74,7 +75,8 @@ async function fetchKeysIfNeeded(
|
|||
sessionSecret: string,
|
||||
existingKid: string | null,
|
||||
existingKey: string | null,
|
||||
allowFetch: boolean
|
||||
allowFetch: boolean,
|
||||
meta?: { channelName?: string | null; externalId?: string | null }
|
||||
): Promise<{ kidEnc: string | null; keyEnc: string | null }> {
|
||||
if (existingKid && existingKey) return { kidEnc: existingKid, keyEnc: existingKey };
|
||||
if (!allowFetch || !play.licenseUrl || !play.licenseToken) {
|
||||
|
|
@ -92,6 +94,19 @@ async function fetchKeysIfNeeded(
|
|||
});
|
||||
const key = keys.map(parseKey).find((item) => item !== null);
|
||||
if (!key) throw new Error("Geen bruikbare ClearKey ontvangen");
|
||||
try {
|
||||
const { upsertDrmKey } = await import("../drm/keys");
|
||||
await upsertDrmKey(prisma, sessionSecret, {
|
||||
kid: key.kid,
|
||||
key: key.key,
|
||||
mpdUrl: play.playUrl,
|
||||
channelName: meta?.channelName,
|
||||
source: "odido",
|
||||
externalId: meta?.externalId,
|
||||
});
|
||||
} catch {
|
||||
/* DRM-db mag play niet breken */
|
||||
}
|
||||
return {
|
||||
kidEnc: encryptSecret(key.kid, sessionSecret),
|
||||
keyEnc: encryptSecret(key.key, sessionSecret),
|
||||
|
|
@ -159,7 +174,8 @@ export async function refreshChannelMpd(
|
|||
sessionSecret,
|
||||
channel.kidEnc,
|
||||
channel.keyEnc,
|
||||
opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay
|
||||
opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay,
|
||||
{ channelName: channel.name, externalId }
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
|
|
|
|||
|
|
@ -314,6 +314,24 @@ export async function syncOdidoList(
|
|||
if (!key) throw new Error("Geen bruikbare ClearKey ontvangen");
|
||||
kidEnc = encryptSecret(key.kid, sessionSecret);
|
||||
keyEnc = encryptSecret(key.key, sessionSecret);
|
||||
try {
|
||||
const { upsertDrmKey } = await import("../drm/keys");
|
||||
const chName =
|
||||
entry.channel.name?.trim() ||
|
||||
entry.channel.channelName?.trim() ||
|
||||
physical?.mediaName?.trim() ||
|
||||
externalId;
|
||||
await upsertDrmKey(prisma, sessionSecret, {
|
||||
kid: key.kid,
|
||||
key: key.key,
|
||||
mpdUrl: entry.playUrl,
|
||||
channelName: chName,
|
||||
source: "odido",
|
||||
externalId,
|
||||
});
|
||||
} catch {
|
||||
/* ignore drm db errors */
|
||||
}
|
||||
} catch {
|
||||
counts.failed += 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -446,6 +446,7 @@ export async function getCustomPlayUrl(
|
|||
const resolved = await resolveOdidoPlay(
|
||||
{
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
externalId,
|
||||
mediaId,
|
||||
contentId: channel.contentId,
|
||||
|
|
|
|||
Loading…
Reference in a new issue