Skip to content

Commit b6255d2

Browse files
[mirotalkc2c] - feat(media): show blocked camera/mic buttons with explanation and retry
1 parent b06d26c commit b6255d2

7 files changed

Lines changed: 309 additions & 38 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.75 - Environment Configuration
2+
# MiroTalk C2C v.1.4.76 - Environment Configuration
33
# ====================================================
44

55
# App environment

‎backend/server.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @link Live demo: https://c2c.mirotalk.com
88
* @license Open source: AGPLv3 | Commercial/closed source (official channel): https://docs.mirotalk.com/sites/c2c/
99
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
10-
* @version 1.4.75
10+
* @version 1.4.76
1111
*/
1212

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

‎frontend/css/client.css‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,34 @@ body {
686686
color: white !important;
687687
}
688688

689+
/* Blocked/unavailable camera or microphone: dimmed with a warning badge, still clickable to explain why and retry */
690+
.action-btn[data-media-unavailable='true'] {
691+
opacity: 0.65;
692+
cursor: help;
693+
}
694+
695+
.action-btn[data-media-unavailable='true']:hover {
696+
opacity: 0.9;
697+
}
698+
699+
.action-btn[data-media-unavailable='true']::after {
700+
content: '!';
701+
position: absolute;
702+
top: 3px;
703+
right: 3px;
704+
width: 14px;
705+
height: 14px;
706+
color: #1a1d27;
707+
font-family: system-ui, sans-serif;
708+
font-size: 10px;
709+
font-weight: 800;
710+
line-height: 14px;
711+
text-align: center;
712+
border-radius: 50%;
713+
background: #ffc107;
714+
pointer-events: none;
715+
}
716+
689717
/* Responsive for mobile: controls shrink to fit, scrolling only as a last resort */
690718
@media (max-width: 600px) {
691719
.waiting-buttons,

‎frontend/js/client.js‎

Lines changed: 243 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @link Live demo: https://c2c.mirotalk.com
88
* @license Open source: AGPLv3 | Commercial/closed source (official channel): https://docs.mirotalk.com/sites/c2c/
99
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
10-
* @version 1.4.75
10+
* @version 1.4.76
1111
*/
1212

1313
const savedTheme = window.localStorage.getItem('home-theme');
@@ -215,6 +215,8 @@ let activeVideoDeviceId = null;
215215
let activeAudioDeviceId = null;
216216
let cameraAvailable = true;
217217
let microphoneAvailable = true;
218+
let mediaErrors = { audio: null, video: null }; // last getUserMedia error name per device type
219+
let isRetryingMedia = false;
218220
let backgroundImage = null;
219221
let backgroundEffectsBusy = false;
220222
let localMediaStream = null;
@@ -709,6 +711,7 @@ async function enumerateDevices() {
709711
removeAllChildren(audioSource);
710712
removeAllChildren(videoSource);
711713
removeAllChildren(audioOutputSource);
714+
audioOutputSource.disabled = false;
712715
LS.DEVICES_COUNT.audio = 0;
713716
LS.DEVICES_COUNT.video = 0;
714717
LS.DEVICES_COUNT.speaker = 0;
@@ -727,6 +730,7 @@ async function enumerateDevices() {
727730
LS.DEVICES_COUNT.speaker++;
728731
}
729732
elemDisplay(audioOutputDiv, true);
733+
elemDisable(testSpeakerBtn, false);
730734
if (LS.DEVICES_COUNT.speaker === 0) {
731735
showNoDevice(audioOutputSource, 'No speaker');
732736
elemDisable(testSpeakerBtn, true);
@@ -1196,9 +1200,11 @@ function handleEvents() {
11961200
toggleHideMe();
11971201
};
11981202
initAudioBtn.onclick = (e) => {
1203+
if (!microphoneAvailable) return showMediaUnavailable('audio');
11991204
if (hasAudioTrack(localMediaStream)) setLocalAudioStatus(!localMediaStream.getAudioTracks()[0].enabled, e);
12001205
};
12011206
initVideoBtn.onclick = (e) => {
1207+
if (!cameraAvailable) return showMediaUnavailable('video');
12021208
if (hasVideoTrack(localMediaStream)) setLocalVideoStatus(!localMediaStream.getVideoTracks()[0].enabled, e);
12031209
};
12041210
initChatOpenBtn.onclick = () => {
@@ -1211,10 +1217,12 @@ function handleEvents() {
12111217
toggleHideMe();
12121218
};
12131219
audioBtn.onclick = (e) => {
1220+
if (!microphoneAvailable) return showMediaUnavailable('audio');
12141221
if (isPushToTalkActive) return;
12151222
if (hasAudioTrack(localMediaStream)) setLocalAudioStatus(!localMediaStream.getAudioTracks()[0].enabled, e);
12161223
};
12171224
videoBtn.onclick = (e) => {
1225+
if (!cameraAvailable) return showMediaUnavailable('video');
12181226
if (hasVideoTrack(localMediaStream)) setLocalVideoStatus(!localMediaStream.getVideoTracks()[0].enabled, e);
12191227
};
12201228
if (!isMobileDevice && (navigator.getDisplayMedia || navigator.mediaDevices.getDisplayMedia)) {
@@ -1228,16 +1236,7 @@ function handleEvents() {
12281236
elemDisplay(initScreenShareBtn, false);
12291237
elemDisplay(screenShareBtn, false);
12301238
}
1231-
navigator.mediaDevices.enumerateDevices().then((devices) => {
1232-
const videoInput = devices.filter((device) => device.kind === 'videoinput');
1233-
if (videoInput.length > 1 && isMobileDevice) {
1234-
swapCameraBtn.onclick = () => {
1235-
swapCamera();
1236-
};
1237-
} else {
1238-
elemDisplay(swapCameraBtn, false);
1239-
}
1240-
});
1239+
setupSwapCameraBtn();
12411240
recordingBtn.onclick = () => {
12421241
toggleRecording();
12431242
};
@@ -1914,32 +1913,259 @@ function syncDeviceSelects() {
19141913
* @param {string} label
19151914
*/
19161915
function showNoDevice(select, label) {
1916+
select.querySelectorAll('option[data-placeholder]').forEach((option) => option.remove());
19171917
const placeholder = new Option(label, '');
1918+
placeholder.dataset.placeholder = 'true';
19181919
select.add(placeholder);
19191920
select.selectedIndex = placeholder.index;
19201921
select.disabled = true;
19211922
}
19221923

1923-
// Without a microphone there is nothing to configure or toggle
1924+
// Without a microphone there is nothing to configure, the button stays visible to explain why and to retry
19241925
function disableMicrophoneControls() {
19251926
showNoDevice(audioSource, 'No microphone');
19261927
elemDisable(switchNoiseSuppression, true);
1927-
elemDisplay(initAudioBtn, false);
1928-
elemDisplay(document.getElementById('audioControl'), false);
19291928
elemDisplay(pushToTalkDiv, false);
1929+
updateMediaUnavailableUI();
19301930
}
19311931

1932-
// Without a camera there is nothing to configure or toggle
1932+
// Without a camera there is nothing to configure, the button stays visible to explain why and to retry
19331933
function disableCameraControls() {
1934-
elemDisplay(initVideoBtn, false);
1935-
elemDisplay(videoBtn, false);
19361934
elemDisplay(swapCameraBtn, false);
19371935
elemDisable(videoQualitySelect, true);
19381936
elemDisable(videoFpsSelect, true);
19391937
elemDisable(switchMaxVideoQuality, true);
19401938
showNoDevice(videoSource, 'No camera');
19411939
backgroundEffectSelect.value = 'off';
19421940
updateBackgroundControls();
1941+
updateMediaUnavailableUI();
1942+
}
1943+
1944+
function restoreMicrophoneControls() {
1945+
audioSource.disabled = false;
1946+
elemDisable(switchNoiseSuppression, false);
1947+
if (!isMobileDevice) pushToTalkDiv.style.display = '';
1948+
updateMediaUnavailableUI();
1949+
}
1950+
1951+
function restoreCameraControls() {
1952+
videoSource.disabled = false;
1953+
elemDisable(switchMaxVideoQuality, false);
1954+
elemDisable(videoQualitySelect, localStorageConfig.video.settings.best_quality);
1955+
updateBackgroundControls();
1956+
setupSwapCameraBtn();
1957+
updateMediaUnavailableUI();
1958+
}
1959+
1960+
async function setupSwapCameraBtn() {
1961+
let videoInputs = 0;
1962+
try {
1963+
const devices = await navigator.mediaDevices.enumerateDevices();
1964+
videoInputs = devices.filter((device) => device.kind === 'videoinput').length;
1965+
} catch (err) {
1966+
console.warn('Unable to enumerate cameras', err);
1967+
}
1968+
if (cameraAvailable && videoInputs > 1 && isMobileDevice) {
1969+
swapCameraBtn.onclick = () => {
1970+
swapCamera();
1971+
};
1972+
swapCameraBtn.style.display = '';
1973+
} else {
1974+
elemDisplay(swapCameraBtn, false);
1975+
}
1976+
}
1977+
1978+
/**
1979+
* Mark the mic/camera buttons as unavailable (they stay clickable to explain why and to retry)
1980+
*/
1981+
function updateMediaUnavailableUI() {
1982+
const groups = [
1983+
{
1984+
available: microphoneAvailable,
1985+
elements: [audioBtn, initAudioBtn],
1986+
tooltip: 'Toggle audio',
1987+
label: 'Toggle microphone',
1988+
unavailable: 'My audio is disabled, click to try again',
1989+
},
1990+
{
1991+
available: cameraAvailable,
1992+
elements: [videoBtn, initVideoBtn],
1993+
tooltip: 'Toggle video',
1994+
label: 'Toggle camera',
1995+
unavailable: 'My video is disabled, click to try again',
1996+
},
1997+
];
1998+
groups.forEach(({ available, elements, tooltip, label, unavailable }) => {
1999+
elements.forEach((el) => {
2000+
if (!el) return;
2001+
if (available) {
2002+
el.removeAttribute('data-media-unavailable');
2003+
} else {
2004+
el.setAttribute('data-media-unavailable', 'true');
2005+
}
2006+
el.setAttribute('aria-label', available ? label : unavailable);
2007+
setTippy(el, available ? tooltip : unavailable, 'top');
2008+
});
2009+
});
2010+
}
2011+
2012+
/**
2013+
* Describe why a device is unavailable, based on the last getUserMedia error
2014+
* @param {string} kind 'audio' or 'video'
2015+
* @returns {{type: string, title: string, text: string}}
2016+
*/
2017+
function getMediaUnavailableInfo(kind) {
2018+
const device = kind === 'audio' ? 'microphone' : 'camera';
2019+
const Device = kind === 'audio' ? 'Microphone' : 'Camera';
2020+
const messages = {
2021+
blocked: {
2022+
title: `${Device} blocked`,
2023+
text: `Permission denied. Please allow access to the ${device} in your browser settings, then try again.`,
2024+
},
2025+
missing: {
2026+
title: `No ${device} found`,
2027+
text: `No ${device} was detected. Connect one, then try again.`,
2028+
},
2029+
busy: {
2030+
title: `${Device} busy`,
2031+
text: `The ${device} may be in use by another app. Close it, then try again.`,
2032+
},
2033+
error: {
2034+
title: `${Device} unavailable`,
2035+
text: `Unable to access the ${device}. Check your browser and system settings, then try again.`,
2036+
},
2037+
};
2038+
const types = {
2039+
NotAllowedError: 'blocked',
2040+
PermissionDeniedError: 'blocked',
2041+
SecurityError: 'blocked',
2042+
NotFoundError: 'missing',
2043+
DevicesNotFoundError: 'missing',
2044+
NotReadableError: 'busy',
2045+
TrackStartError: 'busy',
2046+
AbortError: 'busy',
2047+
};
2048+
const type = types[mediaErrors[kind]] || 'error';
2049+
return { type, ...messages[type] };
2050+
}
2051+
2052+
/**
2053+
* Explain why the camera/microphone is unavailable and let the user try again
2054+
* @param {string} kind 'audio' or 'video'
2055+
*/
2056+
function showMediaUnavailable(kind) {
2057+
if (isRetryingMedia) return;
2058+
const info = getMediaUnavailableInfo(kind);
2059+
Swal.fire({
2060+
background: swal.background,
2061+
position: 'center',
2062+
icon: 'warning',
2063+
title: info.title,
2064+
text: info.text,
2065+
footer: '<a href="https://blog.addpipe.com/common-getusermedia-errors/" target="_blank" rel="noopener noreferrer">Check the common getUserMedia errors</a>',
2066+
showCancelButton: true,
2067+
reverseButtons: true,
2068+
confirmButtonText: 'Try again',
2069+
cancelButtonText: 'Close',
2070+
showClass: { popup: 'animate__animated animate__zoomIn animate__faster' },
2071+
hideClass: { popup: 'animate__animated animate__zoomOut animate__faster' },
2072+
}).then((result) => {
2073+
if (result.isConfirmed) retryMediaAccess([kind]);
2074+
});
2075+
}
2076+
2077+
/**
2078+
* Ask again for the camera/microphone (after the user fixed the permission or plugged the device)
2079+
* and start sharing them with the peer when already in a call
2080+
* @param {Array<string>} kinds 'audio' and/or 'video'
2081+
*/
2082+
async function retryMediaAccess(kinds) {
2083+
if (isRetryingMedia || !kinds.length) return;
2084+
if (isScreenStreaming) {
2085+
return popupMessage('toast', 'Screen sharing', 'Stop screen sharing to retry the camera/microphone', 'top');
2086+
}
2087+
if (recording && recording.isStreamRecording()) {
2088+
return popupMessage('toast', 'Recording', 'Cannot retry the camera/microphone while recording', 'top');
2089+
}
2090+
isRetryingMedia = true;
2091+
2092+
const wantAudio = kinds.includes('audio');
2093+
const wantVideo = kinds.includes('video');
2094+
const got = { audio: false, video: false };
2095+
let rawStream = null;
2096+
let newStream = null;
2097+
try {
2098+
const audioDeviceId = localStorageConfig.audio.devices.select.id || (audioSource && audioSource.value);
2099+
const videoDeviceId = localStorageConfig.video.devices.select.id || (videoSource && videoSource.value);
2100+
2101+
// The video constraints helper keeps the camera selects disabled while the camera is unavailable
2102+
if (wantVideo) cameraAvailable = true;
2103+
const stream = (rawStream = await getBestUserMedia({
2104+
audio: wantAudio && getAudioConstraints(audioDeviceId),
2105+
video: wantVideo && getVideoConstraints(videoDeviceId),
2106+
}));
2107+
got.audio = wantAudio && hasAudioTrack(stream);
2108+
got.video = wantVideo && hasVideoTrack(stream);
2109+
if (wantAudio) microphoneAvailable = got.audio;
2110+
if (wantVideo) cameraAvailable = got.video;
2111+
2112+
if (got.audio || got.video) {
2113+
if (got.audio) activeAudioDeviceId = getStreamDeviceId(stream, 'audio') || activeAudioDeviceId;
2114+
if (got.video) activeVideoDeviceId = getStreamDeviceId(stream, 'video') || activeVideoDeviceId;
2115+
newStream = await applyNoiseSuppressionToLocalStream(stream);
2116+
if (got.video) camera = detectCameraFacingMode(newStream);
2117+
newStream = await prepareCameraBackground(newStream);
2118+
}
2119+
} catch (err) {
2120+
console.error('[Error] retry media access', err);
2121+
rawStream?.getTracks().forEach((track) => track.stop());
2122+
newStream = null;
2123+
got.audio = got.video = false;
2124+
if (wantAudio) microphoneAvailable = false;
2125+
if (wantVideo) cameraAvailable = false;
2126+
}
2127+
2128+
try {
2129+
if (newStream && (got.audio || got.video)) {
2130+
// Keep the tracks of the device that was not retried
2131+
const kept = (localMediaStream?.getTracks() || []).filter((track) => !got[track.kind]);
2132+
localMediaStream?.getTracks().forEach((track) => got[track.kind] && track.stop());
2133+
localMediaStream = buildUniqueMediaStream(new MediaStream([...kept, ...newStream.getTracks()]));
2134+
setLocalMedia(localMediaStream);
2135+
if (got.video) refreshMyVideoStreamToPeers(localMediaStream);
2136+
if (got.audio) refreshMyAudioStreamToPeers(localMediaStream);
2137+
if (got.audio && !isPushToTalkActive) setLocalAudioStatus(true);
2138+
if (got.video) setLocalVideoStatus(true);
2139+
handleCameraMirror(window.myVideo, camera);
2140+
await enumerateDevices();
2141+
selectDevice(videoSource, activeVideoDeviceId);
2142+
selectDevice(audioSource, activeAudioDeviceId);
2143+
selectDevice(audioOutputSource, getSelectedSpeakerId());
2144+
if (got.audio) changeSpeaker(audioOutputSource.value);
2145+
}
2146+
if (got.audio) restoreMicrophoneControls();
2147+
if (got.video) restoreCameraControls();
2148+
syncDeviceSelects();
2149+
updateBackgroundControls();
2150+
updateMediaUnavailableUI();
2151+
} catch (err) {
2152+
console.error('[Error] apply retried media', err);
2153+
} finally {
2154+
isRetryingMedia = false;
2155+
}
2156+
2157+
const failed = kinds.filter((kind) => !got[kind === 'audio' ? 'audio' : 'video']);
2158+
if (failed.length) return showMediaUnavailable(failed[0]);
2159+
popupMessage(
2160+
'toast',
2161+
'Ready',
2162+
kinds.length > 1
2163+
? 'Camera and microphone are ready'
2164+
: kinds[0] === 'audio'
2165+
? 'Microphone is ready'
2166+
: 'Camera is ready',
2167+
'top'
2168+
);
19432169
}
19442170

19452171
function saveLocalStorageConfig() {

0 commit comments

Comments
 (0)