Skip to main content

Web Video Player - Building Custom Web Player with HLS

 

Introduction

In the last post, we have looked upon how the video element work, various events and hls. Now its time to build the custom web player.

Building Up

Preparing m3u8

For preparing the hls playlist file (m3u8) we will be using the ffmpeg. For this I have taken the a video from Pexels site. There are various commands/syntax available for preparing the m3u8 which supports the adaptive bitrate stream. For this, I have taken the following code:
ffmpeg -hide_banner -y -i green_plants-pexels.mp4^
  -vf scale=w=640:h=360:force_original_aspect_ratio=decrease -c:a aac -ar 48000 -c:v h264 -profile:v main -crf 20 -sc_threshold 0 -g 48 -keyint_min 48 -hls_time 4 -hls_playlist_type vod  -b:v 800k -maxrate 856k -bufsize 1200k -b:a 96k -hls_segment_filename green_plants-pexels/360p_%03d.ts green_plants-pexels/360p.m3u8 ^
  -vf scale=w=842:h=480:force_original_aspect_ratio=decrease -c:a aac -ar 48000 -c:v h264 -profile:v main -crf 20 -sc_threshold 0 -g 48 -keyint_min 48 -hls_time 4 -hls_playlist_type vod -b:v 1400k -maxrate 1498k -bufsize 2100k -b:a 128k -hls_segment_filename green_plants-pexels/480p_%03d.ts green_plants-pexels/480p.m3u8 ^
  -vf scale=w=1280:h=720:force_original_aspect_ratio=decrease -c:a aac -ar 48000 -c:v h264 -profile:v main -crf 20 -sc_threshold 0 -g 48 -keyint_min 48 -hls_time 4 -hls_playlist_type vod -b:v 2800k -maxrate 2996k -bufsize 4200k -b:a 128k -hls_segment_filename green_plants-pexels/720p_%03d.ts green_plants-pexels/720p.m3u8 ^
  -vf scale=w=1920:h=1080:force_original_aspect_ratio=decrease -c:a aac -ar 48000 -c:v h264 -profile:v main -crf 20 -sc_threshold 0 -g 48 -keyint_min 48 -hls_time 4 -hls_playlist_type vod -b:v 5000k -maxrate 5350k -bufsize 7500k -b:a 192k -hls_segment_filename green_plants-pexels/1080p_%03d.ts green_plants-pexels/1080p.m3u8

Here we have specified the video resolution with the scale and bitrate. We can also specify the subtitles/captions as m3u8. The hls supports WebVTT format, a standard format that is widely used and accepted by HTML. For this demo I have created a simple small .vtt file following the format for WebVTT. 


WEBVTT

1
00:00:00.000 --> 00:00:04.000
Subtitles
    
2
00:00:04.200 --> 00:00:10.200
In English
    
3
00:00:10.400 --> 00:00:19.400
Demo


And to use with the hls we need to run following ffmpeg command.

ffmpeg -i green_plants.vtt -f segment -segment_time 4 -segment_list_size 0 -segment_list green_plants-pexels/sub.m3u8 -segment_format webvtt -scodec copy green_plants-pexels/sub_%03d.vtt  

The here we only take .vtt (subtitle) file and kept -segment_time same as the -hls_time. Here, .m3u8 file will be generated and .vtt files. 

Files Output

Now we need another m3u8 file which will be the master file giving reference to the other m3u8 files of different quality and also to subtitles.

We can use any text editor and add the following text, and save the file with .m3u8 format. It will be created in same folder where other files are located.


#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",DEFAULT=NO,AUTOSELECT=YES,FORCED=NO,LANGUAGE="en",URI="sub.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,SUBTITLES="subs"
360p.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1400000,RESOLUTION=842x480,SUBTITLES="subs"
480p.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1280x720,SUBTITLES="subs"
720p.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080,SUBTITLES="subs"
1080p.m3u8

Here, in second line we have specified the subtitles with a group ID, name and Language. URI specifies the location of subtitles m3u8 file. In remaining part we have given reference to different video quality files. It is always recommended to specify the Bandwidth and resolution, as it will be used as metadata for the player to choose best quality video when we set to auto. For specifying the subtitles we use group ID.


Preparing basic structure

Before moving lets create basic structure with html, css and javascript. And create a empty video element with div parent. We will also be using the FontAwesome fee pack for various icons. In order to use FontAwesome  simply go to their site and either use their CDN link by creating free account. Or we can also download for free and use the package. After downloading, extract the zip file. For using the icons we only need ./css.all.css file to link in the header. And to use any icon simply search on the site and follow the provided code.

Using hls.js

As m3u8 can't be used directly we need to use javascript library, hls.js. It is one of the best open source library available for the HTTP Live Streaming available. To make hls work use the following code provided in their documentation.

HTML

<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>


Javascript
var videoElement = document.querySelector("#video_player > video");
var videoSrc = "./media/green_plants-pexels/master.m3u8";
if (Hls.isSupported()) {
    var hls = new Hls();
    hls.loadSource(videoSrc);
    hls.attachMedia(videoElement);
  } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
    video.src = videoSrc;
}

Now if we look we will have video element ready to play.

Output


Making Controls

Lets work on the controls of the video player.

Play and Load

It will display the play icon and a loading animation when video paused due to buffer.

HTML

<div id="video_action" class="video_action_start">
    <i class="fa-solid fa-play control_icons"></i>
</div>

CSS
#video_player {
    width: 90%;
    max-width: 800px;
    margin: auto;
    position: relative;
    font-family: 'Anek Tamil', sans-serif;
    box-shadow: 0 0 20px -5px rgba(0, 0, 0, 0.7);

    --control-group-y-scale: 0;
}

#video_player > video {
    width: 100%;
    display: block;
    z-index: 1;
}

#video_player > .hiding_area {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    z-index: 2;
}

#video_action {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    border-radius: 50%;
    z-index: 3;
}

#video_action.video_action_start {
    width: 100px;
    height: 100px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(0, 0, 0, 0.4);
    box-shadow: 0 0 20px 0px rgba(0, 0, 0, 0.7);
    cursor: pointer;
}

#video_action.video_action_start > i {
    font-size: 4rem;
    color: #fffcfc;
}

#video_action.video_action_loading {
    width: 100px;
    height: 100px;
    
    opacity: 0.9;
    border: 10px solid #c9c9c9;


    border-top: 10px solid #fffcfc;
    animation: video_loading 2s linear infinite;
    box-shadow: 0 0 20px 10px rgba(0, 0, 0, 0.7);
}

@keyframes video_loading {
    0% {
        transform: translate(-50%, -50%) rotate(0deg);
    }

    100% {
        transform: translate(-50%, -50%) rotate(360deg);
    }
}


Output
For Pause Icon

Loading Animation


Playing Bar

For playing bar we can use input range slider or can do custom build. For this lets build it custom. And we will also add an element that will tell the time at which user position user has hovered. 

HTML

<div id="control_group">
    <div id="playing_bar" class="video_controls">
        <div id="time_passed">
            <div></div>
            <i class="fa-solid fa-circle control_icons"></i>
        </div>

        <div id="video_loaded"></div>
    </div>

    <div id="hover_timings"></div>
</div>

CSS


.video_controls {
    color: #f1f1f1;
}

.control_icons {
    font-size: 1.2rem;
    
}

.control_icons:hover {
    color: #ffffff;
}


#control_group {
    background: linear-gradient(to top,  rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0));
    padding: 7px 15px;
    box-sizing: border-box;
    position: absolute;
    bottom: 0;
    width: 100%;
    z-index: 4;

    transform: scale(1, var(--control-group-y-scale));
    transform-origin: bottom;
    transition: transform 0.2s ease-in-out;

    /* For non selectable */
    -webkit-user-select: none;  /* Chrome all / Safari all */
    -moz-user-select: none;     /* Firefox all */
    -ms-user-select: none;      /* IE 10+ */
    user-select: none; 
}

#playing_bar {
    --pb-time-passed: 0%;
    --pb-video-loaded: 0%;

    position: relative;
    width: 100%;
    background-color: #c9c9c9;
    border-radius: 5px;
    cursor: pointer;
    margin-bottom: 10px;
}

#time_passed {
    width: var(--pb-time-passed);
    position: relative;
    z-index: 2;
    display: flex;
    flex-direction: column;
    justify-content: center;
}

#time_passed > div {
    width: 100%;
    background-color: #05c19c;
    height: 8px;
    border-radius: 5px;
}

#time_passed > i {
    font-size: 1.1rem;
    color: #05c19c;
    position: absolute;
    
    left: calc(100% - 1.1rem / 2);

    cursor: pointer;
    pointer-events: none;
    z-index: 5;
}

#video_loaded {
    position: absolute;
    background-color: #fffcfc;
    width: var(--pb-video-loaded);
    height: 8px;
    top: 0;
    z-index: 1;
    border-radius: 5px;
}

#hover_timings {
    --hover-timings-position: 0px;

    color: #f1f1f1;
    position: absolute;
    background: rgba(0, 0, 0, 0.7);
    bottom: calc(100% + 10px);

    transform: scale(1, 0) translateX(var(--hover-timings-position));
    transition: transform 0.1s linear;
    transform-origin: bottom;
    z-index: 4;
}

#playing_bar:hover + #hover_timings {
    transform: scale(1) translateX(var(--hover-timings-position));
}


JavaScript

  // Playing bar
var playingBar = document.getElementById("playing_bar");

var isPlayingPointerPressed = false;
var isMouseMoved = false;

playingBar.addEventListener("mousedown", () => {
    isPlayingPointerPressed = true;
    isMouseMoved = false;

    document.addEventListener("mousemove", pointerIsPressedAndMoving);
    document.addEventListener("mouseup", pointerMouseIsUp);
});

playingBar.addEventListener("click", e => {
    if (!isMouseMoved) {
        positionPlayingPointer(e);

        // Updating the current time
        let newTimePercentage = playingBar.style.getPropertyValue("--pb-time-passed").slice(0, -1);
        let newTimeInSeconds = (Number(newTimePercentage) / 100) * Number(videoElement.duration);
        videoElement.currentTime = newTimeInSeconds;
    }
});


function positionPlayingPointer(e) {
    var playingBarDOMRect = playingBar.getBoundingClientRect();
        var mousePosition = e.clientX - playingBarDOMRect.x;

        if (mousePosition < 0) {
            mousePosition = 0;
        } else if (mousePosition > playingBarDOMRect.width) {
            mousePosition = playingBarDOMRect.width;
        }

        playingBar.style.setProperty("--pb-time-passed", (mousePosition/playingBarDOMRect.width)*100 + "%");
}

function pointerMouseIsUp() {
    if(isPlayingPointerPressed) {
        isPlayingPointerPressed = false;

        // Removing the listeners
        document.removeEventListener("mousemove", pointerIsPressedAndMoving);
        document.removeEventListener("mouseup", pointerMouseIsUp);

        // Updating the current time only if the pointer was moved
        if(isMouseMoved) {
            // Updating the current time
            let newTimePercentage = playingBar.style.getPropertyValue("--pb-time-passed").slice(0, -1);
            let newTimeInSeconds = (Number(newTimePercentage) / 100) * Number(videoElement.duration);
            videoElement.currentTime = newTimeInSeconds;
        }
    }
}

function pointerIsPressedAndMoving(e) {
    if (isPlayingPointerPressed) {
        isMouseMoved = true;
        positionPlayingPointer(e);
    }
}



// Hover and show
var hoverTimings = document.getElementById("hover_timings");

playingBar.addEventListener("mousemove", e => {
    let newHoverTimingPosition = e.offsetX - hoverTimings.getBoundingClientRect().width / 2;

    if (newHoverTimingPosition < 0) {
        newHoverTimingPosition = 0;
    } else if (newHoverTimingPosition > (e.target.clientWidth - hoverTimings.getBoundingClientRect().width)) {
        newHoverTimingPosition = e.target.clientWidth - hoverTimings.getBoundingClientRect().width;
    }

    hoverTimings.innerHTML = timeMinutesSeconds(Math.floor((e.offsetX/playingBar.getBoundingClientRect().width) * videoElement.duration));
    hoverTimings.style.setProperty("--hover-timings-position", (newHoverTimingPosition) + "px");
});


// Function for minutes and seconds
function timeMinutesSeconds(videoTime) {
    if (videoTime < 0) {
        videoTime = 0;
    }
    let minutes = Math.floor(videoTime / 60);
    let seconds = Math.floor(videoTime % 60);

    return minutes + ":" + String(seconds).padStart(2, "0");
}


Output

More Controls

Now we will be adding the controls like play pause, volume, full screen. Here we will be creating custom range slider with input tag and using webkit and moz browser engines properties.

HTML

<div id="other_controls">
    <div>
        <div id="play_pause" class="video_controls">
            <i class="fa-solid fa-play control_icons"></i>
        </div>

        <div id="current_time" class="video_controls">
            <span>0:00</span><span>/0:00</span>
        </div>
        
        <div id="volume_control" class="video_controls">
            <i class="fa-solid fa-volume-high control_icons"></i>

            <div id="volume_control_bar">
                <input type="range" min="0" max="100" value="100" class="custom_range_slider">
                <div id="vcb_selected"></div>
            </div>
        </div>
    </div>

    <div>
        <div id="more_setting" class="video_controls">
            <i class="fa-solid fa-gear control_icons"></i>
        </div>

        <div id="full_screen" class="video_controls">
            <i class="fa-solid fa-expand control_icons"></i>
        </div>
    </div>
</div>

CSS

#other_controls {
    margin-top: 7px;
    margin-bottom: 3px;
    display: flex;
    justify-content: space-between;
}


#other_controls > div:not(.m_setting_box) {
    display: flex;
    gap: 13px;
    align-items: center;
}

#play_pause {
    min-width: 1.2rem;
}

#current_time {
    font-size: 1rem;
    min-width: calc(4 * 1rem);
}


#volume_control {
    display: flex;
    align-items: center;
    gap: 10px;
}

#volume_control > i {
    min-width: 1.5rem;
}

#volume_control_bar {
    transform: scale(0, 1);
    transform-origin: 0% 100%;
    transition: transform 0.3s ease-in;
}

#volume_control:hover #volume_control_bar {
    transform: scale(1);
}

.custom_range_slider {
    -webkit-appearance: none;
    background: transparent;
    cursor: pointer;
}

#volume_control_bar {
    /* Variable for holding the height of the range bar */
    --volume-bar-height: 5px;
    display: flex;
    width: 80px;
    height: var(--volume-bar-height);
    position: relative;
    background: #c9c9c9;
    border-radius: 3px;
}

#volume_control_bar input[type = "range"] {
    margin: 0;
    position: relative;
    -webkit-appearance: none;
    width: 100%;
    outline: none;
    border-radius: 3px;
    z-index: 3;
}

#volume_control_bar input[type = "range"]::-webkit-slider-runnable-track {
    height: var(--volume-bar-height);
    border-radius: 3px;
}

#volume_control_bar input[type = "range"]::-moz-range-track {
    height: var(--volume-bar-height);
    border-radius: 3px;
}

#volume_control_bar input[type = "range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    height: 15px;
    width: 15px;
    border-radius: 50%;
    background: #05c19c;
    cursor: pointer;

    /* Formula: (track height in pixels / 2) - (thumb height in pixels /2) */
    margin-top: -5px;
}

#volume_control_bar input[type = "range"]::-moz-range-thumb {
    border: none;
    height: 15px;
    width: 15px;
    border-radius: 50%;
    background: #05c19c;
}

#vcb_selected {
    position: absolute;
    width: 100%;
    height: var(--volume-bar-height);
    background-color: #fffcfc;
    border-radius: 3px;
    z-index: 2;
}

JavaScript


var volumeControlIcon = document.querySelector("#volume_control > i");
var volumeRangeInput = document.querySelector('#volume_control_bar input[type = "range"]');
volumeRangeInput.addEventListener("input", volumeRangeChange);

function volumeRangeChange() {
    document.getElementById("vcb_selected").style.width = volumeRangeInput.value + "%";

    let volumeRangeInputNumber = Number(volumeRangeInput.value);
    videoElement.volume =  volumeRangeInputNumber/ 100;

    const volumeMuteIconClass = "fa-volume-xmark";
    const volumeLowIconClass = "fa-volume-low";
    const volumeNormalIconClass = "fa-volume-high";

    if (volumeRangeInputNumber == 0) {
        if(volumeControlIcon.classList.contains(volumeLowIconClass)) {
            volumeControlIcon.classList.replace(volumeLowIconClass, volumeMuteIconClass);
        } else if (volumeControlIcon.classList.contains(volumeNormalIconClass)) {
            volumeControlIcon.classList.replace(volumeNormalIconClass, volumeMuteIconClass);
        }

    } else if (volumeRangeInputNumber > 0 && volumeRangeInputNumber <= 50) {
        if(volumeControlIcon.classList.contains(volumeMuteIconClass)) {
            volumeControlIcon.classList.replace(volumeMuteIconClass, volumeLowIconClass);
        } else if (volumeControlIcon.classList.contains(volumeNormalIconClass)) {
            volumeControlIcon.classList.replace(volumeNormalIconClass, volumeLowIconClass);
        }
    } else {
        if(volumeControlIcon.classList.contains(volumeMuteIconClass)) {
            volumeControlIcon.classList.replace(volumeMuteIconClass, volumeNormalIconClass);
        } else if (volumeControlIcon.classList.contains(volumeLowIconClass)) {
            volumeControlIcon.classList.replace(volumeLowIconClass, volumeNormalIconClass);
        }
    }
}

volumeControlIcon.addEventListener("click", () => {
    if(Number(volumeRangeInput.value) == 0) {
        volumeRangeInput.value = "100";
    } else {
        volumeRangeInput.value = "0";
    }

    volumeRangeChange();
});

Output


Adding more setting options

Here we will be adding the more setting options like playback speed, quality change, subtitles. 
HTML

    <div id="other_controls">
    <!-- For Mor setting -->
    <div id="more_setting_options" class="m_setting_box" data-setting-view="false">
        <div class="ms_box_heading" onclick="hideShowAnySettingBox(event, 'more_setting_options')">
            <i class="fa-solid fa-angle-left more_setting_icons"></i>
            <span>More Setting</span>
        </div>

        <div class="ms_box_option">
            <div>
                <i class="fa-solid fa-repeat more_setting_icons"></i>
                <!-- <i class="fa-solid fa-gauge-high more_setting_icons"></i> -->
                <span>Repeat</span>
            </div>
            <div>
                <div id="mso_repeat_switch" data-enabled="false">
                    <div></div>
                </div>
            </div>
        </div>

        <div class="ms_box_option" onclick="hideShowAnySettingBox(event, 'mso_playback_speed')">
            <div>
                <i class="fa-solid fa-gauge more_setting_icons"></i>
                <span>Playback Speed</span>
            </div>
            <div>
                <span id="mso-current-playback">1</span>
                <i class="fa-solid fa-angle-right more_setting_icons"></i>
            </div>
        </div>

        <!-- For Changing Quality -->
        <div class="ms_box_option" onclick="hideShowAnySettingBox(event, 'mso_qaulity')">
            <div>
                <i class="fa-solid fa-bars-staggered more_setting_icons"></i>
                <span>Quality</span>
            </div>
            <div>
                <span id="mso-current-quality">Auto</span>
                <i class="fa-solid fa-angle-right more_setting_icons"></i>
            </div>
        </div>

        <!-- For Subtitles -->
        <div class="ms_box_option" onclick="hideShowAnySettingBox(event, 'mso_subtitles')">
            <div>
                <i class="fa-solid fa-closed-captioning more_setting_icons"></i>
                <span>Subtitles</span>
            </div>
            <div>
                <span id="mso-current-subtitles">Off</span>
                <i class="fa-solid fa-angle-right more_setting_icons"></i>
            </div>
        </div>
    </div>

    <!-- Playback speed options -->
    <div id="mso_playback_speed" class="m_setting_box" data-setting-view="false">
        <div class="ms_box_heading" onclick="hideShowAnySettingBox(event, 'more_setting_options')">
            <i class="fa-solid fa-angle-left more_setting_icons"></i>
            <span>Playback Speed</span>
        </div>

        <div class="ms_box_option" onclick="changePlayback(0.5, this)">
            <div>
                <i class="fa-solid fa-check more_setting_icons" data-selected="false"></i>
            </div>
            <div>
                <span>0.5</span>
            </div>
        </div>

        <div class="ms_box_option" onclick="changePlayback(0.75, this)">
            <div>
                <i class="fa-solid fa-check more_setting_icons" data-selected="false"></i>
            </div>
            <div>
                <span>0.75</span>
            </div>
        </div>

        <div class="ms_box_option" onclick="changePlayback(1, this)">
            <div>
                <i class="fa-solid fa-check more_setting_icons" data-selected="true"></i>
            </div>
            <div>
                <span>Normal</span>
            </div>
        </div>

        <div class="ms_box_option" onclick="changePlayback(1.5, this)">
            <div>
                <i class="fa-solid fa-check more_setting_icons" data-selected="false"></i>
            </div>
            <div>
                <span>1.5</span>
            </div>
        </div>

        <div class="ms_box_option" onclick="changePlayback(2, this)">
            <div>
                <i class="fa-solid fa-check more_setting_icons" data-selected="false"></i>
            </div>
            <div>
                <span>2</span>
            </div>
        </div>
    </div>

    <!-- Quality options -->
    <div id="mso_qaulity" class="m_setting_box" data-setting-view="false">
        <div class="ms_box_heading" onclick="hideShowAnySettingBox(event, 'more_setting_options')">
            <i class="fa-solid fa-angle-left more_setting_icons"></i>
            <span>Quality</span>
        </div>

        <div class="ms_box_option" onclick="changeQualityLevel(-1)">
            <div>
                <i class="fa-solid fa-check more_setting_icons" data-selected="true"></i>
            </div>
            <div>
                <span>Auto</span>
            </div>
        </div>
    </div>

    <!-- Captions Option -->
    <div id="mso_subtitles" class="m_setting_box" data-setting-view="false">
        <div class="ms_box_heading" onclick="hideShowAnySettingBox(event, 'more_setting_options')">
            <i class="fa-solid fa-angle-left more_setting_icons"></i>
            <span>Subtitles</span>
        </div>

        <div class="ms_box_option" onclick="changeSubtitles(-1)">
            <div>
                <i class="fa-solid fa-check more_setting_icons" data-selected="true"></i>
            </div>
            <div>
                <span>Off</span>
            </div>
        </div>
    </div>
</div>

CSS

.m_setting_box {
    position: absolute;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    gap: 0px;
    padding: 5px 10px;
    bottom: 100%;
    right: 5%;
    font-size: 1rem;
    background: rgba(0, 0, 0, 0.5);
}

.m_setting_box[data-setting-view= "false"] {
    display: none;
}

.ms_box_heading {
    padding: 5px 10px;
    display: flex;
    gap: 20px;
    align-items: center;
    justify-content: space-evenly;

    border-bottom: 1px solid #c9c9c9;
    border-bottom-left-radius: 1px;
    border-bottom-right-radius: 1px;
}

.ms_box_heading:hover {
    background: rgba(0, 0, 0, 0.7);
}

.ms_box_heading span {
    color: #fffcfc;
}

.more_setting_icons {
    color: #fffcfc;
    font-size: 0.9rem;
}

.ms_box_option {
    display: flex;
    gap: 30px;
    padding: 7px 5px;
    align-items: center;
    justify-content: space-between;
}

.ms_box_option:hover {
    background: rgba(0, 0, 0, 0.7);
}

.ms_box_option > div {
    display: flex;
    align-items: center;
    gap: 5px;
}

.ms_box_option > div:nth-child(1) > span {
    color: #fffcfc;
}

.ms_box_option > div:nth-child(2) > span {
    color: #f1f1f1;
    font-size: 0.9rem;
}

#mso_repeat_switch {
    position: relative;
    background: #c9c9c9;
    width: 34px;
    height: 17px;
    border-radius: 10px;
    z-index: 1;
    cursor: pointer;
}

#mso_repeat_switch > div {
    position: absolute;
    background: #ffffff;
    left: 0%;
    width: 50%;
    height: 100%;
    border-radius: 100%;
    z-index: 2;
}

#mso_repeat_switch[data-enabled = "true"] {
    background-color: #05c19c;
}

#mso_repeat_switch[data-enabled = "true"] > div {
    left: 50%;
}

#mso_playback_speed i[data-selected = "false"] {
    display: none;
}

#mso_qaulity i[data-selected = "false"] {
    display: none;
}

#mso_qaulity i[data-selected = "false"] {
    display: none;
}

#mso_subtitles  i[data-selected = "false"] {
    display: none;
}

JavaScript

var mso_repeat_switch = document.getElementById("mso_repeat_switch");

mso_repeat_switch.addEventListener("click", () => {
    if(mso_repeat_switch.getAttribute("data-enabled") == "false") {
        mso_repeat_switch.setAttribute("data-enabled", "true");
        videoElement.loop = true;
    } else {
        mso_repeat_switch.setAttribute("data-enabled", "false");
        videoElement.loop = false;
    }
});

// hiding and showing more settings
var anySetingVisible = false;
var currentVisibleSetting;

document.getElementById("more_setting").addEventListener("click", e => {
    e.stopPropagation();
    if(!anySetingVisible) {
        hideShowAnySettingBox(e, "more_setting_options");
        anySetingVisible = true;
    } else {
        anySetingVisible = false;
        document.querySelector('.m_setting_box[data-setting-view= "true"]').setAttribute("data-setting-view", "false");
    }
});


function hideShowAnySettingBox(event, boxId) {
    event.stopPropagation();
    currentVisibleSetting = document.getElementById(boxId);

    if(currentVisibleSetting.getAttribute("data-setting-view") == "false") {
        if(anySetingVisible){
            document.querySelector('.m_setting_box[data-setting-view= "true"]').setAttribute("data-setting-view", "false");
        }
        currentVisibleSetting.setAttribute("data-setting-view", "true");
    } else if(anySetingVisible) {
        document.querySelector('.m_setting_box[data-setting-view= "true"]').setAttribute("data-setting-view", "false");
        
        anySetingVisible = false;
    }
}


// Playback change
function changePlayback(newPlaybackRate, theParentElement) {
    document.querySelector('#mso_playback_speed i[data-selected = "true"]').setAttribute("data-selected", "false");

    videoElement.playbackRate = newPlaybackRate;
    theParentElement.firstElementChild.firstElementChild.setAttribute("data-selected", "true");
    document.getElementById("mso-current-playback").innerHTML = newPlaybackRate;
}

Output



Handling all control

So we have added all graphical component of the player lets start working on the background part.

Mouse in show option Mouse out hide

So with the help of JS we will be adding that when user came into area the controls are shown, when moves out or no movement for 3 seconds, options are hidden. Also it will hide the shown setting if user click anywhere else.

JavaScript

var mainVideoElement = document.getElementById("video_player");
var isMouseOnControlGroup = false;
var controlGroupTimeout;

function runControlGroupTimeout() {
    controlGroupTimeout = setTimeout(() => {
        if(!isMouseOnControlGroup) {
            mainVideoElement.style.setProperty("--control-group-y-scale", "0");
        }
        
    }, 3000);
}

mainVideoElement.addEventListener("mousemove", () => {
    mainVideoElement.style.setProperty("--control-group-y-scale", "1");

    clearTimeout(controlGroupTimeout);
    runControlGroupTimeout();
    
});

mainVideoElement.addEventListener("mouseleave", () => {
    mainVideoElement.style.setProperty("--control-group-y-scale", "0");
});


var controlGroup = document.getElementById("control_group");

controlGroup.addEventListener("mouseover", () => {
    isMouseOnControlGroup = true;
});


controlGroup.addEventListener("mouseout", () => {
    isMouseOnControlGroup = false;
});



controlGroup.addEventListener("click", e => {
    // To stop bubble event propagation
    e.stopPropagation();

    // If setting is visible but user clicks on elsewhere
    if(anySetingVisible) {
        if(! (currentVisibleSetting.contains(e.target))) {
            document.querySelector('.m_setting_box[data-setting-view= "true"]').setAttribute("data-setting-view", "false");
            anySetingVisible = false;
            currentVisibleSetting = null;
        }
    }
    
});

// Stop double click bubble propagation
controlGroup.addEventListener("dblclick", e => {
    e.stopPropagation();
})


We Have also added stopPropagation to prevent event buuble on some events.

Output


Play Pause and Buffer

Javascript

// Play pause video
var playPauseBtn = document.querySelector("#play_pause i");

function playPauseVideo() {
    if(videoElement.paused) {
        videoElement.play();

    } else {
        videoElement.pause();
    }
}

playPauseBtn.addEventListener("click", () => {
    playPauseVideo();
});

videoElement.addEventListener("pause", () => {
    playPauseBtn.classList.remove("fa-pause");
    playPauseBtn.classList.add("fa-play");
});

videoElement.addEventListener("play", () => {
    playPauseBtn.classList.remove("fa-play");
    playPauseBtn.classList.add("fa-pause");
});

videoElement.addEventListener("play", () => {
    if(videoActionElement.classList.contains("video_action_start")) {
        videoActionElement.classList.remove("video_action_start");
        videoActionElement.innerHTML = "";
    }
}, {once: true});

var videoActionElement = document.getElementById("video_action");
videoActionElement.addEventListener("click", e => {
    e.stopPropagation();
    playPauseVideo();
    videoActionElement.innerHTML = "";

    videoActionElement.classList.remove("video_action_start");

}, {once: true});

mainVideoElement.addEventListener("click", () => {
    playPauseVideo();

    if(anySetingVisible) {
        anySetingVisible = false;
        document.querySelector('.m_setting_box[data-setting-view= "true"]').setAttribute("data-setting-view", "false");
    }
});


Full Screen

Full screen mode can be viewed with full screen icon or double click.

JavaScript

var fullScreenBtn = document.querySelector("#full_screen > i");
var isCurrentFullScreen = false;

fullScreenBtn.addEventListener("click", () =>{
    if(!isCurrentFullScreen) {
        openFullscreenVideo();
        fullScreenBtn.classList.replace("fa-expand", "fa-compress");
        isCurrentFullScreen = true;
    } else {
        closeFullscreenVideo();
        fullScreenBtn.classList.replace("fa-compress", "fa-expand");
        isCurrentFullScreen = false;
    }
});



function openFullscreenVideo() {
    if (mainVideoElement.requestFullscreen) {
        mainVideoElement.requestFullscreen();
    } else if (mainVideoElement.webkitRequestFullscreen) { /* Safari */
        mainVideoElement.webkitRequestFullscreen();
    } else if (elem.msRequestFullscreen) { /* IE11 */
        mainVideoElement.msRequestFullscreen();
    }
}

function closeFullscreenVideo() {
    if (document.exitFullscreen) {
        document.exitFullscreen();
    } else if (document.webkitExitFullscreen) { /* Safari */
        document.webkitExitFullscreen();
    } else if (document.msExitFullscreen) { /* IE11 */
        document.msExitFullscreen();
    }
}

// Full screen using double click
mainVideoElement.addEventListener("dblclick", () => {
    if(!isCurrentFullScreen) {
        openFullscreenVideo();
        fullScreenBtn.classList.replace("fa-expand", "fa-compress");
        isCurrentFullScreen = true;
    } else {
        closeFullscreenVideo();
        fullScreenBtn.classList.replace("fa-compress", "fa-expand");
        isCurrentFullScreen = false;
    }
});

Updating time and loaded

JavaScript

// Changing total duration and current duration
var currentDurationElement = document.querySelector("#current_time > span:nth-child(1)");
var totalDurationElement = document.querySelector("#current_time > span:nth-child(2)");

// Video loads metadata
videoElement.addEventListener("loadedmetadata", () => {
    totalDurationElement.innerHTML = "/" + timeMinutesSeconds(Number(videoElement.duration));
    availableQualityLevels();
    availableSubtitles();
});

videoElement.addEventListener("timeupdate", () => {
    currentDurationElement.innerHTML = timeMinutesSeconds(Number(videoElement.currentTime));

    // Updating the bar
    if(!isPlayingPointerPressed) {
        playingBar.style.setProperty("--pb-time-passed", (Number(videoElement.currentTime)/Number(videoElement.duration))*100 + "%");
    }
    
});


// When media buffer
videoElement.addEventListener("waiting", () => {
    if (!videoActionElement.classList.contains("video_action_start")) {
        videoActionElement.innerHTML = "";
        videoActionElement.className = "video_action_loading";
    }
    
});

videoElement.addEventListener("canplay", () => {
    if (!videoActionElement.classList.contains("video_action_start")) {
        videoActionElement.className = "";
    }
});


// When media loaded
videoElement.addEventListener("progress", updateLoadedMediaBar);

function updateLoadedMediaBar() {
    let videoBuffered = videoElement.buffered;
    let endPoint = 0;

    for(let i = 0; i < videoBuffered.length; i++) {
        if(endPoint < videoBuffered.end(i)) {
            endPoint = videoBuffered.end(i);
        }
    }
    
    playingBar.style.setProperty("--pb-video-loaded", (endPoint / videoElement.duration) * 100 + "%");
}

Quality and Subtitle Change

JavaScript

// Quality change options
function availableQualityLevels() {
    let currentFirstOption;

    for(let i = 0; i < hls.levels.length; i++) {
        currentFirstOption = document.querySelector("#mso_qaulity > .ms_box_option:nth-child(2)");
        currentFirstOption.insertAdjacentHTML("beforebegin", '<div class="ms_box_option" onclick="changeQualityLevel('+ i + ')"><div><i class="fa-solid fa-check more_setting_icons" data-selected="false"></i></div><div><span>' + hls.levels[i].height + 'p</span></div></div>');
    }
}

function changeQualityLevel(newLevel) {
    // Changes the level
    hls.currentLevel = newLevel;

    // Deselect previous level
    document.querySelector('#mso_qaulity i[data-selected = "true"]').setAttribute("data-selected", "false");

    // Selects new level
    document.querySelector('#mso_qaulity > .ms_box_option[onclick="changeQualityLevel(' + newLevel + ')"] > div > i').setAttribute("data-selected", "true");

    //Changes quality level on the front setting view
    let newLevelName;
    if(newLevel < 0) {
        newLevelName = "Auto";
    } else {
        newLevelName = hls.levels[hls.currentLevel].height;
    }
    document.getElementById("mso-current-quality").innerHTML = newLevelName;
}


// Subtitles
function availableSubtitles() {
    let currentLastOption;

    for(let i = 0; i < hls.subtitleTracks.length; i++) {
        currentLastOption = document.querySelector("#mso_subtitles > .ms_box_option:last-child");
        currentLastOption.insertAdjacentHTML("afterend", '<div class="ms_box_option" onclick="changeSubtitles('+ i + ')"><div><i class="fa-solid fa-check more_setting_icons" data-selected="false"></i></div><div><span>' + hls.subtitleTracks[i].name + '</span></div></div>');
    }
}

function changeSubtitles(newIndex) {
    // Change the track
    hls.subtitleTrack = newIndex;

    // Deselect previous level
    document.querySelector('#mso_subtitles i[data-selected = "true"]').setAttribute("data-selected", "false");

    // Selects new level
    document.querySelector('#mso_subtitles > .ms_box_option[onclick="changeSubtitles(' + newIndex + ')"] > div > i').setAttribute("data-selected", "true");

    let newSubtName;
    if (newIndex < 0) {
        newSubtName = "Off";
    } else {
        newSubtName = hls.subtitleTracks[hls.subtitleTrack].name;
    }
    document.getElementById("mso-current-subtitles").innerHTML = newSubtName;    
}

Combining All

To check the final output use the following link https://media-webplayer.netlify.app/

To know the final code use the following link https://media-webplayer.netlify.app/source_code.html

To know more you can watch the video from here

Reference

Comments