Skip to content

Commit 48b7e19

Browse files
[mirotalkc2c] - fix(devices): handle missing or blocked camera/mic/speaker in the UI
1 parent 013bdd4 commit 48b7e19

8 files changed

Lines changed: 108 additions & 23 deletions

File tree

‎.env.template‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# ====================================================
2-
# MiroTalk C2C v.1.4.71 - Environment Configuration
2+
# MiroTalk C2C v.1.4.72 - Environment Configuration
33
# ====================================================
44

55
# App environment

‎backend/server.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* @license For private project or commercial purposes contact us at: license.mirotalk@gmail.com or purchase it directly via Code Canyon:
1010
* @license https://codecanyon.net/item/mirotalk-c2c-webrtc-real-time-cam-2-cam-video-conferences-and-screen-sharing/43383005
1111
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
12-
* @version 1.4.71
12+
* @version 1.4.72
1313
*/
1414

1515
require('dotenv').config();

‎frontend/css/home.css‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,13 @@ a {
423423
color: var(--success);
424424
background: rgba(22, 163, 74, 0.1);
425425
}
426+
.media-toggle-btn:disabled {
427+
cursor: not-allowed;
428+
opacity: 0.6;
429+
}
430+
.media-toggle-btn:disabled:hover {
431+
transform: none;
432+
}
426433
.media-toggle-btn:hover {
427434
transform: translateY(-1px);
428435
}

‎frontend/js/client.js‎

Lines changed: 65 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* @license For private project or commercial purposes contact us at: license.mirotalk@gmail.com or purchase it directly via Code Canyon:
1010
* @license https://codecanyon.net/item/mirotalk-c2c-webrtc-real-time-cam-2-cam-video-conferences-and-screen-sharing/43383005
1111
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
12-
* @version 1.4.71
12+
* @version 1.4.72
1313
*/
1414

1515
const savedTheme = window.localStorage.getItem('home-theme');
@@ -215,6 +215,8 @@ let cameraEffects = null;
215215
// Devices actually in use, to restore the selects when a swap fails or a fallback device was opened
216216
let activeVideoDeviceId = null;
217217
let activeAudioDeviceId = null;
218+
let cameraAvailable = true;
219+
let microphoneAvailable = true;
218220
let backgroundImage = null;
219221
let backgroundEffectsBusy = false;
220222
let localMediaStream = null;
@@ -669,6 +671,8 @@ async function setupLocalMedia(callback, errorBack) {
669671
let stream = await getBestUserMedia(constraints);
670672
activeVideoDeviceId = getStreamDeviceId(stream, 'video') || null;
671673
activeAudioDeviceId = getStreamDeviceId(stream, 'audio') || null;
674+
cameraAvailable = hasVideoTrack(stream);
675+
microphoneAvailable = hasAudioTrack(stream);
672676

673677
// Apply noise suppression to the stream if enabled
674678
const originalStream = stream;
@@ -698,11 +702,12 @@ async function setupLocalMedia(callback, errorBack) {
698702
async function enumerateDevices() {
699703
try {
700704
const devices = await navigator.mediaDevices.enumerateDevices();
701-
const videoDevices = devices.filter((device) => device.kind === 'videoinput' && device.deviceId !== 'default');
702-
const audioDevices = devices.filter((device) => device.kind === 'audioinput' && device.deviceId !== 'default');
703-
const speakerDevices = devices.filter(
704-
(device) => device.kind === 'audiooutput' && device.deviceId !== 'default'
705-
);
705+
// Without permission the browser lists devices with an empty id: they cannot be used
706+
const usable = (kind) =>
707+
devices.filter((device) => device.kind === kind && device.deviceId && device.deviceId !== 'default');
708+
const videoDevices = usable('videoinput');
709+
const audioDevices = usable('audioinput');
710+
const speakerDevices = usable('audiooutput');
706711
removeAllChildren(audioSource);
707712
removeAllChildren(videoSource);
708713
removeAllChildren(audioOutputSource);
@@ -723,7 +728,11 @@ async function enumerateDevices() {
723728
await addChild(audioOutputSource, device);
724729
LS.DEVICES_COUNT.speaker++;
725730
}
726-
elemDisplay(audioOutputDiv, LS.DEVICES_COUNT.speaker > 0);
731+
elemDisplay(audioOutputDiv, true);
732+
if (LS.DEVICES_COUNT.speaker === 0) {
733+
showNoDevice(audioOutputSource, 'No speaker');
734+
elemDisable(testSpeakerBtn, true);
735+
}
727736
} else {
728737
elemDisplay(audioOutputDiv, false);
729738
}
@@ -990,9 +999,12 @@ function setRemoteMedia(stream, peers, peerId) {
990999
remoteVideoElem = createVideoElement(peerId + '_remoteVideo', stream, false);
9911000
remoteVideoElem.style.display = 'block';
9921001
remoteVideoAvatarImage.style.display = 'none';
993-
const remoteVideoSpinner = createVideoSpinner();
994-
remoteVideoWrap.appendChild(remoteVideoSpinner);
995-
remoteVideoElem.addEventListener('playing', () => remoteVideoSpinner.remove(), { once: true });
1002+
// A peer without an active camera sends a disabled placeholder track: nothing to wait for
1003+
if (peerVideo || peerScreen) {
1004+
const remoteVideoSpinner = createVideoSpinner();
1005+
remoteVideoWrap.appendChild(remoteVideoSpinner);
1006+
remoteVideoElem.addEventListener('playing', () => remoteVideoSpinner.remove(), { once: true });
1007+
}
9961008
}
9971009

9981010
// Audio element (if audio track)
@@ -1434,14 +1446,14 @@ function toggleSettings() {
14341446

14351447
function updateBackgroundControls() {
14361448
const imageMode = backgroundEffectSelect.value === 'image';
1437-
backgroundEffectSelect.disabled = backgroundEffectsBusy || isScreenStreaming;
1438-
backgroundImageBtn.disabled = backgroundEffectsBusy || isScreenStreaming;
1449+
backgroundEffectSelect.disabled = backgroundEffectsBusy || isScreenStreaming || !cameraAvailable;
1450+
backgroundImageBtn.disabled = backgroundEffectsBusy || isScreenStreaming || !cameraAvailable;
14391451
backgroundImageBtn.hidden = !imageMode;
14401452
backgroundImageName.hidden = !imageMode || !backgroundImage;
14411453
backgroundEffectLoading.hidden = !backgroundEffectsBusy;
14421454
backgroundEffectsSection.setAttribute('aria-busy', String(backgroundEffectsBusy));
1443-
videoSource.disabled = backgroundEffectsBusy || isScreenStreaming;
1444-
swapCameraBtn.disabled = backgroundEffectsBusy || isScreenStreaming;
1455+
videoSource.disabled = backgroundEffectsBusy || isScreenStreaming || !cameraAvailable;
1456+
swapCameraBtn.disabled = backgroundEffectsBusy || isScreenStreaming || !cameraAvailable;
14451457
screenShareBtn.disabled = backgroundEffectsBusy;
14461458
initScreenShareBtn.disabled = backgroundEffectsBusy;
14471459
}
@@ -1758,8 +1770,8 @@ async function playTestSound() {
17581770
function getVideoConstraints(deviceId = false) {
17591771
let videoConstraints = true;
17601772

1761-
elemDisable(videoQualitySelect, localStorageConfig.video.settings.best_quality);
1762-
elemDisable(videoFpsSelect, localStorageConfig.video.settings.best_quality);
1773+
elemDisable(videoQualitySelect, localStorageConfig.video.settings.best_quality || !cameraAvailable);
1774+
elemDisable(videoFpsSelect, localStorageConfig.video.settings.best_quality || !cameraAvailable);
17631775

17641776
if (localStorageConfig.video.settings.best_quality) {
17651777
resetVideoConstraints();
@@ -1872,6 +1884,36 @@ function restoreDeviceSelect(select, deviceId, setLocalStorageConfig) {
18721884
function syncDeviceSelects() {
18731885
selectDevice(videoSource, activeVideoDeviceId);
18741886
selectDevice(audioSource, activeAudioDeviceId);
1887+
if (!cameraAvailable) disableCameraControls();
1888+
if (!microphoneAvailable) disableMicrophoneControls();
1889+
}
1890+
1891+
/**
1892+
* Show a disabled placeholder in the select (the saved device preference is left untouched)
1893+
* @param {HTMLSelectElement} select
1894+
* @param {string} label
1895+
*/
1896+
function showNoDevice(select, label) {
1897+
const placeholder = new Option(label, '');
1898+
select.add(placeholder);
1899+
select.selectedIndex = placeholder.index;
1900+
select.disabled = true;
1901+
}
1902+
1903+
// Without a microphone there is nothing to configure
1904+
function disableMicrophoneControls() {
1905+
showNoDevice(audioSource, 'No microphone');
1906+
elemDisable(switchNoiseSuppression, true);
1907+
}
1908+
1909+
// Without a camera there is nothing to configure
1910+
function disableCameraControls() {
1911+
elemDisable(videoQualitySelect, true);
1912+
elemDisable(videoFpsSelect, true);
1913+
elemDisable(switchMaxVideoQuality, true);
1914+
showNoDevice(videoSource, 'No camera');
1915+
backgroundEffectSelect.value = 'off';
1916+
updateBackgroundControls();
18751917
}
18761918

18771919
function saveLocalStorageConfig() {
@@ -1886,7 +1928,7 @@ function loadLocalStorageConfig() {
18861928
videoQualitySelect.selectedIndex = localStorageConfig.video.settings.quality_index;
18871929
videoFpsSelect.selectedIndex = localStorageConfig.video.settings.fps_index;
18881930
audioSource.selectedIndex = localStorageConfig.audio.devices.select.index;
1889-
if (isAudioOutputSupported() && audioOutputSource.options.length > 0) {
1931+
if (isAudioOutputSupported() && audioOutputSource.options.length > 0 && !audioOutputSource.disabled) {
18901932
audioOutputSource.selectedIndex = localStorageConfig?.speaker?.devices?.select?.index ?? 0;
18911933
}
18921934
switchNoiseSuppression.checked = localStorageConfig?.audio?.settings?.noise_suppression ?? false;
@@ -1965,7 +2007,7 @@ function setLocalVideoStatus(active = true, e = false) {
19652007
emitPeerStatus('video', active);
19662008
localStorageConfig.video.init.active = active;
19672009
saveLocalStorageConfig();
1968-
elemDisable(videoQualitySelect, !active || localStorageConfig.video.settings.best_quality);
2010+
elemDisable(videoQualitySelect, !active || localStorageConfig.video.settings.best_quality || !cameraAvailable);
19692011
}
19702012

19712013
function setLocalScreenStatus(active = false, e = false) {
@@ -2581,6 +2623,11 @@ function updatePeerMediaDisplay(peerId, active, type) {
25812623
const peerVideo = document.getElementById(peerId + '_remoteVideo');
25822624
const peerVideoAvatarImage = document.getElementById(peerId + '_remoteVideoAvatar');
25832625
if (peerVideoAvatarImage) elemDisplay(peerVideoAvatarImage, !active);
2626+
if (!active)
2627+
document
2628+
.getElementById(peerId + '_remoteVideoWrap')
2629+
?.querySelector('.video-spinner')
2630+
?.remove();
25842631
if (peerVideo) {
25852632
peerVideo.style.display = active ? 'block' : 'none';
25862633
peerVideo.style.objectFit =

‎frontend/js/home.js‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ async function initHome() {
9595
updateMediaToggle(initVideoBtn, 'video', active);
9696
};
9797

98+
watchMediaPermission('microphone', initAudioBtn, 'audio');
99+
watchMediaPermission('camera', initVideoBtn, 'video');
100+
98101
joinForm.onsubmit = (event) => {
99102
event.preventDefault();
100103

@@ -204,6 +207,33 @@ function updateMediaToggle(btn, kind, active) {
204207
btn.setAttribute('aria-pressed', String(active));
205208
}
206209

210+
/**
211+
* Lock the toggle in the Off state while the browser permission is blocked,
212+
* and restore the saved choice when the permission is granted again.
213+
* Browsers that cannot query the permission keep the toggle as is.
214+
* @param {string} name permission name ('microphone' or 'camera')
215+
* @param {HTMLButtonElement} btn
216+
* @param {string} kind 'audio' or 'video'
217+
*/
218+
async function watchMediaPermission(name, btn, kind) {
219+
if (!navigator.permissions?.query) return;
220+
let status;
221+
try {
222+
status = await navigator.permissions.query({ name });
223+
} catch {
224+
return;
225+
}
226+
const defaultTitle = btn.title;
227+
const update = () => {
228+
const blocked = status.state === 'denied';
229+
btn.disabled = blocked;
230+
btn.title = blocked ? `${name} access is blocked in the browser` : defaultTitle;
231+
updateMediaToggle(btn, kind, !blocked && localStorageConfig[kind].init.active);
232+
};
233+
status.onchange = update;
234+
update();
235+
}
236+
207237
function elementDisplay(elem, display) {
208238
elem.hidden = !display;
209239
}

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mirotalkc2c",
3-
"version": "1.4.71",
3+
"version": "1.4.72",
44
"description": "A free WebRTC Cam-2-Cam browser-based video calls",
55
"main": "server.js",
66
"scripts": {

‎tests/test-background-effects.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ describe('camera background integration', () => {
150150
getBestUserMedia: async () => new TestStream([makeTrack('video', 'returned-camera'), audio]),
151151
getVideoConstraints: () => ({ frameRate: 30 }),
152152
activeVideoDeviceId: null,
153+
cameraAvailable: true,
153154
getStreamDeviceId: () => undefined,
154155
selectDevice: () => false,
155156
notifyBusyDevice() {},

0 commit comments

Comments
 (0)