DEV Community

Keyur Paralkar
Keyur Paralkar

Posted on

10

How I build a YouTube Video Player with ReactJS: Add chapters to the video

Introduction

If you are into online learning/education or an unboxing video of a gadget on youtube, etc. Then I am pretty sure you might have come across this thing here:

Youtube’s Video Frame Preview
Youtube’s progress bar gets split up into multiple mini sliders. Video source

The progress bar is broken into sections and each section describes what it represents. This is mostly done by the video's author so that viewers can quickly get a gist of what they will be learning in this section or the next few minutes.

These quick gist of each section are called chapters. In this article, we will be looking at how to implement them.

So without further ado, let us get started.

Prerequisites

The What?

So this broken time bar/seek bar/ progress bar that you see on the video is nothing but chapters. This is a very common feature that you get to see on YouTube.

We already have built the seek bar in our previous blog posts and with some tweaks to the existing Slider component we can achieve this functionality.

The Why?

We are building this component because it:

  • Tells the user what is happening in the given time frame.
  • Gives a quick reference to the content the user is looking for with the help of chapter text below the frame snapshot.
  • Provides a better User experience.

The How?

Before we get started with the implementation let us have a quick overview of what the steps are:

  • Generate VTT for chapters
  • Load the VTT into the video with the help of track element.
  • Extract chapters from the track element.
  • Update the Slider component for the chapter logic
  • Display the chapters in the tooltip

There isn’t much jargon here if you have gone through the previous article. If not, I would highly recommend going through it from the prerequisites section.

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

Generating VTT for chapters

Web video text track is a file format that has time-based data. Meaning, that it contains text that needs to be shown on the video along with the information of when this text needs to be shown(nth second).

Since we want the users to know from which second to which second the chapter starts and ends we will write a VTT file like the one below:



WEBVTT

00:00:00.000 --> 00:00:15.000
Chapter 1

00:00:15.000 --> 00:00:42.000
Chapter 2

00:00:42.000 --> 00:00:48.000
Chapter 3

00:00:48.000 --> 00:01:01.000
Chapter 4

00:01:01.000 --> 00:01:09.000
Chapter 5 


Enter fullscreen mode Exit fullscreen mode

This tells us that, from the 0th to the 15th second there would be text that corresponds to Chapter 1 and so on.

Load the VTT into the video

Next, to load the VTT into the video we make use of the [track element.](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/track) We update the Video.tsx like below:

<video onTimeUpdate={handleTimeUpdate} ref={videoRef} crossOrigin="" width={800}>
<source src={videoSourceInfo.src} type="video/mp4" />
<track ref={trackMetaDataRef} default kind="metadata" src="<path-to-image-sprite-vtt" />
<track ref={trackChaptersRef} default kind="chapters" src="path-to-chapters-vtt" />
</video>
view raw video.tsx hosted with ❤ by GitHub

Here we added a track element with kind attribute of chapters. In the src attribute make sure that you place the location/url of the chapter’s vtt file that we generated above.

Extracting chapters from the track element

Now that we have track element in place let us make use of it to extract the chapters into our code. What we want here is that we need these chapters when the video is loaded completely.

To do that, we already have a useEffect in the Video.tsx file that hydrates the store with some parameters. We make use of this same effect to capture the chapters. Copy and paste below useEffect :

useEffect(() => {
const video = videoRef.current;
if (video) {
video.addEventListener("loadeddata", () => {
const payload: OnLoadType = {
hasVideoLoaded: true,
totalDuration: video.duration,
};
if (trackChaptersRef.current) {
const { track } = trackChaptersRef.current;
const cues = track.cues;
if (cues) {
payload["chapters"] = (Object.values(cues) as VTTCue[]).map((cue: VTTCue, index) => ({
index,
chapterName: cue.text,
endTime: cue.endTime,
startTime: cue.startTime,
percentageTime: (((cue.endTime - cue.startTime) / video.duration) * 100).toFixed(2),
}));
}
}
dispatch({
type: HAS_VIDEO_LOADED,
payload,
});
});
}
return () => {
video &&
video.removeEventListener("loadeddata", () => {
console.log("Loadeddata event removed");
});
};
}, []);
view raw Video.tsx hosted with ❤ by GitHub

Here we make use of the video element’s loadeddata event to hydrate the store. The code is pretty self-explanatory but I would like to explain the part of the chapter here:

  • In line 12, we make use of trackChaptersRef which is being passed to the track element. From this, we access the track object.
  • In line 13, we get all the cues in the track.
  • On the next line, we make use of the map function to return an array of objects that consists of:
    • index
    • name of the chapter, start and end time
    • percentageTime: This represents what percentage of time this chapter contributes to out of the total video duration.

I know this hydration of chapter object with additional fields doesn’t make any sense right now but it will when we get into the changes of the slider component. We will discuss this in the next section.

Update the Slider component for the chapter logic

Folks, take a cup of coffee now since this will be a long section. I will try my best to make it interesting 🙂.

Before proceeding further, I would highly recommend going through this Slider component implement article.

Here is the outline of the steps that we will be doing to achieve the chapter functionality:

  • Split the Slider component into multiple mini-slider components
  • Handling Slider fill across the multiple mini-sliders
  • Handling mouse moves on hover, click and drag.
  • Updating the overall slider on playback
  • Styling the slider

Split the Slider component into multiple mini-slider components

Ok so hear me out:

Chapters on Slider is nothing but a bunch of mini sliders.

From this what I mean is, in this functionality, we won’t have a single slider of width 100%. But instead, we will have mini sliders of length equal to the width of percentageTime. This state is the derived state that we computed when we loaded the video component in the above section.

We got the jest of what we are doing, now let us start with the implementation.

  • The first thing that we need to do is, extend our Slider component such that it accepts chapters as a prop:
    • Let us update the SliderProps interface:
      interface SliderProps
      extends Omit<
      React.HTMLAttributes<HTMLDivElement>,
      "onClick" | "onDrag" | "onMouseUp" | "onMouseMove"
      > {
      $total: number;
      $shouldDisplayChapters?: boolean;
      $currentTime?: number;
      //=============== here ================
      $currentChapter?: Chapter;
      $chapters?: Chapter[];
      $fillColor?: string;
      //===============
      onClick?: (currentPercentage: number) => void;
      onDrag?: (completedPercentage: number) => void;
      onMouseUp?: () => void;
      onMouseMove?: (pointerPercentage: number) => void;
      }
      export interface SliderRefProps {
      updateSliderFill: (completedPercentage: number) => void;
      updateChapterFill: (
      currentChapterIdx: number,
      completedPercentage: number,
      ) => void;
      }
      view raw SliderProps.ts hosted with ❤ by GitHub
    • Update the render method of the slider component such that when chapters are enabled we map over them and create multiple sliders:
      return (
      <StyledContainer
      className="slider"
      $hasChapters={hasChapters}
      $fillColor={$fillColor}
      onMouseMove={handleContainerMouseMove}
      onMouseUp={handleContainerMouseUp}
      ref={rootRef}
      $total={$total}
      >
      {hasChapters ? (
      $chapters?.map((chapter, index) => (
      <StyleChapterContainer
      className={`chapter-${index}`}
      key={`key-${chapter.percentageTime}`}
      ref={(el: HTMLDivElement) => el && (chapterRefs.current[index] = el)}
      $width={`${chapter.percentageTime}%`}
      >
      <StyledTrack className="slider-track" onClick={handleClick} />
      <StyledSliderFill className="slider-fill" $hasChapters />
      </StyleChapterContainer>
      ))
      ) : (
      <>
      <StyledTrack className="slider-track" onClick={handleClick} />
      <StyledSliderFill className="slider-fill" $hasChapters={false} />
      </>
      )}
      <StyledThumb
      className="slider-thumb"
      onMouseDown={handleThumbMouseDown}
      ></StyledThumb>
      </StyledContainer>
      );
      view raw Slider.tsx hosted with ❤ by GitHub
    • We also maintain references to all these mini sliders with the help of chapterRefs. It is an array ref that holds the reference to these elements. We do this in this line: ```

ref={(el: HTMLDivElement) => el && (chapterRefs.current[index] = el)}

    - Quick thing to note here, We have created a StyleChapterContainer. It may look like this:
      
type StyledChapterContainerProps = {
$width: string;
};
const StyleChapterContainer = styled.div<StyledChapterContainerProps>`
width: ${(props) => props.$width};
height: 5px;
display: inline-block;
margin-right: 2px;
position: relative;
transition: height 200ms ease;
`;
- It accepts a prop as `$width` that defines the width of this mini - slider component.
Enter fullscreen mode Exit fullscreen mode
  • Now pass the chapters to the slider component via the seekbar component as follows:

    <Slider
    $chapters={chapters}
    $currentTime={currentTime}
    $total={780}
    $fillColor="#ff0000"
    onClick={onPositionChangeByClick}
    onDrag={onPositionChangeByDrag}
    onMouseUp={handleMouseUp}
    onMouseMove={handleMouseMove}
    ref={sliderRef}
    />;
    view raw Seekbar.tsx hosted with ❤ by GitHub
  • Once this is hooked the output will look like below

Chapters as mini sliders

Handling filling logic on mouse move and click events

The above logic needs to be handled for the following scenarios:

  • While clicking and,
  • While dragging the slider

Here is the pictorial representation of these scenarios:

  • While Clicking:

<p>
<figure>
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/erymo9rbzpjls48q4cw5.gif" alt="Click interaction on sliders with chapters" />
<figcaption>Click interaction on sliders with chapters</figcaption>
</figure>
</p>

  • While dragging:

<p>
<figure>
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/g8ryc9ia89pw6gc3bvo6.gif" alt="Drag interaction on sliders with chapters" />
<figcaption>Drag interaction on sliders with chapters</figcaption>
</figure>
</p>

<aside>
💡 NOTE: I would like to remind you guys that to go through this blogpost: How I build a YouTube Video Player with ReactJS: Build the Seekbar control before proceeding further. Because it contains a lot of jargon which is better explained there.
</aside>

So here we go, below is the handleClick event handler that takes care of fill the chapters on click:

const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (rootRef.current) {
/**
* Algorithm:
* 1. We first get the value from slider fill.
* 2. Then we loop over the chapers and take the sum with previous chapters fill width and see if its less than slider fill.
* 3. If yes, then we completely fill the chapter with 100% width
* 4. If no, then we calculate the chapter-fill for that particular chapter.
*/
updateSliderFillByEvent("--slider-fill", e);
const width = getCSSVariableAbsoluteValue("--slider-fill", rootRef);
if ($chapters) {
const allChapterWidths = $chapters.map((chapter) => Number(chapter.percentageTime));
let acc = 0;
const currentChapterIdx = allChapterWidths.findIndex((val) => {
acc += val;
if (acc > width) {
return true;
}
});
const nextIdx = currentChapterIdx === $chapters.length - 1 ? $chapters.length - 1 : currentChapterIdx + 1;
const prevChapterIdx = currentChapterIdx === 0 ? 0 : currentChapterIdx - 1;
const prevChapterElem = chapterRefs.current[prevChapterIdx];
const currentChapterElem = chapterRefs.current[currentChapterIdx];
const nextChapterElem = chapterRefs.current[nextIdx];
// Fill the previous elements:
for (let i = 0; i < currentChapterIdx; i++) {
chapterRefs.current[i].style.setProperty("--chapter-fill", "100%");
}
const previousChapterFill = getCSSVariableAbsoluteValue("--chapter-fill", prevChapterElem);
const currentChapterFill = getCSSVariableAbsoluteValue("--chapter-fill", currentChapterElem);
const nextChapterFill = getCSSVariableAbsoluteValue("--chapter-fill", nextChapterElem);
// Fill the current chapter;
if (previousChapterFill === 100 && currentChapterFill >= 0) {
const currentChapterWidth = allChapterWidths[currentChapterIdx];
const rect = currentChapterElem.getBoundingClientRect();
const totalChapterWidth = (currentChapterWidth * $total) / 100;
const chapterFillWidth = computeCurrentWidthFromPointerPos(e.pageX, rect.left, totalChapterWidth);
currentChapterElem.style.setProperty("--chapter-fill", `${chapterFillWidth}%`);
}
// clean up the later chapters when going from right to left;
if (nextChapterFill > 0) {
for (let i = currentChapterIdx + 1; i < $chapters.length; i++) {
chapterRefs.current[i].style.setProperty("--chapter-fill", "0%");
}
}
}
onClick?.(width);
}
};

Below is the explanation for the same:

  • This part right here calculates all the chapter's width and with the help of the current --slider-fill value we get the current chapter index:
const allChapterWidths = $chapters.map((chapter) => Number(chapter.percentageTime));
let acc = 0;
const currentChapterIdx = allChapterWidths.findIndex((val) => {
acc += val;
if (acc > width) {
return true;
}
});
view raw Slider.tsx hosted with ❤ by GitHub
  • Next, we get the previous and the next chapter elements:
const nextIdx =
currentChapterIdx === $chapters.length - 1 ? $chapters.length - 1 : currentChapterIdx + 1;
const prevChapterIdx = currentChapterIdx === 0 ? 0 : currentChapterIdx - 1;
const prevChapterElem = chapterRefs.current[prevChapterIdx];
const currentChapterElem = chapterRefs.current[currentChapterIdx];
const nextChapterElem = chapterRefs.current[nextIdx];
view raw Slider.tsx hosted with ❤ by GitHub
  • Next, we fill all the previous chapters before the current chapter:
// Fill the previous elements:
for (let i = 0; i < currentChapterIdx; i++) {
chapterRefs.current[i].style.setProperty("--chapter-fill", "100%");
}
view raw Slider.tsx hosted with ❤ by GitHub
  • A thing to note here, we track the fill of each slider element with the help of the CSS variable: --chapter-fill.

  • We also need to update our StyledSliderFill component such that whenever chapters exist we make use of the —chapter-fill CSS variable.

const StyledSliderFill = styled.div<StyledSliderFillProps>`
height: 5px;
background-color: var(--slider-fill-color);
width: var(${(props) => (props.$hasChapters ? "--chapter-fill" : "--slider-fill")}, 0%);
position: absolute;
pointer-events: none;
`;
view raw Slider.tsx hosted with ❤ by GitHub
  • Next, we summon all the chapter fill values below:
const previousChapterFill = getCSSVariableAbsoluteValue("--chapter-fill", prevChapterElem);
const currentChapterFill = getCSSVariableAbsoluteValue("--chapter-fill", currentChapterElem);
const nextChapterFill = getCSSVariableAbsoluteValue("--chapter-fill", nextChapterElem);
view raw Slider.tsx hosted with ❤ by GitHub

Then, to fill the current chapter we make use of the computeCurrentWidthFromPointerPos utility function and then finally set its value to the current chapter’s --chapter-fill.

// Fill the current chapter;
if (previousChapterFill === 100 && currentChapterFill >= 0) {
const currentChapterWidth = allChapterWidths[currentChapterIdx];
const rect = currentChapterElem.getBoundingClientRect();
const totalChapterWidth = (currentChapterWidth * $total) / 100;
const chapterFillWidth = computeCurrentWidthFromPointerPos(e.pageX, rect.left, totalChapterWidth);
currentChapterElem.style.setProperty("--chapter-fill", `${chapterFillWidth}%`);
}
view raw Slider.tsx hosted with ❤ by GitHub
  • For scenarios of clicking from right to left direction i.e. going back in video, we fill the next chapter to 0 from the current chapter:
// clean up the later chapters when going from right to left;
if (nextChapterFill > 0) {
for (let i = currentChapterIdx + 1; i < $chapters.length; i++) {
chapterRefs.current[i].style.setProperty("--chapter-fill", "0%");
}
}
view raw Slider.tsx hosted with ❤ by GitHub

Similarly, we make use of the handleMouseMove function that handles the filling of chapters during the mousemove event i.e. when we are dragging the slider knob:

const handleContainerMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
/**
* NOTE: 'data-dragging' attribute is available only when we are dragging the slider-thumb.
* Thumb is said to be dragged when onMouseDown gets triggered on the .slider-thumb -> [data-dragging] is added to .slider
* -> then handleMouseMove off .slider gets executed.
* The dragging of thumb gets stopped when the data-dragging attribute is removed from .slider
* We remove this attribute on MouseUp of .slider because on mouseup the target element can be different during dragging if the mouseup where on .slider-thumb
* To have better dragging experience the data-dragging is removed on the mouseup of .slider
*/
if (rootRef.current?.getAttribute("data-dragging")) {
updateSliderFillByEvent("--slider-fill", e);
const sliderFillWidth = getCSSVariableAbsoluteValue("--slider-fill", rootRef);
onDrag?.(sliderFillWidth);
// When chapters exists update the chapter fills for each div.
if ($currentChapter) {
const { index, percentageTime } = $currentChapter;
const currentChapterElem = chapterRefs.current[index];
const rect = currentChapterElem.getBoundingClientRect();
const totalChapterWidth = (Number(percentageTime) * $total) / 100;
const chapterFillWidth = computeCurrentWidthFromPointerPos(e.pageX, rect.left, totalChapterWidth);
/**
* Below if block removes the data-chapter-dragging attribute whenever the dragging happens from left to right or vice-versa;
*/
if ($currentTime && $chapters) {
// movement from left to right;
if (index > 0 && $currentTime >= $chapters[index].startTime) {
chapterRefs.current[index - 1].removeAttribute("data-chapter-dragging");
/**
* Here we update the chapter fill of the previous element since the previous element on
* complete wasn't getting completely filled i.e. around 98% or 97%.
* So to approximate this error we manually set the fill to 100%.
* Similar is the case when we are moving from right to left in the below if block
*/
chapterRefs.current[index - 1].style.setProperty("--chapter-fill", "100%");
}
if (index < $chapters.length - 1 && $currentTime <= $chapters[index].endTime) {
chapterRefs.current[index + 1].removeAttribute("data-chapter-dragging");
chapterRefs.current[index + 1].style.setProperty("--chapter-fill", "0%");
}
}
// Don't update the chapter-fill when it is beyond the limits
if (chapterFillWidth < 0 || chapterFillWidth > 100) {
return;
}
currentChapterElem.style.setProperty("--chapter-fill", `${chapterFillWidth}%`);
currentChapterElem.setAttribute("data-chapter-dragging", "true");
}
}
updateSliderFillByEvent("--slider-pointer", e);
const pointerPos = getCSSVariableAbsoluteValue("--slider-pointer", rootRef);
onMouseMove?.(pointerPos);
};
view raw Slider.tsx hosted with ❤ by GitHub

This is the same function we used to update the --slider-pointer CSS variable in the previous blog posts. The purpose of this function from the chapters point of view is as follows:

  • Update the chapter fill of the respective elements
  • Add a data-chapter-dragging attribute

I would like you guys to go through the comments in the above code block to understand the functionality.

Just a quick note: The purpose of adding data-chapter-dragging attribute is to use it in styling which we will look into in the later section.

Updating the slider position on playback

There is one more scenario left that we need to cover which is handling the playback mechanism across multiple sliders. Now to do this we need a mechanism that will help us to update the --chapter-fill CSS variable of each chapter whenever the current duration of the video changes.

This is a simple task. We just need to expose a function that updates this chapter fill for the given chapter with the help of [useImperativeHandle hook](https://react.dev/reference/react/useImperativeHandle).

The first thing is to update the useImperativeHandle hook in the Slider component:

useImperativeHandle(
ref,
() => {
return {
updateSliderFill(percentageCompleted: number) {
rootRef.current?.style.setProperty("--slider-fill", `${percentageCompleted}%`);
},
updateChapterFill(currentChapterIdx: number, completedPercentage: number) {
chapterRefs.current[currentChapterIdx].style.setProperty("--chapter-fill", `${completedPercentage}%`);
},
};
},
[],
);
view raw Slider.tsx hosted with ❤ by GitHub

We introduced a new function here: updateChapterFill which accepts the current chapter’s index and the percentage completion of that chapter.

Next, we consume this function via refs in the Seekbar component like below:

const currentChapter = chapters?.filter((chapter) => currentTime && currentTime > chapter.startTime && currentTime < chapter.endTime);
useEffect(() => {
if (sliderRef.current && !isSeeking) {
const newPosPercentage = (currentTime / totalDuration) * 100;
sliderRef.current.updateSliderFill(newPosPercentage);
if (currentChapter.length > 0) {
const { index, endTime, startTime } = currentChapter[0];
const totalChapterDuration = endTime - startTime;
const currentChapterFillWidth = index === 0 ? (currentTime / totalChapterDuration) * 100 : ((currentTime - chapters[index - 1].endTime) / totalChapterDuration) * 100;
sliderRef.current.updateChapterFill(index, currentChapterFillWidth);
}
}
}, [currentTime, isSeeking]);
view raw Seekbar.tsx hosted with ❤ by GitHub

Inside the Seekbar component, we access the currentTime and the chapters from the player context.

Then we make use of the useEffect that updates on currentTime and isSeeking. Along with updating the slider-fill it also updates the chapter fill with the help of updateChapterFill.

We do some simple calculations and get the currentChapterFillWidth by dividing the currentTime by the total chapter duration.

lastly, we pass this to our update function.

This is how our functionality will look like when during playback:

<p>
<figure>
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/whuawmq3nm8u8ucmy6nd.gif" alt="Chapter fill on video playback" />
<figcaption>Chapter fill on video playback</figcaption>
</figure>
</p>

Styling the chapter sliders

One last thing that is pending is adding a small styling effect that will give better UX feedback. The style that I am talking about is increasing the width of the slider whenever we are dragging the slider like below:

<p>
<figure>
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/iyb08j2kbzjpj4tt56qy.gif" alt="Emphasising chapters on drag" />
<figcaption>Emphasising chapters on drag</figcaption>
</figure>
</p>

To do this we make use of the data-chapter-dragging attribute that we add in the handleMouseMove function. We use this attribute in StyledContainer of the Slider component:

const StyledContainer = styled.div<StyledContainerProps>`
--slider-pointer: 0%; // when hover happens pointer is updated
--slider-fill: 0%; // when click and drag happens fill is updated
--slider-track-bg-color: ${COLORS.TRACK_BG_COLOR};
--slider-fill-color: ${(props) => props.$fillColor};
position: relative;
height: 30px;
width: ${(props) => props.$total};
display: flex;
flex-direction: row;
${(props) => props.$hasChapters && "justify-content: center;"}
align-items: center;
cursor: pointer;
// For animating ring behind the thumb;
&[data-dragging] {
& .slider-thumb::before {
opacity: 1;
}
// Increase the height of the chapter when dragging is enabled;
& [data-chapter-dragging] {
height: 8px;
}
}
// Make thumb visible when hovered on this container;
&:hover {
& .slider-thumb {
opacity: 1;
}
}
`;
view raw Slider.tsx hosted with ❤ by GitHub

We make sure that whenever we are dragging i.e. whenever the container has the data-dragging attribute only then check for any child div that has data-chapter-dragging attribute. If it has then we update the height of the slider/container by 8px.

Summary

So to summarize we achieved the following things in this blogpost:

  • We understand the reason why are we doing this feature.
  • We saw how to get the chapter's data into the video via the track element.
  • And lastly, we saw the changes that we need to make to the slider component to achieve the desired chapter UX.

The entire code for this tutorial can be found here.

Thank you for reading!

Follow me on twittergithub, and linkedIn.

Enter fullscreen mode Exit fullscreen mode

AWS GenAI LIVE image

Real challenges. Real solutions. Real talk.

From technical discussions to philosophical debates, AWS and AWS Partners examine the impact and evolution of gen AI.

Learn more

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs