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