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 <noreply@anthropic.com>
186 lines
7.2 KiB
TypeScript
186 lines
7.2 KiB
TypeScript
/** 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<Untertitelstil>;
|
|
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;
|
|
}
|
|
}
|