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