feat: audio and video device list

This commit is contained in:
Tristan Yang
2023-05-18 06:35:28 +08:00
parent c0c1dc8bf5
commit 664e3e57a3
4 changed files with 133 additions and 41 deletions
+24 -7
View File
@@ -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>) {
+102 -29
View File
@@ -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>
+5 -3
View File
@@ -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
};
};
+2 -2
View File
@@ -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>