Files
SubMiner/vendor/hachidori/extension/audio-player.js
T
sudacode d9fdc7ef6d 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
2026-09-22 00:21:19 -07:00

132 lines
4.8 KiB
JavaScript

// SPDX-License-Identifier: GPL-3.0-or-later
import { createAudioRepository } from "./audio-repository.js";
import { resolveSpeech } from "./speech.js";
// One pronunciation owner; playback leases and native speech callbacks belong
// to that operation. The shared offscreen repository owns warm media, not WASM.
export function createAudioPlayer({ window, fetch, repository = createAudioRepository({ window, fetch }) }) {
let current = null;
function stop(reason = new DOMException("Playback stopped.", "AbortError")) {
current?.abort(reason);
current = null;
}
async function playUrl(candidate, signal, onPlaying) {
const lease = await repository.acquire(candidate, signal);
let audio;
let abort;
try {
signal.throwIfAborted();
audio = new window.Audio(lease.url);
const ended = new Promise((resolve, reject) => {
audio.onended = resolve;
audio.onplaying = () => { if (!signal.aborted) onPlaying?.(candidate); };
audio.onerror = () => {
lease.invalidate();
reject(new Error("The pronunciation could not be decoded or played."));
};
abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
});
await Promise.all([Promise.resolve().then(() => audio.play()), ended]);
} finally {
if (audio) {
signal.removeEventListener("abort", abort);
audio.onended = audio.onerror = audio.onplaying = null;
audio.pause();
audio.removeAttribute("src");
audio.load();
}
lease.release();
}
}
async function playSpeech(source, term, signal, onPlaying) {
const { speech, utterance, candidate } = await resolveSpeech(window, source, term, signal);
signal.throwIfAborted();
let abort;
try {
await new Promise((resolve, reject) => {
utterance.onend = resolve;
utterance.onstart = () => { if (!signal.aborted) onPlaying?.(candidate); };
utterance.onerror = event => {
const detail = event.error ? ` (${event.error})` : "";
reject(new Error(`Text-to-speech could not be played${detail}.`));
};
abort = () => {
// Cancel synchronously, before a newer operation can speak. A late
// finally calling global speech.cancel() would stop that new voice.
utterance.onend = utterance.onerror = utterance.onstart = null;
speech.cancel();
reject(signal.reason);
};
signal.addEventListener("abort", abort, { once: true });
speech.speak(utterance);
});
return candidate;
} finally {
signal.removeEventListener("abort", abort);
utterance.onend = utterance.onerror = utterance.onstart = null;
}
}
async function firstPlayable(found, signal, onPlaying, onResolving) {
let failure;
for (const [index, entry] of found.entries()) {
try {
onResolving?.();
const candidate = { ...entry, index: entry.index ?? index };
await playUrl(candidate, signal, onPlaying);
return candidate;
} catch (error) {
signal.throwIfAborted();
failure = error;
}
}
throw failure;
}
async function playSources(sources, term, { onPlaying, onResolving, candidate: selectedCandidate } = {}) {
stop();
const controller = new AbortController();
current = controller;
const { signal } = controller;
try {
let failure;
for (const source of sources) {
try {
onResolving?.();
const playing = candidate => onPlaying?.({ sourceId: source.id, candidate });
const candidate = await playSource(source, term, signal, playing, onResolving, selectedCandidate);
signal.throwIfAborted();
if (candidate) return { status: "success", sourceId: source.id, candidate };
} catch (error) {
signal.throwIfAborted();
failure = error;
}
}
if (failure) throw failure;
return { status: "no-result" };
} catch (error) {
if (signal.aborted && signal.reason?.name === "AbortError") return { status: "cancelled" };
throw signal.aborted ? signal.reason : error;
} finally {
if (current === controller) current = null;
}
}
async function playSource(source, term, signal, onPlaying, onResolving, selectedCandidate) {
if (source.type.startsWith("text-to-speech")) return playSpeech(source, term, signal, onPlaying);
const found = selectedCandidate ? [selectedCandidate] : await repository.candidates(source, term, signal);
signal.throwIfAborted();
return found.length ? firstPlayable(found, signal, onPlaying, onResolving) : null;
}
return {
stop, playSources,
play: (source, term) => playSources([source], term),
dispose() { stop(); repository.clear(); },
};
}