企鹅辅导H5直播点播实践

5,334 阅读15分钟

本文作者:陈俊文,来自IMWeb团队,未经同意,禁止转载。

前言

企鹅辅导是腾讯推出的针对小学、初中、高中课外的在线学习服务平台。近期,企鹅辅导为了方便用户更加快速便捷地上课,新增了移动端H5的上课形式。本文主要介绍了H5“实时直播”和“点播”的选型,以及开发过程中遇到的问题和解决方案。

需求背景

企鹅辅导的上课功能是产品较为核心的功能,而原有上课方式只有以下两种:APP上课/PC浏览器上课。

  • APP上课: 环境要求不高,拿起手机app即可体验,首次上课的需要先下载App。
  • PC浏览器上课: 登入网址即可上课,但是上课环境要求必须在电脑前。

现在多了一种上课方式和载体 —— 移动端H5上课。它的特点在于:分享链接即可进入指定课程指定节,易于传播。无需求下载app,进入链接报名即可上课。主要用于快速体验上课的场景

需求详情

H5上课页入口

1.非APP环境下,原先H5课程详情页、支付成功页,均提供入口进入H5上课页,(目前入口灰度)。对于买了课的学生在不下载APP的情况下,快捷进入辅导上课页,立即体验上课。

2.通过群里指定分享链接,链接带有课程ID和节ID了,即可自动进入指定节的直播课堂,或查看节的回放内容。节ID缺省情况下,也会自动定位最近一节直播课。


H5上课页功能需求

1.上课直播: 通过腾讯云WebRTC直播(优先方案)以及hls直播(降级方案)两种方式完成。 2.点播回放: 通过腾讯云TcPlayer超级点播的进行hls播放。 3.讨论互动: 基于impush,学生能发送和接收文案+表情。其余互动场景通过toast提示。 4.课程大纲: 查看课程每节的内容及时间,可在同一页面直接切换节进行直播/回放。

其它需求 在PC Web 上课的业务逻辑基础上,还有以下需求: 1、根据不同状态展示不同封面。 2、横竖屏检测及播放、切换全屏播放。 3、网络类型检测及提示,wifi => 4G流量 暂停并提示等。 4、引导跳转:①购买检查引导跳转购课。②引导APP更好体验上课。

功能梳理


技术实现

直播和点播方案

音视频方案选型

WebRTC直播 降级hls直播

由于上课直播的同时,还有讨论区模块以及其他交互模块(如举手,答题等)。如果音视频存在延迟学生用户能够明显感知,体验会大打折扣。因此PlanA采用低延时的直播方案——WebRTC,时延能在1秒内。

但由于WebRTC在移动端兼容性并不够好,尤其在苹果机型上,需要IOS版本11.1.2以上才逐渐支持。因此需要一个兼容性覆盖面广的降级直播方案作为PlanB,保证能兜底直播。

降级直播方案采用HLS协议直播方式,它在移动端H5有良好广泛的兼容性。但时延较高达到20秒以上。因此降级直播时,讨论区的消息也顺延相应的时间长度,且禁止发言功能。通过只让学生单方面的接收互动消息,而不能发送消息的方式,来屏蔽低延时的感知。

这里为什么不选用延迟比hls更少的其他直播方式作为降级方案呢? 原因在于即便其他直播方式延时更少(如flv可达到5-7秒),但是降级方案优先考虑兼容性,而不再考虑延迟。即便延迟5-7秒仍会被感知,也需要禁言等单向信息流的方式了抹掉延迟感,延迟多少反而没那么重要。所以采用移动端支持性更好的HLS。

点播回放

点播方案采用腾讯云点播服务,web侧使用加密 HLS 的点播方案。由于之前的在PC上经验,使用了腾讯云的超级点播播放器,目前这块相比直播更加稳定。由于防盗需要对加密点播资源解密,主要的工作就是token身份认证换取解密密钥等鉴权处理。

播放器接入及播放流程


直播接入 webrtc使用的是在线教育webrtc-live-player,依赖腾讯云WebRTC SDK,需要支持到3.4.1以上(原因IOS新版本已经支持unifiedPlan)。直播播放器接入,比较简单。确定容器dom,指定相关业务上报模块。然后实例化,执行preconnect。

const opts = {
       // 一些上报等配置项
      };
      this.webrtcPlayer = new WebRTCLivePlayer(opts);
      this.preconnect();

这里的preconnect,只是先建立webrtc的websocket信令连接,由于webtc的usersig可能不会第一时间下发。预先创建信令连接,可以节省信令建连的耗时。

当usersig准备好后,执行init,异步创建播放器成功后可以设置播放器宽高。最后执行connet进入webrtc的房间成功后,交换SDP以及candidate建立ICE连接,等待视频流后就可以播放了。

this.webrtcPlayer.init(sig, roomId).then(() => {
     // 设置播放器宽高
      this.webrtcPlayer.setContainerStyle({
        width: xxx,
        height: xxx,
      });
      this.webrtcPlayer.connect();
    });

当然接入webrtc播放器,实际业务层还有更多的事情要做,涉及流更新处理,视频宽高变化更新布局,重连机制,以及上报处理等。

降级Hls直播接入 基于TCPlayerLite,传入m3u8混流地址,实例化播放器即可。初始化参数请参考:cloud.tencent.com/document/pr…

TCPlayerLite 采用 H5<video>和 Flash 相结合的方式来进行视频播放,根据不同的播放环境,播放器会选择默认最合适的播放方案。 在移动端需要使用m3u8,由于我们获取混流地址业务接口提供rtmp,flv和hls地址,因此在移动端H5下传入options地址中,要把rtmp和flv去掉只传m3u8地址。

const player = new TcPlayer('tcplayer_container', {
    "m3u8": "http://2157.liveplay.myqcloud.com/2157_358535a.m3u8",//举例 
    "autoplay" : false,(备注:true只对大部分 PC 平台生效) 
    "width":"100%",
    "height":"100%",
    "live":true,
    "x5_player":true
});

为了更好理解直播的步骤,自己梳理了直播的流程和架构图。

腾讯云TCPlayer点播接入

点播资源作为学生用户付费获取的资源,自然不可能凭借一个m3u8地址就能播放,必须是有加密的。因此点播的接入,除了需要点播资源fileId之外,还需要用户的身份信息校验用于视频解密。

加密HLS请参考:cloud.tencent.com/document/pr… 里面提供了两个视频播放方案。简单来说都是用cookie生成token,通过业务的DK接口结果获取解密密钥DK。

方案1:通过 QueryString 传递身份认证token信息。

http://example.vod2.myqcloud.com/path/to/a/voddrm.token.ABC123.video.m3u8

方案2:播放器在访问 EXT-X-KEY 标签所标识的 URL 时会带上 Cookie。

但这里有坑点:PC上我们采用了方案2,同域的业务dk接口带上cookie是没有问题,但是在安卓H5下怎么也播不了。抓包发现,在安卓下明明是同域的接口却无法携带任何cookie。查回文档才发现这么一句。

看来我们在移动端H5上我们无法采用和PC一样简单的方案二,但是方案一根据cookie生成token外,还要在播放地址参数,而我们原本只有filedId和dk接口,而并非用m3u8地址进行播放。 经过请教课堂的同事,原来Tcplayer提供一个文档中没有的参数HLStoken来传递token。

而这里的token,在播放器请求/cgi-bin/qcloud/get_dk时携带token,这样安卓浏览器上没办法携带cookie也没关系。

this.player = new window.TCPlaye(this.elIDId, {
      fileID, // 请传入需要播放的视频filID 必须
      appID  // 请传入点播账号的appID 必须
      autoplay: false,
      plugins: {
        HLSToken: {
          token, //根据cookie生成的token,这个参数文档中并没有。
        },
      },
    });

移动端H5音视频踩坑点

由于移动端H5除了系统及版本(IOS和安卓)各不相同、浏览器环境(微信、手Q和自带浏览器)也比PC复杂很多。加之WebRTC在移动端兼容性问题,从ios11.1.2到最新的12.4表现也存在差别。因此特意把一些踩过的主要坑做下总结。

  1. IOS 12.3开始需要支持unifiedplan 问题原因:WebRTC SDP标准 个浏览器厂商逐步采用unifiedplan,而苹果下plan-b无法使用。 解决方案:升级你的腾讯云WebRTC SDK至3.4.1,这部分修改了原本默认plan-b为unifiedplan,且补充unifiedplan路径走到的对应RTCUtils的API。
  2. WebRTC 在IOS非safari下,存在2分钟断流。 问题原因:IOS非safari的app(微信,手Q)的浏览器内核,无法获取PeerConnection的质量。关键在于获取质量的既没有成功的回调,也没有失败的回调。导致没有2s上报质量,然后就会断流。 解决方法:判断ua为IOS非safari的浏览器,强制补充失败的回调。
  3. video标签属性的兼容性问题。 解决方法:H5下需要使用playsinline属性,否则会自动全屏播放。属性需要加-webkit前缀,x5前缀。autoplay可能无效,需要用户操作事件触发播放。遇到video标签属性都要case by case处理。部分兼容性可参考docs.qq.com/sheet/DTGp4…
  4. videoEVent触发机制问题。 问题原因: H5 视频播放标准在各个平台终端的实现不一致性,事件的触发方式和结果会有差异。developer.mozilla.org/en-US/docs/…errortimeupdateloadloadedmetadataloadeddataprogressfullscreenplayplayingpauseendedseekingseekedresizevolumechange 解决方法:部分机器为触发播放后,才触发loadmeatadata和loaddata。调整首帧时间为可播放时间。
  5. IOS非unfiedplan,WebRTC更新流时无法给video重新设置流。 解决方法:IOS12.3以下版本,当画中画从有到无,再从无到有,重新建立WebRTC连接。
  6. X5内核tcplayer点播,系统全屏需要屏蔽下载按钮。 解决方法:找tcplayer同事,想x5内核团队提供域名,即可屏蔽。
  7. video系统自带control栏。 解决方法:<video controls >设为false,自己用dom实现控制栏,两个控制栏必备按键为:静音键,设置video.muted=true/false即可,另一个全屏键,参考下面全屏方案以及requestfullscreen API 相关内容。
  8. WebRTC系统全屏在X5内核的内部实现路径和MSE video不一致。 问题原因:询问X5内核同事,目前webrtc系统全屏和普通video的系统全屏,内部路径不一样。导致WebRTC系统全屏不完美,点击返回键会退出页面。关于整体全屏方案下面说明。

横竖屏检测及全屏方案

概念定义:屏幕全屏(系统全屏)or网页全屏(伪全屏)

  • 屏幕全屏:是指在屏幕范围内全屏,全屏后只有视频画面内容,看不到浏览器的地址栏等界面,这种全屏需要浏览器提供接口支持。
  • 网页全屏:是指在网页显示区域范围内全屏,全屏后仍可以看到浏览器的地址栏等界面,通常情况下网页全屏是为了应对浏览器不支持系统全屏而实现类似全屏的一种方式,所以又称伪全屏。该全屏方式由 CSS 实现。

至于网页全屏怎么实现,这里最简单的方式:进入网页全屏时,video设为fixed,调整z-index值,control栏同理占满。在横竖屏下处理稍有不同,对比屏幕和video的宽高比例,以宽或高设置为100%,另一边按比例计算即可,最终目的是达到contain的填充效果。

requestFullScreen API

支持屏幕全屏的接口有两种,一种称为 Fullscreen API,通过 Fullscreen API 进入屏幕全屏后的特点是,进入全屏后仍然可以看到由 HTML CSS 组成的播放器界面。另一种接口为 webkitEnterFullScreen,该接口只能作用于 video 标签,通常用于移动端不支持 Fullscreen API 的情况,通过该接口全屏后,播放器界面为系统自带的界面。

在实际业务中,对于全屏优先执行video.webkitEnterFullscreen();(苹果只支持这个API),当执行不成功则再执行video.webkitRequestFullScreen();

横竖屏判断,orientation检测

这里我使用了第三方检测代码:github.com/shrekshrek/… 这断代码除了能检测横竖屏外,还能检测重力及水平仪角度等,并且做了多终端兼容处理。

使用方法很简单:初始化监听,当dir值发生变化即为横竖屏变化,随即更新布局即可。

const _orienter = new window.Orienter();

this.props.updateOrientation(_orienter.direction); //先判断一次当前横竖屏

_orienter.onOrient = function (obj) {
  if (this.dir !== obj.dir) {
    this.dir = obj.dir;
    setTimeout(() => {
      //延迟200ms更新布局
      this.props.updateOrientation(obj.dir);
    }, 200);
  }
};
//开始监听横竖屏
_orienter.on();

注意:这里有设置200毫秒延时,原因是当横竖屏变化之后,取到的屏幕宽高仍然是“变化前”的屏幕宽高,立即更新布局会有问题。因此延迟200毫秒再更新布局。

上课状态及封面控制状态的设计

当拿到需求和交互视觉稿后,会发现一节课的有多种的封面,且封面可能在一个页面中随时间发生来回变化。这一切都源于上课状态的复杂,判断的维度有三个:1.当前时间。2.老师端是否上课。3.是否有视频流。 因此如何控制封面的变化,主要是维护和控制课堂状态的store值,然后映射封面即可。

封面状态设计:采用分级封面,状态映射来控制。组封面覆盖在子封面之上。 主封面组件:负责时间维度及CGI可以判断的大的课程状态,单向流程,不会切换。 子封面组件:负责播放状态的控制,根据老师上课状态、流状态控制,非单向流程,可能会来回切换。

网络类型及断开检测

在PC浏览器上课,一般都是wifi和有线上网,但是在移动端浏览器看直播和点播,除了wifi可能会消耗流量,需求是需要能够检测当前的网络状态(流量模式/wifi模式)进行判断。从而给用户响应的提示,避免用户在不知情的情况下消耗流量。

通过浏览器本身navigator.connection.type确实可以判断浏览器,但只有chrome支持移动端,其他浏览器几乎都不可用。

因此判断网络类型,还是要依赖于app提供的能力,基于两大app内浏览器,手Q和微信都有提供JS API判断网络类型,下面是代码实现。

return new Promise((resolve, reject) => {
    if (window.WeixinJSBridge) {
     //微信检测networktype
      window.WeixinJSBridge.invoke('getNetworkType', {}, (e) => {
        resolve({
          isNetWorkFlow:
            e.err_msg !== 'network_type:wifi' &&
            e.err_msg !== 'network_type:fail',
          isNetworkBroken: e.err_msg === 'network_type:fail',
        });
      });
    }
    if (window.mqq) {
    //手Q检测networktype
      window.mqq.device.getNetworkInfo((res) => {
        resolve({
          isNetWorkFlow: res.type === 2 || res.type === 3 || res.type === 4,
          isNetworkBroken: res.type === 0,
        });
      });
    } else {
      reject();
    }
  });

通过每3秒轮讯回调,除了判断网络类型是否为流量isNetWorkFlow,还判断当前网络isNetworkBroken是否断开。最后根据回调结果基于不同的提示。

上课页链接query参数控制及课程节切换

PC上课页链接query参数

在PC web项目中,每个上课页只对应一节课,链接的参数对应每节课的信息和当前课堂状态。

https://fudao.qq.com/pc/webclass.html?term_id=2000010153&course_id=113536&lesson=0&lesson_id=93561&status=2&sub_termid=0

course_idterm_id: 课程id和班级id lesson_id: 节id status:课堂状态 0未开始、1直播中、2回放、3、生成回放中 sub_termid:小班id

在PC浏览器,从课程任务页跳到上课页,链接上的query参数在上个页面已经确定,可以立即拿到status参数(由时间计算),这样有个好处就是,页面加载能立即根据课堂状态加载播放器和相应样式,不需要等待CGI请求;而坏处是切换课程是要重新加载刷新页面。

不刷新页面切换一节课

由于H5上课页包含了“课程大纲”,相当于把原本PC上的“课程任务页”和“上课页”集中在一个页面,产品希望能达到以下要求: 1. 课程切换不刷新页面。 2. 当链接上没有lesson_id,能定位到最近的未开始的一节课。 3. 切换节后,分享的链接是能定位到当前节。

以上三点就是实现起来比较困难,原因在于原本代码中,有相当的多地方是从链接上一次性获取参数,并且由于分享机制,也需要在不刷新页面修改链接参数,并且重新获取链接参数,重新加载播放器以及字幕组件。实现一个switchLesson方法,思路如下:

1.利用history.replaceState修改链接参数。
2.将已经挂载在全局的播放器销毁,比如tcplayer.dispose()。
3.通过disaptch触发CustomEvent,在页面根位置接收事件。
4.重置部分store的值、重新执行"获取服务器时间","获取课程信息"(设置缓存),"检查购买"等方法。(只需要_重置影响节切换_的值和执行方法)
5.卸载相关的react组件,willunmount中需要①取消时间监听②销毁定时器③销毁播放器实例和讨论区实例
6.重新加载渲染局部react组件。