]> www.vanbest.org Git - motioneye-debian/commitdiff
- Initial version of movie playback via HTML5 video tags.
authorDermot Duffy <dermot.duffy@gmail.com>
Mon, 6 Jun 2016 19:23:17 +0000 (20:23 +0100)
committerDermot Duffy <dermot.duffy@gmail.com>
Mon, 6 Jun 2016 19:23:17 +0000 (20:23 +0100)
motioneye/handlers.py
motioneye/remote.py
motioneye/static/css/main.css
motioneye/static/js/main.js

index 7a3ea78698fdac7620eb4c2b01399026382c527f..1d6d17ebbdb8ec06766b6a1ed9e609e5dbe199a0 100644 (file)
 import datetime
 import json
 import logging
+import mimetypes
 import os
 import re
 import socket
 import subprocess
 
+from tornado import httputil
 from tornado.ioloop import IOLoop
-from tornado.web import RequestHandler, HTTPError, asynchronous
+from tornado.web import RequestHandler, StaticFileHandler, HTTPError, asynchronous
 
 import config
 import mediafiles
@@ -977,7 +979,7 @@ class PictureHandler(BaseHandler):
                 self.set_header('Content-Type', 'image/jpeg')
                 self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';')
                 
-                self.finish(response)
+                self.finish(response.body)
 
             remote.get_media_content(camera_config, filename=filename, media_type='picture', callback=on_response)
 
@@ -1338,28 +1340,74 @@ class MovieHandler(BaseHandler):
                 'filename': filename, 'id': camera_id})
         
         camera_config = config.get_camera(camera_id)
+
+        # To facilitiate cross-browser HTML5 <video> playback we need
+        # to support HTTP Range requests.
+        # (The below adapted from Tornado's StaticFileHandler)
+        request_range = None
+        range_header = self.request.headers.get("Range")
+        if range_header:
+            request_range = httputil._parse_request_range(range_header)
+
         if utils.local_motion_camera(camera_config):
-            content = mediafiles.get_media_content(camera_config, filename, 'movie')
-            
+            full_path = os.path.join(camera_config.get('target_dir'), filename)
+            size = os.stat(full_path).st_size
+            if request_range:
+                start, end = request_range
+                if (start is not None and start >= size) or end == 0:
+                    raise HTTPError(416, 'Range not satisfiable')
+                if start is not None and start < 0:
+                    start += size
+                if end is not None and end > size:
+                    end = size
+                # Chrome won't allow seeking unless we always return 206 for Range requests,
+                # even if this represents the entire file.
+                self.set_status(206)  # Partial Content
+                self.set_header("Content-Range", httputil._get_content_range(start, end, size))
+            else:
+                start = end = None
+
+            if start is not None and end is not None:
+                content_length = end - start
+            elif end is not None:
+                content_length = end
+            elif start is not None:
+                content_length = size - start
+            else:
+                content_length = size
+
             pretty_filename = camera_config['@name'] + '_' + os.path.basename(filename)
-            self.set_header('Content-Type', 'video/mpeg')
+            self.set_header('Content-Type', mimetypes.guess_type(full_path)[0] or 'video/mpeg')
             self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';')
-            
-            self.finish(content)
-        
+            self.set_header("Content-Length", content_length)
+
+
+            content = StaticFileHandler.get_content(full_path, start, end)
+            if content:           
+                for chunk in content:
+                    try:
+                        self.write(chunk)
+                        self.flush()
+                    except iostream.StreamClosedError:
+                        return
+            self.finish()
+
         elif utils.remote_camera(camera_config):
             def on_response(response=None, error=None):
                 if error:
                     return self.finish_json({'error': 'Failed to download movie from %(url)s: %(msg)s.' % {
                             'url': remote.pretty_camera_url(camera_config), 'msg': error}})
 
-                pretty_filename = os.path.basename(filename) # no camera name available w/o additional request
-                self.set_header('Content-Type', 'video/mpeg')
-                self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';')
-                
-                self.finish(response)
+                self.set_status(response.code)
+                for header in ('Content-Type', 'Content-Range', 'Content-Length', 'Content-Disposition'):
+                    if header in response.headers:
+                        self.set_header(header, response.headers[header])
+                self.finish(response.body)
 
-            remote.get_media_content(camera_config, filename=filename, media_type='movie', callback=on_response)
+            start = end = None
+            if request_range:
+                start, end = request_range
+            remote.get_media_content(camera_config, filename=filename, media_type='movie', callback=on_response, start=start, end=end)
 
         else: # assuming simple mjpeg camera
             raise HTTPError(400, 'unknown operation')
index d2ddb786e2ce9cb09dbd97556c340efd08829ae9..2d1757bcf37c047e40e052c66320dbb17151a359 100644 (file)
@@ -379,7 +379,7 @@ def list_media(local_config, media_type, prefix, callback):
     http_client.fetch(request, _callback_wrapper(on_response))
 
 
-def get_media_content(local_config, filename, media_type, callback):
+def get_media_content(local_config, filename, media_type, callback, start=None, end=None):
     scheme, host, port, username, password, path, camera_id = _remote_params(local_config)
     
     logging.debug('downloading file %(filename)s of remote camera %(id)s on %(url)s' % {
@@ -391,11 +391,18 @@ def get_media_content(local_config, filename, media_type, callback):
             'media_type': media_type,
             'id': camera_id,
             'filename': filename}
-    
     # timeout here is 10 times larger than usual - we expect a big delay when fetching the media list
     request = _make_request(scheme, host, port, username, password,
             path, timeout=10 * settings.REMOTE_REQUEST_TIMEOUT)
-    
+   
+    if start is not None or end is not None:
+        end_str = ''
+        start = start or 0
+        if end:
+            end_str = str(end - 1)
+        request.headers['Range'] = 'bytes=%i-%s' % (start, end_str)
     def on_response(response):
         if response.error:
             logging.error('failed to download file %(filename)s of remote camera %(id)s on %(url)s: %(msg)s' % {
@@ -406,7 +413,7 @@ def get_media_content(local_config, filename, media_type, callback):
             
             return callback(error=utils.pretty_http_error(response))
         
-        return callback(response.body)
+        return callback(response)
 
     http_client = AsyncHTTPClient()
     http_client.fetch(request, _callback_wrapper(on_response))
index 2ee7404fa561d8f32b165d8cbc0394013bf66d33..9664d1d53d93ef24482436b38e6de06d33044d92 100644 (file)
@@ -746,11 +746,11 @@ div.picture-dialog-content {
 }
 
 div.picture-dialog-prev-arrow,
-div.picture-dialog-next-arrow {
+div.picture-dialog-next-arrow,
+div.picture-dialog-play {
     position: absolute;
     top: 45%;
     background-color: rgba(0, 0, 0, 0.6);
-    background-image: url(../img/arrows.svg);
     background-size: cover;
     width: 3em;
     height: 3em;
@@ -761,6 +761,19 @@ div.picture-dialog-next-arrow {
     cursor: pointer;
 }
 
+div.picture-dialog-prev-arrow,
+div.picture-dialog-next-arrow {
+    background-image: url(../img/arrows.svg);
+}
+
+div.picture-dialog-play {
+    background-image: url(../img/camera-top-buttons.svg);
+    background-position: -300% 0px;
+    left: 50%;
+    right: 50%;
+    transform: translate(-1.5em,0);
+}
+
 div.picture-dialog-prev-arrow {
     left: 1em;
 }
index f1fc9f058472c719b93fb55aae7f4d2efce32986..3c1fa13c3d336bfc1fffb78da310c64c09e8a310 100644 (file)
@@ -2919,15 +2919,23 @@ function runPictureDialog(entries, pos, mediaType) {
     
     var img = $('<img class="picture-dialog-content">');
     content.append(img);
-    
+
+    var video_container = $('<video class="picture-dialog-content" controls="true">');
+    var video_source = $('<source type="video/mp4">')
+    video_container.append(video_source);
+    video_container.hide()
+    content.append(video_container);
     var prevArrow = $('<div class="picture-dialog-prev-arrow button mouse-effect" title="previous picture"></div>');
     content.append(prevArrow);
-    
+
+    var playButton = $('<div class="picture-dialog-play button mouse-effect" title="play"></div>');  
+    content.append(playButton);
+
     var nextArrow = $('<div class="picture-dialog-next-arrow button mouse-effect" title="next picture"></div>');
     content.append(nextArrow);
-    
     var progressImg = $('<img class="picture-dialog-progress" src="' + staticPath + 'img/modal-progress.gif">');
-    
+
     function updatePicture() {
         var entry = entries[pos];
 
@@ -2941,12 +2949,34 @@ function runPictureDialog(entries, pos, mediaType) {
         
         prevArrow.css('display', 'none');
         nextArrow.css('display', 'none');
+
+        var playable = video_container.get(0).canPlayType('video/' + entry.path.split('.').pop()) != ''
+        playButton.hide();
+        video_container.hide();
+        img.show();
+
         img.parent().append(progressImg);
         updateModalDialogPosition();
         progressImg.css('left', (img.parent().width() - progressImg.width()) / 2);
         progressImg.css('top', (img.parent().height() - progressImg.height()) / 2);
         
         img.attr('src', addAuthParams('GET', basePath + mediaType + '/' + entry.cameraId + '/preview' + entry.path));
+
+        if (playable) {
+         playButton.on('click', function() {
+            video_source.attr('src', addAuthParams('GET', basePath + mediaType + '/' + entry.cameraId + '/download' + entry.path));
+            video_container.show();
+            video_container.get(0).load();
+            img.hide();
+            playButton.hide();
+            video_container.on('canplay', function() {
+              video_container.get(0).play();
+            });
+          });
+
+          playButton.show();
+        }
+
         img.load(function () {
             var aspectRatio = this.naturalWidth / this.naturalHeight;
             var sizeWidth = width * width / aspectRatio;
@@ -2954,16 +2984,18 @@ function runPictureDialog(entries, pos, mediaType) {
             
             if (sizeWidth < sizeHeight) {
                 img.width(width);
+                video_container.width(width);
             }
             else {
                 img.height(height);
+                video_container.height(height);
             }
             updateModalDialogPosition();
             prevArrow.css('display', pos > 0 ? '' : 'none');
             nextArrow.css('display', pos < entries.length - 1 ? '' : 'none');
             progressImg.remove();
         });
-        
+
         $('div.modal-container').find('span.modal-title:last').html(entry.name);
         updateModalDialogPosition();
     }