Files
Flaro/web/app/src/prefs.ts
T
ScarriffleandClaude Opus 5 a04784ca15 Das Web bekommt ein Fundament und einen eigenen Server
Schritt 2 des Plans, erste Hälfte. Vite mit TypeScript, und ein Server
ohne eine einzige Abhängigkeit — er soll auf einem kleinen Container
laufen, nicht sein halbes Verzeichnis mitschleppen.

Der Server ist nicht Zierde. Er löst zwei Dinge, gegen die keine Zeile im
Frontend etwas ausrichten kann: Anfragen laufen über dieselbe Adresse wie
die Seite, womit CORS erledigt ist, auch beim Streamen. Und läuft der
Medienserver im Klartext, während die Seite verschlüsselt ausgeliefert
wird, blockiert der Browser sonst den Strom.

Geprüft: Weiterreichen mitsamt Autorisierung und richtig gesetztem Host,
Ausliefern gebauter Dateien, unbekannte Wege landen auf der Seite — und
ein Ausbruchsversuch mit "../../../etc/passwd" liefert die Seite statt
der Passwortdatei.

Der Körper wird durchgereicht statt gepuffert: ein Film ist ein Strom,
und ihn erst vollständig einzulesen hieße, ihn zwischenzuspeichern.

Die einzelne Datei bleibt vorerst, sie läuft ja.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:25:11 +02:00

145 lines
5.1 KiB
TypeScript

/* Die Einstellungen.
----------------------------------------------------------------------------
**Pro Gerät, nicht über den Server.** Am Fernseher will man größere Kacheln
als am Laptop, und das eine soll das andere nicht umstellen. Dieselbe
Entscheidung wie in der App, dort nachzulesen in der Spec zum Abgleich.
Namen und Wertebereiche decken sich mit denen der App — ein Regler von
70 bis 180 Prozent heißt auf beiden Seiten dasselbe. Nur der Speicherort
unterscheidet sich, hier localStorage statt UserDefaults. */
export type Navigation = 'top' | 'side';
export type Aufmacherquelle = 'continueFirst' | 'latestOnly';
interface Einstellungen {
accent: string;
nav: Navigation;
cardW: number;
heroSource: Aufmacherquelle;
heroCount: number;
heroHeight: number;
combineContinue: boolean;
hideWatched: boolean;
hiddenRows: string[];
rowOrder: string[];
rate: number;
skipForward: number;
skipBackward: number;
quality: number;
audioLanguage: string;
subtitleLanguage: string;
}
const vorgabe: Einstellungen = {
accent: '#C04AC9',
nav: 'top',
cardW: 150,
heroSource: 'continueFirst',
heroCount: 5,
heroHeight: 82,
combineContinue: true,
hideWatched: false,
hiddenRows: [],
rowOrder: [],
rate: 1,
skipForward: 30,
skipBackward: 10,
quality: 0,
audioLanguage: '',
subtitleLanguage: '',
};
function lesen<K extends keyof Einstellungen>(schluessel: K): Einstellungen[K] {
const roh = localStorage.getItem('flixy.' + schluessel);
if (roh == null) return vorgabe[schluessel];
const art = typeof vorgabe[schluessel];
if (Array.isArray(vorgabe[schluessel])) {
try {
return JSON.parse(roh) as Einstellungen[K];
} catch {
return vorgabe[schluessel];
}
}
if (art === 'number') return Number(roh) as Einstellungen[K];
if (art === 'boolean') return (roh === 'true') as Einstellungen[K];
return roh as Einstellungen[K];
}
/* Ausgeschrieben statt über `Object.fromEntries` zusammengesetzt: dort geht
der Typ jedes einzelnen Feldes verloren, und es bliebe nur ein doppelter
Cast, der die Prüfung stilllegt statt sie zu bestehen. */
export const P: Einstellungen = {
accent: lesen('accent'),
nav: lesen('nav'),
cardW: lesen('cardW'),
heroSource: lesen('heroSource'),
heroCount: lesen('heroCount'),
heroHeight: lesen('heroHeight'),
combineContinue: lesen('combineContinue'),
hideWatched: lesen('hideWatched'),
hiddenRows: lesen('hiddenRows'),
rowOrder: lesen('rowOrder'),
rate: lesen('rate'),
skipForward: lesen('skipForward'),
skipBackward: lesen('skipBackward'),
quality: lesen('quality'),
audioLanguage: lesen('audioLanguage'),
subtitleLanguage: lesen('subtitleLanguage'),
};
export function setzen<K extends keyof Einstellungen>(schluessel: K, wert: Einstellungen[K]): void {
P[schluessel] = wert;
// Listen müssen als JSON abgelegt werden — ein Array landete sonst als
// "a,b,c" im Speicher und käme als Zeichenkette zurück.
localStorage.setItem(
'flixy.' + schluessel,
Array.isArray(wert) ? JSON.stringify(wert) : String(wert),
);
anwenden();
}
export function anwenden(): void {
const r = document.documentElement.style;
r.setProperty('--accent', P.accent);
r.setProperty('--card-w', P.cardW + 'px');
r.setProperty('--hero-h', P.heroHeight + 'vh');
document.getElementById('app')?.classList.toggle('side', P.nav === 'side');
}
/** Die Palette der App, damit die Farben sich decken. */
export const PALETTE: readonly [string, string][] = [
['Ember', '#E5484D'], ['Tangerine', '#F76B15'], ['Amber', '#FFB224'],
['Lime', '#99D52A'], ['Mint', '#3DD68C'], ['Teal', '#12A594'],
['Sky', '#52A9FF'], ['Indigo', '#7C77E8'], ['Orchid', '#C04AC9'],
['Rose', '#F76190'],
];
export const RATES = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0] as const;
export const SKIPS = [5, 10, 15, 30, 45] as const;
export const QUALITIES: readonly [number, string][] = [
[0, 'Original'], [12000, 'Hoch — 12 Mbit/s'], [8000, 'Mittel — 8 Mbit/s'],
[4000, 'Niedrig — 4 Mbit/s'], [2000, 'Sparsam — 2 Mbit/s'],
];
export const LANGUAGES: readonly [string, string][] = [
['', 'Serverstandard'], ['ger', 'Deutsch'], ['eng', 'Englisch'],
['fre', 'Französisch'], ['spa', 'Spanisch'], ['ita', 'Italienisch'],
['jpn', 'Japanisch'], ['kor', 'Koreanisch'],
];
/* Anordnung der Reihen — dieselbe Rechnung wie `Arrangement` in MediaKit,
samt ihrer wichtigsten Eigenschaft: **unbekannte Einträge landen am Ende
statt zu verschwinden.** Sie zu verwerfen wäre die einfachere Regel — und
der Grund dafür, dass eine neu angelegte Mediathek nie aufgetaucht wäre. */
export function anordnen<T extends { id: string }>(reihen: T[]): T[] {
const sichtbar = reihen.filter((r) => !P.hiddenRows.includes(r.id));
if (!P.rowOrder.length) return sichtbar;
const platz = new Map(P.rowOrder.map((id, i) => [id, i]));
return sichtbar
.map((reihe, i) => ({ reihe, rang: platz.get(reihe.id) ?? Infinity, i }))
// Bei gleichem Rang gilt die ursprüngliche Folge — sonst springen zwei
// unbekannte Reihen bei jedem Laden gegeneinander.
.sort((a, b) => a.rang - b.rang || a.i - b.i)
.map((x) => x.reihe);
}