moonfire-nvr/ui-src/index.js

426 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/';
const allStreamTypes = ['main', 'sub'];
// 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 within
// the streams dicts:
// * "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'},
});
}
// Produces a human-readable but unambiguous format of the given timestamp:
// ISO-8601 plus an extra colon component after the seconds indicating the
// fractional time in 90,000ths of a second.
function formatTime(ts90k) {
const m = moment.tz(ts90k / 90, zone);
const frac = ts90k % 90000;
return m.format('YYYY-MM-DDTHH:mm:ss:' + String(100000 + frac).substr(1) + 'Z');
}
function onSelectVideo(camera, streamType, range, recording) {
let url = apiUrl + 'cameras/' + camera.uuid + '/' + streamType + '/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 + " " + streamType + ", " + formattedStart + " to " + formattedEnd,
width: recording.videoSampleEntryWidth / 4,
close: function() { dialog.remove(); },
});
video.attr("src", url);
}
function formatRecordings(camera, streamType) {
let tbody = $("#tab-" + camera.uuid + "-" + streamType);
$(".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");
const stream = camera.streams[streamType];
for (const recording of stream.recordingsData.recordings) {
const duration = (recording.endTime90k - recording.startTime90k) / 90000;
let row = $('<tr class="r"/>');
const startTime90k = trim && recording.startTime90k < stream.recordingsRange.startTime90k
? stream.recordingsRange.startTime90k : recording.startTime90k;
const endTime90k = trim && recording.endTime90k > stream.recordingsRange.endTime90k
? stream.recordingsRange.endTime90k : recording.endTime90k;
let formattedStart = formatTime(startTime90k);
let formattedEnd = formatTime(endTime90k);
const singleDateStr = stream.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, streamType, stream.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) {
for (const streamType in camera.streams) {
let stream = camera.streams[streamType];
let url = apiUrl + 'cameras/' + camera.uuid + '/' + streamType + '/recordings?startTime90k=' +
selectedRange.startTime90k + '&endTime90k=' + selectedRange.endTime90k;
if (split !== '') {
url += '&split90k=' + split;
}
if (url === stream.recordingsUrl) {
continue; // nothing to do.
}
console.log('url: ', url);
if (stream.recordingsReq !== null) {
stream.recordingsReq.abort();
}
let tbody = $("#tab-" + camera.uuid + "-" + streamType);
$(".r", tbody).remove();
$(".loading", tbody).show();
let r = req(url);
stream.recordingsUrl = url;
stream.recordingsRange = selectedRange;
stream.recordingsReq = r;
r.always(function() { stream.recordingsReq = null; });
r.then(function(data, status, req) {
// Sort recordings in descending order.
data.recordings.sort(function(a, b) { return b.startId - a.startId; });
stream.recordingsData = data;
formatRecordings(camera, streamType);
}).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) {
for (const streamType in camera.streams) {
const stream = camera.streams[streamType];
if (!stream.enabled) {
continue;
}
for (const dateStr in stream.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 onStreamChange(event, camera, streamType) {
let stream = camera.streams[streamType];
stream.enabled = event.target.checked;
let id = "#tab-" + camera.uuid + "-" + streamType;
if (stream.enabled) {
$(id).show();
} else {
$(id).hide();
}
console.log(camera.shortName + "/" + streamType, stream.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 camtable = $("#cameras");
if (data.cameras.length === 0) {
return;
}
// Add a header row.
let hdr = $('<tr/>').append($('<th/>'));
for (const streamType of allStreamTypes) {
hdr.append($('<th/>').text(streamType));
}
camtable.append(hdr);
var reqs = [];
let videos = $("#videos");
for (let camera of data.cameras) {
let row = $('<tr/>').append($('<td>').text(camera.shortName));
let anyCheckedForCam = false;
for (const streamType of allStreamTypes) {
let stream = camera.streams[streamType];
if (stream === undefined) {
row.append('<td/>');
continue;
}
const id = "cam-" + camera.uuid + "-" + streamType;
let checkBox = $('<input type="checkbox">').attr("name", id).attr("id", id);
checkBox.change(function(event) { onStreamChange(event, camera, streamType); });
row.append($("<td/>").append(checkBox));
let tab = $("<tbody>").attr("id", "tab-" + camera.uuid + "-" + streamType);
tab.append(
$('<tr class="name">').append($('<th colspan=6/>').text(camera.shortName + " " + streamType)),
$('<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);
stream.recordingsUrl = null;
stream.recordingsRange = null;
stream.recordingsData = null;
stream.recordingsReq = null;
stream.enabled = false;
if (!anyCheckedForCam) {
checkBox.attr("checked", "checked");
anyCheckedForCam = true;
stream.enabled = true;
} else {
tab.hide();
}
}
camtable.append(row);
}
$("#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) {
for (streamType in camera.streams) {
const stream = camera.streams[streamType];
if (stream.recordingsData !== null) {
formatRecordings(camera, streamType);
}
}
}
});
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);
});
});