"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