diff --git a/modules/libpref/init/all.js b/modules/libpref/init/all.js index b5754e97af4..61161bc674f 100644 --- a/modules/libpref/init/all.js +++ b/modules/libpref/init/all.js @@ -5162,6 +5162,16 @@ pref("reader.has_used_toolbar", false); // Whether to use a vertical or horizontal toolbar. pref("reader.toolbar.vertical", true); +#if !defined(ANDROID) +pref("narrate.enabled", true); +#else +pref("narrate.enabled", false); +#endif + +pref("narrate.test", false); +pref("narrate.rate", 0); +pref("narrate.voice", "automatic"); + #if defined(XP_LINUX) && defined(MOZ_GMP_SANDBOX) // Whether to allow, on a Linux system that doesn't support the necessary sandboxing // features, loading Gecko Media Plugins unsandboxed. However, EME CDMs will not be diff --git a/toolkit/components/moz.build b/toolkit/components/moz.build index c115ef8219c..890bed038b7 100644 --- a/toolkit/components/moz.build +++ b/toolkit/components/moz.build @@ -34,6 +34,7 @@ DIRS += [ 'gfx', 'jsdownloads', 'lz4', + 'narrate', 'mediasniffer', 'microformats', 'osfile', diff --git a/toolkit/components/narrate/.eslintrc b/toolkit/components/narrate/.eslintrc new file mode 100644 index 00000000000..0bbf0b79448 --- /dev/null +++ b/toolkit/components/narrate/.eslintrc @@ -0,0 +1,93 @@ +{ + "extends": [ + "../../.eslintrc" + ], + + "globals": { + "Components": true, + "dump": true, + "Iterator": true + }, + + "env": { "browser": true }, + + "rules": { + // Mozilla stuff + "mozilla/no-aArgs": 1, + "mozilla/reject-importGlobalProperties": 1, + "mozilla/var-only-at-top-level": 1, + + "block-scoped-var": 2, + "brace-style": [1, "1tbs", {"allowSingleLine": false}], + "camelcase": 1, + "comma-dangle": 1, + "comma-spacing": [1, {"before": false, "after": true}], + "comma-style": [1, "last"], + "complexity": 1, + "consistent-return": 2, + "curly": 2, + "dot-location": [1, "property"], + "dot-notation": 2, + "eol-last": 2, + "generator-star-spacing": [1, "after"], + "indent": [1, 2, {"SwitchCase": 1}], + "key-spacing": [1, {"beforeColon": false, "afterColon": true}], + "max-len": [1, 80, 2, {"ignoreUrls": true}], + "max-nested-callbacks": [2, 3], + "new-cap": [2, {"capIsNew": false}], + "new-parens": 2, + "no-array-constructor": 2, + "no-cond-assign": 2, + "no-control-regex": 2, + "no-debugger": 2, + "no-delete-var": 2, + "no-dupe-args": 2, + "no-dupe-keys": 2, + "no-duplicate-case": 2, + "no-else-return": 2, + "no-eval": 2, + "no-extend-native": 2, + "no-extra-bind": 2, + "no-extra-boolean-cast": 2, + "no-extra-semi": 1, + "no-fallthrough": 2, + "no-inline-comments": 1, + "no-lonely-if": 2, + "no-mixed-spaces-and-tabs": 2, + "no-multi-spaces": 1, + "no-multi-str": 1, + "no-multiple-empty-lines": [1, {"max": 1}], + "no-native-reassign": 2, + "no-nested-ternary": 2, + "no-redeclare": 2, + "no-return-assign": 2, + "no-self-compare": 2, + "no-sequences": 2, + "no-shadow": 1, + "no-shadow-restricted-names": 2, + "no-spaced-func": 1, + "no-throw-literal": 2, + "no-trailing-spaces": 2, + "no-undef": 2, + "no-unneeded-ternary": 2, + "no-unreachable": 2, + "no-unused-vars": 2, + "no-with": 2, + "padded-blocks": [1, "never"], + "quotes": [1, "double", "avoid-escape"], + "semi": [1, "always"], + "semi-spacing": [1, {"before": false, "after": true}], + "space-after-keywords": [1, "always"], + "space-before-blocks": [1, "always"], + "space-before-function-paren": [1, "never"], + "space-in-parens": [1, "never"], + "space-infix-ops": [1, {"int32Hint": true}], + "space-return-throw-case": 1, + "space-unary-ops": [1, { "words": true, "nonwords": false }], + "spaced-comment": [1, "always"], + "strict": [2, "global"], + "use-isnan": 2, + "valid-typeof": 2, + "yoda": 2 + } +} diff --git a/toolkit/components/narrate/NarrateControls.jsm b/toolkit/components/narrate/NarrateControls.jsm new file mode 100644 index 00000000000..1b56d189a32 --- /dev/null +++ b/toolkit/components/narrate/NarrateControls.jsm @@ -0,0 +1,244 @@ +/* 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"); + +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); + + // 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"; + dropdown.innerHTML = + localize` +
  • + +
  • + `; + + this.narrator = new Narrator(win); + + let selectLabel = gStrings.GetStringFromName("selectvoicelabel"); + let comparer = win.Intl ? + (new Intl.Collator()).compare : (a, b) => a.localeCompare(b); + let options = this.narrator.getVoiceOptions().map(v => { + return { + label: this._createVoiceLabel(v), + value: v.voiceURI + }; + }).sort((a, b) => comparer(a.label, b.label)); + options.unshift({ + label: gStrings.GetStringFromName("defaultvoice"), + value: "automatic" + }); + this.voiceSelect = new VoiceSelect(win, selectLabel, options); + this.voiceSelect.element.addEventListener("change", this); + this.voiceSelect.element.id = "voice-select"; + dropdown.querySelector("#narrate-voices").appendChild( + this.voiceSelect.element); + + dropdown.addEventListener("click", this, true); + + let rateRange = dropdown.querySelector("#narrate-rate > input"); + rateRange.addEventListener("input", this); + rateRange.addEventListener("mousedown", this); + rateRange.addEventListener("mouseup", this); + + let branch = Services.prefs.getBranch("narrate."); + this.voiceSelect.value = branch.getCharPref("voice"); + // The rate is stored as an integer. + rateRange.value = branch.getIntPref("rate"); + + let tb = win.document.getElementById("reader-toolbar"); + tb.appendChild(dropdown); +} + +NarrateControls.prototype = { + handleEvent: function(evt) { + switch (evt.type) { + case "mousedown": + this._rateMousedown = true; + break; + case "mouseup": + this._rateMousedown = false; + break; + case "input": + this._onRateInput(evt); + break; + case "change": + this._onVoiceChange(); + break; + case "click": + this._onButtonClick(evt); + break; + } + }, + + _onRateInput: function(evt) { + if (!this._rateMousedown) { + this._mm.sendAsyncMessage("Reader:SetIntPref", + { name: "narrate.rate", value: evt.target.value }); + this.narrator.setRate(this._convertRate(evt.target.value)); + } + }, + + _onVoiceChange: function() { + let voice = this.voice; + this._mm.sendAsyncMessage("Reader:SetCharPref", + { name: "narrate.voice", value: voice }); + this.narrator.setVoice(voice); + }, + + _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); + }); + } + break; + case "narrate-toggle": + let dropdown = this._doc.getElementById("narrate-dropdown"); + if (dropdown.classList.contains("open")) { + if (this.narrator.speaking) { + this.narrator.stop(); + } + + // We need to remove "keep-open" class here so that AboutReader + // closes this dropdown properly. This class is eventually removed in + // _updateSpeechControls which gets called after narration stops, + // but that happend asynchronously and is too late. + dropdown.classList.remove("keep-open"); + } + break; + } + }, + + _updateSpeechControls: function(speaking) { + let dropdown = this._doc.getElementById("narrate-dropdown"); + dropdown.classList.toggle("keep-open", speaking); + + let startStopButton = this._doc.getElementById("narrate-start-stop"); + startStopButton.classList.toggle("speaking", speaking); + startStopButton.title = + gStrings.GetStringFromName(speaking ? "start" : "stop"); + + this._doc.getElementById("narrate-skip-previous").disabled = !speaking; + this._doc.getElementById("narrate-skip-next").disabled = !speaking; + }, + + _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; + } +}; diff --git a/toolkit/components/narrate/Narrator.jsm b/toolkit/components/narrate/Narrator.jsm new file mode 100644 index 00000000000..14a6ea44c8a --- /dev/null +++ b/toolkit/components/narrate/Narrator.jsm @@ -0,0 +1,219 @@ +/* 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 _paragraphs() { + if (!this._paragraphsInner) { + let wu = this._win.QueryInterface( + Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + let queryString = "#reader-header > *:not(style):not(:empty), " + + "#moz-reader-content > .page > * > *:not(style):not(:empty)"; + // filter out zero sized paragraphs. + let paragraphs = Array.from(this._doc.querySelectorAll(queryString)); + paragraphs = paragraphs.filter(p => { + let bb = wu.getBoundsWithoutFlushing(p); + return bb.width && bb.height; + }); + + this._paragraphsInner = paragraphs.map(Cu.getWeakReference); + } + + return this._paragraphsInner; + }, + + get _timeIntoParagraph() { + let rv = Date.now() - this._startTime; + return rv; + }, + + get speaking() { + return this._win.speechSynthesis.speaking || + this._win.speechSynthesis.pending; + }, + + _getParagraphAt: function(index) { + let paragraph = this._paragraphsInner[index]; + return paragraph ? paragraph.get() : null; + }, + + _isParagraphInView: function(paragraphRef) { + let paragraph = paragraphRef && paragraphRef.get && paragraphRef.get(); + 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 paragraph = this._getParagraphAt(this._index); + 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 => { + 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: this._index + }); + } + }); + + 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._index + 1 >= this._paragraphs.length || this._stopped) { + // We reached the end of the document, or the user pressed stopped. + resolve(); + } else { + this._index++; + this._speakInner().then(resolve); + } + }); + + 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(() => { + if (!this._isParagraphInView(this._paragraphs[this._index])) { + this._index = this._paragraphs.findIndex( + this._isParagraphInView.bind(this)); + } + + return this._speakInner(); + }); + }, + + stop: function() { + this._stopped = true; + this._win.speechSynthesis.cancel(); + }, + + skipNext: function() { + this._win.speechSynthesis.cancel(); + }, + + skipPrevious: function() { + this._index -= + this._index > 0 && this._timeIntoParagraph < PREV_THRESHOLD ? 2 : 1; + this._win.speechSynthesis.cancel(); + }, + + setRate: function(rate) { + this._speechOptions.rate = rate; + /* repeat current paragraph */ + this._index--; + this._win.speechSynthesis.cancel(); + }, + + setVoice: function(voice) { + this._speechOptions.voice = this._voiceMap.get(voice); + /* repeat current paragraph */ + this._index--; + this._win.speechSynthesis.cancel(); + } +}; diff --git a/toolkit/components/narrate/VoiceSelect.jsm b/toolkit/components/narrate/VoiceSelect.jsm new file mode 100644 index 00000000000..4965575401d --- /dev/null +++ b/toolkit/components/narrate/VoiceSelect.jsm @@ -0,0 +1,291 @@ +/* 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; + +this.EXPORTED_SYMBOLS = ["VoiceSelect"]; + +function VoiceSelect(win, label, options = []) { + this._winRef = Cu.getWeakReference(win); + + let element = win.document.createElement("div"); + element.classList.add("voiceselect"); + element.innerHTML = + ` +
    `; + + this._elementRef = Cu.getWeakReference(element); + + let button = this.selectToggle; + button.addEventListener("click", this); + button.addEventListener("keypress", this); + + let listbox = this.listbox; + listbox.addEventListener("click", this); + listbox.addEventListener("mousemove", this); + listbox.addEventListener("keypress", this); + listbox.addEventListener("wheel", this, true); + + win.addEventListener("resize", () => { + this._updateDropdownHeight(); + }); + + for (let option of options) { + this.add(option.label, option.value); + } + + this.selectedIndex = 0; +} + +VoiceSelect.prototype = { + add: function(label, value) { + let option = this._doc.createElement("button"); + option.dataset.value = value; + option.classList.add("option"); + option.tabIndex = "-1"; + option.setAttribute("role", "option"); + option.textContent = label; + this.listbox.appendChild(option); + }, + + toggleList: function(force, focus = true) { + if (this.element.classList.toggle("open", force)) { + if (focus) { + (this.selected || this.options[0]).focus(); + } + + this._updateDropdownHeight(true); + this.listbox.setAttribute("aria-expanded", true); + this._win.addEventListener("focus", this, true); + } else { + if (focus) { + this.element.querySelector(".select-toggle").focus(); + } + + this.listbox.setAttribute("aria-expanded", false); + this._win.removeEventListener("focus", this, true); + } + }, + + handleEvent: function(evt) { + let target = evt.target; + + switch (evt.type) { + case "click": + if (target.classList.contains("option")) { + if (!target.classList.contains("selected")) { + this.selected = target; + } + + this.toggleList(false); + } else if (target.classList.contains("select-toggle")) { + this.toggleList(); + } + break; + + case "mousemove": + this.listbox.classList.add("hovering"); + break; + + case "keypress": + if (target.classList.contains("select-toggle")) { + if (evt.altKey) { + this.toggleList(true); + } else { + this._keyPressedButton(evt); + } + } else { + this.listbox.classList.remove("hovering"); + this._keyPressedInBox(evt); + } + break; + + case "wheel": + // Don't let wheel events bubble to document. It will scroll the page + // and close the entire narrate dialog. + evt.stopPropagation(); + break; + + case "focus": + this._win.console.log(evt); + if (!evt.target.closest('.options')) { + this.toggleList(false, false); + } + break; + } + }, + + _getPagedOption: function(option, up) { + let height = elem => elem.getBoundingClientRect().height; + let listboxHeight = height(this.listbox); + + let next = option; + for (let delta = 0; delta < listboxHeight; delta += height(next)) { + let sibling = up ? next.previousElementSibling : next.nextElementSibling; + if (!sibling) { + break; + } + + next = sibling; + } + + return next; + }, + + _keyPressedButton: function(evt) { + if (evt.altKey && (evt.key === "ArrowUp" || evt.key === "ArrowUp")) { + this.toggleList(true); + return; + } + + let toSelect; + switch (evt.key) { + case "PageUp": + case "ArrowUp": + toSelect = this.selected.previousElementSibling; + break; + case "PageDown": + case "ArrowDown": + toSelect = this.selected.nextElementSibling; + break; + case "Home": + toSelect = this.selected.parentNode.firstElementChild; + break; + case "End": + toSelect = this.selected.parentNode.lastElementChild; + break; + } + + if (toSelect && toSelect.classList.contains("option")) { + evt.preventDefault(); + this.selected = toSelect; + } + }, + + _keyPressedInBox: function(evt) { + let toFocus; + let cur = this._doc.activeElement; + + switch (evt.key) { + case "ArrowUp": + toFocus = cur.previousElementSibling || this.listbox.lastElementChild; + break; + case "ArrowDown": + toFocus = cur.nextElementSibling || this.listbox.firstElementChild; + break; + case "PageUp": + toFocus = this._getPagedOption(cur, true); + break; + case "PageDown": + toFocus = this._getPagedOption(cur, false); + break; + case "Home": + toFocus = cur.parentNode.firstElementChild; + break; + case "End": + toFocus = cur.parentNode.lastElementChild; + break; + case "Escape": + this.toggleList(false); + break; + } + + if (toFocus && toFocus.classList.contains("option")) { + evt.preventDefault(); + toFocus.focus(); + } + }, + + _select: function(option) { + let oldSelected = this.selected; + if (oldSelected) { + oldSelected.removeAttribute("aria-selected"); + oldSelected.classList.remove("selected"); + } + + if (option) { + option.setAttribute("aria-selected", true); + option.classList.add("selected"); + this.element.querySelector(".current-voice").textContent = + option.textContent; + } + + let evt = this.element.ownerDocument.createEvent("Event"); + evt.initEvent("change", true, true); + this.element.dispatchEvent(evt); + }, + + _updateDropdownHeight: function(now) { + let updateInner = () => { + let winHeight = this._win.innerHeight; + let listbox = this.listbox; + let listboxTop = listbox.getBoundingClientRect().top; + listbox.style.maxHeight = (winHeight - listboxTop - 10) + "px"; + }; + + if (now) { + updateInner(); + } else if (!this._pendingDropdownUpdate) { + this._pendingDropdownUpdate = true; + this._win.requestAnimationFrame(() => { + updateInner(); + delete this._pendingDropdownUpdate; + }); + } + }, + + get element() { + return this._elementRef.get(); + }, + + get listbox() { + return this._elementRef.get().querySelector(".options"); + }, + + get selectToggle() { + return this._elementRef.get().querySelector(".select-toggle"); + }, + + get _win() { + return this._winRef.get(); + }, + + get _doc() { + return this._win.document; + }, + + set selected(option) { + this._select(option); + }, + + get selected() { + return this.element.querySelector(".options > .option.selected"); + }, + + get options() { + return this.element.querySelectorAll(".options > .option"); + }, + + set selectedIndex(index) { + this._select(this.options[index]); + }, + + get selectedIndex() { + return Array.from(this.options).indexOf(this.selected); + }, + + set value(value) { + let option = Array.from(this.options).find(o => o.dataset.value === value); + this._select(option); + }, + + get value() { + let selected = this.selected; + return selected ? selected.dataset.value : ""; + } +}; diff --git a/toolkit/components/narrate/moz.build b/toolkit/components/narrate/moz.build new file mode 100644 index 00000000000..9d286d4f6fe --- /dev/null +++ b/toolkit/components/narrate/moz.build @@ -0,0 +1,11 @@ +# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +EXTRA_JS_MODULES.narrate = [ + 'NarrateControls.jsm', + 'Narrator.jsm', + 'VoiceSelect.jsm' +] diff --git a/toolkit/components/reader/AboutReader.jsm b/toolkit/components/reader/AboutReader.jsm index 5e8b03133ed..01d400029d5 100644 --- a/toolkit/components/reader/AboutReader.jsm +++ b/toolkit/components/reader/AboutReader.jsm @@ -15,6 +15,7 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Rect", "resource://gre/modules/Geometry.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry", "resource://gre/modules/UITelemetry.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NarrateControls", "resource://gre/modules/narrate/NarrateControls.jsm"); var gStrings = Services.strings.createBundle("chrome://global/locale/aboutReader.properties"); @@ -102,6 +103,10 @@ var AboutReader = function(mm, win, articlePromise) { this._setupFontSizeButtons(); + if (win.speechSynthesis && Services.prefs.getBoolPref("narrate.enabled")) { + new NarrateControls(mm, win); + } + this._loadArticle(); } diff --git a/toolkit/locales/en-US/chrome/global/narrate.properties b/toolkit/locales/en-US/chrome/global/narrate.properties new file mode 100644 index 00000000000..9ac839427fc --- /dev/null +++ b/toolkit/locales/en-US/chrome/global/narrate.properties @@ -0,0 +1,19 @@ +# 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/. + +# Narrate, meaning "read the page out loud". This is the name of the feature +# and it is the label for the popup button. +narrate = Narrate +back = Back +start = Start +stop = Stop +forward = Forward +speed = Speed +selectvoicelabel = Voice: +# Default voice is determined by the language of the document. +defaultvoice = Default + +# Voice name and language. +# eg. David (English) +voiceLabel = %S (%S) \ No newline at end of file diff --git a/toolkit/locales/jar.mn b/toolkit/locales/jar.mn index 3cf51d80ae6..e4fdcc3014a 100644 --- a/toolkit/locales/jar.mn +++ b/toolkit/locales/jar.mn @@ -62,6 +62,7 @@ locale/@AB_CD@/global/keys.properties (%chrome/global/keys.properties) locale/@AB_CD@/global/languageNames.properties (%chrome/global/languageNames.properties) locale/@AB_CD@/global/mozilla.dtd (%chrome/global/mozilla.dtd) + locale/@AB_CD@/global/narrate.properties (%chrome/global/narrate.properties) locale/@AB_CD@/global/notification.dtd (%chrome/global/notification.dtd) locale/@AB_CD@/global/preferences.dtd (%chrome/global/preferences.dtd) locale/@AB_CD@/global/printdialog.dtd (%chrome/global/printdialog.dtd) diff --git a/toolkit/themes/shared/jar.inc.mn b/toolkit/themes/shared/jar.inc.mn index 688c23705e6..8ee65fc7b7d 100644 --- a/toolkit/themes/shared/jar.inc.mn +++ b/toolkit/themes/shared/jar.inc.mn @@ -26,6 +26,16 @@ toolkit.jar: skin/classic/global/icons/loading-inverted@2x.png (../../shared/icons/loading-inverted@2x.png) skin/classic/global/icons/warning.svg (../../shared/incontent-icons/warning.svg) skin/classic/global/alerts/alert-common.css (../../shared/alert-common.css) + skin/classic/global/narrate.css (../../shared/narrate.css) + skin/classic/global/narrateControls.css (../../shared/narrateControls.css) + skin/classic/global/narrate/arrow.svg (../../shared/narrate/arrow.svg) + skin/classic/global/narrate/back.svg (../../shared/narrate/back.svg) + skin/classic/global/narrate/fast.svg (../../shared/narrate/fast.svg) + skin/classic/global/narrate/forward.svg (../../shared/narrate/forward.svg) + skin/classic/global/narrate/narrate.svg (../../shared/narrate/narrate.svg) + skin/classic/global/narrate/slow.svg (../../shared/narrate/slow.svg) + skin/classic/global/narrate/start.svg (../../shared/narrate/start.svg) + skin/classic/global/narrate/stop.svg (../../shared/narrate/stop.svg) skin/classic/global/menu/shared-menu-check@2x.png (../../shared/menu-check@2x.png) skin/classic/global/menu/shared-menu-check.png (../../shared/menu-check.png) skin/classic/global/menu/shared-menu-check-active.svg (../../shared/menu-check-active.svg) diff --git a/toolkit/themes/shared/narrate.css b/toolkit/themes/shared/narrate.css new file mode 100644 index 00000000000..3aa8a8a6e31 --- /dev/null +++ b/toolkit/themes/shared/narrate.css @@ -0,0 +1,11 @@ +body.light .narrating { + background-color: #ffc; +} + +body.sepia .narrating { + background-color: #e0d7c5; +} + +body.dark .narrating { + background-color: #242424; +} diff --git a/toolkit/themes/shared/narrate/arrow.svg b/toolkit/themes/shared/narrate/arrow.svg new file mode 100644 index 00000000000..2fb21417d66 --- /dev/null +++ b/toolkit/themes/shared/narrate/arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/toolkit/themes/shared/narrate/back.svg b/toolkit/themes/shared/narrate/back.svg new file mode 100644 index 00000000000..d29586e382a --- /dev/null +++ b/toolkit/themes/shared/narrate/back.svg @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/toolkit/themes/shared/narrate/fast.svg b/toolkit/themes/shared/narrate/fast.svg new file mode 100644 index 00000000000..cd25a6a0353 --- /dev/null +++ b/toolkit/themes/shared/narrate/fast.svg @@ -0,0 +1,3 @@ + + + diff --git a/toolkit/themes/shared/narrate/forward.svg b/toolkit/themes/shared/narrate/forward.svg new file mode 100644 index 00000000000..53e64e9511b --- /dev/null +++ b/toolkit/themes/shared/narrate/forward.svg @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/toolkit/themes/shared/narrate/narrate.svg b/toolkit/themes/shared/narrate/narrate.svg new file mode 100644 index 00000000000..597b0a65c0f --- /dev/null +++ b/toolkit/themes/shared/narrate/narrate.svg @@ -0,0 +1,3 @@ + + + diff --git a/toolkit/themes/shared/narrate/slow.svg b/toolkit/themes/shared/narrate/slow.svg new file mode 100644 index 00000000000..1892b66e1fc --- /dev/null +++ b/toolkit/themes/shared/narrate/slow.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/toolkit/themes/shared/narrate/start.svg b/toolkit/themes/shared/narrate/start.svg new file mode 100644 index 00000000000..95fa7131339 --- /dev/null +++ b/toolkit/themes/shared/narrate/start.svg @@ -0,0 +1,3 @@ + + + diff --git a/toolkit/themes/shared/narrate/stop.svg b/toolkit/themes/shared/narrate/stop.svg new file mode 100644 index 00000000000..c017c578f9e --- /dev/null +++ b/toolkit/themes/shared/narrate/stop.svg @@ -0,0 +1,3 @@ + + + diff --git a/toolkit/themes/shared/narrateControls.css b/toolkit/themes/shared/narrateControls.css new file mode 100644 index 00000000000..dc24a4c9628 --- /dev/null +++ b/toolkit/themes/shared/narrateControls.css @@ -0,0 +1,184 @@ +:scope { + --border-color: #e5e5e5; +} + +#narrate-toggle { + background-image: url("chrome://global/skin/narrate/narrate.svg"); +} + +.dropdown-popup button { + background-color: transparent; +} + +.dropdown-popup button:hover:not(:disabled) { + background-color: #eaeaea; +} + +.narrate-row { + display: flex; + align-items: center; + min-height: 40px; + box-sizing: border-box; +} + +.narrate-row:not(:first-child) { + border-top: 1px solid var(--border-color); +} + +/* Control buttons */ + +#narrate-control > button { + background-size: 24px 24px; + background-repeat: no-repeat; + background-position: center center; + height: 64px; + width: 100px; + border: none; + color: #666; + box-sizing: border-box; +} + +#narrate-control > button:not(:first-child) { + border-left: 1px solid var(--border-color); +} + +#narrate-skip-previous { + border-top-left-radius: 3px; + background-image: url("chrome://global/skin/narrate/back.svg#enabled"); +} + +#narrate-skip-next { + border-top-right-radius: 3px; + background-image: url("chrome://global/skin/narrate/forward.svg#enabled"); +} + +#narrate-skip-previous:disabled { + background-image: url("chrome://global/skin/narrate/back.svg#disabled"); +} + +#narrate-skip-next:disabled { + background-image: url("chrome://global/skin/narrate/forward.svg#disabled"); +} + +#narrate-start-stop { + background-image: url("chrome://global/skin/narrate/start.svg"); +} + +#narrate-start-stop.speaking { + background-image: url("chrome://global/skin/narrate/stop.svg"); +} + +/* Rate control */ + +#narrate-rate::before, #narrate-rate::after { + content: ''; + width: 48px; + height: 40px; + background-position: center; + background-repeat: no-repeat; + background-size: 24px auto; +} + +#narrate-rate::before { + background-image: url("chrome://global/skin/narrate/slow.svg"); +} + +#narrate-rate::after { + background-image: url("chrome://global/skin/narrate/fast.svg"); +} + +#narrate-rate-input { + margin: 0 1px; + flex-grow: 1; +} + +#narrate-rate-input::-moz-range-track { + background-color: #979797; + height: 2px; +} + +#narrate-rate-input::-moz-range-progress { + background-color: #2EA3FF; + height: 2px; +} + +#narrate-rate-input::-moz-range-thumb { + background-color: #808080; + height: 16px; + width: 16px; + border-radius: 8px; + border-width: 0; +} + +#narrate-rate-input:active::-moz-range-thumb { + background-color: #2EA3FF; +} + +/* Voice selection */ + +.voiceselect { + width: 100%; +} + +.voiceselect > button.select-toggle, +.voiceselect > .options > button.option { + -moz-appearance: none; + border: none; + width: 100%; + min-height: 40px; +} + +.voiceselect.open > button.select-toggle { + border-bottom: 1px solid var(--border-color); +} + +.voiceselect > button.select-toggle::after { + content: ''; + background-image: url("chrome://global/skin/narrate/arrow.svg"); + background-position: center; + background-repeat: no-repeat; + background-size: 12px 12px; + display: inline-block; + width: 1.5em; + height: 1em; + vertical-align: middle; +} + +.voiceselect > .options > button.option:not(:first-child) { + border-top: 1px solid var(--border-color); +} + +.voiceselect > .options > button.option { + box-sizing: border-box; +} + +.voiceselect > .options:not(.hovering) > button.option:focus { + background-color: #eaeaea; +} + +.voiceselect > .options:not(.hovering) > button.option:hover:not(:focus) { + background-color: transparent; +} + +.voiceselect > .options > button.option::-moz-focus-inner { + outline: none; + border: 0; +} + +.voiceselect > .options { + display: none; + overflow-y: auto; +} + +.voiceselect.open > .options { + display: block; +} + +.current-voice { + color: #7f7f7f; +} + +.voiceselect:not(.open) > button, +.option:last-child { + border-radius: 0 0 3px 3px; +}