summaryrefslogtreecommitdiffstats
path: root/toolkit/components/narrate/NarrateControls.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/components/narrate/NarrateControls.sys.mjs358
1 files changed, 358 insertions, 0 deletions
diff --git a/toolkit/components/narrate/NarrateControls.sys.mjs b/toolkit/components/narrate/NarrateControls.sys.mjs
new file mode 100644
index 0000000000..d2e7b13b5d
--- /dev/null
+++ b/toolkit/components/narrate/NarrateControls.sys.mjs
@@ -0,0 +1,358 @@
+/* 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/. */
+
+import { AsyncPrefs } from "resource://gre/modules/AsyncPrefs.sys.mjs";
+import { Narrator } from "resource://gre/modules/narrate/Narrator.sys.mjs";
+import { VoiceSelect } from "resource://gre/modules/narrate/VoiceSelect.sys.mjs";
+
+var gStrings = Services.strings.createBundle(
+ "chrome://global/locale/narrate.properties"
+);
+
+export function NarrateControls(win, languagePromise) {
+ this._winRef = Cu.getWeakReference(win);
+ this._languagePromise = languagePromise;
+
+ 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);
+
+ let elemL10nMap = {
+ ".narrate-skip-previous": "back",
+ ".narrate-start-stop": "start-label",
+ ".narrate-skip-next": "forward",
+ ".narrate-rate-input": "speed",
+ };
+
+ let dropdown = win.document.createElement("ul");
+ dropdown.className = "dropdown narrate-dropdown";
+
+ let toggle = win.document.createElement("li");
+ let toggleButton = win.document.createElement("button");
+ toggleButton.className = "dropdown-toggle toolbar-button narrate-toggle";
+ toggleButton.dataset.telemetryId = "reader-listen";
+ let tip = win.document.createElement("span");
+ let shortcutNarrateKey = gStrings.GetStringFromName("narrate-key-shortcut");
+ let labelText = gStrings.formatStringFromName("listen-label", [
+ shortcutNarrateKey,
+ ]);
+ tip.textContent = labelText;
+ tip.className = "hover-label";
+ toggleButton.append(tip);
+ toggleButton.setAttribute("aria-label", labelText);
+ toggleButton.hidden = true;
+ dropdown.appendChild(toggle);
+ toggle.appendChild(toggleButton);
+
+ let dropdownList = win.document.createElement("li");
+ dropdownList.className = "dropdown-popup";
+ dropdown.appendChild(dropdownList);
+
+ let narrateControl = win.document.createElement("div");
+ narrateControl.className = "narrate-row narrate-control";
+ dropdownList.appendChild(narrateControl);
+
+ let narrateRate = win.document.createElement("div");
+ narrateRate.className = "narrate-row narrate-rate";
+ dropdownList.appendChild(narrateRate);
+
+ let narrateVoices = win.document.createElement("div");
+ narrateVoices.className = "narrate-row narrate-voices";
+ dropdownList.appendChild(narrateVoices);
+
+ let dropdownArrow = win.document.createElement("div");
+ dropdownArrow.className = "dropdown-arrow";
+ dropdownList.appendChild(dropdownArrow);
+
+ let narrateSkipPrevious = win.document.createElement("button");
+ narrateSkipPrevious.className = "narrate-skip-previous";
+ narrateSkipPrevious.disabled = true;
+ narrateControl.appendChild(narrateSkipPrevious);
+
+ let narrateStartStop = win.document.createElement("button");
+ narrateStartStop.className = "narrate-start-stop";
+ narrateControl.appendChild(narrateStartStop);
+
+ win.document.addEventListener("keydown", function (event) {
+ if (win.document.hasFocus() && event.key === "n") {
+ narrateStartStop.click();
+ }
+ });
+
+ let narrateSkipNext = win.document.createElement("button");
+ narrateSkipNext.className = "narrate-skip-next";
+ narrateSkipNext.disabled = true;
+ narrateControl.appendChild(narrateSkipNext);
+
+ let narrateRateInput = win.document.createElement("input");
+ narrateRateInput.className = "narrate-rate-input";
+ narrateRateInput.setAttribute("value", "0");
+ narrateRateInput.setAttribute("step", "5");
+ narrateRateInput.setAttribute("max", "100");
+ narrateRateInput.setAttribute("min", "-100");
+ narrateRateInput.setAttribute("type", "range");
+ narrateRate.appendChild(narrateRateInput);
+
+ for (let [selector, stringID] of Object.entries(elemL10nMap)) {
+ if (selector === ".narrate-start-stop") {
+ let shortcut = gStrings.GetStringFromName("narrate-key-shortcut");
+ let label = gStrings.formatStringFromName(stringID, [shortcut]);
+
+ dropdown.querySelector(selector).setAttribute("title", label);
+ } else {
+ dropdown
+ .querySelector(selector)
+ .setAttribute("title", gStrings.GetStringFromName(stringID));
+ }
+ }
+
+ this.narrator = new Narrator(win, languagePromise);
+
+ 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.classList.add("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.querySelector(".reader-controls");
+ tb.appendChild(dropdown);
+}
+
+NarrateControls.prototype = {
+ handleEvent(evt) {
+ switch (evt.type) {
+ case "change":
+ if (evt.target.classList.contains("narrate-rate-input")) {
+ this._onRateInput(evt);
+ } else {
+ this._onVoiceChange();
+ }
+ break;
+ case "click":
+ this._onButtonClick(evt);
+ break;
+ case "voiceschanged":
+ this._setupVoices();
+ break;
+ case "unload":
+ this.narrator.stop();
+ break;
+ }
+ },
+
+ /**
+ * Returns true if synth voices are available.
+ */
+ _setupVoices() {
+ return this._languagePromise.then(language => {
+ this.voiceSelect.clear();
+ let win = this._win;
+ let voicePrefs = this._getVoicePref();
+ let selectedVoice = voicePrefs[language || "default"];
+ let comparer = new Services.intl.Collator().compare;
+ 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.querySelector(".narrate-toggle");
+ let histogram = Services.telemetry.getKeyedHistogramById(
+ "NARRATE_CONTENT_BY_LANGUAGE_2"
+ );
+ let initial = !this._voicesInitialized;
+ this._voicesInitialized = true;
+
+ // if language is null, re-assign it to "unknown-language"
+ if (language == null) {
+ language = "unknown-language";
+ }
+
+ 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() {
+ let voicePref = Services.prefs.getCharPref("narrate.voice");
+ try {
+ return JSON.parse(voicePref);
+ } catch (e) {
+ return { default: voicePref };
+ }
+ },
+
+ _onRateInput(evt) {
+ AsyncPrefs.set("narrate.rate", parseInt(evt.target.value, 10));
+ this.narrator.setRate(this._convertRate(evt.target.value));
+ },
+
+ _onVoiceChange() {
+ let voice = this.voice;
+ this.narrator.setVoice(voice);
+ this._languagePromise.then(language => {
+ if (language) {
+ let voicePref = this._getVoicePref();
+ voicePref[language || "default"] = voice;
+ AsyncPrefs.set("narrate.voice", JSON.stringify(voicePref));
+ }
+ });
+ },
+
+ _onButtonClick(evt) {
+ let classList = evt.target.classList;
+ if (classList.contains("narrate-skip-previous")) {
+ this.narrator.skipPrevious();
+ } else if (classList.contains("narrate-skip-next")) {
+ this.narrator.skipNext();
+ } else if (classList.contains("narrate-start-stop")) {
+ if (this.narrator.speaking) {
+ this.narrator.stop();
+ } else {
+ this._updateSpeechControls(true);
+ TelemetryStopwatch.start("NARRATE_CONTENT_SPEAKTIME_MS", this);
+ let options = { rate: this.rate, voice: this.voice };
+ this.narrator
+ .start(options)
+ .catch(err => {
+ console.error(`Narrate failed: ${err}.`);
+ })
+ .then(() => {
+ this._updateSpeechControls(false);
+ TelemetryStopwatch.finish("NARRATE_CONTENT_SPEAKTIME_MS", this);
+ });
+ }
+ }
+ },
+
+ _updateSpeechControls(speaking) {
+ let dropdown = this._doc.querySelector(".narrate-dropdown");
+ if (!dropdown) {
+ // Elements got destroyed, but window lingers on for a bit.
+ return;
+ }
+
+ dropdown.classList.toggle("keep-open", speaking);
+ dropdown.classList.toggle("speaking", speaking);
+
+ let startStopButton = this._doc.querySelector(".narrate-start-stop");
+ let shortcutId = gStrings.GetStringFromName("narrate-key-shortcut");
+
+ startStopButton.title = gStrings.formatStringFromName(
+ speaking ? "stop-label" : "start-label",
+ [shortcutId]
+ );
+
+ this._doc.querySelector(".narrate-skip-previous").disabled = !speaking;
+ this._doc.querySelector(".narrate-skip-next").disabled = !speaking;
+ },
+
+ _createVoiceLabel(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,
+ ]);
+ 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,
+ ]);
+ }
+ },
+
+ _getLanguageName(lang) {
+ try {
+ // This may throw if the lang can't be parsed.
+ let langCode = new Services.intl.Locale(lang).language;
+
+ return Services.intl.getLanguageDisplayNames(undefined, [langCode]);
+ } catch {
+ return "";
+ }
+ },
+
+ _convertRate(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.querySelector(".narrate-rate-input").value
+ );
+ },
+
+ get voice() {
+ return this.voiceSelect.value;
+ },
+};