Bug 1166365 - Introduce Narrate extension. r?Gijs draft
authorEitan Isaacson <eitan@monotonous.org>
Mon, 01 Feb 2016 11:09:14 -0800
changeset 331585 33ea61473404d0b6127698161a505432e45578a6
parent 331584 e57893da8ab7cbcf552b506f3423562b0bbbcaf6
child 331586 7621866322296c8de835448c64983d2d48a18c2a
push id11019
push userbmo:eitan@monotonous.org
push dateWed, 17 Feb 2016 17:54:47 +0000
reviewersGijs
bugs1166365
milestone47.0a1
Bug 1166365 - Introduce Narrate extension. r?Gijs MozReview-Commit-ID: 6tJIu7C4eAv
browser/extensions/moz.build
browser/extensions/narrate/.eslintrc
browser/extensions/narrate/bootstrap.js
browser/extensions/narrate/content/Controls.jsm
browser/extensions/narrate/content/Narrator.jsm
browser/extensions/narrate/content/VoiceSelect.jsm
browser/extensions/narrate/content/content-process.js
browser/extensions/narrate/install.rdf.in
browser/extensions/narrate/jar.mn
browser/extensions/narrate/locales/en-US/narrate.properties
browser/extensions/narrate/locales/jar.mn
browser/extensions/narrate/locales/moz.build
browser/extensions/narrate/moz.build
browser/extensions/narrate/skin/images/arrow.svg
browser/extensions/narrate/skin/images/back.svg
browser/extensions/narrate/skin/images/fast.svg
browser/extensions/narrate/skin/images/forward.svg
browser/extensions/narrate/skin/images/narrate.svg
browser/extensions/narrate/skin/images/slow.svg
browser/extensions/narrate/skin/images/start.svg
browser/extensions/narrate/skin/images/stop.svg
browser/extensions/narrate/skin/narrate-controls.css
browser/extensions/narrate/skin/narrate.css
browser/locales/Makefile.in
browser/locales/filter.py
browser/locales/l10n.ini
--- a/browser/extensions/moz.build
+++ b/browser/extensions/moz.build
@@ -1,12 +1,13 @@
 # -*- 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/.
 
 DIRS += [
     'loop',
+    'narrate',
     'pdfjs',
     'pocket',
     'shumway',
 ]
new file mode 100644
--- /dev/null
+++ b/browser/extensions/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/browser/extensions/narrate/bootstrap.js
@@ -0,0 +1,82 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+/* globals APP_SHUTDOWN */
+/* exported startup, shutdown, install, uninstall */
+
+"use strict";
+
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+  "resource://gre/modules/Services.jsm");
+
+const PREF_BRANCH = "extensions.narrate.";
+
+const PREFS = {
+  enabled: true,
+  voice: "automatic",
+  rate: "1",
+  test: false
+};
+
+function setDefaultPrefs() {
+  let branch = Services.prefs.getDefaultBranch(PREF_BRANCH);
+  for (let [key, val] in Iterator(PREFS)) {
+    switch (typeof val) {
+      case "boolean":
+        branch.setBoolPref(key, val);
+        break;
+      case "string":
+        branch.setCharPref(key, val);
+        break;
+    }
+  }
+}
+
+function prefObserver() {
+  let mm = Services.ppmm;
+  mm.broadcastAsyncMessage("Narrate:PrefChanged",
+    Services.prefs.getBoolPref("extensions.narrate.enabled"));
+}
+
+function saveVoice(message) {
+  let branch = Services.prefs.getBranch(PREF_BRANCH);
+  branch.setCharPref("voice", message.data);
+}
+
+function saveRate(message) {
+  let branch = Services.prefs.getBranch(PREF_BRANCH);
+  branch.setCharPref("rate", message.data);
+}
+
+function startup() {
+  setDefaultPrefs();
+  Services.prefs.addObserver("extensions.narrate.enabled", prefObserver, false);
+  let mm = Services.ppmm;
+  mm.loadProcessScript("chrome://narrate/content/content-process.js", true);
+  mm.addMessageListener("Narrate:SaveVoice", saveVoice);
+  mm.addMessageListener("Narrate:SaveRate", saveRate);
+}
+
+function shutdown(data, reason) {
+  // If the app is shutting down, don't bother.
+  if (reason != APP_SHUTDOWN) {
+    let mm = Services.ppmm;
+    mm.removeDelayedProcessScript("chrome://narrate/content/content-process.js");
+    mm.broadcastAsyncMessage("Narrate:Shutdown");
+    mm.removeMessageListener("Narrate:SaveVoice", saveVoice);
+    mm.removeMessageListener("Narrate:SaveRate", saveRate);
+    Services.prefs.removeObserver("extensions.narrate.enabled", prefObserver);
+  }
+}
+
+function install() {
+}
+
+function uninstall() {
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/content/Controls.jsm
@@ -0,0 +1,215 @@
+/* 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("chrome://narrate/content/VoiceSelect.jsm");
+Cu.import("chrome://narrate/content/Narrator.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+this.EXPORTED_SYMBOLS = ["Controls"];
+
+function Controls(win) {
+  this._winRef = Cu.getWeakReference(win);
+  if (win.document.getElementById("narrate-dropdown")) {
+    Cu.reportError("Narrate controls already inserted.");
+  } else {
+    this.create();
+  }
+}
+
+Controls.prototype = {
+  create: function() {
+    let bundle = this.stringBundle;
+    function localize(pieces, ...substitutions) {
+      let result = pieces[0];
+      for (let i = 0; i < substitutions.length; ++i) {
+        result += bundle.GetStringFromName(substitutions[i]) + pieces[i + 1];
+      }
+      return result;
+    }
+
+    let dropdown = this.doc.createElement("ul");
+    dropdown.className = "dropdown";
+    dropdown.id = "narrate-dropdown";
+    dropdown.innerHTML =
+      localize`<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="0.25" max="4" min="-4" type="range">
+         </div>
+         <div id="narrate-voices" class="narrate-row"></div>
+         <div class="dropdown-arrow"></div>
+       </li>`;
+
+    let branch = Services.prefs.getBranch("extensions.narrate.");
+    this.narrator = new Narrator(this.win);
+
+    let selectLabel = bundle.GetStringFromName("selectvoicelabel");
+    let options = this.narrator.getVoiceOptions().map(v => {
+      return {
+        label: bundle.formatStringFromName("voiceLabel", [v.name, v.lang], 2),
+        value: v.voiceURI
+      };
+    });
+    options.unshift(
+      { label: bundle.GetStringFromName("automaticvoice"),
+        value: "automatic" });
+    this.voiceSelect = new VoiceSelect(this.win, selectLabel, options);
+    this.voiceSelect.value = branch.getCharPref("voice");
+    this.voiceSelect.element.addEventListener(
+      "change", this._onVoiceChange.bind(this));
+    this.voiceSelect.element.id = "voice-select";
+    dropdown.querySelector("#narrate-voices").appendChild(
+      this.voiceSelect.element);
+
+    dropdown.addEventListener("click", this._onButtonClick.bind(this), true);
+
+    let rateRange = dropdown.querySelector("#narrate-rate > input");
+    rateRange.value = branch.getCharPref("rate");
+    this.setRateValueText(rateRange);
+    rateRange.addEventListener("input", this._onRateInput.bind(this));
+    rateRange.addEventListener("mousedown", this._onRateMouseDown.bind(this));
+    rateRange.addEventListener("mouseup", this._onRateMouseUp.bind(this));
+
+    let tb = this.doc.getElementById("reader-toolbar");
+    tb.appendChild(dropdown);
+  },
+
+  remove: function() {
+    let doc = this.doc;
+    if (doc) {
+      doc.getElementById("narrate-dropdown").remove();
+    }
+  },
+
+  _onRateMouseDown: function() {
+    this._rateMousedown = true;
+  },
+
+  _onRateMouseUp: function() {
+    this._rateMousedown = false;
+  },
+
+  _onRateInput: function(evt) {
+    if (!this._rateMousedown) {
+      let rate = this.rate;
+      this.setRateValueText(evt.target);
+      Services.cpmm.sendAsyncMessage("Narrate:SaveRate", rate + "");
+      this.narrator.setRate(rate);
+    }
+  },
+
+  _onVoiceChange: function() {
+    let voice = this.voice;
+    Services.cpmm.sendAsyncMessage("Narrate:SaveVoice", 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")) {
+          this.voiceSelect.close();
+          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 =
+      this.stringBundle.GetStringFromName(speaking ? "start" : "stop");
+
+    this.doc.getElementById("narrate-skip-previous").disabled = !speaking;
+    this.doc.getElementById("narrate-skip-next").disabled = !speaking;
+  },
+
+  get stringBundle() {
+    if (!this._bundle) {
+      this._bundle = Services.strings.createBundle(
+        "chrome://narrate/locale/narrate.properties");
+    }
+
+    return this._bundle;
+  },
+
+  get win() {
+    return this._winRef.get();
+  },
+
+  get doc() {
+    return this.win.document;
+  },
+
+  get rate() {
+    return this.doc.querySelector("#narrate-rate > input").value;
+  },
+
+  setRateValueText: function(rateRange) {
+    let rate = rateRange.value;
+    let absRate = Math.abs(rate);
+    let valueText;
+
+    if (rate > 0) {
+      valueText = this.stringBundle.formatStringFromName(
+        "rateFaster", [absRate], 1);
+    } else if (rate < 0) {
+      valueText = this.stringBundle.formatStringFromName(
+        "rateSlower", [absRate], 1);
+    } else {
+      valueText = this.stringBundle.GetStringFromName("rateNormal");
+    }
+
+    rateRange.setAttribute("aria-valuetext", valueText);
+  },
+
+  get voice() {
+    return this.voiceSelect.value;
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/content/Narrator.jsm
@@ -0,0 +1,195 @@
+/* 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");
+
+this.EXPORTED_SYMBOLS = [ "Narrator" ];
+
+function Narrator(win) {
+  this._winRef = Cu.getWeakReference(win);
+  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(paragraph) {
+    if (!paragraph || (paragraph.get && !paragraph.get())) {
+      return false;
+    }
+
+    let bb =
+      (paragraph.get ? paragraph.get() : 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;
+      }
+    });
+  },
+
+  _speakInner: function() {
+    this._win.speechSynthesis.cancel();
+    let paragraph = this._getParagraphAt(this._index);
+    let utterance = new this._win.SpeechSynthesisUtterance(
+      paragraph.textContent);
+    let rate = this._speechOptions.rate;
+    utterance.rate = Math.pow(Math.abs(rate) + 1, rate < 0 ? -1 : 1);
+    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"});
+        }
+      });
+
+      utterance.addEventListener("end", () => {
+        if (!this._win) {
+          // page got unloaded, don't do anything.
+          return;
+        }
+
+        paragraph.classList.remove("narrating");
+        this._startTime = 0;
+        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()).sort((a, b) =>
+      a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()));
+  },
+
+  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 < 2000 ? 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/browser/extensions/narrate/content/VoiceSelect.jsm
@@ -0,0 +1,213 @@
+/* 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);
+  this._docRef = Cu.getWeakReference(win.document);
+  this.create(label);
+
+  for (let option of options) {
+    this.add(option.label, option.value);
+  }
+
+  this.selectedIndex = 0;
+}
+
+VoiceSelect.prototype = {
+  create: function(label) {
+    let element = this.doc.createElement("div");
+    element.classList.add("voiceselect");
+    element.innerHTML =
+      "<button class=\"select-toggle\" aria-controls=\"voice-options\">" +
+        "<span class=\"label\"></span> <span class=\"current-voice\"></span>" +
+      "</button>" +
+      "<div class=\"options\" id=\"voice-options\" role=\"listbox\"></div>";
+
+    let button = element.querySelector(".select-toggle");
+    button.addEventListener("click", this._clickedButton.bind(this));
+    button.addEventListener("keypress", this._keyPressedButton.bind(this));
+
+    element.querySelector(".label").textContent = label;
+
+    let listbox = element.querySelector(".options");
+    listbox.addEventListener("click", this._optionClicked.bind(this));
+    listbox.addEventListener("keypress", this._keyPressedInBox.bind(this));
+
+    this.win.addEventListener("resize", () => {
+      this._updateDropdownHeight();
+    });
+
+    this._elementRef = Cu.getWeakReference(element);
+  },
+
+  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) {
+    if (this.element.classList.toggle("open", force)) {
+      (this.selected || this.options[0]).focus();
+      this._updateDropdownHeight(true);
+      this.listbox.setAttribute("aria-expanded", true);
+    } else {
+      this.element.querySelector(".select-toggle").focus();
+      this.listbox.setAttribute("aria-expanded", false);
+    }
+  },
+
+  close: function() {
+    this.element.classList.remove("open");
+    this.listbox.setAttribute("aria-expanded", false);
+  },
+
+  _optionClicked: function(event) {
+    let target = event.target;
+    if (target.classList.contains("option") &&
+        !target.classList.contains("selected")) {
+      this._select(target);
+    }
+
+    this.toggleList(false);
+  },
+
+  _clickedButton: function() {
+    this.toggleList();
+  },
+
+  _keyPressedButton: function(event) {
+    switch (event.key) {
+      case "ArrowUp":
+      case "ArrowDown":
+        let toSelect = event.key === "ArrowUp" ?
+          this.selected.previousElementSibling :
+          this.selected.nextElementSibling;
+        if (toSelect && toSelect.classList.contains("option")) {
+          this.selected = toSelect;
+        }
+        event.preventDefault();
+        break;
+    }
+  },
+
+  _keyPressedInBox: function(event) {
+    switch (event.key) {
+      case "ArrowUp":
+      case "ArrowDown":
+        let current = this.doc.activeElement;
+        let focusTo = event.key === "ArrowUp" ?
+          current.previousElementSibling : current.nextElementSibling;
+        if (focusTo && focusTo.classList.contains("option")) {
+          focusTo.focus();
+        }
+        event.preventDefault();
+        break;
+      case "Escape":
+        this.toggleList(false);
+        break;
+    }
+  },
+
+  _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 {
+      this._updatedDropdown = false;
+      this.win.requestAnimationFrame(() => {
+        if (!this._updatedDropdown) {
+          updateInner();
+        }
+
+        this._updatedDropdown = true;
+      });
+    }
+  },
+
+  get element() {
+    return this._elementRef.get();
+  },
+
+  get listbox() {
+    return this._elementRef.get().querySelector(".options");
+  },
+
+  get win() {
+    return this._winRef.get();
+  },
+
+  get doc() {
+    return this._docRef.get();
+  },
+
+  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;
+    if (selected) {
+      return selected.dataset.value;
+    }
+
+    return "";
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/content/content-process.js
@@ -0,0 +1,85 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+/* globals removeMessageListener, addMessageListener  */
+
+"use strict";
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+                                  "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Controls",
+                                  "chrome://narrate/content/Controls.jsm");
+
+function NarrateInjector() {
+  if (Services.prefs.getBoolPref("extensions.narrate.enabled")) {
+    this.enable();
+  }
+}
+
+NarrateInjector.prototype = {
+  STYLESHEETS: ["chrome://narrate/skin/narrate.css",
+                "chrome://narrate/skin/narrate-controls.css"],
+
+  get sheets() {
+    if (!this._sheets) {
+      let sheetService = Cc["@mozilla.org/content/style-sheet-service;1"]
+        .getService(Ci.nsIStyleSheetService);
+      let io = Services.io;
+
+      this._sheets = this.STYLESHEETS.map(uri =>
+        sheetService.preloadSheet(io.newURI(uri, null, null),
+          sheetService.AUTHOR_SHEET));
+    }
+
+    return this._sheets;
+  },
+
+  addStylesheets: function(window) {
+    let wu = window.QueryInterface(
+      Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
+    for (let sheet of this.sheets) {
+      wu.addSheet(sheet, wu.AUTHOR_SHEET);
+    }
+  },
+
+  observe: function(subject) {
+    this.addStylesheets(subject);
+    new Controls(subject);
+  },
+
+  enable: function() {
+    Services.obs.addObserver(this, "AboutReader:Ready", true);
+  },
+
+  disable: function() {
+    Services.obs.removeObserver(this, "AboutReader:Ready");
+  },
+
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsISupportsWeakReference])
+};
+
+function onShutdown() {
+  injector.disable();
+  injector = null;
+  removeMessageListener("Narrate:PrefChanged", onPrefChanged);
+  removeMessageListener("Narrate:Shutdown", onShutdown);
+}
+
+function onPrefChanged() {
+  if (injector) {
+    if (Services.prefs.getBoolPref("extensions.narrate.enabled")) {
+      injector.enable();
+    } else {
+      injector.disable();
+    }
+  }
+}
+
+let injector = new NarrateInjector();
+addMessageListener("Narrate:PrefChanged", onPrefChanged);
+addMessageListener("Narrate:Shutdown", onShutdown);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/install.rdf.in
@@ -0,0 +1,31 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+#filter substitution
+
+<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+
+  <Description about="urn:mozilla:install-manifest">
+    <em:id>narrate@mozilla.org</em:id>
+    <em:version>@FIREFOX_VERSION@</em:version>
+    <em:type>2</em:type>
+    <em:bootstrap>true</em:bootstrap>
+
+    <!-- Target Application this extension can install into,
+         with minimum and maximum supported versions. -->
+    <em:targetApplication>
+      <Description>
+        <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id>
+        <em:minVersion>@FIREFOX_VERSION@</em:minVersion>
+        <em:maxVersion>@FIREFOX_VERSION@</em:maxVersion>
+      </Description>
+    </em:targetApplication>
+
+    <!-- Front End MetaData -->
+    <em:name>Narrate</em:name>
+    <em:description>Have articles read aloud in reader mode.</em:description>
+  </Description>
+</RDF>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/jar.mn
@@ -0,0 +1,9 @@
+# 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/.
+
+[features/narrate@mozilla.org] chrome.jar:
+% content narrate %content/
+% skin narrate classic/1.0 %skin/
+  content/  (content/*)
+  skin/  (skin/*)
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/locales/en-US/narrate.properties
@@ -0,0 +1,21 @@
+# 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 = Narrate
+back = Back
+start = Start
+stop = Stop
+forward = Forward
+speed = Speed
+selectvoicelabel = Voice:
+automaticvoice = Automatic
+
+# Voice name and language.
+# eg. David (U.S. English)
+voiceLabel = %S (%S)
+
+# Narrate rate values
+rateNormal = Normal speed
+rateFaster = %S times faster
+rateSlower = %S times slower
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/locales/jar.mn
@@ -0,0 +1,8 @@
+#filter substitution
+# 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/.
+
+[features/narrate@mozilla.org] @AB_CD@.jar:
+%   locale narrate @AB_CD@ %locale/@AB_CD@/
+    locale/@AB_CD@/ (%*)
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/locales/moz.build
@@ -0,0 +1,7 @@
+# -*- 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/.
+
+JAR_MANIFESTS += ['jar.mn']
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/moz.build
@@ -0,0 +1,17 @@
+# -*- 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/.
+
+DIRS += ['locales']
+
+FINAL_TARGET_FILES.features['narrate@mozilla.org'] += [
+  'bootstrap.js'
+]
+
+FINAL_TARGET_PP_FILES.features['narrate@mozilla.org'] += [
+  'install.rdf.in'
+]
+
+JAR_MANIFESTS += ['jar.mn']
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/skin/images/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/browser/extensions/narrate/skin/images/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/browser/extensions/narrate/skin/images/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/browser/extensions/narrate/skin/images/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/browser/extensions/narrate/skin/images/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/browser/extensions/narrate/skin/images/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/browser/extensions/narrate/skin/images/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/browser/extensions/narrate/skin/images/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/browser/extensions/narrate/skin/narrate-controls.css
@@ -0,0 +1,160 @@
+#narrate-toggle {
+  background-image: url("chrome://narrate/skin/images/narrate.svg");
+}
+
+#narrate-dropdown button {
+  background-color: transparent;
+}
+
+#narrate-dropdown 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 #e5e5e5;
+}
+
+/* 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 #e5e5e5;
+}
+
+#narrate-skip-previous {
+  border-top-left-radius: 3px;
+  background-image: url("chrome://narrate/skin/images/back.svg#enabled");
+}
+
+#narrate-skip-next {
+  border-top-right-radius: 3px;
+  background-image: url("chrome://narrate/skin/images/forward.svg#enabled");
+}
+
+#narrate-skip-previous:disabled {
+  background-image: url("chrome://narrate/skin/images/back.svg#disabled");
+}
+
+#narrate-skip-next:disabled {
+  background-image: url("chrome://narrate/skin/images/forward.svg#disabled");
+}
+
+#narrate-start-stop {
+  background-image: url("chrome://narrate/skin/images/start.svg");
+}
+
+#narrate-start-stop.speaking {
+  background-image: url("chrome://narrate/skin/images/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://narrate/skin/images/slow.svg");
+}
+
+#narrate-rate::after {
+  background-image: url("chrome://narrate/skin/images/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;
+  background-color: transparent;
+}
+
+.voiceselect > button.select-toggle::after {
+  content: '';
+  background-image: url("chrome://narrate/skin/images/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  {
+  border-top: 1px solid #e5e5e5;
+  box-sizing: border-box;
+}
+
+.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;
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/skin/narrate.css
@@ -0,0 +1,11 @@
+body.light .narrating {
+  background-color: #ffc;
+}
+
+body.sepia .narrating {
+  background-color: #e0d7c5;
+}
+
+body.dark .narrating {
+  background-color: #242424;
+}
--- a/browser/locales/Makefile.in
+++ b/browser/locales/Makefile.in
@@ -101,16 +101,17 @@ libs-%:
 	@$(MAKE) -C ../../toolkit/locales libs-$* XPI_ROOT_APPID='$(XPI_ROOT_APPID)'
 	@$(MAKE) -C ../../services/sync/locales AB_CD=$* XPI_NAME=locale-$*
 ifdef MOZ_WEBAPP_RUNTIME
 	@$(MAKE) -C ../../webapprt/locales AB_CD=$* XPI_NAME=locale-$*
 endif
 	@$(MAKE) -C ../../extensions/spellcheck/locales AB_CD=$* XPI_NAME=locale-$*
 	@$(MAKE) -C ../extensions/pocket/locales AB_CD=$* XPI_NAME=locale-$*
 	@$(MAKE) -C ../extensions/loop/chrome/locale AB_CD=$* XPI_NAME=locale-$*
+	@$(MAKE) -C ../extensions/narrate/locales AB_CD=$* XPI_NAME=locale-$*
 	@$(MAKE) -C ../../intl/locales AB_CD=$* XPI_NAME=locale-$*
 	@$(MAKE) -C ../../devtools/client/locales AB_CD=$* XPI_NAME=locale-$* XPI_ROOT_APPID='$(XPI_ROOT_APPID)'
 	@$(MAKE) -B searchplugins AB_CD=$* XPI_NAME=locale-$*
 	@$(MAKE) libs AB_CD=$* XPI_NAME=locale-$* PREF_DIR=$(PREF_DIR)
 	@$(MAKE) -C $(DEPTH)/$(MOZ_BRANDING_DIRECTORY)/locales AB_CD=$* XPI_NAME=locale-$*
 
 repackage-win32-installer: WIN32_INSTALLER_OUT=$(ABS_DIST)/$(PKG_INST_PATH)$(PKG_INST_BASENAME).exe
 repackage-win32-installer: $(call ESCAPE_WILDCARD,$(WIN32_INSTALLER_IN)) $(SUBMAKEFILES) libs-$(AB_CD)
--- a/browser/locales/filter.py
+++ b/browser/locales/filter.py
@@ -7,17 +7,18 @@ def test(mod, path, entity = None):
   # ignore anything but Firefox
   if mod not in ("netwerk", "dom", "toolkit", "security/manager",
                  "devtools/client", "devtools/shared",
                  "browser", "webapprt",
                  "extensions/reporter", "extensions/spellcheck",
                  "other-licenses/branding/firefox",
                  "browser/branding/official",
                  "services/sync",
-                 "browser/extensions/pocket"):
+                 "browser/extensions/pocket",
+                 "browser/extensions/narrate"):
     return "ignore"
   if mod not in ("browser", "extensions/spellcheck"):
     # we only have exceptions for browser and extensions/spellcheck
     return "error"
   if not entity:
     # the only files to ignore are spell checkers and search
     if mod == "extensions/spellcheck":
       return "ignore"
--- a/browser/locales/l10n.ini
+++ b/browser/locales/l10n.ini
@@ -8,16 +8,17 @@ all = browser/locales/all-locales
 
 [compare]
 dirs = browser
      extensions/reporter
      other-licenses/branding/firefox
      browser/branding/official
      devtools/client
      browser/extensions/pocket
+     browser/extensions/narrate
 
 [includes]
 # non-central apps might want to use %(topsrcdir)s here, or other vars
 # RFE: that needs to be supported by compare-locales, too, though
 toolkit = toolkit/locales/l10n.ini
 services_sync = services/sync/locales/l10n.ini
 webapprt = webapprt/locales/l10n.ini