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.
208
gaia/apps/video/index.html
Executable 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
|
|
@ -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
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
127
gaia/apps/video/js/forward_rewind_controller.js
Executable 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
|
|
@ -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
|
|
@ -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));
|
||||
156
gaia/apps/video/js/thumbnail_date_group.js
Executable 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(); });
|
||||
};
|
||||
216
gaia/apps/video/js/thumbnail_item.js
Executable 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);
|
||||
});
|
||||
}
|
||||
};
|
||||
235
gaia/apps/video/js/thumbnail_list.js
Executable 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
85
gaia/apps/video/js/video_loading_checker.js
Executable 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
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
99
gaia/apps/video/locales/video.en-US.properties
Executable 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
|
|
@ -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
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"webide": {
|
||||
"prepackage": {
|
||||
"command": "make",
|
||||
"env": ["APP=video"],
|
||||
"cwd": "../.."
|
||||
},
|
||||
"packageDir": "../../build_stage/video/"
|
||||
}
|
||||
}
|
||||
BIN
gaia/apps/video/style/icons/video_126.png
Executable file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
gaia/apps/video/style/icons/video_142.png
Executable file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
gaia/apps/video/style/icons/video_189.png
Executable file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
gaia/apps/video/style/icons/video_284.png
Executable file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
gaia/apps/video/style/icons/video_84.png
Executable file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
gaia/apps/video/style/images/default_thumbnail.png
Executable file
|
After Width: | Height: | Size: 530 B |
BIN
gaia/apps/video/style/images/default_thumbnail@1.5x.png
Executable file
|
After Width: | Height: | Size: 665 B |
BIN
gaia/apps/video/style/images/default_thumbnail@2.25x.png
Executable file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
gaia/apps/video/style/images/default_thumbnail@2x.png
Executable file
|
After Width: | Height: | Size: 872 B |
BIN
gaia/apps/video/style/images/spinner.png
Executable file
|
After Width: | Height: | Size: 1,016 B |
BIN
gaia/apps/video/style/images/spinner@1.5x.png
Executable file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
gaia/apps/video/style/images/spinner@2.25x.png
Executable file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
gaia/apps/video/style/images/spinner@2x.png
Executable file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
gaia/apps/video/style/images/tablet_button_separator.png
Executable file
|
After Width: | Height: | Size: 198 B |
BIN
gaia/apps/video/style/images/tablet_column_separator.png
Executable file
|
After Width: | Height: | Size: 134 B |
BIN
gaia/apps/video/style/images/tablet_fullscreen.png
Executable file
|
After Width: | Height: | Size: 584 B |
BIN
gaia/apps/video/style/images/tablet_fullscreen_pressed.png
Executable file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
gaia/apps/video/style/images/tablet_header.png
Executable file
|
After Width: | Height: | Size: 128 B |
BIN
gaia/apps/video/style/images/tablet_header_pressed.png
Executable file
|
After Width: | Height: | Size: 130 B |
BIN
gaia/apps/video/style/images/tablet_pause.png
Executable file
|
After Width: | Height: | Size: 321 B |
BIN
gaia/apps/video/style/images/tablet_pause_pressed.png
Executable file
|
After Width: | Height: | Size: 1 KiB |
BIN
gaia/apps/video/style/images/tablet_play.png
Executable file
|
After Width: | Height: | Size: 615 B |
BIN
gaia/apps/video/style/images/tablet_play_pressed.png
Executable file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
gaia/apps/video/style/images/tablet_player_mask.png
Executable file
|
After Width: | Height: | Size: 93 B |
BIN
gaia/apps/video/style/images/tablet_scrubber.png
Executable file
|
After Width: | Height: | Size: 660 B |
BIN
gaia/apps/video/style/images/tablet_scrubber_pressed.png
Executable file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
gaia/apps/video/style/images/tablet_section_divider.png
Executable file
|
After Width: | Height: | Size: 131 B |
BIN
gaia/apps/video/style/images/tablet_text_mask.png
Executable file
|
After Width: | Height: | Size: 104 B |
BIN
gaia/apps/video/style/images/tablet_video_divider.png
Executable file
|
After Width: | Height: | Size: 161 B |
BIN
gaia/apps/video/style/images/unwatched.png
Executable file
|
After Width: | Height: | Size: 147 B |
BIN
gaia/apps/video/style/images/unwatched@1.5x.png
Executable file
|
After Width: | Height: | Size: 177 B |
BIN
gaia/apps/video/style/images/unwatched@2.25x.png
Executable file
|
After Width: | Height: | Size: 284 B |
BIN
gaia/apps/video/style/images/unwatched@2x.png
Executable file
|
After Width: | Height: | Size: 190 B |
48
gaia/apps/video/style/info.css
Executable 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;
|
||||
}
|
||||
38
gaia/apps/video/style/spinner.css
Executable 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
|
|
@ -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;
|
||||
}
|
||||
739
gaia/apps/video/style/video_tablet.css
Executable 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;
|
||||
}
|
||||
27
gaia/apps/video/test/build/integration/video_test.js
Executable 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
17
gaia/apps/video/test/marionette/.jshintrc
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": "../../../../.jshintrc",
|
||||
"predef": [
|
||||
"suite",
|
||||
"test",
|
||||
"setup",
|
||||
"teardown",
|
||||
"suiteSetup",
|
||||
"suiteTeardown",
|
||||
"marionette",
|
||||
"marionetteScriptFinished",
|
||||
"require",
|
||||
"define",
|
||||
"requireApp",
|
||||
"sinon"
|
||||
]
|
||||
}
|
||||
67
gaia/apps/video/test/marionette/info_view_test.js
Executable 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');
|
||||
});
|
||||
});
|
||||
25
gaia/apps/video/test/marionette/lib/mocks/mock_window_mozactivity.js
Executable 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);
|
||||
294
gaia/apps/video/test/marionette/lib/video.js
Executable 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');
|
||||
}
|
||||
};
|
||||
115
gaia/apps/video/test/marionette/lib/video_info_view.js
Executable 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);
|
||||
89
gaia/apps/video/test/marionette/lib/video_options_view.js
Executable 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);
|
||||
29
gaia/apps/video/test/marionette/overlay_test.js
Executable 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');
|
||||
});
|
||||
});
|
||||
68
gaia/apps/video/test/marionette/play_multiple_types_test.js
Executable 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
89
gaia/apps/video/test/marionette/video_list_test.js
Executable 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/*.'
|
||||
);
|
||||
});
|
||||
*/
|
||||
});
|
||||
17
gaia/apps/video/test/unit/.jshintrc
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": "../../../../.jshintrc",
|
||||
"predef": [
|
||||
"assert",
|
||||
"suite",
|
||||
"test",
|
||||
"setup",
|
||||
"teardown",
|
||||
"suiteSetup",
|
||||
"suiteTeardown",
|
||||
"require",
|
||||
"requireApp",
|
||||
"sinon",
|
||||
"mocha",
|
||||
"loadBodyHTML"
|
||||
]
|
||||
}
|
||||
65
gaia/apps/video/test/unit/captions_test.js
Executable 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);
|
||||
});
|
||||
});
|
||||
BIN
gaia/apps/video/test/unit/media/test.webm
Executable file
15
gaia/apps/video/test/unit/mock_forward_rewind_controller.js
Executable 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
|
||||
};
|
||||
|
||||
}());
|
||||
32
gaia/apps/video/test/unit/mock_mediadb.js
Executable 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;
|
||||
|
||||
}());
|
||||
19
gaia/apps/video/test/unit/mock_metadata.js
Executable 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();
|
||||
}
|
||||
48
gaia/apps/video/test/unit/mock_thumbnail_group.js
Executable 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;
|
||||
};
|
||||
15
gaia/apps/video/test/unit/mock_thumbnail_item.js
Executable 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() {}
|
||||
};
|
||||
};
|
||||
27
gaia/apps/video/test/unit/mock_video_loading_checker.js
Executable 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() {},
|
||||
};
|
||||
|
||||
70
gaia/apps/video/test/unit/mock_video_player.js
Executable 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 [];
|
||||
};
|
||||
136
gaia/apps/video/test/unit/seeker_test.js
Executable 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
3
gaia/apps/video/test/unit/setup.js
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
'use strict';
|
||||
|
||||
require('/shared/test/unit/mocks/mocks_helper.js');
|
||||
181
gaia/apps/video/test/unit/thumbnail_date_group_test.js
Executable 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
212
gaia/apps/video/test/unit/thumbnail_item_test.js
Executable 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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
164
gaia/apps/video/test/unit/thumbnail_list_test.js
Executable 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
143
gaia/apps/video/test/unit/video_forward_rewind_test.js
Executable 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
2301
gaia/apps/video/test/unit/video_test.js
Executable file
303
gaia/apps/video/test/unit/video_utils_test.js
Executable 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
BIN
gaia/apps/video/test/videos/test_mp4_video.mp4
Executable file
BIN
gaia/apps/video/test/videos/test_ogg_video.ogg
Executable file
BIN
gaia/apps/video/test/videos/test_ogv_video.ogv
Executable file
BIN
gaia/apps/video/test/videos/test_webm_video.webm
Executable file
102
gaia/apps/video/view.html
Executable 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>
|
||||