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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user