diff --git a/modules/libpref/init/all.js b/modules/libpref/init/all.js
index b5754e97af4..61161bc674f 100644
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -5162,6 +5162,16 @@ pref("reader.has_used_toolbar", false);
// Whether to use a vertical or horizontal toolbar.
pref("reader.toolbar.vertical", true);
+#if !defined(ANDROID)
+pref("narrate.enabled", true);
+#else
+pref("narrate.enabled", false);
+#endif
+
+pref("narrate.test", false);
+pref("narrate.rate", 0);
+pref("narrate.voice", "automatic");
+
#if defined(XP_LINUX) && defined(MOZ_GMP_SANDBOX)
// Whether to allow, on a Linux system that doesn't support the necessary sandboxing
// features, loading Gecko Media Plugins unsandboxed. However, EME CDMs will not be
diff --git a/toolkit/components/moz.build b/toolkit/components/moz.build
index c115ef8219c..890bed038b7 100644
--- a/toolkit/components/moz.build
+++ b/toolkit/components/moz.build
@@ -34,6 +34,7 @@ DIRS += [
'gfx',
'jsdownloads',
'lz4',
+ 'narrate',
'mediasniffer',
'microformats',
'osfile',
diff --git a/toolkit/components/narrate/.eslintrc b/toolkit/components/narrate/.eslintrc
new file mode 100644
index 00000000000..0bbf0b79448
--- /dev/null
+++ b/toolkit/components/narrate/.eslintrc
@@ -0,0 +1,93 @@
+{
+ "extends": [
+ "../../.eslintrc"
+ ],
+
+ "globals": {
+ "Components": true,
+ "dump": true,
+ "Iterator": true
+ },
+
+ "env": { "browser": true },
+
+ "rules": {
+ // Mozilla stuff
+ "mozilla/no-aArgs": 1,
+ "mozilla/reject-importGlobalProperties": 1,
+ "mozilla/var-only-at-top-level": 1,
+
+ "block-scoped-var": 2,
+ "brace-style": [1, "1tbs", {"allowSingleLine": false}],
+ "camelcase": 1,
+ "comma-dangle": 1,
+ "comma-spacing": [1, {"before": false, "after": true}],
+ "comma-style": [1, "last"],
+ "complexity": 1,
+ "consistent-return": 2,
+ "curly": 2,
+ "dot-location": [1, "property"],
+ "dot-notation": 2,
+ "eol-last": 2,
+ "generator-star-spacing": [1, "after"],
+ "indent": [1, 2, {"SwitchCase": 1}],
+ "key-spacing": [1, {"beforeColon": false, "afterColon": true}],
+ "max-len": [1, 80, 2, {"ignoreUrls": true}],
+ "max-nested-callbacks": [2, 3],
+ "new-cap": [2, {"capIsNew": false}],
+ "new-parens": 2,
+ "no-array-constructor": 2,
+ "no-cond-assign": 2,
+ "no-control-regex": 2,
+ "no-debugger": 2,
+ "no-delete-var": 2,
+ "no-dupe-args": 2,
+ "no-dupe-keys": 2,
+ "no-duplicate-case": 2,
+ "no-else-return": 2,
+ "no-eval": 2,
+ "no-extend-native": 2,
+ "no-extra-bind": 2,
+ "no-extra-boolean-cast": 2,
+ "no-extra-semi": 1,
+ "no-fallthrough": 2,
+ "no-inline-comments": 1,
+ "no-lonely-if": 2,
+ "no-mixed-spaces-and-tabs": 2,
+ "no-multi-spaces": 1,
+ "no-multi-str": 1,
+ "no-multiple-empty-lines": [1, {"max": 1}],
+ "no-native-reassign": 2,
+ "no-nested-ternary": 2,
+ "no-redeclare": 2,
+ "no-return-assign": 2,
+ "no-self-compare": 2,
+ "no-sequences": 2,
+ "no-shadow": 1,
+ "no-shadow-restricted-names": 2,
+ "no-spaced-func": 1,
+ "no-throw-literal": 2,
+ "no-trailing-spaces": 2,
+ "no-undef": 2,
+ "no-unneeded-ternary": 2,
+ "no-unreachable": 2,
+ "no-unused-vars": 2,
+ "no-with": 2,
+ "padded-blocks": [1, "never"],
+ "quotes": [1, "double", "avoid-escape"],
+ "semi": [1, "always"],
+ "semi-spacing": [1, {"before": false, "after": true}],
+ "space-after-keywords": [1, "always"],
+ "space-before-blocks": [1, "always"],
+ "space-before-function-paren": [1, "never"],
+ "space-in-parens": [1, "never"],
+ "space-infix-ops": [1, {"int32Hint": true}],
+ "space-return-throw-case": 1,
+ "space-unary-ops": [1, { "words": true, "nonwords": false }],
+ "spaced-comment": [1, "always"],
+ "strict": [2, "global"],
+ "use-isnan": 2,
+ "valid-typeof": 2,
+ "yoda": 2
+ }
+}
diff --git a/toolkit/components/narrate/NarrateControls.jsm b/toolkit/components/narrate/NarrateControls.jsm
new file mode 100644
index 00000000000..1b56d189a32
--- /dev/null
+++ b/toolkit/components/narrate/NarrateControls.jsm
@@ -0,0 +1,244 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/narrate/VoiceSelect.jsm");
+Cu.import("resource://gre/modules/narrate/Narrator.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+this.EXPORTED_SYMBOLS = ["NarrateControls"];
+
+var gStrings = Services.strings.createBundle("chrome://global/locale/narrate.properties");
+
+function NarrateControls(mm, win) {
+ this._mm = mm;
+ this._winRef = Cu.getWeakReference(win);
+
+ // Append content style sheet in document head
+ let style = win.document.createElement("link");
+ style.rel = "stylesheet";
+ style.href = "chrome://global/skin/narrate.css";
+ win.document.head.appendChild(style);
+
+ function localize(pieces, ...substitutions) {
+ let result = pieces[0];
+ for (let i = 0; i < substitutions.length; ++i) {
+ result += gStrings.GetStringFromName(substitutions[i]) + pieces[i + 1];
+ }
+ return result;
+ }
+
+ let dropdown = win.document.createElement("ul");
+ dropdown.className = "dropdown";
+ dropdown.id = "narrate-dropdown";
+ dropdown.innerHTML =
+ localize`
+
+
+
+ `;
+
+ this.narrator = new Narrator(win);
+
+ let selectLabel = gStrings.GetStringFromName("selectvoicelabel");
+ let comparer = win.Intl ?
+ (new Intl.Collator()).compare : (a, b) => a.localeCompare(b);
+ let options = this.narrator.getVoiceOptions().map(v => {
+ return {
+ label: this._createVoiceLabel(v),
+ value: v.voiceURI
+ };
+ }).sort((a, b) => comparer(a.label, b.label));
+ options.unshift({
+ label: gStrings.GetStringFromName("defaultvoice"),
+ value: "automatic"
+ });
+ this.voiceSelect = new VoiceSelect(win, selectLabel, options);
+ this.voiceSelect.element.addEventListener("change", this);
+ this.voiceSelect.element.id = "voice-select";
+ dropdown.querySelector("#narrate-voices").appendChild(
+ this.voiceSelect.element);
+
+ dropdown.addEventListener("click", this, true);
+
+ let rateRange = dropdown.querySelector("#narrate-rate > input");
+ rateRange.addEventListener("input", this);
+ rateRange.addEventListener("mousedown", this);
+ rateRange.addEventListener("mouseup", this);
+
+ let branch = Services.prefs.getBranch("narrate.");
+ this.voiceSelect.value = branch.getCharPref("voice");
+ // The rate is stored as an integer.
+ rateRange.value = branch.getIntPref("rate");
+
+ let tb = win.document.getElementById("reader-toolbar");
+ tb.appendChild(dropdown);
+}
+
+NarrateControls.prototype = {
+ handleEvent: function(evt) {
+ switch (evt.type) {
+ case "mousedown":
+ this._rateMousedown = true;
+ break;
+ case "mouseup":
+ this._rateMousedown = false;
+ break;
+ case "input":
+ this._onRateInput(evt);
+ break;
+ case "change":
+ this._onVoiceChange();
+ break;
+ case "click":
+ this._onButtonClick(evt);
+ break;
+ }
+ },
+
+ _onRateInput: function(evt) {
+ if (!this._rateMousedown) {
+ this._mm.sendAsyncMessage("Reader:SetIntPref",
+ { name: "narrate.rate", value: evt.target.value });
+ this.narrator.setRate(this._convertRate(evt.target.value));
+ }
+ },
+
+ _onVoiceChange: function() {
+ let voice = this.voice;
+ this._mm.sendAsyncMessage("Reader:SetCharPref",
+ { name: "narrate.voice", value: voice });
+ this.narrator.setVoice(voice);
+ },
+
+ _onButtonClick: function(evt) {
+ switch (evt.target.id) {
+ case "narrate-skip-previous":
+ this.narrator.skipPrevious();
+ break;
+ case "narrate-skip-next":
+ this.narrator.skipNext();
+ break;
+ case "narrate-start-stop":
+ if (this.narrator.speaking) {
+ this.narrator.stop();
+ } else {
+ this._updateSpeechControls(true);
+ let options = { rate: this.rate, voice: this.voice };
+ this.narrator.start(options).then(() => {
+ this._updateSpeechControls(false);
+ });
+ }
+ break;
+ case "narrate-toggle":
+ let dropdown = this._doc.getElementById("narrate-dropdown");
+ if (dropdown.classList.contains("open")) {
+ if (this.narrator.speaking) {
+ this.narrator.stop();
+ }
+
+ // We need to remove "keep-open" class here so that AboutReader
+ // closes this dropdown properly. This class is eventually removed in
+ // _updateSpeechControls which gets called after narration stops,
+ // but that happend asynchronously and is too late.
+ dropdown.classList.remove("keep-open");
+ }
+ break;
+ }
+ },
+
+ _updateSpeechControls: function(speaking) {
+ let dropdown = this._doc.getElementById("narrate-dropdown");
+ dropdown.classList.toggle("keep-open", speaking);
+
+ let startStopButton = this._doc.getElementById("narrate-start-stop");
+ startStopButton.classList.toggle("speaking", speaking);
+ startStopButton.title =
+ gStrings.GetStringFromName(speaking ? "start" : "stop");
+
+ this._doc.getElementById("narrate-skip-previous").disabled = !speaking;
+ this._doc.getElementById("narrate-skip-next").disabled = !speaking;
+ },
+
+ _createVoiceLabel: function(voice) {
+ // This is a highly imperfect method of making human-readable labels
+ // for system voices. Because each platform has a different naming scheme
+ // for voices, we use a different method for each platform.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ // On windows the language is included in the name, so just use the name
+ return voice.name;
+ case "Linux":
+ // On Linux, the name is usually the unlocalized language name.
+ // Use a localized language name, and have the language tag in
+ // parenthisis. This is to avoid six languages called "English".
+ return gStrings.formatStringFromName("voiceLabel",
+ [this._getLanguageName(voice.lang) || voice.name, voice.lang], 2);
+ default:
+ // On Mac the language is not included in the name, find a localized
+ // language name or show the tag if none exists.
+ // This is the ideal naming scheme so it is also the "default".
+ return gStrings.formatStringFromName("voiceLabel",
+ [voice.name, this._getLanguageName(voice.lang) || voice.lang], 2);
+ }
+ },
+
+ _getLanguageName: function(lang) {
+ if (!this._langStrings) {
+ this._langStrings = Services.strings.createBundle(
+ "chrome://global/locale/languageNames.properties ");
+ }
+
+ try {
+ // language tags will be lower case ascii between 2 and 3 characters long.
+ return this._langStrings.GetStringFromName(lang.match(/^[a-z]{2,3}/)[0]);
+ } catch (e) {
+ return "";
+ }
+ },
+
+ _convertRate: function(rate) {
+ // We need to convert a relative percentage value to a fraction rate value.
+ // eg. -100 is half the speed, 100 is twice the speed in percentage,
+ // 0.5 is half the speed and 2 is twice the speed in fractions.
+ return Math.pow(Math.abs(rate / 100) + 1, rate < 0 ? -1 : 1);
+ },
+
+ get _win() {
+ return this._winRef.get();
+ },
+
+ get _doc() {
+ return this._win.document;
+ },
+
+ get rate() {
+ return this._convertRate(
+ this._doc.getElementById("narrate-rate-input").value);
+ },
+
+ get voice() {
+ return this.voiceSelect.value;
+ }
+};
diff --git a/toolkit/components/narrate/Narrator.jsm b/toolkit/components/narrate/Narrator.jsm
new file mode 100644
index 00000000000..14a6ea44c8a
--- /dev/null
+++ b/toolkit/components/narrate/Narrator.jsm
@@ -0,0 +1,219 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "LanguageDetector",
+ "resource:///modules/translation/LanguageDetector.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+ "resource://gre/modules/Services.jsm");
+
+this.EXPORTED_SYMBOLS = [ "Narrator" ];
+
+// Maximum time into paragraph when pressing "skip previous" will go
+// to previous paragraph and not the start of current one.
+const PREV_THRESHOLD = 2000;
+
+function Narrator(win) {
+ this._winRef = Cu.getWeakReference(win);
+ this._inTest = Services.prefs.getBoolPref("narrate.test");
+ this._speechOptions = {};
+ this._startTime = 0;
+ this._stopped = false;
+}
+
+Narrator.prototype = {
+ get _doc() {
+ return this._winRef.get().document;
+ },
+
+ get _win() {
+ return this._winRef.get();
+ },
+
+ get _voiceMap() {
+ if (!this._voiceMapInner) {
+ this._voiceMapInner = new Map();
+ for (let voice of this._win.speechSynthesis.getVoices()) {
+ this._voiceMapInner.set(voice.voiceURI, voice);
+ }
+ }
+
+ return this._voiceMapInner;
+ },
+
+ get _paragraphs() {
+ if (!this._paragraphsInner) {
+ let wu = this._win.QueryInterface(
+ Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
+ let queryString = "#reader-header > *:not(style):not(:empty), " +
+ "#moz-reader-content > .page > * > *:not(style):not(:empty)";
+ // filter out zero sized paragraphs.
+ let paragraphs = Array.from(this._doc.querySelectorAll(queryString));
+ paragraphs = paragraphs.filter(p => {
+ let bb = wu.getBoundsWithoutFlushing(p);
+ return bb.width && bb.height;
+ });
+
+ this._paragraphsInner = paragraphs.map(Cu.getWeakReference);
+ }
+
+ return this._paragraphsInner;
+ },
+
+ get _timeIntoParagraph() {
+ let rv = Date.now() - this._startTime;
+ return rv;
+ },
+
+ get speaking() {
+ return this._win.speechSynthesis.speaking ||
+ this._win.speechSynthesis.pending;
+ },
+
+ _getParagraphAt: function(index) {
+ let paragraph = this._paragraphsInner[index];
+ return paragraph ? paragraph.get() : null;
+ },
+
+ _isParagraphInView: function(paragraphRef) {
+ let paragraph = paragraphRef && paragraphRef.get && paragraphRef.get();
+ if (!paragraph) {
+ return false;
+ }
+
+ let bb = paragraph.getBoundingClientRect();
+ return bb.top >= 0 && bb.top < this._win.innerHeight;
+ },
+
+ _detectLanguage: function() {
+ if (this._speechOptions.lang || this._speechOptions.voice) {
+ return Promise.resolve();
+ }
+
+ let sampleText = this._doc.getElementById(
+ "moz-reader-content").textContent.substring(0, 60 * 1024);
+ return LanguageDetector.detectLanguage(sampleText).then(result => {
+ if (result.confident) {
+ this._speechOptions.lang = result.language;
+ }
+ });
+ },
+
+ _sendTestEvent: function(eventType, detail) {
+ let win = this._win;
+ win.dispatchEvent(new win.CustomEvent(eventType,
+ { detail: Cu.cloneInto(detail, win.document) }));
+ },
+
+ _speakInner: function() {
+ this._win.speechSynthesis.cancel();
+ let paragraph = this._getParagraphAt(this._index);
+ let utterance = new this._win.SpeechSynthesisUtterance(
+ paragraph.textContent);
+ utterance.rate = this._speechOptions.rate;
+ if (this._speechOptions.voice) {
+ utterance.voice = this._speechOptions.voice;
+ } else {
+ utterance.lang = this._speechOptions.lang;
+ }
+
+ this._startTime = Date.now();
+
+ return new Promise(resolve => {
+ utterance.addEventListener("start", () => {
+ paragraph.classList.add("narrating");
+ let bb = paragraph.getBoundingClientRect();
+ if (bb.top < 0 || bb.bottom > this._win.innerHeight) {
+ paragraph.scrollIntoView({ behavior: "smooth", block: "start"});
+ }
+
+ if (this._inTest) {
+ this._sendTestEvent("paragraphstart", {
+ voice: utterance.chosenVoiceURI,
+ rate: utterance.rate,
+ paragraph: this._index
+ });
+ }
+ });
+
+ utterance.addEventListener("end", () => {
+ if (!this._win) {
+ // page got unloaded, don't do anything.
+ return;
+ }
+
+ paragraph.classList.remove("narrating");
+ this._startTime = 0;
+ if (this._inTest) {
+ this._sendTestEvent("paragraphend", {});
+ }
+
+ if (this._index + 1 >= this._paragraphs.length || this._stopped) {
+ // We reached the end of the document, or the user pressed stopped.
+ resolve();
+ } else {
+ this._index++;
+ this._speakInner().then(resolve);
+ }
+ });
+
+ this._win.speechSynthesis.speak(utterance);
+ });
+ },
+
+ getVoiceOptions: function() {
+ return Array.from(this._voiceMap.values());
+ },
+
+ start: function(speechOptions) {
+ this._speechOptions = {
+ rate: speechOptions.rate,
+ voice: this._voiceMap.get(speechOptions.voice)
+ };
+
+ this._stopped = false;
+ return this._detectLanguage().then(() => {
+ if (!this._isParagraphInView(this._paragraphs[this._index])) {
+ this._index = this._paragraphs.findIndex(
+ this._isParagraphInView.bind(this));
+ }
+
+ return this._speakInner();
+ });
+ },
+
+ stop: function() {
+ this._stopped = true;
+ this._win.speechSynthesis.cancel();
+ },
+
+ skipNext: function() {
+ this._win.speechSynthesis.cancel();
+ },
+
+ skipPrevious: function() {
+ this._index -=
+ this._index > 0 && this._timeIntoParagraph < PREV_THRESHOLD ? 2 : 1;
+ this._win.speechSynthesis.cancel();
+ },
+
+ setRate: function(rate) {
+ this._speechOptions.rate = rate;
+ /* repeat current paragraph */
+ this._index--;
+ this._win.speechSynthesis.cancel();
+ },
+
+ setVoice: function(voice) {
+ this._speechOptions.voice = this._voiceMap.get(voice);
+ /* repeat current paragraph */
+ this._index--;
+ this._win.speechSynthesis.cancel();
+ }
+};
diff --git a/toolkit/components/narrate/VoiceSelect.jsm b/toolkit/components/narrate/VoiceSelect.jsm
new file mode 100644
index 00000000000..4965575401d
--- /dev/null
+++ b/toolkit/components/narrate/VoiceSelect.jsm
@@ -0,0 +1,291 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Cu = Components.utils;
+
+this.EXPORTED_SYMBOLS = ["VoiceSelect"];
+
+function VoiceSelect(win, label, options = []) {
+ this._winRef = Cu.getWeakReference(win);
+
+ let element = win.document.createElement("div");
+ element.classList.add("voiceselect");
+ element.innerHTML =
+ `
+ `;
+
+ this._elementRef = Cu.getWeakReference(element);
+
+ let button = this.selectToggle;
+ button.addEventListener("click", this);
+ button.addEventListener("keypress", this);
+
+ let listbox = this.listbox;
+ listbox.addEventListener("click", this);
+ listbox.addEventListener("mousemove", this);
+ listbox.addEventListener("keypress", this);
+ listbox.addEventListener("wheel", this, true);
+
+ win.addEventListener("resize", () => {
+ this._updateDropdownHeight();
+ });
+
+ for (let option of options) {
+ this.add(option.label, option.value);
+ }
+
+ this.selectedIndex = 0;
+}
+
+VoiceSelect.prototype = {
+ add: function(label, value) {
+ let option = this._doc.createElement("button");
+ option.dataset.value = value;
+ option.classList.add("option");
+ option.tabIndex = "-1";
+ option.setAttribute("role", "option");
+ option.textContent = label;
+ this.listbox.appendChild(option);
+ },
+
+ toggleList: function(force, focus = true) {
+ if (this.element.classList.toggle("open", force)) {
+ if (focus) {
+ (this.selected || this.options[0]).focus();
+ }
+
+ this._updateDropdownHeight(true);
+ this.listbox.setAttribute("aria-expanded", true);
+ this._win.addEventListener("focus", this, true);
+ } else {
+ if (focus) {
+ this.element.querySelector(".select-toggle").focus();
+ }
+
+ this.listbox.setAttribute("aria-expanded", false);
+ this._win.removeEventListener("focus", this, true);
+ }
+ },
+
+ handleEvent: function(evt) {
+ let target = evt.target;
+
+ switch (evt.type) {
+ case "click":
+ if (target.classList.contains("option")) {
+ if (!target.classList.contains("selected")) {
+ this.selected = target;
+ }
+
+ this.toggleList(false);
+ } else if (target.classList.contains("select-toggle")) {
+ this.toggleList();
+ }
+ break;
+
+ case "mousemove":
+ this.listbox.classList.add("hovering");
+ break;
+
+ case "keypress":
+ if (target.classList.contains("select-toggle")) {
+ if (evt.altKey) {
+ this.toggleList(true);
+ } else {
+ this._keyPressedButton(evt);
+ }
+ } else {
+ this.listbox.classList.remove("hovering");
+ this._keyPressedInBox(evt);
+ }
+ break;
+
+ case "wheel":
+ // Don't let wheel events bubble to document. It will scroll the page
+ // and close the entire narrate dialog.
+ evt.stopPropagation();
+ break;
+
+ case "focus":
+ this._win.console.log(evt);
+ if (!evt.target.closest('.options')) {
+ this.toggleList(false, false);
+ }
+ break;
+ }
+ },
+
+ _getPagedOption: function(option, up) {
+ let height = elem => elem.getBoundingClientRect().height;
+ let listboxHeight = height(this.listbox);
+
+ let next = option;
+ for (let delta = 0; delta < listboxHeight; delta += height(next)) {
+ let sibling = up ? next.previousElementSibling : next.nextElementSibling;
+ if (!sibling) {
+ break;
+ }
+
+ next = sibling;
+ }
+
+ return next;
+ },
+
+ _keyPressedButton: function(evt) {
+ if (evt.altKey && (evt.key === "ArrowUp" || evt.key === "ArrowUp")) {
+ this.toggleList(true);
+ return;
+ }
+
+ let toSelect;
+ switch (evt.key) {
+ case "PageUp":
+ case "ArrowUp":
+ toSelect = this.selected.previousElementSibling;
+ break;
+ case "PageDown":
+ case "ArrowDown":
+ toSelect = this.selected.nextElementSibling;
+ break;
+ case "Home":
+ toSelect = this.selected.parentNode.firstElementChild;
+ break;
+ case "End":
+ toSelect = this.selected.parentNode.lastElementChild;
+ break;
+ }
+
+ if (toSelect && toSelect.classList.contains("option")) {
+ evt.preventDefault();
+ this.selected = toSelect;
+ }
+ },
+
+ _keyPressedInBox: function(evt) {
+ let toFocus;
+ let cur = this._doc.activeElement;
+
+ switch (evt.key) {
+ case "ArrowUp":
+ toFocus = cur.previousElementSibling || this.listbox.lastElementChild;
+ break;
+ case "ArrowDown":
+ toFocus = cur.nextElementSibling || this.listbox.firstElementChild;
+ break;
+ case "PageUp":
+ toFocus = this._getPagedOption(cur, true);
+ break;
+ case "PageDown":
+ toFocus = this._getPagedOption(cur, false);
+ break;
+ case "Home":
+ toFocus = cur.parentNode.firstElementChild;
+ break;
+ case "End":
+ toFocus = cur.parentNode.lastElementChild;
+ break;
+ case "Escape":
+ this.toggleList(false);
+ break;
+ }
+
+ if (toFocus && toFocus.classList.contains("option")) {
+ evt.preventDefault();
+ toFocus.focus();
+ }
+ },
+
+ _select: function(option) {
+ let oldSelected = this.selected;
+ if (oldSelected) {
+ oldSelected.removeAttribute("aria-selected");
+ oldSelected.classList.remove("selected");
+ }
+
+ if (option) {
+ option.setAttribute("aria-selected", true);
+ option.classList.add("selected");
+ this.element.querySelector(".current-voice").textContent =
+ option.textContent;
+ }
+
+ let evt = this.element.ownerDocument.createEvent("Event");
+ evt.initEvent("change", true, true);
+ this.element.dispatchEvent(evt);
+ },
+
+ _updateDropdownHeight: function(now) {
+ let updateInner = () => {
+ let winHeight = this._win.innerHeight;
+ let listbox = this.listbox;
+ let listboxTop = listbox.getBoundingClientRect().top;
+ listbox.style.maxHeight = (winHeight - listboxTop - 10) + "px";
+ };
+
+ if (now) {
+ updateInner();
+ } else if (!this._pendingDropdownUpdate) {
+ this._pendingDropdownUpdate = true;
+ this._win.requestAnimationFrame(() => {
+ updateInner();
+ delete this._pendingDropdownUpdate;
+ });
+ }
+ },
+
+ get element() {
+ return this._elementRef.get();
+ },
+
+ get listbox() {
+ return this._elementRef.get().querySelector(".options");
+ },
+
+ get selectToggle() {
+ return this._elementRef.get().querySelector(".select-toggle");
+ },
+
+ get _win() {
+ return this._winRef.get();
+ },
+
+ get _doc() {
+ return this._win.document;
+ },
+
+ set selected(option) {
+ this._select(option);
+ },
+
+ get selected() {
+ return this.element.querySelector(".options > .option.selected");
+ },
+
+ get options() {
+ return this.element.querySelectorAll(".options > .option");
+ },
+
+ set selectedIndex(index) {
+ this._select(this.options[index]);
+ },
+
+ get selectedIndex() {
+ return Array.from(this.options).indexOf(this.selected);
+ },
+
+ set value(value) {
+ let option = Array.from(this.options).find(o => o.dataset.value === value);
+ this._select(option);
+ },
+
+ get value() {
+ let selected = this.selected;
+ return selected ? selected.dataset.value : "";
+ }
+};
diff --git a/toolkit/components/narrate/moz.build b/toolkit/components/narrate/moz.build
new file mode 100644
index 00000000000..9d286d4f6fe
--- /dev/null
+++ b/toolkit/components/narrate/moz.build
@@ -0,0 +1,11 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES.narrate = [
+ 'NarrateControls.jsm',
+ 'Narrator.jsm',
+ 'VoiceSelect.jsm'
+]
diff --git a/toolkit/components/reader/AboutReader.jsm b/toolkit/components/reader/AboutReader.jsm
index 5e8b03133ed..01d400029d5 100644
--- a/toolkit/components/reader/AboutReader.jsm
+++ b/toolkit/components/reader/AboutReader.jsm
@@ -15,6 +15,7 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Rect", "resource://gre/modules/Geometry.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry", "resource://gre/modules/UITelemetry.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NarrateControls", "resource://gre/modules/narrate/NarrateControls.jsm");
var gStrings = Services.strings.createBundle("chrome://global/locale/aboutReader.properties");
@@ -102,6 +103,10 @@ var AboutReader = function(mm, win, articlePromise) {
this._setupFontSizeButtons();
+ if (win.speechSynthesis && Services.prefs.getBoolPref("narrate.enabled")) {
+ new NarrateControls(mm, win);
+ }
+
this._loadArticle();
}
diff --git a/toolkit/locales/en-US/chrome/global/narrate.properties b/toolkit/locales/en-US/chrome/global/narrate.properties
new file mode 100644
index 00000000000..9ac839427fc
--- /dev/null
+++ b/toolkit/locales/en-US/chrome/global/narrate.properties
@@ -0,0 +1,19 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# Narrate, meaning "read the page out loud". This is the name of the feature
+# and it is the label for the popup button.
+narrate = Narrate
+back = Back
+start = Start
+stop = Stop
+forward = Forward
+speed = Speed
+selectvoicelabel = Voice:
+# Default voice is determined by the language of the document.
+defaultvoice = Default
+
+# Voice name and language.
+# eg. David (English)
+voiceLabel = %S (%S)
\ No newline at end of file
diff --git a/toolkit/locales/jar.mn b/toolkit/locales/jar.mn
index 3cf51d80ae6..e4fdcc3014a 100644
--- a/toolkit/locales/jar.mn
+++ b/toolkit/locales/jar.mn
@@ -62,6 +62,7 @@
locale/@AB_CD@/global/keys.properties (%chrome/global/keys.properties)
locale/@AB_CD@/global/languageNames.properties (%chrome/global/languageNames.properties)
locale/@AB_CD@/global/mozilla.dtd (%chrome/global/mozilla.dtd)
+ locale/@AB_CD@/global/narrate.properties (%chrome/global/narrate.properties)
locale/@AB_CD@/global/notification.dtd (%chrome/global/notification.dtd)
locale/@AB_CD@/global/preferences.dtd (%chrome/global/preferences.dtd)
locale/@AB_CD@/global/printdialog.dtd (%chrome/global/printdialog.dtd)
diff --git a/toolkit/themes/shared/jar.inc.mn b/toolkit/themes/shared/jar.inc.mn
index 688c23705e6..8ee65fc7b7d 100644
--- a/toolkit/themes/shared/jar.inc.mn
+++ b/toolkit/themes/shared/jar.inc.mn
@@ -26,6 +26,16 @@ toolkit.jar:
skin/classic/global/icons/loading-inverted@2x.png (../../shared/icons/loading-inverted@2x.png)
skin/classic/global/icons/warning.svg (../../shared/incontent-icons/warning.svg)
skin/classic/global/alerts/alert-common.css (../../shared/alert-common.css)
+ skin/classic/global/narrate.css (../../shared/narrate.css)
+ skin/classic/global/narrateControls.css (../../shared/narrateControls.css)
+ skin/classic/global/narrate/arrow.svg (../../shared/narrate/arrow.svg)
+ skin/classic/global/narrate/back.svg (../../shared/narrate/back.svg)
+ skin/classic/global/narrate/fast.svg (../../shared/narrate/fast.svg)
+ skin/classic/global/narrate/forward.svg (../../shared/narrate/forward.svg)
+ skin/classic/global/narrate/narrate.svg (../../shared/narrate/narrate.svg)
+ skin/classic/global/narrate/slow.svg (../../shared/narrate/slow.svg)
+ skin/classic/global/narrate/start.svg (../../shared/narrate/start.svg)
+ skin/classic/global/narrate/stop.svg (../../shared/narrate/stop.svg)
skin/classic/global/menu/shared-menu-check@2x.png (../../shared/menu-check@2x.png)
skin/classic/global/menu/shared-menu-check.png (../../shared/menu-check.png)
skin/classic/global/menu/shared-menu-check-active.svg (../../shared/menu-check-active.svg)
diff --git a/toolkit/themes/shared/narrate.css b/toolkit/themes/shared/narrate.css
new file mode 100644
index 00000000000..3aa8a8a6e31
--- /dev/null
+++ b/toolkit/themes/shared/narrate.css
@@ -0,0 +1,11 @@
+body.light .narrating {
+ background-color: #ffc;
+}
+
+body.sepia .narrating {
+ background-color: #e0d7c5;
+}
+
+body.dark .narrating {
+ background-color: #242424;
+}
diff --git a/toolkit/themes/shared/narrate/arrow.svg b/toolkit/themes/shared/narrate/arrow.svg
new file mode 100644
index 00000000000..2fb21417d66
--- /dev/null
+++ b/toolkit/themes/shared/narrate/arrow.svg
@@ -0,0 +1,3 @@
+
diff --git a/toolkit/themes/shared/narrate/back.svg b/toolkit/themes/shared/narrate/back.svg
new file mode 100644
index 00000000000..d29586e382a
--- /dev/null
+++ b/toolkit/themes/shared/narrate/back.svg
@@ -0,0 +1,15 @@
+
diff --git a/toolkit/themes/shared/narrate/fast.svg b/toolkit/themes/shared/narrate/fast.svg
new file mode 100644
index 00000000000..cd25a6a0353
--- /dev/null
+++ b/toolkit/themes/shared/narrate/fast.svg
@@ -0,0 +1,3 @@
+
diff --git a/toolkit/themes/shared/narrate/forward.svg b/toolkit/themes/shared/narrate/forward.svg
new file mode 100644
index 00000000000..53e64e9511b
--- /dev/null
+++ b/toolkit/themes/shared/narrate/forward.svg
@@ -0,0 +1,15 @@
+
diff --git a/toolkit/themes/shared/narrate/narrate.svg b/toolkit/themes/shared/narrate/narrate.svg
new file mode 100644
index 00000000000..597b0a65c0f
--- /dev/null
+++ b/toolkit/themes/shared/narrate/narrate.svg
@@ -0,0 +1,3 @@
+
diff --git a/toolkit/themes/shared/narrate/slow.svg b/toolkit/themes/shared/narrate/slow.svg
new file mode 100644
index 00000000000..1892b66e1fc
--- /dev/null
+++ b/toolkit/themes/shared/narrate/slow.svg
@@ -0,0 +1,6 @@
+
diff --git a/toolkit/themes/shared/narrate/start.svg b/toolkit/themes/shared/narrate/start.svg
new file mode 100644
index 00000000000..95fa7131339
--- /dev/null
+++ b/toolkit/themes/shared/narrate/start.svg
@@ -0,0 +1,3 @@
+
diff --git a/toolkit/themes/shared/narrate/stop.svg b/toolkit/themes/shared/narrate/stop.svg
new file mode 100644
index 00000000000..c017c578f9e
--- /dev/null
+++ b/toolkit/themes/shared/narrate/stop.svg
@@ -0,0 +1,3 @@
+
diff --git a/toolkit/themes/shared/narrateControls.css b/toolkit/themes/shared/narrateControls.css
new file mode 100644
index 00000000000..dc24a4c9628
--- /dev/null
+++ b/toolkit/themes/shared/narrateControls.css
@@ -0,0 +1,184 @@
+:scope {
+ --border-color: #e5e5e5;
+}
+
+#narrate-toggle {
+ background-image: url("chrome://global/skin/narrate/narrate.svg");
+}
+
+.dropdown-popup button {
+ background-color: transparent;
+}
+
+.dropdown-popup button:hover:not(:disabled) {
+ background-color: #eaeaea;
+}
+
+.narrate-row {
+ display: flex;
+ align-items: center;
+ min-height: 40px;
+ box-sizing: border-box;
+}
+
+.narrate-row:not(:first-child) {
+ border-top: 1px solid var(--border-color);
+}
+
+/* Control buttons */
+
+#narrate-control > button {
+ background-size: 24px 24px;
+ background-repeat: no-repeat;
+ background-position: center center;
+ height: 64px;
+ width: 100px;
+ border: none;
+ color: #666;
+ box-sizing: border-box;
+}
+
+#narrate-control > button:not(:first-child) {
+ border-left: 1px solid var(--border-color);
+}
+
+#narrate-skip-previous {
+ border-top-left-radius: 3px;
+ background-image: url("chrome://global/skin/narrate/back.svg#enabled");
+}
+
+#narrate-skip-next {
+ border-top-right-radius: 3px;
+ background-image: url("chrome://global/skin/narrate/forward.svg#enabled");
+}
+
+#narrate-skip-previous:disabled {
+ background-image: url("chrome://global/skin/narrate/back.svg#disabled");
+}
+
+#narrate-skip-next:disabled {
+ background-image: url("chrome://global/skin/narrate/forward.svg#disabled");
+}
+
+#narrate-start-stop {
+ background-image: url("chrome://global/skin/narrate/start.svg");
+}
+
+#narrate-start-stop.speaking {
+ background-image: url("chrome://global/skin/narrate/stop.svg");
+}
+
+/* Rate control */
+
+#narrate-rate::before, #narrate-rate::after {
+ content: '';
+ width: 48px;
+ height: 40px;
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: 24px auto;
+}
+
+#narrate-rate::before {
+ background-image: url("chrome://global/skin/narrate/slow.svg");
+}
+
+#narrate-rate::after {
+ background-image: url("chrome://global/skin/narrate/fast.svg");
+}
+
+#narrate-rate-input {
+ margin: 0 1px;
+ flex-grow: 1;
+}
+
+#narrate-rate-input::-moz-range-track {
+ background-color: #979797;
+ height: 2px;
+}
+
+#narrate-rate-input::-moz-range-progress {
+ background-color: #2EA3FF;
+ height: 2px;
+}
+
+#narrate-rate-input::-moz-range-thumb {
+ background-color: #808080;
+ height: 16px;
+ width: 16px;
+ border-radius: 8px;
+ border-width: 0;
+}
+
+#narrate-rate-input:active::-moz-range-thumb {
+ background-color: #2EA3FF;
+}
+
+/* Voice selection */
+
+.voiceselect {
+ width: 100%;
+}
+
+.voiceselect > button.select-toggle,
+.voiceselect > .options > button.option {
+ -moz-appearance: none;
+ border: none;
+ width: 100%;
+ min-height: 40px;
+}
+
+.voiceselect.open > button.select-toggle {
+ border-bottom: 1px solid var(--border-color);
+}
+
+.voiceselect > button.select-toggle::after {
+ content: '';
+ background-image: url("chrome://global/skin/narrate/arrow.svg");
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: 12px 12px;
+ display: inline-block;
+ width: 1.5em;
+ height: 1em;
+ vertical-align: middle;
+}
+
+.voiceselect > .options > button.option:not(:first-child) {
+ border-top: 1px solid var(--border-color);
+}
+
+.voiceselect > .options > button.option {
+ box-sizing: border-box;
+}
+
+.voiceselect > .options:not(.hovering) > button.option:focus {
+ background-color: #eaeaea;
+}
+
+.voiceselect > .options:not(.hovering) > button.option:hover:not(:focus) {
+ background-color: transparent;
+}
+
+.voiceselect > .options > button.option::-moz-focus-inner {
+ outline: none;
+ border: 0;
+}
+
+.voiceselect > .options {
+ display: none;
+ overflow-y: auto;
+}
+
+.voiceselect.open > .options {
+ display: block;
+}
+
+.current-voice {
+ color: #7f7f7f;
+}
+
+.voiceselect:not(.open) > button,
+.option:last-child {
+ border-radius: 0 0 3px 3px;
+}