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(),