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
+187 -35
View File
@@ -1,6 +1,6 @@
import { ApiFehler,
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 Mitwirkender, type Spur, aehnliche, metadatenAuffrischen, scanstand,
} from '../api';
@@ -14,6 +14,9 @@ import {
spieler, tonsprache, tonspracheMerken,
untertitelsprache, untertitelspracheMerken, OHNE_UNTERTITEL,
} from '../spieler';
import {
serienton, serientonMerken, serientext, serientextMerken, spurFuer, OHNE,
} from '../sprachwahl';
import { menueAnZiel } from '../menue';
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
// Knopfreihe einer Serie nie, ob sie abgehakt ist.
kopfbereich(eintrag, eintrag.state, null),
serienspracheblock(eintrag),
await staffeln(eintrag),
besetzung(eintrag.credits),
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
* drei- bis sechsmal auf der Seite und meinte jedes Mal denselben Titel. */
melden?: () => void,
/** Zu welcher Serie die Folge gehört — gesetzt heisst: es ist eine Folge. */
serienId?: string,
): HTMLElement {
const reihe = document.createElement('div');
reihe.className = 'aktionen';
@@ -394,12 +400,13 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
const angefangen = stand?.inProgress ?? stelle > 0;
const los = knopf(angefangen ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
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);
if (angefangen) {
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);
}
@@ -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
// Neuem. Welche Spuren es gibt, steht hier ohnehin schon; gewählt wird
// also, bevor etwas läuft.
const tonwahl = tonspurwahl(datei);
if (tonwahl) reihe.appendChild(tonwahl);
//
// **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);
if (tonwahl) reihe.appendChild(tonwahl);
const textwahl = untertitelwahl(datei);
if (textwahl) reihe.appendChild(textwahl);
const textwahl = untertitelwahl(datei);
if (textwahl) reihe.appendChild(textwahl);
}
// **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> {
const bereich = document.createElement('section');
serieKennung = serie.id;
const alle = await folgen(serie.id);
serie.seasons.forEach(nummer => {
// **Die Überschrift bekommt eine Zeile.** Eine Staffel ist das Einzige,
// was man melden kann und was es als Eintrag nicht gibt — sie ist Serie
// plus Nummer. Also hängt der Knopf an ihrer Überschrift.
const kopf = document.createElement('div');
kopf.className = 'reihe-kopf';
const h = document.createElement('h2');
h.textContent = `Staffel ${nummer}`;
// **Eine Staffel zur Zeit, über ein Menü.**
//
// Vorher 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 die Wahl.
const nummern = serie.seasons.length > 0
? serie.seasons
: [...new Set(alle.items.map(f => f.season))].sort((a, b) => a - b);
const melden = document.createElement('button');
melden.className = 'pfeil';
melden.style.marginLeft = 'auto';
melden.innerHTML = icons.fahne;
melden.title = `Problem mit Staffel ${nummer} melden`;
melden.setAttribute('aria-label', `Problem mit Staffel ${nummer} melden`);
let gewaehlt = nummern[0] ?? 1;
const kopf = document.createElement('div');
kopf.className = 'reihe-kopf';
const wahl = knopf('', 'btn');
const h = document.createElement('h2');
const melden = document.createElement('button');
melden.className = 'pfeil';
melden.style.marginLeft = 'auto';
melden.innerHTML = icons.fahne;
const liste = document.createElement('div');
liste.className = 'einstellungen';
const zeichnen = (): void => {
h.textContent = `Staffel ${gewaehlt}`;
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 ${nummer}`, nummer);
serie.id, `${serie.title} — Staffel ${gewaehlt}`, gewaehlt);
kopf.append(h, melden);
bereich.appendChild(kopf);
liste.innerHTML = '';
alle.items.filter(f => f.season === gewaehlt)
.forEach(folge => liste.appendChild(folgenblock(folge)));
};
const liste = document.createElement('div');
liste.className = 'einstellungen';
alle.items.filter(f => f.season === nummer).forEach(folge => {
liste.appendChild(folgenblock(folge));
});
bereich.appendChild(liste);
});
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;
}
/** 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 {
const block = document.createElement('div');
block.className = 'einstellung';
@@ -919,7 +1057,8 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
}
block.appendChild(aktionsreihe(
{ ...({} 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;
}
@@ -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
* abbauen und bei jedem Wechsel der Auflösung neu anlegen muss. Ihn hier
* 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({
dateiId: datei.id,
titelId,
name,
ab,
laenge: datei.durationSeconds,
tonspur: gewaehlteSpur,
untertitelspur: gewaehlterText,
tonspur: ton,
untertitelspur: text,
danach: laden,
});
}