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>
239 lines
8.2 KiB
TypeScript
239 lines
8.2 KiB
TypeScript
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 — mit Reitern, wie bei Plex.
|
|
*
|
|
* **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 welcher: Reiter =
|
|
reiter === 'empfehlungen' || reiter === 'kategorien' ? reiter : 'mediathek';
|
|
|
|
wurzel.appendChild(kopf());
|
|
const buehne = document.createElement('div');
|
|
wurzel.appendChild(buehne);
|
|
|
|
if (welcher === 'empfehlungen') empfehlungen(buehne);
|
|
else if (welcher === 'kategorien') kategorien(buehne);
|
|
else raster(buehne);
|
|
|
|
/* ---------- Kopf mit Reitern ---------- */
|
|
|
|
function kopf(): HTMLElement {
|
|
const k = document.createElement('div');
|
|
k.className = 'seitenkopf';
|
|
|
|
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 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);
|
|
});
|
|
|
|
k.append(wer, reiterleiste);
|
|
return k;
|
|
}
|
|
|
|
/* ---------- 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;
|
|
}
|
|
|
|
/** 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%))`;
|
|
}
|