mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
Update TASK-20.2 status to done
This commit is contained in:
@@ -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 -->
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
149
src/core/services/overlay-content-measurement-service.ts
Normal file
149
src/core/services/overlay-content-measurement-service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
10
src/main.ts
10
src/main.ts
@@ -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);
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
113
src/renderer/overlay-content-measurement.ts
Normal file
113
src/renderer/overlay-content-measurement.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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") {
|
||||||
|
|||||||
20
src/types.ts
20
src/types.ts
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user