@@ -12,6 +12,9 @@ import { TextComposeStep } from "@/features/compose/text-compose-step";
|
||||
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
||||
import { useMediaDevicesStore } from "@/stores/media-devices-store";
|
||||
import { useMediaDevices } from "@/hooks/use-media-devices";
|
||||
import { resolveEffectiveDeviceId } from "@/hooks/use-effective-device-id";
|
||||
import { useFileInput } from "@/hooks/use-file-input";
|
||||
import { createImageThumbnail } from "@/lib/image-thumbnail";
|
||||
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants";
|
||||
@@ -59,6 +62,11 @@ export function ComposeOverlay({
|
||||
const [recordingSource, setRecordingSource] = useState<RecordingSource>("media");
|
||||
|
||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||
const savedMic = useMediaDevicesStore((s) => s.mic);
|
||||
const savedCamera = useMediaDevicesStore((s) => s.camera);
|
||||
const { audioInputs, videoInputs } = useMediaDevices();
|
||||
const micDeviceId = resolveEffectiveDeviceId(savedMic, audioInputs);
|
||||
const cameraDeviceId = resolveEffectiveDeviceId(savedCamera, videoInputs);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const createParticle = useCreateParticle();
|
||||
const createStream = useCreateStreamParticle();
|
||||
@@ -151,6 +159,8 @@ export function ComposeOverlay({
|
||||
|
||||
const { startRecording, stopRecording, cancelRecording } = useRecorder({
|
||||
mode: recordingMode,
|
||||
micDeviceId,
|
||||
cameraDeviceId,
|
||||
onStreamReady: (stream) => setMediaStream(stream),
|
||||
onStreamCleanup: () => setMediaStream(null),
|
||||
onFinish: (blob, durationMs, mimeType) => {
|
||||
@@ -167,6 +177,7 @@ export function ComposeOverlay({
|
||||
stopRecording: stopScreenRecording,
|
||||
cancelRecording: cancelScreenRecording,
|
||||
} = useScreenRecorder({
|
||||
micDeviceId,
|
||||
onFinish: (blob, durationMs, mimeType) => {
|
||||
setStepSync("reviewing");
|
||||
setReviewBlob(blob);
|
||||
|
||||
@@ -19,18 +19,69 @@ function getMediaMime(mode: "video" | "audio"): string {
|
||||
|
||||
interface UseRecorderOptions {
|
||||
mode: RecordingMode;
|
||||
micDeviceId?: string;
|
||||
cameraDeviceId?: string;
|
||||
onStreamReady: (stream: MediaStream) => void;
|
||||
onStreamCleanup: () => void;
|
||||
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
function buildConstraints(
|
||||
mode: RecordingMode,
|
||||
micDeviceId: string | undefined,
|
||||
cameraDeviceId: string | undefined,
|
||||
): MediaStreamConstraints {
|
||||
const audio: MediaTrackConstraints | boolean = micDeviceId
|
||||
? { deviceId: { exact: micDeviceId } }
|
||||
: true;
|
||||
|
||||
if (mode === "audio") return { audio };
|
||||
|
||||
const video: MediaTrackConstraints = cameraDeviceId
|
||||
? { deviceId: { exact: cameraDeviceId }, aspectRatio: { ideal: 4 / 3 } }
|
||||
: { aspectRatio: { ideal: 4 / 3 } };
|
||||
return { audio, video };
|
||||
}
|
||||
|
||||
async function getStreamWithFallback(
|
||||
constraints: MediaStreamConstraints,
|
||||
hasDeviceId: boolean,
|
||||
): Promise<MediaStream> {
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia(constraints);
|
||||
} catch (err) {
|
||||
// When a saved device has been unplugged, `{ exact }` throws
|
||||
// OverconstrainedError. Fall back to the system default so users
|
||||
// aren't blocked from recording.
|
||||
if (
|
||||
hasDeviceId &&
|
||||
err instanceof Error &&
|
||||
(err.name === "OverconstrainedError" || err.name === "NotFoundError")
|
||||
) {
|
||||
const relaxed: MediaStreamConstraints = {
|
||||
audio: typeof constraints.audio === "object" ? true : constraints.audio,
|
||||
...(constraints.video !== undefined && {
|
||||
video:
|
||||
typeof constraints.video === "object"
|
||||
? { aspectRatio: { ideal: 4 / 3 } }
|
||||
: constraints.video,
|
||||
}),
|
||||
};
|
||||
return navigator.mediaDevices.getUserMedia(relaxed);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages MediaRecorder lifecycle. Pure media utility — knows nothing
|
||||
* about application state. The consumer provides callbacks for all outputs.
|
||||
*/
|
||||
export function useRecorder({
|
||||
mode,
|
||||
micDeviceId,
|
||||
cameraDeviceId,
|
||||
onStreamReady,
|
||||
onStreamCleanup,
|
||||
onFinish,
|
||||
@@ -61,13 +112,9 @@ export function useRecorder({
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
const constraints =
|
||||
mode === "video"
|
||||
? { video: { aspectRatio: { ideal: 4 / 3 } }, audio: true }
|
||||
: { audio: true };
|
||||
|
||||
const mediaStream =
|
||||
await navigator.mediaDevices.getUserMedia(constraints);
|
||||
const constraints = buildConstraints(mode, micDeviceId, cameraDeviceId);
|
||||
const hasDeviceId = Boolean(micDeviceId || cameraDeviceId);
|
||||
const mediaStream = await getStreamWithFallback(constraints, hasDeviceId);
|
||||
|
||||
streamRef.current = mediaStream;
|
||||
onStreamReady(mediaStream);
|
||||
@@ -99,7 +146,7 @@ export function useRecorder({
|
||||
err instanceof Error ? err.message : "Failed to start recording",
|
||||
);
|
||||
}
|
||||
}, [mode, onStreamReady, stopTracks]);
|
||||
}, [mode, micDeviceId, cameraDeviceId, onStreamReady, stopTracks]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (recorderRef.current?.state === "recording") {
|
||||
|
||||
@@ -14,15 +14,37 @@ function getScreenMime(): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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) {
|
||||
@@ -67,7 +89,7 @@ export function useScreenRecorder({
|
||||
screenStreamRef.current = screenStream;
|
||||
|
||||
// 2. Mic audio
|
||||
const micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const micStream = await getMicStream(micDeviceId);
|
||||
micStreamRef.current = micStream;
|
||||
|
||||
// 3. Combine screen video + mic audio
|
||||
@@ -117,7 +139,7 @@ export function useScreenRecorder({
|
||||
);
|
||||
}
|
||||
},
|
||||
[stopAllTracks],
|
||||
[micDeviceId, stopAllTracks],
|
||||
);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user