diff --git a/web/app/src/hero.ts b/web/app/src/hero.ts
new file mode 100644
index 0000000..7e9d5e5
--- /dev/null
+++ b/web/app/src/hero.ts
@@ -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 `
`s: mit der Tastatur
+ // und mit Vorlesesoftware ist ein `
` 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,
+ };
+}
diff --git a/web/app/src/style.css b/web/app/src/style.css
index a8c0a3e..121cdc3 100644
--- a/web/app/src/style.css
+++ b/web/app/src/style.css
@@ -770,3 +770,95 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
.kachel:hover .plakat,
.kachel:focus-visible .plakat { transform: none; }
}
+
+/* --- Hero ----------------------------------------------------------------
+ *
+ * Die Startseite begann bei einer Reihe aus 150 px breiten Plakaten; das
+ * grösste Element über dem Falz war 225 px hoch. Ein Bild über die volle
+ * Breite ist der grösste Einzelunterschied im Eindruck — und ein Layout-,
+ * kein Farbproblem. */
+
+.hero {
+ position: relative;
+ height: min(62vh, 560px);
+ /* Randlos: der Inhaltsbereich hat seitlich Polster, das Bild soll es
+ nicht haben. Negative Ränder statt eines eigenen Containers. */
+ margin: calc(var(--s-page) * -1) calc(var(--s-page) * -1) var(--s-section);
+ overflow: hidden;
+ display: flex;
+ align-items: flex-end;
+}
+
+.hero-buehne { position: absolute; inset: 0; }
+
+.hero-folie {
+ position: absolute; inset: 0;
+ background-size: cover;
+ background-position: center 20%;
+ opacity: 0;
+ transition: opacity .9s ease-in-out;
+}
+.hero-folie.an { opacity: 1; }
+
+/* **Zwei Verläufe, nicht einer.** 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. */
+.hero-buehne::after {
+ content: '';
+ position: absolute; inset: 0;
+ background:
+ linear-gradient(to top, var(--canvas) 2%, rgba(11, 11, 13, .55) 45%, transparent 85%),
+ linear-gradient(to right, rgba(11, 11, 13, .88) 0%, rgba(11, 11, 13, .35) 45%, transparent 75%);
+}
+
+.hero-inhalt {
+ position: relative;
+ padding: var(--s-page);
+ max-width: 620px;
+ display: flex; flex-direction: column; gap: var(--s-regular);
+}
+
+.hero-schriftzug {
+ max-width: 380px; max-height: 120px;
+ object-fit: contain; object-position: left bottom;
+}
+
+.hero-titel {
+ margin: 0;
+ font-size: var(--t-hero); font-weight: 700; letter-spacing: -.03em;
+}
+
+.hero-meta { font-size: var(--t-normal); color: var(--text-dim); }
+
+/* Drei Zeilen, dann Schluss — eine volle Inhaltsangabe gehört auf die
+ Detailseite, nicht in den Aufmacher. */
+.hero-text {
+ margin: 0;
+ color: var(--text-dim);
+ display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.hero-knoepfe { display: flex; gap: var(--s-snug); margin-top: var(--s-tight); }
+.hero-knoepfe .btn { height: 40px; padding: 0 var(--s-section); gap: 6px; }
+
+.hero-punkte {
+ position: absolute; right: var(--s-page); bottom: var(--s-page);
+ display: flex; gap: 6px;
+}
+.hero-punkt {
+ width: 26px; height: 3px; padding: 0; border: 0; border-radius: 2px;
+ background: rgba(255, 255, 255, .28);
+ transition: background .2s ease;
+}
+.hero-punkt.an { background: var(--text); }
+.hero-punkt:hover { background: rgba(255, 255, 255, .6); }
+
+@media (max-width: 860px) {
+ .hero {
+ height: min(52vh, 420px);
+ margin: calc(var(--s-loose) * -1) calc(var(--s-loose) * -1) var(--s-section);
+ }
+ .hero-inhalt { padding: var(--s-loose); max-width: none; }
+ .hero-punkte { right: var(--s-loose); bottom: var(--s-loose); }
+}
diff --git a/web/app/src/views/home.ts b/web/app/src/views/home.ts
index a4353c7..bd413e2 100644
--- a/web/app/src/views/home.ts
+++ b/web/app/src/views/home.ts
@@ -4,6 +4,7 @@ import {
} from '../api';
import { leer, fehlerzeile } from '../util';
import { kachel, alsKachel, angefangenAlsKachel, reihe } from '../card';
+import { hero, alsHero, type Heroeintrag } from '../hero';
type Art = 'filme' | 'serien';
@@ -43,6 +44,30 @@ export function startseite(
return;
}
+ // **Der Aufmacher, aus dem, was ohnehin schon geholt wurde.**
+ // Erst Angefangenes — wer mittendrin ist, will dort weiter —, dann
+ // Neuzugänge, dann Vorschläge. Titel ohne Hintergrundbild fallen im
+ // Hero selbst heraus; ohne einen einzigen entfällt er ganz.
+ const heroTitel: Heroeintrag[] = [];
+ const schonDa = new Set();
+ const dazu = (e: Heroeintrag) => {
+ if (schonDa.has(e.id) || !e.hintergrund) return;
+ schonDa.add(e.id);
+ heroTitel.push(e);
+ };
+
+ weiter.items.forEach(e => dazu(alsHero(e as never, 'serien' in e ? 'serien' : 'filme')));
+ bibliotheken.forEach((b, i) => {
+ const art: Art = b.kind === 'Shows' ? 'serien' : 'filme';
+ bestaende[i]?.items.forEach(e => dazu(alsHero(e as never, art)));
+ });
+ vorschlag.items.forEach(e => dazu(alsHero(e as never, 'filme')));
+
+ const aufmacher = hero(heroTitel,
+ (art, id) => oeffnen(art, id),
+ (art, id) => oeffnen(art, id));
+ if (aufmacher) wurzel.appendChild(aufmacher);
+
if (weiter.items.length) {
wurzel.appendChild(reihe('Fortsetzen',
weiter.items.map(e => kachel(angefangenAlsKachel(e), oeffnen))));