前言
因在 APP Webview 内使用 video 标签会被浏览器劫持,导致在各机型上表现不一致。为了使 APP Webview 播放表现更一致,决定自定义 video 组件。
先来看看最终效果:
由于 iOS 在全屏状态下会使用原生的视频控制组件,所以在全屏状态下,Android、iOS 在全屏状态下均使用原生视频播放。
使用 React 实现。
一、步骤
- 数据结构
- DOM 元素及 CSS 样式编写
- 播放、暂停功能
- 时间显示
- 进度条
- 进入全屏、退出全屏控制,包含 Android、ios 兼容性
二、实现
数据结构
组件状态需要视频播放状态、当前时间、总时长、是否是全屏状态。
const initialState:VideoczState = {
videoState: 'null',
currentTime: 0,
duration: 0,
fullScreen: false,
};
const reducer = (state:VideoczState, action:VideoActionTypes) => {
switch (action.type) {
case VideoTypes.PLAY:
return { ...state, videoState: 'play' };
case VideoTypes.PAUSE:
return { ...state, videoState: 'pause' };
case VideoTypes.MODIFY:
return { ...state, ...action.payload };
case VideoTypes.ENTRYFULLSCREEN:
return { ...state, fullScreen: true };
case VideoTypes.QUITFULLSCREEN:
return { ...state, fullScreen: false };
default:
throw new Error();
}
};
DOM 元素及 CSS 样式编写
在 DOM 结构上分为四个部分:视频区、底部控制区、遮罩区(中间播放/暂停图标)、底部简易进度条。其中遮罩区为覆盖整个 video 区域,而底部简易进度条是可以并进底部控制区组件中的。
由于在移动端不需要音量调节,所以在组件中隐藏了音量调节。音量的实现和进度条是一样的,参考即可。
播放、暂停功能
播放、暂停功能就很简单了,直接调用 video 的 API 即可,代码参考:
const handleVideoPause = () => {
videoRef.current && videoRef.current.pause();
};
const handleVideoPlay = () => {
videoRef.current && videoRef.current.play();
};
时间显示
由于 video 的 currentTime、duration 为毫秒数,需要对这两个毫秒数做格式化处理:
// 时间转换
export const formatDuraton = (time:number) => {
let str = '00:00';
if(time > 0) {
const hour = Math.floor(time / 3600);
const min = Math.floor(time / 60) % 60;
const sec = Math.floor(time % 60);
const minStr = min >= 10 ? `${min}` : `0${min}`;
const secStr = sec >= 10 ? `${sec}` : `0${sec}`;
if(hour > 10) {
str = `${hour}:${minStr}:${secStr}`;
}
if(hour > 1) {
str = `0${hour}:${minStr}:${secStr}`;
} else {
str = `${minStr}:${secStr}`;
}
}
return str;
};
其中 duration 为视频的总时长,在视频未加载的情况下,无法获取到 duration,需要监听 video ondurationchange 钩子获取。注意:不能使用 onloadedmatedata 来获取 duration,此钩子在部分 Android 机中无法获得响应。
而 currentTime 为当前视频播放的时间节点,需要监听 ontimeupdate 钩子函数。
进度条显示
进度条的显示、拖拽、点击是自定义视频组件的重点。进度条的难点在于拖动的时候,需要知道拖动的终点占视频的百分之多少,从而调整视频当前时间,实现方式有两种,一种是使用 div 元素,一种是使用 input 元素,这里把两种实现方式都写一遍。
1. div 实现方式
使用 div 想知道拖动、点击时百分占比的话,需要通过 getBoundingClientRect 来获取进度条相对于屏幕的位置和拖动/点击时相对于屏幕的位置,从而计算出视频播放时间变化的百分比。
先来看看进度条结构,它包含三个部分:整体进度条、左侧已播放段、时间指针(thumb)
{/* 进度条 */}
<div
id="progress"
styleName="progress"
style={progressStyle}
>
// 左侧已播放
<div style={progressSuppleStyle}>
<span styleName="inside" style={insideStyle} />
</div>
// 时间指针
<span
styleName="point"
style={pointStyle}
onMouseDown={handleMouseDownProgress}
onTouchStart={handleMouseDownProgress} />
</div>
需要通过监听鼠标移动事件,来判断用户拖动,注意在 PC 端和移动端监听的事件名不一样:
// 监听手指放在时间指针上
useEffect(() => {
const hanldeMouseUp = () => {
if(!progressLockRef.current) {
progressLockRef.current = true;
dispatch({ type: VideoTypes.PAUSE });
}
};
document.body.addEventListener('mouseup', hanldeMouseUp);
document.body.addEventListener('touchend', hanldeMouseUp);
return () => {
document.body.removeEventListener('touchend', hanldeMouseUp);
document.body.removeEventListener('mouseup', hanldeMouseUp);
};
}, []);
/**
* 进度条拖动
*/
useEffect(() => {
const mouseMoveListener = (event:any) => {
// 设置一个开关,只有用户放在时间指针上时才解锁
// 防止用户在其它地方移动时,触发进度条拖动
if(progressLockRef.current) return;
const client = getClient(event);
if(!client) return;
const { clientX } = client;
// 获取进度条位置
const elePosition = getEleRelativeScreenPosition('progress');
if(!elePosition) return;
const { eleWidth: progressWidth, eleBorderLeft: progressBorderLeft, eleBorderRight: progressBorderRight } = elePosition;
if(clientX <= progressBorderLeft) {
// 拖动范围超出进度条左侧
dispatch({ type: VideoTypes.MODIFY, payload: { currentTime: 0 } });
}else if(clientX >= progressBorderRight) {
// 拖动范围超出进度条右侧
dispatch({ type: VideoTypes.MODIFY, payload: { currentTime: duration } });
}else {
// 拖动范围在进度条内
const percent = (clientX - progressBorderLeft) / progressWidth;
const time = Math.floor(duration * percent);
dispatch({ type: VideoTypes.MODIFY, payload: { currentTime: time } });
}
};
// PC 端
window.addEventListener('mousemove', mouseMoveListener, false);
// 移动端
window.addEventListener('touchmove', mouseMoveListener, false);
return () => {
window.removeEventListener('mousemove', mouseMoveListener);
window.removeEventListener('touchmove', mouseMoveListener, false);
};
}, [duration]);
// utils.js
// 获取元素位置
export const getEleRelativeScreenPosition = (ele:HTMLElement|string) => {
let _ele;
if(typeof ele === 'object') {
_ele = ele;
} else if(typeof ele === 'string') {
_ele = document.getElementById(ele);
}
if(!isElement(_ele) || !_ele) return null;
const client = _ele.getBoundingClientRect();
const eleWidth = _ele ? _ele.offsetWidth : 0;
const eleHeight = _ele ? _ele.offsetHeight : 0;
// 元素左侧边界相对于屏幕左侧的位置
const eleBorderLeft = _ele ? client.left : 0;
// 元素右侧边界相对于屏幕左侧的位置
const eleBorderRight = _ele ? client.right : 0;
// 元素顶部边界相对于屏幕顶部的位置
const eleBorderTop = _ele ? client.top : 0;
// 元素底部边界相对于屏幕顶部的位置
const eleBorderBottom = _ele ? client.bottom : 0;
return { eleWidth, eleHeight, eleBorderBottom, eleBorderLeft, eleBorderRight, eleBorderTop };
};
2. input 实现方式
input 有 type=range 属性,原生支持拖动事件,实现起来比 div 简单很多。onChange 事件直接返回当前时间指针所占百分比
<input
css={inputCss}
type="range"
min="0"
max="100"
value={percent >> 0}
step="1"
onMouseDown={(e) => {
operation.handleVideoPause();
}}
onMouseUp={(e) => {
operation.handleVideoPlay();
}}
onTouchStart={(e) => {
operation.handleVideoPause();
}}
onTouchEnd={(e) => {
operation.handleVideoPlay();
}}
onChange={(e) => {
e.preventDefault();
e.stopPropagation();
const time = Math.floor(duration * (Number.parseInt(e.target.value) / 100));
// TODO: 修改 video currentTime
}}
/>
进入/退出全屏
1. 进入全屏
进入全屏直接使用 requestFullScreen API 即可,注意兼容性:
const handleFullScreen = () => {
const container = document.getElementById('video-container');
if(!container) return;
if(videoRef.current) {
if (videoRef.current.requestFullscreen) {
videoRef.current.requestFullscreen();
} else if (videoRef.current.mozRequestFullScreen) {
videoRef.current.mozRequestFullScreen();
} else if (videoRef.current.webkitRequestFullscreen) {
videoRef.current.webkitRequestFullscreen();
} else if (videoRef.current.msRequestFullscreen) {
videoRef.current.msRequestFullscreen();
} else if (videoRef.current.webkitEnterFullScreen) {
videoRef.current.webkitEnterFullScreen();
}
}
在 iOS 中,只要视频进入全屏状态,无论 video 有没有设置 controls ,系统的全屏控制器都会接管 video,无法应用自定义的控制器。因此视频进入全屏状态后,无论在 iOS 还是 Android 均使用原生的全屏控制器。
另外,在 Android 部分机型中,例如 OPPO 系列的手机,即使是处于 inline 的播放状态,也依然会使用系统自带的播放器,目前这个没有办法可以解决。
2. 退出全屏
组件还需要监听用户退出全屏,监听 fullscreenchange 再使用 document.fullscreenElement 判断是否有元素处于全屏状态。
在 iOS 中无法监听到 fullscreenchange,搭配兼容性写法也依然无法监听。最后使用一个定时器去轮训检查。且在 iOS 中 document.fullscrrenElement 也始终返回 undefined,查阅了苹果的开发者文档,需要使用 webkitDisplayingFullscreen 才能检查到元素是否处于全屏状态。
/**
* 监听全屏
*/
useEffect(() => {
const handleFullScreenChange = () => {
if(document.fullscreenElement === null) {
dispatch({ type: VideoTypes.QUITFULLSCREEN });
}
};
const handleIosInterval = () => {
if(videoRef.current) {
!videoRef.current.webkitDisplayingFullscreen && dispatch({ type: VideoTypes.QUITFULLSCREEN });
}
};
// ios 无法监听到 fullscreenchange 事件,设置一个定时器检查
const iosIntervalTimer = setInterval(handleIosInterval, 500);
document.addEventListener('fullscreenchange', handleFullScreenChange, false);
document.addEventListener('mozfullscreenchange', handleFullScreenChange, false);
document.addEventListener('webkitfullscreenchange', handleFullScreenChange, false);
document.addEventListener('msfullscreenchange', handleFullScreenChange, false);
return () => {
document.removeEventListener('fullscreenchange', handleFullScreenChange);
document.removeEventListener('mozFullScreen', handleFullScreenChange);
document.removeEventListener('webkitfullscreenchange', handleFullScreenChange);
clearInterval(iosIntervalTimer);
};
}, []);
兼容性问题处理
在设计自定义视频播放组件时,原本设计的是通过控制 State,监听 State 的变化,然后修改 video 的状态。
在 Chrome 中表现良好,但是在 ios Safari 中无效,提示错误为:
原因是修改 State 是一个异步非实时的操作,而 Safari 不允许非用户触发的播放、暂停等行为。
最后修改为直接操作 Video 的行为,然后监听 Video 行为变化从而改变 State 状态。
总结
观察了其它移动端自定义控制器,发现多是使用 div 的实现方式。目前 input 的实现方式符合实际需求,或许是有什么细节的地方需要使用 div 去实现。
做移动端视频组件有很多兼容性问题需要注意,在 iOS 和 Android 端均有很多不一致的表现,并且很多 Android 手机厂商底层就不接受自定义视频控制器。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!