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:
co-authored by
Claude Opus 5
parent
a04784ca15
commit
3af6c3112f
@@ -0,0 +1,193 @@
|
||||
import './style.css';
|
||||
import type { Item, Seite } from './types';
|
||||
import { api, sitzung, angemeldet, abmelden } from './api';
|
||||
import { P, anwenden, setzen } from './prefs';
|
||||
import { icon } from './icons';
|
||||
import { esc, FELDER } from './util';
|
||||
import { anmeldung } from './views/login';
|
||||
import { startseite } from './views/home';
|
||||
import { mediathek } from './views/library';
|
||||
import { einstellungen } from './views/settings';
|
||||
import { blatt } from './views/cards';
|
||||
import { playerOeffnen } from './views/player';
|
||||
|
||||
const app = document.getElementById('app')!;
|
||||
const inhalt = document.getElementById('content')!;
|
||||
const insel = document.getElementById('island')!;
|
||||
const chrome = document.getElementById('chrome')!;
|
||||
|
||||
let ansichten: Item[] = [];
|
||||
let wo = 'home';
|
||||
|
||||
/* ---------- Navigation ---------- */
|
||||
|
||||
function inselBauen(): void {
|
||||
insel.innerHTML = '';
|
||||
|
||||
// Der Umschalter zuerst, wie auf dem iPad: an der Seite oben in der Ecke,
|
||||
// oben ganz links vor den Einträgen.
|
||||
const nav = document.createElement('button');
|
||||
nav.className = 'toggle-nav';
|
||||
nav.title = P.nav === 'side' ? 'Leiste nach oben' : 'Leiste an die Seite';
|
||||
nav.setAttribute('aria-label', nav.title);
|
||||
nav.innerHTML = icon(P.nav === 'side' ? 'barTop' : 'barSide', 18);
|
||||
nav.onclick = () => leisteUmziehen(P.nav === 'side' ? 'top' : 'side');
|
||||
insel.appendChild(nav);
|
||||
|
||||
const stueck = (klasse: string, text?: string): void => {
|
||||
const d = document.createElement('div');
|
||||
d.className = klasse;
|
||||
if (text) d.textContent = text;
|
||||
insel.appendChild(d);
|
||||
};
|
||||
const eintrag = (text: string, ziel: string): void => {
|
||||
const b = document.createElement('button');
|
||||
b.textContent = text;
|
||||
b.dataset.key = ziel;
|
||||
b.onclick = () => gehe(ziel);
|
||||
insel.appendChild(b);
|
||||
};
|
||||
|
||||
stueck('brand', 'Flixy');
|
||||
eintrag('Start', 'home');
|
||||
eintrag('Einstellungen', 'settings');
|
||||
|
||||
if (ansichten.length) {
|
||||
stueck('sep');
|
||||
stueck('group-label', 'Mediatheken');
|
||||
for (const v of ansichten) eintrag(v.Name ?? '—', 'lib:' + v.Id);
|
||||
}
|
||||
stueck('sep');
|
||||
eintrag('Abmelden', 'out');
|
||||
|
||||
[...insel.querySelectorAll('button[data-key]')].forEach((b) =>
|
||||
b.classList.toggle('active', (b as HTMLElement).dataset.key === wo));
|
||||
}
|
||||
|
||||
/* Lässt die Leiste von ihrer alten Lage in die neue fahren.
|
||||
----------------------------------------------------------------------------
|
||||
**Zwei Züge, nicht einer.** Ein reiner Weg von A nach B sieht aus, als würde
|
||||
ein Kasten verschoben. Die Leiste zieht sich erst *in sich* zusammen und
|
||||
zieht dann erst um — wie ein Gummiband, das sich sammelt, bevor es schnellt.
|
||||
|
||||
Gemacht mit FLIP: alte Lage messen, umbauen, neue Lage messen, und vom alten
|
||||
Zustand in den neuen fahren. Über CSS allein ginge das nicht — `right: auto`
|
||||
und `bottom: auto` lassen sich nicht animieren, und genau die wechseln. */
|
||||
function leisteUmziehen(nach: 'top' | 'side'): void {
|
||||
const vorher = insel.getBoundingClientRect();
|
||||
const sparsam = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
setzen('nav', nach);
|
||||
inselBauen();
|
||||
|
||||
if (sparsam) return;
|
||||
const nachher = insel.getBoundingClientRect();
|
||||
if (!vorher.width || !nachher.width) return;
|
||||
|
||||
const dx = vorher.left - nachher.left;
|
||||
const dy = vorher.top - nachher.top;
|
||||
const sx = vorher.width / nachher.width;
|
||||
const sy = vorher.height / nachher.height;
|
||||
|
||||
// Welche Richtung sich zuerst sammelt, hängt davon ab, wohin es geht: Wer
|
||||
// nach oben zieht, wird flach — also klappt zuerst die Höhe zusammen.
|
||||
const gesammelt = nach === 'top'
|
||||
? `translate(${dx}px, ${dy}px) scale(${sx}, 1)`
|
||||
: `translate(${dx}px, ${dy}px) scale(1, ${sy})`;
|
||||
|
||||
insel.animate(
|
||||
[
|
||||
{ transformOrigin: 'top left', transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`,
|
||||
easing: 'cubic-bezier(.5,0,.75,0)', offset: 0 },
|
||||
{ transformOrigin: 'top left', transform: gesammelt,
|
||||
easing: 'cubic-bezier(.22,1,.36,1)', offset: 0.42 },
|
||||
{ transformOrigin: 'top left', transform: 'none', offset: 1 },
|
||||
],
|
||||
{ duration: 520 },
|
||||
);
|
||||
insel.animate([{ opacity: 0.35 }, { opacity: 0.5, offset: 0.42 }, { opacity: 1 }],
|
||||
{ duration: 420, easing: 'ease-out' });
|
||||
}
|
||||
|
||||
function gehe(ziel: string): void {
|
||||
if (ziel === 'out') return abmelden();
|
||||
wo = ziel;
|
||||
[...insel.querySelectorAll('button[data-key]')].forEach((b) =>
|
||||
b.classList.toggle('active', (b as HTMLElement).dataset.key === ziel));
|
||||
scrollTo({ top: 0 });
|
||||
inhalt.classList.remove('pad-top');
|
||||
|
||||
if (ziel === 'home') void startseite(inhalt, ansichten, tippen);
|
||||
else if (ziel === 'settings') einstellungen(inhalt, () => gehe('home'));
|
||||
else void mediathek(inhalt, ziel.slice(4), tippen);
|
||||
}
|
||||
|
||||
/* ---------- Was beim Antippen geschieht ---------- */
|
||||
|
||||
function tippen(item: Item): void {
|
||||
blatt(item, starten);
|
||||
}
|
||||
|
||||
/** Startet — und bei einer Serie die Folge, die als Nächstes dran ist.
|
||||
*
|
||||
* Eine Serie gar nicht abzuspielen war die falsche Antwort: die
|
||||
* offensichtliche Erwartung ist, dass es losgeht. */
|
||||
async function starten(item: Item): Promise<void> {
|
||||
const ordner = ['Series', 'Season'].includes(item.Type ?? '');
|
||||
const ziel = ordner ? await naechsteFolge(item) : item;
|
||||
if (!ziel) return;
|
||||
playerOeffnen(ziel, () => { if (wo === 'home') void startseite(inhalt, ansichten, tippen); });
|
||||
}
|
||||
|
||||
async function naechsteFolge(item: Item): Promise<Item | null> {
|
||||
const serie = item.Type === 'Season' ? (item.SeriesId ?? item.Id) : item.Id;
|
||||
try {
|
||||
// Erst „Als Nächstes": wer eine Serie anfängt und zurückkommt, meint die
|
||||
// Folge, bei der er stehengeblieben ist — nicht die erste.
|
||||
const weiter = await api<Seite>('/Shows/NextUp?' + new URLSearchParams({
|
||||
userId: sitzung.userId, seriesId: serie, Limit: '1', Fields: FELDER,
|
||||
}));
|
||||
const folge = weiter.Items?.[0];
|
||||
if (folge && (item.Type !== 'Season' || folge.SeasonId === item.Id)) return folge;
|
||||
} catch { /* dann eben die erste */ }
|
||||
|
||||
try {
|
||||
const q: Record<string, string> = { userId: sitzung.userId, Limit: '1', Fields: FELDER };
|
||||
if (item.Type === 'Season') q.seasonId = item.Id;
|
||||
const alle = await api<Seite>(`/Shows/${serie}/Episodes?` + new URLSearchParams(q));
|
||||
return alle.Items?.[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Los ---------- */
|
||||
|
||||
async function starte(): Promise<void> {
|
||||
document.getElementById('login')!.classList.add('hidden');
|
||||
app.classList.remove('hidden');
|
||||
anwenden();
|
||||
inhalt.innerHTML = '<div class="spinner"></div>';
|
||||
|
||||
try {
|
||||
const r = await api<Seite>(`/Users/${sitzung.userId}/Views`);
|
||||
ansichten = (r.Items ?? []).filter((v) => ['movies', 'tvshows'].includes(v.CollectionType ?? ''));
|
||||
inselBauen();
|
||||
gehe('home');
|
||||
} catch (e) {
|
||||
inhalt.innerHTML = `<div class="empty pad-top">${esc((e as Error).message)}<br><br>
|
||||
<button class="btn" id="raus">Abmelden</button></div>`;
|
||||
document.getElementById('raus')!.onclick = abmelden;
|
||||
}
|
||||
}
|
||||
|
||||
// Die Insel oben fährt beim Scrollen weg und kommt zurück, sobald man wieder
|
||||
// oben steht. An der Seite bleibt sie stehen — dort nimmt sie niemandem etwas.
|
||||
addEventListener('scroll', () => {
|
||||
if (P.nav === 'side') return;
|
||||
chrome.classList.toggle('away', scrollY > 8);
|
||||
}, { passive: true });
|
||||
|
||||
anwenden();
|
||||
if (angemeldet()) void starte();
|
||||
else anmeldung(document.getElementById('login')!, () => void starte());
|
||||
Reference in New Issue
Block a user