Files
Flaro/web/app/src/hero.ts
T
ScarriffleandClaude Opus 5 1d70be0f99 Fünf Aufmacher je Titel, und der Abspann zählt als Schluss
**Der Aufmacher darf wechseln.** Bisher hing hinter jedem Titel genau ein
Hintergrundbild, und das Karussell war nach einer Woche ein Standbild.
Jetzt lassen sich bis zu fünf Aufnahmen aussuchen — im Bilderdialog unter
einem vierten Reiter, gespeist aus denselben Hintergründen.

Welches gerade gilt, entscheidet der Server aus Uhrzeit und Kennung: alle
fünf Stunden ein anderes, überall dasselbe — im Web, in der App, nach
einem Neustart. Ein Zufall je Abruf hiesse, dass das Bild beim Blättern
springt; eine gespeicherte Wahl hiesse einen Schreibvorgang je Titel und
Fenster. Die Kennung geht mit in die Rechnung, damit nicht alle Titel im
selben Augenblick umschalten.

Plakat und Hintergrund bleiben unberührt. Sie stehen hinter Kacheln und
Kopfzeilen, und dort soll nichts wechseln.

**Und der Abspann zählt als Schluss.** Eine Serie hängt sechs Minuten
Abspann an eine Folge von fünfundvierzig; neunzig Prozent liegen dann
mittendrin, und die Folge gilt erst als gesehen, wenn man ihn sich ganz
ansieht. Die Schwelle herunterzudrehen hiesse, jeden anderen Titel zu
früh abzuhaken. Der erkannte Abspann ist die genauere Antwort auf
dieselbe Frage — er zieht die Schwelle nach vorn, nie nach hinten.

Dazu der Verlauf über dem Aufmacher: er war immer noch zu dunkel. Jetzt
trägt der Schattenwurf der Schrift, was vorher eine Decke über dem
ganzen Bild tat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 21:44:43 +02:00

507 lines
19 KiB
TypeScript

import { bildAdresse, type Angefangen, type Film, type Serie } from './api';
import { icons } from './icons';
import { dauer, knopf } from './util';
/** Was im Hero stehen kann — Film, Serie oder eine angefangene Folge. */
export type Heroeintrag = {
/** Wohin „Mehr" führt. Bei einer Folge zur Serie, nicht zur Folge. */
art: 'filme' | 'serien';
id: string;
titel: string;
schriftzug: string | null;
hintergrund: string;
jahr: number | null;
laufzeit: number | null;
freigabe: string | null;
bewertung: number | null;
beschreibung: string | null;
/** Ab wann es den Titel gibt. In der Zukunft heisst: noch nicht. */
verfuegbarAb: string | null;
/** Die Datei, die abgespielt würde. Ohne sie gibt es nichts zu starten. */
dateiId: string | null;
/** Ihre Länge — aus der Mediathek, nicht aus dem Strom. */
laenge: number | null;
/** Wo man stehengeblieben ist. */
stelle: number;
/** Ob der Server den Titel als angefangen führt — die Schwelle ist einstellbar. */
angefangen: boolean;
/** Wem der Fortschritt gehört: bei einer Folge der Folge, nicht der Serie. */
standId: string;
};
const WECHSEL_MS = 9000;
const HOECHSTENS = 7;
/** Der Aufmacher der Startseite.
*
* **Warum überhaupt.** Die Startseite begann bei einer Reihe aus 150 px
* breiten Plakaten — das grösste Element über dem Falz war 225 px hoch. Plex
* und Netflix öffnen mit einem Bild über die volle Breite, und das ist der
* grösste Einzelunterschied im Eindruck. Es ist ein Layout-, kein
* Farbproblem.
*
* **Nur mit Hintergrundbild.** Ein Hero ohne Bild ist schlechter als keiner:
* eine graue Fläche über den halben Bildschirm sagt nichts und nimmt den
* Reihen den Platz. Gibt es keinen einzigen Titel mit Backdrop, entfällt er
* ersatzlos.
*
* **Pause bei Mauszeiger und Fokus.** Wer gerade liest oder mit der Tastatur
* unterwegs ist, soll nicht nach neun Sekunden woanders landen. Und wer
* „Bewegung reduzieren" eingestellt hat, bekommt gar keinen selbsttätigen
* Wechsel — nur die Punkte zum Weiterklicken.
*/
export function hero(
eintraege: Heroeintrag[],
oeffnen: (art: 'filme' | 'serien', id: string) => void,
abspielen: (eintrag: Heroeintrag) => void,
signal?: AbortSignal,
): HTMLElement | null {
const mitBild = eintraege.filter(e => e.hintergrund).slice(0, HOECHSTENS);
if (mitBild.length === 0) return null;
const wurzel = document.createElement('section');
wurzel.className = 'hero';
wurzel.setAttribute('aria-roledescription', 'Karussell');
wurzel.setAttribute('aria-label', 'Vorgestellte Titel');
const buehne = document.createElement('div');
buehne.className = 'hero-buehne';
const folien = mitBild.map((_, i) => {
const folie = document.createElement('div');
folie.className = 'hero-folie' + (i === 0 ? ' an' : '');
folie.setAttribute('aria-hidden', i === 0 ? 'false' : 'true');
buehne.appendChild(folie);
return folie;
});
/** Hängt einer Folie ihr Bild an — und nur dieser, und nur einmal.
*
* **Sieben Bilder auf einmal waren sechs zu viel.** Hier wurde jeder
* Folie sofort ihr Hintergrundbild gesetzt; sichtbar ist aber immer nur
* eine, und es sind `w1280`-Bilder. Beim ersten Aufbau der Startseite
* gingen damit sieben grosse Abrufe gleichzeitig hinaus — über dieselbe
* Leitung, auf der auch die Plakate und die Reihen kommen. */
function bildAnhaengen(i: number): void {
const folie = folien[i];
const eintrag = mitBild[i];
if (!folie || !eintrag || folie.style.backgroundImage) return;
folie.style.backgroundImage = `url("${bildAdresse(eintrag.hintergrund)}")`;
}
bildAnhaengen(0);
// **Der Rest, sobald der Browser Luft hat.** Nicht erst beim
// Weiterschalten: dann sähe man beim ersten Wechsel einen leeren Kasten.
// `requestIdleCallback` kennt nicht jeder Browser — der Rückfall ist ein
// Zeitgeber, und eine Sekunde reicht für den ersten Aufbau.
const nachtragen = () => mitBild.forEach((_, i) => bildAnhaengen(i));
const leerlauf = (window as unknown as {
requestIdleCallback?: (f: () => void, o?: { timeout: number }) => void;
}).requestIdleCallback;
if (leerlauf) leerlauf(nachtragen, { timeout: 3000 });
else window.setTimeout(nachtragen, 1000);
const inhalt = document.createElement('div');
inhalt.className = 'hero-inhalt';
const schriftzug = document.createElement('img');
schriftzug.className = 'hero-schriftzug';
const titel = document.createElement('h1');
titel.className = 'hero-titel';
// Kommt der Schriftzug nicht an, tritt der Name an seine Stelle.
schriftzug.onerror = () => {
schriftzug.classList.add('hidden');
titel.classList.remove('hidden');
};
const meta = document.createElement('div');
meta.className = 'hero-meta';
const text = document.createElement('p');
text.className = 'hero-text';
const spielen = knopf('Abspielen', 'btn btn-primary');
const spielIcon = iconElement(icons.abspielen);
spielen.prepend(spielIcon);
const mehr = knopf('Mehr', 'btn');
// **Statt des Knopfes, wenn es den Titel noch nicht gibt.** Ein Knopf, der
// „Abspielen" sagt und eine Absage bringt, ist schlimmer als keiner.
//
// Hier einzeilig, anders als auf der Detailseite: zwei Zeilen wären höher
// als ein Knopf, und dann rückte beim Umblättern die ganze Reihe auf und ab.
const bald = document.createElement('div');
bald.className = 'verfuegbar-ab hero-bald';
const baldText = document.createElement('span');
bald.append(iconElement(icons.uhr), baldText);
// **Ein Platz fester Breite für das, was vorne steht.**
//
// „Abspielen", „Fortsetzen ab 1:23:45" und der Termin sind verschieden
// breit. Standen sie im selben Fluss, wanderte „Mehr" bei jedem Umblättern
// mit — und das Karussell blättert von allein. Was sich ändert, soll den
// Rest in Ruhe lassen.
const haupt = document.createElement('div');
haupt.className = 'hero-haupt';
haupt.append(bald, spielen);
const knoepfe = document.createElement('div');
knoepfe.className = 'hero-knoepfe';
knoepfe.append(haupt, mehr);
inhalt.append(schriftzug, titel, meta, text, knoepfe);
const punkte = document.createElement('div');
punkte.className = 'hero-punkte';
const punktknoepfe = mitBild.map((e, i) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'hero-punkt' + (i === 0 ? ' an' : '');
// **Echte Knöpfe mit Beschriftung**, keine `<div>`s: mit der Tastatur
// und mit Vorlesesoftware ist ein `<div>` kein Bedienelement.
b.setAttribute('aria-label', `Zu „${e.titel}“`);
b.onclick = () => { zeige(i); anhalten(); };
punkte.appendChild(b);
return b;
});
// **Pfeile am Bildrand.**
//
// Die Punkte allein sind drei Pixel hoch und sagen nur, wo man steht —
// zum Blättern muss man sie treffen wollen. Die Pfeile sind gross, liegen
// immer an derselben Stelle und heißen, was sie tun.
//
// Am Telefon gibt es sie nicht: dort nähme ein Knopf über dem Bild ein
// Drittel der Breite weg, und die Geste heisst dort Wischen.
const pfeile = (['links', 'rechts'] as const).map(richtung => {
const p = document.createElement('button');
p.type = 'button';
p.className = `hero-pfeil hero-pfeil-${richtung}`;
p.innerHTML = icons[richtung];
p.setAttribute('aria-label', richtung === 'links' ? 'Vorheriger Titel' : 'Nächster Titel');
p.onclick = () => { zeige(jetzt + (richtung === 'links' ? -1 : 1)); anhalten(); };
return p;
});
wurzel.append(buehne, inhalt);
if (mitBild.length > 1) {
wurzel.append(...pfeile, punkte);
wischenEinrichten();
}
let jetzt = 0;
let takt: number | undefined;
function zeige(i: number): void {
jetzt = ((i % mitBild.length) + mitBild.length) % mitBild.length;
const e = mitBild[jetzt]!;
// Wer weiterschaltet, bevor der Nachtrag lief, bekommt sein Bild jetzt.
bildAnhaengen(jetzt);
folien.forEach((f, n) => {
f.classList.toggle('an', n === jetzt);
f.setAttribute('aria-hidden', n === jetzt ? 'false' : 'true');
});
punktknoepfe.forEach((b, n) => b.classList.toggle('an', n === jetzt));
// **Der Name steht immer bereit**, auch wenn der Schriftzug gewinnt.
//
// Gemessen: fehlt das Logo im Bildspeicher — es kommt von TMDB und wird
// beim Auffrischen geholt —, lud das `<img>` nicht, und weil der Titel
// daneben versteckt war, stand im Aufmacher ein kaputtes Bild und sonst
// nichts. Ein Name ist besser als ein Platzhalter.
titel.textContent = e.titel;
const logo = bildAdresse(e.schriftzug);
if (logo) {
schriftzug.src = logo;
schriftzug.alt = e.titel;
schriftzug.classList.remove('hidden');
titel.classList.add('hidden');
} else {
schriftzug.removeAttribute('src');
schriftzug.classList.add('hidden');
titel.classList.remove('hidden');
}
meta.textContent = [
e.jahr?.toString(),
e.laufzeit ? laufzeit(e.laufzeit) : null,
e.freigabe,
e.bewertung ? `★ ${e.bewertung.toFixed(1)}` : null,
].filter(Boolean).join(' · ');
// **Nichts zu zeigen heisst: kein Aufklappen.** Bei einer angefangenen
// Folge steht hier oft gar nichts, und bei „S2 · F5 — Der Pakt" wäre das
// Auffahren eine Bewegung um einer Zeile willen, die schon oben steht.
// Mit `.hidden` fällt der Absatz aus dem Fluss und nimmt seinen
// Rasterabstand mit; der Block steht dann bei jeder Folie gleich hoch.
//
// **Hier und nicht am Ende von `zeige`** — weiter unten steht ein `return`
// für noch nicht freigegebene Titel.
const beschreibung = e.beschreibung?.trim() ?? '';
text.textContent = beschreibung;
text.classList.toggle('hidden', beschreibung.length === 0);
hatText = beschreibung.length > 0;
if (!hatText) zuklappen();
anstossen();
mehr.onclick = () => oeffnen(e.art, e.id);
// **Drei Fälle, und jeder sagt die Wahrheit.**
//
// Geplant: kein Knopf, sondern der Termin. Mit Datei: abspielen, und
// zwar wirklich — vorher öffnete „Abspielen" die Detailseite, was
// niemand erwartet. Ohne Datei (eine ganze Serie): „Ansehen", denn es
// gibt keine eine Datei, die gemeint wäre.
const geplantAb = e.verfuegbarAb ? new Date(e.verfuegbarAb) : null;
const nochNicht = geplantAb !== null && geplantAb > new Date();
bald.classList.toggle('hidden', !nochNicht);
spielen.classList.toggle('hidden', nochNicht);
if (nochNicht) {
// Kurz geschrieben, weil es in eine Knopfzeile passen muss. Der ganze
// Satz steht auf der Detailseite.
baldText.textContent = `Ab ${geplantAb!.toLocaleString('de-CH',
{ weekday: 'short', day: 'numeric', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit' })} Uhr`;
return;
}
const beschriftung = e.dateiId === null ? 'Ansehen'
: e.angefangen ? `Fortsetzen ab ${dauer(e.stelle)}`
: 'Abspielen';
spielen.textContent = beschriftung;
spielen.prepend(spielIcon);
spielIcon.classList.toggle('hidden', e.dateiId === null);
spielen.onclick = e.dateiId === null
? () => oeffnen(e.art, e.id)
: () => abspielen(e);
}
function weiter(): void { zeige(jetzt + 1); }
function anhalten(): void {
if (takt !== undefined) { window.clearInterval(takt); takt = undefined; }
}
function starten(): void {
if (takt !== undefined || mitBild.length < 2) return;
// **Kein selbsttätiger Wechsel, wenn das System es abbestellt hat.**
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
takt = window.setInterval(weiter, WECHSEL_MS);
}
/** Wischen — die Geste, wo es keine Pfeile gibt.
*
* **Nur waagerecht, und erst ab einer Strecke.** Senkrecht blättert die
* Seite; fasste der Aufmacher jede Bewegung ab, käme man an ihm nicht mehr
* vorbei. Deshalb entscheidet die erste erkennbare Richtung, wem die Geste
* gehört — und unter der Schwelle gehört sie niemandem.
*/
function wischenEinrichten(): void {
const SCHWELLE = 48;
let start: { x: number; y: number } | null = null;
let waagerecht = false;
wurzel.addEventListener('pointerdown', e => {
if (e.pointerType === 'mouse') return;
start = { x: e.clientX, y: e.clientY };
waagerecht = false;
});
wurzel.addEventListener('pointermove', e => {
if (start === null) return;
const dx = e.clientX - start.x;
const dy = e.clientY - start.y;
if (!waagerecht && Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 12) waagerecht = true;
});
const beenden = (e: PointerEvent): void => {
if (start === null) return;
const dx = e.clientX - start.x;
start = null;
if (!waagerecht || Math.abs(dx) < SCHWELLE) return;
zeige(jetzt + (dx < 0 ? 1 : -1));
anhalten();
};
wurzel.addEventListener('pointerup', beenden);
wurzel.addEventListener('pointercancel', () => { start = null; });
}
/* ---------- Die Inhaltsangabe klappt auf ---------- */
/** Wie lange der Zeiger auf dem Titel stehen muss. */
const GEDULD_MS = 2000;
let hatText = false;
let wartet: number | undefined;
let angemeldet = false;
/** Misst die aufgeklappte Höhe und schreibt sie als Eigenschaft.
*
* **Kurz auf `auto` und wieder zurück.** Dazwischen wird nicht gezeichnet —
* beides liegt in derselben Aufgabe. `scrollHeight` ginge nicht: bei einem
* `-webkit-box` meldet der die *ungeklammerte* Höhe, bei sechs Zeilen also
* das Doppelte dessen, was je zu sehen ist. */
function messen(): void {
if (!hatText) return;
const vorher = text.style.height;
text.style.height = 'auto';
const hoehe = Math.round(text.getBoundingClientRect().height);
text.style.height = vorher;
inhalt.style.setProperty('--hero-texthoehe', `${hoehe}px`);
}
/** Einmal je Bild, nicht einmal je Ereignis — wie `weichNachBildlauf`. */
function anstossen(): void {
if (angemeldet) return;
angemeldet = true;
requestAnimationFrame(() => { angemeldet = false; messen(); });
}
function aufklappen(): void {
if (!hatText) return;
messen(); // Erst messen, dann schalten.
inhalt.classList.add('aufgeklappt');
}
function zuklappen(): void {
if (wartet !== undefined) { window.clearTimeout(wartet); wartet = undefined; }
inhalt.classList.remove('aufgeklappt');
}
// **Angefasst wird am Titel, losgelassen am ganzen Aufmacher.**
//
// Das ist die ganze Vorsicht an dieser Stelle. Klappt die Inhaltsangabe auf,
// wächst `.hero-inhalt` nach oben — der Titel rückt um zwanzig bis siebzig
// Bildpunkte weg und steht nicht mehr unter dem Zeiger. Hinge das Zuklappen
// an seinem `:hover`, wäre das die erste Hälfte einer Schaukel: weg vom
// Zeiger, zu, wieder unter den Zeiger, auf, und so fort im Takt der
// Überblendung.
//
// Der Aufmacher selbst kann das nicht: er ist fest hoch (`min(72vh, 640px)`,
// am Telefon weniger — die Zahl ändert nichts am Argument).
// Sein Rechteck ändert sich beim Aufklappen um keinen Bildpunkt, ein Zeiger
// darin kann also nicht hinausgeschoben werden.
const anfassen = (e: PointerEvent): void => {
// Ohne Zeiger kein Verweilen — am Finger steht die Inhaltsangabe ohnehin
// die ganze Zeit da, siehe `hover: hover` im Regelwerk.
if (e.pointerType === 'touch') return;
if (!hatText || inhalt.classList.contains('aufgeklappt')) return;
window.clearTimeout(wartet);
wartet = window.setTimeout(() => { wartet = undefined; aufklappen(); }, GEDULD_MS);
};
/** Bricht nur das Warten ab, nie das Aufgeklappte.
*
* Ist es offen, ist dieses Verlassen gerade das, was das Aufklappen selbst
* verursacht hat. */
const loslassen = (): void => {
if (wartet !== undefined) { window.clearTimeout(wartet); wartet = undefined; }
};
[schriftzug, titel].forEach(el => {
el.addEventListener('pointerenter', anfassen);
el.addEventListener('pointerleave', loslassen);
});
// Drei Zeilen sind bei 620 px Breite andere als bei 380.
window.addEventListener('resize', anstossen, { passive: true });
wurzel.addEventListener('mouseenter', anhalten);
wurzel.addEventListener('mouseleave', () => { starten(); zuklappen(); });
wurzel.addEventListener('focusin', anhalten);
wurzel.addEventListener('focusout', starten);
signal?.addEventListener('abort', () => {
anhalten();
loslassen();
window.removeEventListener('resize', anstossen);
});
zeige(0);
starten();
return wurzel;
}
/** „2 Std. 14 Min." — aus Minuten, wie TMDB sie liefert. */
function laufzeit(minuten: number): string {
const h = Math.floor(minuten / 60), m = minuten % 60;
return h > 0 ? `${h} Std. ${m} Min.` : `${m} Min.`;
}
/** Ein Icon als Element — `innerHTML` am Knopf löschte dessen Text. */
function iconElement(svg: string): HTMLElement {
const s = document.createElement('span');
s.className = 'icon';
s.innerHTML = svg;
return s;
}
/** Baut Heroeinträge aus dem, was die Startseite ohnehin geholt hat. */
export function alsHero(eintrag: Film | Serie, art: 'filme' | 'serien'): Heroeintrag {
const datei = 'files' in eintrag ? eintrag.files[0] ?? null : null;
return {
art,
id: eintrag.id,
titel: eintrag.title,
schriftzug: eintrag.logoImage,
// **Das Aufmacherbild sticht den Hintergrund.** Wer mehrere Aufnahmen
// ausgesucht hat, bekommt hier die, die gerade gilt — der Server
// entscheidet das, damit Web und App dasselbe zeigen. Ohne Auswahl
// bleibt es beim Hintergrund.
hintergrund: eintrag.heroImage ?? eintrag.backdropImage ?? '',
jahr: eintrag.year,
laufzeit: eintrag.runtimeMinutes,
freigabe: eintrag.contentRating,
bewertung: eintrag.rating,
beschreibung: eintrag.overview,
verfuegbarAb: eintrag.publishAt,
dateiId: datei?.id ?? null,
laenge: datei?.durationSeconds ?? null,
stelle: eintrag.state?.positionSeconds ?? 0,
angefangen: eintrag.state?.inProgress ?? (eintrag.state?.positionSeconds ?? 0) > 0,
standId: eintrag.id,
};
}
/** Dasselbe für eine angefangene Zeile.
*
* **Eine Folge ist kein Film.** Vorher lief auch sie durch `alsHero` — mit
* `art: 'filme'` und der Kennung der Folge. Wer den Aufmacher anklickte,
* landete auf `#/filme/<Folgenkennung>` und las „Diesen Film gibt es nicht
* mehr". Die Detailseite gehört der Serie; der Fortschritt der Folge.
*/
export function angefangenAlsHero(eintrag: Angefangen): Heroeintrag {
const folge = eintrag.kind === 'episode';
return {
art: folge ? 'serien' : 'filme',
id: folge ? eintrag.seriesId ?? eintrag.id : eintrag.id,
titel: eintrag.title,
schriftzug: null,
// **Das Bild der Serie, nicht das der Folge.** `backdropImage` ist bei
// einer Folge ihr eigenes Standbild — richtig für die Kachel, falsch für
// eine Fläche über den halben Bildschirm: wer der Serie ein Bild
// ausgesucht hat, fand hier ein anderes vor.
hintergrund: eintrag.heroImage ?? eintrag.seriesBackdropImage ?? eintrag.backdropImage ?? '',
jahr: eintrag.year,
laufzeit: null,
freigabe: null,
bewertung: null,
beschreibung: folge && eintrag.season !== null
? `S${eintrag.season} · F${eintrag.episode}${eintrag.subTitle ? ` — ${eintrag.subTitle}` : ''}`
: null,
verfuegbarAb: eintrag.publishAt ?? null,
dateiId: eintrag.fileId ?? null,
laenge: eintrag.durationSeconds ?? eintrag.state.runtimeSeconds,
stelle: eintrag.state.positionSeconds,
// Was in „Weiterschauen" steht, ist per Definition angefangen.
angefangen: eintrag.state.inProgress ?? eintrag.state.positionSeconds > 0,
standId: eintrag.id,
};
}