Windows update (#49)

This commit is contained in:
2026-04-11 21:45:52 -07:00
committed by GitHub
parent 49e46e6b9b
commit 52bab1d611
168 changed files with 9732 additions and 1422 deletions

View File

@@ -28,7 +28,21 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerFieldGrouping: false,
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,

View File

@@ -76,9 +76,7 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
);
assert.ok(calls.includes('startBackgroundWarmups'));
assert.ok(
calls.includes(
'log:Runtime ready: immersion tracker startup deferred until first media activity.',
),
calls.includes('log:Runtime ready: immersion tracker startup requested.'),
);
});
@@ -103,6 +101,17 @@ test('runAppReadyRuntime starts texthooker on startup when enabled in config', a
);
});
test('runAppReadyRuntime creates immersion tracker during heavy startup', async () => {
const { deps, calls } = makeDeps({
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
});
await runAppReadyRuntime(deps);
assert.equal(calls.includes('createImmersionTracker'), false);
assert.ok(calls.includes('log:Runtime ready: immersion tracker startup requested.'));
});
test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => {
const { deps, calls } = makeDeps({
getResolvedConfig: () => ({

View File

@@ -29,8 +29,22 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerFieldGrouping: false,
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
toggleSubtitleSidebar: false,
refreshKnownWords: false,
openRuntimeOptions: false,
openSessionHelp: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,
@@ -143,6 +157,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openRuntimeOptionsPalette: () => {
calls.push('openRuntimeOptionsPalette');
},
dispatchSessionAction: async () => {
calls.push('dispatchSessionAction');
},
getAnilistStatus: () => ({
tokenStatus: 'resolved',
tokenSource: 'stored',
@@ -499,6 +516,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
expected: 'startPendingMineSentenceMultiple:2500',
},
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
{ args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' },
{
args: { openRuntimeOptions: true },
expected: 'openRuntimeOptionsPalette',
@@ -518,6 +536,33 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
}
});
test('handleCliCommand dispatches cycle-runtime-option session action', async () => {
let request: unknown = null;
const { deps } = createDeps({
dispatchSessionAction: async (nextRequest) => {
request = nextRequest;
},
});
handleCliCommand(
makeArgs({
cycleRuntimeOptionId: 'anki.autoUpdateNewCards',
cycleRuntimeOptionDirection: -1,
}),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(request, {
actionId: 'cycleRuntimeOption',
payload: {
runtimeOptionId: 'anki.autoUpdateNewCards',
direction: -1,
},
});
});
test('handleCliCommand logs AniList status details', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);

View File

@@ -1,4 +1,5 @@
import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args';
import type { SessionActionDispatchRequest } from '../../types/runtime';
export interface CliCommandServiceDeps {
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
@@ -32,6 +33,7 @@ export interface CliCommandServiceDeps {
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
openRuntimeOptionsPalette: () => void;
dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>;
getAnilistStatus: () => {
tokenStatus: 'not_checked' | 'resolved' | 'error';
tokenSource: 'none' | 'literal' | 'stored';
@@ -168,6 +170,7 @@ export interface CliCommandDepsRuntimeOptions {
};
ui: UiCliRuntime;
app: AppCliRuntime;
dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>;
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => unknown;
log: (message: string) => void;
@@ -226,6 +229,7 @@ export function createCliCommandDepsRuntime(
triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig,
markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard,
openRuntimeOptionsPalette: options.ui.openRuntimeOptionsPalette,
dispatchSessionAction: options.dispatchSessionAction,
getAnilistStatus: options.anilist.getStatus,
clearAnilistToken: options.anilist.clearToken,
openAnilistSetup: options.anilist.openSetup,
@@ -268,6 +272,19 @@ export function handleCliCommand(
source: CliCommandSource = 'initial',
deps: CliCommandServiceDeps,
): void {
const dispatchCliSessionAction = (
request: SessionActionDispatchRequest,
logLabel: string,
osdLabel: string,
): void => {
runAsyncWithOsd(
() => deps.dispatchSessionAction(request),
deps,
logLabel,
osdLabel,
);
};
if (args.logLevel) {
deps.setLogLevel?.(args.logLevel);
}
@@ -379,8 +396,100 @@ export function handleCliCommand(
'markLastCardAsAudioCard',
'Audio card failed',
);
} else if (args.toggleStatsOverlay) {
dispatchCliSessionAction(
{ actionId: 'toggleStatsOverlay' },
'toggleStatsOverlay',
'Stats toggle failed',
);
} else if (args.toggleSubtitleSidebar) {
dispatchCliSessionAction(
{ actionId: 'toggleSubtitleSidebar' },
'toggleSubtitleSidebar',
'Subtitle sidebar toggle failed',
);
} else if (args.openRuntimeOptions) {
deps.openRuntimeOptionsPalette();
} else if (args.openSessionHelp) {
dispatchCliSessionAction(
{ actionId: 'openSessionHelp' },
'openSessionHelp',
'Open session help failed',
);
} else if (args.openControllerSelect) {
dispatchCliSessionAction(
{ actionId: 'openControllerSelect' },
'openControllerSelect',
'Open controller select failed',
);
} else if (args.openControllerDebug) {
dispatchCliSessionAction(
{ actionId: 'openControllerDebug' },
'openControllerDebug',
'Open controller debug failed',
);
} else if (args.openJimaku) {
dispatchCliSessionAction({ actionId: 'openJimaku' }, 'openJimaku', 'Open jimaku failed');
} else if (args.openYoutubePicker) {
dispatchCliSessionAction(
{ actionId: 'openYoutubePicker' },
'openYoutubePicker',
'Open YouTube picker failed',
);
} else if (args.openPlaylistBrowser) {
dispatchCliSessionAction(
{ actionId: 'openPlaylistBrowser' },
'openPlaylistBrowser',
'Open playlist browser failed',
);
} else if (args.replayCurrentSubtitle) {
dispatchCliSessionAction(
{ actionId: 'replayCurrentSubtitle' },
'replayCurrentSubtitle',
'Replay subtitle failed',
);
} else if (args.playNextSubtitle) {
dispatchCliSessionAction(
{ actionId: 'playNextSubtitle' },
'playNextSubtitle',
'Play next subtitle failed',
);
} else if (args.shiftSubDelayPrevLine) {
dispatchCliSessionAction(
{ actionId: 'shiftSubDelayPrevLine' },
'shiftSubDelayPrevLine',
'Shift subtitle delay failed',
);
} else if (args.shiftSubDelayNextLine) {
dispatchCliSessionAction(
{ actionId: 'shiftSubDelayNextLine' },
'shiftSubDelayNextLine',
'Shift subtitle delay failed',
);
} else if (args.cycleRuntimeOptionId !== undefined) {
dispatchCliSessionAction(
{
actionId: 'cycleRuntimeOption',
payload: {
runtimeOptionId: args.cycleRuntimeOptionId,
direction: args.cycleRuntimeOptionDirection ?? 1,
},
},
'cycleRuntimeOption',
'Runtime option change failed',
);
} else if (args.copySubtitleCount !== undefined) {
dispatchCliSessionAction(
{ actionId: 'copySubtitleMultiple', payload: { count: args.copySubtitleCount } },
'copySubtitleMultiple',
'Copy failed',
);
} else if (args.mineSentenceCount !== undefined) {
dispatchCliSessionAction(
{ actionId: 'mineSentenceMultiple', payload: { count: args.mineSentenceCount } },
'mineSentenceMultiple',
'Mine sentence failed',
);
} else if (args.anilistStatus) {
const status = deps.getAnilistStatus();
deps.log(`AniList token status: ${status.tokenStatus} (source=${status.tokenSource})`);

View File

@@ -72,6 +72,7 @@ export {
createOverlayWindow,
enforceOverlayLayerOrder,
ensureOverlayWindowLevel,
isOverlayWindowContentReady,
syncOverlayWindowLayer,
updateOverlayWindowBounds,
} from './overlay-window';

View File

@@ -3,7 +3,11 @@ import assert from 'node:assert/strict';
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { PlaylistBrowserSnapshot, SubtitleSidebarSnapshot } from '../../types';
import type {
PlaylistBrowserSnapshot,
SessionActionDispatchRequest,
SubtitleSidebarSnapshot,
} from '../../types';
interface FakeIpcRegistrar {
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
@@ -127,7 +131,9 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
setMecabEnabled: () => {},
handleMpvCommand: () => {},
getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(),
@@ -226,7 +232,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
getMecabTokenizer: () => null,
handleMpvCommand: () => {},
getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(),
@@ -382,7 +390,9 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
setMecabEnabled: () => {},
handleMpvCommand: () => {},
getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(),
@@ -707,7 +717,9 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
setMecabEnabled: () => {},
handleMpvCommand: () => {},
getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(),
@@ -786,7 +798,9 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
setMecabEnabled: () => {},
handleMpvCommand: () => {},
getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(),
@@ -850,6 +864,79 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
]);
});
test('registerIpcHandlers validates dispatchSessionAction payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const dispatched: SessionActionDispatchRequest[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
dispatchSessionAction: async (request) => {
dispatched.push(request);
},
}),
registrar,
);
const dispatchHandler = handlers.handle.get(IPC_CHANNELS.command.dispatchSessionAction);
assert.ok(dispatchHandler);
await assert.rejects(async () => {
await dispatchHandler!({}, { actionId: 'cycleRuntimeOption', payload: { direction: 1 } });
}, /Invalid session action payload/);
await assert.rejects(async () => {
await dispatchHandler!({}, { actionId: 'unknown-action' });
}, /Invalid session action payload/);
await dispatchHandler!({}, {
actionId: 'copySubtitleMultiple',
payload: { count: 3 },
});
await dispatchHandler!({}, {
actionId: 'cycleRuntimeOption',
payload: {
runtimeOptionId: 'anki.autoUpdateNewCards',
direction: -1,
},
});
await dispatchHandler!({}, {
actionId: 'toggleSubtitleSidebar',
});
await dispatchHandler!({}, {
actionId: 'openSessionHelp',
});
await dispatchHandler!({}, {
actionId: 'openControllerSelect',
});
await dispatchHandler!({}, {
actionId: 'openControllerDebug',
});
assert.deepEqual(dispatched, [
{
actionId: 'copySubtitleMultiple',
payload: { count: 3 },
},
{
actionId: 'cycleRuntimeOption',
payload: {
runtimeOptionId: 'anki.autoUpdateNewCards',
direction: -1,
},
},
{
actionId: 'toggleSubtitleSidebar',
},
{
actionId: 'openSessionHelp',
},
{
actionId: 'openControllerSelect',
},
{
actionId: 'openControllerDebug',
},
]);
});
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
registerIpcHandlers(
@@ -872,7 +959,9 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
setMecabEnabled: () => {},
handleMpvCommand: () => {},
getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(),

View File

@@ -1,6 +1,7 @@
import electron from 'electron';
import type { IpcMainEvent } from 'electron';
import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
import type {
CompiledSessionBinding,
ControllerConfigUpdate,
PlaylistBrowserMutationResult,
PlaylistBrowserSnapshot,
@@ -12,6 +13,7 @@ import type {
SubtitlePosition,
SubsyncManualRunRequest,
SubsyncResult,
SessionActionDispatchRequest,
YoutubePickerResolveRequest,
YoutubePickerResolveResult,
} from '../../types';
@@ -25,16 +27,23 @@ import {
parseRuntimeOptionDirection,
parseRuntimeOptionId,
parseRuntimeOptionValue,
parseSessionActionDispatchRequest,
parseSubtitlePosition,
parseSubsyncManualRunRequest,
parseYoutubePickerResolveRequest,
} from '../../shared/ipc/validators';
const { BrowserWindow, ipcMain } = electron;
const { ipcMain } = electron;
export interface IpcServiceDeps {
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
onOverlayModalOpened?: (modal: OverlayHostedModal) => void;
onOverlayModalClosed: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayModalOpened?: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleDevTools: () => void;
@@ -56,7 +65,9 @@ export interface IpcServiceDeps {
setMecabEnabled: (enabled: boolean) => void;
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getSessionBindings?: () => CompiledSessionBinding[];
getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string;
getMarkWatchedKey: () => string;
getControllerConfig: () => ResolvedControllerConfig;
@@ -153,8 +164,14 @@ interface IpcMainRegistrar {
export interface IpcDepsRuntimeOptions {
getMainWindow: () => WindowLike | null;
getVisibleOverlayVisibility: () => boolean;
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
onOverlayModalOpened?: (modal: OverlayHostedModal) => void;
onOverlayModalClosed: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayModalOpened?: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleVisibleOverlay: () => void;
@@ -169,7 +186,9 @@ export interface IpcDepsRuntimeOptions {
getMecabTokenizer: () => MecabTokenizerLike | null;
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getSessionBindings?: () => CompiledSessionBinding[];
getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string;
getMarkWatchedKey: () => string;
getControllerConfig: () => ResolvedControllerConfig;
@@ -238,7 +257,9 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
},
handleMpvCommand: options.handleMpvCommand,
getKeybindings: options.getKeybindings,
getSessionBindings: options.getSessionBindings ?? (() => []),
getConfiguredShortcuts: options.getConfiguredShortcuts,
dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}),
getStatsToggleKey: options.getStatsToggleKey,
getMarkWatchedKey: options.getMarkWatchedKey,
getControllerConfig: options.getControllerConfig,
@@ -299,23 +320,28 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
(event: unknown, ignore: unknown, options: unknown = {}) => {
if (typeof ignore !== 'boolean') return;
const parsedOptions = parseOptionalForwardingOptions(options);
const senderWindow = BrowserWindow.fromWebContents((event as IpcMainEvent).sender);
const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
}
},
);
ipc.on(IPC_CHANNELS.command.overlayModalClosed, (_event: unknown, modal: unknown) => {
ipc.on(IPC_CHANNELS.command.overlayModalClosed, (event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return;
deps.onOverlayModalClosed(parsedModal);
const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayModalClosed(parsedModal, senderWindow);
});
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (_event: unknown, modal: unknown) => {
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return;
if (!deps.onOverlayModalOpened) return;
deps.onOverlayModalOpened(parsedModal);
const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayModalOpened(parsedModal, senderWindow);
});
ipc.handle(
@@ -431,10 +457,25 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.handleMpvCommand(parsedCommand);
});
ipc.handle(
IPC_CHANNELS.command.dispatchSessionAction,
async (_event: unknown, request: unknown) => {
const parsedRequest = parseSessionActionDispatchRequest(request);
if (!parsedRequest) {
throw new Error('Invalid session action payload');
}
await deps.dispatchSessionAction?.(parsedRequest);
},
);
ipc.handle(IPC_CHANNELS.request.getKeybindings, () => {
return deps.getKeybindings();
});
ipc.handle(IPC_CHANNELS.request.getSessionBindings, () => {
return deps.getSessionBindings?.() ?? [];
});
ipc.handle(IPC_CHANNELS.request.getConfigShortcuts, () => {
return deps.getConfiguredShortcuts();
});

View File

@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildMpvLoadfileCommands,
buildMpvSubtitleAddCommands,
collectDroppedSubtitlePaths,
collectDroppedVideoPaths,
parseClipboardVideoPath,
type DropDataTransferLike,
@@ -41,6 +43,33 @@ test('collectDroppedVideoPaths parses text/uri-list entries and de-duplicates',
assert.deepEqual(result, ['/tmp/ep01.mkv', '/tmp/ep02.webm']);
});
test('collectDroppedSubtitlePaths keeps supported dropped subtitle paths in order', () => {
const transfer = makeTransfer({
files: [
{ path: '/subs/ep02.ass' },
{ path: '/subs/readme.txt' },
{ path: '/subs/ep03.SRT' },
],
});
const result = collectDroppedSubtitlePaths(transfer);
assert.deepEqual(result, ['/subs/ep02.ass', '/subs/ep03.SRT']);
});
test('collectDroppedSubtitlePaths parses text/uri-list entries and de-duplicates', () => {
const transfer = makeTransfer({
getData: (format: string) =>
format === 'text/uri-list'
? '#comment\nfile:///tmp/ep01.ass\nfile:///tmp/ep01.ass\nfile:///tmp/ep02.vtt\nfile:///tmp/readme.md\n'
: '',
});
const result = collectDroppedSubtitlePaths(transfer);
assert.deepEqual(result, ['/tmp/ep01.ass', '/tmp/ep02.vtt']);
});
test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => {
const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false);
@@ -59,6 +88,15 @@ test('buildMpvLoadfileCommands uses append mode when shift-drop is used', () =>
]);
});
test('buildMpvSubtitleAddCommands selects first subtitle and adds remainder', () => {
const commands = buildMpvSubtitleAddCommands(['/tmp/ep01.ass', '/tmp/ep02.srt']);
assert.deepEqual(commands, [
['sub-add', '/tmp/ep01.ass', 'select'],
['sub-add', '/tmp/ep02.srt'],
]);
});
test('parseClipboardVideoPath accepts quoted local paths', () => {
assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv');
});

View File

@@ -22,6 +22,8 @@ const VIDEO_EXTENSIONS = new Set([
'.wmv',
]);
const SUBTITLE_EXTENSIONS = new Set(['.ass', '.srt', '.ssa', '.sub', '.vtt']);
function getPathExtension(pathValue: string): string {
const normalized = pathValue.split(/[?#]/, 1)[0] ?? '';
const dot = normalized.lastIndexOf('.');
@@ -32,7 +34,11 @@ function isSupportedVideoPath(pathValue: string): boolean {
return VIDEO_EXTENSIONS.has(getPathExtension(pathValue));
}
function parseUriList(data: string): string[] {
function isSupportedSubtitlePath(pathValue: string): boolean {
return SUBTITLE_EXTENSIONS.has(getPathExtension(pathValue));
}
function parseUriList(data: string, isSupportedPath: (pathValue: string) => boolean): string[] {
if (!data.trim()) return [];
const out: string[] = [];
@@ -47,7 +53,7 @@ function parseUriList(data: string): string[] {
if (/^\/[A-Za-z]:\//.test(filePath)) {
filePath = filePath.slice(1);
}
if (filePath && isSupportedVideoPath(filePath)) {
if (filePath && isSupportedPath(filePath)) {
out.push(filePath);
}
} catch {
@@ -87,6 +93,19 @@ export function parseClipboardVideoPath(text: string): string | null {
export function collectDroppedVideoPaths(
dataTransfer: DropDataTransferLike | null | undefined,
): string[] {
return collectDroppedPaths(dataTransfer, isSupportedVideoPath);
}
export function collectDroppedSubtitlePaths(
dataTransfer: DropDataTransferLike | null | undefined,
): string[] {
return collectDroppedPaths(dataTransfer, isSupportedSubtitlePath);
}
function collectDroppedPaths(
dataTransfer: DropDataTransferLike | null | undefined,
isSupportedPath: (pathValue: string) => boolean,
): string[] {
if (!dataTransfer) return [];
@@ -96,7 +115,7 @@ export function collectDroppedVideoPaths(
const addPath = (candidate: string | null | undefined): void => {
if (!candidate) return;
const trimmed = candidate.trim();
if (!trimmed || !isSupportedVideoPath(trimmed) || seen.has(trimmed)) return;
if (!trimmed || !isSupportedPath(trimmed) || seen.has(trimmed)) return;
seen.add(trimmed);
out.push(trimmed);
};
@@ -109,7 +128,7 @@ export function collectDroppedVideoPaths(
}
if (typeof dataTransfer.getData === 'function') {
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'))) {
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'), isSupportedPath)) {
addPath(pathValue);
}
}
@@ -130,3 +149,9 @@ export function buildMpvLoadfileCommands(
index === 0 ? 'replace' : 'append',
]);
}
export function buildMpvSubtitleAddCommands(paths: string[]): Array<(string | number)[]> {
return paths.map((pathValue, index) =>
index === 0 ? ['sub-add', pathValue, 'select'] : ['sub-add', pathValue],
);
}

View File

@@ -443,3 +443,214 @@ test('initializeOverlayRuntime refreshes visible overlay when tracker focus chan
assert.equal(visibilityRefreshCalls, 2);
});
test('initializeOverlayRuntime refreshes the current subtitle when tracker finds the target window again', () => {
let subtitleRefreshCalls = 0;
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
onWindowFound: null as ((...args: unknown[]) => void) | null,
onWindowLost: null as (() => void) | null,
onWindowFocusChange: null as ((focused: boolean) => void) | null,
start: () => {},
};
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => true,
updateVisibleOverlayVisibility: () => {},
refreshCurrentSubtitle: () => {
subtitleRefreshCalls += 1;
},
getOverlayWindows: () => [],
syncOverlayShortcuts: () => {},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => tracker as never,
getResolvedConfig: () => ({
ankiConnect: { enabled: false } as never,
}),
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => {},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
tracker.onWindowFound?.({ x: 100, y: 200, width: 1280, height: 720 });
assert.equal(subtitleRefreshCalls, 1);
});
test('initializeOverlayRuntime hides overlay windows when tracker loses the target window', () => {
const calls: string[] = [];
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
onWindowFound: null as ((...args: unknown[]) => void) | null,
onWindowLost: null as (() => void) | null,
onWindowFocusChange: null as ((focused: boolean) => void) | null,
isTargetWindowMinimized: () => true,
start: () => {},
};
const overlayWindows = [
{
hide: () => calls.push('hide-visible'),
},
{
hide: () => calls.push('hide-modal'),
},
];
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => true,
updateVisibleOverlayVisibility: () => {},
refreshCurrentSubtitle: () => {},
getOverlayWindows: () => overlayWindows as never,
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => tracker as never,
getResolvedConfig: () => ({
ankiConnect: { enabled: false } as never,
}),
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => {},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
tracker.onWindowLost?.();
assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']);
});
test('initializeOverlayRuntime hides visible overlay on Windows tracker loss when target is not minimized', () => {
const calls: string[] = [];
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
onWindowFound: null as ((...args: unknown[]) => void) | null,
onWindowLost: null as (() => void) | null,
onWindowFocusChange: null as ((focused: boolean) => void) | null,
isTargetWindowMinimized: () => false,
start: () => {},
};
const overlayWindows = [
{
hide: () => calls.push('hide-visible'),
},
];
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => true,
updateVisibleOverlayVisibility: () => {
calls.push('update-visible');
},
refreshCurrentSubtitle: () => {},
getOverlayWindows: () => overlayWindows as never,
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => tracker as never,
getResolvedConfig: () => ({
ankiConnect: { enabled: false } as never,
}),
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => {},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
calls.length = 0;
tracker.onWindowLost?.();
assert.deepEqual(calls, ['hide-visible', 'sync-shortcuts']);
});
test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => {
const bounds: Array<{ x: number; y: number; width: number; height: number }> = [];
let visibilityRefreshCalls = 0;
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
onWindowFound: null as ((...args: unknown[]) => void) | null,
onWindowLost: null as (() => void) | null,
onWindowFocusChange: null as ((focused: boolean) => void) | null,
start: () => {},
};
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: (geometry) => {
bounds.push(geometry);
},
isVisibleOverlayVisible: () => true,
updateVisibleOverlayVisibility: () => {
visibilityRefreshCalls += 1;
},
refreshCurrentSubtitle: () => {},
getOverlayWindows: () => [],
syncOverlayShortcuts: () => {},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => tracker as never,
getResolvedConfig: () => ({
ankiConnect: { enabled: false } as never,
}),
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => {},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
const restoredGeometry = { x: 100, y: 200, width: 1280, height: 720 };
tracker.onWindowFound?.(restoredGeometry);
assert.deepEqual(bounds, [restoredGeometry]);
assert.equal(visibilityRefreshCalls, 2);
});

View File

@@ -71,6 +71,7 @@ export function initializeOverlayRuntime(options: {
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void;
refreshCurrentSubtitle?: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
@@ -78,6 +79,8 @@ export function initializeOverlayRuntime(options: {
override?: string | null,
targetMpvSocketPath?: string | null,
) => BaseWindowTracker | null;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
}): void {
options.createMainWindow();
options.registerGlobalShortcuts();
@@ -94,11 +97,14 @@ export function initializeOverlayRuntime(options: {
};
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
options.updateVisibleOverlayBounds(geometry);
options.bindOverlayOwner?.();
if (options.isVisibleOverlayVisible()) {
options.updateVisibleOverlayVisibility();
options.refreshCurrentSubtitle?.();
}
};
windowTracker.onWindowLost = () => {
options.releaseOverlayOwner?.();
for (const window of options.getOverlayWindows()) {
window.hide();
}

View File

@@ -6,7 +6,11 @@ import {
OverlayShortcutRuntimeDeps,
runOverlayShortcutLocalFallback,
} from './overlay-shortcut-handler';
import { shouldActivateOverlayShortcuts } from './overlay-shortcut';
import {
registerOverlayShortcutsRuntime,
shouldActivateOverlayShortcuts,
unregisterOverlayShortcutsRuntime,
} from './overlay-shortcut';
function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return {
@@ -23,6 +27,10 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
...overrides,
};
}
@@ -313,3 +321,59 @@ test('shouldActivateOverlayShortcuts preserves non-macOS behavior', () => {
true,
);
});
test('registerOverlayShortcutsRuntime reports active shortcuts when configured', () => {
const deps = {
getConfiguredShortcuts: () => makeShortcuts({ openJimaku: 'Ctrl+J' }),
getOverlayHandlers: () => ({
copySubtitle: () => {},
copySubtitleMultiple: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
cancelPendingMultiCopy: () => {},
cancelPendingMineSentenceMultiple: () => {},
};
const result = registerOverlayShortcutsRuntime(deps);
assert.equal(result, true);
assert.equal(unregisterOverlayShortcutsRuntime(result, deps), false);
});
test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active', () => {
const calls: string[] = [];
const deps = {
getConfiguredShortcuts: () => makeShortcuts({ openJimaku: 'Ctrl+J' }),
getOverlayHandlers: () => ({
copySubtitle: () => {},
copySubtitleMultiple: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
cancelPendingMultiCopy: () => {
calls.push('cancel-multi-copy');
},
cancelPendingMineSentenceMultiple: () => {
calls.push('cancel-mine-sentence-multiple');
},
};
assert.equal(registerOverlayShortcutsRuntime(deps), true);
const result = unregisterOverlayShortcutsRuntime(true, deps);
assert.equal(result, false);
assert.deepEqual(calls, ['cancel-multi-copy', 'cancel-mine-sentence-multiple']);
});

View File

@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
import {
registerOverlayShortcuts,
syncOverlayShortcutsRuntime,
unregisterOverlayShortcutsRuntime,
} from './overlay-shortcut';
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: null,
copySubtitle: null,
copySubtitleMultiple: null,
updateLastCardFromClipboard: null,
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: null,
mineSentenceMultiple: null,
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
...overrides,
};
}
test('registerOverlayShortcuts reports active overlay shortcuts when configured', () => {
assert.equal(
registerOverlayShortcuts(createShortcuts({ openJimaku: 'Ctrl+J' }), {
copySubtitle: () => {},
copySubtitleMultiple: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
true,
);
});
test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent', () => {
assert.equal(
registerOverlayShortcuts(createShortcuts(), {
copySubtitle: () => {},
copySubtitleMultiple: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
false,
);
});
test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active', () => {
const calls: string[] = [];
const result = syncOverlayShortcutsRuntime(false, true, {
getConfiguredShortcuts: () => createShortcuts(),
getOverlayHandlers: () => ({
copySubtitle: () => {},
copySubtitleMultiple: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
cancelPendingMultiCopy: () => {
calls.push('cancel-multi-copy');
},
cancelPendingMineSentenceMultiple: () => {
calls.push('cancel-mine-sentence-multiple');
},
});
assert.equal(result, false);
assert.deepEqual(calls, ['cancel-multi-copy', 'cancel-mine-sentence-multiple']);
});

View File

@@ -1,10 +1,4 @@
import electron from 'electron';
import { ConfiguredShortcuts } from '../utils/shortcut-config';
import { isGlobalShortcutRegisteredSafe } from './shortcut-fallback';
import { createLogger } from '../../logger';
const { globalShortcut } = electron;
const logger = createLogger('main:overlay-shortcut-service');
export interface OverlayShortcutHandlers {
copySubtitle: () => void;
@@ -27,6 +21,27 @@ export interface OverlayShortcutLifecycleDeps {
cancelPendingMineSentenceMultiple: () => void;
}
const OVERLAY_SHORTCUT_KEYS: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTimeoutMs'>> = [
'copySubtitle',
'copySubtitleMultiple',
'updateLastCardFromClipboard',
'triggerFieldGrouping',
'triggerSubsync',
'mineSentence',
'mineSentenceMultiple',
'toggleSecondarySub',
'markAudioCard',
'openRuntimeOptions',
'openJimaku',
];
function hasConfiguredOverlayShortcuts(shortcuts: ConfiguredShortcuts): boolean {
return OVERLAY_SHORTCUT_KEYS.some((key) => {
const shortcut = shortcuts[key];
return typeof shortcut === 'string' && shortcut.trim().length > 0;
});
}
export function shouldActivateOverlayShortcuts(args: {
overlayRuntimeInitialized: boolean;
isMacOSPlatform: boolean;
@@ -43,139 +58,12 @@ export function shouldActivateOverlayShortcuts(args: {
export function registerOverlayShortcuts(
shortcuts: ConfiguredShortcuts,
handlers: OverlayShortcutHandlers,
_handlers: OverlayShortcutHandlers,
): boolean {
let registeredAny = false;
const registerOverlayShortcut = (
accelerator: string,
handler: () => void,
label: string,
): void => {
if (isGlobalShortcutRegisteredSafe(accelerator)) {
registeredAny = true;
return;
}
const ok = globalShortcut.register(accelerator, handler);
if (!ok) {
logger.warn(`Failed to register overlay shortcut ${label}: ${accelerator}`);
return;
}
registeredAny = true;
};
if (shortcuts.copySubtitleMultiple) {
registerOverlayShortcut(
shortcuts.copySubtitleMultiple,
() => handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs),
'copySubtitleMultiple',
);
}
if (shortcuts.copySubtitle) {
registerOverlayShortcut(shortcuts.copySubtitle, () => handlers.copySubtitle(), 'copySubtitle');
}
if (shortcuts.triggerFieldGrouping) {
registerOverlayShortcut(
shortcuts.triggerFieldGrouping,
() => handlers.triggerFieldGrouping(),
'triggerFieldGrouping',
);
}
if (shortcuts.triggerSubsync) {
registerOverlayShortcut(
shortcuts.triggerSubsync,
() => handlers.triggerSubsync(),
'triggerSubsync',
);
}
if (shortcuts.mineSentence) {
registerOverlayShortcut(shortcuts.mineSentence, () => handlers.mineSentence(), 'mineSentence');
}
if (shortcuts.mineSentenceMultiple) {
registerOverlayShortcut(
shortcuts.mineSentenceMultiple,
() => handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs),
'mineSentenceMultiple',
);
}
if (shortcuts.toggleSecondarySub) {
registerOverlayShortcut(
shortcuts.toggleSecondarySub,
() => handlers.toggleSecondarySub(),
'toggleSecondarySub',
);
}
if (shortcuts.updateLastCardFromClipboard) {
registerOverlayShortcut(
shortcuts.updateLastCardFromClipboard,
() => handlers.updateLastCardFromClipboard(),
'updateLastCardFromClipboard',
);
}
if (shortcuts.markAudioCard) {
registerOverlayShortcut(
shortcuts.markAudioCard,
() => handlers.markAudioCard(),
'markAudioCard',
);
}
if (shortcuts.openRuntimeOptions) {
registerOverlayShortcut(
shortcuts.openRuntimeOptions,
() => handlers.openRuntimeOptions(),
'openRuntimeOptions',
);
}
if (shortcuts.openJimaku) {
registerOverlayShortcut(shortcuts.openJimaku, () => handlers.openJimaku(), 'openJimaku');
}
return registeredAny;
return hasConfiguredOverlayShortcuts(shortcuts);
}
export function unregisterOverlayShortcuts(shortcuts: ConfiguredShortcuts): void {
if (shortcuts.copySubtitle) {
globalShortcut.unregister(shortcuts.copySubtitle);
}
if (shortcuts.copySubtitleMultiple) {
globalShortcut.unregister(shortcuts.copySubtitleMultiple);
}
if (shortcuts.updateLastCardFromClipboard) {
globalShortcut.unregister(shortcuts.updateLastCardFromClipboard);
}
if (shortcuts.triggerFieldGrouping) {
globalShortcut.unregister(shortcuts.triggerFieldGrouping);
}
if (shortcuts.triggerSubsync) {
globalShortcut.unregister(shortcuts.triggerSubsync);
}
if (shortcuts.mineSentence) {
globalShortcut.unregister(shortcuts.mineSentence);
}
if (shortcuts.mineSentenceMultiple) {
globalShortcut.unregister(shortcuts.mineSentenceMultiple);
}
if (shortcuts.toggleSecondarySub) {
globalShortcut.unregister(shortcuts.toggleSecondarySub);
}
if (shortcuts.markAudioCard) {
globalShortcut.unregister(shortcuts.markAudioCard);
}
if (shortcuts.openRuntimeOptions) {
globalShortcut.unregister(shortcuts.openRuntimeOptions);
}
if (shortcuts.openJimaku) {
globalShortcut.unregister(shortcuts.openJimaku);
}
}
export function unregisterOverlayShortcuts(_shortcuts: ConfiguredShortcuts): void {}
export function registerOverlayShortcutsRuntime(deps: OverlayShortcutLifecycleDeps): boolean {
return registerOverlayShortcuts(deps.getConfiguredShortcuts(), deps.getOverlayHandlers());

View File

@@ -1,32 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
import { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
type WindowTrackerStub = {
isTracking: () => boolean;
getGeometry: () => { x: number; y: number; width: number; height: number } | null;
isTargetWindowFocused?: () => boolean;
isTargetWindowMinimized?: () => boolean;
};
function createMainWindowRecorder() {
const calls: string[] = [];
let visible = false;
let focused = false;
let opacity = 1;
let contentReady = true;
const window = {
webContents: {},
isDestroyed: () => false,
isVisible: () => visible,
isFocused: () => focused,
hide: () => {
visible = false;
focused = false;
calls.push('hide');
},
show: () => {
visible = true;
calls.push('show');
},
showInactive: () => {
visible = true;
calls.push('show-inactive');
},
focus: () => {
focused = true;
calls.push('focus');
},
setAlwaysOnTop: (flag: boolean) => {
calls.push(`always-on-top:${flag}`);
},
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
},
setOpacity: (nextOpacity: number) => {
opacity = nextOpacity;
calls.push(`opacity:${nextOpacity}`);
},
moveTop: () => {
calls.push('move-top');
},
};
(
window as {
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
}
)[OVERLAY_WINDOW_CONTENT_READY_FLAG] = contentReady;
return { window, calls };
return {
window,
calls,
getOpacity: () => opacity,
setContentReady: (nextContentReady: boolean) => {
contentReady = nextContentReady;
(
window as {
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
}
)[OVERLAY_WINDOW_CONTENT_READY_FLAG] = contentReady;
},
setFocused: (nextFocused: boolean) => {
focused = nextFocused;
},
};
}
test('macOS keeps visible overlay hidden while tracker is not ready and emits one loading OSD', () => {
@@ -163,7 +211,334 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke
assert.ok(!calls.includes('osd'));
});
test('Windows visible overlay stays click-through and does not steal focus while tracked', () => {
test('Windows visible overlay stays click-through and binds to mpv while tracked', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('opacity:0'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show-inactive'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('move-top'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('focus'));
});
test('Windows visible overlay restores opacity after the deferred reveal delay', async () => {
const { window, calls, getOpacity } = createMainWindowRecorder();
let syncWindowsZOrderCalls = 0;
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
syncWindowsZOrderCalls += 1;
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.equal(getOpacity(), 0);
assert.equal(syncWindowsZOrderCalls, 1);
await new Promise<void>((resolve) => setTimeout(resolve, 60));
assert.equal(getOpacity(), 1);
assert.equal(syncWindowsZOrderCalls, 2);
assert.ok(calls.includes('opacity:1'));
});
test('Windows visible overlay waits for content-ready before first reveal', () => {
const { window, calls, setContentReady } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
setContentReady(false);
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
run();
assert.ok(!calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
setContentReady(true);
run();
assert.ok(calls.includes('show-inactive'));
});
test('tracked Windows overlay refresh rebinds while already visible', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('move-top'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(calls.includes('sync-shortcuts'));
});
test('forced passthrough still reapplies while visible on Windows', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
test('forced passthrough still shows tracked overlay while bound to mpv on Windows', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('sync-windows-z-order'));
});
test('forced mouse passthrough drops macOS tracked overlay below higher-priority windows', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
@@ -191,13 +566,283 @@ test('Windows visible overlay stays click-through and does not steal focus while
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
test('tracked Windows overlay rebinds without hiding when tracker focus changes', () => {
const { window, calls } = createMainWindowRecorder();
let focused = true;
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => focused,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
focused = false;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('show'));
});
test('tracked Windows overlay stays interactive while the overlay window itself is focused', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
setFocused(true);
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('move-top'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
test('tracked Windows overlay reshows click-through even if focus state is stale after a modal closes', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
window.hide();
calls.length = 0;
setFocused(true);
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show'));
assert.ok(!calls.includes('focus'));
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
});
test('tracked Windows overlay binds above mpv even when tracker focus lags', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('ensure-level'));
});
test('visible overlay stays hidden while a modal window is active', () => {
@@ -355,6 +1000,157 @@ test('Windows keeps visible overlay hidden while tracker is not ready', () => {
assert.ok(!calls.includes('update-bounds'));
});
test('Windows preserves visible overlay and rebinds to mpv while tracker transiently loses a non-minimized window', () => {
const { window, calls } = createMainWindowRecorder();
let tracking = true;
const tracker: WindowTrackerStub = {
isTracking: () => tracking,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => false,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
tracking = false;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(!calls.includes('hide'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(calls.includes('sync-shortcuts'));
});
test('Windows hides the visible overlay when the tracked window is minimized', () => {
const { window, calls } = createMainWindowRecorder();
let tracking = true;
const tracker: WindowTrackerStub = {
isTracking: () => tracking,
getGeometry: () => (tracking ? { x: 0, y: 0, width: 1280, height: 720 } : null),
isTargetWindowMinimized: () => !tracking,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
tracking = false;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('sync-windows-z-order'));
});
test('macOS keeps visible overlay hidden while tracker is not initialized yet', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;

View File

@@ -1,6 +1,52 @@
import type { BrowserWindow } from 'electron';
import { BaseWindowTracker } from '../../window-trackers';
import { WindowGeometry } from '../../types';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap<
BrowserWindow,
ReturnType<typeof setTimeout>
>();
function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
const opacityCapableWindow = window as BrowserWindow & {
setOpacity?: (opacity: number) => void;
};
opacityCapableWindow.setOpacity?.(opacity);
}
function clearPendingWindowsOverlayReveal(window: BrowserWindow): void {
const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window);
if (!pendingTimeout) {
return;
}
clearTimeout(pendingTimeout);
pendingWindowsOverlayRevealTimeoutByWindow.delete(window);
}
function scheduleWindowsOverlayReveal(
window: BrowserWindow,
onReveal?: (window: BrowserWindow) => void,
): void {
clearPendingWindowsOverlayReveal(window);
const timeout = setTimeout(() => {
pendingWindowsOverlayRevealTimeoutByWindow.delete(window);
if (window.isDestroyed() || !window.isVisible()) {
return;
}
setOverlayWindowOpacity(window, 1);
onReveal?.(window);
}, WINDOWS_OVERLAY_REVEAL_DELAY_MS);
pendingWindowsOverlayRevealTimeoutByWindow.set(window, timeout);
}
function isOverlayWindowContentReady(window: BrowserWindow): boolean {
return (
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] === true
);
}
export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean;
@@ -8,10 +54,14 @@ export function updateVisibleOverlayVisibility(args: {
forceMousePassthrough?: boolean;
mainWindow: BrowserWindow | null;
windowTracker: BaseWindowTracker | null;
lastKnownWindowsForegroundProcessName?: string | null;
windowsOverlayProcessName?: string | null;
windowsFocusHandoffGraceActive?: boolean;
trackerNotReadyWarningShown: boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
syncWindowsOverlayToMpvZOrder?: (window: BrowserWindow) => void;
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void;
@@ -30,6 +80,10 @@ export function updateVisibleOverlayVisibility(args: {
const mainWindow = args.mainWindow;
if (args.modalActive) {
if (args.isWindowsPlatform) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.hide();
args.syncOverlayShortcuts();
return;
@@ -37,13 +91,93 @@ export function updateVisibleOverlayVisibility(args: {
const showPassiveVisibleOverlay = (): void => {
const forceMousePassthrough = args.forceMousePassthrough === true;
if (args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough) {
const wasVisible = mainWindow.isVisible();
const shouldDefaultToPassthrough =
args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough;
const isVisibleOverlayFocused =
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
const windowsForegroundProcessName =
args.lastKnownWindowsForegroundProcessName?.trim().toLowerCase() ?? null;
const windowsOverlayProcessName = args.windowsOverlayProcessName?.trim().toLowerCase() ?? null;
const hasWindowsForegroundProcessSignal =
args.isWindowsPlatform && windowsForegroundProcessName !== null;
const isTrackedWindowsTargetFocused = args.windowTracker?.isTargetWindowFocused?.() ?? true;
const isTrackedWindowsTargetMinimized =
args.isWindowsPlatform &&
typeof args.windowTracker?.isTargetWindowMinimized === 'function' &&
args.windowTracker.isTargetWindowMinimized();
const shouldPreserveWindowsOverlayDuringFocusHandoff =
args.isWindowsPlatform &&
args.windowsFocusHandoffGraceActive === true &&
!!args.windowTracker &&
(!hasWindowsForegroundProcessSignal ||
windowsForegroundProcessName === 'mpv' ||
(windowsOverlayProcessName !== null &&
windowsForegroundProcessName === windowsOverlayProcessName)) &&
!isTrackedWindowsTargetMinimized &&
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
const shouldIgnoreMouseEvents =
forceMousePassthrough ||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
const shouldKeepTrackedWindowsOverlayTopmost =
!args.isWindowsPlatform ||
!args.windowTracker ||
isVisibleOverlayFocused ||
isTrackedWindowsTargetFocused ||
shouldPreserveWindowsOverlayDuringFocusHandoff ||
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
if (shouldIgnoreMouseEvents) {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
} else {
mainWindow.setIgnoreMouseEvents(false);
}
args.ensureOverlayWindowLevel(mainWindow);
mainWindow.show();
if (shouldBindTrackedWindowsOverlay) {
// On Windows, z-order is enforced by the OS via the owner window mechanism
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
// without any manual z-order management.
} else if (!forceMousePassthrough) {
args.ensureOverlayWindowLevel(mainWindow);
} else {
mainWindow.setAlwaysOnTop(false);
}
if (!wasVisible) {
const hasWebContents =
typeof (mainWindow as unknown as { webContents?: unknown }).webContents === 'object';
if (
args.isWindowsPlatform &&
hasWebContents &&
!isOverlayWindowContentReady(mainWindow as unknown as import('electron').BrowserWindow)
) {
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
// callback will trigger another visibility update when the renderer
// has painted its first frame.
} else if (args.isWindowsPlatform && shouldIgnoreMouseEvents) {
setOverlayWindowOpacity(mainWindow, 0);
mainWindow.showInactive();
mainWindow.setIgnoreMouseEvents(true, { forward: true });
scheduleWindowsOverlayReveal(mainWindow, shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined);
} else {
if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.show();
if (args.isWindowsPlatform) {
scheduleWindowsOverlayReveal(mainWindow, shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined);
}
}
}
if (shouldBindTrackedWindowsOverlay) {
args.syncWindowsOverlayToMpvZOrder?.(mainWindow);
}
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
mainWindow.focus();
}
@@ -63,12 +197,27 @@ export function updateVisibleOverlayVisibility(args: {
if (!args.visibleOverlayVisible) {
args.setTrackerNotReadyWarningShown(false);
args.resetOverlayLoadingOsdSuppression?.();
if (args.isWindowsPlatform) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
if (args.windowTracker && args.windowTracker.isTracking()) {
if (
args.isWindowsPlatform &&
typeof args.windowTracker.isTargetWindowMinimized === 'function' &&
args.windowTracker.isTargetWindowMinimized()
) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
args.setTrackerNotReadyWarningShown(false);
const geometry = args.windowTracker.getGeometry();
if (geometry) {
@@ -76,7 +225,9 @@ export function updateVisibleOverlayVisibility(args: {
}
args.syncPrimaryOverlayWindowLayer('visible');
showPassiveVisibleOverlay();
args.enforceOverlayLayerOrder();
if (!args.forceMousePassthrough && !args.isWindowsPlatform) {
args.enforceOverlayLayerOrder();
}
args.syncOverlayShortcuts();
return;
}
@@ -87,6 +238,10 @@ export function updateVisibleOverlayVisibility(args: {
args.setTrackerNotReadyWarningShown(true);
maybeShowOverlayLoadingOsd();
}
if (args.isWindowsPlatform) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.hide();
args.syncOverlayShortcuts();
return;
@@ -99,11 +254,32 @@ export function updateVisibleOverlayVisibility(args: {
return;
}
if (
args.isWindowsPlatform &&
typeof args.windowTracker.isTargetWindowMinimized === 'function' &&
!args.windowTracker.isTargetWindowMinimized() &&
(mainWindow.isVisible() || args.windowTracker.getGeometry() !== null)
) {
args.setTrackerNotReadyWarningShown(false);
const geometry = args.windowTracker.getGeometry();
if (geometry) {
args.updateVisibleOverlayBounds(geometry);
}
args.syncPrimaryOverlayWindowLayer('visible');
showPassiveVisibleOverlay();
args.syncOverlayShortcuts();
return;
}
if (!args.trackerNotReadyWarningShown) {
args.setTrackerNotReadyWarningShown(true);
maybeShowOverlayLoadingOsd();
}
if (args.isWindowsPlatform) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.hide();
args.syncOverlayShortcuts();
}

View File

@@ -8,7 +8,31 @@ test('overlay window config explicitly disables renderer sandbox for preload com
yomitanSession: null,
});
assert.equal(options.backgroundColor, '#00000000');
assert.equal(options.webPreferences?.sandbox, false);
assert.equal(options.webPreferences?.backgroundThrottling, false);
});
test('Windows visible overlay window config does not start as always-on-top', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32',
});
try {
const options = buildOverlayWindowOptions('visible', {
isDev: false,
yomitanSession: null,
});
assert.equal(options.alwaysOnTop, false);
} finally {
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('overlay window config uses the provided Yomitan session when available', () => {

View File

@@ -0,0 +1 @@
export const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';

View File

@@ -66,7 +66,14 @@ export function handleOverlayWindowBlurred(options: {
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
ensureOverlayWindowLevel: () => void;
moveWindowTop: () => void;
onWindowsVisibleOverlayBlur?: () => void;
platform?: NodeJS.Platform;
}): boolean {
if ((options.platform ?? process.platform) === 'win32' && options.kind === 'visible') {
options.onWindowsVisibleOverlayBlur?.();
return false;
}
if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) {
return false;
}

View File

@@ -10,6 +10,7 @@ export function buildOverlayWindowOptions(
},
): BrowserWindowConstructorOptions {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible');
return {
show: false,
@@ -18,8 +19,9 @@ export function buildOverlayWindowOptions(
x: 0,
y: 0,
transparent: true,
backgroundColor: '#00000000',
frame: false,
alwaysOnTop: true,
alwaysOnTop: shouldStartAlwaysOnTop,
skipTaskbar: true,
resizable: false,
hasShadow: false,
@@ -31,6 +33,7 @@ export function buildOverlayWindowOptions(
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
backgroundThrottling: false,
webSecurity: true,
session: options.yomitanSession ?? undefined,
additionalArguments: [`--overlay-layer=${kind}`],

View File

@@ -103,6 +103,49 @@ test('handleOverlayWindowBlurred skips visible overlay restacking after manual h
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred skips Windows visible overlay restacking after focus loss', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => true,
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
moveWindowTop: () => {
calls.push('move-top');
},
platform: 'win32',
});
assert.equal(handled, false);
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback without restacking', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => true,
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
moveWindowTop: () => {
calls.push('move-top');
},
onWindowsVisibleOverlayBlur: () => {
calls.push('windows-visible-blur');
},
platform: 'win32',
});
assert.equal(handled, false);
assert.deepEqual(calls, ['windows-visible-blur']);
});
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
const calls: string[] = [];
@@ -117,6 +160,7 @@ test('handleOverlayWindowBlurred preserves active visible/modal window stacking'
moveWindowTop: () => {
calls.push('move-visible');
},
platform: 'linux',
}),
true,
);

View File

@@ -10,9 +10,24 @@ import {
} from './overlay-window-input';
import { buildOverlayWindowOptions } from './overlay-window-options';
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
const logger = createLogger('main:overlay-window');
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
const overlayWindowContentReady = new WeakSet<BrowserWindow>();
export function isOverlayWindowContentReady(window: BrowserWindow): boolean {
if (window.isDestroyed()) {
return false;
}
return (
overlayWindowContentReady.has(window) ||
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] === true
);
}
function getOverlayWindowHtmlPath(): string {
return path.join(__dirname, '..', '..', 'renderer', 'index.html');
@@ -76,13 +91,20 @@ export function createOverlayWindow(
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onVisibleWindowBlurred?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (kind: OverlayWindowKind) => void;
yomitanSession?: Session | null;
},
): BrowserWindow {
const window = new BrowserWindow(buildOverlayWindowOptions(kind, options));
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] = false;
options.ensureOverlayWindowLevel(window);
if (!(process.platform === 'win32' && kind === 'visible')) {
options.ensureOverlayWindowLevel(window);
}
loadOverlayWindowLayer(window, kind);
window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
@@ -93,6 +115,14 @@ export function createOverlayWindow(
options.onRuntimeOptionsChanged();
});
window.once('ready-to-show', () => {
overlayWindowContentReady.add(window);
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] = true;
options.onWindowContentReady?.();
});
if (kind === 'visible') {
window.webContents.on('devtools-opened', () => {
options.setOverlayDebugVisualizationEnabled(true);
@@ -136,6 +166,8 @@ export function createOverlayWindow(
moveWindowTop: () => {
window.moveTop();
},
onWindowsVisibleOverlayBlur:
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
});
});

View File

@@ -0,0 +1,131 @@
import type { RuntimeOptionApplyResult, RuntimeOptionId } from '../../types';
import type { SessionActionId } from '../../types/session-bindings';
import type { SessionActionDispatchRequest } from '../../types/runtime';
export interface SessionActionExecutorDeps {
toggleStatsOverlay: () => void;
toggleVisibleOverlay: () => void;
copyCurrentSubtitle: () => void;
copySubtitleCount: (count: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
mineSentenceCard: () => Promise<void>;
mineSentenceCount: (count: number) => void;
toggleSecondarySub: () => void;
toggleSubtitleSidebar: () => void;
markLastCardAsAudioCard: () => Promise<void>;
openRuntimeOptionsPalette: () => void;
openSessionHelp: () => void;
openControllerSelect: () => void;
openControllerDebug: () => void;
openJimaku: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
replayCurrentSubtitle: () => void;
playNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
}
function resolveCount(count: number | undefined): number {
const normalized = typeof count === 'number' && Number.isInteger(count) ? count : 1;
return Math.min(9, Math.max(1, normalized));
}
function assertUnreachableSessionAction(actionId: never): never {
throw new Error(`Unhandled session action: ${String(actionId)}`);
}
export async function dispatchSessionAction(
request: SessionActionDispatchRequest,
deps: SessionActionExecutorDeps,
): Promise<void> {
switch (request.actionId) {
case 'toggleStatsOverlay':
deps.toggleStatsOverlay();
return;
case 'toggleVisibleOverlay':
deps.toggleVisibleOverlay();
return;
case 'copySubtitle':
deps.copyCurrentSubtitle();
return;
case 'copySubtitleMultiple':
deps.copySubtitleCount(resolveCount(request.payload?.count));
return;
case 'updateLastCardFromClipboard':
await deps.updateLastCardFromClipboard();
return;
case 'triggerFieldGrouping':
await deps.triggerFieldGrouping();
return;
case 'triggerSubsync':
await deps.triggerSubsyncFromConfig();
return;
case 'mineSentence':
await deps.mineSentenceCard();
return;
case 'mineSentenceMultiple':
deps.mineSentenceCount(resolveCount(request.payload?.count));
return;
case 'toggleSecondarySub':
deps.toggleSecondarySub();
return;
case 'toggleSubtitleSidebar':
deps.toggleSubtitleSidebar();
return;
case 'markAudioCard':
await deps.markLastCardAsAudioCard();
return;
case 'openRuntimeOptions':
deps.openRuntimeOptionsPalette();
return;
case 'openSessionHelp':
deps.openSessionHelp();
return;
case 'openControllerSelect':
deps.openControllerSelect();
return;
case 'openControllerDebug':
deps.openControllerDebug();
return;
case 'openJimaku':
deps.openJimaku();
return;
case 'openYoutubePicker':
await deps.openYoutubeTrackPicker();
return;
case 'openPlaylistBrowser':
await deps.openPlaylistBrowser();
return;
case 'replayCurrentSubtitle':
deps.replayCurrentSubtitle();
return;
case 'playNextSubtitle':
deps.playNextSubtitle();
return;
case 'shiftSubDelayPrevLine':
await deps.shiftSubDelayToAdjacentSubtitle('previous');
return;
case 'shiftSubDelayNextLine':
await deps.shiftSubDelayToAdjacentSubtitle('next');
return;
case 'cycleRuntimeOption': {
const runtimeOptionId = request.payload?.runtimeOptionId as RuntimeOptionId | undefined;
if (!runtimeOptionId) {
deps.showMpvOsd('Runtime option id is required.');
return;
}
const direction = request.payload?.direction === -1 ? -1 : 1;
const result = deps.cycleRuntimeOption(runtimeOptionId, direction);
if (!result.ok && result.error) {
deps.showMpvOsd(result.error);
}
return;
}
default:
return assertUnreachableSessionAction(request.actionId);
}
}

View File

@@ -0,0 +1,307 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Keybinding } from '../../types';
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
import { SPECIAL_COMMANDS } from '../../config/definitions';
import { compileSessionBindings } from './session-bindings';
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: null,
copySubtitle: null,
copySubtitleMultiple: null,
updateLastCardFromClipboard: null,
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: null,
mineSentenceMultiple: null,
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
...overrides,
};
}
function createKeybinding(key: string, command: Keybinding['command']): Keybinding {
return { key, command };
}
test('compileSessionBindings merges shortcuts and keybindings into one canonical list', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
openJimaku: 'Ctrl+Shift+J',
openControllerSelect: 'Alt+C',
}),
keybindings: [
createKeybinding('KeyF', ['cycle', 'fullscreen']),
createKeybinding('Ctrl+Shift+Y', [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]),
],
platform: 'linux',
});
assert.equal(result.warnings.length, 0);
assert.deepEqual(
result.bindings.map((binding) => ({
actionType: binding.actionType,
sourcePath: binding.sourcePath,
code: binding.key.code,
modifiers: binding.key.modifiers,
target:
binding.actionType === 'session-action'
? binding.actionId
: binding.command.join(' '),
})),
[
{
actionType: 'mpv-command',
sourcePath: 'keybindings[0].key',
code: 'KeyF',
modifiers: [],
target: 'cycle fullscreen',
},
{
actionType: 'session-action',
sourcePath: 'keybindings[1].key',
code: 'KeyY',
modifiers: ['ctrl', 'shift'],
target: 'openYoutubePicker',
},
{
actionType: 'session-action',
sourcePath: 'shortcuts.openControllerSelect',
code: 'KeyC',
modifiers: ['alt'],
target: 'openControllerSelect',
},
{
actionType: 'session-action',
sourcePath: 'shortcuts.openJimaku',
code: 'KeyJ',
modifiers: ['ctrl', 'shift'],
target: 'openJimaku',
},
{
actionType: 'session-action',
sourcePath: 'shortcuts.toggleVisibleOverlayGlobal',
code: 'KeyO',
modifiers: ['alt', 'shift'],
target: 'toggleVisibleOverlay',
},
],
);
});
test('compileSessionBindings resolves CommandOrControl per platform', () => {
const input = {
shortcuts: createShortcuts({
toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O',
}),
keybindings: [],
};
const windows = compileSessionBindings({ ...input, platform: 'win32' });
const mac = compileSessionBindings({ ...input, platform: 'darwin' });
assert.deepEqual(windows.bindings[0]?.key.modifiers, ['ctrl', 'shift']);
assert.deepEqual(mac.bindings[0]?.key.modifiers, ['shift', 'meta']);
});
test('compileSessionBindings resolves CommandOrControl in DOM key strings per platform', () => {
const input = {
shortcuts: createShortcuts(),
keybindings: [createKeybinding('CommandOrControl+Shift+J', ['cycle', 'fullscreen'])],
statsToggleKey: 'CommandOrControl+Backquote',
};
const windows = compileSessionBindings({ ...input, platform: 'win32' });
const mac = compileSessionBindings({ ...input, platform: 'darwin' });
assert.deepEqual(
windows.bindings
.map((binding) => ({
sourcePath: binding.sourcePath,
modifiers: binding.key.modifiers,
}))
.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath)),
[
{
sourcePath: 'keybindings[0].key',
modifiers: ['ctrl', 'shift'],
},
{
sourcePath: 'stats.toggleKey',
modifiers: ['ctrl'],
},
],
);
assert.deepEqual(
mac.bindings
.map((binding) => ({
sourcePath: binding.sourcePath,
modifiers: binding.key.modifiers,
}))
.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath)),
[
{
sourcePath: 'keybindings[0].key',
modifiers: ['shift', 'meta'],
},
{
sourcePath: 'stats.toggleKey',
modifiers: ['meta'],
},
],
);
});
test('compileSessionBindings drops conflicting bindings that canonicalize to the same key', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
openJimaku: 'Ctrl+Shift+J',
}),
keybindings: [createKeybinding('Ctrl+Shift+J', [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN])],
platform: 'linux',
});
assert.deepEqual(result.bindings, []);
assert.equal(result.warnings.length, 1);
assert.equal(result.warnings[0]?.kind, 'conflict');
assert.deepEqual(result.warnings[0]?.conflictingPaths, [
'shortcuts.openJimaku',
'keybindings[0].key',
]);
});
test('compileSessionBindings omits disabled bindings', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
openJimaku: null,
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
}),
keybindings: [createKeybinding('Ctrl+Shift+J', null)],
platform: 'linux',
});
assert.equal(result.warnings.length, 0);
assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), [
'shortcuts.toggleVisibleOverlayGlobal',
]);
});
test('compileSessionBindings warns on unsupported shortcut and keybinding syntax', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
openJimaku: 'Hyper+J',
}),
keybindings: [createKeybinding('Ctrl+ß', ['cycle', 'fullscreen'])],
platform: 'linux',
});
assert.deepEqual(result.bindings, []);
assert.deepEqual(
result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
['unsupported:shortcuts.openJimaku', 'unsupported:keybindings[0].key'],
);
});
test('compileSessionBindings rejects malformed command arrays', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [
createKeybinding('Ctrl+J', ['show-text', 3000]),
createKeybinding('Ctrl+K', ['show-text', { bad: true } as never] as never),
],
platform: 'linux',
});
assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), ['keybindings[0].key']);
assert.equal(result.bindings[0]?.actionType, 'mpv-command');
assert.deepEqual(result.bindings[0]?.command, ['show-text', 3000]);
assert.deepEqual(result.warnings.map((warning) => `${warning.kind}:${warning.path}`), [
'unsupported:keybindings[1].command',
]);
});
test('compileSessionBindings rejects non-string command heads and extra args on special commands', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [
createKeybinding('Ctrl+J', [42] as never),
createKeybinding('Ctrl+K', [SPECIAL_COMMANDS.JIMAKU_OPEN, 'extra'] as never),
],
platform: 'linux',
});
assert.deepEqual(result.bindings, []);
assert.deepEqual(result.warnings.map((warning) => `${warning.kind}:${warning.path}`), [
'unsupported:keybindings[0].command',
'unsupported:keybindings[1].command',
]);
});
test('compileSessionBindings points unsupported command warnings at the command field', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [createKeybinding('Ctrl+K', [SPECIAL_COMMANDS.JIMAKU_OPEN, 'extra'] as never)],
platform: 'linux',
});
assert.deepEqual(result.bindings, []);
assert.deepEqual(result.warnings.map((warning) => `${warning.kind}:${warning.path}`), [
'unsupported:keybindings[0].command',
]);
});
test('compileSessionBindings warns on deprecated toggleVisibleOverlayGlobal config', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [],
platform: 'linux',
rawConfig: {
shortcuts: {
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
},
} as never,
});
assert.equal(result.bindings.length, 0);
assert.deepEqual(result.warnings, [
{
kind: 'deprecated-config',
path: 'shortcuts.toggleVisibleOverlayGlobal',
value: 'Alt+Shift+O',
message: 'Rename shortcuts.toggleVisibleOverlayGlobal to shortcuts.toggleVisibleOverlay.',
},
]);
});
test('compileSessionBindings includes stats toggle in the shared session binding artifact', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [],
statsToggleKey: 'Backquote',
platform: 'win32',
});
assert.equal(result.warnings.length, 0);
assert.deepEqual(result.bindings, [
{
sourcePath: 'stats.toggleKey',
originalKey: 'Backquote',
key: {
code: 'Backquote',
modifiers: [],
},
actionType: 'session-action',
actionId: 'toggleStatsOverlay',
},
]);
});

View File

@@ -0,0 +1,493 @@
import type { Keybinding, ResolvedConfig } from '../../types';
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
import type {
CompiledMpvCommandBinding,
CompiledSessionActionBinding,
CompiledSessionBinding,
PluginSessionBindingsArtifact,
SessionActionId,
SessionBindingWarning,
SessionKeyModifier,
SessionKeySpec,
} from '../../types/session-bindings';
import { SPECIAL_COMMANDS } from '../../config';
type PlatformKeyModel = 'darwin' | 'win32' | 'linux';
type CompileSessionBindingsInput = {
keybindings: Keybinding[];
shortcuts: ConfiguredShortcuts;
statsToggleKey?: string | null;
platform: PlatformKeyModel;
rawConfig?: ResolvedConfig | null;
};
type DraftBinding = {
binding: CompiledSessionBinding;
actionFingerprint: string;
};
const MODIFIER_ORDER: SessionKeyModifier[] = ['ctrl', 'alt', 'shift', 'meta'];
const SESSION_SHORTCUT_ACTIONS: Array<{
key: keyof Omit<ConfiguredShortcuts, 'multiCopyTimeoutMs'>;
actionId: SessionActionId;
}> = [
{ key: 'toggleVisibleOverlayGlobal', actionId: 'toggleVisibleOverlay' },
{ key: 'copySubtitle', actionId: 'copySubtitle' },
{ key: 'copySubtitleMultiple', actionId: 'copySubtitleMultiple' },
{ key: 'updateLastCardFromClipboard', actionId: 'updateLastCardFromClipboard' },
{ key: 'triggerFieldGrouping', actionId: 'triggerFieldGrouping' },
{ key: 'triggerSubsync', actionId: 'triggerSubsync' },
{ key: 'mineSentence', actionId: 'mineSentence' },
{ key: 'mineSentenceMultiple', actionId: 'mineSentenceMultiple' },
{ key: 'toggleSecondarySub', actionId: 'toggleSecondarySub' },
{ key: 'markAudioCard', actionId: 'markAudioCard' },
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
{ key: 'openJimaku', actionId: 'openJimaku' },
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
{ key: 'openControllerDebug', actionId: 'openControllerDebug' },
{ key: 'toggleSubtitleSidebar', actionId: 'toggleSubtitleSidebar' },
];
function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] {
return [...new Set(modifiers)].sort(
(left, right) => MODIFIER_ORDER.indexOf(left) - MODIFIER_ORDER.indexOf(right),
);
}
function isValidCommandEntry(value: unknown): value is string | number {
return typeof value === 'string' || typeof value === 'number';
}
function normalizeCodeToken(token: string): string | null {
const normalized = token.trim();
if (!normalized) return null;
if (/^[a-z]$/i.test(normalized)) {
return `Key${normalized.toUpperCase()}`;
}
if (/^[0-9]$/.test(normalized)) {
return `Digit${normalized}`;
}
const exactMap: Record<string, string> = {
space: 'Space',
tab: 'Tab',
enter: 'Enter',
return: 'Enter',
esc: 'Escape',
escape: 'Escape',
up: 'ArrowUp',
down: 'ArrowDown',
left: 'ArrowLeft',
right: 'ArrowRight',
backspace: 'Backspace',
delete: 'Delete',
slash: 'Slash',
backslash: 'Backslash',
minus: 'Minus',
plus: 'Equal',
equal: 'Equal',
comma: 'Comma',
period: 'Period',
quote: 'Quote',
semicolon: 'Semicolon',
bracketleft: 'BracketLeft',
bracketright: 'BracketRight',
backquote: 'Backquote',
};
const lower = normalized.toLowerCase();
if (exactMap[lower]) return exactMap[lower];
if (
/^key[a-z]$/i.test(normalized) ||
/^digit[0-9]$/i.test(normalized) ||
/^arrow(?:up|down|left|right)$/i.test(normalized) ||
/^f\d{1,2}$/i.test(normalized)
) {
const keyMatch = normalized.match(/^key([a-z])$/i);
if (keyMatch) {
return `Key${keyMatch[1]!.toUpperCase()}`;
}
const digitMatch = normalized.match(/^digit([0-9])$/i);
if (digitMatch) {
return `Digit${digitMatch[1]}`;
}
const arrowMatch = normalized.match(/^arrow(up|down|left|right)$/i);
if (arrowMatch) {
const direction = arrowMatch[1]!;
return `Arrow${direction[0]!.toUpperCase()}${direction.slice(1).toLowerCase()}`;
}
const functionKeyMatch = normalized.match(/^f(\d{1,2})$/i);
if (functionKeyMatch) {
return `F${functionKeyMatch[1]}`;
}
}
return null;
}
function parseAccelerator(
accelerator: string,
platform: PlatformKeyModel,
): { key: SessionKeySpec | null; message?: string } {
const normalized = accelerator.replace(/\s+/g, '').replace(/cmdorctrl/gi, 'CommandOrControl');
if (!normalized) {
return { key: null, message: 'Empty accelerator is not supported.' };
}
const parts = normalized.split('+').filter(Boolean);
const keyToken = parts.pop();
if (!keyToken) {
return { key: null, message: 'Missing accelerator key token.' };
}
const modifiers: SessionKeyModifier[] = [];
for (const modifier of parts) {
const lower = modifier.toLowerCase();
if (lower === 'ctrl' || lower === 'control') {
modifiers.push('ctrl');
continue;
}
if (lower === 'alt' || lower === 'option') {
modifiers.push('alt');
continue;
}
if (lower === 'shift') {
modifiers.push('shift');
continue;
}
if (lower === 'meta' || lower === 'super' || lower === 'command' || lower === 'cmd') {
modifiers.push('meta');
continue;
}
if (lower === 'commandorcontrol') {
modifiers.push(platform === 'darwin' ? 'meta' : 'ctrl');
continue;
}
return {
key: null,
message: `Unsupported accelerator modifier: ${modifier}`,
};
}
const code = normalizeCodeToken(keyToken);
if (!code) {
return {
key: null,
message: `Unsupported accelerator key token: ${keyToken}`,
};
}
return {
key: {
code,
modifiers: normalizeModifiers(modifiers),
},
};
}
function parseDomKeyString(
key: string,
platform: PlatformKeyModel,
): { key: SessionKeySpec | null; message?: string } {
const parts = key
.split('+')
.map((part) => part.trim())
.filter(Boolean);
const keyToken = parts.pop();
if (!keyToken) {
return { key: null, message: 'Missing keybinding key token.' };
}
const modifiers: SessionKeyModifier[] = [];
for (const modifier of parts) {
const lower = modifier.toLowerCase();
if (lower === 'ctrl' || lower === 'control') {
modifiers.push('ctrl');
continue;
}
if (lower === 'alt' || lower === 'option') {
modifiers.push('alt');
continue;
}
if (lower === 'shift') {
modifiers.push('shift');
continue;
}
if (
lower === 'meta' ||
lower === 'super' ||
lower === 'command' ||
lower === 'cmd' ||
lower === 'commandorcontrol'
) {
modifiers.push(
lower === 'commandorcontrol' ? (platform === 'darwin' ? 'meta' : 'ctrl') : 'meta',
);
continue;
}
return {
key: null,
message: `Unsupported keybinding modifier: ${modifier}`,
};
}
const code = normalizeCodeToken(keyToken);
if (!code) {
return {
key: null,
message: `Unsupported keybinding token: ${keyToken}`,
};
}
return {
key: {
code,
modifiers: normalizeModifiers(modifiers),
},
};
}
export function getSessionKeySpecSignature(key: SessionKeySpec): string {
return [...key.modifiers, key.code].join('+');
}
function resolveCommandBinding(
binding: Keybinding,
):
| Omit<CompiledMpvCommandBinding, 'key' | 'sourcePath' | 'originalKey'>
| Omit<CompiledSessionActionBinding, 'key' | 'sourcePath' | 'originalKey'>
| null {
const command = binding.command;
if (!Array.isArray(command) || command.length === 0 || !command.every(isValidCommandEntry)) {
return null;
}
const first = command[0];
if (typeof first !== 'string') {
return null;
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'triggerSubsync' };
}
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'openRuntimeOptions' };
}
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'openJimaku' };
}
if (first === SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'openYoutubePicker' };
}
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'openPlaylistBrowser' };
}
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'replayCurrentSubtitle' };
}
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'shiftSubDelayPrevLine' };
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'shiftSubDelayNextLine' };
}
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
if (command.length !== 1) {
return null;
}
const parts = first.split(':');
if (parts.length !== 3) {
return null;
}
const [, runtimeOptionId, rawDirection] = parts;
if (!runtimeOptionId || (rawDirection !== 'prev' && rawDirection !== 'next')) {
return null;
}
return {
actionType: 'session-action',
actionId: 'cycleRuntimeOption',
payload: {
runtimeOptionId,
direction: rawDirection === 'prev' ? -1 : 1,
},
};
}
return {
actionType: 'mpv-command',
command,
};
}
function getBindingFingerprint(binding: CompiledSessionBinding): string {
if (binding.actionType === 'mpv-command') {
return `mpv:${JSON.stringify(binding.command)}`;
}
return `session:${binding.actionId}:${JSON.stringify(binding.payload ?? null)}`;
}
export function compileSessionBindings(
input: CompileSessionBindingsInput,
): {
bindings: CompiledSessionBinding[];
warnings: SessionBindingWarning[];
} {
const warnings: SessionBindingWarning[] = [];
const candidates = new Map<string, DraftBinding[]>();
const legacyToggleVisibleOverlayGlobal = (
input.rawConfig?.shortcuts as Record<string, unknown> | undefined
)?.toggleVisibleOverlayGlobal;
const statsToggleKey = input.statsToggleKey ?? input.rawConfig?.stats?.toggleKey ?? null;
if (legacyToggleVisibleOverlayGlobal !== undefined) {
warnings.push({
kind: 'deprecated-config',
path: 'shortcuts.toggleVisibleOverlayGlobal',
value: legacyToggleVisibleOverlayGlobal,
message: 'Rename shortcuts.toggleVisibleOverlayGlobal to shortcuts.toggleVisibleOverlay.',
});
}
for (const shortcut of SESSION_SHORTCUT_ACTIONS) {
const accelerator = input.shortcuts[shortcut.key];
if (!accelerator) continue;
const parsed = parseAccelerator(accelerator, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
path: `shortcuts.${shortcut.key}`,
value: accelerator,
message: parsed.message ?? 'Unsupported accelerator syntax.',
});
continue;
}
const binding: CompiledSessionActionBinding = {
sourcePath: `shortcuts.${shortcut.key}`,
originalKey: accelerator,
key: parsed.key,
actionType: 'session-action',
actionId: shortcut.actionId,
};
const signature = getSessionKeySpecSignature(parsed.key);
const draft = candidates.get(signature) ?? [];
draft.push({
binding,
actionFingerprint: getBindingFingerprint(binding),
});
candidates.set(signature, draft);
}
if (statsToggleKey) {
const parsed = parseDomKeyString(statsToggleKey, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
path: 'stats.toggleKey',
value: statsToggleKey,
message: parsed.message ?? 'Unsupported stats toggle key syntax.',
});
} else {
const binding: CompiledSessionActionBinding = {
sourcePath: 'stats.toggleKey',
originalKey: statsToggleKey,
key: parsed.key,
actionType: 'session-action',
actionId: 'toggleStatsOverlay',
};
const signature = getSessionKeySpecSignature(parsed.key);
const draft = candidates.get(signature) ?? [];
draft.push({
binding,
actionFingerprint: getBindingFingerprint(binding),
});
candidates.set(signature, draft);
}
}
input.keybindings.forEach((binding, index) => {
if (!binding.command) return;
const parsed = parseDomKeyString(binding.key, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
path: `keybindings[${index}].key`,
value: binding.key,
message: parsed.message ?? 'Unsupported keybinding syntax.',
});
return;
}
const resolved = resolveCommandBinding(binding);
if (!resolved) {
warnings.push({
kind: 'unsupported',
path: `keybindings[${index}].command`,
value: binding.command,
message: 'Unsupported keybinding command syntax.',
});
return;
}
const compiled: CompiledSessionBinding = {
sourcePath: `keybindings[${index}].key`,
originalKey: binding.key,
key: parsed.key,
...resolved,
};
const signature = getSessionKeySpecSignature(parsed.key);
const draft = candidates.get(signature) ?? [];
draft.push({
binding: compiled,
actionFingerprint: getBindingFingerprint(compiled),
});
candidates.set(signature, draft);
});
const bindings: CompiledSessionBinding[] = [];
for (const [signature, draftBindings] of candidates.entries()) {
const uniqueFingerprints = new Set(draftBindings.map((entry) => entry.actionFingerprint));
if (uniqueFingerprints.size > 1) {
warnings.push({
kind: 'conflict',
path: draftBindings[0]!.binding.sourcePath,
value: signature,
conflictingPaths: draftBindings.map((entry) => entry.binding.sourcePath),
message: `Conflicting session bindings compile to ${signature}; SubMiner will bind neither action.`,
});
continue;
}
bindings.push(draftBindings[0]!.binding);
}
bindings.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath));
return { bindings, warnings };
}
export function buildPluginSessionBindingsArtifact(input: {
bindings: CompiledSessionBinding[];
warnings: SessionBindingWarning[];
numericSelectionTimeoutMs: number;
now?: Date;
}): PluginSessionBindingsArtifact {
return {
version: 1,
generatedAt: (input.now ?? new Date()).toISOString(),
numericSelectionTimeoutMs: input.numericSelectionTimeoutMs,
bindings: input.bindings,
warnings: input.warnings,
};
}

View File

@@ -20,42 +20,6 @@ export interface RegisterGlobalShortcutsServiceOptions {
}
export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceOptions): void {
const visibleShortcut = options.shortcuts.toggleVisibleOverlayGlobal;
const normalizedVisible = visibleShortcut?.replace(/\s+/g, '').toLowerCase();
const normalizedJimaku = options.shortcuts.openJimaku?.replace(/\s+/g, '').toLowerCase();
const normalizedSettings = 'alt+shift+y';
if (visibleShortcut) {
const toggleVisibleRegistered = globalShortcut.register(visibleShortcut, () => {
options.onToggleVisibleOverlay();
});
if (!toggleVisibleRegistered) {
logger.warn(
`Failed to register global shortcut toggleVisibleOverlayGlobal: ${visibleShortcut}`,
);
}
}
if (options.shortcuts.openJimaku && options.onOpenJimaku) {
if (
normalizedJimaku &&
(normalizedJimaku === normalizedVisible || normalizedJimaku === normalizedSettings)
) {
logger.warn(
'Skipped registering openJimaku because it collides with another global shortcut',
);
} else {
const openJimakuRegistered = globalShortcut.register(options.shortcuts.openJimaku, () => {
options.onOpenJimaku?.();
});
if (!openJimakuRegistered) {
logger.warn(
`Failed to register global shortcut openJimaku: ${options.shortcuts.openJimaku}`,
);
}
}
}
const settingsRegistered = globalShortcut.register('Alt+Shift+Y', () => {
options.onOpenYomitanSettings();
});

View File

@@ -28,7 +28,21 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerFieldGrouping: false,
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,

View File

@@ -311,7 +311,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.createSubtitleTimingTracker();
if (deps.createImmersionTracker) {
deps.log('Runtime ready: immersion tracker startup deferred until first media activity.');
deps.log('Runtime ready: immersion tracker startup requested.');
} else {
deps.log('Runtime ready: immersion tracker dependency is missing.');
}

View File

@@ -14,6 +14,10 @@ export interface ConfiguredShortcuts {
markAudioCard: string | null | undefined;
openRuntimeOptions: string | null | undefined;
openJimaku: string | null | undefined;
openSessionHelp: string | null | undefined;
openControllerSelect: string | null | undefined;
openControllerDebug: string | null | undefined;
toggleSubtitleSidebar: string | null | undefined;
}
export function resolveConfiguredShortcuts(
@@ -78,5 +82,17 @@ export function resolveConfiguredShortcuts(
openJimaku: normalizeShortcut(
config.shortcuts?.openJimaku ?? defaultConfig.shortcuts?.openJimaku,
),
openSessionHelp: normalizeShortcut(
config.shortcuts?.openSessionHelp ?? defaultConfig.shortcuts?.openSessionHelp,
),
openControllerSelect: normalizeShortcut(
config.shortcuts?.openControllerSelect ?? defaultConfig.shortcuts?.openControllerSelect,
),
openControllerDebug: normalizeShortcut(
config.shortcuts?.openControllerDebug ?? defaultConfig.shortcuts?.openControllerDebug,
),
toggleSubtitleSidebar: normalizeShortcut(
config.shortcuts?.toggleSubtitleSidebar ?? defaultConfig.shortcuts?.toggleSubtitleSidebar,
),
};
}