feat: bind overlay state to secondary subtitle mpv visibility

This commit is contained in:
2026-02-26 16:40:51 -08:00
parent a33a87bf8f
commit fa0cb00f70
48 changed files with 1231 additions and 1070 deletions

View File

@@ -77,7 +77,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
if (isObject(src.shortcuts)) {
const shortcutKeys = [
'toggleVisibleOverlayGlobal',
'toggleInvisibleOverlayGlobal',
'copySubtitle',
'copySubtitleMultiple',
'updateLastCardFromClipboard',
@@ -113,24 +112,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
}
}
if (isObject(src.invisibleOverlay)) {
const startupVisibility = src.invisibleOverlay.startupVisibility;
if (
startupVisibility === 'platform-default' ||
startupVisibility === 'visible' ||
startupVisibility === 'hidden'
) {
resolved.invisibleOverlay.startupVisibility = startupVisibility;
} else if (startupVisibility !== undefined) {
warn(
'invisibleOverlay.startupVisibility',
startupVisibility,
resolved.invisibleOverlay.startupVisibility,
'Expected platform-default, visible, or hidden.',
);
}
}
if (isObject(src.secondarySub)) {
if (Array.isArray(src.secondarySub.secondarySubLanguages)) {
resolved.secondarySub.secondarySubLanguages = src.secondarySub.secondarySubLanguages.filter(

View File

@@ -23,7 +23,6 @@ export {
export { createAppLifecycleDepsRuntime, startAppLifecycle } from './app-lifecycle';
export { cycleSecondarySubMode } from './subtitle-position';
export {
getInitialInvisibleOverlayVisibility,
isAutoUpdateEnabledRuntime,
shouldAutoInitializeOverlayRuntimeFromConfig,
shouldBindVisibleOverlayToMpvSubVisibility,
@@ -59,14 +58,12 @@ export {
createOverlayWindow,
enforceOverlayLayerOrder,
ensureOverlayWindowLevel,
syncOverlayWindowLayer,
updateOverlayWindowBounds,
} from './overlay-window';
export { initializeOverlayRuntime } from './overlay-runtime-init';
export {
setInvisibleOverlayVisible,
setVisibleOverlayVisible,
syncInvisibleOverlayMousePassthrough,
updateInvisibleOverlayVisibility,
updateVisibleOverlayVisibility,
} from './overlay-visibility';
export {
@@ -76,6 +73,7 @@ export {
replayCurrentSubtitleRuntime,
resolveCurrentAudioStreamIndex,
sendMpvCommandRuntime,
setMpvSecondarySubVisibilityRuntime,
setMpvSubVisibilityRuntime,
showMpvOsdRuntime,
} from './mpv';

View File

@@ -60,6 +60,8 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
'sub-use-margins',
'pause',
'media-title',
'secondary-sub-visibility',
'sub-visibility',
];
const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [

View File

@@ -119,6 +119,38 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
});
test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => {
const { deps, state } = createDeps({
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
isVisibleOverlayVisible: () => true,
});
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'sub-visibility', data: 'yes' },
deps,
);
assert.deepEqual(state.commands.pop(), {
command: ['set_property', 'sub-visibility', 'no'],
});
});
test('dispatchMpvProtocolMessage enforces secondary sub-visibility hidden when overlay suppression is enabled', async () => {
const { deps, state } = createDeps({
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
isVisibleOverlayVisible: () => true,
});
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'secondary-sub-visibility', data: 'yes' },
deps,
);
assert.deepEqual(state.commands.pop(), {
command: ['set_property', 'secondary-sub-visibility', 'no'],
});
});
test('dispatchMpvProtocolMessage sets secondary subtitle track based on track list response', async () => {
const { deps, state } = createDeps();

View File

@@ -48,6 +48,7 @@ export interface MpvProtocolHandleMessageDeps {
};
getSubtitleMetrics: () => MpvSubtitleRenderMetrics;
isVisibleOverlayVisible: () => boolean;
shouldBindVisibleOverlayToMpvSubVisibility?: () => boolean;
emitSubtitleChange: (payload: { text: string; isOverlayVisible: boolean }) => void;
emitSubtitleAssChange: (payload: { text: string }) => void;
emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
@@ -216,6 +217,22 @@ export async function dispatchMpvProtocolMessage(
deps.emitSubtitleMetricsChange({
subScaleByWindow: asBoolean(msg.data, deps.getSubtitleMetrics().subScaleByWindow),
});
} else if (msg.name === 'sub-visibility') {
if (
deps.shouldBindVisibleOverlayToMpvSubVisibility?.() &&
deps.isVisibleOverlayVisible() &&
asBoolean(msg.data, false)
) {
deps.sendCommand({ command: ['set_property', 'sub-visibility', 'no'] });
}
} else if (msg.name === 'secondary-sub-visibility') {
if (
deps.shouldBindVisibleOverlayToMpvSubVisibility?.() &&
deps.isVisibleOverlayVisible() &&
asBoolean(msg.data, false)
) {
deps.sendCommand({ command: ['set_property', 'secondary-sub-visibility', 'no'] });
}
} else if (msg.name === 'sub-use-margins') {
deps.emitSubtitleMetricsChange({
subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins),

View File

@@ -306,6 +306,32 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re
assert.equal(hasPathRequest, true);
});
test('MpvIpcClient connect does not force primary subtitle visibility from binding path', () => {
const commands: unknown[] = [];
const client = new MpvIpcClient(
'/tmp/mpv.sock',
makeDeps({
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
isVisibleOverlayVisible: () => true,
}),
);
(client as any).send = (command: unknown) => {
commands.push(command);
return true;
};
const callbacks = (client as any).transport.callbacks;
callbacks.onConnect();
const hasPrimaryVisibilityMutation = commands.some(
(command) =>
Array.isArray((command as { command: unknown[] }).command) &&
(command as { command: unknown[] }).command[0] === 'set_property' &&
(command as { command: unknown[] }).command[1] === 'sub-visibility',
);
assert.equal(hasPrimaryVisibilityMutation, false);
});
test('MpvIpcClient captures and disables secondary subtitle visibility on request', async () => {
const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());

View File

@@ -44,6 +44,7 @@ export interface MpvRuntimeClientLike {
replayCurrentSubtitle?: () => void;
playNextSubtitle?: () => void;
setSubVisibility?: (visible: boolean) => void;
setSecondarySubVisibility?: (visible: boolean) => void;
}
export function showMpvOsdRuntime(
@@ -84,6 +85,14 @@ export function setMpvSubVisibilityRuntime(
mpvClient.setSubVisibility(visible);
}
export function setMpvSecondarySubVisibilityRuntime(
mpvClient: MpvRuntimeClientLike | null,
visible: boolean,
): void {
if (!mpvClient?.setSecondarySubVisibility) return;
mpvClient.setSecondarySubVisibility(visible);
}
export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from './mpv-protocol';
export interface MpvIpcClientProtocolDeps {
@@ -181,8 +190,6 @@ export class MpvIpcClient implements MpvClient {
setTimeout(() => {
this.deps.setOverlayVisible(true);
}, 100);
} else if (this.deps.shouldBindVisibleOverlayToMpvSubVisibility()) {
this.setSubVisibility(!this.deps.isVisibleOverlayVisible());
}
this.firstConnection = false;
@@ -290,6 +297,8 @@ export class MpvIpcClient implements MpvClient {
getResolvedConfig: () => this.deps.getResolvedConfig(),
getSubtitleMetrics: () => this.mpvSubtitleRenderMetrics,
isVisibleOverlayVisible: () => this.deps.isVisibleOverlayVisible(),
shouldBindVisibleOverlayToMpvSubVisibility: () =>
this.deps.shouldBindVisibleOverlayToMpvSubVisibility(),
emitSubtitleChange: (payload) => {
this.emit('subtitle-change', payload);
},
@@ -488,7 +497,7 @@ export class MpvIpcClient implements MpvClient {
this.previousSecondarySubVisibility = null;
}
private setSecondarySubVisibility(visible: boolean): void {
setSecondarySubVisibility(visible: boolean): void {
this.send({
command: ['set_property', 'secondary-sub-visibility', visible ? 'yes' : 'no'],
});

View File

@@ -9,11 +9,8 @@ import {
test('overlay manager initializes with empty windows and hidden overlays', () => {
const manager = createOverlayManager();
assert.equal(manager.getMainWindow(), null);
assert.equal(manager.getInvisibleWindow(), null);
assert.equal(manager.getSecondaryWindow(), null);
assert.equal(manager.getModalWindow(), null);
assert.equal(manager.getVisibleOverlayVisible(), false);
assert.equal(manager.getInvisibleOverlayVisible(), false);
assert.deepEqual(manager.getOverlayWindows(), []);
});
@@ -22,28 +19,17 @@ test('overlay manager stores window references and returns stable window order',
const visibleWindow = {
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow;
const invisibleWindow = {
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow;
const secondaryWindow = {
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow;
const modalWindow = {
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow;
manager.setMainWindow(visibleWindow);
manager.setInvisibleWindow(invisibleWindow);
manager.setSecondaryWindow(secondaryWindow);
manager.setModalWindow(modalWindow);
assert.equal(manager.getMainWindow(), visibleWindow);
assert.equal(manager.getInvisibleWindow(), invisibleWindow);
assert.equal(manager.getSecondaryWindow(), secondaryWindow);
assert.equal(manager.getModalWindow(), modalWindow);
assert.equal(manager.getOverlayWindow('visible'), visibleWindow);
assert.equal(manager.getOverlayWindow('invisible'), invisibleWindow);
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow, secondaryWindow]);
assert.equal(manager.getOverlayWindow(), visibleWindow);
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow]);
});
test('overlay manager excludes destroyed windows', () => {
@@ -51,26 +37,18 @@ test('overlay manager excludes destroyed windows', () => {
manager.setMainWindow({
isDestroyed: () => true,
} as unknown as Electron.BrowserWindow);
manager.setInvisibleWindow({
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow);
manager.setSecondaryWindow({
isDestroyed: () => true,
} as unknown as Electron.BrowserWindow);
manager.setModalWindow({
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow);
assert.equal(manager.getOverlayWindows().length, 1);
assert.equal(manager.getOverlayWindows().length, 0);
});
test('overlay manager stores visibility state', () => {
const manager = createOverlayManager();
manager.setVisibleOverlayVisible(true);
manager.setInvisibleOverlayVisible(true);
assert.equal(manager.getVisibleOverlayVisible(), true);
assert.equal(manager.getInvisibleOverlayVisible(), true);
});
test('overlay manager broadcasts to non-destroyed windows', () => {
@@ -84,58 +62,25 @@ test('overlay manager broadcasts to non-destroyed windows', () => {
},
},
} as unknown as Electron.BrowserWindow;
const deadWindow = {
isDestroyed: () => true,
webContents: {
send: (..._args: unknown[]) => {},
},
} as unknown as Electron.BrowserWindow;
const secondaryWindow = {
isDestroyed: () => false,
webContents: {
send: (...args: unknown[]) => {
calls.push(args);
},
},
} as unknown as Electron.BrowserWindow;
manager.setMainWindow(aliveWindow);
manager.setInvisibleWindow(deadWindow);
manager.setSecondaryWindow(secondaryWindow);
manager.setModalWindow({
isDestroyed: () => false,
webContents: { send: () => {} },
} as unknown as Electron.BrowserWindow);
manager.broadcastToOverlayWindows('x', 1, 'a');
assert.deepEqual(calls, [
['x', 1, 'a'],
['x', 1, 'a'],
]);
assert.deepEqual(calls, [['x', 1, 'a']]);
});
test('overlay manager applies bounds by layer', () => {
test('overlay manager applies bounds for main and modal windows', () => {
const manager = createOverlayManager();
const visibleCalls: Electron.Rectangle[] = [];
const invisibleCalls: Electron.Rectangle[] = [];
const visibleWindow = {
isDestroyed: () => false,
setBounds: (bounds: Electron.Rectangle) => {
visibleCalls.push(bounds);
},
} as unknown as Electron.BrowserWindow;
const invisibleWindow = {
isDestroyed: () => false,
setBounds: (bounds: Electron.Rectangle) => {
invisibleCalls.push(bounds);
},
} as unknown as Electron.BrowserWindow;
const secondaryWindow = {
isDestroyed: () => false,
setBounds: (bounds: Electron.Rectangle) => {
invisibleCalls.push(bounds);
},
} as unknown as Electron.BrowserWindow;
const modalCalls: Electron.Rectangle[] = [];
const modalWindow = {
isDestroyed: () => false,
@@ -144,28 +89,14 @@ test('overlay manager applies bounds by layer', () => {
},
} as unknown as Electron.BrowserWindow;
manager.setMainWindow(visibleWindow);
manager.setInvisibleWindow(invisibleWindow);
manager.setSecondaryWindow(secondaryWindow);
manager.setModalWindow(modalWindow);
manager.setOverlayWindowBounds('visible', {
manager.setOverlayWindowBounds({
x: 10,
y: 20,
width: 30,
height: 40,
});
manager.setOverlayWindowBounds('invisible', {
x: 1,
y: 2,
width: 3,
height: 4,
});
manager.setSecondaryWindowBounds({
x: 8,
y: 9,
width: 10,
height: 11,
});
manager.setModalWindowBounds({
x: 80,
y: 90,
@@ -174,14 +105,10 @@ test('overlay manager applies bounds by layer', () => {
});
assert.deepEqual(visibleCalls, [{ x: 10, y: 20, width: 30, height: 40 }]);
assert.deepEqual(invisibleCalls, [
{ x: 1, y: 2, width: 3, height: 4 },
{ x: 8, y: 9, width: 10, height: 11 },
]);
assert.deepEqual(modalCalls, [{ x: 80, y: 90, width: 100, height: 110 }]);
});
test('runtime-option and debug broadcasts use expected channels', () => {
test('runtime-option broadcast still uses expected channel', () => {
const broadcasts: unknown[][] = [];
broadcastRuntimeOptionsChangedRuntime(
() => [],
@@ -196,14 +123,8 @@ test('runtime-option and debug broadcasts use expected channels', () => {
(enabled) => {
state = enabled;
},
(channel, ...args) => {
broadcasts.push([channel, ...args]);
},
);
assert.equal(changed, true);
assert.equal(state, true);
assert.deepEqual(broadcasts, [
['runtime-options:changed', []],
['overlay-debug-visualization:set', true],
]);
assert.deepEqual(broadcasts, [['runtime-options:changed', []]]);
});

View File

@@ -2,60 +2,37 @@ import type { BrowserWindow } from 'electron';
import { RuntimeOptionState, WindowGeometry } from '../../types';
import { updateOverlayWindowBounds } from './overlay-window';
type OverlayLayer = 'visible' | 'invisible';
export interface OverlayManager {
getMainWindow: () => BrowserWindow | null;
setMainWindow: (window: BrowserWindow | null) => void;
getInvisibleWindow: () => BrowserWindow | null;
setInvisibleWindow: (window: BrowserWindow | null) => void;
getSecondaryWindow: () => BrowserWindow | null;
setSecondaryWindow: (window: BrowserWindow | null) => void;
getModalWindow: () => BrowserWindow | null;
setModalWindow: (window: BrowserWindow | null) => void;
getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null;
setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void;
setSecondaryWindowBounds: (geometry: WindowGeometry) => void;
getOverlayWindow: () => BrowserWindow | null;
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
setModalWindowBounds: (geometry: WindowGeometry) => void;
getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
getInvisibleOverlayVisible: () => boolean;
setInvisibleOverlayVisible: (visible: boolean) => void;
getOverlayWindows: () => BrowserWindow[];
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
}
export function createOverlayManager(): OverlayManager {
let mainWindow: BrowserWindow | null = null;
let invisibleWindow: BrowserWindow | null = null;
let secondaryWindow: BrowserWindow | null = null;
let modalWindow: BrowserWindow | null = null;
let visibleOverlayVisible = false;
let invisibleOverlayVisible = false;
return {
getMainWindow: () => mainWindow,
setMainWindow: (window) => {
mainWindow = window;
},
getInvisibleWindow: () => invisibleWindow,
setInvisibleWindow: (window) => {
invisibleWindow = window;
},
getSecondaryWindow: () => secondaryWindow,
setSecondaryWindow: (window) => {
secondaryWindow = window;
},
getModalWindow: () => modalWindow,
setModalWindow: (window) => {
modalWindow = window;
},
getOverlayWindow: (layer) => (layer === 'visible' ? mainWindow : invisibleWindow),
setOverlayWindowBounds: (layer, geometry) => {
updateOverlayWindowBounds(geometry, layer === 'visible' ? mainWindow : invisibleWindow);
},
setSecondaryWindowBounds: (geometry) => {
updateOverlayWindowBounds(geometry, secondaryWindow);
getOverlayWindow: () => mainWindow,
setOverlayWindowBounds: (geometry) => {
updateOverlayWindowBounds(geometry, mainWindow);
},
setModalWindowBounds: (geometry) => {
updateOverlayWindowBounds(geometry, modalWindow);
@@ -64,36 +41,12 @@ export function createOverlayManager(): OverlayManager {
setVisibleOverlayVisible: (visible) => {
visibleOverlayVisible = visible;
},
getInvisibleOverlayVisible: () => invisibleOverlayVisible,
setInvisibleOverlayVisible: (visible) => {
invisibleOverlayVisible = visible;
},
getOverlayWindows: () => {
const windows: BrowserWindow[] = [];
if (mainWindow && !mainWindow.isDestroyed()) {
windows.push(mainWindow);
}
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
windows.push(invisibleWindow);
}
if (secondaryWindow && !secondaryWindow.isDestroyed()) {
windows.push(secondaryWindow);
}
return windows;
return mainWindow && !mainWindow.isDestroyed() ? [mainWindow] : [];
},
broadcastToOverlayWindows: (channel, ...args) => {
const windows: BrowserWindow[] = [];
if (mainWindow && !mainWindow.isDestroyed()) {
windows.push(mainWindow);
}
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
windows.push(invisibleWindow);
}
if (secondaryWindow && !secondaryWindow.isDestroyed()) {
windows.push(secondaryWindow);
}
for (const window of windows) {
window.webContents.send(channel, ...args);
mainWindow.webContents.send(channel, ...args);
}
},
};
@@ -110,10 +63,8 @@ export function setOverlayDebugVisualizationEnabledRuntime(
currentEnabled: boolean,
nextEnabled: boolean,
setState: (enabled: boolean) => void,
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
): boolean {
if (currentEnabled === nextEnabled) return false;
setState(nextEnabled);
broadcastToOverlayWindows('overlay-debug-visualization:set', nextEnabled);
return true;
}

View File

@@ -1,41 +0,0 @@
import type { WindowGeometry } from '../../types';
export const SECONDARY_OVERLAY_MAX_HEIGHT_RATIO = 0.2;
function toInteger(value: number): number {
return Number.isFinite(value) ? Math.round(value) : 0;
}
function clampPositive(value: number): number {
return Math.max(1, toInteger(value));
}
export function splitOverlayGeometryForSecondaryBar(geometry: WindowGeometry): {
secondary: WindowGeometry;
primary: WindowGeometry;
} {
const x = toInteger(geometry.x);
const y = toInteger(geometry.y);
const width = clampPositive(geometry.width);
const totalHeight = clampPositive(geometry.height);
const secondaryHeight = clampPositive(
Math.min(totalHeight, Math.round(totalHeight * SECONDARY_OVERLAY_MAX_HEIGHT_RATIO)),
);
const primaryHeight = clampPositive(totalHeight - secondaryHeight);
return {
secondary: {
x,
y,
width,
height: secondaryHeight,
},
primary: {
x,
y: y + secondaryHeight,
width,
height: primaryHeight,
},
};
}

View File

@@ -1,37 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { splitOverlayGeometryForSecondaryBar } from './overlay-window-geometry';
test('splitOverlayGeometryForSecondaryBar returns 20/80 top-bottom regions', () => {
const regions = splitOverlayGeometryForSecondaryBar({
x: 100,
y: 50,
width: 1200,
height: 900,
});
assert.deepEqual(regions.secondary, {
x: 100,
y: 50,
width: 1200,
height: 180,
});
assert.deepEqual(regions.primary, {
x: 100,
y: 230,
width: 1200,
height: 720,
});
});
test('splitOverlayGeometryForSecondaryBar keeps positive sizes for tiny heights', () => {
const regions = splitOverlayGeometryForSecondaryBar({
x: 0,
y: 0,
width: 300,
height: 1,
});
assert.ok(regions.secondary.height >= 1);
assert.ok(regions.primary.height >= 1);
});

View File

@@ -4,8 +4,25 @@ import { WindowGeometry } from '../../types';
import { createLogger } from '../../logger';
const logger = createLogger('main:overlay-window');
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
export type OverlayWindowKind = 'visible' | 'invisible' | 'secondary' | 'modal';
function getOverlayWindowHtmlPath(): string {
return path.join(__dirname, '..', '..', 'renderer', 'index.html');
}
function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind): void {
overlayWindowLayerByInstance.set(window, layer);
const htmlPath = getOverlayWindowHtmlPath();
window
.loadFile(htmlPath, {
query: { layer },
})
.catch((err) => {
logger.error('Failed to load HTML file:', err);
});
}
export type OverlayWindowKind = 'visible' | 'modal';
export function updateOverlayWindowBounds(
geometry: WindowGeometry,
@@ -32,14 +49,11 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void {
export function enforceOverlayLayerOrder(options: {
visibleOverlayVisible: boolean;
invisibleOverlayVisible: boolean;
mainWindow: BrowserWindow | null;
invisibleWindow: BrowserWindow | null;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
}): void {
if (!options.visibleOverlayVisible || !options.invisibleOverlayVisible) return;
if (!options.visibleOverlayVisible) return;
if (!options.mainWindow || options.mainWindow.isDestroyed()) return;
if (!options.invisibleWindow || options.invisibleWindow.isDestroyed()) return;
options.ensureOverlayWindowLevel(options.mainWindow);
options.mainWindow.moveTop();
@@ -49,7 +63,6 @@ export function createOverlayWindow(
kind: OverlayWindowKind,
options: {
isDev: boolean;
overlayDebugVisualizationEnabled: boolean;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
@@ -83,16 +96,7 @@ export function createOverlayWindow(
});
options.ensureOverlayWindowLevel(window);
const htmlPath = path.join(__dirname, '..', '..', 'renderer', 'index.html');
window
.loadFile(htmlPath, {
query: { layer: kind },
})
.catch((err) => {
logger.error('Failed to load HTML file:', err);
});
loadOverlayWindowLayer(window, kind);
window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
logger.error('Page failed to load:', errorCode, errorDescription, validatedURL);
@@ -100,10 +104,6 @@ export function createOverlayWindow(
window.webContents.on('did-finish-load', () => {
options.onRuntimeOptionsChanged();
window.webContents.send(
'overlay-debug-visualization:set',
options.overlayDebugVisualizationEnabled,
);
});
if (kind === 'visible') {
@@ -140,3 +140,9 @@ export function createOverlayWindow(
return window;
}
export function syncOverlayWindowLayer(window: BrowserWindow, layer: 'visible'): void {
if (window.isDestroyed()) return;
if (overlayWindowLayerByInstance.get(window) === layer) return;
loadOverlayWindowLayer(window, layer);
}

View File

@@ -218,7 +218,6 @@ import {
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler,
createBuildEnforceOverlayLayerOrderMainDepsHandler,
createBuildEnsureOverlayWindowLevelMainDepsHandler,
createBuildUpdateInvisibleOverlayBoundsMainDepsHandler,
createBuildUpdateVisibleOverlayBoundsMainDepsHandler,
createOverlayWindowRuntimeHandlers,
createOverlayRuntimeBootstrapHandlers,
@@ -234,7 +233,6 @@ import {
createSetOverlayDebugVisualizationEnabledHandler,
createEnforceOverlayLayerOrderHandler,
createEnsureOverlayWindowLevelHandler,
createUpdateInvisibleOverlayBoundsHandler,
createUpdateVisibleOverlayBoundsHandler,
createLoadSubtitlePositionHandler,
createSaveSubtitlePositionHandler,
@@ -356,16 +354,16 @@ import {
runStartupBootstrapRuntime,
saveSubtitlePosition as saveSubtitlePositionCore,
sendMpvCommandRuntime,
setInvisibleOverlayVisible as setInvisibleOverlayVisibleCore,
setMpvSecondarySubVisibilityRuntime,
setMpvSubVisibilityRuntime,
setOverlayDebugVisualizationEnabledRuntime,
syncOverlayWindowLayer,
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
showMpvOsdRuntime,
tokenizeSubtitle as tokenizeSubtitleCore,
triggerFieldGrouping as triggerFieldGroupingCore,
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
} from './core/services';
import { splitOverlayGeometryForSecondaryBar } from './core/services/overlay-window-geometry';
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
import {
guessAnilistMediaInfo,
@@ -376,7 +374,10 @@ import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
import { createMainRuntimeRegistry } from './main/runtime/registry';
import { createApplyHoveredTokenOverlayHandler } from './main/runtime/mpv-hover-highlight';
import {
createEnsureOverlayMpvSubtitlesHiddenHandler,
createRestoreOverlayMpvSubtitlesHandler,
} from './main/runtime/overlay-mpv-sub-visibility';
import {
composeAnilistSetupHandlers,
composeAnilistTrackingHandlers,
@@ -644,7 +645,6 @@ const buildOverlayContentMeasurementStoreMainDepsHandler =
});
const buildOverlayModalRuntimeMainDepsHandler = createBuildOverlayModalRuntimeMainDepsHandler({
getMainWindow: () => overlayManager.getMainWindow(),
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
getModalWindow: () => overlayManager.getModalWindow(),
createModalWindow: () => createModalWindow(),
getModalGeometry: () => getCurrentOverlayGeometry(),
@@ -725,13 +725,70 @@ async function initializeDiscordPresenceService(): Promise<void> {
await appState.discordPresenceService.start();
publishDiscordPresence();
}
const applyHoveredTokenOverlay = createApplyHoveredTokenOverlayHandler({
const ensureOverlayMpvSubtitlesHidden = createEnsureOverlayMpvSubtitlesHiddenHandler({
getMpvClient: () => appState.mpvClient,
getCurrentSubtitleData: () => appState.currentSubtitleData,
getHoveredTokenIndex: () => appState.hoveredSubtitleTokenIndex,
getHoveredSubtitleRevision: () => appState.hoveredSubtitleRevision,
getHoverTokenColor: () => getResolvedConfig().subtitleStyle.hoverTokenColor ?? null,
getSavedSubVisibility: () => appState.overlaySavedMpvSubVisibility,
setSavedSubVisibility: (visible) => {
appState.overlaySavedMpvSubVisibility = visible;
},
getSavedSecondarySubVisibility: () => appState.overlaySavedSecondaryMpvSubVisibility,
setSavedSecondarySubVisibility: (visible) => {
appState.overlaySavedSecondaryMpvSubVisibility = visible;
},
getRevision: () => appState.overlayMpvSubVisibilityRevision,
setRevision: (revision) => {
appState.overlayMpvSubVisibilityRevision = revision;
},
setMpvSubVisibility: (visible) => {
setMpvSubVisibilityRuntime(appState.mpvClient, visible);
},
setMpvSecondarySubVisibility: (visible) => {
setMpvSecondarySubVisibilityRuntime(appState.mpvClient, visible);
},
logWarn: (message, error) => {
logger.warn(message, error);
},
});
const restoreOverlayMpvSubtitles = createRestoreOverlayMpvSubtitlesHandler({
getSavedSubVisibility: () => appState.overlaySavedMpvSubVisibility,
setSavedSubVisibility: (visible) => {
appState.overlaySavedMpvSubVisibility = visible;
},
getSavedSecondarySubVisibility: () => appState.overlaySavedSecondaryMpvSubVisibility,
setSavedSecondarySubVisibility: (visible) => {
appState.overlaySavedSecondaryMpvSubVisibility = visible;
},
getRevision: () => appState.overlayMpvSubVisibilityRevision,
setRevision: (revision) => {
appState.overlayMpvSubVisibilityRevision = revision;
},
isMpvConnected: () => Boolean(appState.mpvClient?.connected),
shouldKeepSuppressedFromVisibleOverlayBinding: () => shouldSuppressMpvSubtitlesForOverlay(),
setMpvSubVisibility: (visible) => {
setMpvSubVisibilityRuntime(appState.mpvClient, visible);
},
setMpvSecondarySubVisibility: (visible) => {
setMpvSecondarySubVisibilityRuntime(appState.mpvClient, visible);
},
});
function shouldSuppressMpvSubtitlesForOverlay(): boolean {
return (
appState.secondarySubMode === 'visible' ||
(overlayManager.getVisibleOverlayVisible() &&
configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility())
);
}
function syncOverlayMpvSubtitleSuppression(): void {
if (shouldSuppressMpvSubtitlesForOverlay()) {
void ensureOverlayMpvSubtitlesHidden();
return;
}
restoreOverlayMpvSubtitles();
}
const buildImmersionMediaRuntimeMainDepsHandler = createBuildImmersionMediaRuntimeMainDepsHandler({
getResolvedConfig: () => getResolvedConfig(),
defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH,
@@ -766,7 +823,6 @@ const buildAnilistStateRuntimeMainDepsHandler = createBuildAnilistStateRuntimeMa
const buildConfigDerivedRuntimeMainDepsHandler = createBuildConfigDerivedRuntimeMainDepsHandler({
getResolvedConfig: () => getResolvedConfig(),
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
platform: process.platform,
defaultJimakuLanguagePreference: DEFAULT_CONFIG.jimaku.languagePreference,
defaultJimakuMaxEntryResults: DEFAULT_CONFIG.jimaku.maxEntryResults,
defaultJimakuApiBaseUrl: DEFAULT_CONFIG.jimaku.apiBaseUrl,
@@ -801,15 +857,7 @@ const buildSubtitleProcessingControllerMainDepsHandler =
return await tokenizeSubtitle(text);
},
emitSubtitle: (payload) => {
const previousSubtitleText = appState.currentSubtitleData?.text ?? null;
const nextSubtitleText = payload?.text ?? null;
const subtitleChanged = previousSubtitleText !== nextSubtitleText;
appState.currentSubtitleData = payload;
if (subtitleChanged) {
appState.hoveredSubtitleTokenIndex = null;
appState.hoveredSubtitleRevision += 1;
applyHoveredTokenOverlay();
}
broadcastToOverlayWindows('subtitle:set', payload);
subtitleWsService.broadcast(payload, {
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
@@ -850,7 +898,7 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
copySubtitle: () => {
copyCurrentSubtitle();
},
toggleSecondarySubMode: () => cycleSecondarySubMode(),
toggleSecondarySubMode: () => handleCycleSecondarySubMode(),
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
@@ -899,8 +947,7 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
refreshGlobalAndOverlayShortcuts();
},
setSecondarySubMode: (mode) => {
appState.secondarySubMode = mode;
syncSecondaryOverlayWindowVisibility();
setSecondarySubMode(mode);
},
broadcastToOverlayWindows: (channel, payload) => {
broadcastToOverlayWindows(channel, payload);
@@ -1023,9 +1070,7 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
createBuildFieldGroupingOverlayMainDepsHandler<OverlayHostedModal>({
getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible),
setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible),
getResolver: () => getFieldGroupingResolver(),
setResolver: (resolver) => setFieldGroupingResolver(resolver),
getRestoreVisibleOverlayOnModalClose: () =>
@@ -1067,26 +1112,40 @@ const mediaRuntime = createMediaRuntimeService(
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
createBuildOverlayVisibilityRuntimeMainDepsHandler({
getMainWindow: () => overlayManager.getMainWindow(),
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
getWindowTracker: () => appState.windowTracker,
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown: boolean) => {
appState.trackerNotReadyWarningShown = shown;
},
updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry),
updateInvisibleOverlayBounds: (geometry: WindowGeometry) =>
updateInvisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window) => {
ensureOverlayWindowLevel(window);
},
syncPrimaryOverlayWindowLayer: (layer) => {
syncPrimaryOverlayWindowLayer(layer);
},
enforceOverlayLayerOrder: () => {
enforceOverlayLayerOrder();
},
syncOverlayShortcuts: () => {
overlayShortcutsRuntime.syncOverlayShortcuts();
},
isMacOSPlatform: () => process.platform === 'darwin',
showOverlayLoadingOsd: (message: string) => {
showMpvOsd(message);
},
resolveFallbackBounds: () => {
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
const fallbackBounds = display.workArea;
return {
x: fallbackBounds.x,
y: fallbackBounds.y,
width: fallbackBounds.width,
height: fallbackBounds.height,
};
},
})(),
);
@@ -1165,7 +1224,6 @@ const buildSetOverlayDebugVisualizationEnabledMainDepsHandler =
setCurrentEnabled: (next) => {
appState.overlayDebugVisualizationEnabled = next;
},
broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args),
});
const setOverlayDebugVisualizationEnabledMainDeps =
buildSetOverlayDebugVisualizationEnabledMainDepsHandler();
@@ -1826,6 +1884,9 @@ const {
destroyTray: () => destroyTray(),
stopConfigHotReload: () => configHotReloadRuntime.stop(),
restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(),
restoreMpvSubVisibilityForInvisibleOverlay: () => {
restoreOverlayMpvSubtitles({ respectVisibleOverlayBinding: false });
},
unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(),
stopSubtitleWebsocket: () => subtitleWsService.stop(),
stopTexthookerService: () => texthookerService.stop(),
@@ -1870,14 +1931,11 @@ const {
createMainWindow: () => {
createMainWindow();
},
createInvisibleWindow: () => {
createInvisibleWindow();
},
updateVisibleOverlayVisibility: () => {
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
},
updateInvisibleOverlayVisibility: () => {
overlayVisibilityRuntime.updateInvisibleOverlayVisibility();
syncOverlayMpvSubtitleSuppression: () => {
syncOverlayMpvSubtitleSuppression();
},
},
});
@@ -1934,8 +1992,7 @@ const { reloadConfig: reloadConfigHandler, appReadyRuntimeRunner } = composeAppR
);
},
setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode;
syncSecondaryOverlayWindowVisibility();
setSecondarySubMode(mode);
},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
@@ -2124,6 +2181,9 @@ const {
updateCurrentMediaPath: (path) => {
mediaRuntime.updateCurrentMediaPath(path);
},
restoreMpvSubVisibilityForInvisibleOverlay: () => {
restoreOverlayMpvSubtitles({ respectVisibleOverlayBinding: false });
},
getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => {
resetAnilistMediaTracking(mediaKey);
@@ -2149,6 +2209,9 @@ const {
updateSubtitleRenderMetrics: (patch) => {
updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>);
},
syncOverlayMpvSubtitleSuppression: () => {
syncOverlayMpvSubtitleSuppression();
},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: MpvIpcClient,
@@ -2170,8 +2233,8 @@ const {
appState.mpvSubtitleRenderMetrics = metrics;
},
applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch),
broadcastMetrics: (metrics) => {
broadcastToOverlayWindows('mpv-subtitle-render-metrics:set', metrics);
broadcastMetrics: () => {
// no renderer consumer for subtitle render metrics updates at present
},
},
tokenizer: {
@@ -2276,52 +2339,21 @@ function getCurrentOverlayGeometry(): WindowGeometry {
return getOverlayGeometryFallback();
}
function syncSecondaryOverlayWindowVisibility(): void {
const secondaryWindow = overlayManager.getSecondaryWindow();
if (!secondaryWindow || secondaryWindow.isDestroyed()) return;
if (appState.secondarySubMode === 'hidden') {
secondaryWindow.setIgnoreMouseEvents(true, { forward: true });
secondaryWindow.hide();
return;
}
secondaryWindow.setIgnoreMouseEvents(false);
ensureOverlayWindowLevel(secondaryWindow);
if (typeof secondaryWindow.showInactive === 'function') {
secondaryWindow.showInactive();
} else {
secondaryWindow.show();
}
}
function applyOverlayRegions(layer: 'visible' | 'invisible', geometry: WindowGeometry): void {
function applyOverlayRegions(geometry: WindowGeometry): void {
lastOverlayWindowGeometry = geometry;
const regions = splitOverlayGeometryForSecondaryBar(geometry);
overlayManager.setOverlayWindowBounds(layer, regions.primary);
overlayManager.setSecondaryWindowBounds(regions.secondary);
overlayManager.setOverlayWindowBounds(geometry);
overlayManager.setModalWindowBounds(geometry);
syncSecondaryOverlayWindowVisibility();
}
const buildUpdateVisibleOverlayBoundsMainDepsHandler =
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
setOverlayWindowBounds: (layer, geometry) => applyOverlayRegions(layer, geometry),
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
});
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
updateVisibleOverlayBoundsMainDeps,
);
const buildUpdateInvisibleOverlayBoundsMainDepsHandler =
createBuildUpdateInvisibleOverlayBoundsMainDepsHandler({
setOverlayWindowBounds: (layer, geometry) => applyOverlayRegions(layer, geometry),
});
const updateInvisibleOverlayBoundsMainDeps = buildUpdateInvisibleOverlayBoundsMainDepsHandler();
const updateInvisibleOverlayBounds = createUpdateInvisibleOverlayBoundsHandler(
updateInvisibleOverlayBoundsMainDeps,
);
const buildEnsureOverlayWindowLevelMainDepsHandler =
createBuildEnsureOverlayWindowLevelMainDepsHandler({
ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow),
@@ -2331,21 +2363,23 @@ const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler(
ensureOverlayWindowLevelMainDeps,
);
function syncPrimaryOverlayWindowLayer(layer: 'visible'): void {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
syncOverlayWindowLayer(mainWindow, layer);
}
const buildEnforceOverlayLayerOrderMainDepsHandler =
createBuildEnforceOverlayLayerOrderMainDepsHandler({
enforceOverlayLayerOrderCore: (params) =>
enforceOverlayLayerOrderCore({
visibleOverlayVisible: params.visibleOverlayVisible,
invisibleOverlayVisible: params.invisibleOverlayVisible,
mainWindow: params.mainWindow as BrowserWindow | null,
invisibleWindow: params.invisibleWindow as BrowserWindow | null,
ensureOverlayWindowLevel: (window) =>
params.ensureOverlayWindowLevel(window as BrowserWindow),
}),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow),
});
const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler();
@@ -2361,7 +2395,7 @@ async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
return yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
}
function createOverlayWindow(kind: 'visible' | 'invisible' | 'secondary' | 'modal'): BrowserWindow {
function createOverlayWindow(kind: 'visible' | 'modal'): BrowserWindow {
return createOverlayWindowHandler(kind);
}
@@ -2375,25 +2409,9 @@ function createModalWindow(): BrowserWindow {
return window;
}
function createSecondaryWindow(): BrowserWindow {
const existingWindow = overlayManager.getSecondaryWindow();
if (existingWindow && !existingWindow.isDestroyed()) {
return existingWindow;
}
const window = createSecondaryWindowHandler();
applyOverlayRegions('visible', getCurrentOverlayGeometry());
return window;
}
function createMainWindow(): BrowserWindow {
const window = createMainWindowHandler();
createSecondaryWindow();
return window;
return createMainWindowHandler();
}
function createInvisibleWindow(): BrowserWindow {
return createInvisibleWindowHandler();
}
function resolveTrayIconPath(): string | null {
return resolveTrayIconPathHandler();
}
@@ -2412,6 +2430,7 @@ function destroyTray(): void {
function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler();
syncOverlayMpvSubtitleSuppression();
}
function openYomitanSettings(): void {
@@ -2441,7 +2460,6 @@ const {
getConfiguredShortcuts: () => getConfiguredShortcutsHandler(),
registerGlobalShortcutsCore,
toggleVisibleOverlay: () => toggleVisibleOverlay(),
toggleInvisibleOverlay: () => toggleInvisibleOverlay(),
openYomitanSettings: () => openYomitanSettings(),
isDev,
getMainWindow: () => overlayManager.getMainWindow(),
@@ -2495,8 +2513,7 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
cycleSecondarySubModeMainDeps: {
getSecondarySubMode: () => appState.secondarySubMode,
setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode;
syncSecondaryOverlayWindowVisibility();
setSecondarySubMode(mode);
},
getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs,
setLastSecondarySubToggleAtMs: (timestampMs: number) => {
@@ -2510,6 +2527,15 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps),
});
function setSecondarySubMode(mode: SecondarySubMode): void {
appState.secondarySubMode = mode;
syncOverlayMpvSubtitleSuppression();
}
function handleCycleSecondarySubMode(): void {
cycleSecondarySubMode();
}
async function triggerSubsyncFromConfig(): Promise<void> {
await subsyncRuntime.triggerFromConfig();
}
@@ -2613,9 +2639,7 @@ const handleMineSentenceDigitHandler = createHandleMineSentenceDigitHandler(
);
const {
setVisibleOverlayVisible: setVisibleOverlayVisibleHandler,
setInvisibleOverlayVisible: setInvisibleOverlayVisibleHandler,
toggleVisibleOverlay: toggleVisibleOverlayHandler,
toggleInvisibleOverlay: toggleInvisibleOverlayHandler,
setOverlayVisible: setOverlayVisibleHandler,
toggleOverlay: toggleOverlayHandler,
} = createOverlayVisibilityRuntime({
@@ -2625,29 +2649,8 @@ const {
overlayManager.setVisibleOverlayVisible(nextVisible);
},
updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
updateInvisibleOverlayVisibility: () =>
overlayVisibilityRuntime.updateInvisibleOverlayVisibility(),
syncInvisibleOverlayMousePassthrough: () =>
overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(),
shouldBindVisibleOverlayToMpvSubVisibility: () =>
configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(),
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
setMpvSubVisibility: (mpvSubVisible) => {
setMpvSubVisibilityRuntime(appState.mpvClient, mpvSubVisible);
},
},
setInvisibleOverlayVisibleDeps: {
setInvisibleOverlayVisibleCore,
setInvisibleOverlayVisibleState: (nextVisible) => {
overlayManager.setInvisibleOverlayVisible(nextVisible);
},
updateInvisibleOverlayVisibility: () =>
overlayVisibilityRuntime.updateInvisibleOverlayVisibility(),
syncInvisibleOverlayMousePassthrough: () =>
overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(),
},
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
});
const buildHandleOverlayModalClosedMainDepsHandler =
@@ -2707,10 +2710,8 @@ const {
showMpvOsd: (text: string) => showMpvOsd(text),
},
mainDeps: {
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisibility: () => overlayManager.getInvisibleOverlayVisible(),
focusMainWindow: () => {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
@@ -2721,13 +2722,15 @@ const {
onOverlayModalClosed: (modal) => {
handleOverlayModalClosed(modal);
},
onOverlayModalOpened: (modal) => {
overlayModalRuntime.notifyOverlayModalOpened(modal);
},
openYomitanSettings: () => openYomitanSettings(),
quitApp: () => app.quit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText,
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
getSubtitlePosition: () => loadSubtitlePosition(),
getSubtitleStyle: () => {
const resolvedConfig = getResolvedConfig();
@@ -2744,9 +2747,6 @@ const {
reportOverlayContentBounds: (payload: unknown) => {
overlayContentMeasurementStore.report(payload);
},
reportHoveredSubtitleToken: (tokenIndex: number | null) => {
reportHoveredSubtitleToken(tokenIndex);
},
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
clearAnilistToken: () => anilistStateRuntime.clearTokenState(),
openAnilistSetup: () => openAnilistSetupWindow(),
@@ -2800,9 +2800,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
showMpvOsd: (text: string) => showMpvOsd(text),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
toggleInvisibleOverlay: () => toggleInvisibleOverlay(),
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
setInvisibleOverlayVisible: (visible: boolean) => setInvisibleOverlayVisible(visible),
copyCurrentSubtitle: () => copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => mineSentenceCard(),
@@ -2821,7 +2819,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => cycleSecondarySubMode(),
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
stopApp: () => app.quit(),
@@ -2835,40 +2833,29 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
const {
createOverlayWindow: createOverlayWindowHandler,
createMainWindow: createMainWindowHandler,
createInvisibleWindow: createInvisibleWindowHandler,
createSecondaryWindow: createSecondaryWindowHandler,
createModalWindow: createModalWindowHandler,
} = createOverlayWindowRuntimeHandlers<BrowserWindow>({
createOverlayWindowDeps: {
createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options),
isDev,
getOverlayDebugVisualizationEnabled: () => appState.overlayDebugVisualizationEnabled,
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window),
onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled),
isOverlayVisible: (windowKind) =>
windowKind === 'visible'
? overlayManager.getVisibleOverlayVisible()
: windowKind === 'invisible'
? overlayManager.getInvisibleOverlayVisible()
: false,
: false,
tryHandleOverlayShortcutLocalFallback: (input) =>
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
onWindowClosed: (windowKind) => {
if (windowKind === 'visible') {
overlayManager.setMainWindow(null);
} else if (windowKind === 'invisible') {
overlayManager.setInvisibleWindow(null);
} else if (windowKind === 'secondary') {
overlayManager.setSecondaryWindow(null);
} else {
overlayManager.setModalWindow(null);
}
},
},
setMainWindow: (window) => overlayManager.setMainWindow(window),
setInvisibleWindow: (window) => overlayManager.setInvisibleWindow(window),
setSecondaryWindow: (window) => overlayManager.setSecondaryWindow(window),
setModalWindow: (window) => overlayManager.setModalWindow(window),
});
const {
@@ -2948,24 +2935,17 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
appState,
overlayManager: {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () =>
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
updateInvisibleOverlayVisibility: () =>
overlayVisibilityRuntime.updateInvisibleOverlayVisibility(),
},
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
},
getInitialInvisibleOverlayVisibility: () =>
configDerivedRuntime.getInitialInvisibleOverlayVisibility(),
createMainWindow: () => createMainWindow(),
createInvisibleWindow: () => createInvisibleWindow(),
registerGlobalShortcuts: () => registerGlobalShortcuts(),
updateVisibleOverlayBounds: (geometry) => updateVisibleOverlayBounds(geometry),
updateInvisibleOverlayBounds: (geometry) => updateInvisibleOverlayBounds(geometry),
updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry),
getOverlayWindows: () => getOverlayWindows(),
getResolvedConfig: () => getResolvedConfig(),
showDesktopNotification,
@@ -2975,9 +2955,6 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
initializeOverlayRuntimeBootstrapDeps: {
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
initializeOverlayRuntimeCore,
setInvisibleOverlayVisible: (visible) => {
overlayManager.setInvisibleOverlayVisible(visible);
},
setOverlayRuntimeInitialized: (initialized) => {
appState.overlayRuntimeInitialized = initialized;
},
@@ -3035,39 +3012,26 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
createMainWindow();
}
const invisibleWindow = overlayManager.getInvisibleWindow();
if (!invisibleWindow || invisibleWindow.isDestroyed()) {
createInvisibleWindow();
}
}
function setVisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions();
setVisibleOverlayVisibleHandler(visible);
}
function setInvisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions();
setInvisibleOverlayVisibleHandler(visible);
if (visible) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
syncOverlayMpvSubtitleSuppression();
}
function toggleVisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions();
toggleVisibleOverlayHandler();
}
function toggleInvisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions();
toggleInvisibleOverlayHandler();
syncOverlayMpvSubtitleSuppression();
}
function setOverlayVisible(visible: boolean): void {
setOverlayVisibleHandler(visible);
syncOverlayMpvSubtitleSuppression();
}
function toggleOverlay(): void {
toggleOverlayHandler();
syncOverlayMpvSubtitleSuppression();
}
function handleOverlayModalClosed(modal: OverlayHostedModal): void {
handleOverlayModalClosedHandler(modal);
@@ -3077,11 +3041,6 @@ function handleMpvCommandFromIpc(command: (string | number)[]): void {
handleMpvCommandFromIpcHandler(command);
}
function reportHoveredSubtitleToken(tokenIndex: number | null): void {
appState.hoveredSubtitleTokenIndex = tokenIndex;
applyHoveredTokenOverlay();
}
async function runSubsyncManualFromIpc(request: SubsyncManualRunRequest): Promise<SubsyncResult> {
return runSubsyncManualFromIpcHandler(request) as Promise<SubsyncResult>;
}

View File

@@ -12,6 +12,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
destroyTray: () => calls.push('destroy-tray'),
stopConfigHotReload: () => calls.push('stop-config'),
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
restoreMpvSubVisibilityForInvisibleOverlay: () => calls.push('restore-mpv-sub'),
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
@@ -33,7 +34,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
});
cleanup();
assert.equal(calls.length, 21);
assert.equal(calls.length, 22);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
@@ -58,11 +59,10 @@ test('restore windows on activate recreates windows then syncs visibility', () =
const calls: string[] = [];
const restore = createRestoreWindowsOnActivateHandler({
createMainWindow: () => calls.push('main'),
createInvisibleWindow: () => calls.push('invisible'),
updateVisibleOverlayVisibility: () => calls.push('visible-sync'),
updateInvisibleOverlayVisibility: () => calls.push('invisible-sync'),
syncOverlayMpvSubtitleSuppression: () => calls.push('mpv-sync'),
});
restore();
assert.deepEqual(calls, ['main', 'invisible', 'visible-sync', 'invisible-sync']);
assert.deepEqual(calls, ['main', 'visible-sync', 'mpv-sync']);
});

View File

@@ -2,6 +2,7 @@ export function createOnWillQuitCleanupHandler(deps: {
destroyTray: () => void;
stopConfigHotReload: () => void;
restorePreviousSecondarySubVisibility: () => void;
restoreMpvSubVisibilityForInvisibleOverlay: () => void;
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
@@ -25,6 +26,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.destroyTray();
deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility();
deps.restoreMpvSubVisibilityForInvisibleOverlay();
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
@@ -55,14 +57,12 @@ export function createShouldRestoreWindowsOnActivateHandler(deps: {
export function createRestoreWindowsOnActivateHandler(deps: {
createMainWindow: () => void;
createInvisibleWindow: () => void;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
}) {
return (): void => {
deps.createMainWindow();
deps.createInvisibleWindow();
deps.updateVisibleOverlayVisibility();
deps.updateInvisibleOverlayVisibility();
deps.syncOverlayMpvSubtitleSuppression();
};
}

View File

@@ -14,6 +14,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
destroyTray: () => calls.push('destroy-tray'),
stopConfigHotReload: () => calls.push('stop-config'),
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
restoreMpvSubVisibilityForInvisibleOverlay: () => calls.push('restore-mpv-sub'),
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
@@ -72,6 +73,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibilityForInvisibleOverlay: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},

View File

@@ -21,6 +21,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
destroyTray: () => void;
stopConfigHotReload: () => void;
restorePreviousSecondarySubVisibility: () => void;
restoreMpvSubVisibilityForInvisibleOverlay: () => void;
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
@@ -51,6 +52,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
destroyTray: () => deps.destroyTray(),
stopConfigHotReload: () => deps.stopConfigHotReload(),
restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(),
restoreMpvSubVisibilityForInvisibleOverlay: () =>
deps.restoreMpvSubVisibilityForInvisibleOverlay(),
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
stopTexthookerService: () => deps.stopTexthookerService(),

View File

@@ -16,6 +16,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibilityForInvisibleOverlay: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
@@ -43,9 +44,8 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
},
restoreWindowsOnActivateMainDeps: {
createMainWindow: () => {},
createInvisibleWindow: () => {},
updateVisibleOverlayVisibility: () => {},
updateInvisibleOverlayVisibility: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
},
});

View File

@@ -33,6 +33,7 @@ export function createHandleMpvSecondarySubtitleChangeHandler(deps: {
export function createHandleMpvMediaPathChangeHandler(deps: {
updateCurrentMediaPath: (path: string) => void;
reportJellyfinRemoteStopped: () => void;
restoreMpvSubVisibilityForInvisibleOverlay: () => void;
getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void;
@@ -44,6 +45,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
deps.updateCurrentMediaPath(path);
if (!path) {
deps.reportJellyfinRemoteStopped();
deps.restoreMpvSubVisibilityForInvisibleOverlay();
}
const mediaKey = deps.getCurrentAnilistMediaKey();
deps.resetAnilistMediaTracking(mediaKey);

View File

@@ -8,6 +8,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
hasInitialJellyfinPlayArg: () => false,
isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => false,
@@ -35,6 +36,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
restoreMpvSubVisibilityForInvisibleOverlay: () => calls.push('restore-mpv-sub'),
getCurrentAnilistMediaKey: () => 'media-key',
resetAnilistMediaTracking: (key) => calls.push(`reset-media:${String(key)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
@@ -62,6 +64,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
});
handlers.get('subtitle-change')?.({ text: 'line' });
handlers.get('media-path-change')?.({ path: '' });
handlers.get('media-title-change')?.({ title: 'Episode 1' });
handlers.get('time-pos-change')?.({ time: 2.5 });
handlers.get('pause-change')?.({ paused: true });
@@ -70,6 +73,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
assert.ok(calls.includes('broadcast-sub:line'));
assert.ok(calls.includes('subtitle-change:line'));
assert.ok(calls.includes('media-title:Episode 1'));
assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('reset-guess-state'));
assert.ok(calls.includes('notify-title:Episode 1'));
assert.ok(calls.includes('progress:normal'));

View File

@@ -19,6 +19,7 @@ type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandl
export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean;
@@ -42,6 +43,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
broadcastSecondarySubtitle: (text: string) => void;
updateCurrentMediaPath: (path: string) => void;
restoreMpvSubVisibilityForInvisibleOverlay: () => void;
getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void;
@@ -63,6 +65,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(),
@@ -94,6 +97,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
const handleMpvMediaPathChange = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path),
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
restoreMpvSubVisibilityForInvisibleOverlay: () =>
deps.restoreMpvSubVisibilityForInvisibleOverlay(),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey),
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),

View File

@@ -32,6 +32,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
},
quitApp: () => calls.push('quit'),
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
maybeRunAnilistPostWatchUpdate: async () => {
calls.push('anilist-post-watch');
},
@@ -40,6 +41,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
calls.push(`broadcast:${channel}:${String(payload)}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
restoreMpvSubVisibilityForInvisibleOverlay: () => calls.push('restore-mpv-sub'),
getCurrentAnilistMediaKey: () => 'media-key',
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
@@ -59,6 +61,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.scheduleQuitCheck(() => calls.push('scheduled-callback'));
deps.quitApp();
deps.reportJellyfinRemoteStopped();
deps.syncOverlayMpvSubtitleSuppression();
deps.recordImmersionSubtitleLine('x', 0, 1);
assert.equal(deps.hasSubtitleTimingTracker(), true);
deps.recordSubtitleTiming('y', 0, 1);
@@ -72,6 +75,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.broadcastSubtitleAss('ass');
deps.broadcastSecondarySubtitle('sec');
deps.updateCurrentMediaPath('/tmp/video');
deps.restoreMpvSubVisibilityForInvisibleOverlay();
assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key');
deps.resetAnilistMediaTracking('media-key');
deps.maybeProbeAnilistDuration('media-key');
@@ -91,8 +95,10 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.equal(appState.playbackPaused, true);
assert.equal(appState.previousSecondarySubVisibility, true);
assert.ok(calls.includes('remote-stopped'));
assert.ok(calls.includes('sync-overlay-mpv-sub'));
assert.ok(calls.includes('anilist-post-watch'));
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub'));
});

View File

@@ -21,11 +21,13 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
scheduleQuitCheck: (callback: () => void) => void;
quitApp: () => void;
reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
logSubtitleTimingError: (message: string, error: unknown) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
onSubtitleChange: (text: string) => void;
updateCurrentMediaPath: (path: string) => void;
restoreMpvSubVisibilityForInvisibleOverlay: () => void;
getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void;
@@ -39,6 +41,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
}) {
return () => ({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
hasInitialJellyfinPlayArg: () => Boolean(deps.appState.initialArgs?.jellyfinPlay),
isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized,
isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(),
@@ -68,6 +71,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
broadcastSecondarySubtitle: (text: string) =>
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
restoreMpvSubVisibilityForInvisibleOverlay: () =>
deps.restoreMpvSubVisibilityForInvisibleOverlay(),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey: string | null) =>
deps.resetAnilistMediaTracking(mediaKey),

View File

@@ -0,0 +1,171 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createEnsureOverlayMpvSubtitlesHiddenHandler,
createRestoreOverlayMpvSubtitlesHandler,
} from './overlay-mpv-sub-visibility';
type VisibilityState = {
savedSubVisibility: boolean | null;
savedSecondarySubVisibility: boolean | null;
revision: number;
};
test('ensure overlay mpv subtitle suppression captures previous visibility then hides subtitles', async () => {
const state: VisibilityState = {
savedSubVisibility: null,
savedSecondarySubVisibility: null,
revision: 0,
};
const calls: boolean[] = [];
const ensureHidden = createEnsureOverlayMpvSubtitlesHiddenHandler({
getMpvClient: () => ({
connected: true,
requestProperty: async (_name: string) => 'no',
}),
getSavedSubVisibility: () => state.savedSubVisibility,
setSavedSubVisibility: (visible) => {
state.savedSubVisibility = visible;
},
getSavedSecondarySubVisibility: () => state.savedSecondarySubVisibility,
setSavedSecondarySubVisibility: (visible) => {
state.savedSecondarySubVisibility = visible;
},
getRevision: () => state.revision,
setRevision: (revision) => {
state.revision = revision;
},
setMpvSubVisibility: (visible) => {
calls.push(visible);
},
setMpvSecondarySubVisibility: (visible) => {
calls.push(visible);
},
logWarn: () => {},
});
await ensureHidden();
assert.equal(state.savedSubVisibility, false);
assert.equal(state.savedSecondarySubVisibility, false);
assert.equal(state.revision, 1);
assert.deepEqual(calls, [false, false]);
});
test('restore overlay mpv subtitle suppression restores saved visibility', () => {
const state: VisibilityState = {
savedSubVisibility: false,
savedSecondarySubVisibility: true,
revision: 4,
};
const calls: boolean[] = [];
const restore = createRestoreOverlayMpvSubtitlesHandler({
getSavedSubVisibility: () => state.savedSubVisibility,
setSavedSubVisibility: (visible) => {
state.savedSubVisibility = visible;
},
getSavedSecondarySubVisibility: () => state.savedSecondarySubVisibility,
setSavedSecondarySubVisibility: (visible) => {
state.savedSecondarySubVisibility = visible;
},
getRevision: () => state.revision,
setRevision: (revision) => {
state.revision = revision;
},
isMpvConnected: () => true,
shouldKeepSuppressedFromVisibleOverlayBinding: () => false,
setMpvSubVisibility: (visible) => {
calls.push(visible);
},
setMpvSecondarySubVisibility: (visible) => {
calls.push(visible);
},
});
restore();
assert.equal(state.savedSubVisibility, null);
assert.equal(state.savedSecondarySubVisibility, null);
assert.equal(state.revision, 5);
assert.deepEqual(calls, [false, true]);
});
test('restore keeps mpv subtitles hidden when visible-overlay binding still requires suppression', () => {
const state: VisibilityState = {
savedSubVisibility: true,
savedSecondarySubVisibility: true,
revision: 9,
};
const calls: boolean[] = [];
const restore = createRestoreOverlayMpvSubtitlesHandler({
getSavedSubVisibility: () => state.savedSubVisibility,
setSavedSubVisibility: (visible) => {
state.savedSubVisibility = visible;
},
getSavedSecondarySubVisibility: () => state.savedSecondarySubVisibility,
setSavedSecondarySubVisibility: (visible) => {
state.savedSecondarySubVisibility = visible;
},
getRevision: () => state.revision,
setRevision: (revision) => {
state.revision = revision;
},
isMpvConnected: () => true,
shouldKeepSuppressedFromVisibleOverlayBinding: () => true,
setMpvSubVisibility: (visible) => {
calls.push(visible);
},
setMpvSecondarySubVisibility: (visible) => {
calls.push(visible);
},
});
restore();
assert.equal(state.savedSubVisibility, true);
assert.equal(state.savedSecondarySubVisibility, true);
assert.equal(state.revision, 10);
assert.deepEqual(calls, [false, false]);
});
test('restore defers mpv subtitle restore while mpv is disconnected', () => {
const state: VisibilityState = {
savedSubVisibility: true,
savedSecondarySubVisibility: false,
revision: 2,
};
const calls: boolean[] = [];
const restore = createRestoreOverlayMpvSubtitlesHandler({
getSavedSubVisibility: () => state.savedSubVisibility,
setSavedSubVisibility: (visible) => {
state.savedSubVisibility = visible;
},
getSavedSecondarySubVisibility: () => state.savedSecondarySubVisibility,
setSavedSecondarySubVisibility: (visible) => {
state.savedSecondarySubVisibility = visible;
},
getRevision: () => state.revision,
setRevision: (revision) => {
state.revision = revision;
},
isMpvConnected: () => false,
shouldKeepSuppressedFromVisibleOverlayBinding: () => false,
setMpvSubVisibility: (visible) => {
calls.push(visible);
},
setMpvSecondarySubVisibility: (visible) => {
calls.push(visible);
},
});
restore();
assert.equal(state.savedSubVisibility, true);
assert.equal(state.revision, 3);
assert.deepEqual(calls, []);
});

View File

@@ -0,0 +1,147 @@
type MpvVisibilityClient = {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
};
type RestoreOptions = {
respectVisibleOverlayBinding?: boolean;
};
function parseSubVisibility(value: unknown): boolean {
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'no' || normalized === 'false' || normalized === '0') {
return false;
}
if (normalized === 'yes' || normalized === 'true' || normalized === '1') {
return true;
}
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
return true;
}
export function createEnsureOverlayMpvSubtitlesHiddenHandler(deps: {
getMpvClient: () => MpvVisibilityClient | null;
getSavedSubVisibility: () => boolean | null;
setSavedSubVisibility: (visible: boolean | null) => void;
getSavedSecondarySubVisibility: () => boolean | null;
setSavedSecondarySubVisibility: (visible: boolean | null) => void;
getRevision: () => number;
setRevision: (revision: number) => void;
setMpvSubVisibility: (visible: boolean) => void;
setMpvSecondarySubVisibility: (visible: boolean) => void;
logWarn: (message: string, error: unknown) => void;
}) {
return async (): Promise<void> => {
const revision = deps.getRevision() + 1;
deps.setRevision(revision);
const mpvClient = deps.getMpvClient();
if (!mpvClient || !mpvClient.connected) {
return;
}
if (deps.getSavedSubVisibility() === null) {
try {
const currentSubVisibility = await mpvClient.requestProperty('sub-visibility');
if (revision !== deps.getRevision()) {
return;
}
deps.setSavedSubVisibility(parseSubVisibility(currentSubVisibility));
} catch (error) {
if (revision !== deps.getRevision()) {
return;
}
deps.logWarn(
'[overlay] Failed to capture mpv sub-visibility; falling back to visible restore',
error,
);
deps.setSavedSubVisibility(true);
}
}
if (deps.getSavedSecondarySubVisibility() === null) {
try {
const currentSecondarySubVisibility = await mpvClient.requestProperty('secondary-sub-visibility');
if (revision !== deps.getRevision()) {
return;
}
deps.setSavedSecondarySubVisibility(parseSubVisibility(currentSecondarySubVisibility));
} catch (error) {
if (revision !== deps.getRevision()) {
return;
}
deps.logWarn(
'[overlay] Failed to capture secondary mpv sub-visibility; falling back to visible restore',
error,
);
deps.setSavedSecondarySubVisibility(true);
}
}
if (revision !== deps.getRevision()) {
return;
}
deps.setMpvSubVisibility(false);
deps.setMpvSecondarySubVisibility(false);
};
}
export function createRestoreOverlayMpvSubtitlesHandler(deps: {
getSavedSubVisibility: () => boolean | null;
setSavedSubVisibility: (visible: boolean | null) => void;
getSavedSecondarySubVisibility: () => boolean | null;
setSavedSecondarySubVisibility: (visible: boolean | null) => void;
getRevision: () => number;
setRevision: (revision: number) => void;
isMpvConnected: () => boolean;
shouldKeepSuppressedFromVisibleOverlayBinding: () => boolean;
setMpvSubVisibility: (visible: boolean) => void;
setMpvSecondarySubVisibility: (visible: boolean) => void;
}) {
return (options?: RestoreOptions): void => {
deps.setRevision(deps.getRevision() + 1);
const savedVisibility = deps.getSavedSubVisibility();
const respectVisibleOverlayBinding = options?.respectVisibleOverlayBinding ?? true;
if (
respectVisibleOverlayBinding &&
deps.shouldKeepSuppressedFromVisibleOverlayBinding()
) {
deps.setMpvSubVisibility(false);
deps.setMpvSecondarySubVisibility(false);
return;
}
const hasSecondarySavedVisibility = deps.getSavedSecondarySubVisibility() !== null;
if (savedVisibility === null && !hasSecondarySavedVisibility) {
return;
}
if (!deps.isMpvConnected()) {
return;
}
if (savedVisibility !== null) {
deps.setMpvSubVisibility(savedVisibility);
}
const savedSecondaryVisibility = deps.getSavedSecondarySubVisibility();
if (savedSecondaryVisibility !== null) {
deps.setMpvSecondarySubVisibility(savedSecondaryVisibility);
}
deps.setSavedSubVisibility(null);
deps.setSavedSecondarySubVisibility(null);
};
}

View File

@@ -1,11 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildCreateInvisibleWindowMainDepsHandler,
createBuildCreateMainWindowMainDepsHandler,
createBuildCreateModalWindowMainDepsHandler,
createBuildCreateOverlayWindowMainDepsHandler,
createBuildCreateSecondaryWindowMainDepsHandler,
} from './overlay-window-factory-main-deps';
test('overlay window factory main deps builders return mapped handlers', () => {
@@ -13,7 +11,6 @@ test('overlay window factory main deps builders return mapped handlers', () => {
const buildOverlayDeps = createBuildCreateOverlayWindowMainDepsHandler({
createOverlayWindowCore: (kind) => ({ kind }),
isDev: true,
getOverlayDebugVisualizationEnabled: () => false,
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'),
setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`),
@@ -24,7 +21,6 @@ test('overlay window factory main deps builders return mapped handlers', () => {
const overlayDeps = buildOverlayDeps();
assert.equal(overlayDeps.isDev, true);
assert.equal(overlayDeps.getOverlayDebugVisualizationEnabled(), false);
assert.equal(overlayDeps.isOverlayVisible('visible'), true);
const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({
@@ -34,20 +30,6 @@ test('overlay window factory main deps builders return mapped handlers', () => {
const mainDeps = buildMainDeps();
mainDeps.setMainWindow(null);
const buildInvisibleDeps = createBuildCreateInvisibleWindowMainDepsHandler({
createOverlayWindow: () => ({ id: 'invisible' }),
setInvisibleWindow: () => calls.push('set-invisible'),
});
const invisibleDeps = buildInvisibleDeps();
invisibleDeps.setInvisibleWindow(null);
const buildSecondaryDeps = createBuildCreateSecondaryWindowMainDepsHandler({
createOverlayWindow: () => ({ id: 'secondary' }),
setSecondaryWindow: () => calls.push('set-secondary'),
});
const secondaryDeps = buildSecondaryDeps();
secondaryDeps.setSecondaryWindow(null);
const buildModalDeps = createBuildCreateModalWindowMainDepsHandler({
createOverlayWindow: () => ({ id: 'modal' }),
setModalWindow: () => calls.push('set-modal'),
@@ -55,5 +37,5 @@ test('overlay window factory main deps builders return mapped handlers', () => {
const modalDeps = buildModalDeps();
modalDeps.setModalWindow(null);
assert.deepEqual(calls, ['set-main', 'set-invisible', 'set-secondary', 'set-modal']);
assert.deepEqual(calls, ['set-main', 'set-modal']);
});

View File

@@ -1,30 +1,27 @@
export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindowCore: (
kind: 'visible' | 'invisible' | 'secondary' | 'modal',
kind: 'visible' | 'modal',
options: {
isDev: boolean;
overlayDebugVisualizationEnabled: boolean;
ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => boolean;
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => void;
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
},
) => TWindow;
isDev: boolean;
getOverlayDebugVisualizationEnabled: () => boolean;
ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => boolean;
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => void;
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
}) {
return () => ({
createOverlayWindowCore: deps.createOverlayWindowCore,
isDev: deps.isDev,
getOverlayDebugVisualizationEnabled: deps.getOverlayDebugVisualizationEnabled,
ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel,
onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged,
setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled,
@@ -35,7 +32,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
}
export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
createOverlayWindow: (kind: 'visible' | 'modal') => TWindow;
setMainWindow: (window: TWindow | null) => void;
}) {
return () => ({
@@ -44,28 +41,8 @@ export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
});
}
export function createBuildCreateInvisibleWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
setInvisibleWindow: (window: TWindow | null) => void;
}) {
return () => ({
createOverlayWindow: deps.createOverlayWindow,
setInvisibleWindow: deps.setInvisibleWindow,
});
}
export function createBuildCreateSecondaryWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
setSecondaryWindow: (window: TWindow | null) => void;
}) {
return () => ({
createOverlayWindow: deps.createOverlayWindow,
setSecondaryWindow: deps.setSecondaryWindow,
});
}
export function createBuildCreateModalWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
createOverlayWindow: (kind: 'visible' | 'modal') => TWindow;
setModalWindow: (window: TWindow | null) => void;
}) {
return () => ({

View File

@@ -1,11 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createCreateInvisibleWindowHandler,
createCreateMainWindowHandler,
createCreateModalWindowHandler,
createCreateOverlayWindowHandler,
createCreateSecondaryWindowHandler,
} from './overlay-window-factory';
test('create overlay window handler forwards options and kind', () => {
@@ -15,16 +13,14 @@ test('create overlay window handler forwards options and kind', () => {
createOverlayWindowCore: (kind, options) => {
calls.push(`kind:${kind}`);
assert.equal(options.isDev, true);
assert.equal(options.overlayDebugVisualizationEnabled, false);
assert.equal(options.isOverlayVisible('visible'), true);
assert.equal(options.isOverlayVisible('invisible'), false);
assert.equal(options.isOverlayVisible('modal'), false);
options.onRuntimeOptionsChanged();
options.setOverlayDebugVisualizationEnabled(true);
options.onWindowClosed(kind);
return window;
},
isDev: true,
getOverlayDebugVisualizationEnabled: () => false,
ensureOverlayWindowLevel: () => {},
onRuntimeOptionsChanged: () => calls.push('runtime-options'),
setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`),
@@ -52,36 +48,6 @@ test('create main window handler stores visible window', () => {
assert.deepEqual(calls, ['create:visible', 'set:visible']);
});
test('create invisible window handler stores invisible window', () => {
const calls: string[] = [];
const invisibleWindow = { id: 'invisible' };
const createInvisibleWindow = createCreateInvisibleWindowHandler({
createOverlayWindow: (kind) => {
calls.push(`create:${kind}`);
return invisibleWindow;
},
setInvisibleWindow: (window) => calls.push(`set:${(window as { id: string }).id}`),
});
assert.equal(createInvisibleWindow(), invisibleWindow);
assert.deepEqual(calls, ['create:invisible', 'set:invisible']);
});
test('create secondary window handler stores secondary window', () => {
const calls: string[] = [];
const secondaryWindow = { id: 'secondary' };
const createSecondaryWindow = createCreateSecondaryWindowHandler({
createOverlayWindow: (kind) => {
calls.push(`create:${kind}`);
return secondaryWindow;
},
setSecondaryWindow: (window) => calls.push(`set:${(window as { id: string }).id}`),
});
assert.equal(createSecondaryWindow(), secondaryWindow);
assert.deepEqual(calls, ['create:secondary', 'set:secondary']);
});
test('create modal window handler stores modal window', () => {
const calls: string[] = [];
const modalWindow = { id: 'modal' };

View File

@@ -1,11 +1,10 @@
type OverlayWindowKind = 'visible' | 'invisible' | 'secondary' | 'modal';
type OverlayWindowKind = 'visible' | 'modal';
export function createCreateOverlayWindowHandler<TWindow>(deps: {
createOverlayWindowCore: (
kind: OverlayWindowKind,
options: {
isDev: boolean;
overlayDebugVisualizationEnabled: boolean;
ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
@@ -15,7 +14,6 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
},
) => TWindow;
isDev: boolean;
getOverlayDebugVisualizationEnabled: () => boolean;
ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
@@ -26,7 +24,6 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
return (kind: OverlayWindowKind): TWindow => {
return deps.createOverlayWindowCore(kind, {
isDev: deps.isDev,
overlayDebugVisualizationEnabled: deps.getOverlayDebugVisualizationEnabled(),
ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel,
onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged,
setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled,
@@ -48,28 +45,6 @@ export function createCreateMainWindowHandler<TWindow>(deps: {
};
}
export function createCreateInvisibleWindowHandler<TWindow>(deps: {
createOverlayWindow: (kind: OverlayWindowKind) => TWindow;
setInvisibleWindow: (window: TWindow | null) => void;
}) {
return (): TWindow => {
const window = deps.createOverlayWindow('invisible');
deps.setInvisibleWindow(window);
return window;
};
}
export function createCreateSecondaryWindowHandler<TWindow>(deps: {
createOverlayWindow: (kind: OverlayWindowKind) => TWindow;
setSecondaryWindow: (window: TWindow | null) => void;
}) {
return (): TWindow => {
const window = deps.createOverlayWindow('secondary');
deps.setSecondaryWindow(window);
return window;
};
}
export function createCreateModalWindowHandler<TWindow>(deps: {
createOverlayWindow: (kind: OverlayWindowKind) => TWindow;
setModalWindow: (window: TWindow | null) => void;

View File

@@ -2,10 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { createOverlayWindowRuntimeHandlers } from './overlay-window-runtime-handlers';
test('overlay window runtime handlers compose create/main/invisible handlers', () => {
test('overlay window runtime handlers compose create/main/modal handlers', () => {
let mainWindow: { kind: string } | null = null;
let invisibleWindow: { kind: string } | null = null;
let secondaryWindow: { kind: string } | null = null;
let modalWindow: { kind: string } | null = null;
let debugEnabled = false;
const calls: string[] = [];
@@ -14,7 +12,6 @@ test('overlay window runtime handlers compose create/main/invisible handlers', (
createOverlayWindowDeps: {
createOverlayWindowCore: (kind) => ({ kind }),
isDev: true,
getOverlayDebugVisualizationEnabled: () => debugEnabled,
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'),
setOverlayDebugVisualizationEnabled: (enabled) => {
@@ -27,29 +24,17 @@ test('overlay window runtime handlers compose create/main/invisible handlers', (
setMainWindow: (window) => {
mainWindow = window;
},
setInvisibleWindow: (window) => {
invisibleWindow = window;
},
setSecondaryWindow: (window) => {
secondaryWindow = window;
},
setModalWindow: (window) => {
modalWindow = window;
},
});
assert.deepEqual(runtime.createOverlayWindow('visible'), { kind: 'visible' });
assert.deepEqual(runtime.createOverlayWindow('invisible'), { kind: 'invisible' });
assert.deepEqual(runtime.createOverlayWindow('secondary'), { kind: 'secondary' });
assert.deepEqual(runtime.createOverlayWindow('modal'), { kind: 'modal' });
assert.deepEqual(runtime.createMainWindow(), { kind: 'visible' });
assert.deepEqual(mainWindow, { kind: 'visible' });
assert.deepEqual(runtime.createInvisibleWindow(), { kind: 'invisible' });
assert.deepEqual(invisibleWindow, { kind: 'invisible' });
assert.deepEqual(runtime.createSecondaryWindow(), { kind: 'secondary' });
assert.deepEqual(secondaryWindow, { kind: 'secondary' });
assert.deepEqual(runtime.createModalWindow(), { kind: 'modal' });
assert.deepEqual(modalWindow, { kind: 'modal' });

View File

@@ -1,16 +1,12 @@
import {
createCreateInvisibleWindowHandler,
createCreateMainWindowHandler,
createCreateModalWindowHandler,
createCreateOverlayWindowHandler,
createCreateSecondaryWindowHandler,
} from './overlay-window-factory';
import {
createBuildCreateInvisibleWindowMainDepsHandler,
createBuildCreateMainWindowMainDepsHandler,
createBuildCreateModalWindowMainDepsHandler,
createBuildCreateOverlayWindowMainDepsHandler,
createBuildCreateSecondaryWindowMainDepsHandler,
} from './overlay-window-factory-main-deps';
type CreateOverlayWindowMainDeps<TWindow> = Parameters<
@@ -20,8 +16,6 @@ type CreateOverlayWindowMainDeps<TWindow> = Parameters<
export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
createOverlayWindowDeps: CreateOverlayWindowMainDeps<TWindow>;
setMainWindow: (window: TWindow | null) => void;
setInvisibleWindow: (window: TWindow | null) => void;
setSecondaryWindow: (window: TWindow | null) => void;
setModalWindow: (window: TWindow | null) => void;
}) {
const createOverlayWindow = createCreateOverlayWindowHandler<TWindow>(
@@ -33,18 +27,6 @@ export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
setMainWindow: (window) => deps.setMainWindow(window),
})(),
);
const createInvisibleWindow = createCreateInvisibleWindowHandler<TWindow>(
createBuildCreateInvisibleWindowMainDepsHandler<TWindow>({
createOverlayWindow: (kind) => createOverlayWindow(kind),
setInvisibleWindow: (window) => deps.setInvisibleWindow(window),
})(),
);
const createSecondaryWindow = createCreateSecondaryWindowHandler<TWindow>(
createBuildCreateSecondaryWindowMainDepsHandler<TWindow>({
createOverlayWindow: (kind) => createOverlayWindow(kind),
setSecondaryWindow: (window) => deps.setSecondaryWindow(window),
})(),
);
const createModalWindow = createCreateModalWindowHandler<TWindow>(
createBuildCreateModalWindowMainDepsHandler<TWindow>({
createOverlayWindow: (kind) => createOverlayWindow(kind),
@@ -55,8 +37,6 @@ export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
return {
createOverlayWindow,
createMainWindow,
createInvisibleWindow,
createSecondaryWindow,
createModalWindow,
};
}

View File

@@ -156,8 +156,6 @@ export interface AppState {
currentSubText: string;
currentSubAssText: string;
currentSubtitleData: SubtitleData | null;
hoveredSubtitleTokenIndex: number | null;
hoveredSubtitleRevision: number;
windowTracker: BaseWindowTracker | null;
subtitlePosition: SubtitlePosition | null;
currentMediaPath: string | null;
@@ -173,6 +171,9 @@ export interface AppState {
secondarySubMode: SecondarySubMode;
lastSecondarySubToggleAtMs: number;
previousSecondarySubVisibility: boolean | null;
overlaySavedMpvSubVisibility: boolean | null;
overlaySavedSecondaryMpvSubVisibility: boolean | null;
overlayMpvSubVisibilityRevision: number;
mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics;
shortcutsRegistered: boolean;
overlayRuntimeInitialized: boolean;
@@ -230,8 +231,6 @@ export function createAppState(values: AppStateInitialValues): AppState {
currentSubText: '',
currentSubAssText: '',
currentSubtitleData: null,
hoveredSubtitleTokenIndex: null,
hoveredSubtitleRevision: 0,
windowTracker: null,
subtitlePosition: null,
currentMediaPath: null,
@@ -247,6 +246,9 @@ export function createAppState(values: AppStateInitialValues): AppState {
secondarySubMode: 'hover',
lastSecondarySubToggleAtMs: 0,
previousSecondarySubVisibility: null,
overlaySavedMpvSubVisibility: null,
overlaySavedSecondaryMpvSubVisibility: null,
overlayMpvSubVisibilityRevision: 0,
mpvSubtitleRenderMetrics: {
...DEFAULT_MPV_SUBTITLE_RENDER_METRICS,
},

View File

@@ -45,7 +45,6 @@ import type {
RuntimeOptionId,
RuntimeOptionState,
RuntimeOptionValue,
MpvSubtitleRenderMetrics,
OverlayContentMeasurement,
ShortcutsConfig,
ConfigHotReloadPayload,
@@ -55,12 +54,80 @@ import { IPC_CHANNELS } from './shared/ipc/contracts';
const overlayLayerArg = process.argv.find((arg) => arg.startsWith('--overlay-layer='));
const overlayLayerFromArg = overlayLayerArg?.slice('--overlay-layer='.length);
const overlayLayer =
overlayLayerFromArg === 'visible' ||
overlayLayerFromArg === 'invisible' ||
overlayLayerFromArg === 'secondary' ||
overlayLayerFromArg === 'modal'
? overlayLayerFromArg
: null;
overlayLayerFromArg === 'visible' || overlayLayerFromArg === 'modal' ? overlayLayerFromArg : null;
type EmptyListener = () => void;
type PayloadedListener<T> = (payload: T) => void;
function createQueuedIpcListener(
channel: string,
): (listener: EmptyListener) => void {
let count = 0;
const listeners: EmptyListener[] = [];
const dispatch = (): void => {
if (listeners.length === 0) {
count += 1;
return;
}
for (const listener of listeners) {
listener();
}
};
ipcRenderer.on(channel, () => {
dispatch();
});
return (listener: EmptyListener): void => {
listeners.push(listener);
while (count > 0) {
count -= 1;
listener();
}
};
}
function createQueuedIpcListenerWithPayload<T>(
channel: string,
normalize: (payload: unknown) => T,
): (listener: PayloadedListener<T>) => void {
const pending: T[] = [];
const listeners: PayloadedListener<T>[] = [];
const dispatch = (payload: T): void => {
if (listeners.length === 0) {
pending.push(payload);
return;
}
for (const listener of listeners) {
listener(payload);
}
};
ipcRenderer.on(channel, (_event: IpcRendererEvent, payloadArg: unknown) => {
dispatch(normalize(payloadArg));
});
return (listener: PayloadedListener<T>): void => {
listeners.push(listener);
while (pending.length > 0) {
const payload = pending.shift();
listener(payload as T);
}
};
}
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen);
const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload<SubsyncManualPayload>(
IPC_CHANNELS.event.subsyncOpenManual,
(payload) => payload as SubsyncManualPayload,
);
const onKikuFieldGroupingRequestEvent = createQueuedIpcListenerWithPayload<KikuFieldGroupingRequestData>(
IPC_CHANNELS.event.kikuFieldGroupingRequest,
(payload) => payload as KikuFieldGroupingRequestData,
);
const electronAPI: ElectronAPI = {
getOverlayLayer: () => overlayLayer,
@@ -94,16 +161,6 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw),
getCurrentSubtitleAss: (): Promise<string> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss),
getMpvSubtitleRenderMetrics: () =>
ipcRenderer.invoke(IPC_CHANNELS.request.getMpvSubtitleRenderMetrics),
onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => {
ipcRenderer.on(
IPC_CHANNELS.event.mpvSubtitleRenderMetricsSet,
(_event: IpcRendererEvent, metrics: MpvSubtitleRenderMetrics) => {
callback(metrics);
},
);
},
onSubtitleAss: (callback: (assText: string) => void) => {
ipcRenderer.on(
IPC_CHANNELS.event.subtitleAssSet,
@@ -112,14 +169,6 @@ const electronAPI: ElectronAPI = {
},
);
},
onOverlayDebugVisualization: (callback: (enabled: boolean) => void) => {
ipcRenderer.on(
IPC_CHANNELS.event.overlayDebugVisualizationSet,
(_event: IpcRendererEvent, enabled: boolean) => {
callback(enabled);
},
);
},
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ipcRenderer.send(IPC_CHANNELS.command.setIgnoreMouseEvents, ignore, options);
@@ -201,23 +250,11 @@ const electronAPI: ElectronAPI = {
focusMainWindow: () => ipcRenderer.invoke(IPC_CHANNELS.request.focusMainWindow) as Promise<void>,
getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleStyle),
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => {
ipcRenderer.on(
IPC_CHANNELS.event.subsyncOpenManual,
(_event: IpcRendererEvent, payload: SubsyncManualPayload) => {
callback(payload);
},
);
},
onSubsyncManualOpen: onSubsyncManualOpenEvent,
runSubsyncManual: (request: SubsyncManualRunRequest): Promise<SubsyncResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request),
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => {
ipcRenderer.on(
IPC_CHANNELS.event.kikuFieldGroupingRequest,
(_event: IpcRendererEvent, data: KikuFieldGroupingRequestData) => callback(data),
);
},
onKikuFieldGroupingRequest: onKikuFieldGroupingRequestEvent,
kikuBuildMergePreview: (request: KikuMergePreviewRequest): Promise<KikuMergePreviewResponse> =>
ipcRenderer.invoke(IPC_CHANNELS.request.kikuBuildMergePreview, request),
@@ -242,27 +279,19 @@ const electronAPI: ElectronAPI = {
},
);
},
onOpenRuntimeOptions: (callback: () => void) => {
ipcRenderer.on(IPC_CHANNELS.event.runtimeOptionsOpen, () => {
callback();
});
},
onOpenJimaku: (callback: () => void) => {
ipcRenderer.on(IPC_CHANNELS.event.jimakuOpen, () => {
callback();
});
},
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
onOpenJimaku: onOpenJimakuEvent,
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
},
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
},
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement);
},
reportHoveredSubtitleToken: (tokenIndex: number | null) => {
ipcRenderer.send('subtitle-token-hover:set', tokenIndex);
},
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
ipcRenderer.on(
IPC_CHANNELS.event.configHotReload,

View File

@@ -2,6 +2,11 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createRendererRecoveryController } from './error-recovery.js';
import {
YOMITAN_POPUP_IFRAME_SELECTOR,
hasYomitanPopupIframe,
isYomitanPopupIframe,
} from './yomitan-popup.js';
import { resolvePlatformInfo } from './utils/platform.js';
test('handleError logs context and recovers overlay state', () => {
@@ -26,7 +31,6 @@ test('handleError logs context and recovers overlay state', () => {
secondarySubtitlePreview: 'secondary',
isOverlayInteractive: true,
isOverSubtitle: true,
invisiblePositionEditMode: false,
overlayLayer: 'visible',
}),
logError: (payload) => {
@@ -72,8 +76,7 @@ test('handleError normalizes non-Error values', () => {
secondarySubtitlePreview: '',
isOverlayInteractive: false,
isOverSubtitle: false,
invisiblePositionEditMode: false,
overlayLayer: 'invisible',
overlayLayer: 'visible',
}),
logError: (payload) => {
payloads.push(payload);
@@ -107,7 +110,6 @@ test('nested recovery errors are ignored while current recovery is active', () =
secondarySubtitlePreview: '',
isOverlayInteractive: true,
isOverSubtitle: false,
invisiblePositionEditMode: true,
overlayLayer: 'visible',
}),
logError: (payload) => {
@@ -130,7 +132,7 @@ test('resolvePlatformInfo prefers query layer over preload layer', () => {
configurable: true,
value: {
electronAPI: {
getOverlayLayer: () => 'invisible',
getOverlayLayer: () => 'modal',
},
location: { search: '?layer=visible' },
},
@@ -146,7 +148,6 @@ test('resolvePlatformInfo prefers query layer over preload layer', () => {
try {
const info = resolvePlatformInfo();
assert.equal(info.overlayLayer, 'visible');
assert.equal(info.isInvisibleLayer, false);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'navigator', {
@@ -156,7 +157,7 @@ test('resolvePlatformInfo prefers query layer over preload layer', () => {
}
});
test('resolvePlatformInfo supports secondary layer and disables mouse-ignore toggles', () => {
test('resolvePlatformInfo ignores legacy secondary layer and falls back to visible', () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
@@ -179,9 +180,8 @@ test('resolvePlatformInfo supports secondary layer and disables mouse-ignore tog
try {
const info = resolvePlatformInfo();
assert.equal(info.overlayLayer, 'secondary');
assert.equal(info.isSecondaryLayer, true);
assert.equal(info.shouldToggleMouseIgnore, false);
assert.equal(info.overlayLayer, 'visible');
assert.equal(info.shouldToggleMouseIgnore, true);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'navigator', {
@@ -225,3 +225,59 @@ test('resolvePlatformInfo supports modal layer and disables mouse-ignore toggles
});
}
});
test('isYomitanPopupIframe matches modern popup class and legacy id prefix', () => {
const createElement = (options: {
tagName: string;
id?: string;
classNames?: string[];
}): Element =>
({
tagName: options.tagName,
id: options.id ?? '',
classList: {
contains: (className: string) => (options.classNames ?? []).includes(className),
},
}) as unknown as Element;
assert.equal(
isYomitanPopupIframe(
createElement({
tagName: 'IFRAME',
classNames: ['yomitan-popup'],
}),
),
true,
);
assert.equal(
isYomitanPopupIframe(
createElement({
tagName: 'IFRAME',
id: 'yomitan-popup-123',
}),
),
true,
);
assert.equal(
isYomitanPopupIframe(
createElement({
tagName: 'IFRAME',
id: 'something-else',
}),
),
false,
);
});
test('hasYomitanPopupIframe queries for modern + legacy selector', () => {
let selector = '';
const root = {
querySelector: (value: string) => {
selector = value;
return {};
},
} as unknown as ParentNode;
assert.equal(hasYomitanPopupIframe(root), true);
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
});

View File

@@ -16,8 +16,7 @@ export type RendererRecoverySnapshot = {
secondarySubtitlePreview: string;
isOverlayInteractive: boolean;
isOverSubtitle: boolean;
invisiblePositionEditMode: boolean;
overlayLayer: 'visible' | 'invisible' | 'secondary' | 'modal';
overlayLayer: 'visible' | 'modal';
};
type NormalizedRendererError = {

View File

@@ -18,7 +18,6 @@
import type {
KikuDuplicateCardInfo,
MpvSubtitleRenderMetrics,
RuntimeOptionState,
SecondarySubMode,
SubtitleData,
@@ -84,10 +83,7 @@ function syncSettingsModalSubtitleSuppression(): void {
const subtitleRenderer = createSubtitleRenderer(ctx);
const measurementReporter = createOverlayContentMeasurementReporter(ctx);
const positioning = createPositioningController(ctx, {
modalStateReader: { isAnySettingsModalOpen },
applySubtitleFontSize: subtitleRenderer.applySubtitleFontSize,
});
const positioning = createPositioningController(ctx);
const runtimeOptionsModal = createRuntimeOptionsModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
@@ -115,25 +111,15 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
saveInvisiblePositionEdit: positioning.saveInvisiblePositionEdit,
cancelInvisiblePositionEdit: positioning.cancelInvisiblePositionEdit,
setInvisiblePositionEditMode: positioning.setInvisiblePositionEditMode,
applyInvisibleSubtitleOffsetPosition: positioning.applyInvisibleSubtitleOffsetPosition,
updateInvisiblePositionEditHud: positioning.updateInvisiblePositionEditHud,
appendClipboardVideoToQueue: () => {
void window.electronAPI.appendClipboardVideoToQueue();
},
});
const mouseHandlers = createMouseHandlers(ctx, {
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
applyInvisibleSubtitleLayoutFromMpvMetrics:
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics,
applyYPercent: positioning.applyYPercent,
getCurrentYPercent: positioning.getCurrentYPercent,
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
reportHoveredTokenIndex: (tokenIndex: number | null) => {
window.electronAPI.reportHoveredSubtitleToken(tokenIndex);
},
});
let lastSubtitlePreview = '';
@@ -179,9 +165,6 @@ function dismissActiveUiAfterError(): void {
function restoreOverlayInteractionAfterError(): void {
ctx.state.isOverSubtitle = false;
if (ctx.state.invisiblePositionEditMode) {
positioning.setInvisiblePositionEditMode(false);
}
ctx.dom.overlay.classList.remove('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
@@ -212,7 +195,6 @@ const recovery = createRendererRecoveryController({
secondarySubtitlePreview: lastSecondarySubtitlePreview,
isOverlayInteractive: ctx.dom.overlay.classList.contains('interactive'),
isOverSubtitle: ctx.state.isOverSubtitle,
invisiblePositionEditMode: ctx.state.invisiblePositionEditMode,
overlayLayer: ctx.platform.overlayLayer,
}),
logError: (payload) => {
@@ -222,6 +204,41 @@ const recovery = createRendererRecoveryController({
registerRendererGlobalErrorHandlers(window, recovery);
function registerModalOpenHandlers(): void {
window.electronAPI.onOpenRuntimeOptions(() => {
runGuardedAsync('runtime-options:open', async () => {
try {
await runtimeOptionsModal.openRuntimeOptionsModal();
window.electronAPI.notifyOverlayModalOpened('runtime-options');
} catch {
runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true);
window.electronAPI.notifyOverlayModalClosed('runtime-options');
syncSettingsModalSubtitleSuppression();
}
});
});
window.electronAPI.onOpenJimaku(() => {
runGuarded('jimaku:open', () => {
jimakuModal.openJimakuModal();
window.electronAPI.notifyOverlayModalOpened('jimaku');
});
});
window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => {
runGuarded('subsync:manual-open', () => {
subsyncModal.openSubsyncModal(payload);
window.electronAPI.notifyOverlayModalOpened('subsync');
});
});
window.electronAPI.onKikuFieldGroupingRequest(
(data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => {
runGuarded('kiku:field-grouping-open', () => {
kikuModal.openKikuFieldGroupingModal(data);
window.electronAPI.notifyOverlayModalOpened('kiku');
});
},
);
}
function runGuarded(action: string, fn: () => void): void {
try {
fn();
@@ -238,6 +255,8 @@ function runGuardedAsync(action: string, fn: () => Promise<void> | void): void {
});
}
registerModalOpenHandlers();
async function init(): Promise<void> {
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);
if (ctx.platform.isMacOSPlatform) {
@@ -252,41 +271,17 @@ async function init(): Promise<void> {
lastSubtitlePreview = truncateForErrorLog(data.text);
}
subtitleRenderer.renderSubtitle(data);
if (ctx.platform.isInvisibleLayer && ctx.state.mpvSubtitleRenderMetrics) {
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(
ctx.state.mpvSubtitleRenderMetrics,
'subtitle-change',
);
}
measurementReporter.schedule();
});
});
window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
runGuarded('subtitle-position:update', () => {
if (ctx.platform.isInvisibleLayer) {
positioning.applyInvisibleStoredSubtitlePosition(position, 'media-change');
} else {
positioning.applyStoredSubtitlePosition(position, 'media-change');
}
positioning.applyStoredSubtitlePosition(position, 'media-change');
measurementReporter.schedule();
});
});
if (ctx.platform.isInvisibleLayer) {
window.electronAPI.onMpvSubtitleRenderMetrics((metrics: MpvSubtitleRenderMetrics) => {
runGuarded('mpv-metrics:update', () => {
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, 'event');
measurementReporter.schedule();
});
});
window.electronAPI.onOverlayDebugVisualization((enabled: boolean) => {
runGuarded('overlay-debug-visualization:update', () => {
document.body.classList.toggle('debug-invisible-visualization', enabled);
});
});
}
const initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
lastSubtitlePreview = truncateForErrorLog(initialSubtitle);
subtitleRenderer.renderSubtitle(initialSubtitle);
@@ -310,17 +305,11 @@ async function init(): Promise<void> {
subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub());
measurementReporter.schedule();
const hoverTarget = ctx.platform.isInvisibleLayer
? ctx.dom.subtitleRoot
: ctx.dom.subtitleContainer;
hoverTarget.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
hoverTarget.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
mouseHandlers.setupInvisibleHoverSelection();
mouseHandlers.setupInvisibleTokenHoverReporter();
positioning.setupInvisiblePositionEditHud();
mouseHandlers.setupResizeHandler();
mouseHandlers.setupSelectionObserver();
mouseHandlers.setupYomitanObserver();
@@ -348,59 +337,14 @@ async function init(): Promise<void> {
measurementReporter.schedule();
});
});
window.electronAPI.onOpenRuntimeOptions(() => {
runGuardedAsync('runtime-options:open', async () => {
try {
await runtimeOptionsModal.openRuntimeOptionsModal();
} catch {
runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true);
window.electronAPI.notifyOverlayModalClosed('runtime-options');
syncSettingsModalSubtitleSuppression();
}
});
});
window.electronAPI.onOpenJimaku(() => {
runGuarded('jimaku:open', () => {
jimakuModal.openJimakuModal();
});
});
window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => {
runGuarded('subsync:manual-open', () => {
subsyncModal.openSubsyncModal(payload);
});
});
window.electronAPI.onKikuFieldGroupingRequest(
(data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => {
runGuarded('kiku:field-grouping-open', () => {
kikuModal.openKikuFieldGroupingModal(data);
});
},
);
if (!ctx.platform.isInvisibleLayer) {
mouseHandlers.setupDragging();
}
mouseHandlers.setupDragging();
await keyboardHandlers.setupMpvInputForwarding();
subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle());
if (ctx.platform.isInvisibleLayer) {
positioning.applyInvisibleStoredSubtitlePosition(
await window.electronAPI.getSubtitlePosition(),
'startup',
);
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(
await window.electronAPI.getMpvSubtitleRenderMetrics(),
'startup',
);
} else {
positioning.applyStoredSubtitlePosition(
await window.electronAPI.getSubtitlePosition(),
'startup',
);
measurementReporter.schedule();
}
positioning.applyStoredSubtitlePosition(await window.electronAPI.getSubtitlePosition(), 'startup');
measurementReporter.schedule();
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
@@ -421,7 +365,7 @@ function setupDragDropToMpvQueue(): void {
const clearDropInteractive = (): void => {
dragDepth = 0;
if (isAnyModalOpen() || ctx.state.isOverSubtitle || ctx.state.invisiblePositionEditMode) {
if (isAnyModalOpen() || ctx.state.isOverSubtitle) {
return;
}
ctx.dom.overlay.classList.remove('interactive');

View File

@@ -280,6 +280,8 @@ body {
text-align: center;
font-size: 35px;
line-height: var(--visible-sub-line-height, 1.32);
overflow-wrap: anywhere;
word-break: keep-all;
color: #cad3f5;
--subtitle-known-word-color: #a6da95;
--subtitle-n-plus-one-color: #c6a0f6;
@@ -288,6 +290,8 @@ body {
--subtitle-jlpt-n3-color: #f9e2af;
--subtitle-jlpt-n4-color: #a6e3a1;
--subtitle-jlpt-n5-color: #8aadf4;
--subtitle-hover-token-color: #f4dbd6;
--subtitle-hover-token-background-color: rgba(54, 58, 79, 0.84);
--subtitle-frequency-single-color: #f5a97f;
--subtitle-frequency-band-1-color: #ed8796;
--subtitle-frequency-band-2-color: #f5a97f;
@@ -300,6 +304,7 @@ body {
/* Enable text selection for Yomitan */
user-select: text;
cursor: text;
-webkit-text-fill-color: currentColor;
}
#subtitleRoot:empty {
@@ -318,16 +323,21 @@ body.settings-modal-open #subtitleContainer {
#subtitleRoot .c {
display: inline;
position: relative;
color: inherit;
-webkit-text-fill-color: currentColor !important;
}
#subtitleRoot .c:hover {
background: rgba(255, 255, 255, 0.15);
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
border-radius: 2px;
}
#subtitleRoot .word {
display: inline;
position: relative;
-webkit-text-fill-color: currentColor !important;
}
#subtitleRoot .word.word-known {
@@ -418,9 +428,103 @@ body.settings-modal-open #subtitleContainer {
color: var(--subtitle-frequency-band-5-color, #8aadf4);
}
#subtitleRoot .word:hover {
background: rgba(255, 255, 255, 0.2);
#subtitleRoot .word:not(.word-known):not(.word-n-plus-one):not(.word-frequency-single):not(
.word-frequency-band-1
):not(.word-frequency-band-2):not(.word-frequency-band-3):not(.word-frequency-band-4):not(
.word-frequency-band-5
):hover {
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
border-radius: 3px;
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
}
#subtitleRoot .word.word-known:hover,
#subtitleRoot .word.word-n-plus-one:hover,
#subtitleRoot .word.word-frequency-single:hover,
#subtitleRoot .word.word-frequency-band-1:hover,
#subtitleRoot .word.word-frequency-band-2:hover,
#subtitleRoot .word.word-frequency-band-3:hover,
#subtitleRoot .word.word-frequency-band-4:hover,
#subtitleRoot .word.word-frequency-band-5:hover {
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
border-radius: 3px;
font-weight: 800;
}
#subtitleRoot .word.word-known .c:hover,
#subtitleRoot .word.word-n-plus-one .c:hover,
#subtitleRoot .word.word-frequency-single .c:hover,
#subtitleRoot .word.word-frequency-band-1 .c:hover,
#subtitleRoot .word.word-frequency-band-2 .c:hover,
#subtitleRoot .word.word-frequency-band-3 .c:hover,
#subtitleRoot .word.word-frequency-band-4 .c:hover,
#subtitleRoot .word.word-frequency-band-5 .c:hover {
background: transparent;
color: inherit !important;
-webkit-text-fill-color: currentColor !important;
}
#subtitleRoot::selection,
#subtitleRoot .word::selection,
#subtitleRoot .c::selection {
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
}
#subtitleRoot *::selection {
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84)) !important;
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
}
#subtitleRoot .word.word-known::selection,
#subtitleRoot .word.word-known .c::selection {
color: var(--subtitle-known-word-color, #a6da95) !important;
-webkit-text-fill-color: var(--subtitle-known-word-color, #a6da95) !important;
}
#subtitleRoot .word.word-n-plus-one::selection,
#subtitleRoot .word.word-n-plus-one .c::selection {
color: var(--subtitle-n-plus-one-color, #c6a0f6) !important;
-webkit-text-fill-color: var(--subtitle-n-plus-one-color, #c6a0f6) !important;
}
#subtitleRoot .word.word-frequency-single::selection,
#subtitleRoot .word.word-frequency-single .c::selection {
color: var(--subtitle-frequency-single-color, #f5a97f) !important;
-webkit-text-fill-color: var(--subtitle-frequency-single-color, #f5a97f) !important;
}
#subtitleRoot .word.word-frequency-band-1::selection,
#subtitleRoot .word.word-frequency-band-1 .c::selection {
color: var(--subtitle-frequency-band-1-color, #ed8796) !important;
-webkit-text-fill-color: var(--subtitle-frequency-band-1-color, #ed8796) !important;
}
#subtitleRoot .word.word-frequency-band-2::selection,
#subtitleRoot .word.word-frequency-band-2 .c::selection {
color: var(--subtitle-frequency-band-2-color, #f5a97f) !important;
-webkit-text-fill-color: var(--subtitle-frequency-band-2-color, #f5a97f) !important;
}
#subtitleRoot .word.word-frequency-band-3::selection,
#subtitleRoot .word.word-frequency-band-3 .c::selection {
color: var(--subtitle-frequency-band-3-color, #f9e2af) !important;
-webkit-text-fill-color: var(--subtitle-frequency-band-3-color, #f9e2af) !important;
}
#subtitleRoot .word.word-frequency-band-4::selection,
#subtitleRoot .word.word-frequency-band-4 .c::selection {
color: var(--subtitle-frequency-band-4-color, #a6e3a1) !important;
-webkit-text-fill-color: var(--subtitle-frequency-band-4-color, #a6e3a1) !important;
}
#subtitleRoot .word.word-frequency-band-5::selection,
#subtitleRoot .word.word-frequency-band-5 .c::selection {
color: var(--subtitle-frequency-band-5-color, #8aadf4) !important;
-webkit-text-fill-color: var(--subtitle-frequency-band-5-color, #8aadf4) !important;
}
#subtitleRoot br {
@@ -439,93 +543,6 @@ body.platform-macos.layer-visible #subtitleRoot {
background: transparent;
}
body.layer-invisible #subtitleContainer {
background: transparent !important;
border: 0 !important;
padding: 0 !important;
border-radius: 0 !important;
position: relative;
z-index: 3;
}
body.layer-invisible #subtitleRoot,
body.layer-invisible #subtitleRoot .word,
body.layer-invisible #subtitleRoot .c {
color: transparent !important;
text-shadow: none !important;
-webkit-text-stroke: 0 !important;
-webkit-text-fill-color: transparent !important;
background: transparent !important;
caret-color: transparent !important;
line-height: var(--invisible-sub-line-height, normal) !important;
font-kerning: auto;
letter-spacing: normal;
font-variant-ligatures: normal;
font-feature-settings: normal;
text-rendering: auto;
}
body.layer-invisible #subtitleRoot br {
margin-bottom: 0 !important;
}
body.layer-invisible #subtitleRoot .word:hover,
body.layer-invisible #subtitleRoot .c:hover,
body.layer-invisible #subtitleRoot.has-selection .word:hover,
body.layer-invisible #subtitleRoot.has-selection .c:hover {
background: transparent !important;
}
body.layer-invisible #subtitleRoot::selection,
body.layer-invisible #subtitleRoot .word::selection,
body.layer-invisible #subtitleRoot .c::selection {
background: transparent !important;
color: transparent !important;
}
body.layer-invisible.debug-invisible-visualization #subtitleRoot,
body.layer-invisible.debug-invisible-visualization #subtitleRoot .word,
body.layer-invisible.debug-invisible-visualization #subtitleRoot .c {
color: #ed8796 !important;
-webkit-text-fill-color: #ed8796 !important;
-webkit-text-stroke: var(--sub-border-size, 2px) rgba(0, 0, 0, 0.85) !important;
paint-order: stroke fill !important;
text-shadow: none !important;
}
.invisible-position-edit-hud {
position: absolute;
top: 14px;
left: 50%;
transform: translateX(-50%);
z-index: 30;
max-width: min(90vw, 1100px);
padding: 6px 10px;
border-radius: 8px;
font-size: 12px;
line-height: 1.35;
color: rgba(255, 255, 255, 0.95);
background: rgba(22, 24, 36, 0.88);
border: 1px solid rgba(130, 150, 255, 0.55);
pointer-events: none;
opacity: 0;
transition: opacity 120ms ease;
}
body.layer-invisible.invisible-position-edit .invisible-position-edit-hud {
opacity: 1;
}
body.layer-invisible.invisible-position-edit #subtitleRoot,
body.layer-invisible.invisible-position-edit #subtitleRoot .word,
body.layer-invisible.invisible-position-edit #subtitleRoot .c {
color: #ed8796 !important;
-webkit-text-fill-color: #ed8796 !important;
-webkit-text-stroke: var(--sub-border-size, 2px) rgba(0, 0, 0, 0.85) !important;
paint-order: stroke fill !important;
text-shadow: none !important;
}
#secondarySubContainer {
position: absolute;
top: 40px;
@@ -538,40 +555,6 @@ body.layer-invisible.invisible-position-edit #subtitleRoot .c {
pointer-events: auto;
}
body.layer-visible #secondarySubContainer,
body.layer-invisible #secondarySubContainer {
display: none !important;
pointer-events: none !important;
}
body.layer-secondary #subtitleContainer,
body.layer-secondary .modal,
body.layer-secondary .overlay-error-toast {
display: none !important;
pointer-events: none !important;
}
body.layer-secondary #overlay {
justify-content: flex-start;
align-items: stretch;
}
body.layer-secondary #secondarySubContainer {
position: absolute;
inset: 0;
top: 0;
left: 0;
right: 0;
transform: none;
max-width: 100%;
border-radius: 0;
background: transparent;
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: center;
}
body.layer-modal #subtitleContainer,
body.layer-modal #secondarySubContainer {
display: none !important;
@@ -597,10 +580,6 @@ body.layer-modal #overlay {
cursor: text;
}
body.layer-secondary #secondarySubRoot {
max-width: 100%;
}
#secondarySubRoot:empty {
display: none;
}
@@ -644,11 +623,7 @@ body.settings-modal-open #secondarySubContainer {
opacity: 1;
}
body.layer-secondary #secondarySubContainer.secondary-sub-hover {
padding: 8px 12px;
align-items: center;
}
iframe.yomitan-popup,
iframe[id^='yomitan-popup'] {
pointer-events: auto !important;
z-index: 2147483647 !important;

View File

@@ -10,9 +10,9 @@ import {
buildInvisibleTokenHoverRanges,
computeWordClass,
normalizeSubtitle,
sanitizeSubtitleHoverTokenColor,
shouldRenderTokenizedSubtitle,
} from './subtitle-render.js';
import { resolveInvisibleLineHeight } from './positioning/invisible-layout-helpers.js';
function createToken(overrides: Partial<MergedToken>): MergedToken {
return {
@@ -210,6 +210,17 @@ test('computeWordClass skips frequency class when rank is out of topX', () => {
assert.equal(actual, 'word');
});
test('sanitizeSubtitleHoverTokenColor falls back for pure black values', () => {
assert.equal(sanitizeSubtitleHoverTokenColor('#000000'), '#f4dbd6');
assert.equal(sanitizeSubtitleHoverTokenColor('000000'), '#f4dbd6');
assert.equal(sanitizeSubtitleHoverTokenColor('#0000'), '#f4dbd6');
});
test('sanitizeSubtitleHoverTokenColor keeps non-black color values', () => {
assert.equal(sanitizeSubtitleHoverTokenColor('#ff00ff'), '#ff00ff');
assert.equal(sanitizeSubtitleHoverTokenColor(undefined), '#f4dbd6');
});
test('alignTokensToSourceText preserves newline separators between adjacent token surfaces', () => {
const tokens = [
createToken({ surface: 'キリキリと', reading: 'きりきりと', headword: 'キリキリと' }),
@@ -285,20 +296,16 @@ test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks i
);
});
test('shouldRenderTokenizedSubtitle disables token rendering on invisible layer', () => {
assert.equal(shouldRenderTokenizedSubtitle(true, 5), false);
});
test('shouldRenderTokenizedSubtitle enables token rendering on visible layer when tokens exist', () => {
assert.equal(shouldRenderTokenizedSubtitle(false, 5), true);
assert.equal(shouldRenderTokenizedSubtitle(false, 0), false);
test('shouldRenderTokenizedSubtitle enables token rendering when tokens exist', () => {
assert.equal(shouldRenderTokenizedSubtitle(5), true);
assert.equal(shouldRenderTokenizedSubtitle(0), false);
});
test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
const distCssPath = path.join(process.cwd(), 'dist', 'renderer', 'style.css');
const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css');
const cssPath = fs.existsSync(distCssPath) ? distCssPath : srcCssPath;
const cssPath = fs.existsSync(srcCssPath) ? srcCssPath : distCssPath;
if (!fs.existsSync(cssPath)) {
assert.fail(
'JLPT CSS file missing. Run `bun run build` first, or ensure src/renderer/style.css exists.',
@@ -330,31 +337,86 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
assert.match(block, /color:\s*var\(/);
}
const invisibleBlock = extractClassBlock(
cssText,
'body.layer-invisible #subtitleRoot',
);
assert.match(
invisibleBlock,
/line-height:\s*var\(--invisible-sub-line-height,\s*normal\)\s*!important;/,
);
const visibleMacBlock = extractClassBlock(
cssText,
'body.platform-macos.layer-visible #subtitleRoot',
);
assert.match(visibleMacBlock, /--visible-sub-line-height:\s*1\.64;/);
assert.match(visibleMacBlock, /--visible-sub-line-gap:\s*0\.54em;/);
});
test('invisible overlay uses looser line height on macOS for multi-line subtitles', () => {
assert.equal(resolveInvisibleLineHeight(1, true), '1.08');
assert.equal(resolveInvisibleLineHeight(2, true), '1.5');
assert.equal(resolveInvisibleLineHeight(3, true), '1.62');
});
const subtitleRootBlock = extractClassBlock(cssText, '#subtitleRoot');
assert.match(
subtitleRootBlock,
/--subtitle-hover-token-color:\s*#f4dbd6;/,
);
assert.match(
subtitleRootBlock,
/--subtitle-hover-token-background-color:\s*rgba\(54,\s*58,\s*79,\s*0\.84\);/,
);
assert.match(subtitleRootBlock, /-webkit-text-fill-color:\s*currentColor;/);
test('invisible overlay keeps default line height on non-macOS platforms', () => {
assert.equal(resolveInvisibleLineHeight(1, false), 'normal');
assert.equal(resolveInvisibleLineHeight(2, false), 'normal');
assert.equal(resolveInvisibleLineHeight(4, false), 'normal');
const charBlock = extractClassBlock(cssText, '#subtitleRoot .c');
assert.match(charBlock, /-webkit-text-fill-color:\s*currentColor\s*!important;/);
const wordBlock = extractClassBlock(cssText, '#subtitleRoot .word');
assert.match(wordBlock, /-webkit-text-fill-color:\s*currentColor\s*!important;/);
assert.match(
cssText,
/#subtitleRoot \.word:not\(\.word-known\):not\(\.word-n-plus-one\):not\(\.word-frequency-single\):not\(\s*\.word-frequency-band-1\s*\):not\(\.word-frequency-band-2\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\s*\.word-frequency-band-5\s*\):hover\s*\{[\s\S]*?background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);[\s\S]*?color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;[\s\S]*?-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
);
const coloredWordHoverBlock = extractClassBlock(cssText, '#subtitleRoot .word.word-known:hover');
assert.match(
coloredWordHoverBlock,
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
);
assert.match(coloredWordHoverBlock, /border-radius:\s*3px;/);
assert.match(coloredWordHoverBlock, /font-weight:\s*800;/);
assert.doesNotMatch(coloredWordHoverBlock, /color:\s*var\(--subtitle-hover-token-color/);
assert.doesNotMatch(coloredWordHoverBlock, /-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color/);
const coloredWordSelectionBlock = extractClassBlock(cssText, '#subtitleRoot .word.word-known::selection');
assert.match(
coloredWordSelectionBlock,
/color:\s*var\(--subtitle-known-word-color,\s*#a6da95\)\s*!important;/,
);
assert.match(
coloredWordSelectionBlock,
/-webkit-text-fill-color:\s*var\(--subtitle-known-word-color,\s*#a6da95\)\s*!important;/,
);
const coloredCharHoverBlock = extractClassBlock(cssText, '#subtitleRoot .word.word-known .c:hover');
assert.match(coloredCharHoverBlock, /background:\s*transparent;/);
assert.match(coloredCharHoverBlock, /color:\s*inherit\s*!important;/);
const selectionBlock = extractClassBlock(cssText, '#subtitleRoot::selection');
assert.match(
selectionBlock,
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
);
assert.match(selectionBlock, /color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/);
assert.match(
selectionBlock,
/-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
);
const descendantSelectionBlock = extractClassBlock(cssText, '#subtitleRoot *::selection');
assert.match(
descendantSelectionBlock,
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\)\s*!important;/,
);
assert.match(
descendantSelectionBlock,
/color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
);
assert.match(
descendantSelectionBlock,
/-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
);
assert.doesNotMatch(
cssText,
/body\.layer-visible\s+#secondarySubContainer\s*\{[^}]*display:\s*none/i,
);
});

View File

@@ -15,11 +15,8 @@ export type InvisibleTokenHoverRange = {
tokenIndex: number;
};
export function shouldRenderTokenizedSubtitle(
isInvisibleLayer: boolean,
tokenCount: number,
): boolean {
return !isInvisibleLayer && tokenCount > 0;
export function shouldRenderTokenizedSubtitle(tokenCount: number): boolean {
return tokenCount > 0;
}
function isWhitespaceOnly(value: string): boolean {
@@ -47,6 +44,23 @@ function sanitizeHexColor(value: unknown, fallback: string): string {
: fallback;
}
export function sanitizeSubtitleHoverTokenColor(value: unknown): string {
const sanitized = sanitizeHexColor(value, '#f4dbd6');
const normalized = sanitized.replace(/^#/, '').toLowerCase();
if (normalized === '000' || normalized === '0000' || normalized === '000000' || normalized === '00000000') {
return '#f4dbd6';
}
return sanitized;
}
function sanitizeSubtitleHoverTokenBackgroundColor(value: unknown): string {
if (typeof value !== 'string') {
return 'rgba(54, 58, 79, 0.84)';
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : 'rgba(54, 58, 79, 0.84)';
}
const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
enabled: false,
topX: 1000,
@@ -79,6 +93,54 @@ function sanitizeFrequencyBandedColors(
];
}
function applyInlineStyleDeclarations(
target: HTMLElement,
declarations: Record<string, unknown>,
excludedKeys: ReadonlySet<string> = new Set<string>(),
): void {
for (const [key, value] of Object.entries(declarations)) {
if (excludedKeys.has(key)) {
continue;
}
if (value === null || value === undefined || typeof value === 'object') {
continue;
}
const cssValue = String(value);
if (key.startsWith('-') || key.includes('-')) {
target.style.setProperty(key, cssValue);
if (key === '--webkit-text-stroke') {
target.style.setProperty('-webkit-text-stroke', cssValue);
}
continue;
}
const styleTarget = target.style as unknown as Record<string, string>;
styleTarget[key] = cssValue;
}
}
function pickInlineStyleDeclarations(
declarations: Record<string, unknown>,
includedKeys: ReadonlySet<string>,
): Record<string, unknown> {
const picked: Record<string, unknown> = {};
for (const [key, value] of Object.entries(declarations)) {
if (!includedKeys.has(key)) continue;
picked[key] = value;
}
return picked;
}
const CONTAINER_STYLE_KEYS = new Set<string>([
'background',
'backgroundColor',
'backdropFilter',
'WebkitBackdropFilter',
'webkitBackdropFilter',
'-webkit-backdrop-filter',
]);
function getFrequencyDictionaryClass(
token: MergedToken,
settings: FrequencyRenderSettings,
@@ -337,11 +399,8 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void
}
export function createSubtitleRenderer(ctx: RendererContext) {
function renderSubtitle(data: SubtitleData | string): void {
function renderSubtitle(data: SubtitleData | string): void {
ctx.dom.subtitleRoot.innerHTML = '';
ctx.state.lastHoverSelectionKey = '';
ctx.state.lastHoverSelectionNode = null;
ctx.state.lastHoveredTokenIndex = null;
let text: string;
let tokens: MergedToken[] | null;
@@ -358,22 +417,8 @@ export function createSubtitleRenderer(ctx: RendererContext) {
if (!text) return;
if (ctx.platform.isInvisibleLayer) {
// Keep natural kerning/shaping in invisible layer to match mpv glyph placement.
const normalizedInvisible = normalizeSubtitle(text, false);
ctx.state.currentInvisibleSubtitleLineCount = Math.max(
1,
normalizedInvisible.split('\n').length,
);
ctx.state.invisibleTokenHoverSourceText = normalizedInvisible;
ctx.state.invisibleTokenHoverRanges =
tokens && tokens.length > 0 ? buildInvisibleTokenHoverRanges(tokens, normalizedInvisible) : [];
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
return;
}
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
if (shouldRenderTokenizedSubtitle(ctx.platform.isInvisibleLayer, tokens?.length ?? 0) && tokens) {
if (shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && tokens) {
renderWithTokens(
ctx.dom.subtitleRoot,
tokens,
@@ -444,17 +489,30 @@ export function createSubtitleRenderer(ctx: RendererContext) {
function applySubtitleStyle(style: SubtitleStyleConfig | null): void {
if (!style) return;
const styleDeclarations = style as Record<string, unknown>;
applyInlineStyleDeclarations(
ctx.dom.subtitleRoot,
styleDeclarations,
CONTAINER_STYLE_KEYS,
);
applyInlineStyleDeclarations(
ctx.dom.subtitleContainer,
pickInlineStyleDeclarations(styleDeclarations, CONTAINER_STYLE_KEYS),
);
if (style.fontFamily) ctx.dom.subtitleRoot.style.fontFamily = style.fontFamily;
if (style.fontSize) ctx.dom.subtitleRoot.style.fontSize = `${style.fontSize}px`;
if (style.fontColor) ctx.dom.subtitleRoot.style.color = style.fontColor;
if (style.fontColor) {
ctx.dom.subtitleRoot.style.color = style.fontColor;
}
if (style.fontWeight) ctx.dom.subtitleRoot.style.fontWeight = style.fontWeight;
if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle;
if (style.backgroundColor) {
ctx.dom.subtitleContainer.style.background = style.backgroundColor;
}
const knownWordColor = style.knownWordColor ?? ctx.state.knownWordColor ?? '#a6da95';
const nPlusOneColor = style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? '#c6a0f6';
const hoverTokenColor = sanitizeSubtitleHoverTokenColor(style.hoverTokenColor);
const hoverTokenBackgroundColor = sanitizeSubtitleHoverTokenBackgroundColor(
style.hoverTokenBackgroundColor,
);
const jlptColors = {
N1: ctx.state.jlptN1Color ?? '#ed8796',
N2: ctx.state.jlptN2Color ?? '#f5a97f',
@@ -476,6 +534,11 @@ export function createSubtitleRenderer(ctx: RendererContext) {
ctx.state.nPlusOneColor = nPlusOneColor;
ctx.dom.subtitleRoot.style.setProperty('--subtitle-known-word-color', knownWordColor);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-n-plus-one-color', nPlusOneColor);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-hover-token-color', hoverTokenColor);
ctx.dom.subtitleRoot.style.setProperty(
'--subtitle-hover-token-background-color',
hoverTokenBackgroundColor,
);
ctx.state.jlptN1Color = jlptColors.N1;
ctx.state.jlptN2Color = jlptColors.N2;
ctx.state.jlptN3Color = jlptColors.N3;
@@ -551,6 +614,17 @@ export function createSubtitleRenderer(ctx: RendererContext) {
const secondaryStyle = style.secondary;
if (!secondaryStyle) return;
const secondaryStyleDeclarations = secondaryStyle as Record<string, unknown>;
applyInlineStyleDeclarations(
ctx.dom.secondarySubRoot,
secondaryStyleDeclarations,
CONTAINER_STYLE_KEYS,
);
applyInlineStyleDeclarations(
ctx.dom.secondarySubContainer,
pickInlineStyleDeclarations(secondaryStyleDeclarations, CONTAINER_STYLE_KEYS),
);
if (secondaryStyle.fontFamily) {
ctx.dom.secondarySubRoot.style.fontFamily = secondaryStyle.fontFamily;
}
@@ -566,9 +640,6 @@ export function createSubtitleRenderer(ctx: RendererContext) {
if (secondaryStyle.fontStyle) {
ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle;
}
if (secondaryStyle.backgroundColor) {
ctx.dom.secondarySubContainer.style.background = secondaryStyle.backgroundColor;
}
}
return {

View File

@@ -1,40 +1,25 @@
export type OverlayLayer = 'visible' | 'invisible' | 'secondary' | 'modal';
export type OverlayLayer = 'visible' | 'modal';
export type PlatformInfo = {
overlayLayer: OverlayLayer;
isInvisibleLayer: boolean;
isSecondaryLayer: boolean;
isModalLayer: boolean;
isLinuxPlatform: boolean;
isMacOSPlatform: boolean;
shouldToggleMouseIgnore: boolean;
invisiblePositionEditToggleCode: string;
invisiblePositionStepPx: number;
invisiblePositionStepFastPx: number;
};
export function resolvePlatformInfo(): PlatformInfo {
const overlayLayerFromPreload = window.electronAPI.getOverlayLayer();
const queryLayer = new URLSearchParams(window.location.search).get('layer');
const overlayLayerFromQuery: OverlayLayer | null =
queryLayer === 'visible' ||
queryLayer === 'invisible' ||
queryLayer === 'secondary' ||
queryLayer === 'modal'
? queryLayer
: null;
queryLayer === 'visible' || queryLayer === 'modal' ? queryLayer : null;
const overlayLayer: OverlayLayer =
overlayLayerFromQuery ??
(overlayLayerFromPreload === 'visible' ||
overlayLayerFromPreload === 'invisible' ||
overlayLayerFromPreload === 'secondary' ||
overlayLayerFromPreload === 'modal'
(overlayLayerFromPreload === 'visible' || overlayLayerFromPreload === 'modal'
? overlayLayerFromPreload
: 'visible');
const isInvisibleLayer = overlayLayer === 'invisible';
const isSecondaryLayer = overlayLayer === 'secondary';
const isModalLayer = overlayLayer === 'modal';
const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux');
const isMacOSPlatform =
@@ -42,14 +27,9 @@ export function resolvePlatformInfo(): PlatformInfo {
return {
overlayLayer,
isInvisibleLayer,
isSecondaryLayer,
isModalLayer,
isLinuxPlatform,
isMacOSPlatform,
shouldToggleMouseIgnore: !isLinuxPlatform && !isSecondaryLayer && !isModalLayer,
invisiblePositionEditToggleCode: 'KeyP',
invisiblePositionStepPx: 1,
invisiblePositionStepFastPx: 4,
shouldToggleMouseIgnore: !isLinuxPlatform && !isModalLayer,
};
}

View File

@@ -19,15 +19,14 @@ export const IPC_CHANNELS = {
refreshKnownWords: 'anki:refresh-known-words',
kikuFieldGroupingRespond: 'kiku:field-grouping-respond',
reportOverlayContentBounds: 'overlay-content-bounds:report',
overlayModalOpened: 'overlay:modal-opened',
},
request: {
getOverlayVisibility: 'get-overlay-visibility',
getVisibleOverlayVisibility: 'get-visible-overlay-visibility',
getInvisibleOverlayVisibility: 'get-invisible-overlay-visibility',
getCurrentSubtitle: 'get-current-subtitle',
getCurrentSubtitleRaw: 'get-current-subtitle-raw',
getCurrentSubtitleAss: 'get-current-subtitle-ass',
getMpvSubtitleRenderMetrics: 'get-mpv-subtitle-render-metrics',
getSubtitlePosition: 'get-subtitle-position',
getSubtitleStyle: 'get-subtitle-style',
getMecabStatus: 'get-mecab-status',
@@ -57,9 +56,7 @@ export const IPC_CHANNELS = {
subtitleSet: 'subtitle:set',
subtitleVisibility: 'mpv:subVisibility',
subtitlePositionSet: 'subtitle-position:set',
mpvSubtitleRenderMetricsSet: 'mpv-subtitle-render-metrics:set',
subtitleAssSet: 'subtitle-ass:set',
overlayDebugVisualizationSet: 'overlay-debug-visualization:set',
secondarySubtitleSet: 'secondary-subtitle:set',
secondarySubtitleMode: 'secondary-subtitle:mode',
subsyncOpenManual: 'subsync:open-manual',

View File

@@ -71,8 +71,6 @@ export interface WindowGeometry {
export interface SubtitlePosition {
yPercent: number;
invisibleOffsetXPx?: number;
invisibleOffsetYPx?: number;
}
export interface SubtitleStyle {
@@ -272,6 +270,7 @@ export interface SubtitleStyleConfig {
enableJlpt?: boolean;
preserveLineBreaks?: boolean;
hoverTokenColor?: string;
hoverTokenBackgroundColor?: string;
fontFamily?: string;
fontSize?: number;
fontColor?: string;
@@ -309,7 +308,6 @@ export type FrequencyDictionaryMode = 'single' | 'banded';
export interface ShortcutsConfig {
toggleVisibleOverlayGlobal?: string | null;
toggleInvisibleOverlayGlobal?: string | null;
copySubtitle?: string | null;
copySubtitleMultiple?: string | null;
updateLastCardFromClipboard?: string | null;
@@ -364,10 +362,6 @@ export interface DiscordPresenceConfig {
debounceMs?: number;
}
export interface InvisibleOverlayConfig {
startupVisibility?: 'platform-default' | 'visible' | 'hidden';
}
export type YoutubeSubgenMode = 'automatic' | 'preprocess' | 'off';
export interface YoutubeSubgenConfig {
@@ -410,7 +404,6 @@ export interface Config {
anilist?: AnilistConfig;
jellyfin?: JellyfinConfig;
discordPresence?: DiscordPresenceConfig;
invisibleOverlay?: InvisibleOverlayConfig;
youtubeSubgen?: YoutubeSubgenConfig;
immersionTracking?: ImmersionTrackingConfig;
logging?: {
@@ -540,7 +533,6 @@ export interface ResolvedConfig {
updateIntervalMs: number;
debounceMs: number;
};
invisibleOverlay: Required<InvisibleOverlayConfig>;
youtubeSubgen: YoutubeSubgenConfig & {
mode: YoutubeSubgenMode;
whisperBin: string;
@@ -630,7 +622,7 @@ export interface MpvSubtitleRenderMetrics {
} | null;
}
export type OverlayLayer = 'visible' | 'invisible';
export type OverlayLayer = 'visible';
export interface OverlayContentRect {
x: number;
@@ -728,7 +720,7 @@ export interface SubtitleHoverTokenPayload {
}
export interface ElectronAPI {
getOverlayLayer: () => 'visible' | 'invisible' | 'secondary' | 'modal' | null;
getOverlayLayer: () => 'visible' | 'modal' | null;
onSubtitle: (callback: (data: SubtitleData) => void) => void;
onVisibility: (callback: (visible: boolean) => void) => void;
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
@@ -736,10 +728,7 @@ export interface ElectronAPI {
getCurrentSubtitle: () => Promise<SubtitleData>;
getCurrentSubtitleRaw: () => Promise<string>;
getCurrentSubtitleAss: () => Promise<string>;
getMpvSubtitleRenderMetrics: () => Promise<MpvSubtitleRenderMetrics>;
onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => void;
onSubtitleAss: (callback: (assText: string) => void) => void;
onOverlayDebugVisualization: (callback: (enabled: boolean) => void) => void;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
openYomitanSettings: () => void;
getSubtitlePosition: () => Promise<SubtitlePosition | null>;
@@ -781,8 +770,8 @@ export interface ElectronAPI {
onOpenJimaku: (callback: () => void) => void;
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
}