mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2024-12-26 07:05:56 -05:00
b2bdccb507
newTimeFormat didn't handle newTimeZone not having been called well. Restore the prior behavior of having called newTimeZone(null), which was apparently good enough.
399 lines
12 KiB
JavaScript
399 lines
12 KiB
JavaScript
// vim: set et sw=2 ts=2:
|
|
//
|
|
// This file is part of Moonfire NVR, a security camera digital video recorder.
|
|
// Copyright (C) 2018 Dolf Starreveld <dolf@starreveld.com>
|
|
//
|
|
// 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 <http://www.gnu.org/licenses/>.
|
|
|
|
// 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 './assets/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}
|
|
*/
|
|
let 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;
|
|
new VideoDialogView()
|
|
.attach($('body'))
|
|
.play(videoTitle, recording.videoSampleEntryWidth / 4, 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 (let streamView of streamViews) {
|
|
let 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);
|
|
let 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.recordings;
|
|
})
|
|
.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) {
|
|
let sessionBar = $('#session');
|
|
sessionBar.empty();
|
|
if (session === null) {
|
|
sessionBar.hide();
|
|
return;
|
|
}
|
|
sessionBar.append($('<span id="session-username" />').text(session.username));
|
|
let logout = $('<a>logout</a>');
|
|
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: [], session: null, 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 = [];
|
|
let streamSelectorCameras = [];
|
|
for (const cameraJson of data.cameras) {
|
|
const camera = new Camera(cameraJson);
|
|
let 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;
|
|
}
|
|
|
|
let username = $('#login-username').val();
|
|
let password = $('#login-password').val();
|
|
let submit = $('#login-submit');
|
|
let 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() {
|
|
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();
|
|
}
|
|
}
|