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:
Dolf Starreveld
2018-03-20 07:03:12 -07:00
committed by Scott Lamb
parent caac324bd5
commit 58152e8d94
35 changed files with 4089 additions and 518 deletions

306
ui-src/NVRApplication.js Normal file
View File

@@ -0,0 +1,306 @@
// 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 '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';
// This causes our custom css to be loaded after the above!
require('./assets/index.css');
import $ from 'jquery';
import 'jquery-ui/ui/widgets/datepicker';
import 'jquery-ui/ui/widgets/dialog';
import 'jquery-ui/ui/widgets/tooltip';
import Camera from './lib/models/Camera';
import CameraView from './lib/views/CameraView';
import CalendarView from './lib/views/CalendarView';
import NVRSettingsView from './lib/views/NVRSettingsView';
import CheckboxGroupView from './lib/views/CheckboxGroupView';
import RecordingFormatter from './lib/support/RecordingFormatter';
import TimeFormatter, {
TimeStamp90kFormatter,
} from './lib/support/TimeFormatter';
import MoonfireAPI from './lib/MoonfireAPI';
const api = new MoonfireAPI();
let cameraViews = null; // CameraView objects
let calendarView = null; // CalendarView object
/**
* 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 {object} range Range Object
* @param {object} recording Recording object
* @return {void}
*/
function onSelectVideo(nvrSettingsView, camera, range, recording) {
console.log('Recording clicked: ', recording);
const trimmedRange = recording.range90k(nvrSettingsView.trim ? range : null);
const url = api.videoPlayUrl(
camera.uuid,
recording,
trimmedRange,
nvrSettingsView.timeStampTrack
);
const video = $('<video controls preload="auto" autoplay="true" />');
const dialog = $('<div class="playdialog" />').append(video);
$('body').append(dialog);
let [formattedStart, formattedEnd] = timeFormatter90k.formatSameDayShortened(
trimmedRange.startTime90k,
trimmedRange.endTime90k
);
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();
},
});
// Now that dialog is up, set the src so video starts
console.log('Video url: ' + url);
video.attr('src', url);
}
/**
* Fetch camera 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 cameraView of cameraViews) {
let url = api.recordingsUrl(
cameraView.camera.uuid,
selectedRange.startTime90k,
selectedRange.endTime90k,
videoLength
);
if (cameraView.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.
*/
cameraView.recordingsReq.abort();
}
cameraView.delayedShowLoading(500);
let r = api.request(url);
cameraView.recordingsUrl = url;
cameraView.recordingsReq = r;
cameraView.recordingsRange = selectedRange.range90k();
r.always(function() {
cameraView.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 > updating recordings');
cameraView.recordingsJSON = data.recordings;
})
.catch(function(data, status, err) {
console.log(url, ' load failed: ', status, ': ', err);
});
}
}
/**
* Initialize the page after receiving camera data.
*
* Sets the following globals:
* zone - timezone from data received
* cameraViews - array of views, one per camera
*
* Builds the dom for the left side controllers
*
* @param {Object} data JSON resulting from the main API request /api/?days=
*/
function onReceivedCameras(data) {
newTimeZone(data.timeZoneName);
// Set up controls and values
const nvrSettingsView = new NVRSettingsView();
nvrSettingsView.onVideoLengthChange = (vl) =>
fetch(calendarView.selectedRange, vl);
nvrSettingsView.onTimeFormatChange = (format) =>
cameraViews.forEach((view) => (view.timeFormat = format));
nvrSettingsView.onTrimChange = (t) =>
cameraViews.forEach((view) => (view.trimmed = t));
newTimeFormat(nvrSettingsView.timeFormatString);
calendarView = new CalendarView({timeZone: timeFormatter.tz});
calendarView.onRangeChange = (selectedRange) =>
fetch(selectedRange, nvrSettingsView.videoLength);
const camerasParent = $('#cameras');
const videos = $('#videos');
cameraViews = data.cameras.map((cameraJson) => {
const camera = new Camera(cameraJson);
const cv = new CameraView(
camera,
new RecordingFormatter(timeFormatter.formatStr, timeFormatter.tz),
nvrSettingsView.trim,
videos
);
cv.onRecordingClicked = (recordingModel) => {
console.log('Recording clicked', recordingModel);
onSelectVideo(
nvrSettingsView,
camera,
calendarView.selectedRange,
recordingModel
);
};
return cv;
});
// Create camera enable checkboxes
const cameraCheckBoxes = new CheckboxGroupView(
cameraViews.map((cv) => ({
id: cv.camera.uuid,
checked: true,
text: cv.camera.shortName,
camView: cv,
})),
camerasParent
);
cameraCheckBoxes.onCheckChange = (groupEl) => {
groupEl.camView.enabled = groupEl.checked;
calendarView.initializeWith(cameraViews);
};
calendarView.initializeWith(cameraViews);
console.log('Loaded: ' + cameraViews.length + ' camera views');
}
/**
* Class representing the entire application.
*/
export default class NVRApplication {
/**
* Construct the application object.
*/
constructor() {}
/**
* Start the application.
*/
start() {
api
.request(api.nvrUrl(true))
.done((data) => onReceivedCameras(data))
.fail((req, status, err) => {
console.log('NVR load error: ', status, err);
onReceivedCameras({cameras: []});
})
.catch((e) => {
console.log('NVR load exception: ', e);
onReceivedCameras({cameras: []});
});
}
}

76
ui-src/assets/index.css Normal file
View File

@@ -0,0 +1,76 @@
body {
font-family: Arial, Helvetica, sans-serif;
}
#nav {
float: left;
}
.ui-datepicker {
width: 100%;
}
#videos {
display: inline-block;
padding-top: 0.5em;
padding-left: 1em;
padding-right: 1em;
}
#videos tbody:after {
content: "";
display: block;
height: 3ex;
}
table.videos {
border-collapse: collapse;
}
tbody tr.name {
font-size: 110%;
background-color: #eee;
}
tbody tr.name th {
border-bottom: 1px solid #666;
}
tbody tr.hdr {
color: #555;
font-size: 90%;
}
tr.r:hover {
background-color: #ddd;
}
tr.r td {
font-size: 80%;
cursor: pointer;
}
tr.r th,
tr.r td {
border: 0;
padding: 0.5ex 1.5em;
text-align: right;
}
fieldset {
font-size: 80%;
}
fieldset legend {
font-size: 120%;
font-weight: bold;
}
#from, #to {
padding-right: 0.75em;
}
#st {
width: 100%;
}
#range {
padding: 0.5em 0;
}
.ui-dialog .ui-dialog-content {
overflow: visible; /* remove stupid scroll bars when resizing. */
padding: 0;
}
video {
width: 100%;
height: 100%;
}

76
ui-src/assets/index.html Normal file
View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<!-- vim: set et: -->
<html lang="en">
<head>
<title>Moonfire NVR</title>
</head>
<body>
<div id="nav">
<form action="#">
<fieldset id="cameras">
<legend>Cameras</legend>
</fieldset>
<fieldset id="datetime">
<legend>Date &amp; Time Range</legend>
<div id="from">
<div id="start-date"></div>
<div id="st">
<label for="start-time">Time:</label>
<input id="start-time" name="start-time" type="text" title="Starting
time within the day. Blank for the beginning of the day. Otherwise
HH:mm[:ss[:FFFFF]][+-HH:mm], where F is 90,000ths of a second.
Timezone is normally left out; it's useful once a year during the
ambiguous times of the &quot;fall back&quot; hour."></div>
</div>
<div id="range">Range:
<input type="radio" name="end-date-type" id="end-date-same" checked>
<label for="end-date-same">Single Day</label>
<input type="radio" name="end-date-type" id="end-date-other">
<label for="end-date-other">Multi Day</label><br/>
</div>
<div id="to">
<div id="end-date"></div>
<label for="start-time">Time:</label>
<input id="end-time" name="end-time" type="text" title="Ending
time within the day. Blank for the end of the day. Otherwise
HH:mm[:ss[:FFFFF]][+-HH:mm], where F is 90,000ths of a second.
Timezone is normally left out; it's useful once a year during the
ambiguous times of the &quot;fall back&quot; hour.">
</div>
</fieldset>
<fieldset>
<legend>Recordings Display</legend>
<label for="split">Max Video Duration:</label>
<select name="split" id="split">
<option value="324000000">1 hour</option>
<option value="1296000000">4 hours</option>
<option value="7776000000">24 hours</option>
<option value="infinite">infinite</option>
</select><br>
<input type="checkbox" checked id="trim" name="trim">
<label for="trim">Trim Segment Start &amp; End</label><br>
<input type="checkbox" checked id="ts" name="ts">
<label for="ts">Timestamp Track</label><br>
<label for="timefmt">Time Format:</label>
<select name="timefmt" id="timefmt">
<option value="MM/DD/YY hh:mm A">US-short</option>
<option value="MM/DD/YYYY hh:mm:ss A">US</option>
<option value="MM/DD/YY HH:mm" selected>Military-short</option>
<option value="MM/DD/YYYY HH:mm:ss">Military</option>
<option value="DD.MM.YY HH:mm">EU-short</option>
<option value="DD-MM-YYYY HH:mm:ss">EU</option>
<option value="YY-MM-DD hh:mm A">ISO-short (12h)</option>
<option value="YY-MM-DD HH:mm">ISO-short (24h)</option>
<option value="YYYY-MM-DD hh:mm:ss A">ISO (12h)</option>
<option value="YYYY-MM-DD HH:mm:ss">ISO (24h)</option>
<option value="YYYY-MM-DD HH:mm:ss">ISO 8601-like (No TZ)</option>
<option value="YYYY-MM-DDTHH:mm:ss">ISO 8601 (No TZ)</option>
<option value="YYYY-MM-DDTHH:mm:ssZ">ISO 8601</option>
<option value="YYYY-MM-DDTHH:mm:ss:FFFFFZ">Internal</option>
</select>
</fieldset>
</form>
</div>
<table id="videos"></table>
</body>
</html>

View File

@@ -1,79 +0,0 @@
<!DOCTYPE html>
<!-- vim: set et: -->
<html lang="en">
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
<title><%= htmlWebpackPlugin.options.title %></title>
<style type="text/css">
#nav {
float: left;
width: 17em;
}
#nav .ui-datepicker { width: 100%; }
#videos {
margin-left: 18em;
}
#videos tbody:after {
content: '';
display: block;
height: 3ex;
}
tbody .name {
background-color: #eee;
}
tr.r:hover { background-color: #ddd; }
tr.r th, tr.r td { padding: 0.5ex 1.5em; text-align: right; }
.ui-dialog .ui-dialog-content {
overflow: visible; /* remove stupid scroll bars when resizing. */
padding: 0;
}
video {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="nav">
<form action="#">
<fieldset id="cameras">
<legend>Cameras</legend>
</fieldset>
<fieldset id="datetime">
<legend>Datetime range</legend>
<div id="start-date"></div>
<input id="start-time" name="start-time" type="text" title="Starting
time within the day. Blank for the beginning of the day. Otherwise
HH:mm[:ss[:FFFFF]][+-HH:mm], where F is 90,000ths of a second.
Timezone is normally left out; it's useful once a year during the
ambiguous times of the &quot;fall back&quot; hour."><br>
<input type="radio" name="end-date-type" id="end-date-same" checked>
<label for="end-date-same">to same day</label><br>
<input type="radio" name="end-date-type" id="end-date-other">
<label for="end-date-other">to other day</label>
<div id="end-date"></div>
<input id="end-time" name="end-time" type="text" title="Ending
time within the day. Blank for the end of the day. Otherwise
HH:mm[:ss[:FFFFF]][+-HH:mm], where F is 90,000ths of a second.
Timezone is normally left out; it's useful once a year during the
ambiguous times of the &quot;fall back&quot; hour."><br>
</fieldset>
<label for="split">Max video length</label>
<select name="split" id="split">
<option value="324000000">1 hour</option>
<option value="1296000000">4 hours</option>
<option value="7776000000">24 hours</option>
<option value="">infinite</option>
</select><br>
<input type="checkbox" checked id="trim" name="trim">
<label for="trim">Trim segment start/end</label><br>
<input type="checkbox" checked id="ts" name="ts">
<label for="ts">Timestamp track</label>
</form>
</div>
<table id="videos"></table>
</body>
</html>

View File

@@ -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();
});

151
ui-src/lib/MoonfireAPI.js Normal file
View File

@@ -0,0 +1,151 @@
// 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/>.
import $ from 'jquery';
import URLBuilder from './support/URLBuilder';
/**
* Class to insulate rest of app from details of Moonfire API.
*
* Can produce URLs for specifc operations, or a request that has been
* started and can have handlers attached.
*/
export default class MoonfireAPI {
/**
* Construct.
*
* The defaults correspond to a standard Moonfire installation on the
* same host that this code runs on.
*
* Requesting relative URLs effectively disregards the host and port options.
*
* @param {String} options.host Host where the API resides
* @param {Number} options.port Port on which the API resides
* @param {[type]} options.relativeUrls True if we should produce relative
* urls
*/
constructor({host = 'localhost', port = 8080, relativeUrls = true} = {}) {
const url = new URL('/api/', `http://${host}`);
url.protocol = 'http:';
url.hostname = host;
url.port = port;
console.log('API: ' + url.origin + url.pathname);
this._builder = new URLBuilder(url.origin + url.pathname);
}
/**
* URL that will cause the state of the NVR to be returned.
*
* @param {Boolean} days True if a return of days with available recordings
* is desired.
* @return {String} Constructed url
*/
nvrUrl(days = true) {
return this._builder.makeUrl('', {days: days});
}
/**
* URL that will cause the state of a specific recording to be returned.
*
* @param {String} cameraUUID UUID for the camera
* @param {String} start90k Timestamp for beginning of range of interest
* @param {String} end90k Timestamp for end of range of interest
* @param {String} split90k Desired maximum size of segments returned, or
* Infinity for infinite range
* @return {String} Constructed url
*/
recordingsUrl(cameraUUID, start90k, end90k, split90k = Infinity) {
const query = {
startTime90k: start90k,
endTime90k: end90k,
};
if (split90k != Infinity) {
query.split90k = split90k;
}
return this._builder.makeUrl(
'cameras/' + cameraUUID + '/recordings',
query
);
}
/**
* URL that will playback a video segment.
*
* @param {String} cameraUUID UUID for the camera from whence comes the video
* @param {Recording} recording Recording model object
* @param {Range90k} trimmedRange Range restricting segments
* @param {Boolean} timestampTrack True if track should be timestamped
* @return {String} Constructed url
*/
videoPlayUrl(cameraUUID, recording, trimmedRange, timestampTrack = true) {
let sParam = recording.startId;
if (recording.endId !== undefined) {
sParam += '-' + recording.endId;
}
let rel = '';
if (recording.startTime90k < trimmedRange.startTime90k) {
rel += trimmedRange.startTime90k - recording.startTime90k;
}
rel += '-';
if (recording.endTime90k > trimmedRange.endTime90k) {
rel += trimmedRange.endTime90k - recording.startTime90k;
}
if (rel !== '-') {
sParam += '.' + rel;
}
console.log('Video query:', {
s: sParam,
ts: timestampTrack,
});
return this._builder.makeUrl('cameras/' + cameraUUID + '/view.mp4', {
s: sParam,
ts: timestampTrack,
});
}
/**
* Start a new AJAX request with the specified URL.
*
* @param {String} url URL to use
* @param {String} cacheOk True if cached results are OK
* @return {Request} jQuery request type
*/
request(url, cacheOk = false) {
return $.ajax(url, {
dataType: 'json',
headers: {
Accept: 'application/json',
},
cache: cacheOk,
});
}
}

View File

@@ -0,0 +1,245 @@
// 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/>.
import Time90kParser from '../support/Time90kParser';
import {TimeStamp90kFormatter} from '../support/TimeFormatter';
import Range90k from './Range90k';
/**
* Class representing a calendar timestamp range based on 90k units.
*
* A calendar timestamp differs from a Range90k in that a date string
* is involved on each end as well.
*
* The range has a start and end property (via getters) and each has three
* contained properties:
* - dateStr: string for date in ISO8601 format
* - timeStr: string for time in ISO8601 format
* - ts90k: Number for the timestamp in 90k units
*/
export default class CalendarTSRange {
/**
* Construct a range with a given timezone for display purposes.
*
* @param {String} timeZone Desired timezone, e.g. 'America/Los_Angeles'
*/
constructor(timeZone) {
this._start = {dateStr: null, timeStr: '', ts90k: null};
this._end = {dateStr: null, timeStr: '', ts90k: null};
// Don't need to keep timezone, but need parser and formatter
this._timeFormatter = new TimeStamp90kFormatter(timeZone);
this._timeParser = new Time90kParser(timeZone);
}
/**
* Determine if a valid start date string is present.
*
* @return {Boolean}
*/
hasStart() {
return this.start.dateStr !== null;
}
/**
* Determine if a valid end date string is present.
*
* @return {Boolean}
*/
hasEnd() {
return this.end.dateStr !== null;
}
/**
* Determine if a valid start and end date string is present.
*
* @return {Boolean}
*/
hasRange() {
return this.hasStart() && this.hasEnd();
}
/**
* Return the range's start component.
*
* @return {object} Object containing dateStr, timeStr, and ts90k components
*/
get start() {
return this._start;
}
/**
* Return the range's end component.
*
* @return {object} Object containing dateStr, timeStr, and ts90k components
*/
get end() {
return this._end;
}
/**
* Return the range's start component's ts90k property
*
* @return {object} timestamp in 90k units
*/
get startTime90k() {
return this.start.ts90k;
}
/**
* Return the range's end component's ts90k property
*
* @return {object} timestamp in 90k units
*/
get endTime90k() {
return this.end.ts90k;
}
/**
* Determine if the range has a defined start timestamp in 90k units.
*
* @return {Boolean}
*/
get hasStartTime() {
return this.startTime90k !== null;
}
/**
* Return the calendar range in terms of a range over 90k timestamps.
*
* @return {Range90k} Range object or null if don't have start and end
*/
range90k() {
return this.hasRange()
? new Range90k(this.startTime90k, this.endTime90k)
: null;
}
/**
* Internal function to update either start or end type range component.
*
* Strings are parsed to check if they are valid. Update only takes place
* if they are. Parsing is in accordance with the installed Time90kParser
* which means:
* - HH:MM:ss:FFFFFZ format, where each componet may be empty to indicate 0
* - YYYY-MM-DD format for the date
*
* NOTE: This function potentially modifies the content of the range
* argument. This is on purpose and should reflect the new range values
* upon succesful parsing!
*
* @param {object} range A range component
* @param {String} dateStr Date string, if null range's value is re-used
* @param {String} timeStr Time string, if null range's value is re-used
* @param {Boolean} dateOnlyThenEndOfDay True if one should be added to date
* which is only meaningful if there
* is no time specified here, and also
* not present in the range.
* @return {Number} New timestamp if succesfully parsed, null otherwise
*/
_setRangeTime(range, dateStr, timeStr, dateOnlyThenEndOfDay) {
dateStr = dateStr || range.dateStr;
timeStr = timeStr || range.timeStr;
const newTs90k = this._timeParser.parseDateTime90k(
dateStr,
timeStr,
dateOnlyThenEndOfDay
);
if (newTs90k !== null) {
range.dateStr = dateStr;
range.timeStr = timeStr;
range.ts90k = newTs90k;
return newTs90k;
}
return null;
}
/**
* Set start component of range from date and time strings.
*
* Uses _setRangeTime with appropriate dateOnlyThenEndOfDay value.
*
* @param {String} dateStr Date string
* @param {String} timeStr Time string
* @return {Number} New timestamp if succesfully parsed, null otherwise
*/
setStartDate(dateStr, timeStr = null) {
return this._setRangeTime(this._start, dateStr, timeStr, false);
}
/**
* Set time of start component of range time string.
*
* Uses _setRangeTime with appropriate dateOnlyThenEndOfDay value.
*
* @param {String} timeStr Time string
* @return {Number} New timestamp if succesfully parsed, null otherwise
*/
setStartTime(timeStr) {
return this._setRangeTime(this._start, null, timeStr, false);
}
/**
* Set end component of range from date and time strings.
*
* Uses _setRangeTime with appropriate addOne value.
*
* @param {String} dateStr Date string
* @param {String} timeStr Time string
* @return {Number} New timestamp if succesfully parsed, null otherwise
*/
setEndDate(dateStr, timeStr = null) {
return this._setRangeTime(this._end, dateStr, timeStr, true);
}
/**
* Set time of end component of range time string.
*
* Uses _setRangeTime with appropriate addOne value.
*
* @param {String} timeStr Time string
* @return {Number} New timestamp if succesfully parsed, null otherwise
*/
setEndTime(timeStr) {
return this._setRangeTime(this._end, null, timeStr, true);
}
/**
* Format a timestamp in 90k units in the manner consistent with
* what the parser of this module expects.
*
* @param {Number} ts90k Timestamp in 90k units
* @return {String} Formatted string
*/
formatTimeStamp90k(ts90k) {
return this._timeFormatter.formatTimeStamp90k(ts90k);
}
}

132
ui-src/lib/models/Camera.js Normal file
View File

@@ -0,0 +1,132 @@
// 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/>.
import JsonWrapper from './JsonWrapper';
import Range90k from './Range90k';
/**
* Camera JSON wrapper.
*/
export default class Camera extends JsonWrapper {
/**
* Construct from JSON.
*
* @param {JSON} cameraJson JSON for single camera.
*/
constructor(cameraJson) {
super(cameraJson);
}
/**
* Get camera uuid.
*
* @return {String} Camera's uuid
*/
get uuid() {
return this.json.uuid;
}
/**
* Get camera's short name.
*
* @return {String} Name of the camera
*/
get shortName() {
return this.json.shortName;
}
/**
* Get camera's description.
*
* @return {String} Camera's description
*/
get description() {
return this.json.description;
}
/**
* Get maximimum amount of storage allowed to be used for camera's video
* samples.
*
* @return {Number} Amount in bytes
*/
get retainBytes() {
return this.json.retainBytes;
}
/**
* Get a Range90K object representing the range encompassing all available
* video samples for the camera.
*
* This range does not mean every second of the range has video!
*
* @return {Range90k} The camera's available recordings range
*/
get range90k() {
return new Range90k(
this.json.minStartTime90k,
this.json.maxEndTime90k,
this.json.totalDuration90k
);
}
/**
* Get the total amount of storage currently taken up by the camera's video
* samples.
*
* @return {Number} Amount in bytes
*/
get totalSampleFileBytes() {
return this.json.totalSampleFileBytes;
}
/**
* Get the list of the camera's days for which there are video samples.
*
* The result is a Map with dates as keys (in YYYY-MM-DD format) and each
* value is a Range90k object for that day. Here too, the range does not
* mean every second in the range has video, but presence of an entry for
* a day does mean there is at least one (however short) video segment
* available.
*
* @return {Map} Dates are keys, values are Range90K objects.
*/
get days() {
return new Map(
Object.entries(this.json.days).map(function(t) {
let [k, v] = t;
v = new Range90k(v.startTime90k, v.endTime90k, v.totalDuration90k);
return [k, v];
})
);
}
}

View File

@@ -0,0 +1,80 @@
// 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/>.
/**
* WeakMap that keeps our private data.
*
* @type {WeakMap}
*/
let _json = new WeakMap();
/**
* Class to encapsulate recording JSON data.
* *
* The JSON is kept internally, but in a manner that does not allow direct
* access. If access is needed, use the "json()" method. Sub-classes for
* specific models shoudl provide the necessary getters instead.
*/
export default class JsonWrapper {
/**
* Accept JSON data to be encapsulated
*
* @param {object} jsonData JSON data
*/
constructor(jsonData) {
_json.set(this, jsonData);
}
/**
* Get associated JSON object.
*
* Use of this should be avoided. Use functions to access the
* data instead.
*
* @return {object} The JSON object.
*/
get json() {
return _json.get(this);
}
/**
* @override
* @return {String} String version
*/
toString() {
if (process.env.NODE_ENV === 'development') {
return this.json.toString();
} else {
return super.toString();
}
}
}

View File

@@ -0,0 +1,73 @@
// 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/>.
/**
* Class to represent ranges of values.
*
* The range has a "low", and "high" value property and is inclusive.
* The "size" property returns the difference between high and low.
*/
export default class Range {
/**
* Create a range.
*
* @param {Number} low Low value (inclusive) in range.
* @param {Number} high High value (inclusive) in range.
*/
constructor(low, high) {
if (high < low) {
console.log('Warning range swap: ' + low + ' - ' + high);
[low, high] = [high, low];
}
this.low = low;
this.high = high;
}
/**
* Size of the range.
*
* @return {Number} high - low
*/
get size() {
return this.high - this.low;
}
/**
* Determine if value is inside the range.
*
* @param {Number} value Value to test
* @return {Boolean}
*/
isInRange(value) {
return value >= this.low && value <= this.high;
}
}

View File

@@ -0,0 +1,109 @@
// 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/>.
import Range from './Range';
/**
* WeakMap that keeps our private data.
*
* @type {WeakMap}
*/
let _range = new WeakMap();
/**
* Subclass of Range to represent ranges over timestamps in 90k format.
*
* This mostly means added some getters with names that make more sense.
*/
export default class Range90k {
/**
* Create a range.
*
* @param {Number} low Low value (inclusive) in range.
* @param {Number} high High value (inclusive) in range.
*/
constructor(low, high) {
_range.set(this, new Range(low, high));
}
/**
* Return the range's start time.
*
* @return {Number} Number in 90k units
*/
get startTime90k() {
return _range.get(this).low;
}
/**
* Return the range's end time.
*
* @return {Number} Number in 90k units
*/
get endTime90k() {
return _range.get(this).high;
}
/**
* Return the range's duration.
*
* @return {Number} Number in 90k units
*/
get duration90k() {
return _range.get(this).size;
}
/**
* Create a new range by trimming the current range against
* another.
*
* The returned range will lie completely within the provided range.
*
* @param {Range90k} against Range the be used for limits
* @return {Range90k} The trimmed range (always a new object)
*/
trimmed(against) {
return new Range90k(
Math.max(this.startTime90k, against.startTime90k),
Math.min(this.endTime90k, against.endTime90k)
);
}
/**
* Return a copy of this range.
*
* @return {Range90k} A copy of this range object.
*/
clone() {
return new Range90k(this.startTime90k, this.endTime90k);
}
}

View File

@@ -0,0 +1,155 @@
// 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/>.
import JsonWrapper from './JsonWrapper';
import Range90k from '../models/Range90k';
/**
* Class to encapsulate recording JSON data.
*/
export default class Recording extends JsonWrapper {
/**
* Accept JSON data to be encapsulated
*
* @param {object} recordingJson JSON for a recording
*/
constructor(recordingJson) {
super(recordingJson);
}
/**
* Get recording's startId.
*
* @return {String} startId for recording
*/
get startId() {
return this.json.startId;
}
/**
* Get recording's endId.
*
* @return {String} endId for recording
*/
get endId() {
return this.json.endId;
}
/**
* Return start time of recording in 90k units.
* @return {Number} Time in units of 90k parts of a second
*/
get startTime90k() {
return this.json.startTime90k;
}
/**
* Return end time of recording in 90k units.
* @return {Number} Time in units of 90k parts of a second
*/
get endTime90k() {
return this.json.endTime90k;
}
/**
* Return duration of recording in 90k units.
* @return {Number} Time in units of 90k parts of a second
*/
get duration90k() {
const data = this.json;
return data.endTime90k - data.startTime90k;
}
/**
* Compute the range of the recording in 90k timestamp units,
* optionally trimmed by another range.
*
* @param {Range90k} trimmedAgainst Optional range to trim against
* @return {Range90k} Resulting range
*/
range90k(trimmedAgainst = null) {
let result = new Range90k(this.startTime90k, this.endTime90k);
return trimmedAgainst ? result.trimmed(trimmedAgainst) : result;
}
/**
* Return duration of recording in seconds.
* @return {Number} Time in units of seconds.
*/
get duration() {
return this.duration90k / 90000;
}
/**
* Get the number of bytes used by sample storage.
*
* @return {Number} Total bytes used
*/
get sampleFileBytes() {
return this.json.sampleFileBytes;
}
/**
* Get the number of video samples (frames) for the recording.
*
* @return {Number} Total bytes used
*/
get frameCount() {
return this.json.videoSamples;
}
/**
* Get the has for the video samples.
*
* @return {String} Hash
*/
get videoSampleEntryHash() {
return this.json.videoSampleEntrySha1;
}
/**
* Get the width of the frame(s) of the video samples.
*
* @return {Number} Width in pixels
*/
get videoSampleEntryWidth() {
return this.json.videoSampleEntryWidth;
}
/**
* Get the height of the frame(s) of the video samples.
*
* @return {Number} Height in pixels
*/
get videoSampleEntryHeight() {
return this.json.videoSampleEntryHeight;
}
}

View File

@@ -0,0 +1,101 @@
// 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/>.
import TimeFormatter from './TimeFormatter';
/**
* Formatter for framerates
* @type {Intl} Formatter
*/
const frameRateFmt = new Intl.NumberFormat([], {
maximumFractionDigits: 0,
});
/**
* Formatter for sizes
* @type {Intl} Formatter
*/
const sizeFmt = new Intl.NumberFormat([], {
maximumFractionDigits: 1,
});
/**
* Class encapsulating formatting of recording time ranges.
*/
export default class RecordingFormatter {
/**
* Construct with desired time format and timezone.
*
* @param {String} formatStr Time format string
* @param {String} tz Timezone
*/
constructor(formatStr, tz) {
this._timeFormatter = new TimeFormatter(formatStr, tz);
this._singleDateStr = null;
}
/**
* Change time format string, preserving timezone.
*
* @param {String} formatStr Time format string
*/
set timeFormat(formatStr) {
this._timeFormatter = new TimeFormatter(formatStr, this._timeFormatter.tz);
}
/**
* Produce an object whose properties are individual pieces of a recording's
* data, formatted for display purposes.
*
* @param {Recording} recording Recording to be formatted
* @param {Range90k} trimRange Optional time range for trimming the
* recording's interval
* @return {Object} Map, keyed by _columnOrder element
*/
format(recording, trimRange = null) {
const duration = recording.duration;
const trimmedRange = recording.range90k(trimRange);
return {
start: this._timeFormatter.formatTimeStamp90k(trimmedRange.startTime90k),
end: this._timeFormatter.formatTimeStamp90k(trimmedRange.endTime90k),
resolution:
recording.videoSampleEntryWidth +
'x' +
recording.videoSampleEntryHeight,
frameRate: frameRateFmt.format(recording.frameCount / duration),
size: sizeFmt.format(recording.sampleFileBytes / 1048576) + ' MB',
rate:
sizeFmt.format(recording.sampleFileBytes / duration * 0.000008) +
' Mbps',
};
}
}

View File

@@ -0,0 +1,134 @@
// 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/>.
import moment from 'moment-timezone';
/**
* Regular expression for parsing time format from timestamps.
*
* These regex captures groups:
* 0: whole match or null if none
* 1: HH:MM portion, or undefined
* 2: :ss portion, or undefined
* 3: FFFFF portion, or undefined
* 4: [+-]hh[:mm] portion, or undefined
*
* @type {RegExp}
*/
const timeRe = new RegExp(
[
'^', // Start
'([0-9]{1,2}:[0-9]{2})', // Capture HH:MM
'(?:(:[0-9]{2})(?::([0-9]{5}))?)?', // Capture [:ss][:FFFFF]
'([+-][0-9]{1,2}:?(?:[0-9]{2})?)?', // Capture [+-][zone]
'$', // End
].join('')
);
/**
* Class to parse time strings that possibly contain fractional
* seconds in 90k units into a Number representation.
*
* The general format:
* Expected timestamps are in this format:
* HH:MM[:ss][:FFFFF][[+-]hh[:mm]]
* where
* HH = hours in one or two digits
* MM = minutes in one or two digits
* ss = seconds in one or two digits
* FFFFF = fractional seconds in 90k units in exactly 5 digits
* hh = hours of timezone offset in one or two digits
* mm = minutes of timezone offset in one or two digits
*
*/
export default class Time90kParser {
/**
* Construct with specific timezone.
*
* @param {String} tz Timezone
*/
constructor(tz) {
self._tz = tz;
}
/**
* Set (another) timezone.
*
* @param {String} tz Timezone
*/
set tz(tz) {
self._tz = tz;
}
/**
* Parses the given date and time string into a valid time90k or null.
*
* The date and time strings must be compatible with the partial ISO-8601
* formats for each, or acceptable to the standard Date object.
*
* If only a date is specified and dateOnlyThenEndOfDay is false, the 00:00
* timestamp for that day is returned. If dateOnlyThenEndOfDay is true, the
* 00:00 of the very next day is returned.
*
* @param {String} dateStr String representing date
* @param {String} timeStr String representing time
* @param {Boolean} dateOnlyThenEndOfDay If only a date was specified and
* this is true, then return time
* for the end of day
* @return {Number} Timestamp in 90k units, or null if parsing failed
*/
parseDateTime90k(dateStr, timeStr, dateOnlyThenEndOfDay) {
// If just date, no special handling needed
if (!timeStr) {
const m = moment.tz(dateStr, self._tz);
if (dateOnlyThenEndOfDay) {
m.add({days: 1});
}
return m.valueOf() * 90;
}
const [match, hhmm, ss, fffff, tz] = timeRe.exec(timeStr) || [];
if (!match) {
return null;
}
const orBlank = (s) => s || '';
const datetimeStr = dateStr + 'T' + hhmm + orBlank(ss) + orBlank(tz);
const m = moment.tz(datetimeStr, self._tz);
if (!m.isValid()) {
return null;
}
const frac = fffff === undefined ? 0 : parseInt(fffff, 10);
return m.valueOf() * 90 + frac;
}
}

View File

@@ -0,0 +1,166 @@
// 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/>.
import moment from 'moment-timezone';
export const internalTimeFormat = 'YYYY-MM-DDTHH:mm:ss:FFFFFZ';
export const defaultTimeFormat = 'YYYY-MM-DD HH:mm:ss';
/**
* Class for formatting timestamps.
*
* There are methods for formatting timestamp in three different unit systems:
* - 90k: The units are multiples of 1/90,000th of a second
* - Sec: The units are multiples of seconds
* - Ms: The units are multiples of milliseconds
*
* The object is initialized with a format string and a timezone. The timezone
* is necessary to format times in that timezone.
*
* The format string is based on those accepted by moment.js with one addition
* detailed in formatTimeStamp90k.
*/
export default class TimeFormatter {
/**
* Construct with specific format string and timezone.
*
* @param {String} formatStr Format specification string
* @param {String} tz Timezone, e.g. "America/Los_Angeles"
*/
constructor(formatStr, tz) {
this._formatStr = formatStr || defaultTimeFormat;
this._tz = tz;
}
/**
* Get current format string
*
* @return {String} Format specification string
*/
get formatStr() {
return this._formatStr;
}
/**
* Get current timezone
*
* @return {String} Timezone
*/
get tz() {
return this._tz;
}
/**
* Produces a human-readable timestamp in 90k units.
*
* The format is anything understood by moment's format function,
* with the addition of one special format indicator consisting of
* five successive Fs. If this pattern is used more than once,
* only the first one will be handled. Subsequent ones will become
* literal strings with five Fs.
*
* Using normal format codes, precision of up the three S (SSS) is
* supported by moment to display decimal seconds. "moment" truncates
* the value passed in to its constructor, effectively truncating
* any fractional values in the timestamp. This function rounds
* to compensate for that, except in the case of the FFFFF pattern,
* where rounding is left out for historical reasons.
*
* FFFFF produces a string indicating how many 90k units are present
* in the sub-second portion of the timestamp. Therefore this is *not*
* a decimal fraction!
*
* @param {Number} ts90k timestamp in 90,000ths of a second resolution
* @return {String} Formatted timestamp
*/
formatTimeStamp90k(ts90k) {
let format = this._formatStr;
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, this._tz).format(format);
}
}
/**
* Specialized class similar to TimeFormatter but forcing a specific time format
* for internal usage purposes.
*/
export class TimeStamp90kFormatter {
/**
* Construct from just a timezone specification.
*
* @param {String} tz Timezone
*/
constructor(tz) {
this._formatter = new TimeFormatter(internalTimeFormat, tz);
}
/**
* Format a timestamp in 90k units using internal format.
*
* @param {Number} ts90k timestamp in 90,000ths of a second resolution
* @return {String} Formatted timestamp
*/
formatTimeStamp90k(ts90k) {
return this._formatter.formatTimeStamp90k(ts90k);
}
/**
* Given two timestamp return formatted versions of both, where the second
* one may have been shortened if it falls on the same date as the first one.
*
* @param {Number} ts1 First timestamp in 90k units
* @param {Number} ts2 Secodn timestamp in 90k units
* @return {Array} Array with two elements: [ ts1Formatted, ts2Formatted ]
*/
formatSameDayShortened(ts1, ts2) {
let ts1Formatted = this.formatTimeStamp90k(ts1);
let ts2Formatted = this.formatTimeStamp90k(ts2);
let timePos = this._formatter.formatStr.indexOf('T');
if (timePos != -1) {
const datePortion = ts1Formatted.substr(0, timePos);
ts1Formatted = datePortion + ' ' + ts1Formatted.substr(timePos + 1);
if (ts2Formatted.startsWith(datePortion)) {
ts2Formatted = ts2Formatted.substr(timePos + 1);
}
}
return [ts1Formatted, ts2Formatted];
}
}

View File

@@ -0,0 +1,81 @@
// 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/>.
/**
* Class to help with URL construction.
*/
export default class URLBuilder {
/**
* Construct builder with a base url.
*
* It is possible to indicate the we only want to extract relative
* urls. In that case, pass a dummy scheme and host.
*
* @param {String} base Base url, including scheme and host
* @param {Boolean} relative True if relative urls desired
*/
constructor(base, relative = true) {
this._baseUrl = base;
this._relative = relative;
}
/**
* Append query parameters from a map to a URL.
*
* This is cumulative, so if you call this multiple times on the same URL
* the resulting URL will have the combined query parameters and values.
*
* @param {URL} url URL to add query parameters to
* @param {Object} query Object with parameter name/value pairs
* @return {URL} URL where query params have been added
*/
_addQuery(url, query = {}) {
Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v));
return url;
}
/**
* Construct a String url based on an initial path and an optional set
* of query parameters.
*
* The url will be constructed based on the base url, with path appended.
*
* @param {String} path Path to be added to base url
* @param {Object} query Object with query parameters
* @return {String} Formatted url, relative if so configured
*/
makeUrl(path, query = {}) {
const url = new URL(path || '', this._baseUrl);
this._addQuery(url, query);
return this._relative ? url.pathname + url.search : url.href;
}
}

View File

@@ -0,0 +1,418 @@
// 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/>.
import $ from 'jquery';
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 'jquery-ui/ui/widgets/datepicker';
import 'jquery-ui/ui/widgets/dialog';
import 'jquery-ui/ui/widgets/tooltip';
import DatePickerView from './DatePickerView';
import CalendarTSRange from '../models/CalendarTSRange';
import {TimeStamp90kFormatter} from '../support/TimeFormatter';
import Time90kParser from '../support/Time90kParser';
/**
* Find the earliest and latest dates from an array of CameraView
* objects.
*
* Each camera view has a "days" property, whose keys identify days with
* recordings. All such dates are collected and then scanned to find earliest
* and latest dates.
*
* "days" for camera views that are not enabled are ignored.
*
* @param {[Iterable]} cameraViews Camera views to look into
* @return {[Set, String, String]} Array with set of all dates, and
* earliest and latest dates
*/
function minMaxDates(cameraViews) {
/*
* Produce a set with all dates, across all enabled cameras, that
* have at least one recording available (allDates).
*/
const allDates = new Set(
[].concat(
...cameraViews
.filter((v) => v.enabled)
.map((v) => Array.from(v.camera.days.keys()))
)
);
return [
allDates,
...Array.from(allDates.values()).reduce((acc, dateStr) => {
acc[0] = !acc[0] || dateStr < acc[0] ? dateStr : acc[0];
acc[1] = !acc[1] || dateStr > acc[1] ? dateStr : acc[1];
return acc;
}, []),
];
}
/**
* Class to represent a calendar view.
*
* The view consists of:
* - Two date pickers (from and to)
* - A time input box with each date picker (from time, to time)
* - A set of radio buttons to select between same day or not
*
*/
export default class CalendarView {
/**
* Construct the view with UI elements IDs specified.
*
* @param {String} options.fromPickerId Id for from datepicker
* @param {String} options.toPickerId Id for to datepicker
* @param {String} options.isSameDayId Id for same day radio button
* @param {String} options.isOtherDayId Id for other day radio button
* @param {String} options.fromPickerTimeId Id for from time field
* @param {String} options.toPickerTimeId Id for to time field
* @param {[type]} options.timeZone Timezone
*/
constructor({
fromPickerId = 'start-date',
toPickerId = 'end-date',
isSameDayId = 'end-date-same',
isOtherDayId = 'end-date-other',
fromPickerTimeId = 'start-time',
toPickerTimeId = 'end-time',
timeZone = null,
} = {}) {
// Lookup all by id, check and remember
[
this._fromPickerView,
this._toPickerView,
this._sameDayElement,
this._otherDayElement,
this._startTimeElement,
this._endTimeElement,
] = [
fromPickerId,
toPickerId,
isSameDayId,
isOtherDayId,
fromPickerTimeId,
toPickerTimeId,
].map((id) => {
const el = $(`#${id}`);
if (el.length == 0) {
console.log('Warning: Calendar element with id "' + id + '" not found');
}
return el;
});
this._fromPickerView = new DatePickerView(this._fromPickerView);
this._toPickerView = new DatePickerView(this._toPickerView);
this._timeFormatter = new TimeStamp90kFormatter(timeZone);
this._timeParser = new Time90kParser(timeZone);
this._selectedRange = new CalendarTSRange(timeZone);
this._sameDay = true; // Start in single day view
this._sameDayElement.prop('checked', this._sameDay);
this._otherDayElement.prop('checked', !this._sameDay);
this._availableDates = null;
this._minDateStr = null;
this._maxDateStr = null;
this._singleDateStr = null;
this._cameraViews = null;
this._rangeChangedHandler = null;
}
/**
* Change timezone.
*
* @param {String} tz New timezone
*/
set tz(tz) {
this._timeParser.tz = tz;
}
/**
* (Re)configure the datepickers and other calendar range inputs to reflect
* available dates.
*/
_configureDatePickers() {
const dateSet = this._availableDates;
const minDateStr = this._minDateStr;
const maxDateStr = this._maxDateStr;
const fromPickerView = this._fromPickerView;
const toPickerView = this._toPickerView;
const beforeShowDay = function(date) {
let dateStr = date.toISOString().substr(0, 10);
return [dateSet.has(dateStr), '', ''];
};
if (this._sameDay) {
fromPickerView.option({
dateFormat: $.datepicker.ISO_8601,
minDate: minDateStr,
maxDate: maxDateStr,
onSelect: (dateStr, picker) => this._updateRangeDates(dateStr, dateStr),
beforeShowDay: beforeShowDay,
disabled: false,
});
toPickerView.destroy();
toPickerView.activate(); // Default state, but alive
} else {
fromPickerView.option({
dateFormat: $.datepicker.ISO_8601,
minDate: minDateStr,
onSelect: (dateStr, picker) => {
toPickerView.option('minDate', this.fromDateISO);
this._updateRangeDates(dateStr, this.toDateISO);
},
beforeShowDay: beforeShowDay,
disabled: false,
});
toPickerView.option({
dateFormat: $.datepicker.ISO_8601,
minDate: fromPickerView.dateISO,
maxDate: maxDateStr,
onSelect: (dateStr, picker) => {
fromPickerView.option('maxDate', this.toDateISO);
this._updateRangeDates(this.fromDateISO, dateStr);
},
beforeShowDay: beforeShowDay,
disabled: false,
});
toPickerView.date = fromPickerView.date;
fromPickerView.maxDate = toPickerView.date;
}
}
/**
* Call an installed handler (if any) to inform that range has changed.
*/
_informRangeChange() {
if (this._rangeChangedHandler !== null) {
this._rangeChangedHandler(this._selectedRange);
}
}
/**
* Handle a change in the time input of either from or to.
*
* The change requires updating the selected range and then informing
* any listeners that the range has changed (so they can update data).
*
* @param {Object} event Time Event on DOM that triggered calling this
* @param {Boolean} isEnd True if this is for end time
*/
_pickerTimeChanged(event, isEnd) {
const pickerElement = event.currentTarget;
const newTimeStr = pickerElement.value;
const selectedRange = this._selectedRange;
const parsedTS = isEnd
? selectedRange.setEndTime(newTimeStr)
: selectedRange.setStartTime(newTimeStr);
if (parsedTS === null) {
console.log('bad time change');
$(pickerElement).addClass('ui-state-error');
return;
}
$(pickerElement).removeClass('ui-state-error');
console.log(
(isEnd ? 'End' : 'Start') +
' time changed to: ' +
parsedTS +
' (' +
this._timeFormatter.formatTimeStamp90k(parsedTS) +
')'
);
this._informRangeChange();
}
/**
* Handle a change in the calendar's same/other day settings.
*
* The change means the selected range changes.
*
* @param {Boolean} isSameDay True if the same day radio button was activated
*/
_pickerSameDayChanged(isSameDay) {
// Prevent change if not real change (can happen on initial setup)
if (this._sameDay != isSameDay) {
/**
* This is called when the status of the same/other day radio buttons
* changes. We need to determine a new selected range and activiate it.
* Doing so will then also inform the change listener.
*/
const endDate = isSameDay
? this.selectedRange.start.dateStr
: this.selectedRange.end.dateStr;
this._updateRangeDates(this.selectedRange.start.dateStr, endDate);
this._sameDay = isSameDay;
// Switch between single day and multi-day
this._configureDatePickers();
}
}
/**
* Reflect a change in start and end date in the calendar view.
*
* The selected range is update, the view is reconfigured as necessary and
* any listeners are informed.
*
* @param {String} startDateStr New starting date
* @param {String} endDateStr New ending date
*/
_updateRangeDates(startDateStr, endDateStr) {
const newRange = this._selectedRange;
const originalStart = Object.assign({}, newRange.start);
const originalEnd = Object.assign({}, newRange.end);
newRange.setStartDate(startDateStr);
newRange.setEndDate(endDateStr);
const isSameRange = (a, b) => {
return (
a.dateStr == b.dateStr && a.timeStr == b.timeStr && a.ts90k == b.ts90k
);
};
// Do nothing if effectively no change
if (
!isSameRange(newRange.start, originalStart) ||
!isSameRange(newRange.end, originalEnd)
) {
console.log('New range: ' + startDateStr + ' - ' + endDateStr);
this._informRangeChange();
}
}
/**
* Install event handlers for same/other day radio buttons and the
* time input boxes as both need to result in an update of the calendar
* view.
*/
_wireControls() {
// If same day status changed, update the view
this._sameDayElement.change(() => this._pickerSameDayChanged(true));
this._otherDayElement.change(() => this._pickerSameDayChanged(false));
// Handle changing of a time input (either from or to)
const handler = (e, isEnd) => {
console.log('Time changed for: ' + (isEnd ? 'end' : 'start'));
this._pickerTimeChanged(e, isEnd);
};
this._startTimeElement.change((e) => handler(e, false));
this._endTimeElement.change((e) => handler(e, true));
}
/**
* (Re)Initialize the calendar based on a collection of camera views.
*
* This is necessary as the camera views ultimately define the limits on
* the date pickers.
*
* @param {Iterable} cameraViews Collection of camera views
*/
initializeWith(cameraViews) {
this._cameraViews = cameraViews;
[this._availableDates, this._minDateStr, this._maxDateStr] = minMaxDates(
cameraViews
);
this._configureDatePickers();
// Initialize the selected range to the from picker's date
// if we do not have a selected range yet
if (!this.selectedRange.hasStart()) {
const date = this.fromDateISO;
this._updateRangeDates(date, date);
this._wireControls();
}
}
/**
* Set a handler to be called when the calendar selection range changes.
*
* The handler will be called with one argument, an object of type
* CalendarTSRange reflecting the current calendar range. It will be called
* whenever that range changes.
*
* @param {Function} handler Function that will be called
*/
set onRangeChange(handler) {
this._rangeChangedHandler = handler;
}
/**
* Get the "to" selected date as Date object.
*
* @return {Date} Date value of the "to"date picker
*/
get toDate() {
return this._toPickerView.date;
}
/**
* Get the "from" selected date as Date object.
*
* @return {Date} Date value of the "from"date picker
*/
get fromDate() {
return this._fromPickerView.date;
}
/**
* Get the "to" selected date as the date component of an ISO-8601
* formatted string.
*
* @return {String} Date value (YYYY-MM-D) of the "to" date picker
*/
get toDateISO() {
return this._toPickerView.dateISO;
}
/**
* Get the "from" selected date as the date component of an ISO-8601
* formatted string.
*
* @return {String} Date value (YYYY-MM-D) of the "from" date picker
*/
get fromDateISO() {
return this._fromPickerView.dateISO;
}
/**
* Get the currently selected range in the calendar view.
*
* @return {CalendarTSRange} Range object
*/
get selectedRange() {
return this._selectedRange;
}
}

View File

@@ -0,0 +1,174 @@
// 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/>.
import RecordingsView from './RecordingsView';
/**
* Class handling a camer view.
*
* A camera view consists of a list of available recording segments for
* playback.
*/
export default class CameraView {
/**
* Construct the view.
*
* @param {Camera} cameraModel Model object for camera
* @param {[type]} recordingFormatter Formatter to be used by recordings
* @param {[type]} trimmed True if rec. ranges should be trimmed
* @param {[type]} recordingsParent Parent element to attach to or null)
*/
constructor(
cameraModel,
recordingFormatter,
trimmed,
recordingsParent = null
) {
this.camera = cameraModel;
this.recordingsView = new RecordingsView(
this.camera,
recordingFormatter,
trimmed,
recordingsParent
);
this._enabled = true;
this.recordingsUrl = null;
this.recordingsReq = null;
}
/**
* Get whether the view is enabled or not.
*
* @return {Boolean}
*/
get enabled() {
return this._enabled;
}
/**
* Change enabled state of the view.
*
* @param {Boolean} enabled Whether view should be enabled
*/
set enabled(enabled) {
this._enabled = enabled;
this.recordingsView.show = enabled;
console.log(
'Camera ',
this.camera.shortName,
this.enabled ? 'enabled' : 'disabled'
);
}
/**
* Get the currently remembered recordings range for this camera.
*
* This is just passed on to the recordings view.
*
* @return {Range90k} Currently remembered range
*/
get recordingsRange() {
return this.recordingsView.recordingsRange;
}
/**
* Set the recordings range for this view.
*
* This is just passed on to the recordings view.
*
* @param {Range90k} range90k Range to remember
*/
set recordingsRange(range90k) {
this.recordingsView.recordingsRange = range90k;
}
/**
* Set whether loading indicator should be shown or not.
*
* This indicator is really on the recordings list.
*
* @param {Boolean} show True if indicator should be showing
*/
set showLoading(show) {
this.recordingsView.showLoading = show;
}
/**
* Show the loading indicated after a delay, unless the timer has been
* cleared already.
*
* @param {Number} timeOutMs Delay (in ms) before indicator should appear
*/
delayedShowLoading(timeOutMs) {
this.recordingsView.delayedShowLoading(timeOutMs);
}
/**
* Set new recordings from JSON data.
*
* @param {Object} dataJSON JSON data (array)
*/
set recordingsJSON(dataJSON) {
this.recordingsView.recordingsJSON = dataJSON;
}
/**
* Set a new time format string for the recordings list.
*
* @param {String} formatStr Formatting string
*/
set timeFormat(formatStr) {
this.recordingsView.timeFormat = formatStr;
}
/**
* Set the trimming option of the cameraview as desired.
*
* This is really just passed on to the recordings view.
*
* @param {Boolean} enabled True if trimming should be enabled
*/
set trimmed(enabled) {
this.recordingsView.trimmed = enabled;
}
/**
* Set a handler for clicks on a recording.
*
* The handler will be called with one argument, the recording model.
*
* @param {Function} h Handler function
*/
set onRecordingClicked(h) {
this.recordingsView.onRecordingClicked = h;
}
}

View File

@@ -0,0 +1,113 @@
// 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/>.
import $ from 'jquery';
/**
* Class to handle a group of (related) checkboxes.
*
* Each checkbox is managed through a simple object containing properties:
* - id: {String} Id (some unique value within the group)
* - selector: {String} jQuery compatible selector to find the dom element
* - checked: {Boolean} Value for checkbox
* - jq: {jQuery} jQuery element for the checkbox, or null if not found
*
* A handler can be called if a checbox changes value.
*/
export default class CheckboxGroupView {
/**
* Construct the seteup for the checkboxes.
*
* The passed group array should contain individual maps describing each
* checkbox. THe maps should contain:
* - id
* - selector: optional. If not provided #id will be used
* - checked: Initial value for checkbox, default true
* - text: Text for the checkbox label (not generated if empty)
*
* @param {Array} group Array of maps, one for each checkbox
* @param {jQuery} parent jQuery parent element to append to
*/
constructor(group = [], parent = null) {
this._group = group.slice(); // Copy
this._group.forEach((element) => {
// If parent specified, create and append
if (parent) {
let cb = `<input type="checkbox" id="${element.id}" name="${
element.id
}">`;
if (element.text) {
cb += `<label for="${element.id}">${element.text}</label>`;
}
parent.append($(cb + '<br/>'));
}
const jq = $(element.selector || `#${element.id}`);
element.jq = jq;
if (jq !== null) {
jq.prop('checked', element.checked || true);
jq.change((e) => {
if (this._checkChangeHandler) {
element.checked = e.target.checked;
this._checkChangeHandler(element);
}
});
}
});
this._checkChangeHandler = null;
}
/**
* Get the checkbox object for the specified checkbox.
*
* The checkbox is looked up by the specified id or selector, which must
* match what was specified during construction.
*
* @param {String} idOrSelector Identifying string
* @return {Object} Object for checkbox, or null if not found
*/
checkBox(idOrSelector) {
return this._group.find(
(el) => el.id === idOrSelector || el.selector === idOrSelector
);
}
/**
* Set a handler for checkbox changes.
*
* Handler will be called with same result as would be found by checkBox().
*
* @param {Function} handler function (checbox)
*/
set onCheckChange(handler) {
this._checkChangeHandler = handler;
}
}

View File

@@ -0,0 +1,283 @@
// 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/>.
import $ from 'jquery';
/**
* Class to encapsulate datepicker UI widget from jQuery.
*/
export default class DatePickerView {
/**
* Construct wapper an attach to a specified parent DOM node.
*
* @param {Node} parent Note to serve for attachign datepicker
* @param {Object} options Options to pass to datepicker
*/
constructor(parent, options = null) {
this._pickerElement = $(parent);
/*
* The widget is somewhat peculiar in that its functionality does
* not exist until it has been called with a settings/options argument
* as the only parameter to the datepicker() function.
* So, unless some are passed in here explicitly, we create a default
* and disabled date picker.
*/
this._initWithOptions(options);
}
/**
* Initialize the date picker with a set of options.
*
* Attach the datepicker function to its parent and set the specified options.
* If the options are not specified a minimum set of options just enabling the
* datepicker with defaults is used.
*
* @param {Object} options Options for datepicker, or null to just enable
*/
_initWithOptions(options = null) {
this._alive = true;
options =
options !== null
? options
: {
disabled: true,
};
this._pickerElement.datepicker(options);
}
/**
* Execute a specified datepicker function, passing the arguments.
*
* This function exists to catch the cases where functions are called when
* the picker is not attached (alive).
*
* The first argument to this function should be the name of the desired
* datepicker function, followed by the correct arguments for that function.
*
* @return {Any} Function result
*/
_apply() {
if (!this._alive) {
console.log('WARN: datepicker not constructed yet [' + this.domId + ']');
}
return this._pickerElement.datepicker(...arguments);
}
/**
* Activate the datepicker if not already attached.
*
* Basically calls _initWithOptions({disabled: disabled}), but only if not
* already attached. Otherwise just sets the disabled status.
*
* @param {Boolean} disabled True if datepicker needs to be disabled
*/
activate(disabled = true) {
if (this._alive) {
this.disabled = disabled;
} else {
this._initWithOptions({
disabled: disabled,
});
}
}
/**
* Get the element the datepicker is attached to.
*
* @return {jQuery} jQuery element
*/
get element() {
return this._pickerElement;
}
/**
* Set option or options to the datepicker, like the 'option' call with
* various arguments.
*
* Special case is when the datepicker is not (yet) attached. In that case
* we need to initialze the datepicker with the options instead.
*
* @param {object} arg0 First parameter or undefined if not given
* @param {array} args Rest of the parameters (might be empty)
* @return {object} Result of the 'option' call.
*/
option(arg0, ...args) {
/*
* Special case the scenario of calling option setting with just a map of
* settings, when the picker is not alive. That really should translate
* to a constructor call to the datepicker.
*/
if (!this._alive && args.length === 0 && typeof arg0 === 'object') {
return this._initWithOptions(arg0);
}
return this._apply('option', arg0, ...args);
}
/**
* Return current set of options.
*
* This is special cased here vs. documentation. We need to ask for 'all'.
*
* @return {Object} Datepicker options
*/
options() {
return this.option('all');
}
/**
* Determine whether datepicker is disabled.
*
* @return {Boolean}
*/
get isDisabled() {
return this._apply('isDisabled');
}
/**
* Set disabled status of picker.
*
* @param {Boolean} disabled True to disable
*/
set disabled(disabled) {
this.option('disabled', disabled);
}
/**
* Get picker's currently selected date.
*
* @return {Date} Selected date
*/
get date() {
return this._apply('getDate');
}
/**
* Set the datepicker to a specific date.
*
* @param {String|Date} date Desired date as string or Date
*/
set date(date) {
this._apply('setDate', date);
}
/**
* Get the picker's current date in ISO format.
*
* This will return just the date portion of the ISO-8601 format, or in other
* words: YYYY-MM-DD
*
* @return {String} Date portion of ISO-8601 formatted selected date
*/
get dateISO() {
return this.date.toISOString().substr(0, 10);
}
/**
* Get currently set minimum date.
*
* @return {Date} Minimum date
*/
get minDate() {
return this.option('minDate');
}
/**
* Set a new minimum date.
*
* @param {String|Date} value Desired minimum date
*/
set minDate(value) {
this.option('minDate', value);
}
/**
* Get currently set maximum date.
*
* @return {Date} Maximum date
*/
get maxDate() {
return this.option('maxDate');
}
/**
* Set a new maximum date.
*
* @param {String|Date} value Desired maximum date
*/
set maxDate(value) {
this.option('maxDate', value);
}
/**
* Set the picker to open up in a dialog.
*
* This takes a variable number of arguments, like the native dialog function.
*
* @param {varargs} dialogArgs Variable argument list
*/
dialog(...dialogArgs) {
this._apply('option', dialogArgs);
}
/**
* Make the picker visible.
*/
show() {
this._apply('show');
}
/**
* Hide the picker.
*/
hide() {
this._apply('hide');
}
/**
* Refresh the picker.
*/
refresh() {
this._apply('refresh');
}
/**
* Destroy the picker.
*
* Destroy means detach it from its element and dispose of everything.
* Sets the status in this object to !alive.
*/
destroy() {
this._alive = true;
this._apply('destroy');
this._alive = false;
}
}

View File

@@ -0,0 +1,205 @@
// 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/>.
import $ from 'jquery';
/**
* Class to control the view of NVR Settings.
*
* These settings/controls include:
* - Max video length
* - Trim segment start/end
* - Time Format
*/
export default class NVRSettingsView {
/**
* Construct based on element ids
*/
constructor({
videoLenId = 'split',
trimCheckId = 'trim',
tsTrackId = 'ts',
timeFmtId = 'timefmt',
} = {}) {
this._ids = {videoLenId, trimCheckId, tsTrackId, timeFmtId};
this._videoLength = null;
this._videoLengthHandler = null;
this._trim = null;
this._trimHandler = null;
this._timeFmtStr = null;
this._timeFmtHandler = null;
this._tsTrack = null;
this._tsTrackHandler = null;
this._wireControls();
}
/**
* Find selected option in <select> and return value, or first option's value.
*
* The first option's value is returned if no option is selected.
*
* @param {jQuery} selectEl jQuery element for the <select>
* @return {String} Value of the selected/first option
*/
_findSelectedOrFirst(selectEl) {
let value = selectEl.find(':selected').val();
if (!value) {
value = selectEl.find('option:first-child').val();
}
return value;
}
/**
* Wire up all controls and handlers.
*
*/
_wireControls() {
const videoLengthEl = $(`#${this._ids.videoLenId}`);
this._videoLength = this._findSelectedOrFirst(videoLengthEl);
videoLengthEl.change((e) => {
const newValueStr = e.currentTarget.value;
this._videoLength =
newValueStr == 'infinite' ? Infinity : Number(newValueStr);
if (this._videoLengthHandler) {
this._videoLengthHandler(this._videoLength);
}
});
const trimEl = $(`#${this._ids.trimCheckId}`);
this._trim = trimEl.is(':checked');
trimEl.change((e) => {
this._trim = e.currentTarget.checked;
if (this._trimHandler) {
this._trimHandler(this._trim);
}
});
const timeFmtEl = $(`#${this._ids.timeFmtId}`);
this._timeFmtStr = this._findSelectedOrFirst(timeFmtEl);
timeFmtEl.change((e) => {
this._timeFmtStr = e.target.value;
if (this._timeFmtHandler) {
this._timeFmtHandler(this._timeFmtStr);
}
});
const trackEl = $(`#${this._ids.tsTrackId}`);
this._tsTrack = trackEl.is(':checked');
trackEl.change((e) => {
this._tsTrack = e.target.checked;
if (this._tsTrackHandler) {
this._tsTrackHandler(this._tsTrack);
}
});
}
/**
* Get currently selected video length.
*
* @return {Number} Video length value
*/
get videoLength() {
return this._videoLength;
}
/**
* Get currently selected time format string.
*
* @return {String} Format string
*/
get timeFormatString() {
return this._timeFmtStr;
}
/**
* Get currently selected trim setting.
*
* @return {Boolean}
*/
get trim() {
return this._trim;
}
/**
* Determine value of timestamp tracking option
*
* @return {Boolean}
*/
get timeStampTrack() {
return this._tsTrack;
}
/**
* Set a handler to be called when the time format string changes.
*
* The handler will be called with one argument: the new format string.
*
* @param {Function} handler Format change handler
*/
set onTimeFormatChange(handler) {
this._timeFmtHandler = handler;
}
/**
* Set a handler to be called when video length popup changes.
*
* The handler will be called with one argument: the new video length.
*
* @param {Function} handler Video Length change handler
*/
set onVideoLengthChange(handler) {
this._videoLengthHandler = handler;
}
/**
* Set a handler to be called when video trim checkbox changes.
*
* The handler will be called with one argument: the new trim value (Boolean).
*
* @param {Function} handler Trim change handler
*/
set onTrimChange(handler) {
this._trimHandler = handler;
}
/**
* Set a handler to be called when video timestamp tracking checkbox changes.
*
* The handler will be called with one argument: the new tsTrack value
* (Boolean).
*
* @param {Function} handler Timestamp track change handler
*/
set onTimeStampTrackChange(handler) {
this._tsTrackHandler = handler;
}
}

View File

@@ -0,0 +1,283 @@
// 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/>.
import $ from 'jquery';
import Recording from '../models/Recording';
/**
* Desired column order in recordings table.
*
* The column names must correspond to the propertu names in the JSON
* representation of recordings.
*
* @todo This should be decoupled!
*
* @type {Array} Array of column names
*/
const _columnOrder = [
'start',
'end',
'resolution',
'frameRate',
'size',
'rate',
];
/**
* Labels for columns.
*/
const _columnLabels = {
start: 'Start',
end: 'End',
resolution: 'Resolution',
frameRate: 'FPS',
size: 'Storage',
rate: 'BitRate',
};
/**
* Class to encapsulate a view of a list of recordings from a single camera.
*/
export default class RecordingsView {
/**
* Construct display from camera data and use supplied formatter.
*
* @param {Camera} camera camera object (immutable)
* @param {RecordingFormatter} recordingFormatter Desired formatter
* @param {Boolean} trimmed True if the display should include trimmed ranges
* @param {jQuery} parent Parent to which new DOM is attached, or null
*/
constructor(camera, recordingFormatter, trimmed = false, parent = null) {
this._cameraName = camera.shortName;
this._cameraRange = camera.range90k;
this._formatter = recordingFormatter;
this._element = $(`tab-${camera.uuid}`); // Might not be there initially
if (this._element.length == 0) {
this._element = this._createElement(camera.uuid, camera.shortName);
}
this._trimmed = trimmed;
this._recordings = null;
this._recordingsRange = null;
this._clickHandler = null;
if (parent) {
parent.append(this._element);
}
this._timeoutId = null;
}
/**
* Create DOM for the recording.
*
* @param {String} id DOM id for the main element
* @param {String} cameraName Name of the corresponding camera
* @return {jQuery} Partial DOM as jQuery object
*/
_createElement(id, cameraName) {
const tab = $('<tbody>').attr('id', id);
tab.append(
$('<tr class="name">').append($('<th colspan=6/>').text(cameraName)),
$('<tr class="hdr">').append(
$(
_columnOrder
.map((name) => '<th>' + _columnLabels[name] + '</th>')
.join('')
)
),
$('</tr>'),
$('<tr class="loading"><td colspan=6>loading...</td></tr>').hide()
);
return tab;
}
/**
* Update display for new recording values.
*
* Each existing row is reformatted.
*
* @param {Array} newRecordings
* @param {Boolean} trimmed True if timestamps should be trimmed
*/
_updateRecordings() {
const trimRange = this._trimmed ? this.recordingsRange : null;
const recordings = this._recordings;
this._element.children('tr.r').each((rowIndex, row) => {
const values = this._formatter.format(recordings[rowIndex], trimRange);
$(row)
.children('td')
.each((index, element) => $(element).text(values[_columnOrder[index]]));
});
}
/**
* Get the currently remembered recordings range for this view.
*
* This range corresponds to what was in the data time range selector UI
* at the time the data for this view was selected. The value is remembered
* purely for trimming purposes.
*
* @return {Range90k} Currently remembered range
*/
get recordingsRange() {
return this._recordingsRange ? this._recordingsRange.clone() : null;
}
/**
* Set the recordings range for this view.
*
* @param {Range90k} range90k Range to remember
*/
set recordingsRange(range90k) {
this._recordingsRange = range90k ? range90k.clone() : null;
}
/**
* Get whether time ranges in the recording list are being trimmed.
*
* @return {Boolean}
*/
get trimmed() {
return this._trimmed;
}
/**
* Set whether recording time ranges should be trimmed.
*
* @param {Boolean} value True if trimming desired
*/
set trimmed(value) {
if (value != this._trimmed) {
this._trimmed = value;
this._updateRecordings();
}
}
/**
* Show or hide the display in the DOM.
*
* @param {Boolean} show True for show, false for hide
*/
set show(show) {
const sel = this._element;
if (show) {
sel.show();
} else {
sel.hide();
}
}
/**
* Set whether loading indicator should be shown or not.
*
* @param {Boolean} show True if indicator should be showing
*/
set showLoading(show) {
const loading = $('tr.loading', this._element);
if (show) {
loading.show();
} else {
if (this._timeoutId) {
clearTimeout(this._timeoutId);
this._timeoutId = null;
}
loading.hide();
}
}
/**
* Show the loading indicated after a delay, unless the timer has been
* cleared already.
*
* @param {Number} timeOutMs Delay (in ms) before indicator should appear
*/
delayedShowLoading(timeOutMs) {
this._timeoutId = setTimeout(() => (this.showLoading = true), timeOutMs);
}
/**
* Set a new time format string.
*
* This string is passed on to the formatter and the recordings list
* is updated (using the formatter).
*
* @param {String} formatStr Formatting string
*/
set timeFormat(formatStr) {
// Change the formatter and update recordings (view)
this._formatter.timeFormat = formatStr;
this._updateRecordings();
}
/**
* Set a handler to receive clicks on a recording.
*
* The handler will be called with one argument: a recording model.
*
* @param {Function} h Handler to be called.
*/
set onRecordingClicked(h) {
this._clickHandler = h;
}
/**
* Set the list of recordings from JSON data.
*
* The data is expected to be an array with recording objects.
*
* @param {String} recordingsJSON JSON data (array)
*/
set recordingsJSON(recordingsJSON) {
this.showLoading = false;
// Store as model objects
this._recordings = recordingsJSON.map(function(r) {
return new Recording(r);
});
const tbody = this._element;
// Remove existing rows, replace with new ones
$('tr.r', tbody).remove();
this._recordings.forEach((r) => {
let row = $('<tr class="r" />');
row.append(_columnOrder.map((k) => $('<td/>')));
row.on('click', (e) => {
console.log('Video clicked');
if (this._clickHandler !== null) {
console.log('Video clicked handler call');
this._clickHandler(r);
}
});
tbody.append(row);
});
// Cause formatting and date to be put in the rows
this._updateRecordings();
}
}