v4: an audio file's waveform is made once by the server, and the player seeks on one accessible scrubber — every player downloaded the whole file a second time and decoded all of it in the browser to draw 150 bars, so a long FLAC cost tens of megabytes of memory per page, and the waveform could only be clicked: no drag, no keyboard, no slider for a screen reader. GET /api/dumps/:id/waveform now answers an audio dump's peaks, which lib/ffmpeg.ts decodes with ffmpeg on first request, mono at 8 kHz and streamed, keeps beside the thumbnails as the response itself, and serves with an ETag (a ten-minute MP3 takes about a second, then a few hundred bytes). The peaks are taken by shared/waveform.ts's peaksOf, scaled to the loudest so a quiet recording fills the track, and the dump form's preview of a file not yet uploaded decodes it with the same function, at 8 kHz in an OfflineAudioContext. Scrubber replaces Waveform and SeekBar: a role="slider" that seeks where a pointer lets go, follows a drag (the clock shows where it is held), takes arrows, Home and End, and draws the bars as one path with the played part clipped by a nested svg, so it moves smoothly and a timeupdate redraws two attributes instead of 150 rects; audio without a waveform, and video, get a plain bar. MediaPlayer reads whether it plays from the element's events alone, so a click no longer reports the change twice, its handles for PlayerProvider are stable callbacks instead of refs to refs, the drag no longer reattaches every listener, and the <source> children that src made dead are gone, with the mime prop. The file, thumbnail and waveform routes resolve their dump, token and caching through lib/dump-media.ts, and what is generated from an upload is named and removed in lib/ffmpeg.ts, the custom thumbnail by dump-service's customThumbnailPath, instead of paths spelled out in each. The test setup's play() and pause() fire their events as a browser does. 277 backend tests and 684 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in the app with a real FLAC on its dump page and in the global player at 1280 and 390px, seeking by keyboard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,10 +16,9 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import { type Dump, dumpFileVersion } from "../../shared/dump.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import { DUMPS_DIR } from "../config.ts";
|
||||
import { UUID_RE } from "../../shared/validation.ts";
|
||||
import { authenticateMedia } from "../lib/auth.ts";
|
||||
import { resolveMediaDump } from "../lib/dump-media.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
import { serveFile } from "../lib/serve-file.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
@@ -66,17 +65,7 @@ function isInlineSafe(mime: string): boolean {
|
||||
|
||||
router.get("/:dumpId/file", async (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
|
||||
// The id names a file on disk: nothing but an id's characters gets through.
|
||||
if (!UUID_RE.test(dumpId)) {
|
||||
throw new APIException(400, "Invalid dump ID");
|
||||
}
|
||||
|
||||
// Media elements can't send an Authorization header, so the viewer's media
|
||||
// token comes in the query string.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const viewerId = token ? await authenticateMedia(token) : null;
|
||||
const dump = getDump(dumpId, viewerId ?? undefined);
|
||||
const { dump, cacheControl } = await resolveMediaDump(ctx, dumpId);
|
||||
|
||||
if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) {
|
||||
throw new APIException(404, "No file for this dump");
|
||||
@@ -98,21 +87,9 @@ router.get("/:dumpId/file", async (ctx) => {
|
||||
"sandbox; default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'",
|
||||
);
|
||||
}
|
||||
// The URL may carry a media token; don't pass it on to wherever the file
|
||||
// links.
|
||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||
|
||||
// A URL naming the file's current version (dumpFileVersion) always serves the
|
||||
// same bytes, so it is cached for good; any other is revalidated against the
|
||||
// ETag. A private dump's bytes must not sit in a shared cache.
|
||||
const scope = dump.isPrivate ? "private" : "public";
|
||||
const current = ctx.request.url.searchParams.get("v") ===
|
||||
dumpFileVersion(dump);
|
||||
const served = await serveFile(ctx, path, {
|
||||
mime: dump.fileMime,
|
||||
cacheControl: current
|
||||
? `${scope}, max-age=31536000, immutable`
|
||||
: `${scope}, no-cache`,
|
||||
cacheControl,
|
||||
ranges: true,
|
||||
});
|
||||
if (!served) {
|
||||
|
||||
Reference in New Issue
Block a user