diff --git a/web/app/index.html b/web/app/index.html new file mode 100644 index 0000000..d78fbda --- /dev/null +++ b/web/app/index.html @@ -0,0 +1,16 @@ + + + + + +Flixy + + +
+ + + + diff --git a/web/app/src/contrast.ts b/web/app/src/contrast.ts new file mode 100644 index 0000000..acf0775 --- /dev/null +++ b/web/app/src/contrast.ts @@ -0,0 +1,97 @@ +/* Kontrastrechnung nach WCAG 2.1 — dieselbe wie in der App. + ---------------------------------------------------------------------------- + Flixy lässt jede Farbe zu, aber ein dunkles Marineblau auf fast schwarzem + Grund ist als Schrift unlesbar. Solche Farben werden angehoben, bis sie den + geforderten Kontrast erreichen — und es wird gesagt, statt die Farbe + stillschweigend zu verändern. + + Zuerst die Helligkeit, weil das den Farbton erhält: eine gewählte + Akzentfarbe soll erkennbar dieselbe bleiben. Reicht selbst volle Helligkeit + nicht, wird die Sättigung zurückgenommen, und die Farbe wandert Richtung + Weiß. */ + +export type RGB = [number, number, number]; + +const MINDESTENS = 4.5; + +export function leuchtdichte([r, g, b]: RGB): number { + // Der Knick bei 0,03928 ist kein Rundungsfehler, sondern Teil der + // sRGB-Definition: unterhalb davon ist die Kurve linear. + const linear = (c: number): number => + c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); +} + +export function verhaeltnis(a: RGB, b: RGB): number { + const la = leuchtdichte(a); + const lb = leuchtdichte(b); + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); +} + +export function lesbarMachen(farbe: RGB, grund: RGB): { farbe: RGB; geaendert: boolean } { + if (verhaeltnis(farbe, grund) >= MINDESTENS) return { farbe, geaendert: false }; + + const [h, s, v] = zuHSV(farbe); + // Beide Schritte sind monoton — mehr Helligkeit und weniger Sättigung + // erhöhen die Leuchtdichte immer. Deshalb genügt eine binäre Suche, und + // sie endet garantiert. + const suche = (tief: number, hoch: number, bauen: (x: number) => RGB): RGB | null => { + if (verhaeltnis(bauen(hoch), grund) < MINDESTENS) return null; + let u = tief; + let o = hoch; + for (let i = 0; i < 24; i++) { + const mitte = (u + o) / 2; + if (verhaeltnis(bauen(mitte), grund) >= MINDESTENS) o = mitte; + else u = mitte; + } + return bauen(o); + }; + + const heller = suche(v, 1, (x) => ausHSV(h, s, x)); + if (heller) return { farbe: heller, geaendert: true }; + + const blasser = suche(s, 0, (x) => ausHSV(h, x, 1)); + return { farbe: blasser ?? [1, 1, 1], geaendert: true }; +} + +function zuHSV([r, g, b]: RGB): RGB { + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const d = max - min; + let h = 0; + if (d) { + if (max === r) h = ((g - b) / d) % 6; + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h /= 6; + if (h < 0) h += 1; + } + return [h, max ? d / max : 0, max]; +} + +function ausHSV(h: number, s: number, v: number): RGB { + const i = Math.floor(h * 6); + const f = h * 6 - i; + const p = v * (1 - s); + const q = v * (1 - f * s); + const t = v * (1 - (1 - f) * s); + const saetze: RGB[] = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]]; + return saetze[i % 6]!; +} + +export function ausHex(hex: string): RGB | null { + const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); + if (!m) return null; + const n = parseInt(m[1]!, 16); + return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; +} + +export function alsHex([r, g, b]: RGB): string { + return ( + '#' + + [r, g, b] + .map((c) => Math.round(Math.min(Math.max(c, 0), 1) * 255).toString(16).padStart(2, '0')) + .join('') + .toUpperCase() + ); +} diff --git a/web/app/src/icons.ts b/web/app/src/icons.ts index 6267586..be3e02d 100644 --- a/web/app/src/icons.ts +++ b/web/app/src/icons.ts @@ -25,6 +25,11 @@ const ICONS: Record = { captions: '', captionsOn: '', check: '', + // Rechteck mit Balken oben bzw. Spalte links — die Leiste zieht dorthin. + barTop: '' + + '', + barSide: '' + + '', pause: '', close: '', sound: '', diff --git a/web/app/src/main.ts b/web/app/src/main.ts new file mode 100644 index 0000000..ffbe4c1 --- /dev/null +++ b/web/app/src/main.ts @@ -0,0 +1,193 @@ +import './style.css'; +import type { Item, Seite } from './types'; +import { api, sitzung, angemeldet, abmelden } from './api'; +import { P, anwenden, setzen } from './prefs'; +import { icon } from './icons'; +import { esc, FELDER } from './util'; +import { anmeldung } from './views/login'; +import { startseite } from './views/home'; +import { mediathek } from './views/library'; +import { einstellungen } from './views/settings'; +import { blatt } from './views/cards'; +import { playerOeffnen } from './views/player'; + +const app = document.getElementById('app')!; +const inhalt = document.getElementById('content')!; +const insel = document.getElementById('island')!; +const chrome = document.getElementById('chrome')!; + +let ansichten: Item[] = []; +let wo = 'home'; + +/* ---------- Navigation ---------- */ + +function inselBauen(): void { + insel.innerHTML = ''; + + // Der Umschalter zuerst, wie auf dem iPad: an der Seite oben in der Ecke, + // oben ganz links vor den Einträgen. + const nav = document.createElement('button'); + nav.className = 'toggle-nav'; + nav.title = P.nav === 'side' ? 'Leiste nach oben' : 'Leiste an die Seite'; + nav.setAttribute('aria-label', nav.title); + nav.innerHTML = icon(P.nav === 'side' ? 'barTop' : 'barSide', 18); + nav.onclick = () => leisteUmziehen(P.nav === 'side' ? 'top' : 'side'); + insel.appendChild(nav); + + const stueck = (klasse: string, text?: string): void => { + const d = document.createElement('div'); + d.className = klasse; + if (text) d.textContent = text; + insel.appendChild(d); + }; + const eintrag = (text: string, ziel: string): void => { + const b = document.createElement('button'); + b.textContent = text; + b.dataset.key = ziel; + b.onclick = () => gehe(ziel); + insel.appendChild(b); + }; + + stueck('brand', 'Flixy'); + eintrag('Start', 'home'); + eintrag('Einstellungen', 'settings'); + + if (ansichten.length) { + stueck('sep'); + stueck('group-label', 'Mediatheken'); + for (const v of ansichten) eintrag(v.Name ?? '—', 'lib:' + v.Id); + } + stueck('sep'); + eintrag('Abmelden', 'out'); + + [...insel.querySelectorAll('button[data-key]')].forEach((b) => + b.classList.toggle('active', (b as HTMLElement).dataset.key === wo)); +} + +/* Lässt die Leiste von ihrer alten Lage in die neue fahren. + ---------------------------------------------------------------------------- + **Zwei Züge, nicht einer.** Ein reiner Weg von A nach B sieht aus, als würde + ein Kasten verschoben. Die Leiste zieht sich erst *in sich* zusammen und + zieht dann erst um — wie ein Gummiband, das sich sammelt, bevor es schnellt. + + Gemacht mit FLIP: alte Lage messen, umbauen, neue Lage messen, und vom alten + Zustand in den neuen fahren. Über CSS allein ginge das nicht — `right: auto` + und `bottom: auto` lassen sich nicht animieren, und genau die wechseln. */ +function leisteUmziehen(nach: 'top' | 'side'): void { + const vorher = insel.getBoundingClientRect(); + const sparsam = matchMedia('(prefers-reduced-motion: reduce)').matches; + + setzen('nav', nach); + inselBauen(); + + if (sparsam) return; + const nachher = insel.getBoundingClientRect(); + if (!vorher.width || !nachher.width) return; + + const dx = vorher.left - nachher.left; + const dy = vorher.top - nachher.top; + const sx = vorher.width / nachher.width; + const sy = vorher.height / nachher.height; + + // Welche Richtung sich zuerst sammelt, hängt davon ab, wohin es geht: Wer + // nach oben zieht, wird flach — also klappt zuerst die Höhe zusammen. + const gesammelt = nach === 'top' + ? `translate(${dx}px, ${dy}px) scale(${sx}, 1)` + : `translate(${dx}px, ${dy}px) scale(1, ${sy})`; + + insel.animate( + [ + { transformOrigin: 'top left', transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`, + easing: 'cubic-bezier(.5,0,.75,0)', offset: 0 }, + { transformOrigin: 'top left', transform: gesammelt, + easing: 'cubic-bezier(.22,1,.36,1)', offset: 0.42 }, + { transformOrigin: 'top left', transform: 'none', offset: 1 }, + ], + { duration: 520 }, + ); + insel.animate([{ opacity: 0.35 }, { opacity: 0.5, offset: 0.42 }, { opacity: 1 }], + { duration: 420, easing: 'ease-out' }); +} + +function gehe(ziel: string): void { + if (ziel === 'out') return abmelden(); + wo = ziel; + [...insel.querySelectorAll('button[data-key]')].forEach((b) => + b.classList.toggle('active', (b as HTMLElement).dataset.key === ziel)); + scrollTo({ top: 0 }); + inhalt.classList.remove('pad-top'); + + if (ziel === 'home') void startseite(inhalt, ansichten, tippen); + else if (ziel === 'settings') einstellungen(inhalt, () => gehe('home')); + else void mediathek(inhalt, ziel.slice(4), tippen); +} + +/* ---------- Was beim Antippen geschieht ---------- */ + +function tippen(item: Item): void { + blatt(item, starten); +} + +/** Startet — und bei einer Serie die Folge, die als Nächstes dran ist. + * + * Eine Serie gar nicht abzuspielen war die falsche Antwort: die + * offensichtliche Erwartung ist, dass es losgeht. */ +async function starten(item: Item): Promise { + const ordner = ['Series', 'Season'].includes(item.Type ?? ''); + const ziel = ordner ? await naechsteFolge(item) : item; + if (!ziel) return; + playerOeffnen(ziel, () => { if (wo === 'home') void startseite(inhalt, ansichten, tippen); }); +} + +async function naechsteFolge(item: Item): Promise { + const serie = item.Type === 'Season' ? (item.SeriesId ?? item.Id) : item.Id; + try { + // Erst „Als Nächstes": wer eine Serie anfängt und zurückkommt, meint die + // Folge, bei der er stehengeblieben ist — nicht die erste. + const weiter = await api('/Shows/NextUp?' + new URLSearchParams({ + userId: sitzung.userId, seriesId: serie, Limit: '1', Fields: FELDER, + })); + const folge = weiter.Items?.[0]; + if (folge && (item.Type !== 'Season' || folge.SeasonId === item.Id)) return folge; + } catch { /* dann eben die erste */ } + + try { + const q: Record = { userId: sitzung.userId, Limit: '1', Fields: FELDER }; + if (item.Type === 'Season') q.seasonId = item.Id; + const alle = await api(`/Shows/${serie}/Episodes?` + new URLSearchParams(q)); + return alle.Items?.[0] ?? null; + } catch { + return null; + } +} + +/* ---------- Los ---------- */ + +async function starte(): Promise { + document.getElementById('login')!.classList.add('hidden'); + app.classList.remove('hidden'); + anwenden(); + inhalt.innerHTML = '
'; + + try { + const r = await api(`/Users/${sitzung.userId}/Views`); + ansichten = (r.Items ?? []).filter((v) => ['movies', 'tvshows'].includes(v.CollectionType ?? '')); + inselBauen(); + gehe('home'); + } catch (e) { + inhalt.innerHTML = `
${esc((e as Error).message)}

+
`; + document.getElementById('raus')!.onclick = abmelden; + } +} + +// Die Insel oben fährt beim Scrollen weg und kommt zurück, sobald man wieder +// oben steht. An der Seite bleibt sie stehen — dort nimmt sie niemandem etwas. +addEventListener('scroll', () => { + if (P.nav === 'side') return; + chrome.classList.toggle('away', scrollY > 8); +}, { passive: true }); + +anwenden(); +if (angemeldet()) void starte(); +else anmeldung(document.getElementById('login')!, () => void starte()); diff --git a/web/app/src/views/cards.ts b/web/app/src/views/cards.ts new file mode 100644 index 0000000..84264be --- /dev/null +++ b/web/app/src/views/cards.ts @@ -0,0 +1,60 @@ +import type { Item } from '../types'; +import { esc, bild, laufzeit } from '../util'; + +/** Eine Kachel. Querformat für Angefangenes, hochkant für alles andere — + * dieselbe Entscheidung wie in der App, und sie ist keine Einstellung: + * Angefangenes erkennt man am Szenenbild wieder, Neues sucht man nach + * Plakaten aus. */ +export function kachel(item: Item, quer: boolean, beimTippen: (item: Item) => void): HTMLElement { + const b = document.createElement('button'); + b.className = 'card' + (quer ? ' wide' : ''); + const anteil = item.UserData?.PlayedPercentage; + const art = bild(item, quer ? 'Backdrop' : 'Primary', quer ? 500 : 300); + + b.innerHTML = ` +
+ ${anteil ? `
` : ''} +
+
${esc(item.SeriesName || item.Name)}
+
${esc(unterzeile(item))}
`; + b.onclick = () => beimTippen(item); + return b; +} + +function unterzeile(item: Item): string { + if (item.Type === 'Episode' && item.ParentIndexNumber != null) { + return `S${item.ParentIndexNumber}:E${item.IndexNumber} · ${item.Name}`; + } + return String(item.ProductionYear ?? ''); +} + +/** Das Blatt zu einem Titel. */ +export function blatt(item: Item, starten: (item: Item) => void): void { + const hinten = document.createElement('div'); + hinten.id = 'sheet'; + + // Serien und Staffeln sind Ordner — abgespielt wird trotzdem, nur eben + // nicht der Ordner, sondern die Folge, die als Nächstes dran ist. + const ordner = ['Series', 'Season'].includes(item.Type ?? ''); + const spielbar = ordner || !['BoxSet', 'Folder', 'CollectionFolder'].includes(item.Type ?? ''); + const teile = [item.ProductionYear, laufzeit(item), item.OfficialRating].filter(Boolean); + + hinten.innerHTML = ` +
+

${esc(item.SeriesName ? item.SeriesName + ' — ' + item.Name : item.Name)}

+
${teile.join(' · ')}
+

${esc(item.Overview ?? '')}

+
+ ${spielbar ? '' : ''} + +
+
`; + hinten.onclick = (e) => { if (e.target === hinten) hinten.remove(); }; + document.body.appendChild(hinten); + hinten.querySelector('#sheet-close')!.onclick = () => hinten.remove(); + + if (!spielbar) return; + const knopf = hinten.querySelector('#sheet-play')!; + knopf.textContent = ordner ? '▶ Weiterschauen' : '▶ Abspielen'; + knopf.onclick = () => { starten(item); hinten.remove(); }; +} diff --git a/web/app/src/views/home.ts b/web/app/src/views/home.ts new file mode 100644 index 0000000..7f9b807 --- /dev/null +++ b/web/app/src/views/home.ts @@ -0,0 +1,207 @@ +import type { Item, Reihe, Seite } from '../types'; +import { api, sitzung } from '../api'; +import { P, anordnen } from '../prefs'; +import { icon } from '../icons'; +import { esc, bild, laufzeit, hatBild, FELDER } from '../util'; +import { kachel } from './cards'; + +/** Welche Reihen es bei diesem Server gibt — für die Einstellungen. */ +export let bekannteReihen: { id: string; title: string }[] = []; + +export async function startseite( + wurzel: HTMLElement, + ansichten: Item[], + oeffnen: (item: Item) => void, +): Promise { + wurzel.innerHTML = '
'; + + const hole = async (pfad: string, q: Record = {}): Promise => { + try { + const r = await api( + pfad + '?' + new URLSearchParams({ Fields: FELDER, Limit: '20', ...q }), + ); + // „Latest" liefert ein blankes Feld, alles andere eine Seite mit Items. + return Array.isArray(r) ? r : (r.Items ?? []); + } catch { + return []; + } + }; + + const [weiter, naechstes, ...neues] = await Promise.all([ + hole(`/Users/${sitzung.userId}/Items/Resume`, { MediaTypes: 'Video' }), + hole('/Shows/NextUp', { userId: sitzung.userId }), + ...ansichten.map((v) => hole(`/Users/${sitzung.userId}/Items/Latest`, { ParentId: v.Id })), + ]); + + let reihen: Reihe[]; + if (P.combineContinue) { + const gesehen = new Set(); + const zusammen = [...weiter!, ...naechstes!].filter((i) => !gesehen.has(i.Id) && gesehen.add(i.Id)); + // Die zusammengelegte Reihe behält die Kennung der ersten — sonst wäre sie + // für die gespeicherte Reihenfolge eine neue und stünde wieder unten. + reihen = [{ id: 'resume', title: 'Weiterschauen', items: zusammen, wide: true }]; + } else { + reihen = [ + { id: 'resume', title: 'Weiterschauen', items: weiter!, wide: true }, + { id: 'nextup', title: 'Als Nächstes', items: naechstes!, wide: true }, + ]; + } + + reihen.push(...ansichten.map((v, i) => ({ + id: 'latest.' + v.Id, + title: `Neu in ${v.Name}`, + // Gesehenes ausblenden gilt nur für Neuzugänge: in „Weiterschauen" wäre + // es sinnlos, dort ist alles angefangen. + items: (neues[i] ?? []).filter((it) => !P.hideWatched || !it.UserData?.Played), + }))); + reihen = reihen.filter((r) => r.items.length); + + // **Vor** dem Ausblenden merken: die Einstellungen müssen auch die Reihen + // anbieten, die gerade versteckt sind. + bekannteReihen = reihen.map((r) => ({ id: r.id, title: r.title })); + reihen = anordnen(reihen); + + const auswahl = P.heroSource === 'latestOnly' + ? neues.flat() + : [...weiter!, ...naechstes!, ...neues.flat()]; + const oben = aufmacherWahl(auswahl); + + wurzel.innerHTML = ''; + wurzel.classList.toggle('pad-top', oben.length === 0); + if (oben.length) wurzel.appendChild(aufmacher(oben, oeffnen)); + for (const r of reihen) wurzel.appendChild(reihe(r, oeffnen)); + + if (!reihen.length && !oben.length) { + wurzel.innerHTML = '
Hier ist noch nichts.
'; + } +} + +/* Wählt aus, was oben groß steht — dieselbe Regel wie in der App. + ---------------------------------------------------------------------------- + Die Auswahl wechselt alle sechs Stunden, und ein Titel ohne Hintergrundbild + zieht ein schlechteres Los. Bei jedem Laden zu würfeln wäre unruhig — man + tippt auf etwas, geht zurück, und es ist weg. Einmal täglich wäre zu träge. */ +function aufmacherWahl(items: Item[]): Item[] { + let keim = (Math.floor(Date.now() / (6 * 3600 * 1000)) * 2654435761) % 2147483647; + const wuerfeln = (): number => (keim = (keim * 16807) % 2147483647) / 2147483647; + + const gesehen = new Set(); + return items + .filter((it) => !gesehen.has(it.Id) && gesehen.add(it.Id)) + // Wer ein Hintergrundbild hat, zieht aus [0,1) — wer keines hat, nur aus + // [0,0.2). Solange es genug mit Bild gibt, kommt er nicht nach vorn; gibt + // es wenige, hat er seine Chance. + .map((it) => ({ it, los: wuerfeln() * (hatBild(it, 'Backdrop') ? 1 : 0.2) })) + .sort((a, b) => b.los - a.los) + .slice(0, P.heroCount) + .map((x) => x.it); +} + +function aufmacher(items: Item[], oeffnen: (item: Item) => void): HTMLElement { + const aussen = document.createElement('div'); + const rahmen = document.createElement('section'); + rahmen.id = 'hero'; + + const bilder = items.map((it, i) => { + const d = document.createElement('div'); + d.className = 'shot' + (i === 0 ? ' on' : ''); + d.style.backgroundImage = `url("${bild(it, 'Backdrop', 1600)}")`; + return d; + }); + bilder.forEach((b) => rahmen.appendChild(b)); + rahmen.insertAdjacentHTML('beforeend', '
'); + + const koerper = document.createElement('div'); + koerper.className = 'body'; + rahmen.appendChild(koerper); + + const punkte = document.createElement('div'); + punkte.className = 'dots'; + + let bei = 0; + let uhr: ReturnType | null = null; + const anhalten = (): void => { if (uhr) { clearInterval(uhr); uhr = null; } }; + const schritt = (um: number): void => { + anhalten(); + bei = (bei + um + items.length) % items.length; + malen(); + }; + + items.forEach((_, i) => { + const d = document.createElement('button'); + d.className = i ? '' : 'on'; + d.setAttribute('aria-label', `Titel ${i + 1}`); + d.onclick = () => { anhalten(); bei = i; malen(); }; + punkte.appendChild(d); + }); + + function malen(): void { + const it = items[bei]!; + const teile = [it.ProductionYear, laufzeit(it), it.OfficialRating].filter(Boolean); + const zeichen = it.ImageTags?.Logo ? bild(it, 'Primary', 600) : ''; + koerper.innerHTML = ` + ${zeichen && it.ImageTags?.Logo ? `` + : `

${esc(it.Name)}

`} +
${teile.join(' · ')}
+
${esc(it.Overview ?? '')}
+
+ + +
`; + const knoepfe = koerper.querySelectorAll('.btn'); + (knoepfe[0] as HTMLElement).onclick = () => oeffnen(it); + (knoepfe[1] as HTMLElement).onclick = () => oeffnen(it); + bilder.forEach((b, i) => b.classList.toggle('on', i === bei)); + [...punkte.children].forEach((d, i) => d.classList.toggle('on', i === bei)); + } + malen(); + + if (items.length > 1) { + const zurueck = document.createElement('button'); + zurueck.className = 'hero-nav prev'; + zurueck.innerHTML = icon('left', 22); + zurueck.setAttribute('aria-label', 'Vorheriger Titel'); + zurueck.onclick = () => schritt(-1); + + const vor = document.createElement('button'); + vor.className = 'hero-nav next'; + vor.innerHTML = icon('right', 22); + vor.setAttribute('aria-label', 'Nächster Titel'); + vor.onclick = () => schritt(1); + rahmen.append(zurueck, vor); + + // Wischen — erst ab einem ordentlichen Weg und nur, wenn es waagerecht + // gemeint ist. Sonst nimmt die Geste dem Scrollen den Vortritt. + let x0: number | null = null; + let y0 = 0; + rahmen.addEventListener('touchstart', (e) => { + x0 = e.touches[0]!.clientX; y0 = e.touches[0]!.clientY; + }, { passive: true }); + rahmen.addEventListener('touchend', (e) => { + if (x0 == null) return; + const dx = e.changedTouches[0]!.clientX - x0; + const dy = e.changedTouches[0]!.clientY - y0; + if (Math.abs(dx) > 48 && Math.abs(dx) > Math.abs(dy)) schritt(dx < 0 ? 1 : -1); + x0 = null; + }, { passive: true }); + + // Sieben Sekunden — lang genug, um den Satz zu Ende zu lesen. + uhr = setInterval(() => { + if (!document.body.contains(rahmen)) return anhalten(); + bei = (bei + 1) % items.length; + malen(); + }, 7000); + } + + aussen.append(rahmen, punkte); + return aussen; +} + +function reihe(r: Reihe, oeffnen: (item: Item) => void): HTMLElement { + const abschnitt = document.createElement('section'); + abschnitt.className = 'row'; + abschnitt.innerHTML = `

${esc(r.title)}

`; + const streifen = abschnitt.querySelector('.strip')!; + for (const it of r.items) streifen.appendChild(kachel(it, Boolean(r.wide), oeffnen)); + return abschnitt; +} diff --git a/web/app/src/views/library.ts b/web/app/src/views/library.ts new file mode 100644 index 0000000..6f9e46c --- /dev/null +++ b/web/app/src/views/library.ts @@ -0,0 +1,28 @@ +import type { Item, Seite } from '../types'; +import { api, sitzung } from '../api'; +import { esc, FELDER } from '../util'; +import { kachel } from './cards'; + +export async function mediathek( + wurzel: HTMLElement, + id: string, + oeffnen: (item: Item) => void, +): Promise { + wurzel.classList.add('pad-top'); + wurzel.innerHTML = '
'; + + try { + const r = await api( + `/Users/${sitzung.userId}/Items?` + new URLSearchParams({ + ParentId: id, Recursive: 'true', IncludeItemTypes: 'Movie,Series', + SortBy: 'SortName', SortOrder: 'Ascending', Limit: '200', Fields: FELDER, + }), + ); + const items = r.Items ?? []; + wurzel.innerHTML = items.length ? '
' : '
Leer.
'; + const raster = wurzel.querySelector('.grid'); + if (raster) for (const it of items) raster.appendChild(kachel(it, false, oeffnen)); + } catch (e) { + wurzel.innerHTML = `
${esc((e as Error).message)}
`; + } +} diff --git a/web/app/src/views/login.ts b/web/app/src/views/login.ts new file mode 100644 index 0000000..7af63b0 --- /dev/null +++ b/web/app/src/views/login.ts @@ -0,0 +1,50 @@ +import { anmelden } from '../api'; +import { esc } from '../util'; + +export function anmeldung(wurzel: HTMLElement, fertig: () => void): void { + wurzel.innerHTML = ` + `; + + const form = wurzel.querySelector('#login-form')!; + const knopf = wurzel.querySelector('#login-btn')!; + const fehler = wurzel.querySelector('#login-error')!; + + form.onsubmit = async (e) => { + e.preventDefault(); + knopf.disabled = true; + knopf.textContent = 'Verbinde …'; + fehler.textContent = ''; + try { + await anmelden( + wurzel.querySelector('#user')!.value, + wurzel.querySelector('#pass')!.value, + ); + fertig(); + } catch (e2) { + // Die Serveradresse steht nicht mehr hier, sondern beim Server davor — + // deshalb kann sie auch nicht mehr falsch eingetippt sein. Bleibt die + // Anmeldung selbst, oder ein Server, der nicht läuft. + fehler.textContent = 'Anmeldung fehlgeschlagen: ' + esc((e2 as Error).message); + } finally { + knopf.disabled = false; + knopf.textContent = 'Anmelden'; + } + }; +} diff --git a/web/app/src/views/player.ts b/web/app/src/views/player.ts new file mode 100644 index 0000000..d550e8c --- /dev/null +++ b/web/app/src/views/player.ts @@ -0,0 +1,467 @@ +import type HlsTyp from 'hls.js'; +import type { Item, Quelle, Spur } from '../types'; +import { api, medienURL, sitzung, wiedergabeauskunft } from '../api'; +import { P, QUALITIES, RATES } from '../prefs'; +import { icon } from '../icons'; +import { esc, folgentitel, uhr } from '../util'; + +/* Der Player. + ---------------------------------------------------------------------------- + Vollbild und eigene Bedienung, nach dem Bauplan der iPad-App: Titel und die + Wahl oben, Transport in der Mitte, unten **nur** der Regler mit den beiden + Zeiten. Und keine Knopfflächen — dort sind es blanke weiße Symbole, der + einzige Hintergrund ist die Abdunklung über dem ganzen Bild. */ + +interface Lauf { + quality: number; + audio: number | null; + subtitle: number | null; + streams: Spur[]; + stelle: number; + sitzung?: Sitzungsmeldung; +} + +interface Sitzungsmeldung { + id?: string; + quelleID: string; + itemID: string; + stelle: number; + melden: (weg: string, stelle?: number, extra?: Record) => void; +} + +export function playerOeffnen(item: Item, beimSchliessen?: () => void): void { + document.getElementById('player')?.remove(); + + const ebene = document.createElement('div'); + ebene.id = 'player'; + ebene.innerHTML = ` + +
+
+ +
+ ${esc(item.SeriesName || item.Name)} + ${item.SeriesName ? `${esc(folgentitel(item))}` : ''} +
+ + + + + + + +
+
+ + + +
+
+
+
0:00–:--
+
+
+ +
`; + document.body.appendChild(ebene); + document.body.style.overflow = 'hidden'; + + const v = ebene.querySelector('video')!; + const chrome = ebene.querySelector('.pl-chrome')!; + const el = (id: string): T => ebene.querySelector('#' + id) as T; + + const lauf: Lauf = { + quality: P.quality, audio: null, subtitle: null, streams: [], stelle: 0, + }; + + /* ---------- Fehler sichtbar machen ---------- */ + let hls: HlsTyp | null = null; + + const scheitern = (satz: string, hinweis?: string, adresse?: string): void => { + ebene.classList.remove('loading'); + el('pl-busy').classList.remove('on'); + ebene.querySelector('.pl-error')?.remove(); + const kasten = document.createElement('div'); + kasten.className = 'pl-error'; + kasten.innerHTML = `${esc(satz)}${hinweis ? `${esc(hinweis)}` : ''} + ${adresse ? `${esc(adresse.replace(/api_key=[^&]*/, 'api_key=…'))}` : ''} + `; + kasten.querySelector('button')!.onclick = schliessen; + ebene.appendChild(kasten); + }; + + const diagnose = { text: '', url: '' }; + v.addEventListener('error', () => { + const c = v.error?.code; + scheitern('Der Browser kann diesen Strom nicht abspielen.', + (c === 4 ? 'Das Format wird nicht unterstützt. ' : `Fehlercode ${c ?? '?'}. `) + diagnose.text, + diagnose.url); + }); + + /* ---------- Bedienung ein- und ausblenden ---------- */ + let ausblenden: ReturnType; + const zeigen = (): void => { + chrome.classList.add('on'); + clearTimeout(ausblenden); + // Solange etwas steht, bleibt die Bedienung: sie auszublenden, während + // niemand spielt, versteckt den einzigen Knopf, der weiterhilft. + if (!v.paused) ausblenden = setTimeout(() => chrome.classList.remove('on'), 3000); + }; + ebene.addEventListener('mousemove', zeigen); + ebene.addEventListener('touchstart', zeigen, { passive: true }); + + /* ---------- Transport ---------- */ + const umschalten = (): void => { void (v.paused ? v.play() : v.pause()); }; + el('pl-toggle').onclick = umschalten; + el('pl-back').onclick = () => { v.currentTime = Math.max(0, v.currentTime - P.skipBackward); zeigen(); }; + el('pl-fwd').onclick = () => { v.currentTime = Math.min(v.duration || 1e9, v.currentTime + P.skipForward); zeigen(); }; + v.addEventListener('click', umschalten); + + const malen = (): void => { + el('pl-toggle').innerHTML = icon(v.paused ? 'play' : 'pause', 52); + el('pl-toggle').setAttribute('aria-label', v.paused ? 'Abspielen' : 'Anhalten'); + zeigen(); + }; + v.addEventListener('play', malen); + v.addEventListener('pause', malen); + v.addEventListener('waiting', () => el('pl-busy').classList.add('on')); + v.addEventListener('playing', () => { + el('pl-busy').classList.remove('on'); + ebene.classList.remove('loading'); + }); + + /* ---------- Fortschrittsleiste ---------- */ + const spur = el('pl-track'); + v.addEventListener('timeupdate', () => { + const teil = v.duration ? v.currentTime / v.duration : 0; + spur.querySelector('.pl-fill')!.style.width = teil * 100 + '%'; + spur.querySelector('i')!.style.left = teil * 100 + '%'; + el('pl-now').textContent = uhr(v.currentTime); + // Restzeit statt Gesamtlänge: „noch 42 Minuten" beantwortet die Frage, + // die man sich beim Schauen tatsächlich stellt. + el('pl-left').textContent = '−' + uhr((v.duration || 0) - v.currentTime); + }); + v.addEventListener('progress', () => { + if (!v.buffered.length || !v.duration) return; + spur.querySelector('.pl-buf')!.style.width = + (v.buffered.end(v.buffered.length - 1) / v.duration) * 100 + '%'; + }); + + const springen = (e: MouseEvent | TouchEvent): void => { + const r = spur.getBoundingClientRect(); + const x = ((e instanceof MouseEvent ? e.clientX : e.touches[0]!.clientX) - r.left) / r.width; + if (v.duration) v.currentTime = Math.min(Math.max(x, 0), 1) * v.duration; + zeigen(); + }; + spur.onclick = springen; + spur.onmousedown = (e) => { + springen(e); + const ziehen = (ev: MouseEvent): void => springen(ev); + const los = (): void => { + removeEventListener('mousemove', ziehen); + removeEventListener('mouseup', los); + }; + addEventListener('mousemove', ziehen); + addEventListener('mouseup', los); + }; + + /* ---------- Die drei Listen ---------- + Getrennt, weil es drei verschiedene Fragen sind: die Tonspur stellt man + einmal am Anfang um, die Untertitel mitten im Film, die Qualität, wenn es + hakt. Eine gemeinsame Liste hieße jedes Mal an den anderen vorbeisuchen. */ + const menue = el('pl-menu'); + const menueZu = (): void => { menue.hidden = true; }; + const spuren = (art: Spur['Type']): Spur[] => lauf.streams.filter((x) => x.Type === art); + const nenne = (t: Spur): string => t.DisplayTitle || t.Language || `Spur ${t.Index}`; + + interface Eintrag { text: string; gewaehlt: boolean; waehlen: () => void } + + function liste(knopf: HTMLElement, titel: string, eintraege: Eintrag[]): void { + if (!menue.hidden && menue.dataset.von === knopf.id) return menueZu(); + menue.dataset.von = knopf.id; + menue.innerHTML = `
${esc(titel)}
`; + + for (const { text, gewaehlt, waehlen } of eintraege) { + const b = document.createElement('button'); + b.className = 'pl-item' + (gewaehlt ? ' on' : ''); + b.innerHTML = `${esc(text)}${gewaehlt ? icon('check', 16) : ''}`; + b.onclick = () => (gewaehlt ? menueZu() : waehlen()); + menue.appendChild(b); + } + + // Unter seinem Knopf, nicht in der Mitte: so ist zu sehen, wozu die Liste + // gehört, wenn mehrere davon nebeneinander sitzen. + const r = knopf.getBoundingClientRect(); + menue.style.right = Math.max(12, innerWidth - r.right) + 'px'; + menue.style.top = r.bottom + 8 + 'px'; + menue.hidden = false; + zeigen(); + } + + const neuLaden = (): void => { + lauf.stelle = v.currentTime || 0; + ebene.querySelector('.pl-error')?.remove(); + ebene.classList.add('loading'); + el('pl-busy').classList.add('on'); + menueZu(); + void strom(); + }; + + el('pl-audio').onclick = () => liste(el('pl-audio'), 'Tonspur', + spuren('Audio').map((t) => ({ + text: nenne(t), gewaehlt: lauf.audio === t.Index, + waehlen: () => { lauf.audio = t.Index; neuLaden(); }, + }))); + + el('pl-subs').onclick = () => liste(el('pl-subs'), 'Untertitel', [ + { text: 'Aus', gewaehlt: (lauf.subtitle ?? -1) < 0, waehlen: () => { lauf.subtitle = -1; neuLaden(); } }, + ...spuren('Subtitle').map((t) => ({ + text: nenne(t), gewaehlt: lauf.subtitle === t.Index, + waehlen: () => { lauf.subtitle = t.Index; neuLaden(); }, + })), + ]); + + el('pl-qual').onclick = () => liste(el('pl-qual'), 'Qualität', + QUALITIES.map(([wert, text]) => ({ + text, gewaehlt: lauf.quality === wert, + waehlen: () => { lauf.quality = wert; neuLaden(); }, + }))); + + el('pl-speed').onclick = () => liste(el('pl-speed'), 'Geschwindigkeit', + RATES.map((r) => ({ + text: r === 1 ? 'Normal' : r + '×', + gewaehlt: Math.abs(v.playbackRate - r) < 0.01, + waehlen: () => { v.playbackRate = r; menueZu(); zeigen(); }, + }))); + + ebene.addEventListener('click', (e) => { + const ziel = e.target as HTMLElement; + if (menue.hidden || menue.contains(ziel) || ziel.closest('.pl-icon')) return; + menueZu(); + }); + + /* ---------- Ton ---------- */ + const tonMalen = (): void => { + el('pl-mute').innerHTML = icon(v.muted || v.volume === 0 ? 'muted' : 'sound'); + el('pl-mute').title = `Lautstärke ${Math.round(v.volume * 100)} %`; + }; + el('pl-mute').onclick = () => { v.muted = !v.muted; tonMalen(); }; + el('pl-mute').addEventListener('wheel', (e) => { + e.preventDefault(); + v.volume = Math.min(1, Math.max(0, v.volume - Math.sign(e.deltaY) * 0.05)); + v.muted = v.volume === 0; + tonMalen(); + }, { passive: false }); + + el('pl-full').onclick = () => { + void (document.fullscreenElement ? document.exitFullscreen() : ebene.requestFullscreen?.()); + }; + + /* ---------- Tastatur ---------- */ + const tasten = (e: KeyboardEvent): void => { + const ziel = e.target as HTMLElement; + if (ziel.tagName === 'SELECT' || ziel.tagName === 'INPUT') return; + const wege: Record void> = { + ' ': umschalten, k: umschalten, + ArrowLeft: () => { v.currentTime -= P.skipBackward; }, + ArrowRight: () => { v.currentTime += P.skipForward; }, + ArrowUp: () => { v.volume = Math.min(1, v.volume + 0.1); tonMalen(); }, + ArrowDown: () => { v.volume = Math.max(0, v.volume - 0.1); tonMalen(); }, + f: () => el('pl-full').click(), + Escape: () => schliessen(), + }; + const tun = wege[e.key]; + if (!tun) return; + e.preventDefault(); + tun(); + zeigen(); + }; + addEventListener('keydown', tasten); + + /* ---------- Zurück aus dem Hintergrund ---------- + Wer die Anwendung wechselt, kommt zu einem stehenden Bild bei laufendem + Ton zurück: der Browser hält das Entpacken an, während der Tab verdeckt + ist, und hls.js findet danach nicht von selbst in den Puffer zurück. */ + const zurueck = (): void => { + if (document.hidden || v.paused || !hls) return; + const stelle = v.currentTime; + hls.startLoad(stelle); + // Ein winziger Sprung erzwingt einen neuen Schlüsselbildpunkt. Ohne ihn + // bleibt das zuletzt gezeigte Bild stehen, obwohl Segmente ankommen. + if (stelle > 0.5) v.currentTime = stelle - 0.1; + }; + document.addEventListener('visibilitychange', zurueck); + + /* ---------- Schließen ---------- */ + function schliessen(): void { + removeEventListener('keydown', tasten); + document.removeEventListener('visibilitychange', zurueck); + clearTimeout(ausblenden); + if (document.fullscreenElement) void document.exitFullscreen?.(); + + // **Erst melden, dann abräumen.** Andersherum steht die Stelle schon auf + // null, wenn die Meldung hinausgeht — und Jellyfin löscht daraufhin den + // Fortschritt, statt ihn zu speichern. + lauf.sitzung?.melden('/Sessions/Playing/Stopped'); + + hls?.destroy(); + hls = null; + v.pause(); + v.removeAttribute('src'); + v.load(); + ebene.remove(); + document.body.style.overflow = ''; + beimSchliessen?.(); + } + el('pl-close').onclick = schliessen; + + /* ---------- Der Strom ---------- */ + async function strom(): Promise { + hls?.destroy(); + hls = null; + + let auskunft; + try { + auskunft = await wiedergabeauskunft(item.Id, lauf); + } catch (e) { + return scheitern('Der Server hat die Wiedergabe abgelehnt.', (e as Error).message); + } + + const quelle: Quelle | undefined = auskunft.MediaSources?.[0]; + if (!quelle) return scheitern('Der Server nennt keine abspielbare Quelle.'); + + // Was zur Auswahl steht, weiß man erst jetzt. Beim ersten Lauf gilt die + // Vorauswahl des Servers. + lauf.streams = quelle.MediaStreams ?? []; + if (lauf.audio == null) { + lauf.audio = quelle.DefaultAudioStreamIndex ?? spuren('Audio')[0]?.Index ?? null; + } + if (lauf.subtitle == null) lauf.subtitle = quelle.DefaultSubtitleStreamIndex ?? -1; + spurenBekannt(); + + // **Die Adresse kommt vom Server.** Nennt er keine, wird ausdrücklich ein + // HLS-Strom verlangt — nicht die Rohdatei. Ein MKV mit DTS ist für einen + // Browser unlesbar, und genau dieser Rückfall war der Grund, warum lange + // gar nichts lief. + const adresse = quelle.TranscodingUrl + ? new URL(quelle.TranscodingUrl, location.origin + '/api').href + : medienURL(`/Videos/${item.Id}/master.m3u8`, { + mediaSourceId: quelle.Id ?? item.Id, + PlaySessionId: auskunft.PlaySessionId ?? '', + DeviceId: sitzung.geraet, + VideoCodec: 'h264', AudioCodec: 'aac,mp3', + TranscodingMaxAudioChannels: '2', SegmentContainer: 'ts', + MinSegments: '1', BreakOnNonKeyFrames: 'true', TranscodingProtocol: 'hls', + ...(lauf.audio != null ? { AudioStreamIndex: lauf.audio } : {}), + ...((lauf.subtitle ?? -1) >= 0 ? { SubtitleStreamIndex: lauf.subtitle! } : {}), + ...(lauf.quality > 0 ? { MaxStreamingBitrate: lauf.quality * 1000 } : {}), + }); + + diagnose.text = [ + quelle.TranscodingUrl ? 'Server nannte eine Adresse' : 'Server nannte keine Adresse (Rückfall auf HLS)', + quelle.Container ? `Container ${quelle.Container}` : null, + ].filter(Boolean).join(' · '); + diagnose.url = adresse; + + v.addEventListener('loadedmetadata', () => { + v.playbackRate = P.rate; + // Zurück an die Stelle, an der umgeschaltet wurde — sonst begänne jeder + // Wechsel der Tonspur den Film von vorn. + if (lauf.stelle > 1) v.currentTime = lauf.stelle; + void v.play(); + }, { once: true }); + + lauf.sitzung = fortschritt(v, { + id: auskunft.PlaySessionId, + quelleID: quelle.Id ?? item.Id, + itemID: item.Id, + }); + + if (v.canPlayType('application/vnd.apple.mpegurl')) { + v.src = adresse; + return; + } + + // **Erst hier geladen.** hls.js ist größer als der ganze Rest zusammen, + // und wer nur stöbert, braucht es nie. Safari schon gar nicht — dort + // sind wir oben schon heraus. + const { default: Hls } = await import('hls.js'); + const h = new Hls(); + hls = h; + h.on(Hls.Events.ERROR, (_e, daten) => { + if (!daten.fatal) return; + scheitern('Die Wiedergabe ist abgebrochen.', + `${daten.type}: ${daten.details}` + + (daten.response?.code ? ` (HTTP ${daten.response.code})` : '')); + h.destroy(); + }); + h.loadSource(adresse); + h.attachMedia(v); + } + + function spurenBekannt(): void { + el('pl-audio').hidden = spuren('Audio').length === 0; + el('pl-subs').hidden = spuren('Subtitle').length === 0; + // Gefülltes Symbol, solange Untertitel laufen — wie auf dem iPad. + el('pl-subs').innerHTML = icon((lauf.subtitle ?? -1) >= 0 ? 'captionsOn' : 'captions'); + el('pl-qual').textContent = + (QUALITIES.find(([w]) => w === lauf.quality)?.[1] ?? 'Original').split(' — ').pop()!; + } + + zeigen(); + tonMalen(); + ebene.classList.add('loading'); + el('pl-busy').classList.add('on'); + void strom(); +} + +/* Meldet dem Server, wo man steht. + ---------------------------------------------------------------------------- + Die Stelle wird **getrennt vom Element** gehalten. Beim Schließen fällt + `currentTime` auf null, und stünde die Meldung erst danach an, bekäme + Jellyfin „gestoppt bei 0:00" und löschte den Fortschritt. + + Nicht ohne Hinsehen: ein verschlucktes `catch` hat lange verborgen, ob die + Meldungen überhaupt ankommen. */ +function fortschritt( + video: HTMLVideoElement, + sitz: { id?: string; quelleID: string; itemID: string }, +): Sitzungsmeldung { + const s: Sitzungsmeldung = { + ...sitz, + stelle: 0, + melden: (weg, stelle, extra = {}) => { + const wo = stelle ?? s.stelle; + void api(weg, { + method: 'POST', + body: JSON.stringify({ + ItemId: s.itemID, PlaySessionId: s.id, MediaSourceId: s.quelleID, + PositionTicks: Math.round(wo * 10_000_000), + // Ohne die ordnet Jellyfin die Meldung keiner Wiedergabe zu. + PlayMethod: 'Transcode', CanSeek: true, + ...extra, + }), + }) + .then(() => console.debug(`[Flixy] ${weg} → ${(wo / 60).toFixed(1)} min angenommen`)) + .catch((e: Error) => console.warn(`[Flixy] ${weg} abgelehnt: ${e.message}`)); + }, + }; + + video.addEventListener('playing', () => s.melden('/Sessions/Playing'), { once: true }); + + let zuletzt = 0; + video.addEventListener('timeupdate', () => { + if (video.currentTime > 0) s.stelle = video.currentTime; + if (video.currentTime - zuletzt < 10) return; + zuletzt = video.currentTime; + s.melden('/Sessions/Playing/Progress', s.stelle, { IsPaused: video.paused }); + }); + + // Anhalten sofort melden: wer pausiert und weggeht, hatte sonst bis zur + // nächsten Zehn-Sekunden-Marke nichts hinterlassen. + video.addEventListener('pause', () => s.melden('/Sessions/Playing/Progress', s.stelle, { IsPaused: true })); + video.addEventListener('play', () => s.melden('/Sessions/Playing/Progress', s.stelle, { IsPaused: false })); + video.addEventListener('ended', () => s.melden('/Sessions/Playing/Stopped', video.duration || s.stelle)); + + return s; +} diff --git a/web/app/src/views/settings.ts b/web/app/src/views/settings.ts new file mode 100644 index 0000000..e8e16b9 --- /dev/null +++ b/web/app/src/views/settings.ts @@ -0,0 +1,246 @@ +import { P, PALETTE, QUALITIES, RATES, SKIPS, LANGUAGES, setzen, anordnen } from '../prefs'; +import { abmelden } from '../api'; +import { icon } from '../icons'; +import { esc } from '../util'; +import { bekannteReihen } from './home'; +import { lesbarMachen, alsHex, ausHex } from '../contrast'; + +/* Die Einstellungen. + ---------------------------------------------------------------------------- + Dieselben fünf Abschnitte in derselben Reihenfolge wie in der App: + Darstellung, Startseite, Wiedergabe, Sprachen, Server. Downloads und + Diagnose fehlen — dort fehlt die Funktion, nicht die Einstellung. */ +export function einstellungen(wurzel: HTMLElement, neuZeichnen: () => void): void { + wurzel.classList.add('pad-top'); + wurzel.innerHTML = ` +
+

Einstellungen

+ +

Darstellung

+
+

Akzentfarbe

+

Dieselbe Palette wie in der App.

+
+
+ + +
+

+
+
+

Kachelgröße

+

Wie groß Plakate und Vorschaubilder erscheinen — auf der Startseite + und in den Mediatheken. Gilt nur auf diesem Gerät.

+
+
+ +

Startseite

+
+

Aufmacher

+

Woher die Titel oben kommen. Die Auswahl wechselt alle sechs Stunden; + Titel ohne Hintergrundbild ziehen dabei ein schlechteres Los.

+
+ + +
+
+
+
+
+

Reihen

+ + +
+
+

Reihen der Startseite

+

Welche Reihen erscheinen und in welcher Folge. Was der Server neu + anlegt, kommt ans Ende, statt zu fehlen.

+
+
+ +

Wiedergabe

+
+

Geschwindigkeit

+

Womit ein Titel beginnt. Im Player lässt sie sich jederzeit ändern.

+ +
+
+

Spulweite

+
+ + +
+
+
+

Qualität

+

„Original“ schickt die Datei unverändert, alles andere lässt den + Server umrechnen. Die App unterscheidet WLAN und Mobilfunk — im + Browser lässt sich das nicht verlässlich feststellen, deshalb eine + Stufe.

+ +
+ +

Sprachen

+
+

Bevorzugte Sprachen

+

Welche Spur beim Start gewählt wird, sofern der Titel sie mitbringt.

+
+ + +
+
+ +

Server

+
+

Verbindung

+

Die Adresse steht beim Server, der diese Seite ausliefert — nicht im + Browser.

+ +
+
`; + + /* ---- Akzentfarbe ---- */ + const felder = wurzel.querySelector('#set-accent')!; + for (const [name, hex] of PALETTE) { + const b = document.createElement('button'); + b.className = 'swatch' + (hex.toLowerCase() === P.accent.toLowerCase() ? ' on' : ''); + b.style.background = hex; + b.title = name; + b.dataset.hex = hex; + b.onclick = () => { setzen('accent', hex); einstellungen(wurzel, neuZeichnen); }; + felder.appendChild(b); + } + + const hex = wurzel.querySelector('#set-hex')!; + const notiz = wurzel.querySelector('#set-hex-note')!; + hex.value = P.accent; + const uebernehmen = (): void => { + const rgb = ausHex(hex.value); + if (!rgb) { notiz.textContent = 'Das ist kein Farbwert — erwartet wird etwa #C04AC9.'; return; } + const { farbe, geaendert } = lesbarMachen(rgb, ausHex('#0B0B0D')!); + const wert = alsHex(farbe); + setzen('accent', wert); + hex.value = wert; + notiz.textContent = geaendert + ? `Auf ${wert} aufgehellt — die gewählte Farbe wäre auf dem dunklen Grund nicht lesbar gewesen.` + : ''; + felder.querySelectorAll('.swatch') + .forEach((b) => b.classList.toggle('on', b.dataset.hex === wert)); + }; + wurzel.querySelector('#set-hex-apply')!.onclick = uebernehmen; + hex.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); uebernehmen(); } }; + + /* ---- Die wiederkehrenden Bauteile ---- */ + const regler = (id: string, feld: 'cardW' | 'heroCount' | 'heroHeight', einheit: string): void => { + const r = wurzel.querySelector('#' + id)!; + const o = wurzel.querySelector('#' + id + '-out')!; + r.value = String(P[feld]); + o.textContent = P[feld] + einheit; + r.oninput = () => { o.textContent = r.value + einheit; setzen(feld, Number(r.value)); }; + }; + regler('set-zoom', 'cardW', ' px'); + regler('set-herocount', 'heroCount', ' Titel'); + regler('set-heroheight', 'heroHeight', ' % Höhe'); + + const schalter = wurzel.querySelector('#set-herosource')!; + [...schalter.children].forEach((b) => { + const el = b as HTMLElement; + el.classList.toggle('on', el.dataset.v === P.heroSource); + el.onclick = () => { + setzen('heroSource', el.dataset.v as typeof P.heroSource); + einstellungen(wurzel, neuZeichnen); + }; + }); + + const haken = (id: string, feld: 'combineContinue' | 'hideWatched'): void => { + const c = wurzel.querySelector('#' + id)!; + c.checked = P[feld]; + c.onchange = () => setzen(feld, c.checked); + }; + haken('set-combine', 'combineContinue'); + haken('set-hidewatched', 'hideWatched'); + + const auswahl = (id: string, feld: keyof typeof P, werte: readonly (readonly [string | number, string])[]): void => { + const s = wurzel.querySelector('#' + id)!; + s.innerHTML = ''; + for (const [wert, text] of werte) { + const o = document.createElement('option'); + o.value = String(wert); + o.textContent = text; + o.selected = String(wert) === String(P[feld]); + s.appendChild(o); + } + s.onchange = () => { + const roh = typeof P[feld] === 'number' ? Number(s.value) : s.value; + setzen(feld as never, roh as never); + }; + }; + auswahl('set-rate', 'rate', RATES.map((r) => [r, r === 1 ? 'Normal' : r + '×'] as const)); + auswahl('set-skipback', 'skipBackward', SKIPS.map((n) => [n, n + ' s'] as const)); + auswahl('set-skipfwd', 'skipForward', SKIPS.map((n) => [n, n + ' s'] as const)); + auswahl('set-quality', 'quality', QUALITIES); + auswahl('set-audio', 'audioLanguage', LANGUAGES); + auswahl('set-subs', 'subtitleLanguage', LANGUAGES); + + wurzel.querySelector('#set-signout')!.onclick = abmelden; + reihenListe(wurzel, neuZeichnen); +} + +/* Welche Reihen erscheinen und in welcher Folge. + ---------------------------------------------------------------------------- + Knöpfe statt Ziehen: auf dem Zeigegerät ist Ziehen schöner, auf einem + Trackpad fummelig und mit der Tastatur gar nicht zu machen. Zwei Pfeile + können alle drei. */ +function reihenListe(wurzel: HTMLElement, neuZeichnen: () => void): void { + const ort = wurzel.querySelector('#set-rows'); + if (!ort) return; + + if (!bekannteReihen.length) { + ort.innerHTML = '

Erst sichtbar, wenn die Startseite einmal geladen war.

'; + return; + } + + // Ausgeblendete hinten dran, damit man sie wiederfindet. + const geordnet = [ + ...anordnen(bekannteReihen), + ...bekannteReihen.filter((r) => P.hiddenRows.includes(r.id)), + ]; + + ort.innerHTML = ''; + geordnet.forEach((reihe, stelle) => { + const aus = P.hiddenRows.includes(reihe.id); + const zeile = document.createElement('div'); + zeile.className = 'rowline' + (aus ? ' off' : ''); + zeile.innerHTML = ` + + + `; + + zeile.querySelector('input')!.onchange = (e) => { + const an = (e.target as HTMLInputElement).checked; + setzen('hiddenRows', an + ? P.hiddenRows.filter((id) => id !== reihe.id) + : [...P.hiddenRows, reihe.id]); + reihenListe(wurzel, neuZeichnen); + neuZeichnen(); + }; + + const schieben = (um: number): void => { + // Die aktuelle Folge festschreiben, bevor darin verschoben wird. + const ids = geordnet.map((r) => r.id); + const nach = stelle + um; + if (nach < 0 || nach >= ids.length) return; + [ids[stelle], ids[nach]] = [ids[nach]!, ids[stelle]!]; + setzen('rowOrder', ids); + reihenListe(wurzel, neuZeichnen); + neuZeichnen(); + }; + const [hoch, runter] = zeile.querySelectorAll('button'); + (hoch as HTMLElement).onclick = () => schieben(-1); + (runter as HTMLElement).onclick = () => schieben(1); + ort.appendChild(zeile); + }); +}