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; /** 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((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'; // 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 `
`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; }); // **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]!; 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 `` 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(' · '); text.textContent = e.beschreibung ?? ''; 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.stelle > 0 ? `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; }); } 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 { const datei = 'files' in eintrag ? eintrag.files[0] ?? null : null; 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, verfuegbarAb: eintrag.publishAt, dateiId: datei?.id ?? null, laenge: datei?.durationSeconds ?? null, stelle: eintrag.state?.positionSeconds ?? 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/` 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.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, standId: eintrag.id, }; }