diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 44084a7..b830f79 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -578,6 +578,23 @@ export const inhalt = (b: Bibliothek, q: Omit = {}) => export const folgen = (serie: string, staffel?: number) => anfrage>(`/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(`/series/${serie}/languages`); + export const film = (id: string) => anfrage(`/movies/${id}`); export const serie = (id: string) => anfrage(`/series/${id}`); diff --git a/web/app/src/sprachwahl.ts b/web/app/src/sprachwahl.ts new file mode 100644 index 0000000..e0c8a7a --- /dev/null +++ b/web/app/src/sprachwahl.ts @@ -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 { + try { + const roh = localStorage.getItem(schluessel); + return roh ? (JSON.parse(roh) as Record) : {}; + } catch { + // Ein unlesbarer Eintrag ist kein Grund, keine Serie zu zeigen. + return {}; + } +} + +function schreiben(schluessel: string, werte: Record): 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; +} diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index de5915c..0907c49 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -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 { 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, }); }