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:
khannurien
2026-10-09 13:34:22 +00:00
parent c709b56911
commit fac0e2db28
30 changed files with 1217 additions and 680 deletions

View File

@@ -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;