diff --git a/web/app/src/ansichten.ts b/web/app/src/ansichten.ts new file mode 100644 index 0000000..c963bae --- /dev/null +++ b/web/app/src/ansichten.ts @@ -0,0 +1,131 @@ +/* Ansichten überleben einen kurzen Wechsel. + * + * **Das Problem.** Der Router warf die Mediathek weg und baute sie beim + * Zurückkommen von Grund auf neu: 458 Kacheln, ein Netzabruf, und die + * Bildlaufposition war hin. Für einen Sprung in die Einstellungen und + * zurück ist das zu viel Aufwand für zu wenig Nutzen. + * + * **Was hier passiert.** Der Knoten einer Ansicht wird beim Verlassen + * ausgehängt statt zerstört. Kommt man zurück, hängt er wieder dort, wo er + * war — mitsamt Bildlauf, offenem Reiter und gesetzten Filtern. Im + * Hintergrund frischt er sich auf, damit nicht stillschweigend Altes + * dasteht. + * + * **Was hier nicht passiert.** Die Detailseite wird nicht behalten. Sie ist + * billig gebaut, und ein veralteter Sehfortschritt darauf wäre ärgerlicher + * als der Neuaufbau. Die Verwaltung auch nicht — sie zeigt Laufendes. */ + +/** Wie viele Ansichten gleichzeitig vorgehalten werden. */ +const HOECHSTENS = 5; + +/** Nach dieser Zeit wird neu gebaut statt wiederverwendet. */ +const VERFALL_MS = 5 * 60 * 1000; + +type Eintrag = { + knoten: HTMLElement; + abbruch: AbortController; + scroll: number; + gebaut: number; + /** Wird beim Wiedereinhängen gerufen — holt im Hintergrund Frisches. */ + auffrischen?: () => void; +}; + +const gemerkt = new Map(); + +/** Was eine Ansicht beim Zurückkommen nachladen will. + * + * Als `WeakMap` und nicht als Eigenschaft am Knoten: so hängt nichts am DOM, + * und mit dem Knoten verschwindet auch der Eintrag. */ +const nachlader = new WeakMap void>(); + +/** Meldet an, was beim Wiedereinhängen im Hintergrund geholt werden soll. + * + * **Ohne das sähe man beim Zurückkommen veraltete Daten und merkte es nicht.** + * Die Ansicht steht sofort da — das Frische kommt, wenn es da ist. */ +export function beiRueckkehr(knoten: HTMLElement, tun: () => void): void { + nachlader.set(knoten, tun); +} + +/** Die Ansicht, die gerade hängt — damit ihr Bildlauf beim Verlassen mitkommt. */ +let aktuell: { schluessel: string; eintrag: Eintrag } | null = null; + +/** Hängt die laufende Ansicht aus und merkt sich, wo sie stand. */ +export function ablegen(): void { + if (!aktuell) return; + aktuell.eintrag.scroll = window.scrollY; + aktuell.eintrag.knoten.remove(); + aktuell = null; +} + +/** Holt eine Ansicht aus dem Speicher oder baut sie — und hängt sie ein. + * + * `bauen` bekommt ein `AbortSignal`. Jede Ansicht, die einen Takt setzt oder + * nachlädt, prüft es, bevor sie weitermacht: ohne das liefen nach zehn + * Wechseln zehn Takte nebeneinander. */ +export function einhaengen( + schluessel: string, + ziel: HTMLElement, + bauen: (signal: AbortSignal) => HTMLElement, + optionen: { merken?: boolean } = {}, +): void { + ablegen(); + + const merken = optionen.merken ?? false; + const vorhanden = merken ? gemerkt.get(schluessel) : undefined; + const frisch = vorhanden && Date.now() - vorhanden.gebaut < VERFALL_MS; + + if (vorhanden && !frisch) { + vorhanden.abbruch.abort(); + gemerkt.delete(schluessel); + } + + const eintrag: Eintrag = frisch ? vorhanden : (() => { + const abbruch = new AbortController(); + return { knoten: bauen(abbruch.signal), abbruch, scroll: 0, gebaut: Date.now() }; + })(); + + ziel.appendChild(eintrag.knoten); + + if (frisch) { + // **Erst hängen, dann scrollen.** Vor dem Einhängen hat das Dokument die + // Höhe noch nicht, und `scrollTo` landete auf 0. + window.scrollTo(0, eintrag.scroll); + // **Danach nachladen, nicht davor.** Die alte Ansicht steht sofort da, + // das Frische kommt, wenn es da ist. Umgekehrt sähe man wieder einen + // Spinner, und der ganze Aufwand wäre umsonst. + nachlader.get(eintrag.knoten)?.(); + } else { + window.scrollTo(0, 0); + } + + if (!merken) { + aktuell = { schluessel, eintrag }; + return; + } + + gemerkt.delete(schluessel); + gemerkt.set(schluessel, eintrag); + aktuell = { schluessel, eintrag }; + + // Die am längsten nicht benutzte fliegt raus. `Map` hält die Einfügereihen- + // folge, und durch das Löschen oben rutscht jede Benutzung ans Ende. + while (gemerkt.size > HOECHSTENS) { + const aeltester = gemerkt.keys().next().value!; + gemerkt.get(aeltester)!.abbruch.abort(); + gemerkt.delete(aeltester); + } +} + +/** Wirft gemerkte Ansichten weg — alle, oder die mit diesem Anfang. + * + * Gerufen von allem, was den Bestand ändert: ein fertiger Scan, eine neue + * Mediathek, ein veröffentlichter Titel. **Nicht** von „gesehen" — das ändert + * eine Kachel, und dafür die ganze Ansicht wegzuwerfen wäre Verschwendung. */ +export function vergessen(praefix?: string): void { + for (const [schluessel, eintrag] of [...gemerkt]) { + if (praefix && !schluessel.startsWith(praefix)) continue; + if (aktuell?.schluessel === schluessel) continue; + eintrag.abbruch.abort(); + gemerkt.delete(schluessel); + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 13a9c87..4fbb263 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -83,6 +83,8 @@ export type Lieferung = { export type Scanstand = { state: string; filesFound: number | null; added: number | null; removed: number | null; unrecognised: number | null; probed: number | null; + /** Wie viele Dateien dieser Lauf als verschwunden gemeldet hat. */ + missing: number | null; problems: { path: string; reason: string }[] | null; error: string | null; metadata: { moviesMatched: number; seriesMatched: number; episodesMatched: number; skipped: boolean; error: string | null } | null; diff --git a/web/app/src/main.ts b/web/app/src/main.ts index d2b3685..2c2e99f 100644 --- a/web/app/src/main.ts +++ b/web/app/src/main.ts @@ -5,6 +5,7 @@ import { } from './api'; import { icons, icon } from './icons'; import { einrichtung } from './views/setup'; +import { einhaengen, ablegen, vergessen } from './ansichten'; import { anmeldung } from './views/login'; import { startseite } from './views/home'; import { mediathek } from './views/library'; @@ -76,6 +77,19 @@ export async function mediathekenNeuLaden(): Promise { zeichnen(); } +/** Frischt nur die Seitenleiste auf — ohne die Ansicht neu zu zeichnen. + * + * **Der Unterschied ist wichtiger, als er aussieht.** Die Verwaltung meldete + * nach jedem Scan „fertig" und liess neu zeichnen; der Neuaufbau startete die + * Verfolgung erneut, die sah wieder „fertig" und meldete wieder — eine + * Endlosschleife mit zwei Abrufen je Runde, die sich als vier dauernd + * drehende Spinner zeigte. Ein neuer Zählwert in der Seitenleiste braucht + * keinen Neuaufbau der Seite. */ +export async function zaehlerAuffrischen(): Promise { + mediatheken = await bibliotheken(); + leisteBauen(); +} + /* ---------- Kopfleiste ---------- */ function kopfleisteBauen(): void { @@ -166,8 +180,10 @@ function zeichnen(): void { b.classList.toggle('check', ziel === `#/${teile.slice(0, 2).join('/')}`); }); + // **Aushängen, nicht leeren.** `innerHTML = ''` zerstörte die Ansicht — + // mitsamt Bildlauf, offenem Reiter und gesetzten Filtern. + ablegen(); inhalt.innerHTML = ''; - window.scrollTo(0, 0); const oeffnen = (art: 'filme' | 'serien', id: string) => { location.hash = `#/${art}/${id}`; }; const alleZeigen = (b: Bibliothek) => { location.hash = `#/m/${b.id}`; }; @@ -176,20 +192,34 @@ function zeichnen(): void { const kennung = teile[1]; if (art && kennung) { - inhalt.appendChild(detailseite(art, kennung, () => history.back())); + // **Die Detailseite wird nicht gemerkt.** Sie ist billig gebaut, und ein + // veralteter Sehfortschritt darauf wäre ärgerlicher als der Neuaufbau. + einhaengen(weg, inhalt, () => detailseite(art, kennung, () => history.back())); } else if (teile[0] === 'm' && kennung) { const b = mediatheken.find(x => x.id === kennung); // 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, serverName, teile[2] ?? 'mediathek', oeffnen)); - else location.hash = '#/'; + if (b) { + einhaengen(weg, inhalt, + () => mediathek(b, serverName, teile[2] ?? 'mediathek', oeffnen), + { merken: true }); + } else location.hash = '#/'; } else if (teile[0] === 'suche' && kennung) { - inhalt.appendChild(suchseite(decodeURIComponent(kennung), oeffnen)); + einhaengen(weg, inhalt, () => suchseite(decodeURIComponent(kennung), oeffnen), + { merken: true }); } else if (teile[0] === 'verwaltung' && istAdmin()) { - inhalt.appendChild(verwaltung(mediathekenNeuLaden)); + // **Die Verwaltung auch nicht** — sie zeigt Laufendes. Ihr Takt hängt am + // Signal und hört auf, sobald man weggeht. + einhaengen(weg, inhalt, signal => verwaltung(() => { + // Gemerkte Ansichten wegwerfen: der Bestand hat sich geändert. + // Die Seitenleiste bekommt neue Zahlen — die Seite selbst bleibt. + vergessen(); + void zaehlerAuffrischen(); + }, signal)); } else { - inhalt.appendChild(startseite(mediatheken, oeffnen, alleZeigen)); + einhaengen(weg, inhalt, () => startseite(mediatheken, oeffnen, alleZeigen), + { merken: true }); } inhalt.appendChild(hinweisleiste()); diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index 3fab1c4..880235a 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -20,10 +20,13 @@ import { icon } from '../icons'; * * `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die * Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */ -export function verwaltung(geaendert: () => void = () => {}): HTMLElement { +export function verwaltung( + geaendert: () => void = () => {}, + signal: AbortSignal = new AbortController().signal, +): HTMLElement { const wurzel = document.createElement('div'); - wurzel.append(dashboard(), vermisstenbereich(geaendert), bibliotheksbereich(geaendert), - benutzerbereich(), serverbereich(geaendert)); + wurzel.append(dashboard(signal), vermisstenbereich(geaendert), + bibliotheksbereich(geaendert, signal), benutzerbereich(), serverbereich(geaendert)); return wurzel; } @@ -51,7 +54,7 @@ function abschnitt(titel: string, knoepfe: HTMLElement[] = []): HTMLElement { * **Fragt alle fünf Sekunden nach.** Ein dauerhaft offener Kanal wäre * sparsamer, aber für eine Handvoll Zeilen auf einer Seite, die man selten * offen hat, ist er der grössere Aufwand — auf beiden Seiten. */ -function dashboard(): HTMLElement { +function dashboard(signal: AbortSignal): HTMLElement { const bereich = document.createElement('section'); bereich.appendChild(abschnitt('Wird abgespielt')); @@ -94,6 +97,10 @@ function dashboard(): HTMLElement { liste.appendChild(fehlerzeile((fehler as Error).message)); return; // Nicht weiter nachfragen, wenn es scheitert. } + // **Nur weitermachen, solange die Ansicht hängt.** Ohne diese Zeile + // legte jeder Besuch der Verwaltung einen weiteren Fünf-Sekunden-Takt + // an, und nach zehn Wechseln liefen zehn davon nebeneinander. + if (signal.aborted) return; timer = window.setTimeout(() => void laden(), 5000); } @@ -229,7 +236,7 @@ function vermisstenbereich(geaendert: () => void): HTMLElement { /* ---------- Bibliotheken ---------- */ -function bibliotheksbereich(geaendert: () => void): HTMLElement { +function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLElement { const bereich = document.createElement('section'); const anlegen = knopf('Mediathek hinzufügen', 'btn btn-primary'); @@ -264,7 +271,10 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { void laden(); async function laden(): Promise { - liste.innerHTML = '
'; + // **Der Spinner gehört an den ersten Aufbau, nicht an jedes Auffrischen.** + // Sonst leert sich die Liste sichtbar, bevor dasselbe wieder erscheint — + // und bei einem Takt im Hintergrund blinkt sie dauernd. + if (liste.childElementCount === 0) liste.innerHTML = '
'; try { const alle = await bibliotheken(); liste.innerHTML = ''; @@ -302,7 +312,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { const los = knopf('Scannen', 'btn'); los.onclick = async () => { los.disabled = true; - try { await scanStarten(b.id); await verfolgen(); } + try { await scanStarten(b.id); await verfolgen(true); } catch (fehler) { stand.textContent = (fehler as Error).message; } los.disabled = false; }; @@ -316,7 +326,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { + 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. ' + 'Bei vielen Titeln dauert es.', bestaetigen: 'Auffrischen', - tun: async () => { await metadatenAuffrischen(b.id); void verfolgen(); }, + tun: async () => { await metadatenAuffrischen(b.id); void verfolgen(true); }, }); const umbenennen = knopf('Umbenennen', 'btn'); @@ -341,8 +351,16 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { }); /** Fragt nach, bis der Lauf durch ist — er läuft im Hintergrund. */ - async function verfolgen(): Promise { + /// Verfolgt einen Lauf bis zum Ende. + /// + /// **`melden` entscheidet, ob am Schluss `geaendert()` gerufen wird.** + /// Beim Aufbau der Seite ist es `false`: ein längst beendeter Lauf meldete + /// sonst „fertig", liess neu zeichnen, und der Neuaufbau verfolgte wieder + /// denselben beendeten Lauf — eine Endlosschleife, die sich als vier + /// dauernd drehende Spinner zeigte. + async function verfolgen(melden: boolean): Promise { for (let i = 0; i < 900; i++) { + if (signal.aborted) return; const s = await scanstand(b.id); if (s.state === 'running') stand.textContent = 'Wird durchsucht…'; else if (s.state === 'enriching') stand.textContent = 'Metadaten werden geholt…'; @@ -353,9 +371,9 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { if (s.metadata?.skipped) teile.push('Metadaten übersprungen'); else if (s.metadata) teile.push(`${s.metadata.moviesMatched + s.metadata.seriesMatched} zugeordnet`); if (s.metadata?.error) teile.push(s.metadata.error); + if (s.missing) teile.push(`${s.missing} nicht mehr gefunden`); stand.textContent = teile.join(' · '); - await laden(); - geaendert(); + if (melden) { await laden(); geaendert(); } return; } await new Promise(r => setTimeout(r, 1000)); @@ -366,7 +384,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { knoepfe.className = 'knopfreihe'; knoepfe.append(los, auffrischen, umbenennen, entfernen); d.append(knoepfe, stand); - void verfolgen(); + void verfolgen(false); return d; } @@ -404,7 +422,10 @@ function benutzerbereich(): HTMLElement { void laden(); async function laden(): Promise { - liste.innerHTML = '
'; + // **Der Spinner gehört an den ersten Aufbau, nicht an jedes Auffrischen.** + // Sonst leert sich die Liste sichtbar, bevor dasselbe wieder erscheint — + // und bei einem Takt im Hintergrund blinkt sie dauernd. + if (liste.childElementCount === 0) liste.innerHTML = '
'; try { const alle = await benutzer(); liste.innerHTML = ''; diff --git a/web/app/src/views/library.ts b/web/app/src/views/library.ts index aa2758b..3860a0e 100644 --- a/web/app/src/views/library.ts +++ b/web/app/src/views/library.ts @@ -2,6 +2,7 @@ import { inhalt, genresVon, empfehlungen as holeEmpfehlungen, type Bibliothek, t import { leer, fehlerzeile } from '../util'; import { icons } from '../icons'; import { kachel, alsKachel, reihe } from '../card'; +import { beiRueckkehr } from '../ansichten'; type Art = 'filme' | 'serien'; type Reiter = 'empfehlungen' | 'mediathek' | 'kategorien'; @@ -181,8 +182,14 @@ export function mediathek( void laden(); + // Beim Zurückkommen aus einer anderen Ansicht leise nachladen — die alte + // Liste bleibt stehen, bis die neue da ist. + beiRueckkehr(wurzel, () => void laden()); + async function laden(): Promise { - gitter.innerHTML = '
'; + // Der Spinner nur beim ersten Mal; beim Nachladen bleibt stehen, was da + // ist, bis es ersetzt wird. + if (gitter.childElementCount === 0) gitter.innerHTML = '
'; try { const seite = await inhalt(bibliothek, { search: suche, sort: ordnung, genre, take: 200 });