/* 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(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 (
{!hideAvatar && ( } /> )}
seek(values[0])} renderTrack={({ props, children }) => bars.length > 0 ? (
{bars.map((amp, index) => { const played = duration > 0 && (index + 0.5) / bars.length <= progress; return ( ); })} {children}
) : (
{children}
) } renderThumb={({ props }) =>
} /> {secondsToMinutesAndSeconds(displayTime)}
); }