// vim: set et sw=2 ts=2: // // This file is part of Moonfire NVR, a security camera network video recorder. // Copyright (C) 2018-2020 The Moonfire NVR Authors // // 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 . // TODO: test abort. // TODO: add error bar on fetch failure. // TODO: live updating. import $ from 'jquery'; // tooltip needs: // css.structure: ../../themes/base/core.css // css.structure: ../../themes/base/tooltip.css // css.theme: ../../themes/base/theme.css import 'jquery-ui/themes/base/core.css'; import 'jquery-ui/themes/base/tooltip.css'; import 'jquery-ui/themes/base/theme.css'; // This causes our custom css to be loaded after the above! import './index.css'; // Get ui widgets themselves import 'jquery-ui/ui/widgets/tooltip'; import Camera from './lib/models/Camera'; import CalendarView from './lib/views/CalendarView'; import VideoDialogView from './lib/views/VideoDialogView'; import NVRSettingsView from './lib/views/NVRSettingsView'; import RecordingFormatter from './lib/support/RecordingFormatter'; import StreamSelectorView from './lib/views/StreamSelectorView'; import StreamView from './lib/views/StreamView'; import TimeFormatter from './lib/support/TimeFormatter'; import TimeStamp90kFormatter from './lib/support/TimeStamp90kFormatter'; import MoonfireAPI from './lib/MoonfireAPI'; const api = new MoonfireAPI(); let streamViews = null; // StreamView objects let calendarView = null; // CalendarView object let loginDialog = null; /** * Currently selected time format specification. * * @type {String} */ const timeFmt = 'YYYY-MM-DD HH:mm:ss'; /** * Currently active time formatter. * This is lazy initialized at the point we receive the timezone information * and never changes afterwards, except possibly for changing the timezone. * * @type {[type]} */ let timeFormatter = null; /** * Currently active time formatter for internal time format. * This is lazy initialized at the point we receive the timezone information * and never changes afterwards, except possibly for changing the timezone. * * @type {[type]} */ let timeFormatter90k = null; /** * Globally set a new timezone for the app. * * @param {String} timeZone Timezone name */ function newTimeZone(timeZone) { timeFormatter = new TimeFormatter(timeFmt, timeZone); timeFormatter90k = new TimeStamp90kFormatter(timeZone); } /** * Globally set a new time format for the app. * * @param {String} format Time format specification */ function newTimeFormat(format) { timeFormatter = new TimeFormatter(format, timeFormatter.tz); } /** * Event handler for clicking on a video. * * A 'dialog' object is attached to the body of the dom and it * properly initialized with the corrcet src url. * * @param {NVRSettings} nvrSettingsView NVRSettingsView in effect * @param {object} camera Object for the camera * @param {String} streamType "main" or "sub" * @param {object} range Range Object * @param {object} recording Recording object * @return {void} */ function onSelectVideo(nvrSettingsView, camera, streamType, range, recording) { console.log('Recording clicked: ', recording); const trimmedRange = recording.range90k(nvrSettingsView.trim ? range : null); const url = api.videoPlayUrl( camera.uuid, streamType, recording, trimmedRange, nvrSettingsView.timeStampTrack ); const [ formattedStart, formattedEnd, ] = timeFormatter90k.formatSameDayShortened( trimmedRange.startTime90k, trimmedRange.endTime90k ); const videoTitle = camera.shortName + ', ' + formattedStart + ' to ' + formattedEnd; let width = recording.videoSampleEntryWidth * recording.videoSampleEntryPaspHSpacing / recording.videoSampleEntryPaspVSpacing; const maxWidth = window.innerWidth * 3 / 4; while (width > maxWidth) { width /= 2; } new VideoDialogView() .attach($('body')) .play(videoTitle, width, url); } /** * Fetch stream view data for a given date/time range. * * @param {Range90k} selectedRange Desired time range * @param {Number} videoLength Desired length of video segments, or Infinity */ function fetch(selectedRange, videoLength) { if (selectedRange.startTime90k === null) { return; } console.log( 'Fetching> ' + selectedRange.formatTimeStamp90k(selectedRange.startTime90k) + ' to ' + selectedRange.formatTimeStamp90k(selectedRange.endTime90k) ); for (const streamView of streamViews) { const url = api.recordingsUrl( streamView.camera.uuid, streamView.streamType, selectedRange.startTime90k, selectedRange.endTime90k, videoLength ); if (streamView.recordingsReq !== null) { /* * If there is another request, it would be because settings changed * and so an abort is to make room for this new request, now necessary * for the changed situation. */ streamView.recordingsReq.abort(); } streamView.delayedShowLoading(500); const r = api.request(url); streamView.recordingsUrl = url; streamView.recordingsReq = r; streamView.recordingsRange = selectedRange.range90k(); r.always(function() { streamView.recordingsReq = null; }); r .then(function(data /* , status, req */) { // Sort recordings in descending order. data.recordings.sort(function(a, b) { return b.startId - a.startId; }); console.log( 'Fetched results for "%s-%s" > updating recordings', streamView.camera.shortName, streamView.streamType ); streamView.recordingsJSON = data; }) .catch(function(data, status, err) { console.error(url, ' load failed: ', status, ': ', err); }); } } /** * Updates the session bar at the top of the page. * * @param {Object} session the "session" key of the main API request's JSON, * or null. */ function updateSession(session) { const sessionBar = $('#session'); sessionBar.empty(); if (session === null || session === undefined) { sessionBar.hide(); return; } sessionBar.append($('').text(session.username)); const logout = $('logout'); logout.click(() => { api .logout(session.csrf) .done(() => { onReceivedTopLevel(null); loginDialog.dialog('open'); }); }); sessionBar.append(' | ', logout); sessionBar.show(); } /** * Initialize the page after receiving top-level data. * * Sets the following globals: * zone - timezone from data received * streamViews - array of views, one per stream * * Builds the dom for the left side controllers * * @param {Object} data JSON resulting from the main API request /api/?days= * or null if the request failed. */ function onReceivedTopLevel(data) { if (data === null) { data = {cameras: [], timeZoneName: null}; } newTimeZone(data.timeZoneName); updateSession(data.session); // Set up controls and values const nvrSettingsView = new NVRSettingsView(); nvrSettingsView.onVideoLengthChange = (vl) => fetch(calendarView.selectedRange, vl); nvrSettingsView.onTimeFormatChange = (format) => streamViews.forEach((view) => (view.timeFormat = format)); nvrSettingsView.onTrimChange = (t) => streamViews.forEach((view) => (view.trimmed = t)); newTimeFormat(nvrSettingsView.timeFormatString); calendarView = new CalendarView({timeZone: timeFormatter.tz}); calendarView.onRangeChange = (selectedRange) => fetch(selectedRange, nvrSettingsView.videoLength); const streamsParent = $('#streams'); const videos = $('#videos'); streamsParent.empty(); videos.empty(); streamViews = []; const streamSelectorCameras = []; for (const cameraJson of data.cameras) { const camera = new Camera(cameraJson); const cameraStreams = {}; Object.keys(camera.streams).forEach((streamType) => { const sv = new StreamView( camera, streamType, new RecordingFormatter(timeFormatter.formatStr, timeFormatter.tz), nvrSettingsView.trim, videos); sv.onRecordingClicked = (recordingModel) => { console.log('Recording clicked', recordingModel); onSelectVideo( nvrSettingsView, camera, streamType, calendarView.selectedRange, recordingModel ); }; streamViews.push(sv); cameraStreams[streamType] = sv; }); streamSelectorCameras.push({ camera: camera, streamViews: cameraStreams, }); }; // Create stream enable checkboxes const streamSelector = new StreamSelectorView(streamSelectorCameras, streamsParent); streamSelector.onChange = () => calendarView.initializeWith(streamViews); calendarView.initializeWith(streamViews); console.log('Loaded: ' + streamViews.length + ' stream views'); } /** * Handles the submit action on the login form. */ function sendLoginRequest() { if (loginDialog.pending) { return; } const username = $('#login-username').val(); const password = $('#login-password').val(); const submit = $('#login-submit'); const error = $('#login-error'); error.empty(); error.removeClass('ui-state-highlight'); submit.button('option', 'disabled', true); loginDialog.pending = true; console.info('logging in as', username); api .login(username, password) .done(() => { console.info('login successful'); loginDialog.dialog('close'); sendTopLevelRequest(); }) .catch((e) => { console.info('login failed:', e); error.show(); error.addClass('ui-state-highlight'); error.text(e.responseText); }) .always(() => { submit.button('option', 'disabled', false); loginDialog.pending = false; }); } /** Sends the top-level api request. */ function sendTopLevelRequest() { api .request(api.nvrUrl(true)) .done((data) => onReceivedTopLevel(data)) .catch((e) => { console.error('NVR load exception: ', e); onReceivedTopLevel(null); if (e.status == 401) { loginDialog.dialog('open'); } }); } /** * Class representing the entire application. */ export default class NVRApplication { /** * Construct the application object. */ constructor() {} /** * Start the application. */ start() { const nav = $('#nav'); $('#toggle-nav').click(() => { nav.toggle('slide'); }); loginDialog = $('#login').dialog({ autoOpen: false, modal: true, buttons: [ { id: 'login-submit', text: 'Login', click: sendLoginRequest, }, ], }); loginDialog.pending = false; loginDialog.find('form').on('submit', function(event) { event.preventDefault(); sendLoginRequest(); }); sendTopLevelRequest(); } }