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
+215 -101
View File
@@ -1,124 +1,238 @@
import { inhalt, type Bibliothek, type Film, type Serie, type Ordnung } from '../api';
import { esc, plakat, leer, fehlerzeile, anteil } from '../util';
import { inhalt, genresVon, empfehlungen as holeEmpfehlungen, type Bibliothek, type Ordnung } from '../api';
import { leer, fehlerzeile } from '../util';
import { kachel, alsKachel, reihe } from '../card';
type Art = 'filme' | 'serien';
type Reiter = 'empfehlungen' | 'mediathek' | 'kategorien';
/** Eine Mediathek als Raster.
/** Eine Mediathek — mit Reitern, wie bei Plex.
*
* **Eine Ansicht je Mediathek, nicht eine je Art.** Welche gezeigt wird,
* entscheidet die Seitenleiste — wie bei Plex, wo jede Mediathek ihr eigener
* Punkt in der Navigation ist. Wer „Filme" und „Dokumentationen" getrennt
* angelegt hat, hat sie getrennt gemeint.
*
* Gesucht und sortiert wird am Server: bei zehntausend Titeln wäre alles zu
* laden und dann zu sieben der falsche Weg. */
* **Drei Sichten auf denselben Bestand.** „Empfehlungen" ist das, womit man
* ankommt; „Mediathek" ist das vollständige Raster für alle, die etwas
* Bestimmtes suchen; „Kategorien" ist der Weg hinein, wenn man noch nicht
* weiss, wonach. Alles in eine Ansicht zu legen hiesse, zwei davon unter ein
* Ausklappmenü zu schieben. */
export function mediathek(
bibliothek: Bibliothek,
servername: string,
reiter: string,
oeffnen: (art: Art, id: string) => void,
): HTMLElement {
const wurzel = document.createElement('div');
const art: Art = bibliothek.kind === 'Shows' ? 'serien' : 'filme';
let suche = '';
let ordnung: Ordnung = 'title';
let tippt: number | undefined;
const welcher: Reiter =
reiter === 'empfehlungen' || reiter === 'kategorien' ? reiter : 'mediathek';
const kopf = document.createElement('div');
kopf.className = 'row';
wurzel.appendChild(kopf());
const buehne = document.createElement('div');
wurzel.appendChild(buehne);
const titel = document.createElement('h1');
titel.className = 'seitentitel';
titel.textContent = bibliothek.name;
if (welcher === 'empfehlungen') empfehlungen(buehne);
else if (welcher === 'kategorien') kategorien(buehne);
else raster(buehne);
const sortierung = document.createElement('select');
sortierung.className = 'field schmal';
([
['title', 'A–Z'],
['added', 'Zuletzt hinzugefügt'],
['year', 'Jahr'],
] as [Ordnung, string][]).forEach(([wert, text]) => {
const o = document.createElement('option');
o.value = wert;
o.textContent = text;
sortierung.appendChild(o);
});
sortierung.onchange = () => { ordnung = sortierung.value as Ordnung; void laden(); };
/* ---------- Kopf mit Reitern ---------- */
const suchfeld = document.createElement('input');
suchfeld.className = 'field';
suchfeld.type = 'search';
suchfeld.placeholder = `In ${bibliothek.name} suchen`;
suchfeld.oninput = () => {
// Nicht bei jedem Tastendruck fragen: das wären bei „Untergang" neun
// Anfragen, von denen acht niemanden interessieren.
window.clearTimeout(tippt);
tippt = window.setTimeout(() => { suche = suchfeld.value.trim(); void laden(); }, 250);
};
function kopf(): HTMLElement {
const k = document.createElement('div');
k.className = 'seitenkopf';
kopf.append(titel, sortierung, suchfeld);
const wer = document.createElement('div');
wer.className = 'wer';
const h = document.createElement('h1');
h.textContent = bibliothek.name;
const quelle = document.createElement('div');
quelle.className = 'quelle';
quelle.textContent = servername;
wer.append(h, quelle);
const raster = document.createElement('div');
raster.className = 'grid';
const reiterleiste = document.createElement('nav');
reiterleiste.className = 'reiter';
([
['empfehlungen', 'Empfehlungen'],
['mediathek', 'Mediathek'],
['kategorien', 'Kategorien'],
] as [Reiter, string][]).forEach(([wert, text]) => {
const b = document.createElement('button');
b.textContent = text;
b.classList.toggle('check', wert === welcher);
b.onclick = () => { location.hash = `#/m/${bibliothek.id}/${wert}`; };
reiterleiste.appendChild(b);
});
wurzel.append(kopf, raster);
async function laden(): Promise<void> {
raster.innerHTML = '';
raster.appendChild(ladeanzeige());
try {
const seite = await inhalt(bibliothek, { search: suche, sort: ordnung, take: 100 });
raster.innerHTML = '';
if (seite.items.length === 0) {
raster.appendChild(leer(suche
? `Nichts gefunden für „${suche}“.`
: `„${bibliothek.name}“ ist leer. Starte unter Verwaltung einen Scan.`));
return;
}
seite.items.forEach(eintrag => raster.appendChild(karte(eintrag)));
} catch (fehler) {
raster.innerHTML = '';
raster.appendChild(fehlerzeile((fehler as Error).message));
}
}
function karte(eintrag: Film | Serie): HTMLElement {
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';
// Zusammengesetzt aus dem, was da ist — ein fest verdrahtetes „ · " davor
// hinterlässt bei fehlendem Jahr einen Punkt ohne Angabe.
const teile = [
eintrag.year ? String(eintrag.year) : null,
'episodeCount' in eintrag ? `${eintrag.episodeCount} Folgen` : null,
'state' in eintrag && eintrag.state?.watched ? 'Gesehen' : null,
].filter(Boolean);
unten.innerHTML = `<strong>${esc(eintrag.title)}</strong>`
+ `<span class="tiny">${esc(teile.join(' · '))}</span>`;
k.appendChild(unten);
k.onclick = () => oeffnen(art, eintrag.id);
k.append(wer, reiterleiste);
return k;
}
void laden();
/* ---------- Reiter „Mediathek" ---------- */
function raster(ziel: HTMLElement): void {
let suche = '';
let ordnung: Ordnung = 'title';
let genre = '';
let tippt: number | undefined;
const zeile = document.createElement('div');
zeile.className = 'filterzeile';
const anzahl = document.createElement('span');
anzahl.className = 'anzahl';
anzahl.textContent = '…';
const genreWahl = document.createElement('select');
genreWahl.innerHTML = '<option value="">Alle Genres</option>';
genreWahl.onchange = () => { genre = genreWahl.value; void laden(); };
const sortierung = document.createElement('select');
([
['title', 'Nach Titel'],
['added', 'Zuletzt hinzugefügt'],
['year', 'Nach Jahr'],
] as [Ordnung, string][]).forEach(([wert, text]) => {
const o = document.createElement('option');
o.value = wert; o.textContent = text;
sortierung.appendChild(o);
});
sortierung.onchange = () => { ordnung = sortierung.value as Ordnung; void laden(); };
const suchfeld = document.createElement('input');
suchfeld.className = 'suche';
suchfeld.type = 'search';
suchfeld.placeholder = `In ${bibliothek.name} suchen`;
suchfeld.oninput = () => {
// Nicht bei jedem Tastendruck fragen: das wären bei „Untergang" neun
// Anfragen, von denen acht niemanden interessieren.
window.clearTimeout(tippt);
tippt = window.setTimeout(() => { suche = suchfeld.value.trim(); void laden(); }, 250);
};
zeile.append(genreWahl, sortierung, anzahl, suchfeld);
const gitter = document.createElement('div');
gitter.className = 'raster';
ziel.append(zeile, gitter);
// Die Genres der Mediathek nachladen — die Liste steht erst, wenn der
// Server sie genannt hat, und solange bleibt „Alle Genres" allein.
void genresVon(bibliothek.id).then(namen => {
namen.forEach(n => {
const o = document.createElement('option');
o.value = n; o.textContent = n;
genreWahl.appendChild(o);
});
}).catch(() => {});
void laden();
async function laden(): Promise<void> {
gitter.innerHTML = '<div class="spinner"></div>';
try {
const seite = await inhalt(bibliothek, { search: suche, sort: ordnung, genre, take: 200 });
anzahl.textContent = String(seite.total);
gitter.innerHTML = '';
if (seite.items.length === 0) {
gitter.appendChild(leer(suche || genre
? 'Nichts gefunden.'
: `„${bibliothek.name}" ist leer. Starte unter Verwaltung einen Scan.`));
return;
}
seite.items.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen)));
} catch (fehler) {
gitter.innerHTML = '';
gitter.appendChild(fehlerzeile((fehler as Error).message));
}
}
}
/* ---------- Reiter „Empfehlungen" ---------- */
function empfehlungen(ziel: HTMLElement): void {
ziel.innerHTML = '<div class="spinner"></div>';
void (async () => {
try {
const [neu, beliebt, vorschlag] = await Promise.all([
inhalt(bibliothek, { sort: 'added', take: 24 }),
inhalt(bibliothek, { sort: 'year', take: 24 }),
holeEmpfehlungen(24).catch(() => ({ items: [], total: 0 })),
]);
ziel.innerHTML = '';
if (neu.items.length === 0) {
ziel.appendChild(leer(`„${bibliothek.name}" ist leer.`));
return;
}
ziel.appendChild(reihe(
`Zuletzt in ${bibliothek.name} hinzugefügt`,
neu.items.map(e => kachel(alsKachel(e), oeffnen))));
if (vorschlag.items.length) {
ziel.appendChild(reihe(
'Weil du Ähnliches gesehen hast',
vorschlag.items.map(e => kachel(alsKachel(e), oeffnen))));
}
ziel.appendChild(reihe(
'Nach Jahr',
beliebt.items.map(e => kachel(alsKachel(e), oeffnen))));
} catch (fehler) {
ziel.innerHTML = '';
ziel.appendChild(fehlerzeile((fehler as Error).message));
}
})();
}
/* ---------- Reiter „Kategorien" ---------- */
function kategorien(ziel: HTMLElement): void {
ziel.innerHTML = '<div class="spinner"></div>';
void (async () => {
try {
const namen = await genresVon(bibliothek.id);
ziel.innerHTML = '';
if (namen.length === 0) {
ziel.appendChild(leer('Für diese Mediathek sind noch keine Genres bekannt. '
+ 'Frisch unter Verwaltung die Metadaten auf.'));
return;
}
const gitter = document.createElement('div');
gitter.className = 'kategorien';
namen.forEach(name => {
const k = document.createElement('button');
k.className = 'kategorie';
k.textContent = name;
// **Die Farbe aus dem Namen, nicht aus einer Liste.** Eine feste
// Zuordnung Genre→Farbe müsste jedes Genre kennen, das TMDB je
// erfindet. So bekommt jedes eine eigene und immer dieselbe.
k.style.background = farbeAus(name);
k.onclick = () => {
location.hash = `#/m/${bibliothek.id}/mediathek`;
// Der Filter wird über die Adresse gesetzt, sobald das Raster
// steht — sonst schriebe man in ein Element, das es nicht gibt.
window.setTimeout(() => {
const wahl = document.querySelector<HTMLSelectElement>('.filterzeile select');
if (wahl) { wahl.value = name; wahl.dispatchEvent(new Event('change')); }
}, 0);
};
gitter.appendChild(k);
});
ziel.appendChild(gitter);
} catch (fehler) {
ziel.innerHTML = '';
ziel.appendChild(fehlerzeile((fehler as Error).message));
}
})();
}
return wurzel;
}
function ladeanzeige(): HTMLElement {
const d = document.createElement('div');
d.className = 'spinner';
return d;
/** Eine immer gleiche Farbe aus einem Namen. */
function farbeAus(name: string): string {
let summe = 0;
for (const z of name) summe = (summe * 31 + z.charCodeAt(0)) % 360;
return `linear-gradient(135deg, hsl(${summe} 42% 28%), hsl(${(summe + 40) % 360} 38% 20%))`;
}