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:
@@ -40,6 +40,7 @@ import {
|
||||
} from "./notification-service.ts";
|
||||
import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts";
|
||||
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
||||
import { removeGeneratedMedia } from "../lib/ffmpeg.ts";
|
||||
import {
|
||||
DUMP_ALLOWED_MIME_PREFIXES,
|
||||
DUMP_ALLOWED_MIME_TYPES,
|
||||
@@ -236,19 +237,6 @@ export async function createFileDump(
|
||||
return dump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the thumbnails that `routes/thumbnails.ts` generated from a dump's
|
||||
* upload, once the upload is replaced or deleted. The route makes them again
|
||||
* from a new one.
|
||||
*/
|
||||
async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
|
||||
await Promise.all(
|
||||
[`${dumpId}.jpg`, `${dumpId}.preview.jpg`, `${dumpId}.preview.png`].map(
|
||||
(name) => Deno.remove(`${THUMBNAILS_DIR}/${name}`).catch(() => {}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns dump `dumpId`, without its categories, or throws a 404. It doesn't
|
||||
* check privacy: the routes do, with {@link getDump}.
|
||||
@@ -671,7 +659,7 @@ export async function replaceFileDump(
|
||||
throw err;
|
||||
}
|
||||
if (hadOld) await Deno.remove(backupPath).catch(() => {});
|
||||
await removeGeneratedThumbnails(dumpId);
|
||||
await removeGeneratedMedia(dumpId);
|
||||
|
||||
if (categoryIds !== undefined) {
|
||||
dump.categoryIds = [...new Set(categoryIds)];
|
||||
@@ -811,19 +799,12 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a dump's id and custom thumbnail type, or undefined without one, for
|
||||
* the thumbnail route. It doesn't check privacy: the route calls
|
||||
* {@link getDump} first.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
* Returns where dump `dumpId`'s custom thumbnail is kept: the thumbnail route
|
||||
* writes and serves it, and this service deletes it with the thumbnail or the
|
||||
* dump.
|
||||
*/
|
||||
export function getDumpThumbnailInfo(
|
||||
dumpId: string,
|
||||
): { id: string; mime: string } | undefined {
|
||||
const dump = fetchDump(dumpId);
|
||||
return dump.thumbnailMime
|
||||
? { id: dump.id, mime: dump.thumbnailMime }
|
||||
: undefined;
|
||||
export function customThumbnailPath(dumpId: string): string {
|
||||
return `${THUMBNAILS_DIR}/${dumpId}-custom`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -853,7 +834,7 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
|
||||
const dump = fetchDump(dumpId);
|
||||
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = NULL WHERE id = ?;`)
|
||||
.run(dump.id);
|
||||
await Deno.remove(`${THUMBNAILS_DIR}/${dump.id}-custom`).catch(() => {});
|
||||
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
|
||||
const updatedDump: Dump = { ...dump, thumbnailMime: undefined };
|
||||
broadcastDumpChange(dump, updatedDump);
|
||||
return updatedDump;
|
||||
@@ -876,10 +857,10 @@ export async function deleteDump(dumpId: string): Promise<void> {
|
||||
|
||||
if (dump.kind === "file") {
|
||||
await Deno.remove(`${DUMPS_DIR}/${dump.id}`).catch(() => {});
|
||||
await removeGeneratedThumbnails(dump.id);
|
||||
await removeGeneratedMedia(dump.id);
|
||||
}
|
||||
if (dump.thumbnailMime) {
|
||||
await Deno.remove(`${THUMBNAILS_DIR}/${dump.id}-custom`).catch(() => {});
|
||||
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
|
||||
}
|
||||
|
||||
broadcastDumpChange(dump, null);
|
||||
|
||||
Reference in New Issue
Block a user