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:
@@ -62,17 +62,22 @@ function WaveformSeek({
|
||||
currentTime,
|
||||
duration,
|
||||
onSeek,
|
||||
getCurrentTime,
|
||||
}: {
|
||||
waveform: number[];
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onSeek: (time: number) => void;
|
||||
/** Reads the live media time (the `currentTime` prop is throttled ~500ms). */
|
||||
getCurrentTime: () => number;
|
||||
}) {
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const bars = useMemo(() => downsampleWaveform(waveform, WAVEFORM_DISPLAY_BARS), [waveform]);
|
||||
const barMax = useMemo(() => Math.max(...bars, 1), [bars]);
|
||||
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(
|
||||
(clientX: number) => {
|
||||
@@ -94,12 +99,14 @@ function WaveformSeek({
|
||||
};
|
||||
const handleKeyDown = (evt: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
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') {
|
||||
evt.preventDefault();
|
||||
onSeek(Math.min(duration, currentTime + 5));
|
||||
onSeek(Math.min(duration, base + 5));
|
||||
} else if (evt.key === 'ArrowLeft' || evt.key === 'ArrowDown') {
|
||||
evt.preventDefault();
|
||||
onSeek(Math.max(0, currentTime - 5));
|
||||
onSeek(Math.max(0, base - 5));
|
||||
} else if (evt.key === 'Home') {
|
||||
evt.preventDefault();
|
||||
onSeek(0);
|
||||
@@ -132,10 +139,11 @@ function WaveformSeek({
|
||||
height: toRem(24),
|
||||
cursor: 'pointer',
|
||||
touchAction: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{bars.map((v, i) => {
|
||||
const played = bars.length > 0 && i / bars.length <= progress;
|
||||
const played = bars.length > 0 && (i + 1) / bars.length <= progress;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
@@ -144,11 +152,7 @@ function WaveformSeek({
|
||||
minWidth: toRem(2),
|
||||
height: toRem(2 + (v / barMax) * 16),
|
||||
borderRadius: toRem(1),
|
||||
background: played
|
||||
? lotusTerminal
|
||||
? 'var(--lt-accent-green)'
|
||||
: color.Primary.Main
|
||||
: color.SurfaceVariant.ContainerActive,
|
||||
background: played ? accent : unplayedColor,
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
/>
|
||||
@@ -196,6 +200,8 @@ export function AudioContent({
|
||||
);
|
||||
|
||||
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(
|
||||
() => () => {
|
||||
@@ -236,6 +242,11 @@ export function AudioContent({
|
||||
if (!audio) return undefined;
|
||||
const applyRate = () => {
|
||||
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
|
||||
// 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;
|
||||
|
||||
return renderMediaControl({
|
||||
@@ -273,7 +298,8 @@ export function AudioContent({
|
||||
waveform={waveform ?? []}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
onSeek={seek}
|
||||
onSeek={handleSeek}
|
||||
getCurrentTime={() => audioRef.current?.currentTime ?? currentTime}
|
||||
/>
|
||||
) : (
|
||||
<Range
|
||||
@@ -281,7 +307,7 @@ export function AudioContent({
|
||||
min={0}
|
||||
max={duration || 1}
|
||||
values={[currentTime]}
|
||||
onChange={(values) => seek(values[0])}
|
||||
onChange={(values) => handleSeek(values[0])}
|
||||
renderTrack={(params) => (
|
||||
<div {...params.props}>
|
||||
{params.children}
|
||||
|
||||
Reference in New Issue
Block a user