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); }