**Der Aufmacher wusste nur die Serie.** `Heroeintrag.titel` ist bei einer angefangenen Folge der Name der Serie — richtig, denn der Aufmacher zeigt die Serie. Wer von dort startete, las über dem Spieler „Biohackers" und nicht, welche Folge läuft. Staffel, Nummer und Folgentitel reisen jetzt mit. Dasselbe im Aufmacher selbst: „Staffel 1 · Folge 2" statt „S1 · F2". **Der Abspann ist kein Angebot, sondern eine Frage.** Die Acht-Sekunden- Frist von gestern war für das Intro gedacht und traf den Abspann mit: „Nächste Folge" verschwand genau dort, wo man hinsieht. Ein Intro-Knopf geht, wenn man ihn nicht nimmt; der Abspann-Knopf bleibt, bis die Folge zu Ende ist. Web und App. **„deu" stand da, weil es in der Datei steht.** Der Server reichte einen vorhandenen Spurnamen unbesehen durch — und viele Dateien tragen als Titel schlicht das Sprachkürzel. Wiederholt er bloss die Sprache, gilt er nicht, und die Tabelle darunter macht „Deutsch" daraus. **Und zweimal „Deutsch" ist keine Wahl.** Zwei deutsche Untertitelspuren sind der Normalfall — Vollfassung und Nur-Fremdsprachiges. Gleichnamige bekommen jetzt ihren Codec dazu, und wenn der auch gleich ist, die Nummer. Das kann keine Spur für sich beantworten: ob ein Name eindeutig ist, hängt an den anderen. Deshalb steht es in `Benennung` und hat fünf Prüfungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
524 lines
20 KiB
TypeScript
524 lines
20 KiB
TypeScript
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 `<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]!;
|
|
|
|
// 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 `<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(' · ');
|
|
|
|
// **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/<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.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,
|
|
};
|
|
}
|