add reader amoled mode; improve reader mode ui;
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
||||
/* 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/. */
|
||||
|
||||
// This background script is needed to update the current tab
|
||||
// and activate reader view.
|
||||
|
||||
browser.runtime.onMessage.addListener(message => {
|
||||
switch (message.action) {
|
||||
case "addSerializedDoc":
|
||||
browser.storage.session.set({ [message.id]: message.doc });
|
||||
return Promise.resolve();
|
||||
case "getSerializedDoc":
|
||||
return (async () => {
|
||||
let doc = await browser.storage.session.get(message.id);
|
||||
browser.storage.session.remove(message.id);
|
||||
return doc[message.id];
|
||||
})();
|
||||
default:
|
||||
console.error(`Received unsupported action ${message.action}`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
/* 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/. */
|
||||
|
||||
/* WebLibre: this file overrides the upstream Mozilla Android Components reader
|
||||
* view stylesheet (org.mozilla.components:feature-readerview). It is identical
|
||||
* to upstream except for the AMOLED / pure-black additions, which are gated on
|
||||
* the `amoled` body class. That class is toggled by readerview.js based on the
|
||||
* WebLibre "pure black" setting (see ReaderViewAppearanceFeature on the native
|
||||
* side). When upgrading mozilla-components, re-sync this file with upstream and
|
||||
* re-apply the AMOLED rules below. */
|
||||
|
||||
.mozac-readerview-body {
|
||||
padding: 20px;
|
||||
transition-property: background-color, color;
|
||||
transition-duration: 0.4s;
|
||||
max-width: 35em;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.light {
|
||||
background-color: #ffffff;
|
||||
color: #222222;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.sepia {
|
||||
color: #5b4636;
|
||||
background-color: #f4ecd8;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.dark {
|
||||
background-color: #1c1b22;
|
||||
color: #eeeeee;
|
||||
}
|
||||
|
||||
/* WebLibre AMOLED / pure-black: only applies on top of the dark scheme when the
|
||||
* WebLibre "pure black" setting is enabled. */
|
||||
.mozac-readerview-body.dark.amoled {
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.light * {
|
||||
color: #222222;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.sepia * {
|
||||
color: #5b4636;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.dark * {
|
||||
color: #eeeeee;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.sans-serif * {
|
||||
font-family: sans-serif !important;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.serif * {
|
||||
font-family: serif !important;
|
||||
}
|
||||
|
||||
/* Override some controls and content styles based on color scheme */
|
||||
|
||||
.mozac-readerview-body.light > .container > .header > .domain {
|
||||
color: #ee7600;
|
||||
border-bottom-color: #d0d0d0;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.light > .container > .header > h1 {
|
||||
color: #222222;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.light > .container > .header > .credits {
|
||||
color: #898989;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.dark > .container > .header > .domain {
|
||||
color: #ff9400;
|
||||
border-bottom-color: #777777;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.dark > .container > .header > h1 {
|
||||
color: #eeeeee;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.dark > .container > .header > .credits {
|
||||
color: #aaaaaa;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.sepia > .container > .header > .domain {
|
||||
border-bottom-color: #5b4636 !important;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.sepia > .container > .footer {
|
||||
background-color: #dedad4 !important;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.light > .container > .content .caption,
|
||||
.mozac-readerview-body.light > .container > .content .wp-caption-text,
|
||||
.mozac-readerview-body.light > .container > .content figcaption {
|
||||
color: #898989;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.dark > .container > .content .caption,
|
||||
.mozac-readerview-body.dark > .container > .content .wp-caption-text,
|
||||
.mozac-readerview-body.dark > .container > .content figcaption {
|
||||
color: #aaaaaa;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.light > .container > .content blockquote {
|
||||
color: #898989 !important;
|
||||
border-left-color: #d0d0d0 !important;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.sepia blockquote {
|
||||
border-inline-start: 2px solid #5b4636 !important;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.dark > .container > .content blockquote {
|
||||
color: #aaaaaa !important;
|
||||
border-left-color: #777777 !important;
|
||||
}
|
||||
|
||||
.mozac-readerview-body > .container > hr {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.mozac-readerview-body > .container > .header {
|
||||
text-align: start;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.mozac-readerview-body > .container > .header > .credits {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.mozac-readerview-body > .container > .header > .domain {
|
||||
margin-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
color: #00acff !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mozac-readerview-body > .container > .header > .domain-border {
|
||||
margin-top: 15px;
|
||||
border-bottom: 1.5px solid #777777;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.mozac-readerview-body > .container > .header > h1 {
|
||||
font-size: 1.33em;
|
||||
font-weight: 700;
|
||||
line-height: 1.1em;
|
||||
width: 100%;
|
||||
margin: 0px;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 16px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.mozac-readerview-body > .container > .header > .credits {
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.mozac-readerview-body > .container > .header > .meta-data {
|
||||
font-size: 0.65em;
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.mozac-readerview-body > .container > .content {
|
||||
padding-top: 10px;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
}
|
||||
|
||||
/*======= Article content =======*/
|
||||
.mozac-readerview-content {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.mozac-readerview-content a {
|
||||
text-decoration: underline !important;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.mozac-readerview-body.dark :is(
|
||||
.mozac-readerview-content a,
|
||||
.mozac-readerview-content a:hover,
|
||||
.mozac-readerview-content a:active
|
||||
):not(.mozac-readerview-content a:visited) {
|
||||
color: #45a1ff !important;
|
||||
}
|
||||
|
||||
.mozac-readerview-content a,
|
||||
.mozac-readerview-content a:hover,
|
||||
.mozac-readerview-content a:active
|
||||
:not(.mozac-readerview-content a:visited) {
|
||||
color: #0060df !important;
|
||||
}
|
||||
|
||||
.mozac-readerview-content a:visited {
|
||||
color: #b5007f !important;
|
||||
}
|
||||
|
||||
.mozac-readerview-content h1 {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 700;
|
||||
font-size: 1.6em;
|
||||
}
|
||||
|
||||
.mozac-readerview-content h2 {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 700;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.mozac-readerview-content h3 {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 700;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.mozac-readerview-content * {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.mozac-readerview-content p {
|
||||
font-size: 1em !important;
|
||||
line-height: 1.4em !important;
|
||||
margin: 0px !important;
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
|
||||
/* Covers all images showing edge-to-edge using a
|
||||
an optional caption text */
|
||||
.mozac-readerview-content .wp-caption,
|
||||
.mozac-readerview-content figure {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
margin: 0px !important;
|
||||
margin-bottom: 32px !important;
|
||||
}
|
||||
|
||||
/* Images marked to be shown edge-to-edge with an
|
||||
optional captio ntext */
|
||||
.mozac-readerview-content p > img:only-child,
|
||||
.mozac-readerview-content p > a:only-child > img:only-child,
|
||||
.mozac-readerview-content .wp-caption img,
|
||||
.mozac-readerview-content figure img {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Account for body padding to make image full width */
|
||||
.mozac-readerview-content img[moz-reader-full-width] {
|
||||
width: calc(100% + 40px);
|
||||
margin-left: -20px;
|
||||
margin-right: -20px;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* Image caption text */
|
||||
.mozac-readerview-content .caption,
|
||||
.mozac-readerview-content .wp-caption-text,
|
||||
.mozac-readerview-content figcaption {
|
||||
font-size: 0.9em;
|
||||
font-family: sans-serif;
|
||||
margin: 0px !important;
|
||||
padding-top: 4px !important;
|
||||
}
|
||||
|
||||
/* Ensure all pre-formatted code inside the reader content
|
||||
are properly wrapped inside content width */
|
||||
.mozac-readerview-content code,
|
||||
.mozac-readerview-content pre {
|
||||
white-space: pre-wrap !important;
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
|
||||
.mozac-readerview-content blockquote {
|
||||
margin: 0px !important;
|
||||
margin-bottom: 20px !important;
|
||||
padding: 0px !important;
|
||||
padding-inline-start: 16px !important;
|
||||
border: 0px !important;
|
||||
border-left: 2px solid !important;
|
||||
}
|
||||
|
||||
.mozac-readerview-content ul,
|
||||
.mozac-readerview-content ol {
|
||||
margin: 0px !important;
|
||||
margin-bottom: 20px !important;
|
||||
padding: 0px !important;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
.mozac-readerview-content ul {
|
||||
padding-inline-start: 30px !important;
|
||||
list-style: disc !important;
|
||||
}
|
||||
|
||||
.mozac-readerview-content ol {
|
||||
padding-inline-start: 35px !important;
|
||||
list-style: decimal !important;
|
||||
}
|
||||
|
||||
/* Hide elements with common "hidden" class names */
|
||||
.mozac-readerview-content .visually-hidden,
|
||||
.mozac-readerview-content .visuallyhidden,
|
||||
.mozac-readerview-content .hidden,
|
||||
.mozac-readerview-content .invisible,
|
||||
.mozac-readerview-content .sr-only {
|
||||
}
|
||||
|
||||
/* Enforce wordpress and similar emoji/smileys aren't sized to be full-width,
|
||||
* see bug 1399616 for context. */
|
||||
.mozac-readerview-content img.wp-smiley,
|
||||
.mozac-readerview-content img.emoji {
|
||||
display: inline-block;
|
||||
border-width: 0;
|
||||
/* height: auto is implied from `.mozac-readerview-content *` rule. */
|
||||
width: 1em;
|
||||
margin: 0 .07em;
|
||||
padding: 0;
|
||||
}
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
/* 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/. */
|
||||
|
||||
/* eslint-disable no-unsanitized/property */ /* bug 1903144 */
|
||||
/* import-globals-from readability/readability-0.4.2.js */
|
||||
/* import-globals-from readability/JSDOMParser-0.4.2.js */
|
||||
|
||||
/* WebLibre: this file overrides the upstream Mozilla Android Components reader
|
||||
* view script (org.mozilla.components:feature-readerview). It is identical to
|
||||
* upstream except for the AMOLED / pure-black additions (search for "WebLibre").
|
||||
* The native side (ReaderViewAppearanceFeature) pushes the WebLibre "pure black"
|
||||
* flag over a dedicated content port ("weblibreReaderviewActive") whenever a
|
||||
* reader page connects and whenever the user toggles the setting. The flag is
|
||||
* reflected as the `amoled` body class, which readerview.css uses to render the
|
||||
* dark scheme as pure black. We also cache it in browser.storage.local so a
|
||||
* freshly opened reader page can apply the last known value before the native
|
||||
* push arrives (avoiding a flash). When upgrading mozilla-components, re-sync
|
||||
* with upstream and re-apply the WebLibre additions. */
|
||||
|
||||
// Class names to preserve in the readerized output. We preserve these class
|
||||
// names so that rules in readerview.css can match them. This list is taken from Fennec:
|
||||
// https://dxr.mozilla.org/mozilla-central/rev/7d47e7fa2489550ffa83aae67715c5497048923f/toolkit/components/reader/ReaderMode.jsm#21
|
||||
const preservedClasses = [
|
||||
"caption",
|
||||
"emoji",
|
||||
"hidden",
|
||||
"invisible",
|
||||
"sr-only",
|
||||
"visually-hidden",
|
||||
"visuallyhidden",
|
||||
"wp-caption",
|
||||
"wp-caption-text",
|
||||
"wp-smiley",
|
||||
];
|
||||
|
||||
// WebLibre: key in browser.storage.local caching the last known "pure black"
|
||||
// flag, and the native content port the flag is pushed over.
|
||||
const WEBLIBRE_AMOLED_STORAGE_KEY = "amoled";
|
||||
const WEBLIBRE_APPEARANCE_PORT = "weblibreReaderviewActive";
|
||||
|
||||
// WebLibre: last known "pure black" flag. Updated by the native push and the
|
||||
// storage cache; re-applied whenever the body element is (re)created.
|
||||
let weblibreAmoled = false;
|
||||
|
||||
class ReaderView {
|
||||
static get MIN_FONT_SIZE() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
static get MAX_FONT_SIZE() {
|
||||
return 9;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a reader view for the provided document. This method is used when activating
|
||||
* reader view on the original page. In this case, we already have the DOM (passed
|
||||
* through in the message from the background script) and can parse it directly.
|
||||
*
|
||||
* @param doc the document to make readerable.
|
||||
* @param url the url of the article.
|
||||
* @param options the fontSize, fontType and colorScheme to use.
|
||||
*/
|
||||
show(
|
||||
doc,
|
||||
url,
|
||||
options = { fontSize: 4, fontType: "sans-serif", colorScheme: "light" }
|
||||
) {
|
||||
let result = new Readability(doc, {
|
||||
classesToPreserve: preservedClasses,
|
||||
}).parse();
|
||||
result.language = doc.documentElement.lang;
|
||||
document.title = result.title;
|
||||
|
||||
let article = Object.assign(
|
||||
result,
|
||||
{ url: new URL(url) },
|
||||
{ readingTime: this.getReadingTime(result.length, result.language) },
|
||||
{ byline: this.getByline(result) },
|
||||
{ dir: this.getTextDirection(result) },
|
||||
{ title: this.getTitle(result) }
|
||||
);
|
||||
|
||||
document.body.outerHTML = this.createHtmlBody(article);
|
||||
|
||||
this.setFontSize(options.fontSize);
|
||||
this.setFontType(options.fontType);
|
||||
this.setColorScheme(options.colorScheme);
|
||||
// WebLibre: re-apply the pure-black flag. createHtmlBody() replaced the body
|
||||
// element (dropping any class set in prepareBody), so re-apply it here.
|
||||
weblibreApplyAmoled(weblibreAmoled);
|
||||
if (options.scrollY) {
|
||||
window.scrollTo({ top: options.scrollY, left: 0, behavior: "instant" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows adjusting the font size in discrete steps between ReaderView.MIN_FONT_SIZE
|
||||
* and ReaderView.MAX_FONT_SIZE.
|
||||
*
|
||||
* @param changeAmount e.g. +1, or -1.
|
||||
*/
|
||||
changeFontSize(changeAmount) {
|
||||
var size = Math.max(
|
||||
ReaderView.MIN_FONT_SIZE,
|
||||
Math.min(ReaderView.MAX_FONT_SIZE, this.fontSize + changeAmount)
|
||||
);
|
||||
this.setFontSize(size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the font size.
|
||||
*
|
||||
* @param fontSize must be value between ReaderView.MIN_FONT_SIZE
|
||||
* and ReaderView.MAX_FONT_SIZE.
|
||||
*/
|
||||
setFontSize(fontSize) {
|
||||
let size = 10 + 2 * fontSize + "px";
|
||||
let readerView = document.getElementById("mozac-readerview-container");
|
||||
readerView.style.setProperty("font-size", size);
|
||||
this.fontSize = fontSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the font type.
|
||||
*
|
||||
* @param fontType the font type to use.
|
||||
*/
|
||||
setFontType(fontType) {
|
||||
let bodyClasses = document.body.classList;
|
||||
|
||||
if (this.fontType) {
|
||||
bodyClasses.remove(this.fontType);
|
||||
}
|
||||
|
||||
this.fontType = fontType;
|
||||
bodyClasses.add(this.fontType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the color scheme.
|
||||
*
|
||||
* @param colorScheme the color scheme to use, must be either light, dark
|
||||
* or sepia.
|
||||
*/
|
||||
setColorScheme(colorScheme) {
|
||||
if (!["light", "sepia", "dark"].includes(colorScheme)) {
|
||||
console.error(`Invalid color scheme specified: ${colorScheme}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let bodyClasses = document.body.classList;
|
||||
|
||||
if (this.colorScheme) {
|
||||
bodyClasses.remove(this.colorScheme);
|
||||
}
|
||||
|
||||
this.colorScheme = colorScheme;
|
||||
bodyClasses.add(this.colorScheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the reader view HTML body.
|
||||
*
|
||||
* @param article a JSONObject representing the article to show.
|
||||
*/
|
||||
createHtmlBody(article) {
|
||||
const safeDir = this.escapeHTML(article.dir);
|
||||
const safeTitle = this.escapeHTML(article.title);
|
||||
const safeByline = this.escapeHTML(article.byline);
|
||||
const safeReadingTime = this.escapeHTML(article.readingTime);
|
||||
return `
|
||||
<body class="mozac-readerview-body">
|
||||
<div id="mozac-readerview-container" class="container" dir="${safeDir}">
|
||||
<div class="header">
|
||||
<a class="domain" href="${article.url.href}">${article.url.hostname}</a>
|
||||
<div class="domain-border"></div>
|
||||
<h1>${safeTitle}</h1>
|
||||
<div class="credits">${safeByline}</div>
|
||||
<div>
|
||||
<div>${safeReadingTime}</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<div class="content">
|
||||
<div class="mozac-readerview-content">${article.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the estimated reading time as localized string.
|
||||
*
|
||||
* @param length of the article (number of chars).
|
||||
* @param optional language of the article, defaults to en.
|
||||
*/
|
||||
getReadingTime(length, lang = "en") {
|
||||
const [readingSpeed, readingSpeedLang] =
|
||||
this.getReadingSpeedForLanguage(lang);
|
||||
const charactersPerMinuteLow = readingSpeed.cpm - readingSpeed.variance;
|
||||
const charactersPerMinuteHigh = readingSpeed.cpm + readingSpeed.variance;
|
||||
const readingTimeMinsSlow = Math.ceil(length / charactersPerMinuteLow);
|
||||
const readingTimeMinsFast = Math.ceil(length / charactersPerMinuteHigh);
|
||||
|
||||
// Construct a localized and "humanized" reading time in minutes.
|
||||
// If we have both a fast and slow reading time we'll show both e.g.
|
||||
// "2 - 4 minutes", otherwise we'll just show "4 minutes".
|
||||
try {
|
||||
var parts = new Intl.RelativeTimeFormat(readingSpeedLang).formatToParts(
|
||||
readingTimeMinsSlow,
|
||||
"minute"
|
||||
);
|
||||
if (parts.length == 3) {
|
||||
// No need to use part[0] which represents the literal "in".
|
||||
var readingTime = parts[1].value; // reading time in minutes
|
||||
var minutesLiteral = parts[2].value; // localized singular or plural literal of 'minute'
|
||||
var readingTimeString = `${readingTime} ${minutesLiteral}`;
|
||||
if (readingTimeMinsSlow != readingTimeMinsFast) {
|
||||
readingTimeString = `${readingTimeMinsFast} - ${readingTimeString}`;
|
||||
}
|
||||
return readingTimeString;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to format reading time: ${error}`);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the reading speed of a selection of languages with likely variance.
|
||||
*
|
||||
* Reading speed estimated from a study done on reading speeds in various languages.
|
||||
* study can be found here: http://iovs.arvojournals.org/article.aspx?articleid=2166061
|
||||
*
|
||||
* @return object with characters per minute and variance. Defaults to English
|
||||
* if no suitable language is found in the collection.
|
||||
*/
|
||||
getReadingSpeedForLanguage(lang) {
|
||||
const readingSpeed = new Map([
|
||||
["en", { cpm: 987, variance: 118 }],
|
||||
["ar", { cpm: 612, variance: 88 }],
|
||||
["de", { cpm: 920, variance: 86 }],
|
||||
["es", { cpm: 1025, variance: 127 }],
|
||||
["fi", { cpm: 1078, variance: 121 }],
|
||||
["fr", { cpm: 998, variance: 126 }],
|
||||
["he", { cpm: 833, variance: 130 }],
|
||||
["it", { cpm: 950, variance: 140 }],
|
||||
["ja", { cpm: 357, variance: 56 }],
|
||||
["nl", { cpm: 978, variance: 143 }],
|
||||
["pl", { cpm: 916, variance: 126 }],
|
||||
["pt", { cpm: 913, variance: 145 }],
|
||||
["ru", { cpm: 986, variance: 175 }],
|
||||
["sl", { cpm: 885, variance: 145 }],
|
||||
["sv", { cpm: 917, variance: 156 }],
|
||||
["tr", { cpm: 1054, variance: 156 }],
|
||||
["zh", { cpm: 255, variance: 29 }],
|
||||
]);
|
||||
|
||||
return readingSpeed.has(lang)
|
||||
? [readingSpeed.get(lang), lang]
|
||||
: [readingSpeed.get("en"), "en"];
|
||||
}
|
||||
|
||||
getByline(article) {
|
||||
return article.byline || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to read the optional text direction from the article and uses
|
||||
* language mapping to detect rtl, if missing.
|
||||
*/
|
||||
getTextDirection(article) {
|
||||
if (article.dir) {
|
||||
return article.dir;
|
||||
}
|
||||
|
||||
if (["ar", "fa", "he", "ug", "ur"].includes(article.language)) {
|
||||
return "rtl";
|
||||
}
|
||||
|
||||
return "ltr";
|
||||
}
|
||||
|
||||
getTitle(article) {
|
||||
return article.title || "";
|
||||
}
|
||||
|
||||
escapeHTML(text) {
|
||||
return text
|
||||
.replace(/\&/g, "&")
|
||||
.replace(/\</g, "<")
|
||||
.replace(/\>/g, ">")
|
||||
.replace(/\"/g, """)
|
||||
.replace(/\'/g, "'");
|
||||
}
|
||||
}
|
||||
|
||||
function fetchDocument(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", url, true);
|
||||
xhr.onerror = evt => reject(evt.error);
|
||||
xhr.responseType = "document";
|
||||
xhr.onload = _evt => {
|
||||
if (xhr.status !== 200) {
|
||||
reject("Reader mode XHR failed with status: " + xhr.status);
|
||||
return;
|
||||
}
|
||||
let doc = xhr.responseXML;
|
||||
if (!doc) {
|
||||
reject("Reader mode XHR didn't return a document");
|
||||
return;
|
||||
}
|
||||
resolve(doc);
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
function getPreparedDocument(id, url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
browser.runtime
|
||||
.sendMessage({ action: "getSerializedDoc", id })
|
||||
.then(serializedDoc => {
|
||||
if (serializedDoc) {
|
||||
// eslint-disable-next-line no-undef
|
||||
let doc = new JSDOMParser().parse(serializedDoc, url);
|
||||
resolve(doc);
|
||||
} else {
|
||||
reject();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* WebLibre: applies the given pure-black flag to the body as the `amoled` class.
|
||||
* readerview.css only acts on it when combined with the dark scheme. The latest
|
||||
* value is remembered so it can be re-applied after the body is recreated.
|
||||
*/
|
||||
function weblibreApplyAmoled(enabled) {
|
||||
weblibreAmoled = !!enabled;
|
||||
if (document.body) {
|
||||
document.body.classList.toggle("amoled", weblibreAmoled);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebLibre: applies the cached pure-black flag from extension storage. This is a
|
||||
* fast path so a freshly opened reader page can paint with the right background
|
||||
* before the authoritative native push arrives over the appearance port.
|
||||
*/
|
||||
function weblibreApplyCachedAmoled() {
|
||||
try {
|
||||
browser.storage.local
|
||||
.get(WEBLIBRE_AMOLED_STORAGE_KEY)
|
||||
.then(result => weblibreApplyAmoled(result[WEBLIBRE_AMOLED_STORAGE_KEY]))
|
||||
.catch(() => {});
|
||||
} catch (e) {
|
||||
// browser.storage may be unavailable in some contexts; ignore.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebLibre: connects the appearance port. The native side pushes the current
|
||||
* pure-black flag on connect and whenever the user toggles the setting, so this
|
||||
* works both for newly opened reader pages and for live toggles while a reader
|
||||
* page is already on screen. The value is cached for the next page load.
|
||||
*/
|
||||
function weblibreConnectAppearancePort() {
|
||||
try {
|
||||
let port = browser.runtime.connectNative(WEBLIBRE_APPEARANCE_PORT);
|
||||
port.onMessage.addListener(message => {
|
||||
if (message && typeof message.amoled !== "undefined") {
|
||||
weblibreApplyAmoled(message.amoled);
|
||||
try {
|
||||
browser.storage.local.set({
|
||||
[WEBLIBRE_AMOLED_STORAGE_KEY]: weblibreAmoled,
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore cache write failures
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`WebLibre reader appearance port failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
let readerView = new ReaderView();
|
||||
connectNativePort();
|
||||
weblibreConnectAppearancePort();
|
||||
prepareBody();
|
||||
|
||||
function connectNativePort() {
|
||||
let url = new URL(window.location.href);
|
||||
let articleUrl = url.searchParams.get("url");
|
||||
let id = url.searchParams.get("id");
|
||||
let baseUrl = browser.runtime.getURL("/");
|
||||
|
||||
let port = browser.runtime.connectNative("mozacReaderviewActive");
|
||||
port.onMessage.addListener(message => {
|
||||
switch (message.action) {
|
||||
case "show": {
|
||||
async function showAsync(options) {
|
||||
try {
|
||||
let doc;
|
||||
if (typeof Promise.any === "function") {
|
||||
doc = await Promise.any([
|
||||
fetchDocument(articleUrl),
|
||||
getPreparedDocument(id, articleUrl),
|
||||
]);
|
||||
} else {
|
||||
try {
|
||||
doc = await getPreparedDocument(id, articleUrl);
|
||||
} catch (e) {
|
||||
doc = await fetchDocument(articleUrl);
|
||||
}
|
||||
}
|
||||
readerView.show(doc, articleUrl, options);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(e);
|
||||
// We weren't able to find the prepared document and also
|
||||
// failed to fetch it. Let's load the original page which
|
||||
// will make sure we show an appropriate error page.
|
||||
window.location.href = articleUrl;
|
||||
}
|
||||
}
|
||||
showAsync(message.value);
|
||||
break;
|
||||
}
|
||||
case "hide":
|
||||
window.location.href = articleUrl;
|
||||
break;
|
||||
case "setColorScheme":
|
||||
readerView.setColorScheme(message.value.toLowerCase());
|
||||
break;
|
||||
case "changeFontSize":
|
||||
readerView.changeFontSize(message.value);
|
||||
break;
|
||||
case "setFontType":
|
||||
readerView.setFontType(message.value.toLowerCase());
|
||||
break;
|
||||
case "checkReaderState":
|
||||
port.postMessage({
|
||||
baseUrl,
|
||||
activeUrl: articleUrl,
|
||||
readerable: true,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.error(`Received invalid action ${message.action}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the configured color scheme to the HTML body while reader view is loading. This is to
|
||||
* prevent "flashes" caused by having to change the color later.
|
||||
*/
|
||||
function prepareBody() {
|
||||
let url = new URL(window.location.href);
|
||||
let colorScheme = url.searchParams.get("colorScheme");
|
||||
let body = document.createElement("body");
|
||||
body.classList.add("mozac-readerview-body");
|
||||
body.classList.add(colorScheme);
|
||||
document.body = body;
|
||||
// WebLibre: apply the cached pure-black flag as early as possible to avoid a
|
||||
// flash from the regular dark background to pure black once the article loads.
|
||||
// The native push over the appearance port corrects it if the cache is stale.
|
||||
weblibreApplyCachedAmoled();
|
||||
}
|
||||
+8
-5
@@ -5,10 +5,12 @@
|
||||
package eu.weblibre.flutter_mozilla_components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import eu.weblibre.flutter_mozilla_components.feature.ContainerProxyFeature
|
||||
import eu.weblibre.flutter_mozilla_components.feature.CookieManagerFeature
|
||||
import eu.weblibre.flutter_mozilla_components.feature.BrowserExtensionFeature
|
||||
import eu.weblibre.flutter_mozilla_components.feature.MLEngineFeature
|
||||
import eu.weblibre.flutter_mozilla_components.feature.ReaderViewAppearanceFeature
|
||||
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents
|
||||
@@ -135,11 +137,12 @@ object EngineProvider {
|
||||
|
||||
SandboxCaptureFeature.install(it)
|
||||
|
||||
BuiltInWebExtensionController(
|
||||
"readerview@mozac.org",
|
||||
"resource://android/assets/extensions/readerview/",
|
||||
"mozacReaderview",
|
||||
).install(it)
|
||||
// Installs Mozilla's reader view extension early and wires the
|
||||
// WebLibre "pure black" (AMOLED) appearance bridge into it.
|
||||
ReaderViewAppearanceFeature.install(
|
||||
it,
|
||||
PreferenceManager.getDefaultSharedPreferences(context),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
@@ -132,6 +132,26 @@ object GlobalComponents {
|
||||
val bottomViewportInsetPx: Int
|
||||
get() = (dynamicToolbarMaxHeightPx + verticalClippingPx).coerceAtLeast(0)
|
||||
|
||||
// Listeners notified when [bottomViewportInsetPx] may have changed (i.e. when
|
||||
// the dynamic toolbar height or vertical clipping is updated). Lets native
|
||||
// views that align to the bottom chrome (e.g. the reader view controls bar)
|
||||
// re-apply their inset while visible, not only when first shown.
|
||||
private val bottomViewportInsetListeners =
|
||||
java.util.concurrent.CopyOnWriteArraySet<(Int) -> Unit>()
|
||||
|
||||
fun addBottomViewportInsetListener(listener: (Int) -> Unit) {
|
||||
bottomViewportInsetListeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeBottomViewportInsetListener(listener: (Int) -> Unit) {
|
||||
bottomViewportInsetListeners.remove(listener)
|
||||
}
|
||||
|
||||
fun notifyBottomViewportInsetChanged() {
|
||||
val inset = bottomViewportInsetPx
|
||||
bottomViewportInsetListeners.forEach { it(inset) }
|
||||
}
|
||||
|
||||
// External download manager setting
|
||||
var useExternalDownloadManager: Boolean = false
|
||||
|
||||
|
||||
+15
@@ -11,6 +11,7 @@ import androidx.preference.PreferenceManager
|
||||
import eu.weblibre.flutter_mozilla_components.ColorSchemePreference
|
||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||
import eu.weblibre.flutter_mozilla_components.R
|
||||
import eu.weblibre.flutter_mozilla_components.feature.ReaderViewAppearanceFeature
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode as PigeonBounceTrackingProtectionMode
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.ColorScheme
|
||||
@@ -501,4 +502,18 @@ class GeckoEngineSettingsApiImpl : GeckoEngineSettingsApi {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setReaderViewPureBlack(enabled: Boolean) {
|
||||
// Push to every tab whose reader view is currently active so the change
|
||||
// applies live, without depending on a single tracked session.
|
||||
val activeReaderSessions = components.core.store.state.tabs
|
||||
.filter { it.readerState.active }
|
||||
.mapNotNull { it.engineState.engineSession }
|
||||
|
||||
ReaderViewAppearanceFeature.setPureBlack(
|
||||
enabled,
|
||||
components.core.prefs,
|
||||
activeReaderSessions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -37,6 +37,7 @@ class GeckoViewportApiImpl : GeckoViewportApi {
|
||||
override fun setDynamicToolbarMaxHeight(heightPx: Long) {
|
||||
val height = heightPx.toInt()
|
||||
GlobalComponents.dynamicToolbarMaxHeightPx = height
|
||||
GlobalComponents.notifyBottomViewportInsetChanged()
|
||||
|
||||
val engineView = components.mainBrowserEngineView
|
||||
if (engineView == null) {
|
||||
@@ -67,6 +68,7 @@ class GeckoViewportApiImpl : GeckoViewportApi {
|
||||
override fun setVerticalClipping(clippingPx: Long) {
|
||||
val clipping = clippingPx.toInt()
|
||||
GlobalComponents.verticalClippingPx = clipping
|
||||
GlobalComponents.notifyBottomViewportInsetChanged()
|
||||
|
||||
val engineView = components.mainBrowserEngineView
|
||||
if (engineView == null) {
|
||||
|
||||
+37
@@ -9,6 +9,7 @@ package eu.weblibre.flutter_mozilla_components.components
|
||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||
import eu.weblibre.flutter_mozilla_components.api.ReaderViewEventsImpl
|
||||
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
|
||||
import eu.weblibre.flutter_mozilla_components.feature.ReaderViewAppearanceFeature
|
||||
import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.ExternalApplicationResource
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.FindResultState
|
||||
@@ -160,6 +161,42 @@ class Events(
|
||||
}
|
||||
}
|
||||
|
||||
// Register the WebLibre "pure black" appearance content port whenever a
|
||||
// tab's reader view becomes active. Store-driven (rather than the user's
|
||||
// reader toggle) so it also covers reader views restored on app start.
|
||||
//
|
||||
// Keyed on both readerState.active AND the engine session: a restored
|
||||
// reader tab can already be active before its engine session is linked,
|
||||
// and the active flag never changes afterwards — so we must also react to
|
||||
// the session becoming available to register on the right session.
|
||||
stateFlow.flowScoped(dispatcher = Dispatchers.Main) { flow ->
|
||||
flow.mapNotNull { state -> state.tabs }
|
||||
.filterChanged { it.readerState.active to it.engineState.engineSession }
|
||||
.collect { tab ->
|
||||
if (tab.readerState.active) {
|
||||
tab.engineState.engineSession?.let { session ->
|
||||
ReaderViewAppearanceFeature.registerSession(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the reader appearance (font/settings) button in sync with the
|
||||
// selected tab's actual reader-active state. Driven by the store rather
|
||||
// than the user's reader toggle (ReaderViewIntegration) so the button
|
||||
// also appears for reader views restored on app start ("resume last tab"),
|
||||
// which never go through an explicit toggle.
|
||||
stateFlow.flowScoped(dispatcher = Dispatchers.Main) { flow ->
|
||||
flow.map { state -> state.selectedTab?.readerState?.active ?: false }
|
||||
.distinctUntilChanged()
|
||||
.collect { active ->
|
||||
GlobalComponents.components?.readerViewController?.appearanceButtonVisibility(
|
||||
EventSequence.next(),
|
||||
active,
|
||||
) { _ -> }
|
||||
}
|
||||
}
|
||||
|
||||
stateFlow.flowScoped(dispatcher = Dispatchers.Main) { flow ->
|
||||
flow.mapNotNull { state -> state.tabs }
|
||||
.filterChanged {
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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 https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package eu.weblibre.flutter_mozilla_components.feature
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.content.edit
|
||||
import mozilla.components.concept.engine.EngineSession
|
||||
import mozilla.components.concept.engine.webextension.MessageHandler
|
||||
import mozilla.components.concept.engine.webextension.Port
|
||||
import mozilla.components.concept.engine.webextension.WebExtensionRuntime
|
||||
import mozilla.components.support.base.log.logger.Logger
|
||||
import mozilla.components.support.webextensions.BuiltInWebExtensionController
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Bridges WebLibre's "pure black" setting into Mozilla's reader view extension so
|
||||
* the dark color scheme can be rendered as pure black (AMOLED).
|
||||
*
|
||||
* Flutter remains the source of truth for the setting (see
|
||||
* general_settings.dart `pureBlack` and engine_settings_replication.dart).
|
||||
* Mozilla's reader view extension (`readerview@mozac.org`) is a prebuilt part of
|
||||
* `org.mozilla.components:feature-readerview`; we override its bundled assets
|
||||
* (readerview.css / readerview.js) to honor an `amoled` body class.
|
||||
*
|
||||
* The flag is delivered into the reader page over a dedicated **content** port
|
||||
* ([APPEARANCE_PORT]) registered on the reader tab's engine session. We use a
|
||||
* content port (registered whenever a tab's reader view becomes active, see
|
||||
* [registerSession]) rather than the extension's background script because the
|
||||
* reader extension's background is a non-persistent event page that gets
|
||||
* suspended when idle, dropping pushes; the reader page (and therefore its
|
||||
* content port) is alive exactly while reader view is on screen. The value is
|
||||
* pushed when the page connects ([MessageHandler.onPortConnected]) and again
|
||||
* whenever the user toggles the setting ([setPureBlack]).
|
||||
*
|
||||
* Registration is driven by the BrowserStore reader-active state (see
|
||||
* Events.kt), not the user's reader toggle, so it also covers reader views that
|
||||
* were restored on app start ("resume last tab") without an explicit toggle.
|
||||
*
|
||||
* The value is also persisted in [SharedPreferences] so it survives process
|
||||
* restarts (e.g. a cold-started Custom Tab / PWA reader view before Flutter has
|
||||
* pushed the setting).
|
||||
*/
|
||||
object ReaderViewAppearanceFeature {
|
||||
private val logger = Logger("reader-view-appearance")
|
||||
|
||||
private const val EXTENSION_ID = "readerview@mozac.org"
|
||||
private const val EXTENSION_URL = "resource://android/assets/extensions/readerview/"
|
||||
private const val APPEARANCE_PORT = "weblibreReaderviewActive"
|
||||
|
||||
private const val PREF_KEY = "weblibre_reader_pure_black"
|
||||
private const val MESSAGE_KEY_AMOLED = "amoled"
|
||||
|
||||
@Volatile
|
||||
private var pureBlack: Boolean = false
|
||||
|
||||
@VisibleForTesting
|
||||
// Internal var to make it mutable for unit testing purposes only.
|
||||
internal var extensionController = BuiltInWebExtensionController(
|
||||
EXTENSION_ID,
|
||||
EXTENSION_URL,
|
||||
APPEARANCE_PORT,
|
||||
)
|
||||
|
||||
private val messageHandler = object : MessageHandler {
|
||||
override fun onPortConnected(port: Port) {
|
||||
// Push the current value as soon as the reader page connects, so it
|
||||
// can correct the cached value it applied on load.
|
||||
port.postMessage(currentMessage())
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentMessage(): JSONObject =
|
||||
JSONObject().put(MESSAGE_KEY_AMOLED, pureBlack)
|
||||
|
||||
/**
|
||||
* Installs Mozilla's reader view extension early and seeds the persisted
|
||||
* value. The reader view feature itself (from android-components) reuses the
|
||||
* already-installed extension via the shared built-in extension registry, so
|
||||
* installing it here does not interfere with its lifecycle.
|
||||
*/
|
||||
fun install(runtime: WebExtensionRuntime, prefs: SharedPreferences) {
|
||||
pureBlack = prefs.getBoolean(PREF_KEY, false)
|
||||
|
||||
extensionController.install(
|
||||
runtime,
|
||||
onSuccess = {
|
||||
logger.debug("Installed reader view extension: ${it.id}")
|
||||
},
|
||||
onError = { throwable ->
|
||||
logger.error("Failed to install reader view extension", throwable)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the appearance content port on an engine session whose reader
|
||||
* view has become active. Idempotent (safe to call again for the same
|
||||
* session). Pushes the current value immediately if the port is already
|
||||
* connected (e.g. a restored reader page that connected before this ran);
|
||||
* otherwise [MessageHandler.onPortConnected] pushes it once the page connects.
|
||||
*/
|
||||
fun registerSession(session: EngineSession) {
|
||||
extensionController.registerContentMessageHandler(
|
||||
session,
|
||||
messageHandler,
|
||||
APPEARANCE_PORT,
|
||||
)
|
||||
|
||||
pushTo(session)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the pure-black flag, persists it, and pushes it to every currently
|
||||
* active reader page so the change is reflected immediately without having to
|
||||
* re-enter reader view.
|
||||
*
|
||||
* @param sessions the engine sessions of all tabs whose reader view is active.
|
||||
*/
|
||||
fun setPureBlack(enabled: Boolean, prefs: SharedPreferences, sessions: List<EngineSession>) {
|
||||
pureBlack = enabled
|
||||
prefs.edit { putBoolean(PREF_KEY, enabled) }
|
||||
|
||||
sessions.forEach { pushTo(it) }
|
||||
}
|
||||
|
||||
private fun pushTo(session: EngineSession) {
|
||||
if (extensionController.portConnected(session, APPEARANCE_PORT)) {
|
||||
extensionController.sendContentMessage(currentMessage(), session, APPEARANCE_PORT)
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
-2
@@ -8,7 +8,11 @@ package eu.weblibre.flutter_mozilla_components.integration
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
|
||||
import eu.weblibre.flutter_mozilla_components.api.ReaderViewEventsImpl
|
||||
@@ -27,12 +31,18 @@ class ReaderViewIntegration(
|
||||
context: Context,
|
||||
engine: Engine,
|
||||
store: BrowserStore,
|
||||
view: ReaderViewControlsView,
|
||||
private val view: ReaderViewControlsView,
|
||||
private val readerViewEvents: ReaderViewEventsImpl,
|
||||
readerViewController: ReaderViewController
|
||||
) : LifecycleAwareFeature, UserInteractionHandler {
|
||||
private var listenerRegistered = false
|
||||
|
||||
// Re-applies the controls bar inset whenever the bottom chrome changes (tab
|
||||
// bar shown/hidden, stacking mode, etc.) while the controls are on screen.
|
||||
private val bottomInsetListener: (Int) -> Unit = {
|
||||
applyControlsBarBottomInset(onlyIfVisible = true)
|
||||
}
|
||||
|
||||
private val controllerListener = object : ReaderViewControllerListener {
|
||||
override fun onReaderViewToggled(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
@@ -48,10 +58,38 @@ class ReaderViewIntegration(
|
||||
}
|
||||
|
||||
override fun onAppearanceButtonTap() {
|
||||
feature.showControls()
|
||||
// Toggle: tapping the appearance button while the controls are open
|
||||
// closes them again, as the user expects.
|
||||
if ((view as? View)?.visibility == View.VISIBLE) {
|
||||
feature.hideControls()
|
||||
} else {
|
||||
applyControlsBarBottomInset(onlyIfVisible = false)
|
||||
feature.showControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifts the reader controls bar above the current bottom chrome (Flutter
|
||||
* bottom app bar + system navigation inset) instead of a hardcoded padding,
|
||||
* which under edge-to-edge left the bar overlapped by the bottom app bar.
|
||||
* [GlobalComponents.bottomViewportInsetPx] already includes the nav inset
|
||||
* when the toolbar is visible; fall back to the nav inset alone otherwise.
|
||||
*
|
||||
* @param onlyIfVisible when true, skips bars that aren't currently shown
|
||||
* (used by the live inset listener; the bar is re-padded when next shown).
|
||||
*/
|
||||
private fun applyControlsBarBottomInset(onlyIfVisible: Boolean) {
|
||||
val barView = view as? View ?: return
|
||||
if (onlyIfVisible && barView.visibility != View.VISIBLE) return
|
||||
|
||||
val navInset = ViewCompat.getRootWindowInsets(barView)
|
||||
?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
|
||||
barView.updatePadding(
|
||||
bottom = maxOf(GlobalComponents.bottomViewportInsetPx, navInset),
|
||||
)
|
||||
}
|
||||
|
||||
private val feature = ReaderViewFeature(context, engine, store, view)
|
||||
// Will be event based in flutter
|
||||
// { available, active ->
|
||||
@@ -65,6 +103,7 @@ class ReaderViewIntegration(
|
||||
override fun start() {
|
||||
if (!listenerRegistered) {
|
||||
readerViewEvents.addListener(controllerListener)
|
||||
GlobalComponents.addBottomViewportInsetListener(bottomInsetListener)
|
||||
listenerRegistered = true
|
||||
}
|
||||
feature.start()
|
||||
@@ -73,6 +112,7 @@ class ReaderViewIntegration(
|
||||
override fun stop() {
|
||||
if (listenerRegistered) {
|
||||
readerViewEvents.removeListener(controllerListener)
|
||||
GlobalComponents.removeBottomViewportInsetListener(bottomInsetListener)
|
||||
listenerRegistered = false
|
||||
}
|
||||
feature.stop()
|
||||
|
||||
+25
@@ -7285,6 +7285,13 @@ interface GeckoEngineSettingsApi {
|
||||
* during startup/replication restore, to avoid clobbering per-tab overrides.
|
||||
*/
|
||||
fun setGlobalDesktopMode(enable: Boolean, applyToExistingTabs: Boolean)
|
||||
/**
|
||||
* Sets whether the reader view dark color scheme should be rendered as pure
|
||||
* black (AMOLED). Mirrors WebLibre's "pure black" theme setting into
|
||||
* Mozilla's reader view extension. Persisted in SharedPreferences so a
|
||||
* cold-started reader view resolves the right value before Flutter runs.
|
||||
*/
|
||||
fun setReaderViewPureBlack(enabled: Boolean)
|
||||
|
||||
companion object {
|
||||
/** The codec used by GeckoEngineSettingsApi. */
|
||||
@@ -7452,6 +7459,24 @@ interface GeckoEngineSettingsApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setReaderViewPureBlack$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val enabledArg = args[0] as Boolean
|
||||
val wrapped: List<Any?> = try {
|
||||
api.setReaderViewPureBlack(enabledArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
GeckoPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -29,6 +29,9 @@
|
||||
<!-- Inject engine here -->
|
||||
</mozilla.components.ui.widgets.VerticalSwipeRefreshLayout>
|
||||
|
||||
<!-- paddingBottom is applied dynamically in ReaderViewIntegration to
|
||||
clear the Flutter bottom app bar + system navigation inset under
|
||||
edge-to-edge; do not hardcode it here. -->
|
||||
<mozilla.components.feature.readerview.view.ReaderViewControlsBar
|
||||
android:id="@+id/readerViewBar"
|
||||
android:layout_width="match_parent"
|
||||
@@ -36,7 +39,6 @@
|
||||
android:layout_gravity="bottom"
|
||||
android:background="#FFFFFFFF"
|
||||
android:elevation="10dp"
|
||||
android:paddingBottom="55dp"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
|
||||
@@ -230,4 +230,8 @@ class GeckoEngineSettingsService {
|
||||
}) {
|
||||
return _api.setGlobalDesktopMode(enable, applyToExistingTabs);
|
||||
}
|
||||
|
||||
Future<void> setReaderViewPureBlack(bool enabled) {
|
||||
return _api.setReaderViewPureBlack(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7386,6 +7386,28 @@ class GeckoEngineSettingsApi {
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
/// Sets whether the reader view dark color scheme should be rendered as pure
|
||||
/// black (AMOLED). Mirrors WebLibre's "pure black" theme setting into
|
||||
/// Mozilla's reader view extension. Persisted in SharedPreferences so a
|
||||
/// cold-started reader view resolves the right value before Flutter runs.
|
||||
Future<void> setReaderViewPureBlack(bool enabled) async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setReaderViewPureBlack$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[enabled]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
_extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
class GeckoSessionApi {
|
||||
|
||||
@@ -1571,6 +1571,12 @@ abstract class GeckoEngineSettingsApi {
|
||||
/// in place). This should only be requested for an explicit user toggle, not
|
||||
/// during startup/replication restore, to avoid clobbering per-tab overrides.
|
||||
void setGlobalDesktopMode(bool enable, bool applyToExistingTabs);
|
||||
|
||||
/// Sets whether the reader view dark color scheme should be rendered as pure
|
||||
/// black (AMOLED). Mirrors WebLibre's "pure black" theme setting into
|
||||
/// Mozilla's reader view extension. Persisted in SharedPreferences so a
|
||||
/// cold-started reader view resolves the right value before Flutter runs.
|
||||
void setReaderViewPureBlack(bool enabled);
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
|
||||
Reference in New Issue
Block a user