A Look Inside Tubi’s Web Player

Liam Schauerman
Tubi Engineering
Published in
9 min readJun 25, 2019

--

Playback is the core offering at Tubi, and we constantly strive to deliver a delightful playback experience. Tubi’s Web Player is powering over a dozen OTT platforms, and our small team of front-end engineers accepts the challenge of supporting playback on any device. We have learned a great deal while growing from one OTT platform to many, culminating in an exciting project focusing on playback and rapid development.

During this time of growth, a dedicated web Player Module was iteratively constructed to help us quickly deliver quality features. This article will go into great detail about this project and hopefully you can take away some valuable information.

Areas for Improvement

“The mere formulation of a problem is far more essential than its solution.”
- Albert Einstein

Tubi has experienced wonderful growth recently, which presented challenges as our OTT platforms largely share a single codebase. While expanding to support so many platforms, we encountered difficulties resolving platform-specific logic across an increasing number of devices. Our solutions that worked well for 1–3 playback platforms were not scaling as well as we rolled out to 10–15 platforms. We realized that it was time to revisit the player architecture and started to design the next-generation player. We aimed to enhance both the user, as well as the development experience.

We began by examining our legacy player, and found the main problems of this player were as follows:

Mixed with business logic.

In the beginning, it was ok to put player and application together because our codebase complexity was small, but over time we let some business and analytics code muddle its focus. Extending our Player Module to support more and more platforms introduced lengthy conditional statements. Design choices that sufficed for one or two player SDK’s became an obstacle to new developers adding player functionality. The different concerns of each platform grew to be too much to maintain.

Lack of specifications for player interface, especially methods and events.

We had a set of player methods and events emitted at specific moments during playback, and our UI components could subscribe and react to these events. However, documentation was not always clear enough for new developers. What did the “ready” event mean? Which methods were required for each player adapter? How do the player states transit? We found ourselves with many questions like this, and no definite answers existed.

Low code quality.

We were lacking a clear architecture. Our Player Module was driven by ad-hoc product or technique functionalities, which led to a poor code design. In addition, there were only a few tests for the Player Module. Most of the time we required manual testing in the Player Module, which was neither scalable nor reliable.

Defining Goals

We defined several goals that would address the three problems mentioned above.

  • Create a dedicated Player Module, focusing strictly on playback.
    We set out to build a package where our application could only interact with its public interface. Furthermore, the Player Module should focus entirely on playback logic. Do one thing and do it well.
  • Expose a simple, easy to use, platform-agnostic interface.
    Because we employ heavy playback functionalities, we needed to build an interface that is well-designed, rich and easy to use. In addition, we needed to support plenty of web and OTT platforms. The interface should be platform-agnostic so that developers can be free to call it and get the expected behavior.
  • Create hierarchical, well-defined architecture.
    A Player Module is not a simple project. We needed to support different player SDK’s in different OTT platforms. We also needed to support various captions protocols, ad playback, and operations like fast forward. It was challenging to divide the different levels reasonably, but once we could create a sound architecture, adding functionality and constantly improving the Player Module would be much easier.
  • Write reliable, well-tested code.
    In addition to high coverage unit tests, we needed to also consider integration tests and E2E tests to guarantee our player worked as expected. Even unsupported media sources should be handled gracefully in some way.
  • Write clear documentation.
    Our front-end team has grown a lot. In order to reduce our frustration and increase both our efficiency and happiness, detailed and clear documentation would be paramount. But we needed to find a good approach to encourage everyone to write code documentation easily.

Code Structure

We saw two ways to make a dedicated Player Module. The first one was to create a new repository, publish the player as a private package and then import it in the main codebase. We currently use this approach in our UIKit project. The main pain-point we’ve experienced with this approach is managing the dependency. When developing UIKit with our main codebase, we needed to set up (and remember to remove) yarn links, while normally (in other projects) we only needed to install with yarn.

Because playback changes a lot with application-level requirements, it is better to keep them closer. So we chose a different approach: use our main repo as a monorepo and introduce Lerna to manage packages. Our folder structure evolved to eventually look like this:

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

Adding Typescript for Reliability

Across our front-end team, we were and still are adopting TypeScript as much as possible. With features like static type checking and auto-completion, writing new modules in TypeScript has given us extra confidence in our code.

TypeScript gave us some easy wins while building our Player Module. We defined interfaces to be used throughout the Player Module so there would be no confusion about what arguments are expected where, and what the schema of each argument should be. Furthermore, any ambiguity around player methods would be removed. For example, future developers would not need to wonder whether `seek` returns a Promise with the new target position. This information would now be available in the method definition.

We also adopted TypeDoc to automatically generate documentation based on comments in our TypeScript files. This would make it easy for developers to understand and use our Player Module.

Changing the Architecture

After multiple iterations, the new architecture was gradually outlined:

We decided to divide the Player Module into four layers: Adapter, Player, Action, and Reducer from bottom to top. Each layer is focused on solving specific problems.

Adapter Layer

Different platforms need to use different video player SDKs, such as WebMAF on PS4/PS3, AVPlay on Samsung TV, Hls.js on Web, and HTML5 video player on FireTV. The Adapter layer is used to handle all the platform-specific details and expose a consistent interface to the upper layers. In this way, the application only relies on that single interface, letting us develop features across platforms easily and reliably. It’s the secret of how we can use one codebase for all OTT platforms.

We leveraged TypeScript to constrain all adapters to the same interface. The following example demonstrates the Adapter interface, and how two adapters implement it via different player SDKs:

Define the Adapter Interface
Implement play in HlsAdapter
Implement play in WebMAFPlayerAdapter

Adapters are free to define properties or methods beyond the set required by the Adapter interface. Each Adapter carries with it some unique behaviors and often requires some properties or methods to match (all of them will be private, thanks to Typescript).

Player Layer

There is some common logic for all player adapters, such as setup, set default captions, etc. It is crucial to keep this layer simple for upper layers to interact with, we wanted each platform to use this layer in the same way. The upper layers should only know and call Player.

In our Player layer, we defined a set of methods to control the playback experience. The methods included a setup method to accept configurable options, and core player controls such as play, pause, and seek. The heavy lifting of each of these methods would be deferred to the Adapter layer. Here is a sample from our simple Player layer:

Player layer defers the implementation to the selected Adapter

The Player class is an event emitter proxy as well, allowing our application to subscribe to underlying adapter events during playback. The Adapter row at the bottom gives us flexibility to support any type of playback SDK, from HLS.js to Samsung’s AVPlayer to Playstation’s WebMAF SDK.

Action / Reducer Layer

At Tubi we use React everywhere, and Redux helps us to manage the data flow. When handling playback logic, we found that we needed to access lots of player data throughout the application. We decided to leverage the power of Redux to manage player state as well. Without going deep into Redux, you can think of the “action” as the API to control the player, and the “reducer” as the player’s state exposed to the web app. Actions can be dispatched from anywhere in the web app, and the reducer will output a single source of truth player state, which can be read by any component in our web app.

Let’s first take a look at how we used a reducer to store almost all necessary data which would then be used by our application components very easily:

In our new approach the actions (from redux) handle two main areas: subscribing to player events (and updating redux state) and player controls. For an example of the player event action flow, see below how we listen for a ‘play’ event and update state accordingly.

Some interrelated updates are handled in these actions as well. For example, we reset the buffering position when content type changes, and we update the HD flag when visual quality level changes. In this way, logic to update player state is isolated in the Action layer rather than the Player layer or Adapter layer, which is expected of an architecture with clear responsibilities for each piece.

The second area employing redux is the promise-based player-control actions:

In the above example, the `seek` action provides a high-level interface by wrapping a low-level player method and event. It returns a time-bound promise that resolves when the seek operation succeeds. This encapsulation significantly improves the efficiency of application calls. We will introduce that in detail in the next section.

Integration in Application

In our player solution, the new Player Module exposes a single source of truth player state, a set of high-level actions, and many useful events. Let’s take a look at how we can use it in our application.

In this example, WebPlayerOverlay renders the progress bar by reading the current playback position, duration, buffering position and content type from the redux player state. Whenever the user seeks to a new position, WebPlayerOverlay simply dispatches a `seek` action and sends an analytics report once the action resolves. Internally, the Player Module will update the player state, and the new position in the player state will trigger a re-render. The UI is synced with our updated player state almost immediately. Awesome!

Separating Player UI

In isolating the Player Module to strictly handle playback, some questions around player UI states came up. Does buffering belong as a player state? What about seeking? How can we represent these common playback states without adding UI concerns into the Player Module?

After considering the “seeking” state, we decided to create a second, separate state for PlayerUI in the web app layer. While the playerUI is “seeking”, the Player Module is “paused”. It is an important distinction that helped us organize our code. As a brief aside, it is important to clarify that “seeking” as we define it is also called fast-forward or rewind, it is NOT the time between a “seek” operation and the subsequent “play” operation.

The action of “seeking” is for the user to find the right playback position in the title. We decided this is not central to playback, so while the user is seeking, our Player Module’s redux state will report playback as “paused”. The UI in this state should help the user find the correct playback position, and the player should wait in a “paused” state until a suitable target position is reached. Keeping this business logic out of the Player Module is in line with our goal, “a dedicated Player Module, focusing strictly on playback”.

Buffering, on the other hand, is inseparable from our playback state. We are now able to leverage our player events to allow our Web App layer to subscribe to buffering events.

Extracting the seeking logic out of our Player Module and into a PlayerUI state was a helpful exercise, and a good reminder that different Web Apps may have different player needs. We do not have fast-forward or rewind seeking functionality in our website, so we did not want to bundle the rules about seek rate and updating the user’s target position in the shared Player Module. In web, scrubbing on the progress bar is sufficient for the user to find their position. The needs of an OTT app are different, and our focused Player Module helped us to solve these problems efficiently.

Looking Forward

Migrating to the new Player Module is sure to make development easier, and should therefore make it easier to continue improving our playback experience. Having isolated Adapters means we can fine-tune the playback parameters for a specific platform without the risk of regressions in other places. We hope to roll out more features such as support for the DASH streaming protocol, DRM, custom Media Source Extensions, as well as some internal tool evolution like state machine, E2E tests with various media files, etc, to support more fluid playback on even more platforms.

--

--