toolkit/components/narrate/Narrator.jsm
author Eitan Isaacson <eitan@monotonous.org>
Tue, 08 Mar 2016 11:32:40 -0800
changeset 288325 fa1daaba023bb75fcb6fe9509d71a5d658e6d92c
parent 287352 26d0246340d34f69d44e35e65851bbf473422963
child 288326 5b6bcc1fec089df9ab8a72dca6abc9d74b81e12c
permissions -rw-r--r--
Bug 1254526 - Don't let Narrate get into bad state after encountering a synth error. r=Gijs MozReview-Commit-ID: GdOgtmM4hGH

/* 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 _treeWalker() {
    if (!this._treeWalkerRef) {
      let wu = this._win.QueryInterface(
        Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
      let nf = this._win.NodeFilter;

      let filter = {
        _matches: new Set(),

        // We want high-level elements that have non-empty text nodes.
        // For example, paragraphs. But nested anchors and other elements
        // are not interesting since their text already appears in their
        // parent's textContent.
        acceptNode: function(node) {
          if (this._matches.has(node.parentNode)) {
            // Reject sub-trees of accepted nodes.
            return nf.FILTER_REJECT;
          }

          let bb = wu.getBoundsWithoutFlushing(node);
          if (!bb.width || !bb.height) {
            // Skip non-rendered nodes.
            return nf.FILTER_SKIP;
          }

          for (let c = node.firstChild; c; c = c.nextSibling) {
            if (c.nodeType == c.TEXT_NODE && !!c.textContent.match(/\S/)) {
              // If node has a non-empty text child accept it.
              this._matches.add(node);
              return nf.FILTER_ACCEPT;
            }
          }

          return nf.FILTER_SKIP;
        }
      };

      this._treeWalkerRef = new WeakMap();

      // We can't hold a weak reference on the treewalker, because there
      // are no other strong references, and it will be GC'ed. Instead,
      // we rely on the window's lifetime and use it as a weak reference.
      this._treeWalkerRef.set(this._win,
        this._doc.createTreeWalker(this._doc.getElementById("container"),
          nf.SHOW_ELEMENT, filter, false));
    }

    return this._treeWalkerRef.get(this._win);
  },

  get _timeIntoParagraph() {
    let rv = Date.now() - this._startTime;
    return rv;
  },

  get speaking() {
    return this._win.speechSynthesis.speaking ||
      this._win.speechSynthesis.pending;
  },

  _isParagraphInView: function(paragraph) {
    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 tw = this._treeWalker;
    let paragraph = tw.nextNode();
    if (!paragraph) {
      tw.currentNode = tw.root;
      return Promise.resolve();
    }

    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, reject) => {
      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: paragraph.textContent
          });
        }
      });

      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._stopped) {
          // User pressed stopped.
          resolve();
        } else {
          this._speakInner().then(resolve, reject);
        }
      });

      utterance.addEventListener("error", () => {
        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)
    };

    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)) {}
        // _speakInner will advance to the next node for us, so we need
        // to have it one paragraph back from the first visible one.
        tw.previousNode();
      }

      return this._speakInner();
    });
  },

  stop: function() {
    this._stopped = true;
    this._win.speechSynthesis.cancel();
  },

  skipNext: function() {
    this._win.speechSynthesis.cancel();
  },

  skipPrevious: function() {
    let tw = this._treeWalker;
    tw.previousNode();
    if (this._timeIntoParagraph < PREV_THRESHOLD) {
      tw.previousNode();
    }
    this._win.speechSynthesis.cancel();
  },

  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);
    /* repeat current paragraph */
    this._treeWalker.previousNode();
    this._win.speechSynthesis.cancel();
  }
};