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, }; }