VulpesOS/gaia/tv_apps/smart-system/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

590 lines
18 KiB
JavaScript
Executable file

'use strict';
/* global SettingsCache, SettingsListener */
(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,
/**
* 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.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
},
/**
* 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'
},
/**
* Start listening for events.
* @memberof Accessibility.prototype
*/
start: function ar_init() {
this.screen = document.getElementById('screen');
this.speechSynthesizer = speechSynthesizer;
window.addEventListener('mozChromeEvent', this);
window.addEventListener('volumeup', this);
window.addEventListener('volumedown', this);
window.addEventListener('logohidden', this);
// Attach all observers.
Object.keys(this.settings).forEach(function attach(settingKey) {
SettingsCache.observe(settingKey, this.settings[settingKey],
function observe(aValue) {
this.settings[settingKey] = aValue;
switch (settingKey) {
case 'accessibility.screenreader':
// Show Accessibility panel if it is not already visible
if (aValue) {
SettingsListener.getSettingsLock().set({
'accessibility.screenreader-show-settings': true
});
}
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'] : '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.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;
}
}.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;
},
/**
* 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();
if (!this.isSpeaking && timeStamp > this.expectedCompleteTimeStamp) {
this.speechSynthesizer.cancel();
this.announceScreenReader(function onEnd() {
this.resetSpeaking(timeStamp + this.REPEAT_BUTTON_PRESS);
}.bind(this));
return;
}
this.speechSynthesizer.cancel();
this.resetSpeaking();
SettingsListener.getSettingsLock().set({
'accessibility.screenreader':
!this.settings['accessibility.screenreader']
});
},
/**
* 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);
},
/**
* Handle accessfu mozChromeEvent.
* @param {Object} accessfu details object.
* @memberof Accessibility.prototype
*/
handleAccessFuOutput: function ar_handleAccessFuOutput(aDetails) {
var options = aDetails.options || {};
window.dispatchEvent(new CustomEvent('accessibility-action'));
switch (aDetails.eventType) {
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;
}
this.speak(aDetails.data, null, {
enqueue: options.enqueue
});
},
/**
* 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);
},
/**
* Handle event.
* @param {Object} aEvent mozChromeEvent/logohidden/volumeup/volumedown.
* @memberof Accessibility.prototype
*/
handleEvent: function ar_handleEvent(aEvent) {
switch (aEvent.type) {
case 'logohidden':
this.activateScreen();
break;
case 'mozChromeEvent':
switch (aEvent.detail.type) {
case 'accessibility-output':
this.handleAccessFuOutput(JSON.parse(aEvent.detail.details));
break;
}
break;
case 'volumeup':
case 'volumedown':
this.handleVolumeButtonPress(aEvent);
break;
}
},
/**
* Based on whether the screen reader is currently enabled, announce the
* instructions of how to enable/disable it.
* @param {Function} aCallback A callback after the speech synthesis is
* completed.
* @memberof Accessibility.prototype
*/
announceScreenReader: function ar_announceScreenReader(aCallback) {
var enabled = this.settings['accessibility.screenreader'];
this.isSpeaking = true;
this.speak({
string: enabled ? 'disableScreenReaderSteps' : 'enableScreenReaderSteps'
}, aCallback, {enqueue: false});
},
/**
* Use speechSynthesis to speak screen reader utterances.
* @param {?Array} aData Speech data before it is localized.
* @param {?Function} aCallback aCallback A callback after the speech
* synthesis is completed.
* @param {?Object} aOptions = {} Speech options such as enqueue etc.
* @memberof Accessibility.prototype
*/
speak: function ar_speak(aData, aCallback, aOptions = {}) {
this.speechSynthesizer.speak(aData, aOptions, this.rate, this.volume,
aCallback);
}
};
/**
* 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);
}
},
/**
* 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.
* @param {Function} aCallback A callback after the speech synthesis is
* completed.
* @memberof speechSynthesizer
*/
speak: function ss_speak(aData, aOptions, aRate, aVolume, aCallback) {
if (!this.speech || !this.utterance) {
if (aCallback) {
aCallback();
}
return;
}
if (!aOptions.enqueue) {
this.cancel();
}
var sentence = this.buildUtterance(aData);
if (!sentence) {
if (aCallback) {
aCallback();
}
return;
}
var utterance = new this.utterance(sentence);
utterance.volume = aVolume;
utterance.rate = aRate;
utterance.addEventListener('end', function() {
if (this.captions) {
this.hideSpeech();
}
if (aCallback) {
aCallback();
}
}.bind(this));
if (this.captions) {
this.showSpeech(sentence);
}
this.speech.speak(utterance);
}
};
exports.Accessibility = Accessibility;
}(window));