Er packte um, was gar nicht am Umpacken lag

Der Browser bekam stillschweigend Apples Geräteprofil — und mit ihm HEVC, AC3
und EAC3, von denen er keines öffnet. Der Server packte die Datei
pflichtschuldig nach MP4 um, liess Bild und Ton dabei unberührt, und heraus
kam eine tadellose Datei, mit der Chrome nichts anfangen konnte. Umpacken
fasst das Bild nun einmal nicht an.

Jetzt sagt das Gerät selbst, was es kann, und es gibt einen zweiten Weg neben
der fertigen Datei: HLS, das nach Sekunden anfängt statt nach Minuten.
Dasselbe Rohr erledigt beides — mkv nach fMP4 mit „-c:v copy", 4K nach 480p
mit libx264 — und eine MP4, die der Browser ohnehin öffnet, geht ganz ohne
ffmpeg hinaus.

Dazu eine eigene Steuerleiste: Tonspur und Untertitel während der Wiedergabe
umschaltbar, vier Auflösungsstufen, Tastenkürzel, und bei einem Fehler ein
deutscher Satz statt eines schwarzen Bildes.

Gemessen statt vermutet: Chromium antwortet auf canPlayType für HLS mit
"maybe" und kann es dann doch nicht — deshalb entscheidet Hls.isSupported().
hls.js wiegt 372 KB und wird erst beim Abspielen geholt; die Oberfläche
bleibt bei 109.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-05 18:52:27 +02:00
co-authored by Claude Opus 5
parent 557708b769
commit 4853200d5c
16 changed files with 2894 additions and 65 deletions
+44
View File
@@ -494,6 +494,50 @@ export const favorit = (id: string, wert: boolean) =>
export const downloadAdresse = (datei: string) => `/api/v1/files/${datei}/download`;
/* ---------- Strom ---------- */
/** Eine wählbare Spur, so wie sie im Menü steht. */
export type Spurauskunft = {
index: number; label: string; language: string | null; isDefault: boolean;
};
export type Stromauskunft = {
sessionId: string;
url: string;
/** „direct", „remux" oder „transcode". */
method: 'direct' | 'remux' | 'transcode';
/** Ab welcher Sekunde des Films dieser Strom beginnt — der Spieler zählt ab null. */
startSeconds: number;
height: number | null;
audioStream: number | null;
audioStreams: Spurauskunft[];
subtitleStreams: Spurauskunft[];
};
export type Stromwunsch = {
containers: string[]; videoCodecs: string[]; audioCodecs: string[];
height?: number | null;
audioStream?: number | null;
startSeconds?: number;
};
/** Legt einen Strom an, der sofort anfängt.
*
* **Das Gerät sagt, was es kann.** Ohne diese Angabe nimmt der Server Apples
* Profil an — und das erlaubt HEVC und EAC3, von denen kein Browser etwas
* öffnet. */
export const stromStarten = (datei: string, wunsch: Stromwunsch) =>
anfrage<Stromauskunft>(`/files/${datei}/stream`, {
method: 'POST', body: JSON.stringify(wunsch),
});
/** Beendet einen Strom und räumt seinen Kratzplatz weg. */
export const stromBeenden = (sitzung: string) =>
anfrage<void>(`/stream/${sitzung}`, { method: 'DELETE' });
export const untertitelAdresse = (datei: string, spur: number) =>
`/api/v1/files/${datei}/subtitles/${spur}.vtt`;
/* ---------- Verwaltung ---------- */
export const bibliotheken = () => anfrage<Bibliothek[]>('/libraries');
+10
View File
@@ -0,0 +1,10 @@
/* Die schlanke Fassung von hls.js bringt keine eigenen Typen mit.
*
* **Sie hat dieselbe Schnittstelle** — ihr fehlen nur Fähigkeiten, die wir
* nicht benutzen: getrennte Tonspuren im Strom, Untertitel in der Playlist,
* Kopierschutz. Die Typen der vollständigen Fassung zu übernehmen ist deshalb
* richtig und nicht bloss bequem. */
declare module 'hls.js/light' {
export * from 'hls.js';
export { default } from 'hls.js';
}
+15
View File
@@ -50,6 +50,21 @@ export const icons = {
+ '<path d="M10 16.5 14.5 12 10 7.5"/><path d="M14.5 12H4"/>'),
schliessen: rahmen('<path d="M6 6l12 12M18 6 6 18"/>'),
stern: rahmen('<path d="m12 3.6 2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.5 9.8l5.9-.9z"/>'),
/* --- Spieler -------------------------------------------------------- */
pause: rahmen('<rect x="7" y="5" width="3.5" height="14" rx="1"/>'
+ '<rect x="13.5" y="5" width="3.5" height="14" rx="1"/>', true),
ton: rahmen('<path d="M4 9.5h3.5L12 5.5v13L7.5 14.5H4z"/>'
+ '<path d="M16 9a4.5 4.5 0 0 1 0 6"/><path d="M18.5 6.5a8 8 0 0 1 0 11"/>'),
stummGeschaltet: rahmen('<path d="M4 9.5h3.5L12 5.5v13L7.5 14.5H4z"/>'
+ '<path d="m16.5 9.5 5 5M21.5 9.5l-5 5"/>'),
vollbild: rahmen('<path d="M4 9V5h4"/><path d="M20 9V5h-4"/>'
+ '<path d="M4 15v4h4"/><path d="M20 15v4h-4"/>'),
untertitel: rahmen('<rect x="3" y="5" width="18" height="14" rx="2"/>'
+ '<path d="M7 14.5h4"/><path d="M13 14.5h4"/>'),
zahnrad: rahmen('<circle cx="12" cy="12" r="3.2"/>'
+ '<path d="M12 2.8v2.4M12 18.8v2.4M4.5 7.5l2.1 1.2M17.4 15.3l2.1 1.2'
+ 'M4.5 16.5l2.1-1.2M17.4 8.7l2.1-1.2"/>'),
} as const;
export type IconName = keyof typeof icons;
+673
View File
@@ -0,0 +1,673 @@
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 ein Film, der vielleicht läuft, ist
* besser als einer, der sicher umgerechnet wird.
*/
export function geraeteprofil(): {
containers: string[]; videoCodecs: string[]; audioCodecs: string[];
} {
const v = document.createElement('video');
const kann = (typ: string): boolean => v.canPlayType(typ) !== '';
const schachteln = ['mp4'];
if (kann('video/webm')) schachteln.push('webm');
const bild: string[] = [];
// H.264 kann jeder Browser, der Video kann — die Prüfung steht trotzdem da,
// weil „jeder" eine Behauptung ist und `canPlayType` eine Auskunft.
if (kann('video/mp4; codecs="avc1.640029"')) bild.push('h264', 'avc1');
if (kann('video/mp4; codecs="hvc1.1.6.L93.B0"')
|| kann('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/mpeg')) 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/ogg; codecs="opus"') || kann('audio/webm; codecs="opus"')) ton.push('opus');
if (kann('audio/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;
/** 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;
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.
*/
export function spieler(anfrage: Spieleranfrage): void {
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;
/** Die gewählte Stufe und Spuren — sie überleben einen Neustart des Stroms. */
let stufe: number | null = null;
let tonspur: number | null = null;
let untertitel: number | null = null;
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 zu = document.createElement('button');
zu.className = 'pl-rund';
zu.innerHTML = icons.schliessen;
zu.title = 'Schliessen (Esc)';
zu.setAttribute('aria-label', 'Schliessen');
zu.onclick = () => schliessen();
kopf.append(name, zu);
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';
schicht.append(video, dreher, mitte, kopf, bedienung, meldungsfeld);
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. */
const tonmenue = menue('ton', 'Tonspur');
const textmenue = menue('untertitel', 'Untertitel');
const stufenmenue = menue('zahnrad', 'Qualität');
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)'; };
video.onpause = () => {
abspielknopf.innerHTML = icons.abspielen;
abspielknopf.title = 'Abspielen (Leertaste)';
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 = () => 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)}%`);
}
function umschalten(): void {
if (video.paused) void video.play().catch(() => zeigeFehler(videofehler()));
else video.pause();
}
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,
});
} 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;
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 ---------- */
function menuesAuffrischen(): void {
tonmenue.setzen(
(auskunft?.audioStreams ?? []).map(s => ({
text: s.label, gewaehlt: s.index === tonspur,
tun: () => { tonspur = s.index; void stromAufbauen(filmzeit()); },
})));
tonmenue.knopf.classList.toggle('hidden', (auskunft?.audioStreams.length ?? 0) < 2);
textmenue.setzen([
{ text: 'Aus', gewaehlt: untertitel === null, tun: () => untertitelWaehlen(null) },
...(auskunft?.subtitleStreams ?? []).map(s => ({
text: s.label, gewaehlt: s.index === untertitel,
tun: () => untertitelWaehlen(s.index),
})),
]);
textmenue.knopf.classList.toggle('hidden', (auskunft?.subtitleStreams.length ?? 0) === 0);
stufenmenue.setzen(STUFEN.map(s => ({
text: s.wert === null && auskunft?.method === 'transcode' && auskunft.height
? 'Original'
: 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 ---------- */
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:
return 'Der Browser konnte das Bild nicht dekodieren. '
+ 'Versuche eine kleinere Auflösung über das Zahnrad.';
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
return 'Dieser Browser kann die Spuren dieser Datei nicht öffnen. '
+ 'Über das Zahnrad lässt sich eine Fassung wählen, die er versteht.';
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 kam mit dem Bild nicht zurecht. '
+ 'Versuche eine kleinere Auflösung über das Zahnrad.';
}
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;
case 'Escape': e.preventDefault(); schliessen(); 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 zurückgeht, will nicht weiter zusehen.** Der Spieler hängt an
// `document.body` und nicht am Inhaltsbereich; ohne diese Zeile blieb er
// über der neuen Seite stehen, und ffmpeg lief dazu weiter.
const beiNavigation = (): void => schliessen();
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 = zeigen;
schicht.onclick = e => {
// 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;
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');
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; 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';
huelle.append(ausloeser, liste);
return {
knopf: huelle,
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);
});
},
};
}
/* ---------- Los ---------- */
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 };
+134 -3
View File
@@ -718,9 +718,140 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
.btn-gefahr { background: var(--destructive); color: #fff; font-weight: 600; }
.btn-gefahr:hover { background: var(--destructive-dim); }
/* Der Spieler über allem. */
.pl-fill {
position: fixed; inset: 0; z-index: 100; background: #000;
/* ---------- Der Spieler ----------
*
* **Eigene Steuerung statt der eingebauten.** Die Leiste des Browsers kennt
* weder Tonspuren noch Qualitätsstufen — und die Tonspurwahl ist der Grund,
* aus dem es diesen Server gibt. Ausserdem sah sie, wie der Benutzer sagte,
* „gefühlt von 1930" aus. */
.pl {
position: fixed; inset: 0; z-index: 150; background: #000;
display: grid; place-items: center; outline: none;
}
.pl-video { width: 100%; height: 100%; object-fit: contain; background: #000; }
.pl-dreher { position: absolute; margin: 0; }
/* Kopf und Leiste blenden sich weg, während etwas läuft. */
.pl-kopf, .pl-leiste {
position: absolute; left: 0; right: 0; z-index: 2;
display: flex; align-items: center; gap: var(--s-regular);
padding: 14px 18px;
transition: opacity .2s ease-out, transform .2s ease-out;
}
.pl-kopf {
top: 0; justify-content: space-between;
background: linear-gradient(to bottom, rgba(0, 0, 0, .78), transparent);
padding-top: max(14px, env(safe-area-inset-top));
}
.pl-leiste {
bottom: 0; flex-direction: column; align-items: stretch; gap: 8px;
background: linear-gradient(to top, rgba(0, 0, 0, .86), transparent);
padding-bottom: max(14px, env(safe-area-inset-bottom));
}
.pl-still .pl-kopf { opacity: 0; transform: translateY(-8px); pointer-events: none; }
.pl-still .pl-leiste { opacity: 0; transform: translateY(8px); pointer-events: none; }
.pl-still .pl-video { cursor: none; }
.pl-titel {
font-size: 15px; font-weight: 600; color: #fff;
text-shadow: 0 1px 3px rgba(0, 0, 0, .7);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pl-unten { display: flex; align-items: center; justify-content: space-between; gap: var(--s-regular); }
.pl-gruppe { display: flex; align-items: center; gap: 6px; min-width: 0; }
/* **44 Bildpunkte, nicht 32.** Darunter trifft ein Finger daneben, und ein
Fehlgriff auf der Zeitleiste wirft einen aus dem Film. */
.pl-rund {
display: grid; place-items: center;
width: 44px; height: 44px; flex: 0 0 auto;
border: 0; border-radius: 50%; background: transparent; color: #fff;
cursor: pointer; transition: background .15s;
}
.pl-rund:hover, .pl-rund:focus-visible { background: rgba(255, 255, 255, .16); }
.pl-rund svg { width: 22px; height: 22px; }
.pl-stand, .pl-rest {
font-size: 13px; color: rgba(255, 255, 255, .88);
font-variant-numeric: tabular-nums; white-space: nowrap;
}
/* Zeit- und Lautstärkeregler: dieselbe Form, verschiedene Breite. */
.pl-zeit, .pl-laut {
-webkit-appearance: none; appearance: none;
height: 20px; background: transparent; cursor: pointer; margin: 0;
}
.pl-zeit { width: 100%; }
.pl-laut { width: 84px; flex: 0 0 auto; }
.pl-zeit::-webkit-slider-runnable-track, .pl-laut::-webkit-slider-runnable-track {
height: 4px; border-radius: 2px;
background: linear-gradient(to right, var(--accent) var(--anteil, 0%),
rgba(255, 255, 255, .28) var(--anteil, 0%));
}
.pl-laut::-webkit-slider-runnable-track { background: rgba(255, 255, 255, .28); }
.pl-zeit::-moz-range-track, .pl-laut::-moz-range-track {
height: 4px; border-radius: 2px; background: rgba(255, 255, 255, .28);
}
.pl-zeit::-moz-range-progress { height: 4px; border-radius: 2px; background: var(--accent); }
.pl-zeit::-webkit-slider-thumb, .pl-laut::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 14px; height: 14px; margin-top: -5px;
border-radius: 50%; background: #fff; border: 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, .5);
}
.pl-zeit::-moz-range-thumb, .pl-laut::-moz-range-thumb {
width: 14px; height: 14px; border-radius: 50%; background: #fff; border: 0;
}
.pl-zeit:focus-visible, .pl-laut:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
/* Die Menüs gehen nach oben auf — nach unten wäre ausserhalb des Bildes. */
.pl-menue { position: relative; }
.pl-liste {
position: absolute; bottom: calc(100% + 8px); right: 0;
display: none; flex-direction: column; min-width: 180px;
padding: 6px; background: rgba(20, 20, 23, .96);
border: 1px solid var(--line); border-radius: var(--r-karte);
box-shadow: 0 12px 36px rgba(0, 0, 0, .6);
max-height: 50vh; overflow-y: auto;
}
.pl-menue.auf .pl-liste { display: flex; }
.pl-punkt {
display: flex; align-items: center; gap: 8px;
background: none; border: 0; color: var(--text);
padding: 9px 12px; border-radius: var(--r-control);
font-size: 14px; text-align: left; cursor: pointer; white-space: nowrap;
}
.pl-punkt:hover, .pl-punkt:focus-visible { background: var(--raised); }
.pl-punkt .icon { width: 16px; height: 16px; flex: 0 0 auto; }
.pl-punkt .icon svg { width: 16px; height: 16px; }
/* Ohne Haken bleibt der Platz stehen — sonst springt die Beschriftung. */
.pl-punkt:not(.an) { padding-left: 36px; }
.pl-meldung {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
z-index: 3; max-width: min(520px, calc(100vw - 48px));
padding: var(--s-regular) var(--s-section);
background: rgba(20, 20, 23, .96); color: var(--text);
border: 1px solid rgba(229, 72, 77, .45); border-radius: var(--r-karte);
text-align: center; line-height: 1.5; font-size: 15px;
}
/* Hinter dem Spieler soll nichts scrollen. */
body.pl-offen { overflow: hidden; }
@media (max-width: 640px) {
.pl-laut { display: none; }
.pl-rest { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.pl-kopf, .pl-leiste { transition: none; }
}
/* ---------- Schmale Bildschirme ---------- */
+28 -62
View File
@@ -1,6 +1,6 @@
import { ApiFehler,
film as filmLaden, serie as serieLaden, folgen, lieferung, downloadAdresse,
vorschlaege, zuordnen, bildAdresse, istAdmin, fortschritt, gesehen, favorit, weglegen,
vorschlaege, zuordnen, bildAdresse, istAdmin, gesehen, favorit, weglegen,
type Film, type Serie, type Folge, type Datei, type Lieferung, type Stand,
type Mitwirkender, aehnliche,
} from '../api';
@@ -10,6 +10,7 @@ import {
import { icons, icon } from '../icons';
import { kachel, alsKachel, reihe } from '../card';
import { vergessen } from '../ansichten';
import { spieler } from '../spieler';
type Art = 'filme' | 'serien';
@@ -227,6 +228,11 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
/* ---------- Knöpfe ---------- */
/** Wie der Titel über dem Spieler steht. */
function titelzeile(eintrag: Film | Serie | { title?: string }): string {
return 'title' in eintrag && eintrag.title ? eintrag.title : 'Wiedergabe';
}
function aktionsreihe(eintrag: Film | Serie, stand: Stand | null, datei: Datei | null): HTMLElement {
const reihe = document.createElement('div');
reihe.className = 'aktionen';
@@ -237,12 +243,12 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
const stelle = stand?.positionSeconds ?? 0;
const los = knopf(stelle > 0 ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
los.prepend(icon('abspielen'));
los.onclick = () => void starten(datei, eintrag.id, stelle, los, hinweisfeld);
los.onclick = () => starten(datei, eintrag.id, titelzeile(eintrag), stelle);
reihe.appendChild(los);
if (stelle > 0) {
const vonVorn = knopf('Von vorn', 'btn');
vonVorn.onclick = () => void starten(datei, eintrag.id, 0, vonVorn, hinweisfeld);
vonVorn.onclick = () => starten(datei, eintrag.id, titelzeile(eintrag), 0);
reihe.appendChild(vonVorn);
}
@@ -527,6 +533,11 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
/* ---------- Abspielen und Herunterladen ---------- */
/** Sorgt dafür, dass die Datei abholbereit ist — packt um, falls nötig. */
/** Baut die Datei für den **Download** auf — dort ist eine fertige richtig.
*
* **Nicht mehr fürs Abspielen.** Dafür gibt es den Strom, der sofort
* anfängt; hier auf eine vollständige MP4 zu warten hiess bei einem
* 4K-Film: Minuten, in denen sichtbar nichts geschieht. */
async function bereitstellen(datei: Datei, hinweisfeld: HTMLElement): Promise<boolean> {
let auskunft: Lieferung;
try { auskunft = await lieferung(datei.id); }
@@ -555,12 +566,20 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
return false;
}
async function starten(
datei: Datei, titel: string, ab: number, knopfDavon: HTMLButtonElement, hinweisfeld: HTMLElement,
): Promise<void> {
knopfDavon.disabled = true;
if (await bereitstellen(datei, hinweisfeld)) spieler(downloadAdresse(datei.id), titel, ab, laden);
knopfDavon.disabled = false;
/** Startet die Wiedergabe — ohne Vorlauf, ohne Warten.
*
* **Der Spieler baut den Strom selbst auf**, weil er ihn auch wieder
* abbauen und bei jedem Wechsel der Auflösung neu anlegen muss. Ihn hier
* fertig anzureichen hiesse, dieselbe Arbeit an zwei Stellen zu haben. */
function starten(datei: Datei, titelId: string, name: string, ab: number): void {
spieler({
dateiId: datei.id,
titelId,
name,
ab,
laenge: datei.durationSeconds,
danach: laden,
});
}
async function herunterladen(
@@ -650,56 +669,3 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
return wurzel;
}
/* ---------- Der Spieler ---------- */
/** Wie oft der Stand zum Server geht. */
const MELDEABSTAND = 10_000;
function spieler(adresse: string, titel: string, ab: number, danach: () => void): void {
const schicht = document.createElement('div');
schicht.className = 'pl-fill';
const video = document.createElement('video');
video.src = adresse;
video.controls = true;
video.autoplay = true;
video.style.cssText = 'width:100%;height:100%;background:#000';
if (ab > 0) video.currentTime = ab;
// **Alle zehn Sekunden, nicht bei jedem Bild.** `timeupdate` feuert
// viermal je Sekunde; daraus Anfragen zu machen hiesse, den Server mit
// Meldungen zu bewerfen, die sich um 250 ms unterscheiden.
let zuletzt = 0;
const melden = (erzwingen = false, beendet = false) => {
const jetzt = Date.now();
if (!erzwingen && jetzt - zuletzt < MELDEABSTAND) return;
zuletzt = jetzt;
const laenge = Number.isFinite(video.duration) ? video.duration : null;
// Eine verlorene Meldung kostet ein paar Sekunden Fortschritt. Dafür
// mitten im Film eine Fehlermeldung zu zeigen wäre die schlechtere
// Hälfte des Tauschs.
void fortschritt(titel, video.currentTime, laenge, beendet).catch(() => {});
};
video.ontimeupdate = () => melden();
video.onpause = () => melden(true);
video.onended = () => melden(true, true);
const schliessen = knopf('Schliessen', 'btn');
schliessen.style.cssText = 'position:absolute;top:16px;right:16px;z-index:2';
schliessen.onclick = () => {
video.pause();
// **Beendet melden, nicht nur den Stand.** Sonst steht der Film im
// Dashboard noch eine Minute als „läuft", und wer gerade geschlossen
// hat, sieht sich selbst beim Schauen zu.
melden(true, true);
schicht.remove();
danach();
};
schicht.append(video, schliessen);
schicht.onkeydown = e => { if (e.key === 'Escape') schliessen.click(); };
document.body.appendChild(schicht);
video.focus();
}