Bug 1254378 - Update Narrate to work with "voiceschanged" event. r=Margaret
authorEitan Isaacson <eitan@monotonous.org>
Tue, 29 Mar 2016 10:30:26 -0700
changeset 291062 f2c661c123c089b02b6b542bbf95bb624bcafc22
parent 291061 f7a5b2b77a7787af1d12eb027e84038da6228aa0
child 291063 3ea44d60cb3fe23293fe3326c43636336e4ac8dc
push id74438
push usereisaacson@mozilla.com
push dateFri, 01 Apr 2016 00:05:56 +0000
treeherdermozilla-inbound@f2c661c123c0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMargaret
bugs1254378
milestone48.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 1254378 - Update Narrate to work with "voiceschanged" event. r=Margaret MozReview-Commit-ID: 9gyzWOqHLJd
toolkit/components/narrate/NarrateControls.jsm
toolkit/components/narrate/Narrator.jsm
toolkit/components/narrate/VoiceSelect.jsm
toolkit/components/narrate/test/NarrateTestUtils.jsm
toolkit/components/narrate/test/browser_narrate.js
toolkit/components/narrate/test/browser_voiceselect.js
--- a/toolkit/components/narrate/NarrateControls.jsm
+++ b/toolkit/components/narrate/NarrateControls.jsm
@@ -57,44 +57,34 @@ function NarrateControls(mm, win) {
                step="25" max="100" min="-100" type="range">
       </div>
       <div id="narrate-voices" class="narrate-row"></div>
       <div class="dropdown-arrow"></div>
     </li>`;
 
   this.narrator = new Narrator(win);
 
+  let branch = Services.prefs.getBranch("narrate.");
   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 = new VoiceSelect(win, selectLabel);
+  this.voiceSelect.addOptions(this._getVoiceOptions(),
+    branch.getCharPref("voice"));
   this.voiceSelect.element.addEventListener("change", this);
   this.voiceSelect.element.id = "voice-select";
+  win.speechSynthesis.addEventListener("voiceschanged", this);
   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 = {
@@ -110,19 +100,42 @@ NarrateControls.prototype = {
         this._onRateInput(evt);
         break;
       case "change":
         this._onVoiceChange();
         break;
       case "click":
         this._onButtonClick(evt);
         break;
+      case "voiceschanged":
+        this.voiceSelect.clear();
+        this.voiceSelect.addOptions(this._getVoiceOptions(),
+          Services.prefs.getCharPref("narrate.voice"));
+        break;
     }
   },
 
+  _getVoiceOptions: function() {
+    let win = this._win;
+    let comparer = win.Intl ?
+      (new Intl.Collator()).compare : (a, b) => a.localeCompare(b);
+    let options = win.speechSynthesis.getVoices().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"
+    });
+
+    return options;
+  },
+
   _onRateInput: function(evt) {
     if (!this._rateMousedown) {
       AsyncPrefs.set("narrate.rate", parseInt(evt.target.value, 10));
       this.narrator.setRate(this._convertRate(evt.target.value));
     }
   },
 
   _onVoiceChange: function() {
--- a/toolkit/components/narrate/Narrator.jsm
+++ b/toolkit/components/narrate/Narrator.jsm
@@ -31,27 +31,16 @@ 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 _treeWalker() {
     if (!this._treeWalkerRef) {
       let wu = this._win.QueryInterface(
         Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
       let nf = this._win.NodeFilter;
 
       let filter = {
         _matches: new Set(),
@@ -108,16 +97,25 @@ Narrator.prototype = {
     return rv;
   },
 
   get speaking() {
     return this._win.speechSynthesis.speaking ||
       this._win.speechSynthesis.pending;
   },
 
+  _getVoice: function(voiceURI) {
+    if (!this._voiceMap || !this._voiceMap.has(voiceURI)) {
+      this._voiceMap = new Map(
+        this._win.speechSynthesis.getVoices().map(v => [v.voiceURI, v]));
+    }
+
+    return this._voiceMap.get(voiceURI);
+  },
+
   _isParagraphInView: function(paragraph) {
     if (!paragraph) {
       return false;
     }
 
     let bb = paragraph.getBoundingClientRect();
     return bb.top >= 0 && bb.top < this._win.innerHeight;
   },
@@ -195,31 +193,27 @@ Narrator.prototype = {
           // User pressed stopped.
           resolve();
         } else {
           this._speakInner().then(resolve, reject);
         }
       });
 
       utterance.addEventListener("error", () => {
-        reject("speech synthesis failed")
+        reject("speech synthesis failed");
       });
 
       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)
+      voice: this._getVoice(speechOptions.voice)
     };
 
     this._stopped = false;
     return this._detectLanguage().then(() => {
       let tw = this._treeWalker;
       if (!this._isParagraphInView(tw.currentNode)) {
         tw.currentNode = tw.root;
         while (tw.nextNode() && !this._isParagraphInView(tw.currentNode)) {}
@@ -253,14 +247,14 @@ Narrator.prototype = {
   setRate: function(rate) {
     this._speechOptions.rate = rate;
     /* repeat current paragraph */
     this._treeWalker.previousNode();
     this._win.speechSynthesis.cancel();
   },
 
   setVoice: function(voice) {
-    this._speechOptions.voice = this._voiceMap.get(voice);
+    this._speechOptions.voice = this._getVoice(voice);
     /* repeat current paragraph */
     this._treeWalker.previousNode();
     this._win.speechSynthesis.cancel();
   }
 };
--- a/toolkit/components/narrate/VoiceSelect.jsm
+++ b/toolkit/components/narrate/VoiceSelect.jsm
@@ -3,17 +3,17 @@
  * 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 = []) {
+function VoiceSelect(win, label) {
   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>
@@ -29,35 +29,42 @@ function VoiceSelect(win, label, options
   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);
   },
 
+  addOptions: function(options, value) {
+    for (let option of options) {
+      this.add(option.label, option.value);
+    }
+
+    let option = value ? this._getOptionFromValue(value) : this.options[0];
+    this._select(option, true);
+  },
+
+  clear: function() {
+    this.listbox.innerHTML = "";
+  },
+
   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);
@@ -108,17 +115,17 @@ VoiceSelect.prototype = {
       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')) {
+        if (!evt.target.closest(".options")) {
           this.toggleList(false, false);
         }
         break;
     }
   },
 
   _getPagedOption: function(option, up) {
     let height = elem => elem.getBoundingClientRect().height;
@@ -196,33 +203,35 @@ VoiceSelect.prototype = {
     }
 
     if (toFocus && toFocus.classList.contains("option")) {
       evt.preventDefault();
       toFocus.focus();
     }
   },
 
-  _select: function(option) {
+  _select: function(option, suppressEvent = false) {
     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);
+    if (!suppressEvent) {
+      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";
@@ -234,16 +243,20 @@ VoiceSelect.prototype = {
       this._pendingDropdownUpdate = true;
       this._win.requestAnimationFrame(() => {
         updateInner();
         delete this._pendingDropdownUpdate;
       });
     }
   },
 
+  _getOptionFromValue: function(value) {
+    return Array.from(this.options).find(o => o.dataset.value === value);
+  },
+
   get element() {
     return this._elementRef.get();
   },
 
   get listbox() {
     return this._elementRef.get().querySelector(".options");
   },
 
@@ -266,26 +279,17 @@ VoiceSelect.prototype = {
   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);
+    this._select(this._getOptionFromValue(value));
   },
 
   get value() {
     let selected = this.selected;
     return selected ? selected.dataset.value : "";
   }
 };
--- a/toolkit/components/narrate/test/NarrateTestUtils.jsm
+++ b/toolkit/components/narrate/test/NarrateTestUtils.jsm
@@ -1,15 +1,17 @@
 /* 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";
 
-Components.utils.import("resource://gre/modules/Services.jsm");
+const Cu = Components.utils;
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://testing-common/ContentTaskUtils.jsm");
 
 this.EXPORTED_SYMBOLS = [ "NarrateTestUtils" ];
 
 this.NarrateTestUtils = {
   TOGGLE: "#narrate-toggle",
   POPUP: "#narrate-dropdown .dropdown-popup",
   VOICE_SELECT: "#narrate-voices .select-toggle",
   VOICE_OPTIONS: "#narrate-voices .options",
@@ -96,16 +98,24 @@ this.NarrateTestUtils = {
       if (window.document.body.classList.contains("loaded")) {
         resolve();
       } else {
         Services.obs.addObserver(observeReady, "AboutReader:Ready", false);
       }
     });
   },
 
+  waitForVoiceOptions: function(window) {
+    let options = window.document.querySelector(this.VOICE_OPTIONS);
+    return ContentTaskUtils.waitForCondition(
+      () => {
+        return options.childElementCount > 1;
+      }, "voice select options populated.");
+  },
+
   waitForPrefChange: function(pref) {
     return new Promise(resolve => {
       function observeChange() {
         Services.prefs.removeObserver(pref, observeChange);
         resolve();
       }
 
       Services.prefs.addObserver(pref, observeChange, false);
--- a/toolkit/components/narrate/test/browser_narrate.js
+++ b/toolkit/components/narrate/test/browser_narrate.js
@@ -18,16 +18,18 @@ add_task(function* testNarrate() {
     let popup = $(NarrateTestUtils.POPUP);
     ok(!NarrateTestUtils.isVisible(popup), "popup is initially hidden");
 
     let toggle = $(NarrateTestUtils.TOGGLE);
     toggle.click();
 
     ok(NarrateTestUtils.isVisible(popup), "popup toggled");
 
+    yield NarrateTestUtils.waitForVoiceOptions(content);
+
     let voiceOptions = $(NarrateTestUtils.VOICE_OPTIONS);
     ok(!NarrateTestUtils.isVisible(voiceOptions),
       "voice options are initially hidden");
 
     $(NarrateTestUtils.VOICE_SELECT).click();
     ok(NarrateTestUtils.isVisible(voiceOptions), "voice options pop up");
 
     let prefChanged = NarrateTestUtils.waitForPrefChange("narrate.voice");
--- a/toolkit/components/narrate/test/browser_voiceselect.js
+++ b/toolkit/components/narrate/test/browser_voiceselect.js
@@ -9,16 +9,18 @@
 registerCleanupFunction(teardown);
 
 add_task(function* testVoiceselectDropdownAutoclose() {
   setup();
 
   yield spawnInNewReaderTab(TEST_ARTICLE, function* () {
     let $ = content.document.querySelector.bind(content.document);
 
+    yield NarrateTestUtils.waitForVoiceOptions(content);
+
     $(NarrateTestUtils.TOGGLE).click();
     ok(NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)),
       "popup is toggled");
 
     ok(!NarrateTestUtils.isVisible($(NarrateTestUtils.VOICE_OPTIONS)),
       "voice options are initially hidden");
 
     $(NarrateTestUtils.VOICE_SELECT).click();
@@ -42,16 +44,18 @@ add_task(function* testVoiceselectDropdo
 });
 
 add_task(function* testVoiceselectLabelChange() {
   setup();
 
   yield spawnInNewReaderTab(TEST_ARTICLE, function* () {
     let $ = content.document.querySelector.bind(content.document);
 
+    yield NarrateTestUtils.waitForVoiceOptions(content);
+
     $(NarrateTestUtils.TOGGLE).click();
     ok(NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)),
       "popup is toggled");
 
     ok(NarrateTestUtils.selectVoice(content, "urn:moz-tts:fake-direct:lenny"),
       "voice selected");
 
     let selectedOption = $(NarrateTestUtils.VOICE_SELECTED);
@@ -63,16 +67,18 @@ add_task(function* testVoiceselectLabelC
 });
 
 add_task(function* testVoiceselectKeyboard() {
   setup();
 
   yield spawnInNewReaderTab(TEST_ARTICLE, function* () {
     let $ = content.document.querySelector.bind(content.document);
 
+    yield NarrateTestUtils.waitForVoiceOptions(content);
+
     $(NarrateTestUtils.TOGGLE).click();
     ok(NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)),
       "popup is toggled");
 
     let eventUtils = NarrateTestUtils.getEventUtils(content);
 
     let firstValue = $(NarrateTestUtils.VOICE_SELECTED).dataset.value;