Ein Aufmacher über die volle Breite

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 ganze Breite, und das ist der grösste Einzelunterschied
im Eindruck. Ein Layout-, kein Farbproblem.

Bis zu sieben Titel, aus dem, was die Startseite ohnehin holt: erst
Angefangenes, dann Neuzugänge, dann Vorschläge. Kein zusätzlicher Abruf.

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.

Zwei Verläufe statt einem: der von unten lässt die erste Reihe sich
darüberschieben, der von links sorgt dafür, dass der Text trägt, auch wenn
das Bild dort hell ist.

Pause bei Mauszeiger und bei Tastaturfokus — wer gerade liest, soll nicht
nach neun Sekunden woanders landen. Bei "Bewegung reduzieren" wechselt er
gar nicht von selbst. Die Punkte sind echte Knöpfe mit Beschriftung; ein
div ist für Tastatur und Vorlesesoftware kein Bedienelement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 12:34:57 +02:00
co-authored by Claude Opus 5
parent 6bd9cdb12d
commit 11a44f6a90
3 changed files with 311 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
import { bildAdresse, type Film, type Serie } from './api';
import { icons } from './icons';
import { knopf } from './util';
/** Was im Hero stehen kann — Film oder Serie, mehr braucht es nicht. */
export type Heroeintrag = {
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;
};
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: (art: 'filme' | 'serien', id: string) => 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((e, i) => {
const folie = document.createElement('div');
folie.className = 'hero-folie' + (i === 0 ? ' an' : '');
folie.style.backgroundImage = `url("${bildAdresse(e.hintergrund)}")`;
folie.setAttribute('aria-hidden', i === 0 ? 'false' : 'true');
buehne.appendChild(folie);
return folie;
});
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';
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');
spielen.prepend(iconElement(icons.abspielen));
const mehr = knopf('Mehr', 'btn');
const knoepfe = document.createElement('div');
knoepfe.className = 'hero-knoepfe';
knoepfe.append(spielen, 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;
});
wurzel.append(buehne, inhalt);
if (mitBild.length > 1) wurzel.appendChild(punkte);
let jetzt = 0;
let takt: number | undefined;
function zeige(i: number): void {
jetzt = ((i % mitBild.length) + mitBild.length) % mitBild.length;
const e = mitBild[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));
const logo = bildAdresse(e.schriftzug);
if (logo) {
schriftzug.src = logo;
schriftzug.alt = e.titel;
schriftzug.classList.remove('hidden');
titel.classList.add('hidden');
} else {
schriftzug.classList.add('hidden');
titel.textContent = e.titel;
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(' · ');
text.textContent = e.beschreibung ?? '';
spielen.onclick = () => abspielen(e.art, e.id);
mehr.onclick = () => oeffnen(e.art, e.id);
}
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);
}
wurzel.addEventListener('mouseenter', anhalten);
wurzel.addEventListener('mouseleave', starten);
wurzel.addEventListener('focusin', anhalten);
wurzel.addEventListener('focusout', starten);
signal?.addEventListener('abort', anhalten);
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 {
return {
art,
id: eintrag.id,
titel: eintrag.title,
schriftzug: eintrag.logoImage,
hintergrund: eintrag.backdropImage ?? '',
jahr: eintrag.year,
laufzeit: eintrag.runtimeMinutes,
freigabe: eintrag.contentRating,
bewertung: eintrag.rating,
beschreibung: eintrag.overview,
};
}