Bug 1166365 - Introduce Narrate feature in reader mode. r=Gijs
authorEitan Isaacson <eitan@monotonous.org>
Mon, 01 Feb 2016 11:09:14 -0800
changeset 323323 df591274c1d7b003034f8275f5bbf850fde8d9ce
parent 323322 e3447f8edd06bc0f13e2e201357151e5b92a02a5
child 323324 c94c22d14ce7181dcc4af8033e9b5f8c14750e7d
push id5913
push userjlund@mozilla.com
push dateMon, 25 Apr 2016 16:57:49 +0000
treeherdermozilla-beta@dcaf0a6fa115 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersGijs
bugs1166365
milestone47.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1166365 - Introduce Narrate feature in reader mode. r=Gijs MozReview-Commit-ID: 6tJIu7C4eAv
modules/libpref/init/all.js
toolkit/components/moz.build
toolkit/components/narrate/.eslintrc
toolkit/components/narrate/NarrateControls.jsm
toolkit/components/narrate/Narrator.jsm
toolkit/components/narrate/VoiceSelect.jsm
toolkit/components/narrate/moz.build
toolkit/components/reader/AboutReader.jsm
toolkit/locales/en-US/chrome/global/narrate.properties
toolkit/locales/jar.mn
toolkit/themes/shared/jar.inc.mn
toolkit/themes/shared/narrate.css
toolkit/themes/shared/narrate/arrow.svg
toolkit/themes/shared/narrate/back.svg
toolkit/themes/shared/narrate/fast.svg
toolkit/themes/shared/narrate/forward.svg
toolkit/themes/shared/narrate/narrate.svg
toolkit/themes/shared/narrate/slow.svg
toolkit/themes/shared/narrate/start.svg
toolkit/themes/shared/narrate/stop.svg
toolkit/themes/shared/narrateControls.css
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -5157,16 +5157,26 @@ pref("reader.font_type", "sans-serif");
 
 // Whether or not the user has interacted with the reader mode toolbar.
 // This is used to show a first-launch tip in reader mode.
 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
 // loaded without sandboxing even if this pref is changed.
 pref("media.gmp.insecure.allow", false);
 #endif
 
 pref("dom.audiochannel.mutedByDefault", false);
--- a/toolkit/components/moz.build
+++ b/toolkit/components/moz.build
@@ -29,16 +29,17 @@ DIRS += [
     'filepicker',
     'filewatcher',
     'finalizationwitness',
     'formautofill',
     'find',
     'gfx',
     'jsdownloads',
     'lz4',
+    'narrate',
     'mediasniffer',
     'microformats',
     'osfile',
     'parentalcontrols',
     'passwordmgr',
     'perf',
     'perfmonitoring',
     'places',
new file mode 100644
--- /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
+  }
+}
new file mode 100644
--- /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`<style scoped>
+      @import url("chrome://global/skin/narrateControls.css");
+    </style>
+    <li>
+       <button class="dropdown-toggle button"
+               id="narrate-toggle" title="${"narrate"}"></button>
+    </li>
+    <li class="dropdown-popup">
+      <div id="narrate-control" class="narrate-row">
+        <button disabled id="narrate-skip-previous"
+                title="${"back"}"></button>
+        <button id="narrate-start-stop" title="${"start"}"></button>
+        <button disabled id="narrate-skip-next"
+                title="${"forward"}"></button>
+      </div>
+      <div id="narrate-rate" class="narrate-row">
+        <input id="narrate-rate-input" value="0" title="${"speed"}"
+               step="25" max="400" min="-400" type="range">
+      </div>
+      <div id="narrate-voices" class="narrate-row"></div>
+      <div class="dropdown-arrow"></div>
+    </li>`;
+
+  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;
+  }
+};
new file mode 100644
--- /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();
+  }
+};
new file mode 100644
--- /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 =
+   `<button class="select-toggle" aria-controls="voice-options">
+      <span class="label">${label}</span> <span class="current-voice"></span>
+    </button>
+    <div class="options" id="voice-options" role="listbox"></div>`;
+
+  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 : "";
+  }
+};
new file mode 100644
--- /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'
+]
--- a/toolkit/components/reader/AboutReader.jsm
+++ b/toolkit/components/reader/AboutReader.jsm
@@ -10,16 +10,17 @@ this.EXPORTED_SYMBOLS = [ "AboutReader" 
 
 Cu.import("resource://gre/modules/ReaderMode.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 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");
 
 var AboutReader = function(mm, win, articlePromise) {
   let url = this._getOriginalUrl(win);
   if (!(url.startsWith("http://") || url.startsWith("https://"))) {
     let errorMsg = "Only http:// and https:// URLs can be loaded in about:reader.";
     if (Services.prefs.getBoolPref("reader.errors.includeURLs"))
@@ -97,16 +98,20 @@ var AboutReader = function(mm, win, arti
   ];
 
   let fontType = Services.prefs.getCharPref("reader.font_type");
   this._setupSegmentedButton("font-type-buttons", fontTypeOptions, fontType, this._setFontType.bind(this));
   this._setFontType(fontType);
 
   this._setupFontSizeButtons();
 
+  if (win.speechSynthesis && Services.prefs.getBoolPref("narrate.enabled")) {
+    new NarrateControls(mm, win);
+  }
+
   this._loadArticle();
 }
 
 AboutReader.prototype = {
   _BLOCK_IMAGES_SELECTOR: ".content p > img:only-child, " +
                           ".content p > a:only-child > img:only-child, " +
                           ".content .wp-caption img, " +
                           ".content figure img",
new file mode 100644
--- /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
--- a/toolkit/locales/jar.mn
+++ b/toolkit/locales/jar.mn
@@ -57,16 +57,17 @@
 #endif
   locale/@AB_CD@/global/globalKeys.dtd                  (%chrome/global/globalKeys.dtd)
   locale/@AB_CD@/global/headsUpDisplay.properties       (%chrome/global/headsUpDisplay.properties)
   locale/@AB_CD@/global/intl.css                        (%chrome/global/intl.css)
   locale/@AB_CD@/global/intl.properties                 (%chrome/global/intl.properties)
   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)
   locale/@AB_CD@/global/printjoboptions.dtd             (%chrome/global/printjoboptions.dtd)
   locale/@AB_CD@/global/printPageSetup.dtd              (%chrome/global/printPageSetup.dtd)
   locale/@AB_CD@/global/printPreview.dtd                (%chrome/global/printPreview.dtd)
   locale/@AB_CD@/global/printPreviewProgress.dtd        (%chrome/global/printPreviewProgress.dtd)
   locale/@AB_CD@/global/printdialog.properties          (%chrome/global/printdialog.properties)
--- a/toolkit/themes/shared/jar.inc.mn
+++ b/toolkit/themes/shared/jar.inc.mn
@@ -21,16 +21,26 @@ toolkit.jar:
   skin/classic/global/aboutSupport.css                     (../../shared/aboutSupport.css)
   skin/classic/global/appPicker.css                        (../../shared/appPicker.css)
   skin/classic/global/config.css                           (../../shared/config.css)
   skin/classic/global/icons/info.svg                       (../../shared/incontent-icons/info.svg)
   skin/classic/global/icons/loading-inverted.png           (../../shared/icons/loading-inverted.png)
   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)
   skin/classic/global/menu/shared-menu-check-black.svg     (../../shared/menu-check-black.svg)
   skin/classic/global/menu/shared-menu-check-hover.svg     (../../shared/menu-check-hover.svg)
   skin/classic/global/in-content/check.svg                 (../../shared/in-content/check.svg)
   skin/classic/global/in-content/check-partial.svg         (../../shared/in-content/check-partial.svg)
   skin/classic/global/in-content/dropdown.svg              (../../shared/in-content/dropdown.svg)
new file mode 100644
--- /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;
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/themes/shared/narrate/arrow.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 12 12">
+  <path d="M6 9L1 4l1-1 4 4 4-4 1 1z" fill="#4C4C4C"/>
+</svg>
new file mode 100644
--- /dev/null
+++ b/toolkit/themes/shared/narrate/back.svg
@@ -0,0 +1,15 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24">
+  <defs>
+    <style>
+      use:not(:target) {
+        display: none;
+      }
+      #disabled {
+        opacity: 0.5;
+      }
+    </style>
+    <path id="shape" d="M 5 0 C 4.446 0 4 0.446 4 1 L 4 23 C 4 23.554 4.446 24 5 24 L 7 24 C 7.554 24 8 23.554 8 23 L 8 12.404297 C 8.04108 12.509297 8.109944 12.610125 8.203125 12.703125 L 19.296875 23.775391 C 19.495259 23.972391 19.661613 24.039562 19.796875 23.976562 C 19.932137 23.915564 20 23.748516 20 23.478516 L 20 0.52148438 C 20 0.25248437 19.93214 0.084484365 19.796875 0.021484375 C 19.661613 -0.040515625 19.495259 0.02856248 19.296875 0.2265625 L 8.203125 11.298828 C 8.1099445 11.381828 8.04108 11.481703 8 11.595703 L 8 1 C 8 0.446 7.554 0 7 0 L 5 0 z " fill="gray"/>
+  </defs>
+  <use id="enabled" xlink:href="#shape"/>
+  <use id="disabled" xlink:href="#shape"/>
+</svg>
new file mode 100644
--- /dev/null
+++ b/toolkit/themes/shared/narrate/fast.svg
@@ -0,0 +1,3 @@
+<svg id="Icons" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 20.4">
+    <path fill="gray" d="M14.42 16.68a.77.77 0 0 0 .54.7l2.51.68a1.58 1.58 0 0 1 1.06 1.22l.05.39-3.89-.53a4.34 4.34 0 0 1-1.74-.72L7.2 14.03a5.79 5.79 0 0 1-5.34-4.88h-.82a1 1 0 0 1-1-1l2.9-3.24a6.16 6.16 0 0 1 4.7-2.39 5.88 5.88 0 0 1 .77.05 5 5 0 0 1 .87.15c3.75 1 6.5 5.84 6.5 5.84a2.27 2.27 0 0 0 1.14.85h.17a1.27 1.27 0 0 0 1.22-.4l.78-1-2.47-1.2c-3.38-1.46-2.46-5.71-2.46-5.71 0-.26.23-.32.42-.14l5.32 5-4.31-4.81a1.39 1.39 0 0 1 .81-1.22l4.17 6.65.33.31 2.19 1.54a2.44 2.44 0 0 1 .92 1.75v2.77l-.16.13a1.66 1.66 0 0 1-1.63.19l-.75-.36a2.57 2.57 0 0 0-2.55.32l-2.18 1.82a4.28 4.28 0 0 1-.89.55 10.18 10.18 0 0 0-4.62-8.46c-.27-.16-.66.31-.47.48a10.52 10.52 0 0 1 3.68 8.5v.48zm8.38-5.42a.49.49 0 1 0-.49-.49.49.49 0 0 0 .49.49zm-18 9.14v-.52a1.39 1.39 0 0 1 .93-1.25s2.7-.66 3.43-1.84l2.06 1.63a25.62 25.62 0 0 1-6.43 2z"/>
+</svg>
new file mode 100644
--- /dev/null
+++ b/toolkit/themes/shared/narrate/forward.svg
@@ -0,0 +1,15 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24">
+  <defs>
+    <style>
+      use:not(:target) {
+        display: none;
+      }
+      #disabled {
+        opacity: 0.5;
+      }
+    </style>
+    <path id="shape" d="m 19,0 c 0.554,0 1,0.446 1,1 l 0,22 c 0,0.554 -0.446,1 -1,1 l -2,0 c -0.554,0 -1,-0.446 -1,-1 l 0,-10.595703 c -0.04108,0.105 -0.109944,0.205828 -0.203125,0.298828 L 4.703125,23.775391 c -0.198384,0.197 -0.364738,0.264171 -0.5,0.201171 C 4.067863,23.915564 4,23.748516 4,23.478516 L 4,0.52148438 c 0,-0.26900001 0.06786,-0.43700001 0.203125,-0.5 0.135262,-0.062 0.301616,0.0070781 0.5,0.20507812 l 11.09375,11.0722655 c 0.09318,0.083 0.162045,0.182875 0.203125,0.296875 L 16,1 c 0,-0.554 0.446,-1 1,-1 l 2,0 z" fill="gray"/>
+  </defs>
+  <use id="enabled" xlink:href="#shape"/>
+  <use id="disabled" xlink:href="#shape"/>
+</svg>
new file mode 100644
--- /dev/null
+++ b/toolkit/themes/shared/narrate/narrate.svg
@@ -0,0 +1,3 @@
+<svg id="Icons" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 18.77">
+    <path fill="gray" d="M3.13 13.72a1.57 1.57 0 0 1-3.13 0V5.41a1.57 1.57 0 0 1 3.13 0v8.31zm6.29 3.62a1.57 1.57 0 0 1-3.13 0V1.44a1.57 1.57 0 0 1 3.13 0v15.9zm6.29-2.9a1.57 1.57 0 0 1-3.13 0V4.83a1.57 1.57 0 0 1 3.13 0v9.61zM22 12.62a1.57 1.57 0 0 1-3.13 0V6.15a1.57 1.57 0 0 1 3.13 0v6.47z"/>
+</svg>
new file mode 100644
--- /dev/null
+++ b/toolkit/themes/shared/narrate/slow.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+    <g fill="gray">
+        <path d="M1.684,13.486c-0.209,0-0.404-0.132-0.474-0.341c-0.528-1.58-0.23-5.767,4.097-7.921 c1.315-0.656,2.589-0.988,3.787-0.988c3.237,0,5.096,2.341,5.99,3.465c0.158,0.199,0.181,0.533,0,0.713 c-0.793,0.794-1.852,1.542-3.231,2.286c-2.46,1.327-5.045,1.775-7.121,2.134c-1.123,0.194-2.093,0.361-2.89,0.627 C1.789,13.479,1.735,13.486,1.684,13.486L1.684,13.486z"/>
+        <path d="M23.185,5.465c-0.86-1.121-2.074-1.819-3.168-1.819c-0.641,0-1.556,0.23-2.273,1.328 c-0.374,0.571-0.577,1.161-0.773,1.73c-0.512,1.482-1.041,3.016-4.662,4.969c-2.316,1.249-4.707,1.664-6.815,2.03 c-2.524,0.438-4.704,0.814-5.455,2.622c-0.069,0.165-0.045,0.354,0.062,0.495c0.107,0.143,0.281,0.217,0.46,0.193 c0.667-0.081,1.533,0.041,2.434,0.217c-0.122,0.146-0.261,0.286-0.391,0.418c-0.38,0.385-0.774,0.783-0.657,1.292 c0.108,0.474,0.604,0.699,0.966,0.828c0.399,0.142,0.843,0.217,1.283,0.217c1.241,0,2.216-0.579,2.649-1.539 c1.704,0.287,3.487,0.313,5.043,0.313l1.639-0.006c0.066,0.056,0.178,0.166,0.264,0.25c0.504,0.506,1.348,1.351,2.721,1.351 c0.129,0,0.264-0.008,0.416-0.026c0.687-0.102,1.351-0.267,1.574-0.787c0.227-0.528-0.123-1.023-0.526-1.597 c-0.481-0.685-1.08-1.532-0.998-2.652c0.196-0.397,0.368-0.824,0.546-1.267c0.479-1.19,0.975-2.421,2.12-3.513 c0.431,0.343,1.022,0.549,1.63,0.549l0,0c0.439,0,0.876-0.102,1.295-0.3c0.624-0.293,1.104-0.967,1.316-1.847 C24.175,7.707,23.914,6.418,23.185,5.465L23.185,5.465z M20.397,7.757c-0.276,0-0.5-0.224-0.5-0.5s0.224-0.5,0.5-0.5 c0.275,0,0.5,0.224,0.5,0.5S20.674,7.757,20.397,7.757z"/>
+    </g>
+</svg>
new file mode 100644
--- /dev/null
+++ b/toolkit/themes/shared/narrate/start.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+    <path d="M21.64 12.44L2.827 22.895c-.217.123-.403.137-.56.042-.155-.094-.233-.264-.233-.51V1.572c0-.244.08-.414.233-.51.157-.093.343-.08.56.044L21.642 11.56c.217.124.326.27.326.44 0 .17-.11.316-.327.44z" fill="gray"/>
+</svg>
new file mode 100644
--- /dev/null
+++ b/toolkit/themes/shared/narrate/stop.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+    <rect ry="1" rx="1" y="2" x="2" height="20" width="20" fill="gray"/>
+</svg>
new file mode 100644
--- /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;
+}