/** Wie Untertitel aussehen — Größe, Schrift- und Hintergrundfarbe. * * **Warum das kein Luxus ist.** Die Voreinstellung des Browsers ist weiße * Schrift auf schwarzem Kasten in einer Größe, die der Browser aus der Höhe * des Videos rechnet. Auf einem großen Bildschirm aus drei Metern Entfernung * ist das zu klein, auf einem hellen Film ohne Kasten unlesbar, und wer * schlecht sieht, braucht beides anders. * * **Gestaltet wird mit `::cue`, nicht mit eigenen Kästen.** Der Browser * zeichnet die Einblendungen selbst — Position, Umbruch, Zeilenabstand und * die Platzangaben aus der Datei. Das nachzubauen hieße, all das noch einmal * zu schreiben und dabei schlechter zu werden. `::cue` greift genau in das * ein, was gewünscht ist, und lässt den Rest in Ruhe. * * **Je Gerät, nicht je Konto.** Wie groß Schrift sein muss, hängt am * Bildschirm und am Abstand davor, nicht an der Person: dasselbe Konto am * Telefon und am Fernseher will zwei verschiedene Größen. Das gehört in den * Browser und nicht auf den Server. */ const SCHLUESSEL = 'flaro.untertitelstil'; /** Die wählbaren Schriftfarben. */ export const SCHRIFTFARBEN = [ { name: 'Weiß', wert: '#ffffff' }, { name: 'Hellgelb', wert: '#f2e14c' }, { name: 'Hellblau', wert: '#8fd6ff' }, { name: 'Schwarz', wert: '#000000' }, ] as const; /** Die wählbaren Hintergründe. */ export const HINTERGRUENDE = [ { name: 'Halbdunkel', wert: 'rgba(0, 0, 0, .75)' }, { name: 'Schwarz', wert: '#000000' }, { name: 'Grau', wert: 'rgba(90, 90, 90, .8)' }, { name: 'Ohne', wert: 'transparent' }, ] as const; /** Der Spielraum der Schriftgrösse, in Prozent der Browservorgabe. * * **Nach unten, nicht nach oben.** Hier standen 60 bis 300 Prozent, und das * war am falschen Ende grosszügig: schon die Vorgabe des Browsers ist auf * einem grossen Bildschirm eher zu gross als zu klein, und 300 Prozent füllt * das halbe Bild. Gebraucht wird der Weg nach unten. * * **Fünferschritte statt Zehner.** Auf einer Spanne von 90 statt 240 Prozent * wiegt ein Zehnerschritt doppelt so schwer; bei 35 Prozent ist der * Unterschied zu 45 Prozent kein Feinschliff mehr, sondern ein Sprung. */ 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; farbe: string; hintergrund: string; }; const VORGABE: Untertitelstil = { groesse: 100, farbe: SCHRIFTFARBEN[0].wert, hintergrund: HINTERGRUENDE[0].wert, }; export function stilLesen(): Untertitelstil { try { const roh = localStorage.getItem(SCHLUESSEL); if (!roh) return { ...VORGABE }; const gelesen = JSON.parse(roh) as Partial; return { groesse: zahlImRahmen(gelesen.groesse, VORGABE.groesse), // Nur bekannte Werte: ein fremder Eintrag im Speicher darf nicht zu // beliebigem CSS in der Seite werden. farbe: SCHRIFTFARBEN.some(f => f.wert === gelesen.farbe) ? gelesen.farbe! : VORGABE.farbe, hintergrund: HINTERGRUENDE.some(h => h.wert === gelesen.hintergrund) ? gelesen.hintergrund! : VORGABE.hintergrund, }; } catch { // Ein unlesbarer Eintrag ist kein Grund, keinen Untertitel zu zeigen. return { ...VORGABE }; } } export function stilSchreiben(stil: Untertitelstil): void { try { localStorage.setItem(SCHLUESSEL, JSON.stringify(stil)); } catch { /* Kür */ } stilAnwenden(stil); } /** Ob überhaupt etwas von der Vorgabe abweicht — für den Zurücksetzen-Knopf. */ export const stilGeaendert = (stil: Untertitelstil): boolean => stil.groesse !== VORGABE.groesse || stil.farbe !== VORGABE.farbe || stil.hintergrund !== VORGABE.hintergrund; export const stilVorgabe = (): Untertitelstil => ({ ...VORGABE }); /** Schreibt die Regeln in die Seite. * * **Ein Stilblock, der ersetzt wird, statt vieler, die sich stapeln.** * `::cue` lässt sich nicht über eine Variable am Element steuern — der * Browser wendet es auf einen eigenen Baum an, den die Seite nicht erreicht. * Also wird die Regel selbst neu geschrieben. */ export function stilAnwenden(stil: Untertitelstil): void { const ohneKasten = stil.hintergrund === 'transparent'; // Ohne Kasten braucht es einen Rand, sonst verschwindet weiße Schrift in // einer hellen Szene. Mit Kasten wäre derselbe Rand nur Unschärfe. const rand = ohneKasten ? '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} }`; let block = document.getElementById(SCHLUESSEL) as HTMLStyleElement | null; if (!block) { block = document.createElement('style'); block.id = SCHLUESSEL; document.head.appendChild(block); } block.textContent = regeln; } function zahlImRahmen(wert: unknown, vorgabe: number): number { if (typeof wert !== 'number' || !Number.isFinite(wert)) return vorgabe; 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; } }