Files
SubMiner/vendor/hachidori/extension/capture.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

146 lines
6.4 KiB
JavaScript

// SPDX-License-Identifier: GPL-3.0-or-later
// This page controls the offscreen recorder; closing it leaves capture running.
import { extensionApi as chrome } from "./browser-api.js";
const CAPTURE_TARGET = "hachidori-capture";
const elements = Object.fromEntries([...document.querySelectorAll("[id]")].map(node => [node.id, node]));
let config;
let selectedTabId = null;
let requestCounter = 0;
let pendingControl = false;
let lastVideos = "";
let lastPageStatus = "";
const describe = error => error instanceof Error ? error.message || String(error) : String(error);
async function send(type, fields = {}) {
const reply = await chrome.runtime.sendMessage({
target: CAPTURE_TARGET,
type,
requestId: `capture-${++requestCounter}`,
...fields,
});
if (!reply?.ok) throw new Error(reply?.error || "The capture service did not reply.");
return reply;
}
function render(status) {
config = status.config;
selectedTabId = status.linkedPage?.tabId ?? null;
const recording = status.state === "recording";
const linkedPage = Boolean(status.linkedPage);
const videos = status.videos ?? [];
const serializedVideos = JSON.stringify(videos);
if (serializedVideos !== lastVideos) {
elements["reading-video"].replaceChildren(...videos.map(video => new Option(video.label, video.id)));
elements["video-row"].hidden = videos.length < 2;
lastVideos = serializedVideos;
}
if (lastPageStatus !== status.pageStatus) {
elements["page-status"].textContent = status.pageStatus || "";
lastPageStatus = status.pageStatus;
}
const duration = (oldest, newest) => Number.isFinite(oldest) && Number.isFinite(newest)
? `${Math.max(0, (newest - oldest) / 1000).toFixed(1)} s` : "0.0 s";
const stateLabels = { recording: "Recording", disabled: "Disabled", stopped: "Stopped" };
elements["capture-state"].textContent = stateLabels[status.state];
elements["capture-state"].classList.toggle("recording", recording);
elements["capture-start"].disabled = pendingControl || status.starting || recording || !config?.enabled;
elements["capture-stop"].disabled = !recording && !status.starting;
elements["link-page"].disabled = !recording;
elements["select-video"].disabled = !recording || !linkedPage
|| config?.timingMode === "recent" || !config?.page.nativeCues;
elements["track-area"].disabled = !recording || !linkedPage
|| config?.timingMode === "recent" || !config?.page.domText;
elements["clear-area"].disabled = !recording || !linkedPage;
elements["media-source"].textContent = captureSourceLabel(status.mediaSource);
elements["capture-error"].textContent = status.error || "";
elements["texthooker-status"].textContent = status.texthookerStatus;
elements["video-history"].textContent = config?.includeAnimation
? `${duration(status.history.frameOldestMs, status.history.frameNewestMs)} · ${status.history.frameCount} frames · ${Math.round(status.history.frameBytes / 1024)} KiB`
: "Disabled";
let audioHistory = "Disabled";
if (status.mediaSource && !status.mediaSource.audioAvailable) audioHistory = "Source audio unavailable";
else if (config?.includeCapturedAudio) {
audioHistory = `${duration(status.history.audioOldestMs, status.history.audioNewestMs)} · ${status.history.audioSamples.toLocaleString()} samples`;
}
elements["audio-history"].textContent = audioHistory;
elements["pin-status"].textContent = status.pinActive ? "Pinned" : "None";
elements["linked-page"].textContent = status.linkedPage?.title || "No reading page is linked.";
}
function captureSourceLabel(source) {
if (!source) return "No tab, window, or screen is being captured.";
const kind = { browser: "Browser tab", window: "Application window", monitor: "Entire screen" }[source.displaySurface]
|| "Shared media";
return !source.name || /^(?:web-contents-media-stream|screen|window):/u.test(source.name)
? kind : `${source.name} · ${kind}`;
}
async function refreshTabs() {
try {
const reply = await send("hd_capture_tabs");
elements["reading-tab"].replaceChildren(...reply.tabs.map(tab => new Option(tab.title || tab.url, String(tab.id))));
if (reply.tabs.some(tab => tab.id === selectedTabId)) elements["reading-tab"].value = String(selectedTabId);
} catch (error) {
elements["page-status"].textContent = describe(error);
}
}
async function control(type) {
pendingControl = true;
elements["capture-start"].disabled = true;
elements["capture-stop"].disabled = false;
try { render(await send(type)); }
catch (error) { elements["capture-error"].textContent = describe(error); }
finally { pendingControl = false; await refreshStatus(); }
}
elements["capture-start"].addEventListener("click", () => { void control("hd_capture_start"); });
elements["capture-stop"].addEventListener("click", () => { void control("hd_capture_stop"); });
elements["reading-tab"].addEventListener("focus", () => { void refreshTabs(); });
elements["link-page"].addEventListener("click", async () => {
try {
const tabId = Number(elements["reading-tab"].value);
const reply = await send("hd_capture_link", { tabId });
selectedTabId = reply.page.tabId;
await refreshStatus();
} catch (error) {
elements["page-status"].textContent = describe(error);
}
});
elements["select-video"].addEventListener("click", async () => {
try {
await send("hd_capture_video_select", {
tabId: selectedTabId,
videoId: elements["reading-video"].value,
});
elements["page-status"].textContent = "Video source selected.";
} catch (error) {
elements["page-status"].textContent = describe(error);
}
});
elements["track-area"].addEventListener("click", async () => {
try {
await send("hd_capture_track_area", { tabId: selectedTabId });
elements["page-status"].textContent = "Choose an area on the linked page; press Escape to cancel.";
} catch (error) {
elements["page-status"].textContent = describe(error);
}
});
elements["clear-area"].addEventListener("click", async () => {
try {
await send("hd_capture_clear_area", { tabId: selectedTabId });
elements["page-status"].textContent = "Tracked text area cleared.";
} catch (error) {
elements["page-status"].textContent = describe(error);
}
});
async function refreshStatus() {
try { render(await send("hd_capture_status")); }
catch (error) { elements["capture-error"].textContent = describe(error); }
}
await refreshStatus();
await refreshTabs();
setInterval(() => { void refreshStatus(); }, 1000);