Enhance audio and video device selection in MobileCallPage

- Refactor `CallDeviceSelect` to `CallAudioDeviceSelect`, allowing simultaneous selection of microphone and output devices.
- Introduce dropdown menus for selecting audio input and output devices, improving user experience.
- Update `useVoicePreview` hook to manage audio output devices, including support for selecting and applying output devices.
- Modify `MobileCallPage` to integrate new device selection components, ensuring seamless audio and video call functionality.
This commit is contained in:
Xin Wang
2026-07-10 11:09:44 +08:00
parent 0fa02cc563
commit dc2a7484bb
2 changed files with 169 additions and 32 deletions

View File

@@ -1,8 +1,17 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { Camera, Loader2, Mic, Phone, PhoneOff, Video } from "lucide-react";
import { Camera, Headphones, Loader2, Mic, Phone, PhoneOff, Video } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
@@ -13,22 +22,99 @@ import {
import { useCameraPreview } from "@/hooks/use-camera-preview";
import { useVoicePreview } from "@/hooks/use-voice-preview";
function CallDeviceSelect({
kind,
function CallAudioDeviceSelect({
micValue,
micDevices,
onMicSelect,
outputValue,
outputDevices,
onOutputSelect,
outputSupported,
disabled,
}: {
micValue: string;
micDevices: MediaDeviceInfo[];
onMicSelect: (deviceId: string) => void | Promise<void>;
outputValue: string;
outputDevices: MediaDeviceInfo[];
onOutputSelect: (deviceId: string) => void;
outputSupported: boolean;
disabled?: boolean;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={disabled}>
<button
type="button"
aria-label="选择麦克风和耳机"
title="选择麦克风和耳机"
className="flex size-12 items-center justify-center rounded-full border border-white/15 bg-black/45 text-white shadow-xl backdrop-blur-md transition-colors hover:bg-black/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 disabled:pointer-events-none disabled:opacity-50 min-[380px]:size-14"
>
<Mic className="size-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
className="max-w-[min(20rem,calc(100vw-2rem))] min-w-56"
>
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuRadioGroup
value={micValue || "default"}
onValueChange={(next) =>
void onMicSelect(next === "default" ? "" : next)
}
>
<DropdownMenuRadioItem value="default"></DropdownMenuRadioItem>
{micDevices.map((device, index) => (
<DropdownMenuRadioItem key={device.deviceId} value={device.deviceId}>
{device.label || `麦克风 ${index + 1}`}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
{outputSupported && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-center gap-2">
<Headphones className="size-3.5" />
/
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={outputValue || "default"}
onValueChange={(next) =>
onOutputSelect(next === "default" ? "" : next)
}
>
<DropdownMenuRadioItem value="default">
</DropdownMenuRadioItem>
{outputDevices.map((device, index) => (
<DropdownMenuRadioItem
key={device.deviceId}
value={device.deviceId}
>
{device.label || `输出设备 ${index + 1}`}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function CallCameraDeviceSelect({
value,
devices,
onSelect,
disabled,
}: {
kind: "microphone" | "camera";
value: string;
devices: MediaDeviceInfo[];
onSelect: (deviceId: string) => void | Promise<void>;
disabled?: boolean;
}) {
const isMic = kind === "microphone";
const label = isMic ? "选择麦克风" : "选择摄像头";
return (
<Select
value={value || "default"}
@@ -39,30 +125,24 @@ function CallDeviceSelect({
>
<SelectTrigger
size="default"
aria-label={label}
title={label}
aria-label="选择摄像头"
title="选择摄像头"
className="w-12 justify-center overflow-hidden rounded-full border-white/15 bg-black/45 p-0 text-white shadow-xl backdrop-blur-md hover:bg-black/60 focus-visible:ring-white/40 data-[size=default]:h-12 min-[380px]:w-14 min-[380px]:data-[size=default]:h-14 [&>svg:last-child]:hidden"
>
<span className="sr-only">
<SelectValue />
</span>
{isMic ? (
<Mic className="size-5" />
) : (
<Camera className="size-5" />
)}
<Camera className="size-5" />
</SelectTrigger>
<SelectContent
side="top"
align={isMic ? "start" : "end"}
align="end"
className="max-w-[min(20rem,calc(100vw-2rem))]"
>
<SelectItem value="default">
{isMic ? "默认麦克风" : "默认摄像头"}
</SelectItem>
<SelectItem value="default"></SelectItem>
{devices.map((device, index) => (
<SelectItem key={device.deviceId} value={device.deviceId}>
{device.label || `${isMic ? "麦克风" : "摄像头"} ${index + 1}`}
{device.label || `摄像头 ${index + 1}`}
</SelectItem>
))}
</SelectContent>
@@ -77,8 +157,12 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
status,
error: previewError,
audioInputs,
audioOutputs,
selectedDeviceId,
selectedOutputDeviceId,
selectDevice,
selectOutputDevice,
supportsOutputSelection,
connect,
replaceVideoStream,
disconnect,
@@ -203,11 +287,14 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
{inCall && (
<div className="absolute bottom-[max(1.25rem,env(safe-area-inset-bottom))] left-1/2 flex -translate-x-1/2 items-center gap-3 min-[380px]:gap-5 landscape:bottom-[max(1rem,env(safe-area-inset-bottom))]">
<CallDeviceSelect
kind="microphone"
value={selectedDeviceId}
devices={audioInputs}
onSelect={selectDevice}
<CallAudioDeviceSelect
micValue={selectedDeviceId}
micDevices={audioInputs}
onMicSelect={selectDevice}
outputValue={selectedOutputDeviceId}
outputDevices={audioOutputs}
onOutputSelect={selectOutputDevice}
outputSupported={supportsOutputSelection}
disabled={connecting}
/>
<button
@@ -219,8 +306,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
>
<PhoneOff className="size-6 min-[380px]:size-7" />
</button>
<CallDeviceSelect
kind="camera"
<CallCameraDeviceSelect
value={cameraDeviceId}
devices={cameraDevices}
onSelect={selectCamera}