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
+174
View File
@@ -0,0 +1,174 @@
import { bildAdresse, type Film, type Serie, type Angefangen, type Stand } from './api';
import { esc, anteil } from './util';
import { icons } from './icons';
/** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */
export type Kachel = {
id: string;
art: 'filme' | 'serien';
/** Die obere, fette Zeile. Bei einer Folge die Serie. */
titel: string;
/** Die zweite Zeile. Bei einer Folge der Folgentitel, sonst das Jahr. */
zweite?: string | null;
/** Die dritte Zeile — „S14 · F22". Nur bei Folgen. */
dritte?: string | null;
bild: string | null;
stand?: Stand | null;
/** Die Zahl offener Folgen, als Plakette oben rechts. */
offen?: number | null;
};
/* ---------- Umwandlungen ---------- */
export const alsKachel = (e: Film | Serie): Kachel => ({
id: e.id,
art: 'episodeCount' in e ? 'serien' : 'filme',
titel: e.title,
zweite: e.year ? String(e.year) : null,
bild: e.posterImage,
stand: 'state' in e ? e.state : null,
offen: 'unwatchedCount' in e ? e.unwatchedCount : null,
});
/** Eine Kachel aus „Weiterschauen" — bei Folgen dreizeilig, wie bei Plex. */
export const angefangenAlsKachel = (e: Angefangen): Kachel => e.kind === 'episode'
? {
id: e.id, art: 'serien',
titel: e.title,
zweite: e.subTitle,
dritte: `S${e.season} · F${e.episode}`,
bild: e.posterImage, stand: e.state,
}
: {
id: e.id, art: 'filme',
titel: e.title,
zweite: e.year ? String(e.year) : null,
bild: e.posterImage, stand: e.state,
};
/* ---------- Die Kachel selbst ---------- */
/** Baut eine Plakatkachel.
*
* **Alles auf einer Kachel steht an einer Stelle**, weil sie an fünf Stellen
* vorkommt — Startseite, Mediathek, Vorschläge, Suche, Staffeln. Jede davon
* eigen zu zeichnen hiesse: der Haken für Gesehenes erscheint an vier
* Stellen und an der fünften nicht, und niemand weiss, warum. */
export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string) => void): HTMLElement {
const knopf = document.createElement('button');
knopf.className = 'kachel';
knopf.onclick = () => oeffnen(k.art, k.id);
const rahmen = document.createElement('div');
rahmen.className = 'plakat';
rahmen.setAttribute('role', 'img');
rahmen.setAttribute('aria-label', k.titel);
const adresse = bildAdresse(k.bild);
if (adresse) {
const bild = document.createElement('img');
bild.src = adresse;
bild.alt = '';
bild.loading = 'lazy';
// Erst einblenden, wenn es da ist — sonst blitzt der Platzhalter durch.
bild.onload = () => rahmen.classList.add('geladen');
rahmen.appendChild(bild);
}
// **Der Haken sitzt in der Ecke, nicht in der Beschriftung.** So wie bei
// Plex: ein Blick über das Raster zeigt, was noch offen ist, ohne eine
// einzige Zeile zu lesen.
if (k.stand?.watched) {
const haken = document.createElement('span');
haken.className = 'ecke haken';
haken.innerHTML = icons.haken;
haken.title = 'Gesehen';
rahmen.appendChild(haken);
} else if (k.offen != null && k.offen > 0) {
const plakette = document.createElement('span');
plakette.className = 'ecke zahl';
plakette.textContent = String(k.offen);
plakette.title = `${k.offen} Folgen offen`;
rahmen.appendChild(plakette);
}
// Der Balken unten — ab der ersten Sekunde, mit Mindestbreite, sonst wären
// zwei Minuten eines Zweistünders anderthalb Bildpunkte.
const wie = anteil(k.stand);
if (wie != null && wie > 0 && !k.stand?.watched) {
const balken = document.createElement('div');
balken.className = 'fortschritt';
const gefuellt = document.createElement('div');
gefuellt.style.width = `${Math.min(100, wie * 100)}%`;
balken.appendChild(gefuellt);
rahmen.appendChild(balken);
}
// Beim Überfahren ein Abspielknopf in der Mitte — auch das von Plex.
const schweben = document.createElement('span');
schweben.className = 'schweber';
schweben.innerHTML = icons.abspielen;
rahmen.appendChild(schweben);
knopf.appendChild(rahmen);
const text = document.createElement('div');
text.className = 'kacheltext';
text.innerHTML =
`<strong>${esc(k.titel)}</strong>`
+ (k.zweite ? `<span>${esc(k.zweite)}</span>` : '')
+ (k.dritte ? `<span class="dritte">${esc(k.dritte)}</span>` : '');
knopf.appendChild(text);
return knopf;
}
/** Eine waagerechte Reihe mit Überschrift und Pfeilen. */
export function reihe(
titel: string,
kacheln: HTMLElement[],
mehr?: { text: string; tun: () => void },
): HTMLElement {
const bereich = document.createElement('section');
bereich.className = 'reihe';
const kopf = document.createElement('div');
kopf.className = 'reihe-kopf';
const h = document.createElement('h2');
h.textContent = titel;
kopf.appendChild(h);
const streifen = document.createElement('div');
streifen.className = 'strip';
kacheln.forEach(k => streifen.appendChild(k));
// **Pfeile, weil eine Reihe mit zwanzig Kacheln nicht in den Bildschirm
// passt.** Scrollen mit dem Rad geht quer nur mit Umschalttaste, und ein
// Trackpad hat nicht jeder.
const steuerung = document.createElement('div');
steuerung.className = 'reihe-pfeile';
(['links', 'rechts'] as const).forEach(richtung => {
const p = document.createElement('button');
p.className = 'pfeil';
p.innerHTML = icons[richtung];
p.setAttribute('aria-label', richtung === 'links' ? 'Zurück' : 'Weiter');
p.onclick = () => streifen.scrollBy({
left: (richtung === 'links' ? -1 : 1) * Math.max(240, streifen.clientWidth * 0.8),
behavior: 'smooth',
});
steuerung.appendChild(p);
});
if (mehr) {
const m = document.createElement('button');
m.className = 'mehr';
m.textContent = mehr.text;
m.onclick = mehr.tun;
kopf.appendChild(m);
}
kopf.appendChild(steuerung);
bereich.append(kopf, streifen);
return bereich;
}