mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-26 16:19:26 -07:00
Windows update (#49)
This commit is contained in:
@@ -73,6 +73,50 @@ test('parseArgs captures youtube startup forwarding flags', () => {
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
});
|
||||
|
||||
test('parseArgs captures session action forwarding flags', () => {
|
||||
const args = parseArgs([
|
||||
'--toggle-stats-overlay',
|
||||
'--open-jimaku',
|
||||
'--open-youtube-picker',
|
||||
'--open-playlist-browser',
|
||||
'--replay-current-subtitle',
|
||||
'--play-next-subtitle',
|
||||
'--shift-sub-delay-prev-line',
|
||||
'--shift-sub-delay-next-line',
|
||||
'--cycle-runtime-option',
|
||||
'anki.autoUpdateNewCards:prev',
|
||||
'--copy-subtitle-count',
|
||||
'3',
|
||||
'--mine-sentence-count=2',
|
||||
]);
|
||||
|
||||
assert.equal(args.toggleStatsOverlay, true);
|
||||
assert.equal(args.openJimaku, true);
|
||||
assert.equal(args.openYoutubePicker, true);
|
||||
assert.equal(args.openPlaylistBrowser, true);
|
||||
assert.equal(args.replayCurrentSubtitle, true);
|
||||
assert.equal(args.playNextSubtitle, true);
|
||||
assert.equal(args.shiftSubDelayPrevLine, true);
|
||||
assert.equal(args.shiftSubDelayNextLine, true);
|
||||
assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
|
||||
assert.equal(args.cycleRuntimeOptionDirection, -1);
|
||||
assert.equal(args.copySubtitleCount, 3);
|
||||
assert.equal(args.mineSentenceCount, 2);
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
});
|
||||
|
||||
test('parseArgs ignores non-positive numeric session action counts', () => {
|
||||
const args = parseArgs([
|
||||
'--copy-subtitle-count=0',
|
||||
'--mine-sentence-count',
|
||||
'-1',
|
||||
]);
|
||||
|
||||
assert.equal(args.copySubtitleCount, undefined);
|
||||
assert.equal(args.mineSentenceCount, undefined);
|
||||
});
|
||||
|
||||
test('youtube playback does not use generic overlay-runtime bootstrap classification', () => {
|
||||
const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
|
||||
|
||||
@@ -172,6 +216,24 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(hasExplicitCommand(anilistRetryQueue), true);
|
||||
assert.equal(shouldStartApp(anilistRetryQueue), false);
|
||||
|
||||
const toggleStatsOverlay = parseArgs(['--toggle-stats-overlay']);
|
||||
assert.equal(toggleStatsOverlay.toggleStatsOverlay, true);
|
||||
assert.equal(hasExplicitCommand(toggleStatsOverlay), true);
|
||||
assert.equal(shouldStartApp(toggleStatsOverlay), true);
|
||||
|
||||
const cycleRuntimeOption = parseArgs([
|
||||
'--cycle-runtime-option',
|
||||
'anki.autoUpdateNewCards:next',
|
||||
]);
|
||||
assert.equal(cycleRuntimeOption.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
|
||||
assert.equal(cycleRuntimeOption.cycleRuntimeOptionDirection, 1);
|
||||
assert.equal(hasExplicitCommand(cycleRuntimeOption), true);
|
||||
assert.equal(shouldStartApp(cycleRuntimeOption), true);
|
||||
assert.equal(commandNeedsOverlayRuntime(cycleRuntimeOption), true);
|
||||
|
||||
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
|
||||
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
|
||||
|
||||
const dictionary = parseArgs(['--dictionary']);
|
||||
assert.equal(dictionary.dictionary, true);
|
||||
assert.equal(hasExplicitCommand(dictionary), true);
|
||||
|
||||
161
src/cli/args.ts
161
src/cli/args.ts
@@ -24,7 +24,23 @@ export interface CliArgs {
|
||||
triggerFieldGrouping: boolean;
|
||||
triggerSubsync: boolean;
|
||||
markAudioCard: boolean;
|
||||
toggleStatsOverlay: boolean;
|
||||
toggleSubtitleSidebar: boolean;
|
||||
openRuntimeOptions: boolean;
|
||||
openSessionHelp: boolean;
|
||||
openControllerSelect: boolean;
|
||||
openControllerDebug: boolean;
|
||||
openJimaku: boolean;
|
||||
openYoutubePicker: boolean;
|
||||
openPlaylistBrowser: boolean;
|
||||
replayCurrentSubtitle: boolean;
|
||||
playNextSubtitle: boolean;
|
||||
shiftSubDelayPrevLine: boolean;
|
||||
shiftSubDelayNextLine: boolean;
|
||||
cycleRuntimeOptionId?: string;
|
||||
cycleRuntimeOptionDirection?: 1 | -1;
|
||||
copySubtitleCount?: number;
|
||||
mineSentenceCount?: number;
|
||||
anilistStatus: boolean;
|
||||
anilistLogout: boolean;
|
||||
anilistSetup: boolean;
|
||||
@@ -102,7 +118,19 @@ export function parseArgs(argv: string[]): 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,
|
||||
anilistStatus: false,
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
@@ -138,6 +166,24 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
return value;
|
||||
};
|
||||
|
||||
const parseCycleRuntimeOption = (
|
||||
value: string | undefined,
|
||||
): { id: string; direction: 1 | -1 } | null => {
|
||||
if (!value) return null;
|
||||
const separatorIndex = value.lastIndexOf(':');
|
||||
if (separatorIndex <= 0 || separatorIndex === value.length - 1) return null;
|
||||
const id = value.slice(0, separatorIndex).trim();
|
||||
const rawDirection = value.slice(separatorIndex + 1).trim().toLowerCase();
|
||||
if (!id) return null;
|
||||
if (rawDirection === 'next' || rawDirection === '1') {
|
||||
return { id, direction: 1 };
|
||||
}
|
||||
if (rawDirection === 'prev' || rawDirection === '-1') {
|
||||
return { id, direction: -1 };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg || !arg.startsWith('--')) continue;
|
||||
@@ -179,8 +225,44 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--trigger-field-grouping') args.triggerFieldGrouping = true;
|
||||
else if (arg === '--trigger-subsync') args.triggerSubsync = true;
|
||||
else if (arg === '--mark-audio-card') args.markAudioCard = true;
|
||||
else if (arg === '--toggle-stats-overlay') args.toggleStatsOverlay = true;
|
||||
else if (arg === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true;
|
||||
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
|
||||
else if (arg === '--anilist-status') args.anilistStatus = true;
|
||||
else if (arg === '--open-session-help') args.openSessionHelp = true;
|
||||
else if (arg === '--open-controller-select') args.openControllerSelect = true;
|
||||
else if (arg === '--open-controller-debug') args.openControllerDebug = true;
|
||||
else if (arg === '--open-jimaku') args.openJimaku = true;
|
||||
else if (arg === '--open-youtube-picker') args.openYoutubePicker = true;
|
||||
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
|
||||
else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true;
|
||||
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
|
||||
else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true;
|
||||
else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true;
|
||||
else if (arg.startsWith('--cycle-runtime-option=')) {
|
||||
const parsed = parseCycleRuntimeOption(arg.split('=', 2)[1]);
|
||||
if (parsed) {
|
||||
args.cycleRuntimeOptionId = parsed.id;
|
||||
args.cycleRuntimeOptionDirection = parsed.direction;
|
||||
}
|
||||
} else if (arg === '--cycle-runtime-option') {
|
||||
const parsed = parseCycleRuntimeOption(readValue(argv[i + 1]));
|
||||
if (parsed) {
|
||||
args.cycleRuntimeOptionId = parsed.id;
|
||||
args.cycleRuntimeOptionDirection = parsed.direction;
|
||||
}
|
||||
} else if (arg.startsWith('--copy-subtitle-count=')) {
|
||||
const value = Number(arg.split('=', 2)[1]);
|
||||
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
|
||||
} else if (arg === '--copy-subtitle-count') {
|
||||
const value = Number(readValue(argv[i + 1]));
|
||||
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
|
||||
} else if (arg.startsWith('--mine-sentence-count=')) {
|
||||
const value = Number(arg.split('=', 2)[1]);
|
||||
if (Number.isInteger(value) && value > 0) args.mineSentenceCount = value;
|
||||
} else if (arg === '--mine-sentence-count') {
|
||||
const value = Number(readValue(argv[i + 1]));
|
||||
if (Number.isInteger(value) && value > 0) args.mineSentenceCount = value;
|
||||
} else if (arg === '--anilist-status') args.anilistStatus = true;
|
||||
else if (arg === '--anilist-logout') args.anilistLogout = true;
|
||||
else if (arg === '--anilist-setup') args.anilistSetup = true;
|
||||
else if (arg === '--anilist-retry-queue') args.anilistRetryQueue = true;
|
||||
@@ -371,7 +453,22 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.triggerFieldGrouping ||
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.toggleStatsOverlay ||
|
||||
args.toggleSubtitleSidebar ||
|
||||
args.openRuntimeOptions ||
|
||||
args.openSessionHelp ||
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
args.mineSentenceCount !== undefined ||
|
||||
args.anilistStatus ||
|
||||
args.anilistLogout ||
|
||||
args.anilistSetup ||
|
||||
@@ -423,7 +520,22 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.triggerFieldGrouping &&
|
||||
!args.triggerSubsync &&
|
||||
!args.markAudioCard &&
|
||||
!args.toggleStatsOverlay &&
|
||||
!args.toggleSubtitleSidebar &&
|
||||
!args.openRuntimeOptions &&
|
||||
!args.openSessionHelp &&
|
||||
!args.openControllerSelect &&
|
||||
!args.openControllerDebug &&
|
||||
!args.openJimaku &&
|
||||
!args.openYoutubePicker &&
|
||||
!args.openPlaylistBrowser &&
|
||||
!args.replayCurrentSubtitle &&
|
||||
!args.playNextSubtitle &&
|
||||
!args.shiftSubDelayPrevLine &&
|
||||
!args.shiftSubDelayNextLine &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.copySubtitleCount === undefined &&
|
||||
args.mineSentenceCount === undefined &&
|
||||
!args.anilistStatus &&
|
||||
!args.anilistLogout &&
|
||||
!args.anilistSetup &&
|
||||
@@ -466,7 +578,22 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.triggerFieldGrouping ||
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.toggleStatsOverlay ||
|
||||
args.toggleSubtitleSidebar ||
|
||||
args.openRuntimeOptions ||
|
||||
args.openSessionHelp ||
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
args.mineSentenceCount !== undefined ||
|
||||
args.dictionary ||
|
||||
args.stats ||
|
||||
args.jellyfin ||
|
||||
@@ -504,7 +631,22 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.triggerFieldGrouping &&
|
||||
!args.triggerSubsync &&
|
||||
!args.markAudioCard &&
|
||||
!args.toggleStatsOverlay &&
|
||||
!args.toggleSubtitleSidebar &&
|
||||
!args.openRuntimeOptions &&
|
||||
!args.openSessionHelp &&
|
||||
!args.openControllerSelect &&
|
||||
!args.openControllerDebug &&
|
||||
!args.openJimaku &&
|
||||
!args.openYoutubePicker &&
|
||||
!args.openPlaylistBrowser &&
|
||||
!args.replayCurrentSubtitle &&
|
||||
!args.playNextSubtitle &&
|
||||
!args.shiftSubDelayPrevLine &&
|
||||
!args.shiftSubDelayNextLine &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.copySubtitleCount === undefined &&
|
||||
args.mineSentenceCount === undefined &&
|
||||
!args.anilistStatus &&
|
||||
!args.anilistLogout &&
|
||||
!args.anilistSetup &&
|
||||
@@ -544,10 +686,25 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
args.mineSentenceMultiple ||
|
||||
args.updateLastCardFromClipboard ||
|
||||
args.toggleSecondarySub ||
|
||||
args.toggleStatsOverlay ||
|
||||
args.toggleSubtitleSidebar ||
|
||||
args.triggerFieldGrouping ||
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.openRuntimeOptions
|
||||
args.openRuntimeOptions ||
|
||||
args.openSessionHelp ||
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
args.mineSentenceCount !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,11 @@ ${B}Mining${R}
|
||||
--trigger-field-grouping Run Kiku field grouping
|
||||
--trigger-subsync Run subtitle sync
|
||||
--toggle-secondary-sub Cycle secondary subtitle mode
|
||||
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
|
||||
--open-runtime-options Open runtime options palette
|
||||
--open-session-help Open session help modal
|
||||
--open-controller-select Open controller select modal
|
||||
--open-controller-debug Open controller debug modal
|
||||
|
||||
${B}AniList${R}
|
||||
--anilist-setup Open AniList authentication flow
|
||||
|
||||
@@ -50,6 +50,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.startupWarmups.yomitanExtension, true);
|
||||
assert.equal(config.startupWarmups.subtitleDictionaries, true);
|
||||
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
|
||||
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
|
||||
assert.equal(config.discordPresence.enabled, true);
|
||||
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
||||
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
|
||||
|
||||
@@ -88,6 +88,10 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
markAudioCard: 'CommandOrControl+Shift+A',
|
||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||
openJimaku: 'Ctrl+Shift+J',
|
||||
openSessionHelp: 'CommandOrControl+Shift+H',
|
||||
openControllerSelect: 'Alt+C',
|
||||
openControllerDebug: 'Alt+Shift+C',
|
||||
toggleSubtitleSidebar: 'Backslash',
|
||||
},
|
||||
secondarySub: {
|
||||
secondarySubLanguages: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: () => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
@@ -72,6 +72,7 @@ export {
|
||||
createOverlayWindow,
|
||||
enforceOverlayLayerOrder,
|
||||
ensureOverlayWindowLevel,
|
||||
isOverlayWindowContentReady,
|
||||
syncOverlayWindowLayer,
|
||||
updateOverlayWindowBounds,
|
||||
} from './overlay-window';
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
98
src/core/services/overlay-shortcut.test.ts
Normal file
98
src/core/services/overlay-shortcut.test.ts
Normal 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']);
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
1
src/core/services/overlay-window-flags.ts
Normal file
1
src/core/services/overlay-window-flags.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
131
src/core/services/session-actions.ts
Normal file
131
src/core/services/session-actions.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
307
src/core/services/session-bindings.test.ts
Normal file
307
src/core/services/session-bindings.test.ts
Normal 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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
493
src/core/services/session-bindings.ts
Normal file
493
src/core/services/session-bindings.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
544
src/main.ts
544
src/main.ts
@@ -109,11 +109,13 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { MecabTokenizer } from './mecab-tokenizer';
|
||||
import type {
|
||||
CompiledSessionBinding,
|
||||
JimakuApiResponse,
|
||||
KikuFieldGroupingChoice,
|
||||
MpvSubtitleRenderMetrics,
|
||||
ResolvedConfig,
|
||||
RuntimeOptionState,
|
||||
SessionActionDispatchRequest,
|
||||
SecondarySubMode,
|
||||
SubtitleData,
|
||||
SubtitlePosition,
|
||||
@@ -130,6 +132,14 @@ import {
|
||||
type LogLevelSource,
|
||||
} from './logger';
|
||||
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
|
||||
import {
|
||||
bindWindowsOverlayAboveMpv,
|
||||
clearWindowsOverlayOwner,
|
||||
ensureWindowsOverlayTransparency,
|
||||
findWindowsMpvTargetWindowHandle,
|
||||
getWindowsForegroundProcessName,
|
||||
setWindowsOverlayOwner,
|
||||
} from './window-trackers/windows-helper';
|
||||
import {
|
||||
commandNeedsOverlayStartupPrereqs,
|
||||
commandNeedsOverlayRuntime,
|
||||
@@ -342,6 +352,7 @@ import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-reso
|
||||
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||
import { startStatsServer } from './core/services/stats-server';
|
||||
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
|
||||
import { toggleStatsOverlay as toggleStatsOverlayWindow } from './core/services/stats-window.js';
|
||||
import {
|
||||
createFirstRunSetupService,
|
||||
getFirstRunSetupCompletionMessage,
|
||||
@@ -404,6 +415,8 @@ import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
|
||||
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
|
||||
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
|
||||
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
|
||||
import { buildPluginSessionBindingsArtifact, compileSessionBindings } from './core/services/session-bindings';
|
||||
import { dispatchSessionAction as dispatchSessionActionCore } from './core/services/session-actions';
|
||||
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
|
||||
import { createMainRuntimeRegistry } from './main/runtime/registry';
|
||||
import {
|
||||
@@ -439,7 +452,14 @@ import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
||||
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
||||
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
|
||||
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
||||
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
|
||||
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
|
||||
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
|
||||
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
||||
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
||||
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
|
||||
import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact';
|
||||
import { openOverlayHostedModal } from './main/runtime/overlay-hosted-modal-open';
|
||||
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
||||
import {
|
||||
createFrequencyDictionaryRuntimeService,
|
||||
@@ -1470,9 +1490,7 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
||||
openRuntimeOptionsPalette();
|
||||
},
|
||||
openJimaku: () => {
|
||||
sendToActiveOverlayWindow('jimaku:open', undefined, {
|
||||
restoreOnModalClose: 'jimaku',
|
||||
});
|
||||
openJimakuOverlay();
|
||||
},
|
||||
markAudioCard: () => markLastCardAsAudioCard(),
|
||||
copySubtitleMultiple: (timeoutMs: number) => {
|
||||
@@ -1526,6 +1544,9 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
|
||||
setKeybindings: (keybindings) => {
|
||||
appState.keybindings = keybindings;
|
||||
},
|
||||
setSessionBindings: (sessionBindings, sessionBindingWarnings) => {
|
||||
persistSessionBindings(sessionBindings, sessionBindingWarnings);
|
||||
},
|
||||
refreshGlobalAndOverlayShortcuts: () => {
|
||||
refreshGlobalAndOverlayShortcuts();
|
||||
},
|
||||
@@ -1835,6 +1856,9 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getForceMousePassthrough: () => appState.statsOverlayVisible,
|
||||
getWindowTracker: () => appState.windowTracker,
|
||||
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
|
||||
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
|
||||
getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(),
|
||||
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
appState.trackerNotReadyWarningShown = shown;
|
||||
@@ -1843,6 +1867,9 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
ensureOverlayWindowLevel: (window) => {
|
||||
ensureOverlayWindowLevel(window);
|
||||
},
|
||||
syncWindowsOverlayToMpvZOrder: (_window) => {
|
||||
requestWindowsVisibleOverlayZOrderSync();
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: (layer) => {
|
||||
syncPrimaryOverlayWindowLayer(layer);
|
||||
},
|
||||
@@ -1870,6 +1897,247 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
},
|
||||
})(),
|
||||
);
|
||||
|
||||
const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
|
||||
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
|
||||
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
|
||||
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
|
||||
let windowsVisibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||
let windowsVisibleOverlayZOrderSyncQueued = false;
|
||||
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||
|
||||
function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void {
|
||||
for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
windowsVisibleOverlayBlurRefreshTimeouts = [];
|
||||
}
|
||||
|
||||
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
|
||||
for (const timeout of windowsVisibleOverlayZOrderRetryTimeouts) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
windowsVisibleOverlayZOrderRetryTimeouts = [];
|
||||
}
|
||||
|
||||
function getWindowsNativeWindowHandle(window: BrowserWindow): string {
|
||||
const handle = window.getNativeWindowHandle();
|
||||
return handle.length >= 8
|
||||
? handle.readBigUInt64LE(0).toString()
|
||||
: BigInt(handle.readUInt32LE(0)).toString();
|
||||
}
|
||||
|
||||
function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number {
|
||||
const handle = window.getNativeWindowHandle();
|
||||
return handle.length >= 8
|
||||
? Number(handle.readBigUInt64LE(0))
|
||||
: handle.readUInt32LE(0);
|
||||
}
|
||||
|
||||
function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | null): number | null {
|
||||
if (process.platform !== 'win32') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (targetMpvSocketPath) {
|
||||
const windowTracker = appState.windowTracker as
|
||||
| {
|
||||
getTargetWindowHandle?: () => number | null;
|
||||
}
|
||||
| null;
|
||||
const trackedHandle = windowTracker?.getTargetWindowHandle?.();
|
||||
if (typeof trackedHandle === 'number' && Number.isFinite(trackedHandle)) {
|
||||
return trackedHandle;
|
||||
}
|
||||
}
|
||||
return findWindowsMpvTargetWindowHandle();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
|
||||
if (process.platform !== 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (
|
||||
!mainWindow ||
|
||||
mainWindow.isDestroyed() ||
|
||||
!mainWindow.isVisible() ||
|
||||
!overlayManager.getVisibleOverlayVisible()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const windowTracker = appState.windowTracker;
|
||||
if (!windowTracker) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof windowTracker.isTargetWindowMinimized === 'function' &&
|
||||
windowTracker.isTargetWindowMinimized()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!windowTracker.isTracking() && windowTracker.getGeometry() === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath);
|
||||
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
|
||||
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function requestWindowsVisibleOverlayZOrderSync(): void {
|
||||
if (process.platform !== 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (windowsVisibleOverlayZOrderSyncInFlight) {
|
||||
windowsVisibleOverlayZOrderSyncQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
windowsVisibleOverlayZOrderSyncInFlight = true;
|
||||
void syncWindowsVisibleOverlayToMpvZOrder()
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to bind Windows overlay z-order to mpv', error);
|
||||
})
|
||||
.finally(() => {
|
||||
windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||
if (!windowsVisibleOverlayZOrderSyncQueued) {
|
||||
return;
|
||||
}
|
||||
|
||||
windowsVisibleOverlayZOrderSyncQueued = false;
|
||||
requestWindowsVisibleOverlayZOrderSync();
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleWindowsVisibleOverlayZOrderSyncBurst(): void {
|
||||
if (process.platform !== 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
clearWindowsVisibleOverlayZOrderRetryTimeouts();
|
||||
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS) {
|
||||
const retryTimeout = setTimeout(() => {
|
||||
windowsVisibleOverlayZOrderRetryTimeouts = windowsVisibleOverlayZOrderRetryTimeouts.filter(
|
||||
(timeout) => timeout !== retryTimeout,
|
||||
);
|
||||
requestWindowsVisibleOverlayZOrderSync();
|
||||
}, delayMs);
|
||||
windowsVisibleOverlayZOrderRetryTimeouts.push(retryTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
function hasWindowsVisibleOverlayFocusHandoffGrace(): boolean {
|
||||
return (
|
||||
process.platform === 'win32' &&
|
||||
lastWindowsVisibleOverlayBlurredAtMs > 0 &&
|
||||
Date.now() - lastWindowsVisibleOverlayBlurredAtMs <=
|
||||
WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS
|
||||
);
|
||||
}
|
||||
|
||||
function shouldPollWindowsVisibleOverlayForegroundProcess(): boolean {
|
||||
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const windowTracker = appState.windowTracker;
|
||||
if (!windowTracker) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof windowTracker.isTargetWindowMinimized === 'function' &&
|
||||
windowTracker.isTargetWindowMinimized()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const overlayFocused = mainWindow.isFocused();
|
||||
const trackerFocused = windowTracker.isTargetWindowFocused?.() ?? false;
|
||||
return !overlayFocused && !trackerFocused;
|
||||
}
|
||||
|
||||
function maybePollWindowsVisibleOverlayForegroundProcess(): void {
|
||||
if (!shouldPollWindowsVisibleOverlayForegroundProcess()) {
|
||||
lastWindowsVisibleOverlayForegroundProcessName = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const processName = getWindowsForegroundProcessName();
|
||||
const normalizedProcessName = processName?.trim().toLowerCase() ?? null;
|
||||
const previousProcessName = lastWindowsVisibleOverlayForegroundProcessName;
|
||||
lastWindowsVisibleOverlayForegroundProcessName = normalizedProcessName;
|
||||
|
||||
if (normalizedProcessName !== previousProcessName) {
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
}
|
||||
if (normalizedProcessName === 'mpv' && previousProcessName !== 'mpv') {
|
||||
requestWindowsVisibleOverlayZOrderSync();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWindowsVisibleOverlayForegroundPollLoop(): void {
|
||||
if (process.platform !== 'win32' || windowsVisibleOverlayForegroundPollInterval !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
|
||||
maybePollWindowsVisibleOverlayForegroundProcess();
|
||||
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function clearWindowsVisibleOverlayForegroundPollLoop(): void {
|
||||
if (windowsVisibleOverlayForegroundPollInterval === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(windowsVisibleOverlayForegroundPollInterval);
|
||||
windowsVisibleOverlayForegroundPollInterval = null;
|
||||
}
|
||||
|
||||
function scheduleVisibleOverlayBlurRefresh(): void {
|
||||
if (process.platform !== 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
||||
clearWindowsVisibleOverlayBlurRefreshTimeouts();
|
||||
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
||||
const refreshTimeout = setTimeout(() => {
|
||||
windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter(
|
||||
(timeout) => timeout !== refreshTimeout,
|
||||
);
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
}, delayMs);
|
||||
windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
ensureWindowsVisibleOverlayForegroundPollLoop();
|
||||
|
||||
const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler(
|
||||
{
|
||||
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
||||
@@ -1957,8 +2225,84 @@ function setOverlayDebugVisualizationEnabled(enabled: boolean): void {
|
||||
overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled);
|
||||
}
|
||||
|
||||
function createOverlayHostedModalOpenDeps(): {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
} {
|
||||
return {
|
||||
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () =>
|
||||
ensureOverlayWindowsReadyForVisibilityActions(),
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||
sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
};
|
||||
}
|
||||
|
||||
function openOverlayHostedModalWithOsd(
|
||||
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
|
||||
unavailableMessage: string,
|
||||
failureLogMessage: string,
|
||||
): void {
|
||||
void openModal(createOverlayHostedModalOpenDeps()).then((opened) => {
|
||||
if (!opened) {
|
||||
showMpvOsd(unavailableMessage);
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.error(failureLogMessage, error);
|
||||
showMpvOsd(unavailableMessage);
|
||||
});
|
||||
}
|
||||
|
||||
function openRuntimeOptionsPalette(): void {
|
||||
overlayVisibilityComposer.openRuntimeOptionsPalette();
|
||||
openOverlayHostedModalWithOsd(
|
||||
openRuntimeOptionsModalRuntime,
|
||||
'Runtime options overlay unavailable.',
|
||||
'Failed to open runtime options overlay.',
|
||||
);
|
||||
}
|
||||
|
||||
function openJimakuOverlay(): void {
|
||||
openOverlayHostedModalWithOsd(
|
||||
openJimakuModalRuntime,
|
||||
'Jimaku overlay unavailable.',
|
||||
'Failed to open Jimaku overlay.',
|
||||
);
|
||||
}
|
||||
|
||||
function openSessionHelpOverlay(): void {
|
||||
openOverlayHostedModalWithOsd(
|
||||
openSessionHelpModalRuntime,
|
||||
'Session help overlay unavailable.',
|
||||
'Failed to open session help overlay.',
|
||||
);
|
||||
}
|
||||
|
||||
function openControllerSelectOverlay(): void {
|
||||
openOverlayHostedModalWithOsd(
|
||||
openControllerSelectModalRuntime,
|
||||
'Controller select overlay unavailable.',
|
||||
'Failed to open controller select overlay.',
|
||||
);
|
||||
}
|
||||
|
||||
function openControllerDebugOverlay(): void {
|
||||
openOverlayHostedModalWithOsd(
|
||||
openControllerDebugModalRuntime,
|
||||
'Controller debug overlay unavailable.',
|
||||
'Failed to open controller debug overlay.',
|
||||
);
|
||||
}
|
||||
|
||||
function openPlaylistBrowser(): void {
|
||||
@@ -1966,16 +2310,11 @@ function openPlaylistBrowser(): void {
|
||||
showMpvOsd('Playlist browser requires active playback.');
|
||||
return;
|
||||
}
|
||||
const opened = openPlaylistBrowserRuntime({
|
||||
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () =>
|
||||
ensureOverlayWindowsReadyForVisibilityActions(),
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||
sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
});
|
||||
if (!opened) {
|
||||
showMpvOsd('Playlist browser overlay unavailable.');
|
||||
}
|
||||
openOverlayHostedModalWithOsd(
|
||||
openPlaylistBrowserRuntime,
|
||||
'Playlist browser overlay unavailable.',
|
||||
'Failed to open playlist browser overlay.',
|
||||
);
|
||||
}
|
||||
|
||||
function getResolvedConfig() {
|
||||
@@ -2746,6 +3085,8 @@ const {
|
||||
annotationSubtitleWsService.stop();
|
||||
},
|
||||
stopTexthookerService: () => texthookerService.stop(),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
clearWindowsVisibleOverlayForegroundPollLoop(),
|
||||
getMainOverlayWindow: () => overlayManager.getMainWindow(),
|
||||
clearMainOverlayWindow: () => overlayManager.setMainWindow(null),
|
||||
getModalOverlayWindow: () => overlayManager.getModalWindow(),
|
||||
@@ -3146,6 +3487,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
loadSubtitlePosition: () => loadSubtitlePosition(),
|
||||
resolveKeybindings: () => {
|
||||
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
|
||||
refreshCurrentSessionBindings();
|
||||
},
|
||||
createMpvClient: () => {
|
||||
appState.mpvClient = createMpvClientRuntimeService();
|
||||
@@ -3288,6 +3630,9 @@ function ensureOverlayStartupPrereqs(): void {
|
||||
}
|
||||
if (appState.keybindings.length === 0) {
|
||||
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
|
||||
refreshCurrentSessionBindings();
|
||||
} else if (!appState.sessionBindingsInitialized) {
|
||||
refreshCurrentSessionBindings();
|
||||
}
|
||||
if (!appState.mpvClient) {
|
||||
appState.mpvClient = createMpvClientRuntimeService();
|
||||
@@ -3674,6 +4019,12 @@ function applyOverlayRegions(geometry: WindowGeometry): void {
|
||||
const buildUpdateVisibleOverlayBoundsMainDepsHandler =
|
||||
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
|
||||
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
|
||||
afterSetOverlayWindowBounds: () => {
|
||||
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
|
||||
return;
|
||||
}
|
||||
scheduleWindowsVisibleOverlayZOrderSyncBurst();
|
||||
},
|
||||
});
|
||||
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
|
||||
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
|
||||
@@ -3796,7 +4147,14 @@ function createModalWindow(): BrowserWindow {
|
||||
}
|
||||
|
||||
function createMainWindow(): BrowserWindow {
|
||||
return createMainWindowHandler();
|
||||
const window = createMainWindowHandler();
|
||||
if (process.platform === 'win32') {
|
||||
const overlayHwnd = getWindowsNativeWindowHandleNumber(window);
|
||||
if (!ensureWindowsOverlayTransparency(overlayHwnd)) {
|
||||
logger.warn('Failed to eagerly extend Windows overlay transparency via koffi');
|
||||
}
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
function ensureTray(): void {
|
||||
@@ -3873,6 +4231,53 @@ const {
|
||||
},
|
||||
});
|
||||
|
||||
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
|
||||
if (process.platform === 'darwin') return 'darwin';
|
||||
if (process.platform === 'win32') return 'win32';
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
function compileCurrentSessionBindings(): {
|
||||
bindings: CompiledSessionBinding[];
|
||||
warnings: ReturnType<typeof compileSessionBindings>['warnings'];
|
||||
} {
|
||||
return compileSessionBindings({
|
||||
keybindings: appState.keybindings,
|
||||
shortcuts: getConfiguredShortcuts(),
|
||||
statsToggleKey: getResolvedConfig().stats.toggleKey,
|
||||
platform: resolveSessionBindingPlatform(),
|
||||
rawConfig: getResolvedConfig(),
|
||||
});
|
||||
}
|
||||
|
||||
function persistSessionBindings(
|
||||
bindings: CompiledSessionBinding[],
|
||||
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
|
||||
): void {
|
||||
const artifact = buildPluginSessionBindingsArtifact({
|
||||
bindings,
|
||||
warnings,
|
||||
numericSelectionTimeoutMs: getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
});
|
||||
writeSessionBindingsArtifact(CONFIG_DIR, artifact);
|
||||
appState.sessionBindings = bindings;
|
||||
appState.sessionBindingsInitialized = true;
|
||||
if (appState.mpvClient?.connected) {
|
||||
sendMpvCommandRuntime(appState.mpvClient, [
|
||||
'script-message',
|
||||
'subminer-reload-session-bindings',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshCurrentSessionBindings(): void {
|
||||
const compiled = compileCurrentSessionBindings();
|
||||
for (const warning of compiled.warnings) {
|
||||
logger.warn(`[session-bindings] ${warning.message}`);
|
||||
}
|
||||
persistSessionBindings(compiled.bindings, compiled.warnings);
|
||||
}
|
||||
|
||||
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
|
||||
appendToMpvLogMainDeps: {
|
||||
logPath: DEFAULT_MPV_LOG_PATH,
|
||||
@@ -3923,6 +4328,10 @@ function handleCycleSecondarySubMode(): void {
|
||||
cycleSecondarySubMode();
|
||||
}
|
||||
|
||||
function toggleSubtitleSidebar(): void {
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle);
|
||||
}
|
||||
|
||||
async function triggerSubsyncFromConfig(): Promise<void> {
|
||||
await subsyncRuntime.triggerFromConfig();
|
||||
}
|
||||
@@ -4184,6 +4593,55 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
});
|
||||
|
||||
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
|
||||
await dispatchSessionActionCore(request, {
|
||||
toggleStatsOverlay: () =>
|
||||
toggleStatsOverlayWindow({
|
||||
staticDir: statsDistPath,
|
||||
preloadPath: statsPreloadPath,
|
||||
getApiBaseUrl: () => ensureStatsServerStarted(),
|
||||
getToggleKey: () => getResolvedConfig().stats.toggleKey,
|
||||
resolveBounds: () => getCurrentOverlayGeometry(),
|
||||
onVisibilityChanged: (visible) => {
|
||||
appState.statsOverlayVisible = visible;
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
},
|
||||
}),
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
copyCurrentSubtitle: () => copyCurrentSubtitle(),
|
||||
copySubtitleCount: (count) => handleMultiCopyDigit(count),
|
||||
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
|
||||
triggerFieldGrouping: () => triggerFieldGrouping(),
|
||||
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
|
||||
mineSentenceCard: () => mineSentenceCard(),
|
||||
mineSentenceCount: (count) => handleMineSentenceDigit(count),
|
||||
toggleSecondarySub: () => handleCycleSecondarySubMode(),
|
||||
toggleSubtitleSidebar: () => toggleSubtitleSidebar(),
|
||||
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openJimaku: () => openJimakuOverlay(),
|
||||
openSessionHelp: () => openSessionHelpOverlay(),
|
||||
openControllerSelect: () => openControllerSelectOverlay(),
|
||||
openControllerDebug: () => openControllerDebugOverlay(),
|
||||
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
||||
openPlaylistBrowser: () => openPlaylistBrowser(),
|
||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
shiftSubtitleDelayToAdjacentCueHandler(direction),
|
||||
cycleRuntimeOption: (id, direction) => {
|
||||
if (!appState.runtimeOptionsManager) {
|
||||
return { ok: false, error: 'Runtime options manager unavailable' };
|
||||
}
|
||||
return applyRuntimeOptionResultRuntime(
|
||||
appState.runtimeOptionsManager.cycleOption(id, direction),
|
||||
(text) => showMpvOsd(text),
|
||||
);
|
||||
},
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
});
|
||||
}
|
||||
|
||||
const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient, {
|
||||
getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages,
|
||||
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
|
||||
@@ -4193,7 +4651,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
mpvCommandMainDeps: {
|
||||
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openJimaku: () => overlayModalRuntime.openJimaku(),
|
||||
openJimaku: () => openJimakuOverlay(),
|
||||
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
||||
openPlaylistBrowser: () => openPlaylistBrowser(),
|
||||
cycleRuntimeOption: (id, direction) => {
|
||||
@@ -4233,7 +4691,17 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
mainWindow.focus();
|
||||
}
|
||||
},
|
||||
onOverlayModalClosed: (modal) => {
|
||||
onOverlayModalClosed: (modal, senderWindow) => {
|
||||
const modalWindow = overlayManager.getModalWindow();
|
||||
if (
|
||||
senderWindow &&
|
||||
modalWindow &&
|
||||
senderWindow === modalWindow &&
|
||||
!senderWindow.isDestroyed()
|
||||
) {
|
||||
senderWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
senderWindow.hide();
|
||||
}
|
||||
handleOverlayModalClosed(modal);
|
||||
},
|
||||
onOverlayModalOpened: (modal) => {
|
||||
@@ -4341,7 +4809,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
|
||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||
getKeybindings: () => appState.keybindings,
|
||||
getSessionBindings: () => appState.sessionBindings,
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
dispatchSessionAction: (request) => dispatchSessionAction(request),
|
||||
getStatsToggleKey: () => getResolvedConfig().stats.toggleKey,
|
||||
getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey,
|
||||
getControllerConfig: () => getResolvedConfig().controller,
|
||||
@@ -4462,6 +4932,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||
stopApp: () => requestAppQuit(),
|
||||
hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
|
||||
dispatchSessionAction: (request: SessionActionDispatchRequest) => dispatchSessionAction(request),
|
||||
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
|
||||
logInfo: (message: string) => logger.info(message),
|
||||
@@ -4595,6 +5066,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
||||
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
|
||||
onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
onWindowClosed: (windowKind) => {
|
||||
if (windowKind === 'visible') {
|
||||
overlayManager.setMainWindow(null);
|
||||
@@ -4696,6 +5169,9 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
updateVisibleOverlayVisibility: () =>
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
},
|
||||
refreshCurrentSubtitle: () => {
|
||||
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||
},
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||
},
|
||||
@@ -4719,6 +5195,40 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
},
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
updateVisibleOverlayBounds(geometry),
|
||||
bindOverlayOwner: () => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
|
||||
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath);
|
||||
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
|
||||
return;
|
||||
}
|
||||
const tracker = appState.windowTracker;
|
||||
const mpvResult = tracker
|
||||
? (() => {
|
||||
try {
|
||||
const win32 = require('./window-trackers/win32') as typeof import('./window-trackers/win32');
|
||||
const poll = win32.findMpvWindows();
|
||||
const focused = poll.matches.find((m) => m.isForeground);
|
||||
return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
if (!mpvResult) return;
|
||||
if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) {
|
||||
logger.warn('Failed to set overlay owner via koffi');
|
||||
}
|
||||
},
|
||||
releaseOverlayOwner: () => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
|
||||
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||
if (!clearWindowsOverlayOwner(overlayHwnd)) {
|
||||
logger.warn('Failed to clear overlay owner via koffi');
|
||||
}
|
||||
},
|
||||
getOverlayWindows: () => getOverlayWindows(),
|
||||
getResolvedConfig: () => getResolvedConfig(),
|
||||
showDesktopNotification,
|
||||
|
||||
@@ -23,7 +23,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
{ kind: string },
|
||||
{ scope: string; warn: () => void; info: () => void; error: () => void },
|
||||
{ registry: boolean },
|
||||
{ getModalWindow: () => null },
|
||||
{ getMainWindow: () => null; getModalWindow: () => null },
|
||||
{
|
||||
inputState: boolean;
|
||||
getModalInputExclusive: () => boolean;
|
||||
@@ -82,6 +82,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
}) as const,
|
||||
createMainRuntimeRegistry: () => ({ registry: true }),
|
||||
createOverlayManager: () => ({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => null,
|
||||
}),
|
||||
createOverlayModalInputState: () => ({
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface MainBootServicesParams<
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
||||
syncOverlayVisibilityForModal: () => void;
|
||||
restoreMainWindowFocus?: () => void;
|
||||
}) => TOverlayModalInputState;
|
||||
createOverlayContentMeasurementStore: (params: {
|
||||
logger: TLogger;
|
||||
@@ -131,7 +132,7 @@ export function createMainBootServices<
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager extends { getModalWindow: () => BrowserWindow | null },
|
||||
TOverlayManager extends { getMainWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null },
|
||||
TOverlayModalInputState extends OverlayModalInputStateShape,
|
||||
TOverlayContentMeasurementStore,
|
||||
TOverlayModalRuntime,
|
||||
@@ -212,6 +213,26 @@ export function createMainBootServices<
|
||||
syncOverlayVisibilityForModal: () => {
|
||||
params.getSyncOverlayVisibilityForModal()();
|
||||
},
|
||||
restoreMainWindowFocus: () => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) return;
|
||||
try {
|
||||
const electron = require('electron') as {
|
||||
app?: { focus?: (options?: { steal?: boolean }) => void };
|
||||
};
|
||||
electron.app?.focus?.({ steal: true });
|
||||
} catch {
|
||||
// Ignore in non-Electron environments.
|
||||
}
|
||||
const maybeFocusable = mainWindow as typeof mainWindow & {
|
||||
setFocusable?: (focusable: boolean) => void;
|
||||
};
|
||||
maybeFocusable.setFocusable?.(true);
|
||||
mainWindow.focus();
|
||||
if (!mainWindow.webContents.isFocused()) {
|
||||
mainWindow.webContents.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
const overlayContentMeasurementStore = params.createOverlayContentMeasurementStore({
|
||||
logger,
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
dispatchSessionAction: CliCommandRuntimeServiceDepsParams['dispatchSessionAction'];
|
||||
getAnilistStatus: CliCommandRuntimeServiceDepsParams['anilist']['getStatus'];
|
||||
clearAnilistToken: CliCommandRuntimeServiceDepsParams['anilist']['clearToken'];
|
||||
openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup'];
|
||||
@@ -113,6 +114,7 @@ function createCliCommandDepsFromContext(
|
||||
hasMainWindow: context.hasMainWindow,
|
||||
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
|
||||
},
|
||||
dispatchSessionAction: context.dispatchSessionAction,
|
||||
ui: {
|
||||
openFirstRunSetup: context.openFirstRunSetup,
|
||||
openYomitanSettings: context.openYomitanSettings,
|
||||
|
||||
@@ -73,7 +73,9 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer'];
|
||||
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
|
||||
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
||||
getSessionBindings: IpcDepsRuntimeOptions['getSessionBindings'];
|
||||
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
||||
dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction'];
|
||||
getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey'];
|
||||
getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey'];
|
||||
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
||||
@@ -178,6 +180,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
||||
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
|
||||
};
|
||||
dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction'];
|
||||
ui: {
|
||||
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
|
||||
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
||||
@@ -233,7 +236,9 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
getMecabTokenizer: params.getMecabTokenizer,
|
||||
handleMpvCommand: params.handleMpvCommand,
|
||||
getKeybindings: params.getKeybindings,
|
||||
getSessionBindings: params.getSessionBindings,
|
||||
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
||||
dispatchSessionAction: params.dispatchSessionAction,
|
||||
getStatsToggleKey: params.getStatsToggleKey,
|
||||
getMarkWatchedKey: params.getMarkWatchedKey,
|
||||
getControllerConfig: params.getControllerConfig,
|
||||
@@ -347,6 +352,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
hasMainWindow: params.app.hasMainWindow,
|
||||
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
|
||||
},
|
||||
dispatchSessionAction: params.dispatchSessionAction,
|
||||
ui: {
|
||||
openFirstRunSetup: params.ui.openFirstRunSetup,
|
||||
openYomitanSettings: params.ui.openYomitanSettings,
|
||||
|
||||
@@ -7,13 +7,16 @@ type MockWindow = {
|
||||
visible: boolean;
|
||||
focused: boolean;
|
||||
ignoreMouseEvents: boolean;
|
||||
forwardedIgnoreMouseEvents: boolean;
|
||||
webContentsFocused: boolean;
|
||||
showCount: number;
|
||||
hideCount: number;
|
||||
sent: unknown[][];
|
||||
loading: boolean;
|
||||
url: string;
|
||||
contentReady: boolean;
|
||||
loadCallbacks: Array<() => void>;
|
||||
readyToShowCallbacks: Array<() => void>;
|
||||
};
|
||||
|
||||
function createMockWindow(): MockWindow & {
|
||||
@@ -28,7 +31,11 @@ function createMockWindow(): MockWindow & {
|
||||
getHideCount: () => number;
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
destroy: () => void;
|
||||
focus: () => void;
|
||||
emitDidFinishLoad: () => void;
|
||||
emitReadyToShow: () => void;
|
||||
once: (event: 'ready-to-show', cb: () => void) => void;
|
||||
webContents: {
|
||||
focused: boolean;
|
||||
isLoading: () => boolean;
|
||||
@@ -44,13 +51,16 @@ function createMockWindow(): MockWindow & {
|
||||
visible: false,
|
||||
focused: false,
|
||||
ignoreMouseEvents: false,
|
||||
forwardedIgnoreMouseEvents: false,
|
||||
webContentsFocused: false,
|
||||
showCount: 0,
|
||||
hideCount: 0,
|
||||
sent: [],
|
||||
loading: false,
|
||||
url: 'file:///overlay/index.html?layer=modal',
|
||||
contentReady: true,
|
||||
loadCallbacks: [],
|
||||
readyToShowCallbacks: [],
|
||||
};
|
||||
const window = {
|
||||
...state,
|
||||
@@ -58,8 +68,9 @@ function createMockWindow(): MockWindow & {
|
||||
isVisible: () => state.visible,
|
||||
isFocused: () => state.focused,
|
||||
getURL: () => state.url,
|
||||
setIgnoreMouseEvents: (ignore: boolean, _options?: { forward?: boolean }) => {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
state.ignoreMouseEvents = ignore;
|
||||
state.forwardedIgnoreMouseEvents = options?.forward === true;
|
||||
},
|
||||
setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {},
|
||||
moveTop: () => {},
|
||||
@@ -73,9 +84,28 @@ function createMockWindow(): MockWindow & {
|
||||
state.visible = false;
|
||||
state.hideCount += 1;
|
||||
},
|
||||
destroy: () => {
|
||||
state.destroyed = true;
|
||||
state.visible = false;
|
||||
},
|
||||
focus: () => {
|
||||
state.focused = true;
|
||||
},
|
||||
emitDidFinishLoad: () => {
|
||||
const callbacks = state.loadCallbacks.splice(0);
|
||||
for (const callback of callbacks) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
emitReadyToShow: () => {
|
||||
const callbacks = state.readyToShowCallbacks.splice(0);
|
||||
for (const callback of callbacks) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
once: (_event: 'ready-to-show', cb: () => void) => {
|
||||
state.readyToShowCallbacks.push(cb);
|
||||
},
|
||||
webContents: {
|
||||
isLoading: () => state.loading,
|
||||
getURL: () => state.url,
|
||||
@@ -139,6 +169,25 @@ function createMockWindow(): MockWindow & {
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'forwardedIgnoreMouseEvents', {
|
||||
get: () => state.forwardedIgnoreMouseEvents,
|
||||
set: (value: boolean) => {
|
||||
state.forwardedIgnoreMouseEvents = value;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'contentReady', {
|
||||
get: () => state.contentReady,
|
||||
set: (value: boolean) => {
|
||||
state.contentReady = value;
|
||||
(window as typeof window & { __subminerOverlayContentReady?: boolean }).__subminerOverlayContentReady =
|
||||
value;
|
||||
},
|
||||
});
|
||||
|
||||
(window as typeof window & { __subminerOverlayContentReady?: boolean }).__subminerOverlayContentReady =
|
||||
state.contentReady;
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
@@ -195,10 +244,29 @@ test('sendToActiveOverlayWindow creates modal window lazily when absent', () =>
|
||||
assert.deepEqual(window.sent, [['jimaku:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow does not retain restore state when modal creation fails', () => {
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => null,
|
||||
createModalWindow: () => null,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().has('runtime-options'), false);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow waits for blank modal URL before sending open command', () => {
|
||||
const window = createMockWindow();
|
||||
window.url = '';
|
||||
window.loading = true;
|
||||
window.contentReady = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
@@ -215,11 +283,13 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
assert.equal(window.loadCallbacks.length, 1);
|
||||
window.loading = false;
|
||||
window.url = 'file:///overlay/index.html?layer=modal';
|
||||
window.loadCallbacks[0]!();
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
@@ -248,10 +318,10 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
|
||||
);
|
||||
|
||||
runtime.handleOverlayModalClosed('runtime-options');
|
||||
assert.equal(window.getHideCount(), 0);
|
||||
assert.equal(window.isDestroyed(), false);
|
||||
|
||||
runtime.handleOverlayModalClosed('subsync');
|
||||
assert.equal(window.getHideCount(), 1);
|
||||
assert.equal(window.isDestroyed(), true);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow prefers visible main overlay window for modal open', () => {
|
||||
@@ -325,11 +395,12 @@ test('modal window path makes visible main overlay click-through until modal clo
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.equal(mainWindow.ignoreMouseEvents, true);
|
||||
assert.equal(mainWindow.forwardedIgnoreMouseEvents, true);
|
||||
assert.equal(modalWindow.ignoreMouseEvents, false);
|
||||
|
||||
runtime.handleOverlayModalClosed('youtube-track-picker');
|
||||
|
||||
assert.equal(mainWindow.ignoreMouseEvents, false);
|
||||
assert.equal(mainWindow.ignoreMouseEvents, true);
|
||||
});
|
||||
|
||||
test('modal window path hides visible main overlay until modal closes', () => {
|
||||
@@ -359,8 +430,8 @@ test('modal window path hides visible main overlay until modal closes', () => {
|
||||
|
||||
runtime.handleOverlayModalClosed('youtube-track-picker');
|
||||
|
||||
assert.equal(mainWindow.getShowCount(), 1);
|
||||
assert.equal(mainWindow.isVisible(), true);
|
||||
assert.equal(mainWindow.getShowCount(), 0);
|
||||
assert.equal(mainWindow.isVisible(), false);
|
||||
});
|
||||
|
||||
test('modal runtime notifies callers when modal input state becomes active/inactive', () => {
|
||||
@@ -437,7 +508,7 @@ test('notifyOverlayModalOpened enables input on visible main overlay window when
|
||||
assert.equal(mainWindow.webContentsFocused, true);
|
||||
});
|
||||
|
||||
test('handleOverlayModalClosed resets modal state even when modal window does not exist', () => {
|
||||
test('handleOverlayModalClosed is a no-op when no modal window can be targeted', () => {
|
||||
const state: boolean[] = [];
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
@@ -454,16 +525,17 @@ test('handleOverlayModalClosed resets modal state even when modal window does no
|
||||
},
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
assert.equal(sent, false);
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
runtime.handleOverlayModalClosed('runtime-options');
|
||||
|
||||
assert.deepEqual(state, [true, false]);
|
||||
assert.deepEqual(state, []);
|
||||
});
|
||||
|
||||
test('handleOverlayModalClosed hides modal window for single kiku modal', () => {
|
||||
test('handleOverlayModalClosed destroys modal window for single kiku modal', () => {
|
||||
const window = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
@@ -482,12 +554,56 @@ test('handleOverlayModalClosed hides modal window for single kiku modal', () =>
|
||||
);
|
||||
runtime.handleOverlayModalClosed('kiku');
|
||||
|
||||
assert.equal(window.getHideCount(), 1);
|
||||
assert.equal(window.isDestroyed(), true);
|
||||
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0);
|
||||
});
|
||||
|
||||
test('modal fallback reveal keeps mouse events ignored until modal confirms open', async () => {
|
||||
test('modal fallback reveal skips showing window when content is not ready', async () => {
|
||||
const window = createMockWindow();
|
||||
let scheduledReveal: (() => void) | null = null;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => {
|
||||
throw new Error('modal window should not be created when already present');
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
}, {
|
||||
scheduleRevealFallback: (callback) => {
|
||||
scheduledReveal = callback;
|
||||
return { scheduled: true } as never;
|
||||
},
|
||||
clearRevealFallback: () => {
|
||||
scheduledReveal = null;
|
||||
},
|
||||
});
|
||||
|
||||
window.loading = true;
|
||||
window.url = '';
|
||||
window.contentReady = false;
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('jimaku:open', undefined, {
|
||||
restoreOnModalClose: 'jimaku',
|
||||
});
|
||||
|
||||
assert.equal(sent, true);
|
||||
if (scheduledReveal === null) {
|
||||
throw new Error('expected reveal callback');
|
||||
}
|
||||
const runScheduledReveal: () => void = scheduledReveal;
|
||||
runScheduledReveal();
|
||||
|
||||
assert.equal(window.getShowCount(), 0);
|
||||
|
||||
runtime.notifyOverlayModalOpened('jimaku');
|
||||
assert.equal(window.getShowCount(), 1);
|
||||
assert.equal(window.ignoreMouseEvents, false);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering open event', () => {
|
||||
const window = createMockWindow();
|
||||
window.contentReady = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
@@ -498,32 +614,162 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
window.loading = true;
|
||||
window.url = '';
|
||||
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('jimaku:open', undefined, {
|
||||
restoreOnModalClose: 'jimaku',
|
||||
assert.equal(sent, true);
|
||||
assert.deepEqual(window.sent, []);
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow flushes every queued load and ready listener before sending', () => {
|
||||
const window = createMockWindow();
|
||||
window.contentReady = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => {
|
||||
throw new Error('modal window should not be created when already present');
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('session-help:open', undefined, {
|
||||
restoreOnModalClose: 'session-help',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
|
||||
});
|
||||
|
||||
test('modal reopen creates a fresh window after close destroys the previous one', () => {
|
||||
const firstWindow = createMockWindow();
|
||||
const secondWindow = createMockWindow();
|
||||
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
|
||||
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => currentModal as never,
|
||||
createModalWindow: () => {
|
||||
currentModal = secondWindow;
|
||||
return secondWindow as never;
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
runtime.handleOverlayModalClosed('runtime-options');
|
||||
|
||||
assert.equal(firstWindow.isDestroyed(), true);
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.equal(currentModal, secondWindow);
|
||||
assert.equal(secondWindow.getShowCount(), 0);
|
||||
});
|
||||
|
||||
test('modal reopen after close-destroy notifies state change on fresh window lifecycle', () => {
|
||||
const firstWindow = createMockWindow();
|
||||
const secondWindow = createMockWindow();
|
||||
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
|
||||
const state: boolean[] = [];
|
||||
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => currentModal as never,
|
||||
createModalWindow: () => {
|
||||
currentModal = secondWindow;
|
||||
return secondWindow as never;
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{
|
||||
onModalStateChange: (active: boolean): void => {
|
||||
state.push(active);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
runtime.handleOverlayModalClosed('runtime-options');
|
||||
|
||||
assert.deepEqual(state, [true, false]);
|
||||
assert.equal(firstWindow.isDestroyed(), true);
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
|
||||
assert.deepEqual(state, [true, false, true]);
|
||||
assert.equal(currentModal, secondWindow);
|
||||
});
|
||||
|
||||
test('visible stale modal window is made interactive again before reopening', () => {
|
||||
const window = createMockWindow();
|
||||
window.visible = true;
|
||||
window.focused = true;
|
||||
window.webContentsFocused = false;
|
||||
window.ignoreMouseEvents = true;
|
||||
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => window as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.equal(window.ignoreMouseEvents, false);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 260);
|
||||
});
|
||||
|
||||
assert.equal(window.getShowCount(), 1);
|
||||
assert.equal(window.ignoreMouseEvents, false);
|
||||
|
||||
runtime.notifyOverlayModalOpened('jimaku');
|
||||
assert.equal(window.ignoreMouseEvents, false);
|
||||
assert.equal(window.isFocused(), true);
|
||||
assert.equal(window.webContentsFocused, true);
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('waitForModalOpen resolves true after modal acknowledgement', async () => {
|
||||
const modalWindow = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => null,
|
||||
createModalWindow: () => null,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { WindowGeometry } from '../types';
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags';
|
||||
|
||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||
|
||||
function requestOverlayApplicationFocus(): void {
|
||||
try {
|
||||
const electron = require('electron') as {
|
||||
app?: {
|
||||
focus?: (options?: { steal?: boolean }) => void;
|
||||
};
|
||||
};
|
||||
electron.app?.focus?.({ steal: true });
|
||||
} catch {
|
||||
// Ignore focus-steal failures in non-Electron test environments.
|
||||
}
|
||||
}
|
||||
|
||||
function setWindowFocusable(window: BrowserWindow): void {
|
||||
const maybeFocusableWindow = window as BrowserWindow & {
|
||||
setFocusable?: (focusable: boolean) => void;
|
||||
};
|
||||
maybeFocusableWindow.setFocusable?.(true);
|
||||
}
|
||||
|
||||
export interface OverlayWindowResolver {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
@@ -29,8 +50,15 @@ export interface OverlayModalRuntime {
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||
}
|
||||
|
||||
type RevealFallbackHandle = NonNullable<Parameters<typeof globalThis.clearTimeout>[0]>;
|
||||
|
||||
export interface OverlayModalRuntimeOptions {
|
||||
onModalStateChange?: (isActive: boolean) => void;
|
||||
scheduleRevealFallback?: (
|
||||
callback: () => void,
|
||||
delayMs: number,
|
||||
) => RevealFallbackHandle;
|
||||
clearRevealFallback?: (timeout: RevealFallbackHandle) => void;
|
||||
}
|
||||
|
||||
export function createOverlayModalRuntimeService(
|
||||
@@ -42,8 +70,16 @@ export function createOverlayModalRuntimeService(
|
||||
let modalActive = false;
|
||||
let mainWindowMousePassthroughForcedByModal = false;
|
||||
let mainWindowHiddenByModal = false;
|
||||
let modalWindowPrimedForImmediateShow = false;
|
||||
let pendingModalWindowReveal: BrowserWindow | null = null;
|
||||
let pendingModalWindowRevealTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
|
||||
const scheduleRevealFallback = (
|
||||
callback: () => void,
|
||||
delayMs: number,
|
||||
): RevealFallbackHandle =>
|
||||
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
|
||||
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
|
||||
(options.clearRevealFallback ?? globalThis.clearTimeout)(timeout);
|
||||
|
||||
const notifyModalStateChange = (nextState: boolean): void => {
|
||||
if (modalActive === nextState) return;
|
||||
@@ -87,9 +123,21 @@ export function createOverlayModalRuntimeService(
|
||||
};
|
||||
|
||||
const isWindowReadyForIpc = (window: BrowserWindow): boolean => {
|
||||
if (window.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
if (window.webContents.isLoading()) {
|
||||
return false;
|
||||
}
|
||||
const overlayWindow = window as BrowserWindow & {
|
||||
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
|
||||
};
|
||||
if (
|
||||
typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' &&
|
||||
overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const currentURL = window.webContents.getURL();
|
||||
return currentURL !== '' && currentURL !== 'about:blank';
|
||||
};
|
||||
@@ -109,11 +157,17 @@ export function createOverlayModalRuntimeService(
|
||||
return;
|
||||
}
|
||||
|
||||
window.webContents.once('did-finish-load', () => {
|
||||
if (!window.isDestroyed() && !window.webContents.isLoading()) {
|
||||
sendNow(window);
|
||||
let delivered = false;
|
||||
const deliverWhenReady = (): void => {
|
||||
if (delivered || window.isDestroyed() || !isWindowReadyForIpc(window)) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
delivered = true;
|
||||
sendNow(window);
|
||||
};
|
||||
|
||||
window.webContents.once('did-finish-load', deliverWhenReady);
|
||||
window.once('ready-to-show', deliverWhenReady);
|
||||
};
|
||||
|
||||
const showModalWindow = (
|
||||
@@ -122,6 +176,8 @@ export function createOverlayModalRuntimeService(
|
||||
passThroughMouseEvents: boolean;
|
||||
} = { passThroughMouseEvents: false },
|
||||
): void => {
|
||||
setWindowFocusable(window);
|
||||
requestOverlayApplicationFocus();
|
||||
if (!window.isVisible()) {
|
||||
window.show();
|
||||
}
|
||||
@@ -138,15 +194,14 @@ export function createOverlayModalRuntimeService(
|
||||
};
|
||||
|
||||
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
|
||||
setWindowFocusable(window);
|
||||
requestOverlayApplicationFocus();
|
||||
window.setIgnoreMouseEvents(false);
|
||||
elevateModalWindow(window);
|
||||
|
||||
if (window.isVisible()) {
|
||||
window.setIgnoreMouseEvents(false);
|
||||
if (!window.isFocused()) {
|
||||
window.focus();
|
||||
}
|
||||
if (!window.webContents.isFocused()) {
|
||||
window.webContents.focus();
|
||||
}
|
||||
elevateModalWindow(window);
|
||||
window.focus();
|
||||
window.webContents.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -166,7 +221,7 @@ export function createOverlayModalRuntimeService(
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(pendingModalWindowRevealTimeout);
|
||||
clearRevealFallback(pendingModalWindowRevealTimeout);
|
||||
pendingModalWindowRevealTimeout = null;
|
||||
pendingModalWindowReveal = null;
|
||||
};
|
||||
@@ -225,12 +280,15 @@ export function createOverlayModalRuntimeService(
|
||||
return;
|
||||
}
|
||||
|
||||
pendingModalWindowRevealTimeout = setTimeout(() => {
|
||||
pendingModalWindowRevealTimeout = scheduleRevealFallback(() => {
|
||||
const targetWindow = pendingModalWindowReveal;
|
||||
clearPendingModalWindowReveal();
|
||||
if (!targetWindow || targetWindow.isDestroyed() || targetWindow.isVisible()) {
|
||||
return;
|
||||
}
|
||||
if (!isWindowReadyForIpc(targetWindow)) {
|
||||
return;
|
||||
}
|
||||
showModalWindow(targetWindow, { passThroughMouseEvents: false });
|
||||
}, MODAL_REVEAL_FALLBACK_DELAY_MS);
|
||||
};
|
||||
@@ -256,9 +314,9 @@ export function createOverlayModalRuntimeService(
|
||||
};
|
||||
|
||||
if (restoreOnModalClose) {
|
||||
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
|
||||
const mainWindow = getTargetOverlayWindow();
|
||||
if (!preferModalWindow && mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
|
||||
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
|
||||
sendOrQueueForWindow(mainWindow, (window) => {
|
||||
if (payload === undefined) {
|
||||
window.webContents.send(channel);
|
||||
@@ -272,15 +330,23 @@ export function createOverlayModalRuntimeService(
|
||||
const modalWindow = resolveModalWindow();
|
||||
if (!modalWindow) return false;
|
||||
|
||||
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
|
||||
deps.setModalWindowBounds(deps.getModalGeometry());
|
||||
const wasVisible = modalWindow.isVisible();
|
||||
if (!wasVisible) {
|
||||
scheduleModalWindowReveal(modalWindow);
|
||||
if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) {
|
||||
showModalWindow(modalWindow);
|
||||
} else {
|
||||
scheduleModalWindowReveal(modalWindow);
|
||||
}
|
||||
} else if (!modalWindow.isFocused()) {
|
||||
showModalWindow(modalWindow);
|
||||
}
|
||||
|
||||
sendOrQueueForWindow(modalWindow, (window) => {
|
||||
if (window.isVisible()) {
|
||||
ensureModalWindowInteractive(window);
|
||||
}
|
||||
if (payload === undefined) {
|
||||
window.webContents.send(channel);
|
||||
} else {
|
||||
@@ -320,12 +386,13 @@ export function createOverlayModalRuntimeService(
|
||||
const modalWindow = deps.getModalWindow();
|
||||
if (restoreVisibleOverlayOnModalClose.size === 0) {
|
||||
clearPendingModalWindowReveal();
|
||||
notifyModalStateChange(false);
|
||||
setMainWindowMousePassthroughForModal(false);
|
||||
setMainWindowVisibilityForModal(false);
|
||||
if (modalWindow && !modalWindow.isDestroyed()) {
|
||||
modalWindow.hide();
|
||||
modalWindow.destroy();
|
||||
}
|
||||
modalWindowPrimedForImmediateShow = false;
|
||||
mainWindowMousePassthroughForcedByModal = false;
|
||||
mainWindowHiddenByModal = false;
|
||||
notifyModalStateChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -350,14 +417,7 @@ export function createOverlayModalRuntimeService(
|
||||
}
|
||||
|
||||
if (targetWindow.isVisible()) {
|
||||
targetWindow.setIgnoreMouseEvents(false);
|
||||
elevateModalWindow(targetWindow);
|
||||
if (!targetWindow.isFocused()) {
|
||||
targetWindow.focus();
|
||||
}
|
||||
if (!targetWindow.webContents.isFocused()) {
|
||||
targetWindow.webContents.focus();
|
||||
}
|
||||
ensureModalWindowInteractive(targetWindow);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,14 @@ export interface OverlayVisibilityRuntimeDeps {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getForceMousePassthrough: () => boolean;
|
||||
getWindowTracker: () => BaseWindowTracker | null;
|
||||
getLastKnownWindowsForegroundProcessName?: () => string | null;
|
||||
getWindowsOverlayProcessName?: () => string | null;
|
||||
getWindowsFocusHandoffGraceActive?: () => boolean;
|
||||
getTrackerNotReadyWarningShown: () => 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;
|
||||
@@ -36,12 +40,20 @@ export function createOverlayVisibilityRuntimeService(
|
||||
|
||||
return {
|
||||
updateVisibleOverlayVisibility(): void {
|
||||
const visibleOverlayVisible = deps.getVisibleOverlayVisible();
|
||||
const forceMousePassthrough = deps.getForceMousePassthrough();
|
||||
const windowTracker = deps.getWindowTracker();
|
||||
const mainWindow = deps.getMainWindow();
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
|
||||
visibleOverlayVisible,
|
||||
modalActive: deps.getModalActive(),
|
||||
forceMousePassthrough: deps.getForceMousePassthrough(),
|
||||
mainWindow: deps.getMainWindow(),
|
||||
windowTracker: deps.getWindowTracker(),
|
||||
forceMousePassthrough,
|
||||
mainWindow,
|
||||
windowTracker,
|
||||
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
|
||||
windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null,
|
||||
windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false,
|
||||
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
deps.setTrackerNotReadyWarningShown(shown);
|
||||
@@ -49,6 +61,8 @@ export function createOverlayVisibilityRuntimeService(
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
deps.updateVisibleOverlayBounds(geometry),
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
|
||||
syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) =>
|
||||
deps.syncWindowsOverlayToMpvZOrder?.(window),
|
||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') =>
|
||||
deps.syncPrimaryOverlayWindowLayer(layer),
|
||||
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
||||
|
||||
@@ -16,6 +16,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => calls.push('clear-windows-visible-overlay-poll'),
|
||||
destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'),
|
||||
destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'),
|
||||
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
|
||||
@@ -40,9 +41,10 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 28);
|
||||
assert.equal(calls.length, 29);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
|
||||
destroyMainOverlayWindow: () => void;
|
||||
destroyModalOverlayWindow: () => void;
|
||||
destroyYomitanParserWindow: () => void;
|
||||
@@ -36,6 +37,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.unregisterAllGlobalShortcuts();
|
||||
deps.stopSubtitleWebsocket();
|
||||
deps.stopTexthookerService();
|
||||
deps.clearWindowsVisibleOverlayForegroundPollLoop();
|
||||
deps.destroyMainOverlayWindow();
|
||||
deps.destroyModalOverlayWindow();
|
||||
deps.destroyYomitanParserWindow();
|
||||
|
||||
@@ -18,6 +18,8 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
|
||||
getMainOverlayWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
destroy: () => calls.push('destroy-main-overlay-window'),
|
||||
@@ -85,6 +87,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||
assert.ok(calls.includes('stop-discord-presence'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
||||
assert.equal(reconnectTimer, null);
|
||||
assert.equal(immersionTracker, null);
|
||||
});
|
||||
@@ -99,6 +102,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||
getMainOverlayWindow: () => ({
|
||||
isDestroyed: () => true,
|
||||
destroy: () => calls.push('destroy-main-overlay-window'),
|
||||
|
||||
@@ -25,6 +25,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
|
||||
getMainOverlayWindow: () => DestroyableWindow | null;
|
||||
clearMainOverlayWindow: () => void;
|
||||
getModalOverlayWindow: () => DestroyableWindow | null;
|
||||
@@ -64,6 +65,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
|
||||
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
|
||||
stopTexthookerService: () => deps.stopTexthookerService(),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
deps.clearWindowsVisibleOverlayForegroundPollLoop(),
|
||||
destroyMainOverlayWindow: () => {
|
||||
const window = deps.getMainOverlayWindow();
|
||||
if (!window) return;
|
||||
|
||||
@@ -42,6 +42,7 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
markLastCardAsAudioCard: async () => {
|
||||
calls.push('mark');
|
||||
},
|
||||
dispatchSessionAction: async () => {},
|
||||
getAnilistStatus: () => ({}) as never,
|
||||
clearAnilistToken: () => calls.push('clear-token'),
|
||||
openAnilistSetup: () => calls.push('anilist'),
|
||||
|
||||
@@ -28,6 +28,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction'];
|
||||
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
|
||||
clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken'];
|
||||
openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup'];
|
||||
@@ -77,6 +78,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
triggerFieldGrouping: deps.triggerFieldGrouping,
|
||||
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
||||
markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
|
||||
dispatchSessionAction: deps.dispatchSessionAction,
|
||||
getAnilistStatus: deps.getAnilistStatus,
|
||||
clearAnilistToken: deps.clearAnilistToken,
|
||||
openAnilistSetup: deps.openAnilistSetup,
|
||||
|
||||
@@ -37,6 +37,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
triggerFieldGrouping: async () => {},
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
dispatchSessionAction: async () => {},
|
||||
getAnilistStatus: () => ({
|
||||
tokenStatus: 'resolved',
|
||||
tokenSource: 'literal',
|
||||
|
||||
@@ -53,6 +53,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
markLastCardAsAudioCard: async () => {
|
||||
calls.push('mark-audio');
|
||||
},
|
||||
dispatchSessionAction: async () => {},
|
||||
|
||||
getAnilistStatus: () => ({
|
||||
tokenStatus: 'resolved',
|
||||
|
||||
@@ -39,6 +39,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction'];
|
||||
|
||||
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
|
||||
clearAnilistToken: () => void;
|
||||
@@ -103,6 +104,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
triggerFieldGrouping: () => deps.triggerFieldGrouping(),
|
||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||
markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(),
|
||||
dispatchSessionAction: (request) => deps.dispatchSessionAction(request),
|
||||
getAnilistStatus: () => deps.getAnilistStatus(),
|
||||
clearAnilistToken: () => deps.clearAnilistToken(),
|
||||
openAnilistSetup: () => deps.openAnilistSetupWindow(),
|
||||
|
||||
@@ -36,6 +36,7 @@ function createDeps() {
|
||||
triggerFieldGrouping: async () => {},
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
dispatchSessionAction: async () => {},
|
||||
getAnilistStatus: () => ({}) as never,
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
|
||||
@@ -33,6 +33,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
dispatchSessionAction: CliCommandRuntimeServiceContext['dispatchSessionAction'];
|
||||
getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus'];
|
||||
clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken'];
|
||||
openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup'];
|
||||
@@ -89,6 +90,7 @@ export function createCliCommandContext(
|
||||
triggerFieldGrouping: deps.triggerFieldGrouping,
|
||||
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
||||
markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
|
||||
dispatchSessionAction: deps.dispatchSessionAction,
|
||||
getAnilistStatus: deps.getAnilistStatus,
|
||||
clearAnilistToken: deps.clearAnilistToken,
|
||||
openAnilistSetup: deps.openAnilistSetup,
|
||||
|
||||
@@ -30,6 +30,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
||||
triggerFieldGrouping: async () => {},
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
dispatchSessionAction: async () => {},
|
||||
getAnilistStatus: () => ({}) as never,
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetupWindow: () => {},
|
||||
|
||||
@@ -53,7 +53,9 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
saveSubtitlePosition: () => {},
|
||||
getMecabTokenizer: () => null,
|
||||
getKeybindings: () => [],
|
||||
getSessionBindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}) as never,
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getControllerConfig: () => ({}) as never,
|
||||
|
||||
@@ -21,6 +21,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||
getMainOverlayWindow: () => null,
|
||||
clearMainOverlayWindow: () => {},
|
||||
getModalOverlayWindow: () => null,
|
||||
|
||||
@@ -11,9 +11,14 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const calls: string[] = [];
|
||||
const ankiPatches: Array<{ enabled: boolean }> = [];
|
||||
const sessionBindingWarnings: string[][] = [];
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
setKeybindings: () => calls.push('set:keybindings'),
|
||||
setSessionBindings: (_sessionBindings, warnings) => {
|
||||
calls.push('set:session-bindings');
|
||||
sessionBindingWarnings.push(warnings.map((warning) => warning.message));
|
||||
},
|
||||
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
|
||||
setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`),
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
@@ -37,11 +42,18 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
);
|
||||
|
||||
assert.ok(calls.includes('set:keybindings'));
|
||||
assert.ok(calls.includes('set:session-bindings'));
|
||||
assert.ok(calls.includes('refresh:shortcuts'));
|
||||
assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`));
|
||||
assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:')));
|
||||
assert.ok(calls.includes('broadcast:config:hot-reload:object'));
|
||||
assert.deepEqual(ankiPatches, [{ enabled: config.ankiConnect.ai.enabled }]);
|
||||
assert.equal(sessionBindingWarnings.length, 1);
|
||||
assert.ok(
|
||||
sessionBindingWarnings[0]?.some((message) =>
|
||||
message.includes('Rename shortcuts.toggleVisibleOverlayGlobal'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('createConfigHotReloadAppliedHandler skips optional effects when no hot fields', () => {
|
||||
@@ -50,6 +62,7 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
setKeybindings: () => calls.push('set:keybindings'),
|
||||
setSessionBindings: () => calls.push('set:session-bindings'),
|
||||
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
|
||||
setSecondarySubMode: () => calls.push('set:secondary'),
|
||||
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
|
||||
@@ -64,7 +77,35 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie
|
||||
config,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['set:keybindings']);
|
||||
assert.deepEqual(calls, ['set:keybindings', 'set:session-bindings']);
|
||||
});
|
||||
|
||||
test('createConfigHotReloadAppliedHandler forwards compiled session-binding warnings', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
config.shortcuts.openSessionHelp = 'Ctrl+?';
|
||||
const warnings: string[][] = [];
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
setKeybindings: () => {},
|
||||
setSessionBindings: (_sessionBindings, sessionBindingWarnings) => {
|
||||
warnings.push(sessionBindingWarnings.map((warning) => warning.message));
|
||||
},
|
||||
refreshGlobalAndOverlayShortcuts: () => {},
|
||||
setSecondarySubMode: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
applyAnkiRuntimeConfigPatch: () => {},
|
||||
});
|
||||
|
||||
applyHotReload(
|
||||
{
|
||||
hotReloadFields: ['shortcuts'],
|
||||
restartRequiredFields: [],
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.ok(warnings[0]?.some((message) => message.includes('Unsupported accelerator key token')));
|
||||
});
|
||||
|
||||
test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload';
|
||||
import { compileSessionBindings } from '../../core/services/session-bindings';
|
||||
import { resolveKeybindings } from '../../core/utils/keybindings';
|
||||
import { DEFAULT_KEYBINDINGS } from '../../config';
|
||||
import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config';
|
||||
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
|
||||
|
||||
type ConfigHotReloadAppliedDeps = {
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
|
||||
setSessionBindings: (
|
||||
sessionBindings: ConfigHotReloadPayload['sessionBindings'],
|
||||
sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'],
|
||||
) => void;
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
@@ -33,8 +39,23 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
|
||||
}
|
||||
|
||||
export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload {
|
||||
const keybindings = resolveKeybindings(config, DEFAULT_KEYBINDINGS);
|
||||
const { bindings: sessionBindings, warnings: sessionBindingWarnings } = compileSessionBindings({
|
||||
keybindings,
|
||||
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
|
||||
statsToggleKey: config.stats.toggleKey,
|
||||
platform:
|
||||
process.platform === 'darwin'
|
||||
? 'darwin'
|
||||
: process.platform === 'win32'
|
||||
? 'win32'
|
||||
: 'linux',
|
||||
rawConfig: config,
|
||||
});
|
||||
return {
|
||||
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
|
||||
keybindings,
|
||||
sessionBindings,
|
||||
sessionBindingWarnings,
|
||||
subtitleStyle: resolveSubtitleStyleForRenderer(config),
|
||||
subtitleSidebar: config.subtitleSidebar,
|
||||
secondarySubMode: config.secondarySub.defaultMode,
|
||||
@@ -45,6 +66,7 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
||||
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
|
||||
const payload = buildConfigHotReloadPayload(config);
|
||||
deps.setKeybindings(payload.keybindings);
|
||||
deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
|
||||
|
||||
if (diff.hotReloadFields.includes('shortcuts')) {
|
||||
deps.refreshGlobalAndOverlayShortcuts();
|
||||
|
||||
@@ -86,26 +86,35 @@ test('config hot reload message main deps builder maps notifications', () => {
|
||||
|
||||
test('config hot reload applied main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildConfigHotReloadAppliedMainDepsHandler({
|
||||
const warningCounts: number[] = [];
|
||||
const buildDeps = createBuildConfigHotReloadAppliedMainDepsHandler({
|
||||
setKeybindings: () => calls.push('keybindings'),
|
||||
setSessionBindings: (_sessionBindings, warnings) => {
|
||||
calls.push('session-bindings');
|
||||
warningCounts.push(warnings.length);
|
||||
},
|
||||
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'),
|
||||
setSecondarySubMode: () => calls.push('set-secondary'),
|
||||
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
|
||||
applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'),
|
||||
})();
|
||||
});
|
||||
|
||||
const deps = buildDeps();
|
||||
deps.setKeybindings([]);
|
||||
deps.setSessionBindings([], []);
|
||||
deps.refreshGlobalAndOverlayShortcuts();
|
||||
deps.setSecondarySubMode('hover');
|
||||
deps.broadcastToOverlayWindows('config:hot-reload', {});
|
||||
deps.applyAnkiRuntimeConfigPatch({ ai: true });
|
||||
assert.deepEqual(calls, [
|
||||
'keybindings',
|
||||
'session-bindings',
|
||||
'refresh-shortcuts',
|
||||
'set-secondary',
|
||||
'broadcast:config:hot-reload',
|
||||
'apply-anki',
|
||||
]);
|
||||
assert.deepEqual(warningCounts, [0]);
|
||||
});
|
||||
|
||||
test('config hot reload runtime main deps builder maps runtime callbacks', () => {
|
||||
|
||||
@@ -62,6 +62,10 @@ export function createBuildConfigHotReloadMessageMainDepsHandler(
|
||||
|
||||
export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
|
||||
setSessionBindings: (
|
||||
sessionBindings: ConfigHotReloadPayload['sessionBindings'],
|
||||
sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'],
|
||||
) => void;
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
@@ -72,6 +76,10 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
||||
return () => ({
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
|
||||
deps.setKeybindings(keybindings),
|
||||
setSessionBindings: (
|
||||
sessionBindings: ConfigHotReloadPayload['sessionBindings'],
|
||||
sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'],
|
||||
) => deps.setSessionBindings(sessionBindings, sessionBindingWarnings),
|
||||
refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(),
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) =>
|
||||
|
||||
48
src/main/runtime/controller-debug-open.ts
Normal file
48
src/main/runtime/controller-debug-open.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const CONTROLLER_DEBUG_MODAL: OverlayHostedModal = 'controller-debug';
|
||||
const CONTROLLER_DEBUG_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openControllerDebugModal(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: CONTROLLER_DEBUG_MODAL,
|
||||
timeoutMs: CONTROLLER_DEBUG_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Controller debug modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.event.controllerDebugOpen,
|
||||
modal: CONTROLLER_DEBUG_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
48
src/main/runtime/controller-select-open.ts
Normal file
48
src/main/runtime/controller-select-open.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const CONTROLLER_SELECT_MODAL: OverlayHostedModal = 'controller-select';
|
||||
const CONTROLLER_SELECT_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openControllerSelectModal(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: CONTROLLER_SELECT_MODAL,
|
||||
timeoutMs: CONTROLLER_SELECT_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Controller select modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.event.controllerSelectOpen,
|
||||
modal: CONTROLLER_SELECT_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,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,
|
||||
@@ -79,6 +93,41 @@ test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
|
||||
});
|
||||
|
||||
test('shouldAutoOpenFirstRunSetup treats numeric startup counts as explicit commands', () => {
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ start: true, copySubtitleCount: 2 })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, mineSentenceCount: 1 })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldAutoOpenFirstRunSetup treats session and stats startup commands as explicit commands', () => {
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ start: true, toggleSubtitleSidebar: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, openSessionHelp: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ start: true, openControllerSelect: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, openControllerDebug: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, stats: true })), false);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinSubtitleUrlsOnly: true })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('setup service auto-completes legacy installs with config and dictionaries', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
|
||||
@@ -68,26 +68,43 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.hideVisibleOverlay ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
args.mineSentence ||
|
||||
args.mineSentenceMultiple ||
|
||||
args.mineSentenceCount !== undefined ||
|
||||
args.updateLastCardFromClipboard ||
|
||||
args.refreshKnownWords ||
|
||||
args.toggleSecondarySub ||
|
||||
args.triggerFieldGrouping ||
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.toggleStatsOverlay ||
|
||||
args.toggleSubtitleSidebar ||
|
||||
args.openRuntimeOptions ||
|
||||
args.openSessionHelp ||
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.anilistStatus ||
|
||||
args.anilistLogout ||
|
||||
args.anilistSetup ||
|
||||
args.anilistRetryQueue ||
|
||||
args.dictionary ||
|
||||
args.stats ||
|
||||
args.jellyfin ||
|
||||
args.jellyfinLogin ||
|
||||
args.jellyfinLogout ||
|
||||
args.jellyfinLibraries ||
|
||||
args.jellyfinItems ||
|
||||
args.jellyfinSubtitles ||
|
||||
args.jellyfinSubtitleUrlsOnly ||
|
||||
args.jellyfinPlay ||
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.jellyfinPreviewAuth ||
|
||||
|
||||
@@ -18,6 +18,10 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
markAudioCard: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
markAudioCard: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -161,6 +161,44 @@ test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv
|
||||
assert.ok(calls.includes('info:Auto-connecting MPV client for immersion tracking'));
|
||||
});
|
||||
|
||||
test('createImmersionTrackerStartupHandler keeps tracker startup alive when mpv auto-connect throws', () => {
|
||||
const calls: string[] = [];
|
||||
const trackerInstance = { kind: 'tracker' };
|
||||
let assignedTracker: unknown = null;
|
||||
const handler = createImmersionTrackerStartupHandler({
|
||||
getResolvedConfig: () => makeConfig(),
|
||||
getConfiguredDbPath: () => '/tmp/subminer.db',
|
||||
createTrackerService: () => trackerInstance,
|
||||
setTracker: (nextTracker) => {
|
||||
assignedTracker = nextTracker;
|
||||
},
|
||||
getMpvClient: () => ({
|
||||
connected: false,
|
||||
connect: () => {
|
||||
throw new Error('socket not ready');
|
||||
},
|
||||
}),
|
||||
seedTrackerFromCurrentMedia: () => calls.push('seedTracker'),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
logWarn: (message, details) => calls.push(`warn:${message}:${(details as Error).message}`),
|
||||
});
|
||||
|
||||
handler();
|
||||
|
||||
assert.equal(assignedTracker, trackerInstance);
|
||||
assert.ok(calls.includes('seedTracker'));
|
||||
assert.ok(
|
||||
calls.includes(
|
||||
'warn:MPV auto-connect failed during immersion tracker startup; continuing.:socket not ready',
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
calls.some((entry) => entry.startsWith('warn:Immersion tracker startup failed; disabling tracking.')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('createImmersionTrackerStartupHandler disables tracker on failure', () => {
|
||||
const calls: string[] = [];
|
||||
let assignedTracker: unknown = 'initial';
|
||||
|
||||
@@ -102,7 +102,11 @@ export function createImmersionTrackerStartupHandler(
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if ((deps.shouldAutoConnectMpv?.() ?? true) && mpvClient && !mpvClient.connected) {
|
||||
deps.logInfo('Auto-connecting MPV client for immersion tracking');
|
||||
mpvClient.connect();
|
||||
try {
|
||||
mpvClient.connect();
|
||||
} catch (error) {
|
||||
deps.logWarn('MPV auto-connect failed during immersion tracker startup; continuing.', error);
|
||||
}
|
||||
}
|
||||
deps.seedTrackerFromCurrentMedia();
|
||||
} catch (error) {
|
||||
|
||||
48
src/main/runtime/jimaku-open.ts
Normal file
48
src/main/runtime/jimaku-open.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const JIMAKU_MODAL: OverlayHostedModal = 'jimaku';
|
||||
const JIMAKU_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openJimakuModal(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: JIMAKU_MODAL,
|
||||
timeoutMs: JIMAKU_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Jimaku modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.event.jimakuOpen,
|
||||
modal: JIMAKU_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
66
src/main/runtime/overlay-hosted-modal-open.test.ts
Normal file
66
src/main/runtime/overlay-hosted-modal-open.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { openOverlayHostedModal } from './overlay-hosted-modal-open';
|
||||
|
||||
test('openOverlayHostedModal ensures overlay readiness before sending the open event', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const opened = openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: () => {
|
||||
calls.push('ensureOverlayStartupPrereqs');
|
||||
},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {
|
||||
calls.push('ensureOverlayWindowsReadyForVisibilityActions');
|
||||
},
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => {
|
||||
calls.push(`send:${channel}`);
|
||||
assert.equal(payload, undefined);
|
||||
assert.deepEqual(runtimeOptions, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
preferModalWindow: undefined,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: 'runtime-options:open',
|
||||
modal: 'runtime-options',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(calls, [
|
||||
'ensureOverlayStartupPrereqs',
|
||||
'ensureOverlayWindowsReadyForVisibilityActions',
|
||||
'send:runtime-options:open',
|
||||
]);
|
||||
});
|
||||
|
||||
test('openOverlayHostedModal forwards payload and modal-window preference', () => {
|
||||
const payload = { sessionId: 'yt-1' };
|
||||
|
||||
const opened = openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||
sendToActiveOverlayWindow: (channel, forwardedPayload, runtimeOptions) => {
|
||||
assert.equal(channel, 'youtube:picker-open');
|
||||
assert.deepEqual(forwardedPayload, payload);
|
||||
assert.deepEqual(runtimeOptions, {
|
||||
restoreOnModalClose: 'youtube-track-picker',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: 'youtube:picker-open',
|
||||
modal: 'youtube-track-picker',
|
||||
payload,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(opened, false);
|
||||
});
|
||||
57
src/main/runtime/overlay-hosted-modal-open.ts
Normal file
57
src/main/runtime/overlay-hosted-modal-open.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
|
||||
export function openOverlayHostedModal(
|
||||
deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
},
|
||||
input: {
|
||||
channel: string;
|
||||
modal: OverlayHostedModal;
|
||||
payload?: unknown;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
): boolean {
|
||||
deps.ensureOverlayStartupPrereqs();
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions();
|
||||
return deps.sendToActiveOverlayWindow(input.channel, input.payload, {
|
||||
restoreOnModalClose: input.modal,
|
||||
preferModalWindow: input.preferModalWindow,
|
||||
});
|
||||
}
|
||||
|
||||
export async function retryOverlayModalOpen(
|
||||
deps: {
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
},
|
||||
input: {
|
||||
modal: OverlayHostedModal;
|
||||
timeoutMs: number;
|
||||
retryWarning: string;
|
||||
sendOpen: () => boolean;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
if (!input.sendOpen()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await deps.waitForModalOpen(input.modal, input.timeoutMs)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
deps.logWarn(input.retryWarning);
|
||||
if (!input.sendOpen()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await deps.waitForModalOpen(input.modal, input.timeoutMs);
|
||||
}
|
||||
@@ -23,6 +23,9 @@ function createModalWindow() {
|
||||
setIgnoreMouseEvents: (ignore: boolean) => {
|
||||
calls.push(`ignore:${ignore}`);
|
||||
},
|
||||
setFocusable: (focusable: boolean) => {
|
||||
calls.push(`focusable:${focusable}`);
|
||||
},
|
||||
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
|
||||
calls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`);
|
||||
},
|
||||
@@ -58,6 +61,7 @@ test('overlay modal input state activates modal window interactivity and syncs d
|
||||
|
||||
assert.equal(state.getModalInputExclusive(), true);
|
||||
assert.deepEqual(modalWindow.calls, [
|
||||
'focusable:true',
|
||||
'ignore:false',
|
||||
'top:true:screen-saver:1',
|
||||
'focus',
|
||||
@@ -66,6 +70,25 @@ test('overlay modal input state activates modal window interactivity and syncs d
|
||||
assert.deepEqual(calls, ['shortcuts:true', 'visibility']);
|
||||
});
|
||||
|
||||
test('overlay modal input state restores main window focus on deactivation', () => {
|
||||
const modalWindow = createModalWindow();
|
||||
const calls: string[] = [];
|
||||
const state = createOverlayModalInputState({
|
||||
getModalWindow: () => modalWindow as never,
|
||||
syncOverlayShortcutsForModal: () => {},
|
||||
syncOverlayVisibilityForModal: () => {},
|
||||
restoreMainWindowFocus: () => {
|
||||
calls.push('restore-focus');
|
||||
},
|
||||
});
|
||||
|
||||
state.handleModalInputStateChange(true);
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
state.handleModalInputStateChange(false);
|
||||
assert.deepEqual(calls, ['restore-focus']);
|
||||
});
|
||||
|
||||
test('overlay modal input state is idempotent for unchanged state', () => {
|
||||
const calls: string[] = [];
|
||||
const state = createOverlayModalInputState({
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
function requestOverlayApplicationFocus(): void {
|
||||
try {
|
||||
const electron = require('electron') as {
|
||||
app?: {
|
||||
focus?: (options?: { steal?: boolean }) => void;
|
||||
};
|
||||
};
|
||||
electron.app?.focus?.({ steal: true });
|
||||
} catch {
|
||||
// Ignore focus-steal failures in non-Electron test environments.
|
||||
}
|
||||
}
|
||||
|
||||
function setWindowFocusable(window: BrowserWindow): void {
|
||||
const maybeFocusableWindow = window as BrowserWindow & {
|
||||
setFocusable?: (focusable: boolean) => void;
|
||||
};
|
||||
maybeFocusableWindow.setFocusable?.(true);
|
||||
}
|
||||
|
||||
export type OverlayModalInputStateDeps = {
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
||||
syncOverlayVisibilityForModal: () => void;
|
||||
restoreMainWindowFocus?: () => void;
|
||||
};
|
||||
|
||||
export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
|
||||
@@ -18,6 +39,8 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
|
||||
if (isActive) {
|
||||
const modalWindow = deps.getModalWindow();
|
||||
if (modalWindow && !modalWindow.isDestroyed()) {
|
||||
setWindowFocusable(modalWindow);
|
||||
requestOverlayApplicationFocus();
|
||||
modalWindow.setIgnoreMouseEvents(false);
|
||||
modalWindow.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
modalWindow.focus();
|
||||
@@ -29,6 +52,9 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
|
||||
|
||||
deps.syncOverlayShortcutsForModal(isActive);
|
||||
deps.syncOverlayVisibilityForModal();
|
||||
if (!isActive) {
|
||||
deps.restoreMainWindowFocus?.();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -31,6 +31,8 @@ type InitializeOverlayRuntimeCore = (options: {
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
}) => void;
|
||||
|
||||
export function createInitializeOverlayRuntimeHandler(deps: {
|
||||
|
||||
@@ -23,6 +23,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
|
||||
},
|
||||
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
||||
},
|
||||
@@ -53,6 +54,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
||||
deps.registerGlobalShortcuts();
|
||||
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
deps.refreshCurrentSubtitle?.();
|
||||
deps.syncOverlayShortcuts();
|
||||
deps.showDesktopNotification('title', {});
|
||||
|
||||
@@ -68,6 +70,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
||||
'register-shortcuts',
|
||||
'visible-bounds',
|
||||
'update-visible',
|
||||
'refresh-subtitle',
|
||||
'sync-shortcuts',
|
||||
'notify',
|
||||
]);
|
||||
|
||||
@@ -21,6 +21,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
};
|
||||
refreshCurrentSubtitle?: () => void;
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => void;
|
||||
};
|
||||
@@ -39,6 +40,8 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
}) {
|
||||
return (): OverlayRuntimeOptionsMainDeps => ({
|
||||
getBackendOverride: () => deps.appState.backendOverride,
|
||||
@@ -53,6 +56,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
|
||||
updateVisibleOverlayVisibility: () =>
|
||||
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(),
|
||||
getOverlayWindows: () => deps.getOverlayWindows(),
|
||||
syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||
setWindowTracker: (tracker) => {
|
||||
@@ -71,5 +75,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
||||
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
||||
bindOverlayOwner: deps.bindOverlayOwner,
|
||||
releaseOverlayOwner: deps.releaseOverlayOwner,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
||||
updateVisibleOverlayBounds: () => calls.push('update-visible-bounds'),
|
||||
isVisibleOverlayVisible: () => true,
|
||||
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
|
||||
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
|
||||
getOverlayWindows: () => [],
|
||||
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
||||
setWindowTracker: () => calls.push('set-tracker'),
|
||||
@@ -41,6 +42,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
||||
options.registerGlobalShortcuts();
|
||||
options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||
options.updateVisibleOverlayVisibility();
|
||||
options.refreshCurrentSubtitle?.();
|
||||
options.syncOverlayShortcuts();
|
||||
options.setWindowTracker(null);
|
||||
options.setAnkiIntegration(null);
|
||||
@@ -51,6 +53,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
||||
'register-shortcuts',
|
||||
'update-visible-bounds',
|
||||
'update-visible',
|
||||
'refresh-subtitle',
|
||||
'sync-shortcuts',
|
||||
'set-tracker',
|
||||
'set-anki',
|
||||
|
||||
@@ -14,6 +14,7 @@ type OverlayRuntimeOptions = {
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
refreshCurrentSubtitle?: () => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
@@ -35,6 +36,8 @@ type OverlayRuntimeOptions = {
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
};
|
||||
|
||||
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
@@ -44,6 +47,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
refreshCurrentSubtitle?: () => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
@@ -65,6 +69,8 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
}) {
|
||||
return (): OverlayRuntimeOptions => ({
|
||||
backendOverride: deps.getBackendOverride(),
|
||||
@@ -73,6 +79,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds,
|
||||
isVisibleOverlayVisible: deps.isVisibleOverlayVisible,
|
||||
updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility,
|
||||
refreshCurrentSubtitle: deps.refreshCurrentSubtitle,
|
||||
getOverlayWindows: deps.getOverlayWindows,
|
||||
syncOverlayShortcuts: deps.syncOverlayShortcuts,
|
||||
setWindowTracker: deps.setWindowTracker,
|
||||
@@ -87,5 +94,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
||||
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
||||
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
||||
bindOverlayOwner: deps.bindOverlayOwner,
|
||||
releaseOverlayOwner: deps.releaseOverlayOwner,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getForceMousePassthrough: () => true,
|
||||
getWindowTracker: () => tracker,
|
||||
getLastKnownWindowsForegroundProcessName: () => 'mpv',
|
||||
getWindowsOverlayProcessName: () => 'subminer',
|
||||
getWindowsFocusHandoffGraceActive: () => true,
|
||||
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
|
||||
setTrackerNotReadyWarningShown: (shown) => {
|
||||
trackerNotReadyWarningShown = shown;
|
||||
@@ -23,6 +26,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
},
|
||||
updateVisibleOverlayBounds: () => calls.push('visible-bounds'),
|
||||
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
|
||||
syncWindowsOverlayToMpvZOrder: () => calls.push('sync-windows-z-order'),
|
||||
syncPrimaryOverlayWindowLayer: (layer) => calls.push(`primary-layer:${layer}`),
|
||||
enforceOverlayLayerOrder: () => calls.push('enforce-order'),
|
||||
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
||||
@@ -36,10 +40,14 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
assert.equal(deps.getModalActive(), true);
|
||||
assert.equal(deps.getVisibleOverlayVisible(), true);
|
||||
assert.equal(deps.getForceMousePassthrough(), true);
|
||||
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
|
||||
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
|
||||
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
|
||||
assert.equal(deps.getTrackerNotReadyWarningShown(), false);
|
||||
deps.setTrackerNotReadyWarningShown(true);
|
||||
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||
deps.ensureOverlayWindowLevel(mainWindow);
|
||||
deps.syncWindowsOverlayToMpvZOrder?.(mainWindow);
|
||||
deps.syncPrimaryOverlayWindowLayer('visible');
|
||||
deps.enforceOverlayLayerOrder();
|
||||
deps.syncOverlayShortcuts();
|
||||
@@ -52,6 +60,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
'tracker-warning:true',
|
||||
'visible-bounds',
|
||||
'ensure-level',
|
||||
'sync-windows-z-order',
|
||||
'primary-layer:visible',
|
||||
'enforce-order',
|
||||
'sync-shortcuts',
|
||||
|
||||
@@ -11,11 +11,17 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
||||
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
||||
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
||||
getWindowTracker: () => deps.getWindowTracker(),
|
||||
getLastKnownWindowsForegroundProcessName: () =>
|
||||
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
|
||||
getWindowsOverlayProcessName: () => deps.getWindowsOverlayProcessName?.() ?? null,
|
||||
getWindowsFocusHandoffGraceActive: () => deps.getWindowsFocusHandoffGraceActive?.() ?? false,
|
||||
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
deps.updateVisibleOverlayBounds(geometry),
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
|
||||
syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) =>
|
||||
deps.syncWindowsOverlayToMpvZOrder?.(window),
|
||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => deps.syncPrimaryOverlayWindowLayer(layer),
|
||||
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
||||
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
||||
|
||||
@@ -11,6 +11,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
@@ -22,6 +24,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||
getYomitanSession?: () => Session | null;
|
||||
}) {
|
||||
@@ -34,6 +38,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
isOverlayVisible: deps.isOverlayVisible,
|
||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||
forwardTabToMpv: deps.forwardTabToMpv,
|
||||
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
|
||||
onWindowContentReady: deps.onWindowContentReady,
|
||||
onWindowClosed: deps.onWindowClosed,
|
||||
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
@@ -24,6 +26,8 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||
getYomitanSession?: () => Session | null;
|
||||
}) {
|
||||
@@ -36,6 +40,8 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
isOverlayVisible: deps.isOverlayVisible,
|
||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||
forwardTabToMpv: deps.forwardTabToMpv,
|
||||
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
|
||||
onWindowContentReady: deps.onWindowContentReady,
|
||||
onWindowClosed: deps.onWindowClosed,
|
||||
yomitanSession: deps.getYomitanSession?.() ?? null,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler(
|
||||
) {
|
||||
return (): UpdateVisibleOverlayBoundsMainDeps => ({
|
||||
setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry),
|
||||
afterSetOverlayWindowBounds: (geometry) => deps.afterSetOverlayWindowBounds?.(geometry),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,22 @@ test('visible bounds handler writes visible layer geometry', () => {
|
||||
assert.deepEqual(calls, [geometry]);
|
||||
});
|
||||
|
||||
test('visible bounds handler runs follow-up callback after applying geometry', () => {
|
||||
const calls: string[] = [];
|
||||
const geometry = { x: 0, y: 0, width: 100, height: 50 };
|
||||
const handleVisible = createUpdateVisibleOverlayBoundsHandler({
|
||||
setOverlayWindowBounds: () => calls.push('set-bounds'),
|
||||
afterSetOverlayWindowBounds: (nextGeometry) => {
|
||||
assert.deepEqual(nextGeometry, geometry);
|
||||
calls.push('after-bounds');
|
||||
},
|
||||
});
|
||||
|
||||
handleVisible(geometry);
|
||||
|
||||
assert.deepEqual(calls, ['set-bounds', 'after-bounds']);
|
||||
});
|
||||
|
||||
test('ensure overlay window level handler delegates to core', () => {
|
||||
const calls: string[] = [];
|
||||
const ensureLevel = createEnsureOverlayWindowLevelHandler({
|
||||
|
||||
@@ -2,9 +2,11 @@ import type { WindowGeometry } from '../../types';
|
||||
|
||||
export function createUpdateVisibleOverlayBoundsHandler(deps: {
|
||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||
afterSetOverlayWindowBounds?: (geometry: WindowGeometry) => void;
|
||||
}) {
|
||||
return (geometry: WindowGeometry): void => {
|
||||
deps.setOverlayWindowBounds(geometry);
|
||||
deps.afterSetOverlayWindowBounds?.(geometry);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ import test from 'node:test';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openPlaylistBrowser } from './playlist-browser-open';
|
||||
|
||||
test('playlist browser open bootstraps overlay runtime before dispatching the modal event', () => {
|
||||
test('playlist browser open bootstraps overlay runtime and sends modal event with preferModalWindow', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const opened = openPlaylistBrowser({
|
||||
const opened = await openPlaylistBrowser({
|
||||
ensureOverlayStartupPrereqs: () => {
|
||||
calls.push('prereqs');
|
||||
},
|
||||
@@ -18,11 +18,31 @@ test('playlist browser open bootstraps overlay runtime before dispatching the mo
|
||||
assert.equal(payload, undefined);
|
||||
assert.deepEqual(runtimeOptions, {
|
||||
restoreOnModalClose: 'playlist-browser',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: async () => true,
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(calls, ['prereqs', 'windows', `send:${IPC_CHANNELS.event.playlistBrowserOpen}`]);
|
||||
});
|
||||
|
||||
test('playlist browser open retries after first attempt timeout', async () => {
|
||||
let attempt = 0;
|
||||
const opened = await openPlaylistBrowser({
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||
sendToActiveOverlayWindow: () => true,
|
||||
waitForModalOpen: async () => {
|
||||
attempt += 1;
|
||||
return attempt >= 2;
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.equal(attempt, 2);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const PLAYLIST_BROWSER_MODAL: OverlayHostedModal = 'playlist-browser';
|
||||
const PLAYLIST_BROWSER_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export function openPlaylistBrowser(deps: {
|
||||
export async function openPlaylistBrowser(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
@@ -14,10 +16,33 @@ export function openPlaylistBrowser(deps: {
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
}): boolean {
|
||||
deps.ensureOverlayStartupPrereqs();
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions();
|
||||
return deps.sendToActiveOverlayWindow(IPC_CHANNELS.event.playlistBrowserOpen, undefined, {
|
||||
restoreOnModalClose: PLAYLIST_BROWSER_MODAL,
|
||||
});
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: PLAYLIST_BROWSER_MODAL,
|
||||
timeoutMs: PLAYLIST_BROWSER_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Playlist browser modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.event.playlistBrowserOpen,
|
||||
modal: PLAYLIST_BROWSER_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
99
src/main/runtime/runtime-options-open.test.ts
Normal file
99
src/main/runtime/runtime-options-open.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { openRuntimeOptionsModal } from './runtime-options-open';
|
||||
|
||||
test('runtime options open prefers dedicated modal window on first attempt', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const opened = await openRuntimeOptionsModal({
|
||||
ensureOverlayStartupPrereqs: () => {
|
||||
calls.push('ensure-startup');
|
||||
},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {
|
||||
calls.push('ensure-windows');
|
||||
},
|
||||
sendToActiveOverlayWindow: (channel, payload, options) => {
|
||||
calls.push(`send:${channel}`);
|
||||
assert.equal(payload, undefined);
|
||||
assert.deepEqual(options, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: async (modal, timeoutMs) => {
|
||||
assert.equal(modal, 'runtime-options');
|
||||
assert.equal(timeoutMs, 1500);
|
||||
return true;
|
||||
},
|
||||
logWarn: () => {
|
||||
throw new Error('should not warn on first-attempt success');
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(calls, ['ensure-startup', 'ensure-windows', 'send:runtime-options:open']);
|
||||
});
|
||||
|
||||
test('runtime options open retries after an open timeout', async () => {
|
||||
const calls: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
let waitCalls = 0;
|
||||
|
||||
const opened = await openRuntimeOptionsModal({
|
||||
ensureOverlayStartupPrereqs: () => {
|
||||
calls.push('ensure-startup');
|
||||
},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {
|
||||
calls.push('ensure-windows');
|
||||
},
|
||||
sendToActiveOverlayWindow: (channel, payload, options) => {
|
||||
calls.push(`send:${channel}`);
|
||||
assert.equal(payload, undefined);
|
||||
assert.deepEqual(options, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: async (modal, timeoutMs) => {
|
||||
assert.equal(modal, 'runtime-options');
|
||||
assert.equal(timeoutMs, 1500);
|
||||
waitCalls += 1;
|
||||
return waitCalls === 2;
|
||||
},
|
||||
logWarn: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(calls, [
|
||||
'ensure-startup',
|
||||
'ensure-windows',
|
||||
'send:runtime-options:open',
|
||||
'ensure-startup',
|
||||
'ensure-windows',
|
||||
'send:runtime-options:open',
|
||||
]);
|
||||
assert.deepEqual(warnings, [
|
||||
'Runtime options modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('runtime options open fails when no overlay window can be targeted', async () => {
|
||||
let waitCalls = 0;
|
||||
const opened = await openRuntimeOptionsModal({
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||
sendToActiveOverlayWindow: () => false,
|
||||
waitForModalOpen: async () => {
|
||||
waitCalls += 1;
|
||||
return true;
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.equal(opened, false);
|
||||
assert.equal(waitCalls, 0);
|
||||
});
|
||||
47
src/main/runtime/runtime-options-open.ts
Normal file
47
src/main/runtime/runtime-options-open.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const RUNTIME_OPTIONS_MODAL: OverlayHostedModal = 'runtime-options';
|
||||
const RUNTIME_OPTIONS_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openRuntimeOptionsModal(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: RUNTIME_OPTIONS_MODAL,
|
||||
timeoutMs: RUNTIME_OPTIONS_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Runtime options modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: 'runtime-options:open',
|
||||
modal: RUNTIME_OPTIONS_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
17
src/main/runtime/session-bindings-artifact.ts
Normal file
17
src/main/runtime/session-bindings-artifact.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { PluginSessionBindingsArtifact } from '../../types';
|
||||
|
||||
export function getSessionBindingsArtifactPath(configDir: string): string {
|
||||
return path.join(configDir, 'session-bindings.json');
|
||||
}
|
||||
|
||||
export function writeSessionBindingsArtifact(
|
||||
configDir: string,
|
||||
artifact: PluginSessionBindingsArtifact,
|
||||
): string {
|
||||
const artifactPath = getSessionBindingsArtifactPath(configDir);
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
|
||||
return artifactPath;
|
||||
}
|
||||
48
src/main/runtime/session-help-open.ts
Normal file
48
src/main/runtime/session-help-open.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const SESSION_HELP_MODAL: OverlayHostedModal = 'session-help';
|
||||
const SESSION_HELP_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openSessionHelpModal(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: SESSION_HELP_MODAL,
|
||||
timeoutMs: SESSION_HELP_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Session help modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.event.sessionHelpOpen,
|
||||
modal: SESSION_HELP_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { YoutubePickerOpenPayload } from '../../types';
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const YOUTUBE_PICKER_MODAL: OverlayHostedModal = 'youtube-track-picker';
|
||||
const YOUTUBE_PICKER_OPEN_TIMEOUT_MS = 1500;
|
||||
@@ -19,24 +20,21 @@ export async function openYoutubeTrackPicker(
|
||||
},
|
||||
payload: YoutubePickerOpenPayload,
|
||||
): Promise<boolean> {
|
||||
const sendPickerOpen = (): boolean =>
|
||||
deps.sendToActiveOverlayWindow('youtube:picker-open', payload, {
|
||||
restoreOnModalClose: YOUTUBE_PICKER_MODAL,
|
||||
preferModalWindow: true,
|
||||
});
|
||||
|
||||
if (!sendPickerOpen()) {
|
||||
return false;
|
||||
}
|
||||
if (await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
deps.logWarn(
|
||||
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: YOUTUBE_PICKER_MODAL,
|
||||
timeoutMs: YOUTUBE_PICKER_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
deps.sendToActiveOverlayWindow('youtube:picker-open', payload, {
|
||||
restoreOnModalClose: YOUTUBE_PICKER_MODAL,
|
||||
preferModalWindow: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!sendPickerOpen()) {
|
||||
return false;
|
||||
}
|
||||
return await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
@@ -113,3 +113,12 @@ test('applyStartupState preserves cleared startup-only runtime flags', () => {
|
||||
|
||||
assert.equal(appState.initialArgs?.settings, true);
|
||||
});
|
||||
|
||||
test('createAppState starts with session bindings marked uninitialized', () => {
|
||||
const appState = createAppState({
|
||||
mpvSocketPath: '/tmp/mpv.sock',
|
||||
texthookerPort: 4000,
|
||||
});
|
||||
|
||||
assert.equal(appState.sessionBindingsInitialized, false);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
|
||||
import type {
|
||||
CompiledSessionBinding,
|
||||
Keybinding,
|
||||
MpvSubtitleRenderMetrics,
|
||||
SecondarySubMode,
|
||||
@@ -170,6 +171,8 @@ export interface AppState {
|
||||
anilistClientSecretState: AnilistSecretResolutionState;
|
||||
mecabTokenizer: MecabTokenizer | null;
|
||||
keybindings: Keybinding[];
|
||||
sessionBindings: CompiledSessionBinding[];
|
||||
sessionBindingsInitialized: boolean;
|
||||
subtitleTimingTracker: SubtitleTimingTracker | null;
|
||||
immersionTracker: ImmersionTrackerService | null;
|
||||
ankiIntegration: AnkiIntegration | null;
|
||||
@@ -252,6 +255,8 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
||||
anilistClientSecretState: createInitialAnilistSecretResolutionState(),
|
||||
mecabTokenizer: null,
|
||||
keybindings: [],
|
||||
sessionBindings: [],
|
||||
sessionBindingsInitialized: false,
|
||||
subtitleTimingTracker: null,
|
||||
immersionTracker: null,
|
||||
ankiIntegration: null,
|
||||
|
||||
@@ -123,6 +123,9 @@ function createQueuedIpcListenerWithPayload<T>(
|
||||
}
|
||||
|
||||
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
||||
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
|
||||
const onOpenControllerSelectEvent = createQueuedIpcListener(IPC_CHANNELS.event.controllerSelectOpen);
|
||||
const onOpenControllerDebugEvent = createQueuedIpcListener(IPC_CHANNELS.event.controllerDebugOpen);
|
||||
const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen);
|
||||
const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<YoutubePickerOpenPayload>(
|
||||
IPC_CHANNELS.event.youtubePickerOpen,
|
||||
@@ -142,6 +145,9 @@ const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload<SubsyncManua
|
||||
IPC_CHANNELS.event.subsyncOpenManual,
|
||||
(payload) => payload as SubsyncManualPayload,
|
||||
);
|
||||
const onSubtitleSidebarToggleEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.subtitleSidebarToggle,
|
||||
);
|
||||
const onKikuFieldGroupingRequestEvent =
|
||||
createQueuedIpcListenerWithPayload<KikuFieldGroupingRequestData>(
|
||||
IPC_CHANNELS.event.kikuFieldGroupingRequest,
|
||||
@@ -223,8 +229,11 @@ const electronAPI: ElectronAPI = {
|
||||
|
||||
getKeybindings: (): Promise<Keybinding[]> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
|
||||
getSessionBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSessionBindings),
|
||||
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
|
||||
dispatchSessionAction: (actionId, payload) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.command.dispatchSessionAction, { actionId, payload }),
|
||||
getStatsToggleKey: (): Promise<string> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey),
|
||||
getMarkWatchedKey: (): Promise<string> =>
|
||||
@@ -323,9 +332,13 @@ const electronAPI: ElectronAPI = {
|
||||
);
|
||||
},
|
||||
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
|
||||
onOpenSessionHelp: onOpenSessionHelpEvent,
|
||||
onOpenControllerSelect: onOpenControllerSelectEvent,
|
||||
onOpenControllerDebug: onOpenControllerDebugEvent,
|
||||
onOpenJimaku: onOpenJimakuEvent,
|
||||
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
||||
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
|
||||
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
|
||||
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
|
||||
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
|
||||
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||
|
||||
105
src/prerelease-workflow.test.ts
Normal file
105
src/prerelease-workflow.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml');
|
||||
const prereleaseWorkflow = readFileSync(prereleaseWorkflowPath, 'utf8').replace(/\r\n/g, '\n');
|
||||
const packageJsonPath = resolve(__dirname, '../package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
|
||||
test('prerelease workflow triggers on beta and rc tags only', () => {
|
||||
assert.match(prereleaseWorkflow, /name: Prerelease/);
|
||||
const tagsBlock = prereleaseWorkflow.match(/tags:\s*\n((?:\s*-\s*'[^']+'\s*\n?)+)/);
|
||||
assert.ok(tagsBlock, 'Workflow tags block not found');
|
||||
const tagsText = tagsBlock[1];
|
||||
assert.ok(tagsText, 'Workflow tags entries not found');
|
||||
const tagPatterns = [...tagsText.matchAll(/-\s*'([^']+)'/g)].map(([, pattern]) => pattern);
|
||||
assert.deepEqual(tagPatterns, ['v*-beta.*', 'v*-rc.*']);
|
||||
});
|
||||
|
||||
test('package scripts expose prerelease notes generation separately from stable changelog build', () => {
|
||||
assert.equal(
|
||||
packageJson.scripts['changelog:prerelease-notes'],
|
||||
'bun run scripts/build-changelog.ts prerelease-notes',
|
||||
);
|
||||
});
|
||||
|
||||
test('prerelease workflow generates prerelease notes from pending fragments', () => {
|
||||
assert.match(prereleaseWorkflow, /bun run changelog:prerelease-notes --version/);
|
||||
assert.doesNotMatch(prereleaseWorkflow, /bun run changelog:build --version/);
|
||||
});
|
||||
|
||||
test('prerelease workflow includes the environment suite in the gate sequence', () => {
|
||||
assert.match(
|
||||
prereleaseWorkflow,
|
||||
/Test suite \(source\)\n\s*run: bun run test:fast\n\s*\n\s*- name: Environment suite(?: \(source\))?\n\s*run: bun run test:env\n\s*\n\s*- name: Coverage suite \(maintained source lane\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('prerelease workflow publishes GitHub prereleases and keeps them off latest', () => {
|
||||
assert.match(prereleaseWorkflow, /gh release edit[\s\S]*--prerelease/);
|
||||
assert.match(prereleaseWorkflow, /gh release create[\s\S]*--prerelease/);
|
||||
assert.match(prereleaseWorkflow, /gh release create[\s\S]*--latest=false/);
|
||||
});
|
||||
|
||||
test('prerelease workflow scopes dependency caches by runner architecture', () => {
|
||||
const archScopedCacheKeyMatches = prereleaseWorkflow.match(
|
||||
/key:\s*\${{\s*runner\.os\s*}}-\${{\s*runner\.arch\s*}}-bun-/g,
|
||||
);
|
||||
const archScopedRestoreKeyMatches = prereleaseWorkflow.match(
|
||||
/\${{\s*runner\.os\s*}}-\${{\s*runner\.arch\s*}}-bun-/g,
|
||||
);
|
||||
assert.equal(archScopedCacheKeyMatches?.length, 5);
|
||||
assert.ok((archScopedRestoreKeyMatches?.length ?? 0) >= 10);
|
||||
});
|
||||
|
||||
test('prerelease workflow builds and uploads all release platforms', () => {
|
||||
assert.match(prereleaseWorkflow, /build-linux:/);
|
||||
assert.match(prereleaseWorkflow, /build-macos:/);
|
||||
assert.match(prereleaseWorkflow, /build-windows:/);
|
||||
assert.match(prereleaseWorkflow, /name: appimage/);
|
||||
assert.match(prereleaseWorkflow, /name: macos/);
|
||||
assert.match(prereleaseWorkflow, /name: windows/);
|
||||
});
|
||||
|
||||
test('prerelease workflow publishes the same release assets as the stable workflow', () => {
|
||||
assert.match(
|
||||
prereleaseWorkflow,
|
||||
/files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz dist\/launcher\/subminer\)/,
|
||||
);
|
||||
assert.match(
|
||||
prereleaseWorkflow,
|
||||
/artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('prerelease workflow writes checksum entries using release asset basenames', () => {
|
||||
assert.match(prereleaseWorkflow, /: > release\/SHA256SUMS\.txt/);
|
||||
assert.match(prereleaseWorkflow, /for file in "\$\{files\[@\]\}"; do/);
|
||||
assert.match(prereleaseWorkflow, /\$\{file##\*\/\}/);
|
||||
assert.doesNotMatch(prereleaseWorkflow, /sha256sum "\$\{files\[@\]\}" > release\/SHA256SUMS\.txt/);
|
||||
});
|
||||
|
||||
test('prerelease workflow validates artifacts before publishing the release and only undrafts after upload', () => {
|
||||
const artifactsIndex = prereleaseWorkflow.indexOf('artifacts=(');
|
||||
const createIndex = prereleaseWorkflow.indexOf('gh release create');
|
||||
const uploadIndex = prereleaseWorkflow.indexOf('gh release upload');
|
||||
const undraftIndex = prereleaseWorkflow.indexOf('--draft=false');
|
||||
|
||||
assert.notEqual(artifactsIndex, -1);
|
||||
assert.notEqual(createIndex, -1);
|
||||
assert.notEqual(uploadIndex, -1);
|
||||
assert.notEqual(undraftIndex, -1);
|
||||
assert.ok(artifactsIndex < createIndex);
|
||||
assert.ok(uploadIndex < undraftIndex);
|
||||
assert.match(prereleaseWorkflow, /gh release create[\s\S]*--draft[\s\S]*--prerelease/);
|
||||
});
|
||||
|
||||
test('prerelease workflow does not publish to AUR', () => {
|
||||
assert.doesNotMatch(prereleaseWorkflow, /aur-publish:/);
|
||||
assert.doesNotMatch(prereleaseWorkflow, /AUR_SSH_PRIVATE_KEY/);
|
||||
assert.doesNotMatch(prereleaseWorkflow, /scripts\/update-aur-package\.sh/);
|
||||
});
|
||||
@@ -22,6 +22,12 @@ test('publish release leaves prerelease unset so gh creates a normal release', (
|
||||
assert.ok(!releaseWorkflow.includes('--prerelease'));
|
||||
});
|
||||
|
||||
test('stable release workflow excludes prerelease beta and rc tags', () => {
|
||||
assert.match(releaseWorkflow, /tags:\s*\n\s*-\s*'v\*'/);
|
||||
assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-beta\.\*'/);
|
||||
assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-rc\.\*'/);
|
||||
});
|
||||
|
||||
test('publish release forces an existing draft tag release to become public', () => {
|
||||
assert.ok(releaseWorkflow.includes('--draft=false'));
|
||||
});
|
||||
@@ -71,6 +77,13 @@ test('release workflow includes the Windows installer in checksums and uploaded
|
||||
);
|
||||
});
|
||||
|
||||
test('release workflow writes checksum entries using release asset basenames', () => {
|
||||
assert.match(releaseWorkflow, /: > release\/SHA256SUMS\.txt/);
|
||||
assert.match(releaseWorkflow, /for file in "\$\{files\[@\]\}"; do/);
|
||||
assert.match(releaseWorkflow, /\$\{file##\*\/\}/);
|
||||
assert.doesNotMatch(releaseWorkflow, /sha256sum "\$\{files\[@\]\}" > release\/SHA256SUMS\.txt/);
|
||||
});
|
||||
|
||||
test('release package scripts disable implicit electron-builder publishing', () => {
|
||||
assert.match(packageJson.scripts['build:appimage'] ?? '', /--publish never/);
|
||||
assert.match(packageJson.scripts['build:mac'] ?? '', /--publish never/);
|
||||
|
||||
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { createRendererRecoveryController } from './error-recovery.js';
|
||||
import {
|
||||
YOMITAN_POPUP_HOST_SELECTOR,
|
||||
YOMITAN_POPUP_IFRAME_SELECTOR,
|
||||
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||
hasYomitanPopupIframe,
|
||||
isYomitanPopupIframe,
|
||||
isYomitanPopupVisible,
|
||||
@@ -228,6 +230,42 @@ test('resolvePlatformInfo supports modal layer and disables mouse-ignore toggles
|
||||
}
|
||||
});
|
||||
|
||||
test('resolvePlatformInfo flags Windows platforms', () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
getOverlayLayer: () => 'visible',
|
||||
},
|
||||
location: { search: '' },
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: {
|
||||
platform: 'Win32',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const info = resolvePlatformInfo();
|
||||
assert.equal(info.isWindowsPlatform, true);
|
||||
assert.equal(info.isMacOSPlatform, false);
|
||||
assert.equal(info.isLinuxPlatform, false);
|
||||
assert.equal(info.shouldToggleMouseIgnore, true);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: previousNavigator,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('isYomitanPopupIframe matches modern popup class and legacy id prefix', () => {
|
||||
const createElement = (options: {
|
||||
tagName: string;
|
||||
@@ -284,9 +322,25 @@ test('hasYomitanPopupIframe queries for modern + legacy selector', () => {
|
||||
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||
});
|
||||
|
||||
test('hasYomitanPopupIframe falls back to popup host selector for shadow-hosted popups', () => {
|
||||
const selectors: string[] = [];
|
||||
const root = {
|
||||
querySelector: (value: string) => {
|
||||
selectors.push(value);
|
||||
if (value === YOMITAN_POPUP_HOST_SELECTOR) {
|
||||
return {};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as ParentNode;
|
||||
|
||||
assert.equal(hasYomitanPopupIframe(root), true);
|
||||
assert.deepEqual(selectors, [YOMITAN_POPUP_IFRAME_SELECTOR, YOMITAN_POPUP_HOST_SELECTOR]);
|
||||
});
|
||||
|
||||
test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
let selector = '';
|
||||
const selectors: string[] = [];
|
||||
const visibleFrame = {
|
||||
getBoundingClientRect: () => ({ width: 320, height: 180 }),
|
||||
} as unknown as HTMLIFrameElement;
|
||||
@@ -309,18 +363,40 @@ test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
||||
try {
|
||||
const root = {
|
||||
querySelectorAll: (value: string) => {
|
||||
selector = value;
|
||||
selectors.push(value);
|
||||
if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR || value === YOMITAN_POPUP_HOST_SELECTOR) {
|
||||
return [];
|
||||
}
|
||||
return [hiddenFrame, visibleFrame];
|
||||
},
|
||||
} as unknown as ParentNode;
|
||||
|
||||
assert.equal(isYomitanPopupVisible(root), true);
|
||||
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||
assert.deepEqual(selectors, [
|
||||
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||
YOMITAN_POPUP_IFRAME_SELECTOR,
|
||||
]);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('isYomitanPopupVisible detects visible shadow-hosted popup marker without iframe access', () => {
|
||||
let selector = '';
|
||||
const root = {
|
||||
querySelectorAll: (value: string) => {
|
||||
selector = value;
|
||||
if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) {
|
||||
return [{ getAttribute: () => 'true' }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
} as unknown as ParentNode;
|
||||
|
||||
assert.equal(isYomitanPopupVisible(root), true);
|
||||
assert.equal(selector, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
|
||||
});
|
||||
|
||||
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
|
||||
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
|
||||
const activeItem = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import { createKeyboardHandlers } from './keyboard.js';
|
||||
import { createRendererState } from '../state.js';
|
||||
import type { CompiledSessionBinding } from '../../types';
|
||||
import { YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_HIDDEN_EVENT } from '../yomitan-popup.js';
|
||||
|
||||
type CommandEventDetail = {
|
||||
@@ -50,6 +51,8 @@ function installKeyboardTestGlobals() {
|
||||
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
const commandEvents: CommandEventDetail[] = [];
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
const sessionActions: Array<{ actionId: string; payload?: unknown }> = [];
|
||||
let sessionBindings: CompiledSessionBinding[] = [];
|
||||
let playbackPausedResponse: boolean | null = false;
|
||||
let statsToggleKey = 'Backquote';
|
||||
let markWatchedKey = 'KeyW';
|
||||
@@ -66,11 +69,16 @@ function installKeyboardTestGlobals() {
|
||||
markAudioCard: '',
|
||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||
openJimaku: 'Ctrl+Shift+J',
|
||||
openSessionHelp: 'CommandOrControl+Shift+H',
|
||||
openControllerSelect: 'Alt+C',
|
||||
openControllerDebug: 'Alt+Shift+C',
|
||||
toggleSubtitleSidebar: '',
|
||||
toggleVisibleOverlayGlobal: '',
|
||||
};
|
||||
let markActiveVideoWatchedResult = true;
|
||||
let markActiveVideoWatchedCalls = 0;
|
||||
let statsToggleOverlayCalls = 0;
|
||||
const openedModalNotifications: string[] = [];
|
||||
let selectionClearCount = 0;
|
||||
let selectionAddCount = 0;
|
||||
|
||||
@@ -153,10 +161,14 @@ function installKeyboardTestGlobals() {
|
||||
},
|
||||
electronAPI: {
|
||||
getKeybindings: async () => [],
|
||||
getSessionBindings: async () => sessionBindings,
|
||||
getConfiguredShortcuts: async () => configuredShortcuts,
|
||||
sendMpvCommand: (command: Array<string | number>) => {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
dispatchSessionAction: async (actionId: string, payload?: unknown) => {
|
||||
sessionActions.push({ actionId, payload });
|
||||
},
|
||||
getPlaybackPaused: async () => playbackPausedResponse,
|
||||
getStatsToggleKey: async () => statsToggleKey,
|
||||
getMarkWatchedKey: async () => markWatchedKey,
|
||||
@@ -172,6 +184,9 @@ function installKeyboardTestGlobals() {
|
||||
focusMainWindowCalls += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
notifyOverlayModalOpened: (modal: string) => {
|
||||
openedModalNotifications.push(modal);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -273,6 +288,7 @@ function installKeyboardTestGlobals() {
|
||||
return {
|
||||
commandEvents,
|
||||
mpvCommands,
|
||||
sessionActions,
|
||||
overlay,
|
||||
overlayFocusCalls,
|
||||
focusMainWindowCalls: () => focusMainWindowCalls,
|
||||
@@ -292,11 +308,15 @@ function installKeyboardTestGlobals() {
|
||||
setConfiguredShortcuts: (value: typeof configuredShortcuts) => {
|
||||
configuredShortcuts = value;
|
||||
},
|
||||
setSessionBindings: (value: CompiledSessionBinding[]) => {
|
||||
sessionBindings = value;
|
||||
},
|
||||
setMarkActiveVideoWatchedResult: (value: boolean) => {
|
||||
markActiveVideoWatchedResult = value;
|
||||
},
|
||||
markActiveVideoWatchedCalls: () => markActiveVideoWatchedCalls,
|
||||
statsToggleOverlayCalls: () => statsToggleOverlayCalls,
|
||||
openedModalNotifications,
|
||||
getPlaybackPaused: async () => playbackPausedResponse,
|
||||
setPlaybackPausedResponse: (value: boolean | null) => {
|
||||
playbackPausedResponse = value;
|
||||
@@ -310,9 +330,9 @@ function installKeyboardTestGlobals() {
|
||||
function createKeyboardHandlerHarness() {
|
||||
const testGlobals = installKeyboardTestGlobals();
|
||||
const subtitleRootClassList = createClassList();
|
||||
let controllerSelectOpenCount = 0;
|
||||
let controllerDebugOpenCount = 0;
|
||||
let controllerSelectKeydownCount = 0;
|
||||
let openControllerSelectCount = 0;
|
||||
let openControllerDebugCount = 0;
|
||||
let playlistBrowserKeydownCount = 0;
|
||||
|
||||
const createWordNode = (left: number) => ({
|
||||
@@ -360,23 +380,23 @@ function createKeyboardHandlerHarness() {
|
||||
},
|
||||
handleSessionHelpKeydown: () => false,
|
||||
openSessionHelpModal: () => {},
|
||||
appendClipboardVideoToQueue: () => {},
|
||||
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
||||
openControllerSelectModal: () => {
|
||||
controllerSelectOpenCount += 1;
|
||||
openControllerSelectCount += 1;
|
||||
},
|
||||
openControllerDebugModal: () => {
|
||||
controllerDebugOpenCount += 1;
|
||||
openControllerDebugCount += 1;
|
||||
},
|
||||
appendClipboardVideoToQueue: () => {},
|
||||
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
handlers,
|
||||
testGlobals,
|
||||
controllerSelectOpenCount: () => controllerSelectOpenCount,
|
||||
controllerDebugOpenCount: () => controllerDebugOpenCount,
|
||||
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
||||
openControllerSelectCount: () => openControllerSelectCount,
|
||||
openControllerDebugCount: () => openControllerDebugCount,
|
||||
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
|
||||
setWordCount: (count: number) => {
|
||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||
@@ -384,6 +404,88 @@ function createKeyboardHandlerHarness() {
|
||||
};
|
||||
}
|
||||
|
||||
test('session help chord resolver follows remapped session bindings', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
assert.deepEqual(handlers.getSessionHelpOpeningInfo(), {
|
||||
bindingKey: 'KeyH',
|
||||
fallbackUsed: false,
|
||||
fallbackUnavailable: false,
|
||||
});
|
||||
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'KeyH',
|
||||
key: { code: 'KeyH', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openJimaku',
|
||||
},
|
||||
{
|
||||
sourcePath: 'keybindings[1].key',
|
||||
originalKey: 'KeyJ',
|
||||
key: { code: 'KeyJ', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['cycle', 'pause'],
|
||||
},
|
||||
] as never);
|
||||
|
||||
assert.deepEqual(handlers.getSessionHelpOpeningInfo(), {
|
||||
bindingKey: 'KeyK',
|
||||
fallbackUsed: true,
|
||||
fallbackUnavailable: false,
|
||||
});
|
||||
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'KeyH',
|
||||
key: { code: 'KeyH', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSessionHelp',
|
||||
},
|
||||
{
|
||||
sourcePath: 'keybindings[1].key',
|
||||
originalKey: 'KeyK',
|
||||
key: { code: 'KeyK', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openControllerSelect',
|
||||
},
|
||||
] as never);
|
||||
|
||||
assert.deepEqual(handlers.getSessionHelpOpeningInfo(), {
|
||||
bindingKey: 'KeyK',
|
||||
fallbackUsed: true,
|
||||
fallbackUnavailable: true,
|
||||
});
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('numeric selection ignores non-digit keys instead of falling through to other shortcuts', async () => {
|
||||
const { handlers, testGlobals, ctx } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.beginSessionNumericSelection('copySubtitleMultiple');
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY' });
|
||||
|
||||
assert.equal(ctx.state.chordPending, false);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
assert.equal(
|
||||
testGlobals.commandEvents.some((event) => event.type === 'forwardKeyDown'),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: left and right move token selection while popup remains open', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -521,13 +623,19 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => {
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateKeybindings([
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
key: 'Space',
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'Space',
|
||||
key: { code: 'Space', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['cycle', 'pause'],
|
||||
},
|
||||
{
|
||||
key: 'KeyQ',
|
||||
sourcePath: 'keybindings[1].key',
|
||||
originalKey: 'KeyQ',
|
||||
key: { code: 'KeyQ', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['quit'],
|
||||
},
|
||||
] as never);
|
||||
@@ -549,9 +657,12 @@ test('paused configured subtitle-jump keybinding re-applies pause after backward
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateKeybindings([
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
key: 'Shift+KeyH',
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'Shift+KeyH',
|
||||
key: { code: 'KeyH', modifiers: ['shift'] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['sub-seek', -1],
|
||||
},
|
||||
] as never);
|
||||
@@ -574,9 +685,12 @@ test('configured subtitle-jump keybinding preserves pause when pause state is un
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateKeybindings([
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
key: 'Shift+KeyH',
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'Shift+KeyH',
|
||||
key: { code: 'KeyH', modifiers: ['shift'] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['sub-seek', -1],
|
||||
},
|
||||
] as never);
|
||||
@@ -614,6 +728,44 @@ test('visible-layer y-t dispatches mpv plugin toggle while overlay owns focus',
|
||||
}
|
||||
});
|
||||
|
||||
test('refreshConfiguredShortcuts updates hot-reloaded stats and watched keys', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
testGlobals.setConfiguredShortcuts({
|
||||
copySubtitle: '',
|
||||
copySubtitleMultiple: '',
|
||||
updateLastCardFromClipboard: '',
|
||||
triggerFieldGrouping: '',
|
||||
triggerSubsync: 'Ctrl+Alt+S',
|
||||
mineSentence: '',
|
||||
mineSentenceMultiple: '',
|
||||
multiCopyTimeoutMs: 3333,
|
||||
toggleSecondarySub: '',
|
||||
markAudioCard: '',
|
||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||
openJimaku: 'Ctrl+Shift+J',
|
||||
openSessionHelp: 'CommandOrControl+Shift+H',
|
||||
openControllerSelect: 'Alt+C',
|
||||
openControllerDebug: 'Alt+Shift+C',
|
||||
toggleSubtitleSidebar: '',
|
||||
toggleVisibleOverlayGlobal: '',
|
||||
});
|
||||
testGlobals.setStatsToggleKey('');
|
||||
testGlobals.setMarkWatchedKey('');
|
||||
|
||||
await handlers.refreshConfiguredShortcuts();
|
||||
|
||||
assert.equal(ctx.state.sessionActionTimeoutMs, 3333);
|
||||
assert.equal(ctx.state.statsToggleKey, '');
|
||||
assert.equal(ctx.state.markWatchedKey, '');
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -636,31 +788,111 @@ test('keyboard mode: controller helpers dispatch popup audio play/cycle and scro
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: Alt+Shift+C opens controller debug modal', async () => {
|
||||
const { testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
|
||||
test('keyboard mode: configured controller select binding opens locally without dispatching a session action', async () => {
|
||||
const { testGlobals, handlers, openControllerSelectCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openControllerSelect',
|
||||
originalKey: 'Alt+D',
|
||||
key: { code: 'KeyD', modifiers: ['alt'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openControllerSelect',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({
|
||||
key: 'C',
|
||||
code: 'KeyC',
|
||||
key: 'd',
|
||||
code: 'KeyD',
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
assert.equal(controllerDebugOpenCount(), 1);
|
||||
assert.equal(openControllerSelectCount(), 1);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-select']);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup is visible', async () => {
|
||||
const { ctx, testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
|
||||
test('keyboard mode: configured controller debug binding opens locally without dispatching a session action', async () => {
|
||||
const { testGlobals, handlers, openControllerDebugCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openControllerDebug',
|
||||
originalKey: 'Alt+Shift+D',
|
||||
key: { code: 'KeyD', modifiers: ['alt', 'shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openControllerDebug',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({
|
||||
key: 'D',
|
||||
code: 'KeyD',
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
assert.equal(openControllerDebugCount(), 1);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: configured controller debug binding is not swallowed while popup is visible', async () => {
|
||||
const { ctx, testGlobals, handlers, openControllerDebugCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openControllerDebug',
|
||||
originalKey: 'Alt+Shift+D',
|
||||
key: { code: 'KeyD', modifiers: ['alt', 'shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openControllerDebug',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({
|
||||
key: 'D',
|
||||
code: 'KeyD',
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
assert.equal(openControllerDebugCount(), 1);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: former fixed Alt+Shift+C does nothing when controller debug is remapped', async () => {
|
||||
const { testGlobals, handlers } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openControllerDebug',
|
||||
originalKey: 'Alt+Shift+D',
|
||||
key: { code: 'KeyD', modifiers: ['alt', 'shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openControllerDebug',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({
|
||||
key: 'C',
|
||||
@@ -669,7 +901,7 @@ test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup i
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
assert.equal(controllerDebugOpenCount(), 1);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
@@ -758,18 +990,47 @@ test('keyboard mode: configured stats toggle works even while popup is open', as
|
||||
}
|
||||
});
|
||||
|
||||
test('refreshConfiguredShortcuts updates refreshed stats and mark-watched keys', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
testGlobals.setStatsToggleKey('KeyG');
|
||||
testGlobals.setMarkWatchedKey('KeyM');
|
||||
await handlers.refreshConfiguredShortcuts();
|
||||
|
||||
const beforeMarkWatchedCalls = testGlobals.markActiveVideoWatchedCalls();
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
|
||||
testGlobals.dispatchKeydown({ key: 'm', code: 'KeyM' });
|
||||
await wait(10);
|
||||
|
||||
assert.equal(testGlobals.statsToggleOverlayCalls(), 1);
|
||||
assert.equal(testGlobals.markActiveVideoWatchedCalls(), beforeMarkWatchedCalls + 1);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('youtube picker: unhandled keys still dispatch mpv keybindings', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateKeybindings([
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
key: 'Space',
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'Space',
|
||||
key: { code: 'Space', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['cycle', 'pause'],
|
||||
},
|
||||
{
|
||||
key: 'KeyQ',
|
||||
sourcePath: 'keybindings[1].key',
|
||||
originalKey: 'KeyQ',
|
||||
key: { code: 'KeyQ', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['quit'],
|
||||
},
|
||||
] as never);
|
||||
@@ -785,46 +1046,72 @@ test('youtube picker: unhandled keys still dispatch mpv keybindings', async () =
|
||||
}
|
||||
});
|
||||
|
||||
test('linux overlay shortcut: Ctrl+Alt+S dispatches subsync special command locally', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
test('session binding: Ctrl+Alt+S dispatches subsync action locally', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
ctx.platform.isLinuxPlatform = true;
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.triggerSubsync',
|
||||
originalKey: 'Ctrl+Alt+S',
|
||||
key: { code: 'KeyS', modifiers: ['ctrl', 'alt'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'triggerSubsync',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS', ctrlKey: true, altKey: true });
|
||||
|
||||
assert.deepEqual(testGlobals.mpvCommands, [['__subsync-trigger']]);
|
||||
assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'triggerSubsync', payload: undefined }]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('linux overlay shortcut: Ctrl+Shift+J dispatches jimaku special command locally', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
test('session binding: Ctrl+Shift+J dispatches jimaku action locally', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
ctx.platform.isLinuxPlatform = true;
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openJimaku',
|
||||
originalKey: 'Ctrl+Shift+J',
|
||||
key: { code: 'KeyJ', modifiers: ['ctrl', 'shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openJimaku',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'J', code: 'KeyJ', ctrlKey: true, shiftKey: true });
|
||||
|
||||
assert.deepEqual(testGlobals.mpvCommands, [['__jimaku-open']]);
|
||||
assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'openJimaku', payload: undefined }]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('linux overlay shortcut: CommandOrControl+Shift+O dispatches runtime options locally', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
test('session binding: Ctrl+Shift+O dispatches runtime options locally', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
ctx.platform.isLinuxPlatform = true;
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openRuntimeOptions',
|
||||
originalKey: 'CommandOrControl+Shift+O',
|
||||
key: { code: 'KeyO', modifiers: ['ctrl', 'shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openRuntimeOptions',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'O', code: 'KeyO', ctrlKey: true, shiftKey: true });
|
||||
|
||||
assert.deepEqual(testGlobals.mpvCommands, [['__runtime-options-open']]);
|
||||
assert.deepEqual(testGlobals.sessionActions, [
|
||||
{ actionId: 'openRuntimeOptions', payload: undefined },
|
||||
]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SPECIAL_COMMANDS } from '../../config/definitions';
|
||||
import type { Keybinding, ShortcutsConfig } from '../../types';
|
||||
import type { CompiledSessionBinding, ShortcutsConfig } from '../../types';
|
||||
import type { RendererContext } from '../context';
|
||||
import {
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
@@ -26,21 +25,26 @@ export function createKeyboardHandlers(
|
||||
fallbackUsed: boolean;
|
||||
fallbackUnavailable: boolean;
|
||||
}) => void;
|
||||
openControllerSelectModal?: () => void;
|
||||
openControllerDebugModal?: () => void;
|
||||
appendClipboardVideoToQueue: () => void;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
openControllerSelectModal: () => void;
|
||||
openControllerDebugModal: () => void;
|
||||
toggleSubtitleSidebarModal?: () => void;
|
||||
},
|
||||
) {
|
||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||
const CHORD_TIMEOUT_MS = 1000;
|
||||
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
|
||||
const linuxOverlayShortcutCommands = new Map<string, (string | number)[]>();
|
||||
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
||||
let pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
let resetSelectionToStartOnNextSubtitleSync = false;
|
||||
let lookupScanFallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingNumericSelection:
|
||||
| {
|
||||
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple';
|
||||
timeout: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
| null = null;
|
||||
|
||||
const CHORD_MAP = new Map<
|
||||
string,
|
||||
@@ -76,113 +80,143 @@ export function createKeyboardHandlers(
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
function acceleratorToKeyToken(token: string): string | null {
|
||||
const normalized = token.trim();
|
||||
if (!normalized) return null;
|
||||
if (/^[a-z]$/i.test(normalized)) {
|
||||
return `Key${normalized.toUpperCase()}`;
|
||||
function updateConfiguredShortcuts(
|
||||
shortcuts: Required<ShortcutsConfig>,
|
||||
statsToggleKey?: string,
|
||||
markWatchedKey?: string,
|
||||
): void {
|
||||
ctx.state.sessionActionTimeoutMs = shortcuts.multiCopyTimeoutMs;
|
||||
if (typeof statsToggleKey === 'string') {
|
||||
ctx.state.statsToggleKey = statsToggleKey;
|
||||
}
|
||||
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)) {
|
||||
return normalized[0]!.toUpperCase() + normalized.slice(1);
|
||||
}
|
||||
if (/^arrow(?:up|down|left|right)$/i.test(normalized)) {
|
||||
return normalized[0]!.toUpperCase() + normalized.slice(1);
|
||||
}
|
||||
if (/^f\d{1,2}$/i.test(normalized)) {
|
||||
return normalized.toUpperCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function acceleratorToKeyString(accelerator: string): string | null {
|
||||
const normalized = accelerator.replace(/\s+/g, '').replace(/cmdorctrl/gi, 'CommandOrControl');
|
||||
if (!normalized) return null;
|
||||
const parts = normalized.split('+').filter(Boolean);
|
||||
const keyToken = parts.pop();
|
||||
if (!keyToken) return null;
|
||||
|
||||
const eventParts: string[] = [];
|
||||
for (const modifier of parts) {
|
||||
const lower = modifier.toLowerCase();
|
||||
if (lower === 'ctrl' || lower === 'control') {
|
||||
eventParts.push('Ctrl');
|
||||
continue;
|
||||
}
|
||||
if (lower === 'alt' || lower === 'option') {
|
||||
eventParts.push('Alt');
|
||||
continue;
|
||||
}
|
||||
if (lower === 'shift') {
|
||||
eventParts.push('Shift');
|
||||
continue;
|
||||
}
|
||||
if (lower === 'meta' || lower === 'super' || lower === 'command' || lower === 'cmd') {
|
||||
eventParts.push('Meta');
|
||||
continue;
|
||||
}
|
||||
if (lower === 'commandorcontrol') {
|
||||
eventParts.push(ctx.platform.isMacOSPlatform ? 'Meta' : 'Ctrl');
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedKey = acceleratorToKeyToken(keyToken);
|
||||
if (!normalizedKey) return null;
|
||||
eventParts.push(normalizedKey);
|
||||
return eventParts.join('+');
|
||||
}
|
||||
|
||||
function updateConfiguredShortcuts(shortcuts: Required<ShortcutsConfig>): void {
|
||||
linuxOverlayShortcutCommands.clear();
|
||||
const bindings: Array<[string | null, (string | number)[]]> = [
|
||||
[shortcuts.triggerSubsync, [SPECIAL_COMMANDS.SUBSYNC_TRIGGER]],
|
||||
[shortcuts.openRuntimeOptions, [SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN]],
|
||||
[shortcuts.openJimaku, [SPECIAL_COMMANDS.JIMAKU_OPEN]],
|
||||
];
|
||||
|
||||
for (const [accelerator, command] of bindings) {
|
||||
if (!accelerator) continue;
|
||||
const keyString = acceleratorToKeyString(accelerator);
|
||||
if (keyString) {
|
||||
linuxOverlayShortcutCommands.set(keyString, command);
|
||||
}
|
||||
if (typeof markWatchedKey === 'string') {
|
||||
ctx.state.markWatchedKey = markWatchedKey;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshConfiguredShortcuts(): Promise<void> {
|
||||
updateConfiguredShortcuts(await window.electronAPI.getConfiguredShortcuts());
|
||||
const [shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([
|
||||
window.electronAPI.getConfiguredShortcuts(),
|
||||
window.electronAPI.getStatsToggleKey(),
|
||||
window.electronAPI.getMarkWatchedKey(),
|
||||
]);
|
||||
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
|
||||
}
|
||||
|
||||
function updateSessionBindings(bindings: CompiledSessionBinding[]): void {
|
||||
ctx.state.sessionBindings = bindings;
|
||||
ctx.state.sessionBindingMap = new Map(
|
||||
bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]),
|
||||
);
|
||||
}
|
||||
|
||||
function keyEventToStringFromBinding(binding: CompiledSessionBinding): string {
|
||||
const parts: string[] = [];
|
||||
for (const modifier of binding.key.modifiers) {
|
||||
if (modifier === 'ctrl') parts.push('Ctrl');
|
||||
else if (modifier === 'alt') parts.push('Alt');
|
||||
else if (modifier === 'shift') parts.push('Shift');
|
||||
else if (modifier === 'meta') parts.push('Meta');
|
||||
}
|
||||
parts.push(binding.key.code);
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
function isTextEntryTarget(target: EventTarget | null): boolean {
|
||||
if (!target || typeof target !== 'object' || !('closest' in target)) return false;
|
||||
const element = target as { closest: (selector: string) => unknown };
|
||||
if (element.closest('[contenteditable="true"]')) return true;
|
||||
return Boolean(element.closest('input, textarea, select'));
|
||||
}
|
||||
|
||||
function showSessionSelectionMessage(message: string): void {
|
||||
window.electronAPI.sendMpvCommand(['show-text', message, '3000']);
|
||||
}
|
||||
|
||||
function cancelPendingNumericSelection(showCancelled: boolean): void {
|
||||
if (!pendingNumericSelection) return;
|
||||
if (pendingNumericSelection.timeout !== null) {
|
||||
clearTimeout(pendingNumericSelection.timeout);
|
||||
}
|
||||
pendingNumericSelection = null;
|
||||
if (showCancelled) {
|
||||
showSessionSelectionMessage('Cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
function startPendingNumericSelection(
|
||||
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
|
||||
): void {
|
||||
cancelPendingNumericSelection(false);
|
||||
const timeoutMessage = actionId === 'copySubtitleMultiple' ? 'Copy timeout' : 'Mine timeout';
|
||||
const promptMessage =
|
||||
actionId === 'copySubtitleMultiple'
|
||||
? 'Copy how many lines? Press 1-9 (Esc to cancel)'
|
||||
: 'Mine how many lines? Press 1-9 (Esc to cancel)';
|
||||
pendingNumericSelection = {
|
||||
actionId,
|
||||
timeout: setTimeout(() => {
|
||||
pendingNumericSelection = null;
|
||||
showSessionSelectionMessage(timeoutMessage);
|
||||
}, ctx.state.sessionActionTimeoutMs),
|
||||
};
|
||||
showSessionSelectionMessage(promptMessage);
|
||||
}
|
||||
|
||||
function beginSessionNumericSelection(
|
||||
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
|
||||
): void {
|
||||
startPendingNumericSelection(actionId);
|
||||
}
|
||||
|
||||
function handlePendingNumericSelection(e: KeyboardEvent): boolean {
|
||||
if (!pendingNumericSelection) return false;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelPendingNumericSelection(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!/^[1-9]$/.test(e.key) || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const count = Number(e.key);
|
||||
const actionId = pendingNumericSelection.actionId;
|
||||
cancelPendingNumericSelection(false);
|
||||
void window.electronAPI.dispatchSessionAction(actionId, { count });
|
||||
return true;
|
||||
}
|
||||
|
||||
function dispatchSessionBinding(binding: CompiledSessionBinding): void {
|
||||
if (
|
||||
binding.actionType === 'session-action' &&
|
||||
(binding.actionId === 'copySubtitleMultiple' || binding.actionId === 'mineSentenceMultiple')
|
||||
) {
|
||||
startPendingNumericSelection(binding.actionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') {
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||
options.openControllerSelectModal?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerDebug') {
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||
options.openControllerDebugModal?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (binding.actionType === 'mpv-command') {
|
||||
dispatchConfiguredMpvCommand(binding.command);
|
||||
return;
|
||||
}
|
||||
|
||||
void window.electronAPI.dispatchSessionAction(binding.actionId, binding.payload);
|
||||
}
|
||||
|
||||
function dispatchYomitanPopupKeydown(
|
||||
@@ -292,10 +326,6 @@ export function createKeyboardHandlers(
|
||||
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
|
||||
}
|
||||
|
||||
function isControllerModalShortcut(e: KeyboardEvent): boolean {
|
||||
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
|
||||
}
|
||||
|
||||
function isSubtitleSidebarToggle(e: KeyboardEvent): boolean {
|
||||
const toggleKey = ctx.state.subtitleSidebarToggleKey;
|
||||
if (!toggleKey) return false;
|
||||
@@ -508,7 +538,7 @@ export function createKeyboardHandlers(
|
||||
clientY: number,
|
||||
modifiers: ScanModifierState = {},
|
||||
): void {
|
||||
if (typeof PointerEvent !== 'undefined') {
|
||||
if (typeof PointerEvent === 'function') {
|
||||
const pointerEventInit = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
@@ -531,23 +561,25 @@ export function createKeyboardHandlers(
|
||||
target.dispatchEvent(new PointerEvent('pointerup', pointerEventInit));
|
||||
}
|
||||
|
||||
const mouseEventInit = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
clientX,
|
||||
clientY,
|
||||
button: 0,
|
||||
buttons: 0,
|
||||
shiftKey: modifiers.shiftKey ?? false,
|
||||
ctrlKey: modifiers.ctrlKey ?? false,
|
||||
altKey: modifiers.altKey ?? false,
|
||||
metaKey: modifiers.metaKey ?? false,
|
||||
} satisfies MouseEventInit;
|
||||
target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit));
|
||||
target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 }));
|
||||
target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit));
|
||||
target.dispatchEvent(new MouseEvent('click', mouseEventInit));
|
||||
if (typeof MouseEvent === 'function') {
|
||||
const mouseEventInit = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
clientX,
|
||||
clientY,
|
||||
button: 0,
|
||||
buttons: 0,
|
||||
shiftKey: modifiers.shiftKey ?? false,
|
||||
ctrlKey: modifiers.ctrlKey ?? false,
|
||||
altKey: modifiers.altKey ?? false,
|
||||
metaKey: modifiers.metaKey ?? false,
|
||||
} satisfies MouseEventInit;
|
||||
target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit));
|
||||
target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 }));
|
||||
target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit));
|
||||
target.dispatchEvent(new MouseEvent('click', mouseEventInit));
|
||||
}
|
||||
}
|
||||
|
||||
function emitLookupScanFallback(target: Element, clientX: number, clientY: number): void {
|
||||
@@ -820,7 +852,7 @@ export function createKeyboardHandlers(
|
||||
if (modifierOnlyCodes.has(e.code)) return false;
|
||||
|
||||
const keyString = keyEventToString(e);
|
||||
if (ctx.state.keybindingsMap.has(keyString)) {
|
||||
if (ctx.state.sessionBindingMap.has(keyString)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -846,7 +878,7 @@ export function createKeyboardHandlers(
|
||||
fallbackUnavailable: boolean;
|
||||
} {
|
||||
const firstChoice = 'KeyH';
|
||||
if (!ctx.state.keybindingsMap.has('KeyH')) {
|
||||
if (!ctx.state.sessionBindingMap.has('KeyH')) {
|
||||
return {
|
||||
bindingKey: firstChoice,
|
||||
fallbackUsed: false,
|
||||
@@ -854,18 +886,18 @@ export function createKeyboardHandlers(
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.state.keybindingsMap.has('KeyK')) {
|
||||
if (!ctx.state.sessionBindingMap.has('KeyK')) {
|
||||
return {
|
||||
bindingKey: 'KeyK',
|
||||
fallbackUsed: true,
|
||||
fallbackUnavailable: true,
|
||||
fallbackUnavailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bindingKey: 'KeyK',
|
||||
fallbackUsed: true,
|
||||
fallbackUnavailable: false,
|
||||
fallbackUnavailable: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -890,16 +922,14 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
async function setupMpvInputForwarding(): Promise<void> {
|
||||
const [keybindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([
|
||||
window.electronAPI.getKeybindings(),
|
||||
const [sessionBindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([
|
||||
window.electronAPI.getSessionBindings(),
|
||||
window.electronAPI.getConfiguredShortcuts(),
|
||||
window.electronAPI.getStatsToggleKey(),
|
||||
window.electronAPI.getMarkWatchedKey(),
|
||||
]);
|
||||
updateKeybindings(keybindings);
|
||||
updateConfiguredShortcuts(shortcuts);
|
||||
ctx.state.statsToggleKey = statsToggleKey;
|
||||
ctx.state.markWatchedKey = markWatchedKey;
|
||||
updateSessionBindings(sessionBindings);
|
||||
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
|
||||
syncKeyboardTokenSelection();
|
||||
|
||||
const subtitleMutationObserver = new MutationObserver(() => {
|
||||
@@ -1006,6 +1036,14 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextEntryTarget(e.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handlePendingNumericSelection(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStatsOverlayToggle(e)) {
|
||||
e.preventDefault();
|
||||
window.electronAPI.toggleStatsOverlay();
|
||||
@@ -1024,10 +1062,7 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
|
||||
!isControllerModalShortcut(e)
|
||||
) {
|
||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||
if (handleYomitanPopupKeybind(e)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
@@ -1084,30 +1119,11 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isControllerModalShortcut(e)) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
options.openControllerDebugModal();
|
||||
} else {
|
||||
options.openControllerSelectModal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const keyString = keyEventToString(e);
|
||||
const linuxOverlayCommand = ctx.platform.isLinuxPlatform
|
||||
? linuxOverlayShortcutCommands.get(keyString)
|
||||
: undefined;
|
||||
if (linuxOverlayCommand) {
|
||||
const binding = ctx.state.sessionBindingMap.get(keyString);
|
||||
if (binding) {
|
||||
e.preventDefault();
|
||||
dispatchConfiguredMpvCommand(linuxOverlayCommand);
|
||||
return;
|
||||
}
|
||||
const command = ctx.state.keybindingsMap.get(keyString);
|
||||
|
||||
if (command) {
|
||||
e.preventDefault();
|
||||
dispatchConfiguredMpvCommand(command);
|
||||
dispatchSessionBinding(binding);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1125,19 +1141,12 @@ export function createKeyboardHandlers(
|
||||
});
|
||||
}
|
||||
|
||||
function updateKeybindings(keybindings: Keybinding[]): void {
|
||||
ctx.state.keybindingsMap = new Map();
|
||||
for (const binding of keybindings) {
|
||||
if (binding.command) {
|
||||
ctx.state.keybindingsMap.set(binding.key, binding.command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
beginSessionNumericSelection,
|
||||
getSessionHelpOpeningInfo: resolveSessionHelpChordBinding,
|
||||
setupMpvInputForwarding,
|
||||
refreshConfiguredShortcuts,
|
||||
updateKeybindings,
|
||||
updateSessionBindings,
|
||||
syncKeyboardTokenSelection,
|
||||
handleSubtitleContentUpdated,
|
||||
handleKeyboardModeToggleRequested,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user