简易播放器

前言

在写 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.currentTimevideo.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));
相关推荐
90后的晨仔25 分钟前
在macOS上无缝整合:为Claude Code配置魔搭社区免费API完全指南
前端
沿着路走到底1 小时前
JS事件循环
java·前端·javascript
子春一21 小时前
Flutter 2025 可访问性(Accessibility)工程体系:从合规达标到包容设计,打造人人可用的数字产品
前端·javascript·flutter
白兰地空瓶1 小时前
别再只会调 API 了!LangChain.js 才是前端 AI 工程化的真正起点
前端·langchain
jlspcsdn2 小时前
20251222项目练习
前端·javascript·html
行走的陀螺仪3 小时前
Sass 详细指南
前端·css·rust·sass
爱吃土豆的马铃薯ㅤㅤㅤㅤㅤㅤㅤㅤㅤ3 小时前
React 怎么区分导入的是组件还是函数,或者是对象
前端·react.js·前端框架
LYFlied3 小时前
【每日算法】LeetCode 136. 只出现一次的数字
前端·算法·leetcode·面试·职场和发展
子春一23 小时前
Flutter 2025 国际化与本地化工程体系:从多语言支持到文化适配,打造真正全球化的应用
前端·flutter
QT 小鲜肉3 小时前
【Linux命令大全】001.文件管理之file命令(实操篇)
linux·运维·前端·网络·chrome·笔记