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:
co-authored by
Claude Opus 5
parent
ede4069cd4
commit
8c395ac051
+215
-101
@@ -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%))`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user