Creating a Custom Video Player in Vue.js

Tim Benniks

In this Media Jam you will learn how to create a video player with custom controls and event handling in Vue.js. This Jam does not use any pre-build video libraries, you will learn to code directly against the native video API in the browser.

Vue.js has great features to make components communicate together. In this Jam, you will learn how to use scoped slots, and how to pass information between different components.

The video player is split up into multiple files:

  1. Videoplayer.vue contains the basics and wraps the native HTML5 video player in Vue code. It exposes video events, and its controls, so they are accessible for the other Vue components in the Jam.

  2. videoplayer-track.vue listens to the timeupdate event, and based on the video duration, calculates the percentage played.

  3. And finally app.vue. This is the file where everything comes together. From custom controls to listening to native events, to showing the state of the video in the custom controls.

To see everything in action, feel free to check the CodeSandBox embedded below.

Wrapping native HTML5 video in Vue.js

The native HTML5 video tag is constructed in 'videoplayer.vue'. It is able to receive props (the ones we add in app.vue), and is able to pass these along to the video tag.

1<template>
2<video
3 :src="src"
4 :muted="muted"
5 :autoplay="autoplay"
6 :controls="controls"
7 :loop="loop"
8 :width="width"
9 :height="height"
10 :poster="poster"
11 :preload="preload"
12 :playsinline="true"
13 ref="player"
14/>
15</template>
16<script>
17export default {
18 name: "Videoplayer",
19 props: {
20 src: { type: String, required: true },
21 controls: { type: Boolean, required: false, default: false },
22 loop: { type: Boolean, required: false, default: false },
23 width: { type: Number, required: false, default: 500 },
24 height: { type: Number, required: false, default: 281 },
25 autoplay: { type: Boolean, required: false, default: false },
26 muted: { type: Boolean, required: false, default: false },
27 poster: { type: String, required: false },
28 preload: { type: String, required: false, default: "auto" },
29 },
30</script>

As you can see above, the default properties that the native HTML5 video tag has been passed through with Vue.js.

Notice the ref="player" prop on the video tag. This allows you to reference the native HTML tag in Vue.js like so: this.$refs.player.

The player ref is used to send actions and to listen to events for the HTML5 video tag in Vue.

Methods to send actions to the player

The videoplayer.vue component has a bunch of functions to control the state of the native HTML5 player. Let's start with the basics: play(), pause(), togglePlay(), mute(), unmute() and toggleMute(). Note that some of the earlier code around the props has been omitted to keep the below code simple and focused on the basic functions.

1export default {
2 name: "Videoplayer",
3 // keeping the state
4 data() {
5 return {
6 playing: false,
7 videoMuted: false,
8 };
9 },
10 methods: {
11 play() {
12 this.$refs.player.play();
13 this.setPlaying(true);
14 },
15
16 pause() {
17 this.$refs.player.pause();
18 this.setPlaying(false);
19 },
20
21 togglePlay() {
22 if (this.playing) {
23 this.pause();
24 } else {
25 this.play();
26 }
27 },
28
29 setPlaying(state) {
30 this.playing = state;
31 },
32
33 mute() {
34 this.$refs.player.muted = true;
35 this.setMuted(true);
36 },
37
38 unmute() {
39 this.$refs.player.muted = false;
40 this.setMuted(false);
41 },
42
43 toggleMute() {
44 if (this.videoMuted) {
45 this.unmute();
46 } else {
47 this.mute();
48 }
49 },
50
51 setMuted(state) {
52 this.videoMuted = state;
53 }
54 }
55}

Some of this code looks a little redundant, especially the setPlaying and setMuted functions. But, these "setter" functions will be needed when you want to set the state of the player from another component while listening to video events.

Controlling the player from another component

By creating a base video player which sends events, and does simple actions, you can potentially create many video player instances with different feature sets without bloating the player code itself. In this Jam, the code is using a "scoped slot" to pass information and actions from the native video player to the code added into the slot. The controls, video track, and duration are created separately and put into the slot where the player is instantiated.

This way you can make one player instance with just a play button, and another with a toggle play, a progress track, and a mute button.

This is how you pass functions and other info into the slot:

1<template>
2 <div>
3 <video
4 :src="src"
5 :muted="muted"
6 :autoplay="autoplay"
7 :controls="controls"
8 :loop="loop"
9 :width="width"
10 :height="height"
11 :poster="poster"
12 :preload="preload"
13 :playsinline="true"
14 ref="player"
15 />
16 <slot
17 name="controls"
18 *:play="play"
19 :pause="pause"
20 :playing="playing"
21 :toggle-play="togglePlay"
22 :video-muted="videoMuted"
23 :toggle-mute="toggleMute"*
24 ></slot>
25 </div>
26</template>

In the example above the functions and state created earlier are passed into the slot called "controls". When the slot is used by another component, that component receives all these properties and functions.

Use it like this:

1<template>
2 <videoplayer src="https://res.cloudinary.com/demo/video/upload/dog.mp4">
3 <template v-slot:controls="{ togglePlay, toggleMute, playing, videoMuted }">
4 <div class="videoplayer-controls">
5 <button @click="togglePlay()">{{ playing ? "pause" : "play" }}</button>
6 <button @click="toggleMute()">{{ videoMuted ? "unmute" : "mute" }}</button>
7 </div>
8 </template>
9 </videoplayer>
10</template>
11
12<script>
13import videoplayer from "./components/videoplayer";
14export default {
15 components: {
16 videoplayer,
17 },
18}
19</script>

Sending events from the native video to the player implementation

Now that the "scoped slot" is working, you can use the Vue.js event emitter to send native video events to the app.vue which implements videoplayer.vue.

These are the most interesting events in most cases:

1const EVENTS = [
2 "play",
3 "pause",
4 "ended",
5 "loadeddata",
6 "waiting",
7 "playing",
8 "timeupdate",
9 "canplay",
10 "canplaythrough",
11 "statechanged",
12];

In the mounted hook of the Vue component, when the video tag exists in the DOM, you can loop over these events, and start listening to them. Some code is omitted to make the example more clear. For the full code see the CodeSandBox link.

1const EVENTS = [
2 "play",
3 "pause",
4 "ended",
5 "loadeddata",
6 "waiting",
7 "playing",
8 "timeupdate",
9 "canplay",
10 "canplaythrough",
11 "statechanged",
12];
13
14export default {
15 name: "Videoplayer",
16 mounted() {
17 this.bindEvents();
18 },
19 methods: {
20 bindEvents() {
21 EVENTS.forEach((event) => {
22 this.bindVideoEvent(event);
23 });
24 },
25
26 bindVideoEvent(which) {
27 const player = this.$refs.player;
28
29 player.addEventListener(
30 which,
31 (event) => {
32 this.$emit(which, { event, player: this });
33 }
34
35 );
36 },
37 }
38}

On mounted the bindEvents() function loops over the list of pre-defined events and it fires off a bindVideoEvent() function which, in turn, takes the video DOM node from this.$refs.player, and adds an addEventListener function for the event.

Now that the code is listening to the events from the native player, it uses the Vue event emitter to send events. It sends the native event data to itself and the player instance. This is handy for the component implementing the video player.

This is how to listen to the events from the other side:

1<template>
2 <videoplayer
3 src="https://res.cloudinary.com/demo/video/upload/dog.mp4"
4 @play="onPlayerPlay"
5 @pause="onPlayerPause"
6 @ended="onPlayerEnded"
7 @loadeddata="onPlayerLoadeddata"
8 @waiting="onPlayerWaiting"
9 @playing="onPlayerPlaying"
10 @timeupdate="onPlayerTimeupdate"
11 @canplay="onPlayerCanplay"
12 @canplaythrough="onPlayerCanplaythrough"
13 @statechanged="playerStateChanged">
14
15 <!-- slot related stuff -->
16
17 </videoplayer>
18</template>
19
20<script>
21import videoplayer from "./components/videoplayer";
22export default {
23 components: {
24 videoplayer,
25 },
26 methods: {
27 onPlayerPlay({ event, player }) {
28 console.log(event.type);
29 player.setPlaying(true);
30 },
31 onPlayerPause({ event, player }) {
32 console.log(event.type);
33 player.setPlaying(false);
34 },
35 onPlayerEnded({ event, player }) {
36 console.log(event.type);
37 player.setPlaying(false);
38 },
39 onPlayerLoadeddata({ event }) {
40 console.log(event.type);
41 },
42 onPlayerWaiting({ event }) {
43 console.log(event.type);
44 },
45 onPlayerPlaying({ event }) {
46 console.log(event.type);
47 },
48 onPlayerTimeupdate({ event }) {
49 console.log({ event: event.type, time: event.target.currentTime });
50 },
51 onPlayerCanplay({ event }) {
52 console.log(event.type);
53 },
54 onPlayerCanplaythrough({ event }) {
55 console.log(event.type);
56 },
57
58 playerStateChanged({ event }) {
59 console.log(event.type);
60 },
61 },
62};
63</script>

By combining the "scoped slot" and the event listeners, you can do anything you need in order to ensure that the player looks and behaves the way you want each time you implement it.

Let's add a time indicator

Now that all tools are in place, let's add a time indicator. For the time indicator, you need the current time of the video and the duration of the video. On top of that, you also need a function to convert seconds to a duration.

In app.vue, you ask for the video duration and the convertTimeToDuration function. You also have to listen to the timeupdate event of the native video to get the currentTime of the video.

Note that all other code is removed so the example stays simple.

1<template>
2 <videoplayer
3 src="https://res.cloudinary.com/demo/video/upload/dog.mp4"
4 @timeupdate="onPlayerTimeupdate">
5 <template v-slot:controls="{ duration, convertTimeToDuration }">
6 <div class="videoplayer-controls">
7 <div class="videoplayer-controls-time">
8 {{ convertTimeToDuration(time) }} /
9 {{ convertTimeToDuration(duration) }}
10 </div>
11 </div>
12 </template>
13 </videoplayer>
14</template>
15
16<script>
17import videoplayer from "./components/videoplayer";
18export default {
19 components: {
20 videoplayer,
21 },
22 data() {
23 return {
24 time: 0,
25 };
26 },
27 methods: {
28 onPlayerTimeupdate({ event }) {
29 this.time = event.target.currentTime;
30 },
31 }
32}
33</script>

In videoplayer.vue, you need to manage to get the duration and create the code for the convertTimeToDuration function.

For the duration, update the bindVideoEvent function from earlier. When the loadeddata event hits the video, 'duration' becomes available.

1data() {
2 return {
3 duration: 0,
4 };
5},
6methods: {
7 bindVideoEvent(which) {
8 const player = this.$refs.player;
9
10 player.addEventListener(
11 which,
12 (event) => {
13 if (which === "loadeddata") {
14 this.duration = player.duration;
15 }
16
17 this.$emit(which, { event, player: this });
18 },
19 true
20 );
21 },
22}

This is a simple function, used to parse seconds, and turn them into a duration. Add this one to your methods object in videoplayer.vue:

1convertTimeToDuration(seconds) {
2 return [parseInt((seconds / 60) % 60, 10), parseInt(seconds % 60, 10)]
3 .join(":")
4 .replace(/\b(\d)\b/g, "0$1");
5},

Conclusion

Now that all conditions of working in the architecture are in place, it becomes clear it is very flexible, and adding the video player track component should be easy. Check the CodeSandBox link for the full example.

Happy coding!

Tim Benniks

NuxtJS Ambassador