From 067b49012e7ecfe0cb9c25cee5daaa6741a3c5f8 Mon Sep 17 00:00:00 2001 From: Miroslav Pejic Date: Wed, 22 Jan 2025 17:32:56 +0100 Subject: [PATCH] [call-me] - #3 Show a drop-down list of registered users to call --- app/server.js | 40 +++++++--- package.json | 2 +- public/client.js | 193 ++++++++++++++++++++++++++++++++++++---------- public/index.html | 11 ++- public/style.css | 27 +++++-- 5 files changed, 212 insertions(+), 61 deletions(-) diff --git a/app/server.js b/app/server.js index 1ab2d04..025293a 100755 --- a/app/server.js +++ b/app/server.js @@ -269,6 +269,9 @@ const isAuthorized = (req) => { function handleConnection(socket) { console.log('User connected:', socket.id); + // Refresh connected users + broadcastConnectedUsers(); + // Send a ping message to the newly connected client sendPing(socket); @@ -287,6 +290,7 @@ function handleConnection(socket) { handleSignIn(data); break; case 'offerAccept': + case 'offerBusy': case 'offerDecline': case 'offerCreate': handleOffer(data); @@ -336,7 +340,7 @@ function handleConnection(socket) { socket.username = name; console.log('User signed in:', name); sendMsgTo(socket, { type: 'signIn', success: true }); - console.log('Connected Users', getConnectedUsers()); + broadcastConnectedUsers(); } else { sendMsgTo(socket, { type: 'signIn', success: false, message: 'Username already in use' }); } @@ -366,6 +370,10 @@ function handleConnection(socket) { console.warn(`User ${name} declined your call`); sendError(recipientSocket || socket, `User ${name} declined your call`); break; + case 'offerBusy': + console.warn(`User ${name} busy in another call`); + sendError(recipientSocket || socket, `User ${name} busy in another call.`); + break; default: console.warn(`Unknown offer type: ${type}`); break; @@ -381,7 +389,7 @@ function handleConnection(socket) { case 'leave': if (recipientSocket !== undefined) { console.log('Leave room', socket.username); - sendMsgTo(recipientSocket, { type: 'leave' }); + sendMsgTo(recipientSocket, { type: 'leave', name: socket.username }); } break; default: @@ -394,10 +402,11 @@ function handleConnection(socket) { // Function to handle the closing of a connection function handleClose() { - if (socket.username) { - console.log('User disconnected:', socket.username); - users.delete(socket.username); - console.log('Connected Users', getConnectedUsers()); + const name = socket.username; + if (name) { + console.log('User disconnected:', name); + users.delete(name); + broadcastConnectedUsers(); } } } @@ -413,9 +422,22 @@ function getConnectedUsers() { return Array.from(users.keys()); } -// Function to broadcast a message to all connection -function broadcastMsg(socket, message) { - console.log('Broadcast message:', message.type); +// Function to broadcast all connected users +function broadcastConnectedUsers() { + const connectedUsers = getConnectedUsers(); + console.log('Connected Users', connectedUsers); + broadcastMsg({ type: 'users', users: connectedUsers }); +} + +// Function to broadcast a message to all connected clients +function broadcastMsg(message) { + console.log('Broadcast message:', message); + io.emit('message', message); +} + +// Function to broadcast a message to all connected clients except the sender +function broadcastMsgExpectSender(socket, message) { + console.log('Broadcast message:', message); socket.broadcast.emit('message', message); } diff --git a/package.json b/package.json index 2a3745a..52a23be 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "call-me", - "version": "1.0.51", + "version": "1.0.60", "description": "Your Go-To for Instant Video Calls", "author": "Miroslav Pejic - miroslav.pejic.85@gmail.com", "license": "AGPLv3", diff --git a/public/client.js b/public/client.js index 5957bb0..246734b 100755 --- a/public/client.js +++ b/public/client.js @@ -3,9 +3,6 @@ // This user agent const userAgent = navigator.userAgent; -// Check device -const isMobileDevice = isMobile(userAgent); - // WebSocket connection to the signaling server const socket = io(); @@ -23,7 +20,7 @@ const signInPage = document.getElementById('signInPage'); const usernameIn = document.getElementById('usernameIn'); const signInBtn = document.getElementById('signInBtn'); const roomPage = document.getElementById('roomPage'); -const callUsernameIn = document.getElementById('callUsernameIn'); +const callUsernameSelect = document.getElementById('callUsernameSelect'); const hideBtn = document.getElementById('hideBtn'); const callBtn = document.getElementById('callBtn'); const swapCameraBtn = document.getElementById('swapCameraBtn'); @@ -36,14 +33,19 @@ const localUsername = document.getElementById('localUsername'); const remoteVideo = document.getElementById('remoteVideo'); // User and connection information +let userInfo; let userName; let connectedUser; let thisConnection; let camera = 'user'; let stream; +// Variable to store the interval ID +let sessionTimerId = null; + // On html page loaded... document.addEventListener('DOMContentLoaded', async function () { + userInfo = getUserInfo(userAgent); handleToolTip(); handleLocalStorage(); handleDirectJoin(); @@ -51,6 +53,28 @@ document.addEventListener('DOMContentLoaded', async function () { fetchRandomImage(); }); +// Get user information from User-Agent string +function getUserInfo(userAgent) { + const parser = new UAParser(userAgent); + const { device, os, browser } = parser.getResult(); + + // Determine device type and specific characteristics + const deviceType = device.type || 'desktop'; + const isIPad = device.model?.toLowerCase() === 'ipad'; + + return { + device: { + isMobile: deviceType === 'mobile', + isTablet: deviceType === 'tablet', + isDesktop: deviceType === 'desktop', + isIPad, + }, + os: { name: os.name || 'Unknown OS', version: os.version || 'Unknown Version' }, + browser: { name: browser.name || 'Unknown Browser', version: browser.version || 'Unknown Version' }, + userAgent, + }; +} + // Handle config appTitle.innerText = app.title; appName.innerText = app.name; @@ -106,6 +130,7 @@ async function checkHostPassword(maxRetries = 3, attempts = 0) { if (validationResult.success) { await Swal.fire({ + position: 'top', icon: 'success', title: 'Access Granted', text: 'Password validated successfully!', @@ -117,6 +142,7 @@ async function checkHostPassword(maxRetries = 3, attempts = 0) { attempts++; if (attempts < maxRetries) { await Swal.fire({ + position: 'top', icon: 'error', title: 'Invalid Password', text: `Please try again. (${attempts}/${maxRetries} attempts)`, @@ -125,6 +151,7 @@ async function checkHostPassword(maxRetries = 3, attempts = 0) { checkHostPassword(maxRetries, attempts); } else { await Swal.fire({ + position: 'top', icon: 'warning', title: 'Too Many Attempts', text: 'You have exceeded the maximum number of attempts. Please try again later.', @@ -138,6 +165,7 @@ async function checkHostPassword(maxRetries = 3, attempts = 0) { } catch (error) { console.error('Error:', error); Swal.fire({ + position: 'top', icon: 'error', title: 'Error', text: 'An error occurred while joining the host.', @@ -177,7 +205,7 @@ async function fetchRandomImage() { // Initialize tooltips and handle hiding them when clicked function handleToolTip() { - if (isMobileDevice) return; + if (userInfo.device.isMobile) return; const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-toggle="tooltip"]')); const tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { @@ -196,7 +224,6 @@ function handleToolTip() { // Handle localStorage data function handleLocalStorage() { usernameIn.value = localStorage.callMeUsername ? localStorage.callMeUsername : ''; - callUsernameIn.value = localStorage.callMeUsernameToCall ? localStorage.callMeUsernameToCall : ''; } // Handle Room direct join @@ -215,25 +242,63 @@ function handleDirectJoin() { if (call) { // Call user if call is provided - callUsernameIn.value = call; - handleCallClick(); + setTimeout(() => { + selectIndexByValue(call); + handleCallClick(); + }, 3000); } } if (!password) checkHostPassword(); } -// Session Time +// Select index by passed value +function selectIndexByValue(value) { + for (let i = 0; i < callUsernameSelect.options.length; i++) { + if (callUsernameSelect.options[i].value === value) { + callUsernameSelect.selectedIndex = i; // Select the option + break; + } + } +} + +// Remove option by value +function removeOptionByValue(value) { + for (let i = 0; i < callUsernameSelect.options.length; i++) { + if (callUsernameSelect.options[i].value === value) { + alert(value); + callUsernameSelect.remove(i); // Remove the matching option + break; + } + } +} + +// Start Session Time function startSessionTime() { console.log('Start session time'); elemDisplay(sessionTime, true, 'inline-flex'); let sessionElapsedTime = 0; - setInterval(function printTime() { + + if (sessionTimerId !== null) { + clearInterval(sessionTimerId); + } + + sessionTimerId = setInterval(function printTime() { sessionElapsedTime++; sessionTime.innerText = secondsToHms(sessionElapsedTime); }, 1000); } +// Stop Session Time +function stopSessionTime() { + console.log('Stop session time'); + if (sessionTimerId !== null) { + clearInterval(sessionTimerId); + sessionTimerId = null; + } + elemDisplay(sessionTime, false); +} + // Session Time in h/m/s function secondsToHms(d) { d = Number(d); @@ -292,8 +357,11 @@ function handleMessage(data) { case 'candidate': handleCandidate(data); break; + case 'users': + handleUsers(data); + break; case 'leave': - handleLeave(); + handleLeave(false); break; case 'error': handleError(data.message, data.message); @@ -303,18 +371,13 @@ function handleMessage(data) { } } -// helpers -function isMobile(userAgent) { - return !!/Android|webOS|iPhone|iPad|iPod|BB10|BlackBerry|IEMobile|Opera Mini|Mobile|mobile/i.test(userAgent || ''); -} - // Enumerate Devices for camera swap functionality function handleEnumerateDevices() { navigator.mediaDevices .enumerateDevices() .then((devices) => { const videoInputs = devices.filter((device) => device.kind === 'videoinput'); - if (videoInputs.length > 1 && isMobileDevice) { + if (videoInputs.length > 1 && userInfo.device.isMobile) { swapCameraBtn.addEventListener('click', swapCamera); elemDisplay(swapCameraBtn, true, 'inline'); } @@ -336,7 +399,7 @@ function handleListeners() { localVideoContainer.addEventListener('click', toggleFullScreen); remoteVideo.addEventListener('click', toggleFullScreen); // Add keyUp listeners - callUsernameIn.addEventListener('keyup', (e) => handleKeyUp(e, handleCallClick)); + callUsernameSelect.addEventListener('keyup', (e) => handleKeyUp(e, handleCallClick)); usernameIn.addEventListener('keyup', (e) => handleKeyUp(e, handleSignInClick)); } @@ -372,10 +435,9 @@ function toggleLocalVideo() { // Handle call button click function handleCallClick() { - const callToUsername = callUsernameIn.value.trim(); + const callToUsername = callUsernameSelect.value.trim(); if (callToUsername.length > 0) { if (callToUsername === userName) { - callUsernameIn.value = ''; handleError('You cannot call yourself.'); return; } @@ -385,7 +447,6 @@ function handleCallClick() { from: userName, to: callToUsername, }); - localStorage.callMeUsernameToCall = callToUsername; popupMsg(`You are calling ${callToUsername}.
Please wait for them to answer.`); } else { handleError('Please enter a username to call.'); @@ -423,6 +484,7 @@ function swapCamera() { // Refresh video streams refreshLocalVideoStream(newStream); refreshPeerVideoStreams(newStream); + // Check video/audio status checkVideoAudioStatus(); }) @@ -503,7 +565,7 @@ function handlePing(data) { type: 'pong', message: { client: 'Hello Server!', - agent: userAgent, + userInfo, }, }); } @@ -511,8 +573,8 @@ function handlePing(data) { // Handle user not found from the server function handleNotFound(data) { const { username } = data; - callUsernameIn.value = ''; handleError(`Username ${username} not found!`); + removeOptionByValue(username); } // Handle sign-in response from the server @@ -603,7 +665,7 @@ function offerCreate() { type: 'offer', offer, }); - elemDisplay(callUsernameIn, false); + elemDisplay(callUsernameSelect, false); }) .catch((error) => { handleError('Error when creating an offer.', error); @@ -612,9 +674,17 @@ function offerCreate() { // Accept incoming offer function offerAccept(data) { + // I'm already in call decline the new one! + if (remoteVideo.srcObject) { + data.type = 'offerBusy'; + sendMsg({ ...data }); + return; + } + sound('ring'); + Swal.fire({ - position: 'center', + position: 'top', imageUrl: 'assets/ring.png', imageWidth: 284, imageHeight: 120, @@ -628,7 +698,7 @@ function offerAccept(data) { hideClass: { popup: 'animate__animated animate__fadeOutUp' }, }).then((result) => { if (result.isConfirmed) { - elemDisplay(callUsernameIn, false); + elemDisplay(callUsernameSelect, false); elemDisplay(callBtn, false); data.type = 'offerCreate'; socket.recipient = data.from; @@ -677,6 +747,22 @@ function handleCandidate(data) { }); } +// Handle connected users +function handleUsers(data) { + console.log('Connected users ------>', data.users); + callUsernameSelect.innerHTML = ''; + data.users.forEach((user) => { + if (user === userName) return; + const option = document.createElement('option'); + option.value = user; + option.textContent = user; + callUsernameSelect.appendChild(option); + }); + if (callUsernameSelect.options.length === 0) { + callUsernameSelect.innerHTML = ''; + } +} + // Play audio sound async function sound(name) { const sound = './assets/' + name + '.wav'; @@ -689,30 +775,53 @@ async function sound(name) { } } -// Handle leaving the room -function handleLeave() { - // Stop local video tracks - if (localVideo.srcObject != null) { - localVideo.srcObject.getTracks().forEach((track) => track.stop()); - localVideo.srcObject = null; +// Helper function to stop all tracks and clear media stream +function stopMediaStream(videoElement) { + if (videoElement.srcObject) { + videoElement.srcObject.getTracks().forEach((track) => track.stop()); + videoElement.srcObject = null; } - // Stop remote video tracks - if (remoteVideo.srcObject != null) { - remoteVideo.srcObject.getTracks().forEach((track) => track.stop()); - remoteVideo.srcObject = null; - } - // Disconnect from server +} + +// Helper function to disconnect and clean up the connection +function disconnectConnection() { if (thisConnection) { thisConnection.close(); thisConnection = null; } - // GoTo homepage - connectedUser = null; - window.location.href = '/'; +} + +// Handle leaving the room +function handleLeave(disconnect = true) { + if (disconnect) { + // Stop local and remote video tracks + stopMediaStream(localVideo); + stopMediaStream(remoteVideo); + + // Disconnect from server and reset state + disconnectConnection(); + connectedUser = null; + + // Redirect to homepage + window.location.href = '/'; + } else { + // Show UI elements + elemDisplay(callUsernameSelect, true); + elemDisplay(callBtn, true, 'inline'); + + // Stop remote video tracks only + stopMediaStream(remoteVideo); + + // Stop session time + stopSessionTime(); + + // Reset state + connectedUser = null; + } } // Handle and display errors -function handleError(message, error = false, position = 'center', timer = 6000) { +function handleError(message, error = false, position = 'top', timer = 6000) { if (error) console.error(error); sound('notify'); Swal.fire({ diff --git a/public/index.html b/public/index.html index a9ad6bd..8197bfe 100755 --- a/public/index.html +++ b/public/index.html @@ -93,10 +93,10 @@ 0s
-
- - -
+ +