From: Calin Crisan Date: Sun, 10 Nov 2013 17:21:58 +0000 (+0200) Subject: added a media browser, previewer and downloader X-Git-Url: http://www.vanbest.org/gitweb/?a=commitdiff_plain;h=6444bb2ce4c0b037124ff8c41e311591dcacf961;p=motioneye-debian added a media browser, previewer and downloader --- diff --git a/doc/todo.txt b/doc/todo.txt index e7968cd..ddcf181 100644 --- a/doc/todo.txt +++ b/doc/todo.txt @@ -1,7 +1,8 @@ +-> fix remote picture previewer +-> add a download option in media list +-> add a previewer for movies -> make camera frames positions configurable -> add a view log functionality --> add a previewer for snapshots --> add a previewer for movies -> style scroll bars -> hint text next to section titles diff --git a/src/handlers.py b/src/handlers.py index cdef782..eb62cb2 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -18,6 +18,7 @@ import base64 import json import logging +import os from tornado.web import RequestHandler, HTTPError, asynchronous @@ -531,6 +532,9 @@ class PictureHandler(BaseHandler): elif op == 'download': self.download(camera_id, filename) + elif op == 'preview': + self.preview(camera_id, filename) + else: raise HTTPError(400, 'unknown operation') @@ -575,9 +579,9 @@ class PictureHandler(BaseHandler): camera_config.get('@host'), camera_config.get('@port'), camera_config.get('@remote_camera_id')) - + camera_full_url = camera_config['@proto'] + '://' + camera_url - + if remote_list is None: return self.finish_json({'error': 'Failed to get picture list for %(url)s.' % { 'url': camera_full_url}}) @@ -594,16 +598,95 @@ class PictureHandler(BaseHandler): else: pictures = mediafiles.list_pictures(camera_config) - self.finish_json({'pictures': pictures}) + self.finish_json({ + 'mediaList': pictures, + 'cameraName': camera_config['@name'] + }) @BaseHandler.auth() def download(self, camera_id, filename): logging.debug('downloading picture %(filename)s of camera %(id)s' % { 'filename': filename, 'id': camera_id}) - # TODO implement me + if camera_id not in config.get_camera_ids(): + raise HTTPError(404, 'no such camera') - self.finish_json() + camera_config = config.get_camera(camera_id) + if camera_config['@proto'] != 'v4l2': + def on_response(remote_list): + camera_url = remote.make_remote_camera_url( + camera_config.get('@host'), + camera_config.get('@port'), + camera_config.get('@remote_camera_id')) + + camera_full_url = camera_config['@proto'] + '://' + camera_url + + if remote_list is None: + return self.finish_json({'error': 'Failed to get picture list for %(url)s.' % { + 'url': camera_full_url}}) + + self.finish_json(remote_list) + + remote.download_picture( + camera_config.get('@host'), + camera_config.get('@port'), + camera_config.get('@username'), + camera_config.get('@password'), + camera_config.get('@remote_camera_id'), on_response) + + else: + content = mediafiles.get_media_content(camera_config, filename) + + pretty_filename = camera_config['@name'] + '_' + os.path.basename(filename) + self.set_header('Content-Type', 'image/jpeg') + self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';') + + self.finish(content) + + + @BaseHandler.auth() + def preview(self, camera_id, filename): + logging.debug('previewing picture %(filename)s of camera %(id)s' % { + 'filename': filename, 'id': camera_id}) + + if camera_id not in config.get_camera_ids(): + raise HTTPError(404, 'no such camera') + + camera_config = config.get_camera(camera_id) + if camera_config['@proto'] != 'v4l2': + def on_response(response): + camera_url = remote.make_remote_camera_url( + camera_config.get('@host'), + camera_config.get('@port'), + camera_config.get('@remote_camera_id')) + + camera_full_url = camera_config['@proto'] + '://' + camera_url + + if response is None: + return self.finish_json({'error': 'Failed to get picture list for %(url)s.' % { + 'url': camera_full_url}}) + + self.set_header('Content-Type', 'image/jpeg') + self.finish(response) + + remote.preview_picture( + camera_config.get('@host'), + camera_config.get('@port'), + camera_config.get('@username'), + camera_config.get('@password'), + camera_config.get('@remote_camera_id'), + filename, + width=self.get_argument('width', None), + height=self.get_argument('height', None), + callback=on_response) + + else: + content = mediafiles.get_media_content(camera_config, filename) + + # TODO add support for ?width=, ?height= + + self.set_header('Content-Type', 'image/jpeg') + self.finish(content) class MovieHandler(BaseHandler): diff --git a/src/mediafiles.py b/src/mediafiles.py index 5701473..f5cd3a3 100644 --- a/src/mediafiles.py +++ b/src/mediafiles.py @@ -20,6 +20,7 @@ import logging import os.path import config +import utils _PICTURE_EXTS = ['.jpg'] @@ -109,7 +110,18 @@ def list_pictures(camera_config): # return [] full_paths = _list_media_files(target_dir, exts=_PICTURE_EXTS) - picture_files = [p[len(target_dir):] for p in full_paths] + picture_files = [] + + for p in full_paths: + path = p[len(target_dir):] + if not path.startswith('/'): + path = '/' + path + + picture_files.append({ + 'path': path, + 'momentStr': utils.pretty_date_time(datetime.datetime.fromtimestamp(os.path.getmtime(p))), + 'timestamp': os.path.getmtime(p) + }) # TODO files listed here may not belong to the given camera @@ -120,8 +132,28 @@ def list_movies(camera_config): target_dir = camera_config.get('target_dir') full_paths = _list_media_files(target_dir, exts=_MOVIE_EXTS) - movie_files = [p[len(target_dir):] for p in full_paths] + movie_files = [{ + 'path': p[len(target_dir):], + 'momentStr': utils.pretty_date_time(datetime.datetime.fromtimestamp(os.path.getmtime(p))), + 'timestamp': os.path.getmtime(p) + } for p in full_paths] # TODO files listed here may not belong to the given camera return movie_files + + +def get_media_content(camera_config, path): + target_dir = camera_config.get('target_dir') + + full_path = os.path.join(target_dir, path) + + try: + with open(full_path) as f: + return f.read() + + except Exception as e: + logging.error('failed to read file %(path)s: %(msg)s' % { + 'path': full_path, 'msg': unicode(e)}) + + return None diff --git a/src/remote.py b/src/remote.py index d4afebf..21299da 100644 --- a/src/remote.py +++ b/src/remote.py @@ -237,7 +237,42 @@ def list_pictures(host, port, username, password, camera_id, callback): return callback(None) - return callback(response['pictures']) + return callback(response) http_client = AsyncHTTPClient() http_client.fetch(request, on_response) + + +def preview_picture(host, port, username, password, camera_id, filename, width, height, callback): + logging.debug('getting preview for file %(filename)s of remote camera %(id)s on %(host)s:%(port)s' % { + 'filename': filename, + 'id': camera_id, + 'host': host, + 'port': port}) + + uri = '/picture/%(id)s/preview/%(filename)s/?' % { + 'id': camera_id, + 'filename': filename} + + if width: + uri += 'width=' + str(width) + if height: + uri += 'height=' + str(height) + + request = _make_request(host, port, username, password, uri) + + def on_response(response): + if response.error: + logging.error('failed to get preview for file %(filename)s of remote camera %(id)s on %(host)s:%(port)s: %(msg)s' % { + 'filename': filename, + 'id': camera_id, + 'host': host, + 'port': port, + 'msg': unicode(response.error)}) + + return callback(None) + + return callback(response.body) + + http_client = AsyncHTTPClient() + http_client.fetch(request, on_response) diff --git a/src/server.py b/src/server.py index fcac5fa..f7ead25 100644 --- a/src/server.py +++ b/src/server.py @@ -45,7 +45,7 @@ application = Application( (r'^/config/(?P\d+)/(?Pget|set|rem|set_preview)/?$', handlers.ConfigHandler), (r'^/config/(?Padd|list|list_devices)/?$', handlers.ConfigHandler), (r'^/picture/(?P\d+)/(?Pcurrent|list)/?$', handlers.PictureHandler), - (r'^/picture/(?P\d+)/(?Pdownload)/(?P.+)/?$', handlers.PictureHandler), + (r'^/picture/(?P\d+)/(?Pdownload|preview)/(?P.+)/?$', handlers.PictureHandler), (r'^/movie/(?P\d+)/(?Plist)/?$', handlers.MovieHandler), (r'^/movie/(?P\d+)/(?Pdownload)/(?P.+)/?$', handlers.MovieHandler), (r'^/update/?$', handlers.UpdateHandler), diff --git a/src/utils.py b/src/utils.py index 4fad4d8..25c9d65 100644 --- a/src/utils.py +++ b/src/utils.py @@ -29,7 +29,7 @@ def pretty_date_time(date_time, tzinfo=None): text = u'{day} {month} {year}, {hm}'.format( day=date_time.day, - month=_(date_time.strftime('%B')), + month=date_time.strftime('%B'), year=date_time.year, hm=date_time.strftime('%H:%M') ) diff --git a/static/css/main.css b/static/css/main.css index a08d5f9..6c44ffb 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -355,6 +355,89 @@ table.add-camera-dialog input[type=password] { } +div.media-dialog { + overflow: auto; +} + +div.media-list-group-title { + background-color: #313131; + font-size: 1.3em; + font-weight: bold; + text-align: center; + padding: 1em 0px 0.2em 0px; +} + +div.media-list-entry { + height: 4em; + background-color: #414141; + border-bottom: 1px solid #313131; + cursor: pointer; + transition: background-color 0.1s linear; +} + +div.media-list-entry:HOVER { + background-color: #494949; +} + +div.media-list-entry:ACTIVE { + background-color: #3b3b3b; +} + +img.media-list-preview { + float: left; + height: 3em; + margin: 0.45em; + border: 1px solid #212121; + box-shadow: 1px 1px 6px rgba(0,0,0,0.3); +} + +div.media-list-entry-name { + font-size: 1.3em; + padding: 0.4em 0em; + text-align: center; + white-space: nowrap; +} + +div.media-list-entry-moment { + font-size: 1em; + text-align: center; + white-space: nowrap; +} + +div.picture-dialog-content { + position: relative; +} + +div.picture-dialog-prev-arrow, +div.picture-dialog-next-arrow { + position: absolute; + top: 45%; + background-color: rgba(0, 0, 0, 0.3); + background-image: url(../img/arrows.svg); + background-size: cover; + width: 3em; + height: 3em; + border-radius: 0.3em; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + cursor: pointer; +} + +div.picture-dialog-prev-arrow { + left: 1em; +} + +div.picture-dialog-next-arrow { + right: 1em; + background-position: -100% 0%; +} + +img.picture-dialog-content { + border: 1px solid #292929; +} + + /* camera frames */ div.camera-list { @@ -393,8 +476,8 @@ div.camera-frame:HOVER { div.camera-top-bar { padding: 3px 0px; - font-size: 17px; - height: 17px; + font-size: 20px; + height: 25px; } div.modal-container div.camera-top-bar { @@ -403,6 +486,7 @@ div.modal-container div.camera-top-bar { span.camera-name { float: left; + line-height: 25px; } div.camera-buttons { @@ -411,12 +495,12 @@ div.camera-buttons { div.camera-button { display: inline-block; - width: 16px; - height: 16px; + width: 24px; + height: 24px; background-image: url(../img/top-bar-buttons.svg); + background-size: cover; margin-left: 3px; cursor: pointer; - opacity: 1; transition: all 0.1s linear; } @@ -432,6 +516,14 @@ div.camera-button.configure { background-position: -100% 0px; } +div.camera-button.media-pictures { + background-position: -300% 0px; +} + +div.camera-button.media-movies { + background-position: -400% 0px; +} + div.camera-container { position: relative; padding: 0px; @@ -521,11 +613,6 @@ img.camera-progress { width: 24px; height: 24px; } - - div.camera-top-bar { - font-size: 20px; - height: 25px; - } } @media all and (max-width: 1900px) { diff --git a/static/css/ui.css b/static/css/ui.css index b9c6cc2..6b55a69 100644 --- a/static/css/ui.css +++ b/static/css/ui.css @@ -60,7 +60,7 @@ div.button.dialog.default { } div.button.mouse-effect { - opacity: 0.9; + opacity: 0.7; transition: opacity 0.1s linear; } @@ -69,7 +69,7 @@ div.button.mouse-effect:HOVER { } div.button.mouse-effect:ACTIVE { - opacity: 0.6; + opacity: 0.7; } @@ -310,16 +310,18 @@ div.modal-title-bar { span.modal-title { color: white; + font-size: 1.2em; + line-height: 1.2em; } div.modal-close-button { position: absolute; - top: 0.3em; + top: 0.2em; right: 0.3em; - width: 16px; - height: 16px; + width: 1.1em; + height: 1.1em; background-image: url(../img/top-bar-buttons.svg); - opacity: 1; + background-size: cover; cursor: pointer; } @@ -327,6 +329,7 @@ table.modal-buttons-container { width: 100%; margin: auto; text-align: center; + table-layout: fixed; -webkit-user-select: none; -moz-user-select: none; user-select: none; diff --git a/static/img/arrows.svg b/static/img/arrows.svg new file mode 100644 index 0000000..f1239a7 --- /dev/null +++ b/static/img/arrows.svg @@ -0,0 +1,86 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/static/img/motioneye.svg b/static/img/motioneye.svg index 6e8677c..acc09f4 100644 --- a/static/img/motioneye.svg +++ b/static/img/motioneye.svg @@ -18,7 +18,7 @@ sodipodi:docname="motioneye.svg">image/svg+xml + + + + diff --git a/static/js/main.js b/static/js/main.js index 234fc96..cb6d00d 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -122,6 +122,23 @@ Array.prototype.map = function (func, thisArg) { return mapped; }; +Array.prototype.sortKey = function (keyFunc) { + this.sort(function (e1, e2) { + var k1 = keyFunc(e1); + var k2 = keyFunc(e2); + + if (k1 < k2) { + return -1; + } + else if (k1 > k2) { + return 1; + } + else { + return 0; + } + }); +}; + /* UI initialization */ @@ -992,6 +1009,74 @@ function runConfirmDialog(message, onYes) { runModalDialog({title: message, buttons: 'yesno', onYes: onYes}); } +function runPictureDialog(entries, pos) { + var content = $('
'); + + var img = $(''); + content.append(img); + + var prevArrow = $('
'); + content.append(prevArrow); + + var nextArrow = $('
'); + content.append(nextArrow); + + var windowWidth = $(window).width(); + + function updatePicture() { + var entry = entries[pos]; + var width; + if (windowWidth <= 1000) { + width = parseInt(windowWidth * 0.9); + } + else { + width = parseInt(windowWidth * 0.5); + } + + img.width(width); + img.attr('src', '/picture/' + entry.cameraId + '/preview' + entry.path + '?width=' + width); + + prevArrow.css('display', pos > 0 ? '' : 'none'); + nextArrow.css('display', pos < entries.length - 1 ? '' : 'none'); + + $('div.modal-container').find('span.modal-title:last').html(entry.name); + } + + prevArrow.click(function () { + if (pos > 0) { + pos--; + } + + updatePicture(); + }); + + nextArrow.click(function () { + if (pos < entries.length - 1) { + pos++; + } + + updatePicture(); + }); + + img.load(updateModalDialogPosition); + + runModalDialog({ + title: ' ', + closeButton: true, + buttons: [ + {caption: 'Close'}, + {caption: 'Download', isDefault: true, click: function () { + window.location.href = img.attr('src').replace('preview', 'download'); + + return false; + }} + ], + content: content, + stack: true, + onShow: updatePicture + }); +} + function runAddCameraDialog() { if (!$('#motionEyeSwitch')[0].checked) { return runAlertDialog('Please enable motionEye first!'); @@ -1206,6 +1291,149 @@ function runAddCameraDialog() { } +function runMediaDialog(cameraId, mediaType) { + var dialogDiv = $( + '
' + + '
'); + + var windowWidth = $(window).width(); + + if (windowWidth <= 1000) { + dialogDiv.width(parseInt(windowWidth * 0.9)); + dialogDiv.height(parseInt(windowWidth * 0.9 * 480 / 640)); + } + else { + dialogDiv.width(parseInt(windowWidth * 0.5)); + dialogDiv.height(parseInt(windowWidth * 0.5 * 480 / 640)); + } + + function fetchMedia() { + var progress = $('
'); + + cameraSelect.hide(); + cameraSelect.before(progress); + cameraSelect.parent().find('div').remove(); /* remove any previous progress div */ + + var data = { + host: hostEntry.val(), + port: portEntry.val(), + username: usernameEntry.val(), + password: passwordEntry.val() + }; + + ajax('GET', '/config/list/', data, function (data) { + if (data == null || data.error) { + progress.remove(); + if (passwordEntry.val()) { /* only show an error message when a password is supplied */ + showErrorMessage(data && data.error); + } + + return; + } + + cameraSelect.html(''); + progress.remove(); + + if (data.error || !data.cameras) { + return; + } + + data.cameras.forEach(function (info) { + cameraSelect.append(''); + }); + + cameraSelect.show(); + }); + } + + showModalDialog(''); + + /* fetch the media list */ + ajax('GET', '/' + mediaType + '/' + cameraId + '/list/', null, function (data) { + if (data == null || data.error) { + hideModalDialog(); + showErrorMessage(data && data.error); + return; + } + + /* sort and group the media */ + var groups = {}; + data.mediaList.forEach(function (media) { + var path = media.path; + var parts = path.split('/'); + var keyParts = parts.splice(0, parts.length - 1); + var key = keyParts.join('/'); + var list = (groups[key] = groups[key] || []); + + list.push({ + 'path': path, + 'group': key, + 'name': parts[parts.length - 1], + 'cameraId': cameraId, + 'momentStr': media.momentStr, + 'timestamp': media.timestamp + }); + }); + + var keys = Object.keys(groups); + keys.sort(); + + keys.forEach(function (key) { + if (key) { + var groupDiv = $('
' + key + '
'); + dialogDiv.append(groupDiv); + } + + var entries = groups[key]; + entries.sortKey(function (e) {return e.timestamp;}); + + entries.forEach(function (entry, pos) { + var entryDiv = $('
'); + + var previewImg = $(''); + entryDiv.append(previewImg); + + var nameDiv = $('
' + entry.name + '
'); + entryDiv.append(nameDiv); + + var momentDiv = $('
' + entry.momentStr + '
'); + entryDiv.append(momentDiv); + entryDiv.click(function () { + if (mediaType === 'picture') { + runPictureDialog(entries, pos); + } + }); + + dialogDiv.append(entryDiv); + }); + }); + + /* scroll to bottom */ + dialogDiv.find('img.media-list-preview:last').load(function () { + dialogDiv.scrollTop(dialogDiv.prop('scrollHeight')); + }); + + var title; + if (mediaType === 'picture') { + title = 'Pictures taken by ' + data.cameraName; + } + else { + title = 'Movies recored by ' + data.cameraName; + } + + runModalDialog({ + title: title, + closeButton: true, + buttons: '', + content: dialogDiv, + onShow: function () { + dialogDiv.scrollTop(dialogDiv.prop('scrollHeight')); + } + }); + }); +} + + /* camera frames */ function addCameraFrameUi(cameraId, cameraName, framerate) { @@ -1223,8 +1451,10 @@ function addCameraFrameUi(cameraId, cameraName, framerate) { '
' + '' + '
' + + '
' + + '
' + '
' + - '
' + +// '
' + '
' + '
' + '
' + @@ -1237,6 +1467,8 @@ function addCameraFrameUi(cameraId, cameraName, framerate) { var nameSpan = cameraFrameDiv.find('span.camera-name'); var configureButton = cameraFrameDiv.find('div.camera-button.configure'); var fullScreenButton = cameraFrameDiv.find('div.camera-button.full-screen'); + var picturesButton = cameraFrameDiv.find('div.camera-button.media-pictures'); + var moviesButton = cameraFrameDiv.find('div.camera-button.media-movies'); var cameraPlaceholder = cameraFrameDiv.find('div.camera-placeholder'); var cameraProgress = cameraFrameDiv.find('div.camera-progress'); var cameraImg = cameraFrameDiv.find('img.camera'); @@ -1290,6 +1522,18 @@ function addCameraFrameUi(cameraId, cameraName, framerate) { }; }(cameraId)); + picturesButton.click(function (cameraId) { + return function () { + runMediaDialog(cameraId, 'picture'); + }; + }(cameraId)); + + moviesButton.click(function (cameraId) { + return function () { + runMediaDialog(cameraId, 'movie'); + }; + }(cameraId)); + /* error and load handlers */ cameraImg.error(function () { this.error = true; @@ -1420,6 +1664,10 @@ function doFullScreenCamera(cameraId) { var windowAspectRatio = windowWidth / windowHeight; var frameIndex = cameraFrameDiv.index(); var pageContainer = $('div.page-container'); + + if (frameImg.hasClass('error')) { + return; /* no full screen for erroneous cameras */ + } fullScreenCameraId = cameraId; diff --git a/static/js/ui.js b/static/js/ui.js index 67ab5e1..88198bb 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -1,4 +1,6 @@ +var _modalDialogContexts = []; + /* UI widgets */ @@ -525,28 +527,36 @@ function makeRegexValidator($input, regex, required) { /* modal dialog */ -function showModalDialog(content, onClose) { +function showModalDialog(content, onClose, onShow, stack) { var glass = $('div.modal-glass'); var container = $('div.modal-container'); if (container.is(':animated')) { return setTimeout(function () { - showModalDialog(content, onClose); + showModalDialog(content, onClose, onShow, stack); }, 100); } - if (container.is(':visible')) { + if (container.is(':visible') && stack) { /* the modal dialog is already visible, * we just replace the content */ - if (container[0]._onClose) { - container[0]._onClose(); - } + var children = container.children(':visible'); + _modalDialogContexts.push({ + children: children, + onClose: container[0]._onClose, + }); + + children.css('display', 'none'); - container[0]._onClose = onClose; /* remember the onClose handler */ - container.html(content); + container[0]._onClose = onClose; /* set the new onClose handler */ + container.append(content); updateModalDialogPosition(); + if (onShow) { + onShow(); + } + return; } @@ -559,6 +569,10 @@ function showModalDialog(content, onClose) { container.css('display', 'block'); updateModalDialogPosition(); container.animate({'opacity': '1'}, 200); + + if (onShow) { + onShow(); + } } function hideModalDialog() { @@ -571,6 +585,21 @@ function hideModalDialog() { }, 100); } + if (_modalDialogContexts.length) { + if (container[0]._onClose) { + container[0]._onClose(); + } + + container.children(':visible').remove(); + + var context = _modalDialogContexts.pop(); + context.children.css('display', ''); + container[0]._onClose = context.onClose; + updateModalDialogPosition(); + + return; + } + glass.animate({'opacity': '0'}, 200, function () { glass.css('display', 'none'); }); @@ -594,8 +623,8 @@ function updateModalDialogPosition() { var windowWidth = $(window).width(); var windowHeight = $(window).height(); - var modalWidth = container.width(); - var modalHeight = container.height(); + var modalWidth = container.width() + 10 /* the margins */; + var modalHeight = container.height() + 10 /* the margins */; container.css('left', Math.floor((windowWidth - modalWidth) / 2)); container.css('top', Math.floor((windowHeight - modalHeight) / 2)); @@ -624,11 +653,11 @@ function makeModalDialogButtons(buttonsInfo) { if (info.click) { var oldClick = info.click; info.click = function () { - hideModalDialog(); - if (oldClick() == false) { return; } + + hideModalDialog(); }; } else { @@ -681,6 +710,8 @@ function runModalDialog(options) { * * onOk: Function * * onCancel: Function * * onClose: Function + * * onShow: Function + * * stack: Boolean */ var content = $('
'); @@ -748,12 +779,14 @@ function runModalDialog(options) { } var handleKeyUp = function (e) { + if (!content.is(':visible')) { + return; + } + switch (e.which) { case 13: - if (defaultClick) { - if (defaultClick() == false) { - return; - }; + if (defaultClick && defaultClick() == false) { + return; } /* intentionally no break */ @@ -776,7 +809,8 @@ function runModalDialog(options) { $('html').bind('keyup', handleKeyUp); /* and finally, show the dialog */ - showModalDialog(content, onClose); + + showModalDialog(content, onClose, options.onShow, options.stack); /* focus the default button if nothing else is focused */ if (content.find('*:focus').length === 0) {