Die Oberfläche nach Plex' Vorbild — Hintergrund, Schriftzug, Besetzung

Nach den Bildern deiner Plex-Installation umgebaut. Übernommen ist der
Aufbau, nicht die Farbe: Plex ist bernsteingelb, Flaro bleibt Orchidee.

**Kopfleiste oben** mit Marke, Suche und Konto. Die Suche gehört dorthin und
nicht in jede Mediathek: wer einen Titel sucht, weiss oft gar nicht, in
welcher er liegt. Sie geht quer durch alles.

**Seitenleiste mit Icons** — echte SVG, keine Zeichen aus einer Schriftart.
Ein ▶ aus Unicode sieht auf jedem System anders aus, lässt sich nicht
einfärben und springt in der Grundlinie. Die aktive Zeile bekommt einen
schmalen Balken links statt eines leuchtenden Blocks; das liest sich in
einer langen Liste ruhiger.

**Die Kachel steht jetzt an einer Stelle** statt an fünfen. Sie trägt den
Haken für Gesehenes in der Ecke — wie bei Plex, ein Blick über das Raster
genügt —, die Zahl offener Folgen an derselben Stelle, den Fortschrittsbalken
unten und beim Überfahren einen Abspielknopf. Folgen sind dreizeilig: Serie,
Folgentitel, „S14 · F22".

**Reihen haben Pfeile.** Zwanzig Kacheln passen nicht in den Bildschirm, und
quer zu scrollen geht mit dem Rad nur mit Umschalttaste. Die Überschrift
nennt die Mediathek beim Namen: „Zuletzt in Dokumentationen hinzugefügt".

**Mediatheken haben drei Reiter** — Empfehlungen, Mediathek, Kategorien.
Darunter eine Filterzeile mit Genre, Sortierung und der Anzahl. Die
Kategorien sind farbige Kacheln; die Farbe kommt aus dem Namen, nicht aus
einer festen Liste, die jedes Genre kennen müsste, das TMDB je erfindet.

**Die Detailseite** hat jetzt den unscharfen Hintergrund über die ganze
Seite, den **Schriftzug des Films** statt der Systemschrift, eine Zeile mit
Jahr, Genres, Laufzeit, Altersfreigabe und Bewertung, runde Knöpfe für
gesehen/Favorit/weglegen, die Medienangaben als Tabelle — Datei, Video, Ton,
Untertitel — und zuletzt Besetzung & Stab mit runden Porträts.

**Dazu am Server:** ein Name. Ohne ihn zeigten die Clients den Hostnamen,
und die App schrieb „localhost" über die Anmeldung. Vorgabe ist der
Rechnername des Containers.

---

**Der Fehler, den das erste Bild zeigte:** die Lupe in der Suchleiste war
dreihundert Bildpunkte gross und lag über der halben Seite. Ein rohes
`<svg>` ohne Breite füllt seinen Container; die Regel galt nur für `.icon`.
Jetzt für jedes.

368 Tests grün. Am Container nachgesehen: „Der Untergang" mit eigenem
Schriftzug, FSK 12, Bewertung 7,9, Genres Drama/Historie/Kriegsfilm, beide
Tonspuren in der Tabelle, 22 Porträts darunter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 01:04:27 +02:00
co-authored by Claude Opus 5
parent ede4069cd4
commit 8c395ac051
15 changed files with 1428 additions and 693 deletions
+44 -47
View File
@@ -1,51 +1,48 @@
/* Die Symbole der Oberfläche.
----------------------------------------------------------------------------
Als Vektor, nicht als Schriftzeichen. Ein Wiedergabedreieck oder ein
Lautsprecher-Emoji wird auf jeder Plattform anders gezeichnet, trägt fremde
Farben, sitzt schief auf der Grundlinie und lässt sich weder in der
Strichstärke noch in der Farbe anpassen — es sieht nach Platzhalter aus,
weil es einer ist.
/* Icons als SVG, nicht als Zeichen aus einer Schriftart.
*
* **Keine Emojis.** Ein ▶ oder ✓ aus Unicode sieht auf jedem System anders
* aus, lässt sich nicht einfärben, nicht in der Strichstärke anpassen und
* springt in der Grundlinie. Diese hier sind auf 24×24 gezeichnet, erben die
* Textfarbe und skalieren mit der Schriftgrösse.
*
* Der Strichstil ist durchgehend derselbe: 1.8 Einheiten, runde Enden. */
`currentColor` erbt Farbe und Zustand vom Knopf darum. */
const rahmen = (inhalt: string, gefuellt = false): string =>
`<svg viewBox="0 0 24 24" aria-hidden="true" fill="${gefuellt ? 'currentColor' : 'none'}"`
+ ` stroke="${gefuellt ? 'none' : 'currentColor'}" stroke-width="1.8"`
+ ` stroke-linecap="round" stroke-linejoin="round">${inhalt}</svg>`;
type Zeichner = string | ((n: number) => string);
export const icons = {
start: rahmen('<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/>'),
/** Filmstreifen — für eine Filmmediathek. */
filme: rahmen('<rect x="3" y="4" width="18" height="16" rx="2"/>'
+ '<path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>'),
/** Bildschirm — für eine Serienmediathek. */
serien: rahmen('<rect x="2.5" y="4.5" width="19" height="12" rx="2"/><path d="M8 20h8"/>'),
verwaltung: rahmen('<circle cx="12" cy="12" r="3.2"/>'
+ '<path d="M19.4 14.5a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-1.8-.3 1.6 1.6 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-1-1.5 1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0 .3-1.8 1.6 1.6 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.5-1 1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 1.8.3H9a1.6 1.6 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 1 1.5 1.6 1.6 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8V9a1.6 1.6 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1z"/>'),
suche: rahmen('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'),
abspielen: rahmen('<path d="M8 5.5v13l11-6.5z"/>', true),
haken: rahmen('<path d="m5 12.5 4.5 4.5L19 7"/>'),
hakenKreis: rahmen('<circle cx="12" cy="12" r="9"/><path d="m8 12.3 2.8 2.7L16 9.6"/>'),
stift: rahmen('<path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z"/>'),
download: rahmen('<path d="M12 3v12"/><path d="m7.5 11 4.5 4.5L16.5 11"/><path d="M4 20h16"/>'),
links: rahmen('<path d="M15 5 8 12l7 7"/>'),
rechts: rahmen('<path d="m9 5 7 7-7 7"/>'),
mehr: rahmen('<circle cx="12" cy="5.5" r="1.3" fill="currentColor" stroke="none"/>'
+ '<circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none"/>'
+ '<circle cx="12" cy="18.5" r="1.3" fill="currentColor" stroke="none"/>'),
abmelden: rahmen('<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3"/>'
+ '<path d="M10 16.5 14.5 12 10 7.5"/><path d="M14.5 12H4"/>'),
stern: rahmen('<path d="m12 3.6 2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.5 9.8l5.9-.9z"/>'),
} as const;
const ICONS: Record<string, Zeichner> = {
play: '<path d="M8 5.5v13l11-6.5z" fill="currentColor" stroke="none"/>',
waveform: '<path d="M3 12h2M7 8v8M11 4.5v15M15 8v8M19 11h2"/>',
speed: '<path d="M12 20a8 8 0 100-16 8 8 0 000 16z"/><path d="M12 12l4-3"/>',
// Kreispfeile mit der Sekundenzahl darin — dasselbe Bild wie
// `gobackward.10` und `goforward.30` auf dem iPad.
goBack: (n) => `<path d="M11.5 4.5L7 8l4.5 3.5" /><path d="M7 8h4.5a7.5 7.5 0 11-7.2 9.6"/>`
+ `<text x="12" y="17.6" text-anchor="middle" font-size="7.5" font-weight="700"`
+ ` fill="currentColor" stroke="none" font-family="inherit">${n}</text>`,
goFwd: (n) => `<path d="M12.5 4.5L17 8l-4.5 3.5"/><path d="M17 8h-4.5a7.5 7.5 0 107.2 9.6"/>`
+ `<text x="12" y="17.6" text-anchor="middle" font-size="7.5" font-weight="700"`
+ ` fill="currentColor" stroke="none" font-family="inherit">${n}</text>`,
captions: '<rect x="3" y="5.5" width="18" height="13" rx="3"/><path d="M9.5 10.5a2.2 2.2 0 100 3M16 10.5a2.2 2.2 0 100 3"/>',
captionsOn: '<rect x="3" y="5.5" width="18" height="13" rx="3" fill="currentColor" stroke="none"/><path d="M9.5 10.5a2.2 2.2 0 100 3M16 10.5a2.2 2.2 0 100 3" stroke="#0B0B0D"/>',
check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
// Rechteck mit Balken oben bzw. Spalte links — die Leiste zieht dorthin.
barTop: '<rect x="2" y="3.5" width="20" height="17" rx="3"/><path d="M2 9h20"/>'
+ '<rect x="2" y="3.5" width="20" height="5.5" rx="3" fill="currentColor" stroke="none" opacity=".9"/>',
barSide: '<rect x="2" y="3.5" width="20" height="17" rx="3"/><path d="M8.5 3.5v17"/>'
+ '<rect x="2" y="3.5" width="6.5" height="17" rx="3" fill="currentColor" stroke="none" opacity=".9"/>',
pause: '<path d="M9 5.5v13M15 5.5v13"/>',
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
sound: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4z" fill="currentColor"/><path d="M16 9a4 4 0 010 6"/><path d="M18.5 6.5a7.5 7.5 0 010 11"/>',
muted: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4z" fill="currentColor"/><path d="M16 9.5l5 5M21 9.5l-5 5"/>',
expand: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
left: '<path d="M14.5 5.5L8 12l6.5 6.5"/>',
right: '<path d="M9.5 5.5L16 12l-6.5 6.5"/>',
up: '<path d="M12 18.5v-13M6.5 11L12 5.5 17.5 11"/>',
down: '<path d="M12 5.5v13M6.5 13L12 18.5 17.5 13"/>',
}
/** Ein Symbol als Vektor. `size` nur, wo es von der Vorgabe abweicht. */
export function icon(name: keyof typeof ICONS, size?: number, arg?: number): string {
const eintrag = ICONS[name];
const inhalt = typeof eintrag === 'function' ? eintrag(arg ?? 0) : eintrag;
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
${size ? `style="width:${size}px;height:${size}px"` : ''}>${inhalt}</svg>`;
export type IconName = keyof typeof icons;
/** Ein Icon als Element — für alles, was nicht aus einer Zeichenkette gebaut wird. */
export function icon(name: IconName, klasse = 'icon'): HTMLElement {
const h = document.createElement('span');
h.className = klasse;
h.innerHTML = icons[name];
return h;
}