Update TASK-20.2 status to done

This commit is contained in:
2026-02-12 02:49:54 -08:00
parent dfb54630df
commit f345547963
11 changed files with 427 additions and 7 deletions

View File

@@ -1,10 +1,10 @@
--- ---
id: TASK-20.2 id: TASK-20.2
title: Add renderer-to-main IPC contract for measured overlay content bounds title: Add renderer-to-main IPC contract for measured overlay content bounds
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-02-12 08:47' created_date: '2026-02-12 08:47'
updated_date: '2026-02-12 09:42' updated_date: '2026-02-12 02:45'
labels: [] labels: []
dependencies: [] dependencies: []
parent_task_id: TASK-20 parent_task_id: TASK-20
@@ -19,8 +19,22 @@ Add renderer-to-main IPC for content measurement reporting, so main process can
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Preload exposes a typed API for reporting overlay content bounds with layer metadata. - [x] #1 Preload exposes a typed API for reporting overlay content bounds with layer metadata.
- [ ] #2 Main-process IPC handler validates payload shape/range and stores latest measurement per layer. - [x] #2 Main-process IPC handler validates payload shape/range and stores latest measurement per layer.
- [ ] #3 Renderer emits measurement updates on subtitle, mode, style, and render-metric changes with throttling/debounce. - [x] #3 Renderer emits measurement updates on subtitle, mode, style, and render-metric changes with throttling/debounce.
- [ ] #4 No crashes or unbounded logging when measurements are missing/empty/invalid; fallback behavior is explicit. - [x] #4 No crashes or unbounded logging when measurements are missing/empty/invalid; fallback behavior is explicit.
<!-- AC:END --> <!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added a typed `OverlayContentMeasurement` IPC contract exposed in preload and Electron API typings. Implemented a main-process measurement store with strict payload validation and rate-limited warning logs for invalid reports. Added renderer-side debounced measurement reporting that emits updates on subtitle content/mode/style/render-metric and resize changes, explicitly sending `contentRect: null` when no measured content exists to signal fallback behavior.
Added unit coverage for measurement validation and store behavior.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented renderer-to-main measurement reporting for overlay content bounds with per-layer metadata. Main now validates and stores latest measurements per layer safely, renderer emits debounced updates on relevant state changes, and invalid/missing payload handling is explicit and non-spammy.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -16,7 +16,7 @@
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs", "docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs",
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort",
"test:config": "pnpm run build && node --test dist/config/config.test.js", "test:config": "pnpm run build && node --test dist/config/config.test.js",
"test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/field-grouping-overlay-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/mpv-control-service.test.js dist/core/services/mpv-service.test.js dist/core/services/runtime-options-ipc-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/tokenizer-service.test.js dist/core/services/subsync-service.test.js dist/core/services/overlay-bridge-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining-service.test.js dist/core/services/anki-jimaku-service.test.js dist/core/services/app-ready-service.test.js dist/core/services/startup-bootstrap-service.test.js dist/subsync/utils.test.js", "test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/field-grouping-overlay-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/overlay-content-measurement-service.test.js dist/core/services/mpv-control-service.test.js dist/core/services/mpv-service.test.js dist/core/services/runtime-options-ipc-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/tokenizer-service.test.js dist/core/services/subsync-service.test.js dist/core/services/overlay-bridge-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining-service.test.js dist/core/services/anki-jimaku-service.test.js dist/core/services/app-ready-service.test.js dist/core/services/startup-bootstrap-service.test.js dist/subsync/utils.test.js",
"test:subtitle": "pnpm run build && node --test dist/subtitle/stages.test.js dist/subtitle/pipeline.test.js", "test:subtitle": "pnpm run build && node --test dist/subtitle/stages.test.js dist/subtitle/pipeline.test.js",
"generate:config-example": "pnpm run build && node dist/generate-config-example.js", "generate:config-example": "pnpm run build && node dist/generate-config-example.js",
"start": "pnpm run build && electron . --start", "start": "pnpm run build && electron . --start",

View File

@@ -69,6 +69,7 @@ export {
DEFAULT_MPV_SUBTITLE_RENDER_METRICS, DEFAULT_MPV_SUBTITLE_RENDER_METRICS,
sanitizeMpvSubtitleRenderMetrics, sanitizeMpvSubtitleRenderMetrics,
} from "./mpv-render-metrics-service"; } from "./mpv-render-metrics-service";
export { createOverlayContentMeasurementStoreService } from "./overlay-content-measurement-service";
export { handleMpvCommandFromIpcService } from "./ipc-command-service"; export { handleMpvCommandFromIpcService } from "./ipc-command-service";
export { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-service"; export { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-service";
export { createNumericShortcutRuntimeService } from "./numeric-shortcut-service"; export { createNumericShortcutRuntimeService } from "./numeric-shortcut-service";

View File

@@ -28,6 +28,7 @@ export interface IpcServiceDeps {
getRuntimeOptions: () => unknown; getRuntimeOptions: () => unknown;
setRuntimeOption: (id: string, value: unknown) => unknown; setRuntimeOption: (id: string, value: unknown) => unknown;
cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown; cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown;
reportOverlayContentBounds: (payload: unknown) => void;
} }
interface WindowLike { interface WindowLike {
@@ -75,6 +76,7 @@ export interface IpcDepsRuntimeOptions {
getRuntimeOptions: () => unknown; getRuntimeOptions: () => unknown;
setRuntimeOption: (id: string, value: unknown) => unknown; setRuntimeOption: (id: string, value: unknown) => unknown;
cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown; cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown;
reportOverlayContentBounds: (payload: unknown) => void;
} }
export function createIpcDepsRuntimeService( export function createIpcDepsRuntimeService(
@@ -126,6 +128,7 @@ export function createIpcDepsRuntimeService(
getRuntimeOptions: options.getRuntimeOptions, getRuntimeOptions: options.getRuntimeOptions,
setRuntimeOption: options.setRuntimeOption, setRuntimeOption: options.setRuntimeOption,
cycleRuntimeOption: options.cycleRuntimeOption, cycleRuntimeOption: options.cycleRuntimeOption,
reportOverlayContentBounds: options.reportOverlayContentBounds,
}; };
} }
@@ -253,4 +256,8 @@ export function registerIpcHandlersService(deps: IpcServiceDeps): void {
ipcMain.handle("runtime-options:cycle", (_event, id: string, direction: 1 | -1) => { ipcMain.handle("runtime-options:cycle", (_event, id: string, direction: 1 | -1) => {
return deps.cycleRuntimeOption(id, direction); return deps.cycleRuntimeOption(id, direction);
}); });
ipcMain.on("overlay-content-bounds:report", (_event: IpcMainEvent, payload: unknown) => {
deps.reportOverlayContentBounds(payload);
});
} }

View File

@@ -0,0 +1,87 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createOverlayContentMeasurementStoreService,
sanitizeOverlayContentMeasurement,
} from "./overlay-content-measurement-service";
test("sanitizeOverlayContentMeasurement accepts valid payload with null rect", () => {
const measurement = sanitizeOverlayContentMeasurement(
{
layer: "visible",
measuredAtMs: 100,
viewport: { width: 1920, height: 1080 },
contentRect: null,
},
500,
);
assert.deepEqual(measurement, {
layer: "visible",
measuredAtMs: 100,
viewport: { width: 1920, height: 1080 },
contentRect: null,
});
});
test("sanitizeOverlayContentMeasurement rejects invalid ranges", () => {
const measurement = sanitizeOverlayContentMeasurement(
{
layer: "invisible",
measuredAtMs: 100,
viewport: { width: 0, height: 1080 },
contentRect: { x: 0, y: 0, width: 100, height: 20 },
},
500,
);
assert.equal(measurement, null);
});
test("overlay measurement store keeps latest payload per layer", () => {
const store = createOverlayContentMeasurementStoreService({
now: () => 1000,
warn: () => {
// noop
},
});
const visible = store.report({
layer: "visible",
measuredAtMs: 900,
viewport: { width: 1280, height: 720 },
contentRect: { x: 50, y: 60, width: 400, height: 80 },
});
const invisible = store.report({
layer: "invisible",
measuredAtMs: 910,
viewport: { width: 1280, height: 720 },
contentRect: { x: 20, y: 30, width: 300, height: 40 },
});
assert.equal(visible?.layer, "visible");
assert.equal(invisible?.layer, "invisible");
assert.equal(store.getLatestByLayer("visible")?.contentRect?.width, 400);
assert.equal(store.getLatestByLayer("invisible")?.contentRect?.height, 40);
});
test("overlay measurement store rate-limits invalid payload warnings", () => {
let now = 1_000;
const warnings: string[] = [];
const store = createOverlayContentMeasurementStoreService({
now: () => now,
warn: (message) => {
warnings.push(message);
},
});
store.report({ layer: "visible" });
store.report({ layer: "visible" });
assert.equal(warnings.length, 0);
now = 11_000;
store.report({ layer: "visible" });
assert.equal(warnings.length, 1);
assert.match(warnings[0], /Dropped 3 invalid measurement payload/);
});

View File

@@ -0,0 +1,149 @@
import { OverlayContentMeasurement, OverlayContentRect, OverlayLayer } from "../../types";
const MAX_VIEWPORT = 10000;
const MAX_RECT_DIMENSION = 10000;
const MAX_RECT_OFFSET = 50000;
const MAX_FUTURE_TIMESTAMP_MS = 60_000;
const INVALID_LOG_THROTTLE_MS = 10_000;
type OverlayMeasurementStore = Record<OverlayLayer, OverlayContentMeasurement | null>;
export function sanitizeOverlayContentMeasurement(
payload: unknown,
nowMs: number,
): OverlayContentMeasurement | null {
if (!payload || typeof payload !== "object") return null;
const candidate = payload as {
layer?: unknown;
measuredAtMs?: unknown;
viewport?: { width?: unknown; height?: unknown };
contentRect?: { x?: unknown; y?: unknown; width?: unknown; height?: unknown } | null;
};
if (candidate.layer !== "visible" && candidate.layer !== "invisible") {
return null;
}
const viewportWidth = readFiniteInRange(candidate.viewport?.width, 1, MAX_VIEWPORT);
const viewportHeight = readFiniteInRange(candidate.viewport?.height, 1, MAX_VIEWPORT);
if (!Number.isFinite(viewportWidth) || !Number.isFinite(viewportHeight)) {
return null;
}
const measuredAtMs = readFiniteInRange(
candidate.measuredAtMs,
1,
nowMs + MAX_FUTURE_TIMESTAMP_MS,
);
if (!Number.isFinite(measuredAtMs)) {
return null;
}
const contentRect = sanitizeOverlayContentRect(candidate.contentRect);
if (candidate.contentRect !== null && !contentRect) {
return null;
}
return {
layer: candidate.layer,
measuredAtMs,
viewport: { width: viewportWidth, height: viewportHeight },
contentRect,
};
}
function sanitizeOverlayContentRect(
rect: unknown,
): OverlayContentRect | null {
if (rect === null || rect === undefined) {
return null;
}
if (!rect || typeof rect !== "object") {
return null;
}
const candidate = rect as {
x?: unknown;
y?: unknown;
width?: unknown;
height?: unknown;
};
const width = readFiniteInRange(candidate.width, 0, MAX_RECT_DIMENSION);
const height = readFiniteInRange(candidate.height, 0, MAX_RECT_DIMENSION);
const x = readFiniteInRange(candidate.x, -MAX_RECT_OFFSET, MAX_RECT_OFFSET);
const y = readFiniteInRange(candidate.y, -MAX_RECT_OFFSET, MAX_RECT_OFFSET);
if (
!Number.isFinite(width) ||
!Number.isFinite(height) ||
!Number.isFinite(x) ||
!Number.isFinite(y)
) {
return null;
}
return { x, y, width, height };
}
function readFiniteInRange(
value: unknown,
min: number,
max: number,
): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return Number.NaN;
}
if (value < min || value > max) {
return Number.NaN;
}
return value;
}
export function createOverlayContentMeasurementStoreService(options?: {
now?: () => number;
warn?: (message: string) => void;
}) {
const now = options?.now ?? (() => Date.now());
const warn = options?.warn ?? ((message: string) => console.warn(message));
const latestByLayer: OverlayMeasurementStore = {
visible: null,
invisible: null,
};
let droppedInvalid = 0;
let lastInvalidLogAtMs = 0;
function report(payload: unknown): OverlayContentMeasurement | null {
const nowMs = now();
const measurement = sanitizeOverlayContentMeasurement(payload, nowMs);
if (!measurement) {
droppedInvalid += 1;
if (
droppedInvalid > 0 &&
nowMs - lastInvalidLogAtMs >= INVALID_LOG_THROTTLE_MS
) {
warn(
`[overlay-content-bounds] Dropped ${droppedInvalid} invalid measurement payload(s) in the last ${INVALID_LOG_THROTTLE_MS}ms.`,
);
droppedInvalid = 0;
lastInvalidLogAtMs = nowMs;
}
return null;
}
latestByLayer[measurement.layer] = measurement;
return measurement;
}
function getLatestByLayer(layer: OverlayLayer): OverlayContentMeasurement | null {
return latestByLayer[layer];
}
return {
getLatestByLayer,
report,
};
}

View File

@@ -101,6 +101,7 @@ import {
createFieldGroupingOverlayRuntimeService, createFieldGroupingOverlayRuntimeService,
createIpcDepsRuntimeService, createIpcDepsRuntimeService,
createNumericShortcutRuntimeService, createNumericShortcutRuntimeService,
createOverlayContentMeasurementStoreService,
createOverlayShortcutRuntimeHandlers, createOverlayShortcutRuntimeHandlers,
createOverlayWindowService, createOverlayWindowService,
createTokenizerDepsRuntimeService, createTokenizerDepsRuntimeService,
@@ -291,6 +292,12 @@ let runtimeOptionsManager: RuntimeOptionsManager | null = null;
let trackerNotReadyWarningShown = false; let trackerNotReadyWarningShown = false;
let overlayDebugVisualizationEnabled = false; let overlayDebugVisualizationEnabled = false;
const overlayManager = createOverlayManagerService(); const overlayManager = createOverlayManagerService();
const overlayContentMeasurementStore = createOverlayContentMeasurementStoreService({
now: () => Date.now(),
warn: (message: string) => {
console.warn(message);
},
});
type OverlayHostedModal = "runtime-options" | "subsync"; type OverlayHostedModal = "runtime-options" | "subsync";
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>(); const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
@@ -1353,6 +1360,9 @@ registerIpcHandlersService(
getRuntimeOptions: () => getRuntimeOptionsState(), getRuntimeOptions: () => getRuntimeOptionsState(),
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption, setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption, cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
reportOverlayContentBounds: (payload) => {
overlayContentMeasurementStore.report(payload);
},
}), }),
); );

View File

@@ -45,6 +45,7 @@ import type {
RuntimeOptionState, RuntimeOptionState,
RuntimeOptionValue, RuntimeOptionValue,
MpvSubtitleRenderMetrics, MpvSubtitleRenderMetrics,
OverlayContentMeasurement,
} from "./types"; } from "./types";
const overlayLayerArg = process.argv.find((arg) => const overlayLayerArg = process.argv.find((arg) =>
@@ -267,6 +268,9 @@ const electronAPI: ElectronAPI = {
notifyOverlayModalClosed: (modal: "runtime-options" | "subsync") => { notifyOverlayModalClosed: (modal: "runtime-options" | "subsync") => {
ipcRenderer.send("overlay:modal-closed", modal); ipcRenderer.send("overlay:modal-closed", modal);
}, },
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
ipcRenderer.send("overlay-content-bounds:report", measurement);
},
}; };
contextBridge.exposeInMainWorld("electronAPI", electronAPI); contextBridge.exposeInMainWorld("electronAPI", electronAPI);

View File

@@ -0,0 +1,113 @@
import type { OverlayContentMeasurement, OverlayContentRect } from "../types";
import type { RendererContext } from "./context";
const MEASUREMENT_DEBOUNCE_MS = 80;
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
function toMeasuredRect(rect: DOMRect): OverlayContentRect | null {
if (!Number.isFinite(rect.left) || !Number.isFinite(rect.top)) {
return null;
}
if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height)) {
return null;
}
const width = Math.max(0, rect.width);
const height = Math.max(0, rect.height);
return {
x: round2(rect.left),
y: round2(rect.top),
width: round2(width),
height: round2(height),
};
}
function unionRects(a: OverlayContentRect, b: OverlayContentRect): OverlayContentRect {
const left = Math.min(a.x, b.x);
const top = Math.min(a.y, b.y);
const right = Math.max(a.x + a.width, b.x + b.width);
const bottom = Math.max(a.y + a.height, b.y + b.height);
return {
x: round2(left),
y: round2(top),
width: round2(Math.max(0, right - left)),
height: round2(Math.max(0, bottom - top)),
};
}
function hasVisibleTextContent(element: HTMLElement): boolean {
return Boolean(element.textContent && element.textContent.trim().length > 0);
}
function collectContentRect(ctx: RendererContext): OverlayContentRect | null {
let combinedRect: OverlayContentRect | null = null;
const subtitleHasContent = hasVisibleTextContent(ctx.dom.subtitleRoot);
if (subtitleHasContent) {
const subtitleRect = toMeasuredRect(ctx.dom.subtitleRoot.getBoundingClientRect());
if (subtitleRect) {
combinedRect = subtitleRect;
}
}
const secondaryHasContent = hasVisibleTextContent(ctx.dom.secondarySubRoot);
if (secondaryHasContent) {
const secondaryRect = toMeasuredRect(
ctx.dom.secondarySubContainer.getBoundingClientRect(),
);
if (secondaryRect) {
combinedRect = combinedRect
? unionRects(combinedRect, secondaryRect)
: secondaryRect;
}
}
if (!combinedRect) {
return null;
}
return {
x: combinedRect.x,
y: combinedRect.y,
width: round2(Math.max(0, combinedRect.width)),
height: round2(Math.max(0, combinedRect.height)),
};
}
export function createOverlayContentMeasurementReporter(ctx: RendererContext) {
let debounceTimer: number | null = null;
function emitNow(): void {
const measurement: OverlayContentMeasurement = {
layer: ctx.platform.overlayLayer,
measuredAtMs: Date.now(),
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
// Explicit null rect signals "no content yet", and main should use fallback bounds.
contentRect: collectContentRect(ctx),
};
window.electronAPI.reportOverlayContentBounds(measurement);
}
function schedule(): void {
if (debounceTimer !== null) {
window.clearTimeout(debounceTimer);
}
debounceTimer = window.setTimeout(() => {
debounceTimer = null;
emitNow();
}, MEASUREMENT_DEBOUNCE_MS);
}
return {
emitNow,
schedule,
};
}

View File

@@ -32,6 +32,7 @@ import { createKikuModal } from "./modals/kiku.js";
import { createRuntimeOptionsModal } from "./modals/runtime-options.js"; import { createRuntimeOptionsModal } from "./modals/runtime-options.js";
import { createSubsyncModal } from "./modals/subsync.js"; import { createSubsyncModal } from "./modals/subsync.js";
import { createPositioningController } from "./positioning.js"; import { createPositioningController } from "./positioning.js";
import { createOverlayContentMeasurementReporter } from "./overlay-content-measurement.js";
import { createRendererState } from "./state.js"; import { createRendererState } from "./state.js";
import { createSubtitleRenderer } from "./subtitle-render.js"; import { createSubtitleRenderer } from "./subtitle-render.js";
import { resolveRendererDom } from "./utils/dom.js"; import { resolveRendererDom } from "./utils/dom.js";
@@ -69,6 +70,7 @@ function syncSettingsModalSubtitleSuppression(): void {
} }
const subtitleRenderer = createSubtitleRenderer(ctx); const subtitleRenderer = createSubtitleRenderer(ctx);
const measurementReporter = createOverlayContentMeasurementReporter(ctx);
const positioning = createPositioningController(ctx, { const positioning = createPositioningController(ctx, {
modalStateReader: { isAnySettingsModalOpen }, modalStateReader: { isAnySettingsModalOpen },
applySubtitleFontSize: subtitleRenderer.applySubtitleFontSize, applySubtitleFontSize: subtitleRenderer.applySubtitleFontSize,
@@ -114,6 +116,7 @@ async function init(): Promise<void> {
window.electronAPI.onSubtitle((data: SubtitleData) => { window.electronAPI.onSubtitle((data: SubtitleData) => {
subtitleRenderer.renderSubtitle(data); subtitleRenderer.renderSubtitle(data);
measurementReporter.schedule();
}); });
window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => { window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
@@ -122,11 +125,13 @@ async function init(): Promise<void> {
} else { } else {
positioning.applyStoredSubtitlePosition(position, "media-change"); positioning.applyStoredSubtitlePosition(position, "media-change");
} }
measurementReporter.schedule();
}); });
if (ctx.platform.isInvisibleLayer) { if (ctx.platform.isInvisibleLayer) {
window.electronAPI.onMpvSubtitleRenderMetrics((metrics: MpvSubtitleRenderMetrics) => { window.electronAPI.onMpvSubtitleRenderMetrics((metrics: MpvSubtitleRenderMetrics) => {
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, "event"); positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, "event");
measurementReporter.schedule();
}); });
window.electronAPI.onOverlayDebugVisualization((enabled: boolean) => { window.electronAPI.onOverlayDebugVisualization((enabled: boolean) => {
document.body.classList.toggle("debug-invisible-visualization", enabled); document.body.classList.toggle("debug-invisible-visualization", enabled);
@@ -135,16 +140,20 @@ async function init(): Promise<void> {
const initialSubtitle = await window.electronAPI.getCurrentSubtitle(); const initialSubtitle = await window.electronAPI.getCurrentSubtitle();
subtitleRenderer.renderSubtitle(initialSubtitle); subtitleRenderer.renderSubtitle(initialSubtitle);
measurementReporter.schedule();
window.electronAPI.onSecondarySub((text: string) => { window.electronAPI.onSecondarySub((text: string) => {
subtitleRenderer.renderSecondarySub(text); subtitleRenderer.renderSecondarySub(text);
measurementReporter.schedule();
}); });
window.electronAPI.onSecondarySubMode((mode: SecondarySubMode) => { window.electronAPI.onSecondarySubMode((mode: SecondarySubMode) => {
subtitleRenderer.updateSecondarySubMode(mode); subtitleRenderer.updateSecondarySubMode(mode);
measurementReporter.schedule();
}); });
subtitleRenderer.updateSecondarySubMode(await window.electronAPI.getSecondarySubMode()); subtitleRenderer.updateSecondarySubMode(await window.electronAPI.getSecondarySubMode());
subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub()); subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub());
measurementReporter.schedule();
const hoverTarget = ctx.platform.isInvisibleLayer const hoverTarget = ctx.platform.isInvisibleLayer
? ctx.dom.subtitleRoot ? ctx.dom.subtitleRoot
@@ -159,6 +168,9 @@ async function init(): Promise<void> {
mouseHandlers.setupResizeHandler(); mouseHandlers.setupResizeHandler();
mouseHandlers.setupSelectionObserver(); mouseHandlers.setupSelectionObserver();
mouseHandlers.setupYomitanObserver(); mouseHandlers.setupYomitanObserver();
window.addEventListener("resize", () => {
measurementReporter.schedule();
});
jimakuModal.wireDomEvents(); jimakuModal.wireDomEvents();
kikuModal.wireDomEvents(); kikuModal.wireDomEvents();
@@ -211,11 +223,14 @@ async function init(): Promise<void> {
"startup", "startup",
); );
subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle()); subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle());
measurementReporter.schedule();
} }
if (ctx.platform.shouldToggleMouseIgnore) { if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
} }
measurementReporter.emitNow();
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {

View File

@@ -465,6 +465,25 @@ export interface MpvSubtitleRenderMetrics {
} | null; } | null;
} }
export type OverlayLayer = "visible" | "invisible";
export interface OverlayContentRect {
x: number;
y: number;
width: number;
height: number;
}
export interface OverlayContentMeasurement {
layer: OverlayLayer;
measuredAtMs: number;
viewport: {
width: number;
height: number;
};
contentRect: OverlayContentRect | null;
}
export interface MecabStatus { export interface MecabStatus {
available: boolean; available: boolean;
enabled: boolean; enabled: boolean;
@@ -611,6 +630,7 @@ export interface ElectronAPI {
onOpenRuntimeOptions: (callback: () => void) => void; onOpenRuntimeOptions: (callback: () => void) => void;
onOpenJimaku: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void;
notifyOverlayModalClosed: (modal: "runtime-options" | "subsync") => void; notifyOverlayModalClosed: (modal: "runtime-options" | "subsync") => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
} }
declare global { declare global {