Transmisión en vivo co WebRTC en su aplicación Laravel

 

https://github.com/Mupati/laravel-video-chat


Transmisión en vivo con WebRTC en su aplicación Laravel

Introducción

Mi primer intento en WebRTC fue implementar una función de videollamada dentro de una aplicación Laravel. La implementación implicó realizar una llamada, mostrar una notificación de llamada entrante y la capacidad del receptor para aceptar la llamada. Escribí sobre eso aquí:

Uno de mis lectores preguntó si era posible crear una aplicación de transmisión en vivo con WebRTC en una aplicación Laravel. Acepté este desafío y, aunque WebRTC tiene limitaciones, se me ocurrió una implementación simple de transmisión en vivo.

Veremos mi implementación en este artículo.

Repositorio de proyectos finales:https://github.com/Mupati/laravel-video-chat Tenga en cuenta que este repositorio contiene código para algunos otros artículos técnicos.

Requisitos

  • Este tutorial asume que sabe cómo configurar un nuevo Laravelproyecto con VueJsautenticación. Cree algunos usuarios después de configurar su proyecto. Debe estar familiarizado con el mecanismo de transmisión de Laravel y tener una idea clara de cómo funcionan los WebSockets. Puede usar este proyecto inicial que creé: Laravel 8 Vue Auth Starter

  • Configure una cuenta gratuita de pusher en pusher.com

  • Configure los detalles de su ICE SERVER (TURN SERVER). Este tutorial es una buena guía. CÓMO INSTALAR COTURN .

Configuración del proyecto

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

Configuración de servidor

  • Agregue rutas para páginas de transmisión en formato routes/web.phpLas rutas se utilizarán para visitar la página de transmisión en vivo, iniciar una transmisión en vivo desde la cámara del dispositivo y generar un enlace de transmisión para que otros usuarios autenticados vean su transmisión en vivo.
    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');
  • Descomentar BroadcastServiceProvideren config/app.phpEsto nos permite usar el sistema de transmisión de Laravel.
+ App\Providers\BroadcastServiceProvider::class
- //App\Providers\BroadcastServiceProvider::class 
  • Crear presencia dinámica y canal privado en rutas/canales.php.

Los usuarios autenticados se suscriben a ambos canales.
El canal de presencia se crea dinámicamente con un streamIdgenerado por la emisora. De esta manera, podemos detectar a todos los usuarios que se han unido a la transmisión en vivo.

La información de señalización se intercambia entre el emisor y el espectador a través del canal privado.

// 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;
});

  • Crear StreamOfferStreamAnswereventos. La información de señalización se transmite en el stream-signal-channel-{userId}canal privado que creamos al principio.

La emisora ​​envía una oferta a un nuevo usuario que se une a la transmisión en vivo cuando emitimos el StreamOfferevento y el espectador responde con una respuesta utilizando el StreamAnswerevento.

php artisan make:event StreamOffer
php artisan make:event StreamAnswer
  • Agregue el siguiente código a 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']);
    }
}
  • Agregue el siguiente código a 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']);
    }
}

  • Crear WebrtcStreamingControllerpara manejar la transmisión, la visualización y la señalización de la transmisión en vivo.
php artisan make:controller WebrtcStreamingController
  • Agregue lo siguiente a laWebrtcStreamingController
<?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));
    }
}

Métodos en WebrtcStreamingController

Exploremos qué están haciendo los métodos en el controlador.

  • index: Esto devuelve la vista para el emisor. Pasamos un 'tipo': emisor y el ID del usuario a la vista para ayudar a identificar quién es el usuario.
  • consumer: Devuelve la vista para un nuevo usuario que quiere unirse a la transmisión en vivo. Pasamos un 'tipo': consumidor, el 'streamId' que extraemos del enlace de transmisión y la identificación del usuario.
  • makeStreamOffer: Emite una señal de oferta enviada por el emisor a un usuario específico que se acaba de incorporar. Se envían los siguientes datos:

    • broadcaster: El ID de usuario del que inició la transmisión en vivo, es decir, el emisor
    • receiver: El ID del usuario al que se envía la oferta de señalización.
    • offer: Esta es la oferta WebRTC de la emisora.
  • makeStreamAnswer: Envía una señal de respuesta a la emisora ​​para establecer completamente la conexión entre pares.

    • broadcaster: El ID de usuario del que inició la transmisión en vivo, es decir, el emisor.
    • answer: Esta es la respuesta WebRTC del espectador, enviada después de recibir una oferta de la emisora.

Configuración de interfaz

  • Cree una instancia Laravel Echoy descomente Pusherel resources/js/bootstrap.jssiguiente bloque de código.
+ 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
-});
  • Crear resources/js/helpers.jsAgregue una getPermissionsfunción para ayudar con el acceso de permisos para el micrófono y la cámara. Este método maneja el permiso de video y audio que requieren los navegadores para realizar las videollamadas. Espera a que el usuario acepte los permisos antes de que podamos continuar con la videollamada. Permitimos audio y video. Lea más en el sitio web de MDN .
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}`);
            });
    });
};
  • Cree un componente para Broadcaster denominado Broadcaster.vue en formato 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>
  • Cree un componente para el Visor denominado Viewer.vue en formato 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>
    view rawViewer.vue hosted with ❤ by GitHub

Explicación de los componentes Emisor y Visor.

El siguiente video explica la lógica de la llamada en el lado del cliente desde la perspectiva tanto del emisor como de los espectadores.

  • Registre los componentes Broadcaster.vuey enViewer.vueresources/js/app.js
//  Streaming Components
Vue.component("broadcaster", require("./components/Broadcaster.vue").default);
Vue.component("viewer", require("./components/Viewer.vue").default);
  • Cree la vista de transmisión de video enresources/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
  • Actualizar variables de entorno. Inserte sus claves API de Pusher

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=

Demostración de transmisión en vivo

Pensamientos finales

La lógica de esta aplicación de transmisión en vivo se puede comparar con una videollamada grupal en la que solo se ve la transmisión de una persona.

La transmisión de la emisora ​​se representa en el navegador de los espectadores, pero la emisora ​​no recibe nada de los espectadores después de intercambiar la información de señalización que se requiere en WebRTC.

Esto parece una topología en estrella y existe una limitación sobre la cantidad de pares que se pueden conectar a un solo usuario.

Quiero explorar la opción de convertir a algunos de los espectadores en locutores después de que el par del locutor inicial se haya conectado a unos 4 usuarios.

El objetivo es retransmitir la transmisión que recibieron de la emisora ​​original.

¿Es posible? no puedo decir Este será un desafío interesante para explorar.

¡¡¡Manténganse al tanto!!!.

Comentarios

Entradas populares de este blog

Filtrando por fecha

10 videojuegos gratis para aprender JavaScript en línea

reloj obs---datetime.lua