How I build a YouTube Video Player with ReactJS: Building the Project Architecture
Keyur Paralkar
Posted on November 25, 2023
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,
}
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.
Prerequisites
To follow along with this guide I would recommend to please go through the following topics:
- Getting started with Create react app to initialize the project.
-
React context API’s
-
useReducer
anduseContext
-
- Passing state down the children component blogpost by react.dev
- Framer-motion
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,
}
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:
- Create a global state
- Make the global state available to all the components
- 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,
};
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);
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>
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>;
};
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);
}
}
};
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>
);
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>
);
};
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>
);
};
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";
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;
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" />
);
};
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"
/>
);
};
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"
/>
);
};
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;
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;
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
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:
- Do not show the bezel icon when mounted
- Whenever the global state:
isPlaying
gets changed then show the respective bezel icon. - 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;
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;
A couple of changes here,
- First, we added a ref in the
StyledBezelContainer
so that it can be later access in theuseEffect
- 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 thedisplay
property. But there is a slight twist here which is we first update thedisplay
property toblock
and after that we update it tonone
after500 ms
. Once the component is unmounted clear the timeout by passing thetimerId
into theclearTimeout
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;
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!
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
November 25, 2023