refactor(main): fold remaining main-deps builders into their handlers (#308)

* refactor(main): drop remaining pass-through main-deps builders

Jellyfin client-info/setup-window/remote, overlay shortcuts/visibility/
layout/window-factory, config hot-reload, and startup bootstrap factory
builders only forwarded deps. Callers now pass deps to the handlers
directly; createWatchConfigPathHandler moves into config-hot-reload-handlers.

* refactor(main): fold small-logic main-deps builders into their handlers

Tray click, mpv client factory, startup bootstrap exit callbacks, overlay
runtime options, and dictionary roots/runtime builders move their logic
into the owning handler or call site. The two duplicated dictionary-root
builders become one createBuildDictionaryRootsHandler(vendorDirName).

* refactor(main): fold cli command context builders into the factory

createCliCommandContextFactory now adapts appState and main services to
the CLI command context deps directly (socket-path change notification,
texthooker port/websocket URL, browser-open default). The pass-through
cli-command-context-main-deps and cli-command-context-deps layers are
removed; their behavioural tests now run against the factory's context.

* refactor(main): fold subtitle tokenization builder into the mpv composer

The tokenizer deps builder's logic (known-word lookup recording and the
character-dictionary gate on name matching) becomes
buildTokenizerRuntimeOptions in mpv-runtime-composer, tested through the
composer. The MeCab check and dictionary prewarm handlers move to
subtitle-tokenization-warmup.ts, and the unused BuiltMainDeps type is
removed from composer contracts.

* refactor(main): drop redundant tokenizer deps keys and cast

* refactor(main): end the main-deps builder pattern

mpv-main-event-main-deps becomes mpv-main-event-binding-deps
(createMpvMainEventBindingDeps), returning the binding deps directly
instead of a thunk; it owns the subtitle-recording state. The will-quit
cleanup adapter likewise returns its deps directly, and the pass-through
app-lifecycle activate builders are removed.

* docs: drop references to main-deps builders
This commit is contained in:
sudacode authored and GitHub committed 2026-10-31 14:07:07 -07:00
1 parent 2a0e931325
commit 90fc7cf940
63 files changed
+1282 -2480

No files matched your search

+224 -261
View File
@@ -176,12 +176,7 @@ import {
type ActiveJellyfinRemotePlaybackState,
} from './main/runtime/domains/jellyfin';
import {
createBuildConfigHotReloadMessageMainDepsHandler,
createBuildConfigHotReloadAppliedMainDepsHandler,
createBuildConfigHotReloadRuntimeMainDepsHandler,
createBuildWatchConfigPathMainDepsHandler,
createWatchConfigPathHandler,
createBuildOverlayVisibilityRuntimeMainDepsHandler,
createTrayRuntimeHandlers,
createOverlayVisibilityRuntime,
createGetRuntimeOptionsStateHandler,
@@ -201,10 +196,7 @@ import {
resolveSubtitleStyleForRenderer,
} from './main/runtime/domains/overlay';
import {
createBuildDictionaryRootsMainHandler,
createBuildFrequencyDictionaryRootsMainHandler,
createBuildFrequencyDictionaryRuntimeMainDepsHandler,
createBuildJlptDictionaryRuntimeMainDepsHandler,
createBuildDictionaryRootsHandler,
createImmersionMediaRuntime,
createConfigDerivedRuntime,
appendClipboardVideoToQueueRuntime,
@@ -420,7 +412,6 @@ import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
import { dispatchSessionAction as dispatchSessionActionCore } from './core/services/session-actions';
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
import { createMainRuntimeRegistry } from './main/runtime/registry';
import {
createEnsureOverlayMpvSubtitlesHiddenHandler,
@@ -2260,67 +2251,64 @@ const subtitlePrefetchRuntime = {
autoplaySubtitlePrimingRuntime.clearScheduledSubtitlePrefetchRefresh(),
} as const;
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
createBuildOverlayShortcutsRuntimeMainDepsHandler({
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getShortcutsRegistered: () => appState.shortcutsRegistered,
setShortcutsRegistered: (registered: boolean) => {
appState.shortcutsRegistered = registered;
},
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
isOverlayShortcutContextActive: () => {
if (process.platform !== 'win32') {
return true;
}
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getShortcutsRegistered: () => appState.shortcutsRegistered,
setShortcutsRegistered: (registered: boolean) => {
appState.shortcutsRegistered = registered;
},
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
isOverlayShortcutContextActive: () => {
if (process.platform !== 'win32') {
return true;
}
if (!overlayManager.getVisibleOverlayVisible()) {
return false;
}
if (!overlayManager.getVisibleOverlayVisible()) {
return false;
}
const windowTracker = appState.windowTracker;
if (!windowTracker || !windowTracker.isTracking()) {
return false;
}
const windowTracker = appState.windowTracker;
if (!windowTracker || !windowTracker.isTracking()) {
return false;
}
return windowTracker.isTargetWindowFocused();
},
showMpvOsd: (text: string) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(text),
openRuntimeOptionsPalette: () => {
openRuntimeOptionsPalette();
},
openCharacterDictionaryManager: () => {
openCharacterDictionaryManagerOverlay();
},
openJimaku: () => {
openJimakuOverlay();
},
openTsukihime: () => {
openTsukihimeOverlay();
},
markAudioCard: () => markLastCardAsAudioCardHandler(),
copySubtitleMultiple: (timeoutMs: number) => {
startPendingMultiCopy(timeoutMs);
},
copySubtitle: () => {
copyCurrentSubtitleHandler();
},
toggleSecondarySubMode: () => cycleSecondarySubMode(),
updateLastCardFromClipboard: () => updateLastCardFromClipboardHandler(),
triggerFieldGrouping: () => triggerFieldGroupingHandler(),
triggerSubsyncFromConfig: () => subsyncRuntime.triggerFromConfig(),
mineSentenceCard: () => mineSentenceCardHandler(),
mineSentenceMultiple: (timeoutMs: number) => {
startPendingMineSentenceMultiple(timeoutMs);
},
cancelPendingMultiCopy: () => {
cancelPendingMultiCopy();
},
cancelPendingMineSentenceMultiple: () => {
cancelPendingMineSentenceMultiple();
},
})(),
);
return windowTracker.isTargetWindowFocused();
},
showMpvOsd: (text: string) => overlayNotificationsRuntime.showConfiguredStatusNotification(text),
openRuntimeOptionsPalette: () => {
openRuntimeOptionsPalette();
},
openCharacterDictionaryManager: () => {
openCharacterDictionaryManagerOverlay();
},
openJimaku: () => {
openJimakuOverlay();
},
openTsukihime: () => {
openTsukihimeOverlay();
},
markAudioCard: () => markLastCardAsAudioCardHandler(),
copySubtitleMultiple: (timeoutMs: number) => {
startPendingMultiCopy(timeoutMs);
},
copySubtitle: () => {
copyCurrentSubtitleHandler();
},
toggleSecondarySubMode: () => cycleSecondarySubMode(),
updateLastCardFromClipboard: () => updateLastCardFromClipboardHandler(),
triggerFieldGrouping: () => triggerFieldGroupingHandler(),
triggerSubsyncFromConfig: () => subsyncRuntime.triggerFromConfig(),
mineSentenceCard: () => mineSentenceCardHandler(),
mineSentenceMultiple: (timeoutMs: number) => {
startPendingMineSentenceMultiple(timeoutMs);
},
cancelPendingMultiCopy: () => {
cancelPendingMultiCopy();
},
cancelPendingMineSentenceMultiple: () => {
cancelPendingMineSentenceMultiple();
},
});
syncOverlayShortcutsForModal = (isActive: boolean): void => {
if (isActive) {
overlayShortcutsRuntime.unregisterOverlayShortcuts();
@@ -2329,106 +2317,89 @@ syncOverlayShortcutsForModal = (isActive: boolean): void => {
}
};
const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler(
{
getNotificationType: () => overlayNotificationsRuntime.getConfiguredStatusNotificationType(),
showMpvOsd: (message) => showMpvOsd(message),
showOverlayNotification: (payload) =>
overlayNotificationsRuntime.showOverlayNotification(payload),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
},
);
const configHotReloadMessageMainDeps = buildConfigHotReloadMessageMainDepsHandler();
const notifyConfigHotReloadMessage = createConfigHotReloadMessageHandler(
configHotReloadMessageMainDeps,
);
const buildWatchConfigPathMainDepsHandler = createBuildWatchConfigPathMainDepsHandler({
const notifyConfigHotReloadMessage = createConfigHotReloadMessageHandler({
getNotificationType: () => overlayNotificationsRuntime.getConfiguredStatusNotificationType(),
showMpvOsd: (message) => showMpvOsd(message),
showOverlayNotification: (payload) =>
overlayNotificationsRuntime.showOverlayNotification(payload),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
});
const watchConfigPathHandler = createWatchConfigPathHandler({
fileExists: (targetPath) => fs.existsSync(targetPath),
dirname: (targetPath) => path.dirname(targetPath),
watchPath: (targetPath, listener) => fs.watch(targetPath, listener),
});
const watchConfigPathHandler = createWatchConfigPathHandler(buildWatchConfigPathMainDepsHandler());
const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadAppliedMainDepsHandler(
{
setKeybindings: (keybindings) => {
appState.keybindings = keybindings;
},
setSessionBindings: (sessionBindings, sessionBindingWarnings) => {
persistSessionBindings(sessionBindings, sessionBindingWarnings);
},
refreshGlobalAndOverlayShortcuts: () => {
refreshGlobalAndOverlayShortcuts();
},
setSecondarySubMode: (mode) => {
setSecondarySubMode(mode);
},
broadcastToOverlayWindows: (channel, payload) => {
overlayManager.broadcastToOverlayWindows(channel, payload);
},
applyAnkiRuntimeConfigPatch: (patch) => {
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
if (activeDictionaryBackend === 'hachidori' && appState.yomitanExt) {
void syncYomitanDefaultProfileAnkiServer().catch((error: unknown) =>
logger.error('Failed to auto-populate Hachidori Anki settings', error),
);
}
},
invalidateTokenizationCache: () => {
subtitleProcessingController.invalidateTokenizationCache();
},
refreshSubtitlePrefetch: () => {
subtitlePrefetchService?.onSeek(lastObservedTimePos);
},
refreshCurrentSubtitle: () => {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
},
setLogLevel: (level) => {
setLogLevel(level, 'config');
},
setLogRotation: (rotation) => {
setLogRotation(rotation);
},
setLogFileToggles: (files) => {
setLogFileToggles(files);
},
applyAniSkipConfig: () => {
aniSkipRuntime.applyConfigChange();
},
const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler({
setKeybindings: (keybindings) => {
appState.keybindings = keybindings;
},
);
const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler(
buildConfigHotReloadAppliedMainDepsHandler(),
);
const buildConfigHotReloadRuntimeMainDepsHandler = createBuildConfigHotReloadRuntimeMainDepsHandler(
{
getCurrentConfig: () => configService.getConfig(),
reloadConfigStrict: () => configService.reloadConfigStrict(),
watchConfigPath: (configPath, onChange) => watchConfigPathHandler(configPath, onChange),
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
clearTimeout: (timeout) => clearTimeout(timeout),
debounceMs: 250,
onHotReloadApplied: applyConfigHotReloadDiff,
onRestartRequired: (fields) =>
notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)),
onInvalidConfig: notifyConfigHotReloadMessage,
onValidationWarnings: (configPath, warnings) => {
showDesktopNotification('SubMiner', {
body: buildConfigWarningNotificationBody(configPath, warnings),
});
if (process.platform === 'darwin') {
dialog.showErrorBox(
'SubMiner config validation warning',
buildConfigWarningDialogDetails(configPath, warnings),
);
}
},
setSessionBindings: (sessionBindings, sessionBindingWarnings) => {
persistSessionBindings(sessionBindings, sessionBindingWarnings);
},
);
const configHotReloadRuntime = createConfigHotReloadRuntime(
buildConfigHotReloadRuntimeMainDepsHandler(),
);
refreshGlobalAndOverlayShortcuts: () => {
refreshGlobalAndOverlayShortcuts();
},
setSecondarySubMode: (mode) => {
setSecondarySubMode(mode);
},
broadcastToOverlayWindows: (channel, payload) => {
overlayManager.broadcastToOverlayWindows(channel, payload);
},
applyAnkiRuntimeConfigPatch: (patch) => {
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
if (activeDictionaryBackend === 'hachidori' && appState.yomitanExt) {
void syncYomitanDefaultProfileAnkiServer().catch((error: unknown) =>
logger.error('Failed to auto-populate Hachidori Anki settings', error),
);
}
},
invalidateTokenizationCache: () => {
subtitleProcessingController.invalidateTokenizationCache();
},
refreshSubtitlePrefetch: () => {
subtitlePrefetchService?.onSeek(lastObservedTimePos);
},
refreshCurrentSubtitle: () => {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
},
setLogLevel: (level) => {
setLogLevel(level, 'config');
},
setLogRotation: (rotation) => {
setLogRotation(rotation);
},
setLogFileToggles: (files) => {
setLogFileToggles(files);
},
applyAniSkipConfig: () => {
aniSkipRuntime.applyConfigChange();
},
});
const configHotReloadRuntime = createConfigHotReloadRuntime({
getCurrentConfig: () => configService.getConfig(),
reloadConfigStrict: () => configService.reloadConfigStrict(),
watchConfigPath: (configPath, onChange) => watchConfigPathHandler(configPath, onChange),
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
clearTimeout: (timeout) => clearTimeout(timeout),
debounceMs: 250,
onHotReloadApplied: applyConfigHotReloadDiff,
onRestartRequired: (fields) =>
notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)),
onInvalidConfig: notifyConfigHotReloadMessage,
onValidationWarnings: (configPath, warnings) => {
showDesktopNotification('SubMiner', {
body: buildConfigWarningNotificationBody(configPath, warnings),
});
if (process.platform === 'darwin') {
dialog.showErrorBox(
'SubMiner config validation warning',
buildConfigWarningDialogDetails(configPath, warnings),
);
}
},
});
async function getCurrentYomitanAnkiDeckNameForRuntime(): Promise<string> {
await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
@@ -2540,7 +2511,7 @@ const syncAutoScheduler = createSyncAutoScheduler({
});
syncAutoScheduler.start();
const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({
const dictionaryRootsDeps = {
platform: process.platform,
dirname: __dirname,
appPath: app.getAppPath(),
@@ -2550,46 +2521,41 @@ const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({
homeDir: os.homedir(),
appDataDir: process.env.APPDATA,
cwd: process.cwd(),
joinPath: (...parts) => path.join(...parts),
joinPath: (...parts: string[]) => path.join(...parts),
};
const buildJlptDictionaryRootsHandler = createBuildDictionaryRootsHandler({
...dictionaryRootsDeps,
vendorDirName: 'yomitan-jlpt-vocab',
});
const buildFrequencyDictionaryRootsHandler = createBuildFrequencyDictionaryRootsMainHandler({
platform: process.platform,
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
userDataPath: USER_DATA_PATH,
appUserDataPath: app.getPath('userData'),
homeDir: os.homedir(),
appDataDir: process.env.APPDATA,
cwd: process.cwd(),
joinPath: (...parts) => path.join(...parts),
const buildFrequencyDictionaryRootsHandler = createBuildDictionaryRootsHandler({
...dictionaryRootsDeps,
vendorDirName: 'frequency-dictionary',
});
const jlptDictionaryRuntime = createJlptDictionaryRuntimeService(
createBuildJlptDictionaryRuntimeMainDepsHandler({
isJlptEnabled: () => configService.getConfig().subtitleStyle.enableJlpt,
getDictionaryRoots: () => buildDictionaryRootsHandler(),
getJlptDictionarySearchPaths,
setJlptLevelLookup: (lookup) => {
appState.jlptLevelLookup = lookup;
},
logInfo: (message) => logger.info(message),
})(),
);
const jlptDictionaryRuntime = createJlptDictionaryRuntimeService({
isJlptEnabled: () => configService.getConfig().subtitleStyle.enableJlpt,
getSearchPaths: () =>
getJlptDictionarySearchPaths({ getDictionaryRoots: () => buildJlptDictionaryRootsHandler() }),
setJlptLevelLookup: (lookup) => {
appState.jlptLevelLookup = lookup;
},
log: (message) => logger.info(`[JLPT] ${message}`),
});
const frequencyDictionaryRuntime = createFrequencyDictionaryRuntimeService(
createBuildFrequencyDictionaryRuntimeMainDepsHandler({
isFrequencyDictionaryEnabled: () =>
configService.getConfig().subtitleStyle.frequencyDictionary.enabled,
getDictionaryRoots: () => buildFrequencyDictionaryRootsHandler(),
getFrequencyDictionarySearchPaths,
getSourcePath: () => configService.getConfig().subtitleStyle.frequencyDictionary.sourcePath,
setFrequencyRankLookup: (lookup) => {
appState.frequencyRankLookup = lookup;
},
logInfo: (message) => logger.info(message),
})(),
);
const frequencyDictionaryRuntime = createFrequencyDictionaryRuntimeService({
isFrequencyDictionaryEnabled: () =>
configService.getConfig().subtitleStyle.frequencyDictionary.enabled,
getSearchPaths: () =>
getFrequencyDictionarySearchPaths({
getDictionaryRoots: () =>
buildFrequencyDictionaryRootsHandler().filter((dictionaryRoot) => dictionaryRoot),
getSourcePath: () => configService.getConfig().subtitleStyle.frequencyDictionary.sourcePath,
}),
setFrequencyRankLookup: (lookup) => {
appState.frequencyRankLookup = lookup;
},
log: (message) => logger.info(`[Frequency] ${message}`),
});
const getFieldGroupingResolverHandler = createGetFieldGroupingResolverHandler({
getResolver: () => appState.fieldGroupingResolver,
@@ -2816,66 +2782,64 @@ const characterNameCandidateLookup = createCharacterNameCandidateLookup({
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
});
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
createBuildOverlayVisibilityRuntimeMainDepsHandler({
getMainWindow: () => overlayManager.getMainWindow(),
getModalActive: () => overlayModalInputState.getModalInputExclusive(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getForceMousePassthrough: () => appState.statsOverlayVisible,
getNonNativeInputRegionActive: () =>
process.platform === 'linux' &&
visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(),
getSuspendVisibleOverlay: () => appState.statsOverlayVisible,
getOverlayInteractionActive: () =>
visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive(),
getWindowTracker: () => appState.windowTracker,
getMacOSForegroundProbeActive: () =>
visibleOverlayInteractionRuntime.getMacOSVisibleOverlayForegroundProbeActive(),
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown: boolean) => {
appState.trackerNotReadyWarningShown = shown;
},
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
overlayGeometryRuntime.updateVisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window) => {
overlayGeometryRuntime.ensureOverlayWindowLevel(window);
},
syncWindowsOverlayToMpvZOrder: (_window) => {
visibleOverlayInteractionRuntime.requestWindowsVisibleOverlayZOrderSync();
},
syncPrimaryOverlayWindowLayer: (layer) => {
overlayGeometryRuntime.syncPrimaryOverlayWindowLayer(layer);
},
enforceOverlayLayerOrder: () => {
overlayGeometryRuntime.enforceOverlayLayerOrder();
},
syncOverlayShortcuts: () => {
overlayShortcutsRuntime.syncOverlayShortcuts();
},
isMacOSPlatform: () => process.platform === 'darwin',
isWindowsPlatform: () => process.platform === 'win32',
showOverlayLoadingOsd: (_message: string) => {
overlayNotificationsRuntime.showOverlayLoadingStatusNotification();
},
dismissOverlayLoadingOsd: () => {
overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification();
},
hideNonNativeOverlayWhenTargetUnfocused: () =>
shouldRunLinuxOverlayZOrderKeepAlive() &&
linuxOverlayModeRuntime.mode === 'fullscreen-override',
resolveFallbackBounds: () => {
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
const fallbackBounds = display.workArea;
return {
x: fallbackBounds.x,
y: fallbackBounds.y,
width: fallbackBounds.width,
height: fallbackBounds.height,
};
},
})(),
);
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService({
getMainWindow: () => overlayManager.getMainWindow(),
getModalActive: () => overlayModalInputState.getModalInputExclusive(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getForceMousePassthrough: () => appState.statsOverlayVisible,
getNonNativeInputRegionActive: () =>
process.platform === 'linux' &&
visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(),
getSuspendVisibleOverlay: () => appState.statsOverlayVisible,
getOverlayInteractionActive: () =>
visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive(),
getWindowTracker: () => appState.windowTracker,
getMacOSForegroundProbeActive: () =>
visibleOverlayInteractionRuntime.getMacOSVisibleOverlayForegroundProbeActive(),
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown: boolean) => {
appState.trackerNotReadyWarningShown = shown;
},
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
overlayGeometryRuntime.updateVisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window) => {
overlayGeometryRuntime.ensureOverlayWindowLevel(window);
},
syncWindowsOverlayToMpvZOrder: (_window) => {
visibleOverlayInteractionRuntime.requestWindowsVisibleOverlayZOrderSync();
},
syncPrimaryOverlayWindowLayer: (layer) => {
overlayGeometryRuntime.syncPrimaryOverlayWindowLayer(layer);
},
enforceOverlayLayerOrder: () => {
overlayGeometryRuntime.enforceOverlayLayerOrder();
},
syncOverlayShortcuts: () => {
overlayShortcutsRuntime.syncOverlayShortcuts();
},
isMacOSPlatform: () => process.platform === 'darwin',
isWindowsPlatform: () => process.platform === 'win32',
showOverlayLoadingOsd: (_message: string) => {
overlayNotificationsRuntime.showOverlayLoadingStatusNotification();
},
dismissOverlayLoadingOsd: () => {
overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification();
},
hideNonNativeOverlayWhenTargetUnfocused: () =>
shouldRunLinuxOverlayZOrderKeepAlive() &&
linuxOverlayModeRuntime.mode === 'fullscreen-override',
resolveFallbackBounds: () => {
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
const fallbackBounds = display.workArea;
return {
x: fallbackBounds.x,
y: fallbackBounds.y,
width: fallbackBounds.width,
height: fallbackBounds.height,
};
},
});
const visibleOverlayInteractionRuntime = createVisibleOverlayInteractionRuntime({
overlayManager: {
@@ -5053,8 +5017,7 @@ const {
startupOsdSequencer.markTokenizationReady();
},
},
createTokenizerRuntimeDeps: (deps) =>
createTokenizerDepsRuntime(deps as Parameters<typeof createTokenizerDepsRuntime>[0]),
createTokenizerRuntimeDeps: (deps) => createTokenizerDepsRuntime(deps),
tokenizeSubtitle: (text, deps) => tokenizeSubtitleCore(text, deps),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => appState.mecabTokenizer,
@@ -1,21 +0,0 @@
export function createBuildShouldRestoreWindowsOnActivateMainDepsHandler(deps: {
isOverlayRuntimeInitialized: () => boolean;
getAllWindowCount: () => number;
}) {
return () => ({
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
getAllWindowCount: () => deps.getAllWindowCount(),
});
}
export function createBuildRestoreWindowsOnActivateMainDepsHandler(deps: {
createMainWindow: () => void;
updateVisibleOverlayVisibility: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
}) {
return () => ({
createMainWindow: () => deps.createMainWindow(),
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
});
}
@@ -1,15 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildOnWillQuitCleanupDepsHandler } from './app-lifecycle-main-cleanup';
import { createOnWillQuitCleanupDeps } from './app-lifecycle-main-cleanup';
import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions';
type BuilderDeps = Parameters<typeof createBuildOnWillQuitCleanupDepsHandler>[0];
type BuilderDeps = Parameters<typeof createOnWillQuitCleanupDeps>[0];
// Defaults model an app that is ready but has no live runtime objects.
function buildCleanupDeps(overrides: Partial<BuilderDeps> = {}) {
const noop = () => {};
const none = () => null;
return createBuildOnWillQuitCleanupDepsHandler({
return createOnWillQuitCleanupDeps({
destroyTray: noop,
stopConfigHotReload: noop,
restorePreviousSecondarySubVisibility: noop,
@@ -53,7 +53,7 @@ function buildCleanupDeps(overrides: Partial<BuilderDeps> = {}) {
cleanupJellyfinSubtitleCache: noop,
stopDiscordPresenceService: noop,
...overrides,
})();
});
}
function fakeWindow(calls: string[], destroyed: boolean) {
@@ -17,7 +17,9 @@ type SocketLike = {
type TimerLike = ReturnType<typeof setTimeout>;
export function createBuildOnWillQuitCleanupDepsHandler(deps: {
// Adapts main-process state to the will-quit cleanup steps: each destroy/stop step looks up
// its current resource and skips it when absent or already destroyed.
export function createOnWillQuitCleanupDeps(deps: {
destroyTray: () => void;
stopConfigHotReload: () => void;
restorePreviousSecondarySubVisibility: () => void;
@@ -66,7 +68,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
return () => ({
return {
destroyTray: () => deps.destroyTray(),
stopConfigHotReload: () => deps.stopConfigHotReload(),
restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(),
@@ -154,5 +156,5 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
cleanupRemoteMediaWindows: () => deps.cleanupRemoteMediaWindows(),
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
});
};
}
@@ -1,25 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildEnsureTrayMainDepsHandler } from './app-runtime-main-deps';
test('ensure tray main deps trigger overlay bootstrap on tray click when runtime not initialized', () => {
const calls: string[] = [];
const deps = createBuildEnsureTrayMainDepsHandler({
getTray: () => null,
setTray: () => calls.push('set-tray'),
buildTrayMenu: () => ({}),
resolveTrayIconPath: () => null,
createImageFromPath: () => ({}),
createEmptyImage: () => ({}),
createTray: () => ({}),
trayTooltip: 'SubMiner',
platform: 'darwin',
logWarn: (message) => calls.push(`warn:${message}`),
initializeOverlayRuntime: () => calls.push('init-overlay'),
isOverlayRuntimeInitialized: () => false,
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
})();
deps.ensureOverlayVisibleFromTrayClick();
assert.deepEqual(calls, ['init-overlay', 'set-visible:true']);
});
-103
View File
@@ -1,103 +0,0 @@
export function createBuildEnsureTrayMainDepsHandler<TTray, TTrayMenu, TTrayIcon>(deps: {
getTray: () => TTray | null;
setTray: (tray: TTray | null) => void;
buildTrayMenu: () => TTrayMenu;
resolveTrayIconPath: () => string | null;
createImageFromPath: (iconPath: string) => TTrayIcon;
createEmptyImage: () => TTrayIcon;
createTray: (icon: TTrayIcon) => TTray;
trayTooltip: string;
platform: string;
logWarn: (message: string) => void;
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
}) {
return () => ({
getTray: () => deps.getTray(),
setTray: (tray: TTray | null) => deps.setTray(tray),
buildTrayMenu: () => deps.buildTrayMenu(),
resolveTrayIconPath: () => deps.resolveTrayIconPath(),
createImageFromPath: (iconPath: string) => deps.createImageFromPath(iconPath),
createEmptyImage: () => deps.createEmptyImage(),
createTray: (icon: TTrayIcon) => deps.createTray(icon),
trayTooltip: deps.trayTooltip,
platform: deps.platform,
logWarn: (message: string) => deps.logWarn(message),
ensureOverlayVisibleFromTrayClick: () => {
if (!deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
}
deps.setVisibleOverlayVisible(true);
},
});
}
export function createBuildDestroyTrayMainDepsHandler<TTray>(deps: {
getTray: () => TTray | null;
setTray: (tray: TTray | null) => void;
}) {
return () => ({
getTray: () => deps.getTray(),
setTray: (tray: TTray | null) => deps.setTray(tray),
});
}
export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<TOptions>(deps: {
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntimeCore: (options: TOptions) => void;
buildOptions: () => TOptions;
setOverlayRuntimeInitialized: (initialized: boolean) => void;
startBackgroundWarmups: () => void;
}) {
return () => ({
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
initializeOverlayRuntimeCore: (options: TOptions) => deps.initializeOverlayRuntimeCore(options),
buildOptions: () => deps.buildOptions(),
setOverlayRuntimeInitialized: (initialized: boolean) =>
deps.setOverlayRuntimeInitialized(initialized),
startBackgroundWarmups: () => deps.startBackgroundWarmups(),
});
}
export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWindow>(deps: {
ensureYomitanExtensionLoaded: () => Promise<TYomitanExt | null>;
getYomitanExtension?: () => TYomitanExt | null;
getYomitanExtensionLoadInFlight?: () => Promise<unknown> | null;
openYomitanSettingsWindow: (params: {
yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
yomitanSession?: unknown | null;
onWindowClosed?: () => void;
}) => void;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
getYomitanSession?: () => unknown | null;
logWarn: (message: string) => void;
logError: (message: string, error: unknown) => void;
}) {
return () => ({
ensureYomitanExtensionLoaded: () => deps.ensureYomitanExtensionLoaded(),
...(deps.getYomitanExtension
? { getYomitanExtension: () => deps.getYomitanExtension?.() ?? null }
: {}),
...(deps.getYomitanExtensionLoadInFlight
? {
getYomitanExtensionLoadInFlight: () => deps.getYomitanExtensionLoadInFlight?.() ?? null,
}
: {}),
openYomitanSettingsWindow: (params: {
yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
yomitanSession?: unknown | null;
onWindowClosed?: () => void;
}) => deps.openYomitanSettingsWindow(params),
getExistingWindow: () => deps.getExistingWindow(),
setWindow: (window: TWindow | null) => deps.setWindow(window),
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
logWarn: (message: string) => deps.logWarn(message),
logError: (message: string, error: unknown) => deps.logError(message, error),
});
}
@@ -1,128 +0,0 @@
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'];
showOsd: (text: string) => void;
showPlaybackFeedback?: (text: string) => void;
texthookerService: CliCommandContextFactoryDeps['texthookerService'];
getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void;
getTexthookerWebsocketUrl: () => string | undefined;
shouldOpenBrowser: () => boolean;
openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void;
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: (force?: boolean) => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWordCache: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction'];
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken'];
openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup'];
openJellyfinSetup: CliCommandContextFactoryDeps['openJellyfinSetup'];
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
getCharacterDictionarySelection?: CliCommandContextFactoryDeps['getCharacterDictionarySelection'];
setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandContextFactoryDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
stopApp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
}) {
return (): CliCommandContextFactoryDeps => ({
setLogLevel: deps.setLogLevel,
getSocketPath: deps.getSocketPath,
setSocketPath: deps.setSocketPath,
getMpvClient: deps.getMpvClient,
showOsd: deps.showOsd,
showPlaybackFeedback: deps.showPlaybackFeedback,
texthookerService: deps.texthookerService,
getTexthookerPort: deps.getTexthookerPort,
setTexthookerPort: deps.setTexthookerPort,
getTexthookerWebsocketUrl: deps.getTexthookerWebsocketUrl,
shouldOpenBrowser: deps.shouldOpenBrowser,
openExternal: deps.openExternal,
logBrowserOpenError: deps.logBrowserOpenError,
isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay,
togglePrimarySubtitleBar: deps.togglePrimarySubtitleBar,
openFirstRunSetup: deps.openFirstRunSetup,
setVisibleOverlay: deps.setVisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle,
startPendingMultiCopy: deps.startPendingMultiCopy,
mineSentenceCard: deps.mineSentenceCard,
startPendingMineSentenceMultiple: deps.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: deps.updateLastCardFromClipboard,
refreshKnownWordCache: deps.refreshKnownWordCache,
triggerFieldGrouping: deps.triggerFieldGrouping,
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
dispatchSessionAction: deps.dispatchSessionAction,
getAnilistStatus: deps.getAnilistStatus,
clearAnilistToken: deps.clearAnilistToken,
openAnilistSetup: deps.openAnilistSetup,
openJellyfinSetup: deps.openJellyfinSetup,
getAnilistQueueStatus: deps.getAnilistQueueStatus,
retryAnilistQueueNow: deps.retryAnilistQueueNow,
generateCharacterDictionary: deps.generateCharacterDictionary,
getCharacterDictionarySelection: deps.getCharacterDictionarySelection,
setCharacterDictionarySelection: deps.setCharacterDictionarySelection,
runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand,
runUpdateCommand: deps.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openHachidoriSettings: deps.openHachidoriSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
stopApp: deps.stopApp,
hasMainWindow: deps.hasMainWindow,
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
schedule: deps.schedule,
logInfo: deps.logInfo,
logDebug: deps.logDebug,
logWarn: deps.logWarn,
logError: deps.logError,
});
}
@@ -1,13 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildCliCommandContextMainDepsHandler } from './cli-command-context-main-deps';
import {
createCliCommandContextFactory,
type CliCommandContextMainDeps,
} from './cli-command-context-factory';
type MainDeps = Parameters<typeof createBuildCliCommandContextMainDepsHandler>[0];
function buildContextDeps(overrides: Partial<MainDeps> = {}) {
function buildContext(overrides: Partial<CliCommandContextMainDeps> = {}) {
const noop = () => {};
const noopAsync = async () => {};
return createBuildCliCommandContextMainDepsHandler({
return createCliCommandContextFactory({
appState: {
mpvSocketPath: '/tmp/mpv.sock',
mpvClient: null,
@@ -100,16 +101,16 @@ test('cli command context setSocketPath notifies only when the socket path chang
texthookerPort: 5174,
overlayRuntimeInitialized: false,
};
const deps = buildContextDeps({
const context = buildContext({
appState,
onMpvSocketPathChanged: (next, previous) => changes.push(`${previous}->${next}`),
});
deps.setSocketPath('/tmp/next.sock');
deps.setSocketPath('/tmp/next.sock');
context.setSocketPath('/tmp/next.sock');
context.setSocketPath('/tmp/next.sock');
assert.equal(appState.mpvSocketPath, '/tmp/next.sock');
assert.equal(deps.getSocketPath(), '/tmp/next.sock');
assert.equal(context.getSocketPath(), '/tmp/next.sock');
assert.deepEqual(changes, ['/tmp/mpv.sock->/tmp/next.sock']);
});
@@ -119,10 +120,10 @@ for (const c of [
{ openBrowser: false, expected: false },
]) {
test(`cli command context shouldOpenBrowser is ${c.expected} when texthooker.openBrowser is ${c.openBrowser}`, () => {
const deps = buildContextDeps({
const context = buildContext({
getResolvedConfig: () => ({ texthooker: { openBrowser: c.openBrowser } }),
});
assert.equal(deps.shouldOpenBrowser(), c.expected);
assert.equal(context.shouldOpenBrowser(), c.expected);
});
}
@@ -147,10 +148,10 @@ for (const c of [
},
]) {
test(`cli command context texthooker websocket url ${c.name}`, () => {
const deps = buildContextDeps({
const context = buildContext({
getResolvedConfig: () => c.config,
hasMpvWebsocketPlugin: () => c.hasPlugin,
});
assert.equal(deps.getTexthookerWebsocketUrl(), c.expected);
assert.equal(context.getTexthookerWebsocketUrl(), c.expected);
});
}
+166 -9
View File
@@ -1,14 +1,171 @@
import { createCliCommandContext } from './cli-command-context';
import { createBuildCliCommandContextDepsHandler } from './cli-command-context-deps';
import { createBuildCliCommandContextMainDepsHandler } from './cli-command-context-main-deps';
import type { CliArgs, CliCommandSource } from '../../cli/args';
import { resolveTexthookerWebsocketUrl } from '../../core/services/startup';
import { createCliCommandContext, type CliCommandContextFactoryDeps } from './cli-command-context';
type CliCommandContextMainDeps = Parameters<typeof createBuildCliCommandContextMainDepsHandler>[0];
type CliCommandContextMainState = {
mpvSocketPath: string;
mpvClient: ReturnType<CliCommandContextFactoryDeps['getMpvClient']>;
texthookerPort: number;
overlayRuntimeInitialized: boolean;
};
export type CliCommandContextMainDeps = {
appState: CliCommandContextMainState;
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
onMpvSocketPathChanged?: (nextSocketPath: string, previousSocketPath: string) => void;
texthookerService: CliCommandContextFactoryDeps['texthookerService'];
getResolvedConfig: () => {
texthooker?: { openBrowser?: boolean };
websocket?: { enabled?: boolean | 'auto'; port?: number };
annotationWebsocket?: { enabled?: boolean; port?: number };
};
defaultWebsocketPort: number;
defaultAnnotationWebsocketPort: number;
hasMpvWebsocketPlugin: () => boolean;
openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void;
showMpvOsd: (text: string) => void;
showPlaybackFeedback?: (text: string) => void;
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetupWindow: (force?: boolean) => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWordCache: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction'];
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
clearAnilistToken: () => void;
openAnilistSetupWindow: () => void;
openJellyfinSetupWindow: () => void;
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
getCharacterDictionarySelection?: CliCommandContextFactoryDeps['getCharacterDictionarySelection'];
setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: (
args: CliArgs,
source: CliCommandSource,
) => Promise<void>;
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
stopApp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
};
// Adapts main-process state and services to the CLI command context. The context deps are
// built once; each call creates a fresh context over them.
export function createCliCommandContextFactory(deps: CliCommandContextMainDeps) {
const buildCliCommandContextMainDepsHandler = createBuildCliCommandContextMainDepsHandler(deps);
const cliCommandContextMainDeps = buildCliCommandContextMainDepsHandler();
const buildCliCommandContextDepsHandler =
createBuildCliCommandContextDepsHandler(cliCommandContextMainDeps);
const contextDeps: CliCommandContextFactoryDeps = {
setLogLevel: deps.setLogLevel,
getSocketPath: () => deps.appState.mpvSocketPath,
setSocketPath: (socketPath: string) => {
const previousSocketPath = deps.appState.mpvSocketPath;
deps.appState.mpvSocketPath = socketPath;
if (socketPath !== previousSocketPath) {
deps.onMpvSocketPathChanged?.(socketPath, previousSocketPath);
}
},
getMpvClient: () => deps.appState.mpvClient,
showOsd: (text: string) => deps.showMpvOsd(text),
showPlaybackFeedback: deps.showPlaybackFeedback,
texthookerService: deps.texthookerService,
getTexthookerPort: () => deps.appState.texthookerPort,
setTexthookerPort: (port: number) => {
deps.appState.texthookerPort = port;
},
getTexthookerWebsocketUrl: () =>
resolveTexthookerWebsocketUrl(
deps.getResolvedConfig(),
{
defaultWebsocketPort: deps.defaultWebsocketPort,
defaultAnnotationWebsocketPort: deps.defaultAnnotationWebsocketPort,
},
deps.hasMpvWebsocketPlugin(),
),
shouldOpenBrowser: () => deps.getResolvedConfig().texthooker?.openBrowser !== false,
openExternal: (url: string) => deps.openExternal(url),
logBrowserOpenError: (url: string, error: unknown) => deps.logBrowserOpenError(url, error),
isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized,
initializeOverlay: () => deps.initializeOverlayRuntime(),
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(),
openFirstRunSetup: (force?: boolean) => deps.openFirstRunSetupWindow(force),
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => deps.mineSentenceCard(),
startPendingMineSentenceMultiple: (timeoutMs: number) =>
deps.startPendingMineSentenceMultiple(timeoutMs),
updateLastCardFromClipboard: () => deps.updateLastCardFromClipboard(),
refreshKnownWordCache: () => deps.refreshKnownWordCache(),
triggerFieldGrouping: () => deps.triggerFieldGrouping(),
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(),
dispatchSessionAction: (request) => deps.dispatchSessionAction(request),
getAnilistStatus: () => deps.getAnilistStatus(),
clearAnilistToken: () => deps.clearAnilistToken(),
openAnilistSetup: () => deps.openAnilistSetupWindow(),
openJellyfinSetup: () => deps.openJellyfinSetupWindow(),
getAnilistQueueStatus: () => deps.getAnilistQueueStatus(),
retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(),
generateCharacterDictionary: (targetPath?: string) =>
deps.generateCharacterDictionary(targetPath),
getCharacterDictionarySelection: deps.getCharacterDictionarySelection,
setCharacterDictionarySelection: deps.setCharacterDictionarySelection,
runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source),
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
runUpdateCommand: (args: CliArgs, source: CliCommandSource) =>
deps.runUpdateCommand(args, source),
runEnsureLinuxRuntimePluginAssetsCommand: (args: CliArgs, source: CliCommandSource) =>
deps.runEnsureLinuxRuntimePluginAssetsCommand(args, source),
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: () => deps.openYomitanSettings(),
openHachidoriSettings: () => deps.openHachidoriSettings(),
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
openSyncUiWindow: () => deps.openSyncUiWindow(),
openYoutubeBrowserWindow: () => deps.openYoutubeBrowserWindow(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
printHelp: () => deps.printHelp(),
stopApp: () => deps.stopApp(),
hasMainWindow: () => deps.hasMainWindow(),
getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(),
schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs),
logInfo: (message: string) => deps.logInfo(message),
logDebug: (message: string) => deps.logDebug(message),
logWarn: (message: string) => deps.logWarn(message),
logError: (message: string, err: unknown) => deps.logError(message, err),
};
return () => createCliCommandContext(buildCliCommandContextDepsHandler());
return () => createCliCommandContext(contextDeps);
}
@@ -1,165 +0,0 @@
import type { CliArgs, CliCommandSource } from '../../cli/args';
import { resolveTexthookerWebsocketUrl } from '../../core/services/startup';
import type { CliCommandContextFactoryDeps } from './cli-command-context';
type CliCommandContextMainState = {
mpvSocketPath: string;
mpvClient: ReturnType<CliCommandContextFactoryDeps['getMpvClient']>;
texthookerPort: number;
overlayRuntimeInitialized: boolean;
};
export function createBuildCliCommandContextMainDepsHandler(deps: {
appState: CliCommandContextMainState;
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
onMpvSocketPathChanged?: (nextSocketPath: string, previousSocketPath: string) => void;
texthookerService: CliCommandContextFactoryDeps['texthookerService'];
getResolvedConfig: () => {
texthooker?: { openBrowser?: boolean };
websocket?: { enabled?: boolean | 'auto'; port?: number };
annotationWebsocket?: { enabled?: boolean; port?: number };
};
defaultWebsocketPort: number;
defaultAnnotationWebsocketPort: number;
hasMpvWebsocketPlugin: () => boolean;
openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void;
showMpvOsd: (text: string) => void;
showPlaybackFeedback?: (text: string) => void;
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetupWindow: (force?: boolean) => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWordCache: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction'];
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
clearAnilistToken: () => void;
openAnilistSetupWindow: () => void;
openJellyfinSetupWindow: () => void;
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
getCharacterDictionarySelection?: CliCommandContextFactoryDeps['getCharacterDictionarySelection'];
setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: (
args: CliArgs,
source: CliCommandSource,
) => Promise<void>;
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
stopApp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
}) {
return (): CliCommandContextFactoryDeps => ({
setLogLevel: deps.setLogLevel,
getSocketPath: () => deps.appState.mpvSocketPath,
setSocketPath: (socketPath: string) => {
const previousSocketPath = deps.appState.mpvSocketPath;
deps.appState.mpvSocketPath = socketPath;
if (socketPath !== previousSocketPath) {
deps.onMpvSocketPathChanged?.(socketPath, previousSocketPath);
}
},
getMpvClient: () => deps.appState.mpvClient,
showOsd: (text: string) => deps.showMpvOsd(text),
showPlaybackFeedback: deps.showPlaybackFeedback,
texthookerService: deps.texthookerService,
getTexthookerPort: () => deps.appState.texthookerPort,
setTexthookerPort: (port: number) => {
deps.appState.texthookerPort = port;
},
getTexthookerWebsocketUrl: () =>
resolveTexthookerWebsocketUrl(
deps.getResolvedConfig(),
{
defaultWebsocketPort: deps.defaultWebsocketPort,
defaultAnnotationWebsocketPort: deps.defaultAnnotationWebsocketPort,
},
deps.hasMpvWebsocketPlugin(),
),
shouldOpenBrowser: () => deps.getResolvedConfig().texthooker?.openBrowser !== false,
openExternal: (url: string) => deps.openExternal(url),
logBrowserOpenError: (url: string, error: unknown) => deps.logBrowserOpenError(url, error),
isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized,
initializeOverlay: () => deps.initializeOverlayRuntime(),
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(),
openFirstRunSetup: (force?: boolean) => deps.openFirstRunSetupWindow(force),
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => deps.mineSentenceCard(),
startPendingMineSentenceMultiple: (timeoutMs: number) =>
deps.startPendingMineSentenceMultiple(timeoutMs),
updateLastCardFromClipboard: () => deps.updateLastCardFromClipboard(),
refreshKnownWordCache: () => deps.refreshKnownWordCache(),
triggerFieldGrouping: () => deps.triggerFieldGrouping(),
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(),
dispatchSessionAction: (request) => deps.dispatchSessionAction(request),
getAnilistStatus: () => deps.getAnilistStatus(),
clearAnilistToken: () => deps.clearAnilistToken(),
openAnilistSetup: () => deps.openAnilistSetupWindow(),
openJellyfinSetup: () => deps.openJellyfinSetupWindow(),
getAnilistQueueStatus: () => deps.getAnilistQueueStatus(),
retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(),
generateCharacterDictionary: (targetPath?: string) =>
deps.generateCharacterDictionary(targetPath),
getCharacterDictionarySelection: deps.getCharacterDictionarySelection,
setCharacterDictionarySelection: deps.setCharacterDictionarySelection,
runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source),
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
runUpdateCommand: (args: CliArgs, source: CliCommandSource) =>
deps.runUpdateCommand(args, source),
runEnsureLinuxRuntimePluginAssetsCommand: (args: CliArgs, source: CliCommandSource) =>
deps.runEnsureLinuxRuntimePluginAssetsCommand(args, source),
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: () => deps.openYomitanSettings(),
openHachidoriSettings: () => deps.openHachidoriSettings(),
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
openSyncUiWindow: () => deps.openSyncUiWindow(),
openYoutubeBrowserWindow: () => deps.openYoutubeBrowserWindow(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
printHelp: () => deps.printHelp(),
stopApp: () => deps.stopApp(),
hasMainWindow: () => deps.hasMainWindow(),
getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(),
schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs),
logInfo: (message: string) => deps.logInfo(message),
logDebug: (message: string) => deps.logDebug(message),
logWarn: (message: string) => deps.logWarn(message),
logError: (message: string, err: unknown) => deps.logError(message, err),
});
}
-8
View File
@@ -3,11 +3,3 @@ type ComposerShape = Record<string, unknown>;
export type ComposerInputs<T extends ComposerShape> = Readonly<Required<T>>;
export type ComposerOutputs<T extends ComposerShape> = Readonly<T>;
export type BuiltMainDeps<TFactory> = TFactory extends (
...args: infer _TFactoryArgs
) => infer TBuilder
? TBuilder extends (...args: infer _TBuilderArgs) => infer TDeps
? TDeps
: never
: never;
@@ -1,15 +1,14 @@
import {
createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler,
createBuildHandleJellyfinRemotePlayMainDepsHandler,
createBuildHandleJellyfinRemotePlaystateMainDepsHandler,
createBuildReportJellyfinRemoteProgressMainDepsHandler,
createBuildReportJellyfinRemoteStoppedMainDepsHandler,
createHandleJellyfinRemoteGeneralCommand,
createHandleJellyfinRemotePlay,
createHandleJellyfinRemotePlaystate,
createJellyfinRemoteReportTracker,
createReportJellyfinRemoteProgressHandler,
createReportJellyfinRemoteStoppedHandler,
type JellyfinRemoteGeneralCommandHandlerDeps,
type JellyfinRemotePlayHandlerDeps,
type JellyfinRemotePlaystateHandlerDeps,
type JellyfinRemoteProgressReporterDeps,
} from '../domains/jellyfin';
import type { ComposerInputs, ComposerOutputs } from './contracts';
@@ -18,44 +17,24 @@ type RemotePlaystatePayload = Parameters<ReturnType<typeof createHandleJellyfinR
type RemoteGeneralPayload = Parameters<
ReturnType<typeof createHandleJellyfinRemoteGeneralCommand>
>[0];
type JellyfinRemotePlayMainDeps = Parameters<
typeof createBuildHandleJellyfinRemotePlayMainDepsHandler
>[0];
type JellyfinRemotePlaystateMainDeps = Parameters<
typeof createBuildHandleJellyfinRemotePlaystateMainDepsHandler
>[0];
type JellyfinRemoteGeneralMainDeps = Parameters<
typeof createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler
>[0];
type JellyfinRemoteProgressMainDeps = Parameters<
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
>[0];
export type JellyfinRemoteComposerOptions = ComposerInputs<{
getConfiguredSession: JellyfinRemotePlayMainDeps['getConfiguredSession'];
getClientInfo: JellyfinRemotePlayMainDeps['getClientInfo'];
getJellyfinConfig: JellyfinRemotePlayMainDeps['getJellyfinConfig'];
playJellyfinItem: JellyfinRemotePlayMainDeps['playJellyfinItem'];
logWarn: JellyfinRemotePlayMainDeps['logWarn'];
getMpvClient: JellyfinRemoteProgressMainDeps['getMpvClient'];
sendMpvCommand: JellyfinRemotePlaystateMainDeps['sendMpvCommand'];
jellyfinTicksToSeconds: Parameters<
typeof createBuildHandleJellyfinRemotePlaystateMainDepsHandler
>[0]['jellyfinTicksToSeconds'];
getActivePlayback: JellyfinRemoteGeneralMainDeps['getActivePlayback'];
clearActivePlayback: JellyfinRemoteProgressMainDeps['clearActivePlayback'];
getNow: JellyfinRemoteProgressMainDeps['getNow'];
getLastProgressAtMs: Parameters<
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
>[0]['getLastProgressAtMs'];
setLastProgressAtMs: Parameters<
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
>[0]['setLastProgressAtMs'];
getConfiguredSession: JellyfinRemotePlayHandlerDeps['getConfiguredSession'];
getClientInfo: JellyfinRemotePlayHandlerDeps['getClientInfo'];
getJellyfinConfig: JellyfinRemotePlayHandlerDeps['getJellyfinConfig'];
playJellyfinItem: JellyfinRemotePlayHandlerDeps['playJellyfinItem'];
logWarn: JellyfinRemotePlayHandlerDeps['logWarn'];
getMpvClient: JellyfinRemoteProgressReporterDeps['getMpvClient'];
sendMpvCommand: JellyfinRemotePlaystateHandlerDeps['sendMpvCommand'];
jellyfinTicksToSeconds: JellyfinRemotePlaystateHandlerDeps['jellyfinTicksToSeconds'];
getActivePlayback: JellyfinRemoteGeneralCommandHandlerDeps['getActivePlayback'];
clearActivePlayback: JellyfinRemoteProgressReporterDeps['clearActivePlayback'];
getNow: JellyfinRemoteProgressReporterDeps['getNow'];
getLastProgressAtMs: JellyfinRemoteProgressReporterDeps['getLastProgressAtMs'];
setLastProgressAtMs: JellyfinRemoteProgressReporterDeps['setLastProgressAtMs'];
progressIntervalMs: number;
ticksPerSecond: number;
logDebug: Parameters<
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
>[0]['logDebug'];
logDebug: JellyfinRemoteProgressReporterDeps['logDebug'];
}>;
export type JellyfinRemoteComposerResult = ComposerOutputs<{
@@ -69,75 +48,54 @@ export type JellyfinRemoteComposerResult = ComposerOutputs<{
export function composeJellyfinRemoteHandlers(
options: JellyfinRemoteComposerOptions,
): JellyfinRemoteComposerResult {
const buildReportJellyfinRemoteProgressMainDepsHandler =
createBuildReportJellyfinRemoteProgressMainDepsHandler({
getActivePlayback: options.getActivePlayback,
clearActivePlayback: options.clearActivePlayback,
getMpvClient: options.getMpvClient,
getNow: options.getNow,
getLastProgressAtMs: options.getLastProgressAtMs,
setLastProgressAtMs: options.setLastProgressAtMs,
progressIntervalMs: options.progressIntervalMs,
ticksPerSecond: options.ticksPerSecond,
logDebug: options.logDebug,
});
const buildReportJellyfinRemoteStoppedMainDepsHandler =
createBuildReportJellyfinRemoteStoppedMainDepsHandler({
getActivePlayback: options.getActivePlayback,
clearActivePlayback: options.clearActivePlayback,
getMpvClient: options.getMpvClient,
getNow: options.getNow,
ticksPerSecond: options.ticksPerSecond,
logDebug: options.logDebug,
logWarn: options.logWarn,
});
const reportTracker = createJellyfinRemoteReportTracker();
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler({
...buildReportJellyfinRemoteProgressMainDepsHandler(),
getActivePlayback: options.getActivePlayback,
clearActivePlayback: options.clearActivePlayback,
getMpvClient: options.getMpvClient,
getNow: options.getNow,
getLastProgressAtMs: options.getLastProgressAtMs,
setLastProgressAtMs: options.setLastProgressAtMs,
progressIntervalMs: options.progressIntervalMs,
ticksPerSecond: options.ticksPerSecond,
logDebug: options.logDebug,
reportTracker,
});
const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler({
...buildReportJellyfinRemoteStoppedMainDepsHandler(),
getActivePlayback: options.getActivePlayback,
clearActivePlayback: options.clearActivePlayback,
getMpvClient: options.getMpvClient,
getNow: options.getNow,
ticksPerSecond: options.ticksPerSecond,
logDebug: options.logDebug,
logWarn: options.logWarn,
reportTracker,
});
const buildHandleJellyfinRemotePlayMainDepsHandler =
createBuildHandleJellyfinRemotePlayMainDepsHandler({
return {
reportJellyfinRemoteProgress,
reportJellyfinRemoteStopped,
handleJellyfinRemotePlay: createHandleJellyfinRemotePlay({
getConfiguredSession: options.getConfiguredSession,
getClientInfo: options.getClientInfo,
getJellyfinConfig: options.getJellyfinConfig,
getActivePlayback: options.getActivePlayback,
playJellyfinItem: options.playJellyfinItem,
logWarn: options.logWarn,
});
const buildHandleJellyfinRemotePlaystateMainDepsHandler =
createBuildHandleJellyfinRemotePlaystateMainDepsHandler({
}),
handleJellyfinRemotePlaystate: createHandleJellyfinRemotePlaystate({
getMpvClient: options.getMpvClient,
sendMpvCommand: options.sendMpvCommand,
reportJellyfinRemoteProgress: (force) => reportJellyfinRemoteProgress(force),
reportJellyfinRemoteStopped: () => reportJellyfinRemoteStopped(),
jellyfinTicksToSeconds: options.jellyfinTicksToSeconds,
});
const buildHandleJellyfinRemoteGeneralCommandMainDepsHandler =
createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler({
}),
handleJellyfinRemoteGeneralCommand: createHandleJellyfinRemoteGeneralCommand({
getMpvClient: options.getMpvClient,
sendMpvCommand: options.sendMpvCommand,
getActivePlayback: options.getActivePlayback,
reportJellyfinRemoteProgress: (force) => reportJellyfinRemoteProgress(force),
logDebug: (message) => options.logDebug(message, undefined),
});
return {
reportJellyfinRemoteProgress,
reportJellyfinRemoteStopped,
handleJellyfinRemotePlay: createHandleJellyfinRemotePlay(
buildHandleJellyfinRemotePlayMainDepsHandler(),
),
handleJellyfinRemotePlaystate: createHandleJellyfinRemotePlaystate(
buildHandleJellyfinRemotePlaystateMainDepsHandler(),
),
handleJellyfinRemoteGeneralCommand: createHandleJellyfinRemoteGeneralCommand(
buildHandleJellyfinRemoteGeneralCommandMainDepsHandler(),
),
}),
};
}
@@ -1,9 +1,6 @@
import {
buildJellyfinSetupFormHtml,
createEnsureMpvConnectedForJellyfinPlaybackHandler,
createBuildGetJellyfinClientInfoMainDepsHandler,
createBuildGetResolvedJellyfinConfigMainDepsHandler,
createBuildOpenJellyfinSetupWindowMainDepsHandler,
createGetJellyfinClientInfoHandler,
createGetResolvedJellyfinConfigHandler,
createHandleJellyfinAuthCommands,
@@ -35,17 +32,11 @@ type HandlePlayCommandMainDeps = Parameters<typeof createHandleJellyfinPlayComma
type HandleRemoteAnnounceMainDeps = Parameters<typeof createHandleJellyfinRemoteAnnounceCommand>[0];
type StartRemoteSessionMainDeps = Parameters<typeof createStartJellyfinRemoteSessionHandler>[0];
type RunJellyfinCommandMainDeps = Parameters<typeof createRunJellyfinCommandHandler>[0];
type OpenJellyfinSetupWindowMainDeps = Parameters<
typeof createBuildOpenJellyfinSetupWindowMainDepsHandler
>[0];
type OpenJellyfinSetupWindowMainDeps = Parameters<typeof createOpenJellyfinSetupWindowHandler>[0];
export type JellyfinRuntimeComposerOptions = ComposerInputs<{
getResolvedJellyfinConfigMainDeps: Parameters<
typeof createBuildGetResolvedJellyfinConfigMainDepsHandler
>[0];
getJellyfinClientInfoMainDeps: Parameters<
typeof createBuildGetJellyfinClientInfoMainDepsHandler
>[0];
getResolvedJellyfinConfigMainDeps: Parameters<typeof createGetResolvedJellyfinConfigHandler>[0];
getJellyfinClientInfoMainDeps: Parameters<typeof createGetJellyfinClientInfoHandler>[0];
waitForMpvConnectedMainDeps: Parameters<typeof createWaitForMpvConnectedHandler>[0];
launchMpvIdleForJellyfinPlaybackMainDeps: Parameters<
typeof createLaunchMpvIdleForJellyfinPlaybackHandler
@@ -141,12 +132,10 @@ export function composeJellyfinRuntimeHandlers(
options: JellyfinRuntimeComposerOptions,
): JellyfinRuntimeComposerResult {
const getResolvedJellyfinConfig = createGetResolvedJellyfinConfigHandler(
createBuildGetResolvedJellyfinConfigMainDepsHandler(
options.getResolvedJellyfinConfigMainDeps,
)(),
options.getResolvedJellyfinConfigMainDeps,
);
const getJellyfinClientInfo = createGetJellyfinClientInfoHandler(
createBuildGetJellyfinClientInfoMainDepsHandler(options.getJellyfinClientInfoMainDeps)(),
options.getJellyfinClientInfoMainDeps,
);
const waitForMpvConnected = createWaitForMpvConnectedHandler(options.waitForMpvConnectedMainDeps);
@@ -233,16 +222,14 @@ export function composeJellyfinRuntimeHandlers(
getCurrentSession: () => options.startJellyfinRemoteSessionMainDeps.getCurrentSession(),
startJellyfinRemoteSession: (startOptions) => startJellyfinRemoteSession(startOptions),
});
const openJellyfinSetupWindow = createOpenJellyfinSetupWindowHandler(
createBuildOpenJellyfinSetupWindowMainDepsHandler({
...options.openJellyfinSetupWindowMainDeps,
maybeFocusExistingSetupWindow: maybeFocusExistingJellyfinSetupWindow,
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
getJellyfinClientInfo: () => getJellyfinClientInfo(),
restartRemoteSession: () => restartJellyfinRemoteSessionAfterSetupLogin(),
stopRemoteSession: () => stopJellyfinRemoteSession(),
})(),
);
const openJellyfinSetupWindow = createOpenJellyfinSetupWindowHandler({
...options.openJellyfinSetupWindowMainDeps,
maybeFocusExistingSetupWindow: maybeFocusExistingJellyfinSetupWindow,
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
getJellyfinClientInfo: () => getJellyfinClientInfo(),
restartRemoteSession: () => restartJellyfinRemoteSessionAfterSetupLogin(),
stopRemoteSession: () => stopJellyfinRemoteSession(),
});
return {
getResolvedJellyfinConfig,
@@ -423,3 +423,63 @@ test('composeMpvRuntimeHandlers reuses completed background tokenization warmups
assert.equal(jlptWarmupCalls, 1);
assert.equal(frequencyWarmupCalls, 1);
});
type TokenizerMainDeps = Parameters<
typeof composeMpvRuntimeHandlers
>[0]['tokenizer']['buildTokenizerDepsMainDeps'];
type TokenizerRuntimeOptions = Parameters<
Parameters<typeof composeMpvRuntimeHandlers>[0]['tokenizer']['createTokenizerRuntimeDeps']
>[0];
async function captureTokenizerRuntimeOptions(
overrides: Partial<TokenizerMainDeps>,
): Promise<TokenizerRuntimeOptions> {
const fixture = createDefaultMpvFixture();
let captured: TokenizerRuntimeOptions | null = null;
const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void },
TokenizerRuntimeOptions,
{ text: string }
>({
...fixture,
tokenizer: {
...fixture.tokenizer,
buildTokenizerDepsMainDeps: { ...fixture.tokenizer.buildTokenizerDepsMainDeps, ...overrides },
createTokenizerRuntimeDeps: (deps) => {
captured = deps;
return deps;
},
tokenizeSubtitle: async (text) => ({ text }),
},
});
await composed.tokenizeSubtitle('line');
assert.ok(captured);
return captured;
}
test('composeMpvRuntimeHandlers records every known-word lookup result', async () => {
const lookups: boolean[] = [];
const runtimeOptions = await captureTokenizerRuntimeOptions({
isKnownWord: (text) => text === 'known',
recordLookup: (hit) => lookups.push(hit),
});
assert.equal(runtimeOptions.isKnownWord('known'), true);
assert.equal(runtimeOptions.isKnownWord('unknown'), false);
assert.deepEqual(lookups, [true, false]);
});
test('composeMpvRuntimeHandlers disables name matching while the character dictionary is disabled', async () => {
let characterDictionaryEnabled = false;
const runtimeOptions = await captureTokenizerRuntimeOptions({
getCharacterDictionaryEnabled: () => characterDictionaryEnabled,
getNameMatchEnabled: () => true,
getNameMatchImagesEnabled: () => true,
});
assert.equal(runtimeOptions.getNameMatchEnabled?.(), false);
assert.equal(runtimeOptions.getNameMatchImagesEnabled?.(), false);
characterDictionaryEnabled = true;
assert.equal(runtimeOptions.getNameMatchEnabled?.(), true);
assert.equal(runtimeOptions.getNameMatchImagesEnabled?.(), true);
});
@@ -1,47 +1,54 @@
import { createBindMpvMainEventHandlersHandler } from '../mpv-main-event-bindings';
import { createBuildBindMpvMainEventHandlersMainDepsHandler } from '../mpv-main-event-main-deps';
import { createBuildMpvClientRuntimeServiceFactoryDepsHandler } from '../mpv-client-runtime-service-main-deps';
import { createMpvMainEventBindingDeps } from '../mpv-main-event-binding-deps';
import { createMpvClientRuntimeServiceFactory } from '../mpv-client-runtime-service';
import type { MpvClientRuntimeServiceOptions } from '../mpv-client-runtime-service';
import type { Config } from '../../../types';
import { createUpdateMpvSubtitleRenderMetricsHandler } from '../mpv-subtitle-render-metrics';
import {
createBuildTokenizerDepsMainHandler,
createCreateMecabTokenizerAndCheckMainHandler,
createPrewarmSubtitleDictionariesMainHandler,
} from '../subtitle-tokenization-main-deps';
createMecabTokenizerAndCheckHandler,
createPrewarmSubtitleDictionariesHandler,
} from '../subtitle-tokenization-warmup';
import type { TokenizerDepsRuntimeOptions } from '../../../core/services/tokenizer';
import {
createLaunchBackgroundWarmupTaskHandler as createLaunchBackgroundWarmupTaskFromStartup,
createStartBackgroundWarmupsHandler as createStartBackgroundWarmupsFromStartup,
} from '../startup-warmups';
import type { BuiltMainDeps, ComposerInputs, ComposerOutputs } from './contracts';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type BindMpvMainEventHandlersMainDeps = Parameters<
typeof createBuildBindMpvMainEventHandlersMainDepsHandler
>[0];
type BindMpvMainEventHandlersMainDeps = Parameters<typeof createMpvMainEventBindingDeps>[0];
type BindMpvMainEventHandlers = ReturnType<typeof createBindMpvMainEventHandlersHandler>;
type BoundMpvClient = Parameters<BindMpvMainEventHandlers>[0];
type RuntimeMpvClient = BoundMpvClient & { connect: () => void };
type MpvClientRuntimeServiceFactoryMainDeps<TMpvClient extends RuntimeMpvClient> = Omit<
Parameters<
typeof createBuildMpvClientRuntimeServiceFactoryDepsHandler<
TMpvClient,
Config,
MpvClientRuntimeServiceOptions
>
>[0],
Parameters<typeof createMpvClientRuntimeServiceFactory<TMpvClient>>[0],
'bindEventHandlers'
>;
type UpdateMpvSubtitleRenderMetricsMainDeps = Parameters<
typeof createUpdateMpvSubtitleRenderMetricsHandler
>[0];
type BuildTokenizerDepsMainDeps = Parameters<typeof createBuildTokenizerDepsMainHandler>[0];
type TokenizerMainDeps = BuiltMainDeps<typeof createBuildTokenizerDepsMainHandler>;
// Tokenizer deps as main.ts supplies them: the core runtime options plus lookup recording and
// the character-dictionary gate applied in buildTokenizerRuntimeOptions.
type BuildTokenizerDepsMainDeps = TokenizerDepsRuntimeOptions & {
getJlptEnabled: NonNullable<TokenizerDepsRuntimeOptions['getJlptEnabled']>;
getCharacterDictionaryEnabled?: () => boolean;
getFrequencyDictionaryEnabled: NonNullable<
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
>;
getFrequencyDictionaryMatchMode: NonNullable<
TokenizerDepsRuntimeOptions['getFrequencyDictionaryMatchMode']
>;
getFrequencyRank: NonNullable<TokenizerDepsRuntimeOptions['getFrequencyRank']>;
getMinSentenceWordsForNPlusOne: NonNullable<
TokenizerDepsRuntimeOptions['getMinSentenceWordsForNPlusOne']
>;
getYomitanGroupDebugEnabled: NonNullable<
TokenizerDepsRuntimeOptions['getYomitanGroupDebugEnabled']
>;
recordLookup: (hit: boolean) => void;
};
type CreateMecabTokenizerAndCheckMainDeps = Parameters<
typeof createCreateMecabTokenizerAndCheckMainHandler
typeof createMecabTokenizerAndCheckHandler
>[0];
type PrewarmSubtitleDictionariesMainDeps = Parameters<
typeof createPrewarmSubtitleDictionariesMainHandler
typeof createPrewarmSubtitleDictionariesHandler
>[0];
type LaunchBackgroundWarmupTaskMainDeps = Parameters<
typeof createLaunchBackgroundWarmupTaskFromStartup
@@ -61,7 +68,7 @@ export type MpvRuntimeComposerOptions<
updateMpvSubtitleRenderMetricsMainDeps: UpdateMpvSubtitleRenderMetricsMainDeps;
tokenizer: {
buildTokenizerDepsMainDeps: BuildTokenizerDepsMainDeps;
createTokenizerRuntimeDeps: (deps: TokenizerMainDeps) => TTokenizerRuntimeDeps;
createTokenizerRuntimeDeps: (deps: TokenizerDepsRuntimeOptions) => TTokenizerRuntimeDeps;
tokenizeSubtitle: (text: string, deps: TTokenizerRuntimeDeps) => Promise<TTokenizedSubtitle>;
createMecabTokenizerAndCheckMainDeps: CreateMecabTokenizerAndCheckMainDeps;
prewarmSubtitleDictionariesMainDeps: PrewarmSubtitleDictionariesMainDeps;
@@ -88,6 +95,39 @@ export type MpvRuntimeComposerResult<
startBackgroundWarmups: ReturnType<typeof createStartBackgroundWarmupsFromStartup>;
}>;
// Adapts main's tokenizer deps to the core runtime options: every known-word lookup is
// recorded, and name matching is off while the character dictionary is disabled. Returns a
// fresh object, since the composer wraps onTokenizationReady on it.
function buildTokenizerRuntimeOptions(
deps: BuildTokenizerDepsMainDeps,
): TokenizerDepsRuntimeOptions {
const {
recordLookup,
getCharacterDictionaryEnabled,
getNameMatchEnabled,
getNameMatchImagesEnabled,
...runtimeOptions
} = deps;
const characterDictionaryEnabled = (): boolean => getCharacterDictionaryEnabled?.() !== false;
return {
...runtimeOptions,
isKnownWord: (text, reading, options) => {
const hit = deps.isKnownWord(text, reading, options);
recordLookup(hit);
return hit;
},
...(getNameMatchEnabled
? { getNameMatchEnabled: () => characterDictionaryEnabled() && getNameMatchEnabled() }
: {}),
...(getNameMatchImagesEnabled
? {
getNameMatchImagesEnabled: () =>
characterDictionaryEnabled() && getNameMatchImagesEnabled(),
}
: {}),
};
}
export function composeMpvRuntimeHandlers<
TMpvClient extends RuntimeMpvClient,
TTokenizerRuntimeDeps,
@@ -95,36 +135,23 @@ export function composeMpvRuntimeHandlers<
>(
options: MpvRuntimeComposerOptions<TMpvClient, TTokenizerRuntimeDeps, TTokenizedSubtitle>,
): MpvRuntimeComposerResult<TMpvClient, TTokenizedSubtitle> {
const bindMpvMainEventHandlersMainDeps = createBuildBindMpvMainEventHandlersMainDepsHandler(
options.bindMpvMainEventHandlersMainDeps,
)();
const bindMpvClientEventHandlers = createBindMpvMainEventHandlersHandler(
bindMpvMainEventHandlersMainDeps,
createMpvMainEventBindingDeps(options.bindMpvMainEventHandlersMainDeps),
);
const buildMpvClientRuntimeServiceFactoryMainDepsHandler =
createBuildMpvClientRuntimeServiceFactoryDepsHandler<
TMpvClient,
Config,
MpvClientRuntimeServiceOptions
>({
...options.mpvClientRuntimeServiceFactoryMainDeps,
bindEventHandlers: (client) => bindMpvClientEventHandlers(client),
});
const createMpvClientRuntimeService = (): TMpvClient =>
createMpvClientRuntimeServiceFactory(buildMpvClientRuntimeServiceFactoryMainDepsHandler())();
const createMpvClientRuntimeService = createMpvClientRuntimeServiceFactory<TMpvClient>({
...options.mpvClientRuntimeServiceFactoryMainDeps,
bindEventHandlers: (client) => bindMpvClientEventHandlers(client),
});
const updateMpvSubtitleRenderMetrics = createUpdateMpvSubtitleRenderMetricsHandler(
options.updateMpvSubtitleRenderMetricsMainDeps,
);
const buildTokenizerDepsHandler = createBuildTokenizerDepsMainHandler(
options.tokenizer.buildTokenizerDepsMainDeps,
);
const createMecabTokenizerAndCheck = createCreateMecabTokenizerAndCheckMainHandler(
const createMecabTokenizerAndCheck = createMecabTokenizerAndCheckHandler(
options.tokenizer.createMecabTokenizerAndCheckMainDeps,
);
const prewarmSubtitleDictionaries = createPrewarmSubtitleDictionariesMainHandler(
const prewarmSubtitleDictionaries = createPrewarmSubtitleDictionariesHandler(
options.tokenizer.prewarmSubtitleDictionariesMainDeps,
);
const shouldInitializeMecabForAnnotations = (): boolean => {
@@ -227,7 +254,9 @@ export function composeMpvRuntimeHandlers<
if (cachedTokenizerRuntimeDeps) {
return cachedTokenizerRuntimeDeps;
}
const tokenizerMainDeps = buildTokenizerDepsHandler();
const tokenizerMainDeps = buildTokenizerRuntimeOptions(
options.tokenizer.buildTokenizerDepsMainDeps,
);
const baseOnTokenizationReady = tokenizerMainDeps.onTokenizationReady;
tokenizerMainDeps.onTokenizationReady = (tokenizedText: string): void => {
if (!shouldWarmupAnnotationDictionaries()) {
@@ -3,22 +3,16 @@ import {
createRestoreWindowsOnActivateHandler,
createShouldRestoreWindowsOnActivateHandler,
} from '../app-lifecycle-actions';
import { createBuildOnWillQuitCleanupDepsHandler } from '../app-lifecycle-main-cleanup';
import {
createBuildRestoreWindowsOnActivateMainDepsHandler,
createBuildShouldRestoreWindowsOnActivateMainDepsHandler,
} from '../app-lifecycle-main-activate';
import { createOnWillQuitCleanupDeps } from '../app-lifecycle-main-cleanup';
import { registerProtocolUrlHandlers } from '../protocol-url-handlers';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type RegisterProtocolUrlHandlersMainDeps = Parameters<typeof registerProtocolUrlHandlers>[0];
type OnWillQuitCleanupDeps = Parameters<typeof createBuildOnWillQuitCleanupDepsHandler>[0];
type OnWillQuitCleanupDeps = Parameters<typeof createOnWillQuitCleanupDeps>[0];
type ShouldRestoreWindowsOnActivateMainDeps = Parameters<
typeof createBuildShouldRestoreWindowsOnActivateMainDepsHandler
>[0];
type RestoreWindowsOnActivateMainDeps = Parameters<
typeof createBuildRestoreWindowsOnActivateMainDepsHandler
typeof createShouldRestoreWindowsOnActivateHandler
>[0];
type RestoreWindowsOnActivateMainDeps = Parameters<typeof createRestoreWindowsOnActivateHandler>[0];
export type StartupLifecycleComposerOptions = ComposerInputs<{
registerProtocolUrlHandlersMainDeps: RegisterProtocolUrlHandlersMainDeps;
@@ -38,15 +32,13 @@ export function composeStartupLifecycleHandlers(
options: StartupLifecycleComposerOptions,
): StartupLifecycleComposerResult {
const onWillQuitCleanupHandler = createOnWillQuitCleanupHandler(
createBuildOnWillQuitCleanupDepsHandler(options.onWillQuitCleanupMainDeps)(),
createOnWillQuitCleanupDeps(options.onWillQuitCleanupMainDeps),
);
const shouldRestoreWindowsOnActivateHandler = createShouldRestoreWindowsOnActivateHandler(
createBuildShouldRestoreWindowsOnActivateMainDepsHandler(
options.shouldRestoreWindowsOnActivateMainDeps,
)(),
options.shouldRestoreWindowsOnActivateMainDeps,
);
const restoreWindowsOnActivateHandler = createRestoreWindowsOnActivateHandler(
createBuildRestoreWindowsOnActivateMainDepsHandler(options.restoreWindowsOnActivateMainDeps)(),
options.restoreWindowsOnActivateMainDeps,
);
return {
@@ -5,6 +5,7 @@ import {
buildConfigHotReloadPayload,
createConfigHotReloadAppliedHandler,
createConfigHotReloadMessageHandler,
createWatchConfigPathHandler,
} from './config-hot-reload-handlers';
test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
@@ -328,3 +329,38 @@ test('createConfigHotReloadMessageHandler routes message through configured noti
'notify:SubMiner:Config reload failed',
]);
});
test('watch config path handler watches file directly when config exists', () => {
const calls: string[] = [];
const watchConfigPath = createWatchConfigPathHandler({
fileExists: () => true,
dirname: (path) => path.split('/').slice(0, -1).join('/'),
watchPath: (targetPath, nextListener) => {
calls.push(`watch:${targetPath}`);
nextListener('change', 'ignored');
return { close: () => calls.push('close') };
},
});
const watcher = watchConfigPath('/tmp/config.jsonc', () => calls.push('change'));
watcher.close();
assert.deepEqual(calls, ['watch:/tmp/config.jsonc', 'change', 'close']);
});
test('watch config path handler filters directory events to config files only', () => {
const calls: string[] = [];
const watchConfigPath = createWatchConfigPathHandler({
fileExists: () => false,
dirname: (path) => path.split('/').slice(0, -1).join('/'),
watchPath: (_targetPath, nextListener) => {
nextListener('change', 'foo.txt');
nextListener('change', 'config.json');
nextListener('change', 'config.jsonc');
nextListener('change', null);
return { close: () => {} };
},
});
watchConfigPath('/tmp/config.jsonc', () => calls.push('change'));
assert.deepEqual(calls, ['change', 'change', 'change']);
});
@@ -228,6 +228,35 @@ export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessage
};
}
type ConfigWatchListener = (eventType: string, filename: string | null) => void;
// Watches the config file, or its directory (filtered to config.json[c]) when the file
// does not exist yet, and calls onChange for relevant events.
export function createWatchConfigPathHandler(deps: {
fileExists: (path: string) => boolean;
dirname: (path: string) => string;
watchPath: (targetPath: string, listener: ConfigWatchListener) => { close: () => void };
}) {
return (configPath: string, onChange: () => void): { close: () => void } => {
const watchTarget = deps.fileExists(configPath) ? configPath : deps.dirname(configPath);
const watcher = deps.watchPath(watchTarget, (_eventType, filename) => {
if (watchTarget === configPath) {
onChange();
return;
}
const normalized =
typeof filename === 'string' ? filename : filename ? String(filename) : undefined;
if (!normalized || normalized === 'config.json' || normalized === 'config.jsonc') {
onChange();
}
});
return {
close: () => watcher.close(),
};
};
}
export function buildRestartRequiredConfigMessage(fields: string[]): string {
return `Config updated; restart required for: ${fields.join(', ')}`;
}
@@ -1,38 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createWatchConfigPathHandler } from './config-hot-reload-main-deps';
test('watch config path handler watches file directly when config exists', () => {
const calls: string[] = [];
const watchConfigPath = createWatchConfigPathHandler({
fileExists: () => true,
dirname: (path) => path.split('/').slice(0, -1).join('/'),
watchPath: (targetPath, nextListener) => {
calls.push(`watch:${targetPath}`);
nextListener('change', 'ignored');
return { close: () => calls.push('close') };
},
});
const watcher = watchConfigPath('/tmp/config.jsonc', () => calls.push('change'));
watcher.close();
assert.deepEqual(calls, ['watch:/tmp/config.jsonc', 'change', 'close']);
});
test('watch config path handler filters directory events to config files only', () => {
const calls: string[] = [];
const watchConfigPath = createWatchConfigPathHandler({
fileExists: () => false,
dirname: (path) => path.split('/').slice(0, -1).join('/'),
watchPath: (_targetPath, nextListener) => {
nextListener('change', 'foo.txt');
nextListener('change', 'config.json');
nextListener('change', 'config.jsonc');
nextListener('change', null);
return { close: () => {} };
},
});
watchConfigPath('/tmp/config.jsonc', () => calls.push('change'));
assert.deepEqual(calls, ['change', 'change', 'change']);
});
@@ -1,134 +0,0 @@
import type {
ConfigHotReloadDiff,
ConfigHotReloadRuntimeDeps,
} from '../../core/services/config-hot-reload';
import type { ReloadConfigStrictResult } from '../../config';
import type { AnkiConnectConfig } from '../../types/anki';
import type {
ConfigHotReloadPayload,
ConfigValidationWarning,
ResolvedConfig,
SecondarySubMode,
} from '../../types';
import type { createConfigHotReloadMessageHandler } from './config-hot-reload-handlers';
type ConfigWatchListener = (eventType: string, filename: string | null) => void;
export function createWatchConfigPathHandler(deps: {
fileExists: (path: string) => boolean;
dirname: (path: string) => string;
watchPath: (targetPath: string, listener: ConfigWatchListener) => { close: () => void };
}) {
return (configPath: string, onChange: () => void): { close: () => void } => {
const watchTarget = deps.fileExists(configPath) ? configPath : deps.dirname(configPath);
const watcher = deps.watchPath(watchTarget, (_eventType, filename) => {
if (watchTarget === configPath) {
onChange();
return;
}
const normalized =
typeof filename === 'string' ? filename : filename ? String(filename) : undefined;
if (!normalized || normalized === 'config.json' || normalized === 'config.jsonc') {
onChange();
}
});
return {
close: () => watcher.close(),
};
};
}
type WatchConfigPathMainDeps = Parameters<typeof createWatchConfigPathHandler>[0];
type ConfigHotReloadMessageMainDeps = Parameters<typeof createConfigHotReloadMessageHandler>[0];
export function createBuildWatchConfigPathMainDepsHandler(deps: WatchConfigPathMainDeps) {
return (): WatchConfigPathMainDeps => ({
fileExists: (targetPath: string) => deps.fileExists(targetPath),
dirname: (targetPath: string) => deps.dirname(targetPath),
watchPath: (targetPath: string, listener: ConfigWatchListener) =>
deps.watchPath(targetPath, listener),
});
}
export function createBuildConfigHotReloadMessageMainDepsHandler(
deps: ConfigHotReloadMessageMainDeps,
) {
return (): ConfigHotReloadMessageMainDeps => ({
getNotificationType: () => deps.getNotificationType?.(),
showMpvOsd: (message: string) => deps.showMpvOsd(message),
showOverlayNotification: (payload) => deps.showOverlayNotification?.(payload),
showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options),
});
}
export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
setSessionBindings: (
sessionBindings: ConfigHotReloadPayload['sessionBindings'],
sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'],
) => void;
refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
applyAnkiRuntimeConfigPatch: (patch: Partial<AnkiConnectConfig>) => void;
invalidateTokenizationCache?: () => void;
refreshSubtitlePrefetch?: () => void;
refreshCurrentSubtitle?: () => void;
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void;
setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void;
applyAniSkipConfig?: () => void;
}) {
return () => ({
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
deps.setKeybindings(keybindings),
setSessionBindings: (
sessionBindings: ConfigHotReloadPayload['sessionBindings'],
sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'],
) => deps.setSessionBindings(sessionBindings, sessionBindingWarnings),
refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(),
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
broadcastToOverlayWindows: (channel: string, payload: unknown) =>
deps.broadcastToOverlayWindows(channel, payload),
applyAnkiRuntimeConfigPatch: (patch: Partial<AnkiConnectConfig>) =>
deps.applyAnkiRuntimeConfigPatch(patch),
invalidateTokenizationCache: () => deps.invalidateTokenizationCache?.(),
refreshSubtitlePrefetch: () => deps.refreshSubtitlePrefetch?.(),
refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(),
setLogLevel: (level: ResolvedConfig['logging']['level']) => deps.setLogLevel?.(level),
setLogRotation: (rotation: ResolvedConfig['logging']['rotation']) =>
deps.setLogRotation?.(rotation),
setLogFileToggles: (files: ResolvedConfig['logging']['files']) =>
deps.setLogFileToggles?.(files),
applyAniSkipConfig: () => deps.applyAniSkipConfig?.(),
});
}
export function createBuildConfigHotReloadRuntimeMainDepsHandler(deps: {
getCurrentConfig: () => ResolvedConfig;
reloadConfigStrict: () => ReloadConfigStrictResult;
watchConfigPath: ConfigHotReloadRuntimeDeps['watchConfigPath'];
setTimeout: (callback: () => void, delayMs: number) => NodeJS.Timeout;
clearTimeout: (timeout: NodeJS.Timeout) => void;
debounceMs: number;
onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void;
onRestartRequired: (fields: string[]) => void;
onInvalidConfig: (message: string) => void;
onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) => void;
}) {
return (): ConfigHotReloadRuntimeDeps => ({
getCurrentConfig: () => deps.getCurrentConfig(),
reloadConfigStrict: () => deps.reloadConfigStrict(),
watchConfigPath: (configPath, onChange) => deps.watchConfigPath(configPath, onChange),
setTimeout: (callback: () => void, delayMs: number) => deps.setTimeout(callback, delayMs),
clearTimeout: (timeout: NodeJS.Timeout) => deps.clearTimeout(timeout),
debounceMs: deps.debounceMs,
onHotReloadApplied: (diff, config) => deps.onHotReloadApplied(diff, config),
onRestartRequired: (fields: string[]) => deps.onRestartRequired(fields),
onInvalidConfig: (message: string) => deps.onInvalidConfig(message),
onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) =>
deps.onValidationWarnings(configPath, warnings),
});
}
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildDictionaryRootsHandler } from './dictionary-roots';
const darwinDeps = {
platform: 'darwin',
dirname: '/repo/dist/main',
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
userDataPath: '/Users/a/.config/SubMiner',
appUserDataPath: '/Users/a/Library/Application Support/SubMiner',
homeDir: '/Users/a',
cwd: '/repo',
joinPath: (...parts: string[]) => parts.join('/'),
} as const;
const win32Deps = {
platform: 'win32',
dirname: 'C:\\repo\\dist\\main',
appPath: 'C:\\Program Files\\SubMiner\\resources\\app.asar',
resourcesPath: 'C:\\Program Files\\SubMiner\\resources',
userDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
appUserDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
homeDir: 'C:\\Users\\a',
appDataDir: 'C:\\Users\\a\\AppData\\Roaming',
cwd: 'C:\\repo',
joinPath: (...parts: string[]) => parts.join('\\'),
} as const;
for (const vendorDirName of ['yomitan-jlpt-vocab', 'frequency-dictionary']) {
test(`dictionary roots for ${vendorDirName} list vendor, user data, platform and cwd roots`, () => {
const roots = createBuildDictionaryRootsHandler({ ...darwinDeps, vendorDirName })();
assert.equal(roots.length, 11);
assert.equal(roots[0], `/repo/dist/main/../../vendor/${vendorDirName}`);
assert.equal(roots[2], `/Applications/SubMiner.app/Contents/Resources/${vendorDirName}`);
assert.equal(roots[10], '/repo');
});
}
test('dictionary roots use APPDATA-style roots on windows', () => {
const roots = createBuildDictionaryRootsHandler({
...win32Deps,
vendorDirName: 'yomitan-jlpt-vocab',
})();
assert.equal(roots.includes('C:\\Users\\a\\.config\\SubMiner'), false);
assert.equal(roots.includes('C:\\Users\\a\\AppData\\Roaming\\SubMiner'), true);
});
+47
View File
@@ -0,0 +1,47 @@
// Candidate root directories searched for a bundled dictionary (JLPT vocab, frequency, ...).
// `vendorDirName` is the dictionary's folder under `vendor/` in dev and in packaged resources.
export function createBuildDictionaryRootsHandler(deps: {
vendorDirName: string;
platform: NodeJS.Platform;
dirname: string;
appPath: string;
resourcesPath: string;
userDataPath: string;
appUserDataPath: string;
homeDir: string;
appDataDir?: string;
cwd: string;
joinPath: (...parts: string[]) => string;
}) {
return (): string[] => {
const platformRoots =
deps.platform === 'win32'
? [
deps.joinPath(
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
'SubMiner',
),
deps.joinPath(
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
'subminer',
),
]
: [
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
deps.joinPath(deps.homeDir, '.config', 'subminer'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
];
return [
deps.joinPath(deps.dirname, '..', '..', 'vendor', deps.vendorDirName),
deps.joinPath(deps.appPath, 'vendor', deps.vendorDirName),
deps.joinPath(deps.resourcesPath, deps.vendorDirName),
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', deps.vendorDirName),
deps.userDataPath,
deps.appUserDataPath,
...platformRoots,
deps.cwd,
];
};
}
@@ -1,78 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildDictionaryRootsMainHandler,
createBuildFrequencyDictionaryRootsMainHandler,
} from './dictionary-runtime-main-deps';
test('dictionary roots main handler returns expected root list', () => {
const roots = createBuildDictionaryRootsMainHandler({
platform: 'darwin',
dirname: '/repo/dist/main',
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
userDataPath: '/Users/a/.config/SubMiner',
appUserDataPath: '/Users/a/Library/Application Support/SubMiner',
homeDir: '/Users/a',
cwd: '/repo',
joinPath: (...parts) => parts.join('/'),
})();
assert.equal(roots.length, 11);
assert.equal(roots[0], '/repo/dist/main/../../vendor/yomitan-jlpt-vocab');
assert.equal(roots[10], '/repo');
});
test('frequency dictionary roots main handler returns expected root list', () => {
const roots = createBuildFrequencyDictionaryRootsMainHandler({
platform: 'darwin',
dirname: '/repo/dist/main',
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
userDataPath: '/Users/a/.config/SubMiner',
appUserDataPath: '/Users/a/Library/Application Support/SubMiner',
homeDir: '/Users/a',
cwd: '/repo',
joinPath: (...parts) => parts.join('/'),
})();
assert.equal(roots.length, 11);
assert.equal(roots[0], '/repo/dist/main/../../vendor/frequency-dictionary');
assert.equal(roots[10], '/repo');
});
test('dictionary roots main handler uses APPDATA-style roots on windows', () => {
const roots = createBuildDictionaryRootsMainHandler({
platform: 'win32',
dirname: 'C:\\repo\\dist\\main',
appPath: 'C:\\Program Files\\SubMiner\\resources\\app.asar',
resourcesPath: 'C:\\Program Files\\SubMiner\\resources',
userDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
appUserDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
homeDir: 'C:\\Users\\a',
appDataDir: 'C:\\Users\\a\\AppData\\Roaming',
cwd: 'C:\\repo',
joinPath: (...parts) => parts.join('\\'),
})();
assert.equal(roots.includes('C:\\Users\\a\\.config\\SubMiner'), false);
assert.equal(roots.includes('C:\\Users\\a\\AppData\\Roaming\\SubMiner'), true);
});
test('frequency dictionary roots main handler uses APPDATA-style roots on windows', () => {
const roots = createBuildFrequencyDictionaryRootsMainHandler({
platform: 'win32',
dirname: 'C:\\repo\\dist\\main',
appPath: 'C:\\Program Files\\SubMiner\\resources\\app.asar',
resourcesPath: 'C:\\Program Files\\SubMiner\\resources',
userDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
appUserDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
homeDir: 'C:\\Users\\a',
appDataDir: 'C:\\Users\\a\\AppData\\Roaming',
cwd: 'C:\\repo',
joinPath: (...parts) => parts.join('\\'),
})();
assert.equal(roots.includes('C:\\Users\\a\\.config\\SubMiner'), false);
assert.equal(roots.includes('C:\\Users\\a\\AppData\\Roaming\\SubMiner'), true);
});
@@ -1,136 +0,0 @@
import type { FrequencyDictionaryLookup, JlptLevel } from '../../types';
type JlptLookup = (term: string) => JlptLevel | null;
export function createBuildDictionaryRootsMainHandler(deps: {
platform: NodeJS.Platform;
dirname: string;
appPath: string;
resourcesPath: string;
userDataPath: string;
appUserDataPath: string;
homeDir: string;
appDataDir?: string;
cwd: string;
joinPath: (...parts: string[]) => string;
}) {
return () => {
const platformRoots =
deps.platform === 'win32'
? [
deps.joinPath(
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
'SubMiner',
),
deps.joinPath(
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
'subminer',
),
]
: [
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
deps.joinPath(deps.homeDir, '.config', 'subminer'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
];
return [
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'yomitan-jlpt-vocab'),
deps.joinPath(deps.appPath, 'vendor', 'yomitan-jlpt-vocab'),
deps.joinPath(deps.resourcesPath, 'yomitan-jlpt-vocab'),
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'yomitan-jlpt-vocab'),
deps.userDataPath,
deps.appUserDataPath,
...platformRoots,
deps.cwd,
];
};
}
export function createBuildFrequencyDictionaryRootsMainHandler(deps: {
platform: NodeJS.Platform;
dirname: string;
appPath: string;
resourcesPath: string;
userDataPath: string;
appUserDataPath: string;
homeDir: string;
appDataDir?: string;
cwd: string;
joinPath: (...parts: string[]) => string;
}) {
return () => {
const platformRoots =
deps.platform === 'win32'
? [
deps.joinPath(
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
'SubMiner',
),
deps.joinPath(
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
'subminer',
),
]
: [
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
deps.joinPath(deps.homeDir, '.config', 'subminer'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
];
return [
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'frequency-dictionary'),
deps.joinPath(deps.appPath, 'vendor', 'frequency-dictionary'),
deps.joinPath(deps.resourcesPath, 'frequency-dictionary'),
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'frequency-dictionary'),
deps.userDataPath,
deps.appUserDataPath,
...platformRoots,
deps.cwd,
];
};
}
export function createBuildJlptDictionaryRuntimeMainDepsHandler(deps: {
isJlptEnabled: () => boolean;
getDictionaryRoots: () => string[];
getJlptDictionarySearchPaths: (deps: { getDictionaryRoots: () => string[] }) => string[];
setJlptLevelLookup: (lookup: JlptLookup) => void;
logInfo: (message: string) => void;
}) {
return () => ({
isJlptEnabled: () => deps.isJlptEnabled(),
getSearchPaths: () =>
deps.getJlptDictionarySearchPaths({
getDictionaryRoots: () => deps.getDictionaryRoots(),
}),
setJlptLevelLookup: (lookup: JlptLookup) => deps.setJlptLevelLookup(lookup),
log: (message: string) => deps.logInfo(`[JLPT] ${message}`),
});
}
export function createBuildFrequencyDictionaryRuntimeMainDepsHandler(deps: {
isFrequencyDictionaryEnabled: () => boolean;
getDictionaryRoots: () => string[];
getFrequencyDictionarySearchPaths: (deps: {
getDictionaryRoots: () => string[];
getSourcePath: () => string | undefined;
}) => string[];
getSourcePath: () => string | undefined;
setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void;
logInfo: (message: string) => void;
}) {
return () => ({
isFrequencyDictionaryEnabled: () => deps.isFrequencyDictionaryEnabled(),
getSearchPaths: () =>
deps.getFrequencyDictionarySearchPaths({
getDictionaryRoots: () =>
deps.getDictionaryRoots().filter((dictionaryRoot) => dictionaryRoot),
getSourcePath: () => deps.getSourcePath(),
}),
setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) =>
deps.setFrequencyRankLookup(lookup),
log: (message: string) => deps.logInfo(`[Frequency] ${message}`),
});
}
-3
View File
@@ -3,16 +3,13 @@ export * from '../jellyfin-cli-list';
export * from '../jellyfin-cli-play';
export * from '../jellyfin-cli-remote-announce';
export * from '../jellyfin-client-info';
export * from '../jellyfin-client-info-main-deps';
export * from '../jellyfin-command-dispatch';
export * from '../jellyfin-device-identity';
export * from '../jellyfin-playback-launch';
export * from '../jellyfin-remote-commands';
export * from '../jellyfin-remote-connection';
export * from '../jellyfin-remote-main-deps';
export * from '../jellyfin-remote-playback';
export * from '../jellyfin-remote-session-lifecycle';
export * from '../jellyfin-setup-window';
export * from '../jellyfin-setup-window-main-deps';
export * from '../jellyfin-subtitle-preload';
export * from '../mpv-jellyfin-defaults';
+2 -3
View File
@@ -1,8 +1,7 @@
export * from '../mpv-client-runtime-service';
export * from '../mpv-client-runtime-service-main-deps';
export * from '../mpv-main-event-bindings';
export * from '../mpv-main-event-main-deps';
export * from '../mpv-main-event-binding-deps';
export * from '../mpv-osd-runtime-handlers';
export * from '../mpv-subtitle-render-metrics';
export * from '../secondary-sub-mode-runtime-handler';
export * from '../subtitle-tokenization-main-deps';
export * from '../subtitle-tokenization-warmup';
-3
View File
@@ -1,13 +1,10 @@
export * from '../config-hot-reload-handlers';
export * from '../config-hot-reload-main-deps';
export * from '../field-grouping-resolver';
export * from '../overlay-main-actions';
export * from '../overlay-runtime-bootstrap-handlers';
export * from '../overlay-runtime-main-actions';
export * from '../overlay-visibility-runtime';
export * from '../overlay-visibility-runtime-main-deps';
export * from '../overlay-window-layout';
export * from '../overlay-window-layout-main-deps';
export * from '../overlay-window-runtime-handlers';
export * from '../subtitle-position';
export * from '../tray-runtime';
-1
View File
@@ -1,4 +1,3 @@
export * from '../global-shortcuts-runtime-handlers';
export * from '../numeric-shortcut-session-runtime-handlers';
export * from '../overlay-shortcuts-runtime-handlers';
export * from '../overlay-shortcuts-runtime-main-deps';
+1 -2
View File
@@ -1,9 +1,8 @@
export * from '../app-lifecycle-actions';
export * from '../app-lifecycle-main-activate';
export * from '../app-lifecycle-main-cleanup';
export * from '../clipboard-queue';
export * from '../config-derived';
export * from '../dictionary-runtime-main-deps';
export * from '../dictionary-roots';
export * from '../immersion-media';
export * from '../immersion-startup';
export * from '../startup-config';
@@ -1,30 +0,0 @@
import type {
createGetJellyfinClientInfoHandler,
createGetResolvedJellyfinConfigHandler,
} from './jellyfin-client-info';
type GetResolvedJellyfinConfigMainDeps = Parameters<
typeof createGetResolvedJellyfinConfigHandler
>[0];
type GetJellyfinClientInfoMainDeps = Parameters<typeof createGetJellyfinClientInfoHandler>[0];
export function createBuildGetResolvedJellyfinConfigMainDepsHandler(
deps: GetResolvedJellyfinConfigMainDeps,
) {
return (): GetResolvedJellyfinConfigMainDeps => ({
getResolvedConfig: () => deps.getResolvedConfig(),
loadStoredSession: () => deps.loadStoredSession(),
getEnv: (name: string) => deps.getEnv(name),
});
}
export function createBuildGetJellyfinClientInfoMainDepsHandler(
deps: GetJellyfinClientInfoMainDeps,
) {
return (): GetJellyfinClientInfoMainDeps => ({
getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(),
getHostName: deps.getHostName ? () => deps.getHostName?.() || '' : undefined,
defaultClientName: deps.defaultClientName,
defaultClientVersion: deps.defaultClientVersion,
});
}
@@ -1,78 +0,0 @@
import type {
JellyfinRemoteGeneralCommandHandlerDeps,
JellyfinRemotePlayHandlerDeps,
JellyfinRemotePlaystateHandlerDeps,
} from './jellyfin-remote-commands';
import type {
JellyfinRemoteProgressReporterDeps,
JellyfinRemoteStoppedReporterDeps,
} from './jellyfin-remote-playback';
export function createBuildHandleJellyfinRemotePlayMainDepsHandler(
deps: JellyfinRemotePlayHandlerDeps,
) {
return (): JellyfinRemotePlayHandlerDeps => ({
getConfiguredSession: () => deps.getConfiguredSession(),
getClientInfo: () => deps.getClientInfo(),
getJellyfinConfig: () => deps.getJellyfinConfig(),
...(deps.getActivePlayback
? { getActivePlayback: () => deps.getActivePlayback?.() ?? null }
: {}),
playJellyfinItem: (params) => deps.playJellyfinItem(params),
logWarn: (message: string) => deps.logWarn(message),
});
}
export function createBuildHandleJellyfinRemotePlaystateMainDepsHandler(
deps: JellyfinRemotePlaystateHandlerDeps,
) {
return (): JellyfinRemotePlaystateHandlerDeps => ({
getMpvClient: () => deps.getMpvClient(),
sendMpvCommand: (client, command) => deps.sendMpvCommand(client, command),
reportJellyfinRemoteProgress: (force: boolean) => deps.reportJellyfinRemoteProgress(force),
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
jellyfinTicksToSeconds: (ticks: number) => deps.jellyfinTicksToSeconds(ticks),
});
}
export function createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler(
deps: JellyfinRemoteGeneralCommandHandlerDeps,
) {
return (): JellyfinRemoteGeneralCommandHandlerDeps => ({
getMpvClient: () => deps.getMpvClient(),
sendMpvCommand: (client, command) => deps.sendMpvCommand(client, command),
getActivePlayback: () => deps.getActivePlayback(),
reportJellyfinRemoteProgress: (force: boolean) => deps.reportJellyfinRemoteProgress(force),
logDebug: (message: string) => deps.logDebug(message),
});
}
export function createBuildReportJellyfinRemoteProgressMainDepsHandler(
deps: JellyfinRemoteProgressReporterDeps,
) {
return (): JellyfinRemoteProgressReporterDeps => ({
getActivePlayback: () => deps.getActivePlayback(),
clearActivePlayback: () => deps.clearActivePlayback(),
getMpvClient: () => deps.getMpvClient(),
getNow: () => deps.getNow(),
getLastProgressAtMs: () => deps.getLastProgressAtMs(),
setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value),
progressIntervalMs: deps.progressIntervalMs,
ticksPerSecond: deps.ticksPerSecond,
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
});
}
export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
deps: JellyfinRemoteStoppedReporterDeps,
) {
return (): JellyfinRemoteStoppedReporterDeps => ({
getActivePlayback: () => deps.getActivePlayback(),
clearActivePlayback: () => deps.clearActivePlayback(),
getMpvClient: () => deps.getMpvClient(),
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
ticksPerSecond: deps.ticksPerSecond,
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
...(deps.logWarn ? { logWarn: (message: string) => deps.logWarn?.(message) } : {}),
});
}
@@ -1,39 +0,0 @@
import type { createOpenJellyfinSetupWindowHandler } from './jellyfin-setup-window';
type OpenJellyfinSetupWindowMainDeps = Parameters<typeof createOpenJellyfinSetupWindowHandler>[0];
export function createBuildOpenJellyfinSetupWindowMainDepsHandler(
deps: OpenJellyfinSetupWindowMainDeps,
) {
return (): OpenJellyfinSetupWindowMainDeps => ({
maybeFocusExistingSetupWindow: () => deps.maybeFocusExistingSetupWindow(),
createSetupWindow: () => deps.createSetupWindow(),
getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(),
buildSetupFormHtml: (state) => deps.buildSetupFormHtml(state),
parseSubmissionUrl: (rawUrl: string) => deps.parseSubmissionUrl(rawUrl),
authenticateWithPassword: (server: string, username: string, password: string, clientInfo) =>
deps.authenticateWithPassword(server, username, password, clientInfo),
getJellyfinClientInfo: () => deps.getJellyfinClientInfo(),
saveStoredSession: (session) => deps.saveStoredSession(session),
clearStoredSession: () => deps.clearStoredSession(),
patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session),
persistAuthenticatedSession: deps.persistAuthenticatedSession
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
: undefined,
restartRemoteSession: deps.restartRemoteSession
? () => deps.restartRemoteSession?.()
: undefined,
stopRemoteSession: deps.stopRemoteSession ? () => deps.stopRemoteSession?.() : undefined,
logInfo: (message: string) => deps.logInfo(message),
logError: (message: string, error: unknown) => deps.logError(message, error),
showMpvOsd: (message: string) => deps.showMpvOsd(message),
clearSetupWindow: () => deps.clearSetupWindow(),
setSetupWindow: (window) => deps.setSetupWindow(window),
registerSetupIpcHandler: deps.registerSetupIpcHandler
? (handler) => deps.registerSetupIpcHandler?.(handler) ?? (() => undefined)
: undefined,
encodeURIComponent: (value: string) => deps.encodeURIComponent(value),
defaultServerUrl: deps.defaultServerUrl,
hasStoredSession: () => deps.hasStoredSession(),
});
}
@@ -1,38 +0,0 @@
export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
TClient,
TResolvedConfig,
TOptions,
>(deps: {
createClient: new (socketPath: string, options: TOptions) => TClient;
getSocketPath: () => string;
getResolvedConfig: () => TResolvedConfig;
isAutoStartOverlayEnabled: () => boolean;
setOverlayVisible: (visible: boolean) => void;
isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
shouldQuitOnMpvShutdown?: () => boolean;
requestAppQuit?: () => void;
bindEventHandlers: (client: TClient) => void;
}) {
return () => ({
createClient: deps.createClient,
socketPath: deps.getSocketPath(),
options: {
getResolvedConfig: () => deps.getResolvedConfig(),
autoStartOverlay: deps.isAutoStartOverlayEnabled(),
setOverlayVisible: (visible: boolean) => deps.setOverlayVisible(visible),
isVisibleOverlayVisible: () => deps.isVisibleOverlayVisible(),
getReconnectTimer: () => deps.getReconnectTimer(),
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) =>
deps.setReconnectTimer(timer),
shouldAutoLoadSecondarySubTrack: deps.shouldAutoLoadSecondarySubTrack
? (path: string) => deps.shouldAutoLoadSecondarySubTrack?.(path) ?? true
: undefined,
shouldQuitOnMpvShutdown: () => deps.shouldQuitOnMpvShutdown?.() ?? false,
requestAppQuit: () => deps.requestAppQuit?.(),
},
bindEventHandlers: (client: TClient) => deps.bindEventHandlers(client),
});
}
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_CONFIG } from '../../config';
import {
createMpvClientRuntimeServiceFactory,
type MpvClientRuntimeServiceOptions,
} from './mpv-client-runtime-service';
test('mpv client factory reads socket path and auto-start per client, binds before connect', () => {
const calls: string[] = [];
let socketPath = '/tmp/a.sock';
let autoStart = false;
class FakeClient {
constructor(
readonly socketPath: string,
readonly options: MpvClientRuntimeServiceOptions,
) {}
connect() {
calls.push(`connect:${this.socketPath}`);
}
}
const createClient = createMpvClientRuntimeServiceFactory({
createClient: FakeClient,
getSocketPath: () => socketPath,
getResolvedConfig: () => DEFAULT_CONFIG,
isAutoStartOverlayEnabled: () => autoStart,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
bindEventHandlers: (client) => calls.push(`bind:${client.socketPath}`),
});
const first = createClient();
socketPath = '/tmp/b.sock';
autoStart = true;
const second = createClient();
assert.deepEqual(calls, [
'bind:/tmp/a.sock',
'connect:/tmp/a.sock',
'bind:/tmp/b.sock',
'connect:/tmp/b.sock',
]);
assert.equal(first.options.autoStartOverlay, false);
assert.equal(second.options.autoStartOverlay, true);
assert.equal(first.options.shouldQuitOnMpvShutdown?.(), false);
});
+26 -13
View File
@@ -16,22 +16,35 @@ type MpvClientLike = {
connect: () => void;
};
type MpvClientCtor<
TClient extends MpvClientLike,
TOptions extends MpvClientRuntimeServiceOptions,
> = new (socketPath: string, options: TOptions) => TClient;
export function createMpvClientRuntimeServiceFactory<
TClient extends MpvClientLike,
TOptions extends MpvClientRuntimeServiceOptions,
>(deps: {
createClient: MpvClientCtor<TClient, TOptions>;
socketPath: string;
options: TOptions;
// Creates, binds and connects a new mpv client. Socket path and auto-start state are read
// on each call, so a reconnect picks up the current values.
export function createMpvClientRuntimeServiceFactory<TClient extends MpvClientLike>(deps: {
createClient: new (socketPath: string, options: MpvClientRuntimeServiceOptions) => TClient;
getSocketPath: () => string;
getResolvedConfig: () => Config;
isAutoStartOverlayEnabled: () => boolean;
setOverlayVisible: (visible: boolean) => void;
isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
shouldQuitOnMpvShutdown?: () => boolean;
requestAppQuit?: () => void;
bindEventHandlers: (client: TClient) => void;
}) {
return (): TClient => {
const mpvClient = new deps.createClient(deps.socketPath, deps.options);
const socketPath = deps.getSocketPath();
const mpvClient = new deps.createClient(socketPath, {
getResolvedConfig: () => deps.getResolvedConfig(),
autoStartOverlay: deps.isAutoStartOverlayEnabled(),
setOverlayVisible: (visible) => deps.setOverlayVisible(visible),
isVisibleOverlayVisible: () => deps.isVisibleOverlayVisible(),
getReconnectTimer: () => deps.getReconnectTimer(),
setReconnectTimer: (timer) => deps.setReconnectTimer(timer),
shouldAutoLoadSecondarySubTrack: deps.shouldAutoLoadSecondarySubTrack,
shouldQuitOnMpvShutdown: () => deps.shouldQuitOnMpvShutdown?.() ?? false,
requestAppQuit: () => deps.requestAppQuit?.(),
});
deps.bindEventHandlers(mpvClient);
mpvClient.connect();
return mpvClient;
@@ -1,9 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseAssCues } from '../../core/services/subtitle-cue-parser';
import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './mpv-main-event-main-deps';
import { createMpvMainEventBindingDeps } from './mpv-main-event-binding-deps';
type MainDeps = Parameters<typeof createBuildBindMpvMainEventHandlersMainDepsHandler>[0];
type MainDeps = Parameters<typeof createMpvMainEventBindingDeps>[0];
/**
* Builds the handlers with inert deps. `appStateOverrides` is merged over a blank app
@@ -24,7 +24,7 @@ function buildMainDeps(
previousSecondarySubVisibility: false,
...appStateOverrides,
};
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
const handlers = createMpvMainEventBindingDeps({
appState,
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
@@ -49,7 +49,7 @@ function buildMainDeps(
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
...depsOverrides,
})();
});
return { handlers, appState };
}
@@ -11,7 +11,9 @@ type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
// Adapts main-process state to the mpv main-event bindings and owns the subtitle-recording
// state (dedup gates, recorded cue keys, session epoch) that those bindings share.
export function createMpvMainEventBindingDeps(deps: {
appState: {
initialArgs?: {
jellyfinPlay?: unknown;
@@ -149,7 +151,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
deps.appState.initialArgs?.youtubePlay,
);
return () => ({
return {
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
onMpvConnected: deps.onMpvConnected ? () => deps.onMpvConnected!() : undefined,
@@ -407,5 +409,5 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
setPreviousSecondarySubVisibility: (visible: boolean) => {
deps.appState.previousSecondarySubVisibility = visible;
},
});
};
}
+62 -83
View File
@@ -20,11 +20,6 @@ import {
createUpdateVisibleOverlayBoundsHandler,
hasLiveOverlayWindowBoundsMismatch,
} from './overlay-window-layout';
import {
createBuildEnforceOverlayLayerOrderMainDepsHandler,
createBuildEnsureOverlayWindowLevelMainDepsHandler,
createBuildUpdateVisibleOverlayBoundsMainDepsHandler,
} from './overlay-window-layout-main-deps';
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './settings-window-z-order';
const LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS = 1_200;
@@ -147,68 +142,57 @@ export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) {
});
}
const buildUpdateVisibleOverlayBoundsMainDepsHandler =
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
getCurrentOverlayWindowBounds: () => lastOverlayWindowGeometry,
shouldRefreshUnchangedGeometry: (geometry) =>
shouldExitLinuxFullscreenOverrideForGeometry(geometry) ||
(process.platform === 'linux' &&
(hasLiveOverlayWindowBoundsMismatch(
[overlayManager.getMainWindow(), overlayManager.getModalWindow()],
geometry,
) ||
hasHyprlandOverlayWindowPlacementMismatch(geometry))),
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
afterSetOverlayWindowBounds: () => {
if (!overlayManager.getVisibleOverlayVisible()) {
return;
}
if (process.platform === 'win32') {
deps.scheduleWindowsVisibleOverlayZOrderSyncBurst();
return;
}
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
if (process.platform === 'linux') {
restoreLinuxOverlayWindowShape(mainWindow);
}
ensureOverlayWindowLevel(mainWindow);
},
});
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
updateVisibleOverlayBoundsMainDeps,
);
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler({
getCurrentOverlayWindowBounds: () => lastOverlayWindowGeometry,
shouldRefreshUnchangedGeometry: (geometry) =>
shouldExitLinuxFullscreenOverrideForGeometry(geometry) ||
(process.platform === 'linux' &&
(hasLiveOverlayWindowBoundsMismatch(
[overlayManager.getMainWindow(), overlayManager.getModalWindow()],
geometry,
) ||
hasHyprlandOverlayWindowPlacementMismatch(geometry))),
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
afterSetOverlayWindowBounds: () => {
if (!overlayManager.getVisibleOverlayVisible()) {
return;
}
if (process.platform === 'win32') {
deps.scheduleWindowsVisibleOverlayZOrderSyncBurst();
return;
}
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
if (process.platform === 'linux') {
restoreLinuxOverlayWindowShape(mainWindow);
}
ensureOverlayWindowLevel(mainWindow);
},
});
const buildEnsureOverlayWindowLevelMainDepsHandler =
createBuildEnsureOverlayWindowLevelMainDepsHandler({
shouldSuppressOverlayWindowLevel: (window) => {
const mainWindow = overlayManager.getMainWindow();
return (
(deps.getStatsOverlayVisible() && window === mainWindow) ||
shouldSuppressVisibleOverlayRaiseForSeparateWindow({
window,
mainWindow,
separateWindows: deps.getOverlayForegroundSeparateWindows(),
})
);
},
ensureOverlayWindowLevelCore: (window) =>
ensureOverlayWindowLevelCore(window as BrowserWindow),
afterEnsureOverlayWindowLevel: () => {
const mainWindow = overlayManager.getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
moveVisibleOverlayAboveTrackedPlaybackWindow(mainWindow);
}
promoteStatsOverlayAbovePlayback();
},
});
const ensureOverlayWindowLevelMainDeps = buildEnsureOverlayWindowLevelMainDepsHandler();
const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler(
ensureOverlayWindowLevelMainDeps,
);
const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler({
shouldSuppressOverlayWindowLevel: (window) => {
const mainWindow = overlayManager.getMainWindow();
return (
(deps.getStatsOverlayVisible() && window === mainWindow) ||
shouldSuppressVisibleOverlayRaiseForSeparateWindow({
window,
mainWindow,
separateWindows: deps.getOverlayForegroundSeparateWindows(),
})
);
},
ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow),
afterEnsureOverlayWindowLevel: () => {
const mainWindow = overlayManager.getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
moveVisibleOverlayAboveTrackedPlaybackWindow(mainWindow);
}
promoteStatsOverlayAbovePlayback();
},
});
function syncPrimaryOverlayWindowLayer(layer: 'visible'): void {
const mainWindow = overlayManager.getMainWindow();
@@ -276,23 +260,18 @@ export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) {
);
}
const buildEnforceOverlayLayerOrderMainDepsHandler =
createBuildEnforceOverlayLayerOrderMainDepsHandler({
enforceOverlayLayerOrderCore: (params) =>
enforceOverlayLayerOrderCore({
visibleOverlayVisible: params.visibleOverlayVisible,
mainWindow: params.mainWindow as BrowserWindow | null,
ensureOverlayWindowLevel: (window) =>
params.ensureOverlayWindowLevel(window as BrowserWindow),
}),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow),
});
const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler();
const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler(
enforceOverlayLayerOrderMainDeps,
);
const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler({
enforceOverlayLayerOrderCore: (params) =>
enforceOverlayLayerOrderCore({
visibleOverlayVisible: params.visibleOverlayVisible,
mainWindow: params.mainWindow as BrowserWindow | null,
ensureOverlayWindowLevel: (window) =>
params.ensureOverlayWindowLevel(window as BrowserWindow),
}),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow),
});
return {
getLastOverlayWindowGeometry: () => lastOverlayWindowGeometry,
@@ -1,35 +1,21 @@
import { createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler } from './app-runtime-main-deps';
import { createInitializeOverlayRuntimeHandler } from './overlay-runtime-bootstrap';
import { createBuildInitializeOverlayRuntimeOptionsHandler } from './overlay-runtime-options';
import { createBuildInitializeOverlayRuntimeMainDepsHandler } from './overlay-runtime-options-main-deps';
type InitializeOverlayRuntimeMainDeps = Parameters<
typeof createBuildInitializeOverlayRuntimeMainDepsHandler
>[0];
type InitializeOverlayRuntimeOptions = ReturnType<
ReturnType<typeof createBuildInitializeOverlayRuntimeOptionsHandler>
>;
type InitializeOverlayRuntimeBootstrapMainDeps = Parameters<
typeof createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<InitializeOverlayRuntimeOptions>
>[0];
type InitializeOverlayRuntimeDeps = Parameters<typeof createInitializeOverlayRuntimeHandler>[0];
export function createOverlayRuntimeBootstrapHandlers(deps: {
initializeOverlayRuntimeMainDeps: InitializeOverlayRuntimeMainDeps;
initializeOverlayRuntimeBootstrapDeps: Omit<
InitializeOverlayRuntimeBootstrapMainDeps,
'buildOptions'
>;
initializeOverlayRuntimeMainDeps: Parameters<
typeof createBuildInitializeOverlayRuntimeOptionsHandler
>[0];
initializeOverlayRuntimeBootstrapDeps: Omit<InitializeOverlayRuntimeDeps, 'buildOptions'>;
}) {
const buildInitializeOverlayRuntimeOptionsHandler =
createBuildInitializeOverlayRuntimeOptionsHandler(
createBuildInitializeOverlayRuntimeMainDepsHandler(deps.initializeOverlayRuntimeMainDeps)(),
);
const initializeOverlayRuntime = createInitializeOverlayRuntimeHandler(
createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler({
...deps.initializeOverlayRuntimeBootstrapDeps,
buildOptions: () => buildInitializeOverlayRuntimeOptionsHandler(),
})(),
const buildInitializeOverlayRuntimeOptions = createBuildInitializeOverlayRuntimeOptionsHandler(
deps.initializeOverlayRuntimeMainDeps,
);
const initializeOverlayRuntime = createInitializeOverlayRuntimeHandler({
...deps.initializeOverlayRuntimeBootstrapDeps,
buildOptions: () => buildInitializeOverlayRuntimeOptions(),
});
return {
initializeOverlayRuntime,
@@ -1,96 +0,0 @@
import type { AnkiConnectConfig } from '../../types';
import type { OverlayNotificationPayload } from '../../types/notification';
import type { createBuildInitializeOverlayRuntimeOptionsHandler } from './overlay-runtime-options';
type OverlayRuntimeOptionsMainDeps = Parameters<
typeof createBuildInitializeOverlayRuntimeOptionsHandler
>[0];
export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
appState: {
backendOverride: string | null;
windowTracker: Parameters<OverlayRuntimeOptionsMainDeps['setWindowTracker']>[0];
subtitleTimingTracker: ReturnType<OverlayRuntimeOptionsMainDeps['getSubtitleTimingTracker']>;
mpvClient: ReturnType<OverlayRuntimeOptionsMainDeps['getMpvClient']>;
mpvSocketPath: string;
runtimeOptionsManager: ReturnType<OverlayRuntimeOptionsMainDeps['getRuntimeOptionsManager']>;
ankiIntegration: Parameters<OverlayRuntimeOptionsMainDeps['setAnkiIntegration']>[0];
};
overlayManager: {
getVisibleOverlayVisible: () => boolean;
};
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => void;
};
refreshCurrentSubtitle?: () => void;
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => void;
};
createMainWindow: () => void;
registerGlobalShortcuts: () => void;
updateVisibleOverlayBounds: (geometry: {
x: number;
y: number;
width: number;
height: number;
}) => void;
getOverlayWindows: OverlayRuntimeOptionsMainDeps['getOverlayWindows'];
createWindowTracker?: OverlayRuntimeOptionsMainDeps['createWindowTracker'];
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath'];
shouldRequireRemoteMediaCache?: OverlayRuntimeOptionsMainDeps['shouldRequireRemoteMediaCache'];
getYoutubeMediaSourceUrl?: OverlayRuntimeOptionsMainDeps['getYoutubeMediaSourceUrl'];
shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
}) {
return (): OverlayRuntimeOptionsMainDeps => ({
getBackendOverride: () => deps.appState.backendOverride,
createMainWindow: () => deps.createMainWindow(),
registerGlobalShortcuts: () => deps.registerGlobalShortcuts(),
updateVisibleOverlayBounds: (geometry: {
x: number;
y: number;
width: number;
height: number;
}) => deps.updateVisibleOverlayBounds(geometry),
isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
updateVisibleOverlayVisibility: () =>
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(),
getOverlayWindows: () => deps.getOverlayWindows(),
syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(),
setWindowTracker: (tracker) => {
deps.appState.windowTracker = tracker;
},
createWindowTracker: deps.createWindowTracker,
getResolvedConfig: () => deps.getResolvedConfig(),
getSubtitleTimingTracker: () => deps.appState.subtitleTimingTracker,
getMpvClient: () => deps.appState.mpvClient,
getMpvSocketPath: () => deps.appState.mpvSocketPath,
getRuntimeOptionsManager: () => deps.appState.runtimeOptionsManager,
setAnkiIntegration: (integration) => {
deps.appState.ankiIntegration = integration;
},
showDesktopNotification: deps.showDesktopNotification,
showOverlayNotification: deps.showOverlayNotification,
dismissOverlayNotification: deps.dismissOverlayNotification,
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
...(deps.shouldRequireRemoteMediaCache
? { shouldRequireRemoteMediaCache: deps.shouldRequireRemoteMediaCache }
: {}),
...(deps.getYoutubeMediaSourceUrl
? { getYoutubeMediaSourceUrl: deps.getYoutubeMediaSourceUrl }
: {}),
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
bindOverlayOwner: deps.bindOverlayOwner,
releaseOverlayOwner: deps.releaseOverlayOwner,
});
}
+51 -45
View File
@@ -49,69 +49,75 @@ type OverlayRuntimeOptions = {
releaseOverlayOwner?: () => void;
};
// Builds the options for core initializeOverlayRuntime from main-process state. appState
// fields are read when the options are built (or lazily via getters), and the window tracker
// and Anki integration produced by the core are written back to appState.
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
getBackendOverride: () => string | null;
appState: {
backendOverride: string | null;
windowTracker: Parameters<OverlayRuntimeOptions['setWindowTracker']>[0];
subtitleTimingTracker: ReturnType<OverlayRuntimeOptions['getSubtitleTimingTracker']>;
mpvClient: ReturnType<OverlayRuntimeOptions['getMpvClient']>;
mpvSocketPath: string;
runtimeOptionsManager: ReturnType<OverlayRuntimeOptions['getRuntimeOptionsManager']>;
ankiIntegration: Parameters<OverlayRuntimeOptions['setAnkiIntegration']>[0];
};
overlayManager: {
getVisibleOverlayVisible: () => boolean;
};
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => void;
};
refreshCurrentSubtitle?: () => void;
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => void;
};
createMainWindow: () => void;
registerGlobalShortcuts: () => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void;
refreshCurrentSubtitle?: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
createWindowTracker?: (
override?: string | null,
targetMpvSocketPath?: string | null,
) => BaseWindowTracker | null;
createWindowTracker?: OverlayRuntimeOptions['createWindowTracker'];
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getSubtitleTimingTracker: () => unknown | null;
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
getMpvSocketPath: () => string;
getRuntimeOptionsManager: () => {
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
} | null;
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showDesktopNotification: OverlayRuntimeOptions['showDesktopNotification'];
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
createFieldGroupingCallback: OverlayRuntimeOptions['createFieldGroupingCallback'];
getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
getCachedMediaPath?: OverlayRuntimeOptions['getCachedMediaPath'];
shouldRequireRemoteMediaCache?: () => boolean;
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
getYoutubeMediaSourceUrl?: OverlayRuntimeOptions['getYoutubeMediaSourceUrl'];
shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
}) {
return (): OverlayRuntimeOptions => ({
backendOverride: deps.getBackendOverride(),
createMainWindow: deps.createMainWindow,
registerGlobalShortcuts: deps.registerGlobalShortcuts,
updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds,
isVisibleOverlayVisible: deps.isVisibleOverlayVisible,
updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility,
refreshCurrentSubtitle: deps.refreshCurrentSubtitle,
getOverlayWindows: deps.getOverlayWindows,
syncOverlayShortcuts: deps.syncOverlayShortcuts,
setWindowTracker: deps.setWindowTracker,
backendOverride: deps.appState.backendOverride,
createMainWindow: () => deps.createMainWindow(),
registerGlobalShortcuts: () => deps.registerGlobalShortcuts(),
updateVisibleOverlayBounds: (geometry) => deps.updateVisibleOverlayBounds(geometry),
isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
updateVisibleOverlayVisibility: () =>
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(),
getOverlayWindows: () => deps.getOverlayWindows(),
syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(),
setWindowTracker: (tracker) => {
deps.appState.windowTracker = tracker;
},
createWindowTracker: deps.createWindowTracker,
getResolvedConfig: deps.getResolvedConfig,
getSubtitleTimingTracker: deps.getSubtitleTimingTracker,
getMpvClient: deps.getMpvClient,
getMpvSocketPath: deps.getMpvSocketPath,
getRuntimeOptionsManager: deps.getRuntimeOptionsManager,
setAnkiIntegration: deps.setAnkiIntegration,
getResolvedConfig: () => deps.getResolvedConfig(),
getSubtitleTimingTracker: () => deps.appState.subtitleTimingTracker,
getMpvClient: () => deps.appState.mpvClient,
getMpvSocketPath: () => deps.appState.mpvSocketPath,
getRuntimeOptionsManager: () => deps.appState.runtimeOptionsManager,
setAnkiIntegration: (integration) => {
deps.appState.ankiIntegration = integration;
},
showDesktopNotification: deps.showDesktopNotification,
showOverlayNotification: deps.showOverlayNotification,
dismissOverlayNotification: deps.dismissOverlayNotification,
createFieldGroupingCallback: deps.createFieldGroupingCallback,
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
...(deps.shouldRequireRemoteMediaCache
? { shouldRequireRemoteMediaCache: deps.shouldRequireRemoteMediaCache }
@@ -119,7 +125,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
...(deps.getYoutubeMediaSourceUrl
? { getYoutubeMediaSourceUrl: deps.getYoutubeMediaSourceUrl }
: {}),
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
bindOverlayOwner: deps.bindOverlayOwner,
releaseOverlayOwner: deps.releaseOverlayOwner,
});
@@ -1,29 +0,0 @@
import type { OverlayShortcutRuntimeServiceInput } from '../overlay-shortcuts-runtime';
export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
deps: OverlayShortcutRuntimeServiceInput,
) {
return (): OverlayShortcutRuntimeServiceInput => ({
getConfiguredShortcuts: () => deps.getConfiguredShortcuts(),
getShortcutsRegistered: () => deps.getShortcutsRegistered(),
setShortcutsRegistered: (registered: boolean) => deps.setShortcutsRegistered(registered),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
isOverlayShortcutContextActive: () => deps.isOverlayShortcutContextActive?.() ?? true,
showMpvOsd: (text: string) => deps.showMpvOsd(text),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openCharacterDictionaryManager: () => deps.openCharacterDictionaryManager(),
openJimaku: () => deps.openJimaku(),
openTsukihime: () => deps.openTsukihime(),
markAudioCard: () => deps.markAudioCard(),
copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs),
copySubtitle: () => deps.copySubtitle(),
toggleSecondarySubMode: () => deps.toggleSecondarySubMode(),
updateLastCardFromClipboard: () => deps.updateLastCardFromClipboard(),
triggerFieldGrouping: () => deps.triggerFieldGrouping(),
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
mineSentenceCard: () => deps.mineSentenceCard(),
mineSentenceMultiple: (timeoutMs: number) => deps.mineSentenceMultiple(timeoutMs),
cancelPendingMultiCopy: () => deps.cancelPendingMultiCopy(),
cancelPendingMineSentenceMultiple: () => deps.cancelPendingMineSentenceMultiple(),
});
}
@@ -1,29 +0,0 @@
import type {
createSetVisibleOverlayVisibleHandler,
createToggleVisibleOverlayHandler,
} from './overlay-visibility-actions';
type SetVisibleOverlayVisibleMainDeps = Parameters<typeof createSetVisibleOverlayVisibleHandler>[0];
type ToggleVisibleOverlayMainDeps = Parameters<typeof createToggleVisibleOverlayHandler>[0];
export function createBuildSetVisibleOverlayVisibleMainDepsHandler(
deps: SetVisibleOverlayVisibleMainDeps,
) {
return (): SetVisibleOverlayVisibleMainDeps => ({
getVisibleOverlayVisible: deps.getVisibleOverlayVisible
? () => deps.getVisibleOverlayVisible?.() ?? false
: undefined,
setVisibleOverlayVisibleCore: (options) => deps.setVisibleOverlayVisibleCore(options),
setVisibleOverlayVisibleState: (visible: boolean) =>
deps.setVisibleOverlayVisibleState(visible),
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
onVisibleOverlayEnabled: deps.onVisibleOverlayEnabled,
});
}
export function createBuildToggleVisibleOverlayMainDepsHandler(deps: ToggleVisibleOverlayMainDeps) {
return (): ToggleVisibleOverlayMainDeps => ({
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
setVisibleOverlayVisible: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
});
}
@@ -1,36 +0,0 @@
import type { BrowserWindow } from 'electron';
import type { WindowGeometry } from '../../types';
import type { OverlayVisibilityRuntimeDeps } from '../overlay-visibility-runtime';
export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
deps: OverlayVisibilityRuntimeDeps,
) {
return (): OverlayVisibilityRuntimeDeps => ({
getMainWindow: () => deps.getMainWindow(),
getModalActive: () => deps.getModalActive(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
getNonNativeInputRegionActive: () => deps.getNonNativeInputRegionActive?.() ?? false,
getSuspendVisibleOverlay: () => deps.getSuspendVisibleOverlay?.() ?? false,
getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false,
getWindowTracker: () => deps.getWindowTracker(),
getMacOSForegroundProbeActive: () => deps.getMacOSForegroundProbeActive?.() ?? false,
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateVisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) =>
deps.syncWindowsOverlayToMpvZOrder?.(window),
syncPrimaryOverlayWindowLayer: (layer: 'visible') => deps.syncPrimaryOverlayWindowLayer(layer),
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
isMacOSPlatform: () => deps.isMacOSPlatform(),
isWindowsPlatform: () => deps.isWindowsPlatform(),
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
dismissOverlayLoadingOsd: () => deps.dismissOverlayLoadingOsd?.(),
hideNonNativeOverlayWhenTargetUnfocused: () =>
deps.hideNonNativeOverlayWhenTargetUnfocused?.() ?? false,
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
});
}
+6 -16
View File
@@ -2,35 +2,25 @@ import {
createSetVisibleOverlayVisibleHandler,
createToggleVisibleOverlayHandler,
} from './overlay-visibility-actions';
import {
createBuildSetVisibleOverlayVisibleMainDepsHandler,
createBuildToggleVisibleOverlayMainDepsHandler,
} from './overlay-visibility-actions-main-deps';
import { createSetOverlayVisibleHandler, createToggleOverlayHandler } from './overlay-main-actions';
type SetVisibleOverlayVisibleMainDeps = Parameters<
typeof createBuildSetVisibleOverlayVisibleMainDepsHandler
>[0];
type SetVisibleOverlayVisibleDeps = Parameters<typeof createSetVisibleOverlayVisibleHandler>[0];
export type OverlayVisibilityRuntimeDeps = {
setVisibleOverlayVisibleDeps: SetVisibleOverlayVisibleMainDeps;
setVisibleOverlayVisibleDeps: SetVisibleOverlayVisibleDeps;
getVisibleOverlayVisible: () => boolean;
};
export function createOverlayVisibilityRuntime(deps: OverlayVisibilityRuntimeDeps) {
const setVisibleOverlayVisibleMainDeps = createBuildSetVisibleOverlayVisibleMainDepsHandler({
const setVisibleOverlayVisible = createSetVisibleOverlayVisibleHandler({
...deps.setVisibleOverlayVisibleDeps,
getVisibleOverlayVisible: deps.getVisibleOverlayVisible,
})();
const setVisibleOverlayVisible = createSetVisibleOverlayVisibleHandler(
setVisibleOverlayVisibleMainDeps,
);
});
const toggleVisibleOverlayMainDeps = createBuildToggleVisibleOverlayMainDepsHandler({
const toggleVisibleOverlay = createToggleVisibleOverlayHandler({
getVisibleOverlayVisible: deps.getVisibleOverlayVisible,
setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible),
})();
const toggleVisibleOverlay = createToggleVisibleOverlayHandler(toggleVisibleOverlayMainDeps);
});
const setOverlayVisible = createSetOverlayVisibleHandler({
setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible),
@@ -1,79 +0,0 @@
import type { Session } from 'electron';
export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindowCore: (
kind: 'visible' | 'modal',
options: {
isDev: boolean;
ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
linuxX11FullscreenOverlay?: boolean;
onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void;
onWindowDidFinishLoad?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void;
yomitanSession?: Session | null;
},
) => TWindow;
isDev: boolean;
ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
getLinuxX11FullscreenOverlay?: () => boolean;
onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void;
onWindowDidFinishLoad?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void;
getYomitanSession?: () => Session | null;
}) {
return () => ({
createOverlayWindowCore: deps.createOverlayWindowCore,
isDev: deps.isDev,
ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel,
onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged,
setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled,
isOverlayVisible: deps.isOverlayVisible,
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
forwardTabToMpv: deps.forwardTabToMpv,
getLinuxX11FullscreenOverlay: deps.getLinuxX11FullscreenOverlay,
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
onVisibleWindowFocused: deps.onVisibleWindowFocused,
onWindowDidFinishLoad: deps.onWindowDidFinishLoad,
onWindowContentReady: deps.onWindowContentReady,
onWindowClosed: deps.onWindowClosed,
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
});
}
export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
getMainWindow: () => TWindow | null;
isWindowDestroyed: (window: TWindow) => boolean;
createOverlayWindow: (kind: 'visible' | 'modal') => TWindow;
setMainWindow: (window: TWindow | null) => void;
}) {
return () => ({
getMainWindow: () => deps.getMainWindow(),
isWindowDestroyed: (window: TWindow) => deps.isWindowDestroyed(window),
createOverlayWindow: deps.createOverlayWindow,
setMainWindow: deps.setMainWindow,
});
}
export function createBuildCreateModalWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'modal') => TWindow;
setModalWindow: (window: TWindow | null) => void;
}) {
return () => ({
createOverlayWindow: deps.createOverlayWindow,
setModalWindow: deps.setModalWindow,
});
}
@@ -1,46 +0,0 @@
import type {
createEnforceOverlayLayerOrderHandler,
createEnsureOverlayWindowLevelHandler,
createUpdateVisibleOverlayBoundsHandler,
} from './overlay-window-layout';
type UpdateVisibleOverlayBoundsMainDeps = Parameters<
typeof createUpdateVisibleOverlayBoundsHandler
>[0];
type EnsureOverlayWindowLevelMainDeps = Parameters<typeof createEnsureOverlayWindowLevelHandler>[0];
type EnforceOverlayLayerOrderMainDeps = Parameters<typeof createEnforceOverlayLayerOrderHandler>[0];
export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler(
deps: UpdateVisibleOverlayBoundsMainDeps,
) {
return (): UpdateVisibleOverlayBoundsMainDeps => ({
getCurrentOverlayWindowBounds: () => deps.getCurrentOverlayWindowBounds?.() ?? null,
shouldRefreshUnchangedGeometry: (geometry) =>
deps.shouldRefreshUnchangedGeometry?.(geometry) ?? false,
setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry),
afterSetOverlayWindowBounds: (geometry) => deps.afterSetOverlayWindowBounds?.(geometry),
});
}
export function createBuildEnsureOverlayWindowLevelMainDepsHandler(
deps: EnsureOverlayWindowLevelMainDeps,
) {
return (): EnsureOverlayWindowLevelMainDeps => ({
shouldSuppressOverlayWindowLevel: (window: unknown) =>
deps.shouldSuppressOverlayWindowLevel?.(window) ?? false,
ensureOverlayWindowLevelCore: (window: unknown) => deps.ensureOverlayWindowLevelCore(window),
afterEnsureOverlayWindowLevel: (window: unknown) =>
deps.afterEnsureOverlayWindowLevel?.(window),
});
}
export function createBuildEnforceOverlayLayerOrderMainDepsHandler(
deps: EnforceOverlayLayerOrderMainDeps,
) {
return (): EnforceOverlayLayerOrderMainDeps => ({
enforceOverlayLayerOrderCore: (params) => deps.enforceOverlayLayerOrderCore(params),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getMainWindow: () => deps.getMainWindow(),
ensureOverlayWindowLevel: (window: unknown) => deps.ensureOverlayWindowLevel(window),
});
}
@@ -3,40 +3,31 @@ import {
createCreateModalWindowHandler,
createCreateOverlayWindowHandler,
} from './overlay-window-factory';
import {
createBuildCreateMainWindowMainDepsHandler,
createBuildCreateModalWindowMainDepsHandler,
createBuildCreateOverlayWindowMainDepsHandler,
} from './overlay-window-factory-main-deps';
type CreateOverlayWindowMainDeps<TWindow> = Parameters<
typeof createBuildCreateOverlayWindowMainDepsHandler<TWindow>
type CreateOverlayWindowDeps<TWindow> = Parameters<
typeof createCreateOverlayWindowHandler<TWindow>
>[0];
export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
createOverlayWindowDeps: CreateOverlayWindowMainDeps<TWindow>;
createOverlayWindowDeps: CreateOverlayWindowDeps<TWindow>;
getMainWindow: () => TWindow | null;
isWindowDestroyed: (window: TWindow) => boolean;
setMainWindow: (window: TWindow | null) => void;
setModalWindow: (window: TWindow | null) => void;
}) {
const createOverlayWindow = createCreateOverlayWindowHandler<TWindow>(
createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps.createOverlayWindowDeps)(),
);
const createMainWindow = createCreateMainWindowHandler<TWindow>(
createBuildCreateMainWindowMainDepsHandler<TWindow>({
getMainWindow: () => deps.getMainWindow(),
isWindowDestroyed: (window) => deps.isWindowDestroyed(window),
createOverlayWindow: (kind) => createOverlayWindow(kind),
setMainWindow: (window) => deps.setMainWindow(window),
})(),
);
const createModalWindow = createCreateModalWindowHandler<TWindow>(
createBuildCreateModalWindowMainDepsHandler<TWindow>({
createOverlayWindow: (kind) => createOverlayWindow(kind),
setModalWindow: (window) => deps.setModalWindow(window),
})(),
deps.createOverlayWindowDeps,
);
const createMainWindow = createCreateMainWindowHandler<TWindow>({
getMainWindow: () => deps.getMainWindow(),
isWindowDestroyed: (window) => deps.isWindowDestroyed(window),
createOverlayWindow: (kind) => createOverlayWindow(kind),
setMainWindow: (window) => deps.setMainWindow(window),
});
const createModalWindow = createCreateModalWindowHandler<TWindow>({
createOverlayWindow: (kind) => createOverlayWindow(kind),
setModalWindow: (window) => deps.setModalWindow(window),
});
return {
createOverlayWindow,
@@ -1,32 +0,0 @@
import type { CliArgs } from '../../cli/args';
import type { ResolvedConfig } from '../../types';
import type { LogLevelSource } from '../../logger';
import type { StartupBootstrapRuntimeFactoryDeps } from '../startup';
export function createBuildStartupBootstrapRuntimeFactoryDepsHandler(
deps: StartupBootstrapRuntimeFactoryDeps,
) {
return (): StartupBootstrapRuntimeFactoryDeps => ({
argv: deps.argv,
parseArgs: deps.parseArgs,
setLogLevel: deps.setLogLevel,
forceX11Backend: deps.forceX11Backend,
enforceUnsupportedWaylandMode: deps.enforceUnsupportedWaylandMode,
shouldStartApp: deps.shouldStartApp,
getDefaultSocketPath: deps.getDefaultSocketPath,
defaultTexthookerPort: deps.defaultTexthookerPort,
configDir: deps.configDir,
defaultConfig: deps.defaultConfig,
generateConfigTemplate: deps.generateConfigTemplate,
generateDefaultConfigFile: deps.generateDefaultConfigFile,
onConfigGenerated: deps.onConfigGenerated,
onGenerateConfigError: deps.onGenerateConfigError,
startAppLifecycle: deps.startAppLifecycle,
});
}
export type {
CliArgs as StartupBuilderCliArgs,
ResolvedConfig as StartupBuilderResolvedConfig,
LogLevelSource as StartupBuilderLogLevelSource,
};
@@ -1,44 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildStartupBootstrapMainDepsHandler } from './startup-bootstrap-main-deps';
function buildDeps() {
const state = { exitCodes: [] as number[], quitCount: 0, errors: [] as string[] };
const deps = createBuildStartupBootstrapMainDepsHandler({
argv: ['node', 'main.js'],
parseArgs: () => ({}) as never,
setLogLevel: () => {},
forceX11Backend: () => {},
enforceUnsupportedWaylandMode: () => {},
shouldStartApp: () => true,
getDefaultSocketPath: () => '/tmp/mpv.sock',
defaultTexthookerPort: 5174,
configDir: '/tmp/config',
defaultConfig: {} as never,
generateConfigTemplate: () => 'template',
generateDefaultConfigFile: async () => 0,
setExitCode: (code) => state.exitCodes.push(code),
quitApp: () => {
state.quitCount += 1;
},
logGenerateConfigError: (message) => state.errors.push(message),
startAppLifecycle: () => {},
})();
return { deps, state };
}
test('onConfigGenerated exits with the generator exit code', () => {
const { deps, state } = buildDeps();
deps.onConfigGenerated(7);
assert.deepEqual(state.exitCodes, [7]);
assert.equal(state.quitCount, 1);
assert.deepEqual(state.errors, []);
});
test('onGenerateConfigError logs the failure and exits with code 1', () => {
const { deps, state } = buildDeps();
deps.onGenerateConfigError(new Error('boom'));
assert.deepEqual(state.errors, ['Failed to generate config: boom']);
assert.deepEqual(state.exitCodes, [1]);
assert.equal(state.quitCount, 1);
});
@@ -1,62 +0,0 @@
import type { CliArgs } from '../../cli/args';
import type { LogLevelSource } from '../../logger';
import type { ResolvedConfig } from '../../types';
import type { StartupBootstrapRuntimeFactoryDeps } from '../startup';
export function createBuildStartupBootstrapMainDepsHandler(deps: {
argv: string[];
parseArgs: (argv: string[]) => CliArgs;
setLogLevel: (level: string, source: LogLevelSource) => void;
forceX11Backend: (args: CliArgs) => void;
enforceUnsupportedWaylandMode: (args: CliArgs) => void;
shouldStartApp: (args: CliArgs) => boolean;
getDefaultSocketPath: () => string;
defaultTexthookerPort: number;
configDir: string;
defaultConfig: ResolvedConfig;
generateConfigTemplate: (config: ResolvedConfig) => string;
generateDefaultConfigFile: (
args: CliArgs,
options: {
configDir: string;
defaultConfig: unknown;
generateTemplate: (config: unknown) => string;
},
) => Promise<number>;
setExitCode: (code: number) => void;
quitApp: () => void;
logGenerateConfigError: (message: string) => void;
startAppLifecycle: (args: CliArgs) => void;
}) {
return (): StartupBootstrapRuntimeFactoryDeps => ({
argv: deps.argv,
parseArgs: (argv: string[]) => deps.parseArgs(argv),
setLogLevel: (level: string, source: LogLevelSource) => deps.setLogLevel(level, source),
forceX11Backend: (args: CliArgs) => deps.forceX11Backend(args),
enforceUnsupportedWaylandMode: (args: CliArgs) => deps.enforceUnsupportedWaylandMode(args),
shouldStartApp: (args: CliArgs) => deps.shouldStartApp(args),
getDefaultSocketPath: () => deps.getDefaultSocketPath(),
defaultTexthookerPort: deps.defaultTexthookerPort,
configDir: deps.configDir,
defaultConfig: deps.defaultConfig,
generateConfigTemplate: (config: ResolvedConfig) => deps.generateConfigTemplate(config),
generateDefaultConfigFile: (
args: CliArgs,
options: {
configDir: string;
defaultConfig: unknown;
generateTemplate: (config: unknown) => string;
},
) => deps.generateDefaultConfigFile(args, options),
onConfigGenerated: (exitCode: number) => {
deps.setExitCode(exitCode);
deps.quitApp();
},
onGenerateConfigError: (error: Error) => {
deps.logGenerateConfigError(`Failed to generate config: ${error.message}`);
deps.setExitCode(1);
deps.quitApp();
},
startAppLifecycle: (args: CliArgs) => deps.startAppLifecycle(args),
});
}
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { StartupBootstrapRuntimeFactoryDeps } from '../startup';
import { createStartupRuntimeHandlers } from './startup-runtime-handlers';
function buildBootstrapDeps() {
const state = { exitCodes: [] as number[], quitCount: 0, errors: [] as string[] };
const captured: StartupBootstrapRuntimeFactoryDeps[] = [];
const { runAndApplyStartupState } = createStartupRuntimeHandlers({
appLifecycleRuntimeRunnerMainDeps: {} as never,
createAppLifecycleRuntimeRunner: () => () => {},
buildStartupBootstrapMainDeps: (startAppLifecycle) => ({
argv: ['node', 'main.js'],
parseArgs: () => ({}) as never,
setLogLevel: () => {},
forceX11Backend: () => {},
enforceUnsupportedWaylandMode: () => {},
shouldStartApp: () => true,
getDefaultSocketPath: () => '/tmp/mpv.sock',
defaultTexthookerPort: 5174,
configDir: '/tmp/config',
defaultConfig: {} as never,
generateConfigTemplate: () => 'template',
generateDefaultConfigFile: async () => 0,
setExitCode: (code) => state.exitCodes.push(code),
quitApp: () => {
state.quitCount += 1;
},
logGenerateConfigError: (message) => state.errors.push(message),
startAppLifecycle,
}),
createStartupBootstrapRuntimeDeps: (deps) => deps,
runStartupBootstrapRuntime: (deps) => {
captured.push(deps);
},
applyStartupState: () => {},
});
runAndApplyStartupState();
const [deps] = captured;
assert.ok(deps);
return { deps, state };
}
test('onConfigGenerated exits with the generator exit code', () => {
const { deps, state } = buildBootstrapDeps();
deps.onConfigGenerated(7);
assert.deepEqual(state.exitCodes, [7]);
assert.equal(state.quitCount, 1);
assert.deepEqual(state.errors, []);
});
test('onGenerateConfigError logs the failure and exits with code 1', () => {
const { deps, state } = buildBootstrapDeps();
deps.onGenerateConfigError(new Error('boom'));
assert.deepEqual(state.errors, ['Failed to generate config: boom']);
assert.deepEqual(state.exitCodes, [1]);
assert.equal(state.quitCount, 1);
});
+24 -11
View File
@@ -1,11 +1,15 @@
import { createBuildStartupBootstrapRuntimeFactoryDepsHandler } from './startup-bootstrap-deps-builder';
import type { AppLifecycleRuntimeRunnerParams } from '../startup-lifecycle';
import { createBuildStartupBootstrapMainDepsHandler } from './startup-bootstrap-main-deps';
import type { StartupBootstrapRuntimeFactoryDeps } from '../startup';
type StartupBootstrapMainDeps = Parameters<typeof createBuildStartupBootstrapMainDepsHandler>[0];
type StartupBootstrapRuntimeFactoryDeps = Parameters<
typeof createBuildStartupBootstrapRuntimeFactoryDepsHandler
>[0];
// Bootstrap deps as main.ts supplies them; the config-generation exit callbacks are built here.
type StartupBootstrapMainDeps = Omit<
StartupBootstrapRuntimeFactoryDeps,
'onConfigGenerated' | 'onGenerateConfigError'
> & {
setExitCode: (code: number) => void;
quitApp: () => void;
logGenerateConfigError: (message: string) => void;
};
export function createStartupRuntimeHandlers<
TCliArgs,
@@ -29,11 +33,20 @@ export function createStartupRuntimeHandlers<
deps.appLifecycleRuntimeRunnerMainDeps,
);
const startupBootstrapMainDeps = createBuildStartupBootstrapMainDepsHandler(
deps.buildStartupBootstrapMainDeps(appLifecycleRuntimeRunner),
)();
const startupBootstrapRuntimeFactoryDeps =
createBuildStartupBootstrapRuntimeFactoryDepsHandler(startupBootstrapMainDeps)();
const { setExitCode, quitApp, logGenerateConfigError, ...bootstrapDeps } =
deps.buildStartupBootstrapMainDeps(appLifecycleRuntimeRunner);
const startupBootstrapRuntimeFactoryDeps: StartupBootstrapRuntimeFactoryDeps = {
...bootstrapDeps,
onConfigGenerated: (exitCode) => {
setExitCode(exitCode);
quitApp();
},
onGenerateConfigError: (error) => {
logGenerateConfigError(`Failed to generate config: ${error.message}`);
setExitCode(1);
quitApp();
},
};
const runAndApplyStartupState = (): TStartupState => {
const startupState = deps.runStartupBootstrapRuntime(
@@ -1,215 +0,0 @@
import type { TokenizerDepsRuntimeOptions } from '../../core/services/tokenizer';
type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
getJlptEnabled: NonNullable<TokenizerDepsRuntimeOptions['getJlptEnabled']>;
getCharacterDictionaryEnabled?: () => boolean;
getNameMatchEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchEnabled']>;
getNameMatchImagesEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchImagesEnabled']>;
getCharacterNameImage?: NonNullable<TokenizerDepsRuntimeOptions['getCharacterNameImage']>;
getCurrentCharacterDictionaryMediaId?: NonNullable<
TokenizerDepsRuntimeOptions['getCurrentCharacterDictionaryMediaId']
>;
getCharacterNameCandidates?: NonNullable<
TokenizerDepsRuntimeOptions['getCharacterNameCandidates']
>;
getFrequencyDictionaryEnabled: NonNullable<
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
>;
getFrequencyDictionaryMatchMode: NonNullable<
TokenizerDepsRuntimeOptions['getFrequencyDictionaryMatchMode']
>;
getFrequencyRank: NonNullable<TokenizerDepsRuntimeOptions['getFrequencyRank']>;
getMinSentenceWordsForNPlusOne: NonNullable<
TokenizerDepsRuntimeOptions['getMinSentenceWordsForNPlusOne']
>;
getYomitanGroupDebugEnabled: NonNullable<
TokenizerDepsRuntimeOptions['getYomitanGroupDebugEnabled']
>;
recordLookup: (hit: boolean) => void;
};
export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
return (): TokenizerDepsRuntimeOptions => ({
getYomitanExt: () => deps.getYomitanExt(),
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window),
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),
setYomitanParserReadyPromise: (promise: Promise<void> | null) =>
deps.setYomitanParserReadyPromise(promise),
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
setYomitanParserInitPromise: (promise: Promise<boolean> | null) =>
deps.setYomitanParserInitPromise(promise),
isKnownWord: (text, reading, options) => {
const hit = deps.isKnownWord(text, reading, options);
deps.recordLookup(hit);
return hit;
},
...(deps.getKnownWordTier
? {
getKnownWordTier: (text, reading, options) =>
deps.getKnownWordTier!(text, reading, options),
}
: {}),
getKnownWordMatchMode: () => deps.getKnownWordMatchMode(),
...(deps.getKnownWordsEnabled
? {
getKnownWordsEnabled: () => deps.getKnownWordsEnabled!(),
}
: {}),
...(deps.getNPlusOneEnabled
? {
getNPlusOneEnabled: () => deps.getNPlusOneEnabled!(),
}
: {}),
getMinSentenceWordsForNPlusOne: () => deps.getMinSentenceWordsForNPlusOne(),
getJlptLevel: (text: string) => deps.getJlptLevel(text),
getJlptEnabled: () => deps.getJlptEnabled(),
...(deps.getNameMatchEnabled
? {
getNameMatchEnabled: () =>
deps.getCharacterDictionaryEnabled?.() !== false && deps.getNameMatchEnabled!(),
}
: {}),
...(deps.getNameMatchImagesEnabled
? {
getNameMatchImagesEnabled: () =>
deps.getCharacterDictionaryEnabled?.() !== false && deps.getNameMatchImagesEnabled!(),
}
: {}),
...(deps.getCharacterNameImage
? {
getCharacterNameImage: (term: string) => deps.getCharacterNameImage!(term),
}
: {}),
...(deps.getCurrentCharacterDictionaryMediaId
? {
getCurrentCharacterDictionaryMediaId: () => deps.getCurrentCharacterDictionaryMediaId!(),
}
: {}),
...(deps.getCharacterNameCandidates
? {
getCharacterNameCandidates: () => deps.getCharacterNameCandidates!(),
}
: {}),
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(),
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),
getYomitanGroupDebugEnabled: () => deps.getYomitanGroupDebugEnabled(),
getMecabTokenizer: () => deps.getMecabTokenizer(),
onTokenizationReady: (text: string) => deps.onTokenizationReady?.(text),
});
}
export function createCreateMecabTokenizerAndCheckMainHandler<TMecab>(deps: {
getMecabTokenizer: () => TMecab | null;
setMecabTokenizer: (tokenizer: TMecab) => void;
createMecabTokenizer: () => TMecab;
checkAvailability: (tokenizer: TMecab) => Promise<unknown>;
}) {
return async (): Promise<void> => {
let tokenizer = deps.getMecabTokenizer();
if (!tokenizer) {
tokenizer = deps.createMecabTokenizer();
deps.setMecabTokenizer(tokenizer);
}
await deps.checkAvailability(tokenizer);
};
}
export function createPrewarmSubtitleDictionariesMainHandler(deps: {
ensureJlptDictionaryLookup: () => Promise<void>;
ensureFrequencyDictionaryLookup: () => Promise<void>;
showMpvOsd?: (message: string) => void;
showLoadingOsd?: (message: string) => void;
showLoadedOsd?: (message: string) => void;
shouldShowOsdNotification?: () => boolean;
setInterval?: (callback: () => void, delayMs: number) => unknown;
clearInterval?: (timer: unknown) => void;
}) {
let prewarmed = false;
let prewarmPromise: Promise<void> | null = null;
let loadingOsdDepth = 0;
let loadingOsdFrame = 0;
let loadingOsdTimer: unknown = null;
const showMpvOsd = deps.showMpvOsd;
const showLoadingOsd = deps.showLoadingOsd ?? showMpvOsd;
const showLoadedOsd = deps.showLoadedOsd ?? showMpvOsd;
const setIntervalHandler =
deps.setInterval ??
((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs));
const clearIntervalHandler =
deps.clearInterval ??
((timer: unknown): void => clearInterval(timer as ReturnType<typeof setInterval>));
const spinnerFrames = ['|', '/', '-', '\\'];
const beginLoadingOsd = (): boolean => {
if (!showLoadingOsd) {
return false;
}
loadingOsdDepth += 1;
if (loadingOsdDepth > 1) {
return true;
}
loadingOsdFrame = 0;
showLoadingOsd(`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame]}`);
loadingOsdFrame += 1;
loadingOsdTimer = setIntervalHandler(() => {
if (!showLoadingOsd) {
return;
}
showLoadingOsd(
`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame % spinnerFrames.length]}`,
);
loadingOsdFrame += 1;
}, 180);
return true;
};
const endLoadingOsd = (): void => {
if (!showLoadedOsd) {
return;
}
loadingOsdDepth = Math.max(0, loadingOsdDepth - 1);
if (loadingOsdDepth > 0) {
return;
}
if (loadingOsdTimer) {
clearIntervalHandler(loadingOsdTimer);
loadingOsdTimer = null;
}
showLoadedOsd('Subtitle annotations loaded');
};
return async (options?: { showLoadingOsd?: boolean }): Promise<void> => {
if (prewarmed) {
return;
}
const shouldTrackLoadingOsd = options?.showLoadingOsd === true;
const loadingOsdStarted = shouldTrackLoadingOsd ? beginLoadingOsd() : false;
if (!prewarmPromise) {
prewarmPromise = (async () => {
try {
await Promise.all([
deps.ensureJlptDictionaryLookup(),
deps.ensureFrequencyDictionaryLookup(),
]);
prewarmed = true;
} finally {
prewarmPromise = null;
}
})();
}
try {
await prewarmPromise;
} finally {
if (loadingOsdStarted) {
endLoadingOsd();
}
}
};
}
@@ -1,10 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildTokenizerDepsMainHandler,
createCreateMecabTokenizerAndCheckMainHandler,
createPrewarmSubtitleDictionariesMainHandler,
} from './subtitle-tokenization-main-deps';
createMecabTokenizerAndCheckHandler,
createPrewarmSubtitleDictionariesHandler,
} from './subtitle-tokenization-warmup';
function createDeferred(): {
promise: Promise<void>;
@@ -17,59 +16,11 @@ function createDeferred(): {
return { promise, resolve };
}
type TokenizerMainDeps = Parameters<typeof createBuildTokenizerDepsMainHandler>[0];
function buildTokenizerDeps(overrides: Partial<TokenizerMainDeps> = {}) {
return createBuildTokenizerDepsMainHandler({
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => undefined,
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => undefined,
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => undefined,
isKnownWord: () => false,
recordLookup: () => undefined,
getKnownWordMatchMode: () => 'surface',
getNPlusOneEnabled: () => true,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => 'N2',
getJlptEnabled: () => true,
getNameMatchEnabled: () => true,
getNameMatchImagesEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'surface',
getFrequencyRank: () => 5,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
...overrides,
})();
}
test('tokenizer deps builder records every known-word lookup result', () => {
const lookups: boolean[] = [];
const deps = buildTokenizerDeps({
isKnownWord: (text) => text === 'known',
recordLookup: (hit) => lookups.push(hit),
});
assert.equal(deps.isKnownWord('known'), true);
assert.equal(deps.isKnownWord('unknown'), false);
assert.deepEqual(lookups, [true, false]);
});
test('tokenizer deps builder disables name matching when character dictionary runtime is disabled', () => {
const deps = buildTokenizerDeps({ getCharacterDictionaryEnabled: () => false });
assert.equal(deps.getNameMatchEnabled?.(), false);
assert.equal(deps.getNameMatchImagesEnabled?.(), false);
});
test('mecab tokenizer check creates tokenizer once and runs availability check', async () => {
const calls: string[] = [];
type Tokenizer = { id: string };
let tokenizer: Tokenizer | null = null;
const run = createCreateMecabTokenizerAndCheckMainHandler<Tokenizer>({
const run = createMecabTokenizerAndCheckHandler<Tokenizer>({
getMecabTokenizer: () => tokenizer,
setMecabTokenizer: (next) => {
tokenizer = next;
@@ -91,7 +42,7 @@ test('mecab tokenizer check creates tokenizer once and runs availability check',
test('dictionary prewarm runs both dictionary loaders', async () => {
const calls: string[] = [];
const prewarm = createPrewarmSubtitleDictionariesMainHandler({
const prewarm = createPrewarmSubtitleDictionariesHandler({
ensureJlptDictionaryLookup: async () => {
calls.push('jlpt');
},
@@ -111,7 +62,7 @@ test('dictionary prewarm shows OSD spinner while loading and completion when loa
const jlptDeferred = createDeferred();
const freqDeferred = createDeferred();
const prewarm = createPrewarmSubtitleDictionariesMainHandler({
const prewarm = createPrewarmSubtitleDictionariesHandler({
ensureJlptDictionaryLookup: async () => jlptDeferred.promise,
ensureFrequencyDictionaryLookup: async () => freqDeferred.promise,
shouldShowOsdNotification: () => true,
@@ -157,7 +108,7 @@ test('dictionary prewarm can show OSD while awaiting background-started load', a
const jlptDeferred = createDeferred();
const freqDeferred = createDeferred();
const prewarm = createPrewarmSubtitleDictionariesMainHandler({
const prewarm = createPrewarmSubtitleDictionariesHandler({
ensureJlptDictionaryLookup: async () => jlptDeferred.promise,
ensureFrequencyDictionaryLookup: async () => freqDeferred.promise,
shouldShowOsdNotification: () => true,
@@ -183,7 +134,7 @@ test('dictionary prewarm can show OSD while awaiting background-started load', a
test('dictionary prewarm shows OSD when loading indicator is requested even if notification predicate is disabled', async () => {
const osdMessages: string[] = [];
const prewarm = createPrewarmSubtitleDictionariesMainHandler({
const prewarm = createPrewarmSubtitleDictionariesHandler({
ensureJlptDictionaryLookup: async () => undefined,
ensureFrequencyDictionaryLookup: async () => undefined,
shouldShowOsdNotification: () => false,
@@ -203,7 +154,7 @@ test('dictionary prewarm clears loading OSD timer even if notifications are disa
const freqDeferred = createDeferred();
let shouldShowNotification = true;
const prewarm = createPrewarmSubtitleDictionariesMainHandler({
const prewarm = createPrewarmSubtitleDictionariesHandler({
ensureJlptDictionaryLookup: async () => jlptDeferred.promise,
ensureFrequencyDictionaryLookup: async () => freqDeferred.promise,
shouldShowOsdNotification: () => shouldShowNotification,
@@ -0,0 +1,115 @@
// Creates the MeCab tokenizer on first use, then runs its availability check.
export function createMecabTokenizerAndCheckHandler<TMecab>(deps: {
getMecabTokenizer: () => TMecab | null;
setMecabTokenizer: (tokenizer: TMecab) => void;
createMecabTokenizer: () => TMecab;
checkAvailability: (tokenizer: TMecab) => Promise<unknown>;
}) {
return async (): Promise<void> => {
let tokenizer = deps.getMecabTokenizer();
if (!tokenizer) {
tokenizer = deps.createMecabTokenizer();
deps.setMecabTokenizer(tokenizer);
}
await deps.checkAvailability(tokenizer);
};
}
// Loads the JLPT and frequency dictionaries once (concurrent callers share the in-flight load),
// optionally showing a spinner OSD while loading.
export function createPrewarmSubtitleDictionariesHandler(deps: {
ensureJlptDictionaryLookup: () => Promise<void>;
ensureFrequencyDictionaryLookup: () => Promise<void>;
showMpvOsd?: (message: string) => void;
showLoadingOsd?: (message: string) => void;
showLoadedOsd?: (message: string) => void;
shouldShowOsdNotification?: () => boolean;
setInterval?: (callback: () => void, delayMs: number) => unknown;
clearInterval?: (timer: unknown) => void;
}) {
let prewarmed = false;
let prewarmPromise: Promise<void> | null = null;
let loadingOsdDepth = 0;
let loadingOsdFrame = 0;
let loadingOsdTimer: unknown = null;
const showMpvOsd = deps.showMpvOsd;
const showLoadingOsd = deps.showLoadingOsd ?? showMpvOsd;
const showLoadedOsd = deps.showLoadedOsd ?? showMpvOsd;
const setIntervalHandler =
deps.setInterval ??
((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs));
const clearIntervalHandler =
deps.clearInterval ??
((timer: unknown): void => clearInterval(timer as ReturnType<typeof setInterval>));
const spinnerFrames = ['|', '/', '-', '\\'];
const beginLoadingOsd = (): boolean => {
if (!showLoadingOsd) {
return false;
}
loadingOsdDepth += 1;
if (loadingOsdDepth > 1) {
return true;
}
loadingOsdFrame = 0;
showLoadingOsd(`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame]}`);
loadingOsdFrame += 1;
loadingOsdTimer = setIntervalHandler(() => {
if (!showLoadingOsd) {
return;
}
showLoadingOsd(
`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame % spinnerFrames.length]}`,
);
loadingOsdFrame += 1;
}, 180);
return true;
};
const endLoadingOsd = (): void => {
if (!showLoadedOsd) {
return;
}
loadingOsdDepth = Math.max(0, loadingOsdDepth - 1);
if (loadingOsdDepth > 0) {
return;
}
if (loadingOsdTimer) {
clearIntervalHandler(loadingOsdTimer);
loadingOsdTimer = null;
}
showLoadedOsd('Subtitle annotations loaded');
};
return async (options?: { showLoadingOsd?: boolean }): Promise<void> => {
if (prewarmed) {
return;
}
const shouldTrackLoadingOsd = options?.showLoadingOsd === true;
const loadingOsdStarted = shouldTrackLoadingOsd ? beginLoadingOsd() : false;
if (!prewarmPromise) {
prewarmPromise = (async () => {
try {
await Promise.all([
deps.ensureJlptDictionaryLookup(),
deps.ensureFrequencyDictionaryLookup(),
]);
prewarmed = true;
} finally {
prewarmPromise = null;
}
})();
}
try {
await prewarmPromise;
} finally {
if (loadingOsdStarted) {
endLoadingOsd();
}
}
};
}
+20 -1
View File
@@ -1,6 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDestroyTrayHandler, createEnsureTrayHandler } from './tray-lifecycle';
import {
createDestroyTrayHandler,
createEnsureOverlayVisibleFromTrayClickHandler,
createEnsureTrayHandler,
} from './tray-lifecycle';
type EnsureTrayDeps = Parameters<typeof createEnsureTrayHandler>[0];
type TrayIcon = ReturnType<EnsureTrayDeps['createEmptyImage']>;
@@ -154,3 +158,18 @@ test('destroy tray handler destroys active tray and clears ref', () => {
assert.equal(activeTray.destroyed, true);
assert.equal(tray, null);
});
for (const initialized of [false, true]) {
test(`tray click shows overlay${initialized ? '' : ' after booting runtime'}`, () => {
const calls: string[] = [];
createEnsureOverlayVisibleFromTrayClickHandler({
isOverlayRuntimeInitialized: () => initialized,
initializeOverlayRuntime: () => calls.push('init-overlay'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
})();
assert.deepEqual(
calls,
initialized ? ['set-visible:true'] : ['init-overlay', 'set-visible:true'],
);
});
}
+14
View File
@@ -82,3 +82,17 @@ export function createDestroyTrayHandler(deps: {
deps.setTray(null);
};
}
// Tray icon click: boot the overlay runtime on first use, then show the visible overlay.
export function createEnsureOverlayVisibleFromTrayClickHandler(deps: {
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntime: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
}) {
return (): void => {
if (!deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
}
deps.setVisibleOverlayVisible(true);
};
}
+29 -22
View File
@@ -1,8 +1,8 @@
import { createDestroyTrayHandler, createEnsureTrayHandler } from './tray-lifecycle';
import {
createBuildDestroyTrayMainDepsHandler,
createBuildEnsureTrayMainDepsHandler,
} from './app-runtime-main-deps';
createDestroyTrayHandler,
createEnsureOverlayVisibleFromTrayClickHandler,
createEnsureTrayHandler,
} from './tray-lifecycle';
import {
createBuildTrayMenuTemplateHandler,
createResolveTrayIconPathHandler,
@@ -12,34 +12,41 @@ type ResolveTrayIconPathMainDeps = Parameters<typeof createResolveTrayIconPathHa
type BuildTrayMenuTemplateMainDeps<TMenuItem> = Parameters<
typeof createBuildTrayMenuTemplateHandler<TMenuItem>
>[0];
type EnsureTrayMainDeps<TTrayMenu> = Parameters<
typeof createBuildEnsureTrayMainDepsHandler<TrayLike, TTrayMenu, TrayIconLike>
>[0];
type TrayLike = NonNullable<ReturnType<Parameters<typeof createEnsureTrayHandler>[0]['getTray']>>;
type TrayIconLike = Parameters<Parameters<typeof createEnsureTrayHandler>[0]['createTray']>[0];
type DestroyTrayMainDeps = Parameters<typeof createBuildDestroyTrayMainDepsHandler<TrayLike>>[0];
type EnsureTrayDeps = Parameters<typeof createEnsureTrayHandler>[0];
type DestroyTrayDeps = Parameters<typeof createDestroyTrayHandler>[0];
export function createTrayRuntimeHandlers<TMenuItem, TMenu>(deps: {
resolveTrayIconPathDeps: ResolveTrayIconPathMainDeps;
buildTrayMenuTemplateDeps: BuildTrayMenuTemplateMainDeps<TMenuItem>;
ensureTrayDeps: Omit<EnsureTrayMainDeps<TMenu>, 'buildTrayMenu' | 'resolveTrayIconPath'>;
destroyTrayDeps: DestroyTrayMainDeps;
ensureTrayDeps: Omit<
EnsureTrayDeps,
'buildTrayMenu' | 'resolveTrayIconPath' | 'ensureOverlayVisibleFromTrayClick'
> &
Parameters<typeof createEnsureOverlayVisibleFromTrayClickHandler>[0];
destroyTrayDeps: DestroyTrayDeps;
buildMenuFromTemplate: (template: TMenuItem[]) => TMenu;
}) {
const resolveTrayIconPath = createResolveTrayIconPathHandler(deps.resolveTrayIconPathDeps);
const buildTrayMenuTemplate = createBuildTrayMenuTemplateHandler(deps.buildTrayMenuTemplateDeps);
const buildTrayMenu = () => deps.buildMenuFromTemplate(buildTrayMenuTemplate());
const ensureTray = createEnsureTrayHandler(
createBuildEnsureTrayMainDepsHandler({
...deps.ensureTrayDeps,
buildTrayMenu: () => buildTrayMenu(),
resolveTrayIconPath: () => resolveTrayIconPath(),
})(),
);
const destroyTray = createDestroyTrayHandler(
createBuildDestroyTrayMainDepsHandler(deps.destroyTrayDeps)(),
);
const {
initializeOverlayRuntime,
isOverlayRuntimeInitialized,
setVisibleOverlayVisible,
...ensureTrayDeps
} = deps.ensureTrayDeps;
const ensureTray = createEnsureTrayHandler({
...ensureTrayDeps,
buildTrayMenu: () => buildTrayMenu(),
resolveTrayIconPath: () => resolveTrayIconPath(),
ensureOverlayVisibleFromTrayClick: createEnsureOverlayVisibleFromTrayClickHandler({
initializeOverlayRuntime,
isOverlayRuntimeInitialized,
setVisibleOverlayVisible,
}),
});
const destroyTray = createDestroyTrayHandler(deps.destroyTrayDeps);
return {
resolveTrayIconPath,
+4 -10
View File
@@ -1,15 +1,9 @@
import { createBuildOpenYomitanSettingsMainDepsHandler } from './app-runtime-main-deps';
import { createOpenYomitanSettingsHandler } from './yomitan-settings-opener';
type OpenYomitanSettingsMainDeps = Parameters<
typeof createBuildOpenYomitanSettingsMainDepsHandler
>[0];
export function createYomitanSettingsRuntime(deps: OpenYomitanSettingsMainDeps) {
const openYomitanSettingsMainDeps = createBuildOpenYomitanSettingsMainDepsHandler(deps)();
const openYomitanSettings = createOpenYomitanSettingsHandler(openYomitanSettingsMainDeps);
export function createYomitanSettingsRuntime(
deps: Parameters<typeof createOpenYomitanSettingsHandler>[0],
) {
return {
openYomitanSettings,
openYomitanSettings: createOpenYomitanSettingsHandler(deps),
};
}