Eine Serie, eine Sprachwahl — nicht zwanzig

Auf der Serienseite im Web stand bei jeder einzelnen Folge ein Knopf für
Ton und einer für Untertitel. Zwanzig Folgen hiessen zwanzigmal dieselbe
Frage, und am Ende wusste niemand, welche Antwort gilt. „Dieser Anime
läuft auf Japanisch mit deutschen Untertiteln" ist eine Aussage über die
Serie, nicht über Folge 7.

Die Wahl steht jetzt einmal oben bei den Angaben zur Serie und an den
Folgen gar nicht mehr. Beim Film bleibt sie, wo sie war — dort ist die
Datei der Titel.

Angeboten werden nur Sprachen, die in **jeder** Folge vorkommen; was
dadurch wegfällt, steht als Satz darunter. Gemerkt wird die Sprache, nicht
die Spurnummer: die unterscheidet sich von Folge zu Folge, und beim Start
wird sie für diese Datei nachgeschlagen.

Dazu die Staffeln. Sie standen alle untereinander — nach Folge 8 kam
wieder eine Folge 1, und wer zur dritten Staffel wollte, scrollte an
zwanzig Folgen vorbei. Die Überschrift ist jetzt das Menü. Bei einer
einzigen Staffel bleibt es bei der Überschrift: ein Menü, das nur sich
selbst anbietet, ist keine Wahl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-10 21:43:17 +02:00
co-authored by Claude Opus 5
parent 22be288fc3
commit fc36bb7ddb
3 changed files with 262 additions and 35 deletions
+17
View File
@@ -578,6 +578,23 @@ export const inhalt = (b: Bibliothek, q: Omit<Suchfrage, 'library'> = {}) =>
export const folgen = (serie: string, staffel?: number) => export const folgen = (serie: string, staffel?: number) =>
anfrage<Seite<Folge>>(`/series/${serie}/episodes${abfrage({ season: staffel })}`); anfrage<Seite<Folge>>(`/series/${serie}/episodes${abfrage({ season: staffel })}`);
/** Eine Sprache, wie sie in einem Menü steht. */
export type Sprachangabe = { code: string; name: string };
/** Welche Sprachen eine Serie durchgehend führt.
*
* `partial` zählt, wie viele es nur in einem Teil der Folgen gibt — damit
* die Oberfläche es sagen kann, statt eine gekürzte Liste als ganze
* Wahrheit auszugeben. */
export type Serienstimmen = {
audio: Sprachangabe[];
subtitles: Sprachangabe[];
partial: number;
};
export const serienstimmen = (serie: string) =>
anfrage<Serienstimmen>(`/series/${serie}/languages`);
export const film = (id: string) => anfrage<Film>(`/movies/${id}`); export const film = (id: string) => anfrage<Film>(`/movies/${id}`);
export const serie = (id: string) => anfrage<Serie>(`/series/${id}`); export const serie = (id: string) => anfrage<Serie>(`/series/${id}`);
+58
View File
@@ -0,0 +1,58 @@
/** Die Sprachvorwahl einer Serie — Ton und Untertitel, dauerhaft je Serie.
*
* **Warum je Serie und nicht je Folge.** „Dieser Anime läuft auf Japanisch
* mit deutschen Untertiteln" ist eine Aussage über die Serie, nicht über
* Folge 7. Sie bei jeder Folge erneut zu treffen wäre dieselbe Antwort
* zwanzigmal.
*
* **Im Browser und nicht auf dem Server.** Wie bei der Untertitelgrösse:
* dasselbe Konto am Fernseher und am Telefon darf verschiedene Antworten
* haben, und der Server hat damit nichts zu tun.
*/
const TON = 'flaro.serie.ton';
const TEXT = 'flaro.serie.text';
/** Was „keine Untertitel" im Speicher heisst — abgewählt ist eine Aussage. */
export const OHNE = 'keine';
function lesen(schluessel: string): Record<string, string> {
try {
const roh = localStorage.getItem(schluessel);
return roh ? (JSON.parse(roh) as Record<string, string>) : {};
} catch {
// Ein unlesbarer Eintrag ist kein Grund, keine Serie zu zeigen.
return {};
}
}
function schreiben(schluessel: string, werte: Record<string, string>): void {
try { localStorage.setItem(schluessel, JSON.stringify(werte)); } catch { /* Privates Fenster. */ }
}
export const serienton = (serie: string): string | null => lesen(TON)[serie] ?? null;
export function serientonMerken(serie: string, code: string | null): void {
const werte = lesen(TON);
if (code === null) delete werte[serie]; else werte[serie] = code;
schreiben(TON, werte);
}
export const serientext = (serie: string): string | null => lesen(TEXT)[serie] ?? null;
export function serientextMerken(serie: string, wunsch: string | null): void {
const werte = lesen(TEXT);
if (wunsch === null) delete werte[serie]; else werte[serie] = wunsch;
schreiben(TEXT, werte);
}
/** Die Spurnummer in dieser Datei, die zur gemerkten Sprache gehört. */
export function spurFuer(
spuren: { index: number; language: string | null }[],
sprache: string | null,
): number | null {
if (!sprache || sprache === OHNE) return null;
// Nur der Sprachteil: „de-DE" und „de" meinen dasselbe.
const blank = (s: string) => s.toLowerCase().split(/[-_]/)[0] ?? s;
return spuren.find(s => s.language && blank(s.language) === blank(sprache))?.index ?? null;
}
+176 -24
View File
@@ -1,6 +1,6 @@
import { ApiFehler, import { ApiFehler,
film as filmLaden, serie as serieLaden, folgen, film as filmLaden, serie as serieLaden, folgen,
vorschlaege, zuordnen, bildAdresse, istAdmin, gesehen, favorit, weglegen, vorschlaege, zuordnen, bildAdresse, istAdmin, gesehen, favorit, weglegen, serienstimmen,
type Film, type Serie, type Folge, type Datei, type Stand, type Film, type Serie, type Folge, type Datei, type Stand,
type Mitwirkender, type Spur, aehnliche, metadatenAuffrischen, scanstand, type Mitwirkender, type Spur, aehnliche, metadatenAuffrischen, scanstand,
} from '../api'; } from '../api';
@@ -14,6 +14,9 @@ import {
spieler, tonsprache, tonspracheMerken, spieler, tonsprache, tonspracheMerken,
untertitelsprache, untertitelspracheMerken, OHNE_UNTERTITEL, untertitelsprache, untertitelspracheMerken, OHNE_UNTERTITEL,
} from '../spieler'; } from '../spieler';
import {
serienton, serientonMerken, serientext, serientextMerken, spurFuer, OHNE,
} from '../sprachwahl';
import { menueAnZiel } from '../menue'; import { menueAnZiel } from '../menue';
import { meldedialog } from '../melden'; import { meldedialog } from '../melden';
@@ -112,6 +115,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
// **Mit Zustand.** Hier stand `null`, und darum wusste die // **Mit Zustand.** Hier stand `null`, und darum wusste die
// Knopfreihe einer Serie nie, ob sie abgehakt ist. // Knopfreihe einer Serie nie, ob sie abgehakt ist.
kopfbereich(eintrag, eintrag.state, null), kopfbereich(eintrag, eintrag.state, null),
serienspracheblock(eintrag),
await staffeln(eintrag), await staffeln(eintrag),
besetzung(eintrag.credits), besetzung(eintrag.credits),
zuordnungsbereich(eintrag.title)); zuordnungsbereich(eintrag.title));
@@ -353,6 +357,8 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
* Fassung und über jeder Folge; unbedingt gesetzt stünde der Melden-Knopf * Fassung und über jeder Folge; unbedingt gesetzt stünde der Melden-Knopf
* drei- bis sechsmal auf der Seite und meinte jedes Mal denselben Titel. */ * drei- bis sechsmal auf der Seite und meinte jedes Mal denselben Titel. */
melden?: () => void, melden?: () => void,
/** Zu welcher Serie die Folge gehört — gesetzt heisst: es ist eine Folge. */
serienId?: string,
): HTMLElement { ): HTMLElement {
const reihe = document.createElement('div'); const reihe = document.createElement('div');
reihe.className = 'aktionen'; reihe.className = 'aktionen';
@@ -394,12 +400,13 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
const angefangen = stand?.inProgress ?? stelle > 0; const angefangen = stand?.inProgress ?? stelle > 0;
const los = knopf(angefangen ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary'); const los = knopf(angefangen ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
los.prepend(icon('abspielen')); los.prepend(icon('abspielen'));
los.onclick = () => starten(datei, eintrag.id, titelzeile(eintrag), angefangen ? stelle : 0); los.onclick = () => starten(
datei, eintrag.id, titelzeile(eintrag), angefangen ? stelle : 0, serienId);
reihe.appendChild(los); reihe.appendChild(los);
if (angefangen) { if (angefangen) {
const vonVorn = knopf('Von vorn', 'btn'); const vonVorn = knopf('Von vorn', 'btn');
vonVorn.onclick = () => starten(datei, eintrag.id, titelzeile(eintrag), 0); vonVorn.onclick = () => starten(datei, eintrag.id, titelzeile(eintrag), 0, serienId);
reihe.appendChild(vonVorn); reihe.appendChild(vonVorn);
} }
@@ -409,11 +416,18 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
// einem Film, den man immer auf Englisch sehen will, jedes Mal von // einem Film, den man immer auf Englisch sehen will, jedes Mal von
// Neuem. Welche Spuren es gibt, steht hier ohnehin schon; gewählt wird // Neuem. Welche Spuren es gibt, steht hier ohnehin schon; gewählt wird
// also, bevor etwas läuft. // also, bevor etwas läuft.
//
// **Bei einer Folge nicht.** Dort gilt die Wahl der Serie, oben bei
// ihren Angaben. Zwei Orte für dieselbe Frage hiessen: an zwanzig
// Folgen zwanzigmal dasselbe beantworten, und am Ende weiss niemand,
// welcher der beiden gewonnen hat.
if (!serienId) {
const tonwahl = tonspurwahl(datei); const tonwahl = tonspurwahl(datei);
if (tonwahl) reihe.appendChild(tonwahl); if (tonwahl) reihe.appendChild(tonwahl);
const textwahl = untertitelwahl(datei); const textwahl = untertitelwahl(datei);
if (textwahl) reihe.appendChild(textwahl); if (textwahl) reihe.appendChild(textwahl);
}
// **Kein „Herunterladen" im Browser.** // **Kein „Herunterladen" im Browser.**
// //
@@ -829,39 +843,163 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
async function staffeln(serie: Serie): Promise<HTMLElement> { async function staffeln(serie: Serie): Promise<HTMLElement> {
const bereich = document.createElement('section'); const bereich = document.createElement('section');
serieKennung = serie.id;
const alle = await folgen(serie.id); const alle = await folgen(serie.id);
serie.seasons.forEach(nummer => { // **Eine Staffel zur Zeit, über ein Menü.**
// **Die Überschrift bekommt eine Zeile.** Eine Staffel ist das Einzige, //
// was man melden kann und was es als Eintrag nicht gibt — sie ist Serie // Vorher standen alle untereinander: nach Folge 8 kam wieder eine Folge
// plus Nummer. Also hängt der Knopf an ihrer Überschrift. // 1, und wer zur dritten Staffel wollte, scrollte an zwanzig Folgen
// vorbei. Die Überschrift ist jetzt die Wahl.
const nummern = serie.seasons.length > 0
? serie.seasons
: [...new Set(alle.items.map(f => f.season))].sort((a, b) => a - b);
let gewaehlt = nummern[0] ?? 1;
const kopf = document.createElement('div'); const kopf = document.createElement('div');
kopf.className = 'reihe-kopf'; kopf.className = 'reihe-kopf';
const wahl = knopf('', 'btn');
const h = document.createElement('h2'); const h = document.createElement('h2');
h.textContent = `Staffel ${nummer}`;
const melden = document.createElement('button'); const melden = document.createElement('button');
melden.className = 'pfeil'; melden.className = 'pfeil';
melden.style.marginLeft = 'auto'; melden.style.marginLeft = 'auto';
melden.innerHTML = icons.fahne; melden.innerHTML = icons.fahne;
melden.title = `Problem mit Staffel ${nummer} melden`;
melden.setAttribute('aria-label', `Problem mit Staffel ${nummer} melden`);
melden.onclick = () => meldedialog(
serie.id, `${serie.title} — Staffel ${nummer}`, nummer);
kopf.append(h, melden);
bereich.appendChild(kopf);
const liste = document.createElement('div'); const liste = document.createElement('div');
liste.className = 'einstellungen'; liste.className = 'einstellungen';
alle.items.filter(f => f.season === nummer).forEach(folge => {
liste.appendChild(folgenblock(folge)); const zeichnen = (): void => {
}); h.textContent = `Staffel ${gewaehlt}`;
bereich.appendChild(liste); wahl.textContent = `Staffel ${gewaehlt}`;
}); wahl.prepend(icon('runter'));
melden.title = `Problem mit Staffel ${gewaehlt} melden`;
melden.setAttribute('aria-label', `Problem mit Staffel ${gewaehlt} melden`);
melden.onclick = () => meldedialog(
serie.id, `${serie.title} — Staffel ${gewaehlt}`, gewaehlt);
liste.innerHTML = '';
alle.items.filter(f => f.season === gewaehlt)
.forEach(folge => liste.appendChild(folgenblock(folge)));
};
wahl.onclick = () => menueAnZiel(wahl, nummern.map(n => ({
text: `Staffel ${n}`,
icon: n === gewaehlt ? ('haken' as const) : undefined,
tun: () => { gewaehlt = n; zeichnen(); },
})));
// Bei einer einzigen Staffel gibt es nichts zu wählen — dann steht da
// die Überschrift und kein Menü, das nur sich selbst anbietet.
kopf.append(nummern.length > 1 ? wahl : h, melden);
zeichnen();
bereich.append(kopf, liste);
return bereich; return bereich;
} }
/** Ton- und Untertitelsprache für die ganze Serie.
*
* **Eine Aussage über die Serie, nicht über Folge 7.** „Dieser Anime läuft
* auf Japanisch mit deutschen Untertiteln" bei jeder Folge erneut zu
* treffen wäre dieselbe Antwort zwanzigmal. Deshalb steht die Wahl oben
* bei den Angaben zur Serie — und an den Folgen gar nicht mehr.
*/
function serienspracheblock(serie: Serie): HTMLElement {
const bereich = document.createElement('section');
bereich.className = 'serienstimmen';
const reihe = document.createElement('div');
reihe.className = 'knopfreihe';
bereich.appendChild(reihe);
const hinweis = document.createElement('div');
hinweis.className = 'hinweis';
bereich.appendChild(hinweis);
void (async () => {
let stimmen;
try { stimmen = await serienstimmen(serie.id); } catch { bereich.remove(); return; }
if (stimmen.audio.length === 0 && stimmen.subtitles.length === 0) {
bereich.remove();
return;
}
if (stimmen.audio.length > 0) {
reihe.appendChild(sprachknopf(
'tonspur', 'Ton', stimmen.audio,
() => serienton(serie.id),
code => serientonMerken(serie.id, code),
null));
}
if (stimmen.subtitles.length > 0) {
reihe.appendChild(sprachknopf(
'untertitel', 'Untertitel', stimmen.subtitles,
() => serientext(serie.id) ?? OHNE,
wunsch => serientextMerken(serie.id, wunsch),
'Keine'));
}
// **Was fehlt, wird gesagt.** Eine gekürzte Liste sieht sonst aus wie
// die ganze Wahrheit, und wer eine Sprache vermisst, die er in
// Staffel 1 gesehen hat, hält es für einen Fehler.
hinweis.textContent = stimmen.partial > 0
? 'Nur Sprachen, die in jeder Folge vorkommen. '
+ `${stimmen.partial} weitere gibt es nur in einem Teil der Folgen.`
: '';
})();
return bereich;
}
/** Ein Knopf mit Menü für eine Sprachwahl.
*
* `ohne` ist die Beschriftung der Nullwahl — beim Ton gibt es keine, beim
* Untertitel heisst sie „Keine". */
function sprachknopf(
bild: keyof typeof icons,
beschriftung: string,
sprachen: { code: string; name: string }[],
lesen: () => string | null,
merken: (wert: string | null) => void,
ohne: string | null,
): HTMLElement {
const k = knopf('', 'btn');
const zeichnen = (): void => {
const jetzt = lesen();
const name = jetzt === null || jetzt === OHNE
? (ohne ?? 'Automatisch')
: sprachen.find(x => x.code === jetzt)?.name ?? jetzt;
k.textContent = `${beschriftung}: ${name}`;
k.prepend(icon(bild));
};
zeichnen();
k.onclick = () => menueAnZiel(k, [
{
text: ohne ?? 'Automatisch',
icon: (lesen() ?? OHNE) === OHNE || lesen() === null
? ('haken' as const) : undefined,
tun: () => { merken(ohne === null ? null : OHNE); zeichnen(); },
},
...sprachen.map(sp => ({
text: sp.name,
icon: lesen() === sp.code ? ('haken' as const) : undefined,
tun: () => { merken(sp.code); zeichnen(); },
})),
]);
return k;
}
/** Zu welcher Serie die Folgen gehören — für die Sprachwahl beim Start. */
let serieKennung: string | undefined;
function folgenblock(folge: Folge): HTMLElement { function folgenblock(folge: Folge): HTMLElement {
const block = document.createElement('div'); const block = document.createElement('div');
block.className = 'einstellung'; block.className = 'einstellung';
@@ -919,7 +1057,8 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
} }
block.appendChild(aktionsreihe( block.appendChild(aktionsreihe(
{ ...({} as Film), id: folge.id, files: folge.files, { ...({} as Film), id: folge.id, files: folge.files,
publishAt: folge.publishAt ?? null } as Film, folge.state, datei)); publishAt: folge.publishAt ?? null } as Film,
folge.state, datei, undefined, serieKennung));
}); });
return block; return block;
} }
@@ -931,15 +1070,28 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
* **Der Spieler baut den Strom selbst auf**, weil er ihn auch wieder * **Der Spieler baut den Strom selbst auf**, weil er ihn auch wieder
* abbauen und bei jedem Wechsel der Auflösung neu anlegen muss. Ihn hier * abbauen und bei jedem Wechsel der Auflösung neu anlegen muss. Ihn hier
* fertig anzureichen hiesse, dieselbe Arbeit an zwei Stellen zu haben. */ * fertig anzureichen hiesse, dieselbe Arbeit an zwei Stellen zu haben. */
function starten(datei: Datei, titelId: string, name: string, ab: number): void { function starten(
datei: Datei, titelId: string, name: string, ab: number, serienId?: string,
): void {
// **Bei einer Folge gilt, was für die Serie eingestellt ist.** Die
// Nummern der Spuren unterscheiden sich von Folge zu Folge; gemerkt wird
// deshalb die Sprache, und hier wird sie zur Nummer dieser Datei.
const ton = serienId
? spurFuer(datei.streams.filter(s => s.kind === 'Audio'), serienton(serienId))
: gewaehlteSpur;
const text = serienId
? spurFuer(datei.streams.filter(s => s.kind === 'Subtitle'), serientext(serienId))
: gewaehlterText;
spieler({ spieler({
dateiId: datei.id, dateiId: datei.id,
titelId, titelId,
name, name,
ab, ab,
laenge: datei.durationSeconds, laenge: datei.durationSeconds,
tonspur: gewaehlteSpur, tonspur: ton,
untertitelspur: gewaehlterText, untertitelspur: text,
danach: laden, danach: laden,
}); });
} }