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 `