Files

3758 lines
128 KiB
JavaScript
Executable File

'use strict';
// This user agent
const userAgent = navigator.userAgent;
// WebSocket connection to the signaling server
const socket = io();
// WebRTC configuration
const config = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] };
// DOM elements
const appTitle = document.getElementById('appTitle');
const appName = document.getElementById('appName');
const attribution = document.getElementById('attribution');
const randomImage = document.getElementById('randomImage');
const sessionTime = document.getElementById('sessionTime');
const githubDiv = document.getElementById('githubDiv');
const signInPage = document.getElementById('signInPage');
const usernameIn = document.getElementById('usernameIn');
const signInBtn = document.getElementById('signInBtn');
const joinVideoToggle = document.getElementById('joinVideoToggle');
const joinAudioToggle = document.getElementById('joinAudioToggle');
const roomPage = document.getElementById('roomPage');
const exitSidebarBtn = document.getElementById('exitSidebarBtn');
const userSidebar = document.getElementById('userSidebar');
const userSearchInput = document.getElementById('userSearchInput');
const userList = document.getElementById('userList');
const sidebarBtn = document.getElementById('sidebarBtn');
const usersTab = document.getElementById('usersTab');
const chatTab = document.getElementById('chatTab');
const settingsTab = document.getElementById('settingsTab');
const usersContent = document.getElementById('usersContent');
const chatContent = document.getElementById('chatContent');
const settingsContent = document.getElementById('settingsContent');
const chatNotification = document.getElementById('chatNotification');
const participantCount = document.getElementById('participantCount');
const messageCount = document.getElementById('messageCount');
const chatMessages = document.getElementById('chatMessages');
const chatForm = document.getElementById('chatForm');
const chatInput = document.getElementById('chatInput');
const emojiBtn = document.getElementById('emojiBtn');
const emojiPicker = document.getElementById('emojiPicker');
const saveChatBtn = document.getElementById('saveChatBtn');
const clearChatBtn = document.getElementById('clearChatBtn');
const videoSelect = document.getElementById('videoSelect');
const audioSelect = document.getElementById('audioSelect');
const audioOutputSelect = document.getElementById('audioOutputSelect');
const testDevicesBtn = document.getElementById('testDevicesBtn');
const refreshDevicesBtn = document.getElementById('refreshDevicesBtn');
const shareRoomBtn = document.getElementById('shareRoomBtn');
const hideLocalVideoToggle = document.getElementById('hideLocalVideoToggle');
const swapCameraBtn = document.getElementById('swapCameraBtn');
const videoBtn = document.getElementById('videoBtn');
const audioBtn = document.getElementById('audioBtn');
const screenShareBtn = document.getElementById('screenShareBtn');
const leaveBtn = document.getElementById('leaveBtn');
const localVideoContainer = document.getElementById('localVideoContainer');
const localVideo = document.getElementById('localVideo');
const remoteAudioDisabled = document.getElementById('remoteAudioDisabled');
const remoteVideoDisabled = document.getElementById('remoteVideoDisabled');
const localUsername = document.getElementById('localUsername');
const remoteUsername = document.getElementById('remoteUsername');
const remoteVideo = document.getElementById('remoteVideo');
const directCallInput = document.getElementById('directCallInput');
const directCallBtn = document.getElementById('directCallBtn');
const callingOverlay = document.getElementById('callingOverlay');
const callingUsername = document.getElementById('callingUsername');
const callingTimer = document.getElementById('callingTimer');
const cancelCallBtn = document.getElementById('cancelCallBtn');
const incomingCallOverlay = document.getElementById('incomingCallOverlay');
const incomingCallUsername = document.getElementById('incomingCallUsername');
const incomingCallTimer = document.getElementById('incomingCallTimer');
const acceptCallBtn = document.getElementById('acceptCallBtn');
const declineCallBtn = document.getElementById('declineCallBtn');
const pushNotificationGroup = document.getElementById('pushNotificationGroup');
const pushNotificationToggle = document.getElementById('pushNotificationToggle');
const pushTestBtn = document.getElementById('pushTestBtn');
// Ensure app is defined, even if config.js is not loaded
const app = window.myAppConfig || {};
// User and connection information
let userInfo;
let userName;
let connectedUser;
let pendingUser; // Track outgoing call target
let callingTimerId = null; // Timer for calling overlay
let callingElapsed = 0; // Seconds elapsed while calling
let incomingCallData = null; // Pending incoming call data
let incomingCallTimerId = null; // Auto-decline timer for incoming call
let ringTimeout = 30; // Seconds before unanswered call is auto-cancelled/declined (from server)
let ringingAudio = null; // Looping ring sound for incoming call
let thisConnection;
let camera = 'user';
let stream;
let isScreenSharing = false;
let originalStream = null; // Store original camera stream
let wasCameraOffBeforeScreenShare = false; // Track camera state before screen share
// User list state
let userSignedIn = false;
let allConnectedUsers = [];
let filteredUsers = [];
let selectedUser = null;
// Push notification state
let pushEnabled = false;
let pushSubscription = null;
// Chat state
let unreadMessages = 0;
let currentTab = 'users';
// Store last applied media status for reapplication after stream connection
let lastAppliedMediaStatus = null;
// Device state
let availableDevices = {
videoInputs: [],
audioInputs: [],
audioOutputs: [],
};
let selectedDevices = {
videoInput: null,
audioInput: null,
audioOutput: null,
};
// Variable to store the interval ID
let sessionTimerId = null;
// Data channel and file transfer state
let dataChannel = null;
let incomingFileMeta = null;
let incomingFileData = null;
let fileInput = null;
let pendingFileRecipient = null;
let incomingFileBuffers = [];
let incomingFileReceivedBytes = 0;
let outgoingTransfer = null;
let incomingTransfer = null;
let fileTransferStatusEl = null;
// On html page loaded...
document.addEventListener('DOMContentLoaded', async function () {
userInfo = getUserInfo(userAgent);
await initI18n();
handleConfig();
handleToolTip();
handleLocalStorage();
await handleDirectJoin();
handleListeners();
initializeFileSharing();
await 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';
const osName = os.name || 'Unknown OS';
const osVersion = os.version || 'Unknown Version';
const browserName = browser.name || 'Unknown Browse';
const browserVersion = browser.version || 'Unknown Version';
return {
device: {
isMobile: deviceType === 'mobile',
isTablet: deviceType === 'tablet',
isDesktop: deviceType === 'desktop',
isIPad,
},
os: `${osName} ${osVersion}`,
browser: `${browserName} ${browserVersion}`,
userAgent,
};
}
// Handle config - only set custom values if provided, otherwise let i18n handle it
function handleConfig() {
// Only override if custom values are provided in config
if (app?.title && app.title !== 'Call-me') {
appTitle.innerText = app.title;
}
if (app?.name && app.name !== 'Call-me') {
appName.innerText = app.name;
}
// Hide elements based on config conditions
const elementsToHide = [
{ condition: !(app?.showGithub ?? true), element: githubDiv },
{ condition: !(app?.attribution ?? true), element: attribution },
];
elementsToHide.forEach(({ condition, element }) => {
if (condition && element) {
elemDisplay(element, false);
}
});
}
async function checkHostPassword(maxRetries = 3, attempts = 0) {
try {
// Fetch host configuration
const { data: config } = await axios.get('/api/hostPassword');
if (config.isPasswordRequired) {
// Show prompt for the password
const { value: password } = await Swal.fire({
heightAuto: false,
scrollbarPadding: false,
title: t('host.protectedTitle'),
text: t('host.enterPassword'),
input: 'password',
inputPlaceholder: t('host.passwordPlaceholder'),
inputAttributes: {
autocapitalize: 'off',
autocorrect: 'off',
},
imageUrl: 'assets/locked.png',
imageWidth: 150,
imageHeight: 150,
allowOutsideClick: false,
allowEscapeKey: false,
showDenyButton: true,
confirmButtonText: t('host.submit'),
denyButtonText: t('settings.cancel'),
preConfirm: (password) => {
if (!password) {
Swal.showValidationMessage(t('host.passwordEmpty'));
}
return password;
},
});
// If the user cancels, exit
if (!password) {
return;
}
// Validate the password
const { data: validationResult } = await axios.post('/api/hostPasswordValidate', { password });
if (validationResult.success) {
await Swal.fire({
heightAuto: false,
scrollbarPadding: false,
position: 'top',
icon: 'success',
title: t('host.accessGrantedTitle'),
text: t('host.accessGrantedText'),
timer: 1500,
showConfirmButton: false,
});
elemDisplay(signInPage, true);
} else {
attempts++;
if (attempts < maxRetries) {
await Swal.fire({
heightAuto: false,
scrollbarPadding: false,
position: 'top',
icon: 'error',
title: t('host.invalidPasswordTitle'),
text: t('host.invalidPasswordText', { attempts, maxRetries }),
});
// Retry the process
await checkHostPassword(maxRetries, attempts);
} else {
await Swal.fire({
heightAuto: false,
scrollbarPadding: false,
position: 'top',
icon: 'warning',
title: t('host.tooManyAttemptsTitle'),
text: t('host.tooManyAttemptsText'),
});
}
}
} else {
// No password required
elemDisplay(signInPage, true);
}
} catch (error) {
console.error('Error:', error);
Swal.fire({
heightAuto: false,
scrollbarPadding: false,
position: 'top',
icon: 'error',
title: t('messages.error'),
text: t('host.joinError'),
});
}
}
// Get Random Images
async function fetchRandomImage() {
if (sessionStorage.cachedImage) {
// If there's cached data, use it
randomImage.src = sessionStorage.cachedImage;
console.log('Using cached image');
return;
}
try {
const response = await axios.get('/randomImage');
const data = response.data;
// Cache the image URL for subsequent calls
sessionStorage.cachedImage = data.urls.regular;
// Update the image source
randomImage.src = sessionStorage.cachedImage;
console.log('Fetched and cached image');
// Create and display attribution
const attributionText = `Photo by <a href="${data.user.links.html}?utm_source=call-me&utm_medium=referral" target="_blank">${data.user.name}</a> on <a href="https://unsplash.com/?utm_source=call-me&utm_medium=referral" target="_blank">Unsplash</a>`;
// Assuming you have an element with id 'attribution' for the attribution text
attribution.innerHTML = attributionText;
} catch (error) {
console.error('Error fetching image', error.message);
}
}
// Initialize tooltips and handle hiding them when clicked
function handleToolTip() {
if (userInfo.device.isMobile) return;
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-toggle="tooltip"]'));
const tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
// Always force placement to bottom for sidebar tabs
if (
tooltipTriggerEl.classList.contains('sidebar-tab') &&
tooltipTriggerEl.getAttribute('data-placement') !== 'bottom'
) {
tooltipTriggerEl.setAttribute('data-placement', 'bottom');
}
return new bootstrap.Tooltip(tooltipTriggerEl, {
placement: tooltipTriggerEl.getAttribute('data-placement') || 'bottom',
});
});
// Add click event listener to hide all tooltips
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
tooltipTriggerEl.addEventListener('click', function () {
tooltipList.forEach(function (tooltip) {
tooltip.hide(); // Hide all tooltips
});
});
});
}
// Handle localStorage data
function handleLocalStorage() {
usernameIn.value = localStorage.callMeUsername
? localStorage.callMeUsername
: 'Guest' + Math.floor(Math.random() * 10000);
// Restore media toggle states
if (localStorage.callMeJoinVideo !== undefined) {
joinVideoToggle.checked = localStorage.callMeJoinVideo === 'true';
}
if (localStorage.callMeJoinAudio !== undefined) {
joinAudioToggle.checked = localStorage.callMeJoinAudio === 'true';
}
// Persist toggle changes
joinVideoToggle.addEventListener('change', () => {
localStorage.callMeJoinVideo = joinVideoToggle.checked;
});
joinAudioToggle.addEventListener('change', () => {
localStorage.callMeJoinAudio = joinAudioToggle.checked;
});
}
// Handle Room direct join
async function handleDirectJoin() {
const usp = new URLSearchParams(window.location.search);
const user = usp.get('user');
const call = usp.get('call');
const password = usp.get('password');
if (user) {
console.log('Direct Join detected', { user, call, password });
// SignIn
usernameIn.value = user;
handleSignInClick();
if (call) {
// Call user if call is provided
setTimeout(() => {
handleUserClickToCall(call);
}, 3000);
}
}
if (!password) await checkHostPassword();
}
// Start Session Time
function startSessionTime() {
console.log('Start session time');
elemDisplay(sessionTime, true, 'inline-flex');
let sessionElapsedTime = 0;
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);
let h = Math.floor(d / 3600);
let m = Math.floor((d % 3600) / 60);
let s = Math.floor((d % 3600) % 60);
let hDisplay = h > 0 ? h + 'h' : '';
let mDisplay = m > 0 ? m + 'm' : '';
let sDisplay = s > 0 ? s + 's' : '';
return hDisplay + ' ' + mDisplay + ' ' + sDisplay;
}
// WebSocket event listeners
socket.on('connect', handleSocketConnect);
socket.on('message', handleMessage);
socket.on('error', handleSocketError);
// Handle WebSocket connection establishment
function handleSocketConnect() {
console.log('Connected to the signaling server');
}
// Handle WebSocket errors
function handleSocketError(err) {
handleError(t('errors.socketError'), err.message);
}
// Handle incoming messages based on type
function handleMessage(data) {
const { type } = data;
console.log('Got message', type);
switch (type) {
case 'ping':
handlePing(data);
break;
case 'signIn':
handleSignIn(data);
break;
case 'notfound':
handleNotFound(data);
break;
case 'pushSent':
handlePushSent(data);
break;
case 'offerAccept':
offerAccept(data);
break;
case 'offerCreate':
// Apply caller's media status when receiving offerCreate
if (data.callerMediaStatus) {
applyCallerMediaStatus(data.callerMediaStatus);
}
offerCreate();
break;
case 'offerDecline':
handleOfferDecline(data);
break;
case 'offerBusy':
handleOfferBusy(data);
break;
case 'offer':
handleOffer(data);
break;
case 'answer':
handleAnswer(data);
break;
case 'candidate':
handleCandidate(data);
break;
case 'users':
handleUsers(data);
break;
case 'remoteAudio':
handleRemoteAudio(data);
break;
case 'remoteVideo':
handleRemoteVideo(data);
break;
case 'remoteScreenShare':
handleRemoteScreenShare(data);
break;
case 'chat':
addChatMessage(data, false);
break;
case 'leave':
handleLeave(false);
break;
case 'error':
handleError(data.message, data.message);
break;
default:
break;
}
}
// Enumerate Devices for camera swap functionality
async function handleEnumerateDevices() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoInputs = devices.filter((device) => device.kind === 'videoinput');
if (videoInputs.length > 1 && userInfo.device.isMobile) {
swapCameraBtn.addEventListener('click', swapCamera);
elemDisplay(swapCameraBtn, true, 'inline-flex');
}
// Check if screen sharing is supported
if (navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) {
elemDisplay(screenShareBtn, true, 'inline-flex');
} else {
elemDisplay(screenShareBtn, false);
console.log('Screen sharing not supported in this browser');
}
} catch (error) {
handleError(t('errors.enumerateDevicesFailed'), error);
}
}
// Handle Listeners
function handleListeners() {
signInBtn.addEventListener('click', handleSignInClick);
shareRoomBtn.addEventListener('click', async () => {
await handleShareRoomClick();
});
hideLocalVideoToggle.addEventListener('change', toggleLocalVideo);
videoBtn.addEventListener('click', handleVideoClick);
audioBtn.addEventListener('click', handleAudioClick);
screenShareBtn.addEventListener('click', handleScreenShareClick);
leaveBtn.addEventListener('click', handleLeaveClick);
exitSidebarBtn.addEventListener('click', handleExitSidebarClick);
localVideoContainer.addEventListener('click', toggleFullScreen);
remoteVideo.addEventListener('click', toggleFullScreen);
usernameIn.addEventListener('keyup', (e) => handleKeyUp(e, handleSignInClick));
document.getElementById('randomUsernameBtn').addEventListener('click', handleRandomUsername);
document.getElementById('copyUsernameBtn').addEventListener('click', handleCopyUsername);
usersTab.addEventListener('click', () => switchTab('users'));
chatTab.addEventListener('click', () => switchTab('chat'));
settingsTab.addEventListener('click', () => switchTab('settings'));
// Settings event listeners
videoSelect.addEventListener('change', handleVideoDeviceChange);
audioSelect.addEventListener('change', handleAudioDeviceChange);
audioOutputSelect.addEventListener('change', handleAudioOutputDeviceChange);
testDevicesBtn.addEventListener('click', testDevices);
refreshDevicesBtn.addEventListener('click', refreshDevices);
// Chat event listeners
emojiBtn.addEventListener('click', handleEmojiClick);
saveChatBtn.addEventListener('click', handleSaveChatClick);
clearChatBtn.addEventListener('click', handleClearChatClick);
// Direct call listeners
directCallBtn.addEventListener('click', handleDirectCallClick);
directCallInput.addEventListener('keyup', (e) => handleKeyUp(e, handleDirectCallClick));
cancelCallBtn.addEventListener('click', handleCancelCall);
acceptCallBtn.addEventListener('click', handleAcceptIncomingCall);
declineCallBtn.addEventListener('click', handleDeclineIncomingCall);
// Language change event listener - reapply config after translation
window.addEventListener('languageChanged', () => {
handleConfig();
renderUserList();
});
// Sidebar toggle
if (sidebarBtn && userSidebar) {
sidebarBtn.addEventListener('click', (e) => {
e.stopPropagation();
userSidebar.classList.toggle('active');
// Clear message badge when opening sidebar with chat tab active
if (userSidebar.classList.contains('active') && currentTab === 'chat') {
unreadMessages = 0;
updateChatNotification();
}
});
// Utility to handle click outside for any element
function handleClickOutside(targetElement, triggerElement, callback, minWidth = 0) {
document.addEventListener('click', (e) => {
if (minWidth && window.innerWidth > minWidth) return;
let el = e.target;
let shouldExclude = false;
while (el) {
if (el instanceof HTMLElement && (el === targetElement || el === triggerElement)) {
shouldExclude = true;
break;
}
el = el.parentElement;
}
if (!shouldExclude) callback();
});
}
// Hide sidebar on mobile when clicking outside
handleClickOutside(
userSidebar,
sidebarBtn,
() => {
if (userSidebar.classList.contains('active')) {
userSidebar.classList.remove('active');
}
},
768
);
// Hide emoji picker when clicking outside
handleClickOutside(emojiPicker, emojiBtn, () => {
if (emojiPicker && emojiPicker.classList.contains('show')) {
emojiPicker.classList.remove('show');
}
});
}
}
// Initialize hidden file input and handlers for file sharing
function initializeFileSharing() {
fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
fileInput.addEventListener('change', async () => {
const file = fileInput.files[0];
if (!file) return;
if (!dataChannel || dataChannel.readyState !== 'open') {
toast(t('file.dataChannelNotReady'), 'warning', 'top', 3000);
fileInput.value = '';
return;
}
if (!connectedUser || pendingFileRecipient !== connectedUser) {
toast(t('file.onlyActiveParticipant'), 'warning', 'top', 3000);
fileInput.value = '';
return;
}
try {
await sendFileOverDataChannel(file);
} catch (error) {
handleError(t('file.failedToSend'), error.message || error);
} finally {
fileInput.value = '';
pendingFileRecipient = null;
}
});
// Create global file transfer status container (top-center of the screen)
if (!fileTransferStatusEl) {
fileTransferStatusEl = document.createElement('div');
fileTransferStatusEl.id = 'fileTransferStatus';
fileTransferStatusEl.className = 'file-transfer-status';
document.body.appendChild(fileTransferStatusEl);
}
}
// Hide sidebar after user selection (on mobile)
function handleUserClickToCall(user) {
if (!user) {
handleError(t('errors.noUserSelected'));
return;
}
if (user === userName) {
handleError(t('errors.cannotCallSelf'));
return;
}
selectedUser = user;
pendingUser = user;
renderUserList();
sendMsg({
type: 'offerAccept',
from: userName,
to: user,
});
showCallingOverlay(user);
if (userSidebar.classList.contains('active')) {
userSidebar.classList.remove('active');
}
}
// Handle element display
function elemDisplay(element, display, mode = 'block') {
if (element) element.style.display = display ? mode : 'none';
}
// Generic keyUp handler
function handleKeyUp(e, callback) {
if (e.key === 'Enter') {
e.preventDefault();
callback();
}
}
// Handle sign-in button click
function handleSignInClick() {
userName = usernameIn.value.trim();
if (userName.length > 0) {
sendMsg({
type: 'signIn',
name: userName,
});
localStorage.callMeUsername = userName;
}
}
// Handle direct call button click (sign in + auto-call)
function handleDirectCallClick() {
const myName = usernameIn.value.trim();
const callTarget = directCallInput ? directCallInput.value.trim() : '';
if (!myName) {
handleError(t('signIn.enterUsername'));
usernameIn.focus();
return;
}
if (!callTarget) {
handleError(t('errors.noUserSelected'));
if (directCallInput) directCallInput.focus();
return;
}
// Store call target for after sign-in
userName = myName;
localStorage.callMeUsername = myName;
sendMsg({
type: 'signIn',
name: myName,
});
// After sign-in completes, auto-call the target user
const waitForSignIn = setInterval(() => {
if (userSignedIn) {
clearInterval(waitForSignIn);
setTimeout(() => handleUserClickToCall(callTarget), 1500);
}
}, 200);
// Safety timeout to avoid infinite loop
setTimeout(() => clearInterval(waitForSignIn), 15000);
}
// Show calling overlay
function showCallingOverlay(targetUser) {
if (!callingOverlay) return;
callingElapsed = 0;
if (callingUsername) callingUsername.textContent = targetUser;
if (callingTimer) callingTimer.textContent = '0s';
callingOverlay.style.display = 'flex';
if (callingTimerId) clearInterval(callingTimerId);
callingTimerId = setInterval(() => {
callingElapsed++;
if (callingTimer) callingTimer.textContent = callingElapsed + 's';
if (callingElapsed >= ringTimeout) {
handleCancelCall();
}
}, 1000);
}
// Hide calling overlay
function hideCallingOverlay() {
if (!callingOverlay) return;
callingOverlay.style.display = 'none';
if (callingTimerId) {
clearInterval(callingTimerId);
callingTimerId = null;
}
callingElapsed = 0;
}
// Handle cancel call button click
function handleCancelCall() {
hideCallingOverlay();
if (pendingUser) {
// Notify the remote user that the call was cancelled
sendMsg({ type: 'leave', name: pendingUser });
pendingUser = null;
}
toast(t('messages.callEnded'), 'info', 'top', 2000);
}
// Share Room click handler
async function handleShareRoomClick() {
const roomUrl = window.location.origin;
if (navigator.share) {
try {
await navigator.share({
title: document.title,
text: t('messages.shareRoomText'),
url: roomUrl,
});
} catch (error) {
await copyToClipboard(roomUrl, false);
}
} else {
await copyToClipboard(roomUrl);
}
}
// Generate random username
function handleRandomUsername() {
const adjectives = ['Cool', 'Fast', 'Bright', 'Swift', 'Bold', 'Calm', 'Lucky', 'Brave', 'Clever', 'Happy'];
const nouns = ['Fox', 'Eagle', 'Tiger', 'Wolf', 'Hawk', 'Lion', 'Bear', 'Panda', 'Falcon', 'Shark'];
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const num = Math.floor(Math.random() * 1000);
usernameIn.value = `${adj}${noun}${num}`;
usernameIn.focus();
}
// Copy username to clipboard
async function handleCopyUsername() {
const username = usernameIn.value.trim();
if (!username) {
toast(t('signIn.enterUsername'), 'warning', 'top', 2000);
usernameIn.focus();
return;
}
try {
await navigator.clipboard.writeText(username);
toast(t('messages.usernameCopied', { username }), 'success', 'top', 3000);
} catch (error) {
handleError(t('errors.copyToClipboardFailed'), error.message);
}
}
// Copy text to clipboard
async function copyToClipboard(text, showError = true) {
try {
await navigator.clipboard.writeText(text);
toast(t('messages.roomCopied', { text }), 'success', 'top', 3000);
} catch (error) {
showError
? handleError(t('errors.copyToClipboardFailed'), error.message)
: console.error('Failed to copy to clipboard', error);
}
}
// Toggle local video visibility
function toggleLocalVideo() {
localVideoContainer.classList.toggle('hide');
}
// Handle call button click
function handleCallBtnClick() {
handleUserClickToCall(selectedUser);
}
// Find the video sender reliably — works even when sender.track is null (camera off)
function findVideoSender() {
if (!thisConnection) return null;
// First try direct match
const direct = thisConnection.getSenders().find((s) => s.track && s.track.kind === 'video');
if (direct) return direct;
// Fallback: use transceivers (receiver.track.kind is always set even when sender.track is null)
const transceiver = thisConnection
.getTransceivers()
.find((t) => t.receiver && t.receiver.track && t.receiver.track.kind === 'video');
return transceiver ? transceiver.sender : null;
}
// Toggle video stream — actually stop/restart the camera to release hardware (turn off LED)
async function handleVideoClick() {
// During screen sharing, toggle the screen share track visibility instead of camera
if (isScreenSharing) {
const screenTrack = stream.getVideoTracks()[0];
if (!screenTrack) return;
screenTrack.enabled = !screenTrack.enabled;
videoBtn.classList.toggle('btn-danger');
showCameraOffOverlay('local', !screenTrack.enabled);
// Update peer connection
if (thisConnection) {
const videoSender = findVideoSender();
if (videoSender) {
try {
await videoSender.replaceTrack(screenTrack.enabled ? screenTrack : null);
} catch (error) {
console.warn('Failed to toggle screen share track on peer:', error);
}
}
}
sendMediaStatusToServer();
sendMsg({ type: 'remoteVideo', enabled: screenTrack.enabled });
return;
}
// Normal camera toggle (not screen sharing)
const videoTrack = stream.getVideoTracks()[0];
const isCameraOn = videoTrack && videoTrack.readyState === 'live' && videoTrack.enabled;
if (isCameraOn) {
// Stop the camera track to release the hardware and turn off the LED
videoTrack.stop();
videoTrack.enabled = false;
videoBtn.classList.add('btn-danger');
showCameraOffOverlay('local', true);
// Replace the track on the peer connection with null to stop sending video
if (thisConnection) {
const videoSender = findVideoSender();
if (videoSender) {
try {
await videoSender.replaceTrack(null);
} catch (error) {
console.warn('Failed to replace video track with null:', error);
}
}
}
// Send media status to server
sendMediaStatusToServer();
sendMsg({ type: 'remoteVideo', enabled: false });
} else {
// Re-acquire camera to restart the hardware
try {
const constraints = {
video: selectedDevices.videoInput ? { deviceId: { exact: selectedDevices.videoInput } } : true,
audio: false,
};
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
const newVideoTrack = newStream.getVideoTracks()[0];
if (!newVideoTrack) {
throw new Error('No video track found in new stream');
}
// Replace the old stopped track in the stream
const oldVideoTrack = stream.getVideoTracks()[0];
if (oldVideoTrack) {
stream.removeTrack(oldVideoTrack);
}
stream.addTrack(newVideoTrack);
localVideo.srcObject = stream;
handleVideoMirror(localVideo, stream);
// Update peer connection with the new track
if (thisConnection) {
const videoSender = findVideoSender();
if (videoSender) {
await videoSender.replaceTrack(newVideoTrack);
}
}
videoBtn.classList.remove('btn-danger');
showCameraOffOverlay('local', false);
// Send media status to server
sendMediaStatusToServer();
sendMsg({ type: 'remoteVideo', enabled: true });
} catch (error) {
console.error('Failed to restart camera:', error);
handleError(t('errors.cameraRestartFailed') || 'Failed to restart camera');
}
}
}
// Toggle audio stream
function handleAudioClick() {
const audioTrack = stream.getAudioTracks()[0];
audioTrack.enabled = !audioTrack.enabled;
audioBtn.classList.toggle('btn-danger');
// Send media status to server
sendMediaStatusToServer();
sendMsg({
type: 'remoteAudio',
enabled: audioTrack.enabled,
});
}
// Handle screen sharing toggle
async function handleScreenShareClick() {
try {
if (isScreenSharing) {
// Stop screen sharing and return to camera
await stopScreenSharing();
} else {
// Start screen sharing
await startScreenSharing();
}
} catch (error) {
handleError(t('errors.screenShareFailed'), error.message);
console.error('Screen sharing error:', error);
}
}
// Update remote video styling based on screen sharing state
function updateRemoteVideoStyling(isScreenSharing) {
console.log('updateRemoteVideoStyling called with:', isScreenSharing);
console.log('remoteVideo element:', remoteVideo);
console.log('remoteVideo has srcObject:', !!remoteVideo?.srcObject);
if (isScreenSharing) {
remoteVideo.classList.add('screen-share');
remoteVideo.classList.remove('camera-feed');
console.log('Applied screen-share styling. Classes:', remoteVideo.className);
} else {
remoteVideo.classList.add('camera-feed');
remoteVideo.classList.remove('screen-share');
console.log('Applied camera-feed styling. Classes:', remoteVideo.className);
}
}
// Start screen sharing
async function startScreenSharing() {
try {
// Store original camera stream
originalStream = stream;
// Determine if camera was off (track stopped) before screen share
wasCameraOffBeforeScreenShare = videoBtn && videoBtn.classList.contains('btn-danger');
// Get screen share stream
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: 'always',
displaySurface: 'monitor',
},
audio: true,
});
// Keep original audio track if available
const audioTrack = originalStream.getAudioTracks()[0];
if (audioTrack) {
screenStream.addTrack(audioTrack);
}
// Screen share video respects camera state: if camera was off, start with screen share hidden
const screenVideoTrack = screenStream.getVideoTracks()[0];
if (screenVideoTrack) {
screenVideoTrack.enabled = !wasCameraOffBeforeScreenShare;
}
// Update stream and local video
stream = screenStream;
localVideo.srcObject = stream;
localVideo.classList.remove('mirror'); // Remove mirror for screen share
localVideo.classList.add('screen-share'); // Apply screen share styling
localVideo.classList.remove('camera-feed');
// Show/hide camera off overlay based on camera state
showCameraOffOverlay('local', wasCameraOffBeforeScreenShare);
console.log('Local video classes after screen share start:', localVideo.className);
console.log('Camera was off before screen share:', wasCameraOffBeforeScreenShare);
// Update peer connection if it exists (use helper to find sender even when track is null)
if (thisConnection) {
const videoSender = findVideoSender();
if (videoSender) {
await videoSender.replaceTrack(wasCameraOffBeforeScreenShare ? null : screenStream.getVideoTracks()[0]);
}
}
// Update UI
isScreenSharing = true;
screenShareBtn.classList.add('btn-warning');
screenShareBtn.classList.remove('btn-success');
screenShareBtn.title = t('controls.stopScreenShare');
screenShareBtn.innerHTML = '<i class="fas fa-stop"></i>';
// Keep video button state matching camera state
if (wasCameraOffBeforeScreenShare) {
videoBtn.classList.add('btn-danger');
} else {
videoBtn.classList.remove('btn-danger');
}
// Send screen sharing status to server
sendMediaStatusToServer();
// Listen for screen share end (user clicks browser's stop sharing)
screenStream.getVideoTracks()[0].onended = () => {
stopScreenSharing();
};
toast(t('messages.screenShareStarted'), 'success', 'top', 2000);
console.log('Screen sharing started');
} catch (error) {
if (error.name === 'NotAllowedError') {
handleError(t('errors.screenSharePermissionDenied'));
} else if (error.name === 'NotSupportedError') {
handleError(t('errors.screenShareNotSupported'));
} else {
handleError(t('errors.screenShareStartFailed'), error.message);
}
throw error;
}
}
// Stop screen sharing
async function stopScreenSharing() {
try {
if (!originalStream) {
handleError(t('errors.noOriginalStreamAvailable'));
return;
}
// Use stored flag for camera state (video button now reflects screen share, not camera)
const wasCameraOff = wasCameraOffBeforeScreenShare;
// Stop screen share tracks
if (stream) {
stream.getTracks().forEach((track) => {
if (track.kind === 'video' || (track.kind === 'audio' && track.label.includes('monitor'))) {
track.stop();
}
});
}
// Restore original camera stream
stream = originalStream;
const cameraVideoTrack = stream.getVideoTracks()[0];
if (wasCameraOff) {
// Camera was off before screen share — keep it off (track is already stopped)
// Don't try to set .enabled on a stopped track
console.log('Camera was off before screen share, keeping it off');
} else {
// Camera was on — ensure track is enabled
if (cameraVideoTrack && cameraVideoTrack.readyState === 'live') {
cameraVideoTrack.enabled = true;
}
}
localVideo.srcObject = stream;
handleVideoMirror(localVideo, stream); // Restore mirror for camera
localVideo.classList.remove('screen-share'); // Remove screen share styling
localVideo.classList.add('camera-feed'); // Apply camera feed styling
// Show/hide camera off overlay based on camera state
showCameraOffOverlay('local', wasCameraOff);
console.log('Local video classes after screen share stop:', localVideo.className);
console.log('Camera was off before screen share:', wasCameraOff);
// Update peer connection if it exists
if (thisConnection) {
const videoSender = findVideoSender();
if (videoSender) {
if (wasCameraOff) {
// Camera was off — send null to peer
await videoSender.replaceTrack(null);
} else if (cameraVideoTrack && cameraVideoTrack.readyState === 'live') {
// Camera was on — send camera track to peer
await videoSender.replaceTrack(cameraVideoTrack);
}
}
}
// Update UI
isScreenSharing = false;
screenShareBtn.classList.remove('btn-warning');
screenShareBtn.classList.add('btn-success');
screenShareBtn.title = t('controls.startScreenShare');
screenShareBtn.innerHTML = '<i class="fas fa-desktop"></i>';
// Send screen sharing status to server
sendMediaStatusToServer();
// Reset original stream reference
originalStream = null;
wasCameraOffBeforeScreenShare = false;
// Ensure UI button state matches actual video state
checkVideoAudioStatus();
toast(t('messages.screenShareStopped'), 'success', 'top', 2000);
console.log('Screen sharing stopped');
} catch (error) {
handleError(t('errors.screenShareStopFailed'), error.message);
console.error('Stop screen sharing error:', error);
}
}
// Detect if back or front camera
function detectCameraFacingMode(stream) {
if (!stream || !stream.getVideoTracks().length) {
console.warn("No video track found in the stream. Defaulting to 'user'.");
return 'user';
}
const videoTrack = stream.getVideoTracks()[0];
const settings = videoTrack.getSettings();
const capabilities = videoTrack.getCapabilities?.() || {};
const facingMode = settings.facingMode || capabilities.facingMode?.[0] || 'user';
return facingMode === 'environment' ? 'environment' : 'user';
}
// Function to swap between user-facing and environment cameras
async function swapCamera() {
camera = camera === 'user' ? 'environment' : 'user';
const videoConstraints = camera === 'user' ? true : { facingMode: { exact: camera } };
try {
const newStream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints });
// Stop the previous video track
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack) {
videoTrack.stop();
}
// Update selectedDevices.videoInput so video toggle re-acquires the correct camera
const newVideoTrack = newStream.getVideoTracks()[0];
if (newVideoTrack) {
const newSettings = newVideoTrack.getSettings();
if (newSettings.deviceId) {
selectedDevices.videoInput = newSettings.deviceId;
}
}
// Refresh video streams
refreshLocalVideoStream(newStream);
await refreshPeerVideoStreams(newStream);
// Check video/audio status
checkVideoAudioStatus();
} catch (error) {
handleError(t('errors.swapCameraFailed'), error);
}
}
// Update the local video stream
function refreshLocalVideoStream(newStream) {
const videoTrack = newStream.getVideoTracks()[0];
if (!videoTrack) {
handleError(t('errors.noVideoTrackInStream'));
return;
}
// Preserve previous video enabled state (if the user had video disabled)
let wasVideoEnabled = true;
try {
if (stream && stream.getVideoTracks().length > 0) {
wasVideoEnabled = stream.getVideoTracks()[0].enabled;
} else if (videoBtn && videoBtn.classList.contains('btn-danger')) {
wasVideoEnabled = false;
}
} catch (e) {
console.warn('Could not determine previous video enabled state:', e);
}
// Apply preserved enabled state to new track
videoTrack.enabled = wasVideoEnabled;
const audioTrack = stream.getAudioTracks()[0];
if (!audioTrack) {
handleError(t('errors.noAudioTrackInStream'));
return;
}
const updatedStream = new MediaStream([videoTrack, audioTrack]); // Create a new stream only with valid tracks
stream = updatedStream;
localVideo.srcObject = stream;
// Ensure camera feed styling is maintained during swap
localVideo.classList.add('camera-feed');
localVideo.classList.remove('screen-share');
// Reflect video state in UI and overlays
if (wasVideoEnabled) {
videoBtn && videoBtn.classList.remove('btn-danger');
showCameraOffOverlay('local', false);
} else {
videoBtn && videoBtn.classList.add('btn-danger');
showCameraOffOverlay('local', true);
}
handleVideoMirror(localVideo, stream);
}
// handle video mirror
function handleVideoMirror(video, stream) {
const cameraFacingMode = detectCameraFacingMode(stream);
cameraFacingMode === 'environment'
? video.classList.remove('mirror') // Back camera → No mirror
: video.classList.add('mirror'); // Disable mirror for rear camera
}
// Update the video stream for all peers
async function refreshPeerVideoStreams(newStream) {
if (!thisConnection) return;
const videoTrack = newStream.getVideoTracks()[0];
if (!videoTrack) {
handleError(t('errors.noVideoTrackForPeer'));
return;
}
const videoSender = findVideoSender();
if (videoSender) {
try {
await videoSender.replaceTrack(videoTrack);
} catch (error) {
handleError(t('errors.replacingTrack', { message: error.message }));
}
}
}
// Check video audio status
function checkVideoAudioStatus() {
if (videoBtn.classList.contains('btn-danger')) {
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack && videoTrack.readyState === 'live') {
videoTrack.enabled = false;
}
// Show camera off overlay for local video
showCameraOffOverlay('local', true);
} else {
// Hide camera off overlay for local video
showCameraOffOverlay('local', false);
}
if (audioBtn.classList.contains('btn-danger')) {
const audioTrack = stream.getAudioTracks()[0];
if (audioTrack) {
audioTrack.enabled = false;
}
}
}
// Handle leave button click
function handleLeaveClick() {
sendMsg({ type: 'leave', name: socket.recipient });
handleLeave();
}
// Handle leaving the call
function handleExitSidebarClick() {
if (userSidebar.classList.contains('active')) {
userSidebar.classList.remove('active');
}
}
// Toggle video full screen mode
function toggleFullScreen(e) {
if (!e.target.srcObject) return;
document.fullscreenElement ? document.exitFullscreen() : e.target.requestFullscreen?.();
}
// Handle ping message from the server
function handlePing(data) {
const { iceServers } = data;
if (iceServers) {
config.iceServers = iceServers;
}
if (data.pushEnabled !== undefined) {
pushEnabled = data.pushEnabled;
}
if (data.ringTimeout !== undefined) {
ringTimeout = data.ringTimeout;
}
sendMsg({
type: 'pong',
message: {
client: 'Hello Server!',
userInfo,
},
});
}
// Handle user not found from the server
function handleNotFound(data) {
const { username } = data;
hideCallingOverlay();
handleError(t('errors.userNotFound', { username }));
// Remove from user list if present
allConnectedUsers = allConnectedUsers.filter((u) => u !== username);
filterUserList(userSearchInput.value || '');
updateParticipantCount();
}
// Handle push notification sent confirmation
function handlePushSent(data) {
const { username } = data;
hideCallingOverlay();
toast(
t('push.notificationSent', { username }) ||
`Notification sent to ${username}. Waiting for them to come online...`,
'info',
'top',
6000
);
sound('notify');
}
// Handle call declined by remote user
function handleOfferDecline(data) {
const { from } = data;
hideCallingOverlay();
pendingUser = null;
toast(t('room.callDeclined', { username: from }), 'warning', 'top', 4000);
sound('notify');
}
// Handle remote user busy in another call
function handleOfferBusy(data) {
const { from } = data;
hideCallingOverlay();
pendingUser = null;
toast(t('room.callBusy', { username: from }), 'warning', 'top', 4000);
sound('notify');
}
// Handle sign-in response from the server
async function handleSignIn(data) {
const { success, message } = data;
if (!success) {
handleError(message);
if (!message.startsWith('Invalid username')) {
setTimeout(handleLeaveClick, 3000);
}
} else {
userSignedIn = true;
if (userInfo.device.isDesktop) userSidebar.classList.toggle('active');
if (userInfo.device.isMobile) userSidebar.style.width = '100%';
elemDisplay(githubDiv, false);
elemDisplay(attribution, false);
elemDisplay(signInPage, false);
elemDisplay(roomPage, true);
let myStream = null;
let lastError = null;
// Try to get media with progressive fallback
try {
// Try video + audio first
myStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
console.log('Successfully obtained video + audio stream');
} catch (error) {
lastError = error;
console.warn('Video + audio failed, trying video only:', error.name);
try {
// Try video only
myStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
console.log('Successfully obtained video-only stream');
} catch (error2) {
lastError = error2;
console.warn('Video only failed, trying audio only:', error2.name);
try {
// Try audio only
myStream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
console.log('Successfully obtained audio-only stream');
} catch (error3) {
lastError = error3;
console.error('All getUserMedia attempts failed:', error3.name);
}
}
}
if (myStream) {
stream = myStream;
localVideo.srcObject = stream;
localVideo.playsInline = true;
localVideo.autoplay = true;
localVideo.muted = true;
localVideo.volume = 0;
localVideo.controls = false;
localVideo.classList.add('camera-feed'); // Set default styling for camera
localUsername.innerText = userName;
updateUsernameDisplay();
initializeConnection();
await handleEnumerateDevices();
// Initialize device settings after getting media
await initializeDeviceSettings();
handleVideoMirror(localVideo, myStream);
// Apply initial media toggles from sign-in card
if (!joinVideoToggle.checked) {
const vt = stream.getVideoTracks()[0];
if (vt) {
vt.stop();
vt.enabled = false;
}
videoBtn.classList.add('btn-danger');
showCameraOffOverlay('local', true);
}
if (!joinAudioToggle.checked) {
const at = stream.getAudioTracks()[0];
if (at) at.enabled = false;
audioBtn.classList.add('btn-danger');
}
// Update UI based on available devices
updateUIForAvailableDevices();
// Send initial media status to server
sendMediaStatusToServer();
// Show push notification toggle if server has push enabled
if (pushEnabled) {
initPushNotificationToggle();
}
} else {
// All attempts failed, show error only now
handleMediaStreamError(lastError);
}
}
}
// Handle common media stream error
function handleMediaStreamError(error) {
console.error('GetUserMedia error', error.message);
let errorMessage = error;
let shouldHandleGetUserMediaError = true;
switch (error.name) {
case 'NotFoundError':
case 'DevicesNotFoundError':
errorMessage = t('errors.getUserMediaNotFound');
break;
case 'NotReadableError':
case 'TrackStartError':
errorMessage = t('errors.getUserMediaInUse');
break;
case 'OverconstrainedError':
case 'ConstraintNotSatisfiedError':
errorMessage = t('errors.getUserMediaConstraints');
break;
case 'NotAllowedError':
case 'PermissionDeniedError':
errorMessage = t('errors.getUserMediaPermissionDenied');
break;
case 'AbortError':
errorMessage = t('errors.getUserMediaAborted');
break;
case 'SecurityError':
errorMessage = t('errors.getUserMediaSecurity');
break;
default:
errorMessage = t('errors.getUserMediaDefault');
shouldHandleGetUserMediaError = false;
break;
}
if (shouldHandleGetUserMediaError) {
errorMessage += t('errors.getUserMediaHelp');
}
Swal.fire({
heightAuto: false,
scrollbarPadding: false,
position: 'top',
icon: 'warning',
html: errorMessage,
denyButtonText: t('common.exit'),
showDenyButton: true,
showConfirmButton: false,
allowOutsideClick: false,
allowEscapeKey: false,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.isDenied) {
window.location.href = '/';
}
});
sound('alert');
}
// Initialize WebRTC connection
function initializeConnection() {
thisConnection = new RTCPeerConnection(config);
// Handle incoming data channels (file transfer, etc.)
thisConnection.ondatachannel = (event) => {
const channel = event.channel;
setupDataChannel(channel);
};
// Add existing tracks from local stream
stream.getTracks().forEach((track) => thisConnection.addTrack(track, stream));
// Ensure we have transceivers for both audio and video, even if we don't have those tracks
// This allows us to receive video/audio from remote peer even if we don't have camera/mic
const hasVideo = stream.getVideoTracks().length > 0;
const hasAudio = stream.getAudioTracks().length > 0;
if (!hasVideo) {
// Add video transceiver to receive video from remote peer
thisConnection.addTransceiver('video', { direction: 'recvonly' });
console.log('Added recvonly video transceiver (no local camera)');
}
if (!hasAudio) {
// Add audio transceiver to receive audio from remote peer
thisConnection.addTransceiver('audio', { direction: 'recvonly' });
console.log('Added recvonly audio transceiver (no local microphone)');
}
thisConnection.ontrack = (e) => {
if (e.streams && e.streams[0]) {
const remoteStream = e.streams[0];
remoteVideo.srcObject = remoteStream;
remoteVideo.playsInline = true;
remoteVideo.autoplay = true;
remoteVideo.controls = false;
// Initial styling - will be updated via server status
remoteVideo.classList.add('camera-feed');
remoteVideo.classList.remove('screen-share');
startSessionTime();
renderUserList(); // Update UI to show hang-up button
console.log('Remote stream set to video element');
// Reapply media status after stream is connected (in case caller had screen sharing on)
reapplyRemoteMediaStatus();
} else {
handleError(t('errors.noStreamInOnTrack'));
}
};
thisConnection.onicecandidate = (event) => {
if (event.candidate) {
sendMsg({
type: 'candidate',
candidate: event.candidate,
});
}
};
// For debugging purposes
thisConnection.onconnectionstatechange = (event) => {
console.log('Connection state change:', thisConnection.connectionState);
};
thisConnection.oniceconnectionstatechange = (event) => {
console.log('ICE connection state change:', thisConnection.iceConnectionState);
};
}
// Create and send an offer
async function offerCreate() {
// Always initialize a fresh connection for new calls
console.log('Creating new offer - initializing fresh connection');
initializeConnection();
// Create data channel for file transfer on the offerer side
const channel = thisConnection.createDataChannel('fileTransfer');
setupDataChannel(channel);
try {
const offer = await thisConnection.createOffer();
await thisConnection.setLocalDescription(offer);
sendMsg({
type: 'offer',
offer,
});
console.log('Offer created and sent successfully');
} catch (error) {
handleError(t('errors.offerCreateFailed'), error);
}
}
// Accept incoming offer
function offerAccept(data) {
// I'm already in call decline the new one!
if (remoteVideo.srcObject) {
// Send busy response directly to avoid sendMsg overriding name with connectedUser
socket.emit('message', {
type: 'offerBusy',
from: data.from, // Original caller (for server routing)
name: userName, // This user (the busy one)
});
return;
}
// Show client-side notification if tab is backgrounded
if (document.hidden && Notification.permission === 'granted') {
try {
const notification = new Notification('Call-me', {
body: t('push.incomingCallBody', { caller: data.from }) || `${data.from} is calling you`,
icon: '/favicon/favicon-32x32.png',
tag: 'call-me-incoming',
requireInteraction: true,
});
notification.onclick = () => {
window.focus();
notification.close();
};
} catch (e) {
console.warn('Client notification failed:', e);
}
}
incomingCallData = data;
showIncomingCallOverlay(data.from);
}
// Show incoming call overlay
function showIncomingCallOverlay(callerName) {
if (!incomingCallOverlay) return;
if (incomingCallUsername) incomingCallUsername.textContent = callerName;
// Reset timer bar animation with dynamic duration
if (incomingCallTimer) {
incomingCallTimer.style.setProperty('--ring-duration', ringTimeout + 's');
incomingCallTimer.style.animation = 'none';
incomingCallTimer.offsetHeight; // Force reflow
incomingCallTimer.style.animation = '';
}
// Start looping ring sound with a 1s gap between plays
if (ringingAudio) {
ringingAudio.pause();
ringingAudio = null;
}
let ringingDelayTimer = null;
function playRing() {
if (!ringingAudio) return;
ringingAudio.currentTime = 0;
ringingAudio.play().catch(() => {});
}
ringingAudio = new Audio('./assets/ring.wav');
ringingAudio.volume = 0.5;
ringingAudio.addEventListener('ended', () => {
ringingDelayTimer = setTimeout(playRing, 3000);
});
// Store the delay timer on the audio object so hideIncomingCallOverlay can clear it
ringingAudio._delayTimer = null;
Object.defineProperty(ringingAudio, '_delayTimer', {
get: () => ringingDelayTimer,
set: (v) => {
ringingDelayTimer = v;
},
});
playRing();
incomingCallOverlay.style.display = 'flex';
// Auto-decline after ringTimeout seconds
if (incomingCallTimerId) clearTimeout(incomingCallTimerId);
incomingCallTimerId = setTimeout(() => {
handleDeclineIncomingCall();
}, ringTimeout * 1000);
}
// Hide incoming call overlay
function hideIncomingCallOverlay() {
if (!incomingCallOverlay) return;
incomingCallOverlay.style.display = 'none';
if (incomingCallTimerId) {
clearTimeout(incomingCallTimerId);
incomingCallTimerId = null;
}
if (ringingAudio) {
clearTimeout(ringingAudio._delayTimer);
ringingAudio.pause();
ringingAudio = null;
}
incomingCallData = null;
}
// Handle accept incoming call
function handleAcceptIncomingCall() {
if (!incomingCallData) return;
const data = incomingCallData;
hideIncomingCallOverlay();
// Apply caller's media status before accepting the call
if (data.callerMediaStatus) {
applyCallerMediaStatus(data.callerMediaStatus);
}
data.type = 'offerCreate';
socket.recipient = data.from;
sendMsg({ ...data });
}
// Handle decline incoming call
function handleDeclineIncomingCall() {
if (!incomingCallData) return;
const data = incomingCallData;
hideIncomingCallOverlay();
data.type = 'offerDecline';
sendMsg({ ...data });
}
// Handle incoming offer
async function handleOffer(data) {
const { offer, name } = data;
console.log('Handling offer from:', name);
connectedUser = name;
pendingUser = null;
updateUsernameDisplay();
// Initialize fresh connection for incoming call
initializeConnection();
await thisConnection.setRemoteDescription(new RTCSessionDescription(offer));
try {
const answer = await thisConnection.createAnswer();
await thisConnection.setLocalDescription(answer);
sendMsg({
type: 'answer',
answer,
});
console.log('Answer sent successfully to:', name);
} catch (error) {
handleError(t('errors.answerCreateFailed'), error);
}
}
// Handle incoming answer
async function handleAnswer(data) {
const { answer } = data;
try {
await thisConnection.setRemoteDescription(new RTCSessionDescription(answer));
hideCallingOverlay();
// Set connectedUser from pendingUser after call is accepted
if (pendingUser) {
connectedUser = pendingUser;
pendingUser = null;
updateUsernameDisplay();
renderUserList(); // Update UI to show hang-up button for caller
}
} catch (error) {
hideCallingOverlay();
handleError(t('errors.remoteDescriptionFailed'), error);
}
}
// Handle incoming ICE candidate
async function handleCandidate(data) {
const { candidate } = data;
try {
await thisConnection.addIceCandidate(new RTCIceCandidate(candidate));
} catch (error) {
handleError(t('errors.addIceCandidateFailed'), error);
}
}
// Handle connected users
function handleUsers(data) {
// Show toast for new users
const prevUsers = new Set(allConnectedUsers);
const currentUsers = data.users.filter((u) => u !== userName);
allConnectedUsers = currentUsers;
filterUserList(userSearchInput.value || '');
updateParticipantCount();
if (userSignedIn) {
currentUsers.forEach((u) => {
if (!prevUsers.has(u)) {
toast(t('room.userJoined', { username: u }), 'success');
}
});
}
}
// Handle remote video status
function handleRemoteVideo(data) {
data.enabled ? remoteVideoDisabled.classList.remove('show') : remoteVideoDisabled.classList.add('show');
// Show/hide camera off overlay for remote video
showCameraOffOverlay('remote', !data.enabled);
}
// Handle remote audio status
function handleRemoteAudio(data) {
data.enabled ? remoteAudioDisabled.classList.remove('show') : remoteAudioDisabled.classList.add('show');
}
// Update username displays on video containers
function updateUsernameDisplay() {
if (localUsername) {
localUsername.innerText = userName || 'You';
}
// Only show remoteUsername if call is established (not just pending)
if (remoteUsername && connectedUser) {
remoteUsername.innerText = connectedUser;
remoteUsername.classList.remove('hide');
} else if (remoteUsername) {
remoteUsername.innerText = '';
remoteUsername.classList.add('hide');
}
}
// Show/hide camera off overlay with username display
function showCameraOffOverlay(type, show) {
const container =
type === 'local'
? document.getElementById('localVideoContainer')
: document.getElementById('remoteVideoContainer');
let overlay = container.querySelector(':scope > .camera-off-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'camera-off-overlay';
container.appendChild(overlay);
}
if (show) {
const username = type === 'local' ? userName : connectedUser;
overlay.innerHTML = `
<img src="./assets/camOff.png" alt="${t('room.videoOff')}" />
<span class="username">${username || (type === 'local' ? t('room.localUsername') : t('room.remoteUsername'))}</span>
<span class="status">${type === 'local' ? t('room.videoOff') : t('room.videoDisabled')}</span>
`;
}
overlay.classList.toggle('show', show);
// Hide/show username elements when video is off/on
const usernameElement = type === 'local' ? localUsername : remoteUsername;
if (usernameElement) {
if (show) {
// Video is off - hide username
usernameElement.classList.add('hide');
} else {
// Video is on - show username only if conditions are met
if (type === 'local') {
// Always show local username when video is on
usernameElement.classList.remove('hide');
} else {
// For remote username, only show if user is connected
if (connectedUser) {
usernameElement.classList.remove('hide');
}
// If no connected user, keep it hidden (handled by updateUsernameDisplay)
}
}
}
}
// Play audio sound
async function sound(name) {
const sound = './assets/' + name + '.wav';
const audio = new Audio(sound);
try {
audio.volume = 0.5;
await audio.play();
} catch (err) {
return false;
}
}
// 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;
}
}
// Helper function to disconnect and clean up the connection
function disconnectConnection() {
if (thisConnection) {
thisConnection.close();
thisConnection = null;
}
if (dataChannel) {
try {
dataChannel.close();
} catch (e) {
console.warn('Error closing data channel', e);
}
dataChannel = null;
}
incomingFileMeta = null;
incomingFileData = null;
incomingFileBuffers = [];
incomingFileReceivedBytes = 0;
outgoingTransfer = null;
incomingTransfer = null;
renderFileTransferStatus();
}
// Handle leaving the room
function handleLeave(disconnect = true) {
hideCallingOverlay();
hideIncomingCallOverlay();
if (disconnect) {
// Stop screen sharing if active
if (isScreenSharing) {
stopScreenSharing();
}
// Stop local and remote video tracks
stopMediaStream(localVideo);
stopMediaStream(remoteVideo);
// Disconnect from server and reset state
disconnectConnection();
connectedUser = null;
lastAppliedMediaStatus = null; // Clear stored media status
updateUsernameDisplay();
// Redirect to homepage
window.location.href = '/';
} else {
// Remote user left - clean up properly for new connections
console.log('Remote user left - cleaning up connection');
// Stop screen sharing if active
if (isScreenSharing) {
stopScreenSharing();
}
// Stop remote video tracks only
stopMediaStream(remoteVideo);
// Reset remote video styling
remoteVideo.classList.remove('screen-share', 'camera-feed');
// Clean up the peer connection so new connections work properly
disconnectConnection();
// Stop session time
stopSessionTime();
// Reset remote video & audio status indicators
handleRemoteVideo({ enabled: true });
handleRemoteAudio({ enabled: true });
// Reset state
connectedUser = null;
lastAppliedMediaStatus = null; // Clear stored media status
updateUsernameDisplay();
renderUserList();
console.log('Remote user cleanup completed - ready for new connections');
}
}
// Display toast messages using Toastify
function toast(message, icon = 'info', position = 'top', timer = 3000) {
const backgroundMap = {
success: 'linear-gradient(to right, #10b981, #059669)',
warning: 'linear-gradient(to right, #f59e0b, #d97706)',
error: 'linear-gradient(to right, #ef4444, #dc2626)',
info: 'linear-gradient(to right, #3b82f6, #2563eb)',
};
const iconMap = {
success: '<i class="fas fa-check-circle"></i>',
warning: '<i class="fas fa-exclamation-triangle"></i>',
error: '<i class="fas fa-times-circle"></i>',
info: '<i class="fas fa-info-circle"></i>',
};
const gravity = position === 'bottom' ? 'bottom' : 'top';
const toastIcon = iconMap[icon] || iconMap.info;
const background = backgroundMap[icon] || backgroundMap.info;
const node = document.createElement('span');
node.innerHTML = `${toastIcon} ${message}`;
node.style.display = 'inline-flex';
node.style.alignItems = 'center';
node.style.gap = '8px';
Toastify({
node,
duration: timer,
gravity,
position: 'center',
escapeMarkup: false,
className: 'toastify-custom',
style: {
background,
borderRadius: 'var(--border-radius, 12px)',
fontFamily: 'inherit',
fontSize: '0.9rem',
padding: '12px 20px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
maxWidth: '400px',
},
close: timer > 3000,
stopOnFocus: true,
}).showToast();
}
// Handle and display errors
function handleError(message, error = false, position = 'top', timer = 6000) {
if (error) console.error(error);
toast(message, 'warning', position, timer);
sound('notify');
}
// Display Message to user
function popupMsg(message, position = 'top', timer = 4000) {
Swal.fire({
heightAuto: false,
scrollbarPadding: false,
position,
html: message,
timerProgressBar: true,
timer,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
});
}
// Send current media status to server
function sendMediaStatusToServer() {
if (!stream) return;
const videoTrack = stream.getVideoTracks()[0];
const audioTrack = stream.getAudioTracks()[0];
const mediaStatus = {
type: 'mediaStatus',
video: videoTrack ? videoTrack.enabled : false,
audio: audioTrack ? audioTrack.enabled : false,
screenSharing: isScreenSharing,
};
socket.emit('message', mediaStatus);
console.log('Sent media status to server:', mediaStatus);
}
// Apply caller's media status to remote video/audio indicators
function applyCallerMediaStatus(callerMediaStatus) {
if (!callerMediaStatus) return;
console.log('Applying caller media status:', callerMediaStatus);
// Store for potential reapplication after stream connection
lastAppliedMediaStatus = callerMediaStatus;
// Apply video status
if (!callerMediaStatus.video) {
remoteVideoDisabled.classList.add('show');
showCameraOffOverlay('remote', true);
} else {
remoteVideoDisabled.classList.remove('show');
showCameraOffOverlay('remote', false);
}
// Apply audio status
if (!callerMediaStatus.audio) {
remoteAudioDisabled.classList.add('show');
} else {
remoteAudioDisabled.classList.remove('show');
}
// Apply screen sharing status
if (callerMediaStatus.screenSharing) {
updateRemoteVideoStyling(true);
} else {
updateRemoteVideoStyling(false);
}
}
// Reapply media status after stream connection
function reapplyRemoteMediaStatus() {
if (lastAppliedMediaStatus) {
console.log('Reapplying media status after stream connection:', lastAppliedMediaStatus);
// Only reapply screen sharing status since video/audio indicators should be preserved
if (lastAppliedMediaStatus.screenSharing) {
updateRemoteVideoStyling(true);
} else {
updateRemoteVideoStyling(false);
}
}
}
// Handle remote screen sharing status updates
function handleRemoteScreenShare(data) {
const { from, screenSharing } = data;
// Only apply if this message is from the connected user
if (from === connectedUser) {
console.log('Remote screen sharing status changed:', screenSharing);
// Update stored media status
if (lastAppliedMediaStatus) {
lastAppliedMediaStatus.screenSharing = screenSharing;
}
updateRemoteVideoStyling(screenSharing);
}
}
// Initialize push notification toggle in settings
async function initPushNotificationToggle() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.log('Push notifications not supported in this browser');
return;
}
// Show the toggle group
elemDisplay(pushNotificationGroup, true, 'flex');
// Check current state
const registration = await navigator.serviceWorker.getRegistration('/sw.js');
if (registration) {
const existing = await registration.pushManager.getSubscription();
pushNotificationToggle.checked = !!existing;
if (existing) {
pushSubscription = existing;
// Re-send subscription to server (in case server restarted)
sendMsg({ type: 'pushSubscription', subscription: existing.toJSON() });
}
}
// Restore from localStorage
if (localStorage.callMePushEnabled === 'true' && !pushNotificationToggle.checked) {
pushNotificationToggle.checked = true;
await registerPushNotifications();
}
// Show/hide test button based on current state
updatePushTestBtn();
// Handle toggle change
pushNotificationToggle.addEventListener('change', handlePushToggle);
// Handle test button click
pushTestBtn.addEventListener('click', handlePushTest);
}
// Handle push notification toggle
async function handlePushToggle() {
if (pushNotificationToggle.checked) {
await registerPushNotifications();
if (!pushSubscription) {
// Permission denied or failed — revert toggle
pushNotificationToggle.checked = false;
localStorage.callMePushEnabled = 'false';
toast(t('push.permissionDenied') || 'Notification permission denied', 'warning', 'top', 3000);
updatePushTestBtn();
return;
}
localStorage.callMePushEnabled = 'true';
toast(t('push.enabled') || 'Push notifications enabled', 'success', 'top', 3000);
} else {
await unregisterPushNotifications();
localStorage.callMePushEnabled = 'false';
toast(t('push.disabled') || 'Push notifications disabled', 'info', 'top', 3000);
}
updatePushTestBtn();
}
// Show/hide test push button based on toggle state
function updatePushTestBtn() {
elemDisplay(pushTestBtn, pushNotificationToggle.checked);
}
// Handle test push notification button
async function handlePushTest() {
try {
const registration = await navigator.serviceWorker.ready;
await registration.showNotification('Call-me', {
body: 'Push notifications are working!',
icon: '/favicon/favicon-32x32.png',
badge: '/favicon/favicon-16x16.png',
data: { url: '/', type: 'testPush', caller: '' },
});
} catch (err) {
console.warn('Direct notification failed, falling back to push:', err);
sendMsg({ type: 'testPush' });
}
toast(t('push.testSent') || 'Test notification sent', 'info', 'top', 3000);
}
// Register service worker and subscribe to push notifications
async function registerPushNotifications() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.log('Push notifications not supported in this browser');
return;
}
try {
// Fetch VAPID public key from server
const { data: vapidData } = await axios.get('/api/v1/vapidPublicKey');
if (!vapidData.enabled || !vapidData.vapidPublicKey) {
console.log('Push notifications not enabled on server');
return;
}
// Register service worker and wait for it to become active
await navigator.serviceWorker.register('/sw.js');
const registration = await navigator.serviceWorker.ready;
console.log('Service worker registered and active');
// Request notification permission
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('Notification permission denied');
pushSubscription = null;
return;
}
// Check for existing subscription first
pushSubscription = await registration.pushManager.getSubscription();
if (!pushSubscription) {
// Subscribe to push
const applicationServerKey = urlBase64ToUint8Array(vapidData.vapidPublicKey);
pushSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey,
});
console.log('Push notification: new subscription created');
} else {
console.log('Push notification: using existing subscription');
}
// Send subscription to server via Socket.IO
sendMsg({
type: 'pushSubscription',
subscription: pushSubscription.toJSON(),
});
console.log('Push notification subscription successful', pushSubscription.endpoint);
} catch (err) {
console.warn('Push notification registration failed:', err);
pushSubscription = null;
}
}
// Unsubscribe from push notifications
async function unregisterPushNotifications() {
try {
if (pushSubscription) {
const endpoint = pushSubscription.endpoint;
await pushSubscription.unsubscribe();
console.log('Push subscription removed from browser');
// Tell server to remove the subscription
sendMsg({
type: 'pushUnsubscribe',
endpoint: endpoint,
});
}
pushSubscription = null;
} catch (err) {
console.warn('Push unsubscribe failed:', err);
}
}
// Convert URL-safe base64 string to Uint8Array (for VAPID key)
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i++) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Send messages to the server
function sendMsg(message) {
// Use connectedUser if call is established, otherwise use pendingUser for signaling
if (connectedUser) {
message.name = connectedUser;
} else if (pendingUser) {
message.name = pendingUser;
}
socket.emit('message', message);
}
// Set up a data channel for file transfer
function setupDataChannel(channel) {
dataChannel = channel;
dataChannel.onopen = () => {
console.log('Data channel open for file transfer');
};
dataChannel.onclose = () => {
console.log('Data channel closed');
};
dataChannel.onerror = (event) => {
// Some browsers emit an error event during/after close (e.g. "User-Initiated Abort, reason=Close called").
// That's expected during hang-up/teardown and shouldn't be shown to the user.
const rtcError = event?.error;
const message = (rtcError && (rtcError.message || rtcError.reason)) || '';
const isClosingOrClosed = dataChannel?.readyState && dataChannel.readyState !== 'open';
const isBenignCloseError =
rtcError?.name === 'OperationError' && /close called|user-initiated abort|abort/i.test(String(message));
if (isClosingOrClosed || isBenignCloseError) {
console.debug('Ignoring data channel close-related error:', event);
return;
}
console.error('Data channel error:', event);
toast(t('file.dataChannelError'), 'warning', 'top', 3000);
};
dataChannel.onmessage = async (event) => {
// Meta information is sent as JSON string, file as binary
if (typeof event.data === 'string') {
try {
const msg = JSON.parse(event.data);
if (msg && msg.type === 'file-meta') {
incomingFileMeta = msg;
incomingFileData = null;
incomingFileBuffers = [];
incomingFileReceivedBytes = 0;
incomingTransfer = {
id: msg.id,
name: msg.name,
size: msg.size,
receivedBytes: 0,
cancelled: false,
};
console.log('Received file meta:', msg);
renderFileTransferStatus();
return;
}
if (msg && msg.type === 'file-cancel') {
console.log('Received file cancel:', msg);
if (incomingTransfer && (!msg.id || msg.id === incomingTransfer.id)) {
incomingTransfer.cancelled = true;
incomingTransfer = null;
incomingFileMeta = null;
incomingFileData = null;
incomingFileBuffers = [];
incomingFileReceivedBytes = 0;
toast(t('file.cancelledByRemote'), 'info', 'top', 3000);
renderFileTransferStatus();
}
if (outgoingTransfer && (!msg.id || msg.id === outgoingTransfer.id)) {
outgoingTransfer.cancelled = true;
outgoingTransfer = null;
toast(t('file.cancelledByRemote'), 'info', 'top', 3000);
renderFileTransferStatus();
}
return;
}
} catch (e) {
console.warn('Non-JSON data received on data channel:', event.data);
}
} else {
if (!incomingFileMeta) {
console.warn('Received binary data without file meta');
return;
}
let arrayBuffer;
if (event.data instanceof ArrayBuffer) {
arrayBuffer = event.data;
} else if (event.data instanceof Blob) {
arrayBuffer = await event.data.arrayBuffer();
} else {
console.warn('Unknown binary data type on data channel');
return;
}
// Accumulate chunks until full file is received
incomingFileBuffers.push(arrayBuffer);
incomingFileReceivedBytes += arrayBuffer.byteLength;
if (incomingTransfer) {
incomingTransfer.receivedBytes = incomingFileReceivedBytes;
renderFileTransferStatus();
}
if (incomingFileReceivedBytes >= incomingFileMeta.size) {
const blob = new Blob(incomingFileBuffers, {
type: incomingFileMeta.mime || 'application/octet-stream',
});
const url = URL.createObjectURL(blob);
addFileMessageToChat({
from: connectedUser || 'Remote',
name: incomingFileMeta.name,
size: incomingFileMeta.size,
url,
isSelf: false,
});
incomingFileMeta = null;
incomingFileData = null;
incomingFileBuffers = [];
incomingFileReceivedBytes = 0;
incomingTransfer = null;
renderFileTransferStatus();
}
}
};
}
// Send a file over the data channel to the connected peer
async function sendFileOverDataChannel(file) {
if (!dataChannel || dataChannel.readyState !== 'open') {
throw new Error('Data channel is not open');
}
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
if (file.size > MAX_FILE_SIZE) {
handleError(t('file.tooLarge', { maxSizeMb: 10 }));
return;
}
const transferId = Date.now() + '-' + Math.random().toString(16).slice(2);
const meta = {
type: 'file-meta',
id: transferId,
name: file.name,
size: file.size,
mime: file.type || 'application/octet-stream',
};
try {
outgoingTransfer = {
id: transferId,
name: file.name,
size: file.size,
sentBytes: 0,
cancelled: false,
};
renderFileTransferStatus();
dataChannel.send(JSON.stringify(meta));
const arrayBuffer = await file.arrayBuffer();
// Send the file in small chunks to avoid data channel send errors
const CHUNK_SIZE = 16 * 1024; // 16 KB
for (let offset = 0; offset < arrayBuffer.byteLength; offset += CHUNK_SIZE) {
if (!dataChannel || dataChannel.readyState !== 'open') {
throw new Error('Data channel closed during file transfer');
}
if (!outgoingTransfer || outgoingTransfer.cancelled) {
outgoingTransfer = null;
renderFileTransferStatus();
return;
}
const chunk = arrayBuffer.slice(offset, offset + CHUNK_SIZE);
dataChannel.send(chunk);
if (outgoingTransfer) {
outgoingTransfer.sentBytes = offset + chunk.byteLength;
renderFileTransferStatus();
}
await waitForDataChannelDrain();
}
const blobUrl = URL.createObjectURL(new Blob([arrayBuffer], { type: meta.mime }));
addFileMessageToChat({
from: userName || t('chat.me'),
name: file.name,
size: file.size,
url: blobUrl,
isSelf: true,
});
toast(t('file.sent', { filename: file.name }), 'success', 'top', 3000);
outgoingTransfer = null;
renderFileTransferStatus();
} catch (error) {
console.error('Error sending file over data channel:', error);
handleError(t('file.sendError'), error.message || error);
outgoingTransfer = null;
renderFileTransferStatus();
}
}
// Wait for data channel buffer to drain below a threshold
async function waitForDataChannelDrain() {
if (!dataChannel || dataChannel.readyState !== 'open') return;
const MAX_BUFFERED_AMOUNT = 64 * 1024; // 64 KB
if (dataChannel.bufferedAmount < MAX_BUFFERED_AMOUNT) return;
await new Promise((resolve) => {
const check = () => {
if (!dataChannel || dataChannel.readyState !== 'open' || dataChannel.bufferedAmount < MAX_BUFFERED_AMOUNT) {
resolve();
} else {
setTimeout(check, 50);
}
};
check();
});
}
// Select user by value in the user list
function renderUserList() {
// Dispose existing Bootstrap tooltips before clearing the list
userList.querySelectorAll('[data-toggle="tooltip"]').forEach((el) => {
const tip = bootstrap.Tooltip.getInstance(el);
if (tip) tip.dispose();
});
userList.innerHTML = '';
// Show empty state if no users (only after sign-in)
if (filteredUsers.length === 0 && userSignedIn) {
const emptyLi = document.createElement('li');
emptyLi.className = 'user-list-empty';
emptyLi.innerHTML = `
<div>
<i class="fas fa-users"></i>
<p>${t('room.noUsersOnline')}</p>
<p>${t('room.shareToInvite')}</p>
</div>
`;
userList.appendChild(emptyLi);
return;
}
filteredUsers.forEach((user) => {
const li = document.createElement('li');
li.tabIndex = 0;
if (user === selectedUser) li.classList.add('selected');
// Check if this user is currently in an active call (has answered)
const isInActiveCall =
connectedUser === user && remoteVideo.srcObject && remoteVideo.srcObject.getTracks().length > 0;
// Create call/hangup button based on active call state
const actionBtnEl = document.createElement('button');
actionBtnEl.style.cursor = 'pointer';
if (isInActiveCall) {
// Show hang-up button only if in active call (user has answered)
actionBtnEl.className = 'btn btn-custom btn-danger btn-m hangup-user-btn';
actionBtnEl.innerHTML = '<i class="fas fa-phone-slash"></i>';
actionBtnEl.title = t('room.hangup');
actionBtnEl.addEventListener('click', (e) => {
e.stopPropagation();
if (!userSignedIn) return;
console.log(`Hanging up call with ${user}`);
// Send leave message to notify the other user
sendMsg({ type: 'leave', name: connectedUser });
handleLeave(false); // End call but stay in room
renderUserList(); // Refresh the list to show call button again
});
} else {
// Show call button if not in active call
actionBtnEl.className = 'btn btn-custom btn-warning btn-m call-user-btn';
actionBtnEl.innerHTML = '<i class="fas fa-phone"></i>';
actionBtnEl.title = t('room.call');
actionBtnEl.addEventListener('click', (e) => {
e.stopPropagation();
if (!userSignedIn) return;
handleUserClickToCall(user);
});
}
// Username span
const nameSpan = document.createElement('span');
nameSpan.textContent = user;
// Send file button
const sendFileBtn = document.createElement('button');
sendFileBtn.className = 'btn btn-custom btn-success btn-m';
sendFileBtn.innerHTML = '<i class="fas fa-paperclip"></i>';
sendFileBtn.style.cursor = 'pointer';
sendFileBtn.title = t('file.shareFile');
sendFileBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!userSignedIn) return;
if (!connectedUser || connectedUser !== user || !remoteVideo.srcObject) {
toast(t('file.onlyUserInCall'), 'warning', 'top', 3000);
return;
}
if (!dataChannel || dataChannel.readyState !== 'open') {
toast(t('file.dataChannelNotReadyTryAgain'), 'warning', 'top', 3000);
return;
}
pendingFileRecipient = user;
if (fileInput) {
fileInput.click();
}
});
// User avatar with initials
const avatarColors = [
'linear-gradient(135deg, #3b82f6, #1d4ed8)',
'linear-gradient(135deg, #8b5cf6, #6d28d9)',
'linear-gradient(135deg, #10b981, #047857)',
'linear-gradient(135deg, #f59e0b, #d97706)',
'linear-gradient(135deg, #ef4444, #b91c1c)',
'linear-gradient(135deg, #06b6d4, #0e7490)',
'linear-gradient(135deg, #ec4899, #be185d)',
];
const avatarDiv = document.createElement('div');
avatarDiv.className = 'user-avatar';
const initials = user
.split(/[\s_-]+/)
.map((w) => w[0])
.join('')
.substring(0, 2);
avatarDiv.textContent = initials;
let hash = 0;
for (let i = 0; i < user.length; i++) hash = user.charCodeAt(i) + ((hash << 5) - hash);
avatarDiv.style.background = avatarColors[Math.abs(hash) % avatarColors.length];
li.appendChild(avatarDiv);
li.appendChild(nameSpan);
li.appendChild(sendFileBtn);
li.appendChild(actionBtnEl);
li.addEventListener('click', () => {
if (!userSignedIn) return;
selectedUser = user;
renderUserList();
});
li.addEventListener('keydown', (e) => {
if (!userSignedIn) return;
if (e.key === 'Enter') {
const isInActiveCall =
connectedUser === user && remoteVideo.srcObject && remoteVideo.srcObject.getTracks().length > 0;
if (isInActiveCall) {
// Send leave message to notify the other user
sendMsg({ type: 'leave', name: connectedUser });
handleLeave(false);
renderUserList();
} else {
handleUserClickToCall(user);
}
}
});
userList.appendChild(li);
});
// Initialize Bootstrap tooltips on dynamically created buttons (skip on mobile)
if (userInfo && !userInfo.device.isMobile) {
const tooltipEls = userList.querySelectorAll('[title]');
tooltipEls.forEach((el) => {
el.setAttribute('data-toggle', 'tooltip');
el.setAttribute('data-placement', 'top');
const tip = new bootstrap.Tooltip(el);
el.addEventListener('click', () => tip.hide());
});
}
}
// Filter user list based on search input
function filterUserList(query) {
filteredUsers = allConnectedUsers.filter((u) => u.toLowerCase().includes(query.toLowerCase()));
// If selected user is filtered out, deselect
if (!filteredUsers.includes(selectedUser)) selectedUser = null;
renderUserList();
}
// Handle user search input
userSearchInput?.addEventListener('input', (e) => {
filterUserList(e.target.value);
});
// Tab switching function
function switchTab(tabName) {
currentTab = tabName;
// Update tab buttons
if (usersTab && chatTab && settingsTab) {
usersTab.classList.remove('active');
chatTab.classList.remove('active');
settingsTab.classList.remove('active');
if (tabName === 'users') {
usersTab.classList.add('active');
} else if (tabName === 'chat') {
chatTab.classList.add('active');
// Clear unread messages when switching to chat
unreadMessages = 0;
updateChatNotification();
// Show empty state if no messages
if (chatMessages && chatMessages.children.length === 0) {
showChatEmptyState();
}
} else if (tabName === 'settings') {
settingsTab.classList.add('active');
// Refresh devices when switching to settings
refreshDevices(false);
}
}
// Update tab content
if (usersContent && chatContent && settingsContent) {
usersContent.classList.remove('active');
chatContent.classList.remove('active');
settingsContent.classList.remove('active');
if (tabName === 'users') {
usersContent.classList.add('active');
} else if (tabName === 'chat') {
chatContent.classList.add('active');
} else if (tabName === 'settings') {
settingsContent.classList.add('active');
}
}
}
// Update participant count badge
function updateParticipantCount() {
if (participantCount) {
const count = allConnectedUsers.length;
if (count > 0) {
participantCount.textContent = count > 99 ? '99+' : count.toString();
participantCount.classList.remove('hidden');
} else {
participantCount.classList.add('hidden');
}
}
}
// Update chat notification badge
function updateChatNotification() {
if (chatNotification) {
if (unreadMessages > 0) {
chatNotification.textContent = unreadMessages > 99 ? '99+' : unreadMessages.toString();
chatNotification.classList.remove('hidden');
} else {
chatNotification.classList.add('hidden');
}
}
if (messageCount) {
if (unreadMessages > 0) {
messageCount.textContent = unreadMessages > 99 ? '99+' : unreadMessages.toString();
messageCount.classList.remove('hidden');
} else {
messageCount.classList.add('hidden');
}
}
}
// Chat form handler
if (chatForm && chatInput) {
chatForm.addEventListener('submit', (e) => {
e.preventDefault();
const text = chatInput.value.trim();
if (text.length > 0) {
if (allConnectedUsers.length === 0) {
toast(t('chat.cannotSendNoUsers'), 'warning', 'top', 2000);
chatInput.value = '';
return;
}
socket.emit('message', { type: 'chat', text });
addChatMessage({ from: userName || t('chat.me'), text, timestamp: Date.now() }, true);
chatInput.value = '';
}
});
}
function addChatMessage(msg, isSelf = false) {
// Remove empty state if present
const emptyState = chatMessages.querySelector('.chat-empty-state');
if (emptyState) emptyState.remove();
const div = document.createElement('div');
div.className = 'chat-message';
if (isSelf) {
div.classList.add('own-message');
}
const userSpan = document.createElement('span');
userSpan.className = 'chat-user';
userSpan.textContent = isSelf ? t('chat.me') : msg.from;
const textSpan = document.createElement('span');
textSpan.className = 'chat-text';
textSpan.textContent = ': ' + msg.text;
const timeSpan = document.createElement('span');
timeSpan.className = 'chat-time';
timeSpan.textContent = formatChatTime(msg.timestamp);
div.appendChild(userSpan);
div.appendChild(textSpan);
div.appendChild(timeSpan);
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
// Handle unread message counter
if (!isSelf && (currentTab !== 'chat' || !userSidebar.classList.contains('active'))) {
unreadMessages++;
updateChatNotification();
// Show toast notification for new messages only if sidebar is not opened
if (!userSidebar.classList.contains('active')) {
toast(t('chat.newMessageFrom', { username: msg.from }), 'info', 'top', 2000);
}
}
}
// Add a file message entry into the chat with download link
function addFileMessageToChat({ from, name, size, url, isSelf }) {
if (!chatMessages) return;
const div = document.createElement('div');
div.className = 'chat-message file-message';
if (isSelf) {
div.classList.add('own-message');
}
const userSpan = document.createElement('span');
userSpan.className = 'chat-user';
userSpan.textContent = isSelf ? t('chat.me') : from;
const linkSpan = document.createElement('span');
linkSpan.className = 'chat-text';
const link = document.createElement('a');
link.href = url;
link.download = name;
const sizeKb = Math.max(1, Math.round(size / 1024));
link.textContent = `${name} (${sizeKb} KB)`;
linkSpan.appendChild(document.createTextNode(t('file.sentFileLabel')));
linkSpan.appendChild(link);
const timeSpan = document.createElement('span');
timeSpan.className = 'chat-time';
timeSpan.textContent = formatChatTime(Date.now());
div.appendChild(userSpan);
div.appendChild(linkSpan);
div.appendChild(timeSpan);
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
// Handle unread message counter for received files
if (!isSelf && (currentTab !== 'chat' || !userSidebar.classList.contains('active'))) {
unreadMessages++;
updateChatNotification();
if (!userSidebar.classList.contains('active')) {
toast(t('file.newFileFrom', { username: from }), 'info', 'top', 2000);
}
}
}
// Render file transfer status UI (outgoing + incoming)
function renderFileTransferStatus() {
if (!fileTransferStatusEl) return;
fileTransferStatusEl.innerHTML = '';
const anyTransfer = outgoingTransfer || incomingTransfer;
if (!anyTransfer) {
fileTransferStatusEl.style.display = 'none';
return;
}
fileTransferStatusEl.style.display = 'block';
if (outgoingTransfer) {
const row = document.createElement('div');
row.className = 'file-transfer-row outgoing';
const info = document.createElement('div');
info.className = 'file-transfer-info';
const nameEl = document.createElement('div');
nameEl.className = 'file-transfer-name';
nameEl.textContent = t('file.sending', { filename: outgoingTransfer.name });
const progressWrapper = document.createElement('div');
progressWrapper.className = 'file-transfer-progress';
const bar = document.createElement('div');
bar.className = 'file-transfer-progress-bar';
const percent = outgoingTransfer.size
? Math.min(100, Math.round((outgoingTransfer.sentBytes / outgoingTransfer.size) * 100))
: 0;
bar.style.width = percent + '%';
progressWrapper.appendChild(bar);
const meta = document.createElement('div');
meta.className = 'file-transfer-meta';
const sentKb = Math.round(outgoingTransfer.sentBytes / 1024);
const totalKb = Math.round(outgoingTransfer.size / 1024);
meta.textContent = `${sentKb}/${totalKb} KB (${percent}%)`;
info.appendChild(nameEl);
info.appendChild(progressWrapper);
info.appendChild(meta);
const actions = document.createElement('div');
actions.className = 'file-transfer-actions';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn btn-sm btn-danger';
cancelBtn.textContent = t('settings.cancel');
cancelBtn.addEventListener('click', (e) => {
e.stopPropagation();
handleOutgoingFileCancel();
});
actions.appendChild(cancelBtn);
row.appendChild(info);
row.appendChild(actions);
fileTransferStatusEl.appendChild(row);
}
if (incomingTransfer) {
const row = document.createElement('div');
row.className = 'file-transfer-row incoming';
const info = document.createElement('div');
info.className = 'file-transfer-info';
const nameEl = document.createElement('div');
nameEl.className = 'file-transfer-name';
nameEl.textContent = t('file.receiving', { filename: incomingTransfer.name });
const progressWrapper = document.createElement('div');
progressWrapper.className = 'file-transfer-progress';
const bar = document.createElement('div');
bar.className = 'file-transfer-progress-bar';
const percent = incomingTransfer.size
? Math.min(100, Math.round((incomingTransfer.receivedBytes / incomingTransfer.size) * 100))
: 0;
bar.style.width = percent + '%';
progressWrapper.appendChild(bar);
const meta = document.createElement('div');
meta.className = 'file-transfer-meta';
const receivedKb = Math.round(incomingTransfer.receivedBytes / 1024);
const totalKb = Math.round(incomingTransfer.size / 1024);
meta.textContent = `${receivedKb}/${totalKb} KB (${percent}%)`;
info.appendChild(nameEl);
info.appendChild(progressWrapper);
info.appendChild(meta);
const actions = document.createElement('div');
actions.className = 'file-transfer-actions';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn btn-sm btn-danger';
cancelBtn.textContent = t('settings.cancel');
cancelBtn.addEventListener('click', (e) => {
e.stopPropagation();
handleIncomingFileCancel();
});
actions.appendChild(cancelBtn);
row.appendChild(info);
row.appendChild(actions);
fileTransferStatusEl.appendChild(row);
}
}
// Cancel outgoing file transfer (sender side)
function handleOutgoingFileCancel() {
if (!outgoingTransfer) return;
const id = outgoingTransfer.id;
// Try to notify remote, but cancel locally even if this fails
if (dataChannel && dataChannel.readyState === 'open') {
try {
dataChannel.send(
JSON.stringify({
type: 'file-cancel',
id,
by: 'sender',
})
);
} catch (e) {
console.warn('Error sending cancel message over data channel', e);
}
}
// Mark as cancelled; send loop will see this and stop
outgoingTransfer.cancelled = true;
toast(t('file.cancelled'), 'info', 'top', 3000);
}
// Cancel incoming file transfer (receiver side)
function handleIncomingFileCancel() {
if (!incomingTransfer) return;
const id = incomingTransfer.id;
if (dataChannel && dataChannel.readyState === 'open') {
try {
dataChannel.send(
JSON.stringify({
type: 'file-cancel',
id,
by: 'receiver',
})
);
} catch (e) {
console.warn('Error sending cancel message over data channel', e);
}
}
incomingTransfer = null;
incomingFileMeta = null;
incomingFileData = null;
incomingFileBuffers = [];
incomingFileReceivedBytes = 0;
toast(t('file.cancelled'), 'info', 'top', 3000);
renderFileTransferStatus();
}
function formatChatTime(ts) {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// Handle emoji button click
function handleEmojiClick() {
console.log('Emoji button clicked, current show state:', emojiPicker.classList.contains('show'));
if (!emojiPicker.classList.contains('show')) {
handleChatEmojiPicker();
emojiPicker.classList.add('show');
console.log('Emoji picker shown');
} else {
emojiPicker.classList.remove('show');
console.log('Emoji picker hidden');
}
}
// Initialize and handle chat emoji picker
function handleChatEmojiPicker() {
// Clear any existing picker
emojiPicker.innerHTML = '';
console.log('Initializing EmojiMart picker...');
const pickerOptions = {
theme: 'dark',
perLine: 8,
onEmojiSelect: addEmojiToMsg,
};
const picker = new EmojiMart.Picker(pickerOptions);
emojiPicker.appendChild(picker);
console.log('EmojiMart picker initialized and appended');
function addEmojiToMsg(data) {
console.log('Emoji selected:', data.native);
// Insert emoji at cursor position or at the end
const cursorPosition = chatInput.selectionStart;
const currentValue = chatInput.value;
const newValue = currentValue.slice(0, cursorPosition) + data.native + currentValue.slice(cursorPosition);
chatInput.value = newValue;
// Set cursor position after the emoji
const newCursorPosition = cursorPosition + data.native.length;
chatInput.setSelectionRange(newCursorPosition, newCursorPosition);
chatInput.focus();
console.log('Emoji inserted into chat input at position:', cursorPosition);
// Hide emoji picker after selection
toggleChatEmoji();
}
}
// Toggle chat emoji picker visibility
function toggleChatEmoji() {
emojiPicker.classList.remove('show');
console.log('Emoji picker hidden via toggle');
}
// Generate chat export text
function generateChatExportText() {
const messages = chatMessages.children;
if (messages.length === 0) {
return null;
}
let chatText = '';
const currentDate = new Date();
const dateString = currentDate.toLocaleDateString();
const timeString = currentDate.toLocaleTimeString();
// Add header to the file
chatText += `Call-me Chat Export\n`;
chatText += `Exported on: ${dateString} at ${timeString}\n`;
chatText += `Session participants: ${userName || 'Unknown'}, ${connectedUser || 'Unknown'}\n`;
chatText += `${'='.repeat(50)}\n\n`;
// Extract messages
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const userSpan = message.querySelector('.chat-user');
const textSpan = message.querySelector('.chat-text');
const timeSpan = message.querySelector('.chat-time');
if (userSpan && textSpan && timeSpan) {
const user = userSpan.textContent;
const text = textSpan.textContent.replace(': ', ''); // Remove the ': ' prefix
const time = timeSpan.textContent;
chatText += `[${time}] ${user}: ${text}\n`;
}
}
return chatText;
}
// Download text as file
function downloadTextAsFile(text, filename) {
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Handle save chat button click
function handleSaveChatClick() {
const chatText = generateChatExportText();
if (!chatText) {
toast(t('chat.noMessagesToSave'), 'info', 'top', 2000);
return;
}
const currentDate = new Date();
const fileName = `call-me-chat-${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(currentDate.getDate()).padStart(2, '0')}-${String(currentDate.getHours()).padStart(2, '0')}${String(currentDate.getMinutes()).padStart(2, '0')}.txt`;
downloadTextAsFile(chatText, fileName);
toast(t('chat.savedAs', { filename: fileName }), 'success', 'top', 3000);
}
// Handle clear chat button click
function handleClearChatClick() {
if (!thereAreChatMessages()) {
toast(t('chat.noMessagesToClear'), 'info', 'top', 2000);
return;
}
Swal.fire({
heightAuto: false,
scrollbarPadding: false,
position: 'center',
icon: 'question',
title: t('chat.clearTitle'),
text: t('chat.clearConfirm'),
showCancelButton: true,
confirmButtonText: t('chat.clearConfirmYes'),
cancelButtonText: t('settings.cancel'),
confirmButtonColor: '#dc3545',
cancelButtonColor: '#6c757d',
}).then((result) => {
if (result.isConfirmed) {
// Clear all chat messages
chatMessages.innerHTML = '';
// Reset unread messages counter
unreadMessages = 0;
updateChatNotification();
// Show success message
toast(t('chat.cleared'), 'success', 'top', 2000);
}
});
}
function thereAreChatMessages() {
// Exclude the empty state element from the count
const children = Array.from(chatMessages.children);
return children.some((el) => !el.classList.contains('chat-empty-state'));
}
// Show chat empty state placeholder
function showChatEmptyState() {
if (!chatMessages) return;
const existing = chatMessages.querySelector('.chat-empty-state');
if (existing) return;
const emptyDiv = document.createElement('div');
emptyDiv.className = 'chat-empty-state';
emptyDiv.innerHTML = `
<i class="fas fa-comments"></i>
<p>${t('room.noChatMessages')}</p>
`;
chatMessages.appendChild(emptyDiv);
}
// Device Management Functions
async function initializeDeviceSettings() {
try {
// Set initial device IDs from current stream
if (stream) {
const videoTrack = stream.getVideoTracks()[0];
const audioTrack = stream.getAudioTracks()[0];
if (videoTrack && videoTrack.readyState === 'live') {
const videoSettings = videoTrack.getSettings();
selectedDevices.videoInput = videoSettings.deviceId;
}
if (audioTrack && audioTrack.readyState === 'live') {
const audioSettings = audioTrack.getSettings();
selectedDevices.audioInput = audioSettings.deviceId;
}
}
// Enumerate and populate device lists
await enumerateDevices();
} catch (error) {
console.error('Error initializing device settings:', error);
}
}
async function enumerateDevices() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
availableDevices = {
videoInputs: devices.filter((device) => device.kind === 'videoinput'),
audioInputs: devices.filter((device) => device.kind === 'audioinput'),
audioOutputs: devices.filter((device) => device.kind === 'audiooutput'),
};
populateDeviceSelects();
} catch (error) {
console.error('Error enumerating devices:', error);
handleError(t('errors.mediaDevices'));
}
}
function populateDeviceSelects() {
// Populate video select
if (videoSelect) {
videoSelect.innerHTML = '';
// Check if we actually have camera access by checking the stream
// A stopped track (readyState === 'ended') still counts — the device was available
const hasCamera = stream && stream.getVideoTracks().length > 0 && availableDevices.videoInputs.length > 0;
if (!hasCamera) {
videoSelect.innerHTML = `<option value="">${t('settings.noCamerasFound')}</option>`;
videoSelect.disabled = true;
videoSelect.parentElement.style.opacity = '0.5';
} else {
videoSelect.disabled = false;
videoSelect.parentElement.style.opacity = '1';
availableDevices.videoInputs.forEach((device) => {
const option = document.createElement('option');
option.value = device.deviceId;
option.textContent = device.label || `${t('settings.videoInput')} ${device.deviceId.slice(0, 8)}`;
if (device.deviceId === selectedDevices.videoInput) {
option.selected = true;
}
videoSelect.appendChild(option);
});
}
}
// Populate audio input select
if (audioSelect) {
audioSelect.innerHTML = '';
// Check if we actually have microphone access by checking the stream
const hasMic = stream && stream.getAudioTracks().length > 0;
if (!hasMic) {
audioSelect.innerHTML = `<option value="">${t('settings.noMicrophonesFound')}</option>`;
audioSelect.disabled = true;
audioSelect.parentElement.style.opacity = '0.5';
} else {
audioSelect.disabled = false;
audioSelect.parentElement.style.opacity = '1';
availableDevices.audioInputs.forEach((device) => {
const option = document.createElement('option');
option.value = device.deviceId;
option.textContent = device.label || `${t('settings.audioInput')} ${device.deviceId.slice(0, 8)}`;
if (device.deviceId === selectedDevices.audioInput) {
option.selected = true;
}
audioSelect.appendChild(option);
});
}
}
// Populate audio output select
if (audioOutputSelect) {
audioOutputSelect.innerHTML = '';
if (availableDevices.audioOutputs.length === 0) {
audioOutputSelect.innerHTML = `<option value="">${t('settings.noSpeakersFound')}</option>`;
} else {
availableDevices.audioOutputs.forEach((device) => {
const option = document.createElement('option');
option.value = device.deviceId;
option.textContent = device.label || `${t('settings.audioOutput')} ${device.deviceId.slice(0, 8)}`;
if (device.deviceId === selectedDevices.audioOutput) {
option.selected = true;
}
audioOutputSelect.appendChild(option);
});
}
}
}
// Update UI elements based on available devices
function updateUIForAvailableDevices() {
// A stopped track (readyState 'ended') still counts — the device was available at start
const hasCamera = stream && stream.getVideoTracks().length > 0 && availableDevices.videoInputs.length > 0;
const hasMic = stream && stream.getAudioTracks().length > 0;
// Handle video button and local video
if (!hasCamera) {
if (videoBtn) {
videoBtn.classList.add('btn-danger');
videoBtn.disabled = true;
videoBtn.style.opacity = '0.5';
videoBtn.title = t('errors.noCameraAvailable');
}
if (swapCameraBtn) {
swapCameraBtn.style.display = 'none';
}
if (localVideoContainer) {
showCameraOffOverlay('local', true);
}
} else {
if (videoBtn) {
videoBtn.disabled = false;
videoBtn.style.opacity = '1';
}
}
// Handle audio button
if (!hasMic) {
if (audioBtn) {
audioBtn.classList.add('btn-danger');
audioBtn.disabled = true;
audioBtn.style.opacity = '0.5';
audioBtn.title = t('errors.noMicrophoneAvailable');
}
} else {
if (audioBtn) {
audioBtn.disabled = false;
audioBtn.style.opacity = '1';
}
}
}
async function refreshDevices(showToast = true) {
if (refreshDevicesBtn) {
refreshDevicesBtn.disabled = true;
}
try {
// Only request permissions if we don't already have an active stream
// This prevents re-opening the camera (turning on LED) when it was turned off
const hasLiveVideoTrack = stream && stream.getVideoTracks().some((t) => t.readyState === 'live');
const hasLiveAudioTrack = stream && stream.getAudioTracks().some((t) => t.readyState === 'live');
if (!hasLiveVideoTrack && !hasLiveAudioTrack) {
// No live tracks — we need to request permission to enumerate labeled devices
// Use audio-only to avoid turning on the camera LED
try {
const tempStream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
tempStream.getTracks().forEach((t) => t.stop());
} catch (e) {
// If audio also fails, try video but stop it immediately
try {
const tempStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
tempStream.getTracks().forEach((t) => t.stop());
} catch (videoError) {
console.warn('No media devices available for permission request');
}
}
}
await enumerateDevices();
updateUIForAvailableDevices();
if (showToast) {
toast(t('messages.devicesRefreshed'), 'success', 'top', 2000);
}
} catch (error) {
console.error('Error refreshing devices:', error);
handleError(t('errors.refreshDevicesFailed'));
} finally {
if (refreshDevicesBtn) {
refreshDevicesBtn.disabled = false;
}
}
}
async function handleVideoDeviceChange() {
const newDeviceId = videoSelect.value;
if (newDeviceId && newDeviceId !== selectedDevices.videoInput) {
selectedDevices.videoInput = newDeviceId;
await updateVideoStream();
updateUIForAvailableDevices();
toast(t('messages.cameraChanged'), 'success', 'top', 2000);
}
}
async function handleAudioDeviceChange() {
const newDeviceId = audioSelect.value;
if (newDeviceId && newDeviceId !== selectedDevices.audioInput) {
selectedDevices.audioInput = newDeviceId;
await updateAudioStream();
updateUIForAvailableDevices();
toast(t('messages.microphoneChanged'), 'success', 'top', 2000);
}
}
async function handleAudioOutputDeviceChange() {
const newDeviceId = audioOutputSelect.value;
if (newDeviceId && newDeviceId !== selectedDevices.audioOutput) {
selectedDevices.audioOutput = newDeviceId;
await setAudioOutputDevice(newDeviceId);
toast(t('messages.speakerChanged'), 'success', 'top', 2000);
}
}
async function updateVideoStream() {
try {
// Determine if camera was on before the switch
const oldVideoTrack = stream ? stream.getVideoTracks()[0] : null;
const wasCameraOff = videoBtn && videoBtn.classList.contains('btn-danger');
const constraints = {
video: { deviceId: selectedDevices.videoInput ? { exact: selectedDevices.videoInput } : true },
audio: false,
};
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
const videoTrack = newStream.getVideoTracks()[0];
if (!videoTrack) {
throw new Error('No video track found in new stream');
}
// Remove old track from stream
if (stream && oldVideoTrack) {
stream.removeTrack(oldVideoTrack);
if (oldVideoTrack.readyState === 'live') {
oldVideoTrack.stop();
}
}
if (wasCameraOff) {
// Camera was off — stop the newly acquired track to keep LED off,
// but still add it to stream so the device selection is remembered
videoTrack.stop();
videoTrack.enabled = false;
if (stream) {
stream.addTrack(videoTrack);
}
// Replace peer track with null to ensure nothing is sent
if (thisConnection) {
const sender = findVideoSender();
if (sender) {
await sender.replaceTrack(null);
}
}
// Update local video element
if (localVideo) {
localVideo.srcObject = stream;
}
videoBtn && videoBtn.classList.add('btn-danger');
showCameraOffOverlay('local', true);
} else {
// Camera was on — keep the new track live
if (stream) {
stream.addTrack(videoTrack);
}
// Update peer connection
if (thisConnection) {
const sender = findVideoSender();
if (sender) {
await sender.replaceTrack(videoTrack);
} else {
thisConnection.addTrack(videoTrack, stream);
}
}
// Update local video element
if (localVideo) {
localVideo.srcObject = stream;
handleVideoMirror(localVideo, stream);
}
videoBtn && videoBtn.classList.remove('btn-danger');
showCameraOffOverlay('local', false);
}
// Notify server about media status change so remote user is aware
sendMediaStatusToServer();
// Stop other tracks from the temporary stream
newStream.getAudioTracks().forEach((track) => track.stop());
} catch (error) {
console.error('Error updating video stream:', error);
handleError(t('errors.changeCameraFailed'));
}
}
async function updateAudioStream() {
try {
const constraints = {
video: false,
audio: { deviceId: selectedDevices.audioInput ? { exact: selectedDevices.audioInput } : true },
};
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
const audioTrack = newStream.getAudioTracks()[0];
if (!audioTrack) {
throw new Error('No audio track found in new stream');
}
// Update peer connection if it exists
if (thisConnection) {
const sender = thisConnection.getSenders().find((s) => s.track && s.track.kind === 'audio');
if (sender) {
await sender.replaceTrack(audioTrack);
} else {
// If no sender exists, add the track
thisConnection.addTrack(audioTrack, stream);
}
}
// Update local stream
if (stream) {
const oldAudioTrack = stream.getAudioTracks()[0];
if (oldAudioTrack) {
stream.removeTrack(oldAudioTrack);
oldAudioTrack.stop();
}
stream.addTrack(audioTrack);
}
// Stop other tracks from the temporary stream
newStream.getVideoTracks().forEach((track) => track.stop());
} catch (error) {
console.error('Error updating audio stream:', error);
handleError(t('errors.changeMicrophoneFailed'));
}
}
async function setAudioOutputDevice(deviceId) {
try {
if (remoteVideo && typeof remoteVideo.setSinkId === 'function') {
await remoteVideo.setSinkId(deviceId);
selectedDevices.audioOutput = deviceId;
} else {
console.warn('Browser does not support audio output device selection');
toast(t('errors.audioOutputNotSupported'), 'warning', 'top', 3000);
}
} catch (error) {
console.error('Error setting audio output device:', error);
handleError(t('errors.changeSpeakerFailed'));
}
}
async function testDevices() {
if (testDevicesBtn) {
testDevicesBtn.disabled = true;
}
try {
// Build constraints based on available devices
const hasCamera = availableDevices.videoInputs.length > 0;
const hasMic = availableDevices.audioInputs.length > 0;
if (!hasCamera && !hasMic) {
toast(t('errors.noDevicesToTest'), 'warning', 'top', 2000);
return;
}
const constraints = {
video: hasCamera
? selectedDevices.videoInput
? { deviceId: { exact: selectedDevices.videoInput } }
: true
: false,
audio: hasMic
? selectedDevices.audioInput
? { deviceId: { exact: selectedDevices.audioInput } }
: true
: false,
};
const testStream = await navigator.mediaDevices.getUserMedia(constraints);
// Log stream info for debugging
console.log('Test stream tracks:', {
video: testStream.getVideoTracks().length,
audio: testStream.getAudioTracks().length,
videoSettings: testStream.getVideoTracks()[0]?.getSettings(),
audioSettings: testStream.getAudioTracks()[0]?.getSettings(),
});
// Test for 2 seconds then stop
setTimeout(() => {
testStream.getTracks().forEach((track) => track.stop());
toast(t('messages.deviceTestCompleted'), 'success', 'top', 2000);
}, 2000);
} catch (error) {
console.error('Error testing devices:', error);
handleError(t('errors.deviceTestFailed', { message: error.message }));
} finally {
if (testDevicesBtn) {
testDevicesBtn.disabled = false;
}
}
}
// Initialize devices when settings tab is accessed (not on page load)
// This prevents conflicts with initial stream setup
// Clean up before window close or reload
window.onbeforeunload = () => {
handleLeaveClick();
};