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
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import { createAudioCache } from "./audio-cache.js";
import { audioSourceUrl, parseAudioSourceList } from "./audio-sources.js";
export async function selectedAudioPlan(repository, sources, term, selection, signal) {
const source = sources.find(source => source.id === selection.sourceId && JSON.stringify(source) === selection.sourceKey);
if (!source || selection.expression !== term.expression || selection.reading !== term.reading) {
throw new Error("This pronunciation selection is no longer current. Choose it again.");
}
const candidates = await repository.candidates(source, term, signal);
const candidate = candidates[selection.index];
if (!candidate || (candidate.url ?? null) !== selection.url || candidate.name !== selection.name) {
throw new Error("The provider's pronunciation choices changed. Choose again.");
}
return { sources: [source], candidate: { ...candidate, index: selection.index } };
}
export function createAudioRepository({ window, fetch, now = () => performance.now() }) {
// GSM PR #549's retention budgets, not input or playback size limits.
const candidates = createAudioCache({ maxEntries: 256, maxBytes: 2 * 1024 * 1024, ttlMs: 5 * 60_000, now });
const media = createAudioCache({ maxEntries: 64, maxBytes: 64 * 1024 * 1024, ttlMs: 30 * 60_000, now,
onEvict(entry) { entry.retained = false; releaseUnused(entry); } });
const encoder = new TextEncoder();
function releaseUnused(entry) {
if (!entry.retained && entry.users === 0) window.URL.revokeObjectURL(entry.url);
}
async function response(url, signal) {
const result = await fetch(url, { credentials: "omit", signal });
if (!result.ok) throw new Error(`Audio provider returned HTTP ${result.status}.`);
return result;
}
return {
async candidates(source, term, signal) {
signal.throwIfAborted();
const key = JSON.stringify([source, term.expression, term.reading]);
const cached = candidates.get(key);
if (cached) return cached;
let found = [];
if (source.type.startsWith("text-to-speech")) found = [{ name: source.voice || "Automatic Japanese" }];
else if (source.url) {
const url = audioSourceUrl(source.url, term);
if (source.type === "custom") found = [{ url, name: "" }];
else found = parseAudioSourceList(await (await response(url, signal)).json());
}
signal.throwIfAborted();
candidates.set(key, found, encoder.encode(key).byteLength + encoder.encode(JSON.stringify(found)).byteLength);
return found;
},
async acquire(candidate, signal) {
signal.throwIfAborted();
let entry = media.get(candidate.url);
if (entry) entry.users += 1;
else {
const blob = await (await response(candidate.url, signal)).blob();
signal.throwIfAborted();
entry = { blob, url: window.URL.createObjectURL(blob), users: 1, retained: false };
entry.retained = media.set(candidate.url, entry, blob.size + encoder.encode(candidate.url).byteLength);
}
return {
url: entry.url,
blob: entry.blob,
release() { entry.users -= 1; releaseUnused(entry); },
invalidate() { if (media.get(candidate.url) === entry) media.delete(candidate.url); },
};
},
clear() { candidates.clear(); media.clear(); },
};
}