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

View file

@ -0,0 +1,141 @@
/* global CustomDialog, MozActivity, Toaster */
'use strict';
/**
* Create an actions menu to allow managing individual tones. Tones can be
* shared with other apps or (in the case of user-created tones) deleted from
* the system.
*
* @param {Node} menuElement The DOM node for the menu.
*/
function ActionsMenu(menuElement) {
this._menuElement = menuElement;
function getButton(action) {
return menuElement.querySelector('button[data-action="' + action + '"]');
}
this._shareButton = getButton('share');
this._deleteButton = getButton('delete');
this._shareButton.addEventListener('click', this._share.bind(this));
this._deleteButton.addEventListener('click', this._delete.bind(this));
}
ActionsMenu.prototype = {
/**
* Open the actions menu.
*
* @param {Tone} tone The tone to perform actions on.
* @param {Array} inUseAs An array representing the places using this tone.
* @param {Function} callback A callback to call when the actions menu is
* closed. Takes one argument: the action that was performed.
*/
open: function(tone, inUseAs, callback) {
if (!this._menuElement.hidden) {
throw new Error('actions menu is already open');
}
if (tone.shareable || tone.deletable) {
this._tone = tone;
this._callback = callback;
this._menuElement.hidden = false;
this._inUseAs = inUseAs;
this._shareButton.hidden = !tone.shareable;
this._shareButton.dataset.l10nId = 'actions-share-' + tone.type;
this._deleteButton.hidden = !tone.deletable;
this._deleteButton.dataset.l10nId = 'actions-delete-' + tone.type;
}
return !this._menuElement.hidden;
},
/**
* Handle clicking the "share" button.
*
* @param {Event} event The event.
*/
_share: function(event) {
var self = this;
this._menuElement.hide();
this._tone.getBlob().then(function(blob) {
var activity = new MozActivity({
name: 'share',
data: {
type: 'audio/*',
// Make sure we can't share with ourselves!
__bug1015513_hide_from_self__: true,
number: 1,
blobs: [blob],
filenames: [self._tone.filename],
metadata: [{
title: self._tone.name
}]
}
});
activity.onerror = function(e) {
console.warn('share activity error:', activity.error.name);
self._finish('share');
};
activity.onsuccess = function(e) {
self._finish('share');
};
});
},
/**
* Handle clicking the "delete" button.
*
* @param {Event} event The event.
*/
_delete: function(event) {
var self = this;
this._menuElement.hide();
var messageKey = 'delete-message';
if (self._inUseAs.length) {
messageKey += '-default-' + self._inUseAs[0];
}
var cancelButton = {
title: 'delete-cancel',
callback: function() {
CustomDialog.hide();
self._finish('cancel');
}
};
var confirmButton = {
title: 'delete-confirm',
callback: function() {
CustomDialog.hide();
Toaster.showToast({
messageL10nId: 'deleted-' + self._tone.type,
latency: 3000,
useTransition: true
});
self._tone.remove();
self._finish('delete');
}
};
CustomDialog.show(
'delete-title',
{id: messageKey, args: {tone: self._tone.name}},
cancelButton, confirmButton
);
},
/**
* Fire our callback function and clean up the internal state of the menu.
*
* @param {String} command The command that was selected.
*/
_finish: function(command) {
if (this._callback) {
this._callback(command);
}
this._tone = null;
this._callback = null;
this._inUseAs = null;
}
};

View file

@ -0,0 +1,260 @@
/* global Promise */
'use strict';
/*
* builtInRingtones is an object that provides access to the system-provided
* ringtones on the device. It has the following methods:
*
* list():
* Lists all the built-in tones of a given type.
*
* @param {String} toneType The type of tone to list ('ringtone' or
* 'alerttone').
* @return {Promise} A promise returning an array of BuiltInRingtones
* representing each tone.
*
* get():
* Gets a tone of the given type and id.
*
* @param {String} id The ID of the tone to get.
* @return {Promise} A promise returning the requested BuiltInRingtone.
*
* toneTypes:
* An array of the valid tone types ('ringtone' and 'alerttone').
*/
window.builtInRingtones = (function() {
var ID_PREFIX = 'builtin:';
var BASE_URLS = {
'ringtone': '/shared/resources/media/ringtones/',
'alerttone': '/shared/resources/media/notifications/'
};
var mimeTypeMap = {
'.mp3': 'audio/mp3',
'.mp4': 'audio/mp4',
'.ogg': 'audio/ogg',
'.opus': 'audio/ogg'
};
/**
* Try to guess the MIME type of a file based on its extension. It's a shame
* XHRs don't do this for us automatically!
*
* @param {String} filename The filename of the ringtone.
* @return {String} The MIME type.
*/
function inferMimeType(filename) {
var dot = filename.lastIndexOf('.');
if (dot === -1) {
console.warn('Couldn\'t infer mimetype for ' + filename);
return 'application/octet-stream';
}
var ext = filename.substr(dot);
return mimeTypeMap[ext] || 'application/octet-stream';
}
/**
* Create a new built-in ringtone object.
*
* @param {String} toneType The kind of tone (ringtone or alerttone).
* @param {String} filename The filename of the ringtone.
* @param {Object} toneDef The tone's definition from list.json.
*/
function BuiltInRingtone(toneType, filename, toneDef) {
this._toneType = toneType;
this._filename = filename;
this._l10nID = toneDef.l10nID;
}
BuiltInRingtone.prototype = {
/**
* @return {String} The filename without the extension.
*/
get _rootName() {
// Strip the file extension for the ID to make it easier to change the
// file extensions in the future.
return this._filename.replace(/\.\w+$/, '');
},
/**
* @return {String} The localized name of the tone. Assumes that mozL10n has
* been initialized.
*/
get name() {
return navigator.mozL10n.get(this.l10nID);
},
/**
* @return {String} The filename of the ringtone.
*/
get filename() {
return this._filename;
},
/**
* @return {String} The l10n ID for the tone's name.
*/
get l10nID() {
return this._l10nID;
},
/**
* @return {String} A unique ID for the tone.
*/
get id() {
return ID_PREFIX + this._toneType + '/' + this._rootName;
},
/**
* @return {String} The type of the tone. Either "ringtone" or "alerttone".
*/
get type() {
return this._toneType;
},
/**
* @return {String} A URL pointing to the tone's audio data.
*/
get url() {
return BASE_URLS[this._toneType] + this._filename;
},
/**
* @return {Boolean} Whether this ringtone is shareable (always true).
*/
get shareable() {
return true;
},
/**
* @return {Boolean} Whether this ringtone is deletable (always false).
*/
get deletable() {
return false;
},
/**
* Gets a blob for this tone.
*
* @return {Promise} A promise returning a Blob of the audio data.
*/
getBlob: function() {
var url = this.url;
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.overrideMimeType(inferMimeType(url));
xhr.responseType = 'blob';
xhr.send();
xhr.onload = function() {
resolve(xhr.response);
};
xhr.onerror = function() {
var err = new Error('Could not read sound file: ' + url +
' (status: ' + xhr.status + ')');
console.error(err);
reject(err);
};
});
}
};
/**
* Get the type of a BuiltInRingtone from its ID.
*
* @param {String} id The BuiltInRingtone's ID.
* @return {String} The tone's type ('ringtone' or 'alerttone').
*/
function idToToneType(id) {
var slash = id.indexOf('/');
if (id.indexOf(ID_PREFIX) !== 0 || slash === -1) {
throw new Error('invalid id: ' + id);
}
return id.substring(ID_PREFIX.length, slash);
}
/**
* Check if a BuiltInRingtone ID refers to a particular filename.
*
* @param {String} id The BuiltInRingtone's ID.
* @param {String} toneType The type of the tone held in `filename`.
* @param {String} filename The filename.
* @return {Boolean} True if the id and filename match, false otherwise.
*/
function idMatchesFilename(id, toneType, filename) {
return id === ID_PREFIX + toneType + '/' + filename.replace(/\.\w+$/, '');
}
// Our cache of tone definitions from the list.json files.
var toneDefsCache = {};
/**
* Read the list.json file to get the names of all sounds we know about.
* This function caches its results so that subsequent calls are speedy.
*
* @param {String} toneType The type of tones to get ('ringtone' or
* 'alerttone').
* @return {Promise} A promise returning an array of filenames.
*/
function getSoundFilenames(toneType) {
if (!(toneType in BASE_URLS)) {
throw new Error('tone type not supported: ' + toneType);
}
return new Promise(function(resolve, reject) {
if (toneType in toneDefsCache) {
resolve(toneDefsCache[toneType]);
return;
}
var xhr = new XMLHttpRequest();
xhr.open('GET', BASE_URLS[toneType] + 'list.json');
xhr.responseType = 'json';
xhr.send(null);
xhr.onload = function() {
toneDefsCache[toneType] = xhr.response;
resolve(xhr.response);
};
xhr.onerror = function() {
var err = new Error('Could not read sounds list for ' + toneType +
' (status: ' + xhr.status + ')');
console.error(err);
reject(err);
};
});
}
function list(toneType) {
return getSoundFilenames(toneType).then(function(toneDefs) {
var tones = [];
for (var filename in toneDefs) {
tones.push(new BuiltInRingtone(toneType, filename, toneDefs[filename]));
}
return tones;
});
}
function get(id) {
return new Promise(function(resolve, reject) {
var toneType = idToToneType(id);
resolve(getSoundFilenames(toneType).then(function(toneDefs) {
for (var filename in toneDefs) {
if (idMatchesFilename(id, toneType, filename)) {
return new BuiltInRingtone(toneType, filename, toneDefs[filename]);
}
}
var err = new Error('No ' + toneType + ' found with id = ' + id);
console.error(err);
throw err;
}));
});
}
return {
list: list,
get: get,
get toneTypes() { return Object.keys(BASE_URLS); }
};
})();

View file

@ -0,0 +1,393 @@
/* global IDBKeyRange, indexedDB, Promise */
'use strict';
/*
* customRingtones is an object that provides access to the user-created
* ringtones on the device. It has the following methods:
*
* add():
* Add a new ringtone to the database.
*
* @param {Object} info An object describing the new ringtone. Should contain
* the following properties: name, subtitle (optional), and blob.
* @return {Promise} A promise returning the created CustomRingtone.
*
* remove():
* Remove a ringtone from the database.
*
* @param {String} id The ID of the ringtone to delete.
* @return {Promise} A promise signalling completion of the removal.
*
* clear():
* Remove all ringtones from the database. This function is primarily meant
* for unit tests.
*
* @return {Promise} A promise signalling completion of the removal.
*
* list():
* Lists all the custom ringtones.
*
* @param {String} toneType The type of tone to list ('ringtone' or
* 'alerttone').
* @return {Promise} A promise returning an array of CustomRingtones
* representing each tone.
*
* get():
* Gets a ringtone with the specified id.
*
* @param {String} id The ID of the ringtone to get.
* @return {Promise} A promise returning the requested ringtone.
*/
window.customRingtones = (function() {
var ID_PREFIX = 'custom:';
/**
* Create a new custom ringtone object.
*
* @param {Object} info An object containing info about the ringtone. Contains
* the following members:
* {String} name The name of the tone.
* {String} subtitle (optional) The subtitle for the tone (e.g. the artist
* of a song).
* {Number} uniqueNum The "unique" number for the tone, to disambiguate it
* from other tones with the same name and subtitle.
* {Boolean} isExplicitNum True if the "unique" number was explicitly
* specified in the original name of the tone, false if it was inferred
* for de-duplication purposes.
* @param {Number} dbKey The database key of the tone.
*/
function CustomRingtone(info, dbKey) {
this._name = info.name;
if (info.uniqueNum !== 0 || info.isExplicitNum) {
this._name += ' (' + info.uniqueNum + ')';
}
this._subtitle = info.subtitle || null;
this._id = ID_PREFIX + dbKey;
this._blob = info.blob;
}
CustomRingtone.prototype = {
/**
* @return {String} The name of the ringtone.
*/
get name() {
return this._name;
},
/**
* @return {String} The filename of the ringtone.
*/
get filename() {
// XXX: If we ever start supporting Blobs that aren't really files, we'll
// need to figure out a way to make a fallback filename!
return this._blob.name;
},
/**
* @return {String} The subtitle of the ringtone (e.g. the artist of the
* song), or null if there is no subtitle.
*/
get subtitle() {
return this._subtitle;
},
/**
* @return {String} A unique ID for the ringtone.
*/
get id() {
return this._id;
},
/**
* @return {String} The type of the ringtone. XXX: Currently always
* "ringtone".
*/
get type() {
return 'ringtone';
},
/**
* @return {String} A URL pointing to the ringtone's audio data.
*/
get url() {
// Lazily get the URL for the blob, but once we've done it once, we don't
// need to use the getter again. XXX: We could be smarter here and have a
// way of revoking URLs when we're done with them.
return this._url || (this._url = URL.createObjectURL(this._blob));
},
/**
* @return {Boolean} Whether this ringtone is shareable (always true).
*/
get shareable() {
return true;
},
/**
* @return {Boolean} Whether this ringtone is deletable (always true).
*/
get deletable() {
return true;
},
/**
* Delete this ringtone from the database.
*
* @return A promise returning upon completion of the deletion.
*/
remove: function() {
return remove(this.id);
},
/**
* Gets a blob for this ringtone.
*
* @return {Promise} A promise returning a Blob of the audio data.
*/
getBlob: function() {
return Promise.resolve(this._blob);
}
};
/**
* Convert a CustomRingtone ID to its corresponding DB key.
*
* @param {String} id The ID.
* @return {Number} The DB key.
*/
function idToDBKey(id) {
if (id.indexOf(ID_PREFIX) !== 0) {
throw new Error('invalid id: ' + id);
}
return parseInt(id.substr(ID_PREFIX.length));
}
var DBNAME = 'customTones';
var DBVERSION = 1;
// Eventually, we'll probably want a separate store for alert tones.
var STORENAME = 'ringtones';
var db = null;
function withStore(type, callback) {
if (db) {
var transaction = db.transaction(STORENAME, type);
callback(
null, transaction.objectStore(STORENAME), transaction
);
} else {
var openreq = indexedDB.open(DBNAME, DBVERSION);
openreq.onerror = function() {
console.error('customRingtones: can\'t open database:',
openreq.error.name);
callback(openreq.error);
};
openreq.onupgradeneeded = function() {
var objStore = openreq.result.createObjectStore(
STORENAME, { autoIncrement: true }
);
objStore.createIndex(
'fullname', ['name', 'subtitle', 'uniqueNum'], { unique: false }
);
};
openreq.onsuccess = function() {
db = openreq.result;
var transaction = db.transaction(STORENAME, type);
callback(
null, transaction.objectStore(STORENAME), transaction
);
};
}
}
// This function gives us a unique number for our ringtones in case the name
// and subtitle are the same. We try to be a bit "magical", and if the name
// we got had an explicit number in parens at the end, we'll try incrementing
// that number instead of adding a second number. See also the beginning of
// add() below.
function uniqueify(info) {
return new Promise(function(resolve, reject) {
withStore('readonly', function(err, store) {
if (err) {
reject(err);
return;
}
var nextUniqueNum = info.uniqueNum || 0;
var index = store.index('fullname');
var req = index.openCursor(
IDBKeyRange.bound(
[info.name, info.subtitle, 0],
[info.name, info.subtitle, Infinity]
)
);
req.onsuccess = function(event) {
var cursor = event.target.result;
if (!cursor || nextUniqueNum < cursor.value.uniqueNum) {
resolve(nextUniqueNum);
return;
}
nextUniqueNum = cursor.value.uniqueNum + 1;
cursor.continue();
};
req.onerror = function() {
console.error(
'Error in customRingtones.uniqueify(): ', req.error.name
);
reject(req.error);
};
});
});
}
function add(info) {
var cleanedInfo = {
name: info.name || '',
subtitle: info.subtitle || '',
blob: info.blob,
uniqueNum: 0
};
var m = /^(.*) \((\d+)\)$/.exec(cleanedInfo.name);
if (m) {
cleanedInfo.name = m[1];
cleanedInfo.uniqueNum = m[2];
cleanedInfo.isExplicitNum = true;
}
return uniqueify(cleanedInfo).then(function(uniqueNum) {
return new Promise(function(resolve, reject) {
withStore('readwrite', function(err, store, transaction) {
if (err) {
reject(err);
return;
}
cleanedInfo.uniqueNum = uniqueNum;
var tone;
var req = store.add(cleanedInfo);
req.onsuccess = function(event) {
tone = new CustomRingtone(cleanedInfo, event.target.result);
};
transaction.oncomplete = function(event) {
resolve(tone);
};
transaction.onabort = transaction.onerror = function() {
console.error('Error in customRingtones.add(): ',
transaction.error.name);
reject(req.error);
};
});
});
});
}
function remove(id) {
return new Promise(function(resolve, reject) {
withStore('readwrite', function(err, store, transaction) {
if (err) {
reject(err);
return;
}
var req = store.delete(idToDBKey(id));
transaction.oncomplete = function(event) {
resolve();
};
transaction.onabort = transaction.onerror = function() {
console.error('Error in customRingtones.remove(): ',
transaction.error.name);
reject(req.error);
};
});
});
}
function clear() {
return new Promise(function(resolve, reject) {
withStore('readwrite', function(err, store, transaction) {
if (err) {
reject(err);
return;
}
var req = store.clear();
transaction.oncomplete = function(event) {
resolve();
};
transaction.onabort = transaction.onerror = function() {
console.error('Error in customRingtones.clear(): ',
transaction.error.name);
reject(req.error);
};
});
});
}
function get(id) {
return new Promise(function(resolve, reject) {
withStore('readonly', function(err, store) {
if (err) {
reject(err);
return;
}
var key = idToDBKey(id);
var req = store.get(key);
req.onsuccess = function(event) {
var data = event.target.result;
if (!data) {
resolve(null);
} else {
resolve(new CustomRingtone(data, key));
}
};
req.onerror = function() {
console.error('Error in customRingtones.get(): ', req.error.name);
reject(req.error);
};
});
});
}
function list(toneType) {
return new Promise(function(resolve, reject) {
// XXX: We should actually handle alert tones eventually!
if (toneType === 'alerttone') {
resolve([]);
return;
}
withStore('readonly', function(err, store) {
if (err) {
reject(err);
return;
}
var req = store.openCursor();
var results = [];
req.onsuccess = function listOnSuccess(event) {
var cursor = event.target.result;
if (cursor) {
var value = cursor.value;
results.push(new CustomRingtone(value, cursor.key));
cursor.continue();
} else {
resolve(results);
}
};
req.onerror = function listOnError() {
console.error('Error in customRingtones.list(): ', req.error.name);
reject(req.error);
};
});
});
}
return {
add: add,
remove: remove,
clear: clear,
get: get,
list: list
};
})();

138
gaia/apps/ringtones/js/manage.js Executable file
View file

@ -0,0 +1,138 @@
/* global ActionsMenu, isToneInUse, MozActivity, Promise, setTone,
Toaster, ToneList, TonePlayer */
/* jshint unused:false */
'use strict';
navigator.mozSetMessageHandler('activity', function(activity) {
var tonePlayer = new TonePlayer();
// Conclude the activity if the user taps "back".
document.getElementById('header').addEventListener('action', function() {
tonePlayer.stop();
window.close();
});
function addNewTone(customRingtonesList) {
tonePlayer.stop();
var pickActivity = new MozActivity({
name: 'pick',
data: {
type: 'audio/*'
}
});
pickActivity.onsuccess = function() {
var result = pickActivity.result;
var popup = window.open('share.html', 'share', 'mozhaidasheet');
popup.addEventListener('load', function loaded() {
popup.removeEventListener('load', loaded);
popup.postMessage(result, window.location.origin);
});
window.addEventListener('message', function receive(event) {
window.removeEventListener('message', receive);
if (event.origin !== window.location.origin) {
console.error('Couldn\'t recieve message: origins don\'t match',
event.origin, window.location.origin);
return;
}
var data = event.data;
if (data.command !== 'save') {
return;
}
// XXX: The child window just sends the DB key for the new ringtone and
// we have to grab it from our instance of the DB. It'd be nice if we
// could just listen for changes to the DB and update automatically...
window.customRingtones.get(data.details.toneID).then(function(tone) {
customRingtonesList.add(tone);
});
});
};
}
var actionsMenu = new ActionsMenu(
document.getElementById('ringtone-actions')
);
function ManagerToneList(...args) {
ToneList.apply(this, args);
}
ManagerToneList.prototype = Object.create(ToneList.prototype);
ManagerToneList.prototype.constructor = ManagerToneList;
ManagerToneList.prototype.makeItem = function(tone) {
var item = ToneList.prototype.makeItem.call(this, tone);
item.querySelector('.desc').addEventListener('click', function() {
tonePlayer.setTone(tone, function(playing) {
item.dataset.playing = playing;
});
});
var self = this;
var actionsButton = item.querySelector('.actions-button');
actionsButton.addEventListener('click', function() {
tonePlayer.stop();
window.systemTones.isInUse(tone).then(function(inUseAs) {
actionsMenu.open(tone, inUseAs, function(command) {
if (command === 'delete') {
self.remove(tone);
// If this is the current default ringtone (or alert tone), reset
// the default tone.
inUseAs.forEach(function(toneType) {
window.systemTones.getDefault(toneType).then(function(tone) {
window.systemTones.set(toneType, tone);
});
});
}
});
});
});
return item;
};
navigator.mozL10n.once(function() {
var promises = [];
var listParent = document.getElementById('list-parent');
window.builtInRingtones.toneTypes.forEach(function(toneType) {
// Add the built-in ringtones.
var builtInList = new ManagerToneList(
'section-title-builtin-' + toneType, listParent
);
promises.push(window.builtInRingtones.list(toneType).then(
function(tones) { builtInList.add(tones); }
));
// Add the custom ringtones.
var customList = new ManagerToneList(
'section-title-custom-' + toneType, listParent);
promises.push(window.customRingtones.list(toneType).then(function(tones) {
customList.add(tones);
if (toneType === 'ringtone') {
// Since we've built our custom tones list, we can now let the user
// add new tones to it!
document.getElementById('add').addEventListener(
'click', addNewTone.bind(null, customList)
);
}
}));
// Add the SD card ringtones to the custom list.
promises.push(window.sdCardRingtones.list(toneType).then(function(tones) {
customList.add(tones);
}));
});
Promise.all(promises).then(function() {
// This just notifies the tests that we're finished building our lists.
document.querySelector('body').dataset.ready = true;
});
});
});

View file

@ -0,0 +1,27 @@
'use strict';
/* global Sanitizer */
/* exported Templates */
var Templates = {
soundList: function({l10nID}) {
return Sanitizer.escapeHTML `
<section hidden>
<gaia-subheader skin="organic">
<span data-l10n-id="${l10nID}"></span>
</gaia-subheader>
<ul data-type="list"></ul>
</section>`;
},
soundItem: function({l10nID, name}) {
return Sanitizer.escapeHTML `
<li>
<aside class="pack-end">
<a class="actions-button"></a>
</aside>
<div class="desc">
<div class="play-icon"></div>
<p class="name"><bdi data-l10n-id="${l10nID}">${name}</bdi></p>
</div>
</li>`;
}
};

View file

@ -0,0 +1,79 @@
/* global Promise */
/* exported NullRingtone */
'use strict';
/**
* Create a ringtone with no audio data backing it.
*/
function NullRingtone() {}
NullRingtone.prototype = {
/**
* @return {String} The localized name of the ringtone. Assumes mozL10n has
* been initialized.
*/
get name() {
return navigator.mozL10n.get(this.l10nID);
},
/**
* @return {String} The filename of the ringtone (null in this case).
*/
get filename() {
return null;
},
/**
* @return {String} The l10n ID of the ringtone.
*/
get l10nID() {
return 'ringtone-none';
},
/**
* @return {String} A unique ID for the ringtone.
*/
get id() {
return 'none:none';
},
/**
* @return {String} The type of the tone (null in this case).
*/
get type() {
return null;
},
/**
* @return {String} The URL of the ringtone (null in this case).
*/
get url() {
return null;
},
/**
* @return {Boolean} Whether this ringtone is shareable (always false).
*/
get shareable() {
return false;
},
/**
* @return {Boolean} Whether this ringtone is deletable (always false).
*/
get deletable() {
return false;
},
/**
* Gets a blob for this tone.
*
* @return {Promise} A promise returning the audio data (always null, since
* this is a null ringtone).
*/
getBlob: function() {
return new Promise(function(resolve, reject) {
resolve(null);
});
}
};

116
gaia/apps/ringtones/js/pick.js Executable file
View file

@ -0,0 +1,116 @@
/* global NullRingtone, Promise, ToneList, TonePlayer */
'use strict';
navigator.mozSetMessageHandler('activity', function(activity) {
var tonePlayer = new TonePlayer();
var toneTypes = activity.source.data.type;
var allowNone = activity.source.data.allowNone;
var currentToneID = activity.source.data.currentToneID;
var allToneTypes = window.builtInRingtones.toneTypes;
if (!Array.isArray(toneTypes)) {
toneTypes = [toneTypes];
}
toneTypes = toneTypes.filter(function(x) {
return allToneTypes.indexOf(x) !== -1;
});
// Put any unasked-for types in another array so we can list them at the end.
var otherToneTypes = allToneTypes.filter(function(x) {
return toneTypes.indexOf(x) === -1;
});
document.getElementById('header').addEventListener('action', function() {
tonePlayer.stop();
activity.postError('cancelled');
});
document.getElementById('set').addEventListener('click', function() {
tonePlayer.stop();
tonePlayer.isValid(function(valid) {
if (!valid) {
// The tone couldn't be played. Just act like the user canceled.
activity.postError('cancelled');
}
var selectedTone = tonePlayer.currentTone;
selectedTone.getBlob().then(function(blob) {
activity.postResult({
name: selectedTone.name,
l10nID: selectedTone.l10nID,
id: selectedTone.id,
blob: blob
});
});
});
});
function PickerToneList(...args) {
ToneList.apply(this, args);
}
PickerToneList.prototype = Object.create(ToneList.prototype);
PickerToneList.prototype.constructor = PickerToneList;
PickerToneList.prototype.makeItem = function(tone) {
var item = ToneList.prototype.makeItem.call(this, tone);
var input = item.querySelector('gaia-radio');
input.checked = (tone.id === currentToneID);
input.addEventListener('click', function() {
tonePlayer.setTone(tone);
document.getElementById('set').disabled = false;
});
return item;
};
navigator.mozL10n.once(function() {
var promises = [];
var listParent = document.getElementById('list-parent');
function addToneLists(toneType, includeNone) {
var builtInList = new PickerToneList(
'section-title-builtin-' + toneType, listParent
);
promises.push(window.builtInRingtones.list(toneType)
.then(function(tones) {
builtInList.add(tones);
// Add the empty ringtone to the first list if it's allowed. This is a
// bit strange, since the empty ringtone doesn't *really* belong in any
// of our categories, so as a compromise, we just put it first. In
// practice, this works out ok, since consumers generally set allowNone
// to true when they want an alert tone, and the empty ringtone is
// sort of an alert tone.
if (includeNone) {
builtInList.add(new NullRingtone());
}
}));
var customList = new PickerToneList(
'section-title-custom-' + toneType, listParent
);
promises.push(window.customRingtones.list(toneType).then(function(tones) {
customList.add(tones);
}));
promises.push(window.sdCardRingtones.list(toneType).then(function(tones) {
customList.add(tones);
}));
}
// Add the asked-for built-in tones.
toneTypes.forEach(function(toneType, i) {
addToneLists(toneType, i === 0 && allowNone);
});
// Add the unasked-for built-in tones.
otherToneTypes.forEach(function(toneType) {
addToneLists(toneType);
});
Promise.all(promises).then(function(listsData) {
// This just notifies the tests that we're finished building our lists.
document.querySelector('body').dataset.ready = true;
});
});
});

View file

@ -0,0 +1,28 @@
'use strict';
/* global Sanitizer */
/* exported Templates */
var Templates = {
soundList: function({l10nID}) {
return Sanitizer.escapeHTML `
<section hidden>
<gaia-subheader skin="organic">
<span data-l10n-id="${l10nID}"></span>
</gaia-subheader>
<section data-type="list">
<ul></ul>
</section>
</section>`;
},
soundItem: function({l10nID, name}) {
return Sanitizer.escapeHTML `
<li>
<gaia-radio name="sounds" class="truncate">
<label class="name">
<bdi data-l10n-id="${l10nID}">${name}</bdi>
</label>
<details></details>
</gaia-radio>
</li>`;
}
};

View file

@ -0,0 +1,201 @@
/* global enumerateAll, Promise */
'use strict';
window.sdCardRingtones = (function() {
var ID_PREFIX = 'sdcard:';
var BASE_DIRS = {
'ringtone': 'Ringtones',
'alerttone': 'Notifications'
};
/**
* Given a path to an SD card tone, return its type ("ringtone" or
* "alerttone").
*
* @param {String} path The path to the tone.
* @return {String} The tone's type.
*/
function pathToType(path) {
// Get the name of the folder the tone is stored in. We're looking for
// either "/volume-name/Folder/" or "Folder/". We can guarantee that the
// regex matches, or something horrible has happened, in which case we'll
// throw an exception automatically.
var folder = new RegExp('^(/[^/]*/)?([^/]*)/').exec(path)[2];
for (var type in BASE_DIRS) {
if (BASE_DIRS[type] === folder) {
return type;
}
}
// This should never happen.
throw new Error('unexpected SD card folder: ' + folder);
}
/**
* Create a new SD card ringtone object.
*
* @param {File} file A File object for the ringtone.
*/
function SDCardRingtone(file) {
var leafname = file.name.substr(file.name.lastIndexOf('/') + 1);
this._name = leafname.replace(/\.\w+$/, '');
this._blob = file;
this._type = pathToType(this._blob.name);
}
SDCardRingtone.prototype = {
/**
* @return {String} The name of the ringtone.
*/
get name() {
return this._name;
},
/**
* @return {String} The filename of the ringtone.
*/
get filename() {
return this._blob.name;
},
/**
* @return {String} The subtitle of the ringtone (always "SD Card" in this
* case).
*/
get subtitle() {
return navigator.mozL10n.get('sd-card-subtitle');
},
/**
* @return {String} A unique ID for the ringtone.
*/
get id() {
return ID_PREFIX + this._blob.name;
},
/**
* @return {String} The type of the tone. Either "ringtone" or "alerttone".
*/
get type() {
return this._type;
},
/**
* @return {String} A URL pointing to the ringtone's audio data.
*/
get url() {
// Lazily get the URL for the blob, but once we've done it once, we don't
// need to use the getter again. XXX: We could be smarter here and have a
// way of revoking URLs when we're done with them.
return this._url || (this._url = URL.createObjectURL(this._blob));
},
/**
* @return {Boolean} Whether this ringtone is shareable (always true).
*/
get shareable() {
return true;
},
/**
* @return {Boolean} Whether this ringtone is deletable (always true).
*/
get deletable() {
return true;
},
/**
* Delete this ringtone from the database.
*
* @return A promise returning upon completion of the deletion.
*/
remove: function() {
return remove(this.id);
},
/**
* Gets a blob for this ringtone.
*
* @return {Promise} A promise returning a Blob of the audio data.
*/
getBlob: function() {
return Promise.resolve(this._blob);
}
};
/**
* Convert an SDCardRingtone ID to its corresponding filename.
*
* @param {String} id The ID.
* @return {Number} The filename.
*/
function idToFilename(id) {
return id.substr(ID_PREFIX.length);
}
var defaultStorage = navigator.getDeviceStorage('music');
function remove(id) {
return new Promise(function(resolve, reject) {
var request = defaultStorage.delete(idToFilename(id));
request.onsuccess = function() {
resolve();
};
request.onerror = function() {
console.error('Error in sdCardRingtones.remove(): ', this.error);
reject(this.error);
};
});
}
function get(id) {
return new Promise(function(resolve, reject) {
var request = defaultStorage.get(idToFilename(id));
request.onsuccess = function() {
resolve(new SDCardRingtone(this.result));
};
request.onerror = function() {
console.error('Error in sdCardRingtones.get(): ', this.error);
reject(this.error);
};
});
}
function list(toneType) {
if (!(toneType in BASE_DIRS)) {
throw new Error('tone type not supported: ' + toneType);
}
return new Promise(function(resolve, reject) {
var results = [];
var sdcards = navigator.getDeviceStorages('music');
var cursor = enumerateAll(sdcards, BASE_DIRS[toneType]);
cursor.onsuccess = function() {
var file = this.result;
if (file) {
if (file.name[0] !== '.' && file.name.indexOf('/.') === -1) {
results.push(new SDCardRingtone(file));
}
this.continue();
} else {
resolve(results);
}
};
cursor.onerror = function() {
if (this.error.name === 'NotFoundError' ||
this.error.name === 'TypeMismatchError') {
resolve([]);
} else {
console.error('Error in sdCardRingtones.list(): ', this.error);
reject(this.error);
}
};
});
}
return {
remove: remove,
get: get,
list: list
};
})();

247
gaia/apps/ringtones/js/share.js Executable file
View file

@ -0,0 +1,247 @@
/* global CustomDialog, NotificationHelper */
'use strict';
screen.mozLockOrientation('portrait');
// This page can be launched in one of two ways: as an activity opened by
// another app, or by clicking the "+" icon in the ringtone manager. First up,
// let's handle the activity case.
if (document.location.hash === '#activity') {
navigator.mozSetMessageHandler('activity', function(activity) {
// Turn an array of data into its first element; we need this to make
// "share" activity data look more like "pick" activity data. We still want
// to preserve the rest of the structure though, so that other attributes
// get passed along.
function flatten(arr) {
return arr && arr.length ? arr[0] : null;
}
var data = activity.source.data;
data.blob = flatten(data.blobs);
data.metadata = flatten(data.metadata);
data.name = flatten(data.filenames);
handleShare(data, function(command, details) {
switch (command) {
case 'save':
activity.postResult({});
break;
case 'cancel':
activity.postError('pick cancelled');
break;
case 'error':
activity.postError('pick error');
break;
}
});
});
}
else {
// XXX: If this window was opened from the ringtones manager and we lose
// visibility, we need to close the window so that subsequent calls to
// window.open actually bring the window to the foreground! This is a
// workaround for the fact that the ringtones app is currently has a "system"
// role, which means that it doesn't participate in the window manager's
// stack. See bug 1030954 for more details.
window.addEventListener('visibilitychange', function() {
if (document.hidden) {
window.close();
}
});
window.addEventListener('message', function(event) {
if (event.origin !== window.location.origin) {
console.error('Couldn\'t recieve message: origins don\'t match',
event.origin, window.location.origin);
return;
}
handleShare(event.data, function(command, details) {
event.source.postMessage(
{command: command, details: details}, event.origin
);
window.close();
});
});
}
function handleShare(data, callback) {
navigator.mozL10n.once(function() {
function showError(title, message, okCallback) {
var okButton = {
title: 'ok',
callback: function() {
CustomDialog.hide();
okCallback();
}
};
CustomDialog.show(title, message, okButton);
}
var save = document.getElementById('save');
var header = document.getElementById('header');
var control = document.getElementById('playpause');
var preview = document.getElementById('preview');
var songtitle;
if (data.metadata && data.metadata.title) {
songtitle = data.metadata.title;
}
else if (data.name) {
songtitle = data.name;
}
else {
songtitle = 'ringtone-untitled';
}
var subtitle = '';
if (data.metadata && data.metadata.artist) {
subtitle = data.metadata.artist;
}
document.getElementById('songtitle').textContent = songtitle;
document.getElementById('artist').textContent = subtitle;
if (data.metadata && data.metadata.picture) {
// If we have a picture, make sure the subtitle takes up some vertical
// space so that everything lines up correctly.
if (!subtitle) {
document.getElementById('artist').textContent = '\u00a0'; // &nbsp;
}
try {
var pictureURL = URL.createObjectURL(data.metadata.picture);
var picture = document.getElementById('picture');
picture.hidden = false;
picture.style.backgroundImage = 'url(' + pictureURL + ')';
}
catch(e) {
console.error('Couldn\'t set picture:', e);
}
}
control.addEventListener('click', function() {
if (preview.paused) {
// HACK HACK HACK
//
// This is an ugly workaround for bug 956811.
//
// Bugs in the system app window manager and Gecko's audio
// channel manager prevent the music app from being paused when
// this set ringtone app previews a ringtone. As a workaround,
// the music app listens to the settings database and will pause
// itself if we set a magic property that it specifies. So if
// the activity data includes a special magic property then we
// know that we were invoked by the music app, and the music app
// is playing a song. The value of the property in the activity
// data is the name of the property in the settings database
// that it is listening to.
//
// See also the corresponding code in apps/music/js/Player.js
//
// This hack is implemented as a single self-invoking function so it is
// easy to remove when we have a proper bug fix.
//
// HACK HACK HACK
(function() {
var hack_activity_property = '_hack_hack_shut_up';
var hack_settings_property = data[hack_activity_property];
if (hack_settings_property) {
// Query the value of this setting
var lock = navigator.mozSettings.createLock();
lock.get(hack_settings_property).onsuccess = function(e) {
var value = e.target.result[hack_settings_property];
// Once we have the value, set the setting to something different.
// This should pause the music app. Wait until we have
// confirmation that the setting was set before starting to play
// the preview so that the music app has time to pause first
var o = {};
o[hack_settings_property] = !value;
navigator.mozSettings.createLock().set(o).onsuccess = function() {
preview.play();
};
// Alter the activity data so we only run this code once.
delete data[hack_activity_property];
};
}
else {
preview.play();
}
}());
// END OF HACK
// When there is a real bug fix, just replace this entire if clause
// with:
// preview.play();
}
else {
preview.pause();
}
});
preview.src = URL.createObjectURL(data.blob);
preview.addEventListener('canplay', function() {
save.disabled = false;
});
preview.addEventListener('error', function() {
showError('play-error-title', 'play-error-desc',
callback.bind(null, 'error'));
});
preview.addEventListener('playing', function() {
control.classList.add('playing');
});
preview.addEventListener('pause', function() {
control.classList.remove('playing');
});
header.addEventListener('action', function() {
callback('cancel');
});
save.addEventListener('click', function() {
// Disable the button so the user can't click it twice.
save.disabled = true;
// For a large blob this can take a while, so display a spinner.
document.getElementById('saving-overlay').hidden = false;
// Add to the custom ringtones DB and then set it in the settings.
var info = {name: songtitle, subtitle: subtitle, blob: data.blob};
window.customRingtones.add(info).then(function(tone) {
if (document.getElementById('default-switch').checked) {
return window.systemTones.set('ringtone', tone).then(function() {
return {toneID: tone.id, setAsDefault: true};
});
}
return {toneID: tone.id, setAsDefault: false};
}).then(function(details) {
// Show a notification indicating success, and then close it immediately
// so it doesn't stink up the notifications tray! XXX: This UX isn't
// great; we should turn this into a transient notification when we can.
NotificationHelper.send(
songtitle,
{ bodyL10n: details.setAsDefault ? 'set-default-tone' :
'created-tone',
closeOnClick: false
}
).then((notification) => {
notification.close();
callback('save', details);
});
}, function(error) {
console.log('Error saving ringtone', error);
showError('save-error-title', 'save-error-desc',
callback.bind(null, 'error'));
});
});
});
}

View file

@ -0,0 +1,130 @@
/* global Promise */
'use strict';
/**
* window.systemTones is responsible for managing the systemwide ringtones and
* alert tones (i.e. the default ringer and alert). The following methods are
* provided:
*
* getDefault():
* Get the factory default tone.
*
* @param {String} toneType The type of the tone ('ringtone' or 'alerttone').
* @return {Promise} A promise returning the tone's ID.
*
* set():
* Set a tone as the systemwide tone.
*
* @param {String} toneType The type of the tone ('ringtone' or 'alerttone').
* @param {Object} tone The tone to store.
* @return {Promise} A promise for setting the tone.
*
* isInUse():
* Check if a tone is currently in use (either as the default ringer or
* default alert tone).
*
* @param {String} tone The tone to check
* @return {Promise} A promise returning an array of the tone types that the
* tone is in use as.
*
* XXX: In the future, it might make sense to add a get() function that returns
* the currently-selected systemwide tone, but that's not currently necessary.
* Probably the best way to do this would be to register all the tone providers
* (builtInRingtones, customRingtones, and NullRingtone) with the scheme in
* their IDs and just delegate to the correct provider's get() method.
*/
window.systemTones = (function() {
/**
* Get the base path for the tone settings.
*
* @param {String} toneType The type of the tone ('ringtone' or 'alerttone').
*/
function _getSettingsBase(toneType) {
switch (toneType) {
case 'ringtone':
return 'dialer.ringtone';
case 'alerttone':
return 'notification.ringtone';
default:
throw new Error('tone type not supported');
}
}
/**
* Get the value of a setting.
*
* @param {String} settingKey The name of the setting.
* @return {Promise} A promise returning the setting's value.
*/
function _getSetting(settingKey) {
return new Promise(function(resolve, reject) {
var req = navigator.mozSettings.createLock().get(settingKey);
req.onsuccess = function() {
resolve(req.result[settingKey]);
};
req.onerror = function() {
reject(req.error);
};
});
}
function getDefault(toneType) {
var settingKey = _getSettingsBase(toneType) + '.default.id';
return _getSetting(settingKey).then(function(id) {
// XXX: Eventually, maybe we should allow things other than "built-in"
// tones to be the factory default (e.g. the null ringtone).
return window.builtInRingtones.get(id);
});
}
function set(toneType, tone) {
var settingsBase = _getSettingsBase(toneType);
return tone.getBlob().then(function(blob) {
return new Promise(function(resolve, reject) {
navigator.mozL10n.once(function() {
var settings = {};
var name = tone.l10nID ? {l10nID : tone.l10nID} : tone.name;
settings[settingsBase] = blob;
settings[settingsBase + '.name'] = name || '';
settings[settingsBase + '.id'] = tone.id;
var req = navigator.mozSettings.createLock().set(settings);
req.onsuccess = function() { resolve(); };
req.onerror = function() { reject(req.error); };
});
});
});
}
function isInUse(tone) {
var inUseAs = [];
var getCurrentID = function(toneType) {
return _getSetting(_getSettingsBase(toneType) + '.id');
};
return getCurrentID('ringtone').then(function(id) {
if (tone.id === id) {
inUseAs.push('ringtone');
}
return getCurrentID('alerttone').then(function(id) {
if (tone.id === id) {
inUseAs.push('alerttone');
}
// Finally, we're done! This is the value the user will get eventually.
return inUseAs;
});
});
}
return {
getDefault: getDefault,
set: set,
isInUse: isInUse
};
})();

View file

@ -0,0 +1,195 @@
/* exported ToneList */
/* global Templates */
'use strict';
var ToneList = (function() {
// Make sure to keep this in sync with the value in null_ringtone.js!
// (We copy the value here so that you don't need to load null_ringtone.js
// if you don't need it.)
var NONE_ID = 'none:none';
/**
* Compare two tone objects, sorting first on their title, and then their
* subtitle. Note: The empty ringtone is always the "smallest".
*
* @param {Tone} a The first tone.
* @param {Tone} b The second tone.
* @param {Number} <0 if a < b, 0 if a == b, and >0 if a > b.
*/
function toneCompare(a, b) {
// Make sure the empty ringtone is always first.
if (a.id === NONE_ID && b.id === NONE_ID) {
return 0;
} else if (a.id === NONE_ID) {
return -1;
} else if (b.id === NONE_ID) {
return 1;
}
var aName = a.name.toLocaleLowerCase();
var bName = b.name.toLocaleLowerCase();
var cmp = aName.localeCompare(bName);
if (cmp) {
return cmp;
}
var aSubtitle = (a.subtitle || '').toLocaleLowerCase();
var bSubtitle = (b.subtitle || '').toLocaleLowerCase();
return aSubtitle.localeCompare(bSubtitle);
}
/**
* Convert a string of HTML to a DOM node and translate any l10n IDs as
* appropriate. Assumes that mozL10n has already been fully initialized.
*
* @param {String} htmlText The HTML text to turn into a proper DOM node.
* @return {Node} The converted DOM node.
*/
function domify(htmlText) {
var dummyDiv = document.createElement('div');
dummyDiv.innerHTML = htmlText;
var element = dummyDiv.firstElementChild;
return element;
}
/**
* Create a new list of tones. Assumes that mozL10n has already been fully
* initialized. This class is meant to be subclassed by consumers; feel free
* to extend makeItem() to do whatever you need!
*
* @param {String} titleID The l10n ID of the list's header
* @param {Node} parent (optional) The parent node to append this list to.
*/
function ToneList(titleID, parent) {
this.element = domify(Templates.soundList({l10nID: titleID}));
if (parent) {
parent.appendChild(this.element);
}
this._ul = this.element.querySelector('ul');
// This maps tone IDs to an object containing the tone object and the DOM
// node for it. We have to keep the tone objects around anyway, so we're
// not wasting (much) memory with this.
this._toneMap = {};
}
ToneList.prototype = {
/**
* Create and return a list item for a tone.
*
* @param {Object} tone The tone to use for the list item.
* @return {Node} The list item.
*/
makeItem: function(tone) {
var templateArgs = {};
if (tone.l10nID) {
templateArgs.l10nID = tone.l10nID;
} else {
templateArgs.name = tone.name;
}
var item = domify(Templates.soundItem(templateArgs));
item.dataset.id = tone.id;
var detailNode = item.querySelector('details');
if (detailNode && tone.subtitle) {
detailNode.textContent = tone.subtitle;
} else if (detailNode) {
detailNode.parentNode.hideDetails();
} else if (tone.subtitle) {
var subtitle = document.createElement('p');
subtitle.classList.add('subtitle');
var bdi = document.createElement('bdi');
bdi.textContent = tone.subtitle;
subtitle.appendChild(bdi);
item.querySelector('.name').parentNode.appendChild(subtitle);
}
return item;
},
/**
* Insert a tone or array of tones in sorted order into the list.
*
* @param {Object} tones A single tone object or an array thereof.
*/
add: function(tones) {
// If we have no children yet, we can be speedy and sort our new tones
// first, and then append them to the end of the list.
if (this._ul.children.length === 0) {
if (Array.isArray(tones)) {
tones.sort(toneCompare);
tones.forEach(this._append.bind(this));
} else {
this._append(tones); // tones is really just a single tone here!
}
} else {
if (Array.isArray(tones)) {
tones.forEach(this._insertSorted.bind(this));
} else {
this._insertSorted(tones); // tones is really just a single tone here!
}
}
},
/**
* Remove an item from the list.
*
* @param {Tone} tone The tone to remove.
*/
remove: function(tone) {
this._ul.removeChild(this._toneMap[tone.id].element);
if (!this._ul.firstChild) {
this.element.hidden = true;
}
},
/**
* Append a tone object to the end of the list. Useful when we know it
* belongs at the end.
*
* @param {Tone} tone The tone to append.
*/
_append: function(tone) {
if (tone.id in this._toneMap) {
throw new Error('A tone with this ID is already in the list: ' +
tone.id);
}
var newItem = this.makeItem(tone);
this._toneMap[tone.id] = {tone: tone, element: newItem};
this.element.hidden = false;
this._ul.appendChild(newItem);
},
/**
* Insert a tone object into the list based on its sort order.
*
* @param {Tone} tone The tone to insert.
*/
_insertSorted: function(tone) {
if (tone.id in this._toneMap) {
throw new Error('A tone with this ID is already in the list: ' +
tone.id);
}
var newItem = this.makeItem(tone);
this._toneMap[tone.id] = {tone: tone, element: newItem};
this.element.hidden = false;
var items = this._ul.querySelectorAll('li');
for (var i = 0; i < items.length; i++) {
var currTone = this._toneMap[items[i].dataset.id].tone;
if (toneCompare(tone, currTone) < 0) {
this._ul.insertBefore(newItem, items[i]);
return;
}
}
this._ul.appendChild(newItem);
}
};
return ToneList;
})();

View file

@ -0,0 +1,158 @@
/* global AudioContext */
'use strict';
/**
* Create a new TonePlayer that manages the logic for previewing a ringtone or
* alert tone. There should only be one of these in use at any given time, since
* this object mucks about with audio channels.
*/
function TonePlayer() {
this._currentTone = null;
this._isValid = true; // It's always valid if there's nothing selected.
// Because of the audio competing policy, after a high priority audio channel
// paused/stopped, the low priority channel is able to resume in the
// background, so here we use audio context to occupy the channel until the
// user leaves the ringtones app, see bug 958470 for details.
this._player = new Audio();
this._player.addEventListener('loadedmetadata', function() {
if (this._player.src) { // Null URLs don't need to be validated here.
this._isValid = true;
this._player.dispatchEvent(new CustomEvent(
'validated', { detail: this._isValid }
));
}
}.bind(this));
this._player.addEventListener('error', function() {
if (this._player.src) { // Null URLs don't need to be validated here.
this._isValid = false;
this._player.dispatchEvent(new CustomEvent(
'validated', { detail: this._isValid }
));
}
}.bind(this));
this._player.addEventListener('ended', function() {
this._firePlayingCallback(false);
}.bind(this));
window.addEventListener('visibilitychange', function() {
if (document.hidden) {
this.stop();
this._setExclusiveMode(false);
}
}.bind(this));
}
TonePlayer.prototype = {
/**
* Select a tone and start playing a preview of this (this has the happy side
* effect of ensuring the tone is actually playable before we save it). Note:
* if this function is called twice with the same tone while the preview is
* still playing, this will stop the preview. This is nice for really long
* tones. However, it can be a bit confusing if the tone ends in a bunch of
* silence, since it stops it, but you'd expect it to replay the tone.
*
* @param {Object} tone The tone to select and preview.
* @param {Function} [callback] The callback to call when the playback status
* of the tone changes; takes a boolean value to indicate if the tone is
* currently playing.
*/
setTone: function(tone, callback) {
if (tone !== this._currentTone) {
this._firePlayingCallback(false);
this._currentTone = tone;
this._playingCallback = callback;
if (tone && tone.url) {
this._isValid = undefined;
this._player.src = tone.url;
this._player.play();
this._firePlayingCallback(true);
this._setExclusiveMode(true);
} else {
this._isValid = true;
this._player.removeAttribute('src');
this._player.load();
}
} else {
if (!this._isValid) {
return;
}
if (this._player.paused || this._player.ended) {
this._player.currentTime = 0;
this._player.play();
this._firePlayingCallback(true);
this._setExclusiveMode(true);
} else {
this._player.pause();
this._firePlayingCallback(false);
}
}
},
/**
* Stop playing the current tone (if any). If we start playing it again,
* setTone will reset the current time to 0. Note: even though we're stopping,
* we don't want to clear the player's src URL, since we might need that to
* restart playback of the same song.
*/
stop: function() {
this._player.pause();
this._firePlayingCallback(false);
},
/**
* Get the current tone that the TonePlayer is (or was) playing.
*/
get currentTone() {
return this._currentTone;
},
/**
* Get the validation status of the currently-selected tone.
*
* @param {Function} callback A callback to call when we have the validation
* status. Takes one argument, which is a boolean representing whether the
* currently-selected tone is valid.
*/
isValid: function(callback) {
if (this._isValid !== undefined) {
callback(this._isValid);
return;
}
this._player.addEventListener('validated', function validated(event) {
this.removeEventListener('validated', validated);
callback(event.detail);
});
},
_firePlayingCallback: function(playing) {
if (this._playingCallback) {
this._playingCallback(playing);
}
},
/**
* Creates an AudioContext with "ringer" priority to stop any background audio
* from playing once we've started previewing ringtones, or destroys the
* context.
*
* @param {Boolean} exclusive true to create the AudioContext, false to
* destroy.
*/
_setExclusiveMode: function(exclusive) {
if (exclusive) {
if (!this._source) {
this._context = new AudioContext('ringer');
this._source = this._context.createMediaElementSource(this._player);
this._source.connect(this._context.destination);
}
} else {
if (this._source) {
this._source.disconnect();
this._context = null;
this._source = null;
}
}
}
};