Files
cinny/src/app/components/message/content/AudioContent.tsx
T
jaredandClaude Opus 4.8 7d02f4e538 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>
2026-07-09 22:01:41 -04:00

429 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Badge,
Chip,
color,
Icon,
IconButton,
Icons,
ProgressBar,
Spinner,
Text,
toRem,
} from 'folds';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { Range } from 'react-range';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IAudioInfo } from '../../../../types/matrix/common';
import {
PlayTimeCallback,
useMediaLoading,
useMediaPlay,
useMediaPlayTimeCallback,
useMediaSeek,
useMediaVolume,
} from '../../../hooks/media';
import { useThrottle } from '../../../hooks/useThrottle';
import { secondsToMinutesAndSeconds } from '../../../utils/common';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
const PLAY_TIME_THROTTLE_OPS = {
wait: 500,
immediate: true,
};
const WAVEFORM_DISPLAY_BARS = 48;
/** Reduce a stored MSC1767 waveform (up to ~100 samples) to a fixed display count. */
export function downsampleWaveform(waveform: number[], count: number): number[] {
if (waveform.length <= count) return waveform;
const out: number[] = [];
const step = waveform.length / count;
for (let i = 0; i < count; i += 1) {
out.push(waveform[Math.floor(i * step)] ?? 0);
}
return out;
}
// Voice-message waveform that doubles as a seek control: bars fill with the accent
// as the clip plays; click/drag/keyboard scrubs. Mirrors the recorder's bar styling
// (VoiceMessageRecorder). Falls back to the plain seek Range when there's no waveform.
function WaveformSeek({
waveform,
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) => {
const el = containerRef.current;
if (!el || duration <= 0) return;
const rect = el.getBoundingClientRect();
const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
onSeek(Math.min(duration, Math.max(0, ratio * duration)));
},
[duration, onSeek],
);
const handlePointerDown = (evt: React.PointerEvent<HTMLDivElement>) => {
evt.currentTarget.setPointerCapture(evt.pointerId);
seekFromClientX(evt.clientX);
};
const handlePointerMove = (evt: React.PointerEvent<HTMLDivElement>) => {
if (evt.currentTarget.hasPointerCapture(evt.pointerId)) seekFromClientX(evt.clientX);
};
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, base + 5));
} else if (evt.key === 'ArrowLeft' || evt.key === 'ArrowDown') {
evt.preventDefault();
onSeek(Math.max(0, base - 5));
} else if (evt.key === 'Home') {
evt.preventDefault();
onSeek(0);
} else if (evt.key === 'End') {
evt.preventDefault();
onSeek(duration);
}
};
return (
<div
ref={containerRef}
role="slider"
tabIndex={0}
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
aria-valuenow={Math.round(currentTime)}
aria-valuetext={`${secondsToMinutesAndSeconds(currentTime)} of ${secondsToMinutesAndSeconds(
duration,
)}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onKeyDown={handleKeyDown}
style={{
display: 'flex',
alignItems: 'center',
gap: toRem(2),
width: '100%',
height: toRem(24),
cursor: 'pointer',
touchAction: 'none',
overflow: 'hidden',
}}
>
{bars.map((v, i) => {
const played = bars.length > 0 && (i + 1) / bars.length <= progress;
return (
<div
key={i}
style={{
flex: 1,
minWidth: toRem(2),
height: toRem(2 + (v / barMax) * 16),
borderRadius: toRem(1),
background: played ? accent : unplayedColor,
transition: 'background 0.1s',
}}
/>
);
})}
</div>
);
}
type RenderMediaControlProps = {
after: ReactNode;
leftControl: ReactNode;
rightControl: ReactNode;
children: ReactNode;
};
export type AudioContentProps = {
mimeType: string;
url: string;
info: IAudioInfo;
encInfo?: EncryptedAttachmentInfo;
/** MSC1767 voice waveform (0–1024 ints); when present, the seek bar renders it. */
waveform?: number[];
renderMediaControl: (props: RenderMediaControlProps) => ReactNode;
};
export function AudioContent({
mimeType,
url,
info,
encInfo,
waveform,
renderMediaControl,
}: AudioContentProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo]),
);
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(
() => () => {
if (
srcState.status === AsyncStatus.Success &&
typeof srcState.data === 'string' &&
srcState.data.startsWith('blob:')
) {
URL.revokeObjectURL(srcState.data);
}
},
[srcState],
);
const [currentTime, setCurrentTime] = useState(0);
// duration in seconds. (NOTE: info.duration is in milliseconds)
const infoDuration = info.duration ?? 0;
const [duration, setDuration] = useState((infoDuration >= 0 ? infoDuration : 0) / 1000);
const getAudioRef = useCallback(() => audioRef.current, []);
const { loading } = useMediaLoading(getAudioRef);
const { playing, setPlaying } = useMediaPlay(getAudioRef);
const { seek } = useMediaSeek(getAudioRef);
const { volume, mute, setMute, setVolume } = useMediaVolume(getAudioRef);
const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => {
setDuration(d);
setCurrentTime(ct);
}, []);
useMediaPlayTimeCallback(
getAudioRef,
useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS),
);
const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1);
useEffect(() => {
const audio = audioRef.current;
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
// resets playbackRate to 1 on load, discarding the user's speed choice.
applyRate();
audio.addEventListener('loadedmetadata', applyRate);
audio.addEventListener('play', applyRate);
return () => {
audio.removeEventListener('loadedmetadata', applyRate);
audio.removeEventListener('play', applyRate);
};
}, [playbackSpeed]);
const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2];
const handleSpeedClick = () => {
const currentIndex = SPEED_STEPS.indexOf(playbackSpeed);
const nextIndex = (currentIndex + 1) % SPEED_STEPS.length;
setPlaybackSpeed(SPEED_STEPS[nextIndex]);
};
const handlePlay = () => {
if (srcState.status === AsyncStatus.Success) {
setPlaying(!playing);
} else if (srcState.status !== AsyncStatus.Loading) {
loadSrc();
}
};
// 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({
after: hasWaveform ? (
<WaveformSeek
waveform={waveform ?? []}
currentTime={currentTime}
duration={duration}
onSeek={handleSeek}
getCurrentTime={() => audioRef.current?.currentTime ?? currentTime}
/>
) : (
<Range
step={1}
min={0}
max={duration || 1}
values={[currentTime]}
onChange={(values) => handleSeek(values[0])}
renderTrack={(params) => (
<div {...params.props}>
{params.children}
<ProgressBar
as="div"
variant="Secondary"
size="300"
min={0}
max={duration}
value={currentTime}
radii="300"
/>
</div>
)}
renderThumb={(params) => (
<Badge
size="300"
variant="Secondary"
fill="Solid"
radii="Pill"
outlined
{...params.props}
style={{
...params.props.style,
zIndex: 0,
}}
/>
)}
/>
),
leftControl: (
<>
<Chip
onClick={handlePlay}
variant="Secondary"
radii="300"
disabled={srcState.status === AsyncStatus.Loading}
before={
srcState.status === AsyncStatus.Loading || loading ? (
<Spinner variant="Secondary" size="50" />
) : (
<Icon src={playing ? Icons.Pause : Icons.Play} size="50" filled={playing} />
)
}
>
<Text size="B300">{playing ? 'Pause' : 'Play'}</Text>
</Chip>
<Text size="T200">{`${secondsToMinutesAndSeconds(
currentTime,
)} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
<Chip
onClick={handleSpeedClick}
variant="Secondary"
radii="300"
aria-label={`Playback speed: ${playbackSpeed}×`}
>
<Text size="B300">{`${playbackSpeed}×`}</Text>
</Chip>
</>
),
rightControl: (
<>
<IconButton
variant="SurfaceVariant"
size="300"
radii="Pill"
onClick={() => setMute(!mute)}
aria-label={mute ? 'Unmute' : 'Mute'}
aria-pressed={mute}
>
<Icon src={mute ? Icons.VolumeMute : Icons.VolumeHigh} size="50" />
</IconButton>
<Range
step={0.1}
min={0}
max={1}
values={[volume]}
onChange={(values) => setVolume(values[0])}
renderTrack={(params) => (
<div {...params.props}>
{params.children}
<ProgressBar
style={{ width: toRem(48) }}
variant="Secondary"
size="300"
min={0}
max={1}
value={volume}
radii="300"
/>
</div>
)}
renderThumb={(params) => (
<Badge
size="300"
variant="Secondary"
fill="Solid"
radii="Pill"
outlined
{...params.props}
style={{
...params.props.style,
zIndex: 0,
}}
/>
)}
/>
</>
),
children: (
<audio controls={false} autoPlay ref={audioRef}>
{srcState.status === AsyncStatus.Success && <source src={srcState.data} type={mimeType} />}
</audio>
),
});
}