当前位置: 首页 > news >正文

实战:一天开发一款内置游戏直播的国产版Discord应用【附源码】

游戏直播是Discord产品的核心功能之一,本教程教大家如何1天内开发一款内置游戏直播的国产版Discord应用,用户不仅可以通过IM聊天,也可以进行语聊,看游戏直播,甚至自己进行游戏直播,无任何实时音视频底层技术的Web开发者同样适用,效果如下:

开整!

Step1 初始化

本项目基于环信超级社区的实例项目, 所以我们先从Circle-Demo-Web这个仓库开启做初始化

  1. 克隆项目 git clone https://github.com/easemob/Circle-Demo-Web.git
  2. 安装依赖 npm install
  3. 设置appKey src/utils/WebIM.js 中设置appKey,AppKey为环信后台项目对应的key,注册环信,https://console.easemob.com/user/register,登录console后台获取Appkey
    • appKey为环信后台项目对应的key, 如何开通可见开通配置环信即时通讯 IM 服务
  4. 运行项目 npm run start

运行后, 登录完毕效果如下,
在这里插入图片描述

与discord设计逻辑相似, 左边功能区有

  • 个人信息页
  • 好友会话页
  • 当前加入的频道
  • 创建新频道
  • 加入服务器

超级社区的逻辑为

社区(Server)、频道(Channel) 和子区(Thread) 三层结构

社区为一个独立的结构, 不同社区直接相互隔离, 社区包含不同的频道, 代表了不同的话题, 用户在频道中聊天, 而针对一条单独信息产生的回复为子区.

我们本次的项目主要集中在频道部分, 需要加入一个服务器后, 创建一个测试社区, 保证你具有管理员权限.

Step2 协议设置

在这里插入图片描述

我们的目标是尽量利用现有api扩展功能, 有几个问题需要解决

  1. 如何区分普通频道和游戏频道?
  2. 如何区分当前频道是否有玩家直播, 如果有直播如何获取玩家信息, 第二玩家的状态?
  3. 多人聊天的状态?

如何区分普通频道和游戏频道

这里直接简单采用频道前缀做特殊区分, 创建频道前缀带video-的识别为游戏频道, 同时将渲染内容做替换

// views/Channel/index.jsconst isVideoChannel = useMemo(() => {return currentChannelInfo?.name?.startsWith("video-");
}, [currentChannelInfo]);const renderTextChannel = () => {
// 原来的渲染逻辑
return (<><MessageListmessageInfo={messageInfo}channelId={channelId}handleOperation={handleOperation}className={s.messageWrap}/><div className={s.iptWrap}><Input chatType={CHAT_TYPE.groupChat} fromId={channelId} /></div></>
);
}const renderStreamChannel = () => {
// 先填充一个占位符
return (<>This is a Stream Channel<>
);
}return (...<div className={s.contentWrap}>{isVideoChannel ? renderStreamChannel() : renderTextChannel()}</div>...
);

如果需要区分图标, 可以搜索channelNameWrap, 分别在channelItemChannel/components/Header中添加一个css类, 通过这个类设置图标图片

如何区分当前频道是否有玩家直播, 如果有直播如何获取玩家信息, 第二玩家的状态?

我们可以复用在频道中发送消息的机制, 直播开始, 结束都可以当做一条特殊的消息发送, 只不过这条消息不承载用户的信息, 而是表达用户上下播的行为

当然这个机制存在一定实时性的问题, 不过大致是可行的.

首先我们来看一条普通的消息是如何发送的

  // components/input//发消息const sendMessage = useCallback(() => {if (!text) return;getTarget().then((target) => {let msg = createMsg({chatType,type: "txt",to: target,msg: convertToMessage(ref.current.innerHTML),isChatThread: props.isThread});setText("");deliverMsg(msg).then(() => {if (msg.isChatThread) {setThreadMessage({message: { ...msg, from: WebIM.conn.user },fromId: target});} else {insertChatMessage({chatType,fromId: target,messageInfo: {list: [{ ...msg, from: WebIM.conn.user }]}});scrollBottom();}});});}, [text, props, getTarget, chatType, setThreadMessage, insertChatMessage]);

去除掉与输入框逻辑耦合的部分, 可以分为两步, createMsg创建消息, deliverMsg发送消息, 这两个功能都是环信SDK功能的封装, 经过查阅文档, 它支持发送自定义消息.
在utils中新建一个stream.js文件来封装直播的逻辑

// utils/stream.js
const sendStreamMessage = (content, channelId) => {let msg = createMsg({chatType: CHAT_TYPE.groupChat,type: "custom",to: channelId,ext: {type: "stream",...content,},});return deliverMsg(msg).then(() => {console.log("发送成功");}).catch(console.error);
};

它接收content表示我们的额外信息, 用户名和上下播状态, channelId区分不同的channel, 对它的调用可以如下

// 定义在 utils/stream.js 中
const CMD_START_STREAM = "start";
const CMD_END_STREAM = "end";// 上播
sendStreamMessage({user: userInfo?.username,status: CMD_START_STREAM,},channelId
);
// 下播
sendStreamMessage({user: userInfo?.username,status: CMD_END_STREAM,},channelId
);

第二玩家的状态可以类比第一个玩家用额外的自定义消息实现, 这里不做重复.

关于自定义消息, 原本它的作用是邀请用户加入频道, 你可以在components/CustomMsg中找到, 我们要额外识别一下直播消息(可以渲染在消息列表里, 也可以直接屏蔽掉).

// components/CustomMsg/index.js
const isStream = message?.ext?.type === "stream";// 屏蔽
const renderStream = () => {
return (<>)
}
if (isStream) {return renderStream();
} else {...
}

多人聊天的状态?

我们引入声网RTC sdk, 每个进入直播房间的用户都对应维护一个声网客户端,
通过on事件感知远端视频/音频流.

根据文档 进行如下操作,

  1. 注册声网开发者, 并在后台创建一个测试项目
  2. 项目根目录创建.env文件, 存放api token等信息
# channel, uid 暂时设置为固定
REACT_APP_AGORA_APPID = your app id
REACT_APP_AGORA_CHANNEL = test 
REACT_APP_AGORA_TOKEN = your token
REACT_APP_AGORA_UID = 123xxx
  1. 添加声网sdk依赖 npm install agora-rtc-sdk-ng

我们在下一章中编写接入逻辑

声网RTC接入, 直播与语音实现

接入

views/Channel/components文件夹下新增一个组件StreamHandler, 该组件为后续我们处理游戏房间的组件, 先初步编写声网接入逻辑

// views/Channel/components/StreamHandler/index.jsconst options = {appId:process.env.REACT_APP_AGORA_APPID || "default id",channel: process.env.REACT_APP_AGORA_CHANNEL || "test",token:process.env.REACT_APP_AGORA_TOKEN ||"default token",uid: process.env.REACT_APP_AGORA_UID || "default uid",
};const StreamHandler =  (props) => {// 组件参数: 用户信息, 当前频道所有消息, 当前频道id, 是否开启本地语音const { userInfo, messageInfo, channelId, enableLocalVoice = false } = props;const [rtcClient, setRtcClient] = useState(null);// 声网client连接完成const [connectStatus, setConnectStatus] = useState(false);// RTC相关逻辑useEffect(() => {AgoraRTC.setLogLevel(3);const client = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" });// TODO: use right channelclient.join(options.appId, options.channel, options.token, userInfo?.username).then(() => {setConnectStatus(true);console.log("[Stream] join channel success");}).catch((e) => {console.log(e);});setRtcClient(client);return () => {// 销毁时, 自动退出RTC频道client.leave();setRtcClient(null);};}, []);return (<>{!connectStatus && <Spin tip="Loading" size="large" />}</>);
}// 我们需要全局状态中的userinfo, 映射一下到当前组件的props中
const mapStateToProps = ({ app }) => {return {userInfo: app.userInfo,};
};
export default memo(connect(mapStateToProps)(StreamHandler));

然后回到Channel中, 在之前的renderStreamChannel函数中添加上StreamHandler组件

// view/Channel/index.js
const [enableVoice, setEnableVoice] = useState(false);
const toggleVoice = () => {setEnableVoice((enable) => {return !enable;});
}// 保留了输入窗口, 可以在它的菜单栏中添加游戏频道独有的一些逻辑, 
// 这里我加入了开关本地语音的逻辑, 拓展Input的细节可以参照完整版代码
const renderStreamChannel = () => {return (<><div className={s.messageRowWrap}><StreamHandler messageInfo={messageInfo} channelId={channelId} enableLocalVoice={enableVoice} /></div><div className={s.iptWrap}><Input chatType={CHAT_TYPE.groupChat} fromId={channelId} extraMenuItems={renderStreamMenu()} /></div></>);}const renderStreamMenu = () => {return [{key: "voice",label: (<divclassName="circleDropItem"onClick={toggleVoice}><Iconname="person_wave_slash"size="24px"iconClass="circleDropMenuIcon"/><span className="circleDropMenuOp">{enableVoice ? "关闭语音" : "开启语音"}</span></div>),}];}

此时我们创建一个video-开题的游戏频道, 应该可以看到命令行中输出了RTC连接成功信息. [Stream] join channel success

音视频推流

接下来我们继续做实质的RTC推流逻辑, 及用户上下播的入口. 但在那之前, 先简单过一下声网RTC中的一些概念.

参考以下步骤实现音视频通话的逻辑:

  1. 调用 createClient 方法创建 AgoraRTCClient 对象。
  2. 调用 join 方法加入一个 RTC 频道,你需要在该方法中传入 App ID 、用户 ID、Token、频道名称。
  3. 先调用 createMicrophoneAudioTrack 通过麦克风采集的音频创建本地音频轨道对象,调用 createCameraVideoTrack 通过摄像头采集的视频创建本地视频轨道对象;然后调用 publish 方法,将这些本地音视频轨道对象当作参数即可将音视频发布到频道中。
  4. 当一个远端用户加入频道并发布音视频轨道时:
  5. 监听 client.on(“user-published”) 事件。当 SDK 触发该事件时,在这个事件回调函数的参数中你可以获取远端用户 AgoraRTCRemoteUser 对象 。
  6. 调用 subscribe 方法订阅远端用户 AgoraRTCRemoteUser 对象,获取远端用户的远端音频轨道 RemoteAudioTrack 和远端视频轨道 RemoteVideoTrack 对象。

在这里插入图片描述

(以上内容来自声网官方文档)

在上面的接入中, 我们已经完成了创建对象并加入频道两步.
在RTC中, 可以传输音频和视频信号, 由于单个RTC客户端要传输不同种类的数据, 每个单独的音视频源被分成不同的track(由于它们都是实时不断产生的, 我们称作流), 随后通过publish方法, 将我们本地的信号源交付给RTC客户端传输.
随后通过user-published事件的回调来在其他用户发布信号源时进行处理, 首先需要subscribe该用户来获取后续数据, 随后根据不同类型的信号流做处理.
离开时需要关闭本地当前的信号源, 并退出RTC客户端.
最后通过user-unpublished事件监听其他用户退出, 移除它们对应的信号流.

逻辑理清楚后代码就很容易看懂了

// views/Channel/components/StreamHandler/index.js
const StreamHandler = (props) => {...// 本地视频元素const localVideoEle = useRef(null);// 远程视频元素const canvasEle = useRef(null);const [rtcClient, setRtcClient] = useState(null);const [connectStatus, setConnectStatus] = useState(false);// 当前直播的用户const [remoteUser, setRemoteUser] = useState(null);// 远程音视频trackconst [remoteVoices, setRemoteVoices] = useState([]);const [remoteVideo, setRemoteVideo] = useState(null);// RTC相关逻辑useEffect(() => {...// client.join 后// 监听新用户加入client.on("user-published", async (user, mediaType) => {// auto subscribe when users comingawait client.subscribe(user, mediaType);console.log("[Stream] subscribe success on user ", user);if (mediaType === "video") {// 获取直播流if (remoteUser && remoteUser.uid !== user.uid) {// 只能有一个用户推视频流console.error("already in a call, can not subscribe another user ",user);return;}// 播放并记录下视频流const remoteVideoTrack = user.videoTrack;remoteVideoTrack.play(localVideoEle.current);setRemoteVideo(remoteVideoTrack);// can only have one remote video usersetRemoteUser(user);}if (mediaType === "audio") {// 获取音频流const remoteAudioTrack = user.audioTrack;// 去重if (remoteVoices.findIndex((item) => item.uid === user.uid) == -1) {remoteAudioTrack.play();// 添加到数组中setRemoteVoices([...remoteVoices,{ audio: remoteAudioTrack, uid: user.uid },]);}}});client.on("user-unpublished", (user) => {// 用户离开, 去除流信息console.log("[Stream] user-unpublished", user);removeUserStream(user);});setRtcClient(client);return () => {client.leave();setRtcClient(null);};}, []);const removeUserStream = (user) => {if (remoteUser && remoteUser.uid === user.uid) {setRemoteUser(null);setRemoteVideo(null);}setRemoteVoices(remoteVoices.filter((voice) => voice.uid !== user.uid));};
}

接着我们根据之前提到的自定义消息判断当前在播状态, 以最后一条自定义消息为准.

// views/Channel/components/StreamHandler/index.js
const StreamHandler = (props) => {const { userInfo, messageInfo, channelId, enableLocalVoice = false } = props;// 第一条 stream 消息, 用于判断直播状态const firstStreamMessage = useMemo(() => {return messageInfo?.list?.find((item) => item.type === "custom" && item?.ext?.type === "stream");}, [messageInfo]);// 是否有直播const hasRemoteStream =firstStreamMessage?.ext?.status === CMD_START_STREAM &&firstStreamMessage?.ext?.user !== userInfo?.username;// 本地直播状态const [localStreaming, setLocalStreaming] = useState(firstStreamMessage?.ext?.status === CMD_START_STREAM &&firstStreamMessage?.ext?.user === userInfo?.username);// 本地直播流状态const toggleLocalGameStream = () => {if (hasRemoteStream) {return;}setLocalStreaming(!localStreaming);};// 根据直播状态选择渲染return (<>{!connectStatus && <Spin tip="Loading" size="large" />}{hasRemoteStream ? (<RemoteStreamHandlerremoteUser={firstStreamMessage?.ext?.user}localVideoRef={localVideoEle}channelId={channelId}userInfo={userInfo}rtcClient={rtcClient}/>) : (<LocalStreamHandlerlocalStreaming={localStreaming}canvasRef={canvasEle}toggleLocalGameStream={toggleLocalGameStream}rtcClient={rtcClient}userInfo={userInfo}channelId={channelId}/>)}</>);
}

我们根据hasRemoteStream分成两种逻辑RemoteStreamHandlerLocalStreamHandler(可以先用div+文字的空实现占位), 首先我们来看本地游戏的逻辑

// view/Channel/components/StreamHandler/local_stream.js
const LocalStreamHandler = (props) => {const {toggleLocalGameStream,canvasRef,localStreaming,rtcClient,userInfo,channelId,} = props;const [localVideoStream, setLocalVideoStream] = useState(false);const localPlayerContainerRef = useRef(null);// 开启本地视频流useEffect(() => {if (!localPlayerContainerRef.current) return;const f = async () => {// 暂时使用视频代替游戏流let lgs = await AgoraRTC.createCameraVideoTrack();lgs.play(localPlayerContainerRef.current);setLocalGameStream(lgs);}f();}, [localPlayerContainerRef])const renderLocalStream = () => {return (<div style={{ height: "100%" }} ref={localPlayerContainerRef}></div>)}// 控制上下播const renderFloatButtons = () => {return (<FloatButton.Groupicon={<DesktopOutlined />}trigger="click"style={{ left: "380px" }}><FloatButtononClick={toggleLocalGameStream}icon={localStreaming ? <VideoCameraFilled /> : <VideoCameraOutlined />}tooltip={<div>{localStreaming ? "停止直播" : "开始直播"}</div>}/></FloatButton.Group>);};// 渲染: 悬浮窗和本地流return (<><div style={{ height: "100%" }}>{renderFloatButtons()}{renderLocalStream()}</div></>);
}

现在我们进入直播房间已经可以看到本地摄像头的内容了, 但我们还没有将视频流投放到RTC中, 且上播逻辑也没有处理

// view/Channel/components/StreamHandler/local_stream.jsuseEffect(() => {// 发布直播推流if (!localStreaming || !rtcClient || !localVideoStream) {return;}console.log("height", canvasRef.current.height);console.log("publishing local stream", localVideoStream);// 将流publish到rtc中rtcClient.publish(localVideoStream).then(() => {// 频道中发布一条消息, 表示开始直播sendStreamMessage({user: userInfo?.username,status: CMD_START_STREAM,},channelId).then(() => {message.success({content: "start streaming",});});});return () => {// 用户退出的清理工作, // unpublish流(远程), 停止播放流(本地), 发送直播关闭消息(频道)if (localVideoStream) {rtcClient.unpublish(localVideoStream);localVideoStream.stop();sendStreamMessage({user: userInfo?.username,status: CMD_END_STREAM,},channelId);message.info({content: "stop streaming",});}};}, [rtcClient, localStreaming, canvasRef, userInfo, channelId, localVideoStream]);

为了测试直播效果, 我们需要登录第二个账号(使用浏览器的匿名/开其他的浏览器, 此时cookie没有共享, 可以多账号登录), 进入相同频道, 开启直播, 此时第一个账号应该会自动刷新状态(如果没有则手动切换一下频道), 进入到RemoteStreamHandler, 说明我们直播的逻辑已经完成.

本地语音的逻辑也是类似的, 这里就不再重复.

接下来是远程流的渲染逻辑, 它的逻辑相对简单, 观看者可以选择开始/停止观看直播流

// view/Channel/components/StreamHandler/remote_stream.js
const RemoteStreamHandler = (props) => {const {remoteUser,localVideoRef,toggleRemoteVideo,channelId,userInfo,rtcClient,} = props;// 这里加一个强制t人的开关, 由于debugconst enableForceStop = true;const forceStopStream = () => {sendStreamMessage({user: userInfo?.username,status: CMD_END_STREAM,},channelId);};const renderRemoteStream = () => {return (<div style={{ height: "100%" }}><divid="remote-player"style={{width: "100%",height: "90%",border: "1px solid #fff",}}ref={localVideoRef}/><divstyle={{display: "flex",justifyContent: "center",marginTop: "10px",}}><span style={{ color: "#0ECD0A" }}>{remoteUser}</span>&nbsp; is playing{" "}</div></div>);};const renderFloatButtons = () => {return (<FloatButton.Groupicon={<DesktopOutlined />}trigger="click"style={{ left: "380px" }}><FloatButtononClick={toggleRemoteVideo}icon={<VideoCameraAddOutlined />}tooltip={<div>观看/停止观看直播</div>}/>{enableForceStop && (<FloatButtononClick={forceStopStream}icon={<VideoCameraAddOutlined />}tooltip={<div>强制停止直播</div>}/>)}</FloatButton.Group>);};return (<><div style={{ height: "100%" }}>{renderFloatButtons()}{renderRemoteStream()}</div></>);
}

开关远程流的代码在StreamHander中, 作为参数传给RemoteStream

// views/Channel/components/StreamHandler/index.jsconst toggleRemoteVideo = () => {if (!hasRemoteStream) {return;}console.log("[Stream] set remote video to ", !enableRemoteVideo);// 当前是关闭状态,需要打开// 开关远程音频的逻辑也与此类型.if (enableRemoteVideo) {remoteVideo?.stop();} else {remoteVideo?.play(localVideoEle.current);}setEnableRemoteVideo(!enableRemoteVideo);};

ok, 现在我们已经实现了基于声网RTC, 在环信超级社区集成视频直播的功能.

直播替换为游戏流

接下来我们来将直播流升级一下, 替换成模拟器包, 为了方便测试, 我们直接使用打包好的版本(https://github.com/a71698422/web-0.1.1 ), pkg包解压后直接放置到项目根目录,

RustNESEmulator 是一个基于Rust语言的NES模拟器, 我们在web平台可以使用它编译好的wasm版本

并将mario.nes文件放到src/assets目录下, 这是初代马里奥游戏的ROM文件(你也可以使用你喜欢的nes游戏, 如果遇到问题, 欢迎到RustNESEmulator中提issue)

加入前端的模拟器适配代码

// views/Channel/components/StreamHandler
// from tetanes.import * as wasm from "@/pkg";
class State {constructor() {this.sample_rate = 44100;this.buffer_size = 1024;this.nes = null;this.animation_id = null;this.empty_buffers = [];this.audio_ctx = null;this.gain_node = null;this.next_start_time = 0;this.last_tick = 0;this.mute = false;this.setup_audio();console.log("[NES]: create state");}load_rom(rom) {this.nes = wasm.WebNes.new(rom, "canvas", this.sample_rate);this.run();}toggleMute() {this.mute = !this.mute;}setup_audio() {const AudioContext = window.AudioContext || window.webkitAudioContext;if (!AudioContext) {console.error("Browser does not support audio");return;}this.audio_ctx = new AudioContext();this.gain_node = this.audio_ctx.createGain();this.gain_node.gain.setValueAtTime(1, 0);}run() {const now = performance.now();this.animation_id = requestAnimationFrame(this.run.bind(this));if (now - this.last_tick > 16) {this.nes.do_frame();this.queue_audio();this.last_tick = now;}}get_audio_buffer() {if (!this.audio_ctx) {throw new Error("AudioContext not created");}if (this.empty_buffers.length) {return this.empty_buffers.pop();} else {return this.audio_ctx.createBuffer(1, this.buffer_size, this.sample_rate);}}queue_audio() {if (!this.audio_ctx || !this.gain_node) {throw new Error("Audio not set up correctly");}this.gain_node.gain.setValueAtTime(1, this.audio_ctx.currentTime);const audioBuffer = this.get_audio_buffer();this.nes.audio_callback(this.buffer_size, audioBuffer.getChannelData(0));if (this.mute) {return;}const source = this.audio_ctx.createBufferSource();source.buffer = audioBuffer;source.connect(this.gain_node).connect(this.audio_ctx.destination);source.onended = () => {this.empty_buffers.push(audioBuffer);};const latency = 0.032;const audio_ctxTime = this.audio_ctx.currentTime + latency;const start = Math.max(this.next_start_time, audio_ctxTime);source.start(start);this.next_start_time = start + this.buffer_size / this.sample_rate;}// ...
}export default State;

改造local_stream

// view/Channel/components/StreamHandler/local_stream.jsimport mario_url from "@/assets/mario.nes";
import * as wasm_emulator from "@/pkg";
import State from "./state";const LocalStreamHandler = (props) => {// 模拟器 stateconst stateRef = useRef(new State());// 注意要将原来的代码注释掉/* const [localVideoStream, setLocalVideoStream] = useState(false);const localPlayerContainerRef = useRef(null);// 开启本地视频流useEffect(() => {if (!localPlayerContainerRef.current) return;const f = async () => {// 暂时使用视频代替游戏流let lgs = await AgoraRTC.createCameraVideoTrack();lgs.play(localPlayerContainerRef.current);setLocalGameStream(lgs);}f();}, [localPlayerContainerRef])// 推流的函数也暂时注释useEffet...*/useEffect(() => {// 本地游戏if (!canvasRef) {return;}// 开启键盘监听等全局事件wasm_emulator.wasm_main();fetch(mario_url, {headers: { "Content-Type": "application/octet-stream" },}).then((response) => response.arrayBuffer()).then((data) => {let mario = new Uint8Array(data);// 加载 rom数据stateRef.current.load_rom(mario);});}, [canvasRef]);// 更新本地流渲染const renderLocalStream = () => {return (<div style={{ height: "100%" }}><canvasid="canvas"style={{ width: 600, height: 500 }}width="600"height="500"ref={canvasRef}/></div>);};
}

这一步完成后, 我们就可以在本地试玩马里奥游戏了, 键盘绑定为

A      = J
B      = K
Select = RShift
Start  = Return
Up     = W
Down   = S
Left   = A
Right  = D

将推本地视频流改为游戏流

  useEffect(() => {// 发布直播推流if (!localStreaming || !rtcClient) {return;}// 只修改了流获取部分// canvas的captureStream接口支持获取视频流// 我们用这个视频流构造一个声网的自定义视频流let stream = canvasRef.current.captureStream(30);let localVideoStream = AgoraRTC.createCustomVideoTrack({mediaStreamTrack: stream.getVideoTracks()[0],});console.log("height", canvasRef.current.height);console.log("publishing local stream", localVideoStream);rtcClient.publish(localVideoStream).then(() => {sendStreamMessage({user: userInfo?.username,status: CMD_START_STREAM,},channelId).then(() => {message.success({content: "start streaming",});});});return () => {if (localVideoStream) {rtcClient.unpublish(localVideoStream);localVideoStream.stop();sendStreamMessage({user: userInfo?.username,status: CMD_END_STREAM,},channelId);message.info({content: "stop streaming",});}};}, [rtcClient, localStreaming, canvasRef, userInfo, channelId]);

最后总结一下房间的流程图
在这里插入图片描述

至此该项目的完整流程就算结束啦,如果有哪些步骤细节不太明确, 可以参照完整版项目
环信超级社区项目
注册环信
模拟器直播项目github源码获取

相关文章:

实战:一天开发一款内置游戏直播的国产版Discord应用【附源码】

游戏直播是Discord产品的核心功能之一&#xff0c;本教程教大家如何1天内开发一款内置游戏直播的国产版Discord应用&#xff0c;用户不仅可以通过IM聊天&#xff0c;也可以进行语聊&#xff0c;看游戏直播&#xff0c;甚至自己进行游戏直播&#xff0c;无任何实时音视频底层技术…...

嵌入式学习笔记——基于Cortex-M的单片机介绍

基于Cortex-M的单片机介绍前言生产厂商及其产品线ARM单片机的产品线命名规则留个作业习单片机的资料准备STM32开发所需手册1.芯片的数据手册作业2前言 本文继续接着上一篇中关于Cortex-M的介绍&#xff0c;来记录一些关于ARM系单片机的知识。 生产厂商及其产品线 芯片厂商在…...

Python 虚拟环境的使用

PyCharm 创建的虚拟环境与使用 workon 命令创建的虚拟环境在本质上没有区别&#xff0c;它们都是 Python 的虚拟环境。 使用 PyCharm 创建工程时&#xff0c;使用可以使用曾经工程的虚拟环境&#xff0c;或者新建一个虚拟环境来安装 Python 的库&#xff0c;又或者使用 workon…...

招生咨询|浙江大学MPA项目2023年招生问答与通知

问&#xff1a;报考浙江大学MPA的基本流程是怎么样的&#xff1f; 答&#xff1a;第一阶段为网上报名与确认。MPA考生须参加全国管理类联考&#xff0c;网上报名时间一般为10月初开始、10月下旬截止&#xff0c;错过网上报名时间后不能补报。确认时间一般为11月上旬&#xff0c…...

Qt std :: bad_alloc

文章目录摘要问题出现原因第一种 请求内存多余系统可提供内存第二种 地址空间过于分散&#xff0c;无法满足大块连续内存的请求第三种 堆管理数据结构损坏稍微总结下没想到还能更新参考关键字&#xff1a; std、 bad、 alloc、 OOM、 异常退出摘要 今天又是被BUG统治的一天&a…...

《设计模式》装饰者模式

《设计模式》装饰者模式 装饰者模式&#xff08;Decorator Pattern&#xff09;是一种结构型设计模式&#xff0c;它允许在不改变现有对象结构的情况下&#xff0c;动态地添加行为或责任到对象上。在装饰者模式中&#xff0c;有一个抽象组件&#xff08;Component&#xff09;…...

一文说清Kubernetes的本质

文章目录Kubernetes解决了什么问题&#xff1f;Kubernetes的全局架构Kubernetes的设计思想Kubernetes的核心功能Kubernetes如何启动一个容器化任务&#xff1f;Kubernetes解决了什么问题&#xff1f; 编排&#xff1f;调度&#xff1f;容器云&#xff1f;还是集群管理&#xf…...

信息发布小程序【源码好优多】

简介 信息发布小程序&#xff0c;实现数据与小程序数据同步共享&#xff0c;通过简单的配置就能搭建自己的小程序。&#xff0c;基于微信小程序开发的小程序。 这个框架比较简单就是用微信原生开发技术进行实现的&#xff0c;可以用于信息展示等相关信息。其中目前APP比较多&am…...

创新型中小企业申报流程

据工业和信息化部《优质中小企业梯度培育管理暂行办法》&#xff08;工信部企业〔2022〕63号&#xff09;和省《优质中小企业梯度培育管理实施细则》&#xff08;鲁工信发〔2022〕8号&#xff0c;以下简称《细则》&#xff09;&#xff0c;现就做好2022年山东省创新型中小企业评…...

【UE4 Cesium】加载离线地图

主体思路&#xff1a;先使用水经注软件下载瓦片数据&#xff0c;再使用Python转换瓦片数据格式&#xff08;TMS&#xff09;&#xff0c;使用Nginx发布网络服务&#xff0c;最后将网络服务加载到UE中。步骤&#xff1a;使用水经注下载瓦片数据&#xff0c;这里下载的是全球七级…...

Spring面试题

目录 Spring、Springmvc、Springboot的区别是什么 SpringMVC工作流程是什么 SpringMVC的九大组件有哪些 Spring的核心是什么 spring的事务传播机制是什么 Spring框架中的单例Bean是线程安全的么 spring框架中使用了哪些设计模式及应用场景 spring事务的隔离级别有哪些?…...

动态网站开发讲课笔记03:HTTP协议

文章目录零、本节学习目标一、HTTP概述&#xff08;一&#xff09;HTTP的概念1、HTTP的概念2、HTTP协议的特点&#xff08;1&#xff09;C/S模式&#xff08;2&#xff09;简单快速&#xff08;3&#xff09;灵活&#xff08;4&#xff09;无状态&#xff08;二&#xff09;HTT…...

2023年天津财经大学珠江学院专升本专业课考试题型

天津财经大学珠江学院关于2023年高职升本科专业课考试时间及题型一、专业课考试 &#xff08;一&#xff09;时间安排 2023年天津财经大学珠江学院高职升本科专业课考试定于2023年3月25日14&#xff1a;00-17:00进行&#xff0c;凡报考工商管理、旅游管理、税收学专业的考生&am…...

五方面提高销售流程管理的CRM系统

销售充满了不确定性&#xff0c;面对不同的客户&#xff0c;销售人员需要采用不同的销售策略。也正因为这种不确定性&#xff0c;规范的销售流程对企业尤为重要&#xff0c;它会让销售工作更加有效&#xff0c;快速地实现成交。下面小编给您推荐个不错的CRM销售流程管理系统。 …...

AutoCAD通过handle id选择实体

获得实体的handle id。注意是handle id 不是id&#xff0c;方法有2种&#xff1a;方法&#xff08;a&#xff09;&#xff1a;通过ArxDeg插件&#xff08;ObjectARX附带的源码编译得到&#xff1a;\samples\database\ARXDBG&#xff09;查找&#xff1a;此handle id本来就是16进…...

页面状态码的含义

使用互联网产品或服务的过程中&#xff0c;会遇到网页报错的情况&#xff0c; 比如404、505等&#xff0c;具体这些数字有什么含义呢&#xff1f;本文基本涵盖了99%的报错情况&#xff0c;可供大家查询使用。 状态码的定义 状态码一般是由3位数字和原因短语组成的&#xff08…...

Redis 越来越慢?常见延迟问题定位与分析

Redis作为内存数据库&#xff0c;拥有非常高的性能&#xff0c;单个实例的QPS能够达到10W左右。但我们在使用Redis时&#xff0c;经常时不时会出现访问延迟很大的情况&#xff0c;如果你不知道Redis的内部实现原理&#xff0c;在排查问题时就会一头雾水。很多时候&#xff0c;R…...

【python】python-socketio+firecamp使用踩坑指南

server.py: import eventlet import asyncioeventlet.monkey_patch()import socketio import eventlet.wsgisio socketio.Server(async_modeeventlet, cors_allowed_origins*) # 指明在evenlet模式下sio.event def connect(sid, environ):print(f"connect, sid{sid}, e…...

【OJ比赛日历】快周末了,不来一场比赛吗? #03.04-03.10 #12场

CompHub 实时聚合多平台的数据类(Kaggle、天池…)和OJ类(Leetcode、牛客…&#xff09;比赛。本账号同时会推送最新的比赛消息&#xff0c;欢迎关注&#xff01;更多比赛信息见 CompHub主页 或 点击文末阅读原文以下信息仅供参考&#xff0c;以比赛官网为准目录2023-03-04&…...

C++11:继承

目录 继承的基本概念 继承方式 基类和派生类对象赋值转换/切片 继承中的作用域 派生类的四个成员函数&#xff1a; 构造函数 拷贝构造函数 赋值重载 析构函数 静态成员 继承与友元 多继承 菱形继承 多继承的指针偏移问题 组合 继承的基本概念 继承出现的契机是某一…...

【蓝桥杯试题】递归实现排列型枚举

&#x1f483;&#x1f3fc; 本人简介&#xff1a;男 &#x1f476;&#x1f3fc; 年龄&#xff1a;18 &#x1f91e; 作者&#xff1a;那就叫我亮亮叭 &#x1f4d5; 专栏&#xff1a;蓝桥杯试题 文章目录1. 题目描述2. 代码展示法一&#xff1a;dfs法二&#xff1a;next_perm…...

入职字节测试岗外包一个月,我离职了...

有一种打工人的羡慕&#xff0c;叫做“大厂”。真是年少不知大厂香&#xff0c;错把青春插稻秧。但是&#xff0c;在深圳有一群比大厂员工更庞大的群体&#xff0c;他们顶着大厂的“名”&#xff0c;做着大厂的工作&#xff0c;还可以享受大厂的伙食&#xff0c;却没有大厂的“…...

weak学习入门-01

作用:集中在特征提取、算法选择和参数调优上 本篇几乎是汇总了大佬的参考 官网https://www.cs.waikato.ac.nz/ml/weka 大佬的入门教程:初试weka数据挖掘 - 加拿大小哥哥 - 博客园 (cnblogs.com) 参考书:数据挖掘实用机器学习技术(原书第2版)...

线程池中shutdown()和shutdownNow()方法的区别

线程池中shutdown()和shutdownNow()方法的区别 一般情况下&#xff0c;当我们频繁的使用线程的时候&#xff0c;为了节约资源快速响应需求&#xff0c;我们都会考虑使用线程池&#xff0c;线程池使用完毕都会想着关闭&#xff0c;关闭的时候一般情况下会用到shutdown和shutdow…...

高可用/性能

文章目录1.数据库系统架构发展&#xff08;1&#xff09;单库架构&#xff08;2&#xff09;主备架构&#xff08;3&#xff09;主从架构2.主从复制主从同步配置主从复制模式&#xff08;1&#xff09;异步复制&#xff08;2&#xff09;半同步复制&#xff08;3&#xff09;全…...

PriorityQueues优先队列

优先队列优先队列&#xff08;priority queue&#xff09;是计算机科学中的一类抽象数据类型。优先队列中的每个元素都有各自的优先级&#xff0c;优先级最高的元素最先得到服务&#xff1b;优先级相同的元素按照其在优先队列中的顺序得到服务。优先队列通常使用“堆”&#xf…...

arm 堆栈

先转一篇 stm32 堆和栈(stm32 Heap & Stack)【worldsing笔记】_stm32堆栈_slj_win的博客-CSDN博客 关于堆和栈已经是程序员的一个月经话题&#xff0c;大部分有是基于os层来聊的。 那么&#xff0c;在赤裸裸的单片机下的堆和栈是什么样的分布呢&#xff1f;以下是网摘&…...

leetcode-面试题 05.02. Binary Number to String LCCI

Description Given a real number between 0 and 1 (e.g., 0.72) that is passed in as a double, print the binary representation. If the number cannot be represented accurately in binary with at most 32 characters, print “ERROR”. Example1: Input: 0.625Outpu…...

C语言函数阐述

C 函数 函数是一组一起执行一个任务的语句。每个 C 程序都至少有一个函数&#xff0c;即主函数 main() &#xff0c;所有简单的程序都可以定义其他额外的函数。 您可以把代码划分到不同的函数中。如何划分代码到不同的函数中是由您来决定的&#xff0c;但在逻辑上&#xff0c…...

二叉树——把二叉搜索树转换为累加树

538. 把二叉搜索树转换为累加树 链接 给出二叉 搜索 树的根节点&#xff0c;该树的节点值各不相同&#xff0c;请你将其转换为累加树&#xff08;Greater Sum Tree&#xff09;&#xff0c;使每个节点 node 的新值等于原树中大于或等于 node.val 的值之和。 提醒一下&#xf…...

温州优化网站方法/app优化网站

在练习循环删除list中元素时遇到了一点问题。最开始写的代码是 for i in range(len(list)):del list[i] 这样写到后来会报错&#xff0c;原因是随着列表元素的删除和i的增加&#xff0c;对列表元素的访问会越界。 后来改成了如下代码 while i < len(list):del list1[i] 结果…...

php网站开发更换模板/如何快速收录一个网站的信息

1.失败的BI项目 对于大多数信息化项目来说&#xff0c;BI项目和知识管理项目是难度最大的。暂且放下知识管理不说&#xff0c;首先&#xff0c;我们先把使用BI的角色确认下来。 大家都知道&#xff0c;BI是在1996年提出来的&#xff0c;现在大部分人都认为BI就是一个辅助决策…...

网站反向代理怎么做/百度公司怎么样

5 种使用 Python 代码轻松实现数据可视化的方法#故事数据可视化PYTHON数据可视化是数据科学家工作中的重要组成部分。在项目的早期阶段&#xff0c;你通常会进行探索性数据分析&#xff08;Exploratory Data Analysis&#xff0c;EDA&#xff09;以获取对数据的一些理解。创建可…...

3800给做网站/市场营销七大策略

解决方法&#xff1a;每一位取出来相加等于123456789&#xff0c;每一位相乘等于1*2*3*4*5*6*7*8*9转载于:https://www.cnblogs.com/cstdio1/p/11074022.html...

企业网站网站设计/推荐就业的培训机构

编写一下Android界面的项目使用默认的Android清单文件<?xml version"1.0" encoding"utf-8"?> <manifest xmlns:android"http://schemas.android.com/apk/res/android" package"com.itheima28.writedata" android:versionCo…...

线上营销渠道/seo优化点击软件

谈起面向对象&#xff0c;对于大部分程序员来说都是耳熟能详的玩意&#xff0c;这个面向对象编程说白了无非就是类和对象&#xff0c;方法和 成员变量&#xff0c;封装等等。Python作为一门面向对象的语言&#xff0c;肯定对于这些的支持是没问题&#xff0c;下面我们来说一下…...