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>