diff --git a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs index 286e7c0..f559c32 100644 --- a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs @@ -74,8 +74,17 @@ public static class StreamEndpoints // **Die Playlist.** Sie wächst, während ffmpeg schreibt. app.MapGet("/api/v1/stream/{sitzung:guid}/index.m3u8", async ( - Guid sitzung, Streamkoordinator koordinator, CancellationToken ct) => + Guid sitzung, HttpResponse antwort, Streamkoordinator koordinator, CancellationToken ct) => { + // **Diese Datei darf nie zwischengespeichert werden**, und das ist + // kein Vorsichtsmass, sondern aus einem Fehler gelernt: der Film + // blieb nach zwanzig Sekunden stehen, obwohl auf der Platte längst + // der ganze Rest lag. `Results.File` setzt `Last-Modified`, der + // Browser legte die Liste mit zwei Einträgen ab, und hls.js bekam + // bei jedem Nachfragen dieselben zwei zurück. Eine wachsende Liste + // ist das Gegenteil von zwischenspeicherbar. + antwort.Headers.CacheControl = "no-store, no-cache, must-revalidate"; + antwort.Headers.Pragma = "no-cache"; if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound(); koordinator.Lebenszeichen(sitzung); @@ -104,9 +113,10 @@ public static class StreamEndpoints statusCode: StatusCodes.Status500InternalServerError); } - if (Streamkoordinator.PlaylistBereit(jetzt.Ordner)) - return Results.File(Path.Combine(jetzt.Ordner, HlsArgumente.Playlist), - "application/vnd.apple.mpegurl"); + // **Als Inhalt, nicht als Datei.** `Results.File` hängt + // `Last-Modified` an und lädt damit zum Zwischenspeichern ein. + if (Streamkoordinator.Playlist(jetzt.Ordner) is { } liste) + return Results.Text(liste, "application/vnd.apple.mpegurl", Encoding.UTF8); // **Hört ffmpeg auf, ohne ein Segment geschrieben zu haben**, // kommt auch keines mehr. Weiterzuwarten hiesse, eine halbe @@ -129,7 +139,7 @@ public static class StreamEndpoints // **Die Segmente.** Jedes Abholen ist zugleich das Lebenszeichen. app.MapGet("/api/v1/stream/{sitzung:guid}/{datei}", ( - Guid sitzung, string datei, Streamkoordinator koordinator) => + Guid sitzung, string datei, HttpResponse antwort, Streamkoordinator koordinator) => { if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound(); @@ -148,9 +158,14 @@ public static class StreamEndpoints koordinator.Lebenszeichen(sitzung); var pfad = Path.Combine(laufend.Ordner, datei); - return File.Exists(pfad) - ? Results.File(pfad, "video/mp4", enableRangeProcessing: true) - : Results.NotFound(); + if (!File.Exists(pfad)) return Results.NotFound(); + + // **Segmente ändern sich nie.** Anders als die Liste: einmal + // geschrieben, bleibt eines, wie es ist — es darf also beliebig + // lange liegenbleiben. Das spart beim Zurückspringen den ganzen + // Weg über die Leitung. + antwort.Headers.CacheControl = "private, max-age=86400, immutable"; + return Results.File(pfad, "video/mp4", enableRangeProcessing: true); }).RequireAuthorization(); // **Schliessen heisst aufräumen.** Ohne das liefe ffmpeg weiter, bis diff --git a/server/src/Flaro.Api/Streamkoordinator.cs b/server/src/Flaro.Api/Streamkoordinator.cs index cacba0a..2dab9ed 100644 --- a/server/src/Flaro.Api/Streamkoordinator.cs +++ b/server/src/Flaro.Api/Streamkoordinator.cs @@ -193,10 +193,10 @@ public sealed class Streamkoordinator( /// erste Segment fertig ist; ein Spieler, der sie dann bekommt, sieht /// eine leere Liste und gibt auf. /// - public static bool PlaylistBereit(string ordner) + public static string? Playlist(string ordner) { var pfad = Path.Combine(ordner, HlsArgumente.Playlist); - if (!File.Exists(pfad)) return false; + if (!File.Exists(pfad)) return null; try { @@ -206,11 +206,18 @@ public sealed class Streamkoordinator( using var strom = new FileStream( pfad, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); using var leser = new StreamReader(strom); - return leser.ReadToEnd().Contains(".m4s", StringComparison.Ordinal); + var text = leser.ReadToEnd(); + + // Ohne Segment ist die Liste wertlos — der Spieler sähe eine + // leere Playlist und gäbe auf. + return text.Contains(".m4s", StringComparison.Ordinal) ? text : null; } - catch (IOException) { return false; } + catch (IOException) { return null; } } + /// Ob die Playlist schon etwas enthält, das sich abspielen lässt. + public static bool PlaylistBereit(string ordner) => Playlist(ordner) is not null; + /// Welche Beschleunigung benutzt wird. /// /// diff --git a/server/tests/Flaro.Api.Tests/StreamTests.cs b/server/tests/Flaro.Api.Tests/StreamTests.cs index fc4a639..b7e96ff 100644 --- a/server/tests/Flaro.Api.Tests/StreamTests.cs +++ b/server/tests/Flaro.Api.Tests/StreamTests.cs @@ -167,6 +167,38 @@ public class StreamTests }); } + /// Die Liste darf nicht zwischengespeichert werden, die Segmente schon. + /// + /// + /// Aus einem Fehler gelernt. Die Wiedergabe blieb nach zwanzig + /// Sekunden stehen, obwohl auf der Platte längst der ganze Rest lag: + /// Results.File setzte Last-Modified, der Browser legte die + /// Liste mit zwei Einträgen ab, und hls.js bekam bei jedem Nachfragen + /// dieselben zwei zurück. Eine wachsende Liste ist das Gegenteil von + /// zwischenspeicherbar — ein fertiges Segment dagegen ändert sich nie. + /// + [Test] + public async Task Die_Playlist_wird_nicht_zwischengespeichert() + { + Film("Zwischenspeicher (2020)"); + var (client, datei) = await Eingelesen(); + + var strom = await Strom(client, datei.Id, Browser()); + var liste = await client.GetAsync(strom.Url); + var segment = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/seg00000.m4s"); + + Assert.Multiple(() => + { + Assert.That(liste.Headers.CacheControl?.NoStore, Is.True, + "eine wachsende Liste darf nirgends liegenbleiben"); + Assert.That(liste.Content.Headers.LastModified, Is.Null, + "sonst fragt der Browser bedingt nach und bekommt 304 auf eine veraltete Liste"); + + Assert.That(segment.Headers.CacheControl?.MaxAge, Is.Not.Null, + "ein fertiges Segment darf liegenbleiben — das spart den Weg beim Zurückspringen"); + }); + } + /// Ein unbekannter Bildcodec führt zum Rechnen, nicht zum Umpacken. /// /// diff --git a/web/app/src/icons.ts b/web/app/src/icons.ts index 5c44a7d..6d73d09 100644 --- a/web/app/src/icons.ts +++ b/web/app/src/icons.ts @@ -61,10 +61,21 @@ export const icons = { vollbild: rahmen('' + ''), untertitel: rahmen('' - + ''), - zahnrad: rahmen('' - + ''), + + ''), + + /** Eine Wellenform — Tonspur, und nicht zu verwechseln mit der Lautstärke. */ + tonspur: rahmen(''), + + /** Schieberegler. **Vorher stand hier ein Zahnrad mit acht Strahlen** — das + * sah aus wie eine Sonne, und niemand vermutete die Bildqualität dahinter. */ + regler: rahmen('' + + ''), + + /** Den Spieler auf die Leiste unten schrumpfen. */ + verkleinern: rahmen(''), + + /** Und wieder aufs ganze Bild. */ + vergroessern: rahmen(''), } as const; export type IconName = keyof typeof icons; diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts index ee95f22..49aec4c 100644 --- a/web/app/src/spieler.ts +++ b/web/app/src/spieler.ts @@ -90,7 +90,19 @@ export type Spieleranfrage = { * Tonspuren noch Qualitätsstufen — und die Tonspurwahl ist der Grund, aus dem * es diesen Server überhaupt gibt. */ +/** Der eine Spieler, der gerade offen ist. + * + * **Es darf nur einen geben.** Seit ein Seitenwechsel ihn verkleinert statt + * ihn zu beenden, überlebt er das Blättern — und ohne diese Zeile legte der + * nächste Film einen zweiten darüber: zwei Videoelemente, zwei Tonspuren + * gleichzeitig und zwei ffmpeg-Sitzungen, von denen eine niemandem mehr + * gehört. + */ +let offenerSpieler: (() => void) | null = null; + export function spieler(anfrage: Spieleranfrage): void { + offenerSpieler?.(); + const schicht = document.createElement('div'); schicht.className = 'pl'; schicht.tabIndex = -1; @@ -128,13 +140,26 @@ export function spieler(anfrage: Spieleranfrage): void { name.className = 'pl-titel'; name.textContent = anfrage.name; + const kopfknoepfe = document.createElement('div'); + kopfknoepfe.className = 'pl-gruppe'; + + // **Verkleinern, nicht schliessen.** Wer nebenbei die Mediathek aufräumen + // will, soll den Film nicht verlieren. + const kleiner = document.createElement('button'); + kleiner.className = 'pl-rund'; + kleiner.innerHTML = icons.verkleinern; + kleiner.title = 'Verkleinern (Esc)'; + kleiner.setAttribute('aria-label', 'Verkleinern'); + kleiner.onclick = e => { e.stopPropagation(); kleinSetzen(true); }; + const zu = document.createElement('button'); zu.className = 'pl-rund'; zu.innerHTML = icons.schliessen; - zu.title = 'Schliessen (Esc)'; + zu.title = 'Schliessen'; zu.setAttribute('aria-label', 'Schliessen'); - zu.onclick = () => schliessen(); - kopf.append(name, zu); + zu.onclick = e => { e.stopPropagation(); schliessen(); }; + kopfknoepfe.append(kleiner, zu); + kopf.append(name, kopfknoepfe); const mitte = document.createElement('div'); mitte.className = 'pl-mitte'; @@ -145,7 +170,65 @@ export function spieler(anfrage: Spieleranfrage): void { const dreher = document.createElement('div'); dreher.className = 'spinner pl-dreher'; - schicht.append(video, dreher, mitte, kopf, bedienung, meldungsfeld); + /* ---------- Die kleine Fassung ---------- + * + * **Dasselbe Videoelement, nur anders eingerahmt.** + * + * Der naheliegende Weg wäre ein zweiter Spieler unten — und der wäre + * falsch: ein `