Das Web ist jetzt ein Projekt
Schritt 2 des Plans, fertig. Aus 1700 Zeilen in einer Datei sind zwölf Module geworden: api, prefs, contrast, icons, util, types und die Ansichten für Anmeldung, Startseite, Mediathek, Einstellungen, Kacheln und Player. Alles typgeprüft, und die Prüfung läuft scharf — noUncheckedIndexedAccess inbegriffen. An einer Stelle hat der Compiler mich zu Recht aufgehalten: Object.fromEntries verliert die Typen jedes einzelnen Feldes, und geblieben wäre nur ein doppelter Cast, der die Prüfung stilllegt statt sie zu bestehen. Jetzt steht es ausgeschrieben da. hls.js wird erst beim Abspielen geladen. Es ist größer als der ganze Rest zusammen, und wer nur stöbert, braucht es nie: der Start fällt damit von 632 auf 39 Kilobyte. Geprüft mit einem erfundenen Medienserver dahinter: Seite kommt an, gebaute Dateien werden ausgeliefert, Anfragen gehen weiter, und die Startseite zeichnet Insel, Aufmacher und Reihen. Die einzelne Datei bleibt liegen, bis das Projekt sich bewährt hat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a04784ca15
commit
3af6c3112f
@@ -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 = `
|
||||
<div class="art" ${art ? `style="background-image:url('${art}')"` : ''}>
|
||||
${anteil ? `<div class="bar"><span style="width:${Math.min(anteil, 100)}%"></span></div>` : ''}
|
||||
</div>
|
||||
<div class="title">${esc(item.SeriesName || item.Name)}</div>
|
||||
<div class="sub">${esc(unterzeile(item))}</div>`;
|
||||
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 = `
|
||||
<div class="panel">
|
||||
<h2>${esc(item.SeriesName ? item.SeriesName + ' — ' + item.Name : item.Name)}</h2>
|
||||
<div class="meta">${teile.join(' · ')}</div>
|
||||
<p>${esc(item.Overview ?? '')}</p>
|
||||
<div class="btn-row">
|
||||
${spielbar ? '<button class="btn btn-primary" id="sheet-play"></button>' : ''}
|
||||
<button class="btn" id="sheet-close">Schließen</button>
|
||||
</div>
|
||||
</div>`;
|
||||
hinten.onclick = (e) => { if (e.target === hinten) hinten.remove(); };
|
||||
document.body.appendChild(hinten);
|
||||
hinten.querySelector<HTMLElement>('#sheet-close')!.onclick = () => hinten.remove();
|
||||
|
||||
if (!spielbar) return;
|
||||
const knopf = hinten.querySelector<HTMLButtonElement>('#sheet-play')!;
|
||||
knopf.textContent = ordner ? '▶ Weiterschauen' : '▶ Abspielen';
|
||||
knopf.onclick = () => { starten(item); hinten.remove(); };
|
||||
}
|
||||
@@ -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<void> {
|
||||
wurzel.innerHTML = '<div class="spinner"></div>';
|
||||
|
||||
const hole = async (pfad: string, q: Record<string, string> = {}): Promise<Item[]> => {
|
||||
try {
|
||||
const r = await api<Seite | Item[]>(
|
||||
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<string>();
|
||||
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 = '<div class="empty pad-top">Hier ist noch nichts.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/* 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<string>();
|
||||
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', '<div class="veil"></div>');
|
||||
|
||||
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<typeof setInterval> | 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 ? `<img class="logo" src="${zeichen}" alt="${esc(it.Name)}">`
|
||||
: `<h2>${esc(it.Name)}</h2>`}
|
||||
<div class="meta">${teile.join(' · ')}</div>
|
||||
<div class="summary">${esc(it.Overview ?? '')}</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary">${icon('play', 15)} Abspielen</button>
|
||||
<button class="btn">Details</button>
|
||||
</div>`;
|
||||
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 = `<h3>${esc(r.title)}</h3><div class="strip"></div>`;
|
||||
const streifen = abschnitt.querySelector('.strip')!;
|
||||
for (const it of r.items) streifen.appendChild(kachel(it, Boolean(r.wide), oeffnen));
|
||||
return abschnitt;
|
||||
}
|
||||
@@ -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<void> {
|
||||
wurzel.classList.add('pad-top');
|
||||
wurzel.innerHTML = '<div class="spinner"></div>';
|
||||
|
||||
try {
|
||||
const r = await api<Seite>(
|
||||
`/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 ? '<div class="grid"></div>' : '<div class="empty">Leer.</div>';
|
||||
const raster = wurzel.querySelector('.grid');
|
||||
if (raster) for (const it of items) raster.appendChild(kachel(it, false, oeffnen));
|
||||
} catch (e) {
|
||||
wurzel.innerHTML = `<div class="empty">${esc((e as Error).message)}</div>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { anmelden } from '../api';
|
||||
import { esc } from '../util';
|
||||
|
||||
export function anmeldung(wurzel: HTMLElement, fertig: () => void): void {
|
||||
wurzel.innerHTML = `
|
||||
<form class="login-card" id="login-form">
|
||||
<h1>Flixy</h1>
|
||||
<p class="sub">Deine Mediathek, schöner.</p>
|
||||
<div class="field">
|
||||
<label for="user">Benutzer</label>
|
||||
<input id="user" type="text" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pass">Passwort</label>
|
||||
<input id="pass" type="password" autocomplete="current-password">
|
||||
</div>
|
||||
<p class="error" id="login-error"></p>
|
||||
<button class="btn btn-primary" type="submit" id="login-btn">Anmelden</button>
|
||||
<p class="note">
|
||||
Der Zugangsschlüssel bleibt in diesem Browser. Das Passwort geht an
|
||||
deinen eigenen Server und wird nirgends gespeichert.
|
||||
</p>
|
||||
</form>`;
|
||||
|
||||
const form = wurzel.querySelector<HTMLFormElement>('#login-form')!;
|
||||
const knopf = wurzel.querySelector<HTMLButtonElement>('#login-btn')!;
|
||||
const fehler = wurzel.querySelector<HTMLElement>('#login-error')!;
|
||||
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
knopf.disabled = true;
|
||||
knopf.textContent = 'Verbinde …';
|
||||
fehler.textContent = '';
|
||||
try {
|
||||
await anmelden(
|
||||
wurzel.querySelector<HTMLInputElement>('#user')!.value,
|
||||
wurzel.querySelector<HTMLInputElement>('#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';
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function playerOeffnen(item: Item, beimSchliessen?: () => void): void {
|
||||
document.getElementById('player')?.remove();
|
||||
|
||||
const ebene = document.createElement('div');
|
||||
ebene.id = 'player';
|
||||
ebene.innerHTML = `
|
||||
<video playsinline></video>
|
||||
<div class="pl-chrome">
|
||||
<div class="pl-top">
|
||||
<button class="pl-icon" id="pl-close" aria-label="Schließen">${icon('close')}</button>
|
||||
<div class="pl-title">
|
||||
<strong>${esc(item.SeriesName || item.Name)}</strong>
|
||||
${item.SeriesName ? `<span>${esc(folgentitel(item))}</span>` : ''}
|
||||
</div>
|
||||
<span class="pl-spacer"></span>
|
||||
<button class="pl-icon" id="pl-audio" aria-label="Tonspur" hidden>${icon('waveform')}</button>
|
||||
<button class="pl-icon" id="pl-subs" aria-label="Untertitel" hidden>${icon('captions')}</button>
|
||||
<button class="pl-icon pl-quality" id="pl-qual" aria-label="Qualität">Original</button>
|
||||
<button class="pl-icon" id="pl-speed" aria-label="Geschwindigkeit">${icon('speed')}</button>
|
||||
<button class="pl-icon" id="pl-mute" aria-label="Ton">${icon('sound')}</button>
|
||||
<button class="pl-icon" id="pl-full" aria-label="Vollbild">${icon('expand')}</button>
|
||||
</div>
|
||||
<div class="pl-center">
|
||||
<button class="pl-round" id="pl-back" aria-label="Zurückspulen">${icon('goBack', 40, P.skipBackward)}</button>
|
||||
<button class="pl-round pl-play" id="pl-toggle" aria-label="Abspielen">${icon('play', 52)}</button>
|
||||
<button class="pl-round" id="pl-fwd" aria-label="Vorspulen">${icon('goFwd', 40, P.skipForward)}</button>
|
||||
</div>
|
||||
<div class="pl-bottom">
|
||||
<div class="pl-track" id="pl-track"><div class="pl-buf"></div><div class="pl-fill"></div><i></i></div>
|
||||
<div class="pl-times"><span id="pl-now">0:00</span><span id="pl-left">–:--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-menu" id="pl-menu" hidden></div>
|
||||
<div class="pl-busy" id="pl-busy"><i></i></div>`;
|
||||
document.body.appendChild(ebene);
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const v = ebene.querySelector('video')!;
|
||||
const chrome = ebene.querySelector('.pl-chrome')!;
|
||||
const el = <T extends HTMLElement>(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 = `<strong>${esc(satz)}</strong>${hinweis ? `<span>${esc(hinweis)}</span>` : ''}
|
||||
${adresse ? `<code>${esc(adresse.replace(/api_key=[^&]*/, 'api_key=…'))}</code>` : ''}
|
||||
<button class="btn">Schließen</button>`;
|
||||
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<typeof setTimeout>;
|
||||
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<HTMLElement>('.pl-fill')!.style.width = teil * 100 + '%';
|
||||
spur.querySelector<HTMLElement>('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<HTMLElement>('.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 = `<div class="pl-group">${esc(titel)}</div>`;
|
||||
|
||||
for (const { text, gewaehlt, waehlen } of eintraege) {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'pl-item' + (gewaehlt ? ' on' : '');
|
||||
b.innerHTML = `<span>${esc(text)}</span>${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<string, () => 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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="settings">
|
||||
<h2>Einstellungen</h2>
|
||||
|
||||
<h3 class="group">Darstellung</h3>
|
||||
<div class="setting">
|
||||
<h4>Akzentfarbe</h4>
|
||||
<p>Dieselbe Palette wie in der App.</p>
|
||||
<div class="swatches" id="set-accent"></div>
|
||||
<div class="hexrow">
|
||||
<input type="text" id="set-hex" placeholder="#C04AC9" maxlength="7" spellcheck="false">
|
||||
<button class="btn" id="set-hex-apply">Übernehmen</button>
|
||||
</div>
|
||||
<p class="hint" id="set-hex-note"></p>
|
||||
</div>
|
||||
<div class="setting">
|
||||
<h4>Kachelgröße</h4>
|
||||
<p>Wie groß Plakate und Vorschaubilder erscheinen — auf der Startseite
|
||||
und in den Mediatheken. Gilt nur auf diesem Gerät.</p>
|
||||
<div class="range-row"><input type="range" id="set-zoom" min="100" max="280" step="10"><output id="set-zoom-out"></output></div>
|
||||
</div>
|
||||
|
||||
<h3 class="group">Startseite</h3>
|
||||
<div class="setting">
|
||||
<h4>Aufmacher</h4>
|
||||
<p>Woher die Titel oben kommen. Die Auswahl wechselt alle sechs Stunden;
|
||||
Titel ohne Hintergrundbild ziehen dabei ein schlechteres Los.</p>
|
||||
<div class="seg" id="set-herosource">
|
||||
<button data-v="continueFirst">Angefangenes zuerst</button>
|
||||
<button data-v="latestOnly">Nur Neuzugänge</button>
|
||||
</div>
|
||||
<div class="range-row"><input type="range" id="set-herocount" min="1" max="10" step="1"><output id="set-herocount-out"></output></div>
|
||||
<div class="range-row"><input type="range" id="set-heroheight" min="40" max="95" step="5"><output id="set-heroheight-out"></output></div>
|
||||
</div>
|
||||
<div class="setting">
|
||||
<h4>Reihen</h4>
|
||||
<label class="check"><input type="checkbox" id="set-combine"><span>„Weiterschauen“ und „Als Nächstes“ zusammenlegen</span></label>
|
||||
<label class="check"><input type="checkbox" id="set-hidewatched"><span>Gesehenes aus den Neuzugängen ausblenden</span></label>
|
||||
</div>
|
||||
<div class="setting">
|
||||
<h4>Reihen der Startseite</h4>
|
||||
<p>Welche Reihen erscheinen und in welcher Folge. Was der Server neu
|
||||
anlegt, kommt ans Ende, statt zu fehlen.</p>
|
||||
<div id="set-rows"></div>
|
||||
</div>
|
||||
|
||||
<h3 class="group">Wiedergabe</h3>
|
||||
<div class="setting">
|
||||
<h4>Geschwindigkeit</h4>
|
||||
<p>Womit ein Titel beginnt. Im Player lässt sie sich jederzeit ändern.</p>
|
||||
<select id="set-rate" class="pick"></select>
|
||||
</div>
|
||||
<div class="setting">
|
||||
<h4>Spulweite</h4>
|
||||
<div class="pickrow">
|
||||
<label>Zurück <select id="set-skipback" class="pick"></select></label>
|
||||
<label>Vor <select id="set-skipfwd" class="pick"></select></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting">
|
||||
<h4>Qualität</h4>
|
||||
<p>„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.</p>
|
||||
<select id="set-quality" class="pick"></select>
|
||||
</div>
|
||||
|
||||
<h3 class="group">Sprachen</h3>
|
||||
<div class="setting">
|
||||
<h4>Bevorzugte Sprachen</h4>
|
||||
<p>Welche Spur beim Start gewählt wird, sofern der Titel sie mitbringt.</p>
|
||||
<div class="pickrow">
|
||||
<label>Ton <select id="set-audio" class="pick"></select></label>
|
||||
<label>Untertitel <select id="set-subs" class="pick"></select></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="group">Server</h3>
|
||||
<div class="setting">
|
||||
<h4>Verbindung</h4>
|
||||
<p>Die Adresse steht beim Server, der diese Seite ausliefert — nicht im
|
||||
Browser.</p>
|
||||
<button class="btn" id="set-signout">Abmelden</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/* ---- 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<HTMLInputElement>('#set-hex')!;
|
||||
const notiz = wurzel.querySelector<HTMLElement>('#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<HTMLElement>('.swatch')
|
||||
.forEach((b) => b.classList.toggle('on', b.dataset.hex === wert));
|
||||
};
|
||||
wurzel.querySelector<HTMLElement>('#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<HTMLInputElement>('#' + id)!;
|
||||
const o = wurzel.querySelector<HTMLOutputElement>('#' + 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<HTMLInputElement>('#' + 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<HTMLSelectElement>('#' + 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<HTMLElement>('#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 = '<p class="hint">Erst sichtbar, wenn die Startseite einmal geladen war.</p>';
|
||||
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 = `
|
||||
<label class="check" style="flex:1;padding:0">
|
||||
<input type="checkbox" ${aus ? '' : 'checked'}>
|
||||
<span>${esc(reihe.title)}</span>
|
||||
</label>
|
||||
<button class="tiny" aria-label="Nach oben" ${stelle === 0 ? 'disabled' : ''}>${icon('up', 16)}</button>
|
||||
<button class="tiny" aria-label="Nach unten" ${stelle === geordnet.length - 1 ? 'disabled' : ''}>${icon('down', 16)}</button>`;
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user