**Mehrfachfassungen lassen sich abhaken.** Zwei Dateien an einem Film sind meist zwei Fassungen und völlig in Ordnung. In der Liste standen sie trotzdem für immer: sie rechnet jedes Mal frisch, und es gab keinen Zustand. Wer absichtlich zwei Fassungen hält, konnte die Gruppe nicht loswerden — und unter den gewollten ging das Ungewollte unter. Abgehakt wird eine Dateizahl, kein Zeitpunkt. Ein Zeitstempel müsste gegen `MediaFile.ModifiedAt` verglichen werden, und eine alte Datei, frisch hineinkopiert, behält ihre alte Zeit und käme nie wieder hoch. Die Anzahl ist genau die Frage: kommt eine dritte Fassung dazu, meldet sich die Gruppe von selbst zurück. Gerechnet wird sie auf dem Server — ein Client mit veralteter Liste hakte sonst eine Gruppe mit zwei Dateien ab, die inzwischen drei hat. „Ignorierte zeigen" führt zurück, wie bei den erledigten Meldungen; ohne das wäre das Abhaken unumkehrbar und niemand wüsste, was er weggeklickt hat. **Die Startseite wartete dreimal nacheinander.** Erst die Serverauskunft, dann Konto und Mediatheken, dann erst die Startseite — drei Rundreisen, bevor das erste Bild in Auftrag ging. Liegt eine Sitzung im Speicher, laufen die ersten beiden jetzt zugleich los. Nur dann: ohne Sitzung liefen `/auth/me` und `/libraries` vor der Antwort auf `needsSetup` in ein 401, und die Sitzungsprüfung kann daraufhin abmelden. Gemessen am laufenden Server: `users/me`, `system/info` und `libraries` starten alle bei 37 ms, die Startseitenabfragen folgen bei 73 ms. Vorher lagen drei Runden dazwischen. **Sieben Hintergrundbilder auf einmal waren sechs zu viel.** Jede Folie des Aufmachers bekam sofort ihr `w1280`-Bild; sichtbar ist immer nur eine. Jetzt lädt die sichtbare sofort und der Rest in der Leerlaufzeit — und wer weiterschaltet, bevor das lief, bekommt sein Bild dann. **Die Schrift kam von Google.** `display=swap` rettete den Text, nicht den ersten Aufbau: ein Stylesheet blockiert das Zeichnen, und ein Heimserver ohne Internet oder mit träger Namensauflösung wartete davor. Inter liegt jetzt als zwei Dateien von zusammen sechzig Kilobyte im Projekt, variabel für alle Schnitte, mit `unicode-range` getrennt nach latin und latin-ext. Gemessen: null Anfragen an fonts.googleapis.com. Lizenztext liegt dabei. **Und mein Untertitelvorrat bremste mit.** Er startete sofort beim Hochfahren und las die ganze Mediathek — von derselben Platte, von der die Startseite ihre Bilder holt. Jetzt beginnt er eine Minute später und stellt zurück, solange eine Wiedergabe läuft. Die Datei bleibt dabei vorn in der Reihe; verworfen wird nichts. 678 Tests grün, davon zwei neue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
503 lines
19 KiB
TypeScript
503 lines
19 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;
|
|
/** 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;
|
|
};
|
|
|
|
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,
|
|
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,
|
|
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.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,
|
|
// Was in „Weiterschauen" steht, ist per Definition angefangen.
|
|
angefangen: eintrag.state.inProgress ?? eintrag.state.positionSeconds > 0,
|
|
standId: eintrag.id,
|
|
};
|
|
}
|