Tutorial How to Create Webrtc Laravel – Livestream Application

Tutorial How to Create Webrtc Laravel – Livestream Application – Brillian Solution. One of my compendiums asked whether it was possible to make a live streaming operation with WebRTC in a Laravel Application. I took up this challenge and indeed though WebRTC has limitations, I came up with a simple live streaming perpetration. We will go through my perpetration in this composition.

Final Design Depository https//github.com/Mupati/laravel-video-chat Note that this depository contains law for some other specialized papers. Conditions This tutorial assumes you know how to set up a new Laravel design with VueJs authentication. produce some druggies after setting up your design.

You should be familiar with Laravel’s broadcasting medium and have a fair idea of how WebSockets work. You may use this starter design I created Laravel 8 Vue Auth Starter Set up a free pusher account onpusher.com Set up your ICE SERVER( TURN SERVER) details.

This tutorial is a good companion. Tutorial How to Create Webrtc Laravel – Livestream Application.

Project Setup

# Install needed packages
composer require pusher/pusher-PHP-server "~4.0"
npm install --save laravel-echo pusher-js simple-peer

Configuring Backend

  • Add routes for streaming pages in routes/web.php. The routes will be used to visit the live stream page, start a live stream from the device camera, and generate a broadcast link for other authenticated users to view your live stream.
    Route::get('/streaming', 'App\Http\Controllers\WebrtcStreamingController@index');
    Route::get('/streaming/{streamId}', 'App\Http\Controllers\WebrtcStreamingController@consumer');
    Route::post('/stream-offer', 'App\Http\Controllers\WebrtcStreamingController@makeStreamOffer');
    Route::post('/stream-answer', 'App\Http\Controllers\WebrtcStreamingController@makeStreamAnswer');
  • Uncomment BroadcastServiceProvider in config/app.php. This allows us to use Laravel’s broadcasting system.
+ App\Providers\BroadcastServiceProvider::class
- //App\Providers\BroadcastServiceProvider::class 
  • Create Dynamic Presence and Private Channel in routes/channels.php.

Authenticated users subscribe to both channels.
The presence channel is dynamically created with a streamId generated by the broadcaster. This way, we can detect all users who have joined the live broadcast.

Signaling information is exchanged between the broadcaster and the viewer through the private channel.

// Dynamic Presence Channel for Streaming
Broadcast::channel('streaming-channel.{streamId}', function ($user) {
    return ['id' => $user->id, 'name' => $user->name];
});

// Signaling Offer and Answer Channels
Broadcast::channel('stream-signal-channel.{userId}', function ($user, $userId) {
    return (int) $user->id === (int) $userId;
});
  • Create StreamOffer and StreamAnswer events. Signaling information is broadcast on the stream-signal-channel-{userId} private channel we created early on.

The broadcaster sends an offer to a new user who joins the live stream when we emit the StreamOffer event and the viewer replies with an answer using the StreamAnswer event.

php artisan make:event StreamOffer
php artisan make:event StreamAnswer
  • Add the following code to app/Events/StreamOffer.php.
<?php

namespace App\Events;

use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class StreamOffer implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public $data;
    /**
     * Create a new event instance.
     *
     * @return void
     */
    public function __construct($data)
    {
        $this->data = $data;
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return \Illuminate\Broadcasting\Channel|array
     */
    public function broadcastOn()
    {
        // stream offer can broadcast on a private channel
        return  new PrivateChannel('stream-signal-channel.' . $this->data['receiver']['id']);
    }
}
  • Add the following code to app/Events/StreamAnswer.php.
<?php

namespace App\Events;

use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class StreamAnswer implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public $data;
    /**
     * Create a new event instance.
     *
     * @return void
     */
    public function __construct($data)
    {
        $this->data = $data;
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return \Illuminate\Broadcasting\Channel|array
     */
    public function broadcastOn()
    {
        return  new PrivateChannel('stream-signal-channel.' . $this->data['broadcaster']);
    }
}
  • Create WebrtcStreamingController to handle the broadcasting, viewing, and signaling for the live stream.
php artisan make:controller WebrtcStreamingController
  • Add the following to the WebrtcStreamingController
<?php

namespace App\Http\Controllers;

use App\Events\StreamAnswer;
use App\Events\StreamOffer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class WebrtcStreamingController extends Controller
{

    public function index()
    {
        return view('video-broadcast', ['type' => 'broadcaster', 'id' => Auth::id()]);
    }

    public function consumer(Request $request, $streamId)
    {
        return view('video-broadcast', ['type' => 'consumer', 'streamId' => $streamId, 'id' => Auth::id()]);
    }

    public function makeStreamOffer(Request $request)
    {
        $data['broadcaster'] = $request->broadcaster;
        $data['receiver'] = $request->receiver;
        $data['offer'] = $request->offer;

        event(new StreamOffer($data));
    }

    public function makeStreamAnswer(Request $request)
    {
        $data['broadcaster'] = $request->broadcaster;
        $data['answer'] = $request->answer;
        event(new StreamAnswer($data));
    }
}

Methods in the WebrtcStreamingController

Let’s explore what the methods in the controller are doing.

  • index: This returns the view for the broadcaster. We pass a ‘type’: broadcaster and the user’s ID into the view to help identify who the user is.
  • consumer: It returns the view for a new user who wants to join the live stream. We pass a ‘type’: consumer, the ‘streamId’ we extract from the broadcasting link, and the user’s ID.
  • makeStreamOffer: It broadcasts an offer signal sent by the broadcaster to a specific user who just joined. The following data is sent:
    • broadcaster: The user ID of the one who initiated the live stream i.e the broadcaster
    • receiver: The ID of the user to whom the signaling offer is being sent.
    • offer: This is the WebRTC offer from the broadcaster.
  • makeStreamAnswer: It sends an answer signal to the broadcaster to fully establish the peer connection.
    • broadcaster: The user ID of the one who initiated the live stream i.e the broadcaster.
    • answer: This is the WebRTC answer from the viewer after, sent after receiving an offer from the broadcaster.

Configuring Frontend

  • Instantiate Laravel Echo and Pusher in resources/js/bootstrap.js by uncommenting the following block of code.
+ import Echo from 'laravel-echo';
+ window.Pusher = require('pusher-js');
+ window.Echo = new Echo({
+     broadcaster: 'pusher',
+     key: process.env.MIX_PUSHER_APP_KEY,
+     cluster: process.env.MIX_PUSHER_APP_CLUSTER,
+     forceTLS: true
+ });
- import Echo from 'laravel-echo';
- window.Pusher = require('pusher-js');
- window.Echo = new Echo({
-     broadcaster: 'pusher',
-     key: process.env.MIX_PUSHER_APP_KEY,
-     cluster: process.env.MIX_PUSHER_APP_CLUSTER,
-     forceTLS: true
-});
  • Create resources/js/helpers.js. Add a getPermissions function to help with permission access for the microphone and camera. This method handles the video and audio permission that is required by browsers to make the video calls. It waits for the user to accept the permissions before we can proceed with the video call. We allow both audio and video. Read more on MDN Website.
export const getPermissions = () => {
    // Older browsers might not implement mediaDevices at all, so we set an empty object first
    if (navigator.mediaDevices === undefined) {
        navigator.mediaDevices = {};
    }

    // Some browsers partially implement media devices. We can't just assign an object
    // with getUserMedia as it would overwrite existing properties.
    // Here, we will just add the getUserMedia property if it's missing.
    if (navigator.mediaDevices.getUserMedia === undefined) {
        navigator.mediaDevices.getUserMedia = function(constraints) {
            // First get ahold of the legacy getUserMedia, if present
            const getUserMedia =
                navigator.webkitGetUserMedia || navigator.mozGetUserMedia;

            // Some browsers just don't implement it - return a rejected promise with an error
            // to keep a consistent interface
            if (!getUserMedia) {
                return Promise.reject(
                    new Error("getUserMedia is not implemented in this browser")
                );
            }

            // Otherwise, wrap the call to the old navigator.getUserMedia with a Promise
            return new Promise((resolve, reject) => {
                getUserMedia.call(navigator, constraints, resolve, reject);
            });
        };
    }
    navigator.mediaDevices.getUserMedia =
        navigator.mediaDevices.getUserMedia ||
        navigator.webkitGetUserMedia ||
        navigator.mozGetUserMedia;

    return new Promise((resolve, reject) => {
        navigator.mediaDevices
            .getUserMedia({ video: true, audio: true })
            .then(stream => {
                resolve(stream);
            })
            .catch(err => {
                reject(err);
                //   throw new Error(`Unable to fetch stream ${err}`);
            });
    });
};
  • Create a component for the Broadcaster named Broadcaster.vue in resources/js/components/Broadcaster.vue.

<template>
  <div class="container">
    <div class="row">
      <div class="col-md-8 offset-md-2">
        <button class="btn btn-success" @click="startStream">
          Start Stream</button
        ><br />
        <p v-if="isVisibleLink" class="my-5">
          Share the following streaming link: {{ streamLink }}
        </p>
        <video autoplay ref="broadcaster"></video>
      </div>
    </div>
  </div>
</template>

<script>
import Peer from "simple-peer";
import { getPermissions } from "../helpers";
export default {
  name: "Broadcaster",
  props: [
    "auth_user_id",
    "env",
    "turn_url",
    "turn_username",
    "turn_credential",
  ],
  data() {
    return {
      isVisibleLink: false,
      streamingPresenceChannel: null,
      streamingUsers: [],
      currentlyContactedUser: null,
      allPeers: {}, // this will hold all dynamically created peers using the 'ID' of users who just joined as keys
    };
  },
  computed: {
    streamId() {
      // you can improve streamId generation code. As long as we include the
      // broadcaster's user id, we are assured of getting unique streamiing link everytime.
      // the current code just generates a fixed streaming link for a particular user.
      return `${this.auth_user_id}12acde2`;
    },
    streamLink() {
      // just a quick fix. can be improved by setting the app_url
      if (this.env === "production") {
        return `https://laravel-video-call.herokuapp.com/streaming/${this.streamId}`;
      } else {
        return `http://127.0.0.1:8000/streaming/${this.streamId}`;
      }
    },
  },
  methods: {
    async startStream() {
      // const stream = await navigator.mediaDevices.getUserMedia({
      //   video: true,
      //   audio: true,
      // });
      // microphone and camera permissions
      const stream = await getPermissions();
      this.$refs.broadcaster.srcObject = stream;
      this.initializeStreamingChannel();
      this.initializeSignalAnswerChannel(); // a private channel where the broadcaster listens to incoming signalling answer
      this.isVisibleLink = true;
    },
    peerCreator(stream, user, signalCallback) {
      let peer;
      return {
        create: () => {
          peer = new Peer({
            initiator: true,
            trickle: false,
            stream: stream,
            config: {
              iceServers: [
                {
                  urls: "stun:stun.stunprotocol.org",
                },
                {
                  urls: this.turn_url,
                  username: this.turn_username,
                  credential: this.turn_credential,
                },
              ],
            },
          });
        },
        getPeer: () => peer,
        initEvents: () => {
          peer.on("signal", (data) => {
            // send offer over here.
            signalCallback(data, user);
          });
          peer.on("stream", (stream) => {
            console.log("onStream");
          });
          peer.on("track", (track, stream) => {
            console.log("onTrack");
          });
          peer.on("connect", () => {
            console.log("Broadcaster Peer connected");
          });
          peer.on("close", () => {
            console.log("Broadcaster Peer closed");
          });
          peer.on("error", (err) => {
            console.log("handle error gracefully");
          });
        },
      };
    },
    initializeStreamingChannel() {
      this.streamingPresenceChannel = window.Echo.join(
        `streaming-channel.${this.streamId}`
      );
      this.streamingPresenceChannel.here((users) => {
        this.streamingUsers = users;
      });
      this.streamingPresenceChannel.joining((user) => {
        console.log("New User", user);
        // if this new user is not already on the call, send your stream offer
        const joiningUserIndex = this.streamingUsers.findIndex(
          (data) => data.id === user.id
        );
        if (joiningUserIndex < 0) {
          this.streamingUsers.push(user);
          // A new user just joined the channel so signal that user
          this.currentlyContactedUser = user.id;
          this.$set(
            this.allPeers,
            `${user.id}`,
            this.peerCreator(
              this.$refs.broadcaster.srcObject,
              user,
              this.signalCallback
            )
          );
          // Create Peer
          this.allPeers[user.id].create();
          // Initialize Events
          this.allPeers[user.id].initEvents();
        }
      });
      this.streamingPresenceChannel.leaving((user) => {
        console.log(user.name, "Left");
        // destroy peer
        this.allPeers[user.id].getPeer().destroy();
        // delete peer object
        delete this.allPeers[user.id];
        // if one leaving is the broadcaster set streamingUsers to empty array
        if (user.id === this.auth_user_id) {
          this.streamingUsers = [];
        } else {
          // remove from streamingUsers array
          const leavingUserIndex = this.streamingUsers.findIndex(
            (data) => data.id === user.id
          );
          this.streamingUsers.splice(leavingUserIndex, 1);
        }
      });
    },
    initializeSignalAnswerChannel() {
      window.Echo.private(`stream-signal-channel.${this.auth_user_id}`).listen(
        "StreamAnswer",
        ({ data }) => {
          console.log("Signal Answer from private channel");
          if (data.answer.renegotiate) {
            console.log("renegotating");
          }
          if (data.answer.sdp) {
            const updatedSignal = {
              ...data.answer,
              sdp: `${data.answer.sdp}\n`,
            };
            this.allPeers[this.currentlyContactedUser]
              .getPeer()
              .signal(updatedSignal);
          }
        }
      );
    },
    signalCallback(offer, user) {
      axios
        .post("/stream-offer", {
          broadcaster: this.auth_user_id,
          receiver: user,
          offer,
        })
        .then((res) => {
          console.log(res);
        })
        .catch((err) => {
          console.log(err);
        });
    },
  },
};
</script>

<style scoped>
</style>

Create a component for the Viewer named Viewer.vue in resources/js/components/Viewer.vue.

<template>
  <div class="container">
    <div class="row">
      <div class="col-md-8 offset-md-2">
        <button class="btn btn-success" @click="joinBroadcast">
          Join Stream</button
        ><br />

        <video autoplay ref="viewer"></video>
      </div>
    </div>
  </div>
</template>

<script>
import Peer from "simple-peer";
export default {
  name: "Viewer",
  props: [
    "auth_user_id",
    "stream_id",
    "turn_url",
    "turn_username",
    "turn_credential",
  ],
  data() {
    return {
      streamingPresenceChannel: null,
      broadcasterPeer: null,
      broadcasterId: null,
    };
  },
  methods: {
    joinBroadcast() {
      this.initializeStreamingChannel();
      this.initializeSignalOfferChannel(); // a private channel where the viewer listens to incoming signalling offer
    },
    initializeStreamingChannel() {
      this.streamingPresenceChannel = window.Echo.join(
        `streaming-channel.${this.stream_id}`
      );
    },
    createViewerPeer(incomingOffer, broadcaster) {
      const peer = new Peer({
        initiator: false,
        trickle: false,
        config: {
          iceServers: [
            {
              urls: "stun:stun.stunprotocol.org",
            },
            {
              urls: this.turn_url,
              username: this.turn_username,
              credential: this.turn_credential,
            },
          ],
        },
      });
      // Add Transceivers
      peer.addTransceiver("video", { direction: "recvonly" });
      peer.addTransceiver("audio", { direction: "recvonly" });
      // Initialize Peer events for connection to remote peer
      this.handlePeerEvents(
        peer,
        incomingOffer,
        broadcaster,
        this.removeBroadcastVideo
      );
      this.broadcasterPeer = peer;
    },
    handlePeerEvents(peer, incomingOffer, broadcaster, cleanupCallback) {
      peer.on("signal", (data) => {
        axios
          .post("/stream-answer", {
            broadcaster,
            answer: data,
          })
          .then((res) => {
            console.log(res);
          })
          .catch((err) => {
            console.log(err);
          });
      });
      peer.on("stream", (stream) => {
        // display remote stream
        this.$refs.viewer.srcObject = stream;
      });
      peer.on("track", (track, stream) => {
        console.log("onTrack");
      });
      peer.on("connect", () => {
        console.log("Viewer Peer connected");
      });
      peer.on("close", () => {
        console.log("Viewer Peer closed");
        peer.destroy();
        cleanupCallback();
      });
      peer.on("error", (err) => {
        console.log("handle error gracefully");
      });
      const updatedOffer = {
        ...incomingOffer,
        sdp: `${incomingOffer.sdp}\n`,
      };
      peer.signal(updatedOffer);
    },
    initializeSignalOfferChannel() {
      window.Echo.private(`stream-signal-channel.${this.auth_user_id}`).listen(
        "StreamOffer",
        ({ data }) => {
          console.log("Signal Offer from private channel");
          this.broadcasterId = data.broadcaster;
          this.createViewerPeer(data.offer, data.broadcaster);
        }
      );
    },
    removeBroadcastVideo() {
      console.log("removingBroadcast Video");
      alert("Livestream ended by broadcaster");
      const tracks = this.$refs.viewer.srcObject.getTracks();
      tracks.forEach((track) => {
        track.stop();
      });
      this.$refs.viewer.srcObject = null;
    },
  },
};
</script>

<style scoped>
</style>

Register the Broadcaster.vue and Viewer.vue components in resources/js/app.js

//  Streaming Components
Vue.component("broadcaster", require("./components/Broadcaster.vue").default);
Vue.component("viewer", require("./components/Viewer.vue").default);
  • Create the video broadcast view in resources/views/video-broadcast.blade.php
@extends('layouts.app')


@section('content')
    @if ($type === 'broadcaster')
        <broadcaster :auth_user_id="{{ $id }}" env="{{ env('APP_ENV') }}"
            turn_url="{{ env('TURN_SERVER_URL') }}" turn_username="{{ env('TURN_SERVER_USERNAME') }}"
            turn_credential="{{ env('TURN_SERVER_CREDENTIAL') }}" />

    @else
        <viewer stream_id="{{ $streamId }}" :auth_user_id="{{ $id }}"
            turn_url="{{ env('TURN_SERVER_URL') }}" turn_username="{{ env('TURN_SERVER_USERNAME') }}"
            turn_credential="{{ env('TURN_SERVER_CREDENTIAL') }}" />
    @endif
@endsection

Update env variables. Insert your Pusher API keys:

APP_ENV=

BROADCAST_DRIVER=pusher

PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=

TURN_SERVER_URL=
TURN_SERVER_USERNAME=
TURN_SERVER_CREDENTIAL=

Final Studies

The sense for this live streaming operation can be likened to a group videotape call where only one person’s sluice is seen.

The sluice of the broadcaster is rendered on the observers’ cybersurfer but the broadcaster does not admit anything from the observers after swapping the signaling information which is needed in WebRTC.

This looks like a star topology and there’s a limitation on how numerous peers can be connected to a single stoner.

I want to explore the option of turning some of the observers into broadcasters after the original broadcaster’s peer has connected to about 4 druggies.

The thing is to rebroadcast the sluice they entered from the original broadcaster.

Is it possible? I can not tell. This will be an intriguing challenge to explore.

Stay tuned!!!.

Source Code : https://github.com/metinagaoglu/Laravel-WebRTC

brillian

Leave a Reply

%d bloggers like this: