diff --git a/package-lock.json b/package-lock.json
index f756537..05d318d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "call-me",
- "version": "1.2.99",
+ "version": "1.3.01",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "call-me",
- "version": "1.2.99",
+ "version": "1.3.01",
"license": "AGPLv3",
"dependencies": {
"@ngrok/ngrok": "1.7.0",
@@ -1453,9 +1453,9 @@
"dev": true
},
"node_modules/qs": {
- "version": "6.14.1",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
- "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
+ "version": "6.14.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+ "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
diff --git a/package.json b/package.json
index 8c7face..3ae1753 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "call-me",
- "version": "1.2.99",
+ "version": "1.3.01",
"description": "Your Go-To for Instant Video Calls",
"author": "Miroslav Pejic - miroslav.pejic.85@gmail.com",
"license": "AGPLv3",
diff --git a/public/client.js b/public/client.js
index dd805d4..338d9da 100755
--- a/public/client.js
+++ b/public/client.js
@@ -72,6 +72,7 @@ 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;
@@ -694,22 +695,115 @@ function handleCallBtnClick() {
handleUserClickToCall(selectedUser);
}
-// Toggle video stream
-function handleVideoClick() {
+// 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];
- videoTrack.enabled = !videoTrack.enabled;
- videoBtn.classList.toggle('btn-danger');
+ const isCameraOn = videoTrack && videoTrack.readyState === 'live' && videoTrack.enabled;
- // Show/hide camera off overlay for local video
- showCameraOffOverlay('local', !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);
- // Send media status to server
- sendMediaStatusToServer();
+ // 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);
+ }
+ }
+ }
- sendMsg({
- type: 'remoteVideo',
- enabled: videoTrack.enabled,
- });
+ // 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
@@ -766,9 +860,8 @@ async function startScreenSharing() {
// Store original camera stream
originalStream = stream;
- // Store original video enabled state
- const originalVideoTrack = originalStream.getVideoTracks()[0];
- const wasVideoEnabled = originalVideoTrack ? originalVideoTrack.enabled : true;
+ // 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({
@@ -785,10 +878,10 @@ async function startScreenSharing() {
screenStream.addTrack(audioTrack);
}
- // Apply original video enabled state to screen share track
+ // Screen share video respects camera state: if camera was off, start with screen share hidden
const screenVideoTrack = screenStream.getVideoTracks()[0];
if (screenVideoTrack) {
- screenVideoTrack.enabled = wasVideoEnabled;
+ screenVideoTrack.enabled = !wasCameraOffBeforeScreenShare;
}
// Update stream and local video
@@ -798,19 +891,17 @@ async function startScreenSharing() {
localVideo.classList.add('screen-share'); // Apply screen share styling
localVideo.classList.remove('camera-feed');
- // Show/hide camera off overlay based on video state
- showCameraOffOverlay('local', !wasVideoEnabled);
+ // 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('Screen share video enabled state:', wasVideoEnabled);
+ console.log('Camera was off before screen share:', wasCameraOffBeforeScreenShare);
- // Update peer connection if it exists
+ // Update peer connection if it exists (use helper to find sender even when track is null)
if (thisConnection) {
- const videoSender = thisConnection
- .getSenders()
- .find((sender) => sender.track && sender.track.kind === 'video');
+ const videoSender = findVideoSender();
if (videoSender) {
- await videoSender.replaceTrack(screenStream.getVideoTracks()[0]);
+ await videoSender.replaceTrack(wasCameraOffBeforeScreenShare ? null : screenStream.getVideoTracks()[0]);
}
}
@@ -821,17 +912,16 @@ async function startScreenSharing() {
screenShareBtn.title = t('controls.stopScreenShare');
screenShareBtn.innerHTML = '';
+ // 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();
- // Ensure UI button state matches actual video state
- if (!wasVideoEnabled) {
- // If video was disabled before screen sharing, keep the video button in disabled state
- if (!videoBtn.classList.contains('btn-danger')) {
- videoBtn.classList.add('btn-danger');
- }
- }
-
// Listen for screen share end (user clicks browser's stop sharing)
screenStream.getVideoTracks()[0].onended = () => {
stopScreenSharing();
@@ -859,9 +949,8 @@ async function stopScreenSharing() {
return;
}
- // Store screen share video enabled state to restore to camera
- const screenVideoTrack = stream.getVideoTracks()[0];
- const currentVideoEnabled = screenVideoTrack ? screenVideoTrack.enabled : true;
+ // Use stored flag for camera state (video button now reflects screen share, not camera)
+ const wasCameraOff = wasCameraOffBeforeScreenShare;
// Stop screen share tracks
if (stream) {
@@ -875,10 +964,17 @@ async function stopScreenSharing() {
// Restore original camera stream
stream = originalStream;
- // Apply the video enabled state from screen share to camera
const cameraVideoTrack = stream.getVideoTracks()[0];
- if (cameraVideoTrack) {
- cameraVideoTrack.enabled = currentVideoEnabled;
+
+ 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;
@@ -886,19 +982,23 @@ async function stopScreenSharing() {
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 video state
- showCameraOffOverlay('local', !currentVideoEnabled);
+ // 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('Restored camera video enabled state:', currentVideoEnabled);
+ console.log('Camera was off before screen share:', wasCameraOff);
// Update peer connection if it exists
if (thisConnection) {
- const videoSender = thisConnection
- .getSenders()
- .find((sender) => sender.track && sender.track.kind === 'video');
- if (videoSender && originalStream.getVideoTracks()[0]) {
- await videoSender.replaceTrack(originalStream.getVideoTracks()[0]);
+ 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);
+ }
}
}
@@ -914,6 +1014,7 @@ async function stopScreenSharing() {
// Reset original stream reference
originalStream = null;
+ wasCameraOffBeforeScreenShare = false;
// Ensure UI button state matches actual video state
checkVideoAudioStatus();
@@ -1031,7 +1132,7 @@ async function refreshPeerVideoStreams(newStream) {
return;
}
- const videoSender = thisConnection.getSenders().find((sender) => sender.track && sender.track.kind === 'video');
+ const videoSender = findVideoSender();
if (videoSender) {
try {
await videoSender.replaceTrack(videoTrack);
@@ -1045,7 +1146,9 @@ async function refreshPeerVideoStreams(newStream) {
function checkVideoAudioStatus() {
if (videoBtn.classList.contains('btn-danger')) {
const videoTrack = stream.getVideoTracks()[0];
- videoTrack.enabled = false;
+ if (videoTrack && videoTrack.readyState === 'live') {
+ videoTrack.enabled = false;
+ }
// Show camera off overlay for local video
showCameraOffOverlay('local', true);
} else {
@@ -1054,7 +1157,9 @@ function checkVideoAudioStatus() {
}
if (audioBtn.classList.contains('btn-danger')) {
const audioTrack = stream.getAudioTracks()[0];
- audioTrack.enabled = false;
+ if (audioTrack) {
+ audioTrack.enabled = false;
+ }
}
}
@@ -1414,6 +1519,7 @@ async function handleAnswer(data) {
connectedUser = pendingUser;
pendingUser = null;
updateUsernameDisplay();
+ renderUserList(); // Update UI to show hang-up button for caller
}
} catch (error) {
handleError(t('errors.remoteDescriptionFailed'), error);
@@ -2600,12 +2706,12 @@ async function initializeDeviceSettings() {
const videoTrack = stream.getVideoTracks()[0];
const audioTrack = stream.getAudioTracks()[0];
- if (videoTrack) {
+ if (videoTrack && videoTrack.readyState === 'live') {
const videoSettings = videoTrack.getSettings();
selectedDevices.videoInput = videoSettings.deviceId;
}
- if (audioTrack) {
+ if (audioTrack && audioTrack.readyState === 'live') {
const audioSettings = audioTrack.getSettings();
selectedDevices.audioInput = audioSettings.deviceId;
}
@@ -2640,7 +2746,8 @@ function populateDeviceSelects() {
if (videoSelect) {
videoSelect.innerHTML = '';
// Check if we actually have camera access by checking the stream
- const hasCamera = stream && stream.getVideoTracks().length > 0;
+ // 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 = ``;
@@ -2707,7 +2814,8 @@ function populateDeviceSelects() {
// Update UI elements based on available devices
function updateUIForAvailableDevices() {
- const hasCamera = stream && stream.getVideoTracks().length > 0;
+ // 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
@@ -2753,18 +2861,23 @@ async function refreshDevices(showToast = true) {
}
try {
- // Try to request permissions for available devices (don't fail if one is missing)
- try {
- await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
- } catch (e) {
- // Try video only
+ // 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 {
- await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
- } catch (videoError) {
- // Try audio only as fallback
+ 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 {
- await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
- } catch (audioError) {
+ 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');
}
}
@@ -2816,6 +2929,10 @@ async function handleAudioOutputDeviceChange() {
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,
@@ -2828,35 +2945,53 @@ async function updateVideoStream() {
throw new Error('No video track found in new stream');
}
- // Preserve previous video enabled state
- let wasVideoEnabled = true;
- if (stream && stream.getVideoTracks().length > 0) {
- wasVideoEnabled = stream.getVideoTracks()[0].enabled;
- } else if (videoBtn && videoBtn.classList.contains('btn-danger')) {
- wasVideoEnabled = false;
- }
- // Apply preserved enabled state to new track
- videoTrack.enabled = wasVideoEnabled;
-
- // Update peer connection if it exists
- if (thisConnection) {
- const sender = thisConnection.getSenders().find((s) => s.track && s.track.kind === 'video');
- if (sender) {
- await sender.replaceTrack(videoTrack);
- } else {
- // If no sender exists, add the track
- thisConnection.addTrack(videoTrack, stream);
- }
- }
-
- // Update local video and stream
- if (stream) {
- const oldVideoTrack = stream.getVideoTracks()[0];
- if (oldVideoTrack) {
- stream.removeTrack(oldVideoTrack);
+ // Remove old track from stream
+ if (stream && oldVideoTrack) {
+ stream.removeTrack(oldVideoTrack);
+ if (oldVideoTrack.readyState === 'live') {
oldVideoTrack.stop();
}
- stream.addTrack(videoTrack);
+ }
+
+ 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) {
@@ -2864,19 +2999,13 @@ async function updateVideoStream() {
handleVideoMirror(localVideo, stream);
}
- // 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);
- }
-
- // Notify server about media status change so remote user is aware
- sendMediaStatusToServer();
+ 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) {