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>
429 lines
13 KiB
TypeScript
429 lines
13 KiB
TypeScript
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>
|
||
),
|
||
});
|
||
}
|