Integrate invisible overlay renderer calibration from mac build

This commit is contained in:
2026-02-10 03:26:31 -08:00
parent 920972b1bf
commit b5e5d7c510
3 changed files with 252 additions and 188 deletions

View File

@@ -22,7 +22,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' chrome-extension:; script-src 'self' 'unsafe-inline' chrome-extension:; style-src 'self' 'unsafe-inline' chrome-extension:;" content="default-src 'self' 'unsafe-inline' chrome-extension:; script-src 'self' 'unsafe-inline' chrome-extension:; style-src 'self' 'unsafe-inline' chrome-extension:; worker-src 'self' blob:;"
/> />
<title>SubMiner</title> <title>SubMiner</title>
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />

View File

@@ -31,19 +31,6 @@ interface SubtitleData {
tokens: MergedToken[] | null; tokens: MergedToken[] | null;
} }
interface AssInlineOverrides {
fontFamily?: string;
fontSize?: number;
letterSpacing?: number;
scaleX?: number;
scaleY?: number;
bold?: boolean;
italic?: boolean;
borderSize?: number;
shadowOffset?: number;
alignment?: number;
}
interface MpvSubtitleRenderMetrics { interface MpvSubtitleRenderMetrics {
subPos: number; subPos: number;
subFontSize: number; subFontSize: number;
@@ -349,6 +336,9 @@ const overlayLayer =
: overlayLayerFromQuery; : overlayLayerFromQuery;
const isInvisibleLayer = overlayLayer === "invisible"; const isInvisibleLayer = overlayLayer === "invisible";
const isLinuxPlatform = navigator.platform.toLowerCase().includes("linux"); const isLinuxPlatform = navigator.platform.toLowerCase().includes("linux");
const isMacOSPlatform =
navigator.platform.toLowerCase().includes("mac") ||
/mac/i.test(navigator.userAgent);
// Linux passthrough forwarding is not reliable for this overlay; keep pointer // Linux passthrough forwarding is not reliable for this overlay; keep pointer
// routing local so hover lookup, drag-reposition, and key handling remain usable. // routing local so hover lookup, drag-reposition, and key handling remain usable.
const shouldToggleMouseIgnore = !isLinuxPlatform; const shouldToggleMouseIgnore = !isLinuxPlatform;
@@ -403,7 +393,13 @@ const DEFAULT_MPV_SUBTITLE_RENDER_METRICS: MpvSubtitleRenderMetrics = {
let mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics = { let mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics = {
...DEFAULT_MPV_SUBTITLE_RENDER_METRICS, ...DEFAULT_MPV_SUBTITLE_RENDER_METRICS,
}; };
let currentSubtitleAss = ""; let currentInvisibleSubtitleLineCount = 1;
let lastHoverSelectionKey = "";
let lastHoverSelectionNode: Text | null = null;
const wordSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "word" })
: null;
function isAnySettingsModalOpen(): boolean { function isAnySettingsModalOpen(): boolean {
return runtimeOptionsModalOpen || subsyncModalOpen || kikuModalOpen; return runtimeOptionsModalOpen || subsyncModalOpen || kikuModalOpen;
@@ -502,6 +498,8 @@ function renderPlainTextPreserveLineBreaks(text: string): void {
function renderSubtitle(data: SubtitleData | string): void { function renderSubtitle(data: SubtitleData | string): void {
subtitleRoot.innerHTML = ""; subtitleRoot.innerHTML = "";
lastHoverSelectionKey = "";
lastHoverSelectionNode = null;
let text: string; let text: string;
let tokens: MergedToken[] | null; let tokens: MergedToken[] | null;
@@ -521,8 +519,13 @@ function renderSubtitle(data: SubtitleData | string): void {
} }
if (isInvisibleLayer) { if (isInvisibleLayer) {
// Keep natural kerning/shaping for accurate hitbox alignment with mpv/libass. // Keep natural kerning/shaping for accurate hitbox alignment with mpv.
renderPlainTextPreserveLineBreaks(normalizeSubtitle(text, false)); const normalizedInvisible = normalizeSubtitle(text, false);
currentInvisibleSubtitleLineCount = Math.max(
1,
normalizedInvisible.split("\n").length,
);
renderPlainTextPreserveLineBreaks(normalizedInvisible);
return; return;
} }
@@ -741,101 +744,6 @@ function sanitizeMpvSubtitleRenderMetrics(
}; };
} }
function getLastMatch(text: string, pattern: RegExp): string | undefined {
let last: string | undefined;
let match: RegExpExecArray | null;
// eslint-disable-next-line no-cond-assign
while ((match = pattern.exec(text)) !== null) {
last = match[1];
}
return last;
}
function parseAssInlineOverrides(assText: string): AssInlineOverrides {
if (!assText) return {};
const result: AssInlineOverrides = {};
const fontFamily = getLastMatch(assText, /\\fn([^\\}]+)/g);
if (fontFamily && fontFamily.trim()) {
result.fontFamily = fontFamily.trim();
}
const fontSize = getLastMatch(assText, /\\fs(-?\d+(?:\.\d+)?)/g);
if (fontSize) {
const parsed = Number.parseFloat(fontSize);
if (Number.isFinite(parsed) && parsed > 0) {
result.fontSize = parsed;
}
}
const letterSpacing = getLastMatch(assText, /\\fsp(-?\d+(?:\.\d+)?)/g);
if (letterSpacing) {
const parsed = Number.parseFloat(letterSpacing);
if (Number.isFinite(parsed)) {
result.letterSpacing = parsed;
}
}
const scaleX = getLastMatch(assText, /\\fscx(-?\d+(?:\.\d+)?)/g);
if (scaleX) {
const parsed = Number.parseFloat(scaleX);
if (Number.isFinite(parsed) && parsed > 0) {
result.scaleX = parsed / 100;
}
}
const scaleY = getLastMatch(assText, /\\fscy(-?\d+(?:\.\d+)?)/g);
if (scaleY) {
const parsed = Number.parseFloat(scaleY);
if (Number.isFinite(parsed) && parsed > 0) {
result.scaleY = parsed / 100;
}
}
const bold = getLastMatch(assText, /\\b(-?\d+)/g);
if (bold) {
const parsed = Number.parseInt(bold, 10);
if (!Number.isNaN(parsed)) {
result.bold = parsed !== 0;
}
}
const italic = getLastMatch(assText, /\\i(-?\d+)/g);
if (italic) {
const parsed = Number.parseInt(italic, 10);
if (!Number.isNaN(parsed)) {
result.italic = parsed !== 0;
}
}
const borderSize = getLastMatch(assText, /\\bord(-?\d+(?:\.\d+)?)/g);
if (borderSize) {
const parsed = Number.parseFloat(borderSize);
if (Number.isFinite(parsed) && parsed >= 0) {
result.borderSize = parsed;
}
}
const shadowOffset = getLastMatch(assText, /\\shad(-?\d+(?:\.\d+)?)/g);
if (shadowOffset) {
const parsed = Number.parseFloat(shadowOffset);
if (Number.isFinite(parsed) && parsed >= 0) {
result.shadowOffset = parsed;
}
}
const alignment = getLastMatch(assText, /\\an(\d)/g);
if (alignment) {
const parsed = Number.parseInt(alignment, 10);
if (parsed >= 1 && parsed <= 9) {
result.alignment = parsed;
}
}
return result;
}
function applyInvisibleSubtitleLayoutFromMpvMetrics( function applyInvisibleSubtitleLayoutFromMpvMetrics(
metrics: Partial<MpvSubtitleRenderMetrics> | null | undefined, metrics: Partial<MpvSubtitleRenderMetrics> | null | undefined,
source: string, source: string,
@@ -843,52 +751,50 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics(
mpvSubtitleRenderMetrics = sanitizeMpvSubtitleRenderMetrics(metrics); mpvSubtitleRenderMetrics = sanitizeMpvSubtitleRenderMetrics(metrics);
const dims = mpvSubtitleRenderMetrics.osdDimensions; const dims = mpvSubtitleRenderMetrics.osdDimensions;
const renderAreaHeight = dims?.h ?? window.innerHeight; const dpr = window.devicePixelRatio || 1;
const renderAreaWidth = dims?.w ?? window.innerWidth; // On macOS, mpv osd-dimensions can be reported in either physical pixels or
const videoLeftInset = dims?.ml ?? 0; // point-like units. Infer the osd->CSS scale from current viewport ratio.
const videoRightInset = dims?.mr ?? 0; const osdToCssScale =
const videoTopInset = dims?.mt ?? 0; isMacOSPlatform && dims
const videoBottomInset = dims?.mb ?? 0; ? (() => {
const ratios = [
dims.w / Math.max(1, window.innerWidth),
dims.h / Math.max(1, window.innerHeight),
].filter((value) => Number.isFinite(value) && value > 0);
const avgRatio =
ratios.length > 0
? ratios.reduce((sum, value) => sum + value, 0) / ratios.length
: dpr;
return avgRatio > 1.25 ? avgRatio : 1;
})()
: dpr;
const renderAreaHeight = dims ? dims.h / osdToCssScale : window.innerHeight;
const renderAreaWidth = dims ? dims.w / osdToCssScale : window.innerWidth;
const videoLeftInset = dims ? dims.ml / osdToCssScale : 0;
const videoRightInset = dims ? dims.mr / osdToCssScale : 0;
const videoTopInset = dims ? dims.mt / osdToCssScale : 0;
const videoBottomInset = dims ? dims.mb / osdToCssScale : 0;
const anchorToVideoArea = !mpvSubtitleRenderMetrics.subUseMargins; const anchorToVideoArea = !mpvSubtitleRenderMetrics.subUseMargins;
const leftInset = anchorToVideoArea ? videoLeftInset : 0; const leftInset = anchorToVideoArea ? videoLeftInset : 0;
const rightInset = anchorToVideoArea ? videoRightInset : 0; const rightInset = anchorToVideoArea ? videoRightInset : 0;
const topInset = anchorToVideoArea ? videoTopInset : 0; const topInset = anchorToVideoArea ? videoTopInset : 0;
const bottomInset = anchorToVideoArea ? videoBottomInset : 0; const bottomInset = anchorToVideoArea ? videoBottomInset : 0;
// Match mpv subtitle sizing from scaled pixels in mpv's OSD space. const videoHeight = renderAreaHeight - videoTopInset - videoBottomInset;
const osdReferenceHeight = mpvSubtitleRenderMetrics.subScaleByWindow const scaleRefHeight = mpvSubtitleRenderMetrics.subScaleByWindow
? mpvSubtitleRenderMetrics.osdHeight ? renderAreaHeight
: 720; : videoHeight;
const pxPerScaledPixel = Math.max(0.1, renderAreaHeight / osdReferenceHeight); const pxPerScaledPixel = Math.max(0.1, scaleRefHeight / 720);
const computedFontSize = const computedFontSize =
mpvSubtitleRenderMetrics.subFontSize * mpvSubtitleRenderMetrics.subFontSize *
mpvSubtitleRenderMetrics.subScale * mpvSubtitleRenderMetrics.subScale *
pxPerScaledPixel; (isLinuxPlatform ? 1 : pxPerScaledPixel);
const rawAssOverrides = parseAssInlineOverrides(currentSubtitleAss);
// When sub-ass-override is "yes" (default), "force", or "strip", mpv ignores const macOsFontCompensation = isMacOSPlatform ? 0.87 : 1;
// ASS inline style tags (\fn, \fs, \b, \i, \bord, \shad, \fsp, \fscx, \fscy) const effectiveFontSize = computedFontSize * macOsFontCompensation;
// and uses its own sub-* settings instead. Only positioning tags like \an and
// \pos are always respected. Since sub-text-ass returns the raw ASS text
// regardless of this setting, we must skip style overrides when mpv is
// overriding them.
const assOverrideMode = mpvSubtitleRenderMetrics.subAssOverride;
const mpvOverridesAssStyles =
assOverrideMode === "yes" ||
assOverrideMode === "force" ||
assOverrideMode === "strip";
const assOverrides: AssInlineOverrides = mpvOverridesAssStyles
? {}
: rawAssOverrides;
const effectiveFontSize =
assOverrides.fontSize && assOverrides.fontSize > 0
? assOverrides.fontSize * pxPerScaledPixel
: computedFontSize;
applySubtitleFontSize(effectiveFontSize); applySubtitleFontSize(effectiveFontSize);
// \an is a positioning tag — always respected regardless of sub-ass-override const alignment = 2;
const alignment = rawAssOverrides.alignment ?? 2;
const hAlign = ((alignment - 1) % 3) as 0 | 1 | 2; // 0=left, 1=center, 2=right const hAlign = ((alignment - 1) % 3) as 0 | 1 | 2; // 0=left, 1=center, 2=right
const vAlign = Math.floor((alignment - 1) / 3) as 0 | 1 | 2; // 0=bottom, 1=middle, 2=top const vAlign = Math.floor((alignment - 1) / 3) as 0 | 1 | 2; // 0=bottom, 1=middle, 2=top
@@ -902,12 +808,19 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics(
renderAreaWidth - leftInset - rightInset - Math.round(marginX * 2), renderAreaWidth - leftInset - rightInset - Math.round(marginX * 2),
); );
const effectiveBorderSize = mpvSubtitleRenderMetrics.subBorderSize * pxPerScaledPixel;
document.documentElement.style.setProperty(
"--sub-border-size",
`${effectiveBorderSize}px`,
);
subtitleContainer.style.position = "absolute"; subtitleContainer.style.position = "absolute";
subtitleContainer.style.maxWidth = `${horizontalAvailable}px`; subtitleContainer.style.maxWidth = `${horizontalAvailable}px`;
subtitleContainer.style.width = `${horizontalAvailable}px`; subtitleContainer.style.width = `${horizontalAvailable}px`;
subtitleContainer.style.padding = "0"; subtitleContainer.style.padding = "0";
subtitleContainer.style.background = "transparent"; subtitleContainer.style.background = "transparent";
subtitleContainer.style.marginBottom = "0"; subtitleContainer.style.marginBottom = "0";
subtitleContainer.style.pointerEvents = "none";
// Horizontal positioning based on \an alignment. // Horizontal positioning based on \an alignment.
// All alignments position the container at the left margin with full available // All alignments position the container at the left margin with full available
@@ -916,47 +829,89 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics(
subtitleContainer.style.left = `${leftInset + marginX}px`; subtitleContainer.style.left = `${leftInset + marginX}px`;
subtitleContainer.style.right = ""; subtitleContainer.style.right = "";
subtitleContainer.style.transform = ""; subtitleContainer.style.transform = "";
subtitleContainer.style.textAlign = "";
if (hAlign === 0) { if (hAlign === 0) {
subtitleContainer.style.textAlign = "left";
subtitleRoot.style.textAlign = "left"; subtitleRoot.style.textAlign = "left";
} else if (hAlign === 2) { } else if (hAlign === 2) {
subtitleContainer.style.textAlign = "right";
subtitleRoot.style.textAlign = "right"; subtitleRoot.style.textAlign = "right";
} else { } else {
subtitleContainer.style.textAlign = "center";
subtitleRoot.style.textAlign = "center"; subtitleRoot.style.textAlign = "center";
} }
subtitleRoot.style.display = "inline-block";
subtitleRoot.style.maxWidth = "100%";
subtitleRoot.style.pointerEvents = "auto";
// Vertical positioning based on \an alignment // Vertical positioning based on \an alignment
const lineCount = Math.max(1, currentInvisibleSubtitleLineCount);
const multiline = lineCount > 1;
const baselineCompensationFactor =
lineCount >= 3 ? 0.46 : multiline ? 0.58 : 0.7;
const baselineCompensationPx = Math.max(
0,
effectiveFontSize * baselineCompensationFactor,
);
if (vAlign === 2) { if (vAlign === 2) {
subtitleContainer.style.top = `${topInset + marginY}px`; subtitleContainer.style.top = `${Math.max(0, topInset + marginY - baselineCompensationPx)}px`;
subtitleContainer.style.bottom = ""; subtitleContainer.style.bottom = "";
} else if (vAlign === 1) { } else if (vAlign === 1) {
subtitleContainer.style.top = "50%"; subtitleContainer.style.top = "50%";
subtitleContainer.style.bottom = ""; subtitleContainer.style.bottom = "";
subtitleContainer.style.transform = "translateY(-50%)"; subtitleContainer.style.transform = "translateY(-50%)";
} else { } else {
const subPosOffset = const subPosMargin =
((100 - mpvSubtitleRenderMetrics.subPos) / 100) * renderAreaHeight; ((100 - mpvSubtitleRenderMetrics.subPos) / 100) * renderAreaHeight;
const bottomPx = Math.max(0, bottomInset + marginY + subPosOffset); const effectiveMargin = Math.max(marginY, subPosMargin);
const bottomPx = Math.max(
0,
bottomInset + effectiveMargin + baselineCompensationPx,
);
subtitleContainer.style.top = ""; subtitleContainer.style.top = "";
subtitleContainer.style.bottom = `${bottomPx}px`; subtitleContainer.style.bottom = `${bottomPx}px`;
} }
subtitleRoot.style.setProperty(
"line-height",
isMacOSPlatform
? lineCount >= 3
? "1.18"
: multiline
? "1.08"
: "0.86"
: "normal",
isMacOSPlatform ? "important" : "",
);
const rawFont = mpvSubtitleRenderMetrics.subFont;
const strippedFont = rawFont
.replace(
/\s+(Regular|Bold|Italic|Light|Medium|Semi\s*Bold|Extra\s*Bold|Extra\s*Light|Thin|Black|Heavy|Demi\s*Bold|Book|Condensed)\s*$/i,
"",
)
.trim();
subtitleRoot.style.fontFamily = subtitleRoot.style.fontFamily =
assOverrides.fontFamily || mpvSubtitleRenderMetrics.subFont; strippedFont !== rawFont
const effectiveSpacing = ? `"${rawFont}", "${strippedFont}", sans-serif`
typeof assOverrides.letterSpacing === "number" : `"${rawFont}", sans-serif`;
? assOverrides.letterSpacing const effectiveSpacing = mpvSubtitleRenderMetrics.subSpacing;
: mpvSubtitleRenderMetrics.subSpacing; subtitleRoot.style.setProperty(
subtitleRoot.style.letterSpacing = "letter-spacing",
Math.abs(effectiveSpacing) > 0.0001 Math.abs(effectiveSpacing) > 0.0001
? `${effectiveSpacing * pxPerScaledPixel}px` ? `${effectiveSpacing * pxPerScaledPixel * (isMacOSPlatform ? 0.7 : 1)}px`
: "normal"; : isMacOSPlatform
const effectiveBold = assOverrides.bold ?? mpvSubtitleRenderMetrics.subBold; ? "-0.02em"
const effectiveItalic = : "0px",
assOverrides.italic ?? mpvSubtitleRenderMetrics.subItalic; isMacOSPlatform ? "important" : "",
);
subtitleRoot.style.fontKerning = isMacOSPlatform ? "auto" : "none";
const effectiveBold = mpvSubtitleRenderMetrics.subBold;
const effectiveItalic = mpvSubtitleRenderMetrics.subItalic;
subtitleRoot.style.fontWeight = effectiveBold ? "700" : "400"; subtitleRoot.style.fontWeight = effectiveBold ? "700" : "400";
subtitleRoot.style.fontStyle = effectiveItalic ? "italic" : "normal"; subtitleRoot.style.fontStyle = effectiveItalic ? "italic" : "normal";
const scaleX = assOverrides.scaleX ?? 1; const scaleX = 1;
const scaleY = assOverrides.scaleY ?? 1; const scaleY = 1;
if (Math.abs(scaleX - 1) > 0.0001 || Math.abs(scaleY - 1) > 0.0001) { if (Math.abs(scaleX - 1) > 0.0001 || Math.abs(scaleY - 1) > 0.0001) {
subtitleRoot.style.transform = `scale(${scaleX}, ${scaleY})`; subtitleRoot.style.transform = `scale(${scaleX}, ${scaleY})`;
subtitleRoot.style.transformOrigin = "50% 100%"; subtitleRoot.style.transformOrigin = "50% 100%";
@@ -968,7 +923,7 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics(
// CSS line-height: normal adds "half-leading" — extra space equally distributed // CSS line-height: normal adds "half-leading" — extra space equally distributed
// above and below text within each line box. This means the text's visual bottom // above and below text within each line box. This means the text's visual bottom
// is above the element's bottom edge by halfLeading pixels. We must compensate // is above the element's bottom edge by halfLeading pixels. We must compensate
// by shifting the element down by that amount so glyph positions match mpv/libass. // by shifting the element down by that amount so glyph positions better match mpv.
const computedLineHeight = parseFloat( const computedLineHeight = parseFloat(
getComputedStyle(subtitleRoot).lineHeight, getComputedStyle(subtitleRoot).lineHeight,
); );
@@ -989,12 +944,7 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics(
} }
} }
} }
console.log( console.log("[invisible-overlay] Applied mpv subtitle render metrics from", source);
"[invisible-overlay] Applied mpv subtitle render metrics from",
source,
mpvSubtitleRenderMetrics,
assOverrides,
);
} }
function setJimakuStatus(message: string, isError = false): void { function setJimakuStatus(message: string, isError = false): void {
@@ -1919,6 +1869,129 @@ function setupDragging(): void {
}); });
} }
function getCaretTextPointRange(clientX: number, clientY: number): Range | null {
const documentWithCaretApi = document as Document & {
caretRangeFromPoint?: (x: number, y: number) => Range | null;
caretPositionFromPoint?: (
x: number,
y: number,
) => { offsetNode: Node; offset: number } | null;
};
if (typeof documentWithCaretApi.caretRangeFromPoint === "function") {
return documentWithCaretApi.caretRangeFromPoint(clientX, clientY);
}
if (typeof documentWithCaretApi.caretPositionFromPoint === "function") {
const caretPosition = documentWithCaretApi.caretPositionFromPoint(
clientX,
clientY,
);
if (!caretPosition) return null;
const range = document.createRange();
range.setStart(caretPosition.offsetNode, caretPosition.offset);
range.collapse(true);
return range;
}
return null;
}
function getWordBoundsAtOffset(
text: string,
offset: number,
): { start: number; end: number } | null {
if (!text || text.length === 0) return null;
const clampedOffset = Math.max(0, Math.min(offset, text.length));
const probeIndex =
clampedOffset >= text.length ? Math.max(0, text.length - 1) : clampedOffset;
if (wordSegmenter) {
for (const part of wordSegmenter.segment(text)) {
const start = part.index;
const end = start + part.segment.length;
if (probeIndex >= start && probeIndex < end) {
if (part.isWordLike === false) return null;
return { start, end };
}
}
}
const isBoundary = (char: string): boolean =>
/[\s\u3000.,!?;:()[\]{}"'`~<>/\\|@#$%^&*+=\-、。・「」『』【】〈〉《》]/.test(
char,
);
const probeChar = text[probeIndex];
if (!probeChar || isBoundary(probeChar)) return null;
let start = probeIndex;
while (start > 0 && !isBoundary(text[start - 1])) {
start -= 1;
}
let end = probeIndex + 1;
while (end < text.length && !isBoundary(text[end])) {
end += 1;
}
if (end <= start) return null;
return { start, end };
}
function updateHoverWordSelection(event: MouseEvent): void {
if (!isInvisibleLayer || !isMacOSPlatform) return;
if (event.buttons !== 0) return;
if (!(event.target instanceof Node)) return;
if (!subtitleRoot.contains(event.target)) return;
const caretRange = getCaretTextPointRange(event.clientX, event.clientY);
if (!caretRange) return;
if (caretRange.startContainer.nodeType !== Node.TEXT_NODE) return;
if (!subtitleRoot.contains(caretRange.startContainer)) return;
const textNode = caretRange.startContainer as Text;
const wordBounds = getWordBoundsAtOffset(textNode.data, caretRange.startOffset);
if (!wordBounds) return;
const selectionKey = `${wordBounds.start}:${wordBounds.end}:${textNode.data.slice(
wordBounds.start,
wordBounds.end,
)}`;
if (
selectionKey === lastHoverSelectionKey &&
textNode === lastHoverSelectionNode
) {
return;
}
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.setStart(textNode, wordBounds.start);
range.setEnd(textNode, wordBounds.end);
selection.removeAllRanges();
selection.addRange(range);
lastHoverSelectionKey = selectionKey;
lastHoverSelectionNode = textNode;
}
function setupInvisibleHoverSelection(): void {
if (!isInvisibleLayer || !isMacOSPlatform) return;
subtitleRoot.addEventListener("mousemove", (event: MouseEvent) => {
updateHoverWordSelection(event);
});
subtitleRoot.addEventListener("mouseleave", () => {
lastHoverSelectionKey = "";
lastHoverSelectionNode = null;
});
}
function isInteractiveTarget(target: EventTarget | null): boolean { function isInteractiveTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false; if (!(target instanceof Element)) return false;
if (target.closest(".modal")) return true; if (target.closest(".modal")) return true;
@@ -2267,16 +2340,6 @@ async function init(): Promise<void> {
renderSubtitle(data); renderSubtitle(data);
}); });
if (isInvisibleLayer) {
window.electronAPI.onSubtitleAss((assText: string) => {
currentSubtitleAss = assText || "";
applyInvisibleSubtitleLayoutFromMpvMetrics(
mpvSubtitleRenderMetrics,
"subtitle-ass",
);
});
}
if (!isInvisibleLayer) { if (!isInvisibleLayer) {
window.electronAPI.onSubtitlePosition( window.electronAPI.onSubtitlePosition(
(position: SubtitlePosition | null) => { (position: SubtitlePosition | null) => {
@@ -2296,9 +2359,6 @@ async function init(): Promise<void> {
}); });
} }
if (isInvisibleLayer) {
currentSubtitleAss = await window.electronAPI.getCurrentSubtitleAss();
}
const initialSubtitle = await window.electronAPI.getCurrentSubtitle(); const initialSubtitle = await window.electronAPI.getCurrentSubtitle();
renderSubtitle(initialSubtitle); renderSubtitle(initialSubtitle);
@@ -2316,8 +2376,10 @@ async function init(): Promise<void> {
const initialSecondary = await window.electronAPI.getCurrentSecondarySub(); const initialSecondary = await window.electronAPI.getCurrentSecondarySub();
renderSecondarySub(initialSecondary); renderSecondarySub(initialSecondary);
subtitleContainer.addEventListener("mouseenter", handleMouseEnter); const hoverTarget = isInvisibleLayer ? subtitleRoot : subtitleContainer;
subtitleContainer.addEventListener("mouseleave", handleMouseLeave); hoverTarget.addEventListener("mouseenter", handleMouseEnter);
hoverTarget.addEventListener("mouseleave", handleMouseLeave);
setupInvisibleHoverSelection();
secondarySubContainer.addEventListener("mouseenter", handleMouseEnter); secondarySubContainer.addEventListener("mouseenter", handleMouseEnter);
secondarySubContainer.addEventListener("mouseleave", handleMouseLeave); secondarySubContainer.addEventListener("mouseleave", handleMouseLeave);

View File

@@ -34,6 +34,7 @@ body {
} }
#overlay { #overlay {
position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
@@ -300,6 +301,8 @@ body.layer-invisible #subtitleContainer {
border: 0 !important; border: 0 !important;
padding: 0 !important; padding: 0 !important;
border-radius: 0 !important; border-radius: 0 !important;
position: relative;
z-index: 3;
} }
body.layer-invisible #subtitleRoot, body.layer-invisible #subtitleRoot,
@@ -342,10 +345,9 @@ body.layer-invisible.debug-invisible-visualization #subtitleRoot .word,
body.layer-invisible.debug-invisible-visualization #subtitleRoot .c { body.layer-invisible.debug-invisible-visualization #subtitleRoot .c {
color: #ed8796 !important; color: #ed8796 !important;
-webkit-text-fill-color: #ed8796 !important; -webkit-text-fill-color: #ed8796 !important;
-webkit-text-stroke: 0.55px rgba(0, 0, 0, 0.95) !important; -webkit-text-stroke: calc(var(--sub-border-size, 2px) * 2) rgba(0, 0, 0, 0.85) !important;
text-shadow: paint-order: stroke fill !important;
0 0 8px rgba(237, 135, 150, 0.9), text-shadow: none !important;
0 2px 6px rgba(0, 0, 0, 0.95) !important;
} }
#secondarySubContainer { #secondarySubContainer {