Home
Nacho's Blog

How to Build a S.O.L.I.D. Video Player

...on Typescript

This is an article mostly targeted at JR devs who want some real world examples of Patterns and SOLID Principles coming into use in the real world.

Introduction

So I was building a video player that supports both YouTube and Twitch as video sources, because of this I now had 2 libraries to handle video playing in my app. First I had to take a detour and write typescript definitions for Twitch's video player and published them to DefinitelyTyped Once that was done, I now had 2 libraries that have very similar functions but different methods to control them.

The Plan

planning gif

in order to build my video player, I'll need to create different video player instances that have different methods and have a single way to control them. First I have to have a way to create different types of video players with different kinds, and if you know your Design Patterns you know that this sounds rather Factory-ish.

So the factory pattern gives us a solution when we need two different classes on runtime, we create a method that given an argument will return a different class that responds to the same interface. And at this point we can clearly see the next step...

We need a player interface with known methods, so let's make it:

export interface LiveVideoPlayer {
  play: () => void;
  pause: () => void;
  mute: () => void;
  unmute: () => void;
}

Simple... Because my use of the players is quite basic, I only added the methods I would use. This is so we follow the Interface Segregation Principle. Also, not implementing things I might not use yet is a good idea.

Mo problems ⟹ Mo patterns

problems gif

Twitch's Player and YouTube's Player don't have the same interface... Our new interface is nice and all, but neither YouTube nor twitch follow it. Lucky for us, there is a pattern that solves this exact issue: Adapter

And this is the fun thing about patterns. I can just say that I implemented and adapter and people who know something about the adapter pattern now have an idea of what the solution looks like.

export class TwitchPlayer implements LiveVideoPlayer {
  private player: Twitch.Player | null;
  constructor(divId: string, videoId: string, onReady?: () => {}) {
    // implements player creation and initialization
  }

  pause() {
    this.player?.pause();
  }

  mute() {
    this.player?.setMuted(true);
  }

  unmute() {
    this.player?.setMuted(false);
  }

  play() {
    this.player?.play();
  }
}

export class YoutubePlayer implements LiveVideoPlayer {
  private player: YT.Player | null;
  constructor(divId: string, videoId: string, onReady?: () => {}) {
    // implements player creation and initialization
  }

  pause() {
    this.player?.pauseVideo();
  }

  mute() {
    this.player?.mute();
  }

  unmute() {
    this.player?.unMute();
  }

  play() {
    this.player?.playVideo();
  }
}

As we can see, each video player implements LiveVideoPlayer, holds an internal instance of the adapted class and calls the appropriate methods. Also handles initialization, as those are very different. In my case, events are also captured and adapted to match.

Final Notes

At this point our app can create video players and thanks to the principle of Liskov Substitution and some polymorph magic the React side of the app can just call the LiveVideoPlayer methods and get the appropriate methods called on each version of the player.

And because our React app now uses an interface to use the players, we are following the Dependency Inversion Principle without even trying, similarly with the [Open/Closed Principle](Open–closed principle) as our players implementation is now isolated from our app and its code. Proving that good practices are all tied together and will come naturally once you start taking them into account.

Published: Thursday, Nov 9, 2023
Privacy Policy© 2023 Ignacio Degregori. All rights Reserved.