Ein Bild, wo bisher nur eine Zahl stand

Wer zur Zeitleiste greift, sieht jetzt das Standbild der Stelle — im Web
beim Überfahren, in der App beim Ziehen.

**Erzeugt wird beim Abspielen, nicht auf Vorrat.** Ein Lauf über 453
Filme und 4213 Folgen wäre ein Abend ffmpeg und mehrere Gigabyte für
Titel, die niemand anfasst. Startet jemand einen Titel, stellt sich die
Datei hinten an; bis zum ersten Griff zur Leiste ist der Lauf durch.
Gemessen an einer erzeugten Datei: 191 ms für 250 Sekunden Film, ein
Zweistünder liegt damit bei etwa fünf Sekunden.

**Ein ffmpeg-Aufruf, nicht siebenhundert.** `fps=1/10,scale=320:-2,
tile=10x10` macht aus einem Durchlauf fertige Blätter zu hundert
Kacheln. Bezahlbar ist das durch `-skip_frame nokey` — dieselbe
Abkürzung wie beim Abtasten der Abspannhelligkeit: nur Keyframes
dekodieren. Dass die Vorschau dadurch um zwei Sekunden danebenliegen
kann, sieht niemand.

**Der Spieler lädt ein Blatt und verschiebt danach nur den Ausschnitt.**
Beim Fahren über die Leiste wird kein einziger Abruf mehr fällig. Die
Rechnung Sekunde → Kachel steht an drei Stellen — Server, Web, App —
und deshalb auch dreimal dieselbe; weicht eine ab, hält man die Vorschau
für ein unscharfes Bild statt für einen Fehler. Neun Prüfungen decken
die Ränder ab: Sekunde null, letzte Kachel eines Blattes, erste des
nächsten, und hinter dem Ende.

**Die Kachelmasse werden gemessen, nicht gerechnet.** `scale=320:-2`
hält das Seitenverhältnis, ein Breitbildfilm ergibt also flachere
Kacheln. Geraten wäre jede Kachel um ein paar Bildpunkte verschoben.

Aufgeräumt wird mit dem Transcode-Cache: der Namensanfang trägt Kennung
und Änderungszeit der Mediendatei, verwaiste Blätter sind daran ohne
Tabelle erkennbar. Der Ordner taucht unter „Speicher" auf.

**Und die Pfeiltasten spulen wieder.** Hatte die Zeitleiste den Fokus,
überliess der Spieler die Pfeile dem Browser — der verschiebt `value`
und löst `input` aus, aber `change` kommt bei Tastatureingabe nicht
verlässlich, also wurde nie gesprungen. Die Ausnahme fällt weg; Pfeil
heisst überall ±10 Sekunden. Nativ wären es ohnehin 100 Millisekunden
gewesen, weil `step` auf 0,1 steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-16 17:05:55 +02:00
co-authored by Claude Opus 5
parent ed13d5fa43
commit 68abd71074
20 changed files with 1159 additions and 7 deletions
+21
View File
@@ -1252,6 +1252,27 @@ export const protokolltage = () => anfrage<Protokolltag[]>('/system/logs');
*
* Mit `zeilen` kommt nur das Ende — das ist, was die Ansicht braucht. Ohne
* kommt alles, und das ist der Weg zum Herunterladen. */
/** Wie die Vorschaubilder eines Titels zusammengesetzt sind.
*
* **Die Kachelmasse stehen nicht fest**: skaliert wird auf eine feste
* Breite, die Höhe folgt dem Seitenverhältnis. Der Server misst sie und
* sagt sie — geraten wäre jede Kachel ein paar Bildpunkte verschoben. */
export type Vorschauauskunft = {
fertig: boolean;
intervall: number;
spalten: number;
zeilen: number;
kachelBreite: number;
kachelHoehe: number;
blaetter: number;
};
export const vorschauauskunft = (dateiId: string) =>
anfrage<Vorschauauskunft>(`/files/${dateiId}/vorschau`);
export const vorschaublattAdresse = (dateiId: string, blatt: number) =>
`/api/v1/files/${dateiId}/vorschau/${blatt}.jpg`;
export const protokollAdresse = (tag: string, zeilen?: number) =>
`/api/v1/system/logs/${tag}` + (zeilen ? `?tail=${zeilen}` : '');
+77 -2
View File
@@ -1,6 +1,7 @@
import type HlsTyp from 'hls.js';
import {
stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, segmenteHolen,
vorschauauskunft, vorschaublattAdresse, type Vorschauauskunft,
type Stromauskunft, type Spurauskunft, type Folge, type Segment,
} from './api';
import { dauer, knopf, meldung, hinweisZeigen, folgenname } from './util';
@@ -661,7 +662,12 @@ export function spieler(anfrage: Spieleranfrage): void {
// gibt, ist keiner. Jetzt liegt er als eigene Schicht über dem Bild,
// rechts unten, und erscheint von selbst — bis man die Bedienung einmal
// eingeblendet hat, danach kommt und geht er mit ihr.
bedienung.append(zeitleiste, unten);
// **Die Vorschau hängt an der Bedienung, nicht am Bild.** Sie soll über
// der Leiste stehen und mit ihr verschwinden; als eigene Schicht über
// dem Film müsste ihre Lage zweimal gerechnet werden.
const vorschau = document.createElement('div');
vorschau.className = 'pl-vorschau hidden';
bedienung.append(zeitleiste, vorschau, unten);
// **Beide Nachbarn gleich zu Beginn holen.** Zwei kleine Anfragen, einmal
// je Wiedergabe. Erst beim Antippen zu fragen hiesse, einen Knopf zu
@@ -786,6 +792,68 @@ export function spieler(anfrage: Spieleranfrage): void {
// Regler stehen, bis jemand ihn wieder anfasst.
zeitleiste.onpointercancel = () => { amRegler = false; };
/** Was der Server über die Vorschaubilder sagt — `null`, solange nichts da ist. */
let bilder: Vorschauauskunft | null = null;
// **Einmal fragen, und bei „noch nicht" genau einmal nachfassen.** Die
// Blätter entstehen beim Abspielen im Hintergrund; nach zehn Sekunden
// sind sie bei den meisten Filmen da. Weiter zu pollen hiesse, für eine
// Annehmlichkeit dauernd zu fragen.
void (async () => {
for (const warten of [0, 10000]) {
if (warten) await new Promise(r => setTimeout(r, warten));
if (geschlossen) return;
try {
const auskunft = await vorschauauskunft(anfrage.dateiId);
if (auskunft.fertig) { bilder = auskunft; return; }
} catch { return; }
}
})();
/** Zeigt die Kachel zur Stelle unter dem Zeiger. */
function vorschauZeigen(x: number): void {
if (!bilder || gesamt <= 0) return;
const kasten = zeitleiste.getBoundingClientRect();
const anteil = Math.min(1, Math.max(0, (x - kasten.left) / kasten.width));
const sekunde = anteil * gesamt;
// Dieselbe Rechnung wie im Server und in der App — siehe `Vorschauplan`.
const proBlatt = bilder.spalten * bilder.zeilen;
const nummer = Math.min(
Math.floor(sekunde / bilder.intervall), proBlatt * bilder.blaetter - 1);
const rest = nummer % proBlatt;
vorschau.style.width = `${bilder.kachelBreite}px`;
vorschau.style.height = `${bilder.kachelHoehe}px`;
vorschau.style.backgroundImage =
`url(${vorschaublattAdresse(anfrage.dateiId, Math.floor(nummer / proBlatt))})`;
vorschau.style.backgroundPosition =
`-${(rest % bilder.spalten) * bilder.kachelBreite}px `
+ `-${Math.floor(rest / bilder.spalten) * bilder.kachelHoehe}px`;
// **Gerechnet wird in zwei Bezugssystemen, und das ist die Falle.**
// Die Stelle kommt aus der Leiste, das Feld hängt an der Bedienung —
// dazwischen liegt deren Innenabstand. Ohne die Umrechnung sässe die
// Vorschau achtzehn Bildpunkte daneben.
const bezug = bedienung.getBoundingClientRect();
const halb = bilder.kachelBreite / 2;
const links = Math.min(
kasten.right - bezug.left - halb,
Math.max(kasten.left - bezug.left + halb, x - bezug.left));
vorschau.style.left = `${links}px`;
vorschau.classList.remove('hidden');
zeit.textContent = dauer(sekunde);
}
const zeit = document.createElement('div');
zeit.className = 'pl-vorschauzeit';
vorschau.appendChild(zeit);
zeitleiste.addEventListener('pointermove', e => vorschauZeigen(e.clientX));
zeitleiste.addEventListener('pointerleave', () => vorschau.classList.add('hidden'));
/** Die Gesamtlänge des Films — nicht die des Stroms. */
const gesamt = anfrage.laenge ?? 0;
@@ -1309,7 +1377,14 @@ export function spieler(anfrage: Spieleranfrage): void {
/* ---------- Tastatur ---------- */
function tastendruck(e: KeyboardEvent): void {
if (e.target instanceof HTMLInputElement && e.target.type === 'range' && e.key.startsWith('Arrow')) return;
// **Pfeiltasten gehören dem Spieler, auch auf der Leiste.**
//
// Hier stand eine Ausnahme: hat die Zeitleiste den Fokus, überlasse
// die Pfeile dem Browser. Der verschiebt `value` und löst `input`
// aus — aber `change` kommt bei Tastatureingabe nicht verlässlich,
// und ohne `change` wird nie gesprungen. Der Regler bewegte sich
// also und der Film nicht. Abgesehen davon steht `step` auf 0,1:
// ein nativer Tastendruck wäre eine Zehntelsekunde.
switch (e.key) {
case ' ': case 'k': e.preventDefault(); umschalten(); break;
+29
View File
@@ -987,6 +987,35 @@ textarea.feld {
height: 20px; background: transparent; cursor: pointer; margin: 0;
}
.pl-zeit { width: 100%; }
/* **Die Vorschau steht über der Leiste, nicht unter dem Zeiger.**
Am Zeiger klebend verdeckte sie genau die Stelle, die man sucht; eine
feste Höhe darüber lässt die Leiste frei. `translateX(-50%)` setzt sie
mittig über die Stelle, `left` kommt aus dem Skript. */
.pl-vorschau {
position: absolute;
bottom: calc(100% + var(--s-snug));
transform: translateX(-50%);
border-radius: var(--r-klein);
overflow: hidden;
background-color: #000;
background-repeat: no-repeat;
box-shadow: 0 8px 24px rgb(0 0 0 / .6);
border: 1px solid rgb(255 255 255 / .18);
pointer-events: none;
z-index: 4;
}
.pl-vorschauzeit {
position: absolute;
inset: auto 0 0 0;
padding: 2px 0 3px;
text-align: center;
font-size: .72rem;
font-variant-numeric: tabular-nums;
color: #fff;
background: linear-gradient(to top, rgb(0 0 0 / .85), transparent);
}
.pl-laut { width: 84px; flex: 0 0 auto; }
.pl-zeit::-webkit-slider-runnable-track, .pl-laut::-webkit-slider-runnable-track {