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
+48
View File
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Chrome may return an empty list until its first voiceschanged event. Keep
// this wait inside the caller's existing deadline and cancellation.
function waitForSpeechVoices(speech, signal) {
signal.throwIfAborted();
return new Promise((resolve, reject) => {
function clean() {
speech.removeEventListener("voiceschanged", changed);
signal.removeEventListener("abort", aborted);
}
function changed() { clean(); resolve(speech.getVoices()); }
function aborted() { clean(); reject(signal.reason); }
speech.addEventListener("voiceschanged", changed, { once: true });
signal.addEventListener("abort", aborted, { once: true });
// The voice service can finish loading between the initial query and the
// listener registration; checking again avoids missing that transition.
if (speech.getVoices().length) changed();
});
}
export async function resolveSpeech(window, source, term, signal) {
const speech = window.speechSynthesis;
let voices = speech.getVoices();
if (!voices.length) voices = await waitForSpeechVoices(speech, signal);
signal.throwIfAborted();
if (voices.length === 0) throw new Error("No speech voices are available in this browser.");
const text = source.type === "text-to-speech-reading" ? term.reading || term.expression : term.expression;
const utterance = new window.SpeechSynthesisUtterance(text);
utterance.lang = "ja-JP";
const japanese = voices.filter(voice => /^ja(?:[-_]|$)/i.test(voice.lang));
const voice = source.voice
? voices.find(value => value.voiceURI === source.voice || value.name === source.voice)
: japanese.find(value => /^Google\b/i.test(value.name))
|| japanese.find(value => value.default) || japanese[0];
if (source.voice && !voice) {
throw new Error("The selected speech voice is no longer available. Choose another voice in Audio Settings.");
}
if (!voice) {
throw new Error("No Japanese speech voice is available. Install a Japanese voice or add an audio provider in Audio Settings.");
}
utterance.voice = voice;
return {
speech,
utterance,
candidate: { name: voice.name, text, voice: source.voice, index: 0 },
};
}