import { bildAdresse, type Angefangen, type Film, type Serie } from './api'; import { icons } from './icons'; import { dauer, knopf, folgenname } 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; /** Bei einer Folge ihre Teile — für die Zeile über dem Spieler. * * **`titel` reicht dafür nicht.** Dort steht bei einer Folge der Name der * *Serie*, weil der Aufmacher die Serie zeigt. Wer von hier aus startete, * las über dem Spieler nur „Biohackers" und nicht, welche Folge läuft. */ staffel: number | null; folge: number | null; folgentitel: 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: (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 `
`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]!; // 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 `` 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, staffel: null, folge: null, folgentitel: null, 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/` 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, // Ausgeschrieben, nicht „S1 · F2": der Aufmacher hat den Platz dafür, // und zwei Buchstaben mit Zahl liest niemand gern. beschreibung: folge && eintrag.season !== null ? folgenname(null, eintrag.season, eintrag.episode, eintrag.subTitle) : null, verfuegbarAb: eintrag.publishAt ?? null, staffel: folge ? eintrag.season : null, folge: folge ? eintrag.episode : null, folgentitel: folge ? eintrag.subTitle : 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, }; }