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}

View File

@@ -99,10 +99,14 @@ export function useVoicePreview(
// 远端(助手 TTS)媒体流:除挂到 <audio> 播放外,也暴露给波形可视化
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
// 可选麦克风列表与当前选择(空串表示交给浏览器选默认设备)
// 可选麦克风/扬声器列表与当前选择(空串表示交给浏览器选默认设备)
const [audioInputs, setAudioInputs] = useState<MediaDeviceInfo[]>([]);
const [audioOutputs, setAudioOutputs] = useState<MediaDeviceInfo[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("");
const [selectedOutputDeviceId, setSelectedOutputDeviceId] =
useState<string>("");
const selectedDeviceIdRef = useRef("");
const selectedOutputDeviceIdRef = useRef("");
const audioRef = useRef<HTMLAudioElement | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const wsRef = useRef<WebSocket | null>(null);
@@ -118,14 +122,32 @@ export function useVoicePreview(
onNodeActiveRef.current = 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 () => {
if (!navigator.mediaDevices?.enumerateDevices) return;
try {
const devices = await navigator.mediaDevices.enumerateDevices();
// 未授权时设备可能没有 deviceId(空串),无法选择,直接过滤掉
const inputs = devices.filter((d) => d.kind === "audioinput" && d.deviceId);
const outputs = devices.filter(
(d) => d.kind === "audiooutput" && d.deviceId,
);
setAudioInputs(inputs);
setAudioOutputs(outputs);
// 选中的设备已被拔出时,回退到浏览器默认设备
if (
selectedDeviceIdRef.current &&
@@ -133,16 +155,31 @@ export function useVoicePreview(
) {
setSelectedDeviceId("");
}
if (
selectedOutputDeviceIdRef.current &&
!outputs.some((d) => d.deviceId === selectedOutputDeviceIdRef.current)
) {
setSelectedOutputDeviceId("");
void applyOutputDevice("");
}
} catch {
/* 忽略枚举失败 */
}
}, []);
}, [applyOutputDevice]);
// connect/refreshDevices 在回调里读最新选择,避免把它们挂进依赖反复重建
useEffect(() => {
selectedDeviceIdRef.current = selectedDeviceId;
}, [selectedDeviceId]);
useEffect(() => {
selectedOutputDeviceIdRef.current = selectedOutputDeviceId;
}, [selectedOutputDeviceId]);
useEffect(() => {
void applyOutputDevice(selectedOutputDeviceId);
}, [applyOutputDevice, selectedOutputDeviceId, remoteStream]);
useEffect(() => {
if (!navigator.mediaDevices?.enumerateDevices) return;
// 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");
dataChannelRef.current = channel;
channel.onopen = () => {
@@ -433,7 +469,9 @@ export function useVoicePreview(
setRemoteStream(remote);
if (audioRef.current) {
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 {
startingRef.current = false;
}
}, [assistantId, fail, closeOnRemoteEnd, refreshDevices]);
}, [assistantId, applyOutputDevice, fail, closeOnRemoteEnd, refreshDevices]);
const replaceVideoStream = useCallback(
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。
const sendText = useCallback((text: string): boolean => {
@@ -575,9 +622,13 @@ export function useVoicePreview(
remoteStream,
messages,
audioInputs,
audioOutputs,
selectedDeviceId,
selectedOutputDeviceId,
setSelectedDeviceId,
selectDevice,
selectOutputDevice,
supportsOutputSelection,
sendText,
connect,
replaceVideoStream,