fune/toolkit/components/narrate/NarrateControls.sys.mjs
Mark Banner ae619c5049 Bug 1838155 - Clean up some console.error calls that had been migrated from Cu.reportError. r=jdescottes,perftest-reviewers,geckoview-reviewers,credential-management-reviewers,search-reviewers,sgalich,owlish,jteow,sparky
This goes through the previous changes in the dependencies of bug 877389, and does two things:
1) Remove instances of \n
2) Change reporting of exceptions so that they are passed as separate arguments. This should result
   in an improved display of the exception in the browser console, should it occur.

Differential Revision: https://phabricator.services.mozilla.com/D180843
2023-06-15 08:33:57 +00:00

358 lines
12 KiB
JavaScript

/* 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;
},
};