Bug 1166365 - Introduce Narrate feature in reader mode. r=Gijs
MozReview-Commit-ID: 6tJIu7C4eAv
@ -5162,6 +5162,16 @@ pref("reader.has_used_toolbar", false);
|
|||||||
// Whether to use a vertical or horizontal toolbar.
|
// Whether to use a vertical or horizontal toolbar.
|
||||||
pref("reader.toolbar.vertical", true);
|
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)
|
#if defined(XP_LINUX) && defined(MOZ_GMP_SANDBOX)
|
||||||
// Whether to allow, on a Linux system that doesn't support the necessary sandboxing
|
// 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
|
// features, loading Gecko Media Plugins unsandboxed. However, EME CDMs will not be
|
||||||
|
@ -34,6 +34,7 @@ DIRS += [
|
|||||||
'gfx',
|
'gfx',
|
||||||
'jsdownloads',
|
'jsdownloads',
|
||||||
'lz4',
|
'lz4',
|
||||||
|
'narrate',
|
||||||
'mediasniffer',
|
'mediasniffer',
|
||||||
'microformats',
|
'microformats',
|
||||||
'osfile',
|
'osfile',
|
||||||
|
93
toolkit/components/narrate/.eslintrc
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
244
toolkit/components/narrate/NarrateControls.jsm
Normal file
@ -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`<style scoped>
|
||||||
|
@import url("chrome://global/skin/narrateControls.css");
|
||||||
|
</style>
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-toggle button"
|
||||||
|
id="narrate-toggle" title="${"narrate"}"></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="25" max="400" min="-400" type="range">
|
||||||
|
</div>
|
||||||
|
<div id="narrate-voices" class="narrate-row"></div>
|
||||||
|
<div class="dropdown-arrow"></div>
|
||||||
|
</li>`;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
219
toolkit/components/narrate/Narrator.jsm
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
};
|
291
toolkit/components/narrate/VoiceSelect.jsm
Normal file
@ -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 =
|
||||||
|
`<button class="select-toggle" aria-controls="voice-options">
|
||||||
|
<span class="label">${label}</span> <span class="current-voice"></span>
|
||||||
|
</button>
|
||||||
|
<div class="options" id="voice-options" role="listbox"></div>`;
|
||||||
|
|
||||||
|
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 : "";
|
||||||
|
}
|
||||||
|
};
|
11
toolkit/components/narrate/moz.build
Normal file
@ -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'
|
||||||
|
]
|
@ -15,6 +15,7 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
|||||||
XPCOMUtils.defineLazyModuleGetter(this, "Rect", "resource://gre/modules/Geometry.jsm");
|
XPCOMUtils.defineLazyModuleGetter(this, "Rect", "resource://gre/modules/Geometry.jsm");
|
||||||
XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm");
|
XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm");
|
||||||
XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry", "resource://gre/modules/UITelemetry.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");
|
var gStrings = Services.strings.createBundle("chrome://global/locale/aboutReader.properties");
|
||||||
|
|
||||||
@ -102,6 +103,10 @@ var AboutReader = function(mm, win, articlePromise) {
|
|||||||
|
|
||||||
this._setupFontSizeButtons();
|
this._setupFontSizeButtons();
|
||||||
|
|
||||||
|
if (win.speechSynthesis && Services.prefs.getBoolPref("narrate.enabled")) {
|
||||||
|
new NarrateControls(mm, win);
|
||||||
|
}
|
||||||
|
|
||||||
this._loadArticle();
|
this._loadArticle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
19
toolkit/locales/en-US/chrome/global/narrate.properties
Normal file
@ -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)
|
@ -62,6 +62,7 @@
|
|||||||
locale/@AB_CD@/global/keys.properties (%chrome/global/keys.properties)
|
locale/@AB_CD@/global/keys.properties (%chrome/global/keys.properties)
|
||||||
locale/@AB_CD@/global/languageNames.properties (%chrome/global/languageNames.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/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/notification.dtd (%chrome/global/notification.dtd)
|
||||||
locale/@AB_CD@/global/preferences.dtd (%chrome/global/preferences.dtd)
|
locale/@AB_CD@/global/preferences.dtd (%chrome/global/preferences.dtd)
|
||||||
locale/@AB_CD@/global/printdialog.dtd (%chrome/global/printdialog.dtd)
|
locale/@AB_CD@/global/printdialog.dtd (%chrome/global/printdialog.dtd)
|
||||||
|
@ -26,6 +26,16 @@ toolkit.jar:
|
|||||||
skin/classic/global/icons/loading-inverted@2x.png (../../shared/icons/loading-inverted@2x.png)
|
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/icons/warning.svg (../../shared/incontent-icons/warning.svg)
|
||||||
skin/classic/global/alerts/alert-common.css (../../shared/alert-common.css)
|
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@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.png (../../shared/menu-check.png)
|
||||||
skin/classic/global/menu/shared-menu-check-active.svg (../../shared/menu-check-active.svg)
|
skin/classic/global/menu/shared-menu-check-active.svg (../../shared/menu-check-active.svg)
|
||||||
|
11
toolkit/themes/shared/narrate.css
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
body.light .narrating {
|
||||||
|
background-color: #ffc;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.sepia .narrating {
|
||||||
|
background-color: #e0d7c5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .narrating {
|
||||||
|
background-color: #242424;
|
||||||
|
}
|
3
toolkit/themes/shared/narrate/arrow.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 12 12">
|
||||||
|
<path d="M6 9L1 4l1-1 4 4 4-4 1 1z" fill="#4C4C4C"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 166 B |
15
toolkit/themes/shared/narrate/back.svg
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
use:not(:target) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path id="shape" d="M 5 0 C 4.446 0 4 0.446 4 1 L 4 23 C 4 23.554 4.446 24 5 24 L 7 24 C 7.554 24 8 23.554 8 23 L 8 12.404297 C 8.04108 12.509297 8.109944 12.610125 8.203125 12.703125 L 19.296875 23.775391 C 19.495259 23.972391 19.661613 24.039562 19.796875 23.976562 C 19.932137 23.915564 20 23.748516 20 23.478516 L 20 0.52148438 C 20 0.25248437 19.93214 0.084484365 19.796875 0.021484375 C 19.661613 -0.040515625 19.495259 0.02856248 19.296875 0.2265625 L 8.203125 11.298828 C 8.1099445 11.381828 8.04108 11.481703 8 11.595703 L 8 1 C 8 0.446 7.554 0 7 0 L 5 0 z " fill="gray"/>
|
||||||
|
</defs>
|
||||||
|
<use id="enabled" xlink:href="#shape"/>
|
||||||
|
<use id="disabled" xlink:href="#shape"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 930 B |
3
toolkit/themes/shared/narrate/fast.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg id="Icons" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 20.4">
|
||||||
|
<path fill="gray" d="M14.42 16.68a.77.77 0 0 0 .54.7l2.51.68a1.58 1.58 0 0 1 1.06 1.22l.05.39-3.89-.53a4.34 4.34 0 0 1-1.74-.72L7.2 14.03a5.79 5.79 0 0 1-5.34-4.88h-.82a1 1 0 0 1-1-1l2.9-3.24a6.16 6.16 0 0 1 4.7-2.39 5.88 5.88 0 0 1 .77.05 5 5 0 0 1 .87.15c3.75 1 6.5 5.84 6.5 5.84a2.27 2.27 0 0 0 1.14.85h.17a1.27 1.27 0 0 0 1.22-.4l.78-1-2.47-1.2c-3.38-1.46-2.46-5.71-2.46-5.71 0-.26.23-.32.42-.14l5.32 5-4.31-4.81a1.39 1.39 0 0 1 .81-1.22l4.17 6.65.33.31 2.19 1.54a2.44 2.44 0 0 1 .92 1.75v2.77l-.16.13a1.66 1.66 0 0 1-1.63.19l-.75-.36a2.57 2.57 0 0 0-2.55.32l-2.18 1.82a4.28 4.28 0 0 1-.89.55 10.18 10.18 0 0 0-4.62-8.46c-.27-.16-.66.31-.47.48a10.52 10.52 0 0 1 3.68 8.5v.48zm8.38-5.42a.49.49 0 1 0-.49-.49.49.49 0 0 0 .49.49zm-18 9.14v-.52a1.39 1.39 0 0 1 .93-1.25s2.7-.66 3.43-1.84l2.06 1.63a25.62 25.62 0 0 1-6.43 2z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 912 B |
15
toolkit/themes/shared/narrate/forward.svg
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
use:not(:target) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path id="shape" d="m 19,0 c 0.554,0 1,0.446 1,1 l 0,22 c 0,0.554 -0.446,1 -1,1 l -2,0 c -0.554,0 -1,-0.446 -1,-1 l 0,-10.595703 c -0.04108,0.105 -0.109944,0.205828 -0.203125,0.298828 L 4.703125,23.775391 c -0.198384,0.197 -0.364738,0.264171 -0.5,0.201171 C 4.067863,23.915564 4,23.748516 4,23.478516 L 4,0.52148438 c 0,-0.26900001 0.06786,-0.43700001 0.203125,-0.5 0.135262,-0.062 0.301616,0.0070781 0.5,0.20507812 l 11.09375,11.0722655 c 0.09318,0.083 0.162045,0.182875 0.203125,0.296875 L 16,1 c 0,-0.554 0.446,-1 1,-1 l 2,0 z" fill="gray"/>
|
||||||
|
</defs>
|
||||||
|
<use id="enabled" xlink:href="#shape"/>
|
||||||
|
<use id="disabled" xlink:href="#shape"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 893 B |
3
toolkit/themes/shared/narrate/narrate.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg id="Icons" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 18.77">
|
||||||
|
<path fill="gray" d="M3.13 13.72a1.57 1.57 0 0 1-3.13 0V5.41a1.57 1.57 0 0 1 3.13 0v8.31zm6.29 3.62a1.57 1.57 0 0 1-3.13 0V1.44a1.57 1.57 0 0 1 3.13 0v15.9zm6.29-2.9a1.57 1.57 0 0 1-3.13 0V4.83a1.57 1.57 0 0 1 3.13 0v9.61zM22 12.62a1.57 1.57 0 0 1-3.13 0V6.15a1.57 1.57 0 0 1 3.13 0v6.47z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 378 B |
6
toolkit/themes/shared/narrate/slow.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<g fill="gray">
|
||||||
|
<path d="M1.684,13.486c-0.209,0-0.404-0.132-0.474-0.341c-0.528-1.58-0.23-5.767,4.097-7.921 c1.315-0.656,2.589-0.988,3.787-0.988c3.237,0,5.096,2.341,5.99,3.465c0.158,0.199,0.181,0.533,0,0.713 c-0.793,0.794-1.852,1.542-3.231,2.286c-2.46,1.327-5.045,1.775-7.121,2.134c-1.123,0.194-2.093,0.361-2.89,0.627 C1.789,13.479,1.735,13.486,1.684,13.486L1.684,13.486z"/>
|
||||||
|
<path d="M23.185,5.465c-0.86-1.121-2.074-1.819-3.168-1.819c-0.641,0-1.556,0.23-2.273,1.328 c-0.374,0.571-0.577,1.161-0.773,1.73c-0.512,1.482-1.041,3.016-4.662,4.969c-2.316,1.249-4.707,1.664-6.815,2.03 c-2.524,0.438-4.704,0.814-5.455,2.622c-0.069,0.165-0.045,0.354,0.062,0.495c0.107,0.143,0.281,0.217,0.46,0.193 c0.667-0.081,1.533,0.041,2.434,0.217c-0.122,0.146-0.261,0.286-0.391,0.418c-0.38,0.385-0.774,0.783-0.657,1.292 c0.108,0.474,0.604,0.699,0.966,0.828c0.399,0.142,0.843,0.217,1.283,0.217c1.241,0,2.216-0.579,2.649-1.539 c1.704,0.287,3.487,0.313,5.043,0.313l1.639-0.006c0.066,0.056,0.178,0.166,0.264,0.25c0.504,0.506,1.348,1.351,2.721,1.351 c0.129,0,0.264-0.008,0.416-0.026c0.687-0.102,1.351-0.267,1.574-0.787c0.227-0.528-0.123-1.023-0.526-1.597 c-0.481-0.685-1.08-1.532-0.998-2.652c0.196-0.397,0.368-0.824,0.546-1.267c0.479-1.19,0.975-2.421,2.12-3.513 c0.431,0.343,1.022,0.549,1.63,0.549l0,0c0.439,0,0.876-0.102,1.295-0.3c0.624-0.293,1.104-0.967,1.316-1.847 C24.175,7.707,23.914,6.418,23.185,5.465L23.185,5.465z M20.397,7.757c-0.276,0-0.5-0.224-0.5-0.5s0.224-0.5,0.5-0.5 c0.275,0,0.5,0.224,0.5,0.5S20.674,7.757,20.397,7.757z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.6 KiB |
3
toolkit/themes/shared/narrate/start.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M21.64 12.44L2.827 22.895c-.217.123-.403.137-.56.042-.155-.094-.233-.264-.233-.51V1.572c0-.244.08-.414.233-.51.157-.093.343-.08.56.044L21.642 11.56c.217.124.326.27.326.44 0 .17-.11.316-.327.44z" fill="gray"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 290 B |
3
toolkit/themes/shared/narrate/stop.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<rect ry="1" rx="1" y="2" x="2" height="20" width="20" fill="gray"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 141 B |
184
toolkit/themes/shared/narrateControls.css
Normal file
@ -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;
|
||||||
|
}
|