diff --git a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
index 8bb2e60..605f860 100644
--- a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
@@ -212,16 +212,24 @@ public static class StreamEndpoints
// So lassen sie sich während der Wiedergabe umschalten, ohne dass
// irgendetwas neu gerechnet wird. In einer fMP4-Segmentierung hätten
// sie ohnehin keinen Platz.
+ // **`ab` ist nicht geschmückt, sondern nötig.** Die Zeitstempel einer
+ // Untertitelspur zählen ab Dateianfang, der Strom zählt ab null —
+ // steigt jemand bei Minute 30 ein, liegt jeder Satz dreissig Minuten
+ // in dessen Zukunft, und zwar für den ganzen Rest des Films. Gemessen
+ // im Spieler: bei „Fortsetzen ab 8:37" stand der Satz von Sekunde 0
+ // auf dem Bild.
app.MapGet("/api/v1/files/{id:guid}/subtitles/{spur:int}.vtt", async (
- Guid id, int spur, FlaroContext db, UntertitelLeser leser, CancellationToken ct) =>
+ Guid id, int spur, FlaroContext db, UntertitelLeser leser,
+ CancellationToken ct, double ab = 0) =>
{
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct);
if (datei is null || !File.Exists(datei.Path)) return Results.NotFound();
var vtt = await leser.AlsWebVttAsync(datei.Path, spur, ct);
- return vtt is null
- ? Results.NotFound()
- : Results.Text(vtt, "text/vtt", Encoding.UTF8);
+ if (vtt is null) return Results.NotFound();
+
+ return Results.Text(
+ Untertitelversatz.Verschieben(vtt, Math.Max(0, ab)), "text/vtt", Encoding.UTF8);
}).RequireAuthorization();
}
diff --git a/server/src/Flaro.Media/Untertitelversatz.cs b/server/src/Flaro.Media/Untertitelversatz.cs
new file mode 100644
index 0000000..919aa60
--- /dev/null
+++ b/server/src/Flaro.Media/Untertitelversatz.cs
@@ -0,0 +1,114 @@
+using System.Globalization;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace Flaro.Media;
+
+/// Rückt WebVTT auf die Zeitachse eines Stroms, der mittendrin anfängt.
+///
+///
+///
+/// Zwei Uhren, und niemand hat sie verglichen. Die Zeitstempel einer
+/// Untertitelspur zählen ab Dateianfang — bei Minute 30 steht dort
+/// 00:30:00.000. Der Strom zählt dagegen ab null: steigt jemand bei
+/// Minute 30 ein, ist dessen Sekunde 0 die Filmminute 30. Der Browser
+/// vergleicht die Einblendzeiten aber ausschliesslich mit
+/// video.currentTime, und der kennt nur die Stromuhr.
+///
+///
+/// Gemessen im Spieler: „Fortsetzen ab 8:37" angeklickt, deutsche Spur
+/// gewählt — angezeigt wurde der Satz von Sekunde 0, bei einer Standanzeige
+/// von 8:38. Versatz 515 Sekunden, also genau der Einstiegspunkt. Und das
+/// ist der freundliche Fall: liegen die ersten Sätze eines Films hinter dem
+/// Vorspann, erscheint gar nichts mehr, weil jeder Einblendzeitpunkt hinter
+/// dem Ende des Stroms liegt. Genau so wurde es gemeldet — „auf keiner Spur
+/// kam Text".
+///
+///
+/// Hier verschoben und nicht im Browser. Im Browser hiesse es, die
+/// Spur selbst zu zerlegen und als VTTCue neu aufzubauen — ein eigener
+/// Parser für ein Format, das der Browser schon kann. Hier ist es eine
+/// Zeilenersetzung, und sie lässt sich prüfen.
+///
+///
+public static partial class Untertitelversatz
+{
+ /// Verschiebt alle Zeitstempel um Sekunden nach vorn.
+ ///
+ ///
+ /// Was ganz vor dem Einstieg liegt, fällt weg; was darüber hinwegreicht,
+ /// fängt bei null an. Ein Satz, der schon lief, als man einstieg, soll
+ /// stehen bleiben — abgeschnitten ist er immer noch lesbar.
+ ///
+ public static string Verschieben(string vtt, double ab)
+ {
+ if (ab <= 0 || string.IsNullOrEmpty(vtt)) return vtt;
+
+ var zeilen = vtt.Split('\n');
+ var heraus = new StringBuilder(vtt.Length);
+
+ // Ein Block ist das, was zwischen zwei Leerzeilen steht. Fällt seine
+ // Zeitzeile weg, fällt der ganze Block weg — sonst bliebe sein Text
+ // als Absatz ohne Zeitangabe stehen und der Browser verschluckt sich.
+ var block = new List();
+ var behalten = true;
+
+ foreach (var roh in zeilen)
+ {
+ var zeile = roh.TrimEnd('\r');
+
+ if (zeile.Trim().Length == 0)
+ {
+ if (behalten) foreach (var b in block) heraus.Append(b).Append('\n');
+ heraus.Append('\n');
+ block.Clear();
+ behalten = true;
+ continue;
+ }
+
+ if (Zeitzeile().Match(zeile) is { Success: true } treffer)
+ {
+ var von = Lesen(treffer.Groups["von"].Value) - ab;
+ var bis = Lesen(treffer.Groups["bis"].Value) - ab;
+
+ // Ganz vorbei, bevor es losging.
+ if (bis <= 0) { behalten = false; block.Clear(); continue; }
+
+ block.Add($"{Schreiben(Math.Max(0, von))} --> {Schreiben(bis)}"
+ + treffer.Groups["rest"].Value);
+ continue;
+ }
+
+ block.Add(zeile);
+ }
+
+ if (behalten) foreach (var b in block) heraus.Append(b).Append('\n');
+ return heraus.ToString();
+ }
+
+ /// „00:01:02.500" oder „01:02.500" — beide Formen sind erlaubt.
+ private static double Lesen(string wert)
+ {
+ var teile = wert.Replace(',', '.').Split(':');
+ double sekunden = 0;
+ foreach (var teil in teile)
+ {
+ sekunden = sekunden * 60
+ + double.Parse(teil, NumberStyles.Float, CultureInfo.InvariantCulture);
+ }
+
+ return sekunden;
+ }
+
+ /// Immer mit Stunden — die kurze Form ist erlaubt, die lange nie falsch.
+ private static string Schreiben(double sekunden)
+ {
+ var t = TimeSpan.FromSeconds(sekunden);
+ return string.Create(CultureInfo.InvariantCulture,
+ $"{(int)t.TotalHours:00}:{t.Minutes:00}:{t.Seconds:00}.{t.Milliseconds:000}");
+ }
+
+ [GeneratedRegex(
+ @"^(?\d{1,2}:\d{2}(?::\d{2})?[.,]\d{1,3})\s*-->\s*(?\d{1,2}:\d{2}(?::\d{2})?[.,]\d{1,3})(?.*)$")]
+ private static partial Regex Zeitzeile();
+}
diff --git a/server/tests/Flaro.Media.Tests/UntertitelversatzTests.cs b/server/tests/Flaro.Media.Tests/UntertitelversatzTests.cs
new file mode 100644
index 0000000..cdd16e9
--- /dev/null
+++ b/server/tests/Flaro.Media.Tests/UntertitelversatzTests.cs
@@ -0,0 +1,122 @@
+namespace Flaro.Media.Tests;
+
+/// Untertitel auf der Zeitachse eines Stroms, der mittendrin anfängt.
+///
+///
+/// Der Fehler, den diese Prüfung festhält. Die Zeitstempel zählen ab
+/// Dateianfang, der Strom zählt ab null. Beim Einstieg bei Minute 30 lag
+/// damit jeder Satz dreissig Minuten in der Zukunft — und weil ein Strom nur
+/// bis zum Filmende reicht, erschien überhaupt keiner mehr. Gemeldet wurde
+/// das als „auf keiner Spur kommt Text".
+///
+public class UntertitelversatzTests
+{
+ private const string Probe = """
+ WEBVTT
+
+ 1
+ 00:00:01.000 --> 00:00:04.000
+ Ganz am Anfang
+
+ 2
+ 00:00:09.000 --> 00:00:12.000
+ Über den Einstieg hinweg
+
+ 3
+ 00:00:20.000 --> 00:00:24.000
+ Weit dahinter
+
+ """;
+
+ [Test]
+ public void Ohne_Versatz_bleibt_alles_wie_es_war() =>
+ Assert.That(Untertitelversatz.Verschieben(Probe, 0), Is.EqualTo(Probe));
+
+ [Test]
+ public void Die_Zeiten_rutschen_um_den_Einstieg_nach_vorn()
+ {
+ var heraus = Untertitelversatz.Verschieben(Probe, 10);
+
+ Assert.That(heraus, Does.Contain("00:00:10.000 --> 00:00:14.000"),
+ "Sekunde 20 im Film ist Sekunde 10 in einem Strom, der bei 10 beginnt.");
+ }
+
+ /// Was schon vorbei war, fällt weg.
+ ///
+ ///
+ /// Und zwar samt seinem Text: bliebe der Absatz ohne Zeitzeile stehen,
+ /// hinge er als Fliesstext in der Datei und der Browser verschluckt sich
+ /// daran.
+ ///
+ [Test]
+ public void Was_vor_dem_Einstieg_endet_faellt_weg()
+ {
+ var heraus = Untertitelversatz.Verschieben(Probe, 10);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(heraus, Does.Not.Contain("Ganz am Anfang"));
+ Assert.That(heraus, Does.Not.Contain("00:00:01.000"));
+ });
+ }
+
+ /// Ein Satz, der beim Einstieg schon lief, bleibt stehen.
+ ///
+ ///
+ /// Abgeschnitten ist er immer noch lesbar — ihn wegzuwerfen hiesse, beim
+ /// Wiedereinstieg mitten in einem Dialog mit Stille zu beginnen.
+ ///
+ [Test]
+ public void Was_ueber_den_Einstieg_hinwegreicht_faengt_bei_null_an()
+ {
+ var heraus = Untertitelversatz.Verschieben(Probe, 10);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(heraus, Does.Contain("Über den Einstieg hinweg"));
+ Assert.That(heraus, Does.Contain("00:00:00.000 --> 00:00:02.000"));
+ });
+ }
+
+ [Test]
+ public void Der_Kopf_bleibt_stehen() =>
+ Assert.That(Untertitelversatz.Verschieben(Probe, 10), Does.StartWith("WEBVTT"));
+
+ /// Die kurze Schreibweise ohne Stunden ist erlaubt.
+ [Test]
+ public void Auch_ohne_Stundenangabe()
+ {
+ var kurz = "WEBVTT\n\n01:05.000 --> 01:09.000\nKurz geschrieben\n";
+
+ Assert.That(Untertitelversatz.Verschieben(kurz, 60),
+ Does.Contain("00:00:05.000 --> 00:00:09.000"));
+ }
+
+ /// Was hinter der Zeitzeile steht, bleibt dran.
+ ///
+ ///
+ /// Dort stehen Platzangaben wie line:90% align:center. Sie
+ /// abzuschneiden hiesse, erzwungene Untertitel mitten ins Bild zu setzen.
+ ///
+ [Test]
+ public void Die_Platzangaben_bleiben_erhalten()
+ {
+ var mitLage = "WEBVTT\n\n00:00:20.000 --> 00:00:24.000 line:90% align:center\nUnten\n";
+
+ Assert.That(Untertitelversatz.Verschieben(mitLage, 10),
+ Does.Contain("00:00:10.000 --> 00:00:14.000 line:90% align:center"));
+ }
+
+ /// Eine Spur, die ganz vor dem Einstieg liegt, wird leer — aber gültig.
+ [Test]
+ public void Eine_leere_Spur_bleibt_lesbares_WebVTT()
+ {
+ var heraus = Untertitelversatz.Verschieben(Probe, 3600);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(heraus, Does.StartWith("WEBVTT"));
+ Assert.That(heraus, Does.Not.Contain("-->"));
+ });
+ }
+}
diff --git a/web/app/src/api.ts b/web/app/src/api.ts
index 6f7176e..e74a912 100644
--- a/web/app/src/api.ts
+++ b/web/app/src/api.ts
@@ -661,8 +661,16 @@ export const stromStarten = (datei: string, wunsch: Stromwunsch) =>
export const stromBeenden = (sitzung: string) =>
anfrage(`/stream/${sitzung}`, { method: 'DELETE' });
-export const untertitelAdresse = (datei: string, spur: number) =>
- `/api/v1/files/${datei}/subtitles/${spur}.vtt`;
+/** Die Untertitelspur als WebVTT, passend zur Zeitachse des Stroms.
+ *
+ * **`vorlauf` ist nicht geschmückt, sondern nötig.** Die Zeitstempel in der
+ * Datei zählen ab Filmanfang, der Strom zählt ab null. Ohne diesen Wert lägen
+ * beim Einstieg in Minute 30 alle Einblendungen dreissig Minuten in der
+ * Zukunft — und weil der Strom nur bis zum Filmende reicht, käme kein
+ * einziger Satz mehr. */
+export const untertitelAdresse = (datei: string, spur: number, vorlauf = 0) =>
+ `/api/v1/files/${datei}/subtitles/${spur}.vtt`
+ + (vorlauf > 0 ? `?ab=${vorlauf.toFixed(3)}` : '');
/* ---------- Verwaltung ---------- */
diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts
index 2f83c8a..6114468 100644
--- a/web/app/src/spieler.ts
+++ b/web/app/src/spieler.ts
@@ -3,7 +3,7 @@ import {
stromStarten, stromBeenden, untertitelAdresse, fortschritt,
type Stromauskunft, type Spurauskunft,
} from './api';
-import { dauer, knopf, meldung } from './util';
+import { dauer, knopf, meldung, hinweisZeigen } from './util';
import { icons, icon } from './icons';
/* ---------- Was der Browser kann ---------- */
@@ -745,9 +745,23 @@ export function spieler(anfrage: Spieleranfrage): void {
const t = document.createElement('track');
t.kind = 'subtitles';
t.label = s.label;
- if (s.language) t.srclang = s.language.slice(0, 2);
- t.src = untertitelAdresse(anfrage.dateiId, s.index);
+ // **Nicht abschneiden.** `s.language` ist ein Dreibuchstabencode;
+ // „ger".slice(0, 2) ergibt „ge", und das ist keine Sprache — Deutsch
+ // wäre „de". Lieber keine Angabe als eine falsche: die Beschriftung
+ // steht ohnehin in `label`.
+ if (s.language?.length === 2) t.srclang = s.language;
+
+ // Mit dem Vorlauf: der Strom beginnt bei null, die Datei nicht.
+ t.src = untertitelAdresse(anfrage.dateiId, s.index, vorlauf);
t.dataset.spur = String(s.index);
+
+ // **Ein gescheiterter Abruf war bisher vollkommen stumm.** Der Haken
+ // stand im Menü, als liefe es — und niemand konnte unterscheiden, ob
+ // die Spur leer ist oder der Abruf schiefging.
+ t.onerror = () => {
+ if (untertitel === s.index) hinweisZeigen('Diese Untertitelspur liess sich nicht laden.', 'fehler');
+ };
+
video.appendChild(t);
});
if (untertitel !== null) untertitelWaehlen(untertitel);