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:
@@ -26,7 +26,6 @@ describe("MediaPlayer", () => {
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
onPlayStateChange={onPlayStateChange}
|
||||
/>,
|
||||
);
|
||||
@@ -44,7 +43,6 @@ describe("MediaPlayer", () => {
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
autoplay
|
||||
onPlayStateChange={onPlayStateChange}
|
||||
/>,
|
||||
@@ -61,7 +59,6 @@ describe("MediaPlayer", () => {
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
autoplay
|
||||
/>,
|
||||
);
|
||||
@@ -75,7 +72,6 @@ describe("MediaPlayer", () => {
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
onPlayStateChange={onPlayStateChange}
|
||||
/>,
|
||||
);
|
||||
@@ -95,7 +91,6 @@ describe("MediaPlayer", () => {
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onEnded={onEnded}
|
||||
/>,
|
||||
@@ -105,28 +100,46 @@ describe("MediaPlayer", () => {
|
||||
expect(screen.getByText("1:05")).toBeInTheDocument();
|
||||
expect(screen.getByText("3:20")).toBeInTheDocument();
|
||||
expect(onTimeUpdate).toHaveBeenLastCalledWith(65, 200);
|
||||
fireEvent.change(screen.getByRole("slider", { name: "Seek" }), {
|
||||
target: { value: "120" },
|
||||
// Five seconds on with an arrow, as the scrubber's keyboard does.
|
||||
fireEvent.keyDown(screen.getByRole("slider", { name: "Seek" }), {
|
||||
key: "ArrowRight",
|
||||
});
|
||||
expect(el.currentTime).toBe(120);
|
||||
expect(el.currentTime).toBe(70);
|
||||
fireEvent(el, new Event("ended"));
|
||||
expect(onEnded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("while the seek bar is held, playback doesn't move the thumb — by touch too", () => {
|
||||
test("while the scrubber is held, the clock follows it, not playback", () => {
|
||||
const { container } = renderWithContexts(
|
||||
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" />,
|
||||
<MediaPlayer src="https://s/a.mp3" kind="audio" />,
|
||||
);
|
||||
const el = media(container);
|
||||
setTime(el, 10, 100);
|
||||
const seek = screen.getByRole("slider", { name: "Seek" });
|
||||
fireEvent.pointerDown(seek, { pointerType: "touch" });
|
||||
fireEvent.change(seek, { target: { value: "50" } });
|
||||
seek.getBoundingClientRect = () => new DOMRect(0, 0, 200, 48);
|
||||
fireEvent.pointerDown(seek, { button: 0, clientX: 100 });
|
||||
setTime(el, 11, 100);
|
||||
expect(screen.getByText("0:50")).toBeInTheDocument();
|
||||
fireEvent.pointerUp(seek, { pointerType: "touch" });
|
||||
setTime(el, 51, 100);
|
||||
expect(screen.getByText("0:51")).toBeInTheDocument();
|
||||
expect(el.currentTime).toBe(11);
|
||||
fireEvent.pointerUp(seek, { clientX: 150 });
|
||||
expect(el.currentTime).toBe(75);
|
||||
expect(screen.getByText("1:15")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("an audio file draws its waveform once loaded", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
Response.json({ success: true, data: { peaks: [0.5, 1, 0.25] } }),
|
||||
);
|
||||
const { container } = renderWithContexts(
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
waveform={{ peaksUrl: "https://s/w-mp" }}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector(".scrubber-peaks")).not.toBeNull()
|
||||
);
|
||||
});
|
||||
|
||||
test("an error is reported for re-resolving", () => {
|
||||
@@ -135,7 +148,6 @@ describe("MediaPlayer", () => {
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
onError={onError}
|
||||
/>,
|
||||
);
|
||||
@@ -145,7 +157,7 @@ describe("MediaPlayer", () => {
|
||||
|
||||
test("volume and mute are remembered", async () => {
|
||||
const { container } = renderWithContexts(
|
||||
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" />,
|
||||
<MediaPlayer src="https://s/a.mp3" kind="audio" />,
|
||||
);
|
||||
fireEvent.change(screen.getByRole("slider", { name: "Volume" }), {
|
||||
target: { value: "0.4" },
|
||||
@@ -172,7 +184,6 @@ describe("MediaPlayer", () => {
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
seekRef={seekRef}
|
||||
toggleRef={toggleRef}
|
||||
/>,
|
||||
@@ -190,7 +201,6 @@ describe("MediaPlayer", () => {
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
startTime={42}
|
||||
/>,
|
||||
);
|
||||
|
||||
99
src/test/components/player/Scrubber.test.tsx
Normal file
99
src/test/components/player/Scrubber.test.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { Scrubber } from "../../../components/player/Scrubber.tsx";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
function slider() {
|
||||
const el = screen.getByRole("slider", { name: "Seek" });
|
||||
el.getBoundingClientRect = () => new DOMRect(10, 0, 200, 48);
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("Scrubber", () => {
|
||||
test("states its position for assistive tech", () => {
|
||||
renderWithContexts(
|
||||
<Scrubber current={65} duration={200} onSeek={() => {}} />,
|
||||
);
|
||||
const el = slider();
|
||||
expect(el).toHaveAttribute("aria-valuenow", "65");
|
||||
expect(el).toHaveAttribute("aria-valuemax", "200");
|
||||
expect(el).toHaveAttribute("aria-valuetext", "1:05 / 3:20");
|
||||
});
|
||||
|
||||
test("keys seek by five seconds, Home and End to the ends, within bounds", () => {
|
||||
const onSeek = vi.fn();
|
||||
renderWithContexts(
|
||||
<Scrubber current={3} duration={200} onSeek={onSeek} />,
|
||||
);
|
||||
const el = slider();
|
||||
fireEvent.keyDown(el, { key: "ArrowRight" });
|
||||
expect(onSeek).toHaveBeenLastCalledWith(8);
|
||||
fireEvent.keyDown(el, { key: "ArrowLeft" });
|
||||
expect(onSeek).toHaveBeenLastCalledWith(0);
|
||||
fireEvent.keyDown(el, { key: "End" });
|
||||
expect(onSeek).toHaveBeenLastCalledWith(200);
|
||||
fireEvent.keyDown(el, { key: "Tab" });
|
||||
expect(onSeek).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("a drag shows where it is held and seeks where it lets go", () => {
|
||||
const onSeek = vi.fn();
|
||||
const onScrub = vi.fn();
|
||||
const { container } = renderWithContexts(
|
||||
<Scrubber
|
||||
current={0}
|
||||
duration={100}
|
||||
onSeek={onSeek}
|
||||
onScrub={onScrub}
|
||||
/>,
|
||||
);
|
||||
const el = slider();
|
||||
const fill = () =>
|
||||
container.querySelector<HTMLElement>(".scrubber-fill")!.style.width;
|
||||
fireEvent.pointerDown(el, { button: 0, clientX: 60 });
|
||||
expect(onScrub).toHaveBeenLastCalledWith(25);
|
||||
expect(fill()).toBe("25%");
|
||||
// Past the end clamps.
|
||||
fireEvent.pointerMove(el, { clientX: 400 });
|
||||
expect(fill()).toBe("100%");
|
||||
expect(onSeek).not.toHaveBeenCalled();
|
||||
fireEvent.pointerUp(el, { clientX: 110 });
|
||||
expect(onSeek).toHaveBeenCalledWith(50);
|
||||
expect(onScrub).toHaveBeenLastCalledWith(null);
|
||||
// A move with nothing held does nothing.
|
||||
fireEvent.pointerMove(el, { clientX: 30 });
|
||||
expect(onScrub).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("a cancelled drag seeks nowhere", () => {
|
||||
const onSeek = vi.fn();
|
||||
renderWithContexts(
|
||||
<Scrubber current={0} duration={100} onSeek={onSeek} />,
|
||||
);
|
||||
const el = slider();
|
||||
fireEvent.pointerDown(el, { button: 0, clientX: 60 });
|
||||
fireEvent.pointerCancel(el);
|
||||
expect(onSeek).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("peaks draw as bars, the played part clipped to the playhead", () => {
|
||||
const { container } = renderWithContexts(
|
||||
<Scrubber
|
||||
current={25}
|
||||
duration={100}
|
||||
onSeek={() => {}}
|
||||
peaks={[0.5, 1, 0.25, 0]}
|
||||
/>,
|
||||
);
|
||||
const svg = container.querySelector(".scrubber-peaks")!;
|
||||
// Four bars, 3 wide with 1 between: 15 units.
|
||||
expect(svg.getAttribute("viewBox")).toBe("0 0 15 48");
|
||||
expect(svg.querySelector(":scope > path")!.getAttribute("d")).toBe(
|
||||
"M0 12h3v24h-3zM4 0h3v48h-3zM8 18h3v12h-3zM12 23h3v2h-3z",
|
||||
);
|
||||
expect(
|
||||
container.querySelector(".scrubber-peaks-played")!.getAttribute("width"),
|
||||
).toBe("3.75");
|
||||
expect(container.querySelector(".scrubber-bar")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -150,10 +150,25 @@ class FakeAnimation extends EventTarget implements Animation {
|
||||
Element.prototype.animate ??= () => new FakeAnimation();
|
||||
Element.prototype.getAnimations ??= () => [];
|
||||
globalThis.scrollTo = () => {};
|
||||
HTMLMediaElement.prototype.play = function () {
|
||||
// jsdom plays nothing. These keep `paused` and fire `play` and `pause` as a
|
||||
// browser does, which the players' state follows.
|
||||
const playingMedia = new WeakSet<HTMLMediaElement>();
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "paused", {
|
||||
configurable: true,
|
||||
get(this: HTMLMediaElement) {
|
||||
return !playingMedia.has(this);
|
||||
},
|
||||
});
|
||||
HTMLMediaElement.prototype.play = function (this: HTMLMediaElement) {
|
||||
if (!playingMedia.has(this)) {
|
||||
playingMedia.add(this);
|
||||
this.dispatchEvent(new Event("play"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
HTMLMediaElement.prototype.pause = function () {};
|
||||
HTMLMediaElement.prototype.pause = function (this: HTMLMediaElement) {
|
||||
if (playingMedia.delete(this)) this.dispatchEvent(new Event("pause"));
|
||||
};
|
||||
HTMLMediaElement.prototype.load = function () {};
|
||||
// The emoji picker probes emoji support on a canvas; jsdom has none.
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
|
||||
|
||||
@@ -1,40 +1,51 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { extractPeaks } from "../../../utils/player/waveform.ts";
|
||||
import { loadPeaks } from "../../../utils/player/waveform.ts";
|
||||
import { calls, fail, route } from "../../fetch.ts";
|
||||
|
||||
describe("extractPeaks", () => {
|
||||
test("decodes once per URL and takes each block's peak", async () => {
|
||||
const data = new Float32Array([0.1, -0.5, 0.2, 0.9, -0.3, 0.4]);
|
||||
const decode = vi.fn(() => Promise.resolve({ getChannelData: () => data }));
|
||||
const close = vi.fn();
|
||||
describe("loadPeaks", () => {
|
||||
test("an uploaded file's come from the server, once per URL", async () => {
|
||||
route(
|
||||
"GET /api/dumps/:id/waveform",
|
||||
() => Response.json({ success: true, data: { peaks: [0.5, 1] } }),
|
||||
);
|
||||
const source = {
|
||||
peaksUrl: "http://localhost:8000/api/dumps/w1/waveform",
|
||||
};
|
||||
expect(await loadPeaks(source)).toEqual([0.5, 1]);
|
||||
await loadPeaks(source);
|
||||
expect(calls("GET /api/dumps/:id/waveform")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a failed load is tried again", async () => {
|
||||
route("GET /api/dumps/:id/waveform", () => fail("gone", 404));
|
||||
const source = {
|
||||
peaksUrl: "http://localhost:8000/api/dumps/w2/waveform",
|
||||
};
|
||||
await expect(loadPeaks(source)).rejects.toThrow("gone");
|
||||
await expect(loadPeaks(source)).rejects.toThrow("gone");
|
||||
expect(calls("GET /api/dumps/:id/waveform")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a local file is decoded here, at 8 kHz, into the shared peaks", async () => {
|
||||
const data = new Float32Array([0.1, -0.5, 0.2, 0.9, -0.3, 0.45]);
|
||||
const created: unknown[][] = [];
|
||||
vi.stubGlobal(
|
||||
"AudioContext",
|
||||
"OfflineAudioContext",
|
||||
class {
|
||||
decodeAudioData = decode;
|
||||
close = close;
|
||||
constructor(...args: unknown[]) {
|
||||
created.push(args);
|
||||
}
|
||||
decodeAudioData = () => Promise.resolve({ getChannelData: () => data });
|
||||
},
|
||||
);
|
||||
route(
|
||||
"GET /api/dumps/:id/file",
|
||||
() => new Response(new Uint8Array([1, 2])),
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(new Uint8Array([1, 2])),
|
||||
);
|
||||
|
||||
const peaks = await extractPeaks(
|
||||
"http://localhost:8000/api/dumps/w1/file",
|
||||
3,
|
||||
);
|
||||
expect([...peaks].map((p) => +p.toFixed(2))).toEqual([0.5, 0.9, 0.4]);
|
||||
expect(close).toHaveBeenCalled();
|
||||
|
||||
await extractPeaks("http://localhost:8000/api/dumps/w1/file", 3);
|
||||
expect(decode).toHaveBeenCalledTimes(1);
|
||||
expect(calls("GET /api/dumps/:id/file")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a failed fetch throws", async () => {
|
||||
route("GET /api/dumps/:id/file", () => fail("gone", 404));
|
||||
await expect(extractPeaks("http://localhost:8000/api/dumps/w2/file", 3))
|
||||
.rejects
|
||||
.toThrow(/404/);
|
||||
const peaks = await loadPeaks({ localFile: "blob:http://x/local-1" });
|
||||
// Fewer samples than peaks: each sample's slice is shared, scaled to 0.9.
|
||||
expect(peaks).toHaveLength(150);
|
||||
expect(peaks[0]).toBe(0.111);
|
||||
expect(Math.max(...peaks)).toBe(1);
|
||||
expect(created).toEqual([[1, 1, 8000]]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user