mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
refactor: split main runtime handlers into focused modules
This commit is contained in:
124
src/main/runtime/anilist-media-state.test.ts
Normal file
124
src/main/runtime/anilist-media-state.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createGetAnilistMediaGuessRuntimeStateHandler,
|
||||
createGetCurrentAnilistMediaKeyHandler,
|
||||
createResetAnilistMediaGuessStateHandler,
|
||||
createResetAnilistMediaTrackingHandler,
|
||||
createSetAnilistMediaGuessRuntimeStateHandler,
|
||||
} from './anilist-media-state';
|
||||
|
||||
test('get current anilist media key trims and normalizes empty path', () => {
|
||||
const getKey = createGetCurrentAnilistMediaKeyHandler({
|
||||
getCurrentMediaPath: () => ' /tmp/video.mkv ',
|
||||
});
|
||||
const getEmptyKey = createGetCurrentAnilistMediaKeyHandler({
|
||||
getCurrentMediaPath: () => ' ',
|
||||
});
|
||||
|
||||
assert.equal(getKey(), '/tmp/video.mkv');
|
||||
assert.equal(getEmptyKey(), null);
|
||||
});
|
||||
|
||||
test('reset anilist media tracking clears duration/guess/probe state', () => {
|
||||
let mediaKey: string | null = 'old';
|
||||
let mediaDurationSec: number | null = 123;
|
||||
let mediaGuess: { title: string } | null = { title: 'guess' };
|
||||
let mediaGuessPromise: Promise<unknown> | null = Promise.resolve(null);
|
||||
let lastDurationProbeAtMs = 999;
|
||||
|
||||
const reset = createResetAnilistMediaTrackingHandler({
|
||||
setMediaKey: (value) => {
|
||||
mediaKey = value;
|
||||
},
|
||||
setMediaDurationSec: (value) => {
|
||||
mediaDurationSec = value;
|
||||
},
|
||||
setMediaGuess: (value) => {
|
||||
mediaGuess = value as { title: string } | null;
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
mediaGuessPromise = value;
|
||||
},
|
||||
setLastDurationProbeAtMs: (value) => {
|
||||
lastDurationProbeAtMs = value;
|
||||
},
|
||||
});
|
||||
|
||||
reset('/new/media');
|
||||
assert.equal(mediaKey, '/new/media');
|
||||
assert.equal(mediaDurationSec, null);
|
||||
assert.equal(mediaGuess, null);
|
||||
assert.equal(mediaGuessPromise, null);
|
||||
assert.equal(lastDurationProbeAtMs, 0);
|
||||
});
|
||||
|
||||
test('get/set anilist media guess runtime state round-trips fields', () => {
|
||||
let state = {
|
||||
mediaKey: null as string | null,
|
||||
mediaDurationSec: null as number | null,
|
||||
mediaGuess: null as { title: string } | null,
|
||||
mediaGuessPromise: null as Promise<unknown> | null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
};
|
||||
|
||||
const setState = createSetAnilistMediaGuessRuntimeStateHandler({
|
||||
setMediaKey: (value) => {
|
||||
state.mediaKey = value;
|
||||
},
|
||||
setMediaDurationSec: (value) => {
|
||||
state.mediaDurationSec = value;
|
||||
},
|
||||
setMediaGuess: (value) => {
|
||||
state.mediaGuess = value as { title: string } | null;
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
state.mediaGuessPromise = value;
|
||||
},
|
||||
setLastDurationProbeAtMs: (value) => {
|
||||
state.lastDurationProbeAtMs = value;
|
||||
},
|
||||
});
|
||||
|
||||
const getState = createGetAnilistMediaGuessRuntimeStateHandler({
|
||||
getMediaKey: () => state.mediaKey,
|
||||
getMediaDurationSec: () => state.mediaDurationSec,
|
||||
getMediaGuess: () => state.mediaGuess as never,
|
||||
getMediaGuessPromise: () => state.mediaGuessPromise as never,
|
||||
getLastDurationProbeAtMs: () => state.lastDurationProbeAtMs,
|
||||
});
|
||||
|
||||
const nextPromise = Promise.resolve(null);
|
||||
setState({
|
||||
mediaKey: '/tmp/video.mkv',
|
||||
mediaDurationSec: 24,
|
||||
mediaGuess: { title: 'Title' } as never,
|
||||
mediaGuessPromise: nextPromise as never,
|
||||
lastDurationProbeAtMs: 321,
|
||||
});
|
||||
|
||||
const roundTrip = getState();
|
||||
assert.equal(roundTrip.mediaKey, '/tmp/video.mkv');
|
||||
assert.equal(roundTrip.mediaDurationSec, 24);
|
||||
assert.deepEqual(roundTrip.mediaGuess, { title: 'Title' });
|
||||
assert.equal(roundTrip.mediaGuessPromise, nextPromise);
|
||||
assert.equal(roundTrip.lastDurationProbeAtMs, 321);
|
||||
});
|
||||
|
||||
test('reset anilist media guess state clears guess and in-flight promise', () => {
|
||||
let mediaGuess: { title: string } | null = { title: 'guess' };
|
||||
let mediaGuessPromise: Promise<unknown> | null = Promise.resolve(null);
|
||||
|
||||
const resetGuessState = createResetAnilistMediaGuessStateHandler({
|
||||
setMediaGuess: (value) => {
|
||||
mediaGuess = value as { title: string } | null;
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
mediaGuessPromise = value;
|
||||
},
|
||||
});
|
||||
|
||||
resetGuessState();
|
||||
assert.equal(mediaGuess, null);
|
||||
assert.equal(mediaGuessPromise, null);
|
||||
});
|
||||
68
src/main/runtime/anilist-media-state.ts
Normal file
68
src/main/runtime/anilist-media-state.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { AnilistMediaGuessRuntimeState } from './anilist-media-guess';
|
||||
|
||||
export function createGetCurrentAnilistMediaKeyHandler(deps: {
|
||||
getCurrentMediaPath: () => string | null;
|
||||
}) {
|
||||
return (): string | null => {
|
||||
const mediaPath = deps.getCurrentMediaPath()?.trim();
|
||||
return mediaPath && mediaPath.length > 0 ? mediaPath : null;
|
||||
};
|
||||
}
|
||||
|
||||
export function createResetAnilistMediaTrackingHandler(deps: {
|
||||
setMediaKey: (value: string | null) => void;
|
||||
setMediaDurationSec: (value: number | null) => void;
|
||||
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
|
||||
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
|
||||
setLastDurationProbeAtMs: (value: number) => void;
|
||||
}) {
|
||||
return (mediaKey: string | null): void => {
|
||||
deps.setMediaKey(mediaKey);
|
||||
deps.setMediaDurationSec(null);
|
||||
deps.setMediaGuess(null);
|
||||
deps.setMediaGuessPromise(null);
|
||||
deps.setLastDurationProbeAtMs(0);
|
||||
};
|
||||
}
|
||||
|
||||
export function createGetAnilistMediaGuessRuntimeStateHandler(deps: {
|
||||
getMediaKey: () => string | null;
|
||||
getMediaDurationSec: () => number | null;
|
||||
getMediaGuess: () => AnilistMediaGuessRuntimeState['mediaGuess'];
|
||||
getMediaGuessPromise: () => AnilistMediaGuessRuntimeState['mediaGuessPromise'];
|
||||
getLastDurationProbeAtMs: () => number;
|
||||
}) {
|
||||
return (): AnilistMediaGuessRuntimeState => ({
|
||||
mediaKey: deps.getMediaKey(),
|
||||
mediaDurationSec: deps.getMediaDurationSec(),
|
||||
mediaGuess: deps.getMediaGuess(),
|
||||
mediaGuessPromise: deps.getMediaGuessPromise(),
|
||||
lastDurationProbeAtMs: deps.getLastDurationProbeAtMs(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createSetAnilistMediaGuessRuntimeStateHandler(deps: {
|
||||
setMediaKey: (value: string | null) => void;
|
||||
setMediaDurationSec: (value: number | null) => void;
|
||||
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
|
||||
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
|
||||
setLastDurationProbeAtMs: (value: number) => void;
|
||||
}) {
|
||||
return (state: AnilistMediaGuessRuntimeState): void => {
|
||||
deps.setMediaKey(state.mediaKey);
|
||||
deps.setMediaDurationSec(state.mediaDurationSec);
|
||||
deps.setMediaGuess(state.mediaGuess);
|
||||
deps.setMediaGuessPromise(state.mediaGuessPromise);
|
||||
deps.setLastDurationProbeAtMs(state.lastDurationProbeAtMs);
|
||||
};
|
||||
}
|
||||
|
||||
export function createResetAnilistMediaGuessStateHandler(deps: {
|
||||
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
|
||||
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.setMediaGuess(null);
|
||||
deps.setMediaGuessPromise(null);
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createAnilistSetupWillRedirectHandler,
|
||||
createAnilistSetupWindowOpenHandler,
|
||||
createHandleManualAnilistSetupSubmissionHandler,
|
||||
createOpenAnilistSetupWindowHandler,
|
||||
} from './anilist-setup-window';
|
||||
|
||||
test('manual anilist setup submission forwards access token to callback consumer', () => {
|
||||
@@ -224,3 +225,143 @@ test('anilist setup window opened handler sets references', () => {
|
||||
handler();
|
||||
assert.deepEqual(calls, ['set-window', 'opened:yes']);
|
||||
});
|
||||
|
||||
test('open anilist setup handler no-ops when existing setup window focused', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createOpenAnilistSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => {
|
||||
calls.push('focus-existing');
|
||||
return true;
|
||||
},
|
||||
createSetupWindow: () => {
|
||||
calls.push('create-window');
|
||||
throw new Error('should not create');
|
||||
},
|
||||
buildAuthorizeUrl: () => 'https://anilist.co/api/v2/oauth/authorize?client_id=36084',
|
||||
consumeCallbackUrl: () => false,
|
||||
openSetupInBrowser: () => {},
|
||||
loadManualTokenEntry: () => {},
|
||||
redirectUri: 'https://anilist.subminer.moe/',
|
||||
developerSettingsUrl: 'https://anilist.co/settings/developer',
|
||||
isAllowedExternalUrl: () => true,
|
||||
isAllowedNavigationUrl: () => true,
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
clearSetupWindow: () => {},
|
||||
setSetupPageOpened: () => {},
|
||||
setSetupWindow: () => {},
|
||||
openExternal: () => {},
|
||||
});
|
||||
|
||||
handler();
|
||||
assert.deepEqual(calls, ['focus-existing']);
|
||||
});
|
||||
|
||||
test('open anilist setup handler wires navigation, fallback, and lifecycle', () => {
|
||||
let openHandler: ((params: { url: string }) => { action: 'deny' }) | null = null;
|
||||
let willNavigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null;
|
||||
let didNavigateHandler: ((event: unknown, url: string) => void) | null = null;
|
||||
let didFinishLoadHandler: (() => void) | null = null;
|
||||
let didFailLoadHandler:
|
||||
| ((event: unknown, errorCode: number, errorDescription: string, validatedURL: string) => void)
|
||||
| null = null;
|
||||
let closedHandler: (() => void) | null = null;
|
||||
let prevented = false;
|
||||
const calls: string[] = [];
|
||||
|
||||
const fakeWindow = {
|
||||
focus: () => {},
|
||||
webContents: {
|
||||
setWindowOpenHandler: (handler: (params: { url: string }) => { action: 'deny' }) => {
|
||||
openHandler = handler;
|
||||
},
|
||||
on: (
|
||||
event: 'will-navigate' | 'will-redirect' | 'did-navigate' | 'did-fail-load' | 'did-finish-load',
|
||||
handler: (...args: any[]) => void,
|
||||
) => {
|
||||
if (event === 'will-navigate') willNavigateHandler = handler as never;
|
||||
if (event === 'did-navigate') didNavigateHandler = handler as never;
|
||||
if (event === 'did-finish-load') didFinishLoadHandler = handler as never;
|
||||
if (event === 'did-fail-load') didFailLoadHandler = handler as never;
|
||||
},
|
||||
getURL: () => 'about:blank',
|
||||
},
|
||||
on: (event: 'closed', handler: () => void) => {
|
||||
if (event === 'closed') closedHandler = handler;
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
};
|
||||
|
||||
const handler = createOpenAnilistSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => false,
|
||||
createSetupWindow: () => fakeWindow,
|
||||
buildAuthorizeUrl: () => 'https://anilist.co/api/v2/oauth/authorize?client_id=36084',
|
||||
consumeCallbackUrl: (rawUrl) => {
|
||||
calls.push(`consume:${rawUrl}`);
|
||||
return rawUrl.includes('access_token=');
|
||||
},
|
||||
openSetupInBrowser: () => calls.push('open-browser'),
|
||||
loadManualTokenEntry: () => calls.push('load-manual'),
|
||||
redirectUri: 'https://anilist.subminer.moe/',
|
||||
developerSettingsUrl: 'https://anilist.co/settings/developer',
|
||||
isAllowedExternalUrl: () => true,
|
||||
isAllowedNavigationUrl: () => true,
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
clearSetupWindow: () => calls.push('clear-window'),
|
||||
setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`),
|
||||
setSetupWindow: () => calls.push('set-window'),
|
||||
openExternal: (url) => calls.push(`open:${url}`),
|
||||
});
|
||||
|
||||
handler();
|
||||
assert.ok(openHandler);
|
||||
assert.ok(willNavigateHandler);
|
||||
assert.ok(didNavigateHandler);
|
||||
assert.ok(didFinishLoadHandler);
|
||||
assert.ok(didFailLoadHandler);
|
||||
assert.ok(closedHandler);
|
||||
assert.deepEqual(calls.slice(0, 3), ['load-manual', 'set-window', 'opened:yes']);
|
||||
|
||||
const onOpen = openHandler as ((params: { url: string }) => { action: 'deny' }) | null;
|
||||
if (!onOpen) throw new Error('missing window open handler');
|
||||
assert.deepEqual(onOpen({ url: 'https://anilist.co/settings/developer' }), { action: 'deny' });
|
||||
assert.ok(calls.includes('open:https://anilist.co/settings/developer'));
|
||||
|
||||
const onWillNavigate = willNavigateHandler as
|
||||
| ((event: { preventDefault: () => void }, url: string) => void)
|
||||
| null;
|
||||
if (!onWillNavigate) throw new Error('missing will navigate handler');
|
||||
onWillNavigate(
|
||||
{
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
},
|
||||
'https://anilist.subminer.moe/#access_token=abc',
|
||||
);
|
||||
assert.equal(prevented, true);
|
||||
|
||||
const onDidNavigate = didNavigateHandler as ((event: unknown, url: string) => void) | null;
|
||||
if (!onDidNavigate) throw new Error('missing did navigate handler');
|
||||
onDidNavigate({}, 'https://anilist.subminer.moe/#access_token=abc');
|
||||
|
||||
const onDidFinishLoad = didFinishLoadHandler as (() => void) | null;
|
||||
if (!onDidFinishLoad) throw new Error('missing did finish load handler');
|
||||
onDidFinishLoad();
|
||||
assert.ok(calls.includes('warn:AniList setup loaded a blank page; using fallback'));
|
||||
assert.ok(calls.includes('open-browser'));
|
||||
|
||||
const onDidFailLoad = didFailLoadHandler as
|
||||
| ((event: unknown, errorCode: number, errorDescription: string, validatedURL: string) => void)
|
||||
| null;
|
||||
if (!onDidFailLoad) throw new Error('missing did fail load handler');
|
||||
onDidFailLoad({}, -1, 'load failed', 'about:blank');
|
||||
assert.ok(calls.includes('error:AniList setup window failed to load'));
|
||||
|
||||
const onClosed = closedHandler as (() => void) | null;
|
||||
if (!onClosed) throw new Error('missing closed handler');
|
||||
onClosed();
|
||||
assert.ok(calls.includes('clear-window'));
|
||||
assert.ok(calls.includes('opened:no'));
|
||||
});
|
||||
|
||||
@@ -8,6 +8,18 @@ type FocusableWindowLike = {
|
||||
focus: () => void;
|
||||
};
|
||||
|
||||
type AnilistSetupWebContentsLike = {
|
||||
setWindowOpenHandler: (...args: any[]) => unknown;
|
||||
on: (...args: any[]) => unknown;
|
||||
getURL: () => string;
|
||||
};
|
||||
|
||||
type AnilistSetupWindowLike = FocusableWindowLike & {
|
||||
webContents: AnilistSetupWebContentsLike;
|
||||
on: (...args: any[]) => unknown;
|
||||
isDestroyed: () => boolean;
|
||||
};
|
||||
|
||||
export function createHandleManualAnilistSetupSubmissionHandler(deps: {
|
||||
consumeCallbackUrl: (rawUrl: string) => boolean;
|
||||
redirectUri: string;
|
||||
@@ -179,3 +191,133 @@ export function createAnilistSetupFallbackHandler(deps: {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createOpenAnilistSetupWindowHandler<TWindow extends AnilistSetupWindowLike>(deps: {
|
||||
maybeFocusExistingSetupWindow: () => boolean;
|
||||
createSetupWindow: () => TWindow;
|
||||
buildAuthorizeUrl: () => string;
|
||||
consumeCallbackUrl: (rawUrl: string) => boolean;
|
||||
openSetupInBrowser: (authorizeUrl: string) => void;
|
||||
loadManualTokenEntry: (setupWindow: TWindow, authorizeUrl: string) => void;
|
||||
redirectUri: string;
|
||||
developerSettingsUrl: string;
|
||||
isAllowedExternalUrl: (url: string) => boolean;
|
||||
isAllowedNavigationUrl: (url: string) => boolean;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
logError: (message: string, details: unknown) => void;
|
||||
clearSetupWindow: () => void;
|
||||
setSetupPageOpened: (opened: boolean) => void;
|
||||
setSetupWindow: (window: TWindow) => void;
|
||||
openExternal: (url: string) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
if (deps.maybeFocusExistingSetupWindow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setupWindow = deps.createSetupWindow();
|
||||
const authorizeUrl = deps.buildAuthorizeUrl();
|
||||
const consumeCallbackUrl = (rawUrl: string): boolean => deps.consumeCallbackUrl(rawUrl);
|
||||
const openSetupInBrowser = () => deps.openSetupInBrowser(authorizeUrl);
|
||||
const loadManualTokenEntry = () => deps.loadManualTokenEntry(setupWindow, authorizeUrl);
|
||||
const handleManualSubmission = createHandleManualAnilistSetupSubmissionHandler({
|
||||
consumeCallbackUrl: (rawUrl) => consumeCallbackUrl(rawUrl),
|
||||
redirectUri: deps.redirectUri,
|
||||
logWarn: (message) => deps.logWarn(message),
|
||||
});
|
||||
const fallback = createAnilistSetupFallbackHandler({
|
||||
authorizeUrl,
|
||||
developerSettingsUrl: deps.developerSettingsUrl,
|
||||
setupWindow,
|
||||
openSetupInBrowser,
|
||||
loadManualTokenEntry,
|
||||
logError: (message, details) => deps.logError(message, details),
|
||||
logWarn: (message) => deps.logWarn(message),
|
||||
});
|
||||
const handleWindowOpen = createAnilistSetupWindowOpenHandler({
|
||||
isAllowedExternalUrl: (url) => deps.isAllowedExternalUrl(url),
|
||||
openExternal: (url) => deps.openExternal(url),
|
||||
logWarn: (message, details) => deps.logWarn(message, details),
|
||||
});
|
||||
const handleWillNavigate = createAnilistSetupWillNavigateHandler({
|
||||
handleManualSubmission: (url) => handleManualSubmission(url),
|
||||
consumeCallbackUrl: (url) => consumeCallbackUrl(url),
|
||||
redirectUri: deps.redirectUri,
|
||||
isAllowedNavigationUrl: (url) => deps.isAllowedNavigationUrl(url),
|
||||
logWarn: (message, details) => deps.logWarn(message, details),
|
||||
});
|
||||
const handleWillRedirect = createAnilistSetupWillRedirectHandler({
|
||||
consumeCallbackUrl: (url) => consumeCallbackUrl(url),
|
||||
});
|
||||
const handleDidNavigate = createAnilistSetupDidNavigateHandler({
|
||||
consumeCallbackUrl: (url) => consumeCallbackUrl(url),
|
||||
});
|
||||
const handleDidFailLoad = createAnilistSetupDidFailLoadHandler({
|
||||
onLoadFailure: (details) => fallback.onLoadFailure(details),
|
||||
});
|
||||
const handleDidFinishLoad = createAnilistSetupDidFinishLoadHandler({
|
||||
getLoadedUrl: () => setupWindow.webContents.getURL(),
|
||||
onBlankPageLoaded: () => fallback.onBlankPageLoaded(),
|
||||
});
|
||||
const handleWindowClosed = createHandleAnilistSetupWindowClosedHandler({
|
||||
clearSetupWindow: () => deps.clearSetupWindow(),
|
||||
setSetupPageOpened: (opened) => deps.setSetupPageOpened(opened),
|
||||
});
|
||||
const handleWindowOpened = createHandleAnilistSetupWindowOpenedHandler({
|
||||
setSetupWindow: () => deps.setSetupWindow(setupWindow),
|
||||
setSetupPageOpened: (opened) => deps.setSetupPageOpened(opened),
|
||||
});
|
||||
|
||||
setupWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) =>
|
||||
handleWindowOpen({ url }),
|
||||
);
|
||||
setupWindow.webContents.on('will-navigate', (event: unknown, url: string) => {
|
||||
handleWillNavigate({
|
||||
url,
|
||||
preventDefault: () => {
|
||||
if (event && typeof event === 'object' && 'preventDefault' in event) {
|
||||
const typedEvent = event as { preventDefault?: () => void };
|
||||
typedEvent.preventDefault?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
setupWindow.webContents.on('will-redirect', (event: unknown, url: string) => {
|
||||
handleWillRedirect({
|
||||
url,
|
||||
preventDefault: () => {
|
||||
if (event && typeof event === 'object' && 'preventDefault' in event) {
|
||||
const typedEvent = event as { preventDefault?: () => void };
|
||||
typedEvent.preventDefault?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
setupWindow.webContents.on('did-navigate', (_event: unknown, url: string) => {
|
||||
handleDidNavigate(url);
|
||||
});
|
||||
setupWindow.webContents.on(
|
||||
'did-fail-load',
|
||||
(
|
||||
_event: unknown,
|
||||
errorCode: number,
|
||||
errorDescription: string,
|
||||
validatedURL: string,
|
||||
) => {
|
||||
handleDidFailLoad({
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedURL,
|
||||
});
|
||||
},
|
||||
);
|
||||
setupWindow.webContents.on('did-finish-load', () => {
|
||||
handleDidFinishLoad();
|
||||
});
|
||||
loadManualTokenEntry();
|
||||
setupWindow.on('closed', () => {
|
||||
handleWindowClosed();
|
||||
});
|
||||
handleWindowOpened();
|
||||
};
|
||||
}
|
||||
|
||||
65
src/main/runtime/app-lifecycle-actions.test.ts
Normal file
65
src/main/runtime/app-lifecycle-actions.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createOnWillQuitCleanupHandler,
|
||||
createRestoreWindowsOnActivateHandler,
|
||||
createShouldRestoreWindowsOnActivateHandler,
|
||||
} from './app-lifecycle-actions';
|
||||
|
||||
test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
const calls: string[] = [];
|
||||
const cleanup = createOnWillQuitCleanupHandler({
|
||||
destroyTray: () => calls.push('destroy-tray'),
|
||||
stopConfigHotReload: () => calls.push('stop-config'),
|
||||
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
|
||||
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
|
||||
stopWindowTracker: () => calls.push('stop-tracker'),
|
||||
destroyMpvSocket: () => calls.push('destroy-socket'),
|
||||
clearReconnectTimer: () => calls.push('clear-reconnect'),
|
||||
destroySubtitleTimingTracker: () => calls.push('destroy-subtitle-tracker'),
|
||||
destroyImmersionTracker: () => calls.push('destroy-immersion'),
|
||||
destroyAnkiIntegration: () => calls.push('destroy-anki'),
|
||||
destroyAnilistSetupWindow: () => calls.push('destroy-anilist-window'),
|
||||
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
|
||||
destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'),
|
||||
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 19);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-jellyfin-remote');
|
||||
});
|
||||
|
||||
test('should restore windows on activate requires initialized runtime and no windows', () => {
|
||||
let initialized = false;
|
||||
let windowCount = 1;
|
||||
const shouldRestore = createShouldRestoreWindowsOnActivateHandler({
|
||||
isOverlayRuntimeInitialized: () => initialized,
|
||||
getAllWindowCount: () => windowCount,
|
||||
});
|
||||
|
||||
assert.equal(shouldRestore(), false);
|
||||
initialized = true;
|
||||
assert.equal(shouldRestore(), false);
|
||||
windowCount = 0;
|
||||
assert.equal(shouldRestore(), true);
|
||||
});
|
||||
|
||||
test('restore windows on activate recreates windows then syncs visibility', () => {
|
||||
const calls: string[] = [];
|
||||
const restore = createRestoreWindowsOnActivateHandler({
|
||||
createMainWindow: () => calls.push('main'),
|
||||
createInvisibleWindow: () => calls.push('invisible'),
|
||||
updateVisibleOverlayVisibility: () => calls.push('visible-sync'),
|
||||
updateInvisibleOverlayVisibility: () => calls.push('invisible-sync'),
|
||||
});
|
||||
|
||||
restore();
|
||||
assert.deepEqual(calls, ['main', 'invisible', 'visible-sync', 'invisible-sync']);
|
||||
});
|
||||
64
src/main/runtime/app-lifecycle-actions.ts
Normal file
64
src/main/runtime/app-lifecycle-actions.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export function createOnWillQuitCleanupHandler(deps: {
|
||||
destroyTray: () => void;
|
||||
stopConfigHotReload: () => void;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
destroyYomitanParserWindow: () => void;
|
||||
clearYomitanParserState: () => void;
|
||||
stopWindowTracker: () => void;
|
||||
destroyMpvSocket: () => void;
|
||||
clearReconnectTimer: () => void;
|
||||
destroySubtitleTimingTracker: () => void;
|
||||
destroyImmersionTracker: () => void;
|
||||
destroyAnkiIntegration: () => void;
|
||||
destroyAnilistSetupWindow: () => void;
|
||||
clearAnilistSetupWindow: () => void;
|
||||
destroyJellyfinSetupWindow: () => void;
|
||||
clearJellyfinSetupWindow: () => void;
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.destroyTray();
|
||||
deps.stopConfigHotReload();
|
||||
deps.restorePreviousSecondarySubVisibility();
|
||||
deps.unregisterAllGlobalShortcuts();
|
||||
deps.stopSubtitleWebsocket();
|
||||
deps.stopTexthookerService();
|
||||
deps.destroyYomitanParserWindow();
|
||||
deps.clearYomitanParserState();
|
||||
deps.stopWindowTracker();
|
||||
deps.destroyMpvSocket();
|
||||
deps.clearReconnectTimer();
|
||||
deps.destroySubtitleTimingTracker();
|
||||
deps.destroyImmersionTracker();
|
||||
deps.destroyAnkiIntegration();
|
||||
deps.destroyAnilistSetupWindow();
|
||||
deps.clearAnilistSetupWindow();
|
||||
deps.destroyJellyfinSetupWindow();
|
||||
deps.clearJellyfinSetupWindow();
|
||||
deps.stopJellyfinRemoteSession();
|
||||
};
|
||||
}
|
||||
|
||||
export function createShouldRestoreWindowsOnActivateHandler(deps: {
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
getAllWindowCount: () => number;
|
||||
}) {
|
||||
return (): boolean => deps.isOverlayRuntimeInitialized() && deps.getAllWindowCount() === 0;
|
||||
}
|
||||
|
||||
export function createRestoreWindowsOnActivateHandler(deps: {
|
||||
createMainWindow: () => void;
|
||||
createInvisibleWindow: () => void;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.createMainWindow();
|
||||
deps.createInvisibleWindow();
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
deps.updateInvisibleOverlayVisibility();
|
||||
};
|
||||
}
|
||||
32
src/main/runtime/app-lifecycle-main-activate.test.ts
Normal file
32
src/main/runtime/app-lifecycle-main-activate.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildRestoreWindowsOnActivateMainDepsHandler,
|
||||
createBuildShouldRestoreWindowsOnActivateMainDepsHandler,
|
||||
} from './app-lifecycle-main-activate';
|
||||
|
||||
test('should restore windows on activate deps builder maps visibility state checks', () => {
|
||||
const deps = createBuildShouldRestoreWindowsOnActivateMainDepsHandler({
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
getAllWindowCount: () => 0,
|
||||
})();
|
||||
|
||||
assert.equal(deps.isOverlayRuntimeInitialized(), true);
|
||||
assert.equal(deps.getAllWindowCount(), 0);
|
||||
});
|
||||
|
||||
test('restore windows on activate deps builder maps all restoration callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildRestoreWindowsOnActivateMainDepsHandler({
|
||||
createMainWindow: () => calls.push('main'),
|
||||
createInvisibleWindow: () => calls.push('invisible'),
|
||||
updateVisibleOverlayVisibility: () => calls.push('visible'),
|
||||
updateInvisibleOverlayVisibility: () => calls.push('invisible-visible'),
|
||||
})();
|
||||
|
||||
deps.createMainWindow();
|
||||
deps.createInvisibleWindow();
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
deps.updateInvisibleOverlayVisibility();
|
||||
assert.deepEqual(calls, ['main', 'invisible', 'visible', 'invisible-visible']);
|
||||
});
|
||||
23
src/main/runtime/app-lifecycle-main-activate.ts
Normal file
23
src/main/runtime/app-lifecycle-main-activate.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export function createBuildShouldRestoreWindowsOnActivateMainDepsHandler(deps: {
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
getAllWindowCount: () => number;
|
||||
}) {
|
||||
return () => ({
|
||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||
getAllWindowCount: () => deps.getAllWindowCount(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildRestoreWindowsOnActivateMainDepsHandler(deps: {
|
||||
createMainWindow: () => void;
|
||||
createInvisibleWindow: () => void;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
createMainWindow: () => deps.createMainWindow(),
|
||||
createInvisibleWindow: () => deps.createInvisibleWindow(),
|
||||
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
|
||||
updateInvisibleOverlayVisibility: () => deps.updateInvisibleOverlayVisibility(),
|
||||
});
|
||||
}
|
||||
98
src/main/runtime/app-lifecycle-main-cleanup.test.ts
Normal file
98
src/main/runtime/app-lifecycle-main-cleanup.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBuildOnWillQuitCleanupDepsHandler } from './app-lifecycle-main-cleanup';
|
||||
import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions';
|
||||
|
||||
test('cleanup deps builder returns handlers that guard optional runtime objects', () => {
|
||||
const calls: string[] = [];
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {}, 60_000);
|
||||
let immersionTracker: { destroy: () => void } | null = {
|
||||
destroy: () => calls.push('destroy-immersion'),
|
||||
};
|
||||
|
||||
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
|
||||
destroyTray: () => calls.push('destroy-tray'),
|
||||
stopConfigHotReload: () => calls.push('stop-config'),
|
||||
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
|
||||
getYomitanParserWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
destroy: () => calls.push('destroy-yomitan-window'),
|
||||
}),
|
||||
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
|
||||
|
||||
getWindowTracker: () => ({ stop: () => calls.push('stop-tracker') }),
|
||||
getMpvSocket: () => ({ destroy: () => calls.push('destroy-socket') }),
|
||||
getReconnectTimer: () => reconnectTimer,
|
||||
clearReconnectTimerRef: () => {
|
||||
reconnectTimer = null;
|
||||
calls.push('clear-reconnect-ref');
|
||||
},
|
||||
|
||||
getSubtitleTimingTracker: () => ({ destroy: () => calls.push('destroy-subtitle-tracker') }),
|
||||
getImmersionTracker: () => immersionTracker,
|
||||
clearImmersionTracker: () => {
|
||||
immersionTracker = null;
|
||||
calls.push('clear-immersion-ref');
|
||||
},
|
||||
getAnkiIntegration: () => ({ destroy: () => calls.push('destroy-anki') }),
|
||||
|
||||
getAnilistSetupWindow: () => ({ destroy: () => calls.push('destroy-anilist-window') }),
|
||||
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
|
||||
getJellyfinSetupWindow: () => ({ destroy: () => calls.push('destroy-jellyfin-window') }),
|
||||
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
|
||||
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
});
|
||||
|
||||
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
|
||||
cleanup();
|
||||
|
||||
assert.ok(calls.includes('destroy-tray'));
|
||||
assert.ok(calls.includes('destroy-yomitan-window'));
|
||||
assert.ok(calls.includes('destroy-socket'));
|
||||
assert.ok(calls.includes('clear-reconnect-ref'));
|
||||
assert.ok(calls.includes('destroy-immersion'));
|
||||
assert.ok(calls.includes('clear-immersion-ref'));
|
||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||
assert.equal(reconnectTimer, null);
|
||||
assert.equal(immersionTracker, null);
|
||||
});
|
||||
|
||||
test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
const calls: string[] = [];
|
||||
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
|
||||
destroyTray: () => {},
|
||||
stopConfigHotReload: () => {},
|
||||
restorePreviousSecondarySubVisibility: () => {},
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
getYomitanParserWindow: () => ({
|
||||
isDestroyed: () => true,
|
||||
destroy: () => calls.push('destroy-yomitan-window'),
|
||||
}),
|
||||
clearYomitanParserState: () => {},
|
||||
getWindowTracker: () => null,
|
||||
getMpvSocket: () => null,
|
||||
getReconnectTimer: () => null,
|
||||
clearReconnectTimerRef: () => {},
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getImmersionTracker: () => null,
|
||||
clearImmersionTracker: () => {},
|
||||
getAnkiIntegration: () => null,
|
||||
getAnilistSetupWindow: () => null,
|
||||
clearAnilistSetupWindow: () => {},
|
||||
getJellyfinSetupWindow: () => null,
|
||||
clearJellyfinSetupWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
});
|
||||
|
||||
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
|
||||
cleanup();
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
98
src/main/runtime/app-lifecycle-main-cleanup.ts
Normal file
98
src/main/runtime/app-lifecycle-main-cleanup.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// Narrow structural types used by cleanup assembly.
|
||||
type Destroyable = {
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
type DestroyableWindow = Destroyable & {
|
||||
isDestroyed: () => boolean;
|
||||
};
|
||||
|
||||
type Stoppable = {
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
type SocketLike = {
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
type TimerLike = ReturnType<typeof setTimeout>;
|
||||
|
||||
export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
destroyTray: () => void;
|
||||
stopConfigHotReload: () => void;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
|
||||
getYomitanParserWindow: () => DestroyableWindow | null;
|
||||
clearYomitanParserState: () => void;
|
||||
|
||||
getWindowTracker: () => Stoppable | null;
|
||||
getMpvSocket: () => SocketLike | null;
|
||||
getReconnectTimer: () => TimerLike | null;
|
||||
clearReconnectTimerRef: () => void;
|
||||
|
||||
getSubtitleTimingTracker: () => Destroyable | null;
|
||||
getImmersionTracker: () => Destroyable | null;
|
||||
clearImmersionTracker: () => void;
|
||||
getAnkiIntegration: () => Destroyable | null;
|
||||
|
||||
getAnilistSetupWindow: () => Destroyable | null;
|
||||
clearAnilistSetupWindow: () => void;
|
||||
getJellyfinSetupWindow: () => Destroyable | null;
|
||||
clearJellyfinSetupWindow: () => void;
|
||||
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
destroyTray: () => deps.destroyTray(),
|
||||
stopConfigHotReload: () => deps.stopConfigHotReload(),
|
||||
restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(),
|
||||
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
|
||||
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
|
||||
stopTexthookerService: () => deps.stopTexthookerService(),
|
||||
destroyYomitanParserWindow: () => {
|
||||
const window = deps.getYomitanParserWindow();
|
||||
if (!window) return;
|
||||
if (window.isDestroyed()) return;
|
||||
window.destroy();
|
||||
},
|
||||
clearYomitanParserState: () => deps.clearYomitanParserState(),
|
||||
stopWindowTracker: () => {
|
||||
const tracker = deps.getWindowTracker();
|
||||
tracker?.stop();
|
||||
},
|
||||
destroyMpvSocket: () => {
|
||||
const socket = deps.getMpvSocket();
|
||||
socket?.destroy();
|
||||
},
|
||||
clearReconnectTimer: () => {
|
||||
const timer = deps.getReconnectTimer();
|
||||
if (!timer) return;
|
||||
clearTimeout(timer);
|
||||
deps.clearReconnectTimerRef();
|
||||
},
|
||||
destroySubtitleTimingTracker: () => {
|
||||
deps.getSubtitleTimingTracker()?.destroy();
|
||||
},
|
||||
destroyImmersionTracker: () => {
|
||||
const tracker = deps.getImmersionTracker();
|
||||
if (!tracker) return;
|
||||
tracker.destroy();
|
||||
deps.clearImmersionTracker();
|
||||
},
|
||||
destroyAnkiIntegration: () => {
|
||||
deps.getAnkiIntegration()?.destroy();
|
||||
},
|
||||
destroyAnilistSetupWindow: () => {
|
||||
deps.getAnilistSetupWindow()?.destroy();
|
||||
},
|
||||
clearAnilistSetupWindow: () => deps.clearAnilistSetupWindow(),
|
||||
destroyJellyfinSetupWindow: () => {
|
||||
deps.getJellyfinSetupWindow()?.destroy();
|
||||
},
|
||||
clearJellyfinSetupWindow: () => deps.clearJellyfinSetupWindow(),
|
||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||
});
|
||||
}
|
||||
103
src/main/runtime/app-runtime-main-deps.test.ts
Normal file
103
src/main/runtime/app-runtime-main-deps.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildDestroyTrayMainDepsHandler,
|
||||
createBuildEnsureTrayMainDepsHandler,
|
||||
createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler,
|
||||
createBuildOpenYomitanSettingsMainDepsHandler,
|
||||
} from './app-runtime-main-deps';
|
||||
|
||||
test('ensure tray main deps trigger overlay bootstrap on tray click when runtime not initialized', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildEnsureTrayMainDepsHandler({
|
||||
getTray: () => null,
|
||||
setTray: () => calls.push('set-tray'),
|
||||
buildTrayMenu: () => ({}),
|
||||
resolveTrayIconPath: () => null,
|
||||
createImageFromPath: () => ({}),
|
||||
createEmptyImage: () => ({}),
|
||||
createTray: () => ({}),
|
||||
trayTooltip: 'SubMiner',
|
||||
platform: 'darwin',
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
|
||||
})();
|
||||
|
||||
deps.ensureOverlayVisibleFromTrayClick();
|
||||
assert.deepEqual(calls, ['init-overlay', 'set-visible:true']);
|
||||
});
|
||||
|
||||
test('destroy tray main deps map passthrough getters/setters', () => {
|
||||
let tray: unknown = { id: 'tray' };
|
||||
const deps = createBuildDestroyTrayMainDepsHandler({
|
||||
getTray: () => tray,
|
||||
setTray: (next) => {
|
||||
tray = next;
|
||||
},
|
||||
})();
|
||||
|
||||
assert.deepEqual(deps.getTray(), { id: 'tray' });
|
||||
deps.setTray(null);
|
||||
assert.equal(tray, null);
|
||||
});
|
||||
|
||||
test('initialize overlay runtime main deps map build options and callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const options = { id: 'opts' };
|
||||
const deps = createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler({
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntimeCore: (value) => {
|
||||
calls.push(`core:${JSON.stringify(value)}`);
|
||||
return { invisibleOverlayVisible: true };
|
||||
},
|
||||
buildOptions: () => options,
|
||||
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
|
||||
setOverlayRuntimeInitialized: (initialized) => calls.push(`set-initialized:${initialized}`),
|
||||
startBackgroundWarmups: () => calls.push('warmups'),
|
||||
})();
|
||||
|
||||
assert.equal(deps.isOverlayRuntimeInitialized(), false);
|
||||
assert.equal(deps.buildOptions(), options);
|
||||
assert.deepEqual(deps.initializeOverlayRuntimeCore(options), { invisibleOverlayVisible: true });
|
||||
deps.setInvisibleOverlayVisible(true);
|
||||
deps.setOverlayRuntimeInitialized(true);
|
||||
deps.startBackgroundWarmups();
|
||||
assert.deepEqual(calls, [
|
||||
'core:{"id":"opts"}',
|
||||
'set-invisible:true',
|
||||
'set-initialized:true',
|
||||
'warmups',
|
||||
]);
|
||||
});
|
||||
|
||||
test('open yomitan settings main deps map async open callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
let currentWindow: unknown = null;
|
||||
const extension = { id: 'ext' };
|
||||
const deps = createBuildOpenYomitanSettingsMainDepsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => extension,
|
||||
openYomitanSettingsWindow: ({ yomitanExt }) => calls.push(`open:${(yomitanExt as { id: string }).id}`),
|
||||
getExistingWindow: () => currentWindow,
|
||||
setWindow: (window) => {
|
||||
currentWindow = window;
|
||||
calls.push('set-window');
|
||||
},
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
})();
|
||||
|
||||
assert.equal(await deps.ensureYomitanExtensionLoaded(), extension);
|
||||
assert.equal(deps.getExistingWindow(), null);
|
||||
deps.setWindow({ id: 'win' });
|
||||
deps.openYomitanSettingsWindow({
|
||||
yomitanExt: extension,
|
||||
getExistingWindow: () => deps.getExistingWindow(),
|
||||
setWindow: (window) => deps.setWindow(window),
|
||||
});
|
||||
deps.logWarn('warn');
|
||||
deps.logError('error', new Error('boom'));
|
||||
assert.deepEqual(calls, ['set-window', 'open:ext', 'warn:warn', 'error:error']);
|
||||
assert.deepEqual(currentWindow, { id: 'win' });
|
||||
});
|
||||
89
src/main/runtime/app-runtime-main-deps.ts
Normal file
89
src/main/runtime/app-runtime-main-deps.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export function createBuildEnsureTrayMainDepsHandler(deps: {
|
||||
getTray: () => unknown | null;
|
||||
setTray: (tray: unknown | null) => void;
|
||||
buildTrayMenu: () => unknown;
|
||||
resolveTrayIconPath: () => string | null;
|
||||
createImageFromPath: (iconPath: string) => unknown;
|
||||
createEmptyImage: () => unknown;
|
||||
createTray: (icon: unknown) => unknown;
|
||||
trayTooltip: string;
|
||||
platform: string;
|
||||
logWarn: (message: string) => void;
|
||||
initializeOverlayRuntime: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
getTray: () => deps.getTray() as never,
|
||||
setTray: (tray: unknown | null) => deps.setTray(tray),
|
||||
buildTrayMenu: () => deps.buildTrayMenu() as never,
|
||||
resolveTrayIconPath: () => deps.resolveTrayIconPath(),
|
||||
createImageFromPath: (iconPath: string) => deps.createImageFromPath(iconPath) as never,
|
||||
createEmptyImage: () => deps.createEmptyImage() as never,
|
||||
createTray: (icon: unknown) => deps.createTray(icon) as never,
|
||||
trayTooltip: deps.trayTooltip,
|
||||
platform: deps.platform,
|
||||
logWarn: (message: string) => deps.logWarn(message),
|
||||
ensureOverlayVisibleFromTrayClick: () => {
|
||||
if (!deps.isOverlayRuntimeInitialized()) {
|
||||
deps.initializeOverlayRuntime();
|
||||
}
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildDestroyTrayMainDepsHandler(deps: {
|
||||
getTray: () => unknown | null;
|
||||
setTray: (tray: unknown | null) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
getTray: () => deps.getTray() as never,
|
||||
setTray: (tray: unknown | null) => deps.setTray(tray),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler(deps: {
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
initializeOverlayRuntimeCore: (options: unknown) => { invisibleOverlayVisible: boolean };
|
||||
buildOptions: () => unknown;
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
setOverlayRuntimeInitialized: (initialized: boolean) => void;
|
||||
startBackgroundWarmups: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||
initializeOverlayRuntimeCore: (options: unknown) => deps.initializeOverlayRuntimeCore(options),
|
||||
buildOptions: () => deps.buildOptions() as never,
|
||||
setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
|
||||
setOverlayRuntimeInitialized: (initialized: boolean) =>
|
||||
deps.setOverlayRuntimeInitialized(initialized),
|
||||
startBackgroundWarmups: () => deps.startBackgroundWarmups(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildOpenYomitanSettingsMainDepsHandler(deps: {
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown | null>;
|
||||
openYomitanSettingsWindow: (params: {
|
||||
yomitanExt: unknown;
|
||||
getExistingWindow: () => unknown | null;
|
||||
setWindow: (window: unknown | null) => void;
|
||||
}) => void;
|
||||
getExistingWindow: () => unknown | null;
|
||||
setWindow: (window: unknown | null) => void;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
ensureYomitanExtensionLoaded: () => deps.ensureYomitanExtensionLoaded(),
|
||||
openYomitanSettingsWindow: (params: {
|
||||
yomitanExt: unknown;
|
||||
getExistingWindow: () => unknown | null;
|
||||
setWindow: (window: unknown | null) => void;
|
||||
}) => deps.openYomitanSettingsWindow(params),
|
||||
getExistingWindow: () => deps.getExistingWindow(),
|
||||
setWindow: (window: unknown | null) => deps.setWindow(window),
|
||||
logWarn: (message: string) => deps.logWarn(message),
|
||||
logError: (message: string, error: unknown) => deps.logError(message, error),
|
||||
});
|
||||
}
|
||||
102
src/main/runtime/cli-command-context-main-deps.test.ts
Normal file
102
src/main/runtime/cli-command-context-main-deps.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBuildCliCommandContextMainDepsHandler } from './cli-command-context-main-deps';
|
||||
|
||||
test('cli command context main deps builder maps state and callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
const appState = {
|
||||
mpvSocketPath: '/tmp/mpv.sock',
|
||||
mpvClient: null as unknown,
|
||||
texthookerPort: 5174,
|
||||
overlayRuntimeInitialized: false,
|
||||
};
|
||||
|
||||
const build = createBuildCliCommandContextMainDepsHandler({
|
||||
appState,
|
||||
texthookerService: { start: () => null },
|
||||
getResolvedConfig: () => ({ texthooker: { openBrowser: true } }),
|
||||
openExternal: async (url) => {
|
||||
calls.push(`open:${url}`);
|
||||
},
|
||||
logBrowserOpenError: (url) => calls.push(`open-error:${url}`),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
|
||||
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
|
||||
|
||||
copyCurrentSubtitle: () => calls.push('copy-sub'),
|
||||
startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`),
|
||||
mineSentenceCard: async () => {
|
||||
calls.push('mine');
|
||||
},
|
||||
startPendingMineSentenceMultiple: (timeoutMs) => calls.push(`mine-multi:${timeoutMs}`),
|
||||
updateLastCardFromClipboard: async () => {
|
||||
calls.push('update-last-card');
|
||||
},
|
||||
refreshKnownWordCache: async () => {
|
||||
calls.push('refresh-known');
|
||||
},
|
||||
triggerFieldGrouping: async () => {
|
||||
calls.push('field-grouping');
|
||||
},
|
||||
triggerSubsyncFromConfig: async () => {
|
||||
calls.push('subsync');
|
||||
},
|
||||
markLastCardAsAudioCard: async () => {
|
||||
calls.push('mark-audio');
|
||||
},
|
||||
|
||||
getAnilistStatus: () => ({ status: 'ok' }),
|
||||
clearAnilistToken: () => calls.push('clear-token'),
|
||||
openAnilistSetupWindow: () => calls.push('open-anilist-setup'),
|
||||
openJellyfinSetupWindow: () => calls.push('open-jellyfin-setup'),
|
||||
getAnilistQueueStatus: () => ({ queued: 1 }),
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
|
||||
runJellyfinCommand: async () => {
|
||||
calls.push('run-jellyfin');
|
||||
},
|
||||
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
|
||||
printHelp: () => calls.push('help'),
|
||||
stopApp: () => calls.push('stop-app'),
|
||||
hasMainWindow: () => true,
|
||||
getMultiCopyTimeoutMs: () => 5000,
|
||||
schedule: (fn) => {
|
||||
fn();
|
||||
return setTimeout(() => {}, 0);
|
||||
},
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
});
|
||||
|
||||
const deps = build();
|
||||
assert.equal(deps.getSocketPath(), '/tmp/mpv.sock');
|
||||
deps.setSocketPath('/tmp/next.sock');
|
||||
assert.equal(appState.mpvSocketPath, '/tmp/next.sock');
|
||||
assert.equal(deps.getTexthookerPort(), 5174);
|
||||
deps.setTexthookerPort(5175);
|
||||
assert.equal(appState.texthookerPort, 5175);
|
||||
assert.equal(deps.shouldOpenBrowser(), true);
|
||||
deps.showOsd('hello');
|
||||
deps.initializeOverlay();
|
||||
deps.setVisibleOverlay(true);
|
||||
deps.setInvisibleOverlay(false);
|
||||
deps.printHelp();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'osd:hello',
|
||||
'init-overlay',
|
||||
'set-visible:true',
|
||||
'set-invisible:false',
|
||||
'help',
|
||||
]);
|
||||
|
||||
const retry = await deps.retryAnilistQueueNow();
|
||||
assert.deepEqual(retry, { ok: true, message: 'ok' });
|
||||
});
|
||||
102
src/main/runtime/cli-command-context-main-deps.ts
Normal file
102
src/main/runtime/cli-command-context-main-deps.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
|
||||
export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
appState: {
|
||||
mpvSocketPath: string;
|
||||
mpvClient: unknown | null;
|
||||
texthookerPort: number;
|
||||
overlayRuntimeInitialized: boolean;
|
||||
};
|
||||
texthookerService: unknown;
|
||||
getResolvedConfig: () => { texthooker?: { openBrowser?: boolean } };
|
||||
openExternal: (url: string) => Promise<unknown>;
|
||||
logBrowserOpenError: (url: string, error: unknown) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
toggleInvisibleOverlay: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
mineSentenceCard: () => Promise<void>;
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
||||
updateLastCardFromClipboard: () => Promise<void>;
|
||||
refreshKnownWordCache: () => Promise<void>;
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
|
||||
getAnilistStatus: () => unknown;
|
||||
clearAnilistToken: () => void;
|
||||
openAnilistSetupWindow: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
stopApp: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
getSocketPath: () => deps.appState.mpvSocketPath,
|
||||
setSocketPath: (socketPath: string) => {
|
||||
deps.appState.mpvSocketPath = socketPath;
|
||||
},
|
||||
getMpvClient: () => deps.appState.mpvClient as never,
|
||||
showOsd: (text: string) => deps.showMpvOsd(text),
|
||||
texthookerService: deps.texthookerService as never,
|
||||
getTexthookerPort: () => deps.appState.texthookerPort,
|
||||
setTexthookerPort: (port: number) => {
|
||||
deps.appState.texthookerPort = port;
|
||||
},
|
||||
shouldOpenBrowser: () => deps.getResolvedConfig().texthooker?.openBrowser !== false,
|
||||
openExternal: (url: string) => deps.openExternal(url),
|
||||
logBrowserOpenError: (url: string, error: unknown) => deps.logBrowserOpenError(url, error),
|
||||
isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized,
|
||||
initializeOverlay: () => deps.initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
|
||||
toggleInvisibleOverlay: () => deps.toggleInvisibleOverlay(),
|
||||
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
|
||||
setInvisibleOverlay: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
|
||||
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
|
||||
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
|
||||
mineSentenceCard: () => deps.mineSentenceCard(),
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) =>
|
||||
deps.startPendingMineSentenceMultiple(timeoutMs),
|
||||
updateLastCardFromClipboard: () => deps.updateLastCardFromClipboard(),
|
||||
refreshKnownWordCache: () => deps.refreshKnownWordCache(),
|
||||
triggerFieldGrouping: () => deps.triggerFieldGrouping(),
|
||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||
markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(),
|
||||
getAnilistStatus: () => deps.getAnilistStatus() as never,
|
||||
clearAnilistToken: () => deps.clearAnilistToken(),
|
||||
openAnilistSetup: () => deps.openAnilistSetupWindow(),
|
||||
openJellyfinSetup: () => deps.openJellyfinSetupWindow(),
|
||||
getAnilistQueueStatus: () => deps.getAnilistQueueStatus() as never,
|
||||
retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(),
|
||||
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
|
||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
printHelp: () => deps.printHelp(),
|
||||
stopApp: () => deps.stopApp(),
|
||||
hasMainWindow: () => deps.hasMainWindow(),
|
||||
getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(),
|
||||
schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
logWarn: (message: string) => deps.logWarn(message),
|
||||
logError: (message: string, err: unknown) => deps.logError(message, err),
|
||||
});
|
||||
}
|
||||
66
src/main/runtime/global-shortcuts-main-deps.test.ts
Normal file
66
src/main/runtime/global-shortcuts-main-deps.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildGetConfiguredShortcutsMainDepsHandler,
|
||||
createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler,
|
||||
createBuildRegisterGlobalShortcutsMainDepsHandler,
|
||||
} from './global-shortcuts-main-deps';
|
||||
|
||||
test('get configured shortcuts main deps map config resolver inputs', () => {
|
||||
const config = { shortcuts: { copySubtitle: 's' } } as never;
|
||||
const defaults = { shortcuts: { copySubtitle: 'c' } } as never;
|
||||
const build = createBuildGetConfiguredShortcutsMainDepsHandler({
|
||||
getResolvedConfig: () => config,
|
||||
defaultConfig: defaults,
|
||||
resolveConfiguredShortcuts: (nextConfig, nextDefaults) => ({ nextConfig, nextDefaults }) as never,
|
||||
});
|
||||
|
||||
const deps = build();
|
||||
assert.equal(deps.getResolvedConfig(), config);
|
||||
assert.equal(deps.defaultConfig, defaults);
|
||||
assert.deepEqual(deps.resolveConfiguredShortcuts(config, defaults), { nextConfig: config, nextDefaults: defaults });
|
||||
});
|
||||
|
||||
test('register global shortcuts main deps map callbacks and flags', () => {
|
||||
const calls: string[] = [];
|
||||
const mainWindow = { id: 'main' };
|
||||
const build = createBuildRegisterGlobalShortcutsMainDepsHandler({
|
||||
getConfiguredShortcuts: () => ({ copySubtitle: 's' } as never),
|
||||
registerGlobalShortcutsCore: () => calls.push('register'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
isDev: true,
|
||||
getMainWindow: () => mainWindow as never,
|
||||
});
|
||||
|
||||
const deps = build();
|
||||
deps.registerGlobalShortcutsCore({
|
||||
shortcuts: deps.getConfiguredShortcuts(),
|
||||
onToggleVisibleOverlay: () => undefined,
|
||||
onToggleInvisibleOverlay: () => undefined,
|
||||
onOpenYomitanSettings: () => undefined,
|
||||
isDev: deps.isDev,
|
||||
getMainWindow: deps.getMainWindow,
|
||||
});
|
||||
deps.onToggleVisibleOverlay();
|
||||
deps.onToggleInvisibleOverlay();
|
||||
deps.onOpenYomitanSettings();
|
||||
assert.equal(deps.isDev, true);
|
||||
assert.deepEqual(deps.getMainWindow(), mainWindow);
|
||||
assert.deepEqual(calls, ['register', 'toggle-visible', 'toggle-invisible', 'open-yomitan']);
|
||||
});
|
||||
|
||||
test('refresh global shortcuts main deps map passthrough handlers', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler({
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister'),
|
||||
registerGlobalShortcuts: () => calls.push('register'),
|
||||
syncOverlayShortcuts: () => calls.push('sync'),
|
||||
})();
|
||||
|
||||
deps.unregisterAllGlobalShortcuts();
|
||||
deps.registerGlobalShortcuts();
|
||||
deps.syncOverlayShortcuts();
|
||||
assert.deepEqual(calls, ['unregister', 'register', 'sync']);
|
||||
});
|
||||
49
src/main/runtime/global-shortcuts-main-deps.ts
Normal file
49
src/main/runtime/global-shortcuts-main-deps.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Config } from '../../types';
|
||||
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
import type { RegisterGlobalShortcutsServiceOptions } from '../../core/services/shortcut';
|
||||
|
||||
export function createBuildGetConfiguredShortcutsMainDepsHandler(deps: {
|
||||
getResolvedConfig: () => Config;
|
||||
defaultConfig: Config;
|
||||
resolveConfiguredShortcuts: (config: Config, defaultConfig: Config) => ConfiguredShortcuts;
|
||||
}) {
|
||||
return () => ({
|
||||
getResolvedConfig: () => deps.getResolvedConfig(),
|
||||
defaultConfig: deps.defaultConfig,
|
||||
resolveConfiguredShortcuts: (config: Config, defaultConfig: Config) =>
|
||||
deps.resolveConfiguredShortcuts(config, defaultConfig),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildRegisterGlobalShortcutsMainDepsHandler(deps: {
|
||||
getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts'];
|
||||
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
toggleInvisibleOverlay: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
isDev: boolean;
|
||||
getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow'];
|
||||
}) {
|
||||
return () => ({
|
||||
getConfiguredShortcuts: () => deps.getConfiguredShortcuts(),
|
||||
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) =>
|
||||
deps.registerGlobalShortcutsCore(options),
|
||||
onToggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
|
||||
onToggleInvisibleOverlay: () => deps.toggleInvisibleOverlay(),
|
||||
onOpenYomitanSettings: () => deps.openYomitanSettings(),
|
||||
isDev: deps.isDev,
|
||||
getMainWindow: deps.getMainWindow,
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler(deps: {
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
registerGlobalShortcuts: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
|
||||
registerGlobalShortcuts: () => deps.registerGlobalShortcuts(),
|
||||
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
||||
});
|
||||
}
|
||||
30
src/main/runtime/initial-args-main-deps.test.ts
Normal file
30
src/main/runtime/initial-args-main-deps.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBuildHandleInitialArgsMainDepsHandler } from './initial-args-main-deps';
|
||||
|
||||
test('initial args main deps builder maps runtime callbacks and state readers', () => {
|
||||
const calls: string[] = [];
|
||||
const args = { start: true } as never;
|
||||
const mpvClient = { connected: false, connect: () => calls.push('connect') };
|
||||
const deps = createBuildHandleInitialArgsMainDepsHandler({
|
||||
getInitialArgs: () => args,
|
||||
isBackgroundMode: () => true,
|
||||
ensureTray: () => calls.push('ensure-tray'),
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
getMpvClient: () => mpvClient,
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
|
||||
})();
|
||||
|
||||
assert.equal(deps.getInitialArgs(), args);
|
||||
assert.equal(deps.isBackgroundMode(), true);
|
||||
assert.equal(deps.isTexthookerOnlyMode(), false);
|
||||
assert.equal(deps.hasImmersionTracker(), true);
|
||||
assert.equal(deps.getMpvClient(), mpvClient);
|
||||
|
||||
deps.ensureTray();
|
||||
deps.logInfo('x');
|
||||
deps.handleCliCommand(args, 'initial');
|
||||
assert.deepEqual(calls, ['ensure-tray', 'info:x', 'cli:initial']);
|
||||
});
|
||||
23
src/main/runtime/initial-args-main-deps.ts
Normal file
23
src/main/runtime/initial-args-main-deps.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
|
||||
export function createBuildHandleInitialArgsMainDepsHandler(deps: {
|
||||
getInitialArgs: () => CliArgs | null;
|
||||
isBackgroundMode: () => boolean;
|
||||
ensureTray: () => void;
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
hasImmersionTracker: () => boolean;
|
||||
getMpvClient: () => { connected: boolean; connect: () => void } | null;
|
||||
logInfo: (message: string) => void;
|
||||
handleCliCommand: (args: CliArgs, source: 'initial') => void;
|
||||
}) {
|
||||
return () => ({
|
||||
getInitialArgs: () => deps.getInitialArgs(),
|
||||
isBackgroundMode: () => deps.isBackgroundMode(),
|
||||
ensureTray: () => deps.ensureTray(),
|
||||
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
|
||||
hasImmersionTracker: () => deps.hasImmersionTracker(),
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
handleCliCommand: (args: CliArgs, source: 'initial') => deps.handleCliCommand(args, source),
|
||||
});
|
||||
}
|
||||
132
src/main/runtime/jellyfin-command-dispatch.test.ts
Normal file
132
src/main/runtime/jellyfin-command-dispatch.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createRunJellyfinCommandHandler } from './jellyfin-command-dispatch';
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
|
||||
function createArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
raw: [],
|
||||
target: null,
|
||||
start: false,
|
||||
stats: false,
|
||||
listRecent: false,
|
||||
listMediaInfo: false,
|
||||
subs: false,
|
||||
noAnki: false,
|
||||
noKnown: false,
|
||||
noAnilist: false,
|
||||
anilistStatus: false,
|
||||
clearAnilistToken: false,
|
||||
anilistSetup: false,
|
||||
anilistQueueStatus: false,
|
||||
anilistQueueRetry: false,
|
||||
yomitanSettings: false,
|
||||
toggleOverlay: false,
|
||||
hideOverlay: false,
|
||||
showOverlay: false,
|
||||
toggleInvisibleOverlay: false,
|
||||
hideInvisibleOverlay: false,
|
||||
showInvisibleOverlay: false,
|
||||
copyCurrentSubtitle: false,
|
||||
multiCopy: false,
|
||||
mineSentence: false,
|
||||
mineSentenceMultiple: false,
|
||||
updateLastCardFromClipboard: false,
|
||||
refreshKnownCache: false,
|
||||
triggerFieldGrouping: false,
|
||||
manualSubsync: false,
|
||||
markAudioCard: false,
|
||||
cycleSecondarySub: false,
|
||||
runtimeOptions: false,
|
||||
debugOverlay: false,
|
||||
jellyfinSetup: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLibraries: false,
|
||||
jellyfinItems: false,
|
||||
jellyfinSubtitles: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinRemoteAnnounce: false,
|
||||
help: false,
|
||||
...overrides,
|
||||
} as CliArgs;
|
||||
}
|
||||
|
||||
test('run jellyfin command returns after auth branch handles command', async () => {
|
||||
const calls: string[] = [];
|
||||
const run = createRunJellyfinCommandHandler({
|
||||
getJellyfinConfig: () => ({ serverUrl: 'http://localhost:8096' }),
|
||||
defaultServerUrl: 'http://127.0.0.1:8096',
|
||||
getJellyfinClientInfo: () => ({ clientName: 'SubMiner' }),
|
||||
handleAuthCommands: async () => {
|
||||
calls.push('auth');
|
||||
return true;
|
||||
},
|
||||
handleRemoteAnnounceCommand: async () => {
|
||||
calls.push('remote');
|
||||
return false;
|
||||
},
|
||||
handleListCommands: async () => {
|
||||
calls.push('list');
|
||||
return false;
|
||||
},
|
||||
handlePlayCommand: async () => {
|
||||
calls.push('play');
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
await run(createArgs());
|
||||
assert.deepEqual(calls, ['auth']);
|
||||
});
|
||||
|
||||
test('run jellyfin command throws when session missing after auth', async () => {
|
||||
const run = createRunJellyfinCommandHandler({
|
||||
getJellyfinConfig: () => ({ serverUrl: '', accessToken: '', userId: '' }),
|
||||
defaultServerUrl: '',
|
||||
getJellyfinClientInfo: () => ({ clientName: 'SubMiner' }),
|
||||
handleAuthCommands: async () => false,
|
||||
handleRemoteAnnounceCommand: async () => false,
|
||||
handleListCommands: async () => false,
|
||||
handlePlayCommand: async () => false,
|
||||
});
|
||||
|
||||
await assert.rejects(() => run(createArgs()), /Missing Jellyfin session/);
|
||||
});
|
||||
|
||||
test('run jellyfin command dispatches remote/list/play in order until handled', async () => {
|
||||
const calls: string[] = [];
|
||||
const seenServerUrls: string[] = [];
|
||||
const run = createRunJellyfinCommandHandler({
|
||||
getJellyfinConfig: () => ({
|
||||
serverUrl: 'http://localhost:8096',
|
||||
accessToken: 'token',
|
||||
userId: 'uid',
|
||||
username: 'alice',
|
||||
}),
|
||||
defaultServerUrl: 'http://127.0.0.1:8096',
|
||||
getJellyfinClientInfo: () => ({ clientName: 'SubMiner' }),
|
||||
handleAuthCommands: async ({ serverUrl }) => {
|
||||
calls.push('auth');
|
||||
seenServerUrls.push(serverUrl);
|
||||
return false;
|
||||
},
|
||||
handleRemoteAnnounceCommand: async () => {
|
||||
calls.push('remote');
|
||||
return false;
|
||||
},
|
||||
handleListCommands: async ({ session }) => {
|
||||
calls.push('list');
|
||||
seenServerUrls.push(session.serverUrl);
|
||||
return true;
|
||||
},
|
||||
handlePlayCommand: async () => {
|
||||
calls.push('play');
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
await run(createArgs({ jellyfinServer: 'http://override:8096' }));
|
||||
|
||||
assert.deepEqual(calls, ['auth', 'remote', 'list']);
|
||||
assert.deepEqual(seenServerUrls, ['http://override:8096', 'http://override:8096']);
|
||||
});
|
||||
100
src/main/runtime/jellyfin-command-dispatch.ts
Normal file
100
src/main/runtime/jellyfin-command-dispatch.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
|
||||
type JellyfinConfigBase = {
|
||||
serverUrl?: string;
|
||||
accessToken?: string;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
type JellyfinSession = {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export function createRunJellyfinCommandHandler<
|
||||
TClientInfo,
|
||||
TConfig extends JellyfinConfigBase,
|
||||
>(deps: {
|
||||
getJellyfinConfig: () => TConfig;
|
||||
defaultServerUrl: string;
|
||||
getJellyfinClientInfo: (config: TConfig) => TClientInfo;
|
||||
handleAuthCommands: (params: {
|
||||
args: CliArgs;
|
||||
jellyfinConfig: TConfig;
|
||||
serverUrl: string;
|
||||
clientInfo: TClientInfo;
|
||||
}) => Promise<boolean>;
|
||||
handleRemoteAnnounceCommand: (args: CliArgs) => Promise<boolean>;
|
||||
handleListCommands: (params: {
|
||||
args: CliArgs;
|
||||
session: JellyfinSession;
|
||||
clientInfo: TClientInfo;
|
||||
jellyfinConfig: TConfig;
|
||||
}) => Promise<boolean>;
|
||||
handlePlayCommand: (params: {
|
||||
args: CliArgs;
|
||||
session: JellyfinSession;
|
||||
clientInfo: TClientInfo;
|
||||
jellyfinConfig: TConfig;
|
||||
}) => Promise<boolean>;
|
||||
}) {
|
||||
return async (args: CliArgs): Promise<void> => {
|
||||
const jellyfinConfig = deps.getJellyfinConfig();
|
||||
const serverUrl =
|
||||
args.jellyfinServer?.trim() || jellyfinConfig.serverUrl || deps.defaultServerUrl;
|
||||
const clientInfo = deps.getJellyfinClientInfo(jellyfinConfig);
|
||||
|
||||
if (
|
||||
await deps.handleAuthCommands({
|
||||
args,
|
||||
jellyfinConfig,
|
||||
serverUrl,
|
||||
clientInfo,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accessToken = jellyfinConfig.accessToken;
|
||||
const userId = jellyfinConfig.userId;
|
||||
if (!serverUrl || !accessToken || !userId) {
|
||||
throw new Error('Missing Jellyfin session. Run --jellyfin-login first.');
|
||||
}
|
||||
|
||||
const session: JellyfinSession = {
|
||||
serverUrl,
|
||||
accessToken,
|
||||
userId,
|
||||
username: jellyfinConfig.username || '',
|
||||
};
|
||||
|
||||
if (await deps.handleRemoteAnnounceCommand(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
await deps.handleListCommands({
|
||||
args,
|
||||
session,
|
||||
clientInfo,
|
||||
jellyfinConfig,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
await deps.handlePlayCommand({
|
||||
args,
|
||||
session,
|
||||
clientInfo,
|
||||
jellyfinConfig,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
109
src/main/runtime/jellyfin-playback-launch.test.ts
Normal file
109
src/main/runtime/jellyfin-playback-launch.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPlayJellyfinItemInMpvHandler } from './jellyfin-playback-launch';
|
||||
|
||||
const baseSession = {
|
||||
serverUrl: 'http://localhost:8096',
|
||||
accessToken: 'token',
|
||||
userId: 'uid',
|
||||
username: 'alice',
|
||||
};
|
||||
|
||||
const baseClientInfo = {
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0.0',
|
||||
deviceId: 'did',
|
||||
};
|
||||
|
||||
test('playback handler throws when mpv is not connected', async () => {
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => false,
|
||||
getMpvClient: () => null,
|
||||
resolvePlaybackPlan: async () => {
|
||||
throw new Error('unreachable');
|
||||
},
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
sendMpvCommand: () => {},
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: () => {},
|
||||
setActivePlayback: () => {},
|
||||
setLastProgressAtMs: () => {},
|
||||
reportPlaying: () => {},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-1',
|
||||
}),
|
||||
/MPV not connected and auto-launch failed/,
|
||||
);
|
||||
});
|
||||
|
||||
test('playback handler drives mpv commands and playback state', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const scheduled: Array<{ delay: number; callback: () => void }> = [];
|
||||
const calls: string[] = [];
|
||||
const activeStates: Array<Record<string, unknown>> = [];
|
||||
const reportPayloads: Array<Record<string, unknown>> = [];
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => true,
|
||||
getMpvClient: () => ({ connected: true }),
|
||||
resolvePlaybackPlan: async () => ({
|
||||
url: 'https://stream.example/video.m3u8',
|
||||
mode: 'direct',
|
||||
title: 'Episode 1',
|
||||
startTimeTicks: 12_000_000,
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => calls.push('defaults'),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
armQuitOnDisconnect: () => calls.push('arm'),
|
||||
schedule: (callback, delayMs) => {
|
||||
scheduled.push({ delay: delayMs, callback });
|
||||
},
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: () => calls.push('preload'),
|
||||
setActivePlayback: (state) => activeStates.push(state as Record<string, unknown>),
|
||||
setLastProgressAtMs: (value) => calls.push(`progress:${value}`),
|
||||
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-1',
|
||||
});
|
||||
|
||||
assert.deepEqual(commands.slice(0, 5), [
|
||||
['set_property', 'sub-auto', 'no'],
|
||||
['loadfile', 'https://stream.example/video.m3u8', 'replace'],
|
||||
['set_property', 'force-media-title', '[Jellyfin/direct] Episode 1'],
|
||||
['set_property', 'sid', 'no'],
|
||||
['seek', 1.2, 'absolute+exact'],
|
||||
]);
|
||||
assert.equal(scheduled.length, 1);
|
||||
assert.equal(scheduled[0]?.delay, 500);
|
||||
scheduled[0]?.callback();
|
||||
assert.deepEqual(commands[commands.length - 1], ['set_property', 'sid', 'no']);
|
||||
|
||||
assert.ok(calls.includes('defaults'));
|
||||
assert.ok(calls.includes('arm'));
|
||||
assert.ok(calls.includes('preload'));
|
||||
assert.ok(calls.includes('progress:0'));
|
||||
assert.ok(calls.includes('osd:Jellyfin direct: Episode 1'));
|
||||
|
||||
assert.equal(activeStates.length, 1);
|
||||
assert.equal(activeStates[0]?.playMethod, 'DirectPlay');
|
||||
assert.equal(reportPayloads.length, 1);
|
||||
assert.equal(reportPayloads[0]?.eventName, 'start');
|
||||
});
|
||||
138
src/main/runtime/jellyfin-playback-launch.ts
Normal file
138
src/main/runtime/jellyfin-playback-launch.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
type JellyfinSession = {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
type JellyfinClientInfo = {
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
deviceId: string;
|
||||
};
|
||||
|
||||
type JellyfinPlaybackPlan = {
|
||||
url: string;
|
||||
mode: 'direct' | 'transcode';
|
||||
title: string;
|
||||
startTimeTicks: number;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
};
|
||||
|
||||
type ActivePlaybackState = {
|
||||
itemId: string;
|
||||
mediaSourceId: undefined;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
};
|
||||
|
||||
type MpvClientLike = unknown;
|
||||
|
||||
export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
ensureMpvConnectedForPlayback: () => Promise<boolean>;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
resolvePlaybackPlan: (params: {
|
||||
session: JellyfinSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
jellyfinConfig: unknown;
|
||||
itemId: string;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
}) => Promise<JellyfinPlaybackPlan>;
|
||||
applyJellyfinMpvDefaults: (mpvClient: MpvClientLike) => void;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
armQuitOnDisconnect: () => void;
|
||||
schedule: (callback: () => void, delayMs: number) => void;
|
||||
convertTicksToSeconds: (ticks: number) => number;
|
||||
preloadExternalSubtitles: (params: {
|
||||
session: JellyfinSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
itemId: string;
|
||||
}) => void;
|
||||
setActivePlayback: (state: ActivePlaybackState) => void;
|
||||
setLastProgressAtMs: (value: number) => void;
|
||||
reportPlaying: (payload: {
|
||||
itemId: string;
|
||||
mediaSourceId: undefined;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
eventName: 'start';
|
||||
}) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) {
|
||||
return async (params: {
|
||||
session: JellyfinSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
jellyfinConfig: unknown;
|
||||
itemId: string;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
startTimeTicksOverride?: number;
|
||||
setQuitOnDisconnectArm?: boolean;
|
||||
}): Promise<void> => {
|
||||
const connected = await deps.ensureMpvConnectedForPlayback();
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (!connected || !mpvClient) {
|
||||
throw new Error(
|
||||
'MPV not connected and auto-launch failed. Ensure mpv is installed and available in PATH.',
|
||||
);
|
||||
}
|
||||
|
||||
const plan = await deps.resolvePlaybackPlan({
|
||||
session: params.session,
|
||||
clientInfo: params.clientInfo,
|
||||
jellyfinConfig: params.jellyfinConfig,
|
||||
itemId: params.itemId,
|
||||
audioStreamIndex: params.audioStreamIndex,
|
||||
subtitleStreamIndex: params.subtitleStreamIndex,
|
||||
});
|
||||
|
||||
deps.applyJellyfinMpvDefaults(mpvClient);
|
||||
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||
deps.sendMpvCommand(['loadfile', plan.url, 'replace']);
|
||||
if (params.setQuitOnDisconnectArm !== false) {
|
||||
deps.armQuitOnDisconnect();
|
||||
}
|
||||
deps.sendMpvCommand(['set_property', 'force-media-title', `[Jellyfin/${plan.mode}] ${plan.title}`]);
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
deps.schedule(() => {
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
}, 500);
|
||||
|
||||
const startTimeTicks =
|
||||
typeof params.startTimeTicksOverride === 'number'
|
||||
? Math.max(0, params.startTimeTicksOverride)
|
||||
: plan.startTimeTicks;
|
||||
if (startTimeTicks > 0) {
|
||||
deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']);
|
||||
}
|
||||
|
||||
deps.preloadExternalSubtitles({
|
||||
session: params.session,
|
||||
clientInfo: params.clientInfo,
|
||||
itemId: params.itemId,
|
||||
});
|
||||
|
||||
const playMethod = plan.mode === 'direct' ? 'DirectPlay' : 'Transcode';
|
||||
deps.setActivePlayback({
|
||||
itemId: params.itemId,
|
||||
mediaSourceId: undefined,
|
||||
audioStreamIndex: plan.audioStreamIndex,
|
||||
subtitleStreamIndex: plan.subtitleStreamIndex,
|
||||
playMethod,
|
||||
});
|
||||
deps.setLastProgressAtMs(0);
|
||||
deps.reportPlaying({
|
||||
itemId: params.itemId,
|
||||
mediaSourceId: undefined,
|
||||
playMethod,
|
||||
audioStreamIndex: plan.audioStreamIndex,
|
||||
subtitleStreamIndex: plan.subtitleStreamIndex,
|
||||
eventName: 'start',
|
||||
});
|
||||
deps.showMpvOsd(`Jellyfin ${plan.mode}: ${plan.title}`);
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createHandleJellyfinSetupSubmissionHandler,
|
||||
createHandleJellyfinSetupWindowOpenedHandler,
|
||||
createMaybeFocusExistingJellyfinSetupWindowHandler,
|
||||
createOpenJellyfinSetupWindowHandler,
|
||||
parseJellyfinSetupSubmissionUrl,
|
||||
} from './jellyfin-setup-window';
|
||||
|
||||
@@ -144,3 +145,114 @@ test('createHandleJellyfinSetupWindowOpenedHandler sets setup window ref', () =>
|
||||
handler();
|
||||
assert.equal(set, true);
|
||||
});
|
||||
|
||||
test('createOpenJellyfinSetupWindowHandler no-ops when existing setup window is focused', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createOpenJellyfinSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => {
|
||||
calls.push('focus-existing');
|
||||
return true;
|
||||
},
|
||||
createSetupWindow: () => {
|
||||
calls.push('create-window');
|
||||
throw new Error('should not create');
|
||||
},
|
||||
getResolvedJellyfinConfig: () => ({}),
|
||||
buildSetupFormHtml: () => '<html></html>',
|
||||
parseSubmissionUrl: () => null,
|
||||
authenticateWithPassword: async () => {
|
||||
throw new Error('should not auth');
|
||||
},
|
||||
getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }),
|
||||
patchJellyfinConfig: () => {},
|
||||
logInfo: () => {},
|
||||
logError: () => {},
|
||||
showMpvOsd: () => {},
|
||||
clearSetupWindow: () => {},
|
||||
setSetupWindow: () => {},
|
||||
encodeURIComponent: (value) => value,
|
||||
});
|
||||
|
||||
handler();
|
||||
assert.deepEqual(calls, ['focus-existing']);
|
||||
});
|
||||
|
||||
test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window lifecycle', async () => {
|
||||
let willNavigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null;
|
||||
let closedHandler: (() => void) | null = null;
|
||||
let prevented = false;
|
||||
const calls: string[] = [];
|
||||
const fakeWindow = {
|
||||
focus: () => {},
|
||||
webContents: {
|
||||
on: (event: 'will-navigate', handler: (event: { preventDefault: () => void }, url: string) => void) => {
|
||||
if (event === 'will-navigate') {
|
||||
willNavigateHandler = handler;
|
||||
}
|
||||
},
|
||||
},
|
||||
loadURL: (url: string) => {
|
||||
calls.push(`load:${url.startsWith('data:text/html;charset=utf-8,') ? 'data-url' : 'other'}`);
|
||||
},
|
||||
on: (event: 'closed', handler: () => void) => {
|
||||
if (event === 'closed') {
|
||||
closedHandler = handler;
|
||||
}
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
close: () => calls.push('close'),
|
||||
};
|
||||
|
||||
const handler = createOpenJellyfinSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => false,
|
||||
createSetupWindow: () => fakeWindow,
|
||||
getResolvedJellyfinConfig: () => ({ serverUrl: 'http://localhost:8096', username: 'alice' }),
|
||||
buildSetupFormHtml: (server, username) => `<html>${server}|${username}</html>`,
|
||||
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
|
||||
authenticateWithPassword: async () => ({
|
||||
serverUrl: 'http://localhost:8096',
|
||||
username: 'alice',
|
||||
accessToken: 'token',
|
||||
userId: 'uid',
|
||||
}),
|
||||
getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }),
|
||||
patchJellyfinConfig: () => calls.push('patch'),
|
||||
logInfo: () => calls.push('info'),
|
||||
logError: () => calls.push('error'),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
clearSetupWindow: () => calls.push('clear-window'),
|
||||
setSetupWindow: () => calls.push('set-window'),
|
||||
encodeURIComponent: (value) => encodeURIComponent(value),
|
||||
});
|
||||
|
||||
handler();
|
||||
assert.ok(willNavigateHandler);
|
||||
assert.ok(closedHandler);
|
||||
assert.deepEqual(calls.slice(0, 2), ['load:data-url', 'set-window']);
|
||||
|
||||
const navHandler = willNavigateHandler as ((event: { preventDefault: () => void }, url: string) => void) | null;
|
||||
if (!navHandler) {
|
||||
throw new Error('missing will-navigate handler');
|
||||
}
|
||||
navHandler(
|
||||
{
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
},
|
||||
'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=alice&password=pass',
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.ok(calls.includes('patch'));
|
||||
assert.ok(calls.includes('osd:Jellyfin login success'));
|
||||
assert.ok(calls.includes('close'));
|
||||
|
||||
const onClosed = closedHandler as (() => void) | null;
|
||||
if (!onClosed) {
|
||||
throw new Error('missing closed handler');
|
||||
}
|
||||
onClosed();
|
||||
assert.ok(calls.includes('clear-window'));
|
||||
});
|
||||
|
||||
@@ -15,6 +15,18 @@ type FocusableWindowLike = {
|
||||
focus: () => void;
|
||||
};
|
||||
|
||||
type JellyfinSetupWebContentsLike = {
|
||||
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
|
||||
};
|
||||
|
||||
type JellyfinSetupWindowLike = FocusableWindowLike & {
|
||||
webContents: JellyfinSetupWebContentsLike;
|
||||
loadURL: (url: string) => unknown;
|
||||
on: (event: 'closed', handler: () => void) => void;
|
||||
isDestroyed: () => boolean;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
function escapeHtmlAttr(value: string): string {
|
||||
return value.replace(/"/g, '"');
|
||||
}
|
||||
@@ -169,3 +181,82 @@ export function createHandleJellyfinSetupWindowOpenedHandler(deps: {
|
||||
deps.setSetupWindow();
|
||||
};
|
||||
}
|
||||
|
||||
export function createOpenJellyfinSetupWindowHandler<TWindow extends JellyfinSetupWindowLike>(deps: {
|
||||
maybeFocusExistingSetupWindow: () => boolean;
|
||||
createSetupWindow: () => TWindow;
|
||||
getResolvedJellyfinConfig: () => { serverUrl?: string | null; username?: string | null };
|
||||
buildSetupFormHtml: (defaultServer: string, defaultUser: string) => string;
|
||||
parseSubmissionUrl: (rawUrl: string) => { server: string; username: string; password: string } | null;
|
||||
authenticateWithPassword: (
|
||||
server: string,
|
||||
username: string,
|
||||
password: string,
|
||||
clientInfo: JellyfinClientInfo,
|
||||
) => Promise<JellyfinSession>;
|
||||
getJellyfinClientInfo: () => JellyfinClientInfo;
|
||||
patchJellyfinConfig: (session: JellyfinSession) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
clearSetupWindow: () => void;
|
||||
setSetupWindow: (window: TWindow) => void;
|
||||
encodeURIComponent: (value: string) => string;
|
||||
}) {
|
||||
return (): void => {
|
||||
if (deps.maybeFocusExistingSetupWindow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setupWindow = deps.createSetupWindow();
|
||||
const defaults = deps.getResolvedJellyfinConfig();
|
||||
const defaultServer = defaults.serverUrl || 'http://127.0.0.1:8096';
|
||||
const defaultUser = defaults.username || '';
|
||||
const formHtml = deps.buildSetupFormHtml(defaultServer, defaultUser);
|
||||
const handleSubmission = createHandleJellyfinSetupSubmissionHandler({
|
||||
parseSubmissionUrl: (rawUrl) => deps.parseSubmissionUrl(rawUrl),
|
||||
authenticateWithPassword: (server, username, password, clientInfo) =>
|
||||
deps.authenticateWithPassword(server, username, password, clientInfo),
|
||||
getJellyfinClientInfo: () => deps.getJellyfinClientInfo(),
|
||||
patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session),
|
||||
logInfo: (message) => deps.logInfo(message),
|
||||
logError: (message, error) => deps.logError(message, error),
|
||||
showMpvOsd: (message) => deps.showMpvOsd(message),
|
||||
closeSetupWindow: () => {
|
||||
if (!setupWindow.isDestroyed()) {
|
||||
setupWindow.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
const handleNavigation = createHandleJellyfinSetupNavigationHandler({
|
||||
setupSchemePrefix: 'subminer://jellyfin-setup',
|
||||
handleSubmission: (rawUrl) => handleSubmission(rawUrl),
|
||||
logError: (message, error) => deps.logError(message, error),
|
||||
});
|
||||
const handleWindowClosed = createHandleJellyfinSetupWindowClosedHandler({
|
||||
clearSetupWindow: () => deps.clearSetupWindow(),
|
||||
});
|
||||
const handleWindowOpened = createHandleJellyfinSetupWindowOpenedHandler({
|
||||
setSetupWindow: () => deps.setSetupWindow(setupWindow),
|
||||
});
|
||||
|
||||
setupWindow.webContents.on('will-navigate', (event, url) => {
|
||||
handleNavigation({
|
||||
url,
|
||||
preventDefault: () => {
|
||||
if (event && typeof event === 'object' && 'preventDefault' in event) {
|
||||
const typedEvent = event as { preventDefault?: () => void };
|
||||
typedEvent.preventDefault?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
void setupWindow.loadURL(
|
||||
`data:text/html;charset=utf-8,${deps.encodeURIComponent(formHtml)}`,
|
||||
);
|
||||
setupWindow.on('closed', () => {
|
||||
handleWindowClosed();
|
||||
});
|
||||
handleWindowOpened();
|
||||
};
|
||||
}
|
||||
|
||||
81
src/main/runtime/jellyfin-subtitle-preload.test.ts
Normal file
81
src/main/runtime/jellyfin-subtitle-preload.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPreloadJellyfinExternalSubtitlesHandler } from './jellyfin-subtitle-preload';
|
||||
|
||||
const session = {
|
||||
serverUrl: 'http://localhost:8096',
|
||||
accessToken: 'token',
|
||||
userId: 'uid',
|
||||
username: 'alice',
|
||||
};
|
||||
|
||||
const clientInfo = {
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
deviceId: 'dev',
|
||||
};
|
||||
|
||||
test('preload jellyfin subtitles adds external tracks and chooses japanese+english tracks', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||
{ index: 1, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' },
|
||||
{ index: 2, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{ type: 'sub', id: 5, lang: 'jpn', title: 'Japanese', external: true },
|
||||
{ type: 'sub', id: 6, lang: 'eng', title: 'English', external: true },
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: async () => {},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['sub-add', 'https://sub/a.srt', 'cached', 'Japanese', 'jpn'],
|
||||
['sub-add', 'https://sub/b.srt', 'cached', 'English SDH', 'eng'],
|
||||
['set_property', 'sid', 5],
|
||||
['set_property', 'secondary-sid', 6],
|
||||
]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles exits quietly when no external tracks', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let waited = false;
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler({
|
||||
listJellyfinSubtitleTracks: async () => [{ index: 0, language: 'jpn', title: 'Embedded' }],
|
||||
getMpvClient: () => ({ requestProperty: async () => [] }),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: async () => {
|
||||
waited = true;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.equal(waited, false);
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles logs debug on failure', async () => {
|
||||
const logs: string[] = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler({
|
||||
listJellyfinSubtitleTracks: async () => {
|
||||
throw new Error('network down');
|
||||
},
|
||||
getMpvClient: () => null,
|
||||
sendMpvCommand: () => {},
|
||||
wait: async () => {},
|
||||
logDebug: (message) => logs.push(message),
|
||||
});
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.deepEqual(logs, ['Failed to preload Jellyfin external subtitles']);
|
||||
});
|
||||
163
src/main/runtime/jellyfin-subtitle-preload.ts
Normal file
163
src/main/runtime/jellyfin-subtitle-preload.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
type JellyfinSession = {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
type JellyfinClientInfo = {
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
deviceId: string;
|
||||
};
|
||||
|
||||
type JellyfinSubtitleTrack = {
|
||||
index: number;
|
||||
language?: string;
|
||||
title?: string;
|
||||
deliveryUrl?: string | null;
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
function normalizeLang(value: unknown): string {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/_/g, '-');
|
||||
}
|
||||
|
||||
function isJapanese(value: string): boolean {
|
||||
const v = normalizeLang(value);
|
||||
return (
|
||||
v === 'ja' ||
|
||||
v === 'jp' ||
|
||||
v === 'jpn' ||
|
||||
v === 'japanese' ||
|
||||
v.startsWith('ja-') ||
|
||||
v.startsWith('jp-')
|
||||
);
|
||||
}
|
||||
|
||||
function isEnglish(value: string): boolean {
|
||||
const v = normalizeLang(value);
|
||||
return (
|
||||
v === 'en' ||
|
||||
v === 'eng' ||
|
||||
v === 'english' ||
|
||||
v === 'enus' ||
|
||||
v === 'en-us' ||
|
||||
v.startsWith('en-')
|
||||
);
|
||||
}
|
||||
|
||||
function isLikelyHearingImpaired(title: string): boolean {
|
||||
return /\b(hearing impaired|sdh|closed captions?|cc)\b/i.test(title);
|
||||
}
|
||||
|
||||
function pickBestTrackId(
|
||||
tracks: Array<{
|
||||
id: number;
|
||||
lang: string;
|
||||
title: string;
|
||||
external: boolean;
|
||||
}>,
|
||||
languageMatcher: (value: string) => boolean,
|
||||
excludeId: number | null = null,
|
||||
): number | null {
|
||||
const ranked = tracks
|
||||
.filter((track) => languageMatcher(track.lang))
|
||||
.filter((track) => track.id !== excludeId)
|
||||
.map((track) => ({
|
||||
track,
|
||||
score:
|
||||
(track.external ? 100 : 0) +
|
||||
(isLikelyHearingImpaired(track.title) ? -10 : 10) +
|
||||
(/\bdefault\b/i.test(track.title) ? 3 : 0),
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
return ranked[0]?.track.id ?? null;
|
||||
}
|
||||
|
||||
export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
listJellyfinSubtitleTracks: (
|
||||
session: JellyfinSession,
|
||||
clientInfo: JellyfinClientInfo,
|
||||
itemId: string,
|
||||
) => Promise<JellyfinSubtitleTrack[]>;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
wait: (ms: number) => Promise<void>;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return async (params: {
|
||||
session: JellyfinSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
itemId: string;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
const tracks = await deps.listJellyfinSubtitleTracks(
|
||||
params.session,
|
||||
params.clientInfo,
|
||||
params.itemId,
|
||||
);
|
||||
const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl));
|
||||
if (externalTracks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deps.wait(300);
|
||||
const seenUrls = new Set<string>();
|
||||
for (const track of externalTracks) {
|
||||
if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) {
|
||||
continue;
|
||||
}
|
||||
seenUrls.add(track.deliveryUrl);
|
||||
const labelBase = (track.title || track.language || '').trim();
|
||||
const label = labelBase || `Jellyfin Subtitle ${track.index}`;
|
||||
deps.sendMpvCommand([
|
||||
'sub-add',
|
||||
track.deliveryUrl,
|
||||
'cached',
|
||||
label,
|
||||
track.language || '',
|
||||
]);
|
||||
}
|
||||
|
||||
await deps.wait(250);
|
||||
const trackListRaw = await deps.getMpvClient()?.requestProperty('track-list');
|
||||
const subtitleTracks = Array.isArray(trackListRaw)
|
||||
? trackListRaw
|
||||
.filter(
|
||||
(track): track is Record<string, unknown> =>
|
||||
Boolean(track) &&
|
||||
typeof track === 'object' &&
|
||||
track.type === 'sub' &&
|
||||
typeof track.id === 'number',
|
||||
)
|
||||
.map((track) => ({
|
||||
id: track.id as number,
|
||||
lang: String(track.lang || ''),
|
||||
title: String(track.title || ''),
|
||||
external: track.external === true,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const japanesePrimaryId = pickBestTrackId(subtitleTracks, isJapanese);
|
||||
if (japanesePrimaryId !== null) {
|
||||
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
||||
} else {
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
}
|
||||
|
||||
const englishSecondaryId = pickBestTrackId(subtitleTracks, isEnglish, japanesePrimaryId);
|
||||
if (englishSecondaryId !== null) {
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]);
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to preload Jellyfin external subtitles', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBuildMpvClientRuntimeServiceFactoryDepsHandler } from './mpv-client-runtime-service-main-deps';
|
||||
|
||||
test('mpv runtime service main deps builder maps state and callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
class FakeClient {
|
||||
constructor(public socketPath: string, public options: unknown) {}
|
||||
}
|
||||
|
||||
const build = createBuildMpvClientRuntimeServiceFactoryDepsHandler({
|
||||
createClient: FakeClient,
|
||||
getSocketPath: () => '/tmp/mpv.sock',
|
||||
getResolvedConfig: () => ({ mode: 'test' }),
|
||||
isAutoStartOverlayEnabled: () => true,
|
||||
setOverlayVisible: (visible) => calls.push(`overlay:${visible}`),
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
|
||||
isVisibleOverlayVisible: () => false,
|
||||
getReconnectTimer: () => reconnectTimer,
|
||||
setReconnectTimer: (timer) => {
|
||||
reconnectTimer = timer;
|
||||
calls.push('set-reconnect');
|
||||
},
|
||||
bindEventHandlers: () => calls.push('bind'),
|
||||
});
|
||||
|
||||
const deps = build();
|
||||
assert.equal(deps.socketPath, '/tmp/mpv.sock');
|
||||
assert.equal(deps.options.autoStartOverlay, true);
|
||||
assert.equal(deps.options.shouldBindVisibleOverlayToMpvSubVisibility(), true);
|
||||
assert.equal(deps.options.isVisibleOverlayVisible(), false);
|
||||
assert.deepEqual(deps.options.getResolvedConfig(), { mode: 'test' });
|
||||
|
||||
deps.options.setOverlayVisible(true);
|
||||
deps.options.setReconnectTimer(setTimeout(() => {}, 0));
|
||||
deps.bindEventHandlers(new FakeClient('/tmp/mpv.sock', {}));
|
||||
|
||||
assert.ok(calls.includes('overlay:true'));
|
||||
assert.ok(calls.includes('set-reconnect'));
|
||||
assert.ok(calls.includes('bind'));
|
||||
assert.ok(reconnectTimer);
|
||||
});
|
||||
32
src/main/runtime/mpv-client-runtime-service-main-deps.ts
Normal file
32
src/main/runtime/mpv-client-runtime-service-main-deps.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
|
||||
TClient,
|
||||
TResolvedConfig,
|
||||
TOptions,
|
||||
>(deps: {
|
||||
createClient: new (socketPath: string, options: TOptions) => TClient;
|
||||
getSocketPath: () => string;
|
||||
getResolvedConfig: () => TResolvedConfig;
|
||||
isAutoStartOverlayEnabled: () => boolean;
|
||||
setOverlayVisible: (visible: boolean) => void;
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
bindEventHandlers: (client: TClient) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
createClient: deps.createClient,
|
||||
socketPath: deps.getSocketPath(),
|
||||
options: {
|
||||
getResolvedConfig: () => deps.getResolvedConfig(),
|
||||
autoStartOverlay: deps.isAutoStartOverlayEnabled(),
|
||||
setOverlayVisible: (visible: boolean) => deps.setOverlayVisible(visible),
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () =>
|
||||
deps.shouldBindVisibleOverlayToMpvSubVisibility(),
|
||||
isVisibleOverlayVisible: () => deps.isVisibleOverlayVisible(),
|
||||
getReconnectTimer: () => deps.getReconnectTimer(),
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => deps.setReconnectTimer(timer),
|
||||
},
|
||||
bindEventHandlers: (client: TClient) => deps.bindEventHandlers(client),
|
||||
});
|
||||
}
|
||||
122
src/main/runtime/mpv-main-event-actions.test.ts
Normal file
122
src/main/runtime/mpv-main-event-actions.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createHandleMpvMediaPathChangeHandler,
|
||||
createHandleMpvMediaTitleChangeHandler,
|
||||
createHandleMpvPauseChangeHandler,
|
||||
createHandleMpvSecondarySubtitleChangeHandler,
|
||||
createHandleMpvSecondarySubtitleVisibilityHandler,
|
||||
createHandleMpvSubtitleAssChangeHandler,
|
||||
createHandleMpvSubtitleChangeHandler,
|
||||
createHandleMpvSubtitleMetricsChangeHandler,
|
||||
createHandleMpvTimePosChangeHandler,
|
||||
} from './mpv-main-event-actions';
|
||||
|
||||
test('subtitle change handler updates state, broadcasts, and forwards', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleChangeHandler({
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
broadcastSubtitle: (payload) => calls.push(`broadcast:${payload.text}`),
|
||||
onSubtitleChange: (text) => calls.push(`process:${text}`),
|
||||
});
|
||||
|
||||
handler({ text: 'line' });
|
||||
assert.deepEqual(calls, ['set:line', 'broadcast:line', 'process:line']);
|
||||
});
|
||||
|
||||
test('subtitle ass change handler updates state and broadcasts', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleAssChangeHandler({
|
||||
setCurrentSubAssText: (text) => calls.push(`set:${text}`),
|
||||
broadcastSubtitleAss: (text) => calls.push(`broadcast:${text}`),
|
||||
});
|
||||
|
||||
handler({ text: '{\\an8}line' });
|
||||
assert.deepEqual(calls, ['set:{\\an8}line', 'broadcast:{\\an8}line']);
|
||||
});
|
||||
|
||||
test('secondary subtitle change handler broadcasts text', () => {
|
||||
const seen: string[] = [];
|
||||
const handler = createHandleMpvSecondarySubtitleChangeHandler({
|
||||
broadcastSecondarySubtitle: (text) => seen.push(text),
|
||||
});
|
||||
|
||||
handler({ text: 'secondary' });
|
||||
assert.deepEqual(seen, ['secondary']);
|
||||
});
|
||||
|
||||
test('media path change handler reports stop for empty path and probes media key', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvMediaPathChangeHandler({
|
||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
||||
getCurrentAnilistMediaKey: () => 'show:1',
|
||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||
syncImmersionMediaState: () => calls.push('sync'),
|
||||
});
|
||||
|
||||
handler({ path: '' });
|
||||
assert.deepEqual(calls, [
|
||||
'path:',
|
||||
'stopped',
|
||||
'reset:show:1',
|
||||
'probe:show:1',
|
||||
'guess:show:1',
|
||||
'sync',
|
||||
]);
|
||||
});
|
||||
|
||||
test('media title change handler clears guess state and syncs immersion', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvMediaTitleChangeHandler({
|
||||
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
|
||||
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
|
||||
notifyImmersionTitleUpdate: (title) => calls.push(`notify:${title}`),
|
||||
syncImmersionMediaState: () => calls.push('sync'),
|
||||
});
|
||||
|
||||
handler({ title: 'Episode 1' });
|
||||
assert.deepEqual(calls, ['title:Episode 1', 'reset-guess', 'notify:Episode 1', 'sync']);
|
||||
});
|
||||
|
||||
test('time-pos and pause handlers report progress with correct urgency', () => {
|
||||
const calls: string[] = [];
|
||||
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||
recordPlaybackPosition: (time) => calls.push(`time:${time}`),
|
||||
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
|
||||
});
|
||||
const pauseHandler = createHandleMpvPauseChangeHandler({
|
||||
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
|
||||
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
|
||||
});
|
||||
|
||||
timeHandler({ time: 12.5 });
|
||||
pauseHandler({ paused: true });
|
||||
assert.deepEqual(calls, ['time:12.5', 'progress:normal', 'pause:yes', 'progress:force']);
|
||||
});
|
||||
|
||||
test('subtitle metrics change handler forwards patch payload', () => {
|
||||
let received: Record<string, unknown> | null = null;
|
||||
const handler = createHandleMpvSubtitleMetricsChangeHandler({
|
||||
updateSubtitleRenderMetrics: (patch) => {
|
||||
received = patch;
|
||||
},
|
||||
});
|
||||
|
||||
const patch = { fontSize: 48 };
|
||||
handler({ patch });
|
||||
assert.deepEqual(received, patch);
|
||||
});
|
||||
|
||||
test('secondary subtitle visibility handler stores visibility flag', () => {
|
||||
const seen: boolean[] = [];
|
||||
const handler = createHandleMpvSecondarySubtitleVisibilityHandler({
|
||||
setPreviousSecondarySubVisibility: (visible) => seen.push(visible),
|
||||
});
|
||||
|
||||
handler({ visible: true });
|
||||
handler({ visible: false });
|
||||
assert.deepEqual(seen, [true, false]);
|
||||
});
|
||||
103
src/main/runtime/mpv-main-event-actions.ts
Normal file
103
src/main/runtime/mpv-main-event-actions.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||
setCurrentSubText: (text: string) => void;
|
||||
broadcastSubtitle: (payload: { text: string; tokens: null }) => void;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
}) {
|
||||
return ({ text }: { text: string }): void => {
|
||||
deps.setCurrentSubText(text);
|
||||
deps.broadcastSubtitle({ text, tokens: null });
|
||||
deps.onSubtitleChange(text);
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleMpvSubtitleAssChangeHandler(deps: {
|
||||
setCurrentSubAssText: (text: string) => void;
|
||||
broadcastSubtitleAss: (text: string) => void;
|
||||
}) {
|
||||
return ({ text }: { text: string }): void => {
|
||||
deps.setCurrentSubAssText(text);
|
||||
deps.broadcastSubtitleAss(text);
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleMpvSecondarySubtitleChangeHandler(deps: {
|
||||
broadcastSecondarySubtitle: (text: string) => void;
|
||||
}) {
|
||||
return ({ text }: { text: string }): void => {
|
||||
deps.broadcastSecondarySubtitle(text);
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleMpvMediaPathChangeHandler(deps: {
|
||||
updateCurrentMediaPath: (path: string) => void;
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
getCurrentAnilistMediaKey: () => string | null;
|
||||
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
||||
syncImmersionMediaState: () => void;
|
||||
}) {
|
||||
return ({ path }: { path: string }): void => {
|
||||
deps.updateCurrentMediaPath(path);
|
||||
if (!path) {
|
||||
deps.reportJellyfinRemoteStopped();
|
||||
}
|
||||
const mediaKey = deps.getCurrentAnilistMediaKey();
|
||||
deps.resetAnilistMediaTracking(mediaKey);
|
||||
if (mediaKey) {
|
||||
deps.maybeProbeAnilistDuration(mediaKey);
|
||||
deps.ensureAnilistMediaGuess(mediaKey);
|
||||
}
|
||||
deps.syncImmersionMediaState();
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleMpvMediaTitleChangeHandler(deps: {
|
||||
updateCurrentMediaTitle: (title: string) => void;
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
notifyImmersionTitleUpdate: (title: string) => void;
|
||||
syncImmersionMediaState: () => void;
|
||||
}) {
|
||||
return ({ title }: { title: string }): void => {
|
||||
deps.updateCurrentMediaTitle(title);
|
||||
deps.resetAnilistMediaGuessState();
|
||||
deps.notifyImmersionTitleUpdate(title);
|
||||
deps.syncImmersionMediaState();
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleMpvTimePosChangeHandler(deps: {
|
||||
recordPlaybackPosition: (time: number) => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
}) {
|
||||
return ({ time }: { time: number }): void => {
|
||||
deps.recordPlaybackPosition(time);
|
||||
deps.reportJellyfinRemoteProgress(false);
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleMpvPauseChangeHandler(deps: {
|
||||
recordPauseState: (paused: boolean) => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
}) {
|
||||
return ({ paused }: { paused: boolean }): void => {
|
||||
deps.recordPauseState(paused);
|
||||
deps.reportJellyfinRemoteProgress(true);
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleMpvSubtitleMetricsChangeHandler(deps: {
|
||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
|
||||
}) {
|
||||
return ({ patch }: { patch: Record<string, unknown> }): void => {
|
||||
deps.updateSubtitleRenderMetrics(patch);
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleMpvSecondarySubtitleVisibilityHandler(deps: {
|
||||
setPreviousSecondarySubVisibility: (visible: boolean) => void;
|
||||
}) {
|
||||
return ({ visible }: { visible: boolean }): void => {
|
||||
deps.setPreviousSecondarySubVisibility(visible);
|
||||
};
|
||||
}
|
||||
76
src/main/runtime/mpv-main-event-bindings.test.ts
Normal file
76
src/main/runtime/mpv-main-event-bindings.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBindMpvMainEventHandlersHandler } from './mpv-main-event-bindings';
|
||||
|
||||
test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
const handlers = new Map<string, (payload: unknown) => void>();
|
||||
const calls: string[] = [];
|
||||
|
||||
const bind = createBindMpvMainEventHandlersHandler({
|
||||
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
|
||||
hasInitialJellyfinPlayArg: () => false,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
isQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {
|
||||
calls.push('schedule-quit-check');
|
||||
},
|
||||
isMpvConnected: () => false,
|
||||
quitApp: () => calls.push('quit-app'),
|
||||
|
||||
recordImmersionSubtitleLine: (text) => calls.push(`immersion:${text}`),
|
||||
hasSubtitleTimingTracker: () => false,
|
||||
recordSubtitleTiming: () => calls.push('record-timing'),
|
||||
maybeRunAnilistPostWatchUpdate: async () => {
|
||||
calls.push('post-watch');
|
||||
},
|
||||
logSubtitleTimingError: () => calls.push('subtitle-error'),
|
||||
|
||||
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
|
||||
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
|
||||
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
|
||||
|
||||
setCurrentSubAssText: (text) => calls.push(`set-ass:${text}`),
|
||||
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
|
||||
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
|
||||
|
||||
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
|
||||
getCurrentAnilistMediaKey: () => 'media-key',
|
||||
resetAnilistMediaTracking: (key) => calls.push(`reset-media:${String(key)}`),
|
||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||
syncImmersionMediaState: () => calls.push('sync-immersion'),
|
||||
|
||||
updateCurrentMediaTitle: (title) => calls.push(`media-title:${title}`),
|
||||
resetAnilistMediaGuessState: () => calls.push('reset-guess-state'),
|
||||
notifyImmersionTitleUpdate: (title) => calls.push(`notify-title:${title}`),
|
||||
|
||||
recordPlaybackPosition: (time) => calls.push(`time-pos:${time}`),
|
||||
reportJellyfinRemoteProgress: (forceImmediate) =>
|
||||
calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`),
|
||||
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
|
||||
|
||||
updateSubtitleRenderMetrics: () => calls.push('subtitle-metrics'),
|
||||
setPreviousSecondarySubVisibility: (visible) =>
|
||||
calls.push(`secondary-visible:${visible ? 'yes' : 'no'}`),
|
||||
});
|
||||
|
||||
bind({
|
||||
on: (event, handler) => {
|
||||
handlers.set(event, handler as (payload: unknown) => void);
|
||||
},
|
||||
});
|
||||
|
||||
handlers.get('subtitle-change')?.({ text: 'line' });
|
||||
handlers.get('media-title-change')?.({ title: 'Episode 1' });
|
||||
handlers.get('time-pos-change')?.({ time: 2.5 });
|
||||
handlers.get('pause-change')?.({ paused: true });
|
||||
|
||||
assert.ok(calls.includes('set-sub:line'));
|
||||
assert.ok(calls.includes('broadcast-sub:line'));
|
||||
assert.ok(calls.includes('subtitle-change:line'));
|
||||
assert.ok(calls.includes('media-title:Episode 1'));
|
||||
assert.ok(calls.includes('reset-guess-state'));
|
||||
assert.ok(calls.includes('notify-title:Episode 1'));
|
||||
assert.ok(calls.includes('progress:normal'));
|
||||
assert.ok(calls.includes('progress:force'));
|
||||
});
|
||||
139
src/main/runtime/mpv-main-event-bindings.ts
Normal file
139
src/main/runtime/mpv-main-event-bindings.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
createBindMpvClientEventHandlers,
|
||||
createHandleMpvConnectionChangeHandler,
|
||||
createHandleMpvSubtitleTimingHandler,
|
||||
} from './mpv-client-event-bindings';
|
||||
import {
|
||||
createHandleMpvMediaPathChangeHandler,
|
||||
createHandleMpvMediaTitleChangeHandler,
|
||||
createHandleMpvPauseChangeHandler,
|
||||
createHandleMpvSecondarySubtitleChangeHandler,
|
||||
createHandleMpvSecondarySubtitleVisibilityHandler,
|
||||
createHandleMpvSubtitleAssChangeHandler,
|
||||
createHandleMpvSubtitleChangeHandler,
|
||||
createHandleMpvSubtitleMetricsChangeHandler,
|
||||
createHandleMpvTimePosChangeHandler,
|
||||
} from './mpv-main-event-actions';
|
||||
|
||||
type MpvEventClient = {
|
||||
on: (...args: any[]) => unknown;
|
||||
};
|
||||
|
||||
export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
hasInitialJellyfinPlayArg: () => boolean;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
isQuitOnDisconnectArmed: () => boolean;
|
||||
scheduleQuitCheck: (callback: () => void) => void;
|
||||
isMpvConnected: () => boolean;
|
||||
quitApp: () => void;
|
||||
|
||||
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
|
||||
hasSubtitleTimingTracker: () => boolean;
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) => void;
|
||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||
|
||||
setCurrentSubText: (text: string) => void;
|
||||
broadcastSubtitle: (payload: { text: string; tokens: null }) => void;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
|
||||
setCurrentSubAssText: (text: string) => void;
|
||||
broadcastSubtitleAss: (text: string) => void;
|
||||
broadcastSecondarySubtitle: (text: string) => void;
|
||||
|
||||
updateCurrentMediaPath: (path: string) => void;
|
||||
getCurrentAnilistMediaKey: () => string | null;
|
||||
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
||||
syncImmersionMediaState: () => void;
|
||||
|
||||
updateCurrentMediaTitle: (title: string) => void;
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
notifyImmersionTitleUpdate: (title: string) => void;
|
||||
|
||||
recordPlaybackPosition: (time: number) => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
recordPauseState: (paused: boolean) => void;
|
||||
|
||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
|
||||
setPreviousSecondarySubVisibility: (visible: boolean) => void;
|
||||
}) {
|
||||
return (mpvClient: MpvEventClient): void => {
|
||||
const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({
|
||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||
hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(),
|
||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||
isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(),
|
||||
scheduleQuitCheck: (callback) => deps.scheduleQuitCheck(callback),
|
||||
isMpvConnected: () => deps.isMpvConnected(),
|
||||
quitApp: () => deps.quitApp(),
|
||||
});
|
||||
const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({
|
||||
recordImmersionSubtitleLine: (text, start, end) =>
|
||||
deps.recordImmersionSubtitleLine(text, start, end),
|
||||
hasSubtitleTimingTracker: () => deps.hasSubtitleTimingTracker(),
|
||||
recordSubtitleTiming: (text, start, end) => deps.recordSubtitleTiming(text, start, end),
|
||||
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
|
||||
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
||||
});
|
||||
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
|
||||
setCurrentSubText: (text) => deps.setCurrentSubText(text),
|
||||
broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload),
|
||||
onSubtitleChange: (text) => deps.onSubtitleChange(text),
|
||||
});
|
||||
const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({
|
||||
setCurrentSubAssText: (text) => deps.setCurrentSubAssText(text),
|
||||
broadcastSubtitleAss: (text) => deps.broadcastSubtitleAss(text),
|
||||
});
|
||||
const handleMpvSecondarySubtitleChange = createHandleMpvSecondarySubtitleChangeHandler({
|
||||
broadcastSecondarySubtitle: (text) => deps.broadcastSecondarySubtitle(text),
|
||||
});
|
||||
const handleMpvMediaPathChange = createHandleMpvMediaPathChangeHandler({
|
||||
updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path),
|
||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
||||
resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey),
|
||||
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
|
||||
ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey),
|
||||
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
|
||||
});
|
||||
const handleMpvMediaTitleChange = createHandleMpvMediaTitleChangeHandler({
|
||||
updateCurrentMediaTitle: (title) => deps.updateCurrentMediaTitle(title),
|
||||
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
|
||||
notifyImmersionTitleUpdate: (title) => deps.notifyImmersionTitleUpdate(title),
|
||||
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
|
||||
});
|
||||
const handleMpvTimePosChange = createHandleMpvTimePosChangeHandler({
|
||||
recordPlaybackPosition: (time) => deps.recordPlaybackPosition(time),
|
||||
reportJellyfinRemoteProgress: (forceImmediate) =>
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
});
|
||||
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
|
||||
recordPauseState: (paused) => deps.recordPauseState(paused),
|
||||
reportJellyfinRemoteProgress: (forceImmediate) =>
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
});
|
||||
const handleMpvSubtitleMetricsChange = createHandleMpvSubtitleMetricsChangeHandler({
|
||||
updateSubtitleRenderMetrics: (patch) => deps.updateSubtitleRenderMetrics(patch),
|
||||
});
|
||||
const handleMpvSecondarySubtitleVisibility = createHandleMpvSecondarySubtitleVisibilityHandler({
|
||||
setPreviousSecondarySubVisibility: (visible) => deps.setPreviousSecondarySubVisibility(visible),
|
||||
});
|
||||
|
||||
createBindMpvClientEventHandlers({
|
||||
onConnectionChange: handleMpvConnectionChange,
|
||||
onSubtitleChange: handleMpvSubtitleChange,
|
||||
onSubtitleAssChange: handleMpvSubtitleAssChange,
|
||||
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
|
||||
onSubtitleTiming: handleMpvSubtitleTiming,
|
||||
onMediaPathChange: handleMpvMediaPathChange,
|
||||
onMediaTitleChange: handleMpvMediaTitleChange,
|
||||
onTimePosChange: handleMpvTimePosChange,
|
||||
onPauseChange: handleMpvPauseChange,
|
||||
onSubtitleMetricsChange: handleMpvSubtitleMetricsChange,
|
||||
onSecondarySubtitleVisibility: handleMpvSecondarySubtitleVisibility,
|
||||
})(mpvClient as never);
|
||||
};
|
||||
}
|
||||
92
src/main/runtime/mpv-main-event-main-deps.test.ts
Normal file
92
src/main/runtime/mpv-main-event-main-deps.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './mpv-main-event-main-deps';
|
||||
|
||||
test('mpv main event main deps map app state updates and delegate callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
const appState = {
|
||||
initialArgs: { jellyfinPlay: true },
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: { connected: true },
|
||||
immersionTracker: {
|
||||
recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`),
|
||||
handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`),
|
||||
recordPlaybackPosition: (time: number) => calls.push(`immersion-time:${time}`),
|
||||
recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`),
|
||||
},
|
||||
subtitleTimingTracker: {
|
||||
recordSubtitle: (text: string) => calls.push(`timing:${text}`),
|
||||
},
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
previousSecondarySubVisibility: false,
|
||||
};
|
||||
|
||||
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||
appState,
|
||||
getQuitOnDisconnectArmed: () => true,
|
||||
scheduleQuitCheck: (callback) => {
|
||||
calls.push('schedule');
|
||||
callback();
|
||||
},
|
||||
quitApp: () => calls.push('quit'),
|
||||
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
|
||||
maybeRunAnilistPostWatchUpdate: async () => {
|
||||
calls.push('anilist-post-watch');
|
||||
},
|
||||
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
|
||||
broadcastToOverlayWindows: (channel, payload) => calls.push(`broadcast:${channel}:${String(payload)}`),
|
||||
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
|
||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||
getCurrentAnilistMediaKey: () => 'media-key',
|
||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`),
|
||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||
syncImmersionMediaState: () => calls.push('sync-immersion'),
|
||||
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
|
||||
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
|
||||
reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`),
|
||||
updateSubtitleRenderMetrics: () => calls.push('metrics'),
|
||||
})();
|
||||
|
||||
assert.equal(deps.hasInitialJellyfinPlayArg(), true);
|
||||
assert.equal(deps.isOverlayRuntimeInitialized(), true);
|
||||
assert.equal(deps.isQuitOnDisconnectArmed(), true);
|
||||
assert.equal(deps.isMpvConnected(), true);
|
||||
deps.scheduleQuitCheck(() => calls.push('scheduled-callback'));
|
||||
deps.quitApp();
|
||||
deps.reportJellyfinRemoteStopped();
|
||||
deps.recordImmersionSubtitleLine('x', 0, 1);
|
||||
assert.equal(deps.hasSubtitleTimingTracker(), true);
|
||||
deps.recordSubtitleTiming('y', 0, 1);
|
||||
await deps.maybeRunAnilistPostWatchUpdate();
|
||||
deps.logSubtitleTimingError('err', new Error('boom'));
|
||||
deps.setCurrentSubText('sub');
|
||||
deps.broadcastSubtitle({ text: 'sub', tokens: null });
|
||||
deps.onSubtitleChange('sub');
|
||||
deps.setCurrentSubAssText('ass');
|
||||
deps.broadcastSubtitleAss('ass');
|
||||
deps.broadcastSecondarySubtitle('sec');
|
||||
deps.updateCurrentMediaPath('/tmp/video');
|
||||
assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key');
|
||||
deps.resetAnilistMediaTracking('media-key');
|
||||
deps.maybeProbeAnilistDuration('media-key');
|
||||
deps.ensureAnilistMediaGuess('media-key');
|
||||
deps.syncImmersionMediaState();
|
||||
deps.updateCurrentMediaTitle('title');
|
||||
deps.resetAnilistMediaGuessState();
|
||||
deps.notifyImmersionTitleUpdate('title');
|
||||
deps.recordPlaybackPosition(10);
|
||||
deps.reportJellyfinRemoteProgress(true);
|
||||
deps.recordPauseState(true);
|
||||
deps.updateSubtitleRenderMetrics({});
|
||||
deps.setPreviousSecondarySubVisibility(true);
|
||||
|
||||
assert.equal(appState.currentSubText, 'sub');
|
||||
assert.equal(appState.currentSubAssText, 'ass');
|
||||
assert.equal(appState.previousSecondarySubVisibility, true);
|
||||
assert.ok(calls.includes('remote-stopped'));
|
||||
assert.ok(calls.includes('anilist-post-watch'));
|
||||
assert.ok(calls.includes('sync-immersion'));
|
||||
assert.ok(calls.includes('metrics'));
|
||||
});
|
||||
86
src/main/runtime/mpv-main-event-main-deps.ts
Normal file
86
src/main/runtime/mpv-main-event-main-deps.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
appState: {
|
||||
initialArgs?: { jellyfinPlay?: unknown } | null;
|
||||
overlayRuntimeInitialized: boolean;
|
||||
mpvClient: { connected?: boolean } | null;
|
||||
immersionTracker: {
|
||||
recordSubtitleLine?: (text: string, start: number, end: number) => void;
|
||||
handleMediaTitleUpdate?: (title: string) => void;
|
||||
recordPlaybackPosition?: (time: number) => void;
|
||||
recordPauseState?: (paused: boolean) => void;
|
||||
} | null;
|
||||
subtitleTimingTracker: {
|
||||
recordSubtitle?: (text: string, start: number, end: number) => void;
|
||||
} | null;
|
||||
currentSubText: string;
|
||||
currentSubAssText: string;
|
||||
previousSecondarySubVisibility: boolean | null;
|
||||
};
|
||||
getQuitOnDisconnectArmed: () => boolean;
|
||||
scheduleQuitCheck: (callback: () => void) => void;
|
||||
quitApp: () => void;
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
updateCurrentMediaPath: (path: string) => void;
|
||||
getCurrentAnilistMediaKey: () => string | null;
|
||||
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
||||
syncImmersionMediaState: () => void;
|
||||
updateCurrentMediaTitle: (title: string) => void;
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||
hasInitialJellyfinPlayArg: () => Boolean(deps.appState.initialArgs?.jellyfinPlay),
|
||||
isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized,
|
||||
isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(),
|
||||
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
|
||||
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
|
||||
quitApp: () => deps.quitApp(),
|
||||
recordImmersionSubtitleLine: (text: string, start: number, end: number) =>
|
||||
deps.appState.immersionTracker?.recordSubtitleLine?.(text, start, end),
|
||||
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) =>
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end),
|
||||
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
|
||||
logSubtitleTimingError: (message: string, error: unknown) =>
|
||||
deps.logSubtitleTimingError(message, error),
|
||||
setCurrentSubText: (text: string) => {
|
||||
deps.appState.currentSubText = text;
|
||||
},
|
||||
broadcastSubtitle: (payload: { text: string; tokens: null }) =>
|
||||
deps.broadcastToOverlayWindows('subtitle:set', payload),
|
||||
onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
|
||||
setCurrentSubAssText: (text: string) => {
|
||||
deps.appState.currentSubAssText = text;
|
||||
},
|
||||
broadcastSubtitleAss: (text: string) => deps.broadcastToOverlayWindows('subtitle-ass:set', text),
|
||||
broadcastSecondarySubtitle: (text: string) =>
|
||||
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
|
||||
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
|
||||
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
||||
resetAnilistMediaTracking: (mediaKey: string | null) => deps.resetAnilistMediaTracking(mediaKey),
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey),
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
|
||||
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
|
||||
updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title),
|
||||
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
|
||||
notifyImmersionTitleUpdate: (title: string) =>
|
||||
deps.appState.immersionTracker?.handleMediaTitleUpdate?.(title),
|
||||
recordPlaybackPosition: (time: number) => deps.appState.immersionTracker?.recordPlaybackPosition?.(time),
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
recordPauseState: (paused: boolean) => deps.appState.immersionTracker?.recordPauseState?.(paused),
|
||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>
|
||||
deps.updateSubtitleRenderMetrics(patch),
|
||||
setPreviousSecondarySubVisibility: (visible: boolean) => {
|
||||
deps.appState.previousSecondarySubVisibility = visible;
|
||||
},
|
||||
});
|
||||
}
|
||||
46
src/main/runtime/mpv-osd-log-main-deps.test.ts
Normal file
46
src/main/runtime/mpv-osd-log-main-deps.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildAppendToMpvLogMainDepsHandler,
|
||||
createBuildShowMpvOsdMainDepsHandler,
|
||||
} from './mpv-osd-log-main-deps';
|
||||
|
||||
test('append to mpv log main deps map filesystem functions and log path', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildAppendToMpvLogMainDepsHandler({
|
||||
logPath: '/tmp/mpv.log',
|
||||
dirname: (targetPath) => {
|
||||
calls.push(`dirname:${targetPath}`);
|
||||
return '/tmp';
|
||||
},
|
||||
mkdirSync: (targetPath) => calls.push(`mkdir:${targetPath}`),
|
||||
appendFileSync: (_targetPath, data) => calls.push(`append:${data}`),
|
||||
now: () => new Date('2026-02-20T00:00:00.000Z'),
|
||||
})();
|
||||
|
||||
assert.equal(deps.logPath, '/tmp/mpv.log');
|
||||
assert.equal(deps.dirname('/tmp/mpv.log'), '/tmp');
|
||||
deps.mkdirSync('/tmp', { recursive: true });
|
||||
deps.appendFileSync('/tmp/mpv.log', 'line', { encoding: 'utf8' });
|
||||
assert.equal(deps.now().toISOString(), '2026-02-20T00:00:00.000Z');
|
||||
assert.deepEqual(calls, ['dirname:/tmp/mpv.log', 'mkdir:/tmp', 'append:line']);
|
||||
});
|
||||
|
||||
test('show mpv osd main deps map runtime delegates and logging callback', () => {
|
||||
const calls: string[] = [];
|
||||
const client = { id: 'mpv' };
|
||||
const deps = createBuildShowMpvOsdMainDepsHandler({
|
||||
appendToMpvLog: (message) => calls.push(`append:${message}`),
|
||||
showMpvOsdRuntime: (_mpvClient, text, fallbackLog) => {
|
||||
calls.push(`show:${text}`);
|
||||
fallbackLog('fallback');
|
||||
},
|
||||
getMpvClient: () => client,
|
||||
logInfo: (line) => calls.push(`info:${line}`),
|
||||
})();
|
||||
|
||||
assert.deepEqual(deps.getMpvClient(), client);
|
||||
deps.appendToMpvLog('hello');
|
||||
deps.showMpvOsdRuntime(deps.getMpvClient(), 'subtitle', (line) => deps.logInfo(line));
|
||||
assert.deepEqual(calls, ['append:hello', 'show:subtitle', 'info:fallback']);
|
||||
});
|
||||
39
src/main/runtime/mpv-osd-log-main-deps.ts
Normal file
39
src/main/runtime/mpv-osd-log-main-deps.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export function createBuildAppendToMpvLogMainDepsHandler(deps: {
|
||||
logPath: string;
|
||||
dirname: (targetPath: string) => string;
|
||||
mkdirSync: (targetPath: string, options: { recursive: boolean }) => void;
|
||||
appendFileSync: (targetPath: string, data: string, options: { encoding: 'utf8' }) => void;
|
||||
now: () => Date;
|
||||
}) {
|
||||
return () => ({
|
||||
logPath: deps.logPath,
|
||||
dirname: (targetPath: string) => deps.dirname(targetPath),
|
||||
mkdirSync: (targetPath: string, options: { recursive: boolean }) =>
|
||||
deps.mkdirSync(targetPath, options),
|
||||
appendFileSync: (targetPath: string, data: string, options: { encoding: 'utf8' }) =>
|
||||
deps.appendFileSync(targetPath, data, options),
|
||||
now: () => deps.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildShowMpvOsdMainDepsHandler(deps: {
|
||||
appendToMpvLog: (message: string) => void;
|
||||
showMpvOsdRuntime: (
|
||||
mpvClient: unknown | null,
|
||||
text: string,
|
||||
fallbackLog: (line: string) => void,
|
||||
) => void;
|
||||
getMpvClient: () => unknown | null;
|
||||
logInfo: (line: string) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
appendToMpvLog: (message: string) => deps.appendToMpvLog(message),
|
||||
showMpvOsdRuntime: (
|
||||
mpvClient: unknown | null,
|
||||
text: string,
|
||||
fallbackLog: (line: string) => void,
|
||||
) => deps.showMpvOsdRuntime(mpvClient, text, fallbackLog),
|
||||
getMpvClient: () => deps.getMpvClient() as never,
|
||||
logInfo: (line: string) => deps.logInfo(line),
|
||||
});
|
||||
}
|
||||
77
src/main/runtime/overlay-runtime-options-main-deps.test.ts
Normal file
77
src/main/runtime/overlay-runtime-options-main-deps.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBuildInitializeOverlayRuntimeMainDepsHandler } from './overlay-runtime-options-main-deps';
|
||||
|
||||
test('overlay runtime main deps builder maps runtime state and callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const appState = {
|
||||
backendOverride: 'x11' as string | null,
|
||||
windowTracker: null as unknown,
|
||||
subtitleTimingTracker: { id: 'tracker' } as unknown,
|
||||
mpvClient: null as { send?: (payload: { command: string[] }) => void } | null,
|
||||
mpvSocketPath: '/tmp/mpv.sock',
|
||||
runtimeOptionsManager: null,
|
||||
ankiIntegration: null as unknown,
|
||||
};
|
||||
|
||||
const build = createBuildInitializeOverlayRuntimeMainDepsHandler({
|
||||
appState,
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getInvisibleOverlayVisible: () => false,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
|
||||
updateInvisibleOverlayVisibility: () => calls.push('update-invisible'),
|
||||
},
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
||||
},
|
||||
getInitialInvisibleOverlayVisibility: () => true,
|
||||
createMainWindow: () => calls.push('create-main'),
|
||||
createInvisibleWindow: () => calls.push('create-invisible'),
|
||||
registerGlobalShortcuts: () => calls.push('register-shortcuts'),
|
||||
updateVisibleOverlayBounds: () => calls.push('visible-bounds'),
|
||||
updateInvisibleOverlayBounds: () => calls.push('invisible-bounds'),
|
||||
getOverlayWindows: () => [],
|
||||
getResolvedConfig: () => ({}),
|
||||
showDesktopNotification: () => calls.push('notify'),
|
||||
createFieldGroupingCallback: () => async () => ({ cancelled: true }),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
});
|
||||
|
||||
const deps = build();
|
||||
assert.equal(deps.getBackendOverride(), 'x11');
|
||||
assert.equal(deps.getInitialInvisibleOverlayVisibility(), true);
|
||||
assert.equal(deps.isVisibleOverlayVisible(), true);
|
||||
assert.equal(deps.isInvisibleOverlayVisible(), false);
|
||||
assert.equal(deps.getMpvSocketPath(), '/tmp/mpv.sock');
|
||||
assert.equal(deps.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json');
|
||||
|
||||
deps.createMainWindow();
|
||||
deps.createInvisibleWindow();
|
||||
deps.registerGlobalShortcuts();
|
||||
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||
deps.updateInvisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
deps.updateInvisibleOverlayVisibility();
|
||||
deps.syncOverlayShortcuts();
|
||||
deps.showDesktopNotification('title', {});
|
||||
|
||||
deps.setWindowTracker({ id: 'tracker' });
|
||||
deps.setAnkiIntegration({ id: 'anki' });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'create-main',
|
||||
'create-invisible',
|
||||
'register-shortcuts',
|
||||
'visible-bounds',
|
||||
'invisible-bounds',
|
||||
'update-visible',
|
||||
'update-invisible',
|
||||
'sync-shortcuts',
|
||||
'notify',
|
||||
]);
|
||||
assert.deepEqual(appState.windowTracker, { id: 'tracker' });
|
||||
assert.deepEqual(appState.ankiIntegration, { id: 'anki' });
|
||||
});
|
||||
81
src/main/runtime/overlay-runtime-options-main-deps.ts
Normal file
81
src/main/runtime/overlay-runtime-options-main-deps.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { AnkiConnectConfig } from '../../types';
|
||||
|
||||
export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
appState: {
|
||||
backendOverride: string | null;
|
||||
windowTracker: unknown | null;
|
||||
subtitleTimingTracker: unknown | null;
|
||||
mpvClient: unknown | null;
|
||||
mpvSocketPath: string;
|
||||
runtimeOptionsManager: unknown | null;
|
||||
ankiIntegration: unknown | null;
|
||||
};
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getInvisibleOverlayVisible: () => boolean;
|
||||
};
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
};
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => void;
|
||||
};
|
||||
getInitialInvisibleOverlayVisibility: () => boolean;
|
||||
createMainWindow: () => void;
|
||||
createInvisibleWindow: () => void;
|
||||
registerGlobalShortcuts: () => void;
|
||||
updateVisibleOverlayBounds: (geometry: { x: number; y: number; width: number; height: number }) => void;
|
||||
updateInvisibleOverlayBounds: (geometry: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => void;
|
||||
getOverlayWindows: () => unknown[];
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
createFieldGroupingCallback: () => unknown;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
}) {
|
||||
return () => ({
|
||||
getBackendOverride: () => deps.appState.backendOverride,
|
||||
getInitialInvisibleOverlayVisibility: () => deps.getInitialInvisibleOverlayVisibility(),
|
||||
createMainWindow: () => deps.createMainWindow(),
|
||||
createInvisibleWindow: () => deps.createInvisibleWindow(),
|
||||
registerGlobalShortcuts: () => deps.registerGlobalShortcuts(),
|
||||
updateVisibleOverlayBounds: (geometry: { x: number; y: number; width: number; height: number }) =>
|
||||
deps.updateVisibleOverlayBounds(geometry),
|
||||
updateInvisibleOverlayBounds: (geometry: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => deps.updateInvisibleOverlayBounds(geometry),
|
||||
isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
|
||||
isInvisibleOverlayVisible: () => deps.overlayManager.getInvisibleOverlayVisible(),
|
||||
updateVisibleOverlayVisibility: () => deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
updateInvisibleOverlayVisibility: () =>
|
||||
deps.overlayVisibilityRuntime.updateInvisibleOverlayVisibility(),
|
||||
getOverlayWindows: () => deps.getOverlayWindows() as never,
|
||||
syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||
setWindowTracker: (tracker: unknown | null) => {
|
||||
deps.appState.windowTracker = tracker;
|
||||
},
|
||||
getResolvedConfig: () => deps.getResolvedConfig(),
|
||||
getSubtitleTimingTracker: () => deps.appState.subtitleTimingTracker,
|
||||
getMpvClient: () =>
|
||||
(deps.appState.mpvClient as { send?: (payload: { command: string[] }) => void } | null),
|
||||
getMpvSocketPath: () => deps.appState.mpvSocketPath,
|
||||
getRuntimeOptionsManager: () =>
|
||||
deps.appState.runtimeOptionsManager as
|
||||
| { getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig }
|
||||
| null,
|
||||
setAnkiIntegration: (integration: unknown | null) => {
|
||||
deps.appState.ankiIntegration = integration;
|
||||
},
|
||||
showDesktopNotification: deps.showDesktopNotification,
|
||||
createFieldGroupingCallback: () => deps.createFieldGroupingCallback() as never,
|
||||
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
||||
});
|
||||
}
|
||||
89
src/main/runtime/overlay-visibility-actions.test.ts
Normal file
89
src/main/runtime/overlay-visibility-actions.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createSetInvisibleOverlayVisibleHandler,
|
||||
createSetVisibleOverlayVisibleHandler,
|
||||
createToggleInvisibleOverlayHandler,
|
||||
createToggleVisibleOverlayHandler,
|
||||
} from './overlay-visibility-actions';
|
||||
|
||||
test('set visible overlay handler forwards dependencies to core', () => {
|
||||
const calls: string[] = [];
|
||||
const setVisible = createSetVisibleOverlayVisibleHandler({
|
||||
setVisibleOverlayVisibleCore: (options) => {
|
||||
calls.push(`core:${options.visible}`);
|
||||
options.setVisibleOverlayVisibleState(options.visible);
|
||||
options.updateVisibleOverlayVisibility();
|
||||
options.updateInvisibleOverlayVisibility();
|
||||
options.syncInvisibleOverlayMousePassthrough();
|
||||
options.setMpvSubVisibility(!options.visible);
|
||||
},
|
||||
setVisibleOverlayVisibleState: (visible) => calls.push(`state:${visible}`),
|
||||
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
|
||||
updateInvisibleOverlayVisibility: () => calls.push('update-invisible'),
|
||||
syncInvisibleOverlayMousePassthrough: () => calls.push('sync-mouse'),
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
|
||||
isMpvConnected: () => true,
|
||||
setMpvSubVisibility: (visible) => calls.push(`mpv-sub:${visible}`),
|
||||
});
|
||||
|
||||
setVisible(true);
|
||||
assert.deepEqual(calls, [
|
||||
'core:true',
|
||||
'state:true',
|
||||
'update-visible',
|
||||
'update-invisible',
|
||||
'sync-mouse',
|
||||
'mpv-sub:false',
|
||||
]);
|
||||
});
|
||||
|
||||
test('set invisible overlay handler forwards dependencies to core', () => {
|
||||
const calls: string[] = [];
|
||||
const setInvisible = createSetInvisibleOverlayVisibleHandler({
|
||||
setInvisibleOverlayVisibleCore: (options) => {
|
||||
calls.push(`core:${options.visible}`);
|
||||
options.setInvisibleOverlayVisibleState(options.visible);
|
||||
options.updateInvisibleOverlayVisibility();
|
||||
options.syncInvisibleOverlayMousePassthrough();
|
||||
},
|
||||
setInvisibleOverlayVisibleState: (visible) => calls.push(`state:${visible}`),
|
||||
updateInvisibleOverlayVisibility: () => calls.push('update-invisible'),
|
||||
syncInvisibleOverlayMousePassthrough: () => calls.push('sync-mouse'),
|
||||
});
|
||||
|
||||
setInvisible(false);
|
||||
assert.deepEqual(calls, ['core:false', 'state:false', 'update-invisible', 'sync-mouse']);
|
||||
});
|
||||
|
||||
test('toggle visible overlay flips current visible state', () => {
|
||||
const calls: string[] = [];
|
||||
let current = false;
|
||||
const toggle = createToggleVisibleOverlayHandler({
|
||||
getVisibleOverlayVisible: () => current,
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
current = visible;
|
||||
calls.push(`set:${visible}`);
|
||||
},
|
||||
});
|
||||
|
||||
toggle();
|
||||
toggle();
|
||||
assert.deepEqual(calls, ['set:true', 'set:false']);
|
||||
});
|
||||
|
||||
test('toggle invisible overlay flips current invisible state', () => {
|
||||
const calls: string[] = [];
|
||||
let current = true;
|
||||
const toggle = createToggleInvisibleOverlayHandler({
|
||||
getInvisibleOverlayVisible: () => current,
|
||||
setInvisibleOverlayVisible: (visible) => {
|
||||
current = visible;
|
||||
calls.push(`set:${visible}`);
|
||||
},
|
||||
});
|
||||
|
||||
toggle();
|
||||
toggle();
|
||||
assert.deepEqual(calls, ['set:false', 'set:true']);
|
||||
});
|
||||
72
src/main/runtime/overlay-visibility-actions.ts
Normal file
72
src/main/runtime/overlay-visibility-actions.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export function createSetVisibleOverlayVisibleHandler(deps: {
|
||||
setVisibleOverlayVisibleCore: (options: {
|
||||
visible: boolean;
|
||||
setVisibleOverlayVisibleState: (visible: boolean) => void;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
syncInvisibleOverlayMousePassthrough: () => void;
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
|
||||
isMpvConnected: () => boolean;
|
||||
setMpvSubVisibility: (visible: boolean) => void;
|
||||
}) => void;
|
||||
setVisibleOverlayVisibleState: (visible: boolean) => void;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
syncInvisibleOverlayMousePassthrough: () => void;
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
|
||||
isMpvConnected: () => boolean;
|
||||
setMpvSubVisibility: (visible: boolean) => void;
|
||||
}) {
|
||||
return (visible: boolean): void => {
|
||||
deps.setVisibleOverlayVisibleCore({
|
||||
visible,
|
||||
setVisibleOverlayVisibleState: deps.setVisibleOverlayVisibleState,
|
||||
updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility,
|
||||
updateInvisibleOverlayVisibility: deps.updateInvisibleOverlayVisibility,
|
||||
syncInvisibleOverlayMousePassthrough: deps.syncInvisibleOverlayMousePassthrough,
|
||||
shouldBindVisibleOverlayToMpvSubVisibility:
|
||||
deps.shouldBindVisibleOverlayToMpvSubVisibility,
|
||||
isMpvConnected: deps.isMpvConnected,
|
||||
setMpvSubVisibility: deps.setMpvSubVisibility,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createSetInvisibleOverlayVisibleHandler(deps: {
|
||||
setInvisibleOverlayVisibleCore: (options: {
|
||||
visible: boolean;
|
||||
setInvisibleOverlayVisibleState: (visible: boolean) => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
syncInvisibleOverlayMousePassthrough: () => void;
|
||||
}) => void;
|
||||
setInvisibleOverlayVisibleState: (visible: boolean) => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
syncInvisibleOverlayMousePassthrough: () => void;
|
||||
}) {
|
||||
return (visible: boolean): void => {
|
||||
deps.setInvisibleOverlayVisibleCore({
|
||||
visible,
|
||||
setInvisibleOverlayVisibleState: deps.setInvisibleOverlayVisibleState,
|
||||
updateInvisibleOverlayVisibility: deps.updateInvisibleOverlayVisibility,
|
||||
syncInvisibleOverlayMousePassthrough: deps.syncInvisibleOverlayMousePassthrough,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createToggleVisibleOverlayHandler(deps: {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.setVisibleOverlayVisible(!deps.getVisibleOverlayVisible());
|
||||
};
|
||||
}
|
||||
|
||||
export function createToggleInvisibleOverlayHandler(deps: {
|
||||
getInvisibleOverlayVisible: () => boolean;
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.setInvisibleOverlayVisible(!deps.getInvisibleOverlayVisible());
|
||||
};
|
||||
}
|
||||
43
src/main/runtime/overlay-window-factory-main-deps.test.ts
Normal file
43
src/main/runtime/overlay-window-factory-main-deps.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildCreateInvisibleWindowMainDepsHandler,
|
||||
createBuildCreateMainWindowMainDepsHandler,
|
||||
createBuildCreateOverlayWindowMainDepsHandler,
|
||||
} from './overlay-window-factory-main-deps';
|
||||
|
||||
test('overlay window factory main deps builders return mapped handlers', () => {
|
||||
const calls: string[] = [];
|
||||
const buildOverlayDeps = createBuildCreateOverlayWindowMainDepsHandler({
|
||||
createOverlayWindowCore: (kind) => ({ kind }),
|
||||
isDev: true,
|
||||
getOverlayDebugVisualizationEnabled: () => false,
|
||||
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
|
||||
onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'),
|
||||
setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`),
|
||||
isOverlayVisible: (kind) => kind === 'visible',
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
||||
});
|
||||
|
||||
const overlayDeps = buildOverlayDeps();
|
||||
assert.equal(overlayDeps.isDev, true);
|
||||
assert.equal(overlayDeps.getOverlayDebugVisualizationEnabled(), false);
|
||||
assert.equal(overlayDeps.isOverlayVisible('visible'), true);
|
||||
|
||||
const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({
|
||||
createOverlayWindow: () => ({ id: 'visible' }),
|
||||
setMainWindow: () => calls.push('set-main'),
|
||||
});
|
||||
const mainDeps = buildMainDeps();
|
||||
mainDeps.setMainWindow(null);
|
||||
|
||||
const buildInvisibleDeps = createBuildCreateInvisibleWindowMainDepsHandler({
|
||||
createOverlayWindow: () => ({ id: 'invisible' }),
|
||||
setInvisibleWindow: () => calls.push('set-invisible'),
|
||||
});
|
||||
const invisibleDeps = buildInvisibleDeps();
|
||||
invisibleDeps.setInvisibleWindow(null);
|
||||
|
||||
assert.deepEqual(calls, ['set-main', 'set-invisible']);
|
||||
});
|
||||
55
src/main/runtime/overlay-window-factory-main-deps.ts
Normal file
55
src/main/runtime/overlay-window-factory-main-deps.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
createOverlayWindowCore: (
|
||||
kind: 'visible' | 'invisible',
|
||||
options: {
|
||||
isDev: boolean;
|
||||
overlayDebugVisualizationEnabled: boolean;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
onRuntimeOptionsChanged: () => void;
|
||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||
isOverlayVisible: (windowKind: 'visible' | 'invisible') => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
onWindowClosed: (windowKind: 'visible' | 'invisible') => void;
|
||||
},
|
||||
) => TWindow;
|
||||
isDev: boolean;
|
||||
getOverlayDebugVisualizationEnabled: () => boolean;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
onRuntimeOptionsChanged: () => void;
|
||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||
isOverlayVisible: (windowKind: 'visible' | 'invisible') => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
onWindowClosed: (windowKind: 'visible' | 'invisible') => void;
|
||||
}) {
|
||||
return () => ({
|
||||
createOverlayWindowCore: deps.createOverlayWindowCore,
|
||||
isDev: deps.isDev,
|
||||
getOverlayDebugVisualizationEnabled: deps.getOverlayDebugVisualizationEnabled,
|
||||
ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel,
|
||||
onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged,
|
||||
setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled,
|
||||
isOverlayVisible: deps.isOverlayVisible,
|
||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||
onWindowClosed: deps.onWindowClosed,
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
|
||||
createOverlayWindow: (kind: 'visible' | 'invisible') => TWindow;
|
||||
setMainWindow: (window: TWindow | null) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
createOverlayWindow: deps.createOverlayWindow,
|
||||
setMainWindow: deps.setMainWindow,
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildCreateInvisibleWindowMainDepsHandler<TWindow>(deps: {
|
||||
createOverlayWindow: (kind: 'visible' | 'invisible') => TWindow;
|
||||
setInvisibleWindow: (window: TWindow | null) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
createOverlayWindow: deps.createOverlayWindow,
|
||||
setInvisibleWindow: deps.setInvisibleWindow,
|
||||
});
|
||||
}
|
||||
44
src/main/runtime/startup-bootstrap-deps-builder.test.ts
Normal file
44
src/main/runtime/startup-bootstrap-deps-builder.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createBuildStartupBootstrapRuntimeFactoryDepsHandler } from './startup-bootstrap-deps-builder';
|
||||
|
||||
test('startup bootstrap deps builder returns mapped runtime factory deps', () => {
|
||||
const calls: string[] = [];
|
||||
const factory = createBuildStartupBootstrapRuntimeFactoryDepsHandler({
|
||||
argv: ['node', 'main.js'],
|
||||
parseArgs: () => ({}) as never,
|
||||
setLogLevel: (level) => calls.push(`log:${level}`),
|
||||
forceX11Backend: () => calls.push('force-x11'),
|
||||
enforceUnsupportedWaylandMode: () => calls.push('wayland-guard'),
|
||||
shouldStartApp: () => true,
|
||||
getDefaultSocketPath: () => '/tmp/mpv.sock',
|
||||
defaultTexthookerPort: 5174,
|
||||
configDir: '/tmp/config',
|
||||
defaultConfig: {} as never,
|
||||
generateConfigTemplate: () => 'template',
|
||||
generateDefaultConfigFile: async () => 0,
|
||||
onConfigGenerated: (exitCode) => calls.push(`generated:${exitCode}`),
|
||||
onGenerateConfigError: (error) => calls.push(`error:${error.message}`),
|
||||
startAppLifecycle: () => calls.push('start-lifecycle'),
|
||||
});
|
||||
|
||||
const deps = factory();
|
||||
assert.deepEqual(deps.argv, ['node', 'main.js']);
|
||||
assert.equal(deps.getDefaultSocketPath(), '/tmp/mpv.sock');
|
||||
assert.equal(deps.defaultTexthookerPort, 5174);
|
||||
deps.setLogLevel('debug', 'config');
|
||||
deps.forceX11Backend({} as never);
|
||||
deps.enforceUnsupportedWaylandMode({} as never);
|
||||
deps.onConfigGenerated(0);
|
||||
deps.onGenerateConfigError(new Error('oops'));
|
||||
deps.startAppLifecycle({} as never);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'log:debug',
|
||||
'force-x11',
|
||||
'wayland-guard',
|
||||
'generated:0',
|
||||
'error:oops',
|
||||
'start-lifecycle',
|
||||
]);
|
||||
});
|
||||
32
src/main/runtime/startup-bootstrap-deps-builder.ts
Normal file
32
src/main/runtime/startup-bootstrap-deps-builder.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import type { LogLevelSource } from '../../logger';
|
||||
import type { StartupBootstrapRuntimeFactoryDeps } from '../startup';
|
||||
|
||||
export function createBuildStartupBootstrapRuntimeFactoryDepsHandler(
|
||||
deps: StartupBootstrapRuntimeFactoryDeps,
|
||||
) {
|
||||
return (): StartupBootstrapRuntimeFactoryDeps => ({
|
||||
argv: deps.argv,
|
||||
parseArgs: deps.parseArgs,
|
||||
setLogLevel: deps.setLogLevel,
|
||||
forceX11Backend: deps.forceX11Backend,
|
||||
enforceUnsupportedWaylandMode: deps.enforceUnsupportedWaylandMode,
|
||||
shouldStartApp: deps.shouldStartApp,
|
||||
getDefaultSocketPath: deps.getDefaultSocketPath,
|
||||
defaultTexthookerPort: deps.defaultTexthookerPort,
|
||||
configDir: deps.configDir,
|
||||
defaultConfig: deps.defaultConfig,
|
||||
generateConfigTemplate: deps.generateConfigTemplate,
|
||||
generateDefaultConfigFile: deps.generateDefaultConfigFile,
|
||||
onConfigGenerated: deps.onConfigGenerated,
|
||||
onGenerateConfigError: deps.onGenerateConfigError,
|
||||
startAppLifecycle: deps.startAppLifecycle,
|
||||
});
|
||||
}
|
||||
|
||||
export type {
|
||||
CliArgs as StartupBuilderCliArgs,
|
||||
ResolvedConfig as StartupBuilderResolvedConfig,
|
||||
LogLevelSource as StartupBuilderLogLevelSource,
|
||||
};
|
||||
45
src/main/runtime/tray-main-deps.test.ts
Normal file
45
src/main/runtime/tray-main-deps.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildResolveTrayIconPathMainDepsHandler,
|
||||
createBuildTrayMenuTemplateMainDepsHandler,
|
||||
} from './tray-main-deps';
|
||||
|
||||
test('tray main deps builders return mapped handlers', () => {
|
||||
const calls: string[] = [];
|
||||
const resolveDeps = createBuildResolveTrayIconPathMainDepsHandler({
|
||||
resolveTrayIconPathRuntime: () => '/tmp/icon.png',
|
||||
platform: 'darwin',
|
||||
resourcesPath: '/resources',
|
||||
appPath: '/app',
|
||||
dirname: '/dir',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
fileExists: () => true,
|
||||
})();
|
||||
|
||||
assert.equal(resolveDeps.platform, 'darwin');
|
||||
assert.equal(resolveDeps.joinPath('a', 'b'), 'a/b');
|
||||
|
||||
const menuDeps = createBuildTrayMenuTemplateMainDepsHandler({
|
||||
buildTrayMenuTemplateRuntime: () => [{ label: 'tray' }] as never,
|
||||
initializeOverlayRuntime: () => calls.push('init'),
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
openAnilistSetupWindow: () => calls.push('anilist'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
})();
|
||||
|
||||
const template = menuDeps.buildTrayMenuTemplateRuntime({
|
||||
openOverlay: () => calls.push('open-overlay'),
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openRuntimeOptions: () => calls.push('open-runtime-options'),
|
||||
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
||||
openAnilistSetup: () => calls.push('open-anilist'),
|
||||
quitApp: () => calls.push('quit-app'),
|
||||
});
|
||||
|
||||
assert.deepEqual(template, [{ label: 'tray' }]);
|
||||
});
|
||||
57
src/main/runtime/tray-main-deps.ts
Normal file
57
src/main/runtime/tray-main-deps.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
|
||||
resolveTrayIconPathRuntime: (options: {
|
||||
platform: string;
|
||||
resourcesPath: string;
|
||||
appPath: string;
|
||||
dirname: string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
fileExists: (path: string) => boolean;
|
||||
}) => string | null;
|
||||
platform: string;
|
||||
resourcesPath: string;
|
||||
appPath: string;
|
||||
dirname: string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
fileExists: (path: string) => boolean;
|
||||
}) {
|
||||
return () => ({
|
||||
resolveTrayIconPathRuntime: deps.resolveTrayIconPathRuntime,
|
||||
platform: deps.platform,
|
||||
resourcesPath: deps.resourcesPath,
|
||||
appPath: deps.appPath,
|
||||
dirname: deps.dirname,
|
||||
joinPath: deps.joinPath,
|
||||
fileExists: deps.fileExists,
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
openOverlay: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
quitApp: () => void;
|
||||
}) => TMenuItem[];
|
||||
initializeOverlayRuntime: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
openAnilistSetupWindow: () => void;
|
||||
quitApp: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
buildTrayMenuTemplateRuntime: deps.buildTrayMenuTemplateRuntime,
|
||||
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
||||
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
|
||||
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
|
||||
openAnilistSetupWindow: deps.openAnilistSetupWindow,
|
||||
quitApp: deps.quitApp,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user