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
+4 -1
View File
@@ -5,7 +5,10 @@ namespace Flaro.Api.Endpoints;
// und bricht Clients, die niemand mehr aktualisiert.
public sealed record SystemInfo(
string Version, bool NeedsSetup, IReadOnlyList<AttributionResponse> Attributions);
string Version, string ServerName, bool NeedsSetup,
IReadOnlyList<AttributionResponse> Attributions);
public sealed record ServerNameRequest(string Name);
public sealed record SetupRequest(string UserName, string Password);
@@ -1,4 +1,5 @@
using Flaro.Core.Users;
using Flaro.Core;
using Flaro.Metadata;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
@@ -16,11 +17,23 @@ public static class SystemEndpoints
/// </remarks>
private static readonly SemaphoreSlim EinrichtungsSchloss = new(1, 1);
/// <summary>Unter welchem Schlüssel der Servername liegt.</summary>
public const string NameSchlüssel = "server.name";
public static void MapSystem(this IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/system/info", async (FlaroContext db) =>
Results.Ok(new SystemInfo(
Version: typeof(SystemEndpoints).Assembly.GetName().Version?.ToString(3) ?? "0.0.0",
// **Wie dieser Server heisst.** Ohne ihn zeigen die Clients
// den Hostnamen — „localhost", „192.168.1.50" —, und wer zwei
// Server hat, unterscheidet sie an einer Zahl.
//
// Vorgabe ist der Rechnername des Containers: besser als
// nichts und besser als „Flaro", weil davon jeder einen hat.
ServerName: (await db.Settings.AsNoTracking()
.FirstOrDefaultAsync(e => e.Key == NameSchlüssel))?.Value
?? Environment.MachineName,
NeedsSetup: !await db.Users.AnyAsync(),
// Ohne Anmeldung abrufbar: ein Client muss den Hinweis auch
// auf dem Anmeldebildschirm zeigen können.
@@ -30,6 +43,23 @@ public static class SystemEndpoints
])))
.AllowAnonymous();
app.MapPut("/api/v1/system/name", async (ServerNameRequest anfrage, FlaroContext db) =>
{
var name = anfrage.Name.Trim();
if (name.Length == 0)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["name"] = ["Ein Name darf nicht leer sein."],
});
var vorhanden = await db.Settings.FirstOrDefaultAsync(e => e.Key == NameSchlüssel);
if (vorhanden is null) db.Settings.Add(new ServerSetting { Key = NameSchlüssel, Value = name });
else vorhanden.Value = name;
await db.SaveChangesAsync();
return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPost("/api/v1/system/setup", async (SetupRequest anfrage, FlaroContext db) =>
{
if (string.IsNullOrWhiteSpace(anfrage.UserName) || anfrage.Password.Length < 8)
+1
View File
@@ -8,6 +8,7 @@
</head>
<body>
<section id="login"></section>
<header id="topbar" class="hidden"></header>
<div id="app" class="hidden">
<nav id="chrome"></nav>
<main id="content"></main>
+41 -2
View File
@@ -21,15 +21,36 @@ export type Stand = {
favourite: boolean; rating: number | null; dismissed: boolean;
lastPlayedAt: string | null;
};
export type Mitwirkender = {
id: string; name: string;
role: 'Actor' | 'Director' | 'Writer';
character: string | null; image: string | null;
};
export type Film = {
id: string; title: string; year: number | null; overview: string | null;
posterImage: string | null; backdropImage: string | null; files: Datei[];
state: Stand | null;
logoImage: string | null;
runtimeMinutes: number | null;
rating: number | null;
tagline: string | null;
contentRating: string | null;
/** Leer in Listen, gefüllt beim Einzelabruf. */
genres: string[] | null;
credits: Mitwirkender[] | null;
};
export type Serie = {
id: string; title: string; year: number | null; overview: string | null;
posterImage: string | null; backdropImage: string | null;
episodeCount: number; seasons: number[]; unwatchedCount: number;
logoImage: string | null;
runtimeMinutes: number | null;
rating: number | null;
tagline: string | null;
contentRating: string | null;
genres: string[] | null;
credits: Mitwirkender[] | null;
};
export type Folge = {
id: string; season: number; episode: number;
@@ -71,6 +92,10 @@ export type Vorschlag = {
overview: string | null; posterUrl: string | null;
};
export type Hinweis = { text: string; logoUrl: string; url: string };
export type Systemauskunft = {
version: string; serverName: string | null;
needsSetup: boolean; attributions: Hinweis[];
};
/* ---------- Sitzung ---------- */
@@ -139,7 +164,7 @@ export const bildAdresse = (schluessel: string | null): string | null =>
/* ---------- System und Anmeldung ---------- */
export const systemauskunft = () =>
anfrage<{ version: string; needsSetup: boolean; attributions: Hinweis[] }>('/system/info');
anfrage<Systemauskunft>('/system/info');
export const einrichten = (benutzer: string, passwort: string) =>
anfrage<Rolle>('/system/setup', {
@@ -188,7 +213,8 @@ function geraetekennung(): string {
export type Ordnung = 'title' | 'added' | 'year';
type Suchfrage = {
library?: string; search?: string; sort?: Ordnung; skip?: number; take?: number;
library?: string; search?: string; sort?: Ordnung; genre?: string;
skip?: number; take?: number;
};
export const filme = (q: Suchfrage = {}) => anfrage<Seite<Film>>(`/movies${abfrage(q)}`);
@@ -247,6 +273,19 @@ export const downloadAdresse = (datei: string) => `/api/v1/files/${datei}/downlo
export const bibliotheken = () => anfrage<Bibliothek[]>('/libraries');
/** Nur die Genres, die in dieser Mediathek auch vorkommen. */
export const genresVon = (id: string) => anfrage<string[]>(`/libraries/${id}/genres`);
/** „Weil du Ähnliches gesehen hast" — über die Genres des Gesehenen. */
export const empfehlungen = (take = 20) =>
anfrage<Seite<Film>>(`/home/suggestions${abfrage({ take })}`);
export const metadatenAuffrischen = (id: string) =>
anfrage<void>(`/libraries/${id}/refresh`, { method: 'POST' });
export const servernameSetzen = (name: string) =>
anfrage<void>('/system/name', { method: 'PUT', body: JSON.stringify({ name }) });
export const bibliothekUmbenennen = (id: string, name: string) =>
anfrage<Bibliothek>(`/libraries/${id}`, {
method: 'PUT', body: JSON.stringify({ name }),
+174
View File
@@ -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;
}
+44 -47
View File
@@ -1,51 +1,48 @@
/* Die Symbole der Oberfläche.
----------------------------------------------------------------------------
Als Vektor, nicht als Schriftzeichen. Ein Wiedergabedreieck oder ein
Lautsprecher-Emoji wird auf jeder Plattform anders gezeichnet, trägt fremde
Farben, sitzt schief auf der Grundlinie und lässt sich weder in der
Strichstärke noch in der Farbe anpassen — es sieht nach Platzhalter aus,
weil es einer ist.
/* Icons als SVG, nicht als Zeichen aus einer Schriftart.
*
* **Keine Emojis.** Ein ▶ oder ✓ aus Unicode sieht auf jedem System anders
* aus, lässt sich nicht einfärben, nicht in der Strichstärke anpassen und
* springt in der Grundlinie. Diese hier sind auf 24×24 gezeichnet, erben die
* Textfarbe und skalieren mit der Schriftgrösse.
*
* Der Strichstil ist durchgehend derselbe: 1.8 Einheiten, runde Enden. */
`currentColor` erbt Farbe und Zustand vom Knopf darum. */
const rahmen = (inhalt: string, gefuellt = false): string =>
`<svg viewBox="0 0 24 24" aria-hidden="true" fill="${gefuellt ? 'currentColor' : 'none'}"`
+ ` stroke="${gefuellt ? 'none' : 'currentColor'}" stroke-width="1.8"`
+ ` stroke-linecap="round" stroke-linejoin="round">${inhalt}</svg>`;
type Zeichner = string | ((n: number) => string);
export const icons = {
start: rahmen('<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/>'),
/** Filmstreifen — für eine Filmmediathek. */
filme: rahmen('<rect x="3" y="4" width="18" height="16" rx="2"/>'
+ '<path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>'),
/** Bildschirm — für eine Serienmediathek. */
serien: rahmen('<rect x="2.5" y="4.5" width="19" height="12" rx="2"/><path d="M8 20h8"/>'),
verwaltung: rahmen('<circle cx="12" cy="12" r="3.2"/>'
+ '<path d="M19.4 14.5a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-1.8-.3 1.6 1.6 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-1-1.5 1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0 .3-1.8 1.6 1.6 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.5-1 1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 1.8.3H9a1.6 1.6 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 1 1.5 1.6 1.6 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8V9a1.6 1.6 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1z"/>'),
suche: rahmen('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'),
abspielen: rahmen('<path d="M8 5.5v13l11-6.5z"/>', true),
haken: rahmen('<path d="m5 12.5 4.5 4.5L19 7"/>'),
hakenKreis: rahmen('<circle cx="12" cy="12" r="9"/><path d="m8 12.3 2.8 2.7L16 9.6"/>'),
stift: rahmen('<path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z"/>'),
download: rahmen('<path d="M12 3v12"/><path d="m7.5 11 4.5 4.5L16.5 11"/><path d="M4 20h16"/>'),
links: rahmen('<path d="M15 5 8 12l7 7"/>'),
rechts: rahmen('<path d="m9 5 7 7-7 7"/>'),
mehr: rahmen('<circle cx="12" cy="5.5" r="1.3" fill="currentColor" stroke="none"/>'
+ '<circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none"/>'
+ '<circle cx="12" cy="18.5" r="1.3" fill="currentColor" stroke="none"/>'),
abmelden: rahmen('<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3"/>'
+ '<path d="M10 16.5 14.5 12 10 7.5"/><path d="M14.5 12H4"/>'),
stern: rahmen('<path d="m12 3.6 2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.5 9.8l5.9-.9z"/>'),
} as const;
const ICONS: Record<string, Zeichner> = {
play: '<path d="M8 5.5v13l11-6.5z" fill="currentColor" stroke="none"/>',
waveform: '<path d="M3 12h2M7 8v8M11 4.5v15M15 8v8M19 11h2"/>',
speed: '<path d="M12 20a8 8 0 100-16 8 8 0 000 16z"/><path d="M12 12l4-3"/>',
// Kreispfeile mit der Sekundenzahl darin — dasselbe Bild wie
// `gobackward.10` und `goforward.30` auf dem iPad.
goBack: (n) => `<path d="M11.5 4.5L7 8l4.5 3.5" /><path d="M7 8h4.5a7.5 7.5 0 11-7.2 9.6"/>`
+ `<text x="12" y="17.6" text-anchor="middle" font-size="7.5" font-weight="700"`
+ ` fill="currentColor" stroke="none" font-family="inherit">${n}</text>`,
goFwd: (n) => `<path d="M12.5 4.5L17 8l-4.5 3.5"/><path d="M17 8h-4.5a7.5 7.5 0 107.2 9.6"/>`
+ `<text x="12" y="17.6" text-anchor="middle" font-size="7.5" font-weight="700"`
+ ` fill="currentColor" stroke="none" font-family="inherit">${n}</text>`,
captions: '<rect x="3" y="5.5" width="18" height="13" rx="3"/><path d="M9.5 10.5a2.2 2.2 0 100 3M16 10.5a2.2 2.2 0 100 3"/>',
captionsOn: '<rect x="3" y="5.5" width="18" height="13" rx="3" fill="currentColor" stroke="none"/><path d="M9.5 10.5a2.2 2.2 0 100 3M16 10.5a2.2 2.2 0 100 3" stroke="#0B0B0D"/>',
check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
// Rechteck mit Balken oben bzw. Spalte links — die Leiste zieht dorthin.
barTop: '<rect x="2" y="3.5" width="20" height="17" rx="3"/><path d="M2 9h20"/>'
+ '<rect x="2" y="3.5" width="20" height="5.5" rx="3" fill="currentColor" stroke="none" opacity=".9"/>',
barSide: '<rect x="2" y="3.5" width="20" height="17" rx="3"/><path d="M8.5 3.5v17"/>'
+ '<rect x="2" y="3.5" width="6.5" height="17" rx="3" fill="currentColor" stroke="none" opacity=".9"/>',
pause: '<path d="M9 5.5v13M15 5.5v13"/>',
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
sound: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4z" fill="currentColor"/><path d="M16 9a4 4 0 010 6"/><path d="M18.5 6.5a7.5 7.5 0 010 11"/>',
muted: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4z" fill="currentColor"/><path d="M16 9.5l5 5M21 9.5l-5 5"/>',
expand: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
left: '<path d="M14.5 5.5L8 12l6.5 6.5"/>',
right: '<path d="M9.5 5.5L16 12l-6.5 6.5"/>',
up: '<path d="M12 18.5v-13M6.5 11L12 5.5 17.5 11"/>',
down: '<path d="M12 5.5v13M6.5 13L12 18.5 17.5 13"/>',
}
/** Ein Symbol als Vektor. `size` nur, wo es von der Vorgabe abweicht. */
export function icon(name: keyof typeof ICONS, size?: number, arg?: number): string {
const eintrag = ICONS[name];
const inhalt = typeof eintrag === 'function' ? eintrag(arg ?? 0) : eintrag;
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
${size ? `style="width:${size}px;height:${size}px"` : ''}>${inhalt}</svg>`;
export type IconName = keyof typeof icons;
/** Ein Icon als Element — für alles, was nicht aus einer Zeichenkette gebaut wird. */
export function icon(name: IconName, klasse = 'icon'): HTMLElement {
const h = document.createElement('span');
h.className = klasse;
h.innerHTML = icons[name];
return h;
}
+70 -29
View File
@@ -3,22 +3,25 @@ import {
systemauskunft, angemeldet, istAdmin, abmelden, sitzung, bibliotheken,
type Hinweis, type Bibliothek,
} from './api';
import { icons, icon } from './icons';
import { einrichtung } from './views/setup';
import { anmeldung } from './views/login';
import { startseite } from './views/home';
import { mediathek } from './views/library';
import { detailseite } from './views/detail';
import { verwaltung } from './views/admin';
import { suchseite } from './views/search';
import { esc } from './util';
const anmeldeflaeche = document.getElementById('login')!;
const kopfleiste = document.getElementById('topbar')!;
const app = document.getElementById('app')!;
const leiste = document.getElementById('chrome')!;
const inhalt = document.getElementById('content')!;
let hinweise: Hinweis[] = [];
/** Die Mediatheken, einmal geholt — die Seitenleiste steht auf ihnen. */
let mediatheken: Bibliothek[] = [];
let serverName = 'Flaro';
/* ---------- Start ---------- */
@@ -28,18 +31,21 @@ async function hochfahren(): Promise<void> {
try {
const auskunft = await systemauskunft();
hinweise = auskunft.attributions;
serverName = auskunft.serverName ?? 'Flaro';
if (auskunft.needsSetup) { zeigeAnmeldung(einrichtung(() => location.reload())); return; }
if (!angemeldet()) { zeigeAnmeldung(anmeldung(() => location.reload())); return; }
anmeldeflaeche.classList.add('hidden');
kopfleiste.classList.remove('hidden');
app.classList.remove('hidden');
// **Vor dem ersten Zeichnen.** Die Seitenleiste führt jede Mediathek
// einzeln auf; ohne die Liste gäbe es nichts zu zeichnen und der Router
// könnte `#/m/{id}` nicht auflösen.
// einzeln auf; ohne die Liste könnte der Router `#/m/{id}` nicht
// auflösen.
mediatheken = await bibliotheken();
kopfleisteBauen();
leisteBauen();
window.addEventListener('hashchange', zeichnen);
zeichnen();
@@ -48,9 +54,10 @@ async function hochfahren(): Promise<void> {
// Lüge — sie würde bei jedem Versuch scheitern.
anmeldeflaeche.innerHTML =
`<div class="login-card"><h1>Flaro</h1>`
+ `<div class="error">Der Server antwortet nicht: ${esc((fehler as Error).message)}</div>`
+ `<div class="hint">Läuft er? Stimmt die Adresse?</div></div>`;
+ `<div class="fehler">Der Server antwortet nicht: ${esc((fehler as Error).message)}</div>`
+ `<div class="hinweis">Läuft er? Stimmt die Adresse?</div></div>`;
anmeldeflaeche.classList.remove('hidden');
kopfleiste.classList.add('hidden');
app.classList.add('hidden');
}
}
@@ -59,45 +66,74 @@ function zeigeAnmeldung(karte: HTMLElement): void {
anmeldeflaeche.innerHTML = '';
anmeldeflaeche.appendChild(karte);
anmeldeflaeche.classList.remove('hidden');
kopfleiste.classList.add('hidden');
app.classList.add('hidden');
}
/** Lädt die Mediathekenliste neu — nach dem Anlegen oder Löschen einer. */
export async function mediathekenNeuLaden(): Promise<void> {
mediatheken = await bibliotheken();
leisteBauen();
zeichnen();
}
/* ---------- Kopfleiste ---------- */
function kopfleisteBauen(): void {
kopfleiste.innerHTML = '';
const marke = document.createElement('div');
marke.className = 'marke';
marke.textContent = 'Flaro';
// **Die Suche gehört nach oben, nicht in jede Mediathek.** Bei Plex steht
// sie in der Kopfleiste und geht quer durch alles; wer einen Titel sucht,
// weiss oft gar nicht, in welcher Mediathek er liegt.
const suche = document.createElement('form');
suche.className = 'suchfeld';
suche.innerHTML = icons.suche;
const eingabe = document.createElement('input');
eingabe.type = 'search';
eingabe.placeholder = 'Suchen';
eingabe.setAttribute('aria-label', 'Mediathek durchsuchen');
suche.appendChild(eingabe);
suche.onsubmit = e => {
e.preventDefault();
const text = eingabe.value.trim();
if (text) location.hash = `#/suche/${encodeURIComponent(text)}`;
};
const rechts = document.createElement('div');
rechts.className = 'rechts';
const konto = document.createElement('button');
konto.className = 'konto';
const name = sitzung.wer?.userName ?? '';
konto.innerHTML = `<span class="kreis">${esc(name.slice(0, 1).toUpperCase())}</span>`;
konto.title = `${name} — abmelden`;
konto.appendChild(icon('abmelden'));
konto.onclick = () => { abmelden(); location.reload(); };
rechts.appendChild(konto);
kopfleiste.append(marke, suche, rechts);
}
/* ---------- Seitenleiste ---------- */
function leisteBauen(): void {
leiste.innerHTML = '';
const marke = document.createElement('div');
marke.className = 'marke';
marke.textContent = 'Flaro';
leiste.appendChild(marke);
eintrag('Start', '#/', 'start');
eintrag('Start', '#/');
// **Jede Mediathek ihre eigene Zeile**, mit Namen und Zahl. Eine Zeile
// „Filme", hinter der zwei getrennt angelegte Bestände liegen, nähme die
// Entscheidung zurück, sie zu trennen.
// **Jede Mediathek ihre eigene Zeile**, mit eigenem Icon und Zahl — eine
// Zeile „Filme", hinter der zwei getrennt angelegte Bestände liegen, nähme
// die Entscheidung zurück, sie zu trennen.
if (mediatheken.length) {
gruppe('Mediatheken');
mediatheken.forEach(b => eintrag(b.name, `#/m/${b.id}`, b.itemCount));
mediatheken.forEach(b => eintrag(
b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount));
}
if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung'); }
const fuss = document.createElement('div');
fuss.className = 'nav-fuss';
const ab = document.createElement('button');
ab.textContent = `Abmelden · ${sitzung.wer?.userName ?? ''}`;
ab.onclick = () => { abmelden(); location.reload(); };
fuss.appendChild(ab);
leiste.appendChild(fuss);
if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung', 'verwaltung'); }
function gruppe(text: string): void {
const d = document.createElement('div');
@@ -106,10 +142,13 @@ function leisteBauen(): void {
leiste.appendChild(d);
}
function eintrag(text: string, ziel: string, anzahl?: number | null): void {
function eintrag(
text: string, ziel: string, bild: keyof typeof icons, anzahl?: number | null,
): void {
const b = document.createElement('button');
b.dataset.ziel = ziel;
b.innerHTML = `<span>${esc(text)}</span>`
b.innerHTML = `<span class="icon">${icons[bild]}</span>`
+ `<span class="name">${esc(text)}</span>`
+ (anzahl != null ? `<span class="zahl">${anzahl}</span>` : '');
b.onclick = () => { location.hash = ziel; };
leiste.appendChild(b);
@@ -124,7 +163,7 @@ function zeichnen(): void {
leiste.querySelectorAll('button[data-ziel]').forEach(b => {
const ziel = (b as HTMLElement).dataset.ziel!;
b.classList.toggle('check', ziel === `#/${teile.join('/')}`);
b.classList.toggle('check', ziel === `#/${teile.slice(0, 2).join('/')}`);
});
inhalt.innerHTML = '';
@@ -143,8 +182,10 @@ function zeichnen(): void {
// Eine Mediathek, die es nicht mehr gibt — etwa, weil sie in einem
// anderen Fenster gelöscht wurde. Zurück auf Start ist hier richtiger
// als eine Fehlermeldung über eine Kennung, die niemand gelesen hat.
if (b) inhalt.appendChild(mediathek(b, oeffnen));
if (b) inhalt.appendChild(mediathek(b, serverName, teile[2] ?? 'mediathek', oeffnen));
else location.hash = '#/';
} else if (teile[0] === 'suche' && kennung) {
inhalt.appendChild(suchseite(decodeURIComponent(kennung), oeffnen));
} else if (teile[0] === 'verwaltung' && istAdmin()) {
inhalt.appendChild(verwaltung(mediathekenNeuLaden));
} else {
+363 -182
View File
@@ -1,292 +1,473 @@
/* Flaro — Weboberfläche
/* Flaro — Weboberfläche.
*
* Der Aufbau folgt Plex: Seitenleiste links, Inhalt als waagerechte Reihen,
* Detailseite mit grossem unscharfem Hintergrund. Die Farben nicht — Orchid
* stimmt mit der iOS-App überein, und Plex' Orange ist deren Marke.
*/
* Der Aufbau folgt Plex: Kopfleiste oben, Seitenleiste links mit Icons,
* waagerechte Reihen auf der Startseite, Plakate im Verhältnis 2:3 mit Haken
* und Fortschrittsbalken, eine Detailseite mit unscharfem Hintergrund und
* dem Schriftzug des Films.
*
* **Die Farbe nicht.** Plex ist bernsteingelb, Flaro ist Orchidee. Das
* Vorbild ist die Anordnung, nicht das Aussehen. */
:root {
--accent: #C04AC9;
--accent-dim: #8E3595;
--accent-soft: rgba(192, 74, 201, .16);
--canvas: #0B0B0D;
--surface: #141417;
--elevated: #1D1D21;
--placeholder: #26262B;
--text: #F5F5F7;
--text-dim: #A1A1A8;
--text-faint: #6E6E76;
--separator: rgba(255, 255, 255, .10);
--elevated: #1D1D22;
--raised: #26262C;
--placeholder: #202026;
--line: rgba(255, 255, 255, .08);
--r-card: 6px;
--r-control: 8px;
--r-panel: 14px;
--text: #F2F2F5;
--text-dim: #B4B4BE;
--text-muted: #8A8A95;
--text-faint: #65656F;
--destructive: #E5484D;
--s-tight: 4px; --s-snug: 8px; --s-regular: 12px;
--s-loose: 16px; --s-section: 28px;
--s-loose: 16px; --s-section: 24px; --s-page: 32px;
--r-control: 8px; --r-card: 6px; --r-pill: 999px;
--card-w: 150px;
--sidebar-w: 240px;
--topbar-h: 56px;
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; height: 100%;
background: var(--canvas); color: var(--text);
font-family: var(--font); font-size: 14px; line-height: 1.45;
html, body { height: 100%; }
body {
margin: 0;
background: var(--canvas);
color: var(--text);
font: 400 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
button, input, select { font: inherit; color: inherit; }
button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }
.hidden { display: none !important; }
/* ---------- Gerüst ---------- */
/* **Jedes SVG bekommt eine Grösse, nicht nur die in `.icon`.** Ein rohes
`<svg>` ohne Breite füllt sonst seinen Container — und die Lupe in der
Suchleiste war dreihundert Bildpunkte gross und lag über der Seite. */
svg { width: 20px; height: 20px; display: block; flex: none; }
.icon { width: 20px; height: 20px; display: block; flex: none; }
#app { display: flex; min-height: 100%; }
/* ---------- Kopfleiste ---------- */
#chrome {
width: var(--sidebar-w); flex: 0 0 var(--sidebar-w);
background: var(--surface);
border-right: 1px solid var(--separator);
padding: var(--s-loose) 0;
display: flex; flex-direction: column; gap: var(--s-tight);
position: sticky; top: 0; height: 100vh; overflow-y: auto;
#topbar {
position: fixed; inset: 0 0 auto 0; height: var(--topbar-h); z-index: 30;
display: flex; align-items: center; gap: var(--s-loose);
padding: 0 var(--s-loose);
background: #0E0E11;
border-bottom: 1px solid var(--line);
}
#topbar .marke {
font-size: 20px; font-weight: 700; letter-spacing: -.02em;
color: var(--accent); user-select: none;
}
#topbar .suchfeld {
flex: 1; max-width: 420px;
display: flex; align-items: center; gap: var(--s-snug);
background: var(--elevated); border: 1px solid transparent;
border-radius: var(--r-pill); padding: 0 var(--s-regular); height: 34px;
color: var(--text-muted);
}
#topbar .suchfeld:focus-within { border-color: var(--accent); color: var(--text); }
#topbar .suchfeld input {
flex: 1; min-width: 0; background: none; border: 0; outline: none; color: var(--text);
}
#topbar .rechts { margin-left: auto; display: flex; align-items: center; gap: var(--s-regular); }
#topbar .konto {
display: flex; align-items: center; gap: var(--s-snug);
background: none; border: 0; color: var(--text-dim); padding: 0;
}
#topbar .konto .kreis {
width: 28px; height: 28px; border-radius: 50%;
background: var(--accent); color: #fff;
display: grid; place-items: center; font-size: 13px; font-weight: 600;
}
.marke {
font-size: 20px; font-weight: 700; letter-spacing: -.02em;
padding: 0 var(--s-loose) var(--s-loose);
color: var(--accent);
/* ---------- Grundgerüst ---------- */
#app { display: flex; min-height: 100%; padding-top: var(--topbar-h); }
#chrome {
position: fixed; top: var(--topbar-h); bottom: 0; left: 0;
width: var(--sidebar-w); overflow-y: auto;
background: #0E0E11;
border-right: 1px solid var(--line);
padding: var(--s-loose) 0;
display: flex; flex-direction: column; gap: 2px;
}
.nav-gruppe {
font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
color: var(--text-faint); padding: var(--s-loose) var(--s-loose) var(--s-tight);
font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase;
color: var(--text-faint); padding: var(--s-loose) var(--s-loose) var(--s-snug);
}
#chrome button {
display: block; width: calc(100% - var(--s-regular)); margin: 0 var(--s-snug);
text-align: left; background: none; border: 0; cursor: pointer;
padding: var(--s-snug) var(--s-regular); border-radius: var(--r-control);
color: var(--text-dim); font-size: 14px;
display: flex; align-items: center; gap: var(--s-regular);
background: none; border: 0; text-align: left;
padding: 9px var(--s-loose); color: var(--text-dim);
border-left: 3px solid transparent;
}
/* Jede Mediathek eine Zeile: Name links, Zahl rechts. */
#chrome button { display: flex; align-items: center; gap: var(--s-tight); }
#chrome button > span:first-child {
#chrome button > span.name {
flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
#chrome button .icon { flex: none; color: var(--text-muted); }
#chrome button:hover { color: var(--text); background: rgba(255, 255, 255, .04); }
#chrome button:hover .icon { color: var(--text-dim); }
/* **Der Balken links, nicht ein gefüllter Knopf.** Bei Plex markiert ein
schmaler farbiger Strich die aktive Zeile; das liest sich in einer langen
Liste ruhiger als ein leuchtender Block. */
#chrome button.check {
color: var(--accent); border-left-color: var(--accent);
background: linear-gradient(90deg, var(--accent-soft), transparent 60%);
}
#chrome button.check .icon { color: var(--accent); }
#chrome button .zahl {
flex: none; font-size: 11px; font-variant-numeric: tabular-nums;
color: var(--text-muted); background: var(--elevated);
padding: 1px 6px; border-radius: 8px;
color: var(--text-faint);
}
#chrome button.check .zahl { background: rgba(255, 255, 255, .22); color: #fff; }
#chrome button:hover { background: var(--elevated); color: var(--text); }
#chrome button.check { background: var(--accent-dim); color: #fff; }
#chrome button.check .zahl { color: var(--accent); }
.nav-fuss { margin-top: auto; padding-top: var(--s-loose); }
#content { flex: 1; min-width: 0; padding: var(--s-section); }
#content {
flex: 1; min-width: 0;
margin-left: var(--sidebar-w);
padding: var(--s-section) var(--s-page) var(--s-page);
}
/* ---------- Anmeldung ---------- */
#login {
display: flex; align-items: center; justify-content: center;
min-height: 100vh; padding: var(--s-loose);
min-height: 100vh; display: grid; place-items: center; padding: var(--s-section);
background: var(--canvas);
}
.login-card {
width: 100%; max-width: 360px; background: var(--surface);
border: 1px solid var(--separator); border-radius: var(--r-panel);
padding: var(--s-section); display: flex; flex-direction: column; gap: var(--s-regular);
width: 100%; max-width: 360px;
display: flex; flex-direction: column; gap: var(--s-regular);
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--r-control); padding: var(--s-section);
}
.login-card h1 { margin: 0; font-size: 26px; letter-spacing: -.02em; }
.login-card h1 { margin: 0; font-size: 26px; letter-spacing: -.02em; color: var(--accent); }
/* ---------- Bedienelemente ---------- */
/* ---------- Kopf einer Mediathek ---------- */
.field {
width: 100%; background: var(--elevated); border: 1px solid var(--separator);
border-radius: var(--r-control); padding: 10px var(--s-regular); outline: none;
.seitenkopf {
display: flex; align-items: flex-start; gap: var(--s-section);
margin-bottom: var(--s-section);
}
.field:focus { border-color: var(--accent); }
.btn {
background: var(--elevated); border: 1px solid var(--separator);
border-radius: var(--r-control); padding: 9px var(--s-loose);
cursor: pointer; white-space: nowrap;
.seitenkopf .wer { min-width: 0; }
.seitenkopf h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.02em; }
.seitenkopf .quelle {
font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-faint);
}
.btn:hover:not(:disabled) { background: #2A2A30; }
.btn:disabled { opacity: .5; cursor: default; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
.btn-primary:hover:not(:disabled) { background: #CB5BD3; }
.btn-row { display: flex; gap: var(--s-snug); flex-wrap: wrap; align-items: center; }
.seg { display: inline-flex; background: var(--elevated); border-radius: var(--r-control); padding: 2px; }
.seg button {
background: none; border: 0; cursor: pointer; color: var(--text-dim);
padding: 6px var(--s-loose); border-radius: 6px;
/* Reiter in der Mitte, aktiver unterstrichen — wie bei Plex. */
.reiter { display: flex; gap: var(--s-section); margin: 0 auto; }
.reiter button {
background: none; border: 0; padding: 4px 0 8px;
color: var(--text-muted); font-weight: 500;
border-bottom: 2px solid transparent;
}
.seg button.check { background: var(--accent); color: #fff; }
.reiter button:hover { color: var(--text); }
.reiter button.check { color: var(--accent); border-bottom-color: var(--accent); }
.seitentitel { margin: 0; font-size: 24px; letter-spacing: -.02em; }
.row { display: flex; gap: var(--s-regular); align-items: center; margin-bottom: var(--s-section); }
.row .field { max-width: 280px; margin-left: auto; }
/* Die Sortierung steht neben der Suche und braucht weniger Platz. */
.row .field.schmal { max-width: 190px; margin-left: auto; }
.row .field.schmal + .field { margin-left: 0; }
/* Filterzeile darunter. */
.filterzeile {
display: flex; align-items: center; gap: var(--s-regular);
margin-bottom: var(--s-section); flex-wrap: wrap;
}
.filterzeile .anzahl {
background: var(--elevated); border-radius: var(--r-card);
padding: 2px 10px; font-size: 13px; color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.filterzeile select, .filterzeile input {
background: var(--elevated); border: 1px solid transparent;
border-radius: var(--r-control); padding: 6px 10px; color: var(--text); outline: none;
}
.filterzeile select:focus, .filterzeile input:focus { border-color: var(--accent); }
.filterzeile .suche { margin-left: auto; min-width: 220px; }
/* ---------- Karten und Raster ---------- */
/* ---------- Kacheln ---------- */
.grid {
.raster {
display: grid; gap: var(--s-loose);
grid-template-columns: repeat(auto-fill, minmax(var(--card-w), 1fr));
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.card {
background: none; border: 0; padding: 0; cursor: pointer; text-align: left;
.kachel {
display: flex; flex-direction: column; gap: var(--s-snug);
background: none; border: 0; padding: 0; text-align: left; min-width: 0;
}
.card:hover .poster { transform: translateY(-3px); box-shadow: 0 10px 24px rgba(0,0,0,.5); }
.poster {
aspect-ratio: 2 / 3; width: 100%; background: var(--placeholder);
border-radius: var(--r-card); overflow: hidden; position: relative;
.plakat {
position: relative; width: 100%; aspect-ratio: 2 / 3;
background: var(--placeholder);
border-radius: var(--r-card); overflow: hidden;
transition: transform .15s ease, box-shadow .15s ease;
}
.poster img { width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity .2s; }
.poster.geladen img { opacity: 1; }
.plakat img {
width: 100%; height: 100%; object-fit: cover;
opacity: 0; transition: opacity .2s;
}
.plakat.geladen img { opacity: 1; }
/* Der Fortschrittsbalken auf der Kachel. Flaro zeigt ihn ab der ersten
Sekunde — es gibt keinen Anteil, der zu klein wäre, um ihn zu merken. */
.kachel:hover .plakat {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, .55);
}
/* Der Abspielknopf erscheint beim Überfahren, mittig. */
.schweber {
position: absolute; inset: 0; display: grid; place-items: center;
background: rgba(0, 0, 0, .45);
opacity: 0; transition: opacity .15s ease;
}
.schweber svg { width: 44px; height: 44px; color: #fff; filter: drop-shadow(0 2px 8px rgba(0,0,0,.6)); }
.kachel:hover .schweber { opacity: 1; }
/* **Haken und Zahl sitzen in derselben Ecke**, weil sie dieselbe Frage
beantworten: ist hier noch etwas offen? */
.ecke {
position: absolute; top: 0; right: 0; z-index: 2;
min-width: 26px; height: 26px; padding: 0 6px;
display: grid; place-items: center;
font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums;
border-bottom-left-radius: var(--r-card);
}
.ecke.haken { background: rgba(0, 0, 0, .72); color: #fff; }
.ecke.haken svg { width: 15px; height: 15px; }
.ecke.zahl { background: var(--accent); color: #fff; }
/* Ab der ersten Sekunde sichtbar — darum die Mindestbreite. */
.fortschritt {
position: absolute; left: 0; right: 0; bottom: 0; height: 3px;
position: absolute; left: 0; right: 0; bottom: 0; height: 3px; z-index: 2;
background: rgba(255, 255, 255, .22);
}
/* Eine Mindestbreite, damit auch eine einzige Minute sichtbar ist — bei
2:36 Laufzeit wären das sonst anderthalb Bildpunkte. */
.fortschritt > div { height: 100%; min-width: 4px; background: var(--accent); }
/* Die Plakette mit den offenen Folgen, wie bei Plex in der Ecke. */
.offen {
position: absolute; top: 6px; right: 6px;
min-width: 18px; padding: 1px 5px; border-radius: 9px;
background: var(--accent); color: #fff;
font-size: 11px; font-weight: 600; line-height: 16px; text-align: center;
.kacheltext { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.kacheltext strong {
font-weight: 500; font-size: 13px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.rowline { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.rowline strong { font-weight: 500; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---------- Waagerechte Reihen, wie bei Plex ---------- */
.reihe { margin-bottom: var(--s-section); }
.reihe h2 {
font-size: 17px; font-weight: 600; margin: 0;
letter-spacing: -.01em;
.kacheltext span {
font-size: 12px; color: var(--text-muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.kacheltext .dritte { color: var(--text-faint); }
/* ---------- Waagerechte Reihen ---------- */
.reihe { margin-bottom: var(--s-page); }
/* Überschrift links, „Alle zeigen" rechts — steht mehr drin, als die Reihe
zeigt, soll die Reihe keine Sackgasse sein. */
.reihe-kopf {
display: flex; align-items: baseline; gap: var(--s-regular);
display: flex; align-items: center; gap: var(--s-regular);
margin-bottom: var(--s-regular);
}
.reihe-kopf h2 { margin: 0; font-size: 19px; font-weight: 700; letter-spacing: -.01em; }
.reihe-kopf .mehr {
margin-left: auto; flex: none;
background: none; border: 0; cursor: pointer; padding: 0;
font-size: 13px; color: var(--accent);
background: none; border: 0; padding: 0; font-size: 13px; color: var(--accent);
}
.reihe-kopf .mehr:hover { text-decoration: underline; }
.strip {
display: grid; grid-auto-flow: column; grid-auto-columns: var(--card-w);
gap: var(--s-loose); overflow-x: auto; padding-bottom: var(--s-snug);
scrollbar-width: thin; scroll-snap-type: x proximity;
.reihe-pfeile { margin-left: auto; display: flex; gap: var(--s-tight); }
.pfeil {
width: 28px; height: 28px; display: grid; place-items: center;
background: var(--elevated); border: 0; border-radius: 50%; color: var(--text-dim);
}
.pfeil:hover { background: var(--raised); color: var(--text); }
.pfeil svg { width: 16px; height: 16px; }
.strip {
display: grid; grid-auto-flow: column; grid-auto-columns: 150px;
gap: var(--s-loose);
overflow-x: auto; scroll-snap-type: x proximity;
scrollbar-width: none; padding-bottom: var(--s-tight);
}
.strip::-webkit-scrollbar { display: none; }
.strip > * { scroll-snap-align: start; }
.strip::-webkit-scrollbar { height: 8px; }
.strip::-webkit-scrollbar-thumb { background: var(--elevated); border-radius: 4px; }
/* ---------- Detailseite ---------- */
.detail-head {
position: relative; margin: calc(var(--s-section) * -1) calc(var(--s-section) * -1) var(--s-section);
padding: var(--s-section); overflow: hidden;
.detail { position: relative; }
/* **Der Hintergrund füllt die Seite, nicht nur einen Streifen.** Bei Plex
liegt er unscharf und abgedunkelt hinter allem und gibt der Seite die
Farbe des Films. */
.detail-hintergrund {
position: fixed; top: var(--topbar-h); left: var(--sidebar-w); right: 0; bottom: 0;
z-index: -1;
background-size: cover; background-position: center top;
opacity: .28; filter: blur(2px);
mask-image: linear-gradient(180deg, #000 0%, rgba(0,0,0,.45) 55%, transparent 92%);
}
/* Der unscharfe Hintergrund, der die Seite trägt — Plex' auffälligster Zug. */
.detail-head::before {
content: ''; position: absolute; inset: -40px;
background-image: var(--backdrop); background-size: cover; background-position: center;
filter: blur(28px) saturate(1.2); opacity: .35; z-index: 0;
.detail-kopf {
display: flex; gap: var(--s-page); align-items: flex-start;
margin-bottom: var(--s-page);
}
.detail-head::after {
content: ''; position: absolute; inset: 0; z-index: 0;
background: linear-gradient(to bottom, rgba(11,11,13,.3), var(--canvas));
.detail-kopf .plakat {
flex: none; width: 240px;
box-shadow: 0 12px 40px rgba(0, 0, 0, .6);
}
.detail-head > * { position: relative; z-index: 1; }
.detail-text { min-width: 0; flex: 1; padding-top: var(--s-snug); }
.detail-inner { display: flex; gap: var(--s-section); margin-top: var(--s-loose); align-items: flex-start; }
.detail-inner .poster { width: 200px; flex: 0 0 200px; }
.detail-text h1 { margin: 0 0 var(--s-snug); font-size: 30px; letter-spacing: -.02em; }
.detail-text p { margin: 0; color: var(--text-dim); max-width: 60ch; }
.detail h2 { font-size: 17px; margin: var(--s-section) 0 var(--s-regular); }
/* ---------- Listen und Einstellungen ---------- */
.settings { display: flex; flex-direction: column; gap: var(--s-regular); }
.setting {
background: var(--surface); border: 1px solid var(--separator);
border-radius: var(--r-panel); padding: var(--s-loose);
display: flex; flex-direction: column; gap: var(--s-snug);
/* Der Schriftzug des Films statt der Systemschrift — wenn es einen gibt. */
.detail-text .schriftzug {
max-width: 420px; max-height: 110px; margin-bottom: var(--s-regular);
object-fit: contain; object-position: left bottom; display: block;
}
button.setting { cursor: pointer; text-align: left; width: 100%; }
button.setting:hover { background: var(--elevated); }
.detail-text h1 { margin: 0 0 var(--s-snug); font-size: 34px; font-weight: 700; letter-spacing: -.03em; }
.detail-text .unter { margin: 0 0 var(--s-regular); font-size: 17px; color: var(--text-dim); }
.hint { color: var(--text-dim); font-size: 13px; }
.tiny { color: var(--text-faint); font-size: 12px; }
.note { color: var(--text-faint); font-size: 12px; }
.error {
background: rgba(220, 60, 60, .14); border: 1px solid rgba(220, 60, 60, .4);
color: #FF9A9A; border-radius: var(--r-control); padding: var(--s-regular);
.eckdaten {
display: flex; align-items: center; gap: var(--s-regular); flex-wrap: wrap;
color: var(--text-dim); font-size: 13px; margin-bottom: var(--s-loose);
}
.empty { color: var(--text-faint); padding: var(--s-section) 0; grid-column: 1 / -1; }
.freigabe {
border: 1px solid var(--text-faint); border-radius: 3px;
padding: 0 6px; font-size: 11px; font-weight: 700; letter-spacing: .04em;
color: var(--text-dim);
}
.bewertung { display: flex; align-items: center; gap: 5px; color: var(--text); font-weight: 600; }
.bewertung svg { width: 15px; height: 15px; color: var(--accent); }
.werbezeile { color: var(--text-muted); font-style: italic; margin: 0 0 var(--s-loose); }
.beschreibung { max-width: 70ch; margin: 0 0 var(--s-section); color: var(--text-dim); }
.aktionen { display: flex; align-items: center; gap: var(--s-regular); margin-bottom: var(--s-section); flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; gap: var(--s-snug);
background: var(--elevated); border: 0; border-radius: var(--r-pill);
padding: 9px var(--s-loose); color: var(--text);
}
.btn:hover { background: var(--raised); }
.btn:disabled { opacity: .5; cursor: default; }
.btn svg { width: 18px; height: 18px; }
.btn-primary { background: var(--accent); color: #fff; font-weight: 600; }
.btn-primary:hover { background: var(--accent-dim); }
/* Runde Knöpfe für alles ohne Beschriftung — wie Plex' Merkliste und Haken. */
.rund {
width: 38px; height: 38px; padding: 0; border-radius: 50%;
display: grid; place-items: center;
background: none; border: 1px solid var(--line); color: var(--text-dim);
}
.rund:hover { border-color: var(--text-muted); color: var(--text); }
.rund.an { background: var(--accent); border-color: var(--accent); color: #fff; }
.rund svg { width: 19px; height: 19px; }
/* Die Medienangaben als Tabelle — Plex' „Video / Ton / Untertitel". */
.medientabelle { display: grid; grid-template-columns: auto 1fr; gap: var(--s-snug) var(--s-section); margin-bottom: var(--s-section); }
.medientabelle dt { color: var(--text-muted); font-size: 13px; }
.medientabelle dd { margin: 0; font-size: 13px; }
/* Besetzung: runde Porträts, wie bei Plex. */
.besetzung { display: grid; grid-auto-flow: column; grid-auto-columns: 110px; gap: var(--s-loose); overflow-x: auto; scrollbar-width: none; }
.besetzung::-webkit-scrollbar { display: none; }
.kopf-kachel { display: flex; flex-direction: column; align-items: center; gap: var(--s-snug); text-align: center; }
.kopf-kachel .portrait {
width: 92px; height: 92px; border-radius: 50%; overflow: hidden;
background: var(--placeholder); display: grid; place-items: center;
color: var(--text-faint); font-size: 26px; font-weight: 600;
}
.kopf-kachel .portrait img { width: 100%; height: 100%; object-fit: cover; }
.kopf-kachel strong { font-size: 12px; font-weight: 500; }
.kopf-kachel span { font-size: 11px; color: var(--text-muted); }
/* Genre-Kacheln, Plex' „Kategorien". */
.kategorien { display: grid; gap: var(--s-loose); grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); }
.kategorie {
aspect-ratio: 16 / 9; border: 0; border-radius: var(--r-control);
display: grid; place-items: center; color: #fff;
font-size: 19px; font-weight: 700; letter-spacing: -.01em;
position: relative; overflow: hidden;
}
.kategorie:hover { filter: brightness(1.15); }
/* ---------- Kleinteile ---------- */
h2 { font-size: 19px; font-weight: 700; letter-spacing: -.01em; margin: 0 0 var(--s-regular); }
.seitentitel { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.02em; }
.einstellungen { display: flex; flex-direction: column; gap: var(--s-regular); margin-bottom: var(--s-page); }
.einstellung {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--r-control); padding: var(--s-loose);
display: flex; flex-direction: column; gap: var(--s-regular);
}
.einstellung.waehlbar { text-align: left; width: 100%; }
.einstellung.waehlbar:hover { border-color: var(--accent); }
.zeile { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.zeile strong { font-weight: 600; }
.winzig { font-size: 12px; color: var(--text-muted); }
.hinweis { color: var(--text-dim); font-size: 13px; }
.feld {
background: var(--elevated); border: 1px solid transparent;
border-radius: var(--r-control); padding: 9px var(--s-regular);
color: var(--text); outline: none; min-width: 0;
}
.feld:focus { border-color: var(--accent); }
.knopfreihe { display: flex; gap: var(--s-snug); flex-wrap: wrap; align-items: center; }
.fehler {
background: rgba(229, 72, 77, .12); border: 1px solid rgba(229, 72, 77, .35);
border-radius: var(--r-control); padding: var(--s-regular); color: #FF9A9D; font-size: 13px;
}
.leer { color: var(--text-muted); padding: var(--s-section) 0; }
.spinner {
width: 22px; height: 22px; margin: var(--s-section) auto;
border: 2px solid var(--separator); border-top-color: var(--accent);
border: 2px solid var(--line); border-top-color: var(--accent);
border-radius: 50%; animation: dreh .7s linear infinite;
}
@keyframes dreh { to { transform: rotate(360deg); } }
/* ---------- Abspielen ---------- */
.pl-fill { position: fixed; inset: 0; background: #000; z-index: 50; }
/* ---------- Hinweis auf TMDB ---------- */
.attribution {
margin-top: var(--s-section); padding-top: var(--s-loose);
border-top: 1px solid var(--separator);
color: var(--text-faint); font-size: 11px; display: flex; gap: var(--s-snug); align-items: center;
display: flex; align-items: center; gap: var(--s-snug);
margin-top: var(--s-page); padding-top: var(--s-loose);
border-top: 1px solid var(--line);
color: var(--text-faint); font-size: 12px;
}
.attribution img { height: 14px; opacity: .7; }
/* ---------- Schmale Fenster ---------- */
/* Der Spieler über allem. */
.pl-fill {
position: fixed; inset: 0; z-index: 100; background: #000;
}
@media (max-width: 760px) {
/* ---------- Schmale Bildschirme ---------- */
@media (max-width: 860px) {
#app { flex-direction: column; }
#chrome {
width: 100%; flex: none; height: auto; position: static;
flex-direction: row; overflow-x: auto; padding: var(--s-snug);
border-right: 0; border-bottom: 1px solid var(--separator);
position: static; width: auto; flex-direction: row; overflow-x: auto;
border-right: 0; border-bottom: 1px solid var(--line); padding: var(--s-snug);
}
.marke, .nav-gruppe { display: none; }
#chrome button { border-left: 0; border-bottom: 3px solid transparent; white-space: nowrap; }
#chrome button.check { border-left-color: transparent; border-bottom-color: var(--accent); background: none; }
.nav-gruppe { display: none; }
.nav-fuss { margin: 0; padding: 0; display: flex; }
#content { padding: var(--s-loose); }
.detail-inner { flex-direction: column; }
.detail-inner .poster { width: 140px; flex: none; }
#content { margin-left: 0; padding: var(--s-loose); }
.detail-hintergrund { left: 0; }
.detail-kopf { flex-direction: column; gap: var(--s-loose); }
.detail-kopf .plakat { width: 160px; }
.detail-text h1 { font-size: 26px; }
#topbar .suchfeld { max-width: none; }
}
+4 -40
View File
@@ -1,4 +1,4 @@
import { bildAdresse } from './api';
export function esc(wert: unknown): string {
return String(wert ?? '').replace(/[&<>"']/g, z =>
@@ -20,42 +20,6 @@ export function groesse(bytes: number): string {
return `${Math.round(bytes / 1024)} KB`;
}
/** Ein Plakat mit Platzhalter dahinter.
*
* Der Platzhalter hält die Fläche frei, bis das Bild da ist — ohne ihn
* springt das Raster beim Nachladen jedes Mal um. */
export function plakat(
schluessel: string | null, beschriftung: string, fortschritt?: number | null,
): HTMLElement {
const rahmen = document.createElement('div');
rahmen.className = 'poster';
rahmen.setAttribute('role', 'img');
rahmen.setAttribute('aria-label', beschriftung);
const adresse = bildAdresse(schluessel);
if (adresse) {
const img = document.createElement('img');
img.src = adresse;
img.alt = '';
img.loading = 'lazy';
// Erst einblenden, wenn es da ist — sonst blitzt der Platzhalter durch.
img.onload = () => rahmen.classList.add('geladen');
rahmen.appendChild(img);
}
// Der Balken unten, wie bei Plex. Er erscheint, sobald überhaupt etwas
// gesehen wurde — es gibt keinen Anteil, der dafür zu klein wäre.
if (fortschritt != null && fortschritt > 0) {
const balken = document.createElement('div');
balken.className = 'fortschritt';
const gefuellt = document.createElement('div');
gefuellt.style.width = `${Math.min(100, fortschritt * 100)}%`;
balken.appendChild(gefuellt);
rahmen.appendChild(balken);
}
return rahmen;
}
/** Wie weit ein Stand gediehen ist, als Anteil — oder nichts. */
export function anteil(stand: { positionSeconds: number; runtimeSeconds: number | null } | null | undefined)
: number | null {
@@ -79,7 +43,7 @@ export function knopf(text: string, klasse = 'btn'): HTMLButtonElement {
export function feld(platzhalter: string, art = 'text'): HTMLInputElement {
const i = document.createElement('input');
i.className = 'field';
i.className = 'feld';
i.type = art;
i.placeholder = platzhalter;
return i;
@@ -88,14 +52,14 @@ export function feld(platzhalter: string, art = 'text'): HTMLInputElement {
/** Zeigt einen Fehler, ohne die Seite zu verlassen. */
export function fehlerzeile(text: string): HTMLElement {
const d = document.createElement('div');
d.className = 'error';
d.className = 'fehler';
d.textContent = text;
return d;
}
export function leer(text: string): HTMLElement {
const d = document.createElement('div');
d.className = 'empty';
d.className = 'leer';
d.textContent = text;
return d;
}
+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 () => {
+370 -164
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 ---------- */
function dateiliste(dateien: Datei[], titel: string, stand: Stand | null): HTMLElement {
const bereich = document.createElement('section');
bereich.className = 'settings';
if (dateien.length === 0) { bereich.appendChild(leer('Keine Datei hinterlegt.')); return bereich; }
dateien.forEach(datei => {
const block = document.createElement('div');
block.className = 'setting';
/** Jahr · Genres · Laufzeit · Freigabe · Bewertung — eine Zeile. */
function eckdaten(eintrag: Film | Serie): HTMLElement {
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);
zeile.className = 'eckdaten';
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 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);
}
block.appendChild(aktionen(datei, titel, stand));
bereich.appendChild(block);
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');
if (!mitwirkende?.length) return bereich;
const h = document.createElement('h2');
h.textContent = 'Besetzung & Stab';
const reihe = document.createElement('div');
reihe.className = 'besetzung';
mitwirkende.forEach(m => {
const k = document.createElement('div');
k.className = 'kopf-kachel';
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('');
}
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;
}
+196 -82
View File
@@ -1,50 +1,101 @@
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';
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 kopf = document.createElement('div');
kopf.className = 'row';
const zeile = document.createElement('div');
zeile.className = 'filterzeile';
const titel = document.createElement('h1');
titel.className = 'seitentitel';
titel.textContent = bibliothek.name;
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');
sortierung.className = 'field schmal';
([
['title', 'A–Z'],
['title', 'Nach Titel'],
['added', 'Zuletzt hinzugefügt'],
['year', 'Jahr'],
['year', 'Nach Jahr'],
] as [Ordnung, string][]).forEach(([wert, text]) => {
const o = document.createElement('option');
o.value = wert;
o.textContent = text;
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 = 'field';
suchfeld.className = 'suche';
suchfeld.type = 'search';
suchfeld.placeholder = `In ${bibliothek.name} suchen`;
suchfeld.oninput = () => {
@@ -54,71 +105,134 @@ export function mediathek(
tippt = window.setTimeout(() => { suche = suchfeld.value.trim(); void laden(); }, 250);
};
kopf.append(titel, sortierung, suchfeld);
zeile.append(genreWahl, sortierung, anzahl, suchfeld);
const raster = document.createElement('div');
raster.className = 'grid';
const gitter = document.createElement('div');
gitter.className = 'raster';
ziel.append(zeile, gitter);
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);
return k;
}
// 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.';