VulpesOS/assets/webapps/system.gaiamobile.org/js/accessibility.js
OverSu b38dea0597 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.
2026-09-30 23:13:03 +02:00

838 lines
26 KiB
JavaScript

'use strict';
/* global SettingsListener, LazyLoader, Service, SettingsHelper */
/* global AccessibilityQuicknavMenu */
(function(exports) {
/**
* Accessibility enables and disables the screenreader after the user
* gestures using the hardware buttons of the phone. To toggle the setting.
* the user must press volume up, then volume down three times in a row.
* @class Accessibility
* @requires SettingsListener
*/
function Accessibility() {}
Accessibility.prototype = {
/**
* How fast the autorepeat is.
* @type {Number}
* @memberof Accessibility.prototype
*/
REPEAT_INTERVAL: 600000,
/**
* Maximum interval between initial and final TOGGLE_SCREEN_READER_COUNT
* volume button presses.
* @type {Number}
* @memberof Accessibility.prototype
*/
REPEAT_BUTTON_PRESS: 15000000,
/**
* Number of times the buttons need to be pressed before the screen reader
* setting is toggled.
* @type {Number}
* @memberof Accessibility.prototype
*/
TOGGLE_SCREEN_READER_COUNT: 6,
/**
* Cap the full range of contrast. Actual -1 is completely gray, and 1
* makes things hard to see. This value is the max/min contrast.
*/
CONTRAST_CAP: 0.6,
/**
* Timeout (in milliseconds) between when a vc-change event fires
* and when interaction hints (if any) are spoken
*/
HINTS_TIMEOUT: 2000,
/**
* Default timeout value (in milliseconds) between the launch of the FTU and
* the time when screen reader instructions should be spoken.
* @type {Number}
*/
FTU_STARTED_TIMEOUT: 15000,
/**
* Current counter for button presses in short succession.
* @type {Number}
* @memberof Accessibility.prototype
*/
counter: 0,
/**
* Next expected event.
* @type {Object}
* @memberof Accessibility.prototype
*/
expectedEvent: {
type: 'volumeup',
timeStamp: 0
},
/**
* Expected complete time stamp.
* @type {Number}
* @memberof Accessibility.prototype
*/
expectedCompleteTimeStamp: 0,
/**
* Accessibility settings to be observed.
* @type {Object} name: value pairs.
* @memberof Accessibility.prototype
*/
settings: {
'accessibility.screenreader': false,
'accessibility.screenreader-volume': 1,
'accessibility.screenreader-rate': 0,
'accessibility.screenreader-captions': false,
'accessibility.screenreader-shade': false,
'accessibility.screenreader-ftu-timeout-seconds': 15,
'accessibility.screenreader-fallback-lang': 'en-US',
'accessibility.colors.enable': false,
'accessibility.colors.invert': false,
'accessibility.colors.grayscale': false,
'accessibility.colors.contrast': '0.0'
},
/**
* Audio used by the screen reader.
* Note: Lazy-loaded when first needed
* @type {Object}
* @memberof Accessibility.prototype
*/
sounds: {
clickedAudio: null,
vcKeyAudio: null,
vcMoveAudio: null,
noMoveAudio: null
},
/**
* URLs for screen reader audio files.
* @type {Object}
* @memberof Accessibility.prototype
*/
soundURLs: {
clickedAudio: './resources/sounds/screen_reader_clicked.ogg',
vcKeyAudio: './resources/sounds/screen_reader_virtual_cursor_key.ogg',
vcMoveAudio: './resources/sounds/screen_reader_virtual_cursor_move.ogg',
noMoveAudio: './resources/sounds/screen_reader_no_move.ogg'
},
/**
* Start listening for events.
* @memberof Accessibility.prototype
*/
start: function ar_init() {
this.screen = document.getElementById('screen');
this.speechSynthesizer = speechSynthesizer;
window.addEventListener('volumeup', this);
window.addEventListener('volumedown', this);
window.addEventListener('logohidden', this);
window.addEventListener('screenchange', this);
window.addEventListener('iac-ftucomms', this);
this.FTUStartedTimeout = null;
this.appOpening = false;
// Attach all observers.
Object.keys(this.settings).forEach(function attach(settingKey) {
SettingsListener.observe(settingKey, this.settings[settingKey],
function observe(aValue) {
var oldValue = this.settings[settingKey];
this.settings[settingKey] = aValue;
switch (settingKey) {
case 'accessibility.screenreader':
if (aValue) {
this.setToSupportedLanguage();
window.addEventListener('mozChromeEvent', this);
window.addEventListener('appwillopen', this);
window.addEventListener('appopened', this);
window.addEventListener('homescreenopening', this);
window.addEventListener('homescreenopened', this);
} else {
window.removeEventListener('mozChromeEvent', this);
window.removeEventListener('appwillopen', this);
window.removeEventListener('appopened', this);
window.removeEventListener('homescreenopening', this);
window.removeEventListener('homescreenopened', this);
}
if (this.settings['accessibility.screenreader-shade']) {
this.toggleShade(aValue, !aValue);
}
this.screen.classList.toggle('screenreader', aValue);
break;
case 'accessibility.colors.enable':
SettingsListener.getSettingsLock().set({
'layers.effect.invert': aValue ?
this.settings['accessibility.colors.invert'] : false,
'layers.effect.grayscale': aValue ?
this.settings['accessibility.colors.grayscale'] : false,
'layers.effect.contrast': aValue ?
this.settings['accessibility.colors.contrast'] *
this.CONTRAST_CAP : '0.0'
});
break;
case 'accessibility.screenreader-captions':
this.speechSynthesizer.captions = aValue;
// If captions are displayed hide them.
if (!aValue) {
this.speechSynthesizer.hideSpeech(true);
}
break;
case 'accessibility.screenreader-shade':
if (this.settings['accessibility.screenreader']) {
this.toggleShade(aValue, oldValue === aValue);
}
break;
case 'accessibility.colors.invert':
case 'accessibility.colors.grayscale':
case 'accessibility.colors.contrast':
if (this.settings['accessibility.colors.enable']) {
var effect = settingKey.split('.').pop();
var gfxSetting = {};
if (effect === 'contrast') {
gfxSetting['layers.effect.contrast'] =
aValue * this.CONTRAST_CAP;
} else {
gfxSetting['layers.effect.' + effect] = aValue;
}
SettingsListener.getSettingsLock().set(gfxSetting);
}
break;
case 'accessibility.screenreader-ftu-timeout-seconds':
this.FTU_STARTED_TIMEOUT = 1000 * aValue;
break;
}
}.bind(this));
}, this);
},
/**
* Reset the expected event to defaults.
* @memberof Accessibility.prototype
*/
reset: function ar_resetEvent() {
this.expectedEvent = {
type: 'volumeup',
timeStamp: 0
};
this.counter = 0;
},
/**
* Unset speaking flag and set the expected complete time stamp.
* @param {?Number} aExpectedCompleteTimeStamp Expected complete time
* stamp.
* @memberof Accessibility.prototype
*/
resetSpeaking: function ar_resetSpeaking(aExpectedCompleteTimeStamp) {
this.isSpeaking = false;
this.expectedCompleteTimeStamp = aExpectedCompleteTimeStamp || 0;
},
toggleShade: function ar_toggleShage(aEnable, aSilent) {
Service.request(aEnable ? 'turnShadeOn' : 'turnShadeOff');
if (!aSilent) {
this.speak({ string: aEnable ? 'shadeToggleOn' : 'shadeToggleOff' },
{ enqueue: true });
}
},
/**
* Checks that device language is supported in text to speech, if not
* it is set to a predetermined fallback language.
* @memberof Accessibility.prototype
*/
setToSupportedLanguage: function ar_setToSupportedLanguage() {
var settingsHelper = SettingsHelper('language.current');
var voices = this.speechSynthesizer.speech.getVoices();
var speechLangs = new Set(voices.map(v => v.lang.split('-')[0]));
settingsHelper.get((value) => {
if (!speechLangs.has(value.split('-')[0])) {
settingsHelper.set(
this.settings['accessibility.screenreader-fallback-lang']);
}
});
},
/**
* Handle volumeup and volumedown events generated from HardwareButtons.
* @param {Object} aEvent a high-level key event object generated from
* HardwareButtons.
* @memberof Accessibility.prototype
*/
handleVolumeButtonPress: function ar_handleVolumeButtonPress(aEvent) {
var type = aEvent.type;
var timeStamp = aEvent.timeStamp;
var expectedEvent = this.expectedEvent;
if (type !== expectedEvent.type || timeStamp > expectedEvent.timeStamp) {
this.reset();
if (type !== 'volumeup') {
return;
}
}
this.expectedEvent = {
type: type === 'volumeup' ? 'volumedown' :
'volumeup',
timeStamp: timeStamp + this.REPEAT_INTERVAL
};
if (++this.counter < this.TOGGLE_SCREEN_READER_COUNT) {
return;
}
this.reset();
this.disableFTUStartedTimeout();
if (!this.isSpeaking && timeStamp > this.expectedCompleteTimeStamp) {
this.cancelSpeech();
this.announceScreenReader()
.then(() => this.resetSpeaking(timeStamp + this.REPEAT_BUTTON_PRESS))
.catch(err => console.error(err));
return;
}
this.cancelSpeech();
this.resetSpeaking();
SettingsListener.getSettingsLock().set({
'accessibility.screenreader':
!this.settings['accessibility.screenreader']
});
},
/**
* Announce screen reader FTU_STARTED_TIMEOUT milliseconds after the FTU is
* loaded if the user does not proceed beyond the first step.
* @param {Object} aEvent an event object generated from FTU launcher.
*/
handleFTUStarted: function ar_handleFTUStarted(aEvent) {
if (this.settings['accessibility.screenreader']) {
// Only set the FTU timeout if the screen reader is not enabled.
return;
}
this.FTUStartedTimeout = setTimeout(() => {
this.cancelSpeech();
this.reset();
this.announceScreenReader()
.then(() => this.resetSpeaking(aEvent.timeStamp +
this.REPEAT_BUTTON_PRESS + this.FTU_STARTED_TIMEOUT * 1000))
.catch(err => console.error(err));
}, this.FTU_STARTED_TIMEOUT);
},
/**
* Disable a timeout before the screen reader starts speaking in FTU.
*/
disableFTUStartedTimeout: function ar_disableFTUStartedTimeout() {
clearTimeout(this.FTUStartedTimeout);
this.FTUStartedTimeout = null;
},
/**
* Reset FTU timeout and stop any spoken instructions if the user steps to
* the next FTU screen.
*/
handleFTUStep: function ar_handleFTUStep() {
if (!this.FTUStartedTimeout) {
// we've not got the started event yet
return;
}
this.disableFTUStartedTimeout();
this.cancelSpeech();
this.reset();
window.removeEventListener('iac-ftucomms', this);
},
/**
* Play audio for a screen reader notification.
* @param {String} aSoundKey a key for the screen reader audio.
* XXX: When Bug 848954 lands we should be able to use Web Audio API.
* @memberof Accessibility.prototype
*/
_playSound: function ar__playSound(aSoundKey) {
if (!this.sounds[aSoundKey]) {
this.sounds[aSoundKey] = new Audio(this.soundURLs[aSoundKey]);
this.sounds[aSoundKey].load();
}
var audio = this.sounds[aSoundKey].cloneNode(false);
audio.volume = this.volume;
audio.play();
},
/**
* Get current screen reader volume defined by the setting.
* @return {Number} Screen reader volume wihtin the [0, 1] interval.
* @memberof Accessibility.prototype
*/
get volume() {
return this.settings['accessibility.screenreader-volume'];
},
/**
* Get current screen reader speech rate defined by the setting.
* @return {Number} Screen reader rate within the [0.2, 10] interval.
* @memberof Accessibility.prototype
*/
get rate() {
var rate = this.settings['accessibility.screenreader-rate'];
return rate >= 0 ? rate + 1 : 1 / (Math.abs(rate) + 1);
},
/**
* Start a timeout that waits to display hints
* @memberof Accessibility.prototype
*/
setHintsTimeout: function ar_setHintsTimeout(aHints) {
clearTimeout(this.hintsTimer);
this.hintsTimer = setTimeout(function onHintsTimeout() {
this.isSpeakingHints = true;
this.speak(aHints, { enqueue: true })
.then(() => this.isSpeakingHints = false)
.catch(err => console.error(err));
}.bind(this), this.HINTS_TIMEOUT);
},
/**
* Handle accessfu mozChromeEvent.
* @param {Object} accessfu details object.
* @memberof Accessibility.prototype
*/
handleAccessFuOutput: function ar_handleAccessFuOutput(aDetails) {
this.cancelHints();
var options = aDetails.options || {};
var type = aDetails.eventType;
window.dispatchEvent(new CustomEvent('accessibility-action'));
if (this.appOpening) {
if (type === 'vc-change') {
this.lastVCChangeDetails = aDetails;
}
return;
}
switch (type) {
case 'vc-change':
// Vibrate when the virtual cursor changes.
navigator.vibrate(options.pattern);
this._playSound(options.isKey ? 'vcKeyAudio' : 'vcMoveAudio');
break;
case 'action':
if (aDetails.data[0].string === 'clickAction') {
// If element is clicked, play 'click' sound instead of speech.
this._playSound('clickedAudio');
return;
}
break;
case 'no-move':
this._playSound('noMoveAudio');
return;
}
this.speak(aDetails.data, { enqueue: options.enqueue }).then(() => {
if (options.hints) {
this.setHintsTimeout(options.hints);
}
}).catch(err => console.error(err));
},
handleAccessFuControl: function ar_handleAccessFuControls(aDetails) {
this.cancelHints();
switch (aDetails.eventType) {
case 'quicknav-menu':
if (!this.quicknav) {
LazyLoader.load(['js/accessibility_quicknav_menu.js'])
.then(() => {
this.quicknav = new AccessibilityQuicknavMenu();
this.quicknav.show();
}).catch(err => console.error(err));
} else {
this.quicknav.show();
}
break;
case 'toggle-shade':
SettingsListener.getSettingsLock().set({
'accessibility.screenreader-shade':
!this.settings['accessibility.screenreader-shade']
});
window.dispatchEvent(new CustomEvent('accessibility-action'));
break;
case 'toggle-pause':
this.speechSynthesizer.togglePause();
break;
default:
break;
}
},
/**
* Listen for screen change events and stop speaking if the
* screen is disabled (in 'off' state)
* @memberof Accessibility.prototype
*/
handleScreenChange: function ar_handleScreenChange(aDetail){
if(!aDetail.screenEnabled){
this.cancelHints();
}
},
/**
* Remove aria-hidden from the screen element to make content accessible to
* the screen reader.
* @memberof Accessibility.prototype
*/
activateScreen: function ar_activateScreen() {
// Screen reader will not say anything until the splash animation is
// hidden and the aria-hidden attribute is removed from #screen.
this.screen.removeAttribute('aria-hidden');
window.removeEventListener('logohidden', this);
},
/**
* Set the flag indicating that the app is opening.
*/
prepareForApp: function ar_prepareForApp() {
this.appOpening = true;
},
/**
* Speak the name of the app and announce latest virtual cursor change.
* @param {Object} aDetail app_window object
*/
announceApp: function ar_announceApp(aDetail) {
this.speak(aDetail.name)
.then(() => {
this.appOpening = false;
if (this.lastVCChangeDetails) {
this.handleAccessFuOutput(this.lastVCChangeDetails);
delete this.lastVCChangeDetails;
}
})
.catch(err => console.error(err));
},
/**
* Handle event.
* @param {Object} aEvent mozChromeEvent/logohidden/volumeup/volumedown.
* @memberof Accessibility.prototype
*/
handleEvent: function ar_handleEvent(aEvent) {
switch (aEvent.type) {
case 'screenchange':
this.handleScreenChange(aEvent.detail);
break;
case 'logohidden':
this.activateScreen();
break;
case 'iac-ftucomms':
if (aEvent.detail === 'started') {
this.handleFTUStarted(aEvent);
} else if(aEvent.detail.type == 'step') {
this.handleFTUStep();
}
break;
case 'mozChromeEvent':
switch (aEvent.detail.type) {
case 'accessibility-output':
this.handleAccessFuOutput(JSON.parse(aEvent.detail.details));
break;
case 'accessibility-control':
this.handleAccessFuControl(JSON.parse(aEvent.detail.details));
break;
}
break;
case 'volumeup':
case 'volumedown':
this.handleVolumeButtonPress(aEvent);
break;
case 'appwillopen':
case 'homescreenopening':
this.prepareForApp();
break;
case 'appopened':
case 'homescreenopened':
this.announceApp(aEvent.detail);
break;
}
},
/**
* Check for Hints speech/timer and clear.
* @memberof Accessibility.prototype
*/
cancelHints: function ar_cancelHints() {
clearTimeout(this.hintsTimer);
if(this.isSpeakingHints){
this.cancelSpeech();
this.isSpeakingHints = false;
}
},
/**
* Based on whether the screen reader is currently enabled, announce the
* instructions of how to enable/disable it.
* @memberof Accessibility.prototype
*/
announceScreenReader: function ar_announceScreenReader() {
var enabled = this.settings['accessibility.screenreader'];
this.isSpeaking = true;
return this.speak({
string: enabled ? 'disableScreenReaderSteps' : 'enableScreenReaderSteps'
}, {enqueue: false});
},
/**
* Use speechSynthesis to speak screen reader utterances.
* @param {?Array} aData Speech data before it is localized.
* synthesis is completed.
* @param {?Object} aOptions = {} Speech options such as enqueue etc.
* @memberof Accessibility.prototype
*/
speak: function ar_speak(aData, aOptions = {}) {
return this.speechSynthesizer.speak(
aData, aOptions, this.rate, this.volume);
},
/**
* Cancel any utterances currently being spoken by speechSynthesis.
* @memberof Accessibility.prototype
*/
cancelSpeech: function ar_cancelSpeech() {
this.speechSynthesizer.cancel();
}
};
/**
* A speech synthesizer component that handles speech localization and
* pronunciation.
* @type {Object}
*/
var speechSynthesizer = {
/**
* A delay before hiding screen reader caption.
* @type {Number}
* @memberof speechSynthesizer
*/
CAPTIONS_DELAY: 1500,
/**
* A flag used for speech captions rendering.
* @type {Boolean}
* @memberof speechSynthesizer
*/
captions: false,
/**
* Screen container getter.
* @type {Object}
* @memberof speechSynthesizer
*/
get screen() {
delete this.screen;
this.screen = document.getElementById('screen');
return this.screen;
},
/**
* Speech Synthesis
* @type {Object}
* @memberof speechSynthesizer
*/
get speech() {
delete this.speech;
// If there are no voices bundled, consider speech synthesis unavailable.
if (!window.speechSynthesis ||
window.speechSynthesis.getVoices().length === 0) {
this.speech = null;
}
this.speech = window.speechSynthesis;
return this.speech;
},
/**
* Speech utterance
* @type {Object}
* @memberof speechSynthesizer
*/
get utterance() {
delete this.utterance;
this.utterance = window.SpeechSynthesisUtterance;
return this.utterance;
},
/**
* Cancle speech if the screen reader is speaking.
* @memberof speechSynthesizer
*/
cancel: function ss_cancel() {
if (this.speech) {
this.speech.cancel();
}
},
/**
* Localize speech data.
* @param {Object} aDetails Speech data object.
* @return {String} Localized speech data.
* @memberof speechSynthesizer
*/
localize: function ss_localize(aDetails) {
if (!aDetails || typeof aDetails === 'string') {
return aDetails;
}
var string = aDetails.string;
var data = {
count: aDetails.count
};
if (!string) {
return '';
} else {
string = 'accessibility-' + string;
}
if (aDetails.args) {
data = aDetails.args.reduce(function(aData, val, index) {
aData[index] = val;
return aData;
}, data);
}
return navigator.mozL10n.get(string, data);
},
/**
* Build a complete utterance string by localizing an array of speech data.
* @param {?Array} aData Speech data.
* @return {String} A complete localized string from speech array data.
* @memberof speechSynthesizer
*/
buildUtterance: function ss_buildUtterance(aData) {
if (!Array.isArray(aData)) {
aData = [aData];
}
var words = [], localize = this.localize;
aData.reduce(function(words, details) {
var localized = localize(details);
if (localized) {
var word = localized.trim();
if (word) {
words.push(word);
}
}
return words;
}, words);
return words.join(' ');
},
/**
* Show caption for the screen reader.
* @param {String} aUtterance Current screen reader caption.
* @memberof speechSynthesizer
*/
showSpeech: function ar_showSpeech(aUtterance) {
if (!this.captionsBox) {
this.captionsBox = document.createElement('div');
this.captionsBox.id = 'accessibility-captions-box';
this.captionsBox.setAttribute('data-z-index-level',
'accessibility-captions');
this.captionsBox.setAttribute('aria-hidden', true);
this.screen.appendChild(this.captionsBox);
}
window.clearTimeout(this.captionsHideTimeout);
this.captionsHideTimeout = null;
this.captionsBox.textContent = aUtterance;
this.captionsBox.classList.add('visible');
},
/**
* Hide current screen reader caption.
* @param {Boolean} aImmediately A flag to hide captionsBox immediately.
* @memberof speechSynthesizer
*/
hideSpeech: function ar_hideSpeech(aImmediately) {
if (!this.captionsBox) {
return;
}
// Hide the caption after CAPTIONS_DELAY.
if (aImmediately) {
this.captionsBox.classList.remove('visible');
} else {
this.captionsHideTimeout = window.setTimeout(function() {
this.captionsBox.classList.remove('visible');
}.bind(this), this.CAPTIONS_DELAY);
}
},
togglePause: function ss_togglePause() {
if (this.speech.paused) {
this.speech.resume();
} else if (this.speech.speaking) {
this.speech.pause();
}
},
/**
* Utter a message with a speechSynthesizer.
* @param {?Array} aData A messages array to be localized.
* @param {JSON} aOptions Options to be used when speaking. For example: {
* enqueue: false
* }
* @param {Number} aRate Speech rate.
* @param {Number} aVolume Speech volume.
* @memberof speechSynthesizer
*/
speak: function ss_speak(aData, aOptions, aRate, aVolume) {
if (!this.speech || !this.utterance) {
return Promise.resolve();
}
if (!aOptions.enqueue) {
this.cancel();
}
if (this.speech.paused) {
this.speech.resume();
}
var sentence = this.buildUtterance(aData);
if (!sentence) {
return Promise.resolve();
}
return new Promise(resolve => {
var utterance = new this.utterance(sentence);
utterance.volume = aVolume;
utterance.rate = aRate;
utterance.addEventListener('end', () => {
if (this.captions) {
this.hideSpeech();
}
resolve();
});
if (this.captions) {
this.showSpeech(sentence);
}
this.speech.speak(utterance);
});
}
};
exports.Accessibility = Accessibility;
}(window));