diff --git a/apps/camera/js/filmstrip.js b/apps/camera/js/filmstrip.js index d4288010fd8e..86ec289a3de6 100644 --- a/apps/camera/js/filmstrip.js +++ b/apps/camera/js/filmstrip.js @@ -116,7 +116,9 @@ var Filmstrip = (function() { frame.displayImage(item.blob, item.width, item.height, item.preview); } else if (item.isVideo) { - frame.displayVideo(item.blob, item.width, item.height, item.rotation); + frame.displayVideo(item.blob, item.poster, + item.width, item.height, + item.rotation); } preview.classList.remove('offscreen'); @@ -328,18 +330,17 @@ var Filmstrip = (function() { offscreenImage.src = URL.createObjectURL(previewBlob); offscreenImage.onload = function() { - createThumbnailFromElement(offscreenImage, false, 0, - function(thumbnail) { - addItem({ - isImage: true, - filename: filename, - thumbnail: thumbnail, - blob: blob, - width: metadata.width, - height: metadata.height, - preview: metadata.preview - }); - }); + createThumbnailFromImage(offscreenImage, function(thumbnail) { + addItem({ + isImage: true, + filename: filename, + thumbnail: thumbnail, + blob: blob, + width: metadata.width, + height: metadata.height, + preview: metadata.preview + }); + }); URL.revokeObjectURL(offscreenImage.src); offscreenImage.onload = null; offscreenImage.src = null; @@ -378,18 +379,19 @@ var Filmstrip = (function() { } offscreenVideo.onloadedmetadata = function() { - createThumbnailFromElement(offscreenVideo, true, rotation, - function(thumbnail) { - addItem({ - isVideo: true, - filename: filename, - thumbnail: thumbnail, - blob: blob, - width: offscreenVideo.videoWidth, - height: offscreenVideo.videoHeight, - rotation: rotation - }); + createThumbnailFromVideo(offscreenVideo, rotation, filename, + function(thumbnail, poster) { + addItem({ + isVideo: true, + filename: filename, + thumbnail: thumbnail, + poster: poster, + blob: blob, + width: offscreenVideo.videoWidth, + height: offscreenVideo.videoHeight, + rotation: rotation }); + }); URL.revokeObjectURL(url); offscreenVideo.onerror = null; offscreenVideo.onloadedmetadata = null; @@ -444,16 +446,16 @@ var Filmstrip = (function() { // Create a thumbnail size canvas, copy the or into it // cropping the edges as needed to make it fit, and then extract the // thumbnail image as a blob and pass it to the callback. - function createThumbnailFromElement(elt, video, rotation, callback) { + function createThumbnailFromImage(img, callback) { // Create a thumbnail image var canvas = document.createElement('canvas'); var context = canvas.getContext('2d'); canvas.width = THUMBNAIL_WIDTH; canvas.height = THUMBNAIL_HEIGHT; - var eltwidth = video ? elt.videoWidth : elt.width; - var eltheight = video ? elt.videoHeight : elt.height; - var scalex = canvas.width / eltwidth; - var scaley = canvas.height / eltheight; + var imgwidth = img.width; + var imgheight = img.height; + var scalex = canvas.width / imgwidth; + var scaley = canvas.height / imgheight; // Take the larger of the two scales: we crop the image to the thumbnail var scale = Math.max(scalex, scaley); @@ -462,69 +464,116 @@ var Filmstrip = (function() { // canvas to create the thumbnail var w = Math.round(THUMBNAIL_WIDTH / scale); var h = Math.round(THUMBNAIL_HEIGHT / scale); - var x = Math.round((eltwidth - w) / 2); - var y = Math.round((eltheight - h) / 2); + var x = Math.round((imgwidth - w) / 2); + var y = Math.round((imgheight - h) / 2); + + // Draw that region of the image into the canvas, scaling it down + context.drawImage(img, x, y, w, h, + 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); + + canvas.toBlob(callback, 'image/jpeg'); + } + + // Create a thumbnail size canvas, copy the or into it + // cropping the edges as needed to make it fit, and then extract the + // thumbnail image as a blob and pass it to the callback. + function createThumbnailFromVideo(video, rotation, filename, callback) { + var videowidth = video.videoWidth; + var videoheight = video.videoHeight; + + // First, create a full-size unrotated poster image + var postercanvas = document.createElement('canvas'); + var postercontext = postercanvas.getContext('2d'); + postercanvas.width = videowidth; + postercanvas.height = videoheight; + postercontext.drawImage(video, 0, 0); + + // Now create a thumbnail + var thumbnailcanvas = document.createElement('canvas'); + var thumbnailcontext = thumbnailcanvas.getContext('2d'); + thumbnailcanvas.width = THUMBNAIL_WIDTH; + thumbnailcanvas.height = THUMBNAIL_HEIGHT; + + var scalex = THUMBNAIL_WIDTH / videowidth; + var scaley = THUMBNAIL_HEIGHT / videoheight; + + // Take the larger of the two scales: we crop the image to the thumbnail + var scale = Math.max(scalex, scaley); + + // Calculate the region of the image that will be copied to the + // canvas to create the thumbnail + var w = Math.round(THUMBNAIL_WIDTH / scale); + var h = Math.round(THUMBNAIL_HEIGHT / scale); + var x = Math.round((videowidth - w) / 2); + var y = Math.round((videoheight - h) / 2); // If a rotation is specified, rotate the canvas context if (rotation) { - context.save(); + thumbnailcontext.save(); switch (rotation) { case 90: - context.translate(THUMBNAIL_WIDTH, 0); - context.rotate(Math.PI / 2); + thumbnailcontext.translate(THUMBNAIL_WIDTH, 0); + thumbnailcontext.rotate(Math.PI / 2); break; case 180: - context.translate(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); - context.rotate(Math.PI); + thumbnailcontext.translate(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); + thumbnailcontext.rotate(Math.PI); break; case 270: - context.translate(0, THUMBNAIL_HEIGHT); - context.rotate(-Math.PI / 2); + thumbnailcontext.translate(0, THUMBNAIL_HEIGHT); + thumbnailcontext.rotate(-Math.PI / 2); break; } } - // Draw that region of the image into the canvas, scaling it down - context.drawImage(elt, x, y, w, h, - 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); + // Draw that region of the poster into the thumbnail, scaling it down + thumbnailcontext.drawImage(postercanvas, x, y, w, h, + 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); // Restore the default rotation so the play arrow comes out correctly if (rotation) { - context.restore(); + thumbnailcontext.restore(); } // If this is a video, superimpose a translucent play button over - // the captured video frame to distinguish it from a still photo thumbnail - if (video) { - // First draw a transparent gray circle - context.fillStyle = 'rgba(0, 0, 0, .3)'; - context.beginPath(); - context.arc(THUMBNAIL_WIDTH / 2, THUMBNAIL_HEIGHT / 2, - THUMBNAIL_HEIGHT / 3, 0, 2 * Math.PI, false); - context.fill(); - - // Now outline the circle in white - context.strokeStyle = 'rgba(255,255,255,.6)'; - context.lineWidth = 2; - context.stroke(); - - // And add a white play arrow. - context.beginPath(); - context.fillStyle = 'rgba(255,255,255,.6)'; - // The height of an equilateral triangle is sqrt(3)/2 times the side - var side = THUMBNAIL_HEIGHT / 3; - var triangle_height = side * Math.sqrt(3) / 2; - context.moveTo(THUMBNAIL_WIDTH / 2 + triangle_height * 2 / 3, - THUMBNAIL_HEIGHT / 2); - context.lineTo(THUMBNAIL_WIDTH / 2 - triangle_height / 3, - THUMBNAIL_HEIGHT / 2 - side / 2); - context.lineTo(THUMBNAIL_WIDTH / 2 - triangle_height / 3, - THUMBNAIL_HEIGHT / 2 + side / 2); - context.closePath(); - context.fill(); + // the captured video frame to distinguish it from a still photo + // thumbnail. First draw a transparent gray circle + thumbnailcontext.fillStyle = 'rgba(0, 0, 0, .3)'; + thumbnailcontext.beginPath(); + thumbnailcontext.arc(THUMBNAIL_WIDTH / 2, THUMBNAIL_HEIGHT / 2, + THUMBNAIL_HEIGHT / 3, 0, 2 * Math.PI, false); + thumbnailcontext.fill(); + + // Now outline the circle in white + thumbnailcontext.strokeStyle = 'rgba(255,255,255,.6)'; + thumbnailcontext.lineWidth = 2; + thumbnailcontext.stroke(); + + // And add a white play arrow. + thumbnailcontext.beginPath(); + thumbnailcontext.fillStyle = 'rgba(255,255,255,.6)'; + // The height of an equilateral triangle is sqrt(3)/2 times the side + var side = THUMBNAIL_HEIGHT / 3; + var triangle_height = side * Math.sqrt(3) / 2; + thumbnailcontext.moveTo(THUMBNAIL_WIDTH / 2 + triangle_height * 2 / 3, + THUMBNAIL_HEIGHT / 2); + thumbnailcontext.lineTo(THUMBNAIL_WIDTH / 2 - triangle_height / 3, + THUMBNAIL_HEIGHT / 2 - side / 2); + thumbnailcontext.lineTo(THUMBNAIL_WIDTH / 2 - triangle_height / 3, + THUMBNAIL_HEIGHT / 2 + side / 2); + thumbnailcontext.closePath(); + thumbnailcontext.fill(); + + // Save the poster image to storage, then call the callback + postercanvas.toBlob(savePosterImage, 'image/jpeg'); + + // The Gallery app depends on this poster image being saved here + function savePosterImage(poster) { + Camera._pictureStorage.addNamed(poster, filename.replace('.3gp', '.jpg')); + thumbnailcanvas.toBlob(function(thumbnail) { + callback(thumbnail, poster); + }, 'image/jpeg'); } - - canvas.toBlob(callback, 'image/jpeg'); } function setOrientation(orientation) { diff --git a/apps/camera/style/VideoPlayer.css b/apps/camera/style/VideoPlayer.css index bc2b23c006f8..4ed95ddb6d6d 100644 --- a/apps/camera/style/VideoPlayer.css +++ b/apps/camera/style/VideoPlayer.css @@ -1,5 +1,5 @@ /* styles for the video element itself */ -.videoPlayer { +.videoPoster, .videoPlayer { position: absolute; left: 0; /* we position it with a transform */ top:0; diff --git a/apps/gallery/js/MetadataParser.js b/apps/gallery/js/MetadataParser.js index 59802ee6e6ad..1f02c10441bb 100644 --- a/apps/gallery/js/MetadataParser.js +++ b/apps/gallery/js/MetadataParser.js @@ -7,7 +7,7 @@ // // This file depends on JPEGMetadataParser.js and blobview.js // -var metadataParsers = (function() { +var metadataParser = (function() { // If we generate our own thumbnails, aim for this size var THUMBNAIL_WIDTH = 120; var THUMBNAIL_HEIGHT = 120; @@ -18,9 +18,8 @@ var metadataParsers = (function() { // Don't try to open images with more pixels than this var MAX_IMAGE_PIXEL_SIZE = 5 * 1024 * 1024; // 5 megapixels - // and elements for loading images and videos + // An element for loading images var offscreenImage = new Image(); - var offscreenVideo = document.createElement('video'); // Create a thumbnail size canvas, copy the or into it // cropping the edges as needed to make it fit, and then extract the @@ -32,8 +31,8 @@ var metadataParsers = (function() { var context = canvas.getContext('2d'); canvas.width = THUMBNAIL_WIDTH; canvas.height = THUMBNAIL_HEIGHT; - var eltwidth = video ? elt.videoWidth : elt.width; - var eltheight = video ? elt.videoHeight : elt.height; + var eltwidth = elt.width; + var eltheight = elt.height; var scalex = canvas.width / eltwidth; var scaley = canvas.height / eltheight; @@ -106,17 +105,19 @@ var metadataParsers = (function() { context.fill(); } - canvas.toBlob(function(blob) { - // This setTimeout is here in the hopes that it gives gecko a bit - // of time to release the memory that holds the decoded image before - // we start creating the next thumbnail. - setTimeout(function() { - callback(blob); - }); - }, 'image/jpeg'); + canvas.toBlob(callback, 'image/jpeg'); } - function imageMetadataParser(file, metadataCallback, metadataError) { + var VIDEOFILE = /DCIM\/\d{3}MZLLA\/VID_\d{4}\.jpg/; + + function metadataParser(file, metadataCallback, metadataError) { + // If the file is a poster image for a video file, then we've want + // video metadata, not image metadata + if (VIDEOFILE.test(file.name)) { + videoMetadataParser(file, metadataCallback, metadataError); + return; + } + if (file.type !== 'image/jpeg') { // For any kind of image other than JPEG, we just have to get // our metadata with an tag @@ -228,76 +229,54 @@ var metadataParsers = (function() { } function videoMetadataParser(file, metadataCallback, errorCallback) { - try { - if (file.type && !offscreenVideo.canPlayType(file.type)) { - errorCallback("can't play video file type: " + file.type); - return; - } + var metadata = {}; + var videofilename = file.name.replace('.jpg', '.3gp'); + metadata.video = videofilename; + var getreq = videostorage.get(videofilename); + getreq.onerror = function() { + errorCallback('cannot get video file: ' + videofilename); + } + getreq.onsuccess = function() { + var videofile = getreq.result; + getVideoRotation(videofile, function(rotation) { + if (typeof rotation === 'number') { + metadata.rotation = rotation; + getVideoThumbnailAndSize(); + } + else if (typeof rotation === 'string') { + errorCallback('Video rotation:', rotation); + } + }); + } + + function getVideoThumbnailAndSize() { var url = URL.createObjectURL(file); + offscreenImage.src = url; - offscreenVideo.preload = 'metadata'; - offscreenVideo.style.width = THUMBNAIL_WIDTH + 'px'; - offscreenVideo.style.height = THUMBNAIL_HEIGHT + 'px'; - offscreenVideo.src = url; + offscreenImage.onerror = function() { + URL.revokeObjectURL(url); + offscreenImage.removeAttribute('src'); + errorCallback('getVideoThumanailAndSize: Image failed to load'); + }; - offscreenVideo.onerror = function() { + offscreenImage.onload = function() { URL.revokeObjectURL(url); - offscreenVideo.onerror = null; - offscreenVideo.src = null; - errorCallback('not a video file'); - } - offscreenVideo.onloadedmetadata = function() { - var metadata = {}; - metadata.video = true; - metadata.duration = offscreenVideo.duration; - metadata.width = offscreenVideo.videoWidth; - metadata.height = offscreenVideo.videoHeight; - metadata.rotation = 0; - - // If this is a .3gp video file, look for its rotation matrix and - // then create the thumbnail. Otherwise set rotation to 0 and - // create the thumbnail. - // getVideoRotation is defined in shared/js/media/get_video_rotation.js - if (file.name.substring(file.name.lastIndexOf('.') + 1) === '3gp') { - getVideoRotation(file, function(rotation) { - if (typeof rotation === 'number') - metadata.rotation = rotation; - else if (typeof rotation === 'string') - console.warn('Video rotation:', rotation); - createThumbnail(); - }); - } - else { - createThumbnail(); - } + // We store the unrotated size of the poster image, which we + // require to have the same size and rotation as the video + metadata.width = offscreenImage.width; + metadata.height = offscreenImage.height; - function createThumbnail() { - offscreenVideo.currentTime = 1; // read 1 second into video - offscreenVideo.onseeked = function onseeked() { - createThumbnailFromElement(offscreenVideo, true, metadata.rotation, - function(thumbnail) { - URL.revokeObjectURL(url); - offscreenVideo.onerror = null; - offscreenVideo.onseeked = null; - offscreenVideo.removeAttribute('src'); - offscreenVideo.load(); - metadata.thumbnail = thumbnail; - metadataCallback(metadata); - }); - }; - } + createThumbnailFromElement(offscreenImage, true, metadata.rotation, + function(thumbnail) { + metadata.thumbnail = thumbnail; + offscreenImage.removeAttribute('src'); + metadataCallback(metadata); + }); }; } - catch (e) { - console.error('Exception in videoMetadataParser', e, e.stack); - errorCallback('Exception in videoMetadataParser'); - } } - return { - imageMetadataParser: imageMetadataParser, - videoMetadataParser: videoMetadataParser - }; + return metadataParser; }()); diff --git a/apps/gallery/js/frames.js b/apps/gallery/js/frames.js index 7b3d8fca8177..3eea70071d1c 100644 --- a/apps/gallery/js/frames.js +++ b/apps/gallery/js/frames.js @@ -293,22 +293,25 @@ function setupFrameContent(n, frame) { // Remember what file we're going to display frame.filename = fileinfo.name; - if (fileinfo.metadata.video) { - videodb.getFile(fileinfo.name, function(file) { - frame.displayVideo(file, - fileinfo.metadata.width, - fileinfo.metadata.height, - fileinfo.metadata.rotation || 0); - }); - } - else { - photodb.getFile(fileinfo.name, function(file) { - frame.displayImage(file, + photodb.getFile(fileinfo.name, function(imagefile) { + if (fileinfo.metadata.video) { + // If this is a video, then the file we just got is the poster image + // and we still have to fetch the actual video + getVideoFile(fileinfo.metadata.video, function(videofile) { + frame.displayVideo(videofile, imagefile, + fileinfo.metadata.width, + fileinfo.metadata.height, + fileinfo.metadata.rotation || 0); + }); + } + else { + // Otherwise, just display the image + frame.displayImage(imagefile, fileinfo.metadata.width, fileinfo.metadata.height, fileinfo.metadata.preview); - }); - } + } + }); } var FRAME_BORDER_WIDTH = 3; @@ -356,9 +359,10 @@ function nextFile(time) { if (currentFileIndex === files.length - 1) return; - // Don't pan a playing video! - if (currentFrame.displayingVideo && !currentFrame.video.player.paused) - currentFrame.video.pause(); + // If the current frame is using a element instead of just + // displaying a poster image, reset it back to just the image + if (currentFrame.displayingVideo && currentFrame.video.playerShowing) + currentFrame.video.init(); // Set a flag to ignore pan and zoom gestures during the transition. transitioning = true; @@ -405,9 +409,10 @@ function previousFile(time) { if (currentFileIndex === 0) return; - // Don't pan a playing video! - if (currentFrame.displayingVideo && !currentFrame.video.player.paused) - currentFrame.video.pause(); + // If the current frame is using a element instead of just + // displaying a poster image, reset it back to just the image. + if (currentFrame.displayingVideo && currentFrame.video.playerShowing) + currentFrame.video.init(); // Set a flag to ignore pan and zoom gestures during the transition. transitioning = true; diff --git a/apps/gallery/js/gallery.js b/apps/gallery/js/gallery.js index f816f3936087..c07ee3bb2094 100644 --- a/apps/gallery/js/gallery.js +++ b/apps/gallery/js/gallery.js @@ -82,9 +82,13 @@ var editedPhotoIndex; var selectedFileNames = []; var selectedFileNamesToBlobs = {}; -// The MediaDB objects that manage the filesystem and the database of metadata -// See init() -var photodb, videodb; +// The MediaDB object that manages the filesystem and the database of metadata +var photodb; + +// We manage videos through their poster images, which are photos and so get +// listed in the photodb above. But when we need to access the actual video +// file, we have to get that from a device storage object for videos. +var videostorage; var visibilityMonitor; @@ -175,7 +179,7 @@ function init() { // Initialize MediaDB objects for photos and videos, and set up their // event handlers. function initDB(include_videos) { - photodb = new MediaDB('pictures', imageMetadataParser, { + photodb = new MediaDB('pictures', metadataParserWrapper, { mimeTypes: ['image/jpeg', 'image/png'], version: 2, autoscan: false, // We're going to call scan() explicitly @@ -184,40 +188,19 @@ function initDB(include_videos) { }); if (include_videos) { - // For videos, this app is only interested in files under DCIM/. - videodb = new MediaDB('videos', videoMetadataParser, { - directory: 'DCIM/', - autoscan: false, // We're going to call scan() explicitly - batchHoldTime: 150, // Batch files during scanning - batchSize: PAGE_SIZE // Max batch size: one screenful - }); - } - else { - videodb = null; + videostorage = navigator.getDeviceStorage('videos'); } var loaded = false; - function imageMetadataParser(file, onsuccess, onerror) { - if (loaded) { - metadataParsers.imageMetadataParser(file, onsuccess, onerror); - return; - } - - loadScript('js/metadata_scripts.js', function() { - loaded = true; - metadataParsers.imageMetadataParser(file, onsuccess, onerror); - }); - } - - function videoMetadataParser(file, onsuccess, onerror) { + function metadataParserWrapper(file, onsuccess, onerror) { if (loaded) { - metadataParsers.videoMetadataParser(file, onsuccess, onerror); + metadataParser(file, onsuccess, onerror); return; } loadScript('js/metadata_scripts.js', function() { loaded = true; - metadataParsers.videoMetadataParser(file, onsuccess, onerror); + metadataParser(file, onsuccess, onerror); }); } @@ -239,46 +222,19 @@ function initDB(include_videos) { if (currentOverlay === 'nocard' || currentOverlay === 'pluggedin') showOverlay(null); - // If we're including videos also, be sure that they are ready - if (include_videos) { - if (videodb.state === MediaDB.READY) - initThumbnails(); - } - else { - initThumbnails(); - } + initThumbnails(include_videos); }; - if (include_videos) { - videodb.onready = function() { - // If the photodb is also ready, create thumbnails. - // Depending on the order of the ready events, either this code - // or the code above will fire and set up the thumbnails - if (photodb.state === MediaDB.READY) - initThumbnails(); - }; - } - - // When the mediadbs are scanning, let the user know. We count scan starts - // and ends so we correctly display the throbber while either db is scanning. - var scanning = 0; - photodb.onscanstart = function onscanstart() { - scanning++; - if (scanning == 1) { - // Show the scanning indicator - $('progress').classList.remove('hidden'); - $('throbber').classList.add('throb'); - } + // Show the scanning indicator + $('progress').classList.remove('hidden'); + $('throbber').classList.add('throb'); }; photodb.onscanend = function onscanend() { - scanning--; - if (scanning == 0) { - // Hide the scanning indicator - $('progress').classList.add('hidden'); - $('throbber').classList.remove('throb'); - } + // Hide the scanning indicator + $('progress').classList.add('hidden'); + $('throbber').classList.remove('throb'); }; // One or more files was created (or was just discovered by a scan) @@ -290,12 +246,17 @@ function initDB(include_videos) { photodb.ondeleted = function(event) { event.detail.forEach(fileDeleted); }; +} - if (include_videos) { - videodb.onscanstart = photodb.onscanstart; - videodb.onscanend = photodb.onscanend; - videodb.oncreated = photodb.oncreated; - videodb.ondeleted = photodb.ondeleted; +// Pass the filename of the poster image and get the video file back +function getVideoFile(filename, callback) { + // We get videos directly through the video device storage + var req = videostorage.get(filename); + req.onsuccess = function() { + callback(req.result); + }; + req.onerror = function() { + console.error('Failed to get video file', filename); } } @@ -310,7 +271,7 @@ function compareFilesByDate(a, b) { } // -// Enumerate existing entries in the photo and video databases in reverse +// Enumerate existing entries in the media database in reverse // chronological order (most recent first) and display thumbnails for them all. // After the thumbnails are displayed, scan for new files. // @@ -318,7 +279,7 @@ function compareFilesByDate(a, b) { // when the sdcard becomes available again after a USB mass storage // session or an sdcard replacement. // -function initThumbnails() { +function initThumbnails(include_videos) { // If we've already been called once, then we've already got thumbnails // displayed. There is no need to re-enumerate them, so we just go // straight to scanning for new files @@ -343,63 +304,29 @@ function initThumbnails() { // from most recent to least recent. // Temporary arrays to hold enumerated files - var photos = [], videos = [], interleaved = []; + var batch = []; var batchsize = PAGE_SIZE; photodb.enumerate('date', null, 'prev', function(fileinfo) { - photos.push(fileinfo); - merge(); - }); - - if (videodb) { - videodb.enumerate('date', null, 'prev', function(fileinfo) { - videos.push(fileinfo); - merge(); - }); - } - else { - videos.push(null); // This means we're done enumerating videos - } - - // Create thumbnails for as many of the files in the photos and videos arrays - // as we can. This is the tricky bit of the algorithm for ensuring that - // they are sorted by date - function merge() { - // If we don't have at least one of each, we don't know what the newest is - while (photos.length > 0 && videos.length > 0) { - if (photos[0] === null && videos[0] === null) { - // Both enumerations are done - done(); - break; - } - - // If we've finished enumerating photos, then videos[0] is next - if (photos[0] === null) { - batch(videos.shift()); - } - else if (videos[0] === null) { - batch(photos.shift()); - } - else if (videos[0].date > photos[0].date) { - batch(videos.shift()); - } - else { - batch(photos.shift()); + if (fileinfo) { + // For a pick activity, don't display videos + if (!include_videos && fileinfo.metadata.video) + return; + + batch.push(fileinfo); + if (batch.length >= batchsize) { + flush(); + batchsize *= 2; } } - } - - function batch(fileinfo) { - interleaved.push(fileinfo); - if (interleaved.length >= batchsize) { - flush(); - batchsize *= 2; + else { + done(); } - } + }); function flush() { - interleaved.forEach(thumb); - interleaved.length = 0; + batch.forEach(thumb); + batch.length = 0; } function thumb(fileinfo) { @@ -415,16 +342,10 @@ function initThumbnails() { } // Now that we've enumerated all the photos and videos we already know // about go start looking for new photos and videos. - scan(); + photodb.scan(); } } -function scan() { - photodb.scan(); - if (videodb) - videodb.scan(); -} - function fileDeleted(filename) { // Find the deleted file in our files array for (var n = 0; n < files.length; n++) { @@ -485,10 +406,13 @@ function deleteFile(n) { // deletes the file in device storage. This will generate an change // event which will call imageDeleted() var fileinfo = files[n]; - if (fileinfo.metadata.video) - videodb.deleteFile(fileinfo.name); - else - photodb.deleteFile(files[n].name); + photodb.deleteFile(files[n].name); + + // If it is a video, however, we can't just delete the poster image, but + // must also delete the video file. + if (fileinfo.metadata.video) { + videostorage.delete(fileinfo.metadata.video); + } } function fileCreated(fileinfo) { @@ -822,10 +746,17 @@ function updateSelection(thumbnail) { if (selected) { selectedFileNames.push(filename); - var db = files[index].metadata.video ? videodb : photodb; - db.getFile(filename, function(file) { - selectedFileNamesToBlobs[filename] = file; - }); + if (files[index].metadata.video) { + getVideoFile(files[index].metadata.video, function(file) { + selectedFileNamesToBlobs[filename] = file; + }); + } + else { + // We get photos through the photo db + photodb.getFile(filename, function(file) { + selectedFileNamesToBlobs[filename] = file; + }); + } } else { delete selectedFileNamesToBlobs[filename]; diff --git a/apps/gallery/manifest.webapp b/apps/gallery/manifest.webapp index 0fa926047f86..0c21b801468d 100644 --- a/apps/gallery/manifest.webapp +++ b/apps/gallery/manifest.webapp @@ -11,6 +11,7 @@ "permissions": { "device-storage:pictures":{ "access": "readwrite" }, "device-storage:videos":{ "access": "readwrite" }, + "deprecated-hwvideo":{}, "audio-channel-content":{}, "settings":{ "access": "readonly" } }, diff --git a/apps/gallery/style/VideoPlayer.css b/apps/gallery/style/VideoPlayer.css index bc2b23c006f8..5952c97baa32 100644 --- a/apps/gallery/style/VideoPlayer.css +++ b/apps/gallery/style/VideoPlayer.css @@ -1,5 +1,5 @@ -/* styles for the video element itself */ -.videoPlayer { +/* styles for the video player and poster image */ +.videoPoster, .videoPlayer { position: absolute; left: 0; /* we position it with a transform */ top:0; diff --git a/apps/video/js/video.js b/apps/video/js/video.js index 18deb5dd2e13..6db109c37dd1 100644 --- a/apps/video/js/video.js +++ b/apps/video/js/video.js @@ -48,6 +48,9 @@ var dragging = false; var fullscreenTimer; var fullscreenCallback; +// Videos recorded by our own camera have filenames of this form +var FROMCAMERA = /^DCIM\/\d{3}MZLLA\/VID_\d{4}\.3gp$/; + function init() { videodb = new MediaDB('videos', metaDataParser); @@ -162,6 +165,17 @@ dom.thumbnails.addEventListener('contextmenu', function(evt) { function deleteFile(file) { var msg = navigator.mozL10n.get('confirm-delete'); if (confirm(msg + ' ' + file)) { + if (FROMCAMERA.test(file)) { + // If we're deleting a video file recorded by our camera, + // we also need to delete the poster image associated with + // that video. + var postername = file.replace('.3gp', '.jpg'); + navigator.getDeviceStorage('pictures').delete(postername); + } + + // Whether or not there was a poster file to delete, delete the + // actual video file. This will cause the MediaDB to send a 'deleted' + // event, and the handler for that event will call videoDeleted() below. videodb.deleteFile(file); } } @@ -196,7 +210,26 @@ function updateDialog() { } } -function metaDataParser(videofile, callback, metadataError) { +function metaDataParser(videofile, callback, metadataError, delayed) { + // XXX + // When the camera records a video, it saves the video file and then + // uses a tag to create a poster image for that video. + // But if the Video app is running, we get an event from device storage + // and start parsing the metadata when the video file is created. So now + // the Camera app and the Video app are both trying to use the video + // decoding hardware at the same time. The camera app really has to + // succeed. We should modify this app to wait for and use the poster image + // the way that the Gallery app does. For now, however, we avoid the problem + // by just waiting to give the Camera app time to save the poster image. + // In the worst case, we could fail to parse the metadata here. But that + // is better than having the camera fail to record the video correctly. + // + if (!delayed && FROMCAMERA.test(videofile.name)) { + setTimeout(function() { + metaDataParser(videofile, callback, metadataError, true); + }, 2000); + return; + } var previewPlayer = document.createElement('video'); var completed = false; @@ -219,6 +252,8 @@ function metaDataParser(videofile, callback, metadataError) { if (!completed) { metadataError(metadata.title); } + previewPlayer.removeAttribute('src'); + previewPlayer.load(); }; previewPlayer.onloadedmetadata = function() { @@ -326,8 +361,8 @@ function setPosterImage(dom, poster) { if (dom.dataset.uri) { URL.revokeObjectURL(dom.dataset.uri); } - dom.dataset.uri = URL.createObjectURL(poster) - dom.style.backgroundImage = 'url('+dom.dataset.uri+')'; + dom.dataset.uri = URL.createObjectURL(poster); + dom.style.backgroundImage = 'url(' + dom.dataset.uri + ')'; } function showOverlay(id) { @@ -496,7 +531,7 @@ function showPlayer(data, autoPlay) { playerShowing = true; setPlayerSize(); - if ('name' in currentVideo && /^DCIM/.test(currentVideo.name)) { + if ('name' in currentVideo && FROMCAMERA.test(currentVideo.name)) { dom.deleteVideoButton.classList.remove('hidden'); } @@ -533,6 +568,14 @@ function hidePlayer() { dom.thumbnails.classList.remove('hidden'); playerShowing = false; updateDialog(); + + // Unload the video. This releases the video decoding hardware + // so other apps can use it. Note that any time the video app is hidden + // (by switching to another app) we leave fullscreen mode, and this + // code gets triggered, so if the video app is not visible it should + // not be holding on to the video hardware + dom.player.removeAttribute('src'); + dom.player.load(); } if (!('metadata' in currentVideo)) { @@ -886,13 +929,43 @@ document.addEventListener('mozfullscreenchange', function() { // Pause on visibility change document.addEventListener('mozvisibilitychange', function visibilityChange() { - if (document.mozHidden && playing) { - pause(); - } else if (!document.mozHidden && document.mozFullScreenElement) { - setControlsVisibility(true); + if (document.mozHidden) { + if (playing) + pause(); + + if (playerShowing) + releaseVideo(); + } + else { + if (document.mozFullScreenElement) + setControlsVisibility(true); + + if (playerShowing) + restoreVideo(); } }); +// This app uses deprecated-hwvideo permission to access video decoding hardware +// But Camera and Gallery also need to use that hardware, and those three apps +// may only have one video playing at a time among them. So we need to be +// careful to relinquish the hardware when we are not visible. + +var restoreTime; + +// Call this when the app is hidden +function releaseVideo() { + restoreTime = dom.player.currentTime; + dom.player.removeAttribute('src'); + dom.player.load(); +} + +// Call this when the app becomes visible again +function restoreVideo() { + setVideoUrl(dom.player, currentVideo, function() { + dom.player.currentTime = restoreTime; + }); +} + // show|hide controls over the player dom.videoControls.addEventListener('mousedown', playerMousedown); diff --git a/apps/video/manifest.webapp b/apps/video/manifest.webapp index 32a22f672acc..ba9c391f97ec 100644 --- a/apps/video/manifest.webapp +++ b/apps/video/manifest.webapp @@ -8,6 +8,7 @@ "url": "https://github.com/mozilla-b2g/gaia" }, "permissions": { + "device-storage:pictures":{ "access": "readwrite" }, "device-storage:videos":{ "access": "readwrite" }, "settings":{ "access": "readonly" }, "deprecated-hwvideo":{}, diff --git a/shared/js/media/media_frame.js b/shared/js/media/media_frame.js index aaf8fbe61dca..25088dc1ef5b 100644 --- a/shared/js/media/media_frame.js +++ b/shared/js/media/media_frame.js @@ -40,7 +40,9 @@ function MediaFrame(container, includeVideo) { } this.displayingVideo = false; this.displayingImage = false; - this.blob = null; + this.imageblob = null; + this.videoblob = null; + this.posterblob = null; this.url = null; } @@ -50,7 +52,7 @@ MediaFrame.prototype.displayImage = function displayImage(blob, width, height, this.clear(); // Reset everything // Remember what we're displaying - this.blob = blob; + this.imageblob = blob; this.fullsizeWidth = width; this.fullsizeHeight = height; this.preview = preview; @@ -190,7 +192,7 @@ MediaFrame.prototype._displayImage = function _displayImage(blob, width, height, MediaFrame.prototype._switchToFullSizeImage = function _switchToFull(cb) { if (this.displayingImage && this.displayingPreview) { this.displayingPreview = false; - this._displayImage(this.blob, this.fullsizeWidth, this.fullsizeHeight, + this._displayImage(this.imageblob, this.fullsizeWidth, this.fullsizeHeight, true, cb); } }; @@ -198,15 +200,16 @@ MediaFrame.prototype._switchToFullSizeImage = function _switchToFull(cb) { MediaFrame.prototype._switchToPreviewImage = function _switchToPreview() { if (this.displayingImage && !this.displayingPreview) { this.displayingPreview = true; - this._displayImage(this.blob.slice(this.preview.start, - this.preview.end, - 'image/jpeg'), + this._displayImage(this.imageblob.slice(this.preview.start, + this.preview.end, + 'image/jpeg'), this.preview.width, this.preview.height); } }; -MediaFrame.prototype.displayVideo = function displayVideo(blob, width, height, +MediaFrame.prototype.displayVideo = function displayVideo(videoblob, posterblob, + width, height, rotation) { if (!this.video) @@ -217,19 +220,21 @@ MediaFrame.prototype.displayVideo = function displayVideo(blob, width, height, // Keep track of what kind of content we have this.displayingVideo = true; - // Show the video player and hide the image - this.video.show(); - - // Remember the blob - this.blob = blob; + // Remember the blobs + this.videoblob = videoblob; + this.posterblob = posterblob; - // Get a new URL for this blob - this.url = URL.createObjectURL(blob); + // Get new URLs for the blobs + this.videourl = URL.createObjectURL(videoblob); + this.posterurl = URL.createObjectURL(posterblob); - // Display it in the video element. + // Display them in the video element. // The VideoPlayer class takes care of positioning itself, so we // don't have to do anything here with computeFit() or setPosition() - this.video.load(this.url, rotation || 0); + this.video.load(this.videourl, this.posterurl, width, height, rotation || 0); + + // Show the player controls + this.video.show(); }; // Reset the frame state, release any urls and and hide everything @@ -239,7 +244,9 @@ MediaFrame.prototype.clear = function clear() { this.displayingPreview = false; this.displayingVideo = false; this.itemWidth = this.itemHeight = null; - this.blob = null; + this.imageblob = null; + this.videoblob = null; + this.posterblob = null; this.fullsizeWidth = this.fullsizeHeight = null; this.preview = null; this.fit = null; @@ -254,14 +261,12 @@ MediaFrame.prototype.clear = function clear() { // Hide the video player if (this.video) { + this.video.reset(); this.video.hide(); - - // If the video player has its src set, clear it and release resources - // We do this in a roundabout way to avoid getting a warning in the console - if (this.video.player.src) { - this.video.player.removeAttribute('src'); - this.video.player.load(); - } + if (this.videourl) + URL.revokeObjectURL(this.videourl); + if (this.posterurl) + URL.revokeObjectURL(this.posterurl); } }; diff --git a/shared/js/media/video_player.js b/shared/js/media/video_player.js index c79bb8b70837..2947fdba53df 100644 --- a/shared/js/media/video_player.js +++ b/shared/js/media/video_player.js @@ -1,8 +1,22 @@ 'use strict'; +// // Create a element and containing a video player UI and // add them to the specified container. The UI requires a GestureDetector // to be running for the container or one of its ancestors. +// +// Some devices have only a single hardware video decoder and can only +// have one video tag playing anywhere at once. So this class is careful +// to only load content into a element when the user really wants +// to play it. At other times it displays a poster image for the video. +// Initially, it displays the poster image. Pressing play starts the video. +// Pausing pauses the video but does not revert to the poster. Finishing the +// video reverts to the initial state with the poster image displayed. +// If we get a visiblitychange event saying that we've been hidden, we +// remember the playback position, pause the video take a temporary +// screenshot and display it, and unload the video. If shown again +// and if the user clicks play again, we resume the video where we left off. +// function VideoPlayer(container) { if (typeof container === 'string') container = document.getElementById(container); @@ -16,6 +30,7 @@ function VideoPlayer(container) { } // This copies the controls structure of the Video app + var poster = newelt(container, 'img', 'videoPoster'); var player = newelt(container, 'video', 'videoPlayer'); var controls = newelt(container, 'div', 'videoPlayerControls'); var playbutton = newelt(controls, 'button', 'videoPlayerPlayButton'); @@ -29,10 +44,12 @@ function VideoPlayer(container) { var playHead = newelt(progress, 'div', 'videoPlayerPlayHead'); var durationText = newelt(slider, 'span', 'videoPlayerDurationText'); + this.poster = poster; this.player = player; this.controls = controls; player.preload = 'metadata'; + player.mozAudioChannelType = 'content'; var self = this; var controlsHidden = false; @@ -40,21 +57,87 @@ function VideoPlayer(container) { var pausedBeforeDragging = false; var screenLock; // keep the screen on when playing var endedTimer; + var videourl; // the url of the video to play + var posterurl; // the url of the poster image to display var rotation; // Do we have to rotate the video? Set by load() - this.load = function(url, rotate) { + // These are the raw (unrotated) size of the poster image, which + // must have the same size as the video. + var videowidth, videoheight; + + var playbackTime; + var capturedFrame; + + this.load = function(video, posterimage, width, height, rotate) { + this.reset(); + videourl = video; + posterurl = posterimage; rotation = rotate || 0; - player.mozAudioChannelType = 'content'; - player.src = url; + videowidth = width; + videoheight = height; + this.init(); + setPlayerSize(); }; + this.reset = function() { + hidePlayer(); + hidePoster(); + } + + this.init = function() { + playbackTime = 0; + hidePlayer(); + showPoster(); + this.pause(); + } + + function hidePlayer() { + player.style.display = 'none'; + player.removeAttribute('src'); + player.load(); + self.playerShowing = false; + } + + function showPlayer() { + player.style.display = 'block'; + player.src = videourl; + self.playerShowing = true; + + // The only place we call showPlayer() is from the play() function. + // If play() has to show the player, call it again when we're ready to play. + player.oncanplay = function() { + player.oncanplay = null; + if (playbackTime !== 0) { + player.currentTime = playbackTime; + } + self.play(); + } + } + + function hidePoster() { + poster.style.display = 'none'; + poster.removeAttribute('src'); + if (capturedFrame) { + URL.revokeObjectURL(capturedFrame); + capturedFrame = null; + } + } + + function showPoster() { + poster.style.display = 'block'; + if (capturedFrame) + poster.src = capturedFrame; + else + poster.src = posterurl; + } + // Call this when the container size changes this.setPlayerSize = setPlayerSize; - // Set up everything for the initial paused state this.pause = function pause() { // Pause video playback - player.pause(); + if (self.playerShowing) + player.pause(); // Hide the pause button and slider footer.classList.add('hidden'); @@ -75,12 +158,14 @@ function VideoPlayer(container) { // Set up the playing state this.play = function play() { - // If we're at the end of the video, restart at the beginning. - // This seems to happen automatically when an 'ended' event was fired. - // But some media types don't generate the ended event and don't - // automatically go back to the start. - if (player.currentTime >= player.duration - 0.5) - player.currentTime = 0; + if (!this.playerShowing) { + // If we're displaying the poster image, we have to switch + // to the player first. When the player is ready it wil call this + // function again. + hidePoster(); + showPlayer(); + return; + } // Start playing the video player.play(); @@ -102,8 +187,8 @@ function VideoPlayer(container) { // Hook up the play button playbutton.addEventListener('tap', function(e) { - // If we're paused, go to the play state - if (player.paused) { + // If we're not showing the player or are paused, go to the play state + if (!self.playerShowing || player.paused) { self.play(); } e.stopPropagation(); @@ -124,10 +209,9 @@ function VideoPlayer(container) { } }); - // Set the video size and duration when we get metadata + // Set the video duration when we get metadata player.onloadedmetadata = function() { durationText.textContent = formatTime(player.duration); - setPlayerSize(); // start off in the paused state self.pause(); }; @@ -150,6 +234,7 @@ function VideoPlayer(container) { endedTimer = null; } self.pause(); + self.init(); }; // Update the slider and elapsed time as the video plays @@ -189,6 +274,46 @@ function VideoPlayer(container) { } } + // Pause and unload the video if we're hidden so that other apps + // can use the video decoder hardware. + window.addEventListener('mozvisibilitychange', visibilityChanged); + + function visibilityChanged() { + if (document.mozHidden) { + // If we're just showing the poster image when we're hidden + // then we don't have to do anything special + if (!self.playerShowing) + return; + + self.pause(); + + // If we're not at the beginning of the video, capture a + // temporary poster image to display when we come back + if (player.currentTime !== 0) { + playbackTime = player.currentTime; + captureCurrentFrame(function(blob) { + capturedFrame = URL.createObjectURL(blob); + hidePlayer(); + showPoster(); + }); + } + else { + // Even if we don't capture a frame, hide the video + hidePlayer(); + showPoster(); + } + } + } + + function captureCurrentFrame(callback) { + var canvas = document.createElement('canvas'); + canvas.width = videowidth; + canvas.height = videoheight; + var context = canvas.getContext('2d'); + context.drawImage(player, 0, 0); + canvas.toBlob(callback); + } + // Make the video fit the container function setPlayerSize() { var containerWidth = container.clientWidth; @@ -196,20 +321,20 @@ function VideoPlayer(container) { // Don't do anything if we don't know our size. // This could happen if we get a resize event before our metadata loads - if (!player.videoWidth || !player.videoHeight) + if (!videowidth || !videoheight) return; var width, height; // The size the video will appear, after rotation switch (rotation) { case 0: case 180: - width = player.videoWidth; - height = player.videoHeight; + width = videowidth; + height = videoheight; break; case 90: case 270: - width = player.videoHeight; - height = player.videoWidth; + width = videoheight; + height = videowidth; } var xscale = containerWidth / width; @@ -248,6 +373,7 @@ function VideoPlayer(container) { transform += ' scale(' + scale + ')'; + poster.style.transform = transform; player.style.transform = transform; } @@ -303,11 +429,11 @@ function VideoPlayer(container) { } VideoPlayer.prototype.hide = function() { - this.player.style.display = 'none'; + // Call reset() to hide the poster and player this.controls.style.display = 'none'; }; VideoPlayer.prototype.show = function() { - this.player.style.display = 'block'; + // Call init() to show the poster this.controls.style.display = 'block'; };