@@ -8,6 +8,7 @@ import {
|
|||||||
QueryClientProvider,
|
QueryClientProvider,
|
||||||
} from '@tanstack/react-query'
|
} from '@tanstack/react-query'
|
||||||
import SettingsPage from "@/features/settings-page";
|
import SettingsPage from "@/features/settings-page";
|
||||||
|
import AudioVideoSettingsPage from "@/features/settings/audio-video-settings-page";
|
||||||
import NetworkSelector from "@/features/network-selector";
|
import NetworkSelector from "@/features/network-selector";
|
||||||
import NetworkRoot from "@/features/network-root";
|
import NetworkRoot from "@/features/network-root";
|
||||||
import ParticleViewResolver from "@/features/particles/particle-view-resolver";
|
import ParticleViewResolver from "@/features/particles/particle-view-resolver";
|
||||||
@@ -63,6 +64,7 @@ function AuthenticatedApp() {
|
|||||||
<AutoplayNavigationListener />
|
<AutoplayNavigationListener />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
<Route path="settings/audio-video" element={<AudioVideoSettingsPage />} />
|
||||||
|
|
||||||
<Route path="/">
|
<Route path="/">
|
||||||
<Route index element={<Layout><NetworkSelector /></Layout>} />
|
<Route index element={<Layout><NetworkSelector /></Layout>} />
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import { TextComposeStep } from "@/features/compose/text-compose-step";
|
|||||||
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
|
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from "@/api/client";
|
||||||
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
||||||
|
import { useMediaDevicesStore } from "@/stores/media-devices-store";
|
||||||
|
import { useMediaDevices } from "@/hooks/use-media-devices";
|
||||||
|
import { resolveEffectiveDeviceId } from "@/hooks/use-effective-device-id";
|
||||||
import { useFileInput } from "@/hooks/use-file-input";
|
import { useFileInput } from "@/hooks/use-file-input";
|
||||||
import { createImageThumbnail } from "@/lib/image-thumbnail";
|
import { createImageThumbnail } from "@/lib/image-thumbnail";
|
||||||
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants";
|
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants";
|
||||||
@@ -59,6 +62,11 @@ export function ComposeOverlay({
|
|||||||
const [recordingSource, setRecordingSource] = useState<RecordingSource>("media");
|
const [recordingSource, setRecordingSource] = useState<RecordingSource>("media");
|
||||||
|
|
||||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||||
|
const savedMic = useMediaDevicesStore((s) => s.mic);
|
||||||
|
const savedCamera = useMediaDevicesStore((s) => s.camera);
|
||||||
|
const { audioInputs, videoInputs } = useMediaDevices();
|
||||||
|
const micDeviceId = resolveEffectiveDeviceId(savedMic, audioInputs);
|
||||||
|
const cameraDeviceId = resolveEffectiveDeviceId(savedCamera, videoInputs);
|
||||||
const userId = useAuthStore((s) => s.user?.id);
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
const createParticle = useCreateParticle();
|
const createParticle = useCreateParticle();
|
||||||
const createStream = useCreateStreamParticle();
|
const createStream = useCreateStreamParticle();
|
||||||
@@ -151,6 +159,8 @@ export function ComposeOverlay({
|
|||||||
|
|
||||||
const { startRecording, stopRecording, cancelRecording } = useRecorder({
|
const { startRecording, stopRecording, cancelRecording } = useRecorder({
|
||||||
mode: recordingMode,
|
mode: recordingMode,
|
||||||
|
micDeviceId,
|
||||||
|
cameraDeviceId,
|
||||||
onStreamReady: (stream) => setMediaStream(stream),
|
onStreamReady: (stream) => setMediaStream(stream),
|
||||||
onStreamCleanup: () => setMediaStream(null),
|
onStreamCleanup: () => setMediaStream(null),
|
||||||
onFinish: (blob, durationMs, mimeType) => {
|
onFinish: (blob, durationMs, mimeType) => {
|
||||||
@@ -167,6 +177,7 @@ export function ComposeOverlay({
|
|||||||
stopRecording: stopScreenRecording,
|
stopRecording: stopScreenRecording,
|
||||||
cancelRecording: cancelScreenRecording,
|
cancelRecording: cancelScreenRecording,
|
||||||
} = useScreenRecorder({
|
} = useScreenRecorder({
|
||||||
|
micDeviceId,
|
||||||
onFinish: (blob, durationMs, mimeType) => {
|
onFinish: (blob, durationMs, mimeType) => {
|
||||||
setStepSync("reviewing");
|
setStepSync("reviewing");
|
||||||
setReviewBlob(blob);
|
setReviewBlob(blob);
|
||||||
|
|||||||
@@ -19,18 +19,69 @@ function getMediaMime(mode: "video" | "audio"): string {
|
|||||||
|
|
||||||
interface UseRecorderOptions {
|
interface UseRecorderOptions {
|
||||||
mode: RecordingMode;
|
mode: RecordingMode;
|
||||||
|
micDeviceId?: string;
|
||||||
|
cameraDeviceId?: string;
|
||||||
onStreamReady: (stream: MediaStream) => void;
|
onStreamReady: (stream: MediaStream) => void;
|
||||||
onStreamCleanup: () => void;
|
onStreamCleanup: () => void;
|
||||||
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
|
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
|
||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildConstraints(
|
||||||
|
mode: RecordingMode,
|
||||||
|
micDeviceId: string | undefined,
|
||||||
|
cameraDeviceId: string | undefined,
|
||||||
|
): MediaStreamConstraints {
|
||||||
|
const audio: MediaTrackConstraints | boolean = micDeviceId
|
||||||
|
? { deviceId: { exact: micDeviceId } }
|
||||||
|
: true;
|
||||||
|
|
||||||
|
if (mode === "audio") return { audio };
|
||||||
|
|
||||||
|
const video: MediaTrackConstraints = cameraDeviceId
|
||||||
|
? { deviceId: { exact: cameraDeviceId }, aspectRatio: { ideal: 4 / 3 } }
|
||||||
|
: { aspectRatio: { ideal: 4 / 3 } };
|
||||||
|
return { audio, video };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStreamWithFallback(
|
||||||
|
constraints: MediaStreamConstraints,
|
||||||
|
hasDeviceId: boolean,
|
||||||
|
): Promise<MediaStream> {
|
||||||
|
try {
|
||||||
|
return await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
} catch (err) {
|
||||||
|
// When a saved device has been unplugged, `{ exact }` throws
|
||||||
|
// OverconstrainedError. Fall back to the system default so users
|
||||||
|
// aren't blocked from recording.
|
||||||
|
if (
|
||||||
|
hasDeviceId &&
|
||||||
|
err instanceof Error &&
|
||||||
|
(err.name === "OverconstrainedError" || err.name === "NotFoundError")
|
||||||
|
) {
|
||||||
|
const relaxed: MediaStreamConstraints = {
|
||||||
|
audio: typeof constraints.audio === "object" ? true : constraints.audio,
|
||||||
|
...(constraints.video !== undefined && {
|
||||||
|
video:
|
||||||
|
typeof constraints.video === "object"
|
||||||
|
? { aspectRatio: { ideal: 4 / 3 } }
|
||||||
|
: constraints.video,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return navigator.mediaDevices.getUserMedia(relaxed);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages MediaRecorder lifecycle. Pure media utility — knows nothing
|
* Manages MediaRecorder lifecycle. Pure media utility — knows nothing
|
||||||
* about application state. The consumer provides callbacks for all outputs.
|
* about application state. The consumer provides callbacks for all outputs.
|
||||||
*/
|
*/
|
||||||
export function useRecorder({
|
export function useRecorder({
|
||||||
mode,
|
mode,
|
||||||
|
micDeviceId,
|
||||||
|
cameraDeviceId,
|
||||||
onStreamReady,
|
onStreamReady,
|
||||||
onStreamCleanup,
|
onStreamCleanup,
|
||||||
onFinish,
|
onFinish,
|
||||||
@@ -61,13 +112,9 @@ export function useRecorder({
|
|||||||
|
|
||||||
const startRecording = useCallback(async () => {
|
const startRecording = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const constraints =
|
const constraints = buildConstraints(mode, micDeviceId, cameraDeviceId);
|
||||||
mode === "video"
|
const hasDeviceId = Boolean(micDeviceId || cameraDeviceId);
|
||||||
? { video: { aspectRatio: { ideal: 4 / 3 } }, audio: true }
|
const mediaStream = await getStreamWithFallback(constraints, hasDeviceId);
|
||||||
: { audio: true };
|
|
||||||
|
|
||||||
const mediaStream =
|
|
||||||
await navigator.mediaDevices.getUserMedia(constraints);
|
|
||||||
|
|
||||||
streamRef.current = mediaStream;
|
streamRef.current = mediaStream;
|
||||||
onStreamReady(mediaStream);
|
onStreamReady(mediaStream);
|
||||||
@@ -99,7 +146,7 @@ export function useRecorder({
|
|||||||
err instanceof Error ? err.message : "Failed to start recording",
|
err instanceof Error ? err.message : "Failed to start recording",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [mode, onStreamReady, stopTracks]);
|
}, [mode, micDeviceId, cameraDeviceId, onStreamReady, stopTracks]);
|
||||||
|
|
||||||
const stopRecording = useCallback(() => {
|
const stopRecording = useCallback(() => {
|
||||||
if (recorderRef.current?.state === "recording") {
|
if (recorderRef.current?.state === "recording") {
|
||||||
|
|||||||
@@ -14,15 +14,37 @@ function getScreenMime(): string {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface UseScreenRecorderOptions {
|
interface UseScreenRecorderOptions {
|
||||||
|
micDeviceId?: string;
|
||||||
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
|
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
|
||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getMicStream(
|
||||||
|
micDeviceId: string | undefined,
|
||||||
|
): Promise<MediaStream> {
|
||||||
|
const constraints: MediaStreamConstraints = {
|
||||||
|
audio: micDeviceId ? { deviceId: { exact: micDeviceId } } : true,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
return await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
micDeviceId &&
|
||||||
|
err instanceof Error &&
|
||||||
|
(err.name === "OverconstrainedError" || err.name === "NotFoundError")
|
||||||
|
) {
|
||||||
|
return navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages screen recording via Electron's desktopCapturer.
|
* Manages screen recording via Electron's desktopCapturer.
|
||||||
* Captures screen video + mic audio.
|
* Captures screen video + mic audio.
|
||||||
*/
|
*/
|
||||||
export function useScreenRecorder({
|
export function useScreenRecorder({
|
||||||
|
micDeviceId,
|
||||||
onFinish,
|
onFinish,
|
||||||
onError,
|
onError,
|
||||||
}: UseScreenRecorderOptions) {
|
}: UseScreenRecorderOptions) {
|
||||||
@@ -67,7 +89,7 @@ export function useScreenRecorder({
|
|||||||
screenStreamRef.current = screenStream;
|
screenStreamRef.current = screenStream;
|
||||||
|
|
||||||
// 2. Mic audio
|
// 2. Mic audio
|
||||||
const micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
const micStream = await getMicStream(micDeviceId);
|
||||||
micStreamRef.current = micStream;
|
micStreamRef.current = micStream;
|
||||||
|
|
||||||
// 3. Combine screen video + mic audio
|
// 3. Combine screen video + mic audio
|
||||||
@@ -117,7 +139,7 @@ export function useScreenRecorder({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[stopAllTracks],
|
[micDeviceId, stopAllTracks],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stopRecording = useCallback(() => {
|
const stopRecording = useCallback(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ChevronRight, LogOut, User, Info, Shield, Mail } from "lucide-react";
|
import { ChevronRight, LogOut, User, Info, Shield, Mail, Mic } from "lucide-react";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@@ -155,6 +155,16 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<Separator className="mt-4" />
|
<Separator className="mt-4" />
|
||||||
|
|
||||||
|
<SettingsGroup title="Media">
|
||||||
|
<SettingsRow
|
||||||
|
icon={<Mic className="size-4" />}
|
||||||
|
label="Audio & Video"
|
||||||
|
onClick={() => navigate("/settings/audio-video")}
|
||||||
|
/>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<Separator className="mt-4" />
|
||||||
|
|
||||||
<SettingsGroup title="About">
|
<SettingsGroup title="About">
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
icon={<Info className="size-4" />}
|
icon={<Info className="size-4" />}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { SavedDevice } from "@/stores/media-devices-store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a saved device preference against the currently available
|
||||||
|
* devices. Returns the saved `deviceId` only if it still appears in the
|
||||||
|
* list — otherwise `undefined` so getUserMedia falls back to the
|
||||||
|
* system default. This keeps "unplugged device" handling in one place.
|
||||||
|
*/
|
||||||
|
export function resolveEffectiveDeviceId(
|
||||||
|
saved: SavedDevice | null,
|
||||||
|
available: MediaDeviceInfo[],
|
||||||
|
): string | undefined {
|
||||||
|
if (!saved) return undefined;
|
||||||
|
const match = available.find((d) => d.deviceId === saved.deviceId);
|
||||||
|
return match ? match.deviceId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSavedDeviceAvailable(
|
||||||
|
saved: SavedDevice | null,
|
||||||
|
available: MediaDeviceInfo[],
|
||||||
|
): boolean {
|
||||||
|
if (!saved) return true;
|
||||||
|
return available.some((d) => d.deviceId === saved.deviceId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type PermissionState = "unknown" | "granted" | "denied";
|
||||||
|
|
||||||
|
interface UseMediaDevicesResult {
|
||||||
|
audioInputs: MediaDeviceInfo[];
|
||||||
|
videoInputs: MediaDeviceInfo[];
|
||||||
|
permissionState: PermissionState;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
requestLabels: () => Promise<void>;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerates input devices and stays subscribed to `devicechange`.
|
||||||
|
*
|
||||||
|
* Labels are only populated after the user has granted mic/camera
|
||||||
|
* permission — `requestLabels` triggers a brief getUserMedia so that
|
||||||
|
* subsequent enumerations return human-readable names, matching the
|
||||||
|
* pattern most video-conferencing apps use.
|
||||||
|
*/
|
||||||
|
export function useMediaDevices(): UseMediaDevicesResult {
|
||||||
|
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||||
|
const [permissionState, setPermissionState] =
|
||||||
|
useState<PermissionState>("unknown");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
setDevices(list);
|
||||||
|
// If at least one input device has a non-empty label, permission
|
||||||
|
// has been granted at some point for that device kind.
|
||||||
|
const hasLabels = list.some(
|
||||||
|
(d) =>
|
||||||
|
(d.kind === "audioinput" || d.kind === "videoinput") &&
|
||||||
|
d.label.length > 0,
|
||||||
|
);
|
||||||
|
if (hasLabels) setPermissionState("granted");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to list devices");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestLabels = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: true,
|
||||||
|
video: true,
|
||||||
|
});
|
||||||
|
// Immediately stop — we only needed the permission grant.
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
setPermissionState("granted");
|
||||||
|
setError(null);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setPermissionState("denied");
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Microphone/camera access denied",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
const handle = () => {
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
navigator.mediaDevices.addEventListener("devicechange", handle);
|
||||||
|
return () => {
|
||||||
|
navigator.mediaDevices.removeEventListener("devicechange", handle);
|
||||||
|
};
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioInputs: devices.filter((d) => d.kind === "audioinput"),
|
||||||
|
videoInputs: devices.filter((d) => d.kind === "videoinput"),
|
||||||
|
permissionState,
|
||||||
|
refresh,
|
||||||
|
requestLabels,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export interface SavedDevice {
|
||||||
|
deviceId: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIC_KEY = "llink:mic-device";
|
||||||
|
const CAMERA_KEY = "llink:camera-device";
|
||||||
|
|
||||||
|
function load(key: string): SavedDevice | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as SavedDevice;
|
||||||
|
if (typeof parsed?.deviceId !== "string") return null;
|
||||||
|
return { deviceId: parsed.deviceId, label: parsed.label ?? "" };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(key: string, value: SavedDevice | null) {
|
||||||
|
try {
|
||||||
|
if (value) localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
else localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// Storage unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaDevicesState {
|
||||||
|
mic: SavedDevice | null;
|
||||||
|
camera: SavedDevice | null;
|
||||||
|
setMic: (device: SavedDevice | null) => void;
|
||||||
|
setCamera: (device: SavedDevice | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMediaDevicesStore = create<MediaDevicesState>((set) => ({
|
||||||
|
mic: load(MIC_KEY),
|
||||||
|
camera: load(CAMERA_KEY),
|
||||||
|
setMic: (device) => {
|
||||||
|
save(MIC_KEY, device);
|
||||||
|
set({ mic: device });
|
||||||
|
},
|
||||||
|
setCamera: (device) => {
|
||||||
|
save(CAMERA_KEY, device);
|
||||||
|
set({ camera: device });
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user