Deven is an Entrepreneur, and Full-stack developer, Constantly learning and experiencing new things. He currently runs CodeSource.io and Dunebook.com.
More about
Deven
↬
Building A Video Streaming App With Nuxt.js, Node And Express
Videos work with streams. This means that instead of sending the entire video at once, a video is sent as a set of smaller chunks that make up the full video. This explains why videos buffer when watching a video on slow broadband because it only plays the chunks it has received and tries to load more.
This article is for developers who are willing to learn a new technology by building an actual project: a video streaming app with Node.js as the backend and Nuxt.js as the client.
Prerequisities
Setting Up Our Application
In this application, we will build the routes to make requests from the frontend:
After our routes have been created, we’ll scaffold our Nuxt
frontend, where we’ll create the Home
and dynamic player
page. Then we request our videos
route to fill the home page with the video data, another request to stream the videos on our player
page, and finally a request to serve the caption files to be used by the videos.
To set up our application, we create our project directory,
Setting Up Our Server
In our streaming-app
directory, we create a folder named backend
.
In our backend folder, we initialize a package.json
file to store information about our server project.
we need to install the following packages to build our app.
In our backend directory, we create a folder assets
to hold our videos for streaming.
Copy a .mp4
file into the assets folder, and name it video1
. You can use .mp4
short sample videos that can be found on Github Repo.
Create an app.js
file and add the necessary packages for our app.
The fs
module is used to read and write into files easily on our server, while the path
module provides a way of working with directories and file paths.
Now we create a ./video
route. When requested, it will send a video file back to the client.
This route serves the video1.mp4
video file when requested. We then listen to our server at port 3000
.
A script is added in the package.json
file to start our server using nodemon.
Then on your terminal run:
If you see the message Listening on port 3000!
in the terminal, then the server is working correctly. Navigate to http://localhost:5000/video in your browser and you should see the video playing.
Requests To Be Handled By The Frontend
Below are the requests that we will make to the backend from our frontend that we need the server to handle.
Let’s create the routes.
Return Mockup Data For List Of Videos
For this demo application, we’ll create an array of objects that will hold the metadata and send that to the frontend when requested. In a real application, you would probably be reading the data from a database, which would then be used to generate an array like this. For simplicity’s sake, we won’t be doing that in this tutorial.
In our backend folder create a file mockdata.js
and populate it with metadata for our list of videos.
const allVideos = [
{
id: "tom and jerry",
poster: 'https://image.tmdb.org/t/p/w500/fev8UFNFFYsD5q7AcYS8LyTzqwl.jpg',
duration: '3 mins',
name: 'Tom & Jerry'
},
{
id: "soul",
poster: 'https://image.tmdb.org/t/p/w500/kf456ZqeC45XTvo6W9pW5clYKfQ.jpg',
duration: '4 mins',
name: 'Soul'
},
{
id: "outside the wire",
poster: 'https://image.tmdb.org/t/p/w500/lOSdUkGQmbAl5JQ3QoHqBZUbZhC.jpg',
duration: '2 mins',
name: 'Outside the wire'
},
];
module.exports = allVideos
We can see from above, each object contains information about the video. Notice the poster
attribute which contains the link to a poster image of the video.
Let’s create a videos
route since all our request to be made by the frontend is prepended with /videos
.
To do this, let’s create a routes
folder and add a Video.js
file for our /videos
route. In this file, we’ll require express
and use the express router to create our route.
When we go to the /videos
route, we want to get our list of videos, so let’s require the mockData.js
file into our Video.js
file and make our request.
The /videos
route is now declared, save the file and it should automatically restart the server. Once it’s started, navigate to http://localhost:3000/videos and our array is returned in JSON format.
Return Data For A Single Video
We want to be able to make a request for a particular video in our list of videos. We can fetch a particular video data in our array by using the id
we gave it. Let’s make a request, still in our Video.js
file.
The code above gets the id
from the route parameters and converts it to an integer. Then we send the object that matches the id
from the videos
array back to the client.
Streaming The Videos
In our app.js
file, we created a /video
route that serves a video to the client. We want this endpoint to send smaller chunks of the video, instead of serving an entire video file on request.
We want to be able to dynamically serve one of the three videos that are in the allVideos
array, and stream the videos in chunks, so:
Delete the /video
route from app.js
.
We need three videos, so copy the example videos from the tutorial’s source code into the assets/
directory of your server
project. Make sure the filenames for the videos are corresponding to the id
in the videos
array:
Back in our Video.js
file, create the route for streaming videos.
router.get('/video/:id', (req, res) => {
const videoPath = `assets/${req.params.id}.mp4`;
const videoStat = fs.statSync(videoPath);
const fileSize = videoStat.size;
const videoRange = req.headers.range;
if (videoRange) {
const parts = videoRange.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1]
? parseInt(parts[1], 10)
: fileSize-1;
const chunksize = (end-start) + 1;
const file = fs.createReadStream(videoPath, {start, end});
const head = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': 'video/mp4',
};
res.writeHead(206, head);
file.pipe(res);
} else {
const head = {
'Content-Length': fileSize,
'Content-Type': 'video/mp4',
};
res.writeHead(200, head);
fs.createReadStream(videoPath).pipe(res);
}
});
If we navigate to http://localhost:5000/videos/video/outside-the-wire in our browser, we can see the video streaming.
How The Streaming Video Route Works
There is a fair bit of code written in our stream video route, so let’s look at it line by line.
First, from our request, we get the id
from the route using req.params.id
and use it to generate the videoPath
to the video. We then read the fileSize
using the file system fs
we imported. For videos, a user’s browser will send a range
parameter in the request. This lets the server know which chunk of the video to send back to the client.
Some browsers send a range in the initial request, but others don’t. For those that don’t, or if for any other reason the browser doesn’t send a range, we handle that in the else
block. This code gets the file size and send the first few chunks of the video:
We will handle subsequent requests including the range in an if
block.
if (videoRange) {
const parts = videoRange.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1]
? parseInt(parts[1], 10)
: fileSize-1;
const chunksize = (end-start) + 1;
const file = fs.createReadStream(videoPath, {start, end});
const head = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': 'video/mp4',
};
res.writeHead(206, head);
file.pipe(res);
}
This code above creates a read stream using the start
and end
values of the range. Set the Content-Length
of the response headers to the chunk size that is calculated from the start
and end
values. We also use HTTP code 206, signifying that the response contains partial content. This means the browser will keep making requests until it has fetched all chunks of the video.
What Happens On Unstable Connections
If the user is on a slow connection, the network stream will signal it by requesting that the I/O source pauses until the client is ready for more data. This is known as back-pressure. We can take this example one step further and see how easy it is to extend the stream. We can easily add compression, too!
const start = parseInt(parts[0], 10);
const end = parts[1]
? parseInt(parts[1], 10)
: fileSize-1;
const chunksize = (end-start) + 1;
const file = fs.createReadStream(videoPath, {start, end});
We can see above that a ReadStream
is created and serves the video chunk by chunk.
const head = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': 'video/mp4',
};
res.writeHead(206, head);
file.pipe(res);
The request header contains the Content-Range
, which is the start and end changing to get the next chunk of video to stream to the frontend, the content-length
is the chunk of video sent. We also specify the type of content we are streaming which is mp4
. The writehead of 206 is set to respond with only newly made streams.
Creating A Caption File For Our Videos
This is what a .vtt
caption file looks like.
Captions files contain text for what is said in a video. It also contains time codes for when each line of text should be displayed. We want our videos to have captions, and we won’t be creating our own caption file for this tutorial, so you can head over to the captions folder in the assets
directory in the repo and download the captions.
Let’s create a new route that will handle the caption request:
router.get('/video/:id/caption', (req, res) => res.sendFile(`assets/captions/${req.params.id}.vtt`, { root: __dirname }));
Building Our Frontend
To get started on the visual part of our system, we would have to build out our frontend scaffold.
Note: You need vue-cli to create our app. If you don’t have it installed on your computer, you can run npm install -g @vue/cli
to install it.
At the root of our project, let’s create our front-end folder:
and in it, we initialize our package.json
file, copy and paste the following in it:
then install nuxt
:
and execute the following command to run Nuxt.js app:
Our Nuxt File Structure
Now that we have Nuxt installed, we can begin laying out our frontend.
First, we need to create a layouts
folder at the root of our app. This folder defines the layout of the app, no matter the page we navigate to. Things like our navigation bar and footer are found here. In the frontend folder, we create default.vue
for our default layout when we start our frontend app.
Then a components
folder to create all our components. We will be needing only two components, NavBar
and video
component. So in our root folder of frontend we:
Finally, a pages folder where all our pages like home
and about
can be created. The two pages we need in this app, are the home
page displaying all our videos and video information and a dynamic player page that routes to the video we click on.
Our frontend directory now looks like this:
Navbar Component
Our NavBar.vue
looks like this:
The NavBar
has a h1
tag that displays Streaming App, with some little styling.
Let’s import the NavBar
into our default.vue
layout.
The default.vue
layout now contains our NavBar
component and the <nuxt />
tag after it signifies where any page we create will be displayed.
In our index.vue
(which is our homepage), let’s make a request to http://localhost:5000/videos
to get all the videos from our server. Passing the data as a prop to our video.vue
component we will create later. But for now, we have already imported it.
Video Component
Below, we first declare our prop. Since the video data is now available in the component, using Vue’s v-for
we iterate on all the data received and for each one, we display the information. We can use the v-for
directive to loop through the data and display it as a list. Some basic styling has also been added.
We also notice that the NuxtLink
has a dynamic route, that is routing to the /player/video.id
.
The functionality we want is when a user clicks on any of the videos, it starts streaming. To achieve this, we make use of the dynamic nature of the _name.vue
route.
In it, we create a video player and set the source to our endpoint for streaming the video, but we dynamically append which video to play to our endpoint with the help of this.$route.params.name
that captures which parameter the link received.
<template>
<div class="player">
<video controls muted autoPlay>
<source :src="`http://localhost:5000/videos/video/${vidName}`" type="video/mp4">
</video>
</div>
</template>
<script>
export default {
data() {
return {
vidName: ''
}
},
mounted(){
this.vidName = this.$route.params.name
}
}
</script>
<style scoped>
.player {
display: flex;
justify-content: center;
align-items: center;
margin-top: 2em;
}
</style>
When we click on any of the video we get:
Adding Our Caption File
To add our track file, we make sure all the .vtt
files in the captions folder have the same name as our id
. Update our video element with the track, making a request for the captions.
<template>
<div class="player">
<video controls muted autoPlay crossOrigin="anonymous">
<source :src="`http://localhost:5000/videos/video/${vidName}`" type="video/mp4">
<track label="English" kind="captions" srcLang="en" :src="`http://localhost:5000/videos/video/${vidName}/caption`" default>
</video>
</div>
</template>
We’ve added crossOrigin="anonymous"
to the video element; otherwise, the request for captions will fail. Now refresh and you’ll see captions have been added successfully.
What To Keep In Mind When Building Resilient Video Streaming.
When building streaming applications like Twitch, Hulu or Netflix, there are a number of things that are put into consideration:
In this tutorial, we have seen how to create a server in Node.js that streams videos, generates captions for those videos, and serves metadata of the videos. We’ve also seen how to use Nuxt.js on the frontend to consume the endpoints and the data generated by the server.
Unlike other frameworks, building an application with Nuxt.js and Express.js is quite easy and fast. The cool part about Nuxt.js is the way it manages your routes and makes you structure your apps better.
Resources
This content was originally published here.