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>
145 lines
5.1 KiB
TypeScript
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);
|
|
}
|