Untertitel so gross und so bunt, wie man sie braucht

Die Voreinstellung des Browsers ist weisse Schrift auf schwarzem Kasten, in
einer Grösse, die er aus der Höhe des Videos rechnet. Auf einem grossen
Bildschirm aus drei Metern Abstand ist das zu klein, und wer schlecht sieht,
braucht es anders.

Einstellbar sind Grösse (60 bis 300 Prozent in Zehnerschritten),
Schriftfarbe (Weiss, Hellgelb, Hellblau, Schwarz) und Hintergrund
(Halbdunkel, Schwarz, Grau, Ohne).

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 hiesse, all das noch einmal zu
schreiben und dabei schlechter zu werden.

Gemessen an einem Video mit echter Spur, Bild für Bild: 200 Prozent in
Hellgelb ohne Kasten und 140 Prozent in Hellblau auf Grau kommen beide so
heraus, wie sie gemeint sind.

Ohne Kasten setzt es von selbst einen Schatten. Weisse Schrift in einer
hellen Szene ist sonst weg, und das wäre eine Einstellung, die sich selbst
unbrauchbar macht. Mit Kasten wäre derselbe Schatten nur Unschärfe.

Die Einstellung sitzt im Untertitelmenü, abgetrennt unter den Spuren — wie
der Tonversatz im Tonmenü. Ob die Schrift gross genug ist, sieht man nur an
einem laufenden Film, in diesem Licht, aus diesem Abstand; wer dafür die
Wiedergabe verlassen muss, stellt zweimal falsch ein, bevor er einmal
richtig liegt.

Gemerkt wird im Browser, nicht am Konto: wie gross Schrift sein muss, hängt
am Bildschirm und am Abstand davor. Dasselbe Konto am Telefon und am
Fernseher will zwei verschiedene Grössen.

Farbfelder statt einer Liste mit Farbnamen — „Hellblau" muss man lesen und
sich vorstellen, ein Feld sieht man. Das gewählte trägt einen Ring aussen
herum und keinen Haken darin: ein Haken wäre auf Weiss unsichtbar und auf
Schwarz erst recht. „Ohne Hintergrund" zeigt ein Karomuster, weil ein
leeres Feld nach Weiss aussähe.

Gelesen wird nur, was bekannt ist. Ein fremder Eintrag im Speicher darf
nicht zu beliebigem CSS in der Seite werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-08 06:43:26 +02:00
co-authored by Claude Opus 5
parent 40f41f0cee
commit 963e5a74ae
3 changed files with 287 additions and 0 deletions
+112
View File
@@ -4,6 +4,11 @@ import {
type Stromauskunft, type Spurauskunft, type Stromauskunft, type Spurauskunft,
} from './api'; } from './api';
import { dauer, knopf, meldung, hinweisZeigen } from './util'; import { dauer, knopf, meldung, hinweisZeigen } from './util';
import {
GROESSE_GROESST, GROESSE_KLEINST, GROESSE_SCHRITT, HINTERGRUENDE, SCHRIFTFARBEN,
stilAnwenden, stilGeaendert, stilLesen, stilSchreiben, stilVorgabe,
type Untertitelstil,
} from './untertitelstil';
import { icons, icon } from './icons'; import { icons, icon } from './icons';
/* ---------- Was der Browser kann ---------- */ /* ---------- Was der Browser kann ---------- */
@@ -444,6 +449,113 @@ export function spieler(anfrage: Spieleranfrage): void {
tonmenue.fuss.append(versatzkopf, versatzregler, versatzzurueck); tonmenue.fuss.append(versatzkopf, versatzregler, versatzzurueck);
/* ---------- Das Aussehen der Untertitel, abgetrennt im Textmenü ---------- */
// **Hier und nicht in den Einstellungen.** Ob die Schrift gross genug ist,
// sieht man nur an einem laufenden Film — und zwar an diesem, in diesem
// Licht, aus diesem Abstand. Wer dafür die Wiedergabe verlassen muss,
// stellt zweimal falsch ein, bevor er einmal richtig liegt.
let stil = stilLesen();
stilAnwenden(stil);
const stilkopf = document.createElement('div');
stilkopf.className = 'pl-fusstitel';
const stilname = document.createElement('span');
stilname.textContent = 'Aussehen';
const stilwert = document.createElement('span');
stilwert.className = 'pl-fusswert';
stilkopf.append(stilname, stilwert);
const groessenregler = document.createElement('input');
groessenregler.type = 'range';
groessenregler.className = 'pl-laut pl-groesse';
groessenregler.min = String(GROESSE_KLEINST);
groessenregler.max = String(GROESSE_GROESST);
groessenregler.step = String(GROESSE_SCHRITT);
groessenregler.setAttribute('aria-label', 'Schriftgrösse der Untertitel in Prozent');
const stilzurueck = knopf('Zurücksetzen', 'pl-punkt');
/** Eine Reihe Farbfelder — die gewählte trägt einen Haken.
*
* **Felder statt einer Liste mit Farbnamen.** „Hellblau" muss man lesen
* und sich vorstellen; ein Feld sieht man. Der Name bleibt trotzdem dran,
* als Beschriftung für alles, was nicht sieht. */
function farbreihe(
beschriftung: string,
werte: readonly { name: string; wert: string }[],
jetzt: () => string,
waehlen: (wert: string) => void,
): HTMLElement {
const reihe = document.createElement('div');
reihe.className = 'pl-farbreihe';
const name = document.createElement('span');
name.className = 'pl-farbname';
name.textContent = beschriftung;
// **Die Felder in einen eigenen Kasten.** Ohne ihn wird jedes Feld eine
// eigene Rasterzelle und die Reihe bricht nach dem zweiten um — gemessen
// am Bildschirm: aus einer Zeile wurden drei, und die Beschriftung stand
// neben dem ersten Feld statt neben allen.
const felder = document.createElement('div');
felder.className = 'pl-farben';
reihe.append(name, felder);
werte.forEach(f => {
const feld = document.createElement('button');
feld.className = 'pl-farbe';
feld.style.setProperty('--farbe', f.wert);
feld.title = f.name;
feld.setAttribute('aria-label', `${beschriftung}: ${f.name}`);
// „Ohne" ist keine Farbe, sondern deren Abwesenheit — das Karomuster
// sagt das, ein leeres Feld sähe nach Weiss aus.
feld.classList.toggle('kariert', f.wert === 'transparent');
feld.onclick = e => { e.stopPropagation(); waehlen(f.wert); };
feld.dataset.wert = f.wert;
felder.appendChild(feld);
});
reihe.onclick = e => e.stopPropagation();
(reihe as HTMLElement & { auffrischen?: () => void }).auffrischen = () => {
reihe.querySelectorAll<HTMLElement>('.pl-farbe').forEach(f => {
f.classList.toggle('an', f.dataset.wert === jetzt());
});
};
return reihe;
}
const schriftreihe = farbreihe('Schrift', SCHRIFTFARBEN,
() => stil.farbe, wert => stilSetzen({ ...stil, farbe: wert }));
const grundreihe = farbreihe('Hintergrund', HINTERGRUENDE,
() => stil.hintergrund, wert => stilSetzen({ ...stil, hintergrund: wert }));
function stilstand(): void {
stilwert.textContent = `${stil.groesse} %`;
groessenregler.value = String(stil.groesse);
groessenregler.setAttribute('aria-valuetext', `${stil.groesse} Prozent`);
stilzurueck.classList.toggle('hidden', !stilGeaendert(stil));
(schriftreihe as HTMLElement & { auffrischen?: () => void }).auffrischen?.();
(grundreihe as HTMLElement & { auffrischen?: () => void }).auffrischen?.();
}
function stilSetzen(neu: Untertitelstil): void {
stil = neu;
stilSchreiben(stil);
stilstand();
}
// Beim Ziehen sofort anwenden: anders als beim Tonversatz kostet das
// nichts, und man will ja im Bild sehen, wie gross es wird.
groessenregler.oninput = () => stilSetzen({ ...stil, groesse: Number(groessenregler.value) });
groessenregler.onclick = e => e.stopPropagation();
stilzurueck.onclick = e => { e.stopPropagation(); stilSetzen(stilVorgabe()); };
textmenue.fuss.append(stilkopf, groessenregler, schriftreihe, grundreihe, stilzurueck);
textmenue.fuss.classList.remove('hidden');
stilstand();
const links = document.createElement('div'); const links = document.createElement('div');
links.className = 'pl-gruppe'; links.className = 'pl-gruppe';
+49
View File
@@ -928,6 +928,55 @@ textarea.feld {
rgba(255, 255, 255, .28); rgba(255, 255, 255, .28);
} }
/* ---------- Das Aussehen der Untertitel ---------- */
.pl-groesse { width: 100%; }
/* Beschriftung links, die Felder rechts — zwei Reihen untereinander, damit
„Schrift" und „Hintergrund" auf derselben Kante stehen. */
.pl-farbreihe {
display: flex; align-items: center; justify-content: space-between;
gap: var(--s-snug);
}
.pl-farben { display: flex; align-items: center; }
.pl-farbname { font-size: 12px; color: var(--text-muted); }
/* Die Felder selbst. Rand in beide Richtungen: ein weisses Feld braucht
einen dunklen Umriss, ein schwarzes einen hellen — ein halbdurchsichtiger
Rand leistet beides auf jedem Untergrund. */
.pl-farbe {
width: 20px; height: 20px; padding: 0;
border-radius: 50%; cursor: pointer;
background: var(--farbe);
border: 1px solid rgba(255, 255, 255, .45);
box-shadow: 0 0 0 1px rgba(0, 0, 0, .5);
}
.pl-farbe + .pl-farbe { margin-left: 6px; }
.pl-farbe:hover { transform: scale(1.12); }
.pl-farbe:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
/* Das gewählte Feld: ein Ring aussen herum, nicht ein Haken darin — ein
Haken wäre auf Weiss unsichtbar und auf Schwarz erst recht. */
.pl-farbe.an {
box-shadow: 0 0 0 1px rgba(0, 0, 0, .5), 0 0 0 3px var(--text);
}
/* „Ohne Hintergrund" ist keine Farbe. Das Karomuster sagt das; ein leeres
Feld sähe nach Weiss aus und wäre damit eine Lüge. */
.pl-farbe.kariert {
background-image:
linear-gradient(45deg, rgba(255, 255, 255, .55) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255, 255, 255, .55) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, .55) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, .55) 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0;
}
@media (prefers-reduced-motion: reduce) {
.pl-farbe:hover { transform: none; }
}
.pl-punkt { .pl-punkt {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
background: none; border: 0; color: var(--text); background: none; border: 0; color: var(--text);
+126
View File
@@ -0,0 +1,126 @@
/** 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;
export const GROESSE_KLEINST = 60;
export const GROESSE_GROESST = 300;
export const GROESSE_SCHRITT = 10;
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;';
const regeln = `video::cue {
font-size: ${stil.groesse}%;
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));
}