实战:一天开发一款内置游戏直播的国产版Discord应用附源码
Posted 环信即时通讯云
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了实战:一天开发一款内置游戏直播的国产版Discord应用附源码相关的知识,希望对你有一定的参考价值。
游戏直播是Discord产品的核心功能之一,本教程教大家如何1天内开发一款内置游戏直播的国产版Discord应用,用户不仅可以通过IM聊天,也可以进行语聊,看游戏直播,甚至自己进行游戏直播,无任何实时音视频底层技术的Web开发者同样适用,效果如下:
开整!
Step1 初始化
本项目基于环信超级社区的实例项目, 所以我们先从Circle-Demo-Web
这个仓库开启做初始化
- 克隆项目
git clone https://github.com/easemob/Circle-Demo-Web.git
- 安装依赖
npm install
- 设置appKey
src/utils/WebIM.js
中设置appKey,AppKey为环信后台项目对应的key,注册环信,https://console.easemob.com/user/register,登录console后台获取Appkey- appKey为环信后台项目对应的key, 如何开通可见开通配置环信即时通讯 IM 服务
- 运行项目
npm run start
运行后, 登录完毕效果如下,
与discord设计逻辑相似, 左边功能区有
- 个人信息页
- 好友会话页
- 当前加入的频道
- 创建新频道
- 加入服务器
超级社区的逻辑为
社区(Server)、频道(Channel) 和子区(Thread) 三层结构
社区为一个独立的结构, 不同社区直接相互隔离, 社区包含不同的频道, 代表了不同的话题, 用户在频道中聊天, 而针对一条单独信息产生的回复为子区.
我们本次的项目主要集中在频道部分, 需要加入一个服务器后, 创建一个测试社区, 保证你具有管理员权限.
Step2 协议设置
我们的目标是尽量利用现有api扩展功能, 有几个问题需要解决
- 如何区分普通频道和游戏频道?
- 如何区分当前频道是否有玩家直播, 如果有直播如何获取玩家信息, 第二玩家的状态?
- 多人聊天的状态?
如何区分普通频道和游戏频道
这里直接简单采用频道前缀做特殊区分, 创建频道前缀带video-
的识别为游戏频道, 同时将渲染内容做替换
// views/Channel/index.js
const isVideoChannel = useMemo(() =>
return currentChannelInfo?.name?.startsWith("video-");
, [currentChannelInfo]);
const renderTextChannel = () =>
// 原来的渲染逻辑
return (
<>
<MessageList
messageInfo=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
, 分别在channelItem
和Channel/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事件感知远端视频/音频流.
根据文档 进行如下操作,
- 注册声网开发者, 并在后台创建一个测试项目
- 项目根目录创建
.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
- 添加声网sdk依赖
npm install agora-rtc-sdk-ng
我们在下一章中编写接入逻辑
声网RTC接入, 直播与语音实现
接入
在views/Channel/components
文件夹下新增一个组件StreamHandler
, 该组件为后续我们处理游戏房间的组件, 先初步编写声网接入逻辑
// views/Channel/components/StreamHandler/index.js
const 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 channel
client
.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: (
<div
className="circleDropItem"
onClick=toggleVoice
>
<Icon
name="person_wave_slash"
size="24px"
iconClass="circleDropMenuIcon"
/>
<span className="circleDropMenuOp">
enableVoice ? "关闭语音" : "开启语音"
</span>
</div>
),
];
此时我们创建一个video-
开题的游戏频道, 应该可以看到命令行中输出了RTC连接成功信息. [Stream] join channel success
音视频推流
接下来我们继续做实质的RTC推流逻辑, 及用户上下播的入口. 但在那之前, 先简单过一下声网RTC中的一些概念.
参考以下步骤实现音视频通话的逻辑:
- 调用 createClient 方法创建
AgoraRTCClient
对象。- 调用 join 方法加入一个 RTC 频道,你需要在该方法中传入 App ID 、用户 ID、Token、频道名称。
- 先调用 createMicrophoneAudioTrack 通过麦克风采集的音频创建本地音频轨道对象,调用 createCameraVideoTrack 通过摄像头采集的视频创建本地视频轨道对象;然后调用 publish 方法,将这些本地音视频轨道对象当作参数即可将音视频发布到频道中。
- 当一个远端用户加入频道并发布音视频轨道时:
- 监听 client.on(“user-published”) 事件。当 SDK 触发该事件时,在这个事件回调函数的参数中你可以获取远端用户
AgoraRTCRemoteUser
对象 。- 调用 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);
// 远程音视频track
const [remoteVoices, setRemoteVoices] = useState([]);
const [remoteVideo, setRemoteVideo] = useState(null);
// RTC相关逻辑
useEffect(() =>
...
// client.join 后
// 监听新用户加入
client.on("user-published", async (user, mediaType) =>
// auto subscribe when users coming
await 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 user
setRemoteUser(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]以上是关于实战:一天开发一款内置游戏直播的国产版Discord应用附源码的主要内容,如果未能解决你的问题,请参考以下文章