feat: 增加了声网插件用户布局与基于用户维度的视图渲染

并重新封装抽象了组件,并修复了错误的错误边界组件的捕获
pull/64/head
moonrailgun 3 years ago
parent 928f1a25b2
commit feab2c240c

@ -971,6 +971,7 @@ importers:
ahooks: ^3.7.4 ahooks: ^3.7.4
react: 18.2.0 react: 18.2.0
styled-components: ^5.3.6 styled-components: ^5.3.6
zustand: ^4.1.5
dependencies: dependencies:
agora-rtc-react: 1.1.3_react@18.2.0 agora-rtc-react: 1.1.3_react@18.2.0
ahooks: 3.7.4_react@18.2.0 ahooks: 3.7.4_react@18.2.0
@ -978,6 +979,7 @@ importers:
'@types/styled-components': 5.1.26 '@types/styled-components': 5.1.26
react: 18.2.0 react: 18.2.0
styled-components: 5.3.6_react@18.2.0 styled-components: 5.3.6_react@18.2.0
zustand: 4.1.5_react@18.2.0
server/plugins/com.msgbyte.github: server/plugins/com.msgbyte.github:
specifiers: specifiers:
@ -36356,5 +36358,21 @@ packages:
use-sync-external-store: 1.2.0_react@18.2.0 use-sync-external-store: 1.2.0_react@18.2.0
dev: false dev: false
/zustand/4.1.5_react@18.2.0:
resolution: {integrity: sha512-PsdRT8Bvq22Yyh1tvpgdHNE7OAeFKqJXUxtJvj1Ixw2B9O2YZ1M34ImQ+xyZah4wZrR4lENMoDUutKPpyXCQ/Q==}
engines: {node: '>=12.7.0'}
peerDependencies:
immer: '>=9.0'
react: '>=16.8'
peerDependenciesMeta:
immer:
optional: true
react:
optional: true
dependencies:
react: 18.2.0
use-sync-external-store: 1.2.0_react@18.2.0
dev: true
/zwitch/1.0.5: /zwitch/1.0.5:
resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==} resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==}

@ -81,11 +81,9 @@ class AgoraService extends TcService {
} }
this.registerLocalDb(require('../models/agora-meeting').default); this.registerLocalDb(require('../models/agora-meeting').default);
this.registerAction('generateToken', this.generateToken, { this.registerAction('generateJoinInfo', this.generateJoinInfo, {
params: { params: {
channelName: 'string', channelName: 'string',
appId: { type: 'string', optional: true },
appCert: { type: 'string', optional: true },
}, },
}); });
this.registerAction('getChannelUserList', this.getChannelUserList, { this.registerAction('getChannelUserList', this.getChannelUserList, {
@ -104,18 +102,14 @@ class AgoraService extends TcService {
}); });
} }
generateToken( generateJoinInfo(
ctx: TcContext<{ ctx: TcContext<{
channelName: string; channelName: string;
appId?: string;
appCert?: string;
}> }>
) { ) {
const { const { channelName } = ctx.params;
channelName, const appId = this.serverAppId;
appId = this.serverAppId, const appCert = this.serverAppCertificate;
appCert = this.serverAppCertificate,
} = ctx.params;
if (!appId || !appCert) { if (!appId || !appCert) {
throw new Error('Agora.io AppId/AppCert not init'); throw new Error('Agora.io AppId/AppCert not init');
@ -139,7 +133,7 @@ class AgoraService extends TcService {
privilegeExpirationInSecond privilegeExpirationInSecond
); );
return token; return { appId, token };
} }
/** /**

@ -14,6 +14,7 @@
"devDependencies": { "devDependencies": {
"@types/styled-components": "^5.1.26", "@types/styled-components": "^5.1.26",
"react": "18.2.0", "react": "18.2.0",
"styled-components": "^5.3.6" "styled-components": "^5.3.6",
"zustand": "^4.1.5"
} }
} }

@ -1,6 +1,7 @@
import { IconBtn } from '@capital/component'; import { IconBtn } from '@capital/component';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useClient, useMicrophoneAndCameraTracks } from './client'; import { useClient, useMicrophoneAndCameraTracks } from './client';
import { useMeetingStore } from './store';
export const Controls: React.FC<{ export const Controls: React.FC<{
onClose: () => void; onClose: () => void;
@ -38,6 +39,7 @@ export const Controls: React.FC<{
const leaveChannel = async () => { const leaveChannel = async () => {
await client.leave(); await client.leave();
client.removeAllListeners(); client.removeAllListeners();
useMeetingStore.getState().clearUser();
// we close the tracks to perform cleanup // we close the tracks to perform cleanup
tracks[0].close(); tracks[0].close();
tracks[1].close(); tracks[1].close();

@ -0,0 +1,107 @@
import React, { useEffect, useRef, useState } from 'react';
import { getJWTUserInfo, isValidStr, showErrorToasts } from '@capital/common';
import type { IAgoraRTCRemoteUser } from 'agora-rtc-react';
import { useClient } from './client';
import { Videos } from './Videos';
import { Controls } from './Controls';
import { LoadingSpinner } from '@capital/component';
import { useMemoizedFn } from 'ahooks';
import { request } from '../request';
import styled from 'styled-components';
import { useMeetingStore } from './store';
const Root = styled.div`
.body {
flex: 1;
}
.controller {
text-align: center;
padding: 10px 0;
* + * {
margin-left: 10px;
}
}
`;
export interface MeetingViewProps {
meetingId: string;
onClose: () => void;
}
export const MeetingView: React.FC<MeetingViewProps> = React.memo((props) => {
const client = useClient();
const channelName = props.meetingId;
const [start, setStart] = useState<boolean>(false);
const initedRef = useRef(false);
const init = useMemoizedFn(async (channelName: string) => {
client.on('user-published', async (user, mediaType) => {
await client.subscribe(user, mediaType);
console.log('subscribe success');
if (mediaType === 'audio') {
user.audioTrack?.play();
}
useMeetingStore.getState().updateUserInfo(user);
});
client.on('user-unpublished', async (user, mediaType) => {
console.log('unpublished', user, mediaType);
await client.unsubscribe(user, mediaType);
if (mediaType === 'audio') {
user.audioTrack?.stop();
}
useMeetingStore.getState().updateUserInfo(user);
});
client.on('user-joined', (user) => {
console.log('user-joined', user);
useMeetingStore.getState().appendUser(user);
});
client.on('user-left', (user) => {
console.log('user-left', user);
useMeetingStore.getState().removeUser(user);
});
try {
const { _id } = await getJWTUserInfo();
const { data } = await request.post('generateJoinInfo', {
channelName,
});
const { appId, token } = data ?? {};
await client.join(appId, channelName, token, _id);
console.log('client.remoteUsers', client.remoteUsers);
setStart(true);
} catch (err) {
showErrorToasts(err);
}
});
useEffect(() => {
if (initedRef.current) {
return;
}
if (isValidStr(channelName)) {
init(channelName);
initedRef.current = true;
}
}, [channelName]);
return (
<Root>
<div className="body">
{start ? <Videos /> : <LoadingSpinner tip={'正在加入通话...'} />}
</div>
<div className="controller">
<Controls onClose={props.onClose} />
</div>
</Root>
);
});
MeetingView.displayName = 'MeetingView';

@ -0,0 +1,44 @@
import { UserName } from '@capital/component';
import { AgoraVideoPlayer, IAgoraRTCRemoteUser } from 'agora-rtc-react';
import React from 'react';
import styled from 'styled-components';
const Root = styled.div`
width: 95%;
height: auto;
position: relative;
background-color: #333;
border-radius: 10px;
aspect-ratio: 16/9;
justify-self: center;
align-self: center;
.player {
width: 100%;
height: 100%;
}
.name {
position: absolute;
left: 0;
bottom: 0;
padding: 4px 8px;
}
`;
export const VideoView: React.FC<{
user: IAgoraRTCRemoteUser;
}> = (props) => {
const user = props.user;
return (
<Root>
{user.hasVideo && (
<AgoraVideoPlayer className="player" videoTrack={user.videoTrack} />
)}
<UserName className="name" userId={String(user.uid)} />
</Root>
);
};
VideoView.displayName = 'VideoView';

@ -1,34 +1,32 @@
import { AgoraVideoPlayer, IAgoraRTCRemoteUser } from 'agora-rtc-react'; import { AgoraVideoPlayer } from 'agora-rtc-react';
import React from 'react'; import React from 'react';
import styled from 'styled-components';
import { useMicrophoneAndCameraTracks } from './client';
import { useMeetingStore } from './store';
import { VideoView } from './VideoView';
export const Videos: React.FC<{ const Root = styled.div`
users: IAgoraRTCRemoteUser[]; height: 70vh;
}> = React.memo((props) => { /* align-self: flex-start; */
const { users } = props; display: grid;
grid-template-columns: repeat(auto-fit, minmax(440px, 1fr));
`;
export const Videos: React.FC = React.memo(() => {
const users = useMeetingStore((state) => state.users);
const { ready, tracks } = useMicrophoneAndCameraTracks();
return ( return (
<div> <Root>
<div className="videos"> {/* AgoraVideoPlayer component takes in the video track to render the stream,
{/* AgoraVideoPlayer component takes in the video track to render the stream,
you can pass in other props that get passed to the rendered div */} you can pass in other props that get passed to the rendered div */}
<AgoraVideoPlayer className="vid" videoTrack={tracks[1]} /> {ready && <AgoraVideoPlayer className="vid" videoTrack={tracks[1]} />}
{users.length > 0 && {users.length > 0 &&
users.map((user) => { users.map((user) => {
if (user.videoTrack) { return <VideoView key={user.uid} user={user} />;
return ( })}
<AgoraVideoPlayer </Root>
className="vid"
videoTrack={user.videoTrack}
key={user.uid}
/>
);
} else {
return null;
}
})}
</div>
</div>
); );
}); });
Videos.displayName = 'Videos'; Videos.displayName = 'Videos';

@ -9,9 +9,5 @@ const config: ClientConfig = {
codec: 'vp8', codec: 'vp8',
}; };
// TODO 应该从本地设置或者远程中获取
export const appId = ''; //ENTER APP ID HERE
export const token = '';
export const useClient = createClient(config); export const useClient = createClient(config);
export const useMicrophoneAndCameraTracks = createMicrophoneAndCameraTracks(); export const useMicrophoneAndCameraTracks = createMicrophoneAndCameraTracks();

@ -1,5 +1,5 @@
import { showToasts } from '@capital/common'; import { showToasts } from '@capital/common';
import { PortalAdd, PortalRemove, ErrorBoundary } from '@capital/component'; import { PortalAdd, PortalRemove } from '@capital/component';
import React from 'react'; import React from 'react';
import { FloatMeetingWindow } from './window'; import { FloatMeetingWindow } from './window';
@ -21,14 +21,12 @@ export function startFastMeeting(meetingId: string) {
currentMeeting = meetingId; currentMeeting = meetingId;
const key = PortalAdd( const key = PortalAdd(
<ErrorBoundary> <FloatMeetingWindow
<FloatMeetingWindow meetingId={meetingId}
meetingId={meetingId} onClose={() => {
onClose={() => { PortalRemove(key);
PortalRemove(key); currentMeeting = null;
currentMeeting = null; }}
}} />
/>
</ErrorBoundary>
); );
} }

@ -0,0 +1,50 @@
import type { IAgoraRTCRemoteUser } from 'agora-rtc-react';
import create from 'zustand';
interface MeetingState {
/**
*
*/
users: IAgoraRTCRemoteUser[];
appendUser: (user: IAgoraRTCRemoteUser) => void;
removeUser: (user: IAgoraRTCRemoteUser) => void;
clearUser: () => void;
/**
*
*/
updateUserInfo: (user: IAgoraRTCRemoteUser) => void;
}
export const useMeetingStore = create<MeetingState>((set) => ({
users: [],
appendUser: (user: IAgoraRTCRemoteUser) => {
set((state) => ({
users: [...state.users, user],
}));
},
removeUser: (user: IAgoraRTCRemoteUser) => {
set((state) => {
return {
users: state.users.filter((_u) => _u.uid !== user.uid),
};
});
},
clearUser: () => {
set({ users: [] });
},
updateUserInfo: (user: IAgoraRTCRemoteUser) => {
set((state) => {
const users = [...state.users];
const targetUserIndex = state.users.findIndex((u) => u.uid === user.uid);
if (targetUserIndex === -1) {
return {};
}
users[targetUserIndex] = user;
return {
users,
};
});
},
}));

@ -1,12 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useState } from 'react';
import { getJWTUserInfo, isValidStr, showErrorToasts } from '@capital/common';
import type { IAgoraRTCRemoteUser } from 'agora-rtc-react';
import styled from 'styled-components'; import styled from 'styled-components';
import { appId, token, useClient } from './client'; import { ErrorBoundary } from '@capital/component';
import { Videos } from './Videos'; import { MeetingView, MeetingViewProps } from './MeetingView';
import { Controls } from './Controls';
import { LoadingSpinner } from '@capital/component';
import { useMemoizedFn } from 'ahooks';
const FloatWindow = styled.div` const FloatWindow = styled.div`
z-index: 100; z-index: 100;
@ -21,38 +16,6 @@ const FloatWindow = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.body {
flex: 1;
.videos {
height: 70vh;
align-self: flex-start;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(440px, 1fr));
justify-items: center;
align-items: center;
.vid {
height: 95%;
width: 95%;
position: relative;
background-color: black;
border-width: 1px;
border-color: #38373a;
border-style: solid;
}
}
}
.controller {
text-align: center;
padding: 10px 0;
* + * {
margin-left: 10px;
}
}
.folder-btn { .folder-btn {
background-color: var(--tc-content-background-color); background-color: var(--tc-content-background-color);
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
@ -72,92 +35,25 @@ const FloatWindow = styled.div`
/** /**
* *
*/ */
export const FloatMeetingWindow: React.FC<{ export const FloatMeetingWindow: React.FC<MeetingViewProps> = React.memo(
meetingId: string; (props) => {
onClose: () => void; const [folder, setFolder] = useState(false);
}> = React.memo((props) => {
const [folder, setFolder] = useState(false); return (
const client = useClient(); <FloatWindow
const channelName = props.meetingId; style={{
const [users, setUsers] = useState<IAgoraRTCRemoteUser[]>([]); transform: folder ? 'translateY(-100%)' : 'none',
const [start, setStart] = useState<boolean>(false); }}
const initedRef = useRef(false); >
<ErrorBoundary>
const init = useMemoizedFn(async (channelName: string) => { <MeetingView {...props} />
client.on('user-published', async (user, mediaType) => { </ErrorBoundary>
await client.subscribe(user, mediaType);
console.log('subscribe success'); <div className="folder-btn" onClick={() => setFolder(!folder)}>
if (mediaType === 'video') { {folder ? '展开' : '收起'}
setUsers((prevUsers) => { </div>
return [...prevUsers, user]; </FloatWindow>
}); );
} }
if (mediaType === 'audio') { );
user.audioTrack?.play();
}
});
client.on('user-unpublished', (user, type) => {
console.log('unpublished', user, type);
if (type === 'audio') {
user.audioTrack?.stop();
}
if (type === 'video') {
setUsers((prevUsers) => {
return prevUsers.filter((User) => User.uid !== user.uid);
});
}
});
client.on('user-left', (user) => {
console.log('leaving', user);
setUsers((prevUsers) => {
return prevUsers.filter((User) => User.uid !== user.uid);
});
});
const { _id } = await getJWTUserInfo();
try {
await client.join(appId, channelName, token, _id);
setStart(true);
} catch (err) {
showErrorToasts(err);
}
});
useEffect(() => {
if (initedRef.current) {
return;
}
if (isValidStr(channelName)) {
init(channelName);
initedRef.current = true;
}
}, [channelName]);
return (
<FloatWindow
style={{
transform: folder ? 'translateY(-100%)' : 'none',
}}
>
<div className="body">
{start ? (
<Videos users={users} />
) : (
<LoadingSpinner tip={'正在加入通话...'} />
)}
</div>
<div className="controller">
<Controls onClose={props.onClose} />
</div>
<div className="folder-btn" onClick={() => setFolder(!folder)}>
{folder ? '展开' : '收起'}
</div>
</FloatWindow>
);
});
FloatMeetingWindow.displayName = 'FloatMeetingWindow'; FloatMeetingWindow.displayName = 'FloatMeetingWindow';

@ -0,0 +1,3 @@
import { createPluginRequest } from '@capital/common';
export const request = createPluginRequest('com.msgbyte.agora');
Loading…
Cancel
Save