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(