diff options
Diffstat (limited to 'toolkit/components/narrate/NarrateControls.sys.mjs')
-rw-r--r-- | toolkit/components/narrate/NarrateControls.sys.mjs | 358 |
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; + }, +}; |