From c46063637fee8080cb10f52b1e8c297a2b00bb4f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 5 Oct 2026 21:48:00 +0200 Subject: [PATCH] Die Kachel zeigte auf die Folge und wollte zur Serie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit „Nächste Folge" führte ins Leere, und es lag weder am Scan noch am Auffrischen: die Kachel trug die Folgenkennung und öffnete damit eine Serienseite — die es unter dieser Kennung nie gibt. Heraus kam ausgerechnet dort „Diese Serie gibt es nicht mehr", wo man gerade weiterschauen wollte. Der Server kannte die Serie an beiden Stellen und schickte sie nur nicht mit. Wohin die Kachel führt und was sie misst, ist jetzt getrennt. Beides auf dieselbe Kennung zu legen wäre der nächste Fehler gewesen: „Als gesehen markieren" hätte die ganze Serie abgehakt statt der einen Folge, die man zur Hälfte gesehen hat. Dazu Streichen wie im Dateimanager: gedrückt halten und darüberfahren wählt aus, rückwärts über Gewähltes nimmt wieder weg. Ein kurzer Klick bleibt, was er war. Abgetastet wird die Strecke zwischen zwei Zeigerereignissen, nicht nur deren Endpunkte — gemessen: ein Zug über vier Kacheln erwischte sonst die erste und die letzte. Plakate lassen sich nicht mehr durch die Gegend schleppen. „Abwählen" ist ein Kreuz geworden; als vierter Knopf neben drei Aktionen sah es aus, als täte es etwas mit den Titeln. In der kleinen Leiste lag das Vorschaubild über dem Fortschrittsstrich und verdeckte ihn rechts. Und ein Klick genau darauf tat nichts, obwohl das die Stelle ist, auf die man zeigt — jetzt holt auch das Bild den Film zurück. Co-Authored-By: Claude Opus 5 --- server/src/Flaro.Api/Endpoints/Contracts.cs | 17 +- .../Flaro.Api/Endpoints/PlaybackEndpoints.cs | 6 +- server/tests/Flaro.Api.Tests/PlaybackTests.cs | 80 ++++++++ web/app/src/api.ts | 3 + web/app/src/card.ts | 37 +++- web/app/src/spieler.ts | 10 +- web/app/src/style.css | 29 ++- web/app/src/views/library.ts | 184 +++++++++++++++++- 8 files changed, 350 insertions(+), 16 deletions(-) diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 1e604be..7a66beb 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -486,11 +486,26 @@ public sealed record ItemStateResponse( /// steht in Title die Serie und in SubTitle die Folge — so liest /// sich die Kachel wie bei Plex: oben, worum es geht, darunter, wo man ist. /// +/// +/// Die Kennung des Titels selbst — bei einer Folge also die der Folge. Dorthin +/// geht der Fortschritt. +/// +/// +/// Zu welcher Serie die Folge gehört — null bei einem Film. +/// +/// +/// Ohne diese Angabe führte die Kachel ins Leere. Sie trug die +/// Folgenkennung und sollte damit eine Serienseite öffnen; die gibt es unter +/// dieser Kennung nie, und heraus kam „Diese Serie gibt es nicht mehr" — +/// ausgerechnet unter „Nächste Folge", wo man gerade weiterschauen wollte. +/// +/// public sealed record ContinueEntry( Guid Id, string Kind, string Title, string? SubTitle, int? Season, int? Episode, int? Year, string? PosterImage, string? BackdropImage, - ItemStateResponse State); + ItemStateResponse State, + Guid? SeriesId = null); /// Eine laufende Wiedergabe — für das Dashboard. public sealed record SessionResponse( diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs index 702f6d6..7984bab 100644 --- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs @@ -318,7 +318,8 @@ public static class PlaybackEndpoints naechste.Id, "episode", serie.Title, naechste.Title, naechste.SeasonNumber, naechste.EpisodeNumber, null, serie.PosterImage, naechste.StillImage ?? serie.PosterImage, - zustand is null ? Leer() : Abbild(zustand))); + zustand is null ? Leer() : Abbild(zustand), + SeriesId: x.SeriesId)); } return Results.Ok(new Page(liste, liste.Count)); @@ -432,7 +433,8 @@ public static class PlaybackEndpoints else if (z.EpisodeId is { } folge && folgendaten.TryGetValue(folge, out var f)) liste.Add(new ContinueEntry( f.Id, "episode", f.SerienTitel, f.FolgenTitel, f.SeasonNumber, f.EpisodeNumber, null, - f.PosterImage, f.StillImage ?? f.PosterImage, Abbild(z))); + f.PosterImage, f.StillImage ?? f.PosterImage, Abbild(z), + SeriesId: f.SerienId)); } return liste; } diff --git a/server/tests/Flaro.Api.Tests/PlaybackTests.cs b/server/tests/Flaro.Api.Tests/PlaybackTests.cs index ec77a6f..6518c3e 100644 --- a/server/tests/Flaro.Api.Tests/PlaybackTests.cs +++ b/server/tests/Flaro.Api.Tests/PlaybackTests.cs @@ -556,6 +556,86 @@ public class PlaybackTests }); } + /// Eine Folge in „Weiterschauen" nennt ihre Serie. + /// + /// + /// Ohne diese Angabe führte die Kachel ins Leere. Sie trug die + /// Folgenkennung und öffnete damit eine Serienseite — die es unter dieser + /// Kennung nie gibt. Heraus kam „Diese Serie gibt es nicht mehr", + /// ausgerechnet unter „Nächste Folge", wo man gerade weiterschauen wollte. + /// Es lag nicht am Scan und nicht am Auffrischen: die Kachel hat von + /// Anfang an auf die falsche Kennung gezeigt. + /// + [Test] + public async Task Eine_angefangene_Folge_nennt_ihre_Serie() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var serie = serien!.Items[0]; + var folgen = await client.GetFromJsonAsync>( + $"/api/v1/series/{serie.Id}/episodes"); + + // Mittendrin stehenbleiben — dann steht die Folge in „Weiterschauen". + await Melden(client, folgen!.Items[0].Id, 600, 7200); + + var weiter = await client.GetFromJsonAsync>("/api/v1/home/continue"); + var eintrag = weiter!.Items.Single(); + + Assert.Multiple(() => + { + Assert.That(eintrag.Id, Is.EqualTo(folgen.Items[0].Id), "die Kennung bleibt die der Folge"); + Assert.That(eintrag.SeriesId, Is.EqualTo(serie.Id), "und daneben steht, wohin sie gehört"); + }); + } + + /// Dasselbe für „Nächste Folge". + [Test] + public async Task Die_naechste_Folge_nennt_ihre_Serie() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var serie = serien!.Items[0]; + var folgen = await client.GetFromJsonAsync>( + $"/api/v1/series/{serie.Id}/episodes"); + + // Die erste ganz gesehen — dann ist die zweite „die nächste". + await client.PutAsJsonAsync($"/api/v1/items/{folgen!.Items[0].Id}/watched", new WatchedRequest(true)); + + var naechste = await client.GetFromJsonAsync>("/api/v1/home/next"); + var eintrag = naechste!.Items.Single(); + + Assert.Multiple(() => + { + Assert.That(eintrag.Id, Is.EqualTo(folgen.Items[1].Id)); + Assert.That(eintrag.SeriesId, Is.EqualTo(serie.Id)); + }); + } + + /// Ein Film hat keine Serie — und behauptet es auch nicht. + [Test] + public async Task Ein_angefangener_Film_nennt_keine_Serie() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + await Melden(client, film, 600, 7200); + + var weiter = await client.GetFromJsonAsync>("/api/v1/home/continue"); + Assert.That(weiter!.Items.Single().SeriesId, Is.Null); + } + [Test] public async Task Die_naechste_Folge_richtet_sich_nach_Staffel_und_Nummer() { diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 58361f4..3f5b46c 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -78,11 +78,14 @@ export type Folge = { /** Eine Kachel in „Weiterschauen" oder „Nächste Folge" — Film wie Folge. */ export type Angefangen = { + /** Die Kennung des Titels selbst — bei einer Folge die der Folge. */ id: string; kind: 'movie' | 'episode'; title: string; subTitle: string | null; season: number | null; episode: number | null; year: number | null; posterImage: string | null; backdropImage: string | null; state: Stand; + /** Zu welcher Serie die Folge gehört — dorthin führt die Kachel. */ + seriesId: string | null; }; export type Bibliothek = { id: string; name: string; diff --git a/web/app/src/card.ts b/web/app/src/card.ts index 533679b..72b7bae 100644 --- a/web/app/src/card.ts +++ b/web/app/src/card.ts @@ -11,7 +11,14 @@ import { titeldialog } from './titel'; /** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */ export type Kachel = { + /** Wohin der Klick führt — bei einer Folge die Serie, zu der sie gehört. */ id: string; + /** Welche Kennung den Sehstand trägt. Fehlt sie, ist es `id`. + * + * **Nur dort verschieden, wo die Kachel woandershin führt als sie misst:** + * eine Folge aus „Weiterschauen" öffnet die Serie, abgehakt wird aber die + * Folge. */ + standId?: string; art: 'filme' | 'serien'; /** Die obere, fette Zeile. Bei einer Folge die Serie. */ titel: string; @@ -43,10 +50,25 @@ export const alsKachel = (e: Film | Serie): Kachel => ({ freigabe: e.publishAt, }); -/** Eine Kachel aus „Weiterschauen" — bei Folgen dreizeilig, wie bei Plex. */ +/** Eine Kachel aus „Weiterschauen" oder „Nächste Folge" — bei Folgen + * dreizeilig, wie bei Plex. + * + * **Die Kachel führt zur Serie, der Sehstand bleibt bei der Folge.** + * + * Hier stand die Folgenkennung, und die Kachel öffnete damit eine Serienseite + * — die es unter dieser Kennung nie gibt. Heraus kam „Diese Serie gibt es + * nicht mehr", ausgerechnet unter „Nächste Folge", wo man gerade + * weiterschauen wollte. Eine eigene Seite je Folge gibt es nicht; die Staffeln + * stehen auf der Serienseite, und dorthin gehört der Klick. + * + * **Beides auf dieselbe Kennung zu legen wäre der nächste Fehler gewesen:** + * „Als gesehen markieren" hätte dann die ganze Serie abgehakt statt der + * einen Folge, die man gerade zur Hälfte gesehen hat. + */ export const angefangenAlsKachel = (e: Angefangen): Kachel => e.kind === 'episode' ? { - id: e.id, art: 'serien', + id: e.seriesId ?? e.id, art: 'serien', + standId: e.id, titel: e.title, zweite: e.subTitle, dritte: `S${e.season} · F${e.episode}`, @@ -116,6 +138,10 @@ export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string) bild.src = adresse; bild.alt = ''; bild.loading = 'lazy'; + // **Kein Ziehen des Bildes.** Browser machen aus jedem Bild von sich aus + // ein Ziehgut; wer über die Kacheln streicht, um mehrere auszuwählen, + // schleppte sonst ein halbdurchsichtiges Plakat hinter sich her. + bild.draggable = false; // Erst einblenden, wenn es da ist — sonst blitzt der Platzhalter durch. bild.onload = () => rahmen.classList.add('geladen'); rahmen.appendChild(bild); @@ -259,7 +285,7 @@ function kachelpunkte( // **Die Antwort des Servers gilt, nicht die Annahme hier.** Bei einer // Serie zieht das Häkchen alle Folgen mit; was dabei herauskommt, // weiss nur der Server. - k.stand = await gesehen(k.id, !istGesehen); + k.stand = await gesehen(k.standId ?? k.id, !istGesehen); // **Die Kachel spiegelt den neuen Zustand selbst.** Die Liste neu zu // laden verlöre Bildlauf und Filter — für einen Haken. k.offen = istGesehen ? (k.gesamt ?? k.offen) : 0; @@ -271,7 +297,7 @@ function kachelpunkte( icon: 'stern', tun: async () => { const neuerWert = !k.stand?.favourite; - k.stand = await favorit(k.id, neuerWert); + k.stand = await favorit(k.standId ?? k.id, neuerWert); }, }, ]; @@ -282,7 +308,7 @@ function kachelpunkte( punkte.push({ text: 'Aus Weiterschauen nehmen', tun: async () => { - await weglegen(k.id); + await weglegen(k.standId ?? k.id); if (k.stand) k.stand.dismissed = true; knopf.querySelector('.fortschritt')?.remove(); }, @@ -361,6 +387,7 @@ async function kachelAuffrischen(k: Kachel, knopf: HTMLElement): Promise { const bild = document.createElement('img'); bild.src = adresse; bild.alt = ''; + bild.draggable = false; bild.onload = () => plakat.classList.add('geladen'); plakat.prepend(bild); } diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts index 49aec4c..999ee21 100644 --- a/web/app/src/spieler.ts +++ b/web/app/src/spieler.ts @@ -699,7 +699,15 @@ export function spieler(anfrage: Spieleranfrage): void { } schicht.onmousemove = () => { if (!klein) zeigen(); }; schicht.onclick = e => { - if (klein) return; + // **In der Leiste holt auch das Bild den Film zurück.** + // + // Die Leiste selbst tat es schon, das Vorschaubild liegt aber darüber und + // gehört nicht zu ihr — ein Klick genau darauf tat deshalb nichts. Das ist + // ausgerechnet die Stelle, auf die man zeigt. + if (klein) { + if (e.target === video || e.target === schicht) kleinSetzen(false); + return; + } // Ein Klick neben ein offenes Menü schliesst es, statt den Film anzuhalten. const offen = bedienung.querySelector('.pl-menue.auf'); if (offen && !offen.contains(e.target as Node)) { offen.classList.remove('auf'); return; } diff --git a/web/app/src/style.css b/web/app/src/style.css index b8f7627..a59742d 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -867,9 +867,14 @@ body.pl-offen { overflow: hidden; } box-shadow: 0 -8px 28px rgba(0, 0, 0, .45); padding-bottom: env(safe-area-inset-bottom); } +/* **Unter dem Fortschrittsstrich, nicht darüber.** + * + * Das Bild lag bündig am oberen Rand und schob sich über den Strich — auf der + * rechten Seite, wo noch nichts gefüllt war, verschwand er ganz darunter. + * Zwei Bildpunkte Abstand und eine Ebene darunter, dann läuft er durch. */ .pl.pl-klein .pl-video { - position: absolute; left: 0; top: 1px; - width: 128px; height: 71px; + position: absolute; left: 0; top: 2px; z-index: 0; + width: 128px; height: 70px; object-fit: cover; cursor: pointer; } .pl.pl-klein .pl-kopf, @@ -894,7 +899,7 @@ body.pl-offen { overflow: hidden; } /* Der Fortschritt als dünner Strich ganz oben auf der Leiste. */ .pl-kleinbalken { - position: absolute; left: 0; right: 0; top: 0; height: 2px; + position: absolute; left: 0; right: 0; top: 0; height: 2px; z-index: 2; background: rgba(255, 255, 255, .14); } .pl-kleinbalken div { height: 100%; background: var(--accent); transition: width .25s linear; } @@ -1493,6 +1498,24 @@ input[type="datetime-local"].feld::-webkit-calendar-picker-indicator:hover { } .auswahlzahl { font-size: 14px; font-weight: 600; white-space: nowrap; } +/* Das Kreuz steht abgesetzt — es tut nichts mit den Titeln, es räumt auf. */ +.auswahl-zu { + display: grid; place-items: center; flex: 0 0 auto; + width: 34px; height: 34px; border-radius: 50%; + border: 0; background: transparent; color: var(--text-dim); + cursor: pointer; transition: background .15s, color .15s; + margin-left: 4px; +} +.auswahl-zu:hover, .auswahl-zu:focus-visible { background: var(--raised); color: var(--text); } +.auswahl-zu svg { width: 17px; height: 17px; } + +/* **Während des Streichens markiert der Browser nichts.** Sonst zieht er die + Beschriftungen blau an, als wolle man Text kopieren. */ +body.keine-auswahl { user-select: none; -webkit-user-select: none; } +.raster.zieht .kachel { cursor: crosshair; } +/* Kein Ziehgut aus Plakaten — sonst hängt eines am Mauszeiger. */ +.plakat img { -webkit-user-drag: none; user-drag: none; } + @media (max-width: 640px) { .auswahlleiste { left: 12px; right: 12px; transform: none; max-width: none; } .auswahlleiste .knopfreihe { width: 100%; } diff --git a/web/app/src/views/library.ts b/web/app/src/views/library.ts index 0ab2d73..83ef6c6 100644 --- a/web/app/src/views/library.ts +++ b/web/app/src/views/library.ts @@ -239,6 +239,15 @@ export function mediathek( const k = ziel.closest('.kachel'); if (!k || !gitter.contains(k)) return; + // **Nach einem Streichen kommt noch ein Klick.** Der Browser schickt ihn + // auf die Kachel, auf der die Maus losgelassen wurde — und der würde + // genau das wieder umschalten, was gerade gewählt wurde. + if (geradeGestrichen) { + e.preventDefault(); + e.stopPropagation(); + 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; @@ -248,6 +257,166 @@ export function mediathek( umschalten(k); }, true); + /* ---------- Streichen wie im Dateimanager ---------- + * + * **Gedrückt halten und darüberfahren wählt aus.** + * + * Ein kurzer Klick bleibt, was er war: die Detailseite. Erst eine + * Bewegung über ein paar Bildpunkte hinaus macht daraus ein Streichen — + * dieselbe Unterscheidung, die jeder Dateimanager trifft, und deshalb + * muss sie niemand lernen. + * + * **Was die erste Kachel wird, werden alle.** Fängt man auf einer + * ungewählten an, wählt der ganze Zug aus; fängt man auf einer gewählten + * an, nimmt er weg. Jede einzeln umzuschalten hiesse, dass ein Zug über + * eine gemischte Reihe sie nur vertauscht — und das will niemand. + */ + + /** Wie weit die Maus muss, bevor aus dem Klick ein Streichen wird. */ + const SCHWELLE = 6; + + /** Wie nah an den Rand, bevor die Seite mitwandert. */ + const RANDBEREICH = 72; + + /** In welchen Abständen die Strecke zwischen zwei Ereignissen abgetastet wird. + * + * **Kleiner als die schmalste Kachel.** Bei 150 Bildpunkten Breite liesse + * sich mit 48 keine überspringen — 24 ist der halbe Weg dahin und kostet + * nichts, weil `elementFromPoint` nur nachschlägt. */ + const ABTASTUNG = 24; + + let geradeGestrichen = false; + let zug: { + anschalten: boolean; + losX: number; losY: number; + losKachel: HTMLElement; + begonnen: boolean; + zeiger: number; + /** Wo der Zeiger zuletzt war — für die Strecke dazwischen. */ + letztX: number; letztY: number; + } | null = null; + let randtakt = 0; + + gitter.addEventListener('pointerdown', e => { + // **Jede neue Berührung fängt sauber an.** + // + // Die Sperre hier zurückzusetzen und nicht erst im Klick ist der + // Unterschied zwischen „funktioniert" und „funktioniert meistens": + // ging der Klick nach einem Streichen woandershin — auf das Kreuz der + // Auswahlleiste etwa —, blieb sie stehen, und der nächste Klick auf + // eine Kachel öffnete nichts mehr. Genau so gemessen. + geradeGestrichen = false; + + // **Nur die Maus.** Auf einem Berührungsbildschirm wäre jedes Wischen + // zum Blättern ein Streichen über die Kacheln — die Liste liesse sich + // nicht mehr scrollen. + if (e.pointerType !== 'mouse' || e.button !== 0) return; + + const k = (e.target as HTMLElement).closest('.kachel'); + if (!k || !gitter.contains(k) || !k.dataset.id) return; + + zug = { + anschalten: !gewaehlt.has(k.dataset.id), + losX: e.clientX, losY: e.clientY, + losKachel: k, + begonnen: false, + zeiger: e.pointerId, + letztX: e.clientX, letztY: e.clientY, + }; + + window.addEventListener('pointermove', beiZug); + window.addEventListener('pointerup', zugEnde); + window.addEventListener('pointercancel', zugEnde); + }); + + function beiZug(e: PointerEvent): void { + if (!zug || e.pointerId !== zug.zeiger) return; + + if (!zug.begonnen) { + if (Math.abs(e.clientX - zug.losX) < SCHWELLE + && Math.abs(e.clientY - zug.losY) < SCHWELLE) return; + + zug.begonnen = true; + gitter.classList.add('zieht'); + // Ohne das markiert der Browser nebenher die Beschriftungen blau. + document.body.classList.add('keine-auswahl'); + setzen(zug.losKachel, zug.anschalten); + } + + // **Die Strecke zählt, nicht nur der Punkt.** + // + // Ein Zeigerereignis kommt nicht für jeden Bildpunkt: wer schnell + // streicht, springt mit einem Sprung über zwei Kacheln hinweg, und die + // blieben ungewählt. Gemessen beim Prüfen: ein Zug über vier Kacheln + // erwischte die erste und die letzte. Deshalb wird die Strecke seit dem + // letzten Mal abgeschritten. + // + // Nicht `e.target`: der Zeiger ist gedrückt, und damit gehen alle + // Ereignisse an die Kachel, auf der es angefangen hat. + const weitX = e.clientX - zug.letztX; + const weitY = e.clientY - zug.letztY; + const strecke = Math.hypot(weitX, weitY); + const schritte = Math.max(1, Math.ceil(strecke / ABTASTUNG)); + + for (let i = 1; i <= schritte; i++) { + const x = zug.letztX + (weitX * i) / schritte; + const y = zug.letztY + (weitY * i) / schritte; + const k = document.elementFromPoint(x, y)?.closest('.kachel'); + if (k && gitter.contains(k)) setzen(k, zug.anschalten); + } + + zug.letztX = e.clientX; + zug.letztY = e.clientY; + randlauf(e.clientY); + } + + function zugEnde(e: PointerEvent): void { + if (zug && e.pointerId !== zug.zeiger) return; + + window.removeEventListener('pointermove', beiZug); + window.removeEventListener('pointerup', zugEnde); + window.removeEventListener('pointercancel', zugEnde); + window.cancelAnimationFrame(randtakt); + randtakt = 0; + + gitter.classList.remove('zieht'); + document.body.classList.remove('keine-auswahl'); + geradeGestrichen = zug?.begonnen === true; + zug = null; + } + + /** Setzt eine Kachel auf einen Wert — ohne hin und her zu schalten. */ + function setzen(k: HTMLElement, an: boolean): void { + const id = k.dataset.id; + if (!id || gewaehlt.has(id) === an) return; + + if (an) gewaehlt.add(id); else gewaehlt.delete(id); + k.classList.toggle('gewaehlt', an); + k.querySelector('.wahlmarke')?.setAttribute('aria-checked', String(an)); + nachAuswahl(); + } + + /** Streicht man an den Rand, wandert die Seite mit. + * + * **Sonst endet jede Auswahl am unteren Bildrand.** Bei 447 Titeln sind + * das zwei Reihen von vierzig. */ + function randlauf(y: number): void { + window.cancelAnimationFrame(randtakt); + if (!zug?.begonnen) return; + + const oben = y - RANDBEREICH; + const unten = y - (window.innerHeight - RANDBEREICH); + const schritt = oben < 0 ? Math.max(-24, oben / 3) + : unten > 0 ? Math.min(24, unten / 3) + : 0; + if (schritt === 0) return; + + randtakt = window.requestAnimationFrame(() => { + window.scrollBy(0, schritt); + randlauf(y); + }); + } + // Dieselbe Geste für die Tastatur. gitter.addEventListener('keydown', e => { if (e.key !== 'Enter' && e.key !== ' ') return; @@ -312,12 +481,19 @@ export function mediathek( })); } - // **Abwählen bleibt immer anklickbar** — es ist der Weg heraus. - const wegKnopf = knopf('Abwählen', 'btn'); + // **Ein Kreuz, kein Wort.** + // + // „Abwählen" stand neben drei Knöpfen, die etwas tun — und sah damit aus + // wie ein vierter davon. Das Kreuz sagt, was es ist: die Auswahl + // schliessen. Es steht abgesetzt am Rand, nicht in der Reihe. + const wegKnopf = document.createElement('button'); + wegKnopf.className = 'auswahl-zu'; + wegKnopf.innerHTML = icons.schliessen; + wegKnopf.title = 'Auswahl schliessen'; + wegKnopf.setAttribute('aria-label', 'Auswahl schliessen'); wegKnopf.onclick = abwaehlen; - knoepfe.appendChild(wegKnopf); - b.append(zahl, knoepfe); + b.append(zahl, knoepfe, wegKnopf); return { element: b,