Files
Flaro/web/app/src/spieler.ts
T
ScarriffleandClaude Opus 5 2f7768136b Der Tonversatz ist ein Regler, abgetrennt unter den Spuren
Drei Anläufe, und du hattest jedes Mal recht.

**Er gehört ins Tonmenü** — aber sichtbar nicht zu den Spuren. Jetzt
trennt ihn ein Strich ab, darüber steht „TONVERSATZ — Ohne Versatz" als
Überschrift, darunter ein Regler. Drei Mittel, und jedes einzelne würde
schon sagen, dass das keine Sprache ist.

**Ein Regler statt Auswahlpunkten.** Feste Stufen hiessen: für jeden Wert
ein Eintrag, und die standen zwischen den Sprachen wie weitere Sprachen.
Gezogen wird von −3 bis +3 Sekunden in Hundertern; die Mitte ist als
Strich markiert, sonst trifft man die Null nur zufällig. Gerechnet wird
erst beim Loslassen, nicht beim Ziehen — jede Änderung baut den Strom neu
auf, und das tut man nicht zwanzigmal beim Hinschieben. Daneben
„Zurücksetzen", sobald etwas eingestellt ist.

**Und der Lautsprecher ist zurück.** Hier stand ein eigenes Zeichen, damit
er nicht zweimal in der Leiste auftaucht — links Stummschalten, rechts die
Tonspur. Die Wellenform erkannte aber niemand als „Ton". Zwei Lautsprecher
sind das kleinere Übel, zumal der linke beim Stummschalten
durchgestrichen wird und damit anders aussieht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 20:42:50 +02:00

1036 lines
40 KiB
TypeScript

import type HlsTyp from 'hls.js';
import {
stromStarten, stromBeenden, untertitelAdresse, fortschritt,
type Stromauskunft, type Spurauskunft,
} from './api';
import { dauer, knopf, meldung } from './util';
import { icons, icon } from './icons';
/* ---------- Was der Browser kann ---------- */
/** Fragt den Browser, statt es zu raten.
*
* **Vorher bekam er Apples Geräteprofil**, und das erlaubt HEVC, AC3 und
* EAC3 — von denen Chrome keines öffnet. Der Server packte die Datei
* pflichtschuldig in eine MP4 um, liess Bild und Ton aber unberührt, und
* heraus kam eine tadellose Datei, mit der der Browser nichts anfangen
* konnte. Genau das war „der Player funktioniert nicht".
*
* `canPlayType` antwortet mit `"probably"`, `"maybe"` oder `""`. Alles ausser
* der leeren Zeichenkette zählt: `"maybe"` heisst „der Codec ist bekannt, über
* das Profil sage ich nichts".
*
* **Und es zählt nur, wenn der MSE-Puffer mitgeht.** Gemessen in Chromium:
* `canPlayType('audio/flac')` sagt „probably", `MediaSource.isTypeSupported`
* derselben Zeichenkette sagt `false`. Ein Strom läuft über den Puffer, eine
* durchgereichte Datei über das Element — und welcher der beiden Wege es
* wird, entscheidet der Server erst danach. Also muss beides gehen.
*/
export function geraeteprofil(): {
containers: string[]; videoCodecs: string[]; audioCodecs: string[];
} {
const v = document.createElement('video');
const hatPuffer = typeof MediaSource !== 'undefined'
&& typeof MediaSource.isTypeSupported === 'function';
/** Ein Typ zählt nur, wenn **beide** Auskünfte ihn bestätigen.
*
* **Gemessen, und es ist der Grund für „kann die Spuren nicht öffnen".**
* In Chromium sagt `canPlayType('audio/flac')` „probably", und
* `MediaSource.isTypeSupported` derselben Zeichenkette sagt `false`. Für
* MP3 in einer MP4 ebenso. Der Spieler weiss vorher nicht, auf welchem Weg
* die Datei kommt — als fertige Datei im `<video>`-Element oder als Strom
* durch den MSE-Puffer —, also muss sie auf beiden Wegen gehen.
*
* Ohne Puffer (Safari auf dem iPhone) gibt es nur den einen Weg, und dort
* stimmt `canPlayType` auch. */
const kann = (...typen: string[]): boolean => typen.some(t =>
v.canPlayType(t) !== '' && (!hatPuffer || MediaSource.isTypeSupported(t)));
const schachteln = ['mp4'];
if (kann('video/webm; codecs="vp8"', 'video/webm; codecs="vp9"')) schachteln.push('webm');
// **Jeder Typ in der Form, in der er wirklich ankommt.** Ein Codec steckt
// hier in einem Film, nicht in einer nackten Tondatei: also `audio/mp4` und
// nicht `audio/flac`. Genau dieser Unterschied entschied bei FLAC zwischen
// „geht" und „geht nicht".
const bild: string[] = [];
if (kann('video/mp4; codecs="avc1.640029"')) bild.push('h264', 'avc1');
if (kann('video/mp4; codecs="hvc1.1.6.L93.B0"', 'video/mp4; codecs="hev1.1.6.L93.B0"')) {
bild.push('hevc', 'h265', 'hvc1');
}
if (kann('video/webm; codecs="vp9"')) bild.push('vp9');
if (kann('video/mp4; codecs="av01.0.05M.08"')) bild.push('av1');
const ton: string[] = [];
if (kann('audio/mp4; codecs="mp4a.40.2"')) ton.push('aac');
if (kann('audio/mp4; codecs="mp3"')) ton.push('mp3');
if (kann('audio/mp4; codecs="ac-3"')) ton.push('ac3');
if (kann('audio/mp4; codecs="ec-3"')) ton.push('eac3');
if (kann('audio/mp4; codecs="opus"', 'audio/webm; codecs="opus"')) ton.push('opus');
if (kann('audio/mp4; codecs="flac"')) ton.push('flac');
// **Eine leere Liste wäre schlimmer als eine ungenaue.** Der Server hielte
// sie für „kein Profil mitgeschickt" und fiele auf Apples zurück — also
// genau auf den Fehler, den das hier beheben soll.
return {
containers: schachteln,
videoCodecs: bild.length > 0 ? bild : ['h264', 'avc1'],
audioCodecs: ton.length > 0 ? ton : ['aac'],
};
}
/* ---------- Der Spieler ---------- */
/** Wie oft der Stand zum Server geht. */
const MELDEABSTAND = 10_000;
/** In welchen Schritten der Regler einrastet. */
const VERSATZSCHRITT = 100;
/** Wie weit in beide Richtungen. Darüber hinaus ist es kein Versatz mehr,
* sondern eine kaputte Datei. */
const VERSATZGRENZE = 3000;
/** Wo gemerkte Tonversätze liegen — je Datei, in diesem Browser.
*
* **Je Datei, weil es eine Eigenschaft der Datei ist.** Wer einen Film einmal
* geradegerückt hat, soll das beim nächsten Mal nicht wieder tun. Und in
* diesem Browser, weil ein Versatz auch an der Anlage liegen kann — über
* Bluetooth-Kopfhörer stimmt etwas anderes als am Fernseher. */
const VERSATZSCHLUESSEL = 'flaro.tonversatz';
function versatzLesen(datei: string): number {
try {
const roh = JSON.parse(localStorage.getItem(VERSATZSCHLUESSEL) ?? '{}');
const wert = Number(roh?.[datei]);
return Number.isFinite(wert) ? wert : 0;
} catch { return 0; }
}
function versatzMerken(datei: string, wert: number): void {
try {
const roh = JSON.parse(localStorage.getItem(VERSATZSCHLUESSEL) ?? '{}');
if (wert === 0) delete roh[datei]; else roh[datei] = wert;
localStorage.setItem(VERSATZSCHLUESSEL, JSON.stringify(roh));
} catch { /* Privates Fenster — dann gilt er eben nur für diesen Film. */ }
}
/** Welche Tonsprache zuletzt gewählt wurde.
*
* **Eine Sprache, keine Spurnummer.** Welche Nummer die englische Spur trägt,
* ist von Film zu Film verschieden; dass jemand Englisch will, ist es nicht.
* Hier steht deshalb eine Sprache, und die Detailseite sucht damit die
* passende Spur. */
const SPRACHSCHLUESSEL = 'flaro.tonsprache';
export function tonsprache(): string | null {
try { return localStorage.getItem(SPRACHSCHLUESSEL); } catch { return null; }
}
export function tonspracheMerken(sprache: string): void {
try { localStorage.setItem(SPRACHSCHLUESSEL, sprache); } catch { /* Privates Fenster. */ }
}
/** „Ohne Versatz", „Ton 200 ms später", „Ton 1 s früher". */
function versatzWort(ms: number): string {
if (ms === 0) return 'Ohne Versatz';
const betrag = Math.abs(ms) >= 1000 ? `${Math.abs(ms) / 1000} s` : `${Math.abs(ms)} ms`;
return `Ton ${betrag} ${ms > 0 ? 'später' : 'früher'}`;
}
/** Die Stufen, die im Menü stehen. Muss zu `Stromentscheid.Stufen` passen. */
const STUFEN: { wert: number | null; text: string }[] = [
{ wert: null, text: 'Original' },
{ wert: 1080, text: '1080p' },
{ wert: 720, text: '720p' },
{ wert: 480, text: '480p' },
];
export type Spieleranfrage = {
dateiId: string;
/** Die Kennung des Titels — dorthin geht der Fortschritt. */
titelId: string;
name: string;
/** Ab welcher Sekunde des Films. */
ab: number;
/** Wie lang der Film ist. **Aus der Mediathek, nicht aus dem Strom** — der
* kennt nur den Rest ab dem Einstiegspunkt und machte aus einem
* Zweistünder ab Minute 90 einen Halbstünder. */
laenge: number | null;
/** Mit welcher Tonspur gestartet wird — gewählt auf der Detailseite. */
tonspur?: number | null;
danach: () => void;
};
/** Öffnet den Spieler über der Seite.
*
* **Eigene Steuerung statt `controls`.** Die eingebaute Leiste kennt weder
* Tonspuren noch Qualitätsstufen — und die Tonspurwahl ist der Grund, aus dem
* es diesen Server überhaupt gibt.
*/
/** Der eine Spieler, der gerade offen ist.
*
* **Es darf nur einen geben.** Seit ein Seitenwechsel ihn verkleinert statt
* ihn zu beenden, überlebt er das Blättern — und ohne diese Zeile legte der
* nächste Film einen zweiten darüber: zwei Videoelemente, zwei Tonspuren
* gleichzeitig und zwei ffmpeg-Sitzungen, von denen eine niemandem mehr
* gehört.
*/
let offenerSpieler: (() => void) | null = null;
export function spieler(anfrage: Spieleranfrage): void {
offenerSpieler?.();
const schicht = document.createElement('div');
schicht.className = 'pl';
schicht.tabIndex = -1;
const video = document.createElement('video');
video.className = 'pl-video';
video.autoplay = true;
video.playsInline = true;
/** Wo im Film wir stehen — nicht, wo im Strom. */
let vorlauf = anfrage.ab;
let sitzung: string | null = null;
let auskunft: Stromauskunft | null = null;
let hls: HlsTyp | null = null;
let geschlossen = false;
/** Wohin gesprungen wird, sobald die Metadaten da sind. Nur bei Direktwiedergabe. */
let zuSpringen = 0;
/** Der Tonversatz in Millisekunden — gemerkt je Datei. */
let tonversatz = versatzLesen(anfrage.dateiId);
/** Die gewählte Stufe und Spuren — sie überleben einen Neustart des Stroms. */
let stufe: number | null = null;
let tonspur: number | null = anfrage.tonspur ?? null;
let untertitel: number | null = null;
/** Ob der Server umkodieren soll, egal was das Profil sagt.
*
* **Das Urteil des Browsers schlägt seine eigene Auskunft.** `canPlayType`
* ist eine Behauptung — „kenne ich" heisst nicht „kann ich diese Datei".
* Scheitert die Wiedergabe trotzdem, wird einmal umgerechnet statt den
* Benutzer ans Zahnrad zu schicken. Wer eine Fassung von Hand wählen soll,
* weiss selten, welche. */
let umkodierenErzwingen = false;
const filmzeit = (): number => vorlauf + (Number.isFinite(video.currentTime) ? video.currentTime : 0);
/* ---------- Aufbau ---------- */
const bedienung = document.createElement('div');
bedienung.className = 'pl-leiste';
const kopf = document.createElement('div');
kopf.className = 'pl-kopf';
const name = document.createElement('span');
name.className = 'pl-titel';
name.textContent = anfrage.name;
const kopfknoepfe = document.createElement('div');
kopfknoepfe.className = 'pl-gruppe';
// **Verkleinern, nicht schliessen.** Wer nebenbei die Mediathek aufräumen
// will, soll den Film nicht verlieren.
const kleiner = document.createElement('button');
kleiner.className = 'pl-rund';
kleiner.innerHTML = icons.verkleinern;
kleiner.title = 'Verkleinern (Esc)';
kleiner.setAttribute('aria-label', 'Verkleinern');
kleiner.onclick = e => { e.stopPropagation(); kleinSetzen(true); };
const zu = document.createElement('button');
zu.className = 'pl-rund';
zu.innerHTML = icons.schliessen;
zu.title = 'Schliessen';
zu.setAttribute('aria-label', 'Schliessen');
zu.onclick = e => { e.stopPropagation(); schliessen(); };
kopfknoepfe.append(kleiner, zu);
kopf.append(name, kopfknoepfe);
const mitte = document.createElement('div');
mitte.className = 'pl-mitte';
const meldungsfeld = document.createElement('div');
meldungsfeld.className = 'pl-meldung hidden';
const dreher = document.createElement('div');
dreher.className = 'spinner pl-dreher';
/* ---------- Die kleine Fassung ----------
*
* **Dasselbe Videoelement, nur anders eingerahmt.**
*
* Der naheliegende Weg wäre ein zweiter Spieler unten — und der wäre
* falsch: ein `<video>`, das aus dem Dokument genommen und anderswo wieder
* eingehängt wird, hört auf zu spielen und fängt beim Einhängen neu zu
* puffern an. Beim Umkodieren hiesse das: eine neue Sitzung, ein neues
* ffmpeg, Sekunden schwarzes Bild. Stattdessen wechselt nur die Klasse an
* der Hülle, und das Video merkt nichts davon.
*/
let klein = false;
const kleinleiste = document.createElement('div');
kleinleiste.className = 'pl-kleinleiste';
const kleinName = document.createElement('span');
kleinName.className = 'pl-kleintitel';
kleinName.textContent = anfrage.name;
const kleinStand = document.createElement('span');
kleinStand.className = 'pl-kleinstand';
const kleinAb = document.createElement('button');
kleinAb.className = 'pl-rund';
kleinAb.innerHTML = icons.abspielen;
kleinAb.title = 'Abspielen';
kleinAb.setAttribute('aria-label', 'Abspielen');
kleinAb.onclick = e => { e.stopPropagation(); umschalten(); };
const kleinAuf = document.createElement('button');
kleinAuf.className = 'pl-rund';
kleinAuf.innerHTML = icons.vergroessern;
kleinAuf.title = 'Wieder gross';
kleinAuf.setAttribute('aria-label', 'Wieder gross');
kleinAuf.onclick = e => { e.stopPropagation(); kleinSetzen(false); };
const kleinZu = document.createElement('button');
kleinZu.className = 'pl-rund';
kleinZu.innerHTML = icons.schliessen;
kleinZu.title = 'Schliessen';
kleinZu.setAttribute('aria-label', 'Schliessen');
kleinZu.onclick = e => { e.stopPropagation(); schliessen(); };
const kleinBalken = document.createElement('div');
kleinBalken.className = 'pl-kleinbalken';
const kleinGefuellt = document.createElement('div');
kleinBalken.appendChild(kleinGefuellt);
const kleinText = document.createElement('div');
kleinText.className = 'pl-kleintext';
kleinText.append(kleinName, kleinStand);
kleinleiste.append(kleinBalken, kleinText, kleinAb, kleinAuf, kleinZu);
// Ein Klick auf die Leiste selbst holt den Film zurück — wie bei Plex.
kleinleiste.onclick = () => kleinSetzen(false);
schicht.append(video, dreher, mitte, kopf, bedienung, meldungsfeld, kleinleiste);
document.body.appendChild(schicht);
document.body.classList.add('pl-offen');
/* ---------- Steuerung ---------- */
const abspielknopf = rundknopf('abspielen', 'Abspielen (Leertaste)', () => umschalten());
const zeitleiste = document.createElement('input');
zeitleiste.type = 'range';
zeitleiste.className = 'pl-zeit';
zeitleiste.min = '0';
zeitleiste.step = '0.1';
zeitleiste.value = '0';
zeitleiste.setAttribute('aria-label', 'Position im Film');
const stand = document.createElement('span');
stand.className = 'pl-stand';
stand.textContent = '0:00';
const rest = document.createElement('span');
rest.className = 'pl-rest';
const lautstaerke = document.createElement('input');
lautstaerke.type = 'range';
lautstaerke.className = 'pl-laut';
lautstaerke.min = '0';
lautstaerke.max = '1';
lautstaerke.step = '0.05';
lautstaerke.value = '1';
lautstaerke.setAttribute('aria-label', 'Lautstärke');
lautstaerke.oninput = () => {
video.volume = Number(lautstaerke.value);
video.muted = video.volume === 0;
tonknopf.innerHTML = video.muted ? icons.stummGeschaltet : icons.ton;
};
const tonknopf = rundknopf('ton', 'Stumm (m)', () => {
video.muted = !video.muted;
tonknopf.innerHTML = video.muted ? icons.stummGeschaltet : icons.ton;
lautstaerke.value = video.muted ? '0' : String(video.volume);
});
const vollbild = rundknopf('vollbild', 'Vollbild (f)', () => vollbildUmschalten());
/** Die drei Menüs — Ton, Untertitel, Qualität.
*
* **Eigene Zeichen für jedes.** Der Lautsprecher stand vorher zweimal da:
* einmal fürs Stummschalten, einmal für die Tonspur. Und das Zahnrad mit
* acht Strahlen sah aus wie eine Sonne — niemand vermutete die Bildqualität
* dahinter. Jetzt: Wellenform für den Ton, CC-Kasten für die Untertitel,
* Schieberegler für die Qualität. */
// **Der Lautsprecher, nicht die Wellenform.** Hier stand einmal ein eigenes
// Zeichen, damit er nicht zweimal in der Leiste auftaucht — links für
// Stummschalten, rechts für die Tonspur. Die Wellenform erkannte aber
// niemand als „Ton"; zwei Lautsprecher sind das kleinere Übel, zumal der
// linke beim Stummschalten durchgestrichen wird und damit anders aussieht.
const tonmenue = menue('ton', 'Ton');
const textmenue = menue('untertitel', 'Untertitel');
const stufenmenue = menue('regler', 'Qualität');
/* ---------- Der Tonversatz, abgetrennt im Tonmenü ---------- */
// **Ein Regler, keine Liste.** Feste Stufen hiessen: für jeden Wert ein
// Eintrag, und die standen zwischen den Sprachen wie weitere Sprachen.
// Hier wird gezogen, und zwar erst beim Loslassen — jede Änderung baut den
// Strom neu auf, und das tut man nicht zwanzigmal beim Hinschieben.
const versatzkopf = document.createElement('div');
versatzkopf.className = 'pl-fusstitel';
const versatzregler = document.createElement('input');
versatzregler.type = 'range';
versatzregler.className = 'pl-laut pl-versatz';
versatzregler.min = String(-VERSATZGRENZE);
versatzregler.max = String(VERSATZGRENZE);
versatzregler.step = String(VERSATZSCHRITT);
versatzregler.setAttribute('aria-label', 'Tonversatz in Millisekunden');
const versatzzurueck = knopf('Zurücksetzen', 'pl-punkt');
function versatzstand(): void {
versatzkopf.textContent = `Tonversatz — ${versatzWort(tonversatz)}`;
versatzregler.value = String(tonversatz);
versatzregler.setAttribute('aria-valuetext', versatzWort(tonversatz));
versatzzurueck.classList.toggle('hidden', tonversatz === 0);
}
function versatzSetzen(ms: number): void {
tonversatz = Math.max(-VERSATZGRENZE, Math.min(VERSATZGRENZE, Math.round(ms / VERSATZSCHRITT) * VERSATZSCHRITT));
versatzMerken(anfrage.dateiId, tonversatz);
versatzstand();
void stromAufbauen(filmzeit());
}
// Beim Ziehen nur die Zahl mitlaufen lassen, beim Loslassen rechnen.
versatzregler.oninput = () => {
versatzkopf.textContent = `Tonversatz — ${versatzWort(Number(versatzregler.value))}`;
};
versatzregler.onchange = () => versatzSetzen(Number(versatzregler.value));
versatzregler.onclick = e => e.stopPropagation();
versatzzurueck.onclick = e => { e.stopPropagation(); versatzSetzen(0); };
tonmenue.fuss.append(versatzkopf, versatzregler, versatzzurueck);
const links = document.createElement('div');
links.className = 'pl-gruppe';
links.append(abspielknopf, tonknopf, lautstaerke, stand);
const rechts = document.createElement('div');
rechts.className = 'pl-gruppe';
rechts.append(rest, tonmenue.knopf, textmenue.knopf, stufenmenue.knopf, vollbild);
const unten = document.createElement('div');
unten.className = 'pl-unten';
unten.append(links, rechts);
bedienung.append(zeitleiste, unten);
/* ---------- Verhalten ---------- */
video.onloadedmetadata = () => {
// **Erst hier, nicht vorher.** `currentTime` vor den Metadaten zu setzen
// verfällt stillschweigend — genau daran scheiterte „Fortsetzen ab".
//
// Nur bei einer durchgereichten Datei: beim Strom steckt die Stelle schon
// im Schnitt, und noch einmal zu springen hiesse, sie doppelt zu zählen.
if (zuSpringen > 0) {
video.currentTime = zuSpringen;
zuSpringen = 0;
}
dreher.classList.add('hidden');
zeitAuffrischen();
};
video.onplay = () => {
abspielknopf.innerHTML = icons.pause;
abspielknopf.title = 'Pause (Leertaste)';
kleinAb.innerHTML = icons.pause;
kleinAb.title = 'Pause';
};
video.onpause = () => {
abspielknopf.innerHTML = icons.abspielen;
abspielknopf.title = 'Abspielen (Leertaste)';
kleinAb.innerHTML = icons.abspielen;
kleinAb.title = 'Abspielen';
melden(true);
};
video.onwaiting = () => dreher.classList.remove('hidden');
video.onplaying = () => { dreher.classList.add('hidden'); verbergenPlanen(); };
video.ontimeupdate = () => { zeitAuffrischen(); melden(); };
video.onended = () => { melden(true, true); };
video.onerror = () => {
if (nochmalMitUmkodieren(video.error?.code)) return;
zeigeFehler(videofehler());
};
zeitleiste.oninput = () => { stand.textContent = dauer(Number(zeitleiste.value)); };
zeitleiste.onchange = () => void springen(Number(zeitleiste.value));
/** Die Gesamtlänge des Films — nicht die des Stroms. */
const gesamt = anfrage.laenge ?? 0;
function zeitAuffrischen(): void {
const jetzt = filmzeit();
const laenge = gesamt > 0 ? gesamt : vorlauf + (Number.isFinite(video.duration) ? video.duration : 0);
zeitleiste.max = String(Math.max(1, laenge));
zeitleiste.value = String(jetzt);
stand.textContent = dauer(jetzt);
rest.textContent = laenge > jetzt ? `noch ${dauer(laenge - jetzt)}` : '';
// Der gepufferte Bereich als zweite Farbe in der Leiste.
const anteil = laenge > 0 ? jetzt / laenge : 0;
zeitleiste.style.setProperty('--anteil', `${Math.min(100, anteil * 100)}%`);
kleinStand.textContent = laenge > 0 ? `${dauer(jetzt)} / ${dauer(laenge)}` : dauer(jetzt);
kleinGefuellt.style.width = `${Math.min(100, anteil * 100)}%`;
}
function umschalten(): void {
if (video.paused) void video.play().catch(() => zeigeFehler(videofehler()));
else video.pause();
}
/** Zwischen ganzem Bild und Leiste am unteren Rand.
*
* **Die Seite wird wieder bedienbar.** `body.pl-offen` sperrt den Bildlauf;
* in der kleinen Fassung muss er zurück, sonst liesse sich die Mediathek
* nicht durchblättern, während etwas läuft. Dafür bekommt die Seite unten
* Platz, damit die letzte Kachelreihe nicht unter der Leiste liegt.
*/
function kleinSetzen(an: boolean): void {
klein = an;
schicht.classList.toggle('pl-klein', an);
document.body.classList.toggle('pl-offen', !an);
document.body.classList.toggle('pl-leiste-da', an);
if (an) {
// Ein offenes Menü hätte in der Leiste keinen Platz.
bedienung.querySelectorAll('.pl-menue.auf').forEach(m => m.classList.remove('auf'));
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
} else {
zeigen();
schicht.focus();
}
zeitAuffrischen();
}
async function vollbildUmschalten(): Promise<void> {
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await schicht.requestFullscreen();
} catch {
// Lehnt der Browser ab — etwa ohne Benutzergeste —, bleibt es, wie es
// ist. Eine Meldung dafür wäre lauter als der Anlass.
}
}
/* ---------- Melden ---------- */
let zuletzt = 0;
function melden(erzwingen = false, beendet = false): void {
const jetzt = Date.now();
if (!erzwingen && jetzt - zuletzt < MELDEABSTAND) return;
zuletzt = jetzt;
const laenge = gesamt > 0 ? gesamt : null;
// Eine verlorene Meldung kostet ein paar Sekunden Fortschritt. Mitten im
// Film dafür eine Fehlermeldung zu zeigen wäre die schlechtere Hälfte.
void fortschritt(anfrage.titelId, filmzeit(), laenge, beendet).catch(() => {});
}
/* ---------- Strom ---------- */
async function stromAufbauen(ab: number): Promise<void> {
dreher.classList.remove('hidden');
meldungsfeld.classList.add('hidden');
await stromAbbauen();
if (geschlossen) return;
try {
auskunft = await stromStarten(anfrage.dateiId, {
...geraeteprofil(),
height: stufe,
audioStream: tonspur,
startSeconds: ab,
transcode: umkodierenErzwingen,
audioOffsetMs: tonversatz,
});
} catch (fehler) {
zeigeFehler(meldung(fehler));
return;
}
if (geschlossen) {
if (auskunft.method !== 'direct') void stromBeenden(auskunft.sessionId).catch(() => {});
return;
}
// **Eine Direktwiedergabe hat keine Sitzung.** Der Server schickt dafür
// eine leere Kennung: es gibt nichts zu beenden und nichts aufzuräumen.
const direkt = auskunft.method === 'direct';
sitzung = direkt ? null : auskunft.sessionId;
vorlauf = auskunft.startSeconds;
zuSpringen = direkt ? ab : 0;
if (tonspur === null) tonspur = auskunft.audioStream;
menuesAuffrischen();
untertitelAnhaengen();
if (direkt) {
video.src = auskunft.url;
void video.play().catch(() => dreher.classList.add('hidden'));
return;
}
// **hls.js zuerst, natives HLS nur als Rückfall — und das ist gemessen.**
//
// Erst stand die Prüfung andersherum: wer `application/vnd.apple.mpegurl`
// zu können behauptet, bekommt die Adresse unmittelbar. Chromium
// antwortet darauf aber mit `"maybe"` und kann es dann doch nicht —
// heraus kam ein schwarzes Bild mit „kann die Spuren nicht öffnen".
// `Hls.isSupported()` prüft dagegen, ob es einen MSE-Puffer gibt, und
// das ist eine Tatsache statt einer Vermutung. Safari auf dem iPhone hat
// keinen; dort greift der Rückfall, und dort stimmt die Auskunft auch.
const Hls = await hlsLaden();
if (geschlossen) return;
if (Hls?.isSupported()) {
hls = new Hls({
// **Die Playlist wächst.** Ohne beharrliches Nachfragen sähe hls.js
// nur das erste Segment und hielte den Film für zu Ende.
manifestLoadingMaxRetry: 20,
manifestLoadingRetryDelay: 500,
levelLoadingMaxRetry: 20,
levelLoadingRetryDelay: 500,
});
hls.on(Hls.Events.ERROR, (_: unknown, daten: { fatal: boolean; type: string }) => {
if (!daten.fatal) return;
// Dasselbe wie beim `<video>`: ein Medienfehler heisst, dass der
// Browser mit dem Bild nicht zurechtkam. Dann wird gerechnet, nicht
// gemeldet.
if (daten.type === Hls.ErrorTypes.MEDIA_ERROR && nochmalMitUmkodieren()) return;
zeigeFehler(hlsfehler(daten.type, Hls.ErrorTypes));
});
hls.loadSource(auskunft.url);
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl') !== '') {
video.src = auskunft.url;
} else {
zeigeFehler('Dieser Browser kann keine Streams abspielen. '
+ 'Lade den Film herunter oder nimm einen anderen Browser.');
return;
}
void video.play().catch(() => {
// Startet der Browser nicht von selbst — manche verlangen eine Geste —,
// steht der Knopf bereit. Das ist kein Fehler.
dreher.classList.add('hidden');
});
}
async function stromAbbauen(): Promise<void> {
hls?.destroy();
hls = null;
video.removeAttribute('src');
video.load();
if (sitzung) {
const alt = sitzung;
sitzung = null;
await stromBeenden(alt).catch(() => {});
}
}
/** Springt — im Strom, wenn es geht, sonst mit einer neuen Sitzung. */
async function springen(aufFilmzeit: number): Promise<void> {
const imStrom = aufFilmzeit - vorlauf;
const gepuffert = Number.isFinite(video.duration) ? video.duration : 0;
// **Innerhalb des Erzeugten springt der Spieler selbst.** Dafür eine neue
// Sitzung zu starten hiesse, dreissig Sekunden zu warten, um fünf
// Sekunden zurückzugehen.
if (auskunft?.method === 'direct' || (imStrom >= 0 && imStrom <= gepuffert)) {
video.currentTime = Math.max(0, imStrom);
melden(true);
return;
}
melden(true);
await stromAufbauen(aufFilmzeit);
}
/* ---------- Menüs ---------- */
/** Was in den drei Menüs steht.
*
* **Alle drei stehen immer da.**
*
* Vorher verschwand das Tonmenü bei nur einer Spur und das Untertitelmenü,
* wenn die Datei keine hat. Das war als Aufräumen gemeint und ging nach
* hinten los: wer sie sucht, findet nichts und weiss nicht, ob es sie nicht
* gibt oder ob er sie übersehen hat. Jetzt sagt das Menü es selbst —
* „Diese Datei hat nur eine Tonspur" ist eine Antwort, ein fehlender Knopf
* ist keine.
*/
function menuesAuffrischen(): void {
const toene = auskunft?.audioStreams ?? [];
const spuren = toene.length > 0
? toene.map(s => ({
text: s.label, gewaehlt: s.index === tonspur,
tun: () => {
tonspur = s.index;
// Wer hier wechselt, meint es auch beim nächsten Film so.
if (s.language) tonspracheMerken(s.language);
void stromAufbauen(filmzeit());
},
}))
: [{ text: 'Diese Datei hat keinen Ton.', gewaehlt: false, tun: () => {} }];
tonmenue.setzen(spuren);
// Ohne Tonspur gibt es nichts zu verschieben.
tonmenue.fuss.classList.toggle('hidden', toene.length === 0);
versatzstand();
const texte = auskunft?.subtitleStreams ?? [];
textmenue.setzen(
texte.length > 0
? [
{ text: 'Aus', gewaehlt: untertitel === null, tun: () => untertitelWaehlen(null) },
...texte.map(s => ({
text: s.label, gewaehlt: s.index === untertitel,
tun: () => untertitelWaehlen(s.index),
})),
]
: [{ text: 'Keine Untertitel in dieser Datei.', gewaehlt: false, tun: () => {} }]);
stufenmenue.setzen(STUFEN.map(s => ({
text: s.text,
gewaehlt: s.wert === stufe,
tun: () => { stufe = s.wert; void stromAufbauen(filmzeit()); },
})));
}
/** Untertitel umschalten — ohne den Strom anzufassen. */
function untertitelWaehlen(spur: number | null): void {
untertitel = spur;
[...video.textTracks].forEach((t, i) => {
t.mode = spur !== null && video.querySelectorAll('track')[i]?.dataset.spur === String(spur)
? 'showing' : 'disabled';
});
menuesAuffrischen();
}
function untertitelAnhaengen(): void {
video.querySelectorAll('track').forEach(t => t.remove());
(auskunft?.subtitleStreams ?? []).forEach(s => {
const t = document.createElement('track');
t.kind = 'subtitles';
t.label = s.label;
if (s.language) t.srclang = s.language.slice(0, 2);
t.src = untertitelAdresse(anfrage.dateiId, s.index);
t.dataset.spur = String(s.index);
video.appendChild(t);
});
if (untertitel !== null) untertitelWaehlen(untertitel);
}
/* ---------- Fehler ---------- */
/** Zweiter Versuch, diesmal gerechnet.
*
* **Der Benutzer soll nicht raten müssen.** Hier stand „über das Zahnrad
* lässt sich eine Fassung wählen, die er versteht" — und wer das liest,
* weiss weder, welche das wäre, noch warum der Server sie ihm nicht gleich
* gegeben hat. Er hat recht: das ist die Aufgabe des Servers.
*
* **Genau einmal.** Scheitert auch der gerechnete Strom, liegt es nicht am
* Codec, und ein dritter Anlauf wäre eine Schleife statt einer Lösung.
*
* Gibt `true` zurück, wenn der Versuch läuft — dann bleibt die Meldung aus.
*/
function nochmalMitUmkodieren(code?: number): boolean {
const kannAmCodecLiegen = code === undefined
|| code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
|| code === MediaError.MEDIA_ERR_DECODE;
if (!kannAmCodecLiegen || umkodierenErzwingen || geschlossen) return false;
// **Lief schon ein gerechneter Strom, hilft ein zweiter nicht.** Dann
// liegt es nicht am Codec, und dreissig Sekunden zu warten, um dasselbe
// noch einmal zu bekommen, ist keine Hilfe, sondern eine Verzögerung.
if (auskunft?.method === 'transcode') return false;
umkodierenErzwingen = true;
meldungsfeld.classList.add('hidden');
dreher.classList.remove('hidden');
// Die Stelle, an der es hing — nicht von vorn.
void stromAufbauen(filmzeit());
return true;
}
function zeigeFehler(text: string): void {
dreher.classList.add('hidden');
meldungsfeld.textContent = text;
meldungsfeld.classList.remove('hidden');
}
/** Was `MediaError` bedeutet — auf Deutsch, ohne Fehlercode.
*
* **Vorher stand hier nichts.** Der Spieler hatte kein `onerror`; scheiterte
* etwas, blieb ein schwarzes Bild stehen, und niemand erfuhr, woran es lag. */
function videofehler(): string {
switch (video.error?.code) {
case MediaError.MEDIA_ERR_ABORTED:
return 'Die Wiedergabe wurde abgebrochen.';
case MediaError.MEDIA_ERR_NETWORK:
return 'Die Verbindung zum Server ist abgerissen. Läuft er noch?';
case MediaError.MEDIA_ERR_DECODE:
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
// Hierher kommt nur, wer den gerechneten Strom auch nicht öffnen
// konnte — der erste Versuch wird ohne Rückfrage wiederholt.
return 'Dieser Browser kommt mit dem Bild nicht zurecht, auch '
+ 'umgerechnet nicht. Eine kleinere Auflösung über das Zahnrad hilft '
+ 'vielleicht; sonst lade den Film herunter.';
default:
return 'Die Wiedergabe ist gescheitert. Die Einzelheiten stehen im Protokoll von heute.';
}
}
function hlsfehler(art: string, arten: { NETWORK_ERROR: string; MEDIA_ERROR: string }): string {
if (art === arten.NETWORK_ERROR) {
return 'Der Strom ist abgerissen. Entweder war die Verbindung weg, '
+ 'oder der Server hat die Sitzung aufgeräumt — fang die Wiedergabe neu an.';
}
if (art === arten.MEDIA_ERROR) {
return 'Der Browser kommt mit dem Bild nicht zurecht, auch umgerechnet '
+ 'nicht. Eine kleinere Auflösung über das Zahnrad hilft vielleicht; '
+ 'sonst lade den Film herunter.';
}
return 'Die Wiedergabe ist gescheitert. Die Einzelheiten stehen im Protokoll von heute.';
}
/* ---------- Tastatur ---------- */
function tastendruck(e: KeyboardEvent): void {
if (e.target instanceof HTMLInputElement && e.target.type === 'range' && e.key.startsWith('Arrow')) return;
switch (e.key) {
case ' ': case 'k': e.preventDefault(); umschalten(); break;
// **Escape verkleinert, es schliesst nicht.** Wer aus Versehen drückt,
// verliert damit nicht die Stelle im Film; zum Schliessen gibt es das
// Kreuz. Im Vollbild nimmt der Browser die Taste ohnehin für sich.
case 'Escape': e.preventDefault(); kleinSetzen(true); break;
case 'f': e.preventDefault(); void vollbildUmschalten(); break;
case 'm': e.preventDefault(); tonknopf.click(); break;
case 'ArrowLeft': e.preventDefault(); void springen(Math.max(0, filmzeit() - 10)); break;
case 'ArrowRight': e.preventDefault(); void springen(filmzeit() + 10); break;
case 'ArrowUp':
e.preventDefault();
video.volume = Math.min(1, video.volume + 0.1);
lautstaerke.value = String(video.volume);
break;
case 'ArrowDown':
e.preventDefault();
video.volume = Math.max(0, video.volume - 0.1);
lautstaerke.value = String(video.volume);
break;
default: return;
}
zeigen();
}
schicht.addEventListener('keydown', tastendruck);
// **Wer weiterblättert, verkleinert — er beendet nicht.**
//
// Zuerst schloss der Spieler bei jedem Seitenwechsel: er hängt an
// `document.body` und stand sonst über der neuen Seite. Das war aber die
// falsche Hälfte der Lösung — wer nebenbei Plakate geraderückt, will den
// Film behalten. Jetzt rutscht er nach unten in die Leiste, und nur das
// Kreuz beendet ihn.
const beiNavigation = (): void => { if (!klein) kleinSetzen(true); };
window.addEventListener('hashchange', beiNavigation);
window.addEventListener('popstate', beiNavigation);
// **Und beim Schliessen des Reiters.** `DELETE` als gewöhnliche Anfrage
// kommt dabei oft nicht mehr durch; `sendBeacon` ist genau dafür da.
const beiEntladen = (): void => {
if (sitzung) navigator.sendBeacon?.(`/api/v1/stream/${sitzung}/schliessen`);
};
window.addEventListener('pagehide', beiEntladen);
/* ---------- Leiste ein- und ausblenden ---------- */
let verstecker: number | undefined;
function zeigen(): void {
schicht.classList.remove('pl-still');
verbergenPlanen();
}
function verbergenPlanen(): void {
window.clearTimeout(verstecker);
if (video.paused) return;
verstecker = window.setTimeout(() => {
// **Nicht, solange ein Menü offen steht.** Sonst verschwindet die Liste
// unter der Hand, während man die Tonspur liest — gesehen beim Prüfen,
// und es war genau so ärgerlich, wie es klingt.
if (bedienung.querySelector('.pl-menue.auf')) { verbergenPlanen(); return; }
schicht.classList.add('pl-still');
}, 3000);
}
schicht.onmousemove = () => { if (!klein) zeigen(); };
schicht.onclick = e => {
// **In der Leiste holt auch das Bild den Film zurück.**
//
// Die Leiste selbst tat es schon, das Vorschaubild liegt aber darüber und
// gehört nicht zu ihr — ein Klick genau darauf tat deshalb nichts. Das ist
// ausgerechnet die Stelle, auf die man zeigt.
if (klein) {
if (e.target === video || e.target === schicht) kleinSetzen(false);
return;
}
// Ein Klick neben ein offenes Menü schliesst es, statt den Film anzuhalten.
const offen = bedienung.querySelector('.pl-menue.auf');
if (offen && !offen.contains(e.target as Node)) { offen.classList.remove('auf'); return; }
if (e.target === schicht || e.target === video) umschalten();
};
/* ---------- Schliessen ---------- */
function schliessen(): void {
if (geschlossen) return;
geschlossen = true;
if (offenerSpieler === schliessen) offenerSpieler = null;
video.pause();
// **Beendet melden, nicht nur den Stand.** Sonst steht der Film im
// Dashboard noch eine Minute als „läuft".
melden(true, true);
void stromAbbauen();
window.clearTimeout(verstecker);
window.removeEventListener('hashchange', beiNavigation);
window.removeEventListener('popstate', beiNavigation);
window.removeEventListener('pagehide', beiEntladen);
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
document.body.classList.remove('pl-offen', 'pl-leiste-da');
schicht.remove();
anfrage.danach();
}
/* ---------- Hilfsbausteine ---------- */
function rundknopf(bild: keyof typeof icons, titel: string, tun: () => void): HTMLButtonElement {
const b = document.createElement('button');
b.className = 'pl-rund';
b.innerHTML = icons[bild];
b.title = titel;
b.setAttribute('aria-label', titel);
b.onclick = e => { e.stopPropagation(); tun(); };
return b;
}
type Wahl = { text: string; gewaehlt: boolean; tun: () => void };
/** Ein kleines Menü, das über der Leiste aufgeht. */
function menue(bild: keyof typeof icons, titel: string): {
knopf: HTMLElement; fuss: HTMLElement; setzen: (punkte: Wahl[]) => void;
} {
const huelle = document.createElement('div');
huelle.className = 'pl-menue';
const ausloeser = rundknopf(bild, titel, () => {
const offen = huelle.classList.toggle('auf');
// Nur eines auf einmal — zwei offene Listen übereinander sind ein Rätsel.
if (offen) {
bedienung.querySelectorAll('.pl-menue.auf').forEach(m => {
if (m !== huelle) m.classList.remove('auf');
});
}
// **Und die Leiste bleibt stehen.** War sie gerade weggeblendet, ginge
// das Menü sonst hinter einer unsichtbaren Leiste auf.
zeigen();
});
const liste = document.createElement('div');
liste.className = 'pl-liste';
// **Ein abgetrennter Fuss für das, was keine Auswahl ist.**
// Eine Einstellung zwischen Inhalten sieht aus wie ein Inhalt — der
// Tonversatz stand als „Später (+100 ms)" zwischen den Sprachen und las
// sich wie eine. Hier ist er durch einen Strich und eine Überschrift
// sichtbar etwas anderes.
const fuss = document.createElement('div');
fuss.className = 'pl-fuss hidden';
huelle.append(ausloeser, liste);
liste.appendChild(fuss);
return {
knopf: huelle,
fuss,
setzen: punkte => {
liste.innerHTML = '';
punkte.forEach(p => {
const b = knopf(p.text, 'pl-punkt' + (p.gewaehlt ? ' an' : ''));
if (p.gewaehlt) b.prepend(icon('haken'));
b.onclick = e => {
e.stopPropagation();
huelle.classList.remove('auf');
p.tun();
};
liste.appendChild(b);
});
// Der Fuss gehört nicht zur Auswahl und wird deshalb wieder angehängt.
liste.appendChild(fuss);
},
};
}
/* ---------- Los ---------- */
offenerSpieler = schliessen;
menuesAuffrischen();
zeitAuffrischen();
schicht.focus();
void stromAufbauen(anfrage.ab);
}
/** Holt hls.js erst, wenn wirklich etwas abgespielt wird.
*
* **Fest eingebunden wog die Oberfläche 685 KB statt 97.** Das sind sechs
* Siebtel für etwas, das beim Durchblättern der Mediathek niemand braucht —
* und gemessen, nicht geschätzt: der Bau sagt es.
*
* **Die schlanke Fassung reicht.** Was die vollständige mehr kann — getrennte
* Tonspuren im Strom, Untertitel in der Playlist, Kopierschutz — brauchen wir
* nicht: die Tonspur steckt im Strom, die Untertitel kommen als eigene Datei.
*/
let hlsVersprechen: Promise<typeof HlsTyp | null> | null = null;
function hlsLaden(): Promise<typeof HlsTyp | null> {
hlsVersprechen ??= import('hls.js/light')
.then(m => m.default)
.catch(() => {
// Kommt die Datei nicht an — kein Netz, ein Proxy dazwischen —, ist das
// kein Grund abzustürzen. Der Aufrufer zeigt dann einen Satz.
hlsVersprechen = null;
return null;
});
return hlsVersprechen;
}
export type { Spurauskunft };