From 5a785c4e0f6fd39c1674a1fe48e5e499bcb0b129 Mon Sep 17 00:00:00 2001
From: Abe Jellinek <1770299+AbeJellinek@users.noreply.github.com>
Date: Wed, 29 Apr 2026 16:50:40 -0400
Subject: [PATCH] Read Aloud: Support highlight granularity customization &
timestamps
---
.../preferences/preferences_general.xhtml | 17 +++++++++
chrome/content/zotero/xpcom/reader.js | 38 ++++++++++++++++---
.../zotero/xpcom/sync/syncAPIClient.js | 25 ++++++++++--
chrome/locale/en-US/zotero/preferences.ftl | 9 +++++
defaults/preferences/zotero.js | 1 +
reader | 2 +-
6 files changed, 83 insertions(+), 9 deletions(-)
diff --git a/chrome/content/zotero/preferences/preferences_general.xhtml b/chrome/content/zotero/preferences/preferences_general.xhtml
index 8089fed245..00f7d2901c 100644
--- a/chrome/content/zotero/preferences/preferences_general.xhtml
+++ b/chrome/content/zotero/preferences/preferences_general.xhtml
@@ -222,6 +222,23 @@
+
+
+
+
+
+
+
+
+
{
@@ -662,6 +663,7 @@ class ReaderInstance {
Zotero.Prefs.registerObserver('reader.autoDisableTool.text', this._handleAutoDisableToolPrefChange),
Zotero.Prefs.registerObserver('reader.autoDisableTool.image', this._handleAutoDisableToolPrefChange),
Zotero.Prefs.registerObserver('reader.readAloudVoices', this._handleReadAloudVoicesPrefChange),
+ Zotero.Prefs.registerObserver('reader.readAloud.highlightGranularity', this._handleReadAloudHighlightGranularityChange),
];
return true;
@@ -1223,6 +1225,12 @@ class ReaderInstance {
this._internalReader.setReadAloudVoices(Cu.cloneInto(this._getReadAloudVoices(), this._iframeWindow));
};
+ _handleReadAloudHighlightGranularityChange = () => {
+ this._internalReader.setReadAloudHighlightGranularity(
+ Zotero.Prefs.get('reader.readAloud.highlightGranularity')
+ );
+ };
+
_handleReadAloudEnabledVoicesChange = async (voices) => {
if (!voices) {
voices = await this._getReadAloudEnabledVoices();
@@ -1729,7 +1737,19 @@ class ReaderInstance {
cache = await audioCache;
let cached = await cache.match(cacheURL);
if (cached) {
- resolve(Cu.cloneInto({ audio: await cached.blob() }, targetWindow));
+ // Word-level timestamps are cached in a response header
+ // alongside the audio (see below)
+ let timestamps;
+ let timestampsHeader = cached.headers.get('X-Zotero-Timestamps');
+ if (timestampsHeader) {
+ try {
+ timestamps = JSON.parse(timestampsHeader);
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
+ }
+ resolve(Cu.cloneInto({ audio: await cached.blob(), timestamps }, targetWindow));
return;
}
}
@@ -1743,7 +1763,13 @@ class ReaderInstance {
// returned with Cache-Control: no-store)
if (result.audio && cache && !result.noStore) {
try {
- await cache.put(cacheURL, new Response(result.audio));
+ // Put word-level timestamps in a header so they
+ // get cached with the audio response
+ let headers = {};
+ if (result.timestamps) {
+ headers['X-Zotero-Timestamps'] = JSON.stringify(result.timestamps);
+ }
+ await cache.put(cacheURL, new Response(result.audio, { headers }));
}
catch (e) {
Zotero.logError(e);
@@ -1775,7 +1801,7 @@ class ReaderInstance {
// cacheVersion is included so that a server-side version bump changes the
// key, causing old entries to miss and the correct audio to be re-fetched.
_getReadAloudCacheURL(segment, voice) {
- let params = { voice: voice.id, text: segment.text, cacheVersion: voice.cacheVersion };
+ let params = { voice: voice.id, text: segment.text, cacheVersion: voice.cacheVersion, timestamps: 1 };
return 'https://read-aloud.zotero.invalid/audio?' + new URLSearchParams(params);
}
@@ -1797,8 +1823,10 @@ class ReaderInstance {
}
let cache = await audioCache;
for (let request of await cache.keys()) {
- let version = new URL(request.url).searchParams.get('cacheVersion');
- if (!validVersions.has(version)) {
+ let params = new URL(request.url).searchParams;
+ // Drop entries stored in the pre-timestamp format, plus those
+ // whose cacheVersion is no longer offered by the server
+ if (params.get('timestamps') !== '1' || !validVersions.has(params.get('cacheVersion'))) {
await cache.delete(request);
}
}
diff --git a/chrome/content/zotero/xpcom/sync/syncAPIClient.js b/chrome/content/zotero/xpcom/sync/syncAPIClient.js
index c33b73375a..5931e41d90 100644
--- a/chrome/content/zotero/xpcom/sync/syncAPIClient.js
+++ b/chrome/content/zotero/xpcom/sync/syncAPIClient.js
@@ -672,13 +672,16 @@ Zotero.Sync.APIClient.prototype = {
let method;
let url;
let options = {
- responseType: "blob",
+ // Could be audio bytes or a JSON { audioURL, timestamps } object
+ // Inspect the Content-Type before parsing
+ responseType: "arraybuffer",
errorDelayMax: 8000,
};
if (segment === 'sample') {
method = "GET";
let params = new URLSearchParams();
params.set('voice', voiceID);
+ params.set('timestamps', '1');
url = this.baseURL + "tts/sample?" + params;
options.noAPIKey = true;
}
@@ -691,20 +694,36 @@ Zotero.Sync.APIClient.prototype = {
options.body = JSON.stringify({
voice: voiceID,
text: segment.text,
+ timestamps: 1,
});
}
try {
let xmlhttp = await this.makeRequest(method, url, options);
+ let contentType = xmlhttp.getResponseHeader('Content-Type') || '';
let cacheControl = xmlhttp.getResponseHeader('Cache-Control') || '';
let noStore = /(?:^|,)\s*no-store\s*(?:,|$)/i.test(cacheControl);
- return { audio: xmlhttp.response, noStore };
+ if (contentType.includes('application/json')) {
+ let json = JSON.parse(new TextDecoder().decode(xmlhttp.response));
+ let audioResponse = await Zotero.HTTP.request('GET', json.audioURL, {
+ responseType: "blob",
+ errorDelayMax: 8000,
+ });
+ return { audio: audioResponse.response, timestamps: json.timestamps, noStore };
+ }
+ return { audio: new Blob([xmlhttp.response], { type: contentType }), noStore };
}
catch (e) {
Zotero.logError(e);
let error;
if (e instanceof Zotero.HTTP.UnexpectedStatusException && e.status === 402) {
- let body = await e.xmlhttp.response?.text();
+ let body = null;
+ try {
+ body = new TextDecoder().decode(e.xmlhttp.response);
+ }
+ catch {
+ // Ignore
+ }
error = body === 'daily_limit_exceeded' ? 'daily-limit-exceeded' : 'quota-exceeded';
}
else if (e instanceof Zotero.HTTP.BrowserOfflineException) {
diff --git a/chrome/locale/en-US/zotero/preferences.ftl b/chrome/locale/en-US/zotero/preferences.ftl
index 987465ec69..3e4f318a6e 100644
--- a/chrome/locale/en-US/zotero/preferences.ftl
+++ b/chrome/locale/en-US/zotero/preferences.ftl
@@ -28,6 +28,15 @@ preferences-reader-ebook-font = Ebook font:
preferences-reader-ebook-hyphenate =
.label = Enable automatic hyphenation
+preferences-read-aloud-title = Read Aloud
+preferences-read-aloud-highlight-granularity = While reading, highlight:
+preferences-read-aloud-highlight-granularity-paragraph =
+ .label = Paragraphs
+preferences-read-aloud-highlight-granularity-sentence =
+ .label = Sentences
+preferences-read-aloud-highlight-granularity-word =
+ .label = Words
+
preferences-note-title = Notes
preferences-note-open-in-new-window =
.label = Open notes in new windows instead of tabs
diff --git a/defaults/preferences/zotero.js b/defaults/preferences/zotero.js
index 6ecac853dd..02d8aaa8ce 100644
--- a/defaults/preferences/zotero.js
+++ b/defaults/preferences/zotero.js
@@ -249,6 +249,7 @@ pref("extensions.zotero.reader.autoDisableTool.note", true);
pref("extensions.zotero.reader.autoDisableTool.text", true);
pref("extensions.zotero.reader.autoDisableTool.image", true);
pref("extensions.zotero.reader.lastSidebarTab", "annotations");
+pref("extensions.zotero.reader.readAloud.highlightGranularity", "sentence");
// Set color scheme to auto by default
pref("browser.theme.toolbar-theme", 2);
diff --git a/reader b/reader
index 8cb2963207..c12c65e3f0 160000
--- a/reader
+++ b/reader
@@ -1 +1 @@
-Subproject commit 8cb29632079c26a920d08590643d7242c72c1986
+Subproject commit c12c65e3f01414ae244f6102da4028c700cf6584