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
@@ -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<MediaStream | null>(null);
const [error, setError] = useState<string | null>(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 (
<section className="px-4 py-4">
<div className="mb-3 flex items-center gap-2">
<Mic className="text-muted-foreground size-4" />
<h2 className="text-sm font-medium">Microphone</h2>
</div>
<Select
value={value}
onValueChange={(v) => {
if (v === SYSTEM_DEFAULT) {
onChange(null);
return;
}
const match = devices.find((d) => d.deviceId === v);
if (match) onChange({ deviceId: match.deviceId, label: match.label });
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="System default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={SYSTEM_DEFAULT}>System default</SelectItem>
{devices.map((d, i) => (
<SelectItem key={d.deviceId} value={d.deviceId}>
{deviceLabel(d, i)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="mt-4 flex items-center gap-3">
<div className="flex h-12 items-end">
{audioSource ? (
<AudioLevelBars sourceNode={audioSource.sourceNode} />
) : (
<div className="flex items-end gap-1.5">
{[0, 1, 2].map((i) => (
<div
key={i}
className="bg-muted h-1.5 w-1.5 rounded-full"
/>
))}
</div>
)}
</div>
<Muted className="text-xs">Input level</Muted>
</div>
{unavailable && (
<Muted className="mt-2 text-xs">
Previously selected microphone is unavailable using system default.
</Muted>
)}
</section>
);
}
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<HTMLVideoElement>(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 (
<section className="px-4 py-4">
<div className="mb-3 flex items-center gap-2">
<Camera className="text-muted-foreground size-4" />
<h2 className="text-sm font-medium">Camera</h2>
</div>
<Select
value={value}
onValueChange={(v) => {
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}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={devices.length === 0 ? "No cameras found" : "System default"}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value={SYSTEM_DEFAULT}>System default</SelectItem>
{devices.map((d, i) => (
<SelectItem key={d.deviceId} value={d.deviceId}>
{deviceLabel(d, i)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="bg-muted mt-4 flex aspect-[16/10] w-full items-center justify-center overflow-hidden rounded-lg">
{hasVideoTrack ? (
<video
ref={videoRef}
muted
autoPlay
playsInline
className="h-full w-full -scale-x-100 object-cover"
/>
) : (
<div className="flex flex-col items-center gap-2">
<VideoOff className="text-muted-foreground size-6" />
<Muted className="text-xs">
{devices.length === 0 ? "No camera detected" : "Preview unavailable"}
</Muted>
</div>
)}
</div>
{unavailable && (
<Muted className="mt-2 text-xs">
Previously selected camera is unavailable using system default.
</Muted>
)}
</section>
);
}
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 (
<div className="flex h-screen flex-col">
<div className="drag-region flex items-center gap-3 border-b px-3 py-1">
<WindowControls />
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
onClick={() => navigate(-1)}
>
<ArrowLeft className="size-3.5" />
</Button>
<span className="text-sm font-medium">Audio & Video</span>
<div className="flex-1" />
</div>
<ScrollArea className="flex-1">
{!permissionGranted && (
<div className="border-b px-4 py-4">
<p className="text-sm font-medium">
Allow microphone and camera access
</p>
<Muted className="mt-1 text-xs">
Grant access once so llink can show device names and previews.
</Muted>
<Button
size="sm"
className="mt-3"
onClick={() => {
requestLabels();
}}
>
Allow access
</Button>
{deviceError && permissionState === "denied" && (
<Muted className="mt-2 text-xs text-destructive">
{deviceError}
</Muted>
)}
</div>
)}
<MicSection
devices={audioInputs}
saved={mic}
onChange={setMic}
stream={stream}
unavailable={permissionGranted && micUnavailable}
/>
<Separator />
<CameraSection
devices={videoInputs}
saved={camera}
onChange={setCamera}
stream={stream}
unavailable={permissionGranted && cameraUnavailable}
/>
{previewError && permissionGranted && (
<div className="px-4 pb-4">
<Muted className="text-destructive text-xs">
{previewError}
</Muted>
</div>
)}
</ScrollArea>
</div>
);
}