import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Badge, Chip, color, 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 { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; 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, }; 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, getCurrentTime, }: { waveform: number[]; currentTime: number; duration: number; onSeek: (time: number) => void; /** Reads the live media time (the `currentTime` prop is throttled ~500ms). */ getCurrentTime: () => number; }) { const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); const containerRef = useRef(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; const accent = lotusTerminal ? 'var(--lt-accent-green)' : color.Primary.Main; const unplayedColor = `color-mix(in srgb, ${accent} 32%, transparent)`; 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) => { evt.currentTarget.setPointerCapture(evt.pointerId); seekFromClientX(evt.clientX); }; const handlePointerMove = (evt: React.PointerEvent) => { if (evt.currentTarget.hasPointerCapture(evt.pointerId)) seekFromClientX(evt.clientX); }; const handleKeyDown = (evt: React.KeyboardEvent) => { if (duration <= 0) return; // Base off the LIVE time so rapid presses accumulate (the prop is throttled). const base = getCurrentTime(); if (evt.key === 'ArrowRight' || evt.key === 'ArrowUp') { evt.preventDefault(); onSeek(Math.min(duration, base + 5)); } else if (evt.key === 'ArrowLeft' || evt.key === 'ArrowDown') { evt.preventDefault(); onSeek(Math.max(0, base - 5)); } else if (evt.key === 'Home') { evt.preventDefault(); onSeek(0); } else if (evt.key === 'End') { evt.preventDefault(); onSeek(duration); } }; return (
{bars.map((v, i) => { const played = bars.length > 0 && (i + 1) / bars.length <= progress; return (
); })}
); } type RenderMediaControlProps = { after: ReactNode; leftControl: ReactNode; rightControl: ReactNode; children: ReactNode; }; export type AudioContentProps = { mimeType: string; url: string; info: IAudioInfo; encInfo?: EncryptedAttachmentInfo; /** MSC1767 voice waveform (0–1024 ints); when present, the seek bar renders it. */ waveform?: number[]; renderMediaControl: (props: RenderMediaControlProps) => ReactNode; }; export function AudioContent({ mimeType, url, info, encInfo, waveform, 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(null); // A seek requested before the media has loaded; applied once metadata arrives. const pendingSeekRef = useRef(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(() => { const audio = audioRef.current; if (!audio) return undefined; const applyRate = () => { audio.playbackRate = playbackSpeed; // Apply a seek that was requested before the source loaded. if (pendingSeekRef.current != null && audio.readyState >= 1) { audio.currentTime = pendingSeekRef.current; pendingSeekRef.current = null; } }; // 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); }; }, [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(); } }; // Seeking before the media has loaded (e.g. clicking the waveform first) loads it // and applies the position once metadata arrives (the