DEV Community

Cover image for How I build a YouTube Video Player with ReactJS: Build the Seekbar control
Keyur Paralkar
Keyur Paralkar

Posted on • Edited on

5

How I build a YouTube Video Player with ReactJS: Build the Seekbar control

Introduction

In the previous blog, we discussed the volume slider and how it utilizes the slider component. To dive deeper we also built the slider component in that blog. I recommend you all give it a read.

In this blog, we will be building the Seekbar of the video using the Slider component.

So, without further ado, let's get started!

💡 NOTE: The implementation of chapter fill css-variable was inspired from the https://www.vidstack.io/

Prerequisites

I would highly recommend going through the below topics before diving into this blog post:

What is a seekbar component?

seekbar

A seek bar is a component that represents the video’s overall progress. It represents how much time that has elpased of the video. It can also be used to go forward or backward in the video by clicking or dragging the slider. It can also be used to preview the video’s current frame.

All these feature of the seek bar we will be implementing in this and in the subsequent blog posts.

Building the basic structure of the Seekbar Control component

In this section, we will be building the Seekbar component’s basic structure. So first let us start by creating the component file at this location: src/components/Seekbar.tsx. Next, copy-paste the below code:

const Seekbar = () => {
return <div></div>
}
export default Seekbar;
view raw Seekbar.tsx hosted with ❤ by GitHub

Here we build the very basic component structure. We will take a look at the details in the next section. Now, import this component inside the src/components/ControlToolbar.tsx:

import { motion } from 'framer-motion';
import { useContext } from 'react';
import styled from 'styled-components';
import { PlayerContext } from '../context';
import PlayButton from './PlayButton';
import VolumeControl from './VolumeControl';
import Seekbar from './Seekbar';
const StyledContainer = styled(motion.div)<{ isPlaying?: boolean }>`
background: #ffffff00;
background: linear-gradient(180deg, #ffffff00, #010101);
${(props) => (!props.isPlaying ? 'opacity: 1 !important' : '')};
position: absolute;
color: #eee;
bottom: 0.2rem;
`;
const StyledVideoControl = styled.div`
margin-left: 10px;
margin-right: 10px;
`;
const StyledInteractionGroup1 = styled.div`
display: flex;
height: 40px;
`;
const ControlToolbar = () => {
const { isPlaying } = useContext(PlayerContext);
return (
<StyledContainer className="video-controls-container" isPlaying={isPlaying}>
<StyledVideoControl>
<Seekbar /> // <----------- Here
<StyledInteractionGroup1 className="interaction-group-1">
<PlayButton />
<VolumeControl />
</StyledInteractionGroup1>
</StyledVideoControl>
</StyledContainer>
);
};
export default ControlToolbar;

So the basic functionality of defining and calling the component is done. Now, let us dive deeper into the implementation of this component.

Implementing the Seekbar control component

Implementation is straightforward, we build this component below step-by-step:

  • First, import the slider component and add these basic props:

    const Seekbar = () => {
    return (
    <div style={{ width: 780 }}>
    <Slider total={780} $fillColor="#ff0000" />
    </div>
    );
    };
    export default Seekbar;
    view raw Seekbar.tsx hosted with ❤ by GitHub

    This will create a basic slider with a width of 780px and with the $fillColor of red. Here is what it will look like:

    Seekbar with fill

  • Since this component aims to seek through the video. So for that, we need to create new properties in the global state: currentTime, totalDuration, isSeeking and hasVideoLoaded. I won’t go into much detail on how to add these into the global state. For that you can follow this blog post. Will be giving a small summary of changes quickly:

    • Update the interface of the Global state along with the initial state:
      export type Duration = number;
      export type StateProps = {
      //...
      totalDuration: Duration;
      currentTime: Duration;
      isSeeking: boolean;
      hasVideoLoaded: boolean;
      };
      export const initialState: StateProps = {
      //...
      totalDuration: 0,
      currentTime: 0,
      isSeeking: false,
      hasVideoLoaded: false,
      };
      view raw index.tsx hosted with ❤ by GitHub
    • Update actions:
      //...
      export const UPDATE_VIDEO_CURRENT_TIME = 'UPDATE_VIDEO_CURRENT_TIME';
      export const UPDATE_SEEKING = 'UPDATE_SEEKING';
      export const HAS_VIDEO_LOADED = 'HAS_VIDEO_LOADED';
      view raw actions.ts hosted with ❤ by GitHub
    • Update reducer:
      export const playerReducer = (state: StateProps, action: ActionProps) => {
      switch (action.type) {
      //...
      case UPDATE_VIDEO_CURRENT_TIME: {
      return {
      ...state,
      currentTime: action.payload.currentTime,
      };
      }
      case HAS_VIDEO_LOADED: {
      return {
      ...state,
      hasVideoLoaded: action.payload.hasVideoLoaded,
      totalDuration: action.payload.totalDuration,
      };
      }
      case UPDATE_SEEKING: {
      return {
      ...state,
      isSeeking: action.payload,
      };
      }
      //...
      }
      };
      view raw reducers.ts hosted with ❤ by GitHub
    • In the src/components/Video.tsx file we add a useEffect such that whenever the video is loaded we initialize the totalDuration and hasVideoLoaded like below by dispatching the HAS_VIDEO_LOADED action:
      useEffect(() => {
      const video = videoRef.current;
      if (video) {
      video.addEventListener("loadeddata", () => {
      dispatch({
      type: HAS_VIDEO_LOADED,
      payload: {
      hasVideoLoaded: true,
      totalDuration: video.duration,
      },
      });
      });
      }
      return () => {
      video &&
      video.removeEventListener("loadeddata", () => {
      console.log("Loadeddata event removed");
      });
      };
      }, []);
      view raw video.tsx hosted with ❤ by GitHub
      • Next, we also need to add the following useEffect to update the duration of the video when the currentTime changes while dragging of the slider:
        useEffect(() => {
        const video = videoRef.current;
        if (video) {
        video.addEventListener("loadeddata", () => {
        dispatch({
        type: HAS_VIDEO_LOADED,
        payload: {
        hasVideoLoaded: true,
        totalDuration: video.duration,
        },
        });
        });
        }
        return () => {
        video &&
        video.removeEventListener("loadeddata", () => {
        console.log("Loadeddata event removed");
        });
        };
        }, []);
        // Updates video's actual current time on state update
        useEffect(() => {
        if (videoRef.current && (isSeeking || !isPlaying)) {
        videoRef.current.currentTime = currentTime;
        }
        }, [currentTime, isSeeking]);
        view raw video.tsx hosted with ❤ by GitHub
      • Lastly, to make the above effect run we update the video duration on each second by passing on the handleTimeUpdate to the onTimeUpdate event of the video element:
        //...
        // Updates the currentTime state
        const handleTimeUpdate = () => {
        if (videoRef.current && isPlaying && !isSeeking) {
        dispatch({
        type: UPDATE_VIDEO_CURRENT_TIME,
        payload: {
        currentTime: videoRef.current.currentTime,
        },
        });
        }
        };
        useEffect(() => {
        const video = videoRef.current;
        if (video) {
        video.addEventListener("loadeddata", () => {
        dispatch({
        type: HAS_VIDEO_LOADED,
        payload: {
        hasVideoLoaded: true,
        totalDuration: video.duration,
        },
        });
        });
        }
        return () => {
        video &&
        video.removeEventListener("loadeddata", () => {
        console.log("Loadeddata event removed");
        });
        };
        }, []);
        // Updates video's actual current time on state update
        useEffect(() => {
        if (videoRef.current && (isSeeking || !isPlaying)) {
        videoRef.current.currentTime = currentTime;
        }
        }, [currentTime, isSeeking]);
        return <video onTimeUpdate={handleTimeUpdate} ref={videoRef} crossOrigin=""></video>;
        view raw video.tsx hosted with ❤ by GitHub
  • Once we have our global store setup we now start implementing the Seekbar component. We first implement the mechanism of updating the slider on the playback of the video such that the slider gets updated every second. To do that we add the following useEffect:

    const Seekbar = () => {
    const { currentTime, totalDuration, isSeeking } = useContext(PlayerContext);
    const sliderRef = useRef<SliderRefProps>(null);
    // Update CSS variables that drives the slider component
    useEffect(() => {
    if (sliderRef.current && !isSeeking) {
    const newPosPercentage = (currentTime / totalDuration) * 100;
    sliderRef.current.updateSliderFill(newPosPercentage);
    }
    }, [currentTime, isSeeking]);
    return (
    <div style={{ width: 780 }}>
    <Slider total={780} $fillColor="#ff0000" ref={sliderRef} />
    </div>
    );
    };
    export default Seekbar;
    view raw Seekbar.tsx hosted with ❤ by GitHub

    To update the slider on every second we need to update the --slider-fill variable on each second. To achieve that, inside the useEffect we calculate the fill percentage in newPosPercentage with the help of currentTime and totalDuration. Once we get it, we update the --slider-fill with the updateSliderFill function.

    If you are not getting the above concept I highly recommend you to go through the previous blog post for slider’s implementation.

    Here if you see we also make use of the isSeeking state. This is because, we don’t want the above mechanism to get triggered while we are dragging the slider.

  • Next, to add the logic of seeking the video on click of the slider we add an event handler called: onPositionChangeByClick and pass it on to the Slider component:

    const Seekbar = () => {
    const { currentTime, totalDuration, isSeeking } = useContext(PlayerContext);
    const sliderRef = useRef<SliderRefProps>(null);
    const handleMouseUp = () => {
    dispatch({
    type: UPDATE_SEEKING,
    payload: false,
    });
    };
    const onPositionChangeByClick = (currentPercentage: number) => {
    let newCurrentTime = (currentPercentage * totalDuration) / 100;
    newCurrentTime = numberToFixed(newCurrentTime, 2);
    dispatch({
    type: UPDATE_VIDEO_CURRENT_TIME,
    payload: { currentTime: newCurrentTime },
    });
    dispatch({
    type: UPDATE_SEEKING,
    payload: true,
    });
    };
    // Update CSS variables that drives the slider component
    useEffect(() => {
    if (sliderRef.current && !isSeeking) {
    const newPosPercentage = (currentTime / totalDuration) * 100;
    sliderRef.current.updateSliderFill(newPosPercentage);
    }
    }, [currentTime, isSeeking]);
    return (
    <div style={{ width: 780 }}>
    <Slider
    onClick={onPositionChangeByClick}
    onMouseUp={handleMouseUp}
    total={780}
    $fillColor="#ff0000"
    ref={sliderRef}
    />
    </div>
    );
    };
    export default Seekbar;
    view raw Seekbar.tsx hosted with ❤ by GitHub
    The above event handler will receive the currentPercentage of the slider i.e. the current value. From that we calculate the new current duration of the video. Then, we dispatch the action: UPDATE_VIDEO_CURRENT_TIME to update the currentTime.

    We also make use of the UPDATE_SEEKING action that updates the isSeeking state. We first update it to true in the event handler and then to false whenever the mouse up handler is fired. We do this so that we can distinguish when the slider is getting dragging for seeking or not. This helps us distinguish in lot many places.

  • Next, to handle the scenario where we want to seek the video on drag we add the onPositionChangeByDrag event handler for the onDrag event of the Slider component:

    const Seekbar = () => {
    const { currentTime, totalDuration, isSeeking } = useContext(PlayerContext);
    const sliderRef = useRef<SliderRefProps>(null);
    const handleMouseUp = () => {
    dispatch({
    type: UPDATE_SEEKING,
    payload: false,
    });
    };
    const onPositionChangeByClick = (currentPercentage: number) => {
    let newCurrentTime = (currentPercentage * totalDuration) / 100;
    newCurrentTime = numberToFixed(newCurrentTime, 2);
    dispatch({
    type: UPDATE_VIDEO_CURRENT_TIME,
    payload: { currentTime: newCurrentTime },
    });
    dispatch({
    type: UPDATE_SEEKING,
    payload: true,
    });
    };
    const onPositionChangeByDrag = (completedPercentage: number) => {
    let currentTime = (completedPercentage * totalDuration) / 100;
    currentTime = numberToFixed(currentTime, 4);
    dispatch({
    type: UPDATE_VIDEO_CURRENT_TIME,
    payload: { currentTime },
    });
    dispatch({
    type: UPDATE_SEEKING,
    payload: true,
    });
    };
    // Update CSS variables that drives the slider component
    useEffect(() => {
    if (sliderRef.current && !isSeeking) {
    const newPosPercentage = (currentTime / totalDuration) * 100;
    sliderRef.current.updateSliderFill(newPosPercentage);
    }
    }, [currentTime, isSeeking]);
    return (
    <div style={{ width: 780 }}>
    <Slider
    onClick={onPositionChangeByClick}
    onDrag={onPositionChangeByDrag}
    onMouseUp={handleMouseUp}
    total={780}
    $fillColor="#ff0000"
    ref={sliderRef}
    />
    </div>
    );
    };
    export default Seekbar;
    view raw Seekbar.tsx hosted with ❤ by GitHub

Our final component updates will look like below:

import { useContext, useEffect, useRef } from "react";
import { PlayerContext, PlayerDispatchContext } from "../../context";
import {
UPDATE_SEEKING,
UPDATE_VIDEO_CURRENT_TIME,
} from "../../context/actions";
import { numberToFixed } from "../../utils";
import Slider, { SliderRefProps } from "../common/Slider";
import Tooltip from "../common/Tooltip";
import FrameTooltip from "./FrameTooltip";
const tooltipStyles: React.CSSProperties = {
backgroundColor: "transparent",
};
const Seekbar = () => {
const { currentTime, totalDuration, isSeeking } = useContext(PlayerContext);
const dispatch = useContext(PlayerDispatchContext);
const sliderRef = useRef<SliderRefProps>(null);
const onPositionChangeByDrag = (completedPercentage: number) => {
let currentTime = (completedPercentage * totalDuration) / 100;
currentTime = numberToFixed(currentTime, 4);
dispatch({
type: UPDATE_VIDEO_CURRENT_TIME,
payload: { currentTime },
});
dispatch({
type: UPDATE_SEEKING,
payload: true,
});
};
const handleMouseUp = () => {
dispatch({
type: UPDATE_SEEKING,
payload: false,
});
};
const onPositionChangeByClick = (currentPercentage: number) => {
let newCurrentTime = (currentPercentage * totalDuration) / 100;
newCurrentTime = numberToFixed(newCurrentTime, 2);
dispatch({
type: UPDATE_VIDEO_CURRENT_TIME,
payload: { currentTime: newCurrentTime },
});
dispatch({
type: UPDATE_SEEKING,
payload: true,
});
};
// Update CSS variables that drives the slider component
useEffect(() => {
if (sliderRef.current && !isSeeking) {
const newPosPercentage = (currentTime / totalDuration) * 100;
sliderRef.current.updateSliderFill(newPosPercentage);
}
}, [currentTime, isSeeking]);
return (
<div style={{ width: 780 }}>
<Slider
total={780}
$fillColor="#ff0000"
onClick={onPositionChangeByClick}
onDrag={onPositionChangeByDrag}
onMouseUp={handleMouseUp}
ref={sliderRef}
/>
</div>
);
};
export default Seekbar;
view raw Seekbar.tsx hosted with ❤ by GitHub

Here is how are complete change will look like:

final-gif

Summary

To Summarize we implemented the following things in this post:

  • We saw the global state changes that were required.
  • We implemented the seekbar that wired up with video.
  • We made the seekbar intectative by adding event handlers for drag and click events.
  • We also implemented the logic to update the seekbar on video playback.

In the next blog post of this series, we are going to talk about building an exciting component called FrameTooltip component that shows the preview of each video frame on hover of seekbar, so stay tuned guys !!!

The entire code for this tutorial can be found here.

Thank you for reading!

Follow me on twittergithub, and linkedIn.

Top comments (0)