feat: select input devices for recordings

Closes #68
This commit is contained in:
talksik
2026-04-13 10:29:10 -07:00
parent 106254ef83
commit 38bd098ca6
9 changed files with 621 additions and 11 deletions
@@ -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);
+55 -8
View File
@@ -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") {
+24 -2
View File
@@ -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(() => {