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
+37 -111
View File
@@ -1,18 +1,18 @@
import {
inhalt, weiterschauen, naechsteFolgen,
type Bibliothek, type Film, type Serie, type Angefangen,
inhalt, weiterschauen, naechsteFolgen, empfehlungen,
type Bibliothek,
} from '../api';
import { esc, plakat, leer, fehlerzeile, anteil, verbleibend } from '../util';
import { leer, fehlerzeile } from '../util';
import { kachel, alsKachel, angefangenAlsKachel, reihe } from '../card';
type Art = 'filme' | 'serien';
/** Die Startseite: waagerechte Reihen, wie bei Plex.
/** Die Startseite — waagerechte Reihen, wie bei Plex.
*
* Oben, was angefangen ist — darum geht man schliesslich hin. Darunter **je
* Mediathek eine Reihe** mit dem Zuletzt-Hinzugefügten. Nicht eine Reihe für
* alle Filme: wer „Filme" und „Dokumentationen" getrennt angelegt hat, hat
* sie getrennt gemeint, und eine Startseite, die sie wieder zusammenwirft,
* nimmt die Entscheidung zurück.
* Oben, was angefangen ist. Darunter **je Mediathek eine Reihe** mit dem
* zuletzt Hinzugefügten: wer „Filme" und „Dokumentationen" getrennt angelegt
* hat, hat sie getrennt gemeint, und eine Startseite, die sie wieder
* zusammenwirft, nimmt die Entscheidung zurück.
*
* Eine Reihe ohne Inhalt wird gar nicht erst gezeigt. */
export function startseite(
@@ -25,13 +25,13 @@ export function startseite(
void (async () => {
try {
// **Alles auf einmal anfragen, nicht eine Reihe nach der anderen.** Bei
// fünf Mediatheken wären das sonst sieben Anfragen nacheinander, und
// die Startseite baut sich vor den Augen des Benutzers Zeile für Zeile
// auf.
const [weiter, naechste, ...bestaende] = await Promise.all([
// **Alles auf einmal anfragen.** Bei fünf Mediatheken wären es sonst
// acht Anfragen nacheinander, und die Startseite baut sich vor den
// Augen des Benutzers Zeile für Zeile auf.
const [weiter, naechste, vorschlag, ...bestaende] = await Promise.all([
weiterschauen(),
naechsteFolgen(),
empfehlungen(24).catch(() => ({ items: [], total: 0 })),
...bibliotheken.map(b => inhalt(b, { sort: 'added', take: 24 })),
]);
@@ -43,17 +43,37 @@ export function startseite(
return;
}
if (weiter.items.length) wurzel.appendChild(angefangenReihe('Weiterschauen', weiter.items));
if (naechste.items.length) wurzel.appendChild(angefangenReihe('Nächste Folge', naechste.items));
if (weiter.items.length) {
wurzel.appendChild(reihe('Fortsetzen',
weiter.items.map(e => kachel(angefangenAlsKachel(e), oeffnen))));
}
if (naechste.items.length) {
wurzel.appendChild(reihe('Nächste Folge',
naechste.items.map(e => kachel(angefangenAlsKachel(e), oeffnen))));
}
let etwasDa = false;
bibliotheken.forEach((b, i) => {
const seite = bestaende[i];
if (!seite?.items.length) return;
etwasDa = true;
wurzel.appendChild(reihe(b, seite.items, seite.total));
wurzel.appendChild(reihe(
// Plex schreibt den Namen der Mediathek in die Überschrift —
// „Zuletzt in Filme hinzugefügt". Bei mehreren Mediatheken ist das
// der Unterschied zwischen einer Reihe und sieben gleich
// aussehenden.
`Zuletzt in ${b.name} hinzugefügt`,
seite.items.map(e => kachel(alsKachel(e), oeffnen)),
seite.total > seite.items.length
? { text: 'Alle zeigen', tun: () => alleZeigen(b) }
: undefined));
});
if (vorschlag.items.length) {
wurzel.appendChild(reihe('Weil du Ähnliches gesehen hast',
vorschlag.items.map(e => kachel(alsKachel(e), oeffnen))));
}
if (!etwasDa && !weiter.items.length && !naechste.items.length) {
wurzel.appendChild(leer('Noch nichts da. Starte unter Verwaltung einen Scan.'));
}
@@ -63,99 +83,5 @@ export function startseite(
}
})();
/** Eine Reihe aus Einträgen mit Stand — Filme und Folgen gemischt. */
function angefangenReihe(titel: string, eintraege: Angefangen[]): HTMLElement {
const bereich = kopfzeile(titel);
const streifen = document.createElement('div');
streifen.className = 'strip';
eintraege.forEach(eintrag => {
const k = document.createElement('button');
k.className = 'card';
k.appendChild(plakat(eintrag.posterImage, eintrag.title, anteil(eintrag.state)));
const unten = document.createElement('div');
unten.className = 'rowline';
// Bei einer Folge oben die Serie, darunter wo man ist — eine Folge
// ohne ihre Serie zu nennen hilft niemandem beim Wiedererkennen.
const zweite = eintrag.kind === 'episode'
? `S${String(eintrag.season).padStart(2, '0')}E${String(eintrag.episode).padStart(2, '0')}`
+ (eintrag.subTitle ? ` · ${eintrag.subTitle}` : '')
: verbleibend(eintrag.state);
unten.innerHTML = `<strong>${esc(eintrag.title)}</strong>`
+ `<span class="tiny">${esc(zweite)}</span>`;
k.appendChild(unten);
// Angesprungen wird der Titel, nicht die Folge: von dort kommt man zur
// Folge, umgekehrt fehlte der Zusammenhang.
k.onclick = () => oeffnen(eintrag.kind === 'movie' ? 'filme' : 'serien', eintrag.id);
streifen.appendChild(k);
});
bereich.appendChild(streifen);
return bereich;
}
function reihe(b: Bibliothek, eintraege: (Film | Serie)[], gesamt: number): HTMLElement {
const art: Art = b.kind === 'Shows' ? 'serien' : 'filme';
// Steht mehr drin, als die Reihe zeigt, führt ein Weg dorthin. Ohne ihn
// wäre die Reihe eine Sackgasse mit vierundzwanzig Kacheln.
const bereich = kopfzeile(
b.name, gesamt > eintraege.length ? () => alleZeigen(b) : undefined, gesamt);
const streifen = document.createElement('div');
streifen.className = 'strip';
eintraege.forEach(eintrag => {
const k = document.createElement('button');
k.className = 'card';
const bild = plakat(
eintrag.posterImage, eintrag.title,
'state' in eintrag ? anteil(eintrag.state) : null);
if ('unwatchedCount' in eintrag && eintrag.unwatchedCount > 0) {
const plakette = document.createElement('span');
plakette.className = 'offen';
plakette.textContent = String(eintrag.unwatchedCount);
bild.appendChild(plakette);
}
k.appendChild(bild);
const unten = document.createElement('div');
unten.className = 'rowline';
unten.innerHTML = `<strong>${esc(eintrag.title)}</strong>`
+ `<span class="tiny">${esc(eintrag.year ?? '')}</span>`;
k.appendChild(unten);
k.onclick = () => oeffnen(art, eintrag.id);
streifen.appendChild(k);
});
bereich.appendChild(streifen);
return bereich;
}
function kopfzeile(titel: string, mehr?: () => void, gesamt?: number): HTMLElement {
const bereich = document.createElement('section');
bereich.className = 'reihe';
const zeile = document.createElement('div');
zeile.className = 'reihe-kopf';
const h = document.createElement('h2');
h.textContent = gesamt != null ? `${titel} · ${gesamt}` : titel;
zeile.appendChild(h);
if (mehr) {
const knopf = document.createElement('button');
knopf.className = 'mehr';
knopf.textContent = 'Alle zeigen';
knopf.onclick = mehr;
zeile.appendChild(knopf);
}
bereich.appendChild(zeile);
return bereich;
}
return wurzel;
}