fix(voice): apply waveform review findings

Three review agents (no regressions found). Applied:
- Keyboard arrow-seek now reads the live media currentTime, not the throttled
  ~500ms state, so rapid presses accumulate instead of dropping steps.
- Scrubbing the waveform (or the fallback seek bar) BEFORE first play now loads
  the media and plays from the clicked position (was a silent no-op).
- Unplayed bars use a dimmed accent (color-mix 32%) instead of a faint surface
  token, for consistent contrast across TDS-dark/light + normal themes.
- Fixed first-bar always-lit off-by-one ((i+1)/len), and added overflow:hidden so
  the strip clips rather than overflows in a very narrow drawer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 22:01:41 -04:00
co-authored by Claude Opus 4.8
parent 56863d2649
commit 7d02f4e538
@@ -62,17 +62,22 @@ function WaveformSeek({
currentTime, currentTime,
duration, duration,
onSeek, onSeek,
getCurrentTime,
}: { }: {
waveform: number[]; waveform: number[];
currentTime: number; currentTime: number;
duration: number; duration: number;
onSeek: (time: number) => void; onSeek: (time: number) => void;
/** Reads the live media time (the `currentTime` prop is throttled ~500ms). */
getCurrentTime: () => number;
}) { }) {
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const bars = useMemo(() => downsampleWaveform(waveform, WAVEFORM_DISPLAY_BARS), [waveform]); const bars = useMemo(() => downsampleWaveform(waveform, WAVEFORM_DISPLAY_BARS), [waveform]);
const barMax = useMemo(() => Math.max(...bars, 1), [bars]); const barMax = useMemo(() => Math.max(...bars, 1), [bars]);
const progress = duration > 0 ? Math.min(1, Math.max(0, currentTime / duration)) : 0; const progress = duration > 0 ? Math.min(1, Math.max(0, currentTime / duration)) : 0;
const accent = lotusTerminal ? 'var(--lt-accent-green)' : color.Primary.Main;
const unplayedColor = `color-mix(in srgb, ${accent} 32%, transparent)`;
const seekFromClientX = useCallback( const seekFromClientX = useCallback(
(clientX: number) => { (clientX: number) => {
@@ -94,12 +99,14 @@ function WaveformSeek({
}; };
const handleKeyDown = (evt: React.KeyboardEvent<HTMLDivElement>) => { const handleKeyDown = (evt: React.KeyboardEvent<HTMLDivElement>) => {
if (duration <= 0) return; if (duration <= 0) return;
// Base off the LIVE time so rapid presses accumulate (the prop is throttled).
const base = getCurrentTime();
if (evt.key === 'ArrowRight' || evt.key === 'ArrowUp') { if (evt.key === 'ArrowRight' || evt.key === 'ArrowUp') {
evt.preventDefault(); evt.preventDefault();
onSeek(Math.min(duration, currentTime + 5)); onSeek(Math.min(duration, base + 5));
} else if (evt.key === 'ArrowLeft' || evt.key === 'ArrowDown') { } else if (evt.key === 'ArrowLeft' || evt.key === 'ArrowDown') {
evt.preventDefault(); evt.preventDefault();
onSeek(Math.max(0, currentTime - 5)); onSeek(Math.max(0, base - 5));
} else if (evt.key === 'Home') { } else if (evt.key === 'Home') {
evt.preventDefault(); evt.preventDefault();
onSeek(0); onSeek(0);
@@ -132,10 +139,11 @@ function WaveformSeek({
height: toRem(24), height: toRem(24),
cursor: 'pointer', cursor: 'pointer',
touchAction: 'none', touchAction: 'none',
overflow: 'hidden',
}} }}
> >
{bars.map((v, i) => { {bars.map((v, i) => {
const played = bars.length > 0 && i / bars.length <= progress; const played = bars.length > 0 && (i + 1) / bars.length <= progress;
return ( return (
<div <div
key={i} key={i}
@@ -144,11 +152,7 @@ function WaveformSeek({
minWidth: toRem(2), minWidth: toRem(2),
height: toRem(2 + (v / barMax) * 16), height: toRem(2 + (v / barMax) * 16),
borderRadius: toRem(1), borderRadius: toRem(1),
background: played background: played ? accent : unplayedColor,
? lotusTerminal
? 'var(--lt-accent-green)'
: color.Primary.Main
: color.SurfaceVariant.ContainerActive,
transition: 'background 0.1s', transition: 'background 0.1s',
}} }}
/> />
@@ -196,6 +200,8 @@ export function AudioContent({
); );
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
// A seek requested before the media has loaded; applied once metadata arrives.
const pendingSeekRef = useRef<number | null>(null);
useEffect( useEffect(
() => () => { () => () => {
@@ -236,6 +242,11 @@ export function AudioContent({
if (!audio) return undefined; if (!audio) return undefined;
const applyRate = () => { const applyRate = () => {
audio.playbackRate = playbackSpeed; audio.playbackRate = playbackSpeed;
// Apply a seek that was requested before the source loaded.
if (pendingSeekRef.current != null && audio.readyState >= 1) {
audio.currentTime = pendingSeekRef.current;
pendingSeekRef.current = null;
}
}; };
// Apply immediately, and re-apply whenever the media element (re)loads a new // Apply immediately, and re-apply whenever the media element (re)loads a new
// source — e.g. after async decrypt swaps in the blob URL — since the browser // source — e.g. after async decrypt swaps in the blob URL — since the browser
@@ -265,6 +276,20 @@ export function AudioContent({
} }
}; };
// Seeking before the media has loaded (e.g. clicking the waveform first) loads it
// and applies the position once metadata arrives (the <audio> autoPlays).
const handleSeek = useCallback(
(time: number) => {
if (srcState.status === AsyncStatus.Success) {
seek(time);
} else if (srcState.status !== AsyncStatus.Loading) {
pendingSeekRef.current = time;
loadSrc();
}
},
[srcState.status, seek, loadSrc],
);
const hasWaveform = !!waveform && waveform.length > 0 && duration > 0; const hasWaveform = !!waveform && waveform.length > 0 && duration > 0;
return renderMediaControl({ return renderMediaControl({
@@ -273,7 +298,8 @@ export function AudioContent({
waveform={waveform ?? []} waveform={waveform ?? []}
currentTime={currentTime} currentTime={currentTime}
duration={duration} duration={duration}
onSeek={seek} onSeek={handleSeek}
getCurrentTime={() => audioRef.current?.currentTime ?? currentTime}
/> />
) : ( ) : (
<Range <Range
@@ -281,7 +307,7 @@ export function AudioContent({
min={0} min={0}
max={duration || 1} max={duration || 1}
values={[currentTime]} values={[currentTime]}
onChange={(values) => seek(values[0])} onChange={(values) => handleSeek(values[0])}
renderTrack={(params) => ( renderTrack={(params) => (
<div {...params.props}> <div {...params.props}>
{params.children} {params.children}