@蔓蔓喜欢你头像
关注

视频前端播放器架构复盘:从原生 Video 到 HLS/DASH 的适配之路

视频前端播放器架构复盘:从原生 Video 到 HLS/DASH 的适配之路

一、播放器架构进化的核心矛盾:单一格式的易用性与多格式的兼容性

浏览器的 <video> 标签看似简单,实则是一个极其复杂的系统。<video src="xxx.mp4"> 在 MP4 文件上能正常播放,但一旦涉及直播流(HLS)、自适应码率(DASH)、加密内容(DRM)或自定义 UI,原生 Video API 的局限性就暴露无遗。

一个视频平台面临的播放场景包括:

  • 点播 MP4:最简单的场景,直接用 <video>src 属性即可。
  • HLS 直播/点播:需要引入 hls.js 做 MSE(Media Source Extensions)解封装。
  • DASH 自适应码率:需要 dash.js 做流切换。
  • 私有加密协议:需要自定义解封装逻辑。
  • 多清晰度切换:需要在 HLS/DASH 的码率自适应之上,给用户手动选择的入口。
  • 弹幕/字幕/HDR 等叠加层:需要精确的时间同步和渲染层级管理。

这六种场景如果各自独立实现,会导致维护成本指数级增长。正确的做法是用统一的播放器架构,通过适配器模式封装差异,对外暴露一致的 API。

二、核心层设计:协议无关的状态管理与事件总线

2.1 状态机的设计

播放器状态比看起来要复杂。一个简单的 playing/paused 二元状态无法描述真实场景。完整的状态机应该涵盖:

/**
 * 播放器核心状态机
 * 管理从空闲到播放、从播放到销毁的完整生命周期
 */
type PlayerState =
  | 'IDLE'         // 未加载任何资源
  | 'LOADING'      // 正在加载视频源
  | 'READY'        // 资源就绪,等待播放
  | 'PLAYING'      // 播放中
  | 'PAUSED'       // 暂停
  | 'BUFFERING'    // 缓冲中
  | 'SEEKING'      // 正在跳转
  | 'STALLED'      // 缓冲耗尽,等待数据
  | 'ENDED'        // 播放结束
  | 'ERROR'        // 错误状态
  | 'DESTROYING';  // 正在销毁

type PlayerEvent =
  | 'LOAD_START' | 'LOAD_COMPLETE' | 'LOAD_ERROR'
  | 'PLAY' | 'PAUSE' | 'RESUME'
  | 'TIME_UPDATE' | 'SEEK_START' | 'SEEK_END'
  | 'BUFFERING_START' | 'BUFFERING_END'
  | 'QUALITY_CHANGE' | 'RATE_CHANGE'
  | 'ERROR' | 'RECOVER'
  | 'DESTROY';

interface PlayerTransition {
  from: PlayerState;
  event: PlayerEvent;
  to: PlayerState;
}

class PlayerStateMachine {
  private current: PlayerState = 'IDLE';
  private transitions: PlayerTransition[] = [];
  private listeners = new Map<PlayerEvent, Set<() => void>>();

  constructor() {
    this.defineTransitions();
  }

  private defineTransitions(): void {
    this.transitions = [
      { from: 'IDLE',      event: 'LOAD_START',      to: 'LOADING' },
      { from: 'LOADING',   event: 'LOAD_COMPLETE',   to: 'READY' },
      { from: 'LOADING',   event: 'LOAD_ERROR',      to: 'ERROR' },
      { from: 'READY',     event: 'PLAY',             to: 'PLAYING' },
      { from: 'PLAYING',   event: 'PAUSE',            to: 'PAUSED' },
      { from: 'PAUSED',    event: 'RESUME',           to: 'PLAYING' },
      { from: 'PLAYING',   event: 'BUFFERING_START',  to: 'BUFFERING' },
      { from: 'BUFFERING', event: 'BUFFERING_END',    to: 'PLAYING' },
      { from: 'PLAYING',   event: 'SEEK_START',       to: 'SEEKING' },
      { from: 'SEEKING',   event: 'SEEK_END',         to: 'PLAYING' },
      { from: 'PLAYING',   event: 'LOAD_START',       to: 'STALLED' },
      { from: 'STALLED',   event: 'LOAD_COMPLETE',    to: 'PLAYING' },
      { from: 'PLAYING',   event: 'LOAD_COMPLETE',    to: 'ENDED' },
      { from: 'ERROR',     event: 'RECOVER',          to: 'IDLE' },
      { from: '*',         event: 'DESTROY',          to: 'DESTROYING' },
    ] as PlayerTransition[];
  }

  dispatch(event: PlayerEvent): boolean {
    const transition = this.transitions.find(
      (t) => (t.from === this.current || t.from === '*') && t.event === event
    );

    if (!transition) {
      console.warn(`[PlayerFSM] 无效转换: ${this.current} -> ${event}`);
      return false;
    }

    this.current = transition.to;

    // 触发事件监听
    const handlers = this.listeners.get(event);
    if (handlers) {
      for (const handler of handlers) {
        try {
          handler();
        } catch (err) {
          console.error('[PlayerFSM] 事件处理器错误:', err);
        }
      }
    }

    return true;
  }

  on(event: PlayerEvent, handler: () => void): () => void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(handler);

    return () => {
      this.listeners.get(event)?.delete(handler);
    };
  }

  getState(): PlayerState {
    return this.current;
  }
}

2.2 播放器核心的接口设计

核心层不关心底层用的是什么协议(MP4/HLS/DASH),它只通过一个统一的 IPlayerAdapter 接口与底层交互。

/**
 * 播放器适配器接口
 * 所有协议适配器(MP4/HLS/DASH)必须实现此接口
 */
interface IPlayerAdapter {
  readonly type: 'mp4' | 'hls' | 'dash' | 'custom';

  /** 加载视频源 */
  load(source: VideoSource): Promise<void>;

  /** 基础控制 */
  play(): Promise<void>;
  pause(): void;
  seek(time: number): void;
  stop(): void;

  /** 属性获取 */
  getCurrentTime(): number;
  getDuration(): number;
  getBuffered(): { start: number; end: number }[];
  getVolume(): number;

  /** 属性设置 */
  setVolume(volume: number): void;
  setPlaybackRate(rate: number): void;
  setQuality(quality: QualityLevel): void;

  /** 获取可用清晰度列表 */
  getQualities(): QualityLevel[];

  /** 事件订阅 */
  on(event: string, handler: (...args: unknown[]) => void): () => void;

  /** 销毁适配器 */
  destroy(): void;
}

interface VideoSource {
  url: string;
  type: 'mp4' | 'hls' | 'dash';
  drm?: DRMConfig;
  headers?: Record<string, string>;
  startTime?: number;
}

interface QualityLevel {
  id: string;
  label: string;       // e.g. "1080P 超清"
  width: number;
  height: number;
  bitrate: number;     // bps
  codec: string;       // e.g. "h264" / "h265"
}

interface DRMConfig {
  type: 'widevine' | 'fairplay' | 'playready';
  licenseUrl: string;
  certificateUrl?: string;
}

2.3 事件总线的错误恢复设计

播放器是长时间运行的服务,网络抖动、解码错误、内存压力等问题不可避免。错误恢复策略分三级:

  • L1 自动恢复:缓冲不足时触发 BUFFERING 状态,恢复后静默继续(用户无感知)。
  • L2 降级播放:当前清晰度解码失败,自动切换到低一档清晰度重试。
  • L3 重载资源:HLS 流断开超过 30 秒,销毁当前适配器、重新创建并加载。

三、HLS 适配器:MSE 解封装与自适应码率切换

3.1 HLS 的基本原理与 hls.js 的封装

HLS(HTTP Live Streaming)将视频切分为短小的 .ts 分片(每个 2~10 秒),通过 .m3u8 播放列表描述分片顺序。浏览器原生不支持 .m3u8 直接播放,需要借助 MSE 将 .ts 分片解封装为 fMP4 后喂给 <video>

hls.js 承担了下载 .m3u8、解析分片列表、下载 .ts 文件、通过 MSE 解封装、以及自适应码率切换的全部工作。适配器的责任是将 hls.js 的 API 映射为统一的 IPlayerAdapter 接口。

/**
 * HLS 适配器
 * 封装 hls.js,提供分片级别的错误恢复和自定义清晰度切换
 */
class HLSAdapter implements IPlayerAdapter {
  readonly type = 'hls' as const;
  private hls: HlsInstance | null = null;
  private videoElement: HTMLVideoElement;
  private source: VideoSource | null = null;
  private recoveryAttempts = 0;
  private maxRecoveryAttempts = 3;

  constructor(videoElement: HTMLVideoElement) {
    this.videoElement = videoElement;
  }

  async load(source: VideoSource): Promise<void> {
    this.source = source;

    return new Promise((resolve, reject) => {
      // 如果已有实例,先销毁
      if (this.hls) {
        this.hls.destroy();
        this.hls = null;
      }

      const hls = new Hls({
        // 关键配置
        maxBufferLength: 30,              // 最大缓冲 30 秒
        maxMaxBufferLength: 60,           // 绝对最大缓冲 60 秒
        liveSyncDurationCount: 3,         // 直播延迟:保持 3 个分片
        startLevel: -1,                   // 从最低码率开始
        abrEwmaFastLive: 3,               // 直播场景下快速码率调整
        abrEwmaSlowVoD: 9,                // 点播场景下慢速码率调整
        manifestLoadingTimeOut: 10_000,   // m3u8 加载超时
        manifestLoadingMaxRetry: 3,       // m3u8 最大重试次数
        levelLoadingTimeOut: 10_000,      // 分片加载超时
        levelLoadingMaxRetry: 4,          // 分片最大重试次数
        fragLoadingTimeOut: 20_000,
        fragLoadingMaxRetry: 6,
      });

      hls.loadSource(source.url);
      hls.attachMedia(this.videoElement);

      hls.on(Hls.Events.MANIFEST_PARSED, () => {
        resolve();
      });

      hls.on(Hls.Events.ERROR, (_event, data) => {
        // 致命错误:销毁并退出
        if (data.fatal) {
          switch (data.type) {
            case Hls.ErrorTypes.NETWORK_ERROR:
              // 网络错误:尝试恢复
              if (this.recoveryAttempts < this.maxRecoveryAttempts) {
                this.recoveryAttempts++;
                console.warn(
                  `[HLSAdapter] 网络错误,第 ${this.recoveryAttempts} 次恢复尝试`
                );
                hls.startLoad();
              } else {
                hls.destroy();
                reject(new Error('HLS 网络错误,已达最大重试次数'));
              }
              break;
            case Hls.ErrorTypes.MEDIA_ERROR:
              // 媒体解码错误:尝试切换到备用码率
              hls.recoverMediaError();
              break;
            default:
              hls.destroy();
              reject(new Error(`HLS 致命错误: ${data.type}`));
              break;
          }
        }
      });

      this.hls = hls;
    });
  }

  async play(): Promise<void> {
    try {
      await this.videoElement.play();
    } catch (err) {
      // 浏览器自动播放策略拦截
      if ((err as DOMException).name === 'NotAllowedError') {
        console.warn('[HLSAdapter] 自动播放被阻止,用户需手动交互');
      }
      throw err;
    }
  }

  pause(): void {
    this.videoElement.pause();
  }

  seek(time: number): void {
    this.videoElement.currentTime = Math.max(
      0,
      Math.min(time, this.videoElement.duration || 0)
    );
  }

  stop(): void {
    this.videoElement.pause();
    if (this.hls) {
      this.hls.stopLoad();
    }
  }

  getCurrentTime(): number {
    return this.videoElement.currentTime;
  }

  getDuration(): number {
    return this.videoElement.duration || 0;
  }

  getBuffered(): { start: number; end: number }[] {
    const buffered = this.videoElement.buffered;
    const ranges: { start: number; end: number }[] = [];
    for (let i = 0; i < buffered.length; i++) {
      ranges.push({ start: buffered.start(i), end: buffered.end(i) });
    }
    return ranges;
  }

  getVolume(): number {
    return this.videoElement.volume;
  }

  setVolume(volume: number): void {
    this.videoElement.volume = Math.max(0, Math.min(1, volume));
  }

  setPlaybackRate(rate: number): void {
    // 限制倍速范围 0.25x ~ 4x
    this.videoElement.playbackRate = Math.max(0.25, Math.min(4, rate));
  }

  setQuality(quality: QualityLevel): void {
    if (!this.hls) return;

    const levels = this.hls.levels;
    const index = levels.findIndex(
      (l) => l.height === quality.height && l.bitrate === quality.bitrate
    );

    if (index !== -1) {
      this.hls.currentLevel = index;
    }
  }

  getQualities(): QualityLevel[] {
    if (!this.hls) return [];
    return this.hls.levels.map((level, index) => ({
      id: String(index),
      label: `${level.height}P`,
      width: level.width,
      height: level.height,
      bitrate: level.bitrate,
      codec: level.codecSet || 'unknown',
    }));
  }

  on(event: string, handler: (...args: unknown[]) => void): () => void {
    if (!this.hls) return () => {};

    const wrappedHandler = (...args: unknown[]) => handler(...args);
    this.hls.on(event as any, wrappedHandler);

    return () => {
      this.hls?.off(event as any, wrappedHandler);
    };
  }

  destroy(): void {
    if (this.hls) {
      this.hls.destroy();
      this.hls = null;
    }
    this.videoElement.removeAttribute('src');
    this.videoElement.load();
  }
}

/** hls.js 的类型占位 */
type HlsInstance = {
  loadSource(url: string): void;
  attachMedia(element: HTMLVideoElement): void;
  startLoad(): void;
  stopLoad(): void;
  destroy(): void;
  recoverMediaError(): void;
  on(event: string, handler: (...args: any[]) => void): void;
  off(event: string, handler: (...args: any[]) => void): void;
  readonly levels: HlsLevel[];
  currentLevel: number;
};
type HlsLevel = {
  width: number;
  height: number;
  bitrate: number;
  codecSet: string;
};
const Hls = {
  Events: { MANIFEST_PARSED: 'hlsManifestParsed', ERROR: 'hlsError' },
  ErrorTypes: {
    NETWORK_ERROR: 'networkError',
    MEDIA_ERROR: 'mediaError',
    MUX_ERROR: 'muxError',
    OTHER_ERROR: 'otherError',
  },
};

3.2 自适应码率的用户控制

hls.js 内置的 ABR(Adaptive Bitrate)算法会自动根据网络状况调整码率,但这不意味着用户不应该有手动选择权。常见的体验问题:

  • 自动切到低码率后一直回不来:网络短暂波动导致切到 480P,网络恢复后 ABR 的回升速度太慢。这时需要一个"回到自动"或"强制高码率"的入口。
  • 用户在数据流量环境下想锁定低码率:手动选择 360P 后,需要锁定 currentLevel 不再自动切换,直到用户选择"自动"模式。

实现上,通过 hls.currentLevel 的设置和 hls.autoLevelCapping 来控制。

四、DASH 适配器与多协议统一调度

4.1 DASH 与 HLS 的差异化处理

DASH(Dynamic Adaptive Streaming over HTTP)在原理上与 HLS 类似,也是分片 + 播放列表。核心差异在于:

  • 分片格式:DASH 使用 fMP4/WebM,HLS 使用 TS/fMP4。
  • 播放列表格式:DASH 用 XML 格式的 MPD 文件,HLS 用 m3u8。
  • DRM 集成:DASH 通过 MPD 中的 <ContentProtection> 标签原生支持多 DRM,HLS 需要额外处理。

适配器的实现思路与 HLS 类似,使用 dash.js 作为底层引擎,封装为 IPlayerAdapter 接口。

4.2 协议检测与自动调度

播放器核心层需要在加载视频源时自动判断协议类型并创建对应的适配器:

/**
 * 协议检测与适配器工厂
 * 根据视频源 URL 或类型字段自动选择正确的适配器
 */
class AdapterFactory {
  /**
   * 创建适配器
   * 优先使用 source.type 指定,未指定则从 URL 后缀推断
   */
  static create(
    source: VideoSource,
    videoElement: HTMLVideoElement
  ): IPlayerAdapter {
    const type = source.type || AdapterFactory.detectFromUrl(source.url);

    switch (type) {
      case 'mp4':
        return new MP4Adapter(videoElement);
      case 'hls':
        return new HLSAdapter(videoElement);
      case 'dash':
        return new DASHAdapter(videoElement);
      case 'custom':
        throw new Error('自定义协议需手动提供适配器');
      default:
        throw new Error(`不支持的视频协议: ${type}`);
    }
  }

  private static detectFromUrl(url: string): VideoSource['type'] {
    const lower = url.toLowerCase();
    if (lower.endsWith('.m3u8') || lower.includes('.m3u8')) return 'hls';
    if (lower.endsWith('.mpd') || lower.includes('/dash/')) return 'dash';
    if (lower.endsWith('.mp4') || lower.endsWith('.webm')) return 'mp4';

    // 兜底:默认为 HLS(最常见)
    return 'hls';
  }
}

五、总结

视频播放器架构的演进是从"单一 <video> 标签"到"协议无关的多适配器架构"的过程。核心设计原则包括:

适配器模式封装差异。通过 IPlayerAdapter 接口,将 MP4/HLS/DASH 的底层差异封装在适配器内部,播放器核心只与接口交互,不关心底层协议。新增协议(如 WebRTC 直播)只需新增一个适配器实现。

三级错误恢复保证播放连续性。L1 自动缓冲恢复(用户无感知)、L2 清晰度降级(切低码率重试)、L3 资源重载(重建适配器),覆盖从网络抖动到致命错误的完整错误链路。

状态机管理全生命周期。从 IDLE 到 DESTROYING 的 11 个状态覆盖了播放器的完整生命周期。状态转换不可逆(如 ERROR 只能通过 RECOVER 回到 IDLE 重建),避免状态混乱导致的 UI 不一致。

落地路线:优先实现 HLS 适配器(覆盖直播 + 点播两大场景),随后补齐 MP4 适配器(兼容存量视频),DASH 适配器根据业务需要按需引入。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/weixin_49475940/article/details/163160766

文章来源crawl

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--