@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user