refactor: organize desktop vs. mobile into separate folders
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
|
||||
const VIDEO_FALLBACK_MIME = "video/webm";
|
||||
|
||||
function getScreenMime(): string {
|
||||
return MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME)
|
||||
? VIDEO_PREFERRED_MIME
|
||||
: VIDEO_FALLBACK_MIME;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UseScreenRecorderOptions {
|
||||
micDeviceId?: string;
|
||||
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
async function getMicStream(
|
||||
micDeviceId: string | undefined,
|
||||
): Promise<MediaStream> {
|
||||
const constraints: MediaStreamConstraints = {
|
||||
audio: micDeviceId ? { deviceId: { exact: micDeviceId } } : true,
|
||||
};
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia(constraints);
|
||||
} catch (err) {
|
||||
if (
|
||||
micDeviceId &&
|
||||
err instanceof Error &&
|
||||
(err.name === "OverconstrainedError" || err.name === "NotFoundError")
|
||||
) {
|
||||
return navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages screen recording via Electron's desktopCapturer.
|
||||
* Captures screen video + mic audio.
|
||||
*/
|
||||
export function useScreenRecorder({
|
||||
micDeviceId,
|
||||
onFinish,
|
||||
onError,
|
||||
}: UseScreenRecorderOptions) {
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const screenStreamRef = useRef<MediaStream | null>(null);
|
||||
const micStreamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const cleanupIpcRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const onFinishRef = useRef(onFinish);
|
||||
const onErrorRef = useRef(onError);
|
||||
useEffect(() => {
|
||||
onFinishRef.current = onFinish;
|
||||
onErrorRef.current = onError;
|
||||
});
|
||||
|
||||
const stopAllTracks = useCallback(() => {
|
||||
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
micStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
screenStreamRef.current = null;
|
||||
micStreamRef.current = null;
|
||||
recorderRef.current = null;
|
||||
chunksRef.current = [];
|
||||
cleanupIpcRef.current?.();
|
||||
cleanupIpcRef.current = null;
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(
|
||||
async (sourceId: string) => {
|
||||
try {
|
||||
// 1. Screen video
|
||||
const screenStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: "desktop",
|
||||
chromeMediaSourceId: sourceId,
|
||||
},
|
||||
} as MediaTrackConstraints,
|
||||
});
|
||||
screenStreamRef.current = screenStream;
|
||||
|
||||
// 2. Mic audio
|
||||
const micStream = await getMicStream(micDeviceId);
|
||||
micStreamRef.current = micStream;
|
||||
|
||||
// 3. Combine screen video + mic audio
|
||||
const combined = new MediaStream([
|
||||
...screenStream.getVideoTracks(),
|
||||
...micStream.getAudioTracks(),
|
||||
]);
|
||||
|
||||
chunksRef.current = [];
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
const mime = getScreenMime();
|
||||
const recorder = new MediaRecorder(combined, { mimeType: mime });
|
||||
recorderRef.current = recorder;
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
const durationMs = Date.now() - startTimeRef.current;
|
||||
const blob = new Blob(chunksRef.current, { type: mime });
|
||||
stopAllTracks();
|
||||
window.electronScreen.stopRecordingWindow();
|
||||
|
||||
if (blob.size > 0) {
|
||||
onFinishRef.current(blob, durationMs, mime);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.start(1000);
|
||||
|
||||
// 4. Show floating control window
|
||||
window.electronScreen.startRecordingWindow();
|
||||
|
||||
// 5. Listen for stop from floating window
|
||||
cleanupIpcRef.current = window.electronScreen.onStopRequested(() => {
|
||||
if (recorderRef.current?.state === "recording") {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
stopAllTracks();
|
||||
window.electronScreen.stopRecordingWindow();
|
||||
onErrorRef.current(
|
||||
err instanceof Error ? err.message : "Failed to start screen recording",
|
||||
);
|
||||
}
|
||||
},
|
||||
[micDeviceId, stopAllTracks],
|
||||
);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (recorderRef.current?.state === "recording") {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancelRecording = useCallback(() => {
|
||||
if (recorderRef.current) {
|
||||
recorderRef.current.ondataavailable = null;
|
||||
recorderRef.current.onstop = null;
|
||||
if (recorderRef.current.state === "recording") {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
}
|
||||
stopAllTracks();
|
||||
window.electronScreen.stopRecordingWindow();
|
||||
}, [stopAllTracks]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopAllTracks();
|
||||
window.electronScreen.stopRecordingWindow();
|
||||
};
|
||||
}, [stopAllTracks]);
|
||||
|
||||
return { startRecording, stopRecording, cancelRecording };
|
||||
}
|
||||
Reference in New Issue
Block a user