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:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

52
gaia/apps/video/js/captions.js Executable file
View file

@ -0,0 +1,52 @@
(function(exports) {
'use strict';
function Captions(player) {
this.player = player;
}
//
// Remove all <track> elements from the <video> element
//
Captions.prototype.remove = function() {
for(var track of this.player.querySelectorAll('track')) {
if (track.src && track.src.startsWith('blob:')) {
URL.revokeObjectURL(track.src);
}
track.remove();
}
};
//
// Given the path of a video file, look in the same directory for a
// file of captions or subtitles for that video. If we find one, set
// it on the <track> element of the player to display the captions.
//
// This ought to be locale sensitive so that we pick the best captions
// for the current locale. But for now, if the video file is named foo.mp4
// then we'll just look for a file in the same directory with name foo.vtt.
//
Captions.prototype.findAndDisplay = function(filepath) {
if (!filepath) { // If it is a blob with no name we can't look for captions
return;
}
// Look for a vtt file for this video
// XXX: ignoring locale for now
var basename = filepath.substring(0, filepath.lastIndexOf('.'));
var captionsFilename = basename + '.vtt';
var sdcard = navigator.getDeviceStorage('sdcard');
if (!sdcard) return; // Captions are optional on desktop.
var request = sdcard.get(captionsFilename);
request.onsuccess = function() {
var track = document.createElement('track');
track.src = URL.createObjectURL(request.result);
track.default = true;
this.player.appendChild(track);
track.track.mode = 'showing';
}.bind(this);
};
exports.Captions = Captions;
}(window));

194
gaia/apps/video/js/db.js Executable file
View file

@ -0,0 +1,194 @@
/* global videodb:true,storageState:true,firstScanEnded:true,
MediaDB,updateDialog,playerShowing,hidePlayer,updateLoadingSpinner,
addToMetadataQueue,thumbnailList,currentVideo,resetCurrentVideo,
hideSelectView,thumbnailClickHandler,startParsingMetadata */
/* exported initDB */
'use strict';
//
// This file is part of the Gaia Video app. It uses the MediaDB libarary
// and the code in metadata.js to ensure that the videos[] array is up to date.
//
function initDB() {
// use excludeFilter to ignore dummy files from camera.
videodb = new MediaDB('videos', null,
{excludeFilter: /DCIM\/\d{3}MZLLA\/\.VID_\d{4}\.3gp$/});
videodb.onupgrading = function(evt) {
// show dialog in upgradestart, when it finished, it will turned to ready.
storageState = MediaDB.UPGRADING;
updateDialog();
};
videodb.onunavailable = function(event) {
storageState = event.detail;
// If player is playing, we need to hide the player which pauses the player
// and unloads the video file.
if (playerShowing) {
hidePlayer(true);
}
updateDialog();
};
// On devices that have internal and external storage, we get this event
// when the user pulls the sdcard out. If we're playing a video when that
// happens, we need to stop or risk a crash.
videodb.oncardremoved = function() {
if (playerShowing) {
hidePlayer(true);
}
};
videodb.onscanend = function() {
// If this was the first scan after startup, then tell
// performance monitors that the app is finally fully loaded and stable.
if (!firstScanEnded) {
firstScanEnded = true;
window.performance.mark('fullyLoaded');
}
updateDialog();
updateLoadingSpinner();
};
videodb.oncreated = function(event) {
event.detail.forEach(videoCreated);
};
videodb.ondeleted = function(event) {
event.detail.forEach(videoDeleted);
};
videodb.onenumerable = function() {
storageState = false;
updateDialog();
enumerateDB();
};
// The video app is relying on the mediadb auto-scanning to scan the db
// when it becomes ready or when the SD card has been mounted or remounted.
// This 'ready' handler is responsible for removing the 'unplug your ...'
// overlay (displayed when USB storage is enabled when the device is connected
// via USB cable), when the USB cable is unplugged.
videodb.onready = function() {
storageState = false;
updateDialog();
// It is possible during startup to queue metadata to parse before
// the MediaDB is ready to be able to parse it. So now that we are
// ready, we should try again and parse metadata for any queued videos.
startParsingMetadata();
};
}
// Remember whether we've already run the enumeration step
var enumerated = false;
// This function runs once when the app starts up. It gets all known videos
// from the MediaDB and handles the appropriately
function enumerateDB() {
if (enumerated) {
return;
}
enumerated = true;
var firstBatchDisplayed = false;
var batch = [];
var batchSize = 4;
videodb.enumerate('date', null, 'prev', function(videoinfo) {
// When we're done with the enumeration flush any batched files
if (videoinfo === null) {
flush();
return;
}
var isVideo = videoinfo.metadata.isVideo;
// If we know this is not a video, ignore it
if (isVideo === false) {
return;
}
// If we don't have metadata for this video yet, add it to the
// metadata queue to get processed. Once the metadata is
// available, it will be passed to addVideo()
if (isVideo === undefined) {
addToMetadataQueue(videoinfo);
return;
}
// If we've parsed the metadata and know this is a video, display it.
if (isVideo === true) {
batch.push(videoinfo);
if (batch.length >= batchSize) {
flush();
batchSize *= 2;
}
}
});
function flush() {
batch.forEach(addVideo);
batch.length = 0;
if (!firstBatchDisplayed) {
firstBatchDisplayed = true;
// Tell performance monitors that "above the fold" content is displayed
// and is ready to interact with.
window.performance.mark('visuallyLoaded');
window.performance.mark('contentInteractive');
// Remove the attribute to trigger gaia header font-fit logic, defer it
// until now to postpone the overhead. Also, do this asynchronously to
// avoid interrupting the enumerating of the db.
setTimeout(function() {
var headers = document.querySelectorAll('gaia-header');
for (var i = 0; i < headers.length; ++i) {
headers[i].removeAttribute('no-font-fit');
}
});
}
}
}
function addVideo(videodata) {
if (!videodata || !videodata.metadata.isVideo) {
return;
}
// Create the thumbnail view for this video and insert it at the right spot
var view = thumbnailList.addItem(videodata);
// thumbnailClickHandler is defined in video.js
view.addTapListener(thumbnailClickHandler);
view.updateTitleText();
// If we just added the first video we need to hide the "no video" overlay
if (thumbnailList.count === 1) {
updateDialog();
}
}
function videoCreated(videoinfo) {
// When MediaDB tells us about a new video, we add it to the queue for
// metadata parsing when we are able to do that.
addToMetadataQueue(videoinfo);
}
function videoDeleted(filename) {
// In tablet landscape mode, we use currentVideo to be the current playing
// video and last played video. When deleting file and the file is playing or
// last played video, we need to change the it to the next, previous or null.
if (currentVideo && filename === currentVideo.name) {
resetCurrentVideo();
}
// And remove its thumbnail from the document
thumbnailList.removeItem(filename);
// If we just deleted the last video we need to display the "no video" overlay
// and go back to thumbnail list view in case we were in thumbnail select view
if (thumbnailList.count === 0) {
updateDialog();
hideSelectView();
}
}

View file

@ -0,0 +1,127 @@
/* exported ForwardRewindController */
/* globals pause */
/**
* This file is used for forward and rewind funtionality of Gaia Video app.
*
* If the user taps the forward or rewind icons,
* the video will jump forward or back by 10 seconds.
*
* When the user presses and holds on the forward or rewind icons,
* the video time will move forward or back at 10 times the regular speed.
*/
'use strict';
var ForwardRewindController = (function() {
var isLongPressing = false;
var intervalId = null;
var forwardButton;
var backwardButton;
var videoToolbar;
var player;
var seekHelper;
function init(video, forward, backward, seeker) {
forwardButton = forward;
backwardButton = backward;
player = video;
seekHelper = seeker;
videoToolbar = forward.parentElement;
videoToolbar.addEventListener('click', handleButtonClick);
videoToolbar.addEventListener('contextmenu', handlePlayerLongPress);
videoToolbar.addEventListener('touchend', stopFastSeeking);
}
function uninit(video, forward, backward) {
videoToolbar.removeEventListener('click', handleButtonClick);
videoToolbar.removeEventListener('contextmenu', handlePlayerLongPress);
videoToolbar.removeEventListener('touchend', stopFastSeeking);
forwardButton = null;
backwardButton = null;
player = null;
}
function handleButtonClick(event) {
if (event.target === forwardButton) {
startFastSeeking(1);
} else if (event.target === backwardButton) {
startFastSeeking(-1);
} else {
return;
}
}
function handlePlayerLongPress(event) {
if (event.target === forwardButton) {
isLongPressing = true;
startFastSeeking(1);
} else if (event.target === backwardButton) {
isLongPressing = true;
startFastSeeking(-1);
} else {
return;
}
}
function startFastSeeking(direction) {
// direction can be 1 or -1, 1 means forward and -1 means rewind.
var offset = direction * 10;
if (isLongPressing) {
intervalId = window.setInterval(function() {
seekVideo(player.currentTime + offset);
}, 1000);
} else {
seekVideo(player.currentTime + offset);
}
}
function stopFastSeeking() {
if (intervalId) {
window.clearInterval(intervalId);
intervalId = null;
isLongPressing = false;
}
}
function seekVideo(seekTime) {
if (seekTime >= player.duration || seekTime < 0) {
if (isLongPressing) {
stopFastSeeking();
}
if (seekTime >= player.duration) {
seekTime = player.duration;
// If the user tries to seek past the end then pause playback
// because otherwise when we get the 'ended' event we'll skip
// to the beginning of the movie. Even though we pause, we'll
// still get the ended event, but the handler sees that we're
// paused and does not skip back to the beginning.
pause();
}
else {
seekTime = 0;
}
}
if (seekHelper) {
seekHelper.seekTo(seekTime);
}
else {
player.fastSeek(seekTime);
}
}
return {
init: init,
uninit: uninit,
stopFastSeeking: stopFastSeeking
};
}());

419
gaia/apps/video/js/metadata.js Executable file
View file

@ -0,0 +1,419 @@
/* global playerShowing,showThrobber,hideThrobber,updateDialog,videodb,
addVideo,getVideoRotation,THUMBNAIL_WIDTH,THUMBNAIL_HEIGHT,MediaDB */
/* exported addToMetadataQueue,stopParsingMetadata */
// This file is part of the Gaia Video app.
//
// It includes functions to obtain metadata for video files. The most
// important parts of this metadata are obtained by loading the video into
// an offscreen <video> element.
//
// The phones we want to run on have only a single hardware h.264 video
// decoder, and gecko is not yet smart enough to share the hardware among
// all the <video> elements that want to use it. So all apps (camera,
// gallery and video) that have permission to use the video hardware must be
// careful to only use it while they are in the foreground and to relinquish
// it when they go to the background.
//
// The video app wants to use <video> elements for metadata parsing and for
// playing videos for the user. It can't do both of these things at the same
// time. The MediaDB metadata parsing architecture assumes that it can,
// however. So the Video app does not pass a metadata parser function to
// MediaDB. MediaDB notifies the app about new video files but does not
// include any metadata with those notifications. Instead, we use the
// queueing and metadata parsing functions in this file to handle metadata
// parsing in an interruptible way.
//
// When we have a new video file that needs metadata, we pass it to
// addToMetadataQueue(). When we need to stop parsing metadata (because we
// want to play a video or because we went to the background) we call
// stopParsingMetadata(). And when we're able to parse metadata again we
// call startParsingMetadata(). When a queued video is processed and its
// metadata is ready, it is saved to MediaDB, and then addVideo() is invoked
// to display the video title and thumbnail.
//
'use strict';
var metadataQueue = [];
var processingQueue = false;
var stopParsingMetadataCallback = null;
// noMoreWorkCallback is fired when the queue is processed or empty.
var noMoreWorkCallback = null;
// This function queues a fileinfo object with no metadata. When the app is
// able it will obtain metadata for the video and pass the updated fileinfo
// to addVideo() so that it becomes visible to the user.
function addToMetadataQueue(fileinfo) {
metadataQueue.push(fileinfo);
startParsingMetadata();
}
// Start or resume metadata parsing, if conditions are right
function startParsingMetadata() {
// If we're already working, return right away
if (processingQueue) {
return;
}
// If there is no work queued, fire noMoreWorkCallback event and return right
// away.
if (metadataQueue.length === 0) {
if (noMoreWorkCallback) {
noMoreWorkCallback();
noMoreWorkCallback = null;
}
return;
}
// Don't parse metadata if we are not the foreground app. When we're
// in the background we need to allow the foreground app to use the
// video hardware. Also, if the video player is showing then we
// can't parse metadata because we're already using the video hardware.
if (document.hidden || playerShowing) {
return;
}
// If our MediaDB object is not ready, it means that we can't read
// video files so can't process metadata yet. We just return in this
// case. The onready event handler in db.js will call this function
// when the db becomes ready.
if (videodb.state !== MediaDB.READY) {
return;
}
// Start processing the queue
processingQueue = true;
showThrobber();
processFirstQueuedItem();
}
// Stop parsing metadata. If a callback is specified, call it when stopped.
function stopParsingMetadata(callback) {
// If we're not processing metadata, just call the callback right away
if (!processingQueue) {
if (callback) {
callback();
}
return;
}
// Otherwise, request a stop.
// This variable acts as a flag to prevent processFirstQueuedItem from
// creating any more metadatas.
stopParsingMetadataCallback = callback || true;
}
// If we've been asked to stop processing, then run the callback, and stop.
// Otherwise, if there is nothing in the queue, do nothing. Otherwise, take
// the first item off the queue, obtain metadata for it, save the metadata
// to the database, and then call addVideo() to display the newly processed
// video to the user. Finally, use setTimeout() to schedule a new call to
// this same function.
//
// XXX: are there any race conditions here?
// Are we guaranteed to always stop and to always call the callback?
//
function processFirstQueuedItem() {
// If the stop flag is set, call the callback and stop
if (stopParsingMetadataCallback) {
var callback = stopParsingMetadataCallback;
stopParsingMetadataCallback = null;
processingQueue = false;
hideThrobber();
if (callback !== true) {
callback(); // Okay, we've stopped.
}
return;
}
// If there is no work queued, update dialog in case there are no playable
// videos; then return right away
if (metadataQueue.length === 0) {
processingQueue = false;
hideThrobber();
updateDialog();
// Check explicitly for noMoreWorkCallBack as function, see bug 972651
if (typeof(noMoreWorkCallback) === 'function') {
noMoreWorkCallback();
}
return;
}
// Otherwise, take a fileinfo object from the front of the queue and
// process it with a <video> tag to obtain size, duration and a poster
// image. This happens asynchronously. When it is done, we call
// processFirstQueuedItem() again to re-check the stop flag and process
// the next item on the queue.
var fileinfo = metadataQueue.shift();
// XXX
// The code below is brittle because it does not have exception handlers.
// If anything goes wrong with this getFile() call or any of the nexted
// functions that get called asynchronously, then we may never advance to
// the next item in the queue and the metadata parsing process will just
// freeze up forever. The fix, I suppose is to add try/catch for each
// async step (or to convert to promises). See Bug 1229651 for a specific
// example of what happens when getFile() throws an exception.
videodb.getFile(fileinfo.name, function(file) {
getMetadata(file, function(metadata) {
// Associate the metadata with this fileinfo object
fileinfo.metadata = metadata;
// Save it to the database
videodb.updateMetadata(fileinfo.name, metadata, function() {
// Create and insert a thumbnail for the video
if (metadata.isVideo) {
videodb.getFileInfo(fileinfo.name, function(dbfileinfo) {
addVideo(dbfileinfo);
});
}
});
// And process the next video in the queue
setTimeout(processFirstQueuedItem);
});
}, function(err) {
console.error('getFile error: ', fileinfo.name, err);
processFirstQueuedItem();
});
}
// Given a video File object, asynchronously pass an object of metadata to
// the specified callback.
function getMetadata(videofile, callback) {
// This is the video element that will get the metadata for us.
// Because of an apparent bug in gecko, it needs to be here rather than
// something that is shared globally.
var offscreenVideo = document.createElement('video');
var metadata = {};
// If its a video type we don't know how to play, ignore it.
if (!offscreenVideo.canPlayType(videofile.type)) {
metadata.isVideo = false;
callback(metadata);
return;
}
// Create a blob: url for the file. It will be revoked in unload().
var url = URL.createObjectURL(videofile);
// Load the video into an offscreen <video> element.
offscreenVideo.preload = 'metadata';
offscreenVideo.src = url;
//
// There seems to have been a gecko regression, and error events are no
// longer being fired when we try to play some zero-length or truncated
// video files. To workaround this, we use a timeout to assume the
// video is unplayable if we nave not gotten any events within a reasonable
// amount of time.
//
// See bug 1198169
//
const METADATA_TIMEOUT = 3000;
var timeoutId = setTimeout(function() {
console.error('No loadedmetadata or error events seen yet.',
'Assuming video file is corrupt:', videofile.name);
offscreenVideo.onerror();
}, METADATA_TIMEOUT);
offscreenVideo.onerror = function(e) {
// Something went wrong. Maybe the file was corrupt?
console.error('Can\'t play video', videofile.name, e);
metadata.isVideo = false;
clearTimeout(timeoutId);
unload();
callback(metadata);
};
offscreenVideo.onloadedmetadata = function() {
// We got metadata, so we don't need the timeout
clearTimeout(timeoutId);
// If videoWidth is 0 then this is likely an audio file (ogg / mp4)
// with an ambiguous filename extension that makes it look like a video.
// This test only works correctly if we're using a new offscreen video
// element each time. If I try to make the element a global, then it
// retains the size of the last video when a non-video is loaded.
if (!offscreenVideo.videoWidth) {
metadata.isVideo = false;
unload();
callback(metadata);
return;
}
// Otherwise it is a video!
metadata.isVideo = true;
// read title from metadata or fallback to filename.
metadata.title = readFromMetadata('title') ||
fileNameToVideoName(videofile.name);
// The video element tells us the video duration and size.
metadata.duration = offscreenVideo.duration;
metadata.width = offscreenVideo.videoWidth;
metadata.height = offscreenVideo.videoHeight;
// If this is a .3gp video file, look for its rotation matrix and
// then create the thumbnail. Otherwise set rotation to 0 and
// create the thumbnail immediately. getVideoRotation is defined
// in shared/js/media/get_video_rotation.js
if (/.3gp$/.test(videofile.name)) {
getVideoRotation(videofile, function(rotation) {
if (typeof rotation === 'number') {
metadata.rotation = rotation;
}
else if (typeof rotation === 'string') {
console.warn('Video rotation:', rotation);
}
createThumbnail();
});
} else {
metadata.rotation = 0;
createThumbnail();
}
};
// The text case of key in metadata is not always lower or upper cases. That
// depends on the creation tools. This function helps to read keys in lower
// cases and returns the value of corresponding key.
function readFromMetadata(lowerCaseKey) {
var tags = offscreenVideo.mozGetMetadata();
for (var key in tags) {
// to lower case and match it.
if (key.toLowerCase() === lowerCaseKey) {
return tags[key];
}
}
// no matched key, return undefined.
return;
}
function createThumbnail() {
// Videos often begin with a black screen, so skip ahead 5 seconds
// or 1/10th of the video, whichever is shorter in the hope that we'll
// get a more interesting thumbnail that way.
// Because of bug 874692, corrupt video files may not be seekable,
// and may not fire an error event, so if we aren't able to seek
// after a certain amount of time, we'll abort and assume that the
// video is invalid.
var t = Math.min(5, offscreenVideo.duration / 10);
offscreenVideo.fastSeek(t);
var failed = false; // Did seeking fail?
var timeout = setTimeout(fail, 10000); // Fail after 10 seconds
offscreenVideo.onerror = fail; // Or if we get an error event
function fail() { // Seeking failure case
console.warn('Seek failed while creating thumbnail for', videofile.name,
'. Ignoring corrupt file.');
failed = true;
clearTimeout(timeout);
offscreenVideo.onerror = null;
metadata.isVideo = false;
unload();
callback(metadata);
}
offscreenVideo.onseeked = function() { // Seeking success case
// Avoid race condition: if we already failed, stop now
if (failed) {
return;
}
clearTimeout(timeout);
captureFrame(offscreenVideo, metadata, function(poster) {
metadata.poster = poster;
unload();
callback(metadata); // We've got all the metadata we need now.
});
};
}
// Free the resources being used by the offscreen video element
function unload() {
URL.revokeObjectURL(offscreenVideo.src);
offscreenVideo.removeAttribute('src');
offscreenVideo.load();
}
// Derive the video title from its filename.
function fileNameToVideoName(filename) {
filename = filename.split('/').pop()
.replace(/\.(webm|ogv|ogg|mp4|3gp)$/, '')
.replace(/[_\.]/g, ' ');
return filename.charAt(0).toUpperCase() + filename.slice(1);
}
}
function captureFrame(player, metadata, callback) {
try {
// Create a new canvas, and set its size
var canvas = document.createElement('canvas');
canvas.width = THUMBNAIL_WIDTH;
canvas.height = THUMBNAIL_HEIGHT;
// Now create the context for the canvas after the size is set.
// The flag is a hint that we're going to be calling toBlob() on
// this canvas and that it is more efficient to use a software canvas
// than it is to do this on the GPU.
var ctx = canvas.getContext('2d', { willReadFrequently: true });
var vw = player.videoWidth, vh = player.videoHeight;
var tw, th;
// If a rotation is specified, rotate the canvas context
switch (metadata.rotation) {
case 90:
ctx.translate(THUMBNAIL_WIDTH, 0);
ctx.rotate(Math.PI / 2);
tw = THUMBNAIL_HEIGHT;
th = THUMBNAIL_WIDTH;
break;
case 180:
ctx.translate(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT);
ctx.rotate(Math.PI);
tw = THUMBNAIL_WIDTH;
th = THUMBNAIL_HEIGHT;
break;
case 270:
ctx.translate(0, THUMBNAIL_HEIGHT);
ctx.rotate(-Math.PI / 2);
tw = THUMBNAIL_HEIGHT;
th = THUMBNAIL_WIDTH;
break;
default:
tw = THUMBNAIL_WIDTH;
th = THUMBNAIL_HEIGHT;
break;
}
// Need to find the minimum ratio between heights and widths,
// so the image (especailly the square thumbnails) would fit
// in the container with right ratio and no extra stretch.
// x and y are right/left and top/bottom margins and where we
// start drawing the image. Since we scale the image, x and y
// will be scaled too. Below gives us x and y actual pixels
// without scaling.
var scale = Math.min(tw / vw, th / vh),
w = scale * vw, h = scale * vh,
x = (tw - w) / 2 / scale, y = (th - h) / 2 / scale;
// Scale the image
ctx.scale(scale, scale);
// Draw the current video frame into the image
ctx.drawImage(player, x, y);
// Convert it to an image file and pass to the callback.
canvas.toBlob(done, 'image/jpeg');
}
catch (e) {
console.error('Exception in captureFrame:', e, e.stack);
done(null);
}
function done(blob) {
canvas.width = 0; // Free canvas memory
ctx = canvas = null; // Prevent leaks, just to be safe
callback(blob); // Return the frame thumbnail to the caller
}
}

74
gaia/apps/video/js/seeker.js Executable file
View file

@ -0,0 +1,74 @@
/* global performance */
(function(exports) {
'use strict';
function Seeker(player) {
this.player = player; // The <video> element we seek for
this.seekStartedAt = null; // When was the last call to fastSeek
this.currentSeekTime = null; // What time are we currently seeking to
this.nextSeekTime = null; // Where to seek when done with this one
this.player.addEventListener('seeked', this); // Find out when seeks end
}
// We may defer seek requests that are made less than this amount of
// time (in milliseconds) from the currently pending seek request
Seeker.SEEK_INTERVAL = 200; // milliseconds
// We may defer seek requests if the seek target is less than this
// distance (in seconds) away from the currently pending seek request
Seeker.SEEK_DISTANCE = 10; // seconds
/*
* Call this method to efficiently seek the video player to the
* specified time.
*
* When the user is dragging the time slider we may request a seek
* every 50ms or less. fastSeek() can take 100ms or more to
* complete. And it only seeks to the nearest keyframe so is
* probably only accurate to +/- 10 seconds, depending on the video.
* We want to avoid sending lots of redundant seek requests to gecko
* since this seems to cause performance problems. But at the same
* time, we don't want to have to wait for gecko to complete a seek
* to one position if the user now wants to seek to some position
* far away.
*
* So, if we're already seeking, and if the last seek request was
* not very long ago and if the target of that seek request was
* close to the target of this seek, then we defer this seek request
* until the current seek request finishes with a 'seeked' event.
* Because it is common to seek to 0, we'll never defer that request.
*/
Seeker.prototype.seekTo = function(time) {
if (this.currentSeekTime === time) { // If we are already seeking to time
return; // then we don't have to do anything.
}
var now = performance.now();
if (this.currentSeekTime === null ||
time === 0 ||
Math.abs(this.currentSeekTime - time) > Seeker.SEEK_DISTANCE ||
now - this.seekStartedAt > Seeker.SEEK_INTERVAL)
{
// In this case we want to actually request a new seek
this.player.fastSeek(time);
this.seekStartedAt = now;
this.currentSeekTime = time;
this.nextSeekTime = null;
}
else {
// In this case we defer the seek until the current seek ends
this.nextSeekTime = time;
}
};
// This is invoked when we get a seeked event from the player
Seeker.prototype.handleEvent = function() {
this.currentSeekTime = null;
this.seekStartedAt = null;
if (this.nextSeekTime !== null) {
this.seekTo(this.nextSeekTime);
}
};
exports.Seeker = Seeker;
}(window));

View file

@ -0,0 +1,156 @@
/* global MediaUtils,Sanitizer,ThumbnailItem,IntlHelper */
/**
* ThumbnailDateGroup is a grouping mechanism supported in video app. It
* groups video data by its year and month, see bug 908380. The grouping
* identity is based on the groupID. Once the groupID is different, we view
* two groups as different groups. ThumbnailDateGroup also sorts the added video
* data descendant. We can just put a item into group and let it sort the list.
*
* The sanitizer.js library is used to escape HTML within the view method.
* It is used when rendering group header, the HTML DOM node of this object.
*
* The HTML Node of this object contains all UI of its children. If we move it,
* all items under this group are also moved.
*
* CONSTRUCTOR:
* To create a group, we need to supply one argument:
* item: the first data object for this group. It will be used for
* rendering the information about this group.
*
* API:
* addItem(): add a new item to this group. ThumbnailDateGroup will create a
* ThumbnailItem for the supplied video data and insert it to UI.
* args:
* item: the video data object.
* return: ThumbnailItem object
* removeItem(): remove a ThumbnailItem from this group. It also removes the
* UI from this group.
* thumbnail: ThumbnailItem object to remove.
* getCount(): returns the total items in this group
*
* Properties:
* htmlNode: the HTML DOM node of this group.
* groupID: the ID of this group.
* thumbnails: all ThumbnailItem objects under this group.
*
* Static API:
* getGroupID: calculate the group id for video object.
* item: the video data object.
* return: an id string.
* compareGroupID
*
*/
'use strict';
function ThumbnailDateGroup(item) {
if (!item) {
throw new Error('item should not be null or undefined.');
}
this.thumbnails = [];
this.groupID = ThumbnailDateGroup.getGroupID(item);
this.date = item.date;
var wrappedHtmlText = ThumbnailDateGroup.view();
// create dummy node for converting to DOM node.
var dummyDiv = document.createElement('DIV');
dummyDiv.innerHTML = Sanitizer.unwrapSafeHTML(wrappedHtmlText);
var domNode = dummyDiv.firstElementChild;
if (!domNode) {
throw new Error('the template is empty');
}
this.htmlNode = domNode;
this.container = domNode.querySelector('.thumbnail-group-container');
this.header = domNode.querySelector('.thumbnail-group-header');
this.localize();
}
// static functions
ThumbnailDateGroup.view = function() {
return Sanitizer.createSafeHTML `<li role="presentation">
<div class="thumbnail-group-header" role="heading"
aria-level="1"></div>
<ul class="thumbnail-group-container" role="listbox"
data-l10n-id="videos-list" aria-multiselectable="true"></ul>
</li>`;
};
ThumbnailDateGroup.getGroupID = function(item) {
// id is group_yyyy-mm. this id will be used as a key
var dateObj = new Date(item.date);
var month = dateObj.getMonth() + 1;
return 'group_' + dateObj.getFullYear() + '-' +
(month < 10 ? '0' + month : month);
};
ThumbnailDateGroup.compareGroupID = function(id1, id2) {
return id1 > id2 ? 1 : (id1 < id2 ? -1 : 0);
};
// class functions
ThumbnailDateGroup.prototype.addItem = function(item) {
if (!item) {
return;
}
var _this = this;
function getInsertPosition(thumbnail) {
if (_this.thumbnails.length === 0 ||
thumbnail.data.date > _this.thumbnails[0].data.date) {
// This video is the first or is newer than the first one.
// This is the most common case for new videos.
return 0;
}
else if (thumbnail.data.date <
_this.thumbnails[_this.thumbnails.length - 1].data.date) {
// This video is older than the last one.
// This is the most common case when we enumerate the database.
return _this.thumbnails.length;
}
else {
// Otherwise we have to search for the right insertion spot
return MediaUtils.binarySearch(_this.thumbnails,
thumbnail,
function(a, b) {
return b.data.date - a.data.date;
});
}
}
var thumbnail = new ThumbnailItem(item);
// calculate the position.
var insertPosition = getInsertPosition(thumbnail);
// put into DOM tree.
this.container.insertBefore(thumbnail.htmlNode,
this.container.children[insertPosition]);
// insert thumbnail view into array.
this.thumbnails.splice(insertPosition, 0, thumbnail);
return thumbnail;
};
ThumbnailDateGroup.prototype.getCount = function() {
return this.thumbnails.length;
};
ThumbnailDateGroup.prototype.removeItem = function(thumbnail) {
var idx = this.thumbnails.indexOf(thumbnail);
if (idx < 0) {
return;
}
this.thumbnails.splice(idx, 1);
this.container.removeChild(thumbnail.htmlNode);
};
ThumbnailDateGroup.prototype.localize = function() {
// Localize the date header for this group
var date = new Date(this.date);
var formatter = IntlHelper.get('date-group');
this.header.textContent = formatter.format(date);
// Then localize the thumbnails in the group.
this.thumbnails.forEach(function(thumbnail) { thumbnail.localize(); });
};

View file

@ -0,0 +1,216 @@
/* global MediaUtils,Sanitizer,VideoUtils */
/**
* ThumbnailItem is view object for a single video data. It renders video with
* ThumbnailItem.view. ThumbnailItem keeps the referenced video data, while
* dispatching tap event, it supplies the video data as the argument.
* ThumbnailItem wraps the dom event to its owned addTapListener. When we need
* to change the behavior to trigger tap, we may just change the implementation
* layer here.
*
* If we need to mark a thumbnail as selected or context, we need to add/remove
* the CSS classes of htmlNode property which is created within constructor.
*
* CONSTRUCTOR:
* To create a ThumbnailItem objet requires the following argument:
* videoData: the video data object from mediadb.
*
* API:
* addTapListener: add tap event listener to this thumbnail item. args:
* listener: the listener callback.
* removeTapListener: remove tap event listener. args:
* listener: the listener callback.
* setWatched: set this thumbnail item as watched or not. args:
* watched: a boolean for watched or not.
* updatePoster: update the poster image. args:
* imageblob: a image blob object.
* updateTitleText: updates the title text according to the size of it.
*
* Properties:
* htmlNode: the HTML DOM node for this thumbnail item. It is rendered at the
* creation of object.
* data: the video data object bound with this thumbnail item.
* posterNode: the HTML DOM node for poster image. This property may be null
* if template doesn't supply one.
* unwatchedNode: the HTML DOM node for unwatched marker. This property may be
* null if template doesn't supply one.
* detailNode: the HTML DOM node for detail information. This property may be
* null if template doesn't supply one.
*
* Global Variables:
* titleMaxLines: the maximum lines of title field. The default value is 2.
*/
'use strict';
function ThumbnailItem(videoData) {
if (!videoData) {
throw new Error('videoData should not be null or undefined.');
}
// the dom element hosting the poster image.
this.posterNode = null;
// the dom element for indicating watched or unwatched.
this.unwatchedNode = null;
// the detail dom element.
this.detailNode = null;
this.titleNode = null;
this.htmlNode = null;
// array for hosting tap listeners.
this.tapListeners = [];
this.data = videoData;
var _this = this;
render();
function convertToDOM(wrappedHTML) {
// convert as DOM node
var dummyDiv = document.createElement('div');
dummyDiv.innerHTML = Sanitizer.unwrapSafeHTML(wrappedHTML);
var domNode = dummyDiv.firstElementChild;
if (!domNode) {
throw new Error('the template does not contain any element');
}
_this.htmlNode = domNode;
// This is the element that displays the image blob
_this.posterNode = domNode.querySelector('.img');
_this.detailNode = domNode.querySelector('.details');
_this.titleNode = domNode.querySelector('.title');
_this.unwatchedNode = domNode.querySelector('.unwatched');
_this.sizeNode = domNode.querySelector('.size-text');
}
// the main function to render everything to UI.
function render() {
// render title
var duration = '';
if (isFinite(_this.data.metadata.duration)) {
duration = MediaUtils.formatDuration(_this.data.metadata.duration);
}
// render type
var videoType = '';
if (_this.data.type) {
var pos = _this.data.type.indexOf('/');
videoType = (pos > -1 ? _this.data.type.slice(pos + 1) : _this.data.type);
}
// populate html text
var wrappedHtmlText = ThumbnailItem.view({
'title': _this.data.metadata.title,
'durationText': duration,
'typeText': videoType
});
convertToDOM(wrappedHtmlText);
// This is the image blob we display for the video.
// If the video is part-way played, we display the bookmark image.
// Otherwise we display the poster image from metadata parsing.
_this.updatePoster(_this.data.metadata.bookmark ||
_this.data.metadata.poster);
_this.setWatched(_this.data.metadata.watched);
if (_this.detailNode) {
_this.detailNode.dataset.title = _this.data.metadata.title;
}
// add click event listeners.
_this.htmlNode.addEventListener('click', dispatchClick);
// Insert the video size with a localized version of "KB" or "MB".
_this.localize();
}
function dispatchClick() {
_this.tapListeners.forEach(function(listener) {
if (listener.handleEvent) {
listener.handleEvent(_this.data);
} else if ((typeof listener) === 'function') {
listener(_this.data);
}
});
}
}
ThumbnailItem.titleMaxLines = 2;
ThumbnailItem.view = function({title, durationText, typeText}) {
return Sanitizer.createSafeHTML `<li class="thumbnail" role="option">
<div class="inner">
<div class="unwatched"></div>
<img class="img" role="presentation"></img>
<div class="details">
<span class="title">${title}</span>
<span class="duration-text after line-break">${durationText}</span>
<span class="size-type-group">
<span class="size-text after"></span>
<span class="type-text after">${typeText}</span>
</span>
</div>
</div>
</li>`;
};
ThumbnailItem.prototype.addTapListener = function(listener) {
if (!listener) {
return;
}
this.tapListeners[this.tapListeners.length] = listener;
};
ThumbnailItem.prototype.removeTapListener = function(listener) {
if (!listener) {
return;
}
var idx = this.tapListeners.indexOf(listener);
if (idx > -1) {
this.tapListeners.splice(idx, 1);
}
};
ThumbnailItem.prototype.setWatched = function(watched) {
if (!this.unwatchedNode) {
return;
}
this.unwatchedNode.hidden = watched;
};
ThumbnailItem.prototype.updatePoster = function(imageblob) {
if (!this.posterNode) {
return;
}
if (this.posterNode.dataset.uri) {
URL.revokeObjectURL(this.posterNode.dataset.uri);
}
if (imageblob) {
this.posterNode.classList.remove('default');
var imageUri = URL.createObjectURL(imageblob);
this.posterNode.dataset.uri = imageUri;
this.posterNode.src = imageUri;
} else {
this.posterNode.classList.add('default');
this.posterNode.dataset.uri = '';
this.posterNode.src = 'style/images/default_thumbnail.png';
}
};
ThumbnailItem.prototype.updateTitleText = function() {
this.titleNode.textContent = VideoUtils.getTruncated(this.data.metadata.title,
{
node: this.titleNode,
maxLine: ThumbnailItem.titleMaxLines
});
};
ThumbnailItem.prototype.localize = function() {
if (this.sizeNode && isFinite(this.data.size)) {
MediaUtils.getLocalizedSizeTokens(this.data.size).then((args) => {
document.l10n.setAttributes(this.sizeNode, 'fileSize', args);
});
}
};

View file

@ -0,0 +1,235 @@
/**
* ThumbnailList is the class reponsible for rendering all video data in to a
* list. It uses GroupClass to group and sort video data as ThumbnailItem.
*
* Constructor:
* groupClass: the grouping class. We only have ThumbnailDataGroup, now.
* container: the HTML DOM object for containing everything.
*
* API:
* addItem: add a video data and render it.
* item: the video data to add.
* removeItem: remove a ThumbnailItem.
* filename: the filename of video data.
* getCount: returns the total video data in this list.
* isPickMode: returns if ThumbnailList is in pick mode.
* setPickMode: set ThumbnailList as pick mode or not.
* isSelectMode: returns if ThumbnailList is in selection mode.
* setSelectMode: set ThumbnailList as selection mode or not.
* reset: clears all the internal data structure.
* updateAllThumbnailTitles: update all thumbnail title text.
* findNextFocused: find the next focused item after the file name.
* filename: the referenced filename.
*
* Properties:
* thumbnailMap: a mapping of filename to ThumbnailItem.
* groupMap: a mapping of filename to thumbnail group.
* itemGroups: an sorted array of thumbnail group.
* count: the total ThumbnailItem in this list.
* groupClass: the grouping class this list used.
* container: the HTML DOM element containing this list.
*/
'use strict';
function ThumbnailList(groupClass, container) {
if (!groupClass || !container) {
throw new Error('group class or container cannot be null or undefined');
}
// the filename to ThumbnailItem map
this.thumbnailMap = {};
// the filename to group
this.groupMap = {};
// the group list
this.itemGroups = [];
this.count = 0;
this.groupClass = groupClass;
this.container = container;
// If the locale changes while we're running, then we need to
// re-localize the dates in the group headers
document.addEventListener('DOMRetranslated', this.localize.bind(this));
}
ThumbnailList.prototype.addItem = function(item) {
if (!item) {
return null;
}
if (this.thumbnailMap[item.name]) {
return this.thumbnailMap[item.name];
}
var _this = this;
function createItemGroup(item, before) {
var group = new _this.groupClass(item);
_this.container.insertBefore(group.htmlNode,
before ? before.htmlNode : null);
return group;
}
function getItemGroup(item) {
var groupID = _this.groupClass.getGroupID(item);
var i;
for (i = 0; i < _this.itemGroups.length; i++) {
if (_this.itemGroups[i].groupID === groupID) {
return _this.itemGroups[i];
} else if (_this.groupClass.compareGroupID(_this.itemGroups[i].groupID,
groupID) < 0) {
// existing ID is less than current groupID, stop searching/
break;
}
}
var createdGroup = createItemGroup(item, _this.itemGroups[i]);
_this.itemGroups.splice(i, 0, createdGroup);
return createdGroup;
}
var group = getItemGroup(item);
var thumbnail = group.addItem(item);
this.groupMap[item.name] = group;
this.thumbnailMap[item.name] = thumbnail;
this.count++;
return thumbnail;
};
ThumbnailList.prototype.removeItem = function(filename) {
if (!this.thumbnailMap[filename]) {
return;
}
var group = this.groupMap[filename];
group.removeItem(this.thumbnailMap[filename]);
if (!group.getCount()) {
this.container.removeChild(group.htmlNode);
this.itemGroups.splice(this.itemGroups.indexOf(group), 1);
}
this.count--;
delete this.groupMap[filename];
delete this.thumbnailMap[filename];
};
ThumbnailList.prototype.reset = function() {
for (var name in this.thumbnailMap) {
this.groupMap[name].removeItem(this.thumbnailMap[name]);
}
this.container.innerHTML = '';
this.thumbnailMap = {};
this.itemGroups = [];
this.groupMap = {};
this.count = 0;
};
ThumbnailList.prototype.isPickMode = function() {
return this.container.classList.contains('pick');
};
ThumbnailList.prototype.setPickMode = function(pick) {
if (pick) {
this.container.classList.add('pick');
} else {
this.container.classList.remove('pick');
}
};
ThumbnailList.prototype.isSelectMode = function() {
return this.container.classList.contains('select');
};
ThumbnailList.prototype.setSelectMode = function(select) {
if (select) {
this.container.classList.add('select');
} else {
this.container.classList.remove('select');
}
};
// Asynchronously call updateTitleText on all of the thumbnails
ThumbnailList.prototype.updateAllThumbnailTitles = function() {
var self = this;
// Cancel any pending title text update. Note that we cancel not by
// deleting the array but by setting its length to zero. Previous
// invocations will see this new length in their closure and will
// terminate. This handles the case where the user is switching
// orientations back and forth. We need to be sure that we don't
// keep adding work in this case.
if (this.pendingTitleTextUpdate && this.pendingTitleTextUpdate.length) {
this.pendingTitleTextUpdate.length = 0;
}
// Get a list of thumbnails that need to be updated
var thumbnailKeys = Object.keys(this.thumbnailMap);
// Remember the list in case we need to cancel it.
this.pendingTitleTextUpdate = thumbnailKeys;
setTimeout(updateNextThumbnailBatch);
function updateNextThumbnailBatch() {
// If we just do one at a time, it takes quite a bit longer
for (var i = 0; i < 10; i++) {
// If there aren't any thumbnails left, we're done
if (thumbnailKeys.length === 0) {
return;
}
// Otherwise, update the next thumbnail
var nextKey = thumbnailKeys.shift();
self.thumbnailMap[nextKey].updateTitleText();
}
// And schedule another call ASAP
setTimeout(updateNextThumbnailBatch);
}
};
/**
* To find the next thumbnail item after the filename. There are multiple cases:
* 1. filename is not existed: returns the first thumbnail.
* 2. filename is at last position, returns the previous thumbnail of filename.
* 3. filename is at other position, returns the next thumbnail of filename.
*
* @param {string} filename the file name of video
*/
ThumbnailList.prototype.findNextThumbnail = function(filename) {
var currentGroup = this.groupMap[filename];
if (!currentGroup) {
// the file is not in this list, return the first one.
return this.itemGroups.length ? this.itemGroups[0].thumbnails[0] : null;
}
var currentThumbnail = this.thumbnailMap[filename];
var idx;
if (currentGroup.thumbnails.length === 1) {
// only the deleted one, back to previous/next group;
idx = this.itemGroups.indexOf(currentGroup);
if (idx === 0) {
// the delete group is the first one, set target video to first item of
// next group.
if (this.itemGroups.length > 1) {
currentThumbnail = this.itemGroups[1].thumbnails[0];
} else {
// no more videos
currentThumbnail = null;
}
} else {
// focus previous video
var thumbnails = this.itemGroups[idx - 1].thumbnails;
currentThumbnail = thumbnails[thumbnails.length - 1];
}
} else {
idx = currentGroup.thumbnails.indexOf(currentThumbnail);
if (idx === 0) {
currentThumbnail = currentGroup.thumbnails[1];
} else {
currentThumbnail = currentGroup.thumbnails[idx - 1];
}
}
return currentThumbnail;
};
ThumbnailList.prototype.localize = function() {
this.itemGroups.forEach(function(group) { group.localize(); });
};

1475
gaia/apps/video/js/video.js Executable file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,85 @@
/*
* Bug 1079519: If a WebRTC call (from the Hello app) is running in the
* background, then the video decoding hardware may be in use, and the video
* app will not be able to play h264 videos that require hardware decoding.
* The <video> element does not have any way to report this condition currently
* and instead just sits and waits its turn to use the hardware.
*
* This modules is a hacky workaround: if we do not get loadedmetadata
* or timeupdate events quickly enough we assume that the video
* hardware is in use and display an error message to the user.
*
* Bug 1093283 has been filed for a real fix to the video element and
* if we get that fix, then we can check for this 'hardware in use'
* condition explicitly and can remove this hack.
*/
(function(exports) {
'use strict';
exports.VideoLoadingChecker = VideoLoadingChecker;
function VideoLoadingChecker(player, overlay, overlayTitle, overlayText) {
this.player = player;
this.overlay = overlay;
this.overlayTitle = overlayTitle;
this.overlayText = overlayText;
}
VideoLoadingChecker.prototype = {
ensureVideoLoads: function vpc_ensureVideoLoads(callback) {
var self = this;
var videoLoadedTimeoutId = setTimeout(showOverlay.bind(null, this), 1500);
this.player.addEventListener('loadedmetadata', handleLoadedMetadata);
function handleLoadedMetadata() {
// Video was loaded, don't display the 'video could not be loaded'
// overlay (or hide it).
clearTimeout(videoLoadedTimeoutId);
videoLoadedTimeoutId = null;
self.player.removeEventListener('loadedmetadata', handleLoadedMetadata);
hideOverlay(self);
if (callback) {
callback();
}
}
},
ensureVideoPlays: function() {
this.playingTimeout = setTimeout(showOverlay.bind(null, this), 1500);
this.playingListener = this.cancelEnsureVideoPlays.bind(this);
// When the video hardware is in use, we still get a playing event
// after calling play(). So to ensure that playback is actually
// happening, we listen for a timeupdate event instead.
this.player.addEventListener('timeupdate', this.playingListener);
},
cancelEnsureVideoPlays: function() {
hideOverlay(this);
if (this.playingTimeout) {
clearTimeout(this.playingTimeout);
this.playingTimeout = null;
}
if (this.playingListener) {
this.player.removeEventListener('timeupdate', this.playingListener);
this.playingListener = null;
}
}
};
function showOverlay(checker) {
checker.overlayTitle.setAttribute('data-l10n-id',
'video-hardware-in-use-title');
checker.overlayText.setAttribute('data-l10n-id',
'video-hardware-in-use-text');
checker.overlay.classList.remove('hidden');
document.body.classList.add('in-use-overlay');
}
function hideOverlay(checker) {
document.body.classList.remove('in-use-overlay');
checker.overlay.classList.add('hidden');
}
}(window));

187
gaia/apps/video/js/video_utils.js Executable file
View file

@ -0,0 +1,187 @@
/* exported VideoUtils */
'use strict';
var VideoUtils = {
/**
* fit the video player to the container. This function aspects the video
* ratio and scales video player to fit as large as possible area of
* container.
*
* @param {DOM} |container| the container for displaying this video.
* @param {DOM} |player| the video element to fit the container.
* @param {int} |videoRotation| the rotation of video content. (optional)
*/
fitContainer: function(container, player, videoRotation) {
var containerWidth = container.clientWidth;
var containerHeight = container.clientHeight;
// Don't do anything if we don't know our size.
// This could happen if we get a resize event before our metadata loads
if (!player.videoWidth || !player.videoHeight) {
return;
}
var width, height; // The size the video will appear, after rotation
var rotation = videoRotation || 0;
switch (rotation) {
case 0:
case 180:
width = player.videoWidth;
height = player.videoHeight;
break;
case 90:
case 270:
width = player.videoHeight;
height = player.videoWidth;
}
var xscale = containerWidth / width;
var yscale = containerHeight / height;
var scale = Math.min(xscale, yscale);
// scale large videos down and scale small videos up
// this might result in lower image quality for small videos
width *= scale;
height *= scale;
var left = ((containerWidth - width) / 2);
var top = ((containerHeight - height) / 2);
/*
* The translate and scale only takes a number + length unit. According to
* MDN's number definition, the scientific notation, 2.1234e-14, is not a
* valid value. To prevent that, we use toFixed(4) to round at the 4th
* digits after decimal point.
*/
var transform;
switch (rotation) {
case 0:
transform = 'translate(' + left.toFixed(4) + 'px,' +
top.toFixed(4) + 'px)';
break;
case 90:
transform =
'translate(' + (left + width).toFixed(4) + 'px,' +
top.toFixed(4) + 'px) ' +
'rotate(90deg)';
break;
case 180:
transform =
'translate(' + (left + width).toFixed(4) + 'px,' +
(top + height).toFixed(4) + 'px) ' +
'rotate(180deg)';
break;
case 270:
transform =
'translate(' + left.toFixed(4) + 'px,' +
(top + height).toFixed(4) + 'px) ' +
'rotate(270deg)';
break;
}
transform += ' scale(' + scale.toFixed(4) + ')';
player.style.transform = transform;
},
/**
* To get the truncated string. It loads oldName into a HTML Element provided
* with options. This element must put into DOM tree and all css are applied.
* We use the height of this element to detect the creation of new line. So,
* the height of this element should not be fixed.
*
* arguments:
* oldName {string} the original string
* options {object} an object with the following attributes:
* node {HTMLElement}: the element contains the text.
* maxLine {int}: optional, maximum line of text. The default
* value is 2.
* ellipsisCharacter {string}: optional, the text of ellipsis
* char. The default value is '...'.
*
*/
getTruncated: function(oldName, options) {
// options
var maxLine = options.maxLine || 2;
var node = options.node;
var ellipsisCharacter = options.ellipsisCharacter || '...';
if (node === null) {
return oldName;
}
var realVisibility = node.style.visibility;
var realWordBreak = node.style.wordBreak;
/*
* Hide UI, because we are manipulating DOM
*/
node.style.visibility = 'hidden';
/*
* Force breaking on boundaries
*/
node.style.wordBreak = 'break-all';
/*
* Get the height of a line of text
*/
node.textContent = '.';
var baseHeight = node.clientHeight;
var computedStyle = window.getComputedStyle(node, null);
var paddingTop =
parseInt(computedStyle.getPropertyValue('padding-top'), 10);
var paddingBottom =
parseInt(computedStyle.getPropertyValue('padding-bottom'), 10);
var padding = paddingTop + paddingBottom;
var lineHeight = baseHeight - padding;
/*
* Determine if the title needs truncating
*/
node.textContent = oldName;
var originalTitleHeight = node.clientHeight - padding;
var linesForOriginalTitle = (originalTitleHeight / lineHeight);
if (linesForOriginalTitle <= maxLine) {
/*
* Title does not need truncating. Restore UI and return original title
*/
node.style.visibility = realVisibility;
node.style.wordBreak = realWordBreak;
return oldName;
}
function titleTooLong(pos) {
node.textContent = oldName.slice(0, pos) + ellipsisCharacter;
var tempTitleHeight = node.clientHeight - padding;
return (tempTitleHeight / lineHeight) > maxLine;
}
var low = 0;
var high = oldName.length - 1;
var mid;
var ellipsisIndex = 0;
do {
mid = Math.floor((low + high) / 2);
if (titleTooLong(mid)) {
high = mid - 1;
}
else {
ellipsisIndex = mid;
low = mid + 1;
}
}
while (low <= high);
var newName = oldName.slice(0, ellipsisIndex) + ellipsisCharacter;
// restore UI
node.style.visibility = realVisibility;
node.style.wordBreak = realWordBreak;
return newName;
}
};

595
gaia/apps/video/js/view.js Executable file
View file

@ -0,0 +1,595 @@
'use strict';
/* global
getStorageIfAvailable,
getVideoRotation,
VideoUtils,
ForwardRewindController,
MimeMapper,
getUnusedFilename,
MediaUtils,
VideoLoadingChecker,
MediaError,
Seeker
*/
/*
* This is a stripped down version of video.js that handles the view activity
* and displays streaming videos specified by url.
*
* Unfortunately, there is a fair bit of code duplication between this
* file and video.js, but we are too close to the v1 deadline to refactor
* the shared code into a single shared file. If the video player UI changes
* those changes will have to be made in both video.js and view.js
*/
(function init() {
var dom = {}; // document elements
var seeker;
var playing = false;
var controlShowing = false;
var controlFadeTimeout = null;
var dragging = false;
var storage; // A device storage object used by the save button
var saved = false; // Did we save it?
var videoRotation = 0;
var activity; // The activity object we're handling
var data; // The data sent by the initiating app
var blob; // The blob we'll be displaying and maybe saving
var url; // URL string representing the blob object
var title; // What we call the video in the titlebar and banner
// touch start id is the identifier of touch event. we only need to process
// events related to this id.
var touchStartID = null;
var sliderRect;
window.pause = pause;
//
// Bug 1088456: when the view activity is launched by the bluetooth transfer
// app (when the user taps on a downloaded file in the notification tray)
// this code starts running while the regular video app is still running as
// the foreground app. Since the video app does not get sent to the
// background in this case, the currently playing video (if there is one) is
// not paused. And so, in the case of videos that require decoder hardware,
// the view activity cannot play the video. For this workaround, we have set
// a localStorage property here. The video.js file should receive an event
// when we do that and will use that as a signal to unload its video. We use
// Date.now() as the value of the property so we get a different value and
// generate an event each time we run.
//
localStorage.setItem('view-activity-wants-to-use-hardware', Date.now());
// Register a handler to receive the Activity object
navigator.mozSetMessageHandler('activity', handleViewActivity);
function handleViewActivity(request) {
activity = request;
data = activity.source.data;
blob = data.blob;
url = data.url;
title = data.title || '';
// Set up the UI, if it is not already set up
if (!dom.player) {
initUI();
}
// Hide save button
dom.save.hidden = true;
// If blob exists, video should be launched by open activity
if (blob) {
// The title we display for this video may be explicitly specified,
// or it might be the specified filename to save to or it might be
// the filename of the blob.
title = data.title || baseName(data.filename) || baseName(blob.name);
url = URL.createObjectURL(blob);
// If the app that initiated this activity wants us to allow the
// user to save this blob as a file, and if device storage is available
// and if there is enough free space, then display a save button.
if (data.allowSave && data.filename && checkFilename()) {
getStorageIfAvailable('videos', blob.size, function(ds) {
dom.save.hidden = false;
// HACK: Cause gaia-header to re-run font-fit logic
// now that the 'save' button is visible.
dom.videoTitle.textContent = dom.videoTitle.textContent;
storage = ds;
});
}
// to hide player because it shows in the wrong rotation.
dom.player.classList.add('hidden');
// video rotation is not parsed, parse it.
getVideoRotation(blob, function(rotation) {
// when error found, fallback to 0
if (typeof rotation === 'string') {
console.error('get video rotation error: ' + rotation);
videoRotation = 0;
} else {
videoRotation = rotation;
}
// show player when player size and rotation are correct.
dom.player.classList.remove('hidden');
// start to play the video that showPlayer also calls fitContainer.
showPlayer(url, title);
});
} else {
// In the url case, we don't need to calculate the rotation.
showPlayer(url, title);
}
}
// Terminate video playback when visibility is changed.
window.addEventListener('visibilitychange',
function onVisibilityChanged() {
if (document.hidden) {
done();
}
});
// We get headphoneschange event when the headphones is plugged or unplugged
var acm = navigator.mozAudioChannelManager;
if (acm) {
acm.addEventListener('headphoneschange', function onheadphoneschange() {
if (!acm.headphones && playing) {
setVideoPlaying(false);
}
});
}
function setVideoPlaying(playing) {
if (playing) {
play();
} else {
pause();
}
}
function initUI() {
// Fullscreen mode and inline activities don't seem to play well together
// so we'll play the video without going into fullscreen mode.
// Get all the elements we use by their id
var ids = ['player', 'player-view',
'player-header', 'play', 'playHead', 'video-container',
'elapsedTime', 'video-title', 'duration-text', 'elapsed-text',
'slider-wrapper', 'spinner-overlay', 'timeSlider',
'save', 'banner', 'message', 'seek-forward',
'seek-backward', 'videoControlBar', 'in-use-overlay',
'in-use-overlay-title', 'in-use-overlay-text'];
ids.forEach(function createElementRef(name) {
dom[toCamelCase(name)] = document.getElementById(name);
});
function toCamelCase(str) {
return str.replace(/\-(.)/g, function replacer(str, p1) {
return p1.toUpperCase();
});
}
seeker = new Seeker(dom.player);
dom.player.mozAudioChannelType = 'content';
// handling the dragging of slider
dom.sliderWrapper.addEventListener('touchstart', handleSliderTouchStart);
dom.sliderWrapper.addEventListener('touchmove', handleSliderTouchMove);
dom.sliderWrapper.addEventListener('touchend', handleSliderTouchEnd);
// Rescale when window size changes. This should get called when
// orientation changes.
window.addEventListener('resize', function() {
if (dom.player.readyState !== dom.player.HAVE_NOTHING) {
VideoUtils.fitContainer(dom.videoContainer, dom.player,
videoRotation || 0);
}
});
dom.player.addEventListener('timeupdate', timeUpdated);
dom.player.addEventListener('seeked', updateSlider);
// showing + hiding the loading spinner
dom.player.addEventListener('waiting', showSpinner);
dom.player.addEventListener('playing', hideSpinner);
dom.player.addEventListener('play', hideSpinner);
dom.player.addEventListener('pause', hideSpinner);
dom.player.addEventListener('ended', playerEnded);
dom.player.addEventListener('canplaythrough', hideSpinner);
// option buttons
dom.play.addEventListener('click', handlePlayButtonClick);
dom.playerHeader.addEventListener('action', done);
dom.save.addEventListener('click', save);
// show/hide controls
dom.videoContainer.addEventListener('click', toggleVideoControls);
// handle slider keypress, emitted by the screen reader
dom.timeSlider.addEventListener('keypress', handleSliderKeypress);
ForwardRewindController.init(dom.player, dom.seekForward, dom.seekBackward,
seeker);
}
function checkFilename() {
var dotIdx = data.filename.lastIndexOf('.');
if (dotIdx > -1) {
var ext = data.filename.substr(dotIdx + 1);
return MimeMapper.guessTypeFromExtension(ext) === blob.type;
} else {
return false;
}
}
function setControlsVisibility(visible) {
dom.playerView.classList[visible ? 'remove' : 'add'](
'video-controls-hidden');
controlShowing = visible;
// Set the proper accessibility label for the video container based on
// controls showing.
dom.videoContainer.setAttribute('data-l10n-id', controlShowing ?
'hide-controls-button' : 'show-controls-button');
if (visible) {
// update elapsed time and slider while showing.
updateSlider();
}
}
function handlePlayButtonClick() {
setVideoPlaying(dom.player.paused);
}
function toggleVideoControls(e) {
// When we change the visibility state of video controls, we need to check
// the timeout of auto hiding.
if (controlFadeTimeout) {
clearTimeout(controlFadeTimeout);
controlFadeTimeout = null;
}
// We cannot change the visibility state of video contorls when we are in
// picking mode.
e.cancelBubble = !controlShowing;
setControlsVisibility(!controlShowing);
}
function handleSliderTouchStart(event) {
// If we have a touch start event, we don't need others.
if (null != touchStartID) {
return;
}
touchStartID = event.changedTouches[0].identifier;
dragging = true;
// calculate the slider wrapper size for slider dragging.
sliderRect = dom.sliderWrapper.getBoundingClientRect();
// We can't do anything if we don't know our duration
if (dom.player.duration === Infinity) {
return;
}
handleSliderTouchMove(event);
}
function done() {
pause();
// Release any video resources
dom.player.removeAttribute('src');
dom.player.load();
// End the activity
activity.postResult({saved: saved});
// Undo the bug 1088456 workaround hack.
localStorage.removeItem('view-activity-wants-to-use-hardware');
}
function save() {
// Hide the menu that holds the save button: we can only save once
dom.save.hidden = true;
// XXX work around bug 870619
dom.videoTitle.textContent = dom.videoTitle.textContent;
getUnusedFilename(storage, data.filename, function(filename) {
var savereq = storage.addNamed(blob, filename);
savereq.onsuccess = function() {
// Remember that it has been saved so we can pass this back
// to the invoking app
saved = filename;
// And tell the user
showBanner({
id: 'saved',
args: { title: title }
});
};
savereq.onerror = function(e) {
// XXX we don't report this to the user because it is hard to
// localize.
console.error('Error saving', filename, e);
};
});
}
// show video player
function showPlayer(url, title) {
function handleLoadedMetadata() {
var formattedDuration = MediaUtils.formatDuration(dom.player.duration);
dom.durationText.textContent = formattedDuration;
timeUpdated();
setButtonPaused(false);
VideoUtils.fitContainer(dom.videoContainer, dom.player,
videoRotation || 0);
dom.player.currentTime = 0;
// Show the controls briefly then fade out
controlFadeTimeout = setTimeout(function() {
setControlsVisibility(false);
}, 2000);
document.l10n.setAttributes(dom.timeSlider, 'seek-bar',
{ duration: formattedDuration });
dom.timeSlider.setAttribute('aria-valuemin', 0);
dom.timeSlider.setAttribute('aria-valuemax', dom.player.duration);
dom.timeSlider.setAttribute('aria-valuenow', dom.player.currentTime);
dom.timeSlider.setAttribute('aria-valuetext',
MediaUtils.formatDuration(dom.player.currentTime));
play();
}
dom.videoTitle.textContent = title || '';
setControlsVisibility(true);
var loadingChecker =
new VideoLoadingChecker(dom.player, dom.inUseOverlay,
dom.inUseOverlayTitle,
dom.inUseOverlayText);
loadingChecker.ensureVideoLoads(handleLoadedMetadata);
dom.player.src = url;
dom.player.onloadeddata = function(evt) { URL.revokeObjectURL(url); };
dom.player.onerror = function(evt) {
var errorid = '';
switch (evt.target.error.code) {
case MediaError.MEDIA_ERR_ABORTED:
// This aborted error should be triggered by the user
// so we don't have to show any error messages
return;
case MediaError.MEDIA_ERR_NETWORK:
errorid = 'error-network';
break;
case MediaError.MEDIA_ERR_DECODE:
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
// If users tap some video link in an offline page
// the error code will be MEDIA_ERR_SRC_NOT_SUPPORTED
// we also prompt the unsupported error message for it
errorid = 'error-unsupported';
break;
// Is it possible to be unknown errors?
default:
errorid = 'error-unknown';
break;
}
handleError(errorid);
};
}
function handleError(l10nId) {
document.l10n.formatValue(l10nId).then((msg) => {
alert(msg);
done();
});
}
function play() {
// Switch the button icon
setButtonPaused(false);
// Start playing
dom.player.play();
playing = true;
}
function pause() {
// Switch the button icon
setButtonPaused(true);
// Stop playing the video
dom.player.pause();
playing = false;
}
// Update the progress bar and play head as the video plays
function timeUpdated() {
if (controlShowing) {
// 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 (dom.player.duration === Infinity || dom.player.duration === 0) {
return;
}
updateSlider();
}
dom.timeSlider.setAttribute('aria-valuenow', dom.player.currentTime);
dom.timeSlider.setAttribute('aria-valuetext',
MediaUtils.formatDuration(dom.player.currentTime));
}
function playerEnded() {
if (dragging) {
return;
}
// If we are still playing when this 'ended' event arrives, then the
// user played the video all the way to the end, and we skip to the
// beginning and pause so it is easy for the user to restart. If we
// reach the end because the user fast forwarded or dragged the slider
// to the end, then we will have paused the video before we get this
// event and in that case we will remain paused at the end of the video.
if (playing) {
dom.player.currentTime = 0;
pause();
}
}
function setButtonPaused(paused) {
dom.play.classList.toggle('paused', paused);
dom.play.setAttribute('data-l10n-id',
paused ? 'play-button' : 'pause-button');
}
function movePlayHead(percent) {
if (document.documentElement.dir === 'ltr') {
dom.playHead.style.left = percent;
}
else {
dom.playHead.style.right = percent;
}
}
function updateSlider() {
// We update the slider when we get a 'seeked' event.
// Don't do updates while we're seeking because the position we fastSeek()
// to probably isn't exactly where we requested and we don't want jerky
// updates
if (dom.player.seeking) {
return;
}
var percent = (dom.player.currentTime / dom.player.duration) * 100;
if (isNaN(percent)) { // this happens when we end the activity
return;
}
percent += '%';
dom.elapsedText.textContent = MediaUtils.formatDuration(
dom.player.currentTime);
dom.elapsedTime.style.width = percent;
// Don't move the play head if the user is dragging it.
if (!dragging) {
movePlayHead(percent);
}
}
function handleSliderTouchEnd(event) {
// We don't care the event not related to touchStartID
if (!event.changedTouches.identifiedTouch(touchStartID)) {
return;
}
touchStartID = null;
if (!dragging) {
// We don't need to do anything without dragging.
return;
}
dragging = false;
dom.playHead.classList.remove('active');
// We didn't move the play head while the user's finger was on it
// so now it may be in the wrong place.
updateSlider();
if (dom.player.currentTime === dom.player.duration) {
pause();
}
}
function handleSliderTouchMove(event) {
if (!dragging) {
return;
}
var touch = event.changedTouches.identifiedTouch(touchStartID);
// We don't care the event not related to touchStartID
if (!touch) {
return;
}
function getTouchPos() {
return (document.documentElement.dir === 'ltr') ?
(touch.clientX - sliderRect.left) :
(sliderRect.right - touch.clientX);
}
var touchPos = getTouchPos();
var pos = touchPos / sliderRect.width;
pos = Math.max(pos, 0);
pos = Math.min(pos, 1);
// Update the slider to match the position of the user's finger.
// Note, however, that we don't update the displayed time until
// we actually get a 'seeked' event.
var percent = pos * 100 + '%';
dom.playHead.classList.add('active');
movePlayHead(percent);
dom.elapsedTime.style.width = percent;
seeker.seekTo(dom.player.duration * pos);
}
function handleSliderKeypress(event) {
// 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(dom.player.duration / 20, 2);
if (event.keyCode === event.DOM_VK_DOWN) {
seeker.seekTo(dom.player.currentTime - step);
} else if (event.keyCode === event.DOM_VK_UP) {
seeker.seekTo(dom.player.currentTime + step);
}
}
function showBanner(l10n) {
document.l10n.setAttributes(
dom.message,
l10n.id,
l10n.args);
dom.banner.hidden = false;
setTimeout(function() {
dom.banner.hidden = true;
}, 3000);
}
// Strip directories and just return the base filename
function baseName(filename) {
if (!filename) {
return '';
}
return filename.substring(filename.lastIndexOf('/') + 1);
}
function setDisabled(element, disabled) {
element.classList.toggle('disabled', disabled);
// Set ARIA disabled attribute to maintain semantic meaning for the
// assistive technologies like screen reader.
element.setAttribute('aria-disabled', disabled);
}
function showSpinner() {
if (!blob) {
dom.spinnerOverlay.classList.remove('hidden');
setDisabled(dom.playerView, true);
}
}
function hideSpinner() {
dom.spinnerOverlay.classList.add('hidden');
setDisabled(dom.playerView, false);
}
})();