cleanup video audio settings view

This commit is contained in:
talksik
2026-04-13 10:39:58 -07:00
parent 38bd098ca6
commit f676baf8b3
@@ -1,10 +1,9 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ArrowLeft, Camera, Mic, VideoOff } from "lucide-react"; import { ArrowLeft, VideoOff } from "lucide-react";
import { WindowControls } from "@/components/window-controls"; import { WindowControls } from "@/components/window-controls";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Muted } from "@/components/ui/typography"; import { Muted } from "@/components/ui/typography";
import { import {
Select, Select,
@@ -27,12 +26,6 @@ import {
const SYSTEM_DEFAULT = "__system_default__"; 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( function usePreviewStream(
enabled: boolean, enabled: boolean,
micId: string | undefined, micId: string | undefined,
@@ -56,8 +49,8 @@ function usePreviewStream(
: true; : true;
const video: MediaTrackConstraints | false = cameraAvailable const video: MediaTrackConstraints | false = cameraAvailable
? cameraId ? cameraId
? { deviceId: { exact: cameraId }, aspectRatio: { ideal: 16 / 10 } } ? { deviceId: { exact: cameraId }, aspectRatio: { ideal: 16 / 9 } }
: { aspectRatio: { ideal: 16 / 10 } } : { aspectRatio: { ideal: 16 / 9 } }
: false; : false;
navigator.mediaDevices navigator.mediaDevices
@@ -74,9 +67,7 @@ function usePreviewStream(
.catch((err: unknown) => { .catch((err: unknown) => {
if (cancelled) return; if (cancelled) return;
setStream(null); setStream(null);
setError( setError(err instanceof Error ? err.message : "Unable to access devices");
err instanceof Error ? err.message : "Unable to access devices",
);
}); });
return () => { return () => {
@@ -94,94 +85,36 @@ function deviceLabel(d: MediaDeviceInfo, index: number): string {
return `${kind} ${index + 1}`; return `${kind} ${index + 1}`;
} }
function MicSection({ function FieldLabel({ children }: { children: React.ReactNode }) {
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 ( return (
<section className="px-4 py-4"> <label className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider">
<div className="mb-3 flex items-center gap-2"> {children}
<Mic className="text-muted-foreground size-4" /> </label>
<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({ function InlineLevelMeter({ stream }: { stream: MediaStream | null }) {
devices, const audioSource = useAudioSource(stream);
saved, if (!audioSource) {
onChange, return (
stream, <div className="flex h-3 items-end gap-1">
unavailable, {[0, 1, 2].map((i) => (
}: { <div key={i} className="bg-muted h-1 w-1 rounded-full" />
devices: MediaDeviceInfo[]; ))}
saved: SavedDevice | null; </div>
onChange: (device: SavedDevice | null) => void; );
stream: MediaStream | null; }
unavailable: boolean; 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 videoRef = useRef<HTMLVideoElement>(null);
const value = saved?.deviceId ?? SYSTEM_DEFAULT;
const hasVideoTrack = (stream?.getVideoTracks().length ?? 0) > 0; const hasVideoTrack = (stream?.getVideoTracks().length ?? 0) > 0;
useEffect(() => { useEffect(() => {
@@ -191,63 +124,61 @@ function CameraSection({
}, [stream, hasVideoTrack]); }, [stream, hasVideoTrack]);
return ( return (
<section className="px-4 py-4"> <div className="bg-muted/40 relative aspect-video w-full overflow-hidden rounded-md border">
<div className="mb-3 flex items-center gap-2"> {hasVideoTrack ? (
<Camera className="text-muted-foreground size-4" /> <video
<h2 className="text-sm font-medium">Camera</h2> ref={videoRef}
</div> muted
<Select autoPlay
value={value} playsInline
onValueChange={(v) => { className="h-full w-full -scale-x-100 object-cover"
if (v === SYSTEM_DEFAULT) { />
onChange(null); ) : (
return; <div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5">
} <VideoOff className="text-muted-foreground size-4" />
const match = devices.find((d) => d.deviceId === v); <Muted className="text-[11px]">No preview</Muted>
if (match) onChange({ deviceId: match.deviceId, label: match.label }); </div>
}}
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> </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>
); );
} }
@@ -285,8 +216,10 @@ export default function AudioVideoSettingsPage() {
cameraAvailable, cameraAvailable,
); );
const micUnavailable = !isSavedDeviceAvailable(mic, audioInputs); const micUnavailable =
const cameraUnavailable = !isSavedDeviceAvailable(camera, videoInputs); permissionGranted && !isSavedDeviceAvailable(mic, audioInputs);
const cameraUnavailable =
permissionGranted && !isSavedDeviceAvailable(camera, videoInputs);
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
@@ -305,57 +238,72 @@ export default function AudioVideoSettingsPage() {
</div> </div>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
{!permissionGranted && ( <div className="space-y-5 px-5 py-5">
<div className="border-b px-4 py-4"> {!permissionGranted && (
<p className="text-sm font-medium"> <div className="bg-muted/40 flex items-start justify-between gap-3 rounded-md border px-3 py-2.5">
Allow microphone and camera access <div className="min-w-0">
</p> <p className="text-sm font-medium">Allow device access</p>
<Muted className="mt-1 text-xs"> <Muted className="text-[11px] leading-snug">
Grant access once so llink can show device names and previews. Grant permission to see device names and a live preview.
</Muted> </Muted>
<Button </div>
size="sm" <Button size="sm" onClick={() => requestLabels()}>
className="mt-3" Allow
onClick={() => { </Button>
requestLabels(); </div>
}} )}
>
Allow access {/* Microphone */}
</Button> <div className="space-y-2">
{deviceError && permissionState === "denied" && ( <div className="flex items-center justify-between">
<Muted className="mt-2 text-xs text-destructive"> <FieldLabel>Microphone</FieldLabel>
{deviceError} <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> </Muted>
)} )}
</div> </div>
)}
<MicSection {/* Camera */}
devices={audioInputs} <div className="space-y-2">
saved={mic} <FieldLabel>Camera</FieldLabel>
onChange={setMic} <DeviceSelect
stream={stream} devices={videoInputs}
unavailable={permissionGranted && micUnavailable} saved={camera}
/> onChange={setCamera}
placeholder={
<Separator /> videoInputs.length === 0 ? "No cameras found" : "System default"
}
<CameraSection />
devices={videoInputs} <CameraPreview stream={permissionGranted ? stream : null} />
saved={camera} {cameraUnavailable && (
onChange={setCamera} <Muted className="text-[11px]">
stream={stream} Saved camera unavailable using system default.
unavailable={permissionGranted && cameraUnavailable} </Muted>
/> )}
{previewError && permissionGranted && (
<div className="px-4 pb-4">
<Muted className="text-destructive text-xs">
{previewError}
</Muted>
</div> </div>
)}
{(previewError || (deviceError && permissionState === "denied")) && (
<Muted className="text-destructive text-[11px]">
{previewError ?? deviceError}
</Muted>
)}
</div>
</ScrollArea> </ScrollArea>
<div className="flex items-center justify-end border-t px-5 py-3">
<Button size="sm" onClick={() => navigate(-1)}>
Done
</Button>
</div>
</div> </div>
); );
} }