In this article, we will build a custom video player with Svelte, a modern front-end JavaScript framework for developing dynamic and reactive web applications.
For styling, we will use Tailwind CSS, a utility-first CSS framework, and for the player controls, we will incorporate icons from the Svelte Icons Pack.
Install Required Packages
npx sv create svelte-video-player
cd svelte-video-player
npm install
npm run dev
Next let us install and setup Tailwind CSS and Svelte Icons Pack
Component Setup
Create a VideoPlayer.svelte
file under src/lib
directory and import it into src/routes/+page.svelte
<!-- src/routes/+page.svelte -->
<script>
import VideoPlayer from '$lib/VideoPlayer.svelte';
</script>
<VideoPlayer />
Next we will create the UI and styling for the video player, with our custom control
<!-- src/lib/VideoPlayer.svelte -->
<script>
import { Icon } from 'svelte-icons-pack';
import { FaSolidPlay, FaSolidPause } from 'svelte-icons-pack/fa';
import { VscMute, VscUnmute } from 'svelte-icons-pack/vsc';
import { RiMediaFullscreenLine, RiMediaFullscreenExitFill } from 'svelte-icons-pack/ri';
</script>
<div class="bg-gray-200 h-screen flex items-center justify-center">
<div
class="relative w-3/4 h-3/4 bg-black flex items-center justify-center overflow-hidden player-container"
>
<video class="h-auto w-auto max-w-full">
<track kind="captions" />
<source src="/demo-video.mp4" type="video/mp4" />
</video>
<div class="absolute w-full bottom-0 left-0 bg-[#00000086]">
<div class="w-full px-2">
<div class="h-1 bg-gray-700 rounded-lg relative cursor-pointer" role="presentation">
<div
class="absolute h-full bg-white rounded-lg"
role="progressbar"
aria-label="played"
></div>
</div>
</div>
<div class="h-14 flex justify-between items-center px-4">
<div class="flex items-center gap-4">
<button>
<Icon color="#ffffff" src={FaSolidPlay} size="22px" />
</button>
<button>
<Icon src={VscUnmute} color="#ffffff" size="22px" />
</button>
<div>
<p class="text-white">00:00 / 00:00</p>
</div>
</div>
<div>
<button>
<Icon color="#ffffff" src={RiMediaFullscreenLine} size="22px" />
</button>
</div>
</div>
</div>
</div>
</div>
Play/Pause
The first function we will implement is the play/pause control. We will create a paused
variable and bind it to the paused
property of the video element using bind:paused
. Then, we will create a function to toggle the play/pause state of the video by updating this variable.
<script>
...
let paused;
const togglePlay = () => {
paused = !paused;
};
</script>
...
<video class="h-auto w-auto max-w-full" bind:paused>
<track kind="captions" />
<source src="/demo-video.mp4" type="video/mp4" />
</video>
...
...
<button on:click={togglePlay}>
<Icon color="#ffffff" src={paused ? FaSolidPlay : FaSolidPause} size="22px" />
</button>
...
Muted State
The next function is for the mute state of the video. Create a muted
variable and bind it to the muted
property of the video element, then create a function to toggle the state
<script>
...
let muted;
const toggleAudio = () => {
muted = !muted;
};
...
</script>
<video bind:paused bind:muted>
<track kind="captions" />
<source src="/demo-video.mp4" type="video/mp4" />
</video>
Time Display
Next, we will display the current time and total duration of the video next to the audio icon. Create a function to format time in hh:mm:ss
format for better readability. Define duration
and currentTime
variables and bind them to the respective properties of the video element using bind:duration
and bind:currentTime
<script>
...
let currentTime = 0;
let duration = 0;
const formatVideoTime = (timeInSeconds) => {
const hour = Math.floor(timeInSeconds / 3600)
.toString()
.padStart(2, '0');
const minutes = Math.floor((timeInSeconds % 3600) / 60)
.toString()
.padStart(2, '0');
const seconds = Math.floor(timeInSeconds % 60)
.toString()
.padStart(2, '0');
return `${hour > 0 ? hour + ':' : ''}${minutes}:${seconds}`;
};
</script>
<video
class="h-auto w-auto max-w-full"
bind:paused
bind:muted
bind:currentTime
bind:duration
>
<track kind="captions" />
<source src="/demo-video.mp4" type="video/mp4" />
</video>
...
<div>
<p class="text-white">{formatVideoTime(currentTime)} / {formatVideoTime(duration)}</p>
</div>
Fullscreen Mode
We will use the native browser API to implement fullscreen mode. First, we will check if the document is currently in fullscreen mode. If it is, we will exit fullscreen mode. If it is not, we will request fullscreen mode for the target element. For compatibility, since Document.fullscreenElement
is not fully supported in all browsers, we will account for vendor prefixes where necessary.
<script>
...
let isFullScreen = false;
let videoContainerElement;
const toggleFullScreen = () => {
if (
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.msFullscreenElement
) {
// Exit fullscreen mode
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
isFullScreen = false;
} else {
// Enter fullscreen mode
if (videoContainerElement.requestFullscreen) {
videoContainerElement.requestFullscreen();
} else if (videoContainerElement.webkitRequestFullscreen) {
videoContainerElement.webkitRequestFullscreen();
} else if (videoContainerElement.msRequestFullscreen) {
videoContainerElement.msRequestFullscreen();
}
isFullScreen = true;
}
};
</script>
<div class="bg-gray-200 h-screen flex items-center justify-center">
<div
class="relative w-3/4 h-3/4 bg-black flex items-center justify-center overflow-hidden player-container"
bind:this={videoContainerElement}
>
<video class="h-auto w-auto max-w-full" bind:paused bind:muted>
<track kind="captions" />
<source src="/demo-video4.mp4" type="video/mp4" />
</video>
...
<button on:click={toggleFullScreen}>
<Icon
color="#ffffff"
src={isFullScreen ? RiMediaFullscreenExitFill : RiMediaFullscreenLine}
size="22px"
/>
</button>
</div>
</div>
Progress Bar
Now we are going to create a function for the custom progress bar, to display the video progress and handle manual scrubbing of the video.
<script>
...
let progressbarElement;
let progressPlayed = 0;
let playerContainer = typeof window !== 'undefined' ? document.querySelector('.player-container') : null;
const handleTimeUpdate = () => {
const playedPercent = (time / duration) * 100;
progressPlayed = playedPercent;
};
const scrubVideo = (event) => {
const rect = progressbarElement.getBoundingClientRect();
let relX = event.pageX - (rect.left + document.body.scrollLeft);
if (relX < 0) {
relX = 0;
} else if (relX > progressbarElement.offsetWidth) {
relX = progressbarElement.offsetWidth;
}
const relXPercent = (relX / progressbarElement.offsetWidth) * 100;
const timeInSeconds = (duration * relXPercent) / 100;
const playedPercent = (timeInSeconds / duration) * 100;
progressPlayed = playedPercent;
time = timeInSeconds;
};
const onProgressDragStop = () => {
paused = false;
playerContainer.removeEventListener('mousemove', scrubVideo);
document.removeEventListener('mouseup', onProgressDragStop);
};
const handleProgressDrag = (event) => {
paused = true;
scrubVideo(event);
playerContainer.addEventListener('mousemove', scrubVideo);
document.addEventListener('mouseup', onProgressDragStop);
};
</script>
<div class="bg-gray-200 h-screen flex items-center justify-center">
<div
class="relative w-3/4 h-3/4 bg-black flex items-center justify-center overflow-hidden player-container"
bind:this={videoContainerElement}
>
...
<div class="w-full px-2">
<div
class="h-1 bg-gray-700 rounded-lg relative cursor-pointer"
role="presentation"
bind:this={progressbarElement}
on:mousedown={handleProgressDrag}
>
<div
class="absolute h-full bg-white rounded-lg"
role="progressbar"
aria-label="played"
style="width: {progressPlayed}%"
></div>
</div>
</div>
...
</div>
</div>
The handleProgressDrag
function pauses the video and allows users to adjust the playback position by dragging the progress bar or clicking on it. It calculates the new playback time based on the mouse's position on the bar and updates the video using the scrubVideo
function. While the user is dragging, it listens for mousemove
events to continuously update the playback position. When the user releases the mouse -mouseup
event, it cleans up the event listeners and resumes playback.
The scrubVideo
function calculates the playback position based on the user's interaction with the progress bar. It determines the mouse's relative position relX
on the progress bar, ensuring the value stays within its boundaries. This relative position is converted into a percentage relXPercent
, which is used to calculate the corresponding playback time in seconds. Finally, it updates the progressPlayed
percentage and sets the video's time
to the new calculated position.
Keyboard Shortcuts
Let us handle keyboard shortcuts for toggling fullscreen and pause/play. We are going to use Svelte special elements to handle this <svelte:window>
this will allow us to add event listeners to the window
object. This should be placed at the top level of the component.
<script>
...
const onKeyDown = (event) => {
if (event.key === 'f') {
toggleFullScreen();
}
if (event.key === ' ') {
togglePlay();
}
};
</script>
<svelte:window on:keydown={onKeyDown} />
Using the keydown
event, we will detect when the f
key or spacebar
is pressed and trigger the corresponding function.
Conclusion
In this tutorial, we covered the basic controls of a video player, including play/pause, mute, displaying the current time and duration, and toggling fullscreen mode. You can further extend this functionality by adding features such as playback speed controls, hiding the controls when the cursor is idle, and much more.
Here is a link to the code.
Thanks for reading!
Demo video by Alex Kuimov from Pixabay