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,