320 lines
9.4 KiB
TypeScript
320 lines
9.4 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { ArrowLeft, VideoOff } from 'lucide-react';
|
|
import { WindowControls } from '@/components/window-controls';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
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__';
|
|
|
|
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);
|
|
const [prevEnabled, setPrevEnabled] = useState(enabled);
|
|
|
|
// Clear the preview when disabled; the effect below only manages the
|
|
// getUserMedia subscription.
|
|
if (enabled !== prevEnabled) {
|
|
setPrevEnabled(enabled);
|
|
if (!enabled) {
|
|
setStream(null);
|
|
setError(null);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!enabled) 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 / 9 } }
|
|
: { aspectRatio: { ideal: 16 / 9 } }
|
|
: 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 FieldLabel({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<label className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider">
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function InlineLevelMeter({ stream }: { stream: MediaStream | null }) {
|
|
const audioSource = useAudioSource(stream);
|
|
if (!audioSource) {
|
|
return (
|
|
<div className="flex h-3 items-end gap-1">
|
|
{[0, 1, 2].map((i) => (
|
|
<div key={i} className="bg-muted h-1 w-1 rounded-full" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="flex h-3 items-end">
|
|
<div className="scale-[0.55] origin-right">
|
|
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CameraPreview({ stream }: { stream: MediaStream | null }) {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const hasVideoTrack = (stream?.getVideoTracks().length ?? 0) > 0;
|
|
|
|
useEffect(() => {
|
|
if (videoRef.current) {
|
|
videoRef.current.srcObject = hasVideoTrack ? stream : null;
|
|
}
|
|
}, [stream, hasVideoTrack]);
|
|
|
|
return (
|
|
<div className="bg-muted/40 relative aspect-video w-full overflow-hidden rounded-md border">
|
|
{hasVideoTrack ? (
|
|
<video
|
|
ref={videoRef}
|
|
muted
|
|
autoPlay
|
|
playsInline
|
|
className="h-full w-full -scale-x-100 object-cover"
|
|
/>
|
|
) : (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5">
|
|
<VideoOff className="text-muted-foreground size-4" />
|
|
<Muted className="text-[11px]">No preview</Muted>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DeviceSelect({
|
|
devices,
|
|
saved,
|
|
onChange,
|
|
placeholder,
|
|
}: {
|
|
devices: MediaDeviceInfo[];
|
|
saved: SavedDevice | null;
|
|
onChange: (d: SavedDevice | null) => void;
|
|
placeholder: string;
|
|
}) {
|
|
const value = saved?.deviceId ?? SYSTEM_DEFAULT;
|
|
return (
|
|
<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 size="sm" className="w-full">
|
|
<SelectValue placeholder={placeholder} />
|
|
</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>
|
|
);
|
|
}
|
|
|
|
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 =
|
|
permissionGranted && !isSavedDeviceAvailable(mic, audioInputs);
|
|
const cameraUnavailable =
|
|
permissionGranted && !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="min-h-0 flex-1">
|
|
<div className="space-y-5 px-5 py-5">
|
|
{!permissionGranted && (
|
|
<div className="bg-muted/40 flex items-start justify-between gap-3 rounded-md border px-3 py-2.5">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium">Allow device access</p>
|
|
<Muted className="text-[11px] leading-snug">
|
|
Grant permission to see device names and a live preview.
|
|
</Muted>
|
|
</div>
|
|
<Button size="sm" onClick={() => requestLabels()}>
|
|
Allow
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Microphone */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<FieldLabel>Microphone</FieldLabel>
|
|
<InlineLevelMeter stream={permissionGranted ? stream : null} />
|
|
</div>
|
|
<DeviceSelect
|
|
devices={audioInputs}
|
|
saved={mic}
|
|
onChange={setMic}
|
|
placeholder="System default"
|
|
/>
|
|
{micUnavailable && (
|
|
<Muted className="text-[11px]">
|
|
Saved mic unavailable — using system default.
|
|
</Muted>
|
|
)}
|
|
</div>
|
|
|
|
{/* Camera */}
|
|
<div className="space-y-2">
|
|
<FieldLabel>Camera</FieldLabel>
|
|
<DeviceSelect
|
|
devices={videoInputs}
|
|
saved={camera}
|
|
onChange={setCamera}
|
|
placeholder={
|
|
videoInputs.length === 0 ? 'No cameras found' : 'System default'
|
|
}
|
|
/>
|
|
<CameraPreview stream={permissionGranted ? stream : null} />
|
|
{cameraUnavailable && (
|
|
<Muted className="text-[11px]">
|
|
Saved camera unavailable — using system default.
|
|
</Muted>
|
|
)}
|
|
</div>
|
|
|
|
{(previewError || (deviceError && permissionState === 'denied')) && (
|
|
<Muted className="text-destructive text-[11px]">
|
|
{previewError ?? deviceError}
|
|
</Muted>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
<div className="flex items-center justify-end border-t px-5 py-3">
|
|
<Button size="sm" onClick={() => navigate(-1)}>
|
|
Done
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|