DEV Community

Cover image for Building a video player with a streamlined and innovative approach
Marsel Akhmetshin
Marsel Akhmetshin

Posted on • Updated on

Building a video player with a streamlined and innovative approach

Let’s develop a video player.

The video player will be implemented with a non-standard approach where control of the video element is done through a controller class, and its state is derived from the state of the DOM element. Instead of duplicating the state of the video element in the state of the React component, we use the existing state of the video element, making the code more efficient, stable, and reducing the amount of code.

The approximate structure of the project that I will adhere to:

├── src
│   ├── components
│   │   ├── player
|   |   |   ├── Player.styles.ts
|   |   |   └── Player.tsx
|   |   ├── progress 
|   |   |   ├── Progress.styles.ts
|   |   |   └── Player.tsx
|   |   ├── timer
|   |   |   ├── Timer.styles.ts
|   |   |   ├── Times.tsx
|   |   |   └── utils.ts
|   |   ├── volume
|   |   |   ├── Volume.styles.ts
|   |   |   └── Volume.tsx
|   |   ├── controllers
|   |   |   ├── gesture-controller.ts
|   |   |   ├── rectanglre-controller.ts
|   |   |   └── video-controller.ts
|   |   ├── styles
|   |   |   └── common.styles.ts

Enter fullscreen mode Exit fullscreen mode

TECHNOLOGIES

ReactJS - A library for web and native user interfaces.
TypeScript - A strictly typed programming language based on JavaScript.
Styled Components - A CSS-in-JS style library.
Screenfull - A wrapper for cross-browser use of the JavaScript Fullscreen API.
Create React App - A project template configured for ReactJS.
You can see an example of the video player here: https://bymarsel.github.io/videoplayer-example/

1. PLAY/PAUSE

Let's start implementing our player with play/pause. For this, we'll start writing our controller. We describe the contract: play, pause methods, and add an element property where we will store a reference to the video DOM element.

export class VideoController {
  private readonly element: HTMLVideoElement;

  constructor(el: HTMLVideoElement) {
    this.element = el;
  }

  play() {
  }

  pause() {
  }
}

Enter fullscreen mode Exit fullscreen mode

Let's start describing the play method:

export class VideoController {
  private readonly element: HTMLVideoElement;
  //...
  play() {
    this.element.play();
  }
  //...
}

Enter fullscreen mode Exit fullscreen mode

Seems simple enough? Actually, not quite. The play() method of the video element is asynchronous, which can lead to isome nteresting effects:

  1. Firstly, video.play() inside an asynchronous function can lead to the loss of the "user gesture token," necessary for playing the video. Without this token, the browser will think that the JS code is trying to do something bad, like showing ads without the user's consent, and just block the video playback.

  2. If you called play(), and then the user pressed pause, you will get a playback error in the browser console. Subsequent calls to play() will lead to a similar result. Why? Because play() is an asynchronous call, and the subsequent call to pause() interrupts the video loading, making subsequent playback impossible. The best way to prevent such a scenario is to block the pause call until the video starts playing. For example, you can wait until the play() call resolves.

Let's slightly change our play() and pause() methods:

export class VideoController {
  private readonly element: HTMLVideoElement;
  private isPausingBlocked: boolean;

  constructor(el: HTMLVideoElement) {
    this.element = el;
  }

  play() {
    this.isPausingBlocked = true;

    this.element
      .play()
      .then(() => {
        this.isPausingBlocked = false;
      })
      .catch((e) => {
        console.log(e);
      });
  }

  pause() {
    if (!this.isPausingBlocked) {
      this.element.pause();
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Here’s what's happening here:

We set this.isPausingBlocked = true so that the pause call is ignored until the this.element.play() method finishes.

Now we have a basic controller, let's start writing our player. So far, without adding any styles, we will add them later:

import {
  FC,
  useState,
} from "react";

interface Props {
  src: string;
}

export const Player: FC<Props> = ({ src }) => {
  const [element, setElement] = useState<HTMLVideoElement | null>(null);

  return (
    <video src={src} ref={setElement} />
  )
}

Enter fullscreen mode Exit fullscreen mode

It may seem strange or erroneous that we record a reference to the element in the React state. We use this approach so that we can subscribe to state changes. As soon as the reference to the DOM element is available, setElement will be called, which will lead to the component state update.

Now let's create our controller:

import {
  FC,
  useState,
    **useEffect**
} from "react";
**import { VideoController } from "../../controllers/video-controller";**

 // Player.tsx
export const Player: FC<Props> = ({ src }) => {
//...
const [controller, setController] = useState<VideoController | null>(null);
//...
useEffect(() => {
    if (element) {
      const newVideoController = new VideoController(element);
      setController(newVideoController);
    }
 }, [element, src]);
 //...
}

Enter fullscreen mode Exit fullscreen mode

I think everything is clear here: as soon as the element appears in the state (see the code above), useEffect responds to it, and inside useEffect, we create a controller and also save it in the component state.

Okay, now let's give the user the ability to start and pause the video.

import {
  FC,
  useState,
    useEffect**,
    useCallback**
} from "react";

//...
export const Player: FC<Props> = ({ src }) => {
  // ...
  const [controller, setController] = useState<VideoController | null>(null);
  // ...
  const handlePlay = useCallback(() => {
    controller?.play();
  }, [controller]);

  const handlePause = useCallback(() => {
    controller?.pause();
  }, [controller]);

  return (
    <div>
      <video src={src} ref={setElement} />
      <button onClick={handlePlay}>play</button>
      <button onClick={handlePause}>pause</button>
    </div>
  )
}

Enter fullscreen mode Exit fullscreen mode

Here we added pause and play buttons, which call the corresponding methods of the video controller.

💡 In our implementation, we use separate buttons for different playback states. You can use one button and different icons.

Now our player supports two actions: play and pause. But the interface does not react to this, except for playing the video. What if we want to change the play icon to pause on the play event, or some other visual action? The first thing that comes to mind is to add a new property, isPlaying , to the component state and change it on click in handlePlay and handlePause:

// do not copy, this is just an example
  const [isPlaying, setIsPlaying] = useState<boolean>(false);

  const handlePlay = useCallback(() => {
    controller?.play();
  }, [controller]);

  const handlePause = useCallback(() => {
    controller?.pause();
  }, [controller]);

Enter fullscreen mode Exit fullscreen mode

This would work, if not for one thing: The video may be interrupted due to a connection break or slow internet because the video may play faster than it downloads, and our isPlaying won't know about it.

Let's solve this problem. Let's return to our controller and add the getPlayingState method, which will return the current playback state.

export class VideoController {
  // ...
  getPlayingState() {
    if (!this.element.paused) {
      return "playing";
    }

    return "paused";
  }
  // ...
}

Enter fullscreen mode Exit fullscreen mode

With that sorted out, we need to somehow notify the React component that the playback state has changed. For this, we will make some changes to our VideoController:

export class VideoController {
  //...
  private playingStateListeners: (() => void)[] = [];
  private boundEmitPlayingStateChangeListener: () => void;

  //...
  constructor(el: HTMLVideoElement) {
      //...
      this.element = el;
      //...
      this.boundEmitPlayingStateChangeListener = this.emitPlayingStateChangeListeners.bind(this);

      el.addEventListener(
        "play",
        this.boundEmitPlayingStateChangeListener
      );
      el.addEventListener(
        "pause",
        this.boundEmitPlayingStateChangeListener
      );
  }

  private emitPlayingStateChangeListeners() {
    try {
      this.playingStateListeners.map((s) => s());
    } catch (e) {
      console.error(e);
    }
  }

  subscribe(
    event: "playingState",
    callb: () => void
  ) {
    if (event === "playingState") {
      this.playingStateListeners.push(callb);
    }
  }
  //...
}

Enter fullscreen mode Exit fullscreen mode

What's happening here? In very simple terms, we have made a mechanism for subscribing to video element events. In more detail:

  • playingStateListeners - an array of callbacks where we will store all subscribers to the playback state change.
  • boundEmitPlayingStateChangeListener - a link to the event listener, bound to the context of the VideoController instance through bind(). We store a separate link so that we can unsubscribe this listener from the event.
  • emitPlayingStateChangeListeners - a method that notifies subscriber components about the playback state change.
  • subscribe - a method for subscribing to events, in this case, to playingState.

With that sorted out, let's teach our React component to react to these events.

export const Player: FC<Props> = ({ src }) => {
  //...
  const [playingState, setPlayingState] = useState<
    "playing" | "paused"
  >("paused");

  useEffect(() => {
    if (controller) {
      controller.subscribe("playingState", () => {
        setPlayingState(controller.getPlayingState());
      });
    }
  }, [controller]);
  // ...

  return (
    <div>
      <video src={src} ref={setElement} />
      {playingState === "paused" && <button onClick={handlePlay}>play</button>}
      {playingState === "play" && <button onClick={handlePause}>pause</button>}
    </div>
  )

Enter fullscreen mode Exit fullscreen mode

In useEffect, we subscribed to changes in playingState. For every change in playingState, we will update the component's state. We also made a small change: now, when a video is playing, a button with the text "pause" will be displayed, and conversely, when on "pause", a button with the text "play" will be displayed.

Wait, why go through such complexities when we could have simply implemented it as in the "isPlaying" example? To answer this question, let's return to our controller and add a couple more subscriptions:

constructor(el: HTMLVideoElement) {
    //...
    el.addEventListener(
      "playing",
      this.boundEmitPlayingStateChangeListener
    );
    el.addEventListener(
      "waiting",
      this.boundEmitPlayingStateChangeListener
    );
    //...
  }

Enter fullscreen mode Exit fullscreen mode

We subscribed to two events:

  • The playing event will be thrown after video playback resumes, after being paused due to slow internet. The video is playing faster than it's loading.

  • The waiting event will be thrown after video playback is paused due to insufficient data for video playback because the video has not yet finished loading.

Unlike the play and pause events, the playing and waiting events will not be directly triggered by user actions.

Let's summarise these subscriptions: now, when the waiting event occurs, our player will switch to the pause state, and with playing, it will switch to the play state.

We could stop here and say that we've figured out play/pause, but let's do something else, namely, subscribe to the "ended" event.

Why do we need it? To understand that the video has been fully played and to inform the user about it, or, for example, to offer the next video for viewing, as YouTube does.

Let's make some changes to the controller:

export class VideoController {
  //...
  constructor(el: HTMLVideoElement) {
      //...
      el.addEventListener(
        "ended",
        this.boundEmitPlayingStateChangeListener
      );
      //...
  }

  getPlayingState() {
    // ...
    if (this.element.ended) {
      return "ended";
    }
    // ...
  }
}

Enter fullscreen mode Exit fullscreen mode

Here we’ve done the following:

  • Subscribed to the "ended" event;
  • Added a new playingState with the simple name ended.

But that's not all, let's teach our component to react to this event:

export const Player: FC<Props> = ({ src }) => {
   const [playingState, setPlayingState] = useState<
     "playing" | "paused" | "ended"
   >("paused");

  //...
  const handlePlayAgain = useCallback(() => {
    controller?.replay();
  }, [controller]);

  return (
    <div>
       //...
      {playingState === "ended" && <button onClick={handlePlayAgain}>replay</button>}
    </div>
  )
}

Enter fullscreen mode Exit fullscreen mode

Here we expanded the type of states in playingState, adding "ended", added a button with the text "ended" and added a click handler handlePlayAgain.

A careful reader will notice that in handlePlayAgain we call the method controller.replay(), which we did not describe earlier, and this is indeed the case.

In fact, to restart the video, assuming that videoElement.ended === true (the video has played to the end), it's enough to call the play() method on the video element, or in our case, controller.play(), and the video will start again.

But what if we wanted to interrupt the video and start it over again, for example, we need a "stop" button, then how to do it? Easy! Let's implement the replay() method in our VideoController:

export class VideoController {
  //...
  private reset() {
    this.element.currentTime = 0;
  }

  replay() {
    if (this.getPlayingState() !== "ended") {
       this.reset();
    }

    this.play();
    }
  //...
}

Enter fullscreen mode Exit fullscreen mode

Here we added the reset and replay methods:

  • To reset the video playback progress, you need to set the currentTime property to 0, then it will be played from the beginning. Which is what happens in the reset() method.
  • The replay method calls reset if the video has not ended, and calls the play method.

We wrote a basic version of the player that can play and pause videos. Let’s keep moving.

2. Timer

We've sorted out play/pause; now let's learn how to display the video playback progress time - a timer.

There are two ways to find out the current playback time of a video:

  1. Subscribe to the timeupdate event and, when it's thrown, retrieve the current second of playback from the currentTime property of the video.
  2. Retrieve the current second of playback from the currentTime property while the video is not paused.

We will use the second approach because the first one generates a timeupdate event every 250ms, but this can vary depending on the system. First, it loads our event loop with events, and secondly, our timer might "flip" oddly - sometimes faster by 250ms, sometimes slower, which will not look very good.

Let's describe our component:

import { FC, useRef } from "react";
import { VideoController } from "../../controllers/video-controller";

interface Props {
  controller: VideoController | null;
}

export const Timer: FC<Props> = ({ controller }) => {
    const timerRef = useRef<HTMLSpanElement>(null);

    return <span ref={timerRef}>0:00</span>
}

Enter fullscreen mode Exit fullscreen mode

Right now, the controller does not return the current playback time to us; let's add a getter getPlayingState.

export class VideoController {
  //...
  getPlayingProgress() {
    return this.element.currentTime;
  }
  //...
}

Enter fullscreen mode Exit fullscreen mode

Yes, it's a simple getter that returns currentTime.

The basic structure is ready; let's get it to work. The first thing we'll do is add a function to our component that will update the current playback time.

import { FC, useRef, **useCallback** } from "react";
import { formatCurrentVideoTime } from "./utils";
//...
export const Timer: FC<Props> = ({ controller }) => {
    //...
    const updateCurrentVideoTime = useCallback(() => {
      const timeEl = timerRef.current;

      if (timeEl?.textContent) {
        timeEl.textContent = formatCurrentVideoTime(
          controller?.getPlayingProgress() || 0
        );
      }
    }, [controller]);
    //...
}

Enter fullscreen mode Exit fullscreen mode

Here we get a reference to the DOM element and set its current playback time, previously converting seconds to minutes using the formatCurrentVideoTime function. We need this function to convert video.currentTime from just seconds into human-readable minutes, hours, etc.

💡 The code for the formatCurrentVideoTime function can be found here
.

Let's describe a task that will set a task for updating the time:

//...
import { formatCurrentVideoTime } from "./utils";

export const Timer: FC = ({ controller }) =&gt; {
    //...
    const runTask = useCallback(async () =&gt; {
       const task = () =&gt; {
         if (controller?.getPlayingState() === "playing") {
           updateCurrentVideoTime();
           window.requestAnimationFrame(task);
          }
      };

      window.requestAnimationFrame(task);
    }, [controller, updateCurrentVideoTime]);
    //...
}

Enter fullscreen mode Exit fullscreen mode

Let's figure it out. Here we start the task through requestAnimationFrame, in which we call the updateCurrentVideoTime function, which updates the time and again calls requestAnimationFrame. Before this, we check that the video is still playing by requesting the current playback status through controller?.getPlayingState() === "playing".

But that's not all. RunTask should be somehow called; let's add this functionality:

import { FC, useRef, useCallback, **useEffect** } from "react";

export const Timer: FC = ({ controller }) =&gt; {
  //...
  useEffect(() =&gt; {
    if (controller) {
      controller.subscribe("playingState", runTask);
    }
  }, [controller, runTask]);
  //...
}

Enter fullscreen mode Exit fullscreen mode

Here we subscribed to changes in playingState (play/pause/ended). For every change in playingState, we will call runTask, which will call the task to update the video timer.

Now let's add styles to our timer:

//...
import { Time } from "./Timer.styles";

export const Timer: FC = ({ controller }) =&gt; {
        //... 
   return <time ref="{timerRef}">0:00</time>;
}

Enter fullscreen mode Exit fullscreen mode

Copy the Timer styles from here, and our Timer component is ready.

💡 All component styles are described in the CSS-in-JS approach. The library used was styled-components. If you're not familiar with this approach, no worries, you only need to copy the style file and place it in the appropriate folder according to the structure described at the beginning of the article.

💡 Some styles, such as those for the sound control component, inherit common styles. Therefore, copy them from here into the project before continuing.

With the styles sorted out, let's slightly modify our Player component:

//...
import { Timer } from "../timer/Timer";
//...

export const Player: FC<Props> = ({ src }) => {
  //...
  return (
    <StyledContainer ref={containerRef}>
      <StyledPlayer ref={setElement} src={src} />
      <StyledControls>
        <div>
          {playingState === "paused" && (
            <PlayPauseButton onClick={handlePlay}>play</PlayPauseButton>
          )}
          {playingState === "playing" && (
            <PlayPauseButton onClick={handlePause}>pause</PlayPauseButton>
          )}
          {playingState === "ended" && (
            <PlayPauseButton onClick={handlePlayAgain}>replay</PlayPauseButton>
          )}
        </div>
        <ProgressAndTimerContainer>
          **<Timer controller={controller} />**
        </ProgressAndTimerContainer>
      </StyledControls>
    </StyledContainer>
  );
};

Here we added styles to our player, and also, pay attention, we added the Timer component to our Player. In the block, all player control components will be located in the StyledControlls block.

3. Playback status

As mentioned above, you can find out the current progress of video playback in seconds through the video.currentTimeproperty. Next, we will work with this property of the video.

💡 Take the styles for our progress from here and here, just copy them into your project.

Let's describe our Progress component:

import { useRef } from 'react';
import { VideoController } from "../../controllers/video-controller";
import {
  StripValue,
  StripValuePlaceholder,
  StripValueWrapper,
} from "../../styles/common.styles";

interface Props {
    controller: VideoController | null;
}

export const Progress: FC<Props> = ({ controller }) => {
    const progressRef = useRef<HTMLDivElement>(null);
  const placeholderRef = useRef<HTMLDivElement>(null);

  return ( 
        <StripValueWrapper>
      <StripValue ref={progressRef} />
      <StripValuePlaceholder ref={placeholderRef} />
    </StripValueWrapper>
    )
}

The basic implementation of the component will be as follows: The component will consist of a progress bar (a wide strip) and a progress placeholder (a strip across the entire width of the player, showing 100%). This is the strip that will represent the entire progress of the video. It sounds complicated, but it's not in practice.

To calculate the percentage of the video played, let's teach our VideoController to return the duration of the video:

export class VideoController {
    //...
  getDuration() {
    return this.element.duration;
  }
    //...
}

Here it's simple, we refer to the video element.duration property and get the duration of the video in seconds.

Let's add a method to our component that will change the width of the progress bar depending on video.currentTime:

export const Progress: FC<Props> = ({ controller }) => {
  //...
  const updateProgressBar = useCallback(() => {
    const progressEl = progressRef.current;

    if (progressEl) {
      const videoProgress = controller?.getPlayingProgress() || 0;
      const duration = controller?.getDuration();

      if (duration) {
        progressEl.style.width = `${(100 / duration) * videoProgress}%`;
      }
    }
  }, [controller]);
  //...
};

We take the current played second through getPlayingProgress(), then get the duration of the video through getDuration() and calculate the percentage of the video progress by the formula 100 / duration * videoProgress, after that, we write this value in percentages in the CSS property width of the element.

Now, like the timer, let's write code that will launch the task to update the progress bar in requestAnimationFrame:

export const Progress: FC<Props> = ({ controller }) => {
        //...
        const runTask = useCallback(async () => {
    const task = () => {
      if (controller?.getPlayingState() === "playing") {
                updateProgressBar();
        window.requestAnimationFrame(task);
      }
    };

    window.requestAnimationFrame(task);
  }, [controller, updateProgressBar]);

  useEffect(() => {
    if (controller) {
      controller.subscribe("playingState", runTask);
    }
  }, [controller, runTask]);
    //...
}

Here we did the same as in Timer: described a task that will be called in requestAnimationFrame, and subscribed to the playingState event, the handler of which will call runTask, which will cause our progress bar to be updated.

Let's display our Progress component in Player:

//...
import { Progress } from "../progress/Progress";

export const Player: FC<Props> = ({ src }) => {
  //...
  return (
    <StyledContainer ref={containerRef}>
      //...
      <StyledControls>
        //...
        <ProgressAndTimerContainer>
          **<Progress controller={controller} />**
          <Timer controller={controller} />
        </ProgressAndTimerContainer>
      </StyledControls>
    </StyledContainer>
  );
};

4. Rewinding

In this chapter, we will implement video rewinding. As I mentioned earlier, video rewinding is achieved through the video's currentTime property. To rewind a video, you simply need to set the required second of the video in this property.

Let's implement this. To calculate the second to which we need to rewind the video, we will need to determine the cursor's position relative to the beginning of the progress block. To understand where our progress block is located, we will have to use getBoundingClientRect.

💡 Be careful. When calling getBoundingClientRect, the browser recalculates the layout and returns the current values of the element's size and position. This can be a costly operation, so it is recommended to use this method cautiously and not to abuse its call within loops or frequently recurring events.

Considering the above, let's implement a controller that will call getBoundingClientRect once and request it again on the resize:

export class RectangleController {
    private readonly element: HTMLElement;
  private width: number = 0;
  private left: number = 0;
  private leftPadding: number = 0;

    getWidth() {
    return this.width;
  }

    getLeftOffset() {
    return this.left;
  }

    getLeftPadding() {
    return this.leftPadding;
  }

    private update() {
  }
}

Let's understand the structure of our controller. We will store three main variables in the controller: width, left, leftPadding, which will be accessible outside the controller. We will also store a reference to the DOM element, but the element will not be accessible externally.

Now let's describe the update() method:

export class RectangleController {

    //...
    private update() {
    const domRect = this.element.getBoundingClientRect();

    this.leftPadding = this.element.offsetLeft;
    this.width = domRect.width;
    this.left = domRect.left;
    }
    //...

}

In this method, we will call getBoundingClientRect on our DOM element and fill width, left, leftPadding. leftPadding will be filled from this.element.offsetLeft, because getBoundingClientRect does not have this data. It's all straightforward at this point.

Now let's teach the controller to update the necessary values on resize.

export class RectangleController {
    //...
    private boundUpdate: () => void;

    constructor(element: HTMLElement) {
    this.element = element;
    this.boundUpdate = this.update.bind(this);

    window.addEventListener("resize", this.boundUpdate);

        this.boundUpdate();
    }
    //...
}

Here too, everything is quite simple. We subscribe to the resize event and pass the update method as a handler, bound to the context of our controller class using .bind(). At the end, we call update, or rather its bound version.

We've figured out RectangleController, let’s keep moving.
To make it easier, let's implement helpers that will calculate the cursor position by the x-coordinate, taking into account the position of the block on the page:

import { RectangleController } from "../../controllers/rectangle-controller";

export const calculateCursorPosition = (
  rectangleController: RectangleController,
  x: number
) => {
  const offsetLeft = rectangleController.getLeftOffset();
  return x - offsetLeft;
};

I think everything is clear here. We take the x-coordinate (or offset) of the element from the left edge and the x-coordinate of the mouse cursor and subtract one from the other x - offsetLeft, so we get the mouse cursor position relative to the element from 0px to the maximum width of the block.

Let's add another helper. It will immediately return the mouse cursor position in percentages relative to the element.

export const calculateCursorPositionInPercents = (
  rectangleController: RectangleController,
  x: number
) => {
  const widthInPercents = 100 / rectangleController.getWidth();

  return (
    widthInPercents * calculateCursorPosition(rectangleController, x)
  );
};

Here we get the number of percent per 1px width of the element and multiply it by the value obtained for the position of the cursor relative to the element. It sounds complicated, but let's try it this way:

Suppose the width of the element is 200px, so when dividing 100% by 200px, we get a value of 0.5, meaning for each 1px there is 0.5% of the element's fill.

Now let's implement a helper that will calculate for us the second where the mouse cursor is located:

export const calculateCurrentTimeByCursorPosition = (
  rectangleController: RectangleController,
  x: number,
  duration: number
) => {
  const normalizedPercent =
    calculateCursorPositionInPercents(rectangleController, x) / 100;

  return Math.min(duration * normalizedPercent, duration);
};

Here we convert our calculated percentage of the mouse cursor's position to a decimal number normalizedPercent. That is, 38% is converted to 0.38, etc. Then we take the duration of the video and multiply it by normalizedPercent, ultimately getting the number of seconds of video duration relative to the position of the mouse cursor on the element.

It seems we are done with the calculations for now, let's implement the video rewinding. The first thing we need to do is initialise our RectangleController:

//...
import { RectangleController } from "../../controllers/rectangle-controller";
//...

export const Progress: FC<Props> = ({ controller }) => {

    //...
    const [rectangleController, setRectangleController] = useState<RectangleController | null>(null);

          useEffect(() => {
            if (placeholderRef.current) {
              setRectangleController(new RectangleController(placeholderRef.current));
            }
          }, []);
    //...

}


ts
Here everything is simple: we take placeholderRef.current and create a RectangleController with it.

But our videoController knows nothing about video rewinding, let's expand its capabilities:

export class VideoController {
    //...
    seek(seconds: number) {
      this.element.currentTime = seconds;
  }
    //...
}

As mentioned above, to rewind a video, you just need to specify the necessary time in seconds. For this, we implemented the seek method, which takes time in seconds and sets it in this.element.currentTime.

Now let's implement video rewinding in the component:

import {
  calculateCurrentTimeByCursorPosition
} from "./utils";

export const Progress: FC<Props> = ({ controller }) => {
        //...
        const seek = useCallback((x: number) => {
        if (rectangleController && controller) {
          const seekingValue = calculateCurrentTimeByCursorPosition(
            rectangleController,
            x,
            controller.getDuration()
          );

          controller.seek(seekingValue);

         if (controller.getPlayingState() !== "playing") {
            window.requestAnimationFrame(updateProgressBar);
         }
        }
      }, [controller, rectangleController, updateProgressBar])
        //...
}

Here we described the seek method in the component. Let's figure out what's happening here.

We get the second to which we want to rewind, calculated by the position of the mouse cursor (by the x-coordinate) - the calculateCurrentTimeByCursorPosition method helps us with this - and we pass this value to the seek method. After calling seek, we update our playing progress bar by calling window.requestAnimationFrame(updateProgressBar), provided the video is not playing.

Now, all we have left to do is to add a click handler, thanks to which the user will be able to rewind the video in our player by clicking on the progress bar:

export const Progress: FC<Props> = ({ controller }) => {
    //...
    const handleClick: React.MouseEventHandler<HTMLDivElement> = useCallback(
    (event) => {
        seek(event.clientX)
    },
    [seek]
  );

  return ( 
        <StripValueWrapper onClick={handleClick}>
      <StripValue ref={progressRef} />
      <StripValuePlaceholder ref={placeholderRef} />
    </StripValueWrapper>
    )
}

handleClick calls the seek method of the component we implemented above and passes the X-axis cursor coordinate taken from our click event.

💡 An attentive reader would have noticed that we attach the clickHandler to the progress wrapper, not to the progress itself or the placeholder. We did this because the wrapper is wider, and clicking outside the bounds of the placeholder would result in a negative value, which would be interpreted as rewinding to 0 seconds or to the duration seconds if the click occurred beyond the progress. This is necessary because the user cannot and will not be precisely clicking to the pixel when rewinding the video to the beginning or end.

After we supported the seeking event, it's time to make some adjustments to our Timer and Player.

Now, when the video is playing, our timer will update. But what happens if the user pauses the video and rewinds it? Currently, our timer does not react to rewinding, so let's fix that.

The first thing we will do is teach our controller to work with the seeking event.

This is an event that is triggered when the video's currentTime property changes.

export class VideoController {
    //...
    private seekingListeners: (() => Promise<void>)[] = [];
    //...
    private boundEmitSeekingListeners: () => void;

    constructor(el: HTMLVideoElement) {
        //...
        this.boundEmitSeekingListeners = this.emitSeekingListeners.bind(this);
        el.addEventListener("seeking", this.boundEmitSeekingListeners);
    }

    //...
    subscribe(
    event: "playingState" | **"seeking"**,
    callb: () => Promise<void>
  ) {
        //...
        if (event === "seeking") {
      this.seekingListeners.push(callb);
    }
    }
}

At this point, everything is fairly simple - in the constructor, we subscribed to the seeking event and added the ability to subscribe to this event for our components in the subscribe method.

Now let's return to our Timer component and add support for this event:

export const Timer: FC<Props> = ({ controller }) => {
    //...
  useEffect(() => {
    if (controller) {
      controller.subscribe("seeking", async () => {
        window.requestAnimationFrame(updateCurrentVideoTime);
      });
    }
  }, [controller, updateCurrentVideoTime]);
    //...
};

Here, it's just as simple: we subscribed to the seeking event. As soon as this event is thrown, requestAnimationFrame will be called with a function that updates our timer. That is, even if the video is on pause, and the user rewinds the video from 21 seconds to 1:30, the timer will update because the seeking event is called, and our handler will trigger the timer update.

Now let's make changes to our Player:

export const Player: FC<Props> = ({ src }) => {
    //...
  useEffect(() => {
    if (controller) {
      //...
      controller.subscribe("seeking", async () =>
        setPlayingState(controller.getPlayingState())
      );
      //...
    }
  }, [controller]);
    //...
};

Here we subscribed to the seeking event. When processing this event, we record the current playback state of the video.

Why was this done? For the sole case when the video has reached the end, and the user rewinds it, for example, to the middle. If we do not update the status, then the playback button will be for the action "start over" instead of the desired "play."

5. Volume control

//...
import { Volume } from "../volume/Volume";
**import { isIOS } from 'mobile-device-detect'**
//...

export const Player: FC<Props> = ({ src }) => {
  return (
    <StyledContainer ref={containerRef}>
      <StyledPlayer ref={setElement} src={src} />
      <StyledControls **isIOS={isIOS}**>
              //...
        <ProgressAndTimerContainer>
             //...
        </ProgressAndTimerContainer>
                **{!isIOS &&** <Volume controller={controller} />**}**
      </StyledControls>
    </StyledContainer>
  );
};

Now Volume will be hidden on an iOS device.

6. Fullscreen mode

It's time to talk about fullscreen mode. The first thing we will do is install the screenfull library. It is convenient for working with the Fullscreen API, and it is cross-browser compatible, well, almost.

💡 The Fullscreen API is not supported on the iPhone, I will show you later how to work around this.

So, let's get started. First, let's add a click handler that will request the browser to enter fullscreen mode:


export const Player: FC<Props> = ({ src }) => {
//...
const handleToggleFullscreen: MouseEventHandler<HTMLButtonElement> =
    useCallback(() => {
      if (screenfull.isEnabled) {
        if (containerRef.current) {
          screenfull.toggle(containerRef.current);
        }
      } else {
       //...
      }
    }, [element]);
//...
}

Here everything is simple: first, we check if the Fullscreen API is available on the device through screenfull.isEnabled. If yes, then we try to request fullscreen mode if we are not in it, or exit from fullscreen mode if we are in it, using the toggle method of the screenfull library.

💡 The attentive reader noticed that we request fullscreen mode for the container or wrapper of the player, not the video element itself. This is because if we request fullscreen mode for the video element, we will get the native video player in fullscreen mode without our styles and player buttons. For this reason, we request fullscreen mode for a div element, which simply shows us the element in full screen, as well as our custom video player in it.

But simply calling fullscreen mode is not enough, we need to somehow react to it, for example, we want to change the icon on the button that opens/closes fullscreen mode. First, let's add the flag isFullscreen to the state:

export const Player: FC<Props> = ({ src }) => {
    //...
    const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
    //...
}

At this point, everything is fairly straightforward: we simply added the state of the isFullscreen flag, which we will change depending on whether we are in fullscreen mode or not.

Now let's subscribe to the transition/exit from fullscreen mode:

export const Player: FC<Props> = ({ src }) => {
        //...
        useEffect(() => {
            if (screenfull.isEnabled) {
                  screenfull.on("change", () => {
                    setIsFullscreen(screenfull.isFullscreen);
                  });
                }
      }, []);
        //...
}

Here we check that Fullscreen API is supported by the browser, and subscribe to the transition/exit from fullscreen mode and in the listener of this event, we record the value from screenfull.isFullscreen into the state.

Let's add a button that will call and close fullscreen:

export const Player: FC<Props> = ({ src }) => {
    //...
    return (
        <StyledContainer>
        //...
        </StyledControls>
                //...
        **<FullscreenButton onClick={handleToggleFullscreen}>**
          **{isFullscreen ? "]  [" : "[  ]"}**
        **</FullscreenButton>**
        </StyledControls>
        //...
    )
}

Here everything is simple: on click on the button, the handler we described above is called, and on the change of the isFullscreen variable, we change our “icon” of the button.

What about the iPhone? We have two ways:

  • try to display the player in full width and height, darkening everything around;
  • request fullscreen mode of the native iPhone player.

The second option seems to me more reasonable and functional. Let's implement it:

export const Player: FC<Props> = ({ src }) => {
  //...
  return (
    //...
    <StyledPlayer src={src} preload="metadata" ref={setElement} **playsInline** />
    //...
  );
};

At this point, once again, everything is simple: we set the playsInline attribute for the video element. playsInline is an attribute of the <video> element, which tells the browser to play the video inside the <video> element on the page, rather than in fullscreen mode. This is useful for mobile devices, especially on iOS devices, where video can automatically switch to fullscreen mode during playback.

Setting the playsInline attribute to true ensures that the video will play inside the <video> element, rather than switching to fullscreen mode when play is pressed on iOS devices.

Next, let's improve our method:

export const Player: FC<Props> = ({ src }) => {
//...
const handleToggleFullscreen: MouseEventHandler<HTMLButtonElement> =
    useCallback(() => {
      if (screenfull.isEnabled) {
        if (containerRef.current) {
          screenfull.toggle(containerRef.current);
        }
      } else {
       if (
          element &&
          (element as any)?.webkitSupportsPresentationMode("fullscreen")
        ) {
          (element as any)?.webkitSetPresentationMode("fullscreen");
        }
      }
    }, [element]);
//...
}

The webkitSetPresentationMode method is used to set the display mode of an element to fullscreen mode on devices supporting WebKit. In this case, we use it to request fullscreen mode for the video element on iPhone devices.

We check for the support of the webkitSetPresentationMode method with the fullscreen type for the video element. If the device supports this method, we call it with the fullscreen parameter to request the display of the video in fullscreen mode. This allows the video to open in fullscreen mode, but in the native interface of the device.

This way, using the webkitSetPresentationMode method, we can request fullscreen mode for video on iPhone devices where fullscreen mode using the Fullscreen API is not supported. On some iOS devices, this works reliably only after the user has pressed play and the video has started playing. It would help us if we set the preload attribute of the video element to auto, then the browser would always load the video, even if the user does not plan to watch it. In our case, we have set the value to metadata.

💡 When the preload attribute with the value metadata is set on a video element in HTML, the browser only loads the video's metadata. This includes information such as the video's duration, dimensions (width and height), and possibly the first frame for display as a thumbnail. However, the video itself (i.e., its content) will not be loaded until the user starts playback. This allows the page to load faster, as it avoids loading large amounts of video data if the user is not planning to watch it.

But there is another way to solve this problem - ask the user to click on the player to start playback. This way, we hit two birds with one stone: achieve stable operation on iOS and optimise video loading.

Let's improve our Player component:

export const Player: FC<Props> = ({ src }) => {
    //...
    const [wasFirstPlayed, setWasFirstPlayed] = useState<boolean>(false);
    //...
}

Here we added a flag wasFirstPlayed to our player's state, which will indicate that the user clicked for the first time on the player to start the video and hide our improvised poster.

Let's add a handler that will change this flag:

export const Player: FC<Props> = ({ src }) => {
    //...
  const handleContainerClick = useCallback(() => {
    setWasFirstPlayed(true);
    controller?.play();
  }, [controller]);

    //...
  return (
    <StyledContainer **onClick={handleContainerClick}** ref={containerRef}>
            {!wasFirstPlayed && (
        <FirstPlayOverlay>
          <FirstPlayButton>Click to Play</FirstPlayButton>
        </FirstPlayOverlay>
      )}
      //...
    </StyledContainer>
  );
};

Now, on the first click on our entire player, the wasFirstPlayed flag will be set to true, the video will start playing, and our improvised poster will be hidden:

<FirstPlayOverlay>
    <FirstPlayButton>Click to Play</FirstPlayButton>
 </FirstPlayOverlay>

Since we've touched on the topic of playing the video by clicking on the entire player, and not just on the play/pause/replay buttons, let's improve our handleContainerClick so that it not only plays the video but also pauses it.

export const Player: FC<Props> = ({ src }) => {
    //...
  const handleContainerClick = useCallback(() => {
    if (controller?.getPlayingState() === "paused") {
      setWasFirstPlayed(true);
      controller?.play();
    }

    if (controller?.getPlayingState() === "playing") {
      controller?.pause();
    }
  }, [controller]);
};

Now our click event handler will play or pause the video depending on the current playback state.

But now we've created a bug. During rewinding and changing the volume, in general, with any click inside the player, the player will play/pause our video. Let's fix this:

export const Player: FC<Props> = ({ src }) => {

const handleControllsClick: MouseEventHandler<HTMLDivElement> = useCallback(
    (event) => {
      event.stopPropagation();
    },
    []
  );

return (
    <StyledContainer **onClick={handleContainerClick}** ref={containerRef}>
            {!wasFirstPlayed && (
        <FirstPlayOverlay>
          <FirstPlayButton>Click to Play</FirstPlayButton>
        </FirstPlayOverlay>
      )}
            //...
            <StyledControls isIOS={isIOS} **onClick={handleControllsClick}**>
        //... 
      </StyledControls>
    </StyledContainer>
  );)

}

The solution is very simple: catch the click event on bubbling and cancel its further propagation using the stopPropagation method of the event.

7. Frame preview

In this chapter, we will implement a video frame preview feature. When the mouse cursor hovers over any point on the progress bar, we will display the frame corresponding to that position. However, this is just a demonstration version.

The first thing we will do is add our FramePreview component to the Progress component:

//...
import { FramePreview } from "./Progress.styles";

export const Progress: FC<Props> = ({ controller }) => {
    //...
    const framePreviewRef = useRef<HTMLVideoElement>(null);

    return ( 
        <StripValueWrapper onClick={handleClick}>
      <StripValue ref={progressRef} />
      <StripValuePlaceholder ref={placeholderRef} />
            **<FramePreview
            muted
            preload="metadata"
            src={controller?.getVideoSrc()}
            ref={framePreviewRef}
      />**
    </StripValueWrapper>
    )
}

Here we add the FramePreview component, which is a child component of the Progress component. It represents a <video> element, which will be used to display the video frame preview.

In the Progress component, a reference framePreviewRef is created using useRef, which will be used to access the <video> element of the FramePreview component.

Inside the Progress component, the FramePreview component is utilised. It takes the following attributes: muted indicates that the sound should be turned off, preload="metadata" specifies that only video metadata should be loaded, and src={controller?.getVideoSrc()} sets the video source, which is obtained from the controllerobject.

Effectively, we will be showing a player within a player (like exzibit) and rewinding it to the second where the cursor is positioned in the main player.

💡 It's best to implement video previews using a pre-prepared sprite (an image with video frame cutouts) on the server: this solution is more optimal as the sprite loads much faster than the video. To show the desired frame in our preview, in this case, the browser needs to load the video up to that second.

First, let's implement a method that will draw the video frame:

export const Progress: FC<Props> = ({ controller }) => {
 //...
    const renderFramePreview = useCallback(
    (x: number) => {
      const framePreviewElement = framePreviewRef.current;

      const updatePosition = () => {
        if (rectangleController && framePreviewElement) {
          const cursorPosition = calculateCursorPosition(rectangleController, x);

          const currentTime = calculateCurrentTimeByCursorPosition(
            rectangleController,
            x,
            framePreviewElement.duration
          );

          const leftPadding = rectangleController.getLeftPadding();

          framePreviewElement.currentTime = currentTime;
          framePreviewElement.style.left = `${cursorPosition - 50 + leftPadding
            }px`;
        }
      }

      window.requestAnimationFrame(updatePosition)
    },
    [rectangleController]
  );
    //...
}

Let's understand what's happening here. First, we get cursorPosition (the position of the cursor) relative to the width of the progress block by the x-coordinate. Next, by the x-coordinate, we try to get currentTime (frame time) where our cursor is positioned.

Then we rewind the video in FramePreview to the obtained time and move our preview image to the cursor position on the mouse.

💡 The position of FramePreview is calculated according to the formula: cursorPosition - 50 + leftPadding, where cursorPosition is the position of the mouse cursor on the progress block, 50 is half the width of FramePreview, and leftPadding is the padding inside the progress block.

As expected, the task of changing the element's styles is placed in requestAnimationFrame.

We don't always need to show the frame preview, only when the mouse enters the progress block.

export const Progress: FC<Props> = ({ controller }) => {
  //...
  const setFramePreviewVisible = useCallback(() => {
    const framePreviewElement = framePreviewRef.current;

    if (framePreviewElement) {
      framePreviewRef.current.style.visibility = "initial";
    }
  }, []);

  const hideFramePreview = useCallback(() => {
    const framePreviewElement = framePreviewRef.current;

    if (framePreviewElement) {
      framePreviewRef.current.style.visibility = "hidden";
    }
  }, []);
  //...

  return (
    <StripValueWrapper
      onClick={handleClick}
      onMouseEnter={setFramePreviewVisible}
      onMouseLeave={hideFramePreview}
    >
      <StripValue ref={progressRef} />
      <StripValuePlaceholder ref={placeholderRef} />
      <FramePreview
        muted
        preload="metadata"
        src={controller?.getVideoSrc()}
        ref={framePreviewRef}
      />
    </StripValueWrapper>
  );
};

Here everything is simple: on mouseEnter we change visibility to initial, and on mouseLeave we change it to hidden. And then we attach these event handlers to StripValueWrapper.

Now that the frame preview can appear and disappear, let's make it so that the frame preview moves with the mouse.

export const Progress: FC<Props> = ({ controller }) => {
  //...
  const handleMouseMove: React.MouseEventHandler<HTMLDivElement> = useCallback(
    (event) => {
        renderFramePreview(event.clientX);
    },
    [renderFramePreview]
  );
  //...

  return (
    <StripValueWrapper
      onClick={handleClick}
      onMouseEnter={setFramePreviewVisible}
      onMouseLeave={hideFramePreview}
            **onMouseMove={handleMouseMove}**
    >
      <StripValue ref={progressRef} />
      <StripValuePlaceholder ref={placeholderRef} />
      <FramePreview
        muted
        preload="metadata"
        src={controller?.getVideoSrc()}
        ref={framePreviewRef}
      />
    </StripValueWrapper>
  );
};

At this point, everything is pretty simple: we added the handleMouseMove event handler and attached it to the mousemove event of the StripValueWrapper component.

Now, when hovering the mouse, we will see the video frame preview at the second where the cursor is pointed.

8. Touch device support

If the click event still works and features can be used, then when it comes to mousemove, it almost doesn't work as it should on touch devices.

On touch devices, such as smartphones and tablets, the mousemove event does not function the same way as it does on a computer with a mouse. Instead, events like touchstart, touchmove, and touchend are used to track finger movements on the screen.

To support video rewinding on touch devices, you will need a touchmove event handler that will track the finger's coordinates on the screen and correspondingly change the position of the progress bar and the video time.

The first thing we do is implement a kind of GesturesController:

export class GesturesController {
    private touchStarted = false;
    private lastFingerPositionX = 0;
    private timeoutId = 0; 

    touch() {
        window.clearTimeout(this.timeoutId)
        this.touchStarted = true;
    }

    touchEnd() {
        this.timeoutId = window.setTimeout(() => {
            this.touchStarted = false;
        }, 300)
    }

    getTouchStarted() {
        return this.touchStarted;
    }

    setLastFingerPosition(x: number) {
        this.lastFingerPositionX = x;
    }

    getLastFingerPosition() {
        return this.lastFingerPositionX;
    }
}

GesturesController is a class used for managing gestures on touch devices. It has the following functionalities:

  • touch() - a method called when the screen is touched, sets the touchStarted flag to true.
  • touchEnd() - a method called when the finger is lifted off the screen, sets the touchStarted flag to false after 300 milliseconds using setTimeout.
  • getTouchStarted() - a method that returns the current value of the touchStarted flag.
  • setLastFingerPosition(x: number) - a method that sets the last finger position on the X-axis.
  • getLastFingerPosition() - a method that returns the last finger position on the X-axis.

This class allows tracking the start and end of a touch on the screen, as well as the last finger position. This is useful for implementing gestures and managing video rewinding on touch devices.

💡 Pay attention to the touchEnd method. In it, we set the touchStarted flag with a delay of 300ms. This is done to ignore mouseclick events on touch devices. It's a small hack for the stability of our code.

Let's support touch actions in our Progress component:

//...
const gesturesController = new GesturesController();

interface Props {
  controller: VideoController | null;
}

export const Progress: FC<Props> = ({ controller }) => {
//...

Here we did something a bit unusual. gesturesController was created at the level of the JS module. Why? Because we don't need to store it in the state of our player, and one instance is enough for the entire life cycle of the module.

Now let's write a touchstart event handler:

export const Progress: FC<Props> = ({ controller }) => {
  //...
  const handleTouchStart: React.TouchEventHandler<HTMLDivElement> = useCallback(
    (event) => {
      gesturesController.touch();

      if (event.touches.length === 1) {
        const touch = event.touches[0];

        const x = touch.clientX;
        gesturesController.setLastFingerPosition(x);
      }

      setFramePreviewVisible();
    },
    [setFramePreviewVisible]
  );
  //...
};

Let's understand what's happening here. First, we will notify our gesturesController that a touch event has started. Then we check that the number of touches is one event.touches.length === 1, extract our touch and get its position on the x-axis. Next, we call setFramePreviewVisible() to make our video preview visible.

Now let's implement a touchmove event handler:

export const Progress: FC<Props> = ({ controller }) => {
    //...
    const handleTouchMove: React.TouchEventHandler<HTMLDivElement> = useCallback((event) => {
    if (event.touches.length === 1) {
      const touch = event.touches[0];

      const x = touch.clientX;
      gesturesController.setLastFingerPosition(x);
      renderFramePreview(x)
    }
  }, [renderFramePreview])
    //...
}

Here we also get the x-coordinate of the user's touch and record it in gesturesController, and also pass this coordinate to the renderFramePreview method to render the video frame preview.

Now let's implement a touchend event handler:

export const Progress: FC<Props> = ({ controller }) => {
    //...
    const handleTouchMove: React.TouchEventHandler<HTMLDivElement> = useCallback(() => {
        gesturesController.touchEnd();

    seek(gesturesController.getLastFingerPosition())
    hideFramePreview()
  }, [renderFramePreview])
    //...
}

The simplest handler of all. Here we notify our gesturesController that the touch has ended. Then we call the seek method with the last touch coordinate for video rewinding and then hide our video frame preview.

This is not the only component that should support touch devices. Let's support touch devices in the Volume component.

//...
const gesturesController = new GesturesController();

interface Props {
  controller: VideoController | null;
}

export const Volume: FC<Props> = ({ controller }) => {
//...

As in the Progress component, we initialise our gesturesController.

Let's make changes to our click handler:

const handleClick: React.MouseEventHandler<HTMLDivElement> = useCallback(
    (event) => {
      if (!gesturesController.getTouchStarted()) {
        updateVolume(event.clientX);
      }
    },
    [updateVolume]
  );

Here it's the same as in Progress, we don't process the click if a "touch" action is already in progress.

Let's implement touch device handlers:

export const Volume: FC<Props> = ({ controller }) => {
  //...
  const handleTouchMove = useCallback(
    (event: React.TouchEvent) => {
      if (event.touches.length === 1) {
        const touch = event.touches[0];

        const x = touch.clientX;
        updateVolume(x);
      }
    },
    [updateVolume]
  );

  const handleTouchStart = useCallback(
    (event: React.TouchEvent) => {
      if (event.touches.length === 1) {
        const touch = event.touches[0];

        const x = touch.clientX;
        updateVolume(x);
      }

      gesturesController.touch();
    },
    [updateVolume]
  );

  const handleTouchEnd = useCallback(() => {
    gesturesController.touchEnd();
  }, []);

  return (
    <StyledStripValueWrapper
      onClick={handleClick}
      **onTouchMove={handleTouchMove}
      onTouchStart={handleTouchStart}
      onTouchEnd={handleTouchEnd}
      onTouchCancel={handleTouchEnd}**
    >
      <StyledStripeValue ref={volumeRef} />
      <StripValuePlaceholder ref={placeHolderRef} />
    </StyledStripValueWrapper>
  );
};

Here everything is analogous to what we did in the Progress component part.

Final touches

The full implementation and all the code can be viewed here: https://github.com/ByMarsel/videoplayer-example.

Streaming Video: If you want features like switching video tracks with different qualities (480p, 720p), streaming video (downloading video in chunks) using HLS, subtitle support, and more, consider using the hls.js library.

💡 HLS (HTTP Live Streaming) is a video format developed by Apple that allows streaming video via HTTP protocol. It is used for delivering video to various devices and platforms, including web pages, mobile apps, and smart TVs.

HLS divides video into small fragments, which are progressively downloaded and played by the client application. This allows delivering video of varying quality depending on the device and network bandwidth availability. For instance, if a device has a slow internet connection, HLS will automatically switch to lower quality video to ensure smooth playback without buffering.

HLS also supports the addition of subtitles, multi-channel audio, and other additional features. It has become a popular format for streaming services such as YouTube, Netflix, and Twitch.

*Video Speed *

If you want to change the video speed, this can be done through the video property playbackRate, with the maximum supported value being 4x.

*Autoplay *

Autoplay will only work if the video is muted, meaning the muted attribute of the video element is set to true. Otherwise, playback of such a video by the browser will be blocked.

Top comments (1)

Collapse
 
evilj0e profile image
Anton Konev

Hey Marsel,
Nicely done. I haven’t seen so detailed and step-by-step instruction by now. Will send my colleagues to look through your solution either. Brilliant work.