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