mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
feat(overlay): split secondary subtitles into dedicated top window
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
41
src/core/services/overlay-window-geometry.ts
Normal file
41
src/core/services/overlay-window-geometry.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
37
src/core/services/overlay-window.test.ts
Normal file
37
src/core/services/overlay-window.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
85
src/main.ts
85
src/main.ts
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ export type RendererRecoverySnapshot = {
|
||||
isOverlayInteractive: boolean;
|
||||
isOverSubtitle: boolean;
|
||||
invisiblePositionEditMode: boolean;
|
||||
overlayLayer: 'visible' | 'invisible';
|
||||
overlayLayer: 'visible' | 'invisible' | 'secondary';
|
||||
};
|
||||
|
||||
type NormalizedRendererError = {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user