diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts index 524d6fe..ed7a725 100644 --- a/web/app/src/spieler.ts +++ b/web/app/src/spieler.ts @@ -4,6 +4,11 @@ import { type Stromauskunft, type Spurauskunft, } from './api'; 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'; /* ---------- Was der Browser kann ---------- */ @@ -444,6 +449,113 @@ export function spieler(anfrage: Spieleranfrage): void { 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('.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'); links.className = 'pl-gruppe'; diff --git a/web/app/src/style.css b/web/app/src/style.css index 4ee80c2..cb6f9fe 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -928,6 +928,55 @@ textarea.feld { 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 { display: flex; align-items: center; gap: 8px; background: none; border: 0; color: var(--text); diff --git a/web/app/src/untertitelstil.ts b/web/app/src/untertitelstil.ts new file mode 100644 index 0000000..583384c --- /dev/null +++ b/web/app/src/untertitelstil.ts @@ -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; + 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)); +}