视频播放器集成与优化
视频播放器是 AI 视频平台最核心的用户交互组件——用户在这个平台上花费的绝大部分时间都是在看视频。一个好的播放器不只是能播放视频,还要支持自适应码率(网络差时自动降质)、进度记忆(下次从上次停的地方继续)、弹幕、画中画、键盘快捷键等。本章从 HLS 协议讲起,完整实现一个生产级的视频播放器组件。
1. HLS 协议基础
1.1 为什么用 HLS
直接使用 <video src="video.mp4"> 在生产环境有几个严重问题:
| 问题 | 原因 | HLS 如何解决 |
|---|---|---|
| 大文件加载慢 | MP4 需要下载完整文件才能跳转 | HLS 分片加载,按需请求 |
| 网络自适应 | 固定码率不适应网络波动 | 多码率清单,自动切换 |
| CDN 缓存 | 大文件缓存效率低 | 小分片(6s)高缓存命中率 |
| 起播速度 | 需要加载较多数据才能开始播放 | 加载第一个分片即可播放 |
HLS(HTTP Live Streaming)由 Apple 提出,已成为 Web 视频流的事实标准。
1.2 HLS 文件结构
video/
├── master.m3u8 # 主播放列表(指向不同码率)
├── 720p/
│ ├── playlist.m3u8 # 720p 分片列表
│ ├── segment-000.ts # 视频分片(~6s)
│ ├── segment-001.ts
│ └── ...
├── 480p/
│ ├── playlist.m3u8
│ └── ...
└── 360p/
├── playlist.m3u8
└── ...
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1280x720
720p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1400000,RESOLUTION=854x480
480p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360
360p/playlist.m3u8浏览器根据当前网络带宽自动选择合适的码率流。
2. HLS.js 在 Nuxt4 中的集成
2.1 SSR 安全集成
HLS.js 依赖浏览器 API(MediaSource、HTMLVideoElement),在 SSR 阶段不可用。必须使用动态导入 + 客户端保护:
// composables/useHls.ts
export function useHls(
videoRef: Ref<HTMLVideoElement | null>,
src: Ref<string | null>
) {
let hls: any = null
const isSupported = ref(false)
const currentLevel = ref(-1) // -1 = auto
const levels = ref<{ height: number; bitrate: number }[]>([])
async function init() {
if (!import.meta.client) return
if (!videoRef.value || !src.value) return
const { default: Hls } = await import('hls.js')
if (!Hls.isSupported()) {
// Safari 原生支持 HLS
if (videoRef.value.canPlayType('application/vnd.apple.mpegurl')) {
videoRef.value.src = src.value
isSupported.value = true
}
return
}
isSupported.value = true
hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
startLevel: -1, // 自动选择起始码率
capLevelToPlayerSize: true, // 不加载超过播放器尺寸的码率
})
hls.loadSource(src.value)
hls.attachMedia(videoRef.value)
hls.on(Hls.Events.MANIFEST_PARSED, (_: any, data: any) => {
levels.value = data.levels.map((l: any) => ({
height: l.height,
bitrate: l.bitrate,
}))
})
hls.on(Hls.Events.ERROR, (_: any, data: any) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hls.startLoad() // 网络错误自动重试
break
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError()
break
default:
destroy()
break
}
}
})
}
function setQuality(levelIndex: number) {
if (hls) {
hls.currentLevel = levelIndex // -1 = auto
currentLevel.value = levelIndex
}
}
function destroy() {
if (hls) {
hls.destroy()
hls = null
}
}
// 响应 src 变化
watch(src, () => {
destroy()
init()
})
onMounted(init)
onUnmounted(destroy)
return { isSupported, levels, currentLevel, setQuality }
}2.2 播放器组件
<!-- components/VideoPlayer.client.vue -->
<script setup lang="ts">
const { src, poster, autoplay = false } = defineProps<{
src: string
poster?: string
autoplay?: boolean
}>()
const videoRef = useTemplateRef<HTMLVideoElement>('video')
const srcRef = computed(() => src)
const { levels, currentLevel, setQuality } = useHls(videoRef, srcRef)
// 播放状态
const isPlaying = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const volume = ref(1)
const isMuted = ref(false)
const isFullscreen = ref(false)
const showControls = ref(true)
let hideTimer: ReturnType<typeof setTimeout>
function togglePlay() {
const el = videoRef.value
if (!el) return
el.paused ? el.play() : el.pause()
}
function handleTimeUpdate() {
const el = videoRef.value
if (!el) return
currentTime.value = el.currentTime
duration.value = el.duration
}
function seek(time: number) {
if (videoRef.value) {
videoRef.value.currentTime = time
}
}
function handleMouseMove() {
showControls.value = true
clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
if (isPlaying.value) showControls.value = false
}, 3000)
}
// 键盘快捷键
function handleKeydown(e: KeyboardEvent) {
switch (e.key) {
case ' ':
case 'k':
e.preventDefault()
togglePlay()
break
case 'ArrowLeft':
seek(currentTime.value - 10)
break
case 'ArrowRight':
seek(currentTime.value + 10)
break
case 'ArrowUp':
volume.value = Math.min(1, volume.value + 0.1)
break
case 'ArrowDown':
volume.value = Math.max(0, volume.value - 0.1)
break
case 'f':
toggleFullscreen()
break
case 'm':
isMuted.value = !isMuted.value
break
}
}
async function toggleFullscreen() {
const container = videoRef.value?.parentElement
if (!container) return
if (document.fullscreenElement) {
await document.exitFullscreen()
isFullscreen.value = false
} else {
await container.requestFullscreen()
isFullscreen.value = true
}
}
</script>
<template>
<div
class="relative bg-black rounded-lg overflow-hidden group"
@mousemove="handleMouseMove"
@keydown="handleKeydown"
tabindex="0"
>
<video
ref="video"
:poster="poster"
:autoplay="autoplay"
:muted="isMuted"
:volume="volume"
class="w-full"
@play="isPlaying = true"
@pause="isPlaying = false"
@timeupdate="handleTimeUpdate"
@click="togglePlay"
/>
<!-- 控制栏 -->
<div
v-show="showControls || !isPlaying"
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 p-4 transition-opacity"
>
<!-- 进度条 -->
<input
type="range"
:value="currentTime"
:max="duration"
class="w-full"
@input="seek(Number(($event.target as HTMLInputElement).value))"
/>
<div class="flex items-center justify-between mt-2">
<div class="flex items-center gap-3">
<button @click="togglePlay" class="text-white">
{{ isPlaying ? '⏸' : '▶' }}
</button>
<span class="text-white text-sm">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
</div>
<div class="flex items-center gap-3">
<!-- 画质选择 -->
<select
:value="currentLevel"
@change="setQuality(Number(($event.target as HTMLSelectElement).value))"
class="bg-transparent text-white text-sm"
>
<option value="-1">自动</option>
<option v-for="(level, i) in levels" :key="i" :value="i">
{{ level.height }}p
</option>
</select>
<button @click="isMuted = !isMuted" class="text-white">
{{ isMuted ? '🔇' : '🔊' }}
</button>
<button @click="toggleFullscreen" class="text-white">
{{ isFullscreen ? '⊙' : '⛶' }}
</button>
</div>
</div>
</div>
</div>
</template>注意文件名是 VideoPlayer.client.vue——.client 后缀确保只在客户端渲染。
3. 自适应码率(ABR)
3.1 HLS.js 的 ABR 策略
HLS.js 内置了自适应码率切换算法:
- 测量带宽:记录每个分片的下载速度
- 估算可用带宽:使用 EWMA(指数加权移动平均)平滑波动
- 选择码率:选择不超过估算带宽 80% 的最高码率
- 缓冲保护:缓冲区不足时主动降级
// HLS.js 配置调优
const hls = new Hls({
abrEwmaDefaultEstimate: 500000, // 初始带宽估算(500Kbps)
abrEwmaFastLive: 3, // 快速 EWMA 半衰期
abrEwmaSlowLive: 9, // 慢速 EWMA 半衰期
abrBandWidthFactor: 0.8, // 安全系数(使用估算带宽的 80%)
abrBandWidthUpFactor: 0.7, // 升级码率时更保守
maxStarvationDelay: 4, // 缓冲区耗尽前开始降级
})3.2 手动切换与锁定
用户可以手动选择固定码率(如"总是 720p"):
function setQuality(levelIndex: number) {
if (!hls) return
if (levelIndex === -1) {
// 自动模式
hls.currentLevel = -1
hls.nextLevel = -1
} else {
// 锁定码率
hls.currentLevel = levelIndex
}
currentLevel.value = levelIndex
}currentLevel = -1 表示自动模式,HLS.js 根据带宽自动切换。设置为具体值则锁定在该码率。
4. 弹幕系统
4.1 弹幕数据结构
// packages/shared/types/danmaku.ts
export interface Danmaku {
id: string
videoId: string
userId: string
text: string
time: number // 出现时间(秒)
color: string // 颜色
type: 'scroll' | 'top' | 'bottom'
createdAt: string
}4.2 Canvas 弹幕渲染器
弹幕渲染有两种方案:DOM 和 Canvas。大量弹幕(100+同屏)时 Canvas 性能远优于 DOM:
// composables/useDanmaku.ts
export function useDanmaku(
canvasRef: Ref<HTMLCanvasElement | null>,
videoRef: Ref<HTMLVideoElement | null>
) {
const danmakuList = ref<Danmaku[]>([])
const isEnabled = ref(true)
const fontSize = ref(24)
const opacity = ref(0.8)
interface LiveDanmaku {
text: string
x: number
y: number
speed: number
color: string
width: number
}
let activeDanmaku: LiveDanmaku[] = []
let animationId: number | null = null
function loadDanmaku(list: Danmaku[]) {
danmakuList.value = list.sort((a, b) => a.time - b.time)
}
function render() {
const canvas = canvasRef.value
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.font = `${fontSize.value}px sans-serif`
ctx.globalAlpha = opacity.value
// 移动和绘制活跃弹幕
activeDanmaku = activeDanmaku.filter((d) => {
d.x -= d.speed
if (d.x + d.width < 0) return false
ctx.fillStyle = d.color
ctx.fillText(d.text, d.x, d.y)
return true
})
animationId = requestAnimationFrame(render)
}
// 根据视频时间注入新弹幕
function tick(currentTime: number) {
if (!isEnabled.value || !canvasRef.value) return
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
if (!ctx) return
const newDanmaku = danmakuList.value.filter(
(d) => Math.abs(d.time - currentTime) < 0.5
)
for (const d of newDanmaku) {
ctx.font = `${fontSize.value}px sans-serif`
const width = ctx.measureText(d.text).width
const y = Math.random() * (canvas.height - fontSize.value) + fontSize.value
activeDanmaku.push({
text: d.text,
x: canvas.width,
y,
speed: 2 + Math.random(),
color: d.color || '#ffffff',
width,
})
}
}
onMounted(() => {
render()
})
onUnmounted(() => {
if (animationId) cancelAnimationFrame(animationId)
})
return { loadDanmaku, tick, isEnabled, fontSize, opacity }
}4.3 发送弹幕
// server/api/videos/[id]/danmaku.post.ts
const schema = z.object({
text: z.string().min(1).max(100),
time: z.number().min(0),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default('#ffffff'),
type: z.enum(['scroll', 'top', 'bottom']).default('scroll'),
})
export default defineEventHandler(async (event) => {
const session = await requireAuth(event)
const videoId = getRouterParam(event, 'id')!
const body = await readValidatedBody(event, schema.parse)
const db = useDB()
const [danmaku] = await db.insert(danmakuTable).values({
videoId,
userId: session.user.id,
...body,
}).returning()
return danmaku
})5. 播放进度记忆
5.1 存储策略
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| localStorage | 简单、无网络开销 | 不跨设备 | 未登录用户 |
| 服务端 API | 跨设备同步 | 需要网络 | 已登录用户 |
两者结合:未登录用 localStorage,已登录用服务端 + localStorage 双写。
5.2 实现
// composables/useWatchProgress.ts
export function useWatchProgress(videoId: string) {
const STORAGE_KEY = `watch-progress-${videoId}`
const savedTime = ref(0)
// 读取进度
async function load() {
// 1. 先从 localStorage 快速读取
if (import.meta.client) {
const local = localStorage.getItem(STORAGE_KEY)
if (local) savedTime.value = Number(local)
}
// 2. 已登录用户从服务端读取(更准确)
const { data } = await useFetch(`/api/videos/${videoId}/progress`)
if (data.value?.time) {
savedTime.value = data.value.time
}
}
// 保存进度(节流:每 10 秒保存一次)
const save = useDebounceFn(async (time: number) => {
if (import.meta.client) {
localStorage.setItem(STORAGE_KEY, String(Math.floor(time)))
}
// 已登录用户同步到服务端
await $fetch(`/api/videos/${videoId}/progress`, {
method: 'PUT',
body: { time: Math.floor(time) },
}).catch(() => {})
}, 10000)
onMounted(load)
return { savedTime, save }
}5.3 在播放器中使用
<script setup>
const { savedTime, save } = useWatchProgress(videoId)
// 恢复进度
onMounted(() => {
if (savedTime.value > 0 && videoRef.value) {
videoRef.value.currentTime = savedTime.value
}
})
// 定期保存
function handleTimeUpdate() {
currentTime.value = videoRef.value?.currentTime || 0
save(currentTime.value)
}
</script>6. 画中画与全屏
6.1 画中画(Picture-in-Picture)
画中画让用户在浏览其他页面时继续观看视频:
async function togglePiP() {
const el = videoRef.value
if (!el) return
if (document.pictureInPictureElement) {
await document.exitPictureInPicture()
} else if (document.pictureInPictureEnabled) {
await el.requestPictureInPicture()
}
}6.2 全屏
async function toggleFullscreen() {
const container = videoRef.value?.closest('.video-container')
if (!container) return
if (document.fullscreenElement) {
await document.exitFullscreen()
isFullscreen.value = false
} else {
await container.requestFullscreen()
isFullscreen.value = true
// iOS Safari 使用 webkitEnterFullscreen
if (!document.fullscreenEnabled && videoRef.value) {
(videoRef.value as any).webkitEnterFullscreen?.()
}
}
}注意全屏应该作用在容器(包含控制栏)上,不是 <video> 元素本身。
6.3 媒体会话 API
让系统通知栏和锁屏界面显示播放信息和控制按钮:
function setupMediaSession(video: { title: string; artist: string; artwork: string }) {
if (!('mediaSession' in navigator)) return
navigator.mediaSession.metadata = new MediaMetadata({
title: video.title,
artist: video.artist,
artwork: [
{ src: video.artwork, sizes: '512x512', type: 'image/png' },
],
})
navigator.mediaSession.setActionHandler('play', () => videoRef.value?.play())
navigator.mediaSession.setActionHandler('pause', () => videoRef.value?.pause())
navigator.mediaSession.setActionHandler('seekbackward', () => {
if (videoRef.value) videoRef.value.currentTime -= 10
})
navigator.mediaSession.setActionHandler('seekforward', () => {
if (videoRef.value) videoRef.value.currentTime += 10
})
}本章小结
- HLS 协议:分片加载、多码率、CDN 友好,Web 视频流事实标准
- SSR 安全集成:
.client.vue+ 动态import('hls.js'),Safari 原生 HLS 降级 - 自适应码率:HLS.js 内置 ABR 算法(EWMA 带宽估算),支持手动锁定码率
- 弹幕系统:Canvas 渲染(大量弹幕高性能),
requestAnimationFrame驱动动画 - 进度记忆:localStorage + 服务端双写,未登录本地存储,已登录跨设备同步
- 高级功能:画中画、全屏(容器级)、键盘快捷键、Media Session API 系统集成