diff --git a/src/app/slices/voice.ts b/src/app/slices/voice.ts index 5c98ceb5..66281b0a 100644 --- a/src/app/slices/voice.ts +++ b/src/app/slices/voice.ts @@ -11,13 +11,9 @@ export type VoiceBasicInfo = { export type VoicingInfo = { downlinkNetworkQuality?: number, - muted?: boolean, - deafen?: boolean, - video?: boolean, - shareScreen?: boolean, joining?: boolean, connectionState?: ConnectionState -} & VoiceBasicInfo +} & VoiceBasicInfo & VoicingMemberInfo export type VoicingMemberInfo = { speakingVolume?: number, @@ -75,6 +71,33 @@ const voiceSlice = createSlice({ updateVoicingInfo(state, { payload }: PayloadAction) { if (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 { // reset state.voicing = payload; @@ -84,29 +107,6 @@ const voiceSlice = createSlice({ }; } }, - updateMuteStatus(state, { payload }: PayloadAction) { - 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) { - 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) { if (state.voicing) { 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; diff --git a/src/common/component/FileMessage/AudioMessage.tsx b/src/common/component/FileMessage/AudioMessage.tsx index b1651c61..035f0067 100644 --- a/src/common/component/FileMessage/AudioMessage.tsx +++ b/src/common/component/FileMessage/AudioMessage.tsx @@ -18,7 +18,7 @@ const AudioMessage = ({ url, name, size, download }: Props) => { }; const _size = formatBytes(size); return ( -
+
@@ -27,7 +27,7 @@ const AudioMessage = ({ url, name, size, download }: Props) => { {_size}
- +
diff --git a/src/common/component/Message/Reply.tsx b/src/common/component/Message/Reply.tsx index e20b5f02..7396ab4c 100644 --- a/src/common/component/Message/Reply.tsx +++ b/src/common/component/Message/Reply.tsx @@ -100,7 +100,7 @@ const Reply: FC = ({ mid, interactive = true }) => { )} onClick={interactive ? handleClick : undefined} > -
+
{ - const { t } = useTranslation("chat"); - const { leave, joinVoice, joining, setMute, closeCamera, openCamera, startShareScreen, stopShareScreen } = useVoice({ id: to, context: "dm" }); - const containerRef = useRef(null); + const navigate = useNavigate(); + const { pathname } = useLocation(); const dispatch = useDispatch(); + const { leave, joinVoice, joining } = useVoice({ id: to, context: "dm" }); + const containerRef = useRef(null); const { voicingInfo, voicingMembers, fromUser, toUser, loginUser } = useAppSelector(store => { return { voicingInfo: store.voice.voicing ?? {}, @@ -39,6 +36,13 @@ const DMCalling = ({ from, to = 0 }: Props) => { }; }); const sendByMe = loginUser?.uid !== toUser.uid; + + useEffect(() => { + const ids = voicingMembers.ids; + ids.forEach(id => { + playAgoraVideo(id); + }); + }, [voicingMembers.ids]); const handleCancel = () => { console.log('cancel'); if (sendByMe || voicingMembers.ids.length == 2) { @@ -48,95 +52,56 @@ const DMCalling = ({ from, to = 0 }: Props) => { }; const handleAnswer = () => { 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 { muted, shareScreen, video } = voicingInfo as VoicingInfo; - const { speakingVolume = 0 } = voicingMembers.byId[sendByMe ? to : from] ?? {}; - const speaking = speakingVolume > 50; + const connected = voicingMembers.ids.length == 2; + console.log("dm calling", !fromUser, !toUser, connected); + const atChatPath = sendByMe ? pathname == `/chat/dm/${to}` : pathname == `/chat/dm/${from}`; + if (!fromUser || !toUser || connected || atChatPath) return null; + return (
-
- {speaking &&
} - -
-
- {/* from camera video */} -
+ w-64 h-80 + `)}> +
{/* to camera video */}
- {!connected && <> -
-
- -
- {name} +
+
+
-
- - {sendByMe ? `Calling` : `Incoming call`} -
- - } + {name} +
+
+ + {sendByMe ? `Calling` : `Incoming call`} +
- {!sendByMe && !connected && + {!sendByMe && } - {connected && <> - - - - - - - - - - - }
-
diff --git a/src/common/component/Voice/index.tsx b/src/common/component/Voice/index.tsx index 992cbeb8..efdea95c 100644 --- a/src/common/component/Voice/index.tsx +++ b/src/common/component/Voice/index.tsx @@ -13,8 +13,9 @@ window.VOICE_TRACK_MAP = window.VOICE_TRACK_MAP ?? {}; window.VIDEO_TRACK_MAP = window.VIDEO_TRACK_MAP ?? {}; // let tmpUids: number[] = []; const Voice = () => { - const { from, to, voiceList, loginUid } = useAppSelector(store => { + const { from, to, voiceList, loginUid, voicingInfo } = useAppSelector(store => { return { + voicingInfo: store.voice.voicing, voiceList: store.voice.list, from: store.voice.callingFrom, to: store.voice.callingTo, @@ -83,6 +84,10 @@ const Voice = () => { // clear tracks window.VOICE_TRACK_MAP[+user.uid] = null; window.VIDEO_TRACK_MAP[+user.uid] = null; + if (voicingInfo && voicingInfo.context == "dm") { + // 有人离开,就断开 + dispatch(updateCalling({ from: 0 })); + } } break; default: @@ -119,6 +124,14 @@ const Voice = () => { // todo dispatch(updateVoicingMember({ uid: +uid, info: { muted: false } })); 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: break; } @@ -147,7 +160,7 @@ const Voice = () => { window.removeEventListener("beforeunload", handlePageUnload, { capture: true }); }; - }, []); + }, [voicingInfo]); useEffect(() => { // 有人呼叫我 const callMeList = voiceList.filter(item => item.context == "dm" && item.id == loginUid); diff --git a/src/common/component/Voice/useVoice.ts b/src/common/component/Voice/useVoice.ts index 0aed4243..665b9421 100644 --- a/src/common/component/Voice/useVoice.ts +++ b/src/common/component/Voice/useVoice.ts @@ -6,7 +6,7 @@ import { useDispatch } from 'react-redux'; import { useGenerateAgoraTokenMutation } from '../../../app/services/server'; 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 { playAgoraVideo } from '../../utils'; @@ -76,6 +76,7 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => { await window.VOICE_CLIENT?.publish(localVideoTrack); dispatch(updateVoicingInfo({ video: true, + shareScreen: false, id, context, })); @@ -94,6 +95,7 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => { playerEle.classList.remove("h-[120px]"); dispatch(updateVoicingInfo({ video: false, + shareScreen: false, id, context, })); @@ -195,9 +197,17 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => { Object.entries(window.VOICE_TRACK_MAP).forEach(([, audioTrack]) => { 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 localAudioTrack = window.VOICE_TRACK_MAP[loginUid] as IMicrophoneAudioTrack; @@ -215,7 +225,7 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => { audioTrack?.setVolume(100); }); } - dispatch(updateDeafenStatus(deafen)); + dispatch(updateVoicingInfo({ deafen, id, context })); }; const joinedAtThisContext = voicingInfo ? (voicingInfo.id == id && voicingInfo.context == context) : false; return { diff --git a/src/common/component/VoiceMessage.tsx b/src/common/component/VoiceMessage.tsx index e73c169e..95affcf9 100644 --- a/src/common/component/VoiceMessage.tsx +++ b/src/common/component/VoiceMessage.tsx @@ -84,9 +84,13 @@ const VoiceMessage = ({ file_path }: { file_path: string }) => { if (!current.isPlaying()) { // 先停掉其他的 Object.keys(VoiceMap).forEach((key) => { - const item = VoiceMap[key]; - if (item && item.isPlaying()) { - item.stop(); + try { + const item = VoiceMap[key]; + if (item && item.backend && item.isPlaying()) { + item.stop(); + } + } catch (error) { + console.log(error); } } ); diff --git a/src/routes/chat/Layout/DMVoicing.tsx b/src/routes/chat/Layout/DMVoicing.tsx new file mode 100644 index 00000000..4933efed --- /dev/null +++ b/src/routes/chat/Layout/DMVoicing.tsx @@ -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
+
+ {speaking &&
} + {showWaiting &&
} + +
+
+ {/* camera video */} +
+
; + } + ); + 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 ( +
+
+ +
+
+ + + + {!sendByMe && !connected && + + + + } + {connected && <> + + + + + + + + + + + } +
+
+ ); +}; + +export default DMVoice; \ No newline at end of file diff --git a/src/routes/chat/Layout/index.tsx b/src/routes/chat/Layout/index.tsx index a1a6cb1e..8ca929f3 100644 --- a/src/routes/chat/Layout/index.tsx +++ b/src/routes/chat/Layout/index.tsx @@ -17,6 +17,7 @@ import IconWarning from '../../../assets/icons/warning.svg'; import ImagePreview from "../../../common/component/ImagePreview"; import VirtualMessageFeed from "./VirtualMessageFeed"; +import DMVoice from "./DMVoicing"; interface Props { readonly?: boolean; @@ -98,6 +99,7 @@ const Layout: FC = ({ {header}
+ {context == "user" && } {/* 消息流 */} {/* 发送框 */} @@ -120,7 +122,7 @@ const Layout: FC = ({ {aside}
} {users &&
{users}
} - {voice &&
{voice}
} + {voice &&
{voice}
} {!readonly && inputMode == "text" && isActive && ( )} diff --git a/src/routes/chat/SessionList/Session.tsx b/src/routes/chat/SessionList/Session.tsx index 15244dce..fc99695c 100644 --- a/src/routes/chat/SessionList/Session.tsx +++ b/src/routes/chat/SessionList/Session.tsx @@ -63,9 +63,11 @@ const Session: FC = ({ mid: number; 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) => { return { + callingFrom: store.voice.callingFrom, + callingTo: store.voice.callingTo, voiceList: store.voice.list, mids: type == "dm" ? store.userMessage.byId[id] : store.channelMessage[id], loginUid: store.authData.user?.uid || 0, @@ -101,9 +103,9 @@ const Session: FC = ({ messageData, loginUid }); - const isVoicing = voiceList.some((item) => { + const isVoicing = type == "channel" ? voiceList.some((item) => { return item.context == type && item.id === id; - }); + }) : (id == callingFrom || id == callingTo); return (