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

208
gaia/apps/video/index.html Executable file
View file

@ -0,0 +1,208 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<meta charset="utf-8" />
<title>Videos</title>
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="locales/video.{locale}.properties">
<link rel="localization" href="/shared/locales/date/date.{locale}.properties">
<link rel="localization" href="/shared/elements/gaia_menu/locales/gaia_menu.{locale}.properties">
<!-- this file is loaded lazily but is listed here so it gets packaged
<link rel="stylesheet" href="shared/style/confirm.css" type="text/css" />
-->
<link rel="stylesheet" type="text/css" href="style/video.css" />
<link rel="stylesheet" type="text/css" href="style/info.css" />
<link rel="stylesheet" type="text/css" href="style/spinner.css" />
<link rel="stylesheet" type="text/css" href="style/video_tablet.css" media="(min-height: 768px) and (min-width: 768px)" />
<!-- Shared Libs -->
<script defer src="shared/js/intl/l20n.js"></script>
<script defer src="shared/js/intl_helper.js"></script>
<script defer src="shared/js/screen_layout.js"></script>
<script defer src="shared/js/sanitizer.js"></script>
<script defer src="shared/js/mediadb.js"></script>
<script defer src="shared/js/media/media_utils.js"></script>
<script defer src="shared/js/blobview.js"></script>
<script defer src="shared/js/media/get_video_rotation.js"></script>
<script defer src="shared/js/manifest_helper.js"></script>
<script defer src="shared/js/media/video_stats.js"></script>
<script defer src="shared/js/lazy_loader.js"></script>
<script defer src="shared/js/dialogs.js"></script>
<!-- Web Components -->
<script defer src="shared/elements/config.js"></script>
<script defer src="shared/js/component_utils.js"></script>
<link rel="stylesheet" type="text/css" href="app://theme.gaiamobile.org/shared/elements/gaia-theme/gaia-theme.css" />
<link rel="stylesheet" type="text/css" href="shared/elements/gaia-icons/gaia-icons.css" />
<script defer src="shared/elements/gaia-header/dist/gaia-header.js"></script>
<script defer src="shared/elements/gaia_menu/script.js"></script>
<script defer src="js/metadata.js"></script>
<script defer src="js/db.js"></script>
<script defer src="js/thumbnail_list.js"></script>
<script defer src="js/thumbnail_date_group.js"></script>
<script defer src="js/thumbnail_item.js"></script>
<script defer src="js/video_utils.js"></script>
<script defer src="js/forward_rewind_controller.js"></script>
<script defer src="js/video_loading_checker.js"></script>
<script defer src="js/seeker.js"></script>
<script defer src="js/captions.js"></script>
<script defer src="js/video.js"></script>
</head>
<body role="application" class="skin-dark theme-media">
<div id="app-container">
<div id="two-column-spearator"></div>
<section role="region" id="thumbnail-views">
<gaia-header ignore-dir id="picker-header" action="back" class="thumbnails-list" no-font-fit>
<h1 id="picker-title" data-l10n-id="pick-title"></h1>
<h1 id="thumbnail-list-title">Video</h1>
</gaia-header>
<gaia-header ignore-dir id="thumbnails-select-top" action="close"
class="thumbnails-select" no-font-fit>
<h1 id="thumbnails-number-selected"></h1>
</gaia-header>
<!-- This element gets positioned over different view elements -->
<!-- It gets styled differently depending on what it is over -->
<!-- The real thumbnails list is actually inside this ul. We need
to remove the semantics of this list to be less verbose, if the
screen reader is on. -->
<ul role="presentation" id="thumbnails"></ul> <!-- Thumbnails inserted here -->
<footer id="thumbnails-bottom" class="thumbnails-list">
<a id="thumbnails-video-button" class="button" data-icon="video"
role="button" data-l10n-id="video-button" tabindex="0"></a>
<a id="thumbnails-select-button" class="button" data-icon="select"
role="button" data-l10n-id="select-button" tabindex="0"></a>
</footer>
<footer id="thumbnails-select-bottom" class="thumbnails-select">
<a id="thumbnails-delete-button" class="button" data-icon="delete"
role="button" data-l10n-id="delete-button" tabindex="0"></a>
<a id="thumbnails-share-button" class="button" data-icon="share"
role="button" data-l10n-id="share-button" tabindex="0"></a>
</footer>
</section>
<!-- (maximized) video player -->
<section role="region" id="player-view">
<!-- display this at startup while we create thumbnails -->
<div id="spinner-overlay" class="hidden" role="progress"
data-l10n-id="spinner-overlay"><div id="spinner"></div></div>
<!-- video controls header -->
<gaia-header ignore-dir id="player-header" action="back" class="video-controls" no-font-fit>
<h1 id="video-title"></h1>
<button id="options" data-icon="more"
data-l10n-id="options-more"></button>
<button id="picker-done" data-l10n-id="done">Done</button>
</gaia-header>
<!-- In order for the screen reader to be able to activate the container
to toggle controls, it must have the semantics of the button -->
<div id="video-container" role="button" tabindex="0">
<!-- Hiding the video element from the screen reader since the
controls are implemented elsewhere. -->
<video src="about:blank" id="player" aria-hidden="true"></video>
</div>
<!-- video controls footer toolbar -->
<div role="toolbar" class="video-controls bottom">
<!-- It is simpler and less verbose (for the screen reader) to not
expose that this block is a footer and consider it part of
its toolbar container. -->
<footer id="videoBar" role="presentation">
<div id="timeSlider" role="slider">
<!-- We need to use aria hideen for the text nodes in the slider
subtree because the screen reader treats sliders as atomic
elements and does not step inside their subtree. All the
information is conveyed via the slider attributes instead -->
<span id="elapsed-text" aria-hidden="true"></span>
<div id="slider-wrapper" aria-hidden="true">
<div id="elapsedTime" class="progress"></div>
<div id="bufferedTime" class="progress"></div>
<div id="timeBackground" class="progress"></div>
<button id="playHead"></button>
</div>
<span id="duration-text" aria-hidden="true"></span>
</div>
<div id="fullscreen-button" role="button"
data-l10n-id="fullscreen-button" tabindex="0"></div>
</footer>
<!-- It is simpler and less verbose (for the screen reader) to not
expose that this block is a footer and consider it part of
its toolbar container. -->
<footer id="videoControlBar" role="presentation">
<div id="videoToolBar">
<button id="seek-backward" class="player-controls-button"
data-icon="skip-back" data-l10n-id="seek-backward"></button>
<button id="play" class="player-controls-button" data-icon="pause"></button>
<button id="seek-forward" class="player-controls-button"
data-icon="skip-forward" data-l10n-id="seek-forward"></button>
</div>
</footer>
</div>
</section>
</div>
<form id="info-view" role="dialog" data-type="confirm" class="hidden"
data-l10n-id="info-view">
<section>
<dl>
<dt data-l10n-id="name-label"></dt>
<dd id="info-name" class="value"></dd>
<dt data-l10n-id="length-label"></dt>
<dd id="info-length" class="value"></dd>
<dt data-l10n-id="size-label"></dt>
<dd id="info-size" class="value"></dd>
<dt data-l10n-id="video-type-label"></dt>
<dd id="info-type" class="value"></dd>
<dt data-l10n-id="date-taken-label"></dt>
<dd id="info-date" class="value"></dd>
<dt data-l10n-id="resolution-label"></dt>
<dd id="info-resolution" class="value"></dd>
</dl>
</section>
<menu>
<button type="button" id="info-close-button"
data-l10n-id="close-button" class="full"></button>
</menu>
</form>
<gaia-menu id="options-view" hidden>
<button class="single-share-button" data-l10n-id="share"></button>
<button class="single-info-button" data-l10n-id="more-info"></button>
<button class="single-delete-button" data-l10n-id="delete"></button>
</gaia-menu>
<!-- A thin indeterminate progress bar to indicate scanning -->
<!-- set class to "throb" to make it go, or hidden" to make it stop -->
<div id="throbber" class="hidden"></div>
<!-- display messages like "No Photos" and "No SD Card" here -->
<form id="overlay" role="dialog" data-type="confirm" class="hidden">
<section id="overlay-content">
<h1 id="overlay-title"></h1>
<p id="overlay-text"></p>
</section>
<menu id="overlay-menu" class="hidden">
<button type="button" id="overlay-action-button" class="full"></button>
</menu>
</form>
<form id="confirm-dialog" role="dialog" data-type="confirm" class="hidden"
data-l10n-id="confirm-dialog">
<section>
<p id="confirm-msg"></p>
</section>
<menu>
<button id="confirm-cancel"></button>
<button id="confirm-ok"></button>
</menu>
</form>
<!-- display "Cannot load video because it is in use" here -->
<form id="in-use-overlay" role="dialog" data-type="confirm" class="hidden">
<section id="in-use-overlay-content">
<h1 id="in-use-overlay-title"></h1>
<p id="in-use-overlay-text"></p>
</section>
</form>
</body>
</html>

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);
}
})();

18
gaia/apps/video/jsdoc.json Executable file
View file

@ -0,0 +1,18 @@
{
"name": "video",
"tags": {
"dictionaries": ["jsdoc"]
},
"source": {
"include": ["apps/video/js/**/*.js"]
},
"plugins": [
"plugins/markdown",
"plugins/summarize"
],
"opts": {
"private": true,
"recurse": true,
"destination": "docs/video"
}
}

View file

@ -0,0 +1,99 @@
done = Done
number-selected2 = {[ plural(n) ]}
number-selected2[zero] = Select
number-selected2[one] = {{ n }} selected
number-selected2[two] = {{ n }} selected
number-selected2[few] = {{ n }} selected
number-selected2[many] = {{ n }} selected
number-selected2[other] = {{ n }} selected
# An "item" means a video
# The app currently doesn't use the [zero] case
delete-n-items? = {[ plural(n) ]}
delete-n-items?[zero] = Nothing selected
delete-n-items?[one] = Delete selected item?
delete-n-items?[two] = Delete {{ n }} items?
delete-n-items?[few] = Delete {{ n }} items?
delete-n-items?[many] = Delete {{ n }} items?
delete-n-items?[other] = Delete {{ n }} items?
share-noprovider = Try sharing one video at a time.
videos = Videos
cancel = Cancel
delete-video? = Delete video?
empty-title = Add videos to get started
empty-text = Load videos on to the memory card.
nocard2-title = No memory card found
nocard3-text = Insert a memory card to watch videos.
pluggedin-title = Video can not be watched while plugged in
pluggedin-text = Unplug the phone to watch videos.
upgrade-title = Database Upgrade
upgrade-text = Please wait while Video updates its database.
confirm-delete = Are you sure you want to delete:
pick-title = Select a video
save = Save
saved = Saved {{title}} to Video
error-network = The video could not be downloaded because of a network error.
error-unsupported = Video cannot be played: either because the server or network failed or because the format is not supported.
error-unknown = An unknown video error occurred.
video-hardware-in-use-title = Cannot play video
video-hardware-in-use-text = Another app is currently using the video player.
overlay-cancel-button = Cancel
overlay-camera-button = Go to Camera
name-label = Name
length-label = Length
size-label = Size
video-type-label = Video Type
date-taken-label = Date Taken
resolution-label = Resolution
close-button = Close
byteUnit-B = Bytes
byteUnit-KB = KB
byteUnit-MB = MB
byteUnit-GB = GB
byteUnit-TB = TB
byteUnit-PB = PB
byteUnit-EB = EB
byteUnit-ZB = ZB
byteUnit-YB = YB
# Localization note (fileSize): {{size}} is a floating number with 2 decimal places
fileSize = {{size}} {{unit}}
share = Share
more-info = More info
delete = Delete
# The following strings are spoken by screen readers and not shown on the
# screen.
video-button.ariaLabel = Video
select-button.ariaLabel = Select
delete-button.ariaLabel = Delete
share-button.ariaLabel = Share
info-view.ariaLabel = Info
confirm-dialog.ariaLabel = Confirm
videos-list.ariaLabel = Videos
options-more.ariaLabel = More
seek-backward.ariaLabel = Seek backward
seek-bar.ariaLabel = Seek bar, duration {{ duration }}
seek-forward.ariaLabel = Seek forward
play-button.ariaLabel = Play
pause-button.ariaLabel = Pause
fullscreen-button.ariaLabel = Full screen
show-controls-button.ariaLabel = Show controls
hide-controls-button.ariaLabel = Hide controls
spinner-overlay.ariaValueText = Loading

97
gaia/apps/video/manifest.webapp Executable file
View file

@ -0,0 +1,97 @@
{
"name": "Video",
"description": "Gaia Video",
"type": "certified",
"launch_path": "/index.html",
"developer": {
"name": "The Gaia Team",
"url": "https://github.com/mozilla-b2g/gaia"
},
"fullscreen": true,
"permissions": {
"themeable": {},
"device-storage:pictures": {
"access": "readwrite"
},
"device-storage:videos": {
"access": "readwrite"
},
"device-storage:sdcard": {
"access": "readonly"
},
"audio-channel-content": {},
"nfc-share": {},
"moz-extremely-unstable-and-will-change-webcomponents": {}
},
"locales": {
"ar": {
"name": "فيديو",
"description": "Gaia فيديو"
},
"en-US": {
"name": "Video",
"description": "Gaia Video"
},
"fr": {
"name": "Vidéo",
"description": "Lecteur vidéo de Gaia"
},
"zh-TW": {
"name": "影片",
"description": "Gaia 影片"
}
},
"default_locale": "en-US",
"icons": {
"84": "/style/icons/video_84.png",
"126": "/style/icons/video_126.png",
"142": "/style/icons/video_142.png",
"189": "/style/icons/video_189.png",
"284": "/style/icons/video_284.png"
},
"activities": {
"pick": {
"filters": {
"type": [
"video/*",
"video/webm",
"video/mp4",
"video/3gpp",
"video/3gpp2",
"video/ogg"
]
},
"disposition": "inline",
"returnValue": true,
"href": "/index.html#pick"
},
"view": {
"filters": {
"type": [
"video/webm",
"video/mp4",
"video/3gpp",
"video/3gpp2",
"video/ogg"
]
},
"href": "view.html",
"disposition": "inline",
"returnValue": true
},
"open": {
"filters": {
"type": [
"video/webm",
"video/mp4",
"video/3gpp",
"video/3gpp2",
"video/ogg"
]
},
"href": "view.html",
"disposition": "inline",
"returnValue": true
}
}
}

10
gaia/apps/video/package.json Executable file
View file

@ -0,0 +1,10 @@
{
"webide": {
"prepackage": {
"command": "make",
"env": ["APP=video"],
"cwd": "../.."
},
"packageDir": "../../build_stage/video/"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,016 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

48
gaia/apps/video/style/info.css Executable file
View file

@ -0,0 +1,48 @@
#info-view > section {
vertical-align: top; /* override confirm.css */
margin: 0;
padding: 0;
}
#info-view > section > dl {
border-top: none; /* override confirm.css */
margin: 0.6rem 2rem 0 2rem;
padding: 0;
}
#info-view > section > dl > dt {
display: block;
font-size: 1.6rem;
line-height: 1;
font-weight: 400;
color: #8a9699;
margin: 1rem 0 0.3rem 0;
padding: 0 1rem;
/* override confirm.css */
width: 100% !important;
float: none !important;
}
/* 1. Text in the info view should not be selectable */
#info-view > section > dl > dd {
display: block;
color: #ffffff;
font-size: 1.9rem;
line-height: 1;
font-weight: 400;
margin: 0;
padding: 0 1rem 1.2rem 1rem;
border-bottom: 0.1rem solid rgba(255,255,255,0.1);
-moz-user-select: none; /* 1 */
}
#info-view > section > dl > dd:last-child {
/* no separator after the last item in the list */
border-bottom: none;
}
#info-name {
/* file name is not directional and stays LTR */
direction: ltr;
}

View file

@ -0,0 +1,38 @@
/* Spinner */
#spinner-overlay { /* just like the #overlay element */
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: transparent;
}
/*
* spinner animation styles copied from
* shared/style/progress_activity.css
*/
#spinner {
position: absolute;
top: calc(50% - 1.5rem);
left: calc(50% - 1.5rem);
width: 2.9rem;
height: 2.9rem;
background: url(images/spinner.png) no-repeat center center / 100%;
animation: 0.9s spinner-animation infinite steps(30);
border: none;
z-index: 100;
}
#spinner-overlay.hidden {
display: none;
}
@keyframes spinner-animation {
from {
transform: rotate(1deg);
}
to {
transform: rotate(360deg);
}
}

757
gaia/apps/video/style/video.css Executable file
View file

@ -0,0 +1,757 @@
html, body {
margin: 0;
padding: 0;
font-size: 10px;
height: 100%;
}
body {
background-color: #0E1214;
color: #fff;
font-size: 10px;
margin: 0;
padding: 0;
height: 100%;
width: 100%;
overflow: hidden;
padding-bottom: 5rem;
}
body > section {
margin: 0;
padding: 0;
overflow: hidden;
font-size: 1.4rem;
background-color: #0E1214;
}
.hidden {
display: none;
}
ul {
padding: 0;
margin: 0;
overflow-x: hidden;
overflow-y: auto;
background-color: #0E1214;
}
li .inner {
height: 12rem;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: stretch;
}
/* unwatched doesn't participate in the flexbox because its position is
absolute so that it appears on top of the image instead of beside it */
.unwatched {
background: url(images/unwatched.png) no-repeat center / 100%;
position: absolute;
bottom: 0;
width: 0.4rem;
top: 0;
}
li .inner .img {
width: 21rem;
overflow: hidden;
}
li .img.default {
width: 6rem;
}
/* 1. Leave room for two rem when computing width of details
to account for one rem margin left and right */
li .inner .details {
line-height: 1.6rem;
margin: 1.6rem 1rem; /* 1 */
font-size: 1.4rem;
color: #fff;
width: calc(100% - 23rem); /* 1 */
overflow: hidden;
}
li .details[data-visible] {
visibility: visible;
}
li .after {
color: #95a3a6;
}
span.line-break {
display:block;
}
/* All of the main views fill the screen */
#thumbnail-views, #player-view {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
overflow: hidden;
font-size: 2.1rem;
}
/*
* The thumbnail list appears in regular browsing mode, in selection mode,
* and in pick mode.
*/
#thumbnails {
position: absolute;
left: 0;
right: 0;
/* top and bottom are set depending on view below */
padding: 0;
margin: 0;
overflow-y: scroll;
overflow-x: hidden;
/*
* the body background is black, but remind the UA of that here to
* keep the thumbnail view opaque if possible.
*/
background-color: #0E1214;
height: 100%;
}
/* thumbnails in thumbnail select view where there is a top bar */
#thumbnails.select,
#thumbnails.pick {
top: 5rem;
bottom: 0;
}
.thumbnail {
border-top: 0.1rem solid;
border-color:rgba(255,255,255, 0.1);
margin: 0;
height: 12rem;
overflow: hidden;
position: relative;
list-style-type: none;
}
.thumbnail.selected > .inner {
outline: solid #00aacc 0.3rem;
outline-offset: -0.2rem;
}
/*
* give the very last thumbnail a large bottom margin so that it doesn't
* get stuck hidden behind the overlaid toolbar.
* for normal thumbnails-list case, it is 4rem at bottom.
*/
#thumbnails>li:last-child{
margin-bottom: 4rem;
}
/*
* for normal thumbnails-select case, it is 5rem at top and 4rem at bottom.
*/
#thumbnails.select>li:last-child{
margin-bottom: 9rem;
}
.thumbnail-group-header {
font-size: 1.6rem;
font-weight: 400;
color: #8a9699;
padding: 0;
-moz-padding-start: 1rem;
height: 4rem;
line-height: 4rem;
}
#thumbnails>li:first-child .thumbnail-group-header {
border-top: 0.1rem solid;
border-color:rgba(255,255,255, 0.1);
}
#video-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
#video-container:active {
background: none;
}
#player {
/* size and position are set in JS depending on*/
/* video size and screen orientation */
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
}
/*
* The overlay is where we display messages like Scanning, No Videos,
* No SD card and SD Card in Use along with instructions for resolving
* the issue. The user can't interact with the app while the overlay
* is displayed.
*/
#overlay,
#in-use-overlay,
#confirm-dialog {
/* it takes up the whole screen */
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
#overlay,
#in-use-overlay {
/* almost transparent gray */
background-color: rgba(0, 0, 0, 0.4);
}
#confirm-dialog {
background-color: #2d2d2d;
}
/*
* In view.html (but not index.html) we put the in-use-overlay element
* inside video-controls. In that case there is a 5rem title bar that needs
* to be visible, so we alter the top position. And if we're handling
* a pick activity, we also need the title bar with the back button to show.
*/
.video-controls > #in-use-overlay,
body.pick-activity > #in-use-overlay {
top: 5rem;
}
/*
* The overlay content area holds the text of the overlay.
* It has borders and a less transparent background so that
* the overlay text shows up more clearly
*/
#overlay-content, #in-use-overlay-content {
background-color: #2d2d2d;
background-size: auto auto, 100% 100%;
/* We can't use shortand with background size because is not implemented yet:
https://bugzilla.mozilla.org/show_bug.cgi?id=570326; */
overflow: hidden;
position: absolute;
z-index: 100;
top: 0;
left: 0;
right: 0;
bottom: 0;
font-size: 0;
/* Using font-size: 0; we avoid the unwanted visual space (about 3px)
created by white-spaces and break lines in the code betewen inline-block elements */
color: #fff;
padding: 11rem 2.5rem 0 2.5rem;
}
#overlay-title, #in-use-overlay-title {
font-weight: normal;
font-size: 1.9rem;
color: #fff;
margin: 0 0.5rem 0 0.5rem;
padding: 0;
}
#overlay-text, #in-use-overlay-text {
padding: 1rem 0.5rem 0 0.5rem;
border-top: 0.1rem solid #686868;
font-weight: 300;
font-size: 2.5rem;
color: #ebebeb;
}
#overlay-menu {
z-index: 100;
}
/* For overriding the style from BB */
#overlay-menu.hidden {
display: none;
}
/* localization */
body.hidden *[data-l10n-id] {
visibility: hidden;
}
#close {
outline: none;
}
#h1 {
background: none !important;
}
section[role="region"] > header {
/* Need to use !important,else color is not reflecting */
background: rgba(0, 0, 0, 0.85) !important;
}
section[role="region"] header:first-child h1 {
font-size: 2.5rem;
line-height: 5rem;
margin: 0 0 0 3.4rem;
}
/* the indeterminate progress bar displayed while scanning */
#throbber {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 0.3rem;
overflow: hidden;
background-size: 1rem;
background-image: repeating-linear-gradient(135deg,
#000 0%, #000 42%,
#32D1F2 42%, #32D1F2 57%,
#000 57%, #000 100%);
z-index: 100;
}
#throbber.throb {
animation: throb 1s steps(10) infinite;
}
@keyframes throb {
from { background-position: 0 0; }
to { background-position: -1rem 0; }
}
/* buttons */
a.button {
display: block;
padding: 0;
margin: 0;
border-width: 0;
/* for buttons that display text */
font-size: 1.4rem;
text-align: center;
line-height: 4rem;
}
a.button:active, a.button:focus {
outline: none;
}
a.button.disabled {
opacity: 0.4;
pointer-events: none;
}
footer {
background: rgba(0, 0, 0, 0.75);
height: 4rem;
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 1;
}
#thumbnails-video-button {
position: absolute;
width: 50%;
height: 100%;
left: 0;
}
#thumbnails-select-button {
position: absolute;
width: 50%;
height: 100%;
left: 50%;
}
#thumbnails-delete-button {
position: absolute;
width: 50%;
height: 100%;
left: 0;
border-right: 0.1rem solid #000;
}
#thumbnails-share-button {
position: absolute;
width: 50%;
height: 100%;
left: 50%;
}
header .icon:after {
background: none !important;
}
/* video player controls */
.video-controls {
position: absolute;
width: 100%;
z-index: 10;
opacity: 1;
will-change: opacity, visibility;
transition: visibility 0s, opacity var(--transition-duration);
}
.video-controls-hidden .video-controls {
opacity: 0;
visibility: hidden;
transition-delay: 0.5s, 0s;
}
.video-controls.bottom {
bottom: 0;
}
/* main control bar: play|pause, timeupdate, rwd|fwd
For landscape mode here. Settings for portrait mode is written in
media query at bottom */
#videoBar {
position: absolute;
right: 0;
bottom: 4.4rem;
left: 0;
height: 4rem;
font-size: 0;
border-bottom: 0.1rem solid rgba(255,255,255, 0.1);
background-color: rgba(0,0,0, 0.85);
white-space: nowrap;
z-index: 10;
}
#videoBar:last-child {
bottom: 0;
}
#elapsed-text,
#timeSlider,
#slider-wrapper,
#duration-text {
display: inline-block;
position: relative;
line-height: 4.2rem;
vertical-align: top;
}
#elapsed-text, #duration-text {
color: #ffffff;
font-size: 1.4rem;
}
/* elapsed-text and duration-text have padding on left and right
to support ltr and rtl locales */
#elapsed-text {
width: 3.8rem;
padding: 0 1.5rem;
text-align: center;
}
#duration-text {
width: 3.8rem;
padding: 0 1.5rem;
text-align: center;
}
/* time slider */
#timeSlider {
position: relative;
width: 100%;
z-index: 10;
}
#slider-wrapper {
/* Take into account width and padding of elapsed and duration text */
width: calc(100% - 13.6rem);
height: 4.2rem;
}
#slider-wrapper div {
position: absolute;
pointer-events: none;
}
.progress {
height: 0.3rem;
width: 0;
top: 50%;
margin-top: -0.1rem;
}
#elapsedTime {
background-color: #00caf2;
z-index: 30;
margin-top: -0.2rem;
}
#bufferedTime {
background-color: blue;
z-index: 20;
}
#timeBackground {
width: 100%;
height: 0.1rem;
background-color: #a6b4b6;
z-index: 10;
}
#videoControlBar {
height: 4.5rem;
}
#videoToolBar {
position: relative;
height: 4.8rem;
font-size: 0;
vertical-align: top;
border-top: 0.1rem solid rgba(255,255,255, 0.1);
background-color: #000;
overflow: hidden;
direction: ltr
}
#seek-backward,
#seek-forward,
#play {
position: relative;
height: 100%;
padding: 0;
font-weight: 500;
background-position: center center;
background-repeat: no-repeat;
background-size: 3rem;
}
#seek-backward,
#seek-forward {
width: 33%;
}
#play {
width: 34%;
}
#play.paused:before {
content: 'play';
padding-left: 4px;
}
.player-controls-button {
color: #FFFFFF;
border: none;
border-radius: 0;
background: transparent;
}
.player-controls-button:hover {
background: transparent;
}
.player-controls-button:active {
background: #00caf2;
}
.player-controls-button:disabled {
opacity: 0.3;
}
.player-controls-button:disabled:active {
background: transparent;
}
#playHead {
position: absolute;
top: calc(50% - 1.15rem);
width: 2.3rem;
height: 2.3rem;
/* For LTR langauges, position the playhead 1.15 rems to the left
* so that the center of the playhead aligns with the beginning of
* the time slider.
*/
margin-left: -1.15rem;
/* For RTL langauges, position the playhead 1.15 rems to the right
* so that the center of the playhead aligns with the end of
* the time slider.
*/
margin-right: -1.15rem;
border: none;
background: none;
pointer-events: none;
z-index: 40;
}
#playHead:after {
content: "";
position: absolute;
top: calc(50% - 1.15rem);
left: calc(50% - 1.15rem);
width: 2.3rem;
height: 2.3rem;
border-radius: 50%;
background-color: #fff;
}
#playHead.active:before {
content: "";
position: absolute;
top: calc(50% - 3.05rem);
left: calc(50% - 3.05rem);
width: 6.1rem;
height: 6.1rem;
border-radius: 50%;
background-color: #00CAF2;
}
/* layout changes for pick activity */
body:not(.pick-activity) #picker-title,
body:not(.pick-activity) #picker-done,
body:not(.pick-activity) #picker-close {
display: none;
}
/**
* 1. COMPLEX: Scoped style-sheets within <gaia-header>
* trump all other rules. !important is required
* for an app to over-ride the default appearance
* of content within the custom-element. This may
* change when we get `:host` and `:content` selectors
* in shadow-dom.
*/
body:not(.pick-activity) #picker-done,
body:not(.pick-activity) #picker-header {
display: none !important; /* 1 */
}
/* hide options when picking */
body.pick-activity #options,
body.pick-activity #thumbnails-bottom {
display: none !important; /* 1 */
}
#two-column-spearator {
display: none;
}
/* details of a thumbnail */
.thumbnail .details {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
.thumbnail .details .title {
order: 1;
align-self: auto;
word-break: break-all;
direction: ltr /* the title is from the file name : always ltr */
}
.thumbnail .details .duration-text {
order: 2;
align-self: auto;
}
.thumbnail .details .size-type-group {
order: 3;
align-self: auto;
}
/**
* 1. COMPLEX: Scoped style-sheets within <gaia-header>
* trump all other rules. !important is required
* for an app to over-ride the default appearance
* of content within the custom-element. This may
* change when we get `:host` and `:content` selectors
* in shadow-dom.
*/
#thumbnail-list-title {
display: none !important; /* 1 */
}
/* we don't have fullscreen button when we are not in large layout */
#fullscreen-button {
display: none;
}
.large-only {
display: none !important; /* 1 */
}
/* not selection mode */
body:not(.layout-selection) .thumbnails-select,
/* not list mode */
body:not(.layout-list) .thumbnails-list,
/* not fullscreen-player mode */
body:not(.layout-fullscreen-player) #player-view {
display: none !important; /* 1 */
}
.layout-fullscreen-player #thumbnails {
display: none;
}
/* overlay mode */
.overlay #app-container, .info-view #app-container,
.options-view #app-container, .in-use-overlay #app-container,
.confirm-dialog #app-container {
visibility: hidden;
}
/* spinner is only valid for tablet and landscape mode */
body:not(.view-activity) #spinner-overlay {
display: none;
}
/* make videocontrolbar visible when picking */
body.pick-activity .video-controls #videoBar {
bottom: 4.4rem;
}
/*
* increase specificity to overwrite confirm.css rules for dialog buttons
*/
form[role="dialog"][data-type="confirm"] button.hidden {
display: none;
}
@media screen and (orientation: portrait) {
li.details {
width: 8rem;
}
}
:focus { outline:none; }
::-moz-focus-inner {
outline: none;
border: none;
padding: 0;
}
/*
* By default do not allow any user text selection, only enable
* on a per-element/region area.
*/
* {
-moz-user-select: none;
}
html[dir=rtl] #video-title {
direction: ltr;
}

View file

@ -0,0 +1,739 @@
section[role="region"] header:first-child h1 {
margin: 0;
}
body {
background-color: #1c2124;
}
#player-view.disabled .video-controls {
pointer-events: none;
}
/**
* 1. COMPLEX: Scoped style-sheets within <gaia-header>
* trump all other rules. !important is required
* for an app to over-ride the default appearance
* of content within the custom-element. This may
* change when we get `:host` and `:content` selectors
* in shadow-dom.
*/
body:not(.pick-activity) gaia-header.thumbnails-list,
#thumbnail-list-title {
display: inherit !important; /* 1 */
}
/* app name field is shown at non-pick mode and list view */
body.pick-activity #thumbnail-list-title {
display: none !important; /* 1 */
}
/* moves thumbnails down to 5rem for thumbnail list title showing */
#thumbnails {
top: 5rem;
}
/* change bottom of thumbnails to 11rem (5rem of top + 6rem of footer) */
#thumbnails>li:last-child,
#thumbnails.select>li:last-child{
margin-bottom: 11rem;
}
/* pick activity only has top: 5rem */
body.pick-activity #thumbnails>li:last-child {
margin-bottom: 5rem;
}
#thumbnails-bottom {
background-color: rgba(26, 33, 36, 0.9);
}
#videoBar {
background: none;
box-shadow: none;
height: 8rem;
background-image: url(images/tablet_player_mask.png);
background-repeat: repeat-x;;
background-size: 100% 8rem;
border: 0;
}
.player-controls-button {
color: #FFFFFF;
border: none;
border-radius: 0;
background: transparent;
transition: none;
}
/* player-view of background */
#player-view {
background-color: #1c2124;
}
/* play/pause button */
#play:before {
border: 0;
}
#play:active {
background: none;
}
#play:after {
background-image: url("images/tablet_pause.png");
background-position: center center;
background-repeat: no-repeat;
background-size: 4.6rem;
display: block;
z-index: 10;
}
#play.paused:after {
background-image: url("images/tablet_play.png");
background-size: 4.6rem;
}
#play:active:after {
background-image: url("images/tablet_pause_pressed.png");
}
#play.paused:active:after {
background-image: url("images/tablet_play_pressed.png");
}
/* time slider */
#timeSlider {
position: absolute;
top: 0;
height: 100%;
width: auto;
}
/* slider text */
#timeSlider span {
display: block;
height: 100%;
line-height: 8rem;
color: #FFFFFF;
position: absolute;
font-size: 1.8rem;
text-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
text-align: left;
}
span#elapsed-text {
width: 6rem;
}
#duration-text {
right: 0;
top: 0;
padding: 0;
}
.progress {
border-radius: 0.2rem;
height: 0;
}
/* slider */
#slider-wrapper {
top: 0;
left: 6rem;
height: 100%;
}
#slider-wrapper div{
left: 0;
}
#elapsedTime {
background-color: #00CAF2;
border: 0.2rem solid #00CAF2;
z-index: 30;
}
#bufferedTime {
background-color: #00CAF2;
border: 0.2rem solid #00CAF2;
z-index: 20;
}
#timeBackground {
background-color: rgba(255, 255, 255, 0.3);
border: 0.2rem solid rgba(255, 255, 255, 0.3);
z-index: 10;
}
/* player head */
#playHead {
position: absolute;
background: url(images/tablet_scrubber.png) no-repeat center center;
margin: 0 0 0 -2.3rem;
background-size: 4.6rem;
pointer-events: none;
border: none;
display: block;
height: 100%;
width: 4.6rem;
z-index: 100;
top: 0;
}
#playHead.active {
background: url(images/tablet_scrubber_pressed.png) no-repeat center center;
width: 4.6rem;
height: 100%;
margin: 0 0 0 -2.3rem;
top: 0;
}
footer {
height: 6rem;
}
/* selected thumbnail */
.thumbnail.selected > .inner {
outline: solid #00caf2 0.4rem;
outline-offset: -0.4rem;
}
/* unwatched bar */
.thumbnail .unwatched {
background-image: none;
background-color: #00CAF2;
}
.thumbnail .details .title {
margin-bottom: 1rem;
}
.layout-selection .thumbnails-select,
.layout-list .thumbnails-list {
display: block;
}
/**
* 1. COMPLEX: Scoped style-sheets within <gaia-header>
* trump all other rules. !important is required
* for an app to over-ride the default appearance
* of content within the custom-element. This may
* change when we get `:host` and `:content` selectors
* in shadow-dom.
*/
.layout-fullscreen-player .large-only {
display: block !important; /* 1 */
}
/* we don't need to show toolbar buttons in pick mode */
body.pick-activity .single-delete-button,
body.pick-activity .single-share-button,
body.pick-activity .single-info-button {
display: none;
}
@media (orientation: landscape) {
/* panels of selection mode */
.layout-selection .thumbnails-list {
display: none !important; /* 1 */
}
.layout-selection #switch-fullscreen,
.layout-selection #player-view > *,
.layout-selection .single-delete-button,
.layout-selection .single-share-button,
.layout-selection .single-info-button {
display: none;
}
/* show player-view for displaying background image, but all decendants are
* hidden */
.layout-selection #player-view {
display: block;
}
/* panels of list mode */
.layout-list #player-view,
.layout-list #player-view #fullscreen-button {
display: block;
}
.layout-list .thumbnails-select {
display: none !important; /* 1 */
}
.layout-list #switch-fullscreen,
.layout-list .video-controls #videoActionBar,
.layout-list .video-controls #close,
.layout-list .single-delete-button,
.layout-list .single-share-button,
.layout-list .single-info-button {
display: none;
}
/* panels of fullscreen player mode */
.layout-fullscreen-player #player-view {
display: block;
}
.layout-fullscreen-player .thumbnails-list,
.layout-fullscreen-player .thumbnails-select {
display: none !important; /* 1 */
}
.layout-fullscreen-player #thumbnails,
.layout-fullscreen-player .video-controls #videoActionBar {
display: none;
}
/* left pane size */
.thumbnails-select,
.thumbnails-list,
#thumbnails {
left: 0;
width: 40rem;
}
/* player size */
#player-view {
left: 40rem;
width: calc(100% - 40rem);
}
.layout-list #video-container {
margin: 6rem 1rem 1rem 1rem;
width: calc(100% - 2rem);
height: calc(100% - 7rem); /* top 6rem + bottom 1 rem */
}
.layout-fullscreen-player #player-view {
left: 0;
width: 100%;
}
/* the separator for two panes */
#two-column-spearator {
display: block;
position: absolute;
left: 40rem;
width: 0.2rem;
top: 5rem;
bottom: 0;
background-image: url(images/tablet_column_separator.png);
background-size: 0.2rem 100%
}
#two-column-spearator:before {
content: "";
display: block;
position: absolute;
width: 0.2rem;
height: 5rem;
top: -5rem;
background-image: url(images/tablet_section_divider.png);
background-size: 0.2rem 100%
}
.layout-list #player-view header:before {
content: "";
position: absolute;
left: 0;
top: 0;
z-index: -1;
height: 5rem;
width: 0.2rem;
background: url(images/tablet_section_divider.png) no-repeat left;
}
#fullscreen-button {
background-image: url(images/tablet_fullscreen.png);
background-size: 4.6rem;
background-position: center center;
background-repeat: no-repeat;
position: absolute;
width: 8rem;
height: 100%;
right: 0;
}
#fullscreen-button:active {
background-image: url(images/tablet_fullscreen_pressed.png);
}
/* disable video controls when selected */
.layout-selection .video-controls {
pointer-events: none;
}
body:not(.layout-selection):not(.pick-activity) .thumbnail.focused > .inner {
outline: solid #00caf2 0.4rem;
outline-offset: -0.4rem;
}
/* video controls */
.layout-list #play{
width: 10rem;
}
.layout-list #timeSlider {
left: 10rem;
right: 8rem;
width: auto;
}
.layout-list #slider-wrapper {
/* duration text and padding */
right: 7rem;
/* text at left and right are 6rem and 1rem for additional padding between
* fullscreen button and duration text */
width: calc(100% - 6rem - 7rem);
}
.layout-list #timeBackground {
width: 100%;
}
.layout-list #duration-text {
width: 5rem;
}
.layout-fullscreen-player #timeSlider {
left: 20rem;
right: 0;
}
.layout-fullscreen-player #slider-wrapper {
right: 12rem;
/* text at left is 6rem, right is 10rem and additional 2rem for padding
* between edge and duration text */
width: calc(100% - 6rem - 12rem);
}
.layout-fullscreen-player #timeBackground {
width: 100%;
}
.layout-fullscreen-player #duration-text {
width: 10rem;
}
.layout-fullscreen-player #fullscreen-button {
display: none;
}
/* normal case of thumbnail size */
.thumbnail {
border: 0;
margin: 0.2rem 0 0 0;
overflow: visible;
}
.thumbnail:before {
content: "";
width: 40rem;
height: 0.2rem;
background-image: url(images/tablet_video_divider.png);
position: absolute;
top: -0.2rem;
}
.thumbnail .details {
margin: 1.5rem 1.5rem 1rem 1.5rem;
height: 9.5rem;
width: calc(100% - 24rem);
font-size: 1.4rem;
color: #8A9699;
}
.thumbnail .details .title {
font-size: 2.4rem;
line-height: 2.4rem;
color: #FFFFFF;
}
/* spinner is only valid for tablet and landscape mode */
#spinner-overlay {
display: block;
}
/* pick mode */
body.pick-activity.layout-selection #player-view,
body.pick-activity.layout-list #player-view {
display: none;
}
body.pick-activity #two-column-spearator {
display: none;
}
/* left pane size */
body.pick-activity .thumbnails-select,
body.pick-activity .thumbnails-list,
body.pick-activity #thumbnails {
left: 0;
width: 100%;
}
/* pick view of thumbnail size */
body.pick-activity .thumbnail {
width: calc(33% - 0.2rem);
height: 0;
border: 0;
margin: 0 0 0.4rem 0;
overflow: visible;
float: left;
/*
* Why padding-bottom? We need to keep the same image ratio which 7:4.
* According to MDN padding-top, the percentage is referring to width of the
* block. So, we use (33% - 0.2rem) to have correct width width and applying
* 7:4 ratio to get the correct block height.
* See: https://developer.mozilla.org/en-US/docs/Web/CSS/padding-top
**/
padding-bottom: calc((33% - 0.2rem) * 4 / 7);
}
body.pick-activity .thumbnail:nth-child(3n),
body.pick-activity .thumbnail:nth-child(3n + 2) {
margin-left: 0.4rem;
}
body.pick-activity .thumbnail .inner,
body.pick-activity .thumbnail .inner .img {
width: 100%;
height: 0;
padding-bottom: calc(100% * 4 / 7);
}
body.pick-activity .thumbnail .details {
margin: 0;
/*
* We still need display:flex in this case because we need all elements are
* vertically aligned to bottom. So, we keep the height as 19.7rem which is
* the visual spec in 800x1280. If a device's width is less than 768px, it
* turns into tiny mode. Another 1.5rem is for the padding bottom.
* The equation is:
* padding top = calculated height - detail's height - padding bottom.
**/
padding: calc(100% * 4 / 7 - 19.7rem - 1.5rem) 2rem 1.5rem 2rem;
width: 16.5rem;
height: 19.7rem;
font-size: 1.6rem;
color: #FFFFFF;
text-shadow:0 0 5px #000000;
background-image: url(images/tablet_text_mask.png);
background-repeat: repeat-y;
background-size: 100%;
justify-content: flex-end;
top: auto;
bottom: 0;
}
body.pick-activity .thumbnail .details span {
bottom: 0;
}
body.pick-activity .thumbnail .details .after {
color: #FFFFFF;
}
body.pick-activity .thumbnail .details .title {
font-size: 2.6rem;
line-height: 2.6rem;
}
}
@media (orientation: portrait) {
.inner {
position: relative;
}
/* we don't need fullscreen button in this mode */
#fullscreen-button {
display: none;
}
/* selection mode */
.layout-selection .thumbnails-list {
display: none !important; /* 1 */
}
.layout-selection #player-view {
display: none;
}
/* list mode */
.layout-list .thumbnails-select {
display: none !important; /* 1 */
}
.layout-list #player-view {
display: none;
}
/* player mode / fullscreen-player mode */
.layout-fullscreen-player .thumbnails-select,
.layout-fullscreen-player .thumbnails-list {
display: none !important; /* 1 */
}
.layout-fullscreen-player #thumbnails,
.layout-fullscreen-player .video-controls #videoActionBar {
display: none;
}
/* spinner is only valid for tablet and landscape mode */
#spinner-overlay {
display: none;
}
.layout-fullscreen-player #timeSlider {
left: 14rem;
right: 0;
}
.layout-fullscreen-player #slider-wrapper {
right: 10rem;
/* text at left is 6rem, right is 8rem and additional 2rem for padding
* between edge and duration text */
width: calc(100% - 16rem);
}
.layout-fullscreen-player #timeBackground {
width: 100%;
}
.layout-fullscreen-player #duration-text {
width: 8rem;
}
/* thumbnail size */
.thumbnail {
width: calc(50% - 0.2rem);
height: 0;
border: 0;
margin: 0 0 0.4rem 0;
overflow: visible;
float: left;
padding-bottom: calc((50% - 0.2rem) * 4 / 7);
}
.thumbnail:nth-child(even) {
margin-left: 0.4rem;
}
.thumbnail .inner,
.thumbnail .inner .img {
width: 100%;
height: 0;
padding-bottom: calc(100% * 4 / 7);
}
.thumbnail .details {
margin: 0;
/*
* We still need display:flex in this case because we need all elements are
* vertically aligned to bottom. So, we keep the height as 19.7rem which is
* the visual spec in 800x1280. If a device's width is less than 768px, it
* turns into tiny mode. Another 1.5rem is for the padding bottom.
* The equation is:
* padding top = calculated height - detail's height - padding bottom.
**/
padding: calc(100% * 4 / 7 - 19.7rem - 1.5rem) 2rem 1.5rem 2rem;
width: 16.5rem;
height: 19.7rem;
font-size: 1.6rem;
color: #FFFFFF;
text-shadow:0 0 5px #000000;
background-image: url(images/tablet_text_mask.png);
background-repeat: repeat-y;
background-size: 100%;
justify-content: flex-end;
top: auto;
bottom: 0;
}
.thumbnail .details span {
bottom: 0;
}
.thumbnail .details .after {
color: #FFFFFF;
}
.thumbnail .details .title {
font-size: 2.6rem;
line-height: 2.6rem;
color: #FFFFFF;
}
}
/* Override header bar of building block */
/* XXX: the following css styles should be moved to building blocks,
* bug 905056 */
section[role="region"] > header:first-child > button,
section[role="region"] > header:first-child > a {
position: relative;
width: 5rem;
height: 4.9rem;
background: none;
float: left;
overflow: visible;
margin: 0;
}
section[role="region"].skin-organic > header:first-child,
.skin-organic section[role="region"] > header:first-child,
section[role="region"].skin-dark > header:first-child,
.skin-dark section[role="region"] > header:first-child {
background: url("images/tablet_header.png") repeat-x scroll 0 0 / auto 100% transparent;
}
section[role="region"] > header:first-child h1 {
padding: 0 1rem 0 2rem;
}
section[role="region"] > header:first-child .icon {
width: 5rem;
}
section[role="region"] > header:first-child > a:not([aria-disabled="true"]):active:after,
section[role="region"] > header:first-child > button:not(:disabled):active:after,
section[role="region"] > header:first-child > a:not([aria-disabled="true"]):hover:after,
section[role="region"] > header:first-child > button:not(:disabled):hover:after,
section[role="region"] > header:first-child menu[type="toolbar"] a:not([aria-disabled="true"]):hover,
section[role="region"] > header:first-child menu[type="toolbar"] button:not(:disabled):hover,
section[role="region"] > header:first-child menu[type="toolbar"] a:not([aria-disabled="true"]):active,
section[role="region"] > header:first-child menu[type="toolbar"] button:not(:disabled):active {
background: url("images/tablet_header_pressed.png") repeat-x center / 0.2rem transparent !important;
}
section[role="region"] > header:first-child menu[type="toolbar"] a,
section[role="region"] > header:first-child menu[type="toolbar"] button {
width: 8.2rem;
}
section[role="region"].skin-organic > header:first-child a:after,
.skin-organic section[role="region"] > header:first-child a:after,
section[role="region"].skin-dark > header:first-child a:after,
.skin-dark section[role="region"] > header:first-child a:after,
section[role="region"].skin-organic > header:first-child button:after,
.skin-organic section[role="region"] > header:first-child button:after,
section[role="region"].skin-dark > header:first-child button:after,
.skin-dark section[role="region"] > header:first-child button:after {
background: url("images/tablet_button_separator.png") no-repeat right / 0.2rem transparent;
}
section[role="region"] > header:first-child > button:after,
section[role="region"] > header:first-child > a:after {
width: 5.2rem;
}
section[role="region"] > header:first-child menu[type="toolbar"] a:after,
section[role="region"] > header:first-child menu[type="toolbar"] button:after {
content: "";
position: absolute;
left: 0;
top: 0;
z-index: -1;
height: 5rem;
width: 8.2rem;
background: url("images/tablet_button_separator.png") no-repeat left / 0.2rem transparent;
}

View file

@ -0,0 +1,27 @@
'use strict';
/* jshint node: true, mocha: true */
/* global suiteSetup */
var helper = require('helper');
suite('Video tests', function() {
suiteSetup(helper.cleanupWorkspace);
teardown(helper.cleanupWorkspace);
test('APP=video make', function(done) {
helper.exec('APP=video make', function(error, stdout, stderr) {
helper.checkError(error, stdout, stderr);
var zipPath = process.cwd() +
'/profile/webapps/video.gaiamobile.org/application.zip';
var checkList = ['manifest.webapp'];
checkList.forEach(function(filePath) {
helper.checkFilePathInZip(zipPath, filePath);
});
done();
});
});
});

View file

@ -0,0 +1,17 @@
{
"extends": "../../../../.jshintrc",
"predef": [
"suite",
"test",
"setup",
"teardown",
"suiteSetup",
"suiteTeardown",
"marionette",
"marionetteScriptFinished",
"require",
"define",
"requireApp",
"sinon"
]
}

View file

@ -0,0 +1,67 @@
'use strict';
var Video = require('./lib/video'),
assert = require('assert'),
VideoOptionsView = require('./lib/video_options_view.js'),
VideoInfoView = require('./lib/video_info_view.js');
marionette('video info view', function() {
var client = marionette.client({
profile: {
prefs: {
'device.storage.enabled': true,
'device.storage.testing': true,
'device.storage.prompt.testing': true
}
},
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
var app, selectors, optionsView, infoView;
setup(function() {
// Remove all files in temp device storage.
client.fileManager.removeAllFiles();
// Add file into the videos directory
client.fileManager.add({
type: 'videos',
filePath: 'test_media/Movies/elephants-dream.webm'
});
selectors = Video.Selector;
app = new Video(client);
optionsView = new VideoOptionsView(client);
infoView = new VideoInfoView(client);
});
test('open video and check video info', function() {
// tap thumbnail to open video
app.launch();
app.waitForThumbnails(1);
app.tapThumbnail(0);
app.waitForVideoToStartPlaying();
// tap screen to show menu, then tap option
app.tapOption();
assert.equal(optionsView.waitForOptionsViewDisplayed(), true);
// tap more info from options menu
optionsView.tapMoreInfo();
assert.equal(infoView.waitForInfoViewDisplayed(), true);
// check displayed values
assert.equal(infoView.displayedName, 'Elephants-dream');
assert.equal(infoView.displayedLength, '01:58');
assert.equal(infoView.displayedSize, '8.2 MB');
assert.equal(infoView.displayedType, 'webm');
var today = new Date();
assert.equal(infoView.displayedDate, today.getMonth() + 1 +
'/' + today.getDate() + '/' + today.getFullYear());
assert.equal(infoView.displayedResolution, '540x360');
// tap close, go back to the player view, check it still shows the file
infoView.tapClose();
assert.equal(app.waitForControls(), true);
assert.equal(app.videoTitle(),'Elephants-dream');
});
});

View file

@ -0,0 +1,25 @@
/* global Components,Services */
'use strict';
Components.utils.import('resource://gre/modules/Services.jsm');
Services.obs.addObserver(function(document) {
if (!document || !document.location) {
return;
}
var window = document.defaultView.wrappedJSObject;
var mozActivityData = {};
window.__defineGetter__('MozActivity', function() {
return function fakeMozActivity(data) {
mozActivityData = data;
};
});
window.__defineGetter__('getMozActivityData', function() {
return function getMozActivityData() {
return mozActivityData;
};
});
}, 'document-element-inserted', false);

View file

@ -0,0 +1,294 @@
/* global module */
'use strict';
/**
* @constructor
* @param {Marionette.Client} client Marionette client to use.
*/
function Video(client) {
this.client = client.scope({
searchTimeout: 20000
});
}
/**
* @const {string}
*/
Video.ORIGIN = 'app://video.gaiamobile.org';
module.exports = Video;
/**
* @const {Object}
*/
Video.Selector = Object.freeze({
thumbnail: 'li.thumbnail',
fullscreenView: '#player-view',
infoView: '#info-view',
thumbnailsSingleInfoButton: 'a.button.single-info-button',
thumbnailsSingleDeleteButton: 'a.button.single-delete-button',
thumbnailsSingleShareButton: 'a.button.single-share-button',
player: 'video#player',
optionsButton: '#options',
videoTitle: '#video-title',
overlay: '#overlay',
elapsedTime: '#elapsed-text',
durationText: '#duration-text',
thumbnailsSelectButton: '#thumbnails-select-button',
thumbnailSelectTop: '#thumbnail-select-top',
thumbnailsShareButton: '#thumbnails-share-button',
thumbnailsVideoButton: '#thumbnails-video-button',
thumbnailsDeleteButton: '#thumbnails-delete-button',
confirmOkButton: '.modal-dialog-confirm-ok',
playerHeader: '#player-header',
gaiaHeaderActionButton: '.action-button'
});
Video.prototype = {
/**
* Marionette client to use
* @type {Marionette.Client}
*/
client: null,
/**
* @return {Marionette.Element} Container for fullscreen view of an video.
*/
get fullscreenView() {
return this.client.findElement(Video.Selector.fullscreenView);
},
/**
* @return {Marionette.Element} Container for info view of an video.
*/
get infoView() {
return this.client.findElement(Video.Selector.infoView);
},
/**
* @return {Marionette.Element} overlay that displays message load videos
* to get started.
*/
get overlay() {
return this.client.findElement(Video.Selector.overlay);
},
/**
* @return {Marionette.Element} that share videos in selection mode.
*/
get thumbnailsShare() {
return this.client.findElement(Video.Selector.thumbnailsShareButton);
},
/**
* @return {Marionette.Element} that opens camera view.
*/
get thumbnailsVideo() {
return this.client.findElement(Video.Selector.thumbnailsVideoButton);
},
/**
* @return {Marionette.Element} that opens select view.
*/
get thumbnailsSelectButton() {
return this.client.findElement(Video.Selector.thumbnailsSelectButton);
},
/**
* @return {Marionette.Element} that delete videos in selection mode.
*/
get thumbnailsDelete() {
return this.client.findElement(Video.Selector.thumbnailsDeleteButton);
},
/**
* @return {Marionette.Element} to view info about the playing current video.
*/
get thumbnailsSingleInfo() {
return this.client.findElement(Video.Selector.thumbnailsSingleInfoButton);
},
/**
* @return {Marionette.Element} to delete the playing current video.
*/
get thumbnailsSingleDelete() {
return this.client.findElement(Video.Selector.thumbnailsSingleDeleteButton);
},
/**
* @return {Marionette.Element} to share the playing current video.
*/
get thumbnailsSingleShare() {
return this.client.findElement(Video.Selector.thumbnailsSingleShareButton);
},
/**
* @return {Marionette.Element} ok button in confirm delete files dialog.
*/
get confirmOk() {
// selector specific to the out-of-app confirm dialog
// found in System App
return this.client.findElement(Video.Selector.confirmOkButton);
},
/**
* @return {Marionette.Element} First element of all thumbnail images.
*/
get thumbnail() {
return this.client.helper.waitForElement(Video.Selector.thumbnail);
},
/**
* @return {Marionette.Element} video player element.
*/
get player() {
return this.client.helper.waitForElement(Video.Selector.player);
},
/**
* @return {Marionette.Element} video player option button element.
*/
get optionsButton() {
return this.client.helper.waitForElement(Video.Selector.optionsButton);
},
/**
* Called after tapping on a thumbnail to wait for the video to
* begin playing.
*/
waitForVideoToStartPlaying: function() {
var elapsedElem = this.client.findElement(Video.Selector.elapsedTime);
// Ensure when we tap on the player view the video controls are not
// displayed -- they are displayed when the video first plays and then
// they are automatically hidden.
//
// Wait for controls to appear as the video begins to play. Specify
// a timeout to account for the controls having already been displayed
// and auto-hidden.
this.client.waitFor(function() { return elapsedElem.displayed(); },
{ interval: 10, timeout: 5000 }
);
// Either the controls are now displayed or they have already been
// automatically hidden. In the case where they have been displayed,
// wait for them to be automatically hidden.
this.client.waitFor(function() { return !elapsedElem.displayed(); });
// Tap to display the controls and wait for them to be displayed.
this.player.tap();
this.waitForControls();
// Wait for the video to play for at least one second (to allow
// for validation checks).
this.client.waitFor(() => {
return this.getElapsedTimeSeconds() > 0;
});
},
waitForControls: function() {
return this.client.helper.waitForElement(
Video.Selector.elapsedTime).displayed();
},
/**
* Taps 'back' button on gaia-header to return from the video player view
* to the thumbnails view.
*/
back: function() {
var playerHeader = this.client.findElement(Video.Selector.playerHeader);
this.client.switchToShadowRoot(playerHeader);
var actionButton =
this.client.helper.waitForElement(Video.Selector.gaiaHeaderActionButton);
actionButton.tap();
this.client.switchToShadowRoot();
},
getElapsedTime: function() {
var elapsedTimeElem = this.client.findElement(Video.Selector.elapsedTime);
return elapsedTimeElem;
},
getElapsedTimeSeconds: function() {
return parseInt(this.getElapsedTime().text().split(':').pop());
},
videoTitle: function() {
var titleElem =
this.client.findElement(Video.Selector.videoTitle);
return titleElem.text();
},
/**
* @return {Marionette.Element} List of elements of thumbnail videos.
*/
getThumbnails: function() {
return this.client.findElements(Video.Selector.thumbnail);
},
get thumbnails() {
return this.client.findElements(Video.Selector.thumbnail);
},
tapThumbnail: function(n) {
var thumbnail = this.thumbnails[n];
thumbnail.click();
},
waitForThumbnails: function(num) {
var getThumbnails = this.getThumbnails.bind(this);
this.client.helper.waitFor(function() {
return getThumbnails().length === num;
});
},
/**
* Click select button in the video gallery view footer to enter
* selection mode to share or delete multiple videos.
*/
enterSelectionMode: function() {
this.client.helper.waitForElement(Video.Selector.thumbnailsSelectButton)
.click();
this.client.helper.waitForElement(Video.Selector.thumbnailSelectTop);
},
/**
* Click first thumbnail in gallery view to play the video
*/
playVideoFromGalleryView: function() {
this.client.helper.waitForElement(Video.Selector.thumbnail).click();
},
/**
* Select first element of thumbnail videos
* list in selection mode.
*/
selectThumbnail: function() {
var firstItem = this.client.helper.waitForElement(Video.Selector.thumbnail);
firstItem.click();
// Wait for element is shown as selected, otherwise error
this.client.waitFor(function() {
var classList = firstItem.getAttribute('class').split(' ');
return classList.indexOf('selected') > -1;
});
},
/**
* Click option button from player view and wait for button selection
*/
tapOption: function() {
this.optionsButton.click();
},
/**
* Start the Video, save the client for future ops, and wait for the
* Video to finish an initial render.
*/
launch: function() {
this.client.apps.launch(Video.ORIGIN);
this.client.apps.switchToApp(Video.ORIGIN);
// Wait for the document body to know we're really 'launched'.
this.client.helper.waitForElement('body');
}
};

View file

@ -0,0 +1,115 @@
/* global module */
(function(module) {
'use strict';
/**
* @constructor
* @param {Marionette.Client} client Marionette client to use.
*/
function VideoInfoView(client) {
this.client = client.scope({
searchTimeout: 20000
});
}
module.exports = VideoInfoView;
/**
* @const {Object}
*/
VideoInfoView.Selector = Object.freeze({
infoView: '#info-view',
infoName: '#info-name',
infoLength: '#info-length',
infoSize: '#info-size',
infoType: '#info-type',
infoDate: '#info-date',
infoResolution: '#info-resolution',
closeButton: '#info-close-button'
});
VideoInfoView.prototype = {
client: null,
/**
* @return {String} Currently displayed video name text.
*/
get displayedName() {
return this.client.findElement(
VideoInfoView.Selector.infoName).text();
},
/**
* @return {String} Currently displayed video length.
*/
get displayedLength() {
return this.client.findElement(
VideoInfoView.Selector.infoLength).text();
},
/**
* @return {String} Currently displayed video size.
*/
get displayedSize() {
var size = this.client.findElement(
VideoInfoView.Selector.infoSize).text();
return this._trimTextContent(size);
},
/**
* @return {String} Currently displayed video type.
*/
get displayedType() {
return this.client.findElement(
VideoInfoView.Selector.infoType).text();
},
/**
* @return {String} Currently displayed date (taken) for video.
*/
get displayedDate() {
return this.client.findElement(
VideoInfoView.Selector.infoDate).text();
},
/**
* @return {String} Currently displayed video resolution.
*/
get displayedResolution() {
return this.client.findElement(
VideoInfoView.Selector.infoResolution).text();
},
/**
* @return {Marionette.Element} close button.
*/
get closeBtn() {
return this.client.findElement(
VideoInfoView.Selector.closeButton);
},
// strip l10n delimiters
_trimTextContent: function(text) {
// for some reason these unicode delimiters are inserted
// in the text node
return text.trim().replace(/[\u2068\u2069]/g, '');
},
/**
* @return {Boolean} display status of info view.
*/
waitForInfoViewDisplayed: function() {
return this.client.helper.waitForElement(
VideoInfoView.Selector.infoView).displayed();
},
/**
* Close the info view and waits for the player view
*/
tapClose: function() {
this.closeBtn.click();
}
};
})(module);

View file

@ -0,0 +1,89 @@
/* global module */
(function(module) {
'use strict';
/**
* @constructor
* @param {Marionette.Client} client Marionette client to use.
*/
function VideoOptionsView(client) {
this.client = client.scope({
searchTimeout: 20000
});
}
module.exports = VideoOptionsView;
/**
* @const {Object}
*/
VideoOptionsView.Selector = Object.freeze({
optionsView: '#options-view',
shareButton: '.single-share-button',
infoButton: '.single-info-button',
deleteButton: '.single-delete-button',
cancelButton: '.gaia-menu-cancel'
});
VideoOptionsView.prototype = {
client: null,
/**
* @return {Marionette.element} share button object
*/
get shareButton() {
return this.client.findElement(
VideoOptionsView.Selector.shareButton);
},
/**
* @return {Marionette.element} more info button object
*/
get infoButton() {
return this.client.findElement(
VideoOptionsView.Selector.infoButton);
},
/**
* @return {Marionette.element} delete button object
*/
get deleteButton() {
return this.client.findElement(
VideoOptionsView.Selector.deleteButton);
},
/**
* @return {Marionette.element} cancel button object
*/
get cancelButton() {
return this.client.findElement(
VideoOptionsView.Selector.cancelButton);
},
/**
* @return {boolean} the display status of the options view
*/
waitForOptionsViewDisplayed: function() {
return this.client.helper.waitForElement(
VideoOptionsView.Selector.optionsView).displayed();
},
tapShare: function() {
this.shareButton.click();
},
tapMoreInfo: function() {
this.infoButton.click();
},
tapDelete: function() {
this.deleteButton.click();
},
tapCancel: function() {
this.cancelButton.click();
}
};
})(module);

View file

@ -0,0 +1,29 @@
'use strict';
var Video = require('./lib/video');
marionette('video overlay', function() {
var assert = require('assert');
var client = marionette.client({
profile: {
prefs: {
'device.storage.enabled': true,
'device.storage.testing': true,
'device.storage.prompt.testing': true
}
}
});
var app;
setup(function() {
// Remove all files in temp device storage.
client.fileManager.removeAllFiles();
app = new Video(client);
app.launch();
});
test('check for empty overlay', function() {
var overlay = client.helper.waitForElement(Video.Selector.overlay);
assert.ok(overlay.displayed(), 'The list should be empty');
});
});

View file

@ -0,0 +1,68 @@
'use strict';
var assert = require('assert'),
Marionette = require('marionette-client'),
Video = require('./lib/video');
marionette('play various types of videos', function() {
var app, client, actions;
client = marionette.client({
profile: {
prefs: {
'device.storage.enabled': true,
'device.storage.testing': true,
'device.storage.prompt.testing': true
}
},
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
setup(function() {
// Remove all files in temp device storage.
client.fileManager.removeAllFiles();
app = new Video(client);
actions = new Marionette.Actions(client);
});
test ('test various videos', function() {
var types = ['mp4', 'ogg', 'ogv', 'webm'];
types.forEach(function(type) {
var filename = 'test_' + type + '_video.' + type;
client.fileManager.add({
type: 'videos',
filePath: 'apps/video/test/videos/' + filename
});
});
app.launch();
app.waitForThumbnails(types.length);
var thumbnails = app.getThumbnails();
for (var i = 0; i < thumbnails.length; i++) {
app.tapThumbnail(i);
app.waitForVideoToStartPlaying();
// Add some context to the output in case of test failure.
console.log('playing video:', app.videoTitle());
// Verify the video has begun playing and the
// title has been read properly.
assert(app.getElapsedTimeSeconds() > 0);
var expectedTitle = 'Test ' + types[i] + ' video';
assert(app.videoTitle(), expectedTitle);
// Go back to the thumbnail view.
app.back();
// Scroll the thumbnails a bit after each video is played
// to ensure the thumbnails are displayed when they are
// 'tapped'.
actions.flick(thumbnails[i], 0, 0, 0, -30).perform();
}
});
});

View file

@ -0,0 +1,89 @@
/* global __dirname */
'use strict';
var Video = require('./lib/video'),
assert = require('assert');
marionette('video list', function() {
var app, client, selectors;
client = marionette.client({
profile: {
prefs: {
'device.storage.enabled': true,
'device.storage.testing': true,
'device.storage.prompt.testing': true
}
}
});
function getActivityData(client) {
var data;
client.waitFor(function() {
data = client.executeScript(function() {
return window.wrappedJSObject.getMozActivityData();
});
return data;
});
return data;
}
setup(function() {
// inject MozActivity to get activity details
client.contentScript.inject(__dirname +
'/lib/mocks/mock_window_mozactivity.js');
// Remove all files in temp device storage.
client.fileManager.removeAllFiles();
// Add file into the videos directory
client.fileManager.add({
type: 'videos',
filePath: 'test_media/Movies/elephants-dream.webm'
});
selectors = Video.Selector;
app = new Video(client);
app.launch();
client.helper.waitForElement(selectors.thumbnail);
});
test('check video lists', function() {
assert.ok(app.getThumbnails().length > 0, 'the list is not empty');
assert.ok(app.getThumbnails()[0].displayed(),
'first item should be shown');
});
test('open camera app', function() {
app.thumbnailsVideo.click();
var activityObj = getActivityData(client);
assert.equal(
activityObj.name,
'record',
'the activity name should be record.'
);
assert.equal(
activityObj.data.type,
'videos',
'type in data should be videos.'
);
});
/*
test('share video', function() {
app.enterSelectionMode();
app.selectThumbnail();
app.thumbnailsShare.click();
var activityObj = getActivityData(client);
assert.equal(
activityObj.name,
'share',
'the activity name should be share.'
);
assert.equal(activityObj.data.type,
'video/*',
'type data in share activity is video/*.'
);
});
*/
});

View file

@ -0,0 +1,17 @@
{
"extends": "../../../../.jshintrc",
"predef": [
"assert",
"suite",
"test",
"setup",
"teardown",
"suiteSetup",
"suiteTeardown",
"require",
"requireApp",
"sinon",
"mocha",
"loadBodyHTML"
]
}

View file

@ -0,0 +1,65 @@
/* global Captions */
'use strict';
requireApp('/video/js/captions.js');
suite('captions.js unit tests', function() {
var player, captions, realGetDeviceStorage;
var mockVTTFile = new Blob(['fake vtt file'], { type: 'text/vtt' });
var mockGetRequest;
function mockGetDeviceStorage(type) {
return {
get: function(filename) {
mockGetRequest.requestedFilename = filename;
return mockGetRequest;
}
};
}
setup(function() {
player = document.createElement('video');
captions = new Captions(player);
realGetDeviceStorage = navigator.getDeviceStorage;
navigator.getDeviceStorage = mockGetDeviceStorage;
mockGetRequest = { result: mockVTTFile };
});
teardown(function() {
navigator.getDeviceStorage = realGetDeviceStorage;
});
test('remove() removes track elements from player', function() {
player.appendChild(document.createElement('track'));
player.appendChild(document.createElement('track'));
assert.equal(player.querySelectorAll('track').length, 2);
captions.remove();
assert.equal(player.querySelectorAll('track').length, 0);
});
test('findAndDisplay() looks for a .vtt file', function() {
captions.findAndDisplay('foo/bar.mp4');
assert.equal(mockGetRequest.requestedFilename, 'foo/bar.vtt');
});
test('findAndDisplay() adds a track element if captions exist', function() {
captions.findAndDisplay('foo.mp4');
mockGetRequest.onsuccess(); // A captions file was found
assert.equal(player.querySelectorAll('track').length, 1);
});
test('findAndDisplay() adds a showing track element', function() {
captions.findAndDisplay('foo.mp4');
mockGetRequest.onsuccess(); // A captions file was found
assert.equal(player.querySelector('track').track.mode, 'showing');
});
test('findAndDisplay() adds no track element if no captions', function() {
captions.findAndDisplay('foo.mp4');
if (mockGetRequest.onerror) {
mockGetRequest.onerror(); // No captions file was found
}
assert.equal(player.querySelectorAll('track').length, 0);
});
});

Binary file not shown.

View file

@ -0,0 +1,15 @@
/* exported MockForwardRewindController */
'use strict';
var MockForwardRewindController = (function() {
function init(video, forward, backward) {}
function uninit(video, forward, backward) {}
return {
init: init,
uninit: uninit
};
}());

View file

@ -0,0 +1,32 @@
/* exported MockMediaDB, MediaDB */
'use strict';
var MediaDB;
var MockMediaDB = (function() {
function MockMediaDB(blob) {
this.blob = blob;
this.OPENING = 'opening'; // this is initializing itself
this.UPGRADING = 'upgrading'; // this is upgrading database
this.READY = 'ready'; // this is available and ready for use
this.NOCARD = 'nocard'; // Unavailable because there is no sd card
this.UNMOUNTED = 'unmounted'; // Unavailable because card unmounted
this.CLOSED = 'closed'; // Unavailable because closed() was called
}
MockMediaDB.prototype = {
getFile: function getFile(filename, callback) {
this.blob.name = filename;
callback(this.blob);
},
updateMetadata: function updateMetadata() {
}
};
return MockMediaDB;
}());

View file

@ -0,0 +1,19 @@
/* exported startParsingMetadata, stopParsingMetadata */
/* exported metadataQueue, processingQueue, captureFrame */
'use strict';
var metadataQueue;
var processingQueue;
function startParsingMetadata() {
}
function stopParsingMetadata() {
}
// We need a mock captureFrame function as opposed to simply
// using a spy because the function needs to have an implementation,
// it needs to invoke the callback function.
function captureFrame(player, metadata, callback) {
callback();
}

View file

@ -0,0 +1,48 @@
'use strict';
var MockThumbnailGroup = function(item) {
var counter = 0;
var dummyNode = document.createElement('div');
dummyNode.textContent = MockThumbnailGroup._GroupID;
dummyNode.htmlNode = dummyNode;
dummyNode.updateTitleText = function() {};
function addItem() {
counter++;
return dummyNode;
}
function removeItem() {
counter--;
}
function localize() {}
MockThumbnailGroup._GroupMap[MockThumbnailGroup._GroupID] = {
// api
addItem: addItem,
getCount: function() {return counter;},
removeItem: removeItem,
localize: localize,
// properties.
groupID: MockThumbnailGroup._GroupID,
htmlNode: dummyNode
};
return MockThumbnailGroup._GroupMap[MockThumbnailGroup._GroupID];
};
MockThumbnailGroup.getGroupID = function(video) {
return MockThumbnailGroup._GroupID;
};
MockThumbnailGroup.compareGroupID = function(id1, id2) {
return id1 > id2 ? 1 : (id1 < id2 ? -1 : 0);
};
MockThumbnailGroup._GroupMap = {};
MockThumbnailGroup.reset = function() {
MockThumbnailGroup._GroupMap = {};
delete MockThumbnailGroup._GroupID;
};

View file

@ -0,0 +1,15 @@
/* exported MockThumbnailItem */
'use strict';
var MockThumbnailItem = function(video) {
var dummyNode = document.createElement('div');
dummyNode.textContent = video.date;
var titleMaxLines;
return {
htmlNode: dummyNode,
data: video,
titleMaxLines: titleMaxLines,
updateTitleText: function() {}
};
};

View file

@ -0,0 +1,27 @@
'use strict';
function VideoLoadingChecker(player, overlay, overlayTitle, overlayText) {
this.player = player;
this.overlay = overlay;
this.overlayTitle = overlayTitle;
this.overlayText = overlayText;
this.callback = null;
}
VideoLoadingChecker.prototype = {
ensureVideoLoads: function vpc_ensureVideoLoads(callback) {
this.callback = callback;
},
invokeLoadedMetadataCallback: function vpc_invokeLoadedMetadataCallback() {
if (this.callback) {
this.callback();
}
},
resetLoadedMetadataCallback: function vpc_resetLoadedMetadataCallback() {
this.callback = null;
},
ensureVideoPlays: function() {},
cancelEnsureVideoPlays: function() {},
};

View file

@ -0,0 +1,70 @@
'use strict';
function MockVideoPlayer() {
MockVideoPlayer.instances.push(this);
this.src = null;
this.readyState = 0;
this.seeking = false;
this.duration = 0;
this.currentTime = 0;
this.classList = [];
}
MockVideoPlayer.instances = [];
MockVideoPlayer.mSetup = function() {
MockVideoPlayer.instances = [];
};
MockVideoPlayer.mTeardown = function() {
MockVideoPlayer.instances = [];
};
MockVideoPlayer.prototype.play = function() {
};
MockVideoPlayer.prototype.pause = function() {
};
MockVideoPlayer.prototype.load = function() {
};
MockVideoPlayer.prototype.setSeeking = function(seeking) {
this.seeking = seeking;
};
MockVideoPlayer.prototype.setDuration = function(duration) {
this.duration = duration;
};
MockVideoPlayer.prototype.hasAttribute = function(attr) {
if (attr === 'src') {
return (this.src !== undefined && this.src !== null);
}
return (this.classList.indexOf(attr) !== -1);
};
MockVideoPlayer.prototype.removeAttribute = function(attr) {
if (attr === 'src') {
this.src = null;
}
else {
var index = this.classList.indexOf(attr);
if (index >= 0) {
this.classList.splice(index, 1);
}
}
};
MockVideoPlayer.prototype.fastSeek = function(seekTime) {
this.currentTime = seekTime;
};
MockVideoPlayer.prototype.cloneNode = function() {
return this;
};
MockVideoPlayer.prototype.querySelectorAll = function(selector) {
return [];
};

View file

@ -0,0 +1,136 @@
/* global Seeker, performance */
'use strict';
requireApp('/video/js/seeker.js');
// We need a simple mock <video> element for these testsx
function MockVideo() {
this.fastSeek = sinon.spy();
this.seekedHandlers = [];
this.addEventListener = function(type, handler) {
if (type === 'seeked') {
this.seekedHandlers.push(handler);
}
};
this._fireSeekedEvent = function() {
this.seekedHandlers.forEach((f) => {
typeof f === 'object' ? f.handleEvent() : f();
});
};
}
suite('seeker.js unit tests', function() {
var player, seeker;
setup(function() {
player = new MockVideo();
seeker = new Seeker(player);
});
test('it calls fastSeek()', function() {
seeker.seekTo(1);
assert.ok(player.fastSeek.calledOnce);
assert.ok(player.fastSeek.calledWith(1));
});
test('it doesn\'t seek twice to same spot', function() {
seeker.seekTo(1);
seeker.seekTo(1);
assert.ok(player.fastSeek.calledOnce);
assert.ok(player.fastSeek.calledWith(1));
});
test('it defers rapidly repeated seeks', function() {
seeker.seekTo(1);
seeker.seekTo(2);
seeker.seekTo(3);
seeker.seekTo(4);
assert.ok(player.fastSeek.calledOnce);
assert.ok(player.fastSeek.calledWith(1));
player._fireSeekedEvent();
assert.ok(player.fastSeek.calledTwice);
assert.ok(player.fastSeek.calledWith(4));
player._fireSeekedEvent();
assert.ok(player.fastSeek.calledTwice);
});
test('it does not defer seeks if previous seek has ended', function() {
seeker.seekTo(1);
player._fireSeekedEvent();
assert.ok(player.fastSeek.calledOnce);
assert.ok(player.fastSeek.calledWith(1));
seeker.seekTo(2);
assert.ok(player.fastSeek.calledTwice);
assert.ok(player.fastSeek.calledWith(2));
player._fireSeekedEvent();
seeker.seekTo(3);
assert.ok(player.fastSeek.calledThrice);
assert.ok(player.fastSeek.calledWith(3));
});
test('it does not defer seeks to 0', function() {
seeker.seekTo(4);
seeker.seekTo(3);
seeker.seekTo(2);
assert.ok(player.fastSeek.calledOnce);
assert.ok(player.fastSeek.calledWith(4));
seeker.seekTo(0);
assert.ok(player.fastSeek.calledTwice);
assert.ok(player.fastSeek.calledWith(0));
player._fireSeekedEvent();
assert.ok(player.fastSeek.calledTwice);
});
test('it does not defer long-distance seeks', function() {
seeker.seekTo(1);
assert.ok(player.fastSeek.calledOnce);
assert.ok(player.fastSeek.calledWith(1));
Seeker.SEEK_DISTANCE = 5;
seeker.seekTo(7);
assert.ok(player.fastSeek.calledTwice);
assert.ok(player.fastSeek.calledWith(7));
Seeker.SEEK_DISTANCE = 50;
seeker.seekTo(17); // not far enough, it gets deferred
assert.ok(player.fastSeek.calledTwice);
assert.ok(player.fastSeek.calledWith(7));
seeker.seekTo(58);
assert.ok(player.fastSeek.calledThrice);
assert.ok(player.fastSeek.calledWith(58));
player._fireSeekedEvent();
assert.ok(player.fastSeek.calledThrice);
});
test('it does not defer infrequent seeks', function() {
// We need to fake out performance.now() for this test
var realPerformanceNow = performance.now;
var time = 0;
performance.now = function() { return time; };
try {
seeker.seekTo(1);
assert.ok(player.fastSeek.calledOnce);
assert.ok(player.fastSeek.calledWith(1));
Seeker.SEEK_INTERVAL = 10;
time = 12;
seeker.seekTo(2);
assert.ok(player.fastSeek.calledTwice);
assert.ok(player.fastSeek.calledWith(2));
Seeker.SEEK_INTERVAL = 100;
time = 50;
seeker.seekTo(3); // not enough fake time has passed, this is deferred
assert.ok(player.fastSeek.calledTwice);
assert.ok(player.fastSeek.calledWith(2));
time = 113;
seeker.seekTo(4);
assert.ok(player.fastSeek.calledThrice);
assert.ok(player.fastSeek.calledWith(4));
player._fireSeekedEvent();
assert.ok(player.fastSeek.calledThrice);
}
finally {
performance.now = realPerformanceNow;
}
});
});

View file

@ -0,0 +1,3 @@
'use strict';
require('/shared/test/unit/mocks/mocks_helper.js');

View file

@ -0,0 +1,181 @@
/* global ThumbnailItem: true,MockThumbnailItem,MockL10n,
ThumbnailDateGroup,MockIntlHelper */
/*
* Thumbnail Date Group tests
*/
'use strict';
require('/shared/js/sanitizer.js');
require('/shared/js/media/media_utils.js');
require('/shared/test/unit/mocks/mock_l20n.js');
require('/shared/test/unit/mocks/mock_intl_helper.js');
requireApp('/video/test/unit/mock_thumbnail_item.js');
requireApp('/video/js/thumbnail_date_group.js');
requireApp('/video/js/thumbnail_item.js');
suite('Thumbnail Date Group Unit Tests', function() {
var nativeMozL10n;
var nativeThumbnailItem;
var nativeIntlHelper;
suiteSetup(function() {
nativeThumbnailItem = ThumbnailItem;
nativeMozL10n = document.l10n;
document.l10n = MockL10n;
nativeIntlHelper = window.IntlHelper;
window.IntlHelper = MockIntlHelper;
ThumbnailItem = MockThumbnailItem;
window.IntlHelper.define('date-group', 'datetime', {
month: 'long',
year: 'numeric',
});
});
suiteTeardown(function() {
document.l10n = nativeMozL10n;
window.IntlHelper = nativeIntlHelper;
ThumbnailItem = nativeThumbnailItem;
});
suite('#static functions', function() {
test('#getGroupID', function() {
assert.equal(ThumbnailDateGroup.getGroupID({date: 1375873140000}),
'group_2013-08');
assert.equal(ThumbnailDateGroup.getGroupID({date: 1381967318275}),
'group_2013-10');
});
test('#compareGroupID', function() {
assert.equal(ThumbnailDateGroup.compareGroupID('group_2013-08',
'group_2013-08'),
0);
assert.equal(ThumbnailDateGroup.compareGroupID('group_2013-08',
'group_2013-07'),
1);
assert.equal(ThumbnailDateGroup.compareGroupID('group_2013-08',
'group_2013-09'),
-1);
assert.equal(ThumbnailDateGroup.compareGroupID('group_2013-09',
'group_2013-10'),
-1);
});
});
suite('#test object creation', function() {
test('#empty video object', function() {
try {
new ThumbnailDateGroup(); // jshint ignore:line
assert.fail('undefined or null videoitem should not be ok.');
} catch (ex) {
assert.ok('correct behavior caught');
}
});
});
suite('#rendering', function() {
var dateGroup;
var videoData;
var domNode;
suiteSetup(function() {
videoData = {date: 1375873140000};
dateGroup = new ThumbnailDateGroup(videoData, 'test-id');
domNode = dateGroup.htmlNode;
});
test('#normal', function() {
assert.equal(domNode.firstElementChild.textContent, 'August 2013');
});
});
suite('#API', function() {
var dateGroup;
var videoData;
var domNode;
var groupContainerNode;
setup(function() {
videoData = {date: 1375873140000};
dateGroup = new ThumbnailDateGroup(videoData);
domNode = dateGroup.htmlNode;
groupContainerNode = domNode.querySelector('.thumbnail-group-container');
});
test('#addItem, simple', function() {
assert.isUndefined(dateGroup.addItem());
var thumbnail = dateGroup.addItem({date: 1375873140005});
assert.isDefined(thumbnail);
assert.equal(dateGroup.getCount(), 1);
assert.equal(groupContainerNode.children[0], thumbnail.htmlNode);
});
test('#addItem, first, middle, last', function() {
// addItem, to empty
var thumbnail2 = dateGroup.addItem({date: 1375873140003});
assert.equal(dateGroup.getCount(), 1);
assert.equal(groupContainerNode.children[0], thumbnail2.htmlNode);
// addItem, to first
var thumbnail1 = dateGroup.addItem({date: 1375873140004});
assert.equal(dateGroup.getCount(), 2);
assert.equal(groupContainerNode.children[0], thumbnail1.htmlNode);
assert.equal(groupContainerNode.children[1], thumbnail2.htmlNode);
// addItem, to last
var thumbnail4 = dateGroup.addItem({date: 1375873140001});
assert.equal(dateGroup.getCount(), 3);
assert.isNull(thumbnail4.htmlNode.nextSibling);
assert.equal(groupContainerNode.children[2], thumbnail4.htmlNode);
// addItem to middle
var thumbnail3 = dateGroup.addItem({date: 1375873140002});
assert.equal(dateGroup.getCount(), 4);
assert.equal(groupContainerNode.children[2], thumbnail3.htmlNode);
assert.equal(groupContainerNode.children[3], thumbnail4.htmlNode);
// check from dummyContainer
assert.equal(groupContainerNode.children.length, 4);
});
test('#groupID', function() {
assert.equal(dateGroup.groupID, 'group_2013-08');
});
test('#htmlNode', function() {
assert.equal(dateGroup.htmlNode, domNode);
});
test('#removeItem, unknown thumbnail', function() {
dateGroup.addItem({date: 1375873140004});
dateGroup.addItem({date: 1375873140003});
var dummyThumbnail = new ThumbnailItem({});
dateGroup.removeItem(dummyThumbnail);
assert.equal(dateGroup.getCount(), 2);
});
test('#removeItem, remove all', function() {
var thumbnail1 = dateGroup.addItem({date: 1375873140004});
var thumbnail2 = dateGroup.addItem({date: 1375873140003});
var thumbnail3 = dateGroup.addItem({date: 1375873140002});
var thumbnail4 = dateGroup.addItem({date: 1375873140001});
dateGroup.removeItem(thumbnail1);
assert.equal(dateGroup.getCount(), 3);
assert.equal(groupContainerNode.children[0], thumbnail2.htmlNode);
assert.isUndefined(groupContainerNode.children[3]);
dateGroup.removeItem(thumbnail4);
assert.equal(dateGroup.getCount(), 2);
assert.equal(groupContainerNode.children[1], thumbnail3.htmlNode);
assert.isUndefined(groupContainerNode.children[2]);
dateGroup.removeItem(thumbnail3);
assert.equal(dateGroup.getCount(), 1);
assert.equal(groupContainerNode.children[0], thumbnail2.htmlNode);
assert.isUndefined(groupContainerNode.children[1]);
dateGroup.removeItem(thumbnail2);
assert.equal(dateGroup.getCount(), 0);
assert.isUndefined(groupContainerNode.children[0]);
});
});
});

View file

@ -0,0 +1,212 @@
/* global MockL10n,ThumbnailItem,l10nAssert */
/*
* Thumbnail Item tests
*/
'use strict';
require('/shared/js/sanitizer.js');
require('/shared/js/media/media_utils.js');
require('/shared/test/unit/mocks/mock_l20n.js');
requireApp('/video/js/thumbnail_item.js');
suite('Thumbnail Item Unit Tests', function() {
var nativeMozL10n;
suiteSetup(function() {
nativeMozL10n = document.l10n;
document.l10n = MockL10n;
});
suiteTeardown(function() {
document.l10n = nativeMozL10n;
});
suite('#test object creation', function() {
test('#empty video object', function() {
try {
new ThumbnailItem(); // jshint ignore:line
assert.fail('undefined or null videoitem should not be ok.');
} catch (ex) {
assert.ok('correct behavior caught');
}
});
});
suite('#error handling', function() {
var videodata;
suiteSetup(function() {
videodata = {
metadata: {
title: 'Small webm',
duration: 5.568,
width: 560,
height: 320,
watched: false
},
type: 'video/webm',
date: 1375873140000,
size: 229455,
name: 'dummy-file-name'
};
});
test('#partial fields of template inexistent', function() {
var thumbnail = new ThumbnailItem(videodata);
thumbnail.updatePoster(new Blob(['empty-image'], {'type': 'image/jpeg'}));
assert.ok('it is ok without any fields');
});
});
suite('#rendering', function() {
var videodata;
var thumbnail;
var domNode;
suiteSetup(function() {
videodata = {
metadata: {
title: 'Small webm',
duration: 5.568,
width: 560,
height: 320,
watched: false
},
type: 'video/webm',
date: 1375873140000,
size: 229455,
name: 'dummy-file-name'
};
thumbnail = new ThumbnailItem(videodata);
domNode = thumbnail.htmlNode;
});
test('#unwatched', function() {
assert.isDefined(domNode);
var unwatchedNode = domNode.querySelector('.unwatched');
// it is is unwatched now.
assert.isFalse(unwatchedNode.hidden);
});
test('#title', function() {
var titleNode = domNode.querySelector('.title');
assert.equal(titleNode.textContent, 'Small webm');
});
test('#duration-text', function() {
var durationNode = domNode.querySelector('.duration-text');
assert.equal(durationNode.textContent, '00:06');
});
test('#size-text', function() {
var sizeNode = domNode.querySelector('.size-text');
l10nAssert(
sizeNode,
'fileSize',
{size: 224, unit: 'KB'}
);
});
test('#type-text', function() {
var typeNode = domNode.querySelector('.type-text');
assert.equal(typeNode.textContent, 'webm');
});
});
suite('#api tests', function() {
var videodata;
var thumbnail;
var domNode;
suiteSetup(function() {
videodata = {
metadata: {
title: 'Small webm',
duration: 5.568,
width: 560,
height: 320,
watched: false,
poster: new Blob(['empty-image'], {'type': 'image/jpeg'})
},
type: 'video/webm',
date: 1375873140000,
size: 229455,
name: 'dummy-file-name'
};
thumbnail = new ThumbnailItem(videodata);
domNode = thumbnail.htmlNode;
});
test('#addTapListener and fired', function(done) {
function testFunc(video) {
assert.deepEqual(video, videodata);
thumbnail.removeTapListener(testFunc);
done();
}
thumbnail.addTapListener(testFunc);
domNode.click();
});
test('#removeTapListener and fired', function(done) {
function testFunc(video) {
assert.fail('event should not fired.');
}
thumbnail.addTapListener(testFunc);
thumbnail.removeTapListener(testFunc);
domNode.click();
window.setTimeout(done);
});
test('#addTapListener, empty argument', function() {
thumbnail.addTapListener();
assert.ok('everything should be ok');
});
test('#removeTapListener, empty argument', function() {
thumbnail.removeTapListener();
assert.ok('everything should be ok');
});
test('#addTapListener, object listener', function(done) {
function testFunc(video) {
assert.deepEqual(video, videodata);
thumbnail.removeTapListener(testFunc);
done();
}
thumbnail.addTapListener({handleEvent: testFunc});
domNode.click();
});
test('#overflow', function() {
var evt = new Event('overflow');
thumbnail.detailNode.dispatchEvent(evt);
assert.ok('everything should be ok');
});
test('#htmlNode', function() {
assert.equal(thumbnail.htmlNode, domNode);
});
test('#data', function() {
assert.deepEqual(thumbnail.data, videodata);
});
test('#watched', function() {
thumbnail.setWatched(false);
var node = domNode.querySelector('.unwatched');
assert.isFalse(node.hidden);
thumbnail.setWatched(true);
assert.isTrue(node.hidden);
});
test('#updatePoster', function() {
var blob = new Blob(['empty-image'], {'type': 'image/jpeg'});
thumbnail.updatePoster(blob);
var node = domNode.querySelector('.img');
assert.notEqual(node.src, '');
thumbnail.updatePoster(null);
assert.ok(node.src.endsWith('style/images/default_thumbnail.png'));
});
});
});

View file

@ -0,0 +1,164 @@
/* global MockL10n,MockThumbnailGroup,ThumbnailList */
/*
* Thumbnail List tests
*/
'use strict';
require('/shared/test/unit/mocks/mock_l20n.js');
requireApp('/video/test/unit/mock_thumbnail_group.js');
requireApp('/video/js/thumbnail_list.js');
suite('Thumbnail List Unit Tests', function() {
var realL10n;
suiteSetup(function() {
realL10n = document.l10n;
document.l10n = MockL10n;
});
suiteTeardown(function() {
document.l10n = realL10n;
});
suite('#constructor tests', function() {
var dummyContainer;
suiteSetup(function() {
MockThumbnailGroup.reset();
dummyContainer = document.createElement('div');
});
test('normal', function() {
new ThumbnailList(MockThumbnailGroup, dummyContainer); //jshint ignore:line
assert.ok('everything should be ok');
});
test('missing arguments', function() {
try {
new ThumbnailList(MockThumbnailGroup); //jshint ignore:line
assert.fail('it should throws an error.');
} catch (ex) {
assert.ok('everything should be ok');
}
try {
new ThumbnailList(); //jshint ignore:line
assert.fail('it should throws an error.');
} catch (ex) {
assert.ok('everything should be ok');
}
});
});
suite('#api tests', function() {
var dummyContainer;
var thumbnailList;
suiteSetup(function() {
MockThumbnailGroup.reset();
dummyContainer = document.createElement('div');
// we use mock thumbnail group which will not add any thumbnail item to
// container.
thumbnailList = new ThumbnailList(MockThumbnailGroup, dummyContainer);
});
test('#addItem, simple', function() {
MockThumbnailGroup._GroupID = '2013-07';
thumbnailList.addItem({'name': 'dummy-file-name-07.3gp'});
assert.equal(dummyContainer.firstChild.textContent, '2013-07');
assert.equal(thumbnailList.count, 1);
});
test('#addItem, first, middle, last', function() {
// first
MockThumbnailGroup._GroupID = '2013-09';
thumbnailList.addItem({'name': 'dummy-file-name-09.3gp'});
assert.equal(dummyContainer.firstChild.textContent, '2013-09');
assert.equal(thumbnailList.count, 2);
// middle
MockThumbnailGroup._GroupID = '2013-08';
thumbnailList.addItem({'name': 'dummy-file-name-08.3gp'});
assert.equal(dummyContainer.children[1].textContent, '2013-08');
assert.equal(thumbnailList.count, 3);
// last
MockThumbnailGroup._GroupID = '2013-06';
thumbnailList.addItem({'name': 'dummy-file-name-06.3gp'});
assert.equal(dummyContainer.children[3].textContent, '2013-06');
assert.equal(thumbnailList.count, 4);
});
test('#addItem, to existed group', function() {
// first
MockThumbnailGroup._GroupID = '2013-07';
thumbnailList.addItem({'name': 'dummy-file-name-07-2.3gp'});
assert.equal(thumbnailList.count, 5);
assert.equal(MockThumbnailGroup._GroupMap['2013-07'].getCount(), 2);
assert.equal(dummyContainer.children.length, 4);
});
test('#addItem, empty', function() {
// first
MockThumbnailGroup._GroupID = '2013-07';
thumbnailList.addItem(null);
assert.equal(thumbnailList.count, 5);
assert.equal(MockThumbnailGroup._GroupMap['2013-07'].getCount(), 2);
assert.equal(dummyContainer.children.length, 4);
});
test('#addItem, duplicated', function() {
// first
MockThumbnailGroup._GroupID = '2013-07';
thumbnailList.addItem({'name': 'dummy-file-name-07-2.3gp'});
assert.equal(thumbnailList.count, 5);
assert.equal(MockThumbnailGroup._GroupMap['2013-07'].getCount(), 2);
assert.equal(dummyContainer.children.length, 4);
});
test('#removeItem', function() {
MockThumbnailGroup._GroupID = '2013-07';
thumbnailList.removeItem('dummy-file-name-07-2.3gp');
assert.equal(thumbnailList.count, 4);
assert.equal(MockThumbnailGroup._GroupMap['2013-07'].getCount(), 1);
thumbnailList.removeItem('dummy-file-name-07.3gp');
assert.equal(thumbnailList.count, 3);
assert.equal(MockThumbnailGroup._GroupMap['2013-07'].getCount(), 0);
assert.equal(dummyContainer.children.length, 3);
});
test('#removeItem, empty', function() {
MockThumbnailGroup._GroupID = '2013-07';
thumbnailList.removeItem(null);
assert.equal(thumbnailList.count, 3);
});
test('#thumbnailMap', function() {
assert.isDefined(thumbnailList.thumbnailMap['dummy-file-name-06.3gp']);
});
test('#pick mode', function() {
thumbnailList.setPickMode(true);
assert.isTrue(thumbnailList.isPickMode());
assert.isTrue(dummyContainer.classList.contains('pick'));
thumbnailList.setPickMode(false);
assert.isFalse(thumbnailList.isPickMode());
assert.isFalse(dummyContainer.classList.contains('pick'));
});
test('#select mode', function() {
thumbnailList.setSelectMode(true);
assert.isTrue(thumbnailList.isSelectMode());
assert.isTrue(dummyContainer.classList.contains('select'));
thumbnailList.setSelectMode(false);
assert.isFalse(thumbnailList.isSelectMode());
assert.isFalse(dummyContainer.classList.contains('select'));
});
test('#reset', function() {
thumbnailList.reset();
assert.equal(thumbnailList.count, 0);
assert.isUndefined(thumbnailList.thumbnailMap['dummy-file-name-06.3gp']);
});
});
});

View file

@ -0,0 +1,143 @@
/* globals MockVideoPlayer, ForwardRewindController:true */
'use strict';
requireApp('/video/test/unit/mock_video_player.js');
requireApp('/video/js/forward_rewind_controller.js');
var pause;
suite('Video forward rewind Unit Tests', function() {
var seekForward;
var seekBackward;
var play;
var videoToolBar;
var player;
var videoDuration = 50;
var pauseSpy;
var fakeTimer;
suiteSetup(function() {
videoToolBar = document.createElement('div');
seekForward = document.createElement('button');
seekBackward = document.createElement('button');
play = document.createElement('button');
videoToolBar.appendChild(seekBackward);
videoToolBar.appendChild(play);
videoToolBar.appendChild(seekForward);
player = new MockVideoPlayer();
player.setDuration(videoDuration);
ForwardRewindController.init(player, seekForward,
seekBackward);
pauseSpy = sinon.spy();
pause = pauseSpy;
fakeTimer = sinon.useFakeTimers();
});
suiteTeardown(function() {
ForwardRewindController.uninit(player, seekForward,
seekBackward);
});
suite('#Video Forward/Rewind Test', function() {
var simulateClick = function(button) {
var evt = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true
});
button.dispatchEvent(evt);
};
var simulateContextMenu = function(button) {
var ev = document.createEvent('MouseEvents');
ev.initMouseEvent('contextmenu', true, false, window, 0, 0, 0, 0, 0,
false, false, false, false, 1, null);
button.dispatchEvent(ev);
};
setup(function() {
pauseSpy.reset();
});
test('#Test Playing Video Forward Button Tap Test', function() {
simulateClick(seekForward);
assert.equal(player.currentTime, 10);
});
test('#Test Playing Video Rewind Button Tap Test', function() {
simulateClick(seekBackward);
assert.equal(player.currentTime, 0);
});
test('#Test Playing Video Play Button Tap Test', function() {
simulateClick(play);
simulateClick(seekForward);
assert.equal(player.currentTime, 10);
simulateClick(seekBackward);
assert.equal(player.currentTime, 0);
});
test('#Test Playing Video Play Button Tap Test, wrap', function() {
player.currentTime = videoDuration - 5;
simulateClick(play);
simulateClick(seekForward);
assert.equal(player.currentTime, videoDuration);
assert.isTrue(pauseSpy.calledOnce);
});
test('#Test Playing Video Rewind Button Tap Test, wrap', function() {
player.currentTime = 5;
simulateClick(play);
simulateClick(seekBackward);
assert.equal(player.currentTime, 0);
assert.equal(pauseSpy.callCount, 0);
});
test('#Test Playing Video Forward Button Hold Test', function() {
var currentTime = player.currentTime;
simulateContextMenu(seekForward);
fakeTimer.tick(1500);
ForwardRewindController.stopFastSeeking();
assert.equal(player.currentTime, currentTime + 10);
assert.equal(pauseSpy.callCount, 0);
});
test('#Test Playing Video Forward Button Hold Test, wrap', function() {
player.currentTime = videoDuration - 5;
simulateContextMenu(seekForward);
fakeTimer.tick(1500);
ForwardRewindController.stopFastSeeking();
assert.equal(player.currentTime, videoDuration);
assert.isTrue(pauseSpy.calledOnce);
});
test('#Test Playing Video Rewind Button Hold Test', function() {
player.currentTime = 20;
simulateContextMenu(seekBackward);
fakeTimer.tick(1500);
ForwardRewindController.stopFastSeeking();
assert.equal(player.currentTime, 10);
assert.equal(pauseSpy.callCount, 0);
});
test('#Test Playing Video Rewind Button Hold Test, wrap', function() {
player.currentTime = 5;
simulateContextMenu(seekBackward);
fakeTimer.tick(1500);
ForwardRewindController.stopFastSeeking();
assert.equal(player.currentTime, 0);
assert.equal(pauseSpy.callCount, 0);
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,303 @@
/* global VideoUtils */
'use strict';
requireApp('/video/js/video_utils.js');
suite('Video Utils Unit Tests', function() {
suite('#fitContiner tests - 4:3 video fit HVGA', function() {
test('fit 640x480 to HVGA, 0deg', function() {
var testPlayer = {videoWidth: 640, videoHeight: 480, style: {}};
VideoUtils.fitContainer({clientWidth: 320, clientHeight: 480},
testPlayer, 0);
assert.equal(testPlayer.style.transform,
'translate(0.0000px,120.0000px) scale(0.5000)');
});
test('fit 640x480 to HVGA, 90deg', function() {
var testPlayer = {videoWidth: 640, videoHeight: 480, style: {}};
VideoUtils.fitContainer({clientWidth: 320, clientHeight: 480},
testPlayer, 90);
assert.equal(testPlayer.style.transform,
'translate(320.0000px,26.6667px) rotate(90deg) scale(0.6667)');
});
test('fit 640x480 to HVGA, 180deg', function() {
var testPlayer = {videoWidth: 640, videoHeight: 480, style: {}};
VideoUtils.fitContainer({clientWidth: 320, clientHeight: 480},
testPlayer, 180);
assert.equal(testPlayer.style.transform,
'translate(320.0000px,360.0000px) rotate(180deg) scale(0.5000)');
});
test('fit 640x480 to HVGA, 270deg', function() {
var testPlayer = {videoWidth: 640, videoHeight: 480, style: {}};
VideoUtils.fitContainer({clientWidth: 320, clientHeight: 480},
testPlayer, 270);
assert.equal(testPlayer.style.transform,
'translate(0.0000px,453.3333px) rotate(270deg) scale(0.6667)');
});
test('fit 640x480 to HVGA landscape, 0deg', function() {
var testPlayer = {videoWidth: 640, videoHeight: 480, style: {}};
VideoUtils.fitContainer({clientWidth: 480, clientHeight: 320},
testPlayer, 0);
assert.equal(testPlayer.style.transform,
'translate(26.6667px,0.0000px) scale(0.6667)');
});
test('fit 640x480 to HVGA landscape, 90deg', function() {
var testPlayer = {videoWidth: 640, videoHeight: 480, style: {}};
VideoUtils.fitContainer({clientWidth: 480, clientHeight: 320},
testPlayer, 90);
assert.equal(testPlayer.style.transform,
'translate(360.0000px,0.0000px) rotate(90deg) scale(0.5000)');
});
test('fit 320x240 to HVGA, 0deg, no scale', function() {
var testPlayer = {videoWidth: 320, videoHeight: 240, style: {}};
VideoUtils.fitContainer({clientWidth: 320, clientHeight: 480},
testPlayer, 0);
assert.equal(testPlayer.style.transform,
'translate(0.0000px,120.0000px) scale(1.0000)');
});
test('fit 240x180 to HVGA, 0deg, scale up', function() {
var testPlayer = {videoWidth: 240, videoHeight: 180, style: {}};
VideoUtils.fitContainer({clientWidth: 320, clientHeight: 480},
testPlayer, 0);
assert.equal(testPlayer.style.transform,
'translate(0.0000px,120.0000px) scale(1.3333)');
});
test('fit 320x480 to HVGA, 0deg, 100% fit', function() {
var testPlayer = {videoWidth: 320, videoHeight: 480, style: {}};
VideoUtils.fitContainer({clientWidth: 320, clientHeight: 480},
testPlayer, 0);
assert.equal(testPlayer.style.transform,
'translate(0.0000px,0.0000px) scale(1.0000)');
});
});
suite('#fitContiner tests - 16:9 video fit HVGA', function() {
test('fit 720p to HVGA, 0deg', function() {
var testPlayer = {videoWidth: 1280, videoHeight: 720, style: {}};
VideoUtils.fitContainer({clientWidth: 320, clientHeight: 480},
testPlayer, 0);
assert.equal(testPlayer.style.transform,
'translate(0.0000px,150.0000px) scale(0.2500)');
});
test('fit 720p to HVGA, 90deg', function() {
var testPlayer = {videoWidth: 1280, videoHeight: 720, style: {}};
VideoUtils.fitContainer({clientWidth: 320, clientHeight: 480},
testPlayer, 90);
assert.equal(testPlayer.style.transform,
'translate(295.0000px,0.0000px) rotate(90deg) scale(0.3750)');
});
test('fit 720p to HVGA, 180deg', function() {
var testPlayer = {videoWidth: 1280, videoHeight: 720, style: {}};
VideoUtils.fitContainer({clientWidth: 320, clientHeight: 480},
testPlayer, 180);
assert.equal(testPlayer.style.transform,
'translate(320.0000px,330.0000px) rotate(180deg) scale(0.2500)');
});
test('fit 720p to HVGA, 270deg', function() {
var testPlayer = {videoWidth: 1280, videoHeight: 720, style: {}};
VideoUtils.fitContainer({clientWidth: 320, clientHeight: 480},
testPlayer, 270);
assert.equal(testPlayer.style.transform,
'translate(25.0000px,480.0000px) rotate(270deg) scale(0.3750)');
});
test('fit 720p to HVGA landscape, 0deg', function() {
var testPlayer = {videoWidth: 1280, videoHeight: 720, style: {}};
VideoUtils.fitContainer({clientWidth: 480, clientHeight: 320},
testPlayer, 0);
assert.equal(testPlayer.style.transform,
'translate(0.0000px,25.0000px) scale(0.3750)');
});
test('fit 720p to HVGA landscape, 90deg', function() {
var testPlayer = {videoWidth: 1280, videoHeight: 720, style: {}};
VideoUtils.fitContainer({clientWidth: 480, clientHeight: 320},
testPlayer, 90);
assert.equal(testPlayer.style.transform,
'translate(330.0000px,0.0000px) rotate(90deg) scale(0.2500)');
});
});
suite('getTruncated tests', function() {
var _clientHeight;
var titleNode;
var inputTitleText;
suiteSetup(function() {
titleNode = document.createElement('span');
// override native clientHeight
_clientHeight = Object.getOwnPropertyDescriptor(
Element.prototype, 'clientHeight');
Object.defineProperty(Element.prototype, 'clientHeight', {
configurable: true,
enumerable: true,
get: function videoUtilsTestClientHeightStub() {
function getPaddingInt(paddingInput) {
var paddingInt = parseInt(paddingInput, 10);
return Number.isInteger(paddingInt) ? paddingInt : 0;
}
//
// the video_utils getTruncated function uses clientHeight
// to determine the height of the title text. clientHeight
// includes padding, therfore, we have tests that set
// padding on the title node to test that getTruncated
// filters out the padding when determine the line height.
//
var padding = getPaddingInt(this.style.paddingTop) +
getPaddingInt(this.style.paddingBottom);
if (this.textContent.length >= 60) {
return 6 + padding;
}
if (this.textContent.length >= 50) {
return 5 + padding;
}
if (this.textContent.length >= 40) {
return 4 + padding;
}
if (this.textContent.length >= 30) {
return 3 + padding;
}
if (this.textContent.length >= 20) {
return 2 + padding;
}
if (this.textContent === '.') {
return 1 + padding;
}
return 1 + padding;
}
});
});
suiteTeardown(function() {
Object.defineProperty(Element.prototype, 'clientHeight', _clientHeight);
});
setup(function() {
titleNode.style.visibility = 'visible';
titleNode.style.wordBreak = 'normal';
titleNode.style.paddingTop = '';
titleNode.style.paddingBottom = '';
});
test('title fits within max lines, maxLine specified', function() {
inputTitleText = '1111111111222222222233333333334444444444';
var truncatedTitle = VideoUtils.getTruncated(inputTitleText,
{
node: titleNode,
maxLine: 4
});
assert.equal(inputTitleText, truncatedTitle);
assert.equal(titleNode.style.visibility, 'visible');
assert.equal(titleNode.style.wordBreak, 'normal');
});
test('title fits within max lines, maxLine specified, padding', function() {
inputTitleText = '1111111111222222222233333333334444444444';
titleNode.style.paddingTop = '4px';
titleNode.style.paddingBottom = '4px';
var truncatedTitle = VideoUtils.getTruncated(inputTitleText,
{
node: titleNode,
maxLine: 4
});
assert.equal(inputTitleText, truncatedTitle);
assert.equal(titleNode.style.visibility, 'visible');
assert.equal(titleNode.style.wordBreak, 'normal');
});
test('title fits within max lines, maxLine not specified', function() {
inputTitleText = '11111111112222222222';
var truncatedTitle = VideoUtils.getTruncated(inputTitleText,
{
node: titleNode
});
assert.equal(inputTitleText, truncatedTitle);
assert.equal(titleNode.style.visibility, 'visible');
assert.equal(titleNode.style.wordBreak, 'normal');
});
test('title doesnt fit, maxLine not specified', function() {
inputTitleText = '111111111122222222223333333333';
var expectedTruncatedTitle = '11111111112222222222333333...';
var truncatedTitle = VideoUtils.getTruncated(inputTitleText,
{
node: titleNode
});
assert.equal(truncatedTitle, expectedTruncatedTitle);
assert.equal(titleNode.style.visibility, 'visible');
assert.equal(titleNode.style.wordBreak, 'normal');
});
test('title doesnt fit, maxLine specified', function() {
inputTitleText = '1111111111222222222233333333334444444444';
var expectedTruncatedTitle = '111111111122222222223333333333444444...';
var truncatedTitle = VideoUtils.getTruncated(inputTitleText,
{
node: titleNode,
maxLine: 3
});
assert.equal(truncatedTitle, expectedTruncatedTitle);
assert.equal(titleNode.style.visibility, 'visible');
assert.equal(titleNode.style.wordBreak, 'normal');
});
test('title doesnt fit, maxLine specified, padding', function() {
titleNode.style.paddingTop = '4px';
titleNode.style.paddingBottom = '4px';
inputTitleText = '1111111111222222222233333333334444444444';
var expectedTruncatedTitle = '111111111122222222223333333333444444...';
var truncatedTitle = VideoUtils.getTruncated(inputTitleText,
{
node: titleNode,
maxLine: 3
});
assert.equal(truncatedTitle, expectedTruncatedTitle);
assert.equal(titleNode.style.visibility, 'visible');
assert.equal(titleNode.style.wordBreak, 'normal');
});
test('node is null', function() {
var node = null;
var inputTitleText;
var truncatedTitle = VideoUtils.getTruncated(inputTitleText,
{
node: node,
maxLine: 4,
ellipsisIndex: 0
});
assert.equal(inputTitleText, truncatedTitle);
assert.equal(titleNode.style.visibility, 'visible');
assert.equal(titleNode.style.wordBreak, 'normal');
});
});
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

102
gaia/apps/video/view.html Executable file
View file

@ -0,0 +1,102 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<meta charset="utf-8" />
<title>Videos</title>
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="locales/video.{locale}.properties">
<link rel="stylesheet" type="text/css" href="style/video.css" />
<link rel="stylesheet" type="text/css" href="style/spinner.css" />
<link rel="stylesheet" type="text/css" href="shared/style/status.css" />
<link rel="stylesheet" type="text/css" href="style/video_tablet.css" media="(min-height: 768px) and (min-width: 768px)" />
</head>
<body role="application" class="skin-dark layout-fullscreen-player view-activity theme-media">
<!-- display this at startup while we create thumbnails -->
<div id="spinner-overlay" class="hidden" role="progress"
data-l10n-id="spinner-overlay"><div id="spinner"></div></div>
<section role="region" id="player-view" class="video-controls-hidden">
<!-- video controls header -->
<gaia-header ignore-dir id="player-header" action="back" class="video-controls">
<h1 id="video-title"></h1>
<button id="save" data-l10n-id="save" hidden></button>
</gaia-header>
<!-- In order for the screen reader to be able to activate the container
to toggle controls, it must have the semantics of the button -->
<div id="video-container" role="button" tabindex="0">
<!-- Hiding the video element from the screen reader since the
controls are implemented elsewhere. -->
<video id="player" preload="metadata" aria-hidden="true"></video>
</div>
<!-- video controls footer toolbar -->
<div role="toolbar" class="video-controls bottom">
<!-- It is simpler and less verbose (for the screen reader) to not
expose that this block is a footer and consider it part of
its toolbar container. -->
<footer id="videoBar" role="presentation">
<div id="timeSlider" role="slider">
<!-- We need to use aria hideen for the text nodes in the slider
subtree because the screen reader treats sliders as atomic
elements and does not step inside their subtree. All the
information is conveyed via the slider attributes instead -->
<span id="elapsed-text" aria-hidden="true"></span>
<div id="slider-wrapper" aria-hidden="true">
<div id="elapsedTime" class="progress"></div>
<div id="bufferedTime" class="progress"></div>
<div id="timeBackground" class="progress"></div>
<button id="playHead"></button>
</div>
<span id="duration-text" aria-hidden="true"></span>
</div>
</footer>
<!-- It is simpler and less verbose (for the screen reader) to not
expose that this block is a footer and consider it part of
its toolbar container. -->
<footer id="videoControlBar" role="presentation">
<div id="videoToolBar">
<button id="seek-backward" class="player-controls-button"
data-icon="skip-back" data-l10n-id="seek-backward"></button>
<button id="play" data-l10n-id="pause-button" class="player-controls-button" data-icon="pause"></button>
<button id="seek-forward" class="player-controls-button"
data-icon="skip-forward" data-l10n-id="seek-forward"></button>
</div>
</footer>
</div>
<!-- display messages like "Cannot load video" here -->
<form id="in-use-overlay" role="dialog" data-type="confirm" class="hidden">
<section id="in-use-overlay-content">
<h1 id="in-use-overlay-title"></h1>
<p id="in-use-overlay-text"></p>
</section>
</form>
</section>
<section id="banner" role="status" hidden>
<p id="message"></p>
</section>
<!-- Shared Libs -->
<script src="shared/js/intl/l20n.js"></script>
<script src="shared/js/device_storage/get_storage_if_available.js"></script>
<script src="shared/js/device_storage/get_unused_filename.js"></script>
<script defer src="shared/js/blobview.js"></script>
<script defer src="shared/js/mime_mapper.js"></script>
<script defer src="shared/js/media/get_video_rotation.js"></script>
<script defer src="shared/js/media/media_utils.js"></script>
<!-- Web Components -->
<script defer src="shared/elements/config.js"></script>
<link rel="stylesheet" type="text/css" href="shared/elements/gaia-icons/gaia-icons.css">
<link rel="stylesheet" type="text/css" href="app://theme.gaiamobile.org/shared/elements/gaia-theme/gaia-theme.css">
<script defer src="shared/elements/gaia-header/dist/gaia-header.js"></script>
<!-- App Code -->
<script defer src="js/video_loading_checker.js"></script>
<script defer src="js/video_utils.js"></script>
<script defer src="js/forward_rewind_controller.js"></script>
<script defer src="js/seeker.js"></script>
<script defer src="js/view.js"></script>
</body>
</html>