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