mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
- 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
115 lines
5.0 KiB
JavaScript
115 lines
5.0 KiB
JavaScript
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
import { selectExtensionApi } from "./browser-api.js";
|
|
import { createAudioPlayer } from "./audio-player.js";
|
|
import { createAudioRepository, selectedAudioPlan } from "./audio-repository.js";
|
|
|
|
const TEST_TERM = { expression: "聞く", reading: "きく" };
|
|
// Matches the reference Settings Test deadline; ordinary dictionary work never
|
|
// waits on this timer or the pronunciation's network/audio callbacks.
|
|
const TEST_TIMEOUT_MS = 15_000;
|
|
const FALLBACK_TIMEOUT_MS = 12_000;
|
|
|
|
// Identify the played source the way the candidate menu does, so the reader
|
|
// can pin the pronunciation it just played as its Anki selection.
|
|
function withSourceKey(played, sources) {
|
|
if (played.status !== "success") return played;
|
|
const source = sources.find(candidate => candidate.id === played.sourceId);
|
|
return { ...played, sourceKey: JSON.stringify(source) };
|
|
}
|
|
|
|
export function createAudioService(window, repository = createAudioRepository({ window, fetch: window.fetch.bind(window), now: () => window.performance.now() })) {
|
|
const extensionApi = selectExtensionApi(window);
|
|
const player = createAudioPlayer({ window, repository });
|
|
let active = null;
|
|
let watchingVoices = false;
|
|
|
|
function stop(reason = new DOMException("Playback stopped.", "AbortError")) {
|
|
active?.controller.abort(reason);
|
|
player.stop(reason);
|
|
}
|
|
window.addEventListener("pagehide", () => { stop(); player.dispose(); }, { once: true });
|
|
|
|
function voices() {
|
|
if (!watchingVoices) {
|
|
window.speechSynthesis.addEventListener("voiceschanged", () => {
|
|
extensionApi.runtime.sendMessage({ target: "hachidori-audio-ui", type: "hd_audio_voices_changed", voices: voices() })
|
|
.catch(() => {}); // The Settings page may already have closed.
|
|
});
|
|
watchingVoices = true;
|
|
}
|
|
return window.speechSynthesis.getVoices().map(({ voiceURI, name, lang, localService, default: isDefault }) =>
|
|
({ voiceURI, name, lang, localService, default: isDefault }));
|
|
}
|
|
|
|
async function candidateGroups(sources, term, signal) {
|
|
const groups = [];
|
|
for (const source of sources) {
|
|
const group = { sourceId: source.id, sourceKey: JSON.stringify(source), type: source.type };
|
|
try {
|
|
group.candidates = await repository.candidates(source, term, signal);
|
|
} catch (error) {
|
|
signal.throwIfAborted();
|
|
group.error = error.message;
|
|
}
|
|
groups.push(group);
|
|
}
|
|
signal.throwIfAborted();
|
|
return { groups };
|
|
}
|
|
|
|
return async message => {
|
|
if (message.type === "hd_audio_voices") return { voices: voices() };
|
|
if (message.type === "hd_audio_stop") {
|
|
if (active && active.owner === message.owner && active.requestId === message.playRequestId) stop();
|
|
return { status: "cancelled" };
|
|
}
|
|
if (!["hd_audio_test", "hd_audio_play", "hd_audio_candidates"].includes(message.type)) throw new Error("Unknown audio request.");
|
|
stop();
|
|
const operation = { owner: message.owner, requestId: message.requestId, controller: new AbortController() };
|
|
active = operation;
|
|
const isTest = message.type === "hd_audio_test";
|
|
let remaining = isTest ? TEST_TIMEOUT_MS : FALLBACK_TIMEOUT_MS;
|
|
let timer = null, armedAt;
|
|
function resumeDeadline() {
|
|
if (timer !== null) return;
|
|
armedAt = window.performance.now();
|
|
timer = window.setTimeout(() => {
|
|
if (active === operation) stop(new Error(isTest ? "Audio Test timed out after 15 seconds." : "Pronunciation discovery timed out after 12 seconds."));
|
|
}, remaining);
|
|
}
|
|
function pauseDeadline() {
|
|
if (timer === null) return;
|
|
window.clearTimeout(timer);
|
|
timer = null;
|
|
remaining = Math.max(0, remaining - (window.performance.now() - armedAt));
|
|
}
|
|
resumeDeadline();
|
|
try {
|
|
if (isTest) return await player.play(message.source, TEST_TERM);
|
|
const { signal } = operation.controller;
|
|
if (message.type === "hd_audio_candidates") return await candidateGroups(message.sources, message.term, signal);
|
|
const plan = message.selection
|
|
? await selectedAudioPlan(repository, message.sources, message.term, message.selection, signal) : { sources: message.sources };
|
|
signal.throwIfAborted();
|
|
const played = await player.playSources(plan.sources, message.term, { candidate: plan.candidate,
|
|
onResolving: resumeDeadline,
|
|
onPlaying(value) {
|
|
if (active !== operation) return;
|
|
// This bounds discovery/fallback, not the duration of a playable file.
|
|
pauseDeadline();
|
|
extensionApi.runtime.sendMessage({ target: "hachidori-audio-events", type: "hd_audio_playing",
|
|
owner: operation.owner, requestId: operation.requestId, ...value }).catch(() => {});
|
|
},
|
|
});
|
|
return withSourceKey(played, plan.sources);
|
|
} catch (error) {
|
|
if (operation.controller.signal.aborted && error?.name === "AbortError") return { status: "cancelled" };
|
|
throw error;
|
|
}
|
|
finally {
|
|
window.clearTimeout(timer);
|
|
if (active === operation) active = null;
|
|
}
|
|
};
|
|
}
|