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
+19 -19
View File
@@ -22,7 +22,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
h.textContent = 'Bibliotheken';
const liste = document.createElement('div');
liste.className = 'settings';
liste.className = 'einstellungen';
bereich.append(h, liste, anlegeform());
void laden();
@@ -45,10 +45,10 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
function block(b: Bibliothek): HTMLElement {
const d = document.createElement('div');
d.className = 'setting';
d.className = 'einstellung';
const kopf = document.createElement('div');
kopf.className = 'rowline';
kopf.className = 'zeile';
// „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat.
const serie = b.kind === 'Shows';
const anzahl = b.itemCount == null ? ''
@@ -56,11 +56,11 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
? (serie ? 'Serie' : 'Film')
: (serie ? 'Serien' : 'Filme'));
kopf.innerHTML = `<strong>${esc(b.name)}</strong>`
+ `<span class="tiny">${esc(b.path)}${esc(anzahl)}</span>`;
+ `<span class="winzig">${esc(b.path)}${esc(anzahl)}</span>`;
d.appendChild(kopf);
const stand = document.createElement('div');
stand.className = 'hint';
stand.className = 'hinweis';
const los = knopf('Scannen', 'btn');
const umbenennen = knopf('Umbenennen', 'btn');
const entfernen = knopf('Entfernen', 'btn');
@@ -71,7 +71,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
eingabe.value = b.name;
const fertig = knopf('Speichern', 'btn btn-primary');
const reihe = document.createElement('div');
reihe.className = 'btn-row';
reihe.className = 'knopfreihe';
reihe.append(eingabe, fertig);
d.appendChild(reihe);
eingabe.focus();
@@ -140,7 +140,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
}
const knoepfe = document.createElement('div');
knoepfe.className = 'btn-row';
knoepfe.className = 'knopfreihe';
knoepfe.append(los, umbenennen, entfernen);
d.append(knoepfe, stand);
void verfolgen();
@@ -149,7 +149,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
function anlegeform(): HTMLElement {
const form = document.createElement('div');
form.className = 'setting';
form.className = 'einstellung';
const name = feld('Name, etwa Filme');
const pfad = feld('Pfad im Container, etwa /media/Filme');
@@ -159,7 +159,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
const los = knopf('Anlegen', 'btn btn-primary');
const reihe = document.createElement('div');
reihe.className = 'btn-row';
reihe.className = 'knopfreihe';
reihe.append(name, art, pfad, los);
form.append(reihe);
@@ -187,21 +187,21 @@ function benutzerbereich(): HTMLElement {
const h = document.createElement('h2');
h.textContent = 'Benutzer';
const liste = document.createElement('div');
liste.className = 'settings';
liste.className = 'einstellungen';
const name = feld('Benutzername');
const passwort = feld('Passwort, mindestens 8 Zeichen', 'password');
const admin = document.createElement('label');
admin.className = 'hint';
admin.className = 'hinweis';
const haken = document.createElement('input');
haken.type = 'checkbox';
admin.append(haken, document.createTextNode(' Administrator'));
const los = knopf('Anlegen', 'btn');
const form = document.createElement('div');
form.className = 'setting';
form.className = 'einstellung';
const reihe = document.createElement('div');
reihe.className = 'btn-row';
reihe.className = 'knopfreihe';
reihe.append(name, passwort, admin, los);
form.append(reihe);
@@ -215,9 +215,9 @@ function benutzerbereich(): HTMLElement {
liste.innerHTML = '';
alle.forEach(b => {
const d = document.createElement('div');
d.className = 'setting';
d.innerHTML = `<div class="rowline"><strong>${esc(b.userName)}</strong>`
+ `<span class="tiny">${b.isAdministrator ? 'Administrator' : 'Benutzer'}</span></div>`;
d.className = 'einstellung';
d.innerHTML = `<div class="zeile"><strong>${esc(b.userName)}</strong>`
+ `<span class="winzig">${b.isAdministrator ? 'Administrator' : 'Benutzer'}</span></div>`;
liste.appendChild(d);
});
} catch (fehler) {
@@ -247,7 +247,7 @@ function metadatenbereich(): HTMLElement {
const h = document.createElement('h2');
h.textContent = 'Metadaten';
const block = document.createElement('div');
block.className = 'setting';
block.className = 'einstellung';
bereich.append(h, block);
void (async () => {
@@ -255,7 +255,7 @@ function metadatenbereich(): HTMLElement {
const stand = await metadatenEinstellung();
const auskunft = document.createElement('div');
auskunft.className = 'hint';
auskunft.className = 'hinweis';
auskunft.textContent = stand.usingProjectKey
? 'Es wird der mitgelieferte TMDB-Zugang benutzt. Ein eigener löst ihn ab.'
: stand.hasApiKey ? 'Ein eigener TMDB-Zugang ist hinterlegt.'
@@ -267,7 +267,7 @@ function metadatenbereich(): HTMLElement {
const los = knopf('Speichern', 'btn');
const reihe = document.createElement('div');
reihe.className = 'btn-row';
reihe.className = 'knopfreihe';
reihe.append(schluessel, sprache, los);
los.onclick = async () => {
+366 -160
View File
@@ -1,14 +1,20 @@
import {
film as filmLaden, serie as serieLaden, folgen, lieferung, downloadAdresse,
vorschlaege, zuordnen, bildAdresse, istAdmin, fortschritt, gesehen,
vorschlaege, zuordnen, bildAdresse, istAdmin, fortschritt, gesehen, favorit, weglegen,
type Film, type Serie, type Folge, type Datei, type Lieferung, type Stand,
type Mitwirkender,
} from '../api';
import {
esc, dauer, groesse, plakat, knopf, feld, fehlerzeile, leer, anteil, verbleibend,
} from '../util';
import { esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil } from '../util';
import { icons, icon } from '../icons';
type Art = 'filme' | 'serien';
/** Die Detailseite — im Aufbau von Plex.
*
* Unscharfer Hintergrund über die ganze Seite, Plakat links, rechts der
* Schriftzug des Films statt der Systemschrift, darunter Eckdaten,
* Bewertung, Knöpfe, Beschreibung, die Medienangaben als Tabelle und zuletzt
* die Besetzung mit runden Porträts. */
export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElement {
const wurzel = document.createElement('div');
wurzel.className = 'detail';
@@ -17,23 +23,26 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
async function laden(): Promise<void> {
wurzel.innerHTML = '<div class="spinner"></div>';
try {
// **Einzeln geholt, nicht aus einer Liste gefischt.** Vorher wurden
// 200 Titel geladen und darin gesucht; bei 201 Filmen hätte die
// Detailseite des letzten „gibt es nicht mehr" gesagt.
if (art === 'filme') {
const film = await filmLaden(id);
const eintrag = await filmLaden(id);
wurzel.innerHTML = '';
wurzel.append(
kopfbereich(film, film.state),
dateiliste(film.files, film.id, film.state),
zuordnungsbereich(film.title, film.year));
hintergrund(eintrag.backdropImage),
zurueckKnopf(),
kopfbereich(eintrag, eintrag.state, eintrag.files[0] ?? null),
dateiliste(eintrag.files, eintrag.id, eintrag.state),
besetzung(eintrag.credits),
zuordnungsbereich(eintrag.title, eintrag.year));
} else {
const serie = await serieLaden(id);
const eintrag = await serieLaden(id);
wurzel.innerHTML = '';
wurzel.append(
kopfbereich(serie, null),
await staffeln(serie),
zuordnungsbereich(serie.title, serie.year));
hintergrund(eintrag.backdropImage),
zurueckKnopf(),
kopfbereich(eintrag, null, null),
await staffeln(eintrag),
besetzung(eintrag.credits),
zuordnungsbereich(eintrag.title, eintrag.year));
}
} catch (fehler) {
wurzel.innerHTML = '';
@@ -43,159 +52,297 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
}
}
function zurueckKnopf(): HTMLElement {
const b = knopf('Zurück', 'btn');
b.prepend(icon('links'));
b.style.marginBottom = 'var(--s-loose)';
b.onclick = zurueck;
return b;
}
/* ---------- Hintergrund ---------- */
function hintergrund(schluessel: string | null): HTMLElement {
const d = document.createElement('div');
d.className = 'detail-hintergrund';
const adresse = bildAdresse(schluessel);
if (adresse) d.style.backgroundImage = `url("${adresse}")`;
return d;
}
/* ---------- Kopf ---------- */
function kopfbereich(eintrag: Film | Serie, stand: Stand | null): HTMLElement {
function kopfbereich(eintrag: Film | Serie, stand: Stand | null, datei: Datei | null): HTMLElement {
const kopf = document.createElement('header');
kopf.className = 'detail-head';
kopf.className = 'detail-kopf';
const hintergrund = bildAdresse(eintrag.backdropImage);
if (hintergrund) kopf.style.setProperty('--backdrop', `url("${hintergrund}")`);
const zurueckKnopf = knopf('Zurück', 'btn');
zurueckKnopf.onclick = zurueck;
// Plakat
const rahmen = document.createElement('div');
rahmen.className = 'plakat geladen';
const plakatAdresse = bildAdresse(eintrag.posterImage);
if (plakatAdresse) {
const bild = document.createElement('img');
bild.src = plakatAdresse;
bild.alt = '';
rahmen.appendChild(bild);
}
if (anteil(stand)) {
const balken = document.createElement('div');
balken.className = 'fortschritt';
const gefuellt = document.createElement('div');
gefuellt.style.width = `${Math.min(100, anteil(stand)! * 100)}%`;
balken.appendChild(gefuellt);
rahmen.appendChild(balken);
}
const text = document.createElement('div');
text.className = 'detail-text';
const jahr = eintrag.year ? ` (${eintrag.year})` : '';
const offen = 'unwatchedCount' in eintrag && eintrag.unwatchedCount > 0
? `<span class="tiny">${eintrag.unwatchedCount} von ${eintrag.episodeCount} Folgen offen</span>`
: '';
text.innerHTML =
`<h1>${esc(eintrag.title)}${esc(jahr)}</h1>` +
(stand?.watched ? '<span class="tiny">Gesehen</span>' : offen) +
`<p>${esc(eintrag.overview ?? 'Keine Beschreibung.')}</p>`;
const innen = document.createElement('div');
innen.className = 'detail-inner';
innen.append(plakat(eintrag.posterImage, eintrag.title, anteil(stand)), text);
// **Der Schriftzug, wenn es einen gibt.** „Blade Runner 2049" in seiner
// eigenen Schrift sagt mehr als derselbe Titel in der Systemschrift.
const logo = bildAdresse(eintrag.logoImage);
if (logo) {
const bild = document.createElement('img');
bild.className = 'schriftzug';
bild.src = logo;
bild.alt = eintrag.title;
text.appendChild(bild);
} else {
const h = document.createElement('h1');
h.textContent = eintrag.title;
text.appendChild(h);
}
kopf.append(zurueckKnopf, innen);
text.appendChild(eckdaten(eintrag));
if (eintrag.tagline) {
const zeile = document.createElement('p');
zeile.className = 'werbezeile';
zeile.textContent = eintrag.tagline;
text.appendChild(zeile);
}
text.appendChild(aktionsreihe(eintrag, stand, datei));
const beschreibung = document.createElement('p');
beschreibung.className = 'beschreibung';
beschreibung.textContent = eintrag.overview ?? 'Keine Beschreibung.';
text.appendChild(beschreibung);
if (datei) text.appendChild(medienangaben(datei));
kopf.append(rahmen, text);
return kopf;
}
/* ---------- Dateien und Spuren ---------- */
/** Jahr · Genres · Laufzeit · Freigabe · Bewertung — eine Zeile. */
function eckdaten(eintrag: Film | Serie): HTMLElement {
const zeile = document.createElement('div');
zeile.className = 'eckdaten';
function dateiliste(dateien: Datei[], titel: string, stand: Stand | null): HTMLElement {
const teile: string[] = [];
if (eintrag.year) teile.push(String(eintrag.year));
if (eintrag.genres?.length) teile.push(eintrag.genres.join(', '));
if (eintrag.runtimeMinutes) teile.push(`${eintrag.runtimeMinutes} Min`);
if ('episodeCount' in eintrag) {
teile.push(`${eintrag.episodeCount} Folgen`);
if (eintrag.unwatchedCount > 0) teile.push(`${eintrag.unwatchedCount} offen`);
}
if (teile.length) {
const t = document.createElement('span');
t.textContent = teile.join(' · ');
zeile.appendChild(t);
}
if (eintrag.contentRating) {
const f = document.createElement('span');
f.className = 'freigabe';
f.textContent = eintrag.contentRating;
f.title = 'Altersfreigabe';
zeile.appendChild(f);
}
if (eintrag.rating) {
const b = document.createElement('span');
b.className = 'bewertung';
b.innerHTML = icons.stern;
b.append(document.createTextNode(eintrag.rating.toFixed(1)));
b.title = 'Bewertung bei TMDB';
zeile.appendChild(b);
}
return zeile;
}
/* ---------- Knöpfe ---------- */
function aktionsreihe(eintrag: Film | Serie, stand: Stand | null, datei: Datei | null): HTMLElement {
const reihe = document.createElement('div');
reihe.className = 'aktionen';
const meldung = document.createElement('div');
meldung.className = 'hinweis';
if (datei) {
const stelle = stand?.positionSeconds ?? 0;
const los = knopf(stelle > 0 ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
los.prepend(icon('abspielen'));
los.onclick = () => void starten(datei, eintrag.id, stelle, los, meldung);
reihe.appendChild(los);
if (stelle > 0) {
const vonVorn = knopf('Von vorn', 'btn');
vonVorn.onclick = () => void starten(datei, eintrag.id, 0, vonVorn, meldung);
reihe.appendChild(vonVorn);
}
const laden = knopf('Herunterladen', 'btn');
laden.prepend(icon('download'));
laden.onclick = () => void herunterladen(datei, laden, meldung);
reihe.appendChild(laden);
}
// **Runde Knöpfe ohne Beschriftung, wie bei Plex.** Gesehen, Favorit,
// weglegen — drei Dinge, die man oft tut und nie liest.
if ('files' in eintrag) {
reihe.appendChild(rundKnopf('hakenKreis', stand?.watched ?? false,
stand?.watched ? 'Als ungesehen markieren' : 'Als gesehen markieren',
async () => { await gesehen(eintrag.id, !stand?.watched); await laden(); }));
reihe.appendChild(rundKnopf('stern', stand?.favourite ?? false,
stand?.favourite ? 'Aus Favoriten nehmen' : 'Zu Favoriten',
async () => { await favorit(eintrag.id, !stand?.favourite); await laden(); }));
if ((stand?.positionSeconds ?? 0) > 0 && !stand?.dismissed) {
reihe.appendChild(rundKnopf('mehr', false, 'Aus „Weiterschauen" nehmen',
async () => { await weglegen(eintrag.id); await laden(); }));
}
}
const huelle = document.createElement('div');
huelle.append(reihe, meldung);
return huelle;
}
function rundKnopf(
bild: keyof typeof icons, an: boolean, titel: string, tun: () => Promise<void>,
): HTMLButtonElement {
const b = document.createElement('button');
b.className = an ? 'rund an' : 'rund';
b.innerHTML = icons[bild];
b.title = titel;
b.setAttribute('aria-label', titel);
b.onclick = async () => { b.disabled = true; try { await tun(); } catch { b.disabled = false; } };
return b;
}
/* ---------- Medienangaben ---------- */
/** Video / Ton / Untertitel — Plex' Tabelle unter der Beschreibung. */
function medienangaben(datei: Datei): HTMLElement {
const liste = document.createElement('dl');
liste.className = 'medientabelle';
const bild = datei.streams.find(s => s.kind === 'Video');
const ton = datei.streams.filter(s => s.kind === 'Audio');
const text = datei.streams.filter(s => s.kind === 'Subtitle');
zeile('Datei', [datei.container?.toUpperCase(), groesse(datei.sizeBytes),
dauer(datei.durationSeconds)].filter(Boolean).join(' · '));
if (bild) zeile('Video', `${bild.height ? `${bild.height}p` : '—'} (${(bild.codec ?? '?').toUpperCase()})`);
if (ton.length) zeile('Ton', ton.map(spurname).join(' · '));
if (text.length) zeile('Untertitel', text.map(spurname).join(' · '));
function zeile(name: string, wert: string): void {
const dt = document.createElement('dt');
dt.textContent = name;
const dd = document.createElement('dd');
dd.textContent = wert;
liste.append(dt, dd);
}
return liste;
}
function spurname(s: Datei['streams'][number]): string {
const teile = [s.title, s.language?.toUpperCase(), s.codec?.toUpperCase()]
.filter(Boolean);
const kanaele = s.channels ? `${s.channels}.0` : null;
return [teile.join(' '), kanaele].filter(Boolean).join(' ');
}
/* ---------- Besetzung ---------- */
function besetzung(mitwirkende: Mitwirkender[] | null): HTMLElement {
const bereich = document.createElement('section');
bereich.className = 'settings';
if (!mitwirkende?.length) return bereich;
if (dateien.length === 0) { bereich.appendChild(leer('Keine Datei hinterlegt.')); return bereich; }
const h = document.createElement('h2');
h.textContent = 'Besetzung & Stab';
dateien.forEach(datei => {
const block = document.createElement('div');
block.className = 'setting';
const reihe = document.createElement('div');
reihe.className = 'besetzung';
const zeile = document.createElement('div');
zeile.className = 'rowline';
zeile.innerHTML =
`<strong>${esc(datei.fileName)}</strong>` +
`<span class="tiny">${esc([datei.container?.toUpperCase(), groesse(datei.sizeBytes),
dauer(datei.durationSeconds)].filter(Boolean).join(' · '))}</span>`;
block.appendChild(zeile);
mitwirkende.forEach(m => {
const k = document.createElement('div');
k.className = 'kopf-kachel';
const spuren = document.createElement('div');
spuren.className = 'hint';
spuren.innerHTML = datei.streams.map(s => {
const was = s.kind === 'Video' ? `Bild ${s.width}×${s.height}`
: s.kind === 'Audio' ? `Ton${s.channels ? ` ${s.channels}.0` : ''}`
: 'Text';
const name = [s.language?.toUpperCase(), s.title].filter(Boolean).join(' · ');
return `<div>${esc(was)} — ${esc(s.codec ?? '?')}${name ? ` — ${esc(name)}` : ''}</div>`;
}).join('');
block.appendChild(spuren);
const portrait = document.createElement('div');
portrait.className = 'portrait';
const adresse = bildAdresse(m.image);
if (adresse) {
const bild = document.createElement('img');
bild.src = adresse;
bild.alt = '';
bild.loading = 'lazy';
portrait.appendChild(bild);
} else {
// Kein Bild: die Anfangsbuchstaben. Ein leerer Kreis sieht aus wie
// ein Ladefehler.
portrait.textContent = m.name.split(' ').map(t => t[0]).slice(0, 2).join('');
}
block.appendChild(aktionen(datei, titel, stand));
bereich.appendChild(block);
const rolle = m.role === 'Director' ? 'Regie'
: m.role === 'Writer' ? 'Drehbuch'
: m.character ?? '';
k.innerHTML += '';
k.append(portrait);
const name = document.createElement('strong');
name.textContent = m.name;
const unten = document.createElement('span');
unten.textContent = rolle;
k.append(name, unten);
reihe.appendChild(k);
});
bereich.append(h, reihe);
return bereich;
}
function aktionen(datei: Datei, titel: string, stand: Stand | null): HTMLElement {
const reihe = document.createElement('div');
reihe.className = 'btn-row';
const zustandszeile = document.createElement('div');
zustandszeile.className = 'hint';
/* ---------- Dateien ---------- */
// **Gibt es eine Stelle, stehen beide Knöpfe da** — und zwar auch bei
// einem gesehenen Film. Der Server wirft die Stelle nicht weg, wenn das
// Häkchen kommt; sie hier trotzdem zu verschweigen hiesse, den
// Unterschied wegzuwerfen, um dessentwillen er sie behält.
//
// Ein „Abspielen", das stillschweigend in der Mitte anfängt, wäre die
// andere falsche Lösung: eine Überraschung, keine Hilfe.
const stelle = stand?.positionSeconds ?? 0;
const abspielen = knopf(
stelle > 0 ? `Weiterschauen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
const vonVorn = stelle > 0 ? knopf('Von vorn', 'btn') : null;
const herunterladen = knopf('Herunterladen', 'btn');
function dateiliste(dateien: Datei[], titel: string, stand: Stand | null): HTMLElement {
const bereich = document.createElement('section');
if (dateien.length <= 1) return bereich;
const haken = knopf(stand?.watched ? 'Als ungesehen markieren' : 'Als gesehen markieren', 'btn');
haken.onclick = async () => {
haken.disabled = true;
try { await gesehen(titel, !stand?.watched); await laden(); }
catch (fehler) { zustandszeile.textContent = (fehler as Error).message; haken.disabled = false; }
};
// Nur bei mehreren Fassungen: bei einer stehen die Angaben schon oben.
const h = document.createElement('h2');
h.textContent = 'Fassungen';
bereich.appendChild(h);
/** Sorgt dafür, dass die Datei abholbereit ist — packt um, falls nötig. */
async function bereitstellen(): Promise<boolean> {
let auskunft: Lieferung;
try { auskunft = await lieferung(datei.id); }
catch (fehler) { zustandszeile.textContent = (fehler as Error).message; return false; }
if (auskunft.method === 'directFile') return true;
if (auskunft.packageState === 'ready') return true;
// **Hier steht, was passieren wird, bevor es passiert.** Welche Spuren
// neu gerechnet werden, welche Untertitel nicht mitkönnen.
const gruende: string[] = [];
if (auskunft.audioToEncode.length) gruende.push(`${auskunft.audioToEncode.length} Tonspur(en) werden neu gerechnet`);
if (auskunft.subtitlesDropped.length) gruende.push(`${auskunft.subtitlesDropped.length} Bilduntertitel können nicht mit`);
zustandszeile.textContent = `Wird in eine MP4 umgepackt${gruende.length ? ` — ${gruende.join(', ')}` : ''}…`;
// Der erste Abruf stösst an, danach wird nachgefragt.
for (let i = 0; i < 600; i++) {
const antwort = await fetch(downloadAdresse(datei.id), { method: 'HEAD' });
if (antwort.ok) { zustandszeile.textContent = ''; return true; }
if (antwort.status !== 202) { zustandszeile.textContent = `Umpacken gescheitert (${antwort.status}).`; return false; }
const wie = await lieferung(datei.id);
if (wie.error) { zustandszeile.textContent = wie.error; return false; }
zustandszeile.textContent = `Wird umgepackt… ${Math.round(wie.progress * 100)} %`;
await new Promise(r => setTimeout(r, 1000));
}
zustandszeile.textContent = 'Das Umpacken dauert ungewöhnlich lange.';
return false;
}
async function starten(ab: number, knopfDavon: HTMLButtonElement): Promise<void> {
knopfDavon.disabled = true;
if (await bereitstellen()) spieler(downloadAdresse(datei.id), titel, ab, laden);
knopfDavon.disabled = false;
}
abspielen.onclick = () => void starten(stelle, abspielen);
if (vonVorn) vonVorn.onclick = () => void starten(0, vonVorn);
herunterladen.onclick = async () => {
herunterladen.disabled = true;
if (await bereitstellen()) {
// Ein echter Link, kein Blob: der Browser lädt im Hintergrund und
// kann fortsetzen, statt den ganzen Film in den Speicher zu nehmen.
const a = document.createElement('a');
a.href = downloadAdresse(datei.id);
a.download = '';
a.click();
}
herunterladen.disabled = false;
};
reihe.append(abspielen);
if (vonVorn) reihe.appendChild(vonVorn);
reihe.append(herunterladen, haken);
const huelle = document.createElement('div');
huelle.append(reihe, zustandszeile);
return huelle;
const liste = document.createElement('div');
liste.className = 'einstellungen';
dateien.forEach(datei => {
const block = document.createElement('div');
block.className = 'einstellung';
block.innerHTML = `<div class="zeile"><strong>${esc(datei.fileName)}</strong>`
+ `<span class="winzig">${esc([datei.container?.toUpperCase(), groesse(datei.sizeBytes),
dauer(datei.durationSeconds)].filter(Boolean).join(' · '))}</span></div>`;
block.appendChild(aktionsreihe({ ...({} as Film), id: titel, files: dateien } as Film, stand, datei));
liste.appendChild(block);
});
bereich.appendChild(liste);
return bereich;
}
/* ---------- Staffeln und Folgen ---------- */
@@ -205,12 +352,12 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
const alle = await folgen(serie.id);
serie.seasons.forEach(nummer => {
const titel = document.createElement('h2');
titel.textContent = `Staffel ${nummer}`;
bereich.appendChild(titel);
const h = document.createElement('h2');
h.textContent = `Staffel ${nummer}`;
bereich.appendChild(h);
const liste = document.createElement('div');
liste.className = 'settings';
liste.className = 'einstellungen';
alle.items.filter(f => f.season === nummer).forEach(folge => {
liste.appendChild(folgenblock(folge));
});
@@ -221,26 +368,85 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
function folgenblock(folge: Folge): HTMLElement {
const block = document.createElement('div');
block.className = 'setting';
block.className = 'einstellung';
const zeile = document.createElement('div');
zeile.className = 'rowline';
zeile.className = 'zeile';
const nebenbei = folge.state?.watched ? 'Gesehen'
: folge.state && folge.state.positionSeconds > 0 ? verbleibend(folge.state) : '';
: folge.state && folge.state.positionSeconds > 0
? `noch ${Math.round(((folge.state.runtimeSeconds ?? 0) - folge.state.positionSeconds) / 60)} Min`
: '';
zeile.innerHTML =
`<strong>${String(folge.episode).padStart(2, '0')} · ${esc(folge.title ?? 'Ohne Titel')}</strong>`
+ (nebenbei ? `<span class="tiny">${esc(nebenbei)}</span>` : '');
+ (nebenbei ? `<span class="winzig">${esc(nebenbei)}</span>` : '');
block.appendChild(zeile);
if (folge.overview) {
const text = document.createElement('div');
text.className = 'hint';
text.className = 'hinweis';
text.textContent = folge.overview;
block.appendChild(text);
}
folge.files.forEach(datei => block.appendChild(aktionen(datei, folge.id, folge.state)));
folge.files.forEach(datei => block.appendChild(
aktionsreihe({ ...({} as Film), id: folge.id, files: folge.files } as Film, folge.state, datei)));
return block;
}
/* ---------- Abspielen und Herunterladen ---------- */
/** Sorgt dafür, dass die Datei abholbereit ist — packt um, falls nötig. */
async function bereitstellen(datei: Datei, meldung: HTMLElement): Promise<boolean> {
let auskunft: Lieferung;
try { auskunft = await lieferung(datei.id); }
catch (fehler) { meldung.textContent = (fehler as Error).message; return false; }
if (auskunft.method === 'directFile') return true;
if (auskunft.packageState === 'ready') return true;
// **Hier steht, was passieren wird, bevor es passiert.**
const gruende: string[] = [];
if (auskunft.audioToEncode.length) gruende.push(`${auskunft.audioToEncode.length} Tonspur(en) werden neu gerechnet`);
if (auskunft.subtitlesDropped.length) gruende.push(`${auskunft.subtitlesDropped.length} Bilduntertitel können nicht mit`);
meldung.textContent = `Wird in eine MP4 umgepackt${gruende.length ? ` — ${gruende.join(', ')}` : ''}…`;
for (let i = 0; i < 600; i++) {
const antwort = await fetch(downloadAdresse(datei.id), { method: 'HEAD' });
if (antwort.ok) { meldung.textContent = ''; return true; }
if (antwort.status !== 202) { meldung.textContent = `Umpacken gescheitert (${antwort.status}).`; return false; }
const wie = await lieferung(datei.id);
if (wie.error) { meldung.textContent = wie.error; return false; }
meldung.textContent = `Wird umgepackt… ${Math.round(wie.progress * 100)} %`;
await new Promise(r => setTimeout(r, 1000));
}
meldung.textContent = 'Das Umpacken dauert ungewöhnlich lange.';
return false;
}
async function starten(
datei: Datei, titel: string, ab: number, knopfDavon: HTMLButtonElement, meldung: HTMLElement,
): Promise<void> {
knopfDavon.disabled = true;
if (await bereitstellen(datei, meldung)) spieler(downloadAdresse(datei.id), titel, ab, laden);
knopfDavon.disabled = false;
}
async function herunterladen(
datei: Datei, knopfDavon: HTMLButtonElement, meldung: HTMLElement,
): Promise<void> {
knopfDavon.disabled = true;
if (await bereitstellen(datei, meldung)) {
// Ein echter Link, kein Blob: der Browser lädt im Hintergrund und kann
// fortsetzen, statt den ganzen Film in den Speicher zu nehmen.
const a = document.createElement('a');
a.href = downloadAdresse(datei.id);
a.download = '';
a.click();
}
knopfDavon.disabled = false;
}
/* ---------- Zuordnung korrigieren ---------- */
function zuordnungsbereich(titel: string, jahr: number | null): HTMLElement {
@@ -248,6 +454,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
if (!istAdmin()) return bereich;
const oeffner = knopf('Zuordnung korrigieren', 'btn');
oeffner.prepend(icon('stift'));
bereich.appendChild(oeffner);
oeffner.onclick = () => {
@@ -259,7 +466,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
function zuordnungsmaske(titel: string, jahr: number | null): HTMLElement {
const maske = document.createElement('div');
maske.className = 'settings';
maske.className = 'einstellungen';
const suche = feld('Anderer Suchbegriff');
suche.value = titel;
@@ -268,7 +475,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
const suchen = knopf('Suchen', 'btn');
const reihe = document.createElement('div');
reihe.className = 'btn-row';
reihe.className = 'knopfreihe';
reihe.append(suche, jahrfeld, suchen);
const treffer = document.createElement('div');
@@ -285,10 +492,11 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
liste.forEach(v => {
const zeile = document.createElement('button');
zeile.className = 'setting pick';
zeile.className = 'einstellung waehlbar';
zeile.innerHTML =
`<strong>${esc(v.title)}</strong> <span class="tiny">${esc(v.year ?? '')} · TMDB ${v.tmdbId}</span>` +
`<div class="hint">${esc((v.overview ?? '').slice(0, 160))}</div>`;
`<div class="zeile"><strong>${esc(v.title)}</strong>`
+ `<span class="winzig">${esc(v.year ?? '')} · TMDB ${v.tmdbId}</span></div>`
+ `<div class="hinweis">${esc((v.overview ?? '').slice(0, 160))}</div>`;
zeile.onclick = async () => {
zeile.disabled = true;
try {
@@ -312,7 +520,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
return wurzel;
}
/* ---------- Abspielen ---------- */
/* ---------- Der Spieler ---------- */
/** Wie oft der Stand zum Server geht. */
const MELDEABSTAND = 10_000;
@@ -353,8 +561,6 @@ function spieler(adresse: string, titel: string, ab: number, danach: () => void)
video.pause();
melden(true);
schicht.remove();
// Damit Knopfbeschriftung, Balken und Häkchen stimmen, wenn man
// dahinter wieder auftaucht.
danach();
};
+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;
}
+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%))`;
}
+59
View File
@@ -0,0 +1,59 @@
import { filme, serien } from '../api';
import { esc, leer, fehlerzeile } from '../util';
import { kachel, alsKachel } from '../card';
/** Suchergebnisse quer durch alle Mediatheken.
*
* **Nicht je Mediathek.** Wer einen Titel sucht, weiss oft gar nicht, in
* welcher er liegt — genau deshalb steht die Suche oben in der Kopfleiste
* und nicht in jeder Mediathek einzeln. */
export function suchseite(
text: string,
oeffnen: (art: 'filme' | 'serien', id: string) => void,
): HTMLElement {
const wurzel = document.createElement('div');
const kopf = document.createElement('div');
kopf.className = 'seitenkopf';
kopf.innerHTML = `<div class="wer"><h1>${esc(text)}</h1>`
+ '<div class="quelle">Suchergebnisse</div></div>';
wurzel.appendChild(kopf);
const buehne = document.createElement('div');
buehne.innerHTML = '<div class="spinner"></div>';
wurzel.appendChild(buehne);
void (async () => {
try {
const [f, s] = await Promise.all([
filme({ search: text, take: 100 }),
serien({ search: text, take: 100 }),
]);
buehne.innerHTML = '';
if (f.total === 0 && s.total === 0) {
buehne.appendChild(leer(`Nichts gefunden für „${text}".`));
return;
}
if (f.total > 0) buehne.appendChild(abschnitt(`Filme · ${f.total}`, f.items));
if (s.total > 0) buehne.appendChild(abschnitt(`Serien · ${s.total}`, s.items));
} catch (fehler) {
buehne.innerHTML = '';
buehne.appendChild(fehlerzeile((fehler as Error).message));
}
})();
function abschnitt(titel: string, eintraege: Parameters<typeof alsKachel>[0][]): HTMLElement {
const bereich = document.createElement('section');
const h = document.createElement('h2');
h.textContent = titel;
const gitter = document.createElement('div');
gitter.className = 'raster';
eintraege.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen)));
bereich.append(h, gitter);
return bereich;
}
return wurzel;
}
+1 -1
View File
@@ -12,7 +12,7 @@ export function einrichtung(fertig: () => void): HTMLElement {
const titel = document.createElement('h1');
titel.textContent = 'Flaro einrichten';
const text = document.createElement('p');
text.className = 'hint';
text.className = 'hinweis';
text.textContent = 'Lege das erste Konto an. Es wird Administrator und kann '
+ 'später Bibliotheken und weitere Benutzer verwalten.';