refactor: DM voicing
This commit is contained in:
+29
-29
@@ -11,13 +11,9 @@ export type VoiceBasicInfo = {
|
|||||||
|
|
||||||
export type VoicingInfo = {
|
export type VoicingInfo = {
|
||||||
downlinkNetworkQuality?: number,
|
downlinkNetworkQuality?: number,
|
||||||
muted?: boolean,
|
|
||||||
deafen?: boolean,
|
|
||||||
video?: boolean,
|
|
||||||
shareScreen?: boolean,
|
|
||||||
joining?: boolean,
|
joining?: boolean,
|
||||||
connectionState?: ConnectionState
|
connectionState?: ConnectionState
|
||||||
} & VoiceBasicInfo
|
} & VoiceBasicInfo & VoicingMemberInfo
|
||||||
|
|
||||||
export type VoicingMemberInfo = {
|
export type VoicingMemberInfo = {
|
||||||
speakingVolume?: number,
|
speakingVolume?: number,
|
||||||
@@ -75,6 +71,33 @@ const voiceSlice = createSlice({
|
|||||||
updateVoicingInfo(state, { payload }: PayloadAction<VoicingInfo | null>) {
|
updateVoicingInfo(state, { payload }: PayloadAction<VoicingInfo | null>) {
|
||||||
if (payload) {
|
if (payload) {
|
||||||
state.voicing = { ...(state.voicing ?? {}), ...payload };
|
state.voicing = { ...(state.voicing ?? {}), ...payload };
|
||||||
|
// 更新登录用户在member list的状态
|
||||||
|
const loginUid = localStorage.getItem(KEY_UID) ?? 0;
|
||||||
|
const idx = state.voicingMembers.ids.findIndex((uid) => uid == loginUid);
|
||||||
|
if (idx > -1) {
|
||||||
|
state.voicingMembers.byId[+loginUid] = payload;
|
||||||
|
Object.keys(payload).forEach((key) => {
|
||||||
|
switch (key) {
|
||||||
|
case "video":
|
||||||
|
state.voicingMembers.byId[+loginUid].video = payload.video;
|
||||||
|
break;
|
||||||
|
case "muted":
|
||||||
|
state.voicingMembers.byId[+loginUid].muted = payload.muted;
|
||||||
|
break;
|
||||||
|
case "deafen":
|
||||||
|
state.voicingMembers.byId[+loginUid].deafen = payload.deafen;
|
||||||
|
state.voicingMembers.byId[+loginUid].muted = payload.deafen;
|
||||||
|
break;
|
||||||
|
case "shareScreen":
|
||||||
|
state.voicingMembers.byId[+loginUid].shareScreen = payload.shareScreen;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// reset
|
// reset
|
||||||
state.voicing = payload;
|
state.voicing = payload;
|
||||||
@@ -84,29 +107,6 @@ const voiceSlice = createSlice({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateMuteStatus(state, { payload }: PayloadAction<boolean>) {
|
|
||||||
if (state.voicing) {
|
|
||||||
state.voicing.muted = payload;
|
|
||||||
// 更新登录用户在member list的状态
|
|
||||||
const loginUid = localStorage.getItem(KEY_UID) ?? 0;
|
|
||||||
const idx = state.voicingMembers.ids.findIndex((uid) => uid == loginUid);
|
|
||||||
if (idx > -1) {
|
|
||||||
state.voicingMembers.byId[+loginUid].muted = payload;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
updateDeafenStatus(state, { payload }: PayloadAction<boolean>) {
|
|
||||||
if (state.voicing) {
|
|
||||||
state.voicing.deafen = payload;
|
|
||||||
state.voicing.muted = payload;
|
|
||||||
// 更新登录用户在member list的状态
|
|
||||||
const loginUid = localStorage.getItem(KEY_UID) ?? 0;
|
|
||||||
const idx = state.voicingMembers.ids.findIndex((uid) => uid == loginUid);
|
|
||||||
if (idx > -1) {
|
|
||||||
state.voicingMembers.byId[+loginUid].muted = payload;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
updateConnectionState(state, { payload }: PayloadAction<ConnectionState>) {
|
updateConnectionState(state, { payload }: PayloadAction<ConnectionState>) {
|
||||||
if (state.voicing) {
|
if (state.voicing) {
|
||||||
state.voicing.connectionState = payload;
|
state.voicing.connectionState = payload;
|
||||||
@@ -189,5 +189,5 @@ const voiceSlice = createSlice({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { updateCalling, updatePin, updateConnectionState, addVoiceMember, removeVoiceMember, upsertVoiceList, updateVoicingInfo, updateVoicingNetworkQuality, updateMuteStatus, updateVoicingMember, updateDeafenStatus } = voiceSlice.actions;
|
export const { updateCalling, updatePin, updateConnectionState, addVoiceMember, removeVoiceMember, upsertVoiceList, updateVoicingInfo, updateVoicingNetworkQuality, updateVoicingMember } = voiceSlice.actions;
|
||||||
export default voiceSlice.reducer;
|
export default voiceSlice.reducer;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const AudioMessage = ({ url, name, size, download }: Props) => {
|
|||||||
};
|
};
|
||||||
const _size = formatBytes(size);
|
const _size = formatBytes(size);
|
||||||
return (
|
return (
|
||||||
<div className='w-96 flex flex-col gap-2 px-3 py-2 rounded-md border border-solid border-gray-300 dark:border-gray-500 overflow-hidden bg-stone-100 dark:bg-stone-900'>
|
<div className='md:w-96 flex flex-col gap-2 px-3 py-2 rounded-md border border-solid border-gray-300 dark:border-gray-500 overflow-hidden bg-stone-100 dark:bg-stone-900'>
|
||||||
<div className="flex justify-between z-30 overflow-hidden">
|
<div className="flex justify-between z-30 overflow-hidden">
|
||||||
<div className="flex gap-2 ">
|
<div className="flex gap-2 ">
|
||||||
<IconAudio className="w-9 h-auto" />
|
<IconAudio className="w-9 h-auto" />
|
||||||
@@ -27,7 +27,7 @@ const AudioMessage = ({ url, name, size, download }: Props) => {
|
|||||||
<span>{_size}</span>
|
<span>{_size}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href={download} className="mt-2"><IconDownload className="fill-gray-500 dark:fill-gray-400" /></a>
|
<a href={download} className="mt-2 hidden md:block"><IconDownload className="fill-gray-500 dark:fill-gray-400" /></a>
|
||||||
</div>
|
</div>
|
||||||
<audio src={url} onCanPlay={handleCanPlay} controls className={clsx("w-full object-cover z-10", canPlay ? "visible" : "invisible")} />
|
<audio src={url} onCanPlay={handleCanPlay} controls className={clsx("w-full object-cover z-10", canPlay ? "visible" : "invisible")} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ const Reply: FC<ReplyProps> = ({ mid, interactive = true }) => {
|
|||||||
)}
|
)}
|
||||||
onClick={interactive ? handleClick : undefined}
|
onClick={interactive ? handleClick : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex shrink-0 w-6 h-6">
|
<div className="md:flex shrink-0 w-6 h-6 hidden ">
|
||||||
<Avatar
|
<Avatar
|
||||||
width={24}
|
width={24}
|
||||||
height={24}
|
height={24}
|
||||||
|
|||||||
@@ -1,23 +1,19 @@
|
|||||||
// import React from 'react';
|
// import React from 'react';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useTranslation } from 'react-i18next';
|
// import { useTranslation } from 'react-i18next';
|
||||||
import { Waveform } from '@uiball/loaders';
|
import { Waveform } from '@uiball/loaders';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useAppSelector } from '../../../app/store';
|
import { useAppSelector } from '../../../app/store';
|
||||||
|
import Tooltip from "../../../common/component/Tooltip";
|
||||||
import IconCallOff from '../../../assets/icons/call.off.svg';
|
import IconCallOff from '../../../assets/icons/call.off.svg';
|
||||||
import IconCallAnswer from '../../../assets/icons/call.svg';
|
import IconCallAnswer from '../../../assets/icons/call.svg';
|
||||||
import IconMicOff from '../../../assets/icons/mic.off.svg';
|
|
||||||
import IconMic from '../../../assets/icons/mic.on.svg';
|
|
||||||
import IconCamera from '../../../assets/icons/camera.svg';
|
|
||||||
import IconCameraOff from '../../../assets/icons/camera.off.svg';
|
|
||||||
import IconScreen from '../../../assets/icons/share.screen.svg';
|
|
||||||
import Avatar from '../Avatar';
|
import Avatar from '../Avatar';
|
||||||
import { VoicingInfo, updateCalling } from '../../../app/slices/voice';
|
import { updateCalling } from '../../../app/slices/voice';
|
||||||
import useVoice from './useVoice';
|
import useVoice from './useVoice';
|
||||||
import { playAgoraVideo } from '../../utils';
|
import { playAgoraVideo } from '../../utils';
|
||||||
import Tooltip from '../Tooltip';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
from: number
|
from: number
|
||||||
@@ -25,10 +21,11 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DMCalling = ({ from, to = 0 }: Props) => {
|
const DMCalling = ({ from, to = 0 }: Props) => {
|
||||||
const { t } = useTranslation("chat");
|
const navigate = useNavigate();
|
||||||
const { leave, joinVoice, joining, setMute, closeCamera, openCamera, startShareScreen, stopShareScreen } = useVoice({ id: to, context: "dm" });
|
const { pathname } = useLocation();
|
||||||
const containerRef = useRef(null);
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const { leave, joinVoice, joining } = useVoice({ id: to, context: "dm" });
|
||||||
|
const containerRef = useRef(null);
|
||||||
const { voicingInfo, voicingMembers, fromUser, toUser, loginUser } = useAppSelector(store => {
|
const { voicingInfo, voicingMembers, fromUser, toUser, loginUser } = useAppSelector(store => {
|
||||||
return {
|
return {
|
||||||
voicingInfo: store.voice.voicing ?? {},
|
voicingInfo: store.voice.voicing ?? {},
|
||||||
@@ -39,6 +36,13 @@ const DMCalling = ({ from, to = 0 }: Props) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const sendByMe = loginUser?.uid !== toUser.uid;
|
const sendByMe = loginUser?.uid !== toUser.uid;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ids = voicingMembers.ids;
|
||||||
|
ids.forEach(id => {
|
||||||
|
playAgoraVideo(id);
|
||||||
|
});
|
||||||
|
}, [voicingMembers.ids]);
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
console.log('cancel');
|
console.log('cancel');
|
||||||
if (sendByMe || voicingMembers.ids.length == 2) {
|
if (sendByMe || voicingMembers.ids.length == 2) {
|
||||||
@@ -48,45 +52,27 @@ const DMCalling = ({ from, to = 0 }: Props) => {
|
|||||||
};
|
};
|
||||||
const handleAnswer = () => {
|
const handleAnswer = () => {
|
||||||
joinVoice();
|
joinVoice();
|
||||||
|
navigate(`/chat/dm/${from}`);
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
|
||||||
const ids = voicingMembers.ids;
|
|
||||||
ids.forEach(id => {
|
|
||||||
playAgoraVideo(id);
|
|
||||||
});
|
|
||||||
}, [voicingMembers.ids]);
|
|
||||||
if (!fromUser || !toUser) return null;
|
|
||||||
const connected = voicingMembers.ids.length == 2;
|
|
||||||
const { name, avatar } = sendByMe ? toUser : fromUser;
|
const { name, avatar } = sendByMe ? toUser : fromUser;
|
||||||
const { muted, shareScreen, video } = voicingInfo as VoicingInfo;
|
const connected = voicingMembers.ids.length == 2;
|
||||||
const { speakingVolume = 0 } = voicingMembers.byId[sendByMe ? to : from] ?? {};
|
console.log("dm calling", !fromUser, !toUser, connected);
|
||||||
const speaking = speakingVolume > 50;
|
const atChatPath = sendByMe ? pathname == `/chat/dm/${to}` : pathname == `/chat/dm/${from}`;
|
||||||
|
if (!fromUser || !toUser || connected || atChatPath) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="fixed top-0 left-0 w-full h-screen z-[999] pointer-events-none flex items-center justify-end pr-10">
|
<div ref={containerRef} className="fixed top-0 left-0 w-full h-screen z-[999] pointer-events-none flex items-center justify-end pr-10">
|
||||||
<motion.aside drag dragConstraints={containerRef} dragMomentum={false} whileDrag={{ scale: 1.1 }} className={clsx(`pointer-events-auto
|
<motion.aside drag dragConstraints={containerRef} dragMomentum={false} whileDrag={{ scale: 1.1 }} className={clsx(`pointer-events-auto
|
||||||
rounded bg-gray-800 relative
|
rounded bg-gray-800 relative
|
||||||
shadow-lg shadow-slate-200 dark:shadow-slate-800
|
shadow-lg shadow-slate-200 dark:shadow-slate-800
|
||||||
cursor-move overflow-hidden
|
cursor-move overflow-hidden
|
||||||
`, connected ? "w-80 h-96" : "w-64 h-80")}>
|
w-64 h-80
|
||||||
<div className={clsx("w-20 h-20 flex shrink-0 relative transition-opacity mt-32 m-auto")}>
|
`)}>
|
||||||
{speaking && <div className={clsx("absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 rounded-full bg-green-500 animate-speaking", "w-[86px] h-[86px]")}></div>}
|
|
||||||
<Avatar
|
|
||||||
width={80}
|
|
||||||
height={80}
|
|
||||||
className="w-full h-full rounded-full object-cover"
|
|
||||||
src={avatar}
|
|
||||||
name={name}
|
|
||||||
alt="avatar"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="absolute left-0 top-0 w-full h-full" id={`CAMERA_${from}`}>
|
|
||||||
{/* from camera video */}
|
|
||||||
</div>
|
|
||||||
<div className="absolute right-0-0 top-0 w-40 h-32" id={`CAMERA_${to}`}>
|
<div className="absolute right-0-0 top-0 w-40 h-32" id={`CAMERA_${to}`}>
|
||||||
{/* to camera video */}
|
{/* to camera video */}
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx("absolute left-0 top-0 py-5 w-full h-full flex flex-col justify-between items-center", connected ? "bg-transparent" : "bg-gray-800")}>
|
<div className={clsx("absolute left-0 top-0 py-5 w-full h-full flex flex-col justify-between items-center", connected ? "bg-transparent" : "bg-gray-800")}>
|
||||||
{!connected && <>
|
|
||||||
<div className="flex flex-col gap-2 items-center">
|
<div className="flex flex-col gap-2 items-center">
|
||||||
<div className="rounded-full overflow-hidden w-20 h-20 shrink-0">
|
<div className="rounded-full overflow-hidden w-20 h-20 shrink-0">
|
||||||
<Avatar name={name} src={avatar} width={80} height={80} className='h-20' />
|
<Avatar name={name} src={avatar} width={80} height={80} className='h-20' />
|
||||||
@@ -102,41 +88,20 @@ const DMCalling = ({ from, to = 0 }: Props) => {
|
|||||||
/>
|
/>
|
||||||
<span className='text-xs text-gray-600 dark:text-gray-400'>{sendByMe ? `Calling` : `Incoming call`}</span>
|
<span className='text-xs text-gray-600 dark:text-gray-400'>{sendByMe ? `Calling` : `Incoming call`}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
}
|
|
||||||
<div className={clsx("flex gap-3", connected ? "h-full items-end" : "")}>
|
<div className={clsx("flex gap-3", connected ? "h-full items-end" : "")}>
|
||||||
<Tooltip tip={"Disconnect"} placement="top">
|
<Tooltip tip={"Disconnect"} placement="top">
|
||||||
<button onClick={handleCancel} className='flex-center bg-red-600 hover:bg-red-700 py-2 px-3 rounded-lg'>
|
<button onClick={handleCancel} className='flex-center bg-red-600 hover:bg-red-700 py-2 px-3 rounded-lg'>
|
||||||
<IconCallOff className="w-6 h-6" />
|
<IconCallOff className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{!sendByMe && !connected &&
|
{!sendByMe &&
|
||||||
<Tooltip tip={"Answer"} placement="top">
|
<Tooltip tip={"Answer"} placement="top">
|
||||||
<button disabled={joining} onClick={handleAnswer} className='flex-center bg-green-600 hover:bg-green-700 py-2 px-3 rounded-lg'>
|
<button disabled={joining} onClick={handleAnswer} className='flex-center bg-green-600 hover:bg-green-700 py-2 px-3 rounded-lg'>
|
||||||
<IconCallAnswer className="w-6 h-6 fill-white" />
|
<IconCallAnswer className="w-6 h-6 fill-white" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
{connected && <>
|
|
||||||
<Tooltip tip={muted ? t("unmute") : t("mute")} placement="top">
|
|
||||||
<button onClick={setMute.bind(null, !muted)} className="flex-center py-2 px-3 rounded-lg bg-gray-100 dark:bg-gray-900">
|
|
||||||
{muted ? <IconMicOff className="fill-gray-700 dark:fill-gray-200" /> : <IconMic className="fill-gray-700 dark:fill-gray-200" />}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip tip={video ? t("camera_off") : t("camera_on")} placement="top">
|
|
||||||
<button onClick={video ? closeCamera : openCamera} className="flex-center py-2 px-3 rounded-lg bg-gray-100 dark:bg-gray-900">
|
|
||||||
{video ? <IconCamera className="fill-gray-700 dark:fill-gray-200" /> : <IconCameraOff className="fill-gray-700 dark:fill-gray-200" />}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip tip={"Share Screen"} placement="top">
|
|
||||||
<button onClick={shareScreen ? stopShareScreen : startShareScreen} className={clsx("flex-center py-2 px-3 rounded-lg ", shareScreen ? "bg-green-700" : "bg-gray-100 dark:bg-gray-900")}>
|
|
||||||
<IconScreen className={clsx("dark:fill-gray-200 w-6 h-6", shareScreen ? "fill-gray-200" : "fill-gray-700")} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
</>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</motion.aside>
|
</motion.aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ window.VOICE_TRACK_MAP = window.VOICE_TRACK_MAP ?? {};
|
|||||||
window.VIDEO_TRACK_MAP = window.VIDEO_TRACK_MAP ?? {};
|
window.VIDEO_TRACK_MAP = window.VIDEO_TRACK_MAP ?? {};
|
||||||
// let tmpUids: number[] = [];
|
// let tmpUids: number[] = [];
|
||||||
const Voice = () => {
|
const Voice = () => {
|
||||||
const { from, to, voiceList, loginUid } = useAppSelector(store => {
|
const { from, to, voiceList, loginUid, voicingInfo } = useAppSelector(store => {
|
||||||
return {
|
return {
|
||||||
|
voicingInfo: store.voice.voicing,
|
||||||
voiceList: store.voice.list,
|
voiceList: store.voice.list,
|
||||||
from: store.voice.callingFrom,
|
from: store.voice.callingFrom,
|
||||||
to: store.voice.callingTo,
|
to: store.voice.callingTo,
|
||||||
@@ -83,6 +84,10 @@ const Voice = () => {
|
|||||||
// clear tracks
|
// clear tracks
|
||||||
window.VOICE_TRACK_MAP[+user.uid] = null;
|
window.VOICE_TRACK_MAP[+user.uid] = null;
|
||||||
window.VIDEO_TRACK_MAP[+user.uid] = null;
|
window.VIDEO_TRACK_MAP[+user.uid] = null;
|
||||||
|
if (voicingInfo && voicingInfo.context == "dm") {
|
||||||
|
// 有人离开,就断开
|
||||||
|
dispatch(updateCalling({ from: 0 }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -119,6 +124,14 @@ const Voice = () => {
|
|||||||
// todo
|
// todo
|
||||||
dispatch(updateVoicingMember({ uid: +uid, info: { muted: false } }));
|
dispatch(updateVoicingMember({ uid: +uid, info: { muted: false } }));
|
||||||
break;
|
break;
|
||||||
|
case "mute-video":
|
||||||
|
// todo
|
||||||
|
dispatch(updateVoicingMember({ uid: +uid, info: { video: false } }));
|
||||||
|
break;
|
||||||
|
case "unmute-video":
|
||||||
|
// todo
|
||||||
|
dispatch(updateVoicingMember({ uid: +uid, info: { video: true } }));
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -147,7 +160,7 @@ const Voice = () => {
|
|||||||
window.removeEventListener("beforeunload", handlePageUnload, { capture: true });
|
window.removeEventListener("beforeunload", handlePageUnload, { capture: true });
|
||||||
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, [voicingInfo]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 有人呼叫我
|
// 有人呼叫我
|
||||||
const callMeList = voiceList.filter(item => item.context == "dm" && item.id == loginUid);
|
const callMeList = voiceList.filter(item => item.context == "dm" && item.id == loginUid);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useDispatch } from 'react-redux';
|
|||||||
import { useGenerateAgoraTokenMutation } from '../../../app/services/server';
|
import { useGenerateAgoraTokenMutation } from '../../../app/services/server';
|
||||||
import { updateChannelVisibleAside, updateDMVisibleAside } from '../../../app/slices/footprint';
|
import { updateChannelVisibleAside, updateDMVisibleAside } from '../../../app/slices/footprint';
|
||||||
|
|
||||||
import { addVoiceMember, updateDeafenStatus, updateMuteStatus, updatePin, updateVoicingInfo, upsertVoiceList } from '../../../app/slices/voice';
|
import { addVoiceMember, updatePin, updateVoicingInfo, upsertVoiceList } from '../../../app/slices/voice';
|
||||||
import { useAppSelector } from '../../../app/store';
|
import { useAppSelector } from '../../../app/store';
|
||||||
import { playAgoraVideo } from '../../utils';
|
import { playAgoraVideo } from '../../utils';
|
||||||
|
|
||||||
@@ -76,6 +76,7 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => {
|
|||||||
await window.VOICE_CLIENT?.publish(localVideoTrack);
|
await window.VOICE_CLIENT?.publish(localVideoTrack);
|
||||||
dispatch(updateVoicingInfo({
|
dispatch(updateVoicingInfo({
|
||||||
video: true,
|
video: true,
|
||||||
|
shareScreen: false,
|
||||||
id,
|
id,
|
||||||
context,
|
context,
|
||||||
}));
|
}));
|
||||||
@@ -94,6 +95,7 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => {
|
|||||||
playerEle.classList.remove("h-[120px]");
|
playerEle.classList.remove("h-[120px]");
|
||||||
dispatch(updateVoicingInfo({
|
dispatch(updateVoicingInfo({
|
||||||
video: false,
|
video: false,
|
||||||
|
shareScreen: false,
|
||||||
id,
|
id,
|
||||||
context,
|
context,
|
||||||
}));
|
}));
|
||||||
@@ -195,9 +197,17 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => {
|
|||||||
Object.entries(window.VOICE_TRACK_MAP).forEach(([, audioTrack]) => {
|
Object.entries(window.VOICE_TRACK_MAP).forEach(([, audioTrack]) => {
|
||||||
audioTrack?.setVolume(100);
|
audioTrack?.setVolume(100);
|
||||||
});
|
});
|
||||||
dispatch(updateDeafenStatus(false));
|
dispatch(updateVoicingInfo({
|
||||||
|
muted: false,
|
||||||
|
id,
|
||||||
|
context
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
dispatch(updateMuteStatus(mute));
|
dispatch(updateVoicingInfo({
|
||||||
|
muted: mute,
|
||||||
|
id,
|
||||||
|
context
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
const setDeafen = (deafen: boolean) => {
|
const setDeafen = (deafen: boolean) => {
|
||||||
const localAudioTrack = window.VOICE_TRACK_MAP[loginUid] as IMicrophoneAudioTrack;
|
const localAudioTrack = window.VOICE_TRACK_MAP[loginUid] as IMicrophoneAudioTrack;
|
||||||
@@ -215,7 +225,7 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => {
|
|||||||
audioTrack?.setVolume(100);
|
audioTrack?.setVolume(100);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
dispatch(updateDeafenStatus(deafen));
|
dispatch(updateVoicingInfo({ deafen, id, context }));
|
||||||
};
|
};
|
||||||
const joinedAtThisContext = voicingInfo ? (voicingInfo.id == id && voicingInfo.context == context) : false;
|
const joinedAtThisContext = voicingInfo ? (voicingInfo.id == id && voicingInfo.context == context) : false;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -84,10 +84,14 @@ const VoiceMessage = ({ file_path }: { file_path: string }) => {
|
|||||||
if (!current.isPlaying()) {
|
if (!current.isPlaying()) {
|
||||||
// 先停掉其他的
|
// 先停掉其他的
|
||||||
Object.keys(VoiceMap).forEach((key) => {
|
Object.keys(VoiceMap).forEach((key) => {
|
||||||
|
try {
|
||||||
const item = VoiceMap[key];
|
const item = VoiceMap[key];
|
||||||
if (item && item.isPlaying()) {
|
if (item && item.backend && item.isPlaying()) {
|
||||||
item.stop();
|
item.stop();
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// import React from 'react'
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { useAppSelector } from "../../../app/store";
|
||||||
|
import Tooltip from "../../../common/component/Tooltip";
|
||||||
|
import IconCallOff from '../../../assets/icons/call.off.svg';
|
||||||
|
import IconCallAnswer from '../../../assets/icons/call.svg';
|
||||||
|
import IconMicOff from '../../../assets/icons/mic.off.svg';
|
||||||
|
import IconMic from '../../../assets/icons/mic.on.svg';
|
||||||
|
import IconCamera from '../../../assets/icons/camera.svg';
|
||||||
|
import IconCameraOff from '../../../assets/icons/camera.off.svg';
|
||||||
|
import IconScreen from '../../../assets/icons/share.screen.svg';
|
||||||
|
import { VoicingInfo, updateCalling } from "../../../app/slices/voice";
|
||||||
|
import { useVoice } from "../../../common/component/Voice";
|
||||||
|
import { playAgoraVideo } from "../../../common/utils";
|
||||||
|
import Avatar from "../../../common/component/Avatar";
|
||||||
|
|
||||||
|
type VoicingMember = {
|
||||||
|
id: number,
|
||||||
|
video: boolean,
|
||||||
|
speaking: boolean,
|
||||||
|
name: string,
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
type BlockProps = {
|
||||||
|
sendByMe: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
from: VoicingMember,
|
||||||
|
to: VoicingMember
|
||||||
|
}
|
||||||
|
const VoicingBlocks = ({ sendByMe, connected, from, to }: BlockProps) => {
|
||||||
|
|
||||||
|
const blocks = [from, to].map(({ id, speaking, name, avatar, video }, idx) => {
|
||||||
|
const showWaiting = idx == 1 && !connected && sendByMe;
|
||||||
|
return <div key={id} className={clsx("relative flex-center", video ? "w-80 h-60 overflow-hidden rounded" : "")}>
|
||||||
|
<div className={clsx("w-20 h-20 flex shrink-0 relative transition-opacity", showWaiting && "animate-pulse")}>
|
||||||
|
{speaking && <div className={clsx("z-10 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 rounded-full bg-green-300 animate-speaking", "w-[86px] h-[86px]")}></div>}
|
||||||
|
{showWaiting && <div className={clsx("z-10 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-gray-400", "w-[88px] h-[88px]")}></div>}
|
||||||
|
<Avatar
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
className={clsx("z-20 w-full h-full rounded-full object-cover")}
|
||||||
|
src={avatar}
|
||||||
|
name={name}
|
||||||
|
alt="avatar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="z-30 absolute left-0 top-0 w-full h-full" id={`CAMERA_${id}`}>
|
||||||
|
{/* camera video */}
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return <>{blocks}</>;
|
||||||
|
};
|
||||||
|
type Props = {
|
||||||
|
uid: number
|
||||||
|
}
|
||||||
|
const DMVoice = ({ uid }: Props) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { t } = useTranslation("chat");
|
||||||
|
const { voice: {
|
||||||
|
callingFrom, callingTo, voicing: voicingInfo, voicingMembers
|
||||||
|
}, userData, loginUser } = useAppSelector(store => {
|
||||||
|
return {
|
||||||
|
voice: store.voice,
|
||||||
|
userData: store.users.byId,
|
||||||
|
loginUser: store.authData.user
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const { leave, joinVoice, joining, setMute, closeCamera, openCamera, startShareScreen, stopShareScreen } = useVoice({ id: callingTo, context: "dm" });
|
||||||
|
useEffect(() => {
|
||||||
|
const ids = voicingMembers.ids;
|
||||||
|
ids.forEach(id => {
|
||||||
|
playAgoraVideo(id);
|
||||||
|
});
|
||||||
|
}, [voicingMembers.ids]);
|
||||||
|
if (![callingFrom, callingTo].includes(uid)) return null;
|
||||||
|
|
||||||
|
const { name: fromUsername, avatar: fromAvatar } = userData[callingFrom];
|
||||||
|
const { name: toUsername, avatar: toAvatar, uid: toUid } = userData[callingTo];
|
||||||
|
const sendByMe = loginUser?.uid !== toUid;
|
||||||
|
const handleCancel = () => {
|
||||||
|
console.log('cancel');
|
||||||
|
if (sendByMe || voicingMembers.ids.length == 2) {
|
||||||
|
leave();
|
||||||
|
}
|
||||||
|
dispatch(updateCalling({ from: 0, to: 0 }));
|
||||||
|
};
|
||||||
|
const handleAnswer = () => {
|
||||||
|
joinVoice();
|
||||||
|
};
|
||||||
|
const connected = voicingMembers.ids.length == 2;
|
||||||
|
const { muted, shareScreen, video } = voicingInfo ?? {} as VoicingInfo;
|
||||||
|
const { speakingVolume: toSpeakingVol = 0, video: toVideo = false, shareScreen: toScreen = false } = voicingMembers.byId[callingTo] ?? {};
|
||||||
|
const toSpeaking = toSpeakingVol > 50;
|
||||||
|
const { speakingVolume: fromSpeakingVol = 0, video: fromVideo = false, shareScreen: fromScreen = false } = voicingMembers.byId[callingFrom] ?? {};
|
||||||
|
const fromSpeaking = fromSpeakingVol > 50;
|
||||||
|
return (
|
||||||
|
<div className="py-4 px-10 flex flex-col items-center gap-3 bg-slate-200 dark:bg-slate-800">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<VoicingBlocks sendByMe={sendByMe} connected={connected} from={{
|
||||||
|
id: callingFrom,
|
||||||
|
video: fromVideo || fromScreen,
|
||||||
|
speaking: fromSpeaking,
|
||||||
|
name: fromUsername,
|
||||||
|
avatar: fromAvatar
|
||||||
|
}} to={{
|
||||||
|
id: callingTo,
|
||||||
|
video: toVideo || toScreen,
|
||||||
|
speaking: toSpeaking,
|
||||||
|
name: toUsername,
|
||||||
|
avatar: toAvatar
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div className={clsx("flex gap-3", connected ? "h-full items-end" : "")}>
|
||||||
|
<Tooltip tip={"Disconnect"} placement="top">
|
||||||
|
<button onClick={handleCancel} className='flex-center bg-red-600 hover:bg-red-700 py-2 px-3 rounded-lg'>
|
||||||
|
<IconCallOff className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
{!sendByMe && !connected &&
|
||||||
|
<Tooltip tip={"Answer"} placement="top">
|
||||||
|
<button disabled={joining} onClick={handleAnswer} className='flex-center bg-green-600 hover:bg-green-700 py-2 px-3 rounded-lg'>
|
||||||
|
<IconCallAnswer className="w-6 h-6 fill-white" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
{connected && <>
|
||||||
|
<Tooltip tip={muted ? t("unmute") : t("mute")} placement="top">
|
||||||
|
<button onClick={setMute.bind(null, !muted)} className="flex-center py-2 px-3 rounded-lg bg-gray-100 dark:bg-gray-900">
|
||||||
|
{muted ? <IconMicOff className="fill-gray-700 dark:fill-gray-200" /> : <IconMic className="fill-gray-700 dark:fill-gray-200" />}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip tip={video ? t("camera_off") : t("camera_on")} placement="top">
|
||||||
|
<button onClick={video ? closeCamera : openCamera} className="flex-center py-2 px-3 rounded-lg bg-gray-100 dark:bg-gray-900">
|
||||||
|
{video ? <IconCamera className="fill-gray-700 dark:fill-gray-200" /> : <IconCameraOff className="fill-gray-700 dark:fill-gray-200" />}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip tip={"Share Screen"} placement="top">
|
||||||
|
<button onClick={shareScreen ? stopShareScreen : startShareScreen} className={clsx("flex-center py-2 px-3 rounded-lg ", shareScreen ? "bg-green-700" : "bg-gray-100 dark:bg-gray-900")}>
|
||||||
|
<IconScreen className={clsx("dark:fill-gray-200 w-6 h-6", shareScreen ? "fill-gray-200" : "fill-gray-700")} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DMVoice;
|
||||||
@@ -17,6 +17,7 @@ import IconWarning from '../../../assets/icons/warning.svg';
|
|||||||
import ImagePreview from "../../../common/component/ImagePreview";
|
import ImagePreview from "../../../common/component/ImagePreview";
|
||||||
|
|
||||||
import VirtualMessageFeed from "./VirtualMessageFeed";
|
import VirtualMessageFeed from "./VirtualMessageFeed";
|
||||||
|
import DMVoice from "./DMVoicing";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
@@ -98,6 +99,7 @@ const Layout: FC<Props> = ({
|
|||||||
{header}
|
{header}
|
||||||
<div className="w-full h-full flex items-start justify-between relative" >
|
<div className="w-full h-full flex items-start justify-between relative" >
|
||||||
<div className="rounded-br-2xl flex flex-col absolute bottom-0 w-full h-full" ref={messagesContainer}>
|
<div className="rounded-br-2xl flex flex-col absolute bottom-0 w-full h-full" ref={messagesContainer}>
|
||||||
|
{context == "user" && <DMVoice uid={to} />}
|
||||||
{/* 消息流 */}
|
{/* 消息流 */}
|
||||||
<VirtualMessageFeed key={`${context}_${to}`} context={context} id={to} />
|
<VirtualMessageFeed key={`${context}_${to}`} context={context} id={to} />
|
||||||
{/* 发送框 */}
|
{/* 发送框 */}
|
||||||
@@ -120,7 +122,7 @@ const Layout: FC<Props> = ({
|
|||||||
{aside}
|
{aside}
|
||||||
</div>}
|
</div>}
|
||||||
{users && <div className="hidden md:block">{users}</div>}
|
{users && <div className="hidden md:block">{users}</div>}
|
||||||
{voice && <div className="max-md:absolute max-md:right-11 max-md:top-14 max-md:h-fit max-md:overflow-y-scroll bg-white dark:!bg-gray-700 md:block">{voice}</div>}
|
{voice && <div className="z-10 max-md:absolute max-md:right-11 max-md:top-14 max-md:overflow-y-scroll bg-white dark:!bg-gray-700 md:block">{voice}</div>}
|
||||||
{!readonly && inputMode == "text" && isActive && (
|
{!readonly && inputMode == "text" && isActive && (
|
||||||
<DnDTip context={context} name={name} />
|
<DnDTip context={context} name={name} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -63,9 +63,11 @@ const Session: FC<IProps> = ({
|
|||||||
mid: number;
|
mid: number;
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
}>();
|
}>();
|
||||||
const { messageData, userData, channelData, readIndex, loginUid, mids, muted, voiceList } = useAppSelector(
|
const { callingFrom, callingTo, messageData, userData, channelData, readIndex, loginUid, mids, muted, voiceList } = useAppSelector(
|
||||||
(store) => {
|
(store) => {
|
||||||
return {
|
return {
|
||||||
|
callingFrom: store.voice.callingFrom,
|
||||||
|
callingTo: store.voice.callingTo,
|
||||||
voiceList: store.voice.list,
|
voiceList: store.voice.list,
|
||||||
mids: type == "dm" ? store.userMessage.byId[id] : store.channelMessage[id],
|
mids: type == "dm" ? store.userMessage.byId[id] : store.channelMessage[id],
|
||||||
loginUid: store.authData.user?.uid || 0,
|
loginUid: store.authData.user?.uid || 0,
|
||||||
@@ -101,9 +103,9 @@ const Session: FC<IProps> = ({
|
|||||||
messageData,
|
messageData,
|
||||||
loginUid
|
loginUid
|
||||||
});
|
});
|
||||||
const isVoicing = voiceList.some((item) => {
|
const isVoicing = type == "channel" ? voiceList.some((item) => {
|
||||||
return item.context == type && item.id === id;
|
return item.context == type && item.id === id;
|
||||||
});
|
}) : (id == callingFrom || id == callingTo);
|
||||||
return (
|
return (
|
||||||
<li className="session">
|
<li className="session">
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
|
|||||||
Reference in New Issue
Block a user