需求背景
- 节省使用第三方直播服务的成本。
- 提供更加符合医言康运营的直播可定制化需求服务。
- 为医言康项目赋能,同时也提供了宣传亮点。
直播播放器与回放点播的实现
技术方案的选择—腾讯实时音视频与云直播
实时音视频主打低延时互动直播和多人音视频两大解决方案,支持低延时直播观看、实时录制、屏幕分享、美颜特效、立体声等能力,还能和直播 CDN 无缝对接,适用于互动连麦、跨房PK、语音电台、K 歌、小班课、大班课、语音聊天、视频聊天、在线会议等业务场景。
云直播供极速、稳定、专业的云端直播处理服务,根据业务的不同直播场景需求,云直播提供了标准直播、慢直播、快直播三种服务,分别针对大规模实时观看、高并发推流录制、超低延时直播场景,配合移动直播 SDK,为您提供一站式的音视频直播解决方案。
基于目前的需求和服务成本,云直播是更好的选择。
播放SDK的选择—hls.js与TcPlayerLite.js的选择
直播输出的常用文件是m3u8类型的文件,部分浏览器并未支持播放m3u8视频文件,使用hls.js库可以提供对m3u8视频的支持。但是不同的系统内置的浏览器视频播放器样式是不一样的,比如下图就是iphone和android下的播放器样式,如果想要统一类型,需要在对video标签进行二次开发。
TcplayerLite.js是基于hls.js对直播和点播进行了功能拓展的JS库,也是腾讯云直播的官方SDK,对播放器样式进行了自定义制作,同时提供了很多的配置,点击可查看详情。在此选择TcplayerLite.js来实现敏捷开发。
使用如下:
new TcPlayer.TcPlayer('video', {
m3u8: liveInfo.hlsLive,
flv: liveInfo.flv,
autoplay: true, //iOS 下 safari 浏览器,以及大部分移动端浏览器是不开放视频自动播放这个能力的
poster: liveInfo.poster,
width: '100%',
height: '211',
live: true,
});
<div
id="video"
style={{
width: '100%',
height: '211px',
backgroundImage: `url(${liveInfo.poster})`,
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
}}
></div>
点播、直播横屏与竖屏的表现差异
直播与点播的区别在于,点播拥有视频时长,可随时查看更个时间点的内容,而直播是实时更新的,所以进度条与时长对于直播来说没有意义。
在移动端上,尤其是H5上,横竖屏的交互有很大的差异,由于竖屏直播占用了更大的空间,视频控制器将会缩简为一个控制播放暂停的按钮,弹幕也表现为浮现在直播下方
即使通讯的实现
即时通讯IM
基于 QQ 底层 IM 能力开发,仅需植入 SDK 即可轻松集成聊天、会话、群组、资料管理能力,帮助您实现文字、图片、短语音、短视频等富媒体消息收发,全面满足通信需要。
IM服务的选择及其限制
对弹幕对功能需求,这里解析为直播间或者是群组概念、信息的即时性、成员数量无限制、附带成员以及角色信息。
我们针对这些需求最终选择直播群服务,但经过跟腾讯技术人员沟通仍然存在以下限制。
- 直播群没有对历史消息提供接口,目前的方案是后端通过前端提供的进入直播间的时间戳从mongo数据库查询数据。
- 消息对象没有附带角色信息,目前的方案是在用户进入直播间的时候从后台请求所有管理员信息,然后对每一个消息进行信息附加。
不同身份的原型设计
直播观看功能、弹幕观看功能、点赞功能对所有用户开放。
弹幕发送功能、订阅功能对登录用户开发。
IM中匿名登录与实名登录
未登录医言康平台的用户也能够查看弹幕,这需要我们实现IM的匿名登录,匿名用户登录IM因为没有用户头像昵称信息,所以限制只能观看弹幕。当用户从未登录状态进行了登录操作后,这里就需要将IM的登录态也进行相应的转变,这个转变的具体过程就是先对匿名用户进行退群操作,再退出IM登录,继而重新获取用户IM授权信息,再重新使用新的身份登录IM。
直播页面的技术设计
中转页在有多设计形式的页面中的应用
有时候我们的某个落地页可能包含了多个类型,分别有着不同的设计,比方说圈子动态落地页包含了图文内容、语音内容、视频内容等,当前讲述的直播页也分为横屏直播页以及竖屏直播页,如果给每一个类型都定一个路由,那么不管是做页面跳转还是做页面分享抑或是其他都增加了维护成本,而中转页在这里就凸显出了它的价值,将数据查询以及公共部分的逻辑都提取出来,根据类型、状态渲染相关的内容,实现一个路由对应一个页面,节省开发成本。
import React, { useState, useEffect } from 'react';
import { Toast } from 'antd-mobile';
import { history } from 'umi';
import { setWechatShareWithConfig } from '@/utils/wxJssdkHelper';
import isWechat from '@/utils/isWechat';
import { judgeClient } from '@/utils/tools';
import newRequest from '@/utils/request';
import Horizontal from './horizontal/horizontal';
import Vertical from './vertical/vertical';
const statusMatch: any = {
'-1': {
push: false,
remark: '已删除',
},
'0': {
push: false,
remark: '正在审核中',
},
'1': {
push: false,
remark: '审核已拒绝',
},
'2': {
push: true,
remark: '预告',
},
'3': {
push: true,
remark: '直播中',
},
'4': {
push: true,
remark: '暂停中',
},
'5': {
push: true,
remark: '生成回放中',
},
'6': {
push: true,
remark: '回放',
},
};
export default () => {
let liveId: any;
const [loaded, setLoaded] = useState(false);
const [liveInfo, setLiveInfo] = useState<any>({});
const [groupInfo, setGroupInfo] = useState<any>({});
const [subscribe, setSubscribe] = useState<any>(false);
const fetchLive = async () => {
let res = await newRequest({
url: '/api/live/stream',
options: {
method: 'GET',
params: {
// groupId: 45,
liveId: liveId,
},
},
});
if (res.code == 0) {
console.log('res:', res.data.live);
if (res.data && res.data.live) {
setLiveInfo(res.data.live);
setGroupInfo(res.data.groupInfo);
setSubscribe(res.data.subscribe);
document.title = res.data.live.name;
setShare(res.data.live);
setLoaded(true);
}
} else {
Toast.fail(res.message);
}
};
const setShare = (liveInfo: any) => {
if (isWechat()) {
const shareObj = {
title: liveInfo.name ? liveInfo.name : '', // 分享标题
desc: liveInfo.notification
? liveInfo.notification
: '精彩直播内容,点击马上进入直播间观看', // 分享描述
link: window.location.href, // 分享链接,该链接域名或路径必须与当前页面对应的公众号JS安全域名一致
imgUrl: liveInfo.thumbShare ? liveInfo.thumbShare : liveInfo.banner, // 分享图标
};
if (judgeClient().toLocaleLowerCase() === 'ios') {
sessionStorage.setItem('first_enter_url', window.location.href);
setWechatShareWithConfig(shareObj);
} else {
setWechatShareWithConfig(shareObj);
}
}
};
useEffect(() => {
liveId = history.location.query.id;
if (liveId) {
fetchLive();
} else {
Toast.fail('直播ID为空!');
}
return () => {
document.title = '医言康';
};
}, []);
return loaded ? (
statusMatch[`${liveInfo.status}`].push ? (
liveInfo.orientation === 'landscape' ||
liveInfo.status == 5 ||
liveInfo.status == 6 ? (
<Horizontal
liveInfo={liveInfo}
groupInfo={groupInfo}
subscribe={subscribe}
/>
) : (
<Vertical
liveInfo={liveInfo}
groupInfo={groupInfo}
subscribe={subscribe}
/>
)
) : (
<div>{statusMatch[`${liveInfo.status}`].remark}</div>
)
) : (
<></>
);
};
直播、点播、即时通讯在程序中设计
弹幕/点赞的交互
新弹幕出现的自动触底
// 1. react中的方式
...
// 定义一个ref
const messagesEndRef = useRef<any>(null); // 滚动到底部的目标元素
// 在滚动盒子最下面写一个盒子,作为一个ref目标
<div ref={messagesEndRef} />
...
// 在需要滚动触底的时候,比方说在监听到新消息的时候使用如下
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
// 2. 原生js中的方式
...
// 同样在滚动盒子最下面写一个盒子,给一个id作为标识
<div id="end" />
// 在需要滚动触底的时候使用如下
document.getElementById('end').scrollIntoView({ behavior: 'smooth' });
// behavior参数说明: smooth缓慢带动画的滚动, auto无动画即刻滚动
下拉加载
下拉加载的触发点其实很简单,就是根据滚动盒子scrollTop的值是否到达0。另外需要注意的是下拉的时候需要标记当前位置,因为下拉加载后,盒子会滚动到顶部,这个时候需要将盒子滚动到标记的位置。
<div
className={styles.chatBox}
onScroll={(e: any) => {
if (e.target.scrollTop == 0) {
fetchHistory();
}
}}
>
...消息列表
</div>
...
const fetchHistory = async (isFirst = false) => {
if (!loaded) {
console.log('加载中...');
return;
}
if (!hasMore) {
console.log('没有更多了!');
return;
}
setLoaded(false);
if (!isFirst) {
autoScollBottom = false;
} // 关闭自动滚动
let res = await newRequest({
url: '/api/live/internal/live_history',
options: {
method: 'GET',
params: {
current_ms: currentTime,
group_id: liveInfo.streamName,
page: page,
size: size,
},
},
withToken: false,
hasLoading: false,
});
console.log('历史消息:', res);
setLoaded(true);
if (res.code == 0) {
page += 1;
const histories = res.data.histories;
if (histories) {
let formatData = [];
for (let i = 0; i < histories.length; i++) {
const item = histories[i];
if (item.type === 'TIMTextElem') {
let { payload, ..._item } = item;
_item.payload = {
text: payload.Text ? payload.Text : '',
};
formatData.push(_item);
} else {
// 其他类型的消息TODO
formatData.push(item);
}
}
messageList = formatData.concat(messageList);
setTalkContents(JSON.parse(JSON.stringify(messageList)));
if (isFirst) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
} else {
document
.getElementById('scroll' + histories.length)
?.scrollIntoView({ behavior: 'auto' });
}
}
if (!(res.data.page && res.data.page.hasNextPage)) {
setHasMore(false);
}
} else {
Toast.fail(res.message);
}
};
查看历史消息与自动触底冲突的解决方案
第一种方案:首先定义一个自动触底的开关,在用户进行手动下拉加载的时候将自动触底关闭,当用户在滑动触底的时候重新打开自动触底。这个方案有一个缺点就是当消息比较多同时弹幕发送频繁的时候,用户可能根本翻不到上拉加载加载的位置就被自动触底了,所以舍弃掉。
第二种方案: 只要用户下拉位置大于一定距离,就关闭自动触底,而滚动少于这个距离就打开自动触底。代码如下
<div
className={styles.chatBox}
onScroll={(e: any) => {
if (e.target.scrollTop == 0) {
fetchHistory();
}
let scrollH = e.target.scrollHeight;
let scrollT = e.target.scrollTop;
let divHeight = e.target.clientHeight;
if (scrollH - scrollT - divHeight < 500) {
// 滚动到底部
autoScollBottom = true;
} else {
autoScollBottom = false;
}
}}
>
...消息列表
</div>
为了方便理解,这里画一个简图
为什么距离底部是500px而不是屏幕高度或者是其他,用户滚动500px的时间是较短的,这个时间点足够大于大部分弹幕发送的间隔以至于不被自动触底,当然这里完全是有优化空间的,比方说用户在下拉的过程中禁止自动触底,只是在实现上需要花上一些心思。
点赞动画特效在react中的实现
效果预览如下
这个效果有几个关键点, 点击松开的时候,按钮有一个缩放的动画;每一个心心的横坐标和纵坐标都是在一定范围类浮动的;每一个心心的颜色是随机的;从心心创建到消失有一个从大到小从实体到透明的渐变。基于这几个点我们来看代码。
// 视图部分
<div className={styles.heartBox}>
// 按钮
<div
className={styles.like}
style={{ transform: `scale(${isTouch ? '1.2' : '1'})` }}
onTouchStart={() => {
setIsTouch(true);
}}
onTouchEnd={() => {
setIsTouch(false);
}}
onClick={heartClick}
>
<img
src={require('@/assets/images/i_live/i_like_black.png')}
alt=""
/>
</div>
// 心心列表
<div>
{heartList.map((v: any, index: number) => {
return (
<div
key={index}
className={styles.heart}
style={{
transform: `scale(${v.scale}) translate(${v.x}px, ${v.y}px)`,
opacity: v.opacity,
}}
>
<img
style={{ width: '100%' }}
src={require(`@/assets/images/i_live/0${v.icon}.png`)}
alt=""
/>
</div>
);
})}
</div>
// js部分
...
const [heartList, setHeartList] = useState<any>([]); // 心心列表
const [isTouch, setIsTouch] = useState(false); // 是否正在点击心心
const heartClick = () => {
likeClickCount++; // 心心统计,每10秒发送给后台
if (timer) clearTimeout(timer);
let newHeartList: any = heartList.concat([]);
const newItem = {
x: 0,
y: 0,
opacity: 1,
scale: 0.8,
icon: Math.ceil(Math.random() * 12),
};
newHeartList.push(newItem);
setHeartList(newHeartList);
setTimeout(() => {
newHeartList[newHeartList.length - 1] = {
...newItem,
x: Math.random() * 300 - 220, // 在-220至80之间浮动
y: -(300 + Math.random() * 200), // -300至-500之间浮动
opacity: 0,
scale: 0.3,
};
setHeartList(newHeartList.concat([]));
}, 10);
timer = setTimeout(() => {
setHeartList([]);
}, 2000); // 两秒内没有点击心心,将消息队列清空,减少心心盒子的渲染,优化性能。
};
// css部分
.heartBox {
position: relative;
width: 12vw;
height: 12vw;
.like {
cursor: pointer;
width: 100%;
height: 100%;
transition: 0.1s linear;
img {
width: 100%;
height: 100%;
}
}
.heart {
position: absolute;
left: 0;
top: -6vw;
width: 100%;
height: 100%;
transition: 2s ease-in;
}
其他
TcPlayLite在Umi架构中的兼容性处理
根据页面的进入方式实现不同的返回上一页逻辑
用户进入直播页面的方式有两种, 第一是从医言康项目中的其他页面跳转进入;第二是通过分享链接或者直接打开直播地址进入。这两种方式让直播页面中的【返回上一页】按钮困惑。这里给出的解决方案是凡事从项目内进入直播页面都会在地址中加一个参数back=1(/liveplay?id=xxx&back=1), 所以只要在直播页面对此进行判断即可解决,如果back为1执行返回上一页,如果没有back值执行返回首页。代码如下
<NavBar
title="直播"
goBack={() => {
if (history.location.query.back) {
history.goBack();
} else {
history.replace('/');
}
}}
/>
直播数据的实时更新
尽管云直播和即时通讯IM为我们实现直播提供了很多帮助,但是一些直播的数据仍然需要我们维护,就比如直播状态、点赞数量、热度等。这里的处理方式是定义一个定时器,每10秒向后端查询并更新数据,同时将这10秒内用户的点赞数报告给后端。
const startUpdateData = () => {
timerUpdate = setInterval(() => {
messageSendCount = 0;
fetchLiveData();
}, 10000);
};
const fetchLiveData = async () => {
if (!liveInfo.id) return;
const likeNumber = likeClickCount;
likeClickCount = 0;
let res = await newRequest({
url: '/api/live/countHotLike',
options: {
method: 'GET',
params: {
liveId: liveInfo.id,
likeNumber: likeNumber,
},
},
withToken: false,
hasLoading: false,
});
if (res.code == 0) {
if (res.data) {
if (
res.data.status != liveInfo.status &&
(res.data.status == 5 || res.data.status == 6)
) {
Toast.info('直播已结束');
}
setLiveInfo((oldState: any) => {
oldState.likeNumber = res.data.likeNumber;
oldState.hot = res.data.hot;
oldState.status = res.data.status;
return JSON.parse(JSON.stringify(oldState));
});
}
} else {
// Toast.fail(res.message);
}
};
弹幕频繁发送限制的限制
对于弹幕,产品给出了一些限制,弹幕不能为空也不能为纯空格,一条弹幕最多200字,同一个用户不能太过频繁的发送弹幕。代码如下
<input
className={styles.input}
placeholder="说点什么吧~"
value={textInput}
maxLength={200}
onChange={(e: any) => {
setTextInput(e.target.value);
}}
onKeyDown={event => {
var e = event || window.event;
if (e.key === 'Enter') {
if (isLogin) {
console.log('send');
// 判定IM是否登录
sendMsg(textInput);
} else {
setShowLogin(true);
}
}
}}
></input>
...
const startUpdateData = () => {
timerUpdate = setInterval(() => {
messageSendCount = 0; // 每10秒,消息限制清0
fetchLiveData();
}, 10000);
};
// 发送消息
const sendMsg = (text: string) => {
if (liveInfo.status < 2) {
Toast.info('直播准备中...');
return;
}
if (!TIM_READY) {
Toast.fail('弹幕连接中...');
fetchImData();
return;
}
if (messageSendCount > 6) {
Toast.info('您语速有点太快了,请歇会再聊!');
return;
}
if (trim(text)) { // trim方法用于判断内容是否为纯空格
const message = tim.createTextMessage({
to: liveInfo.streamName,
conversationType: TIM.TYPES.CONV_GROUP,
payload: {
text: text,
},
});
let promise = tim.sendMessage(message);
promise
.then(function(imResponse: any) {
// 发送成功
messageSendCount += 1;
const userInfo = storage.getObject('userInfo');
let _item = {
avatar:
message.flow === 'out'
? userInfo.doctorPhoto
? userInfo.doctorPhoto
: userInfo.avatarUrl
: message.avatar,
nick: userInfo.doctorName ? userInfo.doctorName : userInfo.nickname,
type: message.type,
payload: message.payload,
isSelf: message.flow === 'out',
from: message.from,
};
messageList.push(_item);
setTalkContents(JSON.parse(JSON.stringify(messageList)));
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
setTextInput('');
console.log('发送成功!');
})
.catch(function(err: any) {
Toast.fail(`${err}`);
});
} else {
console.warn('消息不能为空!');
}
};
// trim 方法
export const trim = (str: string) => {
return str.replace(/\s*/g, '');
};
单页面的标题与微信分享设置问题
由于医言康H5端设计之初就是一个单页面应用,意味着如果不做处理,每一个页面都公用一个标题和分享,但我们现在对直播页面有一个不一样的需求,那就是标题得设置为直播主题,微信分享的内容也是后台给的动态内容。
const fetchLive = async () => {
let res = await newRequest({
url: '/api/live/stream',
options: {
method: 'GET',
params: {
// groupId: 45,
liveId: liveId,
},
},
});
if (res.code == 0) {
console.log('res:', res.data.live);
if (res.data && res.data.live) {
setLiveInfo(res.data.live);
setGroupInfo(res.data.groupInfo);
setSubscribe(res.data.subscribe);
document.title = res.data.live.name;
setShare(res.data.live);
setLoaded(true);
}
} else {
Toast.fail(res.message);
}
};
const setShare = (liveInfo: any) => {
if (isWechat()) {
const shareObj = {
title: liveInfo.name ? liveInfo.name : '', // 分享标题
desc: liveInfo.notification
? liveInfo.notification
: '精彩直播内容,点击马上进入直播间观看', // 分享描述
link: window.location.href, // 分享链接,该链接域名或路径必须与当前页面对应的公众号JS安全域名一致
imgUrl: liveInfo.thumbShare ? liveInfo.thumbShare : liveInfo.banner, // 分享图标
};
if (judgeClient().toLocaleLowerCase() === 'ios') {
sessionStorage.setItem('first_enter_url', window.location.href);
setWechatShareWithConfig(shareObj);
} else {
setWechatShareWithConfig(shareObj);
}
}
};
useEffect(() => {
liveId = history.location.query.id;
if (liveId) {
fetchLive();
} else {
Toast.fail('直播ID为空!');
}
return () => {
document.title = '医言康';
};
}, []);
不同角色弹幕样式的匹配
在获取直播间信息的接口中下发了直播间所有的管理员信息,这个信息的格式是
[
{
id: xxx,
role: Admin,
},
{
id: xxx,
role: Owner,
},
...
}
为了方便渲染,我将这个数据结果做了一次转换,转换的原因后续我会说明
if (role && role.length) {
let _dict: any = {};
for (let i = 0; i < role.length; i++) {
let _item: any = role[i];
_dict[`${_item.id}`] = _item.role;
}
setAdminsInfo(_dict);
}
// 格式结果形如
{
'userIDA': 'Admin',
'userIDB': 'Owner',
'userIDC': 'Admin',
...
}
为什么做这个转换呢, 就是给所有管理员分别添加添加一个value,这个value就是他的角色标识,同时也是他的样式类名
.Admin {
color: red !important;
}
.Owner {
color: #00d6aa !important;
}
在视图上遍历渲染
{talkContents.map((v: any, index: number) => {
return v.type == 'TIMTextElem' ? (
<li
id={'scroll' + index}
className={styles.item}
key={index}
>
<div className={styles.message}>
<div className={styles.avatar}>
<img
src={
v.avatar
? v.avatar
: require('@/assets/images/default/noName.png')
}
alt=""
/>
</div>
<div className={styles.col}>
<div className={styles.msgTitle}>
<div
// 关键点
className={[
styles.nickname,
adminsInfo[`${v.from}`]
? styles[adminsInfo[`${v.from}`]]
: '',
].join(' ')}
>
{v.nick !== ''
? v.nick
? v.nick
: '匿名'
: '直播间管理员'}
</div>
</div>
<div className={styles.msgContent}>
{v.payload.text}
</div>
</div>
</div>
</li>
) : (
<div key={index}></div>
);
})}
不要忘记在页面销毁的时候清楚程序中的定时器以及监听函数
useEffect(() => {
...
return () => {
clearInterval(timer);
clearInterval(timerUpdate);
removeListener();
if (liveInfo.status >= 2) {
tim
.quitGroup(liveInfo.streamName)
.then(function(imResponse: any) {
tim.logout();
})
.catch(function(imError: any) {
tim.logout();
});
}
};
}, []);