mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-11-21 02:09:09 -05:00
Major refactoring of UI code, small UI changes. (#48)
* Major refactoring of UI code, small UI changes. * Single file index.js split up into separate modules * Modules for handling UI view components * Modules for handling JSON/Model data * Modules for support tasks * Module to encapsulate Moonfire API * Main application module * index.js simplified to just activating main app * Settings file functionality expanded * UI adds "Time Format" popup to allow changing time representation * CSS changes/additions to streamline looks * Recordings loading indicator only appears after 500ms delay, if at all * Address first set of PR change requests from Scott. * Add copyright headers to all files (except JSON files) * Fix bug with entering time values in range pickers * Fixed an erroneous comment and/or spelling error here and there * Fixed JSDoc comments where [description] was not filled in * Removed a TODO from NVRApplication as it no longer applies * Fixed bug handling "infinite" case of video segment lengths * Fixed bug in "trim" handler and trim execution * Retrofit video continues loading from separate PR Signed-off-by: Dolf Starreveld <dolf@starreveld.com> * Address PR comments Signed-off-by: Dolf Starreveld <dolf@starreveld.com> * Address PR comments Signed-off-by: Dolf Starreveld <dolf@starreveld.com>
This commit is contained in:
committed by
Scott Lamb
parent
caac324bd5
commit
58152e8d94
445
ui-src/index.js
445
ui-src/index.js
@@ -1,418 +1,41 @@
|
||||
// vim: set et sw=2:
|
||||
// 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: style: no globals? string literals? line length? fn comments?
|
||||
// TODO: live updating.
|
||||
|
||||
import './favicon.ico';
|
||||
|
||||
import 'jquery-ui/themes/base/button.css';
|
||||
import 'jquery-ui/themes/base/core.css';
|
||||
import 'jquery-ui/themes/base/datepicker.css';
|
||||
import 'jquery-ui/themes/base/dialog.css';
|
||||
import 'jquery-ui/themes/base/resizable.css';
|
||||
import 'jquery-ui/themes/base/theme.css';
|
||||
import 'jquery-ui/themes/base/tooltip.css';
|
||||
import NVRApplication from './NVRApplication';
|
||||
|
||||
import $ from 'jquery';
|
||||
import 'jquery-ui/ui/widgets/datepicker';
|
||||
import 'jquery-ui/ui/widgets/dialog';
|
||||
import 'jquery-ui/ui/widgets/tooltip';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
const apiUrl = '/api/';
|
||||
|
||||
// IANA timezone name.
|
||||
let zone = null;
|
||||
|
||||
// A dict describing the currently selected range.
|
||||
let selectedRange = {
|
||||
startDateStr: null, // null or YYYY-MM-DD
|
||||
startTimeStr: '', // empty or HH:mm[:ss[:FFFFF]][+-HHmm]
|
||||
startTime90k: null, // null or 90k units since epoch
|
||||
endDateStr: null, // null or YYYY-MM-DD
|
||||
endTimeStr: '', // empty or HH:mm[:ss[:FFFFF]][+-HHmm]
|
||||
endTime90k: null, // null or 90k units since epoch
|
||||
singleDateStr: null, // if startDateStr===endDateStr, that value, otherwise null
|
||||
};
|
||||
|
||||
// Cameras is a dictionary as retrieved from apiUrl + some extra props:
|
||||
// * "enabled" is a boolean indicating if the camera should be displayed and
|
||||
// if it should be used to constrain the datepickers.
|
||||
// * "recordingsUrl" is null or the currently fetched/fetching .../recordings url.
|
||||
// * "recordingsRange" is a null or a dict (in the same format as
|
||||
// selectedRange) describing what is fetching/fetched.
|
||||
// * "recordingsData" is null or the data fetched from "recordingsUrl".
|
||||
// * "recordingsReq" is null or a jQuery ajax object of an active .../recordings
|
||||
// request if currently fetching.
|
||||
let cameras = null;
|
||||
|
||||
function req(url) {
|
||||
return $.ajax(url, {
|
||||
dataType: 'json',
|
||||
headers: {'Accept': 'application/json'},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp using a format string.
|
||||
*
|
||||
* The timestamp to be formatted is expected to be in units of 90,000 to a
|
||||
* second (90k format).
|
||||
*
|
||||
* The format string should comply with what is accepted by moment.format,
|
||||
* with one addition. A format pattern of FFFFF (5 Fs) can be used. This
|
||||
* format pattern will be replaced with the fractional second part of the
|
||||
* timestamp, still in 90k units. Thus if the timestamp was 89900 (which is
|
||||
* almost a full second; 0.99888 seconds decimal), the output would be
|
||||
* 89900, and NOT 0.99888. Only a pattern of five Fs is recognized and it
|
||||
* will produce exactly a five position output! You cannot vary the number
|
||||
* of Fs to produce less.
|
||||
*
|
||||
* The default format string was chosen to produce results identical to
|
||||
* a previous version of this code that was hard-coded to produce that output.
|
||||
*
|
||||
* @param {Number} ts90k Timestamp in 90k units
|
||||
* @param {String} format moment.format plus FFFFF pattern supported
|
||||
* @return {String} Formatted timestamp
|
||||
*/
|
||||
function formatTime(ts90k, format = 'YYYY-MM-DDTHH:mm:ss:FFFFFZ') {
|
||||
const ms = ts90k / 90.0;
|
||||
const fracFmt = 'FFFFF';
|
||||
let fracLoc = format.indexOf(fracFmt);
|
||||
if (fracLoc != -1) {
|
||||
const frac = ts90k % 90000;
|
||||
format = format.substr(0, fracLoc) + String(100000 + frac).substr(1) +
|
||||
format.substr(fracLoc + fracFmt.length);
|
||||
}
|
||||
return moment.tz(ms, zone).format(format);
|
||||
}
|
||||
|
||||
function onSelectVideo(camera, range, recording) {
|
||||
let url = apiUrl + 'cameras/' + camera.uuid + '/view.mp4?s=' + recording.startId;
|
||||
if (recording.endId !== undefined) {
|
||||
url += '-' + recording.endId;
|
||||
}
|
||||
const trim = $("#trim").prop("checked");
|
||||
let rel = '';
|
||||
let startTime90k = recording.startTime90k;
|
||||
if (trim && recording.startTime90k < range.startTime90k) {
|
||||
rel += range.startTime90k - recording.startTime90k;
|
||||
startTime90k = range.startTime90k;
|
||||
}
|
||||
rel += '-';
|
||||
let endTime90k = recording.endTime90k;
|
||||
if (trim && recording.endTime90k > range.endTime90k) {
|
||||
rel += range.endTime90k - recording.startTime90k;
|
||||
endTime90k = range.endTime90k;
|
||||
}
|
||||
if (rel !== '-') {
|
||||
url += '.' + rel;
|
||||
}
|
||||
if ($("#ts").prop("checked")) {
|
||||
url += '&ts=true';
|
||||
}
|
||||
console.log('Displaying video: ', url);
|
||||
let video = $('<video controls preload="auto" autoplay="true"/>');
|
||||
let dialog = $('<div class="playdialog"/>').append(video);
|
||||
$("body").append(dialog);
|
||||
|
||||
// Format start and end times for the dialog title. If they're the same day,
|
||||
// abbreviate the end time.
|
||||
let formattedStart = formatTime(startTime90k);
|
||||
let formattedEnd = formatTime(endTime90k);
|
||||
let timePos = 'YYYY-mm-ddT'.length;
|
||||
if (formattedEnd.startsWith(formattedStart.substr(0, timePos))) {
|
||||
formattedEnd = formattedEnd.substr(timePos);
|
||||
}
|
||||
dialog.dialog({
|
||||
title: camera.shortName + ", " + formattedStart + " to " + formattedEnd,
|
||||
width: recording.videoSampleEntryWidth / 4,
|
||||
close: () => {
|
||||
const videoDOMElement = video[0];
|
||||
videoDOMElement.pause();
|
||||
videoDOMElement.src = ''; // Remove current source to stop loading
|
||||
dialog.remove();
|
||||
},
|
||||
});
|
||||
video.attr("src", url);
|
||||
}
|
||||
|
||||
function formatRecordings(camera) {
|
||||
let tbody = $("#tab-" + camera.uuid);
|
||||
$(".loading", tbody).hide();
|
||||
$(".r", tbody).remove();
|
||||
const frameRateFmt = new Intl.NumberFormat([], {maximumFractionDigits: 0});
|
||||
const sizeFmt = new Intl.NumberFormat([], {maximumFractionDigits: 1});
|
||||
const trim = $("#trim").prop("checked");
|
||||
for (let recording of camera.recordingsData.recordings) {
|
||||
const duration = (recording.endTime90k - recording.startTime90k) / 90000;
|
||||
let row = $('<tr class="r"/>');
|
||||
const startTime90k = trim && recording.startTime90k < camera.recordingsRange.startTime90k
|
||||
? camera.recordingsRange.startTime90k : recording.startTime90k;
|
||||
const endTime90k = trim && recording.endTime90k > camera.recordingsRange.endTime90k
|
||||
? camera.recordingsRange.endTime90k : recording.endTime90k;
|
||||
let formattedStart = formatTime(startTime90k);
|
||||
let formattedEnd = formatTime(endTime90k);
|
||||
const singleDateStr = camera.recordingsRange.singleDateStr;
|
||||
if (singleDateStr !== null && formattedStart.startsWith(singleDateStr)) {
|
||||
formattedStart = formattedStart.substr(11);
|
||||
}
|
||||
if (singleDateStr !== null && formattedEnd.startsWith(singleDateStr)) {
|
||||
formattedEnd = formattedEnd.substr(11);
|
||||
}
|
||||
row.append(
|
||||
$("<td/>").text(formattedStart),
|
||||
$("<td/>").text(formattedEnd),
|
||||
$("<td/>").text(recording.videoSampleEntryWidth + "x" + recording.videoSampleEntryHeight),
|
||||
$("<td/>").text(frameRateFmt.format(recording.videoSamples / duration)),
|
||||
$("<td/>").text(sizeFmt.format(recording.sampleFileBytes / 1048576) + " MB"),
|
||||
$("<td/>").text(sizeFmt.format(recording.sampleFileBytes / duration * .000008) + " Mbps"));
|
||||
row.on("click", function() { onSelectVideo(camera, camera.recordingsRange, recording); });
|
||||
tbody.append(row);
|
||||
}
|
||||
};
|
||||
|
||||
function reselectDateRange(startDateStr, endDateStr) {
|
||||
selectedRange.startDateStr = startDateStr;
|
||||
selectedRange.endDateStr = endDateStr;
|
||||
selectedRange.startTime90k = parseDateTime(startDateStr, selectedRange.startTimeStr, false);
|
||||
selectedRange.endTime90k = parseDateTime(endDateStr, selectedRange.endTimeStr, true);
|
||||
fetch();
|
||||
}
|
||||
|
||||
// Run when selectedRange is populated/changed or when split changes.
|
||||
function fetch() {
|
||||
console.log('Fetching ', formatTime(selectedRange.startTime90k), ' to ',
|
||||
formatTime(selectedRange.endTime90k));
|
||||
let split = $("#split").val();
|
||||
for (let camera of cameras) {
|
||||
let url = apiUrl + 'cameras/' + camera.uuid + '/recordings?startTime90k=' +
|
||||
selectedRange.startTime90k + '&endTime90k=' + selectedRange.endTime90k;
|
||||
if (split !== '') {
|
||||
url += '&split90k=' + split;
|
||||
}
|
||||
if (url === camera.recordingsUrl) {
|
||||
continue; // nothing to do.
|
||||
}
|
||||
if (camera.recordingsReq !== null) {
|
||||
camera.recordingsReq.abort();
|
||||
}
|
||||
let tbody = $("#tab-" + camera.uuid);
|
||||
$(".r", tbody).remove();
|
||||
$(".loading", tbody).show();
|
||||
let r = req(url);
|
||||
camera.recordingsUrl = url;
|
||||
camera.recordingsRange = selectedRange;
|
||||
camera.recordingsReq = r;
|
||||
r.always(function() { camera.recordingsReq = null; });
|
||||
r.then(function(data, status, req) {
|
||||
// Sort recordings in descending order.
|
||||
data.recordings.sort(function(a, b) { return b.startId - a.startId; });
|
||||
camera.recordingsData = data;
|
||||
formatRecordings(camera);
|
||||
}).catch(function(data, status, err) {
|
||||
console.log(url, ' load failed: ', status, ': ', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run initially and when changing camera filter.
|
||||
function setupCalendar() {
|
||||
let merged = {};
|
||||
for (const camera of cameras) {
|
||||
if (!camera.enabled) {
|
||||
continue;
|
||||
}
|
||||
for (const dateStr in camera.days) {
|
||||
merged[dateStr] = true;
|
||||
}
|
||||
}
|
||||
let minDateStr = '9999-99-99';
|
||||
let maxDateStr = '0000-00-00';
|
||||
for (const dateStr in merged) {
|
||||
if (dateStr > maxDateStr) {
|
||||
maxDateStr = dateStr;
|
||||
}
|
||||
if (dateStr < minDateStr) {
|
||||
minDateStr = dateStr;
|
||||
}
|
||||
}
|
||||
let from = $("#start-date");
|
||||
let to = $("#end-date");
|
||||
let beforeShowDay = function(date) {
|
||||
let dateStr = date.toISOString().substr(0, 10);
|
||||
return [dateStr in merged, "", ""];
|
||||
}
|
||||
if ($("#end-date-same").prop("checked")) {
|
||||
from.datepicker("option", {
|
||||
dateFormat: $.datepicker.ISO_8601,
|
||||
minDate: minDateStr,
|
||||
maxDate: maxDateStr,
|
||||
onSelect: function(dateStr, picker) {
|
||||
reselectDateRange(dateStr, dateStr);
|
||||
},
|
||||
beforeShowDay: beforeShowDay,
|
||||
disabled: false,
|
||||
});
|
||||
to.datepicker("destroy");
|
||||
to.datepicker({disabled: true});
|
||||
} else {
|
||||
from.datepicker("option", {
|
||||
dateFormat: $.datepicker.ISO_8601,
|
||||
minDate: minDateStr,
|
||||
onSelect: function(dateStr, picker) {
|
||||
to.datepicker("option", "minDate", from.datepicker("getDate").toISOString().substr(0, 10));
|
||||
reselectDateRange(dateStr, to.datepicker("getDate").toISOString().substr(0, 10));
|
||||
},
|
||||
beforeShowDay: beforeShowDay,
|
||||
disabled: false,
|
||||
});
|
||||
to.datepicker("option", {
|
||||
dateFormat: $.datepicker.ISO_8601,
|
||||
minDate: from.datepicker("getDate"),
|
||||
maxDate: maxDateStr,
|
||||
onSelect: function(dateStr, picker) {
|
||||
from.datepicker("option", "maxDate", to.datepicker("getDate").toISOString().substr(0, 10));
|
||||
reselectDateRange(from.datepicker("getDate").toISOString().substr(0, 10), dateStr);
|
||||
},
|
||||
beforeShowDay: beforeShowDay,
|
||||
disabled: false,
|
||||
});
|
||||
to.datepicker("setDate", from.datepicker("getDate"));
|
||||
from.datepicker("option", {maxDate: to.datepicker("getDate")});
|
||||
}
|
||||
const date = from.datepicker("getDate");
|
||||
if (date !== null) {
|
||||
const dateStr = date.toISOString().substr(0, 10);
|
||||
reselectDateRange(dateStr, dateStr);
|
||||
}
|
||||
};
|
||||
|
||||
function onCameraChange(event, camera) {
|
||||
camera.enabled = event.target.checked;
|
||||
if (camera.enabled) {
|
||||
$("#tab-" + camera.uuid).show();
|
||||
} else {
|
||||
$("#tab-" + camera.uuid).hide();
|
||||
}
|
||||
console.log('Camera ', camera.shortName, camera.enabled ? 'enabled' : 'disabled');
|
||||
setupCalendar();
|
||||
}
|
||||
|
||||
// Parses the given date and time string into a valid time90k or null.
|
||||
function parseDateTime(dateStr, timeStr, isEnd) {
|
||||
// Match HH:mm[:ss[:FFFFF]][+-HH:mm]
|
||||
// Group 1 is the hour and minute (HH:mm).
|
||||
// Group 2 is the seconds (:ss), if any.
|
||||
// Group 3 is the fraction (FFFFF), if any.
|
||||
// Group 4 is the zone (+-HH:mm), if any.
|
||||
const timeRe =
|
||||
/^([0-9]{1,2}:[0-9]{2})(?:(:[0-9]{2})(?::([0-9]{5}))?)?([+-][0-9]{1,2}:?(?:[0-9]{2})?)?$/;
|
||||
|
||||
if (timeStr === '') {
|
||||
const m = moment.tz(dateStr, zone);
|
||||
if (isEnd) {
|
||||
m.add({days: 1});
|
||||
}
|
||||
return m.valueOf() * 90;
|
||||
}
|
||||
|
||||
const match = timeRe.exec(timeStr);
|
||||
if (match === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const orBlank = function(s) { return s === undefined ? "" : s; };
|
||||
const datetimeStr = dateStr + 'T' + match[1] + orBlank(match[2]) + orBlank(match[4]);
|
||||
const m = moment.tz(datetimeStr, zone);
|
||||
if (!m.isValid()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frac = match[3] === undefined ? 0 : parseInt(match[3], 10);
|
||||
return m.valueOf() * 90 + frac;
|
||||
}
|
||||
|
||||
function onTimeChange(e, isEnd) {
|
||||
let parsed = parseDateTime(isEnd ? selectedRange.endDateStr : selectedRange.startDateStr,
|
||||
e.target.value, isEnd);
|
||||
if (parsed == null) {
|
||||
console.log('bad time change');
|
||||
$(e.target).addClass('ui-state-error');
|
||||
return;
|
||||
}
|
||||
$(e.target).removeClass('ui-state-error');
|
||||
console.log(isEnd ? "end" : "start", ' time change to: ', parsed, ' (', formatTime(parsed), ')');
|
||||
if (isEnd) {
|
||||
selectedRange.endTimeStr = e.target.value;
|
||||
selectedRange.endTime90k = parsed;
|
||||
} else {
|
||||
selectedRange.startTimeStr = e.target.value;
|
||||
selectedRange.startTime90k = parsed;
|
||||
}
|
||||
fetch();
|
||||
}
|
||||
|
||||
function onReceivedCameras(data) {
|
||||
let fieldset = $("#cameras");
|
||||
if (data.cameras.length === 0) {
|
||||
return;
|
||||
}
|
||||
var reqs = [];
|
||||
let videos = $("#videos");
|
||||
for (let camera of data.cameras) {
|
||||
const id = "cam-" + camera.uuid;
|
||||
let checkBox = $('<input type="checkbox" checked>').attr("name", id).attr("id", id);
|
||||
checkBox.change(function(event) { onCameraChange(event, camera); });
|
||||
fieldset.append(checkBox,
|
||||
$("<label/>").attr("for", id).text(camera.shortName),
|
||||
$("<br/>"));
|
||||
let tab = $("<tbody>").attr("id", "tab-" + camera.uuid);
|
||||
tab.append(
|
||||
$('<tr class="name">').append($('<th colspan=6/>').text(camera.shortName)),
|
||||
$('<tr class="hdr"><th>start</th><th>end</th><th>resolution</th><th>fps</th><th>size</th><th>bitrate</th></tr>'),
|
||||
$('<tr class="loading"><td colspan=6>loading...</td></tr>'));
|
||||
videos.append(tab);
|
||||
camera.enabled = true;
|
||||
camera.recordingsUrl = null;
|
||||
camera.recordingsRange = null;
|
||||
camera.recordingsData = null;
|
||||
camera.recordingsReq = null;
|
||||
}
|
||||
$("#end-date-same").change(function(e) { setupCalendar(); });
|
||||
$("#end-date-other").change(function(e) { setupCalendar(); });
|
||||
$("#start-date").datepicker({disabled: true});
|
||||
$("#end-date").datepicker({disabled: true});
|
||||
$("#start-time").change(function(e) { onTimeChange(e, false); });
|
||||
$("#end-time").change(function(e) { onTimeChange(e, true); });
|
||||
$("#split").change(function(e) {
|
||||
if (selectedRange.startTime90k !== null) {
|
||||
fetch();
|
||||
}
|
||||
});
|
||||
$("#trim").change(function(e) {
|
||||
// Changing the trim doesn't need to refetch data, but it does need to
|
||||
// reformat the tables.
|
||||
let newTrim = e.target.checked;
|
||||
for (camera of cameras) {
|
||||
if (camera.recordingsData !== null) {
|
||||
formatRecordings(camera);
|
||||
}
|
||||
}
|
||||
});
|
||||
zone = data.timeZoneName;
|
||||
cameras = data.cameras;
|
||||
console.log('Loaded cameras.');
|
||||
setupCalendar();
|
||||
}
|
||||
|
||||
// On document load, start application
|
||||
$(function() {
|
||||
$(document).tooltip();
|
||||
req(apiUrl + '?days=true').then(function(data, status, req) {
|
||||
onReceivedCameras(data);
|
||||
}).catch(function(data, status, err) {
|
||||
console.log('cameras load failed: ', status, err);
|
||||
});
|
||||
(new NVRApplication()).start();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user