208 lines
7.5 KiB
TypeScript
208 lines
7.5 KiB
TypeScript
/* eslint-disable jsx-a11y/media-has-caption */
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Avatar, Icon, Icons, Spinner, Text } from 'folds';
|
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
|
import { Range } from 'react-range';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|
import { IAudioInfo } from '../../../../types/matrix/common';
|
|
import {
|
|
PlayTimeCallback,
|
|
useMediaLoading,
|
|
useMediaPlay,
|
|
useMediaPlayTimeCallback,
|
|
useMediaSeek,
|
|
} 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';
|
|
import { normalizeWaveform } from '../../../utils/audioWaveform';
|
|
import { UserAvatar } from '../../user-avatar';
|
|
import * as css from './VoiceContent.css';
|
|
|
|
const PLAY_TIME_THROTTLE_OPS = {
|
|
wait: 200,
|
|
immediate: true,
|
|
};
|
|
|
|
const TARGET_BARS = 40;
|
|
|
|
export type VoiceContentProps = {
|
|
mimeType: string;
|
|
url: string;
|
|
info: IAudioInfo;
|
|
encInfo?: EncryptedAttachmentInfo;
|
|
waveform?: number[];
|
|
// The message sender — drives the always-present avatar on the left and the
|
|
// own-vs-other frame. Non-timeline callers (pin-menu, search) may omit them,
|
|
// in which case the avatar falls back to a placeholder.
|
|
senderId?: string;
|
|
senderAvatarUrl?: string;
|
|
senderName?: string;
|
|
// Set when the surrounding layout already draws a per-message avatar (channel
|
|
// layout / thread drawer) — skip our own to avoid two avatars on the row.
|
|
hideAvatar?: boolean;
|
|
};
|
|
export function VoiceContent({
|
|
mimeType,
|
|
url,
|
|
info,
|
|
encInfo,
|
|
waveform,
|
|
senderId,
|
|
senderAvatarUrl,
|
|
senderName,
|
|
hideAvatar,
|
|
}: VoiceContentProps) {
|
|
const { t } = useTranslation();
|
|
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])
|
|
);
|
|
|
|
// Revoke the downloaded object URL when it changes / on unmount.
|
|
useEffect(() => {
|
|
const objectUrl = srcState.status === AsyncStatus.Success ? srcState.data : undefined;
|
|
return () => {
|
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
};
|
|
}, [srcState]);
|
|
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
// duration in seconds. (NOTE: info.duration is in milliseconds)
|
|
const infoDuration = info.duration ?? 0;
|
|
const [duration, setDuration] = useState(
|
|
(Number.isFinite(infoDuration) && infoDuration >= 0 ? infoDuration : 0) / 1000
|
|
);
|
|
|
|
const getAudioRef = useCallback(() => audioRef.current, []);
|
|
const { loading } = useMediaLoading(getAudioRef);
|
|
const { playing, setPlaying } = useMediaPlay(getAudioRef);
|
|
const { seek } = useMediaSeek(getAudioRef);
|
|
const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => {
|
|
// Keep the info.duration seed when the element reports a non-finite duration
|
|
// (ogg/opus frequently reports Infinity until fully buffered) — overwriting
|
|
// it with 0 would flatten the waveform and show 0:00.
|
|
if (Number.isFinite(d) && d > 0) setDuration(d);
|
|
setCurrentTime(ct);
|
|
}, []);
|
|
useMediaPlayTimeCallback(
|
|
getAudioRef,
|
|
useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS)
|
|
);
|
|
|
|
const isOwn = !!senderId && senderId === mx.getUserId();
|
|
const bars = useMemo(() => normalizeWaveform(waveform, TARGET_BARS), [waveform]);
|
|
const max = duration || 1;
|
|
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
|
|
const clampedValue = Math.min(Math.max(currentTime, 0), max);
|
|
|
|
const handlePlay = () => {
|
|
if (srcState.status === AsyncStatus.Success) {
|
|
setPlaying(!playing);
|
|
} else if (srcState.status !== AsyncStatus.Loading) {
|
|
loadSrc();
|
|
}
|
|
};
|
|
|
|
const isLoading = srcState.status === AsyncStatus.Loading || loading;
|
|
// Show elapsed once the user is into playback, otherwise the total length.
|
|
const displayTime = playing || currentTime > 0 ? currentTime : duration;
|
|
|
|
return (
|
|
<div
|
|
className={`${css.Row} ${isOwn && !hideAvatar ? css.RowOwn : ''}`}
|
|
// The voice card runs its own play/seek interactions (the waveform track
|
|
// isn't a button/slider element). Mark the whole card so the bubble-DM
|
|
// tap-to-open-rail handler skips it (seeking must not also toggle the rail).
|
|
data-no-rail-toggle="true"
|
|
>
|
|
{!hideAvatar && (
|
|
<Avatar className={css.AvatarSlot} size="300">
|
|
<UserAvatar
|
|
userId={senderId ?? ''}
|
|
src={senderAvatarUrl}
|
|
alt={senderName ?? senderId ?? ''}
|
|
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
|
/>
|
|
</Avatar>
|
|
)}
|
|
|
|
<div className={`${css.Bubble} ${isOwn ? css.BubbleOwn : ''}`}>
|
|
<button
|
|
type="button"
|
|
className={`${css.PlayButton} ${playing ? css.PlayButtonPlaying : ''}`}
|
|
onClick={handlePlay}
|
|
disabled={srcState.status === AsyncStatus.Loading}
|
|
aria-label={playing ? t('Room.voice_pause') : t('Room.voice_play')}
|
|
>
|
|
{isLoading ? (
|
|
<Spinner variant="Secondary" size="200" />
|
|
) : (
|
|
<Icon src={playing ? Icons.Pause : Icons.Play} size="300" filled />
|
|
)}
|
|
</button>
|
|
|
|
<Range
|
|
step={0.05}
|
|
min={0}
|
|
max={max}
|
|
values={[clampedValue]}
|
|
onChange={(values) => seek(values[0])}
|
|
renderTrack={({ props, children }) =>
|
|
bars.length > 0 ? (
|
|
<div {...props} className={css.Waveform}>
|
|
{bars.map((amp, index) => {
|
|
const played = duration > 0 && (index + 0.5) / bars.length <= progress;
|
|
return (
|
|
<span
|
|
// eslint-disable-next-line react/no-array-index-key
|
|
key={index}
|
|
className={`${css.WaveBar} ${played ? css.WaveBarPlayed : css.WaveBarRest}`}
|
|
style={{ height: `${Math.max(10, Math.round(amp * 100))}%` }}
|
|
/>
|
|
);
|
|
})}
|
|
{children}
|
|
</div>
|
|
) : (
|
|
<div {...props} className={css.ProgressTrack}>
|
|
<div className={css.ProgressFill} style={{ width: `${progress * 100}%` }} />
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
renderThumb={({ props }) => <div {...props} className={css.WaveThumb} />}
|
|
/>
|
|
|
|
<Text className={css.Time} size="T200">
|
|
{secondsToMinutesAndSeconds(displayTime)}
|
|
</Text>
|
|
|
|
<audio controls={false} autoPlay ref={audioRef} style={{ display: 'none' }}>
|
|
{srcState.status === AsyncStatus.Success && (
|
|
<source src={srcState.data} type={mimeType} />
|
|
)}
|
|
</audio>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|