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
1
gaia/shared/js/media/README
Executable file
1
gaia/shared/js/media/README
Executable file
|
|
@ -0,0 +1 @@
|
|||
This directory contains files shared by the Camera, Gallery and Video apps.
|
||||
554
gaia/shared/js/media/crop_resize_rotate.js
Executable file
554
gaia/shared/js/media/crop_resize_rotate.js
Executable file
|
|
@ -0,0 +1,554 @@
|
|||
/* exported cropResizeRotate */
|
||||
/* global getImageSize, LazyLoader, JPEGParser */
|
||||
/* global Downsample */
|
||||
|
||||
//
|
||||
// Given a blob that represents an encoded image, decode the image, crop it,
|
||||
// rotate it, resize it, encode it again and pass the encoded blob to the
|
||||
// callback.
|
||||
//
|
||||
// If the image includes EXIF orientation information, it will be
|
||||
// rotated and/or mirrored so that the proper side is up and EXIF
|
||||
// orientation information will not be needed in the output blob. The
|
||||
// blob will not include any EXIF data.
|
||||
//
|
||||
// The cropRegion argument is optional. If specfied, it should be an
|
||||
// object with left, top, width and height properties that specify the
|
||||
// region of interest in the image. These coordinates should be
|
||||
// specified as if the image has already been rotated and mirrored. If
|
||||
// this argument is not specified, then no cropping is done and the
|
||||
// entire image is returned.
|
||||
//
|
||||
// The outputSize argument specifies the desired size of the output
|
||||
// image. If not specified, then the image is returned full-size. If
|
||||
// this argument is specified, then it should be an object with width
|
||||
// and height properties or a single number. If outputSize is an
|
||||
// object, then the returned image will have the specified size or
|
||||
// smaller. If the aspect ratio of the output size does not match the
|
||||
// aspect ratio of the original image or of the crop region, then the
|
||||
// largest area of the input region that fits the output size without
|
||||
// letterboxing will be used. If the output size is larger than the
|
||||
// crop region, then the output size is reduced to match the crop
|
||||
// region.
|
||||
//
|
||||
// If outputSize is a number, then the #-moz-samplesize media fragment
|
||||
// will be used, if necessary, to ensure that the input image is
|
||||
// decoded at the specified size or smaller. Note that this media
|
||||
// fragment gives only coarse control over image size, so passing a
|
||||
// number for this argument can result in the image being decoded at a
|
||||
// size substantially smaller than the specified value. If outputSize
|
||||
// is a number and a crop region is specified, the image may be
|
||||
// downsampled and then cropped, further reducing the size of the
|
||||
// resulting image.
|
||||
//
|
||||
// The outputType argument specifies the type of the output
|
||||
// image. Legal values are "image/jpeg", "image/jpeg+exif" and
|
||||
// "image/png". If not specified, and if the input image does not need
|
||||
// to be cropped resized or rotated, then it will be returned
|
||||
// unchanged regardless of the type. If no output type is specified
|
||||
// and a new blob needs to be created then "image/jpeg" will be
|
||||
// used. If a type is explicitly specified, and does not match the
|
||||
// type of the input image, then a new blob will be created even if no
|
||||
// other changes to the image are necessary. "image/jpeg+exif" is what
|
||||
// you want if you need to have the Exif in the updated copy.
|
||||
//
|
||||
// The optional metadata argument provides a way to pass in image size and
|
||||
// rotation metadata if you already have it. If this argument is omitted
|
||||
// or null, getImageSize() will be used to compute the metadata. But if you
|
||||
// have already called getImageSize() on the blob, you can provide the
|
||||
// metadata you have and avoid having to reparse the blob.
|
||||
//
|
||||
// The callback argument should be a function that expects two arguments.
|
||||
// If the image is successfully processed, the first argument will be null
|
||||
// and the second will be a blob. If there was an error, the first argument
|
||||
// will be an error message and the second argument will be undefined.
|
||||
//
|
||||
// If no cropRegion and no outputSize are specified, if the type of the
|
||||
// input blob matches the requested outputType, and if the image does not
|
||||
// require any rotation, then this function will not do any work and will
|
||||
// simply pass the input blob to the callback.
|
||||
//
|
||||
// This function requires other shared JS files:
|
||||
//
|
||||
// shared/js/blobview.js
|
||||
// shared/js/media/image_size.js
|
||||
// shared/js/media/jpeg_metadata_parser.js
|
||||
// shared/js/media/downsample.js
|
||||
// shared/js/media/jpeg-exif.js (with LazyLoader)
|
||||
//
|
||||
function cropResizeRotate(blob, cropRegion, outputSize, outputType,
|
||||
metadata, callback)
|
||||
{
|
||||
'use strict';
|
||||
|
||||
const JPEG = 'image/jpeg';
|
||||
const PNG = 'image/png';
|
||||
const EXIF = 'image/jpeg+exif';
|
||||
|
||||
// The 2nd, 3rd, 4th and 5th arguments are optional, so fix things up if we're
|
||||
// called with fewer than 6 args. The last argument is always the callback.
|
||||
switch (arguments.length) {
|
||||
case 2:
|
||||
callback = cropRegion;
|
||||
cropRegion = outputSize = outputType = metadata = null;
|
||||
break;
|
||||
|
||||
case 3:
|
||||
callback = outputSize;
|
||||
outputSize = outputType = metadata = null;
|
||||
break;
|
||||
|
||||
case 4:
|
||||
callback = outputType;
|
||||
outputType = metadata = null;
|
||||
break;
|
||||
|
||||
case 5:
|
||||
callback = metadata;
|
||||
metadata = null;
|
||||
break;
|
||||
|
||||
case 6:
|
||||
// everything fine. do nothing here
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error('wrong number of arguments: ' + arguments.length);
|
||||
}
|
||||
|
||||
if (cropRegion) { // make a private copy
|
||||
cropRegion = {
|
||||
left: cropRegion.left,
|
||||
top: cropRegion.top,
|
||||
width: cropRegion.width,
|
||||
height: cropRegion.height
|
||||
};
|
||||
}
|
||||
|
||||
if (outputSize && typeof outputSize === 'object') { // make a private copy
|
||||
outputSize = {
|
||||
width: outputSize.width,
|
||||
height: outputSize.height
|
||||
};
|
||||
}
|
||||
// If we were passed a metadata object, pass it to gotSize. Otherwise,
|
||||
// find the metadata object first and then pass it.
|
||||
if (metadata) {
|
||||
gotSize(metadata);
|
||||
}
|
||||
else {
|
||||
getImageSize(blob, gotSize, function(msg) { callback(msg); });
|
||||
}
|
||||
|
||||
function gotSize(metadata) {
|
||||
// This is the full size of the image in the input coordiate system
|
||||
var rawImageWidth = metadata.width;
|
||||
var rawImageHeight = metadata.height;
|
||||
var fullsize = rawImageWidth * rawImageHeight;
|
||||
var rotation = metadata.rotation || 0;
|
||||
var mirrored = metadata.mirrored || false;
|
||||
|
||||
// Compute the full size of the image in the output coordinate system
|
||||
// I.e. if the image is sideways, swap the width and height
|
||||
var rotatedImageWidth, rotatedImageHeight;
|
||||
if (rotation === 0 || rotation === 180) {
|
||||
rotatedImageWidth = rawImageWidth;
|
||||
rotatedImageHeight = rawImageHeight;
|
||||
}
|
||||
else {
|
||||
rotatedImageWidth = rawImageHeight;
|
||||
rotatedImageHeight = rawImageWidth;
|
||||
}
|
||||
|
||||
// If there is no crop region, use the full, rotated image.
|
||||
// If there is a crop region, make sure it fits inside the image.
|
||||
if (!cropRegion) {
|
||||
cropRegion = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: rotatedImageWidth,
|
||||
height: rotatedImageHeight
|
||||
};
|
||||
}
|
||||
else {
|
||||
if (cropRegion.left < 0 || cropRegion.top < 0 ||
|
||||
(cropRegion.left + cropRegion.width > rotatedImageWidth) ||
|
||||
(cropRegion.top + cropRegion.height > rotatedImageHeight)) {
|
||||
callback('crop region does not fit inside image');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If there is no output size, use the size of the crop region.
|
||||
// If there is an output size make sure it is smaller than the crop region
|
||||
// and then adjust the crop region as needed so that the aspect ratios
|
||||
// match
|
||||
if (outputSize === null || outputSize === undefined) {
|
||||
outputSize = {
|
||||
width: cropRegion.width,
|
||||
height: cropRegion.height
|
||||
};
|
||||
}
|
||||
else if (typeof outputSize === 'number') {
|
||||
if (outputSize <= 0) {
|
||||
callback('outputSize must be positive');
|
||||
return;
|
||||
}
|
||||
|
||||
if (fullsize < outputSize) {
|
||||
// If the full size of the image is less than the image decode size
|
||||
// limit, then we can decode the image at full size and use the full
|
||||
// crop region dimensions as the output size.
|
||||
outputSize = {
|
||||
width: cropRegion.width,
|
||||
height: cropRegion.height
|
||||
};
|
||||
}
|
||||
else {
|
||||
// In this case we need to specify an output size that is small
|
||||
// enough that we will be forced below to use #-moz-samplesize
|
||||
// to downsample the image while decoding it.
|
||||
// Note that we base this samplesize computation on the full size
|
||||
// of the image, because we have to decode the entire thing even
|
||||
// if we are later going to crop it.
|
||||
var ds = Downsample.areaAtLeast(outputSize / fullsize);
|
||||
|
||||
// Now that we've figured out how much the full image will be
|
||||
// downsampled, scale the crop region to match.
|
||||
outputSize = {
|
||||
width: ds.scale(cropRegion.width),
|
||||
height: ds.scale(cropRegion.height)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!(outputSize.width > 0 && outputSize.height > 0)) {
|
||||
callback('outputSize width and height must be positive');
|
||||
return;
|
||||
}
|
||||
|
||||
// If the outputSize is bigger than the crop region, just adjust
|
||||
// the output size to match.
|
||||
if (outputSize.width > cropRegion.width) {
|
||||
outputSize.width = cropRegion.width;
|
||||
}
|
||||
if (outputSize.height > cropRegion.height) {
|
||||
outputSize.height = cropRegion.height;
|
||||
}
|
||||
|
||||
// How much do we have to scale the crop region in X and Y dimensions
|
||||
// to match the output size?
|
||||
var scaleX = outputSize.width / cropRegion.width;
|
||||
var scaleY = outputSize.height / cropRegion.height;
|
||||
|
||||
// We now adjust the crop region to match the output size. For
|
||||
// example if the outputSize is 200x200 and the cropRegion is
|
||||
// 600x400, then scaleX is .33 and scaleY is .5. In this case we can
|
||||
// leave the height of the crop region alone, but we need to reduce
|
||||
// the width of the crop region and adjust the left of the crop region
|
||||
|
||||
if (scaleY > scaleX) { // adjust width of crop region
|
||||
var oldCropWidth = cropRegion.width;
|
||||
cropRegion.width = Math.round(outputSize.width / scaleY);
|
||||
cropRegion.left += (oldCropWidth - cropRegion.width) >> 1;
|
||||
}
|
||||
else if (scaleX > scaleY) { // adjust height of crop region
|
||||
var oldCropHeight = cropRegion.height;
|
||||
cropRegion.height = Math.round(outputSize.height / scaleX);
|
||||
cropRegion.top += (oldCropHeight - cropRegion.height) >> 1;
|
||||
}
|
||||
|
||||
// Make sure the outputType is valid, if one was specified
|
||||
if (outputType && outputType !== JPEG &&
|
||||
outputType !== PNG && outputType !== EXIF) {
|
||||
callback('unsupported outputType: ' + outputType);
|
||||
return;
|
||||
}
|
||||
var preserveExif = false;
|
||||
if (blob.type === JPEG && outputType === EXIF) {
|
||||
preserveExif = true;
|
||||
outputType = JPEG;
|
||||
}
|
||||
|
||||
// Now that we've done these computations, we can pause for a moment
|
||||
// to see if there is actually any work that needs doing here. If not
|
||||
// we can just pass the input blob unchanged through to the callback
|
||||
if (rotation === 0 && // No need to rotate
|
||||
!mirrored && // or to mirror the image.
|
||||
(!outputType || // Don't care about output type
|
||||
blob.type === outputType) && // or type is unchanged.
|
||||
outputSize.width === rawImageWidth && // Doesn't need crop or resize.
|
||||
outputSize.height == rawImageHeight) {
|
||||
callback(null, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
// The crop region we've been working with so far is in the output
|
||||
// coordinate system: it assumes that any required rotation has been done.
|
||||
// In order to know exactly which pixels to extract from the image we
|
||||
// need to convert to the unrotated, unmirrored input coordinate system.
|
||||
var inputCropRegion;
|
||||
|
||||
// First, handle rotation
|
||||
switch (rotation) {
|
||||
case 180:
|
||||
// The image is upside down. The width and height are the same but
|
||||
// the top and left have to change.
|
||||
inputCropRegion = {
|
||||
left: rawImageWidth - cropRegion.left - cropRegion.width,
|
||||
top: rawImageHeight - cropRegion.top - cropRegion.height,
|
||||
width: cropRegion.width,
|
||||
height: cropRegion.height
|
||||
};
|
||||
break;
|
||||
|
||||
case 90:
|
||||
// sideways: swap width and height and adjust top and left
|
||||
inputCropRegion = {
|
||||
left: cropRegion.top,
|
||||
top: rawImageHeight - cropRegion.left - cropRegion.width,
|
||||
width: cropRegion.height,
|
||||
height: cropRegion.width
|
||||
};
|
||||
break;
|
||||
|
||||
case 270:
|
||||
// sideways: swap width and height and adjust top and left
|
||||
inputCropRegion = {
|
||||
left: rawImageWidth - cropRegion.top - cropRegion.height,
|
||||
top: cropRegion.left,
|
||||
width: cropRegion.height,
|
||||
height: cropRegion.width
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
// the crop region is the same in this case
|
||||
inputCropRegion = {
|
||||
left: cropRegion.left,
|
||||
top: cropRegion.top,
|
||||
width: cropRegion.width,
|
||||
height: cropRegion.height
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
// Next, adjust for mirroring
|
||||
if (mirrored) {
|
||||
if (rotation === 90 || rotation === 270) {
|
||||
inputCropRegion.top =
|
||||
rawImageHeight - inputCropRegion.top - inputCropRegion.height;
|
||||
}
|
||||
else {
|
||||
inputCropRegion.left =
|
||||
rawImageWidth - inputCropRegion.left - inputCropRegion.width;
|
||||
}
|
||||
}
|
||||
|
||||
// In order to decode the image, we create a blob:// URL for it
|
||||
var baseURL = URL.createObjectURL(blob);
|
||||
|
||||
// Decoding an image takes a lot of memory and we want to minimize that.
|
||||
// Gecko allows us to use media fragments with our image URL to specify
|
||||
// that we do not want it to decode all of the pixels in the image. The
|
||||
// #-moz-samplesize= fragment allows us to specify that JPEG images
|
||||
// should be downsampled while being decoded, and this can save a lot of
|
||||
// memory.
|
||||
var sampledsize;
|
||||
var downsample;
|
||||
|
||||
// If we decode the image with a #-moz-samplesize media fragment, both
|
||||
// the x and y dimensions are reduced by the sample size, so the total
|
||||
// number of pixels is reduced by the square of the sample size.
|
||||
if (blob.type === JPEG) {
|
||||
// What media fragment can we use to downsample the crop region
|
||||
// so that it is as small as possible without being smaller than
|
||||
// the output size? We know that the output size and crop
|
||||
// region have the same aspect ratio now, so we only have to
|
||||
// consider one dimension. If we passed in a single number outputSize
|
||||
// up above then we Downsample.areaAtLeast() to compute the outputSize.
|
||||
// We should now get the same media fragment value here.
|
||||
downsample =
|
||||
Downsample.sizeNoMoreThan(outputSize.width / cropRegion.width);
|
||||
|
||||
// And if apply that media fragment to the entire image, how big is
|
||||
// the result?
|
||||
sampledsize = downsample.scale(rawImageWidth) *
|
||||
downsample.scale(rawImageHeight);
|
||||
}
|
||||
else {
|
||||
downsample = Downsample.NONE;
|
||||
sampledsize = fullsize;
|
||||
}
|
||||
|
||||
// Now add the appropriate media fragments to the url
|
||||
var url;
|
||||
var resizedWithMediaFragment = false;
|
||||
|
||||
if (sampledsize < fullsize) {
|
||||
// Use a #-moz-samplesize media fragment to downsample while decoding
|
||||
url = baseURL + downsample;
|
||||
resizedWithMediaFragment = true;
|
||||
}
|
||||
else {
|
||||
// No media fragments in this case
|
||||
url = baseURL;
|
||||
}
|
||||
|
||||
// Now we've done our calculations and we have an image URL to decode
|
||||
var offscreenImage = new Image();
|
||||
offscreenImage.src = url;
|
||||
offscreenImage.onerror = function() {
|
||||
callback('error decoding image: ' + url);
|
||||
};
|
||||
offscreenImage.onload = gotImage;
|
||||
|
||||
// Called when the image has loaded
|
||||
function gotImage() {
|
||||
// If we used a #-moz-samplesize media fragment on the image url,
|
||||
// and we got an image that is smaller than full-size, then we
|
||||
// need to reduce the crop region proportionally.
|
||||
if (resizedWithMediaFragment) {
|
||||
if (offscreenImage.width < rawImageWidth ||
|
||||
offscreenImage.height < rawImageHeight) {
|
||||
var sampleSizeX = rawImageWidth / offscreenImage.width;
|
||||
var sampleSizeY = rawImageHeight / offscreenImage.height;
|
||||
inputCropRegion.left =
|
||||
Math.round(inputCropRegion.left / sampleSizeX);
|
||||
inputCropRegion.top =
|
||||
Math.round(inputCropRegion.top / sampleSizeY);
|
||||
inputCropRegion.width =
|
||||
Math.round(inputCropRegion.width / sampleSizeX);
|
||||
inputCropRegion.height =
|
||||
Math.round(inputCropRegion.height / sampleSizeY);
|
||||
}
|
||||
}
|
||||
|
||||
// We've decoded the image now, so create a canvas we can copy it into
|
||||
var canvas = document.createElement('canvas');
|
||||
var destWidth = canvas.width = outputSize.width;
|
||||
var destHeight = canvas.height = outputSize.height;
|
||||
|
||||
// Since we're only using the canvas as a way to encode the image
|
||||
// we set this willReadFrequently flag as a hint so that we avoid
|
||||
// copying the image data to and from the GPU since we don't do any
|
||||
// GPU operations on it
|
||||
var context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
// If the image needs to be rotated or mirrored we have to establish
|
||||
// an appropriate transform on the context
|
||||
if (rotation || mirrored) {
|
||||
// translate so we're rotating around the center
|
||||
context.translate(canvas.width / 2, canvas.height / 2);
|
||||
|
||||
if (mirrored) {
|
||||
context.scale(-1, 1);
|
||||
}
|
||||
|
||||
// rotate
|
||||
switch (rotation) {
|
||||
case 90:
|
||||
context.rotate(Math.PI / 2);
|
||||
destWidth = canvas.height;
|
||||
destHeight = canvas.width;
|
||||
break;
|
||||
case 180:
|
||||
context.rotate(Math.PI);
|
||||
break;
|
||||
case 270:
|
||||
context.rotate(-Math.PI / 2);
|
||||
destWidth = canvas.height;
|
||||
destHeight = canvas.width;
|
||||
break;
|
||||
}
|
||||
|
||||
// And translate back
|
||||
if (rotation === 90 || rotation === 270) {
|
||||
// For the 90 and 270 case we swap width and height
|
||||
context.translate(-canvas.height / 2, -canvas.width / 2);
|
||||
}
|
||||
else {
|
||||
context.translate(-canvas.width / 2, -canvas.height / 2);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Now we copy the image into the canvas.
|
||||
// The image has been loaded, but not decoded yet. If the image file
|
||||
// appears to be valid and has valid width and height metadata, then
|
||||
// the onload event handler will fire. But if the image is corrupt
|
||||
// or too big for gecko to decode with the amount of available
|
||||
// memory, then this drawImage() call can fail with an exception.
|
||||
context.drawImage(offscreenImage,
|
||||
// What part of the image we're drawing
|
||||
inputCropRegion.left, inputCropRegion.top,
|
||||
inputCropRegion.width, inputCropRegion.height,
|
||||
// And what part of the canvas we're drawing it to
|
||||
0, 0, destWidth, destHeight);
|
||||
}
|
||||
catch(e) {
|
||||
callback('Failed to decode image in cropResizeRotate; ' +
|
||||
'image may be corrupt or too large: ' + e);
|
||||
return;
|
||||
}
|
||||
finally {
|
||||
// Once the image has been copied, we can release the decoded image
|
||||
// memory and the blob URL.
|
||||
offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
|
||||
URL.revokeObjectURL(baseURL);
|
||||
}
|
||||
|
||||
var inputBlob = blob; // XXX just use a different parameter name
|
||||
|
||||
// Finally, encode the image into a blob
|
||||
|
||||
var postCallback = preserveExif ? copyMetadata : gotEncodedBlob;
|
||||
|
||||
canvas.toBlob(postCallback, outputType || JPEG);
|
||||
|
||||
function copyMetadata(outputBlob) {
|
||||
try {
|
||||
LazyLoader.load(['shared/js/media/jpeg-exif.js'], () => {
|
||||
JPEGParser.readExifMetaData(inputBlob, (error, metaData) => {
|
||||
|
||||
if (error || !metaData) {
|
||||
gotEncodedBlob(outputBlob);
|
||||
return;
|
||||
}
|
||||
metaData.Orientation = 1;
|
||||
if (metaData.PixelXDimension) {
|
||||
metaData.PixelXDimension = outputSize.width;
|
||||
}
|
||||
if (metaData.PixelYDimension) {
|
||||
metaData.PixelYDimension = outputSize.height;
|
||||
}
|
||||
|
||||
JPEGParser.writeExifMetaData(
|
||||
outputBlob,
|
||||
metaData,
|
||||
(error, modifiedBlob) => {
|
||||
if (error) {
|
||||
console.error('Error' + error);
|
||||
}
|
||||
// Process modified file
|
||||
gotEncodedBlob(modifiedBlob);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
catch(e) {
|
||||
console.log(e.stack);
|
||||
}
|
||||
}
|
||||
|
||||
function gotEncodedBlob(blob) {
|
||||
// We have the encoded image but before we pass it to the callback
|
||||
// we need to free the canvas.
|
||||
canvas.width = canvas.height = 0;
|
||||
canvas = context = null;
|
||||
callback(null, blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
155
gaia/shared/js/media/downsample.js
Executable file
155
gaia/shared/js/media/downsample.js
Executable file
|
|
@ -0,0 +1,155 @@
|
|||
// downsample.js
|
||||
//
|
||||
// This module defines a single global Downsample object with static
|
||||
// methods that return objects representing media fragments for
|
||||
// downsampling images while they are decoded. The current implementation
|
||||
// is based on the #-moz-samplesize media fragment. But because of
|
||||
// problems with that fragment (see bug 1004908) it seems likely that a
|
||||
// new syntax or new fragment will be introduced. If that happens, we can
|
||||
// just change this module and not have to change anything else that
|
||||
// depends on it.
|
||||
//
|
||||
// The method Downsample.areaAtLeast(scale) returns an object
|
||||
// representing a media fragment to use to decode an image downsampled by
|
||||
// at least as much as the specified scale. If you are trying to preview
|
||||
// an 8mp image and don't want to use more than 2mp of image memory, for
|
||||
// example, you would pass a scale of .25 (2mp/8mp) here, and the
|
||||
// resulting media fragment could be appended to the url to make the
|
||||
// image decode at a size equal to or smaller than 2mp.
|
||||
//
|
||||
// The method Downsample.sizeNoMoreThan(scale) returns a media fragment
|
||||
// object that you can use to reduce the dimensions of an image as much
|
||||
// as possible without exceeding the specified scale. If you have a
|
||||
// 1600x1200 image and want to decode it to produce an image that is as
|
||||
// small as possible but at least 160x120, you would pass a scale of 0.1.
|
||||
//
|
||||
// The returned objects have a dimensionScale property that specifies how
|
||||
// they affect the dimensions of the image and an areaScale property that
|
||||
// specifies how much they affect the area (number of pixels) in an
|
||||
// image. (The areaScale is just the square of the scale.) To avoid
|
||||
// floating-point rounding issues, the values of these scale properties
|
||||
// are rounded to the nearest hundredth.
|
||||
//
|
||||
// The returned objects also have a scale() method that scales a
|
||||
// dimension with proper rounding (it rounds up to the nearest integer
|
||||
// just as libjpeg does).
|
||||
//
|
||||
// Each object also has a toString() method that returns the required
|
||||
// media fragment (including the hash mark) so you can simply use
|
||||
// string concatentation to append one of these objects to the URL of
|
||||
// the image you want to decode.
|
||||
//
|
||||
// Downsample.NONE is a no-op media fragment object with scale set to
|
||||
// 1, and a toString() method that returns the empty string.
|
||||
//
|
||||
(function(exports) {
|
||||
'use strict';
|
||||
|
||||
// Round to the nearest hundredth to combat floating point rounding errors
|
||||
function round(x) {
|
||||
return Math.round(x * 100) / 100;
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// A factory method for returning an object that represents a
|
||||
// #-moz-samplesize media fragment. The use of Math.ceil() in the
|
||||
// scale method is from jpeg_core_output_dimensions() in
|
||||
// media/libjpeg/jdmaster.c and jdiv_round_up() in media/libjpeg/jutils.c
|
||||
//
|
||||
function MozSampleSize(n, scale) {
|
||||
return Object.freeze({
|
||||
dimensionScale: round(scale),
|
||||
areaScale: round(scale * scale),
|
||||
toString: function() { return '#-moz-samplesize=' + n; },
|
||||
scale: function(x) { return Math.ceil(x * scale); }
|
||||
});
|
||||
}
|
||||
|
||||
// A fragment object that represents no downsampling with no fragment
|
||||
var NONE = Object.freeze({
|
||||
dimensionScale: 1,
|
||||
areaScale: 1,
|
||||
toString: function() { return ''; },
|
||||
scale: function(x) { return x; }
|
||||
});
|
||||
|
||||
//
|
||||
// The five possible #-moz-samplesize values.
|
||||
// The mapping from sample size to scale comes from:
|
||||
// the moz-samplesize code in /image/decoders/nsJPEGDecoder.cpp and
|
||||
// the jpeg_core_output_dimensions() function in media/libjpeg/jdmaster.c
|
||||
//
|
||||
var fragments = [
|
||||
NONE,
|
||||
MozSampleSize(2, 1 / 2), // samplesize=2 reduces size by 1/2 and area by 1/4
|
||||
MozSampleSize(3, 3 / 8), // etc.
|
||||
MozSampleSize(4, 1 / 4),
|
||||
MozSampleSize(8, 1 / 8)
|
||||
];
|
||||
|
||||
// Return the fragment object that has the largest scale and downsamples the
|
||||
// dimensions of an image at least as much as the specified scale.
|
||||
// If none of the choices scales enough, return the one that comes closest
|
||||
function sizeAtLeast(scale) {
|
||||
scale = round(scale);
|
||||
for (var i = 0; i < fragments.length; i++) {
|
||||
var f = fragments[i];
|
||||
if (f.dimensionScale <= scale) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
return fragments[fragments.length - 1];
|
||||
}
|
||||
|
||||
// Return the fragment object that downsamples an image as far as possible
|
||||
// without going beyond the specified scale. This might return NONE.
|
||||
function sizeNoMoreThan(scale) {
|
||||
scale = round(scale);
|
||||
for (var i = fragments.length - 1; i >= 0; i--) {
|
||||
var f = fragments[i];
|
||||
if (f.dimensionScale >= scale) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
return NONE;
|
||||
}
|
||||
|
||||
// Return the fragment object that has the largest scale and downsamples the
|
||||
// area of an image at least as much as the specified scale.
|
||||
// If none of the choices scales enough, return the one that comes closest
|
||||
function areaAtLeast(scale) {
|
||||
scale = round(scale);
|
||||
for (var i = 0; i < fragments.length; i++) {
|
||||
var f = fragments[i];
|
||||
if (f.areaScale <= scale) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
return fragments[fragments.length - 1];
|
||||
}
|
||||
|
||||
// Return the fragment object that downsamples the area of an image
|
||||
// as far as possible without going beyond the specified scale. This
|
||||
// might return NONE.
|
||||
function areaNoMoreThan(scale) {
|
||||
scale = round(scale);
|
||||
for (var i = fragments.length - 1; i >= 0; i--) {
|
||||
var f = fragments[i];
|
||||
if (f.areaScale >= scale) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
return NONE;
|
||||
}
|
||||
|
||||
exports.Downsample = {
|
||||
sizeAtLeast: sizeAtLeast,
|
||||
sizeNoMoreThan: sizeNoMoreThan,
|
||||
areaAtLeast: areaAtLeast,
|
||||
areaNoMoreThan: areaNoMoreThan,
|
||||
NONE: NONE,
|
||||
MAX_SIZE_REDUCTION: 1 / fragments[fragments.length - 1].dimensionScale,
|
||||
MAX_AREA_REDUCTION: 1 / fragments[fragments.length - 1].areaScale
|
||||
};
|
||||
}(window));
|
||||
152
gaia/shared/js/media/get_video_rotation.js
Executable file
152
gaia/shared/js/media/get_video_rotation.js
Executable file
|
|
@ -0,0 +1,152 @@
|
|||
'use strict';
|
||||
/* global BlobView */
|
||||
/* exported getVideoRotation */
|
||||
|
||||
//
|
||||
// Given an MP4/Quicktime based video file as a blob, read through its
|
||||
// atoms to find the track header "tkhd" atom and extract the rotation
|
||||
// matrix from it. Convert the matrix value to rotation in degrees and
|
||||
// pass that number to the specified callback function. If no value is
|
||||
// found but the video file is valid, pass null to the callback. If
|
||||
// any errors occur, pass an error message (a string) callback.
|
||||
//
|
||||
// See also:
|
||||
// http://androidxref.com/4.0.4/xref/frameworks/base/media/libstagefright/MPEG4Writer.cpp
|
||||
// https://developer.apple.com/library/mac/#documentation/QuickTime/QTFF/QTFFChap2/qtff2.html
|
||||
//
|
||||
function getVideoRotation(blob, rotationCallback) {
|
||||
|
||||
// A utility for traversing the tree of atoms in an MP4 file
|
||||
function MP4Parser(blob, handlers) {
|
||||
// Start off with a 1024 chunk from the start of the blob.
|
||||
BlobView.get(blob, 0, Math.min(1024, blob.size), function(data, error) {
|
||||
// Make sure that the blob is, in fact, some kind of MP4 file
|
||||
if (data.byteLength <= 8 || data.getBinaryText(4, 4) !== 'ftyp') {
|
||||
handlers.errorHandler('not an MP4 file');
|
||||
return;
|
||||
}
|
||||
parseAtom(data);
|
||||
});
|
||||
|
||||
// Call this with a BlobView object that includes the first 16 bytes of
|
||||
// an atom. It doesn't matter whether the body of the atom is included.
|
||||
function parseAtom(data) {
|
||||
var offset = data.sliceOffset + data.viewOffset; // atom position in blob
|
||||
var size = data.readUnsignedInt(); // atom length
|
||||
var type = data.readBinaryText(4); // atom type
|
||||
var contentOffset = 8; // position of content
|
||||
|
||||
if (size === 0) {
|
||||
// Zero size means the rest of the file
|
||||
size = blob.size - offset;
|
||||
}
|
||||
else if (size === 1) {
|
||||
// A size of 1 means the size is in bytes 8-15
|
||||
size = data.readUnsignedInt() * 4294967296 + data.readUnsignedInt();
|
||||
contentOffset = 16;
|
||||
}
|
||||
|
||||
var handler = handlers[type] || handlers.defaultHandler;
|
||||
if (typeof handler === 'function') {
|
||||
// If the handler is a function, pass that function a
|
||||
// DataView object that contains the entire atom
|
||||
// including size and type. Then use the return value
|
||||
// of the function as instructions on what to do next.
|
||||
data.getMore(data.sliceOffset + data.viewOffset, size, function(atom) {
|
||||
// Pass the entire atom to the handler function
|
||||
var rv = handler(atom);
|
||||
|
||||
// If the return value is 'done', stop parsing.
|
||||
// Otherwise, continue with the next atom.
|
||||
// XXX: For more general parsing we need a way to pop some
|
||||
// stack levels. A return value that is an atom name should mean
|
||||
// pop back up to this atom type and go on to the next atom
|
||||
// after that.
|
||||
if (rv !== 'done') {
|
||||
parseAtomAt(data, offset + size);
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (handler === 'children') {
|
||||
// If the handler is this string, then assume that the atom is
|
||||
// a container atom and do its next child atom next
|
||||
var skip = (type === 'meta') ? 4 : 0; // special case for meta atoms
|
||||
parseAtomAt(data, offset + contentOffset + skip);
|
||||
}
|
||||
else if (handler === 'skip' || !handler) {
|
||||
// Skip the atom entirely and go on to the next one.
|
||||
// If there is no next one, call the eofHandler or just return
|
||||
parseAtomAt(data, offset + size);
|
||||
}
|
||||
else if (handler === 'done') {
|
||||
// Stop parsing
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function parseAtomAt(data, offset) {
|
||||
if (offset >= blob.size) {
|
||||
if (handlers.eofHandler) {
|
||||
handlers.eofHandler();
|
||||
}
|
||||
return;
|
||||
}
|
||||
else {
|
||||
data.getMore(offset, 16, parseAtom);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We want to loop through the top-level atoms until we find the 'moov'
|
||||
// atom. Then, within this atom, there are one or more 'trak' atoms.
|
||||
// Each 'trak' should begin with a 'tkhd' atom. The tkhd atom has
|
||||
// a transformation matrix at byte 48. The matrix is 9 32 bit integers.
|
||||
// We'll interpret those numbers as a rotation of 0, 90, 180 or 270.
|
||||
// If the video has more than one track, we expect all of them to have
|
||||
// the same rotation, so we'll only look at the first 'trak' atom that
|
||||
// we find.
|
||||
MP4Parser(blob, {
|
||||
errorHandler: function(msg) { rotationCallback(msg); },
|
||||
eofHandler: function() { rotationCallback(null); },
|
||||
defaultHandler: 'skip', // Skip all atoms other than those listed below
|
||||
moov: 'children', // Enumerate children of the moov atom
|
||||
trak: 'children', // Enumerate children of the trak atom
|
||||
tkhd: function(data) { // Pass the tkhd atom to this function
|
||||
// The matrix begins at byte 48
|
||||
data.advance(48);
|
||||
|
||||
var a = data.readUnsignedInt();
|
||||
var b = data.readUnsignedInt();
|
||||
data.advance(4); // we don't care about this number
|
||||
var c = data.readUnsignedInt();
|
||||
var d = data.readUnsignedInt();
|
||||
|
||||
if (a === 0 && d === 0) { // 90 or 270 degrees
|
||||
if (b === 0x00010000 && c === 0xFFFF0000) {
|
||||
rotationCallback(90);
|
||||
}
|
||||
else if (b === 0xFFFF0000 && c === 0x00010000) {
|
||||
rotationCallback(270);
|
||||
}
|
||||
else {
|
||||
rotationCallback('unexpected rotation matrix');
|
||||
}
|
||||
}
|
||||
else if (b === 0 && c === 0) { // 0 or 180 degrees
|
||||
if (a === 0x00010000 && d === 0x00010000) {
|
||||
rotationCallback(0);
|
||||
}
|
||||
else if (a === 0xFFFF0000 && d === 0xFFFF0000) {
|
||||
rotationCallback(180);
|
||||
}
|
||||
else {
|
||||
rotationCallback('unexpected rotation matrix');
|
||||
}
|
||||
}
|
||||
else {
|
||||
rotationCallback('unexpected rotation matrix');
|
||||
}
|
||||
return 'done';
|
||||
}
|
||||
});
|
||||
}
|
||||
105
gaia/shared/js/media/image_size.js
Executable file
105
gaia/shared/js/media/image_size.js
Executable file
|
|
@ -0,0 +1,105 @@
|
|||
/* exported getImageSize */
|
||||
/* global BlobView */
|
||||
/* global parseJPEGMetadata */
|
||||
|
||||
/*
|
||||
* Determine the pixel dimensions of an image without actually
|
||||
* decoding the image. Passes an object of metadata to the callback
|
||||
* function on success or an error message to the error function on
|
||||
* failure. The metadata object will include type, width and height
|
||||
* properties. Supported image types are GIF, PNG and JPEG. JPEG
|
||||
* metadata may also include information about an EXIF preview image.
|
||||
*
|
||||
* Because of shortcomings in the way Gecko handles images, the
|
||||
* Gallery app will crash with an OOM error if it attempts to decode
|
||||
* and display an image that is too big. Images require 4 bytes per
|
||||
* pixel, so a 10 megapixel photograph requires 40 megabytes of image
|
||||
* memory. This function gives the gallery app a way to reject images
|
||||
* that are too large.
|
||||
*
|
||||
* Requires the BlobView class from shared/js/blobview.js and the
|
||||
* parseJPEGMetadata() function from shared/js/media/jpeg_metadata_parser.js
|
||||
*/
|
||||
function getImageSize(blob, callback, error) {
|
||||
'use strict';
|
||||
|
||||
BlobView.get(blob, 0, Math.min(1024, blob.size), function(data) {
|
||||
// Make sure we are at least 8 bytes long before reading the first 8 bytes
|
||||
if (data.byteLength <= 8) {
|
||||
error('corrupt image file');
|
||||
return;
|
||||
}
|
||||
var magic = data.getBinaryText(0, 8);
|
||||
if (magic.substring(0, 4) === 'GIF8') {
|
||||
try {
|
||||
callback({
|
||||
type: 'gif',
|
||||
width: data.getUint16(6, true),
|
||||
height: data.getUint16(8, true)
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
error(e.toString());
|
||||
}
|
||||
}
|
||||
else if (magic.substring(0, 8) === '\x89PNG\r\n\x1A\n') {
|
||||
try {
|
||||
callback({
|
||||
type: 'png',
|
||||
width: data.getUint32(16, false),
|
||||
height: data.getUint32(20, false)
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
error(e.toString());
|
||||
}
|
||||
}
|
||||
else if (magic.substring(0, 2) === 'BM' &&
|
||||
data.getUint32(2, true) === blob.size) {
|
||||
// This is a BMP file
|
||||
try {
|
||||
var width, height;
|
||||
|
||||
if (data.getUint16(14, true) === 12) { // check format version
|
||||
width = data.getUint16(18, true); // 16-bit little endian width
|
||||
height = data.getUint16(20, true); // 16-bit little endian height
|
||||
}
|
||||
else { // newer versions of the format use 32-bit ints
|
||||
width = data.getUint32(18, true); // 32-bit little endian width
|
||||
height = data.getUint32(22, true); // 32-bit little endian height
|
||||
}
|
||||
|
||||
callback({
|
||||
type: 'bmp',
|
||||
width: width,
|
||||
height: height
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
error(e.toString());
|
||||
}
|
||||
}
|
||||
else if (magic.substring(0, 2) === '\xFF\xD8') {
|
||||
parseJPEGMetadata(blob,
|
||||
function(metadata) {
|
||||
if (metadata.progressive) {
|
||||
// If the jpeg parser tells us that this is
|
||||
// a progressive jpeg, then treat that as a
|
||||
// distinct image type because pjpegs have
|
||||
// such different memory requirements than
|
||||
// regular jpegs.
|
||||
delete metadata.progressive;
|
||||
metadata.type = 'pjpeg';
|
||||
}
|
||||
else {
|
||||
metadata.type = 'jpeg';
|
||||
}
|
||||
callback(metadata);
|
||||
},
|
||||
error);
|
||||
}
|
||||
else {
|
||||
error('unknown image type');
|
||||
}
|
||||
});
|
||||
}
|
||||
3220
gaia/shared/js/media/jpeg-exif.js
Executable file
3220
gaia/shared/js/media/jpeg-exif.js
Executable file
File diff suppressed because it is too large
Load diff
373
gaia/shared/js/media/jpeg_metadata_parser.js
Executable file
373
gaia/shared/js/media/jpeg_metadata_parser.js
Executable file
|
|
@ -0,0 +1,373 @@
|
|||
'use strict';
|
||||
|
||||
/* global BlobView */
|
||||
/* exported parseJPEGMetadata */
|
||||
//
|
||||
// This file defines a single function that asynchronously reads a
|
||||
// JPEG file (or blob) to determine its width and height and find the
|
||||
// location and size of the embedded preview image, if it has one. If
|
||||
// it succeeds, it passes an object containing this data to the
|
||||
// specified callback function. If it fails, it passes an error message
|
||||
// to the specified error function instead.
|
||||
//
|
||||
// This function is capable of parsing and returning EXIF data for a
|
||||
// JPEG file, but for speed, it ignores all EXIF data except the embedded
|
||||
// preview image and the image orientation.
|
||||
//
|
||||
// This function requires the BlobView utility class
|
||||
//
|
||||
function parseJPEGMetadata(file, metadataCallback, metadataError) {
|
||||
// This is the object we'll pass to metadataCallback
|
||||
var metadata = {};
|
||||
|
||||
// this is the start offset for the data in exif. Should be
|
||||
// where to find the TIFF magic header.
|
||||
var TIFFHeaderOffset = 0;
|
||||
|
||||
// Start off reading a 16kb slice of the JPEG file.
|
||||
// Hopefully, this will be all we need and everything else will
|
||||
// be synchronous
|
||||
BlobView.get(file, 0, Math.min(16 * 1024, file.size), function(data) {
|
||||
if (data.byteLength < 2 ||
|
||||
data.getUint8(0) !== 0xFF ||
|
||||
data.getUint8(1) !== 0xD8) {
|
||||
metadataError('Not a JPEG file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Now start reading JPEG segments
|
||||
// getSegment() and segmentHandler() are defined below.
|
||||
getSegment(data, 2, segmentHandler);
|
||||
});
|
||||
|
||||
// Read the JPEG segment at the specified offset and
|
||||
// pass it to the callback function.
|
||||
// Offset is relative to the current data offsets.
|
||||
// We assume that data has enough data in it that we can
|
||||
// can determine the size of the segment, and we guarantee that
|
||||
// we read extra bytes so the next call works
|
||||
function getSegment(data, offset, callback) {
|
||||
try {
|
||||
var header = data.getUint8(offset);
|
||||
if (header !== 0xFF) {
|
||||
metadataError('Malformed JPEG file: bad segment header');
|
||||
return;
|
||||
}
|
||||
|
||||
var type = data.getUint8(offset + 1);
|
||||
var size = data.getUint16(offset + 2) + 2;
|
||||
|
||||
// the absolute position of the segment
|
||||
var start = data.sliceOffset + data.viewOffset + offset;
|
||||
// If this isn't the last segment in the file, add 4 bytes
|
||||
// so we can read the size of the next segment
|
||||
var isLast = (start + size >= file.size);
|
||||
var length = isLast ? size : size + 4;
|
||||
|
||||
data.getMore(start, length,
|
||||
function(data) {
|
||||
callback(type, size, data, isLast);
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
metadataError(e.toString() + '\n' + e.stack);
|
||||
}
|
||||
}
|
||||
|
||||
// This is a callback function for getNextSegment that handles the
|
||||
// various types of segments we expect to see in a jpeg file
|
||||
function segmentHandler(type, size, data, isLastSegment) {
|
||||
try {
|
||||
switch (type) {
|
||||
case 0xC0: // Some actual image data, including image dimensions
|
||||
case 0xC1:
|
||||
case 0xC2:
|
||||
case 0xC3:
|
||||
// Get image dimensions
|
||||
metadata.height = data.getUint16(5);
|
||||
metadata.width = data.getUint16(7);
|
||||
|
||||
if (type === 0xC2) {
|
||||
// pjpeg files can't be efficiently downsampled while decoded
|
||||
// so we need to distinguish them from regular jpegs
|
||||
metadata.progressive = true;
|
||||
}
|
||||
|
||||
// We're done. All the EXIF data will come before this segment
|
||||
// So call the callback
|
||||
metadataCallback(metadata);
|
||||
break;
|
||||
|
||||
case 0xE1: // APP1 segment. Probably holds EXIF metadata
|
||||
parseAPP1(data);
|
||||
|
||||
/* falls through */
|
||||
default:
|
||||
// A segment we don't care about, so just go on and read the next one
|
||||
if (isLastSegment) {
|
||||
metadataError('unexpected end of JPEG file');
|
||||
return;
|
||||
}
|
||||
getSegment(data, size, segmentHandler);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
metadataError(e.toString() + '\n' + e.stack);
|
||||
}
|
||||
}
|
||||
|
||||
function parseAPP1(data) {
|
||||
if (data.getUint32(4, false) === 0x45786966) { // "Exif"
|
||||
var exif = parseEXIFData(data);
|
||||
|
||||
if (exif.THUMBNAIL && exif.THUMBNAILLENGTH) {
|
||||
var start = data.sliceOffset + data.viewOffset + TIFFHeaderOffset +
|
||||
exif.THUMBNAIL;
|
||||
metadata.preview = {
|
||||
start: start,
|
||||
end: start + exif.THUMBNAILLENGTH
|
||||
};
|
||||
}
|
||||
|
||||
// map exif orientation flags for easy transforms
|
||||
switch (exif.ORIENTATION) {
|
||||
case 2:
|
||||
metadata.rotation = 0;
|
||||
metadata.mirrored = true;
|
||||
break;
|
||||
case 3:
|
||||
metadata.rotation = 180;
|
||||
metadata.mirrored = false;
|
||||
break;
|
||||
case 4:
|
||||
metadata.rotation = 180;
|
||||
metadata.mirrored = true;
|
||||
break;
|
||||
case 5:
|
||||
metadata.rotation = 90;
|
||||
metadata.mirrored = true;
|
||||
break;
|
||||
case 6:
|
||||
metadata.rotation = 90;
|
||||
metadata.mirrored = false;
|
||||
break;
|
||||
case 7:
|
||||
metadata.rotation = 270;
|
||||
metadata.mirrored = true;
|
||||
break;
|
||||
case 8:
|
||||
metadata.rotation = 270;
|
||||
metadata.mirrored = false;
|
||||
break;
|
||||
default:
|
||||
// This is the default orientation. If it is properly encoded
|
||||
// we will get 1 here. But sometimes it is undefined and some
|
||||
// files have a 0 here as well.
|
||||
metadata.rotation = 0;
|
||||
metadata.mirrored = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse an EXIF segment from a JPEG file and return an object
|
||||
// of metadata attributes. The argument must be a DataView object
|
||||
function parseEXIFData(data) {
|
||||
var exif = {};
|
||||
|
||||
var byteorder = data.getUint8(10);
|
||||
if (byteorder === 0x4D) { // big endian
|
||||
byteorder = false;
|
||||
} else if (byteorder === 0x49) { // little endian
|
||||
byteorder = true;
|
||||
} else {
|
||||
throw Error('invalid byteorder in EXIF segment');
|
||||
}
|
||||
|
||||
// The TIFF magic started at +10.
|
||||
TIFFHeaderOffset = 10;
|
||||
|
||||
if (data.getUint16(12, byteorder) !== 42) { // magic number
|
||||
throw Error('bad magic number in EXIF segment');
|
||||
}
|
||||
|
||||
var offset = data.getUint32(14, byteorder);
|
||||
|
||||
// This is how we would parse all EXIF metadata more generally.
|
||||
// Especially need for iamge orientation
|
||||
parseIFD(data, offset + TIFFHeaderOffset, byteorder, exif, true);
|
||||
|
||||
// I'm leaving this code in as a comment in case we need other EXIF
|
||||
// data in the future.
|
||||
// if (exif.EXIFIFD) {
|
||||
// parseIFD(data, exif.EXIFIFD + TIFFHeaderOffset, byteorder, exif);
|
||||
// delete exif.EXIFIFD;
|
||||
// }
|
||||
|
||||
// if (exif.GPSIFD) {
|
||||
// parseIFD(data, exif.GPSIFD + TIFFHeaderOffset, byteorder, exif);
|
||||
// delete exif.GPSIFD;
|
||||
// }
|
||||
|
||||
// Instead of a general purpose EXIF parse, we're going to drill
|
||||
// down directly to the thumbnail image.
|
||||
// We're in IFD0 here. We want the offset of IFD1
|
||||
var ifd0entries = data.getUint16(offset + TIFFHeaderOffset, byteorder);
|
||||
var ifd1 = data.getUint32(offset + 12 + 12 * ifd0entries, byteorder);
|
||||
// If there is an offset for IFD1, parse that
|
||||
if (ifd1 !== 0) {
|
||||
parseIFD(data, ifd1 + TIFFHeaderOffset, byteorder, exif, true);
|
||||
}
|
||||
|
||||
return exif;
|
||||
}
|
||||
|
||||
function parseIFD(data, offset, byteorder, exif, onlyParseOne) {
|
||||
var numentries = data.getUint16(offset, byteorder);
|
||||
for (var i = 0; i < numentries; i++) {
|
||||
parseEntry(data, offset + 2 + 12 * i, byteorder, exif);
|
||||
}
|
||||
|
||||
if (onlyParseOne) {
|
||||
return;
|
||||
}
|
||||
|
||||
var next = data.getUint32(offset + 2 + 12 * numentries, byteorder);
|
||||
if (next !== 0 && next < file.size) {
|
||||
parseIFD(data, next + TIFFHeaderOffset, byteorder, exif);
|
||||
}
|
||||
}
|
||||
|
||||
// size, in bytes, of each TIFF data type
|
||||
var typesize = [
|
||||
0, // Unused
|
||||
1, // BYTE
|
||||
1, // ASCII
|
||||
2, // SHORT
|
||||
4, // LONG
|
||||
8, // RATIONAL
|
||||
1, // SBYTE
|
||||
1, // UNDEFINED
|
||||
2, // SSHORT
|
||||
4, // SLONG
|
||||
8, // SRATIONAL
|
||||
4, // FLOAT
|
||||
8 // DOUBLE
|
||||
];
|
||||
|
||||
// This object maps EXIF tag numbers to their names.
|
||||
// Only list the ones we want to bother parsing and returning.
|
||||
// All others will be ignored.
|
||||
var tagnames = {
|
||||
/*
|
||||
* We don't currently use any of these EXIF tags for anything.
|
||||
*
|
||||
*
|
||||
'256': 'ImageWidth',
|
||||
'257': 'ImageHeight',
|
||||
'40962': 'PixelXDimension',
|
||||
'40963': 'PixelYDimension',
|
||||
'306': 'DateTime',
|
||||
'315': 'Artist',
|
||||
'33432': 'Copyright',
|
||||
'36867': 'DateTimeOriginal',
|
||||
'33434': 'ExposureTime',
|
||||
'33437': 'FNumber',
|
||||
'34850': 'ExposureProgram',
|
||||
'34867': 'ISOSpeed',
|
||||
'37377': 'ShutterSpeedValue',
|
||||
'37378': 'ApertureValue',
|
||||
'37379': 'BrightnessValue',
|
||||
'37380': 'ExposureBiasValue',
|
||||
'37382': 'SubjectDistance',
|
||||
'37383': 'MeteringMode',
|
||||
'37384': 'LightSource',
|
||||
'37385': 'Flash',
|
||||
'37386': 'FocalLength',
|
||||
'41986': 'ExposureMode',
|
||||
'41987': 'WhiteBalance',
|
||||
'41991': 'GainControl',
|
||||
'41992': 'Contrast',
|
||||
'41993': 'Saturation',
|
||||
'41994': 'Sharpness',
|
||||
// These are special tags that we handle internally
|
||||
'34665': 'EXIFIFD', // Offset of EXIF data
|
||||
'34853': 'GPSIFD', // Offset of GPS data
|
||||
*/
|
||||
'274' : 'ORIENTATION',
|
||||
'513': 'THUMBNAIL', // Offset of thumbnail
|
||||
'514': 'THUMBNAILLENGTH' // Length of thumbnail
|
||||
};
|
||||
|
||||
function parseEntry(data, offset, byteorder, exif) {
|
||||
var tag = data.getUint16(offset, byteorder);
|
||||
var tagname = tagnames[tag];
|
||||
|
||||
// If we don't know about this tag type or already processed it, skip it
|
||||
if (!tagname || exif[tagname]) {
|
||||
return;
|
||||
}
|
||||
|
||||
var type = data.getUint16(offset + 2, byteorder);
|
||||
var count = data.getUint32(offset + 4, byteorder);
|
||||
|
||||
var total = count * typesize[type];
|
||||
var valueOffset = total <= 4 ? offset + 8 :
|
||||
data.getUint32(offset + 8, byteorder) + TIFFHeaderOffset;
|
||||
exif[tagname] = parseValue(data, valueOffset, type, count, byteorder);
|
||||
}
|
||||
|
||||
function parseValue(data, offset, type, count, byteorder) {
|
||||
var i;
|
||||
if (type === 2) { // ASCII string
|
||||
var codes = [];
|
||||
for (i = 0; i < count - 1; i++) {
|
||||
codes[i] = data.getUint8(offset + i);
|
||||
}
|
||||
return String.fromCharCode.apply(String, codes);
|
||||
} else {
|
||||
if (count == 1) {
|
||||
return parseOneValue(data, offset, type, byteorder);
|
||||
} else {
|
||||
var values = [];
|
||||
var size = typesize[type];
|
||||
for (i = 0; i < count; i++) {
|
||||
values[i] = parseOneValue(data, offset + size * i, type, byteorder);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseOneValue(data, offset, type, byteorder) {
|
||||
switch (type) {
|
||||
case 1: // BYTE
|
||||
case 7: // UNDEFINED
|
||||
return data.getUint8(offset);
|
||||
case 2: // ASCII
|
||||
// This case is handed in parseValue
|
||||
return null;
|
||||
case 3: // SHORT
|
||||
return data.getUint16(offset, byteorder);
|
||||
case 4: // LONG
|
||||
return data.getUint32(offset, byteorder);
|
||||
case 5: // RATIONAL
|
||||
return data.getUint32(offset, byteorder) /
|
||||
data.getUint32(offset + 4, byteorder);
|
||||
case 6: // SBYTE
|
||||
return data.getInt8(offset);
|
||||
case 8: // SSHORT
|
||||
return data.getInt16(offset, byteorder);
|
||||
case 9: // SLONG
|
||||
return data.getInt32(offset, byteorder);
|
||||
case 10: // SRATIONAL
|
||||
return data.getInt32(offset, byteorder) /
|
||||
data.getInt32(offset + 4, byteorder);
|
||||
case 11: // FLOAT
|
||||
return data.getFloat32(offset, byteorder);
|
||||
case 12: // DOUBLE
|
||||
return data.getFloat64(offset, byteorder);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
879
gaia/shared/js/media/media_frame.js
Executable file
879
gaia/shared/js/media/media_frame.js
Executable file
|
|
@ -0,0 +1,879 @@
|
|||
'use strict';
|
||||
/* global Downsample, VideoPlayer */
|
||||
/*
|
||||
* media_frame.js:
|
||||
*
|
||||
* A MediaFrame displays a photo or a video. The gallery app uses
|
||||
* three side by side to support smooth panning from one item to the
|
||||
* next. The Camera app uses one for image and video preview. The
|
||||
* Gallery app's open activity uses one of these to display the opened
|
||||
* item.
|
||||
*
|
||||
* MediaFrames have different behavior depending on whether they display
|
||||
* images or videos. Photo frames allow the user to zoom and pan on the photo.
|
||||
* Video frames allow the user to play and pause but don't allow zooming.
|
||||
*
|
||||
* When a frame is displaying a video, it handles mouse events.
|
||||
* When displaying a picture, however, it expects the client to handle events
|
||||
* and call the pan() and zoom() methods.
|
||||
*
|
||||
* The pan() method is a little unusual. It "uses" as much of the pan
|
||||
* event as it can, and returns a number indicating how much of the
|
||||
* horizontal motion it did not use. The gallery uses this returned
|
||||
* value for transitioning between frames. If a frame displays a
|
||||
* photo that is not zoomed in at all, then it can't use any of the
|
||||
* pan, and returns the full amount which the gallery app turns into a
|
||||
* panning motion between frames. But if the photo is zoomed in, then
|
||||
* the MediaFrame will just move the photo within itself, if it can, and
|
||||
* return 0.
|
||||
*
|
||||
* MediaFrame uses the #-moz-samplesize media fragment (via the downsample.js
|
||||
* module) to downsample large jpeg images while decoding them when necessary.
|
||||
* You can specify a maximum image decode size (in megapixels) when invoking
|
||||
* the constructor. The MediaFrame code also includes a runtime check for
|
||||
* the amount of RAM available on the device, and may limit the image decode
|
||||
* size on low-memory devices.
|
||||
*/
|
||||
function MediaFrame(container, includeVideo, maxImageSize) {
|
||||
this.clear(); // Set all the properties we'll use to default values
|
||||
|
||||
if (typeof container === 'string') {
|
||||
container = document.getElementById(container);
|
||||
}
|
||||
this.container = container;
|
||||
this.maximumImageSize = maxImageSize || 0;
|
||||
|
||||
// Create an <img> element to display the image
|
||||
this.image = new Image();
|
||||
this.image.className = 'image-view';
|
||||
this.image.style.opacity = 0; // Start off hidden.
|
||||
this.image.onload = function () { // When image loads...
|
||||
this.style.opacity = 1; // make it visible
|
||||
};
|
||||
this.image.style.transformOrigin = 'center center'; // for zooming
|
||||
this.image.style.backgroundImage = 'none';
|
||||
this.image.style.backgroundSize = 'contain';
|
||||
this.image.style.backgroundRepeat = 'no-repeat';
|
||||
this.image.style.backgroundColor = '#222'; // should be overridable somehow
|
||||
this.container.appendChild(this.image);
|
||||
|
||||
// Create the video player element unless we know we'll never need it
|
||||
if (includeVideo !== false) {
|
||||
this.video = new VideoPlayer(container);
|
||||
this.video.hide();
|
||||
}
|
||||
|
||||
// Add a class to the container so we could find it later and use it as
|
||||
// a key in the instance weakmap.
|
||||
container.classList.add('media-frame-container');
|
||||
MediaFrame.instancesToLocalize.set(container, this);
|
||||
}
|
||||
|
||||
// WeakMap with the container nodes as keys and MediaFrame instances as values.
|
||||
MediaFrame.instancesToLocalize = new WeakMap();
|
||||
|
||||
function localize() {
|
||||
// Retrieve MediaFrame instances by searching for container nodes.
|
||||
for (var container of document.querySelectorAll('.media-frame-container')) {
|
||||
var instance = MediaFrame.instancesToLocalize.get(container);
|
||||
if (instance) {
|
||||
instance.localize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMRetranslated', localize);
|
||||
|
||||
MediaFrame.computeMaxImageDecodeSize = function(mem) {
|
||||
if (!mem) {
|
||||
return 0;
|
||||
}
|
||||
else if (mem < 256) { // This is a Tarako-class device ultra low-end device.
|
||||
return 2 * 1024 * 1024; // 2 megapixels
|
||||
}
|
||||
else if (mem < 512) { // This is a low-end 256mb device
|
||||
// Normally we can handle 3mp images on devices like this, but if
|
||||
// this device has a big screen and low memory (like a memory
|
||||
// throttled Flame) then it needs something smaller than 3mp.
|
||||
var screensize = screen.width * window.devicePixelRatio *
|
||||
screen.height * window.devicePixelRatio;
|
||||
if (mem < 325 && screensize > 480 * 800) {
|
||||
return 2.5 * 1024 * 1024; // 2.5mp megapixels for throttled Flame
|
||||
}
|
||||
|
||||
return 3 * 1024 * 1024; // 3 megapixels otherwise
|
||||
}
|
||||
else if (mem < 1024) { // A mid-range 512mb device
|
||||
return 5 * 1024 * 1024; // 5 megapixels
|
||||
}
|
||||
else { // A high-end device with 1 gigabyte or more of memory
|
||||
// Allow 10 megapixels of image decode size per gigabyte of memory
|
||||
return (mem / 1024) * 10 * 1024 * 1024;
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Find out how much memory this device has because we may need to limit image
|
||||
// decode size on low-end devices. Note that navigator.getFeature requires
|
||||
// the "feature-detection" permission (at least for now) so we only run this
|
||||
// code if the client app has that permission.
|
||||
//
|
||||
if (navigator.getFeature) {
|
||||
MediaFrame.pendingPromise = navigator.getFeature('hardware.memory');
|
||||
MediaFrame.pendingPromise.then(
|
||||
function resolve(mem) {
|
||||
delete MediaFrame.pendingPromise;
|
||||
MediaFrame.maxImageDecodeSize = MediaFrame.computeMaxImageDecodeSize(mem);
|
||||
},
|
||||
function reject(err) {
|
||||
// This should never happen!
|
||||
delete MediaFrame.pendingPromise;
|
||||
MediaFrame.maxImageDecodeSize = 0;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
MediaFrame.prototype.displayImage = function displayImage(blob,
|
||||
width,
|
||||
height,
|
||||
preview,
|
||||
rotation,
|
||||
mirrored)
|
||||
{
|
||||
var self = this;
|
||||
|
||||
// If we are still querying the device memory, wait for that query to
|
||||
// complete and then try again.
|
||||
if (MediaFrame.pendingPromise) {
|
||||
MediaFrame.pendingPromise.then(function resolve() {
|
||||
self.displayImage(blob, width, height, preview, rotation, mirrored);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.clear(); // Reset everything
|
||||
|
||||
// Remember what we're displaying. This doesn't really need to be public
|
||||
// but the Gallery app uses it.
|
||||
this.imageblob = blob;
|
||||
|
||||
// Figure out if we are going to downsample the image before displaying it
|
||||
// We expose fullSampleSize as part of the public api only for testing.
|
||||
this.fullSampleSize = computeFullSampleSize(blob, width, height);
|
||||
this.fullsizeWidth = this.fullSampleSize.scale(width);
|
||||
this.fullsizeHeight = this.fullSampleSize.scale(height);
|
||||
|
||||
// Valid small EXIF preview used for setting css background image
|
||||
this.tinyPreviewURL = null;
|
||||
|
||||
// Create a blob URL for the image and combine it with the media fragment.
|
||||
// imageurl is what we'll call revokeObjectURL on. fullImageURL may
|
||||
// have a media fragment appended, so we need to track both properties.
|
||||
this.imageurl = URL.createObjectURL(blob);
|
||||
this.fullImageURL = this.imageurl + this.fullSampleSize;
|
||||
|
||||
// Note: There is a default value for orientation/mirrored since some
|
||||
// images don't have EXIF data to retrieve this information.
|
||||
this.rotation = rotation || 0;
|
||||
this.mirrored = mirrored || false;
|
||||
|
||||
// Keep track of what kind of content we have
|
||||
this.displayingImage = true;
|
||||
|
||||
this.localize();
|
||||
|
||||
function isValidPreview(preview) {
|
||||
// If no preview at all, we can't use it.
|
||||
if (!preview) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we don't know the preview size, we can't use it.
|
||||
if (!preview.width || !preview.height) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If there isn't a preview offset or file, we can't use it
|
||||
if (!preview.start && !preview.filename) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the aspect ratio does not match, we can't use it
|
||||
if (Math.abs(width / height - preview.width / preview.height) > 0.01) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isBigEnoughPreview(preview) {
|
||||
// Check if we have preview before continuing with size check
|
||||
if (!preview) {
|
||||
return false;
|
||||
}
|
||||
// If setMinimumPreviewSize has been called, then a preview is big
|
||||
// enough if it is at least that big.
|
||||
if (self.minimumPreviewWidth && self.minimumPreviewHeight) {
|
||||
return Math.max(preview.width, preview.height) >=
|
||||
Math.max(self.minimumPreviewWidth, self.minimumPreviewHeight) &&
|
||||
Math.min(preview.width, preview.height) >=
|
||||
Math.min(self.minimumPreviewWidth, self.minimumPreviewHeight);
|
||||
}
|
||||
|
||||
// Otherwise a preview is big enough if at least one dimension is >= the
|
||||
// screen size in both portrait and landscape mode.
|
||||
var screenWidth = window.innerWidth * window.devicePixelRatio;
|
||||
var screenHeight = window.innerHeight * window.devicePixelRatio;
|
||||
|
||||
return ((preview.width >= screenWidth ||
|
||||
preview.height >= screenHeight) && // portrait
|
||||
(preview.width >= screenHeight ||
|
||||
preview.height >= screenWidth)); // landscape
|
||||
}
|
||||
|
||||
// To save memory, we want to avoid displaying the image at full size
|
||||
// whenever we can display a smaller preview of it. In general, we only
|
||||
// want to decode the full-size image if the user zooms in on it.
|
||||
// This code determines whether we have a usable preview image (or whether
|
||||
// we can downsample the full-size image) and if so, displays that image
|
||||
var isValid = isValidPreview(preview);
|
||||
var isBigEnough = isBigEnoughPreview(preview);
|
||||
|
||||
if (isValid && isBigEnough) {
|
||||
if (preview.start) {
|
||||
gotPreview(blob.slice(preview.start, preview.end, 'image/jpeg'),
|
||||
preview.width, preview.height);
|
||||
}
|
||||
else {
|
||||
var storage = navigator.getDeviceStorage('pictures');
|
||||
var getreq = storage.get(preview.filename);
|
||||
getreq.onsuccess = function() {
|
||||
gotPreview(getreq.result, preview.width, preview.height);
|
||||
};
|
||||
getreq.onerror = function() {
|
||||
noPreview();
|
||||
};
|
||||
}
|
||||
}
|
||||
else if (isValid) {
|
||||
// If we have valid small EXIF preview use it for css background image
|
||||
// This will help to load large downsampled preview images without flash
|
||||
// See bug 1188286
|
||||
|
||||
// Create a blob URL for the tiny preview
|
||||
this.tinyPreviewURL = URL.createObjectURL(
|
||||
blob.slice(preview.start, preview.end, 'image/jpeg'));
|
||||
|
||||
noPreview(this.tinyPreviewURL);
|
||||
}
|
||||
else {
|
||||
noPreview();
|
||||
}
|
||||
|
||||
// If we've got a usable preview blob from EXIF or an external file,
|
||||
// this is what we do with it.
|
||||
function gotPreview(previewblob, previewWidth, previewHeight) {
|
||||
// Create a blob URL for the preview
|
||||
self.previewurl = URL.createObjectURL(previewblob);
|
||||
// In this case previewImageURL is the same as previewurl. In some other
|
||||
// cases, previewImageURL may have a media fragment appended, so we need
|
||||
// two distinct properties, however.
|
||||
self.previewImageURL = self.previewurl;
|
||||
|
||||
// Remember the preview size
|
||||
self.previewWidth = previewWidth;
|
||||
self.previewHeight = previewHeight;
|
||||
|
||||
// Start off with the preview image displayed
|
||||
self.displayingPreview = true;
|
||||
self._displayImage(self.previewImageURL,
|
||||
self.previewWidth, self.previewHeight);
|
||||
}
|
||||
|
||||
// If we don't have a preview image we can use this is what we do.
|
||||
function noPreview(bgURL) {
|
||||
self.previewurl = null;
|
||||
// Figure out whether we can downsample the fullsize image for
|
||||
// use as a preview
|
||||
var previewSampleSize = computePreviewSampleSize(blob, width, height);
|
||||
|
||||
// If we can create a preview by downsampling...
|
||||
if (previewSampleSize !== Downsample.NONE) {
|
||||
// Combine the full image url with the downsample media fragment
|
||||
// to create a url for the downsampled preview.
|
||||
self.previewImageURL = self.imageurl + previewSampleSize;
|
||||
// Compute the preview size based on the downsample amount.
|
||||
self.previewWidth = previewSampleSize.scale(width);
|
||||
self.previewHeight = previewSampleSize.scale(height);
|
||||
|
||||
// Now start off with the downsampled image displayed
|
||||
self.displayingPreview = true;
|
||||
self._displayImage(self.previewImageURL,
|
||||
self.previewWidth, self.previewHeight, bgURL);
|
||||
}
|
||||
else {
|
||||
// If we can't (or don't need to) downsample the full image then note
|
||||
// that we don't have a preview and display the image at full size.
|
||||
self.previewImageURL = null;
|
||||
self.displayingPreview = false;
|
||||
self._displayImage(self.fullImageURL,
|
||||
self.fullsizeWidth, self.fullsizeHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// If the blob is a JPEG then we can use #-moz-samplesize to downsample
|
||||
// it while decoding. If this is a particularly large image then to avoid
|
||||
// OOMs, we may not want to allow it to ever be decoded at full size
|
||||
function computeFullSampleSize(blob, width, height) {
|
||||
if (blob.type !== 'image/jpeg') {
|
||||
// We're not using #-moz-samplesize at all
|
||||
return Downsample.NONE;
|
||||
}
|
||||
|
||||
// Determine the maximum size we will decode the image at, based on
|
||||
// device memory and the maximum size passed to the constructor.
|
||||
var max = MediaFrame.maxImageDecodeSize || 0;
|
||||
if (self.maximumImageSize && (max === 0 || self.maximumImageSize < max)) {
|
||||
max = self.maximumImageSize;
|
||||
}
|
||||
|
||||
if (!max || width * height <= max) {
|
||||
return Downsample.NONE;
|
||||
}
|
||||
|
||||
return Downsample.areaAtLeast(max / (width * height));
|
||||
}
|
||||
|
||||
function computePreviewSampleSize(blob, width, height) {
|
||||
// If the image is not a JPEG we can't use a samplesize
|
||||
if (blob.type !== 'image/jpeg') {
|
||||
return Downsample.NONE;
|
||||
}
|
||||
|
||||
//
|
||||
// Determine how much we can scale the image down and still have it
|
||||
// big enough to fill the screen in at least one dimension.
|
||||
//
|
||||
// For example, suppose we have a 1600x1200 photo and a 320x480 screen
|
||||
//
|
||||
// portraitScale = Math.min(.2, .4) = 0.2
|
||||
// landscapeScale = Math.min(.3, .266) = 0.266
|
||||
// scale = 0.266
|
||||
//
|
||||
var screenWidth = window.innerWidth * window.devicePixelRatio;
|
||||
var screenHeight = window.innerHeight * window.devicePixelRatio;
|
||||
|
||||
// To display the image in portrait orientation, this is how much we
|
||||
// have to scale it down to ensure that both dimensions fit
|
||||
var portraitScale = Math.min(screenWidth / width, screenHeight / height);
|
||||
|
||||
// To display the image in landscape, this is we need to scale it
|
||||
// this much
|
||||
var landscapeScale = Math.min(screenHeight / width, screenWidth / height);
|
||||
|
||||
// We need an image that is big enough in either orientation
|
||||
var scale = Math.max(portraitScale, landscapeScale);
|
||||
|
||||
// Return the largest samplesize that still produces a big enough preview
|
||||
return Downsample.sizeNoMoreThan(scale);
|
||||
}
|
||||
};
|
||||
|
||||
// An internal method to set the url and size of the img element and to
|
||||
// reposition the image appropriately. We use this when first displaying an
|
||||
// image and when switching from the preview image to the full image and back.
|
||||
// The url argument is used as the img.src. The bg argument, if specified, is
|
||||
// a URL used for the CSS background-image property. This is useful for
|
||||
// switching from a preview image to a full-size image (when the user zooms
|
||||
// in) without a flash while the full-size image is decoded.
|
||||
MediaFrame.prototype._displayImage = function(url, width, height, bg) {
|
||||
// Set the size of the image
|
||||
this.image.style.width = width + 'px';
|
||||
this.image.style.height = height + 'px';
|
||||
|
||||
// If a background was specfied, use it. If this is the preview image URL
|
||||
// and it is already decoded, it gives us something to display while the
|
||||
// full-size image is decoding.
|
||||
if (bg) {
|
||||
this.image.style.backgroundImage = 'url(' + bg + ')';
|
||||
// Wait one frame before loading the main image in this case
|
||||
// to ensure that this background image actually gets displayed
|
||||
requestAnimationFrame(() => { this.image.src = url; });
|
||||
}
|
||||
else {
|
||||
this.image.style.backgroundImage = 'none';
|
||||
// Start loading and decoding the main image
|
||||
this.image.src = url;
|
||||
}
|
||||
|
||||
// Remember the width and height, but swap them for rotated images.
|
||||
if (this.rotation === 0 || this.rotation === 180) {
|
||||
this.itemWidth = width;
|
||||
this.itemHeight = height;
|
||||
} else {
|
||||
this.itemWidth = height;
|
||||
this.itemHeight = width;
|
||||
}
|
||||
|
||||
// The image div has a new size, so we have to change its transform
|
||||
this.computeFit();
|
||||
this.setPosition();
|
||||
|
||||
// Query the position of the image in order to flush the changes
|
||||
// made by setPosition() above. This prevents us from accidentally
|
||||
// animating those changes when the user double taps to zoom.
|
||||
var temp = this.image.clientLeft; // jshint ignore:line
|
||||
};
|
||||
|
||||
// This function adds a label for accessibility to the image frame.
|
||||
// Videos are localized within the video player, so this is only for images.
|
||||
MediaFrame.prototype.localize = function localize() {
|
||||
if (!this.displayingImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
var portrait = this.fullsizeWidth < this.fullsizeHeight;
|
||||
if (this.rotation == 90 || this.rotation == 270) {
|
||||
// If rotated sideways, the width and height are swapped.
|
||||
portrait = !portrait;
|
||||
}
|
||||
|
||||
var timestamp = this.imageblob.lastModifiedDate;
|
||||
|
||||
var orientationL10nId = portrait ? 'orientationPortrait' :
|
||||
'orientationLandscape';
|
||||
|
||||
document.l10n.formatValue(orientationL10nId).then((orientationText) => {
|
||||
if (timestamp) {
|
||||
if (!this.dtf) {
|
||||
// XXX: add localized/timeformatchange event to reset
|
||||
this.dtf = Intl.DateTimeFormat(navigator.languages, {
|
||||
hour12: navigator.mozHour12,
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
var ts = this.dtf.format(new Date(timestamp));
|
||||
|
||||
document.l10n.setAttributes(
|
||||
this.image,
|
||||
'imageDescription',
|
||||
{
|
||||
orientation: orientationText,
|
||||
timestamp: ts
|
||||
}
|
||||
);
|
||||
} else {
|
||||
document.l10n.setAttributes(
|
||||
this.image,
|
||||
'imageDescriptionNoTimestamp',
|
||||
{ orientation: orientationText }
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
MediaFrame.prototype._switchToFullSizeImage = function _switchToFull() {
|
||||
if (!this.displayingImage || !this.displayingPreview) {
|
||||
return;
|
||||
}
|
||||
this.displayingPreview = false;
|
||||
this._displayImage(this.fullImageURL,
|
||||
this.fullsizeWidth, this.fullsizeHeight,
|
||||
this.previewImageURL);
|
||||
};
|
||||
|
||||
MediaFrame.prototype._switchToPreviewImage = function _switchToPreview() {
|
||||
// If we're not displaying an image or already displaying preview
|
||||
// or don't have a preview to display then there is nothing to do.
|
||||
if (!this.displayingImage || this.displayingPreview ||
|
||||
!this.previewImageURL) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.displayingPreview = true;
|
||||
this._displayImage(this.previewImageURL,
|
||||
this.previewWidth, this.previewHeight);
|
||||
};
|
||||
|
||||
// The video and poster image can have different sizes, but they must
|
||||
// have the same aspect ratio and rotation. The aspect ratio is the raw
|
||||
// media width divided by the raw media height before any rotation is applied.
|
||||
MediaFrame.prototype.displayVideo = function displayVideo(videoblob, posterblob,
|
||||
aspectRatio, rotation)
|
||||
{
|
||||
if (!this.video) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clear(); // reset everything
|
||||
|
||||
// Keep track of what kind of content we have
|
||||
this.displayingVideo = true;
|
||||
|
||||
// Remember the blobs
|
||||
this.videoblob = videoblob;
|
||||
this.posterblob = posterblob;
|
||||
|
||||
// Get new URLs for the blobs
|
||||
this.videourl = URL.createObjectURL(videoblob);
|
||||
this.posterurl = URL.createObjectURL(posterblob);
|
||||
|
||||
// Display them in the video element.
|
||||
// The VideoPlayer class takes care of positioning itself, so we
|
||||
// don't have to do anything here with computeFit() or setPosition()
|
||||
this.video.load(this.videourl, this.posterurl, aspectRatio, rotation || 0,
|
||||
videoblob.lastModifiedDate);
|
||||
|
||||
// Show the player controls
|
||||
this.video.show();
|
||||
};
|
||||
|
||||
// Reset the frame state, release any urls and and hide everything
|
||||
MediaFrame.prototype.clear = function clear() {
|
||||
// Reset the saved state
|
||||
this.displayingImage = false;
|
||||
this.displayingPreview = false;
|
||||
this.displayingVideo = false;
|
||||
this.itemWidth = this.itemHeight = null;
|
||||
this.imageblob = null;
|
||||
this.videoblob = null;
|
||||
this.posterblob = null;
|
||||
this.fullSampleSize = null;
|
||||
this.fullImageURL = null;
|
||||
this.previewImageURL = null;
|
||||
this.fullsizeWidth = this.fullsizeHeight = null;
|
||||
this.previewWidth = this.previewHeight = null;
|
||||
this.fit = null;
|
||||
|
||||
if (this.imageurl) {
|
||||
URL.revokeObjectURL(this.imageurl);
|
||||
}
|
||||
this.imageurl = null;
|
||||
|
||||
if (this.previewurl) {
|
||||
URL.revokeObjectURL(this.previewurl);
|
||||
}
|
||||
this.previewurl = null;
|
||||
|
||||
if (this.tinyPreviewURL) {
|
||||
URL.revokeObjectURL(this.tinyPreviewURL);
|
||||
}
|
||||
this.tinyPreviewURL = null;
|
||||
|
||||
// hide the image and release anything it was displaying
|
||||
if (this.image) {
|
||||
this.image.style.opacity = 0;
|
||||
this.image.style.backgroundImage = 'none';
|
||||
this.image.src = '';
|
||||
this.image.removeAttribute('aria-label');
|
||||
}
|
||||
|
||||
// Hide the video player
|
||||
if (this.video) {
|
||||
this.video.reset();
|
||||
this.video.hide();
|
||||
if (this.videourl) {
|
||||
URL.revokeObjectURL(this.videourl);
|
||||
}
|
||||
this.videourl = null;
|
||||
if (this.posterurl) {
|
||||
URL.revokeObjectURL(this.posterurl);
|
||||
}
|
||||
this.posterurl = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Set the item's position based on this.fit
|
||||
// The VideoPlayer object fits itself to its container, and it
|
||||
// can't be zoomed or panned, so we only need to do this for images
|
||||
MediaFrame.prototype.setPosition = function setPosition() {
|
||||
if (!this.fit || !this.displayingImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
var dx = this.fit.left, dy = this.fit.top;
|
||||
|
||||
// We have to adjust the translation to account for the fact that the
|
||||
// scaling is being done around the middle of the image, rather than the
|
||||
// upper-left corner. And we have to make this adjustment differently
|
||||
// for different rotations.
|
||||
switch (this.rotation) {
|
||||
case 0:
|
||||
case 180:
|
||||
dx += (this.fit.width - this.itemWidth) / 2;
|
||||
dy += (this.fit.height - this.itemHeight) / 2;
|
||||
break;
|
||||
case 90:
|
||||
case 270:
|
||||
dx += (this.fit.width - this.itemHeight) / 2;
|
||||
dy += (this.fit.height - this.itemWidth) / 2;
|
||||
break;
|
||||
}
|
||||
|
||||
var sx = this.mirrored ? -this.fit.scale : this.fit.scale;
|
||||
var sy = this.fit.scale;
|
||||
|
||||
var transform =
|
||||
'translate(' + dx + 'px, ' + dy + 'px) ' +
|
||||
'scale(' + sx + ',' + sy + ')' +
|
||||
'rotate(' + this.rotation + 'deg) ';
|
||||
|
||||
this.image.style.transform = transform;
|
||||
};
|
||||
|
||||
MediaFrame.prototype.computeFit = function computeFit() {
|
||||
if (!this.displayingImage) {
|
||||
return;
|
||||
}
|
||||
this.viewportWidth = this.container.offsetWidth;
|
||||
this.viewportHeight = this.container.offsetHeight;
|
||||
|
||||
var scalex = this.viewportWidth / this.itemWidth;
|
||||
var scaley = this.viewportHeight / this.itemHeight;
|
||||
var scale = Math.min(Math.min(scalex, scaley), 1);
|
||||
|
||||
// Set the image size and position
|
||||
var width = Math.floor(this.itemWidth * scale);
|
||||
var height = Math.floor(this.itemHeight * scale);
|
||||
|
||||
this.fit = {
|
||||
width: width,
|
||||
height: height,
|
||||
left: Math.floor((this.viewportWidth - width) / 2),
|
||||
top: Math.floor((this.viewportHeight - height) / 2),
|
||||
scale: scale,
|
||||
baseScale: scale
|
||||
};
|
||||
};
|
||||
|
||||
MediaFrame.prototype.reset = function reset() {
|
||||
// If we're not displaying the preview image, but we have one,
|
||||
// and it is the right size, then switch to it
|
||||
if (this.displayingImage && !this.displayingPreview &&
|
||||
this.previewImageURL) {
|
||||
this._switchToPreviewImage(); // resets image size and position
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, just resize and position the item we're already displaying
|
||||
this.computeFit();
|
||||
this.setPosition();
|
||||
// If frame is resized, the video's size also need to reset.
|
||||
if (this.displayingVideo) {
|
||||
this.video.setPlayerSize();
|
||||
}
|
||||
};
|
||||
|
||||
// We call this from the resize handler when the user rotates the
|
||||
// screen or when going into or out of fullscreen mode. If the user
|
||||
// has not zoomed in, then we just fit the image to the new size (same
|
||||
// as reset). But if the user has zoomed in (and we need to stay
|
||||
// zoomed for the new size) then we adjust the fit properties so that
|
||||
// the pixel that was at the center of the screen before remains at
|
||||
// the center now, or as close as possible
|
||||
MediaFrame.prototype.resize = function resize() {
|
||||
var oldWidth = this.viewportWidth;
|
||||
var oldHeight = this.viewportHeight;
|
||||
var newWidth = this.container.offsetWidth;
|
||||
var newHeight = this.container.offsetHeight;
|
||||
|
||||
var oldfit = this.fit; // The current image fit
|
||||
|
||||
// If this is triggered by a resize event before the frame has computed
|
||||
// its size, then there is nothing we can do yet.
|
||||
if (!oldfit) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute the new fit.
|
||||
// This updates the the viewportWidth, viewportHeight and fit properties
|
||||
this.computeFit();
|
||||
|
||||
// This is how the image would fit at the new screen size
|
||||
var newfit = this.fit;
|
||||
|
||||
// If no zooming has been done (or almost no zooming), then a resize is just
|
||||
// a reset. The same is true if the new fit base scale is greater than the
|
||||
// old scale.
|
||||
// The scale is calculated with division, the value may not be accurate
|
||||
// because of IEEE 754. We use abs difference to do the equality checking.
|
||||
if (Math.abs(oldfit.scale - oldfit.baseScale) < 0.01 ||
|
||||
newfit.baseScale > oldfit.scale) {
|
||||
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, just adjust the old fit as needed and use that so we
|
||||
// retain the zoom factor.
|
||||
oldfit.left += (newWidth - oldWidth) / 2;
|
||||
oldfit.top += (newHeight - oldHeight) / 2;
|
||||
oldfit.baseScale = newfit.baseScale;
|
||||
this.fit = oldfit;
|
||||
|
||||
// Reposition this image without resetting the zoom
|
||||
this.setPosition();
|
||||
};
|
||||
|
||||
// Zoom in by the specified factor, adjusting the pan amount so that
|
||||
// the image pixels at (fixedX, fixedY) remain at that position.
|
||||
// Assume that zoom gestures can't be done in the middle of swipes, so
|
||||
// if we're calling zoom, then the swipe property will be 0.
|
||||
// If time is specified and non-zero, then we set a CSS transition
|
||||
// to animate the zoom.
|
||||
MediaFrame.prototype.zoom = function zoom(scale, fixedX, fixedY, time) {
|
||||
// Ignore zooms if we're not displaying an image
|
||||
if (!this.displayingImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we were displaying the preview switch to the full-size image.
|
||||
if (this.displayingPreview) {
|
||||
this._switchToFullSizeImage();
|
||||
}
|
||||
|
||||
// Never zoom in farther than the native resolution of the image
|
||||
if (this.fit.scale * scale > 1) {
|
||||
scale = 1 / (this.fit.scale);
|
||||
}
|
||||
// And never zoom out to make the image smaller than it would normally be
|
||||
else if (this.fit.scale * scale < this.fit.baseScale) {
|
||||
scale = this.fit.baseScale / this.fit.scale;
|
||||
}
|
||||
|
||||
this.fit.scale = this.fit.scale * scale;
|
||||
|
||||
// Change the size of the photo
|
||||
this.fit.width = Math.floor(this.itemWidth * this.fit.scale);
|
||||
this.fit.height = Math.floor(this.itemHeight * this.fit.scale);
|
||||
|
||||
// fixedX and fixedY are in viewport coordinates.
|
||||
// These are the photo coordinates displayed at that point in the viewport
|
||||
var photoX = fixedX - this.fit.left;
|
||||
var photoY = fixedY - this.fit.top;
|
||||
|
||||
// After zooming, these are the new photo coordinates.
|
||||
// Note we just use the relative scale amount here, not this.fit.scale
|
||||
photoX = Math.floor(photoX * scale);
|
||||
photoY = Math.floor(photoY * scale);
|
||||
|
||||
// To keep that point still, here are the new left and top values we need
|
||||
this.fit.left = fixedX - photoX;
|
||||
this.fit.top = fixedY - photoY;
|
||||
|
||||
// Now make sure we didn't pan too much: If the image fits on the
|
||||
// screen, fixed it. If the image is bigger than the screen, then
|
||||
// make sure we haven't gone past any edges
|
||||
if (this.fit.width <= this.viewportWidth) {
|
||||
this.fit.left = (this.viewportWidth - this.fit.width) / 2;
|
||||
}
|
||||
else {
|
||||
// Don't let the left of the photo be past the left edge of the screen
|
||||
if (this.fit.left > 0) {
|
||||
this.fit.left = 0;
|
||||
}
|
||||
|
||||
// Right of photo shouldn't be to the left of the right edge
|
||||
if (this.fit.left + this.fit.width < this.viewportWidth) {
|
||||
this.fit.left = this.viewportWidth - this.fit.width;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.fit.height <= this.viewportHeight) {
|
||||
this.fit.top = (this.viewportHeight - this.fit.height) / 2;
|
||||
}
|
||||
else {
|
||||
// Don't let the top of the photo be below the top of the screen
|
||||
if (this.fit.top > 0) {
|
||||
this.fit.top = 0;
|
||||
}
|
||||
|
||||
// bottom of photo shouldn't be above the bottom of screen
|
||||
if (this.fit.top + this.fit.height < this.viewportHeight) {
|
||||
this.fit.top = this.viewportHeight - this.fit.height;
|
||||
}
|
||||
}
|
||||
|
||||
// If a time was specified, set up a transition so that the
|
||||
// call to setPosition() below is animated
|
||||
if (time) {
|
||||
// If a time was specfied, animate the transformation
|
||||
var transition = 'transform ' + time + 'ms ease';
|
||||
this.image.style.transition = transition;
|
||||
|
||||
var self = this;
|
||||
this.image.addEventListener('transitionend', function done() {
|
||||
self.image.removeEventListener('transitionend', done);
|
||||
self.image.style.transition = '';
|
||||
});
|
||||
}
|
||||
|
||||
this.setPosition();
|
||||
};
|
||||
|
||||
// If the item being displayed is larger than the continer, pan it by
|
||||
// the specified amounts. Return the "unused" dx amount for the gallery app
|
||||
// to use for sideways swiping
|
||||
MediaFrame.prototype.pan = function(dx, dy) {
|
||||
// We can only pan images, so return the entire dx amount
|
||||
if (!this.displayingImage) {
|
||||
return dx;
|
||||
}
|
||||
|
||||
// Handle panning in the y direction first, since it is easier.
|
||||
// Don't pan in the y direction if we already fit on the screen
|
||||
if (this.fit.height > this.viewportHeight) {
|
||||
this.fit.top += dy;
|
||||
|
||||
// Don't let the top of the photo be below the top of the screen
|
||||
if (this.fit.top > 0) {
|
||||
this.fit.top = 0;
|
||||
}
|
||||
|
||||
// bottom of photo shouldn't be above the bottom of screen
|
||||
if (this.fit.top + this.fit.height < this.viewportHeight) {
|
||||
this.fit.top = this.viewportHeight - this.fit.height;
|
||||
}
|
||||
}
|
||||
|
||||
// Now handle the X dimension. If we've already panned as far as we can
|
||||
// within the image (or if it isn't zoomed in) then return the "extra"
|
||||
// unused dx amount to the caller so that the caller can use them to
|
||||
// shift the frame left or right.
|
||||
var extra = 0;
|
||||
|
||||
if (this.fit.width <= this.viewportWidth) {
|
||||
// In this case, the photo isn't zoomed in, so it is all extra
|
||||
extra = dx;
|
||||
}
|
||||
else {
|
||||
this.fit.left += dx;
|
||||
|
||||
// If this would take the left edge of the photo past the
|
||||
// left edge of the screen, then some of the motion is extra
|
||||
if (this.fit.left > 0) {
|
||||
extra = this.fit.left;
|
||||
this.fit.left = 0;
|
||||
}
|
||||
|
||||
// Or, if this would take the right edge of the photo past the
|
||||
// right edge of the screen, then we've got extra.
|
||||
if (this.fit.left + this.fit.width < this.viewportWidth) {
|
||||
extra = this.fit.left + this.fit.width - this.viewportWidth;
|
||||
this.fit.left = this.viewportWidth - this.fit.width;
|
||||
}
|
||||
}
|
||||
|
||||
this.setPosition();
|
||||
return extra;
|
||||
};
|
||||
|
||||
MediaFrame.prototype.setMinimumPreviewSize = function(w, h) {
|
||||
this.minimumPreviewWidth = w;
|
||||
this.minimumPreviewHeight = h;
|
||||
};
|
||||
125
gaia/shared/js/media/media_utils.js
Executable file
125
gaia/shared/js/media/media_utils.js
Executable file
|
|
@ -0,0 +1,125 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Define a MediaUtils object for helper
|
||||
* function used across Media Apps
|
||||
*
|
||||
*/
|
||||
|
||||
var MediaUtils = {
|
||||
//Format Date
|
||||
formatDate: function(timestamp) {
|
||||
if (!timestamp || timestamp === undefined || isNaN(timestamp)) {
|
||||
return;
|
||||
}
|
||||
return new Date(timestamp).toLocaleString(navigator.languages, {
|
||||
'month': 'numeric',
|
||||
'year': 'numeric',
|
||||
'day': 'numeric'
|
||||
});
|
||||
},
|
||||
|
||||
getLocalizedSizeTokens: function(size) {
|
||||
if (!size || size === undefined || isNaN(size)) {
|
||||
return Promise.resolve('');
|
||||
}
|
||||
var units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
var i = 0;
|
||||
while (size >= 1024 && i < (units.length - 1)) {
|
||||
size /= 1024;
|
||||
++i;
|
||||
}
|
||||
var sizeDecimal = i < 2 ? Math.round(size) : Math.round(size * 10) / 10;
|
||||
|
||||
return document.l10n.formatValue('byteUnit-' + units[i]).then(
|
||||
(unit) => { return { size: sizeDecimal, unit }; }
|
||||
);
|
||||
},
|
||||
|
||||
getLocalizedSize: function(size) {
|
||||
return this.getLocalizedSizeTokens(size).then((args) => {
|
||||
return document.l10n.formatValue('fileSize', args);
|
||||
});
|
||||
},
|
||||
|
||||
//Format Duration
|
||||
formatDuration: function(duration) {
|
||||
function padLeft(num, length) {
|
||||
var r = String(num);
|
||||
while (r.length < length) {
|
||||
r = '0' + r;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
duration = Math.round(duration);
|
||||
var minutes = Math.floor(duration / 60);
|
||||
var seconds = duration % 60;
|
||||
if (minutes < 60) {
|
||||
return padLeft(minutes, 2) + ':' + padLeft(seconds, 2);
|
||||
}
|
||||
var hours = Math.floor(minutes / 60);
|
||||
minutes = Math.floor(minutes % 60);
|
||||
return hours + ':' + padLeft(minutes, 2) + ':' + padLeft(seconds, 2);
|
||||
},
|
||||
|
||||
// Each media app has a static info overlay view, hidden by default.
|
||||
// populateMediaInfo takes a data object and fills in the field of
|
||||
// info overlay view. Format of data object is
|
||||
// {
|
||||
// id: 'value',
|
||||
// ...
|
||||
// }
|
||||
// where each property is the id of an element that is statically defined
|
||||
// in the index.html file.
|
||||
// If data object has properties that do not match an element,
|
||||
// then ignore them.
|
||||
populateMediaInfo: function(data) {
|
||||
for (var id in data) {
|
||||
if (data.hasOwnProperty(id)) {
|
||||
var element = document.getElementById(id);
|
||||
//fill respective value tag to display value passed in data object
|
||||
if (element) {
|
||||
if (typeof data[id] === 'string') {
|
||||
element.setAttribute('data-l10n-id', data[id]);
|
||||
} else if (data[id].hasOwnProperty('raw')) {
|
||||
element.removeAttribute('data-l10n-id');
|
||||
element.textContent = data[id].raw;
|
||||
} else {
|
||||
document.l10n.setAttributes(element,
|
||||
data[id].id, data[id].args);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Assuming that array is sorted according to comparator, return the
|
||||
// array index at which element should be inserted to maintain sort order
|
||||
binarySearch: function(array, element, comparator, from, to) {
|
||||
if (comparator === undefined) {
|
||||
comparator = function(a, b) {
|
||||
return a - b;
|
||||
};
|
||||
}
|
||||
|
||||
if (from === undefined) {
|
||||
return MediaUtils.binarySearch(array, element, comparator, 0,
|
||||
array.length);
|
||||
}
|
||||
|
||||
if (from === to) {
|
||||
return from;
|
||||
}
|
||||
|
||||
var mid = Math.floor((from + to) / 2);
|
||||
|
||||
var result = comparator(element, array[mid]);
|
||||
if (result < 0) {
|
||||
return MediaUtils.binarySearch(array, element, comparator, from, mid);
|
||||
}
|
||||
else {
|
||||
return MediaUtils.binarySearch(array, element, comparator, mid + 1, to);
|
||||
}
|
||||
}
|
||||
};
|
||||
379
gaia/shared/js/media/remote_controls.js
Executable file
379
gaia/shared/js/media/remote_controls.js
Executable file
|
|
@ -0,0 +1,379 @@
|
|||
/* globals BluetoothHelper */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Media Remote Controls
|
||||
*
|
||||
* For media players, like music and video apps should support the capabilities
|
||||
* to receive and interact with the remote media commands from the connected
|
||||
* device/client, it might be a bluetooth device that supports A2DP and AVRCP,
|
||||
* or a remote client, such as the controls in Lock Screen/Utility tray, which
|
||||
* in the system app and uses the IAC.
|
||||
*
|
||||
* Since both AVRCP and IAC use system message to communication, this module
|
||||
* is designed for handling all the remote commands from system message. With
|
||||
* this module, players can just set the command listeners to the corresponding
|
||||
* commands, they might be the common media actions that a player should have,
|
||||
* so a player that uses this module doesn't need to write duplicate logic on
|
||||
* handling the same commands from the different requesters, and doesn't need
|
||||
* to care about where the requests are from.
|
||||
*
|
||||
* More, this module also handle the bluetooth states because to receive the
|
||||
* remote request or send the metadata and play status depends on the bluetooth
|
||||
* availability and the connection of A2DP, the player that uses the module
|
||||
* will automatically receive the requests or send the play information to the
|
||||
* remote client when the bluetooth states change.
|
||||
*
|
||||
*/
|
||||
|
||||
// Commands for AVRCP.
|
||||
var AVRCP = {
|
||||
PLAY_PRESS: 'media-play-button-press',
|
||||
PLAY_RELEASE: 'media-play-button-release',
|
||||
PAUSE_PRESS: 'media-pause-button-press',
|
||||
PAUSE_RELEASE: 'media-pause-button-release',
|
||||
PLAY_PAUSE_PRESS: 'media-play-pause-button-press',
|
||||
PLAY_PAUSE_RELEASE: 'media-play-pause-button-release',
|
||||
STOP_PRESS: 'media-stop-button-press',
|
||||
STOP_RELEASE: 'media-stop-button-release',
|
||||
NEXT_PRESS: 'media-next-track-button-press',
|
||||
NEXT_RELEASE: 'media-next-track-button-release',
|
||||
PREVIOUS_PRESS: 'media-previous-track-button-press',
|
||||
PREVIOUS_RELEASE: 'media-previous-track-button-release',
|
||||
FAST_FORWARD_PRESS: 'media-fast-forward-button-press',
|
||||
FAST_FORWARD_RELEASE: 'media-fast-forward-button-release',
|
||||
REWIND_PRESS: 'media-rewind-button-press',
|
||||
REWIND_RELEASE: 'media-rewind-button-release'
|
||||
};
|
||||
|
||||
// Commands for Inter-App Communication.
|
||||
var IAC = {
|
||||
PLAY_PRESS: 'play',
|
||||
PLAY_PAUSE_PRESS: 'playpause',
|
||||
PAUSE_PRESS: 'pause',
|
||||
STOP_PRESS: 'stop',
|
||||
NEXT_PRESS: 'nexttrack',
|
||||
PREVIOUS_PRESS: 'prevtrack',
|
||||
FAST_FORWARD_PRESS: 'fastforwardstart',
|
||||
FAST_FORWARD_RELEASE: 'fastforwardend',
|
||||
REWIND_PRESS: 'rewindstart',
|
||||
REWIND_RELEASE: 'rewindend',
|
||||
UPDATE_METADATA: 'updatemetadata',
|
||||
UPDATE_PLAYSTATUS: 'updateplaystatus'
|
||||
};
|
||||
|
||||
// Commands for the players to register, events will be fired to the listeners.
|
||||
var REMOTE_CONTROLS = {
|
||||
PLAY: 'play',
|
||||
PLAY_PAUSE: 'playpause',
|
||||
PAUSE: 'pause',
|
||||
STOP: 'stop',
|
||||
NEXT: 'next',
|
||||
PREVIOUS: 'previous',
|
||||
SEEK_PRESS: 'seekpress',
|
||||
SEEK_RELEASE: 'seekrelease',
|
||||
UPDATE_METADATA: 'updatemetadata',
|
||||
UPDATE_PLAYSTATUS: 'updateplaystatus'
|
||||
};
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*/
|
||||
function MediaRemoteControls() {
|
||||
this._bluetoothHelper = null;
|
||||
this._commandListeners = {};
|
||||
this._isSCOConnected = false;
|
||||
|
||||
// Create the empty object for all command listeners.
|
||||
for (var command in REMOTE_CONTROLS) {
|
||||
this._commandListeners[REMOTE_CONTROLS[command]] = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the command listener so that it will be executed when the remote
|
||||
* commands fired.
|
||||
*
|
||||
* @param {String} command
|
||||
* @param {Object} listener
|
||||
*/
|
||||
MediaRemoteControls.prototype.addCommandListener = function(command, listener) {
|
||||
if (this._commandListeners[command]) {
|
||||
this._commandListeners[command].push(listener);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove the command listener.
|
||||
*
|
||||
* @param {String} name
|
||||
* @param {Object} listener
|
||||
*/
|
||||
MediaRemoteControls.prototype.removeCommandListener = function(name, listener) {
|
||||
if (this._commandListeners[name]) {
|
||||
var index = -1;
|
||||
this._commandListeners[name].forEach(function(currListener, i) {
|
||||
if (currListener === listener) {
|
||||
index = i;
|
||||
}
|
||||
});
|
||||
|
||||
if (index !== -1) {
|
||||
this._commandListeners[name].splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup the bluetooth/system message, IAC and wired controls.
|
||||
*/
|
||||
MediaRemoteControls.prototype.start = function(callback) {
|
||||
this._setupBluetooth(callback);
|
||||
this._setupIAC();
|
||||
};
|
||||
|
||||
/*
|
||||
* Setup and configure the bluetooth/system message.
|
||||
*/
|
||||
MediaRemoteControls.prototype._setupBluetooth = function(callback) {
|
||||
var self = this;
|
||||
|
||||
// AVRCP commands use system message.
|
||||
navigator.mozSetMessageHandler(
|
||||
'media-button', this._commandHandler.bind(this)
|
||||
);
|
||||
|
||||
// bluetooth will be handled with BluetoothHelper
|
||||
// to send metadata and play status when those information are changed.
|
||||
this._bluetoothHelper = new BluetoothHelper();
|
||||
this._bluetoothHelper.onrequestmediaplaystatus = playstatusHandler;
|
||||
this._bluetoothHelper.ona2dpstatuschanged = a2dpConnectionHandler;
|
||||
this._bluetoothHelper.onscostatuschanged = scoConnectionHandler;
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
|
||||
function playstatusHandler() {
|
||||
if (self._commandListeners.updateplaystatus.length > 0) {
|
||||
self._commandHandler(REMOTE_CONTROLS.UPDATE_PLAYSTATUS);
|
||||
}
|
||||
}
|
||||
|
||||
// A2DP is connected: update the status to the bluetooth device.
|
||||
// A2DP is disconnected: pause the player like the headphone is unplugged.
|
||||
function a2dpConnectionHandler(event) {
|
||||
var isConnected = event.status;
|
||||
if (isConnected && self._commandListeners.updatemetadata.length > 0) {
|
||||
self._commandHandler(REMOTE_CONTROLS.UPDATE_METADATA);
|
||||
}
|
||||
}
|
||||
|
||||
// Also expose the SCO status with the custom event because the SCO connection
|
||||
// could occupy the audio channel that the media apps using, besides sending
|
||||
// the play/pause commands, these additional status can help the media apps
|
||||
// to decide if they want more interaction with users while SCO is connected.
|
||||
function scoConnectionHandler(event) {
|
||||
self._isSCOConnected = event.status;
|
||||
if (self._isSCOConnected) {
|
||||
self._commandHandler(AVRCP.PAUSE_PRESS);
|
||||
}
|
||||
else {
|
||||
self._commandHandler(AVRCP.PLAY_PRESS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Setup and configure the IAC.
|
||||
*/
|
||||
MediaRemoteControls.prototype._setupIAC = function() {
|
||||
var self = this;
|
||||
|
||||
this._queuedMessages = [];
|
||||
// Set up Inter-App Communications
|
||||
navigator.mozApps.getSelf().onsuccess = function() {
|
||||
var app = this.result;
|
||||
// If IAC doesn't exist, just bail out.
|
||||
if (!app.connect) {
|
||||
this._queuedMessages = null;
|
||||
return;
|
||||
}
|
||||
|
||||
app.connect('mediacomms').then(function(ports) {
|
||||
self._ports = ports;
|
||||
self._ports.forEach(function(port) {
|
||||
port.onmessage = function(event) {
|
||||
self._commandHandler(event.data.command);
|
||||
};
|
||||
|
||||
self._queuedMessages.forEach(function(message) {
|
||||
port.postMessage(message);
|
||||
});
|
||||
});
|
||||
self._queuedMessages = null;
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// Synchronous connection-oriented(SCO) link is the type of radio link used
|
||||
// for voice data of bluetooth, it establishes when the user is in a call via
|
||||
// bluetooth hands-free headset. And since only voice will be routed to the
|
||||
// headset during a call, the media apps can use this api to know the SCO
|
||||
// status, so that the apps can adjust the ui or notify the user to get better
|
||||
// user experience, for more detail, please see:
|
||||
// http://en.wikipedia.org/wiki/Bluetooth_protocols
|
||||
MediaRemoteControls.prototype.getSCOStatus = function(callback) {
|
||||
this._bluetoothHelper.isScoConnected(callback, function onerror() {
|
||||
callback(false);
|
||||
});
|
||||
};
|
||||
|
||||
MediaRemoteControls.prototype._postMessage = function(name, value) {
|
||||
var message = {type: name, data: value};
|
||||
if (!this._ports) {
|
||||
if (this._queuedMessages) {
|
||||
this._queuedMessages.push(message);
|
||||
}
|
||||
} else {
|
||||
this._ports.forEach(function(port) {
|
||||
port.postMessage(message);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert the received messages to the REMOTE_CONTROLS commands, and create
|
||||
* custom event then pass the new event to the command listeners executer.
|
||||
*
|
||||
* @param {String} message
|
||||
*/
|
||||
MediaRemoteControls.prototype._commandHandler = function(message) {
|
||||
var type = 'remote';
|
||||
var option = {};
|
||||
|
||||
switch (message) {
|
||||
case AVRCP.PLAY_PRESS:
|
||||
case IAC.PLAY_PRESS:
|
||||
option.detail = {
|
||||
command: REMOTE_CONTROLS.PLAY,
|
||||
isSCOConnected: this._isSCOConnected
|
||||
};
|
||||
break;
|
||||
case AVRCP.PLAY_PAUSE_PRESS:
|
||||
case IAC.PLAY_PAUSE_PRESS:
|
||||
option.detail = {
|
||||
command: REMOTE_CONTROLS.PLAY_PAUSE,
|
||||
isSCOConnected: this._isSCOConnected
|
||||
};
|
||||
break;
|
||||
case AVRCP.PAUSE_PRESS:
|
||||
case IAC.PAUSE_PRESS:
|
||||
option.detail = {
|
||||
command: REMOTE_CONTROLS.PAUSE,
|
||||
isSCOConnected: this._isSCOConnected
|
||||
};
|
||||
break;
|
||||
case AVRCP.STOP_PRESS:
|
||||
case IAC.STOP_PRESS:
|
||||
option.detail = { command: REMOTE_CONTROLS.STOP };
|
||||
break;
|
||||
case AVRCP.NEXT_PRESS:
|
||||
case IAC.NEXT_PRESS:
|
||||
option.detail = { command: REMOTE_CONTROLS.NEXT };
|
||||
break;
|
||||
case AVRCP.PREVIOUS_PRESS:
|
||||
case IAC.PREVIOUS_PRESS:
|
||||
option.detail = { command: REMOTE_CONTROLS.PREVIOUS };
|
||||
break;
|
||||
case AVRCP.FAST_FORWARD_PRESS:
|
||||
case IAC.FAST_FORWARD_PRESS:
|
||||
option.detail = { command: REMOTE_CONTROLS.SEEK_PRESS, direction: 1 };
|
||||
break;
|
||||
case AVRCP.REWIND_PRESS:
|
||||
case IAC.REWIND_PRESS:
|
||||
option.detail = { command: REMOTE_CONTROLS.SEEK_PRESS, direction: -1 };
|
||||
break;
|
||||
case AVRCP.FAST_FORWARD_RELEASE:
|
||||
case IAC.FAST_FORWARD_RELEASE:
|
||||
case AVRCP.REWIND_RELEASE:
|
||||
case IAC.REWIND_RELEASE:
|
||||
option.detail = { command: REMOTE_CONTROLS.SEEK_RELEASE };
|
||||
break;
|
||||
case IAC.UPDATE_METADATA:
|
||||
case IAC.UPDATE_PLAYSTATUS:
|
||||
case REMOTE_CONTROLS.UPDATE_METADATA:
|
||||
case REMOTE_CONTROLS.UPDATE_PLAYSTATUS:
|
||||
option.detail = { command: message };
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
var event = new CustomEvent(type, option);
|
||||
this._executeCommandListeners(event);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fire the event to the command listeners.
|
||||
*
|
||||
* @param {Object} event
|
||||
*/
|
||||
MediaRemoteControls.prototype._executeCommandListeners = function(event) {
|
||||
if (!event.detail) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._commandListeners[event.detail.command].forEach(function(listener) {
|
||||
listener(event);
|
||||
});
|
||||
};
|
||||
|
||||
MediaRemoteControls.prototype.notifyAppInfo = function(info) {
|
||||
// Send the app info via IAC.
|
||||
this._postMessage('appinfo', info);
|
||||
};
|
||||
|
||||
/**
|
||||
* Send the updated metadata to the remote requester/receiver.
|
||||
*
|
||||
* @param {Object} metadata
|
||||
*/
|
||||
MediaRemoteControls.prototype.notifyMetadataChanged = function(metadata) {
|
||||
// Send the new metadata via bluetooth.
|
||||
this._bluetoothHelper.sendMediaMetaData(metadata,
|
||||
function onsuccess() {
|
||||
// To do nothing here, expect onsuccess normally.
|
||||
},
|
||||
function onerror() {
|
||||
console.log('Sending Metadata error');
|
||||
});
|
||||
|
||||
// Now, send it via IAC.
|
||||
this._postMessage('nowplaying', metadata);
|
||||
};
|
||||
|
||||
/**
|
||||
* Send the updated play status to the remote requester/receiver.
|
||||
*
|
||||
* @param {Object} status
|
||||
*/
|
||||
MediaRemoteControls.prototype.notifyStatusChanged = function(status) {
|
||||
// Don't send the interrupted statuses to the remote client because
|
||||
// they are not the AVRCP statuses.
|
||||
if (status === 'mozinterruptbegin') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the new status via bluetooth.
|
||||
this._bluetoothHelper.sendMediaPlayStatus(status,
|
||||
function onsuccess() {
|
||||
// To do nothing here, expect onsuccess normally.
|
||||
},
|
||||
function onerror() {
|
||||
console.log('Sending Playstatus error');
|
||||
});
|
||||
|
||||
// Now, send it via IAC.
|
||||
this._postMessage('status', status);
|
||||
};
|
||||
617
gaia/shared/js/media/video_player.js
Executable file
617
gaia/shared/js/media/video_player.js
Executable file
|
|
@ -0,0 +1,617 @@
|
|||
'use strict';
|
||||
/* global Format */
|
||||
|
||||
//
|
||||
// Create a <video> element and <div> containing a video player UI and
|
||||
// add them to the specified container. The UI requires a GestureDetector
|
||||
// to be running for the container or one of its ancestors.
|
||||
//
|
||||
// Some devices have only a single hardware video decoder and can only
|
||||
// have one video tag playing anywhere at once. So this class is careful
|
||||
// to only load content into a <video> element when the user really wants
|
||||
// to play it. At other times it displays a poster image for the video.
|
||||
// Initially, it displays the poster image. Pressing play starts the video.
|
||||
// Pausing pauses the video but does not revert to the poster. Finishing the
|
||||
// video reverts to the initial state with the poster image displayed.
|
||||
// If we get a visiblitychange event saying that we've been hidden, we
|
||||
// remember the playback position, pause the video take a temporary
|
||||
// screenshot and display it, and unload the video. If shown again
|
||||
// and if the user clicks play again, we resume the video where we left off.
|
||||
//
|
||||
// WARNING (bug 1135278):
|
||||
//
|
||||
// The VideoPlayer object registers event listeners on the window object
|
||||
// and never unregisters them. This means that VideoPlayer objects will
|
||||
// never be garbage collected since the window will always have a reference
|
||||
// to every one that has been created.
|
||||
//
|
||||
// A conscious decision has been made to not fix this issue because:
|
||||
//
|
||||
// 1) Currently only gallery and camera use the object and they use a
|
||||
// fixed number of objects whose lifetime is the same as that of the
|
||||
// app.
|
||||
// 2) We are moving towards replacing this VideoPlayer object with web
|
||||
// components (bug 1117885, bug 1131321)
|
||||
//
|
||||
function VideoPlayer(container) {
|
||||
if (typeof container === 'string') {
|
||||
container = document.getElementById(container);
|
||||
}
|
||||
|
||||
// Add a class to the container so we could find it later and use it as
|
||||
// a key in the instance weakmap.
|
||||
container.classList.add('video-player-container');
|
||||
VideoPlayer.instancesToLocalize.set(container, this);
|
||||
|
||||
function newelt(parent, type, classes, l10n_id, attributes) {
|
||||
var e = document.createElement(type);
|
||||
if (classes) {
|
||||
e.className = classes;
|
||||
}
|
||||
if (l10n_id) {
|
||||
e.dataset.l10nId = l10n_id;
|
||||
}
|
||||
if (attributes) {
|
||||
for (var attribute in attributes) {
|
||||
e.setAttribute(attribute, attributes[attribute]);
|
||||
}
|
||||
}
|
||||
parent.appendChild(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
// This copies the controls structure of the Video app
|
||||
var poster = newelt(container, 'img', 'videoPoster');
|
||||
var player = newelt(container, 'video', 'videoPlayer', null,
|
||||
{ 'aria-hidden': true }); // Since Video Player controls
|
||||
// are custom, we need to hide
|
||||
// the video element to not
|
||||
// confuse screen reader users.
|
||||
var controls = newelt(container, 'div', 'videoPlayerControls');
|
||||
var playbutton = newelt(controls, 'button', 'videoPlayerPlayButton',
|
||||
'playbackPlay');
|
||||
var footer = newelt(controls, 'div', 'videoPlayerFooter hidden');
|
||||
var pausebutton = newelt(footer, 'button', 'videoPlayerPauseButton',
|
||||
'playbackPause');
|
||||
var slider = newelt(footer, 'div', 'videoPlayerSlider', null,
|
||||
{ 'role': 'slider', 'aria-valuemin': 0 });
|
||||
var elapsedText = newelt(slider, 'span', 'videoPlayerElapsedText');
|
||||
var progress = newelt(slider, 'div', 'videoPlayerProgress');
|
||||
var backgroundBar = newelt(progress, 'div', 'videoPlayerBackgroundBar');
|
||||
var elapsedBar = newelt(progress, 'div', 'videoPlayerElapsedBar');
|
||||
var playHead = newelt(progress, 'div', 'videoPlayerPlayHead');
|
||||
var durationText = newelt(slider, 'span', 'videoPlayerDurationText');
|
||||
// expose fullscreen button, so that client can manipulate it directly
|
||||
var fullscreenButton = newelt(slider, 'button', 'videoPlayerFullscreenButton',
|
||||
'playbackFullscreen');
|
||||
|
||||
this.poster = poster;
|
||||
this.player = player;
|
||||
this.controls = controls;
|
||||
this.playing = false;
|
||||
|
||||
player.preload = 'metadata';
|
||||
player.mozAudioChannelType = 'content';
|
||||
|
||||
var self = this;
|
||||
var controlsHidden = false;
|
||||
var dragging = false;
|
||||
var endedTimer;
|
||||
var videourl; // the url of the video to play
|
||||
var posterurl; // the url of the poster image to display
|
||||
var rotation; // Do we have to rotate the video? Set by load()
|
||||
var aspectRatio; // Width divided by height (after rotation)
|
||||
var videotimestamp;
|
||||
|
||||
// These are the dimensions at which the poster image and video will
|
||||
// be displayed on the screen. We make them as big as possible while still
|
||||
// fitting on the screen, so small videos get scaled up and large videos
|
||||
// get scaled down. These are the sizes that the user actually sees. So
|
||||
// if the video is rotated 90 or 270 degrees, then the img and video
|
||||
// elements will have these dimension swapped.
|
||||
var playbackWidth, playbackHeight;
|
||||
|
||||
var playbackTime;
|
||||
var capturedFrame;
|
||||
|
||||
this.load = function(video, posterimage, aspect, rotate, timestamp) {
|
||||
this.reset();
|
||||
videourl = video;
|
||||
posterurl = posterimage;
|
||||
rotation = rotate || 0;
|
||||
|
||||
// We don't need to know what the actual resolution of the video
|
||||
// or the poster image is. We just need to know its aspect ratio.
|
||||
// Both the video and the poster *must* have the same ratio. Note
|
||||
// that the aspect ratio passed to load() is raw width/height
|
||||
// without considering rotation. But we take the reciprocal here
|
||||
// if necessary to account for rotation.
|
||||
if (rotation === 0 || rotation === 180) {
|
||||
aspectRatio = aspect;
|
||||
}
|
||||
else {
|
||||
aspectRatio = 1 / aspect;
|
||||
}
|
||||
|
||||
videotimestamp = timestamp;
|
||||
|
||||
this.localize();
|
||||
|
||||
this.init();
|
||||
setPlayerSize();
|
||||
};
|
||||
|
||||
this.reset = function() {
|
||||
videourl = null;
|
||||
posterurl = null;
|
||||
rotation = null;
|
||||
aspectRatio = null;
|
||||
videotimestamp = 0;
|
||||
hidePlayer();
|
||||
hidePoster();
|
||||
};
|
||||
|
||||
this.init = function() {
|
||||
playbackTime = 0;
|
||||
hidePlayer();
|
||||
showPoster();
|
||||
this.pause();
|
||||
};
|
||||
|
||||
function hidePlayer() {
|
||||
player.style.display = 'none';
|
||||
player.removeAttribute('src');
|
||||
player.load();
|
||||
self.playerShowing = false;
|
||||
}
|
||||
|
||||
function showPlayer() {
|
||||
if (self.onloading) {
|
||||
self.onloading();
|
||||
}
|
||||
|
||||
player.style.display = 'block';
|
||||
player.src = videourl;
|
||||
self.playerShowing = true;
|
||||
|
||||
// The only place we call showPlayer() is from the play() function.
|
||||
// If play() has to show the player, call it again when we're ready to play.
|
||||
player.oncanplay = function() {
|
||||
player.oncanplay = null;
|
||||
if (playbackTime !== 0) {
|
||||
player.currentTime = playbackTime;
|
||||
}
|
||||
self.play();
|
||||
};
|
||||
}
|
||||
|
||||
function hidePoster() {
|
||||
poster.style.display = 'none';
|
||||
poster.removeAttribute('src');
|
||||
if (capturedFrame) {
|
||||
URL.revokeObjectURL(capturedFrame);
|
||||
capturedFrame = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showPoster() {
|
||||
poster.style.display = 'block';
|
||||
if (capturedFrame) {
|
||||
poster.src = capturedFrame;
|
||||
}
|
||||
else {
|
||||
poster.src = posterurl;
|
||||
}
|
||||
}
|
||||
|
||||
// Call this when the container size changes
|
||||
this.setPlayerSize = setPlayerSize;
|
||||
|
||||
this.pause = function pause() {
|
||||
// Pause video playback
|
||||
if (self.playerShowing) {
|
||||
this.playing = false;
|
||||
player.pause();
|
||||
}
|
||||
|
||||
// Hide the pause button and slider
|
||||
footer.classList.add('hidden');
|
||||
controlsHidden = true;
|
||||
|
||||
// Show the big central play button
|
||||
playbutton.classList.remove('hidden');
|
||||
|
||||
if (this.onpaused) {
|
||||
this.onpaused();
|
||||
}
|
||||
};
|
||||
|
||||
// Set up the playing state
|
||||
this.play = function play() {
|
||||
if (!this.playerShowing) {
|
||||
// If we're displaying the poster image, we have to switch
|
||||
// to the player first. When the player is ready it wil call this
|
||||
// function again.
|
||||
hidePoster();
|
||||
showPlayer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide the play button
|
||||
playbutton.classList.add('hidden');
|
||||
this.playing = true;
|
||||
|
||||
// Start playing the video
|
||||
player.play();
|
||||
|
||||
// Show the controls
|
||||
footer.classList.remove('hidden');
|
||||
controlsHidden = false;
|
||||
|
||||
if (this.onplaying) {
|
||||
this.onplaying();
|
||||
}
|
||||
};
|
||||
|
||||
fullscreenButton.addEventListener('tap', function(e) {
|
||||
if (self.onfullscreentap) {
|
||||
// If the event propagate to controller, videoplayer will hide
|
||||
// the toolbar, so we stopPropagation here.
|
||||
e.stopPropagation();
|
||||
self.onfullscreentap();
|
||||
}
|
||||
});
|
||||
|
||||
// Hook up the play button
|
||||
playbutton.addEventListener('tap', function(e) {
|
||||
// If we're not showing the player or are paused, go to the play state
|
||||
if (!self.playerShowing || player.paused) {
|
||||
self.play();
|
||||
}
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// Hook up the pause button
|
||||
pausebutton.addEventListener('tap', function(e) {
|
||||
self.pause();
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// A click anywhere else on the screen should toggle the footer
|
||||
// But only when the video is playing.
|
||||
container.addEventListener('tap', function(e) {
|
||||
if ((e.target === player || e.target === container) && !player.paused) {
|
||||
footer.classList.toggle('hidden');
|
||||
controlsHidden = !controlsHidden;
|
||||
}
|
||||
});
|
||||
|
||||
// Set the video duration and size when we get metadata
|
||||
player.onloadedmetadata = function() {
|
||||
var formattedTime = formatTime(player.duration);
|
||||
durationText.textContent = formattedTime;
|
||||
slider.setAttribute('aria-valuemax', player.duration);
|
||||
// This sets the aria-label to a localized slider description
|
||||
document.l10n.setAttributes(slider, 'playbackSeekBar',
|
||||
{'duration': formattedTime});
|
||||
// start off in the paused state
|
||||
self.pause();
|
||||
};
|
||||
|
||||
// Also resize the player on a resize event
|
||||
// (when the user rotates the phone)
|
||||
window.addEventListener('resize', function() {
|
||||
setPlayerSize();
|
||||
});
|
||||
|
||||
// If we reach the end of a video, reset to beginning
|
||||
// This isn't always reliable, so we also set a timer in updateTime()
|
||||
player.onended = ended;
|
||||
|
||||
function ended() {
|
||||
if (dragging) {
|
||||
return;
|
||||
}
|
||||
if (endedTimer) {
|
||||
clearTimeout(endedTimer);
|
||||
endedTimer = null;
|
||||
}
|
||||
self.pause();
|
||||
self.init();
|
||||
}
|
||||
|
||||
// Update the slider and elapsed time as the video plays
|
||||
player.ontimeupdate = updateTime;
|
||||
|
||||
// Set the elapsed time and slider position
|
||||
function updateTime() {
|
||||
if (!controlsHidden) {
|
||||
var formattedTime = formatTime(player.currentTime);
|
||||
elapsedText.textContent = formattedTime;
|
||||
slider.setAttribute('aria-valuenow', player.currentTime);
|
||||
slider.setAttribute('aria-valuetext', formattedTime);
|
||||
|
||||
// We can't update a progress bar if we don't know how long
|
||||
// the video is. It is kind of a bug that the <video> element
|
||||
// can't figure this out for ogv videos.
|
||||
if (player.duration === Infinity || player.duration === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
var percent = (player.currentTime / player.duration) * 100 + '%';
|
||||
var startEdge =
|
||||
document.documentElement.dir === 'ltr' ? 'left' : 'right';
|
||||
elapsedBar.style.width = percent;
|
||||
playHead.style[startEdge] = percent;
|
||||
}
|
||||
|
||||
// Since we don't always get reliable 'ended' events, see if
|
||||
// we've reached the end this way.
|
||||
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=783512
|
||||
// If we're within 1 second of the end of the video, register
|
||||
// a timeout a half a second after we'd expect an ended event.
|
||||
if (!endedTimer) {
|
||||
if (!dragging && player.currentTime >= player.duration - 1) {
|
||||
var timeUntilEnd = (player.duration - player.currentTime + 0.5);
|
||||
endedTimer = setTimeout(ended, timeUntilEnd * 1000);
|
||||
}
|
||||
}
|
||||
else if (dragging && player.currentTime < player.duration - 1) {
|
||||
// If there is a timer set and we drag away from the end, cancel the timer
|
||||
clearTimeout(endedTimer);
|
||||
endedTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Pause and unload the video if we're hidden so that other apps
|
||||
// can use the video decoder hardware.
|
||||
window.addEventListener('visibilitychange', visibilityChanged);
|
||||
|
||||
function visibilityChanged() {
|
||||
if (document.hidden) {
|
||||
// If we're just showing the poster image when we're hidden
|
||||
// then we don't have to do anything special
|
||||
if (!self.playerShowing) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.pause();
|
||||
|
||||
// If we're not at the beginning of the video, capture a
|
||||
// temporary poster image to display when we come back
|
||||
if (player.currentTime !== 0) {
|
||||
playbackTime = player.currentTime;
|
||||
captureCurrentFrame(function(blob) {
|
||||
capturedFrame = URL.createObjectURL(blob);
|
||||
hidePlayer();
|
||||
showPoster();
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Even if we don't capture a frame, hide the video
|
||||
hidePlayer();
|
||||
showPoster();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the current frame as an image that we can use as a poster
|
||||
// when the app goes to the background. Note that we capture an
|
||||
// image the size of the screen, not the size of the video. For
|
||||
// really large-format videos this is much more efficient. However,
|
||||
// if the user goes to the background, changes orientation and comes back
|
||||
// it may be that the size we captured is no longer the size to be
|
||||
// displayed on the screen, and the preview image may be upscaled.
|
||||
// Since it is just a preview, this should not be a real problem.
|
||||
function captureCurrentFrame(callback) {
|
||||
// If there is no video, then there is nothing to do here
|
||||
if (!player.src) {
|
||||
return;
|
||||
}
|
||||
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = playbackWidth;
|
||||
canvas.height = playbackHeight;
|
||||
var context = canvas.getContext('2d');
|
||||
context.drawImage(player, 0, 0, canvas.width, canvas.height);
|
||||
canvas.toBlob(callback);
|
||||
}
|
||||
|
||||
// Make the poster img element and video element fit the container
|
||||
function setPlayerSize() {
|
||||
// If we don't have an aspectRatio, then the load() method has not
|
||||
// been called yet (or the reset() method has already been called).
|
||||
// There is nothing we can do in that case (but we might still be called
|
||||
// because of a resize event handler)
|
||||
if (!aspectRatio) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Given the size of the container, and the aspect ratio of the video
|
||||
// (and of its poster image) we need to figure out the largest possible
|
||||
// playback size for the video. (Note that he actual size of the video
|
||||
// and the poster image is does not matter: the img and video elements
|
||||
// will scale them to the playback size for us.)
|
||||
//
|
||||
// If the container aspect ratio is larger than the media aspect ratio
|
||||
// (i.e. if the container is more of a squashed rectangle than the movie
|
||||
// is), then the playback size is constrained by the height of the
|
||||
// container and there will be black bars on the left and right.
|
||||
// Otherwise the video is more squashed than the container, playback
|
||||
// dimensions are constrained by the container width, and there will be
|
||||
// black bars at the top and bottom.
|
||||
var containerWidth = container.clientWidth;
|
||||
var containerHeight = container.clientHeight;
|
||||
if (containerWidth > aspectRatio * containerHeight) {
|
||||
playbackHeight = containerHeight;
|
||||
playbackWidth = Math.round(playbackHeight * aspectRatio);
|
||||
} else {
|
||||
playbackWidth = containerWidth;
|
||||
playbackHeight = Math.round(playbackWidth / aspectRatio);
|
||||
}
|
||||
|
||||
// These are the dimensions of the poster and video elements.
|
||||
// If those elements are going to be rotated before being displayed
|
||||
// the we've got to swap width and height.
|
||||
var elementWidth, elementHeight;
|
||||
if (rotation === 0 || rotation === 180) {
|
||||
elementWidth = playbackWidth;
|
||||
elementHeight = playbackHeight;
|
||||
}
|
||||
else {
|
||||
elementWidth = playbackHeight;
|
||||
elementHeight = playbackWidth;
|
||||
}
|
||||
|
||||
// Now set the size and position of the video player and poster elements.
|
||||
poster.style.position = player.style.position = 'absolute';
|
||||
player.style.width = poster.style.width = elementWidth + 'px';
|
||||
player.style.height = poster.style.height = elementHeight + 'px';
|
||||
poster.style.left = player.style.left =
|
||||
((containerWidth - elementWidth) / 2) + 'px';
|
||||
poster.style.top = player.style.top =
|
||||
((containerHeight - elementHeight) / 2) + 'px';
|
||||
|
||||
// Finally, use a CSS transform to rotate the poster and video as needed
|
||||
poster.style.transformOrigin = player.style.transformOrigin = '50% 50%';
|
||||
poster.style.transform = player.style.transform =
|
||||
'rotate(' + rotation + 'deg)';
|
||||
}
|
||||
|
||||
// handle drags on the time slider
|
||||
slider.addEventListener('pan', function pan(e) {
|
||||
e.stopPropagation();
|
||||
// We can't do anything if we don't know our duration
|
||||
if (player.duration === Infinity) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dragging) { // Do this stuff on the first pan event only
|
||||
dragging = true;
|
||||
}
|
||||
|
||||
var rect = backgroundBar.getBoundingClientRect();
|
||||
var position = (e.detail.position.clientX - rect.left) / rect.width;
|
||||
var pos = Math.min(Math.max(position, 0), 1);
|
||||
// Handle pos so that slider moves correct way
|
||||
// when user drags it for RTL locales
|
||||
if (document.documentElement.dir === 'rtl') {
|
||||
pos = 1 - pos;
|
||||
}
|
||||
player.currentTime = player.duration * pos;
|
||||
updateTime();
|
||||
});
|
||||
|
||||
slider.addEventListener('swipe', function swipe(e) {
|
||||
e.stopPropagation();
|
||||
dragging = false;
|
||||
if (player.currentTime >= player.duration) {
|
||||
self.pause();
|
||||
}
|
||||
});
|
||||
|
||||
slider.addEventListener('keypress', function(e) {
|
||||
// The standard accessible control for sliders is arrow up/down keys.
|
||||
// Our screenreader synthesizes those events on swipe up/down gestures.
|
||||
// Currently, we only allow screen reader users to adjust sliders with a
|
||||
// constant step size (there is no page up/down equivalent). In the case
|
||||
// of videos, we make sure that the maximum amount of steps for the entire
|
||||
// duration is 20, or 2 second increments if the duration is less then 40
|
||||
// seconds.
|
||||
var step = Math.max(player.duration/20, 2);
|
||||
if (e.keyCode == e.DOM_VK_DOWN) {
|
||||
player.currentTime -= step;
|
||||
} else if (e.keyCode == e.DOM_VK_UP) {
|
||||
player.currentTime += step;
|
||||
}
|
||||
});
|
||||
|
||||
function formatTime(time) {
|
||||
time = Math.round(time);
|
||||
var minutes = Math.floor(time / 60);
|
||||
var seconds = time % 60;
|
||||
if (minutes < 60) {
|
||||
return Format.padLeft(minutes, 2, '0') + ':' +
|
||||
Format.padLeft(seconds, 2, '0');
|
||||
} else {
|
||||
var hours = Math.floor(minutes / 60);
|
||||
minutes = Math.round(minutes % 60);
|
||||
return hours + ':' + Format.padLeft(minutes, 2, '0') + ':' +
|
||||
Format.padLeft(seconds, 2, '0');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// pause the video player if user unplugs headphone
|
||||
var acm = navigator.mozAudioChannelManager;
|
||||
if (acm) {
|
||||
acm.addEventListener('headphoneschange', function onheadphoneschange() {
|
||||
if (!acm.headphones && self.playing) {
|
||||
self.pause();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.localize = function() {
|
||||
// XXX: Ideally, we would add the duration too, but that is not
|
||||
// available via fileinfo metadata yet.
|
||||
var portrait = (aspectRatio < 1);
|
||||
|
||||
var orientationL10nId = portrait ? 'orientationPortrait' :
|
||||
'orientationLandscape';
|
||||
document.l10n.formatValue(orientationL10nId).then((orientationText) => {
|
||||
if (videotimestamp) {
|
||||
if (!self.dtf) {
|
||||
// XXX: add localized/timeformatchange event to reset
|
||||
self.dtf = Intl.DateTimeFormat(navigator.languages, {
|
||||
hour12: navigator.mozHour12,
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
var ts = this.dtf.format(new Date(videotimestamp));
|
||||
|
||||
document.l10n.setAttributes(
|
||||
poster,
|
||||
'videoDescription',
|
||||
{
|
||||
orientation: orientationText,
|
||||
timestamp: ts
|
||||
}
|
||||
);
|
||||
} else {
|
||||
document.l10n.setAttributes(
|
||||
poster,
|
||||
'videoDescriptionNoTimestamp',
|
||||
{ orientation: orientationText }
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
VideoPlayer.prototype.hide = function() {
|
||||
// Call reset() to hide the poster and player
|
||||
this.controls.style.display = 'none';
|
||||
};
|
||||
|
||||
VideoPlayer.prototype.show = function() {
|
||||
// Call init() to show the poster
|
||||
this.controls.style.display = 'block';
|
||||
};
|
||||
|
||||
VideoPlayer.instancesToLocalize = new WeakMap();
|
||||
|
||||
document.addEventListener('DOMRetranslated', function() {
|
||||
// Retrieve VideoPlayer instances by searching for container nodes.
|
||||
for (var container of document.querySelectorAll('.video-player-container')) {
|
||||
var instance = VideoPlayer.instancesToLocalize.get(container);
|
||||
if (instance) {
|
||||
instance.localize();
|
||||
}
|
||||
}
|
||||
});
|
||||
107
gaia/shared/js/media/video_stats.js
Executable file
107
gaia/shared/js/media/video_stats.js
Executable file
|
|
@ -0,0 +1,107 @@
|
|||
'use strict';
|
||||
/* global dump, MediaUtils */
|
||||
/* exported VideoStats */
|
||||
|
||||
//
|
||||
// VideoStats records video playback statistics and
|
||||
// dumps them to the native console
|
||||
//
|
||||
var VideoStats = (function() {
|
||||
var startQuality;
|
||||
|
||||
var startTime;
|
||||
|
||||
var endQuality;
|
||||
|
||||
var endTime;
|
||||
|
||||
var video;
|
||||
|
||||
// Start recording stats
|
||||
function start(v) {
|
||||
video = v;
|
||||
if (!video.getVideoPlaybackQuality) {
|
||||
// This API is needed to record stats.
|
||||
// It is enabled in gecko by setting
|
||||
// the media.mediasource.enabled pref to true.
|
||||
return;
|
||||
}
|
||||
|
||||
startQuality = video.getVideoPlaybackQuality();
|
||||
startTime = video.currentTime;
|
||||
endQuality = null;
|
||||
endTime = null;
|
||||
}
|
||||
|
||||
// Stop recording stats
|
||||
function stop() {
|
||||
if (!video) {
|
||||
dump('Warning: no stats have been recorded. ' +
|
||||
'Make sure start() has been called');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!video.getVideoPlaybackQuality) {
|
||||
// This API is needed to record stats.
|
||||
// It is enabled in gecko by setting
|
||||
// the media.mediasource.enabled pref to true.
|
||||
return;
|
||||
}
|
||||
|
||||
endQuality = video.getVideoPlaybackQuality();
|
||||
endTime = video.currentTime;
|
||||
}
|
||||
|
||||
// Dump stats to the native console
|
||||
function print() {
|
||||
if (!video) {
|
||||
dump('Warning: no stats have been recorded. ' +
|
||||
'Make sure start() has been called');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!video.getVideoPlaybackQuality) {
|
||||
// This API is needed to record stats.
|
||||
// It is enabled in gecko by setting
|
||||
// the media.mediasource.enabled pref to true.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!startQuality || !endQuality) {
|
||||
dump('Warning: no stats have been recorded. ' +
|
||||
'Make sure start() and stop() have been called');
|
||||
return;
|
||||
}
|
||||
|
||||
// Playback segment duration in seconds
|
||||
var segmentDuration =
|
||||
(endQuality.creationTime - startQuality.creationTime) / 1000;
|
||||
var startRendered =
|
||||
startQuality.totalVideoFrames - startQuality.droppedVideoFrames;
|
||||
var endRendered =
|
||||
endQuality.totalVideoFrames - endQuality.droppedVideoFrames;
|
||||
var segmentRendered = endRendered - startRendered;
|
||||
var fps = segmentRendered / segmentDuration;
|
||||
|
||||
dump('Video statistics start');
|
||||
dump('Dimensions: ' + video.videoWidth + 'x' + video.videoHeight);
|
||||
dump('Complete duration: ' + MediaUtils.formatDuration(video.duration));
|
||||
dump('Start time: ' + MediaUtils.formatDuration(startTime));
|
||||
dump('End time: ' + MediaUtils.formatDuration(endTime));
|
||||
dump('Total frames: ' + endQuality.totalVideoFrames);
|
||||
dump('Decoded frames: ' +
|
||||
(endQuality.totalVideoFrames - endQuality.corruptedVideoFrames));
|
||||
dump('Corrupted frames: ' + endQuality.corruptedVideoFrames);
|
||||
dump('Rendered frames: ' +
|
||||
(endQuality.totalVideoFrames - endQuality.droppedVideoFrames));
|
||||
dump('Dropped frames: ' + endQuality.droppedVideoFrames);
|
||||
dump('Average rendered FPS: ' + fps.toFixed(2));
|
||||
dump('Video statistics end');
|
||||
}
|
||||
|
||||
return {
|
||||
start: start,
|
||||
stop: stop,
|
||||
dump: print
|
||||
};
|
||||
})();
|
||||
Loading…
Reference in a new issue