alipay-emulator/components/DomVideoPlayer/DomVideoPlayer.vue

574 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- eslint-disable -->
<template>
<view class="player-wrapper" :id="videoWrapperId" :parentId="id" :randomNum="randomNum"
:change:randomNum="domVideoPlayer.randomNumChange" :viewportProps="viewportProps"
:change:viewportProps="domVideoPlayer.viewportChange" :videoSrc="videoSrc"
:change:videoSrc="domVideoPlayer.initVideoPlayer" :command="eventCommand"
:change:command="domVideoPlayer.triggerCommand" :func="renderFunc" :change:func="domVideoPlayer.triggerFunc" />
</template>
<script>
export default {
props: {
src: {
type: String,
default: ''
},
autoplay: {
type: Boolean,
default: false
},
loop: {
type: Boolean,
default: false
},
controls: {
type: Boolean,
default: false
},
objectFit: {
type: String,
default: 'contain'
},
muted: {
type: Boolean,
default: false
},
playbackRate: {
type: Number,
default: 1
},
isLoading: {
type: Boolean,
default: false
},
poster: {
type: String,
default: ''
},
id: {
type: String,
default: ''
}
},
data() {
return {
randomNum: Math.floor(Math.random() * 100000000),
videoSrc: '',
// 父组件向子组件传递的事件指令video的原生事件
eventCommand: null,
// 父组件传递过来的,对 renderjs 层的函数执行(对视频控制的自定义事件)
renderFunc: {
name: null,
params: null
},
// 提供给父组件进行获取的视频属性
currentTime: 0,
duration: 0,
playing: false
}
},
watch: {
// 监听视频资源地址更新
src: {
handler(val) {
if (!val) return
setTimeout(() => {
this.videoSrc = val
}, 0)
},
immediate: true
}
},
computed: {
videoWrapperId() {
return `video-wrapper-${this.randomNum}`
},
// 聚合视图层的所有数据变化传给renderjs的渲染层
viewportProps() {
return {
autoplay: this.autoplay,
muted: this.muted,
controls: this.controls,
loop: this.loop,
objectFit: this.objectFit,
poster: this.poster,
isLoading: this.isLoading,
playbackRate: this.playbackRate
}
}
},
// 方法
methods: {
// 传递事件指令给父组件
eventEmit({ event, data }) {
this.$emit(event, data)
},
// 修改view视图层的data数据
setViewData({ key, value }) {
key && this.$set(this, key, value)
},
// 重置事件指令
resetEventCommand() {
this.eventCommand = null
},
// 播放指令
play() {
this.eventCommand = 'play'
},
// 暂停指令
pause() {
this.eventCommand = 'pause'
},
// 重置自定义函数指令
resetFunc() {
this.renderFunc = {
name: null,
params: null
}
},
// 自定义函数 - 移除视频
remove(params) {
this.renderFunc = {
name: 'removeHandler',
params
}
},
// 自定义函数 - 全屏播放
fullScreen(params) {
this.renderFunc = {
name: 'fullScreenHandler',
params
}
},
// 自定义函数 - 跳转到指定时间点
toSeek(sec, isDelay = false) {
this.renderFunc = {
name: 'toSeekHandler',
params: { sec, isDelay }
}
}
},
// 组件销毁前自动清理 renderjs 层的原生 video 元素,
// 防止 v-if 移除组件后视频仍在后台播放声音("僵尸视频"问题)
beforeUnmount() {
this.renderFunc = {
name: 'removeHandler',
params: null
}
}
}
</script>
<script module="domVideoPlayer" lang="renderjs">
const PLAYER_ID = 'DOM_VIDEO_PLAYER'
export default {
data() {
return {
num: '',
videoEl: null,
loadingEl: null,
// 延迟生效的函数
delayFunc: null,
renderProps: {},
// 是否主动暂停(通过父组件调用 pause() 方法)
intentionalPause: false,
// 上次自动恢复的时间戳,用于冷却控制
lastAutoRecoverTime: 0
}
},
computed: {
playerId() {
return `${PLAYER_ID}_${this.num}`
},
wrapperId() {
return `video-wrapper-${this.num}`
}
},
methods: {
isApple() {
const ua = navigator.userAgent.toLowerCase()
return ua.indexOf('iphone') !== -1 || ua.indexOf('ipad') !== -1
},
async initVideoPlayer(src) {
this.delayFunc = null
await this.$nextTick()
if (!src) return
if (this.videoEl) {
// 切换视频源
if (!this.isApple() && this.loadingEl) {
this.loadingEl.style.display = 'block'
}
this.videoEl.src = src
return
}
const videoEl = document.createElement('video')
this.videoEl = videoEl
// 开始监听视频相关事件
this.listenVideoEvent()
const { autoplay, muted, controls, loop, playbackRate, objectFit, poster } = this.renderProps
videoEl.src = src
videoEl.autoplay = autoplay
videoEl.controls = controls
videoEl.loop = loop
videoEl.muted = muted
videoEl.playbackRate = playbackRate
videoEl.id = this.playerId
// 开启 X5 内核的 H5 同层渲染,防止原生视频组件层级过高在拖拽时脱离或卡死
videoEl.setAttribute('x5-video-player-type', 'h5')
videoEl.setAttribute('x5-video-player-fullscreen', 'false')
videoEl.setAttribute('preload', 'auto')
videoEl.setAttribute('playsinline', true)
videoEl.setAttribute('webkit-playsinline', true)
videoEl.setAttribute('crossorigin', 'anonymous')
videoEl.setAttribute('controlslist', 'nodownload')
videoEl.setAttribute('disablePictureInPicture', true)
videoEl.style.objectFit = objectFit
poster && (videoEl.poster = poster)
videoEl.style.width = '100%'
videoEl.style.height = '100%'
// 插入视频元素
// document.getElementById(this.wrapperId).appendChild(videoEl)
const playerWrapper = document.getElementById(this.wrapperId)
playerWrapper.insertBefore(videoEl, playerWrapper.firstChild)
// 插入loading 元素(遮挡安卓的默认加载过程中的黑色播放按钮)
this.createLoading()
},
// 创建 loading
createLoading() {
const { isLoading } = this.renderProps
if (!this.isApple() && isLoading) {
const loadingEl = document.createElement('div')
this.loadingEl = loadingEl
loadingEl.className = 'loading-wrapper'
loadingEl.style.position = 'absolute'
loadingEl.style.top = '0'
loadingEl.style.left = '0'
loadingEl.style.zIndex = '1'
loadingEl.style.width = '100%'
loadingEl.style.height = '100%'
loadingEl.style.backgroundColor = 'black'
document.getElementById(this.wrapperId).appendChild(loadingEl)
// 创建 loading 动画
const animationEl = document.createElement('div')
animationEl.className = 'loading'
animationEl.style.zIndex = '2'
animationEl.style.position = 'absolute'
animationEl.style.top = '50%'
animationEl.style.left = '50%'
animationEl.style.marginTop = '-15px'
animationEl.style.marginLeft = '-15px'
animationEl.style.width = '30px'
animationEl.style.height = '30px'
animationEl.style.border = '2px solid #FFF'
animationEl.style.borderTopColor = 'rgba(255, 255, 255, 0.2)'
animationEl.style.borderRightColor = 'rgba(255, 255, 255, 0.2)'
animationEl.style.borderBottomColor = 'rgba(255, 255, 255, 0.2)'
animationEl.style.borderRadius = '100%'
animationEl.style.animation = 'circle infinite 0.75s linear'
loadingEl.appendChild(animationEl)
// 创建 loading 动画所需的 keyframes
const style = document.createElement('style')
const keyframes = `
@keyframes circle {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
`
style.type = 'text/css'
if (style.styleSheet) {
style.styleSheet.cssText = keyframes
} else {
style.appendChild(document.createTextNode(keyframes))
}
document.head.appendChild(style)
}
},
// 监听视频相关事件
listenVideoEvent() {
// 播放事件监听
const playHandler = () => {
this.$ownerInstance.callMethod('eventEmit', { event: 'play' })
this.$ownerInstance.callMethod('setViewData', {
key: 'playing',
value: true
})
if (this.loadingEl) {
this.loadingEl.style.display = 'none'
}
}
this.videoEl.removeEventListener('play', playHandler)
this.videoEl.addEventListener('play', playHandler)
// 暂停事件监听
const pauseHandler = () => {
this.$ownerInstance.callMethod('eventEmit', { event: 'pause' })
this.$ownerInstance.callMethod('setViewData', {
key: 'playing',
value: false
})
// 自动恢复机制:仅在非主动暂停时触发,并带有 2 秒冷却期防止循环拖动
const { loop, autoplay } = this.renderProps
const now = Date.now()
if (loop && autoplay && this.videoEl && !this.intentionalPause && (now - this.lastAutoRecoverTime > 2000)) {
this.lastAutoRecoverTime = now
setTimeout(() => {
if (this.videoEl && this.videoEl.paused && !this.intentionalPause) {
this.videoEl.play().catch(() => {})
}
}, 200)
}
}
this.videoEl.removeEventListener('pause', pauseHandler)
this.videoEl.addEventListener('pause', pauseHandler)
// 结束事件监听
const endedHandler = () => {
this.$ownerInstance.callMethod('eventEmit', { event: 'ended' })
this.$ownerInstance.callMethod('resetEventCommand')
}
this.videoEl.removeEventListener('ended', endedHandler)
this.videoEl.addEventListener('ended', endedHandler)
// 加载完成事件监听
const canPlayHandler = () => {
this.$ownerInstance.callMethod('eventEmit', { event: 'canplay' })
this.execDelayFunc()
}
this.videoEl.removeEventListener('canplay', canPlayHandler)
this.videoEl.addEventListener('canplay', canPlayHandler)
// 加载失败事件监听
const errorHandler = (e) => {
if (this.loadingEl) {
this.loadingEl.style.display = 'block'
}
this.$ownerInstance.callMethod('eventEmit', { event: 'error' })
}
this.videoEl.removeEventListener('error', errorHandler)
this.videoEl.addEventListener('error', errorHandler)
// loadedmetadata 事件监听
const loadedMetadataHandler = () => {
this.$ownerInstance.callMethod('eventEmit', { event: 'loadedmetadata' })
// 获取视频的长度
const duration = this.videoEl.duration
this.$ownerInstance.callMethod('eventEmit', {
event: 'durationchange',
data: duration
})
this.$ownerInstance.callMethod('setViewData', {
key: 'duration',
value: duration
})
// 加载首帧视频 模拟出封面图
this.loadFirstFrame()
}
this.videoEl.removeEventListener('loadedmetadata', loadedMetadataHandler)
this.videoEl.addEventListener('loadedmetadata', loadedMetadataHandler)
// 播放进度监听
let lastTime = 0;
const timeupdateHandler = (e) => {
const now = Date.now();
// 增加节流阀,每 500ms 触发一次通信,极大地减少 renderjs 和逻辑层的通讯开销
if (now - lastTime < 500) return;
lastTime = now;
const currentTime = e.target.currentTime
this.$ownerInstance.callMethod('eventEmit', {
event: 'timeupdate',
data: currentTime
})
this.$ownerInstance.callMethod('setViewData', {
key: 'currentTime',
value: currentTime
})
}
this.videoEl.removeEventListener('timeupdate', timeupdateHandler)
this.videoEl.addEventListener('timeupdate', timeupdateHandler)
// 倍速播放监听
const ratechangeHandler = (e) => {
const playbackRate = e.target.playbackRate
this.$ownerInstance.callMethod('eventEmit', {
event: 'ratechange',
data: playbackRate
})
}
this.videoEl.removeEventListener('ratechange', ratechangeHandler)
this.videoEl.addEventListener('ratechange', ratechangeHandler)
// 全屏事件监听
if (this.isApple()) {
const webkitbeginfullscreenHandler = () => {
const presentationMode = this.videoEl.webkitPresentationMode
let isFullScreen = null
if (presentationMode === 'fullscreen') {
isFullScreen = true
} else {
isFullScreen = false
}
this.$ownerInstance.callMethod('eventEmit', {
event: 'fullscreenchange',
data: isFullScreen
})
}
this.videoEl.removeEventListener('webkitpresentationmodechanged', webkitbeginfullscreenHandler)
this.videoEl.addEventListener('webkitpresentationmodechanged', webkitbeginfullscreenHandler)
} else {
const fullscreenchangeHandler = () => {
let isFullScreen = null
if (document.fullscreenElement) {
isFullScreen = true
} else {
isFullScreen = false
}
this.$ownerInstance.callMethod('eventEmit', {
event: 'fullscreenchange',
data: isFullScreen
})
}
document.removeEventListener('fullscreenchange', fullscreenchangeHandler)
document.addEventListener('fullscreenchange', fullscreenchangeHandler)
}
},
// 加载首帧视频,模拟出封面图
loadFirstFrame() {
let { autoplay, muted } = this.renderProps
if (this.isApple()) {
this.videoEl.play()
if (!autoplay) {
this.videoEl.pause()
}
} else {
// optimize: timeout 延迟调用是为了规避控制台的`https://goo.gl/LdLk22`这个报错
/**
* 原因chromium 内核中,谷歌协议规定,视频不允许在非静音状态下进行自动播放
* 解决:在自动播放时,先将视频静音,然后延迟调用 play 方法,播放视频
* 说明iOS 的 Safari 内核不会有这个,仅在 Android 设备出现,即使有这个报错也不影响的,所以不介意控制台报错的话是可以删掉这个 timeout 的
*/
this.videoEl.muted = true
setTimeout(() => {
this.videoEl.play()
this.videoEl.muted = muted
if (!autoplay) {
setTimeout(() => {
this.videoEl.pause()
}, 100)
}
}, 10)
}
},
triggerCommand(eventType) {
if (eventType) {
// 标记主动暂停/播放状态,用于自动恢复机制的判断
if (eventType === 'pause') {
this.intentionalPause = true
} else if (eventType === 'play') {
this.intentionalPause = false
}
this.$ownerInstance.callMethod('resetEventCommand')
this.videoEl && this.videoEl[eventType]()
}
},
triggerFunc(func) {
const { name, params } = func || {}
if (name) {
this[name](params)
this.$ownerInstance.callMethod('resetFunc')
}
},
removeHandler() {
if (this.videoEl) {
this.videoEl.pause()
this.videoEl.src = ''
this.$ownerInstance.callMethod('setViewData', {
key: 'videoSrc',
value: ''
})
this.videoEl.load()
}
},
fullScreenHandler() {
if (this.isApple()) {
this.videoEl.webkitEnterFullscreen()
} else {
this.videoEl.requestFullscreen()
}
},
toSeekHandler({ sec, isDelay }) {
const func = () => {
if (this.videoEl) {
this.videoEl.currentTime = sec
}
}
// 延迟执行
if (isDelay) {
this.delayFunc = func
} else {
func()
}
},
// 执行延迟函数
execDelayFunc() {
this.delayFunc && this.delayFunc()
this.delayFunc = null
},
viewportChange(props) {
this.renderProps = props
const { autoplay, muted, controls, loop, playbackRate } = props
if (this.videoEl) {
this.videoEl.autoplay = autoplay
this.videoEl.controls = controls
this.videoEl.loop = loop
this.videoEl.muted = muted
this.videoEl.playbackRate = playbackRate
}
},
randomNumChange(val) {
this.num = val
}
},
// renderjs 层直接清理原生 video 元素,
// 确保组件被 v-if 销毁时视频立即停止播放和声音输出
beforeUnmount() {
if (this.videoEl) {
this.videoEl.pause()
this.videoEl.src = ''
this.videoEl.load()
this.videoEl = null
}
}
}
</script>
<style scoped>
.player-wrapper {
overflow: hidden;
height: 100%;
padding: 0;
position: relative;
/* 开启硬件加速,将视频提升为独立合成层 */
transform: translateZ(0);
}
</style>