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,
|
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user