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
@@ -0,0 +1,174 @@
|
||||
import { bildAdresse, type Film, type Serie, type Angefangen, type Stand } from './api';
|
||||
import { esc, anteil } from './util';
|
||||
import { icons } from './icons';
|
||||
|
||||
/** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */
|
||||
export type Kachel = {
|
||||
id: string;
|
||||
art: 'filme' | 'serien';
|
||||
/** Die obere, fette Zeile. Bei einer Folge die Serie. */
|
||||
titel: string;
|
||||
/** Die zweite Zeile. Bei einer Folge der Folgentitel, sonst das Jahr. */
|
||||
zweite?: string | null;
|
||||
/** Die dritte Zeile — „S14 · F22". Nur bei Folgen. */
|
||||
dritte?: string | null;
|
||||
bild: string | null;
|
||||
stand?: Stand | null;
|
||||
/** Die Zahl offener Folgen, als Plakette oben rechts. */
|
||||
offen?: number | null;
|
||||
};
|
||||
|
||||
/* ---------- Umwandlungen ---------- */
|
||||
|
||||
export const alsKachel = (e: Film | Serie): Kachel => ({
|
||||
id: e.id,
|
||||
art: 'episodeCount' in e ? 'serien' : 'filme',
|
||||
titel: e.title,
|
||||
zweite: e.year ? String(e.year) : null,
|
||||
bild: e.posterImage,
|
||||
stand: 'state' in e ? e.state : null,
|
||||
offen: 'unwatchedCount' in e ? e.unwatchedCount : null,
|
||||
});
|
||||
|
||||
/** Eine Kachel aus „Weiterschauen" — bei Folgen dreizeilig, wie bei Plex. */
|
||||
export const angefangenAlsKachel = (e: Angefangen): Kachel => e.kind === 'episode'
|
||||
? {
|
||||
id: e.id, art: 'serien',
|
||||
titel: e.title,
|
||||
zweite: e.subTitle,
|
||||
dritte: `S${e.season} · F${e.episode}`,
|
||||
bild: e.posterImage, stand: e.state,
|
||||
}
|
||||
: {
|
||||
id: e.id, art: 'filme',
|
||||
titel: e.title,
|
||||
zweite: e.year ? String(e.year) : null,
|
||||
bild: e.posterImage, stand: e.state,
|
||||
};
|
||||
|
||||
/* ---------- Die Kachel selbst ---------- */
|
||||
|
||||
/** Baut eine Plakatkachel.
|
||||
*
|
||||
* **Alles auf einer Kachel steht an einer Stelle**, weil sie an fünf Stellen
|
||||
* vorkommt — Startseite, Mediathek, Vorschläge, Suche, Staffeln. Jede davon
|
||||
* eigen zu zeichnen hiesse: der Haken für Gesehenes erscheint an vier
|
||||
* Stellen und an der fünften nicht, und niemand weiss, warum. */
|
||||
export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string) => void): HTMLElement {
|
||||
const knopf = document.createElement('button');
|
||||
knopf.className = 'kachel';
|
||||
knopf.onclick = () => oeffnen(k.art, k.id);
|
||||
|
||||
const rahmen = document.createElement('div');
|
||||
rahmen.className = 'plakat';
|
||||
rahmen.setAttribute('role', 'img');
|
||||
rahmen.setAttribute('aria-label', k.titel);
|
||||
|
||||
const adresse = bildAdresse(k.bild);
|
||||
if (adresse) {
|
||||
const bild = document.createElement('img');
|
||||
bild.src = adresse;
|
||||
bild.alt = '';
|
||||
bild.loading = 'lazy';
|
||||
// Erst einblenden, wenn es da ist — sonst blitzt der Platzhalter durch.
|
||||
bild.onload = () => rahmen.classList.add('geladen');
|
||||
rahmen.appendChild(bild);
|
||||
}
|
||||
|
||||
// **Der Haken sitzt in der Ecke, nicht in der Beschriftung.** So wie bei
|
||||
// Plex: ein Blick über das Raster zeigt, was noch offen ist, ohne eine
|
||||
// einzige Zeile zu lesen.
|
||||
if (k.stand?.watched) {
|
||||
const haken = document.createElement('span');
|
||||
haken.className = 'ecke haken';
|
||||
haken.innerHTML = icons.haken;
|
||||
haken.title = 'Gesehen';
|
||||
rahmen.appendChild(haken);
|
||||
} else if (k.offen != null && k.offen > 0) {
|
||||
const plakette = document.createElement('span');
|
||||
plakette.className = 'ecke zahl';
|
||||
plakette.textContent = String(k.offen);
|
||||
plakette.title = `${k.offen} Folgen offen`;
|
||||
rahmen.appendChild(plakette);
|
||||
}
|
||||
|
||||
// Der Balken unten — ab der ersten Sekunde, mit Mindestbreite, sonst wären
|
||||
// zwei Minuten eines Zweistünders anderthalb Bildpunkte.
|
||||
const wie = anteil(k.stand);
|
||||
if (wie != null && wie > 0 && !k.stand?.watched) {
|
||||
const balken = document.createElement('div');
|
||||
balken.className = 'fortschritt';
|
||||
const gefuellt = document.createElement('div');
|
||||
gefuellt.style.width = `${Math.min(100, wie * 100)}%`;
|
||||
balken.appendChild(gefuellt);
|
||||
rahmen.appendChild(balken);
|
||||
}
|
||||
|
||||
// Beim Überfahren ein Abspielknopf in der Mitte — auch das von Plex.
|
||||
const schweben = document.createElement('span');
|
||||
schweben.className = 'schweber';
|
||||
schweben.innerHTML = icons.abspielen;
|
||||
rahmen.appendChild(schweben);
|
||||
|
||||
knopf.appendChild(rahmen);
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'kacheltext';
|
||||
text.innerHTML =
|
||||
`<strong>${esc(k.titel)}</strong>`
|
||||
+ (k.zweite ? `<span>${esc(k.zweite)}</span>` : '')
|
||||
+ (k.dritte ? `<span class="dritte">${esc(k.dritte)}</span>` : '');
|
||||
knopf.appendChild(text);
|
||||
|
||||
return knopf;
|
||||
}
|
||||
|
||||
/** Eine waagerechte Reihe mit Überschrift und Pfeilen. */
|
||||
export function reihe(
|
||||
titel: string,
|
||||
kacheln: HTMLElement[],
|
||||
mehr?: { text: string; tun: () => void },
|
||||
): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
bereich.className = 'reihe';
|
||||
|
||||
const kopf = document.createElement('div');
|
||||
kopf.className = 'reihe-kopf';
|
||||
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = titel;
|
||||
kopf.appendChild(h);
|
||||
|
||||
const streifen = document.createElement('div');
|
||||
streifen.className = 'strip';
|
||||
kacheln.forEach(k => streifen.appendChild(k));
|
||||
|
||||
// **Pfeile, weil eine Reihe mit zwanzig Kacheln nicht in den Bildschirm
|
||||
// passt.** Scrollen mit dem Rad geht quer nur mit Umschalttaste, und ein
|
||||
// Trackpad hat nicht jeder.
|
||||
const steuerung = document.createElement('div');
|
||||
steuerung.className = 'reihe-pfeile';
|
||||
(['links', 'rechts'] as const).forEach(richtung => {
|
||||
const p = document.createElement('button');
|
||||
p.className = 'pfeil';
|
||||
p.innerHTML = icons[richtung];
|
||||
p.setAttribute('aria-label', richtung === 'links' ? 'Zurück' : 'Weiter');
|
||||
p.onclick = () => streifen.scrollBy({
|
||||
left: (richtung === 'links' ? -1 : 1) * Math.max(240, streifen.clientWidth * 0.8),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
steuerung.appendChild(p);
|
||||
});
|
||||
|
||||
if (mehr) {
|
||||
const m = document.createElement('button');
|
||||
m.className = 'mehr';
|
||||
m.textContent = mehr.text;
|
||||
m.onclick = mehr.tun;
|
||||
kopf.appendChild(m);
|
||||
}
|
||||
kopf.appendChild(steuerung);
|
||||
|
||||
bereich.append(kopf, streifen);
|
||||
return bereich;
|
||||
}
|
||||
Reference in New Issue
Block a user