We’re all familiar with how Youtube places Ads in videos with a Skip Ad button. In this tutorial, we shall be looking at how we can implement this in our own way using just Cloudinary for storage and Next.js
The completed project is available on Codesandbox.
As per the codesandbox FAQ’s you can only upload files with a maximum limit of 2 MBs. Hence we can only upload two files of 1 MB Each. You can use the following two videos for testing purposes. Sample video 1 Sample video 2
To perfectly test the application, you can find the full code on my Github repository. Clone it to your local machine and you can upload larger file sizes
In this tutorial we shall be using Next.js, a react framework. In the same, we shall also touch on Node.js. It goes without saying that working knowledge of Node.js and Javascript is required. Basic knowledge of React is also recommended. Ensure you have Node.js and NPM installed. You can check out the Node.js website for instructions on how to install. You also need a code editor. I recommend Visual Studio Code, however, any code editor works.
I mentioned that we shall be using Cloudinary. They have amazing APIs which handle media storage, manipulation, and optimization. Before we can use these APIs, we need a few credentials that will authorize our application to access the APIs. You can get started with a free developer account immediately.
Head over to Cloudinary and create a free account if you do not have one already. Once done, head over to the Console page and get your credentials.
This is all you need, for now, just note them down somewhere, and we’ll come back to them when we’re ready to use them.
It’s time to dive into the code. The first thing we need is to scaffold a new Next.js project. Let’s do that now. In your terminal/command line enter and run the following
npx create-next-app ads-in-video-with-cloudinary && cd ads-in-video-with-cloudinary
Code language: JavaScript (javascript)
This will create a new project named ads-in-video-with-cloudinary
and change the directory to the same. This is a basic Next project that allows us to get up and running easily. There’s a ton of other features you can add. Have a look at the Next.js website to learn more.
In this section, we’ll define the methods that will handle fetching videos from cloudinary, uploading, and deleting. First, we need to install the cloudinary Node.js SDK.
npm install -S cloudinary
Next, create a folder called lib
at the root of your project. This folder will hold our shared functions. Inside the lib
folder, create a new file named cloudinary.js
. Paste the following code inside
// lib/cloudinary.js
// Import the v2 api and rename it to cloudinary
import {
v2 as cloudinary,
UploadApiResponse,
TransformationOptions,
} from "cloudinary";
// Initialize the SDK with cloud_name, api_key, and api_secret
cloudinary.config({
cloud_name: process.env.CLOUD_NAME,
api_key: process.env.API_KEY,
api_secret: process.env.API_SECRET,
});
const FOLDER_NAME = "videos-with-ads/";
/**
*
* @param {string} path
* @param {TransformationOptions} transformation
* @returns {Promise<UploadApiResponse>}
*/
export const handleCloudinaryUpload = (path, transformation = []) => {
// Create and return a new Promise
return new Promise((resolve, reject) => {
// Use the SDK to upload media
cloudinary.uploader.upload(
path,
{
// Folder to store video in
folder: FOLDER_NAME,
// Type of resource
resource_type: "auto",
allowed_formats: ["mp4"],
transformation,
},
(error, result) => {
if (error) {
// Reject the promise with an error if any
return reject(error);
}
// Resolve the promise with a successful result
return resolve(result);
}
);
});
};
/**
*
* @param {string[]} ids
* @returns
*/
export const handleCloudinaryDelete = async (ids) => {
return new Promise((resolve, reject) => {
cloudinary.api.delete_resources(
ids,
{
resource_type: "video",
},
(error, result) => {
if (error) {
return reject(error);
}
return resolve(result);
}
);
});
};
Code language: JavaScript (javascript)
At the very top, we import the v2 cloudinary API and rename it to cloudinary. Just after that, we initialize it by calling the config
method and passing to it cloud_name
, api_key
, and api_secret
. To keep our keys secure, we’ve used environment variables. We’ll define these shortly.
Next, we have our folder name. We want to store all our videos in one folder. This can be whatever name you want, you don’t even have to store them inside any particular folder.
The handleCloudinaryUpload
function takes in a video path and an array of transformations. The function will handle the upload to cloudinary by calling the uploader.upload
method on the SDK. The uploader.upload
method requires a file path as the first argument and an options object as the second. Have a look at all the options available in the docs. The transformation option is of particular interest in this case. Here we can define what transformations we want to be run on our video. We’ll pass these transformations through the transformation parameter.
The handleCloudinaryDelete
function takes in an array of public IDs. It will call the api.delete_resources
method on the cloudinary SDK and pass the IDs. The resources(videos) with the IDs passed will be deleted.
The final thing for this file is to define the environment variables that we used. Next.js comes with support for environment variables built-in as documented in the docs. Create a file at the root of your project called .env.local
. Paste the following inside.
CLOUD_NAME=YOUR_CLOUD_NAME
API_KEY=YOUR_API_KEY
API_SECRET=YOUR_API_SECRET
Make sure to replace YOUR_CLOUD_NAME
, YOUR_API_KEY
, and YOUR_API_SECRET
with the appropriate keys from the cloudinary credentials that we got from the Cloudinary API Keys
section. Remember the ones I said we’ll use later.
We’re done with this file.
We need a way to keep track of the resources(videos) that we upload to cloudinary. The database is really up to your specific needs. It could be production-ready relational/non-relational DBs, memory caches, e.t.c. For the sake of brevity and simplicity of this tutorial, we’ll use a simple flat file database. Please keep in mind that these kind of DBs are not suitable for production. We’re only opting for one because this is a simple tutorial. You might want to look at something like Mongo DB, Postgres, or something similar. We’ll be using Nedb, an embedded persistent or in-memory database for Node.js. Let’s first install the dependencies.
npm install -S nedb
Create a new file called database.js
under the lib/
folder. Paste the following code inside.
// lib/database.js
import { join } from "path";
import Datastore from "nedb";
// Custom database class using nedb, a simple flat-file database
class Database {
constructor() {
this.db = {
// Create a new datastore for videos
videos: new Datastore({
filename: join("data", "videos.db"),
autoload: true,
}),
};
}
// This method queries the database for videos
getVideos() {
return new Promise((resolve, reject) => {
this.db.videos.find().exec((err, videos) => {
if (err) {
reject(err);
return;
}
resolve(videos);
});
});
}
// This method adds a new video to the videos datastore
addNewVideo(video) {
return new Promise((resolve, reject) => {
this.db.videos.insert(video, (err, newDoc) => {
if (err) {
reject(err);
return;
}
resolve(newDoc);
});
});
}
// This method gets a video from the database using the video public id
getVideo(videoId) {
return new Promise((resolve, reject) => {
this.db.videos.findOne({ _id: videoId }, (err, video) => {
if (err) {
reject(err);
return;
}
resolve(video);
});
});
}
// This method deletes a video from the database using the video id
deleteVideo(videoId) {
return new Promise((resolve, reject) => {
this.db.videos.remove({ _id: videoId }, {}, (err, numRemoved) => {
if (err) {
reject(err);
return;
}
resolve(numRemoved);
});
});
}
}
// Create a new instance of the database class and export it as a singleton
export const database = new Database();
Code language: JavaScript (javascript)
This is a class that implements a few simple methods to handle our database operations. We have a class called Database
. In the constructor, we initialize our DB and define a collection/datastore called videos
. This will create a file called videos.db
under a data
folder. Create a folder called data
at the root of your project. I’ll not go into the specifics of creating and initializing the database. Have a look at the documentation for that. Nedb uses a syntax that is very similar to that of MongoDB to find, insert and delete documents. We define a few methods getVideos
, addNewVideo
, getVideo
and deleteVideo
. The names explain the function fairly well. To better understand the syntax used in each of the methods, have a look at the documentation on inserting documents,finding documents and removing documents. We then use the singleton pattern to create an instance of the Database class.
Next.js has built-in support for API routes, see here. It’s important to grasp a few concepts here, so take some time to go over the docs if you’re not familiar.
We want to create an endpoint that matches api/videos
and api/videos/:id
. Create a folder called videos
under the pages/api
folder. Inside the pages/api/videos
folder, create two files, one named index.js
and another called [id].js
. If you’re not familiar, the former will allow us to handle requests to api/videos
and the latter to api/videos/:id
. Read more about the latter here. This is why I mentioned that you need to go over the documentation to understand a few things.
Open pages/api/videos/index.js
and paste the following code inside
// pages/api/videos/index.js
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { IncomingForm, Fields, Files } from "formidable";
import { handleCloudinaryUpload } from "../../../lib/cloudinary";
import { database } from "../../../lib/database";
// Custom config for our API route
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(req, res) {
switch (req.method) {
case "GET": {
try {
const result = await handleGetRequest();
return res.status(200).json({ message: "Success", result });
} catch (error) {
return res.status(400).json({ message: "Error", error });
}
}
case "POST": {
try {
const result = await handlePostRequest(req);
return res.status(200).json({ message: "Success", result });
} catch (error) {
return res.status(400).json({ message: "Error", error });
}
}
default: {
return res.status(405).json({ message: "Method not allowed" });
}
}
}
const handleGetRequest = () => database.getVideos();
const handlePostRequest = async (req) => {
// Get the form data using the parseForm function
const data = await parseForm(req);
// Get the main video file
const video = data.files.video;
// Get the ad video file
const adVideo = data.files.adVideo;
// Upload the main video file to Cloudinary
const videoUploadResult = await handleCloudinaryUpload(video.path);
// Get the main video's midpoint
const videoMidPoint = Math.round(videoUploadResult.duration / 2);
// Upload the ad video file to Cloudinary
const adVideoUploadResult = await handleCloudinaryUpload(adVideo.path, [
{
background: "black",
aspect_ratio: `${videoUploadResult.width / videoUploadResult.height}`,
crop: "lpad",
},
{ effect: "progressbar:frame:FF0000:12" },
]);
// Add the main video and ad video to the database
const result = await database.addNewVideo({
video: videoUploadResult,
adVideo: adVideoUploadResult,
adPlacement: videoMidPoint,
});
return result;
};
/**
*
* @param {*} req
* @returns {Promise<{ fields:Fields; files:Files; }>}
*/
const parseForm = (req) => {
return new Promise((resolve, reject) => {
const form = new IncomingForm({ keepExtensions: true, multiples: true });
form.parse(req, (error, fields, files) => {
if (error) {
return reject(error);
}
return resolve({ fields, files });
});
});
};
Code language: PHP (php)
At the top, we import a few things from a package called formidable
. We’ll install this shortly. We also import the functions we created earlier from lib/cloudinary.js
and the database instance from lib/database.js
. After the imports, we export a custom config object for our API route. This basically tells Next.js that we’ll be using our own body-parser and not to use the default. Read more about custom configs for API routes here. Next comes our handler function. We use a switch statement to handle only the GET and POST requests. Other requests will just fail with a status code of 405 – Method not allowed. The handleGetRequest
method will just fetch all the videos saved in our custom database. The handlePostRequest
method takes in the incoming HTTP request. It then delegates parsing of the form to a function called parseForm
. After the form data is parsed, we get the main video file and the ad video file. We first upload the main video file to cloudinary without any transformations applied to it. We then get the midpoint of the duration of the uploaded video. This is where we will place our ad. Remember that you can place the ad at whichever point you want but for the simplicity of this, we’re just going to place it in the middle. We also round it to the nearest integer. Next, we upload the ad video file to cloudinary. This time we need to apply a few transformations. First, we need the ad video to be the same aspect ratio as the main video so that we don’t see a lot of jank in the front end as we switch between the two videos. For that, we pass the following transformation.
{
background: "black",
aspect_ratio: `${videoUploadResult.width / videoUploadResult.height}`,
crop: "lpad",
}
Code language: CSS (css)
We’re telling Cloudinary to make the video fit into the aspect ratio of the main video but instead of stretching it to fill, we want to apply some padding to the extra space. The next transformation we want is to add a progress bar that shows the progress of the ad. For this, we pass the following
{ effect: "progressbar:frame:FF0000:12" }
Code language: CSS (css)
Read about these transformations from the video transformations documentation. Next, we save the main video, ad video, and ad placement time to our custom DB.
The parseForm
function used formidable to parse our form data.
Let’s add the missing dependency. We use a library called Formidable that will parse the incoming form data for us and get the uploaded files. You can think of this like Multer. Let’s install it.
npm install -S formidable
Let’s move on to the other file. Paste the following inside pages/api/videos/[id].js
.
// pages/api/videos/[id].js
import { handleCloudinaryDelete } from "../../../lib/cloudinary";
import { database } from "../../../lib/database";
export default async function handler(req, res) {
const { id } = req.query;
switch (req.method) {
case "GET": {
try {
if (!id) {
throw "id param is required";
}
const result = await handleGetRequest(id);
return res.status(200).json({ message: "Success", result });
} catch (error) {
return res.status(400).json({ message: "Error", error });
}
}
case "DELETE": {
try {
if (!id) {
throw "id param is required";
}
await handleDeleteRequest(id);
return res.status(200).json({ message: "Success" });
} catch (error) {
return res.status(400).json({ message: "Error", error });
}
}
default: {
return res.status(405).json({ message: "Method not allowed" });
}
}
}
const handleGetRequest = (id) => database.getVideo(id);
const handleDeleteRequest = async (id) => {
const video = await database.getVideo(id);
await handleCloudinaryDelete([
video.video.public_id,
video.adVideo.public_id,
]);
await database.deleteVideo(id);
};
Code language: JavaScript (javascript)
Nothing fancy here. We have the same handler function as before that will only handle the GET and DELETE HTTP requests. The only unique thing is that we’re passing a param called id
in the url and we get that from the incoming request object.
The handleGetRequest
gets a video from the database that has the id passed in the params. handleDeleteRequest
deletes the same from cloudinary then from the database. This is for our backend.
We’ll have a simple frontend where we can upload videos, view all videos and play each video. Let’s first create a layout/partial component. This component will wrap all our pages so that we have a unified layout across the board. It also minimizes code repetition. Create a folder at the root called components
. Create a file called Layout.js
inside the components/
folder. Paste the following code inside
import Head from "next/head";
import Link from "next/link";
export default function Layout({ children }) {
return (
<div>
<Head>
<title>Ads in video with cloudinary</title>
<meta name="description" content="Ads in video with cloudinary" />
<link rel="icon" href="/favicon.ico" />
</Head>
<nav>
<div className="title">
<h1>Ads in video with cloudinary</h1>
</div>
<ul className="links">
<li>
<Link href="/">Home</Link>
</li>
<li>
<Link href="/videos">Videos</Link>
</li>
</ul>
</nav>
<main>{children}</main>
<style jsx>{`
nav {
height: 100px;
background-color: #f5f5f5;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
}
nav div.title {
margin-left: 50px;
}
nav ul {
margin-right: 50px;
display: flex;
list-style: none;
}
nav ul li {
font-weight: bold;
margin: 0 10px;
}
nav ul li:hover {
color: #6f00ff;
}
main {
height: calc(100vh - 100px);
}
`}</style>
</div>
);
}
Code language: JavaScript (javascript)
This is just basic React.js.
Pro Tip: You can change the extension of your frontend files from
.js
to.jsx
for better code completion and IntelliSense.
Next, create a file called index.js
inside the pages
folder. Paste the following inside.
// pages/index.js
import { useRouter } from "next/router";
import Link from "next/link";
import { useState } from "react";
import Layout from "../components/Layout";
export default function Home() {
// Get the router from Next.js
const router = useRouter();
// State for the main video input
const [videoFile, setVideoFile] = useState(null);
// State for the ad video input
const [adFile, setAdFile] = useState(null);
// Loading state
const [loading, setLoading] = useState(false);
const handleFormSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
// Get the form data
const formData = new FormData(e.target);
// Post the form data to the /api/videos endpoint
const response = await fetch("/api/videos", {
method: "POST",
body: formData,
});
const data = await response.json();
if (!response.ok) {
throw data;
}
// Navigate to the videos page
router.push("/videos");
} catch (error) {
// TODO: Show error message to the user
console.error(error);
} finally {
setLoading(false);
}
};
return (
<Layout>
<div className="wrapper">
<h1>Upload a video + Ad</h1>
<hr />
<form onSubmit={handleFormSubmit}>
<label htmlFor="video">
<b>Select a video for upload</b>
</label>
<input
type="file"
name="video"
id="video"
multiple={false}
accept=".mp4"
required
disabled={loading}
onChange={(e) => {
setVideoFile(e.target.files[0]);
}}
/>
<hr />
<label htmlFor="video">
<b>
Select a short video ad that will be placed in the middle of your
video
</b>
</label>
<input
type="file"
name="adVideo"
id="adVideo"
multiple={false}
accept=".mp4"
required
disabled={loading}
onChange={(e) => {
setAdFile(e.target.files[0]);
}}
/>
<button type="submit" disabled={loading || !videoFile || !adFile}>
Upload
</button>
</form>
<hr />
<Link href="/videos" passHref>
<button>View Uploaded Videos</button>
</Link>
</div>
<style jsx>{`
div.wrapper {
background-color: #ffffff;
height: 100%;
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
}
div.wrapper > hr {
min-width: 600px;
}
div.wrapper form {
min-width: 600px;
min-height: 300px;
padding: 20px;
background-color: #f5f5f5;
display: flex;
flex-flow: column;
justify-content: center;
align-items: flex-start;
border-radius: 5px;
}
div.wrapper form hr {
width: 100%;
}
div.wrapper form button {
padding: 20px;
min-width: 200px;
border: none;
background-color: #7700ff;
color: white;
font-weight: bold;
margin-top: 20px;
border-radius: 5px;
}
div.wrapper form button:disabled {
background-color: #cccccc;
}
div.wrapper form button:hover:not([disabled]) {
background-color: #ff0095;
}
div.wrapper > button {
padding: 20px;
min-width: 200px;
border: none;
background-color: #7700ff;
color: white;
font-weight: bold;
margin-top: 20px;
border-radius: 5px;
}
div.wrapper > button:hover {
background-color: #ff0095;
}
`}</style>
</Layout>
);
}
Code language: JavaScript (javascript)
This will be our upload page. As I mentioned earlier, basic knowledge of React.js is recommended. We’re using different React hooks here for our state. Read about React hooks from the official docs and the useState
hook here. The other hook we’re using is the useRouter
hook from Next.js. Read about this here. The handleFormSubmit
function just handles the form submission. It gets the form data(main video and ad video) and posts that to the api/videos
endpoint that we created earlier. It then navigates the user to the videos page that we will create shortly. For the HTML, we just have our Layout component wrapping a form that has two inputs, one for the main video and another for the ad video.
Next, create a folder called videos
under pages/
. Remember that this is different from the one inside pages/api/
. Again, just like dynamic API routes, we also have dynamic URLs. Read more about those here. Create a new file called index.js
inside the pages/videos
folder. Please note that this is not the same file that we have in pages/
or pages/api/videos
. Paste the following inside pages/videos/index.js
// pages/videos/index.js
import { useCallback, useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import Layout from "../../components/Layout";
export default function Videos() {
// State for the videos
const [videos, setVideos] = useState([]);
// Loadin state
const [loading, setLoading] = useState(false);
// Memoized function to Fetch the videos
const getVideos = useCallback(async () => {
try {
// Make a GET request to the /api/videos endpoint
const response = await fetch("/api/videos", {
method: "GET",
});
const data = await response.json();
if (!response.ok) {
throw data;
}
setVideos(data.result);
} catch (error) {
// TODO: Show error message to the user
console.error(error);
}
}, []);
// Fetch the videos on component mount
useEffect(() => {
getVideos();
}, [getVideos]);
const handleDeleteVideo = async (id) => {
try {
setLoading(true);
// Make a DELETE request to the /api/videos/:id endpoint
const response = await fetch(`/api/videos/${id}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) {
throw data;
}
// Refresh the videos
getVideos();
} catch (error) {
// TODO: Show error message to the user
console.error(error);
} finally {
setLoading(false);
}
};
return (
<Layout>
{videos.length ? (
<div className="wrapper">
{videos.map((video, index) => (
<div className="video" key={`video-${index}`}>
<Link href={`/videos/${video._id}`} passHref>
<div className="thumbnail">
<Image
className="thumbnail-image"
layout="fill"
src={video.video.secure_url.replace(".mp4", ".gif")}
alt={video.video.secure_url}
></Image>
<div className="controls">Click to play</div>
</div>
</Link>
<div className="video-info">
<p>{video.video.original_filename}</p>
<a>
<Link href={video.video.secure_url}>
{video.video.secure_url}
</Link>
</a>
<button
disabled={loading}
onClick={() => {
handleDeleteVideo(video._id);
}}
>
Delete
</button>
</div>
</div>
))}
</div>
) : (
<div className="no-videos">
<p>No videos yet</p>
<Link href="/" passHref>
<button>Upload videos</button>
</Link>
</div>
)}
<style jsx>{`
div.wrapper {
padding: 10px;
background-color: #ffffff;
width: 100%;
height: 100%;
overflow-y: auto;
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-items: flex-start;
gap: 10px;
}
div.wrapper div.video {
background-color: #ffffff;
flex: 0 0 400px;
height: 320px;
box-shadow: 0 0 1px rgba(34, 25, 25, 0.4);
overflow: hidden;
}
div.wrapper div.video:hover {
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15);
}
@media only screen and (max-width: 600px) {
div.wrapper div.video {
flex: 1 0 500px;
height: 400px;
}
}
div.wrapper div.video div.thumbnail {
position: relative;
width: 100%;
height: 70%;
cursor: pointer;
}
div.wrapper div.video div.thumbnail .thumbnail-image {
width: 100%;
display: none;
}
div.wrapper div.video div.controls {
position: absolute;
top: 0;
right: 0;
height: 100%;
width: 100%;
background-color: rgba(0, 0, 0, 0.4);
display: flex;
justify-content: center;
align-items: center;
color: #ffffff;
font-weight: bold;
font-size: 1em;
}
div.wrapper div.video div.video-info {
width: 100%;
height: 30%;
padding: 10px;
overflow: hidden;
}
div.wrapper div.video div.video-info p {
margin: 5px 0;
}
div.wrapper div.video div.video-info a {
white-space: nowrap;
overflow-y: auto;
text-overflow: ellipsis;
}
div.no-videos {
background-color: #ffffff;
width: 100%;
height: 100%;
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
}
`}</style>
</Layout>
);
}
Code language: JavaScript (javascript)
On this page, we show all the videos that we have uploaded. We have the videos state that will hold our videos and the loading state. We then have a memoized callback function that will make a GET request to the /api/videos
endpoint and get all our videos. We’re using the useCallback
hook to create a memoized function. Read about this here. Right after that is the useEffect
hook. This is used to run side effects in our component. In this case, we want to get all videos when the component mounts. Read about this hook here. We then have a handleDeleteVideo
function that takes in a video ID then makes a DELETE request to the /api/videos/:id
endpoint. For the HTML we just have our Layout component wrapping all the videos’ thumbnails. Here we take the video’s url and change the extension from .mp4
to .gif
. We then use this to show a thumbnail of the video. Notice how we’re using the Image
component from Next.js. This allows us to optimize the images we use in our application. One of these optimizations requires us to define the different domains that we’ll be getting external images from. Read more about it in the official docs. Create a file called next.config.js
in the root of your project if you don’t already have one and paste the following code inside.
module.exports = {
// ... other code that was there
images: {
domains: ["res.cloudinary.com"],
},
};
Code language: JavaScript (javascript)
Clicking on one of the video’s thumbnail will navigate you to the /videos/:id
URL. Let’s create that page now. Create a file called [id].js
under pages/videos
. Paste the following inside pages/videos/[id].js
.
// pages/videos/[id].js
import { useRouter } from "next/router";
import {
useCallback,
useEffect,
useRef,
useState,
MutableRefObject,
SyntheticEvent,
} from "react";
import Layout from "../../components/Layout";
export default function Video() {
// Get the router
const router = useRouter();
// Get the video id from the url
const { id } = router.query;
// State to store our video
const [video, setVideo] = useState(null);
/**
* Stores a reference to our video element
* @type {MutableRefObject<HTMLVideoElement>}
*/
const videoRef = useRef(null);
/**
* Stores a reference to our ad video element
* @type {MutableRefObject<HTMLVideoElement>}
*/
const adVideoRef = useRef(null);
/**
* Stores a reference to our skip button
* @type {MutableRefObject<HTMLButtonElement>}
*/
const skipButtonRef = useRef(null);
const getVideo = useCallback(async () => {
try {
// Post the form data to the /api/videos/:id endpoint
const response = await fetch(`/api/videos/${id}`, {
method: "GET",
});
const data = await response.json();
if (!response.ok) {
throw data;
}
setVideo(data.result);
} catch (error) {
// TODO: Show error message to user
console.error(error);
}
}, [id]);
// Get videos on component mount
useEffect(() => {
getVideo();
}, [getVideo]);
/**
* This function throttles our event listener to prevent it from firing too often
* @param {Function} func Callback function to execute
* @param {number} delay Delay in milliseconds
* @returns {Function}
*/
const throttle = (func, delay = 800) => {
// Previously called time of the function
let prev = 0;
return (...args) => {
// Current called the time of the function
let now = new Date().getTime();
// If the difference is greater than delay call
// the function again.
if (now - prev > delay) {
prev = now;
// "..." is the spread operator here
// returning the function with the
// array of arguments
return func(...args);
}
};
};
/**
* This function is called when the main video playback progress changes
* @param {SyntheticEvent<HTMLVideoElement, Event>} ev
*/
const onVideoTimeUpdate = (ev) => {
const { currentTime } = ev.target;
// Check if the video's current time matches the ad's beginning time
if (
Math.round(currentTime).toFixed(2) ===
Math.round(video.adPlacement).toFixed(2)
) {
console.log("Play ad");
// Pause the main video
videoRef.current.pause();
// Show the ad video element on top of the main video
adVideoRef.current.style.display = "block";
// Show the skip ad button
skipButtonRef.current.style.display = "block";
// Play the ad video
adVideoRef.current.play();
}
};
/**
* This function is called when the skip ad button is clicked
*/
const skipAd = () => {
// Make sure the ad video is paused
adVideoRef.current.pause();
// Hide the ad video element
adVideoRef.current.style.display = "none";
// Hide the skip ad button
skipButtonRef.current.style.display = "none";
// Increase the main video's current time by one second to prevent the ad from playing twice
videoRef.current.currentTime += 1;
// Play the main video
videoRef.current.play();
};
return (
<Layout>
<div className="wrapper">
{video ? (
<div className="video-wrapper">
<video
ref={videoRef}
id="video"
src={video.video.secure_url}
preload="auto"
controls
onTimeUpdate={throttle(onVideoTimeUpdate)}
></video>
<video
ref={adVideoRef}
id="adVideo"
src={video.adVideo.secure_url}
preload="auto"
controls
onEnded={() => {
console.log("Ad ended");
skipAd();
}}
></video>
<button className="skip" ref={skipButtonRef} onClick={skipAd}>
SKIP AD
</button>
</div>
) : (
<div className="loading">Loading...</div>
)}
</div>
<style jsx>{`
.wrapper {
background-color: #ffffff;
height: 100%;
width: 100%;
}
.video-wrapper {
position: relative;
width: 80%;
margin: 20px auto;
background-color: #ffffff;
}
.video-wrapper video {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-wrapper video#adVideo {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: none;
}
.video-wrapper button.skip {
position: absolute;
bottom: 100px;
right: 20px;
padding: 15px;
min-width: 100px;
font-weight: bold;
border: none;
background-color: #ffffff7c;
display: none;
}
.video-wrapper button.skip:hover {
background-color: #ffffff;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
width: 80%;
height: 80%;
margin: 20px auto;
background-color: #f3f3f3;
}
`}</style>
</Layout>
);
}
Code language: JavaScript (javascript)
Here we have the same hooks from before save for the useRef
hook. This hook allows us to store a reference to any HTML element. Read more about it here. We extract the id param from the router object. Then we have references to our main video element, our ad video element, and our skip button element. We then have a memoized callback function that makes a GET request to the api/videos/:id
endpoint to get the video with the id we get from the URL params. We have an onVideoTimeUpdate
function that will be called when the main video playback progress changes. Inside this function, we want to get the current main video time and check if it matches the ad placement time. If it does, we want to pause the main video, then show the ad video and play it. Now we don’t want the onVideoTimeUpdate
function to be called too often, so we create a throttle function that will delay the calls. The calls will be instead made after every 800 milliseconds or however long you want. I just found 800ms to be optimal for our use case, and also it ensures that the function is called at least once for every second. We also have a skipAd
function that makes sure the ad video is paused/ended, hides the ad video element, hides the skip button, increments the main video’s current playback time by a second then unpauses the main video. For the HTML, we just have our main video and then our ad video placed on top of the main video(on the Z-axis). We accomplish this using CSS’ absolute position. The ad video element is not shown until the main video gets to the ad placement time.
Our simple application is complete. The folder structure inside pages/
may be a bit confusing. Have a look at the following to make it clear.
-
pages/
-
api/
-
videos/
-
[id].js
-
index.js
-
videos/
-
[id].js
-
index.js
-
_app,js
-
index.js
And that’s it. You’re ready to run your application. Open your terminal and run the following
npm run dev
This will run in development mode. I won’t go over-optimizing for a production build. You can check that in the Next.js docs. Please remember that this is a simple implementation. For production-level, you want to look at things such as lazy loading videos, API authentication/authorization, performant databases, Cloudinary’s long-running operations, e.t.c.
Cheers 🥂 to you for making it to the end. You can find the full code on my Github
🥳🥳🥳