**Pfeile.** Der Aufmacher hatte nur die Punkte; die Pfeile, die du gedrückt hast, gehörten zur Reihe darunter. Jetzt stehen links und rechts über dem Bild zwei grosse Knöpfe. Am Telefon gibt es sie nicht — dort nähme ein Knopf über dem Bild ein Drittel der Breite —, dafür lässt sich wischen. Nur waagerecht und erst ab 48 Pixeln, sonst käme man am Aufmacher nicht mehr vorbeigescrollt. **Und die Knöpfe springen nicht mehr.** „Abspielen" ist 128 Pixel breit, „Fortsetzen ab 1:23:45" 207, der Terminhinweis 257 — alles in derselben Flexreihe, also wanderte „Mehr" bei jedem Umblättern mit, und das Karussell blättert von allein. Der vordere Platz hat jetzt eine feste Breite. Gemessen: „Mehr" steht auf allen sieben Folien an derselben Stelle. Dabei fielen zwei Dinge auf, die nicht am Umbau lagen: **Der Textblock war so breit wie sein längster Satz.** Als blosses Flexkind richtete er sich nach dem Inhalt — bei einer kurzen Inhaltsangabe schmaler als bei einer langen. Auf dem Telefon rückte „Mehr" dadurch um fünfzig Pixel, und mit dem festen Platz lief die Zeile sogar über den Bildschirm hinaus; `overflow: hidden` schnitt die rechte Hälfte des Textes ab. **Ein fehlender Schriftzug liess den Aufmacher namenlos.** Steht ein Logo in den Metadaten, wurde der Titel daneben versteckt — auch wenn das Bild gar nicht lud. Zu sehen war ein Platzhalter und sonst nichts. Jetzt tritt der Name an seine Stelle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
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 `<div>`s: mit der Tastatur
|
|
// und mit Vorlesesoftware ist ein `<div>` 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 `<img>` 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/<Folgenkennung>` 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,
|
|
};
|
|
}
|