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))));