How I build a YouTube Video Player with ReactJS: Building the Project Architecture
Tags: react, javascript, webdev, opensource
Published: 2023-11-25 10:36:08

## 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:

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

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.

<figure>
  <img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xif0qg62fpxp8ijzjnfl.jpg" alt="YouTube Player Architecture Diagram">
  <figcaption>YouTube Player Architecture Diagram</figcaption>
</figure>

&nbsp;

## Prerequisites

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

- Getting started with [Create react app](https://create-react-app.dev/docs/getting-started#creating-a-typescript-app) to initialize the project.
- [React context API’s](https://react.dev/learn/scaling-up-with-reducer-and-context)
    - `useReducer` and `useContext`
- Passing state down the children component blogpost by [react.dev](https://react.dev/learn/passing-data-deeply-with-context)
- [Framer-motion](https://www.framer.com/motion/introduction/)

## 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:

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

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:

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

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

```tsx

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

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

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:

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

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:

```tsx
import { useContext } from "react";

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

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

```

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:

```tsx
// 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);
    }
  }
};

```

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:

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

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

```tsx
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>
  );
};
```

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:

```tsx
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>
  );
};
```

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

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

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:

```tsx
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;
```

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:

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

```

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](https://gist.github.com/jsturgis/3b19447b304616f18657).

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:

```tsx
 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"
      />
  );
};
```

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. 

```tsx
 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"
      />
  );
};

```

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.

```tsx
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;
```

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:

```tsx
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;
```

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

<table>
   <tr>
      <td style="border: none !important;">
        <figure>
         <img src='https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i1c58v5myhc0b8t05x6z.png'>
<figcaption>Play Icon</figcaption>
</figure>
      </td>
      <td style="border: none;">
<figure>
<img src='https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nsuku6vtey8xcfi133ks.png'>
<figcaption>Pause Icon</figcaption>
</figure>
</td>
   </tr>
</table>

<figure>
 <img alt="bezel-icon-gif" src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lgz6537o7jygtsz4w5la.gif"/>
<figcaption>Bezel Icon in action</figcaption>
</figure>

&nbsp;

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:

```tsx
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;
```

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](https://react-icons.github.io/react-icons) that provide a react wrapper for icons from various [providers.](http://providers.Here) 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.

```tsx
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;
```

A couple of changes here, 

- First, we added a ref in the `StyledBezelContainer`so 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:

```tsx
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;
```

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](https://github.com/keyurparalkar/react-youtube-player-clone).

Thank you for reading!

Follow me on [twitter](https://twitter.com/keurplkar), [github](http://github.com/keyurparalkar), and [linkedIn](https://www.linkedin.com/in/keyur-paralkar-494415107/).