diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 37ea79fda..d831e7023 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -859,6 +859,7 @@ player.kick). - **Waveform scrubbing** — voice messages carry an MSC1767 waveform (`org.matrix.msc1767.audio.waveform`, sent by the recorder). Playback renders it as bars that fill with the accent as the clip plays (TDS green under Lotus Terminal), and the waveform **is** the seek control — click, drag, or keyboard (arrows ±5s, Home/End) to scrub (`role="slider"`, ARIA value text). Threaded through `MAudio` (`MsgTypeRenderers.tsx`) + passed directly by the gallery. Regular audio with no waveform keeps the plain seek bar. - **Playback speed** — a cycle button (`[0.75, 1, 1.5, 2]×`); a `useEffect` sets `audioElement.playbackRate` and re-applies it on (re)load (the browser resets it). +- **Recording pause / resume** — `VoiceMessageRecorder.tsx` supports a `paused` state via `MediaRecorder.pause()/resume()`, so you can pause mid-recording and continue without a gap. The duration timer accumulates only active-recording time (paused time is excluded), the waveform/meters freeze while paused and the record dot stops pulsing, and the finish button (which advances to the preview/review step) is a checkmark distinct from the Pause control. --- diff --git a/src/app/components/VoiceMessageRecorder.tsx b/src/app/components/VoiceMessageRecorder.tsx index 6d7b17359..7f0e00240 100644 --- a/src/app/components/VoiceMessageRecorder.tsx +++ b/src/app/components/VoiceMessageRecorder.tsx @@ -3,7 +3,7 @@ import { Box, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds' import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; -type RecorderState = 'idle' | 'recording' | 'preview'; +type RecorderState = 'idle' | 'recording' | 'paused' | 'preview'; interface VoiceRecorderProps { onSend: (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => void; @@ -45,7 +45,10 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { const analyserRef = useRef(null); const audioCtxRef = useRef(null); const rawSamplesRef = useRef([]); - const startTimeRef = useRef(0); + // Active-recording duration excluding paused time: accumulated ms from prior + // segments + (now - segmentStart) for the current segment. + const accumulatedMsRef = useRef(0); + const segmentStartRef = useRef(0); const animFrameRef = useRef(0); const timerRef = useRef | null>(null); @@ -54,15 +57,44 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { const previewAudioRef = useRef(null); const [previewPlaying, setPreviewPlaying] = useState(false); - const stopAll = useCallback(() => { + // Start the waveform (rAF) + duration (interval) meters against the live + // analyser. Reused by startRecording and resumeRecording. + const startMeters = useCallback(() => { + const analyser = analyserRef.current; + if (!analyser) return; + const buf = new Uint8Array(analyser.frequencyBinCount); + const tick = () => { + if (!analyserRef.current) return; + analyserRef.current.getByteFrequencyData(buf); + const avg = buf.reduce((a, b) => a + b, 0) / buf.length; + rawSamplesRef.current.push(avg); + setWaveformBars((prev) => [...prev.slice(1), Math.round((avg / 255) * 100)]); + animFrameRef.current = requestAnimationFrame(tick); + }; + animFrameRef.current = requestAnimationFrame(tick); + timerRef.current = setInterval(() => { + setDurationMs(accumulatedMsRef.current + (Date.now() - segmentStartRef.current)); + }, 100); + }, []); + + // Stop the meters (rAF + interval) without tearing down the audio graph, so a + // paused recording can resume. + const stopMeters = useCallback(() => { if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); - if (timerRef.current) clearInterval(timerRef.current); + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + const stopAll = useCallback(() => { + stopMeters(); if (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null; } analyserRef.current = null; - }, []); + }, [stopMeters]); useEffect( () => () => { @@ -85,7 +117,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { mediaRecorderRef.current = mr; chunksRef.current = []; rawSamplesRef.current = []; - startTimeRef.current = Date.now(); + accumulatedMsRef.current = 0; + segmentStartRef.current = Date.now(); const audioCtx = new AudioContext(); audioCtxRef.current = audioCtx; @@ -95,24 +128,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { source.connect(analyser); analyserRef.current = analyser; - const buf = new Uint8Array(analyser.frequencyBinCount); - const tick = () => { - if (!analyserRef.current) return; - analyserRef.current.getByteFrequencyData(buf); - const avg = buf.reduce((a, b) => a + b, 0) / buf.length; - rawSamplesRef.current.push(avg); - - setWaveformBars((prev) => { - const next = [...prev.slice(1), Math.round((avg / 255) * 100)]; - return next; - }); - animFrameRef.current = requestAnimationFrame(tick); - }; - animFrameRef.current = requestAnimationFrame(tick); - - timerRef.current = setInterval(() => { - setDurationMs(Date.now() - startTimeRef.current); - }, 100); + startMeters(); mr.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); @@ -121,7 +137,6 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { mr.onstop = () => { stream.getTracks().forEach((t) => t.stop()); const blob = new Blob(chunksRef.current, { type: mimeType }); - previewDurationRef.current = Date.now() - startTimeRef.current; setPreviewBlob(blob); setPreviewUrl((prev) => { if (prev) URL.revokeObjectURL(prev); @@ -135,19 +150,41 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { } catch { onError?.('Microphone access denied'); } - }, [onError]); + }, [onError, startMeters]); + + const pauseRecording = useCallback(() => { + const mr = mediaRecorderRef.current; + if (mr?.state !== 'recording') return; + accumulatedMsRef.current += Date.now() - segmentStartRef.current; + setDurationMs(accumulatedMsRef.current); + stopMeters(); + mr.pause(); + setState('paused'); + }, [stopMeters]); + + const resumeRecording = useCallback(() => { + const mr = mediaRecorderRef.current; + if (mr?.state !== 'paused') return; + segmentStartRef.current = Date.now(); + mr.resume(); + startMeters(); + setState('recording'); + }, [startMeters]); const stopRecording = useCallback(() => { + const mr = mediaRecorderRef.current; + const activeMs = mr?.state === 'recording' ? Date.now() - segmentStartRef.current : 0; + previewDurationRef.current = accumulatedMsRef.current + activeMs; stopAll(); - if (mediaRecorderRef.current?.state === 'recording') { - mediaRecorderRef.current.stop(); + if (mr && (mr.state === 'recording' || mr.state === 'paused')) { + mr.stop(); } }, [stopAll]); const cancelRecording = useCallback(() => { stopAll(); const mr = mediaRecorderRef.current; - if (mr?.state === 'recording') { + if (mr && (mr.state === 'recording' || mr.state === 'paused')) { mr.ondataavailable = null; mr.onstop = null; mr.stop(); @@ -158,6 +195,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { return null; }); rawSamplesRef.current = []; + accumulatedMsRef.current = 0; + segmentStartRef.current = 0; setWaveformBars(Array(WAVEFORM_BARS).fill(0)); setDurationMs(0); setState('idle'); @@ -187,10 +226,11 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { ); } - if (state === 'recording') { + if (state === 'recording' || state === 'paused') { + const paused = state === 'paused'; return ( ))} + + + - +