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
722 lines
25 KiB
JavaScript
722 lines
25 KiB
JavaScript
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
(function () {
|
||
"use strict";
|
||
|
||
const CAPTURE_TARGET = "hachidori-capture";
|
||
const CONTENT_TARGET = "hachidori-capture-content";
|
||
const MAX_TEXT_LENGTH = 4096;
|
||
const MAX_LINES = 1000;
|
||
const TYPEWRITER_GAP_MS = 750;
|
||
const TYPEWRITER_GROWTH_LIMIT = 12;
|
||
const INLINE_DISPLAY_PATTERN = /^(?:inline|ruby|contents)/u;
|
||
const OMIT_TEXT_SELECTOR = [
|
||
"button", "input", "select", "textarea", "[contenteditable]",
|
||
"rt", "rp", "script", "style", "noscript", "hachidori-host",
|
||
].join(",");
|
||
const videoIds = new WeakMap();
|
||
const trackIds = new WeakMap();
|
||
const cueIds = new WeakMap();
|
||
let nextVideoId = 0;
|
||
let nextTrackId = 0;
|
||
let nextCueId = 0;
|
||
let nextRequestId = 0;
|
||
let nextLineId = 0;
|
||
let linked = false;
|
||
let linkedDocumentId = null;
|
||
let options = null;
|
||
let documentEpoch = crypto.randomUUID();
|
||
let selectedVideoCleanup = null;
|
||
let trackedElement = null;
|
||
let trackedEpoch = "";
|
||
let trackedLines = [];
|
||
let trackedObserver = null;
|
||
let trackedVisibilityObserver = null;
|
||
let trackedMountObserver = null;
|
||
let trackedQueued = false;
|
||
let pickerCleanup = null;
|
||
let rootPin = null;
|
||
let rootPinTail = Promise.resolve();
|
||
|
||
const now = () => performance.timeOrigin + performance.now();
|
||
const normalize = value => typeof value === "string"
|
||
? value.normalize("NFC").replace(/\s+/gu, " ").trim() : "";
|
||
|
||
async function send(type, fields = {}) {
|
||
const reply = await chrome.runtime.sendMessage({
|
||
target: CAPTURE_TARGET,
|
||
type,
|
||
requestId: `capture-content-${++nextRequestId}`,
|
||
...fields,
|
||
});
|
||
if (!reply?.ok) throw new Error(reply?.error || "The capture service did not reply.");
|
||
return reply;
|
||
}
|
||
|
||
async function identify(captureSessionId) {
|
||
return send("hd_capture_content_identify", { captureSessionId });
|
||
}
|
||
|
||
function report(message, fields = {}) {
|
||
void send("hd_capture_page_status", { message, ...fields }).catch(() => {});
|
||
}
|
||
|
||
function videoId(video) {
|
||
let id = videoIds.get(video);
|
||
if (!id) {
|
||
id = `video-${++nextVideoId}`;
|
||
videoIds.set(video, id);
|
||
}
|
||
return id;
|
||
}
|
||
|
||
function videoTracks(video) {
|
||
const tracks = [];
|
||
try {
|
||
for (const track of video.textTracks ?? []) {
|
||
if (track.mode !== "disabled") tracks.push(track);
|
||
}
|
||
} catch {
|
||
return [];
|
||
}
|
||
return tracks;
|
||
}
|
||
|
||
function activeTrackCues(track) {
|
||
try {
|
||
return [...(track.activeCues ?? [])];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function trackId(track) {
|
||
let id = trackIds.get(track);
|
||
if (!id) {
|
||
id = `track-${++nextTrackId}`;
|
||
trackIds.set(track, id);
|
||
}
|
||
return id;
|
||
}
|
||
|
||
function videos() {
|
||
return [...document.querySelectorAll("video")].map((video, index) => ({
|
||
id: videoId(video),
|
||
label: String(video.getAttribute("aria-label") || video.title
|
||
|| `Video ${index + 1} (${video.videoWidth || "?"}×${video.videoHeight || "?"})`).slice(0, 200),
|
||
trackCount: videoTracks(video).length,
|
||
}));
|
||
}
|
||
|
||
function cueId(cue) {
|
||
if (cue.id) return String(cue.id).slice(0, 160);
|
||
let id = cueIds.get(cue);
|
||
if (!id) {
|
||
id = `cue-${++nextCueId}`;
|
||
cueIds.set(cue, id);
|
||
}
|
||
return id;
|
||
}
|
||
|
||
function emitBegin(record) {
|
||
void send("hd_capture_text_begin", { record }).catch(() => {});
|
||
}
|
||
|
||
function emitClose(identity, endMs = now()) {
|
||
void send("hd_capture_text_close", { identity, endMs }).catch(() => {});
|
||
}
|
||
|
||
function attachVideo(video) {
|
||
selectedVideoCleanup?.();
|
||
selectedVideoCleanup = null;
|
||
if (!video) return;
|
||
const attachmentEpoch = crypto.randomUUID();
|
||
let epochCounter = 0;
|
||
let sourceEpoch = "";
|
||
let interrupted = true;
|
||
const active = new Map();
|
||
const cleanups = [];
|
||
const trackCleanups = new Map();
|
||
|
||
function identity(id) {
|
||
return { sourceKind: "cue", sourceEpoch, occurrenceId: id };
|
||
}
|
||
|
||
function closeAll(at = now()) {
|
||
for (const id of active.keys()) emitClose(identity(id), at);
|
||
active.clear();
|
||
}
|
||
|
||
function resetEpoch(at = now()) {
|
||
closeAll(at);
|
||
sourceEpoch = `video:${videoId(video)}:${attachmentEpoch}:${++epochCounter}`;
|
||
}
|
||
|
||
function sync(onsetKnown) {
|
||
const at = now();
|
||
if (video.paused || video.seeking || video.ended || !video.isConnected) {
|
||
closeAll(at);
|
||
return;
|
||
}
|
||
const next = new Map();
|
||
for (const track of videoTracks(video)) {
|
||
for (const cue of activeTrackCues(track)) {
|
||
const text = String(cue.text ?? "");
|
||
if (!normalize(text) || text.length > MAX_TEXT_LENGTH) continue;
|
||
const id = `${trackId(track)}:${cueId(cue)}`;
|
||
next.set(id, text);
|
||
if (active.get(id) !== text) {
|
||
emitBegin({
|
||
sourceKind: "cue",
|
||
sourceEpoch,
|
||
occurrenceId: id,
|
||
text,
|
||
startMs: at,
|
||
onsetKnown,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
for (const id of active.keys()) if (!next.has(id)) emitClose(identity(id), at);
|
||
active.clear();
|
||
for (const entry of next) active.set(...entry);
|
||
}
|
||
|
||
function listen(target, type, listener) {
|
||
target.addEventListener(type, listener);
|
||
cleanups.push(() => target.removeEventListener(type, listener));
|
||
}
|
||
|
||
function bindTracks() {
|
||
const available = new Set(videoTracks(video));
|
||
for (const [track, cleanup] of trackCleanups) {
|
||
if (available.has(track)) continue;
|
||
cleanup();
|
||
trackCleanups.delete(track);
|
||
}
|
||
for (const track of available) {
|
||
if (trackCleanups.has(track)) continue;
|
||
const listener = () => sync(true);
|
||
track.addEventListener("cuechange", listener);
|
||
trackCleanups.set(track, () => track.removeEventListener("cuechange", listener));
|
||
}
|
||
}
|
||
|
||
function interrupt() {
|
||
closeAll(now());
|
||
interrupted = true;
|
||
}
|
||
|
||
function resume() {
|
||
if (video.paused || video.seeking || video.ended || !video.isConnected) return;
|
||
if (interrupted) resetEpoch();
|
||
interrupted = false;
|
||
sync(false);
|
||
}
|
||
|
||
function resetAndSync() {
|
||
resetEpoch();
|
||
bindTracks();
|
||
interrupted = video.paused || video.seeking || video.ended;
|
||
if (!interrupted) sync(false);
|
||
}
|
||
|
||
resetEpoch();
|
||
bindTracks();
|
||
listen(video, "play", resume);
|
||
listen(video, "pause", interrupt);
|
||
listen(video, "seeking", interrupt);
|
||
listen(video, "seeked", resetAndSync);
|
||
listen(video, "ended", interrupt);
|
||
listen(video, "emptied", () => { resetEpoch(); interrupted = true; });
|
||
listen(video, "loadedmetadata", resetAndSync);
|
||
if (video.textTracks?.addEventListener) {
|
||
listen(video.textTracks, "addtrack", resetAndSync);
|
||
listen(video.textTracks, "removetrack", resetAndSync);
|
||
listen(video.textTracks, "change", resetAndSync);
|
||
}
|
||
resume();
|
||
selectedVideoCleanup = () => {
|
||
closeAll();
|
||
for (const cleanup of trackCleanups.values()) cleanup();
|
||
trackCleanups.clear();
|
||
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
|
||
};
|
||
}
|
||
|
||
function renderedElement(element, requireLayout = false, style = null) {
|
||
if (!element?.isConnected || element.hidden || element.getAttribute("aria-hidden") === "true"
|
||
|| element.closest("hachidori-host")) return false;
|
||
style ??= getComputedStyle(element);
|
||
return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse"
|
||
&& Number(style.opacity) !== 0 && (!requireLayout || element.getClientRects().length > 0);
|
||
}
|
||
|
||
function visible(element) {
|
||
if (!renderedElement(element, true)) return false;
|
||
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
|
||
if (!renderedElement(ancestor)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function validTrackedElement(element) {
|
||
return element instanceof Element && !["HTML", "BODY"].includes(element.tagName)
|
||
&& !element.closest("input, textarea, select, button, [contenteditable], hachidori-host");
|
||
}
|
||
|
||
function domRangeFor(boundaries, previous) {
|
||
if (previous?.startContainer === boundaries.startContainer && previous.startOffset === boundaries.startOffset
|
||
&& previous.endContainer === boundaries.endContainer && previous.endOffset === boundaries.endOffset) {
|
||
return previous;
|
||
}
|
||
const range = document.createRange();
|
||
range.setStart(boundaries.startContainer, boundaries.startOffset);
|
||
range.setEnd(boundaries.endContainer, boundaries.endOffset);
|
||
return range;
|
||
}
|
||
|
||
function extractLines(element) {
|
||
if (!visible(element)) return [];
|
||
const lines = [];
|
||
let text = "";
|
||
let range = null;
|
||
const lineBreak = () => {
|
||
const normalized = normalize(text);
|
||
if (normalized && lines.length < MAX_LINES) {
|
||
lines.push({ text: normalized.slice(0, MAX_TEXT_LENGTH),
|
||
range: domRangeFor(range, trackedLines[lines.length]?.range) });
|
||
}
|
||
text = "";
|
||
range = null;
|
||
};
|
||
function appendPart(node, value, offset) {
|
||
range ??= { startContainer: node, startOffset: offset };
|
||
range.endContainer = node;
|
||
range.endOffset = offset + value.length;
|
||
text += value;
|
||
}
|
||
function appendText(node) {
|
||
const value = node.nodeValue || "";
|
||
if (!/[\r\n]/u.test(value)) {
|
||
appendPart(node, value, 0);
|
||
return;
|
||
}
|
||
for (const part of value.matchAll(/[^\r\n]+|[\r\n]+/gu)) {
|
||
if (/^[\r\n]/u.test(part[0])) {
|
||
lineBreak();
|
||
continue;
|
||
}
|
||
appendPart(node, part[0], part.index);
|
||
}
|
||
}
|
||
function visit(node, root = false) {
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
appendText(node);
|
||
return;
|
||
}
|
||
if (!(node instanceof Element) || (!root && node.matches(OMIT_TEXT_SELECTOR))) return;
|
||
const style = getComputedStyle(node);
|
||
if (!renderedElement(node, false, style)) return;
|
||
if (node.tagName === "BR") {
|
||
lineBreak();
|
||
return;
|
||
}
|
||
const block = !root && !INLINE_DISPLAY_PATTERN.test(style.display);
|
||
if (block) lineBreak();
|
||
for (const child of node.childNodes) visit(child);
|
||
if (block) lineBreak();
|
||
}
|
||
visit(element, true);
|
||
lineBreak();
|
||
return lines;
|
||
}
|
||
|
||
function domIdentity(line) {
|
||
return { sourceKind: "dom", sourceEpoch: trackedEpoch, occurrenceId: line.id };
|
||
}
|
||
|
||
function closeTrackedLines(at = now()) {
|
||
for (const line of trackedLines) emitClose(domIdentity(line), at);
|
||
trackedLines = [];
|
||
}
|
||
|
||
function isTypewriterContinuation(previous, text, at) {
|
||
if (!previous || !text || text === previous.text || !text.startsWith(previous.text)) return false;
|
||
return Array.from(text.slice(previous.text.length)).length <= TYPEWRITER_GROWTH_LIMIT
|
||
&& at - previous.updatedMs <= TYPEWRITER_GAP_MS;
|
||
}
|
||
|
||
function lineIndexes(values) {
|
||
const indexesByText = new Map();
|
||
for (const [index, text] of values.entries()) {
|
||
const indexes = indexesByText.get(text) ?? [];
|
||
indexes.push(index);
|
||
indexesByText.set(text, indexes);
|
||
}
|
||
return indexesByText;
|
||
}
|
||
|
||
function retainedLinesFor(values) {
|
||
const previousByText = lineIndexes(trackedLines.map(line => line.text));
|
||
const nextByText = lineIndexes(values);
|
||
const retained = new Map();
|
||
const usedPrevious = new Set();
|
||
for (const [text, previousIndexes] of previousByText) {
|
||
const nextIndexes = nextByText.get(text);
|
||
if (previousIndexes.length !== 1 || nextIndexes?.length !== 1) continue;
|
||
retained.set(nextIndexes[0], trackedLines[previousIndexes[0]]);
|
||
usedPrevious.add(previousIndexes[0]);
|
||
}
|
||
return { previousByText, nextByText, retained, usedPrevious };
|
||
}
|
||
|
||
function refreshUnchangedLineRanges(extracted) {
|
||
if (extracted.length !== trackedLines.length
|
||
|| !extracted.every((line, index) => line.text === trackedLines[index].text)) return false;
|
||
for (const [index, line] of trackedLines.entries()) line.range = extracted[index].range;
|
||
return true;
|
||
}
|
||
|
||
function reconcileTracked(initial = false) {
|
||
trackedQueued = false;
|
||
if (!trackedElement) return;
|
||
if (!trackedElement.isConnected) {
|
||
clearTrackedArea(false);
|
||
report("The tracked text area was replaced. Select it again or use the next lookup to relearn it.",
|
||
{ tracked: false });
|
||
return;
|
||
}
|
||
const at = now();
|
||
const extracted = extractLines(trackedElement);
|
||
if (refreshUnchangedLineRanges(extracted)) return;
|
||
const values = extracted.map(line => line.text);
|
||
const { previousByText, nextByText, retained, usedPrevious } = retainedLinesFor(values);
|
||
const previousLastIndex = trackedLines.length - 1;
|
||
const nextLastIndex = values.length - 1;
|
||
const previousLast = trackedLines[previousLastIndex];
|
||
const nextLast = values[nextLastIndex];
|
||
let typewriter = null;
|
||
if (isTypewriterContinuation(previousLast, nextLast, at)
|
||
&& previousByText.get(previousLast.text)?.length === 1
|
||
&& nextByText.get(nextLast)?.length === 1
|
||
&& !usedPrevious.has(previousLastIndex) && !retained.has(nextLastIndex)) {
|
||
typewriter = { index: nextLastIndex, line: previousLast };
|
||
usedPrevious.add(previousLastIndex);
|
||
}
|
||
for (let index = 0; index < trackedLines.length; index += 1) {
|
||
if (!usedPrevious.has(index)) emitClose(domIdentity(trackedLines[index]), at);
|
||
}
|
||
const next = [];
|
||
for (let index = 0; index < values.length; index += 1) {
|
||
const text = values[index];
|
||
const range = extracted[index].range;
|
||
const previous = retained.get(index);
|
||
if (previous) {
|
||
next.push({ ...previous, range });
|
||
continue;
|
||
}
|
||
if (typewriter?.index === index) {
|
||
emitBegin({ sourceKind: "dom", sourceEpoch: trackedEpoch, occurrenceId: typewriter.line.id,
|
||
text, startMs: at, onsetKnown: !initial });
|
||
next.push({ ...typewriter.line, text, range, updatedMs: at });
|
||
continue;
|
||
}
|
||
const line = { id: `line-${++nextLineId}`, text, range, updatedMs: at };
|
||
emitBegin({ sourceKind: "dom", sourceEpoch: trackedEpoch, occurrenceId: line.id,
|
||
text, startMs: at, onsetKnown: !initial && !previousByText.has(text) });
|
||
next.push(line);
|
||
}
|
||
trackedLines = next;
|
||
}
|
||
|
||
function queueTrackedReconcile() {
|
||
if (trackedQueued) return;
|
||
trackedQueued = true;
|
||
queueMicrotask(() => reconcileTracked(false));
|
||
}
|
||
|
||
function clearTrackedArea(reportChange = true) {
|
||
trackedObserver?.disconnect();
|
||
trackedObserver = null;
|
||
trackedVisibilityObserver?.disconnect();
|
||
trackedVisibilityObserver = null;
|
||
trackedMountObserver?.disconnect();
|
||
trackedMountObserver = null;
|
||
closeTrackedLines();
|
||
trackedElement = null;
|
||
trackedEpoch = "";
|
||
if (reportChange) report("Tracked text area cleared.", { tracked: false });
|
||
}
|
||
|
||
function trackArea(element, manual = false) {
|
||
if (!validTrackedElement(element)) throw new Error("Choose a bounded, non-editable text area.");
|
||
clearTrackedArea(false);
|
||
trackedElement = element;
|
||
trackedEpoch = `dom:${documentEpoch}:${crypto.randomUUID()}`;
|
||
reconcileTracked(true);
|
||
trackedObserver = new MutationObserver(queueTrackedReconcile);
|
||
trackedObserver.observe(element, {
|
||
subtree: true,
|
||
childList: true,
|
||
characterData: true,
|
||
attributes: true,
|
||
attributeFilter: ["class", "style", "hidden", "aria-hidden"],
|
||
});
|
||
trackedVisibilityObserver = new MutationObserver(queueTrackedReconcile);
|
||
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
|
||
trackedVisibilityObserver.observe(ancestor, {
|
||
attributes: true,
|
||
attributeFilter: ["class", "style", "hidden", "aria-hidden"],
|
||
});
|
||
}
|
||
trackedMountObserver = new MutationObserver(queueTrackedReconcile);
|
||
for (let node = element; node.parentElement; node = node.parentElement) {
|
||
trackedMountObserver.observe(node.parentElement, { childList: true });
|
||
}
|
||
report(manual ? "Text area selected." : "Text area learned from the first lookup.", { tracked: true });
|
||
}
|
||
|
||
function learnArea(candidate) {
|
||
if (trackedElement || !linked || !options?.mediaCapture.page.domText
|
||
|| !options.mediaCapture.page.autoLearnArea) return;
|
||
let element = candidate?.anchor instanceof Element ? candidate.anchor : candidate?.anchor?.parentElement;
|
||
if (!validTrackedElement(element)) return;
|
||
let selected = element;
|
||
for (let depth = 0; depth < 3; depth += 1) {
|
||
const parent = selected.parentElement;
|
||
if (!validTrackedElement(parent)) break;
|
||
const textLength = (parent.textContent || "").length;
|
||
if (textLength > MAX_TEXT_LENGTH || parent.querySelectorAll("*").length > 100) break;
|
||
selected = parent;
|
||
}
|
||
try { trackArea(selected, false); } catch { /* Conservative auto-learning may decline the page. */ }
|
||
}
|
||
|
||
function occurrenceFor(candidate) {
|
||
if (!trackedElement || !candidate?.anchor || !trackedElement.contains(candidate.anchor)) return null;
|
||
// Keep the reader's DOM evidence local: a sentence or selection may occupy
|
||
// only part of a timed paragraph, and identical lines need their own identity.
|
||
const range = candidate.anchorRange ?? document.createRange();
|
||
if (!candidate.anchorRange) range.selectNodeContents(candidate.anchor);
|
||
const matches = trackedLines.filter(line => line.range?.comparePoint(range.startContainer, range.startOffset) === 0
|
||
&& line.range.comparePoint(range.endContainer, range.endOffset) === 0);
|
||
return matches.length === 1 ? { occurrenceId: matches[0].id, occurrenceSourceKind: "dom" } : null;
|
||
}
|
||
|
||
function blockPickerInput(event) {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
}
|
||
|
||
function picker() {
|
||
pickerCleanup?.();
|
||
let candidate = null;
|
||
const outline = document.createElement("div");
|
||
outline.setAttribute("aria-hidden", "true");
|
||
outline.style.cssText = [
|
||
"all: initial !important", "position: fixed !important", "pointer-events: none !important",
|
||
"z-index: 2147483647 !important", "border: 3px solid #36d399 !important",
|
||
"background: rgba(54,211,153,.12) !important", "box-sizing: border-box !important",
|
||
].join(";");
|
||
document.documentElement.append(outline);
|
||
|
||
function paint(element) {
|
||
candidate = validTrackedElement(element) ? element : null;
|
||
if (!candidate) {
|
||
outline.style.display = "none";
|
||
return;
|
||
}
|
||
const rect = candidate.getBoundingClientRect();
|
||
outline.style.display = "block";
|
||
outline.style.left = `${rect.left}px`;
|
||
outline.style.top = `${rect.top}px`;
|
||
outline.style.width = `${rect.width}px`;
|
||
outline.style.height = `${rect.height}px`;
|
||
}
|
||
|
||
function finish(element = null) {
|
||
pickerCleanup?.();
|
||
if (element) {
|
||
try { trackArea(element, true); }
|
||
catch (error) { report(error.message, { tracked: false }); }
|
||
} else {
|
||
report("Text area selection cancelled.", { tracked: Boolean(trackedElement) });
|
||
}
|
||
}
|
||
|
||
const listeners = [
|
||
["pointermove", event => {
|
||
blockPickerInput(event);
|
||
paint(document.elementFromPoint(event.clientX, event.clientY));
|
||
}, true],
|
||
["pointerdown", blockPickerInput, true],
|
||
["pointerup", blockPickerInput, true],
|
||
["mousedown", blockPickerInput, true],
|
||
["mouseup", blockPickerInput, true],
|
||
["auxclick", blockPickerInput, true],
|
||
["dblclick", blockPickerInput, true],
|
||
["contextmenu", blockPickerInput, true],
|
||
["click", event => { blockPickerInput(event); finish(candidate); }, true],
|
||
["keydown", event => {
|
||
blockPickerInput(event);
|
||
if (event.key === "Escape") finish();
|
||
else if (event.key === "ArrowUp" && candidate?.parentElement) {
|
||
paint(candidate.parentElement);
|
||
}
|
||
}, true],
|
||
["keypress", blockPickerInput, true],
|
||
["keyup", blockPickerInput, true],
|
||
];
|
||
for (const [type, listener, capture] of listeners) window.addEventListener(type, listener, capture);
|
||
pickerCleanup = () => {
|
||
for (const [type, listener, capture] of listeners) window.removeEventListener(type, listener, capture);
|
||
outline.remove();
|
||
pickerCleanup = null;
|
||
};
|
||
report("Choose a bounded text area. Arrow Up selects its parent; Escape cancels.", { picking: true });
|
||
}
|
||
|
||
async function link(mediaCapture, captureSessionId) {
|
||
const epoch = crypto.randomUUID();
|
||
documentEpoch = epoch;
|
||
linked = false;
|
||
const identity = await identify(captureSessionId);
|
||
if (documentEpoch !== epoch) throw new Error("The reading page link changed before its identity arrived.");
|
||
if (!mediaCapture || typeof mediaCapture !== "object") {
|
||
throw new Error("The capture service did not provide page timing settings.");
|
||
}
|
||
options = {
|
||
mediaCapture: {
|
||
...mediaCapture,
|
||
texthooker: { ...mediaCapture.texthooker },
|
||
page: { ...mediaCapture.page },
|
||
},
|
||
};
|
||
linked = true;
|
||
linkedDocumentId = identity.documentId;
|
||
const available = videos();
|
||
if (options.mediaCapture.timingMode !== "recent"
|
||
&& available.length === 1 && available[0].trackCount > 0
|
||
&& options.mediaCapture.page.nativeCues) {
|
||
attachVideo([...document.querySelectorAll("video")][0]);
|
||
} else {
|
||
attachVideo(null);
|
||
}
|
||
let message = "Reading page linked.";
|
||
if (available.length > 1) message = "Choose which video supplies native subtitle cues.";
|
||
else if (available.length === 0) message = "No native video cues found; page text and recent timing remain available.";
|
||
return { videos: available, message };
|
||
}
|
||
|
||
function lookupSnapshot(candidate, lookupTimeMs = now()) {
|
||
if (!linked || !options?.mediaCapture.enabled) return null;
|
||
const pageTiming = options.mediaCapture.timingMode !== "recent";
|
||
if (pageTiming) learnArea(candidate);
|
||
const occurrence = pageTiming
|
||
? occurrenceFor(candidate) ?? { occurrenceId: "", occurrenceSourceKind: "" }
|
||
: { occurrenceId: "", occurrenceSourceKind: "" };
|
||
return {
|
||
documentEpoch,
|
||
lookup: {
|
||
lookupText: String(candidate.sentence || candidate.query || "").slice(0, MAX_TEXT_LENGTH),
|
||
lookupTimeMs,
|
||
...occurrence,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function pinLookup(snapshot) {
|
||
if (!snapshot || !linked || snapshot.documentEpoch !== documentEpoch) return null;
|
||
try {
|
||
return await send("hd_capture_pin", { lookup: snapshot.lookup });
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function releaseToken(pin) {
|
||
if (pin?.token) {
|
||
try { await send("hd_capture_release", { token: pin.token }); } catch { /* Expired pins need no cleanup. */ }
|
||
}
|
||
}
|
||
|
||
function rootLookup(candidate) {
|
||
const snapshot = lookupSnapshot(candidate);
|
||
const operation = rootPinTail.then(async () => {
|
||
const previous = rootPin;
|
||
rootPin = null;
|
||
await releaseToken(previous);
|
||
const pin = await pinLookup(snapshot);
|
||
rootPin = pin;
|
||
return pin;
|
||
});
|
||
rootPinTail = operation.catch(() => {});
|
||
return operation;
|
||
}
|
||
|
||
function release(pin) {
|
||
const operation = rootPinTail.then(async () => {
|
||
const owned = pin === undefined ? rootPin : pin;
|
||
if (!owned?.token || rootPin?.token !== owned.token) return;
|
||
rootPin = null;
|
||
await releaseToken(owned);
|
||
});
|
||
rootPinTail = operation.catch(() => {});
|
||
return operation;
|
||
}
|
||
|
||
async function unlink() {
|
||
const epoch = crypto.randomUUID();
|
||
documentEpoch = epoch;
|
||
linked = false;
|
||
await release();
|
||
if (documentEpoch !== epoch) return { linked };
|
||
pickerCleanup?.();
|
||
selectedVideoCleanup?.();
|
||
clearTrackedArea(false);
|
||
linked = false;
|
||
linkedDocumentId = null;
|
||
options = null;
|
||
return { linked: false };
|
||
}
|
||
|
||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||
if (message?.target !== CONTENT_TARGET) return false;
|
||
Promise.resolve().then(async () => {
|
||
switch (message.type) {
|
||
case "hd_capture_link": return link(message.mediaCapture, message.captureSessionId);
|
||
case "hd_capture_recover": return { linked, documentId: linkedDocumentId };
|
||
case "hd_capture_video_select": {
|
||
if (options?.mediaCapture.timingMode === "recent"
|
||
|| !options?.mediaCapture.page.nativeCues) {
|
||
throw new Error("Native cue timing is disabled for this capture session.");
|
||
}
|
||
const video = [...document.querySelectorAll("video")].find(item => videoId(item) === message.videoId);
|
||
if (!video) throw new Error("That video is no longer available.");
|
||
attachVideo(video);
|
||
return { selected: message.videoId };
|
||
}
|
||
case "hd_capture_track_area":
|
||
if (options?.mediaCapture.timingMode === "recent"
|
||
|| !options?.mediaCapture.page.domText) {
|
||
throw new Error("Enable webpage timing and watched page text in Media capture settings first.");
|
||
}
|
||
picker();
|
||
return { picking: true };
|
||
case "hd_capture_clear_area":
|
||
clearTrackedArea();
|
||
return { tracked: false };
|
||
case "hd_capture_unlink": return unlink();
|
||
default: throw new Error("Unknown capture command.");
|
||
}
|
||
}).then(result => sendResponse(result), error => sendResponse({ error: error.message || String(error) }));
|
||
return true;
|
||
});
|
||
|
||
window.addEventListener("pagehide", () => {
|
||
void unlink();
|
||
});
|
||
|
||
globalThis.HDCapture = { rootLookup, release };
|
||
}());
|