Files
Flaro/web/app/src/main.ts
T
ScarriffleandClaude Opus 5 3af6c3112f 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>
2026-09-14 19:31:56 +02:00

194 lines
7.2 KiB
TypeScript

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());