前言
在写 JS30 11 - Custom Video Players 时遇到了做个播放器的需求,包括正常的拖拽、快进、调节音量等,记得当时用 React 仿写网抑云音乐时也写到了这个,这次用原生JS实现,顺便复习下 video 标签的一些 api 以及实现拖拽进度条。
文末有完整代码
正文
播放与暂停
js
const toggle = player.querySelector('.toggle');
function toggleBtn() {
let mode, icon
if (video.paused) {
mode = 'play'
icon = '❚ ❚'
} else {
mode = 'pause'
icon = '►'
}
video[mode]()
this.innerHTML = icon
}
toggle.addEventListener('click',toogleBtn)
video.paused
可以拿到视频的播放状态,通过其判断即可
显示进度条
js
function showProgress() {
const { currentTime, duration } = video
const percent = (currentTime / duration) * 100;
progressBar.style.flexBasis = `${percent}%`;
}
video.addEventListener('timeupdate', showProgress);
html
<div class="progress">
<div class="progress__filled"></div>
</div>
css
.progress {
flex: 10;
position: relative;
display: flex;
flex-basis: 100%;
height: 5px;
transition: height 0.3s;
background: rgba(0,0,0,0.5);
cursor: ew-resize;
}
.progress__filled {
width: 50%;
background: #ffc600;
flex: 0;
flex-basis: 50%;
}
video.currentTime
和video.duration
分别可以拿到当前视频的播放进度以及总时长
timeupdate
事件是当currentTime
更新时就会触发
页面设计中他用到了一个 flex 属性:flex-basis,其指定了 flex 元素在主轴方向上的初始大小,用(video.currentTime / video.duration) * 100
得到百分比设置即可
大部分播放器还会显示出当前时间与总时间,例如 xx:xx/yy:yy, 之前写过一个简单的函数来格式化(time单位为 s,其他情况可自行转换)
ts
export function formatTime(time:number){
const minute = Math.floor(time/60)
const second = Math.floor(time%60)
const formatMinute = minute.toString().padStart(2,'0')
const formatSecond = second.toString().padStart(2,'0')
return `${formatMinute}:${formatSecond}`
}
进度条拖拽
之前在写 React 时因为是数据驱动视图并且使用了 Antd 的 Slider 组件,因此写起来更方便
ts
// 拖拽中
function handleSliding(value:number){
setIsSliding(true)
setProgress(value)
const currentTime = (value/100)* duration
setCurrentTime(currentTime)
}
// 拖拽完
function handleSlider(value:number){
const currentTime = (value/100) * duration
audioRef.current!.currentTime = currentTime / 1000
setCurrentTime(currentTime)
setProgress(value)
setIsSliding(false)
}
tsx
<Slider
step={0.5}
value={progress}
tooltip={{formatter : null}}
onChange={handleSliding}
onAfterChange={handleSlider}
/>
这次在写时只能手动监听长度来实现百分比,首先实现简单的点击:
js
function slider(e) {
const currentTime = (e.offsetX / progress.offsetWidth) * video.duration;
video.currentTime = currentTime;
}
progress.addEventListener('click', slider);
再进行拖动时,涉及到鼠标的一些事件,通常这三个事件可以涵括日常大部分操作
mousedown
:在定点设备(如鼠标或触摸板)按钮在元素内按下时,会在该元素上触发,其与click
事件的区别是,click
事件在完整的单击操作完成后
触发、mousedown
事件在按下鼠标按钮的那一刻
触发。mousemove
: 定点设备(通常指鼠标)的光标在元素内移动时,会在该元素上触发。mouseup
: 在定点设备(如鼠标或触摸板)按钮在元素内释放时,在该元素上触发。
捋捋思路,鼠标按下先触发 mousedown 事件,拖动触发 mousemove 事件,再放开鼠标触发 mouseup 事件,那么代码就很清晰明了了
js
progress.addEventListener('mousedown', () => {
const handleSlideWithEvent = (e) => slider(e)
progress.addEventListener('mousemove', handleSlideWithEvent);
progress.addEventListener('mouseup', () => {
progress.removeEventListener('mousemove', handleSlideWithEvent);
});
});
给的示例答案思路在这块能清晰点,使用了一个变量来阻止 mousemove 事件的持续触发
js
let mousedown = false;
progress.addEventListener('click', scrub);
progress.addEventListener('mousemove', (e) => mousedown && scrub(e));
progress.addEventListener('mousedown', () => mousedown = true);
progress.addEventListener('mouseup', () => mousedown = false);
快进快退
因为在 html 标签中已经通过自定义属性写了时间,故可以直接用 dataset 拿到其并直接更改 video.currentTime
html
<button data-skip="-10" class="player__button"><< 10s</button>
<button data-skip="25" class="player__button">25s >></button>
js
const skipButtons = player.querySelectorAll('[data-skip]');
function skip() {
video.currentTime += parseInt(this.dataset.skip);
}
skipButtons.forEach(button => button.addEventListener('click', skip));
声音与倍速
同理在 html 标签中已经通过 name 属性写了要修改的属性,因为是 input 做出来的进度条,可以直接监听 change 事件,鼠标释放后才触发,再绑定 mousemove 事件,使其可以拖动时触发,elegent!
html
<input type="range" name="volume" class="player__slider" min="0" max="1" step="0.05" value="1">
<input type="range" name="playbackRate" class="player__slider" min="0.5" max="2" step="0.1" value="1">
js
function handleRangeUpdate() {
video[this.name] = this.value;
}
ranges.forEach(range => range.addEventListener('change', handleRangeUpdate));
ranges.forEach(range => range.addEventListener('mousemove', handleRangeUpdate));
结语
挺有意思,还得是框架啊,省了一堆事(手动狗头)
完整代码
html
<div class="player">
<video class="player__video viewer" src="652333414.mp4"></video>
<div class="player__controls">
<div class="progress">
<div class="progress__filled"></div>
</div>
<button class="player__button toggle" title="Toggle Play">►</button>
<input type="range" name="volume" class="player__slider" min="0" max="1" step="0.05" value="1">
<input type="range" name="playbackRate" class="player__slider" min="0.5" max="2" step="0.1" value="1">
<button data-skip="-10" class="player__button"><< 10s</button>
<button data-skip="25" class="player__button">25s >></button>
</div>
</div>
css
html {
box-sizing: border-box;
}
*, *:before, *:after {
box-sizing: inherit;
}
body {
margin: 0;
padding: 0;
display: flex;
background: #7A419B;
min-height: 100vh;
background: linear-gradient(135deg, #7c1599 0%,#921099 48%,#7e4ae8 100%);
background-size: cover;
align-items: center;
justify-content: center;
}
.player {
max-width: 750px;
border: 5px solid rgba(0,0,0,0.2);
box-shadow: 0 0 20px rgba(0,0,0,0.2);
position: relative;
font-size: 0;
overflow: hidden;
}
/* This css is only applied when fullscreen is active. */
.player:fullscreen {
max-width: none;
width: 100%;
}
.player:-webkit-full-screen {
max-width: none;
width: 100%;
}
.player__video {
width: 100%;
}
.player__button {
background: none;
border: 0;
line-height: 1;
color: white;
text-align: center;
outline: 0;
padding: 0;
cursor: pointer;
max-width: 50px;
}
.player__button:focus {
border-color: #ffc600;
}
.player__slider {
width: 10px;
height: 30px;
}
.player__controls {
display: flex;
position: absolute;
bottom: 0;
width: 100%;
transform: translateY(100%) translateY(-5px);
transition: all .3s;
flex-wrap: wrap;
background: rgba(0,0,0,0.1);
}
.player:hover .player__controls {
transform: translateY(0);
}
.player:hover .progress {
height: 15px;
}
.player__controls > * {
flex: 1;
}
.progress {
flex: 10;
position: relative;
display: flex;
flex-basis: 100%;
height: 5px;
transition: height 0.3s;
background: rgba(0,0,0,0.5);
cursor: ew-resize;
}
.progress__filled {
width: 50%;
background: #ffc600;
flex: 0;
flex-basis: 50%;
}
/* unholy css to style input type="range" */
input[type=range] {
-webkit-appearance: none;
background: transparent;
width: 100%;
margin: 0 5px;
}
input[type=range]:focus {
outline: none;
}
input[type=range]::-webkit-slider-runnable-track {
width: 100%;
height: 8.4px;
cursor: pointer;
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0), 0 0 1px rgba(13, 13, 13, 0);
background: rgba(255,255,255,0.8);
border-radius: 1.3px;
border: 0.2px solid rgba(1, 1, 1, 0);
}
input[type=range]::-webkit-slider-thumb {
height: 15px;
width: 15px;
border-radius: 50px;
background: #ffc600;
cursor: pointer;
-webkit-appearance: none;
margin-top: -3.5px;
box-shadow:0 0 2px rgba(0,0,0,0.2);
}
input[type=range]:focus::-webkit-slider-runnable-track {
background: #bada55;
}
input[type=range]::-moz-range-track {
width: 100%;
height: 8.4px;
cursor: pointer;
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0), 0 0 1px rgba(13, 13, 13, 0);
background: #ffffff;
border-radius: 1.3px;
border: 0.2px solid rgba(1, 1, 1, 0);
}
input[type=range]::-moz-range-thumb {
box-shadow: 0 0 0 rgba(0, 0, 0, 0), 0 0 0 rgba(13, 13, 13, 0);
height: 15px;
width: 15px;
border-radius: 50px;
background: #ffc600;
cursor: pointer;
}
js
const player = document.querySelector('.player');
const video = player.querySelector('.viewer');
const progress = player.querySelector('.progress');
const progressBar = player.querySelector('.progress__filled');
const toggle = player.querySelector('.toggle');
const skipButtons = player.querySelectorAll('[data-skip]');
const ranges = player.querySelectorAll('.player__slider');
function toggleBtn() {
let mode, icon
if (video.paused) {
mode = 'play'
icon = '❚ ❚'
} else {
mode = 'pause'
icon = '►'
}
video[mode]()
this.innerHTML = icon
}
function skip() {
video.currentTime += parseInt(this.dataset.skip);
}
function showProgress() {
const { currentTime, duration } = video
console.log(`${formatTime(currentTime)}/${formatTime(duration)}`)
const percent = (currentTime / duration) * 100;
progressBar.style.flexBasis = `${percent}%`;
}
function formatTime(time) {
const minute = Math.floor(time / 60)
const second = Math.floor(time % 60)
const formatMinute = minute.toString().padStart(2, '0')
const formatSecond = second.toString().padStart(2, '0')
return `${formatMinute}:${formatSecond}`
}
function slider(e) {
const currentTime = (e.offsetX / progress.offsetWidth) * video.duration;
video.currentTime = currentTime;
}
function handleRangeUpdate() {
video[this.name] = this.value;
}
toggle.addEventListener('click', toggleBtn)
video.addEventListener('timeupdate', showProgress);
progress.addEventListener('click', slider);
progress.addEventListener('mousedown', () => {
const handleSlideWithEvent = (e) => slider(e)
progress.addEventListener('mousemove', handleSlideWithEvent);
progress.addEventListener('mouseup', () => {
progress.removeEventListener('mousemove', handleSlideWithEvent);
});
});
skipButtons.forEach(button => button.addEventListener('click', skip));
ranges.forEach(range => range.addEventListener('change', handleRangeUpdate));
ranges.forEach(range => range.addEventListener('mousemove', handleRangeUpdate));