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 291121 f2c661c123c089b02b6b542bbf95bb624bcafc22
parent 291120 f7a5b2b77a7787af1d12eb027e84038da6228aa0
child 291122 3ea44d60cb3fe23293fe3326c43636336e4ac8dc
push id19656
push usergwagner@mozilla.com
push dateMon, 04 Apr 2016 13:43:23 +0000
treeherderb2g-inbound@e99061fde28a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMargaret
bugs1254378
milestone48.0a1
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;