feat: audio and video device list
This commit is contained in:
+24
-7
@@ -36,6 +36,7 @@ export type VoiceInfo = {
|
||||
memberCount: number
|
||||
} & VoiceBasicInfo
|
||||
export type DeviceInfo = Pick<MediaDeviceInfo, "deviceId" | "groupId" | "kind" | "label">;
|
||||
export type MediaDeviceKind = "audioinput" | "audiooutput" | "videoinput"
|
||||
interface State {
|
||||
callingFrom: number,
|
||||
callingTo: number,
|
||||
@@ -45,6 +46,7 @@ interface State {
|
||||
list: VoiceInfo[],
|
||||
devices: DeviceInfo[],
|
||||
audioInputDeviceId: string,
|
||||
audioOutputDeviceId: string,
|
||||
videoInputDeviceId: string,
|
||||
}
|
||||
const initialState: State = {
|
||||
@@ -59,6 +61,7 @@ const initialState: State = {
|
||||
list: [],
|
||||
devices: [],
|
||||
audioInputDeviceId: "",
|
||||
audioOutputDeviceId: "",
|
||||
videoInputDeviceId: ""
|
||||
};
|
||||
const voiceSlice = createSlice({
|
||||
@@ -76,11 +79,16 @@ const voiceSlice = createSlice({
|
||||
updateDevices(state, { payload }: PayloadAction<DeviceInfo[]>) {
|
||||
state.devices = payload;
|
||||
if (payload.length > 0) {
|
||||
const audioInputDevice = payload.find(v => v.kind == "audioinput" && v.deviceId == "default");
|
||||
// 默认选择第一个
|
||||
const audioInputDevice = payload.find(v => v.kind == "audioinput");
|
||||
const audioOutputDevice = payload.find(v => v.kind == "audiooutput");
|
||||
const videoInputDevice = payload.find(v => v.kind == "videoinput");
|
||||
if (audioInputDevice) {
|
||||
state.audioInputDeviceId = audioInputDevice.deviceId;
|
||||
}
|
||||
if (audioOutputDevice) {
|
||||
state.audioOutputDeviceId = audioOutputDevice.deviceId;
|
||||
}
|
||||
if (videoInputDevice) {
|
||||
state.videoInputDeviceId = videoInputDevice.deviceId;
|
||||
}
|
||||
@@ -89,12 +97,21 @@ const voiceSlice = createSlice({
|
||||
updateCalling(state, { payload }: PayloadAction<boolean>) {
|
||||
state.calling = payload;
|
||||
},
|
||||
updateSelectDeviceId(state, { payload }: PayloadAction<{ type: "video" | "audio", value: string }>) {
|
||||
const { type, value } = payload;
|
||||
if (type == "video") {
|
||||
state.videoInputDeviceId = value;
|
||||
} else {
|
||||
state.audioInputDeviceId = value;
|
||||
updateSelectDeviceId(state, { payload }: PayloadAction<{ kind: MediaDeviceKind, value: string }>) {
|
||||
const { kind, value } = payload;
|
||||
switch (kind) {
|
||||
case "audioinput":
|
||||
state.audioInputDeviceId = value;
|
||||
break;
|
||||
case "audiooutput":
|
||||
state.audioOutputDeviceId = value;
|
||||
break;
|
||||
case "videoinput":
|
||||
state.videoInputDeviceId = value;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
updateVoicingInfo(state, { payload }: PayloadAction<VoicingInfo | null>) {
|
||||
|
||||
@@ -18,17 +18,23 @@ import IconCallOff from '@/assets/icons/call.off.svg';
|
||||
import { ChatContext } from '@/types/common';
|
||||
import useVoice from './useVoice';
|
||||
// import { updateChannelVisibleAside, updateDMVisibleAside } from '@/app/slices/footprint';
|
||||
import { DeviceInfo, updateSelectDeviceId } from '@/app/slices/voice';
|
||||
import { DeviceInfo, MediaDeviceKind, updateSelectDeviceId } from '@/app/slices/voice';
|
||||
import { useAppSelector } from '@/app/store';
|
||||
import { ICameraVideoTrack, IMicrophoneAudioTrack } from 'agora-rtc-sdk-ng';
|
||||
|
||||
type DeviceType = "audio" | "video";
|
||||
// https://docportal.shengwang.cn/cn/video-call-4.x/test_switch_device_web_ng?platform=Web
|
||||
const DeviceList = ({ type, visible, setVisible, devices, selected }: {
|
||||
const DeviceList = ({ type, visible, setVisible, devices }: {
|
||||
type: DeviceType,
|
||||
visible: VisibleType,
|
||||
setVisible: Dispatch<SetStateAction<VisibleType>>,
|
||||
devices: DeviceInfo[],
|
||||
selected: string
|
||||
devices: {
|
||||
title: string,
|
||||
list: DeviceInfo[],
|
||||
selected: string
|
||||
}[],
|
||||
}) => {
|
||||
const loginUid = useAppSelector(store => store.authData.user?.uid ?? 0);
|
||||
const dispatch = useDispatch();
|
||||
// const { t } = useTranslation("chat");
|
||||
const toggleVisible = (evt: MouseEvent<HTMLDivElement>) => {
|
||||
@@ -36,10 +42,47 @@ const DeviceList = ({ type, visible, setVisible, devices, selected }: {
|
||||
setVisible((prev: VisibleType) => prev == type ? "" : type);
|
||||
};
|
||||
const handleSelect = (evt: MouseEvent<HTMLLIElement>) => {
|
||||
// evt.stopPropagation();
|
||||
const { deviceId = "", kind, selected = "" } = evt.currentTarget.dataset;
|
||||
if (selected == deviceId || !kind) return;
|
||||
switch (kind as MediaDeviceKind) {
|
||||
case "audiooutput":
|
||||
case "audioinput": {
|
||||
const localAudioTrack = window.VOICE_TRACK_MAP[loginUid] as IMicrophoneAudioTrack;
|
||||
dispatch(updateSelectDeviceId({ kind: kind as MediaDeviceKind, value: deviceId }));
|
||||
if (localAudioTrack) {
|
||||
localAudioTrack.setDevice(deviceId).then(() => {
|
||||
console.log("audioinput setDevice", deviceId);
|
||||
}
|
||||
).catch((err) => {
|
||||
console.log("audioinput setDevice error", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case "videoinput": {
|
||||
const localCameraTrack = window.VIDEO_TRACK_MAP[loginUid] as ICameraVideoTrack;
|
||||
if (localCameraTrack) {
|
||||
localCameraTrack.setDevice(deviceId).then(() => {
|
||||
console.log("videoinput setDevice", deviceId);
|
||||
dispatch(updateSelectDeviceId({ kind: kind as MediaDeviceKind, value: deviceId }));
|
||||
}
|
||||
).catch((err) => {
|
||||
console.log("videoinput setDevice error", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
};
|
||||
const handleBlockClick = (evt: MouseEvent<HTMLDivElement>) => {
|
||||
evt.stopPropagation();
|
||||
const { deviceId = "" } = evt.currentTarget.dataset;
|
||||
if (selected == deviceId) return;
|
||||
dispatch(updateSelectDeviceId({ type, value: deviceId }));
|
||||
};
|
||||
return <Tippy
|
||||
onClickOutside={() => setVisible("")}
|
||||
@@ -47,22 +90,34 @@ const DeviceList = ({ type, visible, setVisible, devices, selected }: {
|
||||
popperOptions={{ strategy: "fixed" }}
|
||||
visible={visible == type}
|
||||
placement="top-start"
|
||||
content={<div className="p-3 bg-white dark:bg-gray-800 overflow-auto rounded-lg flex flex-col items-start relative drop-shadow">
|
||||
<ul className="w-full flex flex-col gap-4">
|
||||
{devices.map(({ deviceId, kind, groupId, label }) => {
|
||||
return (
|
||||
<li data-device-id={deviceId} key={label} className="relative rounded-sm cursor-pointer flex items-center justify-between gap-4 text-gray-500 hover:text-gray-900 dark:text-gray-300 hover:dark:text-gray-100 font-semibold text-sm whitespace-nowrap"
|
||||
onClick={handleSelect}>
|
||||
{label}
|
||||
{selected == deviceId && <IconCheck className="shrink-0" />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
content={<div onClick={handleBlockClick} className="px-3 pb-3 bg-white dark:bg-gray-800 overflow-auto rounded-lg flex flex-col gap-3 divide-gray-500/50 divide-y-[1px] items-start relative drop-shadow">
|
||||
{devices.map(({ title, list, selected }) => {
|
||||
console.log("device selected", title, selected);
|
||||
if (list.length == 0) return null;
|
||||
return <div key={title} className="w-full flex flex-col items-start gap-2 pt-3">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-xs">{title}</p>
|
||||
<ul className="w-full flex flex-col gap-4">
|
||||
{list.map(({ deviceId, kind, label }) => {
|
||||
return (
|
||||
<li data-selected={selected} data-kind={kind} data-device-id={deviceId} key={label} className="relative rounded-sm cursor-pointer flex items-center justify-between gap-4 text-gray-500 hover:text-gray-900 dark:text-gray-300 hover:dark:text-gray-100 font-semibold text-sm whitespace-nowrap"
|
||||
onClick={handleSelect}>
|
||||
{label}
|
||||
<i className='w-4 h-3'>
|
||||
{selected == deviceId &&
|
||||
<IconCheck className={clsx("shrink-0")} />
|
||||
}
|
||||
</i>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>;
|
||||
})}
|
||||
|
||||
</div>}
|
||||
>
|
||||
<div onClick={toggleVisible} className="p-1 absolute rounded-sm top-0.5 right-0.5 hover:dark:bg-gray-500/50" role='button' >
|
||||
<IconArrow className={clsx("w-2 fill-gray-600 dark:fill-white transition-all", visible == type && "rotate-180")} />
|
||||
<div onClick={toggleVisible} className="group p-1 absolute rounded-sm top-0.5 right-0.5 hover:bg-gray-300/50" role='button' >
|
||||
<IconArrow className={clsx("w-2 fill-gray-600 group-hover:fill-gray-900 dark:fill-white transition-transform", visible == type && "rotate-180")} />
|
||||
</div>
|
||||
</Tippy>;
|
||||
};
|
||||
@@ -75,34 +130,52 @@ type Props = {
|
||||
|
||||
const Operations = ({ id, context, mode = "channel" }: Props) => {
|
||||
const [panelVisible, setPanelVisible] = useState<VisibleType>("");
|
||||
const { enterFullscreen, voicingInfo, leave, setMute, closeCamera, openCamera, startShareScreen, stopShareScreen, audioInputDevices, audioOutputDevices, videoInputDevices, videoInputDeviceId, audioInputDeviceId } = useVoice({ id, context });
|
||||
const { enterFullscreen, voicingInfo,
|
||||
leave, setMute, closeCamera, openCamera, startShareScreen, stopShareScreen,
|
||||
audioInputDevices, audioOutputDevices, videoInputDevices, videoInputDeviceId, audioInputDeviceId, audioOutputDeviceId
|
||||
} = useVoice({ id, context });
|
||||
const { t } = useTranslation("chat");
|
||||
if (!voicingInfo) return null;
|
||||
const { muted, video, shareScreen } = voicingInfo;
|
||||
const baseButtonClass = clsx("flex-center py-2 px-3 rounded bg-gray-100 dark:bg-gray-900 relative");
|
||||
const baseButtonClass = clsx("flex-center py-2 px-3 rounded bg-gray-100 dark:bg-gray-900 relative disabled:pointer-events-none disabled:opacity-50");
|
||||
const baseIconClass = clsx("w-[25px] h-6 m-auto fill-gray-700 dark:fill-gray-300");
|
||||
return <>
|
||||
<Tooltip disabled={panelVisible == "audio"} tip={muted ? t("unmute") : t("mute")} placement="top">
|
||||
<button onClick={setMute.bind(null, !muted)} className={baseButtonClass}>
|
||||
<button disabled={audioInputDevices.length == 0 && audioOutputDevices.length == 0} onClick={setMute.bind(null, !muted)} className={baseButtonClass}>
|
||||
{muted ? <IconMicOff className={baseIconClass} /> : <IconMic className={baseIconClass} />}
|
||||
<DeviceList
|
||||
visible={panelVisible}
|
||||
setVisible={setPanelVisible}
|
||||
type='audio'
|
||||
devices={audioInputDevices}
|
||||
selected={audioInputDeviceId}
|
||||
devices={[
|
||||
{
|
||||
title: "Input Device",
|
||||
list: audioInputDevices,
|
||||
selected: audioInputDeviceId
|
||||
},
|
||||
{
|
||||
title: "Output Device",
|
||||
list: audioOutputDevices,
|
||||
selected: audioOutputDeviceId
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip disabled={panelVisible == "video"} tip={video ? t("camera_off") : t("camera_on")} placement="top">
|
||||
<button onClick={video ? closeCamera : openCamera} className={baseButtonClass}>
|
||||
<button disabled={videoInputDevices.length == 0} onClick={video ? closeCamera : openCamera} className={baseButtonClass}>
|
||||
{video ? <IconCamera className={baseIconClass} /> : <IconCameraOff className={baseIconClass} />}
|
||||
<DeviceList
|
||||
visible={panelVisible}
|
||||
setVisible={setPanelVisible}
|
||||
type='video'
|
||||
devices={videoInputDevices}
|
||||
selected={videoInputDeviceId}
|
||||
devices={[
|
||||
{
|
||||
title: "Camera Device",
|
||||
list: videoInputDevices,
|
||||
selected: videoInputDeviceId
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -20,7 +20,7 @@ type VoiceProps = {
|
||||
const audioJoin = new Audio(AudioJoin);
|
||||
const useVoice = ({ id, context = "channel" }: VoiceProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const { voicingInfo, loginUid, audioInputDevices, audioOutputDevices, videoInputDevices, audioInputDeviceId, videoInputDeviceId } = useAppSelector(store => {
|
||||
const { voicingInfo, loginUid, audioInputDevices, audioOutputDevices, videoInputDevices, audioInputDeviceId, audioOutputDeviceId, videoInputDeviceId } = useAppSelector(store => {
|
||||
return {
|
||||
loginUid: store.authData.user?.uid ?? 0,
|
||||
voicingInfo: store.voice.voicing,
|
||||
@@ -28,6 +28,7 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => {
|
||||
audioOutputDevices: store.voice.devices.filter(d => d.kind == "audiooutput") ?? [],
|
||||
videoInputDevices: store.voice.devices.filter(d => d.kind == "videoinput") ?? [],
|
||||
audioInputDeviceId: store.voice.audioInputDeviceId,
|
||||
audioOutputDeviceId: store.voice.audioOutputDeviceId,
|
||||
videoInputDeviceId: store.voice.videoInputDeviceId,
|
||||
};
|
||||
});
|
||||
@@ -54,7 +55,7 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => {
|
||||
if (window.VOICE_CLIENT) {
|
||||
await window.VOICE_CLIENT.join(app_id, channel_name, agora_token, uid);
|
||||
// Create a local audio track from the microphone audio.
|
||||
const localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack();
|
||||
const localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack({ microphoneId: audioInputDeviceId });
|
||||
// Publish the local audio track in the channel.
|
||||
await window.VOICE_CLIENT.publish(localAudioTrack);
|
||||
// play the join audio
|
||||
@@ -81,7 +82,7 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => {
|
||||
// setJoining(false);
|
||||
};
|
||||
const openCamera = async () => {
|
||||
const localVideoTrack = await AgoraRTC.createCameraVideoTrack();
|
||||
const localVideoTrack = await AgoraRTC.createCameraVideoTrack({ cameraId: videoInputDeviceId });
|
||||
// 取消正在进行的桌面共享
|
||||
await stopShareScreen();
|
||||
await window.VOICE_CLIENT?.publish(localVideoTrack);
|
||||
@@ -278,6 +279,7 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => {
|
||||
audioOutputDevices,
|
||||
videoInputDevices,
|
||||
audioInputDeviceId,
|
||||
audioOutputDeviceId,
|
||||
videoInputDeviceId
|
||||
};
|
||||
};
|
||||
|
||||
@@ -79,9 +79,9 @@ const RTCWidget = ({ id, context = "channel" }: Props) => {
|
||||
</Tooltip>
|
||||
<Tooltip tip={voicingInfo.muted ? t("unmute") : t("mute")} placement="top">
|
||||
{voicingInfo.muted ?
|
||||
<IconMicOff onClick={setMute.bind(null, false)} role="button" />
|
||||
<IconMicOff className="w-6 h-6" onClick={setMute.bind(null, false)} role="button" />
|
||||
:
|
||||
<IconMic onClick={setMute.bind(null, true)} role="button" />}
|
||||
<IconMic className="w-6 h-6" onClick={setMute.bind(null, true)} role="button" />}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user