vojo/src/app/components/message/content/VoiceContent.tsx

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>
);
}