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
+4 -40
View File
@@ -1,4 +1,4 @@
import { bildAdresse } from './api';
export function esc(wert: unknown): string {
return String(wert ?? '').replace(/[&<>"']/g, z =>
@@ -20,42 +20,6 @@ export function groesse(bytes: number): string {
return `${Math.round(bytes / 1024)} KB`;
}
/** Ein Plakat mit Platzhalter dahinter.
*
* Der Platzhalter hält die Fläche frei, bis das Bild da ist — ohne ihn
* springt das Raster beim Nachladen jedes Mal um. */
export function plakat(
schluessel: string | null, beschriftung: string, fortschritt?: number | null,
): HTMLElement {
const rahmen = document.createElement('div');
rahmen.className = 'poster';
rahmen.setAttribute('role', 'img');
rahmen.setAttribute('aria-label', beschriftung);
const adresse = bildAdresse(schluessel);
if (adresse) {
const img = document.createElement('img');
img.src = adresse;
img.alt = '';
img.loading = 'lazy';
// Erst einblenden, wenn es da ist — sonst blitzt der Platzhalter durch.
img.onload = () => rahmen.classList.add('geladen');
rahmen.appendChild(img);
}
// Der Balken unten, wie bei Plex. Er erscheint, sobald überhaupt etwas
// gesehen wurde — es gibt keinen Anteil, der dafür zu klein wäre.
if (fortschritt != null && fortschritt > 0) {
const balken = document.createElement('div');
balken.className = 'fortschritt';
const gefuellt = document.createElement('div');
gefuellt.style.width = `${Math.min(100, fortschritt * 100)}%`;
balken.appendChild(gefuellt);
rahmen.appendChild(balken);
}
return rahmen;
}
/** Wie weit ein Stand gediehen ist, als Anteil — oder nichts. */
export function anteil(stand: { positionSeconds: number; runtimeSeconds: number | null } | null | undefined)
: number | null {
@@ -79,7 +43,7 @@ export function knopf(text: string, klasse = 'btn'): HTMLButtonElement {
export function feld(platzhalter: string, art = 'text'): HTMLInputElement {
const i = document.createElement('input');
i.className = 'field';
i.className = 'feld';
i.type = art;
i.placeholder = platzhalter;
return i;
@@ -88,14 +52,14 @@ export function feld(platzhalter: string, art = 'text'): HTMLInputElement {
/** Zeigt einen Fehler, ohne die Seite zu verlassen. */
export function fehlerzeile(text: string): HTMLElement {
const d = document.createElement('div');
d.className = 'error';
d.className = 'fehler';
d.textContent = text;
return d;
}
export function leer(text: string): HTMLElement {
const d = document.createElement('div');
d.className = 'empty';
d.className = 'leer';
d.textContent = text;
return d;
}