Read Aloud: Support highlight granularity customization & timestamps

This commit is contained in:
Abe Jellinek 2026-04-29 16:50:40 -04:00
parent ccbeab61e2
commit 5a785c4e0f
6 changed files with 83 additions and 9 deletions

View file

@ -222,6 +222,23 @@
</vbox>
</groupbox>
<groupbox id="zotero-prefpane-read-aloud-groupbox" aria-labelledby="preferences-read-aloud-title">
<label><html:h2 id="preferences-read-aloud-title" data-l10n-id="preferences-read-aloud-title"/></label>
<hbox align="center">
<label data-l10n-id="preferences-read-aloud-highlight-granularity" control="read-aloud-highlight-granularity-menulist"/>
<menulist id="read-aloud-highlight-granularity-menulist"
preference="extensions.zotero.reader.readAloud.highlightGranularity"
native="true">
<menupopup>
<menuitem data-l10n-id="preferences-read-aloud-highlight-granularity-paragraph" value="paragraph"/>
<menuitem data-l10n-id="preferences-read-aloud-highlight-granularity-sentence" value="sentence"/>
<menuitem data-l10n-id="preferences-read-aloud-highlight-granularity-word" value="word"/>
</menupopup>
</menulist>
</hbox>
</groupbox>
<groupbox id="zotero-prefpane-note-groupbox" aria-labelledby="preferences-note-title">
<label><html:h2 id="preferences-note-title" data-l10n-id="preferences-note-title"/></label>
<checkbox id="open-note-in-new-window"

View file

@ -264,6 +264,7 @@ class ReaderInstance {
readAloudVoices: this._getReadAloudVoices(),
readAloudEnabledVoices: await this._getReadAloudEnabledVoices(),
readAloudRemoteInterface: this._getReadAloudRemoteInterface(this._iframeWindow),
readAloudHighlightGranularity: Zotero.Prefs.get('reader.readAloud.highlightGranularity'),
getSDTPack: this._createGetSDTPack(this._iframeWindow),
loggedIn: Zotero.Sync.Runner.enabled,
onOpenContextMenu: () => {
@ -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);
}
}

View file

@ -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) {

View file

@ -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

View file

@ -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);

2
reader

@ -1 +1 @@
Subproject commit 8cb29632079c26a920d08590643d7242c72c1986
Subproject commit c12c65e3f01414ae244f6102da4028c700cf6584