Real-time communication has become a core feature in modern applications, enabling users to connect instantly through voice, video, or text across different devices and locations.
In this article, you'll learn how to build a real-time voice chat application using Strapi 5, Vue.js, and WebRTC.
Strapi 5 will be used for the back-end CMS, Vue.js will handle the user interface and layout, and WebRTC will be used for real-time communication. Specifically, we'll use PeerJS, a library that simplifies the process of setting up WebRTC peer-to-peer connections.
By the end of this tutorial, you will have achieved the following:
Let's get started!
Before you dive into the article, make sure you have the following:
WebRTC (Web Real-Time Communication) is an open-source technology that enables real-time communication between devices and browsers. It supports real-time audio and video communication, file sharing, and more.
WebRTC establishes connections between devices using a process called signalling. The signalling process involves exchanging information, such as network details and capabilities, between clients before establishing the direct WebRTC connection. A signalling server acts as an intermediary to relay this information between clients.
Once signalling is complete, the media data (audio, video, files) flows directly between clients without passing through the signalling server. The diagram below illustrates this concept.
While both WebRTC and WebSockets enable real-time communication, they use different approaches.
WebRTC is designed for direct, peer-to-peer communication between clients without requiring an intermediary server. It establishes a direct connection between participating clients, allowing them to exchange data, audio, and video directly.
In contrast, WebSockets rely on a central server for communication. Clients connect to the WebSocket server and exchange messages bidirectionally through this server. All data must pass through the server, which can introduce latency. The difference is shown in the image below.
First, open a new terminal instance. Navigate to a preferred directory and run the following command to create a new Strapi 5 project.
mkdir voice-chat-application
cd voice-chat-application
npx create-strapi@latest voice-chat-api --quickstart
This command creates a new Strapi 5 folder voice-chat-api
in the voice-chat-application
folder.
After installation completes, your browser will open a page prompting you to create an admin account, as shown below:
Enter your details in the form to access the admin dashboard:
In the next section, you'll create test users that you'll use to test your application later on.
With the server running, create test users for the application.
Navigate to the Content Manager section in the admin dashboard and select Create New Entry as shown here:
Fill in the user details and click save:
Repeat this process once more to create a second test user.
Now that the Strapi server is set up and you have test users, you can proceed with integrating WebSockets using Socket.IO. Open the project in your preferred IDE or text editor.
WebSockets will be used for two key purposes in this application:
A user rejects a call
To integrate WebSockets into your Strapi 5 backend, you'll need to install Socket.IO. In a new terminal instance, navigate to your chosen directory and run the following command:
1npm install socket.io @types/socket.io
After installation is done, navigate to src/index.ts
. Import and declare the Socket.IO module with the following lines of code.
1import { Server } from "socket.io";
2import type { Core } from "@strapi/strapi";
3
4declare module "socket.io" {
5 interface Socket {
6 userId?: number;
7 username?: string;
8 }
9}
Next, create a new Socket.IO connection by updating the bootstrap
method in the index.ts
file.
1export default {
2 register() {},
3
4 async bootstrap({ strapi }: { strapi: Core.Strapi }) {
5 const io = new Server(strapi.server.httpServer, {
6 cors: {
7 origin: ["http://localhost:8080"],
8 methods: ["GET", "POST"],
9 credentials: true,
10 },
11 path: "/ws",
12 });
The code snippet above uses Strapi's bootstrap lifecycle function to create a new Socket.IO server instance and configure the path and origin for the frontend application.
After creating a new instance of the Socket.IO server, the next step is to add a setup for listening to events. Update your src/index.ts
file with the code snippet below.
1const onlineUsers: Record<number, string> = {};
2
3 io.on("connection", (socket) => {
4
5 //Events for when a
6 socket.on("userConnected", ({ userId, username }) => {
7 socket.userId = userId;
8 socket.username = username;
9 socket.join(userId.toString());
10 onlineUsers[userId] = username;
11 io.emit("updateOnlineUsers", onlineUsers);
12 console.log(`User ${username} connected and joined room ${userId}`);
13 });
14
15 socket.on("disconnect", () => {
16 if (!socket.userId) return;
17 delete onlineUsers[socket.userId];
18 io.emit("updateOnlineUsers", onlineUsers);
19 console.log(`User ${socket.username} disconnected`);
20 });
21
22 socket.on("initiateCall", ({ callerId, callerUsername, calleeId }) => {
23 console.log(`Initiating call from ${callerId} to ${calleeId}`);
24 const calleeRoom = calleeId.toString();
25 if (!io.sockets.adapter.rooms.has(calleeRoom)) {
26 console.log(`Room for calleeId ${calleeId} does not exist. Cannot emit incoming call.`);
27 return;
28 }
29 io.to(calleeRoom).emit("incomingCall", {
30 from: callerId,
31 username: callerUsername,
32 });
33 console.log(`Incoming call event emitted to user ${calleeId} in room ${calleeRoom}`);
34 });
35
36 socket.on("callRejected", ({ callerId, message }) => {
37 const callerSocketId = onlineUsers[callerId];
38 if (!callerSocketId) {
39 console.log(`Caller with user ID ${callerId} is not online`);
40 return;
41 }
42 io.to(callerSocketId).emit("callRejected", { message });
43 console.log("callRejected sent to socket:", callerSocketId);
44 });
45
46 socket.on("acceptCall", ({ callerId, calleeId, calleeUsername }) => {
47 io.to(callerId).emit("callAccepted", {
48 by: calleeId,
49 username: calleeUsername,
50 });
51 });
52 });
53 },
54};
The code snippet above first initializes a variable named onlineUsers
. It then defines five events that dispatch on specified conditions:
userConnected
: Fired when a user connects. Saves the user’s details, joins a room, and updates the list of online users.disconnect
: Fired when a user disconnects. Removes the user from the online users list and notifies others.initiateCall
: Fired when a user initiates a call. Checks if the callee is online and sends an "incomingCall" event to their room.callRejected
: Fired when a call is rejected. Notifies the caller with a rejection message.acceptCall
: Fired when a call is accepted. Notifies the caller that the call was accepted.PeerJS is a library that simplifies peer-to-peer communication. It acts as a wrapper around the browser's WebRTC implementation to provide an easy way of implementing WebRTC in applications.
To set up the WebRTC signalling server, install the Peer server globally by running this command:
npm install peer -g
After a successful installation, start the signalling server with this command:
peerjs --port 9000 --key peerjs --path /myapp
NOTE: The command above can be run from anywhere in your terminal. This is because we installed the
peer
package globally.
The command above starts the PeerJS signaling server, configuring it to run on port 9000, with an authentication key of 'peerjs', and listening on the /myapp
path.
With your signalling server setup, you can now proceed with building the user interface of the chat application.
Now that the backend part of the voice chat application is fully set up, it’s time to build the user interface of the application.
Navigate to the project folder created earlier and create a new Vue.js project using this command:
cd voice-chat-application
npx @vue/cli create voice-chat-frontend
When prompted, select the Vue 3 default option.
Vue CLI v5.0.8
? Please pick a preset: (Use arrow keys)
❯ Default ([Vue 3] babel, eslint)
Default ([Vue 2] babel, eslint)
Manually select features
After creating the new project, install all the dependencies needed for the Vue.js app's real-time functionality. Run this command in your terminal:
cd voice-chat-frontend
npm install axios socket.io-client vuex vue-router peerjs
The following packages will be installed:
After installing the necessary dependencies, set up the store for user state management.
In the src/
folder, create two new folders named router
and store
. Then create a file named index.js
in each folder.
Navigate to src/store/index.js
and add the code for the Vuex store.
1import { createStore } from "vuex";
2import axios from "axios";
3import io from "socket.io-client";
4
5export default createStore({
6 state: {
7 user: JSON.parse(localStorage.getItem("user")) || null,
8 token: localStorage.getItem("token") || null,
9 socket: null,
10 },
11 mutations: {
12 setUser(state, user) {
13 state.user = user;
14 localStorage.setItem("user", JSON.stringify(user));
15 },
16 setToken(state, token) {
17 state.token = token;
18 localStorage.setItem("token", token);
19 },
20 setSocket(state, socket) {
21 state.socket = socket;
22 },
23 },
24 actions: {
25 async login({ commit, dispatch }, { identifier, password }) {
26 try {
27 const response = await axios.post(
28 "http://localhost:1337/api/auth/local", // Strapi login endpoint
29 {
30 identifier,
31 password,
32 }
33 );
34 const { user, jwt } = response.data;
35 commit("setUser", user);
36 commit("setToken", jwt);
37
38 // Initialize socket after login
39 await dispatch("initializeSocket");
40 } catch (error) {
41 console.error("Login failed:", error);
42 throw error;
43 }
44 },
45
46 async initializeSocket({ commit, state }) {
47 if (!state.user) return; // Ensure user is logged in before connecting
48
49 // Initialize socket connection
50 const socket = io("http://localhost:1337", {
51 transports: ["websocket"],
52 path: "/ws",
53 reconnection: true,
54 reconnectionDelay: 1000,
55 reconnectionDelayMax: 5000,
56 reconnectionAttempts: 5,
57 });
58
59 socket.on("connect", () => {
60 console.log("Connected to WebSocket server");
61 socket.emit("userConnected", { userId: state.user.id, username: state.user.username });
62 });
63
64
65
66 socket.on("disconnect", () => {
67 console.log("Disconnected from server");
68 // socket.emit("userDisconnected", { userId: state.user.id });
69 });
70
71 // Store socket instance in state
72 commit("setSocket", socket);
73 },
74
75 logout({ commit, state }) {
76 // Remove user data from local storage
77 localStorage.removeItem("user");
78 localStorage.removeItem("token");
79
80 // Disconnect socket if it exists
81 if (state.socket) {
82 state.socket.emit("userDisconnected", { userId: state.user.id });
83 state.socket.disconnect();
84 }
85
86 // Clear state
87 commit("setUser", null);
88 commit("setToken", null);
89 commit("setSocket", null);
90 },
91 },
92});
In the code above, the Vuex store manages user authentication, state persistence, and real-time communication. It tracks the user's information, authentication token, and WebSocket instance in the state. The mutations section modifies the user, token, and socket instance in both the state and localStorage while actions handle tasks like logging in (storing user data and token), initializing the WebSocket connection, and logging out.
Next, set up Vue Router to handle navigation between different views in the application. Add the routing configuration to src/router/index.js
.
1import { createRouter, createWebHistory } from "vue-router";
2import LoginView from "../components/LoginView.vue";
3import DashboardView from "../components/DashboardView.vue";
4import CallView from "../components/CallView.vue";
5import store from "../store";
6
7const routes = [
8 { path: "/", redirect: "/login" },
9 { path: "/login", component: LoginView },
10 {
11 path: "/dashboard",
12 name: "dashboard",
13 component: DashboardView,
14 meta: { requiresAuth: true },
15 },
16 {
17 path: "/call/:userId/:username",
18 name: "call",
19 component: CallView,
20 props: true,
21 meta: { requiresAuth: true },
22 },
23];
24
25const router = createRouter({
26 history: createWebHistory(),
27 routes,
28});
29
30// Navigation guard
31router.beforeEach((to, from, next) => {
32 const isAuthenticated = store.state.user || localStorage.getItem("user");
33
34 if (to.meta.requiresAuth && !isAuthenticated) {
35 next("/login");
36 } else {
37 next();
38 }
39});
40
41export default router;
In the code above, a Vue Router instance is configured to handle navigation in the voice chat application. Routes are set up for authentication, and specific views are provided for the dashboard and calling functionality.
The router.beforeEach
navigation guard checks if the user is authenticated before allowing access to routes with requiresAuth
.
To set up the basic structure and style for your Vue.js application, update the App.vue
and main.js
files. These files create the main layout and initialize the app.
Navigate to src/App.vue
and update it with the following lines of code.
1<template>
2 <div id="app">
3 <h1>Voice Chat App</h1>
4 <router-view></router-view>
5 </div>
6</template>
7
8<script>
9export default {
10 name: 'App'
11}
12</script>
13
14<style>
15#app {
16 font-family: Arial, sans-serif;
17 max-width: 400px;
18 margin: 0 auto;
19 padding: 20px;
20}
21
22h1 {
23 text-align: center;
24 color: #333;
25}
26
27.login-form, .call-interface {
28 display: flex;
29 flex-direction: column;
30 gap: 10px;
31}
32
33input, button {
34 padding: 10px;
35 font-size: 16px;
36}
37
38button {
39 background-color: #4CAF50;
40 color: white;
41 border: none;
42 cursor: pointer;
43 transition: background-color 0.3s;
44}
45
46button:hover {
47 background-color: #45a049;
48}
49</style>
Next, navigate to src/main.js
and update it with this code:
1import { createApp } from "vue";
2import App from "./App.vue";
3import router from "./router";
4import store from "./store";
5
6createApp(App).use(router).use(store).mount("#app");
The App.vue
file serves as the main layout for your app. Inside the <template>
section, you have a container <div id="app">
that houses a title <h1>Voice Chat App</h1>
and a <router-view>
element that dynamically loads different components according to the route as users navigate through the app. Additionally styling for the application is defined here.
The main.js
file serves as the entry point for your Vue application. It imports required modules, creates a Vue instance with createApp(App)
, adds router and store plugins, and mounts the application to the HTML element with ID #app
. With the front-end setup complete, you can now create the components.
Create three new components to handle user login, dashboard display, and the main call interface.
Navigate to src/components
and create three new files titled LoginView.vue
, DashboardView.vue
and finally CallView.vue
.
The LoginView
will be the login UI, while the DashboardView
will display the logged-in user. Finally, the CallView
will show the Real-Time call.
LoginView.vue
Open the LoginView.vue
file and add the following code.1<template>
2 <div class="login-form">
3 <h2>Login</h2>
4 <input v-model="identifier" placeholder="Username or Email" />
5 <input v-model="password" type="password" placeholder="Password" />
6 <button @click="handleLogin">Login</button>
7 </div>
8</template>
9
10<script setup>
11import { ref } from 'vue'
12import { useStore } from 'vuex'
13import { useRouter } from 'vue-router'
14
15const store = useStore()
16const router = useRouter()
17
18// State
19const identifier = ref('')
20const password = ref('')
21
22// Methods
23async function handleLogin() {
24 try {
25 await store.dispatch('login', {
26 identifier: identifier.value,
27 password: password.value
28 })
29 router.push('/dashboard')
30 } catch (error) {
31 console.error('Login failed:', error)
32 }
33}
34</script>
This component above allows users to log in to the application. Upon successful login, they’ll be redirected to the dashboard view where they'll be able to see a list of active users.
DashBoardView.vue
Next, set up the DashboardView.vue
component. This component lists online users and allows users to initiate or receive calls from active users. Update the DashboardView.vue
component with the following code.
1<template>
2 <div class="dashboard">
3 <h2>Welcome, {{ user.username }}</h2>
4 <h3>Online Users</h3>
5 <ul class="online-users">
6 <li v-for="onlineUser in onlineUsers" :key="onlineUser.id" @click="initiateCall(onlineUser)" class="user-item">
7 {{ onlineUser.username }}
8 <span class="status-dot online"></span>
9 </li>
10 </ul>
11
12 <!-- Incoming Call Modal -->
13 <div v-if="incomingCall" class="call-modal">
14 <h3>Incoming Call from {{ incomingCall.username }}</h3>
15 <button @click="acceptCall">Accept</button>
16 <button @click="rejectCall">Reject</button>
17 </div>
18
19 <div v-if="rejectionMessage" class="rejection-notification">
20 {{ rejectionMessage }}
21 </div>
22
23 </div>
24</template>
The code above sets up the view interface to display the online users. It also allows the user to initiate and reject calls. Next, add a setup for listening to sending and listening to events from the Strapi backend.
Next, add setup for WebSocket. Update your DashboardView.vue
file with the following lines of code.
1// previous code goes here
2<script setup>
3import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
4import { useStore } from 'vuex'
5import { useRouter } from 'vue-router'
6
7const store = useStore()
8const router = useRouter()
9
10// State
11const onlineUsers = ref([])
12const socket = ref(null)
13const incomingCall = ref(null)
14const callRejected = ref(null)
15const rejectionMessage = ref(null);
16
17// Computed
18const user = computed(() => store.state.user)
19
20// Lifecycle hooks
21onMounted(() => {
22 connectSocket()
23})
24
25onBeforeUnmount(() => {
26 if (socket.value) {
27 socket.value.disconnect()
28 }
29})
30
31// Methods
32function connectSocket() {
33 if (store.state.socket) {
34 // Use the existing socket connection
35 socket.value = store.state.socket;
36 } else {
37 // Initialize socket if not already connected
38 store.dispatch('initializeSocket');
39 socket.value = store.state.socket;
40 }
41
42 socket.value.on('updateOnlineUsers', (users) => {
43 onlineUsers.value = Object.entries(users)
44 .map(([userId, username]) => ({ userId: Number(userId), username }))
45 .filter((u) => u.userId !== user.value.id);
46 });
47
48 socket.value.on('incomingCall', (data) => {
49 incomingCall.value = data;
50 });
51
52 socket.value.on('callRejected', (data) => {
53 rejectionMessage.value = data.message;
54 setTimeout(() => {
55 rejectionMessage.value = null;
56 router.push({ name: 'dashboard' });
57 }, 2000);
58 });
59}
The code snippet above defines the function connectSocket
on the onMounted
lifecycle method of the view. The method is responsible for listening to the events broadcasted by the backend.
Now, add functions to initiate, accept or reject calls. Update your DashboardView.view
file with the below code snippet.
1//previous code goe shere
2function initiateCall(callUser) {
3 socket.value.emit('initiateCall', {
4 callerId: user.value.id,
5 callerUsername: user.value.username,
6 calleeId: callUser.userId,
7 })
8 if (!callRejected.value) {
9 router.push({
10 name: 'call',
11 params: {
12 userId: callUser.userId,
13 username: callUser.username,
14 },
15 })
16 }
17
18}
19
20function acceptCall() {
21 if (incomingCall.value) {
22 callRejected.value = false
23 socket.value.emit('acceptCall', {
24 callerId: incomingCall.value.from,
25 calleeId: user.value.id,
26 calleeUsername: user.value.username,
27 })
28 router.push({
29 name: 'call',
30 params: {
31 userId: incomingCall.value.from,
32 username: incomingCall.value.username,
33 },
34 query: {
35 accepted: 'true'
36 }
37 })
38 store.commit('setSocket', socket.value)
39 incomingCall.value = null
40 }
41}
42
43function rejectCall() {
44 callRejected.value = true;
45 socket.value.emit('callRejected', {
46 callerId: incomingCall.value.from,
47 message: 'Your call was rejected by the callee.',
48 });
49 incomingCall.value = null
50}
51
52</script>
53
54<style scoped>
55.user-item {
56 display: flex;
57 align-items: center;
58 padding: 10px;
59 cursor: pointer;
60}
61
62.status-dot {
63 width: 8px;
64 height: 8px;
65 border-radius: 50%;
66 margin-left: 10px;
67}
68
69.online {
70 background-color: #4CAF50;
71}
72
73.call-modal {
74 position: fixed;
75 top: 50%;
76 left: 50%;
77 transform: translate(-50%, -50%);
78 background: white;
79 padding: 20px;
80 border-radius: 8px;
81 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
82}
83
84.rejection-notification {
85 position: fixed;
86 top: 20px;
87 left: 50%;
88 transform: translateX(-50%);
89 background-color: #ff4d4f;
90 color: white;
91 padding: 10px 20px;
92 border-radius: 4px;
93 box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.2);
94 z-index: 1000;
95}
96</style>
In the code above, three functions are initateCall
, acceptCall
and rejectCall
.
initateCall
: This method is triggered upon click of the online user displayed and it emits an event to the backend which then broadcasts the method to the user intended to be called.acceptCall
: This method is fired when the callee(the user being called) accepts an incoming call and it redirects the user to the call view which we will build next.rejectCall
: This method is fired when the user chooses to reject an incoming call.After creating the login and dashboard view. The next step in building our voice chat application is to implement the real time voice chat functionality. Update your CallView.vue
file with the below code snippet.
1<template>
2 <div class="call-interface">
3 <h2>Call with {{ callee.username }}</h2>
4 <div v-if="connectionStatus" class="status-message">
5 {{ connectionStatus }}
6 </div>
7 <div v-if="connectionError" class="error-message">
8 {{ connectionError }}
9 </div>
10 <button @click="endCall" v-else>End Call</button>
11 <audio ref="remoteAudio" autoplay playsinline></audio>
12 </div>
13</template>
The code above defines the user interface for the call view with an audio tag to play the stream audio. The next step is to initialize a new peer instance for peer-to-peer connection. Update the CallView.vue
file with the following.
1//previous code goes here
2<script setup>
3import { ref, onMounted, onUnmounted, computed } from 'vue'
4import { useRoute, useRouter } from 'vue-router'
5import { useStore } from 'vuex'
6import Peer from 'peerjs'
7
8const route = useRoute()
9const router = useRouter()
10const store = useStore()
11
12// Core refs
13const peer = ref(null)
14const call = ref(null)
15const remoteAudio = ref(null)
16const localStream = ref(null)
17
18// UI state
19const callActive = ref(false)
20const isPeerConnected = ref(false)
21const isConnecting = ref(false)
22const connectionStatus = ref('')
23const connectionError = ref('')
24
25// User data
26const callee = ref({ id: null, username: '' })
27const currentUser = computed(() => store.state.user)
28
29onMounted(async () => {
30 const userId = route.params.userId
31 const username = route.params.username
32 const callAccepted = route.query.accepted
33
34 if (!userId || !username) {
35 console.error("Missing userId or username in route parameters")
36 router.push('/dashboard')
37 return
38 }
39
40 callee.value = { id: Number(userId), username }
41
42 try {
43 await initializePeer()
44 if (callAccepted === 'true') {
45 await startCall()
46 }
47 } catch (error) {
48 connectionError.value = `Failed to initialize: ${error.message}`
49 }
50})
51
52onUnmounted(() => {
53 cleanup()
54})
55
56async function initializePeer() {
57 try {
58 connectionStatus.value = 'Initializing connection...'
59
60 // Create a single peer for the current user
61 peer.value = new Peer(currentUser.value.id.toString(), {
62 host: 'localhost',
63 port: 9000,
64 path: '/myapp',
65 debug: 3,
66 config: {
67 iceServers: [
68 { urls: 'stun:stun.l.google.com:19302' },
69 { urls: 'stun:stun1.l.google.com:19302' }
70 ]
71 }
72 })
73
74 // Set up event handlers for this peer
75 peer.value.on('open', () => {
76 isPeerConnected.value = true
77 connectionStatus.value = 'Connected to server'
78 })
79
80 peer.value.on('call', handleIncomingCall)
81
82 peer.value.on('error', (error) => {
83 connectionError.value = `Connection error: ${error.message}`
84 cleanup()
85 })
86
87 peer.value.on('disconnected', () => {
88 connectionStatus.value = 'Disconnected, attempting to reconnect...'
89 isPeerConnected.value = false
90 peer.value?.reconnect()
91 })
92
93 peer.value.on('close', () => {
94 cleanup()
95 })
96
97 } catch (error) {
98 console.error('Peer initialization error:', error)
99 throw error
100 }
101}
In the code above, an initializePeer
method is defined in the onMounted
lifecycle method to create a new peer instance for each user. The method configures a new PeerJS instance with the user ID as the identifier. It then connects to a local PeerJS at localhost/myapp
, /myapp
here represents a dedicated path which the signalling server listens on. The configuration includes Google's STUN servers for NAT traversal (stun.l.google.com:19302 and stun1.l.google.com:19302) and sets debug level 3 for detailed connection logging.
Also, the peer event listeners are set up to listen for various events that would be emitted by the other party.
Next, add methods to handle calls on both sides(caller and callee).
1// previous code goes here
2async function startCall() {
3 try {
4 isConnecting.value = true
5 connectionError.value = ''
6 connectionStatus.value = 'Getting audio access...'
7
8 // Get local audio stream
9 localStream.value = await navigator.mediaDevices.getUserMedia({ video: false, audio: true })
10
11 // Initiate call to the other user
12 connectionStatus.value = 'Calling...'
13 call.value = peer.value.call(callee.value.id.toString(), localStream.value)
14
15 if (!call.value) {
16 throw new Error('Failed to initiate call')
17 }
18
19 setupCallHandlers(call.value)
20
21 } catch (error) {
22 console.error('Call failed:', error)
23 connectionError.value = `Call failed: ${error.message}`
24 cleanup()
25 } finally {
26 isConnecting.value = false
27 }
28}
29
30async function handleIncomingCall(incomingCall) {
31 try {
32 connectionStatus.value = 'Incoming call...'
33
34 // Get local audio stream if we don't have it yet
35 if (!localStream.value) {
36 localStream.value = await navigator.mediaDevices.getUserMedia({ video: false, audio: true })
37 }
38
39 // Answer the call with our audio stream
40 incomingCall.answer(localStream.value)
41 setupCallHandlers(incomingCall)
42
43 } catch (error) {
44 console.error('Failed to handle incoming call:', error)
45 connectionError.value = `Failed to answer: ${error.message}`
46 incomingCall.close()
47 }
48}
49
50function setupCallHandlers(currentCall) {
51 currentCall.on('stream', (remoteStream) => {
52
53 if (remoteAudio.value) {
54 // Ensure the remote stream has audio tracks
55 const audioTracks = remoteStream.getAudioTracks()
56
57 if (audioTracks.length > 0) {
58 audioTracks.forEach(track => {
59 track.enabled = true
60 })
61 } else {
62 console.warn("No audio tracks in remote stream")
63 return
64 }
65
66 try {
67 // Set the remote stream and attempt playback
68 remoteAudio.value.srcObject = remoteStream
69 const playPromise = remoteAudio.value.play()
70
71 if (playPromise !== undefined) {
72 playPromise
73 .then(() => {
74 // Only set call as active if audio is playing
75 callActive.value = true
76 connectionStatus.value = 'Call connected'
77 connectionError.value = ''
78 })
79 .catch(error => {
80 console.error("Audio playback failed:", error)
81 connectionError.value = 'Audio playback failed'
82 })
83 }
84 } catch (error) {
85 console.error("Error setting up audio:", error)
86 connectionError.value = 'Failed to setup audio'
87 }
88 }
89 })
90
91 currentCall.on('close', () => {
92 cleanup()
93 })
94
95 currentCall.on('error', (error) => {
96 connectionError.value = `Call error: ${error.message}`
97 cleanup()
98 })
99}
100
101function cleanup() {
102 // Stop all audio tracks
103 if (localStream.value) {
104 localStream.value.getTracks().forEach(track => track.stop())
105 localStream.value = null
106 }
107
108 // Close the call
109 if (call.value) {
110 call.value.close()
111 call.value = null
112 }
113
114 // Destroy the peer connection
115 if (peer.value) {
116 peer.value.destroy()
117 peer.value = null
118 }
119
120 // Reset all state
121 callActive.value = false
122 isPeerConnected.value = false
123 isConnecting.value = false
124 connectionStatus.value = ''
125 connectionError.value = ''
126}
127
128function endCall() {
129 cleanup()
130}
131</script>
The code above defines 4 functions, let's go over each one.
startCall
: This method is fired when a callee accepts a call. First, it prompts the user to give microphone permissions. Then the peerjs call method is to establish a peer to peer communication with the other user.handleIncomingCall
: This method is fired when a user receives a call event(remember we defined it earlier). It prompts the user for microphone permissions and uses the answer method from peerjs to connect the the caller.setupCallhandlers
: This method gets the remote stream i.e. the audio coming from the caller and connects it to the local stream(the callee audio) using track.enabled to unmute the remote audio.cleanUp
: This function closes the connections and cleans up the remote audio whenever a user ends the call or if an error occurs.It's time to finally see our voice chat application in action.
Start your vue.js server by running the command below in your terminal.
npm run serve
Once the server is running, open the Firefox browser and navigate to the frontend URL shown in the terminal.
Log in as one user in a regular browser tab and as another user in an incognito tab. Call one user from a tab click accept on the other tab and start interacting in real time.
Below is a video demonstration of the chat application(audio silenced).
In this article, you learnt how to build a real-time voice chat application with Strapi 5, Vue.js and WebRTC. This article provided a solid foundation for creating communication applications powered by WebRTC. With the skills you've learned, you can now build more sophisticated real-time applications.
Cheers to more learning!
Oluwadamilola Oshungboye is a Software Engineer and Technical Writer with a passion for sharing knowledge with the community . He can be reached on X (formerly known as Twitter).