Recording video interviews using NuxtJS

Eugene Musebe

Objective

Since the pandemic began, usage of in-person meetings has become less preferential. For interviews as well, we mostly shifted to video conference services such as Zoom calls. The challenge is that this can be difficult to schedule with over 50 candidates for a single position. Let us explore how we can create self-managed recorded video interviews for our candidates to do on their own.

Knowledge requirements

HTML, CSS, and JavaScript knowledge is essential to be able to follow along with this tutorial. Vue.Js knowledge would be a plus but is not a hard requirement to follow along.

Codesandbox

The completed project is available on Codesandbox.

You can find the full codebase on my Github

App Setup

Nuxt.Js is a Vue.Js framework that boosts productivity due to its simplicity. We will be using it to build our project.

To get started, ensure you have either Yarn or NPM v5.2+/v6.1+ installed. Open the terminal in your preferred working directory and run the following command

1yarn create nuxt-app nuxtjs-video-interviews
2# OR
3npx create-nuxt-app nuxtjs-video-interviews
4# OR
5npm init nuxt-app nuxtjs-video-interviews

A set of questions will help customize the installation. Here are the options we selected for our project:

Project name: nuxtjs-video-interviews Programming language: JavaScript Package manager: Yarn UI framework: Tailwind CSS Nuxt.js modules: N/A Linting tools: N/A Testing frameworks: None Rendering mode: Universal (SSR/SSG) Deployment target: Server (Node.js hosting) Development tools: N/A

Once setup is complete, you may now run the app. It will be accessible on https://localhost:3000.

nuxt/cloudinary setup

We will store the interview videos on Cloudinary, a powerful media platform with a comprehensive set of SDKs and APIs. To create an account, you may signup here.

nuxt/cloudinary is the recommended Nuxt.Js cloudinary plugin. Let's add @nuxtjs/cloudinary to our project:

1yarn add @nuxtjs/cloudinary
2# OR
3npm install @nuxtjs/cloudinary

Next, add @nuxtjs/cloudinary in the modules section of the nuxt.config.js file:

1// nuxt.config.js
2export default {
3 ...
4 modules:[
5 '@nuxtjs/cloudinary'
6 ]
7 ...
8}

Finally, add the cloudinary section in nuxt.config.js to configure our module.

1// nuxt.config.js
2export default {
3 ...
4 cloudinary:{
5 cloudName: process.env.NUXT_ENV_CLOUDINARY_CLOUD_NAME
6 }
7}

The cloudName is being obtained from the process' environmental variables, values we place in a separate file not included in our code repository. To set up our NUXT_ENV_CLOUDINARY_CLOUD_NAME, we will create a .env file and load our environmental variables there:

1touch .env

You can access your cloud name on your dashboard.

1<!-- env -->
2NUXT_ENV_CLOUDINARY_CLOUD_NAME=<your-cloudinary-cloud-name>

Setting up the interview question and submitting the form

The first thing we want to do is build the HTML for our questions and the submit form. Let's add the necessary code to the template section of our pages/index.vue file

1<!-- pages/index.vue -->
2<template>
3 <div class="m-20">
4 <h1>Submit your interview</h1>
5 <h2>For best experience, use either Firefox or Chome</h2>
6 <div v-for="(question,index) in questions" :key="index">
7 <h3>{{index+1}}. {{question.question}}</h3>
8 <div>
9 <button type="button">Record answer</button>
10 </div>
11 </div>
12 <div>
13 <form @submit.prevent="submit">
14 <div>
15 <div>
16 <input required v-model="interviewee" type="text" placeholder="Enter your name...">
17 </div>
18 <button type="submit">Submit interview</button>
19 </div>
20 </form>
21 </div>
22 </div>
23</template>

Let us now add the questions and the interview variable to our page state in the script section.

1<!-- pages/index.vue -->
2<script>
3export default {
4 data(){
5 return {
6 interviewee:null,
7 questions:[
8 {
9 question: "Tell me something about yourself.",
10 recording:false,
11 recorder:null,
12 recordedChunks:[],
13 answer:null,
14 uploading:false
15 },
16 {
17 question: "How did you hear about this position?",
18 recording:false,
19 recorder:null,
20 recordedChunks:[],
21 answer:null,
22 uploading:false
23 },
24 {
25 question: "Why do you want to work here?",
26 recording:false,
27 recorder:null,
28 recordedChunks:[],
29 answer:null,
30 uploading:false
31 },
32 ]
33 }
34 }
35}
36</script>

The above will now render the basic HTML needed for our app to run.

Recording the video

To record the video, we will be interacting with the Media Devices API. We initialize the video and the audio, store an instance of the recorder, pass the stream to a visible video element and store the recorded chunks.

When recording is stopped, we create a video file from the chunks and save it as the answer. The answer is rendered in a different visible video element. Let us add the HTML to support this.

1<!-- pages/index.vue -->
2<template>
3 <div>
4 ...
5 <div v-for="(question,index) in questions" :key="index">
6 <h3>{{index+1}}. {{question.question}}</h3>
7 <div class="mx-10">
8 <video v-if="!question.recording && question.answer" :src="question.answer" controls></video>
9 <video v-else-if="question.recording" :id="`player-${index}`" controls="false"></video>
10 <button v-if="!question.recording" @click="recordAnswer(index)" type="button">
11 {{question.answer ? 'Record again' : 'Record answer'}}
12 </button>
13 <button v-if="question.recording" @click="questions[index].recorder.stop()">
14 Stop Recording
15 </button>
16 </div>
17 </div>
18 ...
19 </div>
20</template>

Within the script section, we add the methods portion of the code.

1// pages/index.vue
2<script>
3export default {
4 data(){
5 return {
6 ...
7 questions:[
8 {
9 question: "Tell me something about yourself.",
10 recording:false,
11 recorder:null,
12 recordedChunks:[],
13 answer:null,
14 uploading:false
15 },
16 {
17 question: "How did you hear about this position?",
18 recording:false,
19 recorder:null,
20 recordedChunks:[],
21 answer:null,
22 uploading:false
23 },
24 {
25 question: "Why do you want to work here?",
26 recording:false,
27 recorder:null,
28 recordedChunks:[],
29 answer:null,
30 uploading:false
31 },
32 ]
33 }
34 },
35 methods:{
36 recordAnswer(index){
37 this.questions[index].recording = true;
38 navigator.mediaDevices.getUserMedia({ audio: true, video: true }).then(
39 stream => this.handleRecordingSuccess(index,stream)
40 );
41 },
42 handleRecordingSuccess(index,stream){
43 this.questions[index].recorder = new MediaRecorder(stream);
44 document.getElementById(`player-${index}`).srcObject = stream;
45 document.getElementById(`player-${index}`).play();
46 this.questions[index].recorder.addEventListener(
47 'dataavailable',
48 e => this.handleDataAvailable(index,e)
49 );
50
51 this.questions[index].recorder.addEventListener(
52 'stop',
53 () => this.handleRecordingStopped(index,stream)
54 );
55
56 this.questions[index].recorder.start();
57 },
58 handleDataAvailable(index, e){
59 if (e.data.size > 0) {
60 this.questions[index].recordedChunks.push(e.data);
61 }
62 },
63 handleRecordingStopped(index,stream){
64 stream.getTracks().forEach(track => track.stop());
65 this.questions[index].recording = false;
66 document.getElementById(`player-${index}`).pause();
67 document.getElementById(`player-${index}`).srcObject = null;
68 this.questions[index].answer = URL.createObjectURL(new Blob(this.questions[index].recordedChunks), {type: 'video/mp4'});
69 },
70 ...
71 }
72}
73</script>

To make our code simpler, we split the JavaScript logic across multiple methods. This is always recommended to make the code more maintainable.

Submitting the response

Once the recording is done and the answer has been saved, we can now submit the responses. Before we submit, we need to get the users' names. Let us create an HTML form for this purpose. When the form is submitting/uploading, we also want the users to view a friendly loader. Hence we add an SVG loader.

1<!-- pages/index.vue -->
2<template>
3 ...
4 <form @submit.prevent="submit">
5 <div>
6 <div>
7 <input required v-model="interviewee" type="text" placeholder="Enter your name...">
8 </div>
9 <button type="submit" :disabled="submitting">
10 <!-- Loader to show when uploading -->
11 <svg v-if="submitting" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
12 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
13 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
14 </svg>
15 <!-- End of loader -->
16 {{submitting ? 'Uploading' : 'Submit interview'}}
17 </button>
18 </div>
19 </form>
20 ...
21</template>

Once the form is submitted, we check if all the questions have been answered. If they have all been answered we will proceed to upload all the videos to a folder named after the interviewee. For easy reviewing, we add the question as a context variable to the upload.

The upload data has to be in Base64. This is why we use the blobToBase64 method to read the blob and return the data as Base64 encoded.

1// pages/index.vue
2<script>
3export default {
4 data(){
5 return {
6 interviewee:null,
7 submitting:false,
8 }
9 },
10 methods:{
11 blobToBase64(blob) {
12 return new Promise((resolve, _) => {
13 const reader = new FileReader();
14 reader.onloadend = () => resolve(reader.result);
15 reader.readAsDataURL(blob);
16 });
17 },
18 submit(){
19 if(this.questions.filter(question => question.answer === null).length){
20 alert("Some questions have not been answered. Answer all questions before submitting");
21 return;
22 }
23 this.submitting = true;
24 Promise.all(this.questions.map(async (question,index) => {
25 this.questions[index].uploading=true;
26 const blob = new Blob(question.recordedChunks);
27 const base64 = await this.blobToBase64(blob);
28 await this.$cloudinary.upload(
29 base64,
30 {
31 public_id: `Question-${index+1}`,
32 folder: `nuxtjs-video-interviews/${this.interviewee}`,
33 upload_preset: "default-preset",
34 context:`question=${question.question}`
35 }
36 );
37 this.questions[index].uploading=true;
38 })).then(() => {
39 this.submitting = false;
40 alert("Upload successful, thank you.");
41 }).catch(() => {
42 this.submitting = false;
43 alert("Upload failed. Please try again.");
44 });
45 }
46 }
47}
48</script>

Conclusion

Using the above code, we have been able to interview our app users and save their responses. To read more about how to use the Media Devices API feel free to review their docs. Also, check out Cloudinary's API documentation for more about how you can interact with their service.

Eugene Musebe

Software Developer

I’m a full-stack software developer, content creator, and tech community builder based in Nairobi, Kenya. I am addicted to learning new technologies and loves working with like-minded people.