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>
1383 lines
55 KiB
TypeScript
1383 lines
55 KiB
TypeScript
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 };
|