2026-07-09 21:49:34 -04:00
|
|
|
|
import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Badge,
|
|
|
|
|
|
Chip,
|
|
|
|
|
|
color,
|
|
|
|
|
|
Icon,
|
|
|
|
|
|
IconButton,
|
|
|
|
|
|
Icons,
|
|
|
|
|
|
ProgressBar,
|
|
|
|
|
|
Spinner,
|
|
|
|
|
|
Text,
|
|
|
|
|
|
toRem,
|
|
|
|
|
|
} from 'folds';
|
2024-05-31 19:49:46 +05:30
|
|
|
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
|
|
|
|
|
import { Range } from 'react-range';
|
|
|
|
|
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
2026-07-09 21:49:34 -04:00
|
|
|
|
import { useSetting } from '../../../state/hooks/settings';
|
|
|
|
|
|
import { settingsAtom } from '../../../state/settings';
|
2024-05-31 19:49:46 +05:30
|
|
|
|
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';
|
2024-09-11 17:07:02 +10:00
|
|
|
|
import {
|
|
|
|
|
|
decryptFile,
|
|
|
|
|
|
downloadEncryptedMedia,
|
|
|
|
|
|
downloadMedia,
|
|
|
|
|
|
mxcUrlToHttp,
|
|
|
|
|
|
} from '../../../utils/matrix';
|
2024-09-09 18:45:20 +10:00
|
|
|
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
2024-05-31 19:49:46 +05:30
|
|
|
|
|
|
|
|
|
|
const PLAY_TIME_THROTTLE_OPS = {
|
|
|
|
|
|
wait: 500,
|
|
|
|
|
|
immediate: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-09 21:49:34 -04:00
|
|
|
|
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,
|
2026-07-09 22:01:41 -04:00
|
|
|
|
getCurrentTime,
|
2026-07-09 21:49:34 -04:00
|
|
|
|
}: {
|
|
|
|
|
|
waveform: number[];
|
|
|
|
|
|
currentTime: number;
|
|
|
|
|
|
duration: number;
|
|
|
|
|
|
onSeek: (time: number) => void;
|
2026-07-09 22:01:41 -04:00
|
|
|
|
/** Reads the live media time (the `currentTime` prop is throttled ~500ms). */
|
|
|
|
|
|
getCurrentTime: () => number;
|
2026-07-09 21:49:34 -04:00
|
|
|
|
}) {
|
|
|
|
|
|
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;
|
2026-07-09 22:01:41 -04:00
|
|
|
|
const accent = lotusTerminal ? 'var(--lt-accent-green)' : color.Primary.Main;
|
|
|
|
|
|
const unplayedColor = `color-mix(in srgb, ${accent} 32%, transparent)`;
|
2026-07-09 21:49:34 -04:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-07-09 22:01:41 -04:00
|
|
|
|
// Base off the LIVE time so rapid presses accumulate (the prop is throttled).
|
|
|
|
|
|
const base = getCurrentTime();
|
2026-07-09 21:49:34 -04:00
|
|
|
|
if (evt.key === 'ArrowRight' || evt.key === 'ArrowUp') {
|
|
|
|
|
|
evt.preventDefault();
|
2026-07-09 22:01:41 -04:00
|
|
|
|
onSeek(Math.min(duration, base + 5));
|
2026-07-09 21:49:34 -04:00
|
|
|
|
} else if (evt.key === 'ArrowLeft' || evt.key === 'ArrowDown') {
|
|
|
|
|
|
evt.preventDefault();
|
2026-07-09 22:01:41 -04:00
|
|
|
|
onSeek(Math.max(0, base - 5));
|
2026-07-09 21:49:34 -04:00
|
|
|
|
} 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',
|
2026-07-09 22:01:41 -04:00
|
|
|
|
overflow: 'hidden',
|
2026-07-09 21:49:34 -04:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{bars.map((v, i) => {
|
2026-07-09 22:01:41 -04:00
|
|
|
|
const played = bars.length > 0 && (i + 1) / bars.length <= progress;
|
2026-07-09 21:49:34 -04:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={i}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
minWidth: toRem(2),
|
|
|
|
|
|
height: toRem(2 + (v / barMax) * 16),
|
|
|
|
|
|
borderRadius: toRem(1),
|
2026-07-09 22:01:41 -04:00
|
|
|
|
background: played ? accent : unplayedColor,
|
2026-07-09 21:49:34 -04:00
|
|
|
|
transition: 'background 0.1s',
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-05-31 19:49:46 +05:30
|
|
|
|
type RenderMediaControlProps = {
|
|
|
|
|
|
after: ReactNode;
|
|
|
|
|
|
leftControl: ReactNode;
|
|
|
|
|
|
rightControl: ReactNode;
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
};
|
|
|
|
|
|
export type AudioContentProps = {
|
|
|
|
|
|
mimeType: string;
|
|
|
|
|
|
url: string;
|
|
|
|
|
|
info: IAudioInfo;
|
|
|
|
|
|
encInfo?: EncryptedAttachmentInfo;
|
2026-07-09 21:49:34 -04:00
|
|
|
|
/** MSC1767 voice waveform (0–1024 ints); when present, the seek bar renders it. */
|
|
|
|
|
|
waveform?: number[];
|
2024-05-31 19:49:46 +05:30
|
|
|
|
renderMediaControl: (props: RenderMediaControlProps) => ReactNode;
|
|
|
|
|
|
};
|
|
|
|
|
|
export function AudioContent({
|
|
|
|
|
|
mimeType,
|
|
|
|
|
|
url,
|
|
|
|
|
|
info,
|
|
|
|
|
|
encInfo,
|
2026-07-09 21:49:34 -04:00
|
|
|
|
waveform,
|
2024-05-31 19:49:46 +05:30
|
|
|
|
renderMediaControl,
|
|
|
|
|
|
}: AudioContentProps) {
|
|
|
|
|
|
const mx = useMatrixClient();
|
2024-09-09 18:45:20 +10:00
|
|
|
|
const useAuthentication = useMediaAuthentication();
|
2024-05-31 19:49:46 +05:30
|
|
|
|
|
|
|
|
|
|
const [srcState, loadSrc] = useAsyncCallback(
|
2024-09-11 17:07:02 +10:00
|
|
|
|
useCallback(async () => {
|
2026-02-14 11:42:28 +05:30
|
|
|
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
|
|
|
|
|
if (!mediaUrl) throw new Error('Invalid media URL');
|
2024-09-11 17:07:02 +10:00
|
|
|
|
const fileContent = encInfo
|
|
|
|
|
|
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
|
|
|
|
|
: await downloadMedia(mediaUrl);
|
|
|
|
|
|
return URL.createObjectURL(fileContent);
|
2026-05-21 23:30:50 -04:00
|
|
|
|
}, [mx, url, useAuthentication, mimeType, encInfo]),
|
2024-05-31 19:49:46 +05:30
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
2026-07-09 22:01:41 -04:00
|
|
|
|
// A seek requested before the media has loaded; applied once metadata arrives.
|
|
|
|
|
|
const pendingSeekRef = useRef<number | null>(null);
|
2024-05-31 19:49:46 +05:30
|
|
|
|
|
2026-05-20 21:11:38 -04:00
|
|
|
|
useEffect(
|
|
|
|
|
|
() => () => {
|
|
|
|
|
|
if (
|
|
|
|
|
|
srcState.status === AsyncStatus.Success &&
|
|
|
|
|
|
typeof srcState.data === 'string' &&
|
|
|
|
|
|
srcState.data.startsWith('blob:')
|
|
|
|
|
|
) {
|
|
|
|
|
|
URL.revokeObjectURL(srcState.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-05-21 23:30:50 -04:00
|
|
|
|
[srcState],
|
2026-05-20 21:11:38 -04:00
|
|
|
|
);
|
|
|
|
|
|
|
2024-05-31 19:49:46 +05:30
|
|
|
|
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,
|
2026-05-21 23:30:50 -04:00
|
|
|
|
useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS),
|
2024-05-31 19:49:46 +05:30
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-02 19:31:30 -04:00
|
|
|
|
const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-07-02 20:56:27 -04:00
|
|
|
|
const audio = audioRef.current;
|
|
|
|
|
|
if (!audio) return undefined;
|
|
|
|
|
|
const applyRate = () => {
|
|
|
|
|
|
audio.playbackRate = playbackSpeed;
|
2026-07-09 22:01:41 -04:00
|
|
|
|
// Apply a seek that was requested before the source loaded.
|
|
|
|
|
|
if (pendingSeekRef.current != null && audio.readyState >= 1) {
|
|
|
|
|
|
audio.currentTime = pendingSeekRef.current;
|
|
|
|
|
|
pendingSeekRef.current = null;
|
|
|
|
|
|
}
|
2026-07-02 20:56:27 -04:00
|
|
|
|
};
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
};
|
2026-06-02 19:31:30 -04:00
|
|
|
|
}, [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]);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2024-05-31 19:49:46 +05:30
|
|
|
|
const handlePlay = () => {
|
|
|
|
|
|
if (srcState.status === AsyncStatus.Success) {
|
|
|
|
|
|
setPlaying(!playing);
|
|
|
|
|
|
} else if (srcState.status !== AsyncStatus.Loading) {
|
|
|
|
|
|
loadSrc();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-09 22:01:41 -04:00
|
|
|
|
// 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],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-09 21:49:34 -04:00
|
|
|
|
const hasWaveform = !!waveform && waveform.length > 0 && duration > 0;
|
|
|
|
|
|
|
2024-05-31 19:49:46 +05:30
|
|
|
|
return renderMediaControl({
|
2026-07-09 21:49:34 -04:00
|
|
|
|
after: hasWaveform ? (
|
|
|
|
|
|
<WaveformSeek
|
|
|
|
|
|
waveform={waveform ?? []}
|
|
|
|
|
|
currentTime={currentTime}
|
|
|
|
|
|
duration={duration}
|
2026-07-09 22:01:41 -04:00
|
|
|
|
onSeek={handleSeek}
|
|
|
|
|
|
getCurrentTime={() => audioRef.current?.currentTime ?? currentTime}
|
2026-07-09 21:49:34 -04:00
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
2024-05-31 19:49:46 +05:30
|
|
|
|
<Range
|
|
|
|
|
|
step={1}
|
|
|
|
|
|
min={0}
|
|
|
|
|
|
max={duration || 1}
|
|
|
|
|
|
values={[currentTime]}
|
2026-07-09 22:01:41 -04:00
|
|
|
|
onChange={(values) => handleSeek(values[0])}
|
2024-05-31 19:49:46 +05:30
|
|
|
|
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(
|
2026-05-21 23:30:50 -04:00
|
|
|
|
currentTime,
|
2024-05-31 19:49:46 +05:30
|
|
|
|
)} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
|
2026-06-02 19:31:30 -04:00
|
|
|
|
|
|
|
|
|
|
<Chip
|
|
|
|
|
|
onClick={handleSpeedClick}
|
2026-06-18 22:46:19 -04:00
|
|
|
|
variant="Secondary"
|
|
|
|
|
|
radii="300"
|
2026-06-02 19:31:30 -04:00
|
|
|
|
aria-label={`Playback speed: ${playbackSpeed}×`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text size="B300">{`${playbackSpeed}×`}</Text>
|
|
|
|
|
|
</Chip>
|
2024-05-31 19:49:46 +05:30
|
|
|
|
</>
|
|
|
|
|
|
),
|
|
|
|
|
|
rightControl: (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<IconButton
|
|
|
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
|
size="300"
|
|
|
|
|
|
radii="Pill"
|
|
|
|
|
|
onClick={() => setMute(!mute)}
|
2026-05-20 21:26:18 -04:00
|
|
|
|
aria-label={mute ? 'Unmute' : 'Mute'}
|
2024-05-31 19:49:46 +05:30
|
|
|
|
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>
|
|
|
|
|
|
),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|