记一次项目总结: 腾讯视频iframeAPI 的使用及踩坑指南
背景
目前业务的某平台内容呈现方式有图文、图片等形式,此次项目为增加视频的呈现。 PM同学调研后,决定使用腾讯视频来接入,其中以下两个功能点是在本次项目中耗时较长的点。
腾讯视频统一播放器是视频应用于全平台播放(电脑,手机,平板电脑,电视[Sumsang],支持点播和直播,支持自定义插件的JavaScript框架。(对内使用的简称为 js api)
1 统计用户实际播放时长
【上图截取自腾讯视频官方api文档】2 断点续播
如果从A视频,点击进入B视频,且点击了播放,再返回A视频,从头进行播放(但记住B的播放位置) 如果从A视频,点击进入B视频,但未点击播放,再返回A视频,继续上次播放位置
前期技术调研时,发现可通过参数控制断点续播
调研后,感觉难度不大。
问题
然而进入开发之后,发现一个悲伤的事情……
对于外部,腾讯视频提供IFrame Player API方式调用(下述简称 iframe api)
1 可用接口与配置参数变少
iframe api 提供的无论是参数还是api都远少于内部使用的 js api。 也就是说,我们调研时的那几个 api 均不可用。
2 接口调用方式变化
比如想要获取当前播放视频的总时长。(我们只能用后者)
// js api
var duration = player.getDuration()
console.log(`总时长为:${duration}`)
// iframe api
player.getDuration().then(t => {
console.log(`总时长为:${t}`)
}
复制代码
解决
统计用户实际播放时长相对来说比较好解决。断点续播走了不少弯路……
1 统计用户实际播放时长
这个功能还好,依赖于大数据的埋点统计功能,我只需要监听 player 的播放状态,控制其在播放时轮询发送埋点,暂停与结束时取消轮询即可。核心代码如下
componentDidMount () {
// ① 初始化播放器
this.initPlayer()
// ② 监听 player 播放状态变更
this.onChangePlayState()
// ...
}
// 监听 player 播放状态变更
// -1(未开始)| 0(已结束)| 1(正在播放)| 2(已暂停)| 3(正在缓冲)
onChangePlayState () {
const statusFn = {
0: () => {
// 若已结束,清空定时器
console.log('已结束')
this.cancelSendDigPoint()
},
1: () => {
// 轮询:点击播放发送埋点,记录播放时长
console.log('播放中')
this.sendDigPoint()
},
2: () => {
console.log('已暂停')
// 暂停不记录当前播放时长,停止轮询
this.cancelSendDigPoint()
},
3: () => {
console.log('正在缓冲')
}
}
player.on('playStateChange', (status) => {
console.log('播放状态变更', status)
statusFn[status] && statusFn[status]()
})
}
// 发送埋点:统计用户实际播放时长
sendDigPoint () {}
// 停止发送埋点: 暂停时触发
cancelSendDigPoint () {}
复制代码
这里有 2 个细节点是要注意的
1.1 定时器的清空
由于视频播放过程中是流式缓存,播放过程中可能产生多个定时器。 故在轮询时,定时器产生的 timer 应该放到数组中,在清除定时器时再去遍历数组清空。
// 发送埋点:统计用户实际播放时长
sendDigPoint () {
let timer = setInterval(() => {
// 发送埋点
}, 3000)
// 因为视频是流式播放,播放过程中可能会产生多个 timerId。
// 故保存在数组中,以便清空定时器时能完全清掉
txvTimers.push(timer)
}
// 停止发送埋点: 暂停时触发
cancelSendDigPoint () {
console.error('停止发送埋点', txvTimers)
txvTimers && txvTimers.length && txvTimers.forEach(timer => {
clearInterval(timer)
})
// clearInterval(this.timer)
console.error('已停止 停止发送埋点')
}
复制代码
1.2 播放结束时的处理
假定我们每3秒轮询一次,视频长度为61秒,那么最后1秒将记录不到。故还需在播放结束时再强制发送一次埋点
onChangePlayState () {
const statusFn = {
0: () => {
// 结束时强制发送一次埋点,将视频总时长作为参数之一发送出去
// ...
},
// ...
}
// ...
}
复制代码
2 断点续播
先回顾一下需求:
- 如果从A视频,点击进入B视频,且点击了播放,再返回A视频,从头进行播放(但记住B的播放位置)
- 如果从A视频,点击进入B视频,但未点击播放,再返回A视频,继续上次播放位置
整理初步思路如下:
- 监听离开当前页面前的事件(beforeunload)
- 在离开页面前,通过
player.getCurrentTime()
获取视频当前播放时间 seektime - 将 seektime 保存在 localStorage 中
- 监听进入页面事件 (load), 从 localStorage 中获取当前视频的 seektime
- 通过
player.seek()
跳转进度到 seektime - 监听视频播放状态,如果为播放中,则清空 localStorage
根据初步思路,实现如下
componentDidMount () {
// ① 初始化播放器
this.initPlayer()
// ② 监听 player 播放状态变更
this.onChangePlayState()
// ③进入页面时:查询是否有续播时间,如有,则定位视频播放进度到该时间
window.addEventListener('load', beforeLoad, false)
// 离开页面前:暂停播放 => 由 ③ 可将当前播放时间存储到 localStorage 中
window.addEventListener('beforeunload', beforeUnload, false)
}
// 监听 player 播放状态变更
// -1(未开始)| 0(已结束)| 1(正在播放)| 2(已暂停)| 3(正在缓冲)
onChangePlayState () {
const statusFn = {
// ...
1: () => {
console.log('播放中')
// ① 只要开始播放, 就清空 localStorage 中存的所有 seektimes(@PM需求)
STORE.remove(SEEK_TIME_KEY)
this.sendDigPoint()
},
// ...
}
// ...
}
// 函数方法 (getSeektimeByStore、setSeektimeToStore 略)
function beforeLoad () {
let seektime = getSeektimeByStore(txVideoId)
if (player && seektime) {
player.seek(seektime)
player.pause()
}
}
function beforeUnload () {
player.pause()
player.getCurrentTime().then(t => {
setSeektimeToStore(txVideoId, seektime)
})
}
复制代码
2.1 问题1:视频会偶发性从头播放
分析找到原因:
视频在初始化之后,其状态刚开始是 -1 (未开始)。过一段时间后,才会完成缓冲(3) 变成暂停状态(2)。
- 当视频的状态还是 -1 时,如果用户点击了播放按钮,将会从头播放
- 当视频的状态变为 2 后,用户点击播放,会从 seektime(上次离开时记录的时间) 开始播放
由于我们无法控制用户的操作,且视频具体何时才会由 -1 变至 2 也无从得知。该写法是无法满足需求的。
解决方案:在监听播放器播放状态变更时,再去取一遍 seektime,强制跳转
onChangePlayState () {
const statusFn = {
// ...
1: () => {
console.log('播放中')
let seektime = getSeektimeByStore(txVideoId)
// 注:仅仅依靠在 componentDidMount 中的 player.seek() 方法,不能完全让视频从上次播放位置起播
// 原因:当 player 的状态值还是 -1 时,点击播放仍会从 0 开始。
if (seektime) {
// 故要再 player.seek() 一次
player.seek(seektime)
}
// ...
}
// ...
}
// ...
}
复制代码
2.2 问题2: 苹果手机仍会从头播放
经排查,发现 iso 无法监听 beforeunload 事件,于是改用 pagehide 做监听
componentDidMount () {
// ...
window.addEventListener('load', beforeLoad, false)
window.addEventListener('pageshow', beforeLoad, false)
window.addEventListener('beforeunload', beforeUnload, false)
window.addEventListener('pagehide', beforeUnload, false)
}
复制代码
然而悲伤地发现,pagehide 中的方法仍未生效
解决:降级处理。在每次轮询发送埋点时,记录当前播放时间到 localStorage 中(简称 maidianTime),在 beforeLoad 时,如果从 localStorage 中取不到 seektime,就使用 maidianTime 作为跳转进度值
// 发送埋点:统计用户实际播放时长
sendDigPoint () {
let timer = setInterval(() => {
// 发送埋点
// 因为 ios 监听不到页面离开的事件,无法在离开页面时去存值
// 发送埋点时,额外存一份t到 localStorage 中
// 另存一份而不是在原来的 SEEK_TIME_KEY 中存的原因:存在 SEEK_TIME_KEY 中将无法拖动进度条
STORE.set(txVideoId, t)
}, 3000)
txvTimers.push(timer)
}
function beforeLoad () {
let seektime = getSeektimeByStore(txVideoId) || STORE.get(txVideoId)
setSeektimeToStore(txVideoId, seektime)
if (player && seektime) {
player.seek(seektime)
player.pause()
}
}
复制代码
2.3 问题3:load 事件不一定第一时间触发
经调试又发现:我们原以为第一时间就会触发的 load 事件,并非如此。大多数情况下能立马触发,偶尔会隔好久才触发。这就导致当其还未触发时,若苹果手机用户点击了播放,会从头播放。
解决:不再监听 load 事件,直接写在 componentDidMount 中
componentDidMount () {
// ...
// 不再监听 load/pageshow 事件,因为监听到的时机不可控
let seektime = getSeektimeByStore(txVideoId) || STORE.get(txVideoId)
setSeektimeToStore(txVideoId, seektime)
window.addEventListener('beforeunload', beforeUnload, false)
window.addEventListener('pagehide', beforeUnload, false)
}
复制代码
反思与总结
其实还有一些其他的问题,篇幅所限,暂时写到这里。 经过这次项目,反思总结如下:
-
熟读文档的重要性。第三方开发文档一定要仔仔细细研读,有的 api 用法可能就是与众不同地藏在角落里(异步api的回调)。
-
开阔思路,如果尝试多种方案均无法达成目标,优雅降级处理也未尝不可(ios 无法监听 unbeforeload 事件,pagehide 事件中的代码也未执行,解决办法:使用上一次发送视频播放埋点的时间)