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
+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();
}