From 7df9fa6c013e2604e43f74053194b8f395702988 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 5 Oct 2026 20:31:12 +0200 Subject: [PATCH] Zwei Dinge in derselben Ecke, und ein Modus, den niemand bestellt hat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Gesehen-Haken und die drei Punkte sassen beide oben rechts — auf dem Plakat sah das aus wie ein Fehler. Jetzt hat jede Ecke eine Aufgabe: oben links die Auswahl, oben rechts der Stand, unten links Abspielen, unten rechts das Menü. Der Knopf „Auswählen" ist weg. Gemessen, warum er stören musste: der längere Text „Auswahl beenden" brach die Filterzeile um und schob das Raster um zwanzig Bildpunkte nach unten. Und grundsätzlicher ist ein Modus ein Zustand, den man kennen muss, bevor man etwas tun kann. Jetzt trägt jede Kachel oben links ein Kästchen, das beim Überfahren erscheint — wie die drei Punkte auch. Der erste Haken fängt die Auswahl an, der letzte beendet sie. Der Spieler lässt sich verkleinern. Er rutscht in eine Leiste am unteren Rand und läuft weiter, während man die Mediathek aufräumt; ein Klick darauf holt ihn zurück. Dasselbe Videoelement, nur anders eingerahmt — ein zweiter Spieler unten hiesse, das Video auszuhängen, und dabei hört es auf zu spielen. Das Zahnrad mit acht Strahlen sah aus wie eine Sonne. Jetzt: Wellenform für den Ton, CC-Kasten für die Untertitel, Schieberegler für die Qualität. Alle drei Menüs stehen immer da — vorher verschwanden sie bei nur einer Tonspur oder ohne Untertitel, und wer sie suchte, fand nichts und wusste nicht warum. Dabei gefunden und behoben: die Wiedergabe blieb nach zwanzig Sekunden stehen, obwohl längst der ganze Rest auf der Platte lag. Results.File setzte Last-Modified, der Browser legte die Playlist mit zwei Einträgen ab, und hls.js bekam bei jedem Nachfragen dieselben zwei zurück. Eine wachsende Liste ist das Gegenteil von zwischenspeicherbar. Co-Authored-By: Claude Opus 5 --- .../Flaro.Api/Endpoints/StreamEndpoints.cs | 31 ++- server/src/Flaro.Api/Streamkoordinator.cs | 15 +- server/tests/Flaro.Api.Tests/StreamTests.cs | 32 +++ web/app/src/icons.ts | 19 +- web/app/src/spieler.ts | 211 +++++++++++++++--- web/app/src/style.css | 129 +++++++++-- web/app/src/views/library.ts | 154 ++++++++----- 7 files changed, 471 insertions(+), 120 deletions(-) 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 `