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
+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();
|
||||
|
||||
Reference in New Issue
Block a user