feat(dictionary): add Hachidori backend support

- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
This commit is contained in:
2026-09-22 00:21:19 -07:00
parent 1508863dbb
commit d9fdc7ef6d
446 changed files with 109060 additions and 244 deletions
+327
View File
@@ -0,0 +1,327 @@
// SPDX-License-Identifier: GPL-3.0-or-later
(function () {
"use strict";
function current(record) {
return record.button.isConnected && !record.popup.hidden && record.isCurrent();
}
function setBusy(record, busy) {
record.button.setAttribute("aria-busy", String(busy));
if (busy) record.button.dataset.state = "loading";
else if (record.button.dataset.state !== "error") delete record.button.dataset.state;
record.button.setAttribute("aria-label", `${busy ? "Stop" : "Play"} pronunciation for ${record.term.expression}`);
record.button.title = `${busy ? "Stop" : "Play"} pronunciation; Shift-click, right-click or press Down for choices`;
}
function createAudioController({ window, send, onMenuChange, onSelectionChange = () => {} }) {
const document = window.document;
const bound = new WeakMap(), visited = new WeakMap(), feedback = new WeakMap();
const controls = new Set();
let selections = new WeakMap();
let options = window.HDReaderOptions.DEFAULT_OPTIONS;
let sourceKey = JSON.stringify(options.audioSources);
let active = null, menu = null;
let optionsReady = false;
// One first result per owner may wait for options or until the owner's
// blurred definitions are revealed; its first-visit key stays unconsumed meanwhile.
const pendingAutoplay = new Map();
function audioAvailable() {
return options.audioSources.some(source => source.enabled
&& (source.type.startsWith("text-to-speech") || source.url.trim()));
}
function updateControls() {
const available = audioAvailable();
for (const record of controls) {
if (!record.button.isConnected) { controls.delete(record); continue; }
record.button.hidden = !available;
const control = record.button.closest(".gsm-hoshidicts-audio-control");
if (control) control.hidden = !available;
}
}
function firstVisit(record) {
if (!record.request) return false;
let keys = visited.get(record.request);
if (!keys) { keys = new Set(); visited.set(record.request, keys); }
if (keys.has(record.autoplayKey)) return false;
keys.add(record.autoplayKey);
return true;
}
// A retired hold keeps its visit: the same request may bind again and its
// reveal still settles it, while a request that is gone has no key to
// spend. A manual play consumes every waiting result.
function cancelAutoplay(owner, consumeHeld = true) {
for (const [key, record] of pendingAutoplay) {
if (owner !== undefined && key !== owner) continue;
if (consumeHeld || !record.autoplayHeld?.()) firstVisit(record);
pendingAutoplay.delete(key);
}
}
function autoplay(record) {
if (!current(record)) return;
if (!optionsReady || record.autoplayHeld?.()) {
const previous = pendingAutoplay.get(record.owner);
if (previous && previous.autoplayKey !== record.autoplayKey) firstVisit(previous);
pendingAutoplay.set(record.owner, record);
return;
}
if (firstVisit(record) && options.audioAutoplay && audioAvailable()) void play(record);
}
function owns(operation) {
return active === operation && current(operation.record);
}
function setStatus(record, text) {
if (text) record.button.dataset.state = "error";
let output = feedback.get(record.button) || record.status;
if (!output && !text) return;
if (!output) {
output = document.createElement("output");
output.className = "gsm-hoshidicts-audio-status";
output.setAttribute("aria-live", "polite");
record.button.after(output);
}
feedback.set(record.button, output);
output.textContent = text;
}
function stop() {
if (!active) return;
const previous = active;
active = null;
setBusy(previous.record, false);
setStatus(previous.record, "");
void send("hd_audio_stop", { playRequestId: previous.requestId }).catch(() => {});
}
function closeMenu(restoreFocus = true) {
if (!menu) return false;
const previous = menu;
menu = null;
if (active?.type === "hd_audio_candidates" && active.record === previous.record) stop();
previous.element.remove();
previous.record.button.setAttribute("aria-expanded", "false");
if (restoreFocus && current(previous.record)) previous.record.button.focus({ preventScroll: true });
onMenuChange(previous.record.owner);
return true;
}
function retire(owner) {
cancelAutoplay(owner, false);
if (menu && (owner === undefined || menu.record.owner === owner)) closeMenu(false);
if (active && (owner === undefined || active.record.owner === owner)) stop();
}
async function request(record, type, fields, accept) {
cancelAutoplay();
firstVisit(record);
stop();
if (!current(record) || !audioAvailable()) return;
const operation = { record, type, requestId: window.crypto.randomUUID() };
active = operation;
setBusy(record, true);
setStatus(record, "");
try {
const reply = await send(type, { term: record.term, requestId: operation.requestId, ...fields });
if (!owns(operation)) return;
if (!reply.ok) throw new Error(reply.error);
accept(reply);
} catch (error) {
if (owns(operation)) {
if (type === "hd_audio_play" && selections.get(record.result) === fields.selection) {
selections.delete(record.result);
onSelectionChange(record.owner);
}
setStatus(record, `Could not play: ${error.message}`);
if (menu?.record === record) menu.output.textContent = error.message;
}
} finally {
if (active === operation) { active = null; setBusy(record, false); }
}
}
function play(record, selection = selections.get(record.result)) {
closeMenu();
return request(record, "hd_audio_play", selection ? { selection } : {}, reply => {
setStatus(record, reply.status === "no-result" ? "No pronunciation was returned. Check Audio Settings." : "");
// The pronunciation the user just heard is the one Add to Anki should
// attach, so a downloadable recording becomes the selection exactly as
// a menu choice would. Browser speech has no recording to pin.
if (!selection && reply.status === "success" && typeof reply.sourceKey === "string"
&& typeof reply.candidate?.url === "string") {
selections.set(record.result, { sourceId: reply.sourceId, sourceKey: reply.sourceKey, ...record.term,
index: reply.candidate.index, url: reply.candidate.url, name: reply.candidate.name });
onSelectionChange(record.owner);
}
});
}
function choices(record) {
closeMenu(false);
if (!current(record) || !audioAvailable()) return;
const element = document.createElement("section");
element.className = "gsm-hoshidicts-audio-menu gsm-hoshidicts-audio-choices";
element.setAttribute("role", "dialog");
element.setAttribute("aria-label", `Pronunciation for ${record.term.expression}`);
const heading = document.createElement("strong");
heading.className = "gsm-hoshidicts-audio-menu-heading";
heading.textContent = `Pronunciation · ${record.term.expression}`;
const close = document.createElement("button");
close.type = "button";
close.className = "gsm-hoshidicts-audio-menu-item gsm-hoshidicts-audio-menu-close";
close.textContent = "Close";
close.addEventListener("click", () => closeMenu());
const output = document.createElement("p");
output.className = "gsm-hoshidicts-audio-menu-status";
output.setAttribute("role", "status");
output.textContent = "Finding choices…";
element.append(heading, close, output);
record.popup.append(element);
menu = { element, output, record };
record.button.setAttribute("aria-expanded", "true");
onMenuChange(record.owner);
close.focus({ preventScroll: true });
void request(record, "hd_audio_candidates", {}, reply => {
let count = 0;
for (const [sourceIndex, group] of reply.groups.entries()) {
const section = document.createElement("div");
const title = document.createElement("h4");
title.className = "gsm-hoshidicts-audio-menu-heading";
title.textContent = `${sourceIndex + 1}. ${window.HDReaderOptions.AUDIO_SOURCE_LABELS[group.type]}`;
section.append(title);
if (group.error) {
const error = document.createElement("p");
error.textContent = group.error;
section.append(error);
}
for (const [index, candidate] of (group.candidates || []).entries()) {
count += 1;
const button = document.createElement("button");
button.className = "gsm-hoshidicts-audio-menu-item";
button.type = "button";
button.textContent = candidate.name || `Pronunciation ${index + 1}`;
button.addEventListener("click", () => {
const selection = { sourceId: group.sourceId, sourceKey: group.sourceKey, ...record.term,
index, url: candidate.url ?? null, name: candidate.name };
selections.set(record.result, selection);
onSelectionChange(record.owner);
void play(record, selection);
});
section.append(button);
}
element.append(section);
}
setStatus(record, "");
output.textContent = count ? "Choose a pronunciation to play." : "No pronunciations found. Check Audio Settings.";
onMenuChange(record.owner);
});
}
function bind(items, context) {
if (active?.record.owner === context.owner && !current(active.record)) stop();
if (menu?.record.owner === context.owner && !current(menu.record)) closeMenu(false);
const first = items[0];
if (!first) return;
const autoplayKey = JSON.stringify([context.request?.selectedDictionaryTab, first.result.term.expression, first.result.term.reading]);
for (const item of items) {
if (bound.has(item.button)) { bound.get(item.button).autoplayKey = autoplayKey; continue; }
const record = { ...item, ...context, autoplayKey,
term: { expression: item.result.term.expression, reading: item.result.term.reading || "" } };
bound.set(item.button, record);
controls.add(record);
item.button.addEventListener("click", event => {
if (event.shiftKey) choices(record);
else if (active?.record.button === item.button) stop();
else void play(record);
});
item.button.addEventListener("contextmenu", event => { event.preventDefault(); choices(record); });
item.button.addEventListener("keydown", event => {
if (event.key === "ArrowDown") { event.preventDefault(); choices(record); }
});
}
updateControls();
autoplay(bound.get(first.button));
}
// Keybinds play the entry's pronunciation even while it is already playing,
// which a click would stop. A source ID plays that source's first choice.
function playButton(button, sourceId = "") {
const record = bound.get(button);
if (!record || !current(record) || !audioAvailable()) return false;
if (!sourceId) {
void play(record);
return true;
}
void request(record, "hd_audio_candidates", {}, reply => {
const group = reply.groups.find(item => item.sourceId === sourceId);
const candidate = group?.candidates?.[0];
if (!candidate) {
setStatus(record, "No pronunciation was returned. Check Audio Settings.");
return;
}
void play(record, { sourceId: group.sourceId, sourceKey: group.sourceKey, ...record.term,
index: 0, url: candidate.url ?? null, name: candidate.name });
});
return true;
}
const listener = message => {
if (!active || message?.target !== "hachidori-audio-content" || message.type !== "hd_audio_playing"
|| message.requestId !== active?.requestId || !current(active.record)) return;
active.record.button.dataset.state = "playing";
};
window.chrome.runtime.onMessage.addListener(listener);
// Chrome 128 can tear down the content owner before content.js receives
// pagehide. Listen here too so the offscreen player is stopped while this
// document can still identify its owned request.
const onPageHide = () => retire();
window.addEventListener("pagehide", onPageHide);
return {
bind, retire, closeMenu, playButton,
hasMenu: owner => Boolean(menu && (owner === undefined || menu.record.owner === owner)),
selectionFor: result => selections.get(result) ?? null,
// Releases the owner's held first result for exactly this request once its
// definitions are revealed. A stale request's late reveal leaves a newer view alone.
settleAutoplay(owner, request) {
const record = pendingAutoplay.get(owner);
if (!record || record.request !== request) return;
pendingAutoplay.delete(owner);
autoplay(record);
},
update(next, ready = true) {
const waiting = !optionsReady && ready ? [...pendingAutoplay] : [];
for (const [owner] of waiting) pendingAutoplay.delete(owner);
const nextKey = JSON.stringify(next.audioSources);
if (nextKey !== sourceKey) { retire(); selections = new WeakMap(); }
else if (options.audioAutoplay && !next.audioAutoplay) retire();
sourceKey = nextKey;
options = next;
optionsReady = ready;
updateControls();
for (const [owner, record] of waiting) {
pendingAutoplay.set(owner, record);
// Adopt the complete storage event, including lookup invalidation,
// before retrying a first result that rendered with unknown options.
window.queueMicrotask(() => {
if (pendingAutoplay.get(owner) !== record) return;
pendingAutoplay.delete(owner);
autoplay(record);
});
}
},
dispose() {
retire();
controls.clear();
window.removeEventListener("pagehide", onPageHide);
window.chrome.runtime.onMessage.removeListener(listener);
},
};
}
globalThis.HDAudio = { createAudioController };
}());