From 8714f295b7168602e4211531e8df5501832898d5 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 4 Feb 2026 16:40:52 +0300 Subject: [PATCH 01/11] IDs for #volumeControls, #volumeSlider, #controlMute now include the monitor ID (watch.php) And also #volumeControls by default has the class "disabled" --- web/skins/classic/views/watch.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/skins/classic/views/watch.php b/web/skins/classic/views/watch.php index d843c019a..7e075519e 100644 --- a/web/skins/classic/views/watch.php +++ b/web/skins/classic/views/watch.php @@ -457,9 +457,9 @@ $muted = (isset($_REQUEST['muted']) and $_REQUEST['muted'] == 'true') ? true : ((isset($_COOKIE['zmWatchMuted']) and $_COOKIE['zmWatchMuted'] == 'true') ? true : false); ZM\Debug("Muted $muted"); ?> - -
- + +
+
From 1bfeb2512dc2d48f575de7aff16dd30f9ce48bf0 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 4 Feb 2026 16:52:24 +0300 Subject: [PATCH 02/11] Added "disabled" class to #volumeControls and ".audio-control-mute" (skin.css) --- web/skins/classic/css/base/skin.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/skins/classic/css/base/skin.css b/web/skins/classic/css/base/skin.css index cc7dbdf05..9c9d935e8 100644 --- a/web/skins/classic/css/base/skin.css +++ b/web/skins/classic/css/base/skin.css @@ -1543,6 +1543,15 @@ noUi-target { right: -9px; /* half the width */ border-radius: 9px; } + +.disabled .audio-control-mute { + color: var(--disabled); + cursor: not-allowed !important; +} + +[id^="volumeControls"].disabled { + opacity: 0.4; +} /* --- Volume сontrol --- */ .rightInFlexContainer { From 85d5cbd63acb26d76da580115855a85b22d0fe86 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 4 Feb 2026 19:28:33 +0300 Subject: [PATCH 03/11] Added the ability to retrieve tracks from a stream, disable Volume Controls if there is no audio track, and improved functionality for RTSP2Web, Janus, and ZMS. (MonitorStream.js) --- web/js/MonitorStream.js | 437 ++++++++++++++++++++++++++++------------ 1 file changed, 304 insertions(+), 133 deletions(-) diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index bde42c6b4..072d6a9ed 100644 --- a/web/js/MonitorStream.js +++ b/web/js/MonitorStream.js @@ -36,6 +36,9 @@ function MonitorStream(monitorData) { this.mseSourceBuffer = null; this.janusEnabled = monitorData.janusEnabled; this.janusPin = monitorData.janus_pin; + this.mediaStream = null; + this.audioTrack = null; + this.videoTrack = null; this.server_id = monitorData.server_id; this.scale = monitorData.scale ? parseInt(monitorData.scale) : 100; this.status = {capturefps: 0, analysisfps: 0}; // json object with alarmstatus, fps etc @@ -331,11 +334,14 @@ function MonitorStream(monitorData) { } }; // setStreamScale - this.updateStreamInfo = function(info) { + /* + * If you specify info='' when calling, only the "status" will be updated, while "info" will not be changed. + */ + this.updateStreamInfo = function(info='', status='') { const modeEl = document.querySelector('#monitor' + this.id + ' .stream-info-mode'); const statusEl = document.querySelector('#monitor' + this.id + ' .stream-info-status'); - if (modeEl) modeEl.innerText = info; - if (statusEl) statusEl.innerText = ''; + if (modeEl && info) modeEl.innerText = info; + if (statusEl) statusEl.innerText = status; }; this.copyAllAttributes = function(fromEl, toEl) { @@ -344,6 +350,18 @@ function MonitorStream(monitorData) { } }; + this.replaceDOMElement = function(fromEl, toTypeEl) { + let newEl = fromEl; + if (fromEl.nodeName != toTypeEl.toUpperCase()) { + const container = fromEl.parentNode; + newEl = document.createElement(toTypeEl); + this.copyAllAttributes(fromEl, newEl); + fromEl.parentNode.removeChild(fromEl); + container.appendChild(newEl); + } + return newEl; + }; + /* * streamChannel options: * 'default' or 'Primary' - Main stream (uses monitor ID, which is ZM restream if RTSPServer enabled) @@ -402,9 +420,7 @@ function MonitorStream(monitorData) { if (ZM_GO2RTC_PATH) { const url = new URL(ZM_GO2RTC_PATH); - const old_stream = this.getElement(); - const stream = this.element = document.createElement('video-stream'); - this.copyAllAttributes(old_stream, stream); + const stream = this.element = this.replaceDOMElement(this.getElement(), 'video-stream'); stream.background = true; // We do not use the document hiding/showing analysis from "video-rtc.js", because we have our own analysis //stream.muted = this.muted; const Go2RTCModUrl = url; @@ -416,10 +432,7 @@ function MonitorStream(monitorData) { webrtcUrl.pathname += "/ws"; webrtcUrl.search = 'src=' + this.id + streamSuffix; stream.src = webrtcUrl.href; - const stream_container = old_stream.parentNode; - old_stream.remove(); - stream_container.appendChild(stream); this.webrtc = stream; // track separately do to api differences between video tag and video-stream if (-1 != this.player.indexOf('_')) { stream.mode = this.player.substring(this.player.indexOf('_')+1); @@ -447,9 +460,15 @@ function MonitorStream(monitorData) { if (this.janusEnabled && ((!this.player) || (-1 !== this.player.indexOf('janus')))) { let server; - document.querySelector('video').addEventListener('play', (e) => { - this.createVolumeSlider(); - }, this); + const stream = this.element = this.replaceDOMElement(this.getElement(), 'video'); + stream.setAttribute("autoplay", ""); + stream.setAttribute("muted", this.muted); + const video_el = document.querySelector('#liveStream'+this.id); + if (video_el) { + video_el.addEventListener('play', (e) => { + this.createVolumeSlider(); + }, this); + } if (ZM_JANUS_PATH) { server = ZM_JANUS_PATH; } else if (this.server_id && Servers[this.server_id]) { @@ -466,39 +485,33 @@ function MonitorStream(monitorData) { janus = new Janus({server: server}); //new Janus }}); } - attachVideo(parseInt(this.id), this.janusPin); + attachVideo(this); this.statusCmdTimer = setInterval(this.statusCmdQuery.bind(this), statusRefreshTimeout); this.started = true; this.streamListenerBind(); this.activePlayer = 'janus'; - this.updateStreamInfo('Janus'); + this.updateStreamInfo('Janus', 'loading'); return; } // FIXME auto mode doesn't work properly here. Ideally it would try each until one succeeds if (this.RTSP2WebEnabled && ((!this.player) || (-1 !== this.player.indexOf('rtsp2web')))) { if (ZM_RTSP2WEB_PATH) { - let stream = this.getElement(); - if (stream.nodeName != 'VIDEO') { - // replace with new video tag. - const stream_container = stream.parentNode; - const new_stream = this.element = document.createElement('video'); - this.copyAllAttributes(stream, new_stream); - new_stream.setAttribute("autoplay", ""); - new_stream.setAttribute("muted", this.muted); - new_stream.setAttribute("playsinline", ""); - new_stream.style = stream.style; // Copy any applied styles - stream.remove(); - stream_container.appendChild(new_stream); - stream = new_stream; - } + const stream = this.element = this.replaceDOMElement(this.getElement(), 'video'); + stream.setAttribute("autoplay", ""); + stream.setAttribute("muted", this.muted); + stream.setAttribute("playsinline", ""); const url = new URL(ZM_RTSP2WEB_PATH); const useSSL = (url.protocol == 'https'); const rtsp2webModUrl = url; - document.querySelector('video').addEventListener('play', (e) => { - this.createVolumeSlider(); - }, this); + const video_el = document.querySelector('video#liveStream'+this.id); + if (video_el) { + video_el.muted = this.muted; + video_el.addEventListener('play', (e) => { + this.createVolumeSlider(); + }, this); + } rtsp2webModUrl.username = ''; rtsp2webModUrl.password = ''; //.urlParts.length > 1 ? urlParts[1] : urlParts[0]; // drop the username and password for viewing @@ -515,7 +528,15 @@ function MonitorStream(monitorData) { } */ if (Hls.isSupported()) { - this.hls = new Hls(); + this.hls = new Hls({ + maxBufferLength: 10, + maxMaxBufferLength: 30, + }); + this.hls.on(Hls.Events.MEDIA_ATTACHED, function (event, data) { + console.log(`Video and hls.js are now bound together for monitor ID=${this.id}`); + this.updateStreamInfo('', ''); //HLS + this.getTracksFromStream(); //HLS + }, this); this.hls.loadSource(hlsUrl.href); this.hls.attachMedia(stream); } else if (stream.canPlayType('application/vnd.apple.mpegurl')) { @@ -539,7 +560,7 @@ function MonitorStream(monitorData) { this.statusCmdTimer = setInterval(this.statusCmdQuery.bind(this), statusRefreshTimeout); this.started = true; this.streamListenerBind(); - this.updateStreamInfo(players ? players[this.activePlayer] : 'RTSP2Web ' + this.RTSP2WebType); + this.updateStreamInfo(players ? players[this.activePlayer] : 'RTSP2Web ' + this.RTSP2WebType, 'loading'); return; } else { console.log("ZM_RTSP2WEB_PATH is empty. Go to Options->System and set ZM_RTSP2WEB_PATH accordingly."); @@ -547,18 +568,9 @@ function MonitorStream(monitorData) { } // zms stream - let stream = this.getElement(); + const stream = this.element = this.replaceDOMElement(this.getElement(), 'img'); if (!stream) return; - if (stream.nodeName != 'IMG') { - // replace with new img tag. - const stream_container = stream.parentNode; - const new_stream = this.element = document.createElement('img'); - this.copyAllAttributes(stream, new_stream); - stream.remove(); - stream_container.appendChild(new_stream); - stream = new_stream; - } this.streamCmdTimer = clearTimeout(this.streamCmdTimer); // Step 1 make sure we are streaming instead of a static image if (stream.getAttribute('loading') == 'lazy') { @@ -582,6 +594,7 @@ function MonitorStream(monitorData) { src = src.replace(/auth=\w+/i, 'auth='+auth_hash); } if (-1 == src.search('connkey')) { + this.streamCmdParms.connkey = this.statusCmdParms.connkey = this.connKey = this.genConnKey(); // The "connkey" needs to be replaced, because on the Watch page, when switching the player to ZMS, then to any other player, and then returning to ZMS, playback will not occur, because the socket="previous connkey" will be closed. src += '&connkey='+this.connKey; } if (-1 == src.search('scale=')) { @@ -608,10 +621,14 @@ function MonitorStream(monitorData) { if (!stream) { console.warn(`! ${dateTimeToISOLocal(new Date())} Stream for ID=${this.id} it is impossible to stop because it is not found.`); return; + } else if (!this.started) { + console.warn(`! ${dateTimeToISOLocal(new Date())} Stream for ID=${this.id} has already stopped.`); + return; } console.debug(`! ${dateTimeToISOLocal(new Date())} Stream for ID=${this.id} STOPPING`); this.statusCmdTimer = clearInterval(this.statusCmdTimer); this.streamCmdTimer = clearInterval(this.streamCmdTimer); + this.mediaStream = this.audioTrack = this.videoTrack = null; if (-1 !== this.activePlayer.indexOf('zms')) { // Icon: My current thought is to just tell zms to stop. Don't go to single. @@ -630,6 +647,7 @@ function MonitorStream(monitorData) { console.log('close not in ', this.webrtc); } this.webrtc = null; + stream.srcObject = null; } else if (-1 !== this.activePlayer.indexOf('rtsp2web')) { if (this.webrtc) { if (this.webrtc.close) this.webrtc.close(); @@ -645,12 +663,17 @@ function MonitorStream(monitorData) { this.stopMse(); } } else if (-1 !== this.activePlayer.indexOf('janus')) { - stream.src = ''; - stream.srcObject = null; + if (janus && streaming[this.id]) { + //streaming[this.id].detach(); // This will result in an error! This requires a more detailed study of Janus, or perhaps it has been fixed in a version higher than 1.1.2. + } + //stream.src = ''; + //stream.srcObject = null; + janus.destroy(); janus = null; } else { console.log("Unknown activePlayer", this.activePlayer); } + this.activePlayer = ''; this.started = false; }; @@ -685,7 +708,7 @@ function MonitorStream(monitorData) { resolve(); } - function onBufferRemoved(this_) { + function onBufferRemoved(event) { this.removeEventListener('updateend', onBufferRemoved); resolve(); } @@ -702,6 +725,7 @@ function MonitorStream(monitorData) { this.MSEBufferCleared = true; }) .catch((error) => { + //IMPORTANT!!! If this error occurs, captureStream will not always work for the next RTSP2Web RTC stream. This requires investigation!!! console.warn(`${dateTimeToISOLocal(new Date())} An error occurred while stopMse() for ID=${this.id}`, error); this.closeWebSocket(); this.mse = null; @@ -714,9 +738,9 @@ function MonitorStream(monitorData) { this.kill = function() { console.log("kill"); /* kill should actually remove the zms process. Resulting in a broken image on screen. */ - if (janus && streaming[this.id]) { - streaming[this.id].detach(); - } + //if (janus && streaming[this.id]) { // This will result in an error! + // streaming[this.id].detach(); + //} const stream = this.getElement(); if (!stream) { console.log("No element found for monitor "+this.id); @@ -729,7 +753,7 @@ function MonitorStream(monitorData) { if (this.started && (-1 !== this.activePlayer.indexOf('zms')) && this.connKey) { // Make zms exit, sometimes zms doesn't receive SIGPIPE, so try to send QUIT this.streamCommand(CMD_QUIT); - this.connKey = null; + this.streamCmdParms.connkey = this.statusCmdParms.connkey = this.connKey = null; this.started = false; } // Kill and stop share a lot of the same code... so just call stop @@ -751,8 +775,12 @@ function MonitorStream(monitorData) { this.statusCmdTimer = clearInterval(this.statusCmdTimer); } else if ((-1 !== this.activePlayer.indexOf('zms')) && this.connKey) { this.streamCommand(CMD_PAUSE); - } else { // janus? - this.element.pause(); + } else { // janus + if ('pause' in this.element) { + this.element.pause(); + } else { + console.log('The "Pause" method cannot be called on the element', this.element); + } this.statusCmdTimer = clearInterval(this.statusCmdTimer); } }; @@ -778,8 +806,12 @@ function MonitorStream(monitorData) { this.statusCmdTimer = setInterval(this.statusCmdQuery.bind(this), statusRefreshTimeout); } else if ((-1 !== this.activePlayer.indexOf('zms')) && this.connKey) { this.streamCommand(CMD_PLAY); - } else { - this.element.play(); + } else { // janus + if ('play' in this.element) { + this.element.play(); + } else { + console.log('The "Play" method cannot be called on the element', this.element); + } this.statusCmdTimer = setInterval(this.statusCmdQuery.bind(this), statusRefreshTimeout); } }; @@ -835,58 +867,62 @@ function MonitorStream(monitorData) { this.onplay = func; }; - this.getVolumeSlider = function(mid) { + + this.getVolumeControls = function() { // On Watch page slider has no ID, on Montage page it has ID - return (document.getElementById('volumeSlider')) ? document.getElementById('volumeSlider') : document.getElementById('volumeSlider'+mid); + return (document.getElementById('volumeControls')) ? document.getElementById('volumeControls') : document.getElementById('volumeControls'+this.id); }; - this.getIconMute = function(mid) { + this.getVolumeSlider = function() { + // On Watch page slider has no ID, on Montage page it has ID + return (document.getElementById('volumeSlider')) ? document.getElementById('volumeSlider') : document.getElementById('volumeSlider'+this.id); + }; + + this.getIconMute = function() { // On Watch page icon has no ID, on Montage page it has ID - return (document.getElementById('controlMute')) ? document.getElementById('controlMute') : document.getElementById('controlMute'+mid); + return (document.getElementById('controlMute')) ? document.getElementById('controlMute') : document.getElementById('controlMute'+this.id); }; - this.getAudioStream = function(mid) { + this.getAudioStream = function() { /* Go2RTC uses , RTSP2Web uses This.getElement() may need to be changed, but the implications of such a change need to be analyzed */ - return (document.querySelector('#liveStream'+mid + ' video') || document.getElementById('liveStream'+mid)); + return (document.querySelector('#liveStream'+this.id + ' video') || document.getElementById('liveStream'+this.id)); }; this.listenerVolumechange = function(el) { // System audio level change - const mid = this.id; const audioStream = el.target; - const volumeSlider = this.getVolumeSlider(mid); - if (volumeSlider.allowSetValue) { - if (audioStream.muted === true) { - this.changeStateIconMute(mid, 'off'); - volumeSlider.classList.add('noUi-mute'); - } else { - this.changeStateIconMute(mid, 'on'); - volumeSlider.classList.remove('noUi-mute'); - } - if (volumeSlider) { - volumeSlider.noUiSlider.set(audioStream.volume * 100); - } - } + const volumeSlider = this.getVolumeSlider(); + if (volumeSlider) { + volumeSlider.setAttribute('data-muted', audioStream.muted); + volumeSlider.setAttribute('data-volume', parseInt(audioStream.volume * 100)); + if (volumeSlider.allowSetValue) { + volumeSlider.noUiSlider.set(audioStream.volume * 100); + if (audioStream.muted === true) { + this.changeStateIconMute('off'); + volumeSlider.classList.add('noUi-mute'); + } else { + this.changeStateIconMute('on'); + volumeSlider.classList.remove('noUi-mute'); + } + } + } else { + console.warn(`volumeSlider for monitor with ID=${this.id} not found`); + } if (currentView != 'montage') { setCookie('zmWatchMuted', audioStream.muted); setCookie('zmWatchVolume', parseInt(audioStream.volume * 100)); } - if (volumeSlider) { - volumeSlider.setAttribute('data-muted', audioStream.muted); - volumeSlider.setAttribute('data-volume', parseInt(audioStream.volume * 100)); - } }; this.createVolumeSlider = function() { - const mid = this.id; - const volumeSlider = this.getVolumeSlider(mid); - const iconMute = this.getIconMute(mid); - const audioStream = this.getAudioStream(mid); + const volumeSlider = this.getVolumeSlider(); + const iconMute = this.getIconMute(); + const audioStream = this.getAudioStream(); if (!volumeSlider || !audioStream) return; const defaultVolume = (volumeSlider.getAttribute("data-volume") || 50); if (volumeSlider.noUiSlider) volumeSlider.noUiSlider.destroy(); @@ -956,9 +992,11 @@ function MonitorStream(monitorData) { /* * volume: on || off */ - this.changeStateIconMute = function(mid, volume) { - const iconMute = this.getIconMute(mid); - if (iconMute) { + this.changeStateIconMute = function(volume) { + const volumeControls = this.getVolumeControls(); + const disabled = (volumeControls) ? volumeControls.classList.contains('disabled') : false; + const iconMute = this.getIconMute(); + if (!disabled && iconMute) { iconMute.innerHTML = (volume == 'on')? 'volume_up' : 'volume_off'; } return iconMute; @@ -967,14 +1005,24 @@ function MonitorStream(monitorData) { /* * volume: on || off */ - this.changeVolumeSlider = function(mid, volume) { - const volumeSlider = this.getVolumeSlider(mid); + this.changeVolumeSlider = function(volume) { + const volumeControls = this.getVolumeControls(); + const controlMute = document.querySelector('[id ^= "controlMute'+this.id+'"]'); + const volumeSlider = this.getVolumeSlider(); + if (volumeSlider) { + let disabled = false; + if (volumeControls) { + disabled = volumeControls.classList.contains('disabled'); + } if (volume == 'on') { volumeSlider.classList.remove('noUi-mute'); } else if (volume == 'off') { volumeSlider.classList.add('noUi-mute'); } + if (volumeSlider.noUiSlider) { + (disabled) ? volumeSlider.noUiSlider.disable() : volumeSlider.noUiSlider.enable(); + } } return volumeSlider; }; @@ -984,39 +1032,113 @@ function MonitorStream(monitorData) { */ this.controlMute = function(mode = 'switch') { const mid = this.id; - let volumeSlider; - const audioStream = this.getAudioStream(mid); - if (!audioStream) { - console.log(`No audiostream! in controlMute for monitor ID=${mid}`); + let volumeSlider = this.getVolumeSlider(); + const audioStream = this.getAudioStream(); + const volumeControls = this.getVolumeControls(); + const disabled = (volumeControls) ? volumeControls.classList.contains('disabled') : false; + + if (volumeSlider && volumeSlider.noUiSlider) { + (disabled) ? volumeSlider.noUiSlider.disable() : volumeSlider.noUiSlider.enable(); + } + + if (disabled) { + console.log(`Volume control is disabled in controlMute for monitor ID=${this.id}`); return; } + if (!audioStream) { + console.log(`No audiostream! in controlMute for monitor ID=${this.id}`); + return; + } + if (mode=='switch') { if (audioStream.muted) { audioStream.muted = this.muted = false; - this.changeStateIconMute(mid, 'on'); - volumeSlider = this.changeVolumeSlider(mid, 'on'); - if (volumeSlider) { + this.changeStateIconMute('on'); + volumeSlider = this.changeVolumeSlider('on'); + if (volumeSlider && volumeSlider.noUiSlider) { audioStream.volume = volumeSlider.noUiSlider.get() / 100; } } else { audioStream.muted = this.muted = true; - this.changeStateIconMute(mid, 'off'); - this.changeVolumeSlider(mid, 'off'); + this.changeStateIconMute('off'); + this.changeVolumeSlider('off'); } } else if (mode=='on') { audioStream.muted = this.muted = true; - this.changeStateIconMute(mid, 'off'); - this.changeVolumeSlider(mid, 'off'); + this.changeStateIconMute('off'); + this.changeVolumeSlider('off'); } else if (mode=='off') { audioStream.muted = this.muted = false; - this.changeStateIconMute(mid, 'on'); - volumeSlider = this.changeVolumeSlider(mid, 'on'); - if (volumeSlider) { + this.changeStateIconMute('on'); + volumeSlider = this.changeVolumeSlider('on'); + if (volumeSlider && volumeSlider.noUiSlider) { audioStream.volume = volumeSlider.noUiSlider.get() / 100; } } }; + /* + * mode = 'disable' || 'enable' + */ + this.volumeControlsHandler = function(mode) { + const volumeControls = this.getVolumeControls(); + const volumeSlider = this.getVolumeSlider(); + if (mode == 'disable') { + if (volumeControls) volumeControls.classList.add('disabled'); + if (volumeSlider && volumeSlider.noUiSlider) { + volumeSlider.noUiSlider.disable(); + } + } else if (mode == 'enable') { + if (volumeControls) volumeControls.classList.remove('disabled'); + if (volumeSlider && volumeSlider.noUiSlider) { + volumeSlider.noUiSlider.enable(); + } + } + } + + /*IMPORTANT DO NOT CALL WITHOUT CONSCIOUS NEED!!!*/ + // https://habr.com/ru/companies/timeweb/articles/667148/ + this.getTracksFromStream = async function() { + this.mediaStream = this.audioTrack = this.videoTrack = null; + + let streamCaptureNotSupported = false; + const el = (-1 !== this.activePlayer.indexOf('go2rtc')) ? document.querySelector('[id ^= "liveStream'+this.id+'"] video') : this.getElement(); + let stream = null; + + // We should NOT call captureStream again, as there may be problems with capturing the stream! + let moz = false; // Detecting Firefox + if ("captureStream" in el) { + stream = await el.captureStream(); + } else if ("mozCaptureStreamUntilEnded" in el) { + stream = await el.mozCaptureStreamUntilEnded(); + moz = true; + } else { + console.warn(`"captureStream" NOT found in STREAM for monitor ID=${this.id} or not supported by the browser.`); + streamCaptureNotSupported = true; // This will enable the volume control if the browser does not support captureStream (for example, Safari) + } + + if (stream) { + this.audioTrack = stream.getAudioTracks()[0]; + this.videoTrack = stream.getVideoTracks()[0]; + this.mediaStream = stream; + if (moz && this.audioTrack) { + // Fix Firefox https://stackoverflow.com/questions/72401396/usage-of-mozcapturestream-stop-audio-output-of-video-element + const ctx = new AudioContext(); + const dest = ctx.createMediaStreamSource(stream); + dest.connect(ctx.destination); + } + } else if (!streamCaptureNotSupported){ + console.warn(`Failed to capture stream for monitor ID=${this.id} while receiving tracks.`); + } + + console.debug(`mediaStream for ID=${this.id}:`, this.mediaStream); + console.debug(`audioTrack for ID=${this.id}:`, this.audioTrack); + console.debug(`videoTrack for ID=${this.id}:`, this.videoTrack); + (this.audioTrack || streamCaptureNotSupported) ? this.volumeControlsHandler('enable') : this.volumeControlsHandler('disable'); + + //this.connectAudioMotion(); + } + this.setStateClass = function(jobj, stateClass) { if (!jobj) { console.log("No obj in setStateClass"); @@ -1370,7 +1492,7 @@ function MonitorStream(monitorData) { // We correct the lag from real time. Relevant for long viewing and network problems. if (-1 !== this.activePlayer.indexOf('mse')) { const videoEl = document.getElementById("liveStream" + this.id); - if (this.wsMSE && videoEl.buffered != undefined && videoEl.buffered.length > 0) { + if (this.wsMSE && videoEl && videoEl.buffered != undefined && videoEl.buffered.length > 0) { const videoElCurrentTime = videoEl.currentTime; // Current time of playback const currentTime = (Date.now() / 1000); const deltaRealTime = (currentTime - this.streamStartTime).toFixed(2); // How much real time has passed since playback started @@ -1401,13 +1523,21 @@ function MonitorStream(monitorData) { } } } else if (!this.wsMSE && this.started) { - console.warn(`UNSCHEDULED CLOSE SOCKET for camera ID=${this.id}`); - this.restart(this.currentChannelStream); + if (this.mse.readyState == 'open') { + console.warn(`UNSCHEDULED CLOSE SOCKET for camera ID=${this.id} RESTART is started.`); + this.restart(this.currentChannelStream); + } else { + console.log(`MediaSource for camera ID=${this.id} is in state "${this.mse.readyState.toUpperCase()}"`); + } } } else if (-1 !== this.player.indexOf('webrtc')) { if ((!this.webrtc || (this.webrtc && this.webrtc.connectionState != "connected")) && this.started) { - console.warn(`UNSCHEDULED CLOSE WebRTC for camera ID=${this.id}`); - this.restart(this.currentChannelStream); + if (this.webrtc && (this.webrtc.connectionState == "new" || this.webrtc.connectionState == "connecting")) { + console.log(`Waiting WebRTC connection for camera ID=${this.id} State="${this.webrtc.connectionState}"`); + } else { + console.warn(`UNSCHEDULED CLOSE WebRTC for camera ID=${this.id}`, this.webrtc, this.started); + this.restart(this.currentChannelStream); + } } } } // end if Go2RTC or RTSP2Web @@ -1504,9 +1634,12 @@ function MonitorStream(monitorData) { }; // end setMaxFPS this.closeWebSocket = function() { - console.log(`${dateTimeToISOLocal(new Date())} WebSocket for a video object ID=${this.id} is being closed.`); - if (this.wsMSE && this.wsMSE.readyState !== WebSocket.CLOSING && this.wsMSE.readyState !== WebSocket.CLOSED) { + if (!this.wsMSE || + (this.wsMSE && (this.wsMSE.readyState === WebSocket.CLOSING || this.wsMSE.readyState === WebSocket.CLOSED))) { + console.log(`${dateTimeToISOLocal(new Date())} WebSocket for a video object ID=${this.id} is already in the process of closing or has already been closed.`); + } else { //Socket may still be in the "CONNECTING" state. It would be better to wait for the connection and only then close it, but we will not complicate the code, since this happens rarely and does not globally affect the overall work. + console.log(`${dateTimeToISOLocal(new Date())} WebSocket for a video object ID=${this.id} is being closed.`); this.wsMSE.close(1000, "We close the connection"); } this.mseQueue = []; // ABSOLUTELY NEEDED @@ -1563,7 +1696,14 @@ function MonitorStream(monitorData) { }; } // end class MonitorStream -async function attachVideo(id, pin) { +/* +++ Janus */ +async function attachVideo(monitorStream) { + const id = parseInt(monitorStream.id); + const pin = monitorStream.janusPin; + if (!janus || !('isConnected' in janus)) { + console.log(`The Janus object for the camera with ID=${id} does not exist.`); + return; + } await waitUntil(() => janus.isConnected() ); janus.attach({ plugin: "janus.plugin.streaming", @@ -1616,9 +1756,18 @@ async function attachVideo(id, pin) { } }, //onmessage function onremotestream: function(ourstream) { - Janus.debug(" ::: Got a remote stream :::"); - Janus.debug(ourstream); - Janus.attachMediaStream(document.getElementById("liveStream" + id), ourstream); + if (monitorStream.started) { + Janus.debug(" ::: Got a remote stream :::"); + Janus.debug(ourstream); + if (ourstream.active) { + Janus.attachMediaStream(document.getElementById("liveStream" + id), ourstream); + } else { + Janus.debug("Janus stream is not active. Restart."); + monitorStream.restart(); + } + monitorStream.updateStreamInfo('', ''); //JANUS + monitorStream.getTracksFromStream(); //JANUS + } }, onremotetrack: function(track, mid, on) { Janus.debug(" ::: Got a remote track :::"); @@ -1653,7 +1802,10 @@ const waitUntil = (condition) => { }, 100); }); }; +/* --- Janus */ +/* +++ What is this ? */ +/* https://github.com/ZoneMinder/zoneminder/commit/a26a2e8020ca910043bc4a8c7e61fb623ba8bc4a */ async function get_PeerConnection(media, videoEl) { const pc = new RTCPeerConnection({ bundlePolicy: 'max-bundle', @@ -1710,6 +1862,7 @@ async function getMediaTracks(media, constraints) { return []; } } +/* --- What is this ? */ function startRTSP2WebPlay(videoEl, url, stream) { if (typeof RTCPeerConnection !== 'function') { @@ -1719,6 +1872,7 @@ function startRTSP2WebPlay(videoEl, url, stream) { stream.RTSP2WebType = null; // Avoid repeated restarts. return; } + stream.updateStreamInfo('', 'loading'); if (stream.webrtc) { stream.webrtc.close(); @@ -1754,19 +1908,29 @@ function startRTSP2WebPlay(videoEl, url, stream) { await stream.webrtc.setLocalDescription(offer); //console.log(stream.webrtc.localDescription.sdp); - $j.post(url, { - data: btoa(stream.webrtc.localDescription.sdp) - }, function(data) { - if ((stream.webrtc && 'sctp' in stream.webrtc && stream.webrtc.sctp) && stream.webrtc.sctp.state != 'stable') { - //console.log(data); - try { - stream.webrtc.setRemoteDescription(new RTCSessionDescription({ - type: 'answer', - sdp: atob(data) - })); - } catch (e) { - console.warn(e); + $j.ajax({ + url: url, + method: 'POST', + data: {data: btoa(stream.webrtc.localDescription.sdp)}, + success: function(response) { + if ((stream.webrtc && 'sctp' in stream.webrtc && stream.webrtc.sctp) && stream.webrtc.sctp.state != 'stable') { + try { + stream.webrtc.setRemoteDescription(new RTCSessionDescription({ + type: 'answer', + sdp: atob(response) + })); + } catch (e) { + console.warn(e); + } } + }, + error: function(xhr, status, error) { + console.warn('Error request localDescription:', error, xhr.responseText); + stream.updateStreamInfo('', 'Error'); //WEBRTC + stream.kill(); + }, + complete: function() { + //console.log('Request localDescription completed.'); } }); }; @@ -1774,6 +1938,7 @@ function startRTSP2WebPlay(videoEl, url, stream) { stream.webrtc.onsignalingstatechange = async function signalingstatechange() { switch (stream.webrtc.signalingState) { case 'have-local-offer': + //console.log("webrtc.onsignalingstatechange (connectionState): ", stream.webrtc.connectionState); break; case 'stable': /* @@ -1800,13 +1965,17 @@ function startRTSP2WebPlay(videoEl, url, stream) { const webrtcSendChannel = stream.webrtc.createDataChannel('rtsptowebSendChannel'); webrtcSendChannel.onopen = (event) => { - console.log(`${webrtcSendChannel.label} has opened`); + stream.updateStreamInfo('', ''); //WEBRTC + stream.getTracksFromStream(); //WEBRTC + console.log(`${webrtcSendChannel.label} for camera ID=${stream.id} has opened`); webrtcSendChannel.send('ping'); }; webrtcSendChannel.onclose = (_event) => { - console.log(`${webrtcSendChannel.label} has closed`); if (stream.started) { - startRTSP2WebPlay(videoEl, url, stream); + console.warn(`UNSCHEDULED CLOSE ${webrtcSendChannel.label} for camera ID=${stream.id}. We execute "stream.restart"`); + stream.restart(stream.currentChannelStream); + } else { + console.log(`${webrtcSendChannel.label} for camera ID=${stream.id} has closed`); } }; webrtcSendChannel.onmessage = (event) => console.log(event.data); @@ -1824,14 +1993,14 @@ function mseListenerSourceopen(context, videoEl, url) { context.wsMSE.binaryType = 'arraybuffer'; context.wsMSE.onopen = function(event) { - console.log(`Connect to ws for a video object ID=${context.id}`); + console.log(`Connect to WebSocket MSE for a video object ID=${context.id}`); }; context.wsMSE.onclose = (event) => { context.clearWebSocket(); - console.log(`${dateTimeToISOLocal(new Date())} WebSocket CLOSED for a video object ID=${context.id}.`); + console.log(`${dateTimeToISOLocal(new Date())} WebSocket MSE CLOSED for a video object ID=${context.id}.`); }; context.wsMSE.onerror = function(event) { - console.warn(`${dateTimeToISOLocal(new Date())} WebSocket ERROR for a video object ID=${context.id}:`, event); + console.warn(`${dateTimeToISOLocal(new Date())} WebSocket MSE ERROR for a video object ID=${context.id}:`, event); if (this.started) this.restart(); }; context.wsMSE.onmessage = function(event) { @@ -1847,9 +2016,9 @@ function mseListenerSourceopen(context, videoEl, url) { } if (MediaSource.isTypeSupported('video/mp4; codecs="' + mimeCodec + '"')) { - console.log(`For a video object ID=${context.id} codec used: ${mimeCodec}`); + console.log(`WebSocket MSE for a video object ID=${context.id} codec used: ${mimeCodec}`); } else { - const msg = `For a video object ID=${context.id} codec '${mimeCodec}' not supported. Monitor '${context.name}' ID=${context.id} not starting.`; + const msg = `WebSocket MSE for a video object ID=${context.id} codec '${mimeCodec}' not supported. Monitor '${context.name}' ID=${context.id} not starting.`; console.log(msg); context.getElement().before(document.createTextNode(msg)); context.stop(); @@ -1891,10 +2060,12 @@ function startMsePlay(context, videoEl, url) { context.mse = new MediaSource(); videoEl.onplay = (event) => { + context.updateStreamInfo('', ''); //MSE + context.getTracksFromStream(); //MSE context.streamStartTime = (Date.now() / 1000).toFixed(2); if (videoEl.buffered.length > 0 && videoEl.currentTime < videoEl.buffered.end(videoEl.buffered.length - 1) - 0.1) { //For example, after a pause you press Play, you need to adjust the time. - console.debug(`${dateTimeToISOLocal(new Date())} Adjusting currentTime for a video object ID=${context.id} Lag='${(videoEl.buffered.end(videoEl.buffered.length - 1) - videoEl.currentTime).toFixed(2)}sec.`); + console.debug(`${dateTimeToISOLocal(new Date())} WebSocket MSE adjusting currentTime for a video object ID=${context.id} Lag='${(videoEl.buffered.end(videoEl.buffered.length - 1) - videoEl.currentTime).toFixed(2)}sec.`); videoEl.currentTime = videoEl.buffered.end(videoEl.buffered.length - 1) - 0.1; } }; From 534a9ef19720dd1c11b99598aac670a5dce0d230 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 4 Feb 2026 19:36:35 +0300 Subject: [PATCH 04/11] Fix: Eslint (MonitorStream.js) --- web/js/MonitorStream.js | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index 072d6a9ed..183fa1ccc 100644 --- a/web/js/MonitorStream.js +++ b/web/js/MonitorStream.js @@ -356,7 +356,7 @@ function MonitorStream(monitorData) { const container = fromEl.parentNode; newEl = document.createElement(toTypeEl); this.copyAllAttributes(fromEl, newEl); - fromEl.parentNode.removeChild(fromEl); + fromEl.parentNode.removeChild(fromEl); container.appendChild(newEl); } return newEl; @@ -532,7 +532,7 @@ function MonitorStream(monitorData) { maxBufferLength: 10, maxMaxBufferLength: 30, }); - this.hls.on(Hls.Events.MEDIA_ATTACHED, function (event, data) { + this.hls.on(Hls.Events.MEDIA_ATTACHED, function(event, data) { console.log(`Video and hls.js are now bound together for monitor ID=${this.id}`); this.updateStreamInfo('', ''); //HLS this.getTracksFromStream(); //HLS @@ -568,7 +568,7 @@ function MonitorStream(monitorData) { } // zms stream - const stream = this.element = this.replaceDOMElement(this.getElement(), 'img'); + const stream = this.element = this.replaceDOMElement(this.getElement(), 'img'); if (!stream) return; this.streamCmdTimer = clearTimeout(this.streamCmdTimer); @@ -1007,7 +1007,7 @@ function MonitorStream(monitorData) { */ this.changeVolumeSlider = function(volume) { const volumeControls = this.getVolumeControls(); - const controlMute = document.querySelector('[id ^= "controlMute'+this.id+'"]'); + //const controlMute = document.querySelector('[id ^= "controlMute'+this.id+'"]'); const volumeSlider = this.getVolumeSlider(); if (volumeSlider) { @@ -1031,7 +1031,6 @@ function MonitorStream(monitorData) { * mode: switch, on, off */ this.controlMute = function(mode = 'switch') { - const mid = this.id; let volumeSlider = this.getVolumeSlider(); const audioStream = this.getAudioStream(); const volumeControls = this.getVolumeControls(); @@ -1094,7 +1093,7 @@ function MonitorStream(monitorData) { volumeSlider.noUiSlider.enable(); } } - } + }; /*IMPORTANT DO NOT CALL WITHOUT CONSCIOUS NEED!!!*/ // https://habr.com/ru/companies/timeweb/articles/667148/ @@ -1105,7 +1104,7 @@ function MonitorStream(monitorData) { const el = (-1 !== this.activePlayer.indexOf('go2rtc')) ? document.querySelector('[id ^= "liveStream'+this.id+'"] video') : this.getElement(); let stream = null; - // We should NOT call captureStream again, as there may be problems with capturing the stream! + // We should NOT call captureStream again, as there may be problems with capturing the stream! let moz = false; // Detecting Firefox if ("captureStream" in el) { stream = await el.captureStream(); @@ -1127,7 +1126,7 @@ function MonitorStream(monitorData) { const dest = ctx.createMediaStreamSource(stream); dest.connect(ctx.destination); } - } else if (!streamCaptureNotSupported){ + } else if (!streamCaptureNotSupported) { console.warn(`Failed to capture stream for monitor ID=${this.id} while receiving tracks.`); } @@ -1137,7 +1136,7 @@ function MonitorStream(monitorData) { (this.audioTrack || streamCaptureNotSupported) ? this.volumeControlsHandler('enable') : this.volumeControlsHandler('disable'); //this.connectAudioMotion(); - } + }; this.setStateClass = function(jobj, stateClass) { if (!jobj) { @@ -1965,8 +1964,8 @@ function startRTSP2WebPlay(videoEl, url, stream) { const webrtcSendChannel = stream.webrtc.createDataChannel('rtsptowebSendChannel'); webrtcSendChannel.onopen = (event) => { - stream.updateStreamInfo('', ''); //WEBRTC - stream.getTracksFromStream(); //WEBRTC + stream.updateStreamInfo('', ''); //WEBRTC + stream.getTracksFromStream(); //WEBRTC console.log(`${webrtcSendChannel.label} for camera ID=${stream.id} has opened`); webrtcSendChannel.send('ping'); }; From 62faabc5f0d045d5be9dca06f2d5efed1bfbbb49 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Thu, 5 Feb 2026 00:30:00 +0300 Subject: [PATCH 05/11] When using ZMS, destroy VolumeSlider if it exists (MonitorStream.js) --- web/js/MonitorStream.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index 183fa1ccc..c0eecec14 100644 --- a/web/js/MonitorStream.js +++ b/web/js/MonitorStream.js @@ -571,6 +571,8 @@ function MonitorStream(monitorData) { const stream = this.element = this.replaceDOMElement(this.getElement(), 'img'); if (!stream) return; + this.destroyVolumeSlider(); + this.streamCmdTimer = clearTimeout(this.streamCmdTimer); // Step 1 make sure we are streaming instead of a static image if (stream.getAttribute('loading') == 'lazy') { @@ -989,6 +991,16 @@ function MonitorStream(monitorData) { } }; + this.destroyVolumeSlider = function() { + const volumeSlider = this.getVolumeSlider(); + const iconMute = this.getIconMute(); + if (volumeSlider) { + volumeSlider.innerText = ""; + } + if (iconMute) iconMute.innerText = ""; + if (volumeSlider && 'noUiSlider' in volumeSlider) volumeSlider.noUiSlider.destroy(); + } + /* * volume: on || off */ From 27c225e4ef84d9decef3edc58f07a28da36a61f8 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Thu, 5 Feb 2026 00:32:31 +0300 Subject: [PATCH 06/11] Missing Semicolon (MonitorStream.js) --- web/js/MonitorStream.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index c0eecec14..812d0a647 100644 --- a/web/js/MonitorStream.js +++ b/web/js/MonitorStream.js @@ -999,7 +999,7 @@ function MonitorStream(monitorData) { } if (iconMute) iconMute.innerText = ""; if (volumeSlider && 'noUiSlider' in volumeSlider) volumeSlider.noUiSlider.destroy(); - } + }; /* * volume: on || off From ef74b635872e7baa7ee5e90cfc8948f11e08de74 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Thu, 5 Feb 2026 00:44:46 +0300 Subject: [PATCH 07/11] Code optimization (MonitorStream.js) --- web/js/MonitorStream.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index 812d0a647..060670eb4 100644 --- a/web/js/MonitorStream.js +++ b/web/js/MonitorStream.js @@ -994,9 +994,6 @@ function MonitorStream(monitorData) { this.destroyVolumeSlider = function() { const volumeSlider = this.getVolumeSlider(); const iconMute = this.getIconMute(); - if (volumeSlider) { - volumeSlider.innerText = ""; - } if (iconMute) iconMute.innerText = ""; if (volumeSlider && 'noUiSlider' in volumeSlider) volumeSlider.noUiSlider.destroy(); }; From ac7a85f2b3f20d8f1b5294e5fe81e73d5b375a95 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Thu, 5 Feb 2026 10:22:28 +0300 Subject: [PATCH 08/11] Added the ability to get monitorStream by camera ID (Update skin.js) --- web/skins/classic/js/skin.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/web/skins/classic/js/skin.js b/web/skins/classic/js/skin.js index a276c5af8..43a8805c6 100644 --- a/web/skins/classic/js/skin.js +++ b/web/skins/classic/js/skin.js @@ -2373,6 +2373,18 @@ function resetSelectElement(el) { selectElement.change(); } +function getMonitorStream(mid) { + let monitorStream_ = null; + if (currentView == 'watch') { + monitorStream_ = monitorStream; + } else if (currentView == 'montage') { + monitorStream_ = monitors.find((o) => { + return parseInt(o["id"]) === mid; + }); + } + return monitorStream_; +} + function initPageGeneral() { $j(document).on('keyup.global keydown.global', function handleKey(e) { shifted = e.shiftKey ? e.shiftKey : e.shift; From 3cb83939d2264d3af891131039c5aa456edb4962 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Fri, 6 Feb 2026 13:57:36 +0300 Subject: [PATCH 09/11] Do not change cursor appearance for disabled #volumeControl (skin.css) --- web/skins/classic/css/base/skin.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/css/base/skin.css b/web/skins/classic/css/base/skin.css index 9c9d935e8..ae48b81fa 100644 --- a/web/skins/classic/css/base/skin.css +++ b/web/skins/classic/css/base/skin.css @@ -1510,7 +1510,7 @@ noUi-target { } */ -.noUi-base, .noUi-touch-area { +[id^="volumeControls"]:not(.disabled) .noUi-base, [id^="volumeControls"]:not(.disabled) .noUi-touch-area { cursor: pointer; } From 3fe3813de6befde8b61ec72287573425ac1519ff Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Fri, 6 Feb 2026 14:08:46 +0300 Subject: [PATCH 10/11] Update skin.css --- web/skins/classic/css/base/skin.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/skins/classic/css/base/skin.css b/web/skins/classic/css/base/skin.css index ae48b81fa..47075dfd3 100644 --- a/web/skins/classic/css/base/skin.css +++ b/web/skins/classic/css/base/skin.css @@ -1530,6 +1530,10 @@ noUi-target { height: 5px; } +.noUi-handle:after { + left: 16px; +} + /* .noUi-round .noUi-connects { background: #c0392b; From 09156ccf11fa2c3fdd512ccfdac2fc8db0f15027 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 9 Feb 2026 20:27:46 +0300 Subject: [PATCH 11/11] Change IDs for volumeControls, volumeSlider, and controlMute when looping (watch.js) --- web/skins/classic/views/js/watch.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/skins/classic/views/js/watch.js b/web/skins/classic/views/js/watch.js index abeb6eadc..d23237e7b 100644 --- a/web/skins/classic/views/js/watch.js +++ b/web/skins/classic/views/js/watch.js @@ -896,6 +896,13 @@ function streamReStart(oldId, newId) { //Change main monitor block monitor_div.innerHTML = currentMonitor.streamHTML; + const volumeControls = document.getElementById('volumeControls'+oldId); + if (volumeControls) volumeControls.id = 'volumeControls'+newId; + const volumeSlider = document.getElementById('volumeSlider'+oldId); + if (volumeSlider) volumeSlider.id = 'volumeSlider'+newId; + const controlMute = document.getElementById('controlMute'+oldId); + if (controlMute) controlMute.id = 'controlMute'+newId; + //Change active element of the navigation menu document.getElementById('nav-item-cycle'+oldId).querySelector('a').classList.remove("active"); document.getElementById('nav-item-cycle'+newId).querySelector('a').classList.add("active");