summaryrefslogtreecommitdiffstats
path: root/toolkit/components/narrate/NarrateControls.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/narrate/NarrateControls.jsm')
-rw-r--r--toolkit/components/narrate/NarrateControls.jsm343
1 files changed, 343 insertions, 0 deletions
diff --git a/toolkit/components/narrate/NarrateControls.jsm b/toolkit/components/narrate/NarrateControls.jsm
new file mode 100644
index 000000000..7d8794b18
--- /dev/null
+++ b/toolkit/components/narrate/NarrateControls.jsm
@@ -0,0 +1,343 @@
+/* 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");
+Cu.import("resource://gre/modules/AsyncPrefs.jsm");
+Cu.import("resource://gre/modules/TelemetryStopwatch.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);
+
+ win.addEventListener("unload", this);
+
+ // 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";
+ // We need inline svg here for the animation to work (bug 908634 & 1190881).
+ // The style animation can't be scoped (bug 830056).
+ dropdown.innerHTML =
+ localize`<style scoped>
+ @import url("chrome://global/skin/narrateControls.css");
+ </style>
+ <li>
+ <button class="dropdown-toggle button" id="narrate-toggle"
+ title="${"narrate"}" hidden>
+ <svg xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ width="24" height="24" viewBox="0 0 24 24">
+ <style>
+ @keyframes grow {
+ 0% { transform: scaleY(1); }
+ 15% { transform: scaleY(1.5); }
+ 15% { transform: scaleY(1.5); }
+ 30% { transform: scaleY(1); }
+ 100% { transform: scaleY(1); }
+ }
+
+ #waveform > rect {
+ fill: #808080;
+ }
+
+ .speaking #waveform > rect {
+ fill: #58bf43;
+ transform-box: fill-box;
+ transform-origin: 50% 50%;
+ animation-name: grow;
+ animation-duration: 1750ms;
+ animation-iteration-count: infinite;
+ animation-timing-function: linear;
+ }
+
+ #waveform > rect:nth-child(2) { animation-delay: 250ms; }
+ #waveform > rect:nth-child(3) { animation-delay: 500ms; }
+ #waveform > rect:nth-child(4) { animation-delay: 750ms; }
+ #waveform > rect:nth-child(5) { animation-delay: 1000ms; }
+ #waveform > rect:nth-child(6) { animation-delay: 1250ms; }
+ #waveform > rect:nth-child(7) { animation-delay: 1500ms; }
+
+ </style>
+ <g id="waveform">
+ <rect x="1" y="8" width="2" height="8" rx=".5" ry=".5" />
+ <rect x="4" y="5" width="2" height="14" rx=".5" ry=".5" />
+ <rect x="7" y="8" width="2" height="8" rx=".5" ry=".5" />
+ <rect x="10" y="4" width="2" height="16" rx=".5" ry=".5" />
+ <rect x="13" y="2" width="2" height="20" rx=".5" ry=".5" />
+ <rect x="16" y="4" width="2" height="16" rx=".5" ry=".5" />
+ <rect x="19" y="7" width="2" height="10" rx=".5" ry=".5" />
+ </g>
+ </svg>
+ </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="5" 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");
+ this.voiceSelect = new VoiceSelect(win, selectLabel);
+ 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("change", this);
+
+ // The rate is stored as an integer.
+ rateRange.value = branch.getIntPref("rate");
+
+ this._setupVoices();
+
+ let tb = win.document.getElementById("reader-toolbar");
+ tb.appendChild(dropdown);
+}
+
+NarrateControls.prototype = {
+ handleEvent: function(evt) {
+ switch (evt.type) {
+ case "change":
+ if (evt.target.id == "narrate-rate-input") {
+ this._onRateInput(evt);
+ } else {
+ this._onVoiceChange();
+ }
+ break;
+ case "click":
+ this._onButtonClick(evt);
+ break;
+ case "voiceschanged":
+ this._setupVoices();
+ break;
+ case "unload":
+ if (this.narrator.speaking) {
+ TelemetryStopwatch.finish("NARRATE_CONTENT_SPEAKTIME_MS", this);
+ }
+ break;
+ }
+ },
+
+ /**
+ * Returns true if synth voices are available.
+ */
+ _setupVoices: function() {
+ return this.narrator.languagePromise.then(language => {
+ this.voiceSelect.clear();
+ let win = this._win;
+ let voicePrefs = this._getVoicePref();
+ let selectedVoice = voicePrefs[language || "default"];
+ let comparer = win.Intl ?
+ (new Intl.Collator()).compare : (a, b) => a.localeCompare(b);
+ let filter = !Services.prefs.getBoolPref("narrate.filter-voices");
+ let options = win.speechSynthesis.getVoices().filter(v => {
+ return filter || !language || v.lang.split("-")[0] == language;
+ }).map(v => {
+ return {
+ label: this._createVoiceLabel(v),
+ value: v.voiceURI,
+ selected: selectedVoice == v.voiceURI
+ };
+ }).sort((a, b) => comparer(a.label, b.label));
+
+ if (options.length) {
+ options.unshift({
+ label: gStrings.GetStringFromName("defaultvoice"),
+ value: "automatic",
+ selected: selectedVoice == "automatic"
+ });
+ this.voiceSelect.addOptions(options);
+ }
+
+ let narrateToggle = win.document.getElementById("narrate-toggle");
+ let histogram = Services.telemetry.getKeyedHistogramById(
+ "NARRATE_CONTENT_BY_LANGUAGE_2");
+ let initial = !this._voicesInitialized;
+ this._voicesInitialized = true;
+
+ if (initial) {
+ histogram.add(language, 0);
+ }
+
+ if (options.length && narrateToggle.hidden) {
+ // About to show for the first time..
+ histogram.add(language, 1);
+ }
+
+ // We disable this entire feature if there are no available voices.
+ narrateToggle.hidden = !options.length;
+ });
+ },
+
+ _getVoicePref: function() {
+ let voicePref = Services.prefs.getCharPref("narrate.voice");
+ try {
+ return JSON.parse(voicePref);
+ } catch (e) {
+ return { default: voicePref };
+ }
+ },
+
+ _onRateInput: function(evt) {
+ AsyncPrefs.set("narrate.rate", parseInt(evt.target.value, 10));
+ this.narrator.setRate(this._convertRate(evt.target.value));
+ },
+
+ _onVoiceChange: function() {
+ let voice = this.voice;
+ this.narrator.setVoice(voice);
+ this.narrator.languagePromise.then(language => {
+ if (language) {
+ let voicePref = this._getVoicePref();
+ voicePref[language || "default"] = voice;
+ AsyncPrefs.set("narrate.voice", JSON.stringify(voicePref));
+ }
+ });
+ },
+
+ _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);
+ }, err => {
+ Cu.reportError(`Narrate failed: ${err}.`);
+ this._updateSpeechControls(false);
+ });
+ }
+ break;
+ }
+ },
+
+ _updateSpeechControls: function(speaking) {
+ let dropdown = this._doc.getElementById("narrate-dropdown");
+ dropdown.classList.toggle("keep-open", speaking);
+ dropdown.classList.toggle("speaking", speaking);
+
+ let startStopButton = this._doc.getElementById("narrate-start-stop");
+ startStopButton.title =
+ gStrings.GetStringFromName(speaking ? "stop" : "start");
+
+ this._doc.getElementById("narrate-skip-previous").disabled = !speaking;
+ this._doc.getElementById("narrate-skip-next").disabled = !speaking;
+
+ if (speaking) {
+ TelemetryStopwatch.start("NARRATE_CONTENT_SPEAKTIME_MS", this);
+ } else {
+ TelemetryStopwatch.finish("NARRATE_CONTENT_SPEAKTIME_MS", this);
+ }
+ },
+
+ _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;
+ }
+};