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