feat(overlay): split secondary subtitles into dedicated top window

This commit is contained in:
2026-02-22 18:41:23 -08:00
parent badb82280a
commit 0a2461f45a
23 changed files with 523 additions and 29 deletions

View File

@@ -10,6 +10,7 @@ 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.getVisibleOverlayVisible(), false);
assert.equal(manager.getInvisibleOverlayVisible(), false);
assert.deepEqual(manager.getOverlayWindows(), []);
@@ -23,15 +24,20 @@ test('overlay manager stores window references and returns stable window order',
const invisibleWindow = {
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow;
const secondaryWindow = {
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow;
manager.setMainWindow(visibleWindow);
manager.setInvisibleWindow(invisibleWindow);
manager.setSecondaryWindow(secondaryWindow);
assert.equal(manager.getMainWindow(), visibleWindow);
assert.equal(manager.getInvisibleWindow(), invisibleWindow);
assert.equal(manager.getSecondaryWindow(), secondaryWindow);
assert.equal(manager.getOverlayWindow('visible'), visibleWindow);
assert.equal(manager.getOverlayWindow('invisible'), invisibleWindow);
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow]);
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow, secondaryWindow]);
});
test('overlay manager excludes destroyed windows', () => {
@@ -42,6 +48,9 @@ test('overlay manager excludes destroyed windows', () => {
manager.setInvisibleWindow({
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow);
manager.setSecondaryWindow({
isDestroyed: () => true,
} as unknown as Electron.BrowserWindow);
assert.equal(manager.getOverlayWindows().length, 1);
});
@@ -72,12 +81,24 @@ test('overlay manager broadcasts to non-destroyed windows', () => {
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.broadcastToOverlayWindows('x', 1, 'a');
assert.deepEqual(calls, [['x', 1, 'a']]);
assert.deepEqual(calls, [
['x', 1, 'a'],
['x', 1, 'a'],
]);
});
test('overlay manager applies bounds by layer', () => {
@@ -96,8 +117,15 @@ test('overlay manager applies bounds by layer', () => {
invisibleCalls.push(bounds);
},
} as unknown as Electron.BrowserWindow;
const secondaryWindow = {
isDestroyed: () => false,
setBounds: (bounds: Electron.Rectangle) => {
invisibleCalls.push(bounds);
},
} as unknown as Electron.BrowserWindow;
manager.setMainWindow(visibleWindow);
manager.setInvisibleWindow(invisibleWindow);
manager.setSecondaryWindow(secondaryWindow);
manager.setOverlayWindowBounds('visible', {
x: 10,
@@ -111,9 +139,18 @@ test('overlay manager applies bounds by layer', () => {
width: 3,
height: 4,
});
manager.setSecondaryWindowBounds({
x: 8,
y: 9,
width: 10,
height: 11,
});
assert.deepEqual(visibleCalls, [{ x: 10, y: 20, width: 30, height: 40 }]);
assert.deepEqual(invisibleCalls, [{ x: 1, y: 2, width: 3, height: 4 }]);
assert.deepEqual(invisibleCalls, [
{ x: 1, y: 2, width: 3, height: 4 },
{ x: 8, y: 9, width: 10, height: 11 },
]);
});
test('runtime-option and debug broadcasts use expected channels', () => {

View File

@@ -9,8 +9,11 @@ export interface OverlayManager {
setMainWindow: (window: BrowserWindow | null) => void;
getInvisibleWindow: () => BrowserWindow | null;
setInvisibleWindow: (window: BrowserWindow | null) => void;
getSecondaryWindow: () => BrowserWindow | null;
setSecondaryWindow: (window: BrowserWindow | null) => void;
getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null;
setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void;
setSecondaryWindowBounds: (geometry: WindowGeometry) => void;
getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
getInvisibleOverlayVisible: () => boolean;
@@ -22,6 +25,7 @@ export interface OverlayManager {
export function createOverlayManager(): OverlayManager {
let mainWindow: BrowserWindow | null = null;
let invisibleWindow: BrowserWindow | null = null;
let secondaryWindow: BrowserWindow | null = null;
let visibleOverlayVisible = false;
let invisibleOverlayVisible = false;
@@ -34,10 +38,17 @@ export function createOverlayManager(): OverlayManager {
setInvisibleWindow: (window) => {
invisibleWindow = window;
},
getSecondaryWindow: () => secondaryWindow,
setSecondaryWindow: (window) => {
secondaryWindow = window;
},
getOverlayWindow: (layer) => (layer === 'visible' ? mainWindow : invisibleWindow),
setOverlayWindowBounds: (layer, geometry) => {
updateOverlayWindowBounds(geometry, layer === 'visible' ? mainWindow : invisibleWindow);
},
setSecondaryWindowBounds: (geometry) => {
updateOverlayWindowBounds(geometry, secondaryWindow);
},
getVisibleOverlayVisible: () => visibleOverlayVisible,
setVisibleOverlayVisible: (visible) => {
visibleOverlayVisible = visible;
@@ -54,6 +65,9 @@ export function createOverlayManager(): OverlayManager {
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
windows.push(invisibleWindow);
}
if (secondaryWindow && !secondaryWindow.isDestroyed()) {
windows.push(secondaryWindow);
}
return windows;
},
broadcastToOverlayWindows: (channel, ...args) => {
@@ -64,6 +78,9 @@ export function createOverlayManager(): OverlayManager {
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
windows.push(invisibleWindow);
}
if (secondaryWindow && !secondaryWindow.isDestroyed()) {
windows.push(secondaryWindow);
}
for (const window of windows) {
window.webContents.send(channel, ...args);
}

View File

@@ -0,0 +1,41 @@
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

@@ -0,0 +1,37 @@
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

@@ -5,7 +5,7 @@ import { createLogger } from '../../logger';
const logger = createLogger('main:overlay-window');
export type OverlayWindowKind = 'visible' | 'invisible';
export type OverlayWindowKind = 'visible' | 'invisible' | 'secondary';
export function updateOverlayWindowBounds(
geometry: WindowGeometry,
@@ -87,7 +87,7 @@ export function createOverlayWindow(
window
.loadFile(htmlPath, {
query: { layer: kind === 'visible' ? 'visible' : 'invisible' },
query: { layer: kind },
})
.catch((err) => {
logger.error('Failed to load HTML file:', err);

View File

@@ -27,6 +27,7 @@ import {
nativeImage,
Tray,
dialog,
screen,
} from 'electron';
protocol.registerSchemesAsPrivileged([
@@ -329,6 +330,7 @@ import {
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,
@@ -814,6 +816,7 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
},
setSecondarySubMode: (mode) => {
appState.secondarySubMode = mode;
syncSecondaryOverlayWindowVisibility();
},
broadcastToOverlayWindows: (channel, payload) => {
broadcastToOverlayWindows(channel, payload);
@@ -1848,6 +1851,7 @@ const { reloadConfig: reloadConfigHandler, appReadyRuntimeRunner } = composeAppR
},
setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode;
syncSecondaryOverlayWindowVisibility();
},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
@@ -2167,10 +2171,57 @@ function updateMpvSubtitleRenderMetrics(patch: Partial<MpvSubtitleRenderMetrics>
updateMpvSubtitleRenderMetricsHandler(patch);
}
let lastOverlayWindowGeometry: WindowGeometry | null = null;
function getOverlayGeometryFallback(): WindowGeometry {
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
const bounds = display.workArea;
return {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
};
}
function getCurrentOverlayGeometry(): WindowGeometry {
if (lastOverlayWindowGeometry) return lastOverlayWindowGeometry;
const trackerGeometry = appState.windowTracker?.getGeometry();
if (trackerGeometry) return trackerGeometry;
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 {
lastOverlayWindowGeometry = geometry;
const regions = splitOverlayGeometryForSecondaryBar(geometry);
overlayManager.setOverlayWindowBounds(layer, regions.primary);
overlayManager.setSecondaryWindowBounds(regions.secondary);
syncSecondaryOverlayWindowVisibility();
}
const buildUpdateVisibleOverlayBoundsMainDepsHandler =
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
setOverlayWindowBounds: (layer, geometry) =>
overlayManager.setOverlayWindowBounds(layer, geometry),
setOverlayWindowBounds: (layer, geometry) => applyOverlayRegions(layer, geometry),
});
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
@@ -2179,8 +2230,7 @@ const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
const buildUpdateInvisibleOverlayBoundsMainDepsHandler =
createBuildUpdateInvisibleOverlayBoundsMainDepsHandler({
setOverlayWindowBounds: (layer, geometry) =>
overlayManager.setOverlayWindowBounds(layer, geometry),
setOverlayWindowBounds: (layer, geometry) => applyOverlayRegions(layer, geometry),
});
const updateInvisibleOverlayBoundsMainDeps = buildUpdateInvisibleOverlayBoundsMainDepsHandler();
const updateInvisibleOverlayBounds = createUpdateInvisibleOverlayBoundsHandler(
@@ -2226,12 +2276,24 @@ async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
return yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
}
function createOverlayWindow(kind: 'visible' | 'invisible'): BrowserWindow {
function createOverlayWindow(kind: 'visible' | 'invisible' | 'secondary'): BrowserWindow {
return createOverlayWindowHandler(kind);
}
function createSecondaryWindow(): BrowserWindow {
const existingWindow = overlayManager.getSecondaryWindow();
if (existingWindow && !existingWindow.isDestroyed()) {
return existingWindow;
}
const window = createSecondaryWindowHandler();
applyOverlayRegions('visible', getCurrentOverlayGeometry());
return window;
}
function createMainWindow(): BrowserWindow {
return createMainWindowHandler();
const window = createMainWindowHandler();
createSecondaryWindow();
return window;
}
function createInvisibleWindow(): BrowserWindow {
return createInvisibleWindowHandler();
@@ -2339,6 +2401,7 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
getSecondarySubMode: () => appState.secondarySubMode,
setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode;
syncSecondaryOverlayWindowVisibility();
},
getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs,
setLastSecondarySubToggleAtMs: (timestampMs: number) => {
@@ -2678,6 +2741,7 @@ const {
createOverlayWindow: createOverlayWindowHandler,
createMainWindow: createMainWindowHandler,
createInvisibleWindow: createInvisibleWindowHandler,
createSecondaryWindow: createSecondaryWindowHandler,
} = createOverlayWindowRuntimeHandlers<BrowserWindow>({
createOverlayWindowDeps: {
createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options),
@@ -2689,19 +2753,24 @@ const {
isOverlayVisible: (windowKind) =>
windowKind === 'visible'
? overlayManager.getVisibleOverlayVisible()
: overlayManager.getInvisibleOverlayVisible(),
: windowKind === 'invisible'
? overlayManager.getInvisibleOverlayVisible()
: false,
tryHandleOverlayShortcutLocalFallback: (input) =>
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
onWindowClosed: (windowKind) => {
if (windowKind === 'visible') {
overlayManager.setMainWindow(null);
} else {
} else if (windowKind === 'invisible') {
overlayManager.setInvisibleWindow(null);
} else {
overlayManager.setSecondaryWindow(null);
}
},
},
setMainWindow: (window) => overlayManager.setMainWindow(window),
setInvisibleWindow: (window) => overlayManager.setInvisibleWindow(window),
setSecondaryWindow: (window) => overlayManager.setSecondaryWindow(window),
});
const {
resolveTrayIconPath: resolveTrayIconPathHandler,

View File

@@ -4,6 +4,7 @@ import {
createBuildCreateInvisibleWindowMainDepsHandler,
createBuildCreateMainWindowMainDepsHandler,
createBuildCreateOverlayWindowMainDepsHandler,
createBuildCreateSecondaryWindowMainDepsHandler,
} from './overlay-window-factory-main-deps';
test('overlay window factory main deps builders return mapped handlers', () => {
@@ -39,5 +40,12 @@ test('overlay window factory main deps builders return mapped handlers', () => {
const invisibleDeps = buildInvisibleDeps();
invisibleDeps.setInvisibleWindow(null);
assert.deepEqual(calls, ['set-main', 'set-invisible']);
const buildSecondaryDeps = createBuildCreateSecondaryWindowMainDepsHandler({
createOverlayWindow: () => ({ id: 'secondary' }),
setSecondaryWindow: () => calls.push('set-secondary'),
});
const secondaryDeps = buildSecondaryDeps();
secondaryDeps.setSecondaryWindow(null);
assert.deepEqual(calls, ['set-main', 'set-invisible', 'set-secondary']);
});

View File

@@ -1,15 +1,15 @@
export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindowCore: (
kind: 'visible' | 'invisible',
kind: 'visible' | 'invisible' | 'secondary',
options: {
isDev: boolean;
overlayDebugVisualizationEnabled: boolean;
ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: 'visible' | 'invisible') => boolean;
isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
onWindowClosed: (windowKind: 'visible' | 'invisible') => void;
onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary') => void;
},
) => TWindow;
isDev: boolean;
@@ -17,9 +17,9 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: 'visible' | 'invisible') => boolean;
isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
onWindowClosed: (windowKind: 'visible' | 'invisible') => void;
onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary') => void;
}) {
return () => ({
createOverlayWindowCore: deps.createOverlayWindowCore,
@@ -35,7 +35,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
}
export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible') => TWindow;
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow;
setMainWindow: (window: TWindow | null) => void;
}) {
return () => ({
@@ -45,7 +45,7 @@ export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
}
export function createBuildCreateInvisibleWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible') => TWindow;
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow;
setInvisibleWindow: (window: TWindow | null) => void;
}) {
return () => ({
@@ -53,3 +53,13 @@ export function createBuildCreateInvisibleWindowMainDepsHandler<TWindow>(deps: {
setInvisibleWindow: deps.setInvisibleWindow,
});
}
export function createBuildCreateSecondaryWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow;
setSecondaryWindow: (window: TWindow | null) => void;
}) {
return () => ({
createOverlayWindow: deps.createOverlayWindow,
setSecondaryWindow: deps.setSecondaryWindow,
});
}

View File

@@ -4,6 +4,7 @@ import {
createCreateInvisibleWindowHandler,
createCreateMainWindowHandler,
createCreateOverlayWindowHandler,
createCreateSecondaryWindowHandler,
} from './overlay-window-factory';
test('create overlay window handler forwards options and kind', () => {
@@ -64,3 +65,18 @@ test('create invisible window handler stores invisible window', () => {
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']);
});

View File

@@ -1,4 +1,4 @@
type OverlayWindowKind = 'visible' | 'invisible';
type OverlayWindowKind = 'visible' | 'invisible' | 'secondary';
export function createCreateOverlayWindowHandler<TWindow>(deps: {
createOverlayWindowCore: (
@@ -58,3 +58,14 @@ export function createCreateInvisibleWindowHandler<TWindow>(deps: {
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;
};
}

View File

@@ -5,6 +5,7 @@ import { createOverlayWindowRuntimeHandlers } from './overlay-window-runtime-han
test('overlay window runtime handlers compose create/main/invisible handlers', () => {
let mainWindow: { kind: string } | null = null;
let invisibleWindow: { kind: string } | null = null;
let secondaryWindow: { kind: string } | null = null;
let debugEnabled = false;
const calls: string[] = [];
@@ -28,10 +29,14 @@ test('overlay window runtime handlers compose create/main/invisible handlers', (
setInvisibleWindow: (window) => {
invisibleWindow = window;
},
setSecondaryWindow: (window) => {
secondaryWindow = 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.createMainWindow(), { kind: 'visible' });
assert.deepEqual(mainWindow, { kind: 'visible' });
@@ -39,6 +44,9 @@ test('overlay window runtime handlers compose create/main/invisible handlers', (
assert.deepEqual(runtime.createInvisibleWindow(), { kind: 'invisible' });
assert.deepEqual(invisibleWindow, { kind: 'invisible' });
assert.deepEqual(runtime.createSecondaryWindow(), { kind: 'secondary' });
assert.deepEqual(secondaryWindow, { kind: 'secondary' });
assert.equal(debugEnabled, false);
assert.deepEqual(calls, []);
});

View File

@@ -2,11 +2,13 @@ import {
createCreateInvisibleWindowHandler,
createCreateMainWindowHandler,
createCreateOverlayWindowHandler,
createCreateSecondaryWindowHandler,
} from './overlay-window-factory';
import {
createBuildCreateInvisibleWindowMainDepsHandler,
createBuildCreateMainWindowMainDepsHandler,
createBuildCreateOverlayWindowMainDepsHandler,
createBuildCreateSecondaryWindowMainDepsHandler,
} from './overlay-window-factory-main-deps';
type CreateOverlayWindowMainDeps<TWindow> = Parameters<
@@ -17,6 +19,7 @@ export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
createOverlayWindowDeps: CreateOverlayWindowMainDeps<TWindow>;
setMainWindow: (window: TWindow | null) => void;
setInvisibleWindow: (window: TWindow | null) => void;
setSecondaryWindow: (window: TWindow | null) => void;
}) {
const createOverlayWindow = createCreateOverlayWindowHandler<TWindow>(
createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps.createOverlayWindowDeps)(),
@@ -33,10 +36,17 @@ export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
setInvisibleWindow: (window) => deps.setInvisibleWindow(window),
})(),
);
const createSecondaryWindow = createCreateSecondaryWindowHandler<TWindow>(
createBuildCreateSecondaryWindowMainDepsHandler<TWindow>({
createOverlayWindow: (kind) => createOverlayWindow(kind),
setSecondaryWindow: (window) => deps.setSecondaryWindow(window),
})(),
);
return {
createOverlayWindow,
createMainWindow,
createInvisibleWindow,
createSecondaryWindow,
};
}

View File

@@ -55,7 +55,9 @@ 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 === 'visible' ||
overlayLayerFromArg === 'invisible' ||
overlayLayerFromArg === 'secondary'
? overlayLayerFromArg
: null;

View File

@@ -155,3 +155,38 @@ test('resolvePlatformInfo prefers query layer over preload layer', () => {
});
}
});
test('resolvePlatformInfo supports secondary layer and disables mouse-ignore toggles', () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getOverlayLayer: () => 'secondary',
},
location: { search: '' },
},
});
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
platform: 'MacIntel',
userAgent: 'Mozilla/5.0 (Macintosh)',
},
});
try {
const info = resolvePlatformInfo();
assert.equal(info.overlayLayer, 'secondary');
assert.equal(info.isSecondaryLayer, true);
assert.equal(info.shouldToggleMouseIgnore, false);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: previousNavigator,
});
}
});

View File

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

View File

@@ -3,6 +3,10 @@ import type { RendererContext } from './context';
const MEASUREMENT_DEBOUNCE_MS = 80;
function isMeasurableOverlayLayer(layer: string): layer is 'visible' | 'invisible' {
return layer === 'visible' || layer === 'invisible';
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
@@ -78,6 +82,10 @@ export function createOverlayContentMeasurementReporter(ctx: RendererContext) {
let debounceTimer: number | null = null;
function emitNow(): void {
if (!isMeasurableOverlayLayer(ctx.platform.overlayLayer)) {
return;
}
const measurement: OverlayContentMeasurement = {
layer: ctx.platform.overlayLayer,
measuredAtMs: Date.now(),

View File

@@ -533,6 +533,40 @@ 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;
}
#secondarySubRoot {
text-align: center;
font-size: 24px;
@@ -548,6 +582,10 @@ body.layer-invisible.invisible-position-edit #subtitleRoot .c {
cursor: text;
}
body.layer-secondary #secondarySubRoot {
max-width: 100%;
}
#secondarySubRoot:empty {
display: none;
}
@@ -591,6 +629,11 @@ body.settings-modal-open #secondarySubContainer {
opacity: 1;
}
body.layer-secondary #secondarySubContainer.secondary-sub-hover {
padding: 8px 12px;
align-items: center;
}
iframe[id^='yomitan-popup'] {
pointer-events: auto !important;
z-index: 2147483647 !important;

View File

@@ -1,8 +1,9 @@
export type OverlayLayer = 'visible' | 'invisible';
export type OverlayLayer = 'visible' | 'invisible' | 'secondary';
export type PlatformInfo = {
overlayLayer: OverlayLayer;
isInvisibleLayer: boolean;
isSecondaryLayer: boolean;
isLinuxPlatform: boolean;
isMacOSPlatform: boolean;
shouldToggleMouseIgnore: boolean;
@@ -15,15 +16,20 @@ 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 : null;
queryLayer === 'visible' || queryLayer === 'invisible' || queryLayer === 'secondary'
? queryLayer
: null;
const overlayLayer: OverlayLayer =
overlayLayerFromQuery ??
(overlayLayerFromPreload === 'visible' || overlayLayerFromPreload === 'invisible'
(overlayLayerFromPreload === 'visible' ||
overlayLayerFromPreload === 'invisible' ||
overlayLayerFromPreload === 'secondary'
? overlayLayerFromPreload
: 'visible');
const isInvisibleLayer = overlayLayer === 'invisible';
const isSecondaryLayer = overlayLayer === 'secondary';
const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux');
const isMacOSPlatform =
navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent);
@@ -31,9 +37,10 @@ export function resolvePlatformInfo(): PlatformInfo {
return {
overlayLayer,
isInvisibleLayer,
isSecondaryLayer,
isLinuxPlatform,
isMacOSPlatform,
shouldToggleMouseIgnore: !isLinuxPlatform,
shouldToggleMouseIgnore: !isLinuxPlatform && !isSecondaryLayer,
invisiblePositionEditToggleCode: 'KeyP',
invisiblePositionStepPx: 1,
invisiblePositionStepFastPx: 4,

View File

@@ -728,7 +728,7 @@ export interface SubtitleHoverTokenPayload {
}
export interface ElectronAPI {
getOverlayLayer: () => 'visible' | 'invisible' | null;
getOverlayLayer: () => 'visible' | 'invisible' | 'secondary' | null;
onSubtitle: (callback: (data: SubtitleData) => void) => void;
onVisibility: (callback: (visible: boolean) => void) => void;
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;