Files
Flaro/web/app/src/spieler.ts
T
ScarriffleandClaude Opus 5 8cf2118723 Vorspann und Abspann — erkannt, abgelegt, überspringbar
Beides mit derselben Rechnung, nur in einem anderen Fenster: vorn die
ersten fünfzehn Minuten, hinten die letzten zehn. Was `Tonabgleich`
findet, hängt allein daran, welchen Ausschnitt man ihm gibt — eine
zweite Erkennung für den Abspann wäre dieselbe Suche ein zweites Mal.

Je Staffel verglichen, nicht je Serie: Serien wechseln ihren Vorspann
zwischen Staffeln, und über alle hinweg fände man nichts oder das
Falsche. Gefundenes über fünf Minuten wird verworfen — zwei Folgen, die
dieselbe Datei tragen, teilen vierzig, und darüber stünde sonst ein
Überspringen-Knopf.

Der Lauf geht auf Zuruf, nicht bei jedem Scan: er liest von jeder Folge
ein Stück Ton und dauert Minuten. Der Knopf dafür steht auf der
Serienseite, bei den Verwalterknöpfen.

Die Marken gehen im Folgenvertrag mit — als „intro" und „credits", die
Wörter, die die Apps von Jellyfin her ohnehin kennen. In der App zeichnet
sie der vorhandene Überspringen-Knopf, der bisher nie etwas zu tun
hatte; im Web ist er neu, rechtsbündig über der Zeitleiste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 21:28:36 +02:00

1383 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type HlsTyp from 'hls.js';
import {
stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, folgeLesen,
type Stromauskunft, type Spurauskunft, type Folge, type Marke,
} from './api';
import { dauer, knopf, meldung, hinweisZeigen } from './util';
import { serienton, serientext, spurFuer } from './sprachwahl';
import {
GROESSE_GROESST, GROESSE_KLEINST, GROESSE_SCHRITT, HINTERGRUENDE, SCHRIFTFARBEN,
stilAnwenden, stilGeaendert, stilLesen, stilSchreiben, stilVorgabe,
type Untertitelstil,
einblendungenAusrichten,
} from './untertitelstil';
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. */ }
}
/** Dasselbe für den Untertitel — mit einem dritten Zustand.
*
* **„Keine" ist eine Antwort, nicht das Fehlen einer.** Wer Untertitel
* abgewählt hat, will sie beim nächsten Film auch nicht; ein leerer Eintrag
* hiesse dagegen „noch nie etwas gesagt", und dann darf eine erzwungene
* Spur durchaus erscheinen. */
const UNTERTITELSCHLUESSEL = 'flaro.untertitelsprache';
/** Der gemerkte Wunsch: eine Sprache, `'keine'` oder `null` für „nie gesagt". */
export function untertitelsprache(): string | null {
try { return localStorage.getItem(UNTERTITELSCHLUESSEL); } catch { return null; }
}
export function untertitelspracheMerken(wunsch: string): void {
try { localStorage.setItem(UNTERTITELSCHLUESSEL, wunsch); } catch { /* Privates Fenster. */ }
}
/** Was „keine Untertitel" im Speicher heisst. */
export const OHNE_UNTERTITEL = 'keine';
/** „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;
/** Mit welchem Untertitel gestartet wird. `null` heisst: keiner. */
untertitelspur?: 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;
// **Die Wahl von der Detailseite gilt ab dem ersten Bild.** Sie erst im
// Spieler zu treffen hiesse, die ersten Sätze ohne Untertitel zu sehen.
let untertitel: number | null = anfrage.untertitelspur ?? 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;
/** Wo im Film der Spieler steht.
*
* **Der noch ausstehende Sprung zählt mit.** Bei Direktwiedergabe gibt der
* Server `startSeconds: 0` zurück und der Browser springt selbst, sobald
* die Metadaten da sind. Bis dahin steht `currentTime` auf null — und
* kommen die Metadaten nie, weil der Browser mit der Datei nicht
* zurechtkommt, bleibt es dabei.
*
* Genau dann greift der Rückfall aufs Umkodieren, und der bekam bisher
* diese Null übergeben: „an der Stelle weiter, an der es hing" fing in
* Wahrheit von vorn an. Gemeldet wurde die Null auch, und sie hat auf dem
* Server eine gemerkte Stelle gelöscht. */
const filmzeit = (): number => zuSpringen > 0
? zuSpringen
: 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.
// Name links, Wert rechts — in einer Zeile. „TONVERSATZ — OHNE VERSATZ"
// am Stück brach in dem schmalen Menü um und sah nach zwei Dingen aus.
const versatzkopf = document.createElement('div');
versatzkopf.className = 'pl-fusstitel';
const versatzname = document.createElement('span');
versatzname.textContent = 'Tonversatz';
const versatzwert = document.createElement('span');
versatzwert.className = 'pl-fusswert';
versatzkopf.append(versatzname, versatzwert);
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 {
versatzwert.textContent = 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 = () => {
versatzwert.textContent = 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);
/* ---------- Das Aussehen der Untertitel, abgetrennt im Textmenü ---------- */
// **Hier und nicht in den Einstellungen.** Ob die Schrift gross genug ist,
// sieht man nur an einem laufenden Film — und zwar an diesem, in diesem
// Licht, aus diesem Abstand. Wer dafür die Wiedergabe verlassen muss,
// stellt zweimal falsch ein, bevor er einmal richtig liegt.
let stil = stilLesen();
stilAnwenden(stil);
const stilkopf = document.createElement('div');
stilkopf.className = 'pl-fusstitel';
const stilname = document.createElement('span');
stilname.textContent = 'Aussehen';
const stilwert = document.createElement('span');
stilwert.className = 'pl-fusswert';
stilkopf.append(stilname, stilwert);
const groessenregler = document.createElement('input');
groessenregler.type = 'range';
groessenregler.className = 'pl-laut pl-groesse';
groessenregler.min = String(GROESSE_KLEINST);
groessenregler.max = String(GROESSE_GROESST);
groessenregler.step = String(GROESSE_SCHRITT);
groessenregler.setAttribute('aria-label', 'Schriftgrösse der Untertitel in Prozent');
const stilzurueck = knopf('Zurücksetzen', 'pl-punkt');
/** Eine Reihe Farbfelder — die gewählte trägt einen Haken.
*
* **Felder statt einer Liste mit Farbnamen.** „Hellblau" muss man lesen
* und sich vorstellen; ein Feld sieht man. Der Name bleibt trotzdem dran,
* als Beschriftung für alles, was nicht sieht. */
function farbreihe(
beschriftung: string,
werte: readonly { name: string; wert: string }[],
jetzt: () => string,
waehlen: (wert: string) => void,
): HTMLElement {
const reihe = document.createElement('div');
reihe.className = 'pl-farbreihe';
const name = document.createElement('span');
name.className = 'pl-farbname';
name.textContent = beschriftung;
// **Die Felder in einen eigenen Kasten.** Ohne ihn wird jedes Feld eine
// eigene Rasterzelle und die Reihe bricht nach dem zweiten um — gemessen
// am Bildschirm: aus einer Zeile wurden drei, und die Beschriftung stand
// neben dem ersten Feld statt neben allen.
const felder = document.createElement('div');
felder.className = 'pl-farben';
reihe.append(name, felder);
werte.forEach(f => {
const feld = document.createElement('button');
feld.className = 'pl-farbe';
feld.style.setProperty('--farbe', f.wert);
feld.title = f.name;
feld.setAttribute('aria-label', `${beschriftung}: ${f.name}`);
// „Ohne" ist keine Farbe, sondern deren Abwesenheit — das Karomuster
// sagt das, ein leeres Feld sähe nach Weiss aus.
feld.classList.toggle('kariert', f.wert === 'transparent');
feld.onclick = e => { e.stopPropagation(); waehlen(f.wert); };
feld.dataset.wert = f.wert;
felder.appendChild(feld);
});
reihe.onclick = e => e.stopPropagation();
(reihe as HTMLElement & { auffrischen?: () => void }).auffrischen = () => {
reihe.querySelectorAll<HTMLElement>('.pl-farbe').forEach(f => {
f.classList.toggle('an', f.dataset.wert === jetzt());
});
};
return reihe;
}
const schriftreihe = farbreihe('Schrift', SCHRIFTFARBEN,
() => stil.farbe, wert => stilSetzen({ ...stil, farbe: wert }));
const grundreihe = farbreihe('Hintergrund', HINTERGRUENDE,
() => stil.hintergrund, wert => stilSetzen({ ...stil, hintergrund: wert }));
function stilstand(): void {
stilwert.textContent = `${stil.groesse} %`;
groessenregler.value = String(stil.groesse);
groessenregler.setAttribute('aria-valuetext', `${stil.groesse} Prozent`);
stilzurueck.classList.toggle('hidden', !stilGeaendert(stil));
(schriftreihe as HTMLElement & { auffrischen?: () => void }).auffrischen?.();
(grundreihe as HTMLElement & { auffrischen?: () => void }).auffrischen?.();
}
function stilSetzen(neu: Untertitelstil): void {
stil = neu;
stilSchreiben(stil);
stilstand();
}
// Beim Ziehen sofort anwenden: anders als beim Tonversatz kostet das
// nichts, und man will ja im Bild sehen, wie gross es wird.
groessenregler.oninput = () => stilSetzen({ ...stil, groesse: Number(groessenregler.value) });
groessenregler.onclick = e => e.stopPropagation();
stilzurueck.onclick = e => { e.stopPropagation(); stilSetzen(stilVorgabe()); };
textmenue.fuss.append(stilkopf, groessenregler, schriftreihe, grundreihe, stilzurueck);
textmenue.fuss.classList.remove('hidden');
stilstand();
const links = document.createElement('div');
links.className = 'pl-gruppe';
links.append(tonknopf, lautstaerke);
// **Das Wichtigste in die Mitte, und die beiden Zeiten dazu.**
//
// Vorher stand die verstrichene Zeit hinter dem Lautstärkeregler und die
// verbleibende ganz rechts hinter den Menüs: zwei Angaben über dieselbe
// Sache, so weit auseinander wie möglich, und die erste neben etwas, mit
// dem sie nichts zu tun hat. „1:51:01" direkt neben einem Schieber für
// Lautstärke liest sich wie dessen Beschriftung.
//
// Jetzt stehen sie dort, wo man hinsieht: links und rechts vom Knopf, den
// man drückt.
/** Die Nachbarfolgen, sobald der Server sie genannt hat. */
let vorige: Folge | null = null;
let naechste: Folge | null = null;
/** Vorspann und Abspann dieser Folge — leer heisst: keine erkannt. */
let marken: Marke[] = [];
// **Eine Folge weiter, eine zurück.**
//
// Nur bei Serien, und nur wo es eine gibt: bei einem Film fragt der Server
// mit 404, bei der letzten Folge mit 204 — beides heisst hier „kein
// Knopf". Erscheinen tun sie also von selbst nur dort, wo sie etwas tun.
//
// Die Knöpfe werden schon jetzt gebaut und bloss versteckt: sie
// nachträglich zwischen die bestehenden zu schieben hiesse, sich auf eine
// Reihenfolge zu verlassen, die anderswo geändert wird.
const vorigeKnopf = rundknopf('folgeZurueck', 'Vorige Folge', () => void folgeWechseln(vorige));
const naechsteKnopf = rundknopf('folgeVor', 'Nächste Folge', () => void folgeWechseln(naechste));
vorigeKnopf.classList.add('hidden');
naechsteKnopf.classList.add('hidden');
const zeitmitte = document.createElement('div');
zeitmitte.className = 'pl-mitte';
zeitmitte.append(stand, vorigeKnopf, abspielknopf, naechsteKnopf, rest);
const rechts = document.createElement('div');
rechts.className = 'pl-gruppe';
rechts.append(tonmenue.knopf, textmenue.knopf, stufenmenue.knopf, vollbild);
const unten = document.createElement('div');
unten.className = 'pl-unten';
unten.append(links, zeitmitte, rechts);
// **„Intro überspringen" über der Leiste, rechtsbündig.**
//
// Dort erwartet man ihn — so macht es jeder Spieler —, und dort verdeckt
// er weder das Bild noch die Zeitleiste. Er steht im selben Block wie die
// Bedienung und blendet mit ihr ein und aus.
const ueberspringen = document.createElement('button');
ueberspringen.className = 'btn pl-ueberspringen hidden';
bedienung.append(ueberspringen, zeitleiste, unten);
// **Beide Nachbarn gleich zu Beginn holen.** Zwei kleine Anfragen, einmal
// je Wiedergabe. Erst beim Antippen zu fragen hiesse, einen Knopf zu
// zeigen, von dem noch niemand weiss, ob er etwas tut.
void (async () => {
const [davor, danach, diese] = await Promise.all([
nachbarfolge(anfrage.titelId, 'previous'),
nachbarfolge(anfrage.titelId, 'next'),
folgeLesen(anfrage.titelId),
]);
vorige = davor;
naechste = danach;
marken = diese?.markers ?? [];
if (geschlossen) return;
vorigeKnopf.classList.toggle('hidden', vorige?.files[0] == null);
naechsteKnopf.classList.toggle('hidden', naechste?.files[0] == null);
})();
/** Wechselt auf eine Nachbarfolge.
*
* **Ein neuer Spieler, kein Umbau des alten.** `spieler()` schliesst den
* offenen zuerst — Strom beenden, Stand melden, aufräumen —, und das ist
* genau, was beim Folgenwechsel passieren soll. Die Datei im laufenden
* Spieler auszutauschen hiesse, dieselbe Abfolge ein zweites Mal zu
* schreiben, nur unvollständig.
*/
function folgeWechseln(ziel: Folge | null): void {
const datei = ziel?.files[0];
if (!ziel || !datei) return;
// Angefangen heisst weiterschauen, sonst von vorn: wer vorblättert,
// will die Folge sehen, nicht ihren Abspann.
const stelle = ziel.state?.inProgress ? (ziel.state.positionSeconds ?? 0) : 0;
// **Die Sprache der Serie gilt weiter.** Die Spurnummern unterscheiden
// sich von Folge zu Folge; gemerkt ist die Sprache, und hier wird sie
// zur Nummer dieser Datei. Ohne das spränge die nächste Folge auf die
// erste Tonspur der Datei zurück — bei deutschen Veröffentlichungen
// also auf Deutsch, mitten in einer Serie, die man im Original schaut.
const serie = ziel.seriesId ?? null;
const ton = serie
? spurFuer(datei.streams.filter(s => s.kind === 'Audio'), serienton(serie))
: null;
const text = serie
? spurFuer(datei.streams.filter(s => s.kind === 'Subtitle'), serientext(serie))
: null;
spieler({
dateiId: datei.id,
titelId: ziel.id,
name: `S${ziel.season} E${ziel.episode}${ziel.title ? ` · ${ziel.title}` : ''}`,
ab: stelle,
laenge: datei.durationSeconds,
tonspur: ton,
untertitelspur: text,
danach: anfrage.danach,
});
}
/* ---------- 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 ? `−${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)}%`;
markenstand(jetzt);
}
/** Zeigt den Überspringen-Knopf, solange man in einer Marke steht.
*
* **Keine eigene Uhr.** Die Zeit wird ohnehin bei jedem Fortschreiten
* nachgeführt; ein zweiter Zeitgeber dafür liefe neben dem ersten her und
* wäre eine zweite Stelle, an der etwas hängen bleibt. */
function markenstand(jetzt: number): void {
const drin = marken.find(m => jetzt >= m.startSeconds && jetzt < m.endSeconds);
ueberspringen.classList.toggle('hidden', !drin);
if (!drin) return;
const text = drin.kind === 'intro' ? 'Intro überspringen' : 'Abspann überspringen';
if (ueberspringen.textContent !== text) ueberspringen.textContent = text;
ueberspringen.onclick = e => { e.stopPropagation(); void springen(drin.endSeconds); };
}
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();
// **Auch hier, nicht nur beim Laden.** Wer eine Spur aus- und wieder
// einschaltet, bekommt keine zweite `load`-Meldung — die Datei liegt
// schon da. Ein zweiter Durchlauf über dieselben Einblendungen kostet
// nichts.
[...video.textTracks].forEach(t => {
if (t.mode === 'showing') einblendungenAusrichten(t);
});
// Geholt wird erst beim Einschalten: einen Untertitel aus einer grossen
// Datei zu ziehen kostet den Server das Lesen der ganzen Datei, und die
// meisten Spuren schaltet nie jemand ein.
if (spur !== null) void spurAnhaengen(spur);
}
/** Die Adressen der schon geholten Spuren — damit nichts zweimal läuft. */
const geholt = new Map<number, string>();
function untertitelAnhaengen(): void {
video.querySelectorAll('track').forEach(t => t.remove());
geholt.forEach(adresse => URL.revokeObjectURL(adresse));
geholt.clear();
(auskunft?.subtitleStreams ?? []).forEach(s => {
const t = document.createElement('track');
t.kind = 'subtitles';
t.label = s.label;
// **Nicht abschneiden.** `s.language` ist ein Dreibuchstabencode;
// „ger".slice(0, 2) ergibt „ge", und das ist keine Sprache — Deutsch
// wäre „de". Lieber keine Angabe als eine falsche: die Beschriftung
// steht ohnehin in `label`.
if (s.language?.length === 2) t.srclang = s.language;
t.dataset.spur = String(s.index);
// **Erst wenn die Datei geladen ist, gibt es Einblendungen.** Vorher
// ist `track.cues` leer, und ein Ausrichten liefe ins Nichts.
t.addEventListener('load', () => einblendungenAusrichten(t.track));
video.appendChild(t);
});
if (untertitel !== null) untertitelWaehlen(untertitel);
}
/** Holt eine Spur und hängt sie an — mit Geduld, wenn der Server noch zieht.
*
* Der fertige Text geht als Blob an den `<track>`: dieselbe Anzeige wie
* vorher, nur mit einem Weg dahin, der sagen kann, was los ist. Warum
* nicht `track.src`, steht bei [untertitelHolen] in `api.ts`. */
async function spurAnhaengen(spur: number): Promise<void> {
if (geholt.has(spur)) return;
const element = [...video.querySelectorAll('track')]
.find(t => t.dataset.spur === String(spur));
if (!element) return;
// **Lange nachfragen, und zwar absichtlich.** Der Server holt die Spur
// normalerweise schon im Hintergrund, bevor jemand sie einschaltet;
// landet man trotzdem hier, läuft gerade das Lesen einer grossen Datei,
// und das dauert Minuten. Aufzugeben hiesse, kurz vor dem Ziel
// abzubrechen — zehn Minuten sind der Preis dafür, dass es dann klappt.
for (let versuch = 0; versuch < 200; versuch++) {
if (geschlossen || untertitel !== spur) return;
const stand = await untertitelHolen(anfrage.dateiId, spur, vorlauf);
if (stand.art === 'da') {
const blob = URL.createObjectURL(new Blob([stand.text], { type: 'text/vtt' }));
geholt.set(spur, blob);
element.src = blob;
// Das Anhängen setzt den Modus zurück; nach dem Laden noch einmal
// setzen, sonst bliebe die Spur stumm im Hintergrund.
if (untertitel === spur) untertitelWaehlen(spur);
return;
}
if (stand.art === 'fehler') {
hinweisZeigen(stand.satz, 'fehler');
return;
}
if (versuch === 0) {
hinweisZeigen('Diese Untertitel werden einmalig aus der Datei geholt. Bei einem grossen Film dauert das ein paar Minuten — danach sind sie sofort da.');
}
await new Promise(r => setTimeout(r, 3000));
}
hinweisZeigen('Die Untertitel brauchen ungewöhnlich lange. Schau später noch einmal — im Hintergrund wird weitergelesen.', 'fehler');
}
/* ---------- 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 };