417 lines
15 KiB
JavaScript
417 lines
15 KiB
JavaScript
// vim: set et sw=2:
|
|
|
|
// TODO: test abort.
|
|
// TODO: add error bar on fetch failure.
|
|
// TODO: style: no globals? string literals? line length? fn comments?
|
|
// 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';
|
|
|
|
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();
|
|
}
|
|
|
|
$(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);
|
|
});
|
|
});
|