import './style.css'; import { systemauskunft, angemeldet, istAdmin, abmelden, sitzung, bibliotheken, reihenfolgeSetzen, bildAdresse, kontoAbgleichen, type Hinweis, type Bibliothek, } from './api'; import { icons } from './icons'; import { einrichtung } from './views/setup'; import { einladungsseite } from './views/einladung'; import { einhaengen, ablegen, vergessen } from './ansichten'; import { anmeldung } from './views/login'; import { startseite } from './views/home'; import { mediathek } from './views/library'; import { detailseite } from './views/detail'; import { einstellungenseite, alsBereich } from './views/einstellungen'; import { suchseite } from './views/search'; import { esc, meldung } from './util'; import { menueAnZiel } from './menue'; import { nachfragen } from './dialog'; 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[] = []; let mediatheken: Bibliothek[] = []; let serverName = 'Flaro'; /* ---------- Start ---------- */ void hochfahren(); async function hochfahren(): Promise { try { const auskunft = await systemauskunft(); hinweise = auskunft.attributions; serverName = auskunft.serverName ?? 'Flaro'; // **So früh wie möglich.** Die Höhe des Aufmachers steht als Marke am // ``; käme sie später, stünde er einen Augenblick in der Vorgabe // und spränge dann — genau das, was eine einstellbare Höhe vermeiden soll. document.documentElement.dataset.aufmacher = auskunft.heroHeight ?? 'mittel'; if (auskunft.needsSetup) { zeigeAnmeldung(einrichtung(() => location.reload())); return; } // **Die Einladung muss hierher, nicht in den Router.** Beide Zweige // ringsum kehren zurück, bevor `zeichnen()` je läuft — ein Ausgeloggter // mit Einladungslink sähe sonst schlicht die Anmeldekarte, und nach dem // Anmelden landete er auf der Startseite, ohne je erfahren zu haben, // wozu er eingeladen war. // // Nach dem Einrichten, damit ein nagelneuer Server zuerst seinen // Administrator bekommt; vor der Anmeldung, weil genau die ja fehlt. const [erstes, zweites] = location.hash.replace(/^#\/?/, '').split('/'); if (erstes === 'einladung' && zweites) { zeigeAnmeldung(einladungsseite(decodeURIComponent(zweites), () => { // Der Hash muss weg, sonst landet das Neuladen wieder hier. location.hash = '#/'; 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 könnte der Router `#/m/{id}` nicht // auflösen. // **Das Konto frisch holen, bevor die Kopfleiste steht.** Sie zeigt Bild // und Namen aus der gemerkten Sitzung, und die stammt von der Anmeldung — // ein Bild, das anderswo gesetzt wurde, käme hier nie an. await Promise.all([kontoAbgleichen(), (async () => { mediatheken = await bibliotheken(); })()]); kopfleisteBauen(); leisteBauen(); // Das Profil meldet, wenn sich am Konto etwas geändert hat — dann zeigt // die Kopfleiste das neue Bild sofort und nicht erst nach dem Neuladen. window.addEventListener('flaro:konto', kopfleisteBauen); window.addEventListener('hashchange', zeichnen); zeichnen(); } catch (fehler) { // Der Server ist nicht erreichbar. Die Anmeldemaske zu zeigen wäre eine // Lüge — sie würde bei jedem Versuch scheitern. anmeldeflaeche.innerHTML = `

Flaro

` + `
Der Server antwortet nicht: ${esc(meldung(fehler))}
` + `
Läuft er? Stimmt die Adresse?
`; anmeldeflaeche.classList.remove('hidden'); kopfleiste.classList.add('hidden'); app.classList.add('hidden'); } } function zeigeAnmeldung(karte: HTMLElement): void { anmeldeflaeche.innerHTML = ''; anmeldeflaeche.appendChild(karte); anmeldeflaeche.classList.remove('hidden'); kopfleiste.classList.add('hidden'); app.classList.add('hidden'); } export async function mediathekenNeuLaden(): Promise { mediatheken = await bibliotheken(); leisteBauen(); 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(bekannt?: Bibliothek[]): Promise { // **Denselben Stand zeigen, nicht denselben Takt haben.** // // Beide Anzeigen fragten getrennt nach — und zeigten damit zwei Momente: // in der Seitenleiste stand 95, in der Verwaltung 84. Während die // Metadaten laufen, legt der Server Doppelte zusammen, die Zahl fällt also // laufend. Wer hier eine Liste mitgibt, hat sie gerade selbst geholt; dann // wird sie benutzt statt eine zweite zu ziehen. const frisch = bekannt ?? await bibliotheken(); // **Nur die Zahlen, solange die Liste dieselbe ist.** // // `leisteBauen()` ersetzt die ganze Seitenleiste. Im Dreisekundentakt eines // laufenden Scans heisst das: sie verschwindet sichtbar und kommt wieder, // der Mauszeiger steht plötzlich über einem anderen Eintrag, und ein // gerade gezogener Eintrag wird mitten in der Bewegung weggerissen. const gleich = frisch.length === mediatheken.length && frisch.every((b, i) => b.id === mediatheken[i]!.id && b.name === mediatheken[i]!.name); mediatheken = frisch; if (!gleich) { leisteBauen(); return; } frisch.forEach(b => { const zeile = leiste.querySelector(`[data-ziel="#/m/${b.id}"] .zahl`); if (zeile) zeile.textContent = String(b.itemCount ?? ''); }); } /* ---------- 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'; // **Das eigene Bild führt zum eigenen Konto, nicht aus dem Server heraus.** // // Hier stand `abmelden()` — ein Klick, keine Rückfrage, kein Weg zurück, // hinter einer Geste, die überall sonst „mein Konto" heisst. Abmelden gibt // es weiterhin, auf der Profilseite und mit Nachfrage. const konto = document.createElement('button'); konto.className = 'konto'; const name = sitzung.wer?.userName ?? ''; const bild = bildAdresse(sitzung.wer?.avatarImage ?? null); if (bild) { konto.innerHTML = ``; // Kommt es nicht an, tritt der Buchstabe an seine Stelle. konto.querySelector('img')!.onerror = () => { konto.innerHTML = `${esc(name.slice(0, 1).toUpperCase())}`; }; } else { konto.innerHTML = `${esc(name.slice(0, 1).toUpperCase())}`; } konto.title = name; konto.setAttribute('aria-label', `${name} — Konto`); // **Ein kurzes Menü, kein Sprung.** Erst meldete der Klick ab — ohne // Rückfrage. Dann führte er auf die eigene Seite, und zum Abmelden musste // man erst dorthin. Beides sind zwei Punkte; ein Menü mit zwei Punkten ist // die ehrlichere Form als eine Tür, hinter der eine zweite liegt. konto.onclick = () => menueAnZiel(konto, [ { text: 'Einstellungen', icon: 'verwaltung', tun: () => { location.hash = '#/einstellungen/konto'; }, }, { text: 'Abmelden', icon: 'abmelden', gefaehrlich: true, tun: () => nachfragen({ titel: 'Abmelden', text: `Du meldest dich als „${name}“ von diesem Gerät ab. ` + 'Sehstände und Favoriten bleiben auf dem Server.', bestaetigen: 'Abmelden', tun: async () => { abmelden(); location.reload(); }, }), }, ]); rechts.appendChild(konto); kopfleiste.append(marke, suche, rechts); } /* ---------- Seitenleiste ---------- */ /** Ob die Seitenleiste gerade umsortiert wird. */ let leisteBearbeiten = false; function leisteBauen(): void { leiste.innerHTML = ''; eintrag('Start', '#/', 'start'); // **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', istAdmin() && mediatheken.length > 1); // **Ein eigener Behälter für die Mediatheken.** Beim Ziehen wird gerechnet, // die wievielte Zeile unter dem Zeiger liegt; stünden Überschriften und // „Start" dazwischen, ginge diese Rechnung schief. const liste = document.createElement('div'); liste.className = 'nav-liste'; leiste.appendChild(liste); mediatheken.forEach((b, i) => eintrag( b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount, i, liste)); } // **Für jeden, nicht nur für Administratoren.** Hier stand „Verwaltung", // und die sah nur, wer Rechte hat — mitsamt „Meine Sprache", die jedem // gehört. Was dem Server gehört, steht jetzt als eigene Gruppe *in* den // Einstellungen und bleibt Administratoren vorbehalten. // // **Unten, ohne Überschrift.** Eine Gruppenüberschrift über einem einzigen // Eintrag ist eine Überschrift über nichts; am Fuss der Leiste steht er // dort, wo man Einstellungen sucht. const fuss = document.createElement('div'); fuss.className = 'nav-fuss'; leiste.appendChild(fuss); eintrag('Einstellungen', '#/einstellungen', 'verwaltung', undefined, undefined, fuss); /** Eine Gruppenüberschrift — bei den Mediatheken mit dem Stift daneben. * * **Der Griff sitzt dort, wo die Reihenfolge wirkt.** Vorher standen die * Pfeile in den Einstellungen: man schob dort etwas und musste danach in * die Seitenleiste sehen, ob es das war, was man wollte. */ function gruppe(text: string, mitStift = false): void { const d = document.createElement('div'); d.className = 'nav-gruppe'; const t = document.createElement('span'); t.textContent = text; d.appendChild(t); if (mitStift) { const stift = document.createElement('button'); stift.className = 'nav-bearbeiten' + (leisteBearbeiten ? ' an' : ''); stift.innerHTML = leisteBearbeiten ? '' + icons.haken + '' : '' + icons.stift + ''; stift.title = leisteBearbeiten ? 'Fertig' : 'Seitenleiste bearbeiten'; stift.setAttribute('aria-label', stift.title); stift.onclick = () => { leisteBearbeiten = !leisteBearbeiten; leisteBauen(); }; d.appendChild(stift); } leiste.appendChild(d); } function eintrag( text: string, ziel: string, bild: keyof typeof icons, anzahl?: number | null, platz?: number, behaelter?: HTMLElement, ): void { const b = document.createElement('button'); b.dataset.ziel = ziel; b.innerHTML = `${icons[bild]}` + `${esc(text)}` + (anzahl != null && !leisteBearbeiten ? `${anzahl}` : ''); if (leisteBearbeiten && platz != null && behaelter) { b.classList.add('ziehbar'); // Der Griff links statt eines Knopfes rechts: er sagt „hier anfassen", // und er nimmt die Zeile nicht als Ganzes in Beschlag. const griff = document.createElement('span'); griff.className = 'nav-griff icon'; griff.innerHTML = icons.griff; b.prepend(griff); // **Die Tastatur bleibt bedienbar.** Ziehen geht damit nicht, und eine // Funktion, die nur mit der Maus erreichbar ist, ist für den, der keine // benutzt, gar keine. Sichtbare Pfeile braucht es dafür nicht. b.setAttribute('aria-roledescription', 'Verschiebbarer Eintrag'); b.title = `„${text}" ziehen — oder mit den Pfeiltasten verschieben`; b.onkeydown = e => { if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; e.preventDefault(); void schieben(platz, platz + (e.key === 'ArrowDown' ? 1 : -1)); }; ziehbarMachen(b, platz, behaelter); } else { b.onclick = () => { location.hash = ziel; }; } (behaelter ?? leiste).appendChild(b); } } /** Macht eine Zeile ziehbar. * * **Pointer Events statt HTML5-Drag.** Letzteres kennt kein Telefon, zeichnet * ein Geisterbild, das sich nicht gestalten lässt, und liefert während des * Ziehens keine brauchbaren Koordinaten. Pointer Events bedienen Maus, Finger * und Stift mit demselben Code. * * **Mit Schwelle.** Erst nach sechs Bildpunkten gilt es als Ziehen — sonst * verschiebt jeder leicht wackelige Klick die Reihenfolge. */ function ziehbarMachen(zeile: HTMLElement, platz: number, behaelter: HTMLElement): void { zeile.addEventListener('pointerdown', start); function start(e: PointerEvent): void { // Nur die linke Maustaste; rechts gehört dem Kontextmenü. if (e.button !== 0) return; e.preventDefault(); const zeilen = [...behaelter.querySelectorAll('.ziehbar')]; const hoehe = zeile.getBoundingClientRect().height + parseFloat(getComputedStyle(behaelter).rowGap || '0'); const startY = e.clientY; let zieht = false; let ziel = platz; zeile.setPointerCapture(e.pointerId); const bewegen = (m: PointerEvent) => { const weg = m.clientY - startY; if (!zieht) { if (Math.abs(weg) < 6) return; zieht = true; behaelter.classList.add('zieht'); zeile.classList.add('wird-gezogen'); } // Wohin die Zeile fiele, wenn man jetzt losliesse. ziel = Math.max(0, Math.min(zeilen.length - 1, platz + Math.round(weg / hoehe))); zeile.style.transform = `translateY(${weg}px)`; // Die anderen machen Platz — nach oben oder unten, je nachdem, auf // welcher Seite sie liegen. zeilen.forEach((andere, i) => { if (andere === zeile) return; let versatz = 0; if (platz < ziel && i > platz && i <= ziel) versatz = -hoehe; else if (platz > ziel && i >= ziel && i < platz) versatz = hoehe; andere.style.transform = versatz ? `translateY(${versatz}px)` : ''; }); }; const beenden = () => { zeile.removeEventListener('pointermove', bewegen); zeile.removeEventListener('pointerup', beenden); zeile.removeEventListener('pointercancel', beenden); behaelter.classList.remove('zieht'); zeilen.forEach(z => { z.style.transform = ''; z.classList.remove('wird-gezogen'); }); // Ohne Ziehen war es ein Klick — und der führt zur Mediathek, auch im // Bearbeitungsmodus. Sonst wäre die Leiste dort unbenutzbar. if (!zieht) { const weg = zeile.dataset.ziel; if (weg) location.hash = weg; return; } if (ziel !== platz) void schieben(platz, ziel); }; zeile.addEventListener('pointermove', bewegen); zeile.addEventListener('pointerup', beenden); zeile.addEventListener('pointercancel', beenden); } } /** Schiebt eine Mediathek und schickt die ganze neue Reihenfolge. * * **Die ganze Liste, nicht „eins hoch".** Zwei Benutzer, die gleichzeitig * schieben, kämen sonst in einen Zustand, den keiner von beiden wollte. */ async function schieben(von: number, nach: number): Promise { if (nach < 0 || nach >= mediatheken.length) return; const neu = [...mediatheken]; const [eintrag] = neu.splice(von, 1); neu.splice(nach, 0, eintrag!); // Sofort zeichnen, damit der Klick sich sofort anfühlt; der Server bekommt // dieselbe Liste und widerspricht beim nächsten Laden, falls doch etwas // schiefging. mediatheken = neu; leisteBauen(); try { await reihenfolgeSetzen(neu.map(b => b.id)); } catch { await zaehlerAuffrischen(); } } /* ---------- Router ---------- */ function zeichnen(): void { const weg = location.hash.replace(/^#\/?/, ''); const teile = weg.split('/').filter(Boolean); // **Zwei Segmente, sonst eines.** Eine Mediathek heisst `#/m/{id}` und // braucht beide; die Verwaltung heisst `#/verwaltung` und hat seit der // zweiten Leiste einen Bereich dahinter. Verglich man stur zwei Segmente, // verlor „Verwaltung" seine Markierung, sobald man darin etwas anklickte. const zweiTeilig = `#/${teile.slice(0, 2).join('/')}`; const einTeilig = `#/${teile.slice(0, 1).join('/')}`; leiste.querySelectorAll('button[data-ziel]').forEach(b => { const ziel = (b as HTMLElement).dataset.ziel!; b.classList.toggle('check', ziel === zweiTeilig || ziel === einTeilig); }); // **Aushängen, nicht leeren.** `innerHTML = ''` zerstörte die Ansicht — // mitsamt Bildlauf, offenem Reiter und gesetzten Filtern. ablegen(); inhalt.innerHTML = ''; const oeffnen = (art: 'filme' | 'serien', id: string) => { location.hash = `#/${art}/${id}`; }; const alleZeigen = (b: Bibliothek) => { location.hash = `#/m/${b.id}`; }; const art = teile[0] === 'filme' || teile[0] === 'serien' ? teile[0] : null; const kennung = teile[1]; if (art && kennung) { // **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) { // Das vierte Segment ist das Genre: `#/m/{id}/mediathek/Horror`. Es // gehört zum Weg und damit auch zum gemerkten Aufbau — zwei Genres // sind zwei Ansichten, nicht eine mit wechselndem Inhalt. einhaengen(weg, inhalt, () => mediathek(b, serverName, teile[2] ?? 'mediathek', oeffnen, teile[3] ? decodeURIComponent(teile[3]) : ''), { merken: true }); } else location.hash = '#/'; } else if (teile[0] === 'suche' && kennung) { einhaengen(weg, inhalt, () => suchseite(decodeURIComponent(kennung), oeffnen), { merken: true }); } else if (teile[0] === 'profil' || teile[0] === 'verwaltung') { // **Die alten Adressen bleiben gültig.** Beides ist in „Einstellungen" // aufgegangen; ein Lesezeichen soll davon nichts merken. location.hash = teile[0] === 'profil' ? '#/einstellungen/konto' : `#/einstellungen/${teile[1] ?? 'uebersicht'}`; return; } else if (teile[0] === 'einstellungen') { // **Nicht gemerkt** — sie zeigt Laufendes. Ihr Takt hängt am Signal und // hört auf, sobald man weggeht. einhaengen(weg, inhalt, signal => einstellungenseite(alsBereich(teile[1], istAdmin()), bekannt => { // Gemerkte Ansichten wegwerfen: der Bestand hat sich geändert. // Die Seitenleiste bekommt neue Zahlen — die Seite selbst bleibt. vergessen(); // `bekannt` ist der Stand, den die Verwaltung gerade geholt hat. Ihn // weiterzureichen heisst: beide zeigen dieselbe Zahl, nicht nur // ähnliche. void zaehlerAuffrischen(bekannt); }, signal)); } else { einhaengen(weg, inhalt, signal => startseite(mediatheken, oeffnen, alleZeigen, signal), { merken: true }); } inhalt.appendChild(hinweisleiste()); } /** Der Pflichthinweis auf TMDB — Bedingung ihrer Lizenz, nicht Höflichkeit. */ function hinweisleiste(): HTMLElement { const d = document.createElement('div'); d.className = 'attribution'; hinweise.forEach(h => { const a = document.createElement('a'); a.href = h.url; a.target = '_blank'; a.rel = 'noreferrer'; a.innerHTML = `TMDB`; d.append(a, document.createTextNode(h.text)); }); return d; }