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