/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / /* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ /* global AsyncSemaphore, CustomDialog, FtuLauncher, ScreenManager, SettingsCache, SettingsListener, Service */ (function(exports) { 'use strict'; /** * SoundManager handles hardware volume key events, bluetooth volume changes, * and volume/channel change events. * @class SoundManager * @requires AsyncSemaphore * @requires FtuLauncher * @requires ScreenManager */ function SoundManager() { } /** * settings key for vibration * @memberOf SoundManager */ SoundManager.VIBRATION_SETTINGS_KEY = 'vibration.enabled'; /** * user preference key for vibration which is used at async storage. * @memberOf SoundManager */ SoundManager.VIBRATION_USER_PREF_KEY = 'preference.vibration.enabled'; /** * reset time span for volume warning dialog. * @memberOf SoundManager */ SoundManager.CE_RESET_TIME = 72000000; /** * constant for CE counter interval. * @memberOf SoundManager */ SoundManager.TIME_ONE_MINUTE = 60000; /** * elapsed time from last volume warning dialog which is used at async * storage. * @memberOf SoundManager */ SoundManager.CACHE_CETIMES = 'CE_ACCTIME'; // volume cache // Platform doesn't provide the maximum value of each channel // therefore, hard code here. SoundManager.MAX_VOLUME = { 'alarm': 15, 'notification': 15, 'content': 15, 'bt_sco': 15 }; /** * Store the current active channel; * change with 'audio-channel-changed' mozChromeEvent * All candidates and definitions can be found at AudioChannels link. * * @see {link https://wiki.mozilla.org/WebAPI/AudioChannels|AudioChannels} * @memberOf SoundManager.prototype * @type {String} */ SoundManager.prototype.currentChannel = 'none'; /** * Tell if vibration is enabled currently. * * @memberOf SoundManager.prototype * @type {Boolean} */ SoundManager.prototype.vibrationEnabled = true; /** * Default volume control channel * Possible values: * normal * content * notification * alarm * ringer * publicnotification * unknown * @memberOf SoundManager.prototype * @type {String} */ SoundManager.prototype.defaultVolumeControlChannel = 'unknown'; /** * is headset connected. * @memberOf SoundManager.prototype * @type {Boolean} */ SoundManager.prototype.isHeadsetConnected = false; /** * We have three virtual states here: * OFF -> VIBRATION -> MUTE * @memberOf SoundManager.prototype * @type {String} */ SoundManager.prototype.muteState = 'OFF'; /** * User preference to tell if vibration is enabled. The value is read from * 'preference.vibration.enabled' key. * @memberOf SoundManager.prototype * @type {Boolean} */ SoundManager.prototype.vibrationUserPrefEnabled = true; /** * Cache the volume when entering silent mode. * Currently only two channel would be used for mute. * @memberOf SoundManager.prototype * @type {Object} */ SoundManager.prototype.cachedVolume = { 'content': -1, 'notification': -1 }; /** * The keys of cachedVolume. * @memberOf SoundManager.prototype * @type {Array} * @see cachedVolume */ SoundManager.prototype.cachedChannels = ['content', 'notification']; /** * The interval ID of CE accumulator. * @memberOf SoundManager.prototype * @type {Number} */ SoundManager.prototype.CEAccumulatorID = null; /** * The minimum warning volume level. * @memberOf SoundManager.prototype * @type {Number} * @default 11 */ SoundManager.prototype.CEWarningVol = 11; /** * The accumulated time where the volume is above {@link CEWarningVol} and the * channel is "content". * @memberOf SoundManager.prototype * @type {Number} * @see CEWarningVol */ SoundManager.prototype.CEAccumulatorTime = 0; /** * The start time of accumulator running. * @memberOf SoundManager.prototype * @type {Number} */ SoundManager.prototype.CETimestamp = 0; /** * The current volume for all channels: alarm, notification, * content, and bt_sco. * @memberOf SoundManager.prototype * @type {Object} * @see {@link https://wiki.mozilla.org/WebAPI/AudioChannels#Volume_control} */ SoundManager.prototype.currentVolume = { 'alarm': 15, 'notification': 15, 'content': 15, 'bt_sco': 15 }; /** * A semaphore used inside of SoundManager. * @memberOf SoundManager.prototype * @type {AsyncSemaphore} */ SoundManager.prototype.pendingRequest = new AsyncSemaphore(); /** * To tell if the homescreen is visible. * * @memberOf SoundManager.prototype * @type {Boolean} */ SoundManager.prototype.homescreenVisible = true; /** * A counter for checking if the vibration settings is made by SoundManager. * * @memberOf SoundManager.prototype * @type {Number} */ SoundManager.prototype.setVibrationEnabledCount = 0; /** * A flag to tell if the volume is fetched from settings. * * @memberOf SoundManager.prototype * @type {Boolean} */ SoundManager.prototype.volumeFetched = false; /** * A timer ID for auto hiding volume UI. * * @memberOf SoundManager.prototype * @type {Boolean} */ SoundManager.prototype.activeTimerID = 0; /** * It adds listeners to window events, observes the change of mozSettings, and * loads settings from mozSettings. * * @memberOf SoundManager.prototype * @returns {SoundManager} */ SoundManager.prototype.start = function sm_start() { window.addEventListener('volumeup', this); window.addEventListener('volumedown', this); window.addEventListener('mute', this); window.addEventListener('unmute', this); window.addEventListener('mozChromeEvent', this); window.addEventListener('unload', this); window.addEventListener('appopen', this); window.addEventListener('ftudone', this); window.addEventListener('holdhome', this); window.addEventListener('homescreenopening', this); window.addEventListener('homescreenopened', this); this.initVibrationUserPref(); this.bindVolumeSettingsHandlers(); var self = this; SettingsCache.observe('audio.volume.cemaxvol', 11, function(volume) { self.CEWarningVol = volume; }); window.asyncStorage.getItem(SoundManager.CACHE_CETIMES, function getCETime(value) { if (!value) { return; } else { self.CEAccumulatorTime = value; } }); }; /** * It removes listeners from window events * * @memberOf SoundManager.prototype */ SoundManager.prototype.stop = function sm_stop() { window.removeEventListener('volumeup', this); window.removeEventListener('volumedown', this); window.removeEventListener('mute', this); window.removeEventListener('unmute', this); window.removeEventListener('mozChromeEvent', this); window.removeEventListener('unload', this); window.removeEventListener('appopen', this); window.removeEventListener('ftudone', this); window.removeEventListener('holdhome', this); window.removeEventListener('homescreenopening', this); window.removeEventListener('homescreenopened', this); }; /** * It handles the events from window object, including hardware key events, * mozChromeEvent, and custom event made by System app. * * @memberOf SoundManager.prototype * @param {DOMEvent} e */ SoundManager.prototype.handleEvent = function sm_handleEvent(e) { switch (e.type) { case 'volumeup': this.handleVolumeKey(1); break; case 'volumedown': this.handleVolumeKey(-1); break; case 'mute': this.setMute(true); break; case 'unmute': this.setMute(false); break; case 'mozChromeEvent': switch (e.detail.type) { case 'bluetooth-volumeset': this.changeVolume(e.detail.value - this.currentVolume.bt_sco, 'bt_sco'); break; case 'audio-channel-changed': this.currentChannel = e.detail.channel; this.ceAccumulator(); break; case 'headphones-status-changed': this.isHeadsetConnected = (e.detail.state !== 'off'); this.ceAccumulator(); break; case 'default-volume-channel-changed': this.defaultVolumeControlChannel = e.detail.channel; // Do not accumulate CE time here because this event // doesn't mean the content is playing now. break; } break; case 'unload': this.stopAccumulator(); break; case 'appopen': this.homescreenVisible = false; break; case 'ftudone': this.homescreenVisible = true; break; case 'holdhome': CustomDialog.hide(); break; case 'homescreenopening': case 'homescreenopened': this.homescreenVisible = true; CustomDialog.hide(); break; } }; /** * This function handles the volume key up/down behavior. When it is under a * call and user sues a bluetooth, we need to change the volume of BT SCO. * When the headset is connected, we need to activate CE counter. Otherwise, * we calls normal changeVolume API. * * @memberOf SoundManager.prototype * @param {Number} offset the offset which will be added to volume value. */ SoundManager.prototype.handleVolumeKey = function sm_handleVolumeKey(offset) { if (!ScreenManager.screenEnabled && this.currentChannel === 'none') { return; } if (this.isHeadsetConnected && offset > 0) { this.headsetVolumeup(); } else { this.changeVolume(offset); } }; /** * The mute/unmute event is dispatched from sleep menu. * But if we have a mute/unmute hardware button or virtual button, * we could make the button handler to fire this event, too. * * @memberOf SoundManager.prototype * @param {Boolean} mute the state of mute. */ SoundManager.prototype.setMute = function sm_setMute(mute) { /** */ if (mute) { // Turn off vibration for really silence. this.setVibrationEnabled(false); this.enterSilentMode('notification'); } else { // Turn on vibration. this.setVibrationEnabled(true); this.leaveSilentMode('notification'); this.leaveSilentMode('content'); } }; /* * When hardware volume key is pressed, we need to decide which channel we * should toggle. * This method returns the string for setting key 'audio.volume.*' represents * that. * Note: this string does not always equal to currentChannel since some * different channels are grouped together to listen to the same setting. * @memberOf SoundManager.prototype * @returns {String} the volume channel */ SoundManager.prototype.getChannel = function sm_getChannel() { switch (this.currentChannel) { case 'normal': case 'content': return 'content'; case 'alarm': return 'alarm'; case 'notification': case 'ringer': return 'notification'; default: if (this.defaultVolumeControlChannel !== 'unknown') { return this.defaultVolumeControlChannel; } else { return this.homescreenVisible || (Service.locked) || FtuLauncher.isFtuRunning() ? 'notification' : 'content'; } } }; /** * It reads vibration user preference from asyncStorage and listen the change * from mozSettings. * * @memberOf SoundManager.prototype */ SoundManager.prototype.initVibrationUserPref = function sm_initVUserPref() { var self = this; // check asyncStorage window.asyncStorage.getItem(SoundManager.VIBRATION_USER_PREF_KEY, function onok(value) { if (value === null) { // not found, read from settings. var r = SettingsListener.getSettingsLock().get( SoundManager.VIBRATION_SETTINGS_KEY); r.onsuccess = function get_onsuccess() { // write back to asyncStorage. self.writeVibrationUserPref( r.result[SoundManager.VIBRATION_SETTINGS_KEY]); }; r.onerror = function get_onerror() { // initial value to true self.writeVibrationUserPref(true); }; } else { self.vibrationUserPrefEnabled = value; } }); // observe settings SettingsCache.observe(SoundManager.VIBRATION_SETTINGS_KEY, true, function(vibration) { var setBySelf = false; var toggleVibrationEnabled = function toggle_vibration_enabled() { // XXX: If the value does not set by sound manager, // we assume it comes from // the settings app and consider it as user preference. if (!setBySelf) { self.writeVibrationUserPref(vibration); } self.vibrationEnabled = vibration; }; if (self.setVibrationEnabledCount > 0) { self.setVibrationEnabledCount--; setBySelf = true; } self.pendingRequest.wait(toggleVibrationEnabled, self); }); }; /** * It saves the user perference of vibration to asyncStorage. * * @memberOf SoundManager.prototype * @param {Boolean} value vibration preference */ SoundManager.prototype.writeVibrationUserPref = function sm_writeUP(value) { if (this.vibrationUserPrefEnabled === value) { return; } // if value is null or undefined, we view it as initial value which is true. if (value === null || typeof(value) === 'undefined') { value = true; } var self = this; window.asyncStorage.setItem(SoundManager.VIBRATION_USER_PREF_KEY, value, function set_onsuccess() { self.vibrationUserPrefEnabled = value; }); }; /** * It checks the state to start/stop the CE accumulator. * * @memberOf SoundManager.prototype */ SoundManager.prototype.ceAccumulator = function sm_ceAccumulator() { if (this.isHeadsetConnected && this.getChannel() === 'content' && this.currentVolume[this.currentChannel] >= this.CEWarningVol) { if (this.CEAccumulatorTime === 0) { this.resetToCEMaxVolume(); } else { this.startAccumulator(); } } else { this.stopAccumulator(); } }; /** * It handles the case of pressing volumeup hardware key when headset is * connected. This function mainly checks the state to start the CE * accumulator and change the volume. * * @memberOf SoundManager.prototype */ SoundManager.prototype.headsetVolumeup = function sm_headsetVolumeup() { if ((this.currentVolume[this.getChannel()] + 1) >= this.CEWarningVol && this.getChannel() === 'content') { if (this.CEAccumulatorTime === 0) { var self = this; var okfn = function() { self.changeVolume(1); self.startAccumulator(); }; this.resetToCEMaxVolume(okfn); } else { this.startAccumulator(); this.changeVolume(1); } } else { this.changeVolume(1); } }; /** * It shows a warning dialog to tell user that the volume may hurt his/her * ear. * * @memberOf SoundManager.prototype * @param {Function} okfun the callback function when user press ok. */ SoundManager.prototype.showCEWarningDialog = function sm_showCEDialog(okfn) { // Show dialog. var ceTitle = { 'icon': '/style/sound_manager/images/icon_Volumewarning.png', 'id': 'ceWarningtitle' }; var ceMsg = 'ceWarningcontent'; var cancel = { 'title': 'ok' }; var self = this; var screen = document.getElementById('screen'); if (okfn instanceof Function) { cancel.callback = function onCancel() { okfn(); CustomDialog.hide(); }; } else { cancel.callback = function onCancel() { self.startAccumulator(); CustomDialog.hide(); }; } CustomDialog .show(ceTitle, ceMsg, cancel, null, screen) .setAttribute('data-z-index-level', 'system-dialog'); }; /** * It starts the CE accumulator. * * @memberOf SoundManager.prototype */ SoundManager.prototype.startAccumulator = function sm_startAccumulator() { if (this.CEAccumulatorID === null) { if (this.CEAccumulatorTime === 0) { this.CEAccumulatorTime = 1; this.CETimestamp = parseInt(new Date().getTime(), 10); } var self = this; this.CEAccumulatorID = window.setInterval(function ceCounter() { self.CEAccumulatorTime += SoundManager.TIME_ONE_MINUTE; self.CETimestamp = parseInt(new Date().getTime(), 10); if (self.CEAccumulatorTime > SoundManager.CE_RESET_TIME) { self.CEAccumulatorTime = 0; // reset time self.CETimestamp = 0; // reset timestamp self.stopAccumulator(); self.resetToCEMaxVolume(); } }, SoundManager.TIME_ONE_MINUTE); } }; /** * It stops the CE accumulator. * * @memberOf SoundManager.prototype */ SoundManager.prototype.stopAccumulator = function sm_stopAccumulator() { if (this.CEAccumulatorID !== null) { window.clearInterval(this.CEAccumulatorID); this.CEAccumulatorID = null; if (this.CETimestamp !== 0) { this.CEAccumulatorTime = this.CEAccumulatorTime + (parseInt(new Date().getTime(), 10) - this.CETimestamp); } window.asyncStorage.setItem(SoundManager.CACHE_CETIMES, this.CEAccumulatorTime); } }; /** * It resets the content channel to warning volume - 1 and shows the warning * dialog for confirm. * * @memberOf SoundManager.prototype * @param {Function} okfun the callback function when user press ok. */ SoundManager.prototype.resetToCEMaxVolume = function sm_resetCEMax(callback) { this.pendingRequest.v(); var req = SettingsListener.getSettingsLock().set({ 'audio.volume.content': this.CEWarningVol - 1 }); var self = this; req.onsuccess = function onSuccess() { self.pendingRequest.p(); self.showCEWarningDialog(callback); }; req.onerror = function onError() { self.pendingRequest.p(); self.showCEWarningDialog(callback); }; }; /** * Bind setting handlers for each channel's volume change. * @memberOf SoundManager.prototype * @param {Function} callback Callback being called after each setting handler * has been invoked once. */ SoundManager.prototype.bindVolumeSettingsHandlers = function sm_bindHdlers() { var callsMade = 0; var callbacksReceived = 0; var self = this; function observeSettingsVolumeChange(channel) { var setting = 'audio.volume.' + channel; SettingsCache.observe(setting, 5, function onChange(volume) { var settingsChange = function settings_change() { var max = SoundManager.MAX_VOLUME[channel]; self.currentVolume[channel] = parseInt(Math.max(0, Math.min(max, volume)), 10); if (channel === 'content' && self.volumeFetched && volume > 0) { self.leaveSilentMode('content', /* skip volume restore */ true); } else if (channel === 'notification' && volume > 0) { self.leaveSilentMode('notification', /* skip volume restore */ true); } else if (channel === 'notification' && volume === 0) { // Enter silent mode when notification volume is 0 // no matter who sets this value. self.enterSilentMode('notification'); } if (!self.volumeFetched && ++callbacksReceived === callsMade) { self.fetchCachedVolume(); } }; // Initial loaded setting should always pass through // (one per channel) self.pendingRequest.wait(settingsChange, self); }); } for (var channel in this.currentVolume) { callsMade++; observeSettingsVolumeChange(channel); } }; /** * Fetch stored volume if it exists. * We should make sure this happens after settingsDB callback * after booting. * @memberOf SoundManager.prototype */ SoundManager.prototype.fetchCachedVolume = function sm_fetchCachedVolume() { if (this.volumeFetched) { return; } this.volumeFetched = true; this.pendingRequest.v(this.cachedChannels.length); var self = this; this.cachedChannels.forEach( function iterator(channel) { window.asyncStorage.getItem('content.volume', function onGettingCachedVolume(value) { if (!value) { self.pendingRequest.p(); return; } self.cachedVolume[channel] = value; self.pendingRequest.p(); }); } ); }; /** * It handles the vibration case when changing the volume. * @memberOf SoundManager.prototype * @param {Number} curVolume the base volume * @param {Number} delta the offset of the change * @param {String} channel the target channel * @returns {Number} the volume value. (-1) is for slient mode and (0) is for * vibration mode */ SoundManager.prototype.calculateVolume = function sm_calVol( curVolume, delta, channel) { var volume = curVolume; if (channel === 'notification') { if (volume === 0 && !this.vibrationEnabled) { // This is for voluming up from Silent to Vibrate. // Let's take -1 as the silent state and // 0 as the vibrate state for easier calculation here. volume = -1; } volume += delta; } else { volume += delta; } return volume; }; /** * It enables the vibration and returns the mute state. * @memberOf SoundManager.prototype * @param {Number} curVolume the base volume * @param {Number} delta the offset of the change * @param {String} channel the target channel * @returns {String} the mute state */ SoundManager.prototype.getVibrationAndMuteState = function sm_getState( curVolume, delta, channel) { if (channel === 'notification') { var state; var volume = curVolume; if (volume === 0 && !this.vibrationEnabled) { // This is for voluming up from Silent to Vibrate. // Let's take -1 as the silent state and // 0 as the vibrate state for easier calculation here. volume = -1; } volume += delta; if (volume < 0) { state = 'MUTE'; this.vibrationEnabled = false; } else if (volume === 0) { state = 'MUTE'; this.vibrationEnabled = true; } else { // Restore the vibration setting only when leaving silent mode. if (curVolume <= 0) { this.vibrationEnabled = this.vibrationUserPrefEnabled; } state = 'OFF'; } return state; } else { if (curVolume + delta <= 0) { return 'MUTE'; } else { return 'OFF'; } } }; /** * Entering silent mode. * @memberOf SoundManager.prototype * @param {String} [channel="content"] Specify the channel name * which is going to enter silent mode. */ SoundManager.prototype.enterSilentMode = function sm_enterSlient(channel) { if (!channel) { channel = 'content'; } // Don't need to enter silent mode more than once. if (this.currentVolume[channel] === 0) { return; } var isCachedAlready = (this.cachedVolume[channel] === this.currentVolume[channel]); this.cachedVolume[channel] = this.currentVolume[channel]; this.pendingRequest.v(); var settingObject = {}; settingObject['audio.volume.' + channel] = 0; var req = SettingsListener.getSettingsLock().set(settingObject); var self = this; req.onsuccess = function onSuccess() { self.pendingRequest.p(); // Write to async storage only happens when // we haven't stored it before. // If the user presses the volume rockers repeatedly down and up, // between silent-mode/vibration mode/normal mode, // we won't repeatedly write the same value to storage. if (!isCachedAlready) { window.asyncStorage.setItem(channel + '.volume', self.cachedVolume[channel]); } }; req.onerror = function onError() { self.pendingRequest.p(); }; }; /** * Leaving silent mode. * @memberOf SoundManager.prototype * @param {String} channel Specify the channel name * which is going to leave silent mode. * @param {Boolean} skip_restore Specify to skip the volume restore or not. */ SoundManager.prototype.leaveSilentMode = function sm_leaveSlient(channel, skip_restore) { if (!channel) { channel = 'content'; } // We're leaving silent mode. if (!skip_restore && (this.cachedVolume[channel] > 0 || this.currentVolume[channel] === 0)) { var req; var settingObject = {}; var self = this; // At least rollback to 1, // otherwise we don't really leave silent mode. settingObject['audio.volume.' + channel] = (this.cachedVolume[channel] > 0) ? this.cachedVolume[channel] : 1; this.pendingRequest.v(); req = SettingsListener.getSettingsLock().set(settingObject); req.onsuccess = function onSuccess() { self.pendingRequest.p(); }; req.onerror = function onError() { self.pendingRequest.p(); }; } this.cachedVolume[channel] = -1; }; /** * It updates the volume, turns on/off slient mode, and updates UI. * @memberOf SoundManager.prototype * @param {Number} delta Specify the channel name * @param {String} [channel] Specify the channel name. It uses getChannel() * when this argument is absent. */ SoundManager.prototype.changeVolume = function sm_changeVolume(delta, channel) { channel = channel ? channel : this.getChannel(); var vibrationEnabledOld = this.vibrationEnabled; var volume = this.calculateVolume(this.currentVolume[channel], delta, channel); this.muteState = this.getVibrationAndMuteState(this.currentVolume[channel], delta, channel); // Silent mode entry point if (volume <= 0 && delta < 0 && channel == 'notification') { this.enterSilentMode('content'); } else if (volume == 1 && delta > 0 && channel == 'notification' && this.cachedVolume.content >= 0) { // Now since the active channel is notification channel, // we're leaving content silent mode and the same time restoring it. this.leaveSilentMode('content'); // In the notification silent mode, volume rocker priority is higher // than stored notification volume value so we skip the restore. this.leaveSilentMode('notification', /*skip volume restore*/ true); } this.currentVolume[channel] = volume = Math.max(0, Math.min(SoundManager.MAX_VOLUME[channel], volume)); var overlay = document.getElementById('system-overlay'); var notification = document.getElementById('volume'); var overlayClasses = overlay.classList; var classes = notification.classList; switch (this.muteState) { case 'OFF': classes.remove('mute'); break; case 'MUTE': classes.add('mute'); break; } if (this.vibrationEnabled) { classes.add('vibration'); } else { classes.remove('vibration'); } if (vibrationEnabledOld != this.vibrationEnabled) { this.setVibrationEnabled(this.vibrationEnabled); } var steps = Array.prototype.slice.call(notification.querySelectorAll('div'), 0); var maxVolumeStep = volume; for (var i = 0; i < steps.length; i++) { var step = steps[i]; if (i < maxVolumeStep) { step.classList.add('active'); } else { step.classList.remove('active'); } } overlayClasses.add('volume'); classes.add('visible'); window.clearTimeout(this.activeTimerID); this.activeTimerID = window.setTimeout(function hideSound() { overlayClasses.remove('volume'); classes.remove('visible'); }, 1500); if (!window.navigator.mozSettings) { return; } this.pendingRequest.v(); notification.dataset.channel = channel; var settingObject = {}; settingObject['audio.volume.' + channel] = volume; var req = SettingsListener.getSettingsLock().set(settingObject); var self = this; req.onsuccess = function onSuccess() { self.pendingRequest.p(); }; req.onerror = function onError() { self.pendingRequest.p(); }; }; /** * It enables the vibration. * @memberOf SoundManager.prototype * @param {Boolean} enabled the enable state of vibration. */ SoundManager.prototype.setVibrationEnabled = function sm_enableVib(enabled) { this.setVibrationEnabledCount++; SettingsListener.getSettingsLock().set({ 'vibration.enabled': enabled }); }; exports.SoundManager = SoundManager; // XXX: we shoud move the code to bootstrap but it is so buggy to put there. // So, we put here temporary. exports.soundManager = new SoundManager(); if (navigator.mozL10n) { // unit tests call start() manually navigator.mozL10n.once(function() { exports.soundManager.start(); }); } })(window);