"use strict"; var janus = null; const streaming = []; function MonitorStream(monitorData) { this.id = monitorData.id; this.name = monitorData.name; this.started = false; this.zmsState = null; this.muted = (currentView == 'watch') ? (getCookie('zmWatchMuted') !== 'false') : true; this.connKey = monitorData.connKey; this.genConnKey = function() { return (Math.floor((Math.random() * 999999) + 1)).toLocaleString('en-US', {minimumIntegerDigits: 6, useGrouping: false}); }; this.url = monitorData.url; this.url_to_zms = monitorData.url_to_zms; this.width = monitorData.width; this.height = monitorData.height; this.RTSP2WebEnabled = monitorData.RTSP2WebEnabled; this.RTSP2WebType = null; this.StreamChannel = monitorData.StreamChannel; this.RTSPServer = monitorData.RTSPServer; this.Go2RTCEnabled = monitorData.Go2RTCEnabled; this.Go2RTCMSEBufferCleared = true; this.currentChannelStream = null; this.MSEBufferCleared = true; this.webrtc = null; this.hls = null; this.mse = null; this.wsMSE = null; this.streamStartTime = 0; // Initial point of flow start time. Used for flow lag time analysis. this.waitingStart; this.handlerEventListener = {}; this.mseListenerSourceopenBind = null; this.streamListenerBind = null; this.mseSourceBufferListenerUpdateendBind = null; this.mseStreamingStarted = false; this.mseQueue = []; 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 this.whatDisplay = monitorData.whatDisplay; this.lastAlarmState = STATE_IDLE; this.statusCmdTimer = null; // timer for requests using ajax to get monitor status this.statusCmdParms = { view: 'request', request: 'status', connkey: this.connKey }; this.streamCmdTimer = null; // timer for requests to zms for status this.streamCmdParms = { view: 'request', request: 'stream', connkey: this.connKey }; this.playerPriority = { 1: { // This setting should always be priority #1. name: 'default', countErrors: 0, durationErrors: 0 }, 2: { name: 'go2rtc_webrtc', countErrors: 0, durationErrors: 0 }, 3: { name: 'go2rtc_mse', countErrors: 0, durationErrors: 0 }, //4: { // name: 'go2rtc_hls', // Doesn't work for live viewing. // countErrors: 0, // durationErrors: 0 //}, 5: { name: 'rtsp2web_webrtc', countErrors: 0, durationErrors: 0 }, 6: { name: 'rtsp2web_mse', countErrors: 0, durationErrors: 0 }, 7: { name: 'rtsp2web_hls', countErrors: 0, durationErrors: 0 }, 8: { name: 'janus', countErrors: 0, durationErrors: 0 }, 9: { name: 'zms', countErrors: 0, durationErrors: 0 }, }; this.ajaxQueue = null; this.type = monitorData.type; this.capturing = monitorData.capturing; this.refresh = monitorData.refresh; this.buttons = {}; // index by name this.setButton = function(name, element) { this.buttons[name] = element; }; this.gridstack = null; this.setGridStack = function(gs) { this.gridstack = gs; }; this.bottomElement = null; this.setBottomElement = function(e) { if (!e) { console.error("Empty bottomElement"); } this.bottomElement = e; }; this.MAX_AUTH_REFRESH_ATTEMPTS = 3; this.authRefreshAttempts = 0; this.authRefreshTimer = null; this.img_onerror = function() { console.log('Image stream has been stopped! stopping streamCmd'); this.streamCmdTimer = clearInterval(this.streamCmdTimer); this.writeTextInfoBlock("Error", {showImg: false}); // zms returns 403 on a stale auth hash (default TTL 2h). For a live multipart // (mode=jpeg) the browser reconnects on its own using the same baked-in // src (same connkey, same expired hash), outside JS control. Once the hash // expires every native reconnect 403s, storming zms with failures (typically // after the capture daemon drops the stream). Capture the broken URL, then // blank src *synchronously* so the browser's native retry loop stops dead; // nothing reconnects until we have fetched a fresh hash below. const stream = this.getElement(); const brokenSrc = (stream && stream.src) ? stream.src : this.url_to_zms; if (stream) stream.src = ''; if (this.authRefreshAttempts >= this.MAX_AUTH_REFRESH_ATTEMPTS) { this.writeTextInfoBlock("Error", {showImg: false}); return; } this.authRefreshAttempts++; const backoffMs = 2000 * Math.pow(2, this.authRefreshAttempts - 1); // 2s, 4s, 8s console.log("Stream error; refreshing auth and reconnecting in "+backoffMs+ "ms (attempt "+this.authRefreshAttempts+"/"+this.MAX_AUTH_REFRESH_ATTEMPTS+")"); this.writeTextInfoBlock("Reconnecting..."); const self = this; if (this.authRefreshTimer) clearTimeout(this.authRefreshTimer); this.authRefreshTimer = setTimeout(function() { $j.getJSON(thisUrl + '?view=request&request=status&entity=navBar' + (auth_relay ? '&' + auth_relay : '')) .done(function(data) { if (data && data.auth) { auth_hash = data.auth; } const el = self.getElement(); if (!el) return; // Use a fresh connkey: the zms process tied to the old connkey has // exited, so reusing it would race a dead socket. self.connKey = self.streamCmdParms.connkey = self.statusCmdParms.connkey = self.genConnKey(); el.src = rebuildStreamSrc(brokenSrc, auth_hash, self.connKey); }) .fail(function(jqxhr) { // A dead session returns 401; redirect to login instead of retrying // a stream that can never authenticate. if (typeof authFailureAction === 'function' && authFailureAction(jqxhr.status) == 'login' && typeof goToLogin === 'function') { goToLogin(); return; } self.writeTextInfoBlock("Error", {showImg: false}); }); }, backoffMs); }; this.img_onload = function() { this.authRefreshAttempts = 0; if (this.authRefreshTimer) { clearTimeout(this.authRefreshTimer); this.authRefreshTimer = null; } if (!this.streamCmdTimer) { console.log('Image stream has loaded! starting streamCmd for monitor ID='+this.id+' connKey='+this.connKey+' in '+statusRefreshTimeout + 'ms'); this.streamCmdQuery(); // This is to get an instant status update this.streamCmdTimer = setInterval(this.streamCmdQuery.bind(this), statusRefreshTimeout); this.writeTextInfoBlock(""); } }; this.player = monitorData.DefaultPlayer; this.defaultPlayer = (this.player) ? this.player : this.playerPriority[1]['name']; this.activePlayer = ''; // Variants: go2rtc, janus, rtsp2web_hls, rtsp2web_mse, rtsp2web_webrtc, zms. Relevant for this.player = ''/Auto this.selectedPlayer = ''; // Selected player in the browser this.setPlayer = function(p) { if (-1 != p.indexOf('go2rtc')) { } else if (-1 != p.indexOf('rtsp2web')) { if (-1 != p.indexOf('_hls')) { this.RTSP2WebType = 'HLS'; } else if (-1 != p.indexOf('_mse')) { this.RTSP2WebType = 'MSE'; } else if (-1 != p.indexOf('_webrtc')) { this.RTSP2WebType = 'WebRTC'; } } else if (-1 != p.indexOf('janus')) { } this.selectedPlayer = p; // Let's clear out the errors for (const key in this.playerPriority) { this.playerPriority[key]['countErrors'] = 0; } return this.player = p; }; this.manageAvailablePlayersOptions = function(action, opt) { if (action == 'disable') { opt.setAttribute('disabled', ''); opt.setAttribute('title', playerDisabledInMonitorSettings); } else if (action == 'enable') { opt.removeAttribute('disabled'); opt.removeAttribute('title'); } }; this.manageAvailablePlayers = function() { const selectPlayers = document.querySelector('[id="player"][name="codec"]'); const opts = selectPlayers.options; for (var opt, j = 0; opt = opts[j]; j++) { if (-1 !== opt.value.indexOf('go2rtc')) { if (this.Go2RTCEnabled) { this.manageAvailablePlayersOptions('enable', opt); } else { this.manageAvailablePlayersOptions('disable', opt); } } else if (-1 !== opt.value.indexOf('rtsp2web')) { if (this.RTSP2WebEnabled) { this.manageAvailablePlayersOptions('enable', opt); } else { this.manageAvailablePlayersOptions('disable', opt); } } else if (-1 !== opt.value.indexOf('janus')) { if (this.janusEnabled) { this.manageAvailablePlayersOptions('enable', opt); } else { this.manageAvailablePlayersOptions('disable', opt); } } } let selectedPlayerOption = selectPlayers.options[selectPlayers.selectedIndex]; if (selectedPlayerOption) { if (selectedPlayerOption.value == '') { // Perhaps "Auto" is left from the previous monitor, we will change it according to the cookies. const zmWatchPlayer = getCookie('zmWatchPlayer'); if (zmWatchPlayer) { selectPlayers.value = zmWatchPlayer; selectedPlayerOption = selectPlayers.options[selectPlayers.selectedIndex]; } } if (selectedPlayerOption && selectedPlayerOption.disabled) { // Selected player is not available for the current monitor selectPlayers.value = ''; // Auto } } this.player = selectPlayers.value; }; this.element = null; this.getElement = function() { if (this.element) return this.element; this.element = document.getElementById('liveStream'+this.id); if (!this.element) { console.error("No element for #liveStream"+this.id); } return this.element; }; this.getFrame = function() { if (this.frame) return this.frame; this.frame = document.getElementById('imageFeed'+this.id); if (!this.frame) { console.error("No frame div for #imageFeed"+this.id); } return this.frame; }; /* if the img element didn't have a src, this would fill it in, causing it to show. */ this.show = function() { const stream = this.getElement(); if (!stream.src) { stream.src = this.url_to_zms+"&mode=single&scale="+this.scale+"&connkey="+this.connKey+(auth_relay?'&'+auth_relay:''); } }; /* scale should be '0' for auto, or an integer value * width should be auto, 100%, integer +px * height should be auto, 100%, integer +px * param.resizeImg be boolean (added only for using GridStack & PanZoom on Montage page) * param.scaleImg scaling 1=100% (added only for using PanZoom on Montage & Watch page) * param.streamQuality in %, numeric value from -50 to +50) * */ this.setScale = function(newscale, width, height, param = {}) { const newscaleSelect = newscale; const stream = this.getElement(); if (!stream) { console.log('No stream in setScale'); return; } //console.trace("setScale", stream, newscale, width, height, param); if (height == '0px') { console.log("Don't want to set 0px height. Reverting to auto"); height = 'auto'; } // Scale the frame const monitor_frame = $j('#monitor'+this.id); if (!monitor_frame) { console.log('Error finding frame'); return; } if (((newscale == '0') || (newscale == 0) || (newscale=='auto')) && (width=='auto' || !width)) { if (!this.bottomElement) { if (param.scaleImg) { newscale = Math.floor(100*monitor_frame.width() / this.width * param.scaleImg); } else { newscale = Math.floor(100*monitor_frame.width() / this.width); } // We don't want to change the existing css, cuz it might be 59% or 123px or auto; width = monitor_frame.css('width'); height = Math.round(parseInt(this.height) * newscale / 100)+'px'; } else { const newSize = scaleToFit(this.width, this.height, $j(stream), $j(this.bottomElement), $j('#wrapperMonitor')); width = newSize.width+'px'; height = newSize.height+'px'; if (param.scaleImg) { newscale = parseInt(newSize.autoScale * param.scaleImg); } else { newscale = parseInt(newSize.autoScale); } if (newscale < 25) newscale = 25; // Arbitrary. 4k shown on 1080p screen looks terrible } } else if (parseInt(width) || parseInt(height)) { if (width) { if (width.search('px') != -1) { newscale = parseInt(100*parseInt(width)/this.width); } else { // % // Set it, then get the calculated width if (param.resizeImg) { monitor_frame.css('width', width); } newscale = parseInt(100*parseInt(monitor_frame.width())/this.width); } } else if (height) { newscale = parseInt(100*parseInt(height)/this.height); width = parseInt(this.width * newscale / 100)+'px'; } } else { // a numeric scale, must take actual monitor dimensions and calculate width = Math.round(parseInt(this.width) * newscale / 100)+'px'; height = Math.round(parseInt(this.height) * newscale / 100)+'px'; } if (width && (width != '0px') && (stream.style.width.search('%') == -1)) { if (param.resizeImg) { monitor_frame.css('width', parseInt(width)); } } if (param.resizeImg) { if (stream.style.width) stream.style.width = '100%'; if (height && (height != '0px')) stream.style.height = height; } else { //This code will not be needed when using GridStack & PanZoom on Montage page. Only required when trying to use "scaleControl" if (newscaleSelect != 0) { stream.style.width = 'auto'; $j(stream).closest('.monitorStream')[0].style.overflow = 'auto'; } else { //const monitor_stream = $j(stream).closest('.monitorStream'); //const realWidth = monitor_stream.attr('data-width'); //const realHeight = monitor_stream.attr('data-height'); //const ratio = realWidth / realHeight; //const imgWidth = $j(stream)[0].offsetWidth + 4; // including border stream.style.width = '100%'; const monitorStream = $j(stream).closest('.monitorStream'); if (monitorStream.length) { monitorStream[0].style.overflow = 'hidden'; } else { console.log('monitorstream not found. Should not happen.'); } } } let streamQuality = 0; if (param.streamQuality) { streamQuality = param.streamQuality; newscale += parseInt(newscale/100*streamQuality); } this.scale = newscale; this.setStreamScale(newscale, streamQuality); }; // setScale this.setStreamScale = function(newscale, streamQuality=0) { const stream = this.getElement(); if (!stream) { console.log("No stream in setStreamScale"); return; } const stream_frame = $j('#monitor'+this.id); if (!newscale) { newscale = parseInt(100*parseInt(stream_frame.width())/this.width); } if (newscale > 100) newscale = 100; // we never request a larger image, as it just wastes bandwidth if (newscale < 25 && streamQuality > -1) newscale = 25; // Arbitrary, lower values look bad if (newscale <= 0) newscale = 100; this.scale = newscale; if (stream.nodeName == 'IMG' && this.started) { if (this.connKey) { /* Can just tell it to scale, in fact will happen automatically on next query */ } else { const oldSrc = stream.src; if (!oldSrc) { console.log('No src on img?!', stream); return; } let newSrc = oldSrc.replace(/scale=\d+/i, 'scale='+newscale); newSrc = newSrc.replace(/auth=\w+/i, 'auth='+auth_hash); if (newSrc != oldSrc) { this.streamCmdTimer = clearTimeout(this.streamCmdTimer); // We know that only the first zms will get the command because the // second can't open the commandQueue until the first exits // This is necessary because safari will never close the first image if (-1 != stream.src.search('connkey') && -1 != stream.src.search('mode=single')) { this.streamCommand(CMD_QUIT); } console.log("Changing src from " + stream.src + " to " + newSrc + 'refresh timeout:' + statusRefreshTimeout); stream.src = ''; stream.src = newSrc; this.streamCmdTimer = setInterval(this.streamCmdQuery.bind(this), statusRefreshTimeout); } } } }; // setStreamScale /* * 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 && info) modeEl.innerText = info; if (statusEl) statusEl.innerText = status; }; /* * streamChannel options: * 'default' or 'Primary' - Main stream (uses monitor ID, which is ZM restream if RTSPServer enabled) * 'Secondary' or 'CameraDirectSecondary' - Secondary camera stream * 'Restream' or 'ZoneMinderPrimary' - ZoneMinder RTSP restream * 'CameraDirectPrimary' - Direct camera primary stream * Legacy numeric values (0, 1, 2) are mapped to new names for backward compatibility */ this.getStreamSuffix = function(channel) { // Map legacy numeric values and string names to go2rtc stream suffixes const channelMap = { 'default': '', // Just monitor ID 'Primary': '', // Just monitor ID (primary based on RTSPServer setting) 'Secondary': '_CameraDirectSecondary', 'CameraDirectSecondary': '_CameraDirectSecondary', 'Restream': '_ZoneMinderPrimary', 'ZoneMinderPrimary': '_ZoneMinderPrimary', 'CameraDirectPrimary': '_CameraDirectPrimary', '0': '', // Legacy: Primary '1': '_CameraDirectSecondary', // Legacy: Secondary '2': '_ZoneMinderPrimary' // Legacy: Restream }; const suffix = channelMap[channel] !== undefined ? channelMap[channel] : ''; // _ZoneMinderPrimary stream is only registered when RTSPServer is enabled. // Fall back to _CameraDirectPrimary if RTSPServer is not enabled. if (suffix === '_ZoneMinderPrimary' && !this.RTSPServer) { console.log('RTSPServer not enabled, falling back from _ZoneMinderPrimary to _CameraDirectPrimary'); return '_CameraDirectPrimary'; } return suffix; }; // For RTSP2Web which still uses numeric channel IDs this.getNumericChannel = function(channel) { const channelMap = { 'default': 0, 'Primary': 0, 'Secondary': 1, 'CameraDirectSecondary': 1, 'Restream': 2, 'ZoneMinderPrimary': 2, 'CameraDirectPrimary': 0, '0': 0, '1': 1, '2': 2 }; return channelMap[channel] !== undefined ? channelMap[channel] : 0; }; this.handlerEventListenerStream = function(stream = null) { if (!stream) stream = this.getAVStream(); if (!stream) { console.debug(`Stream for monitor ID=${this.id} not found. Assigning listeners is not possible.`); return; } this.handlerEventListener['playStream'] = manageEventListener.addEventListener(stream, 'play', (e) => { this.writeTextInfoBlock(""); this.createVolumeSlider(); getTracksFromStream(this); } ); this.handlerEventListener['pauseStream'] = manageEventListener.addEventListener(stream, 'pause', (e) => { this.writeTextInfoBlock("Paused", {showImg: false}); manageEventListener.removeEventListener(this.handlerEventListener['volumechange']); if (typeof pauseAudioMotion === 'function') { pauseAudioMotion(this.id); } } ); this.handlerEventListener['errorStream'] = manageEventListener.addEventListener(stream, 'error', (e) => { this.writeTextInfoBlock("Error"); this.streamErrorRegistration(); this.restart(this.currentChannelStream); } ); }; // When a go2rtc player connects but the source video codec cannot be decoded by // this browser (e.g. an H.265 camera viewed in Chrome, which supports HEVC over // neither WebRTC nor MSE), go2rtc negotiates the video track as inactive and sends // only audio. The