How I build a YouTube Video Player with ReactJS: Building the Project Architecture

keyurparalkar

Keyur Paralkar

Posted on November 25, 2023

How I build a YouTube Video Player with ReactJS: Building the Project Architecture

Introduction

Hey, folks so I am starting this new series of blog posts that explains how to build YouTube’s video player from scratch with React JS. The aim of this series is to understand React JS concepts by building real-life examples out there on the web.

YouTube is a popular video-streaming platform, and being able to replicate its video player will not only enhance our understanding of React and context APIs but also allow us to build similar features in our own applications. Let's dive in!

In this blog post, we will cover the basic architecture of the project.

Video player architecture

Before we dive into the implementation, let us start understanding the architecture of the project. The player is divided into three parts: Video component, BezelIcon component, and ControlToolbar component.

The Video component consists of the actual video element. In this component, we manage all the states such as play, pause, mute, unmute etc.

The BezelIcon component will be the component that shows play and pause animated button at the center of the video whenever the video is being played or paused.

The ControlToolbar component will comprise of various component that helps to control the state of the underlying video. It consists of the below components:

  • Play/Pause button
  • Next button
  • Mute/Volume button
  • Video timer
  • Autoplay button
  • Closed caption button
  • Settings, MiniPlayer, Theater Mode
  • Full-screen mode
  • Seekbar/Progress bar

Now to manage the interaction of these components we make use of the React context APIs. We have a global state that stores the exact state of the video element. This global state looks like below:



{
  isPlaying: false,
  muted: false,
  volume: 1,
}


Enter fullscreen mode Exit fullscreen mode

We make use of this global state to drive and update the state of the actual video present in the Video component. We will take a closer look at this later in this post.

We make use of reducers to dispatch the actions from the control components and BezelIcon component to update the global state. We consider here that the data flow is unidirectional i.e. control components updates the global state and Video component receives all the states and updates the actual video’s state. This decision was taking to avoid the entire prop drilling fiasco and to support cleaner codebase which we will be talking about later in this post.

YouTube Player Architecture Diagram


YouTube Player Architecture Diagram

 

Prerequisites

To follow along with this guide I would recommend to please go through the following topics:

Creating a centralized state

Now we know what the player’s architecture is, let us start by building the first basic block of the player which is the global state.

Our global state as per this blog post will comprise the following attributes:



{
  isPlaying: false,
  muted: false,
  volume: 1,
}


Enter fullscreen mode Exit fullscreen mode

Remember that this is also going to be our initial state.

💡 NOTE: The next section is going to be heavily dependent on the React context API’s. I would recommend you to go through the links related to it in the Prerequisites section.

We need to do the following things to support our architectural needs:

  1. Create a global state
  2. Make the global state available to all the components
  3. Update the global states.

Creating the global state

To create a global state we first declare the initial state as the following constant:



export const initialState = {
  isPlaying: false,
  muted: false,
  volume: 1,
};


Enter fullscreen mode Exit fullscreen mode

Next, we create a context with the help of createContext() from react’s context APIs.




type StateProps = {
  isPlaying: boolean;
  muted: boolean;
  volume: number;
};

export const PlayerContext = createContext<StateProps>(initialState);


Enter fullscreen mode Exit fullscreen mode

The createContext accepts the StateProps type that describes our global state that it comprises and must consist of fields mentioned in the StateProps type.

Make the global state available to all the components

Now that are context is ready, let us set it up for passing the initial state down the children components:



<PlayerContext.Provider value={initialState}>
    <Component1 />
</PlayerContext.Provider>


Enter fullscreen mode Exit fullscreen mode

To pass initialState down the children component you need to wrap up all your components with the provider component. Provider component is a part of React Context’s API that lets pass the value to all the children present under it’s provider. In this case, Component1 can access the initial state with the help of useContext hook:



import { useContext } from "react";

const Component1 = () => {
  const { isPlaying } = useContext(PlayerContext);

  return <span>Component 1: isPlaying: {isPlaying}</span>;
};



Enter fullscreen mode Exit fullscreen mode

So now without even passing the props down the component we can still access the state with the help of context APIs.

Update the global states

Things are shaping up a bit now, but still, one last thing is pending which is updating the global state. This piece of block is going to be really important since it handles the major interaction part of the entire player.

To update this global state, we make use of the concept of reducer function. It helps us to manage the component logic in a better and concise way. We make use of the reducer to update the global state. A reducer function takes in the current state and also takes in the action that needs to be dispatch/trigger. Based on the action a new state is returned all the time. Our global state is immutable and we would like to keep it that way, so therefore - whenever a reducer receives any action we return the updated state as the new object.

We want to update the global state whenever the following things happens:

  • When video is played or paused
  • When video is muted or unmuted
  • Whenever the volume is changed

All these 3 are nothing but actions that our control components within the ControlToolbar component will be doing. Let us create a reducer function with the required actions:



// actions
export const PLAY_PAUSE = "PLAY_PAUSE";
export const ON_MUTE = "ON_MUTE";
export const VOLUME_CHANGE = "VOLUME_CHANGE";

type ActionProps = {
  type: string,
  payload?: any,
};

export const playerReducer = (state: StateProps, action: ActionProps) => {
  switch (action.type) {
    case PLAY_PAUSE: {
      return {
        ...state,
        isPlaying: action.payload,
      };
    }

    case ON_MUTE: {
      return {
        ...state,
        muted: action.payload,
      };
    }

    case VOLUME_CHANGE: {
      return {
        ...state,
        volume: action.payload,
      };
    }

    default: {
      throw Error("Unknown action: " + action.type);
    }
  }
};



Enter fullscreen mode Exit fullscreen mode

Here the playerReducer is the reducer function that takes in the current state and action that needs to be dispatched. If you carefully observe the type of action i.e. ActionProps then you will notice that it accepts type as the mandatory param and payload as optional. It is imperative that we pass type as the action’s type. payload is an optional parameter, it consists of additional data that we can pass while dispatching an event. We also make string constants representing our actions which we have defined at the top.

What we want now is to allow the children components use these actions. To achieve this we again make use of createContext API to create a context but this time it would be for player’s dispatch events:



export const PlayerDispatchContext = createContext<Dispatch<ActionProps>>(
  (() => undefined) as Dispatch<ActionProps>
);


Enter fullscreen mode Exit fullscreen mode

Now to pass this down to the actual children, we need to wrap our existing children with the PlayerDispatchContext provider.



export const PlayerProvider = ({ children }: any) => {
  const [state, dispatch] = useReducer<Reducer<StateProps, ActionProps>>(
    playerReducer,
    initialState
  );

  return (
    <PlayerContext.Provider value={state}>
      <PlayerDispatchContext.Provider value={dispatch}>
        {children}
      </PlayerDispatchContext.Provider>
    </PlayerContext.Provider>
  );
};


Enter fullscreen mode Exit fullscreen mode

A couple of things happened here:

  • First, we created a new component called as PlayerProvider. This component acts as a combined provider wrapper for state i.e. global state and for dispatching actions
  • We make use of the useReducer hook to initialize our reducer function with the initial state.
  • This hooks returns the state and dispatch as the 2 values that provides us the global state and a dispatch method that helps us to dispatch actions.

Cool!! Let us have a quick recap on what all things we covered here:

  • We saw how to create a global state with createContext
  • We saw how to pass this state via the provider wrapper
  • We saw how to combine both the context of player state and player dispatch actions with the help of PlayerProvider

Let us clean up our code a bit and combine all these in a single file. Create a new folder named context and create a file named index.tsx and place the following content inside it:



import { createContext, Dispatch, Reducer, useReducer } from "react";
import { ON_MUTE, PLAY_PAUSE, VOLUME_CHANGE } from "./actions";

type StateProps = {
  isPlaying: boolean;
  muted: boolean;
  volume: number;
};

type ActionProps = {
  type: string;
  payload?: any;
};

export const initialState = {
  isPlaying: false,
  muted: false,
  volume: 1,
};

export const PlayerContext = createContext<StateProps>(initialState);
export const PlayerDispatchContext = createContext<Dispatch<ActionProps>>(
  (() => undefined) as Dispatch<ActionProps>
);

export const playerReducer = (state: StateProps, action: ActionProps) => {
  switch (action.type) {
    case PLAY_PAUSE: {
      return {
        ...state,
        isPlaying: action.payload,
      };
    }

    case ON_MUTE: {
      return {
        ...state,
        muted: action.payload,
      };
    }

    case VOLUME_CHANGE: {
      return {
        ...state,
        volume: action.payload,
      };
    }

    default: {
      throw Error("Unknown action: " + action.type);
    }
  }
};

export const PlayerProvider = ({ children }: any) => {
  const [state, dispatch] = useReducer<Reducer<StateProps, ActionProps>>(
    playerReducer,
    initialState
  );

  return (
    <PlayerContext.Provider value={state}>
      <PlayerDispatchContext.Provider value={dispatch}>
        {children}
      </PlayerDispatchContext.Provider>
    </PlayerContext.Provider>
  );
};


Enter fullscreen mode Exit fullscreen mode

Also, create another file named actions.ts inside the same folder and paste the following in it:



// actions
export const PLAY_PAUSE = "PLAY_PAUSE";
export const ON_MUTE = "ON_MUTE";
export const VOLUME_CHANGE = "VOLUME_CHANGE"; 


Enter fullscreen mode Exit fullscreen mode

Awesome!! Congratulations you successfully setup the entire update mechanism with the help of react context APIs. If you look closely it is also similar to redux 😉

Why was it necessary to follow this pattern?

We followed this approach to avoid excessive prop drilling. I observed during the initial phases of the development of this project that to update the video’s state I had to pass the video’s ref at very deep levels which I thought to be too much of prop drilling with forward refs. It also introduced duplicate passing of props i.e. same props were being passed to multiple components/children.

So I came up with the plan to update the video’s state only at one place and let the reducer handle the logic of updating the global state and dispatching the actions.

Folks but do let me a if there is any better way to do this, feel free to drop in comments about this!!

Build YoutubePlayer component

This is the main wrapper of the video player. It consists of the main provider wrapper: PlayerProvider, in this way all the children component has access to all the states and actions that we created in the above section. Create a file named YoutubePlayer.tsx inside the components folder if not please create this folder. Place the following content in it:



import styled from "styled-components";
import { PlayerProvider } from "../context";

const StyledVideoContainer = styled.div`
  position: relative;
  width: fit-content;
`;

const YoutubePlayer = () => {
  return (
    <PlayerProvider>
      <StyledVideoContainer>
          {/* children component */}
      </StyledVideoContainer>
    </PlayerProvider>
  );
};

export default YoutubePlayer;


Enter fullscreen mode Exit fullscreen mode

We make use of the styled-components to create another wrapper. The purpose of this wrapper is to act like a container for all the children.

With the basic set, let us dive into building the basic blocks of the player.

Build Video component

So now we have our state management ready with the help of react context APIs, let us start with our first child component that helps to build the youtube player and that is Video component.

In the architecture section, we discussed about the Video component. A quick recap it does the following things:

  • It hosts the video HTML element
  • It manages all the states of the video i.e. pausd, played, mute and unmuted.

Additionally, this component will also play and pause the video whenever the user clicks on the entire video. This is one of the basic features of the youtube player i.e. when the user clicks anywhere on the video then it gets paused and played. This is the same thing that we need to achieve on our component. We will look into this later in this section but now let us start with the basics, create a functional component named Video such that it returns the video HTML element:



const Video = () => {
  return (
    <video src="http://iandevlin.github.io/mdn/video-player/video/tears-of-steel-battle-clip-medium.mp4" />
  );
};



Enter fullscreen mode Exit fullscreen mode

Do make sure that you create directory named: components and create a new file named Video.tsx and then place all the content that is present in this section into that file.

As you observe from the above snippet, we are using an open source video as the source of the video element. You can find more opensource videos here.

The aim of this component is to update the state of the video HTML element, so let us first create a reference ref that can be used to control the state of the video:



 const Video = () => {
    const videoRef = useRef<HTMLVideoElement>(null); 
  return (
      <video
                ref={videoRef}
        src="http://iandevlin.github.io/mdn/video-player/video/tears-of-steel-battle-clip-medium.mp4"
      />
  );
};


Enter fullscreen mode Exit fullscreen mode

Now let’s get the values from the global state that we created from the above section. You can do so with the help of useContext hook, it helps us to read the context that were created.



 const Video = () => {
    const videoRef = useRef<HTMLVideoElement>(null);
    const { isPlaying, muted, volume } = useContext(PlayerContext);
  const dispatch = useContext(PlayerDispatchContext);

    useEffect(() => {
    if (videoRef.current) {
      const video = videoRef.current;
      if (isPlaying) {
        video.play();
      } else {
        video.pause();
      }
    }
  }, [isPlaying]);

  useEffect(() => {
    if (videoRef.current) {
      videoRef.current.muted = muted;
    }
  }, [muted]);

  useEffect(() => {
    if (videoRef.current) {
      videoRef.current.volume = volume;
    }
  }, [volume]);

  return (
      <video
                ref={videoRef}
        src="http://iandevlin.github.io/mdn/video-player/video/tears-of-steel-battle-clip-medium.mp4"
      />
  );
};



Enter fullscreen mode Exit fullscreen mode

We get all the states from the PlayerContext since this is the context that acts as the global state. Next, we also get the dispatch method from another context: PlayerDispatchContext. This context stores the dispatch method required to trigger the action such as PLAY_PAUSE etc. We also added couple of effects here. The main purpose of adding multiple effects is the separation of concern. It is always recommended that each useEffect should only perform one side effect. Clubbing multiple side effects can cause unexpected behavior and can lead to buggy code.

Each effect only changes one state of the video for example, the first effect updates the state of the video from paused to playing or vice-versa, second effect mutes and unmutes the video based on the value of mute in the global state and lastly, the last effect updates the volume of the video based on the volume that is present in the global state.

One of our other requirements was to play and pause the video whenever the user clicks on the entire video. To do that we wrap the video HTML tag with a div tag and pass an onClick attribute that updates the state of the video.



import { useContext, useEffect, useRef } from "react";
import { PlayerContext, PlayerDispatchContext } from "../context";
import { PLAY_PAUSE } from "../context/actions";

const Video = () => {
  const videoRef = useRef<HTMLVideoElement>(null);
  const { isPlaying, muted, volume } = useContext(PlayerContext);
  const dispatch = useContext(PlayerDispatchContext);

  const onPlayPause = () => { // <-------- here
    dispatch({ type: PLAY_PAUSE, payload: !isPlaying });
  };

  useEffect(() => {
    if (videoRef.current) {
      const video = videoRef.current;
      if (isPlaying) {
        video.play();
      } else {
        video.pause();
      }
    }
  }, [isPlaying]);

  useEffect(() => {
    if (videoRef.current) {
      videoRef.current.muted = muted;
    }
  }, [muted]);

  useEffect(() => {
    if (videoRef.current) {
      videoRef.current.volume = volume;
    }
  }, [volume]);

  return (
    <div onClick={onPlayPause} className="html-video-container">
      <video
        ref={videoRef}
        src="http://iandevlin.github.io/mdn/video-player/video/tears-of-steel-battle-clip-medium.mp4"
      />
    </div>
  );
};

export default Video;


Enter fullscreen mode Exit fullscreen mode

We passed the wrapper div with an onPlayPause handler. This handler makes use of the dispatch method to trigger a PLAY_PAUSE action along with the payload.

Finally, place this component as a child inside the YoutubePlayer component:



import styled from "styled-components";
import { PlayerProvider } from "../context";

const StyledVideoContainer = styled.div`
  position: relative;
  width: fit-content;
`;

const YoutubePlayer = () => {
  return (
    <PlayerProvider>
      <StyledVideoContainer>
              <Video />
      </StyledVideoContainer>
    </PlayerProvider>
  );
};

export default YoutubePlayer;


Enter fullscreen mode Exit fullscreen mode

In this way, the Video component gets access to the global state when it is placed inside the PlayerProvider and synchronizes with the global state.

Building BezelIcon

Play Icon
Pause Icon

bezel-icon-gif

Bezel Icon in action

 

This component displays the play and pause icon that disappears just after a certain milliseconds when the video is played and paused.

The component works on the following logic:

  1. Do not show the bezel icon when mounted
  2. Whenever the global state: isPlaying gets changed then show the respective bezel icon.
  3. Once the icon is displayed it should fade away and later shouldn’t occupy any space in the DOM.

Create a file named: BezelIcon.tsx inside the components. Then place the following content in it:



import { useContext, useEffect, useRef } from "react";
import { HiMiniPlay, HiMiniPause } from "react-icons/hi2";
import styled, { keyframes } from "styled-components";
import { PlayerContext } from "../context";

const bezelFadeoutKeyframe = keyframes`
    0% {
      opacity: 1;
    }

    100% {
      opacity: 0;
      transform: scale(2);
    }
`;

const StyledBezelContainer = styled.div`
  position: absolute;
  top: 44%;
  left: 44%;
  color: #cacaca;
  background-color: rgba(0, 0, 0, 0.5);
  padding: 12px;
  border-radius: 50%;
  animation: ${bezelFadeoutKeyframe} 0.5s linear 1 normal forwards;
  display: none;
`;

const BezelIcon = () => {
  return (
    <StyledBezelContainer>
      <HiMiniPause size="35px" />
    </StyledBezelContainer>
  );
};

export default BezelIcon;


Enter fullscreen mode Exit fullscreen mode

As you can observe, here we again make use of style-components to create a container for the underlying icon. Also, we make use of the react-icons that provide a react wrapper for icons from various providers. Here we make use of the icons from the HeroIcons2 provider hence we import it from hi2 from the react-icons

With the current styles of the StyledBezelContainer the pause icon won’t be displayed since they it has display: none property. We toggle this property inside the useEffect and the toggle will only happen when the isPlaying global state variable changes. The purpose of this is to have an effect where the icon is displayed for a certain amount of milliseconds and then disappears.



import { useContext, useEffect, useRef } from "react";
import { HiMiniPlay, HiMiniPause } from "react-icons/hi2";
import styled, { keyframes } from "styled-components";
import { PlayerContext } from "../context";

const bezelFadeoutKeyframe = keyframes`
    0% {
      opacity: 1;
    }

    100% {
      opacity: 0;
      transform: scale(2);
    }
`;

const StyledBezelContainer = styled.div`
  position: absolute;
  top: 44%;
  left: 44%;
  color: #cacaca;
  background-color: rgba(0, 0, 0, 0.5);
  padding: 12px;
  border-radius: 50%;
  animation: ${bezelFadeoutKeyframe} 0.5s linear 1 normal forwards;
  display: none;
`;

const BezelIcon = () => {
const iconContainerRef = useRef<HTMLDivElement>(null);
  const { isPlaying } = useContext(PlayerContext);

  useEffect(() => {
    if (iconContainerRef.current) {
      iconContainerRef.current.style.display = "block";
    }

    const timerId = setTimeout(() => {
      if (iconContainerRef.current) {
        iconContainerRef.current.style.display = "none";
      }
    }, 500);

    return () => {
      clearTimeout(timerId);
    };
  }, [isPlaying]);

  return (
    <StyledBezelContainer ref={iconContainerRef}>
            {isPlaying ? <HiMiniPause size="35px" /> : <HiMiniPlay size="35px" />}
    </StyledBezelContainer>
  );
};

export default BezelIcon;


Enter fullscreen mode Exit fullscreen mode

A couple of changes here,

  • First, we added a ref in the StyledBezelContainerso that it can be later access in the useEffect
  • We make use of the isPlaying state to manage the icon to be shown i.e. either play or pause.
  • Lastly, we have added an useEffect that updates the display property. But there is a slight twist here which is we first update the display property to block and after that we update it to none after 500 ms. Once the component is unmounted clear the timeout by passing the timerId into the clearTimeout function.

But we still have a problem, which is the bezel icon still gets displayed at the start i.e. when the component mounts or page refreshes.

In that case, we need to stop the above effect to execute at the first render. To do that, we create another reference that keeps track of the first render of the component. If it's the first render, then we exit the effect:



import { useContext, useEffect, useRef } from "react";
import { HiMiniPlay, HiMiniPause } from "react-icons/hi2";
import styled, { keyframes } from "styled-components";
import { PlayerContext } from "../context";

const bezelFadeoutKeyframe = keyframes`
    0% {
      opacity: 1;
    }

    100% {
      opacity: 0;
      transform: scale(2);
    }
`;

const StyledBezelContainer = styled.div`
  position: absolute;
  top: 44%;
  left: 44%;
  color: #cacaca;
  background-color: rgba(0, 0, 0, 0.5);
  padding: 12px;
  border-radius: 50%;
  animation: ${bezelFadeoutKeyframe} 0.5s linear 1 normal forwards;
  display: none;
`;

const BezelIcon = () => {
  const iconContainerRef = useRef<HTMLDivElement>(null);
  const isFirstRender = useRef(true);
  const { isPlaying } = useContext(PlayerContext);

  useEffect(() => {
    /**
     * We execute this effect apart from first render.
     * In dev env, this effect will render twice since we are in strict mode everything runs twice.
     * Checked this in build and this effect is working as expected
     */
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return;
    }
    if (iconContainerRef.current) {
      iconContainerRef.current.style.display = "block";
    }

    const timerId = setTimeout(() => {
      if (iconContainerRef.current) {
        iconContainerRef.current.style.display = "none";
      }
    }, 500);

    return () => {
      clearTimeout(timerId);
    };
  }, [isPlaying]);

  return (
    <StyledBezelContainer ref={iconContainerRef}>
        {isPlaying ? <HiMiniPause size="35px" /> : <HiMiniPlay size="35px" />}
    </StyledBezelContainer>
  );
};

export default BezelIcon;


Enter fullscreen mode Exit fullscreen mode

This is how we implement the BezelIcon that appears on top of the video player.

Summary

To summarize we learned the following things from this blog post,

  • We learned the architecture of our project
  • The data flow between the video component and the controls
  • Implemented the video component that updates the video’s state whenever the global state changes.
  • Implemented the bezel icon component

In the next blog post, we are going to cover the control toolbar component i.e. the bar at the bottom of the player that contains different controls.

The entire code for this tutorial can be found here.

Thank you for reading!

Follow me on twittergithub, and linkedIn.

💖 💪 🙅 🚩
keyurparalkar
Keyur Paralkar

Posted on November 25, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related