Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

22
gaia/tv_apps/tv-deck/bower.json Executable file
View file

@ -0,0 +1,22 @@
{
"name": "tv-deck",
"version": "0.0.0",
"homepage": "https://github.com/mozilla-b2g/gaia/tree/master/tv_apps/tv-deck",
"description": "Smart Screen Apps Deck",
"main": "index.html",
"license": "MPL",
"private": true,
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"test",
"tests"
],
"dependencies": {
"evt": "fxos-components/evt#~0.4.0",
"smart-button": "fxos-components/smart-button",
"smart-bubbles": "smart-components/smart-bubbles",
"smart-icons": "smart-components/smart-icons"
}
}

View file

@ -0,0 +1,28 @@
{
"name": "evt",
"main": "index.js",
"version": "0.4.0",
"homepage": "https://github.com/wilsonpage/evt",
"authors": [
"Wilson Page <wilsonpage@me.com>"
],
"license": "MIT",
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"package.json",
"test",
"tests",
"README.md"
],
"_release": "0.4.0",
"_resolution": {
"type": "version",
"tag": "v0.4.0",
"commit": "3593dd6b4dd19f683d00fd0eaf2581c11a128da6"
},
"_source": "git://github.com/gaia-components/evt.git",
"_target": "~0.4.0",
"_originalSource": "gaia-components/evt"
}

View file

@ -0,0 +1,19 @@
{
"name": "evt",
"main": "index.js",
"version": "0.4.0",
"homepage": "https://github.com/wilsonpage/evt",
"authors": [
"Wilson Page <wilsonpage@me.com>"
],
"license": "MIT",
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"package.json",
"test",
"tests",
"README.md"
]
}

View file

@ -0,0 +1,154 @@
/**
* Evt
*
* A super lightweight
* event emitter library.
*
* @version 0.3.3
* @author Wilson Page <wilson.page@me.com>
*/
;(function() {
/**
* Locals
*/
var proto = Events.prototype;
var slice = [].slice;
/**
* Creates a new event emitter
* instance, or if passed an
* object, mixes the event logic
* into it.
*
* @param {Object} obj
* @return {Object}
*/
function Events(obj) {
if (!(this instanceof Events)) return new Events(obj);
if (obj) return mixin(obj, proto);
}
/**
* Registers a callback
* with an event name.
*
* @param {String} name
* @param {Function} cb
* @return {Event}
*/
proto.on = function(name, cb) {
this._cbs = this._cbs || {};
(this._cbs[name] || (this._cbs[name] = [])).push(cb);
return this;
};
/**
* Attach a callback that once
* called, detaches itself.
*
* TODO: Implement `.off()` to work
* with `once()` callbacks.
*
* @param {String} name
* @param {Function} cb
* @public
*/
proto.once = function(name, cb) {
this.on(name, one);
function one() {
cb.apply(this, arguments);
this.off(name, one);
}
};
/**
* Removes a single callback,
* or all callbacks associated
* with the passed event name.
*
* @param {String} name
* @param {Function} cb
* @return {Event}
*/
proto.off = function(name, cb) {
this._cbs = this._cbs || {};
if (!name) { this._cbs = {}; return; }
if (!cb) { return delete this._cbs[name]; }
var cbs = this._cbs[name] || [];
var i;
while (cbs && ~(i = cbs.indexOf(cb))) { cbs.splice(i, 1); }
return this;
};
/**
* Fires an event, triggering
* all callbacks registered on this
* event name.
*
* @param {String} name
* @return {Event}
*/
proto.fire = proto.emit = function(options) {
var cbs = this._cbs = this._cbs || {};
var name = options.name || options;
var batch = (cbs[name] || []).concat(cbs['*'] || []);
var ctx = options.ctx || this;
if (batch.length) {
this._fireArgs = arguments;
var args = slice.call(arguments, 1);
while (batch.length) {
batch.shift().apply(ctx, args);
}
}
return this;
};
proto.firer = function(name) {
var self = this;
return function() {
var args = slice.call(arguments);
args.unshift(name);
self.fire.apply(self, args);
};
};
/**
* Util
*/
/**
* Mixes in the properties
* of the second object into
* the first.
*
* @param {Object} a
* @param {Object} b
* @return {Object}
*/
function mixin(a, b) {
for (var key in b) a[key] = b[key];
return a;
}
/**
* Expose 'Event' (UMD)
*/
if (typeof exports === 'object') {
module.exports = Events;
} else if (typeof define === 'function' && define.amd) {
define(function(){ return Events; });
} else {
window.evt = Events;
}
})();

View file

@ -0,0 +1,25 @@
{
"name": "smart-bubbles",
"homepage": "https://github.com/smart-components/smart-bubbles",
"main": "script.js",
"license": "MPL",
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"test",
"tests",
"examples",
"LICENSE",
"README.md"
],
"_release": "600518ce80",
"_resolution": {
"type": "branch",
"branch": "master",
"commit": "600518ce807d8f8cddc0d0553f61738698df98b8"
},
"_source": "git://github.com/smart-components/smart-bubbles.git",
"_target": "*",
"_originalSource": "smart-components/smart-bubbles"
}

View file

@ -0,0 +1,17 @@
{
"name": "smart-bubbles",
"version": "0.0.0",
"homepage": "https://github.com/smart-components/smart-bubbles",
"main": "script.js",
"license": "MPL",
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"test",
"tests",
"examples",
"LICENSE",
"README.md"
]
}

View file

@ -0,0 +1,74 @@
'use strict';
window.SmartBubbles = (function(win) {
var DEFAULT_INTERVAL = 100;
// Extend from the HTMLElement prototype
var proto = Object.create(HTMLElement.prototype);
proto.fireEvent = function(event, detail) {
var evtObject = new CustomEvent(event, {
bubbles: false,
detail: detail || this
});
this.dispatchEvent(evtObject);
};
proto.handleEvent = function(evt) {
switch(evt.type) {
case 'animationend':
evt.currentTarget.classList.remove('smart-bubble-playing');
evt.currentTarget.classList.add('smart-bubble-stopped');
window.getComputedStyle(evt.currentTarget).animationName;
evt.currentTarget.classList.remove('smart-bubble-stopped');
window.getComputedStyle(evt.currentTarget).animationName;
this.fireEvent('item-bubbled', ++this._finishedAnimationCount);
if (this._targetAnimationCount === this._finishedAnimationCount) {
this.playing = false;
this._elements = null;
this.fireEvent('all-items-bubbled');
}
evt.currentTarget.removeEventListener('animationend', this);
break;
}
};
proto.play = function(elements) {
this.stopImmediately();
this._elements = elements;
this._finishedAnimationCount = 0;
this._targetAnimationCount = 0;
var interval = parseInt(this.getAttribute('interval'), 10) ||
DEFAULT_INTERVAL;
[].forEach.call(elements, function(elem) {
this._targetAnimationCount++;
var delay = this._targetAnimationCount * interval / 1000;
elem.classList.add('smart-bubble-playing');
elem.style.animationDelay = delay + 's';
elem.addEventListener('animationend', this);
}.bind(this));
this.playing = true;
};
proto.stopImmediately = function() {
if (!this.playing || !this._elements) {
return;
}
[].forEach.call(this._elements, function(elem) {
elem.classList.remove('smart-bubble-playing');
elem.classList.add('smart-bubble-stopped');
elem.removeEventListener('animationend', this);
// use getComputedStyle to force flushing re-style
window.getComputedStyle(elem).animationName;
elem.classList.remove('smart-bubble-stopped');
}.bind(this));
this._elements = null;
this.playing = false;
this.fireEvent('all-items-stopped');
};
// Register and return the constructor
return document.registerElement('smart-bubbles', { prototype: proto });
})(window);

View file

@ -0,0 +1,18 @@
*[smart-bubbles="true"] .smart-bubble-playing {
transition-property: none;
transform: none;
animation: bubble-animation .5s cubic-bezier(0.25, 0, 0, 1);
animation-fill-mode: backwards;
}
*[smart-bubbles="true"] .smart-bubble-stopped {
transition-property: none;
transform: none;
animation: none;
}
@keyframes bubble-animation {
0% {opacity: 0; transform: translateY(8rem);}
50% {opacity: 1;}
100% {transform: translateY(0);}
}

View file

@ -0,0 +1,25 @@
{
"name": "smart-button",
"homepage": "https://github.com/gaia-components/smart-button",
"main": "script.js",
"license": "MPL",
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"test",
"tests",
"examples",
"LICENSE",
"README.md"
],
"_release": "a13686c562",
"_resolution": {
"type": "branch",
"branch": "master",
"commit": "a13686c5620b996e254764eb9b6993c9555da862"
},
"_source": "git://github.com/gaia-components/smart-button.git",
"_target": "*",
"_originalSource": "gaia-components/smart-button"
}

View file

@ -0,0 +1,17 @@
{
"name": "smart-button",
"version": "0.0.0",
"homepage": "https://github.com/gaia-components/smart-button",
"main": "script.js",
"license": "MPL",
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"test",
"tests",
"examples",
"LICENSE",
"README.md"
]
}

View file

@ -0,0 +1,62 @@
'use strict';
/* global KeyEvent */
window.SmartButton = (function(win) {
// Extend from the HTMLButtonElement prototype
var proto = Object.create(HTMLButtonElement.prototype);
proto.createdCallback = function() {
this.addEventListener('mousedown', this);
this.addEventListener('mouseup', this);
this.addEventListener('touchstart', this);
this.addEventListener('touchend', this);
this.addEventListener('keydown', this);
this.addEventListener('keyup', this);
this.addEventListener('focus', this);
this.addEventListener('blur', this);
this.addEventListener('transitionend', this);
this.tabIndex = 0;
};
proto.handleEvent = function(evt) {
switch(evt.type) {
case 'mousedown':
case 'touchstart':
this.classList.add('pressed');
break;
case 'keydown':
if (evt.keyCode === KeyEvent.DOM_VK_RETURN) {
this.classList.add('pressed');
}
break;
case 'mouseup':
case 'touchend':
this.classList.remove('pressed');
this.classList.add('released');
break;
case 'keyup':
if (evt.keyCode === KeyEvent.DOM_VK_RETURN) {
this.classList.remove('pressed');
this.classList.add('released');
this.click();
}
break;
case 'transitionend':
if (this.classList.contains('released')) {
this.classList.remove('released');
}
break;
case 'focus':
this.classList.add('focused');
break;
case 'blur':
this.classList.remove('pressed');
this.classList.remove('released');
this.classList.remove('focused');
break;
}
};
// Register and return the constructor
return document.registerElement('smart-button', { prototype: proto });
})(window);

View file

@ -0,0 +1,444 @@
/**
* Button state:
* 1. normal
* In this state, we don't add any class.
* 2. focused (class name: focused)
* The focused state may be triggered by keyboard or touch/mouse.
* 3. pressed (class name: pressed)
* This state is triggered by user pressed confirm button, mousedown,
* and touchdown.
* 4. released (class name: released)
* This state is only for changing duration of transition. We enter this
* state when user release the button. After transitionend, we goes back
* to focused state.
**/
smart-button {
font-family: "Fira Sans";
display: inline-block;
width: 8rem;
height: 8rem;
/* we need to set border to none to remove the button border */
border: none;
/* the radius is still make button as round even if we don't have border */
border-radius: 50%;
/* 50% is not a real circle because of rounding, we have to use a value larger
than 50% */
-moz-outline-radius: 60%;
outline: transparent 0 solid;
background-color: rgba(0, 0, 0, 0.5);
background-repeat: no-repeat;
background-size: 5.4rem auto;
background-position: center center;
font-style: italic;
transition-property: all;
transition-timing-function: cubic-bezier(0.25, 0, 0, 1.0);
transition-duration: 0.42s;
}
smart-button.focused {
outline: 0;
background-color: #ffffff;
transform: scale(1.2);
transition-duration: 0.42s;
}
smart-button.released {
transition-duration: 0.16s;
}
smart-button.toggled {
background-color: #00caf2;
}
smart-button.pressed {
background-color: #00caf2;
transform: scale(0.8);
transition-duration: 0.06s;
}
smart-button.disabled {
background-color: rgba(0, 0, 0, 0.5);
}
smart-button[data-icon]:before {
width: 5.4rem;
left: 1.3rem;
font-size: 5.4rem;
line-height: 8rem;
text-align: center;
color: #ffffff;
white-space: normal;
top: 0;
position: absolute;
}
smart-button[data-icon].focused:before {
color: #2d2d2d;
}
smart-button[data-icon].toggled:before {
color: #00caf2;
}
smart-button[data-icon].pressed:before {
color: #ffffff;
}
smart-button[data-icon].disabled:before {
color: #ffffff;
opacity: 0.3;
}
/**
* Simple Text Button
**/
smart-button[type="simple-text"] {
color: #dddddd;
background: none;
border: none;
border-radius: 0;
width: auto;
height: 8rem;
line-height: 8rem;
font-size: 2.4rem;
-moz-user-select: none;
padding-left: 2.6rem;
padding-right: 2.6rem;
}
smart-button[type="simple-text"].focused {
color: #ffffff;
background: none;
}
smart-button[type="simple-text"].toggled {
color: #00caf2;
}
smart-button[type="simple-text"].pressed {
color: #00caf2;
background: none;
}
/**
* End of Simple Text Button
**/
/**
* underlined text button
*/
smart-button[type="underlined-text"] {
position: relative;
color: white;
font-size: 2.4rem;
padding-left: 3.8rem;
padding-right: 3.8rem;
border-radius: 4rem;
width: auto;
line-height: 8rem;
margin-right: 1.4rem;
overflow: hidden;
}
smart-button[type="underlined-text"]:after {
position: absolute;
width: 100%;
height: 100%;
left: 0;
content: ' ';
border-bottom: 0.6rem #61acd7 solid;
box-sizing: border-box;
}
smart-button[type="underlined-text"].focused {
transform: scale(1.2);
color: #2d2d2d;
}
smart-button[type="underlined-text"].pressed {
transform: scale(1);
color: white;
background-color: #00caf2;
}
/**
* End of underlined text button
*/
/**
* App Icons
**/
smart-button[type="app-button"] {
/* To use 33.4rem instead of to use 33.6rem is to work around the anti-alias
pixel. So, the circle is filled by the background almost totally. */
width: 33.4rem;
height: 33.4rem;
position: relative;
overflow: hidden;
background-color: transparent;
}
smart-button[type="app-button"].focused {
transform: scale(1.4);
/* To use outline will not change the size of button and make the whole button
thrilling. */
outline: 0.8rem solid #ffffff;
}
smart-button[type="app-button"].pressed {
/* While scaling, we may have a gap between outline and icon which may be
anti-alias pixel. So, we use scale(1.22) to prevent such kind of case */
transform: scale(1.22);
/* To use outline will not change the size of button and make the whole button
thrilling. */
outline: 1.0rem solid #ffffff;
}
smart-button[type="app-button"].fullsized {
background-size: 33.6rem auto;
}
smart-button[type="app-button"].fitted {
background-size: 22rem auto;
}
/** we use :after as label */
smart-button[type="app-button"]:after {
content: attr(label);
position: absolute;
bottom: 0;
left: auto;
right: auto;
width: 33.6rem;
height: 8rem;
padding-top: 1rem;
line-height: 3rem;
text-align: center;
font-size: 2.8rem;
font-style: italic;
color: #ffffff;
}
/**
* text backgorund color
**/
smart-button[type="app-button"][app-type="deck"]:after {
background-color: transparent;
}
smart-button[type="app-button"][app-type="tv"]:after {
background-color: #d90c83;
}
smart-button[type="app-button"][app-type="app"]:after {
background-color: rgba(0, 0, 0, 0.4);
}
smart-button[type="app-button"][app-type="device"]:after {
background-color: #4a82ea;
}
smart-button[type="app-button"][app-type="folder"] {
background-color: #595959;
}
smart-button[type="app-button"]:before {
left: 0;
line-height: 30rem;
font-size: 16rem;
width: 100%;
}
smart-button[type="app-button"][app-type="folder"]:before {
color: #CCCCCC;
}
/**
* End of App Icons
**/
/**
* text and icon button
*/
smart-button[type="text-and-icon"] {
position: relative;
color: white;
font-size: 2.4rem;
padding-left: 9.6rem;
padding-right: 4.6rem;
border-radius: 4rem;
width: auto;
line-height: 8rem;
margin-right: 1.4rem;
overflow: hidden;
}
smart-button[type="text-and-icon"][data-icon]:before {
left: 3.8rem;
}
smart-button[type="text-and-icon"].focused {
transform: scale(1.2);
color: #2d2d2d;
}
smart-button[type="text-and-icon"].pressed {
transform: scale(1);
color: white;
background-color: #00caf2;
}
/**
* End of text and icon button
*/
/**
* banner button
**/
smart-button[type="banner-button"] {
position: relative;
color: #ffffff;
font-size: 2rem;
width: 36rem;
height: 20rem;
overflow: hidden;
border-radius: 0;
border: 0 solid #ffffff;
background-color: #5f5f5f;
background-repeat: no-repeat;
background-size: 120%;
box-sizing: border-box;
transition-property: all;
transition-timing-function: cubic-bezier(0.25, 0, 0.5, 1.0);
transition-duration: 0.1s;
}
smart-button[type="banner-button"][label]:before {
content: attr(label);
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 5rem;
line-height: 5rem;
text-align: center;
background-color: rgba(0, 0, 0, 0.4);
}
smart-button[type="banner-button"]:after {
content: " ";
position: absolute;
bottom: 0;
left: 0;
right: 0;
top: 0;
transition-property: all;
transition-timing-function: cubic-bezier(0.25, 0, 0, 1.0);
transition-duration: 0.06s;
background-color: rgba(255, 255, 255, 0);
}
smart-button[type="banner-button"].focused {
transform: none;
border-width: 1.2rem;
background-size: 100%;
transition-duration: 0.24s;
}
smart-button[type="banner-button"].pressed {
transform: scale(0.945, 0.9);
}
smart-button[type="banner-button"].pressed:after {
background-color: rgba(255, 255, 255, 0.5);
}
/**
* banner button
**/
/**
* circle text button
*/
smart-button[type="circle-text"] {
width: 21rem;
height: 21rem;
color: white;
background-color: #5f6368;
line-height: 21rem;
padding: 2.5rem 2rem 1.5rem 2rem;
text-align: center;
font-size: 3.2rem;
font-weight: bolder;
overflow: hidden;
transition-property: transform, color, background-color;
}
smart-button[type="circle-text"].focused {
transform: scale(1.2);
color: #2d2d2d;
background-color: white;
transition-property: transform, color, background-color;
}
smart-button[type="circle-text"].pressed {
transform: scale(1);
color: white;
background-color: #00caf2;
transition-property: transform, color, background-color;
}
smart-button[type="circle-text"].primary {
background-color: #00caf2;
transition-property: transform, color, background-color;
}
smart-button[type="circle-text"].primary.focused {
color: #ffffff;
background-color: #00caf2;
transition-property: transform, color, background-color;
}
smart-button[type="circle-text"].primary.pressed {
background-color: #0097b5;
transition-property: transform, color, background-color;
}
smart-button[type="circle-text"].danger {
background-color: #e63d2f;
transition-property: transform, color, background-color;
}
smart-button[type="circle-text"].danger.focused {
color: #ffffff;
background-color: #e63d2f;
transition-property: transform, color, background-color;
}
smart-button[type="circle-text"].danger.pressed {
background-color: #ac2e23;
transition-property: transform, color, background-color;
}
/**
* End of circle text button
*/

View file

@ -0,0 +1,29 @@
{
"name": "smart-icons",
"homepage": "https://github.com/smart-components/smart-icons",
"description": "Icon font for smart-screen",
"license": "MPL",
"private": true,
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"templates",
"images",
"test",
"tests",
"index.html",
"Gruntfile.js",
"README.md",
"package.json"
],
"_release": "2f16605d7c",
"_resolution": {
"type": "branch",
"branch": "master",
"commit": "2f16605d7c965c5d801149b9fed6598621342c54"
},
"_source": "git://github.com/smart-components/smart-icons.git",
"_target": "*",
"_originalSource": "smart-components/smart-icons"
}

View file

@ -0,0 +1,21 @@
{
"name": "smart-icons",
"version": "0.0.0",
"homepage": "https://github.com/smart-components/smart-icons",
"description": "Icon font for smart-screen",
"license": "MPL",
"private": true,
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"templates",
"images",
"test",
"tests",
"index.html",
"Gruntfile.js",
"README.md",
"package.json"
]
}

Binary file not shown.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,25 @@
/* Generated by grunt-webfont */
/* Based on https://github.com/endtwist/fontcustom/blob/master/lib/fontcustom/templates/fontcustom.css */
@font-face {
font-family: "smart-icons";
src: url("fonts/smart-icons.ttf") format("truetype");
font-weight: 500;
font-style: normal;
}
[data-icon]:before,
.ligature-icons {
font-family: "smart-icons";
content: attr(data-icon);
display: inline-block;
font-weight: 500;
font-style: normal;
text-decoration: inherit;
text-transform: none;
text-rendering: optimizeLegibility;
font-size: 30px;
-webkit-font-smoothing: antialiased;
}

85
gaia/tv_apps/tv-deck/index.html Executable file
View file

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<meta charset="utf-8">
<title>Apps</title>
<!-- stylesheets of building blocks -->
<link rel="stylesheet" type="text/css" href="bower_components/smart-icons/smart-icons.css">
<link rel="stylesheet" type="text/css" href="bower_components/smart-button/style.css">
<link rel="stylesheet" type="text/css" href="bower_components/smart-bubbles/style.css">
<link rel="stylesheet" type="text/css" href="shared/style/smart-screen/navigation.css">
<link rel="stylesheet" type="text/css" href="shared/style/smart-screen/icons.css">
<link rel="stylesheet" type="text/css" href="style/tv_deck.css">
<!-- default cards for first time launch -->
<link rel="resource" type="application/json" href="shared/resources/default-cards.json">
<!-- For test purpose on phone only -->
<script defer src="shared/js/gesture_detector.js"></script>
<script defer src="shared/js/smart-screen/init_gesture.js"></script>
<!-- Shared libraries -->
<script src="shared/js/l10n.js"></script>
<script src="shared/js/manifest_helper.js"></script>
<script defer src="bower_components/evt/index.js"></script>
<script defer src="shared/js/async_semaphore.js"></script>
<script defer src="shared/js/async_storage.js"></script>
<script defer src="shared/js/smart-screen/key_navigation_adapter.js"></script>
<script defer src="shared/js/smart-screen/simple_key_navigation.js"></script>
<script defer src="shared/js/smart-screen/shared_utils.js"></script>
<script defer src="shared/js/smart-screen/applications.js"></script>
<script defer src="shared/js/smart-screen/piped_promise.js"></script>
<script defer src="shared/js/smart-screen/card_store.js"></script>
<script defer src="shared/js/smart-screen/cards/card.js"></script>
<script defer src="shared/js/smart-screen/cards/application.js"></script>
<script defer src="shared/js/smart-screen/cards/deck.js"></script>
<script defer src="shared/js/smart-screen/cards/folder.js"></script>
<script defer src="shared/js/smart-screen/card_manager.js"></script>
<script defer src="shared/js/smart-screen/channel_manager.js"></script>
<script defer src="shared/js/iac_handler.js"></script>
<script defer src="shared/js/smart-screen/pin_card.js"></script>
<!-- building blocks -->
<script defer src="bower_components/smart-button/script.js"></script>
<script defer src="bower_components/smart-bubbles/script.js"></script>
<!-- Specific code -->
<script defer src="js/context_menu.js"></script>
<script defer src="js/tv_deck.js"></script>
<script defer src="js/index.js"></script>
<!-- L10n properties -->
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="locales/tv-deck.{locale}.properties"/>
<link rel="localization" href="shared/locales/date/date.{locale}.properties"/>
</head>
<body>
<article id="main-section" contextmenu="context-menu">
<smart-bubbles id="bubble-animation"></smart-bubbles>
<section role="region">
<div id="overlay"></div>
<div id="channel-container">
<div id="loading-icon"></div>
<div id="channel-panel" tabindex="-1">
<div id="channel-number"></div>
<div id="channel-title"></div>
</div>
</div>
<video id="tv-stream" width="100%" height="100%"></video>
<div id="button-group-panel" class="hidden" smart-bubbles="true">
<smart-button id="pin-button" data-icon="pin"></smart-button>
<smart-button id="menu-button" data-icon="menu"></smart-button>
</div>
</section>
<menu type="context" id="context-menu">
<menuitem icon="style/icons/ic_pin.png" data-l10n-id="pin-to-home" id="pin-button-contextmenu"></menuitem>
</menu>
</article>
</body>
</html>

View file

@ -0,0 +1,84 @@
/* global PinCard, MozActivity */
'use strict';
(function(exports) {
function ContextMenu(buttonConfigs, app) {
this.selfApp = app;
this.buttonConfigs = buttonConfigs;
this.pinCard = new PinCard(this.app);
this.pinCard.on('update-pin-button', this.updateMenu.bind(this));
document.addEventListener('contextmenu', this.updateMenu.bind(this));
}
var proto = {};
proto.updateMenu = function cm_showMenu() {
var currentHash = window.location.hash;
if (this.pinCard.pinnedChannels[currentHash]) {
// Show unpin button if current channel is pinned.
this._updatePinButton('unpin-from-home', this._unpinFromHome.bind(this));
} else {
// Show pin button if current channel is not pinned yet.
this._updatePinButton('pin-to-home', this._pinToHome.bind(this));
}
};
proto._updatePinButton = function cm_updatePinButton(l10nId, onclick) {
this.buttonConfigs.forEach(function(buttonConfig) {
if (buttonConfig.element) {
buttonConfig.element.onclick = onclick;
if (buttonConfig.hasText) {
buttonConfig.element.setAttribute('data-l10n-id', l10nId);
}
}
}.bind(this));
};
proto._pinToHome = function cm__pinToHome() {
var number = window.location.hash.split(',')[2];
/* jshint nonew:false */
new MozActivity({
name: 'pin',
data: {
type: 'Application',
group: 'tv',
name: {
id: 'channel-name',
args: {
number: number
}
},
manifestURL: this.selfApp.manifestURL,
launchURL: this.selfApp.origin + '/index.html' + window.location.hash
}
});
};
proto._unpinFromHome = function cm__unpinFromHome() {
var message = {
type: 'unpin',
data: {
manifestURL: this.selfApp.manifestURL,
launchURL: this.selfApp.origin + '/index.html' + window.location.hash
}
};
if (!this.unpinPorts) {
this.selfApp.connect('appdeck-channel').then(function (ports) {
this.unpinPorts = ports;
this.unpinPorts.forEach(function(port) {
port.postMessage(message);
});
}.bind(this));
} else {
this.unpinPorts.forEach(function(port) {
port.postMessage(message);
});
}
};
ContextMenu.prototype = proto;
exports.ContextMenu = ContextMenu;
})(window);

View file

@ -0,0 +1,9 @@
/* global TVDeck */
'use strict';
(function(exports) {
window.addEventListener('load', function() {
/* jshint nonew: false */
window.tvDeck = new TVDeck();
});
})(window);

View file

@ -0,0 +1,327 @@
/* global ChannelManager, KeyNavigationAdapter, SimpleKeyNavigation,
asyncStorage, ContextMenu, IACHandler */
'use strict';
(function(exports) {
/**
* TVDeck is the app that can watch TV channels by connecting TVManager API.
* Every (tuner, source, channel) combination creates an unique URL hash.
* Thus, channel switching can be done by simply changing the URL hash and
* handled by onhashchange handler.
*/
function TVDeck() {
this._pendingPorts = [];
navigator.mozApps.getSelf().onsuccess = function(evt){
if (evt.target && evt.target.result) {
this.selfApp = evt.target.result;
// Prevent the case when _returnMeta is called before mozApps.getSelf
// object returns
if (this._pendingPorts.length) {
this._pendingPorts.forEach(function(port) {
this._sendTVDeckMeta(port);
}.bind(this));
}
this._pendingPorts = [];
this._fetchElements();
// XXX: Currently, we do not support tuner switching
asyncStorage.getItem('Last-Tuner-ID', function(tunerId) {
this._init(tunerId);
this.channelManager.fetchSettingFromHash(window.location.hash);
this.channelManager.scanTuners();
}.bind(this));
}
}.bind(this);
window.addEventListener('iac-tvdeck-getmeta', this._returnMeta.bind(this));
}
var proto = {};
proto._init = function td__init(tunerId) {
// lastChannelId maintains number of channel-switching user did.
this.lastChannelId = 0;
this.channelManager = new ChannelManager(tunerId);
this.channelManager.on('scanned', this.setHash.bind(this));
this.channelManager.on('error', this._showErrorState.bind(this));
// enterNumberTimeoutDelay determines the waiting time for switching TV
// channel according to the number user entered.
this.enterNumberTimeoutDelay = 2000;
// enterNumberTimeoutId is used to record timeout function that will switch
// current TV channel according to the number user entered. We have to
// cancel timeout function if an user enters one more number within waiting
// time, i.e. enterNumberTimeoutDelay.
this.enterNumberTimeoutId = null;
// panelTimeoutDelay is the timeout delay for channel panel to be hidden.
this.panelTimeoutDelay = 3000;
// panelTimeoutId is used to record timeout function with 3s delay that will
// hide channel-panel and button-group-panel. It will be created whenever a
// channel is changed. We have to cancel this timeout function when an user
// switch channel very fast within 3s.
this.panelTimeoutId = null;
// Dependencies
this.keyNavigatorAdapter = new KeyNavigationAdapter();
this.keyNavigatorAdapter.init();
this.keyNavigatorAdapter.on('move', this._onSwitch.bind(this));
this.keyNavigatorAdapter.on('enter-keyup', this._onEnter.bind(this));
this.simpleKeyNavigation = new SimpleKeyNavigation();
this.contextmenu = new ContextMenu([{
element: this.pinButtonContextmenu,
hasText: true
}, {
element: this.pinButton,
hasText: false
}], this.selfApp);
// Event listeners setup
window.addEventListener('hashchange', this._onHashChange.bind(this));
document.addEventListener('keyup', this._onEnterNumber.bind(this));
};
proto._fetchElements = function td__fetchElements() {
this.overlay = document.getElementById('overlay');
this.tvStreamElement = document.getElementById('tv-stream');
this.channelPanel = document.getElementById('channel-panel');
this.channelNumber = document.getElementById('channel-number');
this.channelTitle = document.getElementById('channel-title');
this.buttonGroupPanel = document.getElementById('button-group-panel');
this.pinButton = document.getElementById('pin-button');
this.pinButtonContextmenu =
document.getElementById('pin-button-contextmenu');
this.menuButton = document.getElementById('menu-button');
this.loadingIcon = document.getElementById('loading-icon');
this.bubbleElement = document.getElementById('bubble-animation');
// Flash animation
this.channelPanel.addEventListener('animationend', function(){
this.channelPanel.classList.remove('flash');
}.bind(this));
// Hide loadingIcon when animationend
this.loadingIcon.addEventListener('animationend', function(){
this.loadingIcon.classList.add('hidden');
}.bind(this));
};
/**
* Handles onhashchange event. If the hash is valid, then set current source
* and channel to the new value.
*/
proto._onHashChange = function td_onHashChange() {
this.lastChannelId++;
var id = this.lastChannelId;
var hash = window.location.hash;
this.buttonGroupPanel.classList.add('hidden');
this.overlay.classList.add('visible');
this.channelPanel.classList.remove('hidden');
if (this.simpleKeyNavigation.target) {
this.simpleKeyNavigation.stop();
this.channelPanel.focus();
}
this.channelManager.fetchSettingFromHash(hash);
// Cancel onPanelTimeout function, otherwise, panels will be hidden
// after 3 seconds.
clearTimeout(this.panelTimeoutId);
if (!this.channelManager.getTuner()) {
this.channelManager.scanTuners();
} else if (!this.channelManager.getSource()) {
this.channelManager.scanSources();
} else if (!this.channelManager.getChannel()) {
this.channelManager.scanChannels();
} else {
this._rotateLoadingIcon();
this._updateChannelInfo();
this.channelManager.setPlayingSource(function() {
// When an user swiches back and forth very fast, we only have to
// handle the last hash. Since setPlayingSource is an async function,
// id is used to record the last change.
if (id === this.lastChannelId) {
this.buttonGroupPanel.classList.remove('hidden');
this.overlay.classList.remove('visible');
this.contextmenu.updateMenu();
this.bubbleElement.play([this.pinButton, this.menuButton]);
this.simpleKeyNavigation.start(
[this.pinButton, this.menuButton],
SimpleKeyNavigation.DIRECTION.HORIZONTAL
);
this.panelTimeoutId = setTimeout(this._onPanelTimeout.bind(this),
this.panelTimeoutDelay);
var newStream = this.channelManager.getTuner().tuner.stream;
if (!this.tvStreamElement.mozSrcObject ||
this.tvStreamElement.mozSrcObject.id !== newStream.id) {
this.tvStreamElement.mozSrcObject = newStream;
this.tvStreamElement.play();
}
}
}.bind(this));
}
};
proto._updateChannelInfo = function td__updateChannelInfo(title, number) {
this.channelTitle.textContent =
title || this.channelManager.getChannel().channel.name;
this.channelNumber.textContent =
number || this.channelManager.playingState.channelNumber;
};
proto.setHash = function td_setHash() {
if (!this.channelManager.isReady) {
return;
}
if (this.channelManager.currentHash === window.location.hash) {
this._onHashChange();
}
window.location.hash = this.channelManager.currentHash;
};
/**
* Up and down keys are for switching channels, where left and right keys are
* for navigating buttons in button-group.
*/
proto._onSwitch = function td__onSwitch(direction) {
switch(direction) {
case 'up':
case 'down':
this.channelManager.switchChannel(direction);
break;
case 'left':
case 'right':
if (!this.panelTimeoutId) {
return;
}
clearTimeout(this.panelTimeoutId);
this.panelTimeoutId =
setTimeout(this._onPanelTimeout.bind(this), this.panelTimeoutDelay);
return;
default:
return;
}
clearTimeout(this.enterNumberTimeoutId);
this.enterNumberTimeoutId = null;
this.setHash();
};
/**
* onEnterNumber handles event when an user enters a number through keyboard.
* Channel will be switched after enterNumberTimeoutDelay.
*/
proto._onEnterNumber = function td__onEnterNumber(evt) {
var number = evt.keyCode - 48;
if (number >= 0 && number <= 9) {
if (!this.enterNumberTimeoutId) {
this.channelNumber.textContent = '';
}
clearTimeout(this.enterNumberTimeoutId);
clearTimeout(this.panelTimeoutId);
this.enterNumberTimeoutId =
setTimeout(this._onEnter.bind(this), this.enterNumberTimeoutDelay);
this.channelPanel.classList.remove('hidden');
this.channelPanel.focus();
// length is limit to 4 digits
if (this.channelNumber.textContent.length < 4) {
this.channelNumber.textContent += number;
}
}
};
/**
* onEnter switch channels accroding to channelNumber element. It will be
* triggerred either by pressing enter key or enterNumebrTimeout.
*/
proto._onEnter = function td__onEnter() {
if (this.enterNumberTimeoutId) {
clearTimeout(this.enterNumberTimeoutId);
this.enterNumberTimeoutId = null;
clearTimeout(this.panelTimeoutId);
this.panelTimeoutId =
setTimeout(this._onPanelTimeout.bind(this), this.panelTimeoutDelay);
var newNumber = this.channelNumber.textContent;
var newIndex =
this.channelManager.getSource().channelIndexHash[newNumber];
// reset back to current channel number if the number entered
// is not valid.
if (newIndex !== 0 && !newIndex) {
this.channelNumber.textContent =
this.channelManager.playingState.channelNumber;
this.channelPanel.classList.add('flash');
this.simpleKeyNavigation.focus();
return;
}
this.channelManager.playingState.channelNumber = newNumber;
this.setHash();
}
};
proto._showErrorState = function td__showErrorState() {
this._updateChannelInfo('---', '--');
this.tvStreamElement.removeAttribute('src');
this.tvStreamElement.load();
};
proto._rotateLoadingIcon = function td__rotateLoadingIcon() {
if (!this.loadingIcon) {
return;
}
this.loadingIcon.classList.remove('loading-circle');
this.loadingIcon.classList.remove('hidden');
// getComputedStyle on an element somehow will not reflow pseudo elements
// under it, we have to manually reflow those pseudo elements.
window.getComputedStyle(this.loadingIcon).width;
window.getComputedStyle(this.loadingIcon, ':before').width;
window.getComputedStyle(this.loadingIcon, ':after').width;
this.loadingIcon.classList.add('loading-circle');
};
proto._onPanelTimeout = function td_onPanelTimeout() {
this.buttonGroupPanel.classList.add('hidden');
this.channelPanel.classList.add('hidden');
this.simpleKeyNavigation.stop();
this.channelPanel.focus();
this.panelTimeoutId = null;
};
proto._returnMeta = function td__returnMeta(evt) {
var port = IACHandler.getPort('tvdeck-getmeta');
if (port) {
if (this.selfApp) {
this._sendTVDeckMeta(port);
} else {
this._pendingPorts.push(port);
}
}
};
proto._sendTVDeckMeta = function td__sendTVDeckMeta(port) {
port.postMessage({
origin: this.selfApp.origin,
manifestURL: this.selfApp.manifestURL
});
};
exports.TVDeck = TVDeck;
TVDeck.prototype = proto;
})(window);

18
gaia/tv_apps/tv-deck/jsdoc.json Executable file
View file

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

View file

@ -0,0 +1,4 @@
pin-to-home=Pin to Home
pin-to-home.label=Pin to Home
unpin-from-home=Unpin From Home
unpin-from-home.label=Unpin From Home

View file

@ -0,0 +1,40 @@
{
"name": "TV",
"description": "Smart Screen TV Deck",
"launch_path": "/index.html",
"type": "certified",
"role": "deck",
"developer": {
"name": "The Gaia Team",
"url": "https://github.com/mozilla-b2g/gaia"
},
"locales": {
"en-US": {
"name": "TV",
"description": "Smart Screen TV Deck"
}
},
"default_locale": "en-US",
"icons": {
"354": "/style/icons/ic_default_app.png"
},
"orientation": "landscape",
"permissions": {
"tv": {},
"webapps-manage": {},
"systemXHR": {},
"moz-extremely-unstable-and-will-change-webcomponents":{}
},
"datastores-access": {
"home_cards": {
"access": "readonly",
"description": "Read card list"
}
},
"connections": {
"tvdeck-getmeta": {
"description": "get app information of tvdeck",
"rules": {}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1,238 @@
html {
font-size: 10px;
font-family: "Fira Sans";
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
margin: 0;
padding: 0;
background-color: white;
}
a {
outline: 0;
}
a:hover, a:active, a:focus {
outline: 0;
}
smart-button {
color: white;
}
#main-section {
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
background-color: white;
}
#main-section > section[role='region'] {
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
}
#main-section > section[role='region'] > video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
#overlay {
background-color: #000000;
width: 100%;
height: 100%;
position: absolute;
z-index: 1;
opacity: 0;
transition: opacity 0.4s linear;
}
#overlay.visible {
opacity: 1;
}
#channel-container {
width: 25rem;
height: 25rem;
margin-left: 6rem;
margin-top: 4rem;
position: relative;
overflow: visible;
z-index: 2;
}
#channel-panel {
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
position: absolute;
overflow: hidden;
text-align: center;
transition: opacity 0.5s;
outline: none;
}
#channel-panel.hidden {
opacity: 0;
}
#channel-panel.flash {
animation: flash 0.1s 1 linear;
}
@keyframes flash {
0% {visibility: hidden;}
100% {visibility: visible;}
}
#channel-number {
width: 100%;
height: 12.4rem;
top: 4.2rem;
font-size: 11rem;
font-style : italic;
font-weight: 100;
position: absolute;
color: white;
}
#channel-title {
width: 100%;
height: 3.8rem;
font-size: 2.8rem;
font-style : italic;
font-weight: 100;
color: white;
position: absolute;
bottom: 4.8rem;
}
#button-group-panel {
position: absolute;
left: 4rem;
bottom: 6rem;
}
#button-group-panel.hidden {
opacity: 0;
transition: opacity 0.5s;
}
#button-group-panel > * {
margin-left: 2.2rem;
position: relative;
}
#button-group-panel > *:first-child {
margin-left: 0;
}
.loading-circle {
top: -0.6rem;
left: -0.6rem;
margin-left: 13.1rem;
position:absolute;
width: 13.1rem;
height: 26.2rem;
overflow:hidden;
border-radius: 0 13.1rem 13.1rem 0;
animation:loading-circle-mask 1s 1 cubic-bezier(0.25, 0, 0.5, 1.0) forwards;
transition: opacity 0.5s linear;
}
.loading-circle.hidden {
opacity: 0;
}
.loading-circle:before,
.loading-circle:after{
content:"";
box-sizing:border-box;
position:absolute;
top: 0;
right: 0;
width: 26.2rem;
height: 13.1rem;
background: transparent;
border-style: solid;
border-color: #e72798;
border-width: 0.6rem 0.6rem 0;
border-radius: 13.1rem 13.1rem 0 0;
transform-origin: 13.1rem 13.1rem;
}
.loading-circle:before{
z-index: 1;
animation: loading-circle-rotate 1s 1 cubic-bezier(0.25, 0, 0.5, 1.0) forwards;
transform: rotate(-90deg);
}
.loading-circle:after{
opacity: 0;
z-index: 2;
transform: rotate(90deg);
animation: loading-circle-static 1s 1 cubic-bezier(0.25, 0, 0.5, 1.0) forwards;
transition: opacity 0.5s linear;
}
.loading-circle.hidden:after{
opacity: 0;
}
@keyframes loading-circle-rotate{
0%{
transform: rotate(-90deg);
}
100%{
transform: rotate(270deg);
}
}
@keyframes loading-circle-static{
0%{
opacity: 0;
}
40.49%{
opacity: 0;
}
40.5%{
opacity: 1;
}
100%{
opacity: 1;
}
}
@keyframes loading-circle-mask{
0%{
margin-left: 13.1rem;
width: 13.1rem;
border-radius:0 13.1rem 13.1rem 0;
}
40.49%{
margin-left: 13.1rem;
width: 13.1rem;
border-radius: 0 13.1rem 13.1rem 0;
}
40.5%{
margin-left: 0;
width: 26.2rem;
border-radius:0;
}
100%{
margin-left: 0;
width: 26.2rem;
border-radius: 0;
}
}

View file

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

View file

@ -0,0 +1,394 @@
'use strict';
/*jshint browser: true */
/* global ChannelManager, MockTVManager, MockTVTuner, MockTVSource,
MockTVChannel */
require('/bower_components/evt/index.js');
require('/shared/test/unit/mocks/smart-screen/mock_tv_channel.js');
require('/shared/test/unit/mocks/smart-screen/mock_tv_source.js');
require('/shared/test/unit/mocks/smart-screen/mock_tv_tuner.js');
require('/shared/test/unit/mocks/smart-screen/mock_tv_manager.js');
require('/shared/js/smart-screen/channel_manager.js');
suite('tv-deck/channel_manager', function() {
var realTVManager;
var channelManager;
setup(function() {
realTVManager = window.navigator.tv;
window.navigator.tv = new MockTVManager();
channelManager = new ChannelManager();
});
suite('currentHash', function() {
setup(function() {
channelManager.playingState = {
tunerId: '1',
sourceType: 'dvb-1',
channelNumber: '10'
};
});
test('currentHash should equals to #1,dvb-1,10', function () {
assert.equal(channelManager.currentHash, '#1,dvb-1,10');
});
});
suite('getTuner()', function() {
setup(function() {
channelManager.currentTuners = {
'tuner1': {},
'tuner2': {}
};
channelManager.playingState = {
tunerId: 'tuner1',
sourceType: 'source1',
channelNumber: 'channel1'
};
});
test('Default value should be set to playingState.tunerId', function() {
var tuner = channelManager.currentTuners.tuner1;
assert.equal(channelManager.getTuner(), tuner);
});
test('tuner2 should be found in currentTuners', function() {
var tuner = channelManager.currentTuners.tuner2;
assert.equal(channelManager.getTuner('tuner2'), tuner);
});
});
suite('getSource()', function() {
setup(function() {
channelManager.currentTuners = {
'tuner1': {
sources: {
'source1': {},
'source2': {}
}
}
};
channelManager.playingState = {
tunerId: 'tuner1',
sourceType: 'source1',
channelNumber: 'channel1'
};
});
test('Default value should be set to playingState.sourceType', function() {
var source = channelManager.currentTuners.tuner1.sources.source1;
assert.equal(channelManager.getSource(), source);
});
test('source2 should be found in currentTuners', function() {
var source = channelManager.currentTuners.tuner1.sources.source2;
assert.equal(channelManager.getSource('source2'), source);
});
});
suite('getChannel()', function() {
setup(function() {
channelManager.currentTuners = {
'tuner1': {
sources: {
'source1': {
channels: [{}, {}],
channelIndexHash: {
'channel1': 0,
'channel2': 1
}
}
}
}
};
channelManager.playingState = {
tunerId: 'tuner1',
sourceType: 'source1',
channelNumber: 'channel1'
};
});
test('Default value should be set to playingState.channelNumber',
function() {
var source = channelManager.currentTuners.tuner1.sources.source1;
var channel = source.channels[0];
assert.equal(channelManager.getChannel(), channel);
});
test('channel2 should be found in currentTuners with index 1', function() {
var source = channelManager.currentTuners.tuner1.sources.source1;
var channel = source.channels[1];
assert.equal(channelManager.getChannel('channel2'), channel);
});
});
suite('fetchSettingFromHash()', function() {
test('playingState should be set to the same setting as input hash',
function() {
var hash = '#t0,s0,c0';
channelManager.fetchSettingFromHash(hash);
assert.equal(channelManager.playingState.tunerId, 't0');
assert.equal(channelManager.playingState.sourceType, 's0');
assert.equal(channelManager.playingState.channelNumber, 'c0');
});
});
suite('scanTuners()', function() {
var scanSources;
var fire;
var tuners;
function setTuners() {
tuners = [];
tuners[0] = new MockTVTuner();
tuners[0].id = 'tuner-0';
tuners[1] = new MockTVTuner();
tuners[1].id = 'tuner-1';
}
setup(function() {
setTuners();
this.sinon.stub(navigator.tv, 'getTuners').returns({
then: function(callback) {
callback(tuners);
}
});
fire = this.sinon.stub(channelManager, 'fire');
scanSources = this.sinon.stub(channelManager, 'scanSources');
});
test('playingState.tunerId should be set to the first tuner by default if' +
'it is null value', function() {
channelManager.scanTuners();
assert.equal(channelManager.playingState.tunerId, 'tuner-0');
});
test('scanSources should be called if tuner can be found', function() {
channelManager.scanTuners();
assert.isTrue(scanSources.called);
});
test('error should be fired if the tuner cannot be found', function() {
channelManager.playingState.tunerId = 'null';
channelManager.scanTuners();
assert.isTrue(fire.called);
});
});
suite('scanSources()', function() {
var scanChannels;
var fire;
var tunerItem;
setup(function() {
tunerItem = {
tuner: new MockTVTuner()
};
this.sinon.stub(channelManager, 'getTuner').returns(tunerItem);
fire = this.sinon.stub(channelManager, 'fire');
scanChannels = this.sinon.stub(channelManager, 'scanChannels');
});
test('playingState.sourceType should be set to first source by default if' +
'it is null value', function() {
channelManager.scanSources();
assert.equal(channelManager.playingState.sourceType, 'dvb-0');
});
test('playingState.sourceType should be set to currentSource of the tuner',
function() {
tunerItem.tuner.currentSource = new MockTVSource();
tunerItem.tuner.currentSource.type = 'dvb-1';
channelManager.scanSources();
assert.equal(channelManager.playingState.sourceType, 'dvb-1');
});
test('scanChannels should be called if tuner can be found', function() {
channelManager.scanSources();
assert.isTrue(scanChannels.called);
});
test('error should be fired if the source cannot be found', function() {
channelManager.playingState.sourceType = 'null';
channelManager.scanSources();
assert.isTrue(fire.called);
});
});
suite('scanChannels()', function() {
var fire;
var sourceItem;
setup(function() {
sourceItem = {
source: new MockTVSource()
};
this.sinon.stub(channelManager, 'getSource').returns(sourceItem);
fire = this.sinon.stub(channelManager, 'fire');
});
test('playingState.currentChannel should be set to the first channel by ' +
'default if it is null value', function() {
channelManager.scanChannels();
assert.equal(channelManager.playingState.channelNumber, '0');
});
test('playingState.channelNumber should be set to currentChannel of the ' +
'current source', function() {
sourceItem.source.currentChannel = new MockTVChannel();
sourceItem.source.currentChannel.number = '1';
channelManager.scanChannels();
assert.equal(channelManager.playingState.channelNumber, '1');
});
test('isReady is set to be true', function() {
channelManager.scanChannels();
assert.isTrue(channelManager.isReady);
});
test('error should be fired if the channel cannot be found', function() {
channelManager.playingState.channelNumber = 'null';
channelManager.scanChannels();
assert.isTrue(fire.called);
});
});
suite('setPlayingSource()', function() {
var tunerItem;
var sourceItem;
var setPlayingChannel;
var setCurrentSource;
setup(function() {
tunerItem = {
tuner: new MockTVTuner()
};
sourceItem = {
source: new MockTVSource()
};
this.sinon.stub(channelManager, 'getTuner').returns(tunerItem);
this.sinon.stub(channelManager, 'getSource').returns(sourceItem);
setPlayingChannel = this.sinon.stub(channelManager, 'setPlayingChannel');
setCurrentSource = this.sinon.stub(tunerItem.tuner, 'setCurrentSource');
setCurrentSource.returns({
then: function(callback) {
callback();
}
});
});
test('If currentSource in playing tuner is the same as playing source, ' +
'setPlayingChannel should be called while setCurrentSource should not',
function() {
tunerItem.tuner.currentSource = sourceItem.source;
channelManager.setPlayingSource();
assert.isTrue(setPlayingChannel.called);
assert.isFalse(setCurrentSource.called);
});
test('If currentSource in playing tuner is not the same as playing source' +
', setPlayingChannel and setCurrentSource should both be called',
function() {
tunerItem.tuner.currentSource = new MockTVSource();
channelManager.setPlayingSource();
assert.isTrue(setPlayingChannel.called);
assert.isTrue(setCurrentSource.called);
});
});
suite('setPlayingChannel()', function() {
var sourceItem;
var setCurrentChannel;
setup(function() {
sourceItem = {
source: new MockTVSource()
};
this.sinon.stub(channelManager, 'getSource').returns(sourceItem);
setCurrentChannel =
this.sinon.stub(sourceItem.source, 'setCurrentChannel');
setCurrentChannel.returns({
then: function(callback) {
callback();
}
});
});
test('setCurrentChannel should not be called if source is scanning',
function() {
sourceItem.source.isScanning = true;
channelManager.setPlayingChannel();
assert.isFalse(setCurrentChannel.called);
});
test('callback should be called if source is scanning', function() {
var callback = this.sinon.stub();
sourceItem.source.isScanning = true;
channelManager.setPlayingChannel(callback);
assert.isTrue(callback.called);
});
test('Both setCurrentChannel and callback should be called if source is' +
'not scanning', function() {
var callback = this.sinon.stub();
sourceItem.source.isScanning = false;
channelManager.setPlayingChannel(callback);
assert.isTrue(setCurrentChannel.called);
assert.isTrue(callback.called);
});
});
suite('switchChannel()', function() {
setup(function() {
this.sinon.stub(channelManager, 'getChannel').returns({});
this.sinon.stub(channelManager, 'getSource').returns({
channels: [{
channel: {
number: '0'
}
}, {
channel: {
number: '1'
}
}, {
channel: {
number: '2'
}
}, {
channel: {
number: '3'
}
}, {
channel: {
number: '4'
}
}],
channelIndexHash: {
'0': 0,
'1': 1,
'2': 2,
'3': 3,
'4': 4
}
});
channelManager.isReady = true;
channelManager.playingState = {
channelNumber: '0'
};
});
test('Switch up channel, channelNumber should be 1', function() {
channelManager.switchChannel('up');
assert.equal(channelManager.playingState.channelNumber, '1');
});
test('Switch down channel, channelNumber should be 4', function() {
channelManager.switchChannel('down');
});
});
});

View file

@ -0,0 +1,88 @@
'use strict';
/* jshint browser: true */
/* global ContextMenu, MocksHelper, MozActivity */
require('/shared/test/unit/mocks/mock_moz_activity.js');
require('/shared/test/unit/mocks/smart-screen/mock_pin_card.js');
require('/js/context_menu.js');
var mocksHelper = new MocksHelper([
'MozActivity',
'PinCard'
]).init();
suite('tv-epg/contextmenu', function() {
var contextmenu;
var button;
mocksHelper.attachTestHelpers();
setup(function() {
var mockPorts = [{
postMessage: function() {},
}];
button = document.createElement('DIV');
contextmenu = new ContextMenu([{
element: button,
hasText: true
}], {
origin: 'app://contextmenu.test',
manifestURL: 'app://contextmenu.test/manifest.webapp',
connect: function() {
return {
then: function(callback) {
callback(mockPorts);
if (mockPorts[0].onmessage) {
mockPorts[0].onmessage({
data: {
origin: 'app://tvdeck.test',
manifestURL: 'app://tvdeck.test/manifest.webapp'
}
});
}
}
};
}
});
});
suite('Initialization', function() {
test('Initialize members', function() {
assert.equal(contextmenu.selfApp.origin, 'app://contextmenu.test');
assert.equal(
contextmenu.selfApp.manifestURL,
'app://contextmenu.test/manifest.webapp'
);
assert.isDefined(contextmenu.pinCard);
});
});
suite('updateMenu', function() {
setup(function() {
MozActivity.mSetup();
window.location.hash = '#1,dvb-0,12';
});
teardown(function() {
window.location.hash = '';
MozActivity.mTeardown();
});
test('Pin card to home', function() {
contextmenu.updateMenu();
button.click();
assert.equal(button.getAttribute('data-l10n-id'), 'pin-to-home');
assert.equal(MozActivity.calls[0].name, 'pin');
});
test('Unpin card to home', function() {
contextmenu.pinCard.pinnedChannels = {
'#1,dvb-0,12': true
};
contextmenu.updateMenu();
button.click();
assert.equal(button.getAttribute('data-l10n-id'), 'unpin-from-home');
});
});
});

View file

@ -0,0 +1,34 @@
(function(exports) {
'use strict';
function MockCardManager() {
this.init = function() {
return {
then: function(callback) {}
};
};
var callbacks = {};
this.on = function(name, callback) {
callbacks[name] = callback;
};
this.mTriggerChange = function(name) {
if (callbacks[name]) {
callbacks[name]();
}
};
this.getCardList = function() {
return {
then: function(callback) {
if (this.cards) {
callback(this.cards);
}
}.bind(this)
};
};
}
exports.MockCardManager = MockCardManager;
}(window));

View file

@ -0,0 +1,8 @@
(function(exports) {
'use strict';
function MockContextMenu() {
}
exports.MockContextMenu = MockContextMenu;
}(window));

View file

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

View file

@ -0,0 +1,284 @@
'use strict';
/* jshint browser: true */
/* global TVDeck, MocksHelper, MockTVManager, MockNavigatormozApps */
require('/shared/test/unit/mocks/mock_navigator_moz_apps.js');
require('/shared/test/unit/mocks/smart-screen/mock_key_navigation_adapter.js');
require('/shared/test/unit/mocks/smart-screen/mock_simple_key_navigation.js');
require('/shared/test/unit/mocks/smart-screen/mock_tv_channel.js');
require('/shared/test/unit/mocks/smart-screen/mock_tv_source.js');
require('/shared/test/unit/mocks/smart-screen/mock_tv_tuner.js');
require('/shared/test/unit/mocks/smart-screen/mock_tv_manager.js');
require('/shared/test/unit/mocks/smart-screen/mock_channel_manager.js');
require('/bower_components/smart-bubbles/script.js');
require('/test/unit/mock_context_menu.js');
require('/js/tv_deck.js');
var mocksHelper = new MocksHelper([
'ContextMenu',
'ChannelManager',
'KeyNavigationAdapter',
'SimpleKeyNavigation'
]).init();
suite('tv-deck/tv_deck', function() {
var MockasyncStorage;
var realMozApps;
var realTVManager;
var realasyncStorage;
var tvDeck;
mocksHelper.attachTestHelpers();
function createMockElement(id, type) {
var element;
element = document.createElement(type || 'div');
element.id = id;
document.body.appendChild(element);
return element;
}
function createMockUI() {
createMockElement('overlay');
createMockElement('tv-stream', 'video');
createMockElement('channel-panel');
createMockElement('channel-number');
createMockElement('channel-title');
createMockElement('button-group-panel');
createMockElement('pin-button');
createMockElement('pin-button-contextmenu');
createMockElement('menu-button');
createMockElement('loading-icon');
createMockElement('bubble-animation', 'smart-bubbles');
}
suiteSetup(function() {
createMockUI();
sinon.stub(window, 'addEventListener');
MockasyncStorage = {
_value: {},
getItem: function(name, callback) {
callback(this._value[name]);
}
};
});
setup(function() {
realTVManager = window.navigator.tv;
realMozApps = window.navigator.mozApps;
realasyncStorage = window.asyncStorage;
window.navigator.tv = new MockTVManager();
window.navigator.mozApps = MockNavigatormozApps;
window.asyncStorage = MockasyncStorage;
window.location.hash = '';
window.localStorage.removeItem('TV_Hash');
tvDeck = new TVDeck();
MockNavigatormozApps.mTriggerLastRequestSuccess({
origin: 'test://origin',
manifestURL: 'test://manifestURL'
});
});
teardown(function() {
window.navigator.tv = realTVManager;
window.navigator.mozApps = realMozApps;
window.asyncStorage = realasyncStorage;
});
suite('Initilization', function() {
test('enterNumberTimeoutDelay should be greater than 0', function() {
assert(tvDeck.enterNumberTimeoutDelay > 0);
});
test('panelTimeoutDelay should be greater than 0', function() {
assert(tvDeck.panelTimeoutDelay > 0);
});
test('Must have video element', function() {
assert.isDefined(tvDeck.tvStreamElement);
});
test('lastChannelId should be 0', function() {
assert.equal(tvDeck.lastChannelId, 0);
});
});
suite('_onHashChange()', function() {
test('lastChannelId should not be the same', function() {
var id = tvDeck.lastChannelId;
tvDeck._onHashChange();
assert.notEqual(id, tvDeck.lastChannelId);
});
test('Rescan tuners if playing tuner is not found', function() {
var scanTuners = this.sinon.stub(tvDeck.channelManager, 'scanTuners');
this.sinon.stub(tvDeck.channelManager, 'getTuner').returns(null);
tvDeck._onHashChange();
assert.isTrue(scanTuners.called);
});
test('Rescan sources if playing source is not found', function() {
var scanSources = this.sinon.stub(tvDeck.channelManager, 'scanSources');
this.sinon.stub(tvDeck.channelManager, 'getTuner').returns({});
this.sinon.stub(tvDeck.channelManager, 'getSource').returns(null);
tvDeck._onHashChange();
assert.isTrue(scanSources.called);
});
test('Rescan channels if playing channel is not found', function() {
var scanChannels = this.sinon.stub(tvDeck.channelManager, 'scanChannels');
this.sinon.stub(tvDeck.channelManager, 'getTuner').returns({});
this.sinon.stub(tvDeck.channelManager, 'getSource').returns({});
tvDeck._onHashChange();
assert.isTrue(scanChannels.called);
});
test('buttonGroupPanel should be hidden', function() {
tvDeck._onHashChange();
assert.isTrue(tvDeck.buttonGroupPanel.classList.contains('hidden'));
});
test('channelPanel should be visible', function() {
tvDeck._onHashChange();
assert.isFalse(tvDeck.channelPanel.classList.contains('hidden'));
});
});
suite('setHash()', function() {
test('_onHashChange should not be called if new hash is different from ' +
'current location.hash', function() {
var onHashChange = this.sinon.stub(tvDeck, '_onHashChange');
tvDeck.setHash();
assert.isFalse(onHashChange.called);
});
test('_onHashChange should be called if new hash is the same as current ' +
'location.hash', function() {
var onHashChange = this.sinon.stub(tvDeck, '_onHashChange');
window.location.hash = 'hash';
tvDeck.channelManager.currentHash = '#hash';
tvDeck.setHash();
assert.isTrue(onHashChange.called);
});
test('window.location.hash should equals to currentHash', function() {
tvDeck.channelManager.currentHash = '#new-hash';
tvDeck.setHash();
assert.equal(window.location.hash, '#new-hash');
});
});
suite('_onSwitch()', function() {
test('switchChannel chould be called', function() {
var switchChannel =
this.sinon.stub(tvDeck.channelManager, 'switchChannel');
tvDeck._onSwitch('up');
assert.isTrue(switchChannel.called);
});
});
suite('_onEnterNumber()', function() {
var evt;
setup(function() {
evt = {
keyCode: 50
};
tvDeck.channelNumber.textContent = '';
});
teardown(function() {
tvDeck.channelNumber.textContent = '';
});
test('enterNumberTimeoutId should be reassigned', function() {
var id = tvDeck.enterNumberTimeoutId;
tvDeck._onEnterNumber(evt);
assert.notEqual(id, tvDeck.enterNumberTimeoutId);
});
test('channelPanel should be visible', function() {
tvDeck._onEnterNumber(evt);
assert.isFalse(tvDeck.channelPanel.classList.contains('hidden'));
});
test('Input number should be appended to channelNumber', function() {
tvDeck.enterNumberTimeoutId = 1;
tvDeck._onEnterNumber(evt);
assert.equal(tvDeck.channelNumber.textContent, '2');
});
test('Input number should not be appended to channelNumber if length of ' +
'channelNumber is greater than 4', function() {
tvDeck.enterNumberTimeoutId = 1;
tvDeck.channelNumber.textContent = '1234';
tvDeck._onEnterNumber(evt);
assert.equal(tvDeck.channelNumber.textContent, '1234');
});
});
suite('_onEnter()', function() {
setup(function() {
this.sinon.stub(tvDeck.channelManager, 'getSource').returns({
channelIndexHash: {
'1-1': 5
}
});
});
test('setHash should not be called if enterNumberTimeoutId is null',
function() {
var setHash = this.sinon.stub(tvDeck, 'setHash');
tvDeck._onEnter();
assert.isFalse(setHash.called);
});
test('setHash should be called if enterNumberTimeoutId is defined, and ' +
'new channel number is valid', function() {
var setHash = this.sinon.stub(tvDeck, 'setHash');
tvDeck.channelNumber.textContent = '1-1';
tvDeck.enterNumberTimeoutId = 1;
tvDeck._onEnter();
assert.isTrue(setHash.called);
});
});
suite('_showErrorState()', function() {
test('_updateChannelInfo should be called', function() {
var updateChannelInfo = this.sinon.stub(tvDeck, '_updateChannelInfo');
tvDeck._showErrorState();
assert.isTrue(updateChannelInfo.called);
});
test('src of video element should be remove', function() {
this.sinon.stub(tvDeck, '_updateChannelInfo');
tvDeck._showErrorState();
assert.isDefined(tvDeck.tvStreamElement.src);
});
});
suite('_rotateLoadingIcon()', function() {
test('loadingIcon contains loading-circle class', function() {
tvDeck._rotateLoadingIcon();
assert.isTrue(tvDeck.loadingIcon.classList.contains('loading-circle'));
});
});
suite('_onPanelTimeout()', function() {
test('buttonGroupPanel should be hidden', function() {
tvDeck._onPanelTimeout();
assert.isTrue(tvDeck.buttonGroupPanel.classList.contains('hidden'));
});
test('channelPanel should be hidden', function() {
tvDeck._onPanelTimeout();
assert.isTrue(tvDeck.channelPanel.classList.contains('hidden'));
});
test('panelTimeoutId should be cleared', function() {
tvDeck._onPanelTimeout();
assert.isNull(tvDeck.panelTimeoutId);
});
});
});