import {
bildAdresse, gesehen, favorit, weglegen, istAdmin, film, serie,
type Film, type Serie, type Angefangen, type Stand,
} from './api';
import { esc, anteil, hinweisZeigen } from './util';
import { icons } from './icons';
import { kontextmenue, menueAnZiel, type Menuepunkt } from './menue';
import { zuordnungsdialog } from './zuordnung';
import { bearbeiten } from './bearbeiten';
/** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */
export type Kachel = {
/** Wohin der Klick führt — bei einer Folge die Serie, zu der sie gehört. */
id: string;
/** Welche Kennung den Sehstand trägt. Fehlt sie, ist es `id`.
*
* **Nur dort verschieden, wo die Kachel woandershin führt als sie misst:**
* eine Folge aus „Fortsetzen" öffnet die Serie, abgehakt wird aber die
* Folge. */
standId?: string;
/** Aus welcher Reihe die Kachel stammt — nur dort, wo es etwas ändert.
*
* **Bisher nur für „Als Nächstes".** Dort heisst Weglegen etwas anderes
* als in „Fortsetzen": nicht diese Folge, sondern diese Serie. */
reihe?: 'naechste' | 'fortsetzen';
/** Was ein Tipp auslöst, wenn es nicht die Detailseite sein soll.
*
* **Nur in „Fortsetzen".** Dort ist die Absicht eindeutig: der Titel steht
* in der Reihe, *weil* man ihn angefangen hat, und die Stelle steht
* daneben. Überall sonst führt der Tipp auf die Seite — wer nur nachsehen
* will, wie viele Staffeln eine Serie hat, soll nicht den Film bekommen. */
starten?: () => void;
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;
/** Wie viele Folgen es überhaupt sind — um die Plakette wiederherzustellen. */
gesamt?: number | null;
/** Wonach einsortiert wird. **Damit die Kachel weiss, wohin sie gehört**,
* wenn sich der Titel ändert — sonst stand „WALL·E" nach der Korrektur
* weiter unter E, bis man die Seite neu lud. */
sortTitel?: string | null;
/** `null` heisst: wartet auf Freigabe. Ein Zeitpunkt in der Zukunft: geplant. */
freigabe?: string | 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: e.state,
offen: 'unwatchedCount' in e ? e.unwatchedCount : null,
gesamt: 'episodeCount' in e ? e.episodeCount : null,
sortTitel: e.sortTitle,
freigabe: e.publishAt,
});
/** Eine Kachel aus „Fortsetzen" oder „Nächste Folge" — bei Folgen
* dreizeilig, wie bei Plex.
*
* **Die Kachel führt zur Serie, der Sehstand bleibt bei der Folge.**
*
* Hier stand die Folgenkennung, und die Kachel öffnete damit eine Serienseite
* — die es unter dieser Kennung nie gibt. Heraus kam „Diese Serie gibt es
* nicht mehr", ausgerechnet unter „Nächste Folge", wo man gerade
* weiterschauen wollte. Eine eigene Seite je Folge gibt es nicht; die Staffeln
* stehen auf der Serienseite, und dorthin gehört der Klick.
*
* **Beides auf dieselbe Kennung zu legen wäre der nächste Fehler gewesen:**
* „Als gesehen markieren" hätte dann die ganze Serie abgehakt statt der
* einen Folge, die man gerade zur Hälfte gesehen hat.
*/
export const angefangenAlsKachel = (e: Angefangen, reihe?: Kachel['reihe']): Kachel =>
e.kind === 'episode'
? {
id: e.seriesId ?? e.id, art: 'serien',
standId: e.id,
titel: e.title,
zweite: e.subTitle,
dritte: `S${e.season} · F${e.episode}`,
bild: e.posterImage, stand: e.state, reihe,
}
: {
id: e.id, art: 'filme',
titel: e.title,
zweite: e.year ? String(e.year) : null,
bild: e.posterImage, stand: e.state, reihe,
};
/* ---------- 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';
// **Die Kennung steht am Knoten.** So kann ein Auswahlmodus über dem Raster
// arbeiten, ohne dass jede der fünf Stellen, die Kacheln baut, davon wissen
// muss — sie liest sie vom angeklickten Element ab.
knopf.dataset.id = k.id;
knopf.dataset.art = k.art;
knopf.onclick = () => (k.starten ? k.starten() : oeffnen(k.art, k.id));
// **Das Kontextmenü an jeder Kachel, überall.** Gesehen, Favorit und
// Weglegen gab es nur auf der Detailseite — in einer Übersicht mit zwanzig
// Titeln hiess „die hab ich alle gesehen" vierzig Seitenwechsel.
kontextmenue(knopf, () => kachelpunkte(k, knopf, oeffnen));
// **Ein sichtbarer Griff zum selben Menü.**
//
// Rechtsklick findet nur, wer ihn vermutet, und auf einem Telefon gibt es
// ihn gar nicht. Die drei Punkte erscheinen beim Überfahren und bei
// Tastaturfokus — sie sind ein eigener Knopf, nicht Teil der Kachel, sonst
// öffnete jeder Klick darauf auch die Detailseite.
const mehr = document.createElement('span');
mehr.className = 'kachel-mehr';
mehr.setAttribute('role', 'button');
mehr.setAttribute('tabindex', '0');
mehr.setAttribute('aria-label', `Mehr zu „${k.titel}“`);
mehr.innerHTML = icons.punkte;
mehr.onclick = e => {
e.stopPropagation();
menueAnZiel(mehr, kachelpunkte(k, knopf, oeffnen));
};
mehr.onkeydown = e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
e.stopPropagation();
menueAnZiel(mehr, kachelpunkte(k, knopf, oeffnen));
};
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';
// **Kein Ziehen des Bildes.** Browser machen aus jedem Bild von sich aus
// ein Ziehgut; wer über die Kacheln streicht, um mehrere auszuwählen,
// schleppte sonst ein halbdurchsichtiges Plakat hinter sich her.
bild.draggable = false;
// Erst einblenden, wenn es da ist — sonst blitzt der Platzhalter durch.
bild.onload = () => rahmen.classList.add('geladen');
rahmen.appendChild(bild);
}
// **Was noch wartet, trägt ein Band.**
//
// Für einen Administrator stehen wartende Titel mitten zwischen den
// freigegebenen, ohne dass etwas sie unterscheidet — und wer einen davon
// sucht, findet ihn nur über die Prüfliste. Für alle anderen ist er gar
// nicht da, dann erscheint das Band auch nie.
if (k.freigabe === null || (k.freigabe && new Date(k.freigabe) > new Date())) {
const band = document.createElement('span');
band.className = 'wartet';
band.textContent = k.freigabe === null ? 'Wartet' : 'Geplant';
band.title = k.freigabe === null
? 'Wartet auf Freigabe — nur für Administratoren sichtbar'
: `Erscheint am ${new Date(k.freigabe).toLocaleString('de-CH',
{ dateStyle: 'medium', timeStyle: 'short' })}`;
rahmen.appendChild(band);
}
// **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);
rahmen.appendChild(mehr);
knopf.appendChild(rahmen);
const text = document.createElement('div');
text.className = 'kacheltext';
text.innerHTML =
`${esc(k.titel)}`
+ (k.zweite ? `${esc(k.zweite)}` : '')
+ (k.dritte ? `${esc(k.dritte)}` : '');
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;
}
/** Was im Kontextmenü einer Kachel steht.
*
* **Aktionen wirken sofort auf der Kachel**, ohne Seitenwechsel und ohne
* Neuaufbau der Liste. Den Zustand hier zu spiegeln statt neu zu laden ist
* nicht nur schneller — ein Neuaufbau verlöre Bildlauf und Filter.
*/
function kachelpunkte(
k: Kachel,
knopf: HTMLElement,
oeffnen: (art: 'filme' | 'serien', id: string) => void,
): Menuepunkt[] {
const istGesehen = k.stand?.watched === true;
const punkte: Menuepunkt[] = [
{
text: istGesehen ? 'Als ungesehen markieren' : 'Als gesehen markieren',
icon: 'hakenKreis',
tun: async () => {
// **Die Antwort des Servers gilt, nicht die Annahme hier.** Bei einer
// Serie zieht das Häkchen alle Folgen mit; was dabei herauskommt,
// weiss nur der Server.
k.stand = await gesehen(k.standId ?? k.id, !istGesehen);
// **In „Fortsetzen" und „Als Nächstes" ist ein Haken ein Abschied.**
//
// Beide Reihen zeigen, was offen ist. Eine abgehakte Folge ist nicht
// mehr offen — sie mit einem Haken stehen zu lassen sah aus, als
// hätte das Abhaken nicht gewirkt. Der Server lieferte beim nächsten
// Laden längst das Richtige; nur sah man das nie, weil die Reihe
// nicht neu geladen wurde.
if (k.reihe && !istGesehen) {
knopf.remove();
return;
}
// **Sonst spiegelt die Kachel den neuen Zustand selbst.** Die Liste
// neu zu laden verlöre Bildlauf und Filter — für einen Haken.
k.offen = istGesehen ? (k.gesamt ?? k.offen) : 0;
hakenZeigen(knopf, k, !istGesehen);
},
},
{
text: k.stand?.favourite ? 'Aus Favoriten entfernen' : 'Zu Favoriten',
icon: 'stern',
tun: async () => {
const neuerWert = !k.stand?.favourite;
k.stand = await favorit(k.standId ?? k.id, neuerWert);
},
},
];
// Nur anbieten, wenn es dort auch steht — ein Knopf, der nichts tut, ist
// schlechter als keiner.
if (k.stand && !k.stand.watched && k.stand.positionSeconds > 0) {
punkte.push({
// **Dieselbe Reihe, derselbe Name.** Oben stand „Fortsetzen", hier
// „Weiterschauen" — zwei Wörter für eine Sache, und der Knopf sah
// danach aus, als meinte er etwas anderes.
text: 'Aus Fortsetzen entfernen',
tun: async () => {
await weglegen(k.standId ?? k.id);
// In der Reihe selbst heisst „entfernen" genau das.
if (k.reihe === 'fortsetzen') { knopf.remove(); return; }
if (k.stand) k.stand.dismissed = true;
knopf.querySelector('.fortschritt')?.remove();
},
});
}
// **In „Als Nächstes" wird die Serie weggelegt, nicht die Folge.**
//
// Eine einzelne Folge dort wegzulegen nützt nichts: beim nächsten Laden
// rückt die übernächste nach. Gemeint ist „diese Serie nicht mehr
// vorschlagen" — und `k.id` ist bei einer Folgenkachel ohnehin die Serie.
if (k.reihe === 'naechste') {
punkte.push({
text: 'Serie aus Als Nächstes entfernen',
tun: async () => {
await weglegen(k.id);
knopf.remove();
},
});
}
punkte.push({ text: 'Details', icon: 'mehr', tun: () => oeffnen(k.art, k.id) });
if (istAdmin()) {
const art = k.art === 'serien' ? 'series' : 'movies';
// **Ein Ort für alles, und die Abkürzungen bleiben.** Titel, Sortiertitel,
// Bilder und Zuordnung gehören zusammen: wer einen Film geraderückt, macht
// selten nur eines davon. Wer weiss, was er will, nimmt weiter den
// einzelnen Punkt darunter.
punkte.push({
text: 'Bearbeiten',
icon: 'stift',
tun: () => bearbeiten(art, k.id, ergebnis => {
if (ergebnis.verschmolzen) {
knopf.remove();
hinweisZeigen(`„${ergebnis.titel}" wurde mit einem vorhandenen Eintrag zusammengelegt.`);
return;
}
// Auch ein getauschtes Plakat kommt damit mit — `kachelAuffrischen`
// holt den Eintrag ohnehin frisch.
void kachelAuffrischen(k, knopf);
}),
});
// **Nur noch diese eine Abkürzung.** Titel und Bilder standen zusätzlich
// im Menü, seit es „Bearbeiten" gibt aber doppelt — und ein Menü mit zwei
// Wegen zur selben Sache ist eines zu viel. Die Zuordnung bleibt, weil
// sie der häufigste Grund ist, überhaupt etwas anzufassen.
punkte.push({
text: 'Zuordnung korrigieren',
icon: 'suche',
// **Hier und nicht nur auf der Detailseite.** Wer zwanzig falsch
// erkannte Titel vor sich hat, soll sie hier geradeziehen können.
// **Ohne Jahr.** Das angezeigte stammt aus der Zuordnung, die gerade
// korrigiert werden soll — bei einem falschen Treffer grenzt es die
// Suche auf genau das falsche Jahr ein. Ohne Angabe nimmt der Server
// das aus dem Dateinamen.
tun: () => zuordnungsdialog(art, k.id, k.titel, null, stand => {
// **Zusammengelegt heisst: diesen Eintrag gibt es nicht mehr.**
//
// Vorher blieb die Kachel stehen und zeigte auf einen gelöschten
// Eintrag; wer sie dann bearbeiten wollte, bekam „Not Found" — eine
// englische Meldung für etwas, das gar kein Fehler war. Der
// überlebende Eintrag steht ohnehin schon in der Liste.
if (stand.merged) {
knopf.remove();
hinweisZeigen(`„${stand.title}" wurde mit dem vorhandenen Eintrag zusammengelegt.`);
return;
}
// **Nur die Kachel, nicht die Seite.** `location.reload()` warf
// Bildlauf, Filter und offene Reiter weg — für einen Titel, der sich
// geändert hat. Bei zwanzig Korrekturen hintereinander ist das
// zwanzigmal von vorn anfangen.
void kachelAuffrischen(k, knopf);
}),
});
}
return punkte;
}
/** Holt einen Titel neu und schreibt Beschriftung und Plakat fort.
*
* **Warum nicht die ganze Liste.** Nach einer Korrektur ändert sich genau ein
* Eintrag. Alles neu zu laden kostet einen Netzabruf über mehrere hundert
* Titel und setzt die Ansicht an den Anfang zurück.
*/
export async function kachelAuffrischen(k: Kachel, knopf: HTMLElement): Promise {
let frisch: Film | Serie;
try { frisch = k.art === 'serien' ? await serie(k.id) : await film(k.id); }
catch { return; } // Still bleiben: die alte Kachel ist besser als eine Meldung.
const vorher = k.sortTitel;
k.titel = frisch.title;
k.zweite = frisch.year ? String(frisch.year) : null;
k.bild = frisch.posterImage;
k.sortTitel = frisch.sortTitle;
// **Die Liste erfährt, dass die Kachel woandershin gehört.** Sie allein
// weiss, ob der neue Platz überhaupt geladen ist.
if (vorher !== k.sortTitel) {
knopf.dispatchEvent(new CustomEvent('kachel-umsortiert', {
bubbles: true,
detail: { id: k.id, sortTitel: k.sortTitel, titel: k.titel },
}));
}
const text = knopf.querySelector('.kacheltext');
if (text) {
text.innerHTML = `${esc(k.titel)}`
+ (k.zweite ? `${esc(k.zweite)}` : '');
}
const plakat = knopf.querySelector('.plakat');
const adresse = bildAdresse(k.bild);
plakat?.querySelector('img')?.remove();
plakat?.classList.remove('geladen');
if (plakat && adresse) {
const bild = document.createElement('img');
bild.src = adresse;
bild.alt = '';
bild.draggable = false;
bild.onload = () => plakat.classList.add('geladen');
plakat.prepend(bild);
}
}
/** Setzt oder entfernt den Haken, ohne die Kachel neu zu bauen.
*
* **Die Ecke zeigt eines von beidem.** Haken oder Zahl offener Folgen — beides
* nebeneinander wäre ein Widerspruch. Wird eine Serie abgehakt, geht die Zahl;
* wird das Häkchen genommen, kommt sie zurück.
*/
function hakenZeigen(knopf: HTMLElement, k: Kachel, an: boolean): void {
const plakat = knopf.querySelector('.plakat');
if (!plakat) return;
plakat.querySelector('.ecke')?.remove();
knopf.querySelector('.fortschritt')?.remove();
if (an) {
const ecke = document.createElement('span');
ecke.className = 'ecke haken';
ecke.title = 'Gesehen';
ecke.innerHTML = icons.haken;
plakat.appendChild(ecke);
return;
}
if (k.offen == null || k.offen <= 0) return;
const plakette = document.createElement('span');
plakette.className = 'ecke zahl';
plakette.textContent = String(k.offen);
plakette.title = `${k.offen} Folgen offen`;
plakat.appendChild(plakette);
}