diff --git a/Flaro/Views/Player/PlayerView.swift b/Flaro/Views/Player/PlayerView.swift index 39901b3..721ce3e 100644 --- a/Flaro/Views/Player/PlayerView.swift +++ b/Flaro/Views/Player/PlayerView.swift @@ -817,8 +817,17 @@ struct PlayerView: View { .animation(.easeIn(duration: 0.28), value: zeigtMarken) } + /// **Ein Intro ist ein Angebot, ein Abspann eine Frage.** + /// + /// „Intro überspringen" kommt von selbst und geht nach acht Sekunden + /// wieder: wer es nicht genommen hat, will zusehen. Im Abspann ist es + /// umgekehrt — „Nächste Folge" ist das, wofür man dort überhaupt + /// hinsieht, und der bleibt stehen. private func markenSichtbar(_ model: PlayerModel) -> Bool { - model.hasReachedEnd || showsControls || zeigtMarken + model.hasReachedEnd + || model.activeMarker?.isCredits == true + || showsControls + || zeigtMarken } /// Eine neue Marke zeigt ihre Knöpfe von selbst — für acht Sekunden. diff --git a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs index d6a0ac6..7b3a847 100644 --- a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs @@ -478,13 +478,10 @@ public static class StreamEndpoints StartSeconds: 0, Height: null, AudioStream: spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Audio)?.Index, - AudioStreams: spuren.Where(s => s.Kind == MediaStreamKind.Audio) - .Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault)) - .ToList(), - SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle) - .Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec)) - .Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault)) - .ToList()); + AudioStreams: Spurliste(spuren.Where(s => s.Kind == MediaStreamKind.Audio)), + SubtitleStreams: Spurliste(spuren + .Where(s => s.Kind == MediaStreamKind.Subtitle) + .Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec)))); } /// Das Geräteprofil aus der Anfrage — oder, wenn keines kam, Apples. @@ -549,13 +546,10 @@ public static class StreamEndpoints StartSeconds: sitzung.Ab, Height: sitzung.Hoehe, AudioStream: sitzung.Tonspur, - AudioStreams: spuren.Where(s => s.Kind == MediaStreamKind.Audio) - .Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault)) - .ToList(), - SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle) - .Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec)) - .Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault)) - .ToList()); + AudioStreams: Spurliste(spuren.Where(s => s.Kind == MediaStreamKind.Audio)), + SubtitleStreams: Spurliste(spuren + .Where(s => s.Kind == MediaStreamKind.Subtitle) + .Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec)))); } /// Bildbasierte Untertitel lassen sich nicht nach WebVTT wandeln. @@ -566,10 +560,36 @@ public static class StreamEndpoints /// nichts führt, ist schlechter als kein Eintrag. /// + /// Beschriftet eine Spurliste — unterscheidbar. + /// + /// + /// Das Auftrennen gleicher Namen steht in Benennung: die Apps + /// zeigen dieselben Listen, und zweimal formuliert driften sie + /// auseinander. + /// + private static List Spurliste(IEnumerable spuren) + { + var liste = spuren.ToList(); + var namen = Flaro.Core.Media.Benennung.Unterscheiden( + liste.Select(s => new Flaro.Core.Media.Benennung.Spurzeile( + Benennung(s), s.Codec, s.Index)).ToList()); + + return liste + .Select((s, i) => new SpurAuskunft(s.Index, namen[i], s.Language, s.IsDefault)) + .ToList(); + } + /// Wie eine Spur im Menü heisst. private static string Benennung(MediaStream spur) { - if (spur.Title is { Length: > 0 } eigen) return eigen; + // **Ein Spurname, der nur die Sprache wiederholt, ist keiner.** + // Viele Dateien tragen als Titel schlicht „deu" — durchgereicht + // stand genau das in der Auswahl, roh und dreibuchstabig, obwohl + // die Tabelle darunter „Deutsch" daraus gemacht hätte. + var istKuerzel = spur.Language is { Length: > 0 } kennung + && string.Equals(spur.Title?.Trim(), kennung.Trim(), StringComparison.OrdinalIgnoreCase); + + if (!istKuerzel && spur.Title is { Length: > 0 } eigen) return eigen; var sprache = Sprache(spur.Language); var teile = new List { sprache }; diff --git a/server/src/Flaro.Core/Media/Benennung.cs b/server/src/Flaro.Core/Media/Benennung.cs index 49a6db0..fe844e1 100644 --- a/server/src/Flaro.Core/Media/Benennung.cs +++ b/server/src/Flaro.Core/Media/Benennung.cs @@ -52,6 +52,55 @@ public static class Benennung return Sprachnamen.GetValueOrDefault(blank, blank.ToUpperInvariant()); } + /// Eine Spur, wie sie im Menü stünde — vor dem Vergleich. + public readonly record struct Spurzeile(string Name, string? Codec, int Nummer); + + /// Macht gleichnamige Spuren unterscheidbar. + /// + /// + /// + /// Zweimal „Deutsch" ist keine Wahl. Viele Dateien führen zwei + /// deutsche Untertitelspuren — Vollfassung und Nur-Fremdsprachiges. + /// Standen beide gleich da, musste man raten und es danach wieder + /// umstellen. + /// + /// + /// Warum das keine Spur für sich beantworten kann. Ob ein Name + /// eindeutig ist, hängt an den anderen — einzeln benannt weiss keine + /// Spur, dass es eine zweite mit demselben Namen gibt. Deshalb steht + /// das hier und nicht in . + /// + /// + /// Erst der Codec: „Deutsch · SRT" gegen „Deutsch · ASS" sagt etwas. + /// Hilft er nicht, hilft die Nummer — nichtssagend, aber sie trennt, + /// und das ist hier die Aufgabe. + /// + /// + public static IReadOnlyList Unterscheiden(IReadOnlyList zeilen) + { + var namen = zeilen.Select(z => z.Name).ToList(); + + Entwirren(i => Codec(zeilen[i].Codec)); + Entwirren(i => $"Spur {zeilen[i].Nummer}"); + + return namen; + + void Entwirren(Func zusatz) + { + var doppelt = namen + .Select((name, i) => (name, i)) + .GroupBy(x => x.name) + .Where(g => g.Count() > 1) + .SelectMany(g => g.Select(x => x.i)) + .ToList(); + + foreach (var i in doppelt) + { + if (zusatz(i) is { Length: > 0 } text) namen[i] = $"{namen[i]} · {text}"; + } + } + } + /// Die übliche Schreibweise für eine Kanalzahl. /// /// diff --git a/server/tests/Flaro.Core.Tests/SpurnamenTests.cs b/server/tests/Flaro.Core.Tests/SpurnamenTests.cs new file mode 100644 index 0000000..bff23cd --- /dev/null +++ b/server/tests/Flaro.Core.Tests/SpurnamenTests.cs @@ -0,0 +1,69 @@ +using Flaro.Core.Media; + +namespace Flaro.Core.Tests; + +/// Lassen sich zwei gleichnamige Spuren auseinanderhalten? +/// +/// +/// Der Fehler, für den diese Prüfung steht. In der Spurwahl standen +/// zweimal „deu" untereinander — gleicher Name, gleiches Aussehen, und die +/// einzige Möglichkeit herauszufinden, welche gemeint war, bestand darin, +/// eine zu nehmen und es danach wieder umzustellen. +/// +public class SpurnamenTests +{ + private static Benennung.Spurzeile Zeile(string name, string? codec = null, int nummer = 0) + => new(name, codec, nummer); + + [Test] + public void Eindeutige_Namen_bleiben_wie_sie_sind() + { + var namen = Benennung.Unterscheiden([Zeile("Deutsch"), Zeile("Englisch")]); + + Assert.That(namen, Is.EqualTo(new[] { "Deutsch", "Englisch" })); + } + + [Test] + public void Gleiche_Namen_bekommen_ihren_Codec() + { + var namen = Benennung.Unterscheiden( + [Zeile("Deutsch", "subrip", 2), Zeile("Deutsch", "ass", 3)]); + + Assert.That(namen, Is.EqualTo(new[] { "Deutsch · SRT", "Deutsch · ASS" })); + } + + /// Hilft der Codec nicht, hilft die Nummer. + /// + /// + /// Zwei deutsche SRT-Spuren in derselben Datei sind der Normalfall: + /// Vollfassung und Nur-Fremdsprachiges. „Spur 3" sagt nichts über den + /// Inhalt — aber es trennt, und darum geht es hier. + /// + [Test] + public void Hilft_der_Codec_nicht_hilft_die_Nummer() + { + var namen = Benennung.Unterscheiden( + [Zeile("Deutsch", "subrip", 2), Zeile("Deutsch", "subrip", 3)]); + + Assert.That(namen, Is.EqualTo(new[] { "Deutsch · SRT · Spur 2", "Deutsch · SRT · Spur 3" })); + } + + /// Ohne Codec geht es gleich über die Nummer. + [Test] + public void Ohne_Codec_trennt_die_Nummer() + { + var namen = Benennung.Unterscheiden([Zeile("Deutsch", null, 1), Zeile("Deutsch", null, 4)]); + + Assert.That(namen, Is.EqualTo(new[] { "Deutsch · Spur 1", "Deutsch · Spur 4" })); + } + + /// Drei gleiche auch. + [Test] + public void Drei_gleiche_Namen_werden_alle_drei_getrennt() + { + var namen = Benennung.Unterscheiden( + [Zeile("Deutsch", "subrip", 1), Zeile("Deutsch", "subrip", 2), Zeile("Deutsch", "ass", 3)]); + + Assert.That(namen, Is.Unique); + } +} diff --git a/web/app/src/hero.ts b/web/app/src/hero.ts index 95827f9..969d903 100644 --- a/web/app/src/hero.ts +++ b/web/app/src/hero.ts @@ -1,6 +1,6 @@ import { bildAdresse, type Angefangen, type Film, type Serie } from './api'; import { icons } from './icons'; -import { dauer, knopf } from './util'; +import { dauer, knopf, folgenname } from './util'; /** Was im Hero stehen kann — Film, Serie oder eine angefangene Folge. */ export type Heroeintrag = { @@ -29,6 +29,15 @@ export type Heroeintrag = { angefangen: boolean; /** Wem der Fortschritt gehört: bei einer Folge der Folge, nicht der Serie. */ standId: string; + + /** Bei einer Folge ihre Teile — für die Zeile über dem Spieler. + * + * **`titel` reicht dafür nicht.** Dort steht bei einer Folge der Name der + * *Serie*, weil der Aufmacher die Serie zeigt. Wer von hier aus startete, + * las über dem Spieler nur „Biohackers" und nicht, welche Folge läuft. */ + staffel: number | null; + folge: number | null; + folgentitel: string | null; }; const WECHSEL_MS = 9000; @@ -461,6 +470,9 @@ export function alsHero(eintrag: Film | Serie, art: 'filme' | 'serien'): Heroein bewertung: eintrag.rating, beschreibung: eintrag.overview, verfuegbarAb: eintrag.publishAt, + staffel: null, + folge: null, + folgentitel: null, dateiId: datei?.id ?? null, laenge: datei?.durationSeconds ?? null, stelle: eintrag.state?.positionSeconds ?? 0, @@ -492,10 +504,15 @@ export function angefangenAlsHero(eintrag: Angefangen): Heroeintrag { laufzeit: null, freigabe: null, bewertung: null, + // Ausgeschrieben, nicht „S1 · F2": der Aufmacher hat den Platz dafür, + // und zwei Buchstaben mit Zahl liest niemand gern. beschreibung: folge && eintrag.season !== null - ? `S${eintrag.season} · F${eintrag.episode}${eintrag.subTitle ? ` — ${eintrag.subTitle}` : ''}` + ? folgenname(null, eintrag.season, eintrag.episode, eintrag.subTitle) : null, verfuegbarAb: eintrag.publishAt ?? null, + staffel: folge ? eintrag.season : null, + folge: folge ? eintrag.episode : null, + folgentitel: folge ? eintrag.subTitle : null, dateiId: eintrag.fileId ?? null, laenge: eintrag.durationSeconds ?? eintrag.state.runtimeSeconds, stelle: eintrag.state.positionSeconds, diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts index 74c52fe..b744bea 100644 --- a/web/app/src/spieler.ts +++ b/web/app/src/spieler.ts @@ -852,15 +852,17 @@ export function spieler(anfrage: Spieleranfrage): void { if (kennung !== markenstandJetzt) markenAb = performance.now(); - // **Eine Marke dauert Minuten, ein Angebot nicht.** Die Knöpfe kommen - // von selbst, ohne dass man den Film antippen muss — aber ein Abspann - // läuft sechs Minuten, und so lange gehört kein Knopf über das Bild. - // Acht Sekunden von allein, danach nur noch zusammen mit der übrigen - // Bedienung: wer später doch überspringen will, bewegt die Maus. - const frisch = performance.now() - markenAb < 8000; + // **Ein Intro ist ein Angebot, ein Abspann eine Frage.** + // + // „Intro überspringen" kommt von selbst und geht nach acht Sekunden + // wieder: wer es nicht genommen hat, will zusehen, und dann gehört + // kein Knopf über das Bild. Im Abspann ist es umgekehrt. „Nächste + // Folge" ist das, wofür man dort überhaupt hinsieht — der bleibt + // stehen, bis die Folge zu Ende ist. + const bleibt = abspann || performance.now() - markenAb < 8000; markeLaeuft = drin != null; markenreihe.classList.toggle( - 'hidden', !drin || (!frisch && schicht.classList.contains('pl-still'))); + 'hidden', !drin || (!bleibt && schicht.classList.contains('pl-still'))); if (!drin || kennung === markenstandJetzt) return; markenstandJetzt = kennung; diff --git a/web/app/src/views/home.ts b/web/app/src/views/home.ts index 2956c48..17e8309 100644 --- a/web/app/src/views/home.ts +++ b/web/app/src/views/home.ts @@ -106,6 +106,12 @@ export function startseite( dateiId: e.dateiId, titelId: e.standId, name: e.titel, + // Bei einer Folge steht in `titel` der Name der Serie — der + // Aufmacher zeigt die Serie. Die Folge selbst kommt daneben. + serie: e.staffel != null ? e.titel : null, + staffel: e.staffel, + folge: e.folge, + folgentitel: e.folgentitel, ab: e.stelle, laenge: e.laenge, danach: () => void aufbauen(),