如何打造 Tubi Web Player?

avatar
HR @Tubi

作为 Tubi 毋庸置疑的核心产出,我们一直致力于提供令人赏心悦目的播放体验。Tubi 前端团队支撑着 Web 和十余个 OTT(over-the-top)平台,在维护和提升我们的播放体验上面临着诸多挑战。从起初仅支持单一平台到现在覆盖所有主流的流媒体平台,我们在这个过程中积累了丰富经验,并逐步孕育出一个激动人心的专注于播放和快速开发的项目:Tubi Web Player。这个独立项目可以帮助我们快速产出高质量的视频播放相关功能。本文将详细介绍该项目,希望你可以从中了解到一些有价值的信息。

===

🔎界定主要问题

The mere formulation of a problem is far more essential than its solution.

Albert Einstein

Tubi 在过去一段时间业务增长非常快速,这也带来一些新的挑战。因为我们所有的 OTT 平台共用一个代码仓库,当接入的平台不断增长时,如何解决不断膨胀的平台相关逻辑越发困难。一开始我们的改进方案在两三个平台上运转良好,但当扩展到十几个平台时就变得力不从心。我们意识到是时候重新审视播放器架构并开始设计下一代 Tubi Web Player 了,它要解决的核心问题包括三个方面:提升用户的播放体验,帮助提高代码质量,以及让开发者可以更好地共同维护。

在审视之前的播放器解决方案后,我们发现主要存在以下问题:

  • 业务耦合

    在项目早期,复杂度还比较小,将播放器和业务代码揉在一起并不会带来太多问题。随着时间的推移,持续增长且缺乏管理的业务逻辑,会让播放器模块的职能逐渐变得模糊。当扩展播放器模块以支持越来越多的平台时,我们不得不引入冗长的条件语句。对于一两个平台的底层播放器 SDK 来说足够的设计方案,在添加播放器功能时却成为明显的障碍。每个平台的不同关注点日渐增长,为维护带来了不小的挑战。

  • 缺乏接口定义

    播放器除了提供一系列方法以外,还会有许多事件在播放期间的特定时刻被触发,UI 组件可以订阅并响应这些事件。但是我们并没有提供清晰的文档给开发者,快速解答他们在开发过程中的问题,比如:什么是 ready 事件,它会在什么时候被触发;如果在一个新平台接入播放器,什么类型的数据和哪些接口的实现是必须的;在播放器内部,存在哪些状态......开发者在开发过程中不断产生类似的疑惑,但他们却无法很快找到答案。

  • 代码质量较低

    在过去,Tubi 的播放器方案是由临时的业务需求或者平台的技术要求驱动的,这导致它缺乏一个清晰的架构。此外,与代码量相较而言,测试的覆盖率和质量远远不足。大多数时候,我们只能依靠人肉测试来验证开发结果,这既不可扩展也不可靠。


🎯设定明确的目标

针对以上几个亟需解决的问题,我们设定了如下目标:

  • 聚焦的独立模块

    新的 Tubi Web Player 应该是一个独立模块,应用只能通过公开接口与 Player 模块进行交互,同时严格限制 Player 模块的内部实现,坚持“Do one thing and do it well”的原则,不允许夹杂特殊的业务逻辑。

  • 简单易用、与平台无关的接口

    因为我们的业务中大量应用播放功能,所以我们需要构建一个设计精良、丰富且易于使用的播放器接口。此外,我们需要支持十余个 Web 和 OTT 平台,因此播放器接口应该是与平台无关的,以便开发人员可以随意调用它并获得预期的行为。

  • 层级清晰、设计优良的架构

    播放器模块并不简单。我们需要在不同的 OTT 平台上支持不同的底层播放器 SDK。我们还需要支持各种字幕格式、广告播放和快进快退等操作。合理划分不同层级并确立它们之间的联系充满挑战,不过一旦我们能够建立一个完善的架构,添加功能和不断改进这一模块将会变得更加容易。

  • 全面覆盖且可靠的测试

    不只是单元测试,还需要更多的集成测试、E2E Testing 来保证质量,一些极端情况也需要能够在测试中覆盖。

  • 详细清晰的文档记录

    Tubi 前端团队已经扩充到十名成员,为了减少大家在重复问题上的疑惑,提高我们的效率和幸福感,一份详细而清晰的文档显得很有必要。为了达到这个目的,我们也需要找到一种好的方法来鼓励每个人轻松编写代码文档。

===

🗂选择对的方式去管理代码是好的开端

目前有两种方式创建 Player 模块。第一种是通过独立的仓库维护,将模块发布到私有包管理服务上,在项目中将其添加为依赖(我们目前在内部的 UI Kit 项目中就是使用这种方式)。这种方式的痛点在于管理依赖性。特别是在开发独立模块时,你需要在引用它的项目中用 Yarn 或者 NPM 与你的模块软链,但这个操作往往会引发一系列其它问题。

考虑到 Player 模块和我们的应用关系非常密切,经常有一些联动的修改,两者最好能够放在一起进行维护。所以我们选择了另一种方式:将我们的代码库改进为 Monorepo 并引入 Lerna 来管理各个模块。我们的项目结构演变为:

.
├── lerna.json
├── package.json
├── packages
│   └── player
└── src

🌸再通过 TypeScript 锦上添花

在 Tubi 前端团队,优先使用 TypeScript 是所有人的共识。通过 TypeScript 提供的静态类型检查、自动补全等功能,我们可以更加自信地提交修改,大大降低开发成本并提升代码的稳定性。在用 TypeScript 定义接口的同时,也意味着我们在解释接口的形状和用途,开发者在他们的 IDE 中可以看到模块的接口,接口方法上各种参数的类型,以及接口是否有返回数据等等有用的信息。与此同时,我们引进了 TypeDoc 这个基于 TypeScript 的文档生成工具,借助这个得力工具,我们仅仅需要在文件中合适的位置加上注释,就可以生成一份组织良好的文档。这些都非常有利于解决上面提到有关接口和文档的问题。

===

🍔接着,规划合适的架构

经过若干次迭代,Player 模块逐渐形成了全新的架构,自下而上依次划分为四个层次:Adapter、Player、Action 和 Reducer:

图片

Adapter

不同的平台需要使用不同的底层播放器 SDK,比如 PS4/PS3 上的 WebMAF、三星电视上的 AVPlay、Web 上的 HLS、FireTV 上的 HTML5 Video Player 等。Adapter 层主要职能是处理具体平台内部细节,并向上层暴露统一接口。通过这种方式,不同平台的应用只需要依赖一个一致的接口,而开发者可以在接口的背后实现特定平台相关的技术要求。这正是我们为所有 OTT 平台提供一个代码库的秘诀。

我们利用 TypeScript 将所有 Adapters 约束到同一个接口。以下示例演示了 Adapter 接口,以及两个 Adapters 如何通过不同的底层播放器 SDK 实现它:

// types.ts
interface Adapter extends EventEmitter {
  setup(): Promise<void>;
  getState(): State;
  getPosition(): number;
  getDuration(): number;
  play(): void;
  pause(): void;
  seek(offset: number): void;
  remove(): void;
  isAd(): boolean;
  playAd(tag: string): void;
  setQuality?(index: number): void;
  ...
}

// HlsAdapter.ts
class HlsAdapter extendsEventEmitter implements Adapter {
  play() {
    this.emit(PLAYER\_EVENTS.play);
    this.videoElement.play();
  }
  ...
}

// WebMAFAdapter.ts
class WebMAFPlayerAdapter extends EventEmitter implements Adapter {
  play() {
    this.emit(PLAYER\_EVENTS.play);
    webMAF.nativeCommand('play');
  }
  ...
}

除了 Adapter 接口要求的属性或方法外,Adapters 可以自由定义其它的属性或方法,从而能够满足特定平台的技术要求。所有的这些方法和属性都是私有的,感谢 Typescript。

Player

Adapters 会有一些共同的处理逻辑,比如初始化、设置默认字幕等。此外,我们希望每个平台以相同的方式使用 Player,所以对于上层来说,保持 Adapter 层和它们之间的交互尽可能简单非常重要。基于这两方面的考虑,我们在 Adapter 层之上增加了 Player 层,上层应该只知道并调用 Player 层。

在 Player 层中,我们定义了一组方法来控制内容播放,例如接受配置参数并初始化的 setup 方法,播放、暂停、快进等核心播放控制方法,以及更新字幕、码率等方法。每一个方法都会在内部同步或者异步地调用特定 Adapter 方法来捋顺复杂的平台相关逻辑。下面是一个简化版的 Player 层实现示例:

class Player {
  play(): Promise<void> {
    return this.adapter.play();
  }

  seek(position: number) {
    this.adapter.seek(position);
  }
  ...
}

Player 类同时也是一个可以发送事件的对象,应用可以订阅 Player 实例的事件来监听底层的状态变化,这意味着各式各样的平台相关播放器 SDK 都可以借助 Adapter 在 Player 类中实现相仿的事件和方法。


Action & Reducer

当需要处理复杂的播放逻辑,而且整个应用都重度依赖这些逻辑的时候,如果没有一个好的数据管理方案,开发者会非常头疼。Tubi 前端团队是 React 和 Redux 的忠实拥趸,我们决定借用 Redux 的能力来更好地管理播放逻辑和状态信息。如果对于 Redux 了解不多,你可以简单认为 action 就是调用 Player 的 API,而 reducer 就是 Player 暴露给应用的状态数据。Action 可以在应用的任何地方被调用,而 reducer 则会输出操作完成后的状态,这些状态可以被任意 UI 组件使用。

下面这段代码展示了我们如何在 Reducer 层存储几乎所有必要的数据:

// reducer.ts
const initialState: PlayerStoreState = {
  playerState: State.idle,
  contentType: PLAYER\_CONTENT\_TYPE.video,
  seekRate: -1,
  progress: {
    position: 0,
    duration: 0,
    bufferPosition: 0,
    isBuffering: false,
  },
  quality: {
    qualityList: \[\],
    qualityIndex: 0,
    isHD: false,
  },
  ...
};

我们的新实现中,Action 层会处理两方面逻辑:监听 Player 事件并更新 Reducer 状态,以及控制视频播放。下面的示例展示了我们如何监听 play 事件并发送相应的 action 来更新 Store 状态:

getPlayerInstance().on(PLAYER\_EVENTS.play, () => {
  dispatch(transit(State.playing, {
    contentType: PLAYER\_CONTENT\_TYPE.video,
  }));
});

一些关联的更新也可以在 Action 层处理,例如当电影自动播放到下一个时,我们需要重置缓冲位置,流媒体清晰度变更后,则需要更新视频质量的 UI 按钮。通过引入 Redux 中的 action,这些复杂的逻辑处理可以集中在 Action 层中进行管理,而不是在Player、Adapter 等其它层次中,这正是一个层次清晰、职责明确的架构所期望的。

在 Action 层中另外一部分逻辑是播放控制 actions:

const controlActions = {
  seek: (position: number) => (dispatch, getState) => createTimeBoundedPromise(
    (resolve) => {
      const { progress: { duration } } = getState().player;
      const targetPosition = clamp(position, 0, duration || Number.POSITIVE\_INFINITY);
      getPlayerInstance().once(PLAYER\_EVENTS.seeked, resolve);
      getPlayerInstance().seek(targetPosition);
      
      return {
        // remove event listener if target event timeout
        destroy: () => {
          getPlayerInstance().removeListener(PLAYER\_EVENTS.seeked, resolve);
        },
      };
    },
  ),
  ...
};

在上例中,seek 方法提供了一个更高层次的接口,封装了 Player 方法以及事件,返回了一个具有超时判断条件的 promise,它会在快进操作成功时 resolve。这种封装可以非常显著地提高应用调用接口的容易程度,下一节会重点介绍这方面内容。

===

📺最后,关键是在应用中的实践

对于应用来说,Player 模块提供了播放器状态的 Single Source of Truth,一组高层次封装的 actions,以及丰富的播放器事件。下面我们看一下如何将它和应用集成起来:

// WebPlayerOverlay.ts
class WebPlayerOverlay extends Component<Props, State> {
  ...
  seek(position) {
    const { dispatch, trackEvent, video: { id } } = this.props;
    return dispatch(controlActions.seek(position))
      .then(() => {
        this.refreshActiveTimer();
        if (trackEvent) {
          trackEvent(eventTypes.SEEK, position, id);
        }
      });
  }

  render() {
    ...
    return (
      <div className={styles.webPlayerOverlay}>
        <ProgressBar
          bufferPosition={bufferPosition}
          duration={duration}
          isAd={isAd}
          position={position}
          seek={this.seek}
        />
        <PlayerControls ... />
      </div>
    );
  }

这段代码中,WebPlayerOverlay 组件从 Redux Store 中获取播放进度、影片时长、缓冲位置和内容类型等数据,并渲染了 ProgressBar、PlayerControls 等组件。无论何时用户切换播放位置,WebPlayerOverlay 都会调用 seek action 来轻松地操作底层 Player,并在操作完成后上报统计信息、刷新控制栏活跃状态等。在 Player 模块内部,前面的 seek 操作会更新 Player 的状态,而 Player 的状态更新可以通过监听 Redux Store 被组件捕获,最终组件会根据需要按照最新状态重新渲染。简直太棒了!

💪我们可以做得更好

目前,我们已经完成了计划中的大部分工作,结果正如我们期望的那样:视频播放相关功能的开发更加聚焦和容易,代码质量也有明显提高,这些意味着不断打磨 Tubi 的播放体验变得更加简便。将与平台密切相关的 Adapter 剥离出来意味着我们可以放心地对特定平台的播放参数进行微调,而不必担心这会引起其它平台上的回归问题。

展望未来,我们希望能够推出更多实用功能,例如支持 DASH 流媒体协议、构建跨平台的 DRM、实现自定义轻量级 MSE 封装等。与此同时,我们也会持续改进内部工具,包括引入状态机来更好地管理复杂的播放器状态迁移、增加各种媒体文件的 E2E 测试等。通过这一系列努力,我们期望能够持续地在更多平台上支持愉悦的播放体验。