refactor: video call

This commit is contained in:
Tristan Yang
2023-04-23 17:46:31 +08:00
parent 9dc6bcd483
commit adbd244292
9 changed files with 93 additions and 62 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vocechat-web", "name": "vocechat-web",
"version": "0.3.57", "version": "0.3.58",
"private": true, "private": true,
"homepage": "https://voce.chat", "homepage": "https://voce.chat",
"dependencies": { "dependencies": {
+2
View File
@@ -52,5 +52,7 @@
"undeafen": "Undeafen", "undeafen": "Undeafen",
"mute": "Mute", "mute": "Mute",
"unmute": "Unmute", "unmute": "Unmute",
"camera_on": "Turn on camera",
"camera_off": "Turn off camera",
"leave_voice": "Leave Voice Chat" "leave_voice": "Leave Voice Chat"
} }
+2
View File
@@ -53,5 +53,7 @@
"undeafen": "取消静默", "undeafen": "取消静默",
"mute": "静音", "mute": "静音",
"unmute": "取消静音", "unmute": "取消静音",
"camera_on": "开启摄像头",
"camera_off": "关闭摄像头",
"leave_voice": "离开" "leave_voice": "离开"
} }
+3 -1
View File
@@ -12,6 +12,7 @@ export type VoicingInfo = {
downlinkNetworkQuality?: number, downlinkNetworkQuality?: number,
muted?: boolean, muted?: boolean,
deafen?: boolean, deafen?: boolean,
video?: boolean,
joining?: boolean, joining?: boolean,
connectionState?: ConnectionState connectionState?: ConnectionState
} & VoiceBasicInfo } & VoiceBasicInfo
@@ -19,7 +20,8 @@ export type VoicingInfo = {
export type VoicingMemberInfo = { export type VoicingMemberInfo = {
speakingVolume?: number, speakingVolume?: number,
muted?: boolean, muted?: boolean,
deafen?: boolean deafen?: boolean,
video?: boolean
} }
export type VoicingMembers = { export type VoicingMembers = {
+63 -40
View File
@@ -1,4 +1,4 @@
import AgoraRTC, { IMicrophoneAudioTrack } from 'agora-rtc-sdk-ng'; import AgoraRTC, { ICameraVideoTrack, IMicrophoneAudioTrack } from 'agora-rtc-sdk-ng';
import { memo, useEffect } from 'react'; import { memo, useEffect } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { useGetAgoraChannelsQuery, useGetAgoraStatusQuery, useLazyGetAgoraTokenQuery } from '../../app/services/server'; import { useGetAgoraChannelsQuery, useGetAgoraStatusQuery, useLazyGetAgoraTokenQuery } from '../../app/services/server';
@@ -10,16 +10,14 @@ import AudioJoin from '../../assets/join.wav';
// type Props = {} // type Props = {}
AgoraRTC.setLogLevel(process.env.NODE_ENV === 'development' ? 0 : 4); AgoraRTC.setLogLevel(process.env.NODE_ENV === 'development' ? 0 : 4);
window.VOICE_TRACK_MAP = window.VOICE_TRACK_MAP ?? {}; window.VOICE_TRACK_MAP = window.VOICE_TRACK_MAP ?? {};
window.VIDEO_TRACK_MAP = window.VIDEO_TRACK_MAP ?? {};
// let tmpUids: number[] = []; // let tmpUids: number[] = [];
const Voice = () => { const Voice = () => {
const { serverVersion } = useAppSelector(store => { // const { serverVersion } = useAppSelector(store => {
return { // return {
serverVersion: store.server.version ?? 0, // serverVersion: store.server.version ?? 0,
// joined: !!store.voice.voicing // };
}; // });
});
console.log("serverVersion", serverVersion);
// const skipAgoraStatusCheck = compareVersion(serverVersion, '0.3.5') >= 0; // const skipAgoraStatusCheck = compareVersion(serverVersion, '0.3.5') >= 0;
const { data: enabled } = useGetAgoraStatusQuery(); const { data: enabled } = useGetAgoraStatusQuery();
useGetAgoraChannelsQuery({ page_no: 0, page_size: 100 }, { useGetAgoraChannelsQuery({ page_no: 0, page_size: 100 }, {
@@ -44,16 +42,25 @@ const Voice = () => {
window.VOICE_TRACK_MAP[+user.uid] = user.audioTrack; window.VOICE_TRACK_MAP[+user.uid] = user.audioTrack;
} }
if (mediaType == "video") { if (mediaType == "video") {
const id = `CAMERA_${user.uid}`; const playerEle = document.querySelector(`#CAMERA_${user.uid}`) as HTMLElement;
console.log("video container id", id); if (playerEle) {
playerEle.classList.add("h-[120px]");
// 播放远端视频 user.videoTrack?.play(playerEle);
user.videoTrack?.play(id); }
window.VOICE_TRACK_MAP[+user.uid] = user.videoTrack; window.VIDEO_TRACK_MAP[+user.uid] = user.videoTrack;
} }
agoraEngine.on("user-unpublished", (user) => { agoraEngine.on("user-unpublished", (user) => {
if (!user.hasAudio) {
// 远端用户取消了音频(muted) // 远端用户取消了音频(muted)
dispatch(updateVoicingMember({ uid: +user.uid, info: { muted: true } })); dispatch(updateVoicingMember({ uid: +user.uid, info: { muted: true } }));
}
if (!user.hasVideo) {
// 远端用户取消了视频
dispatch(updateVoicingMember({ uid: +user.uid, info: { video: false } }));
// 关闭视频后,需要把视频的高度设置回去
const playerEle = document.querySelector(`#CAMERA_${user.uid}`) as HTMLElement;
playerEle.classList.remove("h-[120px]");
}
}); });
//remote user leave //remote user leave
agoraEngine.on("user-left", (user, reason) => { agoraEngine.on("user-left", (user, reason) => {
@@ -105,14 +112,14 @@ const Voice = () => {
}); });
// 有新用户加入 // 有新用户加入
agoraEngine.on("user-joined", async (user) => { agoraEngine.on("user-joined", async (user) => {
console.log(user.uid, !!localTrack, agoraEngine.channelName, " has joined the channel"); console.log(user.uid, !!localAudioTrack, agoraEngine.channelName, " has joined the channel");
dispatch(addVoiceMember(+user.uid)); dispatch(addVoiceMember(+user.uid));
}); });
window.VOICE_CLIENT = agoraEngine; window.VOICE_CLIENT = agoraEngine;
}; };
const handlePageUnload = (evt: BeforeUnloadEvent) => { const handlePageUnload = (evt: BeforeUnloadEvent) => {
console.log("unload"); console.log("unload");
if (localTrack) { if (localAudioTrack) {
evt.preventDefault(); evt.preventDefault();
return (evt.returnValue = ""); return (evt.returnValue = "");
} }
@@ -130,7 +137,8 @@ const Voice = () => {
return null; return null;
}; };
let localTrack: IMicrophoneAudioTrack | null = null; let localAudioTrack: IMicrophoneAudioTrack | null = null;
let localVideoTrack: ICameraVideoTrack | null = null;
type VoiceProps = { type VoiceProps = {
id: number, id: number,
context?: "channel" | "dm" context?: "channel" | "dm"
@@ -160,9 +168,9 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => {
await window.VOICE_CLIENT.join(app_id, channel_name, agora_token, uid); await window.VOICE_CLIENT.join(app_id, channel_name, agora_token, uid);
console.table(data); console.table(data);
// Create a local audio track from the microphone audio. // Create a local audio track from the microphone audio.
localTrack = await AgoraRTC.createMicrophoneAudioTrack(); localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack();
// Publish the local audio track in the channel. // Publish the local audio track in the channel.
await window.VOICE_CLIENT.publish(localTrack); await window.VOICE_CLIENT.publish(localAudioTrack);
// play the join audio // play the join audio
audioJoin.play(); audioJoin.play();
console.log("Publish success!,joined the channel"); console.log("Publish success!,joined the channel");
@@ -188,28 +196,43 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => {
// setJoining(false); // setJoining(false);
}; };
const openCamera = async () => { const openCamera = async () => {
if (localTrack) { localVideoTrack = await AgoraRTC.createCameraVideoTrack();
localTrack.close(); await window.VOICE_CLIENT?.publish(localVideoTrack);
localTrack = null; const playerEle = document.querySelector(`#CAMERA_${loginUid}`) as HTMLElement;
if (playerEle) {
playerEle.classList.add("h-[120px]");
localVideoTrack?.play(playerEle);
} }
localTrack = await AgoraRTC.createCameraVideoTrack(); dispatch(updateVoicingInfo({
await window.VOICE_CLIENT?.publish(localTrack); video: true,
const id = `CAMERA_${loginUid}`; id,
localTrack?.play(id); context,
}));
}; };
const closeCamera = async () => { const closeCamera = async () => {
if (localTrack) { if (localVideoTrack) {
localTrack.close(); await window.VOICE_CLIENT?.unpublish(localVideoTrack);
localTrack = null; localVideoTrack.close();
localVideoTrack = null;
// 关闭视频后,需要把视频的高度设置回去
const playerEle = document.querySelector(`#CAMERA_${loginUid}`) as HTMLElement;
playerEle.classList.remove("h-[120px]");
dispatch(updateVoicingInfo({
video: false,
id,
context,
}));
} }
localTrack = await AgoraRTC.createMicrophoneAudioTrack();
await window.VOICE_CLIENT?.publish(localTrack);
}; };
const leave = async () => { const leave = async () => {
if (window.VOICE_CLIENT && localTrack) { if (window.VOICE_CLIENT && localAudioTrack) {
localTrack.close(); localAudioTrack.close();
localTrack = null; localAudioTrack = null;
if (localVideoTrack) {
localVideoTrack.close();
localVideoTrack = null;
}
await window.VOICE_CLIENT.leave(); await window.VOICE_CLIENT.leave();
dispatch(updateVoicingInfo(null)); dispatch(updateVoicingInfo(null));
if (context == "channel") { if (context == "channel") {
@@ -226,21 +249,21 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => {
} }
}; };
const setMute = (mute: boolean) => { const setMute = (mute: boolean) => {
if (localTrack) { if (localAudioTrack) {
localTrack.setMuted(mute); localAudioTrack.setMuted(mute);
dispatch(updateMuteStatus(mute)); dispatch(updateMuteStatus(mute));
} }
}; };
const setDeafen = (deafen: boolean) => { const setDeafen = (deafen: boolean) => {
if (localTrack) { if (localAudioTrack) {
if (deafen) { if (deafen) {
localTrack.setMuted(true); localAudioTrack.setMuted(true);
// 远端音频,全部静音 // 远端音频,全部静音
Object.entries(window.VOICE_TRACK_MAP).forEach(([, audioTrack]) => { Object.entries(window.VOICE_TRACK_MAP).forEach(([, audioTrack]) => {
audioTrack?.setVolume(0); audioTrack?.setVolume(0);
}); });
} else { } else {
localTrack.setMuted(false); localAudioTrack.setMuted(false);
// 远端音频,恢复原音 // 远端音频,恢复原音
Object.entries(window.VOICE_TRACK_MAP).forEach(([, audioTrack]) => { Object.entries(window.VOICE_TRACK_MAP).forEach(([, audioTrack]) => {
audioTrack?.setVolume(100); audioTrack?.setVolume(100);
+4 -5
View File
@@ -8,7 +8,7 @@ import IconMicOff from '../../assets/icons/mic.off.svg';
import IconCallOff from '../../assets/icons/headphone.svg'; import IconCallOff from '../../assets/icons/headphone.svg';
import IconSoundOn from '../../assets/icons/sound.on.svg'; import IconSoundOn from '../../assets/icons/sound.on.svg';
import IconSoundOff from '../../assets/icons/sound.off.svg'; import IconSoundOff from '../../assets/icons/sound.off.svg';
import IconCameraOff from '../../assets/icons/camera.off.svg'; // import IconCameraOff from '../../assets/icons/camera.off.svg';
import IconCamera from '../../assets/icons/camera.svg'; import IconCamera from '../../assets/icons/camera.svg';
import IconScreen from '../../assets/icons/share.screen.svg'; import IconScreen from '../../assets/icons/share.screen.svg';
import { useVoice } from '../../common/component/Voice'; import { useVoice } from '../../common/component/Voice';
@@ -22,7 +22,7 @@ type Props = {
const RTCWidget = ({ id, context = "channel" }: Props) => { const RTCWidget = ({ id, context = "channel" }: Props) => {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const { leave, voicingInfo, setMute, setDeafen, joining = true, openCamera } = useVoice({ context, id }); const { leave, voicingInfo, setMute, setDeafen, joining = true, openCamera, closeCamera } = useVoice({ context, id });
const { loginUser, channelData, userData } = useAppSelector(store => { const { loginUser, channelData, userData } = useAppSelector(store => {
return { return {
userData: store.users.byId, userData: store.users.byId,
@@ -38,7 +38,6 @@ const RTCWidget = ({ id, context = "channel" }: Props) => {
<div className='bg-gray-100 dark:bg-gray-900 flex flex-col p-2 rounded-3xl m-3 mb-4 text-sm'> <div className='bg-gray-100 dark:bg-gray-900 flex flex-col p-2 rounded-3xl m-3 mb-4 text-sm'>
<div className="border-b border-b-gray-200 dark:border-b-gray-800 pb-2"> <div className="border-b border-b-gray-200 dark:border-b-gray-800 pb-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex flex-1 items-center gap-1"> <div className="flex flex-1 items-center gap-1">
<Signal strength={voicingInfo.downlinkNetworkQuality} /> <Signal strength={voicingInfo.downlinkNetworkQuality} />
<div className="flex flex-col"> <div className="flex flex-col">
@@ -51,8 +50,8 @@ const RTCWidget = ({ id, context = "channel" }: Props) => {
</Tooltip> </Tooltip>
</div> </div>
<div className="flex items-center gap-2 mt-3"> <div className="flex items-center gap-2 mt-3">
<button onClick={openCamera} className='rounded-lg py-2 px-5 bg-white/50 dark:bg-black/50 flex items-center gap-1'> <button onClick={voicingInfo.video ? closeCamera : openCamera} className={clsx('rounded-lg py-2 px-5 flex items-center gap-1', voicingInfo.video ? "bg-green-700" : "bg-white/50 dark:bg-black/50")}>
<IconCameraOff className="fill-gray-800 dark:fill-gray-200 w-6 h-6" /> <IconCamera className="fill-gray-800 dark:fill-gray-200 w-6 h-6" />
<span className='text-sm text-gray-800 dark:text-gray-200 font-semibold'>Video</span> <span className='text-sm text-gray-800 dark:text-gray-200 font-semibold'>Video</span>
</button> </button>
<button className='rounded-lg py-2 px-5 bg-white/50 dark:bg-black/50 flex items-center gap-1'> <button className='rounded-lg py-2 px-5 bg-white/50 dark:bg-black/50 flex items-center gap-1'>
+2 -2
View File
@@ -10,12 +10,12 @@ type Props = {
} }
const Dashboard = ({ context = "channel", id, visible }: Props) => { const Dashboard = ({ context = "channel", id, visible }: Props) => {
const { voicingInfo, setMute, setDeafen, leave } = useVoice({ id, context }); const { voicingInfo, setMute, setDeafen, leave, closeCamera, openCamera } = useVoice({ id, context });
// const dispatch = useDispatch(); // const dispatch = useDispatch();
return <div className={`h-full flex-col gap-1 w-[226px] overflow-y-scroll overflow-x-hidden p-2 shadow-[inset_1px_0px_0px_rgba(0,_0,_0,_0.1)] ${visible ? "flex" : "hidden"}`}> return <div className={`h-full flex-col gap-1 w-[226px] overflow-y-scroll overflow-x-hidden p-2 shadow-[inset_1px_0px_0px_rgba(0,_0,_0,_0.1)] ${visible ? "flex" : "hidden"}`}>
<VoiceManagement info={voicingInfo} setMute={setMute} setDeafen={setDeafen} leave={leave} /> <VoiceManagement info={voicingInfo} setMute={setMute} setDeafen={setDeafen} leave={leave} closeCamera={closeCamera} openCamera={openCamera} />
</div>; </div>;
}; };
@@ -21,10 +21,12 @@ type Props = {
info: VoicingInfo | null, info: VoicingInfo | null,
setMute: (param: boolean) => void, setMute: (param: boolean) => void,
setDeafen: (param: boolean) => void, setDeafen: (param: boolean) => void,
leave: () => void leave: () => void,
closeCamera: () => void,
openCamera: () => void
} }
const VoiceManagement = ({ info, setMute, setDeafen, leave }: Props) => { const VoiceManagement = ({ info, setMute, setDeafen, leave, closeCamera, openCamera }: Props) => {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const { userData, voicingMembers } = useAppSelector(store => { const { userData, voicingMembers } = useAppSelector(store => {
return { return {
@@ -33,7 +35,7 @@ const VoiceManagement = ({ info, setMute, setDeafen, leave }: Props) => {
}; };
}); });
if (!info) return null; if (!info) return null;
const { deafen, muted } = info; const { deafen, muted, video } = info;
const nameClass = clsx(`text-sm text-gray-500 max-w-[120px] truncate font-semibold dark:text-white`); const nameClass = clsx(`text-sm text-gray-500 max-w-[120px] truncate font-semibold dark:text-white`);
const members = voicingMembers.ids; const members = voicingMembers.ids;
const membersData = voicingMembers.byId; const membersData = voicingMembers.byId;
@@ -81,7 +83,7 @@ const VoiceManagement = ({ info, setMute, setDeafen, leave }: Props) => {
{muted ? <IconMicOff className="w-4 fill-gray-500 dark:fill-gray-400" /> : <IconMic className="w-4 fill-gray-500 dark:fill-gray-400" />} {muted ? <IconMicOff className="w-4 fill-gray-500 dark:fill-gray-400" /> : <IconMic className="w-4 fill-gray-500 dark:fill-gray-400" />}
</div> </div>
</div> </div>
<div id={`CAMERA_${uid}`} className="w-[98%] h-[120px] rounded overflow-hidden m-auto"> <div id={`CAMERA_${uid}`} className="w-[98%] rounded overflow-hidden m-auto">
{/* camera placeholder */} {/* camera placeholder */}
</div> </div>
</li>; </li>;
@@ -98,13 +100,11 @@ const VoiceManagement = ({ info, setMute, setDeafen, leave }: Props) => {
<Tooltip tip={muted ? t("unmute") : t("mute")} placement="top"> <Tooltip tip={muted ? t("unmute") : t("mute")} placement="top">
<li role={"button"} onClick={setMute.bind(null, !muted)} className="py-2 px-3 rounded bg-gray-100 dark:bg-gray-900"> <li role={"button"} onClick={setMute.bind(null, !muted)} className="py-2 px-3 rounded bg-gray-100 dark:bg-gray-900">
{muted ? <IconMicOff className="fill-gray-700 dark:fill-gray-300" /> : <IconMic className="fill-gray-700 dark:fill-gray-300" />} {muted ? <IconMicOff className="fill-gray-700 dark:fill-gray-300" /> : <IconMic className="fill-gray-700 dark:fill-gray-300" />}
</li> </li>
</Tooltip> </Tooltip>
<Tooltip tip={"Camera"} placement="top"> <Tooltip tip={video ? t("camera_off") : t("camera_on")} placement="top">
<li role={"button"} className="py-2 px-3 rounded bg-gray-100 dark:bg-gray-900"> <li role={"button"} onClick={video ? closeCamera : openCamera} className="py-2 px-3 rounded bg-gray-100 dark:bg-gray-900">
{muted ? <IconCameraOff className="fill-gray-700 dark:fill-gray-300" /> : <IconCamera className="fill-gray-700 dark:fill-gray-300" />} {video ? <IconCamera className="fill-gray-700 dark:fill-gray-300" /> : <IconCameraOff className="fill-gray-700 dark:fill-gray-300" />}
</li> </li>
</Tooltip> </Tooltip>
<Tooltip tip={"Share Screen"} placement="top"> <Tooltip tip={"Share Screen"} placement="top">
+5 -2
View File
@@ -1,4 +1,4 @@
import { IAgoraRTCClient, IMicrophoneAudioTrack, IRemoteAudioTrack } from "agora-rtc-sdk-ng"; import { IAgoraRTCClient, ICameraVideoTrack, IMicrophoneAudioTrack, IRemoteAudioTrack, IRemoteVideoTrack } from "agora-rtc-sdk-ng";
interface BeforeInstallPromptEvent extends Event { interface BeforeInstallPromptEvent extends Event {
/** /**
@@ -34,7 +34,10 @@ export declare global {
VOICE_CLIENT?: IAgoraRTCClient; VOICE_CLIENT?: IAgoraRTCClient;
VOICE_TRACK_MAP: { VOICE_TRACK_MAP: {
[key: number]: IRemoteAudioTrack | IMicrophoneAudioTrack | undefined | null [key: number]: IRemoteAudioTrack | IMicrophoneAudioTrack | undefined | null
} },
VIDEO_TRACK_MAP: {
[key: number]: IRemoteVideoTrack | ICameraVideoTrack | undefined | null
},
} }
interface WindowEventMap { interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent; beforeinstallprompt: BeforeInstallPromptEvent;