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
146 lines
6.4 KiB
JavaScript
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);
|