diff --git a/js/src/App.tsx b/js/src/App.tsx index 514f73b..09999e1 100644 --- a/js/src/App.tsx +++ b/js/src/App.tsx @@ -8,6 +8,7 @@ import { QueryClientProvider, } from '@tanstack/react-query' import SettingsPage from "@/features/settings-page"; +import AudioVideoSettingsPage from "@/features/settings/audio-video-settings-page"; import NetworkSelector from "@/features/network-selector"; import NetworkRoot from "@/features/network-root"; import ParticleViewResolver from "@/features/particles/particle-view-resolver"; @@ -63,6 +64,7 @@ function AuthenticatedApp() { } /> + } /> } /> diff --git a/js/src/features/compose/compose-overlay.tsx b/js/src/features/compose/compose-overlay.tsx index 018affc..8713b5d 100644 --- a/js/src/features/compose/compose-overlay.tsx +++ b/js/src/features/compose/compose-overlay.tsx @@ -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("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); diff --git a/js/src/features/compose/use-recorder.ts b/js/src/features/compose/use-recorder.ts index 041dc1d..43c1e21 100644 --- a/js/src/features/compose/use-recorder.ts +++ b/js/src/features/compose/use-recorder.ts @@ -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 { + 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") { diff --git a/js/src/features/compose/use-screen-recorder.ts b/js/src/features/compose/use-screen-recorder.ts index 07689fd..259243f 100644 --- a/js/src/features/compose/use-screen-recorder.ts +++ b/js/src/features/compose/use-screen-recorder.ts @@ -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 { + 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(() => { diff --git a/js/src/features/settings-page.tsx b/js/src/features/settings-page.tsx index 152181a..7cdc028 100644 --- a/js/src/features/settings-page.tsx +++ b/js/src/features/settings-page.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { ChevronRight, LogOut, User, Info, Shield, Mail } from "lucide-react"; +import { ChevronRight, LogOut, User, Info, Shield, Mail, Mic } from "lucide-react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; @@ -155,6 +155,16 @@ export default function SettingsPage() { + + } + label="Audio & Video" + onClick={() => navigate("/settings/audio-video")} + /> + + + + } diff --git a/js/src/features/settings/audio-video-settings-page.tsx b/js/src/features/settings/audio-video-settings-page.tsx new file mode 100644 index 0000000..4e8bd15 --- /dev/null +++ b/js/src/features/settings/audio-video-settings-page.tsx @@ -0,0 +1,361 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { ArrowLeft, Camera, Mic, VideoOff } from "lucide-react"; +import { WindowControls } from "@/components/window-controls"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Muted } from "@/components/ui/typography"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { AudioLevelBars } from "@/components/audio/audio-level-bars"; +import { useAudioSource } from "@/components/audio/use-audio-source"; +import { useMediaDevices } from "@/hooks/use-media-devices"; +import { + resolveEffectiveDeviceId, + isSavedDeviceAvailable, +} from "@/hooks/use-effective-device-id"; +import { + useMediaDevicesStore, + type SavedDevice, +} from "@/stores/media-devices-store"; + +const SYSTEM_DEFAULT = "__system_default__"; + +/** + * Owns the preview MediaStream for the settings page. Rebuilds the + * stream whenever the effective mic or camera deviceId changes, and + * always stops the previous tracks before issuing a new request so + * the OS camera indicator doesn't linger. + */ +function usePreviewStream( + enabled: boolean, + micId: string | undefined, + cameraId: string | undefined, + cameraAvailable: boolean, +): { stream: MediaStream | null; error: string | null } { + const [stream, setStream] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!enabled) { + setStream(null); + return; + } + + let cancelled = false; + let active: MediaStream | null = null; + + const audio: MediaTrackConstraints | boolean = micId + ? { deviceId: { exact: micId } } + : true; + const video: MediaTrackConstraints | false = cameraAvailable + ? cameraId + ? { deviceId: { exact: cameraId }, aspectRatio: { ideal: 16 / 10 } } + : { aspectRatio: { ideal: 16 / 10 } } + : false; + + navigator.mediaDevices + .getUserMedia({ audio, video }) + .then((s) => { + if (cancelled) { + s.getTracks().forEach((t) => t.stop()); + return; + } + active = s; + setStream(s); + setError(null); + }) + .catch((err: unknown) => { + if (cancelled) return; + setStream(null); + setError( + err instanceof Error ? err.message : "Unable to access devices", + ); + }); + + return () => { + cancelled = true; + active?.getTracks().forEach((t) => t.stop()); + }; + }, [enabled, micId, cameraId, cameraAvailable]); + + return { stream, error }; +} + +function deviceLabel(d: MediaDeviceInfo, index: number): string { + if (d.label) return d.label; + const kind = d.kind === "audioinput" ? "Microphone" : "Camera"; + return `${kind} ${index + 1}`; +} + +function MicSection({ + devices, + saved, + onChange, + stream, + unavailable, +}: { + devices: MediaDeviceInfo[]; + saved: SavedDevice | null; + onChange: (device: SavedDevice | null) => void; + stream: MediaStream | null; + unavailable: boolean; +}) { + const audioSource = useAudioSource(stream); + const value = saved?.deviceId ?? SYSTEM_DEFAULT; + + return ( + + + + Microphone + + { + if (v === SYSTEM_DEFAULT) { + onChange(null); + return; + } + const match = devices.find((d) => d.deviceId === v); + if (match) onChange({ deviceId: match.deviceId, label: match.label }); + }} + > + + + + + System default + {devices.map((d, i) => ( + + {deviceLabel(d, i)} + + ))} + + + + + + {audioSource ? ( + + ) : ( + + {[0, 1, 2].map((i) => ( + + ))} + + )} + + Input level + + + {unavailable && ( + + Previously selected microphone is unavailable — using system default. + + )} + + ); +} + +function CameraSection({ + devices, + saved, + onChange, + stream, + unavailable, +}: { + devices: MediaDeviceInfo[]; + saved: SavedDevice | null; + onChange: (device: SavedDevice | null) => void; + stream: MediaStream | null; + unavailable: boolean; +}) { + const videoRef = useRef(null); + const value = saved?.deviceId ?? SYSTEM_DEFAULT; + const hasVideoTrack = (stream?.getVideoTracks().length ?? 0) > 0; + + useEffect(() => { + if (videoRef.current) { + videoRef.current.srcObject = hasVideoTrack ? stream : null; + } + }, [stream, hasVideoTrack]); + + return ( + + + + Camera + + { + if (v === SYSTEM_DEFAULT) { + onChange(null); + return; + } + const match = devices.find((d) => d.deviceId === v); + if (match) onChange({ deviceId: match.deviceId, label: match.label }); + }} + disabled={devices.length === 0} + > + + + + + System default + {devices.map((d, i) => ( + + {deviceLabel(d, i)} + + ))} + + + + + {hasVideoTrack ? ( + + ) : ( + + + + {devices.length === 0 ? "No camera detected" : "Preview unavailable"} + + + )} + + + {unavailable && ( + + Previously selected camera is unavailable — using system default. + + )} + + ); +} + +export default function AudioVideoSettingsPage() { + const navigate = useNavigate(); + const { + audioInputs, + videoInputs, + permissionState, + requestLabels, + error: deviceError, + } = useMediaDevices(); + + const mic = useMediaDevicesStore((s) => s.mic); + const camera = useMediaDevicesStore((s) => s.camera); + const setMic = useMediaDevicesStore((s) => s.setMic); + const setCamera = useMediaDevicesStore((s) => s.setCamera); + + const effectiveMicId = useMemo( + () => resolveEffectiveDeviceId(mic, audioInputs), + [mic, audioInputs], + ); + const effectiveCameraId = useMemo( + () => resolveEffectiveDeviceId(camera, videoInputs), + [camera, videoInputs], + ); + + const cameraAvailable = videoInputs.length > 0; + const permissionGranted = permissionState === "granted"; + + const { stream, error: previewError } = usePreviewStream( + permissionGranted, + effectiveMicId, + effectiveCameraId, + cameraAvailable, + ); + + const micUnavailable = !isSavedDeviceAvailable(mic, audioInputs); + const cameraUnavailable = !isSavedDeviceAvailable(camera, videoInputs); + + return ( + + + + navigate(-1)} + > + + + Audio & Video + + + + + {!permissionGranted && ( + + + Allow microphone and camera access + + + Grant access once so llink can show device names and previews. + + { + requestLabels(); + }} + > + Allow access + + {deviceError && permissionState === "denied" && ( + + {deviceError} + + )} + + )} + + + + + + + + {previewError && permissionGranted && ( + + + {previewError} + + + )} + + + ); +} diff --git a/js/src/hooks/use-effective-device-id.ts b/js/src/hooks/use-effective-device-id.ts new file mode 100644 index 0000000..049a147 --- /dev/null +++ b/js/src/hooks/use-effective-device-id.ts @@ -0,0 +1,24 @@ +import type { SavedDevice } from "@/stores/media-devices-store"; + +/** + * Resolves a saved device preference against the currently available + * devices. Returns the saved `deviceId` only if it still appears in the + * list — otherwise `undefined` so getUserMedia falls back to the + * system default. This keeps "unplugged device" handling in one place. + */ +export function resolveEffectiveDeviceId( + saved: SavedDevice | null, + available: MediaDeviceInfo[], +): string | undefined { + if (!saved) return undefined; + const match = available.find((d) => d.deviceId === saved.deviceId); + return match ? match.deviceId : undefined; +} + +export function isSavedDeviceAvailable( + saved: SavedDevice | null, + available: MediaDeviceInfo[], +): boolean { + if (!saved) return true; + return available.some((d) => d.deviceId === saved.deviceId); +} diff --git a/js/src/hooks/use-media-devices.ts b/js/src/hooks/use-media-devices.ts new file mode 100644 index 0000000..a0641bb --- /dev/null +++ b/js/src/hooks/use-media-devices.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useState } from "react"; + +export type PermissionState = "unknown" | "granted" | "denied"; + +interface UseMediaDevicesResult { + audioInputs: MediaDeviceInfo[]; + videoInputs: MediaDeviceInfo[]; + permissionState: PermissionState; + refresh: () => Promise; + requestLabels: () => Promise; + error: string | null; +} + +/** + * Enumerates input devices and stays subscribed to `devicechange`. + * + * Labels are only populated after the user has granted mic/camera + * permission — `requestLabels` triggers a brief getUserMedia so that + * subsequent enumerations return human-readable names, matching the + * pattern most video-conferencing apps use. + */ +export function useMediaDevices(): UseMediaDevicesResult { + const [devices, setDevices] = useState([]); + const [permissionState, setPermissionState] = + useState("unknown"); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + const list = await navigator.mediaDevices.enumerateDevices(); + setDevices(list); + // If at least one input device has a non-empty label, permission + // has been granted at some point for that device kind. + const hasLabels = list.some( + (d) => + (d.kind === "audioinput" || d.kind === "videoinput") && + d.label.length > 0, + ); + if (hasLabels) setPermissionState("granted"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to list devices"); + } + }, []); + + const requestLabels = useCallback(async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: true, + }); + // Immediately stop — we only needed the permission grant. + stream.getTracks().forEach((t) => t.stop()); + setPermissionState("granted"); + setError(null); + await refresh(); + } catch (err) { + setPermissionState("denied"); + setError( + err instanceof Error ? err.message : "Microphone/camera access denied", + ); + } + }, [refresh]); + + useEffect(() => { + refresh(); + const handle = () => { + refresh(); + }; + navigator.mediaDevices.addEventListener("devicechange", handle); + return () => { + navigator.mediaDevices.removeEventListener("devicechange", handle); + }; + }, [refresh]); + + return { + audioInputs: devices.filter((d) => d.kind === "audioinput"), + videoInputs: devices.filter((d) => d.kind === "videoinput"), + permissionState, + refresh, + requestLabels, + error, + }; +} diff --git a/js/src/stores/media-devices-store.ts b/js/src/stores/media-devices-store.ts new file mode 100644 index 0000000..b5c03f2 --- /dev/null +++ b/js/src/stores/media-devices-store.ts @@ -0,0 +1,50 @@ +import { create } from "zustand"; + +export interface SavedDevice { + deviceId: string; + label: string; +} + +const MIC_KEY = "llink:mic-device"; +const CAMERA_KEY = "llink:camera-device"; + +function load(key: string): SavedDevice | null { + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw) as SavedDevice; + if (typeof parsed?.deviceId !== "string") return null; + return { deviceId: parsed.deviceId, label: parsed.label ?? "" }; + } catch { + return null; + } +} + +function save(key: string, value: SavedDevice | null) { + try { + if (value) localStorage.setItem(key, JSON.stringify(value)); + else localStorage.removeItem(key); + } catch { + // Storage unavailable + } +} + +interface MediaDevicesState { + mic: SavedDevice | null; + camera: SavedDevice | null; + setMic: (device: SavedDevice | null) => void; + setCamera: (device: SavedDevice | null) => void; +} + +export const useMediaDevicesStore = create((set) => ({ + mic: load(MIC_KEY), + camera: load(CAMERA_KEY), + setMic: (device) => { + save(MIC_KEY, device); + set({ mic: device }); + }, + setCamera: (device) => { + save(CAMERA_KEY, device); + set({ camera: device }); + }, +}));
+ Allow microphone and camera access +