Overlay 2.0 (#12)

This commit is contained in:
2026-03-01 02:36:51 -08:00
committed by GitHub
parent 45df3c466b
commit 44c7761c7c
397 changed files with 15139 additions and 7127 deletions

View File

@@ -3,7 +3,9 @@ import type {
createMaybeProbeAnilistDurationHandler,
} from './anilist-media-guess';
type MaybeProbeAnilistDurationMainDeps = Parameters<typeof createMaybeProbeAnilistDurationHandler>[0];
type MaybeProbeAnilistDurationMainDeps = Parameters<
typeof createMaybeProbeAnilistDurationHandler
>[0];
type EnsureAnilistMediaGuessMainDeps = Parameters<typeof createEnsureAnilistMediaGuessHandler>[0];
export function createBuildMaybeProbeAnilistDurationMainDepsHandler(
@@ -19,13 +21,17 @@ export function createBuildMaybeProbeAnilistDurationMainDepsHandler(
});
}
export function createBuildEnsureAnilistMediaGuessMainDepsHandler(deps: EnsureAnilistMediaGuessMainDeps) {
export function createBuildEnsureAnilistMediaGuessMainDepsHandler(
deps: EnsureAnilistMediaGuessMainDeps,
) {
return (): EnsureAnilistMediaGuessMainDeps => ({
getState: () => deps.getState(),
setState: (state) => deps.setState(state),
resolveMediaPathForJimaku: (currentMediaPath) => deps.resolveMediaPathForJimaku(currentMediaPath),
resolveMediaPathForJimaku: (currentMediaPath) =>
deps.resolveMediaPathForJimaku(currentMediaPath),
getCurrentMediaPath: () => deps.getCurrentMediaPath(),
getCurrentMediaTitle: () => deps.getCurrentMediaTitle(),
guessAnilistMediaInfo: (mediaPath, mediaTitle) => deps.guessAnilistMediaInfo(mediaPath, mediaTitle),
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
deps.guessAnilistMediaInfo(mediaPath, mediaTitle),
});
}

View File

@@ -6,8 +6,12 @@ import type {
createSetAnilistMediaGuessRuntimeStateHandler,
} from './anilist-media-state';
type GetCurrentAnilistMediaKeyMainDeps = Parameters<typeof createGetCurrentAnilistMediaKeyHandler>[0];
type ResetAnilistMediaTrackingMainDeps = Parameters<typeof createResetAnilistMediaTrackingHandler>[0];
type GetCurrentAnilistMediaKeyMainDeps = Parameters<
typeof createGetCurrentAnilistMediaKeyHandler
>[0];
type ResetAnilistMediaTrackingMainDeps = Parameters<
typeof createResetAnilistMediaTrackingHandler
>[0];
type GetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
typeof createGetAnilistMediaGuessRuntimeStateHandler
>[0];

View File

@@ -47,7 +47,8 @@ export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
hasAttemptedUpdateKey: (key: string) => deps.hasAttemptedUpdateKey(key),
processNextAnilistRetryUpdate: () => deps.processNextAnilistRetryUpdate(),
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
enqueueRetry: (key: string, title: string, episode: number) => deps.enqueueRetry(key, title, episode),
enqueueRetry: (key: string, title: string, episode: number) =>
deps.enqueueRetry(key, title, episode),
markRetryFailure: (key: string, message: string) => deps.markRetryFailure(key, message),
markRetrySuccess: (key: string) => deps.markRetrySuccess(key),
refreshRetryQueueState: () => deps.refreshRetryQueueState(),

View File

@@ -64,7 +64,11 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
return { ok: false, message: 'AniList token unavailable for queued retry.' };
}
const result = await deps.updateAnilistPostWatchProgress(accessToken, queued.title, queued.episode);
const result = await deps.updateAnilistPostWatchProgress(
accessToken,
queued.title,
queued.episode,
);
if (result.status === 'updated' || result.status === 'skipped') {
deps.markSuccess(queued.key);
deps.rememberAttemptedUpdateKey(queued.key);
@@ -166,7 +170,11 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
return;
}
const result = await deps.updateAnilistPostWatchProgress(accessToken, guess.title, guess.episode);
const result = await deps.updateAnilistPostWatchProgress(
accessToken,
guess.title,
guess.episode,
);
if (result.status === 'updated') {
deps.rememberAttemptedUpdateKey(attemptKey);
deps.markRetrySuccess(attemptKey);

View File

@@ -44,7 +44,8 @@ export function createBuildHandleAnilistSetupProtocolUrlMainDepsHandler(
deps: HandleAnilistSetupProtocolUrlMainDeps,
) {
return (): HandleAnilistSetupProtocolUrlMainDeps => ({
consumeAnilistSetupTokenFromUrl: (rawUrl: string) => deps.consumeAnilistSetupTokenFromUrl(rawUrl),
consumeAnilistSetupTokenFromUrl: (rawUrl: string) =>
deps.consumeAnilistSetupTokenFromUrl(rawUrl),
logWarn: (message: string, details: unknown) => deps.logWarn(message, details),
});
}

View File

@@ -66,11 +66,7 @@ export function createRegisterSubminerProtocolClientHandler(deps: {
getArgv: () => string[];
execPath: string;
resolvePath: (value: string) => string;
setAsDefaultProtocolClient: (
scheme: string,
path?: string,
args?: string[],
) => boolean;
setAsDefaultProtocolClient: (scheme: string, path?: string, args?: string[]) => boolean;
logWarn: (message: string, details?: unknown) => void;
}) {
return (): void => {

View File

@@ -259,7 +259,8 @@ test('open anilist setup handler no-ops when existing setup window focused', ()
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 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:
@@ -276,7 +277,12 @@ test('open anilist setup handler wires navigation, fallback, and lifecycle', ()
openHandler = handler;
},
on: (
event: 'will-navigate' | 'will-redirect' | 'did-navigate' | 'did-fail-load' | 'did-finish-load',
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;

View File

@@ -126,7 +126,11 @@ export function createAnilistSetupDidNavigateHandler(deps: {
}
export function createAnilistSetupDidFailLoadHandler(deps: {
onLoadFailure: (details: { errorCode: number; errorDescription: string; validatedURL: string }) => void;
onLoadFailure: (details: {
errorCode: number;
errorDescription: string;
validatedURL: string;
}) => void;
}) {
return (details: { errorCode: number; errorDescription: string; validatedURL: string }): void => {
deps.onLoadFailure(details);
@@ -175,7 +179,11 @@ export function createAnilistSetupFallbackHandler(deps: {
logWarn: (message: string) => void;
}) {
return {
onLoadFailure: (details: { errorCode: number; errorDescription: string; validatedURL: string }) => {
onLoadFailure: (details: {
errorCode: number;
errorDescription: string;
validatedURL: string;
}) => {
deps.logError('AniList setup window failed to load', details);
deps.openSetupInBrowser();
if (!deps.setupWindow.isDestroyed()) {
@@ -298,12 +306,7 @@ export function createOpenAnilistSetupWindowHandler<TWindow extends AnilistSetup
});
setupWindow.webContents.on(
'did-fail-load',
(
_event: unknown,
errorCode: number,
errorDescription: string,
validatedURL: string,
) => {
(_event: unknown, errorCode: number, errorDescription: string, validatedURL: string) => {
handleDidFailLoad({
errorCode,
errorDescription,

View File

@@ -65,9 +65,7 @@ export function findAnilistSetupDeepLinkArgvUrl(argv: readonly string[]): string
return null;
}
export function consumeAnilistSetupCallbackUrl(
deps: ConsumeAnilistSetupCallbackUrlDeps,
): boolean {
export function consumeAnilistSetupCallbackUrl(deps: ConsumeAnilistSetupCallbackUrlDeps): boolean {
const token = extractAnilistAccessTokenFromUrl(deps.rawUrl);
if (!token) {
return false;

View File

@@ -12,7 +12,9 @@ type ConfigWithAnilistToken = {
};
};
export function createRefreshAnilistClientSecretStateHandler<TConfig extends ConfigWithAnilistToken>(deps: {
export function createRefreshAnilistClientSecretStateHandler<
TConfig extends ConfigWithAnilistToken,
>(deps: {
getResolvedConfig: () => TConfig;
isAnilistTrackingEnabled: (config: TConfig) => boolean;
getCachedAccessToken: () => string | null;

View File

@@ -24,7 +24,9 @@ export function createBuildUpdateLastCardFromClipboardMainDepsHandler<TAnki>(dep
});
}
export function createBuildRefreshKnownWordCacheMainDepsHandler(deps: RefreshKnownWordCacheMainDeps) {
export function createBuildRefreshKnownWordCacheMainDepsHandler(
deps: RefreshKnownWordCacheMainDeps,
) {
return (): RefreshKnownWordCacheMainDeps => ({
getAnkiIntegration: () => deps.getAnkiIntegration(),
missingIntegrationMessage: deps.missingIntegrationMessage,
@@ -42,8 +44,10 @@ export function createBuildTriggerFieldGroupingMainDepsHandler<TAnki>(deps: {
return () => ({
getAnkiIntegration: () => deps.getAnkiIntegration(),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
triggerFieldGroupingCore: (options: { ankiIntegration: TAnki; showMpvOsd: (text: string) => void }) =>
deps.triggerFieldGroupingCore(options),
triggerFieldGroupingCore: (options: {
ankiIntegration: TAnki;
showMpvOsd: (text: string) => void;
}) => deps.triggerFieldGroupingCore(options),
});
}
@@ -58,8 +62,10 @@ export function createBuildMarkLastCardAsAudioCardMainDepsHandler<TAnki>(deps: {
return () => ({
getAnkiIntegration: () => deps.getAnkiIntegration(),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
markLastCardAsAudioCardCore: (options: { ankiIntegration: TAnki; showMpvOsd: (text: string) => void }) =>
deps.markLastCardAsAudioCardCore(options),
markLastCardAsAudioCardCore: (options: {
ankiIntegration: TAnki;
showMpvOsd: (text: string) => void;
}) => deps.markLastCardAsAudioCardCore(options),
});
}

View File

@@ -12,6 +12,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
destroyTray: () => calls.push('destroy-tray'),
stopConfigHotReload: () => calls.push('stop-config'),
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
@@ -33,7 +34,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
});
cleanup();
assert.equal(calls.length, 21);
assert.equal(calls.length, 22);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
@@ -58,11 +59,10 @@ 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'),
syncOverlayMpvSubtitleSuppression: () => calls.push('mpv-sync'),
});
restore();
assert.deepEqual(calls, ['main', 'invisible', 'visible-sync', 'invisible-sync']);
assert.deepEqual(calls, ['main', 'visible-sync', 'mpv-sync']);
});

View File

@@ -2,6 +2,7 @@ export function createOnWillQuitCleanupHandler(deps: {
destroyTray: () => void;
stopConfigHotReload: () => void;
restorePreviousSecondarySubVisibility: () => void;
restoreMpvSubVisibility: () => void;
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
@@ -25,6 +26,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.destroyTray();
deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility();
deps.restoreMpvSubVisibility();
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
@@ -55,14 +57,12 @@ export function createShouldRestoreWindowsOnActivateHandler(deps: {
export function createRestoreWindowsOnActivateHandler(deps: {
createMainWindow: () => void;
createInvisibleWindow: () => void;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
}) {
return (): void => {
deps.createMainWindow();
deps.createInvisibleWindow();
deps.updateVisibleOverlayVisibility();
deps.updateInvisibleOverlayVisibility();
deps.syncOverlayMpvSubtitleSuppression();
};
}

View File

@@ -19,14 +19,12 @@ 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'),
syncOverlayMpvSubtitleSuppression: () => calls.push('mpv-sync'),
})();
deps.createMainWindow();
deps.createInvisibleWindow();
deps.updateVisibleOverlayVisibility();
deps.updateInvisibleOverlayVisibility();
assert.deepEqual(calls, ['main', 'invisible', 'visible', 'invisible-visible']);
deps.syncOverlayMpvSubtitleSuppression();
assert.deepEqual(calls, ['main', 'visible', 'mpv-sync']);
});

View File

@@ -10,14 +10,12 @@ export function createBuildShouldRestoreWindowsOnActivateMainDepsHandler(deps: {
export function createBuildRestoreWindowsOnActivateMainDepsHandler(deps: {
createMainWindow: () => void;
createInvisibleWindow: () => void;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
}) {
return () => ({
createMainWindow: () => deps.createMainWindow(),
createInvisibleWindow: () => deps.createInvisibleWindow(),
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
updateInvisibleOverlayVisibility: () => deps.updateInvisibleOverlayVisibility(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
});
}

View File

@@ -14,6 +14,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
destroyTray: () => calls.push('destroy-tray'),
stopConfigHotReload: () => calls.push('stop-config'),
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
@@ -72,6 +73,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},

View File

@@ -21,6 +21,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
destroyTray: () => void;
stopConfigHotReload: () => void;
restorePreviousSecondarySubVisibility: () => void;
restoreMpvSubVisibility: () => void;
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
@@ -51,6 +52,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
destroyTray: () => deps.destroyTray(),
stopConfigHotReload: () => deps.stopConfigHotReload(),
restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(),
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
stopTexthookerService: () => deps.stopTexthookerService(),

View File

@@ -1,8 +1,6 @@
import type { AppReadyRuntimeDepsFactoryInput } from '../app-lifecycle';
export function createBuildAppReadyRuntimeMainDepsHandler(
deps: AppReadyRuntimeDepsFactoryInput,
) {
export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeDepsFactoryInput) {
return (): AppReadyRuntimeDepsFactoryInput => ({
loadSubtitlePosition: deps.loadSubtitlePosition,
resolveKeybindings: deps.resolveKeybindings,
@@ -27,12 +25,12 @@ export function createBuildAppReadyRuntimeMainDepsHandler(
prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries,
startBackgroundWarmups: deps.startBackgroundWarmups,
texthookerOnlyMode: deps.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig:
deps.shouldAutoInitializeOverlayRuntimeFromConfig,
shouldAutoInitializeOverlayRuntimeFromConfig: deps.shouldAutoInitializeOverlayRuntimeFromConfig,
initializeOverlayRuntime: deps.initializeOverlayRuntime,
handleInitialArgs: deps.handleInitialArgs,
onCriticalConfigErrors: deps.onCriticalConfigErrors,
logDebug: deps.logDebug,
now: deps.now,
shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup,
});
}

View File

@@ -50,26 +50,18 @@ test('initialize overlay runtime main deps map build options and callbacks', ()
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);
assert.equal(deps.initializeOverlayRuntimeCore(options), undefined);
deps.setOverlayRuntimeInitialized(true);
deps.startBackgroundWarmups();
assert.deepEqual(calls, [
'core:{"id":"opts"}',
'set-invisible:true',
'set-initialized:true',
'warmups',
]);
assert.deepEqual(calls, ['core:{"id":"opts"}', 'set-initialized:true', 'warmups']);
});
test('open yomitan settings main deps map async open callbacks', async () => {
@@ -78,7 +70,8 @@ test('open yomitan settings main deps map async open callbacks', async () => {
const extension = { id: 'ext' };
const deps = createBuildOpenYomitanSettingsMainDepsHandler({
ensureYomitanExtensionLoaded: async () => extension,
openYomitanSettingsWindow: ({ yomitanExt }) => calls.push(`open:${(yomitanExt as { id: string }).id}`),
openYomitanSettingsWindow: ({ yomitanExt }) =>
calls.push(`open:${(yomitanExt as { id: string }).id}`),
getExistingWindow: () => currentWindow,
setWindow: (window) => {
currentWindow = window;

View File

@@ -45,9 +45,8 @@ export function createBuildDestroyTrayMainDepsHandler<TTray>(deps: {
export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<TOptions>(deps: {
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntimeCore: (options: TOptions) => { invisibleOverlayVisible: boolean };
initializeOverlayRuntimeCore: (options: TOptions) => void;
buildOptions: () => TOptions;
setInvisibleOverlayVisible: (visible: boolean) => void;
setOverlayRuntimeInitialized: (initialized: boolean) => void;
startBackgroundWarmups: () => void;
}) {
@@ -55,7 +54,6 @@ export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<TOpt
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
initializeOverlayRuntimeCore: (options: TOptions) => deps.initializeOverlayRuntimeCore(options),
buildOptions: () => deps.buildOptions(),
setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
setOverlayRuntimeInitialized: (initialized: boolean) =>
deps.setOverlayRuntimeInitialized(initialized),
startBackgroundWarmups: () => deps.startBackgroundWarmups(),
@@ -68,6 +66,7 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
onWindowClosed?: () => void;
}) => void;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
@@ -80,6 +79,7 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
onWindowClosed?: () => void;
}) => deps.openYomitanSettingsWindow(params),
getExistingWindow: () => deps.getExistingWindow(),
setWindow: (window: TWindow | null) => deps.setWindow(window),

View File

@@ -18,9 +18,7 @@ test('build cli command context deps maps handlers and values', () => {
isOverlayInitialized: () => true,
initializeOverlay: () => calls.push('init'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`),
setInvisibleOverlay: (visible) => calls.push(`set-invisible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy'),
startPendingMultiCopy: (ms) => calls.push(`multi:${ms}`),
mineSentenceCard: async () => {

View File

@@ -2,6 +2,7 @@ import type { CliArgs } from '../../cli/args';
import type { CliCommandContextFactoryDeps } from './cli-command-context';
export function createBuildCliCommandContextDepsHandler(deps: {
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
getSocketPath: () => string;
setSocketPath: (socketPath: string) => void;
getMpvClient: CliCommandContextFactoryDeps['getMpvClient'];
@@ -15,9 +16,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlay: (visible: boolean) => void;
setInvisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
@@ -47,6 +46,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
logError: (message: string, err: unknown) => void;
}) {
return (): CliCommandContextFactoryDeps => ({
setLogLevel: deps.setLogLevel,
getSocketPath: deps.getSocketPath,
setSocketPath: deps.setSocketPath,
getMpvClient: deps.getMpvClient,
@@ -60,9 +60,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay,
toggleInvisibleOverlay: deps.toggleInvisibleOverlay,
setVisibleOverlay: deps.setVisibleOverlay,
setInvisibleOverlay: deps.setInvisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle,
startPendingMultiCopy: deps.startPendingMultiCopy,
mineSentenceCard: deps.mineSentenceCard,

View File

@@ -20,9 +20,7 @@ test('cli command context factory composes main deps and context handlers', () =
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 () => {},
@@ -73,16 +71,8 @@ test('cli command context factory composes main deps and context handlers', () =
context.setSocketPath('/tmp/new.sock');
context.showOsd('hello');
context.setVisibleOverlay(true);
context.setInvisibleOverlay(false);
context.toggleVisibleOverlay();
context.toggleInvisibleOverlay();
assert.equal(appState.mpvSocketPath, '/tmp/new.sock');
assert.deepEqual(calls, [
'osd:hello',
'set-visible:true',
'set-invisible:false',
'toggle-visible',
'toggle-invisible',
]);
assert.deepEqual(calls, ['osd:hello', 'set-visible:true', 'toggle-visible']);
});

View File

@@ -2,9 +2,7 @@ import { createCliCommandContext } from './cli-command-context';
import { createBuildCliCommandContextDepsHandler } from './cli-command-context-deps';
import { createBuildCliCommandContextMainDepsHandler } from './cli-command-context-main-deps';
type CliCommandContextMainDeps = Parameters<
typeof createBuildCliCommandContextMainDepsHandler
>[0];
type CliCommandContextMainDeps = Parameters<typeof createBuildCliCommandContextMainDepsHandler>[0];
export function createCliCommandContextFactory(deps: CliCommandContextMainDeps) {
const buildCliCommandContextMainDepsHandler = createBuildCliCommandContextMainDepsHandler(deps);

View File

@@ -23,9 +23,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
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}`),
@@ -103,16 +101,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
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',
]);
assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'set-visible:true', 'help']);
const retry = await deps.retryAnilistQueueNow();
assert.deepEqual(retry, { ok: true, message: 'ok' });

View File

@@ -10,6 +10,7 @@ type CliCommandContextMainState = {
export function createBuildCliCommandContextMainDepsHandler(deps: {
appState: CliCommandContextMainState;
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
texthookerService: CliCommandContextFactoryDeps['texthookerService'];
getResolvedConfig: () => { texthooker?: { openBrowser?: boolean } };
openExternal: (url: string) => Promise<unknown>;
@@ -18,9 +19,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -53,6 +52,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
logError: (message: string, err: unknown) => void;
}) {
return (): CliCommandContextFactoryDeps => ({
setLogLevel: deps.setLogLevel,
getSocketPath: () => deps.appState.mpvSocketPath,
setSocketPath: (socketPath: string) => {
deps.appState.mpvSocketPath = socketPath;
@@ -70,9 +70,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
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(),

View File

@@ -24,9 +24,7 @@ function createDeps() {
isOverlayInitialized: () => true,
initializeOverlay: () => {},
toggleVisibleOverlay: () => {},
toggleInvisibleOverlay: () => {},
setVisibleOverlay: () => {},
setInvisibleOverlay: () => {},
copyCurrentSubtitle: () => {},
startPendingMultiCopy: () => {},
mineSentenceCard: async () => {},
@@ -36,11 +34,11 @@ function createDeps() {
triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {},
getAnilistStatus: () => ({} as never),
getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {},
openAnilistSetup: () => {},
openJellyfinSetup: () => {},
getAnilistQueueStatus: () => ({} as never),
getAnilistQueueStatus: () => ({}) as never,
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
runJellyfinCommand: async () => {},
openYomitanSettings: () => {},

View File

@@ -7,6 +7,7 @@ import type {
type MpvClientLike = CliCommandRuntimeServiceContext['getClient'] extends () => infer T ? T : never;
export type CliCommandContextFactoryDeps = {
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
getSocketPath: () => string;
setSocketPath: (socketPath: string) => void;
getMpvClient: () => MpvClientLike;
@@ -20,9 +21,7 @@ export type CliCommandContextFactoryDeps = {
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlay: (visible: boolean) => void;
setInvisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
@@ -56,6 +55,7 @@ export function createCliCommandContext(
deps: CliCommandContextFactoryDeps,
): CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers {
return {
setLogLevel: deps.setLogLevel,
getSocketPath: deps.getSocketPath,
setSocketPath: deps.setSocketPath,
getClient: deps.getMpvClient,
@@ -72,9 +72,7 @@ export function createCliCommandContext(
isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay,
toggleInvisibleOverlay: deps.toggleInvisibleOverlay,
setVisibleOverlay: deps.setVisibleOverlay,
setInvisibleOverlay: deps.setInvisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle,
startPendingMultiCopy: deps.startPendingMultiCopy,
mineSentenceCard: deps.mineSentenceCard,

View File

@@ -15,12 +15,11 @@ export function createCliCommandRuntimeHandler<TCliContext>(deps: {
cliContext: TCliContext,
) => void;
}) {
const handleTexthookerOnlyModeTransitionHandler =
createHandleTexthookerOnlyModeTransitionHandler(
createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(
deps.handleTexthookerOnlyModeTransitionMainDeps,
)(),
);
const handleTexthookerOnlyModeTransitionHandler = createHandleTexthookerOnlyModeTransitionHandler(
createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(
deps.handleTexthookerOnlyModeTransitionMainDeps,
)(),
);
return (args: CliArgs, source: CliCommandSource = 'initial'): void => {
handleTexthookerOnlyModeTransitionHandler(args);

View File

@@ -13,9 +13,10 @@ export type AppendClipboardVideoToQueueRuntimeDeps = {
sendMpvCommand: (command: (string | number)[]) => void;
};
export function appendClipboardVideoToQueueRuntime(
deps: AppendClipboardVideoToQueueRuntimeDeps,
): { ok: boolean; message: string } {
export function appendClipboardVideoToQueueRuntime(deps: AppendClipboardVideoToQueueRuntimeDeps): {
ok: boolean;
message: string;
} {
const mpvClient = deps.getMpvClient();
if (!mpvClient || !mpvClient.connected) {
return { ok: false, message: 'MPV is not connected.' };

View File

@@ -42,11 +42,13 @@ export function composeAppReadyRuntime(options: AppReadyComposerOptions): AppRea
createBuildAppReadyRuntimeMainDepsHandler({
...options.appReadyRuntimeMainDeps,
reloadConfig,
createImmersionTracker: createImmersionTrackerStartupHandler(
createBuildImmersionTrackerStartupMainDepsHandler(
options.immersionTrackerStartupMainDeps,
)(),
),
createImmersionTracker:
options.appReadyRuntimeMainDeps.createImmersionTracker ??
createImmersionTrackerStartupHandler(
createBuildImmersionTrackerStartupMainDepsHandler(
options.immersionTrackerStartupMainDeps,
)(),
),
onCriticalConfigErrors: criticalConfigError,
})(),
);

View File

@@ -32,10 +32,8 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
showMpvOsd: () => {},
},
mainDeps: {
getInvisibleWindow: () => null,
getMainWindow: () => null,
getVisibleOverlayVisibility: () => false,
getInvisibleOverlayVisibility: () => false,
focusMainWindow: () => {},
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
@@ -44,7 +42,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => ({}) as never,
getPlaybackPaused: () => null,
getSubtitlePosition: () => ({}) as never,
getSubtitleStyle: () => ({}) as never,
saveSubtitlePosition: () => {},
@@ -56,7 +54,6 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {},
openAnilistSetup: () => {},

View File

@@ -6,7 +6,8 @@ test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers',
let lastProgressAt = 0;
const composed = composeJellyfinRemoteHandlers({
getConfiguredSession: () => null,
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: 'test', deviceId: 'dev' }) as never,
getClientInfo: () =>
({ clientName: 'SubMiner', clientVersion: 'test', deviceId: 'dev' }) as never,
getJellyfinConfig: () => ({ enabled: false }) as never,
playJellyfinItem: async () => {},
logWarn: () => {},

View File

@@ -26,6 +26,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
const calls: string[] = [];
let started = false;
let metrics = BASE_METRICS;
let mecabTokenizer: { id: string } | null = null;
class FakeMpvClient {
connected = false;
@@ -68,12 +69,15 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
@@ -90,7 +94,6 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => true,
setOverlayVisible: () => {},
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
@@ -125,6 +128,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
getJlptLevel: () => null,
getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
@@ -139,9 +143,15 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
return { text };
},
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => ({ id: 'mecab' }),
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
getMecabTokenizer: () => mecabTokenizer,
setMecabTokenizer: (next) => {
mecabTokenizer = next as { id: string };
calls.push('set-mecab');
},
createMecabTokenizer: () => {
calls.push('create-mecab');
return { id: 'mecab' };
},
checkAvailability: async () => {
calls.push('check-mecab');
},
@@ -175,6 +185,10 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
ensureYomitanExtensionLoaded: async () => {
calls.push('warmup-yomitan');
},
shouldWarmupMecab: () => true,
shouldWarmupYomitanExtension: () => true,
shouldWarmupSubtitleDictionaries: () => true,
shouldWarmupJellyfinRemoteSession: () => true,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {
calls.push('warmup-jellyfin');
@@ -189,6 +203,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
assert.equal(typeof composed.tokenizeSubtitle, 'function');
assert.equal(typeof composed.createMecabTokenizerAndCheck, 'function');
assert.equal(typeof composed.prewarmSubtitleDictionaries, 'function');
assert.equal(typeof composed.startTokenizationWarmups, 'function');
assert.equal(typeof composed.launchBackgroundWarmupTask, 'function');
assert.equal(typeof composed.startBackgroundWarmups, 'function');
@@ -196,6 +211,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
assert.equal(client.connected, true);
composed.updateMpvSubtitleRenderMetrics({ subPos: 90 });
await composed.startTokenizationWarmups();
const tokenized = await composed.tokenizeSubtitle('subtitle text');
await composed.createMecabTokenizerAndCheck();
await composed.prewarmSubtitleDictionaries();
@@ -211,9 +227,12 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
assert.ok(calls.includes('broadcast-metrics'));
assert.ok(calls.includes('create-tokenizer-runtime-deps'));
assert.ok(calls.includes('tokenize:subtitle text'));
assert.ok(calls.includes('create-mecab'));
assert.ok(calls.includes('set-mecab'));
assert.ok(calls.includes('check-mecab'));
assert.ok(calls.includes('prewarm-jlpt'));
assert.ok(calls.includes('prewarm-frequency'));
assert.ok(calls.includes('set-started:true'));
assert.ok(calls.includes('warmup-yomitan'));
assert.ok(calls.indexOf('create-mecab') < calls.indexOf('set-started:true'));
});

View File

@@ -87,6 +87,7 @@ export type MpvRuntimeComposerResult<
tokenizeSubtitle: (text: string) => Promise<TTokenizedSubtitle>;
createMecabTokenizerAndCheck: () => Promise<void>;
prewarmSubtitleDictionaries: () => Promise<void>;
startTokenizationWarmups: () => Promise<void>;
launchBackgroundWarmupTask: ReturnType<typeof createLaunchBackgroundWarmupTaskFromStartup>;
startBackgroundWarmups: ReturnType<typeof createStartBackgroundWarmupsFromStartup>;
}>;
@@ -132,8 +133,23 @@ export function composeMpvRuntimeHandlers<
const prewarmSubtitleDictionaries = createPrewarmSubtitleDictionariesMainHandler(
options.tokenizer.prewarmSubtitleDictionariesMainDeps,
);
let tokenizationWarmupInFlight: Promise<void> | null = null;
const startTokenizationWarmups = (): Promise<void> => {
if (!tokenizationWarmupInFlight) {
tokenizationWarmupInFlight = (async () => {
await options.warmups.startBackgroundWarmupsMainDeps.ensureYomitanExtensionLoaded();
if (!options.tokenizer.createMecabTokenizerAndCheckMainDeps.getMecabTokenizer()) {
await createMecabTokenizerAndCheck().catch(() => {});
}
await prewarmSubtitleDictionaries({ showLoadingOsd: true });
})().finally(() => {
tokenizationWarmupInFlight = null;
});
}
return tokenizationWarmupInFlight;
};
const tokenizeSubtitle = async (text: string): Promise<TTokenizedSubtitle> => {
await prewarmSubtitleDictionaries();
await startTokenizationWarmups();
return options.tokenizer.tokenizeSubtitle(
text,
options.tokenizer.createTokenizerRuntimeDeps(buildTokenizerDepsHandler()),
@@ -161,6 +177,7 @@ export function composeMpvRuntimeHandlers<
tokenizeSubtitle,
createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(),
prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(),
startTokenizationWarmups,
launchBackgroundWarmupTask: (label, task) => launchBackgroundWarmupTask(label, task),
startBackgroundWarmups: () => startBackgroundWarmups(),
};

View File

@@ -14,7 +14,6 @@ test('composeShortcutRuntimes returns callable shortcut runtime handlers', () =>
getConfiguredShortcuts: () => ({}) as never,
registerGlobalShortcutsCore: () => {},
toggleVisibleOverlay: () => {},
toggleInvisibleOverlay: () => {},
openYomitanSettings: () => {},
isDev: false,
getMainWindow: () => null,

View File

@@ -16,6 +16,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
@@ -43,9 +44,8 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
},
restoreWindowsOnActivateMainDeps: {
createMainWindow: () => {},
createInvisibleWindow: () => {},
updateVisibleOverlayVisibility: () => {},
updateInvisibleOverlayVisibility: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
},
});

View File

@@ -1,29 +1,24 @@
import type { RuntimeOptionsManager } from '../../runtime-options';
import type { JimakuApiResponse, JimakuLanguagePreference, ResolvedConfig } from '../../types';
import {
getInitialInvisibleOverlayVisibility as getInitialInvisibleOverlayVisibilityCore,
getJimakuLanguagePreference as getJimakuLanguagePreferenceCore,
getJimakuMaxEntryResults as getJimakuMaxEntryResultsCore,
isAutoUpdateEnabledRuntime as isAutoUpdateEnabledRuntimeCore,
jimakuFetchJson as jimakuFetchJsonCore,
resolveJimakuApiKey as resolveJimakuApiKeyCore,
shouldAutoInitializeOverlayRuntimeFromConfig as shouldAutoInitializeOverlayRuntimeFromConfigCore,
shouldBindVisibleOverlayToMpvSubVisibility as shouldBindVisibleOverlayToMpvSubVisibilityCore,
} from '../../core/services';
export type ConfigDerivedRuntimeDeps = {
getResolvedConfig: () => ResolvedConfig;
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
platform: NodeJS.Platform;
defaultJimakuLanguagePreference: JimakuLanguagePreference;
defaultJimakuMaxEntryResults: number;
defaultJimakuApiBaseUrl: string;
};
export function createConfigDerivedRuntime(deps: ConfigDerivedRuntimeDeps): {
getInitialInvisibleOverlayVisibility: () => boolean;
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isAutoUpdateEnabledRuntime: () => boolean;
getJimakuLanguagePreference: () => JimakuLanguagePreference;
getJimakuMaxEntryResults: () => number;
@@ -34,12 +29,8 @@ export function createConfigDerivedRuntime(deps: ConfigDerivedRuntimeDeps): {
) => Promise<JimakuApiResponse<T>>;
} {
return {
getInitialInvisibleOverlayVisibility: () =>
getInitialInvisibleOverlayVisibilityCore(deps.getResolvedConfig(), deps.platform),
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
shouldAutoInitializeOverlayRuntimeFromConfigCore(deps.getResolvedConfig()),
shouldBindVisibleOverlayToMpvSubVisibility: () =>
shouldBindVisibleOverlayToMpvSubVisibilityCore(deps.getResolvedConfig()),
isAutoUpdateEnabledRuntime: () =>
isAutoUpdateEnabledRuntimeCore(deps.getResolvedConfig(), deps.getRuntimeOptionsManager()),
getJimakuLanguagePreference: () =>
@@ -48,7 +39,10 @@ export function createConfigDerivedRuntime(deps: ConfigDerivedRuntimeDeps): {
deps.defaultJimakuLanguagePreference,
),
getJimakuMaxEntryResults: () =>
getJimakuMaxEntryResultsCore(() => deps.getResolvedConfig(), deps.defaultJimakuMaxEntryResults),
getJimakuMaxEntryResultsCore(
() => deps.getResolvedConfig(),
deps.defaultJimakuMaxEntryResults,
),
resolveJimakuApiKey: () => resolveJimakuApiKeyCore(() => deps.getResolvedConfig()),
jimakuFetchJson: <T>(
endpoint: string,

View File

@@ -111,7 +111,7 @@ test('config hot reload applied main deps builder maps callbacks', () => {
test('config hot reload runtime main deps builder maps runtime callbacks', () => {
const calls: string[] = [];
const deps = createBuildConfigHotReloadRuntimeMainDepsHandler({
getCurrentConfig: () => ({ id: 1 } as never as ResolvedConfig),
getCurrentConfig: () => ({ id: 1 }) as never as ResolvedConfig,
reloadConfigStrict: () =>
({
ok: true,
@@ -144,5 +144,10 @@ test('config hot reload runtime main deps builder maps runtime callbacks', () =>
deps.onRestartRequired([]);
deps.onInvalidConfig('bad');
deps.onValidationWarnings('/tmp/config.jsonc', []);
assert.deepEqual(calls, ['hot-reload', 'restart-required', 'invalid-config', 'validation-warnings']);
assert.deepEqual(calls, [
'hot-reload',
'restart-required',
'invalid-config',
'validation-warnings',
]);
});

View File

@@ -3,7 +3,12 @@ import type {
ConfigHotReloadRuntimeDeps,
} from '../../core/services/config-hot-reload';
import type { ReloadConfigStrictResult } from '../../config';
import type { ConfigHotReloadPayload, ConfigValidationWarning, ResolvedConfig, SecondarySubMode } from '../../types';
import type {
ConfigHotReloadPayload,
ConfigValidationWarning,
ResolvedConfig,
SecondarySubMode,
} from '../../types';
import type { createConfigHotReloadMessageHandler } from './config-hot-reload-handlers';
type ConfigWatchListener = (eventType: string, filename: string | null) => void;

View File

@@ -29,7 +29,8 @@ test('jlpt dictionary runtime main deps builder maps search paths and log prefix
const deps = createBuildJlptDictionaryRuntimeMainDepsHandler({
isJlptEnabled: () => true,
getDictionaryRoots: () => ['/root/a'],
getJlptDictionarySearchPaths: ({ getDictionaryRoots }) => getDictionaryRoots().map((path) => `${path}/jlpt`),
getJlptDictionarySearchPaths: ({ getDictionaryRoots }) =>
getDictionaryRoots().map((path) => `${path}/jlpt`),
setJlptLevelLookup: () => calls.push('set-lookup'),
logInfo: (message) => calls.push(`log:${message}`),
})();
@@ -53,9 +54,9 @@ test('frequency dictionary roots main handler returns expected root list', () =>
joinPath: (...parts) => parts.join('/'),
})();
assert.equal(roots.length, 15);
assert.equal(roots[0], '/repo/dist/main/../../vendor/jiten_freq_global');
assert.equal(roots[14], '/repo');
assert.equal(roots.length, 11);
assert.equal(roots[0], '/repo/dist/main/../../vendor/frequency-dictionary');
assert.equal(roots[10], '/repo');
});
test('frequency dictionary runtime main deps builder maps search paths/source and log prefix', () => {

View File

@@ -38,13 +38,9 @@ export function createBuildFrequencyDictionaryRootsMainHandler(deps: {
joinPath: (...parts: string[]) => string;
}) {
return () => [
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'jiten_freq_global'),
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'frequency-dictionary'),
deps.joinPath(deps.appPath, 'vendor', 'jiten_freq_global'),
deps.joinPath(deps.appPath, 'vendor', 'frequency-dictionary'),
deps.joinPath(deps.resourcesPath, 'jiten_freq_global'),
deps.joinPath(deps.resourcesPath, 'frequency-dictionary'),
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'jiten_freq_global'),
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'frequency-dictionary'),
deps.userDataPath,
deps.appUserDataPath,

View File

@@ -15,9 +15,7 @@ test('field grouping overlay main deps builder maps window visibility and resolv
},
}),
getVisibleOverlayVisible: () => true,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
setInvisibleOverlayVisible: (visible) => calls.push(`invisible:${visible}`),
getResolver: () => resolver,
setResolver: (nextResolver) => {
calls.push(`set-resolver:${nextResolver ? 'set' : 'null'}`);
@@ -31,17 +29,10 @@ test('field grouping overlay main deps builder maps window visibility and resolv
assert.equal(deps.getMainWindow()?.isDestroyed(), false);
assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getInvisibleOverlayVisible(), false);
assert.equal(deps.getResolver(), resolver);
assert.equal(deps.getRestoreVisibleOverlayOnModalClose(), modalSet);
deps.setVisibleOverlayVisible(true);
deps.setInvisibleOverlayVisible(false);
deps.setResolver(null);
assert.equal(deps.sendToVisibleOverlay('kiku:open', 1), true);
assert.deepEqual(calls, [
'visible:true',
'invisible:false',
'set-resolver:null',
'send:kiku:open:1',
]);
assert.deepEqual(calls, ['visible:true', 'set-resolver:null', 'send:kiku:open:1']);
});

View File

@@ -24,9 +24,7 @@ export function createBuildFieldGroupingOverlayMainDepsHandler<TModal extends st
return (): BuiltFieldGroupingOverlayMainDeps<TModal> => ({
getMainWindow: () => deps.getMainWindow(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => deps.getInvisibleOverlayVisible(),
setVisibleOverlayVisible: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
getResolver: () => deps.getResolver(),
setResolver: (resolver) => deps.setResolver(resolver),
getRestoreVisibleOverlayOnModalClose: () => deps.getRestoreVisibleOverlayOnModalClose(),

View File

@@ -12,23 +12,26 @@ test('get configured shortcuts main deps map config resolver inputs', () => {
const build = createBuildGetConfiguredShortcutsMainDepsHandler({
getResolvedConfig: () => config,
defaultConfig: defaults,
resolveConfiguredShortcuts: (nextConfig, nextDefaults) => ({ nextConfig, nextDefaults }) as never,
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 });
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),
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,
@@ -38,17 +41,15 @@ test('register global shortcuts main deps map callbacks and flags', () => {
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']);
assert.deepEqual(calls, ['register', 'toggle-visible', 'open-yomitan']);
});
test('refresh global shortcuts main deps map passthrough handlers', () => {

View File

@@ -19,7 +19,6 @@ export function createBuildRegisterGlobalShortcutsMainDepsHandler(deps: {
getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts'];
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
openYomitanSettings: () => void;
isDev: boolean;
getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow'];
@@ -29,7 +28,6 @@ export function createBuildRegisterGlobalShortcutsMainDepsHandler(deps: {
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) =>
deps.registerGlobalShortcutsCore(options),
onToggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
onToggleInvisibleOverlay: () => deps.toggleInvisibleOverlay(),
onOpenYomitanSettings: () => deps.openYomitanSettings(),
isDev: deps.isDev,
getMainWindow: deps.getMainWindow,

View File

@@ -6,7 +6,6 @@ import { createGlobalShortcutsRuntimeHandlers } from './global-shortcuts-runtime
function createShortcuts(): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O',
toggleInvisibleOverlayGlobal: 'CommandOrControl+Shift+I',
copySubtitle: 's',
copySubtitleMultiple: 'CommandOrControl+s',
updateLastCardFromClipboard: 'c',
@@ -38,7 +37,6 @@ test('global shortcuts runtime handlers compose get/register/refresh flow', () =
assert.equal(options.shortcuts, shortcuts);
},
toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
openYomitanSettings: () => calls.push('open-yomitan'),
isDev: false,
getMainWindow: () => null,

View File

@@ -32,15 +32,17 @@ export function createGlobalShortcutsRuntimeHandlers(deps: {
const getConfiguredShortcutsMainDeps = createBuildGetConfiguredShortcutsMainDepsHandler(
deps.getConfiguredShortcutsMainDeps,
)();
const getConfiguredShortcutsHandler =
createGetConfiguredShortcutsHandler(getConfiguredShortcutsMainDeps);
const getConfiguredShortcutsHandler = createGetConfiguredShortcutsHandler(
getConfiguredShortcutsMainDeps,
);
const getConfiguredShortcuts = () => getConfiguredShortcutsHandler();
const registerGlobalShortcutsMainDeps = createBuildRegisterGlobalShortcutsMainDepsHandler(
deps.buildRegisterGlobalShortcutsMainDeps(getConfiguredShortcuts),
)();
const registerGlobalShortcutsHandler =
createRegisterGlobalShortcutsHandler(registerGlobalShortcutsMainDeps);
const registerGlobalShortcutsHandler = createRegisterGlobalShortcutsHandler(
registerGlobalShortcutsMainDeps,
);
const registerGlobalShortcuts = () => registerGlobalShortcutsHandler();
const refreshGlobalAndOverlayShortcutsMainDeps =

View File

@@ -10,7 +10,6 @@ import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
function createShortcuts(): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O',
toggleInvisibleOverlayGlobal: 'CommandOrControl+Shift+I',
copySubtitle: 's',
copySubtitleMultiple: 'CommandOrControl+s',
updateLastCardFromClipboard: 'c',
@@ -58,18 +57,16 @@ test('register global shortcuts handler passes through callbacks and shortcuts',
assert.equal(options.isDev, true);
assert.equal(options.getMainWindow(), mainWindow);
options.onToggleVisibleOverlay();
options.onToggleInvisibleOverlay();
options.onOpenYomitanSettings();
},
onToggleVisibleOverlay: () => calls.push('toggle-visible'),
onToggleInvisibleOverlay: () => calls.push('toggle-invisible'),
onOpenYomitanSettings: () => calls.push('open-yomitan'),
isDev: true,
getMainWindow: () => mainWindow,
});
registerGlobalShortcuts();
assert.deepEqual(calls, ['register', 'toggle-visible', 'toggle-invisible', 'open-yomitan']);
assert.deepEqual(calls, ['register', 'toggle-visible', 'open-yomitan']);
});
test('refresh global and overlay shortcuts unregisters then re-registers', () => {

View File

@@ -5,10 +5,7 @@ import type { RegisterGlobalShortcutsServiceOptions } from '../../core/services/
export function createGetConfiguredShortcutsHandler(deps: {
getResolvedConfig: () => Config;
defaultConfig: Config;
resolveConfiguredShortcuts: (
config: Config,
defaultConfig: Config,
) => ConfiguredShortcuts;
resolveConfiguredShortcuts: (config: Config, defaultConfig: Config) => ConfiguredShortcuts;
}) {
return (): ConfiguredShortcuts =>
deps.resolveConfiguredShortcuts(deps.getResolvedConfig(), deps.defaultConfig);
@@ -18,7 +15,6 @@ export function createRegisterGlobalShortcutsHandler(deps: {
getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts'];
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void;
onToggleVisibleOverlay: () => void;
onToggleInvisibleOverlay: () => void;
onOpenYomitanSettings: () => void;
isDev: boolean;
getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow'];
@@ -27,7 +23,6 @@ export function createRegisterGlobalShortcutsHandler(deps: {
deps.registerGlobalShortcutsCore({
shortcuts: deps.getConfiguredShortcuts(),
onToggleVisibleOverlay: deps.onToggleVisibleOverlay,
onToggleInvisibleOverlay: deps.onToggleInvisibleOverlay,
onOpenYomitanSettings: deps.onOpenYomitanSettings,
isDev: deps.isDev,
getMainWindow: deps.getMainWindow,

View File

@@ -5,7 +5,7 @@ import { createBuildImmersionTrackerStartupMainDepsHandler } from './immersion-s
test('immersion tracker startup main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildImmersionTrackerStartupMainDepsHandler({
getResolvedConfig: () => ({ immersionTracking: { enabled: true } } as never),
getResolvedConfig: () => ({ immersionTracking: { enabled: true } }) as never,
getConfiguredDbPath: () => '/tmp/immersion.db',
createTrackerService: () => {
calls.push('create');
@@ -21,9 +21,12 @@ test('immersion tracker startup main deps builder maps callbacks', () => {
assert.deepEqual(deps.getResolvedConfig(), { immersionTracking: { enabled: true } });
assert.equal(deps.getConfiguredDbPath(), '/tmp/immersion.db');
assert.deepEqual(deps.createTrackerService({ dbPath: '/tmp/immersion.db', policy: {} as never }), {
id: 'tracker',
});
assert.deepEqual(
deps.createTrackerService({ dbPath: '/tmp/immersion.db', policy: {} as never }),
{
id: 'tracker',
},
);
deps.setTracker(null);
assert.equal(deps.getMpvClient()?.connected, true);
deps.seedTrackerFromCurrentMedia();

View File

@@ -24,7 +24,7 @@ test('initial args handler no-ops without initial args', () => {
test('initial args handler ensures tray in background mode', () => {
let ensuredTray = false;
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ start: true } as never),
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => true,
ensureTray: () => {
ensuredTray = true;
@@ -44,7 +44,7 @@ test('initial args handler auto-connects mpv when needed', () => {
let connectCalls = 0;
let logged = false;
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ start: true } as never),
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
@@ -69,7 +69,7 @@ test('initial args handler auto-connects mpv when needed', () => {
test('initial args handler forwards args to cli handler', () => {
const seenSources: string[] = [];
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ start: true } as never),
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,

View File

@@ -5,7 +5,7 @@ import { createInitialArgsRuntimeHandler } from './initial-args-runtime-handler'
test('initial args runtime handler composes main deps and runs initial command flow', () => {
const calls: string[] = [];
const handleInitialArgs = createInitialArgsRuntimeHandler({
getInitialArgs: () => ({ start: true } as never),
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => true,
ensureTray: () => calls.push('tray'),
isTexthookerOnlyMode: () => false,
@@ -20,5 +20,10 @@ test('initial args runtime handler composes main deps and runs initial command f
handleInitialArgs();
assert.deepEqual(calls, ['tray', 'log:Auto-connecting MPV client for immersion tracking', 'connect', 'cli:initial']);
assert.deepEqual(calls, [
'tray',
'log:Auto-connecting MPV client for immersion tracking',
'connect',
'cli:initial',
]);
});

View File

@@ -1,6 +1,8 @@
import type { MpvCommandFromIpcRuntimeDeps } from '../ipc-mpv-command';
export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(deps: MpvCommandFromIpcRuntimeDeps) {
export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
deps: MpvCommandFromIpcRuntimeDeps,
) {
return (): MpvCommandFromIpcRuntimeDeps => ({
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),

View File

@@ -1,4 +1,7 @@
import { createHandleMpvCommandFromIpcHandler, createRunSubsyncManualFromIpcHandler } from './ipc-bridge-actions';
import {
createHandleMpvCommandFromIpcHandler,
createRunSubsyncManualFromIpcHandler,
} from './ipc-bridge-actions';
import {
createBuildHandleMpvCommandFromIpcMainDepsHandler,
createBuildRunSubsyncManualFromIpcMainDepsHandler,
@@ -22,10 +25,10 @@ export function createIpcRuntimeHandlers<TRequest, TResult>(deps: {
handleMpvCommandFromIpcMainDeps,
);
const runSubsyncManualFromIpcMainDeps =
createBuildRunSubsyncManualFromIpcMainDepsHandler<TRequest, TResult>(
deps.runSubsyncManualFromIpcDeps,
)();
const runSubsyncManualFromIpcMainDeps = createBuildRunSubsyncManualFromIpcMainDepsHandler<
TRequest,
TResult
>(deps.runSubsyncManualFromIpcDeps)();
const runSubsyncManualFromIpc = createRunSubsyncManualFromIpcHandler<TRequest, TResult>(
runSubsyncManualFromIpcMainDeps,
);

View File

@@ -94,7 +94,11 @@ export function createHandleJellyfinListCommands(deps: {
if (!args.jellyfinItemId) {
throw new Error('Missing --jellyfin-item-id for --jellyfin-subtitles.');
}
const tracks = await deps.listJellyfinSubtitleTracks(session, clientInfo, args.jellyfinItemId);
const tracks = await deps.listJellyfinSubtitleTracks(
session,
clientInfo,
args.jellyfinItemId,
);
if (tracks.length === 0) {
deps.logInfo('No Jellyfin subtitle tracks found for item.');
return true;

View File

@@ -1,15 +1,7 @@
import type {
createHandleJellyfinAuthCommands,
} from './jellyfin-cli-auth';
import type {
createHandleJellyfinListCommands,
} from './jellyfin-cli-list';
import type {
createHandleJellyfinPlayCommand,
} from './jellyfin-cli-play';
import type {
createHandleJellyfinRemoteAnnounceCommand,
} from './jellyfin-cli-remote-announce';
import type { createHandleJellyfinAuthCommands } from './jellyfin-cli-auth';
import type { createHandleJellyfinListCommands } from './jellyfin-cli-list';
import type { createHandleJellyfinPlayCommand } from './jellyfin-cli-play';
import type { createHandleJellyfinRemoteAnnounceCommand } from './jellyfin-cli-remote-announce';
type HandleJellyfinAuthCommandsMainDeps = Parameters<typeof createHandleJellyfinAuthCommands>[0];
type HandleJellyfinListCommandsMainDeps = Parameters<typeof createHandleJellyfinListCommands>[0];

View File

@@ -3,7 +3,9 @@ import type {
createGetResolvedJellyfinConfigHandler,
} from './jellyfin-client-info';
type GetResolvedJellyfinConfigMainDeps = Parameters<typeof createGetResolvedJellyfinConfigHandler>[0];
type GetResolvedJellyfinConfigMainDeps = Parameters<
typeof createGetResolvedJellyfinConfigHandler
>[0];
type GetJellyfinClientInfoMainDeps = Parameters<typeof createGetJellyfinClientInfoHandler>[0];
export function createBuildGetResolvedJellyfinConfigMainDepsHandler(

View File

@@ -8,7 +8,7 @@ import {
test('get resolved jellyfin config returns jellyfin section from resolved config', () => {
const jellyfin = { url: 'https://jellyfin.local' } as never;
const getConfig = createGetResolvedJellyfinConfigHandler({
getResolvedConfig: () => ({ jellyfin } as never),
getResolvedConfig: () => ({ jellyfin }) as never,
loadStoredSession: () => null,
getEnv: () => undefined,
});
@@ -68,8 +68,7 @@ test('get resolved jellyfin config uses stored user id when env token set withou
},
}) as never,
loadStoredSession: () => ({ accessToken: 'stored-token', userId: 'stored-user' }),
getEnv: (key: string) =>
key === 'SUBMINER_JELLYFIN_ACCESS_TOKEN' ? 'env-token' : undefined,
getEnv: (key: string) => (key === 'SUBMINER_JELLYFIN_ACCESS_TOKEN' ? 'env-token' : undefined),
});
assert.deepEqual(getConfig(), {
@@ -81,7 +80,7 @@ test('get resolved jellyfin config uses stored user id when env token set withou
test('jellyfin client info resolves defaults when fields are missing', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () => ({ clientName: '', clientVersion: '', deviceId: '' } as never),
getResolvedJellyfinConfig: () => ({ clientName: '', clientVersion: '', deviceId: '' }) as never,
getDefaultJellyfinConfig: () =>
({
clientName: 'SubMiner',

View File

@@ -24,9 +24,6 @@ function createArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleOverlay: false,
hideOverlay: false,
showOverlay: false,
toggleInvisibleOverlay: false,
hideInvisibleOverlay: false,
showInvisibleOverlay: false,
copyCurrentSubtitle: false,
multiCopy: false,
mineSentence: false,

View File

@@ -2,7 +2,9 @@ import type { createPlayJellyfinItemInMpvHandler } from './jellyfin-playback-lau
type PlayJellyfinItemInMpvMainDeps = Parameters<typeof createPlayJellyfinItemInMpvHandler>[0];
export function createBuildPlayJellyfinItemInMpvMainDepsHandler(deps: PlayJellyfinItemInMpvMainDeps) {
export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
deps: PlayJellyfinItemInMpvMainDeps,
) {
return (): PlayJellyfinItemInMpvMainDeps => ({
ensureMpvConnectedForPlayback: () => deps.ensureMpvConnectedForPlayback(),
getMpvClient: () => deps.getMpvClient(),

View File

@@ -136,6 +136,8 @@ test('createHandleJellyfinRemoteGeneralCommand mutates active playback indices',
assert.equal(playback.subtitleStreamIndex, null);
assert.ok(calls.includes('progress:true'));
assert.ok(
calls.some((entry) => entry.includes('Ignoring unsupported Jellyfin GeneralCommand: UnsupportedCommand')),
calls.some((entry) =>
entry.includes('Ignoring unsupported Jellyfin GeneralCommand: UnsupportedCommand'),
),
);
});

View File

@@ -44,7 +44,9 @@ export type JellyfinRemoteProgressReporterDeps = {
logDebug: (message: string, error: unknown) => void;
};
export function createReportJellyfinRemoteProgressHandler(deps: JellyfinRemoteProgressReporterDeps) {
export function createReportJellyfinRemoteProgressHandler(
deps: JellyfinRemoteProgressReporterDeps,
) {
return async (force = false): Promise<void> => {
const playback = deps.getActivePlayback();
if (!playback) return;

View File

@@ -3,8 +3,12 @@ import type {
createStopJellyfinRemoteSessionHandler,
} from './jellyfin-remote-session-lifecycle';
type StartJellyfinRemoteSessionMainDeps = Parameters<typeof createStartJellyfinRemoteSessionHandler>[0];
type StopJellyfinRemoteSessionMainDeps = Parameters<typeof createStopJellyfinRemoteSessionHandler>[0];
type StartJellyfinRemoteSessionMainDeps = Parameters<
typeof createStartJellyfinRemoteSessionHandler
>[0];
type StopJellyfinRemoteSessionMainDeps = Parameters<
typeof createStopJellyfinRemoteSessionHandler
>[0];
export function createBuildStartJellyfinRemoteSessionMainDepsHandler(
deps: StartJellyfinRemoteSessionMainDeps,

View File

@@ -16,7 +16,11 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
accessToken: 'token',
userId: 'uid',
}),
getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'dev' }),
getJellyfinClientInfo: () => ({
clientName: 'SubMiner',
clientVersion: '1.0',
deviceId: 'dev',
}),
saveStoredSession: () => calls.push('save'),
patchJellyfinConfig: () => calls.push('patch'),
logInfo: (message) => calls.push(`info:${message}`),
@@ -38,12 +42,15 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
username: 'u',
password: 'p',
});
assert.deepEqual(await deps.authenticateWithPassword('s', 'u', 'p', deps.getJellyfinClientInfo()), {
serverUrl: 'http://127.0.0.1:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
});
assert.deepEqual(
await deps.authenticateWithPassword('s', 'u', 'p', deps.getJellyfinClientInfo()),
{
serverUrl: 'http://127.0.0.1:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
},
);
deps.saveStoredSession({ accessToken: 'token', userId: 'uid' });
deps.patchJellyfinConfig({
serverUrl: 'http://127.0.0.1:8096',
@@ -57,5 +64,13 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
deps.clearSetupWindow();
deps.setSetupWindow({} as never);
assert.equal(deps.encodeURIComponent('a b'), 'a%20b');
assert.deepEqual(calls, ['save', 'patch', 'info:ok', 'error:bad', 'osd:toast', 'clear', 'set-window']);
assert.deepEqual(calls, [
'save',
'patch',
'info:ok',
'error:bad',
'osd:toast',
'clear',
'set-window',
]);
});

View File

@@ -50,7 +50,11 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
accessToken: 'token',
userId: 'uid',
}),
getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }),
getJellyfinClientInfo: () => ({
clientName: 'SubMiner',
clientVersion: '1.0',
deviceId: 'did',
}),
saveStoredSession: (session) => {
savedSession = session;
calls.push('save');
@@ -86,7 +90,11 @@ test('createHandleJellyfinSetupSubmissionHandler reports failure to OSD', async
authenticateWithPassword: async () => {
throw new Error('bad credentials');
},
getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }),
getJellyfinClientInfo: () => ({
clientName: 'SubMiner',
clientVersion: '1.0',
deviceId: 'did',
}),
saveStoredSession: () => calls.push('save'),
patchJellyfinConfig: () => calls.push('patch'),
logInfo: () => calls.push('info'),
@@ -180,7 +188,11 @@ test('createOpenJellyfinSetupWindowHandler no-ops when existing setup window is
authenticateWithPassword: async () => {
throw new Error('should not auth');
},
getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }),
getJellyfinClientInfo: () => ({
clientName: 'SubMiner',
clientVersion: '1.0',
deviceId: 'did',
}),
saveStoredSession: () => {},
patchJellyfinConfig: () => {},
logInfo: () => {},
@@ -196,14 +208,18 @@ test('createOpenJellyfinSetupWindowHandler no-ops when existing setup window is
});
test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window lifecycle', async () => {
let willNavigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null;
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) => {
on: (
event: 'will-navigate',
handler: (event: { preventDefault: () => void }, url: string) => void,
) => {
if (event === 'will-navigate') {
willNavigateHandler = handler;
}
@@ -233,7 +249,11 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
accessToken: 'token',
userId: 'uid',
}),
getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }),
getJellyfinClientInfo: () => ({
clientName: 'SubMiner',
clientVersion: '1.0',
deviceId: 'did',
}),
saveStoredSession: () => calls.push('save'),
patchJellyfinConfig: () => calls.push('patch'),
logInfo: () => calls.push('info'),
@@ -249,7 +269,9 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
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;
const navHandler = willNavigateHandler as
| ((event: { preventDefault: () => void }, url: string) => void)
| null;
if (!navHandler) {
throw new Error('missing will-navigate handler');
}

View File

@@ -109,7 +109,9 @@ export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
}
export function createHandleJellyfinSetupSubmissionHandler(deps: {
parseSubmissionUrl: (rawUrl: string) => { server: string; username: string; password: string } | null;
parseSubmissionUrl: (
rawUrl: string,
) => { server: string; username: string; password: string } | null;
authenticateWithPassword: (
server: string,
username: string,
@@ -179,20 +181,22 @@ export function createHandleJellyfinSetupWindowClosedHandler(deps: {
};
}
export function createHandleJellyfinSetupWindowOpenedHandler(deps: {
setSetupWindow: () => void;
}) {
export function createHandleJellyfinSetupWindowOpenedHandler(deps: { setSetupWindow: () => void }) {
return (): void => {
deps.setSetupWindow();
};
}
export function createOpenJellyfinSetupWindowHandler<TWindow extends JellyfinSetupWindowLike>(deps: {
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;
parseSubmissionUrl: (
rawUrl: string,
) => { server: string; username: string; password: string } | null;
authenticateWithPassword: (
server: string,
username: string,
@@ -258,9 +262,7 @@ export function createOpenJellyfinSetupWindowHandler<TWindow extends JellyfinSet
},
});
});
void setupWindow.loadURL(
`data:text/html;charset=utf-8,${deps.encodeURIComponent(formHtml)}`,
);
void setupWindow.loadURL(`data:text/html;charset=utf-8,${deps.encodeURIComponent(formHtml)}`);
setupWindow.on('closed', () => {
handleWindowClosed();
});

View File

@@ -117,13 +117,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
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 || '',
]);
deps.sendMpvCommand(['sub-add', track.deliveryUrl, 'cached', label, track.language || '']);
}
await deps.wait(250);

View File

@@ -39,27 +39,28 @@ export function createCopyCurrentSubtitleHandler<TSubtitleTimingTracker>(deps: {
};
}
export function createHandleMineSentenceDigitHandler<TSubtitleTimingTracker, TAnkiIntegration>(
deps: {
getSubtitleTimingTracker: () => TSubtitleTimingTracker;
getAnkiIntegration: () => TAnkiIntegration;
getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void;
onCardsMined: (count: number) => void;
handleMineSentenceDigitCore: (
count: number,
options: {
subtitleTimingTracker: TSubtitleTimingTracker;
ankiIntegration: TAnkiIntegration;
getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void;
onCardsMined: (count: number) => void;
},
) => void;
},
) {
export function createHandleMineSentenceDigitHandler<
TSubtitleTimingTracker,
TAnkiIntegration,
>(deps: {
getSubtitleTimingTracker: () => TSubtitleTimingTracker;
getAnkiIntegration: () => TAnkiIntegration;
getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void;
onCardsMined: (count: number) => void;
handleMineSentenceDigitCore: (
count: number,
options: {
subtitleTimingTracker: TSubtitleTimingTracker;
ankiIntegration: TAnkiIntegration;
getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void;
onCardsMined: (count: number) => void;
},
) => void;
}) {
return (count: number): void => {
deps.handleMineSentenceDigitCore(count, {
subtitleTimingTracker: deps.getSubtitleTimingTracker(),

View File

@@ -11,6 +11,7 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
const handler = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
hasInitialJellyfinPlayArg: () => true,
isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => true,
@@ -26,6 +27,27 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
assert.deepEqual(calls, ['presence-refresh', 'report-stop', 'schedule', 'quit']);
});
test('mpv connection handler syncs overlay subtitle suppression on connect', () => {
const calls: string[] = [];
const handler = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
hasInitialJellyfinPlayArg: () => true,
isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => true,
scheduleQuitCheck: () => {
calls.push('schedule');
},
isMpvConnected: () => false,
quitApp: () => calls.push('quit'),
});
handler({ connected: true });
assert.deepEqual(calls, ['presence-refresh', 'sync-overlay-mpv-sub']);
});
test('mpv subtitle timing handler ignores blank subtitle lines', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleTimingHandler({

View File

@@ -18,6 +18,7 @@ type MpvEventClient = {
export function createHandleMpvConnectionChangeHandler(deps: {
reportJellyfinRemoteStopped: () => void;
refreshDiscordPresence: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean;
@@ -27,7 +28,10 @@ export function createHandleMpvConnectionChangeHandler(deps: {
}) {
return ({ connected }: { connected: boolean }): void => {
deps.refreshDiscordPresence();
if (connected) return;
if (connected) {
deps.syncOverlayMpvSubtitleSuppression();
return;
}
deps.reportJellyfinRemoteStopped();
if (!deps.hasInitialJellyfinPlayArg()) return;
if (deps.isOverlayRuntimeInitialized()) return;

View File

@@ -7,7 +7,10 @@ test('mpv runtime service main deps builder maps state and callbacks', () => {
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
class FakeClient {
constructor(public socketPath: string, public options: unknown) {}
constructor(
public socketPath: string,
public options: unknown,
) {}
}
const build = createBuildMpvClientRuntimeServiceFactoryDepsHandler({
@@ -16,7 +19,6 @@ test('mpv runtime service main deps builder maps state and callbacks', () => {
getResolvedConfig: () => ({ mode: 'test' }),
isAutoStartOverlayEnabled: () => true,
setOverlayVisible: (visible) => calls.push(`overlay:${visible}`),
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => reconnectTimer,
setReconnectTimer: (timer) => {
@@ -29,7 +31,6 @@ test('mpv runtime service main deps builder maps state and callbacks', () => {
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' });

View File

@@ -8,7 +8,6 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
getResolvedConfig: () => TResolvedConfig;
isAutoStartOverlayEnabled: () => boolean;
setOverlayVisible: (visible: boolean) => void;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
@@ -21,11 +20,10 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
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),
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) =>
deps.setReconnectTimer(timer),
},
bindEventHandlers: (client: TClient) => deps.bindEventHandlers(client),
});

View File

@@ -23,7 +23,6 @@ test('mpv runtime service factory constructs client, binds handlers, and connect
getResolvedConfig: () => ({}),
autoStartOverlay: true,
setOverlayVisible: () => {},
shouldBindVisibleOverlayToMpvSubVisibility: () => false,
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},

View File

@@ -4,7 +4,6 @@ export type MpvClientRuntimeServiceOptions = {
getResolvedConfig: () => Config;
autoStartOverlay: boolean;
setOverlayVisible: (visible: boolean) => void;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;

View File

@@ -1,161 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PartOfSpeech, type SubtitleData } from '../../types';
import {
HOVER_TOKEN_MESSAGE,
HOVER_SCRIPT_NAME,
buildHoveredTokenMessageCommand,
buildHoveredTokenPayload,
createApplyHoveredTokenOverlayHandler,
} from './mpv-hover-highlight';
const SUBTITLE: SubtitleData = {
text: '昨日は雨だった。',
tokens: [
{
surface: '昨日',
reading: 'きのう',
headword: '昨日',
startPos: 0,
endPos: 2,
partOfSpeech: PartOfSpeech.noun,
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
surface: 'は',
reading: 'は',
headword: 'は',
startPos: 2,
endPos: 3,
partOfSpeech: PartOfSpeech.particle,
isMerged: false,
isKnown: true,
isNPlusOneTarget: false,
},
{
surface: '雨',
reading: 'あめ',
headword: '雨',
startPos: 3,
endPos: 4,
partOfSpeech: PartOfSpeech.noun,
isMerged: false,
isKnown: false,
isNPlusOneTarget: true,
},
{
surface: 'だった。',
reading: 'だった。',
headword: 'だ',
startPos: 4,
endPos: 8,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
],
};
test('buildHoveredTokenPayload normalizes metadata and strips empty tokens', () => {
const payload = buildHoveredTokenPayload({
subtitle: SUBTITLE,
hoveredTokenIndex: 2,
revision: 5,
});
assert.equal(payload.revision, 5);
assert.equal(payload.subtitle, '昨日は雨だった。');
assert.equal(payload.hoveredTokenIndex, 2);
assert.equal(payload.tokens.length, 4);
assert.equal(payload.tokens[0]?.text, '昨日');
assert.equal(payload.tokens[0]?.index, 0);
assert.equal(payload.tokens[1]?.index, 1);
assert.equal(payload.colors.hover, 'C6A0F6');
});
test('buildHoveredTokenPayload normalizes hover color override', () => {
const payload = buildHoveredTokenPayload({
subtitle: SUBTITLE,
hoveredTokenIndex: 1,
revision: 7,
hoverColor: '#c6a0f6',
});
assert.equal(payload.colors.hover, 'C6A0F6');
});
test('buildHoveredTokenMessageCommand sends script-message-to subminer payload', () => {
const payload = buildHoveredTokenPayload({
subtitle: SUBTITLE,
hoveredTokenIndex: 0,
revision: 1,
});
const command = buildHoveredTokenMessageCommand(payload);
assert.equal(command[0], 'script-message-to');
assert.equal(command[1], HOVER_SCRIPT_NAME);
assert.equal(command[2], HOVER_TOKEN_MESSAGE);
const raw = command[3] as string;
const parsed = JSON.parse(raw);
assert.equal(parsed.revision, 1);
assert.equal(parsed.hoveredTokenIndex, 0);
assert.equal(parsed.subtitle, '昨日は雨だった。');
assert.equal(parsed.tokens.length, 4);
});
test('createApplyHoveredTokenOverlayHandler sends clear payload when hovered token is missing', () => {
const commands: Array<(string | number)[]> = [];
const apply = createApplyHoveredTokenOverlayHandler({
getMpvClient: () => ({
connected: true,
send: ({ command }: { command: (string | number)[] }) => {
commands.push(command);
return true;
},
}),
getCurrentSubtitleData: () => SUBTITLE,
getHoveredTokenIndex: () => null,
getHoveredSubtitleRevision: () => 3,
getHoverTokenColor: () => null,
});
apply();
const parsed = JSON.parse(commands[0]?.[3] as string);
assert.equal(parsed.hoveredTokenIndex, null);
assert.equal(parsed.subtitle, null);
assert.equal(parsed.tokens.length, 0);
});
test('createApplyHoveredTokenOverlayHandler sends highlight payload when hover is active', () => {
const commands: Array<(string | number)[]> = [];
const apply = createApplyHoveredTokenOverlayHandler({
getMpvClient: () => ({
connected: true,
send: ({ command }: { command: (string | number)[] }) => {
commands.push(command);
return true;
},
}),
getCurrentSubtitleData: () => SUBTITLE,
getHoveredTokenIndex: () => 0,
getHoveredSubtitleRevision: () => 3,
getHoverTokenColor: () => '#c6a0f6',
});
apply();
const parsed = JSON.parse(commands[0]?.[3] as string);
assert.equal(parsed.hoveredTokenIndex, 0);
assert.equal(parsed.subtitle, '昨日は雨だった。');
assert.equal(parsed.tokens.length, 4);
assert.equal(parsed.colors.hover, 'C6A0F6');
assert.equal(commands[0]?.[0], 'script-message-to');
assert.equal(commands[0]?.[1], HOVER_SCRIPT_NAME);
});

View File

@@ -1,138 +0,0 @@
import type { SubtitleData } from '../../types';
export const HOVER_SCRIPT_NAME = 'subminer';
export const HOVER_TOKEN_MESSAGE = 'subminer-hover-token';
const DEFAULT_HOVER_TOKEN_COLOR = 'C6A0F6';
const DEFAULT_TOKEN_COLOR = 'FFFFFF';
export type HoverPayloadToken = {
text: string;
index: number;
startPos: number | null;
endPos: number | null;
};
export type HoverTokenPayload = {
revision: number;
subtitle: string | null;
hoveredTokenIndex: number | null;
tokens: HoverPayloadToken[];
colors: {
base: string;
hover: string;
};
};
type HoverTokenInput = {
subtitle: SubtitleData | null;
hoveredTokenIndex: number | null;
revision: number;
hoverColor?: string | null;
};
function normalizeHexColor(color: string | null | undefined, fallback: string): string {
if (typeof color !== 'string') {
return fallback;
}
const normalized = color.trim().replace(/^#/, '').toUpperCase();
return /^[0-9A-F]{6}$/.test(normalized) ? normalized : fallback;
}
function sanitizeSubtitleText(text: string): string {
return text
.replace(/\\N/g, '\n')
.replace(/\\n/g, '\n')
.replace(/\{[^}]*\}/g, '')
.trim();
}
function sanitizeTokenSurface(surface: unknown): string {
return typeof surface === 'string' ? surface : '';
}
function hasHoveredToken(subtitle: SubtitleData | null, hoveredTokenIndex: number | null): boolean {
if (!subtitle || hoveredTokenIndex === null || hoveredTokenIndex < 0) {
return false;
}
return subtitle.tokens?.some((token, index) => index === hoveredTokenIndex) ?? false;
}
export function buildHoveredTokenPayload(input: HoverTokenInput): HoverTokenPayload {
const { subtitle, hoveredTokenIndex, revision, hoverColor } = input;
const tokens: HoverPayloadToken[] = [];
if (subtitle?.tokens && subtitle.tokens.length > 0) {
for (let tokenIndex = 0; tokenIndex < subtitle.tokens.length; tokenIndex += 1) {
const token = subtitle.tokens[tokenIndex];
if (!token) {
continue;
}
const surface = sanitizeTokenSurface(token?.surface);
if (!surface || surface.trim().length === 0) {
continue;
}
tokens.push({
text: surface,
index: tokenIndex,
startPos: Number.isFinite(token.startPos) ? token.startPos : null,
endPos: Number.isFinite(token.endPos) ? token.endPos : null,
});
}
}
return {
revision,
subtitle: subtitle ? sanitizeSubtitleText(subtitle.text) : null,
hoveredTokenIndex:
hoveredTokenIndex !== null && hoveredTokenIndex >= 0 ? hoveredTokenIndex : null,
tokens,
colors: {
base: DEFAULT_TOKEN_COLOR,
hover: normalizeHexColor(hoverColor, DEFAULT_HOVER_TOKEN_COLOR),
},
};
}
export function buildHoveredTokenMessageCommand(payload: HoverTokenPayload): (string | number)[] {
return [
'script-message-to',
HOVER_SCRIPT_NAME,
HOVER_TOKEN_MESSAGE,
JSON.stringify(payload),
];
}
export function createApplyHoveredTokenOverlayHandler(deps: {
getMpvClient: () => {
connected: boolean;
send: (payload: { command: (string | number)[] }) => boolean;
} | null;
getCurrentSubtitleData: () => SubtitleData | null;
getHoveredTokenIndex: () => number | null;
getHoveredSubtitleRevision: () => number;
getHoverTokenColor: () => string | null;
}) {
return (): void => {
const mpvClient = deps.getMpvClient();
if (!mpvClient || !mpvClient.connected) {
return;
}
const subtitle = deps.getCurrentSubtitleData();
const hoveredTokenIndex = deps.getHoveredTokenIndex();
const revision = deps.getHoveredSubtitleRevision();
const hoverColor = deps.getHoverTokenColor();
const payload = buildHoveredTokenPayload({
subtitle: subtitle && hasHoveredToken(subtitle, hoveredTokenIndex) ? subtitle : null,
hoveredTokenIndex: hoveredTokenIndex,
revision,
hoverColor,
});
mpvClient.send({ command: buildHoveredTokenMessageCommand(payload) });
};
}

View File

@@ -15,9 +15,7 @@ export function createBuildApplyJellyfinMpvDefaultsMainDepsHandler(
});
}
export function createBuildGetDefaultSocketPathMainDepsHandler(
deps: GetDefaultSocketPathMainDeps,
) {
export function createBuildGetDefaultSocketPathMainDepsHandler(deps: GetDefaultSocketPathMainDeps) {
return (): GetDefaultSocketPathMainDeps => ({
platform: deps.platform,
});

View File

@@ -51,6 +51,7 @@ test('media path change handler reports stop for empty path and probes media key
const handler = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
getCurrentAnilistMediaKey: () => 'show:1',
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
@@ -63,6 +64,7 @@ test('media path change handler reports stop for empty path and probes media key
assert.deepEqual(calls, [
'path:',
'stopped',
'restore-mpv-sub',
'reset:show:1',
'probe:show:1',
'guess:show:1',

View File

@@ -33,6 +33,7 @@ export function createHandleMpvSecondarySubtitleChangeHandler(deps: {
export function createHandleMpvMediaPathChangeHandler(deps: {
updateCurrentMediaPath: (path: string) => void;
reportJellyfinRemoteStopped: () => void;
restoreMpvSubVisibility: () => void;
getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void;
@@ -44,6 +45,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
deps.updateCurrentMediaPath(path);
if (!path) {
deps.reportJellyfinRemoteStopped();
deps.restoreMpvSubVisibility();
}
const mediaKey = deps.getCurrentAnilistMediaKey();
deps.resetAnilistMediaTracking(mediaKey);

View File

@@ -8,6 +8,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
hasInitialJellyfinPlayArg: () => false,
isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => false,
@@ -35,6 +36,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
getCurrentAnilistMediaKey: () => 'media-key',
resetAnilistMediaTracking: (key) => calls.push(`reset-media:${String(key)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
@@ -62,6 +64,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
});
handlers.get('subtitle-change')?.({ text: 'line' });
handlers.get('media-path-change')?.({ path: '' });
handlers.get('media-title-change')?.({ title: 'Episode 1' });
handlers.get('time-pos-change')?.({ time: 2.5 });
handlers.get('pause-change')?.({ paused: true });
@@ -70,6 +73,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
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('restore-mpv-sub'));
assert.ok(calls.includes('reset-guess-state'));
assert.ok(calls.includes('notify-title:Episode 1'));
assert.ok(calls.includes('progress:normal'));

View File

@@ -19,6 +19,7 @@ type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandl
export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean;
@@ -42,6 +43,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
broadcastSecondarySubtitle: (text: string) => void;
updateCurrentMediaPath: (path: string) => void;
restoreMpvSubVisibility: () => void;
getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void;
@@ -63,6 +65,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(),
@@ -94,6 +97,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
const handleMpvMediaPathChange = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path),
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey),
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),

View File

@@ -32,6 +32,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
},
quitApp: () => calls.push('quit'),
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
maybeRunAnilistPostWatchUpdate: async () => {
calls.push('anilist-post-watch');
},
@@ -39,7 +40,9 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${String(payload)}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
getCurrentAnilistMediaKey: () => 'media-key',
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
@@ -59,6 +62,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.scheduleQuitCheck(() => calls.push('scheduled-callback'));
deps.quitApp();
deps.reportJellyfinRemoteStopped();
deps.syncOverlayMpvSubtitleSuppression();
deps.recordImmersionSubtitleLine('x', 0, 1);
assert.equal(deps.hasSubtitleTimingTracker(), true);
deps.recordSubtitleTiming('y', 0, 1);
@@ -72,6 +76,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.broadcastSubtitleAss('ass');
deps.broadcastSecondarySubtitle('sec');
deps.updateCurrentMediaPath('/tmp/video');
deps.restoreMpvSubVisibility();
assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key');
deps.resetAnilistMediaTracking('media-key');
deps.maybeProbeAnilistDuration('media-key');
@@ -91,8 +96,11 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.equal(appState.playbackPaused, true);
assert.equal(appState.previousSecondarySubVisibility, true);
assert.ok(calls.includes('remote-stopped'));
assert.ok(calls.includes('sync-overlay-mpv-sub'));
assert.ok(calls.includes('anilist-post-watch'));
assert.ok(calls.includes('ensure-immersion'));
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub'));
});

View File

@@ -21,11 +21,13 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
scheduleQuitCheck: (callback: () => void) => void;
quitApp: () => void;
reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
logSubtitleTimingError: (message: string, error: unknown) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
onSubtitleChange: (text: string) => void;
updateCurrentMediaPath: (path: string) => void;
restoreMpvSubVisibility: () => void;
getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void;
@@ -36,17 +38,21 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
refreshDiscordPresence: () => void;
ensureImmersionTrackerInitialized: () => void;
}) {
return () => ({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
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),
recordImmersionSubtitleLine: (text: string, start: number, end: number) => {
deps.ensureImmersionTrackerInitialized();
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),
@@ -68,6 +74,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
broadcastSecondarySubtitle: (text: string) =>
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey: string | null) =>
deps.resetAnilistMediaTracking(mediaKey),
@@ -76,14 +83,19 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
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),
notifyImmersionTitleUpdate: (title: string) => {
deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.handleMediaTitleUpdate?.(title);
},
recordPlaybackPosition: (time: number) => {
deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordPlaybackPosition?.(time);
},
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
recordPauseState: (paused: boolean) => {
deps.appState.playbackPaused = paused;
deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordPauseState?.(paused);
},
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>

View File

@@ -1,6 +1,8 @@
import type { createUpdateMpvSubtitleRenderMetricsHandler } from './mpv-subtitle-render-metrics';
type UpdateMpvSubtitleRenderMetricsMainDeps = Parameters<typeof createUpdateMpvSubtitleRenderMetricsHandler>[0];
type UpdateMpvSubtitleRenderMetricsMainDeps = Parameters<
typeof createUpdateMpvSubtitleRenderMetricsHandler
>[0];
export function createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler(
deps: UpdateMpvSubtitleRenderMetricsMainDeps,

View File

@@ -22,7 +22,10 @@ test('numeric shortcut runtime main deps builder maps callbacks', () => {
},
})();
assert.equal(deps.globalShortcut.register('1', () => {}), true);
assert.equal(
deps.globalShortcut.register('1', () => {}),
true,
);
deps.globalShortcut.unregister('1');
deps.showMpvOsd('x');
deps.setTimer(() => calls.push('tick'), 1000);

View File

@@ -1,6 +1,8 @@
import type { NumericShortcutRuntimeOptions } from '../../core/services/numeric-shortcut';
export function createBuildNumericShortcutRuntimeMainDepsHandler(deps: NumericShortcutRuntimeOptions) {
export function createBuildNumericShortcutRuntimeMainDepsHandler(
deps: NumericShortcutRuntimeOptions,
) {
return (): NumericShortcutRuntimeOptions => ({
globalShortcut: deps.globalShortcut,
showMpvOsd: (text: string) => deps.showMpvOsd(text),

View File

@@ -3,8 +3,12 @@ import type {
createStartNumericShortcutSessionHandler,
} from './numeric-shortcut-session-handlers';
type CancelNumericShortcutSessionMainDeps = Parameters<typeof createCancelNumericShortcutSessionHandler>[0];
type StartNumericShortcutSessionMainDeps = Parameters<typeof createStartNumericShortcutSessionHandler>[0];
type CancelNumericShortcutSessionMainDeps = Parameters<
typeof createCancelNumericShortcutSessionHandler
>[0];
type StartNumericShortcutSessionMainDeps = Parameters<
typeof createStartNumericShortcutSessionHandler
>[0];
export function createBuildCancelNumericShortcutSessionMainDepsHandler(
deps: CancelNumericShortcutSessionMainDeps,

View File

@@ -20,8 +20,9 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: {
const cancelPendingMultiCopyMainDeps = createBuildCancelNumericShortcutSessionMainDepsHandler({
session: deps.multiCopySession,
})();
const cancelPendingMultiCopyHandler =
createCancelNumericShortcutSessionHandler(cancelPendingMultiCopyMainDeps);
const cancelPendingMultiCopyHandler = createCancelNumericShortcutSessionHandler(
cancelPendingMultiCopyMainDeps,
);
const startPendingMultiCopyMainDeps = createBuildStartNumericShortcutSessionMainDepsHandler({
session: deps.multiCopySession,
@@ -32,8 +33,9 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: {
cancelled: 'Cancelled',
},
})();
const startPendingMultiCopyHandler =
createStartNumericShortcutSessionHandler(startPendingMultiCopyMainDeps);
const startPendingMultiCopyHandler = createStartNumericShortcutSessionHandler(
startPendingMultiCopyMainDeps,
);
const cancelPendingMineSentenceMultipleMainDeps =
createBuildCancelNumericShortcutSessionMainDepsHandler({

View File

@@ -19,12 +19,10 @@ test('overlay content measurement store main deps builder maps callbacks', () =>
test('overlay modal runtime main deps builder maps window resolvers', () => {
const mainWindow = { id: 'main' };
const invisibleWindow = { id: 'invisible' };
const modalWindow = { id: 'modal' };
const calls: string[] = [];
const deps = createBuildOverlayModalRuntimeMainDepsHandler({
getMainWindow: () => mainWindow as never,
getInvisibleWindow: () => invisibleWindow as never,
getModalWindow: () => modalWindow as never,
createModalWindow: () => modalWindow as never,
getModalGeometry: () => ({ x: 1, y: 2, width: 3, height: 4 }),
@@ -33,7 +31,6 @@ test('overlay modal runtime main deps builder maps window resolvers', () => {
})();
assert.equal(deps.getMainWindow(), mainWindow);
assert.equal(deps.getInvisibleWindow(), invisibleWindow);
assert.equal(deps.getModalWindow(), modalWindow);
assert.equal(deps.createModalWindow(), modalWindow);
assert.deepEqual(deps.getModalGeometry(), { x: 1, y: 2, width: 3, height: 4 });

View File

@@ -14,12 +14,9 @@ export function createBuildOverlayContentMeasurementStoreMainDepsHandler(
});
}
export function createBuildOverlayModalRuntimeMainDepsHandler(
deps: OverlayWindowResolver,
) {
export function createBuildOverlayModalRuntimeMainDepsHandler(deps: OverlayWindowResolver) {
return (): OverlayWindowResolver => ({
getMainWindow: () => deps.getMainWindow(),
getInvisibleWindow: () => deps.getInvisibleWindow(),
getModalWindow: () => deps.getModalWindow(),
createModalWindow: () => deps.createModalWindow(),
getModalGeometry: () => deps.getModalGeometry(),

View File

@@ -35,12 +35,15 @@ test('overlay main action main deps builders map callbacks', () => {
showMpvOsd: (text) => calls.push(`osd:${text}`),
sendMpvCommand: (command) => calls.push(`cmd:${command.join(':')}`),
})();
assert.deepEqual(append.appendClipboardVideoToQueueRuntime({
getMpvClient: () => ({ connected: true }),
readClipboardText: () => '/tmp/v.mkv',
showMpvOsd: () => {},
sendMpvCommand: () => {},
}), { ok: true, message: 'ok' });
assert.deepEqual(
append.appendClipboardVideoToQueueRuntime({
getMpvClient: () => ({ connected: true }),
readClipboardText: () => '/tmp/v.mkv',
showMpvOsd: () => {},
sendMpvCommand: () => {},
}),
{ ok: true, message: 'ok' },
);
assert.equal(append.readClipboardText(), '/tmp/v.mkv');
assert.equal(typeof append.getMpvClient(), 'object');
append.showMpvOsd('queued');

View File

@@ -8,7 +8,9 @@ import type {
type SetOverlayVisibleMainDeps = Parameters<typeof createSetOverlayVisibleHandler>[0];
type ToggleOverlayMainDeps = Parameters<typeof createToggleOverlayHandler>[0];
type HandleOverlayModalClosedMainDeps = Parameters<typeof createHandleOverlayModalClosedHandler>[0];
type AppendClipboardVideoToQueueMainDeps = Parameters<typeof createAppendClipboardVideoToQueueHandler>[0];
type AppendClipboardVideoToQueueMainDeps = Parameters<
typeof createAppendClipboardVideoToQueueHandler
>[0];
export function createBuildSetOverlayVisibleMainDepsHandler(deps: SetOverlayVisibleMainDeps) {
return (): SetOverlayVisibleMainDeps => ({
@@ -34,7 +36,8 @@ export function createBuildAppendClipboardVideoToQueueMainDepsHandler(
deps: AppendClipboardVideoToQueueMainDeps,
) {
return (): AppendClipboardVideoToQueueMainDeps => ({
appendClipboardVideoToQueueRuntime: (options) => deps.appendClipboardVideoToQueueRuntime(options),
appendClipboardVideoToQueueRuntime: (options) =>
deps.appendClipboardVideoToQueueRuntime(options),
getMpvClient: () => deps.getMpvClient(),
readClipboardText: () => deps.readClipboardText(),
showMpvOsd: (text: string) => deps.showMpvOsd(text),

View File

@@ -9,9 +9,7 @@ export function createSetOverlayVisibleHandler(deps: {
};
}
export function createToggleOverlayHandler(deps: {
toggleVisibleOverlay: () => void;
}) {
export function createToggleOverlayHandler(deps: { toggleVisibleOverlay: () => void }) {
return (): void => {
deps.toggleVisibleOverlay();
};
@@ -26,9 +24,10 @@ export function createHandleOverlayModalClosedHandler(deps: {
}
export function createAppendClipboardVideoToQueueHandler(deps: {
appendClipboardVideoToQueueRuntime: (
options: AppendClipboardVideoToQueueRuntimeDeps,
) => { ok: boolean; message: string };
appendClipboardVideoToQueueRuntime: (options: AppendClipboardVideoToQueueRuntimeDeps) => {
ok: boolean;
message: string;
};
getMpvClient: () => AppendClipboardVideoToQueueRuntimeDeps['getMpvClient'] extends () => infer T
? T
: never;

View File

@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createEnsureOverlayMpvSubtitlesHiddenHandler,
createRestoreOverlayMpvSubtitlesHandler,
} from './overlay-mpv-sub-visibility';
type VisibilityState = {
savedSubVisibility: boolean | null;
revision: number;
};
test('ensure overlay mpv subtitle suppression captures previous visibility then hides subtitles', async () => {
const state: VisibilityState = {
savedSubVisibility: null,
revision: 0,
};
const calls: boolean[] = [];
const ensureHidden = createEnsureOverlayMpvSubtitlesHiddenHandler({
getMpvClient: () => ({
connected: true,
requestProperty: async (_name: string) => 'no',
}),
getSavedSubVisibility: () => state.savedSubVisibility,
setSavedSubVisibility: (visible) => {
state.savedSubVisibility = visible;
},
getRevision: () => state.revision,
setRevision: (revision) => {
state.revision = revision;
},
setMpvSubVisibility: (visible) => {
calls.push(visible);
},
logWarn: () => {},
});
await ensureHidden();
assert.equal(state.savedSubVisibility, false);
assert.equal(state.revision, 1);
assert.deepEqual(calls, [false]);
});
test('restore overlay mpv subtitle suppression restores saved visibility', () => {
const state: VisibilityState = {
savedSubVisibility: false,
revision: 4,
};
const calls: boolean[] = [];
const restore = createRestoreOverlayMpvSubtitlesHandler({
getSavedSubVisibility: () => state.savedSubVisibility,
setSavedSubVisibility: (visible) => {
state.savedSubVisibility = visible;
},
getRevision: () => state.revision,
setRevision: (revision) => {
state.revision = revision;
},
isMpvConnected: () => true,
shouldKeepSuppressedFromVisibleOverlayBinding: () => false,
setMpvSubVisibility: (visible) => {
calls.push(visible);
},
});
restore();
assert.equal(state.savedSubVisibility, null);
assert.equal(state.revision, 5);
assert.deepEqual(calls, [false]);
});
test('restore keeps mpv subtitles hidden when visible-overlay binding still requires suppression', () => {
const state: VisibilityState = {
savedSubVisibility: true,
revision: 9,
};
const calls: boolean[] = [];
const restore = createRestoreOverlayMpvSubtitlesHandler({
getSavedSubVisibility: () => state.savedSubVisibility,
setSavedSubVisibility: (visible) => {
state.savedSubVisibility = visible;
},
getRevision: () => state.revision,
setRevision: (revision) => {
state.revision = revision;
},
isMpvConnected: () => true,
shouldKeepSuppressedFromVisibleOverlayBinding: () => true,
setMpvSubVisibility: (visible) => {
calls.push(visible);
},
});
restore();
assert.equal(state.savedSubVisibility, true);
assert.equal(state.revision, 10);
assert.deepEqual(calls, [false]);
});
test('restore defers mpv subtitle restore while mpv is disconnected', () => {
const state: VisibilityState = {
savedSubVisibility: true,
revision: 2,
};
const calls: boolean[] = [];
const restore = createRestoreOverlayMpvSubtitlesHandler({
getSavedSubVisibility: () => state.savedSubVisibility,
setSavedSubVisibility: (visible) => {
state.savedSubVisibility = visible;
},
getRevision: () => state.revision,
setRevision: (revision) => {
state.revision = revision;
},
isMpvConnected: () => false,
shouldKeepSuppressedFromVisibleOverlayBinding: () => false,
setMpvSubVisibility: (visible) => {
calls.push(visible);
},
});
restore();
assert.equal(state.savedSubVisibility, true);
assert.equal(state.revision, 3);
assert.deepEqual(calls, []);
});

View File

@@ -0,0 +1,107 @@
type MpvVisibilityClient = {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
};
function parseSubVisibility(value: unknown): boolean {
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'no' || normalized === 'false' || normalized === '0') {
return false;
}
if (normalized === 'yes' || normalized === 'true' || normalized === '1') {
return true;
}
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
return true;
}
export function createEnsureOverlayMpvSubtitlesHiddenHandler(deps: {
getMpvClient: () => MpvVisibilityClient | null;
getSavedSubVisibility: () => boolean | null;
setSavedSubVisibility: (visible: boolean | null) => void;
getRevision: () => number;
setRevision: (revision: number) => void;
setMpvSubVisibility: (visible: boolean) => void;
logWarn: (message: string, error: unknown) => void;
}) {
return async (): Promise<void> => {
const revision = deps.getRevision() + 1;
deps.setRevision(revision);
const mpvClient = deps.getMpvClient();
if (!mpvClient || !mpvClient.connected) {
return;
}
const shouldCaptureSavedVisibility = deps.getSavedSubVisibility() === null;
const savedVisibilityPromise = shouldCaptureSavedVisibility
? mpvClient.requestProperty('sub-visibility')
: null;
// Hide immediately on overlay toggle; capture/restore logic is handled separately.
deps.setMpvSubVisibility(false);
if (shouldCaptureSavedVisibility && savedVisibilityPromise) {
try {
const currentSubVisibility = await savedVisibilityPromise;
if (revision !== deps.getRevision()) {
return;
}
deps.setSavedSubVisibility(parseSubVisibility(currentSubVisibility));
} catch (error) {
if (revision !== deps.getRevision()) {
return;
}
deps.logWarn(
'[overlay] Failed to capture mpv sub-visibility; falling back to visible restore',
error,
);
deps.setSavedSubVisibility(true);
}
}
};
}
export function createRestoreOverlayMpvSubtitlesHandler(deps: {
getSavedSubVisibility: () => boolean | null;
setSavedSubVisibility: (visible: boolean | null) => void;
getRevision: () => number;
setRevision: (revision: number) => void;
isMpvConnected: () => boolean;
shouldKeepSuppressedFromVisibleOverlayBinding: () => boolean;
setMpvSubVisibility: (visible: boolean) => void;
}) {
return (): void => {
deps.setRevision(deps.getRevision() + 1);
const savedVisibility = deps.getSavedSubVisibility();
if (deps.shouldKeepSuppressedFromVisibleOverlayBinding()) {
deps.setMpvSubVisibility(false);
return;
}
if (savedVisibility === null) {
return;
}
if (!deps.isMpvConnected()) {
return;
}
if (savedVisibility !== null) {
deps.setMpvSubVisibility(savedVisibility);
}
deps.setSavedSubVisibility(null);
};
}

View File

@@ -15,7 +15,6 @@ test('overlay runtime bootstrap handlers compose options builder and bootstrap h
ankiIntegration: null as unknown,
};
let initialized = false;
let invisibleOverlayVisible = false;
let warmupsStarted = 0;
const { initializeOverlayRuntime } = createOverlayRuntimeBootstrapHandlers({
@@ -23,21 +22,16 @@ test('overlay runtime bootstrap handlers compose options builder and bootstrap h
appState,
overlayManager: {
getVisibleOverlayVisible: () => true,
getInvisibleOverlayVisible: () => false,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => {},
updateInvisibleOverlayVisibility: () => {},
},
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => {},
},
getInitialInvisibleOverlayVisibility: () => false,
createMainWindow: () => {},
createInvisibleWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
updateInvisibleOverlayBounds: () => {},
getOverlayWindows: () => [],
getResolvedConfig: () => ({}),
showDesktopNotification: () => {},
@@ -52,10 +46,7 @@ test('overlay runtime bootstrap handlers compose options builder and bootstrap h
},
initializeOverlayRuntimeBootstrapDeps: {
isOverlayRuntimeInitialized: () => initialized,
initializeOverlayRuntimeCore: () => ({ invisibleOverlayVisible: true }),
setInvisibleOverlayVisible: (visible) => {
invisibleOverlayVisible = visible;
},
initializeOverlayRuntimeCore: () => {},
setOverlayRuntimeInitialized: (next) => {
initialized = next;
},
@@ -68,7 +59,6 @@ test('overlay runtime bootstrap handlers compose options builder and bootstrap h
initializeOverlayRuntime();
initializeOverlayRuntime();
assert.equal(invisibleOverlayVisible, true);
assert.equal(initialized, true);
assert.equal(warmupsStarted, 1);
});

View File

@@ -8,10 +8,8 @@ test('overlay runtime bootstrap no-ops when already initialized', () => {
isOverlayRuntimeInitialized: () => true,
initializeOverlayRuntimeCore: () => {
coreCalls += 1;
return { invisibleOverlayVisible: false };
},
buildOptions: () => ({} as never),
setInvisibleOverlayVisible: () => {},
buildOptions: () => ({}) as never,
setOverlayRuntimeInitialized: () => {},
startBackgroundWarmups: () => {},
});
@@ -27,15 +25,11 @@ test('overlay runtime bootstrap runs core init and applies post-init state', ()
isOverlayRuntimeInitialized: () => initialized,
initializeOverlayRuntimeCore: () => {
calls.push('core');
return { invisibleOverlayVisible: true };
},
buildOptions: () => {
calls.push('options');
return {} as never;
},
setInvisibleOverlayVisible: (visible) => {
calls.push(`invisible:${visible ? 'yes' : 'no'}`);
},
setOverlayRuntimeInitialized: (value) => {
initialized = value;
calls.push(`initialized:${value ? 'yes' : 'no'}`);
@@ -47,5 +41,5 @@ test('overlay runtime bootstrap runs core init and applies post-init state', ()
initialize();
assert.equal(initialized, true);
assert.deepEqual(calls, ['options', 'core', 'invisible:yes', 'initialized:yes', 'warmups']);
assert.deepEqual(calls, ['options', 'initialized:yes', 'core', 'warmups']);
});

View File

@@ -9,16 +9,11 @@ import type {
type InitializeOverlayRuntimeCore = (options: {
backendOverride: string | null;
getInitialInvisibleOverlayVisibility: () => boolean;
createMainWindow: () => void;
createInvisibleWindow: () => void;
registerGlobalShortcuts: () => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean;
isInvisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
@@ -35,21 +30,25 @@ type InitializeOverlayRuntimeCore = (options: {
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
}) => { invisibleOverlayVisible: boolean };
}) => void;
export function createInitializeOverlayRuntimeHandler(deps: {
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntimeCore: InitializeOverlayRuntimeCore;
buildOptions: () => Parameters<InitializeOverlayRuntimeCore>[0];
setInvisibleOverlayVisible: (visible: boolean) => void;
setOverlayRuntimeInitialized: (initialized: boolean) => void;
startBackgroundWarmups: () => void;
}) {
return (): void => {
if (deps.isOverlayRuntimeInitialized()) return;
const result = deps.initializeOverlayRuntimeCore(deps.buildOptions());
deps.setInvisibleOverlayVisible(result.invisibleOverlayVisible);
const options = deps.buildOptions();
deps.setOverlayRuntimeInitialized(true);
try {
deps.initializeOverlayRuntimeCore(options);
} catch (error) {
deps.setOverlayRuntimeInitialized(false);
throw error;
}
deps.startBackgroundWarmups();
};
}

View File

@@ -32,7 +32,10 @@ test('broadcast runtime options changed main deps builder maps callbacks', () =>
broadcastToOverlayWindows: (channel) => calls.push(channel),
})();
deps.broadcastRuntimeOptionsChangedRuntime(() => [], () => {});
deps.broadcastRuntimeOptionsChangedRuntime(
() => [],
() => {},
);
deps.broadcastToOverlayWindows('runtime-options:changed');
assert.deepEqual(deps.getRuntimeOptionsState(), []);
assert.deepEqual(calls, ['broadcast-runtime', 'runtime-options:changed']);
@@ -57,14 +60,12 @@ test('set overlay debug visualization main deps builder maps callbacks', () => {
setOverlayDebugVisualizationEnabledRuntime: () => calls.push('set-runtime'),
getCurrentEnabled: () => false,
setCurrentEnabled: () => calls.push('set-current'),
broadcastToOverlayWindows: () => calls.push('broadcast'),
})();
deps.setOverlayDebugVisualizationEnabledRuntime(false, true, () => {}, () => {});
deps.setOverlayDebugVisualizationEnabledRuntime(false, true, () => {});
assert.equal(deps.getCurrentEnabled(), false);
deps.setCurrentEnabled(true);
deps.broadcastToOverlayWindows('overlay:debug');
assert.deepEqual(calls, ['set-runtime', 'set-current', 'broadcast']);
assert.deepEqual(calls, ['set-runtime', 'set-current']);
});
test('open runtime options palette main deps builder maps callbacks', () => {

Some files were not shown because too many files have changed in this diff Show More