视频播放器集成与优化

视频播放器是 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(MediaSourceHTMLVideoElement),在 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 内置了自适应码率切换算法:

  1. 测量带宽:记录每个分片的下载速度
  2. 估算可用带宽:使用 EWMA(指数加权移动平均)平滑波动
  3. 选择码率:选择不超过估算带宽 80% 的最高码率
  4. 缓冲保护:缓冲区不足时主动降级
// 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?.()
    }
  }
}

注意全屏应该作用在容器(包含控制栏)上,不是 &lt;video&gt; 元素本身。

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 系统集成