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
1313const savedTheme = window . localStorage . getItem ( 'home-theme' ) ;
@@ -215,6 +215,8 @@ let activeVideoDeviceId = null;
215215let activeAudioDeviceId = null ;
216216let cameraAvailable = true ;
217217let microphoneAvailable = true ;
218+ let mediaErrors = { audio : null , video : null } ; // last getUserMedia error name per device type
219+ let isRetryingMedia = false ;
218220let backgroundImage = null ;
219221let backgroundEffectsBusy = false ;
220222let 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 */
19161915function 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
19241925function 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
19331933function 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
19452171function saveLocalStorageConfig ( ) {
0 commit comments