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