refactor: DM voicing

This commit is contained in:
Tristan Yang
2023-05-11 11:56:22 +08:00
parent 52ff1eb7dc
commit 1bead4e244
10 changed files with 270 additions and 119 deletions
+29 -29
View File
@@ -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>
+1 -1
View File
@@ -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}
+25 -60
View File
@@ -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>
+15 -2
View File
@@ -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);
+14 -4
View File
@@ -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 {
+5 -1
View File
@@ -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);
}
} }
); );
} }
+155
View File
@@ -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;
+3 -1
View File
@@ -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} />
)} )}
+5 -3
View File
@@ -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