☰
Vue 3自定义视频播放器控制器:从进度条到全屏的完整实现
2026/10/15 19:53:08 网站建设 项目流程

没做前端播放器之前,我以为视频播放器的“自定义controller”就是把原生controls藏掉,自己写个播放/暂停按钮盖在video上面,再把进度条换根好看点的横条就完事了。直到某次接手一个某公司的视频详情页项目,需求清单里有一条扎眼的描述:“控制条不能用原生样式,进度条改成胶囊形,要带实时预览和拖拽跳转,还要倍速、音量、全屏。”我才意识到,一个可复用的“vue自定义video controller”,要处理的不只是按钮摆放,而是一整套围绕video元素的事件链路、状态同步和边界情况。

这篇文章我把这个项目的完整实现思路拆出来讲。重点覆盖:为什么原生controls在真实业务里撑不住、播放器组件怎么拆分、进度条和缓冲条的数据链路怎么打通、音量倍速全屏这些辅助控件各自的实现细节,以及我在实测里踩过的兼容性坑。适合正在用Vue 3做视频站、直播间、后台媒体库,或者想把播放器皮肤彻底改造成自定义风格的同学参考。代码基于Vue 3 + script setup编写,读起来只需要你有基础的Vue组件和HTML5 video事件认识。

1. 为什么放着现成的controls不用:原生播放器控制条的四个硬伤

先说个结论:如果项目只是临时放个视频,<video controls>完全够用。但一旦产品经理开始跟你谈“控制条风格要融入整体UI”、“播放器右下角要加个按钮”、“进度条点击手感要优化”,原生controls基本就是死路一条。我整理了一下,硬伤主要集中在四个地方。

1.1 不同浏览器渲染出来的控制条长得完全不一样

原生controls的UI不是网页开发者能控制的,它取决于浏览器本身。同一段视频,在桌面端浏览器A、浏览器B、移动端C里展示出来,播放按钮、进度条、音量滑块的位置和样式各有各的形态。如果你的页面设计稿里有统一配色、统一圆角、统一毛玻璃效果,原生控件一个都满足不了。更麻烦的是,不同浏览器对原生控件的CSS覆盖能力也不一样,有的伪元素能改,有的改不动,最后只能在“凑合用原生”和“彻底重写”之间二选一。项目UI一旦对播放器有强风格要求,重写就不可避免。

1.2 原生控件的功能面太窄

原生控件只能做播放/暂停、时间显示、进度拖动、音量调节和全屏。但真实业务里需要的东西远不止这些:多倍速切换(0.75x、1.5x)、清晰度切换、自动播放策略、海报图点击播放、播放结束后的Replay按钮、加载中的转圈loading、直播流的低延迟模式,这些原生控件一个都不提供。你只能在controls之外额外叠加一层浮层按钮去补,但浮层按钮和原生控件的样式又很难统一,交互上还容易互相遮挡。与其缝缝补补,不如从一开始就把它做成完全可控的自定义controller。

1.3 关键状态拿不到,联动无从谈起

用原生controls时,你很难拿到一些关键状态来驱动页面其他区域。比如:缓冲进度是多少?视频是不是已经播放结束?当前倍速是多少?用户是不是进全屏了?这些数据虽然可以通过监听video原生事件拿到,但原生控件本身不会把这些状态暴露成方便你绑定的数据字段。这就导致播放器旁边的“猜你喜欢”列表没法跟播放进度联动,页面的分享按钮没法感知到播完时机,数据埋点也做不了。也就是说,你想要的可视化控制,背后必须有一套自己能完全掌控的状态层,而自定义controller的本质就是在Vue里把video的状态“接管”过来。

1.4 样式细节没法做到像素级

最后再补一刀:即使你只想微调原生控件,比如把进度条改细一点,把时间字体改小,不同浏览器的实现差异就会劝退你。浏览器A的进度条是::-webkit-media-controls-progress-bar,浏览器B的进度条又是另一套结构。调不了多久你就会发现,时间成本全花在逆向浏览器的私有样式上。而自定义controller配合Flex布局和CSS变量,进度条要多粗多圆、控制条背景要渐变还是毛玻璃,全部由你决定,跟视频元素本身彻底解耦。

下面开始说正路:怎么在Vue里把这个自研controller搭起来。

2. 组件骨架:把播放器状态从一堆DOM事件里抽出来

自定义controller最容易翻车的方式,是所有控制逻辑都堆在播放器外层组件里,一个.vue文件里同时塞video、按钮、进度条、音量滑块的DOM事件,结果几百行之后自己都找不到状态在哪。

2.1 组件树怎么拆:一层壳、一条控制条、一组独立小块

我在项目里的拆法是一个PlayerContainer壳组件加一个PlayerControls控制条组件,控制条内部再拆成ProgressBar、TimeDisplay、VolumeControl、PlayButton、RateMenu、FullscreenButton这些独立小块。壳组件负责持有video元素、加载视频、监听原生事件、维护播放器状态;控制条组件负责显隐逻辑和布局;每个小块只做一件事。这样拆的好处是:进度条需要频繁更新,如果它和其他按钮放在一个组件里,整个控制条都会跟着频繁重渲染。拆开之后,进度条自己更新自己,音量滑块自己维护自己的UI状态,互不干扰。实际项目中这个收益非常明显,尤其是拖动进度条时,整个控制条不卡顿。

2.2 状态层:一个useVideoPlayer组合式函数

我建议把video元素相关的所有状态和操作方法收敛到一个组合式函数里,通过provide/inject向下分发。这样任何子组件都不用直接去document.querySelector('video'),而是统一从播放器上下文拿状态和方法。核心状态我一般维护这些字段:

// useVideoPlayer.js import { ref, reactive, provide } from 'vue'; export function useVideoPlayer(videoRef) { const playerState = reactive({ currentTime: 0, // 当前播放秒数 duration: 0, // 总时长 buffered: 0, // 缓冲百分比 0-100 playing: false, // 是否播放中 ended: false, // 是否播完 volume: 1, // 音量 0-1 muted: false, // 是否静音 rate: 1, // 播放速率 waiting: false // 是否在缓冲等待 }); const setCurrentTime = (time) => { if (videoRef.value) { videoRef.value.currentTime = time; } }; const togglePlay = () => { if (!videoRef.value) return; if (videoRef.value.paused) { videoRef.value.play(); } else { videoRef.value.pause(); } }; provide('player', { playerState, videoRef, togglePlay, setCurrentTime }); }

你看,播放按钮、进度条、键盘快捷键用的都是同一套playerState,这样就不会出现“进度条组件里自己维护了一份currentTime,跟video实际播放位置对不上”的问题。整个Controller的核心原则就一句话:所有UI状态都由这一份playerState派生,video DOM永远是唯一数据源,但不直接跟子组件对话。

2.3 video原生事件的分发:模板绑定还是手动监听

video元素上有一堆原生事件,比如loadedmetadata、timeupdate、progress、waiting、playing、ended、volumechange。在Vue模板里,你可以直接这样写:

<video ref="videoRef" :src="src" @loadedmetadata="onLoadedMetadata" @timeupdate="onTimeUpdate" @progress="onProgress" @playing="onPlaying" @waiting="onWaiting" @ended="onEnded" @volumechange="onVolumeChange" />

然后在script里集中处理这些事件,把它们映射到playerState上:

const onLoadedMetadata = () => { playerState.duration = videoRef.value.duration || 0; }; const onTimeUpdate = () => { playerState.currentTime = videoRef.value.currentTime; playerState.ended = false; }; const onProgress = () => { const video = videoRef.value; if (video.buffered.length > 0) { const bufferedEnd = video.buffered.end(video.buffered.length - 1); playerState.buffered = (bufferedEnd / video.duration) * 100; } }; const onEnded = () => { playerState.ended = true; playerState.playing = false; };

这种写法最直白,所有事件集中在一个文件里管理,排查问题时顺着事件名找就行。需要注意的一点是:不要在模板事件回调里写复杂的业务逻辑,事件回调只负责更新state,组件通过state的响应式变化去驱动UI。如果直接把DOM读写逻辑堆在模板里,后期一要加埋点、减交互,改起来会非常痛苦。

3. 进度条与缓冲条:从timeupdate到像素级拖拽的完整实现

进度条是自定义controller里最核心、也最容易出细节问题的地方。它看起来只是一条横线,实则需要处理好数据更新频率、坐标换算、拖拽与播放冲突三层问题。

3.1 数据链路:播放进度和缓冲进度怎么进状态

播放进度的来源是timeupdate事件,它本身足够准确,但触发频率并不高,大概每250毫秒触发一次。250毫秒在数据上是准的,放在UI里却会有肉眼可见的“一顿一顿”感,尤其是进度条宽度百分比变化的时候。你直接width: currentTime / duration * 100%,就会发现进度条像秒针一样一格格跳,而不是视频播放器该有的平滑推进感。

处理办法是把时间差值过渡交给CSS。进度条内部实际进度条用transition: width 0.2s linear;,这0.2秒刚好覆盖掉timeupdate的间隔,视觉上就平滑了。不用起定时器,也不用requestAnimationFrame,成本最低。如果你需要更高精度的UI进度,可以在timeupdate之外再用requestAnimationFrame循环读取video.currentTime,但实测下来普通业务场景没必要,CSS过渡足够。

缓冲进度使用progress事件,它在视频加载数据时周期性触发。注意video.buffered是一个TimeRanges对象,可能有多个分段。简单场景下直接取最后一段的末尾作为整体缓冲进度就已经足够:

const onProgress = () => { const video = videoRef.value; if (!video.duration) return; if (video.buffered.length > 0) { const bufferedEnd = video.buffered.end(video.buffered.length - 1); playerState.buffered = Math.min(100, (bufferedEnd / video.duration) * 100); } };

把buffered和played做成两层横条:底层是半透明灰表示缓冲,上层是主题色表示已播放,再在上层右边放一个圆形手柄,视觉上就是标准播放器了。

3.2 把鼠标坐标换算成播放时间:一套可以直接抄的公式

用户点击或拖动进度条时,我们需要把鼠标在页面上的clientX换算成0到1的百分比,再乘以时长得到目标秒数。换算公式看起来简单,但有个重要细节:不能用event.target的坐标,而要用进度条容器的getBoundingClientRect()来计算,否则你点到进度条内部某个子元素上时,坐标基准就错了。

const progressRef = ref(null); function computePercentFromEvent(e) { const rect = progressRef.value.getBoundingClientRect(); const clickX = e.clientX - rect.left; let percent = clickX / rect.width; percent = Math.min(1, Math.max(0, percent)); return percent; }

这里Math.min和Math.max是必须的,因为鼠标在进度条容器外面按下去、拖回来再松开时,百分比可能超过0到1的范围,不钳制就会出现currentTime越界、进度条溢出的小bug。

百分比转成时间就一行:const targetTime = percent * playerState.duration,然后通过video.currentTime = targetTime让视频跳转。不过要注意:duration在loadedmetadata之前可能是NaN,所以点击处理函数里要加一层防御,duration不可用时就忽略这次点击。

3.3 拖动与播放的冲突:不做好这一步进度条会“打架”

进度条最容易出现的体验问题是:用户按住手柄拖动时,视频仍在播放,timeupdate事件也在实时更新playerState.currentTime,UI刚被你拽到60%的位置,下一帧又因为播放进度回到55%,手柄就像在跟你的手指打架。解决办法是引入一个dragging状态:拖动过程中,进度条UI只跟随手指坐标,不响应播放进度更新;手指松开时一次性写入video.currentTime。

完整的Pointer事件处理逻辑如下:

const isDragging = ref(false); let dragPreviewTime = 0; function onPointerDown(e) { isDragging.value = true; progressRef.value.setPointerCapture(e.pointerId); updateDragProgress(e); } function onPointerMove(e) { if (!isDragging.value) return; updateDragProgress(e); } function onPointerUp(e) { if (!isDragging.value) return; isDragging.value = false; setCurrentTime(dragPreviewTime); } function updateDragProgress(e) { const percent = computePercentFromEvent(e); dragPreviewTime = percent * playerState.duration; }

然后模板里进度条外层容器绑定@pointerdown、@pointermove、@pointerup,内层已播放条宽度这样算:

<div ref="progressRef" class="progress" @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="onPointerUp" > <div class="progress__buffer" :style="{ width: playerState.buffered + '%' }"></div> <div class="progress__played" :style="{ width: (isDragging ? (dragPreviewTime / playerState.duration) * 100 : (playerState.currentTime / playerState.duration) * 100) + '%' }" ></div> <div class="progress__thumb" :style="{ left: (isDragging ? (dragPreviewTime / playerState.duration) * 100 : (playerState.currentTime / playerState.duration) * 100) + '%' }" ></div> </div>

这个isDragging分支是整套拖拽逻辑的命门。没有它,播放和拖拽就会互相抢控件;有了它,拖动过程中整个进度条只认手指,松开后才把控制权交还给video。如果你需要拖拽时实时预览画面(拖动过程中让视频画面跟随预览帧),可以在onPointerMove里用video.currentTime同步跳转,但会频繁触发解码,一般本地小视频没问题,大体积视频或流媒体不建议这么做,用预览帧图片代替更好。

3.4 点击和拖拽同时存在时的判断

如果同一容器既处理点击跳转,又处理拖拽跳转,很容易出现“点一下,视频跳了,松开时又跳了一次”的问题。Pointer事件的处理逻辑里,我习惯只用pointerdown/pointerup来做完整的一次交互:按下记录起始坐标,移动时如果位移超过阈值就进入拖拽模式,松开时无论点击还是拖拽都统一apply时间。

实际项目里更省事的方案是:容器只处理pointerdown,然后setPointerCapture把后续pointermove和pointerup都捕获到容器自身。这样不用监听document,也避免其他地方触发事件时干扰。阈值判断可以这样加:在onPointerDown记录startX,onPointerMove里如果Math.abs(e.clientX - startX)大于3像素,才进入拖拽状态。这样点击和拖拽就能共存,用户轻点一下立即跳转,按住拖动则精细控制。

4. 控制条辅助控件:时间、音量、倍速、全屏的分工

进度条搞定之后,控制条还缺几个标准件:播放/暂停按钮、时间显示、音量控件、倍速菜单、全屏按钮。这些控件相对独立,但每个都有值得注意的细节。

4.1 播放/暂停:别忘了play()返回一个Promise

播放按钮的逻辑不复杂,但有一个坑很典型:video.play()在浏览器自动播放策略限制下可能返回一个被rejected的Promise,如果调用play()时不加catch,控制台会给你刷一条未捕获的错误。我已经在不止一个项目里见过这个报错,原因是用户快速点击播放按钮,视频还没加载出来就开始播放,播放请求被浏览器拒绝。

稳妥的写法是:

const togglePlay = () => { const video = videoRef.value; if (!video) return; if (video.paused) { const playPromise = video.play(); if (playPromise !== undefined) { playPromise.catch(() => { // 播放被浏览器拒绝时静默处理,UI仍保持待播放状态 playerState.playing = false; }); } } else { video.pause(); playerState.playing = false; } };

配合@playing事件更新playerState.playing = true,播放按钮的图标来源于playing字段,而不是直接读写video.paused,这样保证UI和数据流始终一致。

4.2 时间显示:自己写个格式化函数

原生控件自带时间显示,自定义之后就得自己格式化。时长小于1小时显示mm:ss,大于1小时显示h:mm:ss,这个规则很好理解:

function formatTime(seconds) { if (Number.isNaN(seconds) || seconds === Infinity) return '00:00'; const total = Math.floor(seconds); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = total % 60; const ss = String(s).padStart(2, '0'); if (h > 0) { const mm = String(m).padStart(2, '0'); return `${h}:${mm}:${ss}`; } return `${m}:${ss}`; }

注意duration在部分视频元数据还没加载完时可能是NaN或Infinity,formatTime里要兜底返回00:00,否则页面会出现一个奇怪的NaN:NaN。时间显示组件直接用formatTime(playerState.currentTime)和formatTime(playerState.duration),更新频率跟着timeupdate走就好,因为进度条那边已经做了CSS过渡,时间显示本身也可以靠transition淡动,或者不做效果,每秒跳一次其实也符合播放器惯例。

4.3 音量控件:垂直滑块与静音切换

音量控件的核心是video.volume,取值0到1。原生controls的音量调节是水平的,自定义时做成垂直方向更节省横向空间。实现方式是外层容器hover时展开滑条,滑条本身用input[type=range]加writing-mode: vertical之类的CSS旋转,或者直接用div做自定义滑块。我这里推荐用div做,因为input[type=range]在垂直方向上的细节样式在不同浏览器里依然不太统一。

音量状态放到playerState里:

const onVolumeChange = () => { const video = videoRef.value; playerState.volume = video.volume; playerState.muted = video.muted; }; const setVolume = (v) => { const video = videoRef.value; video.volume = v; video.muted = v === 0; }; const toggleMute = () => { const video = videoRef.value; video.muted = !video.muted; };

这里有个细节容易被忽视:如果直接把volume赋成一个小于0.05的数,用户感觉上已经是静音了,但video.muted还是false,图标停留在音量小档。所以赋值时要主动判断v === 0来设置muted状态,确保UI和实际听感一致。

音量滑块的交互和进度条拖拽类似,也是pointerdown记录、pointermove更新、pointerup结束。完全可以把进度条的拖拽逻辑抽成一个小组合式函数,复用给音量和进度条两个组件。不要因为代码量不大就选择复制粘贴,拖拽逻辑里setPointerCapture、边界钳制这些细节一旦在两处出现分歧,排查起来很折磨。

4.4 倍速菜单与全屏切换的实现细节

倍速本质上只是设置video.playbackRate。UI上给一组常用档位:0.5、0.75、1、1.25、1.5、2。点击某个档位后:

const setRate = (rate) => { const video = videoRef.value; video.playbackRate = rate; playerState.rate = rate; };

核心里有一个小坑:视频播完后,有些浏览器会把playbackRate重置回默认值,或者用户切换视频源后倍速不生效。所以在loadedmetadata和ended事件里重新应用一下playerState.rate比较稳妥。这个处理很细节,但产品使用时很敏感,毕竟用户选了1.5倍速,结果播下一集又变回1倍速,体验会打折扣。

全屏切换逻辑虽然只有几行,但注意要全屏的是播放器容器而不是video元素本身,否则控制条会被全屏的video挡在后面。判断当前是否全屏,标准字段是document.fullscreenElement,老浏览器可能用document.webkitFullscreenElement。切换方法同理,容器上可能同时存在requestFullscreen和webkitRequestFullscreen。兼容写法:

const toggleFullscreen = () => { const container = containerRef.value; if (!document.fullscreenElement) { if (container.requestFullscreen) { container.requestFullscreen(); } else if (container.webkitRequestFullscreen) { container.webkitRequestFullscreen(); } } else { if (document.exitFullscreen) { document.exitFullscreen(); } else if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); } } };

全屏状态还需要监听fullscreenchange事件,用来切换全屏/退出全屏图标,以及在全屏时隐藏页面其他元素。容器上监听完记得在onBeforeUnmount里移除。

5. 实测中容易翻车的兼容与边界问题

代码写出来只是第一步,真正让自定义controller可用的,是把视频领域的各种边界情况处理掉。这一节我从真实项目里挑出几个最常遇到的翻车点。

5.1 移动端自动全屏与内联播放

如果不处理移动端,自定义controller会在手机上直接失灵。移动浏览器对video有一个默认行为:点击播放时自动进入系统全屏播放器,你的自定义控制条就完全被系统UI覆盖了。要让视频在页面内联播放,必须在video标签上加playsinline属性,某些移动浏览器还需要兼容webkit-playsinline和x5-playsinline:

<video playsinline webkit-playsinline x5-playsinline :src="src" />

这个属性在桌面端没有副作用,建议默认加上。另一个移动端需要注意的点是:iOS上视频只有muted状态下才能自动播放,所以做自动播放场景时,要muted + autoplay + playsinline一起设置,然后默认静音播放,用户点击音量按钮才取消静音。

5.2 timeupdate频率太低导致的UI卡顿感

前面提过timeupdate大约每250ms一次。在快速拖拽进度条时尤其明显:手指移动的轨迹是流畅的,但进度条已播放宽度因为要等timeupdate更新,会出现闪烁、拉扯。这个问题我在3.3里用dragging状态解决了“打架”,但UI更新频率上还需要再补一层:播放中的平滑推进依赖CSS transition,而拖拽跟手速度不依赖timeupdate,因为onPointerMove自身的触发频率是远高于250ms的。两手抓下来,实测已经很流畅。如果你的项目对进度平滑有更高要求,比如体育比赛回放类页面,再考虑rAF实时读取currentTime的方案。

5.3 拖拽过程中进度条回跳问题

还有一个隐蔽的问题:拖拽松手时,视频跳转需要时间seek,但video的timeupdate事件在seek完成前可能先触发一次,把currentTime更新成旧的播放位置,导致进度条“回跳”一下,然后才跳转到新位置。表现就是用户明显看到进度条先回弹再前进,观感很差。

解决方式是在seek期间忽略timeupdate的UI更新,或者在seek后短暂锁定UI进度,直到seeking和seeked事件序列完成再解封。我项目里用的方案是监听seeking和seeked:

const onSeeking = () => { seekLocked.value = true; }; const onSeeked = () => { seekLocked.value = false; playerState.currentTime = videoRef.value.currentTime; };

进度条显示宽度时,如果seekLocked为true,就沿用dragPreviewTime;为false时再走正常的currentTime。这个锁只需要加在seek跳转那一下,成本很低,但对拖拽体验的改善很明显。

5.4 播放结束与重播的状态复位

视频播放结束时,ended事件触发。如果不做处理,控制条会停在“播放中”的UI状态(播放按钮还是暂停图标),进度条虽然到头了但状态不彻底。我在ended事件里把playing置为false、ended置为true,UI层就可以根据ended状态显示成“重播按钮”,进度条显示完全走完。点击重播时把currentTime归零再调play:

const replay = () => { const video = videoRef.value; video.currentTime = 0; video.play(); };

playerState里的ended要记得在timeupdate里重置为false,不然重播后ended状态仍然残留,UI会继续展示重播图标。

5.5 元数据未加载完成时的NaN与Infinity防御

在loadedmetadata触发之前,video.duration可能是NaN,更长尾的情况下(比如未缓冲完成的流媒体)可能是Infinity。这类值一旦参与计算,进度条百分比会算出NaN,时间显示会变成NaN:NaN,然后整个控制条数字区域就“坏了”。

我的习惯是所有涉及duration的计算入口都做一层防御:duration不是有限数时,进度条点击不生效,进度条宽度为0,时间显示回退00:00。别指望每个视频源都会及时触发loadedmetadata,有些慢网速场景下,用户打开页面时video标签可能先拿到一个不完整header,这个防御必须做。

6. 打磨细节:控制条显隐、键盘操作与后续扩展

功能型闭环跑通之后,剩下的工作是把控制条从“能用”做到“好用”。我最后加一层细节打磨,这些内容也是区分自研播放器专业度的关键。

6.1 控制条自动隐藏与显隐过渡

视频播放时,控制条如果一直亮着会很干扰视线。主流播放器的做法是:鼠标在播放器区域内移动时显示控制条,静止3秒左右自动隐藏;暂停视频时始终显示控制条。实现上需要一个计时器:

let hideTimer = null; function showControls() { controlsVisible.value = true; clearTimeout(hideTimer); hideTimer = setTimeout(() => { if (playerState.playing && !playerState.waiting) { controlsVisible.value = false; } }, 3000); }

鼠标在视频容器上@mousemove时调用showControls。控制条显隐用Vue的<transition>做透明度淡出淡入,配合一个底部渐变遮罩层,视觉上很自然。有一个细节:控制条本身如果也在鼠标hover范围内,建议鼠标停在控制条上时不要自动隐藏,否则用户想点倍速菜单时控制条突然消失,体验很糟。做法是控制条容器监听@mouseenter清除计时器、@mouseleave重新计时。

6.2 键盘快捷键:空格、方向键、音量快捷键

播放器除了鼠标操作,键盘快捷键也是专业度的一部分。在播放器容器获得焦点时,绑定keydown事件:

  • 空格:播放/暂停
  • 左方向键:后退10秒
  • 右方向键:前进10秒
  • 上/下方向键:音量加减
  • M:静音

实现时注意一个问题:空格键在页面上通常会触发滚动,所以要e.preventDefault()。如果同时按了M键或者方向键,浏览器还可能对页面做其他默认操作,统一拦截掉。播放器容器上加上tabindex="0"才能让它获得键盘焦点,否则快捷键只在用户点击过控制条之后才可用。

6.3 可插拔的扩展位:字幕、画中画、清晰度

自研controller最爽的地方是功能可以无限叠加。我在组件设计时会给控制条预留一个slot扩展区,放在时间显示和全屏按钮之间,这样后续加字幕开关、画中画、清晰度切换菜单,都不用改已有控件代码:

<div class="controls"> <PlayButton /> <ProgressBar /> <TimeDisplay /> <div class="controls__extra"> <slot name="extra"></slot> </div> <VolumeControl /> <FullscreenButton /> </div>

画中画功能浏览器原生API就已经支持,调用video.requestPictureInPicture()即可,但注意它只在支持该特性的浏览器里可用,需要先判断document.pictureInPictureEnabled。清晰度切换本质就是切换video的src并维持当前播放位置,切完等loadedmetadata后再重新seek。

6.4 一次实测后的体会

这个项目到后期,我发现真正消耗时间的不是每个控件怎么写,而是控件的状态一致性。比如倍速和拖拽同时发生时,进度条锁是非要不可;全屏切换后控制条层级在容器的最上层,容器本身要套一个position: relative;播放结束立即切换视频源时,旧视频的异步事件可能被新视频的事件覆盖,所以切换源前应该主动解绑或清空状态。

如果你也是从原生controls往自定义controller迁移,我的建议是先别急着把组件树拆得很细。先用最朴素的方式在一张页面上把video事件、进度条、播放按钮这条路走通,确认你能完整控制视频状态之后,再动手把控制条拆成组件。这样可以避免在组件通信还没理清的时候,就陷入“进度条组件改不了视频时间”“音量组件拿不到video引用”这类泥潭。等第一版自研controller在你项目里稳定跑起来,你会发现后续加任何新功能,都只是在已有状态层上多接一个组件而已。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询