feat: audio quality and mute

This commit is contained in:
Tristan Yang
2023-03-28 23:00:45 +08:00
parent 796afda354
commit bd6b02d19a
10 changed files with 189 additions and 54 deletions
+28 -1
View File
@@ -5,6 +5,8 @@ export type VoiceBasicInfo = {
id: number,
}
export type VoicingInfo = {
downlinkNetworkQuality?: number,
muted: boolean,
members: number[]
} & VoiceBasicInfo
export type VoiceInfo = {
@@ -31,8 +33,25 @@ const voiceSlice = createSlice({
initialState,
reducers: {
updateVoicingInfo(state, { payload }: PayloadAction<VoicingInfo | null>) {
if (payload && state.voicing) {
const { members, ...rest } = payload;
const tmpArr = [...state.voicing.members, ...members];
console.log("tmmp arr", tmpArr);
state.voicing = { ...rest, members: Array.from(new Set(tmpArr)) };
}
state.voicing = payload;
},
updateMuteStatus(state, { payload }: PayloadAction<boolean>) {
if (state.voicing) {
state.voicing.muted = payload;
}
},
updateVoicingNetworkQuality(state, { payload }: PayloadAction<number>) {
if (state.voicing) {
state.voicing.downlinkNetworkQuality = payload;
}
},
updateVoiceList(state, { payload }: PayloadAction<VoiceInfo[]>) {
state.list = payload;
},
@@ -41,6 +60,14 @@ const voiceSlice = createSlice({
if (!state.voicing.members.includes(payload)) {
state.voicing.members.push(payload);
}
} else {
// tricky?
state.voicing = {
id: 0,
context: "channel",
muted: false,
members: [payload]
};
}
},
removeVoiceMember(state, { payload }: PayloadAction<number>) {
@@ -54,5 +81,5 @@ const voiceSlice = createSlice({
},
});
export const { addVoiceMember, removeVoiceMember, updateVoiceList, updateVoicingInfo } = voiceSlice.actions;
export const { addVoiceMember, removeVoiceMember, updateVoiceList, updateVoicingInfo, updateVoicingNetworkQuality, updateMuteStatus } = voiceSlice.actions;
export default voiceSlice.reducer;
+10
View File
@@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke="#dc2626" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1027_7306)">
<path d="M1 0.99997L23 23M15 9.33997V3.99997C15.0007 3.256 14.725 2.53829 14.2264 1.98617C13.7277 1.43405 13.0417 1.08691 12.3015 1.01214C11.5613 0.937375 10.8197 1.14031 10.2207 1.58156C9.62172 2.0228 9.20805 2.67088 9.06 3.39997M17 16.95C16.0238 17.9464 14.7721 18.6284 13.4056 18.9086C12.0391 19.1887 10.62 19.0542 9.3305 18.5223C8.04096 17.9903 6.93976 17.0852 6.16817 15.9231C5.39658 14.761 4.98979 13.3949 5 12V9.99997M19 9.99997V12C18.9996 12.4124 18.9628 12.824 18.89 13.23M12 19V23M8 23H16M9 8.99997V12C9.00052 12.5929 9.17675 13.1724 9.50643 13.6653C9.83611 14.1582 10.3045 14.5423 10.8523 14.7691C11.4002 14.996 12.0029 15.0554 12.5845 14.9398C13.1661 14.8243 13.7005 14.539 14.12 14.12L9 8.99997Z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1027_7306">
<rect width="24" height="24" fill="#dc2626"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+2 -3
View File
@@ -1,4 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.25 11C18.6297 11 18.9435 11.2822 18.9932 11.6482L19 11.75V12.25C19 15.8094 16.245 18.7254 12.751 18.9817L12.75 21.25C12.75 21.6642 12.4142 22 12 22C11.6203 22 11.3065 21.7178 11.2568 21.3518L11.25 21.25L11.25 18.9818C7.83323 18.7316 5.12283 15.938 5.00406 12.4863L5 12.25V11.75C5 11.3358 5.33579 11 5.75 11C6.1297 11 6.44349 11.2822 6.49315 11.6482L6.5 11.75V12.25C6.5 15.077 8.73445 17.3821 11.5336 17.4956L11.75 17.5H12.25C15.077 17.5 17.3821 15.2656 17.4956 12.4664L17.5 12.25V11.75C17.5 11.3358 17.8358 11 18.25 11ZM12 2C14.2091 2 16 3.79086 16 6V12C16 14.2091 14.2091 16 12 16C9.79086 16 8 14.2091 8 12V6C8 3.79086 9.79086 2 12 2Z" fill="#78787C"/>
<svg width="24" height="24" viewBox="0 0 24 24" stroke="#78787C" xmlns="http://www.w3.org/2000/svg">
<path d="M19 10V12C19 13.8565 18.2625 15.637 16.9497 16.9497C15.637 18.2625 13.8565 19 12 19M12 19C10.1435 19 8.36301 18.2625 7.05025 16.9497C5.7375 15.637 5 13.8565 5 12V10M12 19V23M8 23H16M12 1C11.2044 1 10.4413 1.31607 9.87868 1.87868C9.31607 2.44129 9 3.20435 9 4V12C9 12.7956 9.31607 13.5587 9.87868 14.1213C10.4413 14.6839 11.2044 15 12 15C12.7956 15 13.5587 14.6839 14.1213 14.1213C14.6839 13.5587 15 12.7956 15 12V4C15 3.20435 14.6839 2.44129 14.1213 1.87868C13.5587 1.31607 12.7956 1 12 1Z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 771 B

After

Width:  |  Height:  |  Size: 675 B

+32
View File
@@ -0,0 +1,32 @@
import clsx from 'clsx';
import React from 'react';
type Props = {
strength?: number
}
// 0:质量未知。
// 1:质量极好。
// 2:用户主观感觉和极好差不多,但码率可能略低于极好。
// 3:用户主观感受有瑕疵但不影响沟通。
// 4:勉强能沟通但不顺畅。
// 5:网络质量非常差,基本不能沟通。
// 6: 网络连接断开,完全无法沟通。
const Signal = ({ strength = 0 }: Props) => {
const finalStrength = 6 - strength;
const color = finalStrength <= 2 ? `bg-red-700` : (finalStrength == 3 ? `bg-yellow-700` : `bg-green-700`);
let bgColor = ` bg-gray-500`;
return (
<div className='w-6 h-6 flex-center'>
<div className="w-[18px] h-[15px] flex items-end gap-[2px]">
<span className={clsx('h-1/5 w-[2px]', finalStrength == 0 ? bgColor : color)}></span>
<span className={clsx('h-2/5 w-[2px]', finalStrength <= 1 ? bgColor : color)}></span>
<span className={clsx('h-3/5 w-[2px]', finalStrength <= 2 ? bgColor : color)}></span>
<span className={clsx('h-4/5 w-[2px]', finalStrength <= 3 ? bgColor : color)}></span>
<span className={clsx('h-full w-[2px]', finalStrength <= 4 ? bgColor : color)}></span>
</div>
</div>
);
};
export default Signal;
+51 -21
View File
@@ -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, updateVoicingInfo } from '../../app/slices/voice';
import { addVoiceMember, removeVoiceMember, updateMuteStatus, updateVoicingInfo, updateVoicingNetworkQuality } from '../../app/slices/voice';
import { useAppSelector } from '../../app/store';
// type Props = {}
@@ -31,22 +31,45 @@ const Voice = () => {
agoraEngine.on("user-published", async (user, mediaType) => {
// Subscribe to the remote user when the SDK triggers the "user-published" event.
await agoraEngine.subscribe(user, mediaType);
dispatch(addVoiceMember(+user.uid));
// console.log("subscribe success");
// Subscribe and play the remote audio track.
console.log(user.uid + "has joined the channel");
console.log(user, " has published at the channel");
if (mediaType == "audio") {
// Play the remote audio track.
user.audioTrack?.play();
window.VOICE_TRACK_MAP[+user.uid] = user.audioTrack;
// console.log("Remote user connected: " + user.uid);
}
// Listen for the "user-unpublished" event.
agoraEngine.on("user-unpublished", user => {
dispatch(removeVoiceMember(+user.uid as number));
console.log(user.uid + "has left the channel");
// console.log("Remote user has left the channel");
});
// 信号强度
agoraEngine.on("network-quality", (qlt) => {
const { downlinkNetworkQuality } = qlt;
dispatch(updateVoicingNetworkQuality(downlinkNetworkQuality));
});
// 用户状态变化
agoraEngine.on("user-info-updated", (uid, msg) => {
console.log("user-info-updated", uid, msg);
switch (msg) {
case "mute-audio":
// todo
break;
case "unmute-audio":
// todo
break;
default:
break;
}
// const { downlinkNetworkQuality } = qlt;
// dispatch(updateVoicingNetworkQuality(downlinkNetworkQuality));
});
});
// 有新用户加入
agoraEngine.on("user-joined", async (user) => {
console.log(user, " has joined the channel");
dispatch(addVoiceMember(+user.uid));
});
window.VOICE_CLIENT = agoraEngine;
};
@@ -56,7 +79,8 @@ const Voice = () => {
}
return () => {
if (window.VOICE_CLIENT) {
if (window.VOICE_CLIENT && localTrack) {
localTrack.close();
window.VOICE_CLIENT.leave();
}
// window.VOICE_CLIENT=null
@@ -66,17 +90,17 @@ const Voice = () => {
return null;
};
let localTrack: IMicrophoneAudioTrack | null = null;
type VoiceProps = {
id: number,
context?: "channel" | "dm"
}
const useVoice = ({ id, context = "channel" }: VoiceProps) => {
const dispatch = useDispatch();
const { voiceInfo, joined, loginUid = 0 } = useAppSelector(store => {
const { voicingInfo } = useAppSelector(store => {
return {
loginUid: store.authData.user?.uid,
voiceInfo: store.voice.voicing,
joined: !!store.voice.voicing
// loginUid: store.authData.user?.uid,
voicingInfo: store.voice.voicing
};
});
const [generateToken] = useLazyGetAgoraTokenQuery();
@@ -90,12 +114,13 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => {
await window.VOICE_CLIENT.join(app_id, channel_name, agora_token, uid);
console.table(data);
// Create a local audio track from the microphone audio.
window.VOICE_TRACK_MAP[loginUid] = await AgoraRTC.createMicrophoneAudioTrack();
localTrack = await AgoraRTC.createMicrophoneAudioTrack();
// Publish the local audio track in the channel.
await window.VOICE_CLIENT.publish(window.VOICE_TRACK_MAP[loginUid]);
console.log("Publish success!");
await window.VOICE_CLIENT.publish(localTrack);
console.log("Publish success!,joined the channel");
dispatch(updateVoicingInfo({
muted: false,
id,
context,
members: [uid]
@@ -105,20 +130,25 @@ const useVoice = ({ id, context = "channel" }: VoiceProps) => {
setJoining(false);
};
const leave = async () => {
if (window.VOICE_CLIENT) {
(window.VOICE_TRACK_MAP[loginUid] as IMicrophoneAudioTrack).close();
if (window.VOICE_CLIENT && localTrack) {
localTrack.close();
await window.VOICE_CLIENT.leave();
dispatch(updateVoicingInfo(null));
// window.VOICE_TRACK_MAP[loginUid]?.stop();
// window.VOICE_TRACK_MAP[loginUid] = null;
}
};
const setMute = (mute: boolean) => {
if (localTrack) {
localTrack.setMuted(mute);
dispatch(updateMuteStatus(mute));
}
};
return {
setMute,
leave,
// canVoice,
voiceInfo,
voicingInfo,
joining,
joined,
joined: !!voicingInfo,
joinVoice
};
};
+27 -19
View File
@@ -3,9 +3,11 @@ import { useAppSelector } from '../../app/store';
import User from '../../common/component/User';
import IconHeadphone from '../../assets/icons/headphone.svg';
import IconMic from '../../assets/icons/mic.on.svg';
import IconSoundOn from '../../assets/icons/sound.on.svg';
import IconSignal from '../../assets/icons/signal.svg';
import IconMicOff from '../../assets/icons/mic.off.svg';
// import IconSoundOn from '../../assets/icons/sound.on.svg';
// import IconSignal from '../../assets/icons/signal.svg';
import { useVoice } from '../../common/component/Voice';
import Signal from '../../common/component/Signal';
type Props = {
id: number,
@@ -13,30 +15,31 @@ type Props = {
}
const RTCWidget = ({ id, context = "channel" }: Props) => {
const { leave } = useVoice({ context, id });
const { loginUser, voicingInfo, channelData, userData } = useAppSelector(store => {
const { leave, voicingInfo, setMute } = useVoice({ context, id });
const { loginUser, channelData, userData } = useAppSelector(store => {
return {
userData: store.users.byId,
channelData: store.channels.byId,
loginUser: store.authData.user,
voicingInfo: store.voice.voicing
};
});
if (!loginUser) return null;
if (!loginUser || !voicingInfo) return null;
const name = voicingInfo.context == "channel" ? channelData[voicingInfo.id]?.name : userData[voicingInfo.id]?.name;
if (!name) return null;
return (
<div className='bg-gray-100 dark:bg-gray-900 flex flex-col p-2 rounded-3xl m-3 text-sm'>
{voicingInfo &&
<div className="flex justify-between items-center border-b border-gray-100 dark:border-gray-800 pb-3 mb-2">
<div className="flex items-center gap-1">
<IconSignal />
<div className="flex flex-col">
<span className='text-green-800'>Voice Connected</span>
<span className='text-gray-600 dark:text-gray-400 text-xs'>{voicingInfo.context == "channel" ? "Channel" : "DM"} / {voicingInfo.context == "channel" ? channelData[voicingInfo.id].name : userData[voicingInfo.id].name}</span>
</div>
{/* {voicingInfo && */}
<div className="flex justify-between items-center border-b border-gray-100 dark:border-gray-800 pb-3 mb-2">
<div className="flex flex-1 items-center gap-1">
<Signal strength={voicingInfo.downlinkNetworkQuality} />
<div className="flex flex-col">
<span className='text-green-800'>Voice Connected</span>
<span className='text-gray-600 dark:text-gray-400 text-xs truncate max-w-[170px]' >{voicingInfo.context == "channel" ? "Channel" : "DM"} / {voicingInfo.context == "channel" ? channelData[voicingInfo.id].name : userData[voicingInfo.id].name}</span>
</div>
<IconHeadphone onClick={leave} role="button" className="fill-red-600" />
</div>
}
<IconHeadphone onClick={leave} role="button" className="fill-red-600" />
</div>
{/* } */}
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<User uid={loginUser.uid} compact />
@@ -45,10 +48,15 @@ const RTCWidget = ({ id, context = "channel" }: Props) => {
<span className='text-gray-400 text-xs'>#{loginUser.uid}</span>
</div>
</div>
<div className="flex gap-2">
<IconSoundOn role="button" />
<IconMic role="button" />
{/* {voicingInfo && */}
<div className="flex gap-2 px-1">
{/* <IconSoundOn role="button" /> */}
{voicingInfo.muted ?
<IconMicOff className="w-5" onClick={setMute.bind(null, false)} role="button" />
:
<IconMic className="w-5" onClick={setMute.bind(null, true)} role="button" />}
</div>
{/* } */}
</div>
</div>
);
+4 -2
View File
@@ -10,11 +10,13 @@ type Props = {
}
const Dashboard = ({ context = "channel", id }: Props) => {
const { joinVoice, joined, joining, voiceInfo } = useVoice({ id, context });
const { joinVoice, joined, joining, voicingInfo } = 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'>{joined ? <VoiceManagement info={voiceInfo} /> : <JoinVoice join={joinVoice} joining={joining} />}</div>;
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} />}
</div>;
};
export default Dashboard;
+1 -1
View File
@@ -11,7 +11,7 @@ const JoinVoice = ({ joining, join }: Props) => {
Joining
</div>;
return (
<div>
<div className='w-full h-full flex-center p-4'>
<StyledButton className='mini' onClick={join}>Join</StyledButton>
</div>
);
+33 -6
View File
@@ -1,22 +1,49 @@
import clsx from 'clsx';
import React from 'react';
import { VoiceInfo } from '../../../app/slices/voice';
import { VoicingInfo } from '../../../app/slices/voice';
import { useAppSelector } from '../../../app/store';
import User from '../../../common/component/User';
import Avatar from '../../../common/component/Avatar';
import IconMic from '../../../assets/icons/mic.on.svg';
import IconMicOff from '../../../assets/icons/mic.off.svg';
// import User from '../../../common/component/User';
type Props = {
info: VoiceInfo | null
info: VoicingInfo | null
}
const VoiceManagement = ({ info }: Props) => {
const userData = useAppSelector(store => store.users.byId);
if (!info) return null;
const { context, id, members } = info;
const nameClass = clsx(`text-sm text-gray-500 max-w-[190px] truncate font-semibold dark:text-white`);
return (
<div>
<ul>
<div className='w-full h-full py-2'>
<ul className='flex flex-col gap-2'>
{members.map((uid) => {
const curr = userData[uid];
if (!curr) return null;
return <li key={uid}>
<User uid={uid} interactive={false} />
<div className="flex items-center justify-between gap-6 ">
<div className="flex items-center gap-2">
<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">
<IconMic className="w-4" role="button" />
</div>
</div>
{/* <User uid={uid} interactive={false} /> */}
{/* {userData[uid]?.name} */}
</li>;
})}
+1 -1
View File
@@ -23,7 +23,7 @@ const VoiceChat = ({ id, context = "channel" }: Props) => {
return (
<Tooltip disabled={dashboardVisible} tip={t("voice")} placement="left">
<li className={`relative`} >
<IconHeadphone className="fill-gray-500" role="button" onClick={toggleDashboard} />
<IconHeadphone className={dashboardVisible ? "fill-gray-600" : "fill-gray-500"} role="button" onClick={toggleDashboard} />
{dashboardVisible && <Dashboard id={id} context={context} />}
</li>
</Tooltip>