From dbd66592a6e0ebb7fd38313d0180f8db7b25aee7 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 10 Oct 2026 14:08:07 +0200 Subject: [PATCH] Untertitel klebten am Bildrand, und ihr Zeilenabstand blieb gross MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Fehler, die sich gegenseitig verstärkt haben. **Der Zeilenabstand kam nicht mit.** `::cue { font-size }` ändert die Schrift, nicht die Zeilenhöhe — die rechnet der Browser weiter aus seiner eigenen Grösse. Bei 50 Prozent stand deshalb winziger Text mit dem Abstand von grossem darunter; zwischen zwei Zeilen klaffte eine dritte. `line-height` als Vielfaches der eigenen Grösse hält das Verhältnis. **Und sie sassen ganz unten.** Ohne Angabe setzt der Browser jede Einblendung an den unteren Rand des Videos. Dort liegt bei uns die Bedienleiste — bei zwei Zeilen verschwand die untere darunter. Die Unterkante sitzt jetzt bei neunzig Prozent der Videohöhe. Gestellt wird das am `VTTCue`, nicht über CSS: `::cue` gestaltet den Text, aber nicht seinen Platz. `lineAlign: "end"` ist dabei der Punkt — damit meint `line` die Unterkante. Ohne ihn stünde ein zweizeiliger Untertitel wieder tiefer als ein einzeiliger, also genau der Fall, um den es ging. Eine Einblendung, die ihre Stelle selbst mitbringt, behält sie: Schilder und Aufschriften stehen dort, wo sie im Bild stehen. Co-Authored-By: Claude Opus 5 --- web/app/src/spieler.ts | 12 +++++++++ web/app/src/untertitelstil.ts | 49 +++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts index ed7a725..241ef5a 100644 --- a/web/app/src/spieler.ts +++ b/web/app/src/spieler.ts @@ -8,6 +8,7 @@ import { GROESSE_GROESST, GROESSE_KLEINST, GROESSE_SCHRITT, HINTERGRUENDE, SCHRIFTFARBEN, stilAnwenden, stilGeaendert, stilLesen, stilSchreiben, stilVorgabe, type Untertitelstil, + einblendungenAusrichten, } from './untertitelstil'; import { icons, icon } from './icons'; @@ -864,6 +865,14 @@ export function spieler(anfrage: Spieleranfrage): void { }); 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. @@ -888,6 +897,9 @@ export function spieler(anfrage: Spieleranfrage): void { // 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); diff --git a/web/app/src/untertitelstil.ts b/web/app/src/untertitelstil.ts index c516635..557e481 100644 --- a/web/app/src/untertitelstil.ts +++ b/web/app/src/untertitelstil.ts @@ -50,6 +50,18 @@ export const GROESSE_KLEINST = 30; export const GROESSE_GROESST = 120; export const GROESSE_SCHRITT = 5; +/** Das Vielfache der Schriftgrösse, das eine Zeile hoch ist. */ +const ZEILENABSTAND = 1.25; + +/** Wo die Unterkante des Untertitels sitzt, in Prozent der Videohöhe. + * + * **Nicht am Bildrand.** Der Browser setzt Einblendungen von sich aus ganz + * nach unten. Dort liegt bei uns die Bedienleiste des Spielers — und bei + * einem zweizeiligen Untertitel verschwand die untere Zeile darunter. Zehn + * Prozent Abstand halten sie frei, unabhängig von der Schriftgrösse und + * davon, ob eine oder zwei Zeilen dastehen. */ +const UNTERKANTE = 90; + export type Untertitelstil = { /** In Prozent der Größe, die der Browser von sich aus wählt. */ groesse: number; @@ -112,8 +124,16 @@ export function stilAnwenden(stil: Untertitelstil): void { ? 'text-shadow: 0 0 4px rgba(0, 0, 0, .9), 0 1px 2px rgba(0, 0, 0, 1);' : 'text-shadow: none;'; + // **Der Zeilenabstand muss mitschrumpfen.** + // + // Ohne diese Zeile rechnet der Browser ihn aus *seiner* Schriftgrösse, + // nicht aus der eingestellten. Bei 50 Prozent stand deshalb winziger Text + // mit dem Abstand von grossem darunter — zwischen zwei Zeilen klaffte eine + // dritte. Ein Vielfaches der eigenen Grösse hält das Verhältnis, egal + // welche gewählt ist. const regeln = `video::cue { font-size: ${stil.groesse}%; + line-height: ${ZEILENABSTAND}; color: ${stil.farbe}; background-color: ${stil.hintergrund}; ${rand} @@ -134,3 +154,32 @@ function zahlImRahmen(wert: unknown, vorgabe: number): number { const gerundet = Math.round(wert / GROESSE_SCHRITT) * GROESSE_SCHRITT; return Math.max(GROESSE_KLEINST, Math.min(GROESSE_GROESST, gerundet)); } + +/** Hebt die Einblendungen einer Spur vom Bildrand ab. + * + * **Warum über die Cues und nicht über CSS.** `::cue` gestaltet den Text, + * aber nicht seinen Platz: die Position steht in der Datei, und der Browser + * setzt sie ohne Angabe ganz nach unten. Erreichbar ist sie nur hier, am + * fertig geladenen `VTTCue`. + * + * **`lineAlign: "end"` ist der eigentliche Trick.** Damit gibt `line` die + * Lage der *Unterkante* an. Ohne ihn wäre es die Oberkante — und dann + * stünde ein zweizeiliger Untertitel wieder weiter unten als ein + * einzeiliger, also genau der Fall, der das Problem war. + * + * Kennt ein Browser `lineAlign` nicht, bleibt der Wert einfach liegen; die + * Einblendung sitzt dann etwas höher als nötig, aber nie zu tief. */ +export function einblendungenAusrichten(spur: TextTrack | null | undefined): void { + if (!spur?.cues) return; + + for (const roh of Array.from(spur.cues)) { + const cue = roh as VTTCue; + // Eine Einblendung, die ihre Stelle selbst mitbringt, behält sie: + // Schilder und Aufschriften stehen dort, wo sie im Bild stehen. + if (cue.line !== 'auto') continue; + + cue.snapToLines = false; + cue.lineAlign = 'end'; + cue.line = UNTERKANTE; + } +}