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:
103
src/App.css
103
src/App.css
@@ -1063,10 +1063,9 @@
|
||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
||||
}
|
||||
|
||||
/* When active, brighten only the *un-played* bars. Excluding played bars keeps
|
||||
this rule from out-specifying `.waveform-bar--played` (which would paint every
|
||||
bar the played colour and make the waveform look stuck at 100%). */
|
||||
.audio-file-preview--active .waveform-bar:not(.waveform-bar--played) {
|
||||
/* When active, brighten the bars still to play: the outer path only, since the
|
||||
played copy sits in the nested svg. */
|
||||
.audio-file-preview--active .scrubber-peaks > path {
|
||||
fill: color-mix(in srgb, var(--color-accent) 45%, var(--color-border) 55%);
|
||||
}
|
||||
|
||||
@@ -1213,75 +1212,75 @@
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Waveform ── */
|
||||
.waveform-svg {
|
||||
/* ── Scrubber: the track MediaPlayer seeks on, a waveform or a plain bar ── */
|
||||
.scrubber {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
/* A drag on a phone scrubs rather than scrolls the page. */
|
||||
touch-action: none;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.waveform-bar {
|
||||
.scrubber:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* A video's controls overlay the picture, so its track stays thin. */
|
||||
.scrubber--slim {
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.scrubber-peaks {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.scrubber-peaks path {
|
||||
fill: color-mix(in srgb, var(--color-accent) 25%, var(--color-border) 75%);
|
||||
}
|
||||
|
||||
.waveform-bar--played {
|
||||
/* The same bars, clipped to the playhead by this nested svg's width. */
|
||||
.scrubber-peaks-played {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrubber-peaks-played path {
|
||||
fill: var(--color-accent);
|
||||
}
|
||||
|
||||
.waveform-skeleton {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 48px;
|
||||
.scrubber-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: var(--radius-xs);
|
||||
overflow: hidden;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 12%,
|
||||
var(--color-border) 88%
|
||||
);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.waveform-skeleton-fill {
|
||||
.scrubber--slim .scrubber-bar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.video-player-controls .scrubber-bar {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.scrubber-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: var(--color-accent);
|
||||
opacity: 0.45;
|
||||
transition: width var(--dur-fast) linear;
|
||||
}
|
||||
|
||||
/* Audio whose peaks can't be decoded (cross-origin streams). Boxed to the
|
||||
waveform's height so the player doesn't resize between item kinds, with the
|
||||
bar itself centred inside. */
|
||||
.audio-player-track--stream {
|
||||
height: 48px;
|
||||
background: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.audio-player-track--stream::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 50% 0 auto 0;
|
||||
transform: translateY(-50%);
|
||||
height: 6px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 12%,
|
||||
var(--color-border) 88%
|
||||
);
|
||||
}
|
||||
.audio-player-track--stream .audio-player-fill {
|
||||
top: 50%;
|
||||
bottom: auto;
|
||||
transform: translateY(-50%);
|
||||
height: 6px;
|
||||
border-radius: var(--radius-xs);
|
||||
z-index: var(--z-raised);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.audio-player-track--volume {
|
||||
@@ -1341,7 +1340,7 @@
|
||||
}
|
||||
|
||||
/* A narrow bar keeps the mute button but drops the volume slider, which took
|
||||
as much room as the waveform (a phone's volume is in its buttons anyway). */
|
||||
as much room as the scrubber (a phone's volume is in its buttons anyway). */
|
||||
@container (max-width: 420px) {
|
||||
.audio-player-track--volume {
|
||||
display: none;
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
import { useContext } from "react";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import { formatBytes } from "../../utils/shared/format.ts";
|
||||
import { dumpFileUrl, dumpThumbnailUrl } from "../../utils/shared/urls.ts";
|
||||
import {
|
||||
dumpFileUrl,
|
||||
dumpThumbnailUrl,
|
||||
dumpWaveformUrl,
|
||||
} from "../../utils/shared/urls.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import {
|
||||
fileArtworkUrl,
|
||||
@@ -16,7 +20,8 @@ import {
|
||||
usePlayFile,
|
||||
} from "../../hooks/player/usePlayFile.ts";
|
||||
import { MediaPlayer } from "../player/MediaPlayer.tsx";
|
||||
import { Waveform } from "../player/Waveform.tsx";
|
||||
import { Scrubber } from "../player/Scrubber.tsx";
|
||||
import { usePeaks } from "../../hooks/player/usePeaks.ts";
|
||||
import { Thumbnail } from "../shared/Thumbnail.tsx";
|
||||
import {
|
||||
PlayerContext,
|
||||
@@ -84,7 +89,9 @@ function FilePlayButton({ dump }: { dump: Dump }) {
|
||||
function AudioFilePreview({ dump }: { dump: Dump }) {
|
||||
const { seekTo } = useContext(PlayerContext);
|
||||
const { currentTime, duration } = useContext(PlayerTimeContext);
|
||||
const { fileUrl, active, toggle } = usePlayFile(dump);
|
||||
const { mediaToken: token } = useAuth();
|
||||
const { active, toggle } = usePlayFile(dump);
|
||||
const peaks = usePeaks({ peaksUrl: dumpWaveformUrl(dump, token) });
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -93,13 +100,13 @@ function AudioFilePreview({ dump }: { dump: Dump }) {
|
||||
}`}
|
||||
>
|
||||
<FilePlayButton dump={dump} />
|
||||
<Waveform
|
||||
src={fileUrl}
|
||||
<Scrubber
|
||||
current={active ? currentTime : 0}
|
||||
duration={active ? duration : 0}
|
||||
// The player has no media to seek yet, so a first click plays from the
|
||||
// start and ignores where it landed.
|
||||
onSeek={(time) => active ? seekTo(time) : toggle()}
|
||||
peaks={peaks}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -190,14 +197,18 @@ export function FilePreview(
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MediaPlayer src={fileUrl} kind="video" mime={mime} poster={artworkUrl} />
|
||||
);
|
||||
return <MediaPlayer src={fileUrl} kind="video" poster={artworkUrl} />;
|
||||
}
|
||||
|
||||
if (mime.startsWith("audio/")) {
|
||||
if (useGlobal) return <AudioFilePreview dump={dump} />;
|
||||
return <MediaPlayer src={fileUrl} kind="audio" mime={mime} />;
|
||||
return (
|
||||
<MediaPlayer
|
||||
src={fileUrl}
|
||||
kind="audio"
|
||||
waveform={{ peaksUrl: dumpWaveformUrl(dump, token) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mime === "application/pdf") {
|
||||
|
||||
@@ -38,10 +38,18 @@ export function LocalFilePreview(
|
||||
return <img src={src} alt={file.name} className="local-preview-image" />;
|
||||
}
|
||||
if (mime.startsWith("video/")) {
|
||||
return <MediaPlayer key={src} src={src} kind="video" mime={mime} />;
|
||||
return <MediaPlayer key={src} src={src} kind="video" />;
|
||||
}
|
||||
if (mime.startsWith("audio/")) {
|
||||
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
|
||||
// Not uploaded yet, so its waveform is decoded here.
|
||||
return (
|
||||
<MediaPlayer
|
||||
key={src}
|
||||
src={src}
|
||||
kind="audio"
|
||||
waveform={{ localFile: src }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -289,7 +289,9 @@ export function GlobalPlayer(): React.JSX.Element | null {
|
||||
key={current.fileUrl}
|
||||
src={current.fileUrl}
|
||||
kind={current.mimeType.startsWith("video/") ? "video" : "audio"}
|
||||
mime={current.mimeType}
|
||||
waveform={current.waveformUrl
|
||||
? { peaksUrl: current.waveformUrl }
|
||||
: undefined}
|
||||
autoplay={autoplay}
|
||||
startTime={startTime}
|
||||
onPlayStateChange={onPlayStateChange}
|
||||
@@ -308,8 +310,6 @@ export function GlobalPlayer(): React.JSX.Element | null {
|
||||
key={current.streamUrl}
|
||||
src={current.streamUrl}
|
||||
kind="audio"
|
||||
mime="audio/mpeg"
|
||||
trackStyle="progress"
|
||||
autoplay={autoplay}
|
||||
startTime={startTime}
|
||||
onPlayStateChange={onPlayStateChange}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fmt } from "../../utils/player/duration.ts";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
IconPlay,
|
||||
IconVolume,
|
||||
} from "./PlayerIcons.tsx";
|
||||
import { SeekBar } from "./SeekBar.tsx";
|
||||
import { Waveform } from "./Waveform.tsx";
|
||||
import { Scrubber } from "./Scrubber.tsx";
|
||||
import { usePeaks } from "../../hooks/player/usePeaks.ts";
|
||||
import type { PeaksSource } from "../../utils/player/waveform.ts";
|
||||
import { rememberVolume, storedVolume } from "../../utils/player/volume.ts";
|
||||
|
||||
/** How long a playing video's controls stay after the pointer stops, in ms. */
|
||||
@@ -30,8 +31,6 @@ const HIDE_DELAY = 2500;
|
||||
export interface MediaPlayerProps {
|
||||
src: string;
|
||||
kind: "audio" | "video";
|
||||
/** The `<source>` type, when known. */
|
||||
mime?: string;
|
||||
/** Shown before a video plays. */
|
||||
poster?: string;
|
||||
autoplay?: boolean;
|
||||
@@ -47,10 +46,10 @@ export interface MediaPlayerProps {
|
||||
*/
|
||||
onError?: () => void;
|
||||
/**
|
||||
* "waveform" (the default for audio) decodes peaks from the source;
|
||||
* "progress" draws a seek bar and never fetches the media a second time.
|
||||
* Where an audio file's waveform comes from. Without it, as for a stream
|
||||
* whose bytes another site serves, audio seeks on a plain bar.
|
||||
*/
|
||||
trackStyle?: "waveform" | "progress";
|
||||
waveform?: PeaksSource;
|
||||
/** Receives its seek handle while mounted, for PlayerContext's `seekTo`. */
|
||||
seekRef?: { current: ((t: number) => void) | null };
|
||||
/** Receives its play/pause handle while mounted, for `togglePlay`. */
|
||||
@@ -58,17 +57,17 @@ export interface MediaPlayerProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a media element with the site's own controls. Audio gets a waveform
|
||||
* to seek on, unless `trackStyle` asks for a seek bar; video gets a seek bar, a
|
||||
* fullscreen button, and controls that hide while it plays. The volume carries
|
||||
* over to the next MediaPlayer, which matters since GlobalPlayer mounts a new
|
||||
* one for each track.
|
||||
* Renders a media element with the site's own controls. Audio seeks on its
|
||||
* waveform when it has one; video gets a slim bar, a fullscreen button, and
|
||||
* controls that hide while it plays. Whether it plays is read from the
|
||||
* element's own events, so a media key or the OS's controls show too. The
|
||||
* volume carries over to the next MediaPlayer, which matters since
|
||||
* GlobalPlayer mounts a new one for each track.
|
||||
*/
|
||||
export function MediaPlayer(
|
||||
{
|
||||
src,
|
||||
kind,
|
||||
mime,
|
||||
poster,
|
||||
autoplay,
|
||||
startTime,
|
||||
@@ -76,7 +75,7 @@ export function MediaPlayer(
|
||||
onTimeUpdate,
|
||||
onEnded,
|
||||
onError,
|
||||
trackStyle = "waveform",
|
||||
waveform,
|
||||
seekRef,
|
||||
toggleRef,
|
||||
}: MediaPlayerProps,
|
||||
@@ -85,11 +84,13 @@ export function MediaPlayer(
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
// Where the scrubber is held, which the clock shows until it lets go.
|
||||
const [scrubTime, setScrubTime] = useState<number | null>(null);
|
||||
const [volume, setVolume] = useState(() => storedVolume().volume);
|
||||
const [muted, setMuted] = useState(() => storedVolume().muted);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const peaks = usePeaks(kind === "audio" ? waveform : undefined);
|
||||
|
||||
// The media listeners call the latest callbacks through these refs, without
|
||||
// being attached again. They are written after render, as the linter wants;
|
||||
@@ -106,40 +107,18 @@ export function MediaPlayer(
|
||||
onErrorRef.current = onError;
|
||||
});
|
||||
|
||||
// seekRef and toggleRef get wrappers that call these, so the handles never go
|
||||
// null while the functions are replaced.
|
||||
const seekToFnRef = useRef((_t: number) => {});
|
||||
const toggleFnRef = useRef(() => {});
|
||||
|
||||
// Everything it uses is stable, so it is set once.
|
||||
useEffect(() => {
|
||||
seekToFnRef.current = (time: number) => {
|
||||
setCurrent(time);
|
||||
mediaRef.current!.currentTime = time;
|
||||
};
|
||||
// Both read the element alone, so they never change, and the handles given
|
||||
// to PlayerProvider stay valid while this is mounted.
|
||||
const seekTo = useCallback((time: number) => {
|
||||
setCurrent(time);
|
||||
mediaRef.current!.currentTime = time;
|
||||
}, []);
|
||||
// The play and pause events update the state, whoever asked.
|
||||
const toggle = useCallback(() => {
|
||||
const a = mediaRef.current!;
|
||||
if (a.paused) a.play().catch(() => {});
|
||||
else a.pause();
|
||||
}, []);
|
||||
|
||||
// It reads `playing`, so it is replaced when that changes.
|
||||
useEffect(() => {
|
||||
toggleFnRef.current = () => {
|
||||
const a = mediaRef.current!;
|
||||
if (playing) {
|
||||
a.pause();
|
||||
setPlaying(false);
|
||||
onPlayStateChangeRef.current?.(false);
|
||||
} else {
|
||||
a.play()
|
||||
.then(() => {
|
||||
setPlaying(true);
|
||||
onPlayStateChangeRef.current?.(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [playing]);
|
||||
|
||||
const seekTo = (time: number) => seekToFnRef.current(time);
|
||||
const toggle = () => toggleFnRef.current();
|
||||
|
||||
// The element can't seek before its metadata loads.
|
||||
useEffect(() => {
|
||||
@@ -165,18 +144,6 @@ export function MediaPlayer(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// The browser may refuse to play without a user gesture, and the player then
|
||||
// just shows paused.
|
||||
useEffect(() => {
|
||||
if (!autoplay) return;
|
||||
mediaRef.current?.play()
|
||||
.then(() => {
|
||||
setPlaying(true);
|
||||
onPlayStateChangeRef.current?.(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [autoplay]);
|
||||
|
||||
// Cutting the callbacks on unmount keeps a late timeupdate or ended event of
|
||||
// this element from reaching PlayerProvider once the next item is playing.
|
||||
useEffect(() => {
|
||||
@@ -192,22 +159,19 @@ export function MediaPlayer(
|
||||
|
||||
// The refs are PlayerProvider's, which never change, so this runs once.
|
||||
useEffect(() => {
|
||||
if (seekRef) seekRef.current = (t) => seekToFnRef.current(t);
|
||||
if (toggleRef) toggleRef.current = () => toggleFnRef.current();
|
||||
if (seekRef) seekRef.current = seekTo;
|
||||
if (toggleRef) toggleRef.current = toggle;
|
||||
return () => {
|
||||
if (seekRef) seekRef.current = null;
|
||||
if (toggleRef) toggleRef.current = null;
|
||||
};
|
||||
}, [seekRef, toggleRef]);
|
||||
}, [seekRef, toggleRef, seekTo, toggle]);
|
||||
|
||||
useEffect(() => {
|
||||
const a = mediaRef.current!;
|
||||
const onTime = () => {
|
||||
// While the seek bar is dragged, its thumb follows the pointer.
|
||||
if (!dragging) {
|
||||
setCurrent(a.currentTime);
|
||||
onTimeUpdateRef.current?.(a.currentTime, a.duration);
|
||||
}
|
||||
setCurrent(a.currentTime);
|
||||
onTimeUpdateRef.current?.(a.currentTime, a.duration);
|
||||
};
|
||||
const onDuration = () => {
|
||||
setDuration(a.duration);
|
||||
@@ -222,9 +186,6 @@ export function MediaPlayer(
|
||||
setPlaying(false);
|
||||
onErrorRef.current?.();
|
||||
};
|
||||
// The element can also be started or stopped from outside the page — a
|
||||
// media key, a headset, the OS's media controls — so follow it rather
|
||||
// than only what our own button did.
|
||||
const onPlayEvent = () => {
|
||||
setPlaying(true);
|
||||
onPlayStateChangeRef.current?.(true);
|
||||
@@ -247,7 +208,13 @@ export function MediaPlayer(
|
||||
a.removeEventListener("ended", onEndedEvent);
|
||||
a.removeEventListener("error", onErrorEvent);
|
||||
};
|
||||
}, [dragging]);
|
||||
}, []);
|
||||
|
||||
// After the listeners, which the play event must find. The browser may
|
||||
// refuse to play without a user gesture, and the player then shows paused.
|
||||
useEffect(() => {
|
||||
if (autoplay) mediaRef.current?.play().catch(() => {});
|
||||
}, [autoplay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (kind !== "video") return;
|
||||
@@ -300,27 +267,6 @@ export function MediaPlayer(
|
||||
(mediaRef.current as HTMLVideoElement).requestFullscreen?.();
|
||||
};
|
||||
|
||||
// A waveform needs the raw bytes, which cross-origin streams won't hand over.
|
||||
// Those get an honest progress bar rather than a permanent loading skeleton.
|
||||
const track = kind === "audio" && trackStyle === "waveform"
|
||||
? (
|
||||
<Waveform
|
||||
src={src}
|
||||
current={current}
|
||||
duration={duration}
|
||||
onSeek={seekTo}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<SeekBar
|
||||
current={current}
|
||||
duration={duration}
|
||||
onSeek={seekTo}
|
||||
onDragChange={setDragging}
|
||||
className={kind === "audio" ? "audio-player-track--stream" : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
const controls = (
|
||||
<>
|
||||
<button
|
||||
@@ -332,9 +278,16 @@ export function MediaPlayer(
|
||||
{playing ? <IconPause /> : <IconPlay />}
|
||||
</button>
|
||||
|
||||
<span className="audio-player-time">{fmt(current)}</span>
|
||||
<span className="audio-player-time">{fmt(scrubTime ?? current)}</span>
|
||||
|
||||
{track}
|
||||
<Scrubber
|
||||
current={current}
|
||||
duration={duration}
|
||||
onSeek={seekTo}
|
||||
onScrub={setScrubTime}
|
||||
peaks={peaks}
|
||||
slim={kind === "video"}
|
||||
/>
|
||||
|
||||
<span className="audio-player-time">{fmt(duration)}</span>
|
||||
|
||||
@@ -394,9 +347,7 @@ export function MediaPlayer(
|
||||
preload="metadata"
|
||||
className="video-player-video"
|
||||
onClick={toggle}
|
||||
>
|
||||
{mime && <source src={src} type={mime} />}
|
||||
</video>
|
||||
/>
|
||||
<div className="video-player-controls audio-player">
|
||||
{controls}
|
||||
</div>
|
||||
@@ -410,9 +361,7 @@ export function MediaPlayer(
|
||||
ref={mediaRef as React.RefObject<HTMLAudioElement>}
|
||||
src={src}
|
||||
preload="metadata"
|
||||
>
|
||||
{mime && <source src={src} type={mime} />}
|
||||
</audio>
|
||||
/>
|
||||
{controls}
|
||||
</div>
|
||||
);
|
||||
|
||||
151
src/components/player/Scrubber.tsx
Normal file
151
src/components/player/Scrubber.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Renders the track MediaPlayer seeks on: an audio file's waveform, or a plain
|
||||
* bar for a stream, a video, or audio whose waveform isn't in yet. The dump
|
||||
* page's audio preview shows one too, beside its cover.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { fmt } from "../../utils/player/duration.ts";
|
||||
|
||||
/** A bar's width, in viewBox units. */
|
||||
const BAR_W = 3;
|
||||
/** The gap between bars, in viewBox units. */
|
||||
const BAR_GAP = 1;
|
||||
/** The waveform's height, in viewBox units. */
|
||||
const WAVEFORM_H = 48;
|
||||
/** How far an arrow key seeks, in seconds. */
|
||||
const KEY_STEP = 5;
|
||||
|
||||
/** Passed to {@link Scrubber} by MediaPlayer and FilePreview. Times are in seconds. */
|
||||
export interface ScrubberProps {
|
||||
current: number;
|
||||
duration: number;
|
||||
/** Called once a click, a drag or a key lands somewhere. */
|
||||
onSeek: (time: number) => void;
|
||||
/**
|
||||
* Called with the time under the pointer while it holds the scrubber, and
|
||||
* with null once it lets go, so MediaPlayer's clock can follow the drag.
|
||||
*/
|
||||
onScrub?: (time: number | null) => void;
|
||||
/** The waveform's heights from 0 to 1; without them, a plain bar. */
|
||||
peaks?: number[] | null;
|
||||
/** Draws the thin bar of a video's controls instead of the audio track. */
|
||||
slim?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the waveform's bars as one path, each `BAR_W` wide and centred on
|
||||
* the middle line, so a redraw for the playhead touches two attributes rather
|
||||
* than a hundred and fifty elements.
|
||||
*/
|
||||
function barsPath(peaks: number[]): string {
|
||||
return peaks.map((p, i) => {
|
||||
const h = Math.max(p * WAVEFORM_H, 2);
|
||||
const x = i * (BAR_W + BAR_GAP);
|
||||
return `M${x} ${(WAVEFORM_H - h) / 2}h${BAR_W}v${h}h-${BAR_W}z`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a slider over the media's timeline. A pointer seeks where it lets
|
||||
* go, showing where it is while held; arrows seek {@link KEY_STEP} seconds,
|
||||
* Home and End to either end. The played part of the waveform is the same
|
||||
* path clipped to the playhead by a nested `<svg>`, so it moves smoothly
|
||||
* within a bar.
|
||||
*/
|
||||
export function Scrubber(
|
||||
{ current, duration, onSeek, onScrub, peaks, slim = false }: ScrubberProps,
|
||||
): React.JSX.Element {
|
||||
// The fraction under the pointer while it holds the scrubber.
|
||||
const [held, setHeld] = useState<number | null>(null);
|
||||
const path = useMemo(() => peaks && barsPath(peaks), [peaks]);
|
||||
|
||||
const shown = held !== null ? held * duration : current;
|
||||
const progress = duration > 0
|
||||
? Math.min(1, Math.max(0, shown / duration))
|
||||
: 0;
|
||||
|
||||
const fractionAt = (e: React.PointerEvent<HTMLElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
if (rect.width <= 0) return 0;
|
||||
return Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
};
|
||||
const hold = (fraction: number | null) => {
|
||||
setHeld(fraction);
|
||||
onScrub?.(fraction === null ? null : fraction * duration);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
const to = {
|
||||
ArrowLeft: current - KEY_STEP,
|
||||
ArrowDown: current - KEY_STEP,
|
||||
ArrowRight: current + KEY_STEP,
|
||||
ArrowUp: current + KEY_STEP,
|
||||
Home: 0,
|
||||
End: duration,
|
||||
}[e.key];
|
||||
if (to === undefined || !(duration > 0)) return;
|
||||
e.preventDefault();
|
||||
onSeek(Math.min(duration, Math.max(0, to)));
|
||||
};
|
||||
|
||||
const width = peaks ? peaks.length * (BAR_W + BAR_GAP) - BAR_GAP : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={t`Seek`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Math.floor(duration) || 0}
|
||||
aria-valuenow={Math.floor(shown) || 0}
|
||||
aria-valuetext={`${fmt(shown)} / ${fmt(duration)}`}
|
||||
className={`scrubber${slim ? " scrubber--slim" : ""}`}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
// Keeps the pointer's moves coming here when it leaves the track.
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
hold(fractionAt(e));
|
||||
}}
|
||||
onPointerMove={(e) => held !== null && hold(fractionAt(e))}
|
||||
onPointerUp={(e) => {
|
||||
if (held === null) return;
|
||||
const fraction = fractionAt(e);
|
||||
hold(null);
|
||||
onSeek(fraction * duration);
|
||||
}}
|
||||
onPointerCancel={() => held !== null && hold(null)}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{path
|
||||
? (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${WAVEFORM_H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="scrubber-peaks"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={path} />
|
||||
<svg
|
||||
width={progress * width}
|
||||
height={WAVEFORM_H}
|
||||
className="scrubber-peaks-played"
|
||||
>
|
||||
<path d={path} />
|
||||
</svg>
|
||||
</svg>
|
||||
)
|
||||
: (
|
||||
<div className="scrubber-bar">
|
||||
<div
|
||||
className="scrubber-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Renders the slim seek bar MediaPlayer shows for video and for streams.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
/** Passed to {@link SeekBar} by MediaPlayer. Times are in seconds. */
|
||||
export interface SeekBarProps {
|
||||
current: number;
|
||||
duration: number;
|
||||
onSeek: (t: number) => void;
|
||||
/**
|
||||
* Called as the thumb is grabbed and let go, so that MediaPlayer stops moving
|
||||
* it with the playhead in between.
|
||||
*/
|
||||
onDragChange: (dragging: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a progress bar with a range input over it to seek. MediaPlayer uses
|
||||
* it for video, and for streams, whose bytes can't be read cross-origin to
|
||||
* draw a waveform.
|
||||
*/
|
||||
export function SeekBar(
|
||||
{ current, duration, onSeek, onDragChange, className }: SeekBarProps,
|
||||
): React.JSX.Element {
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
return (
|
||||
<div className={`audio-player-track${className ? ` ${className}` : ""}`}>
|
||||
<div
|
||||
className="audio-player-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
className="audio-player-range"
|
||||
min={0}
|
||||
max={duration || 1}
|
||||
step={0.01}
|
||||
value={current}
|
||||
// Pointer events, so a finger holds the thumb as a mouse does.
|
||||
onPointerDown={() =>
|
||||
onDragChange(true)}
|
||||
onPointerUp={() => onDragChange(false)}
|
||||
onPointerCancel={() => onDragChange(false)}
|
||||
onChange={(e) => onSeek(Number(e.target.value))}
|
||||
aria-label={t`Seek`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Renders an audio file's waveform, which MediaPlayer shows as its seek bar and
|
||||
* an audio file's dump page beside its cover.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
BAR_GAP,
|
||||
BAR_W,
|
||||
extractPeaks,
|
||||
NUM_BARS,
|
||||
VIEWBOX_W,
|
||||
WAVEFORM_H,
|
||||
} from "../../utils/player/waveform.ts";
|
||||
|
||||
/**
|
||||
* Passed to {@link Waveform} by MediaPlayer and FilePreview. Times are in
|
||||
* seconds.
|
||||
*/
|
||||
export interface WaveformProps {
|
||||
/** Fetched again, apart from the media element, to decode its peaks. */
|
||||
src: string;
|
||||
current: number;
|
||||
duration: number;
|
||||
onSeek: (t: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the peaks of `src` as bars, the played ones highlighted, and seeks
|
||||
* where clicked. Until the peaks are decoded, or if they can't be, it shows a
|
||||
* plain progress bar that seeks all the same.
|
||||
*/
|
||||
export function Waveform(
|
||||
{ src, current, duration, onSeek }: WaveformProps,
|
||||
): React.JSX.Element {
|
||||
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
extractPeaks(src, NUM_BARS)
|
||||
.then((p) => {
|
||||
if (!cancelled) setPeaks(p);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
|
||||
const handleClick = (e: React.MouseEvent<Element>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
);
|
||||
onSeek(ratio * duration);
|
||||
};
|
||||
|
||||
if (!peaks) {
|
||||
return (
|
||||
<div className="waveform-skeleton" onClick={handleClick}>
|
||||
<div
|
||||
className="waveform-skeleton-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEWBOX_W} ${WAVEFORM_H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="waveform-svg"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{Array.from(peaks).map((p, i) => {
|
||||
const barH = Math.max(p * WAVEFORM_H, 2);
|
||||
const x = i * (BAR_W + BAR_GAP);
|
||||
const y = (WAVEFORM_H - barH) / 2;
|
||||
const played = i / NUM_BARS <= progress;
|
||||
return (
|
||||
<rect
|
||||
key={i}
|
||||
x={x}
|
||||
y={y}
|
||||
width={BAR_W}
|
||||
height={barH}
|
||||
className={`waveform-bar${played ? " waveform-bar--played" : ""}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,8 @@ export type PlayerItem =
|
||||
fileUrl: string;
|
||||
/** Decides between the audio and the video player. */
|
||||
mimeType: string;
|
||||
/** An audio file's waveform (`dumpWaveformUrl`), for the player's track. */
|
||||
waveformUrl?: string;
|
||||
})
|
||||
/**
|
||||
* A track streamed from another site, today a Bandcamp mp3. Its URL is
|
||||
|
||||
38
src/hooks/player/usePeaks.ts
Normal file
38
src/hooks/player/usePeaks.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Gives MediaPlayer the waveform it draws on its `Scrubber`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
loadPeaks,
|
||||
peaksKey,
|
||||
type PeaksSource,
|
||||
} from "../../utils/player/waveform.ts";
|
||||
|
||||
/**
|
||||
* Returns the peaks of `source` once loaded, and null until then, without a
|
||||
* source, or when it can't be loaded: the scrubber then draws a plain bar.
|
||||
*/
|
||||
export function usePeaks(source: PeaksSource | undefined): number[] | null {
|
||||
const key = source && peaksKey(source);
|
||||
const [loaded, setLoaded] = useState<{ key: string; peaks: number[] }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) return;
|
||||
let cancelled = false;
|
||||
loadPeaks(source).then(
|
||||
(peaks) => !cancelled && setLoaded({ key: peaksKey(source), peaks }),
|
||||
() => {},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// The source is a new object on each render; its key says when it changed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
// A load for another source may still be in, until this one's arrives.
|
||||
return loaded && loaded.key === key ? loaded.peaks : null;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
dumpFileUrl,
|
||||
dumpThumbnailUrl,
|
||||
dumpUrl,
|
||||
dumpWaveformUrl,
|
||||
} from "../../utils/shared/urls.ts";
|
||||
|
||||
/** Checks whether `dump` is an uploaded file the global player can play. */
|
||||
@@ -76,6 +77,9 @@ export function usePlayFile(dump: Dump): UsePlayFileResult {
|
||||
kind: "file",
|
||||
fileUrl,
|
||||
mimeType: dump.fileMime ?? "",
|
||||
waveformUrl: dump.fileMime?.startsWith("audio/")
|
||||
? dumpWaveformUrl(dump, token)
|
||||
: undefined,
|
||||
title: dump.title,
|
||||
dumpHref: dumpUrl(dump),
|
||||
artworkUrl: fileArtworkUrl(dump, token),
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,64 +1,66 @@
|
||||
/**
|
||||
* Decodes an audio file into the peaks of its waveform, and sets the waveform's
|
||||
* geometry. MediaPlayer's `Waveform` and the dump page's audio preview
|
||||
* (`FilePreview`) both draw with it.
|
||||
* Loads an audio file's waveform for `Scrubber`, by way of `usePeaks`: an
|
||||
* uploaded file's from the server, which made it once with ffmpeg, and a
|
||||
* file not yet uploaded (the dump form's preview) by decoding it here.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** How many bars the waveform draws, one per peak. */
|
||||
export const NUM_BARS = 150;
|
||||
/** A bar's width, in viewBox units. */
|
||||
export const BAR_W = 3;
|
||||
/** The gap between bars, in viewBox units. */
|
||||
export const BAR_GAP = 1;
|
||||
/** The waveform's height, in viewBox units. */
|
||||
export const WAVEFORM_H = 48;
|
||||
/** The waveform's width, in viewBox units. */
|
||||
export const VIEWBOX_W = NUM_BARS * (BAR_W + BAR_GAP);
|
||||
import { type DumpWaveform, peaksOf } from "../../../shared/waveform.ts";
|
||||
import { expectOk } from "../shared/api.ts";
|
||||
|
||||
/**
|
||||
* Kept at module level, so that remounts and StrictMode's double effects don't
|
||||
* fetch and decode a file again.
|
||||
* Where a waveform's peaks come from: the server's for an uploaded file
|
||||
* (`dumpWaveformUrl`), or the bytes of a local file, by its `blob:` URL.
|
||||
*/
|
||||
const peaksCache = new Map<string, Float32Array>();
|
||||
export type PeaksSource = { peaksUrl: string } | { localFile: string };
|
||||
|
||||
/**
|
||||
* Returns `n` peaks of the audio at `url`, each the loudest sample of its slice
|
||||
* of the first channel. Each URL is fetched and decoded once per page load.
|
||||
*
|
||||
* @throws {Error} When the fetch or the decoding fails.
|
||||
* Kept at module level, by URL, so remounts, StrictMode's double effects and
|
||||
* the several players of one file share a load. A failed one is dropped, to be
|
||||
* tried again by the next player.
|
||||
*/
|
||||
export async function extractPeaks(
|
||||
url: string,
|
||||
n: number,
|
||||
): Promise<Float32Array> {
|
||||
if (peaksCache.has(url)) return peaksCache.get(url)!;
|
||||
const loads = new Map<string, Promise<number[]>>();
|
||||
|
||||
// No credentials: the server's CORS doesn't allow them, and the file URL
|
||||
// carries the viewer's media token already. <audio> needs no CORS, this does.
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
|
||||
const buf = await res.arrayBuffer();
|
||||
|
||||
const actx = new AudioContext();
|
||||
try {
|
||||
const decoded = await actx.decodeAudioData(buf);
|
||||
const data = decoded.getChannelData(0);
|
||||
const blockSize = Math.floor(data.length / n);
|
||||
const peaks = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
let peak = 0;
|
||||
const offset = i * blockSize;
|
||||
for (let j = 0; j < blockSize; j++) {
|
||||
const abs = Math.abs(data[offset + j]);
|
||||
if (abs > peak) peak = abs;
|
||||
}
|
||||
peaks[i] = peak;
|
||||
}
|
||||
peaksCache.set(url, peaks);
|
||||
return peaks;
|
||||
} finally {
|
||||
actx.close();
|
||||
}
|
||||
/** Returns the key {@link loads} keeps `source`'s peaks under. */
|
||||
export function peaksKey(source: PeaksSource): string {
|
||||
return "peaksUrl" in source ? source.peaksUrl : source.localFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the peaks of `source`, from 0 to 1.
|
||||
*
|
||||
* @throws {Error} When the server has no waveform for the file, or the local
|
||||
* file can't be decoded.
|
||||
*/
|
||||
export function loadPeaks(source: PeaksSource): Promise<number[]> {
|
||||
const key = peaksKey(source);
|
||||
let load = loads.get(key);
|
||||
if (!load) {
|
||||
load = "peaksUrl" in source
|
||||
? fetchPeaks(source.peaksUrl)
|
||||
: decodePeaks(source.localFile);
|
||||
loads.set(key, load);
|
||||
load.catch(() => loads.delete(key));
|
||||
}
|
||||
return load;
|
||||
}
|
||||
|
||||
async function fetchPeaks(url: string): Promise<number[]> {
|
||||
// No credentials: the URL carries the viewer's media token.
|
||||
const { peaks } = await expectOk<DumpWaveform>(await fetch(url));
|
||||
return peaks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the local file at `url` to its peaks. An offline context at 8 kHz
|
||||
* resamples as it decodes, which holds a sixth of the samples a playback
|
||||
* context would, as the server's decoding does.
|
||||
*/
|
||||
async function decodePeaks(url: string): Promise<number[]> {
|
||||
const bytes = await (await fetch(url)).arrayBuffer();
|
||||
const audio = await new OfflineAudioContext(1, 1, 8000).decodeAudioData(
|
||||
bytes,
|
||||
);
|
||||
return peaksOf(audio.getChannelData(0));
|
||||
}
|
||||
|
||||
@@ -52,12 +52,36 @@ export function dumpFileUrl(
|
||||
export function dumpThumbnailUrl(
|
||||
dump: VersionedDump,
|
||||
token?: string | null,
|
||||
): string {
|
||||
return derivedMediaUrl(dump, "thumbnail", token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL of the waveform of the audio file `dump`, which the server
|
||||
* makes from the file, with the media `token` as for {@link dumpFileUrl}.
|
||||
* The player draws it to seek on.
|
||||
*/
|
||||
export function dumpWaveformUrl(
|
||||
dump: VersionedDump,
|
||||
token?: string | null,
|
||||
): string {
|
||||
return derivedMediaUrl(dump, "waveform", token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL of what the server derives from `dump`'s file, versioned
|
||||
* by it when the dates are known, and with the media `token`.
|
||||
*/
|
||||
function derivedMediaUrl(
|
||||
dump: VersionedDump,
|
||||
resource: "thumbnail" | "waveform",
|
||||
token?: string | null,
|
||||
): string {
|
||||
const params = [
|
||||
...(dump.createdAt ? [`v=${dumpFileVersion(dump)}`] : []),
|
||||
...(token ? [`token=${encodeURIComponent(token)}`] : []),
|
||||
];
|
||||
return `${API_URL}/api/dumps/${dump.id}/thumbnail${
|
||||
return `${API_URL}/api/dumps/${dump.id}/${resource}${
|
||||
params.length ? `?${params.join("&")}` : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user