refactor: split main runtime handlers into focused modules

This commit is contained in:
2026-02-19 21:27:42 -08:00
parent 45c326db6d
commit 4193a6ce8e
49 changed files with 4357 additions and 832 deletions

View File

@@ -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-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-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-20T03:27:35Z` | | `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-20T05:15:40Z` |
| `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-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-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` | | `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` |

View File

@@ -181,6 +181,18 @@
- `src/main/runtime/anilist-setup-window.test.ts` - `src/main/runtime/anilist-setup-window.test.ts`
- `src/main/runtime/jellyfin-setup-window.ts` - `src/main/runtime/jellyfin-setup-window.ts`
- `src/main/runtime/jellyfin-setup-window.test.ts` - `src/main/runtime/jellyfin-setup-window.test.ts`
- `src/main/runtime/app-runtime-main-deps.ts`
- `src/main/runtime/app-runtime-main-deps.test.ts`
- `src/main/runtime/global-shortcuts-main-deps.ts`
- `src/main/runtime/global-shortcuts-main-deps.test.ts`
- `src/main/runtime/mpv-osd-log-main-deps.ts`
- `src/main/runtime/mpv-osd-log-main-deps.test.ts`
- `src/main/runtime/app-lifecycle-main-activate.ts`
- `src/main/runtime/app-lifecycle-main-activate.test.ts`
- `src/main/runtime/initial-args-main-deps.ts`
- `src/main/runtime/initial-args-main-deps.test.ts`
- `src/main/runtime/mpv-main-event-main-deps.ts`
- `src/main/runtime/mpv-main-event-main-deps.test.ts`
## Open Questions / Blockers ## Open Questions / Blockers
@@ -188,4 +200,47 @@
## Next Step ## Next Step
- extract next `src/main.ts` app-lifecycle cleanup/restore wrapper cluster in startup lifecycle wiring (`onWillQuitCleanup`, `restoreWindowsOnActivate`) into runtime helper + tests. - extract next `src/main.ts` runtime-deps assembly cluster (global shortcuts and/or MPV OSD bindings) into focused `*-main-deps` helper(s) with parity tests.
- [2026-02-20T04:04:21Z] progress: extracted MPV main event action callbacks into `src/main/runtime/mpv-main-event-actions.ts` and rewired `bindMpvClientEventHandlers` to delegate through focused handlers.
- [2026-02-20T04:04:21Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js` pass (14/14).
- [2026-02-20T04:06:41Z] progress: extracted Jellyfin setup-window orchestration into `createOpenJellyfinSetupWindowHandler` in `src/main/runtime/jellyfin-setup-window.ts`; rewired `openJellyfinSetupWindow` in `src/main.ts` to thin wrapper.
- [2026-02-20T04:06:41Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/mpv-client-runtime-service.test.js` pass (23/23).
- [2026-02-20T04:09:21Z] progress: extracted AniList setup-window orchestration into `createOpenAnilistSetupWindowHandler` in `src/main/runtime/anilist-setup-window.ts`; rewired `openAnilistSetupWindow` in `src/main.ts` to thin wrapper.
- [2026-02-20T04:09:21Z] progress: `src/main.ts` reduced to 2883 LOC after this slice.
- [2026-02-20T04:09:21Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/mpv-client-runtime-service.test.js` pass (39/39).
- [2026-02-20T04:11:27Z] progress: extracted Jellyfin external subtitle preload/track-picking from `playJellyfinItemInMpv` into new `src/main/runtime/jellyfin-subtitle-preload.ts` (`createPreloadJellyfinExternalSubtitlesHandler`) and rewired `main.ts`.
- [2026-02-20T04:11:27Z] progress: `src/main.ts` reduced to 2785 LOC after subtitle-preload extraction.
- [2026-02-20T04:11:27Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-subtitle-preload.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/mpv-main-event-actions.test.js` pass (41/41).
- [2026-02-20T04:17:41Z] progress: extracted `playJellyfinItemInMpv` orchestration into `src/main/runtime/jellyfin-playback-launch.ts` (`createPlayJellyfinItemInMpvHandler`) + tests; rewired `main.ts` playback handler wiring.
- [2026-02-20T04:17:41Z] progress: extracted `runJellyfinCommand` command routing into `src/main/runtime/jellyfin-command-dispatch.ts` (`createRunJellyfinCommandHandler`) + tests; rewired `main.ts` dispatcher wiring.
- [2026-02-20T04:17:41Z] progress: `src/main.ts` reduced to 2696 LOC after Jellyfin playback/dispatch extraction.
- [2026-02-20T04:17:41Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-command-dispatch.test.js dist/main/runtime/jellyfin-playback-launch.test.js dist/main/runtime/jellyfin-subtitle-preload.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 (24/24).
- [2026-02-20T04:32:30Z] progress: extracted AniList media-state helpers to `src/main/runtime/anilist-media-state.ts` (`createGetCurrentAnilistMediaKeyHandler`, `createResetAnilistMediaTrackingHandler`, `createGetAnilistMediaGuessRuntimeStateHandler`, `createSetAnilistMediaGuessRuntimeStateHandler`, `createResetAnilistMediaGuessStateHandler`) + tests; rewired main AniList guess-state callbacks.
- [2026-02-20T04:32:30Z] progress: extracted MPV main event binder orchestration from `bindMpvClientEventHandlers` into `src/main/runtime/mpv-main-event-bindings.ts` (`createBindMpvMainEventHandlersHandler`) + tests; rewired `main.ts` to dependency-only binder setup.
- [2026-02-20T04:32:30Z] progress: `src/main.ts` reduced to 2665 LOC.
- [2026-02-20T04:32:30Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/mpv-main-event-bindings.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/anilist-media-state.test.js dist/main/runtime/anilist-media-guess.test.js dist/main/runtime/jellyfin-command-dispatch.test.js dist/main/runtime/jellyfin-playback-launch.test.js` pass (23/23).
- [2026-02-20T04:33:17Z] progress: extracted main MPV event binding orchestration from `src/main.ts` into `src/main/runtime/mpv-main-event-bindings.ts` (`createBindMpvMainEventHandlersHandler`) with unit tests in `src/main/runtime/mpv-main-event-bindings.test.ts`; rewired `main.ts` bind setup to dependency map.
- [2026-02-20T04:33:17Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/mpv-main-event-bindings.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/anilist-media-state.test.js dist/main/runtime/anilist-media-guess.test.js dist/main/runtime/jellyfin-command-dispatch.test.js dist/main/runtime/jellyfin-playback-launch.test.js` pass (23/23).
- [2026-02-20T05:00:15Z] progress: extracted on-will-quit cleanup dependency assembly to `src/main/runtime/app-lifecycle-main-cleanup.ts` (`createBuildOnWillQuitCleanupDepsHandler`) + tests; rewired `onWillQuitCleanupHandler` wiring in `src/main.ts`.
- [2026-02-20T05:00:15Z] progress: extracted overlay runtime options dependency assembly to `src/main/runtime/overlay-runtime-options-main-deps.ts` + tests; rewired `buildInitializeOverlayRuntimeOptionsHandler` wiring in `src/main.ts`.
- [2026-02-20T05:00:15Z] progress: extracted CLI command context dependency assembly to `src/main/runtime/cli-command-context-main-deps.ts` + tests; rewired `buildCliCommandContextDepsHandler` wiring in `src/main.ts`.
- [2026-02-20T05:00:15Z] progress: `src/main.ts` reduced to 2617 LOC.
- [2026-02-20T05:00:15Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/cli-command-context-main-deps.test.js dist/main/runtime/cli-command-context-deps.test.js dist/main/runtime/overlay-runtime-options-main-deps.test.js dist/main/runtime/overlay-runtime-options.test.js dist/main/runtime/app-lifecycle-main-cleanup.test.js dist/main/runtime/mpv-main-event-bindings.test.js` pass (6/6).
- [2026-02-20T05:01:42Z] progress: extracted MPV runtime-service dependency assembly into `src/main/runtime/mpv-client-runtime-service-main-deps.ts` + tests; rewired `createMpvClientRuntimeService` in `src/main.ts` to use builder before `createMpvClientRuntimeServiceFactory`.
- [2026-02-20T05:01:42Z] progress: `src/main.ts` currently 2618 LOC.
- [2026-02-20T05:01:42Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/mpv-client-runtime-service-main-deps.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-main-event-bindings.test.js dist/main/runtime/cli-command-context-main-deps.test.js dist/main/runtime/overlay-runtime-options-main-deps.test.js dist/main/runtime/app-lifecycle-main-cleanup.test.js` pass (7/7).
- [2026-02-20T05:06:04Z] progress: extracted overlay-window factory dependency assembly into `src/main/runtime/overlay-window-factory-main-deps.ts` + tests; rewired `createOverlayWindowHandler`, `createMainWindowHandler`, and `createInvisibleWindowHandler` wiring in `src/main.ts`.
- [2026-02-20T05:06:04Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/overlay-window-factory-main-deps.test.js dist/main/runtime/overlay-window-factory.test.js dist/main/runtime/mpv-client-runtime-service-main-deps.test.js dist/main/runtime/cli-command-context-main-deps.test.js dist/main/runtime/overlay-runtime-options-main-deps.test.js dist/main/runtime/app-lifecycle-main-cleanup.test.js` pass (8/8).
- [2026-02-20T05:10:14Z] progress: extracted tray/overlay-bootstrap/Yomitan opener dependency assembly into `src/main/runtime/app-runtime-main-deps.ts` (`createBuildEnsureTrayMainDepsHandler`, `createBuildDestroyTrayMainDepsHandler`, `createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler`, `createBuildOpenYomitanSettingsMainDepsHandler`); rewired `ensureTray`, `destroyTray`, `initializeOverlayRuntime`, and `openYomitanSettings` to thin handlers in `src/main.ts`.
- [2026-02-20T05:10:14Z] progress: `src/main.ts` currently 2652 LOC after this slice (line-count bump from import/deps constant growth while inline wrapper bodies were removed).
- [2026-02-20T05:10:14Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/app-runtime-main-deps.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/yomitan-settings-opener.test.js dist/main/runtime/tray-main-deps.test.js dist/main/runtime/overlay-runtime-options-main-deps.test.js` pass (13/13).
- [2026-02-20T05:12:29Z] progress: extracted global-shortcuts dependency assembly into `src/main/runtime/global-shortcuts-main-deps.ts` (`createBuildGetConfiguredShortcutsMainDepsHandler`, `createBuildRegisterGlobalShortcutsMainDepsHandler`, `createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler`) and rewired corresponding handler setup in `src/main.ts`.
- [2026-02-20T05:12:29Z] progress: extracted MPV OSD/log dependency assembly into `src/main/runtime/mpv-osd-log-main-deps.ts` (`createBuildAppendToMpvLogMainDepsHandler`, `createBuildShowMpvOsdMainDepsHandler`) and rewired `appendToMpvLogHandler` + `showMpvOsdHandler` setup in `src/main.ts`.
- [2026-02-20T05:12:29Z] progress: `src/main.ts` currently 2672 LOC after this slice (net increase from imports and explicit deps builders).
- [2026-02-20T05:12:29Z] test: initial `bun run build` failed with type mismatch in `global-shortcuts-main-deps` (`unknown` options type); fixed by tightening to `RegisterGlobalShortcutsServiceOptions`.
- [2026-02-20T05:12:29Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/global-shortcuts-main-deps.test.js dist/main/runtime/global-shortcuts.test.js dist/main/runtime/mpv-osd-log-main-deps.test.js dist/main/runtime/mpv-osd-log.test.js dist/main/runtime/app-runtime-main-deps.test.js` pass (15/15).
- [2026-02-20T05:15:40Z] progress: extracted activate-lifecycle deps assembly into `src/main/runtime/app-lifecycle-main-activate.ts` (`createBuildShouldRestoreWindowsOnActivateMainDepsHandler`, `createBuildRestoreWindowsOnActivateMainDepsHandler`) and rewired `shouldRestoreWindowsOnActivateHandler` + `restoreWindowsOnActivateHandler` setup in `src/main.ts`.
- [2026-02-20T05:15:40Z] progress: extracted initial-args deps assembly into `src/main/runtime/initial-args-main-deps.ts` (`createBuildHandleInitialArgsMainDepsHandler`) and rewired `handleInitialArgs` setup in `src/main.ts`.
- [2026-02-20T05:15:40Z] progress: extracted MPV main-event deps assembly into `src/main/runtime/mpv-main-event-main-deps.ts` (`createBuildBindMpvMainEventHandlersMainDepsHandler`) and rewired `bindMpvClientEventHandlers` setup in `src/main.ts`.
- [2026-02-20T05:15:40Z] progress: `src/main.ts` currently 2653 LOC after this slice.
- [2026-02-20T05:15:40Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/app-lifecycle-main-activate.test.js dist/main/runtime/initial-args-main-deps.test.js dist/main/runtime/app-lifecycle-actions.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/app-runtime-main-deps.test.js dist/main/runtime/mpv-main-event-main-deps.test.js dist/main/runtime/mpv-main-event-bindings.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/mpv-client-event-bindings.test.js` pass (27/27).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createGetAnilistMediaGuessRuntimeStateHandler,
createGetCurrentAnilistMediaKeyHandler,
createResetAnilistMediaGuessStateHandler,
createResetAnilistMediaTrackingHandler,
createSetAnilistMediaGuessRuntimeStateHandler,
} from './anilist-media-state';
test('get current anilist media key trims and normalizes empty path', () => {
const getKey = createGetCurrentAnilistMediaKeyHandler({
getCurrentMediaPath: () => ' /tmp/video.mkv ',
});
const getEmptyKey = createGetCurrentAnilistMediaKeyHandler({
getCurrentMediaPath: () => ' ',
});
assert.equal(getKey(), '/tmp/video.mkv');
assert.equal(getEmptyKey(), null);
});
test('reset anilist media tracking clears duration/guess/probe state', () => {
let mediaKey: string | null = 'old';
let mediaDurationSec: number | null = 123;
let mediaGuess: { title: string } | null = { title: 'guess' };
let mediaGuessPromise: Promise<unknown> | null = Promise.resolve(null);
let lastDurationProbeAtMs = 999;
const reset = createResetAnilistMediaTrackingHandler({
setMediaKey: (value) => {
mediaKey = value;
},
setMediaDurationSec: (value) => {
mediaDurationSec = value;
},
setMediaGuess: (value) => {
mediaGuess = value as { title: string } | null;
},
setMediaGuessPromise: (value) => {
mediaGuessPromise = value;
},
setLastDurationProbeAtMs: (value) => {
lastDurationProbeAtMs = value;
},
});
reset('/new/media');
assert.equal(mediaKey, '/new/media');
assert.equal(mediaDurationSec, null);
assert.equal(mediaGuess, null);
assert.equal(mediaGuessPromise, null);
assert.equal(lastDurationProbeAtMs, 0);
});
test('get/set anilist media guess runtime state round-trips fields', () => {
let state = {
mediaKey: null as string | null,
mediaDurationSec: null as number | null,
mediaGuess: null as { title: string } | null,
mediaGuessPromise: null as Promise<unknown> | null,
lastDurationProbeAtMs: 0,
};
const setState = createSetAnilistMediaGuessRuntimeStateHandler({
setMediaKey: (value) => {
state.mediaKey = value;
},
setMediaDurationSec: (value) => {
state.mediaDurationSec = value;
},
setMediaGuess: (value) => {
state.mediaGuess = value as { title: string } | null;
},
setMediaGuessPromise: (value) => {
state.mediaGuessPromise = value;
},
setLastDurationProbeAtMs: (value) => {
state.lastDurationProbeAtMs = value;
},
});
const getState = createGetAnilistMediaGuessRuntimeStateHandler({
getMediaKey: () => state.mediaKey,
getMediaDurationSec: () => state.mediaDurationSec,
getMediaGuess: () => state.mediaGuess as never,
getMediaGuessPromise: () => state.mediaGuessPromise as never,
getLastDurationProbeAtMs: () => state.lastDurationProbeAtMs,
});
const nextPromise = Promise.resolve(null);
setState({
mediaKey: '/tmp/video.mkv',
mediaDurationSec: 24,
mediaGuess: { title: 'Title' } as never,
mediaGuessPromise: nextPromise as never,
lastDurationProbeAtMs: 321,
});
const roundTrip = getState();
assert.equal(roundTrip.mediaKey, '/tmp/video.mkv');
assert.equal(roundTrip.mediaDurationSec, 24);
assert.deepEqual(roundTrip.mediaGuess, { title: 'Title' });
assert.equal(roundTrip.mediaGuessPromise, nextPromise);
assert.equal(roundTrip.lastDurationProbeAtMs, 321);
});
test('reset anilist media guess state clears guess and in-flight promise', () => {
let mediaGuess: { title: string } | null = { title: 'guess' };
let mediaGuessPromise: Promise<unknown> | null = Promise.resolve(null);
const resetGuessState = createResetAnilistMediaGuessStateHandler({
setMediaGuess: (value) => {
mediaGuess = value as { title: string } | null;
},
setMediaGuessPromise: (value) => {
mediaGuessPromise = value;
},
});
resetGuessState();
assert.equal(mediaGuess, null);
assert.equal(mediaGuessPromise, null);
});

View File

@@ -0,0 +1,68 @@
import type { AnilistMediaGuessRuntimeState } from './anilist-media-guess';
export function createGetCurrentAnilistMediaKeyHandler(deps: {
getCurrentMediaPath: () => string | null;
}) {
return (): string | null => {
const mediaPath = deps.getCurrentMediaPath()?.trim();
return mediaPath && mediaPath.length > 0 ? mediaPath : null;
};
}
export function createResetAnilistMediaTrackingHandler(deps: {
setMediaKey: (value: string | null) => void;
setMediaDurationSec: (value: number | null) => void;
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
setLastDurationProbeAtMs: (value: number) => void;
}) {
return (mediaKey: string | null): void => {
deps.setMediaKey(mediaKey);
deps.setMediaDurationSec(null);
deps.setMediaGuess(null);
deps.setMediaGuessPromise(null);
deps.setLastDurationProbeAtMs(0);
};
}
export function createGetAnilistMediaGuessRuntimeStateHandler(deps: {
getMediaKey: () => string | null;
getMediaDurationSec: () => number | null;
getMediaGuess: () => AnilistMediaGuessRuntimeState['mediaGuess'];
getMediaGuessPromise: () => AnilistMediaGuessRuntimeState['mediaGuessPromise'];
getLastDurationProbeAtMs: () => number;
}) {
return (): AnilistMediaGuessRuntimeState => ({
mediaKey: deps.getMediaKey(),
mediaDurationSec: deps.getMediaDurationSec(),
mediaGuess: deps.getMediaGuess(),
mediaGuessPromise: deps.getMediaGuessPromise(),
lastDurationProbeAtMs: deps.getLastDurationProbeAtMs(),
});
}
export function createSetAnilistMediaGuessRuntimeStateHandler(deps: {
setMediaKey: (value: string | null) => void;
setMediaDurationSec: (value: number | null) => void;
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
setLastDurationProbeAtMs: (value: number) => void;
}) {
return (state: AnilistMediaGuessRuntimeState): void => {
deps.setMediaKey(state.mediaKey);
deps.setMediaDurationSec(state.mediaDurationSec);
deps.setMediaGuess(state.mediaGuess);
deps.setMediaGuessPromise(state.mediaGuessPromise);
deps.setLastDurationProbeAtMs(state.lastDurationProbeAtMs);
};
}
export function createResetAnilistMediaGuessStateHandler(deps: {
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
}) {
return (): void => {
deps.setMediaGuess(null);
deps.setMediaGuessPromise(null);
};
}

View File

@@ -12,6 +12,7 @@ import {
createAnilistSetupWillRedirectHandler, createAnilistSetupWillRedirectHandler,
createAnilistSetupWindowOpenHandler, createAnilistSetupWindowOpenHandler,
createHandleManualAnilistSetupSubmissionHandler, createHandleManualAnilistSetupSubmissionHandler,
createOpenAnilistSetupWindowHandler,
} from './anilist-setup-window'; } from './anilist-setup-window';
test('manual anilist setup submission forwards access token to callback consumer', () => { test('manual anilist setup submission forwards access token to callback consumer', () => {
@@ -224,3 +225,143 @@ test('anilist setup window opened handler sets references', () => {
handler(); handler();
assert.deepEqual(calls, ['set-window', 'opened:yes']); assert.deepEqual(calls, ['set-window', 'opened:yes']);
}); });
test('open anilist setup handler no-ops when existing setup window focused', () => {
const calls: string[] = [];
const handler = createOpenAnilistSetupWindowHandler({
maybeFocusExistingSetupWindow: () => {
calls.push('focus-existing');
return true;
},
createSetupWindow: () => {
calls.push('create-window');
throw new Error('should not create');
},
buildAuthorizeUrl: () => 'https://anilist.co/api/v2/oauth/authorize?client_id=36084',
consumeCallbackUrl: () => false,
openSetupInBrowser: () => {},
loadManualTokenEntry: () => {},
redirectUri: 'https://anilist.subminer.moe/',
developerSettingsUrl: 'https://anilist.co/settings/developer',
isAllowedExternalUrl: () => true,
isAllowedNavigationUrl: () => true,
logWarn: () => {},
logError: () => {},
clearSetupWindow: () => {},
setSetupPageOpened: () => {},
setSetupWindow: () => {},
openExternal: () => {},
});
handler();
assert.deepEqual(calls, ['focus-existing']);
});
test('open anilist setup handler wires navigation, fallback, and lifecycle', () => {
let openHandler: ((params: { url: string }) => { action: 'deny' }) | null = null;
let willNavigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null;
let didNavigateHandler: ((event: unknown, url: string) => void) | null = null;
let didFinishLoadHandler: (() => void) | null = null;
let didFailLoadHandler:
| ((event: unknown, errorCode: number, errorDescription: string, validatedURL: string) => void)
| null = null;
let closedHandler: (() => void) | null = null;
let prevented = false;
const calls: string[] = [];
const fakeWindow = {
focus: () => {},
webContents: {
setWindowOpenHandler: (handler: (params: { url: string }) => { action: 'deny' }) => {
openHandler = handler;
},
on: (
event: 'will-navigate' | 'will-redirect' | 'did-navigate' | 'did-fail-load' | 'did-finish-load',
handler: (...args: any[]) => void,
) => {
if (event === 'will-navigate') willNavigateHandler = handler as never;
if (event === 'did-navigate') didNavigateHandler = handler as never;
if (event === 'did-finish-load') didFinishLoadHandler = handler as never;
if (event === 'did-fail-load') didFailLoadHandler = handler as never;
},
getURL: () => 'about:blank',
},
on: (event: 'closed', handler: () => void) => {
if (event === 'closed') closedHandler = handler;
},
isDestroyed: () => false,
};
const handler = createOpenAnilistSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => fakeWindow,
buildAuthorizeUrl: () => 'https://anilist.co/api/v2/oauth/authorize?client_id=36084',
consumeCallbackUrl: (rawUrl) => {
calls.push(`consume:${rawUrl}`);
return rawUrl.includes('access_token=');
},
openSetupInBrowser: () => calls.push('open-browser'),
loadManualTokenEntry: () => calls.push('load-manual'),
redirectUri: 'https://anilist.subminer.moe/',
developerSettingsUrl: 'https://anilist.co/settings/developer',
isAllowedExternalUrl: () => true,
isAllowedNavigationUrl: () => true,
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
clearSetupWindow: () => calls.push('clear-window'),
setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`),
setSetupWindow: () => calls.push('set-window'),
openExternal: (url) => calls.push(`open:${url}`),
});
handler();
assert.ok(openHandler);
assert.ok(willNavigateHandler);
assert.ok(didNavigateHandler);
assert.ok(didFinishLoadHandler);
assert.ok(didFailLoadHandler);
assert.ok(closedHandler);
assert.deepEqual(calls.slice(0, 3), ['load-manual', 'set-window', 'opened:yes']);
const onOpen = openHandler as ((params: { url: string }) => { action: 'deny' }) | null;
if (!onOpen) throw new Error('missing window open handler');
assert.deepEqual(onOpen({ url: 'https://anilist.co/settings/developer' }), { action: 'deny' });
assert.ok(calls.includes('open:https://anilist.co/settings/developer'));
const onWillNavigate = willNavigateHandler as
| ((event: { preventDefault: () => void }, url: string) => void)
| null;
if (!onWillNavigate) throw new Error('missing will navigate handler');
onWillNavigate(
{
preventDefault: () => {
prevented = true;
},
},
'https://anilist.subminer.moe/#access_token=abc',
);
assert.equal(prevented, true);
const onDidNavigate = didNavigateHandler as ((event: unknown, url: string) => void) | null;
if (!onDidNavigate) throw new Error('missing did navigate handler');
onDidNavigate({}, 'https://anilist.subminer.moe/#access_token=abc');
const onDidFinishLoad = didFinishLoadHandler as (() => void) | null;
if (!onDidFinishLoad) throw new Error('missing did finish load handler');
onDidFinishLoad();
assert.ok(calls.includes('warn:AniList setup loaded a blank page; using fallback'));
assert.ok(calls.includes('open-browser'));
const onDidFailLoad = didFailLoadHandler as
| ((event: unknown, errorCode: number, errorDescription: string, validatedURL: string) => void)
| null;
if (!onDidFailLoad) throw new Error('missing did fail load handler');
onDidFailLoad({}, -1, 'load failed', 'about:blank');
assert.ok(calls.includes('error:AniList setup window failed to load'));
const onClosed = closedHandler as (() => void) | null;
if (!onClosed) throw new Error('missing closed handler');
onClosed();
assert.ok(calls.includes('clear-window'));
assert.ok(calls.includes('opened:no'));
});

View File

@@ -8,6 +8,18 @@ type FocusableWindowLike = {
focus: () => void; focus: () => void;
}; };
type AnilistSetupWebContentsLike = {
setWindowOpenHandler: (...args: any[]) => unknown;
on: (...args: any[]) => unknown;
getURL: () => string;
};
type AnilistSetupWindowLike = FocusableWindowLike & {
webContents: AnilistSetupWebContentsLike;
on: (...args: any[]) => unknown;
isDestroyed: () => boolean;
};
export function createHandleManualAnilistSetupSubmissionHandler(deps: { export function createHandleManualAnilistSetupSubmissionHandler(deps: {
consumeCallbackUrl: (rawUrl: string) => boolean; consumeCallbackUrl: (rawUrl: string) => boolean;
redirectUri: string; redirectUri: string;
@@ -179,3 +191,133 @@ export function createAnilistSetupFallbackHandler(deps: {
}, },
}; };
} }
export function createOpenAnilistSetupWindowHandler<TWindow extends AnilistSetupWindowLike>(deps: {
maybeFocusExistingSetupWindow: () => boolean;
createSetupWindow: () => TWindow;
buildAuthorizeUrl: () => string;
consumeCallbackUrl: (rawUrl: string) => boolean;
openSetupInBrowser: (authorizeUrl: string) => void;
loadManualTokenEntry: (setupWindow: TWindow, authorizeUrl: string) => void;
redirectUri: string;
developerSettingsUrl: string;
isAllowedExternalUrl: (url: string) => boolean;
isAllowedNavigationUrl: (url: string) => boolean;
logWarn: (message: string, details?: unknown) => void;
logError: (message: string, details: unknown) => void;
clearSetupWindow: () => void;
setSetupPageOpened: (opened: boolean) => void;
setSetupWindow: (window: TWindow) => void;
openExternal: (url: string) => void;
}) {
return (): void => {
if (deps.maybeFocusExistingSetupWindow()) {
return;
}
const setupWindow = deps.createSetupWindow();
const authorizeUrl = deps.buildAuthorizeUrl();
const consumeCallbackUrl = (rawUrl: string): boolean => deps.consumeCallbackUrl(rawUrl);
const openSetupInBrowser = () => deps.openSetupInBrowser(authorizeUrl);
const loadManualTokenEntry = () => deps.loadManualTokenEntry(setupWindow, authorizeUrl);
const handleManualSubmission = createHandleManualAnilistSetupSubmissionHandler({
consumeCallbackUrl: (rawUrl) => consumeCallbackUrl(rawUrl),
redirectUri: deps.redirectUri,
logWarn: (message) => deps.logWarn(message),
});
const fallback = createAnilistSetupFallbackHandler({
authorizeUrl,
developerSettingsUrl: deps.developerSettingsUrl,
setupWindow,
openSetupInBrowser,
loadManualTokenEntry,
logError: (message, details) => deps.logError(message, details),
logWarn: (message) => deps.logWarn(message),
});
const handleWindowOpen = createAnilistSetupWindowOpenHandler({
isAllowedExternalUrl: (url) => deps.isAllowedExternalUrl(url),
openExternal: (url) => deps.openExternal(url),
logWarn: (message, details) => deps.logWarn(message, details),
});
const handleWillNavigate = createAnilistSetupWillNavigateHandler({
handleManualSubmission: (url) => handleManualSubmission(url),
consumeCallbackUrl: (url) => consumeCallbackUrl(url),
redirectUri: deps.redirectUri,
isAllowedNavigationUrl: (url) => deps.isAllowedNavigationUrl(url),
logWarn: (message, details) => deps.logWarn(message, details),
});
const handleWillRedirect = createAnilistSetupWillRedirectHandler({
consumeCallbackUrl: (url) => consumeCallbackUrl(url),
});
const handleDidNavigate = createAnilistSetupDidNavigateHandler({
consumeCallbackUrl: (url) => consumeCallbackUrl(url),
});
const handleDidFailLoad = createAnilistSetupDidFailLoadHandler({
onLoadFailure: (details) => fallback.onLoadFailure(details),
});
const handleDidFinishLoad = createAnilistSetupDidFinishLoadHandler({
getLoadedUrl: () => setupWindow.webContents.getURL(),
onBlankPageLoaded: () => fallback.onBlankPageLoaded(),
});
const handleWindowClosed = createHandleAnilistSetupWindowClosedHandler({
clearSetupWindow: () => deps.clearSetupWindow(),
setSetupPageOpened: (opened) => deps.setSetupPageOpened(opened),
});
const handleWindowOpened = createHandleAnilistSetupWindowOpenedHandler({
setSetupWindow: () => deps.setSetupWindow(setupWindow),
setSetupPageOpened: (opened) => deps.setSetupPageOpened(opened),
});
setupWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) =>
handleWindowOpen({ url }),
);
setupWindow.webContents.on('will-navigate', (event: unknown, url: string) => {
handleWillNavigate({
url,
preventDefault: () => {
if (event && typeof event === 'object' && 'preventDefault' in event) {
const typedEvent = event as { preventDefault?: () => void };
typedEvent.preventDefault?.();
}
},
});
});
setupWindow.webContents.on('will-redirect', (event: unknown, url: string) => {
handleWillRedirect({
url,
preventDefault: () => {
if (event && typeof event === 'object' && 'preventDefault' in event) {
const typedEvent = event as { preventDefault?: () => void };
typedEvent.preventDefault?.();
}
},
});
});
setupWindow.webContents.on('did-navigate', (_event: unknown, url: string) => {
handleDidNavigate(url);
});
setupWindow.webContents.on(
'did-fail-load',
(
_event: unknown,
errorCode: number,
errorDescription: string,
validatedURL: string,
) => {
handleDidFailLoad({
errorCode,
errorDescription,
validatedURL,
});
},
);
setupWindow.webContents.on('did-finish-load', () => {
handleDidFinishLoad();
});
loadManualTokenEntry();
setupWindow.on('closed', () => {
handleWindowClosed();
});
handleWindowOpened();
};
}

View File

@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createOnWillQuitCleanupHandler,
createRestoreWindowsOnActivateHandler,
createShouldRestoreWindowsOnActivateHandler,
} from './app-lifecycle-actions';
test('on will quit cleanup handler runs all cleanup steps', () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => calls.push('destroy-tray'),
stopConfigHotReload: () => calls.push('stop-config'),
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
stopWindowTracker: () => calls.push('stop-tracker'),
destroyMpvSocket: () => calls.push('destroy-socket'),
clearReconnectTimer: () => calls.push('clear-reconnect'),
destroySubtitleTimingTracker: () => calls.push('destroy-subtitle-tracker'),
destroyImmersionTracker: () => calls.push('destroy-immersion'),
destroyAnkiIntegration: () => calls.push('destroy-anki'),
destroyAnilistSetupWindow: () => calls.push('destroy-anilist-window'),
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'),
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
});
cleanup();
assert.equal(calls.length, 19);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-jellyfin-remote');
});
test('should restore windows on activate requires initialized runtime and no windows', () => {
let initialized = false;
let windowCount = 1;
const shouldRestore = createShouldRestoreWindowsOnActivateHandler({
isOverlayRuntimeInitialized: () => initialized,
getAllWindowCount: () => windowCount,
});
assert.equal(shouldRestore(), false);
initialized = true;
assert.equal(shouldRestore(), false);
windowCount = 0;
assert.equal(shouldRestore(), true);
});
test('restore windows on activate recreates windows then syncs visibility', () => {
const calls: string[] = [];
const restore = createRestoreWindowsOnActivateHandler({
createMainWindow: () => calls.push('main'),
createInvisibleWindow: () => calls.push('invisible'),
updateVisibleOverlayVisibility: () => calls.push('visible-sync'),
updateInvisibleOverlayVisibility: () => calls.push('invisible-sync'),
});
restore();
assert.deepEqual(calls, ['main', 'invisible', 'visible-sync', 'invisible-sync']);
});

View File

@@ -0,0 +1,64 @@
export function createOnWillQuitCleanupHandler(deps: {
destroyTray: () => void;
stopConfigHotReload: () => void;
restorePreviousSecondarySubVisibility: () => void;
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
destroyYomitanParserWindow: () => void;
clearYomitanParserState: () => void;
stopWindowTracker: () => void;
destroyMpvSocket: () => void;
clearReconnectTimer: () => void;
destroySubtitleTimingTracker: () => void;
destroyImmersionTracker: () => void;
destroyAnkiIntegration: () => void;
destroyAnilistSetupWindow: () => void;
clearAnilistSetupWindow: () => void;
destroyJellyfinSetupWindow: () => void;
clearJellyfinSetupWindow: () => void;
stopJellyfinRemoteSession: () => void;
}) {
return (): void => {
deps.destroyTray();
deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility();
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
deps.destroyYomitanParserWindow();
deps.clearYomitanParserState();
deps.stopWindowTracker();
deps.destroyMpvSocket();
deps.clearReconnectTimer();
deps.destroySubtitleTimingTracker();
deps.destroyImmersionTracker();
deps.destroyAnkiIntegration();
deps.destroyAnilistSetupWindow();
deps.clearAnilistSetupWindow();
deps.destroyJellyfinSetupWindow();
deps.clearJellyfinSetupWindow();
deps.stopJellyfinRemoteSession();
};
}
export function createShouldRestoreWindowsOnActivateHandler(deps: {
isOverlayRuntimeInitialized: () => boolean;
getAllWindowCount: () => number;
}) {
return (): boolean => deps.isOverlayRuntimeInitialized() && deps.getAllWindowCount() === 0;
}
export function createRestoreWindowsOnActivateHandler(deps: {
createMainWindow: () => void;
createInvisibleWindow: () => void;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
}) {
return (): void => {
deps.createMainWindow();
deps.createInvisibleWindow();
deps.updateVisibleOverlayVisibility();
deps.updateInvisibleOverlayVisibility();
};
}

View File

@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildRestoreWindowsOnActivateMainDepsHandler,
createBuildShouldRestoreWindowsOnActivateMainDepsHandler,
} from './app-lifecycle-main-activate';
test('should restore windows on activate deps builder maps visibility state checks', () => {
const deps = createBuildShouldRestoreWindowsOnActivateMainDepsHandler({
isOverlayRuntimeInitialized: () => true,
getAllWindowCount: () => 0,
})();
assert.equal(deps.isOverlayRuntimeInitialized(), true);
assert.equal(deps.getAllWindowCount(), 0);
});
test('restore windows on activate deps builder maps all restoration callbacks', () => {
const calls: string[] = [];
const deps = createBuildRestoreWindowsOnActivateMainDepsHandler({
createMainWindow: () => calls.push('main'),
createInvisibleWindow: () => calls.push('invisible'),
updateVisibleOverlayVisibility: () => calls.push('visible'),
updateInvisibleOverlayVisibility: () => calls.push('invisible-visible'),
})();
deps.createMainWindow();
deps.createInvisibleWindow();
deps.updateVisibleOverlayVisibility();
deps.updateInvisibleOverlayVisibility();
assert.deepEqual(calls, ['main', 'invisible', 'visible', 'invisible-visible']);
});

View File

@@ -0,0 +1,23 @@
export function createBuildShouldRestoreWindowsOnActivateMainDepsHandler(deps: {
isOverlayRuntimeInitialized: () => boolean;
getAllWindowCount: () => number;
}) {
return () => ({
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
getAllWindowCount: () => deps.getAllWindowCount(),
});
}
export function createBuildRestoreWindowsOnActivateMainDepsHandler(deps: {
createMainWindow: () => void;
createInvisibleWindow: () => void;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
}) {
return () => ({
createMainWindow: () => deps.createMainWindow(),
createInvisibleWindow: () => deps.createInvisibleWindow(),
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
updateInvisibleOverlayVisibility: () => deps.updateInvisibleOverlayVisibility(),
});
}

View File

@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildOnWillQuitCleanupDepsHandler } from './app-lifecycle-main-cleanup';
import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions';
test('cleanup deps builder returns handlers that guard optional runtime objects', () => {
const calls: string[] = [];
let reconnectTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {}, 60_000);
let immersionTracker: { destroy: () => void } | null = {
destroy: () => calls.push('destroy-immersion'),
};
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
destroyTray: () => calls.push('destroy-tray'),
stopConfigHotReload: () => calls.push('stop-config'),
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
getYomitanParserWindow: () => ({
isDestroyed: () => false,
destroy: () => calls.push('destroy-yomitan-window'),
}),
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
getWindowTracker: () => ({ stop: () => calls.push('stop-tracker') }),
getMpvSocket: () => ({ destroy: () => calls.push('destroy-socket') }),
getReconnectTimer: () => reconnectTimer,
clearReconnectTimerRef: () => {
reconnectTimer = null;
calls.push('clear-reconnect-ref');
},
getSubtitleTimingTracker: () => ({ destroy: () => calls.push('destroy-subtitle-tracker') }),
getImmersionTracker: () => immersionTracker,
clearImmersionTracker: () => {
immersionTracker = null;
calls.push('clear-immersion-ref');
},
getAnkiIntegration: () => ({ destroy: () => calls.push('destroy-anki') }),
getAnilistSetupWindow: () => ({ destroy: () => calls.push('destroy-anilist-window') }),
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
getJellyfinSetupWindow: () => ({ destroy: () => calls.push('destroy-jellyfin-window') }),
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
});
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
cleanup();
assert.ok(calls.includes('destroy-tray'));
assert.ok(calls.includes('destroy-yomitan-window'));
assert.ok(calls.includes('destroy-socket'));
assert.ok(calls.includes('clear-reconnect-ref'));
assert.ok(calls.includes('destroy-immersion'));
assert.ok(calls.includes('clear-immersion-ref'));
assert.ok(calls.includes('stop-jellyfin-remote'));
assert.equal(reconnectTimer, null);
assert.equal(immersionTracker, null);
});
test('cleanup deps builder skips destroyed yomitan window', () => {
const calls: string[] = [];
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
getYomitanParserWindow: () => ({
isDestroyed: () => true,
destroy: () => calls.push('destroy-yomitan-window'),
}),
clearYomitanParserState: () => {},
getWindowTracker: () => null,
getMpvSocket: () => null,
getReconnectTimer: () => null,
clearReconnectTimerRef: () => {},
getSubtitleTimingTracker: () => null,
getImmersionTracker: () => null,
clearImmersionTracker: () => {},
getAnkiIntegration: () => null,
getAnilistSetupWindow: () => null,
clearAnilistSetupWindow: () => {},
getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {},
stopJellyfinRemoteSession: () => {},
});
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
cleanup();
assert.deepEqual(calls, []);
});

View File

@@ -0,0 +1,98 @@
// Narrow structural types used by cleanup assembly.
type Destroyable = {
destroy: () => void;
};
type DestroyableWindow = Destroyable & {
isDestroyed: () => boolean;
};
type Stoppable = {
stop: () => void;
};
type SocketLike = {
destroy: () => void;
};
type TimerLike = ReturnType<typeof setTimeout>;
export function createBuildOnWillQuitCleanupDepsHandler(deps: {
destroyTray: () => void;
stopConfigHotReload: () => void;
restorePreviousSecondarySubVisibility: () => void;
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
getYomitanParserWindow: () => DestroyableWindow | null;
clearYomitanParserState: () => void;
getWindowTracker: () => Stoppable | null;
getMpvSocket: () => SocketLike | null;
getReconnectTimer: () => TimerLike | null;
clearReconnectTimerRef: () => void;
getSubtitleTimingTracker: () => Destroyable | null;
getImmersionTracker: () => Destroyable | null;
clearImmersionTracker: () => void;
getAnkiIntegration: () => Destroyable | null;
getAnilistSetupWindow: () => Destroyable | null;
clearAnilistSetupWindow: () => void;
getJellyfinSetupWindow: () => Destroyable | null;
clearJellyfinSetupWindow: () => void;
stopJellyfinRemoteSession: () => void;
}) {
return () => ({
destroyTray: () => deps.destroyTray(),
stopConfigHotReload: () => deps.stopConfigHotReload(),
restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(),
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
stopTexthookerService: () => deps.stopTexthookerService(),
destroyYomitanParserWindow: () => {
const window = deps.getYomitanParserWindow();
if (!window) return;
if (window.isDestroyed()) return;
window.destroy();
},
clearYomitanParserState: () => deps.clearYomitanParserState(),
stopWindowTracker: () => {
const tracker = deps.getWindowTracker();
tracker?.stop();
},
destroyMpvSocket: () => {
const socket = deps.getMpvSocket();
socket?.destroy();
},
clearReconnectTimer: () => {
const timer = deps.getReconnectTimer();
if (!timer) return;
clearTimeout(timer);
deps.clearReconnectTimerRef();
},
destroySubtitleTimingTracker: () => {
deps.getSubtitleTimingTracker()?.destroy();
},
destroyImmersionTracker: () => {
const tracker = deps.getImmersionTracker();
if (!tracker) return;
tracker.destroy();
deps.clearImmersionTracker();
},
destroyAnkiIntegration: () => {
deps.getAnkiIntegration()?.destroy();
},
destroyAnilistSetupWindow: () => {
deps.getAnilistSetupWindow()?.destroy();
},
clearAnilistSetupWindow: () => deps.clearAnilistSetupWindow(),
destroyJellyfinSetupWindow: () => {
deps.getJellyfinSetupWindow()?.destroy();
},
clearJellyfinSetupWindow: () => deps.clearJellyfinSetupWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
});
}

View File

@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildDestroyTrayMainDepsHandler,
createBuildEnsureTrayMainDepsHandler,
createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler,
createBuildOpenYomitanSettingsMainDepsHandler,
} from './app-runtime-main-deps';
test('ensure tray main deps trigger overlay bootstrap on tray click when runtime not initialized', () => {
const calls: string[] = [];
const deps = createBuildEnsureTrayMainDepsHandler({
getTray: () => null,
setTray: () => calls.push('set-tray'),
buildTrayMenu: () => ({}),
resolveTrayIconPath: () => null,
createImageFromPath: () => ({}),
createEmptyImage: () => ({}),
createTray: () => ({}),
trayTooltip: 'SubMiner',
platform: 'darwin',
logWarn: (message) => calls.push(`warn:${message}`),
initializeOverlayRuntime: () => calls.push('init-overlay'),
isOverlayRuntimeInitialized: () => false,
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
})();
deps.ensureOverlayVisibleFromTrayClick();
assert.deepEqual(calls, ['init-overlay', 'set-visible:true']);
});
test('destroy tray main deps map passthrough getters/setters', () => {
let tray: unknown = { id: 'tray' };
const deps = createBuildDestroyTrayMainDepsHandler({
getTray: () => tray,
setTray: (next) => {
tray = next;
},
})();
assert.deepEqual(deps.getTray(), { id: 'tray' });
deps.setTray(null);
assert.equal(tray, null);
});
test('initialize overlay runtime main deps map build options and callbacks', () => {
const calls: string[] = [];
const options = { id: 'opts' };
const deps = createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler({
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntimeCore: (value) => {
calls.push(`core:${JSON.stringify(value)}`);
return { invisibleOverlayVisible: true };
},
buildOptions: () => options,
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
setOverlayRuntimeInitialized: (initialized) => calls.push(`set-initialized:${initialized}`),
startBackgroundWarmups: () => calls.push('warmups'),
})();
assert.equal(deps.isOverlayRuntimeInitialized(), false);
assert.equal(deps.buildOptions(), options);
assert.deepEqual(deps.initializeOverlayRuntimeCore(options), { invisibleOverlayVisible: true });
deps.setInvisibleOverlayVisible(true);
deps.setOverlayRuntimeInitialized(true);
deps.startBackgroundWarmups();
assert.deepEqual(calls, [
'core:{"id":"opts"}',
'set-invisible:true',
'set-initialized:true',
'warmups',
]);
});
test('open yomitan settings main deps map async open callbacks', async () => {
const calls: string[] = [];
let currentWindow: unknown = null;
const extension = { id: 'ext' };
const deps = createBuildOpenYomitanSettingsMainDepsHandler({
ensureYomitanExtensionLoaded: async () => extension,
openYomitanSettingsWindow: ({ yomitanExt }) => calls.push(`open:${(yomitanExt as { id: string }).id}`),
getExistingWindow: () => currentWindow,
setWindow: (window) => {
currentWindow = window;
calls.push('set-window');
},
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
})();
assert.equal(await deps.ensureYomitanExtensionLoaded(), extension);
assert.equal(deps.getExistingWindow(), null);
deps.setWindow({ id: 'win' });
deps.openYomitanSettingsWindow({
yomitanExt: extension,
getExistingWindow: () => deps.getExistingWindow(),
setWindow: (window) => deps.setWindow(window),
});
deps.logWarn('warn');
deps.logError('error', new Error('boom'));
assert.deepEqual(calls, ['set-window', 'open:ext', 'warn:warn', 'error:error']);
assert.deepEqual(currentWindow, { id: 'win' });
});

View File

@@ -0,0 +1,89 @@
export function createBuildEnsureTrayMainDepsHandler(deps: {
getTray: () => unknown | null;
setTray: (tray: unknown | null) => void;
buildTrayMenu: () => unknown;
resolveTrayIconPath: () => string | null;
createImageFromPath: (iconPath: string) => unknown;
createEmptyImage: () => unknown;
createTray: (icon: unknown) => unknown;
trayTooltip: string;
platform: string;
logWarn: (message: string) => void;
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
}) {
return () => ({
getTray: () => deps.getTray() as never,
setTray: (tray: unknown | null) => deps.setTray(tray),
buildTrayMenu: () => deps.buildTrayMenu() as never,
resolveTrayIconPath: () => deps.resolveTrayIconPath(),
createImageFromPath: (iconPath: string) => deps.createImageFromPath(iconPath) as never,
createEmptyImage: () => deps.createEmptyImage() as never,
createTray: (icon: unknown) => deps.createTray(icon) as never,
trayTooltip: deps.trayTooltip,
platform: deps.platform,
logWarn: (message: string) => deps.logWarn(message),
ensureOverlayVisibleFromTrayClick: () => {
if (!deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
}
deps.setVisibleOverlayVisible(true);
},
});
}
export function createBuildDestroyTrayMainDepsHandler(deps: {
getTray: () => unknown | null;
setTray: (tray: unknown | null) => void;
}) {
return () => ({
getTray: () => deps.getTray() as never,
setTray: (tray: unknown | null) => deps.setTray(tray),
});
}
export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler(deps: {
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntimeCore: (options: unknown) => { invisibleOverlayVisible: boolean };
buildOptions: () => unknown;
setInvisibleOverlayVisible: (visible: boolean) => void;
setOverlayRuntimeInitialized: (initialized: boolean) => void;
startBackgroundWarmups: () => void;
}) {
return () => ({
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
initializeOverlayRuntimeCore: (options: unknown) => deps.initializeOverlayRuntimeCore(options),
buildOptions: () => deps.buildOptions() as never,
setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
setOverlayRuntimeInitialized: (initialized: boolean) =>
deps.setOverlayRuntimeInitialized(initialized),
startBackgroundWarmups: () => deps.startBackgroundWarmups(),
});
}
export function createBuildOpenYomitanSettingsMainDepsHandler(deps: {
ensureYomitanExtensionLoaded: () => Promise<unknown | null>;
openYomitanSettingsWindow: (params: {
yomitanExt: unknown;
getExistingWindow: () => unknown | null;
setWindow: (window: unknown | null) => void;
}) => void;
getExistingWindow: () => unknown | null;
setWindow: (window: unknown | null) => void;
logWarn: (message: string) => void;
logError: (message: string, error: unknown) => void;
}) {
return () => ({
ensureYomitanExtensionLoaded: () => deps.ensureYomitanExtensionLoaded(),
openYomitanSettingsWindow: (params: {
yomitanExt: unknown;
getExistingWindow: () => unknown | null;
setWindow: (window: unknown | null) => void;
}) => deps.openYomitanSettingsWindow(params),
getExistingWindow: () => deps.getExistingWindow(),
setWindow: (window: unknown | null) => deps.setWindow(window),
logWarn: (message: string) => deps.logWarn(message),
logError: (message: string, error: unknown) => deps.logError(message, error),
});
}

View File

@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildCliCommandContextMainDepsHandler } from './cli-command-context-main-deps';
test('cli command context main deps builder maps state and callbacks', async () => {
const calls: string[] = [];
const appState = {
mpvSocketPath: '/tmp/mpv.sock',
mpvClient: null as unknown,
texthookerPort: 5174,
overlayRuntimeInitialized: false,
};
const build = createBuildCliCommandContextMainDepsHandler({
appState,
texthookerService: { start: () => null },
getResolvedConfig: () => ({ texthooker: { openBrowser: true } }),
openExternal: async (url) => {
calls.push(`open:${url}`);
},
logBrowserOpenError: (url) => calls.push(`open-error:${url}`),
showMpvOsd: (text) => calls.push(`osd:${text}`),
initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'),
startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`),
mineSentenceCard: async () => {
calls.push('mine');
},
startPendingMineSentenceMultiple: (timeoutMs) => calls.push(`mine-multi:${timeoutMs}`),
updateLastCardFromClipboard: async () => {
calls.push('update-last-card');
},
refreshKnownWordCache: async () => {
calls.push('refresh-known');
},
triggerFieldGrouping: async () => {
calls.push('field-grouping');
},
triggerSubsyncFromConfig: async () => {
calls.push('subsync');
},
markLastCardAsAudioCard: async () => {
calls.push('mark-audio');
},
getAnilistStatus: () => ({ status: 'ok' }),
clearAnilistToken: () => calls.push('clear-token'),
openAnilistSetupWindow: () => calls.push('open-anilist-setup'),
openJellyfinSetupWindow: () => calls.push('open-jellyfin-setup'),
getAnilistQueueStatus: () => ({ queued: 1 }),
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
runJellyfinCommand: async () => {
calls.push('run-jellyfin');
},
openYomitanSettings: () => calls.push('open-yomitan'),
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
printHelp: () => calls.push('help'),
stopApp: () => calls.push('stop-app'),
hasMainWindow: () => true,
getMultiCopyTimeoutMs: () => 5000,
schedule: (fn) => {
fn();
return setTimeout(() => {}, 0);
},
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
});
const deps = build();
assert.equal(deps.getSocketPath(), '/tmp/mpv.sock');
deps.setSocketPath('/tmp/next.sock');
assert.equal(appState.mpvSocketPath, '/tmp/next.sock');
assert.equal(deps.getTexthookerPort(), 5174);
deps.setTexthookerPort(5175);
assert.equal(appState.texthookerPort, 5175);
assert.equal(deps.shouldOpenBrowser(), true);
deps.showOsd('hello');
deps.initializeOverlay();
deps.setVisibleOverlay(true);
deps.setInvisibleOverlay(false);
deps.printHelp();
assert.deepEqual(calls, [
'osd:hello',
'init-overlay',
'set-visible:true',
'set-invisible:false',
'help',
]);
const retry = await deps.retryAnilistQueueNow();
assert.deepEqual(retry, { ok: true, message: 'ok' });
});

View File

@@ -0,0 +1,102 @@
import type { CliArgs } from '../../cli/args';
export function createBuildCliCommandContextMainDepsHandler(deps: {
appState: {
mpvSocketPath: string;
mpvClient: unknown | null;
texthookerPort: number;
overlayRuntimeInitialized: boolean;
};
texthookerService: unknown;
getResolvedConfig: () => { texthooker?: { openBrowser?: boolean } };
openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void;
showMpvOsd: (text: string) => void;
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWordCache: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
getAnilistStatus: () => unknown;
clearAnilistToken: () => void;
openAnilistSetupWindow: () => void;
openJellyfinSetupWindow: () => void;
getAnilistQueueStatus: () => unknown;
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
stopApp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
}) {
return () => ({
getSocketPath: () => deps.appState.mpvSocketPath,
setSocketPath: (socketPath: string) => {
deps.appState.mpvSocketPath = socketPath;
},
getMpvClient: () => deps.appState.mpvClient as never,
showOsd: (text: string) => deps.showMpvOsd(text),
texthookerService: deps.texthookerService as never,
getTexthookerPort: () => deps.appState.texthookerPort,
setTexthookerPort: (port: number) => {
deps.appState.texthookerPort = port;
},
shouldOpenBrowser: () => deps.getResolvedConfig().texthooker?.openBrowser !== false,
openExternal: (url: string) => deps.openExternal(url),
logBrowserOpenError: (url: string, error: unknown) => deps.logBrowserOpenError(url, error),
isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized,
initializeOverlay: () => deps.initializeOverlayRuntime(),
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
toggleInvisibleOverlay: () => deps.toggleInvisibleOverlay(),
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
setInvisibleOverlay: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => deps.mineSentenceCard(),
startPendingMineSentenceMultiple: (timeoutMs: number) =>
deps.startPendingMineSentenceMultiple(timeoutMs),
updateLastCardFromClipboard: () => deps.updateLastCardFromClipboard(),
refreshKnownWordCache: () => deps.refreshKnownWordCache(),
triggerFieldGrouping: () => deps.triggerFieldGrouping(),
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(),
getAnilistStatus: () => deps.getAnilistStatus() as never,
clearAnilistToken: () => deps.clearAnilistToken(),
openAnilistSetup: () => deps.openAnilistSetupWindow(),
openJellyfinSetup: () => deps.openJellyfinSetupWindow(),
getAnilistQueueStatus: () => deps.getAnilistQueueStatus() as never,
retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(),
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
openYomitanSettings: () => deps.openYomitanSettings(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
printHelp: () => deps.printHelp(),
stopApp: () => deps.stopApp(),
hasMainWindow: () => deps.hasMainWindow(),
getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(),
schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs),
logInfo: (message: string) => deps.logInfo(message),
logWarn: (message: string) => deps.logWarn(message),
logError: (message: string, err: unknown) => deps.logError(message, err),
});
}

View File

@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildGetConfiguredShortcutsMainDepsHandler,
createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler,
createBuildRegisterGlobalShortcutsMainDepsHandler,
} from './global-shortcuts-main-deps';
test('get configured shortcuts main deps map config resolver inputs', () => {
const config = { shortcuts: { copySubtitle: 's' } } as never;
const defaults = { shortcuts: { copySubtitle: 'c' } } as never;
const build = createBuildGetConfiguredShortcutsMainDepsHandler({
getResolvedConfig: () => config,
defaultConfig: defaults,
resolveConfiguredShortcuts: (nextConfig, nextDefaults) => ({ nextConfig, nextDefaults }) as never,
});
const deps = build();
assert.equal(deps.getResolvedConfig(), config);
assert.equal(deps.defaultConfig, defaults);
assert.deepEqual(deps.resolveConfiguredShortcuts(config, defaults), { nextConfig: config, nextDefaults: defaults });
});
test('register global shortcuts main deps map callbacks and flags', () => {
const calls: string[] = [];
const mainWindow = { id: 'main' };
const build = createBuildRegisterGlobalShortcutsMainDepsHandler({
getConfiguredShortcuts: () => ({ copySubtitle: 's' } as never),
registerGlobalShortcutsCore: () => calls.push('register'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
openYomitanSettings: () => calls.push('open-yomitan'),
isDev: true,
getMainWindow: () => mainWindow as never,
});
const deps = build();
deps.registerGlobalShortcutsCore({
shortcuts: deps.getConfiguredShortcuts(),
onToggleVisibleOverlay: () => undefined,
onToggleInvisibleOverlay: () => undefined,
onOpenYomitanSettings: () => undefined,
isDev: deps.isDev,
getMainWindow: deps.getMainWindow,
});
deps.onToggleVisibleOverlay();
deps.onToggleInvisibleOverlay();
deps.onOpenYomitanSettings();
assert.equal(deps.isDev, true);
assert.deepEqual(deps.getMainWindow(), mainWindow);
assert.deepEqual(calls, ['register', 'toggle-visible', 'toggle-invisible', 'open-yomitan']);
});
test('refresh global shortcuts main deps map passthrough handlers', () => {
const calls: string[] = [];
const deps = createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler({
unregisterAllGlobalShortcuts: () => calls.push('unregister'),
registerGlobalShortcuts: () => calls.push('register'),
syncOverlayShortcuts: () => calls.push('sync'),
})();
deps.unregisterAllGlobalShortcuts();
deps.registerGlobalShortcuts();
deps.syncOverlayShortcuts();
assert.deepEqual(calls, ['unregister', 'register', 'sync']);
});

View File

@@ -0,0 +1,49 @@
import type { Config } from '../../types';
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
import type { RegisterGlobalShortcutsServiceOptions } from '../../core/services/shortcut';
export function createBuildGetConfiguredShortcutsMainDepsHandler(deps: {
getResolvedConfig: () => Config;
defaultConfig: Config;
resolveConfiguredShortcuts: (config: Config, defaultConfig: Config) => ConfiguredShortcuts;
}) {
return () => ({
getResolvedConfig: () => deps.getResolvedConfig(),
defaultConfig: deps.defaultConfig,
resolveConfiguredShortcuts: (config: Config, defaultConfig: Config) =>
deps.resolveConfiguredShortcuts(config, defaultConfig),
});
}
export function createBuildRegisterGlobalShortcutsMainDepsHandler(deps: {
getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts'];
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
openYomitanSettings: () => void;
isDev: boolean;
getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow'];
}) {
return () => ({
getConfiguredShortcuts: () => deps.getConfiguredShortcuts(),
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) =>
deps.registerGlobalShortcutsCore(options),
onToggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
onToggleInvisibleOverlay: () => deps.toggleInvisibleOverlay(),
onOpenYomitanSettings: () => deps.openYomitanSettings(),
isDev: deps.isDev,
getMainWindow: deps.getMainWindow,
});
}
export function createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
registerGlobalShortcuts: () => void;
syncOverlayShortcuts: () => void;
}) {
return () => ({
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
registerGlobalShortcuts: () => deps.registerGlobalShortcuts(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
});
}

View File

@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildHandleInitialArgsMainDepsHandler } from './initial-args-main-deps';
test('initial args main deps builder maps runtime callbacks and state readers', () => {
const calls: string[] = [];
const args = { start: true } as never;
const mpvClient = { connected: false, connect: () => calls.push('connect') };
const deps = createBuildHandleInitialArgsMainDepsHandler({
getInitialArgs: () => args,
isBackgroundMode: () => true,
ensureTray: () => calls.push('ensure-tray'),
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
getMpvClient: () => mpvClient,
logInfo: (message) => calls.push(`info:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
})();
assert.equal(deps.getInitialArgs(), args);
assert.equal(deps.isBackgroundMode(), true);
assert.equal(deps.isTexthookerOnlyMode(), false);
assert.equal(deps.hasImmersionTracker(), true);
assert.equal(deps.getMpvClient(), mpvClient);
deps.ensureTray();
deps.logInfo('x');
deps.handleCliCommand(args, 'initial');
assert.deepEqual(calls, ['ensure-tray', 'info:x', 'cli:initial']);
});

View File

@@ -0,0 +1,23 @@
import type { CliArgs } from '../../cli/args';
export function createBuildHandleInitialArgsMainDepsHandler(deps: {
getInitialArgs: () => CliArgs | null;
isBackgroundMode: () => boolean;
ensureTray: () => void;
isTexthookerOnlyMode: () => boolean;
hasImmersionTracker: () => boolean;
getMpvClient: () => { connected: boolean; connect: () => void } | null;
logInfo: (message: string) => void;
handleCliCommand: (args: CliArgs, source: 'initial') => void;
}) {
return () => ({
getInitialArgs: () => deps.getInitialArgs(),
isBackgroundMode: () => deps.isBackgroundMode(),
ensureTray: () => deps.ensureTray(),
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
hasImmersionTracker: () => deps.hasImmersionTracker(),
getMpvClient: () => deps.getMpvClient(),
logInfo: (message: string) => deps.logInfo(message),
handleCliCommand: (args: CliArgs, source: 'initial') => deps.handleCliCommand(args, source),
});
}

View File

@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createRunJellyfinCommandHandler } from './jellyfin-command-dispatch';
import type { CliArgs } from '../../cli/args';
function createArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
raw: [],
target: null,
start: false,
stats: false,
listRecent: false,
listMediaInfo: false,
subs: false,
noAnki: false,
noKnown: false,
noAnilist: false,
anilistStatus: false,
clearAnilistToken: false,
anilistSetup: false,
anilistQueueStatus: false,
anilistQueueRetry: false,
yomitanSettings: false,
toggleOverlay: false,
hideOverlay: false,
showOverlay: false,
toggleInvisibleOverlay: false,
hideInvisibleOverlay: false,
showInvisibleOverlay: false,
copyCurrentSubtitle: false,
multiCopy: false,
mineSentence: false,
mineSentenceMultiple: false,
updateLastCardFromClipboard: false,
refreshKnownCache: false,
triggerFieldGrouping: false,
manualSubsync: false,
markAudioCard: false,
cycleSecondarySub: false,
runtimeOptions: false,
debugOverlay: false,
jellyfinSetup: false,
jellyfinLogin: false,
jellyfinLibraries: false,
jellyfinItems: false,
jellyfinSubtitles: false,
jellyfinPlay: false,
jellyfinRemoteAnnounce: false,
help: false,
...overrides,
} as CliArgs;
}
test('run jellyfin command returns after auth branch handles command', async () => {
const calls: string[] = [];
const run = createRunJellyfinCommandHandler({
getJellyfinConfig: () => ({ serverUrl: 'http://localhost:8096' }),
defaultServerUrl: 'http://127.0.0.1:8096',
getJellyfinClientInfo: () => ({ clientName: 'SubMiner' }),
handleAuthCommands: async () => {
calls.push('auth');
return true;
},
handleRemoteAnnounceCommand: async () => {
calls.push('remote');
return false;
},
handleListCommands: async () => {
calls.push('list');
return false;
},
handlePlayCommand: async () => {
calls.push('play');
return false;
},
});
await run(createArgs());
assert.deepEqual(calls, ['auth']);
});
test('run jellyfin command throws when session missing after auth', async () => {
const run = createRunJellyfinCommandHandler({
getJellyfinConfig: () => ({ serverUrl: '', accessToken: '', userId: '' }),
defaultServerUrl: '',
getJellyfinClientInfo: () => ({ clientName: 'SubMiner' }),
handleAuthCommands: async () => false,
handleRemoteAnnounceCommand: async () => false,
handleListCommands: async () => false,
handlePlayCommand: async () => false,
});
await assert.rejects(() => run(createArgs()), /Missing Jellyfin session/);
});
test('run jellyfin command dispatches remote/list/play in order until handled', async () => {
const calls: string[] = [];
const seenServerUrls: string[] = [];
const run = createRunJellyfinCommandHandler({
getJellyfinConfig: () => ({
serverUrl: 'http://localhost:8096',
accessToken: 'token',
userId: 'uid',
username: 'alice',
}),
defaultServerUrl: 'http://127.0.0.1:8096',
getJellyfinClientInfo: () => ({ clientName: 'SubMiner' }),
handleAuthCommands: async ({ serverUrl }) => {
calls.push('auth');
seenServerUrls.push(serverUrl);
return false;
},
handleRemoteAnnounceCommand: async () => {
calls.push('remote');
return false;
},
handleListCommands: async ({ session }) => {
calls.push('list');
seenServerUrls.push(session.serverUrl);
return true;
},
handlePlayCommand: async () => {
calls.push('play');
return false;
},
});
await run(createArgs({ jellyfinServer: 'http://override:8096' }));
assert.deepEqual(calls, ['auth', 'remote', 'list']);
assert.deepEqual(seenServerUrls, ['http://override:8096', 'http://override:8096']);
});

View File

@@ -0,0 +1,100 @@
import type { CliArgs } from '../../cli/args';
type JellyfinConfigBase = {
serverUrl?: string;
accessToken?: string;
userId?: string;
username?: string;
};
type JellyfinSession = {
serverUrl: string;
accessToken: string;
userId: string;
username: string;
};
export function createRunJellyfinCommandHandler<
TClientInfo,
TConfig extends JellyfinConfigBase,
>(deps: {
getJellyfinConfig: () => TConfig;
defaultServerUrl: string;
getJellyfinClientInfo: (config: TConfig) => TClientInfo;
handleAuthCommands: (params: {
args: CliArgs;
jellyfinConfig: TConfig;
serverUrl: string;
clientInfo: TClientInfo;
}) => Promise<boolean>;
handleRemoteAnnounceCommand: (args: CliArgs) => Promise<boolean>;
handleListCommands: (params: {
args: CliArgs;
session: JellyfinSession;
clientInfo: TClientInfo;
jellyfinConfig: TConfig;
}) => Promise<boolean>;
handlePlayCommand: (params: {
args: CliArgs;
session: JellyfinSession;
clientInfo: TClientInfo;
jellyfinConfig: TConfig;
}) => Promise<boolean>;
}) {
return async (args: CliArgs): Promise<void> => {
const jellyfinConfig = deps.getJellyfinConfig();
const serverUrl =
args.jellyfinServer?.trim() || jellyfinConfig.serverUrl || deps.defaultServerUrl;
const clientInfo = deps.getJellyfinClientInfo(jellyfinConfig);
if (
await deps.handleAuthCommands({
args,
jellyfinConfig,
serverUrl,
clientInfo,
})
) {
return;
}
const accessToken = jellyfinConfig.accessToken;
const userId = jellyfinConfig.userId;
if (!serverUrl || !accessToken || !userId) {
throw new Error('Missing Jellyfin session. Run --jellyfin-login first.');
}
const session: JellyfinSession = {
serverUrl,
accessToken,
userId,
username: jellyfinConfig.username || '',
};
if (await deps.handleRemoteAnnounceCommand(args)) {
return;
}
if (
await deps.handleListCommands({
args,
session,
clientInfo,
jellyfinConfig,
})
) {
return;
}
if (
await deps.handlePlayCommand({
args,
session,
clientInfo,
jellyfinConfig,
})
) {
return;
}
};
}

View File

@@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPlayJellyfinItemInMpvHandler } from './jellyfin-playback-launch';
const baseSession = {
serverUrl: 'http://localhost:8096',
accessToken: 'token',
userId: 'uid',
username: 'alice',
};
const baseClientInfo = {
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'did',
};
test('playback handler throws when mpv is not connected', async () => {
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => false,
getMpvClient: () => null,
resolvePlaybackPlan: async () => {
throw new Error('unreachable');
},
applyJellyfinMpvDefaults: () => {},
sendMpvCommand: () => {},
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
});
await assert.rejects(
() =>
handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-1',
}),
/MPV not connected and auto-launch failed/,
);
});
test('playback handler drives mpv commands and playback state', async () => {
const commands: Array<Array<string | number>> = [];
const scheduled: Array<{ delay: number; callback: () => void }> = [];
const calls: string[] = [];
const activeStates: Array<Record<string, unknown>> = [];
const reportPayloads: Array<Record<string, unknown>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8',
mode: 'direct',
title: 'Episode 1',
startTimeTicks: 12_000_000,
audioStreamIndex: 1,
subtitleStreamIndex: 2,
}),
applyJellyfinMpvDefaults: () => calls.push('defaults'),
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => calls.push('arm'),
schedule: (callback, delayMs) => {
scheduled.push({ delay: delayMs, callback });
},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => calls.push('preload'),
setActivePlayback: (state) => activeStates.push(state as Record<string, unknown>),
setLastProgressAtMs: (value) => calls.push(`progress:${value}`),
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
showMpvOsd: (text) => calls.push(`osd:${text}`),
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-1',
});
assert.deepEqual(commands.slice(0, 5), [
['set_property', 'sub-auto', 'no'],
['loadfile', 'https://stream.example/video.m3u8', 'replace'],
['set_property', 'force-media-title', '[Jellyfin/direct] Episode 1'],
['set_property', 'sid', 'no'],
['seek', 1.2, 'absolute+exact'],
]);
assert.equal(scheduled.length, 1);
assert.equal(scheduled[0]?.delay, 500);
scheduled[0]?.callback();
assert.deepEqual(commands[commands.length - 1], ['set_property', 'sid', 'no']);
assert.ok(calls.includes('defaults'));
assert.ok(calls.includes('arm'));
assert.ok(calls.includes('preload'));
assert.ok(calls.includes('progress:0'));
assert.ok(calls.includes('osd:Jellyfin direct: Episode 1'));
assert.equal(activeStates.length, 1);
assert.equal(activeStates[0]?.playMethod, 'DirectPlay');
assert.equal(reportPayloads.length, 1);
assert.equal(reportPayloads[0]?.eventName, 'start');
});

View File

@@ -0,0 +1,138 @@
type JellyfinSession = {
serverUrl: string;
accessToken: string;
userId: string;
username: string;
};
type JellyfinClientInfo = {
clientName: string;
clientVersion: string;
deviceId: string;
};
type JellyfinPlaybackPlan = {
url: string;
mode: 'direct' | 'transcode';
title: string;
startTimeTicks: number;
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
};
type ActivePlaybackState = {
itemId: string;
mediaSourceId: undefined;
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
playMethod: 'DirectPlay' | 'Transcode';
};
type MpvClientLike = unknown;
export function createPlayJellyfinItemInMpvHandler(deps: {
ensureMpvConnectedForPlayback: () => Promise<boolean>;
getMpvClient: () => MpvClientLike | null;
resolvePlaybackPlan: (params: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
jellyfinConfig: unknown;
itemId: string;
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
}) => Promise<JellyfinPlaybackPlan>;
applyJellyfinMpvDefaults: (mpvClient: MpvClientLike) => void;
sendMpvCommand: (command: Array<string | number>) => void;
armQuitOnDisconnect: () => void;
schedule: (callback: () => void, delayMs: number) => void;
convertTicksToSeconds: (ticks: number) => number;
preloadExternalSubtitles: (params: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
itemId: string;
}) => void;
setActivePlayback: (state: ActivePlaybackState) => void;
setLastProgressAtMs: (value: number) => void;
reportPlaying: (payload: {
itemId: string;
mediaSourceId: undefined;
playMethod: 'DirectPlay' | 'Transcode';
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
eventName: 'start';
}) => void;
showMpvOsd: (text: string) => void;
}) {
return async (params: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
jellyfinConfig: unknown;
itemId: string;
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
startTimeTicksOverride?: number;
setQuitOnDisconnectArm?: boolean;
}): Promise<void> => {
const connected = await deps.ensureMpvConnectedForPlayback();
const mpvClient = deps.getMpvClient();
if (!connected || !mpvClient) {
throw new Error(
'MPV not connected and auto-launch failed. Ensure mpv is installed and available in PATH.',
);
}
const plan = await deps.resolvePlaybackPlan({
session: params.session,
clientInfo: params.clientInfo,
jellyfinConfig: params.jellyfinConfig,
itemId: params.itemId,
audioStreamIndex: params.audioStreamIndex,
subtitleStreamIndex: params.subtitleStreamIndex,
});
deps.applyJellyfinMpvDefaults(mpvClient);
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
deps.sendMpvCommand(['loadfile', plan.url, 'replace']);
if (params.setQuitOnDisconnectArm !== false) {
deps.armQuitOnDisconnect();
}
deps.sendMpvCommand(['set_property', 'force-media-title', `[Jellyfin/${plan.mode}] ${plan.title}`]);
deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.schedule(() => {
deps.sendMpvCommand(['set_property', 'sid', 'no']);
}, 500);
const startTimeTicks =
typeof params.startTimeTicksOverride === 'number'
? Math.max(0, params.startTimeTicksOverride)
: plan.startTimeTicks;
if (startTimeTicks > 0) {
deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']);
}
deps.preloadExternalSubtitles({
session: params.session,
clientInfo: params.clientInfo,
itemId: params.itemId,
});
const playMethod = plan.mode === 'direct' ? 'DirectPlay' : 'Transcode';
deps.setActivePlayback({
itemId: params.itemId,
mediaSourceId: undefined,
audioStreamIndex: plan.audioStreamIndex,
subtitleStreamIndex: plan.subtitleStreamIndex,
playMethod,
});
deps.setLastProgressAtMs(0);
deps.reportPlaying({
itemId: params.itemId,
mediaSourceId: undefined,
playMethod,
audioStreamIndex: plan.audioStreamIndex,
subtitleStreamIndex: plan.subtitleStreamIndex,
eventName: 'start',
});
deps.showMpvOsd(`Jellyfin ${plan.mode}: ${plan.title}`);
};
}

View File

@@ -7,6 +7,7 @@ import {
createHandleJellyfinSetupSubmissionHandler, createHandleJellyfinSetupSubmissionHandler,
createHandleJellyfinSetupWindowOpenedHandler, createHandleJellyfinSetupWindowOpenedHandler,
createMaybeFocusExistingJellyfinSetupWindowHandler, createMaybeFocusExistingJellyfinSetupWindowHandler,
createOpenJellyfinSetupWindowHandler,
parseJellyfinSetupSubmissionUrl, parseJellyfinSetupSubmissionUrl,
} from './jellyfin-setup-window'; } from './jellyfin-setup-window';
@@ -144,3 +145,114 @@ test('createHandleJellyfinSetupWindowOpenedHandler sets setup window ref', () =>
handler(); handler();
assert.equal(set, true); assert.equal(set, true);
}); });
test('createOpenJellyfinSetupWindowHandler no-ops when existing setup window is focused', () => {
const calls: string[] = [];
const handler = createOpenJellyfinSetupWindowHandler({
maybeFocusExistingSetupWindow: () => {
calls.push('focus-existing');
return true;
},
createSetupWindow: () => {
calls.push('create-window');
throw new Error('should not create');
},
getResolvedJellyfinConfig: () => ({}),
buildSetupFormHtml: () => '<html></html>',
parseSubmissionUrl: () => null,
authenticateWithPassword: async () => {
throw new Error('should not auth');
},
getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }),
patchJellyfinConfig: () => {},
logInfo: () => {},
logError: () => {},
showMpvOsd: () => {},
clearSetupWindow: () => {},
setSetupWindow: () => {},
encodeURIComponent: (value) => value,
});
handler();
assert.deepEqual(calls, ['focus-existing']);
});
test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window lifecycle', async () => {
let willNavigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null;
let closedHandler: (() => void) | null = null;
let prevented = false;
const calls: string[] = [];
const fakeWindow = {
focus: () => {},
webContents: {
on: (event: 'will-navigate', handler: (event: { preventDefault: () => void }, url: string) => void) => {
if (event === 'will-navigate') {
willNavigateHandler = handler;
}
},
},
loadURL: (url: string) => {
calls.push(`load:${url.startsWith('data:text/html;charset=utf-8,') ? 'data-url' : 'other'}`);
},
on: (event: 'closed', handler: () => void) => {
if (event === 'closed') {
closedHandler = handler;
}
},
isDestroyed: () => false,
close: () => calls.push('close'),
};
const handler = createOpenJellyfinSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => fakeWindow,
getResolvedJellyfinConfig: () => ({ serverUrl: 'http://localhost:8096', username: 'alice' }),
buildSetupFormHtml: (server, username) => `<html>${server}|${username}</html>`,
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
authenticateWithPassword: async () => ({
serverUrl: 'http://localhost:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
}),
getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }),
patchJellyfinConfig: () => calls.push('patch'),
logInfo: () => calls.push('info'),
logError: () => calls.push('error'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
clearSetupWindow: () => calls.push('clear-window'),
setSetupWindow: () => calls.push('set-window'),
encodeURIComponent: (value) => encodeURIComponent(value),
});
handler();
assert.ok(willNavigateHandler);
assert.ok(closedHandler);
assert.deepEqual(calls.slice(0, 2), ['load:data-url', 'set-window']);
const navHandler = willNavigateHandler as ((event: { preventDefault: () => void }, url: string) => void) | null;
if (!navHandler) {
throw new Error('missing will-navigate handler');
}
navHandler(
{
preventDefault: () => {
prevented = true;
},
},
'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=alice&password=pass',
);
await Promise.resolve();
assert.equal(prevented, true);
assert.ok(calls.includes('patch'));
assert.ok(calls.includes('osd:Jellyfin login success'));
assert.ok(calls.includes('close'));
const onClosed = closedHandler as (() => void) | null;
if (!onClosed) {
throw new Error('missing closed handler');
}
onClosed();
assert.ok(calls.includes('clear-window'));
});

View File

@@ -15,6 +15,18 @@ type FocusableWindowLike = {
focus: () => void; focus: () => void;
}; };
type JellyfinSetupWebContentsLike = {
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
};
type JellyfinSetupWindowLike = FocusableWindowLike & {
webContents: JellyfinSetupWebContentsLike;
loadURL: (url: string) => unknown;
on: (event: 'closed', handler: () => void) => void;
isDestroyed: () => boolean;
close: () => void;
};
function escapeHtmlAttr(value: string): string { function escapeHtmlAttr(value: string): string {
return value.replace(/"/g, '&quot;'); return value.replace(/"/g, '&quot;');
} }
@@ -169,3 +181,82 @@ export function createHandleJellyfinSetupWindowOpenedHandler(deps: {
deps.setSetupWindow(); deps.setSetupWindow();
}; };
} }
export function createOpenJellyfinSetupWindowHandler<TWindow extends JellyfinSetupWindowLike>(deps: {
maybeFocusExistingSetupWindow: () => boolean;
createSetupWindow: () => TWindow;
getResolvedJellyfinConfig: () => { serverUrl?: string | null; username?: string | null };
buildSetupFormHtml: (defaultServer: string, defaultUser: string) => string;
parseSubmissionUrl: (rawUrl: string) => { server: string; username: string; password: string } | null;
authenticateWithPassword: (
server: string,
username: string,
password: string,
clientInfo: JellyfinClientInfo,
) => Promise<JellyfinSession>;
getJellyfinClientInfo: () => JellyfinClientInfo;
patchJellyfinConfig: (session: JellyfinSession) => void;
logInfo: (message: string) => void;
logError: (message: string, error: unknown) => void;
showMpvOsd: (message: string) => void;
clearSetupWindow: () => void;
setSetupWindow: (window: TWindow) => void;
encodeURIComponent: (value: string) => string;
}) {
return (): void => {
if (deps.maybeFocusExistingSetupWindow()) {
return;
}
const setupWindow = deps.createSetupWindow();
const defaults = deps.getResolvedJellyfinConfig();
const defaultServer = defaults.serverUrl || 'http://127.0.0.1:8096';
const defaultUser = defaults.username || '';
const formHtml = deps.buildSetupFormHtml(defaultServer, defaultUser);
const handleSubmission = createHandleJellyfinSetupSubmissionHandler({
parseSubmissionUrl: (rawUrl) => deps.parseSubmissionUrl(rawUrl),
authenticateWithPassword: (server, username, password, clientInfo) =>
deps.authenticateWithPassword(server, username, password, clientInfo),
getJellyfinClientInfo: () => deps.getJellyfinClientInfo(),
patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session),
logInfo: (message) => deps.logInfo(message),
logError: (message, error) => deps.logError(message, error),
showMpvOsd: (message) => deps.showMpvOsd(message),
closeSetupWindow: () => {
if (!setupWindow.isDestroyed()) {
setupWindow.close();
}
},
});
const handleNavigation = createHandleJellyfinSetupNavigationHandler({
setupSchemePrefix: 'subminer://jellyfin-setup',
handleSubmission: (rawUrl) => handleSubmission(rawUrl),
logError: (message, error) => deps.logError(message, error),
});
const handleWindowClosed = createHandleJellyfinSetupWindowClosedHandler({
clearSetupWindow: () => deps.clearSetupWindow(),
});
const handleWindowOpened = createHandleJellyfinSetupWindowOpenedHandler({
setSetupWindow: () => deps.setSetupWindow(setupWindow),
});
setupWindow.webContents.on('will-navigate', (event, url) => {
handleNavigation({
url,
preventDefault: () => {
if (event && typeof event === 'object' && 'preventDefault' in event) {
const typedEvent = event as { preventDefault?: () => void };
typedEvent.preventDefault?.();
}
},
});
});
void setupWindow.loadURL(
`data:text/html;charset=utf-8,${deps.encodeURIComponent(formHtml)}`,
);
setupWindow.on('closed', () => {
handleWindowClosed();
});
handleWindowOpened();
};
}

View File

@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPreloadJellyfinExternalSubtitlesHandler } from './jellyfin-subtitle-preload';
const session = {
serverUrl: 'http://localhost:8096',
accessToken: 'token',
userId: 'uid',
username: 'alice',
};
const clientInfo = {
clientName: 'SubMiner',
clientVersion: '1.0',
deviceId: 'dev',
};
test('preload jellyfin subtitles adds external tracks and chooses japanese+english tracks', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
{ index: 1, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' },
{ index: 2, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' },
],
getMpvClient: () => ({
requestProperty: async () => [
{ type: 'sub', id: 5, lang: 'jpn', title: 'Japanese', external: true },
{ type: 'sub', id: 6, lang: 'eng', title: 'English', external: true },
],
}),
sendMpvCommand: (command) => commands.push(command),
wait: async () => {},
logDebug: () => {},
});
await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(commands, [
['sub-add', 'https://sub/a.srt', 'cached', 'Japanese', 'jpn'],
['sub-add', 'https://sub/b.srt', 'cached', 'English SDH', 'eng'],
['set_property', 'sid', 5],
['set_property', 'secondary-sid', 6],
]);
});
test('preload jellyfin subtitles exits quietly when no external tracks', async () => {
const commands: Array<Array<string | number>> = [];
let waited = false;
const preload = createPreloadJellyfinExternalSubtitlesHandler({
listJellyfinSubtitleTracks: async () => [{ index: 0, language: 'jpn', title: 'Embedded' }],
getMpvClient: () => ({ requestProperty: async () => [] }),
sendMpvCommand: (command) => commands.push(command),
wait: async () => {
waited = true;
},
logDebug: () => {},
});
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(waited, false);
assert.deepEqual(commands, []);
});
test('preload jellyfin subtitles logs debug on failure', async () => {
const logs: string[] = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler({
listJellyfinSubtitleTracks: async () => {
throw new Error('network down');
},
getMpvClient: () => null,
sendMpvCommand: () => {},
wait: async () => {},
logDebug: (message) => logs.push(message),
});
await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(logs, ['Failed to preload Jellyfin external subtitles']);
});

View File

@@ -0,0 +1,163 @@
type JellyfinSession = {
serverUrl: string;
accessToken: string;
userId: string;
username: string;
};
type JellyfinClientInfo = {
clientName: string;
clientVersion: string;
deviceId: string;
};
type JellyfinSubtitleTrack = {
index: number;
language?: string;
title?: string;
deliveryUrl?: string | null;
};
type MpvClientLike = {
requestProperty: (name: string) => Promise<unknown>;
};
function normalizeLang(value: unknown): string {
return String(value || '')
.trim()
.toLowerCase()
.replace(/_/g, '-');
}
function isJapanese(value: string): boolean {
const v = normalizeLang(value);
return (
v === 'ja' ||
v === 'jp' ||
v === 'jpn' ||
v === 'japanese' ||
v.startsWith('ja-') ||
v.startsWith('jp-')
);
}
function isEnglish(value: string): boolean {
const v = normalizeLang(value);
return (
v === 'en' ||
v === 'eng' ||
v === 'english' ||
v === 'enus' ||
v === 'en-us' ||
v.startsWith('en-')
);
}
function isLikelyHearingImpaired(title: string): boolean {
return /\b(hearing impaired|sdh|closed captions?|cc)\b/i.test(title);
}
function pickBestTrackId(
tracks: Array<{
id: number;
lang: string;
title: string;
external: boolean;
}>,
languageMatcher: (value: string) => boolean,
excludeId: number | null = null,
): number | null {
const ranked = tracks
.filter((track) => languageMatcher(track.lang))
.filter((track) => track.id !== excludeId)
.map((track) => ({
track,
score:
(track.external ? 100 : 0) +
(isLikelyHearingImpaired(track.title) ? -10 : 10) +
(/\bdefault\b/i.test(track.title) ? 3 : 0),
}))
.sort((a, b) => b.score - a.score);
return ranked[0]?.track.id ?? null;
}
export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
listJellyfinSubtitleTracks: (
session: JellyfinSession,
clientInfo: JellyfinClientInfo,
itemId: string,
) => Promise<JellyfinSubtitleTrack[]>;
getMpvClient: () => MpvClientLike | null;
sendMpvCommand: (command: Array<string | number>) => void;
wait: (ms: number) => Promise<void>;
logDebug: (message: string, error: unknown) => void;
}) {
return async (params: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
itemId: string;
}): Promise<void> => {
try {
const tracks = await deps.listJellyfinSubtitleTracks(
params.session,
params.clientInfo,
params.itemId,
);
const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl));
if (externalTracks.length === 0) {
return;
}
await deps.wait(300);
const seenUrls = new Set<string>();
for (const track of externalTracks) {
if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) {
continue;
}
seenUrls.add(track.deliveryUrl);
const labelBase = (track.title || track.language || '').trim();
const label = labelBase || `Jellyfin Subtitle ${track.index}`;
deps.sendMpvCommand([
'sub-add',
track.deliveryUrl,
'cached',
label,
track.language || '',
]);
}
await deps.wait(250);
const trackListRaw = await deps.getMpvClient()?.requestProperty('track-list');
const subtitleTracks = Array.isArray(trackListRaw)
? trackListRaw
.filter(
(track): track is Record<string, unknown> =>
Boolean(track) &&
typeof track === 'object' &&
track.type === 'sub' &&
typeof track.id === 'number',
)
.map((track) => ({
id: track.id as number,
lang: String(track.lang || ''),
title: String(track.title || ''),
external: track.external === true,
}))
: [];
const japanesePrimaryId = pickBestTrackId(subtitleTracks, isJapanese);
if (japanesePrimaryId !== null) {
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
} else {
deps.sendMpvCommand(['set_property', 'sid', 'no']);
}
const englishSecondaryId = pickBestTrackId(subtitleTracks, isEnglish, japanesePrimaryId);
if (englishSecondaryId !== null) {
deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]);
}
} catch (error) {
deps.logDebug('Failed to preload Jellyfin external subtitles', error);
}
};
}

View File

@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildMpvClientRuntimeServiceFactoryDepsHandler } from './mpv-client-runtime-service-main-deps';
test('mpv runtime service main deps builder maps state and callbacks', () => {
const calls: string[] = [];
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
class FakeClient {
constructor(public socketPath: string, public options: unknown) {}
}
const build = createBuildMpvClientRuntimeServiceFactoryDepsHandler({
createClient: FakeClient,
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ mode: 'test' }),
isAutoStartOverlayEnabled: () => true,
setOverlayVisible: (visible) => calls.push(`overlay:${visible}`),
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => reconnectTimer,
setReconnectTimer: (timer) => {
reconnectTimer = timer;
calls.push('set-reconnect');
},
bindEventHandlers: () => calls.push('bind'),
});
const deps = build();
assert.equal(deps.socketPath, '/tmp/mpv.sock');
assert.equal(deps.options.autoStartOverlay, true);
assert.equal(deps.options.shouldBindVisibleOverlayToMpvSubVisibility(), true);
assert.equal(deps.options.isVisibleOverlayVisible(), false);
assert.deepEqual(deps.options.getResolvedConfig(), { mode: 'test' });
deps.options.setOverlayVisible(true);
deps.options.setReconnectTimer(setTimeout(() => {}, 0));
deps.bindEventHandlers(new FakeClient('/tmp/mpv.sock', {}));
assert.ok(calls.includes('overlay:true'));
assert.ok(calls.includes('set-reconnect'));
assert.ok(calls.includes('bind'));
assert.ok(reconnectTimer);
});

View File

@@ -0,0 +1,32 @@
export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
TClient,
TResolvedConfig,
TOptions,
>(deps: {
createClient: new (socketPath: string, options: TOptions) => TClient;
getSocketPath: () => string;
getResolvedConfig: () => TResolvedConfig;
isAutoStartOverlayEnabled: () => boolean;
setOverlayVisible: (visible: boolean) => void;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
bindEventHandlers: (client: TClient) => void;
}) {
return () => ({
createClient: deps.createClient,
socketPath: deps.getSocketPath(),
options: {
getResolvedConfig: () => deps.getResolvedConfig(),
autoStartOverlay: deps.isAutoStartOverlayEnabled(),
setOverlayVisible: (visible: boolean) => deps.setOverlayVisible(visible),
shouldBindVisibleOverlayToMpvSubVisibility: () =>
deps.shouldBindVisibleOverlayToMpvSubVisibility(),
isVisibleOverlayVisible: () => deps.isVisibleOverlayVisible(),
getReconnectTimer: () => deps.getReconnectTimer(),
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => deps.setReconnectTimer(timer),
},
bindEventHandlers: (client: TClient) => deps.bindEventHandlers(client),
});
}

View File

@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createHandleMpvMediaPathChangeHandler,
createHandleMpvMediaTitleChangeHandler,
createHandleMpvPauseChangeHandler,
createHandleMpvSecondarySubtitleChangeHandler,
createHandleMpvSecondarySubtitleVisibilityHandler,
createHandleMpvSubtitleAssChangeHandler,
createHandleMpvSubtitleChangeHandler,
createHandleMpvSubtitleMetricsChangeHandler,
createHandleMpvTimePosChangeHandler,
} from './mpv-main-event-actions';
test('subtitle change handler updates state, broadcasts, and forwards', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleChangeHandler({
setCurrentSubText: (text) => calls.push(`set:${text}`),
broadcastSubtitle: (payload) => calls.push(`broadcast:${payload.text}`),
onSubtitleChange: (text) => calls.push(`process:${text}`),
});
handler({ text: 'line' });
assert.deepEqual(calls, ['set:line', 'broadcast:line', 'process:line']);
});
test('subtitle ass change handler updates state and broadcasts', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleAssChangeHandler({
setCurrentSubAssText: (text) => calls.push(`set:${text}`),
broadcastSubtitleAss: (text) => calls.push(`broadcast:${text}`),
});
handler({ text: '{\\an8}line' });
assert.deepEqual(calls, ['set:{\\an8}line', 'broadcast:{\\an8}line']);
});
test('secondary subtitle change handler broadcasts text', () => {
const seen: string[] = [];
const handler = createHandleMpvSecondarySubtitleChangeHandler({
broadcastSecondarySubtitle: (text) => seen.push(text),
});
handler({ text: 'secondary' });
assert.deepEqual(seen, ['secondary']);
});
test('media path change handler reports stop for empty path and probes media key', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
getCurrentAnilistMediaKey: () => 'show:1',
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'),
});
handler({ path: '' });
assert.deepEqual(calls, [
'path:',
'stopped',
'reset:show:1',
'probe:show:1',
'guess:show:1',
'sync',
]);
});
test('media title change handler clears guess state and syncs immersion', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaTitleChangeHandler({
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
notifyImmersionTitleUpdate: (title) => calls.push(`notify:${title}`),
syncImmersionMediaState: () => calls.push('sync'),
});
handler({ title: 'Episode 1' });
assert.deepEqual(calls, ['title:Episode 1', 'reset-guess', 'notify:Episode 1', 'sync']);
});
test('time-pos and pause handlers report progress with correct urgency', () => {
const calls: string[] = [];
const timeHandler = createHandleMpvTimePosChangeHandler({
recordPlaybackPosition: (time) => calls.push(`time:${time}`),
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
});
const pauseHandler = createHandleMpvPauseChangeHandler({
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
});
timeHandler({ time: 12.5 });
pauseHandler({ paused: true });
assert.deepEqual(calls, ['time:12.5', 'progress:normal', 'pause:yes', 'progress:force']);
});
test('subtitle metrics change handler forwards patch payload', () => {
let received: Record<string, unknown> | null = null;
const handler = createHandleMpvSubtitleMetricsChangeHandler({
updateSubtitleRenderMetrics: (patch) => {
received = patch;
},
});
const patch = { fontSize: 48 };
handler({ patch });
assert.deepEqual(received, patch);
});
test('secondary subtitle visibility handler stores visibility flag', () => {
const seen: boolean[] = [];
const handler = createHandleMpvSecondarySubtitleVisibilityHandler({
setPreviousSecondarySubVisibility: (visible) => seen.push(visible),
});
handler({ visible: true });
handler({ visible: false });
assert.deepEqual(seen, [true, false]);
});

View File

@@ -0,0 +1,103 @@
export function createHandleMpvSubtitleChangeHandler(deps: {
setCurrentSubText: (text: string) => void;
broadcastSubtitle: (payload: { text: string; tokens: null }) => void;
onSubtitleChange: (text: string) => void;
}) {
return ({ text }: { text: string }): void => {
deps.setCurrentSubText(text);
deps.broadcastSubtitle({ text, tokens: null });
deps.onSubtitleChange(text);
};
}
export function createHandleMpvSubtitleAssChangeHandler(deps: {
setCurrentSubAssText: (text: string) => void;
broadcastSubtitleAss: (text: string) => void;
}) {
return ({ text }: { text: string }): void => {
deps.setCurrentSubAssText(text);
deps.broadcastSubtitleAss(text);
};
}
export function createHandleMpvSecondarySubtitleChangeHandler(deps: {
broadcastSecondarySubtitle: (text: string) => void;
}) {
return ({ text }: { text: string }): void => {
deps.broadcastSecondarySubtitle(text);
};
}
export function createHandleMpvMediaPathChangeHandler(deps: {
updateCurrentMediaPath: (path: string) => void;
reportJellyfinRemoteStopped: () => void;
getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void;
ensureAnilistMediaGuess: (mediaKey: string) => void;
syncImmersionMediaState: () => void;
}) {
return ({ path }: { path: string }): void => {
deps.updateCurrentMediaPath(path);
if (!path) {
deps.reportJellyfinRemoteStopped();
}
const mediaKey = deps.getCurrentAnilistMediaKey();
deps.resetAnilistMediaTracking(mediaKey);
if (mediaKey) {
deps.maybeProbeAnilistDuration(mediaKey);
deps.ensureAnilistMediaGuess(mediaKey);
}
deps.syncImmersionMediaState();
};
}
export function createHandleMpvMediaTitleChangeHandler(deps: {
updateCurrentMediaTitle: (title: string) => void;
resetAnilistMediaGuessState: () => void;
notifyImmersionTitleUpdate: (title: string) => void;
syncImmersionMediaState: () => void;
}) {
return ({ title }: { title: string }): void => {
deps.updateCurrentMediaTitle(title);
deps.resetAnilistMediaGuessState();
deps.notifyImmersionTitleUpdate(title);
deps.syncImmersionMediaState();
};
}
export function createHandleMpvTimePosChangeHandler(deps: {
recordPlaybackPosition: (time: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
}) {
return ({ time }: { time: number }): void => {
deps.recordPlaybackPosition(time);
deps.reportJellyfinRemoteProgress(false);
};
}
export function createHandleMpvPauseChangeHandler(deps: {
recordPauseState: (paused: boolean) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
}) {
return ({ paused }: { paused: boolean }): void => {
deps.recordPauseState(paused);
deps.reportJellyfinRemoteProgress(true);
};
}
export function createHandleMpvSubtitleMetricsChangeHandler(deps: {
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
}) {
return ({ patch }: { patch: Record<string, unknown> }): void => {
deps.updateSubtitleRenderMetrics(patch);
};
}
export function createHandleMpvSecondarySubtitleVisibilityHandler(deps: {
setPreviousSecondarySubVisibility: (visible: boolean) => void;
}) {
return ({ visible }: { visible: boolean }): void => {
deps.setPreviousSecondarySubVisibility(visible);
};
}

View File

@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBindMpvMainEventHandlersHandler } from './mpv-main-event-bindings';
test('main mpv event binder wires callbacks through to runtime deps', () => {
const handlers = new Map<string, (payload: unknown) => void>();
const calls: string[] = [];
const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
hasInitialJellyfinPlayArg: () => false,
isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {
calls.push('schedule-quit-check');
},
isMpvConnected: () => false,
quitApp: () => calls.push('quit-app'),
recordImmersionSubtitleLine: (text) => calls.push(`immersion:${text}`),
hasSubtitleTimingTracker: () => false,
recordSubtitleTiming: () => calls.push('record-timing'),
maybeRunAnilistPostWatchUpdate: async () => {
calls.push('post-watch');
},
logSubtitleTimingError: () => calls.push('subtitle-error'),
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
setCurrentSubAssText: (text) => calls.push(`set-ass:${text}`),
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
getCurrentAnilistMediaKey: () => 'media-key',
resetAnilistMediaTracking: (key) => calls.push(`reset-media:${String(key)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync-immersion'),
updateCurrentMediaTitle: (title) => calls.push(`media-title:${title}`),
resetAnilistMediaGuessState: () => calls.push('reset-guess-state'),
notifyImmersionTitleUpdate: (title) => calls.push(`notify-title:${title}`),
recordPlaybackPosition: (time) => calls.push(`time-pos:${time}`),
reportJellyfinRemoteProgress: (forceImmediate) =>
calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`),
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
updateSubtitleRenderMetrics: () => calls.push('subtitle-metrics'),
setPreviousSecondarySubVisibility: (visible) =>
calls.push(`secondary-visible:${visible ? 'yes' : 'no'}`),
});
bind({
on: (event, handler) => {
handlers.set(event, handler as (payload: unknown) => void);
},
});
handlers.get('subtitle-change')?.({ text: 'line' });
handlers.get('media-title-change')?.({ title: 'Episode 1' });
handlers.get('time-pos-change')?.({ time: 2.5 });
handlers.get('pause-change')?.({ paused: true });
assert.ok(calls.includes('set-sub:line'));
assert.ok(calls.includes('broadcast-sub:line'));
assert.ok(calls.includes('subtitle-change:line'));
assert.ok(calls.includes('media-title:Episode 1'));
assert.ok(calls.includes('reset-guess-state'));
assert.ok(calls.includes('notify-title:Episode 1'));
assert.ok(calls.includes('progress:normal'));
assert.ok(calls.includes('progress:force'));
});

View File

@@ -0,0 +1,139 @@
import {
createBindMpvClientEventHandlers,
createHandleMpvConnectionChangeHandler,
createHandleMpvSubtitleTimingHandler,
} from './mpv-client-event-bindings';
import {
createHandleMpvMediaPathChangeHandler,
createHandleMpvMediaTitleChangeHandler,
createHandleMpvPauseChangeHandler,
createHandleMpvSecondarySubtitleChangeHandler,
createHandleMpvSecondarySubtitleVisibilityHandler,
createHandleMpvSubtitleAssChangeHandler,
createHandleMpvSubtitleChangeHandler,
createHandleMpvSubtitleMetricsChangeHandler,
createHandleMpvTimePosChangeHandler,
} from './mpv-main-event-actions';
type MpvEventClient = {
on: (...args: any[]) => unknown;
};
export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => void;
hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean;
scheduleQuitCheck: (callback: () => void) => void;
isMpvConnected: () => boolean;
quitApp: () => void;
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
hasSubtitleTimingTracker: () => boolean;
recordSubtitleTiming: (text: string, start: number, end: number) => void;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
logSubtitleTimingError: (message: string, error: unknown) => void;
setCurrentSubText: (text: string) => void;
broadcastSubtitle: (payload: { text: string; tokens: null }) => void;
onSubtitleChange: (text: string) => void;
setCurrentSubAssText: (text: string) => void;
broadcastSubtitleAss: (text: string) => void;
broadcastSecondarySubtitle: (text: string) => void;
updateCurrentMediaPath: (path: string) => void;
getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void;
ensureAnilistMediaGuess: (mediaKey: string) => void;
syncImmersionMediaState: () => void;
updateCurrentMediaTitle: (title: string) => void;
resetAnilistMediaGuessState: () => void;
notifyImmersionTitleUpdate: (title: string) => void;
recordPlaybackPosition: (time: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
recordPauseState: (paused: boolean) => void;
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
setPreviousSecondarySubVisibility: (visible: boolean) => void;
}) {
return (mpvClient: MpvEventClient): void => {
const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(),
scheduleQuitCheck: (callback) => deps.scheduleQuitCheck(callback),
isMpvConnected: () => deps.isMpvConnected(),
quitApp: () => deps.quitApp(),
});
const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({
recordImmersionSubtitleLine: (text, start, end) =>
deps.recordImmersionSubtitleLine(text, start, end),
hasSubtitleTimingTracker: () => deps.hasSubtitleTimingTracker(),
recordSubtitleTiming: (text, start, end) => deps.recordSubtitleTiming(text, start, end),
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
logError: (message, error) => deps.logSubtitleTimingError(message, error),
});
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
setCurrentSubText: (text) => deps.setCurrentSubText(text),
broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload),
onSubtitleChange: (text) => deps.onSubtitleChange(text),
});
const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({
setCurrentSubAssText: (text) => deps.setCurrentSubAssText(text),
broadcastSubtitleAss: (text) => deps.broadcastSubtitleAss(text),
});
const handleMpvSecondarySubtitleChange = createHandleMpvSecondarySubtitleChangeHandler({
broadcastSecondarySubtitle: (text) => deps.broadcastSecondarySubtitle(text),
});
const handleMpvMediaPathChange = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path),
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey),
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey),
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
});
const handleMpvMediaTitleChange = createHandleMpvMediaTitleChangeHandler({
updateCurrentMediaTitle: (title) => deps.updateCurrentMediaTitle(title),
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
notifyImmersionTitleUpdate: (title) => deps.notifyImmersionTitleUpdate(title),
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
});
const handleMpvTimePosChange = createHandleMpvTimePosChangeHandler({
recordPlaybackPosition: (time) => deps.recordPlaybackPosition(time),
reportJellyfinRemoteProgress: (forceImmediate) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
});
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
recordPauseState: (paused) => deps.recordPauseState(paused),
reportJellyfinRemoteProgress: (forceImmediate) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
});
const handleMpvSubtitleMetricsChange = createHandleMpvSubtitleMetricsChangeHandler({
updateSubtitleRenderMetrics: (patch) => deps.updateSubtitleRenderMetrics(patch),
});
const handleMpvSecondarySubtitleVisibility = createHandleMpvSecondarySubtitleVisibilityHandler({
setPreviousSecondarySubVisibility: (visible) => deps.setPreviousSecondarySubVisibility(visible),
});
createBindMpvClientEventHandlers({
onConnectionChange: handleMpvConnectionChange,
onSubtitleChange: handleMpvSubtitleChange,
onSubtitleAssChange: handleMpvSubtitleAssChange,
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
onSubtitleTiming: handleMpvSubtitleTiming,
onMediaPathChange: handleMpvMediaPathChange,
onMediaTitleChange: handleMpvMediaTitleChange,
onTimePosChange: handleMpvTimePosChange,
onPauseChange: handleMpvPauseChange,
onSubtitleMetricsChange: handleMpvSubtitleMetricsChange,
onSecondarySubtitleVisibility: handleMpvSecondarySubtitleVisibility,
})(mpvClient as never);
};
}

View File

@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './mpv-main-event-main-deps';
test('mpv main event main deps map app state updates and delegate callbacks', async () => {
const calls: string[] = [];
const appState = {
initialArgs: { jellyfinPlay: true },
overlayRuntimeInitialized: true,
mpvClient: { connected: true },
immersionTracker: {
recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`),
handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`),
recordPlaybackPosition: (time: number) => calls.push(`immersion-time:${time}`),
recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`),
},
subtitleTimingTracker: {
recordSubtitle: (text: string) => calls.push(`timing:${text}`),
},
currentSubText: '',
currentSubAssText: '',
previousSecondarySubVisibility: false,
};
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState,
getQuitOnDisconnectArmed: () => true,
scheduleQuitCheck: (callback) => {
calls.push('schedule');
callback();
},
quitApp: () => calls.push('quit'),
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
maybeRunAnilistPostWatchUpdate: async () => {
calls.push('anilist-post-watch');
},
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
broadcastToOverlayWindows: (channel, payload) => calls.push(`broadcast:${channel}:${String(payload)}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
getCurrentAnilistMediaKey: () => 'media-key',
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync-immersion'),
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`),
updateSubtitleRenderMetrics: () => calls.push('metrics'),
})();
assert.equal(deps.hasInitialJellyfinPlayArg(), true);
assert.equal(deps.isOverlayRuntimeInitialized(), true);
assert.equal(deps.isQuitOnDisconnectArmed(), true);
assert.equal(deps.isMpvConnected(), true);
deps.scheduleQuitCheck(() => calls.push('scheduled-callback'));
deps.quitApp();
deps.reportJellyfinRemoteStopped();
deps.recordImmersionSubtitleLine('x', 0, 1);
assert.equal(deps.hasSubtitleTimingTracker(), true);
deps.recordSubtitleTiming('y', 0, 1);
await deps.maybeRunAnilistPostWatchUpdate();
deps.logSubtitleTimingError('err', new Error('boom'));
deps.setCurrentSubText('sub');
deps.broadcastSubtitle({ text: 'sub', tokens: null });
deps.onSubtitleChange('sub');
deps.setCurrentSubAssText('ass');
deps.broadcastSubtitleAss('ass');
deps.broadcastSecondarySubtitle('sec');
deps.updateCurrentMediaPath('/tmp/video');
assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key');
deps.resetAnilistMediaTracking('media-key');
deps.maybeProbeAnilistDuration('media-key');
deps.ensureAnilistMediaGuess('media-key');
deps.syncImmersionMediaState();
deps.updateCurrentMediaTitle('title');
deps.resetAnilistMediaGuessState();
deps.notifyImmersionTitleUpdate('title');
deps.recordPlaybackPosition(10);
deps.reportJellyfinRemoteProgress(true);
deps.recordPauseState(true);
deps.updateSubtitleRenderMetrics({});
deps.setPreviousSecondarySubVisibility(true);
assert.equal(appState.currentSubText, 'sub');
assert.equal(appState.currentSubAssText, 'ass');
assert.equal(appState.previousSecondarySubVisibility, true);
assert.ok(calls.includes('remote-stopped'));
assert.ok(calls.includes('anilist-post-watch'));
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('metrics'));
});

View File

@@ -0,0 +1,86 @@
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
appState: {
initialArgs?: { jellyfinPlay?: unknown } | null;
overlayRuntimeInitialized: boolean;
mpvClient: { connected?: boolean } | null;
immersionTracker: {
recordSubtitleLine?: (text: string, start: number, end: number) => void;
handleMediaTitleUpdate?: (title: string) => void;
recordPlaybackPosition?: (time: number) => void;
recordPauseState?: (paused: boolean) => void;
} | null;
subtitleTimingTracker: {
recordSubtitle?: (text: string, start: number, end: number) => void;
} | null;
currentSubText: string;
currentSubAssText: string;
previousSecondarySubVisibility: boolean | null;
};
getQuitOnDisconnectArmed: () => boolean;
scheduleQuitCheck: (callback: () => void) => void;
quitApp: () => void;
reportJellyfinRemoteStopped: () => void;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
logSubtitleTimingError: (message: string, error: unknown) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
onSubtitleChange: (text: string) => void;
updateCurrentMediaPath: (path: string) => void;
getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void;
ensureAnilistMediaGuess: (mediaKey: string) => void;
syncImmersionMediaState: () => void;
updateCurrentMediaTitle: (title: string) => void;
resetAnilistMediaGuessState: () => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
}) {
return () => ({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
hasInitialJellyfinPlayArg: () => Boolean(deps.appState.initialArgs?.jellyfinPlay),
isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized,
isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(),
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
quitApp: () => deps.quitApp(),
recordImmersionSubtitleLine: (text: string, start: number, end: number) =>
deps.appState.immersionTracker?.recordSubtitleLine?.(text, start, end),
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
recordSubtitleTiming: (text: string, start: number, end: number) =>
deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end),
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
logSubtitleTimingError: (message: string, error: unknown) =>
deps.logSubtitleTimingError(message, error),
setCurrentSubText: (text: string) => {
deps.appState.currentSubText = text;
},
broadcastSubtitle: (payload: { text: string; tokens: null }) =>
deps.broadcastToOverlayWindows('subtitle:set', payload),
onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
setCurrentSubAssText: (text: string) => {
deps.appState.currentSubAssText = text;
},
broadcastSubtitleAss: (text: string) => deps.broadcastToOverlayWindows('subtitle-ass:set', text),
broadcastSecondarySubtitle: (text: string) =>
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey: string | null) => deps.resetAnilistMediaTracking(mediaKey),
maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey),
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title),
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
notifyImmersionTitleUpdate: (title: string) =>
deps.appState.immersionTracker?.handleMediaTitleUpdate?.(title),
recordPlaybackPosition: (time: number) => deps.appState.immersionTracker?.recordPlaybackPosition?.(time),
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
recordPauseState: (paused: boolean) => deps.appState.immersionTracker?.recordPauseState?.(paused),
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>
deps.updateSubtitleRenderMetrics(patch),
setPreviousSecondarySubVisibility: (visible: boolean) => {
deps.appState.previousSecondarySubVisibility = visible;
},
});
}

View File

@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildAppendToMpvLogMainDepsHandler,
createBuildShowMpvOsdMainDepsHandler,
} from './mpv-osd-log-main-deps';
test('append to mpv log main deps map filesystem functions and log path', () => {
const calls: string[] = [];
const deps = createBuildAppendToMpvLogMainDepsHandler({
logPath: '/tmp/mpv.log',
dirname: (targetPath) => {
calls.push(`dirname:${targetPath}`);
return '/tmp';
},
mkdirSync: (targetPath) => calls.push(`mkdir:${targetPath}`),
appendFileSync: (_targetPath, data) => calls.push(`append:${data}`),
now: () => new Date('2026-02-20T00:00:00.000Z'),
})();
assert.equal(deps.logPath, '/tmp/mpv.log');
assert.equal(deps.dirname('/tmp/mpv.log'), '/tmp');
deps.mkdirSync('/tmp', { recursive: true });
deps.appendFileSync('/tmp/mpv.log', 'line', { encoding: 'utf8' });
assert.equal(deps.now().toISOString(), '2026-02-20T00:00:00.000Z');
assert.deepEqual(calls, ['dirname:/tmp/mpv.log', 'mkdir:/tmp', 'append:line']);
});
test('show mpv osd main deps map runtime delegates and logging callback', () => {
const calls: string[] = [];
const client = { id: 'mpv' };
const deps = createBuildShowMpvOsdMainDepsHandler({
appendToMpvLog: (message) => calls.push(`append:${message}`),
showMpvOsdRuntime: (_mpvClient, text, fallbackLog) => {
calls.push(`show:${text}`);
fallbackLog('fallback');
},
getMpvClient: () => client,
logInfo: (line) => calls.push(`info:${line}`),
})();
assert.deepEqual(deps.getMpvClient(), client);
deps.appendToMpvLog('hello');
deps.showMpvOsdRuntime(deps.getMpvClient(), 'subtitle', (line) => deps.logInfo(line));
assert.deepEqual(calls, ['append:hello', 'show:subtitle', 'info:fallback']);
});

View File

@@ -0,0 +1,39 @@
export function createBuildAppendToMpvLogMainDepsHandler(deps: {
logPath: string;
dirname: (targetPath: string) => string;
mkdirSync: (targetPath: string, options: { recursive: boolean }) => void;
appendFileSync: (targetPath: string, data: string, options: { encoding: 'utf8' }) => void;
now: () => Date;
}) {
return () => ({
logPath: deps.logPath,
dirname: (targetPath: string) => deps.dirname(targetPath),
mkdirSync: (targetPath: string, options: { recursive: boolean }) =>
deps.mkdirSync(targetPath, options),
appendFileSync: (targetPath: string, data: string, options: { encoding: 'utf8' }) =>
deps.appendFileSync(targetPath, data, options),
now: () => deps.now(),
});
}
export function createBuildShowMpvOsdMainDepsHandler(deps: {
appendToMpvLog: (message: string) => void;
showMpvOsdRuntime: (
mpvClient: unknown | null,
text: string,
fallbackLog: (line: string) => void,
) => void;
getMpvClient: () => unknown | null;
logInfo: (line: string) => void;
}) {
return () => ({
appendToMpvLog: (message: string) => deps.appendToMpvLog(message),
showMpvOsdRuntime: (
mpvClient: unknown | null,
text: string,
fallbackLog: (line: string) => void,
) => deps.showMpvOsdRuntime(mpvClient, text, fallbackLog),
getMpvClient: () => deps.getMpvClient() as never,
logInfo: (line: string) => deps.logInfo(line),
});
}

View File

@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildInitializeOverlayRuntimeMainDepsHandler } from './overlay-runtime-options-main-deps';
test('overlay runtime main deps builder maps runtime state and callbacks', () => {
const calls: string[] = [];
const appState = {
backendOverride: 'x11' as string | null,
windowTracker: null as unknown,
subtitleTimingTracker: { id: 'tracker' } as unknown,
mpvClient: null as { send?: (payload: { command: string[] }) => void } | null,
mpvSocketPath: '/tmp/mpv.sock',
runtimeOptionsManager: null,
ankiIntegration: null as unknown,
};
const build = createBuildInitializeOverlayRuntimeMainDepsHandler({
appState,
overlayManager: {
getVisibleOverlayVisible: () => true,
getInvisibleOverlayVisible: () => false,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
updateInvisibleOverlayVisibility: () => calls.push('update-invisible'),
},
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
},
getInitialInvisibleOverlayVisibility: () => true,
createMainWindow: () => calls.push('create-main'),
createInvisibleWindow: () => calls.push('create-invisible'),
registerGlobalShortcuts: () => calls.push('register-shortcuts'),
updateVisibleOverlayBounds: () => calls.push('visible-bounds'),
updateInvisibleOverlayBounds: () => calls.push('invisible-bounds'),
getOverlayWindows: () => [],
getResolvedConfig: () => ({}),
showDesktopNotification: () => calls.push('notify'),
createFieldGroupingCallback: () => async () => ({ cancelled: true }),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
const deps = build();
assert.equal(deps.getBackendOverride(), 'x11');
assert.equal(deps.getInitialInvisibleOverlayVisibility(), true);
assert.equal(deps.isVisibleOverlayVisible(), true);
assert.equal(deps.isInvisibleOverlayVisible(), false);
assert.equal(deps.getMpvSocketPath(), '/tmp/mpv.sock');
assert.equal(deps.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json');
deps.createMainWindow();
deps.createInvisibleWindow();
deps.registerGlobalShortcuts();
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
deps.updateInvisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
deps.updateVisibleOverlayVisibility();
deps.updateInvisibleOverlayVisibility();
deps.syncOverlayShortcuts();
deps.showDesktopNotification('title', {});
deps.setWindowTracker({ id: 'tracker' });
deps.setAnkiIntegration({ id: 'anki' });
assert.deepEqual(calls, [
'create-main',
'create-invisible',
'register-shortcuts',
'visible-bounds',
'invisible-bounds',
'update-visible',
'update-invisible',
'sync-shortcuts',
'notify',
]);
assert.deepEqual(appState.windowTracker, { id: 'tracker' });
assert.deepEqual(appState.ankiIntegration, { id: 'anki' });
});

View File

@@ -0,0 +1,81 @@
import type { AnkiConnectConfig } from '../../types';
export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
appState: {
backendOverride: string | null;
windowTracker: unknown | null;
subtitleTimingTracker: unknown | null;
mpvClient: unknown | null;
mpvSocketPath: string;
runtimeOptionsManager: unknown | null;
ankiIntegration: unknown | null;
};
overlayManager: {
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
};
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
};
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => void;
};
getInitialInvisibleOverlayVisibility: () => boolean;
createMainWindow: () => void;
createInvisibleWindow: () => void;
registerGlobalShortcuts: () => void;
updateVisibleOverlayBounds: (geometry: { x: number; y: number; width: number; height: number }) => void;
updateInvisibleOverlayBounds: (geometry: {
x: number;
y: number;
width: number;
height: number;
}) => void;
getOverlayWindows: () => unknown[];
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
createFieldGroupingCallback: () => unknown;
getKnownWordCacheStatePath: () => string;
}) {
return () => ({
getBackendOverride: () => deps.appState.backendOverride,
getInitialInvisibleOverlayVisibility: () => deps.getInitialInvisibleOverlayVisibility(),
createMainWindow: () => deps.createMainWindow(),
createInvisibleWindow: () => deps.createInvisibleWindow(),
registerGlobalShortcuts: () => deps.registerGlobalShortcuts(),
updateVisibleOverlayBounds: (geometry: { x: number; y: number; width: number; height: number }) =>
deps.updateVisibleOverlayBounds(geometry),
updateInvisibleOverlayBounds: (geometry: {
x: number;
y: number;
width: number;
height: number;
}) => deps.updateInvisibleOverlayBounds(geometry),
isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
isInvisibleOverlayVisible: () => deps.overlayManager.getInvisibleOverlayVisible(),
updateVisibleOverlayVisibility: () => deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
updateInvisibleOverlayVisibility: () =>
deps.overlayVisibilityRuntime.updateInvisibleOverlayVisibility(),
getOverlayWindows: () => deps.getOverlayWindows() as never,
syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(),
setWindowTracker: (tracker: unknown | null) => {
deps.appState.windowTracker = tracker;
},
getResolvedConfig: () => deps.getResolvedConfig(),
getSubtitleTimingTracker: () => deps.appState.subtitleTimingTracker,
getMpvClient: () =>
(deps.appState.mpvClient as { send?: (payload: { command: string[] }) => void } | null),
getMpvSocketPath: () => deps.appState.mpvSocketPath,
getRuntimeOptionsManager: () =>
deps.appState.runtimeOptionsManager as
| { getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig }
| null,
setAnkiIntegration: (integration: unknown | null) => {
deps.appState.ankiIntegration = integration;
},
showDesktopNotification: deps.showDesktopNotification,
createFieldGroupingCallback: () => deps.createFieldGroupingCallback() as never,
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
});
}

View File

@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createSetInvisibleOverlayVisibleHandler,
createSetVisibleOverlayVisibleHandler,
createToggleInvisibleOverlayHandler,
createToggleVisibleOverlayHandler,
} from './overlay-visibility-actions';
test('set visible overlay handler forwards dependencies to core', () => {
const calls: string[] = [];
const setVisible = createSetVisibleOverlayVisibleHandler({
setVisibleOverlayVisibleCore: (options) => {
calls.push(`core:${options.visible}`);
options.setVisibleOverlayVisibleState(options.visible);
options.updateVisibleOverlayVisibility();
options.updateInvisibleOverlayVisibility();
options.syncInvisibleOverlayMousePassthrough();
options.setMpvSubVisibility(!options.visible);
},
setVisibleOverlayVisibleState: (visible) => calls.push(`state:${visible}`),
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
updateInvisibleOverlayVisibility: () => calls.push('update-invisible'),
syncInvisibleOverlayMousePassthrough: () => calls.push('sync-mouse'),
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
isMpvConnected: () => true,
setMpvSubVisibility: (visible) => calls.push(`mpv-sub:${visible}`),
});
setVisible(true);
assert.deepEqual(calls, [
'core:true',
'state:true',
'update-visible',
'update-invisible',
'sync-mouse',
'mpv-sub:false',
]);
});
test('set invisible overlay handler forwards dependencies to core', () => {
const calls: string[] = [];
const setInvisible = createSetInvisibleOverlayVisibleHandler({
setInvisibleOverlayVisibleCore: (options) => {
calls.push(`core:${options.visible}`);
options.setInvisibleOverlayVisibleState(options.visible);
options.updateInvisibleOverlayVisibility();
options.syncInvisibleOverlayMousePassthrough();
},
setInvisibleOverlayVisibleState: (visible) => calls.push(`state:${visible}`),
updateInvisibleOverlayVisibility: () => calls.push('update-invisible'),
syncInvisibleOverlayMousePassthrough: () => calls.push('sync-mouse'),
});
setInvisible(false);
assert.deepEqual(calls, ['core:false', 'state:false', 'update-invisible', 'sync-mouse']);
});
test('toggle visible overlay flips current visible state', () => {
const calls: string[] = [];
let current = false;
const toggle = createToggleVisibleOverlayHandler({
getVisibleOverlayVisible: () => current,
setVisibleOverlayVisible: (visible) => {
current = visible;
calls.push(`set:${visible}`);
},
});
toggle();
toggle();
assert.deepEqual(calls, ['set:true', 'set:false']);
});
test('toggle invisible overlay flips current invisible state', () => {
const calls: string[] = [];
let current = true;
const toggle = createToggleInvisibleOverlayHandler({
getInvisibleOverlayVisible: () => current,
setInvisibleOverlayVisible: (visible) => {
current = visible;
calls.push(`set:${visible}`);
},
});
toggle();
toggle();
assert.deepEqual(calls, ['set:false', 'set:true']);
});

View File

@@ -0,0 +1,72 @@
export function createSetVisibleOverlayVisibleHandler(deps: {
setVisibleOverlayVisibleCore: (options: {
visible: boolean;
setVisibleOverlayVisibleState: (visible: boolean) => void;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isMpvConnected: () => boolean;
setMpvSubVisibility: (visible: boolean) => void;
}) => void;
setVisibleOverlayVisibleState: (visible: boolean) => void;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isMpvConnected: () => boolean;
setMpvSubVisibility: (visible: boolean) => void;
}) {
return (visible: boolean): void => {
deps.setVisibleOverlayVisibleCore({
visible,
setVisibleOverlayVisibleState: deps.setVisibleOverlayVisibleState,
updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility,
updateInvisibleOverlayVisibility: deps.updateInvisibleOverlayVisibility,
syncInvisibleOverlayMousePassthrough: deps.syncInvisibleOverlayMousePassthrough,
shouldBindVisibleOverlayToMpvSubVisibility:
deps.shouldBindVisibleOverlayToMpvSubVisibility,
isMpvConnected: deps.isMpvConnected,
setMpvSubVisibility: deps.setMpvSubVisibility,
});
};
}
export function createSetInvisibleOverlayVisibleHandler(deps: {
setInvisibleOverlayVisibleCore: (options: {
visible: boolean;
setInvisibleOverlayVisibleState: (visible: boolean) => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
}) => void;
setInvisibleOverlayVisibleState: (visible: boolean) => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
}) {
return (visible: boolean): void => {
deps.setInvisibleOverlayVisibleCore({
visible,
setInvisibleOverlayVisibleState: deps.setInvisibleOverlayVisibleState,
updateInvisibleOverlayVisibility: deps.updateInvisibleOverlayVisibility,
syncInvisibleOverlayMousePassthrough: deps.syncInvisibleOverlayMousePassthrough,
});
};
}
export function createToggleVisibleOverlayHandler(deps: {
getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
}) {
return (): void => {
deps.setVisibleOverlayVisible(!deps.getVisibleOverlayVisible());
};
}
export function createToggleInvisibleOverlayHandler(deps: {
getInvisibleOverlayVisible: () => boolean;
setInvisibleOverlayVisible: (visible: boolean) => void;
}) {
return (): void => {
deps.setInvisibleOverlayVisible(!deps.getInvisibleOverlayVisible());
};
}

View File

@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildCreateInvisibleWindowMainDepsHandler,
createBuildCreateMainWindowMainDepsHandler,
createBuildCreateOverlayWindowMainDepsHandler,
} from './overlay-window-factory-main-deps';
test('overlay window factory main deps builders return mapped handlers', () => {
const calls: string[] = [];
const buildOverlayDeps = createBuildCreateOverlayWindowMainDepsHandler({
createOverlayWindowCore: (kind) => ({ kind }),
isDev: true,
getOverlayDebugVisualizationEnabled: () => false,
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'),
setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`),
isOverlayVisible: (kind) => kind === 'visible',
tryHandleOverlayShortcutLocalFallback: () => false,
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
});
const overlayDeps = buildOverlayDeps();
assert.equal(overlayDeps.isDev, true);
assert.equal(overlayDeps.getOverlayDebugVisualizationEnabled(), false);
assert.equal(overlayDeps.isOverlayVisible('visible'), true);
const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({
createOverlayWindow: () => ({ id: 'visible' }),
setMainWindow: () => calls.push('set-main'),
});
const mainDeps = buildMainDeps();
mainDeps.setMainWindow(null);
const buildInvisibleDeps = createBuildCreateInvisibleWindowMainDepsHandler({
createOverlayWindow: () => ({ id: 'invisible' }),
setInvisibleWindow: () => calls.push('set-invisible'),
});
const invisibleDeps = buildInvisibleDeps();
invisibleDeps.setInvisibleWindow(null);
assert.deepEqual(calls, ['set-main', 'set-invisible']);
});

View File

@@ -0,0 +1,55 @@
export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindowCore: (
kind: 'visible' | 'invisible',
options: {
isDev: boolean;
overlayDebugVisualizationEnabled: boolean;
ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: 'visible' | 'invisible') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
onWindowClosed: (windowKind: 'visible' | 'invisible') => void;
},
) => TWindow;
isDev: boolean;
getOverlayDebugVisualizationEnabled: () => boolean;
ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: 'visible' | 'invisible') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
onWindowClosed: (windowKind: 'visible' | 'invisible') => void;
}) {
return () => ({
createOverlayWindowCore: deps.createOverlayWindowCore,
isDev: deps.isDev,
getOverlayDebugVisualizationEnabled: deps.getOverlayDebugVisualizationEnabled,
ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel,
onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged,
setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled,
isOverlayVisible: deps.isOverlayVisible,
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
onWindowClosed: deps.onWindowClosed,
});
}
export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible') => TWindow;
setMainWindow: (window: TWindow | null) => void;
}) {
return () => ({
createOverlayWindow: deps.createOverlayWindow,
setMainWindow: deps.setMainWindow,
});
}
export function createBuildCreateInvisibleWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible') => TWindow;
setInvisibleWindow: (window: TWindow | null) => void;
}) {
return () => ({
createOverlayWindow: deps.createOverlayWindow,
setInvisibleWindow: deps.setInvisibleWindow,
});
}

View File

@@ -0,0 +1,44 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createBuildStartupBootstrapRuntimeFactoryDepsHandler } from './startup-bootstrap-deps-builder';
test('startup bootstrap deps builder returns mapped runtime factory deps', () => {
const calls: string[] = [];
const factory = createBuildStartupBootstrapRuntimeFactoryDepsHandler({
argv: ['node', 'main.js'],
parseArgs: () => ({}) as never,
setLogLevel: (level) => calls.push(`log:${level}`),
forceX11Backend: () => calls.push('force-x11'),
enforceUnsupportedWaylandMode: () => calls.push('wayland-guard'),
shouldStartApp: () => true,
getDefaultSocketPath: () => '/tmp/mpv.sock',
defaultTexthookerPort: 5174,
configDir: '/tmp/config',
defaultConfig: {} as never,
generateConfigTemplate: () => 'template',
generateDefaultConfigFile: async () => 0,
onConfigGenerated: (exitCode) => calls.push(`generated:${exitCode}`),
onGenerateConfigError: (error) => calls.push(`error:${error.message}`),
startAppLifecycle: () => calls.push('start-lifecycle'),
});
const deps = factory();
assert.deepEqual(deps.argv, ['node', 'main.js']);
assert.equal(deps.getDefaultSocketPath(), '/tmp/mpv.sock');
assert.equal(deps.defaultTexthookerPort, 5174);
deps.setLogLevel('debug', 'config');
deps.forceX11Backend({} as never);
deps.enforceUnsupportedWaylandMode({} as never);
deps.onConfigGenerated(0);
deps.onGenerateConfigError(new Error('oops'));
deps.startAppLifecycle({} as never);
assert.deepEqual(calls, [
'log:debug',
'force-x11',
'wayland-guard',
'generated:0',
'error:oops',
'start-lifecycle',
]);
});

View File

@@ -0,0 +1,32 @@
import type { CliArgs } from '../../cli/args';
import type { ResolvedConfig } from '../../types';
import type { LogLevelSource } from '../../logger';
import type { StartupBootstrapRuntimeFactoryDeps } from '../startup';
export function createBuildStartupBootstrapRuntimeFactoryDepsHandler(
deps: StartupBootstrapRuntimeFactoryDeps,
) {
return (): StartupBootstrapRuntimeFactoryDeps => ({
argv: deps.argv,
parseArgs: deps.parseArgs,
setLogLevel: deps.setLogLevel,
forceX11Backend: deps.forceX11Backend,
enforceUnsupportedWaylandMode: deps.enforceUnsupportedWaylandMode,
shouldStartApp: deps.shouldStartApp,
getDefaultSocketPath: deps.getDefaultSocketPath,
defaultTexthookerPort: deps.defaultTexthookerPort,
configDir: deps.configDir,
defaultConfig: deps.defaultConfig,
generateConfigTemplate: deps.generateConfigTemplate,
generateDefaultConfigFile: deps.generateDefaultConfigFile,
onConfigGenerated: deps.onConfigGenerated,
onGenerateConfigError: deps.onGenerateConfigError,
startAppLifecycle: deps.startAppLifecycle,
});
}
export type {
CliArgs as StartupBuilderCliArgs,
ResolvedConfig as StartupBuilderResolvedConfig,
LogLevelSource as StartupBuilderLogLevelSource,
};

View File

@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildResolveTrayIconPathMainDepsHandler,
createBuildTrayMenuTemplateMainDepsHandler,
} from './tray-main-deps';
test('tray main deps builders return mapped handlers', () => {
const calls: string[] = [];
const resolveDeps = createBuildResolveTrayIconPathMainDepsHandler({
resolveTrayIconPathRuntime: () => '/tmp/icon.png',
platform: 'darwin',
resourcesPath: '/resources',
appPath: '/app',
dirname: '/dir',
joinPath: (...parts) => parts.join('/'),
fileExists: () => true,
})();
assert.equal(resolveDeps.platform, 'darwin');
assert.equal(resolveDeps.joinPath('a', 'b'), 'a/b');
const menuDeps = createBuildTrayMenuTemplateMainDepsHandler({
buildTrayMenuTemplateRuntime: () => [{ label: 'tray' }] as never,
initializeOverlayRuntime: () => calls.push('init'),
isOverlayRuntimeInitialized: () => false,
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
openAnilistSetupWindow: () => calls.push('anilist'),
quitApp: () => calls.push('quit'),
})();
const template = menuDeps.buildTrayMenuTemplateRuntime({
openOverlay: () => calls.push('open-overlay'),
openYomitanSettings: () => calls.push('open-yomitan'),
openRuntimeOptions: () => calls.push('open-runtime-options'),
openJellyfinSetup: () => calls.push('open-jellyfin'),
openAnilistSetup: () => calls.push('open-anilist'),
quitApp: () => calls.push('quit-app'),
});
assert.deepEqual(template, [{ label: 'tray' }]);
});

View File

@@ -0,0 +1,57 @@
export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
resolveTrayIconPathRuntime: (options: {
platform: string;
resourcesPath: string;
appPath: string;
dirname: string;
joinPath: (...parts: string[]) => string;
fileExists: (path: string) => boolean;
}) => string | null;
platform: string;
resourcesPath: string;
appPath: string;
dirname: string;
joinPath: (...parts: string[]) => string;
fileExists: (path: string) => boolean;
}) {
return () => ({
resolveTrayIconPathRuntime: deps.resolveTrayIconPathRuntime,
platform: deps.platform,
resourcesPath: deps.resourcesPath,
appPath: deps.appPath,
dirname: deps.dirname,
joinPath: deps.joinPath,
fileExists: deps.fileExists,
});
}
export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
openOverlay: () => void;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
openAnilistSetup: () => void;
quitApp: () => void;
}) => TMenuItem[];
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
openAnilistSetupWindow: () => void;
quitApp: () => void;
}) {
return () => ({
buildTrayMenuTemplateRuntime: deps.buildTrayMenuTemplateRuntime,
initializeOverlayRuntime: deps.initializeOverlayRuntime,
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
openYomitanSettings: deps.openYomitanSettings,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
openAnilistSetupWindow: deps.openAnilistSetupWindow,
quitApp: deps.quitApp,
});
}