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

64
gaia/apps/fm/index.html Executable file
View file

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<meta charset="utf-8" />
<meta name="theme-color" content="#242d33">
<title>FM Radio</title>
<link rel="stylesheet" href="app://theme.gaiamobile.org/shared/elements/gaia-theme/gaia-theme.css" type="text/css" />
<link rel="stylesheet" href="style/fm.css" type="text/css" />
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="locales/fm.{locale}.properties">
<script defer src="shared/js/intl/l20n.js"></script>
<script defer src="shared/js/async_storage.js"></script>
<script defer src="shared/js/airplane_mode_helper.js"></script>
<script defer src="js/fm.js"></script>
</head>
<body>
<div id="container" dir="ltr">
<div id="frequency-bar">
<div id="frequency-display">
<a id="speaker-switch" href="#speaker" data-speaker-on="false" data-l10n-id="speaker-switch" role="button" aria-pressed="false"></a>
<div id="frequency" role="status" data-l10n-id="frequency-MHz" data-l10n-args='{"value": "87.5"}'>
<small></small>
</div>
<a id="bookmark-button" href="#bookmark" data-bookmarked="false" data-l10n-id="bookmark-button" role="button"></a>
</div>
</div>
<div id="dialer-bar">
<div id="dialer-container" data-l10n-id="dialer-container" role="slider" aria-valuemin="87.5" aria-valuemax="108" aria-controls="frequency">
<div id="frequency-indicator"></div>
<div id="frequency-dialer" class="animation-on"></div>
</div>
</div>
<div id="fav-list">
<div id="fav-list-container" role="listbox" data-l10n-id="fav-list-container"></div>
</div>
<div id="action-bar" role="toolbar">
<div>
<a id="frequency-op-seekdown" href="#seekdown" data-l10n-id="frequency-op-seekdown" role="button"></a>
</div>
<div>
<a id="power-switch" href="#power-switch" data-enabled="false" data-enabling="false" data-l10n-id="power-switch-off" role="button"><span></span></a>
</div>
<div>
<a id="frequency-op-seekup" href="#seekup" data-l10n-id="frequency-op-seekup" role="button"></a>
</div>
</div>
</div>
<div id="antenna-warning" class="warning" hidden>
<div></div>
<div id="antenna-warning-header" class="warning-header" data-l10n-id="noAntenna">Please plugin your headset</div>
<div id="antenna-warning-body" class="warning-body" data-l10n-id="noAntennaMsg">The FM Radio requires a headset to use as an antenna.</div>
</div>
<div id="airplane-mode-warning" class="warning" hidden>
<div class="warning-header" data-l10n-id="airplaneModeHeader">Airplane mode is on</div>
<div class="warning-body" data-l10n-id="airplaneModeMsg">Turn off Airplane mode to use FM Radio.</div>
</div>
</body>
</html>

921
gaia/apps/fm/js/fm.js Executable file
View file

@ -0,0 +1,921 @@
'use strict';
/* global
AirplaneModeHelper */
function $(id) {
return document.getElementById(id);
}
function $$(expr) {
return document.querySelectorAll(expr);
}
// XXX fake mozFMRadio object for UI testing on PC
var mozFMRadio = navigator.mozFM || navigator.mozFMRadio || {
speakerEnabled: false,
frequency: null,
enabled: false,
antennaAvailable: true,
signalStrength: 1,
frequencyLowerBound: 87.5,
frequencyUpperBound: 108,
channelWidth: 0.1,
onsignalstrengthchange: function emptyFunction() { },
onfrequencychange: function emptyFunction() { },
onenabled: function emptyFunction() { },
ondisabled: function emptyFunction() { },
onantennaavailablechange: function emptyFunction() { },
disable: function fm_disable() {
if (!this.enabled) {
return;
}
this.enabled = false;
var self = this;
window.setTimeout(function() {
self.ondisabled();
}, 500);
return {};
},
enable: function fm_enable(frequency) {
if (this.enabled) {
return;
}
this.enabled = true;
var self = this;
window.setTimeout(function() {
self.onenabled();
self.setFrequency(frequency);
}, 500);
return {};
},
setFrequency: function fm_setFrequency(freq) {
freq = parseFloat(freq.toFixed(1));
var previousValue = this.frequency;
this.frequency = freq;
if (previousValue != freq) {
this.onfrequencychange();
}
return {};
},
seekUp: function fm_seekUp() {
var self = this;
if (this._seekRequest) {
return;
}
this._seekRequest = {};
this._seekTimeout = window.setTimeout(function su_timeout() {
self.setFrequency(self.frequency + 0.5);
if (self._seekRequest.onsuccess) {
self._seekRequest.onsuccess();
}
self._clearSeekRequest();
}, 1000);
return this._seekRequest;
},
seekDown: function fm_seekDown() {
var self = this;
if (this._seekRequest) {
return;
}
this._seekRequest = {};
this._seekTimeout = window.setTimeout(function sd_timeout() {
self.setFrequency(self.frequency - 0.5);
if (self._seekRequest.onsuccess) {
self._seekRequest.onsuccess();
}
self._clearSeekRequest();
}, 1000);
return this._seekRequest;
},
cancelSeek: function fm_cancelSeek() {
this._clearSeekRequest();
var request = {};
window.setTimeout(function() {
if (request.onsuccess) {
request.onsuccess();
}
}, 0);
return request;
},
_clearSeekRequest: function fm_clearSeek() {
if (this._seekTimeout) {
window.clearTimeout(this._seekTimeout);
this._seekTimeout = null;
}
if (this._seekRequest && this._seekRequest.onerror) {
this._seekRequest.onerror();
this._seekRequest = null;
}
}
};
// XXX fake SpeakerManager object for UI testing on PC
(function(aGlobal) {
aGlobal.SpeakerManager = aGlobal.SpeakerManager || aGlobal.MozSpeakerManager;
if (aGlobal.SpeakerManager) {
return;
}
function SpeakerManager() {
this.speakerforced = false;
}
SpeakerManager.prototype = {
set forcespeaker(enable) {
if (this.speakerforced != enable) {
this.speakerforced = enable;
if (this.onspeakerforcedchange) {
this.onspeakerforcedchange();
}
}
}
};
aGlobal.SpeakerManager = SpeakerManager;
})(window);
var enabling = false;
function updatePowerUI() {
var enabled = mozFMRadio.enabled;
var powerSwitch = $('power-switch');
if (enabled) {
window.performance.mark('fmRadioEnabled');
// ACCESSIBILITY - Must set data-l10n-id to reflect Off switch
powerSwitch.setAttribute('data-l10n-id', 'power-switch-off');
} else {
// ACCESSIBILITY - Must set data-l10n-id to reflect On switch
powerSwitch.setAttribute('data-l10n-id', 'power-switch-on');
}
console.log('Power status: ' + (enabled ? 'on' : 'off'));
powerSwitch.dataset.enabled = enabled;
powerSwitch.dataset.enabling = enabling;
}
var airplaneModeEnabled = false;
function updateWarningModeUI() {
$('airplane-mode-warning').hidden = !airplaneModeEnabled;
$('antenna-warning').hidden = mozFMRadio.antennaAvailable ||
airplaneModeEnabled;
$('container').classList.toggle('hidden-block', airplaneModeEnabled ||
!mozFMRadio.antennaAvailable);
}
function updateFrequencyBarUI() {
var frequencyBar = $('frequency-bar');
if (enabling) {
frequencyBar.classList.add('dim');
} else {
frequencyBar.classList.remove('dim');
}
}
function updateEnablingState(enablingState) {
enabling = enablingState;
updatePowerUI();
updateFrequencyBarUI();
}
function enableFMRadio(frequency) {
if (airplaneModeEnabled) {
return;
}
var request = mozFMRadio.enable(frequency);
// Request might fail, see bug862672
request.onerror = function onerror_enableFMRadio(event) {
updateEnablingState(false);
};
updateEnablingState(true);
}
/**
* If the FM radio is seeking currently, cancel it and then set frequency.
*
* @param {freq} frequency set.
*/
function cancelSeekAndSetFreq(frequency) {
function setFreq() {
mozFMRadio.setFrequency(frequency);
}
var seeking = !!$('power-switch').getAttribute('data-seeking');
if (!seeking) {
setFreq();
} else {
var request = mozFMRadio.cancelSeek();
request.onsuccess = setFreq;
request.onerror = setFreq;
}
}
var frequencyDialer = {
unit: 2,
_bandUpperBound: 0,
_bandLowerBound: 0,
_minFrequency: 0,
_maxFrequency: 0,
_currentFreqency: 0,
_translateX: 0,
init: function() {
// First thing is to show a warning if there // is not antenna.
updateWarningModeUI();
this._initUI();
this.setFrequency(mozFMRadio.frequency);
this._addEventListeners();
},
_addEventListeners: function() {
function _removeEventListeners() {
document.body.removeEventListener('touchend', fd_body_touchend, false);
document.body.removeEventListener('touchmove', fd_body_touchmove, false);
}
function cloneEvent(evt) {
if ('touches' in evt) {
evt = evt.touches[0];
}
return { x: evt.clientX, y: evt.clientX,
timestamp: evt.timeStamp };
}
var self = this;
var SPEED_THRESHOLD = 0.1;
var currentEvent, startEvent, currentSpeed;
var tunedFrequency = 0;
function toFixed(frequency) {
return parseFloat(frequency.toFixed(1));
}
function _calcSpeed() {
var movingSpace = startEvent.x - currentEvent.x;
var deltaTime = currentEvent.timestamp - startEvent.timestamp;
var speed = movingSpace / deltaTime;
currentSpeed = parseFloat(speed.toFixed(2));
}
function _calcTargetFrequency() {
return tunedFrequency - getMovingSpace() / self._space;
}
function getMovingSpace() {
var movingSpace = currentEvent.x - startEvent.x;
return movingSpace;
}
function fd_body_touchmove(event) {
event.stopPropagation();
currentEvent = cloneEvent(event);
_calcSpeed();
// move dialer
var dialer = $('frequency-dialer');
var translateX = self._translateX + getMovingSpace();
self._translateX = translateX;
var count = dialer.childNodes.length;
for (var i = 0; i < count; i++) {
var child = dialer.childNodes[i];
child.style.MozTransform = 'translateX(' + translateX + 'px)';
}
tunedFrequency = _calcTargetFrequency();
var roundedFrequency = Math.round(tunedFrequency * 10) / 10;
if (roundedFrequency != self._currentFreqency) {
self.setFrequency(toFixed(roundedFrequency), true);
}
startEvent = currentEvent;
}
function fd_body_touchend(event) {
event.stopPropagation();
_removeEventListeners();
// Add animation back
$('frequency-dialer').classList.add('animation-on');
// Add momentum if speed is higher than a given threshold.
if (Math.abs(currentSpeed) > SPEED_THRESHOLD) {
var direction = currentSpeed > 0 ? 1 : -1;
tunedFrequency += Math.min(Math.abs(currentSpeed) * 3, 3) * direction;
}
tunedFrequency = self.setFrequency(toFixed(tunedFrequency));
cancelSeekAndSetFreq(tunedFrequency);
// Reset vars
currentEvent = null;
startEvent = null;
currentSpeed = 0;
}
function fd_touchstart(event) {
event.stopPropagation();
// Stop animation
$('frequency-dialer').classList.remove('animation-on');
startEvent = currentEvent = cloneEvent(event);
tunedFrequency = self._currentFreqency;
_removeEventListeners();
document.body.addEventListener('touchmove', fd_body_touchmove, false);
document.body.addEventListener('touchend', fd_body_touchend, false);
}
function fd_key(event) {
if (event.keyCode === event.DOM_VK_UP) {
tunedFrequency = self._currentFreqency + 0.1;
} else if (event.keyCode === event.DOM_VK_DOWN) {
tunedFrequency = self._currentFreqency - 0.1;
} else {
return;
}
tunedFrequency = self.setFrequency(toFixed(tunedFrequency));
cancelSeekAndSetFreq(tunedFrequency);
}
var dialerContainer = $('dialer-container');
dialerContainer.addEventListener('touchstart', fd_touchstart, false);
// ACCESSIBILITY - Add keypress event for screen reader
dialerContainer.addEventListener('keypress', fd_key, false);
},
_initUI: function() {
$('frequency-dialer').innerHTML = '';
var lower = this._bandLowerBound = mozFMRadio.frequencyLowerBound;
var upper = this._bandUpperBound = mozFMRadio.frequencyUpperBound;
var unit = this.unit;
this._minFrequency = lower - lower % unit;
this._maxFrequency = upper + unit - upper % unit;
var unitCount = (this._maxFrequency - this._minFrequency) / unit;
for (var i = 0; i < unitCount; ++i) {
var start = this._minFrequency + i * unit;
start = start < lower ? lower : start;
var end = this._maxFrequency + i * unit + unit;
end = upper < end ? upper : end;
this._addDialerUnit(start, end);
}
// cache the size of dialer
var _dialerUnits = $$('#frequency-dialer .dialer-unit');
var _dialerUnitWidth = _dialerUnits[0].clientWidth;
this._dialerWidth = _dialerUnitWidth * _dialerUnits.length;
this._space = this._dialerWidth /
(this._maxFrequency - this._minFrequency);
for (i = 0; i < _dialerUnits.length; i++) {
_dialerUnits[i].style.left = i * _dialerUnitWidth + 'px';
}
},
_addDialerUnit: function(start, end) {
var markStart = start - start % this.unit;
// At the beginning and end of the dial, some of the notches should be
// hidden. To do this, we use an absolutely positioned div mask.
// startMaskWidth and endMaskWidth track how wide that mask should be.
var startMaskWidth = 0;
var endMaskWidth = 0;
// unitWidth is how wide each notch is that needs to be covered.
var unitWidth = 16;
var total = this.unit * 10; // 0.1MHz
for (var i = 0; i < total; i++) {
var dialValue = markStart + i * 0.1;
if (dialValue < start) {
startMaskWidth += unitWidth;
} else if (dialValue > end) {
endMaskWidth += unitWidth;
}
}
var container = document.createElement('div');
container.classList.add('dialer-unit-mark-box');
if (startMaskWidth > 0) {
var markEl = document.createElement('div');
markEl.classList.add('dialer-unit-mark-mask-start');
markEl.style.width = startMaskWidth + 'px';
container.appendChild(markEl);
}
if (endMaskWidth > 0) {
var markEnd = document.createElement('div');
markEnd.classList.add('dialer-unit-mark-mask-end');
markEnd.style.width = endMaskWidth + 'px';
container.appendChild(markEnd);
}
var width = (100 / this.unit) + '%';
// Show the frequencies on dialer
for (var j = 0; j < this.unit; j++) {
var frequency = Math.floor(markStart) + j;
var showFloor = frequency >= start && frequency <= end;
var unit = document.createElement('div');
unit.classList.add('dialer-unit-floor');
if (!showFloor) {
unit.classList.add('hidden-block');
}
unit.style.width = width;
unit.appendChild(document.createTextNode(frequency));
container.appendChild(unit);
}
var dialerUnit = document.createElement('div');
dialerUnit.className = 'dialer-unit';
dialerUnit.appendChild(container);
$('frequency-dialer').appendChild(dialerUnit);
},
_updateUI: function(frequency, ignoreDialer) {
document.l10n.setAttributes($('frequency'), 'frequency-MHz', {
value: frequency.toFixed(1)
});
if (true !== ignoreDialer) {
this._translateX = (this._minFrequency - frequency) * this._space;
var dialer = $('frequency-dialer');
var count = dialer.childNodes.length;
for (var i = 0; i < count; i++) {
dialer.childNodes[i].style.MozTransform =
'translateX(' + this._translateX + 'px)';
}
$('dialer-container').setAttribute('aria-valuenow', frequency);
}
},
setFrequency: function(frequency, ignoreDialer) {
if (frequency < this._bandLowerBound) {
frequency = this._bandLowerBound;
}
if (frequency > this._bandUpperBound) {
frequency = this._bandUpperBound;
}
this._currentFreqency = frequency;
this._updateUI(frequency, ignoreDialer);
return frequency;
},
getFrequency: function() {
return this._currentFreqency;
}
};
var historyList = {
_historyList: [],
/**
* Storage key name.
* @const
* @type {string}
*/
KEYNAME: 'historylist',
/**
* Maximum size of the history
* @const
* @type {integer}
*/
SIZE: 1,
init: function hl_init(callback) {
var self = this;
window.asyncStorage.getItem(this.KEYNAME, function storage_getItem(value) {
self._historyList = value || [];
if (typeof callback == 'function') {
callback();
}
});
},
_save: function hl_save() {
window.asyncStorage.setItem(this.KEYNAME, this._historyList);
},
/**
* Add frequency to history list.
*
* @param {freq} frequency to add.
*/
add: function hl_add(freq) {
if (freq == null) {
return;
}
var self = this;
self._historyList.push({
name: freq + '',
frequency: freq
});
if (self._historyList.length > self.SIZE) {
self._historyList.shift();
}
self._save();
},
/**
* Get the last frequency tuned
*
* @return {freq} the last frequency tuned.
*/
last: function hl_last() {
if (this._historyList.length === 0) {
return null;
}
else {
return this._historyList[this._historyList.length - 1];
}
}
};
var favoritesList = {
_favList: null,
KEYNAME: 'favlist',
init: function(callback) {
var self = this;
window.asyncStorage.getItem(this.KEYNAME, function storage_getItem(value) {
self._favList = value || { };
self._showListUI();
if (typeof callback == 'function') {
callback();
}
});
var _container = $('fav-list-container');
_container.addEventListener('click', function _onclick(event) {
var frequency = self._getElemFreq(event.target);
if (!frequency) {
return;
}
if (event.target.classList.contains('fav-list-remove-button')) {
// Remove the item from the favorites list.
self.remove(frequency);
updateFreqUI();
} else {
if (mozFMRadio.enabled) {
cancelSeekAndSetFreq(frequency);
} else {
// If fm is disabled, turn the radio on.
enableFMRadio(frequency);
}
}
});
},
_save: function() {
window.asyncStorage.setItem(this.KEYNAME, this._favList);
},
_showListUI: function() {
var self = this;
this.forEach(function(f) {
self._addItemToListUI(f);
});
},
_addItemToListUI: function(item) {
var container = $('fav-list-container');
var elem = document.createElement('div');
elem.id = this._getUIElemId(item);
elem.className = 'fav-list-item';
elem.setAttribute('role', 'option');
var subElem = document.createElement('div');
subElem.className = 'fav-list-frequency';
document.l10n.setAttributes(subElem, 'fav-frequency-MHz', {
value: item.frequency.toFixed(1)
});
elem.appendChild(subElem);
subElem = document.createElement('div');
subElem.className = 'fav-list-remove-button';
elem.appendChild(subElem);
// keep list ascending sorted
if (container.childNodes.length === 0) {
container.appendChild(elem);
} else {
var childNodes = container.childNodes;
for (var i = 0; i < childNodes.length; i++) {
var child = childNodes[i];
var elemFreq = this._getElemFreq(child);
if (item.frequency < elemFreq) {
container.insertBefore(elem, child);
break;
} else if (i == childNodes.length - 1) {
container.appendChild(elem);
break;
}
}
}
return elem;
},
_removeItemFromListUI: function(freq) {
if (!this.contains(freq)) {
return;
}
var itemElem = $(this._getUIElemId(this._favList[freq]));
if (itemElem) {
itemElem.parentNode.removeChild(itemElem);
}
},
_getUIElemId: function(item) {
return 'frequency-' + item.frequency;
},
_getElemFreq: function(elem) {
// ensure we get the closest list-item.
elem = elem.closest('.fav-list-item');
return parseFloat(elem.id.substring(elem.id.indexOf('-') + 1));
},
forEach: function(callback) {
for (var freq in this._favList) {
callback(this._favList[freq]);
}
},
/**
* Check if frequency is in fav list.
*
* @param {number} frequence to check.
*
* @return {boolean} True if freq is in fav list.
*/
contains: function(freq) {
if (!this._favList) {
return false;
}
return typeof this._favList[freq] != 'undefined';
},
/**
* Add frequency to fav list.
*/
add: function(freq) {
if (!this.contains(freq)) {
this._favList[freq] = {
name: freq + '',
frequency: freq
};
this._save();
// show the item in favorites list.
var elem = this._addItemToListUI(this._favList[freq]);
window.scrollTo(0, elem.offsetTop);
}
},
/**
* Remove frequency from fav list.
*
* @param {number} freq to remove.
*
* @return {boolean} True if freq to remove is in fav list.
*/
remove: function(freq) {
var exists = this.contains(freq);
this._removeItemFromListUI(freq);
delete this._favList[freq];
this._save();
return exists;
},
select: function(freq) {
var items = $$('#fav-list-container div.fav-list-item');
for (var i = 0; i < items.length; i++) {
var item = items[i];
if (this._getElemFreq(item) == freq) {
item.classList.add('selected');
item.setAttribute('aria-selected', true);
} else {
item.classList.remove('selected');
item.setAttribute('aria-selected', false);
}
}
}
};
function updateFreqUI() {
historyList.add(mozFMRadio.frequency);
frequencyDialer.setFrequency(mozFMRadio.frequency);
var frequency = frequencyDialer.getFrequency();
favoritesList.select(frequency);
var bookmarkButton = $('bookmark-button');
bookmarkButton.dataset.bookmarked = favoritesList.contains(frequency);
bookmarkButton.setAttribute('aria-pressed',
favoritesList.contains(frequency));
}
function init() {
frequencyDialer.init();
function onclick_seekbutton(event) {
/* jshint validthis: true */
var seekButton = this;
var powerSwitch = $('power-switch');
var seeking = !!powerSwitch.getAttribute('data-seeking');
var up = seekButton.id == 'frequency-op-seekup';
function seek() {
powerSwitch.dataset.seeking = true;
var request = up ? mozFMRadio.seekUp() : mozFMRadio.seekDown();
request.onsuccess = function seek_onsuccess() {
powerSwitch.removeAttribute('data-seeking');
};
request.onerror = function seek_onerror() {
powerSwitch.removeAttribute('data-seeking');
};
}
// If the FM radio is seeking channel currently, cancel it and seek again.
if (seeking) {
var request = mozFMRadio.cancelSeek();
request.onsuccess = seek;
request.onerror = seek;
} else {
seek();
}
}
$('frequency-op-seekdown').addEventListener('click',
onclick_seekbutton, false);
$('frequency-op-seekup').addEventListener('click',
onclick_seekbutton, false);
$('power-switch').addEventListener('click', function toggle_fm() {
if (mozFMRadio.enabled) {
mozFMRadio.disable();
} else {
enableFMRadio(frequencyDialer.getFrequency());
}
}, false);
$('bookmark-button').addEventListener('click', function toggle_bookmark() {
var frequency = frequencyDialer.getFrequency();
if (favoritesList.contains(frequency)) {
favoritesList.remove(frequency);
} else {
favoritesList.add(frequency);
}
updateFreqUI();
}, false);
var speakerManager = new window.SpeakerManager();
$('speaker-switch').addEventListener('click', function toggle_speaker() {
speakerManager.forcespeaker = !speakerManager.speakerforced;
}, false);
speakerManager.onspeakerforcedchange = function onspeakerforcedchange() {
var speakerSwitch = $('speaker-switch');
speakerSwitch.dataset.speakerOn = speakerManager.speakerforced;
speakerSwitch.setAttribute('aria-pressed', speakerManager.speakerforced);
};
mozFMRadio.onfrequencychange = updateFreqUI;
mozFMRadio.onenabled = function() {
updateEnablingState(false);
};
mozFMRadio.ondisabled = function() {
updateEnablingState(false);
};
mozFMRadio.onantennaavailablechange = function onAntennaChange() {
updateWarningModeUI();
if (mozFMRadio.antennaAvailable) {
// If the FM radio is enabled or enabling when the antenna is unplugged,
// turn the FM radio on again.
if (!!window._previousFMRadioState || !!window._previousEnablingState) {
enableFMRadio(frequencyDialer.getFrequency());
}
} else {
// Remember the current state of the FM radio
window._previousFMRadioState = mozFMRadio.enabled;
window._previousEnablingState = enabling;
mozFMRadio.disable();
}
};
// Disable the power button and the fav list when the airplane mode is on.
updateWarningModeUI();
AirplaneModeHelper.addEventListener('statechange', function(status) {
airplaneModeEnabled = status === 'enabled';
updateWarningModeUI();
});
// Load the fav list and enable the FM radio if an antenna is available.
historyList.init(function hl_ready() {
if (mozFMRadio.antennaAvailable) {
// Enable FM immediately
if (historyList.last() && historyList.last().frequency) {
enableFMRadio(historyList.last().frequency);
} else {
enableFMRadio(mozFMRadio.frequencyLowerBound);
}
favoritesList.init(updateFreqUI);
} else {
// Mark the previous state as True,
// so the FM radio be enabled automatically
// when the headset is plugged.
window._previousFMRadioState = true;
updateWarningModeUI();
favoritesList.init();
}
updatePowerUI();
// PERFORMANCE MARKER (5): fullyLoaded
// Designates that the app is *completely* loaded and all relevant
// "below-the-fold" content exists in the DOM, is marked visible,
// has its events bound and is ready for user interaction. All
// required startup background processing should be complete.
window.performance.mark('fullyLoaded');
});
}
document.l10n.ready.then(function() {
// PERFORMANCE MARKER (1): navigationLoaded
// Designates that the app's *core* chrome or navigation interface
// exists in the DOM and is marked as ready to be displayed.
window.performance.mark('navigationLoaded');
AirplaneModeHelper.ready(function() {
airplaneModeEnabled = AirplaneModeHelper.getStatus() == 'enabled';
init();
// PERFORMANCE MARKER (2): navigationInteractive
// Designates that the app's *core* chrome or navigation interface
// has its events bound and is ready for user interaction.
window.performance.mark('navigationInteractive');
// PERFORMANCE MARKER (3): visuallyLoaded
// Designates that the app is visually loaded (e.g.: all of the
// "above-the-fold" content exists in the DOM and is marked as
// ready to be displayed).
window.performance.mark('visuallyLoaded');
// PERFORMANCE MARKER (4): contentInteractive
// Designates that the app has its events bound for the minimum
// set of functionality to allow the user to interact with the
// "above-the-fold" content.
window.performance.mark('contentInteractive');
});
});
// Turn off radio immediately when window is unloaded.
window.addEventListener('unload', function(e) {
mozFMRadio.disable();
}, false);

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

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

View file

@ -0,0 +1,18 @@
noAntenna = Plug in a headset
noAntennaMsg = FM Radio requires a plugged in headset to receive radio signals.
airplaneModeHeader = Airplane mode is on
airplaneModeMsg = Turn off Airplane mode to use FM Radio.
frequency-MHz = {{ value }} <small>MHz</small>
fav-frequency-MHz = {{ value }} <small>MHz</small>
# The following strings are spoken by screen readers and not shown on the
# screen.
speaker-switch.ariaLabel = Speaker
bookmark-button.ariaLabel = Favorite
dialer-container.ariaLabel = Frequency
frequency-op-seekdown.ariaLabel = Seek Down
frequency-op-seekup.ariaLabel = Seek Up
power-switch-on.ariaLabel = On
power-switch-off.ariaLabel = Off
fav-list-container.ariaLabel = Favorited stations

47
gaia/apps/fm/manifest.webapp Executable file
View file

@ -0,0 +1,47 @@
{
"name": "FM Radio",
"description": "Gaia FM Radio",
"type": "certified",
"launch_path": "/index.html",
"developer": {
"name": "The Gaia Team",
"url": "https://github.com/mozilla-b2g/gaia"
},
"permissions": {
"themeable": {},
"fmradio": {},
"settings": {
"access": "readonly"
},
"speaker-control": {},
"audio-channel-content": {},
"moz-extremely-unstable-and-will-change-webcomponents": {}
},
"locales": {
"ar": {
"name": "FM Radio",
"description": "Gaia FM Radio"
},
"en-US": {
"name": "FM Radio",
"description": "Gaia FM Radio"
},
"fr": {
"name": "Radio FM",
"description": "Radio FM de Gaia"
},
"zh-TW": {
"name": "收音机",
"description": "收音机"
}
},
"default_locale": "en-US",
"icons": {
"84": "/style/icons/fm_84.png",
"126": "/style/icons/fm_126.png",
"142": "/style/icons/fm_142.png",
"189": "/style/icons/fm_189.png",
"284": "/style/icons/fm_284.png"
},
"orientation": "default"
}

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

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

522
gaia/apps/fm/style/fm.css Executable file
View file

@ -0,0 +1,522 @@
html, body {
padding: 0;
border: 0;
margin: 0;
height: 100%;
overflow: hidden;
color: #FFF;
font-size: 10px;
}
/**
* 1. No content inside the app
* should be user-selectable.
*/
* {
-moz-user-select: none; /* 1 */
}
a {
outline: 0 none;
text-decoration: none;
color: #FFF;
}
a:active {
color: #000;
background-color: #00ABCD;
}
.hidden-block {
visibility: hidden;
}
#container {
height: 100%;
width: 100%;
background-color: #2F3337;
}
#container > div {
position: absolute;
left: 0;
width: 100%;
}
/****** styles for frequency view *******/
#frequency-bar {
border-bottom: 1px solid #24292E;
box-sizing: border-box;
top: 0;
height: 10rem;
width: 100%;
text-align: center;
background-color: #353A41;
overflow: hidden;
transition: background-color 680ms ease;
}
#frequency-bar.dim {
background-color: #232323;
}
#frequency-bar > div {
text-align: center;
}
#frequency-bar a {
position: absolute;
z-index: 1;
line-height: 4rem;
width: 3.8rem;
height: 3.8rem;
font-size: 1.4rem;
border-radius: 50%;
display: inline-block;
margin-top: 3rem;
box-shadow: 0 -.1rem .1rem #111;
}
#frequency-display {
position: relative;
transition: opacity 680ms ease;
}
.dim #frequency-display {
opacity: 0.1;
}
#bookmark-button {
top: 0;
right: 2rem;
}
#frequency {
font-size: 6rem;
font-weight: 300;
color: #fff;
padding-top: 1.5rem;
position: absolute;
width: 100%;
height: 10rem;
display: block;
top: 0;
left: 0;
}
#frequency small {
font-size: 1.5rem;
padding-left: 0.3rem;
}
#bookmark-button {
background: url("images/toggle-fav-star-off.png") no-repeat center center / 4rem;
}
#bookmark-button:focus {
border-color: #888;
}
#bookmark-button:active,
#bookmark-button[data-bookmarked="true"]:active {
background: #00ABCD url("images/toggle-fav-star-pressed.png") no-repeat center center / 4rem;
}
#bookmark-button[data-bookmarked="true"] {
background: url("images/toggle-fav-star-on.png") no-repeat center center / 4rem;
}
#speaker-switch {
top: 0;
left: 2rem;
background: url("images/speaker-off.png") no-repeat center center / 4rem;
}
#speaker-switch:focus {
border-color: #888;
}
#speaker-switch:active,
#speaker-switch[data-speaker-on="true"]:active {
background: #00ABCD url("images/speaker-pressed.png") no-repeat center center / 4rem;
}
#speaker-switch[data-speaker-on="true"] {
background: url("images/speaker-on.png") no-repeat center center / 4rem;
}
/***** end *****/
/**** styles for frequency dialer *****/
#dialer-bar {
top: 10rem;
font-size: 1.4rem;
font-weight: 400;
height: 9rem;
overflow: hidden;
background: #2F3337;
color: #FFF;
box-shadow: 0 0.2rem 0.4rem rgba(0, 0, 0, 0.3);
}
#dialer-container {
box-shadow: inset 0 0.05rem 0 #3A3F44;
position: relative;
padding-left: 50%;
margin: 0 2rem;
height: 100%;
}
#dialer-container:before,
#dialer-container:after {
content: "";
background-color: #474E57;
box-shadow: inset 0 0.05rem 0 #585E65;
position: absolute;
width: 2rem;
height: 100%;
z-index: 1;
pointer-events: none;
}
#dialer-container:before {
border-right: 0.1rem solid #24292E;
left: -2rem;
}
#dialer-container:after {
border-left: 0.1rem solid #24292E;
right: -2rem;
}
#frequency-indicator {
border-radius: 50%/0.1rem;
height: 8.6rem;
width: 1.6rem;
margin: 0.6rem 0 0 -0.8rem;
position: absolute;
z-index: 1;
background: url(images/selector.png) no-repeat center center / 1.6rem;
top: 0;
left: 50%;
}
div.animation-on > div {
transition: -moz-transform var(--transition-duration) ease 0s;
}
#frequency-dialer {
display: inline-block;
display: -moz-inline-box;
-moz-user-select: none;
width: 100%;
margin-left: -2.2rem;
position: absolute;
top: 0;
}
#frequency-dialer .dialer-unit-mark-box {
background: url(images/dial-notches.png) no-repeat 0 3.4rem / 32rem;
overflow: hidden;
height: 5.5rem;
}
#frequency-dialer .dialer-unit {
text-align: left;
height: 7.5rem;
position: absolute;
width: 32rem;
}
/* The beginning and end of the dial are masked to hide notches that
are out of range. */
.dialer-unit-mark-mask-start,
.dialer-unit-mark-mask-end {
background-color: #2F3337;
display: block;
height: 5.5rem;
position: absolute;
}
/* The mask at the end should be jogged left by 0.3rem, since the
mask is intended to cover everything except the first notch. */
.dialer-unit-mark-mask-end {
margin-left: .3rem;
}
#frequency-dialer .dialer-unit-floor {
color: #AAA;
float: left;
}
/**** end *******/
/***** styles for favorites list ******/
div#fav-list {
overflow: auto;
top: 19rem;
bottom: 9rem;
}
div#fav-list-container {
max-height: 100%;
position: absolute;
bottom: 0;
width: 100%;
}
div#fav-list-container > div:before {
content: "";
position: absolute;
top: 0;
left: 2rem;
right: 2rem;
width: calc(100% - 4rem);
height: .1rem;
background-color: #3D4045;
}
div#fav-list-container > div {
position: relative;
font-size: 2.3rem;
font-weight: 300;
height: 4.4rem;
padding-bottom: 0.2rem;
clear: both;
}
div#fav-list-container > div:active {
background-color: rgba(52,140,158, .6);
}
div#fav-list-container > div:active:before,
div#fav-list-container > div:active + div:before {
background-color: transparent;
}
div#fav-list-container div.fav-list-remove-button {
height: 100%;
width: 4rem;
margin-left: 2rem;
float: right;
cursor: pointer;
background: url("images/fav-star.png") no-repeat center center / 1.4rem;
}
div#fav-list-container div.selected div.fav-list-remove-button {
background: url("images/fav-star-selected.png") no-repeat center center / 1.4rem;
}
div#fav-list-container div.fav-list-frequency {
float: right;
padding: 0.8rem -moz-calc(50% - 2.4rem) 0 0;
text-align: right;
width: 6rem;
}
div#fav-list-container div.fav-list-frequency small {
font-size: 1.5rem;
position: absolute;
padding: .6rem .2rem 0 1rem;
}
/***** end *****/
/***** styles for status bars *****/
#action-bar {
bottom: 0;
border-top: .1rem solid #3A3E46;
box-shadow: 0 0 .2rem #111;
height: 9rem;
overflow: hidden;
}
/* Styles for action bar */
/* FIX: it doesn't hide itself */
#action-bar[hidden] {
display: none;
}
#action-bar > div {
text-align: center;
padding: 1rem 0 0;
width: 33.333%;
float: left;
}
#action-bar > div:first-child {
text-align: right;
}
#action-bar > div:last-child {
text-align: left;
}
#action-bar a {
display: inline-block;
border: none;
font-size: 2.5rem;
width: 7rem;
height: 7rem;
border-radius: 50%;
text-align: center;
box-shadow: 0 .1rem .1rem #A8A8A8 inset, 0 .2rem 0 #1e2226;
}
#action-bar a:active {
color: #FFF;
background-color: #00ABCD;
box-shadow: 0 .1rem .1rem #3F6978 inset, 0 .2rem 0 #1e2226;
}
#power-switch, #power-switch span {
background-image: url(images/play.png), url(images/reflection-72.png);
background-repeat: no-repeat, no-repeat;
background-color: transparent;
background-position: center center, 0 0;
background-size: 7.2rem;
overflow: hidden;
position: relative;
}
#power-switch:active {
background-image: url(images/play.png);
}
#power-switch[data-enabled="true"] {
background-image: url(images/stop.png), url(images/reflection-72.png);
}
#power-switch[data-enabled="true"]:active {
background-image: url(images/stop.png);
}
#power-switch span {
background-image: url(images/spinner-FMRadio.png);
background-size: 6.6rem;
display: none;
height: 6.6rem;
left: 0.2rem;
position: absolute;
top: 0.2rem;
width: 6.6rem;
}
#power-switch[data-enabling="true"] span,
#power-switch[data-seeking="true"] span {
animation: 0.9s spinner-animation infinite steps(30);
display: block;
}
@keyframes spinner-animation {
from {
transform: rotate(1deg);
}
to {
transform: rotate(360deg);
}
}
a#frequency-op-seekdown {
width: 5rem;
height: 5rem;
margin: 1rem 0;
background-image: url(images/seekdown.png), url(images/reflection-56.png);
background-repeat: no-repeat, no-repeat;
background-color: transparent;
background-position: center center;
background-size: 5.6rem;
}
a#frequency-op-seekdown:active {
background-image: url(images/seekdown.png);
}
a#frequency-op-seekup {
width: 5rem;
height: 5rem;
margin: 1rem 0;
background-image: url(images/seekup.png), url(images/reflection-56.png);
background-repeat: no-repeat, no-repeat;
background-color: transparent;
background-position: center center;
background-size: 5.6rem;
}
a#frequency-op-seekup:active {
background-image: url(images/seekup.png);
}
/**** end ****/
/*** styles for warning box ****/
.warning {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
color: #FFF;
background-color: #2d2d2d;
}
.warning .warning-header {
padding: 1rem 3rem 1rem 3rem;
font-weight: normal;
font-size: 1.9rem;
color: #fff;
}
.warning .warning-body {
font-weight: 300;
font-size: 2.5rem;
padding: .5rem;
border-top: .1rem solid #686868;
margin: 0 2.5rem;
}
/* Press (default & recommend) */
.warning .menu button:active {
border-color: #008aaa;
color: #333;
}
/**** end ****/
/*** styles for antenna warning box ****/
#antenna-warning {
z-index: 2;
}
#antenna-warning:not([hidden]) {
display: flex;
flex-direction: column;
}
#antenna-warning > div:first-child {
height: 60%;
background: url(images/headset.png) no-repeat center center / 18.4rem;
}
#antenna-warning .warning-header {
margin-top: -2.5rem;
}
/******* end *******/
/*** styles for airplane mode warning box ****/
#airplane-mode-warning {
z-index: 3;
}
#airplane-mode-warning .warning-header {
margin-top: 50%;
}
/******* end *******/
/*** styles for RTL languages ****/
/* The scrollbar should be on the left.
* But direction is explicitly set to LTR on #container
So we force #fav-list to be RTL, while keeping the children LTR
*/
html[dir=rtl] #fav-list {
direction: rtl;
}
html[dir=rtl] #fav-list > * {
direction: ltr;
}
/******* end *******/

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 B

View file

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

View file

@ -0,0 +1,146 @@
/* global marionette, require, setup, suite, test */
'use strict';
var assert = require('assert');
var Fm = require('./lib/fm.js');
marionette('FM Radio test', function() {
var client = marionette.client({
profile: {},
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
var fm;
setup(function() {
fm = new Fm(client);
fm.launch();
fm.waitForLoaded();
});
suite('FM Radio UI', function() {
test('Test basic UI', function() {
try {
var freq = fm.getCurrentFrequency();
assert.equal(freq, '87.5');
var dialFreq = fm.getDialFreq();
assert.equal(dialFreq, '87.5');
assert.equal(freq, dialFreq);
// test seek up and down
var startFreq = freq;
fm.seekUp();
freq = fm.getCurrentFrequency();
assert.notEqual(freq, startFreq);
dialFreq = fm.getDialFreq();
assert.equal(parseFloat(dialFreq), parseFloat(freq));
fm.seekDown();
freq = fm.getCurrentFrequency();
assert.equal(freq, startFreq);
dialFreq = fm.getDialFreq();
assert.equal(parseFloat(dialFreq), parseFloat(freq));
// see about the frequency being saved.
fm.seekUp();
startFreq = fm.getCurrentFrequency();
fm.close();
fm.launch();
fm.waitForLoaded();
freq = fm.getCurrentFrequency();
assert.equal(parseFloat(freq), parseFloat(startFreq));
} catch(e) {
assert.ok(false, e.stack);
}
});
});
suite('FM Radio favourites', function() {
test('Test favouriting', function() {
try {
fm.seekUp();
var isFav = fm.isFav();
assert.ok(!isFav, 'Frequency shouldn\'t have been favourited');
fm.fav();
isFav = fm.isFav();
assert.ok(isFav, 'Frequency should be favourited');
fm.close();
fm.launch();
fm.waitForLoaded();
isFav = fm.isFav();
assert.ok(isFav, 'Frequency should be favourited after reboot.');
// checking we have a selected favourite.
var elem = fm.selectedFavItem();
assert.ok(elem, 'Can\'t find selected favourite');
var freq = fm.getCurrentFrequency();
// checking the text content of the favourite.
var favFreq = fm.selectedFavItemText();
assert.equal(favFreq, freq,
'Current frequency and selected favourite do not match');
// checking the id of the favourite.
var id = elem.getAttribute('id');
favFreq = id.substring(id.indexOf('-') + 1);
assert.equal(
parseFloat(favFreq), parseFloat(freq),
'Current frequency and selected favourite ID do not match');
fm.seekUp();
var newFreq = fm.getCurrentFrequency();
assert.notEqual(freq, newFreq);
fm.fav();
assert.ok(fm.isFav());
// count number of favs. Should be 2.
var elems = client.findElement(Fm.Selector.favList).
findElements(Fm.Selector.favItem);
assert.equal(elems.length, 2);
favFreq = fm.selectedFavItemText();
assert.equal(favFreq, newFreq);
elem.tap();
assert.ok(fm.isFav());
favFreq = fm.selectedFavItemText();
assert.notEqual(favFreq, newFreq);
// remove the favourite.
fm.fav();
assert.ok(!fm.isFav());
// count number of favs. Should be 1.
elems = client.findElement(Fm.Selector.favList).
findElements(Fm.Selector.favItem);
assert.equal(elems.length, 1);
} catch(e) {
assert.ok(false, e.stack);
}
});
});
});

View file

@ -0,0 +1,141 @@
/* global require, module */
'use strict';
var assert = require('assert');
function Fm(client, origin) {
this.client = client;
this.origin = origin || ('app://' + Fm.DEFAULT_ORIGIN);
this.actions = client.loader.getActions();
}
module.exports = Fm;
Fm.DEFAULT_ORIGIN = 'fm.gaiamobile.org';
Fm.Selector = Object.freeze({
currentFrequency: '#frequency',
dialerContainer: '#dialer-container',
seekDown: '#frequency-op-seekdown',
seekUp: '#frequency-op-seekup',
powerSwitch: '#power-switch',
favButton: '#bookmark-button',
favList: '#fav-list',
favListContainer: '#fav-list-container',
favItemSelected: '.fav-list-item.selected',
favItemSelectedFreq:'.fav-list-item.selected .fav-list-frequency',
favItem: '.fav-list-item',
});
Fm.prototype = {
client: null,
get powerSwitch() {
return this.client.findElement(Fm.Selector.powerSwitch);
},
get favButton() {
return this.client.findElement(Fm.Selector.favButton);
},
get favListContainer() {
return this.client.findElement(Fm.Selector.favListContainer);
},
// debug function.
debugDocument: function() {
var debug = this.client.executeScript(function() {
return document.documentElement.innerHTML;
});
console.log('debug', debug);
},
launch: function() {
this.client.switchToFrame();
this.client.apps.launch(this.origin);
this.client.apps.switchToApp(this.origin);
this.client.helper.waitForElement('body');
},
close: function() {
this.client.apps.close(this.origin);
},
// since we can't get the child text isolated with marionette.
// You might want to call _trimFreqTextContent() on the result.
_getTextNodeContent: function(sel) {
return this.client.executeScript(function(sel) {
return document.querySelector(sel).firstChild.textContent;
}, [sel]);
},
// make sure we have just the frequency
_trimFreqTextContent: function(freq) {
// for some reason these unicode delimiters are inserted
// in the text node
return freq.trim().replace(/[\u2068\u2069]/g, '');
},
getCurrentFrequency: function() {
var elem = this.client.findElement(Fm.Selector.currentFrequency);
assert.ok(elem, 'Couldn\'t find frequency');
var freq = this._getTextNodeContent(Fm.Selector.currentFrequency);
return this._trimFreqTextContent(freq);
},
getDialFreq: function() {
var elem = this.client.findElement(Fm.Selector.dialerContainer);
assert.ok(elem, 'Couldn\'t find dialer container');
var dialFreq = elem.getAttribute('aria-valuenow');
return dialFreq;
},
seekDown: function() {
var elem = this.client.findElement(Fm.Selector.seekDown);
assert.ok(elem, 'Seek Down button not found');
elem.click();
this.client.waitFor(function() {
return !this.powerSwitch.getAttribute('data-seeking');
}.bind(this));
},
seekUp: function() {
var elem = this.client.findElement(Fm.Selector.seekUp);
assert.ok(elem, 'Seek Down button not found');
elem.click();
this.client.waitFor(function() {
return !this.powerSwitch.getAttribute('data-seeking');
}.bind(this));
},
fav: function() {
var elem = this.favButton;
elem.tap();
},
selectedFavItem: function() {
return this.favListContainer.findElement(Fm.Selector.favItemSelected);
},
selectedFavItemText: function() {
var freq = this._getTextNodeContent(Fm.Selector.favItemSelectedFreq);
return this._trimFreqTextContent(freq);
},
isFav: function() {
return this.favButton.getAttribute('data-bookmarked') == 'true';
},
waitForLoaded: function() {
this.client.waitFor(function() {
return this.powerSwitch.getAttribute('data-enabled');
}.bind(this));
}
};

View file

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

386
gaia/apps/fm/test/unit/fm_test.js Executable file
View file

@ -0,0 +1,386 @@
'use strict';
/* global
$,
$$,
airplaneModeEnabled:true,
enabling,
favoritesList,
frequencyDialer,
historyList,
MockL10n,
mozFMRadio,
updateEnablingState,
updateFreqUI,
updateWarningModeUI */
requireApp('shared/js/airplane_mode_helper.js');
requireApp('fm/js/fm.js');
require('/shared/test/unit/load_body_html_helper.js');
require('/shared/test/unit/mocks/mock_l20n.js');
suite('FM', function() {
function setFrequency(frequency) {
var setFreq = frequencyDialer.setFrequency(frequency);
if (frequency < mozFMRadio.frequencyLowerBound) {
assert.equal(setFreq, mozFMRadio.frequencyLowerBound);
} else if (frequency > mozFMRadio.frequencyUpperBound) {
assert.equal(setFreq, mozFMRadio.frequencyUpperBound);
} else {
assert.equal(setFreq, frequency);
}
}
function testScreenReaderSwipe(swipeDirection, startFreq, expectedFreq) {
var modifier, key;
if (swipeDirection === 'up') {
modifier = 0.1;
key = 38; // DOM_VK_UP = 38
} else if (swipeDirection === 'down') {
modifier = -0.1;
key = 40; // DOM_VK_DOWN = 40
}
var keyEvent = document.createEvent('KeyboardEvent');
keyEvent.initKeyEvent('keypress',
true, true, window,
false, false, false, false,
key, 0);
frequencyDialer.setFrequency(startFreq);
$('dialer-container').dispatchEvent(keyEvent);
assert.equal(frequencyDialer._currentFreqency, expectedFreq);
}
function testPowerSwitchLabel() {
assert.equal(mozFMRadio.enabled ? 'power-switch-off' : 'power-switch-on',
$('power-switch').getAttribute('data-l10n-id'));
}
suite('frequency dialer', function() {
var nativeL10n = document.l10n;
suiteSetup(function() {
sinon.stub(favoritesList, '_save').returns(true);
favoritesList._favList = {};
sinon.stub(historyList, '_save').returns(true);
loadBodyHTML('/index.html');
document.l10n = MockL10n;
frequencyDialer.init();
});
suiteTeardown(function() {
document.l10n = nativeL10n;
document.body.innerHTML = '';
});
test('resolved frequency within bounds', function() {
setFrequency(92);
});
test('resolved frequency exceeding upperbound', function() {
setFrequency(mozFMRadio.frequencyUpperBound + 10);
});
test('resolved frequency exceeding lowerbound', function() {
setFrequency(mozFMRadio.frequencyLowerBound - 10);
});
test('retrieved frequency', function() {
assert.equal(frequencyDialer.getFrequency(), 87.5);
});
test('updated #frequency dom display digits', function() {
assert.deepEqual(
document.l10n.getAttributes($('frequency')),
{ id: 'frequency-MHz', args: { value: '87.5' } });
});
test('compare new set frequency with aria-valuenow', function() {
var frequency = 95.7;
frequencyDialer.setFrequency(frequency);
assert.equal($('dialer-container').getAttribute('aria-valuenow'),
frequency.toString());
});
test('screen reader swipe up on dialer', function() {
testScreenReaderSwipe('up', 95.7, 95.8);
testScreenReaderSwipe('down', 95.7, 95.6);
});
test('maintain aria-pressed on bookmark button', function() {
var bookmarkButton = $('bookmark-button');
// Start at arbitrary station
mozFMRadio.setFrequency(88.6);
updateFreqUI();
assert.equal('false', bookmarkButton.getAttribute('aria-pressed'));
// Add station to favorites
favoritesList.add(88.6);
updateFreqUI();
assert.equal('true', bookmarkButton.getAttribute('aria-pressed'));
// Browse to different station
mozFMRadio.setFrequency(99.5);
updateFreqUI();
assert.equal('false', bookmarkButton.getAttribute('aria-pressed'));
// Come back to first station
mozFMRadio.setFrequency(88.6);
updateFreqUI();
assert.equal('true', bookmarkButton.getAttribute('aria-pressed'));
// Remove station from favorites
favoritesList.remove(88.6);
updateFreqUI();
assert.equal('false', bookmarkButton.getAttribute('aria-pressed'));
});
// temporarily removing due to test not passing on TBPL Bug 876265
// test('changed horizontal position of dialer', function() {
// var prevX = frequencyDialer._translateX;
// frequencyDialer.setFrequency(100);
// assert.notEqual(frequencyDialer._translateX, prevX);
// });
test('#frequency display precision to one decimal point', function() {
var freq = document.l10n.getAttributes($('frequency')).args.value;
assert.ok(freq.indexOf('.') > -1);
});
});
suite('history list', function() {
setup(function() {
historyList._save = function() {
return true;
};
});
test('item added to history list', function() {
historyList.add(100);
assert.equal(historyList._historyList.length, 1);
});
test('gets the last frequency tuned', function() {
assert.equal(historyList.last().frequency, 100);
});
});
suite('favorite list', function() {
var nativeL10n = document.l10n;
suiteSetup(function() {
loadBodyHTML('/index.html');
document.l10n = MockL10n;
});
suiteTeardown(function() {
document.l10n = nativeL10n;
document.body.innerHTML = '';
});
test('item added to favorite list', function() {
favoritesList.add(100);
assert.equal(Object.keys(favoritesList._favList).length, 1);
});
test('added item is updated in DOM #fav-list', function() {
assert.equal($('fav-list-container').childNodes.length, 1);
});
test('contains a frequency', function() {
assert.ok(favoritesList.contains(100));
});
test('selected frequency in DOM #fav-list', function() {
favoritesList.select(100);
assert.ok($$('#fav-list-container div.fav-list-item')[0]
.classList.contains('selected'));
});
test('removed frequency from list', function() {
assert.ok(favoritesList.remove(100));
});
test('removed item is updated in DOM #fav-list', function() {
assert.equal($('fav-list-container').childNodes.length, 0);
});
test('items are in desceding order', function() {
favoritesList.add(100);
favoritesList.add(101);
favoritesList.add(102);
var items = $$('#fav-list-container div.fav-list-item');
var isAscending = true;
for (var i = 0, len = items.length; i < len - 1; i++) {
var itemA = items[i].childNodes[0].textContent;
var itemB = items[i + 1].childNodes[0].textContent;
if (itemA > itemB) {
isAscending = false;
break;
}
}
assert.ok(isAscending);
});
test('set aria-selected = true on active favorite stations, else false',
function() {
var testFreqs = [88.6, 103.7, 104.8, 55.6];
var favorites = $$('#fav-list-container div.fav-list-item');
favoritesList.add(88.6);
favoritesList.add(103.7);
favoritesList.add(104.8);
testFreqs.forEach(function(testFreq, index, array) {
favoritesList.select(testFreq);
for (var i = 0; i < favorites.length; i++) {
assert.equal(favoritesList._getElemFreq(favorites[i]) === testFreq ?
'true' : 'false', favorites[i].getAttribute('aria-selected'));
}
});
});
});
suite('update display states', function() {
var nativeL10n = document.l10n;
suiteSetup(function() {
mozFMRadio.enabled = true;
mozFMRadio.antennaAvailable = true;
loadBodyHTML('/index.html');
document.l10n = MockL10n;
updateEnablingState(true);
});
suiteTeardown(function() {
document.l10n = nativeL10n;
document.body.innerHTML = '';
});
suite('enabling UI', function() {
test('#power-switch enabled attribute is correctly set', function() {
assert.equal(!!$('power-switch').dataset.enabled, mozFMRadio.enabled);
});
test('#power-switchh enabling attribute is correctly set', function() {
assert.equal(!!$('power-switch').dataset.enabling, enabling);
});
test('#frequency-bar display is dimmed', function() {
assert.ok($('frequency-bar').classList.contains('dim'));
});
test('#antenna-warning is hidden', function() {
assert.ok(!!$('antenna-warning').hidden, mozFMRadio.antennaAvailable);
});
test('#power-switch has appropriate aria-label based on enabled status',
function() {
testPowerSwitchLabel();
$('power-switch').click();
testPowerSwitchLabel();
}
);
});
});
suite('update UI based on the airplane mode status', function() {
var nativeL10n = document.l10n;
suiteSetup(function() {
loadBodyHTML('/index.html');
document.l10n = MockL10n;
});
suiteTeardown(function() {
document.l10n = nativeL10n;
document.body.innerHTML = '';
});
suite('airplane mode on', function() {
setup(function() {
airplaneModeEnabled = true;
updateWarningModeUI();
});
test('#airplane-mode-warning is shown', function() {
assert.equal(!!$('airplane-mode-warning').hidden, false);
});
test('#container is hidden', function() {
assert.equal($('container').classList.contains('hidden-block'), true);
});
test('#antenna-warning is hidden', function() {
assert.equal(!!$('antenna-warning').hidden, true);
});
});
suite('airplane mode off', function() {
setup(function() {
airplaneModeEnabled = false;
updateWarningModeUI();
});
test('#airplane-mode-warning is hidden', function() {
assert.equal(!!$('airplane-mode-warning').hidden, true);
});
test('#antenna-warning is on and container is hidden', function() {
mozFMRadio.antennaAvailable = false;
updateWarningModeUI();
assert.equal(!!$('antenna-warning').hidden, false);
assert.equal($('container').classList.contains('hidden-block'), true);
});
test('#antenna-warning is off and container is visible', function() {
mozFMRadio.antennaAvailable = true;
updateWarningModeUI();
assert.equal(!!$('antenna-warning').hidden, true);
assert.equal($('container').classList.contains('hidden-block'), false);
});
});
});
suite('update UI based on the antenna status', function() {
var nativeL10n = document.l10n;
suiteSetup(function() {
loadBodyHTML('/index.html');
document.l10n = MockL10n;
});
suiteTeardown(function() {
document.l10n = nativeL10n;
document.body.innerHTML = '';
});
suite('antenna is plugged in', function() {
setup(function() {
mozFMRadio.antennaAvailable = true;
updateWarningModeUI();
});
test('#antenna-warning is hidden', function() {
assert.equal(!!$('antenna-warning').hidden, true);
});
});
suite('antenna is not plugged in', function() {
setup(function() {
mozFMRadio.antennaAvailable = false;
updateWarningModeUI();
});
test('#antenna-warning is shown', function() {
assert.equal(!!$('antenna-warning').hidden, false);
});
});
});
});