Posted on Leave a comment

Adding Livestreaming To The Screen Recorder Using The YouTube Data V3 API

Adding YouTube API To The Screen Recorder

So I wanted to add some more functionality to the app that would separate it from the competition (check it out here). At first, I was going to add YouTube functionality where the user could upload the video straight to Youtube. My brother who is a streamer brought up that unless I added editing capabilities to it, there wasn’t much need for that functionality. Instead, I should stream to YouTube. This made much more sense, even in my case I usually stream myself coding from the desktop but instead of downloading cumbersome software, I can do it straight in the browser! For this, I decided to use Laravel Socialite with a YouTube provider, while on the client-side creating a YouTube class with the various functions needed to interact with the API.

Connect To Youtube!

Extending The Laravel Microservice

The Laravel part is pretty simple first we add the Socialite and Youtube Provider packages.

composer require laravel/socialite socialiteproviders/youtube

Now we have to edit the app/Providers/EventServiceProvider.php file

<?php

namespace App\Providers;

use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;

class EventServiceProvider extends ServiceProvider
{
    /**
     * The event listener mappings for the application.
     *
     * @var array
     */
    protected $listen = [
        Registered::class => [
            SendEmailVerificationNotification::class,
        ],
        \SocialiteProviders\Manager\SocialiteWasCalled::class => [
        // ... other providers
        \SocialiteProviders\YouTube\YouTubeExtendSocialite::class.'@handle',
      ],
    ];

    /**
     * Register any events for your application.
     *
     * @return void
     */
    public function boot()
    {
        //
    }
}

Next we need to set the .env file and add the client secret, recorder URL and redirect URL

YOUTUBE_CLIENT_ID=
YOUTUBE_CLIENT_SECRET=
YOUTUBE_REDIRECT_URI="${APP_URL}/api/callback/youtube"
RECORDER_URL=

If you have worked with Laravel Socialite in the past then all of this is familiar. Finally we need to edit our routes/api.php file and add our two API routes for interacting with Youtube.

Route::get('/login/youtube', function (Request $request) {
  return Socialite::driver('youtube')->scopes(['https://www.googleapis.com/auth/youtube', 'https://www.googleapis.com/auth/youtube.upload', 'https://www.googleapis.com/auth/youtube.readonly'])->stateless()->redirect();
});

Route::get('/callback/youtube', function (Request $request) {
  $user = Socialite::driver('youtube')->stateless()->user();
  return redirect(env('RECORDER_URL').'/#/success?token='.$user->token);
});

The callback function redirects us to the web app and the reason for this will become clear next.

The Client Side

On the web app we need to create a Youtube class that will call all of the functions needed for interacting with the API. Not everything is implemented now and will be as the tutorial goes on. Create a new file src/classes/Youtube.js

export default class Youtube {
  constructor (token) {
    this.token = token
    this.broadcasts = []
  }
  async uploadVideo () {}
  async createNewLiveStream () {
    try {
    const broadcast = await this.createBroadcast()
    const livestream = await this.makeRequest('https://www.googleapis.com/youtube/v3/liveStreams?part=cdn&part=snippet', 'POST', {
      "snippet": {
        "title": "Getting Started With Screen Recorder"
      },
      "cdn": {
        "frameRate": "variable",
        "ingestionType": "dash",
        "resolution": "variable"
      }
    })
      console.log([broadcast, livestream])
      const bind = await this.bindBroadCast(broadcast.id, livestream.id)
      console.log(bind)
    } catch (e) {
      console.log(e)
    }
  }
  async getBroadcasts () {
    try {
      const res = await fetch('https://www.googleapis.com/youtube/v3/liveBroadcasts?broadcastStatus=all', {
        headers: {
          'Authorization': `Bearer ${this.token}`
        }
      })
      const results = await res.json()
      this.broadcasts = results.items
      console.log(this.broadcasts)
      return results
    } catch (e) {
      console.log(e)
    }
  }
  async createBroadcast () {
    try {
      const res = await this.makeRequest('https://youtube.googleapis.com/youtube/v3/liveBroadcasts?part=contentDetails&part=snippet&part=status','POST',{
      "snippet": {
        "scheduledStartTime": new Date(Date.now()).toISOString(),
        "title": "Getting Started With Screen Recorder"
      },
      "contentDetails": {
        "enableDvr": true,
        "enableAutoStart": true,
        "enableAutoStop": true
      },
      "status": {
        "privacyStatus": "unlisted",
      }
    })
    return res
    } catch (e) {
      console.log(e)
    }
  }
  async bindBroadCast (broadcastId, streamId) {
    const url = `https://www.googleapis.com/youtube/v3/liveBroadcasts/bind?id=${broadcastId}&part=snippet&streamId=${streamId}`
    try {
      const res = await this.makeRequest(url, 'POST', {})
      return res
    } catch (e) {
      console.log(e)
    }
  }
  async endBroadcast() {}
  async makeRequest(url, method, data) {
    try {
      const res = await fetch(url, {
        method: method, // *GET, POST, PUT, DELETE, etc.
        mode: 'cors', // no-cors, *cors, same-origin
        cache: 'no-cache',
        headers: {
          Authorization: `Bearer ${this.token}`
        },
        body: JSON.stringify(data)
      })
      const ret = await res.json()
      return ret
    } catch (e) {
      alert('There was an error with the request! Please try agin later.')
    }

  }
}

All of these methods are from the Live and Broadcasts APIs now we will grab the token and init our class! To do this we will create a button that when pressed will open up a new window call the Socialite endpoint, grab the token, close the window, and set the class. First we will create a vuex file and add it to the application open src/store/index.js

import Vue from 'vue'
import Vuex from 'vuex'
import Youtube from '../classes/Youtube'
Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    yt: {}
  },
  mutations: {
    setYouTube (state, token) {
      state.yt = new Youtube(token)
    },
    streamToYouTube (state) {
      return state.yt.createNewLiveStream()
    },
    getBroadcasts (state) {
      return state.yt.getBroadcasts()
    },
    createBroadcast (state) {
      return state.yt.createNewLiveStream()
    }
  },
  actions: {
    setYouTube (context, token) {
      console.log(token)
      context.commit('setYouTube', token)
    },
    streamToYouTube (context) {
      context.commit('streamToYouTube')
    },
    getBroadcasts (context) {
      return context.commit('getBroadcasts')
    },
    createBroadcast (context) {
      context.commit('createBroadcast')
    }
  },
  getters: {
    getYoutube (state) {
      return state.yt
    }
  },
  modules: {
  }
})

We create a universal yt object in the state that represents our Youtube class and we will call the methods. Don’t forget to add the plugin

vue add vuex

Routing

The Youtube API use case requires us to provide a privacy policy so we need to add vue-router and make some new components for the pages.

vue add router

Now create a new file src/router/index.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  },
  {
    path: '/privacy',
    name: 'Privacy',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/Privacy.vue')
  },
  {
    path: '/terms',
    name: 'TOS',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/Terms.vue')
  },
  {
    path: '/success',
    name: 'Success',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/Success.vue')
  }
]

const router = new VueRouter({
  routes
})

export default router

The About, Terms and Privacy pages are simply templates with text in them showing the various content needed and for sake of brevity I won’t show those contents as there is no javascript. The Success page however is very important and is responsible for grabbing the Youtube token from the Laravel callback. Let’s explore it src/views/Success.vue

<template>
  <div class="Success">
    <img alt="Screen Record Pro" src="../assets/logo.svg" class="animate-fade-slow object-contain h-80 w-full">
    <h2 class="text-sm tracking-wide font-medium text-gray-500 uppercase">Youtube Connected!</h2>
    <p class="text-base font-light leading-relaxed mt-0 mb-4 text-gray-800">
    Thank you for authenticating with Screen Record Pro! This window will close automatically
    </p>
  </div>
</template>
<script>
import { mapActions, mapGetters } from 'vuex'
export default {
  name: 'Success',
  mounted () {
    window.localStorage.setItem('youtube_key', this.$route.query.token)
    window.opener.postMessage({youtube_token: this.$route.query.token}, '*')
    window.close()
  },
  computed: {
    ...mapGetters(['getYoutube'])
  },
  methods : {
    ...mapActions(['setYouTube'])
  }
}
</script>

Once the page mountes we use localstorage API to set the youtube_key to the token query parameter. This parameter is set when the redirect is called in the /callback/youtube API endpoint. This window will be a popup window, and we need to send a message to the window that opened this window (make sense?). For this we use the window.opener.postMessage() function. We will listen for this message on the home screen and set the youtube object. Now that we have made our router and vuex object we need to redo the main.js and set our Vue object with them. open up main.js

import Vue from 'vue'
import App from './App.vue'
import VueTailwind from 'vue-tailwind'
import Ads from 'vue-google-adsense'
import VueGtag from "vue-gtag";
import "tailwindcss/tailwind.css"
import router from './router'
import store from './store'
Vue.use(VueGtag, {
  config: { id: "UA-xxxxxxx" }
});

Vue.use(require('vue-script2'))

Vue.use(Ads.Adsense)
const settings = {
  TInput: {
    classes: 'form-input border-2 text-gray-700',
    variants: {
      error: 'form-input border-2 border-red-300 bg-red-100',
      // ... Infinite variants
    }
  },
TButton: {
    classes: 'rounded-lg border block inline-flex items-center justify-center block px-4 py-2 transition duration-100 ease-in-out focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none focus:ring-opacity-50 disabled:opacity-50 disabled:cursor-not-allowed',
    variants: {
      secondary: 'rounded-lg border block inline-flex items-center justify-center bg-purple-500 border-purple-500 hover:bg-purple-600 hover:border-purple-600',
    }
  },
  TAlert: {
    classes: {
      wrapper: 'rounded bg-blue-100 p-4 flex text-sm border-l-4 border-blue-500',
      body: 'flex-grow text-blue-700',
      close: 'text-blue-700 hover:text-blue-500 hover:bg-blue-200 ml-4 rounded',
      closeIcon: 'h-5 w-5 fill-current'
    },
    variants: {
      danger: {
        wrapper: 'rounded bg-red-100 p-4 flex text-sm border-l-4 border-red-500',
        body: 'flex-grow text-red-700',
        close: 'text-red-700 hover:text-red-500 hover:bg-red-200 ml-4 rounded'
      },
      // ... Infinite variants
    }
  },
  // ... The rest of the components
}

Vue.use(VueTailwind, settings)
Vue.config.productionTip = false

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')

Lastly we need to open the src/views/Home.vue file and edit our application. When it mounts we need to set a listener for message and call the setYoutube method. If the localstorage is already set then we don’t show the button for connecting. If the user is connected then they click a button and it creates a live stream.

<template>
  <div id="app">
    <img alt="Screen Record Pro" src="../assets/logo.svg" class="animate-fade-slow object-contain h-80 w-full">
    <h2 class="text-sm tracking-wide font-medium text-gray-500 uppercase">Free Online Screen Recorder</h2>
    <p class="text-base font-light leading-relaxed mt-0 mb-4 text-gray-800">
    Free online screen recorder by J Computer Solutions LLC that allows you to
    record your screen including microphone audio and save the file to your desktop.
    No download required, use this progressive web app in the browser!
    J Computer Solutions LLC provides the #1 free online screen capture software! Due to current
    browser limitations, this software can only be used on desktop. Please ensure you are on a Windows, MacOS or Linux
    computer using Chrome, Firefox or Safari!
    </p>
    <h1 class="text-3xl font-large text-gray-500 uppercase">To Date We Have Processed: <strong class="animate-pulse text-3xl font-large text-red-500">{{bytes_processed}}</strong> bytes worth of video data!</h1>
    <t-modal
      header="Email Recording"
      ref="modal"
    >
  <t-input v-model="sendEmail" placeholder="Email Address" name="send-email" />
  <template v-slot:footer>
    <div class="flex justify-between">
      <t-button type="button" @click="$refs.modal.hide()">
        Cancel
      </t-button>
      <t-button type="button" @click="emailFile">
        Send File
      </t-button>
    </div>
  </template>
</t-modal>
<div class="mt-5 mb-5">
  <t-button v-on:click="connectToYoutube" v-if="!youtube_ready"> Connect To YouTube 📺</t-button>
</div>
<div class="mt-5 mb-5">
  <t-button v-on:click="getStream" v-if="!isRecording" v-show="canRecord" class="ml-10"> Start Recording 🎥</t-button>
    <div v-else>
      <t-button v-on:click="streamToYouTube" @click="createBroadcast" v-if="youtube_ready">Stream To Youtube 📺</t-button>

      <t-button v-on:click="stopStream"> Stop Screen Recording ❌ </t-button>
      </div>
    <t-button v-on:click="download" v-if="fileReady" class="ml-10"> Download Recording 🎬</t-button>
    <t-button  v-on:click="$refs.modal.show()" autoPictureInPicture="true" v-if="fileReady" class="ml-10"> Email Recording 📧</t-button>
</div>
<div class="mt-5" v-show="fileReady">
  <video class="center" height="500px"  controls  id="video" ></video>
</div>
<Adsense
  data-ad-client="ca-pub-7023023584987784"
  data-ad-slot="8876566362">
</Adsense>
<footer>
  <cookie-law theme="base"></cookie-law>
</footer>
  </div>
</template>

<script>
 import CookieLaw from 'vue-cookie-law'
 import { mapGetters, mapActions } from 'vuex'
export default {
  name: 'Home',
  components: { CookieLaw },
  data() {
    return {
      youtube_ready: false,
      canRecord: true,
      isRecording: false,
      options: {
        audioBitsPerSecond: 128000,
        videoBitsPerSecond: 2500000,
        mimeType: 'video/webm; codecs=vp9'
      },
      displayOptions: {
      video: {
        cursor: "always"
      },
      audio: {
          echoCancellation: true,
          noiseSuppression: true,
          sampleRate: 44100
        }
      },
      mediaRecorder: {},
      stream: {},
      recordedChunks: [],
      file: null,
      fileReady: false,
      sendEmail: '',
      url: 'https://screen-recorder-micro.jcompsolu.com',
      bytes_processed: 0,
    }
  },
  methods: {
    ...mapActions(['setYouTube', 'streamToYouTube', 'getBroadcasts', 'createBroadcast']),
    async connectToYoutube () {
      window.open(`${this.url}/api/login/youtube`, "YouTube Login", 'width=800, height=600');
    },
    async emailFile () {
      try {
        const fd = new FormData();
        fd.append('video', this.file)
        fd.append('email', this.sendEmail)
        await fetch(`${this.url}/api/email-file`, {
          method: 'post',
          body: fd
        })
        this.$gtag.event('email-file-data', {
          'name': this.file.name,
          'size': this.file.size,
          'email': this.sendEmail
        })
      this.$refs.modal.hide()
      this.showNotification()
      } catch (err) {
        alert(err.message)
      }
    },
    async uploadFileData () {
      try {
        const fd = new FormData();
        fd.append('video', this.file)
        await fetch(`${this.url}/api/upload-file-data`, {
          method: 'post',
          body: fd
        })
        this.$gtag.event('upload-file-data', {
          'name': this.file.name,
          'size': this.file.size
        })
      } catch (e) {
        this.$gtag.exception('application-error', e)
      }
    },
    setFile (){
      this.file = new Blob(this.recordedChunks, {
        type: "video/webm; codecs=vp9"
      });
      this.$gtag.event('file-set', {
        'event_category' : 'Files',
        'event_label' : 'File Set'
      })
      const newObjectUrl = URL.createObjectURL( this.file );
      const videoEl = document.getElementById('video')
      // URLs created by `URL.createObjectURL` always use the `blob:` URI scheme: https://w3c.github.io/FileAPI/#dfn-createObjectURL
      const oldObjectUrl = videoEl.src;
      if( oldObjectUrl && oldObjectUrl.startsWith('blob:') ) {
          // It is very important to revoke the previous ObjectURL to prevent memory leaks. Un-set the `src` first.
          // See https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL

          videoEl.src = ''; // <-- Un-set the src property *before* revoking the object URL.
          URL.revokeObjectURL( oldObjectUrl );
      }

      // Then set the new URL:
      videoEl.src = newObjectUrl;

      // And load it:
      videoEl.load();
      this.$gtag.event('file-loaded', {
        'event_category' : 'Files',
        'event_label' : 'File Loaded'
      })
      videoEl.onloadedmetadata = () => {
        this.uploadFileData()
        this.getBytes()
      }
      videoEl.onPlay = () => {
        this.$gtag.event('file-played', {
          'event_category' : 'Files',
          'event_label' : 'File Played'
        })
      }

      this.fileReady = true
    },
    download: function(){
    var url = URL.createObjectURL(this.file);
    var a = document.createElement("a");
    document.body.appendChild(a);
    a.style = "display: none";
    a.href = url;
    var d = new Date();
    var n = d.toUTCString();
    a.download = n+".webm";
    a.click();
    window.URL.revokeObjectURL(url);
    this.recordedChunks = []
    this.showNotification()
    this.$gtag.event('file-downloaded', {
      'event_category' : 'Files',
      'event_label' : 'File Downloaded'
    })
    },
    showNotification: function() {
      this.$gtag.event('notification-shown', {})
      var img = '/logo.png';
      var text = 'If you enjoyed this product consider donating!';
      navigator.serviceWorker.getRegistration().then(function(reg) {
        reg.showNotification('Screen Record Pro', { body: text, icon: img, requireInteraction: true,
        actions: [
            {action: 'donate', title: 'Donate',icon: 'logo.png'},
            {action: 'close', title: 'Close',icon: 'logo.png'}
            ]
              });
      });
    },
    handleDataAvailable: function(event) {
      if (event.data.size > 0) {
        this.recordedChunks.push(event.data);
        this.isRecording = false
        this.setFile()
      } else {
        // ...
      }
    },
    async registerPeriodicNewsCheck () {
      const registration = await navigator.serviceWorker.ready;
      try {
        await registration.periodicSync.register('get-latest-stats', {
          minInterval: 24 * 60 * 60 * 1000,
        });
      } catch (e) {
        this.$gtag.exception('application-error', e)
      }
    },
    stopStream: function() {
      this.mediaRecorder.stop()
      this.mediaRecorder = null
      this.stream.getTracks()
      .forEach(track => track.stop())
      this.stream = null
      this.$gtag.event('stream-stop', {
        'event_category' : 'Streams',
        'event_label' : 'Stream Stopped'
      })
    },
    getStream: async function() {
    try {
        this.stream =  await navigator.mediaDevices.getDisplayMedia(this.displayOptions);
        this.stream.getVideoTracks()[0].onended = () => { // Click on browser UI stop sharing button
          this.stream.getTracks()
          .forEach(track => track.stop())
        };
        const audioStream = await navigator.mediaDevices.getUserMedia({audio: true}).catch(e => {throw e});
        const audioTrack = audioStream.getAudioTracks();
        // add audio track
        this.stream.addTrack(audioTrack[0])
        this.mediaRecorder = new MediaRecorder(this.stream)
        this.mediaRecorder.ondataavailable = this.handleDataAvailable;
        this.mediaRecorder.start();
        this.isRecording = true
        this.$gtag.event('stream-start', {
          'event_category' : 'Streams',
          'event_label' : 'Stream Started'
        })
      } catch(e) {
        this.isRecording = false
        this.$gtag.exception('application-error', e)
      }
    },
    async getBytes () {
      const result = await fetch(`${this.url}/api/get-stats`)
      this.bytes_processed = await result.json()
    },
    skipDownloadUseCache () {
      this.bytes_processed = localStorage.bytes_processed
    }

  },
  mounted() {
    const ctx = this
    window.addEventListener("message", function (e) {
      if (typeof e.data.youtube_token !== 'undefined') {
        console.log(e.data.youtube_token)
        ctx.setYouTube(e.data.youtube_token)
        ctx.youtube_ready = true
      }
    })
    this.$gtag.pageview("/");
    const ua = navigator.userAgent;
    if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua) || /Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test(ua)) {
        alert('You must be on desktop to use this application!')
        this.canRecord = false
        this.$gtag.exception('mobile-device-attempt', {})
    }
    let that = this
    if (Notification.permission !== 'denied' || Notification.permission === "default") {
      try {
        Notification.requestPermission().then(function(result) {
          that.$gtag.event('accepted-notifications', {
            'event_category' : 'Notifications',
            'event_label' : 'Notification accepted'
          })
          console.log(result)
        });
      } catch (error) {
          // Safari doesn't return a promise for requestPermissions and it
          // throws a TypeError. It takes a callback as the first argument
          // instead.
          if (error instanceof TypeError) {
              Notification.requestPermission((result) => {
                that.$gtag.event('accepted-notifications', {
                  'event_category' : 'Notifications',
                  'event_label' : 'Notification accepted'
                })
                console.log(result)
              });
          } else {
            this.$gtag.exception('notification-error', error)
            throw error;
          }
      }

    }
  },
  computed: {
    ...mapGetters(['getYoutube'])
  },
  async created () {
    try {
      if(localStorage.youtube_key != null) {
        this.setYouTube(localStorage.youtube_key)
        console.log(this.getBroadcasts())
        this.youtube_ready = true
      }
      const registration = await navigator.serviceWorker.ready
      const tags = await registration.periodicSync.getTags()
      navigator.serviceWorker.addEventListener('message', event => {
        this.bytes_processed = event.data
      });
      if (tags.includes('get-latest-stats')) {
          // this.skipDownloadUseCache()
      } else {
        this.getBytes()
      }
    } catch (e) {
      this.$gtag.exception('application-error', e)
      this.getBytes()
    }
  }
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
:picture-in-picture {
  box-shadow: 0 0 0 5px red;
  height: 500px;
  width: 500px;
}
</style>
OAuth Screen
We can now stream using the Youtube live api
Now we can create a live stream!

We created the stream but now we need to send our packets via MPEG-DASH! In the next series, we create the dash service and send our packets to Youtube for ingestion! Be sure to like and share this article and subscribe to my Youtube channel! Also, be sure to check out the source code for the API and the PWA! Lastly, join the discord and connect with software engineers and entrepreneurs alike!

Leave a Reply