afe957015b
P1-5: Voice message playback speed toggle (0.75×/1×/1.5×/2×) in AudioContent.tsx P1-10: Private read receipts toggle in Privacy settings; wired to notifications.ts P1-3: Room filter input on Home tab and DMs tab (client-side, clears on tab switch) P1-8: Favorite rooms via m.favourite tag — Favorites section in Home sidebar, star/unstar in right-click menu P1-9: Room invite link + QR code in room settings (Share Room tile, api.qrserver.com QR) P1-6: Poll creation modal in composer (PollCreator.tsx, sends m.poll.start) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
249 lines
7.1 KiB
TypeScript
249 lines
7.1 KiB
TypeScript
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||
import { Badge, Chip, 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 { 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,
|
||
};
|
||
|
||
type RenderMediaControlProps = {
|
||
after: ReactNode;
|
||
leftControl: ReactNode;
|
||
rightControl: ReactNode;
|
||
children: ReactNode;
|
||
};
|
||
export type AudioContentProps = {
|
||
mimeType: string;
|
||
url: string;
|
||
info: IAudioInfo;
|
||
encInfo?: EncryptedAttachmentInfo;
|
||
renderMediaControl: (props: RenderMediaControlProps) => ReactNode;
|
||
};
|
||
export function AudioContent({
|
||
mimeType,
|
||
url,
|
||
info,
|
||
encInfo,
|
||
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);
|
||
|
||
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(() => {
|
||
if (audioRef.current) {
|
||
audioRef.current.playbackRate = playbackSpeed;
|
||
}
|
||
}, [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();
|
||
}
|
||
};
|
||
|
||
return renderMediaControl({
|
||
after: (
|
||
<Range
|
||
step={1}
|
||
min={0}
|
||
max={duration || 1}
|
||
values={[currentTime]}
|
||
onChange={(values) => seek(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="SurfaceVariant"
|
||
radii="Pill"
|
||
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>
|
||
),
|
||
});
|
||
}
|