Das Web ist jetzt ein Projekt

Schritt 2 des Plans, fertig. Aus 1700 Zeilen in einer Datei sind zwölf
Module geworden: api, prefs, contrast, icons, util, types und die
Ansichten für Anmeldung, Startseite, Mediathek, Einstellungen, Kacheln
und Player. Alles typgeprüft, und die Prüfung läuft scharf —
noUncheckedIndexedAccess inbegriffen.

An einer Stelle hat der Compiler mich zu Recht aufgehalten: Object.fromEntries
verliert die Typen jedes einzelnen Feldes, und geblieben wäre nur ein
doppelter Cast, der die Prüfung stilllegt statt sie zu bestehen. Jetzt
steht es ausgeschrieben da.

hls.js wird erst beim Abspielen geladen. Es ist größer als der ganze Rest
zusammen, und wer nur stöbert, braucht es nie: der Start fällt damit von
632 auf 39 Kilobyte.

Geprüft mit einem erfundenen Medienserver dahinter: Seite kommt an,
gebaute Dateien werden ausgeliefert, Anfragen gehen weiter, und die
Startseite zeichnet Insel, Aufmacher und Reihen.

Die einzelne Datei bleibt liegen, bis das Projekt sich bewährt hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 19:31:56 +02:00
co-authored by Claude Opus 5
parent a04784ca15
commit 3af6c3112f
10 changed files with 1369 additions and 0 deletions
+207
View File
@@ -0,0 +1,207 @@
import type { Item, Reihe, Seite } from '../types';
import { api, sitzung } from '../api';
import { P, anordnen } from '../prefs';
import { icon } from '../icons';
import { esc, bild, laufzeit, hatBild, FELDER } from '../util';
import { kachel } from './cards';
/** Welche Reihen es bei diesem Server gibt — für die Einstellungen. */
export let bekannteReihen: { id: string; title: string }[] = [];
export async function startseite(
wurzel: HTMLElement,
ansichten: Item[],
oeffnen: (item: Item) => void,
): Promise<void> {
wurzel.innerHTML = '<div class="spinner"></div>';
const hole = async (pfad: string, q: Record<string, string> = {}): Promise<Item[]> => {
try {
const r = await api<Seite | Item[]>(
pfad + '?' + new URLSearchParams({ Fields: FELDER, Limit: '20', ...q }),
);
// „Latest" liefert ein blankes Feld, alles andere eine Seite mit Items.
return Array.isArray(r) ? r : (r.Items ?? []);
} catch {
return [];
}
};
const [weiter, naechstes, ...neues] = await Promise.all([
hole(`/Users/${sitzung.userId}/Items/Resume`, { MediaTypes: 'Video' }),
hole('/Shows/NextUp', { userId: sitzung.userId }),
...ansichten.map((v) => hole(`/Users/${sitzung.userId}/Items/Latest`, { ParentId: v.Id })),
]);
let reihen: Reihe[];
if (P.combineContinue) {
const gesehen = new Set<string>();
const zusammen = [...weiter!, ...naechstes!].filter((i) => !gesehen.has(i.Id) && gesehen.add(i.Id));
// Die zusammengelegte Reihe behält die Kennung der ersten — sonst wäre sie
// für die gespeicherte Reihenfolge eine neue und stünde wieder unten.
reihen = [{ id: 'resume', title: 'Weiterschauen', items: zusammen, wide: true }];
} else {
reihen = [
{ id: 'resume', title: 'Weiterschauen', items: weiter!, wide: true },
{ id: 'nextup', title: 'Als Nächstes', items: naechstes!, wide: true },
];
}
reihen.push(...ansichten.map((v, i) => ({
id: 'latest.' + v.Id,
title: `Neu in ${v.Name}`,
// Gesehenes ausblenden gilt nur für Neuzugänge: in „Weiterschauen" wäre
// es sinnlos, dort ist alles angefangen.
items: (neues[i] ?? []).filter((it) => !P.hideWatched || !it.UserData?.Played),
})));
reihen = reihen.filter((r) => r.items.length);
// **Vor** dem Ausblenden merken: die Einstellungen müssen auch die Reihen
// anbieten, die gerade versteckt sind.
bekannteReihen = reihen.map((r) => ({ id: r.id, title: r.title }));
reihen = anordnen(reihen);
const auswahl = P.heroSource === 'latestOnly'
? neues.flat()
: [...weiter!, ...naechstes!, ...neues.flat()];
const oben = aufmacherWahl(auswahl);
wurzel.innerHTML = '';
wurzel.classList.toggle('pad-top', oben.length === 0);
if (oben.length) wurzel.appendChild(aufmacher(oben, oeffnen));
for (const r of reihen) wurzel.appendChild(reihe(r, oeffnen));
if (!reihen.length && !oben.length) {
wurzel.innerHTML = '<div class="empty pad-top">Hier ist noch nichts.</div>';
}
}
/* Wählt aus, was oben groß steht — dieselbe Regel wie in der App.
----------------------------------------------------------------------------
Die Auswahl wechselt alle sechs Stunden, und ein Titel ohne Hintergrundbild
zieht ein schlechteres Los. Bei jedem Laden zu würfeln wäre unruhig — man
tippt auf etwas, geht zurück, und es ist weg. Einmal täglich wäre zu träge. */
function aufmacherWahl(items: Item[]): Item[] {
let keim = (Math.floor(Date.now() / (6 * 3600 * 1000)) * 2654435761) % 2147483647;
const wuerfeln = (): number => (keim = (keim * 16807) % 2147483647) / 2147483647;
const gesehen = new Set<string>();
return items
.filter((it) => !gesehen.has(it.Id) && gesehen.add(it.Id))
// Wer ein Hintergrundbild hat, zieht aus [0,1) — wer keines hat, nur aus
// [0,0.2). Solange es genug mit Bild gibt, kommt er nicht nach vorn; gibt
// es wenige, hat er seine Chance.
.map((it) => ({ it, los: wuerfeln() * (hatBild(it, 'Backdrop') ? 1 : 0.2) }))
.sort((a, b) => b.los - a.los)
.slice(0, P.heroCount)
.map((x) => x.it);
}
function aufmacher(items: Item[], oeffnen: (item: Item) => void): HTMLElement {
const aussen = document.createElement('div');
const rahmen = document.createElement('section');
rahmen.id = 'hero';
const bilder = items.map((it, i) => {
const d = document.createElement('div');
d.className = 'shot' + (i === 0 ? ' on' : '');
d.style.backgroundImage = `url("${bild(it, 'Backdrop', 1600)}")`;
return d;
});
bilder.forEach((b) => rahmen.appendChild(b));
rahmen.insertAdjacentHTML('beforeend', '<div class="veil"></div>');
const koerper = document.createElement('div');
koerper.className = 'body';
rahmen.appendChild(koerper);
const punkte = document.createElement('div');
punkte.className = 'dots';
let bei = 0;
let uhr: ReturnType<typeof setInterval> | null = null;
const anhalten = (): void => { if (uhr) { clearInterval(uhr); uhr = null; } };
const schritt = (um: number): void => {
anhalten();
bei = (bei + um + items.length) % items.length;
malen();
};
items.forEach((_, i) => {
const d = document.createElement('button');
d.className = i ? '' : 'on';
d.setAttribute('aria-label', `Titel ${i + 1}`);
d.onclick = () => { anhalten(); bei = i; malen(); };
punkte.appendChild(d);
});
function malen(): void {
const it = items[bei]!;
const teile = [it.ProductionYear, laufzeit(it), it.OfficialRating].filter(Boolean);
const zeichen = it.ImageTags?.Logo ? bild(it, 'Primary', 600) : '';
koerper.innerHTML = `
${zeichen && it.ImageTags?.Logo ? `<img class="logo" src="${zeichen}" alt="${esc(it.Name)}">`
: `<h2>${esc(it.Name)}</h2>`}
<div class="meta">${teile.join(' · ')}</div>
<div class="summary">${esc(it.Overview ?? '')}</div>
<div class="actions">
<button class="btn btn-primary">${icon('play', 15)} Abspielen</button>
<button class="btn">Details</button>
</div>`;
const knoepfe = koerper.querySelectorAll('.btn');
(knoepfe[0] as HTMLElement).onclick = () => oeffnen(it);
(knoepfe[1] as HTMLElement).onclick = () => oeffnen(it);
bilder.forEach((b, i) => b.classList.toggle('on', i === bei));
[...punkte.children].forEach((d, i) => d.classList.toggle('on', i === bei));
}
malen();
if (items.length > 1) {
const zurueck = document.createElement('button');
zurueck.className = 'hero-nav prev';
zurueck.innerHTML = icon('left', 22);
zurueck.setAttribute('aria-label', 'Vorheriger Titel');
zurueck.onclick = () => schritt(-1);
const vor = document.createElement('button');
vor.className = 'hero-nav next';
vor.innerHTML = icon('right', 22);
vor.setAttribute('aria-label', 'Nächster Titel');
vor.onclick = () => schritt(1);
rahmen.append(zurueck, vor);
// Wischen — erst ab einem ordentlichen Weg und nur, wenn es waagerecht
// gemeint ist. Sonst nimmt die Geste dem Scrollen den Vortritt.
let x0: number | null = null;
let y0 = 0;
rahmen.addEventListener('touchstart', (e) => {
x0 = e.touches[0]!.clientX; y0 = e.touches[0]!.clientY;
}, { passive: true });
rahmen.addEventListener('touchend', (e) => {
if (x0 == null) return;
const dx = e.changedTouches[0]!.clientX - x0;
const dy = e.changedTouches[0]!.clientY - y0;
if (Math.abs(dx) > 48 && Math.abs(dx) > Math.abs(dy)) schritt(dx < 0 ? 1 : -1);
x0 = null;
}, { passive: true });
// Sieben Sekunden — lang genug, um den Satz zu Ende zu lesen.
uhr = setInterval(() => {
if (!document.body.contains(rahmen)) return anhalten();
bei = (bei + 1) % items.length;
malen();
}, 7000);
}
aussen.append(rahmen, punkte);
return aussen;
}
function reihe(r: Reihe, oeffnen: (item: Item) => void): HTMLElement {
const abschnitt = document.createElement('section');
abschnitt.className = 'row';
abschnitt.innerHTML = `<h3>${esc(r.title)}</h3><div class="strip"></div>`;
const streifen = abschnitt.querySelector('.strip')!;
for (const it of r.items) streifen.appendChild(kachel(it, Boolean(r.wide), oeffnen));
return abschnitt;
}