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:
co-authored by
Claude Opus 5
parent
eda9e67802
commit
7df9fa6c01
+15
-4
@@ -61,10 +61,21 @@ export const icons = {
|
||||
vollbild: rahmen('<path d="M4 9V5h4"/><path d="M20 9V5h-4"/>'
|
||||
+ '<path d="M4 15v4h4"/><path d="M20 15v4h-4"/>'),
|
||||
untertitel: rahmen('<rect x="3" y="5" width="18" height="14" rx="2"/>'
|
||||
+ '<path d="M7 14.5h4"/><path d="M13 14.5h4"/>'),
|
||||
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'
|
||||
+ 'M4.5 16.5l2.1-1.2M17.4 8.7l2.1-1.2"/>'),
|
||||
+ '<path d="M6.5 14.5h4"/><path d="M13.5 14.5h4"/><path d="M6.5 10h11"/>'),
|
||||
|
||||
/** Eine Wellenform — Tonspur, und nicht zu verwechseln mit der Lautstärke. */
|
||||
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;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
+180
-31
@@ -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 `<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.classList.add('pl-offen');
|
||||
|
||||
@@ -190,10 +273,16 @@ export function spieler(anfrage: Spieleranfrage): void {
|
||||
|
||||
const vollbild = rundknopf('vollbild', 'Vollbild (f)', () => vollbildUmschalten());
|
||||
|
||||
/** Die drei Menüs — Ton, Untertitel, Qualität. */
|
||||
const tonmenue = menue('ton', 'Tonspur');
|
||||
/** Die drei Menüs — Ton, Untertitel, Qualität.
|
||||
*
|
||||
* **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 stufenmenue = menue('zahnrad', 'Qualität');
|
||||
const stufenmenue = menue('regler', 'Qualität');
|
||||
|
||||
const links = document.createElement('div');
|
||||
links.className = 'pl-gruppe';
|
||||
@@ -225,10 +314,17 @@ export function spieler(anfrage: Spieleranfrage): void {
|
||||
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 = () => {
|
||||
abspielknopf.innerHTML = icons.abspielen;
|
||||
abspielknopf.title = 'Abspielen (Leertaste)';
|
||||
kleinAb.innerHTML = icons.abspielen;
|
||||
kleinAb.title = 'Abspielen';
|
||||
melden(true);
|
||||
};
|
||||
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.
|
||||
const anteil = laenge > 0 ? jetzt / laenge : 0;
|
||||
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 {
|
||||
@@ -261,6 +360,30 @@ export function spieler(anfrage: Spieleranfrage): void {
|
||||
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> {
|
||||
try {
|
||||
if (document.fullscreenElement) await document.exitFullscreen();
|
||||
@@ -400,27 +523,41 @@ export function spieler(anfrage: Spieleranfrage): void {
|
||||
|
||||
/* ---------- 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 {
|
||||
const toene = auskunft?.audioStreams ?? [];
|
||||
tonmenue.setzen(
|
||||
(auskunft?.audioStreams ?? []).map(s => ({
|
||||
text: s.label, gewaehlt: s.index === tonspur,
|
||||
tun: () => { tonspur = s.index; void stromAufbauen(filmzeit()); },
|
||||
})));
|
||||
tonmenue.knopf.classList.toggle('hidden', (auskunft?.audioStreams.length ?? 0) < 2);
|
||||
toene.length > 0
|
||||
? toene.map(s => ({
|
||||
text: s.label, gewaehlt: s.index === tonspur,
|
||||
tun: () => { tonspur = s.index; void stromAufbauen(filmzeit()); },
|
||||
}))
|
||||
: [{ text: 'Diese Datei hat keinen Ton.', gewaehlt: false, tun: () => {} }]);
|
||||
|
||||
textmenue.setzen([
|
||||
{ text: 'Aus', gewaehlt: untertitel === null, tun: () => untertitelWaehlen(null) },
|
||||
...(auskunft?.subtitleStreams ?? []).map(s => ({
|
||||
text: s.label, gewaehlt: s.index === untertitel,
|
||||
tun: () => untertitelWaehlen(s.index),
|
||||
})),
|
||||
]);
|
||||
textmenue.knopf.classList.toggle('hidden', (auskunft?.subtitleStreams.length ?? 0) === 0);
|
||||
const texte = auskunft?.subtitleStreams ?? [];
|
||||
textmenue.setzen(
|
||||
texte.length > 0
|
||||
? [
|
||||
{ text: 'Aus', gewaehlt: untertitel === null, tun: () => untertitelWaehlen(null) },
|
||||
...texte.map(s => ({
|
||||
text: s.label, gewaehlt: s.index === untertitel,
|
||||
tun: () => untertitelWaehlen(s.index),
|
||||
})),
|
||||
]
|
||||
: [{ text: 'Keine Untertitel in dieser Datei.', gewaehlt: false, tun: () => {} }]);
|
||||
|
||||
stufenmenue.setzen(STUFEN.map(s => ({
|
||||
text: s.wert === null && auskunft?.method === 'transcode' && auskunft.height
|
||||
? 'Original'
|
||||
: s.text,
|
||||
text: s.text,
|
||||
gewaehlt: s.wert === stufe,
|
||||
tun: () => { stufe = s.wert; void stromAufbauen(filmzeit()); },
|
||||
})));
|
||||
@@ -498,7 +635,11 @@ export function spieler(anfrage: Spieleranfrage): void {
|
||||
|
||||
switch (e.key) {
|
||||
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 'm': e.preventDefault(); tonknopf.click(); 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);
|
||||
|
||||
// **Wer zurückgeht, will nicht weiter zusehen.** Der Spieler hängt an
|
||||
// `document.body` und nicht am Inhaltsbereich; ohne diese Zeile blieb er
|
||||
// über der neuen Seite stehen, und ffmpeg lief dazu weiter.
|
||||
const beiNavigation = (): void => schliessen();
|
||||
// **Wer weiterblättert, verkleinert — er beendet nicht.**
|
||||
//
|
||||
// Zuerst schloss der Spieler bei jedem Seitenwechsel: er hängt an
|
||||
// `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('popstate', beiNavigation);
|
||||
|
||||
@@ -552,8 +697,9 @@ export function spieler(anfrage: Spieleranfrage): void {
|
||||
schicht.classList.add('pl-still');
|
||||
}, 3000);
|
||||
}
|
||||
schicht.onmousemove = zeigen;
|
||||
schicht.onmousemove = () => { if (!klein) zeigen(); };
|
||||
schicht.onclick = e => {
|
||||
if (klein) 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; }
|
||||
@@ -565,6 +711,7 @@ export function spieler(anfrage: Spieleranfrage): void {
|
||||
function schliessen(): void {
|
||||
if (geschlossen) return;
|
||||
geschlossen = true;
|
||||
if (offenerSpieler === schliessen) offenerSpieler = null;
|
||||
|
||||
video.pause();
|
||||
// **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('pagehide', beiEntladen);
|
||||
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
|
||||
document.body.classList.remove('pl-offen');
|
||||
document.body.classList.remove('pl-offen', 'pl-leiste-da');
|
||||
schicht.remove();
|
||||
anfrage.danach();
|
||||
}
|
||||
@@ -640,6 +787,8 @@ export function spieler(anfrage: Spieleranfrage): void {
|
||||
|
||||
/* ---------- Los ---------- */
|
||||
|
||||
offenerSpieler = schliessen;
|
||||
|
||||
menuesAuffrischen();
|
||||
zeitAuffrischen();
|
||||
schicht.focus();
|
||||
|
||||
+116
-13
@@ -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;
|
||||
}
|
||||
|
||||
/* Hinter dem Spieler soll nichts scrollen. */
|
||||
/* Hinter dem Spieler soll nichts scrollen — aber nur im ganzen Bild. */
|
||||
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) {
|
||||
.pl-laut { 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
|
||||
* 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 {
|
||||
position: absolute; right: 6px; top: 6px; z-index: 4;
|
||||
position: absolute; right: 6px; bottom: 6px; z-index: 4;
|
||||
display: grid; place-items: center;
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
background: rgba(10, 10, 12, .78);
|
||||
@@ -1353,31 +1429,48 @@ input[type="datetime-local"].feld::-webkit-calendar-picker-indicator:hover {
|
||||
|
||||
/* ---------- Mehrfachauswahl in der Mediathek ----------
|
||||
*
|
||||
* **Die Marke sitzt oben links**, gegenüber von Haken und Folgenzahl. Beide
|
||||
* in dieselbe Ecke zu legen hiesse, dass die Auswahl verdeckt, was man
|
||||
* auswählt. */
|
||||
* **Oben links, gegenüber vom Gesehen-Haken.** Jede Ecke des Plakats hat eine
|
||||
* Aufgabe: oben links die Auswahl, oben rechts der Stand, unten links
|
||||
* 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 {
|
||||
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%;
|
||||
display: grid; place-items: center;
|
||||
background: rgba(10, 10, 12, .62);
|
||||
border: 2px solid rgba(255, 255, 255, .72);
|
||||
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; }
|
||||
|
||||
/* 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 {
|
||||
background: var(--accent); border-color: var(--accent);
|
||||
}
|
||||
.kachel.gewaehlt .wahlmarke svg { opacity: 1; }
|
||||
|
||||
/* **Im Auswahlmodus kein Abspielknopf und kein Griff.** Beide führten
|
||||
woandershin, und ein Klick darauf wählt jetzt nur noch aus. */
|
||||
.raster.auswahl .schweber,
|
||||
.raster.auswahl .kachel-mehr { display: none; }
|
||||
.raster.auswahl .kachel { cursor: pointer; }
|
||||
.raster.auswahl .kachel:hover .plakat { transform: scale(1.02); }
|
||||
/* **Solange etwas gewählt ist, führt kein Klick mehr woandershin.** Der
|
||||
Abspielknopf und der Griff blieben sonst stehen und täten etwas anderes als
|
||||
das, was der Klick daneben tut. */
|
||||
.raster.waehlt .schweber,
|
||||
.raster.waehlt .kachel-mehr { display: none; }
|
||||
.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
|
||||
auch ohne Farbwahrnehmung zu erkennen ist. */
|
||||
@@ -1417,3 +1510,13 @@ body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; }
|
||||
@media (max-width: 640px) {
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -159,80 +159,109 @@ export function mediathek(
|
||||
};
|
||||
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,
|
||||
anzahl, zuruecksetzen, auswahlKnopf, suchhuelle);
|
||||
anzahl, zuruecksetzen, suchhuelle);
|
||||
|
||||
const gitter = document.createElement('div');
|
||||
gitter.className = 'raster';
|
||||
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>();
|
||||
/** Was hinter jeder Kachel steht — nach Kennung, für Sammelaktionen. */
|
||||
const bekannt = new Map<string, Kachel>();
|
||||
const leiste = auswahlleiste();
|
||||
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 {
|
||||
const plakat = k.querySelector('.plakat');
|
||||
if (!plakat || plakat.querySelector('.wahlmarke')) return;
|
||||
|
||||
const m = document.createElement('span');
|
||||
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;
|
||||
plakat.appendChild(m);
|
||||
}
|
||||
|
||||
function auswahlmodusSetzen(an: boolean): 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();
|
||||
|
||||
function umschalten(k: HTMLElement): void {
|
||||
const id = k.dataset.id;
|
||||
if (!id) return;
|
||||
const jetztGewaehlt = !gewaehlt.has(id);
|
||||
if (jetztGewaehlt) gewaehlt.add(id); else gewaehlt.delete(id);
|
||||
k.classList.toggle('gewaehlt', jetztGewaehlt);
|
||||
k.setAttribute('aria-pressed', String(jetztGewaehlt));
|
||||
leiste.auffrischen(gewaehlt.size, true);
|
||||
|
||||
const jetzt = !gewaehlt.has(id);
|
||||
if (jetzt) gewaehlt.add(id); else gewaehlt.delete(id);
|
||||
k.classList.toggle('gewaehlt', jetzt);
|
||||
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);
|
||||
|
||||
/** 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');
|
||||
b.className = 'auswahlleiste hidden';
|
||||
|
||||
@@ -249,7 +278,6 @@ export function mediathek(
|
||||
* zurück, bevor die erste durch ist, und die Meldung wäre geraten. */
|
||||
const aktion = (text: string, arbeit: (ids: string[]) => Promise<string>): HTMLButtonElement => {
|
||||
const b = knopf(text, 'btn');
|
||||
b.dataset.braucht = 'auswahl';
|
||||
b.onclick = async () => {
|
||||
const ids = [...gewaehlt];
|
||||
if (ids.length === 0) return;
|
||||
@@ -286,20 +314,20 @@ export function mediathek(
|
||||
|
||||
// **Abwählen bleibt immer anklickbar** — es ist der Weg heraus.
|
||||
const wegKnopf = knopf('Abwählen', 'btn');
|
||||
wegKnopf.onclick = () => auswahlmodusSetzen(false);
|
||||
wegKnopf.onclick = abwaehlen;
|
||||
knoepfe.appendChild(wegKnopf);
|
||||
|
||||
b.append(zahl, knoepfe);
|
||||
|
||||
return {
|
||||
element: b,
|
||||
auffrischen: (anzahl, an) => {
|
||||
b.classList.toggle('hidden', !an);
|
||||
zahl.textContent = anzahl === 0
|
||||
? 'Nichts ausgewählt'
|
||||
: `${anzahlwort(anzahl)} ausgewählt`;
|
||||
knoepfe.querySelectorAll<HTMLButtonElement>('[data-braucht="auswahl"]')
|
||||
.forEach(x => { x.disabled = anzahl === 0; });
|
||||
auffrischen: anzahl => {
|
||||
// **Die Leiste kommt mit dem ersten Haken und geht mit dem
|
||||
// letzten.** Sie leer stehen zu lassen hiesse, Platz für nichts zu
|
||||
// belegen — und „Nichts ausgewählt" ist keine Auskunft, die jemand
|
||||
// braucht.
|
||||
b.classList.toggle('hidden', anzahl === 0);
|
||||
zahl.textContent = `${anzahlwort(anzahl)} ausgewählt`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -411,7 +439,15 @@ export function mediathek(
|
||||
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;
|
||||
geladen += seite.items.length;
|
||||
@@ -439,9 +475,7 @@ export function mediathek(
|
||||
// muss die Kachel wissen, wie viele Folgen die Serie hat — sonst
|
||||
// liesse sich die Plakette „noch 6 offen" nicht wiederherstellen.
|
||||
bekannt.set(beschreibung.id, beschreibung);
|
||||
// Nachgeladene Kacheln brauchen die Marke auch — sonst sind die
|
||||
// ersten zweihundert auswählbar und der Rest nicht.
|
||||
if (auswahlmodus) markeAn(k);
|
||||
markeAn(k);
|
||||
gitter.appendChild(k);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user