refactor: audio UX and deafen
This commit is contained in:
@@ -3,8 +3,10 @@ import { isNull, omitBy } from "lodash";
|
||||
import BASE_URL from "../config";
|
||||
import { Channel, UpdateChannelDTO, UpdatePinnedMessageDTO } from "../../types/channel";
|
||||
|
||||
type ChannelAside = "members" | "voice" | null;
|
||||
interface StoredChannel extends Channel {
|
||||
icon?: string;
|
||||
visibleAside: ChannelAside
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -33,7 +35,8 @@ const channelsSlice = createSlice({
|
||||
icon:
|
||||
c.avatar_updated_at == 0
|
||||
? ""
|
||||
: `${BASE_URL}/resource/group_avatar?gid=${c.gid}&t=${c.avatar_updated_at}`
|
||||
: `${BASE_URL}/resource/group_avatar?gid=${c.gid}&t=${c.avatar_updated_at}`,
|
||||
visibleAside: "members"
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -48,7 +51,8 @@ const channelsSlice = createSlice({
|
||||
icon:
|
||||
avatar_updated_at == 0
|
||||
? ""
|
||||
: `${BASE_URL}/resource/group_avatar?gid=${gid}&t=${avatar_updated_at}`
|
||||
: `${BASE_URL}/resource/group_avatar?gid=${gid}&t=${avatar_updated_at}`,
|
||||
visibleAside: "members"
|
||||
};
|
||||
},
|
||||
updateChannel(state, action: PayloadAction<UpdateChannelDTO>) {
|
||||
@@ -99,6 +103,12 @@ const channelsSlice = createSlice({
|
||||
}
|
||||
}
|
||||
},
|
||||
updateChannelVisibleAside(state, action: PayloadAction<{ id: number, aside: ChannelAside }>) {
|
||||
const { id, aside } = action.payload;
|
||||
if (state.byId[id]) {
|
||||
state.byId[id].visibleAside = aside;
|
||||
}
|
||||
},
|
||||
removeChannel(state, action: PayloadAction<number>) {
|
||||
const gid = action.payload;
|
||||
const idx = state.ids.findIndex((i) => i == gid);
|
||||
@@ -116,7 +126,8 @@ export const {
|
||||
fillChannels,
|
||||
addChannel,
|
||||
updateChannel,
|
||||
removeChannel
|
||||
removeChannel,
|
||||
updateChannelVisibleAside
|
||||
} = channelsSlice.actions;
|
||||
|
||||
export default channelsSlice.reducer;
|
||||
|
||||
@@ -4,10 +4,12 @@ import BASE_URL from "../config";
|
||||
import { User } from "../../types/user";
|
||||
import { UserLog, UserState } from "../../types/sse";
|
||||
|
||||
type DMAside = "voice" | null;
|
||||
export interface StoredUser extends User {
|
||||
online?: boolean;
|
||||
voice?: boolean;
|
||||
avatar?: string;
|
||||
visibleAside?: DMAside
|
||||
}
|
||||
|
||||
export interface State {
|
||||
|
||||
+16
-2
@@ -9,11 +9,13 @@ export type VoiceBasicInfo = {
|
||||
export type VoicingInfo = {
|
||||
downlinkNetworkQuality?: number,
|
||||
muted: boolean,
|
||||
deafen: boolean
|
||||
} & VoiceBasicInfo
|
||||
|
||||
export type VoicingMemberInfo = {
|
||||
speakingVolume?: number,
|
||||
muted?: boolean
|
||||
muted?: boolean,
|
||||
deafen?: boolean
|
||||
}
|
||||
|
||||
export type VoicingMembers = {
|
||||
@@ -73,6 +75,18 @@ const voiceSlice = createSlice({
|
||||
}
|
||||
}
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateVoicingNetworkQuality(state, { payload }: PayloadAction<number>) {
|
||||
if (state.voicing) {
|
||||
state.voicing.downlinkNetworkQuality = payload;
|
||||
@@ -109,5 +123,5 @@ const voiceSlice = createSlice({
|
||||
},
|
||||
});
|
||||
|
||||
export const { addVoiceMember, removeVoiceMember, updateVoiceList, updateVoicingInfo, updateVoicingNetworkQuality, updateMuteStatus, updateVoicingMember } = voiceSlice.actions;
|
||||
export const { addVoiceMember, removeVoiceMember, updateVoiceList, updateVoicingInfo, updateVoicingNetworkQuality, updateMuteStatus, updateVoicingMember, updateDeafenStatus } = voiceSlice.actions;
|
||||
export default voiceSlice.reducer;
|
||||
|
||||
@@ -2,7 +2,7 @@ import AgoraRTC, { IMicrophoneAudioTrack } from 'agora-rtc-sdk-ng';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useGetAgoraConfigQuery, useGetAgoraVoicingListQuery, useLazyGetAgoraTokenQuery } from '../../app/services/server';
|
||||
import { addVoiceMember, removeVoiceMember, updateMuteStatus, updateVoicingInfo, updateVoicingMember, updateVoicingNetworkQuality } from '../../app/slices/voice';
|
||||
import { addVoiceMember, removeVoiceMember, updateDeafenStatus, updateMuteStatus, updateVoicingInfo, updateVoicingMember, updateVoicingNetworkQuality } from '../../app/slices/voice';
|
||||
import { useAppSelector } from '../../app/store';
|
||||
|
||||
// type Props = {}
|
||||
@@ -150,6 +150,7 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => {
|
||||
await window.VOICE_CLIENT.publish(localTrack);
|
||||
console.log("Publish success!,joined the channel");
|
||||
dispatch(updateVoicingInfo({
|
||||
deafen: false,
|
||||
muted: false,
|
||||
id,
|
||||
context,
|
||||
@@ -174,8 +175,27 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => {
|
||||
dispatch(updateMuteStatus(mute));
|
||||
}
|
||||
};
|
||||
const setDeafen = (deafen: boolean) => {
|
||||
if (localTrack) {
|
||||
if (deafen) {
|
||||
localTrack.setMuted(true);
|
||||
// 远端音频,全部静音
|
||||
Object.entries(window.VOICE_TRACK_MAP).forEach(([, audioTrack]) => {
|
||||
audioTrack?.setVolume(0);
|
||||
});
|
||||
} else {
|
||||
localTrack.setMuted(false);
|
||||
// 远端音频,恢复原音
|
||||
Object.entries(window.VOICE_TRACK_MAP).forEach(([, audioTrack]) => {
|
||||
audioTrack?.setVolume(100);
|
||||
});
|
||||
}
|
||||
dispatch(updateDeafenStatus(deafen));
|
||||
}
|
||||
};
|
||||
return {
|
||||
setMute,
|
||||
setDeafen,
|
||||
leave,
|
||||
// canVoice,
|
||||
voicingInfo,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, memo } from "react";
|
||||
import { useEffect, memo } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import Tippy from "@tippyjs/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -17,6 +17,8 @@ import { useAppSelector } from "../../../app/store";
|
||||
import GoBackNav from "../../../common/component/GoBackNav";
|
||||
import Members from "./Members";
|
||||
import VoiceChat from "../VoiceChat";
|
||||
import { updateChannelVisibleAside } from "../../../app/slices/channels";
|
||||
import Dashboard from "../VoiceChat/Dashboard";
|
||||
type Props = {
|
||||
cid?: number;
|
||||
dropFiles?: File[];
|
||||
@@ -26,7 +28,6 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const [membersVisible, setMembersVisible] = useState(true);
|
||||
|
||||
const {
|
||||
userIds,
|
||||
@@ -53,7 +54,10 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
|
||||
}, [pathname]);
|
||||
|
||||
const toggleMembersVisible = () => {
|
||||
setMembersVisible((prev) => !prev);
|
||||
dispatch(updateChannelVisibleAside({
|
||||
id: cid,
|
||||
aside: data.visibleAside !== "members" ? "members" : null
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -84,7 +88,8 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
|
||||
</li>
|
||||
</Tippy>
|
||||
</Tooltip>
|
||||
<VoiceChat channel={`channel_${cid}`} id={cid} />
|
||||
{/* 音频 */}
|
||||
<VoiceChat context={`channel`} id={cid} />
|
||||
<Tooltip tip={t("fav")} placement="left">
|
||||
<Tippy
|
||||
placement="left-start"
|
||||
@@ -104,7 +109,7 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
|
||||
onClick={toggleMembersVisible}
|
||||
>
|
||||
<Tooltip tip={t("channel_members")} placement="left">
|
||||
<IconPeople className={membersVisible ? "fill-gray-600" : ""} />
|
||||
<IconPeople className={data.visibleAside == "members" ? "fill-gray-600" : ""} />
|
||||
</Tooltip>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -120,7 +125,10 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
|
||||
</header>
|
||||
}
|
||||
users={
|
||||
<Members uids={memberIds} addVisible={addVisible} cid={cid} ownerId={owner} membersVisible={membersVisible} />
|
||||
<Members uids={memberIds} addVisible={addVisible} cid={cid} ownerId={owner} membersVisible={data.visibleAside == "members"} />
|
||||
}
|
||||
voice={
|
||||
<Dashboard visible={data.visibleAside == "voice"} id={cid} context="channel" />
|
||||
}
|
||||
/>;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface Props {
|
||||
header: ReactElement;
|
||||
aside?: ReactElement | null;
|
||||
users?: ReactElement | null;
|
||||
voice?: ReactElement | null;
|
||||
dropFiles?: File[];
|
||||
context: "channel" | "user";
|
||||
to: number;
|
||||
@@ -33,6 +34,7 @@ const Layout: FC<Props> = ({
|
||||
header,
|
||||
aside = null,
|
||||
users = null,
|
||||
voice = null,
|
||||
dropFiles = [],
|
||||
context = "channel",
|
||||
to
|
||||
@@ -116,6 +118,7 @@ const Layout: FC<Props> = ({
|
||||
</main>
|
||||
{aside && <div className={clsx("z-50 p-3 absolute right-0 top-0 translate-x-full flex-col hidden md:flex")}>{aside}</div>}
|
||||
{users && <div className="hidden md:block">{users}</div>}
|
||||
{voice && <div className="hidden md:block">{voice}</div>}
|
||||
{!readonly && inputMode == "text" && isActive && (
|
||||
<DnDTip context={context} name={name} />
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
}
|
||||
|
||||
const RTCWidget = ({ id, context = "channel" }: Props) => {
|
||||
const { leave, voicingInfo, setMute } = useVoice({ context, id });
|
||||
const { leave, voicingInfo, setMute, setDeafen } = useVoice({ context, id });
|
||||
const { loginUser, channelData, userData } = useAppSelector(store => {
|
||||
return {
|
||||
userData: store.users.byId,
|
||||
@@ -51,7 +51,7 @@ const RTCWidget = ({ id, context = "channel" }: Props) => {
|
||||
</div>
|
||||
{/* {voicingInfo && */}
|
||||
<div className="flex gap-2 px-1">
|
||||
<IconHeadphone role="button" />
|
||||
{voicingInfo.deafen ? <IconHeadphoneOff role="button" onClick={setDeafen.bind(null, false)} /> : <IconHeadphone role="button" onClick={setDeafen.bind(null, true)} />}
|
||||
{voicingInfo.muted ?
|
||||
<IconMicOff onClick={setMute.bind(null, false)} role="button" />
|
||||
:
|
||||
|
||||
@@ -5,17 +5,18 @@ import JoinVoice from './JoinVoice';
|
||||
import { useVoice } from '../../../common/component/Voice';
|
||||
|
||||
type Props = {
|
||||
visible: boolean,
|
||||
context?: "channel" | "dm",
|
||||
id: number,
|
||||
}
|
||||
|
||||
const Dashboard = ({ context = "channel", id }: Props) => {
|
||||
const { joinVoice, joined, joining, voicingInfo } = useVoice({ id, context });
|
||||
const Dashboard = ({ context = "channel", id, visible }: Props) => {
|
||||
const { joinVoice, joined, joining, voicingInfo, setMute, setDeafen, leave } = useVoice({ id, context });
|
||||
// const dispatch = useDispatch();
|
||||
|
||||
|
||||
return <div className='absolute -left-full -translate-x-full -top-1 z-50 shadow rounded p-2 bg-white dark:bg-black px-2 py-4'>
|
||||
{joined ? <VoiceManagement info={voicingInfo} /> : <JoinVoice join={joinVoice} joining={joining} />}
|
||||
return <div className={`h-full flex-col gap-1 w-[226px] overflow-y-scroll p-2 shadow-[inset_1px_0px_0px_rgba(0,_0,_0,_0.1)] ${visible ? "flex" : "hidden"}`}>
|
||||
{joined ? <VoiceManagement info={voicingInfo} setMute={setMute} setDeafen={setDeafen} leave={leave} /> : <JoinVoice join={joinVoice} joining={joining} />}
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ type Props = {
|
||||
}
|
||||
|
||||
const JoinVoice = ({ joining, join }: Props) => {
|
||||
if (joining) return <div>
|
||||
Joining
|
||||
if (joining) return <div className='w-full h-full flex-center p-1 text-sm text-gray-600 dark:text-gray-400'>
|
||||
Connecting to voice channel...
|
||||
</div>;
|
||||
return (
|
||||
<div className='w-full h-full flex-center p-4'>
|
||||
<StyledButton className='mini' onClick={join}>Join</StyledButton>
|
||||
<StyledButton className='w-full' onClick={join}>Start Audio Chat</StyledButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,15 +3,22 @@ import React from 'react';
|
||||
import { VoicingInfo } from '../../../app/slices/voice';
|
||||
import { useAppSelector } from '../../../app/store';
|
||||
import Avatar from '../../../common/component/Avatar';
|
||||
import IconHeadphone from '../../../assets/icons/headphone.svg';
|
||||
import IconHeadphoneOff from '../../../assets/icons/headphone.off.svg';
|
||||
import IconMic from '../../../assets/icons/mic.on.svg';
|
||||
import IconMicOff from '../../../assets/icons/mic.off.svg';
|
||||
import StyledButton from '../../../common/component/styled/Button';
|
||||
import IconCallOff from '../../../assets/icons/call.off.svg';
|
||||
// import User from '../../../common/component/User';
|
||||
|
||||
type Props = {
|
||||
info: VoicingInfo | null
|
||||
info: VoicingInfo | null,
|
||||
setMute: (param: boolean) => void,
|
||||
setDeafen: (param: boolean) => void,
|
||||
leave: () => void
|
||||
}
|
||||
|
||||
const VoiceManagement = ({ info }: Props) => {
|
||||
const VoiceManagement = ({ info, setMute, setDeafen, leave }: Props) => {
|
||||
const { userData, voicingMembers } = useAppSelector(store => {
|
||||
return {
|
||||
userData: store.users.byId,
|
||||
@@ -19,39 +26,37 @@ const VoiceManagement = ({ info }: Props) => {
|
||||
};
|
||||
});
|
||||
if (!info) return null;
|
||||
const { context, id } = info;
|
||||
const { deafen, muted } = info;
|
||||
const nameClass = clsx(`text-sm text-gray-500 max-w-[190px] truncate font-semibold dark:text-white`);
|
||||
const members = voicingMembers.ids;
|
||||
const membersData = voicingMembers.byId;
|
||||
return (
|
||||
<div className='w-full h-full py-2'>
|
||||
<ul className='flex flex-col gap-2'>
|
||||
<div className='w-full h-full py-2 flex flex-col'>
|
||||
<ul className='flex grow flex-col gap-2'>
|
||||
{members.map((uid) => {
|
||||
const curr = userData[uid];
|
||||
if (!curr) return null;
|
||||
const { muted, speakingVolume = 0 } = membersData[uid];
|
||||
const { muted, deafen, speakingVolume = 0 } = membersData[uid];
|
||||
const speaking = speakingVolume > 50;
|
||||
return <li key={uid}>
|
||||
<div className="flex items-center justify-between gap-6 ">
|
||||
<div className="flex items-center gap-2 transition-opacity" style={{ opacity: `${speaking ? 0.4 : 1}` }}>
|
||||
<div className="w-8 h-8 flex shrink-0">
|
||||
<Avatar
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
src={curr.avatar}
|
||||
name={curr.name}
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
<span className={nameClass} title={curr?.name}>
|
||||
{curr?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{/* {muted ? <IconMicOff className="w-4" /> : <IconMic className="w-4" />} */}
|
||||
{muted ? <IconMicOff className="w-4" /> : <IconMic className="w-4" />}
|
||||
return <li key={uid} className="flex items-center justify-between gap-6 ">
|
||||
<div className="flex items-center gap-2 transition-opacity" style={{ opacity: `${speaking ? 0.4 : 1}` }}>
|
||||
<div className="w-8 h-8 flex shrink-0">
|
||||
<Avatar
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
src={curr.avatar}
|
||||
name={curr.name}
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
<span className={nameClass} title={curr?.name}>
|
||||
{curr?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{deafen ? <IconHeadphoneOff className="w-4" /> : <IconHeadphone className="w-4" />}
|
||||
{muted ? <IconMicOff className="w-4 fill-gray-500" /> : <IconMic className="w-4 fill-gray-500" />}
|
||||
</div>
|
||||
{/* <User uid={uid} interactive={false} /> */}
|
||||
{/* {userData[uid]?.name} */}
|
||||
@@ -59,6 +64,20 @@ const VoiceManagement = ({ info }: Props) => {
|
||||
})}
|
||||
|
||||
</ul>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ul className='flex justify-between'>
|
||||
<li role={"button"} onClick={setDeafen.bind(null, !deafen)} className="py-2 px-3 rounded bg-gray-100 dark:bg-gray-900">
|
||||
{deafen ? <IconHeadphoneOff className="fill-gray-700 dark:fill-gray-300" /> : <IconHeadphone className="fill-gray-700 dark:fill-gray-300" />}
|
||||
</li>
|
||||
<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" />}
|
||||
|
||||
</li>
|
||||
</ul>
|
||||
<StyledButton onClick={leave} className='bg-red-600 hover:!bg-red-700 text-center'>
|
||||
<IconCallOff className="m-auto" />
|
||||
</StyledButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// import React from 'react';
|
||||
// import Tippy from '@tippyjs/react';
|
||||
import { useState } from 'react';
|
||||
// import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateChannelVisibleAside } from '../../../app/slices/channels';
|
||||
import { useAppSelector } from '../../../app/store';
|
||||
import IconHeadphone from '../../../assets/icons/headphone.svg';
|
||||
import Tooltip from '../../../common/component/Tooltip';
|
||||
import Dashboard from './Dashboard';
|
||||
// import Dashboard from './Dashboard';
|
||||
|
||||
type Props = {
|
||||
context?: "channel" | "dm"
|
||||
@@ -13,18 +15,24 @@ type Props = {
|
||||
}
|
||||
|
||||
const VoiceChat = ({ id, context = "channel" }: Props) => {
|
||||
const [dashboardVisible, setDashboardVisible] = useState(false);
|
||||
const { loginUser } = useAppSelector(store => { return { loginUser: store.authData.user }; });
|
||||
const dispatch = useDispatch();
|
||||
const { loginUser, contextData } = useAppSelector(store => { return { loginUser: store.authData.user, contextData: context == "channel" ? store.channels.byId[id] : store.users.byId[id] }; });
|
||||
const { t } = useTranslation("chat");
|
||||
const toggleDashboard = () => {
|
||||
setDashboardVisible(prev => !prev);
|
||||
if (context == "channel") {
|
||||
dispatch(updateChannelVisibleAside({
|
||||
id,
|
||||
aside: contextData.visibleAside == "voice" ? null : "voice"
|
||||
}));
|
||||
}
|
||||
// todo DM
|
||||
};
|
||||
if (!loginUser) return null;
|
||||
const visible = contextData.visibleAside == "voice";
|
||||
return (
|
||||
<Tooltip disabled={dashboardVisible} tip={t("voice")} placement="left">
|
||||
<Tooltip disabled={visible} tip={t("voice")} placement="left">
|
||||
<li className={`relative`} >
|
||||
<IconHeadphone className={dashboardVisible ? "fill-gray-600" : "fill-gray-500"} role="button" onClick={toggleDashboard} />
|
||||
{dashboardVisible && <Dashboard id={id} context={context} />}
|
||||
<IconHeadphone className={visible ? "fill-gray-600" : "fill-gray-500"} role="button" onClick={toggleDashboard} />
|
||||
</li>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user