This commit is contained in:
@@ -4,6 +4,7 @@ import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react';
|
||||
import { useQuery, useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import { findTrackPubs } from '../utils/streamUtils.jsx';
|
||||
import { VoiceRecorder } from '../utils/voiceRecorder';
|
||||
import { usePlatform } from '../platform';
|
||||
import '@livekit/components-styles';
|
||||
|
||||
@@ -58,7 +59,8 @@ function playSoundUrl(url) {
|
||||
}
|
||||
|
||||
export const VoiceProvider = ({ children }) => {
|
||||
const { idle, voiceService } = usePlatform();
|
||||
const platform = usePlatform();
|
||||
const { idle, voiceService } = platform;
|
||||
const [activeChannelId, setActiveChannelId] = useState(null);
|
||||
const [activeChannelName, setActiveChannelName] = useState(null);
|
||||
const [connectionState, setConnectionState] = useState('disconnected');
|
||||
@@ -85,6 +87,16 @@ export const VoiceProvider = ({ children }) => {
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
const [connectionQualities, setConnectionQualities] = useState({});
|
||||
|
||||
// Voice recording — crash-safe per-participant audio capture.
|
||||
// Only available on Electron (`platform.features.hasRecording`).
|
||||
// `recordingError` is surfaced once per failure so UI can toast
|
||||
// it; consumers should clear it after reading.
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordingStartedAt, setRecordingStartedAt] = useState(null);
|
||||
const [recordingSessionId, setRecordingSessionId] = useState(null);
|
||||
const [recordingError, setRecordingError] = useState(null);
|
||||
const voiceRecorderRef = useRef(null);
|
||||
|
||||
const convex = useConvex();
|
||||
|
||||
// Stream watching state (lifted from VoiceStage so PiP can persist across navigation)
|
||||
@@ -953,6 +965,94 @@ export const VoiceProvider = ({ children }) => {
|
||||
}
|
||||
}, [room]);
|
||||
|
||||
// ── Voice recording ─────────────────────────────────────────
|
||||
// Starts a `VoiceRecorder` bound to the current room. Returns
|
||||
// the session metadata on success so the UI can show a confirm
|
||||
// indicator. Gracefully no-ops (with a console warning) on
|
||||
// platforms that don't support recording.
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!platform?.features?.hasRecording || !platform.recording) {
|
||||
console.warn('Recording is not available on this platform.');
|
||||
return null;
|
||||
}
|
||||
if (!room) {
|
||||
console.warn('Cannot start recording — not connected to a voice channel.');
|
||||
return null;
|
||||
}
|
||||
if (voiceRecorderRef.current) {
|
||||
return {
|
||||
sessionId: voiceRecorderRef.current.sessionId,
|
||||
startedAt: voiceRecorderRef.current.startedAt,
|
||||
};
|
||||
}
|
||||
try {
|
||||
// Resolve the user-preferred recording folder (settings
|
||||
// writes to localStorage under `voiceSettings`, same
|
||||
// bucket the mic/AGC toggles live in).
|
||||
let rootDir = null;
|
||||
try {
|
||||
const raw = localStorage.getItem('voiceSettings');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed.recordingDir === 'string') {
|
||||
rootDir = parsed.recordingDir;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
const recorder = new VoiceRecorder({
|
||||
platform,
|
||||
room,
|
||||
channelId: activeChannelId,
|
||||
channelName: activeChannelName,
|
||||
rootDir,
|
||||
onError: (err) => {
|
||||
console.error('Voice recorder error:', err);
|
||||
setRecordingError(err.message || 'Recording error');
|
||||
},
|
||||
});
|
||||
await recorder.start();
|
||||
voiceRecorderRef.current = recorder;
|
||||
setRecordingSessionId(recorder.sessionId);
|
||||
setRecordingStartedAt(recorder.startedAt);
|
||||
setIsRecording(true);
|
||||
setRecordingError(null);
|
||||
return { sessionId: recorder.sessionId, startedAt: recorder.startedAt };
|
||||
} catch (err) {
|
||||
console.error('Failed to start recording:', err);
|
||||
setRecordingError(err?.message || 'Failed to start recording');
|
||||
voiceRecorderRef.current = null;
|
||||
setIsRecording(false);
|
||||
setRecordingSessionId(null);
|
||||
setRecordingStartedAt(null);
|
||||
return null;
|
||||
}
|
||||
}, [platform, room, activeChannelId, activeChannelName]);
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
const recorder = voiceRecorderRef.current;
|
||||
if (!recorder) return;
|
||||
voiceRecorderRef.current = null;
|
||||
try {
|
||||
await recorder.stop();
|
||||
} catch (err) {
|
||||
console.error('Failed to stop recording cleanly:', err);
|
||||
setRecordingError(err?.message || 'Failed to stop recording');
|
||||
} finally {
|
||||
setIsRecording(false);
|
||||
setRecordingSessionId(null);
|
||||
setRecordingStartedAt(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-stop the recorder if the user disconnects from the
|
||||
// voice channel — we don't want an orphaned VoiceRecorder
|
||||
// holding references to a destroyed LiveKit room.
|
||||
useEffect(() => {
|
||||
if (!room && voiceRecorderRef.current) {
|
||||
void stopRecording();
|
||||
}
|
||||
}, [room, stopRecording]);
|
||||
|
||||
|
||||
return (
|
||||
<VoiceContext.Provider value={{
|
||||
@@ -993,6 +1093,14 @@ export const VoiceProvider = ({ children }) => {
|
||||
isReceivingScreenShareAudio,
|
||||
isReconnecting,
|
||||
connectionQualities,
|
||||
// Voice recording
|
||||
isRecording,
|
||||
recordingStartedAt,
|
||||
recordingSessionId,
|
||||
recordingError,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
clearRecordingError: () => setRecordingError(null),
|
||||
}}>
|
||||
{children}
|
||||
{room && (
|
||||
|
||||
Reference in New Issue
Block a user