Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
parent
93e3a48004
commit
b38dea0597
25901 changed files with 9982925 additions and 369 deletions
1977
gaia/apps/gallery/js/ImageEditor.js
Executable file
1977
gaia/apps/gallery/js/ImageEditor.js
Executable file
File diff suppressed because it is too large
Load diff
708
gaia/apps/gallery/js/MetadataParser.js
Executable file
708
gaia/apps/gallery/js/MetadataParser.js
Executable file
|
|
@ -0,0 +1,708 @@
|
|||
'use strict';
|
||||
/* global
|
||||
CONFIG_MAX_IMAGE_PIXEL_SIZE,
|
||||
CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT,
|
||||
CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH,
|
||||
cropResizeRotate,
|
||||
Downsample,
|
||||
getImageSize,
|
||||
getVideoRotation,
|
||||
isPhone,
|
||||
parseJPEGMetadata,
|
||||
videostorage
|
||||
*/
|
||||
/* exported
|
||||
metadataParser
|
||||
*/
|
||||
|
||||
//
|
||||
// This file defines a single metadataParsers object. The two
|
||||
// properties of this object are metadata parsing functions for image
|
||||
// files and video files, intended for use with the MediaDB class.
|
||||
//
|
||||
// This file depends on JPEGMetadataParser.js and blobview.js
|
||||
//
|
||||
var metadataParser = (function() {
|
||||
// If we generate our own thumbnails, aim for this size.
|
||||
// Calculate needed size from longer side of the screen.
|
||||
var THUMBNAIL_WIDTH = computeThumbnailWidth();
|
||||
var THUMBNAIL_HEIGHT = THUMBNAIL_WIDTH;
|
||||
|
||||
function computeThumbnailWidth() {
|
||||
// Make sure this works regardless of current device orientation
|
||||
var width = window.innerWidth || screen.width;
|
||||
var height = window.innerHeight || screen.height;
|
||||
var portraitWidth = Math.max(1, Math.min(width, height));
|
||||
var landscapeWidth = Math.max(1, Math.max(width, height));
|
||||
var thumbnailsPerRowPortrait = isPhone ? 3 : 4;
|
||||
var thumbnailsPerRowLandscape = isPhone ? 4 : 6;
|
||||
return Math.round(window.devicePixelRatio *
|
||||
Math.max(portraitWidth / thumbnailsPerRowPortrait,
|
||||
landscapeWidth / thumbnailsPerRowLandscape));
|
||||
}
|
||||
|
||||
var thumbnailSize = {
|
||||
width: THUMBNAIL_WIDTH,
|
||||
height: THUMBNAIL_HEIGHT
|
||||
};
|
||||
|
||||
// Don't try to decode image files of unknown type if bigger than this
|
||||
var MAX_UNKNOWN_IMAGE_FILE_SIZE = 0.5 * 1024 * 1024; // half a megabyte
|
||||
|
||||
// If a jpeg does not have a suitable EXIF preview and has more than
|
||||
// this number of pixels, then we create an external preview for it
|
||||
var MAX_PREVIEWLESS_JPEG_SIZE = 5 * 1024 * 1024; // 5 megapixels
|
||||
|
||||
// An <img> element for loading images
|
||||
var offscreenImage = new Image();
|
||||
|
||||
// The screen size. Preview images must be at least this big
|
||||
// Note that we use screen.width instead of window.innerWidth because
|
||||
// the window size is different when we are invoked for a pick activity
|
||||
// (non-fullscreen) and when we are invoked normally (fullscreen).
|
||||
var sw = screen.width * window.devicePixelRatio;
|
||||
var sh = screen.height * window.devicePixelRatio;
|
||||
|
||||
// Create a thumbnail size canvas, copy the <img> or <video> 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.
|
||||
// This utility function is used by both the image and video metadata parsers
|
||||
function createThumbnailFromElement(elt, video, rotation,
|
||||
mirrored, callback, error) {
|
||||
try {
|
||||
// Create a thumbnail image
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = THUMBNAIL_WIDTH;
|
||||
canvas.height = THUMBNAIL_HEIGHT;
|
||||
var context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
var eltwidth = elt.width;
|
||||
var eltheight = elt.height;
|
||||
var scalex = canvas.width / eltwidth;
|
||||
var scaley = canvas.height / eltheight;
|
||||
|
||||
// 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((eltwidth - w) / 2);
|
||||
var y = Math.round((eltheight - h) / 2);
|
||||
|
||||
var centerX = Math.floor(THUMBNAIL_WIDTH / 2);
|
||||
var centerY = Math.floor(THUMBNAIL_HEIGHT / 2);
|
||||
|
||||
// If a orientation is specified, rotate/mirroring the canvas context.
|
||||
if (rotation || mirrored) {
|
||||
context.save();
|
||||
// All transformation are applied to the center of the thumbnail.
|
||||
context.translate(centerX, centerY);
|
||||
}
|
||||
if (mirrored) {
|
||||
context.scale(-1, 1);
|
||||
}
|
||||
if (rotation) {
|
||||
switch (rotation) {
|
||||
case 90:
|
||||
context.rotate(Math.PI / 2);
|
||||
break;
|
||||
case 180:
|
||||
context.rotate(Math.PI);
|
||||
break;
|
||||
case 270:
|
||||
context.rotate(-Math.PI / 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (rotation || mirrored) {
|
||||
context.translate(-centerX, -centerY);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Restore the default rotation so the play arrow comes out correctly
|
||||
if (rotation || mirrored) {
|
||||
context.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, .2)';
|
||||
context.beginPath();
|
||||
context.arc(THUMBNAIL_WIDTH / 2, THUMBNAIL_HEIGHT / 2,
|
||||
THUMBNAIL_HEIGHT / 5, 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 / 5;
|
||||
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();
|
||||
}
|
||||
|
||||
canvas.toBlob(function(blob) {
|
||||
context = null;
|
||||
canvas.width = canvas.height = 0;
|
||||
canvas = null;
|
||||
callback(blob);
|
||||
}, 'image/jpeg');
|
||||
} catch (ex) {
|
||||
// An error may be thrown when the drawImage decodes a broken/trancated
|
||||
// image.
|
||||
// The elt may be a offscreen image. So, the image metadata is parsed, and
|
||||
// the image data is loaded but not decoded. The drawImage triggers the
|
||||
// image decoder to decode the image data. And an error may be thrown.
|
||||
error('createThumbnailFromElement:' + ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
var VIDEOFILE = /DCIM\/\d{3}MZLLA\/VID_\d{4}\.jpg/;
|
||||
|
||||
function metadataParser(file, metadataCallback, metadataError, bigFile) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Figure out how big the image is if we can. For JPEG files this
|
||||
// calls the JPEG parser and returns the EXIF preview if there is one.
|
||||
getImageSize(file, gotImageSize, gotImageSizeError);
|
||||
|
||||
function gotImageSizeError(errmsg) {
|
||||
// The image is not a JPEG, PNG, GIF or BMP file. We may still be
|
||||
// able to decode and display it but we don't know the image
|
||||
// size, so we won't even try if the file is too big.
|
||||
if (file.size > MAX_UNKNOWN_IMAGE_FILE_SIZE) {
|
||||
metadataError('Ignoring large file ' + file.name);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the file is too small to be an image, ignore it
|
||||
if (file.size < 32) {
|
||||
metadataError('Ignoring small file ' + file.name);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the error message is anything other than unknown image type
|
||||
// it means we've got a corrupt image file, or the image metdata parser
|
||||
// can't handle the file for some reason. Log a warning but keep going
|
||||
// in case the image is good and the metadata parser is buggy.
|
||||
if (errmsg !== 'unknown image type') {
|
||||
console.warn('getImageSize', errmsg, file.name);
|
||||
}
|
||||
|
||||
// If it is not too big create a preview and thumbnail.
|
||||
createThumbnailAndPreview(file,
|
||||
metadataCallback,
|
||||
metadataError,
|
||||
false,
|
||||
bigFile,
|
||||
{});
|
||||
}
|
||||
|
||||
function gotImageSize(metadata) {
|
||||
//
|
||||
// If the image is too big, reject it now so we don't have
|
||||
// memory trouble later.
|
||||
//
|
||||
// CONFIG_MAX_IMAGE_PIXEL_SIZE is maximum image resolution we
|
||||
// can handle. It's from config.js which is generated at build
|
||||
// time (see build/application-data.js).
|
||||
//
|
||||
// For jpeg images we can downsample while decoding which means that
|
||||
// we can handle images that are quite a bit larger.
|
||||
//
|
||||
// getImageSize() distinguishes pjpeg files from jpeg, because
|
||||
// progressive jpeg files are quite different than regular jpegs.
|
||||
// Not only do pjpegs not benefit from using #-moz-samplesize, they
|
||||
// actually require more memory to decode than other images of the same
|
||||
// resolution, so we actually need to reduce the image size limit for
|
||||
// this image type.
|
||||
//
|
||||
var imagesizelimit = CONFIG_MAX_IMAGE_PIXEL_SIZE;
|
||||
if (metadata.type === 'jpeg') {
|
||||
imagesizelimit *= Downsample.MAX_AREA_REDUCTION;
|
||||
}
|
||||
else if (metadata.type === 'pjpeg') {
|
||||
// As a semi-educated guess, we'll say that we can handle pjpegs
|
||||
// 2/3rds the size of the largest PNG we can handle.
|
||||
imagesizelimit *= 2 / 3;
|
||||
}
|
||||
|
||||
//
|
||||
// Even if we can downsample an image while decoding it, we still
|
||||
// have to read the entire image file. If the file is particularly
|
||||
// large we might also have memory problems. (See bug 1008834: a 20mb
|
||||
// 80mp jpeg file will cause an OOM on Tarako even though we can
|
||||
// decode it at < 2mp). Rather than adding another build-time config
|
||||
// variable to specify the maximum file size, however, we'll just
|
||||
// base the file size limit on CONFIG_MAX_IMAGE_PIXEL_SIZE.
|
||||
// So if that variable is set to 2M, then we might use up to 12Mb of
|
||||
// memory. 2 * 2M bytes for the image file and 4 bytes times 2M pixels
|
||||
// for the decoded image. A 4mb file size limit should accomodate
|
||||
// most JPEG files up to 12 or 16 megapixels.
|
||||
//
|
||||
// Note that this size limit is just a guess. If we continue to see
|
||||
// OOMs with large files then we should change the number 2 below
|
||||
// to something smaller.
|
||||
//
|
||||
var filesizelimit = 2 * CONFIG_MAX_IMAGE_PIXEL_SIZE;
|
||||
|
||||
if (metadata.width * metadata.height > imagesizelimit ||
|
||||
file.size > filesizelimit) {
|
||||
metadataError('Ignoring high-resolution image ' + file.name);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the file included a preview image, see if it is big enough
|
||||
if (metadata.preview) {
|
||||
// Create a blob that is just the preview image
|
||||
var previewblob = file.slice(metadata.preview.start,
|
||||
metadata.preview.end,
|
||||
'image/jpeg');
|
||||
|
||||
// Check to see if the preview is big enough to use in MediaFrame
|
||||
parseJPEGMetadata(previewblob, previewsuccess, previewerror);
|
||||
}
|
||||
else {
|
||||
// If there wasn't a preview image, then generate a preview and
|
||||
// thumbnail from the full size image.
|
||||
useFullsizeImage();
|
||||
}
|
||||
|
||||
function previewerror(msg) {
|
||||
// The preview isn't a valid jpeg file, so use the full image to
|
||||
// create a preview and a thumbnail
|
||||
console.error(msg);
|
||||
useFullsizeImage();
|
||||
}
|
||||
|
||||
// If we can't find a valid embedded EXIF preview image then we come here
|
||||
function useFullsizeImage() {
|
||||
// If the full size image is a JPEG then we'll just skip the
|
||||
// preview generation because we can downsample and decode it
|
||||
// at preview size when needed. So in this case, we just need
|
||||
// to create a thumbnail for the image. On the other hand, if
|
||||
// this is not a JPEG image then we have to create both a
|
||||
// thumbnail and an external preview image. Note that progressive
|
||||
// jpegs will have a type 'pjpeg' and will be handled below
|
||||
// like png images.
|
||||
|
||||
// For jpeg images with image sizes more than 5MP, we need
|
||||
// to create preview on sdcard to avoid flash while opening image.
|
||||
// See bug 1181290.
|
||||
// This constraint can be removed when gecko supports downscale during
|
||||
// decode by canvas size for offscreen images. This will
|
||||
// facilitate removing #moz-sample-size and using ddd.
|
||||
// See bug 1206206.
|
||||
|
||||
if (metadata.type === 'jpeg') {
|
||||
if (metadata.width * metadata.height <= MAX_PREVIEWLESS_JPEG_SIZE) {
|
||||
// If the jpeg image isn't too big we don't need a preview
|
||||
// because we can decode it quickly enough at screen size.
|
||||
// So in this case we just need to create a thumbnail.
|
||||
cropResizeRotate(file, null, thumbnailSize, null, metadata,
|
||||
function gotThumbnail(error, thumbnailBlob) {
|
||||
if (error) {
|
||||
var msg = 'Failed to create thumbnail for' +
|
||||
file.name;
|
||||
console.error(msg);
|
||||
metadataError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
metadata.preview = null;
|
||||
metadata.thumbnail = thumbnailBlob;
|
||||
metadataCallback(metadata);
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Otherwise, this is a high-resolution jpeg that does not
|
||||
// have an EXIF preview big enough for the screen. We need
|
||||
// to create both a thumbnail and a preview image but we can
|
||||
// do that with efficient downsampling using cropResizeRotate().
|
||||
createJPEGThumbnailAndPreview(file, metadata,
|
||||
metadataCallback,
|
||||
metadataError);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// In this case we have no preview and the image is not a jpeg
|
||||
// that can be downsampled with #-moz-samplesize
|
||||
createThumbnailAndPreview(file,
|
||||
metadataCallback,
|
||||
metadataError,
|
||||
false,
|
||||
bigFile,
|
||||
metadata);
|
||||
}
|
||||
}
|
||||
|
||||
function previewsuccess(previewmetadata) {
|
||||
// Size of the preview image
|
||||
var pw = previewmetadata.width;
|
||||
var ph = previewmetadata.height;
|
||||
// optional configuration specifying minimum size
|
||||
var mw = CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH;
|
||||
var mh = CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT;
|
||||
|
||||
var bigenough;
|
||||
|
||||
// If config.js specifies a minimum required preview size,
|
||||
// then this preview is big enough if both dimensions are
|
||||
// larger than that configured minimum. Otherwise, the preview
|
||||
// is big enough if at least one dimension is >= the screen
|
||||
// size in both portait and landscape mode.
|
||||
if (mw && mh) {
|
||||
bigenough =
|
||||
Math.max(pw, ph) >= Math.max(mw, mh) &&
|
||||
Math.min(pw, ph) >= Math.min(mw, mh);
|
||||
}
|
||||
else {
|
||||
bigenough = (pw >= sw || ph >= sh) && (pw >= sh || ph >= sw);
|
||||
}
|
||||
|
||||
// Does the aspect ratio of the preview match the aspect ratio of
|
||||
// the full-size image? If not we have to assume it is distorted
|
||||
// and should not be used.
|
||||
var aspectRatioMatches =
|
||||
Math.abs(pw / ph - metadata.width / metadata.height) < 0.01;
|
||||
|
||||
// If the preview is good, use it to create a thumbnail.
|
||||
if (bigenough && aspectRatioMatches) {
|
||||
metadata.preview.width = pw;
|
||||
metadata.preview.height = ph;
|
||||
|
||||
// This is the most common case. We've got a JPEG image with a
|
||||
// JPEG preview. All we need is a thumbnail, and we can create that
|
||||
// most efficiently with cropResizeRotate which will use
|
||||
// #-moz-samplesize to save memory while decoding the image.
|
||||
// Note that we apply the EXIF orientation information from
|
||||
// the full size image to the preview image.
|
||||
previewmetadata.rotation = metadata.rotation;
|
||||
previewmetadata.mirrored = metadata.mirrored;
|
||||
cropResizeRotate(previewblob, null, thumbnailSize, null,
|
||||
previewmetadata,
|
||||
function gotThumbnailBlob(error, thumbnailBlob) {
|
||||
if (error) {
|
||||
previewerror(error);
|
||||
return;
|
||||
}
|
||||
metadata.thumbnail = thumbnailBlob;
|
||||
metadataCallback(metadata);
|
||||
});
|
||||
} else {
|
||||
// Preview isn't big enough so get one the hard way
|
||||
useFullsizeImage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This function is called for JPEG images that do not have a suitable
|
||||
// EXIF preview image and that have more than MAX_PREVIEWLESS_JPEG_SIZE
|
||||
// pixels. It creates and saves a preview image and a thumbnail.
|
||||
// Unlike createThumbnailAndPreview(), it uses cropResizeRotate() which
|
||||
// downsamples jpeg images while decoding them to save memory.
|
||||
function createJPEGThumbnailAndPreview(file, metadata,
|
||||
metadataCallback, metadataError)
|
||||
{
|
||||
|
||||
// Figure out the preview size.
|
||||
var scale = Math.max(Math.min(sw / metadata.width,
|
||||
sh / metadata.height, 1),
|
||||
Math.min(sh / metadata.width,
|
||||
sw / metadata.height, 1));
|
||||
var previewSize = {
|
||||
width: Math.round(metadata.width * scale),
|
||||
height: Math.round(metadata.height * scale)
|
||||
};
|
||||
|
||||
// Create a preview image.
|
||||
// Note that we can't use the full metadata object here
|
||||
// because we don't want the preview image to be rotated.
|
||||
// We want the same orientation as the original image
|
||||
cropResizeRotate(file, null, previewSize, null,
|
||||
{ width: metadata.width, height: metadata.height },
|
||||
function gotPreview(error, previewBlob) {
|
||||
if (error) {
|
||||
var msg = 'Failed to create preview for' +
|
||||
file.name;
|
||||
console.error(msg);
|
||||
metadataError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save this preview to the sdcard
|
||||
savePreview(file, previewBlob, metadataError, function(filename) {
|
||||
// Record the preview image in the metadata
|
||||
metadata.preview = {
|
||||
filename: filename,
|
||||
width: previewSize.width,
|
||||
height: previewSize.height
|
||||
};
|
||||
|
||||
// Now create the thumbnail from this smaller preview image
|
||||
// In this case we need to pass the size of the preview
|
||||
// and the rotation of the original image.
|
||||
cropResizeRotate(previewBlob, null, thumbnailSize, null,
|
||||
{
|
||||
width: previewSize.width,
|
||||
height: previewSize.height,
|
||||
rotation: metadata.rotation
|
||||
},
|
||||
function gotThumbnail(error, thumbnailBlob) {
|
||||
// If thumbnail creation failed report the error
|
||||
if (error) {
|
||||
metadataError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, remember thumbnail and we're done
|
||||
metadata.thumbnail = thumbnailBlob;
|
||||
metadataCallback(metadata);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Load an image from a file into an <img> tag, and then use that
|
||||
// to get its dimensions and create a thumbnail. Store these values in
|
||||
// a metadata object, and pass the object to the callback function.
|
||||
// If anything goes wrong, pass an error message to the error function.
|
||||
// If it is a large image, create and save a preview for it as well.
|
||||
//
|
||||
// We used to call this function for every image. Now that gecko supports
|
||||
// the #-moz-samplesize media fragment, however, we handle jpeg images
|
||||
// with cropResizeRotate() for memory efficiency, and this function is
|
||||
// used only for non-jpeg files.
|
||||
function createThumbnailAndPreview(file, callback, error, nopreview,
|
||||
bigFile, metadata) {
|
||||
var url = URL.createObjectURL(file);
|
||||
offscreenImage.src = url;
|
||||
|
||||
offscreenImage.onerror = function() {
|
||||
URL.revokeObjectURL(url);
|
||||
offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
|
||||
error('createThumbnailAndPreview: Image failed to load');
|
||||
};
|
||||
|
||||
offscreenImage.onload = function() {
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
var iw = offscreenImage.width;
|
||||
var ih = offscreenImage.height;
|
||||
|
||||
// Don't overwrite the metadata in the case we read a previewblob.
|
||||
if (!nopreview) {
|
||||
metadata.width = iw;
|
||||
metadata.height = ih;
|
||||
}
|
||||
|
||||
// If this is a big image, then decoding it takes a lot of memory.
|
||||
// We set this flag to prevent the user from zooming in on other
|
||||
// images at the same time because that also takes a lot of memory
|
||||
// and we don't want to crash with an OOM. If we find one big image
|
||||
// we assume that there may be others, so the flag remains set until
|
||||
// the current scan is complete.
|
||||
//
|
||||
// XXX: When bug 854795 is fixed, we'll be able to create previews
|
||||
// for large images without using so much memory, and we can remove
|
||||
// this flag then.
|
||||
if (iw * ih > 2 * 1024 * 1024 && bigFile) {
|
||||
bigFile();
|
||||
}
|
||||
|
||||
// If the image was already thumbnail size, it is its own thumbnail
|
||||
// and it does not need a preview
|
||||
if (metadata.width <= THUMBNAIL_WIDTH &&
|
||||
metadata.height <= THUMBNAIL_HEIGHT) {
|
||||
offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
|
||||
metadata.thumbnail = file;
|
||||
callback(metadata);
|
||||
}
|
||||
else {
|
||||
createThumbnailFromElement(
|
||||
offscreenImage,
|
||||
false,
|
||||
metadata.rotation || 0,
|
||||
metadata.mirrored || false,
|
||||
gotThumbnail,
|
||||
error);
|
||||
}
|
||||
|
||||
function gotThumbnail(thumbnail) {
|
||||
metadata.thumbnail = thumbnail;
|
||||
// If no preview was requested, or if if the image was less
|
||||
// than half a megapixel then it does not need a preview
|
||||
// image, and we can call the callback right away
|
||||
if (nopreview || metadata.width * metadata.height < 512 * 1024) {
|
||||
offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
|
||||
callback(metadata);
|
||||
}
|
||||
else {
|
||||
// Otherwise, this was a big image and we need to create a
|
||||
// preview for it so we can avoid decoding the full size
|
||||
// image again when possible
|
||||
createAndSavePreview();
|
||||
}
|
||||
}
|
||||
|
||||
function createAndSavePreview() {
|
||||
// Figure out the preview size.
|
||||
// Make sure the size is big enough for both landscape and portrait
|
||||
var scale = Math.max(Math.min(sw / iw, sh / ih, 1),
|
||||
Math.min(sh / iw, sw / ih, 1));
|
||||
// preview width and height
|
||||
var pw = Math.round(iw * scale);
|
||||
var ph = Math.round(ih * scale);
|
||||
|
||||
// Create the preview in a canvas
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = pw;
|
||||
canvas.height = ph;
|
||||
var context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(offscreenImage, 0, 0, iw, ih, 0, 0, pw, ph);
|
||||
canvas.toBlob(function(blob) {
|
||||
offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
|
||||
canvas.width = canvas.height = 0;
|
||||
savePreview(file, blob, error, function(filename) {
|
||||
metadata.preview = {
|
||||
filename: filename,
|
||||
width: pw,
|
||||
height: ph
|
||||
};
|
||||
callback(metadata);
|
||||
});
|
||||
}, 'image/jpeg');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Given an image file and a preview blob for it, this function saves
|
||||
// the preview to a file in .gallery/previews/ and then passes the filename
|
||||
// to the specified callback. Calls errorCallback if an error occurs.
|
||||
// This is used by createThumbnailAndPreview() and by
|
||||
// createJPEGThumbnailAndPreview().
|
||||
function savePreview(file, previewblob, errorCallback, filenameCallback) {
|
||||
var storage = navigator.getDeviceStorage('pictures');
|
||||
var filename;
|
||||
if (file.name[0] === '/') {
|
||||
// We expect file.name to be a fully qualified name (perhaps
|
||||
// something like /sdcard/DCIM/100MZLLA/IMG_0001.jpg).
|
||||
var slashIndex = file.name.indexOf('/', 1);
|
||||
if (slashIndex < 0) {
|
||||
errorCallback('savePreview: Bad filename: \'' + file.name + '\'');
|
||||
return;
|
||||
}
|
||||
filename =
|
||||
file.name.substring(0, slashIndex) + // storageName (i.e. /sdcard)
|
||||
|
||||
'/.gallery/previews' +
|
||||
file.name.substring(slashIndex); // rest of path (i,e, /DCIM/...)
|
||||
} else {
|
||||
// On non-composite storage areas (e.g. desktop), file.name will be
|
||||
// a relative path.
|
||||
filename = '.gallery/previews/' + file.name;
|
||||
}
|
||||
|
||||
// Add a '.jpeg' extension to all preview files. This is necessary
|
||||
// because all previews get saved as jpegs even if the original
|
||||
// image is not a jpeg. But DeviceStorage uses the filename extension
|
||||
// to determine the file type.
|
||||
filename += '.jpeg';
|
||||
|
||||
// Delete any existing preview by this name
|
||||
var delreq = storage.delete(filename);
|
||||
delreq.onsuccess = delreq.onerror = save;
|
||||
|
||||
function save() {
|
||||
var savereq = storage.addNamed(previewblob, filename);
|
||||
savereq.onerror = function() {
|
||||
console.error('Could not save preview image', filename);
|
||||
};
|
||||
|
||||
// Don't actually wait for the save to complete.
|
||||
filenameCallback(filename);
|
||||
}
|
||||
}
|
||||
|
||||
function videoMetadataParser(file, metadataCallback, errorCallback) {
|
||||
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;
|
||||
|
||||
offscreenImage.onerror = function() {
|
||||
URL.revokeObjectURL(url);
|
||||
offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
|
||||
errorCallback('getVideoThumanailAndSize: Image failed to load');
|
||||
};
|
||||
|
||||
offscreenImage.onload = function() {
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// We store the unrotated size of the poster image. The video
|
||||
// itself might be larger or smaller than this, but it will
|
||||
// have the same orientation and aspect ratio.
|
||||
metadata.width = offscreenImage.width;
|
||||
metadata.height = offscreenImage.height;
|
||||
|
||||
createThumbnailFromElement(offscreenImage,
|
||||
true,
|
||||
metadata.rotation,
|
||||
false,
|
||||
function(thumbnail) {
|
||||
metadata.thumbnail = thumbnail;
|
||||
offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
|
||||
metadataCallback(metadata);
|
||||
},
|
||||
errorCallback);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return metadataParser;
|
||||
}());
|
||||
715
gaia/apps/gallery/js/frames.js
Executable file
715
gaia/apps/gallery/js/frames.js
Executable file
|
|
@ -0,0 +1,715 @@
|
|||
// This file contains Gallery code related to the fullscreen view
|
||||
'use strict';
|
||||
/* global
|
||||
$,
|
||||
CONFIG_MAX_IMAGE_PIXEL_SIZE,
|
||||
CONFIG_MAX_PICK_PIXEL_SIZE,
|
||||
CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT,
|
||||
CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH,
|
||||
cropResizeRotate,
|
||||
currentFileIndex,
|
||||
currentView,
|
||||
deleteFile,
|
||||
Dialogs,
|
||||
editPhotoIfCardNotFull,
|
||||
files,
|
||||
frames:true,
|
||||
fullscreenButtons,
|
||||
fullscreenView,
|
||||
GestureDetector,
|
||||
getCurrentFile,
|
||||
getVideoFile,
|
||||
isPhone,
|
||||
LAYOUT_MODE,
|
||||
LazyLoader,
|
||||
MediaFrame,
|
||||
NFC,
|
||||
photodb,
|
||||
setView,
|
||||
share,
|
||||
showFileInformation,
|
||||
Spinner,
|
||||
TRANSITION_FRACTION,
|
||||
TRANSITION_SPEED,
|
||||
updateFocusThumbnail
|
||||
*/
|
||||
/* exported
|
||||
clearFrames,
|
||||
showFile
|
||||
*/
|
||||
|
||||
var frames = $('frames');
|
||||
|
||||
// These three objects are holders for the previous, current and next
|
||||
// photos or videos to be displayed. They get swapped around and
|
||||
// reused when we pan to the next or previous photo: next becomes
|
||||
// current, current becomes previous etc. See nextFile() and
|
||||
// previousFile(). Note also that the Frame object is not a DOM
|
||||
// element. Use currentFrame.container to refer to the section
|
||||
// element. The frame constructor creates an <img> element, a <video>
|
||||
// element, and video player controls within the section, and you can refer to
|
||||
// those as currentFrame.image and currentFrame.video.player and
|
||||
// currentFrame.video.controls.
|
||||
var maxImageSize = CONFIG_MAX_IMAGE_PIXEL_SIZE;
|
||||
var previousFrame = new MediaFrame($('frame1'), true, maxImageSize);
|
||||
var currentFrame = new MediaFrame($('frame2'), true, maxImageSize);
|
||||
var nextFrame = new MediaFrame($('frame3'), true, maxImageSize);
|
||||
|
||||
if (CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH) {
|
||||
previousFrame.setMinimumPreviewSize(CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH,
|
||||
CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT);
|
||||
currentFrame.setMinimumPreviewSize(CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH,
|
||||
CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT);
|
||||
nextFrame.setMinimumPreviewSize(CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH,
|
||||
CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT);
|
||||
}
|
||||
|
||||
// When this variable is set to true, we ignore any user gestures
|
||||
// so we don't try to pan or zoom during a frame transition.
|
||||
var transitioning = false;
|
||||
|
||||
// Clicking on the back button will go to the preview view
|
||||
// Note that tablet doesn't have a back button, it's bind to header instead,
|
||||
// so we don't try to register click event on a non-existent button
|
||||
if (fullscreenButtons.back) {
|
||||
fullscreenButtons.back.onclick = setView.bind(null, LAYOUT_MODE.list);
|
||||
}
|
||||
|
||||
// Clicking the delete button while viewing a single item deletes that item
|
||||
fullscreenButtons.delete.onclick = deleteSingleItem;
|
||||
|
||||
// Clicking the Edit button while viewing a photo switches to edit mode
|
||||
fullscreenButtons.edit.onclick = function() {
|
||||
LazyLoader.load(['js/ImageEditor.js',
|
||||
'js/image_processor_thread.js',
|
||||
'shared/js/media/crop_resize_rotate.js',
|
||||
'shared/js/component_utils.js',
|
||||
'shared/elements/gaia_menu/script.js'
|
||||
], function() {
|
||||
editPhotoIfCardNotFull(currentFileIndex);
|
||||
});
|
||||
};
|
||||
|
||||
// In fullscreen mode, the share button shares the current item
|
||||
fullscreenButtons.share.onclick = shareSingleItem;
|
||||
|
||||
// Clicking the information button will display information about the photo.
|
||||
fullscreenButtons.info.onclick = function() {
|
||||
LazyLoader.load(['js/info.js', 'shared/style/confirm.css', 'style/info.css'],
|
||||
function() {
|
||||
showFileInformation(files[currentFileIndex]);
|
||||
});
|
||||
};
|
||||
|
||||
// Use the GestureDetector.js library to handle gestures.
|
||||
// This will generate tap, pan, swipe and transform events
|
||||
new GestureDetector(frames).startDetecting();
|
||||
|
||||
var frameOffset = 0; // how far are the frames swiped side-to-side?
|
||||
|
||||
// Handle gesture events
|
||||
frames.addEventListener('tap', tapHandler);
|
||||
frames.addEventListener('dbltap', dblTapHandler);
|
||||
frames.addEventListener('pan', panHandler);
|
||||
frames.addEventListener('swipe', swipeHandler);
|
||||
frames.addEventListener('transform', transformHandler);
|
||||
frames.addEventListener('wheel', wheelHandler);
|
||||
|
||||
currentFrame.video.onfullscreentap =
|
||||
previousFrame.video.onfullscreentap =
|
||||
nextFrame.video.onfullscreentap =
|
||||
function fullscreenRequested(ev) {
|
||||
setView(LAYOUT_MODE.fullscreen);
|
||||
resizeFrames();
|
||||
};
|
||||
|
||||
// When displaying a photo or video, a tap hides or shows the toolbar.
|
||||
// The video player has its own toolbar, so when a video starts playing
|
||||
// we want to hide the gallery toolbar. And then restore it on pause.
|
||||
// All three players need this pair of event handlers.
|
||||
// Note that we're using the onplaying/onpaused fake handlers the
|
||||
// VideoPlayer object, not the real onplay/onpause handlers of the <video>
|
||||
// element. This is because VideoPlayer pauses and plays the <video> when
|
||||
// the user drags on the slider, and we don't want to trigger these handlers
|
||||
// in that case.
|
||||
currentFrame.video.onplaying =
|
||||
previousFrame.video.onplaying =
|
||||
nextFrame.video.onplaying =
|
||||
function hideToolbarOnPlay() {
|
||||
this.isToolbarHidden =
|
||||
fullscreenView.classList.contains('toolbar-hidden');
|
||||
if (!this.isToolbarHidden) {
|
||||
fullscreenView.classList.add('toolbar-hidden');
|
||||
}
|
||||
};
|
||||
|
||||
currentFrame.video.onpaused =
|
||||
previousFrame.video.onpaused =
|
||||
nextFrame.video.onpaused =
|
||||
function restoreToolbarOnPause() {
|
||||
this.isToolbarHidden =
|
||||
fullscreenView.classList.contains('toolbar-hidden');
|
||||
if (this.isToolbarHidden === true) {
|
||||
fullscreenView.classList.remove('toolbar-hidden');
|
||||
}
|
||||
delete this.isToolbarHidden;
|
||||
};
|
||||
|
||||
// Each of the Frame container elements may be subject to animated
|
||||
// transitions. So give them transitionend event handlers that
|
||||
// remove the transition style property when the transition ends.
|
||||
// This helps prevent unexpected transitions.
|
||||
function removeTransition(event) {
|
||||
event.target.style.transition = null;
|
||||
}
|
||||
|
||||
previousFrame.container.addEventListener('transitionend', removeTransition);
|
||||
currentFrame.container.addEventListener('transitionend', removeTransition);
|
||||
nextFrame.container.addEventListener('transitionend', removeTransition);
|
||||
|
||||
// Clicking the delete button while viewing a single item deletes that item
|
||||
function deleteSingleItem() {
|
||||
var msg;
|
||||
if (files[currentFileIndex].metadata.video) {
|
||||
msg = 'delete-video?';
|
||||
}
|
||||
else {
|
||||
msg = 'delete-photo?';
|
||||
}
|
||||
// We need to disable NFC sharing when showing delete confirmation dialog
|
||||
NFC.unshare();
|
||||
|
||||
Dialogs.confirm({
|
||||
messageId: msg,
|
||||
cancelId: 'cancel',
|
||||
confirmId: 'delete',
|
||||
danger: true,
|
||||
bodyClass: 'showing-dialog'
|
||||
}, function() { // onSuccess
|
||||
// disable delete, edit and share button to prevent
|
||||
// operations while delete item
|
||||
fullscreenButtons.delete.classList.add('disabled');
|
||||
fullscreenButtons.share.classList.add('disabled');
|
||||
fullscreenButtons.edit.classList.add('disabled');
|
||||
|
||||
deleteFile(currentFileIndex);
|
||||
// Enable NFC sharing when done deleting and returns to fullscreen view
|
||||
NFC.share(getCurrentFile);
|
||||
}, function() { // onCancel
|
||||
// Enable NFC sharing when cancels delete and returns to fullscreen view
|
||||
NFC.share(getCurrentFile);
|
||||
});
|
||||
}
|
||||
|
||||
// In fullscreen mode, the share button shares the current item
|
||||
function shareSingleItem() {
|
||||
// This is the item we're sharing
|
||||
var fileinfo = files[currentFileIndex];
|
||||
|
||||
// If the item is a video, just share it
|
||||
if (fileinfo.metadata.video) {
|
||||
share([currentFrame.videoblob]);
|
||||
}
|
||||
else {
|
||||
// Otherwise it is an image.
|
||||
// If it does not have any EXIF orientation, and if we don't need
|
||||
// to downsample it, then just share it as it is.
|
||||
if (!fileinfo.metadata.rotation &&
|
||||
!fileinfo.metadata.mirrored &&
|
||||
!CONFIG_MAX_PICK_PIXEL_SIZE) {
|
||||
share([currentFrame.imageblob]);
|
||||
}
|
||||
else {
|
||||
// This is only tricky case. If we are sharing an image that uses
|
||||
// EXIF orientation for correct display, rotate it before sharing
|
||||
// so that the recieving app doesn't have to know about EXIF
|
||||
LazyLoader.load(['shared/js/media/crop_resize_rotate.js'],
|
||||
shareModifiedImage);
|
||||
}
|
||||
}
|
||||
|
||||
function shareModifiedImage() {
|
||||
var metadata = fileinfo.metadata;
|
||||
var button = fullscreenButtons.share;
|
||||
button.classList.add('disabled');
|
||||
Spinner.show();
|
||||
var maxsize = CONFIG_MAX_PICK_PIXEL_SIZE || CONFIG_MAX_IMAGE_PIXEL_SIZE;
|
||||
cropResizeRotate(currentFrame.imageblob, null,
|
||||
maxsize || null, null, metadata,
|
||||
function(error, rotatedBlob) {
|
||||
Spinner.hide();
|
||||
button.classList.remove('disabled');
|
||||
if (error) {
|
||||
console.error('Error while rotating image: ', error);
|
||||
rotatedBlob = currentFrame.imageblob;
|
||||
}
|
||||
share([rotatedBlob], currentFrame.imageblob.name);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// In order to distinguish single taps from double taps, we have to
|
||||
// wait after a tap arrives to make sure that a dbltap event isn't
|
||||
// coming soon.
|
||||
var taptimer = null;
|
||||
function tapHandler(e) {
|
||||
// Ignore tap event if 1. there is already a timer set, then this
|
||||
// is the second tap and we're about to get a double tap event
|
||||
// 2. currentFrame has not yet loaded any image or video.
|
||||
if (taptimer ||
|
||||
(!currentFrame.displayingImage && !currentFrame.displayingVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we don't get a second tap soon, then treat this as a single tap
|
||||
taptimer = setTimeout(function() {
|
||||
taptimer = null;
|
||||
singletap(e);
|
||||
}, GestureDetector.DOUBLE_TAP_TIME);
|
||||
}
|
||||
|
||||
// Dispatch double tap events, but only when displaying a photo
|
||||
function dblTapHandler(e) {
|
||||
if (currentFrame.displayingVideo) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(taptimer);
|
||||
taptimer = null;
|
||||
doubletapOnPhoto(e);
|
||||
}
|
||||
|
||||
// Resize all the frames' content, if its container's size is changed
|
||||
function resizeFrames() {
|
||||
nextFrame.reset();
|
||||
previousFrame.reset();
|
||||
currentFrame.reset();
|
||||
}
|
||||
|
||||
// Handle single tap event on frames
|
||||
// We manage the display of toolbar on the header and footer after user
|
||||
// tap the image or video. If it's in preview mode, it simply switch to
|
||||
// fullscreen mode directly.
|
||||
function singletap(e) {
|
||||
if (currentView === LAYOUT_MODE.fullscreen) {
|
||||
if ((currentFrame.displayingImage || currentFrame.video.player.paused) &&
|
||||
isPhone) {
|
||||
fullscreenView.classList.toggle('toolbar-hidden');
|
||||
}
|
||||
} else if (!isPhone && currentView === LAYOUT_MODE.list &&
|
||||
!files[currentFileIndex].metadata.video) {
|
||||
// We don't separate cases by screen size, because we don't show
|
||||
// preview screen on tiny device.
|
||||
setView(LAYOUT_MODE.fullscreen);
|
||||
resizeFrames();
|
||||
}
|
||||
}
|
||||
|
||||
// Quick zoom in and out with dbltap events
|
||||
function doubletapOnPhoto(e) {
|
||||
// Don't allow zooming while we're still scanning for photos and
|
||||
// have found large photos without previews on the card. Zooming in
|
||||
// decodes the full-size version of the photo and that can cause OOM
|
||||
// errors if there is also metadata scanning going on with large images.
|
||||
// XXX: Remove this when bug 854795 is fixed.
|
||||
if (photodb.parsingBigFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
var scale;
|
||||
if (currentFrame.fit.scale > currentFrame.fit.baseScale) { // If zoomed in
|
||||
scale = currentFrame.fit.baseScale / currentFrame.fit.scale; // zoom out
|
||||
} else { // Otherwise
|
||||
scale = 2; // zoom in
|
||||
}
|
||||
|
||||
currentFrame.zoom(scale, e.detail.clientX, e.detail.clientY, 200);
|
||||
}
|
||||
|
||||
// Pan the item sideways when the user moves their finger across the screen
|
||||
function panHandler(event) {
|
||||
if (transitioning) {
|
||||
return;
|
||||
}
|
||||
|
||||
var dx = event.detail.relative.dx;
|
||||
var dy = event.detail.relative.dy;
|
||||
var oldFrameOffset = frameOffset;
|
||||
|
||||
// If the frames are already being shifted in the same direction as
|
||||
// dx then this just continues the shift. Otherwise, dx might shift
|
||||
// them back toward the center. If the frames are unshifted to begin
|
||||
// with or become unshifted after applying dx, then we have got to
|
||||
// pass dx to the pan() method of the frame, because it might pan
|
||||
// the image within the frame. But that method returns any dx it
|
||||
// can't use, and we apply that to shifting the frames.
|
||||
|
||||
// If the frames are already shifted and dx is in the same direction, or
|
||||
// if dx is in the opposite direction but isn't big enough to bring
|
||||
// the frames back to the center, just adjust the frame positions.
|
||||
// There is no need to pan the content of the frame in this case.
|
||||
if ((frameOffset > 0 && dx > 0) ||
|
||||
(frameOffset < 0 && dx < 0) ||
|
||||
(frameOffset !== 0 && frameOffset > -dx)) {
|
||||
frameOffset += dx;
|
||||
}
|
||||
else {
|
||||
// If the frame is shifted, this dx brings it back to center
|
||||
if (frameOffset !== 0) {
|
||||
dx += frameOffset;
|
||||
frameOffset = 0;
|
||||
}
|
||||
|
||||
// Now let the frame pan its content, and add any dx that it doesn't use
|
||||
// to the frame offset
|
||||
frameOffset += currentFrame.pan(dx, dy);
|
||||
}
|
||||
|
||||
// Don't swipe past the end of the last item or past the start of the first
|
||||
// Handle frameOffset reset in RTL when directions are reversed. See 1099458
|
||||
if (document.documentElement.dir === 'ltr') {
|
||||
if ((currentFileIndex === 0 && frameOffset > 0) ||
|
||||
(currentFileIndex === files.length - 1 && frameOffset < 0)) {
|
||||
frameOffset = 0;
|
||||
}
|
||||
} else {
|
||||
if ((currentFileIndex === 0 && frameOffset < 0) ||
|
||||
(currentFileIndex === files.length - 1 && frameOffset > 0)) {
|
||||
frameOffset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// If the frameOffset has changed since we started, reposition the frames
|
||||
if (frameOffset !== oldFrameOffset) {
|
||||
setFramesPosition();
|
||||
}
|
||||
}
|
||||
|
||||
// When the user lifts their finger after panning we get this event
|
||||
function swipeHandler(event) {
|
||||
if (transitioning) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we just panned within a zoomed-in photo, and the frames are not
|
||||
// shifted at all, then we don't have to do anything here.
|
||||
if (frameOffset === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1 means we're going to the next item -1 means the previous
|
||||
var direction = (frameOffset < 0) ? 1 : -1;
|
||||
|
||||
// If we're in a right-to-left locale, reverse those directions
|
||||
if (document.documentElement.dir === 'rtl') {
|
||||
direction *= -1;
|
||||
}
|
||||
|
||||
// Did we pan far enough or swipe fast enough to transition to
|
||||
// a different item?
|
||||
var farenough =
|
||||
Math.abs(frameOffset) > window.innerWidth * TRANSITION_FRACTION;
|
||||
var velocity = event.detail.vx;
|
||||
var fastenough = Math.abs(velocity) > TRANSITION_SPEED;
|
||||
|
||||
// Make sure that that the speed and pan amount are in the same direction
|
||||
var samedirection = velocity === 0 || frameOffset / velocity >= 0;
|
||||
|
||||
// Is there a next or previous item to transition to?
|
||||
var fileexists =
|
||||
(direction === 1 && currentFileIndex + 1 < files.length) ||
|
||||
(direction === -1 && currentFileIndex > 0);
|
||||
|
||||
// If all of these conditions hold, then we'll transition to the
|
||||
// next photo or the previous photo
|
||||
var time;
|
||||
if (direction !== 0 && (farenough || fastenough) &&
|
||||
samedirection && fileexists) {
|
||||
|
||||
// Compute how long the transition should take based on the velocity
|
||||
var speed = Math.max(Math.abs(velocity), TRANSITION_SPEED);
|
||||
time = (window.innerWidth - Math.abs(frameOffset)) / speed;
|
||||
|
||||
// Transition frames in the appropriate direction
|
||||
if (direction === 1) {
|
||||
nextFile(time);
|
||||
} else {
|
||||
previousFile(time);
|
||||
}
|
||||
}
|
||||
else if (frameOffset !== 0) {
|
||||
// Otherwise, just restore the current item by undoing
|
||||
// the translations we added during panning
|
||||
time = Math.abs(frameOffset) / TRANSITION_SPEED;
|
||||
|
||||
currentFrame.container.style.transition =
|
||||
nextFrame.container.style.transition =
|
||||
previousFrame.container.style.transition =
|
||||
'transform ' + time + 'ms ease';
|
||||
|
||||
resetFramesPosition();
|
||||
|
||||
// Ignore pan and zoom gestures while the transition happens
|
||||
transitioning = true;
|
||||
setTimeout(function() { transitioning = false; }, time);
|
||||
}
|
||||
}
|
||||
|
||||
// When a screen reader swipes with two fingers
|
||||
function wheelHandler(event) {
|
||||
if (event.deltaMode !== event.DOM_DELTA_PAGE || !event.deltaX) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.deltaX > 0) {
|
||||
nextFile(150);
|
||||
} else {
|
||||
previousFile(150);
|
||||
}
|
||||
}
|
||||
|
||||
// We also support pinch-to-zoom
|
||||
function transformHandler(e) {
|
||||
if (transitioning) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow zooming while we're still scanning for photos and
|
||||
// have found large photos without previews on the card. Zooming in
|
||||
// decodes the full-size version of the photo and that can cause OOM
|
||||
// errors if there is also metadata scanning going on with large images.
|
||||
// XXX: Remove this when bug 854795 is fixed.
|
||||
if (photodb.parsingBigFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentFrame.zoom(e.detail.relative.scale,
|
||||
e.detail.midpoint.clientX,
|
||||
e.detail.midpoint.clientY);
|
||||
}
|
||||
|
||||
// A utility function to display the nth image or video in the specified frame
|
||||
// Used in showFile(), nextFile() and previousFile().
|
||||
function setupFrameContent(n, frame) {
|
||||
// Make sure n is in range
|
||||
if (n < 0 || n >= files.length) {
|
||||
frame.clear();
|
||||
delete frame.filename;
|
||||
return;
|
||||
}
|
||||
|
||||
var fileinfo = files[n];
|
||||
|
||||
// If we're already displaying this file in this frame, then do nothing
|
||||
if (fileinfo.name === frame.filename) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remember what file we're going to display
|
||||
frame.filename = fileinfo.name;
|
||||
|
||||
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,
|
||||
fileinfo.metadata.rotation,
|
||||
fileinfo.metadata.mirrored);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var FRAME_BORDER_WIDTH = 3;
|
||||
|
||||
function setFramesPosition() {
|
||||
var width = window.innerWidth + FRAME_BORDER_WIDTH;
|
||||
currentFrame.container.style.transform =
|
||||
'translateX(' + frameOffset + 'px)';
|
||||
if (document.documentElement.dir === 'ltr') {
|
||||
nextFrame.container.style.transform =
|
||||
'translateX(' + (frameOffset + width) + 'px)';
|
||||
previousFrame.container.style.transform =
|
||||
'translateX(' + (frameOffset - width) + 'px)';
|
||||
}
|
||||
else {
|
||||
// For RTL languages we swap next and previous sides
|
||||
nextFrame.container.style.transform =
|
||||
'translateX(' + (frameOffset - width) + 'px)';
|
||||
previousFrame.container.style.transform =
|
||||
'translateX(' + (frameOffset + width) + 'px)';
|
||||
}
|
||||
|
||||
// XXX Bug 1021782 add 'current' class to currentFrame
|
||||
nextFrame.container.classList.remove('current');
|
||||
previousFrame.container.classList.remove('current');
|
||||
currentFrame.container.classList.add('current');
|
||||
|
||||
// Hide adjacent frames from screen reader
|
||||
nextFrame.container.setAttribute('aria-hidden', true);
|
||||
previousFrame.container.setAttribute('aria-hidden', true);
|
||||
currentFrame.container.removeAttribute('aria-hidden');
|
||||
}
|
||||
|
||||
function resetFramesPosition() {
|
||||
frameOffset = 0;
|
||||
setFramesPosition();
|
||||
}
|
||||
|
||||
// Switch from thumbnail list view to single-picture fullscreen view
|
||||
// and display the specified file.
|
||||
function showFile(n) {
|
||||
// Mark what we're focusing on and unmark the old one
|
||||
updateFocusThumbnail(n);
|
||||
updateFrames();
|
||||
|
||||
// Disable the edit button if this is a video or mediaDB is scanning
|
||||
if (files[currentFileIndex].metadata.video ||
|
||||
photodb.scanning) {
|
||||
fullscreenButtons.edit.classList.add('disabled');
|
||||
} else {
|
||||
fullscreenButtons.edit.classList.remove('disabled');
|
||||
}
|
||||
// Always bring delete and share button back after show file
|
||||
fullscreenButtons.delete.classList.remove('disabled');
|
||||
fullscreenButtons.share.classList.remove('disabled');
|
||||
}
|
||||
|
||||
function updateFrames() {
|
||||
var n = currentFileIndex;
|
||||
setupFrameContent(n - 1, previousFrame);
|
||||
setupFrameContent(n, currentFrame);
|
||||
setupFrameContent(n + 1, nextFrame);
|
||||
|
||||
resetFramesPosition();
|
||||
}
|
||||
|
||||
function clearFrames() {
|
||||
previousFrame.clear();
|
||||
currentFrame.clear();
|
||||
nextFrame.clear();
|
||||
delete previousFrame.filename;
|
||||
delete currentFrame.filename;
|
||||
delete nextFrame.filename;
|
||||
}
|
||||
|
||||
// Transition to the next file, animating it over the specified time (ms).
|
||||
// This is used when the user pans.
|
||||
function nextFile(time) {
|
||||
// If already displaying the last one, do nothing.
|
||||
if (currentFileIndex === files.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the current frame is using a <video> 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;
|
||||
|
||||
// Set transitions for the visible frames
|
||||
var transition = 'transform ' + time + 'ms ease';
|
||||
currentFrame.container.style.transition = transition;
|
||||
nextFrame.container.style.transition = transition;
|
||||
|
||||
// Cycle the three frames so next becomes current,
|
||||
// current becomes previous, and previous becomes next.
|
||||
var tmp = previousFrame;
|
||||
previousFrame = currentFrame;
|
||||
currentFrame = nextFrame;
|
||||
nextFrame = tmp;
|
||||
|
||||
updateFocusThumbnail(currentFileIndex + 1);
|
||||
// Move (transition) the frames to their new position
|
||||
resetFramesPosition();
|
||||
|
||||
// Update the frame for the new next item
|
||||
setupFrameContent(currentFileIndex + 1, nextFrame);
|
||||
|
||||
// When the transition is done, cleanup
|
||||
currentFrame.container.addEventListener('transitionend', function done(e) {
|
||||
this.removeEventListener('transitionend', done);
|
||||
|
||||
transitioning = false;
|
||||
|
||||
// Reposition the item that just transitioned off the screen
|
||||
// to reset any zooming and panning
|
||||
previousFrame.reset();
|
||||
});
|
||||
|
||||
// Disable the edit button if this is a video or
|
||||
// mediaDB is scanning, enable otherwise
|
||||
if (currentFrame.displayingVideo || photodb.scanning) {
|
||||
fullscreenButtons.edit.classList.add('disabled');
|
||||
} else {
|
||||
fullscreenButtons.edit.classList.remove('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Just like nextFile() but in the other direction
|
||||
function previousFile(time) {
|
||||
// if already displaying the first one, do nothing.
|
||||
if (currentFileIndex === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the current frame is using a <video> 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;
|
||||
|
||||
// Set transitions for the visible frames
|
||||
var transition = 'transform ' + time + 'ms ease';
|
||||
previousFrame.container.style.transition = transition;
|
||||
currentFrame.container.style.transition = transition;
|
||||
|
||||
// Transition to the previous item: previous becomes current, current
|
||||
// becomes next, etc.
|
||||
var tmp = nextFrame;
|
||||
nextFrame = currentFrame;
|
||||
currentFrame = previousFrame;
|
||||
previousFrame = tmp;
|
||||
|
||||
updateFocusThumbnail(currentFileIndex - 1);
|
||||
// Move (transition) the frames to their new position
|
||||
resetFramesPosition();
|
||||
|
||||
// Preload the new previous item
|
||||
setupFrameContent(currentFileIndex - 1, previousFrame);
|
||||
|
||||
// When the transition is done do some cleanup
|
||||
currentFrame.container.addEventListener('transitionend', function done(e) {
|
||||
this.removeEventListener('transitionend', done);
|
||||
|
||||
transitioning = false;
|
||||
|
||||
// Reset the size and position of the item that just panned off
|
||||
nextFrame.reset();
|
||||
});
|
||||
|
||||
// Disable the edit button if we're now viewing a video or mediaDB
|
||||
// is scanning, enable otherwise
|
||||
if (currentFrame.displayingVideo || photodb.scanning) {
|
||||
fullscreenButtons.edit.classList.add('disabled');
|
||||
} else {
|
||||
fullscreenButtons.edit.classList.remove('disabled');
|
||||
}
|
||||
}
|
||||
830
gaia/apps/gallery/js/gallery.js
Executable file
830
gaia/apps/gallery/js/gallery.js
Executable file
|
|
@ -0,0 +1,830 @@
|
|||
'use strict';
|
||||
/* global
|
||||
$,
|
||||
clearFrames,
|
||||
currentFrame,
|
||||
Dialogs,
|
||||
files,
|
||||
LazyLoader,
|
||||
Pick,
|
||||
picking,
|
||||
MediaDB,
|
||||
MozActivity,
|
||||
NFC,
|
||||
Overlay,
|
||||
photodb,
|
||||
resizeFrames,
|
||||
ScreenLayout,
|
||||
setFramesPosition,
|
||||
showFile
|
||||
*/
|
||||
/* exported
|
||||
compareFilesByDate,
|
||||
deleteSelectedItems,
|
||||
doNotScanInBackgroundHack,
|
||||
fileCreated,
|
||||
fileDeleted,
|
||||
fullscreenView,
|
||||
launchCameraApp,
|
||||
resizeHandler,
|
||||
shareSelectedItems,
|
||||
thumbnailClickHandler,
|
||||
TRANSITION_FRACTION,
|
||||
TRANSITION_SPEED
|
||||
*/
|
||||
|
||||
/*
|
||||
* This app displays photos and videos that are stored on the phone.
|
||||
*
|
||||
* Its starts with a thumbnail view in which small versions of all photos
|
||||
* and videos are displayed. Tapping on a thumbnail shows the image
|
||||
* or video at the full size of the screen and swiping left or right moves to
|
||||
* the next or previous image or video.
|
||||
*
|
||||
* The app supports two-finger "pinch" gestures to zoom in and out on an
|
||||
* image. When zoomed, a one finger swipe gesture pans within the zoomed
|
||||
* image, and only moves to the next or previous image once you reach the
|
||||
* edge of the currently displayed image.
|
||||
*
|
||||
* To make transitions between photos smooth, the app preloads the next
|
||||
* and previous image or video and positions them off-screen to the right and
|
||||
* left of the currently displayed image.
|
||||
*
|
||||
* Image and videos are displayed in "frames" which are managed by
|
||||
* the Frame.js abstraction. A Frame object includes a video player UI
|
||||
* (from VideoPlayer.js) and also includes the code that manage zooming
|
||||
* and panning within an image.
|
||||
*/
|
||||
|
||||
//
|
||||
// Tuneable parameters
|
||||
//
|
||||
|
||||
// Pan this % of width to transition from one item to the next
|
||||
var TRANSITION_FRACTION = 0.25;
|
||||
|
||||
// This is the speed of our default transitions in pixels/ms.
|
||||
// Swipe faster than this to transition faster. But we'll
|
||||
// never go slower (except slide show transitions).
|
||||
var TRANSITION_SPEED = 0.75;
|
||||
|
||||
// UI elements
|
||||
var thumbnails;
|
||||
var thumbnailList;
|
||||
|
||||
var fullscreenView = $('fullscreen-view');
|
||||
|
||||
// These are the top-level view class which are defined in
|
||||
// gallery_tablet.css
|
||||
// This object is used by setView()
|
||||
|
||||
// Layout Mode Transition:
|
||||
// list <-> selection
|
||||
// list <-> fullscreen <-> edit/crop
|
||||
// (activity) pick <-> crop
|
||||
const LAYOUT_MODE = {
|
||||
list: 'thumbnailListView',
|
||||
select: 'thumbnailSelectView',
|
||||
fullscreen: 'fullscreenView',
|
||||
edit: 'editView',
|
||||
pick: 'pickView',
|
||||
crop: 'cropView'
|
||||
};
|
||||
|
||||
var currentView;
|
||||
|
||||
// Register orientation watcher in ScreenLayout
|
||||
ScreenLayout.watch('portrait', '(orientation: portrait)');
|
||||
var isPortrait = ScreenLayout.getCurrentLayout('portrait');
|
||||
var isPhone = ScreenLayout.getCurrentLayout('tiny');
|
||||
|
||||
var fullscreenButtonIds = ['back', 'delete', 'edit', 'share', 'camera', 'info'];
|
||||
var fullscreenButtons = {};
|
||||
for (var i = 0; i < fullscreenButtonIds.length; i++) {
|
||||
var selector = 'fullscreen-' + fullscreenButtonIds[i] + '-button';
|
||||
selector += (isPhone ? '-tiny' : '-large');
|
||||
fullscreenButtons[fullscreenButtonIds[i]] = document.getElementById(selector);
|
||||
}
|
||||
|
||||
var currentFileIndex = 0; // What file is currently displayed
|
||||
var editedPhotoIndex;
|
||||
|
||||
// In thumbnailSelectView, we allow the user to select thumbnails.
|
||||
// These variables hold the names of the selected files, and map those
|
||||
// names to the corresponding File objects
|
||||
var selectedFileNames = [];
|
||||
var selectedFileNamesToBlobs = {};
|
||||
|
||||
// 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 = navigator.getDeviceStorage('videos');
|
||||
|
||||
// Flag that indicates that we've edited a picture and just saved it
|
||||
var justSavedEditedImage = false;
|
||||
|
||||
// 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);
|
||||
};
|
||||
}
|
||||
|
||||
// Return a Promise that resolves to the File object of the currently
|
||||
// displayed image or video.
|
||||
// XXX This function is temporarily added here as part of NFC
|
||||
// refactoring, but will be moved into the model module.
|
||||
function getCurrentFile() {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var fileInfo = files[currentFileIndex];
|
||||
|
||||
if (fileInfo.metadata.video) {
|
||||
getVideoFile(fileInfo.metadata.video, function(file) {
|
||||
resolve(file);
|
||||
});
|
||||
} else {
|
||||
// share photo
|
||||
photodb.getFile(fileInfo.name, function(file) {
|
||||
resolve(file);
|
||||
}, function(errmsg) {
|
||||
reject(errmsg);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// This comparison function is used for sorting arrays and doing binary
|
||||
// search on the resulting sorted arrays.
|
||||
function compareFilesByDate(a, b) {
|
||||
if (a.date < b.date) {
|
||||
return 1; // larger (newer) dates come first
|
||||
} else if (a.date > b.date) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// getFileIndex return position of a file thumbnail in gallery view
|
||||
// It first find thumbnail's position within its own group
|
||||
// then add the sizes of previous groups
|
||||
function getFileIndex(filename) {
|
||||
// Get the group of the file
|
||||
var fileGroup = thumbnailList.groupMap[filename];
|
||||
if (!fileGroup) {
|
||||
console.error('file group does not exist in thumbnail List', filename);
|
||||
return -1;
|
||||
}
|
||||
var index = 0;
|
||||
// Get the thumbnailItem of the file
|
||||
var thumbnail = thumbnailList.thumbnailMap[filename];
|
||||
// Get the index of the file in its own group
|
||||
index = fileGroup.thumbnails.indexOf(thumbnail);
|
||||
if (index < 0) {
|
||||
console.error('filename does not exist in thumbnail list', filename);
|
||||
return -1;
|
||||
}
|
||||
// Add to index the sizes of previous groups
|
||||
for (var n = 0; n < thumbnailList.itemGroups.length; n++) {
|
||||
if (thumbnailList.itemGroups[n].groupID === fileGroup.groupID) {
|
||||
break;
|
||||
}
|
||||
index += thumbnailList.itemGroups[n].getCount();
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function fileDeleted(filename) {
|
||||
var fileIndex = currentFileIndex;
|
||||
// Find the deleted file in our files array
|
||||
for (var n = 0; n < files.length; n++) {
|
||||
if (files[n].name === filename) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (n >= files.length) { // It was a file we didn't know about
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the image from the array
|
||||
files.splice(n, 1)[0];
|
||||
|
||||
// Remove the corresponding thumbnail
|
||||
thumbnailList.removeItem(filename);
|
||||
|
||||
// Adjust currentFileIndex, too, if we have to.
|
||||
if (n < fileIndex) {
|
||||
fileIndex--;
|
||||
}
|
||||
|
||||
// If we remove the last item in files[],
|
||||
// we need to show the previous image, not the next image.
|
||||
if (fileIndex >= files.length) {
|
||||
fileIndex = files.length - 1;
|
||||
}
|
||||
|
||||
if (n < editedPhotoIndex) {
|
||||
editedPhotoIndex--;
|
||||
}
|
||||
|
||||
// If we're in fullscreen mode or has preview screen, then the only way
|
||||
// this function gets called is when we delete the currently displayed photo.
|
||||
// This means that we need to redisplay.
|
||||
if (files.length > 0 && (currentView === LAYOUT_MODE.fullscreen)) {
|
||||
showFile(fileIndex);
|
||||
} else {
|
||||
updateFocusThumbnail(fileIndex);
|
||||
}
|
||||
|
||||
// If there are no more photos show the "no pix" overlay
|
||||
if (files.length === 0) {
|
||||
if (currentView !== LAYOUT_MODE.pick) {
|
||||
setView(LAYOUT_MODE.list);
|
||||
}
|
||||
Overlay.show('emptygallery');
|
||||
}
|
||||
}
|
||||
|
||||
function deleteFile(n) {
|
||||
if (n < 0 || n >= files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete the file from the MediaDB. This removes the db entry and
|
||||
// deletes the file in device storage. This will generate an change
|
||||
// event which will call imageDeleted()
|
||||
var fileinfo = files[n];
|
||||
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);
|
||||
}
|
||||
|
||||
// If the metdata parser saved a preview image for this photo,
|
||||
// delete that, too.
|
||||
if (fileinfo.metadata.preview && fileinfo.metadata.preview.filename) {
|
||||
// We use raw device storage here instead of MediaDB because that is
|
||||
// what MetadataParser.js uses for saving the preview.
|
||||
var pictures = navigator.getDeviceStorage('pictures');
|
||||
pictures.delete(fileinfo.metadata.preview.filename);
|
||||
}
|
||||
}
|
||||
|
||||
function fileCreated(fileinfo) {
|
||||
// If the new file is a video and we're handling an image pick activity
|
||||
// then we won't display the new file.
|
||||
if (picking && fileinfo.metadata.video) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The fileinfo object that MediaDB sends us has a thumbnail blob in it,
|
||||
// fresh from the metadata parser. This blob has been stored in the db, but
|
||||
// the copy we have is still a memory-backed blob. We need to be using a
|
||||
// file backed blob here so that we don't leak memory if the user scans a
|
||||
// fresh sdcard full of hundreds of photos. So we go get the db record out
|
||||
// of the database. It should be an exact copy of what we already have,
|
||||
// except that the thumbnail will be file-backed instead of memory-backed.
|
||||
photodb.getFileInfo(fileinfo.name, function(fileinfo) {
|
||||
var insertPosition;
|
||||
|
||||
// If we were showing the 'no pictures' overlay, hide it
|
||||
if (Overlay.current === 'emptygallery' || Overlay.current === 'scanning') {
|
||||
Overlay.hide();
|
||||
}
|
||||
|
||||
// Create a thumbnailItem for this image and insert it at the right spot
|
||||
thumbnailList.addItem(fileinfo);
|
||||
insertPosition = getFileIndex(fileinfo.name);
|
||||
if (insertPosition < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert the image info into the array
|
||||
files.splice(insertPosition, 0, fileinfo);
|
||||
|
||||
if (currentFileIndex >= insertPosition) {
|
||||
currentFileIndex++;
|
||||
}
|
||||
if (editedPhotoIndex >= insertPosition) {
|
||||
editedPhotoIndex++;
|
||||
}
|
||||
|
||||
// Redisplay the current photo if we're in photo view. The current
|
||||
// photo should not change, but the content of the next or previous frame
|
||||
// might. This call will only make changes if the filename to display
|
||||
// in a frame has actually changed.
|
||||
if (currentView === LAYOUT_MODE.fullscreen) {
|
||||
if (justSavedEditedImage) {
|
||||
var banner = $('edit-copy-save-banner');
|
||||
// Show latest saved file inserted at index insertPosition
|
||||
showFile(insertPosition);
|
||||
document.l10n.setAttributes($('edit-copy-save-status'),
|
||||
'edit-copy-saved');
|
||||
banner.hidden = false;
|
||||
setTimeout(function() { banner.hidden = true; }, 3000);
|
||||
} else {
|
||||
showFile(currentFileIndex);
|
||||
}
|
||||
}
|
||||
justSavedEditedImage = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Make the thumbnail for image n visible
|
||||
function scrollToShowThumbnail(n) {
|
||||
if (!files[n]) {
|
||||
return;
|
||||
}
|
||||
var selector = 'img[data-filename="' + files[n].name + '"]';
|
||||
var thumbnail = thumbnails.querySelector(selector);
|
||||
if (thumbnail) {
|
||||
var screenTop = thumbnails.scrollTop;
|
||||
var screenBottom = screenTop + thumbnails.clientHeight;
|
||||
var thumbnailTop = thumbnail.offsetTop;
|
||||
var thumbnailBottom = thumbnailTop + thumbnail.offsetHeight;
|
||||
var toolbarHeight = 40; // compute this dynamically?
|
||||
|
||||
// Adjust the screen bottom up to be above the overlaid footer
|
||||
screenBottom -= toolbarHeight;
|
||||
|
||||
if (thumbnailTop < screenTop) { // If thumbnail is above screen
|
||||
thumbnails.scrollTop = thumbnailTop; // scroll up to show it.
|
||||
}
|
||||
else if (thumbnailBottom > screenBottom) { // If thumbnail is below screen
|
||||
thumbnails.scrollTop = // scroll down to show it
|
||||
thumbnailBottom - thumbnails.clientHeight + toolbarHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setView(view) {
|
||||
if (currentView === view) {
|
||||
return;
|
||||
}
|
||||
// define each view's layout based on data-view of body
|
||||
document.body.classList.remove(currentView);
|
||||
document.body.classList.add(view);
|
||||
// Do any necessary cleanup of the view we're exiting
|
||||
switch (currentView) {
|
||||
case LAYOUT_MODE.select:
|
||||
// Clear the selection, if there is one
|
||||
Array.forEach(thumbnails.querySelectorAll('.selected.thumbnailImage'),
|
||||
function(elt) { elt.classList.remove('selected'); });
|
||||
// On large devices we need to display the new current file after deletion
|
||||
// But if we just deleted the last file then we don't do this
|
||||
if (!isPhone && currentFileIndex !== -1) {
|
||||
showFile(currentFileIndex);
|
||||
}
|
||||
break;
|
||||
case LAYOUT_MODE.fullscreen:
|
||||
if (!isPhone && (view === LAYOUT_MODE.list) && !isPortrait &&
|
||||
currentFileIndex !== -1) {
|
||||
// we'll reuse and resize the fullscreen window
|
||||
// when go back to thumbnailList mode from fullscreen
|
||||
// and also does editView in landscape
|
||||
resizeFrames();
|
||||
} else {
|
||||
// Clear the frames to release the memory they're holding and
|
||||
// so that we don't see a flash of the old image when we return
|
||||
// to fullscreen view
|
||||
clearFrames();
|
||||
}
|
||||
break;
|
||||
}
|
||||
switch (view) {
|
||||
case LAYOUT_MODE.list:
|
||||
// If we're going to fullscreen, then we were just viewing a photo
|
||||
// or video, so make sure its thumbnail is fully on the screen.
|
||||
// XXX: do we need to defer this?
|
||||
scrollToShowThumbnail(currentFileIndex);
|
||||
if (currentView === LAYOUT_MODE.fullscreen) {
|
||||
// only do it when we back from fullscreen.
|
||||
NFC.unshare();
|
||||
}
|
||||
break;
|
||||
case LAYOUT_MODE.fullscreen:
|
||||
resizeFrames();
|
||||
NFC.share(getCurrentFile);
|
||||
break;
|
||||
case LAYOUT_MODE.select:
|
||||
clearSelection();
|
||||
// When entering select view, we pause the video
|
||||
if (!isPhone && currentFrame.video && !isPortrait) {
|
||||
currentFrame.video.pause();
|
||||
}
|
||||
break;
|
||||
case LAYOUT_MODE.edit:
|
||||
NFC.unshare();
|
||||
break;
|
||||
}
|
||||
|
||||
// We reuse the fullscreen dom for preview and fullscreen dom,
|
||||
// so the title must be changed while switching
|
||||
if (!isPhone) {
|
||||
if (view !== LAYOUT_MODE.fullscreen) {
|
||||
$('fullscreen-title').setAttribute('data-l10n-id', 'preview');
|
||||
} else {
|
||||
$('fullscreen-title').setAttribute('data-l10n-id', 'gallery');
|
||||
}
|
||||
}
|
||||
// Remember the current view
|
||||
currentView = view;
|
||||
}
|
||||
|
||||
//
|
||||
// Event handlers
|
||||
//
|
||||
|
||||
// Clicking on a thumbnail does different things depending on the view.
|
||||
// 1. On pickView -> go to cropMode
|
||||
// 2. On large/selectView -> update preview image
|
||||
// 3. On tiny/large with listView -> go to fullscreen image
|
||||
function thumbnailClickHandler(evt) {
|
||||
var target = evt.target;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bug 1106877 - Handle tap for clicks in gray area of containing
|
||||
// div for thumbnail images smaller than thumbnail container.
|
||||
target = target.classList.contains('thumbnail') ?
|
||||
target.firstElementChild : target;
|
||||
|
||||
if (!target || !target.classList.contains('thumbnailImage')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the MediaDB is not fully ready yet, then ignore the event
|
||||
if (photodb.state !== MediaDB.READY) {
|
||||
return;
|
||||
}
|
||||
|
||||
var index = getFileIndex(target.dataset.filename);
|
||||
if (picking && currentView === LAYOUT_MODE.pick && index >= 0) {
|
||||
Pick.select(files[index]);
|
||||
} else if (currentView === LAYOUT_MODE.select) {
|
||||
updateSelection(target);
|
||||
} else {
|
||||
LazyLoader.load('js/frame_scripts.js', function() {
|
||||
if (isPortrait || isPhone) {
|
||||
setView(LAYOUT_MODE.fullscreen);
|
||||
}
|
||||
showFile(index);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// On large screen, we outline the picture we're focusing on and
|
||||
// update the currentFileIndex.
|
||||
function updateFocusThumbnail(n) {
|
||||
var previousIndex = currentFileIndex;
|
||||
currentFileIndex = n;
|
||||
if (isPhone || currentFileIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If file is delted on select mode, the currentFileIndex may
|
||||
// be the same as previousIndex. We need to hightlight it again.
|
||||
var newTarget =
|
||||
thumbnailList.thumbnailMap[files[currentFileIndex].name];
|
||||
if (newTarget) {
|
||||
newTarget.htmlNode.classList.add('focus');
|
||||
}
|
||||
|
||||
if (previousIndex === currentFileIndex) {
|
||||
return;
|
||||
}
|
||||
var oldTarget =
|
||||
files[previousIndex] ?
|
||||
thumbnailList.thumbnailMap[files[previousIndex].name] :
|
||||
undefined;
|
||||
if (oldTarget) {
|
||||
oldTarget.htmlNode.classList.remove('focus');
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
if (!isPhone) {
|
||||
// Clear preview screen on large device.
|
||||
clearFrames();
|
||||
}
|
||||
selectedFileNames = [];
|
||||
selectedFileNamesToBlobs = {};
|
||||
$('thumbnails-delete-button').classList.add('disabled');
|
||||
$('thumbnails-share-button').classList.add('disabled');
|
||||
document.l10n.setAttributes(
|
||||
$('thumbnails-number-selected'),
|
||||
'number-selected2',
|
||||
{ n: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
// When we enter thumbnail selection mode, or when the selection changes
|
||||
// we call this function to update the message the top of the screen and to
|
||||
// enable or disable the Delete and Share buttons
|
||||
function updateSelection(thumbnail) {
|
||||
// First, update the visual appearance of the element
|
||||
thumbnail.classList.toggle('selected');
|
||||
|
||||
// Now update the list of selected filenames and filename->blob map
|
||||
// based on whether we selected or deselected the thumbnail
|
||||
var selected = thumbnail.classList.contains('selected');
|
||||
var index = getFileIndex(thumbnail.dataset.filename);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
var filename = files[index].name;
|
||||
|
||||
if (selected) {
|
||||
selectedFileNames.push(filename);
|
||||
// currentFileIndex point to the last selected fileIndex
|
||||
updateFocusThumbnail(index);
|
||||
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;
|
||||
});
|
||||
}
|
||||
if (!isPhone) {
|
||||
showFile(currentFileIndex);
|
||||
}
|
||||
}
|
||||
else {
|
||||
delete selectedFileNamesToBlobs[filename];
|
||||
var i = selectedFileNames.indexOf(filename);
|
||||
if (i !== -1) {
|
||||
selectedFileNames.splice(i, 1);
|
||||
}
|
||||
|
||||
if (currentFileIndex === index && !isPhone) {
|
||||
if (i > 0) {
|
||||
// show the last selected image of selectedFileNames.
|
||||
var lastSelected = selectedFileNames[i - 1];
|
||||
var lastSelectedIndex = getFileIndex(lastSelected);
|
||||
updateFocusThumbnail(lastSelectedIndex);
|
||||
showFile(currentFileIndex);
|
||||
} else {
|
||||
// If selectedFileNames is empty, we clear preview
|
||||
// screen.
|
||||
clearFrames();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now update the UI based on the number of selected thumbnails
|
||||
var numSelected = selectedFileNames.length;
|
||||
document.l10n.setAttributes(
|
||||
$('thumbnails-number-selected'),
|
||||
'number-selected2',
|
||||
{ n: numSelected }
|
||||
);
|
||||
|
||||
if (numSelected === 0) {
|
||||
$('thumbnails-delete-button').classList.add('disabled');
|
||||
$('thumbnails-share-button').classList.add('disabled');
|
||||
}
|
||||
else {
|
||||
$('thumbnails-delete-button').classList.remove('disabled');
|
||||
$('thumbnails-share-button').classList.remove('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
function launchCameraApp() {
|
||||
fullscreenButtons.camera.classList.add('disabled');
|
||||
$('thumbnails-camera-button').classList.add('disabled');
|
||||
$('overlay-camera-button').classList.add('disabled');
|
||||
|
||||
var a = new MozActivity({
|
||||
name: 'record',
|
||||
data: {
|
||||
type: 'photos'
|
||||
}
|
||||
});
|
||||
// Assign an onsuccess function to the activity so jshint doesn't complain.
|
||||
a.onsuccess = () => {};
|
||||
|
||||
// Wait 2000ms before re-enabling the Camera buttons to prevent
|
||||
// hammering them and causing a crash (Bug 957709)
|
||||
window.setTimeout(function() {
|
||||
fullscreenButtons.camera.classList.remove('disabled');
|
||||
$('thumbnails-camera-button').classList.remove('disabled');
|
||||
$('overlay-camera-button').classList.remove('disabled');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function deleteSelectedItems() {
|
||||
var selected = thumbnails.querySelectorAll('.selected.thumbnailImage');
|
||||
if (selected.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Dialogs.confirm({
|
||||
messageId: 'delete-n-items?',
|
||||
messageArgs: {n: selected.length},
|
||||
cancelId: 'cancel',
|
||||
confirmId: 'delete',
|
||||
danger: true,
|
||||
bodyClass: 'showing-dialog'
|
||||
}, function() { // onSuccess
|
||||
// deleteFile is O(n), so this loop is O(n*n). If used with really large
|
||||
// selections, it might have noticably bad performance. If so, we
|
||||
// can write a more efficient deleteFiles() function.
|
||||
for (var i = 0; i < selected.length; i++) {
|
||||
selected[i].classList.toggle('selected');
|
||||
deleteFile(getFileIndex(selected[i].dataset.filename));
|
||||
}
|
||||
clearSelection();
|
||||
});
|
||||
}
|
||||
|
||||
// Clicking on the share button in select mode shares all selected images
|
||||
// Note: when sharing multiple items, we make no attempt to handle EXIF
|
||||
// orientation in photos. So on devices (like Tarako) that rely on EXIF
|
||||
// orientation in the camera app, we may be leaking images that will not
|
||||
// work well on the web.
|
||||
function shareSelectedItems() {
|
||||
var blobs = selectedFileNames.map(function(name) {
|
||||
return selectedFileNamesToBlobs[name];
|
||||
});
|
||||
share(blobs);
|
||||
}
|
||||
|
||||
// Initiate a share activity for all of the Blobs in the blobs array.
|
||||
// Usually these blobs are File objects, with names. But when sharing a
|
||||
// single image, we sometimes pass an in-memory blob to handle EXIF orientation
|
||||
// issues. In that case, use the second name argument for the unnamed blob
|
||||
function share(blobs, blobName) {
|
||||
if (blobs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
var names = [], types = [], fullpaths = [];
|
||||
|
||||
// Get the file name (minus path) and type of each blob
|
||||
blobs.forEach(function(blob) {
|
||||
var name = blob.name;
|
||||
|
||||
// Special case for blobs that are not File objects
|
||||
if (!name && blobs.length === 1) {
|
||||
name = blobName;
|
||||
}
|
||||
|
||||
// We try to fix Bug 814323 by using
|
||||
// current workaround of bluetooth transfer
|
||||
// so we will pass both filenames and fullpaths
|
||||
// The fullpaths can be removed after Bug 811615 is fixed
|
||||
fullpaths.push(name);
|
||||
// Discard the path, we just want the base name
|
||||
name = name.substring(name.lastIndexOf('/') + 1);
|
||||
names.push(name);
|
||||
|
||||
// And we just want the first component of the type "image" or "video"
|
||||
var type = blob.type;
|
||||
if (type) {
|
||||
type = type.substring(0, type.indexOf('/'));
|
||||
}
|
||||
types.push(type);
|
||||
});
|
||||
|
||||
// If there is just one type, or if all types are the same, then use
|
||||
// that type plus '/*'. Otherwise, use 'application/*'
|
||||
// If all the blobs are image we use 'image/*'. If all are videos
|
||||
// we use 'video/*'. Otherwise, 'application/*'.
|
||||
var type;
|
||||
if (types.length === 1 ||
|
||||
types.every(function(t) { return t === types[0]; })) {
|
||||
type = types[0] + '/*';
|
||||
} else {
|
||||
type = 'application/*';
|
||||
}
|
||||
|
||||
var a = new MozActivity({
|
||||
name: 'share',
|
||||
data: {
|
||||
type: type,
|
||||
number: blobs.length,
|
||||
blobs: blobs,
|
||||
filenames: names,
|
||||
filepaths: fullpaths
|
||||
}
|
||||
});
|
||||
|
||||
a.onerror = function(e) {
|
||||
if (a.error.name === 'NO_PROVIDER') {
|
||||
document.l10n.formatValue('share-noprovider').then(alert);
|
||||
}
|
||||
else {
|
||||
console.warn('share activity error:', a.error.name);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// This happens when the user rotates the phone.
|
||||
// When we used mozRequestFullscreen, it would also happen
|
||||
// when we entered or left fullscreen mode.
|
||||
// As a workaround for Bug 961636, use resize event handler
|
||||
// in place of screenlayoutchange event handler.
|
||||
function resizeHandler() {
|
||||
isPortrait = ScreenLayout.getCurrentLayout('portrait');
|
||||
|
||||
// In list view when video is playing, if user rotate screen from
|
||||
// landscape to portrait, the video pause.
|
||||
// Check if currentFrame is undefined for cases where frame_script.js
|
||||
// is not loaded e.g. if a user rotates phone
|
||||
// in list mode before opening an image.
|
||||
if (currentView === LAYOUT_MODE.list && isPortrait &&
|
||||
typeof currentFrame !== 'undefined' && currentFrame.video) {
|
||||
currentFrame.video.pause();
|
||||
}
|
||||
|
||||
// We'll need to resize and reposition frames for below cases, since
|
||||
// the size of container has been changed.
|
||||
if (currentView === LAYOUT_MODE.fullscreen ||
|
||||
(!isPhone && !isPortrait &&
|
||||
(currentView === LAYOUT_MODE.list ||
|
||||
currentView === LAYOUT_MODE.select))) {
|
||||
resizeFrames();
|
||||
// We also have to reposition the frames to get the next and previous
|
||||
// frames the correct distance away from the current frame
|
||||
setFramesPosition();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* This is a temporary workaround to bug 1039943: when the user launches
|
||||
* the gallery and then switches to another app gallery's scanning and
|
||||
* thumbnail generation process can slow down foreground apps.
|
||||
*
|
||||
* For now, we address this by simply making the app exit if it goes to the
|
||||
* background while scanning and if the device has 256mb or less of memory.
|
||||
*
|
||||
* Bug 1046995 should fix this issue in a better way and when it does,
|
||||
* we should remove this function and the code that invokes it.
|
||||
*/
|
||||
function doNotScanInBackgroundHack(photodb) {
|
||||
const enoughMB = 512; // How much memory is enough to not do this hack?
|
||||
var memoryMB = 0; // How much memory do we have?
|
||||
|
||||
// Listen for visibilitychange events that happen when we go to the background
|
||||
window.addEventListener('visibilitychange', backgroundScanKiller);
|
||||
|
||||
// Stop listening for those events when the scan is complete
|
||||
photodb.addEventListener('scanend', function() {
|
||||
window.removeEventListener('visibilitychange', backgroundScanKiller);
|
||||
});
|
||||
|
||||
|
||||
// This is what we do when we go to the background
|
||||
function backgroundScanKiller() {
|
||||
// If we're coming back to the foreground or if we already know that
|
||||
// we have enough memory, then do nothing here.
|
||||
if (!document.hidden || memoryMB >= enoughMB) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we can't query our memory (i.e. this is the 1.4 Dolphin release)
|
||||
// then assume that we have low memory and exit
|
||||
if (!navigator.getFeature) {
|
||||
exit();
|
||||
}
|
||||
else {
|
||||
// Otherwise we're in release 2.0 or later and can actually query
|
||||
// how much memory we have.
|
||||
navigator.getFeature('hardware.memory').then(function(mem) {
|
||||
memoryMB = mem;
|
||||
if (memoryMB < enoughMB) {
|
||||
exit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function exit() {
|
||||
// If we are still hidden and still scanning, then log a message
|
||||
// wait a bit for the log to be flushed, and then close the application
|
||||
if (document.hidden && photodb.scanning) {
|
||||
console.warn('[Gallery] exiting to avoid background scan.');
|
||||
setTimeout(function() {
|
||||
if (document.hidden) {
|
||||
window.close();
|
||||
}
|
||||
else {
|
||||
console.warn('[Gallery] now visible again, so not exiting');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
gaia/apps/gallery/js/globals.js
Executable file
16
gaia/apps/gallery/js/globals.js
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
/* exported files, picking, photodb */
|
||||
|
||||
'use strict';
|
||||
|
||||
// This array holds information about all the image and video files we
|
||||
// know about. Each array element is an object that includes a
|
||||
// filename and metadata. The array is initially filled when we
|
||||
// enumerate the mediadb, and has elements added and removed when we
|
||||
// receive create and delete events from the media databases.
|
||||
var files = [];
|
||||
|
||||
// If we're doing a pick activity, our URL will end with '#pick'
|
||||
var picking = (window.location.hash === '#pick');
|
||||
|
||||
// The MediaDB object that manages the filesystem and the database of metadata
|
||||
var photodb;
|
||||
485
gaia/apps/gallery/js/image_processor.js
Executable file
485
gaia/apps/gallery/js/image_processor.js
Executable file
|
|
@ -0,0 +1,485 @@
|
|||
/*
|
||||
* image_processor.js:
|
||||
*
|
||||
* This module defines an ImageProcessor class, which provides the image
|
||||
* processing primitives required by the Gallery app image editor.
|
||||
*
|
||||
* When creating an ImageProcessor, you must specify the maximum number
|
||||
* of pixels that will be processed in a single batch. When done with
|
||||
* an ImageProcessor, call destroy() if you want to immediately deallocate
|
||||
* any resources it uses.
|
||||
*
|
||||
* Use the setGamma() method to specify the value to use for gamma correction.
|
||||
* a value of 1.0 leaves the image unmodified.
|
||||
*
|
||||
* Use the setLevels() method to specify the level parameters used for
|
||||
* image auto enhancement. These parameters are usually computed from a
|
||||
* histogram and take the form of an object like this:
|
||||
* {
|
||||
* red: { min: 20, max:240 },
|
||||
* green: { min: 30, max:200 },
|
||||
* blue: { min: 0, max:150 },
|
||||
* }
|
||||
*
|
||||
* Use setMatrix() to specify a 3-row by 4 column matrix used to modify
|
||||
* each pixel of the image (for converting to black and white or sepia,
|
||||
* for example). The matrix is represented as an array of 12 floating-point
|
||||
* numbers.
|
||||
*
|
||||
* Call processImage() to process pixels using the gamma, levels and matrix
|
||||
* previously set with other methods. processImage() expects an ArrayBuffer
|
||||
* of pixel data in ImageData format (four bytes per pixel in rgba order).
|
||||
* processImage() returns a Promise that resolves to an ArrayBuffer
|
||||
* (possibly the same one) when the processing is done.
|
||||
*
|
||||
* To compute a histogram for an image, call computeHistogram() with an
|
||||
* array buffer of rgba pixels. The return value is a Promise that resolves
|
||||
* to an array buffer. The returned array buffer is 3kb long and should be
|
||||
* interpreted as 3 arrays each with 256 32-bit integers. These arrays hold
|
||||
* the counts for the red, green, and blue components.
|
||||
*
|
||||
* Even though it implements a Promise-based API, ImageProcessor is fully
|
||||
* synchronous. The Promises it returns are already resolved.
|
||||
*
|
||||
* image_processor_thread.js defines an ImageProcessorThread class that
|
||||
* implements the same API as ImageProcessor, but does so with asynchronous
|
||||
* promises in a different worker thread. For responsiveness, it is
|
||||
* generally better to use ImageProcessorThread instead of ImageProcessor.
|
||||
*
|
||||
* An ImageProcessorThread implements the ImageProcessor API by sending
|
||||
* messsages to a Worker. The worker loads image_processor_worker.js which
|
||||
* is responsible for receiving the messages sent by image_processor_thread.
|
||||
* image_processor_worker.js loads this ImageProcessor class into the
|
||||
* worker context where it can run synchronously without affecting UI
|
||||
* responsiveness.
|
||||
*
|
||||
* Finally, note that this ImageProcessor class contains an
|
||||
* ImageProcessorEngine module inside of it. This "engine" is a hand-written
|
||||
* asm.js module that performs the actual pixel value manipulation much
|
||||
* faster than would be possible with regular JavaScript code.
|
||||
*/
|
||||
(function(exports) {
|
||||
'use strict';
|
||||
|
||||
var global = exports; // window or worker context
|
||||
exports.ImageProcessor = ImageProcessor;
|
||||
|
||||
function ImageProcessor(maxpixels) {
|
||||
// If we're being called directly, then we get maxpixels and initialize now.
|
||||
// Otherwise, if we're being used in a worker, then the initialize call
|
||||
// will arrive separately.
|
||||
if (maxpixels) {
|
||||
this.initialize(maxpixels);
|
||||
}
|
||||
}
|
||||
|
||||
// We do this in a separate method because we have to do this in a worker, too
|
||||
ImageProcessor.prototype.initialize = function(maxpixels) {
|
||||
this.maxpixels = maxpixels;
|
||||
this.hasNewParams = true;
|
||||
this.engine = null;
|
||||
this.params = {};
|
||||
this.setGamma(1.0);
|
||||
this.setLevels(null);
|
||||
this.setMatrix(null);
|
||||
};
|
||||
|
||||
ImageProcessor.prototype.destroy = function() {
|
||||
// ImageProcessorThread needs this method, so we define it here, too.
|
||||
};
|
||||
|
||||
ImageProcessor.prototype.setGamma = function setGamma(gamma) {
|
||||
this.params.gamma = gamma || 1.0;
|
||||
this.hasNewParams = true;
|
||||
};
|
||||
|
||||
// This could be generalized to take three 256-byte arrays for
|
||||
// completely general value mapping.
|
||||
ImageProcessor.prototype.setLevels = function setLevels(levels) {
|
||||
if (levels) {
|
||||
this.params.hasLevels = true;
|
||||
this.params.rmin = levels.red.min;
|
||||
this.params.rmax = levels.red.max;
|
||||
this.params.gmin = levels.green.min;
|
||||
this.params.gmax = levels.green.max;
|
||||
this.params.bmin = levels.blue.min;
|
||||
this.params.bmax = levels.blue.max;
|
||||
}
|
||||
else {
|
||||
this.params.hasLevels = false;
|
||||
// these default values will cause no level correction to be done
|
||||
this.params.rmin = 0;
|
||||
this.params.rmax = 0;
|
||||
this.params.gmin = 0;
|
||||
this.params.gmax = 0;
|
||||
this.params.bmin = 0;
|
||||
this.params.bmax = 0;
|
||||
}
|
||||
|
||||
this.hasNewParams = true;
|
||||
};
|
||||
|
||||
ImageProcessor.prototype.setMatrix = function setMatrix(matrix) {
|
||||
if (matrix) {
|
||||
this.params.hasMatrix = true;
|
||||
this.params.m0 = matrix[0];
|
||||
this.params.m1 = matrix[1];
|
||||
this.params.m2 = matrix[2];
|
||||
this.params.m3 = matrix[3];
|
||||
this.params.m4 = matrix[4];
|
||||
this.params.m5 = matrix[5];
|
||||
this.params.m6 = matrix[6];
|
||||
this.params.m7 = matrix[7];
|
||||
this.params.m8 = matrix[8];
|
||||
this.params.m9 = matrix[9];
|
||||
this.params.m10 = matrix[10];
|
||||
this.params.m11 = matrix[11];
|
||||
}
|
||||
else {
|
||||
this.params.hasMatrix = false;
|
||||
this.params.m0 = 0;
|
||||
this.params.m1 = 0;
|
||||
this.params.m2 = 0;
|
||||
this.params.m3 = 0;
|
||||
this.params.m4 = 0;
|
||||
this.params.m5 = 0;
|
||||
this.params.m6 = 0;
|
||||
this.params.m7 = 0;
|
||||
this.params.m8 = 0;
|
||||
this.params.m9 = 0;
|
||||
this.params.m10 = 0;
|
||||
this.params.m11 = 0;
|
||||
}
|
||||
|
||||
this.hasNewParams = true;
|
||||
};
|
||||
|
||||
ImageProcessor.prototype.processImage = function processImage(pixelBuffer) {
|
||||
// If this is a no-op, just return right away
|
||||
if (this.params.gamma === 1.0 &&
|
||||
!this.params.hasLevels &&
|
||||
!this.params.hasMatrix) {
|
||||
return Promise.resolve(pixelBuffer);
|
||||
}
|
||||
|
||||
if (this.hasNewParams) {
|
||||
this.setParams();
|
||||
}
|
||||
|
||||
// These are the pixels that were passed in
|
||||
var pixels = new Uint8Array(pixelBuffer);
|
||||
|
||||
// This is the array we have to copy the pixels into so that the
|
||||
// asm.js processor can manipulate them.
|
||||
var heapPixels = new Uint8Array(this.heap,
|
||||
ImageProcessorEngine.pixelOffset,
|
||||
pixels.length);
|
||||
|
||||
// Copy the input pixels onto the asm.js heap
|
||||
heapPixels.set(pixels);
|
||||
|
||||
// Process the pixels with our asm.js module
|
||||
this.engine.process(heapPixels.length,
|
||||
this.params.hasLevels || this.params.gamma !== 1.0,
|
||||
this.params.hasMatrix);
|
||||
|
||||
// Copy the processed pixels from the heap back to the local array
|
||||
pixels.set(heapPixels);
|
||||
|
||||
return Promise.resolve(pixelBuffer);
|
||||
};
|
||||
|
||||
ImageProcessor.prototype.computeHistogram = function(pixelBuffer) {
|
||||
var pixels = new Uint8Array(pixelBuffer);
|
||||
var histogramBuffer = new ArrayBuffer(3 * 4 * 256);
|
||||
var red = new Uint32Array(histogramBuffer, 0, 256);
|
||||
var green = new Uint32Array(histogramBuffer, 4 * 256, 256);
|
||||
var blue = new Uint32Array(histogramBuffer, 8 * 256, 256);
|
||||
|
||||
// Compute a histogram of the image
|
||||
// There is not an asm.js version of this becasue it is fast
|
||||
// enough just like this, especially since we compute the
|
||||
// histogram of the preview image, not the full-size image.
|
||||
for(var i = 0; i < pixels.length; i += 4) {
|
||||
red[pixels[i]]++;
|
||||
green[pixels[i+1]]++;
|
||||
blue[pixels[i+2]]++;
|
||||
}
|
||||
return Promise.resolve(histogramBuffer);
|
||||
};
|
||||
|
||||
ImageProcessor.prototype.setParams = function setParams() {
|
||||
if (!this.engine) {
|
||||
this.createEngine();
|
||||
}
|
||||
|
||||
var p = this.params;
|
||||
|
||||
this.engine.setParams(p.gamma,
|
||||
p.rmin, p.rmax,
|
||||
p.gmin, p.gmax,
|
||||
p.bmin, p.bmax,
|
||||
p.hasMatrix,
|
||||
p.m0, p.m1, p.m2, p.m3,
|
||||
p.m4, p.m5, p.m6, p.m7,
|
||||
p.m8, p.m9, p.m10, p.m11);
|
||||
|
||||
this.hasNewParams = false;
|
||||
};
|
||||
|
||||
ImageProcessor.prototype.createEngine = function createEngine() {
|
||||
var heapSize = ImageProcessorEngine.computeHeapSize(this.maxpixels);
|
||||
this.heap = new ArrayBuffer(heapSize);
|
||||
this.engine = ImageProcessorEngine(global, null, this.heap);
|
||||
};
|
||||
|
||||
/*
|
||||
* This asm.js module does the actual manipulation of pixels.
|
||||
* asm.js is giving us a significant speed boost here, so be extra
|
||||
* careful when editing this code to make sure that it still compiles
|
||||
* correctly as an asm.js module.
|
||||
*/
|
||||
function ImageProcessorEngine(stdlib, foreign, heap) {
|
||||
'use asm';
|
||||
|
||||
const bytes = new stdlib.Uint8Array(heap);
|
||||
|
||||
const min = stdlib.Math.min;
|
||||
const max = stdlib.Math.max;
|
||||
const pow = stdlib.Math.pow;
|
||||
|
||||
const GAMMA = 0x0000; // Offsets into the heap
|
||||
const RED = 0x0100;
|
||||
const GREEN = 0x0200;
|
||||
const BLUE = 0x0300;
|
||||
const M0 = 0x0400;
|
||||
const M1 = 0x0500;
|
||||
const M2 = 0x0600;
|
||||
const M3 = 0x0700;
|
||||
const M4 = 0x0800;
|
||||
const M5 = 0x0900;
|
||||
const M6 = 0x0A00;
|
||||
const M7 = 0x0B00;
|
||||
const M8 = 0x0C00;
|
||||
const M9 = 0x0D00;
|
||||
const M10 = 0x0E00;
|
||||
const M11 = 0x0F00;
|
||||
const PX = 0x1000; // pixels go here
|
||||
|
||||
// In setLevels, if the max-min range is smaller than this, then we
|
||||
// assume that the image is not actually a photo and we don't adjust.
|
||||
const LEVELS_THRESHOLD = 25.0;
|
||||
|
||||
function process(numbytes, doLevelsOrGamma, doMatrix) {
|
||||
numbytes = numbytes | 0;
|
||||
doLevelsOrGamma = doLevelsOrGamma | 0;
|
||||
doMatrix = doMatrix | 0;
|
||||
|
||||
var i = 0;
|
||||
var r = 0, g = 0, b = 0;
|
||||
var r2 = 0, g2 = 0, b2 = 0;
|
||||
|
||||
for(i = 0; (i|0) < (numbytes|0); i = (i + 4)|0) {
|
||||
|
||||
// Read the raw red, green and blue values
|
||||
r = bytes[(PX+i)|0]|0;
|
||||
g = bytes[(PX+i+1)|0]|0;
|
||||
b = bytes[(PX+i+2)|0]|0;
|
||||
|
||||
// If we have gamma correction or auto levels, do that now
|
||||
if ((doLevelsOrGamma|0) == 1) {
|
||||
r = bytes[((RED|0) + (r|0))|0] | 0;
|
||||
g = bytes[((GREEN|0) + (g|0))|0] | 0;
|
||||
b = bytes[((BLUE|0) + (b|0))|0] | 0;
|
||||
}
|
||||
|
||||
// Color filter, if there is one specified.
|
||||
if ((doMatrix|0) == 1) {
|
||||
r2 = ((bytes[(M0+r)|0]|0) + (bytes[(M1+g)|0]|0) +
|
||||
(bytes[(M2+b)|0]|0) + (bytes[M3]|0))|0;
|
||||
|
||||
g2 = ((bytes[(M4+r)|0]|0) + (bytes[(M5+g)|0]|0) +
|
||||
(bytes[(M6+b)|0]|0) + (bytes[M7]|0))|0;
|
||||
|
||||
b2 = ((bytes[(M8+r)|0]|0) + (bytes[(M9+g)|0]|0) +
|
||||
(bytes[(M10+b)|0]|0) + (bytes[M11]|0))|0;
|
||||
|
||||
r = min(max(r2|0, 0), 255);
|
||||
g = min(max(g2|0, 0), 255);
|
||||
b = min(max(b2|0, 0), 255);
|
||||
}
|
||||
|
||||
// store the values back into the image
|
||||
bytes[(PX+i)|0] = r;
|
||||
bytes[(PX+i+1)|0] = g;
|
||||
bytes[(PX+i+2)|0] = b;
|
||||
}
|
||||
}
|
||||
function setParams(gamma,
|
||||
rmin, rmax, gmin, gmax, bmin, bmax,
|
||||
hasMatrix,
|
||||
m0, m1, m2, m3, m4, m5, m6, m7, m8, m9, m10, m11) {
|
||||
|
||||
gamma = +gamma;
|
||||
|
||||
rmin = +rmin;
|
||||
rmax = +rmax;
|
||||
gmin = +gmin;
|
||||
gmax = +gmax;
|
||||
bmin = +bmin;
|
||||
bmax = +bmax;
|
||||
|
||||
hasMatrix = hasMatrix|0;
|
||||
m0 = +m0;
|
||||
m1 = +m1;
|
||||
m2 = +m2;
|
||||
m3 = +m3;
|
||||
m4 = +m4;
|
||||
m5 = +m5;
|
||||
m6 = +m6;
|
||||
m7 = +m7;
|
||||
m8 = +m8;
|
||||
m9 = +m9;
|
||||
m10 = +m10;
|
||||
m11 = +m11;
|
||||
|
||||
setGamma(gamma);
|
||||
setLevels(rmin, rmax, RED);
|
||||
setLevels(gmin, gmax, GREEN);
|
||||
setLevels(bmin, bmax, BLUE);
|
||||
|
||||
if ((hasMatrix|0) == 1) {
|
||||
setMatrix(m0, m1, m2, m3, m4, m5, m6, m7, m8, m9, m10, m11);
|
||||
}
|
||||
}
|
||||
|
||||
function setGamma(gamma) {
|
||||
gamma = +gamma;
|
||||
var i = 0;
|
||||
var x = 0.0, y=0.0;
|
||||
|
||||
while((i|0) < 256) {
|
||||
y = pow(x / 255.0, gamma);
|
||||
y = 255.0 * y;
|
||||
y = y + 0.5;
|
||||
bytes[(GAMMA + i)|0] = ~~y; // float to int
|
||||
i = (i + 1)|0;
|
||||
x = x + 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
function setMatrix(m0, m1, m2, m3,
|
||||
m4, m5, m6, m7,
|
||||
m8, m9, m10, m11)
|
||||
{
|
||||
m0 = +m0;
|
||||
m1 = +m1;
|
||||
m2 = +m2;
|
||||
m3 = +m3;
|
||||
m4 = +m4;
|
||||
m5 = +m5;
|
||||
m6 = +m6;
|
||||
m7 = +m7;
|
||||
m8 = +m8;
|
||||
m9 = +m9;
|
||||
m10 = +m10;
|
||||
m11 = +m11;
|
||||
|
||||
var i = 0, x = 0.0;
|
||||
while((i|0) < 256) {
|
||||
|
||||
bytes[(M0 + i)|0] = ~~(m0 * x + 0.5);
|
||||
bytes[(M1 + i)|0] = ~~(m1 * x + 0.5);
|
||||
bytes[(M2 + i)|0] = ~~(m2 * x + 0.5);
|
||||
|
||||
bytes[(M4 + i)|0] = ~~(m4 * x + 0.5);
|
||||
bytes[(M5 + i)|0] = ~~(m5 * x + 0.5);
|
||||
bytes[(M6 + i)|0] = ~~(m6 * x + 0.5);
|
||||
|
||||
bytes[(M8 + i)|0] = ~~(m8 * x + 0.5);
|
||||
bytes[(M9 + i)|0] = ~~(m9 * x + 0.5);
|
||||
bytes[(M10 + i)|0] = ~~(m10 * x + 0.5);
|
||||
|
||||
// constants, no table needed
|
||||
bytes[M3] = ~~(m3 * 255.0 + 0.5);
|
||||
bytes[M7] = ~~(m7 * 255.0 + 0.5);
|
||||
bytes[M11] = ~~(m11 * 255.0 + 0.5);
|
||||
|
||||
i = (i + 1) | 0;
|
||||
x = x + 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// This creates tables that already have gamma correction applied.
|
||||
function setLevels(min, max, offset) {
|
||||
min = +min;
|
||||
max = +max;
|
||||
offset = offset | 0;
|
||||
|
||||
var i = 0, x = 0.0;
|
||||
var imin = 0;
|
||||
var imax = 0;
|
||||
var range = 0.0;
|
||||
var factor = 1.0;
|
||||
var value = 0;
|
||||
|
||||
range = max - min;
|
||||
imin = ~~min;
|
||||
imax = ~~max;
|
||||
|
||||
if (range < LEVELS_THRESHOLD) {
|
||||
// If the pixels are all in a narrow range, this is probably not a
|
||||
// photograph and we shouldn't try to modify it.
|
||||
while((i|0) < 256) {
|
||||
bytes[(offset+i)|0] = bytes[(GAMMA+i)|0];
|
||||
i = (i + 1)|0;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Otherwise, spread the pixels out so min goes to 0 and max to 255
|
||||
factor = 255.0 / range; // Pre-compute so we only have to divide once
|
||||
|
||||
while((i|0) < 256) {
|
||||
if ((i|0) <= (imin|0)) {
|
||||
value = 0;
|
||||
}
|
||||
else if ((i|0) >= (imax|0)) {
|
||||
value = 255;
|
||||
}
|
||||
else {
|
||||
value = ~~((x - min) * factor + 0.5);
|
||||
}
|
||||
|
||||
bytes[(offset+i)|0] = bytes[(GAMMA+value)|0];
|
||||
|
||||
i = (i + 1)|0;
|
||||
x = x + 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
setParams: setParams,
|
||||
process: process
|
||||
};
|
||||
}
|
||||
|
||||
ImageProcessorEngine.gammaOffset = 0x0000;
|
||||
ImageProcessorEngine.redOffset = 0x0100;
|
||||
ImageProcessorEngine.greenOffset = 0x0200;
|
||||
ImageProcessorEngine.blueOffset = 0x0300;
|
||||
ImageProcessorEngine.matrixOffset = 0x0400;
|
||||
ImageProcessorEngine.pixelOffset = 0x1000;
|
||||
|
||||
ImageProcessorEngine.computeHeapSize = function(maxPixels) {
|
||||
// This is the number of bytes we need
|
||||
var numbytes = maxPixels * 4 + ImageProcessorEngine.pixelOffset;
|
||||
|
||||
// But we need to round it up to the nearest power of two.
|
||||
// (Or a multiple of 16mb, but I assume we won't go that high)
|
||||
numbytes = 1 << Math.ceil(Math.log(numbytes) * Math.LOG2E);
|
||||
|
||||
// And we need to allocate at least 64kb
|
||||
return Math.max(numbytes, 64 * 1024);
|
||||
};
|
||||
}(this));
|
||||
77
gaia/apps/gallery/js/image_processor_thread.js
Executable file
77
gaia/apps/gallery/js/image_processor_thread.js
Executable file
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* This module implements the ImageProcessor API by sending messages
|
||||
* to a worker running the code in image_processor_worker.js. That worker
|
||||
* has an actual ImageProcessor object that does the image processing.
|
||||
* Unlike ImageProcessor which is synchronous and returns promises that are
|
||||
* already resolved, this class is asynchronous.
|
||||
*
|
||||
* See the documentation in image_processor.js for details.
|
||||
*/
|
||||
(function(exports) {
|
||||
'use strict';
|
||||
|
||||
exports.ImageProcessorThread = ImageProcessorThread;
|
||||
|
||||
// Define this as a constant so we can change it in tests.
|
||||
ImageProcessorThread.WORKER_URL = 'js/image_processor_worker.js';
|
||||
|
||||
function ImageProcessorThread(maxPixels) {
|
||||
this.maxPixels = maxPixels;
|
||||
this.worker = new Worker(ImageProcessorThread.WORKER_URL);
|
||||
this.worker.postMessage({
|
||||
cmd: 'initialize',
|
||||
arg: maxPixels
|
||||
});
|
||||
|
||||
this.worker.addEventListener('message', this);
|
||||
this.pendingRequests = [];
|
||||
}
|
||||
|
||||
ImageProcessorThread.prototype.destroy = function() {
|
||||
this.worker.terminate();
|
||||
};
|
||||
|
||||
ImageProcessorThread.prototype.setGamma = function(gamma) {
|
||||
this.worker.postMessage({ cmd: 'setGamma', arg: gamma });
|
||||
};
|
||||
|
||||
ImageProcessorThread.prototype.setLevels = function(levels) {
|
||||
this.worker.postMessage({ cmd: 'setLevels', arg: levels });
|
||||
};
|
||||
|
||||
ImageProcessorThread.prototype.setMatrix = function(matrix) {
|
||||
this.worker.postMessage({ cmd: 'setMatrix', arg: matrix });
|
||||
};
|
||||
|
||||
ImageProcessorThread.prototype.processImage = function(pixelBuffer) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
this.worker.postMessage({ cmd: 'processImage', arg: pixelBuffer },
|
||||
[pixelBuffer]);
|
||||
this.pendingRequests.push({ resolve: resolve, reject: reject });
|
||||
}.bind(this));
|
||||
};
|
||||
|
||||
ImageProcessorThread.prototype.computeHistogram = function(buffer) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
this.worker.postMessage({ cmd: 'computeHistogram', arg: buffer },
|
||||
[buffer]);
|
||||
this.pendingRequests.push({ resolve: resolve, reject: reject });
|
||||
}.bind(this));
|
||||
};
|
||||
|
||||
ImageProcessorThread.prototype.handleEvent = function handleEvent(e) {
|
||||
if (e.type !== 'message') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.data.cmd === 'processImage' || e.data.cmd === 'computeHistogram') {
|
||||
var request = this.pendingRequests.shift();
|
||||
if (e.data.result) {
|
||||
request.resolve(e.data.result);
|
||||
}
|
||||
else {
|
||||
request.reject(e.data.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}(this));
|
||||
33
gaia/apps/gallery/js/image_processor_worker.js
Executable file
33
gaia/apps/gallery/js/image_processor_worker.js
Executable file
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* This code runs in a Worker to receive messages sent by
|
||||
* image_processor_thread.js. It loads image_processor.js, creates an
|
||||
* ImageProcessor object, and handles the messages it receives by
|
||||
* calling methods on that object.
|
||||
*
|
||||
* See the documentation in image_processor.js for details.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
/* global console, ImageProcessor */
|
||||
|
||||
importScripts('image_processor.js');
|
||||
|
||||
var processor = new ImageProcessor();
|
||||
|
||||
self.onmessage = function(message) {
|
||||
var cmd = message.data.cmd;
|
||||
var arg = message.data.arg;
|
||||
|
||||
try {
|
||||
var result = processor[cmd](arg);
|
||||
if (result) { // A promised ArrayBuffer
|
||||
result.then(function(returnValue) {
|
||||
postMessage({ cmd: cmd, result: returnValue }, [returnValue]);
|
||||
});
|
||||
}
|
||||
}
|
||||
catch(e) {
|
||||
console.error(e, e.stack);
|
||||
postMessage({ cmd: cmd, error: e.toString() });
|
||||
}
|
||||
};
|
||||
85
gaia/apps/gallery/js/info.js
Executable file
85
gaia/apps/gallery/js/info.js
Executable file
|
|
@ -0,0 +1,85 @@
|
|||
'use strict';
|
||||
/* exported showFileInformation */
|
||||
/* global
|
||||
$,
|
||||
getCurrentFile,
|
||||
MediaUtils,
|
||||
NFC,
|
||||
videostorage
|
||||
*/
|
||||
|
||||
// Hide the information view again, when clicking on cancel
|
||||
$('info-close-button').onclick = function hideFileInformation() {
|
||||
// Enable NFC sharing when user closes info and returns to fullscreen view
|
||||
NFC.share(getCurrentFile);
|
||||
$('info-view').classList.add('hidden');
|
||||
document.body.classList.remove('showing-dialog');
|
||||
};
|
||||
|
||||
function showFileInformation(fileinfo) {
|
||||
if (fileinfo.metadata.video) {
|
||||
var req = videostorage.get(fileinfo.metadata.video);
|
||||
req.onsuccess = function() {
|
||||
fileinfo.size = req.result.size;
|
||||
fileinfo.type = req.result.type || 'video/3gp';
|
||||
populateMediaInfo(fileinfo);
|
||||
};
|
||||
} else {
|
||||
populateMediaInfo(fileinfo);
|
||||
}
|
||||
// We need to disable NFC sharing when showing file info view
|
||||
NFC.unshare();
|
||||
$('info-view').classList.remove('hidden');
|
||||
document.body.classList.add('showing-dialog');
|
||||
|
||||
function populateMediaInfo(fileinfo) {
|
||||
MediaUtils.getLocalizedSizeTokens(fileinfo.size).then((args) => {
|
||||
var data = {
|
||||
//set the video filename using metadata
|
||||
'info-name': {
|
||||
raw: getFileName(fileinfo.metadata.video || fileinfo.name)
|
||||
},
|
||||
'info-size': {
|
||||
id: 'fileSize',
|
||||
args: args
|
||||
},
|
||||
'info-type': {raw: fileinfo.type},
|
||||
'info-date': {raw: MediaUtils.formatDate(fileinfo.date)},
|
||||
'info-resolution': {
|
||||
raw: fileinfo.metadata.width + 'x' + fileinfo.metadata.height
|
||||
}
|
||||
};
|
||||
|
||||
// Populate info overlay view
|
||||
MediaUtils.populateMediaInfo(data);
|
||||
// Hide Resolution for video files. See Bug 1217989
|
||||
fileinfo.metadata.video ? setFieldVisibility('info-resolution', false) :
|
||||
setFieldVisibility('info-resolution', true);
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
function getFileName(path) {
|
||||
return path.split('/').pop();
|
||||
}
|
||||
|
||||
function setFieldVisibility(id, visible) {
|
||||
// Field label in info view
|
||||
var label = $(id).previousElementSibling;
|
||||
|
||||
// If not visible, hide respective field label and
|
||||
// its value and remove bottom border from the previous field.
|
||||
if (visible) {
|
||||
$(id).style.display = 'block';
|
||||
label.style.display = 'block';
|
||||
label.previousElementSibling.classList.remove('no-border');
|
||||
} else {
|
||||
$(id).style.display = 'none';
|
||||
label.style.display = 'none';
|
||||
if ($(id).nextElementSibling === null) {
|
||||
label.previousElementSibling.classList.add('no-border');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
85
gaia/apps/gallery/js/nfc.js
Executable file
85
gaia/apps/gallery/js/nfc.js
Executable file
|
|
@ -0,0 +1,85 @@
|
|||
/* exported NFC */
|
||||
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* This module helps to manage NFC sharing in an app.
|
||||
* Call share() when the UI displays an object that can be shared as a file.
|
||||
* Pass the file or blob object itself or a function that returns a Promise
|
||||
* that resolves to the file.
|
||||
* When the UI changes so that no sharable object is visible, call unshare().
|
||||
*/
|
||||
var NFC = {
|
||||
shrinkingUI: null,
|
||||
peer: null,
|
||||
share: function share(f, options) {
|
||||
if (navigator.mozNfc) {
|
||||
// This is a workaround for bug 1163957 which is only applied to the open
|
||||
// activity. The complete solution should be done in bug 1109495.
|
||||
if (window.ShrinkingUI && options) {
|
||||
NFC.shrinkingUI = new window.ShrinkingUI(options.foregroundElement,
|
||||
options.backgroundElement);
|
||||
window.addEventListener('shrinking-sent',
|
||||
NFC.sendFile.bind(NFC, f));
|
||||
navigator.mozNfc.onpeerfound = function(evt) {
|
||||
if (!evt.peer) {
|
||||
return;
|
||||
}
|
||||
NFC.peer = evt.peer;
|
||||
NFC.shrinkingUI.start();
|
||||
// Without calling preventDefault, gecko will fire onpeerlost right
|
||||
// after onpeerfound since it think gallery app would not handle it.
|
||||
evt.preventDefault();
|
||||
};
|
||||
navigator.mozNfc.onpeerlost = function() {
|
||||
NFC.peer = null;
|
||||
NFC.shrinkingUI.stop();
|
||||
};
|
||||
} else {
|
||||
// Enable NFC sharing by defining a "peerready" callback that
|
||||
// calls sendFile() to transfer the current image or video to the peer
|
||||
navigator.mozNfc.onpeerready = function(event) {
|
||||
NFC.peer = event.peer;
|
||||
NFC.sendFile(f);
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
sendFile: function(f) {
|
||||
if (!NFC.peer) {
|
||||
return;
|
||||
}
|
||||
if (typeof f === 'function') {
|
||||
var promise = f();
|
||||
promise.then(function(file) {
|
||||
NFC.peer.sendFile(file);
|
||||
if (NFC.shrinkingUI) {
|
||||
NFC.shrinkingUI.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
NFC.peer.sendFile(f);
|
||||
if (NFC.shrinkingUI) {
|
||||
NFC.shrinkingUI.stop();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
unshare: function unshare() {
|
||||
// Disable NFC sharing simply by removing the callback
|
||||
if (navigator.mozNfc) {
|
||||
NFC.peer = null;
|
||||
navigator.mozNfc.onpeerready = null;
|
||||
navigator.mozNfc.onpeerfound = null;
|
||||
navigator.mozNfc.onpeerlost = null;
|
||||
if (NFC.shrinkingUI) {
|
||||
NFC.shrinkingUI.stop();
|
||||
NFC.shrinkingUI = null;
|
||||
window.removeEventListener('shrinking-sent',
|
||||
NFC.sendFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
314
gaia/apps/gallery/js/open.js
Executable file
314
gaia/apps/gallery/js/open.js
Executable file
|
|
@ -0,0 +1,314 @@
|
|||
'use strict';
|
||||
/* global
|
||||
CONFIG_MAX_IMAGE_PIXEL_SIZE,
|
||||
CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT,
|
||||
CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH,
|
||||
Downsample,
|
||||
GestureDetector,
|
||||
getImageSize,
|
||||
getStorageIfAvailable,
|
||||
getUnusedFilename,
|
||||
MediaFrame,
|
||||
MimeMapper,
|
||||
NFC,
|
||||
parseJPEGMetadata
|
||||
*/
|
||||
|
||||
(function init() {
|
||||
var activity; // The activity object we're handling
|
||||
var activityData; // The data sent by the initiating app
|
||||
var blob; // The blob we'll be displaying and maybe saving
|
||||
var frame; // The MediaFrame that displays the image
|
||||
var saved = false; // Did we save the file?
|
||||
var storage; // The DeviceStorage object used for saving
|
||||
var title; // What we call the image in the titlebar and banner
|
||||
|
||||
// Register a handler to receive the Activity object
|
||||
navigator.mozSetMessageHandler('activity', handleOpenActivity);
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
// Display the specified blob, unless it is too big to display
|
||||
function open(blob) {
|
||||
|
||||
// If the app that initiated this activity wants us to do allow the
|
||||
// user to save this blob as a file, and if device storage is available
|
||||
// and if there is enough free space, then display a save button.
|
||||
if (activityData.allowSave && activityData.filename && checkFilename()) {
|
||||
getStorageIfAvailable('pictures', blob.size, function(ds) {
|
||||
storage = ds;
|
||||
showSaveButton();
|
||||
});
|
||||
}
|
||||
|
||||
// Figure out how big (in pixels) the image is.
|
||||
// For JPEG images, this also gets us the preview image if there is one.
|
||||
getImageSize(blob, success, error);
|
||||
|
||||
// Called if we get an image size
|
||||
function success(metadata) {
|
||||
var pixels = metadata.width * metadata.height;
|
||||
|
||||
//
|
||||
// If the image is too big, reject it now so we don't have
|
||||
// memory trouble later.
|
||||
//
|
||||
// CONFIG_MAX_IMAGE_PIXEL_SIZE is maximum image resolution we
|
||||
// can handle. It's from config.js which is generated at build
|
||||
// time (see build/application-data.js).
|
||||
//
|
||||
// For jpeg images, we can downsample while decoding so we can
|
||||
// handle images that are quite a bit larger
|
||||
//
|
||||
var imagesizelimit = CONFIG_MAX_IMAGE_PIXEL_SIZE;
|
||||
if (blob.type === 'image/jpeg') {
|
||||
imagesizelimit *= Downsample.MAX_AREA_REDUCTION;
|
||||
}
|
||||
|
||||
//
|
||||
// Even if we can downsample an image while decoding it, we still
|
||||
// have to read the entire image file. If the file is particularly
|
||||
// large we might also have memory problems. (See bug 1008834: a 20mb
|
||||
// 80mp jpeg file will cause an OOM on Tarako even though we can
|
||||
// decode it at < 2mp). Rather than adding another build-time config
|
||||
// variable to specify the maximum file size, however, we'll just
|
||||
// base the file size limit on CONFIG_MAX_IMAGE_PIXEL_SIZE.
|
||||
// So if that variable is set to 2M, then we might use up to 12Mb of
|
||||
// memory. 2 * 2M bytes for the image file and 4 bytes times 2M pixels
|
||||
// for the decoded image. A 4mb file size limit should accomodate
|
||||
// most JPEG files up to 12 or 16 megapixels
|
||||
//
|
||||
var filesizelimit = 2 * CONFIG_MAX_IMAGE_PIXEL_SIZE;
|
||||
|
||||
if (pixels > imagesizelimit || blob.size > filesizelimit) {
|
||||
displayError('imagetoobig');
|
||||
return;
|
||||
}
|
||||
|
||||
// If there was no EXIF preview, or if the image is not very big,
|
||||
// display the full-size image.
|
||||
if (!metadata.preview || pixels < 512 * 1024) {
|
||||
frame.displayImage(blob,
|
||||
metadata.width,
|
||||
metadata.height,
|
||||
null,
|
||||
metadata.rotation,
|
||||
metadata.mirrored);
|
||||
}
|
||||
else {
|
||||
// If we found an EXIF preview, and can determine its size, then
|
||||
// we can display it instead of the big image and save a lot of
|
||||
// memory.
|
||||
parseJPEGMetadata(blob.slice(metadata.preview.start,
|
||||
metadata.preview.end,
|
||||
'image/jpeg'),
|
||||
function success(previewmetadata) {
|
||||
//
|
||||
// If we parsed the preview image, add its
|
||||
// dimensions to the metdata.preview
|
||||
// object, and then let the MediaFrame
|
||||
// object display the preview instead of
|
||||
// the full-size image.
|
||||
//
|
||||
metadata.preview.width = previewmetadata.width;
|
||||
metadata.preview.height = previewmetadata.height;
|
||||
frame.displayImage(blob,
|
||||
metadata.width,
|
||||
metadata.height,
|
||||
metadata.preview,
|
||||
metadata.rotation,
|
||||
metadata.mirrored);
|
||||
},
|
||||
function error() {
|
||||
//
|
||||
// If we couldn't parse the preview image,
|
||||
// just display full-size.
|
||||
//
|
||||
frame.displayImage(blob,
|
||||
metadata.width,
|
||||
metadata.height,
|
||||
null,
|
||||
metadata.rotation,
|
||||
metadata.mirrored);
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Called if getImageSize parsing fails.
|
||||
function error(msg) {
|
||||
//
|
||||
// This wasn't a JPEG, PNG, GIF, or BMP image and we can't figure
|
||||
// out the size of the image. Without the size, we can't pass the
|
||||
// image to MediaFrame.displayImage().
|
||||
//
|
||||
// XXX: Currently, we know how to get the sizes of all the image
|
||||
// types that this activity is registered to handle. If we add new
|
||||
// image types to manifest.webapp, then we should update
|
||||
// shared/js/media/image_size.js to compute the size or we should
|
||||
// add code here to load the image into an offscreen <img> to
|
||||
// determine its size (but only if the file size is not too large).
|
||||
//
|
||||
displayError('imageinvalid');
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenActivity(request) {
|
||||
activity = request;
|
||||
activityData = activity.source.data;
|
||||
|
||||
// Set up the UI, if it is not already set up
|
||||
if (!frame) {
|
||||
|
||||
// Hook up the buttons
|
||||
$('header').addEventListener('action', done);
|
||||
$('save').addEventListener('click', save);
|
||||
|
||||
// And register event handlers for gestures
|
||||
frame = new MediaFrame($('frame'), false, CONFIG_MAX_IMAGE_PIXEL_SIZE);
|
||||
|
||||
if (CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH) {
|
||||
frame.setMinimumPreviewSize(CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH,
|
||||
CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT);
|
||||
}
|
||||
|
||||
var gestureDetector = new GestureDetector(frame.container);
|
||||
gestureDetector.startDetecting();
|
||||
frame.container.addEventListener('dbltap', handleDoubleTap);
|
||||
frame.container.addEventListener('transform', handleTransform);
|
||||
frame.container.addEventListener('pan', handlePan);
|
||||
frame.container.addEventListener('swipe', handleSwipe);
|
||||
|
||||
window.addEventListener('resize', frame.resize.bind(frame));
|
||||
if (activityData.exitWhenHidden) {
|
||||
window.addEventListener('visibilitychange', function() {
|
||||
if (document.hidden) {
|
||||
done();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Report errors if we're passed an invalid image
|
||||
frame.onerror = function invalid() {
|
||||
displayError('imageinvalid');
|
||||
};
|
||||
}
|
||||
|
||||
// Display the filename in the header, if there was one
|
||||
title = baseName(activityData.filename || '');
|
||||
$('filename').textContent = title;
|
||||
|
||||
blob = activityData.blob;
|
||||
open(blob);
|
||||
NFC.share(blob, {
|
||||
foregroundElement: $('open'),
|
||||
backgroundElement: document.body
|
||||
});
|
||||
}
|
||||
|
||||
function checkFilename() {
|
||||
// Hide save button for file names having hidden
|
||||
// .gallery/ directories. See Bug 992426
|
||||
if (activityData.filename.indexOf('.gallery/') != -1) {
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
var dotIdx = activityData.filename.lastIndexOf('.');
|
||||
if (dotIdx > -1) {
|
||||
var ext = activityData.filename.substr(dotIdx + 1);
|
||||
return MimeMapper.guessTypeFromExtension(ext) === blob.type;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function displayError(msgid) {
|
||||
document.l10n.formatValue(msgid).then(function(val) {
|
||||
alert(val);
|
||||
done();
|
||||
});
|
||||
}
|
||||
|
||||
function done() {
|
||||
activity.postResult({ saved: saved });
|
||||
activity = null;
|
||||
NFC.unshare();
|
||||
}
|
||||
|
||||
function handleDoubleTap(e) {
|
||||
var scale;
|
||||
if (frame.fit.scale > frame.fit.baseScale) {
|
||||
scale = frame.fit.baseScale / frame.fit.scale;
|
||||
} else {
|
||||
scale = 2;
|
||||
}
|
||||
|
||||
frame.zoom(scale, e.detail.clientX, e.detail.clientY, 200);
|
||||
}
|
||||
|
||||
function handleTransform(e) {
|
||||
frame.zoom(e.detail.relative.scale,
|
||||
e.detail.midpoint.clientX,
|
||||
e.detail.midpoint.clientY);
|
||||
}
|
||||
|
||||
function handlePan(e) {
|
||||
frame.pan(e.detail.relative.dx, e.detail.relative.dy);
|
||||
}
|
||||
|
||||
function handleSwipe(e) {
|
||||
var direction = e.detail.direction;
|
||||
var velocity = e.detail.vy;
|
||||
if (direction === 'down' && velocity > 2) {
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
|
||||
// Hides the save button: we can only save once
|
||||
hideSaveButton();
|
||||
|
||||
getUnusedFilename(storage, activityData.filename, function(filename) {
|
||||
var savereq = storage.addNamed(blob, filename);
|
||||
savereq.onsuccess = function() {
|
||||
// Remember that it has been saved so we can pass this back
|
||||
// to the invoking app
|
||||
saved = filename;
|
||||
// And tell the user
|
||||
showBanner('saved', title);
|
||||
};
|
||||
savereq.onerror = function(e) {
|
||||
// XXX we don't report this to the user because it is hard to
|
||||
// localize.
|
||||
console.error('Error saving', filename, e);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function showSaveButton() {
|
||||
$('save').classList.remove('hidden');
|
||||
// XXX work around bug 870619
|
||||
$('filename').textContent = $('filename').textContent;
|
||||
}
|
||||
|
||||
function hideSaveButton() {
|
||||
$('save').classList.add('hidden');
|
||||
// XXX work around bug 870619
|
||||
$('filename').textContent = $('filename').textContent;
|
||||
}
|
||||
|
||||
function showBanner(msg, title) {
|
||||
document.l10n.setAttributes($('message'), msg, {filename: title});
|
||||
$('banner').hidden = false;
|
||||
setTimeout(function() {
|
||||
$('banner').hidden = true;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Strip directories and just return the base filename
|
||||
function baseName(filename) {
|
||||
return filename.substring(filename.lastIndexOf('/') + 1);
|
||||
}
|
||||
})();
|
||||
129
gaia/apps/gallery/js/overlay.js
Executable file
129
gaia/apps/gallery/js/overlay.js
Executable file
|
|
@ -0,0 +1,129 @@
|
|||
/* global $, LazyLoader, picking */
|
||||
/* exported Overlay */
|
||||
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* This module handles the display of Gallery app overlays: these are full
|
||||
* screen dialogs that are displayed when the app cannot be used transiently
|
||||
* or permanenty (when there is no sdcard, e.g.)
|
||||
*
|
||||
* Different overlays are identified by with a string. Pass the id of an
|
||||
* overlay to Overlay.show() to show that overlay. Call Overlay.hide() to
|
||||
* hide the current overlay. Overlay.current holds the id of the current
|
||||
* overlay, or null if no overlay is displayed.
|
||||
*
|
||||
* Supported ids include:
|
||||
*
|
||||
* nocard: no sdcard is installed in the phone
|
||||
* pluggedin: the sdcard is being used by USB mass storage
|
||||
* emptygallery: no pictures found
|
||||
* scanning: scanning the sdcard for photo's, but none found yet
|
||||
*
|
||||
* Localization is done using the specified id with "-title" and "-text"
|
||||
* suffixes.
|
||||
*
|
||||
* Some overlays have a cancel button (for cancelling a pick activity) and
|
||||
* some have a camera button (for switching to the Camera) app. When those
|
||||
* buttons are clicked, we dispatch synthetic "cancel" and "camera" events.
|
||||
* Use the addEventListener() and removeEventListener() functions to manage
|
||||
* listeners for these events.
|
||||
*/
|
||||
var Overlay = {
|
||||
current: null,
|
||||
|
||||
hide: function hide() {
|
||||
Overlay.current = null;
|
||||
$('overlay').classList.add('hidden');
|
||||
document.body.classList.remove('showing-dialog');
|
||||
},
|
||||
|
||||
show: function show(id) {
|
||||
Overlay.current = id;
|
||||
LazyLoader.load('shared/style/confirm.css', function() {
|
||||
// hide any special elements
|
||||
$('overlay-camera-button').classList.add('hidden');
|
||||
$('overlay-cancel-button').classList.add('hidden');
|
||||
$('overlay-menu').classList.add('hidden');
|
||||
document.body.classList.add('showing-dialog');
|
||||
var title, text;
|
||||
switch (id) {
|
||||
case null:
|
||||
Overlay.hide();
|
||||
return;
|
||||
case 'nocard':
|
||||
title = 'nocard3-title';
|
||||
text = 'nocard4-text';
|
||||
if (picking) {
|
||||
$('overlay-cancel-button').classList.remove('hidden');
|
||||
$('overlay-menu').classList.remove('hidden');
|
||||
}
|
||||
break;
|
||||
case 'pluggedin':
|
||||
title = 'pluggedin2-title';
|
||||
text = 'pluggedin2-text';
|
||||
if (picking) {
|
||||
$('overlay-cancel-button').classList.remove('hidden');
|
||||
$('overlay-menu').classList.remove('hidden');
|
||||
}
|
||||
break;
|
||||
case 'scanning':
|
||||
title = 'scanning-title';
|
||||
text = 'scanning-text';
|
||||
if (picking) {
|
||||
$('overlay-cancel-button').classList.remove('hidden');
|
||||
$('overlay-menu').classList.remove('hidden');
|
||||
}
|
||||
break;
|
||||
case 'emptygallery':
|
||||
title = picking ? 'emptygallery2-title-pick' :
|
||||
'emptygallery2-title';
|
||||
text = 'emptygallery2-text';
|
||||
$('overlay-menu').classList.remove('hidden');
|
||||
if (picking) {
|
||||
$('overlay-cancel-button').classList.remove('hidden');
|
||||
} else {
|
||||
$('overlay-camera-button').classList.remove('hidden');
|
||||
}
|
||||
break;
|
||||
case 'upgrade':
|
||||
title = 'upgrade-title';
|
||||
text = 'upgrade-text';
|
||||
if (picking) {
|
||||
$('overlay-cancel-button').classList.remove('hidden');
|
||||
$('overlay-menu').classList.remove('hidden');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.warn('Reference to undefined overlay', id);
|
||||
if (picking) {
|
||||
$('overlay-cancel-button').classList.remove('hidden');
|
||||
$('overlay-menu').classList.remove('hidden');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$('overlay-title').setAttribute('data-l10n-id', title);
|
||||
$('overlay-text').setAttribute('data-l10n-id', text);
|
||||
$('overlay').classList.remove('hidden');
|
||||
});
|
||||
},
|
||||
|
||||
addEventListener: function(type, listener) {
|
||||
$('overlay').addEventListener(type, listener);
|
||||
},
|
||||
|
||||
removeEventListener: function(type, listener) {
|
||||
$('overlay').removeEventListener(type, listener);
|
||||
},
|
||||
};
|
||||
|
||||
// Register handlers on the button elements that dispatch higher-level
|
||||
// events on the overlay itself.
|
||||
$('overlay-cancel-button').addEventListener('click', function() {
|
||||
$('overlay').dispatchEvent(new CustomEvent('cancel'));
|
||||
});
|
||||
|
||||
$('overlay-camera-button').addEventListener('click', function() {
|
||||
$('overlay').dispatchEvent(new CustomEvent('camera'));
|
||||
});
|
||||
361
gaia/apps/gallery/js/pick.js
Executable file
361
gaia/apps/gallery/js/pick.js
Executable file
|
|
@ -0,0 +1,361 @@
|
|||
/* global $, setView, LAYOUT_MODE, photodb, LazyLoader, Spinner, ImageEditor */
|
||||
/* global cropResizeRotate, MediaFrame */
|
||||
/* global CONFIG_MAX_PICK_PIXEL_SIZE, CONFIG_MAX_IMAGE_PIXEL_SIZE */
|
||||
/* exported Pick */
|
||||
|
||||
'use strict';
|
||||
|
||||
// XXX: the pick activity could, and probably should be handled with a
|
||||
// completely different entry point from regular invocations of
|
||||
// Gallery. If we can modularize the bootstrap/startup code and the
|
||||
// thumbnail display code enough that we can use it in both entry
|
||||
// points, it would probably be better to do it that way.
|
||||
|
||||
/*
|
||||
* A pick activity can be in two distinct states:
|
||||
*
|
||||
* 1) the picking state where the user is browsing thumbnails.
|
||||
* Tapping a thumbnail moves to state 2. Tapping the cancel
|
||||
* button cancels the activity and the app exits.
|
||||
*
|
||||
* 2) the preview/cropping phase where the user sees a full-screen
|
||||
* image and may have the option to crop it and also sees
|
||||
* cancel and done buttons. Tapping cancel moves back to state 1.
|
||||
* Tapping Done ends the pick activity and the app exits.
|
||||
*
|
||||
* This Pick module defines start(), select(), end(), cancel()
|
||||
* and restart() methods to handle these state transitions.
|
||||
* Note that this is not a Pick class, just a module of interacting
|
||||
* functions for managing picks. The gallery code needs to call
|
||||
* Pick.start() and Pick.crop().
|
||||
*/
|
||||
var Pick = (function() {
|
||||
var request;
|
||||
var pickType;
|
||||
var pickWidth, pickHeight;
|
||||
var pickedFileInfo;
|
||||
var pickedFile;
|
||||
var cropEditor;
|
||||
|
||||
// Called when we are first start up with the activity request object
|
||||
function start(activity) {
|
||||
request = activity;
|
||||
pickType = request.source.data.type;
|
||||
|
||||
if (request.source.data.width && request.source.data.height) {
|
||||
pickWidth = request.source.data.width;
|
||||
pickHeight = request.source.data.height;
|
||||
}
|
||||
else {
|
||||
pickWidth = pickHeight = 0;
|
||||
}
|
||||
|
||||
setView(LAYOUT_MODE.pick);
|
||||
|
||||
// re-run the font-fit logic when header is visible
|
||||
var pickHeading = $('pick-header-title');
|
||||
pickHeading.textContent = pickHeading.textContent;
|
||||
|
||||
// Clicking on the pick back button cancels the pick activity.
|
||||
$('pick-header').addEventListener('action', cancel);
|
||||
|
||||
// In crop view, the back button goes back to pick view
|
||||
$('crop-top').addEventListener('action', restart);
|
||||
|
||||
// In crop view, the Done button crops crops the image and returns
|
||||
// it to the invoking app;
|
||||
$('crop-done-button').addEventListener('click', end);
|
||||
}
|
||||
|
||||
// Called when the user selects a thumbnail in pick mode.
|
||||
function select(fileinfo) {
|
||||
pickedFileInfo = fileinfo;
|
||||
|
||||
// Do we actually want to allow the user to crop the image?
|
||||
var nocrop = request.source.data.nocrop;
|
||||
|
||||
if (nocrop) {
|
||||
// If we're not cropping show file name in the title bar
|
||||
var fileName = pickedFileInfo.name.split('/').pop();
|
||||
$('crop-header').textContent =
|
||||
fileName.substr(0, fileName.lastIndexOf('.')) || fileName;
|
||||
}
|
||||
|
||||
setView(LAYOUT_MODE.crop);
|
||||
|
||||
// Before the picked image is loaded, the done button is disabled
|
||||
// to avoid users picking a black/empty image.
|
||||
var doneButton = $('crop-done-button');
|
||||
doneButton.disabled = true;
|
||||
|
||||
// We need all of these for cropping the photo:
|
||||
// - ImageEditor to display the crop overlay.
|
||||
// - frame_scripts because it has gesture_detector in it.
|
||||
// - crop_resize_rotate.js scripts for cropResizeRotate().
|
||||
LazyLoader.load(['js/frame_scripts.js',
|
||||
'shared/js/media/crop_resize_rotate.js',
|
||||
'js/ImageEditor.js'], gotScripts);
|
||||
|
||||
// When the scripts we need are loaded, load the picked file we need
|
||||
function gotScripts() {
|
||||
photodb.getFile(pickedFileInfo.name, gotFile);
|
||||
}
|
||||
|
||||
// This is called with the file that needs to be cropped
|
||||
function gotFile(file) {
|
||||
pickedFile = file;
|
||||
var previewData = pickedFileInfo.metadata.preview;
|
||||
if (!previewData) {
|
||||
// If there is no preview at all, this is a small image and
|
||||
// it is its own preview. Just crop with the full-size image
|
||||
startCrop();
|
||||
}
|
||||
else if (previewData.filename) {
|
||||
// If there is an external preview file, use that. This means that
|
||||
// the EXIF preview was not big enough
|
||||
var storage = navigator.getDeviceStorage('pictures');
|
||||
var getreq = storage.get(previewData.filename);
|
||||
getreq.onsuccess = function() {
|
||||
startCrop(getreq.result);
|
||||
};
|
||||
// If we fail to get the preview file, just use the full-size image
|
||||
getreq.onerror = function() {
|
||||
startCrop();
|
||||
};
|
||||
}
|
||||
else {
|
||||
// Otherwise, use the internal EXIF preview.
|
||||
// This should be the normal case.
|
||||
startCrop(pickedFile.slice(previewData.start,
|
||||
previewData.end,
|
||||
'image/jpeg'));
|
||||
}
|
||||
|
||||
function startCrop(previewBlob) {
|
||||
// Before the user can crop the image we have to create a
|
||||
// preview of the image at the correct size and orientation
|
||||
// if we do not already have one.
|
||||
var blob, metadata, outputSize, useSpinner;
|
||||
|
||||
if (previewBlob) {
|
||||
// If there is a preview, use it at full size. If we're using
|
||||
// a preview we need to pass the size of the preview, but the
|
||||
// EXIF orientation data from the fullsize image.
|
||||
blob = previewBlob;
|
||||
metadata = {
|
||||
width: previewData.width,
|
||||
height: previewData.height,
|
||||
rotation: pickedFileInfo.metadata.rotation,
|
||||
mirrored: pickedFileInfo.metadata.mirrored
|
||||
};
|
||||
outputSize = null;
|
||||
useSpinner = false;
|
||||
}
|
||||
else {
|
||||
// If there is no preview, use the picked file, but specify a maximum
|
||||
// size so we don't decode at a size larger than needed.
|
||||
blob = pickedFile;
|
||||
metadata = pickedFileInfo.metadata;
|
||||
var windowSize = window.innerWidth * window.innerHeight *
|
||||
window.devicePixelRatio * window.devicePixelRatio;
|
||||
outputSize = Math.min(windowSize,
|
||||
CONFIG_MAX_PICK_PIXEL_SIZE ||
|
||||
CONFIG_MAX_IMAGE_PIXEL_SIZE);
|
||||
useSpinner = metadata.width * metadata.height > outputSize;
|
||||
}
|
||||
|
||||
// Make sure the image is rotated correctly so that it appears
|
||||
// right side up in the crop UI. Note that we only display a spinner
|
||||
// here if we have to downsample a large image.
|
||||
if (useSpinner) {
|
||||
Spinner.show();
|
||||
}
|
||||
cropResizeRotate(blob, null, outputSize, null, metadata,
|
||||
gotRotatedBlob);
|
||||
}
|
||||
|
||||
function gotRotatedBlob(error, rotatedBlob) {
|
||||
Spinner.hide();
|
||||
if (error) {
|
||||
console.error('Error while rotating image:', error);
|
||||
rotatedBlob = pickedFile;
|
||||
}
|
||||
cropEditor = new ImageEditor(rotatedBlob, $('crop-frame'), {},
|
||||
cropEditorReady, true);
|
||||
}
|
||||
|
||||
function cropEditorReady() {
|
||||
// Enable the done button so that users can finish picking image.
|
||||
doneButton.disabled = false;
|
||||
|
||||
// If the initiating app doesn't want to allow the user to crop
|
||||
// the image, we don't display the crop overlay. But we still use
|
||||
// this image editor to preview the image.
|
||||
if (nocrop) {
|
||||
// Set a fake crop region even though we won't display it
|
||||
// so that hasBeenCropped() works.
|
||||
cropEditor.cropOverlayRegion.left = 0;
|
||||
cropEditor.cropOverlayRegion.top = 0;
|
||||
cropEditor.cropOverlayRegion.right = cropEditor.dest.w;
|
||||
cropEditor.cropOverlayRegion.bottom = cropEditor.dest.h;
|
||||
return;
|
||||
}
|
||||
|
||||
cropEditor.showCropOverlay();
|
||||
if (pickWidth) {
|
||||
cropEditor.setCropAspectRatio(pickWidth, pickHeight);
|
||||
}
|
||||
else {
|
||||
cropEditor.setCropAspectRatio(); // free form cropping
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function end() {
|
||||
// First, figure out what kind of image to return to the requesting app.
|
||||
// If the activity request specifically included 'image/jpeg' or
|
||||
// 'image/png', then we'll use that type. Otherwise, if a generic
|
||||
// 'image/*' was requested (or if an unsupported type was requested)
|
||||
// then we use null as the type. This value is passed to
|
||||
// cropResizeRotate() and will leave the image unchanged if possible
|
||||
// or will use jpeg if changes are needed.
|
||||
// EXIF should be preserved.
|
||||
|
||||
if (Array.isArray(pickType)) {
|
||||
if (pickType.indexOf(pickedFileInfo.type) !== -1) {
|
||||
pickType = pickedFileInfo.type;
|
||||
}
|
||||
else if (pickType.indexOf('image/jpeg') !== -1) {
|
||||
pickType = 'image/jpeg+exif';
|
||||
}
|
||||
else if (pickType.indexOf('image/png') !== -1) {
|
||||
pickType = 'image/png';
|
||||
}
|
||||
else {
|
||||
pickType = null; // Return unchanged or convert to JPEG
|
||||
}
|
||||
}
|
||||
else if (pickType === 'image/*') {
|
||||
pickType = null; // Return unchanged or convert to JPEG
|
||||
}
|
||||
|
||||
if (pickType && pickType !== 'image/jpeg' &&
|
||||
pickType !== 'image/jpeg+exif' && pickType !== 'image/png') {
|
||||
pickType = null; // Return unchanged or convert to JPEG
|
||||
}
|
||||
else if (!pickType && pickedFileInfo.type == 'image/jpeg') {
|
||||
// if we picked a JPEG image, then explicitly force EXIF copy.
|
||||
pickType = 'image/jpeg+exif';
|
||||
}
|
||||
|
||||
// In order to determine the cropRegion and outputSize arguments to
|
||||
// cropResizeRotate() below we need to know the actual image size.
|
||||
// If the image has EXIF rotation, we need to take that into account.
|
||||
var fullImageWidth, fullImageHeight;
|
||||
var rotation = pickedFileInfo.metadata.rotation || 0;
|
||||
if (rotation === 90 || rotation === 270) {
|
||||
fullImageWidth = pickedFileInfo.metadata.height;
|
||||
fullImageHeight = pickedFileInfo.metadata.width;
|
||||
}
|
||||
else {
|
||||
fullImageWidth = pickedFileInfo.metadata.width;
|
||||
fullImageHeight = pickedFileInfo.metadata.height;
|
||||
}
|
||||
|
||||
var cropRegion, cropFraction;
|
||||
|
||||
if (request.source.data.nocrop || !cropEditor.hasBeenCropped()) {
|
||||
cropRegion = null;
|
||||
cropFraction = 1;
|
||||
}
|
||||
else {
|
||||
// Get the user's crop region from the crop editor
|
||||
cropRegion = cropEditor.getCropRegion();
|
||||
cropFraction = cropRegion.width * cropRegion.height;
|
||||
|
||||
// Scale to match the actual image size
|
||||
cropRegion.left = Math.round(cropRegion.left * fullImageWidth);
|
||||
cropRegion.top = Math.round(cropRegion.top * fullImageHeight);
|
||||
cropRegion.width = Math.round(cropRegion.width * fullImageWidth);
|
||||
cropRegion.height = Math.round(cropRegion.height * fullImageHeight);
|
||||
}
|
||||
|
||||
var outputSize;
|
||||
if (pickWidth && pickHeight) {
|
||||
outputSize = { width: pickWidth, height: pickHeight };
|
||||
}
|
||||
else {
|
||||
// If no desired size is specified, we have to impose some kind of limit
|
||||
// so that really big images aren't decoded at full size. If there is no
|
||||
// build time configuration that specifies the desired maximum pick size,
|
||||
// check for maximum decode size device can handle based on device memory
|
||||
// and set outputsize to MediaFrame.maxImageDecodeSize
|
||||
// else we use half of the configured maximum decode size. Pick
|
||||
// activities are memory-sensitive because the system app needs to keep
|
||||
// both the requesting app and the gallery app alive at once.
|
||||
outputSize =
|
||||
CONFIG_MAX_PICK_PIXEL_SIZE ||
|
||||
MediaFrame.maxImageDecodeSize ||
|
||||
CONFIG_MAX_IMAGE_PIXEL_SIZE >> 1;
|
||||
|
||||
// If the pick request specifed a maxFileSizeBytes parameter then
|
||||
// we'll use this as a hint for the output size. (We make no guarantee
|
||||
// about the file size of the returned blob, but we try to be close)
|
||||
// JPEG files typically have about 3 times as many pixels as bytes.
|
||||
if (request.source.data.maxFileSizeBytes) {
|
||||
var requestOutputSize =
|
||||
Math.round(request.source.data.maxFileSizeBytes / cropFraction * 3);
|
||||
outputSize = Math.min(outputSize, requestOutputSize);
|
||||
}
|
||||
}
|
||||
|
||||
// show spinner if cropResizeRotate will decode and modify the image
|
||||
if (cropRegion !== null ||
|
||||
typeof outputsize === 'object' ||
|
||||
outputSize < fullImageWidth * fullImageHeight ||
|
||||
pickedFileInfo.metadata.rotation ||
|
||||
pickedFileInfo.metadata.mirrored) {
|
||||
Spinner.show();
|
||||
}
|
||||
|
||||
cropResizeRotate(pickedFile, cropRegion, outputSize, pickType,
|
||||
pickedFileInfo.metadata,
|
||||
function(error, blob) {
|
||||
Spinner.hide();
|
||||
if (error) {
|
||||
console.error('while resizing image: ' + error);
|
||||
blob = pickedFile;
|
||||
}
|
||||
|
||||
// Finally, return the blob to the invoking app
|
||||
request.postResult({
|
||||
name: blob.name || pickedFile.name,
|
||||
type: blob.type,
|
||||
blob: blob
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
request.postError('pick cancelled');
|
||||
}
|
||||
|
||||
// Stop cropping the image and go back to picking mode
|
||||
function restart() {
|
||||
pickedFileInfo = pickedFile = null;
|
||||
if (cropEditor) {
|
||||
cropEditor.destroy();
|
||||
cropEditor = null;
|
||||
}
|
||||
setView(LAYOUT_MODE.pick);
|
||||
}
|
||||
|
||||
return {
|
||||
start: start,
|
||||
select: select,
|
||||
cancel: cancel,
|
||||
restart: restart,
|
||||
end: end
|
||||
};
|
||||
}());
|
||||
14
gaia/apps/gallery/js/spinner.js
Executable file
14
gaia/apps/gallery/js/spinner.js
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
/* global $ */
|
||||
/* exported Spinner */
|
||||
|
||||
'use strict';
|
||||
|
||||
var Spinner = {
|
||||
show: function show() {
|
||||
$('spinner').classList.remove('hidden');
|
||||
},
|
||||
|
||||
hide: function hide() {
|
||||
$('spinner').classList.add('hidden');
|
||||
}
|
||||
};
|
||||
311
gaia/apps/gallery/js/startup.js
Executable file
311
gaia/apps/gallery/js/startup.js
Executable file
|
|
@ -0,0 +1,311 @@
|
|||
/* global LazyLoader, Pick, Thumbnails, $, thumbnails */
|
||||
/* global MediaDB, photodb, picking, setView, LAYOUT_MODE, currentView */
|
||||
/* global Overlay, isPhone, doNotScanInBackgroundHack, fullscreenButtons */
|
||||
/* global launchCameraApp, deleteSelectedItems, shareSelectedItems */
|
||||
/* global resizeHandler, currentFrame, showFile, fileCreated, fileDeleted */
|
||||
/* global files, thumbnailClickHandler, exitEdit */
|
||||
|
||||
(function startup() {
|
||||
'use strict';
|
||||
|
||||
var firstScanDone = false; // Have we completed our first scan yet?
|
||||
|
||||
showUI(); // We're localized, so unhide
|
||||
registerEventHandlers(); // Hook up UI elements
|
||||
finishDBInitialization(); // Finish MediaDB initialization
|
||||
setInitialView(); // Start off in thumbnail list mode
|
||||
registerActivityHandler(); // Handle pick and browse activities
|
||||
displayThumbnails(); // Get the thumbnails displayed
|
||||
doNotScanInBackgroundHack(photodb); // Remove as part of bug 1046995
|
||||
|
||||
function showUI() {
|
||||
// <body> children are hidden until the UI is translated
|
||||
document.body.classList.remove('hidden');
|
||||
|
||||
// load frame_script.js for preview mode and show loading background
|
||||
if (!isPhone) {
|
||||
LazyLoader.load('js/frame_scripts.js');
|
||||
}
|
||||
|
||||
// Tell performance monitors that our chrome is visible
|
||||
window.performance.mark('navigationLoaded');
|
||||
}
|
||||
|
||||
function registerEventHandlers() {
|
||||
// Clicking on the select button goes to thumbnail select mode
|
||||
$('thumbnails-select-button').onclick =
|
||||
setView.bind(null, LAYOUT_MODE.select);
|
||||
|
||||
// Clicking on the cancel button goes from thumbnail select mode
|
||||
// back to thumbnail list mode
|
||||
$('selected-header').addEventListener('action',
|
||||
setView.bind(null, LAYOUT_MODE.list));
|
||||
|
||||
if (!isPhone) {
|
||||
$('fullscreen-toolbar-header').addEventListener('action', function() {
|
||||
setView(LAYOUT_MODE.list);
|
||||
});
|
||||
}
|
||||
|
||||
// The camera buttons should launch the camera app
|
||||
fullscreenButtons.camera.onclick = launchCameraApp;
|
||||
|
||||
$('thumbnails-camera-button').onclick = launchCameraApp;
|
||||
Overlay.addEventListener('camera', launchCameraApp);
|
||||
|
||||
// Clicking on the delete button in thumbnail select mode deletes all
|
||||
// selected items
|
||||
$('thumbnails-delete-button').onclick = deleteSelectedItems;
|
||||
|
||||
// Clicking on the share button in select mode shares all selected images
|
||||
$('thumbnails-share-button').onclick = shareSelectedItems;
|
||||
|
||||
Overlay.addEventListener('cancel', function() {
|
||||
if (picking) {
|
||||
Pick.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle resize events
|
||||
window.onresize = resizeHandler;
|
||||
|
||||
// All our buttons and UI elements are hooked up, so we've reached
|
||||
// the "interactive" mark.
|
||||
window.performance.mark('navigationInteractive');
|
||||
}
|
||||
|
||||
// We created the MediaDB in thumbnails.js. Now we need to finish
|
||||
// setting it up.
|
||||
function finishDBInitialization() {
|
||||
|
||||
// Display a dialog if the DB needs to upgrade before becoming ready
|
||||
photodb.onupgrading = function(evt) {
|
||||
Overlay.show('upgrade');
|
||||
};
|
||||
|
||||
// This is called when DeviceStorage becomes unavailable because the
|
||||
// sd card is removed or because it is mounted for USB mass storage
|
||||
// This may be called before onready if it is unavailable to begin with
|
||||
// We don't need one of these handlers for the video db, since both
|
||||
// will get the same event at more or less the same time.
|
||||
photodb.onunavailable = function() {
|
||||
// Switch back to the thumbnail view unless it is a pick activity.
|
||||
// If we were viewing or editing an image it might not be there
|
||||
// anymore when the MediaDB becomes available again.
|
||||
if (!picking) {
|
||||
// If we are editing image, call exitEdit to revoke blob URLs
|
||||
// and close the editor object. This is a known case where the
|
||||
// user can lose their work. See Bug 1148347
|
||||
if (currentView === LAYOUT_MODE.edit) {
|
||||
exitEdit();
|
||||
}
|
||||
setView(LAYOUT_MODE.list);
|
||||
} else {
|
||||
Pick.restart();
|
||||
}
|
||||
|
||||
// If storage becomes unavailble (e.g. the user starts a USB Mass Storage
|
||||
// Lock the user out of the app, and tell them why
|
||||
var why = photodb.state;
|
||||
if (why === MediaDB.NOCARD) {
|
||||
Overlay.show('nocard');
|
||||
}
|
||||
else if (why === MediaDB.UNMOUNTED) {
|
||||
Overlay.show('pluggedin');
|
||||
}
|
||||
};
|
||||
|
||||
photodb.onready = function() {
|
||||
// Hide the nocard or pluggedin overlay if it is displayed
|
||||
if (Overlay.current === 'nocard' || Overlay.current === 'pluggedin' ||
|
||||
Overlay.current === 'upgrade') {
|
||||
Overlay.hide();
|
||||
}
|
||||
|
||||
// We also scan the file system for new files every time we get
|
||||
// a ready event. That code is in a different event handler that
|
||||
// is not registered until we are notified the Thumbnails.created
|
||||
// promise resolves.
|
||||
};
|
||||
|
||||
photodb.onscanstart = function onscanstart() {
|
||||
// Prevents user to edit images when scanning pictures from storage
|
||||
fullscreenButtons.edit.classList.add('disabled');
|
||||
// Show the scanning indicator
|
||||
$('throbber').classList.remove('hidden');
|
||||
$('throbber').classList.add('throb');
|
||||
};
|
||||
|
||||
photodb.onscanend = function onscanend() {
|
||||
// Allows the user to edit images when scanning is finished
|
||||
fullscreenButtons.edit.classList.remove('disabled');
|
||||
|
||||
if (Overlay.current === 'scanning') {
|
||||
Overlay.show('emptygallery');
|
||||
}
|
||||
else if (!isPhone && !currentFrame.displayingImage &&
|
||||
!currentFrame.displayingVideo) {
|
||||
// focus on latest one if client hasn't clicked any of
|
||||
// them
|
||||
showFile(0);
|
||||
}
|
||||
|
||||
// Hide the scanning indicator
|
||||
// setTimeout() is to workaround Bug 1166500
|
||||
$('throbber').classList.remove('throb');
|
||||
setTimeout(function() { $('throbber').classList.add('hidden'); }, 100);
|
||||
|
||||
// If this was the first scan after startup, then tell
|
||||
// performance monitors that the app is finally fully loaded and stable.
|
||||
if (!firstScanDone) {
|
||||
firstScanDone = true;
|
||||
window.performance.mark('fullyLoaded');
|
||||
}
|
||||
};
|
||||
|
||||
// On devices with internal and external device storage, this handler is
|
||||
// triggered when the user removes the sdcard. MediaDB remains usable
|
||||
// and we'll get a bunch of deleted events for the files that are no longer
|
||||
// available. But we need to listen to this event so we can switch back
|
||||
// to the list of thumbnails. We don't want to be left viewing or editing
|
||||
// a photo that is no longer available.
|
||||
photodb.oncardremoved = function oncardremoved() {
|
||||
// If the user pulls the sdcard while trying to pick an image, give up
|
||||
if (picking) {
|
||||
Pick.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
setView(LAYOUT_MODE.list);
|
||||
};
|
||||
|
||||
// One or more files was created (or was just discovered by a scan)
|
||||
photodb.oncreated = function(event) {
|
||||
event.detail.forEach(fileCreated);
|
||||
};
|
||||
|
||||
// One or more files were deleted (or were just discovered missing)
|
||||
photodb.ondeleted = function(event) {
|
||||
event.detail.forEach(fileDeleted);
|
||||
};
|
||||
|
||||
// If the mediadb is not still in its initial OPENING state then we
|
||||
// missed a "ready" or "unavailable" or "upgrading" event, and need
|
||||
// to call the appropriate handler now. Note that we don't start scanning
|
||||
// until after the ready event arrives and we enumerate the db, so it
|
||||
// is not possible for us to miss a scanstart or scanend events. And if
|
||||
// we miss a created or deleted event, the relevant files will be found
|
||||
// during the enumeration, so this is all we have to look for now
|
||||
switch (photodb.state) {
|
||||
case MediaDB.UPGRADING:
|
||||
photodb.onupgrading();
|
||||
break;
|
||||
case MediaDB.READY:
|
||||
photodb.onready();
|
||||
break;
|
||||
case MediaDB.NOCARD:
|
||||
case MediaDB.UNMOUNTED:
|
||||
photodb.onunavailable();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function setInitialView() {
|
||||
// Start off in thumbnail list mode or in pick mode
|
||||
if (picking) {
|
||||
setView(LAYOUT_MODE.pick);
|
||||
}
|
||||
else {
|
||||
setView(LAYOUT_MODE.list);
|
||||
}
|
||||
}
|
||||
|
||||
// Register a handler for pick and browse activities
|
||||
function registerActivityHandler() {
|
||||
navigator.mozSetMessageHandler('activity', function activityHandler(a) {
|
||||
var activityName = a.source.name;
|
||||
switch (activityName) {
|
||||
case 'browse':
|
||||
// The user is probably coming to us from the camera, and she probably
|
||||
// wants to see the list of thumbnails. If the gallery was already
|
||||
// running when the browse activity was started, then we might not be
|
||||
// displaying the thumbnails. If we're currently displaying a single
|
||||
// image, switch to the thumbnails. But if the user left the gallery
|
||||
// in the middle of an edit or in the middle of making a selection,
|
||||
// then returning to the thumbnail list would cause her to lose work,
|
||||
// so in those cases we don't change anything and let the gallery
|
||||
// resume where the user left it. See Bug 846220.
|
||||
if (currentView === LAYOUT_MODE.fullscreen) {
|
||||
setView(LAYOUT_MODE.list);
|
||||
}
|
||||
break;
|
||||
case 'pick':
|
||||
LazyLoader.load('js/pick.js', function() { Pick.start(a); });
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get the thumbnails displayed
|
||||
function displayThumbnails() {
|
||||
// thumbnails.js started created thumbanils offscreen.
|
||||
// We're ready now to deal with them
|
||||
|
||||
// Copy stuff to global variables
|
||||
// jshint ignore:start
|
||||
thumbnails = Thumbnails.container; // The element that holds the thumbnail
|
||||
thumbnailList = Thumbnails.list; // The object that represents them
|
||||
// jshint ignore:end
|
||||
|
||||
// Now insert the thumbnails into the document.
|
||||
var placeholder = $('thumbnails-placeholder');
|
||||
placeholder.parentElement.replaceChild(thumbnails, placeholder);
|
||||
|
||||
// Handle clicks on the thumbnails
|
||||
thumbnails.addEventListener('click', thumbnailClickHandler);
|
||||
|
||||
// When the first page of thumbnails is diplayed, we can emit
|
||||
// our 'visually complete' mark for startup time comparison
|
||||
Thumbnails.firstpage.then(function() {
|
||||
// Tell performance monitors that "above the fold" content is displayed
|
||||
// and is ready to interact with.
|
||||
window.performance.mark('visuallyLoaded');
|
||||
window.performance.mark('contentInteractive');
|
||||
|
||||
// Remove the attribute to trigger gaia header font-fit logic, defer it
|
||||
// til now to postpone the overhead. Add setTimeout to lazyload
|
||||
// gaia-header.js aynchronously.
|
||||
setTimeout(function() {
|
||||
LazyLoader.load(['shared/elements/gaia-header/dist/gaia-header.js'],
|
||||
function() {
|
||||
var headers = document.querySelectorAll('gaia-header');
|
||||
for (var i = 0; i < headers.length; ++i) {
|
||||
headers[i].removeAttribute('no-font-fit');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// When all the thumbnails have been created, we can start a scan
|
||||
Thumbnails.complete.then(function() {
|
||||
if (files.length === 0) { // If we didn't find anything
|
||||
Overlay.show('scanning');
|
||||
}
|
||||
|
||||
// Send a custom mark to performance monitors to note that we're done
|
||||
// enumerating the database at this point. We won't send the final
|
||||
// fullyLoaded marker until we're completely stable and have
|
||||
// finished scanning.
|
||||
window.performance.mark('mediaEnumerated');
|
||||
|
||||
// Now that we've enumerated all the photos and videos we already know
|
||||
// about it is time to go and scan the filesystem for new ones. If the
|
||||
// MediaDB is fully ready, we can scan now. Either way, we always want
|
||||
// to scan every time we get a new 'ready' event.
|
||||
photodb.addEventListener('ready', function() { photodb.scan(); });
|
||||
if (photodb.state === MediaDB.READY) { // if already ready then scan now
|
||||
photodb.scan();
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
152
gaia/apps/gallery/js/thumbnail_date_group.js
Executable file
152
gaia/apps/gallery/js/thumbnail_date_group.js
Executable file
|
|
@ -0,0 +1,152 @@
|
|||
'use strict';
|
||||
/* global
|
||||
MediaUtils,
|
||||
ThumbnailItem
|
||||
*/
|
||||
|
||||
/**
|
||||
* ThumbnailDateGroup is a grouping mechanism supported in gallery app. It
|
||||
* groups file data by its year and month. The grouping
|
||||
* identity is based on the groupID. Once the groupID is different, we view
|
||||
* two groups as different groups. ThumbnailDateGroup also sorts the added file
|
||||
* data descendant. We can just put a item into group and let it sort the list.
|
||||
*
|
||||
* CONSTRUCTOR:
|
||||
* To create a group, we need to supply one argument:
|
||||
* item: the first data object for this group. It will be used for
|
||||
* rendering the information about this group.
|
||||
*
|
||||
* API:
|
||||
* addItem(): add a new item to this group. ThumbnailDateGroup will create a
|
||||
* ThumbnailItem for the supplied file data and insert it to UI.
|
||||
* args:
|
||||
* item: the file data object.
|
||||
* return: ThumbnailItem object
|
||||
* removeItem(): remove a ThumbnailItem from this group. It also removes the
|
||||
* UI from this group.
|
||||
* thumbnail: ThumbnailItem object to remove.
|
||||
* getCount(): returns the total items in this group
|
||||
*
|
||||
* Properties:
|
||||
* htmlNode: the HTML DOM node of this group.
|
||||
* groupID: the ID of this group.
|
||||
* thumbnails: all ThumbnailItem objects under this group.
|
||||
*
|
||||
* Static API:
|
||||
* getGroupID: calculate the group id for file object.
|
||||
* item: the file data object.
|
||||
* return: an id string.
|
||||
* compareGroupID: Compare group ids in format group_yyyy-mm.
|
||||
* The format is chosen carefully so that string comparison
|
||||
* returns the same result as numeric comparison
|
||||
*
|
||||
*/
|
||||
function ThumbnailDateGroup(item) {
|
||||
if (!item) {
|
||||
throw new Error('item should not be null or undefined.');
|
||||
}
|
||||
|
||||
this.thumbnails = [];
|
||||
this.groupID = ThumbnailDateGroup.getGroupID(item);
|
||||
this.date = item.date;
|
||||
|
||||
// Create the DOM elements for this thumbnail group
|
||||
this.header = document.createElement('div');
|
||||
this.header.className = 'thumbnail-group-header';
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'thumbnail-group-container';
|
||||
this.htmlNode = document.createElement('li');
|
||||
this.htmlNode.appendChild(this.header);
|
||||
this.htmlNode.appendChild(this.container);
|
||||
|
||||
this.localize();
|
||||
}
|
||||
|
||||
ThumbnailDateGroup.getGroupID = function(item) {
|
||||
// id is group_yyyy-mm. this id will be used as a key
|
||||
var dateObj = new Date(item.date);
|
||||
var month = dateObj.getMonth() + 1;
|
||||
return 'group_' + dateObj.getFullYear() + '-' +
|
||||
(month < 10 ? '0' + month : month);
|
||||
};
|
||||
|
||||
ThumbnailDateGroup.compareGroupID = function(id1, id2) {
|
||||
return id1 > id2 ? 1 : (id1 < id2 ? -1 : 0);
|
||||
};
|
||||
|
||||
// Instance methods
|
||||
ThumbnailDateGroup.prototype.addItem = function(item) {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
|
||||
function getInsertPosition(thumbnail) {
|
||||
if (self.thumbnails.length === 0 ||
|
||||
thumbnail.data.date > self.thumbnails[0].data.date) {
|
||||
// This file is the first or is newer than the first one.
|
||||
// This is the most common case for new files.
|
||||
return 0;
|
||||
}
|
||||
else if (thumbnail.data.date <
|
||||
self.thumbnails[self.thumbnails.length - 1].data.date) {
|
||||
// This file is older than the last one.
|
||||
// This is the most common case when we enumerate the database.
|
||||
return self.thumbnails.length;
|
||||
}
|
||||
else {
|
||||
// Otherwise we have to search for the right insertion spot
|
||||
return MediaUtils.binarySearch(self.thumbnails,
|
||||
thumbnail,
|
||||
function(a, b) {
|
||||
return b.data.date - a.data.date;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var thumbnail = new ThumbnailItem(item);
|
||||
// calculate the position.
|
||||
var insertPosition = getInsertPosition(thumbnail);
|
||||
// put into DOM tree.
|
||||
this.container.insertBefore(thumbnail.htmlNode,
|
||||
this.container.children[insertPosition]);
|
||||
// insert thumbnail view into array
|
||||
this.thumbnails.splice(insertPosition, 0, thumbnail);
|
||||
return thumbnail;
|
||||
};
|
||||
|
||||
ThumbnailDateGroup.prototype.getCount = function() {
|
||||
return this.thumbnails.length;
|
||||
};
|
||||
|
||||
ThumbnailDateGroup.prototype.removeItem = function(thumbnail) {
|
||||
var idx = this.thumbnails.indexOf(thumbnail);
|
||||
if (idx < 0) {
|
||||
return;
|
||||
}
|
||||
this.thumbnails.splice(idx, 1);
|
||||
URL.revokeObjectURL(thumbnail.imgNode.src);
|
||||
this.container.removeChild(thumbnail.htmlNode);
|
||||
};
|
||||
|
||||
ThumbnailDateGroup.formatter = new Intl.DateTimeFormat(navigator.languages, {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
ThumbnailDateGroup.resetFormatter = () => {
|
||||
ThumbnailDateGroup.formatter = new Intl.DateTimeFormat(navigator.languages, {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
ThumbnailDateGroup.prototype.localize = function() {
|
||||
var date = new Date(this.date);
|
||||
|
||||
this.header.textContent = ThumbnailDateGroup.formatter.format(date);
|
||||
|
||||
// Localize each of the group's thumbnails.
|
||||
this.thumbnails.forEach(function(thumbnail) { thumbnail.localize(); });
|
||||
};
|
||||
66
gaia/apps/gallery/js/thumbnail_item.js
Executable file
66
gaia/apps/gallery/js/thumbnail_item.js
Executable file
|
|
@ -0,0 +1,66 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* ThumbnailItem is view object for a single gallery item data. It renders file
|
||||
* in listitem object.
|
||||
*
|
||||
* CONSTRUCTOR:
|
||||
* To create a ThumbnailItem objet requires the following argument:
|
||||
* fileData: the file data object from mediadb.
|
||||
*
|
||||
* Properties:
|
||||
* htmlNode: the HTML DOM node for this thumbnail item. It is rendered at the
|
||||
* creation of object.
|
||||
* data: the file data object bound with this thumbnail item.
|
||||
*/
|
||||
function ThumbnailItem(fileData) {
|
||||
if (!fileData) {
|
||||
throw new Error('fileData should not be null or undefined.');
|
||||
}
|
||||
this.data = fileData;
|
||||
|
||||
this.htmlNode = document.createElement('div');
|
||||
this.htmlNode.classList.add('thumbnail');
|
||||
this.htmlNode.setAttribute('role', 'button');
|
||||
this.htmlNode.setAttribute('tabindex', 0);
|
||||
this.imgNode = document.createElement('img');
|
||||
this.imgNode.alt = '';
|
||||
this.imgNode.classList.add('thumbnailImage');
|
||||
this.imgNode.dataset.filename = fileData.name;
|
||||
|
||||
// We revoke this url in imageDeleted
|
||||
var url = URL.createObjectURL(fileData.metadata.thumbnail);
|
||||
this.imgNode.src = url;
|
||||
this.htmlNode.appendChild(this.imgNode);
|
||||
|
||||
this.localize();
|
||||
}
|
||||
|
||||
ThumbnailItem.formatter = new Intl.DateTimeFormat(navigator.languages, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
ThumbnailItem.resetFormatter = () => {
|
||||
ThumbnailItem.formatter = new Intl.DateTimeFormat(navigator.languages, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
ThumbnailItem.prototype.localize = function() {
|
||||
var date = new Date(this.data.date);
|
||||
|
||||
var descId = !this.data.metadata.video ?
|
||||
'imageDated' : 'videoDated';
|
||||
|
||||
document.l10n.setAttributes(this.imgNode, descId, {
|
||||
timeStamp: ThumbnailItem.formatter.format(date)
|
||||
});
|
||||
};
|
||||
114
gaia/apps/gallery/js/thumbnail_list.js
Executable file
114
gaia/apps/gallery/js/thumbnail_list.js
Executable file
|
|
@ -0,0 +1,114 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* ThumbnailList is the class reponsible for rendering all gallery content in to
|
||||
* list. It uses GroupClass to group and sort file data as ThumbnailItem.
|
||||
*
|
||||
* Constructor:
|
||||
* groupClass: the grouping class.
|
||||
* container: the HTML DOM object for containing everything.
|
||||
*
|
||||
* API:
|
||||
* addItem: add a file data and render it.
|
||||
* item: the file data to add.
|
||||
* removeItem: remove a ThumbnailItem.
|
||||
* filename: the filename of file data.
|
||||
* reset: clears all the internal data structure.
|
||||
*
|
||||
* Properties:
|
||||
* thumbnailMap: a mapping of filename to ThumbnailItem.
|
||||
* groupMap: a mapping of filename to thumbnail group.
|
||||
* itemGroups: an sorted array of thumbnail group.
|
||||
* count: the total ThumbnailItem in this list.
|
||||
* groupClass: the grouping class this list used.
|
||||
* container: the HTML DOM element containing this list.
|
||||
*/
|
||||
function ThumbnailList(groupClass, container) {
|
||||
if (!groupClass || !container) {
|
||||
throw new Error('group class or container cannot be null or undefined');
|
||||
}
|
||||
// the filename to ThumbnailItem map
|
||||
this.thumbnailMap = {};
|
||||
// the filename to group
|
||||
this.groupMap = {};
|
||||
// the group list
|
||||
this.itemGroups = [];
|
||||
this.count = 0;
|
||||
this.groupClass = groupClass;
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
ThumbnailList.prototype.addItem = function(item) {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.thumbnailMap[item.name]) {
|
||||
return this.thumbnailMap[item.name];
|
||||
}
|
||||
|
||||
var self = this;
|
||||
|
||||
function createItemGroup(item, before) {
|
||||
var group = new self.groupClass(item);
|
||||
|
||||
self.container.insertBefore(group.htmlNode,
|
||||
before ? before.htmlNode : null);
|
||||
return group;
|
||||
}
|
||||
|
||||
function getItemGroup(item) {
|
||||
var groupID = self.groupClass.getGroupID(item);
|
||||
var i;
|
||||
for (i = 0; i < self.itemGroups.length; i++) {
|
||||
if (self.itemGroups[i].groupID === groupID) {
|
||||
return self.itemGroups[i];
|
||||
} else if (self.groupClass.compareGroupID(self.itemGroups[i].groupID,
|
||||
groupID) < 0) {
|
||||
// existing ID is less than current groupID, stop searching/
|
||||
break;
|
||||
}
|
||||
}
|
||||
var createdGroup = createItemGroup(item, self.itemGroups[i]);
|
||||
self.itemGroups.splice(i, 0, createdGroup);
|
||||
return createdGroup;
|
||||
}
|
||||
|
||||
var group = getItemGroup(item);
|
||||
var thumbnail = group.addItem(item);
|
||||
this.groupMap[item.name] = group;
|
||||
this.thumbnailMap[item.name] = thumbnail;
|
||||
this.count++;
|
||||
return thumbnail;
|
||||
};
|
||||
|
||||
ThumbnailList.prototype.removeItem = function(filename) {
|
||||
if (!this.thumbnailMap[filename]) {
|
||||
return;
|
||||
}
|
||||
|
||||
var group = this.groupMap[filename];
|
||||
group.removeItem(this.thumbnailMap[filename]);
|
||||
if (!group.getCount()) {
|
||||
this.container.removeChild(group.htmlNode);
|
||||
this.itemGroups.splice(this.itemGroups.indexOf(group), 1);
|
||||
}
|
||||
this.count--;
|
||||
delete this.groupMap[filename];
|
||||
delete this.thumbnailMap[filename];
|
||||
};
|
||||
|
||||
ThumbnailList.prototype.reset = function() {
|
||||
for (var name in this.thumbnailMap) {
|
||||
this.groupMap[name].removeItem(this.thumbnailMap[name]);
|
||||
}
|
||||
this.container.innerHTML = '';
|
||||
this.thumbnailMap = {};
|
||||
this.itemGroups = [];
|
||||
this.groupMap = {};
|
||||
this.count = 0;
|
||||
};
|
||||
|
||||
ThumbnailList.prototype.localize = function() {
|
||||
this.itemGroups.forEach(function(group) { group.localize(); });
|
||||
};
|
||||
150
gaia/apps/gallery/js/thumbnails.js
Executable file
150
gaia/apps/gallery/js/thumbnails.js
Executable file
|
|
@ -0,0 +1,150 @@
|
|||
/* exported Thumbnails */
|
||||
/* global ThumbnailList, ThumbnailDateGroup, MediaDB, LazyLoader */
|
||||
/* global ThumbnailItem, photodb, files, picking, metadataParser */
|
||||
(function(exports) {
|
||||
'use strict';
|
||||
|
||||
// This is the object we export
|
||||
var thumbnails = exports.Thumbnails = {};
|
||||
|
||||
// This is the container element for the thumbnails. We put them here
|
||||
// so that we can start creating them before the DOM is loaded. Once
|
||||
// the DOM is loaded and we've been localized, we'll insert this into
|
||||
// the document in the right spot.
|
||||
thumbnails.container = document.createElement('ul');
|
||||
thumbnails.container.id = 'thumbnails';
|
||||
|
||||
// This is the ThumbnailList object that represents the list of thumbnails
|
||||
// contained in the thumbnailContainer.
|
||||
thumbnails.list = new ThumbnailList(ThumbnailDateGroup, thumbnails.container);
|
||||
|
||||
// Bug 1135256: Having a listener in ThumbnailList prevents them from
|
||||
// being garbage collected since ready() holds a strong reference, so
|
||||
// we have a singleton listener here.
|
||||
document.addEventListener('DOMRetranslated', () => {
|
||||
ThumbnailDateGroup.resetFormatter();
|
||||
ThumbnailItem.resetFormatter();
|
||||
thumbnails.list.localize();
|
||||
});
|
||||
|
||||
// How many thumbnails are visible on a page.
|
||||
// Batch sizes are based on this.
|
||||
var PAGE_SIZE = 15;
|
||||
|
||||
// Create the MediaDB object so it can start initializing the DB right away
|
||||
photodb = new MediaDB('pictures', // jshint ignore:line
|
||||
metadataParserWrapper, {
|
||||
version: 2,
|
||||
autoscan: false, // We're going to call scan() explicitly
|
||||
batchHoldTime: 2000, // Batch files during scanning
|
||||
batchSize: 3 // Max batch size when scanning
|
||||
});
|
||||
|
||||
var metadataParserLoaded = false;
|
||||
|
||||
// This is defined here only because we need it defined when we call
|
||||
// the MediaDB() constructor. It will not be called until we start
|
||||
// scanning, so it does not matter that it uses things that are not
|
||||
// defined yet.
|
||||
function metadataParserWrapper(file, onsuccess, onerror, bigFile) {
|
||||
if (metadataParserLoaded) {
|
||||
metadataParser(file, onsuccess, onerror, bigFile);
|
||||
return;
|
||||
}
|
||||
|
||||
LazyLoader.load(['js/metadata_scripts.js',
|
||||
'shared/js/media/crop_resize_rotate.js'], function() {
|
||||
metadataParserLoaded = true;
|
||||
metadataParser(file, onsuccess, onerror, bigFile);
|
||||
});
|
||||
}
|
||||
|
||||
// These variables will hold the functions that will resolve the promises
|
||||
var firstPageResolver;
|
||||
var completionResolver;
|
||||
|
||||
// Create promises to store in the thumbnails object so that
|
||||
// the code that has not loaded yet can find out when we're done.
|
||||
thumbnails.firstpage = new Promise(function(resolve, reject) {
|
||||
firstPageResolver = resolve;
|
||||
|
||||
thumbnails.complete = new Promise(function(resolve, reject) {
|
||||
completionResolver = resolve;
|
||||
|
||||
// Now that the promises are ready, start creating thumbnails
|
||||
createThumbnails();
|
||||
});
|
||||
});
|
||||
|
||||
function createThumbnails() {
|
||||
// If the db is enumerable (or fully ready), start enumerating it.
|
||||
// Otherwise wait until it becomes ready enumerable.
|
||||
if (photodb.state === MediaDB.READY ||
|
||||
photodb.state === MediaDB.ENUMERABLE) {
|
||||
enumerateDB();
|
||||
}
|
||||
else {
|
||||
photodb.addEventListener('enumerable', enumerateDB);
|
||||
}
|
||||
}
|
||||
|
||||
function enumerateDB() {
|
||||
// Temporary arrays to hold enumerated files
|
||||
var batch = [];
|
||||
var batchsize = PAGE_SIZE;
|
||||
var firstPageDisplayed = false;
|
||||
|
||||
photodb.enumerate('date', null, 'prev', function(fileinfo) {
|
||||
if (fileinfo) {
|
||||
// For a pick activity, don't display videos
|
||||
if (picking && fileinfo.metadata.video) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bug 1003036 fixed an issue where explicitly created preview
|
||||
// images could be saved with fractional sizes. We don't do that
|
||||
// anymore, but we still need to clean up existing bad data here.
|
||||
var metadata = fileinfo.metadata;
|
||||
if (metadata &&
|
||||
metadata.preview &&
|
||||
metadata.preview.filename) {
|
||||
metadata.preview.width = Math.floor(metadata.preview.width);
|
||||
metadata.preview.height = Math.floor(metadata.preview.height);
|
||||
}
|
||||
|
||||
batch.push(fileinfo);
|
||||
if (batch.length >= batchsize) {
|
||||
flush();
|
||||
batchsize *= 2;
|
||||
}
|
||||
}
|
||||
else {
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
function flush() {
|
||||
batch.forEach(thumb);
|
||||
batch.length = 0;
|
||||
if (!firstPageDisplayed) {
|
||||
firstPageDisplayed = true;
|
||||
// Resolve the thumbnails.firstpage promise to tell the UI
|
||||
// that "above the fold" content is ready to display
|
||||
firstPageResolver();
|
||||
}
|
||||
}
|
||||
|
||||
function thumb(fileinfo) {
|
||||
files.push(fileinfo); // remember the file
|
||||
// Create the thumbnail view for this file
|
||||
// and insert it at the right spot
|
||||
thumbnails.list.addItem(fileinfo);
|
||||
}
|
||||
|
||||
function done() {
|
||||
flush();
|
||||
// Resolve the thumbnails.complete promise
|
||||
completionResolver();
|
||||
}
|
||||
}
|
||||
}(window));
|
||||
4
gaia/apps/gallery/js/utils.js
Executable file
4
gaia/apps/gallery/js/utils.js
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
/* exported $ */
|
||||
'use strict';
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
Loading…
Reference in a new issue