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

@@ -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,