diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs
index 31ab5c8..b99bd36 100644
--- a/server/src/Flaro.Api/Endpoints/Contracts.cs
+++ b/server/src/Flaro.Api/Endpoints/Contracts.cs
@@ -656,7 +656,18 @@ public sealed record StreamRequest(
/// der Browser mehr als jede Abfrage vorher, und sein Urteil schlägt
/// seine eigene Auskunft.
///
- bool Transcode = false);
+ bool Transcode = false,
+
+ /// Um wie viele Millisekunden der Ton verschoben wird.
+ ///
+ ///
+ /// Für Dateien, bei denen es ab Werk nicht stimmt. Positiv heisst
+ /// später, negativ früher. Der Server schiebt dabei nicht am Bild, sondern
+ /// liest die Datei ein zweites Mal mit -itsoffset und nimmt von dort
+ /// nur den Ton — mit einer Eingabe verschöbe sich beides gemeinsam und
+ /// nichts wäre gewonnen.
+ ///
+ int AudioOffsetMs = 0);
/// Eine wählbare Spur, so wie sie im Menü steht.
public sealed record SpurAuskunft(int Index, string Label, string? Language, bool IsDefault);
diff --git a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
index cbfb0a8..8bb2e60 100644
--- a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
@@ -67,6 +67,7 @@ public static class StreamEndpoints
// zweites Mal unverändert hinzulegen wäre derselbe Fehler mit
// mehr Geduld.
if (hoehe is null && anfrage.AudioStream is null && !anfrage.Transcode
+ && anfrage.AudioOffsetMs == 0
&& await Durchreichbar(db, id, profil) is { } datei)
{
return Results.Ok(await Direktauskunft(db, datei, anfrage.StartSeconds ?? 0));
@@ -74,7 +75,8 @@ public static class StreamEndpoints
var sitzung = await koordinator.StartenAsync(
id, benutzer, profil, hoehe, anfrage.AudioStream, anfrage.StartSeconds ?? 0,
- erzwingen: anfrage.Transcode);
+ erzwingen: anfrage.Transcode,
+ tonversatz: Math.Clamp(anfrage.AudioOffsetMs, -5000, 5000) / 1000.0);
return Results.Ok(await Auskunft(db, sitzung));
}
diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs
index 5016467..8b7baba 100644
--- a/server/src/Flaro.Api/Program.cs
+++ b/server/src/Flaro.Api/Program.cs
@@ -75,6 +75,7 @@ builder.Services.AddRateLimiter(o =>
// und lässt einen Absturz dort den Server nicht mitnehmen.
builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
// Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu
diff --git a/server/src/Flaro.Api/Streamkoordinator.cs b/server/src/Flaro.Api/Streamkoordinator.cs
index 42a3496..2806ddd 100644
--- a/server/src/Flaro.Api/Streamkoordinator.cs
+++ b/server/src/Flaro.Api/Streamkoordinator.cs
@@ -61,6 +61,7 @@ public sealed class Streamkoordinator(
IServiceScopeFactory scopes,
FlaroPaths pfade,
FfmpegOptions ffmpeg,
+ Schluesselbilder schluesselbilder,
TimeProvider clock,
ILogger log) : IDisposable
{
@@ -100,7 +101,7 @@ public sealed class Streamkoordinator(
public async Task StartenAsync(
Guid dateiId, Guid benutzer, DeviceProfile profil,
int? hoehe, int? tonspur, double ab, CancellationToken ct = default,
- bool erzwingen = false)
+ bool erzwingen = false, double tonversatz = 0)
{
AbgelaufeneWegraeumen();
@@ -139,14 +140,31 @@ public sealed class Streamkoordinator(
var stufe = await bereich.ServiceProvider
.GetRequiredService().StufeAsync(ct);
+ // **Beim Kopieren wird auf das Schlüsselbild gerundet.**
+ //
+ // Ein kopierter Bildstrom lässt sich nicht mitten in einer Bildgruppe
+ // aufschneiden; ffmpeg beginnt beim Schlüsselbild davor, während der
+ // Ton neu gerechnet wird und genau beim Wunsch anfängt. Gemessen an
+ // einer Quelle mit Schlüsselbildern alle fünf Sekunden, Sprung auf
+ // Sekunde 12: Bild ab 0, Ton erst ab 1,98 — zwei Sekunden stummes
+ // Bild, und die Uhr des Spielers zwei Sekunden daneben.
+ //
+ // Beim Umkodieren entfällt das: dort wird das Bild neu gerechnet und
+ // schneidet auf die Sekunde genau. Dann vorzurunden hiesse, ohne Not
+ // ein Stück zurückzuspringen.
+ var gewuenscht = Math.Max(0, ab);
+ var anfang = plan.BildKopieren
+ ? await schluesselbilder.DavorAsync(datei.Path, gewuenscht, ct)
+ : gewuenscht;
+
var anfrage = new Stromanfrage(
- datei.Path, ordner, gelesen, plan, tonspur, Math.Max(0, ab), Hardware(), stufe);
+ datei.Path, ordner, gelesen, plan, tonspur, anfang, Hardware(), stufe, tonversatz);
// **Die tatsächlich benutzte Spur, nicht die gewünschte.** Sonst
// stünde im Menü bei keiner ein Haken, obwohl eine davon läuft — und
// bei einer Spur, die es nicht mehr gibt, bei der falschen.
var sitzung = new Stromsitzung(
- kennung, dateiId, benutzer, ordner, Math.Max(0, ab),
+ kennung, dateiId, benutzer, ordner, anfang,
plan.Hoehe, HlsArgumente.Tonspur(gelesen, tonspur)?.Index, plan.Art, clock.GetUtcNow());
var prozess = Starten(anfrage, sitzung);
@@ -273,7 +291,22 @@ public sealed class Streamkoordinator(
await prozess.WaitForExitAsync();
sitzung.Beendet = true;
- if (prozess.ExitCode == 0) return;
+ if (prozess.ExitCode == 0)
+ {
+ // **Auch ein geglückter Lauf darf etwas zu sagen haben.**
+ // Vorher wurde der Text weggeworfen, sobald der Rückgabewert 0
+ // war — und genau die Meldungen, die einen Tonversatz
+ // ankündigen, sind Warnungen, keine Fehler. Ein Film, der
+ // schief klingt, aber durchläuft, hinterliess damit nirgends
+ // eine Spur.
+ if (text.Trim() is { Length: > 0 } warnungen)
+ {
+ log.LogWarning("Strom {Kennung}: ffmpeg meldet {Warnungen}",
+ sitzung.Kennung, warnungen);
+ }
+
+ return;
+ }
sitzung.Fehler = text.Trim();
log.LogWarning("Strom {Kennung}: ffmpeg endete mit {Code}: {Fehler}",
diff --git a/server/src/Flaro.Media/HlsArgumente.cs b/server/src/Flaro.Media/HlsArgumente.cs
index 3407a98..2304e28 100644
--- a/server/src/Flaro.Media/HlsArgumente.cs
+++ b/server/src/Flaro.Media/HlsArgumente.cs
@@ -32,7 +32,12 @@ public sealed record Stromanfrage(
int? Tonspur = null,
double Ab = 0,
Beschleunigung Hardware = Beschleunigung.Software,
- Kodierstufe Stufe = Kodierstufe.Ausgewogen);
+ Kodierstufe Stufe = Kodierstufe.Ausgewogen,
+ ///
+ /// Um wie viele Sekunden der Ton gegenüber dem Bild verschoben wird.
+ /// Positiv heisst später, negativ früher. 0 heisst: nichts tun.
+ ///
+ double Tonversatz = 0);
/// Baut die ffmpeg-Zeile für einen laufenden Strom.
///
@@ -80,7 +85,12 @@ public static class HlsArgumente
public static List Fuer(Stromanfrage anfrage)
{
- var argumente = new List { "-y", "-loglevel", "error" };
+ // **`warning`, nicht `error`.** Genau die Meldungen, die einen
+ // Tonversatz ankündigen — „Non-monotonous DTS", „first pts value …
+ // forcing" —, sind Warnungen. Auf `error` waren sie unsichtbar, und
+ // ein Lauf, der schief aussieht, aber mit 0 endet, hinterliess keine
+ // Spur.
+ var argumente = new List { "-y", "-loglevel", "warning" };
// **Die Beschleunigung steht vor der Eingabe.** Danach wäre sie eine
// Angabe über die Ausgabe und bliebe wirkungslos — ffmpeg sagt dazu
@@ -103,6 +113,26 @@ public static class HlsArgumente
argumente.Add("-i");
argumente.Add(anfrage.Quelle);
+ // **Der Tonversatz braucht eine zweite Eingabe.** `-itsoffset` gilt
+ // für alles, was hinter ihm steht; mit nur einer Eingabe verschöbe es
+ // Bild und Ton gemeinsam und wäre damit wirkungslos. Dieselbe Datei
+ // ein zweites Mal zu öffnen kostet einen Demuxer und sonst nichts —
+ // gelesen wird ohnehin nur eine Spur daraus.
+ var zweiteEingabe = Math.Abs(anfrage.Tonversatz) > 0.0005;
+ if (zweiteEingabe)
+ {
+ argumente.Add("-itsoffset");
+ argumente.Add(anfrage.Tonversatz.ToString("0.###", CultureInfo.InvariantCulture));
+ if (anfrage.Ab > 0)
+ {
+ argumente.Add("-ss");
+ argumente.Add(anfrage.Ab.ToString("0.###", CultureInfo.InvariantCulture));
+ }
+
+ argumente.Add("-i");
+ argumente.Add(anfrage.Quelle);
+ }
+
var bild = anfrage.Spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Video);
var ton = Tonspur(anfrage.Spuren, anfrage.Tonspur);
@@ -111,7 +141,11 @@ public static class HlsArgumente
// deshalb über eine neue Sitzung. Untertitel kommen als eigene
// WebVTT-Datei daneben und brauchen den Strom gar nicht anzufassen.
if (bild is not null) { argumente.Add("-map"); argumente.Add($"0:{bild.Index}"); }
- if (ton is not null) { argumente.Add("-map"); argumente.Add($"0:{ton.Index}"); }
+ if (ton is not null)
+ {
+ argumente.Add("-map");
+ argumente.Add($"{(zweiteEingabe ? 1 : 0)}:{ton.Index}");
+ }
argumente.AddRange(Bildargumente(anfrage, bild));
argumente.AddRange(Tonargumente(anfrage, ton));
diff --git a/server/src/Flaro.Media/Schluesselbilder.cs b/server/src/Flaro.Media/Schluesselbilder.cs
new file mode 100644
index 0000000..21dc5f2
--- /dev/null
+++ b/server/src/Flaro.Media/Schluesselbilder.cs
@@ -0,0 +1,108 @@
+using System.Diagnostics;
+using System.Globalization;
+using Microsoft.Extensions.Logging;
+
+namespace Flaro.Media;
+
+/// Findet das Schlüsselbild, bei dem ein Strom wirklich anfängt.
+///
+///
+///
+/// Warum das überhaupt nötig ist. Beim Umpacken wird das Bild Byte für
+/// Byte übernommen, und ein kopierter Strom lässt sich nicht mitten in einer
+/// Bildgruppe aufschneiden. Wer bei Sekunde 754 einsteigt, bekommt deshalb
+/// Bild ab dem Schlüsselbild davor — der Ton dagegen wird neu gerechnet und
+/// fängt genau bei 754 an.
+///
+///
+/// Gemessen, nicht vermutet. An einer Quelle mit Schlüsselbildern alle
+/// fünf Sekunden, Sprung auf Sekunde 12: das Bild begann bei 0, der Ton erst
+/// bei 1,98 — zwei Sekunden stummes Bild am Anfang. Aneinander ausgerichtet
+/// waren beide dabei richtig; falsch war, was der Server dem Spieler über den
+/// Anfang sagte, und zwei Sekunden Stille sind eine Einladung an jeden
+/// Spieler, sie anders zu deuten.
+///
+///
+/// Also wird gerundet statt geraten. Der Sprung geht auf das
+/// Schlüsselbild, und derselbe Wert geht an den Spieler zurück. Dann fangen
+/// Bild und Ton an derselben Stelle an, es gibt keine Stille davor, und die
+/// Uhr stimmt. Dass man ein paar Sekunden früher einsteigt als gewünscht,
+/// fällt beim Weiterschauen niemandem auf — ein Tonversatz schon.
+///
+///
+public sealed class Schluesselbilder(FfmpegOptions optionen, ILogger log)
+{
+ /// Wie weit vor dem Wunsch gesucht wird.
+ ///
+ ///
+ /// Längere Bildgruppen als eine halbe Minute kommen vor, sind aber selten;
+ /// weiter zurückzulesen kostet bei jeder Sitzung Zeit für einen Fall, der
+ /// kaum eintritt. Findet sich nichts, bleibt es beim Wunsch — dann ist es
+ /// wie vorher und nicht schlechter.
+ ///
+ private static readonly TimeSpan Rueckschau = TimeSpan.FromSeconds(30);
+
+ /// Das Schlüsselbild bei oder vor .
+ public async Task DavorAsync(string pfad, double ab, CancellationToken ct = default)
+ {
+ if (ab <= 0) return 0;
+
+ var von = Math.Max(0, ab - Rueckschau.TotalSeconds);
+ var spanne = $"{Zahl(von)}%+{Zahl(ab - von + 0.5)}";
+
+ var start = new ProcessStartInfo(optionen.Ffprobe)
+ {
+ ArgumentList =
+ {
+ "-v", "error",
+ "-select_streams", "v:0",
+ // Nur Schlüsselbilder dekodieren — sonst liest ffprobe jedes
+ // einzelne Bild der halben Minute durch.
+ "-skip_frame", "nokey",
+ "-show_entries", "frame=pts_time",
+ "-of", "csv=p=0",
+ "-read_intervals", spanne,
+ pfad,
+ },
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ };
+
+ using var frist = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ frist.CancelAfter(optionen.ProbeTimeout);
+
+ try
+ {
+ using var prozess = Process.Start(start);
+ if (prozess is null) return ab;
+
+ var ausgabe = await prozess.StandardOutput.ReadToEndAsync(frist.Token);
+ await prozess.WaitForExitAsync(frist.Token);
+ if (prozess.ExitCode != 0) return ab;
+
+ var treffer = ausgabe
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(z => double.TryParse(z.TrimEnd(','), NumberStyles.Float,
+ CultureInfo.InvariantCulture, out var wert) ? wert : double.NaN)
+ .Where(w => !double.IsNaN(w) && w <= ab)
+ .ToList();
+
+ return treffer.Count > 0 ? treffer.Max() : ab;
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ // Dauert das Suchen länger als das Lesen der Datei erlaubt, ist der
+ // Wunsch die bessere Antwort als gar keine.
+ log.LogWarning("Schlüsselbildsuche in {Pfad} dauerte zu lange.", pfad);
+ return ab;
+ }
+ catch (Exception fehler) when (fehler is IOException or InvalidOperationException)
+ {
+ log.LogWarning(fehler, "Schlüsselbildsuche in {Pfad} ging schief.", pfad);
+ return ab;
+ }
+ }
+
+ private static string Zahl(double wert) => wert.ToString("0.###", CultureInfo.InvariantCulture);
+}
diff --git a/server/tests/Flaro.Api.Tests/StreamTests.cs b/server/tests/Flaro.Api.Tests/StreamTests.cs
index b7e96ff..6edc4e8 100644
--- a/server/tests/Flaro.Api.Tests/StreamTests.cs
+++ b/server/tests/Flaro.Api.Tests/StreamTests.cs
@@ -279,11 +279,22 @@ public class StreamTests
});
}
- /// Der Einstiegspunkt kommt zurück, damit der Spieler richtig zählt.
+ /// Der Einstiegspunkt kommt zurück — und zwar der echte.
///
///
+ ///
/// Sonst ginge jeder gemeldete Stand um den Vorlauf daneben. Der
/// Strom fängt bei null an, der Film bei Minute 30.
+ ///
+ ///
+ /// Und „echt" heisst: beim Schlüsselbild. Dieser Test stand vorher
+ /// auf „genau zwei Sekunden" und hielt damit einen Fehler fest. Ein
+ /// kopierter Bildstrom lässt sich nicht mitten in einer Bildgruppe
+ /// aufschneiden: der Strom fängt beim Schlüsselbild davor an, und wer
+ /// trotzdem „zwei" zurückmeldet, verschiebt die Uhr des Spielers um den
+ /// Unterschied. Diese Testdatei hat genau ein Schlüsselbild, bei null —
+ /// also ist null die richtige Antwort.
+ ///
///
[Test]
public async Task Der_Einstiegspunkt_steht_in_der_Antwort()
@@ -292,7 +303,11 @@ public class StreamTests
var (client, datei) = await Eingelesen();
var strom = await Strom(client, datei.Id, Browser(ab: 2));
- Assert.That(strom.StartSeconds, Is.EqualTo(2));
+
+ Assert.That(strom.StartSeconds, Is.LessThanOrEqualTo(2),
+ "Weiter als gewünscht darf der Strom nie anfangen — das überspränge Film.");
+ Assert.That(strom.StartSeconds, Is.EqualTo(0),
+ "Diese Datei hat nur ein Schlüsselbild, bei null.");
}
/// Schliessen räumt auf — sonst füllt sich der Kratzplatz.
diff --git a/server/tests/Flaro.Media.Tests/HlsLaufTests.cs b/server/tests/Flaro.Media.Tests/HlsLaufTests.cs
index fa3917c..efa235f 100644
--- a/server/tests/Flaro.Media.Tests/HlsLaufTests.cs
+++ b/server/tests/Flaro.Media.Tests/HlsLaufTests.cs
@@ -43,13 +43,15 @@ public class HlsLaufTests
AudioCodecs: new HashSet(StringComparer.OrdinalIgnoreCase) { "aac", "mp3" });
/// Führt die gebaute Zeile aus und gibt den Zielordner zurück.
- private string Laufen(string quelle, int? hoehe = null, int? tonspur = null, double ab = 0)
+ private string Laufen(
+ string quelle, int? hoehe = null, int? tonspur = null, double ab = 0, double tonversatz = 0)
{
var spuren = Spuren(quelle);
var ziel = Directory.CreateDirectory(Path.Combine(_ordner, "aus" + Guid.NewGuid().ToString("N")[..6])).FullName;
var plan = Stromentscheid.Fuer(Path.GetExtension(quelle).TrimStart('.'), spuren, Browser, hoehe);
- var argumente = HlsArgumente.Fuer(new Stromanfrage(quelle, ziel, spuren, plan, tonspur, ab));
+ var argumente = HlsArgumente.Fuer(
+ new Stromanfrage(quelle, ziel, spuren, plan, tonspur, ab, Tonversatz: tonversatz));
var start = new ProcessStartInfo(Testdateien.Suchen("ffmpeg")!)
{
@@ -190,6 +192,54 @@ public class HlsLaufTests
Assert.That(Directory.GetFiles(ziel, "*.m4s"), Is.Not.Empty);
}
+ /// Bild und Ton fangen an derselben Stelle an.
+ ///
+ ///
+ ///
+ /// Die Quelle hat genau ein Schlüsselbild, bei null. Ein Sprung auf
+ /// Sekunde 4 ist damit der schlimmste Fall: das kopierte Bild muss ganz
+ /// vorn anfangen, der neu gerechnete Ton könnte bei 4 anfangen — vier
+ /// Sekunden stummes Bild, und eine Uhr, die um vier Sekunden danebenliegt.
+ ///
+ ///
+ /// Gemessen an einer Quelle mit Schlüsselbildern alle fünf Sekunden und
+ /// einem Sprung auf Sekunde 12 war es genau so: Bild ab 0, Ton ab 1,98.
+ /// Deshalb rundet der Koordinator den Sprung auf das Schlüsselbild —
+ /// hier wird geprüft, dass die Zeile das auch verträgt.
+ ///
+ ///
+ [Test]
+ public void Bild_und_Ton_fangen_zusammen_an()
+ {
+ var quelle = Path.Combine(_ordner, "lang.mkv");
+ Lang(quelle);
+
+ // Auf dem Schlüsselbild, so wie der Koordinator es rundet.
+ var (bild, ton) = Anfang(Laufen(quelle, ab: 0));
+
+ Assert.That(Math.Abs(bild - ton), Is.LessThan(0.2),
+ $"Bild beginnt bei {bild}, Ton bei {ton} — das hört man.");
+ }
+
+ /// Der Tonversatz verschiebt den Ton, nicht das Bild.
+ [Test]
+ public void Ein_Tonversatz_verschiebt_nur_den_Ton()
+ {
+ var quelle = Path.Combine(_ordner, "lang.mkv");
+ Lang(quelle);
+
+ var (bildOhne, tonOhne) = Anfang(Laufen(quelle));
+ var (bildMit, tonMit) = Anfang(Laufen(quelle, tonversatz: 0.5));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(Math.Abs(bildMit - bildOhne), Is.LessThan(0.1),
+ "Das Bild darf sich dabei nicht bewegen.");
+ Assert.That(tonMit - tonOhne, Is.EqualTo(0.5).Within(0.1),
+ "Eine halbe Sekunde später heisst eine halbe Sekunde später.");
+ });
+ }
+
private static void Gross(string ziel) => Werkzeug(
[
"-y", "-loglevel", "error",
@@ -199,6 +249,39 @@ public class HlsLaufTests
ziel,
]);
+ /// Wo Bild und Ton im Ergebnis anfangen — in Sekunden.
+ ///
+ ///
+ /// Das ist die Prüfung, die gefehlt hat. Der Sprungtest daneben sah
+ /// nur nach, ob überhaupt Segmente entstehen — und lief dabei genau durch
+ /// den Fall, der schiefgehen kann, ohne hinzusehen.
+ ///
+ private static (double Bild, double Ton) Anfang(string ziel)
+ {
+ double Erste(string spur)
+ {
+ var start = new ProcessStartInfo(Testdateien.Suchen("ffprobe")!)
+ {
+ ArgumentList =
+ {
+ "-v", "error", "-select_streams", spur,
+ "-show_entries", "packet=pts_time", "-of", "csv=p=0",
+ Path.Combine(ziel, HlsArgumente.Playlist),
+ },
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ };
+ using var prozess = Process.Start(start)!;
+ var text = prozess.StandardOutput.ReadToEnd();
+ prozess.WaitForExit();
+
+ var erste = text.Split('\n', StringSplitOptions.RemoveEmptyEntries)[0].TrimEnd(',', '\r');
+ return double.Parse(erste, System.Globalization.CultureInfo.InvariantCulture);
+ }
+
+ return (Erste("v"), Erste("a"));
+ }
+
private static void Lang(string ziel) => Werkzeug(
[
"-y", "-loglevel", "error",
diff --git a/web/app/src/api.ts b/web/app/src/api.ts
index 7120125..6f7176e 100644
--- a/web/app/src/api.ts
+++ b/web/app/src/api.ts
@@ -643,6 +643,8 @@ export type Stromwunsch = {
startSeconds?: number;
/** Umkodieren, egal was das Profil sagt — für den zweiten Versuch. */
transcode?: boolean;
+ /** Um wie viele Millisekunden der Ton verschoben wird. Positiv: später. */
+ audioOffsetMs?: number;
};
/** Legt einen Strom an, der sofort anfängt.
diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts
index 1f62752..d6afc55 100644
--- a/web/app/src/spieler.ts
+++ b/web/app/src/spieler.ts
@@ -85,6 +85,43 @@ export function geraeteprofil(): {
/** Wie oft der Stand zum Server geht. */
const MELDEABSTAND = 10_000;
+/** In welchen Schritten sich der Tonversatz verstellen lässt. */
+const VERSATZSCHRITT = 100;
+
+/** Wie weit in beide Richtungen. Darüber hinaus ist es kein Versatz mehr,
+ * sondern eine kaputte Datei. */
+const VERSATZGRENZE = 5000;
+
+/** Wo gemerkte Tonversätze liegen — je Datei, in diesem Browser.
+ *
+ * **Je Datei, weil es eine Eigenschaft der Datei ist.** Wer einen Film einmal
+ * geradegerückt hat, soll das beim nächsten Mal nicht wieder tun. Und in
+ * diesem Browser, weil ein Versatz auch an der Anlage liegen kann — über
+ * Bluetooth-Kopfhörer stimmt etwas anderes als am Fernseher. */
+const VERSATZSCHLUESSEL = 'flaro.tonversatz';
+
+function versatzLesen(datei: string): number {
+ try {
+ const roh = JSON.parse(localStorage.getItem(VERSATZSCHLUESSEL) ?? '{}');
+ const wert = Number(roh?.[datei]);
+ return Number.isFinite(wert) ? wert : 0;
+ } catch { return 0; }
+}
+
+function versatzMerken(datei: string, wert: number): void {
+ try {
+ const roh = JSON.parse(localStorage.getItem(VERSATZSCHLUESSEL) ?? '{}');
+ if (wert === 0) delete roh[datei]; else roh[datei] = wert;
+ localStorage.setItem(VERSATZSCHLUESSEL, JSON.stringify(roh));
+ } catch { /* Privates Fenster — dann gilt er eben nur für diesen Film. */ }
+}
+
+/** „läuft mit", „200 ms später", „300 ms früher". */
+function versatzWort(ms: number): string {
+ if (ms === 0) return 'läuft mit dem Bild';
+ return ms > 0 ? `${ms} ms später` : `${-ms} ms früher`;
+}
+
/** Die Stufen, die im Menü stehen. Muss zu `Stromentscheid.Stufen` passen. */
const STUFEN: { wert: number | null; text: string }[] = [
{ wert: null, text: 'Original' },
@@ -145,6 +182,9 @@ export function spieler(anfrage: Spieleranfrage): void {
/** Wohin gesprungen wird, sobald die Metadaten da sind. Nur bei Direktwiedergabe. */
let zuSpringen = 0;
+ /** Der Tonversatz in Millisekunden — gemerkt je Datei. */
+ let tonversatz = versatzLesen(anfrage.dateiId);
+
/** Die gewählte Stufe und Spuren — sie überleben einen Neustart des Stroms. */
let stufe: number | null = null;
let tonspur: number | null = null;
@@ -458,6 +498,7 @@ export function spieler(anfrage: Spieleranfrage): void {
audioStream: tonspur,
startSeconds: ab,
transcode: umkodierenErzwingen,
+ audioOffsetMs: tonversatz,
});
} catch (fehler) {
zeigeFehler(meldung(fehler));
@@ -576,13 +617,37 @@ export function spieler(anfrage: Spieleranfrage): void {
*/
function menuesAuffrischen(): void {
const toene = auskunft?.audioStreams ?? [];
- tonmenue.setzen(
- toene.length > 0
- ? toene.map(s => ({
- text: s.label, gewaehlt: s.index === tonspur,
- tun: () => { tonspur = s.index; void stromAufbauen(filmzeit()); },
- }))
- : [{ text: 'Diese Datei hat keinen Ton.', gewaehlt: false, tun: () => {} }]);
+ const spuren = toene.length > 0
+ ? toene.map(s => ({
+ text: s.label, gewaehlt: s.index === tonspur,
+ tun: () => { tonspur = s.index; void stromAufbauen(filmzeit()); },
+ }))
+ : [{ text: 'Diese Datei hat keinen Ton.', gewaehlt: false, tun: () => {} }];
+
+ // **Der Versatz steht im Tonmenü, nicht in einem eigenen.** Wer ihn
+ // sucht, sucht ihn beim Ton — und ein vierter Knopf in der Leiste wäre
+ // einer für etwas, das man im Jahr dreimal braucht.
+ //
+ // Zwei Schritte und ein Zurück statt einer Liste: eine Aufzählung von
+ // −5000 bis +5000 in Hundertern wären einhunderteins Einträge, und man
+ // sucht ohnehin nicht einen Wert, sondern rückt ihn, bis es passt.
+ const verstellen = (um: number): void => {
+ tonversatz = Math.max(-VERSATZGRENZE, Math.min(VERSATZGRENZE, tonversatz + um));
+ versatzMerken(anfrage.dateiId, tonversatz);
+ void stromAufbauen(filmzeit());
+ };
+
+ // Ohne Tonspur gibt es nichts zu verschieben.
+ const versatzpunkte = toene.length === 0 ? [] : [
+ { text: `Ton ${versatzWort(tonversatz)}`, gewaehlt: false, tun: () => {} },
+ { text: `Später (+${VERSATZSCHRITT} ms)`, gewaehlt: false, tun: () => verstellen(VERSATZSCHRITT) },
+ { text: `Früher (−${VERSATZSCHRITT} ms)`, gewaehlt: false, tun: () => verstellen(-VERSATZSCHRITT) },
+ ...(tonversatz !== 0
+ ? [{ text: 'Versatz zurücksetzen', gewaehlt: false, tun: () => verstellen(-tonversatz) }]
+ : []),
+ ];
+
+ tonmenue.setzen([...spuren, ...versatzpunkte]);
const texte = auskunft?.subtitleStreams ?? [];
textmenue.setzen(