WebRTC Peer-to-peer Re-connection Error - Laravel + Vue.js application

191 Views Asked by At

I'm trying to integrate WebRTC into my Laravel + Vue quiz application. I'm using the simple-peer.js library to handle the RTC connections. The quiz presenter will connect their camera from the dashboard and when clicking 'Start Broadcast' will join a pusher.io presence channel. Any players or participants in the waiting room would've also joined the presence channel. If they're already in the room when the admin broadcasts their video, it will send them a StreamOffer and create a Peer connection via the Laravel Echo .here event listener. Additionally, StreamOffers will be sent to any new users who join the room based on the Echo.joining event listener.

The users who receive a StreamOffer will then send a StreamAnswer event resulting in the Peer connection being made.

This works well so far but the issue I'm having is if the admin stops the broadcast and then starts broadcasting again (without refreshing the page) I get the following error:

DOMException: Failed to execute 'setRemoteDescription' on 'RTCPeerConnection': Failed to set remote answer SDP: Called in wrong state: stable

Within the disconnect camera method, I'm looping through all instances of the Peer connections and destroying them using Peer.destroy() and can confirm the connections are being destroyed for the Viewers based on the peer.on('close') event.

peer.on("close", () => {
                peer.destroy();
                cleanupCallback();
            });

I've tried everything I can think of, even triggering both the presenter and viewers to leave the presence channel when the broadcast is closed and then re-joining when the presenter starts the broadcast again. It works if both the presenter and viewers refresh their page but I'm hoping there's a way of automatically re-connecting if the connection is lost.

My code is as follows. Does anyone know how to handle this correctly?

Admin Broadcast Component

<template>
    <div class="mb-5 panel panel-xs border border-2 border-secondary relative">
        <div class="relative pb-[75%]">
            <video class="absolute w-full h-full inset bg-primary rounded-md" ref="broadcaster" autoplay></video>
            <div class="group flex justify-center items-center absolute top-0 right-0 bottom-0 left-0 m-auto">
                <button v-if="!cameraConnected" @click="connectCamera" class="btn btn-secondary text-white">Connect Camera</button>
                <button v-else @click="disconnectCamera" class="btn btn-primary invisible group-hover:visible">Disconnect Camera</button>
            </div>
        </div>

        <div v-if="cameraConnected || isVisibleLink" class="mt-2">
            <button v-if="cameraConnected && !isVisibleLink" class="btn btn-secondary text-white" @click="startStream">Start Broadcast</button>
            <button v-else class="btn btn-secondary text-white" @click="disconnectCamera">End Broadcast</button>
        </div>
    </div>
</template>
<script>
    import Peer from "simple-peer";
    import { getPermissions } from "../../../helpers";
    import api from "../../../api/axios-api";
    export default {
        name: "Broadcaster",
        props: [
            "quizId",
            "adminId",
        ],
        data() {
            return {
                cameraConnected: false,
                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.quizId}`;
            },
            streamLink() {
                return `http://127.0.0.1:8000/quiz/${this.quizId}/streaming/${this.streamId}`;
            },
        },
        mounted() {

        },
        methods: {
            async connectCamera() {
                const stream = await getPermissions();
                this.$refs.broadcaster.srcObject = stream;
                this.cameraConnected = true;
            },
            async startStream() {
                this.cleanupPeers();
                this.initializeStreamingChannel();
                this.initializeSignalAnswerChannel(); // a private channel where the broadcaster listens to incoming signalling answer
                this.isVisibleLink = true;
            },
            cleanupPeers() {
                for (const peerId in this.allPeers) {
                    if (this.allPeers.hasOwnProperty(peerId)) {
                        const peer = this.allPeers[peerId];
                        const peerInstance = peer.getPeer(); // Get the Peer instance

                        if (peerInstance) {
                            peerInstance.destroy(); // Disconnect the Peer connection
                        }
                    }
                }
                this.allPeers = {}; // Reset the allPeers object
            },
            disconnectCamera() {
                this.cleanupPeers();
                this.streamingUsers = [];
                this.currentlyContactedUser = null;

                const stream = this.$refs.broadcaster.srcObject;

                if (stream) {
                    stream.getTracks().forEach(track => track.stop());
                    this.$refs.broadcaster.srcObject = null;
                }

                // Leave the presence channel
                Echo.leave(`streaming-channel.${this.streamId}`);
                this.streamingPresenceChannel = null;

                this.isVisibleLink = false;
                this.cameraConnected = false;
            },
            peerCreator(stream, user, signalCallback) {
                let peer;
                return {
                    create: () => {
                        peer = new Peer({
                            initiator: true,
                            trickle: false,
                            stream: stream,
                        });
                    },
                    getPeer: () => peer,
                    initEvents: () => {
                        peer.on("signal", (data) => {
                            // send offer over here.
                            signalCallback(data, user);
                        });
                        peer.on("stream", (stream) => {
                        });
                        peer.on("track", (track, stream) => {

                        });
                        peer.on("connect", () => {
                            if (typeof this.signalCallback === "function") {
                                this.signalCallback(peer, user);
                            }
                        });
                        peer.on("close", () => {

                        });
                        peer.on("error", (err) => {
                            console.log("handle error gracefully");
                        });
                    },
                };
            },
            initializeStreamingChannel() {
                this.streamingPresenceChannel = Echo.join(
                    `streaming-channel.${this.streamId}`
                );

                this.streamingPresenceChannel.here((users) =>
                {
                    // Get all users, apart from the admin
                    this.streamingUsers = users.filter(user => !user.is_admin);

                    // Signal offers to all users waiting in lobby
                    this.streamingUsers.forEach((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.joining(async (user) => {
                    if (user.is_admin) return;

                    // 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();
                    }
                });
            },
            initializeSignalAnswerChannel() {
                Echo.channel(`stream-signal-channel.${this.quizId}`).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) {
                api.post(`/quiz/${this.quizId}/stream-offer`, {
                        broadcaster: this.quizId,
                        receiver: user,
                        offer,
                    })
                    .then((res) => {
                        // console.log(res);
                    })
                    .catch((err) => {
                        console.log(err);
                    });
            },
        },
    }
</script>

Viewer broadcast component

<template>
    <div class="mb-5 panel panel-xs border border-2 border-secondary" :class="isVisible ? 'visible' : 'hidden'">
        <div class="relative pb-[75%]">
            <video class="absolute w-full h-full inset bg-primary rounded-md" ref="viewer" autoplay muted></video>
        </div>
    </div>
</template>
<script>
import Peer from "simple-peer";
import api from "../../../api/axios-api";
import { v4 as uuidv4 } from 'uuid';
export default {
    name: "Viewer",
    props: [
        "quizId",
    ],
    data() {
        return {
            clientId: null,
            streamingPresenceChannel: null,
            broadcasterPeer: null,
            broadcasterId: null,
            streamingUsers: [],
            isVisible: false,
        };
    },
    mounted() {
        this.joinBroadcast();
    },
    beforeUnmount() {
        if (this.broadcasterPeer) {
            this.broadcasterPeer.destroy(); // Disconnect the Peer connection
        }
    },
    methods: {
        joinBroadcast() {
            this.initializeStreamingChannel();
            this.initializeSignalOfferChannel(); // a private channel where the viewer listens to incoming signalling offer
        },
        initializeStreamingChannel() {
            this.streamingPresenceChannel = Echo.join(
                `streaming-channel.${this.quizId}`
            );

            // Listen for channel subscription events
            this.streamingPresenceChannel.here((users) => {
                this.streamingUsers = users;
                this.clientId = Echo.socketId();
            });

            // Listen for channel subscription events
            this.streamingPresenceChannel.leaving((user) => {
                console.log('user left');
            });

            // Listen for new user joining the channel
            this.streamingPresenceChannel.joining((user) => {
                const joiningUserIndex = this.streamingUsers.findIndex(
                    (data) => data.id === user.id
                );

                if (joiningUserIndex < 0) {
                    this.streamingUsers.push(user);
                }
            });
        },
        createViewerPeer(incomingOffer, broadcaster) {
            const peer = new Peer({
                initiator: false,
                trickle: false,
            });
            // 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) => {
                api.post(`/quiz/${this.quizId}/stream-answer`, {
                    broadcaster,
                    answer: data,
                })
                .then((res) => {
                    console.log(res);
                })
                .catch((err) => {
                    console.log(err);
                });
            });
            peer.on("stream", (stream) => {
                const video = this.$refs.viewer;
                video.srcObject = stream;

                // Wait for video metadata to load before playing
                video.addEventListener("loadedmetadata", () => {
                    video.play();
                });

                // display remote stream
                this.isVisible = true;
            });
            peer.on("track", (track, stream) => {
                console.log("onTrack");
            });
            peer.on("connect", () => {
                console.log("Viewer Peer connected");
            });
            peer.on("close", () => {
                peer.destroy();
                cleanupCallback();
            });
            peer.on("error", (err) => {
                console.log(err);
                console.log("handle error gracefully");
            });
            const updatedOffer = {
                ...incomingOffer,
                sdp: `${incomingOffer.sdp}\n`,
            };
            peer.signal(updatedOffer);
        },
        initializeSignalOfferChannel() {
            Echo.channel(`stream-signal-channel.${this.quizId}`).listen(
                "StreamOffer",
                ({ data }) => {
                  if (this.broadcasterId == null) {
                      this.broadcasterId = data.broadcaster;
                      this.createViewerPeer(data.offer, data.broadcaster);
                  }
                }
            );
        },
        removeBroadcastVideo() {
            if (this.broadcasterPeer) {
                this.broadcasterPeer.destroy(); // Disconnect the Peer connection
            }
            this.broadcasterId = null;
            this.streamingUsers = [];

            const tracks = this.$refs.viewer.srcObject.getTracks();
            tracks.forEach((track) => {
                track.stop();
            });

            this.$refs.viewer.srcObject = null;
            this.isVisible = false;

            Echo.leave(`streaming-channel.${this.quizId}`);

            this.streamingPresenceChannel = null;

            setTimeout(() => {
                this.initializeStreamingChannel();
            }, 500);
        }
    },
};
</script>
0

There are 0 best solutions below