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"; "use client";
import { useCallback, useEffect, useRef } from "react"; 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 { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -13,22 +22,99 @@ import {
import { useCameraPreview } from "@/hooks/use-camera-preview"; import { useCameraPreview } from "@/hooks/use-camera-preview";
import { useVoicePreview } from "@/hooks/use-voice-preview"; import { useVoicePreview } from "@/hooks/use-voice-preview";
function CallDeviceSelect({ function CallAudioDeviceSelect({
kind, 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, value,
devices, devices,
onSelect, onSelect,
disabled, disabled,
}: { }: {
kind: "microphone" | "camera";
value: string; value: string;
devices: MediaDeviceInfo[]; devices: MediaDeviceInfo[];
onSelect: (deviceId: string) => void | Promise<void>; onSelect: (deviceId: string) => void | Promise<void>;
disabled?: boolean; disabled?: boolean;
}) { }) {
const isMic = kind === "microphone";
const label = isMic ? "选择麦克风" : "选择摄像头";
return ( return (
<Select <Select
value={value || "default"} value={value || "default"}
@@ -39,30 +125,24 @@ function CallDeviceSelect({
> >
<SelectTrigger <SelectTrigger
size="default" size="default"
aria-label={label} aria-label="选择摄像头"
title={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" 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"> <span className="sr-only">
<SelectValue /> <SelectValue />
</span> </span>
{isMic ? ( <Camera className="size-5" />
<Mic className="size-5" />
) : (
<Camera className="size-5" />
)}
</SelectTrigger> </SelectTrigger>
<SelectContent <SelectContent
side="top" side="top"
align={isMic ? "start" : "end"} align="end"
className="max-w-[min(20rem,calc(100vw-2rem))]" className="max-w-[min(20rem,calc(100vw-2rem))]"
> >
<SelectItem value="default"> <SelectItem value="default"></SelectItem>
{isMic ? "默认麦克风" : "默认摄像头"}
</SelectItem>
{devices.map((device, index) => ( {devices.map((device, index) => (
<SelectItem key={device.deviceId} value={device.deviceId}> <SelectItem key={device.deviceId} value={device.deviceId}>
{device.label || `${isMic ? "麦克风" : "摄像头"} ${index + 1}`} {device.label || `摄像头 ${index + 1}`}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -77,8 +157,12 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
status, status,
error: previewError, error: previewError,
audioInputs, audioInputs,
audioOutputs,
selectedDeviceId, selectedDeviceId,
selectedOutputDeviceId,
selectDevice, selectDevice,
selectOutputDevice,
supportsOutputSelection,
connect, connect,
replaceVideoStream, replaceVideoStream,
disconnect, disconnect,
@@ -203,11 +287,14 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
{inCall && ( {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))]"> <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 <CallAudioDeviceSelect
kind="microphone" micValue={selectedDeviceId}
value={selectedDeviceId} micDevices={audioInputs}
devices={audioInputs} onMicSelect={selectDevice}
onSelect={selectDevice} outputValue={selectedOutputDeviceId}
outputDevices={audioOutputs}
onOutputSelect={selectOutputDevice}
outputSupported={supportsOutputSelection}
disabled={connecting} disabled={connecting}
/> />
<button <button
@@ -219,8 +306,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
> >
<PhoneOff className="size-6 min-[380px]:size-7" /> <PhoneOff className="size-6 min-[380px]:size-7" />
</button> </button>
<CallDeviceSelect <CallCameraDeviceSelect
kind="camera"
value={cameraDeviceId} value={cameraDeviceId}
devices={cameraDevices} devices={cameraDevices}
onSelect={selectCamera} onSelect={selectCamera}

View File

@@ -99,10 +99,14 @@ export function useVoicePreview(
// 远端(助手 TTS)媒体流:除挂到 <audio> 播放外,也暴露给波形可视化 // 远端(助手 TTS)媒体流:除挂到 <audio> 播放外,也暴露给波形可视化
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null); const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
// 可选麦克风列表与当前选择(空串表示交给浏览器选默认设备) // 可选麦克风/扬声器列表与当前选择(空串表示交给浏览器选默认设备)
const [audioInputs, setAudioInputs] = useState<MediaDeviceInfo[]>([]); const [audioInputs, setAudioInputs] = useState<MediaDeviceInfo[]>([]);
const [audioOutputs, setAudioOutputs] = useState<MediaDeviceInfo[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>(""); const [selectedDeviceId, setSelectedDeviceId] = useState<string>("");
const [selectedOutputDeviceId, setSelectedOutputDeviceId] =
useState<string>("");
const selectedDeviceIdRef = useRef(""); const selectedDeviceIdRef = useRef("");
const selectedOutputDeviceIdRef = useRef("");
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null); const pcRef = useRef<RTCPeerConnection | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
@@ -118,14 +122,32 @@ export function useVoicePreview(
onNodeActiveRef.current = onNodeActive; onNodeActiveRef.current = onNodeActive;
}, [onNodeActive]); }, [onNodeActive]);
// 枚举可用麦克风。未授权前 label 为空,授权(连接)后再刷新即可拿到名称。 const supportsOutputSelection =
typeof HTMLAudioElement !== "undefined" &&
"setSinkId" in HTMLAudioElement.prototype;
const applyOutputDevice = useCallback(async (deviceId: string) => {
const audio = audioRef.current;
if (!audio || !supportsOutputSelection) return;
try {
await audio.setSinkId(deviceId);
} catch {
/* 忽略切换失败(设备不可用或不支持) */
}
}, [supportsOutputSelection]);
// 枚举可用麦克风与扬声器。未授权前 label 为空,授权(连接)后再刷新即可拿到名称。
const refreshDevices = useCallback(async () => { const refreshDevices = useCallback(async () => {
if (!navigator.mediaDevices?.enumerateDevices) return; if (!navigator.mediaDevices?.enumerateDevices) return;
try { try {
const devices = await navigator.mediaDevices.enumerateDevices(); const devices = await navigator.mediaDevices.enumerateDevices();
// 未授权时设备可能没有 deviceId(空串),无法选择,直接过滤掉 // 未授权时设备可能没有 deviceId(空串),无法选择,直接过滤掉
const inputs = devices.filter((d) => d.kind === "audioinput" && d.deviceId); const inputs = devices.filter((d) => d.kind === "audioinput" && d.deviceId);
const outputs = devices.filter(
(d) => d.kind === "audiooutput" && d.deviceId,
);
setAudioInputs(inputs); setAudioInputs(inputs);
setAudioOutputs(outputs);
// 选中的设备已被拔出时,回退到浏览器默认设备 // 选中的设备已被拔出时,回退到浏览器默认设备
if ( if (
selectedDeviceIdRef.current && selectedDeviceIdRef.current &&
@@ -133,16 +155,31 @@ export function useVoicePreview(
) { ) {
setSelectedDeviceId(""); setSelectedDeviceId("");
} }
if (
selectedOutputDeviceIdRef.current &&
!outputs.some((d) => d.deviceId === selectedOutputDeviceIdRef.current)
) {
setSelectedOutputDeviceId("");
void applyOutputDevice("");
}
} catch { } catch {
/* 忽略枚举失败 */ /* 忽略枚举失败 */
} }
}, []); }, [applyOutputDevice]);
// connect/refreshDevices 在回调里读最新选择,避免把它们挂进依赖反复重建 // connect/refreshDevices 在回调里读最新选择,避免把它们挂进依赖反复重建
useEffect(() => { useEffect(() => {
selectedDeviceIdRef.current = selectedDeviceId; selectedDeviceIdRef.current = selectedDeviceId;
}, [selectedDeviceId]); }, [selectedDeviceId]);
useEffect(() => {
selectedOutputDeviceIdRef.current = selectedOutputDeviceId;
}, [selectedOutputDeviceId]);
useEffect(() => {
void applyOutputDevice(selectedOutputDeviceId);
}, [applyOutputDevice, selectedOutputDeviceId, remoteStream]);
useEffect(() => { useEffect(() => {
if (!navigator.mediaDevices?.enumerateDevices) return; if (!navigator.mediaDevices?.enumerateDevices) return;
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
@@ -336,7 +373,6 @@ export function useVoicePreview(
}; };
// 应用消息通道:收后端转写(聊天记录),发文字输入。 // 应用消息通道:收后端转写(聊天记录),发文字输入。
// 由浏览器侧主动创建,后端 SmallWebRTCConnection 的 on("datachannel") 会接住。
const channel = pc.createDataChannel("chat"); const channel = pc.createDataChannel("chat");
dataChannelRef.current = channel; dataChannelRef.current = channel;
channel.onopen = () => { channel.onopen = () => {
@@ -433,7 +469,9 @@ export function useVoicePreview(
setRemoteStream(remote); setRemoteStream(remote);
if (audioRef.current) { if (audioRef.current) {
audioRef.current.srcObject = remote; audioRef.current.srcObject = remote;
void audioRef.current.play().catch(() => {}); void applyOutputDevice(selectedOutputDeviceIdRef.current).then(() =>
audioRef.current?.play().catch(() => {}),
);
} }
}; };
@@ -491,7 +529,7 @@ export function useVoicePreview(
} finally { } finally {
startingRef.current = false; startingRef.current = false;
} }
}, [assistantId, fail, closeOnRemoteEnd, refreshDevices]); }, [assistantId, applyOutputDevice, fail, closeOnRemoteEnd, refreshDevices]);
const replaceVideoStream = useCallback( const replaceVideoStream = useCallback(
async (videoStream: MediaStream | null) => { async (videoStream: MediaStream | null) => {
@@ -554,6 +592,15 @@ export function useVoicePreview(
[], [],
); );
const selectOutputDevice = useCallback(
(deviceId: string) => {
setSelectedOutputDeviceId(deviceId);
selectedOutputDeviceIdRef.current = deviceId;
void applyOutputDevice(deviceId);
},
[applyOutputDevice],
);
// 发送文字消息:后端先打断当前播报,再按用户输入触发新回复。 // 发送文字消息:后端先打断当前播报,再按用户输入触发新回复。
// 成功返回 true;通道未就绪(未开始对话/连接中)返回 false。 // 成功返回 true;通道未就绪(未开始对话/连接中)返回 false。
const sendText = useCallback((text: string): boolean => { const sendText = useCallback((text: string): boolean => {
@@ -575,9 +622,13 @@ export function useVoicePreview(
remoteStream, remoteStream,
messages, messages,
audioInputs, audioInputs,
audioOutputs,
selectedDeviceId, selectedDeviceId,
selectedOutputDeviceId,
setSelectedDeviceId, setSelectedDeviceId,
selectDevice, selectDevice,
selectOutputDevice,
supportsOutputSelection,
sendText, sendText,
connect, connect,
replaceVideoStream, replaceVideoStream,