Files
cinny/src/app/components/VoiceMessageRecorder.tsx
T
jared b243a18e01 fix(eslint): add missing useCallback deps, remove stale disable directives
- useMessageSearch: add fromTs/toTs to useCallback dep array (exhaustive-deps error)
- useMessageSearch: restore eslint-disable on the correct line for the `as any` cast
- VoiceMessageRecorder: remove two eslint-disable directives for rules that are
  globally off (jsx-a11y/media-has-caption) or not enabled (react/no-array-index-key)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 23:40:08 -04:00

307 lines
9.2 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Text, config, toRem } from 'folds';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
type RecorderState = 'idle' | 'recording' | 'preview';
interface VoiceRecorderProps {
onSend: (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => void;
onError?: (err: string) => void;
}
function formatDuration(ms: number): string {
const totalSec = Math.floor(ms / 1000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function normalizeWaveform(samples: number[]): number[] {
if (samples.length === 0) return Array(20).fill(0);
const max = Math.max(...samples, 1);
const count = Math.min(samples.length, 100);
const step = samples.length / count;
const result: number[] = [];
for (let i = 0; i < count; i += 1) {
const idx = Math.floor(i * step);
result.push(Math.round((samples[idx] / max) * 1024));
}
return result;
}
const WAVEFORM_BARS = 40;
export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [state, setState] = useState<RecorderState>('idle');
const [durationMs, setDurationMs] = useState(0);
const [waveformBars, setWaveformBars] = useState<number[]>(Array(WAVEFORM_BARS).fill(0));
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const analyserRef = useRef<AnalyserNode | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const rawSamplesRef = useRef<number[]>([]);
const startTimeRef = useRef<number>(0);
const animFrameRef = useRef<number>(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const previewMimeRef = useRef('audio/ogg;codecs=opus');
const previewDurationRef = useRef(0);
const stopAll = useCallback(() => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
if (timerRef.current) clearInterval(timerRef.current);
if (audioCtxRef.current) {
audioCtxRef.current.close();
audioCtxRef.current = null;
}
analyserRef.current = null;
}, []);
useEffect(
() => () => {
stopAll();
if (previewUrl) URL.revokeObjectURL(previewUrl);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
? 'audio/ogg;codecs=opus'
: 'audio/webm;codecs=opus';
previewMimeRef.current = mimeType;
const mr = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = mr;
chunksRef.current = [];
rawSamplesRef.current = [];
startTimeRef.current = Date.now();
const audioCtx = new AudioContext();
audioCtxRef.current = audioCtx;
const source = audioCtx.createMediaStreamSource(stream);
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
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);
mr.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
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);
return URL.createObjectURL(blob);
});
setState('preview');
};
mr.start(250);
setState('recording');
} catch {
onError?.('Microphone access denied');
}
}, [onError]);
const stopRecording = useCallback(() => {
stopAll();
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop();
}
}, [stopAll]);
const cancelRecording = useCallback(() => {
stopAll();
const mr = mediaRecorderRef.current;
if (mr?.state === 'recording') {
mr.ondataavailable = null;
mr.onstop = null;
mr.stop();
}
setPreviewBlob(null);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
rawSamplesRef.current = [];
setWaveformBars(Array(WAVEFORM_BARS).fill(0));
setDurationMs(0);
setState('idle');
}, [stopAll]);
const sendVoice = useCallback(() => {
if (!previewBlob) return;
const waveform = normalizeWaveform(rawSamplesRef.current);
onSend(previewBlob, previewMimeRef.current, previewDurationRef.current, waveform);
cancelRecording();
}, [previewBlob, onSend, cancelRecording]);
const barMax = Math.max(...waveformBars, 1);
if (state === 'idle') {
return (
<IconButton
onClick={startRecording}
aria-label="Record voice message"
variant="SurfaceVariant"
size="300"
radii="300"
title="Record voice message"
>
<Icon src={Icons.Mic} size="100" />
</IconButton>
);
}
if (state === 'recording') {
return (
<Box
data-voice-recorder="recording"
alignItems="Center"
gap="200"
style={{
background: 'var(--bg-surface-variant)',
borderRadius: config.radii.R300,
padding: `${toRem(4)} ${toRem(8)}`,
}}
>
<Box
data-voice-rec-dot
style={{
width: toRem(8),
height: toRem(8),
borderRadius: '50%',
background: lotusTerminal ? '#FF6B00' : 'var(--tc-danger-normal)',
flexShrink: 0,
animation: 'pttLivePulse 900ms ease-in-out infinite',
}}
/>
<Text
size="T200"
style={{
minWidth: toRem(32),
fontVariantNumeric: 'tabular-nums',
...(lotusTerminal
? { fontFamily: 'JetBrains Mono, monospace', color: '#00FF88', fontWeight: 700 }
: {}),
}}
>
{formatDuration(durationMs)}
</Text>
<Box
data-voice-waveform
alignItems="Center"
gap="100"
style={{ height: toRem(20), overflow: 'hidden', flexShrink: 0 }}
>
{waveformBars.map((h, i) => (
<div
key={i}
style={{
width: toRem(2),
height: toRem(2 + (h / barMax) * 16),
borderRadius: toRem(1),
background: lotusTerminal ? '#00FF88' : 'var(--tc-primary-normal)',
flexShrink: 0,
}}
/>
))}
</Box>
<IconButton
onClick={stopRecording}
aria-label="Stop recording"
variant="Primary"
fill="Soft"
size="300"
radii="300"
title="Stop recording"
>
<Icon src={Icons.Pause} size="100" />
</IconButton>
<IconButton
onClick={cancelRecording}
aria-label="Cancel recording"
variant="SurfaceVariant"
size="300"
radii="300"
title="Cancel"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
</Box>
);
}
return (
<Box
alignItems="Center"
gap="200"
style={{
background: 'var(--bg-surface-variant)',
borderRadius: config.radii.R300,
padding: `${toRem(4)} ${toRem(8)}`,
}}
>
{previewUrl && (
<audio src={previewUrl} controls style={{ height: toRem(28), maxWidth: toRem(180) }} />
)}
<Text size="T200" style={{ fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>
{formatDuration(previewDurationRef.current)}
</Text>
<IconButton
onClick={sendVoice}
aria-label="Send voice message"
variant="Primary"
fill="Soft"
size="300"
radii="300"
title="Send voice message"
>
<Icon src={Icons.Send} size="100" />
</IconButton>
<IconButton
onClick={cancelRecording}
aria-label="Discard voice message"
variant="SurfaceVariant"
size="300"
radii="300"
title="Discard"
>
<Icon src={Icons.Delete} size="100" />
</IconButton>
</Box>
);
}