Zwei Dinge in derselben Ecke, und ein Modus, den niemand bestellt hat

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-05 20:31:12 +02:00
co-authored by Claude Opus 5
parent eda9e67802
commit 7df9fa6c01
7 changed files with 471 additions and 120 deletions
@@ -74,8 +74,17 @@ public static class StreamEndpoints
// **Die Playlist.** Sie wächst, während ffmpeg schreibt. // **Die Playlist.** Sie wächst, während ffmpeg schreibt.
app.MapGet("/api/v1/stream/{sitzung:guid}/index.m3u8", async ( 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(); if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound();
koordinator.Lebenszeichen(sitzung); koordinator.Lebenszeichen(sitzung);
@@ -104,9 +113,10 @@ public static class StreamEndpoints
statusCode: StatusCodes.Status500InternalServerError); statusCode: StatusCodes.Status500InternalServerError);
} }
if (Streamkoordinator.PlaylistBereit(jetzt.Ordner)) // **Als Inhalt, nicht als Datei.** `Results.File` hängt
return Results.File(Path.Combine(jetzt.Ordner, HlsArgumente.Playlist), // `Last-Modified` an und lädt damit zum Zwischenspeichern ein.
"application/vnd.apple.mpegurl"); 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**, // **Hört ffmpeg auf, ohne ein Segment geschrieben zu haben**,
// kommt auch keines mehr. Weiterzuwarten hiesse, eine halbe // kommt auch keines mehr. Weiterzuwarten hiesse, eine halbe
@@ -129,7 +139,7 @@ public static class StreamEndpoints
// **Die Segmente.** Jedes Abholen ist zugleich das Lebenszeichen. // **Die Segmente.** Jedes Abholen ist zugleich das Lebenszeichen.
app.MapGet("/api/v1/stream/{sitzung:guid}/{datei}", ( 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(); if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound();
@@ -148,9 +158,14 @@ public static class StreamEndpoints
koordinator.Lebenszeichen(sitzung); koordinator.Lebenszeichen(sitzung);
var pfad = Path.Combine(laufend.Ordner, datei); var pfad = Path.Combine(laufend.Ordner, datei);
return File.Exists(pfad) if (!File.Exists(pfad)) return Results.NotFound();
? Results.File(pfad, "video/mp4", enableRangeProcessing: true)
: 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(); }).RequireAuthorization();
// **Schliessen heisst aufräumen.** Ohne das liefe ffmpeg weiter, bis // **Schliessen heisst aufräumen.** Ohne das liefe ffmpeg weiter, bis
+11 -4
View File
@@ -193,10 +193,10 @@ public sealed class Streamkoordinator(
/// erste Segment fertig ist; ein Spieler, der sie dann bekommt, sieht /// erste Segment fertig ist; ein Spieler, der sie dann bekommt, sieht
/// eine leere Liste und gibt auf. /// eine leere Liste und gibt auf.
/// </remarks> /// </remarks>
public static bool PlaylistBereit(string ordner) public static string? Playlist(string ordner)
{ {
var pfad = Path.Combine(ordner, HlsArgumente.Playlist); var pfad = Path.Combine(ordner, HlsArgumente.Playlist);
if (!File.Exists(pfad)) return false; if (!File.Exists(pfad)) return null;
try try
{ {
@@ -206,11 +206,18 @@ public sealed class Streamkoordinator(
using var strom = new FileStream( using var strom = new FileStream(
pfad, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); pfad, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
using var leser = new StreamReader(strom); 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; }
} }
/// <summary>Ob die Playlist schon etwas enthält, das sich abspielen lässt.</summary>
public static bool PlaylistBereit(string ordner) => Playlist(ordner) is not null;
/// <summary>Welche Beschleunigung benutzt wird.</summary> /// <summary>Welche Beschleunigung benutzt wird.</summary>
/// ///
/// <remarks> /// <remarks>
@@ -167,6 +167,38 @@ public class StreamTests
}); });
} }
/// <summary>Die Liste darf nicht zwischengespeichert werden, die Segmente schon.</summary>
///
/// <remarks>
/// <b>Aus einem Fehler gelernt.</b> Die Wiedergabe blieb nach zwanzig
/// Sekunden stehen, obwohl auf der Platte längst der ganze Rest lag:
/// <c>Results.File</c> setzte <c>Last-Modified</c>, 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.
/// </remarks>
[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");
});
}
/// <summary>Ein unbekannter Bildcodec führt zum Rechnen, nicht zum Umpacken.</summary> /// <summary>Ein unbekannter Bildcodec führt zum Rechnen, nicht zum Umpacken.</summary>
/// ///
/// <remarks> /// <remarks>
+15 -4
View File
@@ -61,10 +61,21 @@ export const icons = {
vollbild: rahmen('<path d="M4 9V5h4"/><path d="M20 9V5h-4"/>' vollbild: rahmen('<path d="M4 9V5h4"/><path d="M20 9V5h-4"/>'
+ '<path d="M4 15v4h4"/><path d="M20 15v4h-4"/>'), + '<path d="M4 15v4h4"/><path d="M20 15v4h-4"/>'),
untertitel: rahmen('<rect x="3" y="5" width="18" height="14" rx="2"/>' untertitel: rahmen('<rect x="3" y="5" width="18" height="14" rx="2"/>'
+ '<path d="M7 14.5h4"/><path d="M13 14.5h4"/>'), + '<path d="M6.5 14.5h4"/><path d="M13.5 14.5h4"/><path d="M6.5 10h11"/>'),
zahnrad: rahmen('<circle cx="12" cy="12" r="3.2"/>'
+ '<path d="M12 2.8v2.4M12 18.8v2.4M4.5 7.5l2.1 1.2M17.4 15.3l2.1 1.2' /** Eine Wellenform — Tonspur, und nicht zu verwechseln mit der Lautstärke. */
+ 'M4.5 16.5l2.1-1.2M17.4 8.7l2.1-1.2"/>'), tonspur: rahmen('<path d="M4 10.5v3M8 7v10M12 4.5v15M16 8.5v7M20 11v2"/>'),
/** Schieberegler. **Vorher stand hier ein Zahnrad mit acht Strahlen** — das
* sah aus wie eine Sonne, und niemand vermutete die Bildqualität dahinter. */
regler: rahmen('<path d="M4 7h7M15 7h5M4 17h3M11 17h9"/>'
+ '<circle cx="13" cy="7" r="2.1"/><circle cx="9" cy="17" r="2.1"/>'),
/** Den Spieler auf die Leiste unten schrumpfen. */
verkleinern: rahmen('<path d="M5 9.5 12 16l7-6.5"/>'),
/** Und wieder aufs ganze Bild. */
vergroessern: rahmen('<path d="m5 14.5 7-6.5 7 6.5"/>'),
} as const; } as const;
export type IconName = keyof typeof icons; export type IconName = keyof typeof icons;
+180 -31
View File
@@ -90,7 +90,19 @@ export type Spieleranfrage = {
* Tonspuren noch Qualitätsstufen — und die Tonspurwahl ist der Grund, aus dem * Tonspuren noch Qualitätsstufen — und die Tonspurwahl ist der Grund, aus dem
* es diesen Server überhaupt gibt. * 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 { export function spieler(anfrage: Spieleranfrage): void {
offenerSpieler?.();
const schicht = document.createElement('div'); const schicht = document.createElement('div');
schicht.className = 'pl'; schicht.className = 'pl';
schicht.tabIndex = -1; schicht.tabIndex = -1;
@@ -128,13 +140,26 @@ export function spieler(anfrage: Spieleranfrage): void {
name.className = 'pl-titel'; name.className = 'pl-titel';
name.textContent = anfrage.name; 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'); const zu = document.createElement('button');
zu.className = 'pl-rund'; zu.className = 'pl-rund';
zu.innerHTML = icons.schliessen; zu.innerHTML = icons.schliessen;
zu.title = 'Schliessen (Esc)'; zu.title = 'Schliessen';
zu.setAttribute('aria-label', 'Schliessen'); zu.setAttribute('aria-label', 'Schliessen');
zu.onclick = () => schliessen(); zu.onclick = e => { e.stopPropagation(); schliessen(); };
kopf.append(name, zu); kopfknoepfe.append(kleiner, zu);
kopf.append(name, kopfknoepfe);
const mitte = document.createElement('div'); const mitte = document.createElement('div');
mitte.className = 'pl-mitte'; mitte.className = 'pl-mitte';
@@ -145,7 +170,65 @@ export function spieler(anfrage: Spieleranfrage): void {
const dreher = document.createElement('div'); const dreher = document.createElement('div');
dreher.className = 'spinner pl-dreher'; 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 `<video>`, das aus dem Dokument genommen und anderswo wieder
* eingehängt wird, hört auf zu spielen und fängt beim Einhängen neu zu
* puffern an. Beim Umkodieren hiesse das: eine neue Sitzung, ein neues
* ffmpeg, Sekunden schwarzes Bild. Stattdessen wechselt nur die Klasse an
* der Hülle, und das Video merkt nichts davon.
*/
let klein = false;
const kleinleiste = document.createElement('div');
kleinleiste.className = 'pl-kleinleiste';
const kleinName = document.createElement('span');
kleinName.className = 'pl-kleintitel';
kleinName.textContent = anfrage.name;
const kleinStand = document.createElement('span');
kleinStand.className = 'pl-kleinstand';
const kleinAb = document.createElement('button');
kleinAb.className = 'pl-rund';
kleinAb.innerHTML = icons.abspielen;
kleinAb.title = 'Abspielen';
kleinAb.setAttribute('aria-label', 'Abspielen');
kleinAb.onclick = e => { e.stopPropagation(); umschalten(); };
const kleinAuf = document.createElement('button');
kleinAuf.className = 'pl-rund';
kleinAuf.innerHTML = icons.vergroessern;
kleinAuf.title = 'Wieder gross';
kleinAuf.setAttribute('aria-label', 'Wieder gross');
kleinAuf.onclick = e => { e.stopPropagation(); kleinSetzen(false); };
const kleinZu = document.createElement('button');
kleinZu.className = 'pl-rund';
kleinZu.innerHTML = icons.schliessen;
kleinZu.title = 'Schliessen';
kleinZu.setAttribute('aria-label', 'Schliessen');
kleinZu.onclick = e => { e.stopPropagation(); schliessen(); };
const kleinBalken = document.createElement('div');
kleinBalken.className = 'pl-kleinbalken';
const kleinGefuellt = document.createElement('div');
kleinBalken.appendChild(kleinGefuellt);
const kleinText = document.createElement('div');
kleinText.className = 'pl-kleintext';
kleinText.append(kleinName, kleinStand);
kleinleiste.append(kleinBalken, kleinText, kleinAb, kleinAuf, kleinZu);
// Ein Klick auf die Leiste selbst holt den Film zurück — wie bei Plex.
kleinleiste.onclick = () => kleinSetzen(false);
schicht.append(video, dreher, mitte, kopf, bedienung, meldungsfeld, kleinleiste);
document.body.appendChild(schicht); document.body.appendChild(schicht);
document.body.classList.add('pl-offen'); document.body.classList.add('pl-offen');
@@ -190,10 +273,16 @@ export function spieler(anfrage: Spieleranfrage): void {
const vollbild = rundknopf('vollbild', 'Vollbild (f)', () => vollbildUmschalten()); const vollbild = rundknopf('vollbild', 'Vollbild (f)', () => vollbildUmschalten());
/** Die drei Menüs — Ton, Untertitel, Qualität. */ /** Die drei Menüs — Ton, Untertitel, Qualität.
const tonmenue = menue('ton', 'Tonspur'); *
* **Eigene Zeichen für jedes.** Der Lautsprecher stand vorher zweimal da:
* einmal fürs Stummschalten, einmal für die Tonspur. Und das Zahnrad mit
* acht Strahlen sah aus wie eine Sonne — niemand vermutete die Bildqualität
* dahinter. Jetzt: Wellenform für den Ton, CC-Kasten für die Untertitel,
* Schieberegler für die Qualität. */
const tonmenue = menue('tonspur', 'Tonspur');
const textmenue = menue('untertitel', 'Untertitel'); const textmenue = menue('untertitel', 'Untertitel');
const stufenmenue = menue('zahnrad', 'Qualität'); const stufenmenue = menue('regler', 'Qualität');
const links = document.createElement('div'); const links = document.createElement('div');
links.className = 'pl-gruppe'; links.className = 'pl-gruppe';
@@ -225,10 +314,17 @@ export function spieler(anfrage: Spieleranfrage): void {
zeitAuffrischen(); zeitAuffrischen();
}; };
video.onplay = () => { abspielknopf.innerHTML = icons.pause; abspielknopf.title = 'Pause (Leertaste)'; }; video.onplay = () => {
abspielknopf.innerHTML = icons.pause;
abspielknopf.title = 'Pause (Leertaste)';
kleinAb.innerHTML = icons.pause;
kleinAb.title = 'Pause';
};
video.onpause = () => { video.onpause = () => {
abspielknopf.innerHTML = icons.abspielen; abspielknopf.innerHTML = icons.abspielen;
abspielknopf.title = 'Abspielen (Leertaste)'; abspielknopf.title = 'Abspielen (Leertaste)';
kleinAb.innerHTML = icons.abspielen;
kleinAb.title = 'Abspielen';
melden(true); melden(true);
}; };
video.onwaiting = () => dreher.classList.remove('hidden'); video.onwaiting = () => dreher.classList.remove('hidden');
@@ -254,6 +350,9 @@ export function spieler(anfrage: Spieleranfrage): void {
// Der gepufferte Bereich als zweite Farbe in der Leiste. // Der gepufferte Bereich als zweite Farbe in der Leiste.
const anteil = laenge > 0 ? jetzt / laenge : 0; const anteil = laenge > 0 ? jetzt / laenge : 0;
zeitleiste.style.setProperty('--anteil', `${Math.min(100, anteil * 100)}%`); zeitleiste.style.setProperty('--anteil', `${Math.min(100, anteil * 100)}%`);
kleinStand.textContent = laenge > 0 ? `${dauer(jetzt)} / ${dauer(laenge)}` : dauer(jetzt);
kleinGefuellt.style.width = `${Math.min(100, anteil * 100)}%`;
} }
function umschalten(): void { function umschalten(): void {
@@ -261,6 +360,30 @@ export function spieler(anfrage: Spieleranfrage): void {
else video.pause(); else video.pause();
} }
/** Zwischen ganzem Bild und Leiste am unteren Rand.
*
* **Die Seite wird wieder bedienbar.** `body.pl-offen` sperrt den Bildlauf;
* in der kleinen Fassung muss er zurück, sonst liesse sich die Mediathek
* nicht durchblättern, während etwas läuft. Dafür bekommt die Seite unten
* Platz, damit die letzte Kachelreihe nicht unter der Leiste liegt.
*/
function kleinSetzen(an: boolean): void {
klein = an;
schicht.classList.toggle('pl-klein', an);
document.body.classList.toggle('pl-offen', !an);
document.body.classList.toggle('pl-leiste-da', an);
if (an) {
// Ein offenes Menü hätte in der Leiste keinen Platz.
bedienung.querySelectorAll('.pl-menue.auf').forEach(m => m.classList.remove('auf'));
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
} else {
zeigen();
schicht.focus();
}
zeitAuffrischen();
}
async function vollbildUmschalten(): Promise<void> { async function vollbildUmschalten(): Promise<void> {
try { try {
if (document.fullscreenElement) await document.exitFullscreen(); if (document.fullscreenElement) await document.exitFullscreen();
@@ -400,27 +523,41 @@ export function spieler(anfrage: Spieleranfrage): void {
/* ---------- Menüs ---------- */ /* ---------- Menüs ---------- */
/** Was in den drei Menüs steht.
*
* **Alle drei stehen immer da.**
*
* Vorher verschwand das Tonmenü bei nur einer Spur und das Untertitelmenü,
* wenn die Datei keine hat. Das war als Aufräumen gemeint und ging nach
* hinten los: wer sie sucht, findet nichts und weiss nicht, ob es sie nicht
* gibt oder ob er sie übersehen hat. Jetzt sagt das Menü es selbst —
* „Diese Datei hat nur eine Tonspur" ist eine Antwort, ein fehlender Knopf
* ist keine.
*/
function menuesAuffrischen(): void { function menuesAuffrischen(): void {
const toene = auskunft?.audioStreams ?? [];
tonmenue.setzen( tonmenue.setzen(
(auskunft?.audioStreams ?? []).map(s => ({ toene.length > 0
text: s.label, gewaehlt: s.index === tonspur, ? toene.map(s => ({
tun: () => { tonspur = s.index; void stromAufbauen(filmzeit()); }, text: s.label, gewaehlt: s.index === tonspur,
}))); tun: () => { tonspur = s.index; void stromAufbauen(filmzeit()); },
tonmenue.knopf.classList.toggle('hidden', (auskunft?.audioStreams.length ?? 0) < 2); }))
: [{ text: 'Diese Datei hat keinen Ton.', gewaehlt: false, tun: () => {} }]);
textmenue.setzen([ const texte = auskunft?.subtitleStreams ?? [];
{ text: 'Aus', gewaehlt: untertitel === null, tun: () => untertitelWaehlen(null) }, textmenue.setzen(
...(auskunft?.subtitleStreams ?? []).map(s => ({ texte.length > 0
text: s.label, gewaehlt: s.index === untertitel, ? [
tun: () => untertitelWaehlen(s.index), { text: 'Aus', gewaehlt: untertitel === null, tun: () => untertitelWaehlen(null) },
})), ...texte.map(s => ({
]); text: s.label, gewaehlt: s.index === untertitel,
textmenue.knopf.classList.toggle('hidden', (auskunft?.subtitleStreams.length ?? 0) === 0); tun: () => untertitelWaehlen(s.index),
})),
]
: [{ text: 'Keine Untertitel in dieser Datei.', gewaehlt: false, tun: () => {} }]);
stufenmenue.setzen(STUFEN.map(s => ({ stufenmenue.setzen(STUFEN.map(s => ({
text: s.wert === null && auskunft?.method === 'transcode' && auskunft.height text: s.text,
? 'Original'
: s.text,
gewaehlt: s.wert === stufe, gewaehlt: s.wert === stufe,
tun: () => { stufe = s.wert; void stromAufbauen(filmzeit()); }, tun: () => { stufe = s.wert; void stromAufbauen(filmzeit()); },
}))); })));
@@ -498,7 +635,11 @@ export function spieler(anfrage: Spieleranfrage): void {
switch (e.key) { switch (e.key) {
case ' ': case 'k': e.preventDefault(); umschalten(); break; case ' ': case 'k': e.preventDefault(); umschalten(); break;
case 'Escape': e.preventDefault(); schliessen(); break;
// **Escape verkleinert, es schliesst nicht.** Wer aus Versehen drückt,
// verliert damit nicht die Stelle im Film; zum Schliessen gibt es das
// Kreuz. Im Vollbild nimmt der Browser die Taste ohnehin für sich.
case 'Escape': e.preventDefault(); kleinSetzen(true); break;
case 'f': e.preventDefault(); void vollbildUmschalten(); break; case 'f': e.preventDefault(); void vollbildUmschalten(); break;
case 'm': e.preventDefault(); tonknopf.click(); break; case 'm': e.preventDefault(); tonknopf.click(); break;
case 'ArrowLeft': e.preventDefault(); void springen(Math.max(0, filmzeit() - 10)); break; case 'ArrowLeft': e.preventDefault(); void springen(Math.max(0, filmzeit() - 10)); break;
@@ -520,10 +661,14 @@ export function spieler(anfrage: Spieleranfrage): void {
schicht.addEventListener('keydown', tastendruck); schicht.addEventListener('keydown', tastendruck);
// **Wer zurückgeht, will nicht weiter zusehen.** Der Spieler hängt an // **Wer weiterblättert, verkleinert — er beendet nicht.**
// `document.body` und nicht am Inhaltsbereich; ohne diese Zeile blieb er //
// über der neuen Seite stehen, und ffmpeg lief dazu weiter. // Zuerst schloss der Spieler bei jedem Seitenwechsel: er hängt an
const beiNavigation = (): void => schliessen(); // `document.body` und stand sonst über der neuen Seite. Das war aber die
// falsche Hälfte der Lösung — wer nebenbei Plakate geraderückt, will den
// Film behalten. Jetzt rutscht er nach unten in die Leiste, und nur das
// Kreuz beendet ihn.
const beiNavigation = (): void => { if (!klein) kleinSetzen(true); };
window.addEventListener('hashchange', beiNavigation); window.addEventListener('hashchange', beiNavigation);
window.addEventListener('popstate', beiNavigation); window.addEventListener('popstate', beiNavigation);
@@ -552,8 +697,9 @@ export function spieler(anfrage: Spieleranfrage): void {
schicht.classList.add('pl-still'); schicht.classList.add('pl-still');
}, 3000); }, 3000);
} }
schicht.onmousemove = zeigen; schicht.onmousemove = () => { if (!klein) zeigen(); };
schicht.onclick = e => { schicht.onclick = e => {
if (klein) return;
// Ein Klick neben ein offenes Menü schliesst es, statt den Film anzuhalten. // Ein Klick neben ein offenes Menü schliesst es, statt den Film anzuhalten.
const offen = bedienung.querySelector('.pl-menue.auf'); const offen = bedienung.querySelector('.pl-menue.auf');
if (offen && !offen.contains(e.target as Node)) { offen.classList.remove('auf'); return; } if (offen && !offen.contains(e.target as Node)) { offen.classList.remove('auf'); return; }
@@ -565,6 +711,7 @@ export function spieler(anfrage: Spieleranfrage): void {
function schliessen(): void { function schliessen(): void {
if (geschlossen) return; if (geschlossen) return;
geschlossen = true; geschlossen = true;
if (offenerSpieler === schliessen) offenerSpieler = null;
video.pause(); video.pause();
// **Beendet melden, nicht nur den Stand.** Sonst steht der Film im // **Beendet melden, nicht nur den Stand.** Sonst steht der Film im
@@ -577,7 +724,7 @@ export function spieler(anfrage: Spieleranfrage): void {
window.removeEventListener('popstate', beiNavigation); window.removeEventListener('popstate', beiNavigation);
window.removeEventListener('pagehide', beiEntladen); window.removeEventListener('pagehide', beiEntladen);
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {}); if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
document.body.classList.remove('pl-offen'); document.body.classList.remove('pl-offen', 'pl-leiste-da');
schicht.remove(); schicht.remove();
anfrage.danach(); anfrage.danach();
} }
@@ -640,6 +787,8 @@ export function spieler(anfrage: Spieleranfrage): void {
/* ---------- Los ---------- */ /* ---------- Los ---------- */
offenerSpieler = schliessen;
menuesAuffrischen(); menuesAuffrischen();
zeitAuffrischen(); zeitAuffrischen();
schicht.focus(); schicht.focus();
+116 -13
View File
@@ -842,9 +842,79 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
text-align: center; line-height: 1.5; font-size: 15px; text-align: center; line-height: 1.5; font-size: 15px;
} }
/* Hinter dem Spieler soll nichts scrollen. */ /* Hinter dem Spieler soll nichts scrollen — aber nur im ganzen Bild. */
body.pl-offen { overflow: hidden; } body.pl-offen { overflow: hidden; }
/* ---------- Die kleine Fassung ----------
*
* **Wie bei Plex und YouTube: der Film rutscht nach unten, statt zu enden.**
* Wer nebenbei Plakate geraderückt oder etwas sucht, soll ihn nicht verlieren.
*
* Es ist dieselbe Hülle und dasselbe Videoelement — nur anders eingerahmt.
* Ein zweiter Spieler unten hiesse, das Video aus dem Dokument zu nehmen und
* woanders wieder einzuhängen; dabei hört es auf zu spielen und puffert neu.
*/
.pl.pl-klein {
inset: auto 0 0 0;
height: 72px;
/* **`place-items` muss ausdrücklich zurück.** Im ganzen Bild zentriert die
Hülle ihre Kinder; in der Leiste zog das die Beschriftung in die Mitte,
weit weg vom Vorschaubild, an dem sie hängt. */
display: block;
place-items: normal;
background: var(--chrome);
border-top: 1px solid var(--line);
box-shadow: 0 -8px 28px rgba(0, 0, 0, .45);
padding-bottom: env(safe-area-inset-bottom);
}
.pl.pl-klein .pl-video {
position: absolute; left: 0; top: 1px;
width: 128px; height: 71px;
object-fit: cover; cursor: pointer;
}
.pl.pl-klein .pl-kopf,
.pl.pl-klein .pl-leiste,
.pl.pl-klein .pl-dreher,
.pl.pl-klein .pl-meldung { display: none; }
.pl-kleinleiste { display: none; }
.pl.pl-klein .pl-kleinleiste {
display: flex; align-items: center; gap: var(--s-snug);
width: 100%; height: 100%; padding: 0 var(--s-regular) 0 140px;
cursor: pointer;
}
.pl-kleintext { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.pl-kleintitel {
font-size: 14px; font-weight: 600; color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pl-kleinstand {
font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums;
}
/* Der Fortschritt als dünner Strich ganz oben auf der Leiste. */
.pl-kleinbalken {
position: absolute; left: 0; right: 0; top: 0; height: 2px;
background: rgba(255, 255, 255, .14);
}
.pl-kleinbalken div { height: 100%; background: var(--accent); transition: width .25s linear; }
/* **Platz für die Leiste.** Ohne das läge die letzte Kachelreihe darunter,
und niemand käme an den letzten Film. */
body.pl-leiste-da #content { padding-bottom: 88px; }
body:has(.pl.pl-klein) .hinweise { bottom: 92px; }
body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; }
@media (max-width: 640px) {
.pl.pl-klein .pl-video { width: 96px; height: 71px; }
.pl.pl-klein .pl-kleinleiste { padding-left: 106px; }
.pl-kleinstand { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.pl-kleinbalken div { transition: none; }
}
@media (max-width: 640px) { @media (max-width: 640px) {
.pl-laut { display: none; } .pl-laut { display: none; }
.pl-rest { display: none; } .pl-rest { display: none; }
@@ -1298,8 +1368,14 @@ input[type="datetime-local"].feld::-webkit-calendar-picker-indicator:hover {
* *
* Rechtsklick findet nur, wer ihn vermutet, und auf dem Telefon gibt es ihn * Rechtsklick findet nur, wer ihn vermutet, und auf dem Telefon gibt es ihn
* gar nicht. Beim Überfahren und bei Tastaturfokus erscheinen drei Punkte. */ * gar nicht. Beim Überfahren und bei Tastaturfokus erscheinen drei Punkte. */
/* **Unten rechts, nicht oben rechts.**
*
* Oben rechts sitzt der Gesehen-Haken, und beide übereinander waren genau das,
* was auf dem Plakat wie ein Fehler aussah. Jetzt hat jede Ecke eine Aufgabe:
* oben links die Auswahl, oben rechts der Stand, unten links Abspielen, unten
* rechts das Menü. */
.kachel-mehr { .kachel-mehr {
position: absolute; right: 6px; top: 6px; z-index: 4; position: absolute; right: 6px; bottom: 6px; z-index: 4;
display: grid; place-items: center; display: grid; place-items: center;
width: 28px; height: 28px; border-radius: 50%; width: 28px; height: 28px; border-radius: 50%;
background: rgba(10, 10, 12, .78); background: rgba(10, 10, 12, .78);
@@ -1353,31 +1429,48 @@ input[type="datetime-local"].feld::-webkit-calendar-picker-indicator:hover {
/* ---------- Mehrfachauswahl in der Mediathek ---------- /* ---------- Mehrfachauswahl in der Mediathek ----------
* *
* **Die Marke sitzt oben links**, gegenüber von Haken und Folgenzahl. Beide * **Oben links, gegenüber vom Gesehen-Haken.** Jede Ecke des Plakats hat eine
* in dieselbe Ecke zu legen hiesse, dass die Auswahl verdeckt, was man * Aufgabe: oben links die Auswahl, oben rechts der Stand, unten links
* auswählt. */ * Abspielen, unten rechts das Menü. Zwei Dinge in derselben Ecke sahen auf
* dem Plakat wie ein Fehler aus.
*
* **Kein Modus, den man erst einschaltet.** Das Kästchen erscheint beim
* Überfahren — so wie die drei Punkte. Wer nichts damit zu tun hat, sieht es
* nie; wer etwas auswählen will, findet es dort, wo er ohnehin hinsieht. */
.wahlmarke { .wahlmarke {
position: absolute; top: 8px; left: 8px; z-index: 4; position: absolute; top: 8px; left: 8px; z-index: 5;
width: 26px; height: 26px; border-radius: 50%; width: 26px; height: 26px; border-radius: 50%;
display: grid; place-items: center; display: grid; place-items: center;
background: rgba(10, 10, 12, .62); background: rgba(10, 10, 12, .62);
border: 2px solid rgba(255, 255, 255, .72); border: 2px solid rgba(255, 255, 255, .72);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
transition: background .15s ease-out, border-color .15s ease-out; cursor: pointer;
opacity: 0;
transition: opacity .15s ease, background .15s ease-out, border-color .15s ease-out;
} }
.wahlmarke svg { width: 15px; height: 15px; color: #fff; opacity: 0; transition: opacity .15s; } .wahlmarke svg { width: 15px; height: 15px; color: #fff; opacity: 0; transition: opacity .15s; }
/* Beim Überfahren, bei Tastaturfokus — und durchgehend, solange überhaupt
etwas gewählt ist. Sonst müsste man für jede weitere Kachel erst wieder
das richtige Eck treffen. */
.kachel:hover .wahlmarke,
.kachel:focus-within .wahlmarke,
.raster.waehlt .wahlmarke,
.kachel.gewaehlt .wahlmarke { opacity: 1; }
.wahlmarke:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.kachel.gewaehlt .wahlmarke { .kachel.gewaehlt .wahlmarke {
background: var(--accent); border-color: var(--accent); background: var(--accent); border-color: var(--accent);
} }
.kachel.gewaehlt .wahlmarke svg { opacity: 1; } .kachel.gewaehlt .wahlmarke svg { opacity: 1; }
/* **Im Auswahlmodus kein Abspielknopf und kein Griff.** Beide führten /* **Solange etwas gewählt ist, führt kein Klick mehr woandershin.** Der
woandershin, und ein Klick darauf wählt jetzt nur noch aus. */ Abspielknopf und der Griff blieben sonst stehen und täten etwas anderes als
.raster.auswahl .schweber, das, was der Klick daneben tut. */
.raster.auswahl .kachel-mehr { display: none; } .raster.waehlt .schweber,
.raster.auswahl .kachel { cursor: pointer; } .raster.waehlt .kachel-mehr { display: none; }
.raster.auswahl .kachel:hover .plakat { transform: scale(1.02); } .raster.waehlt .kachel { cursor: pointer; }
.raster.waehlt .kachel:hover .plakat { transform: scale(1.02); }
/* Das Gewählte deutlich abheben — Rahmen und leichtes Abdunkeln, damit es /* Das Gewählte deutlich abheben — Rahmen und leichtes Abdunkeln, damit es
auch ohne Farbwahrnehmung zu erkennen ist. */ auch ohne Farbwahrnehmung zu erkennen ist. */
@@ -1417,3 +1510,13 @@ body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; }
@media (max-width: 640px) { @media (max-width: 640px) {
body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 148px; } body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 148px; }
} }
/* Die Leiste kommt mit dem ersten Haken — ohne Sprung. */
.auswahlleiste:not(.hidden) { animation: leiste-auf .18s ease-out; }
@keyframes leiste-auf { from { opacity: 0; transform: translate(-50%, 8px); } }
@media (max-width: 640px) {
@keyframes leiste-auf { from { opacity: 0; transform: translateY(8px); } }
}
@media (prefers-reduced-motion: reduce) {
.auswahlleiste:not(.hidden) { animation: none; }
}
+94 -60
View File
@@ -159,80 +159,109 @@ export function mediathek(
}; };
suchhuelle.appendChild(suchfeld); suchhuelle.appendChild(suchfeld);
// **Mehrere auf einmal.** „Die Staffel hab ich gesehen" hiess bisher
// zwanzigmal Kontextmenü. Der Knopf steht in der Filterzeile, weil er zu
// dem gehört, was man mit der Liste tut — nicht zu einer einzelnen Kachel.
const auswahlKnopf = document.createElement('button');
auswahlKnopf.className = 'zuruecksetzen';
auswahlKnopf.textContent = 'Auswählen';
auswahlKnopf.onclick = () => auswahlmodusSetzen(!auswahlmodus);
zeile.append(genrePille.huelle, standPille.huelle, sortierHuelle, zeile.append(genrePille.huelle, standPille.huelle, sortierHuelle,
anzahl, zuruecksetzen, auswahlKnopf, suchhuelle); anzahl, zuruecksetzen, suchhuelle);
const gitter = document.createElement('div'); const gitter = document.createElement('div');
gitter.className = 'raster'; gitter.className = 'raster';
ziel.append(zeile, gitter); ziel.append(zeile, gitter);
/* ---------- Auswahl ---------- */ /* ---------- Auswahl ----------
*
* **Kein Modus, den man erst einschalten muss.**
*
* Zuerst stand ein Knopf „Auswählen" in der Filterzeile. Zwei Dinge
* sprachen dagegen, und beide sind nachgemessen: der längere Text
* „Auswahl beenden" brach die Zeile um und schob das Raster um zwanzig
* Bildpunkte nach unten — ein Sprung mitten beim Anschauen. Und
* grundsätzlicher: ein Modus ist 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. Der erste Haken fängt die Auswahl an, der letzte beendet
* sie. Solange etwas gewählt ist, wählt auch ein Klick auf die Kachel
* selbst — sonst öffnet er wie immer die Detailseite.
*/
let auswahlmodus = false;
const gewaehlt = new Set<string>(); const gewaehlt = new Set<string>();
/** Was hinter jeder Kachel steht — nach Kennung, für Sammelaktionen. */ /** Was hinter jeder Kachel steht — nach Kennung, für Sammelaktionen. */
const bekannt = new Map<string, Kachel>(); const bekannt = new Map<string, Kachel>();
const leiste = auswahlleiste(); const leiste = auswahlleiste();
ziel.appendChild(leiste.element); ziel.appendChild(leiste.element);
/** Hängt einer Kachel die Wahlmarke an — oder nimmt sie wieder weg. */ /** Hängt einer Kachel das Kästchen an. Sichtbar wird es über die CSS. */
function markeAn(k: HTMLElement): void { function markeAn(k: HTMLElement): void {
const plakat = k.querySelector('.plakat'); const plakat = k.querySelector('.plakat');
if (!plakat || plakat.querySelector('.wahlmarke')) return; if (!plakat || plakat.querySelector('.wahlmarke')) return;
const m = document.createElement('span'); const m = document.createElement('span');
m.className = 'wahlmarke'; m.className = 'wahlmarke';
m.setAttribute('role', 'checkbox');
m.setAttribute('aria-checked', 'false');
m.setAttribute('aria-label', 'Auswählen');
m.setAttribute('tabindex', '0');
m.innerHTML = icons.haken; m.innerHTML = icons.haken;
plakat.appendChild(m); plakat.appendChild(m);
} }
function auswahlmodusSetzen(an: boolean): void { function umschalten(k: HTMLElement): void {
auswahlmodus = an;
gitter.classList.toggle('auswahl', an);
auswahlKnopf.textContent = an ? 'Auswahl beenden' : 'Auswählen';
auswahlKnopf.classList.toggle('gesetzt', an);
if (an) {
gitter.querySelectorAll<HTMLElement>('.kachel').forEach(markeAn);
} else {
gewaehlt.clear();
gitter.querySelectorAll('.wahlmarke').forEach(m => m.remove());
gitter.querySelectorAll('.kachel.gewaehlt').forEach(k => {
k.classList.remove('gewaehlt');
k.removeAttribute('aria-pressed');
});
}
leiste.auffrischen(gewaehlt.size, an);
}
// **Im Auswahlmodus fängt das Raster den Klick ab**, bevor er die Kachel
// erreicht. Sonst öffnete jeder Haken zugleich die Detailseite — und der
// Griff mit den drei Punkten ein Kontextmenü obendrein.
gitter.addEventListener('click', e => {
if (!auswahlmodus) return;
const k = (e.target as HTMLElement).closest<HTMLElement>('.kachel');
if (!k || !gitter.contains(k)) return;
e.preventDefault();
e.stopPropagation();
const id = k.dataset.id; const id = k.dataset.id;
if (!id) return; if (!id) return;
const jetztGewaehlt = !gewaehlt.has(id);
if (jetztGewaehlt) gewaehlt.add(id); else gewaehlt.delete(id); const jetzt = !gewaehlt.has(id);
k.classList.toggle('gewaehlt', jetztGewaehlt); if (jetzt) gewaehlt.add(id); else gewaehlt.delete(id);
k.setAttribute('aria-pressed', String(jetztGewaehlt)); k.classList.toggle('gewaehlt', jetzt);
leiste.auffrischen(gewaehlt.size, true); k.querySelector('.wahlmarke')?.setAttribute('aria-checked', String(jetzt));
nachAuswahl();
}
function nachAuswahl(): void {
// **Alle Kästchen bleiben stehen, solange etwas gewählt ist.** Sonst
// müsste man für jede weitere Kachel erst wieder das richtige Eck
// treffen.
gitter.classList.toggle('waehlt', gewaehlt.size > 0);
leiste.auffrischen(gewaehlt.size);
}
function abwaehlen(): void {
gewaehlt.clear();
gitter.querySelectorAll('.kachel.gewaehlt').forEach(k => {
k.classList.remove('gewaehlt');
k.querySelector('.wahlmarke')?.setAttribute('aria-checked', 'false');
});
nachAuswahl();
}
// **Das Raster fängt den Klick ab, bevor er die Kachel erreicht.**
// Sonst öffnete ein Haken zugleich die Detailseite.
gitter.addEventListener('click', e => {
const ziel = e.target as HTMLElement;
const k = ziel.closest<HTMLElement>('.kachel');
if (!k || !gitter.contains(k)) return;
// Das Kästchen wählt immer. Die Kachel selbst nur dann, wenn schon
// etwas gewählt ist — sonst bliebe die Mediathek unbenutzbar.
if (!ziel.closest('.wahlmarke') && gewaehlt.size === 0) return;
e.preventDefault();
e.stopPropagation();
umschalten(k);
}, true);
// Dieselbe Geste für die Tastatur.
gitter.addEventListener('keydown', e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const marke = (e.target as HTMLElement).closest('.wahlmarke');
if (!marke) return;
const k = marke.closest<HTMLElement>('.kachel');
if (!k) return;
e.preventDefault();
e.stopPropagation();
umschalten(k);
}, true); }, true);
/** Die Leiste am unteren Rand — nur sichtbar, solange ausgewählt wird. */ /** Die Leiste am unteren Rand — nur sichtbar, solange ausgewählt wird. */
function auswahlleiste(): { element: HTMLElement; auffrischen: (zahl: number, an: boolean) => void } { function auswahlleiste(): { element: HTMLElement; auffrischen: (zahl: number) => void } {
const b = document.createElement('div'); const b = document.createElement('div');
b.className = 'auswahlleiste hidden'; b.className = 'auswahlleiste hidden';
@@ -249,7 +278,6 @@ export function mediathek(
* zurück, bevor die erste durch ist, und die Meldung wäre geraten. */ * zurück, bevor die erste durch ist, und die Meldung wäre geraten. */
const aktion = (text: string, arbeit: (ids: string[]) => Promise<string>): HTMLButtonElement => { const aktion = (text: string, arbeit: (ids: string[]) => Promise<string>): HTMLButtonElement => {
const b = knopf(text, 'btn'); const b = knopf(text, 'btn');
b.dataset.braucht = 'auswahl';
b.onclick = async () => { b.onclick = async () => {
const ids = [...gewaehlt]; const ids = [...gewaehlt];
if (ids.length === 0) return; if (ids.length === 0) return;
@@ -286,20 +314,20 @@ export function mediathek(
// **Abwählen bleibt immer anklickbar** — es ist der Weg heraus. // **Abwählen bleibt immer anklickbar** — es ist der Weg heraus.
const wegKnopf = knopf('Abwählen', 'btn'); const wegKnopf = knopf('Abwählen', 'btn');
wegKnopf.onclick = () => auswahlmodusSetzen(false); wegKnopf.onclick = abwaehlen;
knoepfe.appendChild(wegKnopf); knoepfe.appendChild(wegKnopf);
b.append(zahl, knoepfe); b.append(zahl, knoepfe);
return { return {
element: b, element: b,
auffrischen: (anzahl, an) => { auffrischen: anzahl => {
b.classList.toggle('hidden', !an); // **Die Leiste kommt mit dem ersten Haken und geht mit dem
zahl.textContent = anzahl === 0 // letzten.** Sie leer stehen zu lassen hiesse, Platz für nichts zu
? 'Nichts ausgewählt' // belegen — und „Nichts ausgewählt" ist keine Auskunft, die jemand
: `${anzahlwort(anzahl)} ausgewählt`; // braucht.
knoepfe.querySelectorAll<HTMLButtonElement>('[data-braucht="auswahl"]') b.classList.toggle('hidden', anzahl === 0);
.forEach(x => { x.disabled = anzahl === 0; }); zahl.textContent = `${anzahlwort(anzahl)} ausgewählt`;
}, },
}; };
} }
@@ -411,7 +439,15 @@ export function mediathek(
skip: vonVorn ? 0 : geladen, take: SEITE, skip: vonVorn ? 0 : geladen, take: SEITE,
}); });
if (vonVorn) { gitter.innerHTML = ''; geladen = 0; amEnde = false; bekannt.clear(); } if (vonVorn) {
gitter.innerHTML = ''; geladen = 0; amEnde = false;
bekannt.clear();
// Nach einem Filterwechsel steht die alte Auswahl nicht mehr auf dem
// Bildschirm. Sie stillschweigend weiterzuführen hiesse, Titel zu
// bearbeiten, die niemand mehr sieht.
gewaehlt.clear();
nachAuswahl();
}
gesamt = seite.total; gesamt = seite.total;
geladen += seite.items.length; geladen += seite.items.length;
@@ -439,9 +475,7 @@ export function mediathek(
// muss die Kachel wissen, wie viele Folgen die Serie hat — sonst // muss die Kachel wissen, wie viele Folgen die Serie hat — sonst
// liesse sich die Plakette „noch 6 offen" nicht wiederherstellen. // liesse sich die Plakette „noch 6 offen" nicht wiederherstellen.
bekannt.set(beschreibung.id, beschreibung); bekannt.set(beschreibung.id, beschreibung);
// Nachgeladene Kacheln brauchen die Marke auch — sonst sind die markeAn(k);
// ersten zweihundert auswählbar und der Rest nicht.
if (auswahlmodus) markeAn(k);
gitter.appendChild(k); gitter.appendChild(k);
}); });