diff --git a/docs/subagents/INDEX.md b/docs/subagents/INDEX.md index d36aede..5e437d0 100644 --- a/docs/subagents/INDEX.md +++ b/docs/subagents/INDEX.md @@ -6,7 +6,7 @@ Read first. Keep concise. | ------------ | -------------- | ---------------------------------------------------- | --------- | ------------------------------------- | ---------------------- | | `codex-main` | `planner-exec` | `Fix frequency/N+1 regression in plugin --start flow` | `in_progress` | `docs/subagents/agents/codex-main.md` | `2026-02-19T19:36:46Z` | | `codex-config-validation-20260219T172015Z-iiyf` | `codex-config-validation` | `Find root cause of config validation error for ~/.config/SubMiner/config.jsonc` | `completed` | `docs/subagents/agents/codex-config-validation-20260219T172015Z-iiyf.md` | `2026-02-19T17:26:17Z` | -| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T06:56:20Z` | +| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T07:35:01Z` | | `codex-anilist-deeplink-20260219T233926Z` | `anilist-deeplink` | `Fix external subminer:// AniList callback handling from browser` | `done` | `docs/subagents/agents/codex-anilist-deeplink-20260219T233926Z.md` | `2026-02-19T23:59:21Z` | | `codex-texthooker-highlights-20260220T002354Z-927c` | `codex-texthooker-highlights` | `Add optional texthooker highlight toggles for known/n+1/frequency/JLPT` | `completed` | `docs/subagents/agents/codex-texthooker-highlights-20260220T002354Z-927c.md` | `2026-02-20T00:30:49Z` | | `codex-texthooker-ui-playwright-20260220T003827Z-k3p9` | `codex-texthooker-ui-playwright` | `Run Playwright MCP smoke/regression checks for texthooker-ui changes` | `completed` | `docs/subagents/agents/codex-texthooker-ui-playwright-20260220T003827Z-k3p9.md` | `2026-02-20T00:42:09Z` | diff --git a/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md b/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md index a95f08e..09b5723 100644 --- a/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md +++ b/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md @@ -9,6 +9,16 @@ ## Current Work (newest first) +- [2026-02-20T07:35:01Z] progress: extracted subtitle processing controller deps assembly into `src/main/runtime/subtitle-processing-main-deps.ts` (`createBuildSubtitleProcessingControllerMainDepsHandler`) and rewired `subtitleProcessingController` construction in `src/main.ts`. +- [2026-02-20T07:35:01Z] progress: added parity tests in `src/main/runtime/subtitle-processing-main-deps.test.ts`; `src/main.ts` now 2775 LOC. +- [2026-02-20T07:35:01Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/subtitle-processing-main-deps.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/jellyfin-remote-main-deps.test.js` pass (9/9). +- [2026-02-20T07:33:56Z] progress: extracted Jellyfin remote deps assembly into `src/main/runtime/jellyfin-remote-main-deps.ts` (play/playstate/general-command/progress/stopped builders) and rewired corresponding constructor wiring in `src/main.ts` to builder-based deps. +- [2026-02-20T07:33:56Z] progress: added parity tests in `src/main/runtime/jellyfin-remote-main-deps.test.ts`; `src/main.ts` now 2770 LOC. +- [2026-02-20T07:33:56Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-remote-main-deps.test.js dist/main/runtime/jellyfin-remote-commands.test.js dist/main/runtime/jellyfin-remote-playback.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js` pass (18/18). +- [2026-02-20T07:31:59Z] progress: extracted config hot-reload dependency assembly into `src/main/runtime/config-hot-reload-main-deps.ts` (`createWatchConfigPathHandler`, `createBuildConfigHotReloadAppliedMainDepsHandler`, `createBuildConfigHotReloadRuntimeMainDepsHandler`) and rewired `configHotReloadRuntime` construction in `src/main.ts` to builder-based deps. +- [2026-02-20T07:31:59Z] progress: preserved runtime parity by keeping debounce/watch-path behavior and `createConfigHotReloadAppliedHandler` side-effects unchanged while moving inline glue code out of `src/main.ts`. +- [2026-02-20T07:31:59Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/config-hot-reload-main-deps.test.js dist/main/runtime/config-hot-reload-handlers.test.js dist/core/services/config-hot-reload.test.js` pass (12/12). +- [2026-02-20T07:31:59Z] next: continue TASK-85 by extracting next high-churn deps assembly from `src/main.ts` (candidate: startup/bootstrap or app-ready clusters) into focused `*-main-deps.ts` + parity tests. - [2026-02-20T03:27:35Z] progress: extracted CLI context deps builder into `src/main/runtime/cli-command-context-deps.ts` (`createBuildCliCommandContextDepsHandler`) and rewired `handleCliCommand` to build deps through the helper. - [2026-02-20T03:27:35Z] progress: extracted overlay runtime options builder into `src/main/runtime/overlay-runtime-options.ts` (`createBuildInitializeOverlayRuntimeOptionsHandler`) and rewired `initializeOverlayRuntime` `buildOptions`; extracted Yomitan extension load wrappers into `src/main/runtime/yomitan-extension-loader.ts` (`createLoadYomitanExtensionHandler`, `createEnsureYomitanExtensionLoadedHandler`) and rewired `loadYomitanExtension` + `ensureYomitanExtensionLoaded`. - [2026-02-20T03:27:35Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/cli-command-context-deps.test.js dist/main/runtime/overlay-runtime-options.test.js dist/main/runtime/yomitan-extension-loader.test.js dist/main/runtime/tray-main-actions.test.js dist/main/runtime/overlay-window-factory.test.js dist/main/runtime/ipc-bridge-actions.test.js dist/main/runtime/overlay-main-actions.test.js dist/main/runtime/overlay-visibility-actions.test.js dist/main/runtime/mining-actions.test.js dist/main/runtime/anki-actions.test.js dist/main/runtime/overlay-shortcuts-lifecycle.test.js dist/main/runtime/numeric-shortcut-session-handlers.test.js dist/main/runtime/mpv-osd-log.test.js dist/main/runtime/global-shortcuts.test.js dist/main/runtime/yomitan-settings-opener.test.js dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (116/116). @@ -215,6 +225,16 @@ - `src/main/runtime/field-grouping-resolver.test.ts` - `src/main/runtime/mpv-jellyfin-defaults.ts` - `src/main/runtime/mpv-jellyfin-defaults.test.ts` +- `src/main/runtime/field-grouping-overlay-main-deps.ts` +- `src/main/runtime/field-grouping-overlay-main-deps.test.ts` +- `src/main/runtime/media-runtime-main-deps.ts` +- `src/main/runtime/media-runtime-main-deps.test.ts` +- `src/main/runtime/overlay-visibility-runtime-main-deps.ts` +- `src/main/runtime/overlay-visibility-runtime-main-deps.test.ts` +- `src/main/runtime/dictionary-runtime-main-deps.ts` +- `src/main/runtime/dictionary-runtime-main-deps.test.ts` +- `src/main/runtime/overlay-shortcuts-runtime-main-deps.ts` +- `src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts` ## Open Questions / Blockers @@ -290,3 +310,20 @@ - [2026-02-20T06:56:20Z] progress: extracted `applyJellyfinMpvDefaults` and `getDefaultSocketPath` wrappers into `src/main/runtime/mpv-jellyfin-defaults.ts`; rewired both `main.ts` helper functions to thin handler delegates. - [2026-02-20T06:56:20Z] progress: `src/main.ts` currently 2742 LOC after this slice. - [2026-02-20T06:56:20Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/cli-command-prechecks-main-deps.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/field-grouping-resolver.test.js dist/main/runtime/mpv-jellyfin-defaults.test.js dist/main/runtime/startup-bootstrap-main-deps.test.js` pass (11/11). +- [2026-02-20T07:12:59Z] progress: extracted field-grouping overlay deps assembly into `src/main/runtime/field-grouping-overlay-main-deps.ts` and rewired `createFieldGroupingOverlayRuntime` setup in `src/main.ts` to use builder output. +- [2026-02-20T07:12:59Z] progress: resolved strict typing mismatch by parameterizing builder choice type and binding it to `KikuFieldGroupingChoice` in `main.ts`. +- [2026-02-20T07:12:59Z] progress: `src/main.ts` currently 2747 LOC after this slice. +- [2026-02-20T07:12:59Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/field-grouping-overlay-main-deps.test.js dist/core/services/field-grouping-overlay.test.js dist/main/runtime/field-grouping-resolver.test.js` pass (6/6). +- [2026-02-20T07:14:28Z] progress: extracted media runtime deps assembly into `src/main/runtime/media-runtime-main-deps.ts` and rewired `createMediaRuntimeService` setup in `src/main.ts` through the builder. +- [2026-02-20T07:14:28Z] progress: `src/main.ts` currently 2750 LOC after this slice. +- [2026-02-20T07:14:28Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/media-runtime-main-deps.test.js dist/main/media-runtime.test.js dist/main/runtime/field-grouping-overlay-main-deps.test.js` pass (2/2 from selected bundle output). +- [2026-02-20T07:16:22Z] progress: extracted overlay visibility runtime deps assembly into `src/main/runtime/overlay-visibility-runtime-main-deps.ts` and rewired `createOverlayVisibilityRuntimeService` setup in `src/main.ts` through the builder. +- [2026-02-20T07:16:22Z] progress: `src/main.ts` currently 2753 LOC after this slice. +- [2026-02-20T07:16:22Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/overlay-visibility-runtime-main-deps.test.js dist/main/overlay-visibility-runtime.test.js dist/main/runtime/media-runtime-main-deps.test.js` pass (2/2 from selected bundle output). +- [2026-02-20T07:25:54Z] progress: extracted JLPT/frequency dictionary runtime deps assembly and root-list construction into `src/main/runtime/dictionary-runtime-main-deps.ts`; rewired `createJlptDictionaryRuntimeService` + `createFrequencyDictionaryRuntimeService` setup in `src/main.ts`. +- [2026-02-20T07:25:54Z] progress: adjusted frequency roots wiring to preserve previous behavior exactly (separate frequency roots builder, no JLPT-root leakage). +- [2026-02-20T07:25:54Z] progress: `src/main.ts` currently 2747 LOC after this slice. +- [2026-02-20T07:25:54Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/dictionary-runtime-main-deps.test.js dist/main/frequency-dictionary-runtime.test.js dist/main/jlpt-runtime.test.js` pass (4/4 from selected bundle output). +- [2026-02-20T07:27:15Z] progress: extracted overlay-shortcuts runtime deps assembly into `src/main/runtime/overlay-shortcuts-runtime-main-deps.ts` and rewired `createOverlayShortcutsRuntimeService` setup in `src/main.ts` through the builder. +- [2026-02-20T07:27:15Z] progress: `src/main.ts` currently 2750 LOC after this slice. +- [2026-02-20T07:27:15Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/overlay-shortcuts-runtime-main-deps.test.js dist/main/overlay-shortcuts-runtime.test.js dist/main/runtime/overlay-shortcuts-lifecycle.test.js` pass (5/5). diff --git a/src/main.ts b/src/main.ts index 2672252..308006e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -106,6 +106,14 @@ import { createReportJellyfinRemoteProgressHandler, createReportJellyfinRemoteStoppedHandler, } from './main/runtime/jellyfin-remote-playback'; +import { + createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler, + createBuildHandleJellyfinRemotePlayMainDepsHandler, + createBuildHandleJellyfinRemotePlaystateMainDepsHandler, + createBuildReportJellyfinRemoteProgressMainDepsHandler, + createBuildReportJellyfinRemoteStoppedMainDepsHandler, +} from './main/runtime/jellyfin-remote-main-deps'; +import { createBuildSubtitleProcessingControllerMainDepsHandler } from './main/runtime/subtitle-processing-main-deps'; import { createEnsureMpvConnectedForJellyfinPlaybackHandler, createLaunchMpvIdleForJellyfinPlaybackHandler, @@ -156,6 +164,13 @@ import { createApplyJellyfinMpvDefaultsHandler, createGetDefaultSocketPathHandler, } from './main/runtime/mpv-jellyfin-defaults'; +import { createBuildMediaRuntimeMainDepsHandler } from './main/runtime/media-runtime-main-deps'; +import { + createBuildDictionaryRootsMainHandler, + createBuildFrequencyDictionaryRootsMainHandler, + createBuildFrequencyDictionaryRuntimeMainDepsHandler, + createBuildJlptDictionaryRuntimeMainDepsHandler, +} from './main/runtime/dictionary-runtime-main-deps'; import { createPlayJellyfinItemInMpvHandler } from './main/runtime/jellyfin-playback-launch'; import { createPreloadJellyfinExternalSubtitlesHandler } from './main/runtime/jellyfin-subtitle-preload'; import { @@ -170,6 +185,7 @@ import { createGetFieldGroupingResolverHandler, createSetFieldGroupingResolverHandler, } from './main/runtime/field-grouping-resolver'; +import { createBuildFieldGroupingOverlayMainDepsHandler } from './main/runtime/field-grouping-overlay-main-deps'; import { createCliCommandContext } from './main/runtime/cli-command-context'; import { createBindMpvMainEventHandlersHandler } from './main/runtime/mpv-main-event-bindings'; import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './main/runtime/mpv-main-event-main-deps'; @@ -221,6 +237,7 @@ import { createSyncOverlayShortcutsHandler, createUnregisterOverlayShortcutsHandler, } from './main/runtime/overlay-shortcuts-lifecycle'; +import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/overlay-shortcuts-runtime-main-deps'; import { createMarkLastCardAsAudioCardHandler, createMineSentenceCardHandler, @@ -239,6 +256,7 @@ import { createToggleInvisibleOverlayHandler, createToggleVisibleOverlayHandler, } from './main/runtime/overlay-visibility-actions'; +import { createBuildOverlayVisibilityRuntimeMainDepsHandler } from './main/runtime/overlay-visibility-runtime-main-deps'; import { createAppendClipboardVideoToQueueHandler, createHandleOverlayModalClosedHandler, @@ -306,6 +324,11 @@ import { createConfigHotReloadMessageHandler, resolveSubtitleStyleForRenderer, } from './main/runtime/config-hot-reload-handlers'; +import { + createBuildConfigHotReloadAppliedMainDepsHandler, + createBuildConfigHotReloadRuntimeMainDepsHandler, + createWatchConfigPathHandler, +} from './main/runtime/config-hot-reload-main-deps'; import { createBuildCriticalConfigErrorMainDepsHandler, createBuildReloadConfigMainDepsHandler, @@ -607,7 +630,8 @@ const subsyncRuntime = createMainSubsyncRuntime({ }, }); let appTray: Tray | null = null; -const subtitleProcessingController = createSubtitleProcessingController({ +const buildSubtitleProcessingControllerMainDepsHandler = + createBuildSubtitleProcessingControllerMainDepsHandler({ tokenizeSubtitle: async (text: string) => { if (getOverlayWindows().length === 0 && !subtitleWsService.hasClients()) { return null; @@ -627,50 +651,96 @@ const subtitleProcessingController = createSubtitleProcessingController({ }, now: () => Date.now(), }); -const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({ - getConfiguredShortcuts: () => getConfiguredShortcuts(), - getShortcutsRegistered: () => appState.shortcutsRegistered, - setShortcutsRegistered: (registered) => { - appState.shortcutsRegistered = registered; - }, - isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, - showMpvOsd: (text: string) => showMpvOsd(text), - openRuntimeOptionsPalette: () => { - openRuntimeOptionsPalette(); - }, - openJimaku: () => { - sendToActiveOverlayWindow('jimaku:open', undefined, { - restoreOnModalClose: 'jimaku', - }); - }, - markAudioCard: () => markLastCardAsAudioCard(), - copySubtitleMultiple: (timeoutMs) => { - startPendingMultiCopy(timeoutMs); - }, - copySubtitle: () => { - copyCurrentSubtitle(); - }, - toggleSecondarySubMode: () => cycleSecondarySubMode(), - updateLastCardFromClipboard: () => updateLastCardFromClipboard(), - triggerFieldGrouping: () => triggerFieldGrouping(), - triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), - mineSentenceCard: () => mineSentenceCard(), - mineSentenceMultiple: (timeoutMs) => { - startPendingMineSentenceMultiple(timeoutMs); - }, - cancelPendingMultiCopy: () => { - cancelPendingMultiCopy(); - }, - cancelPendingMineSentenceMultiple: () => { - cancelPendingMineSentenceMultiple(); - }, -}); +const subtitleProcessingController = createSubtitleProcessingController( + buildSubtitleProcessingControllerMainDepsHandler(), +); +const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( + createBuildOverlayShortcutsRuntimeMainDepsHandler({ + getConfiguredShortcuts: () => getConfiguredShortcuts(), + getShortcutsRegistered: () => appState.shortcutsRegistered, + setShortcutsRegistered: (registered) => { + appState.shortcutsRegistered = registered; + }, + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + showMpvOsd: (text: string) => showMpvOsd(text), + openRuntimeOptionsPalette: () => { + openRuntimeOptionsPalette(); + }, + openJimaku: () => { + sendToActiveOverlayWindow('jimaku:open', undefined, { + restoreOnModalClose: 'jimaku', + }); + }, + markAudioCard: () => markLastCardAsAudioCard(), + copySubtitleMultiple: (timeoutMs) => { + startPendingMultiCopy(timeoutMs); + }, + copySubtitle: () => { + copyCurrentSubtitle(); + }, + toggleSecondarySubMode: () => cycleSecondarySubMode(), + updateLastCardFromClipboard: () => updateLastCardFromClipboard(), + triggerFieldGrouping: () => triggerFieldGrouping(), + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + mineSentenceCard: () => mineSentenceCard(), + mineSentenceMultiple: (timeoutMs) => { + startPendingMineSentenceMultiple(timeoutMs); + }, + cancelPendingMultiCopy: () => { + cancelPendingMultiCopy(); + }, + cancelPendingMineSentenceMultiple: () => { + cancelPendingMineSentenceMultiple(); + }, + })(), +); const notifyConfigHotReloadMessage = createConfigHotReloadMessageHandler({ showMpvOsd: (message) => showMpvOsd(message), showDesktopNotification: (title, options) => showDesktopNotification(title, options), }); -const handleJellyfinRemotePlay = createHandleJellyfinRemotePlay({ +const watchConfigPathHandler = createWatchConfigPathHandler({ + fileExists: (targetPath) => fs.existsSync(targetPath), + dirname: (targetPath) => path.dirname(targetPath), + watchPath: (targetPath, listener) => fs.watch(targetPath, listener), +}); +const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadAppliedMainDepsHandler({ + setKeybindings: (keybindings) => { + appState.keybindings = keybindings as never; + }, + refreshGlobalAndOverlayShortcuts: () => { + refreshGlobalAndOverlayShortcuts(); + }, + setSecondarySubMode: (mode) => { + appState.secondarySubMode = mode as never; + }, + broadcastToOverlayWindows: (channel, payload) => { + broadcastToOverlayWindows(channel, payload); + }, + applyAnkiRuntimeConfigPatch: (patch) => { + if (appState.ankiIntegration) { + appState.ankiIntegration.applyRuntimeConfigPatch(patch as never); + } + }, +}); +const buildConfigHotReloadRuntimeMainDepsHandler = createBuildConfigHotReloadRuntimeMainDepsHandler({ + getCurrentConfig: () => getResolvedConfig(), + reloadConfigStrict: () => configService.reloadConfigStrict(), + watchConfigPath: (configPath, onChange) => watchConfigPathHandler(configPath, onChange), + setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimeout: (timeout) => clearTimeout(timeout), + debounceMs: 250, + onHotReloadApplied: createConfigHotReloadAppliedHandler(buildConfigHotReloadAppliedMainDepsHandler()), + onRestartRequired: (fields) => notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)), + onInvalidConfig: notifyConfigHotReloadMessage, + onValidationWarnings: (configPath, warnings) => { + showDesktopNotification('SubMiner', { + body: buildConfigWarningNotificationBody(configPath, warnings), + }); + }, +}); +const buildHandleJellyfinRemotePlayMainDepsHandler = + createBuildHandleJellyfinRemotePlayMainDepsHandler({ getConfiguredSession: () => getConfiguredJellyfinSession(getResolvedJellyfinConfig()), getClientInfo: () => getJellyfinClientInfo(), getJellyfinConfig: () => getResolvedJellyfinConfig(), @@ -678,21 +748,24 @@ const handleJellyfinRemotePlay = createHandleJellyfinRemotePlay({ playJellyfinItemInMpv(params as Parameters[0]), logWarn: (message) => logger.warn(message), }); -const handleJellyfinRemotePlaystate = createHandleJellyfinRemotePlaystate({ +const buildHandleJellyfinRemotePlaystateMainDepsHandler = + createBuildHandleJellyfinRemotePlaystateMainDepsHandler({ getMpvClient: () => appState.mpvClient, sendMpvCommand: (client, command) => sendMpvCommandRuntime(client as MpvIpcClient, command), reportJellyfinRemoteProgress: (force) => reportJellyfinRemoteProgress(force), reportJellyfinRemoteStopped: () => reportJellyfinRemoteStopped(), jellyfinTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks), }); -const handleJellyfinRemoteGeneralCommand = createHandleJellyfinRemoteGeneralCommand({ +const buildHandleJellyfinRemoteGeneralCommandMainDepsHandler = + createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler({ getMpvClient: () => appState.mpvClient, sendMpvCommand: (client, command) => sendMpvCommandRuntime(client as MpvIpcClient, command), getActivePlayback: () => activeJellyfinRemotePlayback, reportJellyfinRemoteProgress: (force) => reportJellyfinRemoteProgress(force), logDebug: (message) => logger.debug(message), }); -const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler({ +const buildReportJellyfinRemoteProgressMainDepsHandler = + createBuildReportJellyfinRemoteProgressMainDepsHandler({ getActivePlayback: () => activeJellyfinRemotePlayback, clearActivePlayback: () => { activeJellyfinRemotePlayback = null; @@ -708,7 +781,8 @@ const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler({ ticksPerSecond: JELLYFIN_TICKS_PER_SECOND, logDebug: (message, error) => logger.debug(message, error), }); -const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler({ +const buildReportJellyfinRemoteStoppedMainDepsHandler = + createBuildReportJellyfinRemoteStoppedMainDepsHandler({ getActivePlayback: () => activeJellyfinRemotePlayback, clearActivePlayback: () => { activeJellyfinRemotePlayback = null; @@ -716,118 +790,69 @@ const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler({ getSession: () => appState.jellyfinRemoteSession, logDebug: (message, error) => logger.debug(message, error), }); +const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler( + buildReportJellyfinRemoteProgressMainDepsHandler(), +); +const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler( + buildReportJellyfinRemoteStoppedMainDepsHandler(), +); +const handleJellyfinRemotePlay = createHandleJellyfinRemotePlay( + buildHandleJellyfinRemotePlayMainDepsHandler(), +); +const handleJellyfinRemotePlaystate = createHandleJellyfinRemotePlaystate( + buildHandleJellyfinRemotePlaystateMainDepsHandler(), +); +const handleJellyfinRemoteGeneralCommand = createHandleJellyfinRemoteGeneralCommand( + buildHandleJellyfinRemoteGeneralCommandMainDepsHandler(), +); -const configHotReloadRuntime = createConfigHotReloadRuntime({ - getCurrentConfig: () => getResolvedConfig(), - reloadConfigStrict: () => configService.reloadConfigStrict(), - watchConfigPath: (configPath, onChange) => { - const watchTarget = fs.existsSync(configPath) ? configPath : path.dirname(configPath); - const watcher = fs.watch(watchTarget, (_eventType, filename) => { - if (watchTarget === configPath) { - onChange(); - return; - } +const configHotReloadRuntime = createConfigHotReloadRuntime(buildConfigHotReloadRuntimeMainDepsHandler()); - const normalized = - typeof filename === 'string' ? filename : filename ? String(filename) : undefined; - if (!normalized || normalized === 'config.json' || normalized === 'config.jsonc') { - onChange(); - } - }); - return { - close: () => { - watcher.close(); - }, - }; - }, - setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), - clearTimeout: (timeout) => clearTimeout(timeout), - debounceMs: 250, - onHotReloadApplied: createConfigHotReloadAppliedHandler({ - setKeybindings: (keybindings) => { - appState.keybindings = keybindings; - }, - refreshGlobalAndOverlayShortcuts: () => { - refreshGlobalAndOverlayShortcuts(); - }, - setSecondarySubMode: (mode) => { - appState.secondarySubMode = mode; - }, - broadcastToOverlayWindows: (channel, payload) => { - broadcastToOverlayWindows(channel, payload); - }, - applyAnkiRuntimeConfigPatch: (patch) => { - if (appState.ankiIntegration) { - appState.ankiIntegration.applyRuntimeConfigPatch(patch); - } - }, - }), - onRestartRequired: (fields) => notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)), - onInvalidConfig: notifyConfigHotReloadMessage, - onValidationWarnings: (configPath, warnings) => { - showDesktopNotification('SubMiner', { - body: buildConfigWarningNotificationBody(configPath, warnings), - }); - }, +const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({ + dirname: __dirname, + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + userDataPath: USER_DATA_PATH, + appUserDataPath: app.getPath('userData'), + homeDir: os.homedir(), + cwd: process.cwd(), + joinPath: (...parts) => path.join(...parts), +}); +const buildFrequencyDictionaryRootsHandler = createBuildFrequencyDictionaryRootsMainHandler({ + dirname: __dirname, + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + userDataPath: USER_DATA_PATH, + appUserDataPath: app.getPath('userData'), + homeDir: os.homedir(), + cwd: process.cwd(), + joinPath: (...parts) => path.join(...parts), }); -const jlptDictionaryRuntime = createJlptDictionaryRuntimeService({ - isJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt, - getSearchPaths: () => - getJlptDictionarySearchPaths({ - getDictionaryRoots: () => [ - path.join(__dirname, '..', '..', 'vendor', 'yomitan-jlpt-vocab'), - path.join(app.getAppPath(), 'vendor', 'yomitan-jlpt-vocab'), - path.join(process.resourcesPath, 'yomitan-jlpt-vocab'), - path.join(process.resourcesPath, 'app.asar', 'vendor', 'yomitan-jlpt-vocab'), - USER_DATA_PATH, - app.getPath('userData'), - path.join(os.homedir(), '.config', 'SubMiner'), - path.join(os.homedir(), '.config', 'subminer'), - path.join(os.homedir(), 'Library', 'Application Support', 'SubMiner'), - path.join(os.homedir(), 'Library', 'Application Support', 'subminer'), - process.cwd(), - ], - }), - setJlptLevelLookup: (lookup) => { - appState.jlptLevelLookup = lookup; - }, - log: (message) => { - logger.info(`[JLPT] ${message}`); - }, -}); +const jlptDictionaryRuntime = createJlptDictionaryRuntimeService( + createBuildJlptDictionaryRuntimeMainDepsHandler({ + isJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt, + getDictionaryRoots: () => buildDictionaryRootsHandler(), + getJlptDictionarySearchPaths, + setJlptLevelLookup: (lookup) => { + appState.jlptLevelLookup = lookup as never; + }, + logInfo: (message) => logger.info(message), + })(), +); -const frequencyDictionaryRuntime = createFrequencyDictionaryRuntimeService({ - isFrequencyDictionaryEnabled: () => getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, - getSearchPaths: () => - getFrequencyDictionarySearchPaths({ - getDictionaryRoots: () => - [ - path.join(__dirname, '..', '..', 'vendor', 'jiten_freq_global'), - path.join(__dirname, '..', '..', 'vendor', 'frequency-dictionary'), - path.join(app.getAppPath(), 'vendor', 'jiten_freq_global'), - path.join(app.getAppPath(), 'vendor', 'frequency-dictionary'), - path.join(process.resourcesPath, 'jiten_freq_global'), - path.join(process.resourcesPath, 'frequency-dictionary'), - path.join(process.resourcesPath, 'app.asar', 'vendor', 'jiten_freq_global'), - path.join(process.resourcesPath, 'app.asar', 'vendor', 'frequency-dictionary'), - USER_DATA_PATH, - app.getPath('userData'), - path.join(os.homedir(), '.config', 'SubMiner'), - path.join(os.homedir(), '.config', 'subminer'), - path.join(os.homedir(), 'Library', 'Application Support', 'SubMiner'), - path.join(os.homedir(), 'Library', 'Application Support', 'subminer'), - process.cwd(), - ].filter((dictionaryRoot) => dictionaryRoot), - getSourcePath: () => getResolvedConfig().subtitleStyle.frequencyDictionary.sourcePath, - }), - setFrequencyRankLookup: (lookup) => { - appState.frequencyRankLookup = lookup; - }, - log: (message) => { - logger.info(`[Frequency] ${message}`); - }, -}); +const frequencyDictionaryRuntime = createFrequencyDictionaryRuntimeService( + createBuildFrequencyDictionaryRuntimeMainDepsHandler({ + isFrequencyDictionaryEnabled: () => getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + getDictionaryRoots: () => buildFrequencyDictionaryRootsHandler(), + getFrequencyDictionarySearchPaths, + getSourcePath: () => getResolvedConfig().subtitleStyle.frequencyDictionary.sourcePath, + setFrequencyRankLookup: (lookup) => { + appState.frequencyRankLookup = lookup as never; + }, + logInfo: (message) => logger.info(message), + })(), +); const getFieldGroupingResolverHandler = createGetFieldGroupingResolverHandler({ getResolver: () => appState.fieldGroupingResolver, @@ -854,71 +879,79 @@ function setFieldGroupingResolver( setFieldGroupingResolverHandler(resolver); } -const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime({ - getMainWindow: () => overlayManager.getMainWindow(), - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), - setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), - setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible), - getResolver: () => getFieldGroupingResolver(), - setResolver: (resolver) => setFieldGroupingResolver(resolver), - getRestoreVisibleOverlayOnModalClose: () => - overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(), - sendToVisibleOverlay: (channel, payload, runtimeOptions) => { - return overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions); - }, -}); +const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime( + createBuildFieldGroupingOverlayMainDepsHandler< + OverlayHostedModal, + KikuFieldGroupingChoice + >({ + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), + setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible), + getResolver: () => getFieldGroupingResolver(), + setResolver: (resolver) => setFieldGroupingResolver(resolver), + getRestoreVisibleOverlayOnModalClose: () => + overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(), + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => + overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), + })(), +); const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroupingCallback; const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions'); -const mediaRuntime = createMediaRuntimeService({ - isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath), - loadSubtitlePosition: () => loadSubtitlePosition(), - getCurrentMediaPath: () => appState.currentMediaPath, - getPendingSubtitlePosition: () => appState.pendingSubtitlePosition, - getSubtitlePositionsDir: () => SUBTITLE_POSITIONS_DIR, - setCurrentMediaPath: (nextPath: string | null) => { - appState.currentMediaPath = nextPath; - }, - clearPendingSubtitlePosition: () => { - appState.pendingSubtitlePosition = null; - }, - setSubtitlePosition: (position: SubtitlePosition | null) => { - appState.subtitlePosition = position; - }, - broadcastSubtitlePosition: (position) => { - broadcastToOverlayWindows('subtitle-position:set', position); - }, - getCurrentMediaTitle: () => appState.currentMediaTitle, - setCurrentMediaTitle: (title) => { - appState.currentMediaTitle = title; - }, -}); +const mediaRuntime = createMediaRuntimeService( + createBuildMediaRuntimeMainDepsHandler({ + isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath), + loadSubtitlePosition: () => loadSubtitlePosition(), + getCurrentMediaPath: () => appState.currentMediaPath, + getPendingSubtitlePosition: () => appState.pendingSubtitlePosition, + getSubtitlePositionsDir: () => SUBTITLE_POSITIONS_DIR, + setCurrentMediaPath: (nextPath: string | null) => { + appState.currentMediaPath = nextPath; + }, + clearPendingSubtitlePosition: () => { + appState.pendingSubtitlePosition = null; + }, + setSubtitlePosition: (position: SubtitlePosition | null) => { + appState.subtitlePosition = position; + }, + broadcastToOverlayWindows: (channel, payload) => { + broadcastToOverlayWindows(channel, payload); + }, + getCurrentMediaTitle: () => appState.currentMediaTitle, + setCurrentMediaTitle: (title) => { + appState.currentMediaTitle = title; + }, + })(), +); -const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService({ - getMainWindow: () => overlayManager.getMainWindow(), - getInvisibleWindow: () => overlayManager.getInvisibleWindow(), - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), - getWindowTracker: () => appState.windowTracker, - getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, - setTrackerNotReadyWarningShown: (shown: boolean) => { - appState.trackerNotReadyWarningShown = shown; - }, - updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry), - updateInvisibleOverlayBounds: (geometry: WindowGeometry) => - updateInvisibleOverlayBounds(geometry), - ensureOverlayWindowLevel: (window) => { - ensureOverlayWindowLevel(window); - }, - enforceOverlayLayerOrder: () => { - enforceOverlayLayerOrder(); - }, - syncOverlayShortcuts: () => { - overlayShortcutsRuntime.syncOverlayShortcuts(); - }, -}); +const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( + createBuildOverlayVisibilityRuntimeMainDepsHandler({ + getMainWindow: () => overlayManager.getMainWindow(), + getInvisibleWindow: () => overlayManager.getInvisibleWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), + getWindowTracker: () => appState.windowTracker, + getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, + setTrackerNotReadyWarningShown: (shown: boolean) => { + appState.trackerNotReadyWarningShown = shown; + }, + updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry), + updateInvisibleOverlayBounds: (geometry: WindowGeometry) => + updateInvisibleOverlayBounds(geometry), + ensureOverlayWindowLevel: (window) => { + ensureOverlayWindowLevel(window); + }, + enforceOverlayLayerOrder: () => { + enforceOverlayLayerOrder(); + }, + syncOverlayShortcuts: () => { + overlayShortcutsRuntime.syncOverlayShortcuts(); + }, + })(), +); const getRuntimeOptionsStateHandler = createGetRuntimeOptionsStateHandler({ getRuntimeOptionsManager: () => appState.runtimeOptionsManager, diff --git a/src/main/runtime/config-hot-reload-main-deps.test.ts b/src/main/runtime/config-hot-reload-main-deps.test.ts new file mode 100644 index 0000000..9166706 --- /dev/null +++ b/src/main/runtime/config-hot-reload-main-deps.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { ReloadConfigStrictResult } from '../../config'; +import type { ResolvedConfig } from '../../types'; +import { + createBuildConfigHotReloadAppliedMainDepsHandler, + createBuildConfigHotReloadRuntimeMainDepsHandler, + 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']); +}); + +test('config hot reload applied main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildConfigHotReloadAppliedMainDepsHandler({ + setKeybindings: () => calls.push('keybindings'), + refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'), + setSecondarySubMode: () => calls.push('set-secondary'), + broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`), + applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'), + })(); + + deps.setKeybindings([]); + deps.refreshGlobalAndOverlayShortcuts(); + deps.setSecondarySubMode('hover'); + deps.broadcastToOverlayWindows('config:hot-reload', {}); + deps.applyAnkiRuntimeConfigPatch({ ai: {} as never }); + assert.deepEqual(calls, [ + 'keybindings', + 'refresh-shortcuts', + 'set-secondary', + 'broadcast:config:hot-reload', + 'apply-anki', + ]); +}); + +test('config hot reload runtime main deps builder maps runtime callbacks', () => { + const calls: string[] = []; + const deps = createBuildConfigHotReloadRuntimeMainDepsHandler({ + getCurrentConfig: () => ({ id: 1 } as never as ResolvedConfig), + reloadConfigStrict: () => + ({ + ok: true, + config: { id: 1 } as never as ResolvedConfig, + warnings: [], + path: '/tmp/config.jsonc', + }) as ReloadConfigStrictResult, + watchConfigPath: (_configPath, _onChange) => ({ close: () => calls.push('close') }), + setTimeout: (callback) => { + callback(); + return 1 as never; + }, + clearTimeout: () => calls.push('clear-timeout'), + debounceMs: 250, + onHotReloadApplied: () => calls.push('hot-reload'), + onRestartRequired: () => calls.push('restart-required'), + onInvalidConfig: () => calls.push('invalid-config'), + onValidationWarnings: () => calls.push('validation-warnings'), + })(); + + assert.deepEqual(deps.getCurrentConfig(), { id: 1 }); + assert.deepEqual(deps.reloadConfigStrict(), { + ok: true, + config: { id: 1 }, + warnings: [], + path: '/tmp/config.jsonc', + }); + assert.equal(deps.debounceMs, 250); + deps.onHotReloadApplied({} as never, {} as never); + deps.onRestartRequired([]); + deps.onInvalidConfig('bad'); + deps.onValidationWarnings('/tmp/config.jsonc', []); + assert.deepEqual(calls, ['hot-reload', 'restart-required', 'invalid-config', 'validation-warnings']); +}); diff --git a/src/main/runtime/config-hot-reload-main-deps.ts b/src/main/runtime/config-hot-reload-main-deps.ts new file mode 100644 index 0000000..84b2a69 --- /dev/null +++ b/src/main/runtime/config-hot-reload-main-deps.ts @@ -0,0 +1,79 @@ +import type { + ConfigHotReloadDiff, + ConfigHotReloadRuntimeDeps, +} from '../../core/services/config-hot-reload'; +import type { ReloadConfigStrictResult } from '../../config'; +import type { ConfigHotReloadPayload, ConfigValidationWarning, ResolvedConfig, SecondarySubMode } from '../../types'; + +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(), + }; + }; +} + +export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { + setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; + refreshGlobalAndOverlayShortcuts: () => void; + setSecondarySubMode: (mode: SecondarySubMode) => void; + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => void; +}) { + return () => ({ + setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => + deps.setKeybindings(keybindings), + refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(), + setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode), + broadcastToOverlayWindows: (channel: string, payload: unknown) => + deps.broadcastToOverlayWindows(channel, payload), + applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => + deps.applyAnkiRuntimeConfigPatch(patch), + }); +} + +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), + }); +} diff --git a/src/main/runtime/dictionary-runtime-main-deps.test.ts b/src/main/runtime/dictionary-runtime-main-deps.test.ts new file mode 100644 index 0000000..5785191 --- /dev/null +++ b/src/main/runtime/dictionary-runtime-main-deps.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildDictionaryRootsMainHandler, + createBuildFrequencyDictionaryRootsMainHandler, + createBuildFrequencyDictionaryRuntimeMainDepsHandler, + createBuildJlptDictionaryRuntimeMainDepsHandler, +} from './dictionary-runtime-main-deps'; + +test('dictionary roots main handler returns expected root list', () => { + const roots = createBuildDictionaryRootsMainHandler({ + 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('jlpt dictionary runtime main deps builder maps search paths and log prefix', () => { + const calls: string[] = []; + const deps = createBuildJlptDictionaryRuntimeMainDepsHandler({ + isJlptEnabled: () => true, + getDictionaryRoots: () => ['/root/a'], + getJlptDictionarySearchPaths: ({ getDictionaryRoots }) => getDictionaryRoots().map((path) => `${path}/jlpt`), + setJlptLevelLookup: () => calls.push('set-lookup'), + logInfo: (message) => calls.push(`log:${message}`), + })(); + + assert.equal(deps.isJlptEnabled(), true); + assert.deepEqual(deps.getSearchPaths(), ['/root/a/jlpt']); + deps.setJlptLevelLookup(() => null); + deps.log('loaded'); + assert.deepEqual(calls, ['set-lookup', 'log:[JLPT] loaded']); +}); + +test('frequency dictionary roots main handler returns expected root list', () => { + const roots = createBuildFrequencyDictionaryRootsMainHandler({ + 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, 15); + assert.equal(roots[0], '/repo/dist/main/../../vendor/jiten_freq_global'); + assert.equal(roots[14], '/repo'); +}); + +test('frequency dictionary runtime main deps builder maps search paths/source and log prefix', () => { + const calls: string[] = []; + const deps = createBuildFrequencyDictionaryRuntimeMainDepsHandler({ + isFrequencyDictionaryEnabled: () => true, + getDictionaryRoots: () => ['/root/a', ''], + getFrequencyDictionarySearchPaths: ({ getDictionaryRoots, getSourcePath }) => [ + ...getDictionaryRoots().map((path) => `${path}/freq`), + getSourcePath() || '', + ], + getSourcePath: () => '/custom/freq.json', + setFrequencyRankLookup: () => calls.push('set-rank'), + logInfo: (message) => calls.push(`log:${message}`), + })(); + + assert.equal(deps.isFrequencyDictionaryEnabled(), true); + assert.deepEqual(deps.getSearchPaths(), ['/root/a/freq', '/custom/freq.json']); + deps.setFrequencyRankLookup(() => null); + deps.log('loaded'); + assert.deepEqual(calls, ['set-rank', 'log:[Frequency] loaded']); +}); diff --git a/src/main/runtime/dictionary-runtime-main-deps.ts b/src/main/runtime/dictionary-runtime-main-deps.ts new file mode 100644 index 0000000..0d73769 --- /dev/null +++ b/src/main/runtime/dictionary-runtime-main-deps.ts @@ -0,0 +1,95 @@ +export function createBuildDictionaryRootsMainHandler(deps: { + dirname: string; + appPath: string; + resourcesPath: string; + userDataPath: string; + appUserDataPath: string; + homeDir: string; + cwd: string; + joinPath: (...parts: string[]) => string; +}) { + 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, + 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'), + deps.cwd, + ]; +} + +export function createBuildFrequencyDictionaryRootsMainHandler(deps: { + dirname: string; + appPath: string; + resourcesPath: string; + userDataPath: string; + appUserDataPath: string; + homeDir: string; + cwd: string; + joinPath: (...parts: string[]) => string; +}) { + return () => [ + deps.joinPath(deps.dirname, '..', '..', 'vendor', 'jiten_freq_global'), + deps.joinPath(deps.dirname, '..', '..', 'vendor', 'frequency-dictionary'), + deps.joinPath(deps.appPath, 'vendor', 'jiten_freq_global'), + deps.joinPath(deps.appPath, 'vendor', 'frequency-dictionary'), + deps.joinPath(deps.resourcesPath, 'jiten_freq_global'), + deps.joinPath(deps.resourcesPath, 'frequency-dictionary'), + deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'jiten_freq_global'), + deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'frequency-dictionary'), + deps.userDataPath, + deps.appUserDataPath, + 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'), + deps.cwd, + ]; +} + +export function createBuildJlptDictionaryRuntimeMainDepsHandler(deps: { + isJlptEnabled: () => boolean; + getDictionaryRoots: () => string[]; + getJlptDictionarySearchPaths: (deps: { getDictionaryRoots: () => string[] }) => string[]; + setJlptLevelLookup: (lookup: unknown) => void; + logInfo: (message: string) => void; +}) { + return () => ({ + isJlptEnabled: () => deps.isJlptEnabled(), + getSearchPaths: () => + deps.getJlptDictionarySearchPaths({ + getDictionaryRoots: () => deps.getDictionaryRoots(), + }), + setJlptLevelLookup: (lookup: unknown) => 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: unknown) => void; + logInfo: (message: string) => void; +}) { + return () => ({ + isFrequencyDictionaryEnabled: () => deps.isFrequencyDictionaryEnabled(), + getSearchPaths: () => + deps.getFrequencyDictionarySearchPaths({ + getDictionaryRoots: () => deps.getDictionaryRoots().filter((dictionaryRoot) => dictionaryRoot), + getSourcePath: () => deps.getSourcePath(), + }), + setFrequencyRankLookup: (lookup: unknown) => deps.setFrequencyRankLookup(lookup), + log: (message: string) => deps.logInfo(`[Frequency] ${message}`), + }); +} diff --git a/src/main/runtime/field-grouping-overlay-main-deps.test.ts b/src/main/runtime/field-grouping-overlay-main-deps.test.ts new file mode 100644 index 0000000..3fde0c6 --- /dev/null +++ b/src/main/runtime/field-grouping-overlay-main-deps.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildFieldGroupingOverlayMainDepsHandler } from './field-grouping-overlay-main-deps'; + +test('field grouping overlay main deps builder maps window visibility and resolver wiring', () => { + const calls: string[] = []; + const modalSet = new Set<'runtime-options'>(); + const resolver = (choice: unknown) => calls.push(`resolver:${choice}`); + + const deps = createBuildFieldGroupingOverlayMainDepsHandler({ + getMainWindow: () => ({ id: 'main' }), + getVisibleOverlayVisible: () => true, + getInvisibleOverlayVisible: () => false, + setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`), + setInvisibleOverlayVisible: (visible) => calls.push(`invisible:${visible}`), + getResolver: () => resolver, + setResolver: (nextResolver) => { + calls.push(`set-resolver:${nextResolver ? 'set' : 'null'}`); + }, + getRestoreVisibleOverlayOnModalClose: () => modalSet, + sendToActiveOverlayWindow: (channel, payload) => { + calls.push(`send:${channel}:${String(payload)}`); + return true; + }, + })(); + + assert.deepEqual(deps.getMainWindow(), { id: 'main' }); + assert.equal(deps.getVisibleOverlayVisible(), true); + assert.equal(deps.getInvisibleOverlayVisible(), false); + assert.equal(deps.getResolver(), resolver); + assert.equal(deps.getRestoreVisibleOverlayOnModalClose(), modalSet); + deps.setVisibleOverlayVisible(true); + deps.setInvisibleOverlayVisible(false); + deps.setResolver(null); + assert.equal(deps.sendToVisibleOverlay('kiku:open', 1), true); + assert.deepEqual(calls, [ + 'visible:true', + 'invisible:false', + 'set-resolver:null', + 'send:kiku:open:1', + ]); +}); diff --git a/src/main/runtime/field-grouping-overlay-main-deps.ts b/src/main/runtime/field-grouping-overlay-main-deps.ts new file mode 100644 index 0000000..1edd8de --- /dev/null +++ b/src/main/runtime/field-grouping-overlay-main-deps.ts @@ -0,0 +1,34 @@ +export function createBuildFieldGroupingOverlayMainDepsHandler< + TModal extends string, + TChoice, +>(deps: { + getMainWindow: () => unknown | null; + getVisibleOverlayVisible: () => boolean; + getInvisibleOverlayVisible: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + setInvisibleOverlayVisible: (visible: boolean) => void; + getResolver: () => ((choice: TChoice) => void) | null; + setResolver: (resolver: ((choice: TChoice) => void) | null) => void; + getRestoreVisibleOverlayOnModalClose: () => Set; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: TModal }, + ) => boolean; +}) { + return () => ({ + getMainWindow: () => deps.getMainWindow() as never, + getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => deps.getInvisibleOverlayVisible(), + setVisibleOverlayVisible: (visible: boolean) => deps.setVisibleOverlayVisible(visible), + setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible), + getResolver: () => deps.getResolver() as never, + setResolver: (resolver: ((choice: TChoice) => void) | null) => deps.setResolver(resolver), + getRestoreVisibleOverlayOnModalClose: () => deps.getRestoreVisibleOverlayOnModalClose(), + sendToVisibleOverlay: ( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: TModal }, + ) => deps.sendToActiveOverlayWindow(channel, payload, runtimeOptions), + }); +} diff --git a/src/main/runtime/jellyfin-remote-main-deps.test.ts b/src/main/runtime/jellyfin-remote-main-deps.test.ts new file mode 100644 index 0000000..e8c6730 --- /dev/null +++ b/src/main/runtime/jellyfin-remote-main-deps.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler, + createBuildHandleJellyfinRemotePlayMainDepsHandler, + createBuildHandleJellyfinRemotePlaystateMainDepsHandler, + createBuildReportJellyfinRemoteProgressMainDepsHandler, + createBuildReportJellyfinRemoteStoppedMainDepsHandler, +} from './jellyfin-remote-main-deps'; + +test('jellyfin remote play main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildHandleJellyfinRemotePlayMainDepsHandler({ + getConfiguredSession: () => ({ id: 1 }) as never, + getClientInfo: () => ({ id: 2 }) as never, + getJellyfinConfig: () => ({ id: 3 }), + playJellyfinItem: async () => { + calls.push('play'); + }, + logWarn: (message) => calls.push(`warn:${message}`), + })(); + + assert.deepEqual(deps.getConfiguredSession(), { id: 1 }); + assert.deepEqual(deps.getClientInfo(), { id: 2 }); + assert.deepEqual(deps.getJellyfinConfig(), { id: 3 }); + await deps.playJellyfinItem({} as never); + deps.logWarn('missing'); + assert.deepEqual(calls, ['play', 'warn:missing']); +}); + +test('jellyfin remote playstate main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildHandleJellyfinRemotePlaystateMainDepsHandler({ + getMpvClient: () => ({ id: 1 }), + sendMpvCommand: () => calls.push('send'), + reportJellyfinRemoteProgress: async () => { + calls.push('progress'); + }, + reportJellyfinRemoteStopped: async () => { + calls.push('stopped'); + }, + jellyfinTicksToSeconds: (ticks) => ticks / 10, + })(); + + assert.deepEqual(deps.getMpvClient(), { id: 1 }); + deps.sendMpvCommand({} as never, ['stop']); + await deps.reportJellyfinRemoteProgress(true); + await deps.reportJellyfinRemoteStopped(); + assert.equal(deps.jellyfinTicksToSeconds(100), 10); + assert.deepEqual(calls, ['send', 'progress', 'stopped']); +}); + +test('jellyfin remote general command main deps builder maps callbacks', async () => { + const calls: string[] = []; + const playback = { itemId: 'abc', playMethod: 'DirectPlay' as const }; + const deps = createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler({ + getMpvClient: () => ({ id: 1 }), + sendMpvCommand: () => calls.push('send'), + getActivePlayback: () => playback, + reportJellyfinRemoteProgress: async () => { + calls.push('progress'); + }, + logDebug: (message) => calls.push(`debug:${message}`), + })(); + + assert.deepEqual(deps.getMpvClient(), { id: 1 }); + deps.sendMpvCommand({} as never, ['set_property', 'sid', 1]); + assert.deepEqual(deps.getActivePlayback(), playback); + await deps.reportJellyfinRemoteProgress(true); + deps.logDebug('ignore'); + assert.deepEqual(calls, ['send', 'progress', 'debug:ignore']); +}); + +test('jellyfin remote progress main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildReportJellyfinRemoteProgressMainDepsHandler({ + getActivePlayback: () => ({ itemId: 'abc', playMethod: 'DirectPlay' }), + clearActivePlayback: () => calls.push('clear'), + getSession: () => ({ id: 1, isConnected: () => true }) as never, + getMpvClient: () => ({ id: 2, requestProperty: async () => 0 }) as never, + getNow: () => 123, + getLastProgressAtMs: () => 10, + setLastProgressAtMs: () => calls.push('set-last'), + progressIntervalMs: 2500, + ticksPerSecond: 10000000, + logDebug: (message) => calls.push(`debug:${message}`), + })(); + + assert.equal(deps.getNow(), 123); + assert.equal(deps.getLastProgressAtMs(), 10); + deps.setLastProgressAtMs(5); + assert.equal(deps.progressIntervalMs, 2500); + assert.equal(deps.ticksPerSecond, 10000000); + deps.clearActivePlayback(); + deps.logDebug('x', null); + assert.deepEqual(calls, ['set-last', 'clear', 'debug:x']); +}); + +test('jellyfin remote stopped main deps builder maps callbacks', () => { + const calls: string[] = []; + const session = { id: 1, isConnected: () => true }; + const deps = createBuildReportJellyfinRemoteStoppedMainDepsHandler({ + getActivePlayback: () => ({ itemId: 'abc', playMethod: 'DirectPlay' }), + clearActivePlayback: () => calls.push('clear'), + getSession: () => session as never, + logDebug: (message) => calls.push(`debug:${message}`), + })(); + + assert.deepEqual(deps.getActivePlayback(), { itemId: 'abc', playMethod: 'DirectPlay' }); + deps.clearActivePlayback(); + assert.equal(deps.getSession(), session); + deps.logDebug('stopped', null); + assert.deepEqual(calls, ['clear', 'debug:stopped']); +}); diff --git a/src/main/runtime/jellyfin-remote-main-deps.ts b/src/main/runtime/jellyfin-remote-main-deps.ts new file mode 100644 index 0000000..aebfa2a --- /dev/null +++ b/src/main/runtime/jellyfin-remote-main-deps.ts @@ -0,0 +1,73 @@ +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(), + 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(), + getSession: () => deps.getSession(), + 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(), + getSession: () => deps.getSession(), + logDebug: (message: string, error: unknown) => deps.logDebug(message, error), + }); +} diff --git a/src/main/runtime/media-runtime-main-deps.test.ts b/src/main/runtime/media-runtime-main-deps.test.ts new file mode 100644 index 0000000..aaeb660 --- /dev/null +++ b/src/main/runtime/media-runtime-main-deps.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildMediaRuntimeMainDepsHandler } from './media-runtime-main-deps'; + +test('media runtime main deps builder maps state and subtitle broadcast channel', () => { + const calls: string[] = []; + let currentPath: string | null = '/tmp/a.mkv'; + let subtitlePosition: unknown = null; + let currentTitle: string | null = 'Title'; + + const deps = createBuildMediaRuntimeMainDepsHandler({ + isRemoteMediaPath: (mediaPath) => mediaPath.startsWith('http'), + loadSubtitlePosition: () => ({ x: 1 }) as never, + getCurrentMediaPath: () => currentPath, + getPendingSubtitlePosition: () => null, + getSubtitlePositionsDir: () => '/tmp/subs', + setCurrentMediaPath: (mediaPath) => { + currentPath = mediaPath; + calls.push(`path:${String(mediaPath)}`); + }, + clearPendingSubtitlePosition: () => calls.push('clear-pending'), + setSubtitlePosition: (position) => { + subtitlePosition = position; + calls.push('set-position'); + }, + broadcastToOverlayWindows: (channel, payload) => + calls.push(`broadcast:${channel}:${JSON.stringify(payload)}`), + getCurrentMediaTitle: () => currentTitle, + setCurrentMediaTitle: (title) => { + currentTitle = title; + calls.push(`title:${String(title)}`); + }, + })(); + + assert.equal(deps.isRemoteMediaPath('http://x'), true); + assert.equal(deps.getCurrentMediaPath(), '/tmp/a.mkv'); + assert.equal(deps.getSubtitlePositionsDir(), '/tmp/subs'); + assert.deepEqual(deps.loadSubtitlePosition(), { x: 1 }); + deps.setCurrentMediaPath('/tmp/b.mkv'); + deps.clearPendingSubtitlePosition(); + deps.setSubtitlePosition({ line: 1 } as never); + deps.broadcastSubtitlePosition({ line: 1 } as never); + deps.setCurrentMediaTitle('Next'); + assert.equal(currentPath, '/tmp/b.mkv'); + assert.deepEqual(subtitlePosition, { line: 1 }); + assert.equal(currentTitle, 'Next'); + assert.deepEqual(calls, [ + 'path:/tmp/b.mkv', + 'clear-pending', + 'set-position', + 'broadcast:subtitle-position:set:{"line":1}', + 'title:Next', + ]); +}); diff --git a/src/main/runtime/media-runtime-main-deps.ts b/src/main/runtime/media-runtime-main-deps.ts new file mode 100644 index 0000000..1d25908 --- /dev/null +++ b/src/main/runtime/media-runtime-main-deps.ts @@ -0,0 +1,30 @@ +import type { SubtitlePosition } from '../../types'; + +export function createBuildMediaRuntimeMainDepsHandler(deps: { + isRemoteMediaPath: (mediaPath: string) => boolean; + loadSubtitlePosition: () => SubtitlePosition | null; + getCurrentMediaPath: () => string | null; + getPendingSubtitlePosition: () => SubtitlePosition | null; + getSubtitlePositionsDir: () => string; + setCurrentMediaPath: (mediaPath: string | null) => void; + clearPendingSubtitlePosition: () => void; + setSubtitlePosition: (position: SubtitlePosition | null) => void; + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + getCurrentMediaTitle: () => string | null; + setCurrentMediaTitle: (title: string | null) => void; +}) { + return () => ({ + isRemoteMediaPath: (mediaPath: string) => deps.isRemoteMediaPath(mediaPath), + loadSubtitlePosition: () => deps.loadSubtitlePosition(), + getCurrentMediaPath: () => deps.getCurrentMediaPath(), + getPendingSubtitlePosition: () => deps.getPendingSubtitlePosition(), + getSubtitlePositionsDir: () => deps.getSubtitlePositionsDir(), + setCurrentMediaPath: (nextPath: string | null) => deps.setCurrentMediaPath(nextPath), + clearPendingSubtitlePosition: () => deps.clearPendingSubtitlePosition(), + setSubtitlePosition: (position: SubtitlePosition | null) => deps.setSubtitlePosition(position), + broadcastSubtitlePosition: (position: SubtitlePosition | null) => + deps.broadcastToOverlayWindows('subtitle-position:set', position), + getCurrentMediaTitle: () => deps.getCurrentMediaTitle(), + setCurrentMediaTitle: (title: string | null) => deps.setCurrentMediaTitle(title), + }); +} diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts new file mode 100644 index 0000000..f83c15c --- /dev/null +++ b/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './overlay-shortcuts-runtime-main-deps'; + +test('overlay shortcuts runtime main deps builder maps lifecycle and action callbacks', async () => { + const calls: string[] = []; + let shortcutsRegistered = false; + const deps = createBuildOverlayShortcutsRuntimeMainDepsHandler({ + getConfiguredShortcuts: () => ({ copySubtitle: 's' } as never), + getShortcutsRegistered: () => shortcutsRegistered, + setShortcutsRegistered: (registered) => { + shortcutsRegistered = registered; + calls.push(`registered:${registered}`); + }, + isOverlayRuntimeInitialized: () => true, + showMpvOsd: (text) => calls.push(`osd:${text}`), + openRuntimeOptionsPalette: () => calls.push('runtime-options'), + openJimaku: () => calls.push('jimaku'), + markAudioCard: async () => { + calls.push('mark-audio'); + }, + copySubtitleMultiple: (timeoutMs) => calls.push(`copy-multi:${timeoutMs}`), + copySubtitle: () => calls.push('copy'), + toggleSecondarySubMode: () => calls.push('toggle-sub'), + updateLastCardFromClipboard: async () => { + calls.push('update-last-card'); + }, + triggerFieldGrouping: async () => { + calls.push('field-grouping'); + }, + triggerSubsyncFromConfig: async () => { + calls.push('subsync'); + }, + mineSentenceCard: async () => { + calls.push('mine'); + }, + mineSentenceMultiple: (timeoutMs) => calls.push(`mine-multi:${timeoutMs}`), + cancelPendingMultiCopy: () => calls.push('cancel-copy'), + cancelPendingMineSentenceMultiple: () => calls.push('cancel-mine'), + })(); + + assert.equal(deps.isOverlayRuntimeInitialized(), true); + assert.equal(deps.getShortcutsRegistered(), false); + deps.setShortcutsRegistered(true); + assert.equal(shortcutsRegistered, true); + deps.showMpvOsd('x'); + deps.openRuntimeOptionsPalette(); + deps.openJimaku(); + await deps.markAudioCard(); + deps.copySubtitleMultiple(5000); + deps.copySubtitle(); + deps.toggleSecondarySubMode(); + await deps.updateLastCardFromClipboard(); + await deps.triggerFieldGrouping(); + await deps.triggerSubsyncFromConfig(); + await deps.mineSentenceCard(); + deps.mineSentenceMultiple(3000); + deps.cancelPendingMultiCopy(); + deps.cancelPendingMineSentenceMultiple(); + assert.deepEqual(calls, [ + 'registered:true', + 'osd:x', + 'runtime-options', + 'jimaku', + 'mark-audio', + 'copy-multi:5000', + 'copy', + 'toggle-sub', + 'update-last-card', + 'field-grouping', + 'subsync', + 'mine', + 'mine-multi:3000', + 'cancel-copy', + 'cancel-mine', + ]); +}); diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts new file mode 100644 index 0000000..ac0dfa3 --- /dev/null +++ b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts @@ -0,0 +1,26 @@ +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(), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), + openJimaku: () => deps.openJimaku(), + 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(), + }); +} diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts new file mode 100644 index 0000000..ecf6e5f --- /dev/null +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildOverlayVisibilityRuntimeMainDepsHandler } from './overlay-visibility-runtime-main-deps'; + +test('overlay visibility runtime main deps builder maps state and geometry callbacks', () => { + const calls: string[] = []; + let trackerNotReadyWarningShown = false; + const mainWindow = { id: 'main' } as never; + const invisibleWindow = { id: 'invisible' } as never; + + const deps = createBuildOverlayVisibilityRuntimeMainDepsHandler({ + getMainWindow: () => mainWindow, + getInvisibleWindow: () => invisibleWindow, + getVisibleOverlayVisible: () => true, + getInvisibleOverlayVisible: () => false, + getWindowTracker: () => ({ id: 'tracker' }), + getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown, + setTrackerNotReadyWarningShown: (shown) => { + trackerNotReadyWarningShown = shown; + calls.push(`tracker-warning:${shown}`); + }, + updateVisibleOverlayBounds: () => calls.push('visible-bounds'), + updateInvisibleOverlayBounds: () => calls.push('invisible-bounds'), + ensureOverlayWindowLevel: () => calls.push('ensure-level'), + enforceOverlayLayerOrder: () => calls.push('enforce-order'), + syncOverlayShortcuts: () => calls.push('sync-shortcuts'), + })(); + + assert.equal(deps.getMainWindow(), mainWindow); + assert.equal(deps.getInvisibleWindow(), invisibleWindow); + assert.equal(deps.getVisibleOverlayVisible(), true); + assert.equal(deps.getInvisibleOverlayVisible(), false); + assert.equal(deps.getTrackerNotReadyWarningShown(), false); + deps.setTrackerNotReadyWarningShown(true); + deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); + deps.updateInvisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); + deps.ensureOverlayWindowLevel(mainWindow); + deps.enforceOverlayLayerOrder(); + deps.syncOverlayShortcuts(); + assert.equal(trackerNotReadyWarningShown, true); + assert.deepEqual(calls, [ + 'tracker-warning:true', + 'visible-bounds', + 'invisible-bounds', + 'ensure-level', + 'enforce-order', + 'sync-shortcuts', + ]); +}); diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.ts new file mode 100644 index 0000000..7a822ac --- /dev/null +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.ts @@ -0,0 +1,34 @@ +import type { BrowserWindow } from 'electron'; +import type { WindowGeometry } from '../../types'; +import type { OverlayVisibilityRuntimeDeps } from '../overlay-visibility-runtime'; + +export function createBuildOverlayVisibilityRuntimeMainDepsHandler(deps: { + getMainWindow: () => BrowserWindow | null; + getInvisibleWindow: () => BrowserWindow | null; + getVisibleOverlayVisible: () => boolean; + getInvisibleOverlayVisible: () => boolean; + getWindowTracker: () => unknown | null; + getTrackerNotReadyWarningShown: () => boolean; + setTrackerNotReadyWarningShown: (shown: boolean) => void; + updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; + updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void; + ensureOverlayWindowLevel: (window: BrowserWindow) => void; + enforceOverlayLayerOrder: () => void; + syncOverlayShortcuts: () => void; +}) { + return (): OverlayVisibilityRuntimeDeps => ({ + getMainWindow: () => deps.getMainWindow(), + getInvisibleWindow: () => deps.getInvisibleWindow(), + getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => deps.getInvisibleOverlayVisible(), + getWindowTracker: () => deps.getWindowTracker() as never, + getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(), + setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown), + updateVisibleOverlayBounds: (geometry: WindowGeometry) => deps.updateVisibleOverlayBounds(geometry), + updateInvisibleOverlayBounds: (geometry: WindowGeometry) => + deps.updateInvisibleOverlayBounds(geometry), + ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window), + enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(), + syncOverlayShortcuts: () => deps.syncOverlayShortcuts(), + }); +} diff --git a/src/main/runtime/subtitle-processing-main-deps.test.ts b/src/main/runtime/subtitle-processing-main-deps.test.ts new file mode 100644 index 0000000..adc5666 --- /dev/null +++ b/src/main/runtime/subtitle-processing-main-deps.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildSubtitleProcessingControllerMainDepsHandler } from './subtitle-processing-main-deps'; + +test('subtitle processing main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildSubtitleProcessingControllerMainDepsHandler({ + tokenizeSubtitle: async (text) => { + calls.push(`tokenize:${text}`); + return { text, tokens: null }; + }, + emitSubtitle: (payload) => calls.push(`emit:${payload.text}`), + logDebug: (message) => calls.push(`log:${message}`), + now: () => 42, + })(); + + const tokenized = await deps.tokenizeSubtitle('line'); + deps.emitSubtitle({ text: 'line', tokens: null }); + deps.logDebug?.('ok'); + assert.equal(deps.now?.(), 42); + assert.deepEqual(tokenized, { text: 'line', tokens: null }); + assert.deepEqual(calls, ['tokenize:line', 'emit:line', 'log:ok']); +}); + +test('subtitle processing main deps builder preserves optional callbacks when absent', () => { + const deps = createBuildSubtitleProcessingControllerMainDepsHandler({ + tokenizeSubtitle: async () => null, + emitSubtitle: () => {}, + })(); + + assert.equal(deps.logDebug, undefined); + assert.equal(deps.now, undefined); +}); diff --git a/src/main/runtime/subtitle-processing-main-deps.ts b/src/main/runtime/subtitle-processing-main-deps.ts new file mode 100644 index 0000000..7d971e4 --- /dev/null +++ b/src/main/runtime/subtitle-processing-main-deps.ts @@ -0,0 +1,12 @@ +import type { SubtitleProcessingControllerDeps } from '../../core/services/subtitle-processing-controller'; + +export function createBuildSubtitleProcessingControllerMainDepsHandler( + deps: SubtitleProcessingControllerDeps, +) { + return (): SubtitleProcessingControllerDeps => ({ + tokenizeSubtitle: (text: string) => deps.tokenizeSubtitle(text), + emitSubtitle: (payload) => deps.emitSubtitle(payload), + logDebug: deps.logDebug, + now: deps.now, + }); +}