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";
|
||||
|
||||
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}
|
||||
|
||||
@@ -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