cleanup video audio settings view
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user