// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018-2020 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
// TODO: test abort.
// TODO: add error bar on fetch failure.
// TODO: live updating.
import $ from 'jquery';
// tooltip needs:
// css.structure: ../../themes/base/core.css
// css.structure: ../../themes/base/tooltip.css
// css.theme: ../../themes/base/theme.css
import 'jquery-ui/themes/base/core.css';
import 'jquery-ui/themes/base/tooltip.css';
import 'jquery-ui/themes/base/theme.css';
// This causes our custom css to be loaded after the above!
import './index.css';
// Get ui widgets themselves
import 'jquery-ui/ui/widgets/tooltip';
import Camera from './lib/models/Camera';
import CalendarView from './lib/views/CalendarView';
import VideoDialogView from './lib/views/VideoDialogView';
import NVRSettingsView from './lib/views/NVRSettingsView';
import RecordingFormatter from './lib/support/RecordingFormatter';
import StreamSelectorView from './lib/views/StreamSelectorView';
import StreamView from './lib/views/StreamView';
import TimeFormatter from './lib/support/TimeFormatter';
import TimeStamp90kFormatter from './lib/support/TimeStamp90kFormatter';
import MoonfireAPI from './lib/MoonfireAPI';
const api = new MoonfireAPI();
let streamViews = null; // StreamView objects
let calendarView = null; // CalendarView object
let loginDialog = null;
/**
* Currently selected time format specification.
*
* @type {String}
*/
const timeFmt = 'YYYY-MM-DD HH:mm:ss';
/**
* Currently active time formatter.
* This is lazy initialized at the point we receive the timezone information
* and never changes afterwards, except possibly for changing the timezone.
*
* @type {[type]}
*/
let timeFormatter = null;
/**
* Currently active time formatter for internal time format.
* This is lazy initialized at the point we receive the timezone information
* and never changes afterwards, except possibly for changing the timezone.
*
* @type {[type]}
*/
let timeFormatter90k = null;
/**
* Globally set a new timezone for the app.
*
* @param {String} timeZone Timezone name
*/
function newTimeZone(timeZone) {
timeFormatter = new TimeFormatter(timeFmt, timeZone);
timeFormatter90k = new TimeStamp90kFormatter(timeZone);
}
/**
* Globally set a new time format for the app.
*
* @param {String} format Time format specification
*/
function newTimeFormat(format) {
timeFormatter = new TimeFormatter(format, timeFormatter.tz);
}
/**
* Event handler for clicking on a video.
*
* A 'dialog' object is attached to the body of the dom and it
* properly initialized with the corrcet src url.
*
* @param {NVRSettings} nvrSettingsView NVRSettingsView in effect
* @param {object} camera Object for the camera
* @param {String} streamType "main" or "sub"
* @param {object} range Range Object
* @param {object} recording Recording object
* @return {void}
*/
function onSelectVideo(nvrSettingsView, camera, streamType, range, recording) {
console.log('Recording clicked: ', recording);
const trimmedRange = recording.range90k(nvrSettingsView.trim ? range : null);
const url = api.videoPlayUrl(
camera.uuid,
streamType,
recording,
trimmedRange,
nvrSettingsView.timeStampTrack
);
const [
formattedStart,
formattedEnd,
] = timeFormatter90k.formatSameDayShortened(
trimmedRange.startTime90k,
trimmedRange.endTime90k
);
const videoTitle =
camera.shortName + ', ' + formattedStart + ' to ' + formattedEnd;
let width = recording.videoSampleEntryWidth *
recording.videoSampleEntryPaspHSpacing /
recording.videoSampleEntryPaspVSpacing;
const maxWidth = window.innerWidth * 3 / 4;
while (width > maxWidth) {
width /= 2;
}
new VideoDialogView()
.attach($('body'))
.play(videoTitle, width, url);
}
/**
* Fetch stream view data for a given date/time range.
*
* @param {Range90k} selectedRange Desired time range
* @param {Number} videoLength Desired length of video segments, or Infinity
*/
function fetch(selectedRange, videoLength) {
if (selectedRange.startTime90k === null) {
return;
}
console.log(
'Fetching> ' +
selectedRange.formatTimeStamp90k(selectedRange.startTime90k) +
' to ' +
selectedRange.formatTimeStamp90k(selectedRange.endTime90k)
);
for (const streamView of streamViews) {
const url = api.recordingsUrl(
streamView.camera.uuid,
streamView.streamType,
selectedRange.startTime90k,
selectedRange.endTime90k,
videoLength
);
if (streamView.recordingsReq !== null) {
/*
* If there is another request, it would be because settings changed
* and so an abort is to make room for this new request, now necessary
* for the changed situation.
*/
streamView.recordingsReq.abort();
}
streamView.delayedShowLoading(500);
const r = api.request(url);
streamView.recordingsUrl = url;
streamView.recordingsReq = r;
streamView.recordingsRange = selectedRange.range90k();
r.always(function() {
streamView.recordingsReq = null;
});
r
.then(function(data /* , status, req */) {
// Sort recordings in descending order.
data.recordings.sort(function(a, b) {
return b.startId - a.startId;
});
console.log(
'Fetched results for "%s-%s" > updating recordings',
streamView.camera.shortName, streamView.streamType
);
streamView.recordingsJSON = data;
})
.catch(function(data, status, err) {
console.error(url, ' load failed: ', status, ': ', err);
});
}
}
/**
* Updates the session bar at the top of the page.
*
* @param {Object} session the "session" key of the main API request's JSON,
* or null.
*/
function updateSession(session) {
const sessionBar = $('#session');
sessionBar.empty();
if (session === null || session === undefined) {
sessionBar.hide();
return;
}
sessionBar.append($('').text(session.username));
const logout = $('logout');
logout.click(() => {
api
.logout(session.csrf)
.done(() => {
onReceivedTopLevel(null);
loginDialog.dialog('open');
});
});
sessionBar.append(' | ', logout);
sessionBar.show();
}
/**
* Initialize the page after receiving top-level data.
*
* Sets the following globals:
* zone - timezone from data received
* streamViews - array of views, one per stream
*
* Builds the dom for the left side controllers
*
* @param {Object} data JSON resulting from the main API request /api/?days=
* or null if the request failed.
*/
function onReceivedTopLevel(data) {
if (data === null) {
data = {cameras: [], timeZoneName: null};
}
newTimeZone(data.timeZoneName);
updateSession(data.session);
// Set up controls and values
const nvrSettingsView = new NVRSettingsView();
nvrSettingsView.onVideoLengthChange = (vl) =>
fetch(calendarView.selectedRange, vl);
nvrSettingsView.onTimeFormatChange = (format) =>
streamViews.forEach((view) => (view.timeFormat = format));
nvrSettingsView.onTrimChange = (t) =>
streamViews.forEach((view) => (view.trimmed = t));
newTimeFormat(nvrSettingsView.timeFormatString);
calendarView = new CalendarView({timeZone: timeFormatter.tz});
calendarView.onRangeChange = (selectedRange) =>
fetch(selectedRange, nvrSettingsView.videoLength);
const streamsParent = $('#streams');
const videos = $('#videos');
streamsParent.empty();
videos.empty();
streamViews = [];
const streamSelectorCameras = [];
for (const cameraJson of data.cameras) {
const camera = new Camera(cameraJson);
const cameraStreams = {};
Object.keys(camera.streams).forEach((streamType) => {
const sv = new StreamView(
camera,
streamType,
new RecordingFormatter(timeFormatter.formatStr, timeFormatter.tz),
nvrSettingsView.trim,
videos);
sv.onRecordingClicked = (recordingModel) => {
console.log('Recording clicked', recordingModel);
onSelectVideo(
nvrSettingsView,
camera,
streamType,
calendarView.selectedRange,
recordingModel
);
};
streamViews.push(sv);
cameraStreams[streamType] = sv;
});
streamSelectorCameras.push({
camera: camera,
streamViews: cameraStreams,
});
};
// Create stream enable checkboxes
const streamSelector =
new StreamSelectorView(streamSelectorCameras, streamsParent);
streamSelector.onChange = () => calendarView.initializeWith(streamViews);
calendarView.initializeWith(streamViews);
console.log('Loaded: ' + streamViews.length + ' stream views');
}
/**
* Handles the submit action on the login form.
*/
function sendLoginRequest() {
if (loginDialog.pending) {
return;
}
const username = $('#login-username').val();
const password = $('#login-password').val();
const submit = $('#login-submit');
const error = $('#login-error');
error.empty();
error.removeClass('ui-state-highlight');
submit.button('option', 'disabled', true);
loginDialog.pending = true;
console.info('logging in as', username);
api
.login(username, password)
.done(() => {
console.info('login successful');
loginDialog.dialog('close');
sendTopLevelRequest();
})
.catch((e) => {
console.info('login failed:', e);
error.show();
error.addClass('ui-state-highlight');
error.text(e.responseText);
})
.always(() => {
submit.button('option', 'disabled', false);
loginDialog.pending = false;
});
}
/** Sends the top-level api request. */
function sendTopLevelRequest() {
api
.request(api.nvrUrl(true))
.done((data) => onReceivedTopLevel(data))
.catch((e) => {
console.error('NVR load exception: ', e);
onReceivedTopLevel(null);
if (e.status == 401) {
loginDialog.dialog('open');
}
});
}
/**
* Class representing the entire application.
*/
export default class NVRApplication {
/**
* Construct the application object.
*/
constructor() {}
/**
* Start the application.
*/
start() {
const nav = $('#nav');
$('#toggle-nav').click(() => {
nav.toggle('slide');
});
loginDialog = $('#login').dialog({
autoOpen: false,
modal: true,
buttons: [
{
id: 'login-submit',
text: 'Login',
click: sendLoginRequest,
},
],
});
loginDialog.pending = false;
loginDialog.find('form').on('submit', function(event) {
event.preventDefault();
sendLoginRequest();
});
sendTopLevelRequest();
}
}