mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-01 18:12:06 -07:00
Compare commits
1 Commits
refactor-m
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d6c72806bb |
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-238.8
|
|
||||||
title: Refactor src/main.ts composition root into domain runtimes
|
|
||||||
status: In Progress
|
|
||||||
assignee: []
|
|
||||||
created_date: '2026-03-31 06:28'
|
|
||||||
updated_date: '2026-04-01 07:07'
|
|
||||||
labels:
|
|
||||||
- tech-debt
|
|
||||||
- runtime
|
|
||||||
- maintainability
|
|
||||||
- composition-root
|
|
||||||
dependencies: []
|
|
||||||
references:
|
|
||||||
- src/main.ts
|
|
||||||
- src/main/boot/services
|
|
||||||
- src/main/runtime/composers
|
|
||||||
- docs/architecture/README.md
|
|
||||||
parent_task_id: TASK-238
|
|
||||||
priority: high
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Refactor `src/main.ts` so it becomes a thin composition root and the domain-specific runtime wiring moves into short wrapper modules under `src/main/`. Preserve all current behavior, IPC contracts, and config/schema semantics while reducing the entrypoint to boot services, grouped runtime instantiation, startup execution, and process-level quit handling.
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [ ] #1 `src/main.ts` is bootstrap/composition only: platform preflight, boot services, runtime creation, startup execution, and top-level quit/signal handling.
|
|
||||||
- [ ] #2 `src/main.ts` no longer imports `src/main/runtime/*-main-deps.ts` directly.
|
|
||||||
- [ ] #3 `src/main.ts` has no local names like `build*MainDepsHandler`, `*MainDeps`, or trivial `*Handler` pass-through wrappers.
|
|
||||||
- [ ] #4 New wrapper files stay under ~500 LOC each; if one exceeds that, split before merge.
|
|
||||||
- [ ] #5 Cross-domain coordination stays in `main.ts`; wrapper modules stay acyclic and communicate via injected callbacks.
|
|
||||||
- [ ] #6 No user-facing behavior, config fields, or IPC channel names change.
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
<!-- SECTION:NOTES:BEGIN -->
|
|
||||||
CI follow-up: typecheck failed after the runtime split because playlist-browser IPC deps were not threaded through the new bootstrap/composer surfaces. Wiring the missing open action and registration deps now.
|
|
||||||
<!-- SECTION:NOTES:END -->
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-262
|
|
||||||
title: Create overlay UI bootstrap input helper
|
|
||||||
status: To Do
|
|
||||||
assignee: []
|
|
||||||
created_date: '2026-03-31 17:04'
|
|
||||||
labels:
|
|
||||||
- refactor
|
|
||||||
- main
|
|
||||||
- overlay-ui
|
|
||||||
dependencies: []
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Add a coarse input-builder/helper module to reduce the large createOverlayUiRuntime(...) callsite in src/main.ts without changing runtime behavior. Do not edit src/main.ts in this task.
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [ ] #1 New helper module(s) live under src/main/
|
|
||||||
- [ ] #2 Helper accepts grouped overlay UI/domain inputs instead of giant inline literals
|
|
||||||
- [ ] #3 Helper keeps files under 500 LOC
|
|
||||||
- [ ] #4 Optional focused tests added if useful
|
|
||||||
- [ ] #5 No runtime behavior changes
|
|
||||||
<!-- AC:END -->
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-263
|
|
||||||
title: Create coarse startup bootstrap wrapper
|
|
||||||
status: To Do
|
|
||||||
assignee: []
|
|
||||||
created_date: '2026-03-31 17:21'
|
|
||||||
labels:
|
|
||||||
- refactor
|
|
||||||
- main
|
|
||||||
- startup
|
|
||||||
dependencies: []
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Move the large createMainStartupRuntime construction and its self-reference handling out of src/main.ts into a coarse startup bootstrap wrapper. Keep behavior identical and shrink the startup section materially.
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [ ] #1 New wrapper module(s) live under src/main/
|
|
||||||
- [ ] #2 Wrapper owns createMainStartupRuntime construction and self-reference handling
|
|
||||||
- [ ] #3 src/main.ts startup section becomes materially smaller
|
|
||||||
- [ ] #4 Files stay under 500 LOC
|
|
||||||
- [ ] #5 Focused tests cover the wrapper if useful
|
|
||||||
- [ ] #6 No runtime behavior changes
|
|
||||||
<!-- AC:END -->
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-269
|
|
||||||
title: 'Assess and address PR #39 latest CodeRabbit review round'
|
|
||||||
status: Done
|
|
||||||
assignee:
|
|
||||||
- codex
|
|
||||||
created_date: '2026-04-01 07:22'
|
|
||||||
updated_date: '2026-04-01 07:55'
|
|
||||||
labels: []
|
|
||||||
dependencies: []
|
|
||||||
references:
|
|
||||||
- src/main
|
|
||||||
- docs/architecture/README.md
|
|
||||||
priority: high
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Assess unresolved CodeRabbit review threads on PR #39, fix valid findings in the current branch, and document any non-actionable findings so the review state is clear for follow-up.
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [x] #1 All unresolved CodeRabbit review threads on PR #39 are assessed and classified as fix or non-actionable with rationale
|
|
||||||
- [x] #2 Valid findings are addressed in code without regressing current runtime behavior
|
|
||||||
- [x] #3 Regression tests or targeted coverage are added when the reviewed behavior can be exercised locally
|
|
||||||
- [x] #4 Relevant verification commands are run and results recorded in the task summary
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
<!-- SECTION:PLAN:BEGIN -->
|
|
||||||
1. Confirm each unresolved CodeRabbit thread against current branch state and mark already-addressed items as non-actionable without code churn.
|
|
||||||
2. Fix validated runtime issues in focused patches: AniList setup window stale close handler, headless known-word refresh effective config usage, main startup websocket probe wiring, startup warmup delegation, mpv media-path duplicate side effects, overlay visibility readiness guard, Discord presence service cleanup, and stats daemon self-stop handling.
|
|
||||||
3. Tighten headless startup runtime typing so custom bootstrap deps require an explicit factory instead of unsafe casting.
|
|
||||||
4. Add or extend targeted tests for behaviors that can be exercised locally, especially overlay visibility, Discord lifecycle cleanup, stats self-stop, and headless startup typing/runtime coverage.
|
|
||||||
5. Run targeted tests first, then the relevant broader verification lane, and capture which CodeRabbit items were fixed versus assessed as already addressed.
|
|
||||||
<!-- SECTION:PLAN:END -->
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
<!-- SECTION:NOTES:BEGIN -->
|
|
||||||
Reassessed current branch after reset concern: most runtime/test fixes were still present; only live worktree delta was IPC playlist-browser cleanup plus the untracked backlog task record.
|
|
||||||
|
|
||||||
Validated and fixed CodeRabbit findings for stale AniList setup window clearing, effective headless Anki config usage, CLI websocket probe wiring, duplicate mpv media-path side effects, overlay visibility readiness, Discord presence service cleanup, stats self-stop, unsafe headless startup custom deps typing, and playlist-browser IPC wiring.
|
|
||||||
|
|
||||||
Assessed main-startup-runtime-bootstrap warmup comment as non-actionable at this layer: the guarded startBackgroundWarmupsIfAllowed wrapper exists in main-startup-bootstrap, while this lower bootstrap still needs to expose the raw mpv warmup command.
|
|
||||||
<!-- SECTION:NOTES:END -->
|
|
||||||
|
|
||||||
## Final Summary
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
|
||||||
Assessed the unresolved CodeRabbit round on PR #39 and applied the confirmed fixes across the main-process runtime split. Updated AniList setup window teardown to avoid clearing newer windows, made headless known-word refresh use one effective Anki config end-to-end, wired the real mpv websocket probe into CLI startup, removed duplicate mpv media-path side effects, guarded overlay visibility changes behind overlay readiness, stopped Discord presence services before disable/replace, and prevented stats daemon stop from SIGTERMing the current Electron process. Also tightened headless startup typing so custom bootstrap deps require an explicit factory, normalized stats coordinator note-id typing, and completed playlist-browser IPC wiring/types so typecheck stays green.
|
|
||||||
|
|
||||||
Added targeted regression coverage for overlay visibility initialization, Discord lifecycle cleanup, stats self-stop, and headless startup custom-deps typing expectations. Verification run from the current tree: `bun run typecheck`, targeted `bun test` for overlay/discord/stats/headless startup files, and full `bun run test:fast` all passed.
|
|
||||||
|
|
||||||
Assessment outcome for remaining review noise: the playlist-browser open action was already wired in IPC bootstrap, and the warmup-delegation comment on `main-startup-runtime-bootstrap.ts` was not applied because the guarded wrapper lives one layer higher in `main-startup-bootstrap.ts`; this lower bootstrap still needs to surface the raw mpv warmup command consumed by that wrapper.
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: main
|
|
||||||
|
|
||||||
- Split `src/main.ts` into domain runtime wrappers and startup sequencing helpers.
|
|
||||||
- Removed the last direct `src/main/runtime/*-main-deps.ts` imports from `src/main.ts`.
|
|
||||||
- Kept startup behavior and IPC contracts stable while reducing composition-root size.
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# Architecture Map
|
# Architecture Map
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-03-31
|
Last verified: 2026-03-26
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: runtime ownership, composition boundaries, or layering questions
|
Read when: runtime ownership, composition boundaries, or layering questions
|
||||||
|
|
||||||
@@ -24,27 +24,6 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
|
|||||||
## Current Shape
|
## Current Shape
|
||||||
|
|
||||||
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
|
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
|
||||||
- `src/main/*.ts` wrapper runtimes sit between `src/main.ts` and `src/main/runtime/**`
|
|
||||||
so the composition root stays thin while exported `createBuild*MainDepsHandler`
|
|
||||||
APIs remain internal plumbing. Key domain runtimes:
|
|
||||||
- `anilist-runtime` – AniList token management, media tracking, retry queue
|
|
||||||
- `cli-startup-runtime` – CLI command dispatch and initial-args handling
|
|
||||||
- `discord-presence-lifecycle-runtime` – Discord Rich Presence lifecycle
|
|
||||||
- `first-run-runtime` – first-run setup wizard
|
|
||||||
- `ipc-runtime` – IPC handler registration and composition
|
|
||||||
- `jellyfin-runtime` – Jellyfin session, playback, mpv orchestration
|
|
||||||
- `main-startup-runtime` – top-level startup orchestration (app-ready → CLI → headless)
|
|
||||||
- `main-startup-bootstrap` – wiring helper that builds startup runtime inputs
|
|
||||||
- `mining-runtime` – Anki card mining actions
|
|
||||||
- `mpv-runtime` – mpv client lifecycle
|
|
||||||
- `overlay-ui-runtime` – overlay window management, visibility, tray
|
|
||||||
- `overlay-geometry-runtime` – overlay bounds resolution
|
|
||||||
- `shortcuts-runtime` – global shortcut registration
|
|
||||||
- `startup-sequence-runtime` – headless known-word refresh and deferred startup sequencing
|
|
||||||
- `stats-runtime` – immersion tracker, stats server, stats CLI
|
|
||||||
- `subtitle-runtime` – subtitle prefetch, tokenization, caching
|
|
||||||
- `youtube-runtime` – YouTube playback flow
|
|
||||||
- `yomitan-runtime` – Yomitan extension loading and settings
|
|
||||||
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
|
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
|
||||||
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
||||||
- `src/renderer/` owns overlay rendering and input behavior.
|
- `src/renderer/` owns overlay rendering and input behavior.
|
||||||
|
|||||||
4799
src/main.ts
4799
src/main.ts
File diff suppressed because it is too large
Load Diff
@@ -1,110 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import { app, BrowserWindow, shell } from 'electron';
|
|
||||||
|
|
||||||
import { DEFAULT_MIN_WATCH_RATIO } from '../shared/watch-threshold';
|
|
||||||
import type { ResolvedConfig } from '../types';
|
|
||||||
import {
|
|
||||||
guessAnilistMediaInfo,
|
|
||||||
updateAnilistPostWatchProgress,
|
|
||||||
} from '../core/services/anilist/anilist-updater';
|
|
||||||
import type { AnilistSetupWindowLike } from './anilist-runtime';
|
|
||||||
import { createAnilistRuntime } from './anilist-runtime';
|
|
||||||
import {
|
|
||||||
isAllowedAnilistExternalUrl,
|
|
||||||
isAllowedAnilistSetupNavigationUrl,
|
|
||||||
} from './anilist-url-guard';
|
|
||||||
|
|
||||||
export interface AnilistRuntimeCoordinatorInput {
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
isTrackingEnabled: (config: ResolvedConfig) => boolean;
|
|
||||||
tokenStore: Parameters<typeof createAnilistRuntime>[0]['tokenStore'];
|
|
||||||
updateQueue: Parameters<typeof createAnilistRuntime>[0]['updateQueue'];
|
|
||||||
appState: {
|
|
||||||
currentMediaPath: string | null;
|
|
||||||
currentMediaTitle: string | null;
|
|
||||||
mpvClient: {
|
|
||||||
currentTimePos?: number | null;
|
|
||||||
requestProperty: (name: string) => Promise<unknown>;
|
|
||||||
} | null;
|
|
||||||
anilistSetupWindow: BrowserWindow | null;
|
|
||||||
};
|
|
||||||
dictionarySupport: {
|
|
||||||
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
|
||||||
};
|
|
||||||
actions: {
|
|
||||||
showMpvOsd: (message: string) => void;
|
|
||||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
|
||||||
};
|
|
||||||
logger: {
|
|
||||||
info: (message: string) => void;
|
|
||||||
warn: (message: string, details?: unknown) => void;
|
|
||||||
error: (message: string, error?: unknown) => void;
|
|
||||||
debug: (message: string, details?: unknown) => void;
|
|
||||||
};
|
|
||||||
constants: {
|
|
||||||
authorizeUrl: string;
|
|
||||||
clientId: string;
|
|
||||||
responseType: string;
|
|
||||||
redirectUri: string;
|
|
||||||
developerSettingsUrl: string;
|
|
||||||
durationRetryIntervalMs: number;
|
|
||||||
minWatchSeconds: number;
|
|
||||||
maxAttemptedUpdateKeys: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAnilistRuntimeCoordinator(input: AnilistRuntimeCoordinatorInput) {
|
|
||||||
return createAnilistRuntime<ResolvedConfig, AnilistSetupWindowLike>({
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
isTrackingEnabled: (config) => input.isTrackingEnabled(config),
|
|
||||||
tokenStore: input.tokenStore,
|
|
||||||
updateQueue: input.updateQueue,
|
|
||||||
getCurrentMediaPath: () => input.appState.currentMediaPath,
|
|
||||||
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
|
|
||||||
getWatchedSeconds: () => input.appState.mpvClient?.currentTimePos ?? Number.NaN,
|
|
||||||
hasMpvClient: () => Boolean(input.appState.mpvClient),
|
|
||||||
requestMpvDuration: async () => input.appState.mpvClient?.requestProperty('duration'),
|
|
||||||
resolveMediaPathForJimaku: (currentMediaPath) =>
|
|
||||||
input.dictionarySupport.resolveMediaPathForJimaku(currentMediaPath),
|
|
||||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
|
|
||||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
|
||||||
updateAnilistPostWatchProgress(accessToken, title, episode),
|
|
||||||
createBrowserWindow: (options) => {
|
|
||||||
const window = new BrowserWindow(options);
|
|
||||||
input.appState.anilistSetupWindow = window;
|
|
||||||
window.on('closed', () => {
|
|
||||||
input.appState.anilistSetupWindow = null;
|
|
||||||
});
|
|
||||||
return window as unknown as AnilistSetupWindowLike;
|
|
||||||
},
|
|
||||||
authorizeUrl: input.constants.authorizeUrl,
|
|
||||||
clientId: input.constants.clientId,
|
|
||||||
responseType: input.constants.responseType,
|
|
||||||
redirectUri: input.constants.redirectUri,
|
|
||||||
developerSettingsUrl: input.constants.developerSettingsUrl,
|
|
||||||
isAllowedExternalUrl: (url) => isAllowedAnilistExternalUrl(url),
|
|
||||||
isAllowedNavigationUrl: (url) => isAllowedAnilistSetupNavigationUrl(url),
|
|
||||||
openExternal: (url) => shell.openExternal(url),
|
|
||||||
showMpvOsd: (message) => input.actions.showMpvOsd(message),
|
|
||||||
showDesktopNotification: (title, options) =>
|
|
||||||
input.actions.showDesktopNotification(title, options),
|
|
||||||
logInfo: (message) => input.logger.info(message),
|
|
||||||
logWarn: (message, details) => input.logger.warn(message, details),
|
|
||||||
logError: (message, error) => input.logger.error(message, error),
|
|
||||||
logDebug: (message, details) => input.logger.debug(message, details),
|
|
||||||
isDefaultApp: () => Boolean(process.defaultApp),
|
|
||||||
getArgv: () => process.argv,
|
|
||||||
execPath: process.execPath,
|
|
||||||
resolvePath: (value) => path.resolve(value),
|
|
||||||
setAsDefaultProtocolClient: (scheme, appPath, args) =>
|
|
||||||
appPath
|
|
||||||
? app.setAsDefaultProtocolClient(scheme, appPath, args)
|
|
||||||
: app.setAsDefaultProtocolClient(scheme),
|
|
||||||
now: () => Date.now(),
|
|
||||||
durationRetryIntervalMs: input.constants.durationRetryIntervalMs,
|
|
||||||
minWatchSeconds: input.constants.minWatchSeconds,
|
|
||||||
minWatchRatio: DEFAULT_MIN_WATCH_RATIO,
|
|
||||||
maxAttemptedUpdateKeys: input.constants.maxAttemptedUpdateKeys,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createAnilistRuntime } from './anilist-runtime';
|
|
||||||
|
|
||||||
function createSetupWindow() {
|
|
||||||
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
|
||||||
let destroyed = false;
|
|
||||||
return {
|
|
||||||
window: {
|
|
||||||
focus: () => {},
|
|
||||||
close: () => {
|
|
||||||
destroyed = true;
|
|
||||||
for (const handler of handlers.get('closed') ?? []) {
|
|
||||||
handler();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isDestroyed: () => destroyed,
|
|
||||||
on: (event: 'closed', handler: () => void) => {
|
|
||||||
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
|
||||||
},
|
|
||||||
loadURL: async () => {},
|
|
||||||
webContents: {
|
|
||||||
setWindowOpenHandler: () => ({ action: 'deny' as const }),
|
|
||||||
on: (event: string, handler: (...args: unknown[]) => void) => {
|
|
||||||
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
|
||||||
},
|
|
||||||
getURL: () => 'about:blank',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRuntime(overrides: Partial<Parameters<typeof createAnilistRuntime>[0]> = {}) {
|
|
||||||
const savedTokens: string[] = [];
|
|
||||||
const queueCalls: string[] = [];
|
|
||||||
const notifications: string[] = [];
|
|
||||||
const state = {
|
|
||||||
config: {
|
|
||||||
anilist: {
|
|
||||||
enabled: true,
|
|
||||||
accessToken: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const setup = createSetupWindow();
|
|
||||||
|
|
||||||
const runtime = createAnilistRuntime({
|
|
||||||
getResolvedConfig: () => state.config,
|
|
||||||
isTrackingEnabled: (config) => config.anilist.enabled === true,
|
|
||||||
tokenStore: {
|
|
||||||
saveToken: (token) => {
|
|
||||||
savedTokens.push(token);
|
|
||||||
},
|
|
||||||
loadToken: () => null,
|
|
||||||
clearToken: () => {
|
|
||||||
savedTokens.push('cleared');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
updateQueue: {
|
|
||||||
enqueue: (key, title, episode) => {
|
|
||||||
queueCalls.push(`enqueue:${key}:${title}:${episode}`);
|
|
||||||
},
|
|
||||||
nextReady: () => ({
|
|
||||||
key: 'retry-1',
|
|
||||||
title: 'Demo',
|
|
||||||
episode: 2,
|
|
||||||
createdAt: 1,
|
|
||||||
attemptCount: 0,
|
|
||||||
nextAttemptAt: 0,
|
|
||||||
lastError: null,
|
|
||||||
}),
|
|
||||||
markSuccess: (key) => {
|
|
||||||
queueCalls.push(`success:${key}`);
|
|
||||||
},
|
|
||||||
markFailure: (key, message) => {
|
|
||||||
queueCalls.push(`failure:${key}:${message}`);
|
|
||||||
},
|
|
||||||
getSnapshot: () => ({
|
|
||||||
pending: 3,
|
|
||||||
ready: 1,
|
|
||||||
deadLetter: 2,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
getCurrentMediaPath: () => '/tmp/demo.mkv',
|
|
||||||
getCurrentMediaTitle: () => 'Demo',
|
|
||||||
getWatchedSeconds: () => 0,
|
|
||||||
hasMpvClient: () => false,
|
|
||||||
requestMpvDuration: async () => 120,
|
|
||||||
resolveMediaPathForJimaku: (value) => value,
|
|
||||||
guessAnilistMediaInfo: async () => null,
|
|
||||||
updateAnilistPostWatchProgress: async () => ({
|
|
||||||
status: 'updated',
|
|
||||||
message: 'updated ok',
|
|
||||||
}),
|
|
||||||
createBrowserWindow: () => setup.window,
|
|
||||||
authorizeUrl: 'https://anilist.co/api/v2/oauth/authorize',
|
|
||||||
clientId: '36084',
|
|
||||||
responseType: 'token',
|
|
||||||
redirectUri: 'https://anilist.subminer.moe/',
|
|
||||||
developerSettingsUrl: 'https://anilist.co/settings/developer',
|
|
||||||
isAllowedExternalUrl: () => true,
|
|
||||||
isAllowedNavigationUrl: () => true,
|
|
||||||
openExternal: async () => {},
|
|
||||||
showMpvOsd: (message) => {
|
|
||||||
notifications.push(`osd:${message}`);
|
|
||||||
},
|
|
||||||
showDesktopNotification: (_title, options) => {
|
|
||||||
notifications.push(`notify:${options.body}`);
|
|
||||||
},
|
|
||||||
logInfo: (message) => {
|
|
||||||
notifications.push(`info:${message}`);
|
|
||||||
},
|
|
||||||
logWarn: () => {},
|
|
||||||
logError: () => {},
|
|
||||||
logDebug: () => {},
|
|
||||||
isDefaultApp: () => false,
|
|
||||||
getArgv: () => [],
|
|
||||||
execPath: process.execPath,
|
|
||||||
resolvePath: (value) => value,
|
|
||||||
setAsDefaultProtocolClient: () => true,
|
|
||||||
now: () => 1234,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
runtime,
|
|
||||||
state,
|
|
||||||
savedTokens,
|
|
||||||
queueCalls,
|
|
||||||
notifications,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('anilist runtime saves setup token and updates resolved state', () => {
|
|
||||||
const harness = createRuntime();
|
|
||||||
|
|
||||||
const consumed = harness.runtime.consumeAnilistSetupTokenFromUrl(
|
|
||||||
'subminer://anilist-setup?access_token=token-123',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(consumed, true);
|
|
||||||
assert.deepEqual(harness.savedTokens, ['token-123']);
|
|
||||||
assert.equal(harness.runtime.getStatusSnapshot().tokenStatus, 'resolved');
|
|
||||||
assert.equal(harness.runtime.getStatusSnapshot().tokenSource, 'stored');
|
|
||||||
assert.equal(harness.runtime.getStatusSnapshot().tokenMessage, 'saved token from AniList login');
|
|
||||||
assert.ok(harness.notifications.includes('notify:AniList login success'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('anilist runtime bypasses refresh when tracking disabled', async () => {
|
|
||||||
const harness = createRuntime();
|
|
||||||
harness.state.config = {
|
|
||||||
anilist: {
|
|
||||||
enabled: false,
|
|
||||||
accessToken: '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const token = await harness.runtime.refreshAnilistClientSecretStateIfEnabled({
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(token, null);
|
|
||||||
assert.equal(harness.runtime.getStatusSnapshot().tokenStatus, 'not_checked');
|
|
||||||
assert.equal(harness.runtime.getStatusSnapshot().tokenSource, 'none');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('anilist runtime refreshes queue snapshot and retry state after processing', async () => {
|
|
||||||
const harness = createRuntime({
|
|
||||||
tokenStore: {
|
|
||||||
saveToken: () => {},
|
|
||||||
loadToken: () => 'stored-token',
|
|
||||||
clearToken: () => {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
harness.runtime.refreshRetryQueueState();
|
|
||||||
assert.deepEqual(harness.runtime.getQueueStatusSnapshot(), {
|
|
||||||
pending: 3,
|
|
||||||
ready: 1,
|
|
||||||
deadLetter: 2,
|
|
||||||
lastAttemptAt: null,
|
|
||||||
lastError: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await harness.runtime.processNextAnilistRetryUpdate();
|
|
||||||
|
|
||||||
assert.deepEqual(result, { ok: true, message: 'updated ok' });
|
|
||||||
assert.ok(harness.queueCalls.includes('success:retry-1'));
|
|
||||||
assert.equal(harness.runtime.getQueueStatusSnapshot().lastAttemptAt, 1234);
|
|
||||||
assert.equal(harness.runtime.getQueueStatusSnapshot().lastError, null);
|
|
||||||
});
|
|
||||||
@@ -1,495 +0,0 @@
|
|||||||
import {
|
|
||||||
createInitialAnilistMediaGuessRuntimeState,
|
|
||||||
createInitialAnilistRetryQueueState,
|
|
||||||
createInitialAnilistSecretResolutionState,
|
|
||||||
createInitialAnilistUpdateInFlightState,
|
|
||||||
type AnilistMediaGuessRuntimeState,
|
|
||||||
type AnilistRetryQueueState,
|
|
||||||
type AnilistSecretResolutionState,
|
|
||||||
} from './state';
|
|
||||||
import { createAnilistStateRuntime } from './runtime/anilist-state';
|
|
||||||
import { composeAnilistSetupHandlers } from './runtime/composers/anilist-setup-composer';
|
|
||||||
import { composeAnilistTrackingHandlers } from './runtime/composers/anilist-tracking-composer';
|
|
||||||
import {
|
|
||||||
buildAnilistSetupUrl,
|
|
||||||
consumeAnilistSetupCallbackUrl,
|
|
||||||
loadAnilistManualTokenEntry,
|
|
||||||
openAnilistSetupInBrowser,
|
|
||||||
} from './runtime/anilist-setup';
|
|
||||||
import {
|
|
||||||
createMaybeFocusExistingAnilistSetupWindowHandler,
|
|
||||||
createOpenAnilistSetupWindowHandler,
|
|
||||||
} from './runtime/anilist-setup-window';
|
|
||||||
import {
|
|
||||||
buildAnilistAttemptKey,
|
|
||||||
rememberAnilistAttemptedUpdateKey,
|
|
||||||
} from './runtime/anilist-post-watch';
|
|
||||||
import { createCreateAnilistSetupWindowHandler } from './runtime/setup-window-factory';
|
|
||||||
import type {
|
|
||||||
AnilistMediaGuess,
|
|
||||||
AnilistPostWatchUpdateResult,
|
|
||||||
} from '../core/services/anilist/anilist-updater';
|
|
||||||
import type { AnilistUpdateQueue } from '../core/services/anilist/anilist-update-queue';
|
|
||||||
|
|
||||||
export interface AnilistSetupWindowLike {
|
|
||||||
focus: () => void;
|
|
||||||
close: () => void;
|
|
||||||
isDestroyed: () => boolean;
|
|
||||||
on: (event: 'closed', handler: () => void) => void;
|
|
||||||
loadURL: (url: string) => Promise<void> | void;
|
|
||||||
webContents: {
|
|
||||||
setWindowOpenHandler: (handler: (details: { url: string }) => { action: 'deny' }) => void;
|
|
||||||
on: (event: string, handler: (...args: unknown[]) => void) => void;
|
|
||||||
getURL: () => string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnilistTokenStoreLike {
|
|
||||||
saveToken: (token: string) => void;
|
|
||||||
loadToken: () => string | null | undefined;
|
|
||||||
clearToken: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnilistRuntimeInput<
|
|
||||||
TConfig extends { anilist: { accessToken: string; enabled?: boolean } } = {
|
|
||||||
anilist: { accessToken: string; enabled?: boolean };
|
|
||||||
},
|
|
||||||
TWindow extends AnilistSetupWindowLike = AnilistSetupWindowLike,
|
|
||||||
> {
|
|
||||||
getResolvedConfig: () => TConfig;
|
|
||||||
isTrackingEnabled: (config: TConfig) => boolean;
|
|
||||||
tokenStore: AnilistTokenStoreLike;
|
|
||||||
updateQueue: AnilistUpdateQueue;
|
|
||||||
getCurrentMediaPath: () => string | null;
|
|
||||||
getCurrentMediaTitle: () => string | null;
|
|
||||||
getWatchedSeconds: () => number;
|
|
||||||
hasMpvClient: () => boolean;
|
|
||||||
requestMpvDuration: () => Promise<unknown>;
|
|
||||||
resolveMediaPathForJimaku: (currentMediaPath: string | null) => string | null;
|
|
||||||
guessAnilistMediaInfo: (
|
|
||||||
mediaPath: string | null,
|
|
||||||
mediaTitle: string | null,
|
|
||||||
) => Promise<AnilistMediaGuess | null>;
|
|
||||||
updateAnilistPostWatchProgress: (
|
|
||||||
accessToken: string,
|
|
||||||
title: string,
|
|
||||||
episode: number,
|
|
||||||
) => Promise<AnilistPostWatchUpdateResult>;
|
|
||||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
|
|
||||||
authorizeUrl: string;
|
|
||||||
clientId: string;
|
|
||||||
responseType: string;
|
|
||||||
redirectUri: string;
|
|
||||||
developerSettingsUrl: string;
|
|
||||||
isAllowedExternalUrl: (url: string) => boolean;
|
|
||||||
isAllowedNavigationUrl: (url: string) => boolean;
|
|
||||||
openExternal: (url: string) => Promise<unknown> | void;
|
|
||||||
showMpvOsd: (message: string) => void;
|
|
||||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
|
||||||
logInfo: (message: string) => void;
|
|
||||||
logWarn: (message: string, details?: unknown) => void;
|
|
||||||
logError: (message: string, error: unknown) => void;
|
|
||||||
logDebug: (message: string, details?: unknown) => void;
|
|
||||||
isDefaultApp: () => boolean;
|
|
||||||
getArgv: () => string[];
|
|
||||||
execPath: string;
|
|
||||||
resolvePath: (value: string) => string;
|
|
||||||
setAsDefaultProtocolClient: (scheme: string, path?: string, args?: string[]) => boolean;
|
|
||||||
now?: () => number;
|
|
||||||
durationRetryIntervalMs?: number;
|
|
||||||
minWatchSeconds?: number;
|
|
||||||
minWatchRatio?: number;
|
|
||||||
maxAttemptedUpdateKeys?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnilistRuntime {
|
|
||||||
notifyAnilistSetup: (message: string) => void;
|
|
||||||
consumeAnilistSetupTokenFromUrl: (rawUrl: string) => boolean;
|
|
||||||
handleAnilistSetupProtocolUrl: (rawUrl: string) => boolean;
|
|
||||||
registerSubminerProtocolClient: () => void;
|
|
||||||
openAnilistSetupWindow: () => void;
|
|
||||||
refreshAnilistClientSecretState: (options?: {
|
|
||||||
force?: boolean;
|
|
||||||
allowSetupPrompt?: boolean;
|
|
||||||
}) => Promise<string | null>;
|
|
||||||
refreshAnilistClientSecretStateIfEnabled: (options?: {
|
|
||||||
force?: boolean;
|
|
||||||
allowSetupPrompt?: boolean;
|
|
||||||
}) => Promise<string | null>;
|
|
||||||
getCurrentAnilistMediaKey: () => string | null;
|
|
||||||
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
|
||||||
getAnilistMediaGuessRuntimeState: () => AnilistMediaGuessRuntimeState;
|
|
||||||
setAnilistMediaGuessRuntimeState: (state: AnilistMediaGuessRuntimeState) => void;
|
|
||||||
resetAnilistMediaGuessState: () => void;
|
|
||||||
maybeProbeAnilistDuration: (mediaKey: string) => Promise<number | null>;
|
|
||||||
ensureAnilistMediaGuess: (mediaKey: string) => Promise<AnilistMediaGuess | null>;
|
|
||||||
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
|
|
||||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
|
||||||
setClientSecretState: (partial: Partial<AnilistSecretResolutionState>) => void;
|
|
||||||
refreshRetryQueueState: () => void;
|
|
||||||
getStatusSnapshot: () => {
|
|
||||||
tokenStatus: AnilistSecretResolutionState['status'];
|
|
||||||
tokenSource: AnilistSecretResolutionState['source'];
|
|
||||||
tokenMessage: string | null;
|
|
||||||
tokenResolvedAt: number | null;
|
|
||||||
tokenErrorAt: number | null;
|
|
||||||
queuePending: number;
|
|
||||||
queueReady: number;
|
|
||||||
queueDeadLetter: number;
|
|
||||||
queueLastAttemptAt: number | null;
|
|
||||||
queueLastError: string | null;
|
|
||||||
};
|
|
||||||
getQueueStatusSnapshot: () => AnilistRetryQueueState;
|
|
||||||
clearTokenState: () => void;
|
|
||||||
getSetupWindow: () => AnilistSetupWindowLike | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_DURATION_RETRY_INTERVAL_MS = 15_000;
|
|
||||||
const DEFAULT_MIN_WATCH_SECONDS = 10 * 60;
|
|
||||||
const DEFAULT_MIN_WATCH_RATIO = 0.85;
|
|
||||||
const DEFAULT_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
|
|
||||||
|
|
||||||
export function createAnilistRuntime<
|
|
||||||
TConfig extends { anilist: { accessToken: string; enabled?: boolean } },
|
|
||||||
TWindow extends AnilistSetupWindowLike,
|
|
||||||
>(input: AnilistRuntimeInput<TConfig, TWindow>): AnilistRuntime {
|
|
||||||
const now = input.now ?? Date.now;
|
|
||||||
|
|
||||||
let setupWindow: TWindow | null = null;
|
|
||||||
let setupPageOpened = false;
|
|
||||||
let cachedAccessToken: string | null = null;
|
|
||||||
let clientSecretState = createInitialAnilistSecretResolutionState();
|
|
||||||
let retryQueueState = createInitialAnilistRetryQueueState();
|
|
||||||
let mediaGuessRuntimeState = createInitialAnilistMediaGuessRuntimeState();
|
|
||||||
let updateInFlightState = createInitialAnilistUpdateInFlightState();
|
|
||||||
const attemptedUpdateKeys = new Set<string>();
|
|
||||||
|
|
||||||
const stateRuntime = createAnilistStateRuntime({
|
|
||||||
getClientSecretState: () => clientSecretState,
|
|
||||||
setClientSecretState: (next) => {
|
|
||||||
clientSecretState = next;
|
|
||||||
},
|
|
||||||
getRetryQueueState: () => retryQueueState,
|
|
||||||
setRetryQueueState: (next) => {
|
|
||||||
retryQueueState = next;
|
|
||||||
},
|
|
||||||
getUpdateQueueSnapshot: () => input.updateQueue.getSnapshot(),
|
|
||||||
clearStoredToken: () => input.tokenStore.clearToken(),
|
|
||||||
clearCachedAccessToken: () => {
|
|
||||||
cachedAccessToken = null;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const rememberAttemptedUpdate = (key: string): void => {
|
|
||||||
rememberAnilistAttemptedUpdateKey(
|
|
||||||
attemptedUpdateKeys,
|
|
||||||
key,
|
|
||||||
input.maxAttemptedUpdateKeys ?? DEFAULT_MAX_ATTEMPTED_UPDATE_KEYS,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const maybeFocusExistingSetupWindow = createMaybeFocusExistingAnilistSetupWindowHandler({
|
|
||||||
getSetupWindow: () => setupWindow,
|
|
||||||
});
|
|
||||||
const createSetupWindow = createCreateAnilistSetupWindowHandler({
|
|
||||||
createBrowserWindow: (options) => input.createBrowserWindow(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
notifyAnilistSetup,
|
|
||||||
consumeAnilistSetupTokenFromUrl,
|
|
||||||
handleAnilistSetupProtocolUrl,
|
|
||||||
registerSubminerProtocolClient,
|
|
||||||
} = composeAnilistSetupHandlers({
|
|
||||||
notifyDeps: {
|
|
||||||
hasMpvClient: () => input.hasMpvClient(),
|
|
||||||
showMpvOsd: (message) => input.showMpvOsd(message),
|
|
||||||
showDesktopNotification: (title, options) => input.showDesktopNotification(title, options),
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
},
|
|
||||||
consumeTokenDeps: {
|
|
||||||
consumeAnilistSetupCallbackUrl,
|
|
||||||
saveToken: (token) => input.tokenStore.saveToken(token),
|
|
||||||
setCachedToken: (token) => {
|
|
||||||
cachedAccessToken = token;
|
|
||||||
},
|
|
||||||
setResolvedState: (resolvedAt) => {
|
|
||||||
stateRuntime.setClientSecretState({
|
|
||||||
status: 'resolved',
|
|
||||||
source: 'stored',
|
|
||||||
message: 'saved token from AniList login',
|
|
||||||
resolvedAt,
|
|
||||||
errorAt: null,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
setSetupPageOpened: (opened) => {
|
|
||||||
setupPageOpened = opened;
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
notifyAnilistSetup('AniList login success');
|
|
||||||
},
|
|
||||||
closeWindow: () => {
|
|
||||||
if (setupWindow && !setupWindow.isDestroyed()) {
|
|
||||||
setupWindow.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
handleProtocolDeps: {
|
|
||||||
consumeAnilistSetupTokenFromUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl),
|
|
||||||
logWarn: (message, details) => input.logWarn(message, details),
|
|
||||||
},
|
|
||||||
registerProtocolClientDeps: {
|
|
||||||
isDefaultApp: () => input.isDefaultApp(),
|
|
||||||
getArgv: () => input.getArgv(),
|
|
||||||
execPath: input.execPath,
|
|
||||||
resolvePath: (value) => input.resolvePath(value),
|
|
||||||
setAsDefaultProtocolClient: (scheme, targetPath, args) =>
|
|
||||||
input.setAsDefaultProtocolClient(scheme, targetPath, args),
|
|
||||||
logDebug: (message, details) => input.logDebug(message, details),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const openAnilistSetupWindow = createOpenAnilistSetupWindowHandler({
|
|
||||||
maybeFocusExistingSetupWindow: () => maybeFocusExistingSetupWindow(),
|
|
||||||
createSetupWindow: () => createSetupWindow(),
|
|
||||||
buildAuthorizeUrl: () =>
|
|
||||||
buildAnilistSetupUrl({
|
|
||||||
authorizeUrl: input.authorizeUrl,
|
|
||||||
clientId: input.clientId,
|
|
||||||
responseType: input.responseType,
|
|
||||||
redirectUri: input.redirectUri,
|
|
||||||
}),
|
|
||||||
consumeCallbackUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl),
|
|
||||||
openSetupInBrowser: (authorizeUrl) =>
|
|
||||||
openAnilistSetupInBrowser({
|
|
||||||
authorizeUrl,
|
|
||||||
openExternal: async (url) => {
|
|
||||||
await input.openExternal(url);
|
|
||||||
},
|
|
||||||
logError: (message, error) => input.logError(message, error),
|
|
||||||
}),
|
|
||||||
loadManualTokenEntry: (window, authorizeUrl) =>
|
|
||||||
loadAnilistManualTokenEntry({
|
|
||||||
setupWindow: window as never,
|
|
||||||
authorizeUrl,
|
|
||||||
developerSettingsUrl: input.developerSettingsUrl,
|
|
||||||
logWarn: (message, details) => input.logWarn(message, details),
|
|
||||||
}),
|
|
||||||
redirectUri: input.redirectUri,
|
|
||||||
developerSettingsUrl: input.developerSettingsUrl,
|
|
||||||
isAllowedExternalUrl: (url) => input.isAllowedExternalUrl(url),
|
|
||||||
isAllowedNavigationUrl: (url) => input.isAllowedNavigationUrl(url),
|
|
||||||
logWarn: (message, details) => input.logWarn(message, details),
|
|
||||||
logError: (message, details) => input.logError(message, details),
|
|
||||||
clearSetupWindow: () => {
|
|
||||||
setupWindow = null;
|
|
||||||
},
|
|
||||||
setSetupPageOpened: (opened) => {
|
|
||||||
setupPageOpened = opened;
|
|
||||||
},
|
|
||||||
setSetupWindow: (window) => {
|
|
||||||
setupWindow = window;
|
|
||||||
},
|
|
||||||
openExternal: (url) => {
|
|
||||||
void input.openExternal(url);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const trackingRuntime = composeAnilistTrackingHandlers({
|
|
||||||
refreshClientSecretMainDeps: {
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
isAnilistTrackingEnabled: (config) => input.isTrackingEnabled(config as TConfig),
|
|
||||||
getCachedAccessToken: () => cachedAccessToken,
|
|
||||||
setCachedAccessToken: (token) => {
|
|
||||||
cachedAccessToken = token;
|
|
||||||
},
|
|
||||||
saveStoredToken: (token) => {
|
|
||||||
input.tokenStore.saveToken(token);
|
|
||||||
},
|
|
||||||
loadStoredToken: () => input.tokenStore.loadToken(),
|
|
||||||
setClientSecretState: (state) => {
|
|
||||||
clientSecretState = state;
|
|
||||||
},
|
|
||||||
getAnilistSetupPageOpened: () => setupPageOpened,
|
|
||||||
setAnilistSetupPageOpened: (opened) => {
|
|
||||||
setupPageOpened = opened;
|
|
||||||
},
|
|
||||||
openAnilistSetupWindow: () => {
|
|
||||||
openAnilistSetupWindow();
|
|
||||||
},
|
|
||||||
now,
|
|
||||||
},
|
|
||||||
getCurrentMediaKeyMainDeps: {
|
|
||||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
|
||||||
},
|
|
||||||
resetMediaTrackingMainDeps: {
|
|
||||||
setMediaKey: (value) => {
|
|
||||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaKey: value };
|
|
||||||
},
|
|
||||||
setMediaDurationSec: (value) => {
|
|
||||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaDurationSec: value };
|
|
||||||
},
|
|
||||||
setMediaGuess: (value) => {
|
|
||||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuess: value };
|
|
||||||
},
|
|
||||||
setMediaGuessPromise: (value) => {
|
|
||||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuessPromise: value };
|
|
||||||
},
|
|
||||||
setLastDurationProbeAtMs: (value) => {
|
|
||||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, lastDurationProbeAtMs: value };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
getMediaGuessRuntimeStateMainDeps: {
|
|
||||||
getMediaKey: () => mediaGuessRuntimeState.mediaKey,
|
|
||||||
getMediaDurationSec: () => mediaGuessRuntimeState.mediaDurationSec,
|
|
||||||
getMediaGuess: () => mediaGuessRuntimeState.mediaGuess,
|
|
||||||
getMediaGuessPromise: () => mediaGuessRuntimeState.mediaGuessPromise,
|
|
||||||
getLastDurationProbeAtMs: () => mediaGuessRuntimeState.lastDurationProbeAtMs,
|
|
||||||
},
|
|
||||||
setMediaGuessRuntimeStateMainDeps: {
|
|
||||||
setMediaKey: (value) => {
|
|
||||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaKey: value };
|
|
||||||
},
|
|
||||||
setMediaDurationSec: (value) => {
|
|
||||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaDurationSec: value };
|
|
||||||
},
|
|
||||||
setMediaGuess: (value) => {
|
|
||||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuess: value };
|
|
||||||
},
|
|
||||||
setMediaGuessPromise: (value) => {
|
|
||||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuessPromise: value };
|
|
||||||
},
|
|
||||||
setLastDurationProbeAtMs: (value) => {
|
|
||||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, lastDurationProbeAtMs: value };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
resetMediaGuessStateMainDeps: {
|
|
||||||
setMediaGuess: (value) => {
|
|
||||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuess: value };
|
|
||||||
},
|
|
||||||
setMediaGuessPromise: (value) => {
|
|
||||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuessPromise: value };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
maybeProbeDurationMainDeps: {
|
|
||||||
getState: () => mediaGuessRuntimeState,
|
|
||||||
setState: (state) => {
|
|
||||||
mediaGuessRuntimeState = state;
|
|
||||||
},
|
|
||||||
durationRetryIntervalMs: input.durationRetryIntervalMs ?? DEFAULT_DURATION_RETRY_INTERVAL_MS,
|
|
||||||
now,
|
|
||||||
requestMpvDuration: () => input.requestMpvDuration(),
|
|
||||||
logWarn: (message, error) => input.logWarn(message, error),
|
|
||||||
},
|
|
||||||
ensureMediaGuessMainDeps: {
|
|
||||||
getState: () => mediaGuessRuntimeState,
|
|
||||||
setState: (state) => {
|
|
||||||
mediaGuessRuntimeState = state;
|
|
||||||
},
|
|
||||||
resolveMediaPathForJimaku: (currentMediaPath) =>
|
|
||||||
input.resolveMediaPathForJimaku(currentMediaPath),
|
|
||||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
|
||||||
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
|
|
||||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
|
|
||||||
input.guessAnilistMediaInfo(mediaPath, mediaTitle),
|
|
||||||
},
|
|
||||||
processNextRetryUpdateMainDeps: {
|
|
||||||
nextReady: () => input.updateQueue.nextReady(),
|
|
||||||
refreshRetryQueueState: () => stateRuntime.refreshRetryQueueState(),
|
|
||||||
setLastAttemptAt: (value) => {
|
|
||||||
retryQueueState = { ...retryQueueState, lastAttemptAt: value };
|
|
||||||
},
|
|
||||||
setLastError: (value) => {
|
|
||||||
retryQueueState = { ...retryQueueState, lastError: value };
|
|
||||||
},
|
|
||||||
refreshAnilistClientSecretState: () => trackingRuntime.refreshAnilistClientSecretState(),
|
|
||||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
|
||||||
input.updateAnilistPostWatchProgress(accessToken, title, episode),
|
|
||||||
markSuccess: (key) => {
|
|
||||||
input.updateQueue.markSuccess(key);
|
|
||||||
},
|
|
||||||
rememberAttemptedUpdateKey: (key) => {
|
|
||||||
rememberAttemptedUpdate(key);
|
|
||||||
},
|
|
||||||
markFailure: (key, message) => {
|
|
||||||
input.updateQueue.markFailure(key, message);
|
|
||||||
},
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
now,
|
|
||||||
},
|
|
||||||
maybeRunPostWatchUpdateMainDeps: {
|
|
||||||
getInFlight: () => updateInFlightState.inFlight,
|
|
||||||
setInFlight: (value) => {
|
|
||||||
updateInFlightState = { ...updateInFlightState, inFlight: value };
|
|
||||||
},
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
isAnilistTrackingEnabled: (config) => input.isTrackingEnabled(config as TConfig),
|
|
||||||
getCurrentMediaKey: () => trackingRuntime.getCurrentAnilistMediaKey(),
|
|
||||||
hasMpvClient: () => input.hasMpvClient(),
|
|
||||||
getTrackedMediaKey: () => mediaGuessRuntimeState.mediaKey,
|
|
||||||
resetTrackedMedia: (mediaKey) => {
|
|
||||||
trackingRuntime.resetAnilistMediaTracking(mediaKey);
|
|
||||||
},
|
|
||||||
getWatchedSeconds: () => input.getWatchedSeconds(),
|
|
||||||
maybeProbeAnilistDuration: (mediaKey) => trackingRuntime.maybeProbeAnilistDuration(mediaKey),
|
|
||||||
ensureAnilistMediaGuess: (mediaKey) => trackingRuntime.ensureAnilistMediaGuess(mediaKey),
|
|
||||||
hasAttemptedUpdateKey: (key) => attemptedUpdateKeys.has(key),
|
|
||||||
processNextAnilistRetryUpdate: () => trackingRuntime.processNextAnilistRetryUpdate(),
|
|
||||||
refreshAnilistClientSecretState: () => trackingRuntime.refreshAnilistClientSecretState(),
|
|
||||||
enqueueRetry: (key, title, episode) => {
|
|
||||||
input.updateQueue.enqueue(key, title, episode);
|
|
||||||
},
|
|
||||||
markRetryFailure: (key, message) => {
|
|
||||||
input.updateQueue.markFailure(key, message);
|
|
||||||
},
|
|
||||||
markRetrySuccess: (key) => {
|
|
||||||
input.updateQueue.markSuccess(key);
|
|
||||||
},
|
|
||||||
refreshRetryQueueState: () => stateRuntime.refreshRetryQueueState(),
|
|
||||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
|
||||||
input.updateAnilistPostWatchProgress(accessToken, title, episode),
|
|
||||||
rememberAttemptedUpdateKey: (key) => {
|
|
||||||
rememberAttemptedUpdate(key);
|
|
||||||
},
|
|
||||||
showMpvOsd: (message) => input.showMpvOsd(message),
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
logWarn: (message) => input.logWarn(message),
|
|
||||||
minWatchSeconds: input.minWatchSeconds ?? DEFAULT_MIN_WATCH_SECONDS,
|
|
||||||
minWatchRatio: input.minWatchRatio ?? DEFAULT_MIN_WATCH_RATIO,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
notifyAnilistSetup,
|
|
||||||
consumeAnilistSetupTokenFromUrl,
|
|
||||||
handleAnilistSetupProtocolUrl,
|
|
||||||
registerSubminerProtocolClient,
|
|
||||||
openAnilistSetupWindow,
|
|
||||||
refreshAnilistClientSecretState: (options) =>
|
|
||||||
trackingRuntime.refreshAnilistClientSecretState(options),
|
|
||||||
refreshAnilistClientSecretStateIfEnabled: (options) => {
|
|
||||||
if (!input.isTrackingEnabled(input.getResolvedConfig())) {
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
|
||||||
return trackingRuntime.refreshAnilistClientSecretState(options);
|
|
||||||
},
|
|
||||||
getCurrentAnilistMediaKey: () => trackingRuntime.getCurrentAnilistMediaKey(),
|
|
||||||
resetAnilistMediaTracking: (mediaKey) => trackingRuntime.resetAnilistMediaTracking(mediaKey),
|
|
||||||
getAnilistMediaGuessRuntimeState: () => trackingRuntime.getAnilistMediaGuessRuntimeState(),
|
|
||||||
setAnilistMediaGuessRuntimeState: (state) =>
|
|
||||||
trackingRuntime.setAnilistMediaGuessRuntimeState(state),
|
|
||||||
resetAnilistMediaGuessState: () => trackingRuntime.resetAnilistMediaGuessState(),
|
|
||||||
maybeProbeAnilistDuration: (mediaKey) => trackingRuntime.maybeProbeAnilistDuration(mediaKey),
|
|
||||||
ensureAnilistMediaGuess: (mediaKey) => trackingRuntime.ensureAnilistMediaGuess(mediaKey),
|
|
||||||
processNextAnilistRetryUpdate: () => trackingRuntime.processNextAnilistRetryUpdate(),
|
|
||||||
maybeRunAnilistPostWatchUpdate: () => trackingRuntime.maybeRunAnilistPostWatchUpdate(),
|
|
||||||
setClientSecretState: (partial) => stateRuntime.setClientSecretState(partial),
|
|
||||||
refreshRetryQueueState: () => stateRuntime.refreshRetryQueueState(),
|
|
||||||
getStatusSnapshot: () => stateRuntime.getStatusSnapshot(),
|
|
||||||
getQueueStatusSnapshot: () => stateRuntime.getQueueStatusSnapshot(),
|
|
||||||
clearTokenState: () => stateRuntime.clearTokenState(),
|
|
||||||
getSetupWindow: () => setupWindow,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { buildAnilistAttemptKey };
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createAppReadyRuntime } from './app-ready-runtime';
|
|
||||||
|
|
||||||
test('app ready runtime shares overlay startup prereqs with youtube runtime init path', async () => {
|
|
||||||
let subtitlePosition: unknown | null = null;
|
|
||||||
let keybindingsCount = 0;
|
|
||||||
let hasMpvClient = false;
|
|
||||||
let runtimeOptionsManager: unknown | null = null;
|
|
||||||
let subtitleTimingTracker: unknown | null = null;
|
|
||||||
let overlayRuntimeInitialized = false;
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const runtime = createAppReadyRuntime({
|
|
||||||
reload: {
|
|
||||||
reloadConfigStrict: () => ({ ok: true as const, path: '/tmp/config.jsonc', warnings: [] }),
|
|
||||||
logInfo: () => {},
|
|
||||||
logWarning: () => {},
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
startConfigHotReload: () => {},
|
|
||||||
refreshAnilistClientSecretState: async () => undefined,
|
|
||||||
failHandlers: {
|
|
||||||
logError: () => {},
|
|
||||||
showErrorBox: () => {},
|
|
||||||
quit: () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
criticalConfig: {
|
|
||||||
getConfigPath: () => '/tmp/config.jsonc',
|
|
||||||
failHandlers: {
|
|
||||||
logError: () => {},
|
|
||||||
showErrorBox: () => {},
|
|
||||||
quit: () => {
|
|
||||||
throw new Error('quit');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
immersion: {
|
|
||||||
getResolvedConfig: () =>
|
|
||||||
({
|
|
||||||
immersionTracking: {
|
|
||||||
enabled: true,
|
|
||||||
batchSize: 1,
|
|
||||||
flushIntervalMs: 1,
|
|
||||||
queueCap: 1,
|
|
||||||
payloadCapBytes: 1,
|
|
||||||
maintenanceIntervalMs: 1,
|
|
||||||
retention: {
|
|
||||||
eventsDays: 1,
|
|
||||||
telemetryDays: 1,
|
|
||||||
sessionsDays: 1,
|
|
||||||
dailyRollupsDays: 1,
|
|
||||||
monthlyRollupsDays: 1,
|
|
||||||
vacuumIntervalDays: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}) as never,
|
|
||||||
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
|
|
||||||
createTrackerService: () => ({}) as never,
|
|
||||||
setTracker: () => {},
|
|
||||||
getMpvClient: () => null,
|
|
||||||
seedTrackerFromCurrentMedia: () => {},
|
|
||||||
logInfo: () => {},
|
|
||||||
logDebug: () => {},
|
|
||||||
logWarn: () => {},
|
|
||||||
},
|
|
||||||
runner: {
|
|
||||||
ensureDefaultConfigBootstrap: () => {},
|
|
||||||
getSubtitlePosition: () => subtitlePosition,
|
|
||||||
loadSubtitlePosition: () => {
|
|
||||||
subtitlePosition = { mode: 'bottom' };
|
|
||||||
calls.push('loadSubtitlePosition');
|
|
||||||
},
|
|
||||||
getKeybindingsCount: () => keybindingsCount,
|
|
||||||
resolveKeybindings: () => {
|
|
||||||
keybindingsCount = 3;
|
|
||||||
calls.push('resolveKeybindings');
|
|
||||||
},
|
|
||||||
hasMpvClient: () => hasMpvClient,
|
|
||||||
createMpvClient: () => {
|
|
||||||
hasMpvClient = true;
|
|
||||||
calls.push('createMpvClient');
|
|
||||||
},
|
|
||||||
getRuntimeOptionsManager: () => runtimeOptionsManager,
|
|
||||||
initRuntimeOptionsManager: () => {
|
|
||||||
runtimeOptionsManager = {};
|
|
||||||
calls.push('initRuntimeOptionsManager');
|
|
||||||
},
|
|
||||||
getSubtitleTimingTracker: () => subtitleTimingTracker,
|
|
||||||
createSubtitleTimingTracker: () => {
|
|
||||||
subtitleTimingTracker = {};
|
|
||||||
calls.push('createSubtitleTimingTracker');
|
|
||||||
},
|
|
||||||
getResolvedConfig: () =>
|
|
||||||
({
|
|
||||||
ankiConnect: {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
}) as never,
|
|
||||||
getConfigWarnings: () => [],
|
|
||||||
logConfigWarning: () => {},
|
|
||||||
setLogLevel: () => {},
|
|
||||||
setSecondarySubMode: () => {},
|
|
||||||
defaultSecondarySubMode: 'hover',
|
|
||||||
defaultWebsocketPort: 5174,
|
|
||||||
defaultAnnotationWebsocketPort: 6678,
|
|
||||||
defaultTexthookerPort: 5174,
|
|
||||||
hasMpvWebsocketPlugin: () => false,
|
|
||||||
startSubtitleWebsocket: () => {},
|
|
||||||
startAnnotationWebsocket: () => {},
|
|
||||||
startTexthooker: () => {},
|
|
||||||
log: () => {},
|
|
||||||
createMecabTokenizerAndCheck: async () => {},
|
|
||||||
loadYomitanExtension: async () => {},
|
|
||||||
ensureYomitanExtensionLoaded: async () => {
|
|
||||||
calls.push('ensureYomitanExtensionLoaded');
|
|
||||||
},
|
|
||||||
handleFirstRunSetup: async () => {},
|
|
||||||
startBackgroundWarmups: () => {},
|
|
||||||
texthookerOnlyMode: false,
|
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
|
||||||
setVisibleOverlayVisible: () => {},
|
|
||||||
initializeOverlayRuntime: () => {
|
|
||||||
overlayRuntimeInitialized = true;
|
|
||||||
calls.push('initializeOverlayRuntime');
|
|
||||||
},
|
|
||||||
ensureOverlayWindowsReadyForVisibilityActions: () => {
|
|
||||||
calls.push('ensureOverlayWindowsReadyForVisibilityActions');
|
|
||||||
},
|
|
||||||
handleInitialArgs: () => {},
|
|
||||||
},
|
|
||||||
isOverlayRuntimeInitialized: () => overlayRuntimeInitialized,
|
|
||||||
});
|
|
||||||
|
|
||||||
runtime.ensureOverlayStartupPrereqs();
|
|
||||||
runtime.ensureOverlayStartupPrereqs();
|
|
||||||
await runtime.ensureYoutubePlaybackRuntimeReady();
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'loadSubtitlePosition',
|
|
||||||
'resolveKeybindings',
|
|
||||||
'createMpvClient',
|
|
||||||
'initRuntimeOptionsManager',
|
|
||||||
'createSubtitleTimingTracker',
|
|
||||||
'ensureYomitanExtensionLoaded',
|
|
||||||
'initializeOverlayRuntime',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('app ready runtime reuses existing overlay runtime during youtube readiness', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const runtime = createAppReadyRuntime({
|
|
||||||
reload: {
|
|
||||||
reloadConfigStrict: () => ({ ok: true as const, path: '/tmp/config.jsonc', warnings: [] }),
|
|
||||||
logInfo: () => {},
|
|
||||||
logWarning: () => {},
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
startConfigHotReload: () => {},
|
|
||||||
refreshAnilistClientSecretState: async () => undefined,
|
|
||||||
failHandlers: {
|
|
||||||
logError: () => {},
|
|
||||||
showErrorBox: () => {},
|
|
||||||
quit: () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
criticalConfig: {
|
|
||||||
getConfigPath: () => '/tmp/config.jsonc',
|
|
||||||
failHandlers: {
|
|
||||||
logError: () => {},
|
|
||||||
showErrorBox: () => {},
|
|
||||||
quit: () => {
|
|
||||||
throw new Error('quit');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
immersion: {
|
|
||||||
getResolvedConfig: () =>
|
|
||||||
({
|
|
||||||
immersionTracking: {
|
|
||||||
enabled: true,
|
|
||||||
batchSize: 1,
|
|
||||||
flushIntervalMs: 1,
|
|
||||||
queueCap: 1,
|
|
||||||
payloadCapBytes: 1,
|
|
||||||
maintenanceIntervalMs: 1,
|
|
||||||
retention: {
|
|
||||||
eventsDays: 1,
|
|
||||||
telemetryDays: 1,
|
|
||||||
sessionsDays: 1,
|
|
||||||
dailyRollupsDays: 1,
|
|
||||||
monthlyRollupsDays: 1,
|
|
||||||
vacuumIntervalDays: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}) as never,
|
|
||||||
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
|
|
||||||
createTrackerService: () => ({}) as never,
|
|
||||||
setTracker: () => {},
|
|
||||||
getMpvClient: () => null,
|
|
||||||
seedTrackerFromCurrentMedia: () => {},
|
|
||||||
logInfo: () => {},
|
|
||||||
logDebug: () => {},
|
|
||||||
logWarn: () => {},
|
|
||||||
},
|
|
||||||
runner: {
|
|
||||||
ensureDefaultConfigBootstrap: () => {},
|
|
||||||
getSubtitlePosition: () => ({}) as never,
|
|
||||||
loadSubtitlePosition: () => {
|
|
||||||
throw new Error('should not load subtitle position');
|
|
||||||
},
|
|
||||||
getKeybindingsCount: () => 1,
|
|
||||||
resolveKeybindings: () => {
|
|
||||||
throw new Error('should not resolve keybindings');
|
|
||||||
},
|
|
||||||
hasMpvClient: () => true,
|
|
||||||
createMpvClient: () => {
|
|
||||||
throw new Error('should not create mpv client');
|
|
||||||
},
|
|
||||||
getRuntimeOptionsManager: () => ({}),
|
|
||||||
initRuntimeOptionsManager: () => {
|
|
||||||
throw new Error('should not init runtime options');
|
|
||||||
},
|
|
||||||
getSubtitleTimingTracker: () => ({}),
|
|
||||||
createSubtitleTimingTracker: () => {
|
|
||||||
throw new Error('should not create subtitle timing tracker');
|
|
||||||
},
|
|
||||||
getResolvedConfig: () => ({}) as never,
|
|
||||||
getConfigWarnings: () => [],
|
|
||||||
logConfigWarning: () => {},
|
|
||||||
setLogLevel: () => {},
|
|
||||||
setSecondarySubMode: () => {},
|
|
||||||
defaultSecondarySubMode: 'hover',
|
|
||||||
defaultWebsocketPort: 5174,
|
|
||||||
defaultAnnotationWebsocketPort: 6678,
|
|
||||||
defaultTexthookerPort: 5174,
|
|
||||||
hasMpvWebsocketPlugin: () => false,
|
|
||||||
startSubtitleWebsocket: () => {},
|
|
||||||
startAnnotationWebsocket: () => {},
|
|
||||||
startTexthooker: () => {},
|
|
||||||
log: () => {},
|
|
||||||
createMecabTokenizerAndCheck: async () => {},
|
|
||||||
loadYomitanExtension: async () => {},
|
|
||||||
ensureYomitanExtensionLoaded: async () => {
|
|
||||||
calls.push('ensureYomitanExtensionLoaded');
|
|
||||||
},
|
|
||||||
handleFirstRunSetup: async () => {},
|
|
||||||
startBackgroundWarmups: () => {},
|
|
||||||
texthookerOnlyMode: false,
|
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
|
||||||
setVisibleOverlayVisible: () => {},
|
|
||||||
initializeOverlayRuntime: () => {
|
|
||||||
calls.push('initializeOverlayRuntime');
|
|
||||||
},
|
|
||||||
ensureOverlayWindowsReadyForVisibilityActions: () => {
|
|
||||||
calls.push('ensureOverlayWindowsReadyForVisibilityActions');
|
|
||||||
},
|
|
||||||
handleInitialArgs: () => {},
|
|
||||||
},
|
|
||||||
isOverlayRuntimeInitialized: () => true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await runtime.ensureYoutubePlaybackRuntimeReady();
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'ensureYomitanExtensionLoaded',
|
|
||||||
'ensureOverlayWindowsReadyForVisibilityActions',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
import type { LogLevelSource } from '../logger';
|
|
||||||
import type { ConfigValidationWarning, SecondarySubMode } from '../types';
|
|
||||||
import { composeAppReadyRuntime } from './runtime/composers/app-ready-composer';
|
|
||||||
|
|
||||||
type AppReadyConfigLike = {
|
|
||||||
logging?: {
|
|
||||||
level?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type SubtitlePositionLike = unknown;
|
|
||||||
type RuntimeOptionsManagerLike = unknown;
|
|
||||||
type SubtitleTimingTrackerLike = unknown;
|
|
||||||
type ImmersionTrackingConfigLike = {
|
|
||||||
immersionTracking?: {
|
|
||||||
enabled?: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
type MpvClientLike = {
|
|
||||||
connected: boolean;
|
|
||||||
connect: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface AppReadyReloadConfigInput {
|
|
||||||
reloadConfigStrict: () =>
|
|
||||||
| { ok: true; path: string; warnings: ConfigValidationWarning[] }
|
|
||||||
| { ok: false; path: string; error: string };
|
|
||||||
logInfo: (message: string) => void;
|
|
||||||
logWarning: (message: string) => void;
|
|
||||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
|
||||||
startConfigHotReload: () => void;
|
|
||||||
refreshAnilistClientSecretState: (options: { force: boolean }) => Promise<unknown>;
|
|
||||||
failHandlers: {
|
|
||||||
logError: (details: string) => void;
|
|
||||||
showErrorBox: (title: string, details: string) => void;
|
|
||||||
setExitCode?: (code: number) => void;
|
|
||||||
quit: () => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppReadyCriticalConfigInput {
|
|
||||||
getConfigPath: () => string;
|
|
||||||
failHandlers: {
|
|
||||||
logError: (details: string) => void;
|
|
||||||
showErrorBox: (title: string, details: string) => void;
|
|
||||||
setExitCode?: (code: number) => void;
|
|
||||||
quit: () => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppReadyImmersionInput {
|
|
||||||
getResolvedConfig: () => ImmersionTrackingConfigLike;
|
|
||||||
getConfiguredDbPath: () => string;
|
|
||||||
createTrackerService: (params: {
|
|
||||||
dbPath: string;
|
|
||||||
policy: {
|
|
||||||
batchSize: number;
|
|
||||||
flushIntervalMs: number;
|
|
||||||
queueCap: number;
|
|
||||||
payloadCapBytes: number;
|
|
||||||
maintenanceIntervalMs: number;
|
|
||||||
retention: {
|
|
||||||
eventsDays: number;
|
|
||||||
telemetryDays: number;
|
|
||||||
sessionsDays: number;
|
|
||||||
dailyRollupsDays: number;
|
|
||||||
monthlyRollupsDays: number;
|
|
||||||
vacuumIntervalDays: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}) => unknown;
|
|
||||||
setTracker: (tracker: unknown | null) => void;
|
|
||||||
getMpvClient: () => MpvClientLike | null;
|
|
||||||
shouldAutoConnectMpv?: () => boolean;
|
|
||||||
seedTrackerFromCurrentMedia: () => void;
|
|
||||||
logInfo: (message: string) => void;
|
|
||||||
logDebug: (message: string) => void;
|
|
||||||
logWarn: (message: string, details: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppReadyRunnerInput<TConfig extends AppReadyConfigLike = AppReadyConfigLike> {
|
|
||||||
ensureDefaultConfigBootstrap: () => void;
|
|
||||||
getSubtitlePosition: () => SubtitlePositionLike | null;
|
|
||||||
loadSubtitlePosition: () => void;
|
|
||||||
getKeybindingsCount: () => number;
|
|
||||||
resolveKeybindings: () => void;
|
|
||||||
hasMpvClient: () => boolean;
|
|
||||||
createMpvClient: () => void;
|
|
||||||
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
|
|
||||||
initRuntimeOptionsManager: () => void;
|
|
||||||
getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null;
|
|
||||||
createSubtitleTimingTracker: () => void;
|
|
||||||
getResolvedConfig: () => TConfig;
|
|
||||||
getConfigWarnings: () => ConfigValidationWarning[];
|
|
||||||
logConfigWarning: (warning: ConfigValidationWarning) => void;
|
|
||||||
setLogLevel: (level: string, source: LogLevelSource) => void;
|
|
||||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
|
||||||
defaultSecondarySubMode: SecondarySubMode;
|
|
||||||
defaultWebsocketPort: number;
|
|
||||||
defaultAnnotationWebsocketPort: number;
|
|
||||||
defaultTexthookerPort: number;
|
|
||||||
hasMpvWebsocketPlugin: () => boolean;
|
|
||||||
startSubtitleWebsocket: (port: number) => void;
|
|
||||||
startAnnotationWebsocket: (port: number) => void;
|
|
||||||
startTexthooker: (port: number, websocketUrl?: string) => void;
|
|
||||||
log: (message: string) => void;
|
|
||||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
|
||||||
createImmersionTracker?: () => void;
|
|
||||||
startJellyfinRemoteSession?: () => Promise<void>;
|
|
||||||
loadYomitanExtension: () => Promise<void>;
|
|
||||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
|
||||||
handleFirstRunSetup: () => Promise<void>;
|
|
||||||
prewarmSubtitleDictionaries?: () => Promise<void>;
|
|
||||||
startBackgroundWarmups: () => void;
|
|
||||||
texthookerOnlyMode: boolean;
|
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
|
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
|
||||||
initializeOverlayRuntime: () => void;
|
|
||||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
|
||||||
runHeadlessInitialCommand?: () => Promise<void>;
|
|
||||||
handleInitialArgs: () => void;
|
|
||||||
onCriticalConfigErrors?: (errors: string[]) => void;
|
|
||||||
logDebug?: (message: string) => void;
|
|
||||||
now?: () => number;
|
|
||||||
shouldRunHeadlessInitialCommand?: () => boolean;
|
|
||||||
shouldUseMinimalStartup?: () => boolean;
|
|
||||||
shouldSkipHeavyStartup?: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppReadyRuntimeInput<TConfig extends AppReadyConfigLike = AppReadyConfigLike> {
|
|
||||||
reload: AppReadyReloadConfigInput;
|
|
||||||
criticalConfig: AppReadyCriticalConfigInput;
|
|
||||||
immersion: AppReadyImmersionInput;
|
|
||||||
runner: AppReadyRunnerInput<TConfig>;
|
|
||||||
isOverlayRuntimeInitialized: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppReadyRuntime {
|
|
||||||
reloadConfig: () => void;
|
|
||||||
criticalConfigError: (errors: string[]) => never;
|
|
||||||
ensureOverlayStartupPrereqs: () => void;
|
|
||||||
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
|
|
||||||
runAppReady: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAppReadyRuntime<TConfig extends AppReadyConfigLike>(
|
|
||||||
input: AppReadyRuntimeInput<TConfig>,
|
|
||||||
): AppReadyRuntime {
|
|
||||||
const ensureSubtitlePositionLoaded = (): void => {
|
|
||||||
if (input.runner.getSubtitlePosition() === null) {
|
|
||||||
input.runner.loadSubtitlePosition();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureKeybindingsResolved = (): void => {
|
|
||||||
if (input.runner.getKeybindingsCount() === 0) {
|
|
||||||
input.runner.resolveKeybindings();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureMpvClientCreated = (): void => {
|
|
||||||
if (!input.runner.hasMpvClient()) {
|
|
||||||
input.runner.createMpvClient();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureRuntimeOptionsManagerInitialized = (): void => {
|
|
||||||
if (!input.runner.getRuntimeOptionsManager()) {
|
|
||||||
input.runner.initRuntimeOptionsManager();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureSubtitleTimingTrackerCreated = (): void => {
|
|
||||||
if (!input.runner.getSubtitleTimingTracker()) {
|
|
||||||
input.runner.createSubtitleTimingTracker();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureOverlayStartupPrereqs = (): void => {
|
|
||||||
ensureSubtitlePositionLoaded();
|
|
||||||
ensureKeybindingsResolved();
|
|
||||||
ensureMpvClientCreated();
|
|
||||||
ensureRuntimeOptionsManagerInitialized();
|
|
||||||
ensureSubtitleTimingTrackerCreated();
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureYoutubePlaybackRuntimeReady = async (): Promise<void> => {
|
|
||||||
ensureOverlayStartupPrereqs();
|
|
||||||
await input.runner.ensureYomitanExtensionLoaded();
|
|
||||||
if (!input.isOverlayRuntimeInitialized()) {
|
|
||||||
input.runner.initializeOverlayRuntime();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
input.runner.ensureOverlayWindowsReadyForVisibilityActions();
|
|
||||||
};
|
|
||||||
|
|
||||||
const createImmersionTracker = input.runner.createImmersionTracker;
|
|
||||||
const startJellyfinRemoteSession = input.runner.startJellyfinRemoteSession;
|
|
||||||
const prewarmSubtitleDictionaries = input.runner.prewarmSubtitleDictionaries;
|
|
||||||
const runHeadlessInitialCommand = input.runner.runHeadlessInitialCommand;
|
|
||||||
|
|
||||||
const { reloadConfig, criticalConfigError, appReadyRuntimeRunner } = composeAppReadyRuntime({
|
|
||||||
reloadConfigMainDeps: input.reload,
|
|
||||||
criticalConfigErrorMainDeps: input.criticalConfig,
|
|
||||||
immersionTrackerStartupMainDeps: input.immersion as never,
|
|
||||||
appReadyRuntimeMainDeps: {
|
|
||||||
ensureDefaultConfigBootstrap: () => input.runner.ensureDefaultConfigBootstrap(),
|
|
||||||
loadSubtitlePosition: () => ensureSubtitlePositionLoaded(),
|
|
||||||
resolveKeybindings: () => ensureKeybindingsResolved(),
|
|
||||||
createMpvClient: () => ensureMpvClientCreated(),
|
|
||||||
initRuntimeOptionsManager: () => ensureRuntimeOptionsManagerInitialized(),
|
|
||||||
createSubtitleTimingTracker: () => ensureSubtitleTimingTrackerCreated(),
|
|
||||||
getResolvedConfig: () => input.runner.getResolvedConfig() as never,
|
|
||||||
getConfigWarnings: () => input.runner.getConfigWarnings(),
|
|
||||||
logConfigWarning: (warning) => input.runner.logConfigWarning(warning),
|
|
||||||
setLogLevel: (level, source) => input.runner.setLogLevel(level, source),
|
|
||||||
setSecondarySubMode: (mode) => input.runner.setSecondarySubMode(mode),
|
|
||||||
defaultSecondarySubMode: input.runner.defaultSecondarySubMode,
|
|
||||||
defaultWebsocketPort: input.runner.defaultWebsocketPort,
|
|
||||||
defaultAnnotationWebsocketPort: input.runner.defaultAnnotationWebsocketPort,
|
|
||||||
defaultTexthookerPort: input.runner.defaultTexthookerPort,
|
|
||||||
hasMpvWebsocketPlugin: () => input.runner.hasMpvWebsocketPlugin(),
|
|
||||||
startSubtitleWebsocket: (port) => input.runner.startSubtitleWebsocket(port),
|
|
||||||
startAnnotationWebsocket: (port) => input.runner.startAnnotationWebsocket(port),
|
|
||||||
startTexthooker: (port, websocketUrl) => input.runner.startTexthooker(port, websocketUrl),
|
|
||||||
log: (message) => input.runner.log(message),
|
|
||||||
createMecabTokenizerAndCheck: () => input.runner.createMecabTokenizerAndCheck(),
|
|
||||||
createImmersionTracker: createImmersionTracker ? () => createImmersionTracker() : undefined,
|
|
||||||
startJellyfinRemoteSession: startJellyfinRemoteSession
|
|
||||||
? () => startJellyfinRemoteSession()
|
|
||||||
: undefined,
|
|
||||||
loadYomitanExtension: () => input.runner.loadYomitanExtension(),
|
|
||||||
handleFirstRunSetup: () => input.runner.handleFirstRunSetup(),
|
|
||||||
prewarmSubtitleDictionaries: prewarmSubtitleDictionaries
|
|
||||||
? () => prewarmSubtitleDictionaries()
|
|
||||||
: undefined,
|
|
||||||
startBackgroundWarmups: () => input.runner.startBackgroundWarmups(),
|
|
||||||
texthookerOnlyMode: input.runner.texthookerOnlyMode,
|
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
|
||||||
input.runner.shouldAutoInitializeOverlayRuntimeFromConfig(),
|
|
||||||
setVisibleOverlayVisible: (visible) => input.runner.setVisibleOverlayVisible(visible),
|
|
||||||
initializeOverlayRuntime: () => input.runner.initializeOverlayRuntime(),
|
|
||||||
runHeadlessInitialCommand: runHeadlessInitialCommand
|
|
||||||
? () => runHeadlessInitialCommand()
|
|
||||||
: undefined,
|
|
||||||
handleInitialArgs: () => input.runner.handleInitialArgs(),
|
|
||||||
logDebug: input.runner.logDebug,
|
|
||||||
now: input.runner.now,
|
|
||||||
shouldRunHeadlessInitialCommand: input.runner.shouldRunHeadlessInitialCommand,
|
|
||||||
shouldUseMinimalStartup: input.runner.shouldUseMinimalStartup,
|
|
||||||
shouldSkipHeavyStartup: input.runner.shouldSkipHeavyStartup,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
reloadConfig,
|
|
||||||
criticalConfigError,
|
|
||||||
ensureOverlayStartupPrereqs,
|
|
||||||
ensureYoutubePlaybackRuntimeReady,
|
|
||||||
runAppReady: async () => {
|
|
||||||
await appReadyRuntimeRunner();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import type { CliArgs } from '../cli/args';
|
|
||||||
|
|
||||||
import { createCliStartupRuntime } from './cli-startup-runtime';
|
|
||||||
|
|
||||||
test('cli startup runtime returns callable CLI handlers', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const runtime = createCliStartupRuntime({
|
|
||||||
appState: {
|
|
||||||
appState: {} as never,
|
|
||||||
getInitialArgs: () => null,
|
|
||||||
isBackgroundMode: () => false,
|
|
||||||
isTexthookerOnlyMode: () => false,
|
|
||||||
setTexthookerOnlyMode: () => {},
|
|
||||||
hasImmersionTracker: () => false,
|
|
||||||
getMpvClient: () => null,
|
|
||||||
isOverlayRuntimeInitialized: () => false,
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
defaultConfig: { websocket: { port: 6677 }, annotationWebsocket: { port: 6678 } } as never,
|
|
||||||
getResolvedConfig: () => ({}) as never,
|
|
||||||
setCliLogLevel: () => {},
|
|
||||||
hasMpvWebsocketPlugin: () => false,
|
|
||||||
},
|
|
||||||
io: {
|
|
||||||
texthookerService: {} as never,
|
|
||||||
openExternal: async () => {},
|
|
||||||
logBrowserOpenError: () => {},
|
|
||||||
showMpvOsd: () => {},
|
|
||||||
schedule: () => 0 as never,
|
|
||||||
logInfo: () => {},
|
|
||||||
logWarn: () => {},
|
|
||||||
logError: () => {},
|
|
||||||
},
|
|
||||||
commands: {
|
|
||||||
initializeOverlayRuntime: () => {},
|
|
||||||
toggleVisibleOverlay: () => {},
|
|
||||||
openFirstRunSetupWindow: () => {},
|
|
||||||
setVisibleOverlayVisible: () => {},
|
|
||||||
copyCurrentSubtitle: () => {},
|
|
||||||
startPendingMultiCopy: () => {},
|
|
||||||
mineSentenceCard: async () => {},
|
|
||||||
startPendingMineSentenceMultiple: () => {},
|
|
||||||
updateLastCardFromClipboard: async () => {},
|
|
||||||
refreshKnownWordCache: async () => {},
|
|
||||||
triggerFieldGrouping: async () => {},
|
|
||||||
triggerSubsyncFromConfig: async () => {},
|
|
||||||
markLastCardAsAudioCard: async () => {},
|
|
||||||
getAnilistStatus: () => ({}) as never,
|
|
||||||
clearAnilistToken: () => {},
|
|
||||||
openAnilistSetupWindow: () => {},
|
|
||||||
openJellyfinSetupWindow: () => {},
|
|
||||||
getAnilistQueueStatus: () => ({}) as never,
|
|
||||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
|
|
||||||
generateCharacterDictionary: async () => ({
|
|
||||||
zipPath: '/tmp/test.zip',
|
|
||||||
fromCache: false,
|
|
||||||
mediaId: 1,
|
|
||||||
mediaTitle: 'Test',
|
|
||||||
entryCount: 1,
|
|
||||||
}),
|
|
||||||
runJellyfinCommand: async () => {},
|
|
||||||
runStatsCommand: async () => {},
|
|
||||||
runYoutubePlaybackFlow: async () => {},
|
|
||||||
openYomitanSettings: () => {},
|
|
||||||
cycleSecondarySubMode: () => {},
|
|
||||||
openRuntimeOptionsPalette: () => {},
|
|
||||||
printHelp: () => {},
|
|
||||||
stopApp: () => {},
|
|
||||||
hasMainWindow: () => false,
|
|
||||||
getMultiCopyTimeoutMs: () => 0,
|
|
||||||
},
|
|
||||||
startup: {
|
|
||||||
shouldEnsureTrayOnStartup: () => false,
|
|
||||||
shouldRunHeadlessInitialCommand: () => false,
|
|
||||||
ensureTray: () => {},
|
|
||||||
commandNeedsOverlayStartupPrereqs: () => false,
|
|
||||||
commandNeedsOverlayRuntime: () => false,
|
|
||||||
ensureOverlayStartupPrereqs: () => {},
|
|
||||||
startBackgroundWarmups: () => {},
|
|
||||||
},
|
|
||||||
handleCliCommandRuntimeServiceWithContext: (args) => {
|
|
||||||
calls.push(`handle:${(args as { command?: string }).command ?? 'unknown'}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(typeof runtime.handleCliCommand, 'function');
|
|
||||||
assert.equal(typeof runtime.handleInitialArgs, 'function');
|
|
||||||
|
|
||||||
runtime.handleCliCommand({ command: 'start' } as unknown as CliArgs);
|
|
||||||
assert.deepEqual(calls, ['handle:start']);
|
|
||||||
});
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import type { CliArgs, CliCommandSource } from '../cli/args';
|
|
||||||
import type {
|
|
||||||
CliCommandRuntimeServiceContext,
|
|
||||||
CliCommandRuntimeServiceContextHandlers,
|
|
||||||
} from './cli-runtime';
|
|
||||||
import { composeCliStartupHandlers } from './runtime/composers/cli-startup-composer';
|
|
||||||
|
|
||||||
/** Mpv client shape required by the CLI command context (appState.mpvClient). */
|
|
||||||
type AppStateMpvClientLike = {
|
|
||||||
setSocketPath: (socketPath: string) => void;
|
|
||||||
connect: () => void;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
/** Mpv client shape required by the initial-args handler (getMpvClient). */
|
|
||||||
type InitialArgsMpvClientLike = { connected: boolean; connect: () => void } | null;
|
|
||||||
|
|
||||||
/** Resolved config shape consumed by the CLI command context builder. */
|
|
||||||
type ResolvedConfigLike = {
|
|
||||||
texthooker?: { openBrowser?: boolean };
|
|
||||||
websocket?: { enabled?: boolean | 'auto'; port?: number };
|
|
||||||
annotationWebsocket?: { enabled?: boolean; port?: number };
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Mutable app state consumed by the CLI command context builder. */
|
|
||||||
type CliCommandContextMainStateLike = {
|
|
||||||
mpvSocketPath: string;
|
|
||||||
mpvClient: AppStateMpvClientLike;
|
|
||||||
texthookerPort: number;
|
|
||||||
overlayRuntimeInitialized: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CliStartupAppStateInput {
|
|
||||||
appState: CliCommandContextMainStateLike;
|
|
||||||
getInitialArgs: () => CliArgs | null | undefined;
|
|
||||||
isBackgroundMode: () => boolean;
|
|
||||||
isTexthookerOnlyMode: () => boolean;
|
|
||||||
setTexthookerOnlyMode: (enabled: boolean) => void;
|
|
||||||
hasImmersionTracker: () => boolean;
|
|
||||||
getMpvClient: () => InitialArgsMpvClientLike;
|
|
||||||
isOverlayRuntimeInitialized: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CliStartupConfigInput {
|
|
||||||
defaultConfig: {
|
|
||||||
websocket: { port: number };
|
|
||||||
annotationWebsocket: { port: number };
|
|
||||||
};
|
|
||||||
getResolvedConfig: () => ResolvedConfigLike;
|
|
||||||
setCliLogLevel: (level: NonNullable<CliArgs['logLevel']>) => void;
|
|
||||||
hasMpvWebsocketPlugin: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CliStartupIoInput {
|
|
||||||
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
|
|
||||||
openExternal: (url: string) => Promise<void>;
|
|
||||||
logBrowserOpenError: (url: string, error: unknown) => void;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
|
||||||
logInfo: (message: string) => void;
|
|
||||||
logWarn: (message: string) => void;
|
|
||||||
logError: (message: string, err: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CliStartupCommandsInput {
|
|
||||||
initializeOverlayRuntime: () => void;
|
|
||||||
toggleVisibleOverlay: () => void;
|
|
||||||
openFirstRunSetupWindow: () => void;
|
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
|
||||||
copyCurrentSubtitle: () => void;
|
|
||||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
|
||||||
mineSentenceCard: () => Promise<void>;
|
|
||||||
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
|
||||||
updateLastCardFromClipboard: () => Promise<void>;
|
|
||||||
refreshKnownWordCache: () => Promise<void>;
|
|
||||||
triggerFieldGrouping: () => Promise<void>;
|
|
||||||
triggerSubsyncFromConfig: () => Promise<void>;
|
|
||||||
markLastCardAsAudioCard: () => Promise<void>;
|
|
||||||
getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus'];
|
|
||||||
clearAnilistToken: () => void;
|
|
||||||
openAnilistSetupWindow: () => void;
|
|
||||||
openJellyfinSetupWindow: () => void;
|
|
||||||
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
|
|
||||||
processNextAnilistRetryUpdate: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
|
|
||||||
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
|
||||||
runJellyfinCommand: (argsFromCommand: CliArgs) => Promise<void>;
|
|
||||||
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
|
|
||||||
runYoutubePlaybackFlow: (request: {
|
|
||||||
url: string;
|
|
||||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
|
||||||
source: CliCommandSource;
|
|
||||||
}) => Promise<void>;
|
|
||||||
openYomitanSettings: () => void;
|
|
||||||
cycleSecondarySubMode: () => void;
|
|
||||||
openRuntimeOptionsPalette: () => void;
|
|
||||||
printHelp: () => void;
|
|
||||||
stopApp: () => void;
|
|
||||||
hasMainWindow: () => boolean;
|
|
||||||
getMultiCopyTimeoutMs: () => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CliStartupStartupInput {
|
|
||||||
shouldEnsureTrayOnStartup: () => boolean;
|
|
||||||
shouldRunHeadlessInitialCommand: (args: CliArgs) => boolean;
|
|
||||||
ensureTray: () => void;
|
|
||||||
commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean;
|
|
||||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
|
||||||
ensureOverlayStartupPrereqs: () => void;
|
|
||||||
startBackgroundWarmups: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CliStartupRuntimeInput {
|
|
||||||
appState: CliStartupAppStateInput;
|
|
||||||
config: CliStartupConfigInput;
|
|
||||||
io: CliStartupIoInput;
|
|
||||||
commands: CliStartupCommandsInput;
|
|
||||||
startup: CliStartupStartupInput;
|
|
||||||
handleCliCommandRuntimeServiceWithContext: (
|
|
||||||
args: CliArgs,
|
|
||||||
source: CliCommandSource,
|
|
||||||
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers,
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CliStartupRuntime {
|
|
||||||
handleCliCommand: (args: CliArgs, source?: CliCommandSource) => void;
|
|
||||||
handleInitialArgs: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCliStartupRuntime(input: CliStartupRuntimeInput): CliStartupRuntime {
|
|
||||||
const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
|
||||||
cliCommandContextMainDeps: {
|
|
||||||
appState: input.appState.appState,
|
|
||||||
setLogLevel: (level) => input.config.setCliLogLevel(level),
|
|
||||||
texthookerService: input.io.texthookerService,
|
|
||||||
getResolvedConfig: () => input.config.getResolvedConfig(),
|
|
||||||
defaultWebsocketPort: input.config.defaultConfig.websocket.port,
|
|
||||||
defaultAnnotationWebsocketPort: input.config.defaultConfig.annotationWebsocket.port,
|
|
||||||
hasMpvWebsocketPlugin: () => input.config.hasMpvWebsocketPlugin(),
|
|
||||||
openExternal: (url: string) => input.io.openExternal(url),
|
|
||||||
logBrowserOpenError: (url: string, error: unknown) =>
|
|
||||||
input.io.logBrowserOpenError(url, error),
|
|
||||||
showMpvOsd: (text: string) => input.io.showMpvOsd(text),
|
|
||||||
initializeOverlayRuntime: () => input.commands.initializeOverlayRuntime(),
|
|
||||||
toggleVisibleOverlay: () => input.commands.toggleVisibleOverlay(),
|
|
||||||
openFirstRunSetupWindow: () => input.commands.openFirstRunSetupWindow(),
|
|
||||||
setVisibleOverlayVisible: (visible: boolean) =>
|
|
||||||
input.commands.setVisibleOverlayVisible(visible),
|
|
||||||
copyCurrentSubtitle: () => input.commands.copyCurrentSubtitle(),
|
|
||||||
startPendingMultiCopy: (timeoutMs: number) => input.commands.startPendingMultiCopy(timeoutMs),
|
|
||||||
mineSentenceCard: () => input.commands.mineSentenceCard(),
|
|
||||||
startPendingMineSentenceMultiple: (timeoutMs: number) =>
|
|
||||||
input.commands.startPendingMineSentenceMultiple(timeoutMs),
|
|
||||||
updateLastCardFromClipboard: () => input.commands.updateLastCardFromClipboard(),
|
|
||||||
refreshKnownWordCache: () => input.commands.refreshKnownWordCache(),
|
|
||||||
triggerFieldGrouping: () => input.commands.triggerFieldGrouping(),
|
|
||||||
triggerSubsyncFromConfig: () => input.commands.triggerSubsyncFromConfig(),
|
|
||||||
markLastCardAsAudioCard: () => input.commands.markLastCardAsAudioCard(),
|
|
||||||
getAnilistStatus: () => input.commands.getAnilistStatus(),
|
|
||||||
clearAnilistToken: () => input.commands.clearAnilistToken(),
|
|
||||||
openAnilistSetupWindow: () => input.commands.openAnilistSetupWindow(),
|
|
||||||
openJellyfinSetupWindow: () => input.commands.openJellyfinSetupWindow(),
|
|
||||||
getAnilistQueueStatus: () => input.commands.getAnilistQueueStatus(),
|
|
||||||
processNextAnilistRetryUpdate: () => input.commands.processNextAnilistRetryUpdate(),
|
|
||||||
generateCharacterDictionary: (targetPath?: string) =>
|
|
||||||
input.commands.generateCharacterDictionary(targetPath),
|
|
||||||
runJellyfinCommand: (argsFromCommand: CliArgs) =>
|
|
||||||
input.commands.runJellyfinCommand(argsFromCommand),
|
|
||||||
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
|
|
||||||
input.commands.runStatsCommand(argsFromCommand, source),
|
|
||||||
runYoutubePlaybackFlow: (request) => input.commands.runYoutubePlaybackFlow(request),
|
|
||||||
openYomitanSettings: () => input.commands.openYomitanSettings(),
|
|
||||||
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
|
|
||||||
openRuntimeOptionsPalette: () => input.commands.openRuntimeOptionsPalette(),
|
|
||||||
printHelp: () => input.commands.printHelp(),
|
|
||||||
stopApp: () => input.commands.stopApp(),
|
|
||||||
hasMainWindow: () => input.commands.hasMainWindow(),
|
|
||||||
getMultiCopyTimeoutMs: () => input.commands.getMultiCopyTimeoutMs(),
|
|
||||||
schedule: (fn: () => void, delayMs: number) => input.io.schedule(fn, delayMs),
|
|
||||||
logInfo: (message: string) => input.io.logInfo(message),
|
|
||||||
logWarn: (message: string) => input.io.logWarn(message),
|
|
||||||
logError: (message: string, err: unknown) => input.io.logError(message, err),
|
|
||||||
},
|
|
||||||
cliCommandRuntimeHandlerMainDeps: {
|
|
||||||
handleTexthookerOnlyModeTransitionMainDeps: {
|
|
||||||
isTexthookerOnlyMode: () => input.appState.isTexthookerOnlyMode(),
|
|
||||||
ensureOverlayStartupPrereqs: () => input.startup.ensureOverlayStartupPrereqs(),
|
|
||||||
setTexthookerOnlyMode: (enabled) => input.appState.setTexthookerOnlyMode(enabled),
|
|
||||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
|
||||||
input.startup.commandNeedsOverlayStartupPrereqs(args),
|
|
||||||
startBackgroundWarmups: () => input.startup.startBackgroundWarmups(),
|
|
||||||
logInfo: (message: string) => input.io.logInfo(message),
|
|
||||||
},
|
|
||||||
handleCliCommandRuntimeServiceWithContext: (args, source, context) =>
|
|
||||||
input.handleCliCommandRuntimeServiceWithContext(args, source, context),
|
|
||||||
},
|
|
||||||
initialArgsRuntimeHandlerMainDeps: {
|
|
||||||
getInitialArgs: () => input.appState.getInitialArgs() ?? null,
|
|
||||||
isBackgroundMode: () => input.appState.isBackgroundMode(),
|
|
||||||
shouldEnsureTrayOnStartup: () => input.startup.shouldEnsureTrayOnStartup(),
|
|
||||||
shouldRunHeadlessInitialCommand: (args) =>
|
|
||||||
input.startup.shouldRunHeadlessInitialCommand(args),
|
|
||||||
ensureTray: () => input.startup.ensureTray(),
|
|
||||||
isTexthookerOnlyMode: () => input.appState.isTexthookerOnlyMode(),
|
|
||||||
hasImmersionTracker: () => input.appState.hasImmersionTracker(),
|
|
||||||
getMpvClient: () => input.appState.getMpvClient(),
|
|
||||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
|
||||||
input.startup.commandNeedsOverlayStartupPrereqs(args),
|
|
||||||
commandNeedsOverlayRuntime: (args) => input.startup.commandNeedsOverlayRuntime(args),
|
|
||||||
ensureOverlayStartupPrereqs: () => input.startup.ensureOverlayStartupPrereqs(),
|
|
||||||
isOverlayRuntimeInitialized: () => input.appState.isOverlayRuntimeInitialized(),
|
|
||||||
initializeOverlayRuntime: () => input.commands.initializeOverlayRuntime(),
|
|
||||||
logInfo: (message) => input.io.logInfo(message),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
handleCliCommand,
|
|
||||||
handleInitialArgs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import {
|
|
||||||
createBuildGetDefaultSocketPathMainDepsHandler,
|
|
||||||
createGetDefaultSocketPathHandler,
|
|
||||||
} from './runtime/domains/jellyfin';
|
|
||||||
|
|
||||||
export function createDefaultSocketPathResolver(platform: NodeJS.Platform) {
|
|
||||||
return createGetDefaultSocketPathHandler(
|
|
||||||
createBuildGetDefaultSocketPathMainDepsHandler({
|
|
||||||
platform,
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
|
||||||
import type { FrequencyDictionaryLookup, ResolvedConfig } from '../types';
|
|
||||||
import type { JlptLookup } from './jlpt-runtime';
|
|
||||||
import type { DictionarySupportRuntimeInput } from './dictionary-support-runtime';
|
|
||||||
import { notifyCharacterDictionaryAutoSyncStatus } from './runtime/character-dictionary-auto-sync-notifications';
|
|
||||||
import type { StartupOsdSequencerCharacterDictionaryEvent } from './runtime/startup-osd-sequencer';
|
|
||||||
|
|
||||||
type BrowserWindowLike = {
|
|
||||||
isDestroyed: () => boolean;
|
|
||||||
webContents: {
|
|
||||||
send: (channel: string, payload?: unknown) => void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type ImmersionTrackerLike = {
|
|
||||||
handleMediaChange: (path: string, title: string | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MpvClientLike = {
|
|
||||||
currentVideoPath?: string | null;
|
|
||||||
connected?: boolean;
|
|
||||||
requestProperty?: (name: string) => Promise<unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StartupOsdSequencerLike = {
|
|
||||||
notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface DictionarySupportRuntimeInputBuilderInput {
|
|
||||||
env: {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
dirname: string;
|
|
||||||
appPath: string;
|
|
||||||
resourcesPath: string;
|
|
||||||
userDataPath: string;
|
|
||||||
appUserDataPath: string;
|
|
||||||
homeDir: string;
|
|
||||||
appDataDir?: string;
|
|
||||||
cwd: string;
|
|
||||||
subtitlePositionsDir: string;
|
|
||||||
defaultImmersionDbPath: string;
|
|
||||||
};
|
|
||||||
config: {
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
};
|
|
||||||
dictionaryState: {
|
|
||||||
setJlptLevelLookup: (lookup: JlptLookup) => void;
|
|
||||||
setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void;
|
|
||||||
};
|
|
||||||
logger: {
|
|
||||||
info: (message: string) => void;
|
|
||||||
debug: (message: string) => void;
|
|
||||||
warn: (message: string) => void;
|
|
||||||
error: (message: string, ...args: unknown[]) => void;
|
|
||||||
};
|
|
||||||
media: {
|
|
||||||
isRemoteMediaPath: (mediaPath: string) => boolean;
|
|
||||||
getCurrentMediaPath: () => string | null;
|
|
||||||
setCurrentMediaPath: (mediaPath: string | null) => void;
|
|
||||||
getCurrentMediaTitle: () => string | null;
|
|
||||||
setCurrentMediaTitle: (title: string | null) => void;
|
|
||||||
getPendingSubtitlePosition: () => DictionarySupportRuntimeInput['getPendingSubtitlePosition'] extends () => infer T
|
|
||||||
? T
|
|
||||||
: never;
|
|
||||||
clearPendingSubtitlePosition: () => void;
|
|
||||||
setSubtitlePosition: DictionarySupportRuntimeInput['setSubtitlePosition'];
|
|
||||||
};
|
|
||||||
subtitle: {
|
|
||||||
loadSubtitlePosition: DictionarySupportRuntimeInput['loadSubtitlePosition'];
|
|
||||||
invalidateTokenizationCache: () => void;
|
|
||||||
refreshSubtitlePrefetchFromActiveTrack: () => void;
|
|
||||||
refreshCurrentSubtitle: (text: string) => void;
|
|
||||||
getCurrentSubtitleText: () => string;
|
|
||||||
};
|
|
||||||
overlay: {
|
|
||||||
broadcastSubtitlePosition: DictionarySupportRuntimeInput<OverlayHostedModal>['broadcastSubtitlePosition'];
|
|
||||||
broadcastToOverlayWindows: DictionarySupportRuntimeInput<OverlayHostedModal>['broadcastToOverlayWindows'];
|
|
||||||
getMainWindow: () => BrowserWindowLike | null;
|
|
||||||
getVisibleOverlayVisible: () => boolean;
|
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
|
||||||
sendToActiveOverlayWindow: DictionarySupportRuntimeInput<OverlayHostedModal>['sendToActiveOverlayWindow'];
|
|
||||||
};
|
|
||||||
tracker: {
|
|
||||||
getTracker: () => ImmersionTrackerLike | null;
|
|
||||||
getMpvClient: () => MpvClientLike | null;
|
|
||||||
};
|
|
||||||
anilist: {
|
|
||||||
guessAnilistMediaInfo: DictionarySupportRuntimeInput['guessAnilistMediaInfo'];
|
|
||||||
};
|
|
||||||
yomitan: {
|
|
||||||
isCharacterDictionaryEnabled: () => boolean;
|
|
||||||
getYomitanDictionaryInfo: () => Promise<Array<{ title: string; revision?: string | number }>>;
|
|
||||||
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
|
||||||
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
|
|
||||||
upsertYomitanDictionarySettings: (
|
|
||||||
dictionaryTitle: string,
|
|
||||||
profileScope: ResolvedConfig['anilist']['characterDictionary']['profileScope'],
|
|
||||||
) => Promise<boolean>;
|
|
||||||
hasParserWindow: () => boolean;
|
|
||||||
clearParserCaches: () => void;
|
|
||||||
};
|
|
||||||
startup: {
|
|
||||||
getNotificationType: () => 'osd' | 'system' | 'both' | 'none' | undefined;
|
|
||||||
showMpvOsd: (message: string) => void;
|
|
||||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
|
||||||
startupOsdSequencer?: StartupOsdSequencerLike;
|
|
||||||
};
|
|
||||||
playback: {
|
|
||||||
isYoutubePlaybackActiveNow: () => boolean;
|
|
||||||
waitForYomitanMutationReady: () => Promise<void>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDictionarySupportRuntimeInput(
|
|
||||||
input: DictionarySupportRuntimeInputBuilderInput,
|
|
||||||
): DictionarySupportRuntimeInput<OverlayHostedModal> {
|
|
||||||
return {
|
|
||||||
platform: input.env.platform,
|
|
||||||
dirname: input.env.dirname,
|
|
||||||
appPath: input.env.appPath,
|
|
||||||
resourcesPath: input.env.resourcesPath,
|
|
||||||
userDataPath: input.env.userDataPath,
|
|
||||||
appUserDataPath: input.env.appUserDataPath,
|
|
||||||
homeDir: input.env.homeDir,
|
|
||||||
appDataDir: input.env.appDataDir,
|
|
||||||
cwd: input.env.cwd,
|
|
||||||
subtitlePositionsDir: input.env.subtitlePositionsDir,
|
|
||||||
getResolvedConfig: () => input.config.getResolvedConfig(),
|
|
||||||
isJlptEnabled: () => input.config.getResolvedConfig().subtitleStyle.enableJlpt,
|
|
||||||
isFrequencyDictionaryEnabled: () =>
|
|
||||||
input.config.getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
|
||||||
getFrequencyDictionarySourcePath: () =>
|
|
||||||
input.config.getResolvedConfig().subtitleStyle.frequencyDictionary.sourcePath,
|
|
||||||
setJlptLevelLookup: (lookup) => input.dictionaryState.setJlptLevelLookup(lookup),
|
|
||||||
setFrequencyRankLookup: (lookup) => input.dictionaryState.setFrequencyRankLookup(lookup),
|
|
||||||
logInfo: (message) => input.logger.info(message),
|
|
||||||
logDebug: (message) => input.logger.debug(message),
|
|
||||||
logWarn: (message) => input.logger.warn(message),
|
|
||||||
isRemoteMediaPath: (mediaPath) => input.media.isRemoteMediaPath(mediaPath),
|
|
||||||
getCurrentMediaPath: () => input.media.getCurrentMediaPath(),
|
|
||||||
setCurrentMediaPath: (mediaPath) => input.media.setCurrentMediaPath(mediaPath),
|
|
||||||
getCurrentMediaTitle: () => input.media.getCurrentMediaTitle(),
|
|
||||||
setCurrentMediaTitle: (title) => input.media.setCurrentMediaTitle(title),
|
|
||||||
getPendingSubtitlePosition: () => input.media.getPendingSubtitlePosition(),
|
|
||||||
loadSubtitlePosition: () => input.subtitle.loadSubtitlePosition(),
|
|
||||||
clearPendingSubtitlePosition: () => input.media.clearPendingSubtitlePosition(),
|
|
||||||
setSubtitlePosition: (position) => input.media.setSubtitlePosition(position),
|
|
||||||
broadcastSubtitlePosition: (position) => input.overlay.broadcastSubtitlePosition(position),
|
|
||||||
broadcastToOverlayWindows: (channel, payload) =>
|
|
||||||
input.overlay.broadcastToOverlayWindows(channel, payload),
|
|
||||||
getTracker: () => input.tracker.getTracker(),
|
|
||||||
getMpvClient: () => input.tracker.getMpvClient(),
|
|
||||||
defaultImmersionDbPath: input.env.defaultImmersionDbPath,
|
|
||||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
|
|
||||||
input.anilist.guessAnilistMediaInfo(mediaPath, mediaTitle),
|
|
||||||
getCollapsibleSectionOpenState: (section) =>
|
|
||||||
input.config.getResolvedConfig().anilist.characterDictionary.collapsibleSections[section],
|
|
||||||
isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(),
|
|
||||||
isYoutubePlaybackActiveNow: () => input.playback.isYoutubePlaybackActiveNow(),
|
|
||||||
waitForYomitanMutationReady: () => input.playback.waitForYomitanMutationReady(),
|
|
||||||
getYomitanDictionaryInfo: () => input.yomitan.getYomitanDictionaryInfo(),
|
|
||||||
importYomitanDictionary: (zipPath) => input.yomitan.importYomitanDictionary(zipPath),
|
|
||||||
deleteYomitanDictionary: (dictionaryTitle) =>
|
|
||||||
input.yomitan.deleteYomitanDictionary(dictionaryTitle),
|
|
||||||
upsertYomitanDictionarySettings: (dictionaryTitle, profileScope) =>
|
|
||||||
input.yomitan.upsertYomitanDictionarySettings(dictionaryTitle, profileScope),
|
|
||||||
getCharacterDictionaryConfig: () => {
|
|
||||||
const config = input.config.getResolvedConfig().anilist.characterDictionary;
|
|
||||||
return {
|
|
||||||
enabled:
|
|
||||||
config.enabled &&
|
|
||||||
input.yomitan.isCharacterDictionaryEnabled() &&
|
|
||||||
!input.playback.isYoutubePlaybackActiveNow(),
|
|
||||||
maxLoaded: config.maxLoaded,
|
|
||||||
profileScope: config.profileScope,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
notifyCharacterDictionaryAutoSyncStatus: (event) => {
|
|
||||||
notifyCharacterDictionaryAutoSyncStatus(event, {
|
|
||||||
getNotificationType: () => input.startup.getNotificationType(),
|
|
||||||
showOsd: (message) => input.startup.showMpvOsd(message),
|
|
||||||
showDesktopNotification: (title, options) =>
|
|
||||||
input.startup.showDesktopNotification(title, options),
|
|
||||||
startupOsdSequencer: input.startup.startupOsdSequencer,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
characterDictionaryAutoSyncCompleteDeps: {
|
|
||||||
hasParserWindow: () => input.yomitan.hasParserWindow(),
|
|
||||||
clearParserCaches: () => input.yomitan.clearParserCaches(),
|
|
||||||
invalidateTokenizationCache: () => input.subtitle.invalidateTokenizationCache(),
|
|
||||||
refreshSubtitlePrefetch: () => input.subtitle.refreshSubtitlePrefetchFromActiveTrack(),
|
|
||||||
refreshCurrentSubtitle: () =>
|
|
||||||
input.subtitle.refreshCurrentSubtitle(input.subtitle.getCurrentSubtitleText()),
|
|
||||||
logInfo: (message) => input.logger.info(message),
|
|
||||||
},
|
|
||||||
getMainWindow: () => input.overlay.getMainWindow(),
|
|
||||||
getVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(),
|
|
||||||
setVisibleOverlayVisible: (visible) => input.overlay.setVisibleOverlayVisible(visible),
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () =>
|
|
||||||
input.overlay.getRestoreVisibleOverlayOnModalClose(),
|
|
||||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
|
||||||
input.overlay.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createDictionarySupportRuntime } from './dictionary-support-runtime';
|
|
||||||
|
|
||||||
function createRuntime() {
|
|
||||||
const state = {
|
|
||||||
currentMediaPath: null as string | null,
|
|
||||||
currentMediaTitle: null as string | null,
|
|
||||||
jlptLookupSet: 0,
|
|
||||||
frequencyLookupSet: 0,
|
|
||||||
trackerCalls: [] as Array<{ path: string; title: string | null }>,
|
|
||||||
characterDictionaryConfig: {
|
|
||||||
enabled: false,
|
|
||||||
maxLoaded: 1,
|
|
||||||
profileScope: 'global',
|
|
||||||
},
|
|
||||||
youtubePlaybackActive: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const runtime = createDictionarySupportRuntime({
|
|
||||||
platform: 'darwin',
|
|
||||||
dirname: '/repo/dist/main',
|
|
||||||
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
|
|
||||||
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
|
|
||||||
userDataPath: '/Users/a/Library/Application Support/SubMiner',
|
|
||||||
appUserDataPath: '/Users/a/Library/Application Support/SubMiner',
|
|
||||||
homeDir: '/Users/a',
|
|
||||||
cwd: '/repo',
|
|
||||||
subtitlePositionsDir: '/Users/a/Library/Application Support/SubMiner/subtitle-positions',
|
|
||||||
getResolvedConfig: () =>
|
|
||||||
({
|
|
||||||
subtitleStyle: {
|
|
||||||
enableJlpt: false,
|
|
||||||
frequencyDictionary: {
|
|
||||||
enabled: false,
|
|
||||||
sourcePath: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
anilist: {
|
|
||||||
characterDictionary: {
|
|
||||||
enabled: false,
|
|
||||||
maxLoaded: 1,
|
|
||||||
profileScope: 'global',
|
|
||||||
collapsibleSections: {
|
|
||||||
description: false,
|
|
||||||
glossary: false,
|
|
||||||
termEntry: false,
|
|
||||||
nameReading: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ankiConnect: {
|
|
||||||
behavior: {
|
|
||||||
notificationType: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}) as never,
|
|
||||||
isJlptEnabled: () => false,
|
|
||||||
isFrequencyDictionaryEnabled: () => false,
|
|
||||||
getFrequencyDictionarySourcePath: () => undefined,
|
|
||||||
setJlptLevelLookup: () => {
|
|
||||||
state.jlptLookupSet += 1;
|
|
||||||
},
|
|
||||||
setFrequencyRankLookup: () => {
|
|
||||||
state.frequencyLookupSet += 1;
|
|
||||||
},
|
|
||||||
logInfo: () => {},
|
|
||||||
logDebug: () => {},
|
|
||||||
logWarn: () => {},
|
|
||||||
isRemoteMediaPath: (mediaPath) => mediaPath.startsWith('remote:'),
|
|
||||||
getCurrentMediaPath: () => state.currentMediaPath,
|
|
||||||
setCurrentMediaPath: (mediaPath) => {
|
|
||||||
state.currentMediaPath = mediaPath;
|
|
||||||
},
|
|
||||||
getCurrentMediaTitle: () => state.currentMediaTitle,
|
|
||||||
setCurrentMediaTitle: (title) => {
|
|
||||||
state.currentMediaTitle = title;
|
|
||||||
},
|
|
||||||
getPendingSubtitlePosition: () => null,
|
|
||||||
loadSubtitlePosition: () => null,
|
|
||||||
clearPendingSubtitlePosition: () => {},
|
|
||||||
setSubtitlePosition: () => {},
|
|
||||||
broadcastSubtitlePosition: () => {},
|
|
||||||
broadcastToOverlayWindows: () => {},
|
|
||||||
getTracker: () =>
|
|
||||||
({
|
|
||||||
handleMediaChange: (path: string, title: string | null) => {
|
|
||||||
state.trackerCalls.push({ path, title });
|
|
||||||
},
|
|
||||||
}) as never,
|
|
||||||
getMpvClient: () => null,
|
|
||||||
defaultImmersionDbPath: '/tmp/immersion.db',
|
|
||||||
guessAnilistMediaInfo: async () => null,
|
|
||||||
getCollapsibleSectionOpenState: () => false,
|
|
||||||
isCharacterDictionaryEnabled: () => state.characterDictionaryConfig.enabled,
|
|
||||||
isYoutubePlaybackActiveNow: () => state.youtubePlaybackActive,
|
|
||||||
waitForYomitanMutationReady: async () => {},
|
|
||||||
getYomitanDictionaryInfo: async () => [],
|
|
||||||
importYomitanDictionary: async () => false,
|
|
||||||
deleteYomitanDictionary: async () => false,
|
|
||||||
upsertYomitanDictionarySettings: async () => false,
|
|
||||||
getCharacterDictionaryConfig: () => state.characterDictionaryConfig as never,
|
|
||||||
notifyCharacterDictionaryAutoSyncStatus: () => {},
|
|
||||||
characterDictionaryAutoSyncCompleteDeps: {
|
|
||||||
hasParserWindow: () => false,
|
|
||||||
clearParserCaches: () => {},
|
|
||||||
invalidateTokenizationCache: () => {},
|
|
||||||
refreshSubtitlePrefetch: () => {},
|
|
||||||
refreshCurrentSubtitle: () => {},
|
|
||||||
logInfo: () => {},
|
|
||||||
},
|
|
||||||
getMainWindow: () => null,
|
|
||||||
getVisibleOverlayVisible: () => false,
|
|
||||||
setVisibleOverlayVisible: () => {},
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () => new Set<string>(),
|
|
||||||
sendToActiveOverlayWindow: () => true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { runtime, state };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('dictionary support runtime wires field grouping resolver and callback', async () => {
|
|
||||||
const { runtime } = createRuntime();
|
|
||||||
const choice = {
|
|
||||||
keepNoteId: 1,
|
|
||||||
deleteNoteId: 2,
|
|
||||||
deleteDuplicate: false,
|
|
||||||
cancelled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const callback = runtime.createFieldGroupingCallback();
|
|
||||||
const pending = callback({} as never);
|
|
||||||
const resolver = runtime.getFieldGroupingResolver();
|
|
||||||
assert.ok(resolver);
|
|
||||||
resolver(choice as never);
|
|
||||||
assert.deepEqual(await pending, choice);
|
|
||||||
assert.equal(typeof runtime.getFieldGroupingResolver(), 'function');
|
|
||||||
|
|
||||||
runtime.setFieldGroupingResolver(null);
|
|
||||||
assert.equal(runtime.getFieldGroupingResolver(), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dictionary support runtime resolves media paths and keeps title in sync', () => {
|
|
||||||
const { runtime, state } = createRuntime();
|
|
||||||
|
|
||||||
runtime.updateCurrentMediaTitle(' Example Title ');
|
|
||||||
runtime.updateCurrentMediaPath('remote://media' as never);
|
|
||||||
assert.equal(state.currentMediaTitle, 'Example Title');
|
|
||||||
assert.equal(runtime.resolveMediaPathForJimaku('remote://media'), 'Example Title');
|
|
||||||
|
|
||||||
runtime.updateCurrentMediaPath('local.mp4' as never);
|
|
||||||
assert.equal(state.currentMediaTitle, null);
|
|
||||||
assert.equal(runtime.resolveMediaPathForJimaku('remote://media'), 'remote://media');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dictionary support runtime skips disabled lookup and sync paths', async () => {
|
|
||||||
const { runtime, state } = createRuntime();
|
|
||||||
|
|
||||||
await runtime.ensureJlptDictionaryLookup();
|
|
||||||
await runtime.ensureFrequencyDictionaryLookup();
|
|
||||||
runtime.scheduleCharacterDictionarySync();
|
|
||||||
|
|
||||||
assert.equal(state.jlptLookupSet, 0);
|
|
||||||
assert.equal(state.frequencyLookupSet, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dictionary support runtime syncs immersion media from current state', async () => {
|
|
||||||
const { runtime, state } = createRuntime();
|
|
||||||
|
|
||||||
runtime.updateCurrentMediaTitle(' Example Title ');
|
|
||||||
runtime.updateCurrentMediaPath('remote://media' as never);
|
|
||||||
await runtime.seedImmersionMediaFromCurrentMedia();
|
|
||||||
runtime.syncImmersionMediaState();
|
|
||||||
|
|
||||||
assert.deepEqual(state.trackerCalls, [
|
|
||||||
{ path: 'remote://media', title: 'Example Title' },
|
|
||||||
{ path: 'remote://media', title: 'Example Title' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dictionary support runtime gates character dictionary auto-sync scheduling', () => {
|
|
||||||
const { runtime, state } = createRuntime();
|
|
||||||
const originalSetTimeout = globalThis.setTimeout;
|
|
||||||
const originalClearTimeout = globalThis.clearTimeout;
|
|
||||||
let timeoutCalls = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: never[]) => {
|
|
||||||
timeoutCalls += 1;
|
|
||||||
return originalSetTimeout(handler, timeout ?? 0, ...args);
|
|
||||||
}) as typeof globalThis.setTimeout;
|
|
||||||
globalThis.clearTimeout = ((handle: number | NodeJS.Timeout | undefined) => {
|
|
||||||
originalClearTimeout(handle);
|
|
||||||
}) as typeof globalThis.clearTimeout;
|
|
||||||
|
|
||||||
runtime.scheduleCharacterDictionarySync();
|
|
||||||
assert.equal(timeoutCalls, 0);
|
|
||||||
|
|
||||||
state.characterDictionaryConfig = {
|
|
||||||
enabled: true,
|
|
||||||
maxLoaded: 1,
|
|
||||||
profileScope: 'global',
|
|
||||||
};
|
|
||||||
runtime.scheduleCharacterDictionarySync();
|
|
||||||
assert.equal(timeoutCalls, 1);
|
|
||||||
|
|
||||||
state.youtubePlaybackActive = true;
|
|
||||||
runtime.scheduleCharacterDictionarySync();
|
|
||||||
assert.equal(timeoutCalls, 1);
|
|
||||||
} finally {
|
|
||||||
globalThis.setTimeout = originalSetTimeout;
|
|
||||||
globalThis.clearTimeout = originalClearTimeout;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AnilistCharacterDictionaryProfileScope,
|
|
||||||
FrequencyDictionaryLookup,
|
|
||||||
KikuFieldGroupingChoice,
|
|
||||||
KikuFieldGroupingRequestData,
|
|
||||||
SubtitlePosition,
|
|
||||||
ResolvedConfig,
|
|
||||||
} from '../types';
|
|
||||||
import {
|
|
||||||
createBuildDictionaryRootsMainHandler,
|
|
||||||
createBuildFrequencyDictionaryRuntimeMainDepsHandler,
|
|
||||||
createBuildJlptDictionaryRuntimeMainDepsHandler,
|
|
||||||
} from './runtime/dictionary-runtime-main-deps';
|
|
||||||
import { createImmersionMediaRuntime } from './runtime/immersion-media';
|
|
||||||
import {
|
|
||||||
createFrequencyDictionaryRuntimeService,
|
|
||||||
getFrequencyDictionarySearchPaths,
|
|
||||||
} from './frequency-dictionary-runtime';
|
|
||||||
import {
|
|
||||||
createJlptDictionaryRuntimeService,
|
|
||||||
getJlptDictionarySearchPaths,
|
|
||||||
type JlptLookup,
|
|
||||||
} from './jlpt-runtime';
|
|
||||||
import { createMediaRuntimeService } from './media-runtime';
|
|
||||||
import {
|
|
||||||
createCharacterDictionaryRuntimeService,
|
|
||||||
type CharacterDictionaryBuildResult,
|
|
||||||
} from './character-dictionary-runtime';
|
|
||||||
import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater';
|
|
||||||
import {
|
|
||||||
createCharacterDictionaryAutoSyncRuntimeService,
|
|
||||||
type CharacterDictionaryAutoSyncConfig,
|
|
||||||
type CharacterDictionaryAutoSyncStatusEvent,
|
|
||||||
} from './runtime/character-dictionary-auto-sync';
|
|
||||||
import { handleCharacterDictionaryAutoSyncComplete } from './runtime/character-dictionary-auto-sync-completion';
|
|
||||||
import { notifyCharacterDictionaryAutoSyncStatus } from './runtime/character-dictionary-auto-sync-notifications';
|
|
||||||
import { createFieldGroupingOverlayRuntime } from '../core/services/field-grouping-overlay';
|
|
||||||
|
|
||||||
type FieldGroupingResolver = ((choice: KikuFieldGroupingChoice) => void) | null;
|
|
||||||
|
|
||||||
type BrowserWindowLike = {
|
|
||||||
isDestroyed: () => boolean;
|
|
||||||
webContents: {
|
|
||||||
send: (channel: string, payload?: unknown) => void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type ImmersionTrackerLike = {
|
|
||||||
handleMediaChange: (path: string, title: string | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MpvClientLike = {
|
|
||||||
currentVideoPath?: string | null;
|
|
||||||
connected?: boolean;
|
|
||||||
requestProperty?: (name: string) => Promise<unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CharacterDictionaryAutoSyncCompleteDeps = {
|
|
||||||
hasParserWindow: () => boolean;
|
|
||||||
clearParserCaches: () => void;
|
|
||||||
invalidateTokenizationCache: () => void;
|
|
||||||
refreshSubtitlePrefetch: () => void;
|
|
||||||
refreshCurrentSubtitle: () => void;
|
|
||||||
logInfo: (message: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface DictionarySupportRuntimeInput<TModal extends string = string> {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
dirname: string;
|
|
||||||
appPath: string;
|
|
||||||
resourcesPath: string;
|
|
||||||
userDataPath: string;
|
|
||||||
appUserDataPath: string;
|
|
||||||
homeDir: string;
|
|
||||||
appDataDir?: string;
|
|
||||||
cwd: string;
|
|
||||||
subtitlePositionsDir: string;
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
isJlptEnabled: () => boolean;
|
|
||||||
isFrequencyDictionaryEnabled: () => boolean;
|
|
||||||
getFrequencyDictionarySourcePath: () => string | undefined;
|
|
||||||
setJlptLevelLookup: (lookup: JlptLookup) => void;
|
|
||||||
setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void;
|
|
||||||
logInfo: (message: string) => void;
|
|
||||||
logDebug?: (message: string) => void;
|
|
||||||
logWarn: (message: string) => void;
|
|
||||||
isRemoteMediaPath: (mediaPath: string) => boolean;
|
|
||||||
getCurrentMediaPath: () => string | null;
|
|
||||||
setCurrentMediaPath: (mediaPath: string | null) => void;
|
|
||||||
getCurrentMediaTitle: () => string | null;
|
|
||||||
setCurrentMediaTitle: (title: string | null) => void;
|
|
||||||
getPendingSubtitlePosition: () => SubtitlePosition | null;
|
|
||||||
loadSubtitlePosition: () => SubtitlePosition | null;
|
|
||||||
clearPendingSubtitlePosition: () => void;
|
|
||||||
setSubtitlePosition: (position: SubtitlePosition | null) => void;
|
|
||||||
broadcastSubtitlePosition: (position: SubtitlePosition | null) => void;
|
|
||||||
broadcastToOverlayWindows: (channel: string, payload?: unknown) => void;
|
|
||||||
getTracker: () => ImmersionTrackerLike | null;
|
|
||||||
getMpvClient: () => MpvClientLike | null;
|
|
||||||
defaultImmersionDbPath: string;
|
|
||||||
guessAnilistMediaInfo: (
|
|
||||||
mediaPath: string | null,
|
|
||||||
mediaTitle: string | null,
|
|
||||||
) => Promise<AnilistMediaGuess | null>;
|
|
||||||
getCollapsibleSectionOpenState: (
|
|
||||||
section: keyof ResolvedConfig['anilist']['characterDictionary']['collapsibleSections'],
|
|
||||||
) => boolean;
|
|
||||||
isCharacterDictionaryEnabled: () => boolean;
|
|
||||||
isYoutubePlaybackActiveNow: () => boolean;
|
|
||||||
waitForYomitanMutationReady: () => Promise<void>;
|
|
||||||
getYomitanDictionaryInfo: () => Promise<Array<{ title: string; revision?: string | number }>>;
|
|
||||||
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
|
||||||
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
|
|
||||||
upsertYomitanDictionarySettings: (
|
|
||||||
dictionaryTitle: string,
|
|
||||||
profileScope: AnilistCharacterDictionaryProfileScope,
|
|
||||||
) => Promise<boolean>;
|
|
||||||
getCharacterDictionaryConfig: () => CharacterDictionaryAutoSyncConfig;
|
|
||||||
notifyCharacterDictionaryAutoSyncStatus: (event: CharacterDictionaryAutoSyncStatusEvent) => void;
|
|
||||||
characterDictionaryAutoSyncCompleteDeps: CharacterDictionaryAutoSyncCompleteDeps;
|
|
||||||
getMainWindow: () => BrowserWindowLike | null;
|
|
||||||
getVisibleOverlayVisible: () => boolean;
|
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () => Set<TModal>;
|
|
||||||
sendToActiveOverlayWindow: (
|
|
||||||
channel: string,
|
|
||||||
payload?: unknown,
|
|
||||||
runtimeOptions?: { restoreOnModalClose?: TModal },
|
|
||||||
) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DictionarySupportRuntime {
|
|
||||||
ensureJlptDictionaryLookup: () => Promise<void>;
|
|
||||||
ensureFrequencyDictionaryLookup: () => Promise<void>;
|
|
||||||
getFieldGroupingResolver: () => FieldGroupingResolver;
|
|
||||||
setFieldGroupingResolver: (resolver: FieldGroupingResolver) => void;
|
|
||||||
createFieldGroupingCallback: () => (
|
|
||||||
data: KikuFieldGroupingRequestData,
|
|
||||||
) => Promise<KikuFieldGroupingChoice>;
|
|
||||||
getConfiguredDbPath: () => string;
|
|
||||||
seedImmersionMediaFromCurrentMedia: () => Promise<void>;
|
|
||||||
syncImmersionMediaState: () => void;
|
|
||||||
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
|
||||||
updateCurrentMediaPath: (mediaPath: unknown) => void;
|
|
||||||
updateCurrentMediaTitle: (mediaTitle: unknown) => void;
|
|
||||||
scheduleCharacterDictionarySync: () => void;
|
|
||||||
generateCharacterDictionaryForCurrentMedia: (
|
|
||||||
targetPath?: string,
|
|
||||||
) => Promise<CharacterDictionaryBuildResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDictionarySupportRuntime<TModal extends string>(
|
|
||||||
input: DictionarySupportRuntimeInput<TModal>,
|
|
||||||
): DictionarySupportRuntime {
|
|
||||||
const dictionaryRoots = createBuildDictionaryRootsMainHandler({
|
|
||||||
platform: input.platform,
|
|
||||||
dirname: input.dirname,
|
|
||||||
appPath: input.appPath,
|
|
||||||
resourcesPath: input.resourcesPath,
|
|
||||||
userDataPath: input.userDataPath,
|
|
||||||
appUserDataPath: input.appUserDataPath,
|
|
||||||
homeDir: input.homeDir,
|
|
||||||
appDataDir: input.appDataDir,
|
|
||||||
cwd: input.cwd,
|
|
||||||
joinPath: (...parts: string[]) => path.join(...parts),
|
|
||||||
});
|
|
||||||
|
|
||||||
const jlptRuntime = createJlptDictionaryRuntimeService(
|
|
||||||
createBuildJlptDictionaryRuntimeMainDepsHandler({
|
|
||||||
isJlptEnabled: () => input.isJlptEnabled(),
|
|
||||||
getDictionaryRoots: () => dictionaryRoots(),
|
|
||||||
getJlptDictionarySearchPaths,
|
|
||||||
setJlptLevelLookup: (lookup) => input.setJlptLevelLookup(lookup),
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const frequencyRuntime = createFrequencyDictionaryRuntimeService(
|
|
||||||
createBuildFrequencyDictionaryRuntimeMainDepsHandler({
|
|
||||||
isFrequencyDictionaryEnabled: () => input.isFrequencyDictionaryEnabled(),
|
|
||||||
getDictionaryRoots: () => dictionaryRoots(),
|
|
||||||
getFrequencyDictionarySearchPaths,
|
|
||||||
getSourcePath: () => input.getFrequencyDictionarySourcePath(),
|
|
||||||
setFrequencyRankLookup: (lookup) => input.setFrequencyRankLookup(lookup),
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let fieldGroupingResolver: FieldGroupingResolver = null;
|
|
||||||
let fieldGroupingResolverSequence = 0;
|
|
||||||
|
|
||||||
const getFieldGroupingResolver = (): FieldGroupingResolver => fieldGroupingResolver;
|
|
||||||
const setFieldGroupingResolver = (resolver: FieldGroupingResolver): void => {
|
|
||||||
if (!resolver) {
|
|
||||||
fieldGroupingResolver = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sequence = ++fieldGroupingResolverSequence;
|
|
||||||
fieldGroupingResolver = (choice) => {
|
|
||||||
if (sequence !== fieldGroupingResolverSequence) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolver(choice);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<TModal>({
|
|
||||||
getMainWindow: () => input.getMainWindow(),
|
|
||||||
getVisibleOverlayVisible: () => input.getVisibleOverlayVisible(),
|
|
||||||
setVisibleOverlayVisible: (visible) => input.setVisibleOverlayVisible(visible),
|
|
||||||
getResolver: () => getFieldGroupingResolver(),
|
|
||||||
setResolver: (resolver) => setFieldGroupingResolver(resolver),
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () => input.getRestoreVisibleOverlayOnModalClose(),
|
|
||||||
sendToVisibleOverlay: (channel, payload, runtimeOptions) =>
|
|
||||||
input.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
|
||||||
});
|
|
||||||
|
|
||||||
const immersionMediaRuntime = createImmersionMediaRuntime({
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
defaultImmersionDbPath: input.defaultImmersionDbPath,
|
|
||||||
getTracker: () => input.getTracker(),
|
|
||||||
getMpvClient: () => input.getMpvClient(),
|
|
||||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
|
||||||
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
|
|
||||||
logDebug: (message) => (input.logDebug ?? input.logInfo)(message),
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mediaRuntime = createMediaRuntimeService({
|
|
||||||
isRemoteMediaPath: (mediaPath) => input.isRemoteMediaPath(mediaPath),
|
|
||||||
loadSubtitlePosition: () => input.loadSubtitlePosition(),
|
|
||||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
|
||||||
getPendingSubtitlePosition: () => input.getPendingSubtitlePosition(),
|
|
||||||
getSubtitlePositionsDir: () => input.subtitlePositionsDir,
|
|
||||||
setCurrentMediaPath: (mediaPath) => input.setCurrentMediaPath(mediaPath),
|
|
||||||
clearPendingSubtitlePosition: () => input.clearPendingSubtitlePosition(),
|
|
||||||
setSubtitlePosition: (position) => input.setSubtitlePosition(position),
|
|
||||||
broadcastSubtitlePosition: (position) => input.broadcastSubtitlePosition(position),
|
|
||||||
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
|
|
||||||
setCurrentMediaTitle: (title) => input.setCurrentMediaTitle(title),
|
|
||||||
});
|
|
||||||
|
|
||||||
const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
|
|
||||||
userDataPath: input.userDataPath,
|
|
||||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
|
||||||
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
|
|
||||||
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
|
||||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
|
|
||||||
input.guessAnilistMediaInfo(mediaPath, mediaTitle),
|
|
||||||
getCollapsibleSectionOpenState: (section) => input.getCollapsibleSectionOpenState(section),
|
|
||||||
now: () => Date.now(),
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
logWarn: (message) => input.logWarn(message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRuntimeService({
|
|
||||||
userDataPath: input.userDataPath,
|
|
||||||
getConfig: () => input.getCharacterDictionaryConfig(),
|
|
||||||
getOrCreateCurrentSnapshot: (targetPath, progress) =>
|
|
||||||
characterDictionaryRuntime.getOrCreateCurrentSnapshot(targetPath, progress),
|
|
||||||
buildMergedDictionary: (mediaIds) => characterDictionaryRuntime.buildMergedDictionary(mediaIds),
|
|
||||||
waitForYomitanMutationReady: () => input.waitForYomitanMutationReady(),
|
|
||||||
getYomitanDictionaryInfo: () => input.getYomitanDictionaryInfo(),
|
|
||||||
importYomitanDictionary: (zipPath) => input.importYomitanDictionary(zipPath),
|
|
||||||
deleteYomitanDictionary: (dictionaryTitle) => input.deleteYomitanDictionary(dictionaryTitle),
|
|
||||||
upsertYomitanDictionarySettings: (dictionaryTitle, profileScope) =>
|
|
||||||
input.upsertYomitanDictionarySettings(dictionaryTitle, profileScope),
|
|
||||||
now: () => Date.now(),
|
|
||||||
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
|
|
||||||
clearSchedule: (timer) => clearTimeout(timer),
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
logWarn: (message) => input.logWarn(message),
|
|
||||||
onSyncStatus: (event) => input.notifyCharacterDictionaryAutoSyncStatus(event),
|
|
||||||
onSyncComplete: (result) =>
|
|
||||||
handleCharacterDictionaryAutoSyncComplete(
|
|
||||||
result,
|
|
||||||
input.characterDictionaryAutoSyncCompleteDeps,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const scheduleCharacterDictionarySync = (): void => {
|
|
||||||
if (!input.isCharacterDictionaryEnabled() || input.isYoutubePlaybackActiveNow()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
characterDictionaryAutoSyncRuntime.scheduleSync();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
ensureJlptDictionaryLookup: () => jlptRuntime.ensureJlptDictionaryLookup(),
|
|
||||||
ensureFrequencyDictionaryLookup: () => frequencyRuntime.ensureFrequencyDictionaryLookup(),
|
|
||||||
getFieldGroupingResolver,
|
|
||||||
setFieldGroupingResolver,
|
|
||||||
createFieldGroupingCallback: () => fieldGroupingOverlayRuntime.createFieldGroupingCallback(),
|
|
||||||
getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(),
|
|
||||||
seedImmersionMediaFromCurrentMedia: () => immersionMediaRuntime.seedFromCurrentMedia(),
|
|
||||||
syncImmersionMediaState: () => immersionMediaRuntime.syncFromCurrentMediaState(),
|
|
||||||
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
|
||||||
updateCurrentMediaPath: (mediaPath) => mediaRuntime.updateCurrentMediaPath(mediaPath),
|
|
||||||
updateCurrentMediaTitle: (mediaTitle) => mediaRuntime.updateCurrentMediaTitle(mediaTitle),
|
|
||||||
scheduleCharacterDictionarySync,
|
|
||||||
generateCharacterDictionaryForCurrentMedia: (targetPath?: string) =>
|
|
||||||
characterDictionaryRuntime.generateForCurrentMedia(targetPath),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createDiscordPresenceLifecycleRuntime } from './discord-presence-lifecycle-runtime';
|
|
||||||
|
|
||||||
test('discord presence lifecycle runtime starts service and publishes presence when enabled', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let service: { start: () => Promise<void>; stop: () => Promise<void> } | null = null;
|
|
||||||
|
|
||||||
const runtime = createDiscordPresenceLifecycleRuntime({
|
|
||||||
getResolvedConfig: () => ({ discordPresence: { enabled: true } }),
|
|
||||||
getDiscordPresenceService: () => service as never,
|
|
||||||
setDiscordPresenceService: (next) => {
|
|
||||||
service = next as typeof service;
|
|
||||||
},
|
|
||||||
getMpvClient: () => null,
|
|
||||||
getCurrentMediaTitle: () => 'Demo',
|
|
||||||
getCurrentMediaPath: () => '/tmp/demo.mkv',
|
|
||||||
getCurrentSubtitleText: () => 'subtitle',
|
|
||||||
getPlaybackPaused: () => false,
|
|
||||||
getFallbackMediaDurationSec: () => 12,
|
|
||||||
createDiscordPresenceService: () => ({
|
|
||||||
start: async () => {
|
|
||||||
calls.push('start');
|
|
||||||
},
|
|
||||||
stop: async () => {
|
|
||||||
calls.push('stop');
|
|
||||||
},
|
|
||||||
publish: () => {
|
|
||||||
calls.push('publish');
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
createDiscordRuntime: (input) => ({
|
|
||||||
refreshDiscordPresenceMediaDuration: async () => {},
|
|
||||||
publishDiscordPresence: () => {
|
|
||||||
calls.push(input.getCurrentMediaTitle() ?? 'unknown');
|
|
||||||
input.getDiscordPresenceService()?.publish({
|
|
||||||
mediaTitle: input.getCurrentMediaTitle(),
|
|
||||||
mediaPath: input.getCurrentMediaPath(),
|
|
||||||
subtitleText: input.getCurrentSubtitleText(),
|
|
||||||
currentTimeSec: null,
|
|
||||||
mediaDurationSec: input.getFallbackMediaDurationSec(),
|
|
||||||
paused: input.getPlaybackPaused(),
|
|
||||||
connected: false,
|
|
||||||
sessionStartedAtMs: input.getSessionStartedAtMs(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
now: () => 123,
|
|
||||||
});
|
|
||||||
|
|
||||||
await runtime.initializeDiscordPresenceService();
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['start', 'Demo', 'publish']);
|
|
||||||
});
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { createDiscordPresenceRuntime } from './runtime/discord-presence-runtime';
|
|
||||||
|
|
||||||
type DiscordPresenceConfigLike = {
|
|
||||||
enabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DiscordPresenceServiceLike = {
|
|
||||||
start: () => Promise<void>;
|
|
||||||
stop?: () => Promise<void>;
|
|
||||||
publish: (snapshot: {
|
|
||||||
mediaTitle: string | null;
|
|
||||||
mediaPath: string | null;
|
|
||||||
subtitleText: string;
|
|
||||||
currentTimeSec: number | null;
|
|
||||||
mediaDurationSec: number | null;
|
|
||||||
paused: boolean | null;
|
|
||||||
connected: boolean;
|
|
||||||
sessionStartedAtMs: number;
|
|
||||||
}) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface DiscordPresenceLifecycleRuntimeInput {
|
|
||||||
getResolvedConfig: () => { discordPresence: DiscordPresenceConfigLike };
|
|
||||||
getDiscordPresenceService: () => DiscordPresenceServiceLike | null;
|
|
||||||
setDiscordPresenceService: (service: DiscordPresenceServiceLike | null) => void;
|
|
||||||
getMpvClient: () => {
|
|
||||||
connected?: boolean;
|
|
||||||
currentTimePos?: number | null;
|
|
||||||
requestProperty: (name: string) => Promise<unknown>;
|
|
||||||
} | null;
|
|
||||||
getCurrentMediaTitle: () => string | null;
|
|
||||||
getCurrentMediaPath: () => string | null;
|
|
||||||
getCurrentSubtitleText: () => string;
|
|
||||||
getPlaybackPaused: () => boolean | null;
|
|
||||||
getFallbackMediaDurationSec: () => number | null;
|
|
||||||
createDiscordPresenceService: (config: unknown) => DiscordPresenceServiceLike;
|
|
||||||
createDiscordRuntime?: typeof createDiscordPresenceRuntime;
|
|
||||||
now?: () => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiscordPresenceLifecycleRuntime {
|
|
||||||
publishDiscordPresence: () => void;
|
|
||||||
initializeDiscordPresenceService: () => Promise<void>;
|
|
||||||
stopDiscordPresenceService: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDiscordPresenceLifecycleRuntime(
|
|
||||||
input: DiscordPresenceLifecycleRuntimeInput,
|
|
||||||
): DiscordPresenceLifecycleRuntime {
|
|
||||||
let discordPresenceMediaDurationSec: number | null = null;
|
|
||||||
const discordPresenceSessionStartedAtMs = input.now ? input.now() : Date.now();
|
|
||||||
|
|
||||||
const discordPresenceRuntime = (input.createDiscordRuntime ?? createDiscordPresenceRuntime)({
|
|
||||||
getDiscordPresenceService: () => input.getDiscordPresenceService(),
|
|
||||||
isDiscordPresenceEnabled: () => input.getResolvedConfig().discordPresence.enabled === true,
|
|
||||||
getMpvClient: () => input.getMpvClient(),
|
|
||||||
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
|
|
||||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
|
||||||
getCurrentSubtitleText: () => input.getCurrentSubtitleText(),
|
|
||||||
getPlaybackPaused: () => input.getPlaybackPaused(),
|
|
||||||
getFallbackMediaDurationSec: () => input.getFallbackMediaDurationSec(),
|
|
||||||
getSessionStartedAtMs: () => discordPresenceSessionStartedAtMs,
|
|
||||||
getMediaDurationSec: () => discordPresenceMediaDurationSec,
|
|
||||||
setMediaDurationSec: (next) => {
|
|
||||||
discordPresenceMediaDurationSec = next;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
publishDiscordPresence: () => {
|
|
||||||
discordPresenceRuntime.publishDiscordPresence();
|
|
||||||
},
|
|
||||||
initializeDiscordPresenceService: async () => {
|
|
||||||
if (input.getResolvedConfig().discordPresence.enabled !== true) {
|
|
||||||
input.setDiscordPresenceService(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.setDiscordPresenceService(
|
|
||||||
input.createDiscordPresenceService(input.getResolvedConfig().discordPresence),
|
|
||||||
);
|
|
||||||
await input.getDiscordPresenceService()?.start();
|
|
||||||
discordPresenceRuntime.publishDiscordPresence();
|
|
||||||
},
|
|
||||||
stopDiscordPresenceService: async () => {
|
|
||||||
await input.getDiscordPresenceService()?.stop?.();
|
|
||||||
input.setDiscordPresenceService(null);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import { getYomitanDictionaryInfo } from '../core/services';
|
|
||||||
import type { ResolvedConfig } from '../types';
|
|
||||||
import { createFirstRunRuntime } from './first-run-runtime';
|
|
||||||
import type { AppState } from './state';
|
|
||||||
|
|
||||||
export interface FirstRunRuntimeCoordinatorInput {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
configDir: string;
|
|
||||||
homeDir: string;
|
|
||||||
xdgConfigHome?: string;
|
|
||||||
binaryPath: string;
|
|
||||||
appPath: string;
|
|
||||||
resourcesPath: string;
|
|
||||||
appDataDir: string;
|
|
||||||
desktopDir: string;
|
|
||||||
appState: Pick<AppState, 'firstRunSetupWindow' | 'firstRunSetupCompleted' | 'backgroundMode'>;
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
yomitan: {
|
|
||||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
|
||||||
getParserRuntimeDeps: () => Parameters<typeof getYomitanDictionaryInfo>[0];
|
|
||||||
openYomitanSettings: () => boolean;
|
|
||||||
};
|
|
||||||
overlay: {
|
|
||||||
ensureTray: () => void;
|
|
||||||
hasTray: () => boolean;
|
|
||||||
};
|
|
||||||
actions: {
|
|
||||||
writeShortcutLink: (
|
|
||||||
shortcutPath: string,
|
|
||||||
operation: 'create' | 'update' | 'replace',
|
|
||||||
details: {
|
|
||||||
target: string;
|
|
||||||
args?: string;
|
|
||||||
cwd?: string;
|
|
||||||
description?: string;
|
|
||||||
icon?: string;
|
|
||||||
iconIndex?: number;
|
|
||||||
},
|
|
||||||
) => boolean;
|
|
||||||
requestAppQuit: () => void;
|
|
||||||
};
|
|
||||||
logger: {
|
|
||||||
error: (message: string, error: unknown) => void;
|
|
||||||
info: (message: string, ...args: unknown[]) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createFirstRunRuntimeCoordinator(input: FirstRunRuntimeCoordinatorInput) {
|
|
||||||
return createFirstRunRuntime<BrowserWindow>({
|
|
||||||
platform: input.platform,
|
|
||||||
configDir: input.configDir,
|
|
||||||
homeDir: input.homeDir,
|
|
||||||
xdgConfigHome: input.xdgConfigHome,
|
|
||||||
binaryPath: input.binaryPath,
|
|
||||||
appPath: input.appPath,
|
|
||||||
resourcesPath: input.resourcesPath,
|
|
||||||
appDataDir: input.appDataDir,
|
|
||||||
desktopDir: input.desktopDir,
|
|
||||||
getYomitanDictionaryCount: async () => {
|
|
||||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
|
||||||
const dictionaries = await getYomitanDictionaryInfo(input.yomitan.getParserRuntimeDeps(), {
|
|
||||||
error: (message, ...args) => input.logger.error(message, args[0]),
|
|
||||||
info: (message, ...args) => input.logger.info(message, ...args),
|
|
||||||
});
|
|
||||||
return dictionaries.length;
|
|
||||||
},
|
|
||||||
isExternalYomitanConfigured: () =>
|
|
||||||
input.getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
|
|
||||||
createBrowserWindow: (options) => {
|
|
||||||
const window = new BrowserWindow(options);
|
|
||||||
input.appState.firstRunSetupWindow = window;
|
|
||||||
window.on('closed', () => {
|
|
||||||
input.appState.firstRunSetupWindow = null;
|
|
||||||
});
|
|
||||||
return window;
|
|
||||||
},
|
|
||||||
writeShortcutLink: (shortcutPath, operation, details) =>
|
|
||||||
input.actions.writeShortcutLink(shortcutPath, operation, details),
|
|
||||||
openYomitanSettings: () => input.yomitan.openYomitanSettings(),
|
|
||||||
shouldQuitWhenClosedIncomplete: () => !input.appState.backgroundMode,
|
|
||||||
quitApp: () => input.actions.requestAppQuit(),
|
|
||||||
logError: (message, error) => input.logger.error(message, error),
|
|
||||||
onStateChanged: (state) => {
|
|
||||||
input.appState.firstRunSetupCompleted = state.status === 'completed';
|
|
||||||
if (input.overlay.hasTray()) {
|
|
||||||
input.overlay.ensureTray();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createFirstRunRuntime } from './first-run-runtime';
|
|
||||||
|
|
||||||
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-runtime-test-'));
|
|
||||||
const result = fn(dir);
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
return result.finally(() => {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockSetupWindow() {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let closedHandler: (() => void) | null = null;
|
|
||||||
let navigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
calls,
|
|
||||||
window: {
|
|
||||||
webContents: {
|
|
||||||
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => {
|
|
||||||
if (event === 'will-navigate') {
|
|
||||||
navigateHandler = handler;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
loadURL: async (url: string) => {
|
|
||||||
calls.push(`load:${url.slice(0, 24)}`);
|
|
||||||
},
|
|
||||||
on: (event: 'closed', handler: () => void) => {
|
|
||||||
if (event === 'closed') {
|
|
||||||
closedHandler = handler;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isDestroyed: () => false,
|
|
||||||
close: () => {
|
|
||||||
calls.push('close');
|
|
||||||
closedHandler?.();
|
|
||||||
},
|
|
||||||
focus: () => {
|
|
||||||
calls.push('focus');
|
|
||||||
},
|
|
||||||
triggerNavigate: (url: string) => {
|
|
||||||
navigateHandler?.(
|
|
||||||
{
|
|
||||||
preventDefault: () => {
|
|
||||||
calls.push('prevent-default');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
url,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('first-run runtime focuses an existing window instead of creating a new one', async () => {
|
|
||||||
await withTempDir(async (root) => {
|
|
||||||
const configDir = path.join(root, 'SubMiner');
|
|
||||||
fs.mkdirSync(configDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
|
||||||
|
|
||||||
let createCount = 0;
|
|
||||||
const mock = createMockSetupWindow();
|
|
||||||
const runtime = createFirstRunRuntime({
|
|
||||||
platform: 'darwin',
|
|
||||||
configDir,
|
|
||||||
homeDir: os.homedir(),
|
|
||||||
binaryPath: process.execPath,
|
|
||||||
appPath: '/app',
|
|
||||||
resourcesPath: '/resources',
|
|
||||||
appDataDir: path.join(root, 'appData'),
|
|
||||||
desktopDir: path.join(root, 'desktop'),
|
|
||||||
getYomitanDictionaryCount: async () => 1,
|
|
||||||
isExternalYomitanConfigured: () => false,
|
|
||||||
createBrowserWindow: () => {
|
|
||||||
createCount += 1;
|
|
||||||
return mock.window;
|
|
||||||
},
|
|
||||||
writeShortcutLink: () => true,
|
|
||||||
openYomitanSettings: () => false,
|
|
||||||
shouldQuitWhenClosedIncomplete: () => true,
|
|
||||||
quitApp: () => {
|
|
||||||
throw new Error('quit should not be called');
|
|
||||||
},
|
|
||||||
logError: () => {
|
|
||||||
throw new Error('logError should not be called');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
runtime.openFirstRunSetupWindow();
|
|
||||||
runtime.openFirstRunSetupWindow();
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
assert.equal(createCount, 1);
|
|
||||||
assert.equal(mock.calls.filter((call) => call === 'focus').length, 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('first-run runtime closes the setup window after completion', async () => {
|
|
||||||
await withTempDir(async (root) => {
|
|
||||||
const configDir = path.join(root, 'SubMiner');
|
|
||||||
fs.mkdirSync(configDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
|
||||||
|
|
||||||
const events: string[] = [];
|
|
||||||
const mock = createMockSetupWindow();
|
|
||||||
const runtime = createFirstRunRuntime({
|
|
||||||
platform: 'linux',
|
|
||||||
configDir,
|
|
||||||
homeDir: os.homedir(),
|
|
||||||
binaryPath: process.execPath,
|
|
||||||
appPath: '/app',
|
|
||||||
resourcesPath: '/resources',
|
|
||||||
appDataDir: path.join(root, 'appData'),
|
|
||||||
desktopDir: path.join(root, 'desktop'),
|
|
||||||
getYomitanDictionaryCount: async () => 1,
|
|
||||||
isExternalYomitanConfigured: () => false,
|
|
||||||
createBrowserWindow: () => mock.window,
|
|
||||||
writeShortcutLink: () => true,
|
|
||||||
openYomitanSettings: () => false,
|
|
||||||
shouldQuitWhenClosedIncomplete: () => true,
|
|
||||||
quitApp: () => {
|
|
||||||
events.push('quit');
|
|
||||||
},
|
|
||||||
logError: (message, error) => {
|
|
||||||
events.push(`${message}:${String(error)}`);
|
|
||||||
},
|
|
||||||
onStateChanged: (state) => {
|
|
||||||
events.push(state.status);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
runtime.openFirstRunSetupWindow();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
mock.window.triggerNavigate('subminer://first-run-setup?action=finish');
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
assert.equal(runtime.isSetupCompleted(), true);
|
|
||||||
assert.equal(events[0], 'in_progress');
|
|
||||||
assert.equal(events.at(-1), 'completed');
|
|
||||||
assert.equal(mock.calls.includes('close'), true);
|
|
||||||
assert.equal(events.includes('quit'), false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
import {
|
|
||||||
createFirstRunSetupService,
|
|
||||||
shouldAutoOpenFirstRunSetup,
|
|
||||||
type FirstRunSetupService,
|
|
||||||
type PluginInstallResult,
|
|
||||||
type SetupStatusSnapshot,
|
|
||||||
} from './runtime/first-run-setup-service';
|
|
||||||
import type { SetupState } from '../shared/setup-state';
|
|
||||||
import {
|
|
||||||
buildFirstRunSetupHtml,
|
|
||||||
createMaybeFocusExistingFirstRunSetupWindowHandler,
|
|
||||||
createOpenFirstRunSetupWindowHandler,
|
|
||||||
parseFirstRunSetupSubmissionUrl,
|
|
||||||
} from './runtime/first-run-setup-window';
|
|
||||||
import { createCreateFirstRunSetupWindowHandler } from './runtime/setup-window-factory';
|
|
||||||
import {
|
|
||||||
detectInstalledFirstRunPlugin,
|
|
||||||
installFirstRunPluginToDefaultLocation,
|
|
||||||
syncInstalledFirstRunPluginBinaryPath,
|
|
||||||
} from './runtime/first-run-setup-plugin';
|
|
||||||
import {
|
|
||||||
applyWindowsMpvShortcuts,
|
|
||||||
detectWindowsMpvShortcuts,
|
|
||||||
resolveWindowsMpvShortcutPaths,
|
|
||||||
} from './runtime/windows-mpv-shortcuts';
|
|
||||||
import { resolveDefaultMpvInstallPaths } from '../shared/setup-state';
|
|
||||||
|
|
||||||
export interface FirstRunSetupWindowLike {
|
|
||||||
webContents: {
|
|
||||||
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
|
|
||||||
};
|
|
||||||
loadURL: (url: string) => Promise<void> | void;
|
|
||||||
on: (event: 'closed', handler: () => void) => void;
|
|
||||||
isDestroyed: () => boolean;
|
|
||||||
close: () => void;
|
|
||||||
focus: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FirstRunRuntimeInput<
|
|
||||||
TWindow extends FirstRunSetupWindowLike = FirstRunSetupWindowLike,
|
|
||||||
> {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
configDir: string;
|
|
||||||
homeDir: string;
|
|
||||||
xdgConfigHome?: string;
|
|
||||||
binaryPath: string;
|
|
||||||
appPath: string;
|
|
||||||
resourcesPath: string;
|
|
||||||
appDataDir: string;
|
|
||||||
desktopDir: string;
|
|
||||||
getYomitanDictionaryCount: () => Promise<number>;
|
|
||||||
isExternalYomitanConfigured: () => boolean;
|
|
||||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
|
|
||||||
writeShortcutLink: (
|
|
||||||
shortcutPath: string,
|
|
||||||
operation: 'create' | 'update' | 'replace',
|
|
||||||
details: {
|
|
||||||
target: string;
|
|
||||||
args?: string;
|
|
||||||
cwd?: string;
|
|
||||||
description?: string;
|
|
||||||
icon?: string;
|
|
||||||
iconIndex?: number;
|
|
||||||
},
|
|
||||||
) => boolean;
|
|
||||||
openYomitanSettings: () => boolean;
|
|
||||||
shouldQuitWhenClosedIncomplete: () => boolean;
|
|
||||||
quitApp: () => void;
|
|
||||||
logError: (message: string, error: unknown) => void;
|
|
||||||
onStateChanged?: (state: SetupState) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FirstRunRuntime {
|
|
||||||
ensureSetupStateInitialized: () => Promise<SetupStatusSnapshot>;
|
|
||||||
isSetupCompleted: () => boolean;
|
|
||||||
openFirstRunSetupWindow: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createFirstRunRuntime<TWindow extends FirstRunSetupWindowLike>(
|
|
||||||
input: FirstRunRuntimeInput<TWindow>,
|
|
||||||
): FirstRunRuntime {
|
|
||||||
syncInstalledFirstRunPluginBinaryPath({
|
|
||||||
platform: input.platform,
|
|
||||||
homeDir: input.homeDir,
|
|
||||||
xdgConfigHome: input.xdgConfigHome,
|
|
||||||
binaryPath: input.binaryPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstRunSetupService = createFirstRunSetupService({
|
|
||||||
platform: input.platform,
|
|
||||||
configDir: input.configDir,
|
|
||||||
getYomitanDictionaryCount: input.getYomitanDictionaryCount,
|
|
||||||
isExternalYomitanConfigured: input.isExternalYomitanConfigured,
|
|
||||||
detectPluginInstalled: () =>
|
|
||||||
detectInstalledFirstRunPlugin(
|
|
||||||
resolveDefaultMpvInstallPaths(input.platform, input.homeDir, input.xdgConfigHome),
|
|
||||||
),
|
|
||||||
installPlugin: async (): Promise<PluginInstallResult> =>
|
|
||||||
installFirstRunPluginToDefaultLocation({
|
|
||||||
platform: input.platform,
|
|
||||||
homeDir: input.homeDir,
|
|
||||||
xdgConfigHome: input.xdgConfigHome,
|
|
||||||
dirname: __dirname,
|
|
||||||
appPath: input.appPath,
|
|
||||||
resourcesPath: input.resourcesPath,
|
|
||||||
binaryPath: input.binaryPath,
|
|
||||||
}),
|
|
||||||
detectWindowsMpvShortcuts: async () =>
|
|
||||||
detectWindowsMpvShortcuts(
|
|
||||||
resolveWindowsMpvShortcutPaths({
|
|
||||||
appDataDir: input.appDataDir,
|
|
||||||
desktopDir: input.desktopDir,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
applyWindowsMpvShortcuts: async (preferences) =>
|
|
||||||
applyWindowsMpvShortcuts({
|
|
||||||
preferences,
|
|
||||||
paths: resolveWindowsMpvShortcutPaths({
|
|
||||||
appDataDir: input.appDataDir,
|
|
||||||
desktopDir: input.desktopDir,
|
|
||||||
}),
|
|
||||||
exePath: input.binaryPath,
|
|
||||||
writeShortcutLink: (shortcutPath, operation, details) =>
|
|
||||||
input.writeShortcutLink(shortcutPath, operation, details),
|
|
||||||
}),
|
|
||||||
onStateChanged: (state) => {
|
|
||||||
input.onStateChanged?.(state);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let firstRunSetupWindow: TWindow | null = null;
|
|
||||||
let firstRunSetupMessage: string | null = null;
|
|
||||||
|
|
||||||
const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({
|
|
||||||
getSetupWindow: () => firstRunSetupWindow,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createSetupWindow = createCreateFirstRunSetupWindowHandler({
|
|
||||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) =>
|
|
||||||
input.createBrowserWindow(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
|
||||||
maybeFocusExistingSetupWindow: () => maybeFocusExistingFirstRunSetupWindow(),
|
|
||||||
createSetupWindow: () => {
|
|
||||||
const window = createSetupWindow();
|
|
||||||
firstRunSetupWindow = window;
|
|
||||||
return window;
|
|
||||||
},
|
|
||||||
getSetupSnapshot: async () => {
|
|
||||||
const snapshot = await firstRunSetupService.getSetupStatus();
|
|
||||||
return {
|
|
||||||
...snapshot,
|
|
||||||
message: firstRunSetupMessage,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
|
|
||||||
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
|
|
||||||
handleAction: async (submission) => {
|
|
||||||
if (submission.action === 'install-plugin') {
|
|
||||||
const snapshot = await firstRunSetupService.installMpvPlugin();
|
|
||||||
firstRunSetupMessage = snapshot.message;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (submission.action === 'configure-windows-mpv-shortcuts') {
|
|
||||||
const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({
|
|
||||||
startMenuEnabled: submission.startMenuEnabled === true,
|
|
||||||
desktopEnabled: submission.desktopEnabled === true,
|
|
||||||
});
|
|
||||||
firstRunSetupMessage = snapshot.message;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (submission.action === 'open-yomitan-settings') {
|
|
||||||
firstRunSetupMessage = input.openYomitanSettings()
|
|
||||||
? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
|
|
||||||
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (submission.action === 'refresh') {
|
|
||||||
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
|
|
||||||
firstRunSetupMessage = snapshot.message;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (submission.action === 'skip-plugin') {
|
|
||||||
await firstRunSetupService.skipPluginInstall();
|
|
||||||
firstRunSetupMessage = 'mpv plugin installation skipped.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = await firstRunSetupService.markSetupCompleted();
|
|
||||||
if (snapshot.state.status === 'completed') {
|
|
||||||
firstRunSetupMessage = null;
|
|
||||||
return { closeWindow: true };
|
|
||||||
}
|
|
||||||
firstRunSetupMessage = 'Install at least one Yomitan dictionary before finishing setup.';
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
markSetupInProgress: async () => {
|
|
||||||
firstRunSetupMessage = null;
|
|
||||||
await firstRunSetupService.markSetupInProgress();
|
|
||||||
},
|
|
||||||
markSetupCancelled: async () => {
|
|
||||||
firstRunSetupMessage = null;
|
|
||||||
await firstRunSetupService.markSetupCancelled();
|
|
||||||
},
|
|
||||||
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
|
|
||||||
shouldQuitWhenClosedIncomplete: () => input.shouldQuitWhenClosedIncomplete(),
|
|
||||||
quitApp: () => input.quitApp(),
|
|
||||||
clearSetupWindow: () => {
|
|
||||||
firstRunSetupWindow = null;
|
|
||||||
},
|
|
||||||
setSetupWindow: (window) => {
|
|
||||||
firstRunSetupWindow = window;
|
|
||||||
},
|
|
||||||
encodeURIComponent: (value) => encodeURIComponent(value),
|
|
||||||
logError: (message, error) => input.logError(message, error),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ensureSetupStateInitialized: () => firstRunSetupService.ensureSetupStateInitialized(),
|
|
||||||
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
|
|
||||||
openFirstRunSetupWindow: () => {
|
|
||||||
if (firstRunSetupService.isSetupCompleted()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openFirstRunSetupWindowHandler();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { shouldAutoOpenFirstRunSetup };
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { FrequencyDictionaryLookup } from '../types';
|
import type { FrequencyDictionaryLookup } from '../types';
|
||||||
import { createFrequencyDictionaryLookup } from '../core/services/frequency-dictionary';
|
import { createFrequencyDictionaryLookup } from '../core/services';
|
||||||
|
|
||||||
export interface FrequencyDictionarySearchPathDeps {
|
export interface FrequencyDictionarySearchPathDeps {
|
||||||
getDictionaryRoots: () => string[];
|
getDictionaryRoots: () => string[];
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import { mergeAiConfig } from '../ai/config';
|
|
||||||
import { AnkiIntegration } from '../anki-integration';
|
|
||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
|
||||||
import type { ResolvedConfig } from '../types';
|
|
||||||
import type { AnkiConnectConfig } from '../types/anki';
|
|
||||||
|
|
||||||
export async function runHeadlessKnownWordRefresh(input: {
|
|
||||||
resolvedConfig: ResolvedConfig;
|
|
||||||
runtimeOptionsManager: {
|
|
||||||
getEffectiveAnkiConnectConfig: (config: AnkiConnectConfig) => AnkiConnectConfig;
|
|
||||||
} | null;
|
|
||||||
userDataPath: string;
|
|
||||||
logger: {
|
|
||||||
error: (message: string, error?: unknown) => void;
|
|
||||||
};
|
|
||||||
requestAppQuit: () => void;
|
|
||||||
}): Promise<void> {
|
|
||||||
if (input.resolvedConfig.ankiConnect.enabled !== true) {
|
|
||||||
input.logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled');
|
|
||||||
process.exitCode = 1;
|
|
||||||
input.requestAppQuit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const effectiveAnkiConfig =
|
|
||||||
input.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(input.resolvedConfig.ankiConnect) ??
|
|
||||||
input.resolvedConfig.ankiConnect;
|
|
||||||
const integration = new AnkiIntegration(
|
|
||||||
effectiveAnkiConfig,
|
|
||||||
new SubtitleTimingTracker(),
|
|
||||||
{ send: () => undefined } as never,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
async () => ({
|
|
||||||
keepNoteId: 0,
|
|
||||||
deleteNoteId: 0,
|
|
||||||
deleteDuplicate: false,
|
|
||||||
cancelled: true,
|
|
||||||
}),
|
|
||||||
path.join(input.userDataPath, 'known-words-cache.json'),
|
|
||||||
mergeAiConfig(input.resolvedConfig.ai, input.resolvedConfig.ankiConnect?.ai),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await integration.refreshKnownWordCache();
|
|
||||||
} catch (error) {
|
|
||||||
input.logger.error('Headless known-word refresh failed:', error);
|
|
||||||
process.exitCode = 1;
|
|
||||||
} finally {
|
|
||||||
integration.stop();
|
|
||||||
input.requestAppQuit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import type { CliArgs } from '../cli/args';
|
|
||||||
import type { LogLevelSource } from '../logger';
|
|
||||||
import type { StartupBootstrapRuntimeFactoryDeps } from './startup';
|
|
||||||
|
|
||||||
import { createHeadlessStartupRuntime } from './headless-startup-runtime';
|
|
||||||
|
|
||||||
test('headless startup runtime returns callable handlers and applies startup state', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const runtime = createHeadlessStartupRuntime<
|
|
||||||
{ mode: string },
|
|
||||||
{ startAppLifecycle: (args: CliArgs) => void }
|
|
||||||
>({
|
|
||||||
appLifecycleRuntimeRunnerMainDeps: {
|
|
||||||
app: { on: () => {} } as never,
|
|
||||||
platform: 'darwin',
|
|
||||||
shouldStartApp: () => true,
|
|
||||||
parseArgs: () => ({}) as never,
|
|
||||||
handleCliCommand: () => {},
|
|
||||||
printHelp: () => {},
|
|
||||||
logNoRunningInstance: () => {},
|
|
||||||
onReady: async () => {},
|
|
||||||
onWillQuitCleanup: () => {},
|
|
||||||
shouldRestoreWindowsOnActivate: () => false,
|
|
||||||
restoreWindowsOnActivate: () => {},
|
|
||||||
shouldQuitOnWindowAllClosed: () => false,
|
|
||||||
},
|
|
||||||
bootstrap: {
|
|
||||||
argv: ['node', 'main.js'],
|
|
||||||
parseArgs: () => ({ command: 'start' }) as never,
|
|
||||||
setLogLevel: (_level: string, _source: LogLevelSource) => {},
|
|
||||||
forceX11Backend: () => {},
|
|
||||||
enforceUnsupportedWaylandMode: () => {},
|
|
||||||
shouldStartApp: () => true,
|
|
||||||
getDefaultSocketPath: () => '/tmp/mpv.sock',
|
|
||||||
defaultTexthookerPort: 5174,
|
|
||||||
configDir: '/tmp/config',
|
|
||||||
defaultConfig: {} as never,
|
|
||||||
generateConfigTemplate: () => 'template',
|
|
||||||
generateDefaultConfigFile: async () => 0,
|
|
||||||
setExitCode: () => {},
|
|
||||||
quitApp: () => {},
|
|
||||||
logGenerateConfigError: () => {},
|
|
||||||
startAppLifecycle: (args: CliArgs) => {
|
|
||||||
calls.push(`bootstrap:${(args as { command?: string }).command ?? 'unknown'}`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
createAppLifecycleRuntimeRunner: () => (args: CliArgs) => {
|
|
||||||
calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`);
|
|
||||||
},
|
|
||||||
createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) => ({
|
|
||||||
startAppLifecycle: deps.startAppLifecycle,
|
|
||||||
}),
|
|
||||||
runStartupBootstrapRuntime: (deps) => {
|
|
||||||
deps.startAppLifecycle({ command: 'start' } as unknown as CliArgs);
|
|
||||||
return { mode: 'started' };
|
|
||||||
},
|
|
||||||
applyStartupState: (state: { mode: string }) => {
|
|
||||||
calls.push(`apply:${state.mode}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(typeof runtime.appLifecycleRuntimeRunner, 'function');
|
|
||||||
assert.equal(typeof runtime.runAndApplyStartupState, 'function');
|
|
||||||
assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' });
|
|
||||||
assert.deepEqual(calls, ['lifecycle:start', 'apply:started']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('headless startup runtime accepts grouped app lifecycle input', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const runtime = createHeadlessStartupRuntime<
|
|
||||||
{ mode: string },
|
|
||||||
{ startAppLifecycle: (args: CliArgs) => void }
|
|
||||||
>({
|
|
||||||
appLifecycle: {
|
|
||||||
app: { on: () => {} } as never,
|
|
||||||
platform: 'darwin',
|
|
||||||
shouldStartApp: () => true,
|
|
||||||
parseArgs: () => ({}) as never,
|
|
||||||
handleCliCommand: () => {},
|
|
||||||
printHelp: () => {},
|
|
||||||
logNoRunningInstance: () => {},
|
|
||||||
onReady: async () => {},
|
|
||||||
onWillQuitCleanup: () => {},
|
|
||||||
shouldRestoreWindowsOnActivate: () => false,
|
|
||||||
restoreWindowsOnActivate: () => {},
|
|
||||||
shouldQuitOnWindowAllClosed: () => false,
|
|
||||||
},
|
|
||||||
bootstrap: {
|
|
||||||
argv: ['node', 'main.js'],
|
|
||||||
parseArgs: () => ({ command: 'start' }) as never,
|
|
||||||
setLogLevel: (_level: string, _source: LogLevelSource) => {},
|
|
||||||
forceX11Backend: () => {},
|
|
||||||
enforceUnsupportedWaylandMode: () => {},
|
|
||||||
shouldStartApp: () => true,
|
|
||||||
getDefaultSocketPath: () => '/tmp/mpv.sock',
|
|
||||||
defaultTexthookerPort: 5174,
|
|
||||||
configDir: '/tmp/config',
|
|
||||||
defaultConfig: {} as never,
|
|
||||||
generateConfigTemplate: () => 'template',
|
|
||||||
generateDefaultConfigFile: async () => 0,
|
|
||||||
setExitCode: () => {},
|
|
||||||
quitApp: () => {},
|
|
||||||
logGenerateConfigError: () => {},
|
|
||||||
startAppLifecycle: (args: CliArgs) => {
|
|
||||||
calls.push(`bootstrap:${(args as { command?: string }).command ?? 'unknown'}`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
createAppLifecycleRuntimeRunner: () => (args: CliArgs) => {
|
|
||||||
calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`);
|
|
||||||
},
|
|
||||||
createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) => ({
|
|
||||||
startAppLifecycle: deps.startAppLifecycle,
|
|
||||||
}),
|
|
||||||
runStartupBootstrapRuntime: (deps) => {
|
|
||||||
deps.startAppLifecycle({ command: 'start' } as unknown as CliArgs);
|
|
||||||
return { mode: 'started' };
|
|
||||||
},
|
|
||||||
applyStartupState: (state: { mode: string }) => {
|
|
||||||
calls.push(`apply:${state.mode}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
runtime.appLifecycleRuntimeRunner({ command: 'start' } as unknown as CliArgs);
|
|
||||||
|
|
||||||
assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' });
|
|
||||||
assert.deepEqual(calls, ['lifecycle:start', 'lifecycle:start', 'apply:started']);
|
|
||||||
});
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import type { CliArgs } from '../cli/args';
|
|
||||||
import type { LogLevelSource } from '../logger';
|
|
||||||
import type { ResolvedConfig } from '../types';
|
|
||||||
import type { StartupBootstrapRuntimeDeps } from '../core/services/startup';
|
|
||||||
import { createAppLifecycleDepsRuntime, startAppLifecycle } from '../core/services/app-lifecycle';
|
|
||||||
import type { AppLifecycleDepsRuntimeOptions } from '../core/services/app-lifecycle';
|
|
||||||
import type { AppLifecycleRuntimeRunnerParams } from './startup-lifecycle';
|
|
||||||
import type { StartupBootstrapRuntimeFactoryDeps } from './startup';
|
|
||||||
import { createStartupBootstrapRuntimeDeps } from './startup';
|
|
||||||
import { composeHeadlessStartupHandlers } from './runtime/composers/headless-startup-composer';
|
|
||||||
import { createAppLifecycleRuntimeDeps } from './app-lifecycle';
|
|
||||||
import { createBuildAppLifecycleRuntimeRunnerMainDepsHandler } from './runtime/startup-lifecycle-main-deps';
|
|
||||||
|
|
||||||
export interface HeadlessStartupBootstrapInput {
|
|
||||||
argv: string[];
|
|
||||||
parseArgs: (argv: string[]) => CliArgs;
|
|
||||||
setLogLevel: (level: string, source: LogLevelSource) => void;
|
|
||||||
forceX11Backend: (args: CliArgs) => void;
|
|
||||||
enforceUnsupportedWaylandMode: (args: CliArgs) => void;
|
|
||||||
shouldStartApp: (args: CliArgs) => boolean;
|
|
||||||
getDefaultSocketPath: () => string;
|
|
||||||
defaultTexthookerPort: number;
|
|
||||||
configDir: string;
|
|
||||||
defaultConfig: ResolvedConfig;
|
|
||||||
generateConfigTemplate: (config: ResolvedConfig) => string;
|
|
||||||
generateDefaultConfigFile: (
|
|
||||||
args: CliArgs,
|
|
||||||
options: {
|
|
||||||
configDir: string;
|
|
||||||
defaultConfig: unknown;
|
|
||||||
generateTemplate: (config: unknown) => string;
|
|
||||||
},
|
|
||||||
) => Promise<number>;
|
|
||||||
setExitCode: (code: number) => void;
|
|
||||||
quitApp: () => void;
|
|
||||||
logGenerateConfigError: (message: string) => void;
|
|
||||||
startAppLifecycle: (args: CliArgs) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HeadlessStartupAppLifecycleInput = AppLifecycleRuntimeRunnerParams;
|
|
||||||
|
|
||||||
export interface HeadlessStartupRuntimeInput<
|
|
||||||
TStartupState,
|
|
||||||
TStartupBootstrapRuntimeDeps = StartupBootstrapRuntimeDeps,
|
|
||||||
> {
|
|
||||||
appLifecycleRuntimeRunnerMainDeps?: AppLifecycleDepsRuntimeOptions;
|
|
||||||
appLifecycle?: HeadlessStartupAppLifecycleInput;
|
|
||||||
bootstrap: HeadlessStartupBootstrapInput;
|
|
||||||
createAppLifecycleRuntimeRunner?: (
|
|
||||||
params: AppLifecycleDepsRuntimeOptions,
|
|
||||||
) => (args: CliArgs) => void;
|
|
||||||
createStartupBootstrapRuntimeDeps?: (
|
|
||||||
deps: StartupBootstrapRuntimeFactoryDeps,
|
|
||||||
) => TStartupBootstrapRuntimeDeps;
|
|
||||||
runStartupBootstrapRuntime: (deps: TStartupBootstrapRuntimeDeps) => TStartupState;
|
|
||||||
applyStartupState: (startupState: TStartupState) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HeadlessStartupRuntime<TStartupState> {
|
|
||||||
appLifecycleRuntimeRunner: (args: CliArgs) => void;
|
|
||||||
runAndApplyStartupState: () => TStartupState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createHeadlessStartupRuntime<
|
|
||||||
TStartupState,
|
|
||||||
TStartupBootstrapRuntimeDeps = StartupBootstrapRuntimeDeps,
|
|
||||||
>(
|
|
||||||
input: HeadlessStartupRuntimeInput<TStartupState, TStartupBootstrapRuntimeDeps>,
|
|
||||||
): HeadlessStartupRuntime<TStartupState> {
|
|
||||||
const appLifecycleRuntimeRunnerMainDeps =
|
|
||||||
input.appLifecycleRuntimeRunnerMainDeps ?? input.appLifecycle;
|
|
||||||
|
|
||||||
if (!appLifecycleRuntimeRunnerMainDeps) {
|
|
||||||
throw new Error('Headless startup runtime needs app lifecycle runtime runner deps');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { appLifecycleRuntimeRunner, runAndApplyStartupState } = composeHeadlessStartupHandlers({
|
|
||||||
startupRuntimeHandlersDeps: {
|
|
||||||
appLifecycleRuntimeRunnerMainDeps: createBuildAppLifecycleRuntimeRunnerMainDepsHandler(
|
|
||||||
appLifecycleRuntimeRunnerMainDeps,
|
|
||||||
)(),
|
|
||||||
createAppLifecycleRuntimeRunner:
|
|
||||||
input.createAppLifecycleRuntimeRunner ??
|
|
||||||
((params: AppLifecycleDepsRuntimeOptions) => (args: CliArgs) =>
|
|
||||||
startAppLifecycle(
|
|
||||||
args,
|
|
||||||
createAppLifecycleDepsRuntime(createAppLifecycleRuntimeDeps(params)),
|
|
||||||
)),
|
|
||||||
buildStartupBootstrapMainDeps: (startAppLifecycle: (args: CliArgs) => void) => ({
|
|
||||||
...input.bootstrap,
|
|
||||||
startAppLifecycle,
|
|
||||||
}),
|
|
||||||
createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) =>
|
|
||||||
input.createStartupBootstrapRuntimeDeps
|
|
||||||
? input.createStartupBootstrapRuntimeDeps(deps)
|
|
||||||
: (createStartupBootstrapRuntimeDeps(deps) as unknown as TStartupBootstrapRuntimeDeps),
|
|
||||||
runStartupBootstrapRuntime: input.runStartupBootstrapRuntime,
|
|
||||||
applyStartupState: input.applyStartupState,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
appLifecycleRuntimeRunner,
|
|
||||||
runAndApplyStartupState,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
import * as path from 'node:path';
|
|
||||||
|
|
||||||
import type { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import type { AnkiIntegration } from '../anki-integration';
|
|
||||||
import type {
|
|
||||||
JimakuApiResponse,
|
|
||||||
JimakuLanguagePreference,
|
|
||||||
KikuFieldGroupingChoice,
|
|
||||||
ResolvedConfig,
|
|
||||||
SubsyncManualRunRequest,
|
|
||||||
SubsyncResult,
|
|
||||||
} from '../types';
|
|
||||||
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from '../jimaku/utils';
|
|
||||||
import { applyRuntimeOptionResultRuntime } from '../core/services/runtime-options-ipc';
|
|
||||||
import {
|
|
||||||
playNextSubtitleRuntime,
|
|
||||||
replayCurrentSubtitleRuntime,
|
|
||||||
sendMpvCommandRuntime,
|
|
||||||
} from '../core/services';
|
|
||||||
import type { ConfigService } from '../config';
|
|
||||||
import { applyControllerConfigUpdate } from './controller-config-update.js';
|
|
||||||
import type { AnilistRuntime } from './anilist-runtime';
|
|
||||||
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
|
|
||||||
import { createIpcRuntimeFromMainState, type IpcRuntime } from './ipc-runtime';
|
|
||||||
import type { MiningRuntime } from './mining-runtime';
|
|
||||||
import type { MpvRuntime } from './mpv-runtime';
|
|
||||||
import type { OverlayModalRuntime } from './overlay-runtime';
|
|
||||||
import type { OverlayUiRuntime } from './overlay-ui-runtime';
|
|
||||||
import type { AppState } from './state';
|
|
||||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
|
||||||
import type { PlaylistBrowserIpcRuntime } from './runtime/playlist-browser-ipc';
|
|
||||||
import type { YoutubeRuntime } from './youtube-runtime';
|
|
||||||
import { resolveSubtitleStyleForRenderer } from './runtime/domains/overlay';
|
|
||||||
import type { ShortcutsRuntime } from './shortcuts-runtime';
|
|
||||||
|
|
||||||
type OverlayManagerLike = {
|
|
||||||
getMainWindow: () => BrowserWindow | null;
|
|
||||||
getVisibleOverlayVisible: () => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OverlayUiLike = Pick<
|
|
||||||
OverlayUiRuntime<BrowserWindow>,
|
|
||||||
| 'broadcastRuntimeOptionsChanged'
|
|
||||||
| 'handleOverlayModalClosed'
|
|
||||||
| 'openRuntimeOptionsPalette'
|
|
||||||
| 'toggleVisibleOverlay'
|
|
||||||
>;
|
|
||||||
|
|
||||||
type OverlayContentMeasurementStoreLike = {
|
|
||||||
report: (payload: unknown) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ConfigDerivedRuntimeLike = {
|
|
||||||
jimakuFetchJson: <T>(
|
|
||||||
endpoint: string,
|
|
||||||
query?: Record<string, string | number | boolean | null | undefined>,
|
|
||||||
) => Promise<JimakuApiResponse<T>>;
|
|
||||||
getJimakuMaxEntryResults: () => number;
|
|
||||||
getJimakuLanguagePreference: () => JimakuLanguagePreference;
|
|
||||||
resolveJimakuApiKey: () => Promise<string | null>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SubsyncRuntimeLike = {
|
|
||||||
triggerFromConfig: () => Promise<void>;
|
|
||||||
runManualFromIpc: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface IpcRuntimeBootstrapInput {
|
|
||||||
appState: AppState;
|
|
||||||
userDataPath: string;
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
configService: Pick<ConfigService, 'getRawConfig' | 'patchRawConfig'>;
|
|
||||||
overlay: {
|
|
||||||
manager: OverlayManagerLike;
|
|
||||||
getOverlayUi: () => OverlayUiLike | undefined;
|
|
||||||
modalRuntime: Pick<OverlayModalRuntime, 'notifyOverlayModalOpened'>;
|
|
||||||
contentMeasurementStore: OverlayContentMeasurementStoreLike;
|
|
||||||
};
|
|
||||||
subtitle: SubtitleRuntime;
|
|
||||||
mpvRuntime: Pick<MpvRuntime, 'shiftSubtitleDelayToAdjacentCue' | 'showMpvOsd'>;
|
|
||||||
shortcuts: Pick<ShortcutsRuntime, 'getConfiguredShortcuts'>;
|
|
||||||
actions: {
|
|
||||||
requestAppQuit: () => void;
|
|
||||||
openYomitanSettings: () => boolean;
|
|
||||||
openPlaylistBrowser: () => void | Promise<void>;
|
|
||||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
|
||||||
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
|
|
||||||
};
|
|
||||||
runtimes: {
|
|
||||||
youtube: Pick<YoutubeRuntime, 'openYoutubeTrackPickerFromPlayback' | 'resolveActivePicker'>;
|
|
||||||
anilist: Pick<
|
|
||||||
AnilistRuntime,
|
|
||||||
| 'getStatusSnapshot'
|
|
||||||
| 'clearTokenState'
|
|
||||||
| 'openAnilistSetupWindow'
|
|
||||||
| 'getQueueStatusSnapshot'
|
|
||||||
| 'processNextAnilistRetryUpdate'
|
|
||||||
>;
|
|
||||||
mining: Pick<MiningRuntime, 'appendClipboardVideoToQueue'>;
|
|
||||||
dictionarySupport: Pick<
|
|
||||||
DictionarySupportRuntime,
|
|
||||||
| 'createFieldGroupingCallback'
|
|
||||||
| 'getFieldGroupingResolver'
|
|
||||||
| 'setFieldGroupingResolver'
|
|
||||||
| 'resolveMediaPathForJimaku'
|
|
||||||
>;
|
|
||||||
playlistBrowser: Pick<PlaylistBrowserIpcRuntime, 'playlistBrowserMainDeps'>;
|
|
||||||
configDerived: ConfigDerivedRuntimeLike;
|
|
||||||
subsync: SubsyncRuntimeLike;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createIpcRuntimeBootstrap(input: IpcRuntimeBootstrapInput): IpcRuntime {
|
|
||||||
return createIpcRuntimeFromMainState({
|
|
||||||
mpv: {
|
|
||||||
mainDeps: {
|
|
||||||
triggerSubsyncFromConfig: () => input.runtimes.subsync.triggerFromConfig(),
|
|
||||||
openRuntimeOptionsPalette: () => input.overlay.getOverlayUi()?.openRuntimeOptionsPalette(),
|
|
||||||
openYoutubeTrackPicker: () => input.runtimes.youtube.openYoutubeTrackPickerFromPlayback(),
|
|
||||||
openPlaylistBrowser: () => input.actions.openPlaylistBrowser(),
|
|
||||||
cycleRuntimeOption: (id, direction) => {
|
|
||||||
if (!input.appState.runtimeOptionsManager) {
|
|
||||||
return { ok: false, error: 'Runtime options manager unavailable' };
|
|
||||||
}
|
|
||||||
return applyRuntimeOptionResultRuntime(
|
|
||||||
input.appState.runtimeOptionsManager.cycleOption(id, direction),
|
|
||||||
(text) => input.mpvRuntime.showMpvOsd(text),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
showMpvOsd: (text) => input.mpvRuntime.showMpvOsd(text),
|
|
||||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(input.appState.mpvClient),
|
|
||||||
playNextSubtitle: () => playNextSubtitleRuntime(input.appState.mpvClient),
|
|
||||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
|
||||||
input.mpvRuntime.shiftSubtitleDelayToAdjacentCue(direction),
|
|
||||||
sendMpvCommand: (rawCommand) => sendMpvCommandRuntime(input.appState.mpvClient, rawCommand),
|
|
||||||
getMpvClient: () => input.appState.mpvClient,
|
|
||||||
isMpvConnected: () =>
|
|
||||||
Boolean(input.appState.mpvClient && input.appState.mpvClient.connected),
|
|
||||||
hasRuntimeOptionsManager: () => input.appState.runtimeOptionsManager !== null,
|
|
||||||
},
|
|
||||||
runSubsyncManualFromIpc: (request) => input.runtimes.subsync.runManualFromIpc(request),
|
|
||||||
},
|
|
||||||
runtimeOptions: {
|
|
||||||
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
|
|
||||||
showMpvOsd: (text) => input.mpvRuntime.showMpvOsd(text),
|
|
||||||
},
|
|
||||||
main: {
|
|
||||||
window: {
|
|
||||||
getMainWindow: () => input.overlay.manager.getMainWindow(),
|
|
||||||
getVisibleOverlayVisibility: () => input.overlay.manager.getVisibleOverlayVisible(),
|
|
||||||
focusMainWindow: () => {
|
|
||||||
const mainWindow = input.overlay.manager.getMainWindow();
|
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
||||||
if (!mainWindow.isFocused()) {
|
|
||||||
mainWindow.focus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onOverlayModalClosed: (modal) =>
|
|
||||||
input.overlay.getOverlayUi()?.handleOverlayModalClosed(modal),
|
|
||||||
onOverlayModalOpened: (modal) => {
|
|
||||||
input.overlay.modalRuntime.notifyOverlayModalOpened(modal);
|
|
||||||
},
|
|
||||||
onYoutubePickerResolve: (request) => input.runtimes.youtube.resolveActivePicker(request),
|
|
||||||
openYomitanSettings: () => input.actions.openYomitanSettings(),
|
|
||||||
quitApp: () => input.actions.requestAppQuit(),
|
|
||||||
toggleVisibleOverlay: () => input.overlay.getOverlayUi()?.toggleVisibleOverlay(),
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
tokenizeCurrentSubtitle: async () => await input.subtitle.tokenizeCurrentSubtitle(),
|
|
||||||
getCurrentSubtitleRaw: () => input.appState.currentSubText,
|
|
||||||
getCurrentSubtitleAss: () => input.appState.currentSubAssText,
|
|
||||||
getSubtitleSidebarSnapshot: async () => await input.subtitle.getSubtitleSidebarSnapshot(),
|
|
||||||
getPlaybackPaused: () => input.appState.playbackPaused,
|
|
||||||
getSubtitlePosition: () => input.subtitle.loadSubtitlePosition(),
|
|
||||||
getSubtitleStyle: () => resolveSubtitleStyleForRenderer(input.getResolvedConfig()),
|
|
||||||
saveSubtitlePosition: (position) => input.subtitle.saveSubtitlePosition(position),
|
|
||||||
getMecabTokenizer: () => input.appState.mecabTokenizer,
|
|
||||||
getKeybindings: () => input.appState.keybindings,
|
|
||||||
getConfiguredShortcuts: () => input.shortcuts.getConfiguredShortcuts(),
|
|
||||||
getStatsToggleKey: () => input.getResolvedConfig().stats.toggleKey,
|
|
||||||
getMarkWatchedKey: () => input.getResolvedConfig().stats.markWatchedKey,
|
|
||||||
getSecondarySubMode: () => input.appState.secondarySubMode,
|
|
||||||
},
|
|
||||||
controller: {
|
|
||||||
getControllerConfig: () => input.getResolvedConfig().controller,
|
|
||||||
saveControllerConfig: (update) => {
|
|
||||||
const currentRawConfig = input.configService.getRawConfig();
|
|
||||||
input.configService.patchRawConfig({
|
|
||||||
controller: applyControllerConfigUpdate(currentRawConfig.controller, update),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => {
|
|
||||||
input.configService.patchRawConfig({
|
|
||||||
controller: {
|
|
||||||
preferredGamepadId,
|
|
||||||
preferredGamepadLabel,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
runtime: {
|
|
||||||
getMpvClient: () => input.appState.mpvClient,
|
|
||||||
getAnkiConnectStatus: () => input.appState.ankiIntegration !== null,
|
|
||||||
getRuntimeOptions: () => input.appState.runtimeOptionsManager?.listOptions() ?? [],
|
|
||||||
reportOverlayContentBounds: (payload) => {
|
|
||||||
input.overlay.contentMeasurementStore.report(payload);
|
|
||||||
},
|
|
||||||
getImmersionTracker: () => input.appState.immersionTracker,
|
|
||||||
},
|
|
||||||
playlistBrowser: input.runtimes.playlistBrowser.playlistBrowserMainDeps,
|
|
||||||
anilist: {
|
|
||||||
getStatus: () => input.runtimes.anilist.getStatusSnapshot(),
|
|
||||||
clearToken: () => input.runtimes.anilist.clearTokenState(),
|
|
||||||
openSetup: () => input.runtimes.anilist.openAnilistSetupWindow(),
|
|
||||||
getQueueStatus: () => input.runtimes.anilist.getQueueStatusSnapshot(),
|
|
||||||
retryQueueNow: () => input.runtimes.anilist.processNextAnilistRetryUpdate(),
|
|
||||||
},
|
|
||||||
mining: {
|
|
||||||
appendClipboardVideoToQueue: () => input.runtimes.mining.appendClipboardVideoToQueue(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ankiJimaku: {
|
|
||||||
patchAnkiConnectEnabled: (enabled) => {
|
|
||||||
input.configService.patchRawConfig({ ankiConnect: { enabled } });
|
|
||||||
},
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
|
|
||||||
getSubtitleTimingTracker: () => input.appState.subtitleTimingTracker,
|
|
||||||
getMpvClient: () => input.appState.mpvClient,
|
|
||||||
getAnkiIntegration: () => input.appState.ankiIntegration,
|
|
||||||
setAnkiIntegration: (integration) => input.actions.setAnkiIntegration(integration),
|
|
||||||
getKnownWordCacheStatePath: () => path.join(input.userDataPath, 'known-words-cache.json'),
|
|
||||||
showDesktopNotification: input.actions.showDesktopNotification,
|
|
||||||
createFieldGroupingCallback: () =>
|
|
||||||
input.runtimes.dictionarySupport.createFieldGroupingCallback(),
|
|
||||||
broadcastRuntimeOptionsChanged: () =>
|
|
||||||
input.overlay.getOverlayUi()?.broadcastRuntimeOptionsChanged(),
|
|
||||||
getFieldGroupingResolver: () => input.runtimes.dictionarySupport.getFieldGroupingResolver(),
|
|
||||||
setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) =>
|
|
||||||
input.runtimes.dictionarySupport.setFieldGroupingResolver(resolver),
|
|
||||||
parseMediaInfo: (mediaPath: string | null) =>
|
|
||||||
parseMediaInfo(input.runtimes.dictionarySupport.resolveMediaPathForJimaku(mediaPath)),
|
|
||||||
getCurrentMediaPath: () => input.appState.currentMediaPath,
|
|
||||||
jimakuFetchJson: <T>(
|
|
||||||
endpoint: string,
|
|
||||||
query?: Record<string, string | number | boolean | null | undefined>,
|
|
||||||
): Promise<JimakuApiResponse<T>> =>
|
|
||||||
input.runtimes.configDerived.jimakuFetchJson<T>(endpoint, query),
|
|
||||||
getJimakuMaxEntryResults: () => input.runtimes.configDerived.getJimakuMaxEntryResults(),
|
|
||||||
getJimakuLanguagePreference: () => input.runtimes.configDerived.getJimakuLanguagePreference(),
|
|
||||||
resolveJimakuApiKey: () => input.runtimes.configDerived.resolveJimakuApiKey(),
|
|
||||||
isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath),
|
|
||||||
downloadToFile: (url: string, destPath: string, headers: Record<string, string>) =>
|
|
||||||
downloadToFile(url, destPath, headers),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import {
|
|
||||||
createIpcDepsRuntime,
|
|
||||||
registerAnkiJimakuIpcRuntime,
|
|
||||||
registerIpcHandlers,
|
|
||||||
} from '../core/services';
|
|
||||||
import { registerAnkiJimakuIpcHandlers } from '../core/services/anki-jimaku-ipc';
|
|
||||||
import {
|
|
||||||
createAnkiJimakuIpcRuntimeServiceDeps,
|
|
||||||
createMainIpcRuntimeServiceDeps,
|
|
||||||
createRuntimeOptionsIpcDeps,
|
|
||||||
} from './dependencies';
|
|
||||||
import type {
|
|
||||||
AnkiJimakuIpcRuntimeServiceDepsParams,
|
|
||||||
MainIpcRuntimeServiceDepsParams,
|
|
||||||
} from './dependencies';
|
|
||||||
import type { RegisterIpcRuntimeServicesParams } from './ipc-runtime';
|
|
||||||
|
|
||||||
export function registerMainIpcRuntimeServices(params: MainIpcRuntimeServiceDepsParams): void {
|
|
||||||
registerIpcHandlers(createIpcDepsRuntime(createMainIpcRuntimeServiceDeps(params)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerAnkiJimakuIpcRuntimeServices(
|
|
||||||
params: AnkiJimakuIpcRuntimeServiceDepsParams,
|
|
||||||
): void {
|
|
||||||
registerAnkiJimakuIpcRuntime(
|
|
||||||
createAnkiJimakuIpcRuntimeServiceDeps(params),
|
|
||||||
registerAnkiJimakuIpcHandlers,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerIpcRuntimeServices(params: RegisterIpcRuntimeServicesParams): void {
|
|
||||||
const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({
|
|
||||||
getRuntimeOptionsManager: params.runtimeOptions.getRuntimeOptionsManager,
|
|
||||||
showMpvOsd: params.runtimeOptions.showMpvOsd,
|
|
||||||
});
|
|
||||||
|
|
||||||
registerMainIpcRuntimeServices({
|
|
||||||
...params.mainDeps,
|
|
||||||
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
|
|
||||||
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
|
|
||||||
});
|
|
||||||
registerAnkiJimakuIpcRuntimeServices(params.ankiJimakuDeps);
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createIpcRuntime, createIpcRuntimeFromMainState } from './ipc-runtime';
|
|
||||||
|
|
||||||
function createBaseRuntimeInput(capturedRegistration: { value: unknown | null }) {
|
|
||||||
const manualResult = { ok: true, summary: 'done' };
|
|
||||||
const main = {
|
|
||||||
window: {
|
|
||||||
getMainWindow: () => null,
|
|
||||||
getVisibleOverlayVisibility: () => false,
|
|
||||||
focusMainWindow: () => {},
|
|
||||||
onOverlayModalClosed: () => {},
|
|
||||||
onOverlayModalOpened: () => {},
|
|
||||||
onYoutubePickerResolve: async () => ({ ok: true }) as never,
|
|
||||||
openYomitanSettings: () => {},
|
|
||||||
quitApp: () => {},
|
|
||||||
toggleVisibleOverlay: () => {},
|
|
||||||
},
|
|
||||||
playlistBrowser: {
|
|
||||||
getPlaylistBrowserSnapshot: async () => ({
|
|
||||||
directoryPath: null,
|
|
||||||
directoryAvailable: false,
|
|
||||||
directoryStatus: '',
|
|
||||||
directoryItems: [],
|
|
||||||
playlistItems: [],
|
|
||||||
playingIndex: null,
|
|
||||||
currentFilePath: null,
|
|
||||||
}),
|
|
||||||
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
|
|
||||||
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
|
||||||
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
|
||||||
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
tokenizeCurrentSubtitle: async () => null,
|
|
||||||
getCurrentSubtitleRaw: () => '',
|
|
||||||
getCurrentSubtitleAss: () => '',
|
|
||||||
getSubtitleSidebarSnapshot: async () => null as never,
|
|
||||||
getPlaybackPaused: () => false,
|
|
||||||
getSubtitlePosition: () => null,
|
|
||||||
getSubtitleStyle: () => null,
|
|
||||||
saveSubtitlePosition: () => {},
|
|
||||||
getMecabTokenizer: () => null,
|
|
||||||
getKeybindings: () => [],
|
|
||||||
getConfiguredShortcuts: () => null,
|
|
||||||
getStatsToggleKey: () => '',
|
|
||||||
getMarkWatchedKey: () => '',
|
|
||||||
getSecondarySubMode: () => 'hover',
|
|
||||||
},
|
|
||||||
controller: {
|
|
||||||
getControllerConfig: () => ({}) as never,
|
|
||||||
saveControllerConfig: () => {},
|
|
||||||
saveControllerPreference: () => {},
|
|
||||||
},
|
|
||||||
runtime: {
|
|
||||||
getMpvClient: () => null,
|
|
||||||
getAnkiConnectStatus: () => false,
|
|
||||||
getRuntimeOptions: () => [],
|
|
||||||
reportOverlayContentBounds: () => {},
|
|
||||||
getImmersionTracker: () => null,
|
|
||||||
},
|
|
||||||
anilist: {
|
|
||||||
getStatus: () => null,
|
|
||||||
clearToken: () => {},
|
|
||||||
openSetup: () => {},
|
|
||||||
getQueueStatus: () => null,
|
|
||||||
retryQueueNow: async () => ({ ok: true, message: 'ok' }) as never,
|
|
||||||
},
|
|
||||||
mining: {
|
|
||||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const ankiJimaku = {
|
|
||||||
patchAnkiConnectEnabled: () => {},
|
|
||||||
getResolvedConfig: () => ({}),
|
|
||||||
getRuntimeOptionsManager: () => null,
|
|
||||||
getSubtitleTimingTracker: () => null,
|
|
||||||
getMpvClient: () => null,
|
|
||||||
getAnkiIntegration: () => null,
|
|
||||||
setAnkiIntegration: () => {},
|
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known-words.json',
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
createFieldGroupingCallback: () => async () => ({}) as never,
|
|
||||||
broadcastRuntimeOptionsChanged: () => {},
|
|
||||||
getFieldGroupingResolver: () => null,
|
|
||||||
setFieldGroupingResolver: () => {},
|
|
||||||
parseMediaInfo: () => ({}) as never,
|
|
||||||
getCurrentMediaPath: () => null,
|
|
||||||
jimakuFetchJson: async () => ({ data: null, error: null }) as never,
|
|
||||||
getJimakuMaxEntryResults: () => 5,
|
|
||||||
getJimakuLanguagePreference: () => 'ja' as const,
|
|
||||||
resolveJimakuApiKey: async () => null,
|
|
||||||
isRemoteMediaPath: () => false,
|
|
||||||
downloadToFile: async () => ({ ok: true, path: '/tmp/file' }) as never,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
mpv: {
|
|
||||||
mainDeps: {
|
|
||||||
triggerSubsyncFromConfig: () => {},
|
|
||||||
openRuntimeOptionsPalette: () => {},
|
|
||||||
openYoutubeTrackPicker: () => {},
|
|
||||||
openPlaylistBrowser: () => {},
|
|
||||||
cycleRuntimeOption: () => ({ ok: true }),
|
|
||||||
showMpvOsd: () => {},
|
|
||||||
replayCurrentSubtitle: () => {},
|
|
||||||
playNextSubtitle: () => {},
|
|
||||||
shiftSubDelayToAdjacentSubtitle: async () => {},
|
|
||||||
sendMpvCommand: () => {},
|
|
||||||
getMpvClient: () => null,
|
|
||||||
isMpvConnected: () => true,
|
|
||||||
hasRuntimeOptionsManager: () => true,
|
|
||||||
},
|
|
||||||
handleMpvCommandFromIpcRuntime: () => {},
|
|
||||||
runSubsyncManualFromIpc: async () => manualResult as never,
|
|
||||||
},
|
|
||||||
main,
|
|
||||||
ankiJimaku,
|
|
||||||
registration: {
|
|
||||||
runtimeOptions: {
|
|
||||||
getRuntimeOptionsManager: () => null,
|
|
||||||
showMpvOsd: () => {},
|
|
||||||
},
|
|
||||||
main,
|
|
||||||
ankiJimaku,
|
|
||||||
registerIpcRuntimeServices: (params: unknown) => {
|
|
||||||
capturedRegistration.value = params;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
registerIpcRuntimeServices: (params: unknown) => {
|
|
||||||
capturedRegistration.value = params;
|
|
||||||
},
|
|
||||||
manualResult,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('ipc runtime registers composed IPC handlers from explicit registration input', async () => {
|
|
||||||
const capturedRegistration = { value: null as unknown | null };
|
|
||||||
const input = createBaseRuntimeInput(capturedRegistration);
|
|
||||||
|
|
||||||
const runtime = createIpcRuntime({
|
|
||||||
mpv: input.mpv,
|
|
||||||
registration: input.registration,
|
|
||||||
});
|
|
||||||
|
|
||||||
runtime.registerIpcRuntimeHandlers();
|
|
||||||
|
|
||||||
assert.ok(capturedRegistration.value);
|
|
||||||
const registration = capturedRegistration.value as {
|
|
||||||
runtimeOptions: { showMpvOsd: unknown };
|
|
||||||
mainDeps: {
|
|
||||||
getPlaylistBrowserSnapshot: unknown;
|
|
||||||
handleMpvCommand: unknown;
|
|
||||||
runSubsyncManual: (payload: unknown) => Promise<unknown>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
assert.equal(registration.runtimeOptions.showMpvOsd !== undefined, true);
|
|
||||||
assert.equal(registration.mainDeps.getPlaylistBrowserSnapshot instanceof Function, true);
|
|
||||||
assert.equal(registration.mainDeps.handleMpvCommand instanceof Function, true);
|
|
||||||
assert.deepEqual(
|
|
||||||
await registration.mainDeps.runSubsyncManual({ payload: null } as never),
|
|
||||||
input.manualResult,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ipc runtime builds grouped registration input from main state', async () => {
|
|
||||||
const capturedRegistration = { value: null as unknown | null };
|
|
||||||
const input = createBaseRuntimeInput(capturedRegistration);
|
|
||||||
|
|
||||||
const runtime = createIpcRuntimeFromMainState({
|
|
||||||
mpv: input.mpv,
|
|
||||||
runtimeOptions: input.registration.runtimeOptions,
|
|
||||||
main: input.main,
|
|
||||||
ankiJimaku: input.ankiJimaku,
|
|
||||||
});
|
|
||||||
|
|
||||||
runtime.registerIpcRuntimeHandlers();
|
|
||||||
|
|
||||||
assert.ok(capturedRegistration.value);
|
|
||||||
const registration = capturedRegistration.value as {
|
|
||||||
runtimeOptions: { showMpvOsd: unknown };
|
|
||||||
mainDeps: {
|
|
||||||
getPlaylistBrowserSnapshot: unknown;
|
|
||||||
handleMpvCommand: unknown;
|
|
||||||
runSubsyncManual: (payload: unknown) => Promise<unknown>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
assert.equal(registration.runtimeOptions.showMpvOsd !== undefined, true);
|
|
||||||
assert.equal(registration.mainDeps.getPlaylistBrowserSnapshot instanceof Function, true);
|
|
||||||
assert.equal(registration.mainDeps.handleMpvCommand instanceof Function, true);
|
|
||||||
assert.deepEqual(
|
|
||||||
await registration.mainDeps.runSubsyncManual({ payload: null } as never),
|
|
||||||
input.manualResult,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import type {
|
import {
|
||||||
|
createIpcDepsRuntime,
|
||||||
|
registerAnkiJimakuIpcRuntime,
|
||||||
|
registerIpcHandlers,
|
||||||
|
} from '../core/services';
|
||||||
|
import { registerAnkiJimakuIpcHandlers } from '../core/services/anki-jimaku-ipc';
|
||||||
|
import {
|
||||||
|
createAnkiJimakuIpcRuntimeServiceDeps,
|
||||||
AnkiJimakuIpcRuntimeServiceDepsParams,
|
AnkiJimakuIpcRuntimeServiceDepsParams,
|
||||||
|
createMainIpcRuntimeServiceDeps,
|
||||||
MainIpcRuntimeServiceDepsParams,
|
MainIpcRuntimeServiceDepsParams,
|
||||||
|
createRuntimeOptionsIpcDeps,
|
||||||
RuntimeOptionsIpcDepsParams,
|
RuntimeOptionsIpcDepsParams,
|
||||||
} from './dependencies';
|
} from './dependencies';
|
||||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './dependencies';
|
|
||||||
import {
|
|
||||||
handleMpvCommandFromIpcRuntime,
|
|
||||||
type MpvCommandFromIpcRuntimeDeps,
|
|
||||||
} from './ipc-mpv-command';
|
|
||||||
import { registerIpcRuntimeServices } from './ipc-runtime-services';
|
|
||||||
import { composeIpcRuntimeHandlers } from './runtime/composers/ipc-runtime-composer';
|
|
||||||
|
|
||||||
export interface RegisterIpcRuntimeServicesParams {
|
export interface RegisterIpcRuntimeServicesParams {
|
||||||
runtimeOptions: RuntimeOptionsIpcDepsParams;
|
runtimeOptions: RuntimeOptionsIpcDepsParams;
|
||||||
@@ -17,140 +19,28 @@ export interface RegisterIpcRuntimeServicesParams {
|
|||||||
ankiJimakuDeps: AnkiJimakuIpcRuntimeServiceDepsParams;
|
ankiJimakuDeps: AnkiJimakuIpcRuntimeServiceDepsParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IpcRuntimeMainInput {
|
export function registerMainIpcRuntimeServices(params: MainIpcRuntimeServiceDepsParams): void {
|
||||||
window: Pick<
|
registerIpcHandlers(createIpcDepsRuntime(createMainIpcRuntimeServiceDeps(params)));
|
||||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
|
||||||
| 'getMainWindow'
|
|
||||||
| 'getVisibleOverlayVisibility'
|
|
||||||
| 'focusMainWindow'
|
|
||||||
| 'onOverlayModalClosed'
|
|
||||||
| 'onOverlayModalOpened'
|
|
||||||
| 'onYoutubePickerResolve'
|
|
||||||
| 'openYomitanSettings'
|
|
||||||
| 'quitApp'
|
|
||||||
| 'toggleVisibleOverlay'
|
|
||||||
>;
|
|
||||||
playlistBrowser: Pick<
|
|
||||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
|
||||||
| 'getPlaylistBrowserSnapshot'
|
|
||||||
| 'appendPlaylistBrowserFile'
|
|
||||||
| 'playPlaylistBrowserIndex'
|
|
||||||
| 'removePlaylistBrowserIndex'
|
|
||||||
| 'movePlaylistBrowserIndex'
|
|
||||||
>;
|
|
||||||
subtitle: Pick<
|
|
||||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
|
||||||
| 'tokenizeCurrentSubtitle'
|
|
||||||
| 'getCurrentSubtitleRaw'
|
|
||||||
| 'getCurrentSubtitleAss'
|
|
||||||
| 'getSubtitleSidebarSnapshot'
|
|
||||||
| 'getPlaybackPaused'
|
|
||||||
| 'getSubtitlePosition'
|
|
||||||
| 'getSubtitleStyle'
|
|
||||||
| 'saveSubtitlePosition'
|
|
||||||
| 'getMecabTokenizer'
|
|
||||||
| 'getKeybindings'
|
|
||||||
| 'getConfiguredShortcuts'
|
|
||||||
| 'getStatsToggleKey'
|
|
||||||
| 'getMarkWatchedKey'
|
|
||||||
| 'getSecondarySubMode'
|
|
||||||
>;
|
|
||||||
controller: Pick<
|
|
||||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
|
||||||
'getControllerConfig' | 'saveControllerConfig' | 'saveControllerPreference'
|
|
||||||
>;
|
|
||||||
runtime: Pick<
|
|
||||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
|
||||||
'getMpvClient' | 'getAnkiConnectStatus' | 'getRuntimeOptions' | 'reportOverlayContentBounds'
|
|
||||||
> &
|
|
||||||
Partial<Pick<RegisterIpcRuntimeServicesParams['mainDeps'], 'getImmersionTracker'>>;
|
|
||||||
anilist: {
|
|
||||||
getStatus: RegisterIpcRuntimeServicesParams['mainDeps']['getAnilistStatus'];
|
|
||||||
clearToken: RegisterIpcRuntimeServicesParams['mainDeps']['clearAnilistToken'];
|
|
||||||
openSetup: RegisterIpcRuntimeServicesParams['mainDeps']['openAnilistSetup'];
|
|
||||||
getQueueStatus: RegisterIpcRuntimeServicesParams['mainDeps']['getAnilistQueueStatus'];
|
|
||||||
retryQueueNow: RegisterIpcRuntimeServicesParams['mainDeps']['retryAnilistQueueNow'];
|
|
||||||
};
|
|
||||||
mining: {
|
|
||||||
appendClipboardVideoToQueue: RegisterIpcRuntimeServicesParams['mainDeps']['appendClipboardVideoToQueue'];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IpcRuntimeRegistrationInput {
|
export function registerAnkiJimakuIpcRuntimeServices(
|
||||||
runtimeOptions: RuntimeOptionsIpcDepsParams;
|
params: AnkiJimakuIpcRuntimeServiceDepsParams,
|
||||||
main: IpcRuntimeMainInput;
|
): void {
|
||||||
ankiJimaku: AnkiJimakuIpcRuntimeServiceDepsParams;
|
registerAnkiJimakuIpcRuntime(
|
||||||
registerIpcRuntimeServices: (params: RegisterIpcRuntimeServicesParams) => void;
|
createAnkiJimakuIpcRuntimeServiceDeps(params),
|
||||||
|
registerAnkiJimakuIpcHandlers,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IpcRuntimeInput {
|
export function registerIpcRuntimeServices(params: RegisterIpcRuntimeServicesParams): void {
|
||||||
mpv: {
|
const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({
|
||||||
mainDeps: MpvCommandFromIpcRuntimeDeps;
|
getRuntimeOptionsManager: params.runtimeOptions.getRuntimeOptionsManager,
|
||||||
handleMpvCommandFromIpcRuntime: (
|
showMpvOsd: params.runtimeOptions.showMpvOsd,
|
||||||
command: (string | number)[],
|
|
||||||
deps: MpvCommandFromIpcRuntimeDeps,
|
|
||||||
) => void;
|
|
||||||
runSubsyncManualFromIpc: MainIpcRuntimeServiceDepsParams['runSubsyncManual'];
|
|
||||||
};
|
|
||||||
registration: IpcRuntimeRegistrationInput;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IpcRuntime {
|
|
||||||
registerIpcRuntimeHandlers: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IpcRuntimeFromMainStateInput {
|
|
||||||
mpv: {
|
|
||||||
mainDeps: MpvCommandFromIpcRuntimeDeps;
|
|
||||||
runSubsyncManualFromIpc: MainIpcRuntimeServiceDepsParams['runSubsyncManual'];
|
|
||||||
};
|
|
||||||
runtimeOptions: RuntimeOptionsIpcDepsParams;
|
|
||||||
main: IpcRuntimeMainInput;
|
|
||||||
ankiJimaku: AnkiJimakuIpcRuntimeServiceDepsParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createIpcRuntime(input: IpcRuntimeInput): IpcRuntime {
|
|
||||||
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|
||||||
mpvCommandMainDeps: input.mpv.mainDeps,
|
|
||||||
handleMpvCommandFromIpcRuntime: input.mpv.handleMpvCommandFromIpcRuntime,
|
|
||||||
runSubsyncManualFromIpc: input.mpv.runSubsyncManualFromIpc,
|
|
||||||
registration: {
|
|
||||||
runtimeOptions: input.registration.runtimeOptions,
|
|
||||||
mainDeps: {
|
|
||||||
...input.registration.main.window,
|
|
||||||
...input.registration.main.playlistBrowser,
|
|
||||||
...input.registration.main.subtitle,
|
|
||||||
...input.registration.main.controller,
|
|
||||||
...input.registration.main.runtime,
|
|
||||||
getAnilistStatus: input.registration.main.anilist.getStatus,
|
|
||||||
clearAnilistToken: input.registration.main.anilist.clearToken,
|
|
||||||
openAnilistSetup: input.registration.main.anilist.openSetup,
|
|
||||||
getAnilistQueueStatus: input.registration.main.anilist.getQueueStatus,
|
|
||||||
retryAnilistQueueNow: input.registration.main.anilist.retryQueueNow,
|
|
||||||
appendClipboardVideoToQueue: input.registration.main.mining.appendClipboardVideoToQueue,
|
|
||||||
},
|
|
||||||
ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps(input.registration.ankiJimaku),
|
|
||||||
registerIpcRuntimeServices: (params) => input.registration.registerIpcRuntimeServices(params),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
registerMainIpcRuntimeServices({
|
||||||
return {
|
...params.mainDeps,
|
||||||
registerIpcRuntimeHandlers,
|
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
|
||||||
};
|
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
|
||||||
}
|
|
||||||
|
|
||||||
export function createIpcRuntimeFromMainState(input: IpcRuntimeFromMainStateInput): IpcRuntime {
|
|
||||||
return createIpcRuntime({
|
|
||||||
mpv: {
|
|
||||||
mainDeps: input.mpv.mainDeps,
|
|
||||||
handleMpvCommandFromIpcRuntime,
|
|
||||||
runSubsyncManualFromIpc: input.mpv.runSubsyncManualFromIpc,
|
|
||||||
},
|
|
||||||
registration: {
|
|
||||||
runtimeOptions: input.runtimeOptions,
|
|
||||||
main: input.main,
|
|
||||||
ankiJimaku: input.ankiJimaku,
|
|
||||||
registerIpcRuntimeServices,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
registerAnkiJimakuIpcRuntimeServices(params.ankiJimakuDeps);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
|
|
||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import { DEFAULT_CONFIG } from '../config';
|
|
||||||
import {
|
|
||||||
JellyfinRemoteSessionService,
|
|
||||||
authenticateWithPasswordRuntime,
|
|
||||||
jellyfinTicksToSecondsRuntime,
|
|
||||||
listJellyfinItemsRuntime,
|
|
||||||
listJellyfinLibrariesRuntime,
|
|
||||||
listJellyfinSubtitleTracksRuntime,
|
|
||||||
resolveJellyfinPlaybackPlanRuntime,
|
|
||||||
sendMpvCommandRuntime,
|
|
||||||
} from '../core/services';
|
|
||||||
import type { MpvIpcClient } from '../core/services/mpv';
|
|
||||||
import type { JellyfinSetupWindowLike } from './jellyfin-runtime';
|
|
||||||
import { createJellyfinRuntime } from './jellyfin-runtime';
|
|
||||||
|
|
||||||
export interface JellyfinRuntimeCoordinatorInput {
|
|
||||||
getResolvedConfig: Parameters<typeof createJellyfinRuntime>[0]['getResolvedConfig'];
|
|
||||||
configService: {
|
|
||||||
patchRawConfig: Parameters<typeof createJellyfinRuntime>[0]['patchRawConfig'];
|
|
||||||
};
|
|
||||||
tokenStore: Parameters<typeof createJellyfinRuntime>[0]['tokenStore'];
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
execPath: string;
|
|
||||||
defaultMpvLogPath: string;
|
|
||||||
defaultMpvArgs: readonly string[];
|
|
||||||
connectTimeoutMs: number;
|
|
||||||
autoLaunchTimeoutMs: number;
|
|
||||||
langPref: string;
|
|
||||||
progressIntervalMs: number;
|
|
||||||
ticksPerSecond: number;
|
|
||||||
appState: {
|
|
||||||
mpvSocketPath: string;
|
|
||||||
mpvClient: MpvIpcClient | null;
|
|
||||||
jellyfinSetupWindow: BrowserWindow | null;
|
|
||||||
};
|
|
||||||
actions: {
|
|
||||||
createMpvClient: () => MpvIpcClient;
|
|
||||||
applyJellyfinMpvDefaults: (client: MpvIpcClient) => void;
|
|
||||||
showMpvOsd: (message: string) => void;
|
|
||||||
};
|
|
||||||
logger: {
|
|
||||||
info: (message: string) => void;
|
|
||||||
warn: (message: string, details?: unknown) => void;
|
|
||||||
debug: (message: string, details?: unknown) => void;
|
|
||||||
error: (message: string, error?: unknown) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createJellyfinRuntimeCoordinator(input: JellyfinRuntimeCoordinatorInput) {
|
|
||||||
return createJellyfinRuntime<JellyfinSetupWindowLike>({
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
getEnv: (name) => process.env[name],
|
|
||||||
patchRawConfig: (patch) => {
|
|
||||||
input.configService.patchRawConfig(patch);
|
|
||||||
},
|
|
||||||
defaultJellyfinConfig: DEFAULT_CONFIG.jellyfin,
|
|
||||||
tokenStore: input.tokenStore,
|
|
||||||
platform: input.platform,
|
|
||||||
execPath: input.execPath,
|
|
||||||
defaultMpvLogPath: input.defaultMpvLogPath,
|
|
||||||
defaultMpvArgs: [...input.defaultMpvArgs],
|
|
||||||
connectTimeoutMs: input.connectTimeoutMs,
|
|
||||||
autoLaunchTimeoutMs: input.autoLaunchTimeoutMs,
|
|
||||||
langPref: input.langPref,
|
|
||||||
progressIntervalMs: input.progressIntervalMs,
|
|
||||||
ticksPerSecond: input.ticksPerSecond,
|
|
||||||
getMpvSocketPath: () => input.appState.mpvSocketPath,
|
|
||||||
getMpvClient: () => input.appState.mpvClient,
|
|
||||||
setMpvClient: (client) => {
|
|
||||||
input.appState.mpvClient = client as MpvIpcClient | null;
|
|
||||||
},
|
|
||||||
createMpvClient: () => input.actions.createMpvClient(),
|
|
||||||
sendMpvCommand: (client, command) => sendMpvCommandRuntime(client as MpvIpcClient, command),
|
|
||||||
applyJellyfinMpvDefaults: (client) =>
|
|
||||||
input.actions.applyJellyfinMpvDefaults(client as MpvIpcClient),
|
|
||||||
showMpvOsd: (message) => input.actions.showMpvOsd(message),
|
|
||||||
removeSocketPath: (socketPath) => {
|
|
||||||
fs.rmSync(socketPath, { force: true });
|
|
||||||
},
|
|
||||||
spawnMpv: (args) =>
|
|
||||||
spawn('mpv', args, {
|
|
||||||
detached: true,
|
|
||||||
stdio: 'ignore',
|
|
||||||
}),
|
|
||||||
wait: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
|
|
||||||
authenticateWithPassword: (serverUrl, username, password, clientInfo) =>
|
|
||||||
authenticateWithPasswordRuntime(
|
|
||||||
serverUrl,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
clientInfo as Parameters<typeof authenticateWithPasswordRuntime>[3],
|
|
||||||
),
|
|
||||||
listJellyfinLibraries: (session, clientInfo) =>
|
|
||||||
listJellyfinLibrariesRuntime(
|
|
||||||
session as Parameters<typeof listJellyfinLibrariesRuntime>[0],
|
|
||||||
clientInfo as Parameters<typeof listJellyfinLibrariesRuntime>[1],
|
|
||||||
),
|
|
||||||
listJellyfinItems: (session, clientInfo, params) =>
|
|
||||||
listJellyfinItemsRuntime(
|
|
||||||
session as Parameters<typeof listJellyfinItemsRuntime>[0],
|
|
||||||
clientInfo as Parameters<typeof listJellyfinItemsRuntime>[1],
|
|
||||||
params as Parameters<typeof listJellyfinItemsRuntime>[2],
|
|
||||||
),
|
|
||||||
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
|
|
||||||
listJellyfinSubtitleTracksRuntime(
|
|
||||||
session as Parameters<typeof listJellyfinSubtitleTracksRuntime>[0],
|
|
||||||
clientInfo as Parameters<typeof listJellyfinSubtitleTracksRuntime>[1],
|
|
||||||
itemId,
|
|
||||||
),
|
|
||||||
writeJellyfinPreviewAuth: (responsePath, payload) => {
|
|
||||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
|
||||||
fs.writeFileSync(responsePath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
||||||
},
|
|
||||||
resolvePlaybackPlan: (params) =>
|
|
||||||
resolveJellyfinPlaybackPlanRuntime(
|
|
||||||
(params as { session: Parameters<typeof resolveJellyfinPlaybackPlanRuntime>[0] }).session,
|
|
||||||
(params as { clientInfo: Parameters<typeof resolveJellyfinPlaybackPlanRuntime>[1] })
|
|
||||||
.clientInfo,
|
|
||||||
(
|
|
||||||
params as {
|
|
||||||
jellyfinConfig: ReturnType<
|
|
||||||
JellyfinRuntimeCoordinatorInput['getResolvedConfig']
|
|
||||||
>['jellyfin'];
|
|
||||||
}
|
|
||||||
).jellyfinConfig,
|
|
||||||
{
|
|
||||||
itemId: (params as { itemId: string }).itemId,
|
|
||||||
audioStreamIndex:
|
|
||||||
(params as { audioStreamIndex?: number | null }).audioStreamIndex ?? undefined,
|
|
||||||
subtitleStreamIndex:
|
|
||||||
(params as { subtitleStreamIndex?: number | null }).subtitleStreamIndex ?? undefined,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks),
|
|
||||||
createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options as never),
|
|
||||||
defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId,
|
|
||||||
defaultClientName: DEFAULT_CONFIG.jellyfin.clientName,
|
|
||||||
defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion,
|
|
||||||
createBrowserWindow: (options) => {
|
|
||||||
const window = new BrowserWindow(options);
|
|
||||||
input.appState.jellyfinSetupWindow = window;
|
|
||||||
window.on('closed', () => {
|
|
||||||
input.appState.jellyfinSetupWindow = null;
|
|
||||||
});
|
|
||||||
return window as unknown as JellyfinSetupWindowLike;
|
|
||||||
},
|
|
||||||
encodeURIComponent: (value) => encodeURIComponent(value),
|
|
||||||
logInfo: (message) => input.logger.info(message),
|
|
||||||
logWarn: (message, details) => input.logger.warn(message, details),
|
|
||||||
logDebug: (message, details) => input.logger.debug(message, details),
|
|
||||||
logError: (message, error) => input.logger.error(message, error),
|
|
||||||
now: () => Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import type { JellyfinRuntimeInput } from './jellyfin-runtime';
|
|
||||||
import { createJellyfinRuntime } from './jellyfin-runtime';
|
|
||||||
|
|
||||||
test('jellyfin runtime reuses existing setup window', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let windowCount = 0;
|
|
||||||
|
|
||||||
const runtime = createJellyfinRuntime({
|
|
||||||
getResolvedConfig: () =>
|
|
||||||
({
|
|
||||||
jellyfin: {
|
|
||||||
enabled: true,
|
|
||||||
serverUrl: 'https://media.example',
|
|
||||||
username: 'demo',
|
|
||||||
},
|
|
||||||
}) as ReturnType<JellyfinRuntimeInput['getResolvedConfig']>,
|
|
||||||
getEnv: () => undefined,
|
|
||||||
patchRawConfig: () => {},
|
|
||||||
defaultJellyfinConfig: {
|
|
||||||
enabled: false,
|
|
||||||
serverUrl: '',
|
|
||||||
username: '',
|
|
||||||
} as JellyfinRuntimeInput['defaultJellyfinConfig'],
|
|
||||||
tokenStore: {
|
|
||||||
loadSession: () => null,
|
|
||||||
saveSession: () => {},
|
|
||||||
clearSession: () => {},
|
|
||||||
},
|
|
||||||
platform: 'linux',
|
|
||||||
execPath: '/usr/bin/electron',
|
|
||||||
defaultMpvLogPath: '/tmp/mpv.log',
|
|
||||||
defaultMpvArgs: ['--idle=yes'],
|
|
||||||
connectTimeoutMs: 1000,
|
|
||||||
autoLaunchTimeoutMs: 1000,
|
|
||||||
langPref: 'ja,en',
|
|
||||||
progressIntervalMs: 3000,
|
|
||||||
ticksPerSecond: 10_000_000,
|
|
||||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
|
||||||
getMpvClient: () => null,
|
|
||||||
setMpvClient: () => {},
|
|
||||||
createMpvClient: () => ({}),
|
|
||||||
sendMpvCommand: () => {},
|
|
||||||
applyJellyfinMpvDefaults: () => {},
|
|
||||||
showMpvOsd: () => {},
|
|
||||||
removeSocketPath: () => {},
|
|
||||||
spawnMpv: () => ({}),
|
|
||||||
wait: async () => {},
|
|
||||||
authenticateWithPassword: async () => {
|
|
||||||
throw new Error('not used');
|
|
||||||
},
|
|
||||||
listJellyfinLibraries: async () => [],
|
|
||||||
listJellyfinItems: async () => [],
|
|
||||||
listJellyfinSubtitleTracks: async () => [],
|
|
||||||
writeJellyfinPreviewAuth: () => {},
|
|
||||||
resolvePlaybackPlan: async () => ({}),
|
|
||||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
|
||||||
createRemoteSessionService: () => ({}),
|
|
||||||
defaultDeviceId: 'device',
|
|
||||||
defaultClientName: 'SubMiner',
|
|
||||||
defaultClientVersion: '1.0.0',
|
|
||||||
createBrowserWindow: () => {
|
|
||||||
windowCount += 1;
|
|
||||||
return {
|
|
||||||
webContents: {
|
|
||||||
on: () => {},
|
|
||||||
},
|
|
||||||
loadURL: () => {
|
|
||||||
calls.push('loadURL');
|
|
||||||
},
|
|
||||||
on: () => {},
|
|
||||||
focus: () => {
|
|
||||||
calls.push('focus');
|
|
||||||
},
|
|
||||||
close: () => {},
|
|
||||||
isDestroyed: () => false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
encodeURIComponent: (value) => encodeURIComponent(value),
|
|
||||||
logInfo: () => {},
|
|
||||||
logWarn: () => {},
|
|
||||||
logDebug: () => {},
|
|
||||||
logError: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
runtime.openJellyfinSetupWindow();
|
|
||||||
runtime.openJellyfinSetupWindow();
|
|
||||||
|
|
||||||
assert.equal(windowCount, 1);
|
|
||||||
assert.deepEqual(calls, ['loadURL', 'focus']);
|
|
||||||
assert.equal(runtime.getQuitOnDisconnectArmed(), false);
|
|
||||||
assert.ok(runtime.getSetupWindow());
|
|
||||||
});
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
import type { CliArgs } from '../cli/args';
|
|
||||||
import {
|
|
||||||
buildJellyfinSetupFormHtml,
|
|
||||||
getConfiguredJellyfinSession,
|
|
||||||
parseJellyfinSetupSubmissionUrl,
|
|
||||||
} from './runtime/domains/jellyfin';
|
|
||||||
import {
|
|
||||||
composeJellyfinRuntimeHandlers,
|
|
||||||
type JellyfinRuntimeComposerOptions,
|
|
||||||
} from './runtime/composers/jellyfin-runtime-composer';
|
|
||||||
import { createCreateJellyfinSetupWindowHandler } from './runtime/setup-window-factory';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helper: extract each dep-block's type from the composer options.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type Deps<K extends keyof JellyfinRuntimeComposerOptions> = JellyfinRuntimeComposerOptions[K];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Resolved-config shape (extracted from composer).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type ResolvedConfigShape =
|
|
||||||
Deps<'getResolvedJellyfinConfigMainDeps'> extends {
|
|
||||||
getResolvedConfig: () => infer R;
|
|
||||||
}
|
|
||||||
? R
|
|
||||||
: never;
|
|
||||||
type JellyfinConfigShape = ResolvedConfigShape extends { jellyfin: infer J } ? J : never;
|
|
||||||
|
|
||||||
/** Stored-session shape (what the token store persists). */
|
|
||||||
type StoredSessionShape = { accessToken: string; userId: string };
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public interfaces
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface JellyfinSessionStoreLike {
|
|
||||||
loadSession: () => StoredSessionShape | null | undefined;
|
|
||||||
saveSession: (session: StoredSessionShape) => void;
|
|
||||||
clearSession: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JellyfinSetupWindowLike {
|
|
||||||
webContents: {
|
|
||||||
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
|
|
||||||
};
|
|
||||||
loadURL: (url: string) => Promise<void> | void;
|
|
||||||
on: (event: 'closed', handler: () => void) => void;
|
|
||||||
focus: () => void;
|
|
||||||
close: () => void;
|
|
||||||
isDestroyed: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Input for createJellyfinRuntime.
|
|
||||||
*
|
|
||||||
* Fields whose types vary across handler files (MpvClient, Session, ClientInfo,
|
|
||||||
* RemoteSessionService, etc.) are typed as `unknown`. The factory body bridges
|
|
||||||
* these to the handler-specific structural types via per-dep-block type
|
|
||||||
* annotations (`Deps<K>`) with targeted `as` casts on the individual
|
|
||||||
* function references. This keeps the public-facing input surface simple and
|
|
||||||
* avoids 7+ generic type parameters that previously required `as never` casts.
|
|
||||||
*/
|
|
||||||
export interface JellyfinRuntimeInput<
|
|
||||||
TSetupWindow extends JellyfinSetupWindowLike = JellyfinSetupWindowLike,
|
|
||||||
> {
|
|
||||||
getResolvedConfig: () => ResolvedConfigShape;
|
|
||||||
getEnv: (name: string) => string | undefined;
|
|
||||||
patchRawConfig: (patch: unknown) => void;
|
|
||||||
defaultJellyfinConfig: JellyfinConfigShape;
|
|
||||||
tokenStore: JellyfinSessionStoreLike;
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
execPath: string;
|
|
||||||
defaultMpvLogPath: string;
|
|
||||||
defaultMpvArgs: readonly string[];
|
|
||||||
connectTimeoutMs: number;
|
|
||||||
autoLaunchTimeoutMs: number;
|
|
||||||
langPref: string;
|
|
||||||
progressIntervalMs: number;
|
|
||||||
ticksPerSecond: number;
|
|
||||||
getMpvSocketPath: () => string;
|
|
||||||
getMpvClient: () => unknown;
|
|
||||||
setMpvClient: (client: unknown) => void;
|
|
||||||
createMpvClient: () => unknown;
|
|
||||||
sendMpvCommand: (client: unknown, command: Array<string | number>) => void;
|
|
||||||
applyJellyfinMpvDefaults: (client: unknown) => void;
|
|
||||||
showMpvOsd: (message: string) => void;
|
|
||||||
removeSocketPath: (socketPath: string) => void;
|
|
||||||
spawnMpv: (args: string[]) => unknown;
|
|
||||||
wait: (delayMs: number) => Promise<void>;
|
|
||||||
authenticateWithPassword: (
|
|
||||||
serverUrl: string,
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
clientInfo: unknown,
|
|
||||||
) => Promise<unknown>;
|
|
||||||
listJellyfinLibraries: (session: unknown, clientInfo: unknown) => Promise<unknown>;
|
|
||||||
listJellyfinItems: (session: unknown, clientInfo: unknown, params: unknown) => Promise<unknown>;
|
|
||||||
listJellyfinSubtitleTracks: (
|
|
||||||
session: unknown,
|
|
||||||
clientInfo: unknown,
|
|
||||||
itemId: string,
|
|
||||||
) => Promise<unknown>;
|
|
||||||
writeJellyfinPreviewAuth: (responsePath: string, payload: unknown) => void;
|
|
||||||
resolvePlaybackPlan: (params: unknown) => Promise<unknown>;
|
|
||||||
convertTicksToSeconds: (ticks: number) => number;
|
|
||||||
createRemoteSessionService: (options: unknown) => unknown;
|
|
||||||
defaultDeviceId: string;
|
|
||||||
defaultClientName: string;
|
|
||||||
defaultClientVersion: string;
|
|
||||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TSetupWindow;
|
|
||||||
encodeURIComponent: (value: string) => string;
|
|
||||||
logInfo: (message: string) => void;
|
|
||||||
logWarn: (message: string, details?: unknown) => void;
|
|
||||||
logDebug: (message: string, details?: unknown) => void;
|
|
||||||
logError: (message: string, error: unknown) => void;
|
|
||||||
now?: () => number;
|
|
||||||
schedule?: (callback: () => void, delayMs: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JellyfinRuntime<
|
|
||||||
TSetupWindow extends JellyfinSetupWindowLike = JellyfinSetupWindowLike,
|
|
||||||
> {
|
|
||||||
getResolvedJellyfinConfig: () => JellyfinConfigShape;
|
|
||||||
reportJellyfinRemoteProgress: (forceImmediate?: boolean) => Promise<void>;
|
|
||||||
reportJellyfinRemoteStopped: () => Promise<void>;
|
|
||||||
startJellyfinRemoteSession: () => Promise<void>;
|
|
||||||
stopJellyfinRemoteSession: () => Promise<void>;
|
|
||||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
|
||||||
openJellyfinSetupWindow: () => void;
|
|
||||||
getQuitOnDisconnectArmed: () => boolean;
|
|
||||||
clearQuitOnDisconnectArm: () => void;
|
|
||||||
getRemoteSession: () => unknown;
|
|
||||||
getSetupWindow: () => TSetupWindow | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createJellyfinRuntime<TSetupWindow extends JellyfinSetupWindowLike>(
|
|
||||||
input: JellyfinRuntimeInput<TSetupWindow>,
|
|
||||||
): JellyfinRuntime<TSetupWindow> {
|
|
||||||
const now = input.now ?? Date.now;
|
|
||||||
const schedule =
|
|
||||||
input.schedule ??
|
|
||||||
((callback: () => void, delayMs: number) => {
|
|
||||||
setTimeout(callback, delayMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
let playQuitOnDisconnectArmed = false;
|
|
||||||
let activePlayback: unknown = null;
|
|
||||||
let lastProgressAtMs = 0;
|
|
||||||
let mpvAutoLaunchInFlight: Promise<boolean> | null = null;
|
|
||||||
let remoteSession: unknown = null;
|
|
||||||
let setupWindow: TSetupWindow | null = null;
|
|
||||||
|
|
||||||
// Each dep block is typed with Deps<K> so TypeScript verifies structural
|
|
||||||
// compatibility with the composer. The `as Deps<K>[field]` casts on
|
|
||||||
// function references bridge `unknown`-typed input methods to the
|
|
||||||
// handler-specific structural types. This replaces 23 `as never` casts
|
|
||||||
// with targeted, auditable type assertions.
|
|
||||||
|
|
||||||
const getResolvedJellyfinConfigMainDeps: Deps<'getResolvedJellyfinConfigMainDeps'> = {
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
loadStoredSession: () => input.tokenStore.loadSession(),
|
|
||||||
getEnv: (name) => input.getEnv(name),
|
|
||||||
};
|
|
||||||
|
|
||||||
const getJellyfinClientInfoMainDeps: Deps<'getJellyfinClientInfoMainDeps'> = {
|
|
||||||
getResolvedJellyfinConfig: () => input.getResolvedConfig().jellyfin,
|
|
||||||
getDefaultJellyfinConfig: () => input.defaultJellyfinConfig,
|
|
||||||
};
|
|
||||||
|
|
||||||
const waitForMpvConnectedMainDeps: Deps<'waitForMpvConnectedMainDeps'> = {
|
|
||||||
getMpvClient: input.getMpvClient as Deps<'waitForMpvConnectedMainDeps'>['getMpvClient'],
|
|
||||||
now,
|
|
||||||
sleep: (delayMs) => input.wait(delayMs),
|
|
||||||
};
|
|
||||||
|
|
||||||
const launchMpvIdleForJellyfinPlaybackMainDeps: Deps<'launchMpvIdleForJellyfinPlaybackMainDeps'> =
|
|
||||||
{
|
|
||||||
getSocketPath: () => input.getMpvSocketPath(),
|
|
||||||
platform: input.platform,
|
|
||||||
execPath: input.execPath,
|
|
||||||
defaultMpvLogPath: input.defaultMpvLogPath,
|
|
||||||
defaultMpvArgs: input.defaultMpvArgs,
|
|
||||||
removeSocketPath: (socketPath) => input.removeSocketPath(socketPath),
|
|
||||||
spawnMpv: input.spawnMpv as Deps<'launchMpvIdleForJellyfinPlaybackMainDeps'>['spawnMpv'],
|
|
||||||
logWarn: (message, error) => input.logWarn(message, error),
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureMpvConnectedForJellyfinPlaybackMainDeps: Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'> =
|
|
||||||
{
|
|
||||||
getMpvClient:
|
|
||||||
input.getMpvClient as Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'>['getMpvClient'],
|
|
||||||
setMpvClient:
|
|
||||||
input.setMpvClient as Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'>['setMpvClient'],
|
|
||||||
createMpvClient:
|
|
||||||
input.createMpvClient as Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'>['createMpvClient'],
|
|
||||||
getAutoLaunchInFlight: () => mpvAutoLaunchInFlight,
|
|
||||||
setAutoLaunchInFlight: (promise) => {
|
|
||||||
mpvAutoLaunchInFlight = promise;
|
|
||||||
},
|
|
||||||
connectTimeoutMs: input.connectTimeoutMs,
|
|
||||||
autoLaunchTimeoutMs: input.autoLaunchTimeoutMs,
|
|
||||||
};
|
|
||||||
|
|
||||||
const preloadJellyfinExternalSubtitlesMainDeps: Deps<'preloadJellyfinExternalSubtitlesMainDeps'> =
|
|
||||||
{
|
|
||||||
listJellyfinSubtitleTracks:
|
|
||||||
input.listJellyfinSubtitleTracks as Deps<'preloadJellyfinExternalSubtitlesMainDeps'>['listJellyfinSubtitleTracks'],
|
|
||||||
getMpvClient:
|
|
||||||
input.getMpvClient as Deps<'preloadJellyfinExternalSubtitlesMainDeps'>['getMpvClient'],
|
|
||||||
sendMpvCommand: (command) => input.sendMpvCommand(input.getMpvClient(), command),
|
|
||||||
wait: (delayMs) => input.wait(delayMs),
|
|
||||||
logDebug: (message, error) => input.logDebug(message, error),
|
|
||||||
};
|
|
||||||
|
|
||||||
const playJellyfinItemInMpvMainDeps: Deps<'playJellyfinItemInMpvMainDeps'> = {
|
|
||||||
getMpvClient: input.getMpvClient as Deps<'playJellyfinItemInMpvMainDeps'>['getMpvClient'],
|
|
||||||
resolvePlaybackPlan:
|
|
||||||
input.resolvePlaybackPlan as Deps<'playJellyfinItemInMpvMainDeps'>['resolvePlaybackPlan'],
|
|
||||||
applyJellyfinMpvDefaults:
|
|
||||||
input.applyJellyfinMpvDefaults as Deps<'playJellyfinItemInMpvMainDeps'>['applyJellyfinMpvDefaults'],
|
|
||||||
sendMpvCommand: (command) => input.sendMpvCommand(input.getMpvClient(), command),
|
|
||||||
armQuitOnDisconnect: () => {
|
|
||||||
playQuitOnDisconnectArmed = false;
|
|
||||||
schedule(() => {
|
|
||||||
playQuitOnDisconnectArmed = true;
|
|
||||||
}, 3000);
|
|
||||||
},
|
|
||||||
schedule: (callback, delayMs) => {
|
|
||||||
schedule(callback, delayMs);
|
|
||||||
},
|
|
||||||
convertTicksToSeconds: (ticks) => input.convertTicksToSeconds(ticks),
|
|
||||||
setActivePlayback: (state) => {
|
|
||||||
activePlayback = state;
|
|
||||||
},
|
|
||||||
setLastProgressAtMs: (value) => {
|
|
||||||
lastProgressAtMs = value;
|
|
||||||
},
|
|
||||||
reportPlaying: (payload) => {
|
|
||||||
const session = remoteSession as { reportPlaying?: (payload: unknown) => unknown } | null;
|
|
||||||
if (typeof session?.reportPlaying === 'function') {
|
|
||||||
void session.reportPlaying(payload);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
showMpvOsd: (message) => input.showMpvOsd(message),
|
|
||||||
};
|
|
||||||
|
|
||||||
const remoteComposerBase: Omit<Deps<'remoteComposerOptions'>, 'getConfiguredSession'> = {
|
|
||||||
logWarn: (message) => input.logWarn(message),
|
|
||||||
getMpvClient: input.getMpvClient as Deps<'remoteComposerOptions'>['getMpvClient'],
|
|
||||||
sendMpvCommand: input.sendMpvCommand as Deps<'remoteComposerOptions'>['sendMpvCommand'],
|
|
||||||
jellyfinTicksToSeconds: (ticks) => input.convertTicksToSeconds(ticks),
|
|
||||||
getActivePlayback: () =>
|
|
||||||
activePlayback as ReturnType<Deps<'remoteComposerOptions'>['getActivePlayback']>,
|
|
||||||
clearActivePlayback: () => {
|
|
||||||
activePlayback = null;
|
|
||||||
},
|
|
||||||
getSession: () => remoteSession as ReturnType<Deps<'remoteComposerOptions'>['getSession']>,
|
|
||||||
getNow: now,
|
|
||||||
getLastProgressAtMs: () => lastProgressAtMs,
|
|
||||||
setLastProgressAtMs: (value) => {
|
|
||||||
lastProgressAtMs = value;
|
|
||||||
},
|
|
||||||
progressIntervalMs: input.progressIntervalMs,
|
|
||||||
ticksPerSecond: input.ticksPerSecond,
|
|
||||||
logDebug: (message, error) => input.logDebug(message, error),
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJellyfinAuthCommandsMainDeps: Deps<'handleJellyfinAuthCommandsMainDeps'> = {
|
|
||||||
patchRawConfig: (patch) => input.patchRawConfig(patch),
|
|
||||||
authenticateWithPassword:
|
|
||||||
input.authenticateWithPassword as Deps<'handleJellyfinAuthCommandsMainDeps'>['authenticateWithPassword'],
|
|
||||||
saveStoredSession: (session) => input.tokenStore.saveSession(session as StoredSessionShape),
|
|
||||||
clearStoredSession: () => input.tokenStore.clearSession(),
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJellyfinListCommandsMainDeps: Deps<'handleJellyfinListCommandsMainDeps'> = {
|
|
||||||
listJellyfinLibraries:
|
|
||||||
input.listJellyfinLibraries as Deps<'handleJellyfinListCommandsMainDeps'>['listJellyfinLibraries'],
|
|
||||||
listJellyfinItems:
|
|
||||||
input.listJellyfinItems as Deps<'handleJellyfinListCommandsMainDeps'>['listJellyfinItems'],
|
|
||||||
listJellyfinSubtitleTracks:
|
|
||||||
input.listJellyfinSubtitleTracks as Deps<'handleJellyfinListCommandsMainDeps'>['listJellyfinSubtitleTracks'],
|
|
||||||
writeJellyfinPreviewAuth: (responsePath, payload) =>
|
|
||||||
input.writeJellyfinPreviewAuth(responsePath, payload),
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJellyfinPlayCommandMainDeps: Deps<'handleJellyfinPlayCommandMainDeps'> = {
|
|
||||||
logWarn: (message) => input.logWarn(message),
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJellyfinRemoteAnnounceCommandMainDeps: Deps<'handleJellyfinRemoteAnnounceCommandMainDeps'> =
|
|
||||||
{
|
|
||||||
getRemoteSession: () =>
|
|
||||||
remoteSession as ReturnType<
|
|
||||||
Deps<'handleJellyfinRemoteAnnounceCommandMainDeps'>['getRemoteSession']
|
|
||||||
>,
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
logWarn: (message) => input.logWarn(message),
|
|
||||||
};
|
|
||||||
|
|
||||||
const startJellyfinRemoteSessionMainDeps: Deps<'startJellyfinRemoteSessionMainDeps'> = {
|
|
||||||
getCurrentSession: () =>
|
|
||||||
remoteSession as ReturnType<Deps<'startJellyfinRemoteSessionMainDeps'>['getCurrentSession']>,
|
|
||||||
setCurrentSession: (session) => {
|
|
||||||
remoteSession = session;
|
|
||||||
},
|
|
||||||
createRemoteSessionService:
|
|
||||||
input.createRemoteSessionService as Deps<'startJellyfinRemoteSessionMainDeps'>['createRemoteSessionService'],
|
|
||||||
defaultDeviceId: input.defaultDeviceId,
|
|
||||||
defaultClientName: input.defaultClientName,
|
|
||||||
defaultClientVersion: input.defaultClientVersion,
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
logWarn: (message, details) => input.logWarn(message, details),
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopJellyfinRemoteSessionMainDeps: Deps<'stopJellyfinRemoteSessionMainDeps'> = {
|
|
||||||
getCurrentSession: () =>
|
|
||||||
remoteSession as ReturnType<Deps<'stopJellyfinRemoteSessionMainDeps'>['getCurrentSession']>,
|
|
||||||
setCurrentSession: (session) => {
|
|
||||||
remoteSession = session;
|
|
||||||
},
|
|
||||||
clearActivePlayback: () => {
|
|
||||||
activePlayback = null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const runJellyfinCommandMainDeps: Deps<'runJellyfinCommandMainDeps'> = {
|
|
||||||
defaultServerUrl: input.defaultJellyfinConfig.serverUrl,
|
|
||||||
};
|
|
||||||
|
|
||||||
const maybeFocusExistingJellyfinSetupWindowMainDeps: Deps<'maybeFocusExistingJellyfinSetupWindowMainDeps'> =
|
|
||||||
{
|
|
||||||
getSetupWindow: () => setupWindow,
|
|
||||||
};
|
|
||||||
|
|
||||||
const openJellyfinSetupWindowMainDeps: Deps<'openJellyfinSetupWindowMainDeps'> = {
|
|
||||||
createSetupWindow: createCreateJellyfinSetupWindowHandler({
|
|
||||||
createBrowserWindow: (options) => input.createBrowserWindow(options),
|
|
||||||
}),
|
|
||||||
buildSetupFormHtml: (defaultServer, defaultUser) =>
|
|
||||||
buildJellyfinSetupFormHtml(defaultServer, defaultUser),
|
|
||||||
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
|
|
||||||
authenticateWithPassword:
|
|
||||||
input.authenticateWithPassword as Deps<'openJellyfinSetupWindowMainDeps'>['authenticateWithPassword'],
|
|
||||||
saveStoredSession: (session) => input.tokenStore.saveSession(session as StoredSessionShape),
|
|
||||||
patchJellyfinConfig: (session) => {
|
|
||||||
const jellyfinSession = session as { serverUrl?: string; username?: string };
|
|
||||||
input.patchRawConfig({
|
|
||||||
jellyfin: {
|
|
||||||
enabled: true,
|
|
||||||
serverUrl: jellyfinSession.serverUrl,
|
|
||||||
username: jellyfinSession.username,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
logError: (message, error) => input.logError(message, error),
|
|
||||||
showMpvOsd: (message) => input.showMpvOsd(message),
|
|
||||||
clearSetupWindow: () => {
|
|
||||||
setupWindow = null;
|
|
||||||
},
|
|
||||||
setSetupWindow: (window) => {
|
|
||||||
setupWindow = window as TSetupWindow | null;
|
|
||||||
},
|
|
||||||
encodeURIComponent: (value) => input.encodeURIComponent(value),
|
|
||||||
};
|
|
||||||
|
|
||||||
const runtime = composeJellyfinRuntimeHandlers({
|
|
||||||
getResolvedJellyfinConfigMainDeps,
|
|
||||||
getJellyfinClientInfoMainDeps,
|
|
||||||
waitForMpvConnectedMainDeps,
|
|
||||||
launchMpvIdleForJellyfinPlaybackMainDeps,
|
|
||||||
ensureMpvConnectedForJellyfinPlaybackMainDeps,
|
|
||||||
preloadJellyfinExternalSubtitlesMainDeps,
|
|
||||||
playJellyfinItemInMpvMainDeps,
|
|
||||||
remoteComposerOptions: {
|
|
||||||
...remoteComposerBase,
|
|
||||||
getConfiguredSession: () => getConfiguredJellyfinSession(runtime.getResolvedJellyfinConfig()),
|
|
||||||
},
|
|
||||||
handleJellyfinAuthCommandsMainDeps,
|
|
||||||
handleJellyfinListCommandsMainDeps,
|
|
||||||
handleJellyfinPlayCommandMainDeps,
|
|
||||||
handleJellyfinRemoteAnnounceCommandMainDeps,
|
|
||||||
startJellyfinRemoteSessionMainDeps,
|
|
||||||
stopJellyfinRemoteSessionMainDeps,
|
|
||||||
runJellyfinCommandMainDeps,
|
|
||||||
maybeFocusExistingJellyfinSetupWindowMainDeps,
|
|
||||||
openJellyfinSetupWindowMainDeps,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
getResolvedJellyfinConfig: () => runtime.getResolvedJellyfinConfig(),
|
|
||||||
reportJellyfinRemoteProgress: async (forceImmediate) => {
|
|
||||||
await runtime.reportJellyfinRemoteProgress(forceImmediate);
|
|
||||||
},
|
|
||||||
reportJellyfinRemoteStopped: async () => {
|
|
||||||
await runtime.reportJellyfinRemoteStopped();
|
|
||||||
},
|
|
||||||
startJellyfinRemoteSession: async () => {
|
|
||||||
await runtime.startJellyfinRemoteSession();
|
|
||||||
},
|
|
||||||
stopJellyfinRemoteSession: async () => {
|
|
||||||
await runtime.stopJellyfinRemoteSession();
|
|
||||||
},
|
|
||||||
runJellyfinCommand: async (args) => {
|
|
||||||
await runtime.runJellyfinCommand(args);
|
|
||||||
},
|
|
||||||
openJellyfinSetupWindow: () => {
|
|
||||||
runtime.openJellyfinSetupWindow();
|
|
||||||
},
|
|
||||||
getQuitOnDisconnectArmed: () => playQuitOnDisconnectArmed,
|
|
||||||
clearQuitOnDisconnectArm: () => {
|
|
||||||
playQuitOnDisconnectArmed = false;
|
|
||||||
},
|
|
||||||
getRemoteSession: () => remoteSession,
|
|
||||||
getSetupWindow: () => setupWindow,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { JlptLevel } from '../types';
|
import type { JlptLevel } from '../types';
|
||||||
|
|
||||||
import { createJlptVocabularyLookup } from '../core/services/jlpt-vocab';
|
import { createJlptVocabularyLookup } from '../core/services';
|
||||||
|
|
||||||
export interface JlptDictionarySearchPathDeps {
|
export interface JlptDictionarySearchPathDeps {
|
||||||
getDictionaryRoots: () => string[];
|
getDictionaryRoots: () => string[];
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
import * as fs from 'node:fs';
|
|
||||||
import * as path from 'node:path';
|
|
||||||
import type { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import { createAnilistTokenStore } from '../core/services/anilist/anilist-token-store';
|
|
||||||
import { createJellyfinTokenStore } from '../core/services/jellyfin-token-store';
|
|
||||||
import { createAnilistUpdateQueue } from '../core/services/anilist/anilist-update-queue';
|
|
||||||
import {
|
|
||||||
SubtitleWebSocket,
|
|
||||||
createOverlayContentMeasurementStore,
|
|
||||||
createOverlayManager,
|
|
||||||
} from '../core/services';
|
|
||||||
import { ConfigService } from '../config';
|
|
||||||
import { resolveConfigDir } from '../config/path-resolution';
|
|
||||||
import { createAppState } from './state';
|
|
||||||
import {
|
|
||||||
createMainBootServices,
|
|
||||||
type AppLifecycleShape,
|
|
||||||
type MainBootServicesResult,
|
|
||||||
} from './boot/services';
|
|
||||||
import { createLogger } from '../logger';
|
|
||||||
import { createMainRuntimeRegistry } from './runtime/registry';
|
|
||||||
import { createOverlayModalInputState } from './runtime/overlay-modal-input-state';
|
|
||||||
import { createOverlayModalRuntimeService } from './overlay-runtime';
|
|
||||||
import { buildConfigParseErrorDetails, failStartupFromConfig } from './config-validation';
|
|
||||||
import {
|
|
||||||
registerSecondInstanceHandlerEarly,
|
|
||||||
requestSingleInstanceLockEarly,
|
|
||||||
shouldBypassSingleInstanceLockForArgv,
|
|
||||||
} from './early-single-instance';
|
|
||||||
import {
|
|
||||||
createBuildOverlayContentMeasurementStoreMainDepsHandler,
|
|
||||||
createBuildOverlayModalRuntimeMainDepsHandler,
|
|
||||||
} from './runtime/domains/overlay';
|
|
||||||
import type { WindowGeometry } from '../types';
|
|
||||||
|
|
||||||
export type MainBootRuntime = MainBootServicesResult<
|
|
||||||
ConfigService,
|
|
||||||
ReturnType<typeof createAnilistTokenStore>,
|
|
||||||
ReturnType<typeof createJellyfinTokenStore>,
|
|
||||||
ReturnType<typeof createAnilistUpdateQueue>,
|
|
||||||
SubtitleWebSocket,
|
|
||||||
ReturnType<typeof createLogger>,
|
|
||||||
ReturnType<typeof createMainRuntimeRegistry>,
|
|
||||||
ReturnType<typeof createOverlayManager>,
|
|
||||||
ReturnType<typeof createOverlayModalInputState>,
|
|
||||||
ReturnType<typeof createOverlayContentMeasurementStore>,
|
|
||||||
ReturnType<typeof createOverlayModalRuntimeService>,
|
|
||||||
ReturnType<typeof createAppState>,
|
|
||||||
AppLifecycleShape
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface MainBootRuntimeInput {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
argv: string[];
|
|
||||||
appDataDir: string | undefined;
|
|
||||||
xdgConfigHome: string | undefined;
|
|
||||||
homeDir: string;
|
|
||||||
defaultMpvLogFile: string;
|
|
||||||
envMpvLog: string | undefined;
|
|
||||||
defaultTexthookerPort: number;
|
|
||||||
getDefaultSocketPath: () => string;
|
|
||||||
app: {
|
|
||||||
setPath: (name: string, value: string) => void;
|
|
||||||
quit: () => void;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures
|
|
||||||
on: Function;
|
|
||||||
whenReady: () => Promise<void>;
|
|
||||||
};
|
|
||||||
dialog: {
|
|
||||||
showErrorBox: (title: string, details: string) => void;
|
|
||||||
};
|
|
||||||
overlay: {
|
|
||||||
getSyncOverlayShortcutsForModal: () => (isActive: boolean) => void;
|
|
||||||
getSyncOverlayVisibilityForModal: () => () => void;
|
|
||||||
createModalWindow: () => BrowserWindow;
|
|
||||||
getOverlayGeometry: () => WindowGeometry;
|
|
||||||
};
|
|
||||||
notifications: {
|
|
||||||
notifyAnilistTokenStoreWarning: (message: string) => void;
|
|
||||||
requestAppQuit: () => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMainBootRuntime(input: MainBootRuntimeInput): MainBootRuntime {
|
|
||||||
return createMainBootServices({
|
|
||||||
platform: input.platform,
|
|
||||||
argv: input.argv,
|
|
||||||
appDataDir: input.appDataDir,
|
|
||||||
xdgConfigHome: input.xdgConfigHome,
|
|
||||||
homeDir: input.homeDir,
|
|
||||||
defaultMpvLogFile: input.defaultMpvLogFile,
|
|
||||||
envMpvLog: input.envMpvLog,
|
|
||||||
defaultTexthookerPort: input.defaultTexthookerPort,
|
|
||||||
getDefaultSocketPath: () => input.getDefaultSocketPath(),
|
|
||||||
resolveConfigDir,
|
|
||||||
existsSync: (targetPath) => fs.existsSync(targetPath),
|
|
||||||
mkdirSync: (targetPath, options) => {
|
|
||||||
fs.mkdirSync(targetPath, options);
|
|
||||||
},
|
|
||||||
joinPath: (...parts) => path.join(...parts),
|
|
||||||
app: input.app,
|
|
||||||
shouldBypassSingleInstanceLock: () => shouldBypassSingleInstanceLockForArgv(input.argv),
|
|
||||||
requestSingleInstanceLockEarly: () => requestSingleInstanceLockEarly(input.app as never),
|
|
||||||
registerSecondInstanceHandlerEarly: (listener) => {
|
|
||||||
registerSecondInstanceHandlerEarly(input.app as never, listener);
|
|
||||||
},
|
|
||||||
onConfigStartupParseError: (error) => {
|
|
||||||
failStartupFromConfig(
|
|
||||||
'SubMiner config parse error',
|
|
||||||
buildConfigParseErrorDetails(error.path, error.parseError),
|
|
||||||
{
|
|
||||||
logError: (details) => console.error(details),
|
|
||||||
showErrorBox: (title, details) => input.dialog.showErrorBox(title, details),
|
|
||||||
quit: () => input.notifications.requestAppQuit(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
createConfigService: (configDir) => new ConfigService(configDir),
|
|
||||||
createAnilistTokenStore: (targetPath) =>
|
|
||||||
createAnilistTokenStore(targetPath, {
|
|
||||||
info: (message: string) => console.info(message),
|
|
||||||
warn: (message: string, details?: unknown) => console.warn(message, details),
|
|
||||||
error: (message: string, details?: unknown) => console.error(message, details),
|
|
||||||
warnUser: (message: string) => input.notifications.notifyAnilistTokenStoreWarning(message),
|
|
||||||
}),
|
|
||||||
createJellyfinTokenStore: (targetPath) =>
|
|
||||||
createJellyfinTokenStore(targetPath, {
|
|
||||||
info: (message: string) => console.info(message),
|
|
||||||
warn: (message: string, details?: unknown) => console.warn(message, details),
|
|
||||||
error: (message: string, details?: unknown) => console.error(message, details),
|
|
||||||
}),
|
|
||||||
createAnilistUpdateQueue: (targetPath) =>
|
|
||||||
createAnilistUpdateQueue(targetPath, {
|
|
||||||
info: (message: string) => console.info(message),
|
|
||||||
warn: (message: string, details?: unknown) => console.warn(message, details),
|
|
||||||
error: (message: string, details?: unknown) => console.error(message, details),
|
|
||||||
}),
|
|
||||||
createSubtitleWebSocket: () => new SubtitleWebSocket(),
|
|
||||||
createLogger,
|
|
||||||
createMainRuntimeRegistry,
|
|
||||||
createOverlayManager,
|
|
||||||
createOverlayModalInputState,
|
|
||||||
createOverlayContentMeasurementStore: ({ logger }) =>
|
|
||||||
createOverlayContentMeasurementStore(
|
|
||||||
createBuildOverlayContentMeasurementStoreMainDepsHandler({
|
|
||||||
now: () => Date.now(),
|
|
||||||
warn: (message: string) => logger.warn(message),
|
|
||||||
})(),
|
|
||||||
),
|
|
||||||
getSyncOverlayShortcutsForModal: () => input.overlay.getSyncOverlayShortcutsForModal(),
|
|
||||||
getSyncOverlayVisibilityForModal: () => input.overlay.getSyncOverlayVisibilityForModal(),
|
|
||||||
createOverlayModalRuntime: ({ overlayManager, overlayModalInputState }) =>
|
|
||||||
createOverlayModalRuntimeService(
|
|
||||||
createBuildOverlayModalRuntimeMainDepsHandler({
|
|
||||||
getMainWindow: () => overlayManager.getMainWindow(),
|
|
||||||
getModalWindow: () => overlayManager.getModalWindow(),
|
|
||||||
createModalWindow: () => input.overlay.createModalWindow(),
|
|
||||||
getModalGeometry: () => input.overlay.getOverlayGeometry(),
|
|
||||||
setModalWindowBounds: (geometry) => overlayManager.setModalWindowBounds(geometry),
|
|
||||||
})(),
|
|
||||||
{
|
|
||||||
onModalStateChange: (isActive: boolean) =>
|
|
||||||
overlayModalInputState.handleModalInputStateChange(isActive),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
createAppState,
|
|
||||||
}) as MainBootRuntime;
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import path from 'node:path';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createMainBootServicesBootstrap } from './main-boot-services-bootstrap';
|
|
||||||
|
|
||||||
test('main boot services bootstrap composes grouped inputs into boot services', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const modalWindow = {} as never;
|
|
||||||
const overlayManager = {
|
|
||||||
getModalWindow: () => modalWindow,
|
|
||||||
};
|
|
||||||
type AppStateStub = {
|
|
||||||
kind: 'app-state';
|
|
||||||
input: {
|
|
||||||
mpvSocketPath: string;
|
|
||||||
texthookerPort: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
type OverlayModalRuntimeStub = {
|
|
||||||
kind: 'overlay-modal-runtime';
|
|
||||||
};
|
|
||||||
|
|
||||||
const overlayModalInputState = {
|
|
||||||
kind: 'overlay-modal-input-state',
|
|
||||||
handleModalInputStateChange: (isActive: boolean) => {
|
|
||||||
calls.push(`modal-state:${String(isActive)}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const overlayModalInputStateParams: {
|
|
||||||
getModalWindow: () => unknown;
|
|
||||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
|
||||||
syncOverlayVisibilityForModal: () => void;
|
|
||||||
}[] = [];
|
|
||||||
const createOverlayModalInputState = (params: (typeof overlayModalInputStateParams)[number]) => {
|
|
||||||
overlayModalInputStateParams.push(params);
|
|
||||||
return overlayModalInputState as never;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createOverlayModalRuntime = (params: {
|
|
||||||
onModalStateChange: (isActive: boolean) => void;
|
|
||||||
}) => {
|
|
||||||
calls.push(`modal:${String(params.onModalStateChange(true))}`);
|
|
||||||
return { kind: 'overlay-modal-runtime' } as OverlayModalRuntimeStub;
|
|
||||||
};
|
|
||||||
|
|
||||||
const boot = createMainBootServicesBootstrap({
|
|
||||||
system: {
|
|
||||||
platform: 'darwin',
|
|
||||||
argv: ['node', 'main.js'],
|
|
||||||
appDataDir: '/tmp/app-data',
|
|
||||||
xdgConfigHome: '/tmp/xdg',
|
|
||||||
homeDir: '/Users/test',
|
|
||||||
defaultMpvLogFile: '/tmp/mpv.log',
|
|
||||||
envMpvLog: '',
|
|
||||||
defaultTexthookerPort: 5174,
|
|
||||||
getDefaultSocketPath: () => '/tmp/mpv.sock',
|
|
||||||
resolveConfigDir: () => '/tmp/config',
|
|
||||||
existsSync: () => true,
|
|
||||||
mkdirSync: () => undefined,
|
|
||||||
joinPath: (...parts: string[]) => path.posix.join(...parts),
|
|
||||||
app: {
|
|
||||||
setPath: () => undefined,
|
|
||||||
quit: () => undefined,
|
|
||||||
on: () => undefined,
|
|
||||||
whenReady: async () => undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
singleInstance: {
|
|
||||||
shouldBypassSingleInstanceLock: () => false,
|
|
||||||
requestSingleInstanceLockEarly: () => true,
|
|
||||||
registerSecondInstanceHandlerEarly: () => undefined,
|
|
||||||
onConfigStartupParseError: () => undefined,
|
|
||||||
},
|
|
||||||
factories: {
|
|
||||||
createConfigService: () => ({ kind: 'config-service' }) as never,
|
|
||||||
createAnilistTokenStore: () => ({ kind: 'anilist-token-store' }) as never,
|
|
||||||
createJellyfinTokenStore: () => ({ kind: 'jellyfin-token-store' }) as never,
|
|
||||||
createAnilistUpdateQueue: () => ({ kind: 'anilist-update-queue' }) as never,
|
|
||||||
createSubtitleWebSocket: () => ({ kind: 'subtitle-websocket' }) as never,
|
|
||||||
createLogger: () =>
|
|
||||||
({
|
|
||||||
warn: () => undefined,
|
|
||||||
info: () => undefined,
|
|
||||||
error: () => undefined,
|
|
||||||
}) as never,
|
|
||||||
createMainRuntimeRegistry: () => ({ kind: 'runtime-registry' }) as never,
|
|
||||||
createOverlayManager: () => overlayManager as never,
|
|
||||||
createOverlayModalInputState,
|
|
||||||
createOverlayContentMeasurementStore: () => ({ kind: 'overlay-content-store' }) as never,
|
|
||||||
getSyncOverlayShortcutsForModal: () => (isActive: boolean) => {
|
|
||||||
calls.push(`shortcuts:${String(isActive)}`);
|
|
||||||
},
|
|
||||||
getSyncOverlayVisibilityForModal: () => () => {
|
|
||||||
calls.push('visibility');
|
|
||||||
},
|
|
||||||
createOverlayModalRuntime,
|
|
||||||
createAppState: (input) => ({ kind: 'app-state', input }) satisfies AppStateStub,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(boot.configDir, '/tmp/config');
|
|
||||||
assert.equal(boot.userDataPath, '/tmp/config');
|
|
||||||
assert.equal(boot.defaultImmersionDbPath, '/tmp/config/immersion.sqlite');
|
|
||||||
assert.equal(boot.appState.input.mpvSocketPath, '/tmp/mpv.sock');
|
|
||||||
assert.equal(boot.appState.input.texthookerPort, 5174);
|
|
||||||
assert.equal(overlayModalInputStateParams.length, 1);
|
|
||||||
assert.equal(overlayModalInputStateParams[0]?.getModalWindow(), modalWindow);
|
|
||||||
overlayModalInputStateParams[0]?.syncOverlayShortcutsForModal(true);
|
|
||||||
overlayModalInputStateParams[0]?.syncOverlayVisibilityForModal();
|
|
||||||
assert.deepEqual(calls, ['modal-state:true', 'modal:undefined', 'shortcuts:true', 'visibility']);
|
|
||||||
assert.equal(boot.overlayManager, overlayManager);
|
|
||||||
assert.equal(boot.overlayModalRuntime.kind, 'overlay-modal-runtime');
|
|
||||||
});
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import type { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import type { ConfigStartupParseError } from '../config';
|
|
||||||
import {
|
|
||||||
createMainBootServices,
|
|
||||||
type MainBootServicesResult,
|
|
||||||
type OverlayModalInputStateShape,
|
|
||||||
type AppLifecycleShape,
|
|
||||||
} from './boot/services';
|
|
||||||
|
|
||||||
export interface MainBootServicesBootstrapInput<
|
|
||||||
TConfigService,
|
|
||||||
TAnilistTokenStore,
|
|
||||||
TJellyfinTokenStore,
|
|
||||||
TAnilistUpdateQueue,
|
|
||||||
TSubtitleWebSocket,
|
|
||||||
TLogger,
|
|
||||||
TRuntimeRegistry,
|
|
||||||
TOverlayManager,
|
|
||||||
TOverlayModalInputState,
|
|
||||||
TOverlayContentMeasurementStore,
|
|
||||||
TOverlayModalRuntime,
|
|
||||||
TAppState,
|
|
||||||
TAppLifecycleApp,
|
|
||||||
> {
|
|
||||||
system: {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
argv: string[];
|
|
||||||
appDataDir: string | undefined;
|
|
||||||
xdgConfigHome: string | undefined;
|
|
||||||
homeDir: string;
|
|
||||||
defaultMpvLogFile: string;
|
|
||||||
envMpvLog: string | undefined;
|
|
||||||
defaultTexthookerPort: number;
|
|
||||||
getDefaultSocketPath: () => string;
|
|
||||||
resolveConfigDir: (input: {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
appDataDir: string | undefined;
|
|
||||||
xdgConfigHome: string | undefined;
|
|
||||||
homeDir: string;
|
|
||||||
existsSync: (targetPath: string) => boolean;
|
|
||||||
}) => string;
|
|
||||||
existsSync: (targetPath: string) => boolean;
|
|
||||||
mkdirSync: (targetPath: string, options: { recursive: true }) => void;
|
|
||||||
joinPath: (...parts: string[]) => string;
|
|
||||||
app: {
|
|
||||||
setPath: (name: string, value: string) => void;
|
|
||||||
quit: () => void;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures
|
|
||||||
on: Function;
|
|
||||||
whenReady: () => Promise<void>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
singleInstance: {
|
|
||||||
shouldBypassSingleInstanceLock: () => boolean;
|
|
||||||
requestSingleInstanceLockEarly: () => boolean;
|
|
||||||
registerSecondInstanceHandlerEarly: (
|
|
||||||
listener: (_event: unknown, argv: string[]) => void,
|
|
||||||
) => void;
|
|
||||||
onConfigStartupParseError: (error: ConfigStartupParseError) => void;
|
|
||||||
};
|
|
||||||
factories: {
|
|
||||||
createConfigService: (configDir: string) => TConfigService;
|
|
||||||
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
|
|
||||||
createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore;
|
|
||||||
createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue;
|
|
||||||
createSubtitleWebSocket: () => TSubtitleWebSocket;
|
|
||||||
createLogger: (scope: string) => TLogger & {
|
|
||||||
warn: (message: string) => void;
|
|
||||||
info: (message: string) => void;
|
|
||||||
error: (message: string, details?: unknown) => void;
|
|
||||||
};
|
|
||||||
createMainRuntimeRegistry: () => TRuntimeRegistry;
|
|
||||||
createOverlayManager: () => TOverlayManager;
|
|
||||||
createOverlayModalInputState: (params: {
|
|
||||||
getModalWindow: () => BrowserWindow | null;
|
|
||||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
|
||||||
syncOverlayVisibilityForModal: () => void;
|
|
||||||
}) => TOverlayModalInputState;
|
|
||||||
createOverlayContentMeasurementStore: (params: {
|
|
||||||
logger: TLogger;
|
|
||||||
}) => TOverlayContentMeasurementStore;
|
|
||||||
getSyncOverlayShortcutsForModal: () => (isActive: boolean) => void;
|
|
||||||
getSyncOverlayVisibilityForModal: () => () => void;
|
|
||||||
createOverlayModalRuntime: (params: {
|
|
||||||
overlayManager: TOverlayManager;
|
|
||||||
overlayModalInputState: TOverlayModalInputState;
|
|
||||||
onModalStateChange: (isActive: boolean) => void;
|
|
||||||
}) => TOverlayModalRuntime;
|
|
||||||
createAppState: (input: { mpvSocketPath: string; texthookerPort: number }) => TAppState;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMainBootServicesBootstrap<
|
|
||||||
TConfigService,
|
|
||||||
TAnilistTokenStore,
|
|
||||||
TJellyfinTokenStore,
|
|
||||||
TAnilistUpdateQueue,
|
|
||||||
TSubtitleWebSocket,
|
|
||||||
TLogger,
|
|
||||||
TRuntimeRegistry,
|
|
||||||
TOverlayManager extends { getModalWindow: () => BrowserWindow | null },
|
|
||||||
TOverlayModalInputState extends OverlayModalInputStateShape,
|
|
||||||
TOverlayContentMeasurementStore,
|
|
||||||
TOverlayModalRuntime,
|
|
||||||
TAppState,
|
|
||||||
TAppLifecycleApp extends AppLifecycleShape,
|
|
||||||
>(
|
|
||||||
input: MainBootServicesBootstrapInput<
|
|
||||||
TConfigService,
|
|
||||||
TAnilistTokenStore,
|
|
||||||
TJellyfinTokenStore,
|
|
||||||
TAnilistUpdateQueue,
|
|
||||||
TSubtitleWebSocket,
|
|
||||||
TLogger,
|
|
||||||
TRuntimeRegistry,
|
|
||||||
TOverlayManager,
|
|
||||||
TOverlayModalInputState,
|
|
||||||
TOverlayContentMeasurementStore,
|
|
||||||
TOverlayModalRuntime,
|
|
||||||
TAppState,
|
|
||||||
TAppLifecycleApp
|
|
||||||
>,
|
|
||||||
): MainBootServicesResult<
|
|
||||||
TConfigService,
|
|
||||||
TAnilistTokenStore,
|
|
||||||
TJellyfinTokenStore,
|
|
||||||
TAnilistUpdateQueue,
|
|
||||||
TSubtitleWebSocket,
|
|
||||||
TLogger,
|
|
||||||
TRuntimeRegistry,
|
|
||||||
TOverlayManager,
|
|
||||||
TOverlayModalInputState,
|
|
||||||
TOverlayContentMeasurementStore,
|
|
||||||
TOverlayModalRuntime,
|
|
||||||
TAppState,
|
|
||||||
TAppLifecycleApp
|
|
||||||
> {
|
|
||||||
return createMainBootServices({
|
|
||||||
platform: input.system.platform,
|
|
||||||
argv: input.system.argv,
|
|
||||||
appDataDir: input.system.appDataDir,
|
|
||||||
xdgConfigHome: input.system.xdgConfigHome,
|
|
||||||
homeDir: input.system.homeDir,
|
|
||||||
defaultMpvLogFile: input.system.defaultMpvLogFile,
|
|
||||||
envMpvLog: input.system.envMpvLog,
|
|
||||||
defaultTexthookerPort: input.system.defaultTexthookerPort,
|
|
||||||
getDefaultSocketPath: input.system.getDefaultSocketPath,
|
|
||||||
resolveConfigDir: input.system.resolveConfigDir,
|
|
||||||
existsSync: input.system.existsSync,
|
|
||||||
mkdirSync: input.system.mkdirSync,
|
|
||||||
joinPath: input.system.joinPath,
|
|
||||||
app: input.system.app,
|
|
||||||
shouldBypassSingleInstanceLock: input.singleInstance.shouldBypassSingleInstanceLock,
|
|
||||||
requestSingleInstanceLockEarly: input.singleInstance.requestSingleInstanceLockEarly,
|
|
||||||
registerSecondInstanceHandlerEarly: input.singleInstance.registerSecondInstanceHandlerEarly,
|
|
||||||
onConfigStartupParseError: input.singleInstance.onConfigStartupParseError,
|
|
||||||
createConfigService: input.factories.createConfigService,
|
|
||||||
createAnilistTokenStore: input.factories.createAnilistTokenStore,
|
|
||||||
createJellyfinTokenStore: input.factories.createJellyfinTokenStore,
|
|
||||||
createAnilistUpdateQueue: input.factories.createAnilistUpdateQueue,
|
|
||||||
createSubtitleWebSocket: input.factories.createSubtitleWebSocket,
|
|
||||||
createLogger: input.factories.createLogger,
|
|
||||||
createMainRuntimeRegistry: input.factories.createMainRuntimeRegistry,
|
|
||||||
createOverlayManager: input.factories.createOverlayManager,
|
|
||||||
createOverlayModalInputState: input.factories.createOverlayModalInputState,
|
|
||||||
createOverlayContentMeasurementStore: input.factories.createOverlayContentMeasurementStore,
|
|
||||||
getSyncOverlayShortcutsForModal: input.factories.getSyncOverlayShortcutsForModal,
|
|
||||||
getSyncOverlayVisibilityForModal: input.factories.getSyncOverlayVisibilityForModal,
|
|
||||||
createOverlayModalRuntime: input.factories.createOverlayModalRuntime,
|
|
||||||
createAppState: input.factories.createAppState,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
import type { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import type { ConfigService } from '../config';
|
|
||||||
import type { ResolvedConfig } from '../types';
|
|
||||||
import type { AppState } from './state';
|
|
||||||
import { createFirstRunRuntimeCoordinator } from './first-run-runtime-coordinator';
|
|
||||||
import { createStartupSupportFromMainState } from './startup-support-coordinator';
|
|
||||||
import { createYoutubeRuntimeFromMainState } from './youtube-runtime-coordinator';
|
|
||||||
import { createOverlayMpvSubtitleSuppressionRuntime } from './runtime/overlay-mpv-sub-visibility';
|
|
||||||
import { createDiscordPresenceRuntimeFromMainState } from './runtime/discord-presence-runtime';
|
|
||||||
import type { OverlayGeometryRuntime } from './overlay-geometry-runtime';
|
|
||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
|
||||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
|
||||||
import type { OverlayUiRuntime } from './overlay-ui-runtime';
|
|
||||||
|
|
||||||
export interface MainEarlyRuntimeInput {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
configDir: string;
|
|
||||||
homeDir: string;
|
|
||||||
xdgConfigHome?: string;
|
|
||||||
binaryPath: string;
|
|
||||||
appPath: string;
|
|
||||||
resourcesPath: string;
|
|
||||||
appDataDir: string;
|
|
||||||
desktopDir: string;
|
|
||||||
defaultImmersionDbPath: string;
|
|
||||||
defaultJimakuLanguagePreference: ResolvedConfig['jimaku']['languagePreference'];
|
|
||||||
defaultJimakuMaxEntryResults: number;
|
|
||||||
defaultJimakuApiBaseUrl: string;
|
|
||||||
jellyfinLangPref: string;
|
|
||||||
youtube: {
|
|
||||||
directPlaybackFormat: string;
|
|
||||||
mpvYtdlFormat: string;
|
|
||||||
autoLaunchTimeoutMs: number;
|
|
||||||
connectTimeoutMs: number;
|
|
||||||
logPath: string;
|
|
||||||
};
|
|
||||||
discordPresenceAppId: string;
|
|
||||||
appState: AppState;
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
getFallbackDiscordMediaDurationSec: () => number | null;
|
|
||||||
configService: Pick<ConfigService, 'reloadConfigStrict'>;
|
|
||||||
overlay: {
|
|
||||||
overlayManager: {
|
|
||||||
getVisibleOverlayVisible: () => boolean;
|
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
|
||||||
getMainWindow: () => BrowserWindow | null;
|
|
||||||
};
|
|
||||||
overlayModalRuntime: {
|
|
||||||
sendToActiveOverlayWindow: (
|
|
||||||
channel: string,
|
|
||||||
payload?: unknown,
|
|
||||||
runtimeOptions?: {
|
|
||||||
restoreOnModalClose?: OverlayHostedModal;
|
|
||||||
preferModalWindow?: boolean;
|
|
||||||
},
|
|
||||||
) => void;
|
|
||||||
};
|
|
||||||
getOverlayUi: () => OverlayUiRuntime<BrowserWindow> | null;
|
|
||||||
getOverlayGeometry: () => OverlayGeometryRuntime<BrowserWindow>;
|
|
||||||
ensureTray: () => void;
|
|
||||||
hasTray: () => boolean;
|
|
||||||
};
|
|
||||||
yomitan: {
|
|
||||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
|
||||||
getParserRuntimeDeps: () => Parameters<
|
|
||||||
typeof import('../core/services').getYomitanDictionaryInfo
|
|
||||||
>[0];
|
|
||||||
openYomitanSettings: () => boolean;
|
|
||||||
};
|
|
||||||
subtitle: {
|
|
||||||
getSubtitle: () => SubtitleRuntime;
|
|
||||||
};
|
|
||||||
tokenization: {
|
|
||||||
startTokenizationWarmups: () => Promise<void>;
|
|
||||||
getGate: Parameters<typeof createYoutubeRuntimeFromMainState>[0]['tokenization']['getGate'];
|
|
||||||
};
|
|
||||||
appReady: {
|
|
||||||
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
|
|
||||||
};
|
|
||||||
shortcuts: {
|
|
||||||
refreshGlobalAndOverlayShortcuts: () => void;
|
|
||||||
};
|
|
||||||
notifications: {
|
|
||||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
|
||||||
showErrorBox: (title: string, content: string) => void;
|
|
||||||
};
|
|
||||||
mpv: {
|
|
||||||
sendMpvCommandRuntime: (client: AppState['mpvClient'], command: (string | number)[]) => void;
|
|
||||||
setSubVisibility: (visible: boolean) => void;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
};
|
|
||||||
actions: {
|
|
||||||
requestAppQuit: () => void;
|
|
||||||
writeShortcutLink: (
|
|
||||||
shortcutPath: string,
|
|
||||||
operation: 'create' | 'update' | 'replace',
|
|
||||||
details: {
|
|
||||||
target: string;
|
|
||||||
args?: string;
|
|
||||||
cwd?: string;
|
|
||||||
description?: string;
|
|
||||||
icon?: string;
|
|
||||||
iconIndex?: number;
|
|
||||||
},
|
|
||||||
) => boolean;
|
|
||||||
};
|
|
||||||
logger: {
|
|
||||||
error: (message: string, error?: unknown) => void;
|
|
||||||
info: (message: string, ...args: unknown[]) => void;
|
|
||||||
warn: (message: string, error?: unknown) => void;
|
|
||||||
debug: (message: string, meta?: unknown) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMainEarlyRuntime(input: MainEarlyRuntimeInput) {
|
|
||||||
const firstRun = createFirstRunRuntimeCoordinator({
|
|
||||||
platform: input.platform,
|
|
||||||
configDir: input.configDir,
|
|
||||||
homeDir: input.homeDir,
|
|
||||||
xdgConfigHome: input.xdgConfigHome,
|
|
||||||
binaryPath: input.binaryPath,
|
|
||||||
appPath: input.appPath,
|
|
||||||
resourcesPath: input.resourcesPath,
|
|
||||||
appDataDir: input.appDataDir,
|
|
||||||
desktopDir: input.desktopDir,
|
|
||||||
appState: input.appState,
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
yomitan: input.yomitan,
|
|
||||||
overlay: {
|
|
||||||
ensureTray: () => input.overlay.ensureTray(),
|
|
||||||
hasTray: () => input.overlay.hasTray(),
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
writeShortcutLink: (shortcutPath, operation, details) =>
|
|
||||||
input.actions.writeShortcutLink(shortcutPath, operation, details),
|
|
||||||
requestAppQuit: () => input.actions.requestAppQuit(),
|
|
||||||
},
|
|
||||||
logger: {
|
|
||||||
error: (message, error) => input.logger.error(message, error),
|
|
||||||
info: (message, ...args) => input.logger.info(message, ...args),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { discordPresenceRuntime, initializeDiscordPresenceService } =
|
|
||||||
createDiscordPresenceRuntimeFromMainState({
|
|
||||||
appId: input.discordPresenceAppId,
|
|
||||||
appState: input.appState,
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
getFallbackMediaDurationSec: () => input.getFallbackDiscordMediaDurationSec(),
|
|
||||||
logger: {
|
|
||||||
debug: (message, meta) => input.logger.debug(message, meta),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const overlaySubtitleSuppression = createOverlayMpvSubtitleSuppressionRuntime({
|
|
||||||
appState: input.appState,
|
|
||||||
getVisibleOverlayVisible: () => input.overlay.overlayManager.getVisibleOverlayVisible(),
|
|
||||||
setMpvSubVisibility: (visible) => input.mpv.setSubVisibility(visible),
|
|
||||||
logWarn: (message, error) => input.logger.warn(message, error),
|
|
||||||
});
|
|
||||||
|
|
||||||
const startupSupport = createStartupSupportFromMainState({
|
|
||||||
platform: input.platform,
|
|
||||||
defaultImmersionDbPath: input.defaultImmersionDbPath,
|
|
||||||
defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference,
|
|
||||||
defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults,
|
|
||||||
defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl,
|
|
||||||
jellyfinLangPref: input.jellyfinLangPref,
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
appState: input.appState,
|
|
||||||
configService: input.configService,
|
|
||||||
overlay: {
|
|
||||||
broadcastToOverlayWindows: (channel, payload) =>
|
|
||||||
input.overlay.overlayManager.broadcastToOverlayWindows(channel, payload),
|
|
||||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
|
||||||
input.overlay.overlayModalRuntime.sendToActiveOverlayWindow(
|
|
||||||
channel,
|
|
||||||
payload,
|
|
||||||
runtimeOptions,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
shortcuts: {
|
|
||||||
refreshGlobalAndOverlayShortcuts: () => input.shortcuts.refreshGlobalAndOverlayShortcuts(),
|
|
||||||
},
|
|
||||||
notifications: {
|
|
||||||
showDesktopNotification: (title, options) =>
|
|
||||||
input.notifications.showDesktopNotification(title, options),
|
|
||||||
showErrorBox: (title, details) => input.notifications.showErrorBox(title, details),
|
|
||||||
},
|
|
||||||
logger: {
|
|
||||||
debug: (message) => input.logger.debug(message),
|
|
||||||
info: (message) => input.logger.info(message),
|
|
||||||
warn: (message, error) => input.logger.warn(message, error),
|
|
||||||
},
|
|
||||||
mpv: {
|
|
||||||
sendMpvCommandRuntime: (client, command) => input.mpv.sendMpvCommandRuntime(client, command),
|
|
||||||
showMpvOsd: (text) => input.mpv.showMpvOsd(text),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const youtube = createYoutubeRuntimeFromMainState({
|
|
||||||
platform: input.platform,
|
|
||||||
directPlaybackFormat: input.youtube.directPlaybackFormat,
|
|
||||||
mpvYtdlFormat: input.youtube.mpvYtdlFormat,
|
|
||||||
autoLaunchTimeoutMs: input.youtube.autoLaunchTimeoutMs,
|
|
||||||
connectTimeoutMs: input.youtube.connectTimeoutMs,
|
|
||||||
logPath: input.youtube.logPath,
|
|
||||||
appState: input.appState,
|
|
||||||
overlay: {
|
|
||||||
getOverlayUi: () => input.overlay.getOverlayUi(),
|
|
||||||
getMainWindow: () => input.overlay.overlayManager.getMainWindow(),
|
|
||||||
getOverlayGeometry: () => input.overlay.getOverlayGeometry(),
|
|
||||||
broadcastToOverlayWindows: (channel, payload) =>
|
|
||||||
input.overlay.overlayManager.broadcastToOverlayWindows(channel, payload),
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
getSubtitle: () => input.subtitle.getSubtitle(),
|
|
||||||
},
|
|
||||||
tokenization: {
|
|
||||||
startTokenizationWarmups: () => input.tokenization.startTokenizationWarmups(),
|
|
||||||
getGate: () => input.tokenization.getGate(),
|
|
||||||
},
|
|
||||||
appReady: {
|
|
||||||
ensureYoutubePlaybackRuntimeReady: () => input.appReady.ensureYoutubePlaybackRuntimeReady(),
|
|
||||||
},
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
notifications: {
|
|
||||||
showDesktopNotification: (title, options) =>
|
|
||||||
input.notifications.showDesktopNotification(title, options),
|
|
||||||
showErrorBox: (title, content) => input.notifications.showErrorBox(title, content),
|
|
||||||
},
|
|
||||||
mpv: {
|
|
||||||
sendMpvCommand: (command) =>
|
|
||||||
input.mpv.sendMpvCommandRuntime(input.appState.mpvClient, command),
|
|
||||||
showMpvOsd: (message) => input.mpv.showMpvOsd(message),
|
|
||||||
},
|
|
||||||
logger: {
|
|
||||||
info: (message) => input.logger.info(message),
|
|
||||||
warn: (message, error) => input.logger.warn(message, error),
|
|
||||||
debug: (message) => input.logger.debug(message),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
firstRun,
|
|
||||||
discordPresenceRuntime,
|
|
||||||
initializeDiscordPresenceService,
|
|
||||||
overlaySubtitleSuppression,
|
|
||||||
startupSupport,
|
|
||||||
youtube,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import type { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
|
||||||
import type { MpvSubtitleRenderMetrics } from '../types';
|
|
||||||
import type { MpvIpcClient } from '../core/services/mpv';
|
|
||||||
import { sendMpvCommandRuntime } from '../core/services';
|
|
||||||
import type { AnilistRuntime } from './anilist-runtime';
|
|
||||||
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
|
|
||||||
import type { JellyfinRuntime } from './jellyfin-runtime';
|
|
||||||
import { createMiningRuntime } from './mining-runtime';
|
|
||||||
import type { MiningRuntimeInput } from './mining-runtime';
|
|
||||||
import { createMpvRuntimeFromMainState } from './mpv-runtime-bootstrap';
|
|
||||||
import type { MpvRuntime } from './mpv-runtime';
|
|
||||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
|
||||||
import type { YoutubeRuntime } from './youtube-runtime';
|
|
||||||
import type { AppState } from './state';
|
|
||||||
|
|
||||||
export interface MainPlaybackRuntimeInput {
|
|
||||||
appState: AppState;
|
|
||||||
logPath: string;
|
|
||||||
logger: Parameters<typeof createMpvRuntimeFromMainState>[0]['logger'] & {
|
|
||||||
error: (message: string, error: unknown) => void;
|
|
||||||
};
|
|
||||||
getResolvedConfig: Parameters<typeof createMpvRuntimeFromMainState>[0]['getResolvedConfig'];
|
|
||||||
getRuntimeBooleanOption: Parameters<
|
|
||||||
typeof createMpvRuntimeFromMainState
|
|
||||||
>[0]['getRuntimeBooleanOption'];
|
|
||||||
subtitle: SubtitleRuntime;
|
|
||||||
yomitan: {
|
|
||||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
|
||||||
isCharacterDictionaryEnabled: () => boolean;
|
|
||||||
};
|
|
||||||
currentMediaTokenizationGate: Parameters<
|
|
||||||
typeof createMpvRuntimeFromMainState
|
|
||||||
>[0]['currentMediaTokenizationGate'];
|
|
||||||
startupOsdSequencer: Parameters<typeof createMpvRuntimeFromMainState>[0]['startupOsdSequencer'];
|
|
||||||
dictionarySupport: DictionarySupportRuntime;
|
|
||||||
overlay: {
|
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
|
||||||
getVisibleOverlayVisible: () => boolean;
|
|
||||||
getOverlayUi: () => { setOverlayVisible: (visible: boolean) => void } | undefined;
|
|
||||||
};
|
|
||||||
lifecycle: {
|
|
||||||
requestAppQuit: () => void;
|
|
||||||
restoreOverlayMpvSubtitles: () => void;
|
|
||||||
syncOverlayMpvSubtitleSuppression: () => void;
|
|
||||||
publishDiscordPresence: () => void;
|
|
||||||
};
|
|
||||||
stats: {
|
|
||||||
ensureImmersionTrackerStarted: () => void;
|
|
||||||
};
|
|
||||||
anilist: AnilistRuntime;
|
|
||||||
jellyfin: JellyfinRuntime;
|
|
||||||
youtube: YoutubeRuntime;
|
|
||||||
mining: Omit<
|
|
||||||
MiningRuntimeInput<any, any>,
|
|
||||||
'showMpvOsd' | 'sendMpvCommand' | 'logError' | 'recordCardsMined'
|
|
||||||
> & {
|
|
||||||
readClipboardText: () => string;
|
|
||||||
writeClipboardText: (text: string) => void;
|
|
||||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MainPlaybackRuntime {
|
|
||||||
mpvRuntime: MpvRuntime;
|
|
||||||
mining: ReturnType<typeof createMiningRuntime>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMainPlaybackRuntime(input: MainPlaybackRuntimeInput): MainPlaybackRuntime {
|
|
||||||
let mpvRuntime!: MpvRuntime;
|
|
||||||
|
|
||||||
const showMpvOsd = (text: string): void => {
|
|
||||||
mpvRuntime.showMpvOsd(text);
|
|
||||||
};
|
|
||||||
|
|
||||||
const mining = createMiningRuntime({
|
|
||||||
...input.mining,
|
|
||||||
showMpvOsd: (text) => showMpvOsd(text),
|
|
||||||
sendMpvCommand: (command) => {
|
|
||||||
sendMpvCommandRuntime(input.appState.mpvClient, command);
|
|
||||||
},
|
|
||||||
logError: (message, err) => {
|
|
||||||
input.logger.error(message, err);
|
|
||||||
},
|
|
||||||
recordCardsMined: (count, noteIds) => input.mining.recordCardsMined(count, noteIds),
|
|
||||||
});
|
|
||||||
|
|
||||||
mpvRuntime = createMpvRuntimeFromMainState({
|
|
||||||
appState: input.appState,
|
|
||||||
logPath: input.logPath,
|
|
||||||
logger: input.logger,
|
|
||||||
getResolvedConfig: input.getResolvedConfig,
|
|
||||||
getRuntimeBooleanOption: input.getRuntimeBooleanOption,
|
|
||||||
subtitle: input.subtitle,
|
|
||||||
yomitan: {
|
|
||||||
ensureYomitanExtensionLoaded: async () => {
|
|
||||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
currentMediaTokenizationGate: input.currentMediaTokenizationGate,
|
|
||||||
startupOsdSequencer: input.startupOsdSequencer,
|
|
||||||
dictionarySupport: input.dictionarySupport,
|
|
||||||
overlay: {
|
|
||||||
broadcastToOverlayWindows: (channel, payload) => {
|
|
||||||
input.overlay.broadcastToOverlayWindows(channel, payload);
|
|
||||||
},
|
|
||||||
getVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(),
|
|
||||||
getOverlayUi: () => input.overlay.getOverlayUi(),
|
|
||||||
},
|
|
||||||
lifecycle: {
|
|
||||||
requestAppQuit: () => input.lifecycle.requestAppQuit(),
|
|
||||||
setQuitCheckTimer: (callback, timeoutMs) => {
|
|
||||||
setTimeout(callback, timeoutMs);
|
|
||||||
},
|
|
||||||
restoreOverlayMpvSubtitles: input.lifecycle.restoreOverlayMpvSubtitles,
|
|
||||||
syncOverlayMpvSubtitleSuppression: input.lifecycle.syncOverlayMpvSubtitleSuppression,
|
|
||||||
publishDiscordPresence: () => input.lifecycle.publishDiscordPresence(),
|
|
||||||
},
|
|
||||||
stats: input.stats,
|
|
||||||
anilist: input.anilist,
|
|
||||||
jellyfin: input.jellyfin,
|
|
||||||
youtube: input.youtube,
|
|
||||||
isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(),
|
|
||||||
}).mpvRuntime;
|
|
||||||
|
|
||||||
return {
|
|
||||||
mpvRuntime,
|
|
||||||
mining,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import type { CliArgs } from '../cli/args';
|
|
||||||
import type { ResolvedConfig, SecondarySubMode, SubtitleData } from '../types';
|
|
||||||
import { RuntimeOptionsManager } from '../runtime-options';
|
|
||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
|
||||||
|
|
||||||
export type StartupBootstrapMpvClientLike = {
|
|
||||||
connected: boolean;
|
|
||||||
connect: () => void;
|
|
||||||
setSocketPath: (socketPath: string) => void;
|
|
||||||
currentSubStart?: number | null;
|
|
||||||
currentSubEnd?: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StartupBootstrapAppStateLike = {
|
|
||||||
subtitlePosition: unknown | null;
|
|
||||||
keybindings: unknown[];
|
|
||||||
mpvSocketPath: string;
|
|
||||||
texthookerPort: number;
|
|
||||||
mpvClient: StartupBootstrapMpvClientLike | null;
|
|
||||||
runtimeOptionsManager: RuntimeOptionsManager | null;
|
|
||||||
subtitleTimingTracker: SubtitleTimingTracker | null;
|
|
||||||
currentSubtitleData: SubtitleData | null;
|
|
||||||
currentSubText: string | null;
|
|
||||||
initialArgs: CliArgs | null | undefined;
|
|
||||||
backgroundMode: boolean;
|
|
||||||
texthookerOnlyMode: boolean;
|
|
||||||
overlayRuntimeInitialized: boolean;
|
|
||||||
firstRunSetupCompleted: boolean;
|
|
||||||
secondarySubMode: SecondarySubMode;
|
|
||||||
ankiIntegration: unknown | null;
|
|
||||||
immersionTracker: unknown | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StartupBootstrapSubtitleWebsocketLike = {
|
|
||||||
start: (
|
|
||||||
port: number,
|
|
||||||
getPayload: () => SubtitleData | null,
|
|
||||||
getFrequencyOptions: () => {
|
|
||||||
enabled: boolean;
|
|
||||||
topX: number;
|
|
||||||
mode: ResolvedConfig['subtitleStyle']['frequencyDictionary']['mode'];
|
|
||||||
},
|
|
||||||
) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StartupBootstrapOverlayUiLike = {
|
|
||||||
broadcastRuntimeOptionsChanged: () => void;
|
|
||||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
|
||||||
ensureTray: () => void;
|
|
||||||
initializeOverlayRuntime: () => void;
|
|
||||||
openRuntimeOptionsPalette: () => void;
|
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
|
||||||
toggleVisibleOverlay: () => void;
|
|
||||||
};
|
|
||||||
@@ -1,505 +0,0 @@
|
|||||||
import type { CliArgs, CliCommandSource } from '../cli/args';
|
|
||||||
import type { LogLevelSource } from '../logger';
|
|
||||||
import type { ConfigValidationWarning, ResolvedConfig, SubtitleData } from '../types';
|
|
||||||
import type { StartupBootstrapRuntimeDeps } from '../core/services/startup';
|
|
||||||
import { resolveKeybindings } from '../core/utils';
|
|
||||||
import { RuntimeOptionsManager } from '../runtime-options';
|
|
||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
|
||||||
import type { AppReadyRuntimeInput } from './app-ready-runtime';
|
|
||||||
import type {
|
|
||||||
CliCommandRuntimeServiceContext,
|
|
||||||
CliCommandRuntimeServiceContextHandlers,
|
|
||||||
} from './cli-runtime';
|
|
||||||
import type {
|
|
||||||
StartupBootstrapAppStateLike,
|
|
||||||
StartupBootstrapMpvClientLike,
|
|
||||||
StartupBootstrapOverlayUiLike,
|
|
||||||
StartupBootstrapSubtitleWebsocketLike,
|
|
||||||
} from './main-startup-bootstrap-types';
|
|
||||||
import type { MainStartupRuntime } from './main-startup-runtime';
|
|
||||||
import { createMainStartupRuntime } from './main-startup-runtime';
|
|
||||||
|
|
||||||
export interface MainStartupBootstrapInput<TStartupState> {
|
|
||||||
appState: StartupBootstrapAppStateLike;
|
|
||||||
appLifecycle: {
|
|
||||||
app: unknown;
|
|
||||||
argv: string[];
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
};
|
|
||||||
config: {
|
|
||||||
configService: {
|
|
||||||
reloadConfigStrict: AppReadyRuntimeInput['reload']['reloadConfigStrict'];
|
|
||||||
getConfigPath: () => string;
|
|
||||||
getWarnings: () => ConfigValidationWarning[];
|
|
||||||
getConfig: () => ResolvedConfig;
|
|
||||||
};
|
|
||||||
configHotReloadRuntime: {
|
|
||||||
start: () => void;
|
|
||||||
};
|
|
||||||
configDerivedRuntime: {
|
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
|
|
||||||
};
|
|
||||||
ensureDefaultConfigBootstrap: (options: {
|
|
||||||
configDir: string;
|
|
||||||
configFilePaths: unknown;
|
|
||||||
generateTemplate: () => string;
|
|
||||||
}) => void;
|
|
||||||
getDefaultConfigFilePaths: (configDir: string) => unknown;
|
|
||||||
generateConfigTemplate: (config: ResolvedConfig) => string;
|
|
||||||
defaultConfig: ResolvedConfig;
|
|
||||||
defaultKeybindings: unknown;
|
|
||||||
configDir: string;
|
|
||||||
};
|
|
||||||
logging: {
|
|
||||||
appLogger: {
|
|
||||||
logInfo: (message: string) => void;
|
|
||||||
logWarning: (message: string) => void;
|
|
||||||
logConfigWarning: (warning: ConfigValidationWarning) => void;
|
|
||||||
logNoRunningInstance: () => void;
|
|
||||||
};
|
|
||||||
logger: {
|
|
||||||
info: (message: string) => void;
|
|
||||||
warn: (message: string, error?: unknown) => void;
|
|
||||||
error: (message: string, error?: unknown) => void;
|
|
||||||
debug: (message: string) => void;
|
|
||||||
};
|
|
||||||
setLogLevel: (level: string, source: LogLevelSource) => void;
|
|
||||||
};
|
|
||||||
shell: {
|
|
||||||
dialog: {
|
|
||||||
showErrorBox: (title: string, message: string) => void;
|
|
||||||
};
|
|
||||||
shell: {
|
|
||||||
openExternal: (url: string) => Promise<void>;
|
|
||||||
};
|
|
||||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
|
||||||
};
|
|
||||||
runtime: {
|
|
||||||
subtitle: {
|
|
||||||
loadSubtitlePosition: () => void;
|
|
||||||
invalidateTokenizationCache: () => void;
|
|
||||||
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
|
|
||||||
};
|
|
||||||
overlayUi: {
|
|
||||||
get: () => StartupBootstrapOverlayUiLike | undefined;
|
|
||||||
};
|
|
||||||
overlayManager: {
|
|
||||||
getMainWindow: () => unknown | null;
|
|
||||||
};
|
|
||||||
firstRun: {
|
|
||||||
ensureSetupStateInitialized: () => Promise<{ state: { status: string } }>;
|
|
||||||
openFirstRunSetupWindow: () => void;
|
|
||||||
};
|
|
||||||
anilist: {
|
|
||||||
refreshAnilistClientSecretStateIfEnabled: (options: {
|
|
||||||
force: boolean;
|
|
||||||
allowSetupPrompt?: boolean;
|
|
||||||
}) => Promise<unknown>;
|
|
||||||
openAnilistSetupWindow: () => void;
|
|
||||||
getStatusSnapshot: CliCommandRuntimeServiceContext['getAnilistStatus'];
|
|
||||||
clearTokenState: () => void;
|
|
||||||
getQueueStatusSnapshot: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
|
|
||||||
processNextAnilistRetryUpdate: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
|
|
||||||
};
|
|
||||||
jellyfin: {
|
|
||||||
startJellyfinRemoteSession: () => Promise<void>;
|
|
||||||
openJellyfinSetupWindow: () => void;
|
|
||||||
runJellyfinCommand: (argsFromCommand: CliArgs) => Promise<void>;
|
|
||||||
};
|
|
||||||
stats: {
|
|
||||||
ensureImmersionTrackerStarted: () => void;
|
|
||||||
runStatsCliCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
|
|
||||||
};
|
|
||||||
mining: {
|
|
||||||
copyCurrentSubtitle: () => void;
|
|
||||||
mineSentenceCard: () => Promise<void>;
|
|
||||||
updateLastCardFromClipboard: () => Promise<void>;
|
|
||||||
refreshKnownWordCache: () => Promise<void>;
|
|
||||||
triggerFieldGrouping: () => Promise<void>;
|
|
||||||
markLastCardAsAudioCard: () => Promise<void>;
|
|
||||||
};
|
|
||||||
yomitan: {
|
|
||||||
loadYomitanExtension: () => Promise<unknown>;
|
|
||||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
|
||||||
openYomitanSettings: () => void;
|
|
||||||
};
|
|
||||||
subsyncRuntime: {
|
|
||||||
triggerFromConfig: () => Promise<void>;
|
|
||||||
};
|
|
||||||
dictionarySupport: {
|
|
||||||
generateCharacterDictionaryForCurrentMedia: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
|
||||||
};
|
|
||||||
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
|
|
||||||
subtitleWsService: StartupBootstrapSubtitleWebsocketLike;
|
|
||||||
annotationSubtitleWsService: StartupBootstrapSubtitleWebsocketLike;
|
|
||||||
immersion: AppReadyRuntimeInput['immersion'];
|
|
||||||
};
|
|
||||||
commands: {
|
|
||||||
createMpvClientRuntimeService: () => StartupBootstrapMpvClientLike;
|
|
||||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
|
||||||
prewarmSubtitleDictionaries: () => Promise<void>;
|
|
||||||
startBackgroundWarmupsIfAllowed: () => void;
|
|
||||||
startBackgroundWarmups: () => void;
|
|
||||||
runHeadlessInitialCommand: () => Promise<void>;
|
|
||||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
|
||||||
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
|
||||||
cycleSecondarySubMode: () => void;
|
|
||||||
refreshOverlayShortcuts: () => void;
|
|
||||||
hasMpvWebsocketPlugin: () => boolean;
|
|
||||||
startTexthooker: (port: number, websocketUrl?: string) => void;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
shouldAutoOpenFirstRunSetup: (args: CliArgs) => boolean;
|
|
||||||
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
|
||||||
runYoutubePlaybackFlow: (request: {
|
|
||||||
url: string;
|
|
||||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
|
||||||
source: CliCommandSource;
|
|
||||||
}) => Promise<void>;
|
|
||||||
getMultiCopyTimeoutMs: () => number;
|
|
||||||
shouldEnsureTrayOnStartupForInitialArgs: (
|
|
||||||
platform: NodeJS.Platform,
|
|
||||||
initialArgs: CliArgs | null | undefined,
|
|
||||||
) => boolean;
|
|
||||||
isHeadlessInitialCommand: (args: CliArgs) => boolean;
|
|
||||||
commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean;
|
|
||||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
|
||||||
handleCliCommandRuntimeServiceWithContext: (
|
|
||||||
args: CliArgs,
|
|
||||||
source: CliCommandSource,
|
|
||||||
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers,
|
|
||||||
) => void;
|
|
||||||
shouldStartApp: (args: CliArgs) => boolean;
|
|
||||||
parseArgs: (argv: string[]) => CliArgs;
|
|
||||||
printHelp: (defaultTexthookerPort: number) => void;
|
|
||||||
onWillQuitCleanupHandler: () => void;
|
|
||||||
shouldRestoreWindowsOnActivateHandler: () => boolean;
|
|
||||||
restoreWindowsOnActivateHandler: () => void;
|
|
||||||
forceX11Backend: (args: CliArgs) => void;
|
|
||||||
enforceUnsupportedWaylandMode: (args: CliArgs) => void;
|
|
||||||
getDefaultSocketPathHandler: () => string;
|
|
||||||
generateDefaultConfigFile: (
|
|
||||||
args: CliArgs,
|
|
||||||
options: {
|
|
||||||
configDir: string;
|
|
||||||
defaultConfig: unknown;
|
|
||||||
generateTemplate: (config: unknown) => string;
|
|
||||||
},
|
|
||||||
) => Promise<number>;
|
|
||||||
runStartupBootstrapRuntime: (deps: StartupBootstrapRuntimeDeps) => TStartupState;
|
|
||||||
applyStartupState: (startupState: TStartupState) => void;
|
|
||||||
getStartupModeFlags: (initialArgs: CliArgs | null | undefined) => {
|
|
||||||
shouldUseMinimalStartup: boolean;
|
|
||||||
shouldSkipHeavyStartup: boolean;
|
|
||||||
};
|
|
||||||
requestAppQuit: () => void;
|
|
||||||
};
|
|
||||||
constants: {
|
|
||||||
defaultTexthookerPort: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMainStartupBootstrap<TStartupState>(
|
|
||||||
input: MainStartupBootstrapInput<TStartupState>,
|
|
||||||
): MainStartupRuntime<TStartupState> {
|
|
||||||
let startup: MainStartupRuntime<TStartupState> | null = null;
|
|
||||||
const getStartup = (): MainStartupRuntime<TStartupState> => {
|
|
||||||
if (!startup) {
|
|
||||||
throw new Error('Main startup runtime not initialized');
|
|
||||||
}
|
|
||||||
return startup;
|
|
||||||
};
|
|
||||||
const getOverlayUi = (): StartupBootstrapOverlayUiLike | undefined =>
|
|
||||||
input.runtime.overlayUi.get();
|
|
||||||
const getSubtitlePayload = (): SubtitleData | null =>
|
|
||||||
input.appState.currentSubtitleData ??
|
|
||||||
(input.appState.currentSubText
|
|
||||||
? {
|
|
||||||
text: input.appState.currentSubText,
|
|
||||||
tokens: null,
|
|
||||||
startTime: input.appState.mpvClient?.currentSubStart ?? null,
|
|
||||||
endTime: input.appState.mpvClient?.currentSubEnd ?? null,
|
|
||||||
}
|
|
||||||
: null);
|
|
||||||
const getSubtitleFrequencyOptions = () => ({
|
|
||||||
enabled: input.config.configService.getConfig().subtitleStyle.frequencyDictionary.enabled,
|
|
||||||
topX: input.config.configService.getConfig().subtitleStyle.frequencyDictionary.topX,
|
|
||||||
mode: input.config.configService.getConfig().subtitleStyle.frequencyDictionary.mode,
|
|
||||||
});
|
|
||||||
|
|
||||||
startup = createMainStartupRuntime<TStartupState>({
|
|
||||||
appReady: {
|
|
||||||
reload: {
|
|
||||||
reloadConfigStrict: () => input.config.configService.reloadConfigStrict(),
|
|
||||||
logInfo: (message) => input.logging.appLogger.logInfo(message),
|
|
||||||
logWarning: (message) => input.logging.appLogger.logWarning(message),
|
|
||||||
showDesktopNotification: (title, options) =>
|
|
||||||
input.shell.showDesktopNotification(title, options),
|
|
||||||
startConfigHotReload: () => input.config.configHotReloadRuntime.start(),
|
|
||||||
refreshAnilistClientSecretState: (options) =>
|
|
||||||
input.runtime.anilist.refreshAnilistClientSecretStateIfEnabled(options),
|
|
||||||
failHandlers: {
|
|
||||||
logError: (details) => input.logging.logger.error(details),
|
|
||||||
showErrorBox: (title, details) => input.shell.dialog.showErrorBox(title, details),
|
|
||||||
quit: () => input.commands.requestAppQuit(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
criticalConfig: {
|
|
||||||
getConfigPath: () => input.config.configService.getConfigPath(),
|
|
||||||
failHandlers: {
|
|
||||||
logError: (message) => input.logging.logger.error(message),
|
|
||||||
showErrorBox: (title, message) => input.shell.dialog.showErrorBox(title, message),
|
|
||||||
quit: () => input.commands.requestAppQuit(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
runner: {
|
|
||||||
ensureDefaultConfigBootstrap: () => {
|
|
||||||
input.config.ensureDefaultConfigBootstrap({
|
|
||||||
configDir: input.config.configDir,
|
|
||||||
configFilePaths: input.config.getDefaultConfigFilePaths(input.config.configDir),
|
|
||||||
generateTemplate: () => input.config.generateConfigTemplate(input.config.defaultConfig),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getSubtitlePosition: () => input.appState.subtitlePosition,
|
|
||||||
loadSubtitlePosition: () => input.runtime.subtitle.loadSubtitlePosition(),
|
|
||||||
getKeybindingsCount: () => input.appState.keybindings.length,
|
|
||||||
resolveKeybindings: () => {
|
|
||||||
input.appState.keybindings = resolveKeybindings(
|
|
||||||
input.config.configService.getConfig(),
|
|
||||||
input.config.defaultKeybindings as never,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
hasMpvClient: () => Boolean(input.appState.mpvClient),
|
|
||||||
createMpvClient: () => {
|
|
||||||
input.appState.mpvClient = input.commands.createMpvClientRuntimeService();
|
|
||||||
},
|
|
||||||
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
|
|
||||||
getResolvedConfig: () => input.config.configService.getConfig(),
|
|
||||||
getConfigWarnings: () => input.config.configService.getWarnings(),
|
|
||||||
logConfigWarning: (warning) => input.logging.appLogger.logConfigWarning(warning),
|
|
||||||
setLogLevel: (level, source) => input.logging.setLogLevel(level, source),
|
|
||||||
initRuntimeOptionsManager: () => {
|
|
||||||
input.appState.runtimeOptionsManager = new RuntimeOptionsManager(
|
|
||||||
() => input.config.configService.getConfig().ankiConnect,
|
|
||||||
{
|
|
||||||
applyAnkiPatch: (patch: unknown) => {
|
|
||||||
(
|
|
||||||
input.appState.ankiIntegration as {
|
|
||||||
applyRuntimeConfigPatch?: (patch: unknown) => void;
|
|
||||||
} | null
|
|
||||||
)?.applyRuntimeConfigPatch?.(patch);
|
|
||||||
},
|
|
||||||
getSubtitleStyleConfig: () => input.config.configService.getConfig().subtitleStyle,
|
|
||||||
onOptionsChanged: () => {
|
|
||||||
input.runtime.subtitle.invalidateTokenizationCache();
|
|
||||||
void input.runtime.subtitle.refreshSubtitlePrefetchFromActiveTrack();
|
|
||||||
getOverlayUi()?.broadcastRuntimeOptionsChanged();
|
|
||||||
input.commands.refreshOverlayShortcuts();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getSubtitleTimingTracker: () => input.appState.subtitleTimingTracker,
|
|
||||||
createSubtitleTimingTracker: () => {
|
|
||||||
input.appState.subtitleTimingTracker = new SubtitleTimingTracker();
|
|
||||||
},
|
|
||||||
setSecondarySubMode: (mode) => {
|
|
||||||
input.appState.secondarySubMode = mode;
|
|
||||||
},
|
|
||||||
defaultSecondarySubMode: 'hover',
|
|
||||||
defaultWebsocketPort: input.config.defaultConfig.websocket.port,
|
|
||||||
defaultAnnotationWebsocketPort: input.config.defaultConfig.annotationWebsocket.port,
|
|
||||||
defaultTexthookerPort: input.constants.defaultTexthookerPort,
|
|
||||||
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
|
|
||||||
startSubtitleWebsocket: (port) => {
|
|
||||||
input.runtime.subtitleWsService.start(
|
|
||||||
port,
|
|
||||||
getSubtitlePayload,
|
|
||||||
getSubtitleFrequencyOptions,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
startAnnotationWebsocket: (port) => {
|
|
||||||
input.runtime.annotationSubtitleWsService.start(
|
|
||||||
port,
|
|
||||||
getSubtitlePayload,
|
|
||||||
getSubtitleFrequencyOptions,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
startTexthooker: (port, websocketUrl) => input.commands.startTexthooker(port, websocketUrl),
|
|
||||||
log: (message) => input.logging.appLogger.logInfo(message),
|
|
||||||
createMecabTokenizerAndCheck: () => input.commands.createMecabTokenizerAndCheck(),
|
|
||||||
createImmersionTracker: () => {
|
|
||||||
input.runtime.stats.ensureImmersionTrackerStarted();
|
|
||||||
},
|
|
||||||
startJellyfinRemoteSession: () => input.runtime.jellyfin.startJellyfinRemoteSession(),
|
|
||||||
loadYomitanExtension: async () => {
|
|
||||||
await input.runtime.yomitan.loadYomitanExtension();
|
|
||||||
},
|
|
||||||
ensureYomitanExtensionLoaded: async () => {
|
|
||||||
await input.runtime.yomitan.ensureYomitanExtensionLoaded();
|
|
||||||
},
|
|
||||||
handleFirstRunSetup: async () => {
|
|
||||||
const snapshot = await input.runtime.firstRun.ensureSetupStateInitialized();
|
|
||||||
input.appState.firstRunSetupCompleted = snapshot.state.status === 'completed';
|
|
||||||
if (
|
|
||||||
input.appState.initialArgs &&
|
|
||||||
input.commands.shouldAutoOpenFirstRunSetup(input.appState.initialArgs) &&
|
|
||||||
snapshot.state.status !== 'completed'
|
|
||||||
) {
|
|
||||||
input.runtime.firstRun.openFirstRunSetupWindow();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
prewarmSubtitleDictionaries: () => input.commands.prewarmSubtitleDictionaries(),
|
|
||||||
startBackgroundWarmups: () => input.commands.startBackgroundWarmupsIfAllowed(),
|
|
||||||
texthookerOnlyMode: input.appState.texthookerOnlyMode,
|
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
|
||||||
input.appState.backgroundMode
|
|
||||||
? false
|
|
||||||
: input.config.configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
|
|
||||||
setVisibleOverlayVisible: (visible) => getOverlayUi()?.setVisibleOverlayVisible(visible),
|
|
||||||
initializeOverlayRuntime: () => getOverlayUi()?.initializeOverlayRuntime(),
|
|
||||||
ensureOverlayWindowsReadyForVisibilityActions: () =>
|
|
||||||
getOverlayUi()?.ensureOverlayWindowsReadyForVisibilityActions(),
|
|
||||||
runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(),
|
|
||||||
handleInitialArgs: () => getStartup().handleInitialArgs(),
|
|
||||||
shouldRunHeadlessInitialCommand: () =>
|
|
||||||
Boolean(
|
|
||||||
input.appState.initialArgs &&
|
|
||||||
input.commands.isHeadlessInitialCommand(input.appState.initialArgs),
|
|
||||||
),
|
|
||||||
shouldUseMinimalStartup: () =>
|
|
||||||
input.commands.getStartupModeFlags(input.appState.initialArgs).shouldUseMinimalStartup,
|
|
||||||
shouldSkipHeavyStartup: () =>
|
|
||||||
input.commands.getStartupModeFlags(input.appState.initialArgs).shouldSkipHeavyStartup,
|
|
||||||
logDebug: (message) => input.logging.logger.debug(message),
|
|
||||||
now: () => Date.now(),
|
|
||||||
},
|
|
||||||
immersion: input.runtime.immersion,
|
|
||||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
|
||||||
},
|
|
||||||
cli: {
|
|
||||||
appState: {
|
|
||||||
appState: input.appState,
|
|
||||||
getInitialArgs: () => input.appState.initialArgs,
|
|
||||||
isBackgroundMode: () => input.appState.backgroundMode,
|
|
||||||
isTexthookerOnlyMode: () => input.appState.texthookerOnlyMode,
|
|
||||||
setTexthookerOnlyMode: (enabled) => {
|
|
||||||
input.appState.texthookerOnlyMode = enabled;
|
|
||||||
},
|
|
||||||
hasImmersionTracker: () => Boolean(input.appState.immersionTracker),
|
|
||||||
getMpvClient: () => input.appState.mpvClient,
|
|
||||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
defaultConfig: input.config.defaultConfig,
|
|
||||||
getResolvedConfig: () => input.config.configService.getConfig(),
|
|
||||||
setCliLogLevel: (level) => input.logging.setLogLevel(level, 'cli'),
|
|
||||||
hasMpvWebsocketPlugin: () => true,
|
|
||||||
},
|
|
||||||
io: {
|
|
||||||
texthookerService: input.runtime.texthookerService,
|
|
||||||
openExternal: (url) => input.shell.shell.openExternal(url),
|
|
||||||
logBrowserOpenError: (url, error) =>
|
|
||||||
input.logging.logger.error(`Failed to open browser for texthooker URL: ${url}`, error),
|
|
||||||
showMpvOsd: (text) => input.commands.showMpvOsd(text),
|
|
||||||
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
|
|
||||||
logInfo: (message) => input.logging.logger.info(message),
|
|
||||||
logWarn: (message) => input.logging.logger.warn(message),
|
|
||||||
logError: (message, err) => input.logging.logger.error(message, err),
|
|
||||||
},
|
|
||||||
commands: {
|
|
||||||
initializeOverlayRuntime: () => getOverlayUi()?.initializeOverlayRuntime(),
|
|
||||||
toggleVisibleOverlay: () => getOverlayUi()?.toggleVisibleOverlay(),
|
|
||||||
openFirstRunSetupWindow: () => input.runtime.firstRun.openFirstRunSetupWindow(),
|
|
||||||
setVisibleOverlayVisible: (visible) => getOverlayUi()?.setVisibleOverlayVisible(visible),
|
|
||||||
copyCurrentSubtitle: () => input.runtime.mining.copyCurrentSubtitle(),
|
|
||||||
startPendingMultiCopy: (timeoutMs) => input.commands.startPendingMultiCopy(timeoutMs),
|
|
||||||
mineSentenceCard: () => input.runtime.mining.mineSentenceCard(),
|
|
||||||
startPendingMineSentenceMultiple: (timeoutMs) =>
|
|
||||||
input.commands.startPendingMineSentenceMultiple(timeoutMs),
|
|
||||||
updateLastCardFromClipboard: () => input.runtime.mining.updateLastCardFromClipboard(),
|
|
||||||
refreshKnownWordCache: () => input.runtime.mining.refreshKnownWordCache(),
|
|
||||||
triggerFieldGrouping: () => input.runtime.mining.triggerFieldGrouping(),
|
|
||||||
triggerSubsyncFromConfig: () => input.runtime.subsyncRuntime.triggerFromConfig(),
|
|
||||||
markLastCardAsAudioCard: () => input.runtime.mining.markLastCardAsAudioCard(),
|
|
||||||
getAnilistStatus: () => input.runtime.anilist.getStatusSnapshot(),
|
|
||||||
clearAnilistToken: () => input.runtime.anilist.clearTokenState(),
|
|
||||||
openAnilistSetupWindow: () => input.runtime.anilist.openAnilistSetupWindow(),
|
|
||||||
openJellyfinSetupWindow: () => input.runtime.jellyfin.openJellyfinSetupWindow(),
|
|
||||||
getAnilistQueueStatus: () => input.runtime.anilist.getQueueStatusSnapshot(),
|
|
||||||
processNextAnilistRetryUpdate: () => input.runtime.anilist.processNextAnilistRetryUpdate(),
|
|
||||||
generateCharacterDictionary: (targetPath?: string) =>
|
|
||||||
input.commands.generateCharacterDictionary(targetPath),
|
|
||||||
runJellyfinCommand: (argsFromCommand) =>
|
|
||||||
input.runtime.jellyfin.runJellyfinCommand(argsFromCommand),
|
|
||||||
runStatsCommand: (argsFromCommand, source) =>
|
|
||||||
input.runtime.stats.runStatsCliCommand(argsFromCommand, source),
|
|
||||||
runYoutubePlaybackFlow: (request) => input.commands.runYoutubePlaybackFlow(request),
|
|
||||||
openYomitanSettings: () => input.runtime.yomitan.openYomitanSettings(),
|
|
||||||
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
|
|
||||||
openRuntimeOptionsPalette: () => getOverlayUi()?.openRuntimeOptionsPalette(),
|
|
||||||
printHelp: () => input.commands.printHelp(input.constants.defaultTexthookerPort),
|
|
||||||
stopApp: () => input.commands.requestAppQuit(),
|
|
||||||
hasMainWindow: () => Boolean(input.runtime.overlayManager.getMainWindow()),
|
|
||||||
getMultiCopyTimeoutMs: () => input.commands.getMultiCopyTimeoutMs(),
|
|
||||||
},
|
|
||||||
startup: {
|
|
||||||
shouldEnsureTrayOnStartup: () =>
|
|
||||||
input.commands.shouldEnsureTrayOnStartupForInitialArgs(
|
|
||||||
input.appLifecycle.platform,
|
|
||||||
input.appState.initialArgs,
|
|
||||||
),
|
|
||||||
shouldRunHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args),
|
|
||||||
ensureTray: () => getOverlayUi()?.ensureTray(),
|
|
||||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
|
||||||
input.commands.commandNeedsOverlayStartupPrereqs(args),
|
|
||||||
commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args),
|
|
||||||
ensureOverlayStartupPrereqs: () => getStartup().appReady.ensureOverlayStartupPrereqs(),
|
|
||||||
startBackgroundWarmups: () => input.commands.startBackgroundWarmups(),
|
|
||||||
},
|
|
||||||
handleCliCommandRuntimeServiceWithContext: (args, source, context) =>
|
|
||||||
input.commands.handleCliCommandRuntimeServiceWithContext(args, source, context),
|
|
||||||
},
|
|
||||||
headless: {
|
|
||||||
appLifecycleRuntimeRunnerMainDeps: {
|
|
||||||
app: input.appLifecycle.app as never,
|
|
||||||
platform: input.appLifecycle.platform,
|
|
||||||
shouldStartApp: (nextArgs) => input.commands.shouldStartApp(nextArgs),
|
|
||||||
parseArgs: (argv) => input.commands.parseArgs(argv),
|
|
||||||
handleCliCommand: (nextArgs, source) => getStartup().handleCliCommand(nextArgs, source),
|
|
||||||
printHelp: () => input.commands.printHelp(input.constants.defaultTexthookerPort),
|
|
||||||
logNoRunningInstance: () => input.logging.appLogger.logNoRunningInstance(),
|
|
||||||
onReady: (): Promise<void> => getStartup().appReady.runAppReady(),
|
|
||||||
onWillQuitCleanup: () => input.commands.onWillQuitCleanupHandler(),
|
|
||||||
shouldRestoreWindowsOnActivate: () =>
|
|
||||||
input.commands.shouldRestoreWindowsOnActivateHandler(),
|
|
||||||
restoreWindowsOnActivate: () => input.commands.restoreWindowsOnActivateHandler(),
|
|
||||||
shouldQuitOnWindowAllClosed: () => !input.appState.backgroundMode,
|
|
||||||
},
|
|
||||||
bootstrap: {
|
|
||||||
argv: input.appLifecycle.argv,
|
|
||||||
parseArgs: (argv) => input.commands.parseArgs(argv),
|
|
||||||
setLogLevel: (level, source) => input.logging.setLogLevel(level, source),
|
|
||||||
forceX11Backend: (args) => input.commands.forceX11Backend(args),
|
|
||||||
enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args),
|
|
||||||
shouldStartApp: (args) => input.commands.shouldStartApp(args),
|
|
||||||
getDefaultSocketPath: () => input.commands.getDefaultSocketPathHandler(),
|
|
||||||
defaultTexthookerPort: input.constants.defaultTexthookerPort,
|
|
||||||
configDir: input.config.configDir,
|
|
||||||
defaultConfig: input.config.defaultConfig,
|
|
||||||
generateConfigTemplate: (config) => input.config.generateConfigTemplate(config),
|
|
||||||
generateDefaultConfigFile: (args, options) =>
|
|
||||||
input.commands.generateDefaultConfigFile(args, options),
|
|
||||||
setExitCode: (exitCode) => {
|
|
||||||
process.exitCode = exitCode;
|
|
||||||
},
|
|
||||||
quitApp: () => input.commands.requestAppQuit(),
|
|
||||||
logGenerateConfigError: (message) => input.logging.logger.error(message),
|
|
||||||
startAppLifecycle: () => {},
|
|
||||||
},
|
|
||||||
runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps),
|
|
||||||
applyStartupState: (startupState) => input.commands.applyStartupState(startupState),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return startup;
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
import type { MainStartupBootstrapInput } from './main-startup-bootstrap';
|
|
||||||
import type { MainStartupRuntime } from './main-startup-runtime';
|
|
||||||
import type { FirstRunRuntime } from './first-run-runtime';
|
|
||||||
import { createMainStartupBootstrap } from './main-startup-bootstrap';
|
|
||||||
|
|
||||||
type StartupBootstrapYomitanRuntime<TStartupState> = {
|
|
||||||
loadYomitanExtension: MainStartupBootstrapInput<TStartupState>['runtime']['yomitan']['loadYomitanExtension'];
|
|
||||||
ensureYomitanExtensionLoaded: MainStartupBootstrapInput<TStartupState>['runtime']['yomitan']['ensureYomitanExtensionLoaded'];
|
|
||||||
openYomitanSettings: MainStartupBootstrapInput<TStartupState>['runtime']['yomitan']['openYomitanSettings'];
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface MainStartupRuntimeBootstrapInput<TStartupState> {
|
|
||||||
appState: MainStartupBootstrapInput<TStartupState>['appState'];
|
|
||||||
appLifecycle: {
|
|
||||||
app: MainStartupBootstrapInput<TStartupState>['appLifecycle']['app'];
|
|
||||||
argv: string[];
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
};
|
|
||||||
config: MainStartupBootstrapInput<TStartupState>['config'];
|
|
||||||
logging: MainStartupBootstrapInput<TStartupState>['logging'];
|
|
||||||
shell: MainStartupBootstrapInput<TStartupState>['shell'];
|
|
||||||
runtime: Omit<MainStartupBootstrapInput<TStartupState>['runtime'], 'overlayUi' | 'yomitan'> & {
|
|
||||||
texthookerService: {
|
|
||||||
isRunning: () => boolean;
|
|
||||||
start: (port: number, websocketUrl?: string) => void;
|
|
||||||
};
|
|
||||||
getOverlayUi: MainStartupBootstrapInput<TStartupState>['runtime']['overlayUi']['get'];
|
|
||||||
getYomitanRuntime: () => StartupBootstrapYomitanRuntime<TStartupState>;
|
|
||||||
getCharacterDictionaryDisabledReason: () => string | null;
|
|
||||||
};
|
|
||||||
commands: Omit<
|
|
||||||
MainStartupBootstrapInput<TStartupState>['commands'],
|
|
||||||
| 'startTexthooker'
|
|
||||||
| 'generateCharacterDictionary'
|
|
||||||
| 'runYoutubePlaybackFlow'
|
|
||||||
| 'getMultiCopyTimeoutMs'
|
|
||||||
> & {
|
|
||||||
getConfiguredShortcuts: () => { multiCopyTimeoutMs: number };
|
|
||||||
runYoutubePlaybackFlow: MainStartupBootstrapInput<TStartupState>['commands']['runYoutubePlaybackFlow'];
|
|
||||||
};
|
|
||||||
constants: MainStartupBootstrapInput<TStartupState>['constants'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MainStartupRuntimeBootstrap<TStartupState> {
|
|
||||||
startupRuntime: MainStartupRuntime<TStartupState>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MainStartupRuntimeFromMainStateInput<TStartupState> {
|
|
||||||
appState: MainStartupRuntimeBootstrapInput<TStartupState>['appState'];
|
|
||||||
appLifecycle: MainStartupRuntimeBootstrapInput<TStartupState>['appLifecycle'];
|
|
||||||
config: MainStartupRuntimeBootstrapInput<TStartupState>['config'];
|
|
||||||
logging: MainStartupRuntimeBootstrapInput<TStartupState>['logging'];
|
|
||||||
shell: MainStartupRuntimeBootstrapInput<TStartupState>['shell'];
|
|
||||||
runtime: {
|
|
||||||
subtitle: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['subtitle'];
|
|
||||||
getOverlayUi: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['getOverlayUi'];
|
|
||||||
overlayManager: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['overlayManager'];
|
|
||||||
firstRun: {
|
|
||||||
ensureSetupStateInitialized: FirstRunRuntime['ensureSetupStateInitialized'];
|
|
||||||
openFirstRunSetupWindow: () => void;
|
|
||||||
};
|
|
||||||
anilist: {
|
|
||||||
refreshAnilistClientSecretStateIfEnabled: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['refreshAnilistClientSecretStateIfEnabled'];
|
|
||||||
openAnilistSetupWindow: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['openAnilistSetupWindow'];
|
|
||||||
getStatusSnapshot: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['getStatusSnapshot'];
|
|
||||||
clearTokenState: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['clearTokenState'];
|
|
||||||
getQueueStatusSnapshot: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['getQueueStatusSnapshot'];
|
|
||||||
processNextAnilistRetryUpdate: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['processNextAnilistRetryUpdate'];
|
|
||||||
};
|
|
||||||
jellyfin: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['jellyfin'];
|
|
||||||
stats: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['stats'];
|
|
||||||
mining: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['mining'];
|
|
||||||
texthookerService: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['texthookerService'];
|
|
||||||
yomitan: StartupBootstrapYomitanRuntime<TStartupState>;
|
|
||||||
getCharacterDictionaryDisabledReason: () => string | null;
|
|
||||||
subsyncRuntime: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['subsyncRuntime'];
|
|
||||||
dictionarySupport: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['dictionarySupport'];
|
|
||||||
subtitleWsService: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['subtitleWsService'];
|
|
||||||
annotationSubtitleWsService: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['annotationSubtitleWsService'];
|
|
||||||
immersion: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['immersion'];
|
|
||||||
};
|
|
||||||
commands: {
|
|
||||||
mpvRuntime: {
|
|
||||||
createMpvClientRuntimeService: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['createMpvClientRuntimeService'];
|
|
||||||
createMecabTokenizerAndCheck: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['createMecabTokenizerAndCheck'];
|
|
||||||
prewarmSubtitleDictionaries: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['prewarmSubtitleDictionaries'];
|
|
||||||
startBackgroundWarmups: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['startBackgroundWarmups'];
|
|
||||||
};
|
|
||||||
runHeadlessInitialCommand: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['runHeadlessInitialCommand'];
|
|
||||||
shortcuts: {
|
|
||||||
startPendingMultiCopy: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['startPendingMultiCopy'];
|
|
||||||
startPendingMineSentenceMultiple: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['startPendingMineSentenceMultiple'];
|
|
||||||
refreshOverlayShortcuts: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['refreshOverlayShortcuts'];
|
|
||||||
getConfiguredShortcuts: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['getConfiguredShortcuts'];
|
|
||||||
};
|
|
||||||
cycleSecondarySubMode: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['cycleSecondarySubMode'];
|
|
||||||
hasMpvWebsocketPlugin: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['hasMpvWebsocketPlugin'];
|
|
||||||
showMpvOsd: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['showMpvOsd'];
|
|
||||||
shouldAutoOpenFirstRunSetup: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['shouldAutoOpenFirstRunSetup'];
|
|
||||||
youtube: {
|
|
||||||
runYoutubePlaybackFlow: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['runYoutubePlaybackFlow'];
|
|
||||||
};
|
|
||||||
shouldEnsureTrayOnStartupForInitialArgs: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['shouldEnsureTrayOnStartupForInitialArgs'];
|
|
||||||
isHeadlessInitialCommand: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['isHeadlessInitialCommand'];
|
|
||||||
commandNeedsOverlayStartupPrereqs: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['commandNeedsOverlayStartupPrereqs'];
|
|
||||||
commandNeedsOverlayRuntime: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['commandNeedsOverlayRuntime'];
|
|
||||||
handleCliCommandRuntimeServiceWithContext: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['handleCliCommandRuntimeServiceWithContext'];
|
|
||||||
shouldStartApp: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['shouldStartApp'];
|
|
||||||
parseArgs: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['parseArgs'];
|
|
||||||
printHelp: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['printHelp'];
|
|
||||||
onWillQuitCleanupHandler: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['onWillQuitCleanupHandler'];
|
|
||||||
shouldRestoreWindowsOnActivateHandler: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['shouldRestoreWindowsOnActivateHandler'];
|
|
||||||
restoreWindowsOnActivateHandler: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['restoreWindowsOnActivateHandler'];
|
|
||||||
forceX11Backend: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['forceX11Backend'];
|
|
||||||
enforceUnsupportedWaylandMode: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['enforceUnsupportedWaylandMode'];
|
|
||||||
getDefaultSocketPath: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['getDefaultSocketPathHandler'];
|
|
||||||
generateDefaultConfigFile: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['generateDefaultConfigFile'];
|
|
||||||
runStartupBootstrapRuntime: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['runStartupBootstrapRuntime'];
|
|
||||||
applyStartupState: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['applyStartupState'];
|
|
||||||
getStartupModeFlags: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['getStartupModeFlags'];
|
|
||||||
requestAppQuit: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['requestAppQuit'];
|
|
||||||
};
|
|
||||||
constants: MainStartupRuntimeBootstrapInput<TStartupState>['constants'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMainStartupRuntimeBootstrap<TStartupState>(
|
|
||||||
input: MainStartupRuntimeBootstrapInput<TStartupState>,
|
|
||||||
): MainStartupRuntimeBootstrap<TStartupState> {
|
|
||||||
const startupRuntime = createMainStartupBootstrap<TStartupState>({
|
|
||||||
appState: input.appState,
|
|
||||||
appLifecycle: {
|
|
||||||
app: input.appLifecycle.app,
|
|
||||||
argv: input.appLifecycle.argv,
|
|
||||||
platform: input.appLifecycle.platform,
|
|
||||||
},
|
|
||||||
config: input.config,
|
|
||||||
logging: input.logging,
|
|
||||||
shell: input.shell,
|
|
||||||
runtime: {
|
|
||||||
...input.runtime,
|
|
||||||
overlayUi: {
|
|
||||||
get: () => input.runtime.getOverlayUi(),
|
|
||||||
},
|
|
||||||
yomitan: {
|
|
||||||
loadYomitanExtension: () => input.runtime.getYomitanRuntime().loadYomitanExtension(),
|
|
||||||
ensureYomitanExtensionLoaded: () =>
|
|
||||||
input.runtime.getYomitanRuntime().ensureYomitanExtensionLoaded(),
|
|
||||||
openYomitanSettings: () => input.runtime.getYomitanRuntime().openYomitanSettings(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
commands: {
|
|
||||||
...input.commands,
|
|
||||||
startTexthooker: (port, websocketUrl) => {
|
|
||||||
if (!input.runtime.texthookerService.isRunning()) {
|
|
||||||
input.runtime.texthookerService.start(port, websocketUrl);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
generateCharacterDictionary: async (targetPath?: string) => {
|
|
||||||
const disabledReason = input.runtime.getCharacterDictionaryDisabledReason();
|
|
||||||
if (disabledReason) {
|
|
||||||
throw new Error(disabledReason);
|
|
||||||
}
|
|
||||||
return await input.runtime.dictionarySupport.generateCharacterDictionaryForCurrentMedia(
|
|
||||||
targetPath,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
runYoutubePlaybackFlow: (request) => input.commands.runYoutubePlaybackFlow(request),
|
|
||||||
getMultiCopyTimeoutMs: () => input.commands.getConfiguredShortcuts().multiCopyTimeoutMs,
|
|
||||||
},
|
|
||||||
constants: input.constants,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
startupRuntime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMainStartupRuntimeFromMainState<TStartupState>(
|
|
||||||
input: MainStartupRuntimeFromMainStateInput<TStartupState>,
|
|
||||||
): MainStartupRuntimeBootstrap<TStartupState> {
|
|
||||||
return createMainStartupRuntimeBootstrap<TStartupState>({
|
|
||||||
appState: input.appState,
|
|
||||||
appLifecycle: input.appLifecycle,
|
|
||||||
config: input.config,
|
|
||||||
logging: input.logging,
|
|
||||||
shell: input.shell,
|
|
||||||
runtime: {
|
|
||||||
subtitle: input.runtime.subtitle,
|
|
||||||
getOverlayUi: () => input.runtime.getOverlayUi(),
|
|
||||||
overlayManager: input.runtime.overlayManager,
|
|
||||||
firstRun: {
|
|
||||||
ensureSetupStateInitialized: () => input.runtime.firstRun.ensureSetupStateInitialized(),
|
|
||||||
openFirstRunSetupWindow: () => input.runtime.firstRun.openFirstRunSetupWindow(),
|
|
||||||
},
|
|
||||||
anilist: {
|
|
||||||
refreshAnilistClientSecretStateIfEnabled: (options) =>
|
|
||||||
input.runtime.anilist.refreshAnilistClientSecretStateIfEnabled(options),
|
|
||||||
openAnilistSetupWindow: () => input.runtime.anilist.openAnilistSetupWindow(),
|
|
||||||
getStatusSnapshot: () => input.runtime.anilist.getStatusSnapshot(),
|
|
||||||
clearTokenState: () => input.runtime.anilist.clearTokenState(),
|
|
||||||
getQueueStatusSnapshot: () => input.runtime.anilist.getQueueStatusSnapshot(),
|
|
||||||
processNextAnilistRetryUpdate: () => input.runtime.anilist.processNextAnilistRetryUpdate(),
|
|
||||||
},
|
|
||||||
jellyfin: input.runtime.jellyfin,
|
|
||||||
stats: input.runtime.stats,
|
|
||||||
mining: input.runtime.mining,
|
|
||||||
texthookerService: input.runtime.texthookerService,
|
|
||||||
getYomitanRuntime: () => input.runtime.yomitan,
|
|
||||||
getCharacterDictionaryDisabledReason: () =>
|
|
||||||
input.runtime.getCharacterDictionaryDisabledReason(),
|
|
||||||
subsyncRuntime: input.runtime.subsyncRuntime,
|
|
||||||
dictionarySupport: input.runtime.dictionarySupport,
|
|
||||||
subtitleWsService: input.runtime.subtitleWsService,
|
|
||||||
annotationSubtitleWsService: input.runtime.annotationSubtitleWsService,
|
|
||||||
immersion: input.runtime.immersion,
|
|
||||||
},
|
|
||||||
commands: {
|
|
||||||
createMpvClientRuntimeService: () =>
|
|
||||||
input.commands.mpvRuntime.createMpvClientRuntimeService(),
|
|
||||||
createMecabTokenizerAndCheck: () => input.commands.mpvRuntime.createMecabTokenizerAndCheck(),
|
|
||||||
prewarmSubtitleDictionaries: () => input.commands.mpvRuntime.prewarmSubtitleDictionaries(),
|
|
||||||
startBackgroundWarmupsIfAllowed: () => input.commands.mpvRuntime.startBackgroundWarmups(),
|
|
||||||
startBackgroundWarmups: () => input.commands.mpvRuntime.startBackgroundWarmups(),
|
|
||||||
runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(),
|
|
||||||
startPendingMultiCopy: (timeoutMs) =>
|
|
||||||
input.commands.shortcuts.startPendingMultiCopy(timeoutMs),
|
|
||||||
startPendingMineSentenceMultiple: (timeoutMs) =>
|
|
||||||
input.commands.shortcuts.startPendingMineSentenceMultiple(timeoutMs),
|
|
||||||
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
|
|
||||||
refreshOverlayShortcuts: () => input.commands.shortcuts.refreshOverlayShortcuts(),
|
|
||||||
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
|
|
||||||
showMpvOsd: (text) => input.commands.showMpvOsd(text),
|
|
||||||
shouldAutoOpenFirstRunSetup: (args) => input.commands.shouldAutoOpenFirstRunSetup(args),
|
|
||||||
getConfiguredShortcuts: () => input.commands.shortcuts.getConfiguredShortcuts(),
|
|
||||||
runYoutubePlaybackFlow: (request) => input.commands.youtube.runYoutubePlaybackFlow(request),
|
|
||||||
shouldEnsureTrayOnStartupForInitialArgs: (platform, initialArgs) =>
|
|
||||||
input.commands.shouldEnsureTrayOnStartupForInitialArgs(platform, initialArgs),
|
|
||||||
isHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args),
|
|
||||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
|
||||||
input.commands.commandNeedsOverlayStartupPrereqs(args),
|
|
||||||
commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args),
|
|
||||||
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
|
|
||||||
input.commands.handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
|
|
||||||
shouldStartApp: (args) => input.commands.shouldStartApp(args),
|
|
||||||
parseArgs: (argv) => input.commands.parseArgs(argv),
|
|
||||||
printHelp: input.commands.printHelp,
|
|
||||||
onWillQuitCleanupHandler: () => input.commands.onWillQuitCleanupHandler(),
|
|
||||||
shouldRestoreWindowsOnActivateHandler: () =>
|
|
||||||
input.commands.shouldRestoreWindowsOnActivateHandler(),
|
|
||||||
restoreWindowsOnActivateHandler: () => input.commands.restoreWindowsOnActivateHandler(),
|
|
||||||
forceX11Backend: (args) => input.commands.forceX11Backend(args),
|
|
||||||
enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args),
|
|
||||||
getDefaultSocketPathHandler: () => input.commands.getDefaultSocketPath(),
|
|
||||||
generateDefaultConfigFile: input.commands.generateDefaultConfigFile,
|
|
||||||
runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps),
|
|
||||||
applyStartupState: (startupState) => input.commands.applyStartupState(startupState),
|
|
||||||
getStartupModeFlags: input.commands.getStartupModeFlags,
|
|
||||||
requestAppQuit: input.commands.requestAppQuit,
|
|
||||||
},
|
|
||||||
constants: input.constants,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
import type { AnilistRuntime } from './anilist-runtime';
|
|
||||||
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
|
|
||||||
import type { FirstRunRuntime } from './first-run-runtime';
|
|
||||||
import type { JellyfinRuntime } from './jellyfin-runtime';
|
|
||||||
import {
|
|
||||||
createMainStartupRuntimeFromMainState,
|
|
||||||
type MainStartupRuntimeBootstrap,
|
|
||||||
type MainStartupRuntimeFromMainStateInput,
|
|
||||||
} from './main-startup-runtime-bootstrap';
|
|
||||||
import type { MiningRuntime } from './mining-runtime';
|
|
||||||
import type { MpvRuntime } from './mpv-runtime';
|
|
||||||
import type { ShortcutsRuntime } from './shortcuts-runtime';
|
|
||||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
|
||||||
import type { YoutubeRuntime } from './youtube-runtime';
|
|
||||||
|
|
||||||
export interface MainStartupRuntimeCoordinatorInput<TStartupState> {
|
|
||||||
appState: MainStartupRuntimeFromMainStateInput<TStartupState>['appState'];
|
|
||||||
appLifecycle: MainStartupRuntimeFromMainStateInput<TStartupState>['appLifecycle'];
|
|
||||||
config: MainStartupRuntimeFromMainStateInput<TStartupState>['config'];
|
|
||||||
logging: MainStartupRuntimeFromMainStateInput<TStartupState>['logging'];
|
|
||||||
shell: MainStartupRuntimeFromMainStateInput<TStartupState>['shell'];
|
|
||||||
runtime: {
|
|
||||||
subtitle: SubtitleRuntime;
|
|
||||||
getOverlayUi: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['getOverlayUi'];
|
|
||||||
overlayManager: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['overlayManager'];
|
|
||||||
firstRun: Pick<FirstRunRuntime, 'ensureSetupStateInitialized' | 'openFirstRunSetupWindow'>;
|
|
||||||
anilist: AnilistRuntime;
|
|
||||||
jellyfin: JellyfinRuntime;
|
|
||||||
stats: {
|
|
||||||
ensureImmersionTrackerStarted: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['stats']['ensureImmersionTrackerStarted'];
|
|
||||||
runStatsCliCommand: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['stats']['runStatsCliCommand'];
|
|
||||||
immersion: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['immersion'];
|
|
||||||
};
|
|
||||||
mining: {
|
|
||||||
copyCurrentSubtitle: Pick<MiningRuntime, 'copyCurrentSubtitle'>['copyCurrentSubtitle'];
|
|
||||||
markLastCardAsAudioCard: Pick<
|
|
||||||
MiningRuntime,
|
|
||||||
'markLastCardAsAudioCard'
|
|
||||||
>['markLastCardAsAudioCard'];
|
|
||||||
mineSentenceCard: Pick<MiningRuntime, 'mineSentenceCard'>['mineSentenceCard'];
|
|
||||||
refreshKnownWordCache: Pick<MiningRuntime, 'refreshKnownWordCache'>['refreshKnownWordCache'];
|
|
||||||
triggerFieldGrouping: Pick<MiningRuntime, 'triggerFieldGrouping'>['triggerFieldGrouping'];
|
|
||||||
updateLastCardFromClipboard: Pick<
|
|
||||||
MiningRuntime,
|
|
||||||
'updateLastCardFromClipboard'
|
|
||||||
>['updateLastCardFromClipboard'];
|
|
||||||
};
|
|
||||||
texthookerService: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['texthookerService'];
|
|
||||||
yomitan: {
|
|
||||||
loadYomitanExtension: () => Promise<unknown>;
|
|
||||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
|
||||||
openYomitanSettings: () => boolean;
|
|
||||||
getCharacterDictionaryDisabledReason: () => string | null;
|
|
||||||
};
|
|
||||||
subsyncRuntime: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['subsyncRuntime'];
|
|
||||||
dictionarySupport: DictionarySupportRuntime;
|
|
||||||
subtitleWsService: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['subtitleWsService'];
|
|
||||||
annotationSubtitleWsService: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['annotationSubtitleWsService'];
|
|
||||||
};
|
|
||||||
commands: {
|
|
||||||
mpvRuntime: {
|
|
||||||
createMpvClientRuntimeService: Pick<
|
|
||||||
MpvRuntime,
|
|
||||||
'createMpvClientRuntimeService'
|
|
||||||
>['createMpvClientRuntimeService'];
|
|
||||||
createMecabTokenizerAndCheck: Pick<
|
|
||||||
MpvRuntime,
|
|
||||||
'createMecabTokenizerAndCheck'
|
|
||||||
>['createMecabTokenizerAndCheck'];
|
|
||||||
prewarmSubtitleDictionaries: Pick<
|
|
||||||
MpvRuntime,
|
|
||||||
'prewarmSubtitleDictionaries'
|
|
||||||
>['prewarmSubtitleDictionaries'];
|
|
||||||
startBackgroundWarmups: Pick<MpvRuntime, 'startBackgroundWarmups'>['startBackgroundWarmups'];
|
|
||||||
};
|
|
||||||
runHeadlessInitialCommand: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['runHeadlessInitialCommand'];
|
|
||||||
shortcuts: Pick<
|
|
||||||
ShortcutsRuntime,
|
|
||||||
| 'getConfiguredShortcuts'
|
|
||||||
| 'refreshOverlayShortcuts'
|
|
||||||
| 'startPendingMineSentenceMultiple'
|
|
||||||
| 'startPendingMultiCopy'
|
|
||||||
>;
|
|
||||||
cycleSecondarySubMode: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['cycleSecondarySubMode'];
|
|
||||||
hasMpvWebsocketPlugin: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['hasMpvWebsocketPlugin'];
|
|
||||||
showMpvOsd: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['showMpvOsd'];
|
|
||||||
shouldAutoOpenFirstRunSetup: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['shouldAutoOpenFirstRunSetup'];
|
|
||||||
youtube: Pick<YoutubeRuntime, 'runYoutubePlaybackFlow'>;
|
|
||||||
shouldEnsureTrayOnStartupForInitialArgs: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['shouldEnsureTrayOnStartupForInitialArgs'];
|
|
||||||
isHeadlessInitialCommand: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['isHeadlessInitialCommand'];
|
|
||||||
commandNeedsOverlayStartupPrereqs: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['commandNeedsOverlayStartupPrereqs'];
|
|
||||||
commandNeedsOverlayRuntime: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['commandNeedsOverlayRuntime'];
|
|
||||||
handleCliCommandRuntimeServiceWithContext: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['handleCliCommandRuntimeServiceWithContext'];
|
|
||||||
shouldStartApp: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['shouldStartApp'];
|
|
||||||
parseArgs: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['parseArgs'];
|
|
||||||
printHelp: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['printHelp'];
|
|
||||||
onWillQuitCleanupHandler: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['onWillQuitCleanupHandler'];
|
|
||||||
shouldRestoreWindowsOnActivateHandler: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['shouldRestoreWindowsOnActivateHandler'];
|
|
||||||
restoreWindowsOnActivateHandler: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['restoreWindowsOnActivateHandler'];
|
|
||||||
forceX11Backend: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['forceX11Backend'];
|
|
||||||
enforceUnsupportedWaylandMode: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['enforceUnsupportedWaylandMode'];
|
|
||||||
getDefaultSocketPath: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['getDefaultSocketPath'];
|
|
||||||
generateDefaultConfigFile: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['generateDefaultConfigFile'];
|
|
||||||
runStartupBootstrapRuntime: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['runStartupBootstrapRuntime'];
|
|
||||||
applyStartupState: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['applyStartupState'];
|
|
||||||
getStartupModeFlags: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['getStartupModeFlags'];
|
|
||||||
requestAppQuit: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['requestAppQuit'];
|
|
||||||
};
|
|
||||||
constants: MainStartupRuntimeFromMainStateInput<TStartupState>['constants'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MainStartupRuntimeFromProcessStateInput<TStartupState> {
|
|
||||||
appState: MainStartupRuntimeCoordinatorInput<TStartupState>['appState'];
|
|
||||||
appLifecycle: MainStartupRuntimeCoordinatorInput<TStartupState>['appLifecycle'];
|
|
||||||
config: MainStartupRuntimeCoordinatorInput<TStartupState>['config'];
|
|
||||||
logging: MainStartupRuntimeCoordinatorInput<TStartupState>['logging'];
|
|
||||||
shell: MainStartupRuntimeCoordinatorInput<TStartupState>['shell'];
|
|
||||||
runtime: {
|
|
||||||
subtitle: SubtitleRuntime;
|
|
||||||
startupOverlayUiAdapter: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['getOverlayUi'] extends () => infer T
|
|
||||||
? T
|
|
||||||
: never;
|
|
||||||
overlayManager: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['overlayManager'];
|
|
||||||
firstRun: Pick<FirstRunRuntime, 'ensureSetupStateInitialized' | 'openFirstRunSetupWindow'>;
|
|
||||||
anilist: AnilistRuntime;
|
|
||||||
jellyfin: JellyfinRuntime;
|
|
||||||
stats: {
|
|
||||||
ensureImmersionTrackerStarted: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['stats']['ensureImmersionTrackerStarted'];
|
|
||||||
runStatsCliCommand: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['stats']['runStatsCliCommand'];
|
|
||||||
immersion: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['stats']['immersion'];
|
|
||||||
};
|
|
||||||
mining: MiningRuntime;
|
|
||||||
texthookerService: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['texthookerService'];
|
|
||||||
yomitan: {
|
|
||||||
loadYomitanExtension: () => Promise<unknown>;
|
|
||||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
|
||||||
openYomitanSettings: () => boolean;
|
|
||||||
getCharacterDictionaryDisabledReason: () => string | null;
|
|
||||||
};
|
|
||||||
subsyncRuntime: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['subsyncRuntime'];
|
|
||||||
dictionarySupport: DictionarySupportRuntime;
|
|
||||||
subtitleWsService: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['subtitleWsService'];
|
|
||||||
annotationSubtitleWsService: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['annotationSubtitleWsService'];
|
|
||||||
};
|
|
||||||
commands: {
|
|
||||||
mpvRuntime: MpvRuntime;
|
|
||||||
runHeadlessInitialCommand: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['runHeadlessInitialCommand'];
|
|
||||||
shortcuts: Pick<
|
|
||||||
ShortcutsRuntime,
|
|
||||||
| 'getConfiguredShortcuts'
|
|
||||||
| 'refreshOverlayShortcuts'
|
|
||||||
| 'startPendingMineSentenceMultiple'
|
|
||||||
| 'startPendingMultiCopy'
|
|
||||||
>;
|
|
||||||
cycleSecondarySubMode: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['cycleSecondarySubMode'];
|
|
||||||
hasMpvWebsocketPlugin: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['hasMpvWebsocketPlugin'];
|
|
||||||
showMpvOsd: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['showMpvOsd'];
|
|
||||||
shouldAutoOpenFirstRunSetup: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['shouldAutoOpenFirstRunSetup'];
|
|
||||||
youtube: YoutubeRuntime;
|
|
||||||
shouldEnsureTrayOnStartupForInitialArgs: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['shouldEnsureTrayOnStartupForInitialArgs'];
|
|
||||||
isHeadlessInitialCommand: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['isHeadlessInitialCommand'];
|
|
||||||
commandNeedsOverlayStartupPrereqs: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['commandNeedsOverlayStartupPrereqs'];
|
|
||||||
commandNeedsOverlayRuntime: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['commandNeedsOverlayRuntime'];
|
|
||||||
handleCliCommandRuntimeServiceWithContext: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['handleCliCommandRuntimeServiceWithContext'];
|
|
||||||
shouldStartApp: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['shouldStartApp'];
|
|
||||||
parseArgs: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['parseArgs'];
|
|
||||||
printHelp: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['printHelp'];
|
|
||||||
onWillQuitCleanupHandler: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['onWillQuitCleanupHandler'];
|
|
||||||
shouldRestoreWindowsOnActivateHandler: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['shouldRestoreWindowsOnActivateHandler'];
|
|
||||||
restoreWindowsOnActivateHandler: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['restoreWindowsOnActivateHandler'];
|
|
||||||
forceX11Backend: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['forceX11Backend'];
|
|
||||||
enforceUnsupportedWaylandMode: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['enforceUnsupportedWaylandMode'];
|
|
||||||
getDefaultSocketPath: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['getDefaultSocketPath'];
|
|
||||||
generateDefaultConfigFile: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['generateDefaultConfigFile'];
|
|
||||||
runStartupBootstrapRuntime: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['runStartupBootstrapRuntime'];
|
|
||||||
applyStartupState: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['applyStartupState'];
|
|
||||||
getStartupModeFlags: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['getStartupModeFlags'];
|
|
||||||
requestAppQuit: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['requestAppQuit'];
|
|
||||||
};
|
|
||||||
constants: MainStartupRuntimeCoordinatorInput<TStartupState>['constants'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMainStartupRuntimeCoordinator<TStartupState>(
|
|
||||||
input: MainStartupRuntimeCoordinatorInput<TStartupState>,
|
|
||||||
): MainStartupRuntimeBootstrap<TStartupState> {
|
|
||||||
return createMainStartupRuntimeFromMainState<TStartupState>({
|
|
||||||
appState: input.appState,
|
|
||||||
appLifecycle: input.appLifecycle,
|
|
||||||
config: input.config,
|
|
||||||
logging: input.logging,
|
|
||||||
shell: input.shell,
|
|
||||||
runtime: {
|
|
||||||
subtitle: {
|
|
||||||
loadSubtitlePosition: () => input.runtime.subtitle.loadSubtitlePosition(),
|
|
||||||
invalidateTokenizationCache: () => {
|
|
||||||
input.runtime.subtitle.invalidateTokenizationCache();
|
|
||||||
},
|
|
||||||
refreshSubtitlePrefetchFromActiveTrack: () =>
|
|
||||||
input.runtime.subtitle.refreshSubtitlePrefetchFromActiveTrack(),
|
|
||||||
},
|
|
||||||
getOverlayUi: () => input.runtime.getOverlayUi(),
|
|
||||||
overlayManager: input.runtime.overlayManager,
|
|
||||||
firstRun: input.runtime.firstRun,
|
|
||||||
anilist: input.runtime.anilist,
|
|
||||||
jellyfin: {
|
|
||||||
startJellyfinRemoteSession: () => input.runtime.jellyfin.startJellyfinRemoteSession(),
|
|
||||||
openJellyfinSetupWindow: () => input.runtime.jellyfin.openJellyfinSetupWindow(),
|
|
||||||
runJellyfinCommand: (argsFromCommand) =>
|
|
||||||
input.runtime.jellyfin.runJellyfinCommand(argsFromCommand),
|
|
||||||
},
|
|
||||||
stats: {
|
|
||||||
ensureImmersionTrackerStarted: () => input.runtime.stats.ensureImmersionTrackerStarted(),
|
|
||||||
runStatsCliCommand: (argsFromCommand, source) =>
|
|
||||||
input.runtime.stats.runStatsCliCommand(argsFromCommand, source),
|
|
||||||
},
|
|
||||||
mining: {
|
|
||||||
copyCurrentSubtitle: () => input.runtime.mining.copyCurrentSubtitle(),
|
|
||||||
mineSentenceCard: () => input.runtime.mining.mineSentenceCard(),
|
|
||||||
updateLastCardFromClipboard: () => input.runtime.mining.updateLastCardFromClipboard(),
|
|
||||||
refreshKnownWordCache: () => input.runtime.mining.refreshKnownWordCache(),
|
|
||||||
triggerFieldGrouping: () => input.runtime.mining.triggerFieldGrouping(),
|
|
||||||
markLastCardAsAudioCard: () => input.runtime.mining.markLastCardAsAudioCard(),
|
|
||||||
},
|
|
||||||
texthookerService: input.runtime.texthookerService,
|
|
||||||
yomitan: {
|
|
||||||
loadYomitanExtension: () => input.runtime.yomitan.loadYomitanExtension(),
|
|
||||||
ensureYomitanExtensionLoaded: () => input.runtime.yomitan.ensureYomitanExtensionLoaded(),
|
|
||||||
openYomitanSettings: () => input.runtime.yomitan.openYomitanSettings(),
|
|
||||||
},
|
|
||||||
getCharacterDictionaryDisabledReason: () =>
|
|
||||||
input.runtime.yomitan.getCharacterDictionaryDisabledReason(),
|
|
||||||
subsyncRuntime: input.runtime.subsyncRuntime,
|
|
||||||
dictionarySupport: input.runtime.dictionarySupport,
|
|
||||||
subtitleWsService: input.runtime.subtitleWsService,
|
|
||||||
annotationSubtitleWsService: input.runtime.annotationSubtitleWsService,
|
|
||||||
immersion: input.runtime.stats.immersion,
|
|
||||||
},
|
|
||||||
commands: {
|
|
||||||
mpvRuntime: {
|
|
||||||
createMpvClientRuntimeService: () =>
|
|
||||||
input.commands.mpvRuntime.createMpvClientRuntimeService(),
|
|
||||||
createMecabTokenizerAndCheck: () =>
|
|
||||||
input.commands.mpvRuntime.createMecabTokenizerAndCheck(),
|
|
||||||
prewarmSubtitleDictionaries: () => input.commands.mpvRuntime.prewarmSubtitleDictionaries(),
|
|
||||||
startBackgroundWarmups: () => input.commands.mpvRuntime.startBackgroundWarmups(),
|
|
||||||
},
|
|
||||||
runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(),
|
|
||||||
shortcuts: {
|
|
||||||
startPendingMultiCopy: (timeoutMs) =>
|
|
||||||
input.commands.shortcuts.startPendingMultiCopy(timeoutMs),
|
|
||||||
startPendingMineSentenceMultiple: (timeoutMs) =>
|
|
||||||
input.commands.shortcuts.startPendingMineSentenceMultiple(timeoutMs),
|
|
||||||
refreshOverlayShortcuts: () => input.commands.shortcuts.refreshOverlayShortcuts(),
|
|
||||||
getConfiguredShortcuts: () => input.commands.shortcuts.getConfiguredShortcuts(),
|
|
||||||
},
|
|
||||||
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
|
|
||||||
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
|
|
||||||
showMpvOsd: (text) => input.commands.showMpvOsd(text),
|
|
||||||
shouldAutoOpenFirstRunSetup: (args) => input.commands.shouldAutoOpenFirstRunSetup(args),
|
|
||||||
youtube: {
|
|
||||||
runYoutubePlaybackFlow: (request) => input.commands.youtube.runYoutubePlaybackFlow(request),
|
|
||||||
},
|
|
||||||
shouldEnsureTrayOnStartupForInitialArgs: (platform, initialArgs) =>
|
|
||||||
input.commands.shouldEnsureTrayOnStartupForInitialArgs(platform, initialArgs ?? null),
|
|
||||||
isHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args),
|
|
||||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
|
||||||
input.commands.commandNeedsOverlayStartupPrereqs(args),
|
|
||||||
commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args),
|
|
||||||
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
|
|
||||||
input.commands.handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
|
|
||||||
shouldStartApp: (args) => input.commands.shouldStartApp(args),
|
|
||||||
parseArgs: (argv) => input.commands.parseArgs(argv),
|
|
||||||
printHelp: input.commands.printHelp,
|
|
||||||
onWillQuitCleanupHandler: () => input.commands.onWillQuitCleanupHandler(),
|
|
||||||
shouldRestoreWindowsOnActivateHandler: () =>
|
|
||||||
input.commands.shouldRestoreWindowsOnActivateHandler(),
|
|
||||||
restoreWindowsOnActivateHandler: () => input.commands.restoreWindowsOnActivateHandler(),
|
|
||||||
forceX11Backend: (args) => input.commands.forceX11Backend(args),
|
|
||||||
enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args),
|
|
||||||
getDefaultSocketPath: () => input.commands.getDefaultSocketPath(),
|
|
||||||
generateDefaultConfigFile: input.commands.generateDefaultConfigFile,
|
|
||||||
runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps),
|
|
||||||
applyStartupState: (startupState) => input.commands.applyStartupState(startupState),
|
|
||||||
getStartupModeFlags: input.commands.getStartupModeFlags,
|
|
||||||
requestAppQuit: input.commands.requestAppQuit,
|
|
||||||
},
|
|
||||||
constants: input.constants,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMainStartupRuntimeFromProcessState<TStartupState>(
|
|
||||||
input: MainStartupRuntimeFromProcessStateInput<TStartupState>,
|
|
||||||
) {
|
|
||||||
return createMainStartupRuntimeCoordinator<TStartupState>({
|
|
||||||
appState: input.appState,
|
|
||||||
appLifecycle: input.appLifecycle,
|
|
||||||
config: input.config,
|
|
||||||
logging: input.logging,
|
|
||||||
shell: input.shell,
|
|
||||||
runtime: {
|
|
||||||
subtitle: input.runtime.subtitle,
|
|
||||||
getOverlayUi: () => input.runtime.startupOverlayUiAdapter,
|
|
||||||
overlayManager: input.runtime.overlayManager,
|
|
||||||
firstRun: input.runtime.firstRun,
|
|
||||||
anilist: input.runtime.anilist,
|
|
||||||
jellyfin: input.runtime.jellyfin,
|
|
||||||
stats: {
|
|
||||||
ensureImmersionTrackerStarted: () => input.runtime.stats.ensureImmersionTrackerStarted(),
|
|
||||||
runStatsCliCommand: (argsFromCommand, source) =>
|
|
||||||
input.runtime.stats.runStatsCliCommand(argsFromCommand, source),
|
|
||||||
immersion: input.runtime.stats.immersion,
|
|
||||||
},
|
|
||||||
mining: {
|
|
||||||
copyCurrentSubtitle: () => input.runtime.mining.copyCurrentSubtitle(),
|
|
||||||
markLastCardAsAudioCard: () => input.runtime.mining.markLastCardAsAudioCard(),
|
|
||||||
mineSentenceCard: () => input.runtime.mining.mineSentenceCard(),
|
|
||||||
refreshKnownWordCache: () => input.runtime.mining.refreshKnownWordCache(),
|
|
||||||
triggerFieldGrouping: () => input.runtime.mining.triggerFieldGrouping(),
|
|
||||||
updateLastCardFromClipboard: () => input.runtime.mining.updateLastCardFromClipboard(),
|
|
||||||
},
|
|
||||||
texthookerService: input.runtime.texthookerService,
|
|
||||||
yomitan: input.runtime.yomitan,
|
|
||||||
subsyncRuntime: input.runtime.subsyncRuntime,
|
|
||||||
dictionarySupport: input.runtime.dictionarySupport,
|
|
||||||
subtitleWsService: input.runtime.subtitleWsService,
|
|
||||||
annotationSubtitleWsService: input.runtime.annotationSubtitleWsService,
|
|
||||||
},
|
|
||||||
commands: {
|
|
||||||
mpvRuntime: {
|
|
||||||
createMpvClientRuntimeService: () =>
|
|
||||||
input.commands.mpvRuntime.createMpvClientRuntimeService(),
|
|
||||||
createMecabTokenizerAndCheck: () =>
|
|
||||||
input.commands.mpvRuntime.createMecabTokenizerAndCheck(),
|
|
||||||
prewarmSubtitleDictionaries: () => input.commands.mpvRuntime.prewarmSubtitleDictionaries(),
|
|
||||||
startBackgroundWarmups: () => input.commands.mpvRuntime.startBackgroundWarmups(),
|
|
||||||
},
|
|
||||||
runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(),
|
|
||||||
shortcuts: input.commands.shortcuts,
|
|
||||||
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
|
|
||||||
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
|
|
||||||
showMpvOsd: (text) => input.commands.showMpvOsd(text),
|
|
||||||
shouldAutoOpenFirstRunSetup: (args) => input.commands.shouldAutoOpenFirstRunSetup(args),
|
|
||||||
youtube: input.commands.youtube,
|
|
||||||
shouldEnsureTrayOnStartupForInitialArgs: (platform, initialArgs) =>
|
|
||||||
input.commands.shouldEnsureTrayOnStartupForInitialArgs(platform, initialArgs ?? null),
|
|
||||||
isHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args),
|
|
||||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
|
||||||
input.commands.commandNeedsOverlayStartupPrereqs(args),
|
|
||||||
commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args),
|
|
||||||
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
|
|
||||||
input.commands.handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
|
|
||||||
shouldStartApp: (args) => input.commands.shouldStartApp(args),
|
|
||||||
parseArgs: (argv) => input.commands.parseArgs(argv),
|
|
||||||
printHelp: input.commands.printHelp,
|
|
||||||
onWillQuitCleanupHandler: () => input.commands.onWillQuitCleanupHandler(),
|
|
||||||
shouldRestoreWindowsOnActivateHandler: () =>
|
|
||||||
input.commands.shouldRestoreWindowsOnActivateHandler(),
|
|
||||||
restoreWindowsOnActivateHandler: () => input.commands.restoreWindowsOnActivateHandler(),
|
|
||||||
forceX11Backend: (args) => input.commands.forceX11Backend(args),
|
|
||||||
enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args),
|
|
||||||
getDefaultSocketPath: () => input.commands.getDefaultSocketPath(),
|
|
||||||
generateDefaultConfigFile: input.commands.generateDefaultConfigFile,
|
|
||||||
runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps),
|
|
||||||
applyStartupState: (startupState) => input.commands.applyStartupState(startupState),
|
|
||||||
getStartupModeFlags: input.commands.getStartupModeFlags,
|
|
||||||
requestAppQuit: input.commands.requestAppQuit,
|
|
||||||
},
|
|
||||||
constants: input.constants,
|
|
||||||
}).startupRuntime;
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createMainStartupRuntime } from './main-startup-runtime';
|
|
||||||
|
|
||||||
test('main startup runtime composes app-ready, cli, and headless runtimes', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const runtime = createMainStartupRuntime<{ mode: string }>({
|
|
||||||
appReady: {
|
|
||||||
reload: {
|
|
||||||
reloadConfigStrict: () => ({ ok: true as const, path: '/tmp/config.jsonc', warnings: [] }),
|
|
||||||
logInfo: () => {},
|
|
||||||
logWarning: () => {},
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
startConfigHotReload: () => {},
|
|
||||||
refreshAnilistClientSecretState: async () => undefined,
|
|
||||||
failHandlers: {
|
|
||||||
logError: () => {},
|
|
||||||
showErrorBox: () => {},
|
|
||||||
quit: () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
criticalConfig: {
|
|
||||||
getConfigPath: () => '/tmp/config.jsonc',
|
|
||||||
failHandlers: {
|
|
||||||
logError: () => {},
|
|
||||||
showErrorBox: () => {},
|
|
||||||
quit: () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
immersion: {
|
|
||||||
getResolvedConfig: () => ({ immersionTracking: { enabled: false } }) as never,
|
|
||||||
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
|
|
||||||
createTrackerService: () => ({}) as never,
|
|
||||||
setTracker: () => {},
|
|
||||||
getMpvClient: () => null,
|
|
||||||
seedTrackerFromCurrentMedia: () => {},
|
|
||||||
logInfo: () => {},
|
|
||||||
logDebug: () => {},
|
|
||||||
logWarn: () => {},
|
|
||||||
},
|
|
||||||
runner: {
|
|
||||||
ensureDefaultConfigBootstrap: () => {
|
|
||||||
calls.push('ensureDefaultConfigBootstrap');
|
|
||||||
},
|
|
||||||
getSubtitlePosition: () => null,
|
|
||||||
loadSubtitlePosition: () => {
|
|
||||||
calls.push('loadSubtitlePosition');
|
|
||||||
},
|
|
||||||
getKeybindingsCount: () => 0,
|
|
||||||
resolveKeybindings: () => {
|
|
||||||
calls.push('resolveKeybindings');
|
|
||||||
},
|
|
||||||
hasMpvClient: () => false,
|
|
||||||
createMpvClient: () => {
|
|
||||||
calls.push('createMpvClient');
|
|
||||||
},
|
|
||||||
getRuntimeOptionsManager: () => null,
|
|
||||||
initRuntimeOptionsManager: () => {
|
|
||||||
calls.push('initRuntimeOptionsManager');
|
|
||||||
},
|
|
||||||
getSubtitleTimingTracker: () => null,
|
|
||||||
createSubtitleTimingTracker: () => {
|
|
||||||
calls.push('createSubtitleTimingTracker');
|
|
||||||
},
|
|
||||||
getResolvedConfig: () => ({ ankiConnect: { enabled: false } }) as never,
|
|
||||||
getConfigWarnings: () => [],
|
|
||||||
logConfigWarning: () => {},
|
|
||||||
setLogLevel: () => {},
|
|
||||||
setSecondarySubMode: () => {},
|
|
||||||
defaultSecondarySubMode: 'hover',
|
|
||||||
defaultWebsocketPort: 5174,
|
|
||||||
defaultAnnotationWebsocketPort: 6678,
|
|
||||||
defaultTexthookerPort: 5174,
|
|
||||||
hasMpvWebsocketPlugin: () => false,
|
|
||||||
startSubtitleWebsocket: () => {},
|
|
||||||
startAnnotationWebsocket: () => {},
|
|
||||||
startTexthooker: () => {},
|
|
||||||
log: () => {},
|
|
||||||
createMecabTokenizerAndCheck: async () => {},
|
|
||||||
loadYomitanExtension: async () => {},
|
|
||||||
ensureYomitanExtensionLoaded: async () => {
|
|
||||||
calls.push('ensureYomitanExtensionLoaded');
|
|
||||||
},
|
|
||||||
handleFirstRunSetup: async () => {},
|
|
||||||
startBackgroundWarmups: () => {
|
|
||||||
calls.push('startBackgroundWarmups');
|
|
||||||
},
|
|
||||||
texthookerOnlyMode: false,
|
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
|
||||||
setVisibleOverlayVisible: () => {},
|
|
||||||
initializeOverlayRuntime: () => {
|
|
||||||
calls.push('initializeOverlayRuntime');
|
|
||||||
},
|
|
||||||
ensureOverlayWindowsReadyForVisibilityActions: () => {
|
|
||||||
calls.push('ensureOverlayWindowsReadyForVisibilityActions');
|
|
||||||
},
|
|
||||||
handleInitialArgs: () => {
|
|
||||||
calls.push('handleInitialArgs');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
isOverlayRuntimeInitialized: () => false,
|
|
||||||
},
|
|
||||||
cli: {
|
|
||||||
appState: {
|
|
||||||
appState: {} as never,
|
|
||||||
getInitialArgs: () => null,
|
|
||||||
isBackgroundMode: () => false,
|
|
||||||
isTexthookerOnlyMode: () => false,
|
|
||||||
setTexthookerOnlyMode: () => {},
|
|
||||||
hasImmersionTracker: () => false,
|
|
||||||
getMpvClient: () => null,
|
|
||||||
isOverlayRuntimeInitialized: () => false,
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
defaultConfig: { websocket: { port: 6677 }, annotationWebsocket: { port: 6678 } } as never,
|
|
||||||
getResolvedConfig: () => ({}) as never,
|
|
||||||
setCliLogLevel: () => {},
|
|
||||||
hasMpvWebsocketPlugin: () => false,
|
|
||||||
},
|
|
||||||
io: {
|
|
||||||
texthookerService: {} as never,
|
|
||||||
openExternal: async () => {},
|
|
||||||
logBrowserOpenError: () => {},
|
|
||||||
showMpvOsd: () => {},
|
|
||||||
schedule: () => 0 as never,
|
|
||||||
logInfo: () => {},
|
|
||||||
logWarn: () => {},
|
|
||||||
logError: () => {},
|
|
||||||
},
|
|
||||||
commands: {
|
|
||||||
initializeOverlayRuntime: () => {},
|
|
||||||
toggleVisibleOverlay: () => {},
|
|
||||||
openFirstRunSetupWindow: () => {},
|
|
||||||
setVisibleOverlayVisible: () => {},
|
|
||||||
copyCurrentSubtitle: () => {},
|
|
||||||
startPendingMultiCopy: () => {},
|
|
||||||
mineSentenceCard: async () => {},
|
|
||||||
startPendingMineSentenceMultiple: () => {},
|
|
||||||
updateLastCardFromClipboard: async () => {},
|
|
||||||
refreshKnownWordCache: async () => {},
|
|
||||||
triggerFieldGrouping: async () => {},
|
|
||||||
triggerSubsyncFromConfig: async () => {},
|
|
||||||
markLastCardAsAudioCard: async () => {},
|
|
||||||
getAnilistStatus: () => ({}) as never,
|
|
||||||
clearAnilistToken: () => {},
|
|
||||||
openAnilistSetupWindow: () => {},
|
|
||||||
openJellyfinSetupWindow: () => {},
|
|
||||||
getAnilistQueueStatus: () => ({}) as never,
|
|
||||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
|
|
||||||
generateCharacterDictionary: async () => ({
|
|
||||||
zipPath: '/tmp/test.zip',
|
|
||||||
fromCache: false,
|
|
||||||
mediaId: 1,
|
|
||||||
mediaTitle: 'Test',
|
|
||||||
entryCount: 1,
|
|
||||||
}),
|
|
||||||
runJellyfinCommand: async () => {},
|
|
||||||
runStatsCommand: async () => {},
|
|
||||||
runYoutubePlaybackFlow: async () => {},
|
|
||||||
openYomitanSettings: () => {},
|
|
||||||
cycleSecondarySubMode: () => {},
|
|
||||||
openRuntimeOptionsPalette: () => {},
|
|
||||||
printHelp: () => {},
|
|
||||||
stopApp: () => {
|
|
||||||
calls.push('stopApp');
|
|
||||||
},
|
|
||||||
hasMainWindow: () => false,
|
|
||||||
getMultiCopyTimeoutMs: () => 0,
|
|
||||||
},
|
|
||||||
startup: {
|
|
||||||
shouldEnsureTrayOnStartup: () => false,
|
|
||||||
shouldRunHeadlessInitialCommand: () => false,
|
|
||||||
ensureTray: () => {},
|
|
||||||
commandNeedsOverlayStartupPrereqs: () => false,
|
|
||||||
commandNeedsOverlayRuntime: () => false,
|
|
||||||
ensureOverlayStartupPrereqs: () => {
|
|
||||||
calls.push('ensureOverlayStartupPrereqs');
|
|
||||||
},
|
|
||||||
startBackgroundWarmups: () => {
|
|
||||||
calls.push('startupStartBackgroundWarmups');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
handleCliCommandRuntimeServiceWithContext: (args) => {
|
|
||||||
calls.push(`handle:${(args as { command?: string }).command ?? 'unknown'}`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
headless: {
|
|
||||||
appLifecycleRuntimeRunnerMainDeps: {
|
|
||||||
app: { on: () => {} } as never,
|
|
||||||
platform: 'darwin',
|
|
||||||
shouldStartApp: () => true,
|
|
||||||
parseArgs: () => ({}) as never,
|
|
||||||
handleCliCommand: () => {},
|
|
||||||
printHelp: () => {},
|
|
||||||
logNoRunningInstance: () => {},
|
|
||||||
onReady: async () => {},
|
|
||||||
onWillQuitCleanup: () => {},
|
|
||||||
shouldRestoreWindowsOnActivate: () => false,
|
|
||||||
restoreWindowsOnActivate: () => {},
|
|
||||||
shouldQuitOnWindowAllClosed: () => false,
|
|
||||||
},
|
|
||||||
createAppLifecycleRuntimeRunner: () => (args) => {
|
|
||||||
calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`);
|
|
||||||
},
|
|
||||||
bootstrap: {
|
|
||||||
argv: ['node', 'main.js'],
|
|
||||||
parseArgs: () => ({ command: 'start' }) as never,
|
|
||||||
setLogLevel: () => {},
|
|
||||||
forceX11Backend: () => {},
|
|
||||||
enforceUnsupportedWaylandMode: () => {},
|
|
||||||
shouldStartApp: () => true,
|
|
||||||
getDefaultSocketPath: () => '/tmp/mpv.sock',
|
|
||||||
defaultTexthookerPort: 5174,
|
|
||||||
configDir: '/tmp/config',
|
|
||||||
defaultConfig: {} as never,
|
|
||||||
generateConfigTemplate: () => 'template',
|
|
||||||
generateDefaultConfigFile: async () => 0,
|
|
||||||
setExitCode: () => {},
|
|
||||||
quitApp: () => {},
|
|
||||||
logGenerateConfigError: () => {},
|
|
||||||
startAppLifecycle: () => {},
|
|
||||||
},
|
|
||||||
runStartupBootstrapRuntime: (deps) => {
|
|
||||||
deps.startAppLifecycle({ command: 'start' } as never);
|
|
||||||
return { mode: 'started' };
|
|
||||||
},
|
|
||||||
applyStartupState: (state: { mode: string }) => {
|
|
||||||
calls.push(`apply:${state.mode}`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(typeof runtime.appReady.runAppReady, 'function');
|
|
||||||
assert.equal(typeof runtime.cliStartup.handleCliCommand, 'function');
|
|
||||||
assert.equal(typeof runtime.headlessStartup.runAndApplyStartupState, 'function');
|
|
||||||
assert.equal(runtime.handleCliCommand, runtime.cliStartup.handleCliCommand);
|
|
||||||
assert.equal(runtime.handleInitialArgs, runtime.cliStartup.handleInitialArgs);
|
|
||||||
assert.equal(
|
|
||||||
runtime.appLifecycleRuntimeRunner,
|
|
||||||
runtime.headlessStartup.appLifecycleRuntimeRunner,
|
|
||||||
);
|
|
||||||
assert.equal(runtime.runAndApplyStartupState, runtime.headlessStartup.runAndApplyStartupState);
|
|
||||||
|
|
||||||
runtime.appReady.ensureOverlayStartupPrereqs();
|
|
||||||
runtime.handleCliCommand({ command: 'start' } as never);
|
|
||||||
assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' });
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'loadSubtitlePosition',
|
|
||||||
'resolveKeybindings',
|
|
||||||
'createMpvClient',
|
|
||||||
'initRuntimeOptionsManager',
|
|
||||||
'createSubtitleTimingTracker',
|
|
||||||
'handle:start',
|
|
||||||
'lifecycle:start',
|
|
||||||
'apply:started',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import type { AppReadyRuntimeInput, AppReadyRuntime } from './app-ready-runtime';
|
|
||||||
import type { CliStartupRuntimeInput, CliStartupRuntime } from './cli-startup-runtime';
|
|
||||||
import type {
|
|
||||||
HeadlessStartupRuntimeInput,
|
|
||||||
HeadlessStartupRuntime,
|
|
||||||
} from './headless-startup-runtime';
|
|
||||||
import { createAppReadyRuntime } from './app-ready-runtime';
|
|
||||||
import { createCliStartupRuntime } from './cli-startup-runtime';
|
|
||||||
import { createHeadlessStartupRuntime } from './headless-startup-runtime';
|
|
||||||
|
|
||||||
export interface MainStartupRuntimeInput<TStartupState> {
|
|
||||||
appReady: AppReadyRuntimeInput;
|
|
||||||
cli: CliStartupRuntimeInput;
|
|
||||||
headless: HeadlessStartupRuntimeInput<TStartupState>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MainStartupRuntime<TStartupState> {
|
|
||||||
appReady: AppReadyRuntime;
|
|
||||||
cliStartup: CliStartupRuntime;
|
|
||||||
headlessStartup: HeadlessStartupRuntime<TStartupState>;
|
|
||||||
handleCliCommand: CliStartupRuntime['handleCliCommand'];
|
|
||||||
handleInitialArgs: CliStartupRuntime['handleInitialArgs'];
|
|
||||||
appLifecycleRuntimeRunner: HeadlessStartupRuntime<TStartupState>['appLifecycleRuntimeRunner'];
|
|
||||||
runAndApplyStartupState: HeadlessStartupRuntime<TStartupState>['runAndApplyStartupState'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMainStartupRuntime<TStartupState>(
|
|
||||||
input: MainStartupRuntimeInput<TStartupState>,
|
|
||||||
): MainStartupRuntime<TStartupState> {
|
|
||||||
const appReady = createAppReadyRuntime(input.appReady);
|
|
||||||
const cliStartup = createCliStartupRuntime(input.cli);
|
|
||||||
const headlessStartup = createHeadlessStartupRuntime<TStartupState>(input.headless);
|
|
||||||
|
|
||||||
return {
|
|
||||||
appReady,
|
|
||||||
cliStartup,
|
|
||||||
headlessStartup,
|
|
||||||
handleCliCommand: cliStartup.handleCliCommand,
|
|
||||||
handleInitialArgs: cliStartup.handleInitialArgs,
|
|
||||||
appLifecycleRuntimeRunner: headlessStartup.appLifecycleRuntimeRunner,
|
|
||||||
runAndApplyStartupState: headlessStartup.runAndApplyStartupState,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { updateCurrentMediaPath } from '../core/services/subtitle-position';
|
import { updateCurrentMediaPath } from '../core/services';
|
||||||
|
|
||||||
import type { SubtitlePosition } from '../types';
|
import type { SubtitlePosition } from '../types';
|
||||||
|
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
import type { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
|
||||||
import { appendClipboardVideoToQueueRuntime } from './runtime/clipboard-queue';
|
|
||||||
import {
|
|
||||||
createUpdateLastCardFromClipboardHandler,
|
|
||||||
createRefreshKnownWordCacheHandler,
|
|
||||||
createTriggerFieldGroupingHandler,
|
|
||||||
createMarkLastCardAsAudioCardHandler,
|
|
||||||
createMineSentenceCardHandler,
|
|
||||||
} from './runtime/anki-actions';
|
|
||||||
import {
|
|
||||||
createHandleMultiCopyDigitHandler,
|
|
||||||
createCopyCurrentSubtitleHandler,
|
|
||||||
createHandleMineSentenceDigitHandler,
|
|
||||||
} from './runtime/mining-actions';
|
|
||||||
|
|
||||||
export interface MiningRuntimeInput<TAnkiIntegration = unknown, TMpvClient = unknown> {
|
|
||||||
getSubtitleTimingTracker: () => SubtitleTimingTracker;
|
|
||||||
getAnkiIntegration: () => TAnkiIntegration;
|
|
||||||
getMpvClient: () => TMpvClient;
|
|
||||||
readClipboardText: () => string;
|
|
||||||
writeClipboardText: (text: string) => void;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
sendMpvCommand: (command: (string | number)[]) => void;
|
|
||||||
updateLastCardFromClipboardCore: (options: {
|
|
||||||
ankiIntegration: TAnkiIntegration;
|
|
||||||
readClipboardText: () => string;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
}) => Promise<void>;
|
|
||||||
triggerFieldGroupingCore: (options: {
|
|
||||||
ankiIntegration: TAnkiIntegration;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
}) => Promise<void>;
|
|
||||||
markLastCardAsAudioCardCore: (options: {
|
|
||||||
ankiIntegration: TAnkiIntegration;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
}) => Promise<void>;
|
|
||||||
mineSentenceCardCore: (options: {
|
|
||||||
ankiIntegration: TAnkiIntegration;
|
|
||||||
mpvClient: TMpvClient;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
}) => Promise<boolean>;
|
|
||||||
handleMultiCopyDigitCore: (
|
|
||||||
count: number,
|
|
||||||
options: {
|
|
||||||
subtitleTimingTracker: SubtitleTimingTracker;
|
|
||||||
writeClipboardText: (text: string) => void;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
},
|
|
||||||
) => void;
|
|
||||||
copyCurrentSubtitleCore: (options: {
|
|
||||||
subtitleTimingTracker: SubtitleTimingTracker;
|
|
||||||
writeClipboardText: (text: string) => void;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
}) => void;
|
|
||||||
handleMineSentenceDigitCore: (
|
|
||||||
count: number,
|
|
||||||
options: {
|
|
||||||
subtitleTimingTracker: SubtitleTimingTracker;
|
|
||||||
ankiIntegration: TAnkiIntegration;
|
|
||||||
getCurrentSecondarySubText: () => string | undefined;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
logError: (message: string, err: unknown) => void;
|
|
||||||
onCardsMined: (count: number) => void;
|
|
||||||
},
|
|
||||||
) => void;
|
|
||||||
getCurrentSecondarySubText: () => string | undefined;
|
|
||||||
logError: (message: string, err: unknown) => void;
|
|
||||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MiningRuntime {
|
|
||||||
updateLastCardFromClipboard: () => Promise<void>;
|
|
||||||
refreshKnownWordCache: () => Promise<void>;
|
|
||||||
triggerFieldGrouping: () => Promise<void>;
|
|
||||||
markLastCardAsAudioCard: () => Promise<void>;
|
|
||||||
mineSentenceCard: () => Promise<void>;
|
|
||||||
handleMultiCopyDigit: (count: number) => void;
|
|
||||||
copyCurrentSubtitle: () => void;
|
|
||||||
handleMineSentenceDigit: (count: number) => void;
|
|
||||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMiningRuntime<TAnkiIntegration, TMpvClient>(
|
|
||||||
input: MiningRuntimeInput<TAnkiIntegration, TMpvClient>,
|
|
||||||
): MiningRuntime {
|
|
||||||
const updateLastCardFromClipboard = createUpdateLastCardFromClipboardHandler({
|
|
||||||
getAnkiIntegration: () => input.getAnkiIntegration(),
|
|
||||||
readClipboardText: () => input.readClipboardText(),
|
|
||||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
|
||||||
updateLastCardFromClipboardCore: (options) => input.updateLastCardFromClipboardCore(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
const refreshKnownWordCache = createRefreshKnownWordCacheHandler({
|
|
||||||
getAnkiIntegration: () =>
|
|
||||||
input.getAnkiIntegration() as { refreshKnownWordCache: () => Promise<void> } | null,
|
|
||||||
missingIntegrationMessage: 'AnkiConnect integration not enabled',
|
|
||||||
});
|
|
||||||
|
|
||||||
const triggerFieldGrouping = createTriggerFieldGroupingHandler({
|
|
||||||
getAnkiIntegration: () => input.getAnkiIntegration(),
|
|
||||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
|
||||||
triggerFieldGroupingCore: (options) => input.triggerFieldGroupingCore(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
const markLastCardAsAudioCard = createMarkLastCardAsAudioCardHandler({
|
|
||||||
getAnkiIntegration: () => input.getAnkiIntegration(),
|
|
||||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
|
||||||
markLastCardAsAudioCardCore: (options) => input.markLastCardAsAudioCardCore(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mineSentenceCard = createMineSentenceCardHandler({
|
|
||||||
getAnkiIntegration: () => input.getAnkiIntegration(),
|
|
||||||
getMpvClient: () => input.getMpvClient(),
|
|
||||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
|
||||||
mineSentenceCardCore: (options) => input.mineSentenceCardCore(options),
|
|
||||||
recordCardsMined: (count, noteIds) => input.recordCardsMined(count, noteIds),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleMultiCopyDigit = createHandleMultiCopyDigitHandler({
|
|
||||||
getSubtitleTimingTracker: () => input.getSubtitleTimingTracker(),
|
|
||||||
writeClipboardText: (text) => input.writeClipboardText(text),
|
|
||||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
|
||||||
handleMultiCopyDigitCore: (count, options) => input.handleMultiCopyDigitCore(count, options),
|
|
||||||
});
|
|
||||||
|
|
||||||
const copyCurrentSubtitle = createCopyCurrentSubtitleHandler({
|
|
||||||
getSubtitleTimingTracker: () => input.getSubtitleTimingTracker(),
|
|
||||||
writeClipboardText: (text) => input.writeClipboardText(text),
|
|
||||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
|
||||||
copyCurrentSubtitleCore: (options) => input.copyCurrentSubtitleCore(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleMineSentenceDigit = createHandleMineSentenceDigitHandler({
|
|
||||||
getSubtitleTimingTracker: () => input.getSubtitleTimingTracker(),
|
|
||||||
getAnkiIntegration: () => input.getAnkiIntegration(),
|
|
||||||
getCurrentSecondarySubText: () => input.getCurrentSecondarySubText(),
|
|
||||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
|
||||||
logError: (message, err) => input.logError(message, err),
|
|
||||||
onCardsMined: (count) => input.recordCardsMined(count),
|
|
||||||
handleMineSentenceDigitCore: (count, options) =>
|
|
||||||
input.handleMineSentenceDigitCore(count, options),
|
|
||||||
});
|
|
||||||
|
|
||||||
const appendClipboardVideoToQueue = (): { ok: boolean; message: string } =>
|
|
||||||
appendClipboardVideoToQueueRuntime({
|
|
||||||
getMpvClient: () => input.getMpvClient() as { connected: boolean } | null,
|
|
||||||
readClipboardText: () => input.readClipboardText(),
|
|
||||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
|
||||||
sendMpvCommand: (command) => input.sendMpvCommand(command),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
updateLastCardFromClipboard,
|
|
||||||
refreshKnownWordCache,
|
|
||||||
triggerFieldGrouping,
|
|
||||||
markLastCardAsAudioCard,
|
|
||||||
mineSentenceCard,
|
|
||||||
handleMultiCopyDigit,
|
|
||||||
copyCurrentSubtitle,
|
|
||||||
handleMineSentenceDigit,
|
|
||||||
appendClipboardVideoToQueue,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
import type { MpvRuntime, MpvRuntimeInput } from './mpv-runtime';
|
|
||||||
import { createMpvRuntime } from './mpv-runtime';
|
|
||||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
|
||||||
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
|
|
||||||
import type { createCurrentMediaTokenizationGate } from './runtime/current-media-tokenization-gate';
|
|
||||||
import type { createStartupOsdSequencer } from './runtime/startup-osd-sequencer';
|
|
||||||
import type { AnilistRuntime } from './anilist-runtime';
|
|
||||||
import type { JellyfinRuntime } from './jellyfin-runtime';
|
|
||||||
import type { YoutubeRuntime } from './youtube-runtime';
|
|
||||||
|
|
||||||
export interface MpvRuntimeBootstrapInput {
|
|
||||||
appState: MpvRuntimeInput['appState'];
|
|
||||||
logPath: string;
|
|
||||||
logger: MpvRuntimeInput['logger'];
|
|
||||||
getResolvedConfig: MpvRuntimeInput['getResolvedConfig'];
|
|
||||||
getRuntimeBooleanOption: MpvRuntimeInput['getRuntimeBooleanOption'];
|
|
||||||
subtitle: MpvRuntimeInput['subtitle'];
|
|
||||||
ensureYomitanExtensionLoaded: MpvRuntimeInput['ensureYomitanExtensionLoaded'];
|
|
||||||
currentMediaTokenizationGate: MpvRuntimeInput['currentMediaTokenizationGate'];
|
|
||||||
startupOsdSequencer: MpvRuntimeInput['startupOsdSequencer'];
|
|
||||||
dictionarySupport: {
|
|
||||||
ensureJlptDictionaryLookup: () => Promise<void>;
|
|
||||||
ensureFrequencyDictionaryLookup: () => Promise<void>;
|
|
||||||
syncImmersionMediaState: () => void;
|
|
||||||
updateCurrentMediaPath: (mediaPath: unknown) => void;
|
|
||||||
updateCurrentMediaTitle: (mediaTitle: unknown) => void;
|
|
||||||
scheduleCharacterDictionarySync: () => void;
|
|
||||||
};
|
|
||||||
overlay: {
|
|
||||||
overlayManager: {
|
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
|
||||||
getVisibleOverlayVisible: () => boolean;
|
|
||||||
};
|
|
||||||
getOverlayUi: () => { setOverlayVisible: (visible: boolean) => void } | undefined;
|
|
||||||
};
|
|
||||||
lifecycle: {
|
|
||||||
requestAppQuit: () => void;
|
|
||||||
setQuitCheckTimer: (callback: () => void, timeoutMs: number) => void;
|
|
||||||
restoreOverlayMpvSubtitles: () => void;
|
|
||||||
syncOverlayMpvSubtitleSuppression: () => void;
|
|
||||||
publishDiscordPresence: () => void;
|
|
||||||
};
|
|
||||||
stats: MpvRuntimeInput['stats'];
|
|
||||||
anilist: MpvRuntimeInput['anilist'];
|
|
||||||
jellyfin: MpvRuntimeInput['jellyfin'];
|
|
||||||
youtube: MpvRuntimeInput['youtube'];
|
|
||||||
isCharacterDictionaryEnabled: MpvRuntimeInput['isCharacterDictionaryEnabled'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MpvRuntimeBootstrap {
|
|
||||||
mpvRuntime: MpvRuntime;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MpvRuntimeFromMainStateInput {
|
|
||||||
appState: MpvRuntimeInput['appState'];
|
|
||||||
logPath: string;
|
|
||||||
logger: MpvRuntimeInput['logger'];
|
|
||||||
getResolvedConfig: MpvRuntimeInput['getResolvedConfig'];
|
|
||||||
getRuntimeBooleanOption: MpvRuntimeInput['getRuntimeBooleanOption'];
|
|
||||||
subtitle: SubtitleRuntime;
|
|
||||||
yomitan: {
|
|
||||||
ensureYomitanExtensionLoaded: () => Promise<void>;
|
|
||||||
};
|
|
||||||
currentMediaTokenizationGate: ReturnType<typeof createCurrentMediaTokenizationGate>;
|
|
||||||
startupOsdSequencer: ReturnType<typeof createStartupOsdSequencer>;
|
|
||||||
dictionarySupport: DictionarySupportRuntime;
|
|
||||||
overlay: {
|
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
|
||||||
getVisibleOverlayVisible: () => boolean;
|
|
||||||
getOverlayUi: () => { setOverlayVisible: (visible: boolean) => void } | undefined;
|
|
||||||
};
|
|
||||||
lifecycle: {
|
|
||||||
requestAppQuit: () => void;
|
|
||||||
setQuitCheckTimer: (callback: () => void, timeoutMs: number) => void;
|
|
||||||
restoreOverlayMpvSubtitles: () => void;
|
|
||||||
syncOverlayMpvSubtitleSuppression: () => void;
|
|
||||||
publishDiscordPresence: () => void;
|
|
||||||
};
|
|
||||||
stats: {
|
|
||||||
ensureImmersionTrackerStarted: () => void;
|
|
||||||
};
|
|
||||||
anilist: AnilistRuntime;
|
|
||||||
jellyfin: JellyfinRuntime;
|
|
||||||
youtube: YoutubeRuntime;
|
|
||||||
isCharacterDictionaryEnabled: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMpvRuntimeBootstrap(input: MpvRuntimeBootstrapInput): MpvRuntimeBootstrap {
|
|
||||||
const mpvRuntime = createMpvRuntime({
|
|
||||||
appState: input.appState,
|
|
||||||
logPath: input.logPath,
|
|
||||||
logger: input.logger,
|
|
||||||
getResolvedConfig: input.getResolvedConfig,
|
|
||||||
getRuntimeBooleanOption: input.getRuntimeBooleanOption,
|
|
||||||
subtitle: input.subtitle,
|
|
||||||
ensureYomitanExtensionLoaded: input.ensureYomitanExtensionLoaded,
|
|
||||||
currentMediaTokenizationGate: input.currentMediaTokenizationGate,
|
|
||||||
startupOsdSequencer: input.startupOsdSequencer,
|
|
||||||
dictionaries: {
|
|
||||||
ensureJlptDictionaryLookup: () => input.dictionarySupport.ensureJlptDictionaryLookup(),
|
|
||||||
ensureFrequencyDictionaryLookup: () =>
|
|
||||||
input.dictionarySupport.ensureFrequencyDictionaryLookup(),
|
|
||||||
},
|
|
||||||
mediaRuntime: {
|
|
||||||
syncImmersionMediaState: () => input.dictionarySupport.syncImmersionMediaState(),
|
|
||||||
updateCurrentMediaPath: (mediaPath) => {
|
|
||||||
input.dictionarySupport.updateCurrentMediaPath(mediaPath);
|
|
||||||
},
|
|
||||||
updateCurrentMediaTitle: (mediaTitle) => {
|
|
||||||
input.dictionarySupport.updateCurrentMediaTitle(mediaTitle);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
characterDictionaryAutoSyncRuntime: {
|
|
||||||
scheduleSync: () => {
|
|
||||||
input.dictionarySupport.scheduleCharacterDictionarySync();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overlay: {
|
|
||||||
broadcastToOverlayWindows: (channel, payload) => {
|
|
||||||
input.overlay.overlayManager.broadcastToOverlayWindows(channel, payload);
|
|
||||||
},
|
|
||||||
getVisibleOverlayVisible: () => input.overlay.overlayManager.getVisibleOverlayVisible(),
|
|
||||||
setOverlayVisible: (visible) => {
|
|
||||||
input.overlay.getOverlayUi()?.setOverlayVisible(visible);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
lifecycle: {
|
|
||||||
requestAppQuit: () => input.lifecycle.requestAppQuit(),
|
|
||||||
scheduleQuitCheck: (callback) => {
|
|
||||||
input.lifecycle.setQuitCheckTimer(callback, 500);
|
|
||||||
},
|
|
||||||
restoreOverlayMpvSubtitles: () => {
|
|
||||||
input.lifecycle.restoreOverlayMpvSubtitles();
|
|
||||||
},
|
|
||||||
syncOverlayMpvSubtitleSuppression: () => {
|
|
||||||
input.lifecycle.syncOverlayMpvSubtitleSuppression();
|
|
||||||
},
|
|
||||||
refreshDiscordPresence: () => {
|
|
||||||
input.lifecycle.publishDiscordPresence();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
stats: input.stats,
|
|
||||||
anilist: input.anilist,
|
|
||||||
jellyfin: input.jellyfin,
|
|
||||||
youtube: input.youtube,
|
|
||||||
isCharacterDictionaryEnabled: input.isCharacterDictionaryEnabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
mpvRuntime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMpvRuntimeFromMainState(
|
|
||||||
input: MpvRuntimeFromMainStateInput,
|
|
||||||
): MpvRuntimeBootstrap {
|
|
||||||
return createMpvRuntimeBootstrap({
|
|
||||||
appState: input.appState,
|
|
||||||
logPath: input.logPath,
|
|
||||||
logger: input.logger,
|
|
||||||
getResolvedConfig: input.getResolvedConfig,
|
|
||||||
getRuntimeBooleanOption: input.getRuntimeBooleanOption,
|
|
||||||
subtitle: {
|
|
||||||
consumeCachedSubtitle: (text) => input.subtitle.consumeCachedSubtitle(text),
|
|
||||||
emitSubtitlePayload: (payload) => input.subtitle.emitSubtitlePayload(payload),
|
|
||||||
onSubtitleChange: (text) => {
|
|
||||||
input.subtitle.onSubtitleChange(text);
|
|
||||||
},
|
|
||||||
onCurrentMediaPathChange: (path) => {
|
|
||||||
input.subtitle.onCurrentMediaPathChange(path);
|
|
||||||
},
|
|
||||||
onTimePosUpdate: (time) => {
|
|
||||||
input.subtitle.onTimePosUpdate(time);
|
|
||||||
},
|
|
||||||
scheduleSubtitlePrefetchRefresh: (delayMs) =>
|
|
||||||
input.subtitle.scheduleSubtitlePrefetchRefresh(delayMs),
|
|
||||||
loadSubtitleSourceText: (source) => input.subtitle.loadSubtitleSourceText(source),
|
|
||||||
setTokenizeSubtitleDeferred: (tokenize) => {
|
|
||||||
input.subtitle.setTokenizeSubtitleDeferred(tokenize);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ensureYomitanExtensionLoaded: async () => {
|
|
||||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
|
||||||
},
|
|
||||||
currentMediaTokenizationGate: input.currentMediaTokenizationGate,
|
|
||||||
startupOsdSequencer: input.startupOsdSequencer,
|
|
||||||
dictionarySupport: {
|
|
||||||
ensureJlptDictionaryLookup: () => input.dictionarySupport.ensureJlptDictionaryLookup(),
|
|
||||||
ensureFrequencyDictionaryLookup: () =>
|
|
||||||
input.dictionarySupport.ensureFrequencyDictionaryLookup(),
|
|
||||||
syncImmersionMediaState: () => {
|
|
||||||
input.dictionarySupport.syncImmersionMediaState();
|
|
||||||
},
|
|
||||||
updateCurrentMediaPath: (mediaPath) => {
|
|
||||||
input.dictionarySupport.updateCurrentMediaPath(mediaPath);
|
|
||||||
},
|
|
||||||
updateCurrentMediaTitle: (mediaTitle) => {
|
|
||||||
input.dictionarySupport.updateCurrentMediaTitle(mediaTitle);
|
|
||||||
},
|
|
||||||
scheduleCharacterDictionarySync: () => {
|
|
||||||
input.dictionarySupport.scheduleCharacterDictionarySync();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overlay: {
|
|
||||||
overlayManager: {
|
|
||||||
broadcastToOverlayWindows: (channel, payload) => {
|
|
||||||
input.overlay.broadcastToOverlayWindows(channel, payload);
|
|
||||||
},
|
|
||||||
getVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(),
|
|
||||||
},
|
|
||||||
getOverlayUi: () => input.overlay.getOverlayUi(),
|
|
||||||
},
|
|
||||||
lifecycle: {
|
|
||||||
requestAppQuit: () => input.lifecycle.requestAppQuit(),
|
|
||||||
setQuitCheckTimer: (callback, timeoutMs) => {
|
|
||||||
input.lifecycle.setQuitCheckTimer(callback, timeoutMs);
|
|
||||||
},
|
|
||||||
restoreOverlayMpvSubtitles: () => {
|
|
||||||
input.lifecycle.restoreOverlayMpvSubtitles();
|
|
||||||
},
|
|
||||||
syncOverlayMpvSubtitleSuppression: () => {
|
|
||||||
input.lifecycle.syncOverlayMpvSubtitleSuppression();
|
|
||||||
},
|
|
||||||
publishDiscordPresence: () => {
|
|
||||||
input.lifecycle.publishDiscordPresence();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
stats: {
|
|
||||||
ensureImmersionTrackerStarted: () => input.stats.ensureImmersionTrackerStarted(),
|
|
||||||
},
|
|
||||||
anilist: {
|
|
||||||
getCurrentAnilistMediaKey: () => input.anilist.getCurrentAnilistMediaKey(),
|
|
||||||
resetAnilistMediaTracking: (mediaKey) => {
|
|
||||||
input.anilist.resetAnilistMediaTracking(mediaKey);
|
|
||||||
},
|
|
||||||
maybeProbeAnilistDuration: (mediaKey) => {
|
|
||||||
if (mediaKey) {
|
|
||||||
void input.anilist.maybeProbeAnilistDuration(mediaKey);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ensureAnilistMediaGuess: (mediaKey) => {
|
|
||||||
if (mediaKey) {
|
|
||||||
void input.anilist.ensureAnilistMediaGuess(mediaKey);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
maybeRunAnilistPostWatchUpdate: () => input.anilist.maybeRunAnilistPostWatchUpdate(),
|
|
||||||
resetAnilistMediaGuessState: () => {
|
|
||||||
input.anilist.resetAnilistMediaGuessState();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
jellyfin: {
|
|
||||||
getQuitOnDisconnectArmed: () => input.jellyfin.getQuitOnDisconnectArmed(),
|
|
||||||
reportJellyfinRemoteStopped: () => input.jellyfin.reportJellyfinRemoteStopped(),
|
|
||||||
reportJellyfinRemoteProgress: (forceImmediate) =>
|
|
||||||
input.jellyfin.reportJellyfinRemoteProgress(forceImmediate),
|
|
||||||
startJellyfinRemoteSession: () => input.jellyfin.startJellyfinRemoteSession(),
|
|
||||||
},
|
|
||||||
youtube: {
|
|
||||||
getQuitOnDisconnectArmed: () => input.youtube.getQuitOnDisconnectArmed(),
|
|
||||||
handleMpvConnectionChange: (connected) => {
|
|
||||||
input.youtube.handleMpvConnectionChange(connected);
|
|
||||||
},
|
|
||||||
handleMediaPathChange: (path) => {
|
|
||||||
input.youtube.invalidatePendingAutoplayReadyFallbacks();
|
|
||||||
input.currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
|
||||||
input.startupOsdSequencer.reset();
|
|
||||||
input.youtube.handleMediaPathChange(path);
|
|
||||||
if (path) {
|
|
||||||
input.stats.ensureImmersionTrackerStarted();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handleSubtitleTrackChange: (sid) => {
|
|
||||||
input.youtube.handleSubtitleTrackChange(sid);
|
|
||||||
},
|
|
||||||
handleSubtitleTrackListChange: (trackList) => {
|
|
||||||
input.youtube.handleSubtitleTrackListChange(trackList);
|
|
||||||
},
|
|
||||||
invalidatePendingAutoplayReadyFallbacks: () =>
|
|
||||||
input.youtube.invalidatePendingAutoplayReadyFallbacks(),
|
|
||||||
maybeSignalPluginAutoplayReady: (subtitle, options) =>
|
|
||||||
input.youtube.maybeSignalPluginAutoplayReady(subtitle, options),
|
|
||||||
},
|
|
||||||
isCharacterDictionaryEnabled: input.isCharacterDictionaryEnabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,504 +0,0 @@
|
|||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { MecabTokenizer } from '../mecab-tokenizer';
|
|
||||||
import {
|
|
||||||
MpvIpcClient,
|
|
||||||
applyMpvSubtitleRenderMetricsPatch,
|
|
||||||
createShiftSubtitleDelayToAdjacentCueHandler,
|
|
||||||
createTokenizerDepsRuntime,
|
|
||||||
cycleSecondarySubMode as cycleSecondarySubModeCore,
|
|
||||||
sendMpvCommandRuntime,
|
|
||||||
showMpvOsdRuntime,
|
|
||||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
|
||||||
} from '../core/services';
|
|
||||||
import type {
|
|
||||||
MpvSubtitleRenderMetrics,
|
|
||||||
ResolvedConfig,
|
|
||||||
SecondarySubMode,
|
|
||||||
SubtitleData,
|
|
||||||
} from '../types';
|
|
||||||
import type { AppState } from './state';
|
|
||||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
|
||||||
import type { createCurrentMediaTokenizationGate } from './runtime/current-media-tokenization-gate';
|
|
||||||
import type { createStartupOsdSequencer } from './runtime/startup-osd-sequencer';
|
|
||||||
import { createMpvOsdRuntimeHandlers } from './runtime/mpv-osd-runtime-handlers';
|
|
||||||
import { createCycleSecondarySubModeRuntimeHandler } from './runtime/secondary-sub-mode-runtime-handler';
|
|
||||||
import { composeMpvRuntimeHandlers } from './runtime/composers';
|
|
||||||
|
|
||||||
type RuntimeOptionId =
|
|
||||||
| 'subtitle.annotation.nPlusOne'
|
|
||||||
| 'subtitle.annotation.jlpt'
|
|
||||||
| 'subtitle.annotation.frequency';
|
|
||||||
|
|
||||||
interface MpvRuntimeLogger {
|
|
||||||
debug: (message: string, meta?: unknown) => void;
|
|
||||||
info: (message: string, meta?: unknown) => void;
|
|
||||||
warn: (message: string, meta?: unknown) => void;
|
|
||||||
error: (message: string, error?: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MpvRuntimeInput {
|
|
||||||
appState: AppState;
|
|
||||||
logPath: string;
|
|
||||||
logger: MpvRuntimeLogger;
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
getRuntimeBooleanOption: (id: RuntimeOptionId, fallback: boolean) => boolean;
|
|
||||||
subtitle: Pick<
|
|
||||||
SubtitleRuntime,
|
|
||||||
| 'consumeCachedSubtitle'
|
|
||||||
| 'emitSubtitlePayload'
|
|
||||||
| 'onSubtitleChange'
|
|
||||||
| 'onCurrentMediaPathChange'
|
|
||||||
| 'onTimePosUpdate'
|
|
||||||
| 'scheduleSubtitlePrefetchRefresh'
|
|
||||||
| 'loadSubtitleSourceText'
|
|
||||||
| 'setTokenizeSubtitleDeferred'
|
|
||||||
>;
|
|
||||||
ensureYomitanExtensionLoaded: () => Promise<void>;
|
|
||||||
currentMediaTokenizationGate: ReturnType<typeof createCurrentMediaTokenizationGate>;
|
|
||||||
startupOsdSequencer: ReturnType<typeof createStartupOsdSequencer>;
|
|
||||||
dictionaries: {
|
|
||||||
ensureJlptDictionaryLookup: () => Promise<void>;
|
|
||||||
ensureFrequencyDictionaryLookup: () => Promise<void>;
|
|
||||||
};
|
|
||||||
mediaRuntime: {
|
|
||||||
syncImmersionMediaState: () => void;
|
|
||||||
updateCurrentMediaPath: (mediaPath: unknown) => void;
|
|
||||||
updateCurrentMediaTitle: (mediaTitle: unknown) => void;
|
|
||||||
};
|
|
||||||
characterDictionaryAutoSyncRuntime: {
|
|
||||||
scheduleSync: () => void;
|
|
||||||
};
|
|
||||||
overlay: {
|
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
|
||||||
getVisibleOverlayVisible: () => boolean;
|
|
||||||
setOverlayVisible: (visible: boolean) => void;
|
|
||||||
};
|
|
||||||
lifecycle: {
|
|
||||||
requestAppQuit: () => void;
|
|
||||||
scheduleQuitCheck: (callback: () => void) => void;
|
|
||||||
restoreOverlayMpvSubtitles: () => void;
|
|
||||||
syncOverlayMpvSubtitleSuppression: () => void;
|
|
||||||
refreshDiscordPresence: () => void;
|
|
||||||
};
|
|
||||||
stats: {
|
|
||||||
ensureImmersionTrackerStarted: () => void;
|
|
||||||
};
|
|
||||||
anilist: {
|
|
||||||
getCurrentAnilistMediaKey: () => string | null;
|
|
||||||
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
|
||||||
maybeProbeAnilistDuration: (mediaKey: string | null) => void;
|
|
||||||
ensureAnilistMediaGuess: (mediaKey: string | null) => void;
|
|
||||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
|
||||||
resetAnilistMediaGuessState: () => void;
|
|
||||||
};
|
|
||||||
jellyfin: {
|
|
||||||
getQuitOnDisconnectArmed: () => boolean;
|
|
||||||
reportJellyfinRemoteStopped: () => Promise<void>;
|
|
||||||
reportJellyfinRemoteProgress: (forceImmediate?: boolean) => Promise<void>;
|
|
||||||
startJellyfinRemoteSession: () => Promise<void>;
|
|
||||||
};
|
|
||||||
youtube: {
|
|
||||||
getQuitOnDisconnectArmed: () => boolean;
|
|
||||||
handleMpvConnectionChange: (connected: boolean) => void;
|
|
||||||
handleMediaPathChange: (path: string | null) => void;
|
|
||||||
handleSubtitleTrackChange: (sid: number | null) => void;
|
|
||||||
handleSubtitleTrackListChange: (trackList: unknown[] | null) => void;
|
|
||||||
invalidatePendingAutoplayReadyFallbacks: () => void;
|
|
||||||
maybeSignalPluginAutoplayReady: (
|
|
||||||
subtitle: { text: string; tokens: null },
|
|
||||||
options?: { forceWhilePaused?: boolean },
|
|
||||||
) => void;
|
|
||||||
};
|
|
||||||
isCharacterDictionaryEnabled: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MpvRuntime {
|
|
||||||
createMpvClientRuntimeService: () => MpvIpcClient;
|
|
||||||
updateMpvSubtitleRenderMetrics: (patch: Partial<MpvSubtitleRenderMetrics>) => void;
|
|
||||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
|
||||||
prewarmSubtitleDictionaries: () => Promise<void>;
|
|
||||||
startTokenizationWarmups: () => Promise<void>;
|
|
||||||
isTokenizationWarmupReady: () => boolean;
|
|
||||||
startBackgroundWarmups: () => void;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
flushMpvLog: () => Promise<void>;
|
|
||||||
cycleSecondarySubMode: () => void;
|
|
||||||
shiftSubtitleDelayToAdjacentCue: (direction: 'next' | 'previous') => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActiveMediaPath(appState: AppState): string | null {
|
|
||||||
return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMpvRuntime(input: MpvRuntimeInput): MpvRuntime {
|
|
||||||
let backgroundWarmupsStarted = false;
|
|
||||||
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
|
|
||||||
|
|
||||||
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
|
|
||||||
appendToMpvLogMainDeps: {
|
|
||||||
logPath: input.logPath,
|
|
||||||
dirname: (targetPath) => path.dirname(targetPath),
|
|
||||||
mkdir: async (targetPath, options) => {
|
|
||||||
await fs.promises.mkdir(targetPath, options);
|
|
||||||
},
|
|
||||||
appendFile: async (targetPath, data, options) => {
|
|
||||||
await fs.promises.appendFile(targetPath, data, options);
|
|
||||||
},
|
|
||||||
now: () => new Date(),
|
|
||||||
},
|
|
||||||
buildShowMpvOsdMainDeps: (appendToMpvLog) => ({
|
|
||||||
appendToMpvLog,
|
|
||||||
showMpvOsdRuntime: (mpvClient, text, fallbackLog) =>
|
|
||||||
showMpvOsdRuntime(mpvClient, text, fallbackLog),
|
|
||||||
getMpvClient: () => input.appState.mpvClient,
|
|
||||||
logInfo: (line) => input.logger.info(line),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
|
|
||||||
cycleSecondarySubModeMainDeps: {
|
|
||||||
getSecondarySubMode: () => input.appState.secondarySubMode,
|
|
||||||
setSecondarySubMode: (mode: SecondarySubMode) => {
|
|
||||||
input.appState.secondarySubMode = mode;
|
|
||||||
},
|
|
||||||
getLastSecondarySubToggleAtMs: () => input.appState.lastSecondarySubToggleAtMs,
|
|
||||||
setLastSecondarySubToggleAtMs: (timestampMs: number) => {
|
|
||||||
input.appState.lastSecondarySubToggleAtMs = timestampMs;
|
|
||||||
},
|
|
||||||
broadcastToOverlayWindows: (channel, mode) => {
|
|
||||||
input.overlay.broadcastToOverlayWindows(channel, mode);
|
|
||||||
},
|
|
||||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
|
||||||
},
|
|
||||||
cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps),
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
|
|
||||||
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
|
|
||||||
tokenizeSubtitle,
|
|
||||||
createMecabTokenizerAndCheck,
|
|
||||||
prewarmSubtitleDictionaries,
|
|
||||||
startBackgroundWarmups,
|
|
||||||
startTokenizationWarmups,
|
|
||||||
isTokenizationWarmupReady,
|
|
||||||
} = composeMpvRuntimeHandlers<
|
|
||||||
MpvIpcClient,
|
|
||||||
ReturnType<typeof createTokenizerDepsRuntime>,
|
|
||||||
SubtitleData
|
|
||||||
>({
|
|
||||||
bindMpvMainEventHandlersMainDeps: {
|
|
||||||
appState: input.appState,
|
|
||||||
getQuitOnDisconnectArmed: () =>
|
|
||||||
input.jellyfin.getQuitOnDisconnectArmed() || input.youtube.getQuitOnDisconnectArmed(),
|
|
||||||
scheduleQuitCheck: (callback) => {
|
|
||||||
input.lifecycle.scheduleQuitCheck(callback);
|
|
||||||
},
|
|
||||||
quitApp: () => input.lifecycle.requestAppQuit(),
|
|
||||||
reportJellyfinRemoteStopped: () => {
|
|
||||||
void input.jellyfin.reportJellyfinRemoteStopped();
|
|
||||||
},
|
|
||||||
maybeRunAnilistPostWatchUpdate: () => input.anilist.maybeRunAnilistPostWatchUpdate(),
|
|
||||||
logSubtitleTimingError: (message, error) => input.logger.error(message, error),
|
|
||||||
broadcastToOverlayWindows: (channel, payload) => {
|
|
||||||
input.overlay.broadcastToOverlayWindows(channel, payload);
|
|
||||||
},
|
|
||||||
getImmediateSubtitlePayload: (text) => input.subtitle.consumeCachedSubtitle(text),
|
|
||||||
emitImmediateSubtitle: (payload) => {
|
|
||||||
input.subtitle.emitSubtitlePayload(payload);
|
|
||||||
},
|
|
||||||
onSubtitleChange: (text) => {
|
|
||||||
input.subtitle.onSubtitleChange(text);
|
|
||||||
},
|
|
||||||
refreshDiscordPresence: () => {
|
|
||||||
input.lifecycle.refreshDiscordPresence();
|
|
||||||
},
|
|
||||||
ensureImmersionTrackerInitialized: () => {
|
|
||||||
input.stats.ensureImmersionTrackerStarted();
|
|
||||||
},
|
|
||||||
tokenizeSubtitleForImmersion: async (text): Promise<SubtitleData | null> =>
|
|
||||||
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
|
|
||||||
updateCurrentMediaPath: (mediaPath) => {
|
|
||||||
input.youtube.invalidatePendingAutoplayReadyFallbacks();
|
|
||||||
input.currentMediaTokenizationGate.updateCurrentMediaPath(mediaPath);
|
|
||||||
input.startupOsdSequencer.reset();
|
|
||||||
input.subtitle.onCurrentMediaPathChange(mediaPath);
|
|
||||||
input.youtube.handleMediaPathChange(mediaPath);
|
|
||||||
if (mediaPath) {
|
|
||||||
input.stats.ensureImmersionTrackerStarted();
|
|
||||||
}
|
|
||||||
input.mediaRuntime.updateCurrentMediaPath(mediaPath);
|
|
||||||
},
|
|
||||||
restoreMpvSubVisibility: () => {
|
|
||||||
input.lifecycle.restoreOverlayMpvSubtitles();
|
|
||||||
},
|
|
||||||
resetSubtitleSidebarEmbeddedLayout: () => {
|
|
||||||
sendMpvCommandRuntime(input.appState.mpvClient, [
|
|
||||||
'set_property',
|
|
||||||
'video-margin-ratio-right',
|
|
||||||
0,
|
|
||||||
]);
|
|
||||||
sendMpvCommandRuntime(input.appState.mpvClient, ['set_property', 'video-pan-x', 0]);
|
|
||||||
},
|
|
||||||
getCurrentAnilistMediaKey: () => input.anilist.getCurrentAnilistMediaKey(),
|
|
||||||
resetAnilistMediaTracking: (mediaKey) => {
|
|
||||||
input.anilist.resetAnilistMediaTracking(mediaKey);
|
|
||||||
},
|
|
||||||
maybeProbeAnilistDuration: (mediaKey) => {
|
|
||||||
if (mediaKey) {
|
|
||||||
input.anilist.maybeProbeAnilistDuration(mediaKey);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ensureAnilistMediaGuess: (mediaKey) => {
|
|
||||||
if (mediaKey) {
|
|
||||||
input.anilist.ensureAnilistMediaGuess(mediaKey);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
syncImmersionMediaState: () => {
|
|
||||||
input.mediaRuntime.syncImmersionMediaState();
|
|
||||||
},
|
|
||||||
signalAutoplayReadyIfWarm: () => {
|
|
||||||
if (!isTokenizationWarmupReady()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
input.youtube.maybeSignalPluginAutoplayReady(
|
|
||||||
{ text: '__warm__', tokens: null },
|
|
||||||
{ forceWhilePaused: true },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
scheduleCharacterDictionarySync: () => {
|
|
||||||
if (!input.isCharacterDictionaryEnabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
input.characterDictionaryAutoSyncRuntime.scheduleSync();
|
|
||||||
},
|
|
||||||
updateCurrentMediaTitle: (title) => {
|
|
||||||
input.mediaRuntime.updateCurrentMediaTitle(title);
|
|
||||||
},
|
|
||||||
resetAnilistMediaGuessState: () => {
|
|
||||||
input.anilist.resetAnilistMediaGuessState();
|
|
||||||
},
|
|
||||||
reportJellyfinRemoteProgress: (forceImmediate) => {
|
|
||||||
void input.jellyfin.reportJellyfinRemoteProgress(forceImmediate);
|
|
||||||
},
|
|
||||||
onTimePosUpdate: (time) => {
|
|
||||||
input.subtitle.onTimePosUpdate(time);
|
|
||||||
},
|
|
||||||
onSubtitleTrackChange: (sid) => {
|
|
||||||
input.subtitle.scheduleSubtitlePrefetchRefresh();
|
|
||||||
input.youtube.handleSubtitleTrackChange(sid);
|
|
||||||
},
|
|
||||||
onSubtitleTrackListChange: (trackList) => {
|
|
||||||
input.subtitle.scheduleSubtitlePrefetchRefresh();
|
|
||||||
input.youtube.handleSubtitleTrackListChange(trackList);
|
|
||||||
},
|
|
||||||
updateSubtitleRenderMetrics: (patch) => {
|
|
||||||
updateMpvSubtitleRenderMetricsHandler(patch as Partial<MpvSubtitleRenderMetrics>);
|
|
||||||
},
|
|
||||||
syncOverlayMpvSubtitleSuppression: () => {
|
|
||||||
input.lifecycle.syncOverlayMpvSubtitleSuppression();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mpvClientRuntimeServiceFactoryMainDeps: {
|
|
||||||
createClient: MpvIpcClient,
|
|
||||||
getSocketPath: () => input.appState.mpvSocketPath,
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
isAutoStartOverlayEnabled: () => input.appState.autoStartOverlay,
|
|
||||||
setOverlayVisible: (visible: boolean) => {
|
|
||||||
input.overlay.setOverlayVisible(visible);
|
|
||||||
},
|
|
||||||
isVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(),
|
|
||||||
getReconnectTimer: () => input.appState.reconnectTimer,
|
|
||||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
|
|
||||||
input.appState.reconnectTimer = timer;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
updateMpvSubtitleRenderMetricsMainDeps: {
|
|
||||||
getCurrentMetrics: () => input.appState.mpvSubtitleRenderMetrics,
|
|
||||||
setCurrentMetrics: (metrics) => {
|
|
||||||
input.appState.mpvSubtitleRenderMetrics = metrics;
|
|
||||||
},
|
|
||||||
applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch),
|
|
||||||
broadcastMetrics: () => {},
|
|
||||||
},
|
|
||||||
tokenizer: {
|
|
||||||
buildTokenizerDepsMainDeps: {
|
|
||||||
getYomitanExt: () => input.appState.yomitanExt,
|
|
||||||
getYomitanSession: () => input.appState.yomitanSession,
|
|
||||||
getYomitanParserWindow: () => input.appState.yomitanParserWindow,
|
|
||||||
setYomitanParserWindow: (window) => {
|
|
||||||
input.appState.yomitanParserWindow = window;
|
|
||||||
},
|
|
||||||
getYomitanParserReadyPromise: () => input.appState.yomitanParserReadyPromise,
|
|
||||||
setYomitanParserReadyPromise: (promise) => {
|
|
||||||
input.appState.yomitanParserReadyPromise = promise;
|
|
||||||
},
|
|
||||||
getYomitanParserInitPromise: () => input.appState.yomitanParserInitPromise,
|
|
||||||
setYomitanParserInitPromise: (promise) => {
|
|
||||||
input.appState.yomitanParserInitPromise = promise;
|
|
||||||
},
|
|
||||||
isKnownWord: (text) => Boolean(input.appState.ankiIntegration?.isKnownWord(text)),
|
|
||||||
recordLookup: (hit) => {
|
|
||||||
input.stats.ensureImmersionTrackerStarted();
|
|
||||||
input.appState.immersionTracker?.recordLookup(hit);
|
|
||||||
},
|
|
||||||
getKnownWordMatchMode: () =>
|
|
||||||
input.appState.ankiIntegration?.getKnownWordMatchMode() ??
|
|
||||||
input.getResolvedConfig().ankiConnect.knownWords.matchMode,
|
|
||||||
getNPlusOneEnabled: () =>
|
|
||||||
input.getRuntimeBooleanOption(
|
|
||||||
'subtitle.annotation.nPlusOne',
|
|
||||||
input.getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
|
|
||||||
),
|
|
||||||
getMinSentenceWordsForNPlusOne: () =>
|
|
||||||
input.getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
|
|
||||||
getJlptLevel: (text) => input.appState.jlptLevelLookup(text),
|
|
||||||
getJlptEnabled: () =>
|
|
||||||
input.getRuntimeBooleanOption(
|
|
||||||
'subtitle.annotation.jlpt',
|
|
||||||
input.getResolvedConfig().subtitleStyle.enableJlpt,
|
|
||||||
),
|
|
||||||
getCharacterDictionaryEnabled: () => input.isCharacterDictionaryEnabled(),
|
|
||||||
getNameMatchEnabled: () => input.getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
|
||||||
getFrequencyDictionaryEnabled: () =>
|
|
||||||
input.getRuntimeBooleanOption(
|
|
||||||
'subtitle.annotation.frequency',
|
|
||||||
input.getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
|
||||||
),
|
|
||||||
getFrequencyDictionaryMatchMode: () =>
|
|
||||||
input.getResolvedConfig().subtitleStyle.frequencyDictionary.matchMode,
|
|
||||||
getFrequencyRank: (text) => input.appState.frequencyRankLookup(text),
|
|
||||||
getYomitanGroupDebugEnabled: () => input.appState.overlayDebugVisualizationEnabled,
|
|
||||||
getMecabTokenizer: () => input.appState.mecabTokenizer,
|
|
||||||
onTokenizationReady: (text) => {
|
|
||||||
input.currentMediaTokenizationGate.markReady(getActiveMediaPath(input.appState));
|
|
||||||
input.startupOsdSequencer.markTokenizationReady();
|
|
||||||
input.youtube.maybeSignalPluginAutoplayReady(
|
|
||||||
{ text, tokens: null },
|
|
||||||
{ forceWhilePaused: true },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
createTokenizerRuntimeDeps: (deps) =>
|
|
||||||
createTokenizerDepsRuntime(deps as Parameters<typeof createTokenizerDepsRuntime>[0]),
|
|
||||||
tokenizeSubtitle: (text, deps) => tokenizeSubtitleCore(text, deps),
|
|
||||||
createMecabTokenizerAndCheckMainDeps: {
|
|
||||||
getMecabTokenizer: () => input.appState.mecabTokenizer,
|
|
||||||
setMecabTokenizer: (tokenizer) => {
|
|
||||||
input.appState.mecabTokenizer = tokenizer as MecabTokenizer | null;
|
|
||||||
},
|
|
||||||
createMecabTokenizer: () => new MecabTokenizer(),
|
|
||||||
checkAvailability: async (tokenizer) => (tokenizer as MecabTokenizer).checkAvailability(),
|
|
||||||
},
|
|
||||||
prewarmSubtitleDictionariesMainDeps: {
|
|
||||||
ensureJlptDictionaryLookup: () => input.dictionaries.ensureJlptDictionaryLookup(),
|
|
||||||
ensureFrequencyDictionaryLookup: () => input.dictionaries.ensureFrequencyDictionaryLookup(),
|
|
||||||
showMpvOsd: (message: string) => showMpvOsd(message),
|
|
||||||
showLoadingOsd: (message: string) =>
|
|
||||||
input.startupOsdSequencer.showAnnotationLoading(message),
|
|
||||||
showLoadedOsd: (message: string) =>
|
|
||||||
input.startupOsdSequencer.markAnnotationLoadingComplete(message),
|
|
||||||
shouldShowOsdNotification: () => {
|
|
||||||
const type = input.getResolvedConfig().ankiConnect.behavior.notificationType;
|
|
||||||
return type === 'osd' || type === 'both';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
warmups: {
|
|
||||||
launchBackgroundWarmupTaskMainDeps: {
|
|
||||||
now: () => Date.now(),
|
|
||||||
logDebug: (message) => input.logger.debug(message),
|
|
||||||
logWarn: (message) => input.logger.warn(message),
|
|
||||||
},
|
|
||||||
startBackgroundWarmupsMainDeps: {
|
|
||||||
getStarted: () => backgroundWarmupsStarted,
|
|
||||||
setStarted: (started) => {
|
|
||||||
backgroundWarmupsStarted = started;
|
|
||||||
},
|
|
||||||
isTexthookerOnlyMode: () => input.appState.texthookerOnlyMode,
|
|
||||||
ensureYomitanExtensionLoaded: () => input.ensureYomitanExtensionLoaded().then(() => {}),
|
|
||||||
shouldWarmupMecab: () => {
|
|
||||||
const startupWarmups = input.getResolvedConfig().startupWarmups;
|
|
||||||
if (startupWarmups.lowPowerMode) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!startupWarmups.mecab) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
input.getRuntimeBooleanOption(
|
|
||||||
'subtitle.annotation.nPlusOne',
|
|
||||||
input.getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
|
|
||||||
) ||
|
|
||||||
input.getRuntimeBooleanOption(
|
|
||||||
'subtitle.annotation.jlpt',
|
|
||||||
input.getResolvedConfig().subtitleStyle.enableJlpt,
|
|
||||||
) ||
|
|
||||||
input.getRuntimeBooleanOption(
|
|
||||||
'subtitle.annotation.frequency',
|
|
||||||
input.getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
shouldWarmupYomitanExtension: () =>
|
|
||||||
input.getResolvedConfig().startupWarmups.yomitanExtension,
|
|
||||||
shouldWarmupSubtitleDictionaries: () => {
|
|
||||||
const startupWarmups = input.getResolvedConfig().startupWarmups;
|
|
||||||
if (startupWarmups.lowPowerMode) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return startupWarmups.subtitleDictionaries;
|
|
||||||
},
|
|
||||||
shouldWarmupJellyfinRemoteSession: () => {
|
|
||||||
const startupWarmups = input.getResolvedConfig().startupWarmups;
|
|
||||||
if (startupWarmups.lowPowerMode) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return startupWarmups.jellyfinRemoteSession;
|
|
||||||
},
|
|
||||||
shouldAutoConnectJellyfinRemote: () => {
|
|
||||||
const jellyfin = input.getResolvedConfig().jellyfin;
|
|
||||||
return (
|
|
||||||
jellyfin.enabled && jellyfin.remoteControlEnabled && jellyfin.remoteControlAutoConnect
|
|
||||||
);
|
|
||||||
},
|
|
||||||
startJellyfinRemoteSession: () => input.jellyfin.startJellyfinRemoteSession(),
|
|
||||||
logDebug: (message) => input.logger.debug(message),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
tokenizeSubtitleDeferred = tokenizeSubtitle;
|
|
||||||
input.subtitle.setTokenizeSubtitleDeferred(tokenizeSubtitle);
|
|
||||||
|
|
||||||
const createMpvClientRuntimeService = (): MpvIpcClient => {
|
|
||||||
const client = createMpvClientRuntimeServiceHandler();
|
|
||||||
client.on('connection-change', ({ connected }) => {
|
|
||||||
input.youtube.handleMpvConnectionChange(connected);
|
|
||||||
});
|
|
||||||
return client;
|
|
||||||
};
|
|
||||||
|
|
||||||
const shiftSubtitleDelayToAdjacentCue = createShiftSubtitleDelayToAdjacentCueHandler({
|
|
||||||
getMpvClient: () => input.appState.mpvClient,
|
|
||||||
loadSubtitleSourceText: (source) => input.subtitle.loadSubtitleSourceText(source),
|
|
||||||
sendMpvCommand: (command) => sendMpvCommandRuntime(input.appState.mpvClient, command),
|
|
||||||
showMpvOsd: (text) => showMpvOsd(text),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
createMpvClientRuntimeService,
|
|
||||||
updateMpvSubtitleRenderMetrics: (patch) => {
|
|
||||||
updateMpvSubtitleRenderMetricsHandler(patch);
|
|
||||||
},
|
|
||||||
createMecabTokenizerAndCheck,
|
|
||||||
prewarmSubtitleDictionaries,
|
|
||||||
startTokenizationWarmups,
|
|
||||||
isTokenizationWarmupReady,
|
|
||||||
startBackgroundWarmups,
|
|
||||||
showMpvOsd,
|
|
||||||
flushMpvLog,
|
|
||||||
cycleSecondarySubMode,
|
|
||||||
shiftSubtitleDelayToAdjacentCue,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { WindowGeometry } from '../types';
|
|
||||||
import type { OverlayGeometryRuntime } from './overlay-geometry-runtime';
|
|
||||||
|
|
||||||
export function createOverlayGeometryAccessors(deps: {
|
|
||||||
getOverlayGeometryRuntime: () => OverlayGeometryRuntime<any> | null;
|
|
||||||
getWindowTracker: () => { getGeometry?: () => WindowGeometry | null } | null;
|
|
||||||
screen: {
|
|
||||||
getCursorScreenPoint: () => { x: number; y: number };
|
|
||||||
getDisplayNearestPoint: (point: { x: number; y: number }) => {
|
|
||||||
workArea: { x: number; y: number; width: number; height: number };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}) {
|
|
||||||
const getOverlayGeometryFallback = (): WindowGeometry => {
|
|
||||||
const runtime = deps.getOverlayGeometryRuntime();
|
|
||||||
if (runtime) {
|
|
||||||
return runtime.getOverlayGeometryFallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
const cursorPoint = deps.screen.getCursorScreenPoint();
|
|
||||||
const display = deps.screen.getDisplayNearestPoint(cursorPoint);
|
|
||||||
const bounds = display.workArea;
|
|
||||||
return {
|
|
||||||
x: bounds.x,
|
|
||||||
y: bounds.y,
|
|
||||||
width: bounds.width,
|
|
||||||
height: bounds.height,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCurrentOverlayGeometry = (): WindowGeometry => {
|
|
||||||
const runtime = deps.getOverlayGeometryRuntime();
|
|
||||||
if (runtime) {
|
|
||||||
return runtime.getCurrentOverlayGeometry();
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackerGeometry = deps.getWindowTracker()?.getGeometry?.() ?? null;
|
|
||||||
if (trackerGeometry) {
|
|
||||||
return trackerGeometry;
|
|
||||||
}
|
|
||||||
|
|
||||||
return getOverlayGeometryFallback();
|
|
||||||
};
|
|
||||||
|
|
||||||
const geometryMatches = (a: WindowGeometry | null, b: WindowGeometry | null): boolean => {
|
|
||||||
const runtime = deps.getOverlayGeometryRuntime();
|
|
||||||
if (runtime) {
|
|
||||||
return runtime.geometryMatches(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!a || !b) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
getOverlayGeometryFallback,
|
|
||||||
getCurrentOverlayGeometry,
|
|
||||||
geometryMatches,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createOverlayGeometryRuntime } from './overlay-geometry-runtime';
|
|
||||||
|
|
||||||
test('overlay geometry runtime prefers tracker geometry before fallback', () => {
|
|
||||||
const overlayBounds: unknown[] = [];
|
|
||||||
const modalBounds: unknown[] = [];
|
|
||||||
const layerCalls: Array<[unknown, unknown]> = [];
|
|
||||||
const levelCalls: unknown[] = [];
|
|
||||||
|
|
||||||
const runtime = createOverlayGeometryRuntime({
|
|
||||||
screen: {
|
|
||||||
getCursorScreenPoint: () => ({ x: 1, y: 2 }),
|
|
||||||
getDisplayNearestPoint: () => ({
|
|
||||||
workArea: { x: 10, y: 20, width: 30, height: 40 },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
windowState: {
|
|
||||||
getMainWindow: () =>
|
|
||||||
({
|
|
||||||
isDestroyed: () => false,
|
|
||||||
}) as never,
|
|
||||||
setOverlayWindowBounds: (geometry) => overlayBounds.push(geometry),
|
|
||||||
setModalWindowBounds: (geometry) => modalBounds.push(geometry),
|
|
||||||
getVisibleOverlayVisible: () => true,
|
|
||||||
},
|
|
||||||
getWindowTracker: () => ({
|
|
||||||
getGeometry: () => ({ x: 100, y: 200, width: 300, height: 400 }),
|
|
||||||
}),
|
|
||||||
ensureOverlayWindowLevelCore: (window) => {
|
|
||||||
levelCalls.push(window);
|
|
||||||
},
|
|
||||||
syncOverlayWindowLayer: (window, layer) => {
|
|
||||||
layerCalls.push([window, layer]);
|
|
||||||
},
|
|
||||||
enforceOverlayLayerOrderCore: ({
|
|
||||||
visibleOverlayVisible,
|
|
||||||
mainWindow,
|
|
||||||
ensureOverlayWindowLevel,
|
|
||||||
}) => {
|
|
||||||
if (visibleOverlayVisible && mainWindow) {
|
|
||||||
ensureOverlayWindowLevel(mainWindow);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(runtime.getCurrentOverlayGeometry(), {
|
|
||||||
x: 100,
|
|
||||||
y: 200,
|
|
||||||
width: 300,
|
|
||||||
height: 400,
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
runtime.geometryMatches(
|
|
||||||
{ x: 1, y: 2, width: 3, height: 4 },
|
|
||||||
{ x: 1, y: 2, width: 3, height: 4 },
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.equal(runtime.geometryMatches({ x: 1, y: 2, width: 3, height: 4 }, null), false);
|
|
||||||
|
|
||||||
runtime.applyOverlayRegions({ x: 7, y: 8, width: 9, height: 10 });
|
|
||||||
assert.deepEqual(overlayBounds, [{ x: 7, y: 8, width: 9, height: 10 }]);
|
|
||||||
assert.deepEqual(modalBounds, [{ x: 7, y: 8, width: 9, height: 10 }]);
|
|
||||||
|
|
||||||
runtime.syncPrimaryOverlayWindowLayer('visible');
|
|
||||||
runtime.ensureOverlayWindowLevel({
|
|
||||||
isDestroyed: () => false,
|
|
||||||
} as never);
|
|
||||||
runtime.enforceOverlayLayerOrder();
|
|
||||||
|
|
||||||
assert.equal(layerCalls.length >= 1, true);
|
|
||||||
assert.equal(levelCalls.length >= 2, true);
|
|
||||||
});
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import {
|
|
||||||
createEnforceOverlayLayerOrderHandler,
|
|
||||||
createEnsureOverlayWindowLevelHandler,
|
|
||||||
createUpdateVisibleOverlayBoundsHandler,
|
|
||||||
} from './runtime/overlay-window-layout';
|
|
||||||
import {
|
|
||||||
createBuildEnforceOverlayLayerOrderMainDepsHandler,
|
|
||||||
createBuildEnsureOverlayWindowLevelMainDepsHandler,
|
|
||||||
createBuildUpdateVisibleOverlayBoundsMainDepsHandler,
|
|
||||||
} from './runtime/overlay-window-layout-main-deps';
|
|
||||||
import type { WindowGeometry } from '../types';
|
|
||||||
|
|
||||||
type BrowserWindowLike = {
|
|
||||||
isDestroyed: () => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ScreenLike = {
|
|
||||||
getCursorScreenPoint: () => { x: number; y: number };
|
|
||||||
getDisplayNearestPoint: (point: { x: number; y: number }) => {
|
|
||||||
workArea: { x: number; y: number; width: number; height: number };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface OverlayGeometryWindowState<TWindow extends BrowserWindowLike = BrowserWindowLike> {
|
|
||||||
getMainWindow: () => TWindow | null;
|
|
||||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
|
||||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
|
||||||
getVisibleOverlayVisible: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayGeometryInput<TWindow extends BrowserWindowLike = BrowserWindowLike> {
|
|
||||||
screen: ScreenLike;
|
|
||||||
windowState: OverlayGeometryWindowState<TWindow>;
|
|
||||||
getWindowTracker: () => { getGeometry?: () => WindowGeometry | null } | null;
|
|
||||||
ensureOverlayWindowLevelCore: (window: TWindow) => void;
|
|
||||||
syncOverlayWindowLayer: (window: TWindow, layer: 'visible') => void;
|
|
||||||
enforceOverlayLayerOrderCore: (params: {
|
|
||||||
visibleOverlayVisible: boolean;
|
|
||||||
mainWindow: TWindow | null;
|
|
||||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
|
||||||
}) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayGeometryRuntime<TWindow extends BrowserWindowLike = BrowserWindowLike> {
|
|
||||||
getLastOverlayWindowGeometry: () => WindowGeometry | null;
|
|
||||||
getOverlayGeometryFallback: () => WindowGeometry;
|
|
||||||
getCurrentOverlayGeometry: () => WindowGeometry;
|
|
||||||
geometryMatches: (a: WindowGeometry | null, b: WindowGeometry | null) => boolean;
|
|
||||||
applyOverlayRegions: (geometry: WindowGeometry) => void;
|
|
||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
|
||||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
|
||||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
|
||||||
enforceOverlayLayerOrder: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createOverlayGeometryRuntime<TWindow extends BrowserWindowLike = BrowserWindowLike>(
|
|
||||||
input: OverlayGeometryInput<TWindow>,
|
|
||||||
): OverlayGeometryRuntime<TWindow> {
|
|
||||||
let lastOverlayWindowGeometry: WindowGeometry | null = null;
|
|
||||||
|
|
||||||
const getOverlayGeometryFallback = (): WindowGeometry => {
|
|
||||||
const cursorPoint = input.screen.getCursorScreenPoint();
|
|
||||||
const display = input.screen.getDisplayNearestPoint(cursorPoint);
|
|
||||||
const bounds = display.workArea;
|
|
||||||
return {
|
|
||||||
x: bounds.x,
|
|
||||||
y: bounds.y,
|
|
||||||
width: bounds.width,
|
|
||||||
height: bounds.height,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCurrentOverlayGeometry = (): WindowGeometry => {
|
|
||||||
if (lastOverlayWindowGeometry) return lastOverlayWindowGeometry;
|
|
||||||
const trackerGeometry = input.getWindowTracker()?.getGeometry?.() ?? null;
|
|
||||||
if (trackerGeometry) return trackerGeometry;
|
|
||||||
return getOverlayGeometryFallback();
|
|
||||||
};
|
|
||||||
|
|
||||||
const geometryMatches = (a: WindowGeometry | null, b: WindowGeometry | null): boolean => {
|
|
||||||
if (!a || !b) return false;
|
|
||||||
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyOverlayRegions = (geometry: WindowGeometry): void => {
|
|
||||||
lastOverlayWindowGeometry = geometry;
|
|
||||||
input.windowState.setOverlayWindowBounds(geometry);
|
|
||||||
input.windowState.setModalWindowBounds(geometry);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
|
|
||||||
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
|
|
||||||
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler(
|
|
||||||
createBuildEnsureOverlayWindowLevelMainDepsHandler({
|
|
||||||
ensureOverlayWindowLevelCore: (window) =>
|
|
||||||
input.ensureOverlayWindowLevelCore(window as TWindow),
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const syncPrimaryOverlayWindowLayer = (layer: 'visible'): void => {
|
|
||||||
const mainWindow = input.windowState.getMainWindow();
|
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
||||||
input.syncOverlayWindowLayer(mainWindow, layer);
|
|
||||||
};
|
|
||||||
|
|
||||||
const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler(
|
|
||||||
createBuildEnforceOverlayLayerOrderMainDepsHandler({
|
|
||||||
enforceOverlayLayerOrderCore: (params) =>
|
|
||||||
input.enforceOverlayLayerOrderCore({
|
|
||||||
visibleOverlayVisible: params.visibleOverlayVisible,
|
|
||||||
mainWindow: params.mainWindow as TWindow | null,
|
|
||||||
ensureOverlayWindowLevel: (window) => params.ensureOverlayWindowLevel(window as TWindow),
|
|
||||||
}),
|
|
||||||
getVisibleOverlayVisible: () => input.windowState.getVisibleOverlayVisible(),
|
|
||||||
getMainWindow: () => input.windowState.getMainWindow(),
|
|
||||||
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as TWindow),
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
getLastOverlayWindowGeometry: () => lastOverlayWindowGeometry,
|
|
||||||
getOverlayGeometryFallback,
|
|
||||||
getCurrentOverlayGeometry,
|
|
||||||
geometryMatches,
|
|
||||||
applyOverlayRegions,
|
|
||||||
updateVisibleOverlayBounds,
|
|
||||||
ensureOverlayWindowLevel,
|
|
||||||
syncPrimaryOverlayWindowLayer,
|
|
||||||
enforceOverlayLayerOrder,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
nativeImage,
|
|
||||||
Tray,
|
|
||||||
type BrowserWindow,
|
|
||||||
type MenuItemConstructorOptions,
|
|
||||||
} from 'electron';
|
|
||||||
|
|
||||||
import type { AnilistRuntime } from './anilist-runtime';
|
|
||||||
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
|
|
||||||
import type { FirstRunRuntime } from './first-run-runtime';
|
|
||||||
import type { JellyfinRuntime } from './jellyfin-runtime';
|
|
||||||
import type { MpvRuntime } from './mpv-runtime';
|
|
||||||
import type { ResolvedConfig } from '../types';
|
|
||||||
import {
|
|
||||||
broadcastRuntimeOptionsChangedRuntime,
|
|
||||||
createOverlayWindow as createOverlayWindowCore,
|
|
||||||
enforceOverlayLayerOrder as enforceOverlayLayerOrderCore,
|
|
||||||
ensureOverlayWindowLevel as ensureOverlayWindowLevelCore,
|
|
||||||
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
|
||||||
setOverlayDebugVisualizationEnabledRuntime,
|
|
||||||
syncOverlayWindowLayer,
|
|
||||||
} from '../core/services';
|
|
||||||
import {
|
|
||||||
buildTrayMenuTemplateRuntime,
|
|
||||||
resolveTrayIconPathRuntime,
|
|
||||||
} from './runtime/domains/overlay';
|
|
||||||
import {
|
|
||||||
createOverlayUiBootstrapRuntime,
|
|
||||||
type OverlayUiBootstrapInput,
|
|
||||||
type OverlayUiBootstrapRuntime,
|
|
||||||
} from './overlay-ui-bootstrap-runtime';
|
|
||||||
import type { OverlayModalRuntime } from './overlay-runtime';
|
|
||||||
import type { ShortcutsRuntimeBootstrap } from './shortcuts-runtime';
|
|
||||||
import { createWindowTracker as createWindowTrackerCore } from '../window-trackers';
|
|
||||||
|
|
||||||
export interface OverlayUiBootstrapFromMainStateInput<
|
|
||||||
TWindow extends BrowserWindow,
|
|
||||||
TMenuItem = MenuItemConstructorOptions | MenuItem,
|
|
||||||
> {
|
|
||||||
appState: OverlayUiBootstrapInput<TWindow>['appState'];
|
|
||||||
overlayManager: OverlayUiBootstrapInput<TWindow>['overlayManager'];
|
|
||||||
overlayModalInputState: OverlayUiBootstrapInput<TWindow>['overlayModalInputState'];
|
|
||||||
overlayModalRuntime: OverlayModalRuntime;
|
|
||||||
overlayShortcutsRuntime: ShortcutsRuntimeBootstrap['overlayShortcutsRuntime'];
|
|
||||||
runtimes: {
|
|
||||||
dictionarySupport: Pick<DictionarySupportRuntime, 'createFieldGroupingCallback'>;
|
|
||||||
firstRun: Pick<FirstRunRuntime, 'isSetupCompleted' | 'openFirstRunSetupWindow'>;
|
|
||||||
yomitan: {
|
|
||||||
openYomitanSettings: () => boolean;
|
|
||||||
};
|
|
||||||
jellyfin: Pick<JellyfinRuntime, 'openJellyfinSetupWindow'>;
|
|
||||||
anilist: Pick<AnilistRuntime, 'openAnilistSetupWindow'>;
|
|
||||||
shortcuts: Pick<ShortcutsRuntimeBootstrap['shortcuts'], 'registerGlobalShortcuts'>;
|
|
||||||
mpvRuntime: Pick<MpvRuntime, 'startBackgroundWarmups'>;
|
|
||||||
};
|
|
||||||
electron: OverlayUiBootstrapInput<TWindow>['electron'] & {
|
|
||||||
buildMenuFromTemplate: (template: TMenuItem[]) => unknown;
|
|
||||||
createTray: (
|
|
||||||
icon: ReturnType<OverlayUiBootstrapInput<TWindow>['electron']['createEmptyImage']>,
|
|
||||||
) => Tray;
|
|
||||||
};
|
|
||||||
windowing: OverlayUiBootstrapInput<TWindow>['windowing'];
|
|
||||||
actions: Omit<
|
|
||||||
OverlayUiBootstrapInput<TWindow>['actions'],
|
|
||||||
'registerGlobalShortcuts' | 'startBackgroundWarmups'
|
|
||||||
>;
|
|
||||||
trayState: OverlayUiBootstrapInput<TWindow>['trayState'];
|
|
||||||
startup: OverlayUiBootstrapInput<TWindow>['startup'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createOverlayUiBootstrapFromMainState<TWindow extends BrowserWindow>(
|
|
||||||
input: OverlayUiBootstrapFromMainStateInput<TWindow>,
|
|
||||||
): OverlayUiBootstrapRuntime<TWindow> {
|
|
||||||
return createOverlayUiBootstrapRuntime<TWindow>({
|
|
||||||
appState: input.appState,
|
|
||||||
overlayManager: input.overlayManager,
|
|
||||||
overlayModalInputState: input.overlayModalInputState,
|
|
||||||
overlayModalRuntime: input.overlayModalRuntime,
|
|
||||||
overlayShortcutsRuntime: input.overlayShortcutsRuntime,
|
|
||||||
dictionarySupport: {
|
|
||||||
createFieldGroupingCallback: () =>
|
|
||||||
input.runtimes.dictionarySupport.createFieldGroupingCallback(),
|
|
||||||
},
|
|
||||||
firstRun: {
|
|
||||||
isSetupCompleted: () => input.runtimes.firstRun.isSetupCompleted(),
|
|
||||||
openFirstRunSetupWindow: () => input.runtimes.firstRun.openFirstRunSetupWindow(),
|
|
||||||
},
|
|
||||||
yomitan: {
|
|
||||||
openYomitanSettings: () => {
|
|
||||||
input.runtimes.yomitan.openYomitanSettings();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
jellyfin: {
|
|
||||||
openJellyfinSetupWindow: () => input.runtimes.jellyfin.openJellyfinSetupWindow(),
|
|
||||||
},
|
|
||||||
anilist: {
|
|
||||||
openAnilistSetupWindow: () => input.runtimes.anilist.openAnilistSetupWindow(),
|
|
||||||
},
|
|
||||||
electron: input.electron,
|
|
||||||
windowing: input.windowing,
|
|
||||||
actions: {
|
|
||||||
...input.actions,
|
|
||||||
registerGlobalShortcuts: () => input.runtimes.shortcuts.registerGlobalShortcuts(),
|
|
||||||
startBackgroundWarmups: () => input.runtimes.mpvRuntime.startBackgroundWarmups(),
|
|
||||||
},
|
|
||||||
trayState: input.trayState,
|
|
||||||
startup: input.startup,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiBootstrapCoordinatorInput<TWindow extends BrowserWindow> {
|
|
||||||
appState: OverlayUiBootstrapFromMainStateInput<TWindow>['appState'];
|
|
||||||
overlayManager: OverlayUiBootstrapFromMainStateInput<TWindow>['overlayManager'];
|
|
||||||
overlayModalInputState: OverlayUiBootstrapFromMainStateInput<TWindow>['overlayModalInputState'];
|
|
||||||
overlayModalRuntime: OverlayUiBootstrapFromMainStateInput<TWindow>['overlayModalRuntime'];
|
|
||||||
overlayShortcutsRuntime: OverlayUiBootstrapFromMainStateInput<TWindow>['overlayShortcutsRuntime'];
|
|
||||||
runtimes: OverlayUiBootstrapFromMainStateInput<TWindow>['runtimes'];
|
|
||||||
env: {
|
|
||||||
screen: OverlayUiBootstrapFromMainStateInput<TWindow>['electron']['screen'];
|
|
||||||
appPath: string;
|
|
||||||
resourcesPath: string;
|
|
||||||
dirname: string;
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
};
|
|
||||||
windowing: OverlayUiBootstrapFromMainStateInput<TWindow>['windowing'];
|
|
||||||
actions: Omit<
|
|
||||||
OverlayUiBootstrapFromMainStateInput<TWindow>['actions'],
|
|
||||||
| 'resolveTrayIconPathRuntime'
|
|
||||||
| 'buildTrayMenuTemplateRuntime'
|
|
||||||
| 'broadcastRuntimeOptionsChangedRuntime'
|
|
||||||
| 'setOverlayDebugVisualizationEnabledRuntime'
|
|
||||||
| 'initializeOverlayRuntimeCore'
|
|
||||||
> &
|
|
||||||
Pick<
|
|
||||||
OverlayUiBootstrapFromMainStateInput<TWindow>['actions'],
|
|
||||||
| 'resolveTrayIconPathRuntime'
|
|
||||||
| 'buildTrayMenuTemplateRuntime'
|
|
||||||
| 'broadcastRuntimeOptionsChangedRuntime'
|
|
||||||
| 'setOverlayDebugVisualizationEnabledRuntime'
|
|
||||||
| 'initializeOverlayRuntimeCore'
|
|
||||||
>;
|
|
||||||
trayState: OverlayUiBootstrapFromMainStateInput<TWindow>['trayState'];
|
|
||||||
startup: OverlayUiBootstrapFromMainStateInput<TWindow>['startup'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createOverlayUiBootstrapCoordinator<TWindow extends BrowserWindow>(
|
|
||||||
input: OverlayUiBootstrapCoordinatorInput<TWindow>,
|
|
||||||
): OverlayUiBootstrapRuntime<TWindow> {
|
|
||||||
return createOverlayUiBootstrapFromMainState<TWindow>({
|
|
||||||
appState: input.appState,
|
|
||||||
overlayManager: input.overlayManager,
|
|
||||||
overlayModalInputState: input.overlayModalInputState,
|
|
||||||
overlayModalRuntime: input.overlayModalRuntime,
|
|
||||||
overlayShortcutsRuntime: input.overlayShortcutsRuntime,
|
|
||||||
runtimes: input.runtimes,
|
|
||||||
electron: {
|
|
||||||
screen: input.env.screen,
|
|
||||||
appPath: input.env.appPath,
|
|
||||||
resourcesPath: input.env.resourcesPath,
|
|
||||||
dirname: input.env.dirname,
|
|
||||||
platform: input.env.platform,
|
|
||||||
joinPath: (...parts) => path.join(...parts),
|
|
||||||
fileExists: (candidate) => fs.existsSync(candidate),
|
|
||||||
createImageFromPath: (iconPath) => nativeImage.createFromPath(iconPath),
|
|
||||||
createEmptyImage: () => nativeImage.createEmpty(),
|
|
||||||
createTray: (icon) => new Tray(icon as ConstructorParameters<typeof Tray>[0]),
|
|
||||||
buildMenuFromTemplate: (template) =>
|
|
||||||
Menu.buildFromTemplate(template as (MenuItemConstructorOptions | MenuItem)[]),
|
|
||||||
},
|
|
||||||
windowing: input.windowing,
|
|
||||||
actions: input.actions,
|
|
||||||
trayState: input.trayState,
|
|
||||||
startup: input.startup,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiBootstrapFromProcessStateInput<TWindow extends BrowserWindow> {
|
|
||||||
appState: OverlayUiBootstrapCoordinatorInput<TWindow>['appState'];
|
|
||||||
overlayManager: OverlayUiBootstrapCoordinatorInput<TWindow>['overlayManager'];
|
|
||||||
overlayModalInputState: OverlayUiBootstrapCoordinatorInput<TWindow>['overlayModalInputState'];
|
|
||||||
overlayModalRuntime: OverlayUiBootstrapCoordinatorInput<TWindow>['overlayModalRuntime'];
|
|
||||||
overlayShortcutsRuntime: OverlayUiBootstrapCoordinatorInput<TWindow>['overlayShortcutsRuntime'];
|
|
||||||
runtimes: OverlayUiBootstrapCoordinatorInput<TWindow>['runtimes'];
|
|
||||||
env: OverlayUiBootstrapCoordinatorInput<TWindow>['env'] & {
|
|
||||||
isDev: boolean;
|
|
||||||
};
|
|
||||||
actions: {
|
|
||||||
showMpvOsd: (message: string) => void;
|
|
||||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
|
||||||
sendMpvCommand: (command: (string | number)[]) => void;
|
|
||||||
ensureOverlayMpvSubtitlesHidden: () => Promise<void>;
|
|
||||||
syncOverlayMpvSubtitleSuppression: () => void;
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
requestAppQuit: () => void;
|
|
||||||
};
|
|
||||||
trayState: OverlayUiBootstrapCoordinatorInput<TWindow>['trayState'];
|
|
||||||
startup: OverlayUiBootstrapCoordinatorInput<TWindow>['startup'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createOverlayUiBootstrapFromProcessState<TWindow extends BrowserWindow>(
|
|
||||||
input: OverlayUiBootstrapFromProcessStateInput<TWindow>,
|
|
||||||
): OverlayUiBootstrapRuntime<TWindow> {
|
|
||||||
return createOverlayUiBootstrapCoordinator({
|
|
||||||
appState: input.appState,
|
|
||||||
overlayManager: input.overlayManager,
|
|
||||||
overlayModalInputState: input.overlayModalInputState,
|
|
||||||
overlayModalRuntime: input.overlayModalRuntime,
|
|
||||||
overlayShortcutsRuntime: input.overlayShortcutsRuntime,
|
|
||||||
runtimes: input.runtimes,
|
|
||||||
env: input.env,
|
|
||||||
windowing: {
|
|
||||||
isDev: input.env.isDev,
|
|
||||||
createOverlayWindowCore: (kind, options) =>
|
|
||||||
createOverlayWindowCore(kind, options as never) as TWindow,
|
|
||||||
ensureOverlayWindowLevelCore: (window) =>
|
|
||||||
ensureOverlayWindowLevelCore(window as BrowserWindow),
|
|
||||||
syncOverlayWindowLayer: (window, layer) =>
|
|
||||||
syncOverlayWindowLayer(window as BrowserWindow, layer),
|
|
||||||
enforceOverlayLayerOrderCore: (params) =>
|
|
||||||
enforceOverlayLayerOrderCore({
|
|
||||||
...params,
|
|
||||||
mainWindow: params.mainWindow as BrowserWindow | null,
|
|
||||||
ensureOverlayWindowLevel: (window) => params.ensureOverlayWindowLevel(window as TWindow),
|
|
||||||
}),
|
|
||||||
createWindowTrackerCore: (override, targetMpvSocketPath) =>
|
|
||||||
createWindowTrackerCore(override, targetMpvSocketPath),
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
showMpvOsd: (message) => input.actions.showMpvOsd(message),
|
|
||||||
showDesktopNotification: (title, options) =>
|
|
||||||
input.actions.showDesktopNotification(title, options),
|
|
||||||
sendMpvCommand: (command) => input.actions.sendMpvCommand(command),
|
|
||||||
broadcastRuntimeOptionsChangedRuntime,
|
|
||||||
setOverlayDebugVisualizationEnabledRuntime,
|
|
||||||
resolveTrayIconPathRuntime,
|
|
||||||
buildTrayMenuTemplateRuntime,
|
|
||||||
initializeOverlayRuntimeCore: (options) => initializeOverlayRuntimeCore(options as never),
|
|
||||||
ensureOverlayMpvSubtitlesHidden: () => input.actions.ensureOverlayMpvSubtitlesHidden(),
|
|
||||||
syncOverlayMpvSubtitleSuppression: () => input.actions.syncOverlayMpvSubtitleSuppression(),
|
|
||||||
getResolvedConfig: () => input.actions.getResolvedConfig(),
|
|
||||||
requestAppQuit: input.actions.requestAppQuit,
|
|
||||||
},
|
|
||||||
trayState: input.trayState,
|
|
||||||
startup: input.startup,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
|
||||||
|
|
||||||
import { createOverlayUiBootstrapRuntimeInput } from './overlay-ui-bootstrap-runtime-input';
|
|
||||||
|
|
||||||
test('overlay ui bootstrap runtime input builder preserves grouped wiring', () => {
|
|
||||||
const input = createOverlayUiBootstrapRuntimeInput({
|
|
||||||
windows: {
|
|
||||||
state: {
|
|
||||||
getMainWindow: () => null,
|
|
||||||
setMainWindow: () => {},
|
|
||||||
getModalWindow: () => null,
|
|
||||||
setModalWindow: () => {},
|
|
||||||
getVisibleOverlayVisible: () => false,
|
|
||||||
setVisibleOverlayVisible: () => {},
|
|
||||||
getOverlayDebugVisualizationEnabled: () => false,
|
|
||||||
setOverlayDebugVisualizationEnabled: () => {},
|
|
||||||
},
|
|
||||||
geometry: {
|
|
||||||
getCurrentOverlayGeometry: () => ({ x: 1, y: 2, width: 3, height: 4 }),
|
|
||||||
},
|
|
||||||
modal: {
|
|
||||||
setModalWindowBounds: () => {},
|
|
||||||
onModalStateChange: () => {},
|
|
||||||
},
|
|
||||||
modalRuntime: {
|
|
||||||
handleOverlayModalClosed: (_modal: OverlayHostedModal) => {},
|
|
||||||
notifyOverlayModalOpened: (_modal: OverlayHostedModal) => {},
|
|
||||||
waitForModalOpen: async () => true,
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () => new Set<OverlayHostedModal>(),
|
|
||||||
openRuntimeOptionsPalette: () => {},
|
|
||||||
sendToActiveOverlayWindow: () => false,
|
|
||||||
},
|
|
||||||
visibility: {
|
|
||||||
service: {
|
|
||||||
getModalActive: () => false,
|
|
||||||
getForceMousePassthrough: () => false,
|
|
||||||
getWindowTracker: () => null,
|
|
||||||
getTrackerNotReadyWarningShown: () => false,
|
|
||||||
setTrackerNotReadyWarningShown: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {},
|
|
||||||
ensureOverlayWindowLevel: () => {},
|
|
||||||
syncPrimaryOverlayWindowLayer: () => {},
|
|
||||||
enforceOverlayLayerOrder: () => {},
|
|
||||||
syncOverlayShortcuts: () => {},
|
|
||||||
isMacOSPlatform: () => false,
|
|
||||||
isWindowsPlatform: () => false,
|
|
||||||
showOverlayLoadingOsd: () => {},
|
|
||||||
resolveFallbackBounds: () => ({ x: 5, y: 6, width: 7, height: 8 }),
|
|
||||||
},
|
|
||||||
overlayWindows: {
|
|
||||||
createOverlayWindowCore: () => ({ isDestroyed: () => false }),
|
|
||||||
isDev: false,
|
|
||||||
ensureOverlayWindowLevel: () => {},
|
|
||||||
onRuntimeOptionsChanged: () => {},
|
|
||||||
setOverlayDebugVisualizationEnabled: () => {},
|
|
||||||
isOverlayVisible: () => false,
|
|
||||||
getYomitanSession: () => null,
|
|
||||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
|
||||||
forwardTabToMpv: () => {},
|
|
||||||
onWindowClosed: () => {},
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
setVisibleOverlayVisibleCore: () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overlayActions: {
|
|
||||||
getRuntimeOptionsManager: () => null,
|
|
||||||
getMpvClient: () => null,
|
|
||||||
broadcastRuntimeOptionsChangedRuntime: () => {},
|
|
||||||
broadcastToOverlayWindows: () => {},
|
|
||||||
setOverlayDebugVisualizationEnabledRuntime: () => {},
|
|
||||||
},
|
|
||||||
tray: null,
|
|
||||||
bootstrap: {
|
|
||||||
initializeOverlayRuntimeMainDeps: {
|
|
||||||
appState: {
|
|
||||||
backendOverride: null,
|
|
||||||
windowTracker: null,
|
|
||||||
subtitleTimingTracker: null,
|
|
||||||
mpvClient: null,
|
|
||||||
mpvSocketPath: '/tmp/mpv.sock',
|
|
||||||
runtimeOptionsManager: null,
|
|
||||||
ankiIntegration: null,
|
|
||||||
},
|
|
||||||
overlayManager: {
|
|
||||||
getVisibleOverlayVisible: () => false,
|
|
||||||
},
|
|
||||||
overlayVisibilityRuntime: {
|
|
||||||
updateVisibleOverlayVisibility: () => {},
|
|
||||||
},
|
|
||||||
overlayShortcutsRuntime: {
|
|
||||||
syncOverlayShortcuts: () => {},
|
|
||||||
},
|
|
||||||
createMainWindow: () => {},
|
|
||||||
registerGlobalShortcuts: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {},
|
|
||||||
getOverlayWindows: () => [],
|
|
||||||
getResolvedConfig: () => ({}) as never,
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
|
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
|
||||||
shouldStartAnkiIntegration: () => false,
|
|
||||||
},
|
|
||||||
initializeOverlayRuntimeBootstrapDeps: {
|
|
||||||
isOverlayRuntimeInitialized: () => false,
|
|
||||||
initializeOverlayRuntimeCore: () => {},
|
|
||||||
setOverlayRuntimeInitialized: () => {},
|
|
||||||
startBackgroundWarmups: () => {},
|
|
||||||
},
|
|
||||||
onInitialized: () => {},
|
|
||||||
},
|
|
||||||
runtimeState: {
|
|
||||||
isOverlayRuntimeInitialized: () => false,
|
|
||||||
setOverlayRuntimeInitialized: () => {},
|
|
||||||
},
|
|
||||||
mpvSubtitle: {
|
|
||||||
ensureOverlayMpvSubtitlesHidden: () => {},
|
|
||||||
syncOverlayMpvSubtitleSuppression: () => {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(input.tray, null);
|
|
||||||
assert.equal(input.windows.windowState.getMainWindow(), null);
|
|
||||||
assert.equal(input.windows.geometry.getCurrentOverlayGeometry().width, 3);
|
|
||||||
assert.equal(input.windows.visibilityService.resolveFallbackBounds().height, 8);
|
|
||||||
assert.equal(
|
|
||||||
input.bootstrap.initializeOverlayRuntimeBootstrapDeps.isOverlayRuntimeInitialized(),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
|
||||||
import type {
|
|
||||||
OverlayUiActionsInput,
|
|
||||||
OverlayUiBootstrapInput,
|
|
||||||
OverlayUiGeometryInput,
|
|
||||||
OverlayUiModalInput,
|
|
||||||
OverlayUiMpvSubtitleInput,
|
|
||||||
OverlayUiRuntimeStateInput,
|
|
||||||
OverlayUiTrayInput,
|
|
||||||
OverlayUiVisibilityActionsInput,
|
|
||||||
OverlayUiVisibilityServiceInput,
|
|
||||||
OverlayUiWindowState,
|
|
||||||
OverlayUiWindowsInput,
|
|
||||||
} from './overlay-ui-runtime';
|
|
||||||
import type { OverlayUiRuntimeGroupedInput } from './overlay-ui-runtime-input';
|
|
||||||
|
|
||||||
type WindowLike = {
|
|
||||||
isDestroyed: () => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface OverlayUiBootstrapRuntimeWindowsInput<TWindow extends WindowLike = WindowLike> {
|
|
||||||
state: OverlayUiWindowState<TWindow>;
|
|
||||||
geometry: OverlayUiGeometryInput;
|
|
||||||
modal: OverlayUiModalInput;
|
|
||||||
modalRuntime: {
|
|
||||||
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
|
||||||
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
|
|
||||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
|
||||||
openRuntimeOptionsPalette: () => void;
|
|
||||||
sendToActiveOverlayWindow: (
|
|
||||||
channel: string,
|
|
||||||
payload?: unknown,
|
|
||||||
runtimeOptions?: {
|
|
||||||
restoreOnModalClose?: OverlayHostedModal;
|
|
||||||
preferModalWindow?: boolean;
|
|
||||||
},
|
|
||||||
) => boolean;
|
|
||||||
};
|
|
||||||
visibility: {
|
|
||||||
service: OverlayUiVisibilityServiceInput<TWindow>;
|
|
||||||
overlayWindows: OverlayUiWindowsInput<TWindow>;
|
|
||||||
actions: OverlayUiVisibilityActionsInput;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiBootstrapRuntimeInput<TWindow extends WindowLike = WindowLike> {
|
|
||||||
windows: OverlayUiBootstrapRuntimeWindowsInput<TWindow>;
|
|
||||||
overlayActions: OverlayUiActionsInput;
|
|
||||||
tray: OverlayUiTrayInput | null;
|
|
||||||
bootstrap: OverlayUiBootstrapInput;
|
|
||||||
runtimeState: OverlayUiRuntimeStateInput;
|
|
||||||
mpvSubtitle: OverlayUiMpvSubtitleInput;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createOverlayUiBootstrapRuntimeInput<TWindow extends WindowLike>(
|
|
||||||
input: OverlayUiBootstrapRuntimeInput<TWindow>,
|
|
||||||
): OverlayUiRuntimeGroupedInput<TWindow> {
|
|
||||||
return {
|
|
||||||
windows: {
|
|
||||||
windowState: input.windows.state,
|
|
||||||
geometry: input.windows.geometry,
|
|
||||||
modal: input.windows.modal,
|
|
||||||
modalRuntime: {
|
|
||||||
handleOverlayModalClosed: (modal) =>
|
|
||||||
input.windows.modalRuntime.handleOverlayModalClosed(modal),
|
|
||||||
notifyOverlayModalOpened: (modal) =>
|
|
||||||
input.windows.modalRuntime.notifyOverlayModalOpened(modal),
|
|
||||||
waitForModalOpen: (modal, timeoutMs) =>
|
|
||||||
input.windows.modalRuntime.waitForModalOpen(modal, timeoutMs),
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () =>
|
|
||||||
input.windows.modalRuntime.getRestoreVisibleOverlayOnModalClose(),
|
|
||||||
openRuntimeOptionsPalette: () => input.windows.modalRuntime.openRuntimeOptionsPalette(),
|
|
||||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
|
||||||
input.windows.modalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
|
||||||
},
|
|
||||||
visibilityService: input.windows.visibility.service,
|
|
||||||
overlayWindows: input.windows.visibility.overlayWindows,
|
|
||||||
visibilityActions: input.windows.visibility.actions,
|
|
||||||
},
|
|
||||||
overlayActions: input.overlayActions,
|
|
||||||
tray: input.tray,
|
|
||||||
bootstrap: input.bootstrap,
|
|
||||||
runtimeState: input.runtimeState,
|
|
||||||
mpvSubtitle: input.mpvSubtitle,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,503 +0,0 @@
|
|||||||
import type { BrowserWindow, Session } from 'electron';
|
|
||||||
import type {
|
|
||||||
AnkiConnectConfig,
|
|
||||||
KikuFieldGroupingChoice,
|
|
||||||
KikuFieldGroupingRequestData,
|
|
||||||
RuntimeOptionState,
|
|
||||||
WindowGeometry,
|
|
||||||
} from '../types';
|
|
||||||
import type { BaseWindowTracker } from '../window-trackers';
|
|
||||||
import {
|
|
||||||
createOverlayGeometryRuntime,
|
|
||||||
type OverlayGeometryRuntime,
|
|
||||||
} from './overlay-geometry-runtime';
|
|
||||||
import { createOverlayUiBootstrapRuntimeInput } from './overlay-ui-bootstrap-runtime-input';
|
|
||||||
import type { OverlayModalRuntime } from './overlay-runtime';
|
|
||||||
import { createOverlayUiRuntime, type OverlayUiRuntime } from './overlay-ui-runtime';
|
|
||||||
|
|
||||||
type WindowLike = {
|
|
||||||
isDestroyed: () => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OverlayWindowKind = 'visible' | 'modal';
|
|
||||||
|
|
||||||
type ScreenLike = {
|
|
||||||
getCursorScreenPoint: () => { x: number; y: number };
|
|
||||||
getDisplayNearestPoint: (point: { x: number; y: number }) => {
|
|
||||||
workArea: { x: number; y: number; width: number; height: number };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type OverlayWindowTrackerLike = BaseWindowTracker | null;
|
|
||||||
|
|
||||||
type OverlayRuntimeOptionsManagerLike = {
|
|
||||||
listOptions: () => RuntimeOptionState[];
|
|
||||||
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
type OverlayMpvClientLike = {
|
|
||||||
connected: boolean;
|
|
||||||
restorePreviousSecondarySubVisibility: () => void;
|
|
||||||
send?: (payload: { command: string[] }) => void;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
type OverlayManagerLike<TWindow extends WindowLike> = {
|
|
||||||
getMainWindow: () => TWindow | null;
|
|
||||||
setMainWindow: (window: TWindow | null) => void;
|
|
||||||
getModalWindow: () => TWindow | null;
|
|
||||||
setModalWindow: (window: TWindow | null) => void;
|
|
||||||
getVisibleOverlayVisible: () => boolean;
|
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
|
||||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
|
||||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
|
||||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
|
|
||||||
getOverlayWindows: () => BrowserWindow[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type OverlayModalInputStateLike = {
|
|
||||||
getModalInputExclusive: () => boolean;
|
|
||||||
handleModalInputStateChange: (active: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OverlayShortcutsRuntimeLike = {
|
|
||||||
syncOverlayShortcuts: () => void;
|
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DictionarySupportLike = {
|
|
||||||
createFieldGroupingCallback: () => (
|
|
||||||
data: KikuFieldGroupingRequestData,
|
|
||||||
) => Promise<KikuFieldGroupingChoice>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FirstRunLike = {
|
|
||||||
isSetupCompleted: () => boolean;
|
|
||||||
openFirstRunSetupWindow: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type YomitanLike = {
|
|
||||||
openYomitanSettings: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type JellyfinLike = {
|
|
||||||
openJellyfinSetupWindow: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AnilistLike = {
|
|
||||||
openAnilistSetupWindow: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BootstrapTrayIconLike = {
|
|
||||||
isEmpty: () => boolean;
|
|
||||||
resize: (options: {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
quality?: 'best' | 'better' | 'good';
|
|
||||||
}) => BootstrapTrayIconLike;
|
|
||||||
setTemplateImage: (enabled: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BootstrapTrayLike = {
|
|
||||||
setContextMenu: (menu: any) => void;
|
|
||||||
setToolTip: (tooltip: string) => void;
|
|
||||||
on: (event: 'click', handler: () => void) => void;
|
|
||||||
destroy: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface OverlayUiBootstrapAppStateInput {
|
|
||||||
backendOverride: string | null;
|
|
||||||
windowTracker: OverlayWindowTrackerLike;
|
|
||||||
subtitleTimingTracker: unknown;
|
|
||||||
mpvClient: OverlayMpvClientLike;
|
|
||||||
mpvSocketPath: string;
|
|
||||||
runtimeOptionsManager: OverlayRuntimeOptionsManagerLike;
|
|
||||||
ankiIntegration: unknown;
|
|
||||||
overlayRuntimeInitialized: boolean;
|
|
||||||
overlayDebugVisualizationEnabled: boolean;
|
|
||||||
statsOverlayVisible: boolean;
|
|
||||||
trackerNotReadyWarningShown: boolean;
|
|
||||||
yomitanSession: Session | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiBootstrapElectronInput<
|
|
||||||
TWindow extends WindowLike,
|
|
||||||
TMenuItem = unknown,
|
|
||||||
TMenu = unknown,
|
|
||||||
> {
|
|
||||||
screen: ScreenLike;
|
|
||||||
appPath: string;
|
|
||||||
resourcesPath: string;
|
|
||||||
dirname: string;
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
joinPath: (...parts: string[]) => string;
|
|
||||||
fileExists: (candidate: string) => boolean;
|
|
||||||
createImageFromPath: (iconPath: string) => BootstrapTrayIconLike;
|
|
||||||
createEmptyImage: () => BootstrapTrayIconLike;
|
|
||||||
createTray: (icon: BootstrapTrayIconLike) => BootstrapTrayLike;
|
|
||||||
buildMenuFromTemplate: (template: TMenuItem[]) => TMenu;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiBootstrapInput<TWindow extends WindowLike> {
|
|
||||||
appState: OverlayUiBootstrapAppStateInput;
|
|
||||||
overlayManager: OverlayManagerLike<TWindow>;
|
|
||||||
overlayModalInputState: OverlayModalInputStateLike;
|
|
||||||
overlayModalRuntime: OverlayModalRuntime;
|
|
||||||
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
|
|
||||||
dictionarySupport: DictionarySupportLike;
|
|
||||||
firstRun: FirstRunLike;
|
|
||||||
yomitan: YomitanLike;
|
|
||||||
jellyfin: JellyfinLike;
|
|
||||||
anilist: AnilistLike;
|
|
||||||
electron: OverlayUiBootstrapElectronInput<TWindow>;
|
|
||||||
windowing: {
|
|
||||||
isDev: boolean;
|
|
||||||
createOverlayWindowCore: (
|
|
||||||
kind: OverlayWindowKind,
|
|
||||||
options: {
|
|
||||||
isDev: boolean;
|
|
||||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
|
||||||
onRuntimeOptionsChanged: () => void;
|
|
||||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
|
||||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
|
||||||
forwardTabToMpv: () => void;
|
|
||||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
|
||||||
yomitanSession?: Electron.Session | null;
|
|
||||||
},
|
|
||||||
) => TWindow;
|
|
||||||
ensureOverlayWindowLevelCore: (window: TWindow) => void;
|
|
||||||
syncOverlayWindowLayer: (window: TWindow, layer: 'visible') => void;
|
|
||||||
enforceOverlayLayerOrderCore: (params: {
|
|
||||||
visibleOverlayVisible: boolean;
|
|
||||||
mainWindow: TWindow | null;
|
|
||||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
|
||||||
}) => void;
|
|
||||||
createWindowTrackerCore: (
|
|
||||||
override?: string | null,
|
|
||||||
targetMpvSocketPath?: string | null,
|
|
||||||
) => BaseWindowTracker | null;
|
|
||||||
};
|
|
||||||
actions: {
|
|
||||||
showMpvOsd: (message: string) => void;
|
|
||||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
|
||||||
sendMpvCommand: (command: (string | number)[]) => void;
|
|
||||||
broadcastRuntimeOptionsChangedRuntime: (
|
|
||||||
getRuntimeOptionsState: () => RuntimeOptionState[],
|
|
||||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
|
|
||||||
) => void;
|
|
||||||
setOverlayDebugVisualizationEnabledRuntime: (
|
|
||||||
currentEnabled: boolean,
|
|
||||||
nextEnabled: boolean,
|
|
||||||
setCurrentEnabled: (enabled: boolean) => void,
|
|
||||||
) => void;
|
|
||||||
resolveTrayIconPathRuntime: (options: {
|
|
||||||
platform: string;
|
|
||||||
resourcesPath: string;
|
|
||||||
appPath: string;
|
|
||||||
dirname: string;
|
|
||||||
joinPath: (...parts: string[]) => string;
|
|
||||||
fileExists: (path: string) => boolean;
|
|
||||||
}) => string | null;
|
|
||||||
buildTrayMenuTemplateRuntime: (handlers: {
|
|
||||||
openOverlay: () => void;
|
|
||||||
openFirstRunSetup: () => void;
|
|
||||||
showFirstRunSetup: boolean;
|
|
||||||
openWindowsMpvLauncherSetup: () => void;
|
|
||||||
showWindowsMpvLauncherSetup: boolean;
|
|
||||||
openYomitanSettings: () => void;
|
|
||||||
openRuntimeOptions: () => void;
|
|
||||||
openJellyfinSetup: () => void;
|
|
||||||
openAnilistSetup: () => void;
|
|
||||||
quitApp: () => void;
|
|
||||||
}) => unknown[];
|
|
||||||
initializeOverlayRuntimeCore: (options: unknown) => void;
|
|
||||||
ensureOverlayMpvSubtitlesHidden: () => Promise<void> | void;
|
|
||||||
syncOverlayMpvSubtitleSuppression: () => void;
|
|
||||||
registerGlobalShortcuts: () => void;
|
|
||||||
startBackgroundWarmups: () => void;
|
|
||||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
|
||||||
requestAppQuit: () => void;
|
|
||||||
};
|
|
||||||
trayState: {
|
|
||||||
getTray: () => BootstrapTrayLike | null;
|
|
||||||
setTray: (tray: BootstrapTrayLike | null) => void;
|
|
||||||
trayTooltip: string;
|
|
||||||
logWarn: (message: string) => void;
|
|
||||||
};
|
|
||||||
startup: {
|
|
||||||
shouldSkipHeadlessOverlayBootstrap: () => boolean;
|
|
||||||
getKnownWordCacheStatePath: () => string;
|
|
||||||
onInitialized?: () => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiBootstrapRuntime<TWindow extends WindowLike> {
|
|
||||||
overlayGeometry: OverlayGeometryRuntime<TWindow>;
|
|
||||||
overlayUi: OverlayUiRuntime<TWindow>;
|
|
||||||
syncOverlayVisibilityForModal: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createOverlayUiBootstrapRuntime<TWindow extends WindowLike>(
|
|
||||||
input: OverlayUiBootstrapInput<TWindow>,
|
|
||||||
): OverlayUiBootstrapRuntime<TWindow> {
|
|
||||||
const overlayGeometry = createOverlayGeometryRuntime<TWindow>({
|
|
||||||
screen: input.electron.screen,
|
|
||||||
windowState: {
|
|
||||||
getMainWindow: () => input.overlayManager.getMainWindow(),
|
|
||||||
setOverlayWindowBounds: (geometry) => input.overlayManager.setOverlayWindowBounds(geometry),
|
|
||||||
setModalWindowBounds: (geometry) => input.overlayManager.setModalWindowBounds(geometry),
|
|
||||||
getVisibleOverlayVisible: () => input.overlayManager.getVisibleOverlayVisible(),
|
|
||||||
},
|
|
||||||
getWindowTracker: () => input.appState.windowTracker,
|
|
||||||
ensureOverlayWindowLevelCore: (window) => input.windowing.ensureOverlayWindowLevelCore(window),
|
|
||||||
syncOverlayWindowLayer: (window, layer) =>
|
|
||||||
input.windowing.syncOverlayWindowLayer(window, layer),
|
|
||||||
enforceOverlayLayerOrderCore: (params) => input.windowing.enforceOverlayLayerOrderCore(params),
|
|
||||||
});
|
|
||||||
|
|
||||||
let overlayUi: OverlayUiRuntime<TWindow> | undefined;
|
|
||||||
|
|
||||||
overlayUi = createOverlayUiRuntime(
|
|
||||||
createOverlayUiBootstrapRuntimeInput<TWindow>({
|
|
||||||
windows: {
|
|
||||||
state: {
|
|
||||||
getMainWindow: () => input.overlayManager.getMainWindow(),
|
|
||||||
setMainWindow: (window) => input.overlayManager.setMainWindow(window),
|
|
||||||
getModalWindow: () => input.overlayManager.getModalWindow(),
|
|
||||||
setModalWindow: (window) => input.overlayManager.setModalWindow(window),
|
|
||||||
getVisibleOverlayVisible: () => input.overlayManager.getVisibleOverlayVisible(),
|
|
||||||
setVisibleOverlayVisible: (visible) =>
|
|
||||||
input.overlayManager.setVisibleOverlayVisible(visible),
|
|
||||||
getOverlayDebugVisualizationEnabled: () =>
|
|
||||||
input.appState.overlayDebugVisualizationEnabled,
|
|
||||||
setOverlayDebugVisualizationEnabled: (enabled) => {
|
|
||||||
input.appState.overlayDebugVisualizationEnabled = enabled;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
geometry: {
|
|
||||||
getCurrentOverlayGeometry: () => overlayGeometry.getCurrentOverlayGeometry(),
|
|
||||||
},
|
|
||||||
modal: {
|
|
||||||
setModalWindowBounds: (geometry) => input.overlayManager.setModalWindowBounds(geometry),
|
|
||||||
onModalStateChange: (active) => {
|
|
||||||
input.overlayModalInputState.handleModalInputStateChange(active);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
modalRuntime: input.overlayModalRuntime as never,
|
|
||||||
visibility: {
|
|
||||||
service: {
|
|
||||||
getModalActive: () => input.overlayModalInputState.getModalInputExclusive(),
|
|
||||||
getForceMousePassthrough: () => input.appState.statsOverlayVisible,
|
|
||||||
getWindowTracker: () => input.appState.windowTracker,
|
|
||||||
getTrackerNotReadyWarningShown: () => input.appState.trackerNotReadyWarningShown,
|
|
||||||
setTrackerNotReadyWarningShown: (shown) => {
|
|
||||||
input.appState.trackerNotReadyWarningShown = shown;
|
|
||||||
},
|
|
||||||
updateVisibleOverlayBounds: (geometry) =>
|
|
||||||
overlayGeometry.updateVisibleOverlayBounds(geometry),
|
|
||||||
ensureOverlayWindowLevel: (window) => overlayGeometry.ensureOverlayWindowLevel(window),
|
|
||||||
syncPrimaryOverlayWindowLayer: (layer) =>
|
|
||||||
overlayGeometry.syncPrimaryOverlayWindowLayer(layer),
|
|
||||||
enforceOverlayLayerOrder: () => overlayGeometry.enforceOverlayLayerOrder(),
|
|
||||||
syncOverlayShortcuts: () => input.overlayShortcutsRuntime.syncOverlayShortcuts(),
|
|
||||||
isMacOSPlatform: () => input.electron.platform === 'darwin',
|
|
||||||
isWindowsPlatform: () => input.electron.platform === 'win32',
|
|
||||||
showOverlayLoadingOsd: (message) => input.actions.showMpvOsd(message),
|
|
||||||
resolveFallbackBounds: () => overlayGeometry.getOverlayGeometryFallback(),
|
|
||||||
},
|
|
||||||
overlayWindows: {
|
|
||||||
createOverlayWindowCore: (kind, options) =>
|
|
||||||
input.windowing.createOverlayWindowCore(kind, options),
|
|
||||||
isDev: input.windowing.isDev,
|
|
||||||
ensureOverlayWindowLevel: (window) => overlayGeometry.ensureOverlayWindowLevel(window),
|
|
||||||
onRuntimeOptionsChanged: () => {
|
|
||||||
overlayUi?.broadcastRuntimeOptionsChanged();
|
|
||||||
},
|
|
||||||
setOverlayDebugVisualizationEnabled: (enabled) => {
|
|
||||||
overlayUi?.setOverlayDebugVisualizationEnabled(enabled);
|
|
||||||
},
|
|
||||||
isOverlayVisible: (windowKind) =>
|
|
||||||
windowKind === 'visible' ? input.overlayManager.getVisibleOverlayVisible() : false,
|
|
||||||
getYomitanSession: () => input.appState.yomitanSession,
|
|
||||||
tryHandleOverlayShortcutLocalFallback: (overlayInput) =>
|
|
||||||
input.overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(overlayInput),
|
|
||||||
forwardTabToMpv: () => input.actions.sendMpvCommand(['keypress', 'TAB']),
|
|
||||||
onWindowClosed: (windowKind) => {
|
|
||||||
if (windowKind === 'visible') {
|
|
||||||
input.overlayManager.setMainWindow(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
input.overlayManager.setModalWindow(null);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
setVisibleOverlayVisibleCore: ({
|
|
||||||
visible,
|
|
||||||
setVisibleOverlayVisibleState,
|
|
||||||
updateVisibleOverlayVisibility,
|
|
||||||
}) => {
|
|
||||||
setVisibleOverlayVisibleState(visible);
|
|
||||||
updateVisibleOverlayVisibility();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overlayActions: {
|
|
||||||
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
|
|
||||||
getMpvClient: () => input.appState.mpvClient,
|
|
||||||
broadcastRuntimeOptionsChangedRuntime: (
|
|
||||||
getRuntimeOptionsState,
|
|
||||||
broadcastToOverlayWindows,
|
|
||||||
) =>
|
|
||||||
input.actions.broadcastRuntimeOptionsChangedRuntime(
|
|
||||||
getRuntimeOptionsState,
|
|
||||||
broadcastToOverlayWindows,
|
|
||||||
),
|
|
||||||
broadcastToOverlayWindows: (channel, ...args) =>
|
|
||||||
input.overlayManager.broadcastToOverlayWindows(channel, ...args),
|
|
||||||
setOverlayDebugVisualizationEnabledRuntime: (
|
|
||||||
currentEnabled,
|
|
||||||
nextEnabled,
|
|
||||||
setCurrentEnabled,
|
|
||||||
) =>
|
|
||||||
input.actions.setOverlayDebugVisualizationEnabledRuntime(
|
|
||||||
currentEnabled,
|
|
||||||
nextEnabled,
|
|
||||||
setCurrentEnabled,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
tray: {
|
|
||||||
resolveTrayIconPathDeps: {
|
|
||||||
resolveTrayIconPathRuntime: input.actions.resolveTrayIconPathRuntime,
|
|
||||||
platform: input.electron.platform,
|
|
||||||
resourcesPath: input.electron.resourcesPath,
|
|
||||||
appPath: input.electron.appPath,
|
|
||||||
dirname: input.electron.dirname,
|
|
||||||
joinPath: (...parts) => input.electron.joinPath(...parts),
|
|
||||||
fileExists: (candidate) => input.electron.fileExists(candidate),
|
|
||||||
},
|
|
||||||
buildTrayMenuTemplateDeps: {
|
|
||||||
buildTrayMenuTemplateRuntime: input.actions.buildTrayMenuTemplateRuntime,
|
|
||||||
initializeOverlayRuntime: () => {
|
|
||||||
overlayUi?.initializeOverlayRuntime();
|
|
||||||
},
|
|
||||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
|
||||||
setVisibleOverlayVisible: (visible) => {
|
|
||||||
overlayUi?.setVisibleOverlayVisible(visible);
|
|
||||||
},
|
|
||||||
showFirstRunSetup: () => !input.firstRun.isSetupCompleted(),
|
|
||||||
openFirstRunSetupWindow: () => input.firstRun.openFirstRunSetupWindow(),
|
|
||||||
showWindowsMpvLauncherSetup: () => input.electron.platform === 'win32',
|
|
||||||
openYomitanSettings: () => input.yomitan.openYomitanSettings(),
|
|
||||||
openRuntimeOptionsPalette: () => {
|
|
||||||
overlayUi?.openRuntimeOptionsPalette();
|
|
||||||
},
|
|
||||||
openJellyfinSetupWindow: () => input.jellyfin.openJellyfinSetupWindow(),
|
|
||||||
openAnilistSetupWindow: () => input.anilist.openAnilistSetupWindow(),
|
|
||||||
quitApp: () => input.actions.requestAppQuit(),
|
|
||||||
},
|
|
||||||
ensureTrayDeps: {
|
|
||||||
getTray: () => input.trayState.getTray(),
|
|
||||||
setTray: (tray) => input.trayState.setTray(tray),
|
|
||||||
createImageFromPath: (iconPath) => input.electron.createImageFromPath(iconPath),
|
|
||||||
createEmptyImage: () => input.electron.createEmptyImage(),
|
|
||||||
createTray: (icon) => input.electron.createTray(icon),
|
|
||||||
trayTooltip: input.trayState.trayTooltip,
|
|
||||||
platform: input.electron.platform,
|
|
||||||
logWarn: (message) => input.trayState.logWarn(message),
|
|
||||||
initializeOverlayRuntime: () => {
|
|
||||||
overlayUi?.initializeOverlayRuntime();
|
|
||||||
},
|
|
||||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
|
||||||
setVisibleOverlayVisible: (visible) => {
|
|
||||||
overlayUi?.setVisibleOverlayVisible(visible);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
destroyTrayDeps: {
|
|
||||||
getTray: () => input.trayState.getTray(),
|
|
||||||
setTray: (tray) => input.trayState.setTray(tray),
|
|
||||||
},
|
|
||||||
buildMenuFromTemplate: (template) => input.electron.buildMenuFromTemplate(template),
|
|
||||||
},
|
|
||||||
bootstrap: {
|
|
||||||
initializeOverlayRuntimeMainDeps: {
|
|
||||||
appState: input.appState,
|
|
||||||
overlayManager: {
|
|
||||||
getVisibleOverlayVisible: () => input.overlayManager.getVisibleOverlayVisible(),
|
|
||||||
},
|
|
||||||
overlayVisibilityRuntime: {
|
|
||||||
updateVisibleOverlayVisibility: () => {
|
|
||||||
overlayUi?.updateVisibleOverlayVisibility();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overlayShortcutsRuntime: {
|
|
||||||
syncOverlayShortcuts: () => input.overlayShortcutsRuntime.syncOverlayShortcuts(),
|
|
||||||
},
|
|
||||||
createMainWindow: () => {
|
|
||||||
if (input.startup.shouldSkipHeadlessOverlayBootstrap()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
overlayUi?.createMainWindow();
|
|
||||||
},
|
|
||||||
registerGlobalShortcuts: () => {
|
|
||||||
if (input.startup.shouldSkipHeadlessOverlayBootstrap()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
input.actions.registerGlobalShortcuts();
|
|
||||||
},
|
|
||||||
createWindowTracker: (override, targetMpvSocketPath) => {
|
|
||||||
if (input.startup.shouldSkipHeadlessOverlayBootstrap()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return input.windowing.createWindowTrackerCore(
|
|
||||||
override as string | null | undefined,
|
|
||||||
targetMpvSocketPath as string | null | undefined,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
updateVisibleOverlayBounds: (geometry) =>
|
|
||||||
overlayGeometry.updateVisibleOverlayBounds(geometry),
|
|
||||||
getOverlayWindows: () => input.overlayManager.getOverlayWindows(),
|
|
||||||
getResolvedConfig: () => input.actions.getResolvedConfig(),
|
|
||||||
showDesktopNotification: (title, options) =>
|
|
||||||
input.actions.showDesktopNotification(title, options),
|
|
||||||
createFieldGroupingCallback: () => input.dictionarySupport.createFieldGroupingCallback(),
|
|
||||||
getKnownWordCacheStatePath: () => input.startup.getKnownWordCacheStatePath(),
|
|
||||||
shouldStartAnkiIntegration: () => !input.startup.shouldSkipHeadlessOverlayBootstrap(),
|
|
||||||
},
|
|
||||||
initializeOverlayRuntimeBootstrapDeps: {
|
|
||||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
|
||||||
initializeOverlayRuntimeCore: (options) =>
|
|
||||||
input.actions.initializeOverlayRuntimeCore(options),
|
|
||||||
setOverlayRuntimeInitialized: (initialized) => {
|
|
||||||
input.appState.overlayRuntimeInitialized = initialized;
|
|
||||||
},
|
|
||||||
startBackgroundWarmups: () => {
|
|
||||||
if (input.startup.shouldSkipHeadlessOverlayBootstrap()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
input.actions.startBackgroundWarmups();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onInitialized: input.startup.onInitialized,
|
|
||||||
},
|
|
||||||
runtimeState: {
|
|
||||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
|
||||||
setOverlayRuntimeInitialized: (initialized) => {
|
|
||||||
input.appState.overlayRuntimeInitialized = initialized;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mpvSubtitle: {
|
|
||||||
ensureOverlayMpvSubtitlesHidden: () => input.actions.ensureOverlayMpvSubtitlesHidden(),
|
|
||||||
syncOverlayMpvSubtitleSuppression: () => input.actions.syncOverlayMpvSubtitleSuppression(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
overlayGeometry,
|
|
||||||
overlayUi,
|
|
||||||
syncOverlayVisibilityForModal: () => {
|
|
||||||
overlayUi.updateVisibleOverlayVisibility();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
|
||||||
import type { WindowGeometry } from '../types';
|
|
||||||
import type {
|
|
||||||
OverlayUiActionsInput,
|
|
||||||
OverlayUiBootstrapInput,
|
|
||||||
OverlayUiGeometryInput,
|
|
||||||
OverlayUiModalInput,
|
|
||||||
OverlayUiMpvSubtitleInput,
|
|
||||||
OverlayUiRuntimeInput,
|
|
||||||
OverlayUiRuntimeStateInput,
|
|
||||||
OverlayUiTrayInput,
|
|
||||||
OverlayUiVisibilityActionsInput,
|
|
||||||
OverlayUiVisibilityServiceInput,
|
|
||||||
OverlayUiWindowState,
|
|
||||||
OverlayUiWindowsInput,
|
|
||||||
} from './overlay-ui-runtime';
|
|
||||||
|
|
||||||
type WindowLike = {
|
|
||||||
isDestroyed: () => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface OverlayUiRuntimeWindowsInput<TWindow extends WindowLike = WindowLike> {
|
|
||||||
windowState: OverlayUiWindowState<TWindow>;
|
|
||||||
geometry: OverlayUiGeometryInput;
|
|
||||||
modal: OverlayUiModalInput;
|
|
||||||
modalRuntime: {
|
|
||||||
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
|
||||||
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
|
|
||||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
|
||||||
openRuntimeOptionsPalette: () => void;
|
|
||||||
sendToActiveOverlayWindow: (
|
|
||||||
channel: string,
|
|
||||||
payload?: unknown,
|
|
||||||
runtimeOptions?: {
|
|
||||||
restoreOnModalClose?: OverlayHostedModal;
|
|
||||||
preferModalWindow?: boolean;
|
|
||||||
},
|
|
||||||
) => boolean;
|
|
||||||
};
|
|
||||||
visibilityService: OverlayUiVisibilityServiceInput<TWindow>;
|
|
||||||
overlayWindows: OverlayUiWindowsInput<TWindow>;
|
|
||||||
visibilityActions: OverlayUiVisibilityActionsInput;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiRuntimeGroupedInput<TWindow extends WindowLike = WindowLike> {
|
|
||||||
windows: OverlayUiRuntimeWindowsInput<TWindow>;
|
|
||||||
overlayActions: OverlayUiActionsInput;
|
|
||||||
tray: OverlayUiTrayInput | null;
|
|
||||||
bootstrap: OverlayUiBootstrapInput;
|
|
||||||
runtimeState: OverlayUiRuntimeStateInput;
|
|
||||||
mpvSubtitle: OverlayUiMpvSubtitleInput;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OverlayUiRuntimeInputLike<TWindow extends WindowLike = WindowLike> =
|
|
||||||
| OverlayUiRuntimeInput<TWindow>
|
|
||||||
| OverlayUiRuntimeGroupedInput<TWindow>;
|
|
||||||
|
|
||||||
export function normalizeOverlayUiRuntimeInput<TWindow extends WindowLike>(
|
|
||||||
input: OverlayUiRuntimeInputLike<TWindow>,
|
|
||||||
): OverlayUiRuntimeInput<TWindow> {
|
|
||||||
if (!('windows' in input)) {
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
windowState: input.windows.windowState,
|
|
||||||
geometry: input.windows.geometry,
|
|
||||||
modal: input.windows.modal,
|
|
||||||
modalRuntime: {
|
|
||||||
handleOverlayModalClosed: (modal) =>
|
|
||||||
input.windows.modalRuntime.handleOverlayModalClosed(modal),
|
|
||||||
notifyOverlayModalOpened: (modal) =>
|
|
||||||
input.windows.modalRuntime.notifyOverlayModalOpened(modal),
|
|
||||||
waitForModalOpen: (modal, timeoutMs) =>
|
|
||||||
input.windows.modalRuntime.waitForModalOpen(modal, timeoutMs),
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () =>
|
|
||||||
input.windows.modalRuntime.getRestoreVisibleOverlayOnModalClose(),
|
|
||||||
openRuntimeOptionsPalette: () => input.windows.modalRuntime.openRuntimeOptionsPalette(),
|
|
||||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
|
||||||
input.windows.modalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
|
||||||
},
|
|
||||||
visibilityService: input.windows.visibilityService,
|
|
||||||
overlayWindows: input.windows.overlayWindows,
|
|
||||||
visibilityActions: input.windows.visibilityActions,
|
|
||||||
overlayActions: input.overlayActions,
|
|
||||||
tray: input.tray,
|
|
||||||
bootstrap: input.bootstrap,
|
|
||||||
runtimeState: input.runtimeState,
|
|
||||||
mpvSubtitle: input.mpvSubtitle,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,461 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
|
||||||
|
|
||||||
import { createOverlayUiRuntime } from './overlay-ui-runtime';
|
|
||||||
|
|
||||||
type MockWindow = {
|
|
||||||
destroyed: boolean;
|
|
||||||
isDestroyed: () => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function createWindow(): MockWindow {
|
|
||||||
return {
|
|
||||||
destroyed: false,
|
|
||||||
isDestroyed() {
|
|
||||||
return this.destroyed;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('overlay ui runtime lazy-creates main window for toggle visibility actions', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let mainWindow: MockWindow | null = null;
|
|
||||||
const createdWindow = createWindow();
|
|
||||||
let visibleOverlayVisible = false;
|
|
||||||
|
|
||||||
const overlayUi = createOverlayUiRuntime({
|
|
||||||
windows: {
|
|
||||||
windowState: {
|
|
||||||
getMainWindow: () => mainWindow,
|
|
||||||
setMainWindow: (window) => {
|
|
||||||
mainWindow = window;
|
|
||||||
},
|
|
||||||
getModalWindow: () => null,
|
|
||||||
setModalWindow: () => {},
|
|
||||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
|
||||||
setVisibleOverlayVisible: (visible) => {
|
|
||||||
visibleOverlayVisible = visible;
|
|
||||||
},
|
|
||||||
getOverlayDebugVisualizationEnabled: () => false,
|
|
||||||
setOverlayDebugVisualizationEnabled: () => {},
|
|
||||||
},
|
|
||||||
geometry: {
|
|
||||||
getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
|
||||||
},
|
|
||||||
modal: {
|
|
||||||
onModalStateChange: () => {},
|
|
||||||
},
|
|
||||||
modalRuntime: {
|
|
||||||
handleOverlayModalClosed: () => {},
|
|
||||||
notifyOverlayModalOpened: () => {},
|
|
||||||
waitForModalOpen: async () => false,
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () => new Set<OverlayHostedModal>(),
|
|
||||||
openRuntimeOptionsPalette: () => {},
|
|
||||||
sendToActiveOverlayWindow: () => false,
|
|
||||||
},
|
|
||||||
visibilityService: {
|
|
||||||
getModalActive: () => false,
|
|
||||||
getForceMousePassthrough: () => false,
|
|
||||||
getWindowTracker: () => null,
|
|
||||||
getTrackerNotReadyWarningShown: () => false,
|
|
||||||
setTrackerNotReadyWarningShown: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {},
|
|
||||||
ensureOverlayWindowLevel: () => {},
|
|
||||||
syncPrimaryOverlayWindowLayer: () => {},
|
|
||||||
enforceOverlayLayerOrder: () => {},
|
|
||||||
syncOverlayShortcuts: () => {},
|
|
||||||
isMacOSPlatform: () => false,
|
|
||||||
isWindowsPlatform: () => false,
|
|
||||||
showOverlayLoadingOsd: () => {},
|
|
||||||
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
|
||||||
},
|
|
||||||
overlayWindows: {
|
|
||||||
createOverlayWindowCore: () => createdWindow,
|
|
||||||
isDev: false,
|
|
||||||
ensureOverlayWindowLevel: () => {},
|
|
||||||
onRuntimeOptionsChanged: () => {},
|
|
||||||
setOverlayDebugVisualizationEnabled: () => {},
|
|
||||||
isOverlayVisible: () => visibleOverlayVisible,
|
|
||||||
getYomitanSession: () => null,
|
|
||||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
|
||||||
forwardTabToMpv: () => {},
|
|
||||||
onWindowClosed: () => {},
|
|
||||||
},
|
|
||||||
visibilityActions: {
|
|
||||||
setVisibleOverlayVisibleCore: ({ visible, setVisibleOverlayVisibleState }) => {
|
|
||||||
calls.push(`setVisible:${visible}`);
|
|
||||||
setVisibleOverlayVisibleState(visible);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overlayActions: {
|
|
||||||
getRuntimeOptionsManager: () => null,
|
|
||||||
getMpvClient: () => null,
|
|
||||||
broadcastRuntimeOptionsChangedRuntime: () => {},
|
|
||||||
broadcastToOverlayWindows: () => {},
|
|
||||||
setOverlayDebugVisualizationEnabledRuntime: () => {},
|
|
||||||
},
|
|
||||||
tray: null,
|
|
||||||
bootstrap: {
|
|
||||||
initializeOverlayRuntimeMainDeps: {
|
|
||||||
appState: {
|
|
||||||
backendOverride: null,
|
|
||||||
windowTracker: null,
|
|
||||||
subtitleTimingTracker: null,
|
|
||||||
mpvClient: null,
|
|
||||||
mpvSocketPath: '/tmp/mpv.sock',
|
|
||||||
runtimeOptionsManager: null,
|
|
||||||
ankiIntegration: null,
|
|
||||||
},
|
|
||||||
overlayManager: {
|
|
||||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
|
||||||
},
|
|
||||||
overlayVisibilityRuntime: {
|
|
||||||
updateVisibleOverlayVisibility: () => {},
|
|
||||||
},
|
|
||||||
overlayShortcutsRuntime: {
|
|
||||||
syncOverlayShortcuts: () => {},
|
|
||||||
},
|
|
||||||
createMainWindow: () => {
|
|
||||||
calls.push('bootstrapCreateMainWindow');
|
|
||||||
},
|
|
||||||
registerGlobalShortcuts: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {},
|
|
||||||
getOverlayWindows: () => [],
|
|
||||||
getResolvedConfig: () => ({ ankiConnect: {} }) as never,
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
|
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
|
||||||
shouldStartAnkiIntegration: () => false,
|
|
||||||
},
|
|
||||||
initializeOverlayRuntimeBootstrapDeps: {
|
|
||||||
isOverlayRuntimeInitialized: () => true,
|
|
||||||
initializeOverlayRuntimeCore: () => {},
|
|
||||||
setOverlayRuntimeInitialized: () => {},
|
|
||||||
startBackgroundWarmups: () => {},
|
|
||||||
},
|
|
||||||
onInitialized: () => {},
|
|
||||||
},
|
|
||||||
runtimeState: {
|
|
||||||
isOverlayRuntimeInitialized: () => true,
|
|
||||||
setOverlayRuntimeInitialized: () => {},
|
|
||||||
},
|
|
||||||
mpvSubtitle: {
|
|
||||||
ensureOverlayMpvSubtitlesHidden: async () => {
|
|
||||||
calls.push('hideMpvSubs');
|
|
||||||
},
|
|
||||||
syncOverlayMpvSubtitleSuppression: () => {
|
|
||||||
calls.push('syncMpvSubs');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
overlayUi.toggleVisibleOverlay();
|
|
||||||
|
|
||||||
assert.equal(mainWindow, createdWindow);
|
|
||||||
assert.deepEqual(calls, ['hideMpvSubs', 'setVisible:true', 'syncMpvSubs']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('overlay ui runtime initializes overlay runtime before visible action when needed', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let visibleOverlayVisible = false;
|
|
||||||
let overlayRuntimeInitialized = false;
|
|
||||||
|
|
||||||
const overlayUi = createOverlayUiRuntime({
|
|
||||||
windowState: {
|
|
||||||
getMainWindow: () => null,
|
|
||||||
setMainWindow: () => {},
|
|
||||||
getModalWindow: () => null,
|
|
||||||
setModalWindow: () => {},
|
|
||||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
|
||||||
setVisibleOverlayVisible: (visible) => {
|
|
||||||
visibleOverlayVisible = visible;
|
|
||||||
},
|
|
||||||
getOverlayDebugVisualizationEnabled: () => false,
|
|
||||||
setOverlayDebugVisualizationEnabled: () => {},
|
|
||||||
},
|
|
||||||
geometry: {
|
|
||||||
getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
|
||||||
},
|
|
||||||
modal: {
|
|
||||||
onModalStateChange: () => {},
|
|
||||||
},
|
|
||||||
modalRuntime: {
|
|
||||||
handleOverlayModalClosed: () => {},
|
|
||||||
notifyOverlayModalOpened: () => {},
|
|
||||||
waitForModalOpen: async () => false,
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () => new Set<OverlayHostedModal>(),
|
|
||||||
openRuntimeOptionsPalette: () => {},
|
|
||||||
sendToActiveOverlayWindow: () => false,
|
|
||||||
},
|
|
||||||
visibilityService: {
|
|
||||||
getModalActive: () => false,
|
|
||||||
getForceMousePassthrough: () => false,
|
|
||||||
getWindowTracker: () => null,
|
|
||||||
getTrackerNotReadyWarningShown: () => false,
|
|
||||||
setTrackerNotReadyWarningShown: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {},
|
|
||||||
ensureOverlayWindowLevel: () => {},
|
|
||||||
syncPrimaryOverlayWindowLayer: () => {},
|
|
||||||
enforceOverlayLayerOrder: () => {},
|
|
||||||
syncOverlayShortcuts: () => {},
|
|
||||||
isMacOSPlatform: () => false,
|
|
||||||
isWindowsPlatform: () => false,
|
|
||||||
showOverlayLoadingOsd: () => {},
|
|
||||||
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
|
||||||
},
|
|
||||||
overlayWindows: {
|
|
||||||
createOverlayWindowCore: () => createWindow(),
|
|
||||||
isDev: false,
|
|
||||||
ensureOverlayWindowLevel: () => {},
|
|
||||||
onRuntimeOptionsChanged: () => {},
|
|
||||||
setOverlayDebugVisualizationEnabled: () => {},
|
|
||||||
isOverlayVisible: () => visibleOverlayVisible,
|
|
||||||
getYomitanSession: () => null,
|
|
||||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
|
||||||
forwardTabToMpv: () => {},
|
|
||||||
onWindowClosed: () => {},
|
|
||||||
},
|
|
||||||
visibilityActions: {
|
|
||||||
setVisibleOverlayVisibleCore: ({ visible, setVisibleOverlayVisibleState }) => {
|
|
||||||
calls.push(`setVisible:${visible}`);
|
|
||||||
setVisibleOverlayVisibleState(visible);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overlayActions: {
|
|
||||||
getRuntimeOptionsManager: () => null,
|
|
||||||
getMpvClient: () => null,
|
|
||||||
broadcastRuntimeOptionsChangedRuntime: () => {},
|
|
||||||
broadcastToOverlayWindows: () => {},
|
|
||||||
setOverlayDebugVisualizationEnabledRuntime: () => {},
|
|
||||||
},
|
|
||||||
tray: null,
|
|
||||||
bootstrap: {
|
|
||||||
initializeOverlayRuntimeMainDeps: {
|
|
||||||
appState: {
|
|
||||||
backendOverride: null,
|
|
||||||
windowTracker: null,
|
|
||||||
subtitleTimingTracker: null,
|
|
||||||
mpvClient: null,
|
|
||||||
mpvSocketPath: '/tmp/mpv.sock',
|
|
||||||
runtimeOptionsManager: null,
|
|
||||||
ankiIntegration: null,
|
|
||||||
},
|
|
||||||
overlayManager: {
|
|
||||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
|
||||||
},
|
|
||||||
overlayVisibilityRuntime: {
|
|
||||||
updateVisibleOverlayVisibility: () => {},
|
|
||||||
},
|
|
||||||
overlayShortcutsRuntime: {
|
|
||||||
syncOverlayShortcuts: () => {},
|
|
||||||
},
|
|
||||||
createMainWindow: () => {},
|
|
||||||
registerGlobalShortcuts: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {},
|
|
||||||
getOverlayWindows: () => [],
|
|
||||||
getResolvedConfig: () => ({ ankiConnect: {} }) as never,
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
|
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
|
||||||
shouldStartAnkiIntegration: () => false,
|
|
||||||
},
|
|
||||||
initializeOverlayRuntimeBootstrapDeps: {
|
|
||||||
isOverlayRuntimeInitialized: () => overlayRuntimeInitialized,
|
|
||||||
initializeOverlayRuntimeCore: () => {
|
|
||||||
calls.push('initializeOverlayRuntimeCore');
|
|
||||||
},
|
|
||||||
setOverlayRuntimeInitialized: (initialized) => {
|
|
||||||
overlayRuntimeInitialized = initialized;
|
|
||||||
calls.push(`setInitialized:${initialized}`);
|
|
||||||
},
|
|
||||||
startBackgroundWarmups: () => {
|
|
||||||
calls.push('startBackgroundWarmups');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onInitialized: () => {
|
|
||||||
calls.push('onInitialized');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
runtimeState: {
|
|
||||||
isOverlayRuntimeInitialized: () => overlayRuntimeInitialized,
|
|
||||||
setOverlayRuntimeInitialized: (initialized) => {
|
|
||||||
overlayRuntimeInitialized = initialized;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mpvSubtitle: {
|
|
||||||
ensureOverlayMpvSubtitlesHidden: async () => {
|
|
||||||
calls.push('hideMpvSubs');
|
|
||||||
},
|
|
||||||
syncOverlayMpvSubtitleSuppression: () => {
|
|
||||||
calls.push('syncMpvSubs');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
overlayUi.setVisibleOverlayVisible(true);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'setInitialized:true',
|
|
||||||
'initializeOverlayRuntimeCore',
|
|
||||||
'startBackgroundWarmups',
|
|
||||||
'onInitialized',
|
|
||||||
'syncMpvSubs',
|
|
||||||
'hideMpvSubs',
|
|
||||||
'setVisible:true',
|
|
||||||
'syncMpvSubs',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('overlay ui runtime delegates modal actions to injected modal runtime', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const restoreOnClose = new Set<OverlayHostedModal>();
|
|
||||||
|
|
||||||
const overlayUi = createOverlayUiRuntime({
|
|
||||||
windowState: {
|
|
||||||
getMainWindow: () => null,
|
|
||||||
setMainWindow: () => {},
|
|
||||||
getModalWindow: () => null,
|
|
||||||
setModalWindow: () => {},
|
|
||||||
getVisibleOverlayVisible: () => false,
|
|
||||||
setVisibleOverlayVisible: () => {},
|
|
||||||
getOverlayDebugVisualizationEnabled: () => false,
|
|
||||||
setOverlayDebugVisualizationEnabled: () => {},
|
|
||||||
},
|
|
||||||
geometry: {
|
|
||||||
getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
|
||||||
},
|
|
||||||
modal: {
|
|
||||||
onModalStateChange: () => {},
|
|
||||||
},
|
|
||||||
visibilityService: {
|
|
||||||
getModalActive: () => false,
|
|
||||||
getForceMousePassthrough: () => false,
|
|
||||||
getWindowTracker: () => null,
|
|
||||||
getTrackerNotReadyWarningShown: () => false,
|
|
||||||
setTrackerNotReadyWarningShown: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {},
|
|
||||||
ensureOverlayWindowLevel: () => {},
|
|
||||||
syncPrimaryOverlayWindowLayer: () => {},
|
|
||||||
enforceOverlayLayerOrder: () => {},
|
|
||||||
syncOverlayShortcuts: () => {},
|
|
||||||
isMacOSPlatform: () => false,
|
|
||||||
isWindowsPlatform: () => false,
|
|
||||||
showOverlayLoadingOsd: () => {},
|
|
||||||
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
|
||||||
},
|
|
||||||
overlayWindows: {
|
|
||||||
createOverlayWindowCore: () => createWindow(),
|
|
||||||
isDev: false,
|
|
||||||
ensureOverlayWindowLevel: () => {},
|
|
||||||
onRuntimeOptionsChanged: () => {},
|
|
||||||
setOverlayDebugVisualizationEnabled: () => {},
|
|
||||||
isOverlayVisible: () => false,
|
|
||||||
getYomitanSession: () => null,
|
|
||||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
|
||||||
forwardTabToMpv: () => {},
|
|
||||||
onWindowClosed: () => {},
|
|
||||||
},
|
|
||||||
visibilityActions: {
|
|
||||||
setVisibleOverlayVisibleCore: ({ visible, setVisibleOverlayVisibleState }) => {
|
|
||||||
setVisibleOverlayVisibleState(visible);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overlayActions: {
|
|
||||||
getRuntimeOptionsManager: () => null,
|
|
||||||
getMpvClient: () => null,
|
|
||||||
broadcastRuntimeOptionsChangedRuntime: () => {},
|
|
||||||
broadcastToOverlayWindows: () => {},
|
|
||||||
setOverlayDebugVisualizationEnabledRuntime: () => {},
|
|
||||||
},
|
|
||||||
modalRuntime: {
|
|
||||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => {
|
|
||||||
calls.push(`send:${channel}:${String(payload)}`);
|
|
||||||
if (runtimeOptions?.restoreOnModalClose) {
|
|
||||||
restoreOnClose.add(runtimeOptions.restoreOnModalClose);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
openRuntimeOptionsPalette: () => {
|
|
||||||
calls.push('openRuntimeOptionsPalette');
|
|
||||||
},
|
|
||||||
handleOverlayModalClosed: (modal) => {
|
|
||||||
calls.push(`closed:${modal}`);
|
|
||||||
},
|
|
||||||
notifyOverlayModalOpened: (modal) => {
|
|
||||||
calls.push(`opened:${modal}`);
|
|
||||||
},
|
|
||||||
waitForModalOpen: async (modal, timeoutMs) => {
|
|
||||||
calls.push(`wait:${modal}:${timeoutMs}`);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () => restoreOnClose,
|
|
||||||
},
|
|
||||||
tray: null,
|
|
||||||
bootstrap: {
|
|
||||||
initializeOverlayRuntimeMainDeps: {
|
|
||||||
appState: {
|
|
||||||
backendOverride: null,
|
|
||||||
windowTracker: null,
|
|
||||||
subtitleTimingTracker: null,
|
|
||||||
mpvClient: null,
|
|
||||||
mpvSocketPath: '/tmp/mpv.sock',
|
|
||||||
runtimeOptionsManager: null,
|
|
||||||
ankiIntegration: null,
|
|
||||||
},
|
|
||||||
overlayManager: {
|
|
||||||
getVisibleOverlayVisible: () => false,
|
|
||||||
},
|
|
||||||
overlayVisibilityRuntime: {
|
|
||||||
updateVisibleOverlayVisibility: () => {},
|
|
||||||
},
|
|
||||||
overlayShortcutsRuntime: {
|
|
||||||
syncOverlayShortcuts: () => {},
|
|
||||||
},
|
|
||||||
createMainWindow: () => {},
|
|
||||||
registerGlobalShortcuts: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {},
|
|
||||||
getOverlayWindows: () => [],
|
|
||||||
getResolvedConfig: () => ({ ankiConnect: {} }) as never,
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
|
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
|
||||||
shouldStartAnkiIntegration: () => false,
|
|
||||||
},
|
|
||||||
initializeOverlayRuntimeBootstrapDeps: {
|
|
||||||
isOverlayRuntimeInitialized: () => true,
|
|
||||||
initializeOverlayRuntimeCore: () => {},
|
|
||||||
setOverlayRuntimeInitialized: () => {},
|
|
||||||
startBackgroundWarmups: () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
runtimeState: {
|
|
||||||
isOverlayRuntimeInitialized: () => true,
|
|
||||||
setOverlayRuntimeInitialized: () => {},
|
|
||||||
},
|
|
||||||
mpvSubtitle: {
|
|
||||||
ensureOverlayMpvSubtitlesHidden: async () => {},
|
|
||||||
syncOverlayMpvSubtitleSuppression: () => {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
overlayUi.sendToActiveOverlayWindow('jimaku:open', 'payload', {
|
|
||||||
restoreOnModalClose: 'jimaku',
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
overlayUi.openRuntimeOptionsPalette();
|
|
||||||
overlayUi.notifyOverlayModalOpened('runtime-options');
|
|
||||||
overlayUi.handleOverlayModalClosed('runtime-options');
|
|
||||||
assert.equal(await overlayUi.waitForModalOpen('youtube-track-picker', 50), true);
|
|
||||||
assert.equal(overlayUi.getRestoreVisibleOverlayOnModalClose(), restoreOnClose);
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'send:jimaku:open:payload',
|
|
||||||
'openRuntimeOptionsPalette',
|
|
||||||
'opened:runtime-options',
|
|
||||||
'closed:runtime-options',
|
|
||||||
'wait:youtube-track-picker:50',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
import type { Session } from 'electron';
|
|
||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
|
||||||
import type { RuntimeOptionState, WindowGeometry } from '../types';
|
|
||||||
import type { OverlayModalRuntime } from './overlay-runtime';
|
|
||||||
import {
|
|
||||||
normalizeOverlayUiRuntimeInput,
|
|
||||||
type OverlayUiRuntimeInputLike,
|
|
||||||
} from './overlay-ui-runtime-input';
|
|
||||||
import {
|
|
||||||
createOverlayVisibilityRuntimeBridge,
|
|
||||||
type OverlayUiVisibilityBridgeWindowLike,
|
|
||||||
} from './overlay-ui-visibility';
|
|
||||||
import { createOverlayVisibilityRuntime } from './runtime/overlay-visibility-runtime';
|
|
||||||
import { createOverlayWindowRuntimeHandlers } from './runtime/overlay-window-runtime-handlers';
|
|
||||||
import { createTrayRuntimeHandlers } from './runtime/tray-runtime-handlers';
|
|
||||||
import { createOverlayRuntimeBootstrapHandlers } from './runtime/overlay-runtime-bootstrap-handlers';
|
|
||||||
import { composeOverlayVisibilityRuntime } from './runtime/composers/overlay-visibility-runtime-composer';
|
|
||||||
import {
|
|
||||||
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler,
|
|
||||||
createBuildGetRuntimeOptionsStateMainDepsHandler,
|
|
||||||
createBuildOpenRuntimeOptionsPaletteMainDepsHandler,
|
|
||||||
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler,
|
|
||||||
createBuildSendToActiveOverlayWindowMainDepsHandler,
|
|
||||||
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler,
|
|
||||||
} from './runtime/overlay-runtime-main-actions-main-deps';
|
|
||||||
import { createGetRuntimeOptionsStateHandler } from './runtime/overlay-runtime-main-actions';
|
|
||||||
|
|
||||||
type OverlayWindowKind = 'visible' | 'modal';
|
|
||||||
|
|
||||||
type WindowLike = OverlayUiVisibilityBridgeWindowLike;
|
|
||||||
|
|
||||||
type RuntimeOptionsManagerLike = {
|
|
||||||
listOptions: () => RuntimeOptionState[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type MpvClientLike = {
|
|
||||||
connected: boolean;
|
|
||||||
restorePreviousSecondarySubVisibility: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TrayHandlersDeps = Parameters<typeof createTrayRuntimeHandlers>[0];
|
|
||||||
type BootstrapHandlersDeps = Parameters<typeof createOverlayRuntimeBootstrapHandlers>[0];
|
|
||||||
|
|
||||||
type OverlayWindowCreateOptions<TWindow extends WindowLike> = {
|
|
||||||
isDev: boolean;
|
|
||||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
|
||||||
onRuntimeOptionsChanged: () => void;
|
|
||||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
|
||||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
|
||||||
forwardTabToMpv: () => void;
|
|
||||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
|
||||||
yomitanSession?: Electron.Session | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface OverlayUiWindowState<TWindow extends WindowLike = WindowLike> {
|
|
||||||
getMainWindow: () => TWindow | null;
|
|
||||||
setMainWindow: (window: TWindow | null) => void;
|
|
||||||
getModalWindow: () => TWindow | null;
|
|
||||||
setModalWindow: (window: TWindow | null) => void;
|
|
||||||
getVisibleOverlayVisible: () => boolean;
|
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
|
||||||
getOverlayDebugVisualizationEnabled: () => boolean;
|
|
||||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiGeometryInput {
|
|
||||||
getCurrentOverlayGeometry: () => WindowGeometry;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiModalInput {
|
|
||||||
setModalWindowBounds?: (geometry: WindowGeometry) => void;
|
|
||||||
onModalStateChange?: (active: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiVisibilityServiceInput<TWindow extends WindowLike = WindowLike> {
|
|
||||||
getModalActive: () => boolean;
|
|
||||||
getForceMousePassthrough: () => boolean;
|
|
||||||
getWindowTracker: () => unknown;
|
|
||||||
getTrackerNotReadyWarningShown: () => boolean;
|
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
|
||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
|
||||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
|
||||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
|
||||||
enforceOverlayLayerOrder: () => void;
|
|
||||||
syncOverlayShortcuts: () => void;
|
|
||||||
isMacOSPlatform: () => boolean;
|
|
||||||
isWindowsPlatform: () => boolean;
|
|
||||||
showOverlayLoadingOsd: (message: string) => void;
|
|
||||||
resolveFallbackBounds: () => WindowGeometry;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiWindowsInput<TWindow extends WindowLike = WindowLike> {
|
|
||||||
createOverlayWindowCore: (
|
|
||||||
kind: OverlayWindowKind,
|
|
||||||
options: OverlayWindowCreateOptions<TWindow>,
|
|
||||||
) => TWindow;
|
|
||||||
isDev: boolean;
|
|
||||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
|
||||||
onRuntimeOptionsChanged: () => void;
|
|
||||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
|
||||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
|
||||||
getYomitanSession: () => Session | null;
|
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
|
||||||
forwardTabToMpv: () => void;
|
|
||||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiVisibilityActionsInput {
|
|
||||||
setVisibleOverlayVisibleCore: (options: {
|
|
||||||
visible: boolean;
|
|
||||||
setVisibleOverlayVisibleState: (visible: boolean) => void;
|
|
||||||
updateVisibleOverlayVisibility: () => void;
|
|
||||||
}) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiActionsInput {
|
|
||||||
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
|
|
||||||
getMpvClient: () => MpvClientLike | null;
|
|
||||||
|
|
||||||
broadcastRuntimeOptionsChangedRuntime: (
|
|
||||||
getRuntimeOptionsState: () => RuntimeOptionState[],
|
|
||||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
|
|
||||||
) => void;
|
|
||||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
|
|
||||||
setOverlayDebugVisualizationEnabledRuntime: (
|
|
||||||
currentEnabled: boolean,
|
|
||||||
nextEnabled: boolean,
|
|
||||||
setCurrentEnabled: (enabled: boolean) => void,
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiTrayInput {
|
|
||||||
resolveTrayIconPathDeps: TrayHandlersDeps['resolveTrayIconPathDeps'];
|
|
||||||
buildTrayMenuTemplateDeps: TrayHandlersDeps['buildTrayMenuTemplateDeps'];
|
|
||||||
ensureTrayDeps: TrayHandlersDeps['ensureTrayDeps'];
|
|
||||||
destroyTrayDeps: TrayHandlersDeps['destroyTrayDeps'];
|
|
||||||
buildMenuFromTemplate: TrayHandlersDeps['buildMenuFromTemplate'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiBootstrapInput {
|
|
||||||
initializeOverlayRuntimeMainDeps: BootstrapHandlersDeps['initializeOverlayRuntimeMainDeps'];
|
|
||||||
initializeOverlayRuntimeBootstrapDeps: BootstrapHandlersDeps['initializeOverlayRuntimeBootstrapDeps'];
|
|
||||||
onInitialized?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiRuntimeStateInput {
|
|
||||||
isOverlayRuntimeInitialized: () => boolean;
|
|
||||||
setOverlayRuntimeInitialized: (initialized: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiMpvSubtitleInput {
|
|
||||||
ensureOverlayMpvSubtitlesHidden: () => Promise<void> | void;
|
|
||||||
syncOverlayMpvSubtitleSuppression: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiRuntimeInput<TWindow extends WindowLike = WindowLike> {
|
|
||||||
windowState: OverlayUiWindowState<TWindow>;
|
|
||||||
geometry: OverlayUiGeometryInput;
|
|
||||||
modal: OverlayUiModalInput;
|
|
||||||
modalRuntime: OverlayModalRuntime;
|
|
||||||
visibilityService: OverlayUiVisibilityServiceInput<TWindow>;
|
|
||||||
overlayWindows: OverlayUiWindowsInput<TWindow>;
|
|
||||||
visibilityActions: OverlayUiVisibilityActionsInput;
|
|
||||||
overlayActions: OverlayUiActionsInput;
|
|
||||||
tray: OverlayUiTrayInput | null;
|
|
||||||
bootstrap: OverlayUiBootstrapInput;
|
|
||||||
runtimeState: OverlayUiRuntimeStateInput;
|
|
||||||
mpvSubtitle: OverlayUiMpvSubtitleInput;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayUiRuntime<TWindow extends WindowLike = WindowLike> {
|
|
||||||
createMainWindow: () => TWindow;
|
|
||||||
createModalWindow: () => TWindow;
|
|
||||||
ensureTray: () => void;
|
|
||||||
destroyTray: () => void;
|
|
||||||
initializeOverlayRuntime: () => void;
|
|
||||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
|
||||||
toggleVisibleOverlay: () => void;
|
|
||||||
setOverlayVisible: (visible: boolean) => void;
|
|
||||||
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
|
||||||
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
|
|
||||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
|
||||||
updateVisibleOverlayVisibility: () => void;
|
|
||||||
sendToActiveOverlayWindow: (
|
|
||||||
channel: string,
|
|
||||||
payload?: unknown,
|
|
||||||
runtimeOptions?: {
|
|
||||||
restoreOnModalClose?: OverlayHostedModal;
|
|
||||||
preferModalWindow?: boolean;
|
|
||||||
},
|
|
||||||
) => boolean;
|
|
||||||
openRuntimeOptionsPalette: () => void;
|
|
||||||
broadcastRuntimeOptionsChanged: () => void;
|
|
||||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
|
||||||
restorePreviousSecondarySubVisibility: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createOverlayUiRuntime<TWindow extends WindowLike>(
|
|
||||||
input: OverlayUiRuntimeInputLike<TWindow>,
|
|
||||||
): OverlayUiRuntime<TWindow> {
|
|
||||||
const runtimeInput = normalizeOverlayUiRuntimeInput(input);
|
|
||||||
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeBridge({
|
|
||||||
getMainWindow: () => runtimeInput.windowState.getMainWindow(),
|
|
||||||
getVisibleOverlayVisible: () => runtimeInput.windowState.getVisibleOverlayVisible(),
|
|
||||||
getModalActive: () => runtimeInput.visibilityService.getModalActive(),
|
|
||||||
getForceMousePassthrough: () => runtimeInput.visibilityService.getForceMousePassthrough(),
|
|
||||||
getWindowTracker: () => runtimeInput.visibilityService.getWindowTracker(),
|
|
||||||
getTrackerNotReadyWarningShown: () =>
|
|
||||||
runtimeInput.visibilityService.getTrackerNotReadyWarningShown(),
|
|
||||||
setTrackerNotReadyWarningShown: (shown) =>
|
|
||||||
runtimeInput.visibilityService.setTrackerNotReadyWarningShown(shown),
|
|
||||||
updateVisibleOverlayBounds: (geometry) =>
|
|
||||||
runtimeInput.visibilityService.updateVisibleOverlayBounds(geometry),
|
|
||||||
ensureOverlayWindowLevel: (window) =>
|
|
||||||
runtimeInput.visibilityService.ensureOverlayWindowLevel(window),
|
|
||||||
syncPrimaryOverlayWindowLayer: (layer) =>
|
|
||||||
runtimeInput.visibilityService.syncPrimaryOverlayWindowLayer(layer),
|
|
||||||
enforceOverlayLayerOrder: () => runtimeInput.visibilityService.enforceOverlayLayerOrder(),
|
|
||||||
syncOverlayShortcuts: () => runtimeInput.visibilityService.syncOverlayShortcuts(),
|
|
||||||
isMacOSPlatform: () => runtimeInput.visibilityService.isMacOSPlatform(),
|
|
||||||
isWindowsPlatform: () => runtimeInput.visibilityService.isWindowsPlatform(),
|
|
||||||
showOverlayLoadingOsd: (message) =>
|
|
||||||
runtimeInput.visibilityService.showOverlayLoadingOsd(message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const overlayWindowHandlers = createOverlayWindowRuntimeHandlers<TWindow>({
|
|
||||||
createOverlayWindowDeps: {
|
|
||||||
createOverlayWindowCore: (kind, options) =>
|
|
||||||
runtimeInput.overlayWindows.createOverlayWindowCore(kind, options),
|
|
||||||
isDev: runtimeInput.overlayWindows.isDev,
|
|
||||||
ensureOverlayWindowLevel: (window) =>
|
|
||||||
runtimeInput.overlayWindows.ensureOverlayWindowLevel(window),
|
|
||||||
onRuntimeOptionsChanged: () => runtimeInput.overlayWindows.onRuntimeOptionsChanged(),
|
|
||||||
setOverlayDebugVisualizationEnabled: (enabled) =>
|
|
||||||
runtimeInput.overlayWindows.setOverlayDebugVisualizationEnabled(enabled),
|
|
||||||
isOverlayVisible: (windowKind) => runtimeInput.overlayWindows.isOverlayVisible(windowKind),
|
|
||||||
getYomitanSession: () => runtimeInput.overlayWindows.getYomitanSession(),
|
|
||||||
tryHandleOverlayShortcutLocalFallback: (overlayInput) =>
|
|
||||||
runtimeInput.overlayWindows.tryHandleOverlayShortcutLocalFallback(overlayInput),
|
|
||||||
forwardTabToMpv: () => runtimeInput.overlayWindows.forwardTabToMpv(),
|
|
||||||
onWindowClosed: (windowKind) => runtimeInput.overlayWindows.onWindowClosed(windowKind),
|
|
||||||
},
|
|
||||||
setMainWindow: (window) => runtimeInput.windowState.setMainWindow(window),
|
|
||||||
setModalWindow: (window) => runtimeInput.windowState.setModalWindow(window),
|
|
||||||
});
|
|
||||||
|
|
||||||
const visibilityActions = createOverlayVisibilityRuntime({
|
|
||||||
setVisibleOverlayVisibleDeps: {
|
|
||||||
setVisibleOverlayVisibleCore: (options) =>
|
|
||||||
runtimeInput.visibilityActions.setVisibleOverlayVisibleCore(options),
|
|
||||||
setVisibleOverlayVisibleState: (visible) =>
|
|
||||||
runtimeInput.windowState.setVisibleOverlayVisible(visible),
|
|
||||||
updateVisibleOverlayVisibility: () =>
|
|
||||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
|
||||||
},
|
|
||||||
getVisibleOverlayVisible: () => runtimeInput.windowState.getVisibleOverlayVisible(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const getRuntimeOptionsState = createGetRuntimeOptionsStateHandler(
|
|
||||||
createBuildGetRuntimeOptionsStateMainDepsHandler({
|
|
||||||
getRuntimeOptionsManager: () => runtimeInput.overlayActions.getRuntimeOptionsManager(),
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const overlayActions = composeOverlayVisibilityRuntime({
|
|
||||||
overlayVisibilityRuntime,
|
|
||||||
restorePreviousSecondarySubVisibilityMainDeps:
|
|
||||||
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler({
|
|
||||||
getMpvClient: () => runtimeInput.overlayActions.getMpvClient(),
|
|
||||||
})(),
|
|
||||||
broadcastRuntimeOptionsChangedMainDeps:
|
|
||||||
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({
|
|
||||||
broadcastRuntimeOptionsChangedRuntime: (getState, broadcast) =>
|
|
||||||
runtimeInput.overlayActions.broadcastRuntimeOptionsChangedRuntime(getState, broadcast),
|
|
||||||
getRuntimeOptionsState: () => getRuntimeOptionsState(),
|
|
||||||
broadcastToOverlayWindows: (channel, ...args) =>
|
|
||||||
runtimeInput.overlayActions.broadcastToOverlayWindows(channel, ...args),
|
|
||||||
})(),
|
|
||||||
sendToActiveOverlayWindowMainDeps: createBuildSendToActiveOverlayWindowMainDepsHandler({
|
|
||||||
sendToActiveOverlayWindowRuntime: (channel, payload, runtimeOptions) =>
|
|
||||||
runtimeInput.modalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
|
||||||
})(),
|
|
||||||
setOverlayDebugVisualizationEnabledMainDeps:
|
|
||||||
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler({
|
|
||||||
setOverlayDebugVisualizationEnabledRuntime: (currentEnabled, nextEnabled, setCurrent) =>
|
|
||||||
runtimeInput.overlayActions.setOverlayDebugVisualizationEnabledRuntime(
|
|
||||||
currentEnabled,
|
|
||||||
nextEnabled,
|
|
||||||
setCurrent,
|
|
||||||
),
|
|
||||||
getCurrentEnabled: () => runtimeInput.windowState.getOverlayDebugVisualizationEnabled(),
|
|
||||||
setCurrentEnabled: (enabled) =>
|
|
||||||
runtimeInput.windowState.setOverlayDebugVisualizationEnabled(enabled),
|
|
||||||
})(),
|
|
||||||
openRuntimeOptionsPaletteMainDeps: createBuildOpenRuntimeOptionsPaletteMainDepsHandler({
|
|
||||||
openRuntimeOptionsPaletteRuntime: () => runtimeInput.modalRuntime.openRuntimeOptionsPalette(),
|
|
||||||
})(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const trayHandlers = runtimeInput.tray
|
|
||||||
? createTrayRuntimeHandlers({
|
|
||||||
resolveTrayIconPathDeps: runtimeInput.tray.resolveTrayIconPathDeps,
|
|
||||||
buildTrayMenuTemplateDeps: runtimeInput.tray.buildTrayMenuTemplateDeps,
|
|
||||||
ensureTrayDeps: runtimeInput.tray.ensureTrayDeps,
|
|
||||||
destroyTrayDeps: runtimeInput.tray.destroyTrayDeps,
|
|
||||||
buildMenuFromTemplate: (template) => runtimeInput.tray!.buildMenuFromTemplate(template),
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
|
||||||
createOverlayRuntimeBootstrapHandlers({
|
|
||||||
initializeOverlayRuntimeMainDeps: runtimeInput.bootstrap.initializeOverlayRuntimeMainDeps,
|
|
||||||
initializeOverlayRuntimeBootstrapDeps:
|
|
||||||
runtimeInput.bootstrap.initializeOverlayRuntimeBootstrapDeps,
|
|
||||||
});
|
|
||||||
|
|
||||||
function createMainWindow(): TWindow {
|
|
||||||
return overlayWindowHandlers.createMainWindow();
|
|
||||||
}
|
|
||||||
|
|
||||||
function createModalWindow(): TWindow {
|
|
||||||
const existingWindow = runtimeInput.windowState.getModalWindow();
|
|
||||||
if (existingWindow && !existingWindow.isDestroyed()) {
|
|
||||||
return existingWindow;
|
|
||||||
}
|
|
||||||
const window = overlayWindowHandlers.createModalWindow();
|
|
||||||
runtimeInput.modal.setModalWindowBounds?.(runtimeInput.geometry.getCurrentOverlayGeometry());
|
|
||||||
return window;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initializeOverlayRuntime(): void {
|
|
||||||
initializeOverlayRuntimeHandler();
|
|
||||||
runtimeInput.bootstrap.onInitialized?.();
|
|
||||||
runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression();
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureOverlayWindowsReadyForVisibilityActions(): void {
|
|
||||||
if (!runtimeInput.runtimeState.isOverlayRuntimeInitialized()) {
|
|
||||||
initializeOverlayRuntime();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainWindow = runtimeInput.windowState.getMainWindow();
|
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
||||||
createMainWindow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setVisibleOverlayVisible(visible: boolean): void {
|
|
||||||
ensureOverlayWindowsReadyForVisibilityActions();
|
|
||||||
if (visible) {
|
|
||||||
void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden();
|
|
||||||
}
|
|
||||||
visibilityActions.setVisibleOverlayVisible(visible);
|
|
||||||
runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleVisibleOverlay(): void {
|
|
||||||
ensureOverlayWindowsReadyForVisibilityActions();
|
|
||||||
if (!runtimeInput.windowState.getVisibleOverlayVisible()) {
|
|
||||||
void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden();
|
|
||||||
}
|
|
||||||
visibilityActions.toggleVisibleOverlay();
|
|
||||||
runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setOverlayVisible(visible: boolean): void {
|
|
||||||
if (visible) {
|
|
||||||
void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden();
|
|
||||||
}
|
|
||||||
visibilityActions.setOverlayVisible(visible);
|
|
||||||
runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
createMainWindow,
|
|
||||||
createModalWindow,
|
|
||||||
ensureTray: () => {
|
|
||||||
trayHandlers?.ensureTray();
|
|
||||||
},
|
|
||||||
destroyTray: () => {
|
|
||||||
trayHandlers?.destroyTray();
|
|
||||||
},
|
|
||||||
initializeOverlayRuntime,
|
|
||||||
ensureOverlayWindowsReadyForVisibilityActions,
|
|
||||||
setVisibleOverlayVisible,
|
|
||||||
toggleVisibleOverlay,
|
|
||||||
setOverlayVisible,
|
|
||||||
handleOverlayModalClosed: (modal) => runtimeInput.modalRuntime.handleOverlayModalClosed(modal),
|
|
||||||
notifyOverlayModalOpened: (modal) => runtimeInput.modalRuntime.notifyOverlayModalOpened(modal),
|
|
||||||
waitForModalOpen: (modal, timeoutMs) =>
|
|
||||||
runtimeInput.modalRuntime.waitForModalOpen(modal, timeoutMs),
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () =>
|
|
||||||
runtimeInput.modalRuntime.getRestoreVisibleOverlayOnModalClose(),
|
|
||||||
updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
|
||||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
|
||||||
overlayActions.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
|
||||||
openRuntimeOptionsPalette: () => overlayActions.openRuntimeOptionsPalette(),
|
|
||||||
broadcastRuntimeOptionsChanged: () => overlayActions.broadcastRuntimeOptionsChanged(),
|
|
||||||
setOverlayDebugVisualizationEnabled: (enabled) =>
|
|
||||||
overlayActions.setOverlayDebugVisualizationEnabled(enabled),
|
|
||||||
restorePreviousSecondarySubVisibility: () =>
|
|
||||||
overlayActions.restorePreviousSecondarySubVisibility(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import type { WindowGeometry } from '../types';
|
|
||||||
|
|
||||||
export type OverlayUiVisibilityBridgeWindowLike = {
|
|
||||||
isDestroyed: () => boolean;
|
|
||||||
hide?: () => void;
|
|
||||||
show?: () => void;
|
|
||||||
focus?: () => void;
|
|
||||||
setIgnoreMouseEvents?: (ignore: boolean, options?: { forward?: boolean }) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface OverlayUiVisibilityBridgeInput<
|
|
||||||
TWindow extends OverlayUiVisibilityBridgeWindowLike = OverlayUiVisibilityBridgeWindowLike,
|
|
||||||
> {
|
|
||||||
getMainWindow: () => TWindow | null;
|
|
||||||
getVisibleOverlayVisible: () => boolean;
|
|
||||||
getModalActive: () => boolean;
|
|
||||||
getForceMousePassthrough: () => boolean;
|
|
||||||
getWindowTracker: () => unknown;
|
|
||||||
getTrackerNotReadyWarningShown: () => boolean;
|
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
|
||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
|
||||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
|
||||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
|
||||||
enforceOverlayLayerOrder: () => void;
|
|
||||||
syncOverlayShortcuts: () => void;
|
|
||||||
isMacOSPlatform: () => boolean;
|
|
||||||
isWindowsPlatform: () => boolean;
|
|
||||||
showOverlayLoadingOsd: (message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createOverlayVisibilityRuntimeBridge<
|
|
||||||
TWindow extends OverlayUiVisibilityBridgeWindowLike,
|
|
||||||
>(input: OverlayUiVisibilityBridgeInput<TWindow>) {
|
|
||||||
let lastOverlayLoadingOsdAtMs: number | null = null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
updateVisibleOverlayVisibility(): void {
|
|
||||||
const mainWindow = input.getMainWindow();
|
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.getModalActive()) {
|
|
||||||
mainWindow.hide?.();
|
|
||||||
input.syncOverlayShortcuts();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const showPassiveVisibleOverlay = (): void => {
|
|
||||||
const forceMousePassthrough = input.getForceMousePassthrough() === true;
|
|
||||||
if (input.isWindowsPlatform() || forceMousePassthrough) {
|
|
||||||
mainWindow.setIgnoreMouseEvents?.(true, { forward: true });
|
|
||||||
} else {
|
|
||||||
mainWindow.setIgnoreMouseEvents?.(false);
|
|
||||||
}
|
|
||||||
input.ensureOverlayWindowLevel(mainWindow);
|
|
||||||
mainWindow.show?.();
|
|
||||||
if (!input.isWindowsPlatform() && !input.isMacOSPlatform() && !forceMousePassthrough) {
|
|
||||||
mainWindow.focus?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const maybeShowOverlayLoadingOsd = (): void => {
|
|
||||||
if (!input.isMacOSPlatform()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (lastOverlayLoadingOsdAtMs !== null && Date.now() - lastOverlayLoadingOsdAtMs < 30_000) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
input.showOverlayLoadingOsd('Overlay loading...');
|
|
||||||
lastOverlayLoadingOsdAtMs = Date.now();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!input.getVisibleOverlayVisible()) {
|
|
||||||
input.setTrackerNotReadyWarningShown(false);
|
|
||||||
lastOverlayLoadingOsdAtMs = null;
|
|
||||||
mainWindow.hide?.();
|
|
||||||
input.syncOverlayShortcuts();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const windowTracker = input.getWindowTracker() as {
|
|
||||||
isTracking: () => boolean;
|
|
||||||
getGeometry: () => WindowGeometry | null;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
if (windowTracker && windowTracker.isTracking()) {
|
|
||||||
input.setTrackerNotReadyWarningShown(false);
|
|
||||||
const geometry = windowTracker.getGeometry();
|
|
||||||
if (geometry) {
|
|
||||||
input.updateVisibleOverlayBounds(geometry);
|
|
||||||
}
|
|
||||||
input.syncPrimaryOverlayWindowLayer('visible');
|
|
||||||
showPassiveVisibleOverlay();
|
|
||||||
input.enforceOverlayLayerOrder();
|
|
||||||
input.syncOverlayShortcuts();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!windowTracker) {
|
|
||||||
if (input.isMacOSPlatform() || input.isWindowsPlatform()) {
|
|
||||||
if (!input.getTrackerNotReadyWarningShown()) {
|
|
||||||
input.setTrackerNotReadyWarningShown(true);
|
|
||||||
maybeShowOverlayLoadingOsd();
|
|
||||||
}
|
|
||||||
mainWindow.hide?.();
|
|
||||||
input.syncOverlayShortcuts();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.setTrackerNotReadyWarningShown(false);
|
|
||||||
input.syncPrimaryOverlayWindowLayer('visible');
|
|
||||||
showPassiveVisibleOverlay();
|
|
||||||
input.enforceOverlayLayerOrder();
|
|
||||||
input.syncOverlayShortcuts();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!input.getTrackerNotReadyWarningShown()) {
|
|
||||||
input.setTrackerNotReadyWarningShown(true);
|
|
||||||
maybeShowOverlayLoadingOsd();
|
|
||||||
}
|
|
||||||
|
|
||||||
mainWindow.hide?.();
|
|
||||||
input.syncOverlayShortcuts();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import type { ResolvedConfig } from '../types';
|
|
||||||
|
|
||||||
export function getRuntimeBooleanOption(
|
|
||||||
getOptionValue: (
|
|
||||||
id:
|
|
||||||
| 'subtitle.annotation.nPlusOne'
|
|
||||||
| 'subtitle.annotation.jlpt'
|
|
||||||
| 'subtitle.annotation.frequency',
|
|
||||||
) => unknown,
|
|
||||||
id: 'subtitle.annotation.nPlusOne' | 'subtitle.annotation.jlpt' | 'subtitle.annotation.frequency',
|
|
||||||
fallback: boolean,
|
|
||||||
): boolean {
|
|
||||||
const value = getOptionValue(id);
|
|
||||||
return typeof value === 'boolean' ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldInitializeMecabForAnnotations(input: {
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
getRuntimeBooleanOption: (
|
|
||||||
id:
|
|
||||||
| 'subtitle.annotation.nPlusOne'
|
|
||||||
| 'subtitle.annotation.jlpt'
|
|
||||||
| 'subtitle.annotation.frequency',
|
|
||||||
fallback: boolean,
|
|
||||||
) => boolean;
|
|
||||||
}): boolean {
|
|
||||||
const config = input.getResolvedConfig();
|
|
||||||
const nPlusOneEnabled = input.getRuntimeBooleanOption(
|
|
||||||
'subtitle.annotation.nPlusOne',
|
|
||||||
config.ankiConnect.knownWords.highlightEnabled,
|
|
||||||
);
|
|
||||||
const jlptEnabled = input.getRuntimeBooleanOption(
|
|
||||||
'subtitle.annotation.jlpt',
|
|
||||||
config.subtitleStyle.enableJlpt,
|
|
||||||
);
|
|
||||||
const frequencyEnabled = input.getRuntimeBooleanOption(
|
|
||||||
'subtitle.annotation.frequency',
|
|
||||||
config.subtitleStyle.frequencyDictionary.enabled,
|
|
||||||
);
|
|
||||||
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
import { createDiscordPresenceService } from '../../core/services';
|
|
||||||
import type { ResolvedConfig } from '../../types';
|
|
||||||
import { createDiscordRpcClient } from './discord-rpc-client.js';
|
|
||||||
|
|
||||||
type DiscordPresenceServiceLike = {
|
type DiscordPresenceServiceLike = {
|
||||||
publish: (snapshot: {
|
publish: (snapshot: {
|
||||||
mediaTitle: string | null;
|
mediaTitle: string | null;
|
||||||
@@ -76,59 +72,3 @@ export function createDiscordPresenceRuntime(deps: DiscordPresenceRuntimeDeps) {
|
|||||||
publishDiscordPresence,
|
publishDiscordPresence,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDiscordPresenceRuntimeFromMainState(input: {
|
|
||||||
appId: string;
|
|
||||||
appState: {
|
|
||||||
discordPresenceService: ReturnType<typeof createDiscordPresenceService> | null;
|
|
||||||
mpvClient: MpvClientLike | null;
|
|
||||||
currentMediaTitle: string | null;
|
|
||||||
currentMediaPath: string | null;
|
|
||||||
currentSubText: string;
|
|
||||||
playbackPaused: boolean | null;
|
|
||||||
};
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
getFallbackMediaDurationSec: () => number | null;
|
|
||||||
logger: {
|
|
||||||
debug: (message: string, meta?: unknown) => void;
|
|
||||||
};
|
|
||||||
}) {
|
|
||||||
const sessionStartedAtMs = Date.now();
|
|
||||||
let mediaDurationSec: number | null = null;
|
|
||||||
|
|
||||||
const discordPresenceRuntime = createDiscordPresenceRuntime({
|
|
||||||
getDiscordPresenceService: () => input.appState.discordPresenceService,
|
|
||||||
isDiscordPresenceEnabled: () => input.getResolvedConfig().discordPresence.enabled === true,
|
|
||||||
getMpvClient: () => input.appState.mpvClient,
|
|
||||||
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
|
|
||||||
getCurrentMediaPath: () => input.appState.currentMediaPath,
|
|
||||||
getCurrentSubtitleText: () => input.appState.currentSubText,
|
|
||||||
getPlaybackPaused: () => input.appState.playbackPaused,
|
|
||||||
getFallbackMediaDurationSec: () => input.getFallbackMediaDurationSec(),
|
|
||||||
getSessionStartedAtMs: () => sessionStartedAtMs,
|
|
||||||
getMediaDurationSec: () => mediaDurationSec,
|
|
||||||
setMediaDurationSec: (next) => {
|
|
||||||
mediaDurationSec = next;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const initializeDiscordPresenceService = async (): Promise<void> => {
|
|
||||||
if (input.getResolvedConfig().discordPresence.enabled !== true) {
|
|
||||||
input.appState.discordPresenceService = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.appState.discordPresenceService = createDiscordPresenceService({
|
|
||||||
config: input.getResolvedConfig().discordPresence,
|
|
||||||
createClient: () => createDiscordRpcClient(input.appId),
|
|
||||||
logDebug: (message, meta) => input.logger.debug(message, meta),
|
|
||||||
});
|
|
||||||
await input.appState.discordPresenceService.start();
|
|
||||||
discordPresenceRuntime.publishDiscordPresence();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
discordPresenceRuntime,
|
|
||||||
initializeDiscordPresenceService,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -98,62 +98,10 @@ export function createRestoreOverlayMpvSubtitlesHandler(deps: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (savedVisibility !== null) {
|
||||||
deps.setMpvSubVisibility(savedVisibility);
|
deps.setMpvSubVisibility(savedVisibility);
|
||||||
|
}
|
||||||
|
|
||||||
deps.setSavedSubVisibility(null);
|
deps.setSavedSubVisibility(null);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOverlayMpvSubtitleSuppressionRuntime(deps: {
|
|
||||||
appState: {
|
|
||||||
mpvClient: MpvVisibilityClient | null;
|
|
||||||
overlaySavedMpvSubVisibility: boolean | null;
|
|
||||||
overlayMpvSubVisibilityRevision: number;
|
|
||||||
};
|
|
||||||
getVisibleOverlayVisible: () => boolean;
|
|
||||||
setMpvSubVisibility: (visible: boolean) => void;
|
|
||||||
logWarn: (message: string, error: unknown) => void;
|
|
||||||
}) {
|
|
||||||
const ensureOverlayMpvSubtitlesHidden = createEnsureOverlayMpvSubtitlesHiddenHandler({
|
|
||||||
getMpvClient: () => deps.appState.mpvClient,
|
|
||||||
getSavedSubVisibility: () => deps.appState.overlaySavedMpvSubVisibility,
|
|
||||||
setSavedSubVisibility: (visible) => {
|
|
||||||
deps.appState.overlaySavedMpvSubVisibility = visible;
|
|
||||||
},
|
|
||||||
getRevision: () => deps.appState.overlayMpvSubVisibilityRevision,
|
|
||||||
setRevision: (revision) => {
|
|
||||||
deps.appState.overlayMpvSubVisibilityRevision = revision;
|
|
||||||
},
|
|
||||||
setMpvSubVisibility: (visible) => deps.setMpvSubVisibility(visible),
|
|
||||||
logWarn: (message, error) => deps.logWarn(message, error),
|
|
||||||
});
|
|
||||||
|
|
||||||
const restoreOverlayMpvSubtitles = createRestoreOverlayMpvSubtitlesHandler({
|
|
||||||
getSavedSubVisibility: () => deps.appState.overlaySavedMpvSubVisibility,
|
|
||||||
setSavedSubVisibility: (visible) => {
|
|
||||||
deps.appState.overlaySavedMpvSubVisibility = visible;
|
|
||||||
},
|
|
||||||
getRevision: () => deps.appState.overlayMpvSubVisibilityRevision,
|
|
||||||
setRevision: (revision) => {
|
|
||||||
deps.appState.overlayMpvSubVisibilityRevision = revision;
|
|
||||||
},
|
|
||||||
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
|
|
||||||
shouldKeepSuppressedFromVisibleOverlayBinding: () => deps.getVisibleOverlayVisible(),
|
|
||||||
setMpvSubVisibility: (visible) => deps.setMpvSubVisibility(visible),
|
|
||||||
});
|
|
||||||
|
|
||||||
const syncOverlayMpvSubtitleSuppression = (): void => {
|
|
||||||
if (deps.getVisibleOverlayVisible()) {
|
|
||||||
void ensureOverlayMpvSubtitlesHidden();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreOverlayMpvSubtitles();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
ensureOverlayMpvSubtitlesHidden,
|
|
||||||
restoreOverlayMpvSubtitles,
|
|
||||||
syncOverlayMpvSubtitleSuppression,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createShortcutsRuntime } from './shortcuts-runtime';
|
|
||||||
|
|
||||||
test('shortcuts runtime bridges modal shortcut sync to unregister and sync', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const runtime = createShortcutsRuntime({
|
|
||||||
globalShortcuts: {
|
|
||||||
getConfiguredShortcutsMainDeps: {
|
|
||||||
getResolvedConfig: () => ({}) as never,
|
|
||||||
defaultConfig: {} as never,
|
|
||||||
resolveConfiguredShortcuts: () => ({}) as never,
|
|
||||||
},
|
|
||||||
buildRegisterGlobalShortcutsMainDeps: () => ({
|
|
||||||
getConfiguredShortcuts: () => ({}) as never,
|
|
||||||
registerGlobalShortcutsCore: () => {
|
|
||||||
calls.push('registerGlobalShortcutsCore');
|
|
||||||
},
|
|
||||||
toggleVisibleOverlay: () => {},
|
|
||||||
openYomitanSettings: () => {},
|
|
||||||
isDev: false,
|
|
||||||
getMainWindow: () => null,
|
|
||||||
}),
|
|
||||||
buildRefreshGlobalAndOverlayShortcutsMainDeps: () => ({
|
|
||||||
unregisterAllGlobalShortcuts: () => {
|
|
||||||
calls.push('unregisterAllGlobalShortcuts');
|
|
||||||
},
|
|
||||||
registerGlobalShortcuts: () => {
|
|
||||||
calls.push('registerGlobalShortcuts');
|
|
||||||
},
|
|
||||||
syncOverlayShortcuts: () => {
|
|
||||||
calls.push('syncOverlayShortcuts');
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
numericShortcutRuntimeMainDeps: {
|
|
||||||
globalShortcut: {
|
|
||||||
register: () => true,
|
|
||||||
unregister: () => {},
|
|
||||||
},
|
|
||||||
showMpvOsd: () => {},
|
|
||||||
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
|
|
||||||
clearTimer: (timer) => clearTimeout(timer),
|
|
||||||
},
|
|
||||||
numericSessions: {
|
|
||||||
onMultiCopyDigit: () => {},
|
|
||||||
onMineSentenceDigit: () => {},
|
|
||||||
},
|
|
||||||
overlayShortcutsRuntimeMainDeps: {
|
|
||||||
overlayShortcutsRuntime: {
|
|
||||||
registerOverlayShortcuts: () => {
|
|
||||||
calls.push('registerOverlayShortcuts');
|
|
||||||
},
|
|
||||||
unregisterOverlayShortcuts: () => {
|
|
||||||
calls.push('unregisterOverlayShortcuts');
|
|
||||||
},
|
|
||||||
syncOverlayShortcuts: () => {
|
|
||||||
calls.push('syncOverlayShortcutsRuntime');
|
|
||||||
},
|
|
||||||
refreshOverlayShortcuts: () => {
|
|
||||||
calls.push('refreshOverlayShortcuts');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(typeof runtime.getConfiguredShortcuts, 'function');
|
|
||||||
assert.equal(typeof runtime.registerGlobalShortcuts, 'function');
|
|
||||||
assert.equal(typeof runtime.syncOverlayShortcutsForModal, 'function');
|
|
||||||
|
|
||||||
runtime.syncOverlayShortcutsForModal(true);
|
|
||||||
runtime.syncOverlayShortcutsForModal(false);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['unregisterOverlayShortcuts', 'syncOverlayShortcutsRuntime']);
|
|
||||||
});
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
|
||||||
import type { ResolvedConfig } from '../types';
|
|
||||||
import type { ConfiguredShortcuts } from '../core/utils/shortcut-config';
|
|
||||||
import { DEFAULT_CONFIG } from '../config';
|
|
||||||
import { resolveConfiguredShortcuts } from '../core/utils';
|
|
||||||
import type { AppState } from './state';
|
|
||||||
import type { MiningRuntime } from './mining-runtime';
|
|
||||||
import type { OverlayModalRuntime } from './overlay-runtime';
|
|
||||||
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './runtime/domains/shortcuts';
|
|
||||||
import {
|
|
||||||
composeShortcutRuntimes,
|
|
||||||
type ShortcutsRuntimeComposerOptions,
|
|
||||||
} from './runtime/composers/shortcuts-runtime-composer';
|
|
||||||
import { createOverlayShortcutsRuntimeService } from './overlay-shortcuts-runtime';
|
|
||||||
|
|
||||||
type GlobalShortcutsInput = ShortcutsRuntimeComposerOptions['globalShortcuts'];
|
|
||||||
type NumericShortcutRuntimeMainDepsInput =
|
|
||||||
ShortcutsRuntimeComposerOptions['numericShortcutRuntimeMainDeps'];
|
|
||||||
type NumericSessionsInput = ShortcutsRuntimeComposerOptions['numericSessions'];
|
|
||||||
type OverlayShortcutsRuntimeMainDepsInput =
|
|
||||||
ShortcutsRuntimeComposerOptions['overlayShortcutsRuntimeMainDeps'];
|
|
||||||
|
|
||||||
export interface ShortcutsRuntimeInput {
|
|
||||||
globalShortcuts: GlobalShortcutsInput;
|
|
||||||
numericShortcutRuntimeMainDeps: NumericShortcutRuntimeMainDepsInput;
|
|
||||||
numericSessions: NumericSessionsInput;
|
|
||||||
overlayShortcutsRuntimeMainDeps: OverlayShortcutsRuntimeMainDepsInput;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShortcutsRuntime {
|
|
||||||
getConfiguredShortcuts: () => ConfiguredShortcuts;
|
|
||||||
registerGlobalShortcuts: () => void;
|
|
||||||
refreshGlobalAndOverlayShortcuts: () => void;
|
|
||||||
cancelPendingMultiCopy: () => void;
|
|
||||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
|
||||||
cancelPendingMineSentenceMultiple: () => void;
|
|
||||||
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
|
||||||
registerOverlayShortcuts: () => void;
|
|
||||||
unregisterOverlayShortcuts: () => void;
|
|
||||||
syncOverlayShortcuts: () => void;
|
|
||||||
refreshOverlayShortcuts: () => void;
|
|
||||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShortcutsRuntimeBootstrapInput {
|
|
||||||
globalShortcuts: ShortcutsRuntimeInput['globalShortcuts'];
|
|
||||||
numericShortcutRuntimeMainDeps: ShortcutsRuntimeInput['numericShortcutRuntimeMainDeps'];
|
|
||||||
numericSessions: ShortcutsRuntimeInput['numericSessions'];
|
|
||||||
overlayShortcuts: {
|
|
||||||
getConfiguredShortcuts: () => ConfiguredShortcuts;
|
|
||||||
getShortcutsRegistered: () => boolean;
|
|
||||||
setShortcutsRegistered: (registered: boolean) => void;
|
|
||||||
isOverlayRuntimeInitialized: () => boolean;
|
|
||||||
isOverlayShortcutContextActive: () => boolean;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
openRuntimeOptionsPalette: () => void;
|
|
||||||
openJimaku: () => void;
|
|
||||||
markAudioCard: () => void | Promise<void>;
|
|
||||||
copySubtitle: () => void | Promise<void>;
|
|
||||||
toggleSecondarySubMode: () => void;
|
|
||||||
updateLastCardFromClipboard: () => void | Promise<void>;
|
|
||||||
triggerFieldGrouping: () => void | Promise<void>;
|
|
||||||
triggerSubsyncFromConfig: () => void | Promise<void>;
|
|
||||||
mineSentenceCard: () => void | Promise<void>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createShortcutsRuntime(input: ShortcutsRuntimeInput): ShortcutsRuntime {
|
|
||||||
const shortcutsRuntime = composeShortcutRuntimes({
|
|
||||||
globalShortcuts: input.globalShortcuts,
|
|
||||||
numericShortcutRuntimeMainDeps: input.numericShortcutRuntimeMainDeps,
|
|
||||||
numericSessions: input.numericSessions,
|
|
||||||
overlayShortcutsRuntimeMainDeps: input.overlayShortcutsRuntimeMainDeps,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...shortcutsRuntime,
|
|
||||||
syncOverlayShortcutsForModal: (isActive: boolean) => {
|
|
||||||
if (isActive) {
|
|
||||||
shortcutsRuntime.unregisterOverlayShortcuts();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
shortcutsRuntime.syncOverlayShortcuts();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShortcutsRuntimeBootstrap {
|
|
||||||
shortcuts: ShortcutsRuntime;
|
|
||||||
overlayShortcutsRuntime: ReturnType<typeof createOverlayShortcutsRuntimeService>;
|
|
||||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShortcutsRuntimeFromMainStateInput {
|
|
||||||
appState: Pick<AppState, 'overlayRuntimeInitialized' | 'shortcutsRegistered' | 'windowTracker'>;
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
globalShortcut: NumericShortcutRuntimeMainDepsInput['globalShortcut'] & {
|
|
||||||
unregisterAll: () => void;
|
|
||||||
};
|
|
||||||
registerGlobalShortcutsCore: typeof import('../core/services').registerGlobalShortcuts;
|
|
||||||
isDev: boolean;
|
|
||||||
overlay: {
|
|
||||||
getOverlayUi: () =>
|
|
||||||
| {
|
|
||||||
toggleVisibleOverlay: () => void;
|
|
||||||
openRuntimeOptionsPalette: () => void;
|
|
||||||
}
|
|
||||||
| null
|
|
||||||
| undefined;
|
|
||||||
overlayManager: {
|
|
||||||
getMainWindow: () => Electron.BrowserWindow | null;
|
|
||||||
getVisibleOverlayVisible: () => boolean;
|
|
||||||
};
|
|
||||||
overlayModalRuntime: Pick<OverlayModalRuntime, 'sendToActiveOverlayWindow'>;
|
|
||||||
};
|
|
||||||
actions: {
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
openYomitanSettings: () => boolean;
|
|
||||||
triggerSubsyncFromConfig: () => Promise<void>;
|
|
||||||
handleCycleSecondarySubMode: () => void;
|
|
||||||
handleMultiCopyDigit: (count: number) => void;
|
|
||||||
};
|
|
||||||
mining: {
|
|
||||||
copyCurrentSubtitle: () => void;
|
|
||||||
handleMineSentenceDigit: (count: number) => void;
|
|
||||||
markLastCardAsAudioCard: () => Promise<void>;
|
|
||||||
mineSentenceCard: () => Promise<void>;
|
|
||||||
triggerFieldGrouping: () => Promise<void>;
|
|
||||||
updateLastCardFromClipboard: () => Promise<void>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createShortcutsRuntimeBootstrap(
|
|
||||||
input: ShortcutsRuntimeBootstrapInput,
|
|
||||||
): ShortcutsRuntimeBootstrap {
|
|
||||||
let shortcuts: ShortcutsRuntime;
|
|
||||||
|
|
||||||
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
|
||||||
createBuildOverlayShortcutsRuntimeMainDepsHandler({
|
|
||||||
getConfiguredShortcuts: () => input.overlayShortcuts.getConfiguredShortcuts(),
|
|
||||||
getShortcutsRegistered: () => input.overlayShortcuts.getShortcutsRegistered(),
|
|
||||||
setShortcutsRegistered: (registered: boolean) => {
|
|
||||||
input.overlayShortcuts.setShortcutsRegistered(registered);
|
|
||||||
},
|
|
||||||
isOverlayRuntimeInitialized: () => input.overlayShortcuts.isOverlayRuntimeInitialized(),
|
|
||||||
isOverlayShortcutContextActive: () => input.overlayShortcuts.isOverlayShortcutContextActive(),
|
|
||||||
showMpvOsd: (text: string) => input.overlayShortcuts.showMpvOsd(text),
|
|
||||||
openRuntimeOptionsPalette: () => {
|
|
||||||
input.overlayShortcuts.openRuntimeOptionsPalette();
|
|
||||||
},
|
|
||||||
openJimaku: () => {
|
|
||||||
input.overlayShortcuts.openJimaku();
|
|
||||||
},
|
|
||||||
markAudioCard: () => Promise.resolve(input.overlayShortcuts.markAudioCard()),
|
|
||||||
copySubtitleMultiple: (timeoutMs: number) => {
|
|
||||||
shortcuts.startPendingMultiCopy(timeoutMs);
|
|
||||||
},
|
|
||||||
copySubtitle: () => Promise.resolve(input.overlayShortcuts.copySubtitle()),
|
|
||||||
toggleSecondarySubMode: () => input.overlayShortcuts.toggleSecondarySubMode(),
|
|
||||||
updateLastCardFromClipboard: () =>
|
|
||||||
Promise.resolve(input.overlayShortcuts.updateLastCardFromClipboard()),
|
|
||||||
triggerFieldGrouping: () => Promise.resolve(input.overlayShortcuts.triggerFieldGrouping()),
|
|
||||||
triggerSubsyncFromConfig: () =>
|
|
||||||
Promise.resolve(input.overlayShortcuts.triggerSubsyncFromConfig()),
|
|
||||||
mineSentenceCard: () => Promise.resolve(input.overlayShortcuts.mineSentenceCard()),
|
|
||||||
mineSentenceMultiple: (timeoutMs: number) => {
|
|
||||||
shortcuts.startPendingMineSentenceMultiple(timeoutMs);
|
|
||||||
},
|
|
||||||
cancelPendingMultiCopy: () => {
|
|
||||||
shortcuts.cancelPendingMultiCopy();
|
|
||||||
},
|
|
||||||
cancelPendingMineSentenceMultiple: () => {
|
|
||||||
shortcuts.cancelPendingMineSentenceMultiple();
|
|
||||||
},
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
shortcuts = createShortcutsRuntime({
|
|
||||||
globalShortcuts: input.globalShortcuts,
|
|
||||||
numericShortcutRuntimeMainDeps: input.numericShortcutRuntimeMainDeps,
|
|
||||||
numericSessions: input.numericSessions,
|
|
||||||
overlayShortcutsRuntimeMainDeps: {
|
|
||||||
overlayShortcutsRuntime,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
shortcuts,
|
|
||||||
overlayShortcutsRuntime,
|
|
||||||
syncOverlayShortcutsForModal: (isActive: boolean) => {
|
|
||||||
shortcuts.syncOverlayShortcutsForModal(isActive);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createShortcutsRuntimeFromMainState(
|
|
||||||
input: ShortcutsRuntimeFromMainStateInput,
|
|
||||||
): ShortcutsRuntimeBootstrap {
|
|
||||||
let shortcuts: ShortcutsRuntime;
|
|
||||||
|
|
||||||
const bootstrap = createShortcutsRuntimeBootstrap({
|
|
||||||
globalShortcuts: {
|
|
||||||
getConfiguredShortcutsMainDeps: {
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
defaultConfig: DEFAULT_CONFIG,
|
|
||||||
resolveConfiguredShortcuts,
|
|
||||||
},
|
|
||||||
buildRegisterGlobalShortcutsMainDeps: (getConfiguredShortcutsHandler) => ({
|
|
||||||
getConfiguredShortcuts: () => getConfiguredShortcutsHandler(),
|
|
||||||
registerGlobalShortcutsCore: input.registerGlobalShortcutsCore,
|
|
||||||
toggleVisibleOverlay: () => input.overlay.getOverlayUi()?.toggleVisibleOverlay(),
|
|
||||||
openYomitanSettings: () => {
|
|
||||||
input.actions.openYomitanSettings();
|
|
||||||
},
|
|
||||||
isDev: input.isDev,
|
|
||||||
getMainWindow: () => input.overlay.overlayManager.getMainWindow(),
|
|
||||||
}),
|
|
||||||
buildRefreshGlobalAndOverlayShortcutsMainDeps: (registerGlobalShortcutsHandler) => ({
|
|
||||||
unregisterAllGlobalShortcuts: () => input.globalShortcut.unregisterAll(),
|
|
||||||
registerGlobalShortcuts: () => registerGlobalShortcutsHandler(),
|
|
||||||
syncOverlayShortcuts: () => shortcuts.syncOverlayShortcuts(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
numericShortcutRuntimeMainDeps: {
|
|
||||||
globalShortcut: input.globalShortcut,
|
|
||||||
showMpvOsd: (text) => input.actions.showMpvOsd(text),
|
|
||||||
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
|
|
||||||
clearTimer: (timer) => clearTimeout(timer),
|
|
||||||
},
|
|
||||||
numericSessions: {
|
|
||||||
onMultiCopyDigit: (count) => input.actions.handleMultiCopyDigit(count),
|
|
||||||
onMineSentenceDigit: (count) => input.mining.handleMineSentenceDigit(count),
|
|
||||||
},
|
|
||||||
overlayShortcuts: {
|
|
||||||
getConfiguredShortcuts: () => shortcuts.getConfiguredShortcuts(),
|
|
||||||
getShortcutsRegistered: () => input.appState.shortcutsRegistered,
|
|
||||||
setShortcutsRegistered: (registered: boolean) => {
|
|
||||||
input.appState.shortcutsRegistered = registered;
|
|
||||||
},
|
|
||||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
|
||||||
isOverlayShortcutContextActive: () => {
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!input.overlay.overlayManager.getVisibleOverlayVisible()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const windowTracker = input.appState.windowTracker;
|
|
||||||
if (!windowTracker || !windowTracker.isTracking()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return windowTracker.isTargetWindowFocused();
|
|
||||||
},
|
|
||||||
showMpvOsd: (text: string) => input.actions.showMpvOsd(text),
|
|
||||||
openRuntimeOptionsPalette: () => {
|
|
||||||
input.overlay.getOverlayUi()?.openRuntimeOptionsPalette();
|
|
||||||
},
|
|
||||||
openJimaku: () => {
|
|
||||||
input.overlay.overlayModalRuntime.sendToActiveOverlayWindow('jimaku:open', undefined, {
|
|
||||||
restoreOnModalClose: 'jimaku' as OverlayHostedModal,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
markAudioCard: () => input.mining.markLastCardAsAudioCard(),
|
|
||||||
copySubtitle: () => input.mining.copyCurrentSubtitle(),
|
|
||||||
toggleSecondarySubMode: () => input.actions.handleCycleSecondarySubMode(),
|
|
||||||
updateLastCardFromClipboard: () => input.mining.updateLastCardFromClipboard(),
|
|
||||||
triggerFieldGrouping: () => input.mining.triggerFieldGrouping(),
|
|
||||||
triggerSubsyncFromConfig: () => input.actions.triggerSubsyncFromConfig(),
|
|
||||||
mineSentenceCard: () => input.mining.mineSentenceCard(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
shortcuts = bootstrap.shortcuts;
|
|
||||||
return bootstrap;
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import type { CliArgs } from '../cli/args';
|
|
||||||
import { isStandaloneTexthookerCommand, shouldRunSettingsOnlyStartup } from '../cli/args';
|
|
||||||
|
|
||||||
export function getPasswordStoreArg(argv: string[]): string | null {
|
|
||||||
for (let i = 0; i < argv.length; i += 1) {
|
|
||||||
const arg = argv[i];
|
|
||||||
if (!arg?.startsWith('--password-store')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (arg === '--password-store') {
|
|
||||||
const value = argv[i + 1];
|
|
||||||
if (value && !value.startsWith('--')) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [prefix, value] = arg.split('=', 2);
|
|
||||||
if (prefix === '--password-store' && value && value.trim().length > 0) {
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizePasswordStoreArg(value: string): string {
|
|
||||||
const normalized = value.trim();
|
|
||||||
if (normalized.toLowerCase() === 'gnome') {
|
|
||||||
return 'gnome-libsecret';
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultPasswordStore(): string {
|
|
||||||
return 'gnome-libsecret';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
|
||||||
shouldUseMinimalStartup: boolean;
|
|
||||||
shouldSkipHeavyStartup: boolean;
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
shouldUseMinimalStartup: Boolean(
|
|
||||||
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
|
|
||||||
(initialArgs?.stats &&
|
|
||||||
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
|
||||||
),
|
|
||||||
shouldSkipHeavyStartup: Boolean(
|
|
||||||
initialArgs &&
|
|
||||||
(shouldRunSettingsOnlyStartup(initialArgs) ||
|
|
||||||
initialArgs.stats ||
|
|
||||||
initialArgs.dictionary ||
|
|
||||||
initialArgs.setup),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import {
|
|
||||||
composeStartupLifecycleHandlers,
|
|
||||||
type StartupLifecycleComposerOptions,
|
|
||||||
} from './runtime/composers';
|
|
||||||
|
|
||||||
export interface StartupLifecycleRuntimeInput {
|
|
||||||
protocolUrl: StartupLifecycleComposerOptions['registerProtocolUrlHandlersMainDeps'];
|
|
||||||
cleanup: StartupLifecycleComposerOptions['onWillQuitCleanupMainDeps'];
|
|
||||||
shouldRestoreWindowsOnActivate: StartupLifecycleComposerOptions['shouldRestoreWindowsOnActivateMainDeps'];
|
|
||||||
restoreWindowsOnActivate: StartupLifecycleComposerOptions['restoreWindowsOnActivateMainDeps'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StartupLifecycleRuntime {
|
|
||||||
registerProtocolUrlHandlers: () => void;
|
|
||||||
onWillQuitCleanup: () => void;
|
|
||||||
shouldRestoreWindowsOnActivate: () => boolean;
|
|
||||||
restoreWindowsOnActivate: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStartupLifecycleRuntime(
|
|
||||||
input: StartupLifecycleRuntimeInput,
|
|
||||||
): StartupLifecycleRuntime {
|
|
||||||
const {
|
|
||||||
registerProtocolUrlHandlers,
|
|
||||||
onWillQuitCleanup,
|
|
||||||
shouldRestoreWindowsOnActivate,
|
|
||||||
restoreWindowsOnActivate,
|
|
||||||
} = composeStartupLifecycleHandlers({
|
|
||||||
registerProtocolUrlHandlersMainDeps: input.protocolUrl,
|
|
||||||
onWillQuitCleanupMainDeps: input.cleanup,
|
|
||||||
shouldRestoreWindowsOnActivateMainDeps: input.shouldRestoreWindowsOnActivate,
|
|
||||||
restoreWindowsOnActivateMainDeps: input.restoreWindowsOnActivate,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
registerProtocolUrlHandlers,
|
|
||||||
onWillQuitCleanup,
|
|
||||||
shouldRestoreWindowsOnActivate,
|
|
||||||
restoreWindowsOnActivate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createStartupSequenceRuntime } from './startup-sequence-runtime';
|
|
||||||
|
|
||||||
test('startup sequence delegates non-refresh headless command to initial args handler', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const runtime = createStartupSequenceRuntime({
|
|
||||||
appState: {
|
|
||||||
initialArgs: { refreshKnownWords: false } as never,
|
|
||||||
runtimeOptionsManager: null,
|
|
||||||
},
|
|
||||||
userDataPath: '/tmp/subminer',
|
|
||||||
getResolvedConfig: () => ({ ankiConnect: { enabled: true } }) as never,
|
|
||||||
anilist: {
|
|
||||||
refreshAnilistClientSecretStateIfEnabled: async () => undefined,
|
|
||||||
refreshRetryQueueState: () => {},
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
initializeDiscordPresenceService: async () => {},
|
|
||||||
requestAppQuit: () => {},
|
|
||||||
},
|
|
||||||
logger: {
|
|
||||||
error: () => {},
|
|
||||||
},
|
|
||||||
runHeadlessKnownWordRefresh: async () => {
|
|
||||||
calls.push('refreshKnownWords');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await runtime.runHeadlessInitialCommand({
|
|
||||||
handleInitialArgs: () => {
|
|
||||||
calls.push('handleInitialArgs');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['handleInitialArgs']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('startup sequence runs headless known-word refresh when requested', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const runtimeOptionsManager = {
|
|
||||||
getEffectiveAnkiConnectConfig: (config: never) => config,
|
|
||||||
} as never;
|
|
||||||
|
|
||||||
const runtime = createStartupSequenceRuntime({
|
|
||||||
appState: {
|
|
||||||
initialArgs: { refreshKnownWords: true } as never,
|
|
||||||
runtimeOptionsManager,
|
|
||||||
},
|
|
||||||
userDataPath: '/tmp/subminer',
|
|
||||||
getResolvedConfig: () => ({ ankiConnect: { enabled: true } }) as never,
|
|
||||||
anilist: {
|
|
||||||
refreshAnilistClientSecretStateIfEnabled: async () => undefined,
|
|
||||||
refreshRetryQueueState: () => {},
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
initializeDiscordPresenceService: async () => {},
|
|
||||||
requestAppQuit: () => {
|
|
||||||
calls.push('requestAppQuit');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logger: {
|
|
||||||
error: () => {},
|
|
||||||
},
|
|
||||||
runHeadlessKnownWordRefresh: async (input) => {
|
|
||||||
calls.push(`refresh:${input.userDataPath}`);
|
|
||||||
assert.equal(input.runtimeOptionsManager, runtimeOptionsManager);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await runtime.runHeadlessInitialCommand({
|
|
||||||
handleInitialArgs: () => {
|
|
||||||
calls.push('handleInitialArgs');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['refresh:/tmp/subminer']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('startup sequence runs deferred AniList and Discord init only for full startup', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const runtime = createStartupSequenceRuntime({
|
|
||||||
appState: {
|
|
||||||
initialArgs: null,
|
|
||||||
runtimeOptionsManager: null,
|
|
||||||
},
|
|
||||||
userDataPath: '/tmp/subminer',
|
|
||||||
getResolvedConfig: () => ({ anilist: { enabled: true } }) as never,
|
|
||||||
anilist: {
|
|
||||||
refreshAnilistClientSecretStateIfEnabled: async (options) => {
|
|
||||||
calls.push(`anilist:${options.force}:${options.allowSetupPrompt}`);
|
|
||||||
},
|
|
||||||
refreshRetryQueueState: () => {
|
|
||||||
calls.push('retryQueue');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
initializeDiscordPresenceService: async () => {
|
|
||||||
calls.push('discord');
|
|
||||||
},
|
|
||||||
requestAppQuit: () => {},
|
|
||||||
},
|
|
||||||
logger: {
|
|
||||||
error: () => {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
runtime.runPostStartupInitialization();
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['anilist:true:false', 'retryQueue', 'discord']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('startup sequence skips deferred startup side effects in minimal mode', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const runtime = createStartupSequenceRuntime({
|
|
||||||
appState: {
|
|
||||||
initialArgs: { background: true } as never,
|
|
||||||
runtimeOptionsManager: null,
|
|
||||||
},
|
|
||||||
userDataPath: '/tmp/subminer',
|
|
||||||
getResolvedConfig: () => ({ anilist: { enabled: true } }) as never,
|
|
||||||
anilist: {
|
|
||||||
refreshAnilistClientSecretStateIfEnabled: async () => {
|
|
||||||
calls.push('anilist');
|
|
||||||
},
|
|
||||||
refreshRetryQueueState: () => {
|
|
||||||
calls.push('retryQueue');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
initializeDiscordPresenceService: async () => {
|
|
||||||
calls.push('discord');
|
|
||||||
},
|
|
||||||
requestAppQuit: () => {},
|
|
||||||
},
|
|
||||||
logger: {
|
|
||||||
error: () => {},
|
|
||||||
},
|
|
||||||
getStartupModeFlags: () => ({
|
|
||||||
shouldUseMinimalStartup: true,
|
|
||||||
shouldSkipHeavyStartup: false,
|
|
||||||
}),
|
|
||||||
isAnilistTrackingEnabled: () => true,
|
|
||||||
});
|
|
||||||
|
|
||||||
runtime.runPostStartupInitialization();
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
assert.deepEqual(calls, []);
|
|
||||||
});
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import type { CliArgs } from '../cli/args';
|
|
||||||
import type { ResolvedConfig } from '../types';
|
|
||||||
import { isAnilistTrackingEnabled } from './runtime/domains/anilist';
|
|
||||||
import { getStartupModeFlags } from './startup-flags';
|
|
||||||
import { runHeadlessKnownWordRefresh } from './headless-known-word-refresh';
|
|
||||||
|
|
||||||
export interface StartupSequenceRuntimeInput {
|
|
||||||
appState: {
|
|
||||||
initialArgs: CliArgs | null | undefined;
|
|
||||||
runtimeOptionsManager: Parameters<
|
|
||||||
typeof runHeadlessKnownWordRefresh
|
|
||||||
>[0]['runtimeOptionsManager'];
|
|
||||||
};
|
|
||||||
userDataPath: string;
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
anilist: {
|
|
||||||
refreshAnilistClientSecretStateIfEnabled: (options: {
|
|
||||||
force: boolean;
|
|
||||||
allowSetupPrompt?: boolean;
|
|
||||||
}) => Promise<unknown>;
|
|
||||||
refreshRetryQueueState: () => void;
|
|
||||||
};
|
|
||||||
actions: {
|
|
||||||
initializeDiscordPresenceService: () => Promise<void>;
|
|
||||||
requestAppQuit: () => void;
|
|
||||||
};
|
|
||||||
logger: {
|
|
||||||
error: (message: string, error?: unknown) => void;
|
|
||||||
};
|
|
||||||
runHeadlessKnownWordRefresh?: typeof runHeadlessKnownWordRefresh;
|
|
||||||
getStartupModeFlags?: typeof getStartupModeFlags;
|
|
||||||
isAnilistTrackingEnabled?: typeof isAnilistTrackingEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StartupSequenceRuntime {
|
|
||||||
runHeadlessInitialCommand: (input: { handleInitialArgs: () => void }) => Promise<void>;
|
|
||||||
runPostStartupInitialization: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStartupSequenceRuntime(
|
|
||||||
input: StartupSequenceRuntimeInput,
|
|
||||||
): StartupSequenceRuntime {
|
|
||||||
const runKnownWordRefresh = input.runHeadlessKnownWordRefresh ?? runHeadlessKnownWordRefresh;
|
|
||||||
const resolveStartupModeFlags = input.getStartupModeFlags ?? getStartupModeFlags;
|
|
||||||
const isTrackingEnabled = input.isAnilistTrackingEnabled ?? isAnilistTrackingEnabled;
|
|
||||||
|
|
||||||
const shouldSkipDeferredStartup = (): boolean => {
|
|
||||||
if (!input.appState.initialArgs) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startupModeFlags = resolveStartupModeFlags(input.appState.initialArgs);
|
|
||||||
return startupModeFlags.shouldUseMinimalStartup || startupModeFlags.shouldSkipHeavyStartup;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
runHeadlessInitialCommand: async ({ handleInitialArgs }): Promise<void> => {
|
|
||||||
if (!input.appState.initialArgs?.refreshKnownWords) {
|
|
||||||
handleInitialArgs();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await runKnownWordRefresh({
|
|
||||||
resolvedConfig: input.getResolvedConfig(),
|
|
||||||
runtimeOptionsManager: input.appState.runtimeOptionsManager,
|
|
||||||
userDataPath: input.userDataPath,
|
|
||||||
logger: input.logger,
|
|
||||||
requestAppQuit: input.actions.requestAppQuit,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
runPostStartupInitialization: (): void => {
|
|
||||||
if (shouldSkipDeferredStartup()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTrackingEnabled(input.getResolvedConfig())) {
|
|
||||||
void input.anilist
|
|
||||||
.refreshAnilistClientSecretStateIfEnabled({
|
|
||||||
force: true,
|
|
||||||
allowSetupPrompt: false,
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
input.logger.error(
|
|
||||||
'Failed to refresh AniList client secret state during startup',
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
input.anilist.refreshRetryQueueState();
|
|
||||||
}
|
|
||||||
|
|
||||||
void input.actions.initializeDiscordPresenceService().catch((error) => {
|
|
||||||
input.logger.error('Failed to initialize Discord presence service during startup', error);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import type { MpvIpcClient } from '../core/services/mpv';
|
|
||||||
import type {
|
|
||||||
JimakuLanguagePreference,
|
|
||||||
ResolvedConfig,
|
|
||||||
SecondarySubMode,
|
|
||||||
SubsyncManualPayload,
|
|
||||||
} from '../types';
|
|
||||||
import type { ConfigService } from '../config';
|
|
||||||
import type { RuntimeOptionsManager } from '../runtime-options';
|
|
||||||
import type { AppState } from './state';
|
|
||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
|
||||||
import { createStartupSupportRuntime, type StartupSupportRuntime } from './startup-support-runtime';
|
|
||||||
|
|
||||||
export interface StartupSupportCoordinatorInput {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
defaultImmersionDbPath: string;
|
|
||||||
defaultJimakuLanguagePreference: JimakuLanguagePreference;
|
|
||||||
defaultJimakuMaxEntryResults: number;
|
|
||||||
defaultJimakuApiBaseUrl: string;
|
|
||||||
jellyfinLangPref: string;
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
appState: AppState;
|
|
||||||
configService: Pick<ConfigService, 'reloadConfigStrict'>;
|
|
||||||
actions: {
|
|
||||||
sendMpvCommandRuntime: (client: MpvIpcClient, command: (string | number)[]) => void;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
openSubsyncManualPicker: (payload: SubsyncManualPayload) => void;
|
|
||||||
refreshGlobalAndOverlayShortcuts: () => void;
|
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
|
||||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
|
||||||
showErrorBox: (title: string, details: string) => void;
|
|
||||||
};
|
|
||||||
logger: StartupSupportRuntime['configHotReloadRuntime'] extends never
|
|
||||||
? never
|
|
||||||
: Parameters<typeof createStartupSupportRuntime>[0]['logger'];
|
|
||||||
watch: Parameters<typeof createStartupSupportRuntime>[0]['watch'];
|
|
||||||
timers: Parameters<typeof createStartupSupportRuntime>[0]['timers'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StartupSupportFromMainStateInput {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
defaultImmersionDbPath: string;
|
|
||||||
defaultJimakuLanguagePreference: JimakuLanguagePreference;
|
|
||||||
defaultJimakuMaxEntryResults: number;
|
|
||||||
defaultJimakuApiBaseUrl: string;
|
|
||||||
jellyfinLangPref: string;
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
appState: AppState;
|
|
||||||
configService: Pick<ConfigService, 'reloadConfigStrict'>;
|
|
||||||
overlay: {
|
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
|
||||||
sendToActiveOverlayWindow: (
|
|
||||||
channel: string,
|
|
||||||
payload?: unknown,
|
|
||||||
runtimeOptions?: {
|
|
||||||
restoreOnModalClose?: OverlayHostedModal;
|
|
||||||
preferModalWindow?: boolean;
|
|
||||||
},
|
|
||||||
) => void;
|
|
||||||
};
|
|
||||||
shortcuts: {
|
|
||||||
refreshGlobalAndOverlayShortcuts: () => void;
|
|
||||||
};
|
|
||||||
notifications: {
|
|
||||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
|
||||||
showErrorBox: (title: string, details: string) => void;
|
|
||||||
};
|
|
||||||
logger: Parameters<typeof createStartupSupportRuntime>[0]['logger'];
|
|
||||||
mpv: {
|
|
||||||
sendMpvCommandRuntime: (client: MpvIpcClient, command: (string | number)[]) => void;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStartupSupportCoordinator(
|
|
||||||
input: StartupSupportCoordinatorInput,
|
|
||||||
): StartupSupportRuntime {
|
|
||||||
return createStartupSupportRuntime({
|
|
||||||
platform: input.platform,
|
|
||||||
defaultImmersionDbPath: input.defaultImmersionDbPath,
|
|
||||||
defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference,
|
|
||||||
defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults,
|
|
||||||
defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl,
|
|
||||||
jellyfinLangPref: input.jellyfinLangPref,
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
appState: {
|
|
||||||
immersionTracker: input.appState.immersionTracker,
|
|
||||||
mpvClient: input.appState.mpvClient,
|
|
||||||
currentMediaPath: input.appState.currentMediaPath,
|
|
||||||
currentMediaTitle: input.appState.currentMediaTitle,
|
|
||||||
runtimeOptionsManager: input.appState.runtimeOptionsManager as RuntimeOptionsManager | null,
|
|
||||||
subsyncInProgress: input.appState.subsyncInProgress,
|
|
||||||
keybindings: input.appState.keybindings,
|
|
||||||
ankiIntegration: input.appState.ankiIntegration,
|
|
||||||
},
|
|
||||||
mpv: {
|
|
||||||
sendMpvCommandRuntime: (client, command) =>
|
|
||||||
input.actions.sendMpvCommandRuntime(client as MpvIpcClient, command),
|
|
||||||
showMpvOsd: (text) => input.actions.showMpvOsd(text),
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
reloadConfigStrict: () => input.configService.reloadConfigStrict(),
|
|
||||||
},
|
|
||||||
subsync: {
|
|
||||||
openManualPicker: (payload) => input.actions.openSubsyncManualPicker(payload),
|
|
||||||
},
|
|
||||||
hotReload: {
|
|
||||||
setSecondarySubMode: (mode: SecondarySubMode) => {
|
|
||||||
input.appState.secondarySubMode = mode;
|
|
||||||
},
|
|
||||||
refreshGlobalAndOverlayShortcuts: () => input.actions.refreshGlobalAndOverlayShortcuts(),
|
|
||||||
broadcastToOverlayWindows: (channel, payload) =>
|
|
||||||
input.actions.broadcastToOverlayWindows(channel, payload),
|
|
||||||
},
|
|
||||||
notifications: {
|
|
||||||
showDesktopNotification: (title, options) =>
|
|
||||||
input.actions.showDesktopNotification(title, options),
|
|
||||||
showErrorBox: (title, details) => input.actions.showErrorBox(title, details),
|
|
||||||
},
|
|
||||||
logger: input.logger,
|
|
||||||
watch: input.watch,
|
|
||||||
timers: input.timers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStartupSupportFromMainState(
|
|
||||||
input: StartupSupportFromMainStateInput,
|
|
||||||
): StartupSupportRuntime {
|
|
||||||
return createStartupSupportCoordinator({
|
|
||||||
platform: input.platform,
|
|
||||||
defaultImmersionDbPath: input.defaultImmersionDbPath,
|
|
||||||
defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference,
|
|
||||||
defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults,
|
|
||||||
defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl,
|
|
||||||
jellyfinLangPref: input.jellyfinLangPref,
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
appState: input.appState,
|
|
||||||
configService: input.configService,
|
|
||||||
actions: {
|
|
||||||
sendMpvCommandRuntime: (client, command) => input.mpv.sendMpvCommandRuntime(client, command),
|
|
||||||
showMpvOsd: (text) => input.mpv.showMpvOsd(text),
|
|
||||||
openSubsyncManualPicker: (payload) => {
|
|
||||||
input.overlay.sendToActiveOverlayWindow('subsync:open-manual', payload, {
|
|
||||||
restoreOnModalClose: 'subsync',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
refreshGlobalAndOverlayShortcuts: () => {
|
|
||||||
input.shortcuts.refreshGlobalAndOverlayShortcuts();
|
|
||||||
},
|
|
||||||
broadcastToOverlayWindows: (channel, payload) => {
|
|
||||||
input.overlay.broadcastToOverlayWindows(channel, payload);
|
|
||||||
},
|
|
||||||
showDesktopNotification: (title, options) =>
|
|
||||||
input.notifications.showDesktopNotification(title, options),
|
|
||||||
showErrorBox: (title, details) => input.notifications.showErrorBox(title, details),
|
|
||||||
},
|
|
||||||
logger: input.logger,
|
|
||||||
watch: {
|
|
||||||
fileExists: (targetPath) => fs.existsSync(targetPath),
|
|
||||||
dirname: (targetPath) => path.dirname(targetPath),
|
|
||||||
watchPath: (targetPath, listener) => fs.watch(targetPath, listener),
|
|
||||||
},
|
|
||||||
timers: {
|
|
||||||
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
||||||
clearTimeout: (timeout) => clearTimeout(timeout),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
import { createConfigHotReloadRuntime, type MpvIpcClient } from '../core/services';
|
|
||||||
import type { MpvRuntimeClientLike } from '../core/services/mpv';
|
|
||||||
import type {
|
|
||||||
ConfigHotReloadPayload,
|
|
||||||
ConfigValidationWarning,
|
|
||||||
JimakuLanguagePreference,
|
|
||||||
ResolvedConfig,
|
|
||||||
SecondarySubMode,
|
|
||||||
SubsyncManualPayload,
|
|
||||||
} from '../types';
|
|
||||||
import type { ReloadConfigStrictResult } from '../config';
|
|
||||||
import { RuntimeOptionsManager } from '../runtime-options';
|
|
||||||
import {
|
|
||||||
createApplyJellyfinMpvDefaultsHandler,
|
|
||||||
createBuildApplyJellyfinMpvDefaultsMainDepsHandler,
|
|
||||||
createBuildGetDefaultSocketPathMainDepsHandler,
|
|
||||||
createGetDefaultSocketPathHandler,
|
|
||||||
} from './runtime/domains/jellyfin';
|
|
||||||
import {
|
|
||||||
createBuildConfigHotReloadAppliedMainDepsHandler,
|
|
||||||
createBuildConfigHotReloadMessageMainDepsHandler,
|
|
||||||
createBuildConfigHotReloadRuntimeMainDepsHandler,
|
|
||||||
createBuildWatchConfigPathMainDepsHandler,
|
|
||||||
createConfigHotReloadAppliedHandler,
|
|
||||||
createConfigHotReloadMessageHandler,
|
|
||||||
createWatchConfigPathHandler,
|
|
||||||
buildRestartRequiredConfigMessage,
|
|
||||||
} from './runtime/domains/overlay';
|
|
||||||
import {
|
|
||||||
createBuildConfigDerivedRuntimeMainDepsHandler,
|
|
||||||
createBuildImmersionMediaRuntimeMainDepsHandler,
|
|
||||||
createBuildMainSubsyncRuntimeMainDepsHandler,
|
|
||||||
createConfigDerivedRuntime,
|
|
||||||
createImmersionMediaRuntime,
|
|
||||||
createMainSubsyncRuntime,
|
|
||||||
} from './runtime/domains/startup';
|
|
||||||
import {
|
|
||||||
buildConfigWarningDialogDetails,
|
|
||||||
buildConfigWarningNotificationBody,
|
|
||||||
} from './config-validation';
|
|
||||||
|
|
||||||
type ImmersionTrackerLike = {
|
|
||||||
handleMediaChange: (path: string, title: string | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MpvClientLike = MpvIpcClient | null;
|
|
||||||
type JellyfinMpvClientLike = MpvRuntimeClientLike;
|
|
||||||
|
|
||||||
export interface StartupSupportRuntimeInput {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
defaultImmersionDbPath: string;
|
|
||||||
defaultJimakuLanguagePreference: JimakuLanguagePreference;
|
|
||||||
defaultJimakuMaxEntryResults: number;
|
|
||||||
defaultJimakuApiBaseUrl: string;
|
|
||||||
jellyfinLangPref: string;
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
appState: {
|
|
||||||
immersionTracker: ImmersionTrackerLike | null;
|
|
||||||
mpvClient: MpvClientLike;
|
|
||||||
currentMediaPath: string | null;
|
|
||||||
currentMediaTitle: string | null;
|
|
||||||
runtimeOptionsManager: RuntimeOptionsManager | null;
|
|
||||||
subsyncInProgress: boolean;
|
|
||||||
keybindings: ConfigHotReloadPayload['keybindings'];
|
|
||||||
ankiIntegration: {
|
|
||||||
applyRuntimeConfigPatch: (patch: {
|
|
||||||
ai: ResolvedConfig['ankiConnect']['ai']['enabled'];
|
|
||||||
}) => void;
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
mpv: {
|
|
||||||
sendMpvCommandRuntime: (client: JellyfinMpvClientLike, command: (string | number)[]) => void;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
};
|
|
||||||
config: {
|
|
||||||
reloadConfigStrict: () => ReloadConfigStrictResult;
|
|
||||||
};
|
|
||||||
subsync: {
|
|
||||||
openManualPicker: (payload: SubsyncManualPayload) => void;
|
|
||||||
};
|
|
||||||
hotReload: {
|
|
||||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
|
||||||
refreshGlobalAndOverlayShortcuts: () => void;
|
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
|
||||||
};
|
|
||||||
notifications: {
|
|
||||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
|
||||||
showErrorBox: (title: string, details: string) => void;
|
|
||||||
};
|
|
||||||
logger: {
|
|
||||||
debug: (message: string) => void;
|
|
||||||
info: (message: string) => void;
|
|
||||||
warn: (message: string, error?: unknown) => void;
|
|
||||||
};
|
|
||||||
watch: {
|
|
||||||
fileExists: (targetPath: string) => boolean;
|
|
||||||
dirname: (targetPath: string) => string;
|
|
||||||
watchPath: (
|
|
||||||
targetPath: string,
|
|
||||||
listener: (eventType: string, filename: string | null) => void,
|
|
||||||
) => { close: () => void };
|
|
||||||
};
|
|
||||||
timers: {
|
|
||||||
setTimeout: (callback: () => void, delayMs: number) => NodeJS.Timeout;
|
|
||||||
clearTimeout: (timeout: NodeJS.Timeout) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StartupSupportRuntime {
|
|
||||||
applyJellyfinMpvDefaults: (client: JellyfinMpvClientLike) => void;
|
|
||||||
getDefaultSocketPath: () => string;
|
|
||||||
immersionMediaRuntime: ReturnType<typeof createImmersionMediaRuntime>;
|
|
||||||
configDerivedRuntime: ReturnType<typeof createConfigDerivedRuntime>;
|
|
||||||
subsyncRuntime: ReturnType<typeof createMainSubsyncRuntime>;
|
|
||||||
configHotReloadRuntime: ReturnType<typeof createConfigHotReloadRuntime>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStartupSupportRuntime(
|
|
||||||
input: StartupSupportRuntimeInput,
|
|
||||||
): StartupSupportRuntime {
|
|
||||||
const applyJellyfinMpvDefaultsHandler = createApplyJellyfinMpvDefaultsHandler(
|
|
||||||
createBuildApplyJellyfinMpvDefaultsMainDepsHandler({
|
|
||||||
sendMpvCommandRuntime: (client, command) => input.mpv.sendMpvCommandRuntime(client, command),
|
|
||||||
jellyfinLangPref: input.jellyfinLangPref,
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler(
|
|
||||||
createBuildGetDefaultSocketPathMainDepsHandler({
|
|
||||||
platform: input.platform,
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const immersionMediaRuntime = createImmersionMediaRuntime(
|
|
||||||
createBuildImmersionMediaRuntimeMainDepsHandler({
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
defaultImmersionDbPath: input.defaultImmersionDbPath,
|
|
||||||
getTracker: () => input.appState.immersionTracker,
|
|
||||||
getMpvClient: () => input.appState.mpvClient,
|
|
||||||
getCurrentMediaPath: () => input.appState.currentMediaPath,
|
|
||||||
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
|
|
||||||
logDebug: (message) => input.logger.debug(message),
|
|
||||||
logInfo: (message) => input.logger.info(message),
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const configDerivedRuntime = createConfigDerivedRuntime(
|
|
||||||
createBuildConfigDerivedRuntimeMainDepsHandler({
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
|
|
||||||
defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference,
|
|
||||||
defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults,
|
|
||||||
defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl,
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const subsyncRuntime = createMainSubsyncRuntime(
|
|
||||||
createBuildMainSubsyncRuntimeMainDepsHandler({
|
|
||||||
getMpvClient: () => input.appState.mpvClient,
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
getSubsyncInProgress: () => input.appState.subsyncInProgress,
|
|
||||||
setSubsyncInProgress: (inProgress) => {
|
|
||||||
input.appState.subsyncInProgress = inProgress;
|
|
||||||
},
|
|
||||||
showMpvOsd: (text) => input.mpv.showMpvOsd(text),
|
|
||||||
openManualPicker: (payload) => input.subsync.openManualPicker(payload),
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const notifyConfigHotReloadMessage = createConfigHotReloadMessageHandler(
|
|
||||||
createBuildConfigHotReloadMessageMainDepsHandler({
|
|
||||||
showMpvOsd: (message) => input.mpv.showMpvOsd(message),
|
|
||||||
showDesktopNotification: (title, options) =>
|
|
||||||
input.notifications.showDesktopNotification(title, options),
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const watchConfigPathHandler = createWatchConfigPathHandler(
|
|
||||||
createBuildWatchConfigPathMainDepsHandler({
|
|
||||||
fileExists: (targetPath) => input.watch.fileExists(targetPath),
|
|
||||||
dirname: (targetPath) => input.watch.dirname(targetPath),
|
|
||||||
watchPath: (targetPath, listener) => input.watch.watchPath(targetPath, listener),
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const configHotReloadRuntime = createConfigHotReloadRuntime(
|
|
||||||
createBuildConfigHotReloadRuntimeMainDepsHandler({
|
|
||||||
getCurrentConfig: () => input.getResolvedConfig(),
|
|
||||||
reloadConfigStrict: () => input.config.reloadConfigStrict(),
|
|
||||||
watchConfigPath: (configPath, onChange) => watchConfigPathHandler(configPath, onChange),
|
|
||||||
setTimeout: (callback, delayMs) => input.timers.setTimeout(callback, delayMs),
|
|
||||||
clearTimeout: (timeout) => input.timers.clearTimeout(timeout),
|
|
||||||
debounceMs: 250,
|
|
||||||
onHotReloadApplied: createConfigHotReloadAppliedHandler(
|
|
||||||
createBuildConfigHotReloadAppliedMainDepsHandler({
|
|
||||||
setKeybindings: (keybindings) => {
|
|
||||||
input.appState.keybindings = keybindings;
|
|
||||||
},
|
|
||||||
refreshGlobalAndOverlayShortcuts: () => {
|
|
||||||
input.hotReload.refreshGlobalAndOverlayShortcuts();
|
|
||||||
},
|
|
||||||
setSecondarySubMode: (mode) => input.hotReload.setSecondarySubMode(mode),
|
|
||||||
broadcastToOverlayWindows: (channel, payload) =>
|
|
||||||
input.hotReload.broadcastToOverlayWindows(channel, payload),
|
|
||||||
applyAnkiRuntimeConfigPatch: (patch) => {
|
|
||||||
input.appState.ankiIntegration?.applyRuntimeConfigPatch(patch);
|
|
||||||
},
|
|
||||||
})(),
|
|
||||||
),
|
|
||||||
onRestartRequired: (fields) =>
|
|
||||||
notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)),
|
|
||||||
onInvalidConfig: notifyConfigHotReloadMessage,
|
|
||||||
onValidationWarnings: (configPath, warnings: ConfigValidationWarning[]) => {
|
|
||||||
input.notifications.showDesktopNotification('SubMiner', {
|
|
||||||
body: buildConfigWarningNotificationBody(configPath, warnings),
|
|
||||||
});
|
|
||||||
if (input.platform === 'darwin') {
|
|
||||||
input.notifications.showErrorBox(
|
|
||||||
'SubMiner config validation warning',
|
|
||||||
buildConfigWarningDialogDetails(configPath, warnings),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
applyJellyfinMpvDefaults: (client) => applyJellyfinMpvDefaultsHandler(client),
|
|
||||||
getDefaultSocketPath: () => getDefaultSocketPathHandler(),
|
|
||||||
immersionMediaRuntime,
|
|
||||||
configDerivedRuntime,
|
|
||||||
subsyncRuntime,
|
|
||||||
configHotReloadRuntime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
import type { BrowserWindow } from 'electron';
|
|
||||||
import { shell } from 'electron';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import type { CliArgs, CliCommandSource } from '../cli/args';
|
|
||||||
import type { ResolvedConfig } from '../types';
|
|
||||||
import {
|
|
||||||
addYomitanNoteViaSearch,
|
|
||||||
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
|
|
||||||
} from '../core/services';
|
|
||||||
import { createLogger } from '../logger';
|
|
||||||
import type { AppState } from './state';
|
|
||||||
import {
|
|
||||||
createStatsRuntimeBootstrap,
|
|
||||||
type StatsRuntime,
|
|
||||||
type StatsRuntimeBootstrap,
|
|
||||||
} from './stats-runtime';
|
|
||||||
import { registerStatsOverlayToggle, destroyStatsWindow } from '../core/services/stats-window.js';
|
|
||||||
|
|
||||||
export interface StatsRuntimeCoordinatorInput {
|
|
||||||
statsDaemonStatePath: string;
|
|
||||||
statsDistPath: string;
|
|
||||||
statsPreloadPath: string;
|
|
||||||
userDataPath: string;
|
|
||||||
appState: AppState;
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
dictionarySupport: {
|
|
||||||
getConfiguredDbPath: () => string;
|
|
||||||
seedImmersionMediaFromCurrentMedia: () => Promise<void> | void;
|
|
||||||
};
|
|
||||||
overlay: {
|
|
||||||
getOverlayGeometry: () => { getCurrentOverlayGeometry: () => Electron.Rectangle };
|
|
||||||
updateVisibleOverlayVisibility: () => void;
|
|
||||||
registerStatsOverlayToggle: StatsRuntimeBootstrap['stats'] extends never
|
|
||||||
? never
|
|
||||||
: Parameters<typeof createStatsRuntimeBootstrap>[0]['overlay']['registerStatsOverlayToggle'];
|
|
||||||
};
|
|
||||||
mpvRuntime: {
|
|
||||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
|
||||||
};
|
|
||||||
actions: {
|
|
||||||
openExternal: (url: string) => Promise<unknown>;
|
|
||||||
requestAppQuit: () => void;
|
|
||||||
destroyStatsWindow: () => void;
|
|
||||||
};
|
|
||||||
logger: {
|
|
||||||
info: (message: string) => void;
|
|
||||||
warn: (message: string, error?: unknown) => void;
|
|
||||||
error: (message: string, error?: unknown) => void;
|
|
||||||
debug: (message: string, details?: unknown) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatsRuntimeCoordinator {
|
|
||||||
statsBootstrap: StatsRuntimeBootstrap;
|
|
||||||
stats: StatsRuntime;
|
|
||||||
ensureStatsServerStarted: () => string;
|
|
||||||
ensureBackgroundStatsServerStarted: () => {
|
|
||||||
url: string;
|
|
||||||
runningInCurrentProcess: boolean;
|
|
||||||
};
|
|
||||||
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
|
|
||||||
ensureImmersionTrackerStarted: () => void;
|
|
||||||
runStatsCliCommand: (
|
|
||||||
args: Pick<
|
|
||||||
CliArgs,
|
|
||||||
| 'statsResponsePath'
|
|
||||||
| 'statsBackground'
|
|
||||||
| 'statsStop'
|
|
||||||
| 'statsCleanup'
|
|
||||||
| 'statsCleanupVocab'
|
|
||||||
| 'statsCleanupLifetime'
|
|
||||||
>,
|
|
||||||
source: CliCommandSource,
|
|
||||||
) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStatsRuntimeCoordinator(
|
|
||||||
input: StatsRuntimeCoordinatorInput,
|
|
||||||
): StatsRuntimeCoordinator {
|
|
||||||
const statsBootstrap = createStatsRuntimeBootstrap({
|
|
||||||
statsDaemonStatePath: input.statsDaemonStatePath,
|
|
||||||
statsDistPath: input.statsDistPath,
|
|
||||||
statsPreloadPath: input.statsPreloadPath,
|
|
||||||
userDataPath: input.userDataPath,
|
|
||||||
appState: input.appState,
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
dictionarySupport: input.dictionarySupport,
|
|
||||||
overlay: input.overlay,
|
|
||||||
createMecabTokenizerAndCheck: async () => {
|
|
||||||
await input.mpvRuntime.createMecabTokenizerAndCheck();
|
|
||||||
},
|
|
||||||
addYomitanNote: async (word: string) => {
|
|
||||||
const yomitanDeps = {
|
|
||||||
getYomitanExt: () => input.appState.yomitanExt,
|
|
||||||
getYomitanSession: () => input.appState.yomitanSession,
|
|
||||||
getYomitanParserWindow: () => input.appState.yomitanParserWindow,
|
|
||||||
setYomitanParserWindow: (window: BrowserWindow | null) => {
|
|
||||||
input.appState.yomitanParserWindow = window;
|
|
||||||
},
|
|
||||||
getYomitanParserReadyPromise: () => input.appState.yomitanParserReadyPromise,
|
|
||||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => {
|
|
||||||
input.appState.yomitanParserReadyPromise = promise;
|
|
||||||
},
|
|
||||||
getYomitanParserInitPromise: () => input.appState.yomitanParserInitPromise,
|
|
||||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => {
|
|
||||||
input.appState.yomitanParserInitPromise = promise;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const yomitanLogger = createLogger('main:yomitan-stats');
|
|
||||||
const ankiUrl = input.getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765';
|
|
||||||
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
|
||||||
forceOverride: true,
|
|
||||||
});
|
|
||||||
return addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
|
||||||
},
|
|
||||||
openExternal: input.actions.openExternal,
|
|
||||||
requestAppQuit: input.actions.requestAppQuit,
|
|
||||||
destroyStatsWindow: input.actions.destroyStatsWindow,
|
|
||||||
logger: input.logger,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stats = statsBootstrap.stats;
|
|
||||||
|
|
||||||
return {
|
|
||||||
statsBootstrap,
|
|
||||||
stats,
|
|
||||||
ensureStatsServerStarted: () => stats.ensureStatsServerStarted(),
|
|
||||||
ensureBackgroundStatsServerStarted: () => stats.ensureBackgroundStatsServerStarted(),
|
|
||||||
stopBackgroundStatsServer: async () => await stats.stopBackgroundStatsServer(),
|
|
||||||
ensureImmersionTrackerStarted: () => {
|
|
||||||
stats.ensureImmersionTrackerStarted();
|
|
||||||
},
|
|
||||||
runStatsCliCommand: async (args, source) => {
|
|
||||||
await stats.runStatsCliCommand(args, source);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatsRuntimeFromMainStateInput {
|
|
||||||
dirname: string;
|
|
||||||
userDataPath: string;
|
|
||||||
appState: AppState;
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
dictionarySupport: StatsRuntimeCoordinatorInput['dictionarySupport'];
|
|
||||||
overlay: {
|
|
||||||
getOverlayGeometry: () => { getCurrentOverlayGeometry: () => Electron.Rectangle };
|
|
||||||
updateVisibleOverlayVisibility: () => void;
|
|
||||||
};
|
|
||||||
mpvRuntime: {
|
|
||||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
|
||||||
};
|
|
||||||
actions: {
|
|
||||||
requestAppQuit: () => void;
|
|
||||||
};
|
|
||||||
logger: StatsRuntimeCoordinatorInput['logger'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStatsRuntimeFromMainState(
|
|
||||||
input: StatsRuntimeFromMainStateInput,
|
|
||||||
): StatsRuntimeCoordinator {
|
|
||||||
return createStatsRuntimeCoordinator({
|
|
||||||
statsDaemonStatePath: path.join(input.userDataPath, 'stats-daemon.json'),
|
|
||||||
statsDistPath: path.join(input.dirname, '..', 'stats', 'dist'),
|
|
||||||
statsPreloadPath: path.join(input.dirname, 'preload-stats.js'),
|
|
||||||
userDataPath: input.userDataPath,
|
|
||||||
appState: input.appState,
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
dictionarySupport: input.dictionarySupport,
|
|
||||||
overlay: {
|
|
||||||
getOverlayGeometry: () => input.overlay.getOverlayGeometry(),
|
|
||||||
updateVisibleOverlayVisibility: () => input.overlay.updateVisibleOverlayVisibility(),
|
|
||||||
registerStatsOverlayToggle,
|
|
||||||
},
|
|
||||||
mpvRuntime: {
|
|
||||||
createMecabTokenizerAndCheck: () => input.mpvRuntime.createMecabTokenizerAndCheck(),
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
openExternal: (url) => shell.openExternal(url),
|
|
||||||
requestAppQuit: () => input.actions.requestAppQuit(),
|
|
||||||
destroyStatsWindow,
|
|
||||||
},
|
|
||||||
logger: input.logger,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createStatsRuntime } from './stats-runtime';
|
|
||||||
|
|
||||||
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-runtime-test-'));
|
|
||||||
const result = fn(dir);
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
return result.finally(() => {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
test('stats runtime removes stale daemon state', async () => {
|
|
||||||
await withTempDir(async (dir) => {
|
|
||||||
const statePath = path.join(dir, 'stats-daemon.json');
|
|
||||||
fs.writeFileSync(
|
|
||||||
statePath,
|
|
||||||
JSON.stringify({ pid: 99999, port: 6969, startedAtMs: 1_234 }, null, 2),
|
|
||||||
);
|
|
||||||
|
|
||||||
const runtime = createStatsRuntime({
|
|
||||||
statsDaemonStatePath: statePath,
|
|
||||||
getResolvedConfig: () => ({
|
|
||||||
immersionTracking: { enabled: true },
|
|
||||||
stats: { serverPort: 6969 },
|
|
||||||
}),
|
|
||||||
getImmersionTracker: () => ({}) as never,
|
|
||||||
ensureImmersionTrackerStartedCore: () => {},
|
|
||||||
startStatsServer: () => ({ close: () => {} }),
|
|
||||||
openExternal: async () => {},
|
|
||||||
exitAppWithCode: () => {},
|
|
||||||
logInfo: () => {},
|
|
||||||
logWarn: () => {},
|
|
||||||
logError: () => {},
|
|
||||||
getCurrentPid: () => 123,
|
|
||||||
isProcessAlive: () => false,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(runtime.readLiveBackgroundStatsDaemonState(), null);
|
|
||||||
assert.equal(fs.existsSync(statePath), false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stats runtime starts background server and writes owned daemon state', async () => {
|
|
||||||
await withTempDir(async (dir) => {
|
|
||||||
const statePath = path.join(dir, 'stats-daemon.json');
|
|
||||||
let startedPort: number | null = null;
|
|
||||||
|
|
||||||
const runtime = createStatsRuntime({
|
|
||||||
statsDaemonStatePath: statePath,
|
|
||||||
getResolvedConfig: () => ({
|
|
||||||
immersionTracking: { enabled: true },
|
|
||||||
stats: { serverPort: 6970 },
|
|
||||||
}),
|
|
||||||
getImmersionTracker: () => ({}) as never,
|
|
||||||
ensureImmersionTrackerStartedCore: () => {},
|
|
||||||
startStatsServer: (port) => {
|
|
||||||
startedPort = port;
|
|
||||||
return { close: () => {} };
|
|
||||||
},
|
|
||||||
openExternal: async () => {},
|
|
||||||
exitAppWithCode: () => {},
|
|
||||||
logInfo: () => {},
|
|
||||||
logWarn: () => {},
|
|
||||||
logError: () => {},
|
|
||||||
getCurrentPid: () => 456,
|
|
||||||
isProcessAlive: () => false,
|
|
||||||
now: () => 999,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runtime.ensureBackgroundStatsServerStarted();
|
|
||||||
|
|
||||||
assert.deepEqual(result, {
|
|
||||||
url: 'http://127.0.0.1:6970',
|
|
||||||
runningInCurrentProcess: true,
|
|
||||||
});
|
|
||||||
assert.equal(startedPort, 6970);
|
|
||||||
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
|
|
||||||
pid: 456,
|
|
||||||
port: 6970,
|
|
||||||
startedAtMs: 999,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stats runtime stops owned server and clears daemon state during quit cleanup', async () => {
|
|
||||||
await withTempDir(async (dir) => {
|
|
||||||
const statePath = path.join(dir, 'stats-daemon.json');
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const runtime = createStatsRuntime({
|
|
||||||
statsDaemonStatePath: statePath,
|
|
||||||
getResolvedConfig: () => ({
|
|
||||||
immersionTracking: { enabled: true },
|
|
||||||
stats: { serverPort: 6971 },
|
|
||||||
}),
|
|
||||||
getImmersionTracker: () => ({}) as never,
|
|
||||||
ensureImmersionTrackerStartedCore: () => {},
|
|
||||||
startStatsServer: () => ({
|
|
||||||
close: () => {
|
|
||||||
calls.push('close');
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
openExternal: async () => {},
|
|
||||||
exitAppWithCode: () => {},
|
|
||||||
destroyStatsWindow: () => {
|
|
||||||
calls.push('destroy-window');
|
|
||||||
},
|
|
||||||
logInfo: () => {},
|
|
||||||
logWarn: () => {},
|
|
||||||
logError: () => {},
|
|
||||||
getCurrentPid: () => 789,
|
|
||||||
isProcessAlive: () => true,
|
|
||||||
now: () => 500,
|
|
||||||
});
|
|
||||||
|
|
||||||
runtime.ensureBackgroundStatsServerStarted();
|
|
||||||
runtime.cleanupBeforeQuit();
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['destroy-window', 'close']);
|
|
||||||
assert.equal(fs.existsSync(statePath), false);
|
|
||||||
assert.equal(runtime.getStatsServer(), null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,469 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import {
|
|
||||||
isBackgroundStatsServerProcessAlive,
|
|
||||||
readBackgroundStatsServerState,
|
|
||||||
removeBackgroundStatsServerState,
|
|
||||||
resolveBackgroundStatsServerUrl,
|
|
||||||
writeBackgroundStatsServerState,
|
|
||||||
type BackgroundStatsServerState,
|
|
||||||
} from './runtime/stats-daemon';
|
|
||||||
import {
|
|
||||||
createRunStatsCliCommandHandler,
|
|
||||||
writeStatsCliCommandResponse,
|
|
||||||
} from './runtime/stats-cli-command';
|
|
||||||
import type { CliArgs, CliCommandSource } from '../cli/args';
|
|
||||||
import { ImmersionTrackerService } from '../core/services/immersion-tracker-service';
|
|
||||||
import { startStatsServer as startStatsServerCore } from '../core/services/stats-server';
|
|
||||||
import { createLogger } from '../logger';
|
|
||||||
import { createCoverArtFetcher } from '../core/services/anilist/cover-art-fetcher';
|
|
||||||
import { createAnilistRateLimiter } from '../core/services/anilist/rate-limiter';
|
|
||||||
import { resolveLegacyVocabularyPosFromTokens } from '../core/services/immersion-tracker/legacy-vocabulary-pos';
|
|
||||||
import type {
|
|
||||||
LifetimeRebuildSummary,
|
|
||||||
VocabularyCleanupSummary,
|
|
||||||
} from '../core/services/immersion-tracker/types';
|
|
||||||
import type { ResolvedConfig } from '../types';
|
|
||||||
import type { AppReadyImmersionInput } from './app-ready-runtime';
|
|
||||||
import { createImmersionTrackerStartupHandler } from './runtime/immersion-startup';
|
|
||||||
|
|
||||||
type StatsConfigLike = {
|
|
||||||
immersionTracking?: {
|
|
||||||
enabled?: boolean;
|
|
||||||
};
|
|
||||||
stats: {
|
|
||||||
serverPort: number;
|
|
||||||
autoOpenBrowser?: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type StatsServerLike = {
|
|
||||||
close: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StatsTrackerLike = {
|
|
||||||
cleanupVocabularyStats?: () => Promise<VocabularyCleanupSummary>;
|
|
||||||
rebuildLifetimeSummaries?: () => Promise<LifetimeRebuildSummary>;
|
|
||||||
recordCardsMined?: (count: number, noteIds?: number[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StatsBootstrapAppState = {
|
|
||||||
mecabTokenizer: {
|
|
||||||
tokenize: (text: string) => Promise<unknown[] | null>;
|
|
||||||
} | null;
|
|
||||||
immersionTracker: ImmersionTrackerService | null;
|
|
||||||
mpvClient: unknown | null;
|
|
||||||
mpvSocketPath: string;
|
|
||||||
ankiIntegration: {
|
|
||||||
resolveCurrentNoteId: (noteId: number) => number;
|
|
||||||
} | null;
|
|
||||||
statsOverlayVisible: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface StatsRuntimeInput<
|
|
||||||
TConfig extends StatsConfigLike = StatsConfigLike,
|
|
||||||
TTracker extends StatsTrackerLike = StatsTrackerLike,
|
|
||||||
TServer extends StatsServerLike = StatsServerLike,
|
|
||||||
> {
|
|
||||||
statsDaemonStatePath: string;
|
|
||||||
getResolvedConfig: () => TConfig;
|
|
||||||
getImmersionTracker: () => TTracker | null;
|
|
||||||
ensureImmersionTrackerStartedCore: () => void;
|
|
||||||
ensureVocabularyCleanupTokenizerReady?: () => Promise<void> | void;
|
|
||||||
startStatsServer: (port: number) => TServer;
|
|
||||||
openExternal: (url: string) => Promise<unknown>;
|
|
||||||
exitAppWithCode: (code: number) => void;
|
|
||||||
destroyStatsWindow?: () => void;
|
|
||||||
logInfo: (message: string) => void;
|
|
||||||
logWarn: (message: string, error?: unknown) => void;
|
|
||||||
logError: (message: string, error: unknown) => void;
|
|
||||||
now?: () => number;
|
|
||||||
getCurrentPid?: () => number;
|
|
||||||
isProcessAlive?: (pid: number) => boolean;
|
|
||||||
killProcess?: (pid: number, signal: NodeJS.Signals) => void;
|
|
||||||
wait?: (delayMs: number) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatsRuntime {
|
|
||||||
readLiveBackgroundStatsDaemonState: () => BackgroundStatsServerState | null;
|
|
||||||
ensureImmersionTrackerStarted: () => void;
|
|
||||||
ensureStatsServerStarted: () => string;
|
|
||||||
stopStatsServer: () => void;
|
|
||||||
ensureBackgroundStatsServerStarted: () => {
|
|
||||||
url: string;
|
|
||||||
runningInCurrentProcess: boolean;
|
|
||||||
};
|
|
||||||
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
|
|
||||||
runStatsCliCommand: (
|
|
||||||
args: Pick<
|
|
||||||
CliArgs,
|
|
||||||
| 'statsResponsePath'
|
|
||||||
| 'statsBackground'
|
|
||||||
| 'statsStop'
|
|
||||||
| 'statsCleanup'
|
|
||||||
| 'statsCleanupVocab'
|
|
||||||
| 'statsCleanupLifetime'
|
|
||||||
>,
|
|
||||||
source: CliCommandSource,
|
|
||||||
) => Promise<void>;
|
|
||||||
cleanupBeforeQuit: () => void;
|
|
||||||
getStatsServer: () => StatsServerLike | null;
|
|
||||||
isStatsStartupInProgress: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatsRuntimeBootstrapInput {
|
|
||||||
statsDaemonStatePath: string;
|
|
||||||
statsDistPath: string;
|
|
||||||
statsPreloadPath: string;
|
|
||||||
userDataPath: string;
|
|
||||||
appState: StatsBootstrapAppState;
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
dictionarySupport: {
|
|
||||||
getConfiguredDbPath: () => string;
|
|
||||||
seedImmersionMediaFromCurrentMedia: () => Promise<void> | void;
|
|
||||||
};
|
|
||||||
overlay: {
|
|
||||||
getOverlayGeometry: () => { getCurrentOverlayGeometry: () => Electron.Rectangle };
|
|
||||||
updateVisibleOverlayVisibility: () => void;
|
|
||||||
registerStatsOverlayToggle: (options: {
|
|
||||||
staticDir: string;
|
|
||||||
preloadPath: string;
|
|
||||||
getApiBaseUrl: () => string;
|
|
||||||
getToggleKey: () => string;
|
|
||||||
resolveBounds: () => Electron.Rectangle;
|
|
||||||
onVisibilityChanged: (visible: boolean) => void;
|
|
||||||
}) => void;
|
|
||||||
};
|
|
||||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
|
||||||
addYomitanNote: (word: string) => Promise<number | null>;
|
|
||||||
openExternal: (url: string) => Promise<unknown>;
|
|
||||||
requestAppQuit: () => void;
|
|
||||||
destroyStatsWindow: () => void;
|
|
||||||
logger: {
|
|
||||||
info: (message: string) => void;
|
|
||||||
warn: (message: string, error?: unknown) => void;
|
|
||||||
error: (message: string, error?: unknown) => void;
|
|
||||||
debug: (message: string, details?: unknown) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatsRuntimeBootstrap {
|
|
||||||
stats: StatsRuntime;
|
|
||||||
immersion: AppReadyImmersionInput;
|
|
||||||
recordTrackedCardsMined: (count: number, noteIds?: number[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStatsRuntime<
|
|
||||||
TConfig extends StatsConfigLike,
|
|
||||||
TTracker extends StatsTrackerLike,
|
|
||||||
TServer extends StatsServerLike,
|
|
||||||
>(input: StatsRuntimeInput<TConfig, TTracker, TServer>): StatsRuntime {
|
|
||||||
const now = input.now ?? Date.now;
|
|
||||||
const getCurrentPid = input.getCurrentPid ?? (() => process.pid);
|
|
||||||
const isProcessAlive = input.isProcessAlive ?? isBackgroundStatsServerProcessAlive;
|
|
||||||
const killProcess =
|
|
||||||
input.killProcess ??
|
|
||||||
((pid: number, signal: NodeJS.Signals) => {
|
|
||||||
process.kill(pid, signal);
|
|
||||||
});
|
|
||||||
const wait =
|
|
||||||
input.wait ??
|
|
||||||
(async (delayMs: number) => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
||||||
});
|
|
||||||
|
|
||||||
let statsServer: TServer | null = null;
|
|
||||||
let statsStartupInProgress = false;
|
|
||||||
let hasAttemptedImmersionTrackerStartup = false;
|
|
||||||
|
|
||||||
const readLiveBackgroundStatsDaemonState = (): BackgroundStatsServerState | null => {
|
|
||||||
const state = readBackgroundStatsServerState(input.statsDaemonStatePath);
|
|
||||||
if (!state) {
|
|
||||||
removeBackgroundStatsServerState(input.statsDaemonStatePath);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (state.pid === getCurrentPid() && !statsServer) {
|
|
||||||
removeBackgroundStatsServerState(input.statsDaemonStatePath);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!isProcessAlive(state.pid)) {
|
|
||||||
removeBackgroundStatsServerState(input.statsDaemonStatePath);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearOwnedBackgroundStatsDaemonState = (): void => {
|
|
||||||
const state = readBackgroundStatsServerState(input.statsDaemonStatePath);
|
|
||||||
if (state?.pid === getCurrentPid()) {
|
|
||||||
removeBackgroundStatsServerState(input.statsDaemonStatePath);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopStatsServer = (): void => {
|
|
||||||
if (!statsServer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
statsServer.close();
|
|
||||||
statsServer = null;
|
|
||||||
clearOwnedBackgroundStatsDaemonState();
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureImmersionTrackerStarted = (): void => {
|
|
||||||
if (hasAttemptedImmersionTrackerStartup || input.getImmersionTracker()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
hasAttemptedImmersionTrackerStartup = true;
|
|
||||||
statsStartupInProgress = true;
|
|
||||||
try {
|
|
||||||
input.ensureImmersionTrackerStartedCore();
|
|
||||||
} finally {
|
|
||||||
statsStartupInProgress = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureStatsServerStarted = (): string => {
|
|
||||||
const liveDaemon = readLiveBackgroundStatsDaemonState();
|
|
||||||
if (liveDaemon && liveDaemon.pid !== getCurrentPid()) {
|
|
||||||
return resolveBackgroundStatsServerUrl(liveDaemon);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tracker = input.getImmersionTracker();
|
|
||||||
if (!tracker) {
|
|
||||||
throw new Error('Immersion tracker failed to initialize.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!statsServer) {
|
|
||||||
statsServer = input.startStatsServer(input.getResolvedConfig().stats.serverPort);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `http://127.0.0.1:${input.getResolvedConfig().stats.serverPort}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureBackgroundStatsServerStarted = (): {
|
|
||||||
url: string;
|
|
||||||
runningInCurrentProcess: boolean;
|
|
||||||
} => {
|
|
||||||
const liveDaemon = readLiveBackgroundStatsDaemonState();
|
|
||||||
if (liveDaemon && liveDaemon.pid !== getCurrentPid()) {
|
|
||||||
return {
|
|
||||||
url: resolveBackgroundStatsServerUrl(liveDaemon),
|
|
||||||
runningInCurrentProcess: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureImmersionTrackerStarted();
|
|
||||||
const url = ensureStatsServerStarted();
|
|
||||||
writeBackgroundStatsServerState(input.statsDaemonStatePath, {
|
|
||||||
pid: getCurrentPid(),
|
|
||||||
port: input.getResolvedConfig().stats.serverPort,
|
|
||||||
startedAtMs: now(),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
runningInCurrentProcess: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
|
|
||||||
const state = readBackgroundStatsServerState(input.statsDaemonStatePath);
|
|
||||||
if (!state) {
|
|
||||||
removeBackgroundStatsServerState(input.statsDaemonStatePath);
|
|
||||||
return { ok: true, stale: true };
|
|
||||||
}
|
|
||||||
if (!isProcessAlive(state.pid)) {
|
|
||||||
removeBackgroundStatsServerState(input.statsDaemonStatePath);
|
|
||||||
return { ok: true, stale: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
killProcess(state.pid, 'SIGTERM');
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') {
|
|
||||||
removeBackgroundStatsServerState(input.statsDaemonStatePath);
|
|
||||||
return { ok: true, stale: true };
|
|
||||||
}
|
|
||||||
if ((error as NodeJS.ErrnoException)?.code === 'EPERM') {
|
|
||||||
throw new Error(
|
|
||||||
`Insufficient permissions to stop background stats server (pid ${state.pid}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deadline = now() + 2_000;
|
|
||||||
while (now() < deadline) {
|
|
||||||
if (!isProcessAlive(state.pid)) {
|
|
||||||
removeBackgroundStatsServerState(input.statsDaemonStatePath);
|
|
||||||
return { ok: true, stale: false };
|
|
||||||
}
|
|
||||||
await wait(50);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Timed out stopping background stats server.');
|
|
||||||
};
|
|
||||||
|
|
||||||
const runStatsCliCommand = createRunStatsCliCommandHandler({
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(),
|
|
||||||
ensureVocabularyCleanupTokenizerReady: input.ensureVocabularyCleanupTokenizerReady,
|
|
||||||
getImmersionTracker: () => input.getImmersionTracker(),
|
|
||||||
ensureStatsServerStarted: () => ensureStatsServerStarted(),
|
|
||||||
ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(),
|
|
||||||
stopBackgroundStatsServer: () => stopBackgroundStatsServer(),
|
|
||||||
openExternal: async (url) => await input.openExternal(url),
|
|
||||||
writeResponse: (responsePath, payload) => {
|
|
||||||
writeStatsCliCommandResponse(responsePath, payload);
|
|
||||||
},
|
|
||||||
exitAppWithCode: (code) => input.exitAppWithCode(code),
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
logWarn: (message, error) => input.logWarn(message, error),
|
|
||||||
logError: (message, error) => input.logError(message, error),
|
|
||||||
});
|
|
||||||
|
|
||||||
const cleanupBeforeQuit = (): void => {
|
|
||||||
input.destroyStatsWindow?.();
|
|
||||||
stopStatsServer();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
readLiveBackgroundStatsDaemonState,
|
|
||||||
ensureImmersionTrackerStarted,
|
|
||||||
ensureStatsServerStarted,
|
|
||||||
stopStatsServer,
|
|
||||||
ensureBackgroundStatsServerStarted,
|
|
||||||
stopBackgroundStatsServer,
|
|
||||||
runStatsCliCommand,
|
|
||||||
cleanupBeforeQuit,
|
|
||||||
getStatsServer: () => statsServer,
|
|
||||||
isStatsStartupInProgress: () => statsStartupInProgress,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStatsRuntimeBootstrap(
|
|
||||||
input: StatsRuntimeBootstrapInput,
|
|
||||||
): StatsRuntimeBootstrap {
|
|
||||||
const statsCoverArtFetcher = createCoverArtFetcher(
|
|
||||||
createAnilistRateLimiter(),
|
|
||||||
createLogger('main:stats-cover-art'),
|
|
||||||
);
|
|
||||||
const resolveLegacyVocabularyPos = async (row: {
|
|
||||||
headword: string;
|
|
||||||
word: string;
|
|
||||||
reading: string | null;
|
|
||||||
}) => {
|
|
||||||
const tokenizer = input.appState.mecabTokenizer;
|
|
||||||
if (!tokenizer) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lookupTexts = [...new Set([row.headword, row.word, row.reading ?? ''])]
|
|
||||||
.map((value) => value.trim())
|
|
||||||
.filter((value) => value.length > 0);
|
|
||||||
|
|
||||||
for (const lookupText of lookupTexts) {
|
|
||||||
const tokens = await tokenizer.tokenize(lookupText);
|
|
||||||
if (!tokens) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const resolved = resolveLegacyVocabularyPosFromTokens(lookupText, tokens as never);
|
|
||||||
if (resolved) {
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
let stats: StatsRuntime | null = null;
|
|
||||||
const immersionMainDeps: Parameters<typeof createImmersionTrackerStartupHandler>[0] = {
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
getConfiguredDbPath: () => input.dictionarySupport.getConfiguredDbPath(),
|
|
||||||
createTrackerService: (params) =>
|
|
||||||
new ImmersionTrackerService({
|
|
||||||
...params,
|
|
||||||
resolveLegacyVocabularyPos,
|
|
||||||
}),
|
|
||||||
setTracker: (tracker) => {
|
|
||||||
const trackerHasChanged =
|
|
||||||
input.appState.immersionTracker !== null && input.appState.immersionTracker !== tracker;
|
|
||||||
if (trackerHasChanged && stats?.getStatsServer()) {
|
|
||||||
stats.stopStatsServer();
|
|
||||||
}
|
|
||||||
|
|
||||||
input.appState.immersionTracker = tracker as ImmersionTrackerService | null;
|
|
||||||
input.appState.immersionTracker?.setCoverArtFetcher?.(statsCoverArtFetcher);
|
|
||||||
if (!tracker) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stats?.getStatsServer() && input.getResolvedConfig().stats.autoStartServer) {
|
|
||||||
stats?.ensureStatsServerStarted();
|
|
||||||
}
|
|
||||||
|
|
||||||
input.overlay.registerStatsOverlayToggle({
|
|
||||||
staticDir: input.statsDistPath,
|
|
||||||
preloadPath: input.statsPreloadPath,
|
|
||||||
getApiBaseUrl: () => stats!.ensureStatsServerStarted(),
|
|
||||||
getToggleKey: () => input.getResolvedConfig().stats.toggleKey,
|
|
||||||
resolveBounds: () => input.overlay.getOverlayGeometry().getCurrentOverlayGeometry(),
|
|
||||||
onVisibilityChanged: (visible) => {
|
|
||||||
input.appState.statsOverlayVisible = visible;
|
|
||||||
input.overlay.updateVisibleOverlayVisibility();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getMpvClient: () => input.appState.mpvClient as never,
|
|
||||||
shouldAutoConnectMpv: () => !stats?.isStatsStartupInProgress(),
|
|
||||||
seedTrackerFromCurrentMedia: () => {
|
|
||||||
void input.dictionarySupport.seedImmersionMediaFromCurrentMedia();
|
|
||||||
},
|
|
||||||
logInfo: (message) => input.logger.info(message),
|
|
||||||
logDebug: (message) => input.logger.debug(message),
|
|
||||||
logWarn: (message, details) => input.logger.warn(message, details),
|
|
||||||
};
|
|
||||||
const createImmersionTrackerStartup = createImmersionTrackerStartupHandler(immersionMainDeps);
|
|
||||||
|
|
||||||
stats = createStatsRuntime({
|
|
||||||
statsDaemonStatePath: input.statsDaemonStatePath,
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
getImmersionTracker: () => input.appState.immersionTracker,
|
|
||||||
ensureImmersionTrackerStartedCore: () => {
|
|
||||||
createImmersionTrackerStartup();
|
|
||||||
},
|
|
||||||
ensureVocabularyCleanupTokenizerReady: async () => {
|
|
||||||
await input.createMecabTokenizerAndCheck();
|
|
||||||
},
|
|
||||||
startStatsServer: (port) =>
|
|
||||||
startStatsServerCore({
|
|
||||||
port,
|
|
||||||
staticDir: input.statsDistPath,
|
|
||||||
tracker: input.appState.immersionTracker as ImmersionTrackerService,
|
|
||||||
knownWordCachePath: path.join(input.userDataPath, 'known-words-cache.json'),
|
|
||||||
mpvSocketPath: input.appState.mpvSocketPath,
|
|
||||||
ankiConnectConfig: input.getResolvedConfig().ankiConnect,
|
|
||||||
resolveAnkiNoteId: (noteId: number) =>
|
|
||||||
input.appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
|
|
||||||
addYomitanNote: (word: string) => input.addYomitanNote(word),
|
|
||||||
}),
|
|
||||||
openExternal: (url) => input.openExternal(url),
|
|
||||||
exitAppWithCode: (code) => {
|
|
||||||
process.exitCode = code;
|
|
||||||
input.requestAppQuit();
|
|
||||||
},
|
|
||||||
destroyStatsWindow: () => {
|
|
||||||
input.destroyStatsWindow();
|
|
||||||
},
|
|
||||||
logInfo: (message) => input.logger.info(message),
|
|
||||||
logWarn: (message, error) => input.logger.warn(message, error),
|
|
||||||
logError: (message, error) => input.logger.error(message, error),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
stats,
|
|
||||||
immersion: immersionMainDeps as AppReadyImmersionInput,
|
|
||||||
recordTrackedCardsMined: (count, noteIds) => {
|
|
||||||
stats.ensureImmersionTrackerStarted();
|
|
||||||
input.appState.immersionTracker?.recordCardsMined?.(count, noteIds);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,451 +0,0 @@
|
|||||||
import * as path from 'node:path';
|
|
||||||
|
|
||||||
import type { SubtitleCue } from '../core/services/subtitle-cue-parser';
|
|
||||||
import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater';
|
|
||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
|
||||||
import type {
|
|
||||||
FrequencyDictionaryLookup,
|
|
||||||
ResolvedConfig,
|
|
||||||
SubtitleData,
|
|
||||||
SubtitlePosition,
|
|
||||||
} from '../types';
|
|
||||||
import {
|
|
||||||
deleteYomitanDictionaryByTitle,
|
|
||||||
getYomitanDictionaryInfo,
|
|
||||||
importYomitanDictionaryFromZip,
|
|
||||||
upsertYomitanDictionarySettings,
|
|
||||||
clearYomitanParserCachesForWindow,
|
|
||||||
} from '../core/services';
|
|
||||||
import type { YomitanParserRuntimeDeps } from './yomitan-runtime';
|
|
||||||
import {
|
|
||||||
createDictionarySupportRuntime,
|
|
||||||
type DictionarySupportRuntime,
|
|
||||||
} from './dictionary-support-runtime';
|
|
||||||
import {
|
|
||||||
createDictionarySupportRuntimeInput,
|
|
||||||
type DictionarySupportRuntimeInputBuilderInput,
|
|
||||||
} from './dictionary-support-runtime-input';
|
|
||||||
import {
|
|
||||||
createSubtitleRuntime,
|
|
||||||
type SubtitleRuntime,
|
|
||||||
type SubtitleRuntimeInput,
|
|
||||||
} from './subtitle-runtime';
|
|
||||||
import type { JlptLookup } from './jlpt-runtime';
|
|
||||||
import { formatSkippedYomitanWriteAction } from './runtime/yomitan-read-only-log';
|
|
||||||
|
|
||||||
type BrowserWindowLike = {
|
|
||||||
isDestroyed: () => boolean;
|
|
||||||
webContents: {
|
|
||||||
send: (channel: string, payload?: unknown) => void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type ImmersionTrackerLike = {
|
|
||||||
handleMediaChange: (path: string, title: string | null) => void;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
type MpvClientLike = {
|
|
||||||
connected?: boolean;
|
|
||||||
currentSubStart?: number | null;
|
|
||||||
currentSubEnd?: number | null;
|
|
||||||
currentTimePos?: number | null;
|
|
||||||
currentVideoPath?: string | null;
|
|
||||||
requestProperty: (name: string) => Promise<unknown>;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
type OverlayUiLike = {
|
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
|
||||||
sendToActiveOverlayWindow: (
|
|
||||||
channel: string,
|
|
||||||
payload?: unknown,
|
|
||||||
runtimeOptions?: {
|
|
||||||
restoreOnModalClose?: OverlayHostedModal;
|
|
||||||
preferModalWindow?: boolean;
|
|
||||||
},
|
|
||||||
) => boolean;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
type OverlayManagerLike = {
|
|
||||||
broadcastToOverlayWindows: (channel: string, payload?: unknown) => void;
|
|
||||||
getMainWindow: () => BrowserWindowLike | null;
|
|
||||||
getVisibleOverlayVisible: () => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StartupOsdSequencerLike = NonNullable<
|
|
||||||
DictionarySupportRuntimeInputBuilderInput['startup']['startupOsdSequencer']
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface SubtitleDictionaryRuntimeInput {
|
|
||||||
env: {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
dirname: string;
|
|
||||||
appPath: string;
|
|
||||||
resourcesPath: string;
|
|
||||||
userDataPath: string;
|
|
||||||
appUserDataPath: string;
|
|
||||||
homeDir: string;
|
|
||||||
appDataDir?: string;
|
|
||||||
cwd: string;
|
|
||||||
configDir: string;
|
|
||||||
defaultImmersionDbPath: string;
|
|
||||||
};
|
|
||||||
appState: {
|
|
||||||
currentMediaPath: string | null;
|
|
||||||
currentMediaTitle: string | null;
|
|
||||||
currentSubText: string;
|
|
||||||
currentSubAssText: string;
|
|
||||||
mpvClient: MpvClientLike;
|
|
||||||
subtitlePosition: SubtitlePosition | null;
|
|
||||||
pendingSubtitlePosition: SubtitlePosition | null;
|
|
||||||
currentSubtitleData: SubtitleData | null;
|
|
||||||
activeParsedSubtitleCues: SubtitleCue[];
|
|
||||||
activeParsedSubtitleSource: string | null;
|
|
||||||
immersionTracker: ImmersionTrackerLike;
|
|
||||||
jlptLevelLookup: JlptLookup;
|
|
||||||
frequencyRankLookup: FrequencyDictionaryLookup;
|
|
||||||
yomitanParserWindow: BrowserWindowLike | null;
|
|
||||||
};
|
|
||||||
config: {
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
};
|
|
||||||
services: {
|
|
||||||
subtitleWsService: SubtitleRuntimeInput['subtitleWsService'];
|
|
||||||
annotationSubtitleWsService: SubtitleRuntimeInput['annotationSubtitleWsService'];
|
|
||||||
overlayManager: OverlayManagerLike;
|
|
||||||
startupOsdSequencer: StartupOsdSequencerLike;
|
|
||||||
};
|
|
||||||
logging: {
|
|
||||||
debug: (message: string) => void;
|
|
||||||
info: (message: string) => void;
|
|
||||||
warn: (message: string) => void;
|
|
||||||
error: (message: string, ...args: unknown[]) => void;
|
|
||||||
};
|
|
||||||
subtitle: {
|
|
||||||
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
|
|
||||||
createSubtitlePrefetchService: SubtitleRuntimeInput['createSubtitlePrefetchService'];
|
|
||||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
|
||||||
clearSchedule: (timer: ReturnType<typeof setTimeout>) => void;
|
|
||||||
};
|
|
||||||
overlay: {
|
|
||||||
getOverlayUi: () => OverlayUiLike;
|
|
||||||
showMpvOsd: (message: string) => void;
|
|
||||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
|
||||||
};
|
|
||||||
playback: {
|
|
||||||
isRemoteMediaPath: (mediaPath: string) => boolean;
|
|
||||||
isYoutubePlaybackActive: (mediaPath: string | null, videoPath: string | null) => boolean;
|
|
||||||
waitForYomitanMutationReady: (mediaKey: string | null) => Promise<void>;
|
|
||||||
};
|
|
||||||
anilist: {
|
|
||||||
guessAnilistMediaInfo: (
|
|
||||||
mediaPath: string | null,
|
|
||||||
mediaTitle: string | null,
|
|
||||||
) => Promise<AnilistMediaGuess | null>;
|
|
||||||
};
|
|
||||||
yomitan: {
|
|
||||||
isCharacterDictionaryEnabled: () => boolean;
|
|
||||||
getYomitanDictionaryInfo: () => Promise<Array<{ title: string; revision?: string | number }>>;
|
|
||||||
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
|
||||||
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
|
|
||||||
upsertYomitanDictionarySettings: (
|
|
||||||
dictionaryTitle: string,
|
|
||||||
profileScope: ResolvedConfig['anilist']['characterDictionary']['profileScope'],
|
|
||||||
) => Promise<boolean>;
|
|
||||||
hasParserWindow: () => boolean;
|
|
||||||
clearParserCaches: () => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubtitleDictionaryRuntime {
|
|
||||||
subtitle: SubtitleRuntime;
|
|
||||||
dictionarySupport: DictionarySupportRuntime;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubtitleDictionaryRuntimeCoordinatorInput {
|
|
||||||
env: SubtitleDictionaryRuntimeInput['env'];
|
|
||||||
appState: SubtitleDictionaryRuntimeInput['appState'];
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
services: SubtitleDictionaryRuntimeInput['services'];
|
|
||||||
logging: {
|
|
||||||
debug: (message: string, ...args: unknown[]) => void;
|
|
||||||
info: (message: string, ...args: unknown[]) => void;
|
|
||||||
warn: (message: string, ...args: unknown[]) => void;
|
|
||||||
error: (message: string, ...args: unknown[]) => void;
|
|
||||||
};
|
|
||||||
overlay: SubtitleDictionaryRuntimeInput['overlay'];
|
|
||||||
playback: SubtitleDictionaryRuntimeInput['playback'];
|
|
||||||
anilist: SubtitleDictionaryRuntimeInput['anilist'];
|
|
||||||
subtitle: {
|
|
||||||
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
|
|
||||||
createSubtitlePrefetchService: SubtitleRuntimeInput['createSubtitlePrefetchService'];
|
|
||||||
};
|
|
||||||
yomitan: {
|
|
||||||
isCharacterDictionaryEnabled: () => boolean;
|
|
||||||
isExternalReadOnlyMode: () => boolean;
|
|
||||||
logSkippedWrite: (message: string) => void;
|
|
||||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
|
||||||
getParserRuntimeDeps: () => YomitanParserRuntimeDeps;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSubtitleDictionaryRuntime(
|
|
||||||
input: SubtitleDictionaryRuntimeInput,
|
|
||||||
): SubtitleDictionaryRuntime {
|
|
||||||
const subtitlePositionsDir = path.join(input.env.configDir, 'subtitle-positions');
|
|
||||||
|
|
||||||
const subtitle = createSubtitleRuntime({
|
|
||||||
getResolvedConfig: () => input.config.getResolvedConfig(),
|
|
||||||
getCurrentMediaPath: () => input.appState.currentMediaPath,
|
|
||||||
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
|
|
||||||
getCurrentSubText: () => input.appState.currentSubText,
|
|
||||||
getCurrentSubAssText: () => input.appState.currentSubAssText,
|
|
||||||
getMpvClient: () => input.appState.mpvClient,
|
|
||||||
subtitleWsService: input.services.subtitleWsService,
|
|
||||||
annotationSubtitleWsService: input.services.annotationSubtitleWsService,
|
|
||||||
broadcastToOverlayWindows: (channel, payload) =>
|
|
||||||
input.services.overlayManager.broadcastToOverlayWindows(channel, payload),
|
|
||||||
subtitlePositionsDir,
|
|
||||||
setSubtitlePosition: (position) => {
|
|
||||||
input.appState.subtitlePosition = position;
|
|
||||||
},
|
|
||||||
setPendingSubtitlePosition: (position) => {
|
|
||||||
input.appState.pendingSubtitlePosition = position;
|
|
||||||
},
|
|
||||||
clearPendingSubtitlePosition: () => {
|
|
||||||
input.appState.pendingSubtitlePosition = null;
|
|
||||||
},
|
|
||||||
setCurrentSubtitleData: (payload) => {
|
|
||||||
input.appState.currentSubtitleData = payload;
|
|
||||||
},
|
|
||||||
setActiveParsedSubtitleState: (cues, sourceKey) => {
|
|
||||||
input.appState.activeParsedSubtitleCues = cues;
|
|
||||||
input.appState.activeParsedSubtitleSource = sourceKey;
|
|
||||||
},
|
|
||||||
parseSubtitleCues: (content, filename) => input.subtitle.parseSubtitleCues(content, filename),
|
|
||||||
createSubtitlePrefetchService: (deps) => input.subtitle.createSubtitlePrefetchService(deps),
|
|
||||||
schedule: (callback, delayMs) => input.subtitle.schedule(callback, delayMs),
|
|
||||||
clearSchedule: (timer) => input.subtitle.clearSchedule(timer),
|
|
||||||
logDebug: (message) => input.logging.debug(message),
|
|
||||||
logInfo: (message) => input.logging.info(message),
|
|
||||||
logWarn: (message) => input.logging.warn(message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const dictionarySupport = createDictionarySupportRuntime<OverlayHostedModal>(
|
|
||||||
createDictionarySupportRuntimeInput({
|
|
||||||
env: {
|
|
||||||
platform: input.env.platform,
|
|
||||||
dirname: input.env.dirname,
|
|
||||||
appPath: input.env.appPath,
|
|
||||||
resourcesPath: input.env.resourcesPath,
|
|
||||||
userDataPath: input.env.userDataPath,
|
|
||||||
appUserDataPath: input.env.appUserDataPath,
|
|
||||||
homeDir: input.env.homeDir,
|
|
||||||
appDataDir: input.env.appDataDir,
|
|
||||||
cwd: input.env.cwd,
|
|
||||||
subtitlePositionsDir,
|
|
||||||
defaultImmersionDbPath: input.env.defaultImmersionDbPath,
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
getResolvedConfig: () => input.config.getResolvedConfig(),
|
|
||||||
},
|
|
||||||
dictionaryState: {
|
|
||||||
setJlptLevelLookup: (lookup) => {
|
|
||||||
input.appState.jlptLevelLookup = lookup;
|
|
||||||
},
|
|
||||||
setFrequencyRankLookup: (lookup) => {
|
|
||||||
input.appState.frequencyRankLookup = lookup;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logger: {
|
|
||||||
info: (message) => input.logging.info(message),
|
|
||||||
debug: (message) => input.logging.debug(message),
|
|
||||||
warn: (message) => input.logging.warn(message),
|
|
||||||
error: (message, ...args) => input.logging.error(message, ...args),
|
|
||||||
},
|
|
||||||
media: {
|
|
||||||
isRemoteMediaPath: (mediaPath) => input.playback.isRemoteMediaPath(mediaPath),
|
|
||||||
getCurrentMediaPath: () => input.appState.currentMediaPath,
|
|
||||||
setCurrentMediaPath: (mediaPath) => {
|
|
||||||
input.appState.currentMediaPath = mediaPath;
|
|
||||||
},
|
|
||||||
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
|
|
||||||
setCurrentMediaTitle: (title) => {
|
|
||||||
input.appState.currentMediaTitle = title;
|
|
||||||
},
|
|
||||||
getPendingSubtitlePosition: () => input.appState.pendingSubtitlePosition,
|
|
||||||
clearPendingSubtitlePosition: () => {
|
|
||||||
input.appState.pendingSubtitlePosition = null;
|
|
||||||
},
|
|
||||||
setSubtitlePosition: (position) => {
|
|
||||||
input.appState.subtitlePosition = position;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
loadSubtitlePosition: () => subtitle.loadSubtitlePosition(),
|
|
||||||
invalidateTokenizationCache: () => {
|
|
||||||
subtitle.invalidateTokenizationCache();
|
|
||||||
},
|
|
||||||
refreshSubtitlePrefetchFromActiveTrack: () => {
|
|
||||||
subtitle.refreshSubtitlePrefetchFromActiveTrack();
|
|
||||||
},
|
|
||||||
refreshCurrentSubtitle: (text) => {
|
|
||||||
subtitle.refreshCurrentSubtitle(text);
|
|
||||||
},
|
|
||||||
getCurrentSubtitleText: () => input.appState.currentSubText,
|
|
||||||
},
|
|
||||||
overlay: {
|
|
||||||
broadcastSubtitlePosition: (position) => {
|
|
||||||
input.services.overlayManager.broadcastToOverlayWindows('subtitle:position', position);
|
|
||||||
},
|
|
||||||
broadcastToOverlayWindows: (channel, payload) => {
|
|
||||||
input.services.overlayManager.broadcastToOverlayWindows(channel, payload);
|
|
||||||
},
|
|
||||||
getMainWindow: () => input.services.overlayManager.getMainWindow(),
|
|
||||||
getVisibleOverlayVisible: () => input.services.overlayManager.getVisibleOverlayVisible(),
|
|
||||||
setVisibleOverlayVisible: (visible) => {
|
|
||||||
input.overlay.getOverlayUi()?.setVisibleOverlayVisible(visible);
|
|
||||||
},
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () =>
|
|
||||||
input.overlay.getOverlayUi()?.getRestoreVisibleOverlayOnModalClose() ??
|
|
||||||
new Set<OverlayHostedModal>(),
|
|
||||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
|
||||||
input.overlay
|
|
||||||
.getOverlayUi()
|
|
||||||
?.sendToActiveOverlayWindow(channel, payload, runtimeOptions) ?? false,
|
|
||||||
},
|
|
||||||
tracker: {
|
|
||||||
getTracker: () => input.appState.immersionTracker,
|
|
||||||
getMpvClient: () => input.appState.mpvClient,
|
|
||||||
},
|
|
||||||
anilist: {
|
|
||||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
|
|
||||||
input.anilist.guessAnilistMediaInfo(mediaPath, mediaTitle),
|
|
||||||
},
|
|
||||||
yomitan: {
|
|
||||||
isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(),
|
|
||||||
getYomitanDictionaryInfo: () => input.yomitan.getYomitanDictionaryInfo(),
|
|
||||||
importYomitanDictionary: (zipPath) => input.yomitan.importYomitanDictionary(zipPath),
|
|
||||||
deleteYomitanDictionary: (dictionaryTitle) =>
|
|
||||||
input.yomitan.deleteYomitanDictionary(dictionaryTitle),
|
|
||||||
upsertYomitanDictionarySettings: (dictionaryTitle, profileScope) =>
|
|
||||||
input.yomitan.upsertYomitanDictionarySettings(dictionaryTitle, profileScope),
|
|
||||||
hasParserWindow: () => input.yomitan.hasParserWindow(),
|
|
||||||
clearParserCaches: () => input.yomitan.clearParserCaches(),
|
|
||||||
},
|
|
||||||
startup: {
|
|
||||||
getNotificationType: () =>
|
|
||||||
input.config.getResolvedConfig().ankiConnect.behavior.notificationType,
|
|
||||||
showMpvOsd: (message) => input.overlay.showMpvOsd(message),
|
|
||||||
showDesktopNotification: (title, options) =>
|
|
||||||
input.overlay.showDesktopNotification(title, options),
|
|
||||||
startupOsdSequencer: input.services.startupOsdSequencer,
|
|
||||||
},
|
|
||||||
playback: {
|
|
||||||
isYoutubePlaybackActiveNow: () =>
|
|
||||||
input.playback.isYoutubePlaybackActive(
|
|
||||||
input.appState.currentMediaPath,
|
|
||||||
input.appState.mpvClient?.currentVideoPath ?? null,
|
|
||||||
),
|
|
||||||
waitForYomitanMutationReady: () =>
|
|
||||||
input.playback.waitForYomitanMutationReady(
|
|
||||||
input.appState.currentMediaPath?.trim() ||
|
|
||||||
input.appState.mpvClient?.currentVideoPath?.trim() ||
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
subtitle,
|
|
||||||
dictionarySupport,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSubtitleDictionaryRuntimeCoordinator(
|
|
||||||
input: SubtitleDictionaryRuntimeCoordinatorInput,
|
|
||||||
): SubtitleDictionaryRuntime {
|
|
||||||
return createSubtitleDictionaryRuntime({
|
|
||||||
env: input.env,
|
|
||||||
appState: input.appState,
|
|
||||||
config: {
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
},
|
|
||||||
services: input.services,
|
|
||||||
logging: input.logging,
|
|
||||||
subtitle: {
|
|
||||||
parseSubtitleCues: (content, filename) => input.subtitle.parseSubtitleCues(content, filename),
|
|
||||||
createSubtitlePrefetchService: (deps) => input.subtitle.createSubtitlePrefetchService(deps),
|
|
||||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
||||||
clearSchedule: (timer) => clearTimeout(timer),
|
|
||||||
},
|
|
||||||
overlay: input.overlay,
|
|
||||||
playback: input.playback,
|
|
||||||
anilist: input.anilist,
|
|
||||||
yomitan: {
|
|
||||||
isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(),
|
|
||||||
getYomitanDictionaryInfo: async () => {
|
|
||||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
|
||||||
return await getYomitanDictionaryInfo(input.yomitan.getParserRuntimeDeps(), {
|
|
||||||
error: (message, ...args) => input.logging.error(message, ...args),
|
|
||||||
info: (message, ...args) => input.logging.info(message, ...args),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
importYomitanDictionary: async (zipPath) => {
|
|
||||||
if (input.yomitan.isExternalReadOnlyMode()) {
|
|
||||||
input.yomitan.logSkippedWrite(
|
|
||||||
formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath),
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
|
||||||
return await importYomitanDictionaryFromZip(zipPath, input.yomitan.getParserRuntimeDeps(), {
|
|
||||||
error: (message, ...args) => input.logging.error(message, ...args),
|
|
||||||
info: (message, ...args) => input.logging.info(message, ...args),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
deleteYomitanDictionary: async (dictionaryTitle) => {
|
|
||||||
if (input.yomitan.isExternalReadOnlyMode()) {
|
|
||||||
input.yomitan.logSkippedWrite(
|
|
||||||
formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle),
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
|
||||||
return await deleteYomitanDictionaryByTitle(
|
|
||||||
dictionaryTitle,
|
|
||||||
input.yomitan.getParserRuntimeDeps(),
|
|
||||||
{
|
|
||||||
error: (message, ...args) => input.logging.error(message, ...args),
|
|
||||||
info: (message, ...args) => input.logging.info(message, ...args),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
|
|
||||||
if (input.yomitan.isExternalReadOnlyMode()) {
|
|
||||||
input.yomitan.logSkippedWrite(
|
|
||||||
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle),
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
|
||||||
return await upsertYomitanDictionarySettings(
|
|
||||||
dictionaryTitle,
|
|
||||||
profileScope,
|
|
||||||
input.yomitan.getParserRuntimeDeps(),
|
|
||||||
{
|
|
||||||
error: (message, ...args) => input.logging.error(message, ...args),
|
|
||||||
info: (message, ...args) => input.logging.info(message, ...args),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
hasParserWindow: () => Boolean(input.appState.yomitanParserWindow),
|
|
||||||
clearParserCaches: () => {
|
|
||||||
if (input.appState.yomitanParserWindow) {
|
|
||||||
clearYomitanParserCachesForWindow(input.appState.yomitanParserWindow as never);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import * as fs from 'node:fs';
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import * as os from 'node:os';
|
|
||||||
import * as path from 'node:path';
|
|
||||||
|
|
||||||
import { codecToExtension } from '../subsync/utils';
|
|
||||||
import { resolveSubtitleSourcePath } from './runtime/subtitle-prefetch-source';
|
|
||||||
|
|
||||||
export type MpvSubtitleTrackLike = {
|
|
||||||
type?: unknown;
|
|
||||||
id?: unknown;
|
|
||||||
codec?: unknown;
|
|
||||||
external?: unknown;
|
|
||||||
'ff-index'?: unknown;
|
|
||||||
'external-filename'?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_SUBTITLE_SOURCE_FETCH_TIMEOUT_MS = 4000;
|
|
||||||
|
|
||||||
function parseTrackId(value: unknown): number | null {
|
|
||||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const parsed = Number(value.trim());
|
|
||||||
return Number.isInteger(parsed) ? parsed : null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFfmpegSubtitleExtractionArgs(
|
|
||||||
videoPath: string,
|
|
||||||
ffIndex: number,
|
|
||||||
outputPath: string,
|
|
||||||
): string[] {
|
|
||||||
return [
|
|
||||||
'-hide_banner',
|
|
||||||
'-nostdin',
|
|
||||||
'-y',
|
|
||||||
'-loglevel',
|
|
||||||
'error',
|
|
||||||
'-an',
|
|
||||||
'-vn',
|
|
||||||
'-i',
|
|
||||||
videoPath,
|
|
||||||
'-map',
|
|
||||||
`0:${ffIndex}`,
|
|
||||||
'-f',
|
|
||||||
path.extname(outputPath).slice(1),
|
|
||||||
outputPath,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSubtitleSourceLoader(options?: {
|
|
||||||
fetchImpl?: typeof fetch;
|
|
||||||
subtitleSourceFetchTimeoutMs?: number;
|
|
||||||
}): (source: string) => Promise<string> {
|
|
||||||
const fetchImpl = options?.fetchImpl ?? fetch;
|
|
||||||
const timeoutMs =
|
|
||||||
options?.subtitleSourceFetchTimeoutMs ?? DEFAULT_SUBTITLE_SOURCE_FETCH_TIMEOUT_MS;
|
|
||||||
|
|
||||||
return async (source: string): Promise<string> => {
|
|
||||||
if (/^https?:\/\//i.test(source)) {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
try {
|
|
||||||
const response = await fetchImpl(source, { signal: controller.signal });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to download subtitle source (${response.status})`);
|
|
||||||
}
|
|
||||||
return await response.text();
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = resolveSubtitleSourcePath(source);
|
|
||||||
return await fs.promises.readFile(filePath, 'utf8');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createExtractInternalSubtitleTrackToTempFileHandler() {
|
|
||||||
return async (
|
|
||||||
ffmpegPath: string,
|
|
||||||
videoPath: string,
|
|
||||||
track: MpvSubtitleTrackLike,
|
|
||||||
): Promise<{ path: string; cleanup: () => Promise<void> } | null> => {
|
|
||||||
const ffIndex = parseTrackId(track['ff-index']);
|
|
||||||
const codec = typeof track.codec === 'string' ? track.codec : null;
|
|
||||||
const extension = codecToExtension(codec ?? undefined);
|
|
||||||
if (ffIndex === null || extension === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-sidebar-'));
|
|
||||||
const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const child = spawn(
|
|
||||||
ffmpegPath,
|
|
||||||
buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath),
|
|
||||||
);
|
|
||||||
let stderr = '';
|
|
||||||
child.stderr.on('data', (chunk: Buffer) => {
|
|
||||||
stderr += chunk.toString();
|
|
||||||
});
|
|
||||||
child.on('error', (error) => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
child.on('close', (code) => {
|
|
||||||
if (code === 0) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
path: outputPath,
|
|
||||||
cleanup: async () => {
|
|
||||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createSubtitleRuntime } from './subtitle-runtime';
|
|
||||||
|
|
||||||
function createResolvedConfig() {
|
|
||||||
return {
|
|
||||||
subtitleStyle: {
|
|
||||||
frequencyDictionary: {
|
|
||||||
enabled: true,
|
|
||||||
topX: 5,
|
|
||||||
mode: 'top',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
subtitleSidebar: {
|
|
||||||
autoScroll: true,
|
|
||||||
pauseVideoOnHover: false,
|
|
||||||
maxWidth: 420,
|
|
||||||
opacity: 0.92,
|
|
||||||
backgroundColor: '#111111',
|
|
||||||
textColor: '#ffffff',
|
|
||||||
fontFamily: 'sans-serif',
|
|
||||||
fontSize: 24,
|
|
||||||
timestampColor: '#cccccc',
|
|
||||||
activeLineColor: '#ffffff',
|
|
||||||
activeLineBackgroundColor: '#222222',
|
|
||||||
hoverLineBackgroundColor: '#333333',
|
|
||||||
},
|
|
||||||
subtitlePosition: {
|
|
||||||
yPercent: 84,
|
|
||||||
},
|
|
||||||
subsync: {
|
|
||||||
defaultMode: 'auto' as const,
|
|
||||||
alass_path: 'alass',
|
|
||||||
ffmpeg_path: 'ffmpeg',
|
|
||||||
ffsubsync_path: 'ffsubsync',
|
|
||||||
replace: false,
|
|
||||||
},
|
|
||||||
} as never;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMpvClient(properties: Record<string, unknown>) {
|
|
||||||
return {
|
|
||||||
connected: true,
|
|
||||||
currentSubStart: 1.25,
|
|
||||||
currentSubEnd: 2.5,
|
|
||||||
currentTimePos: 12.5,
|
|
||||||
requestProperty: async (name: string) => properties[name],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRuntime(overrides: Partial<Parameters<typeof createSubtitleRuntime>[0]> = {}) {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const config = createResolvedConfig();
|
|
||||||
let subtitlePosition: unknown = null;
|
|
||||||
let pendingSubtitlePosition: unknown = null;
|
|
||||||
const runtime = createSubtitleRuntime({
|
|
||||||
getResolvedConfig: () => config,
|
|
||||||
getCurrentMediaPath: () => '/media/episode.mkv',
|
|
||||||
getCurrentMediaTitle: () => 'Episode',
|
|
||||||
getCurrentSubText: () => 'current subtitle',
|
|
||||||
getCurrentSubAssText: () => '[Events]',
|
|
||||||
getMpvClient: () =>
|
|
||||||
createMpvClient({
|
|
||||||
'current-tracks/sub/external-filename': '/tmp/episode.ass',
|
|
||||||
'current-tracks/sub': {
|
|
||||||
type: 'sub',
|
|
||||||
id: 3,
|
|
||||||
external: true,
|
|
||||||
'external-filename': '/tmp/episode.ass',
|
|
||||||
},
|
|
||||||
'track-list': [
|
|
||||||
{
|
|
||||||
type: 'sub',
|
|
||||||
id: 3,
|
|
||||||
external: true,
|
|
||||||
'external-filename': '/tmp/episode.ass',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
sid: 3,
|
|
||||||
path: '/media/episode.mkv',
|
|
||||||
}),
|
|
||||||
broadcastToOverlayWindows: (channel, payload) => {
|
|
||||||
calls.push(`${channel}:${JSON.stringify(payload)}`);
|
|
||||||
},
|
|
||||||
subtitleWsService: {
|
|
||||||
broadcast: () => calls.push('subtitle-ws'),
|
|
||||||
},
|
|
||||||
annotationSubtitleWsService: {
|
|
||||||
broadcast: () => calls.push('annotation-ws'),
|
|
||||||
},
|
|
||||||
subtitlePositionsDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subtitle-runtime-')),
|
|
||||||
setSubtitlePosition: (position) => {
|
|
||||||
subtitlePosition = position;
|
|
||||||
},
|
|
||||||
setPendingSubtitlePosition: (position) => {
|
|
||||||
pendingSubtitlePosition = position;
|
|
||||||
},
|
|
||||||
clearPendingSubtitlePosition: () => {
|
|
||||||
pendingSubtitlePosition = null;
|
|
||||||
},
|
|
||||||
parseSubtitleCues: (content) => [
|
|
||||||
{
|
|
||||||
startTime: 0,
|
|
||||||
endTime: 1,
|
|
||||||
text: content.trim(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
createSubtitlePrefetchService: ({ cues }) => ({
|
|
||||||
start: () => calls.push(`start:${cues.length}`),
|
|
||||||
stop: () => calls.push('stop'),
|
|
||||||
onSeek: (time) => calls.push(`seek:${time}`),
|
|
||||||
pause: () => calls.push('pause'),
|
|
||||||
resume: () => calls.push('resume'),
|
|
||||||
}),
|
|
||||||
logDebug: (message) => calls.push(`debug:${message}`),
|
|
||||||
logInfo: (message) => calls.push(`info:${message}`),
|
|
||||||
logWarn: (message) => calls.push(`warn:${message}`),
|
|
||||||
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
|
|
||||||
clearSchedule: (timer) => clearTimeout(timer),
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
return { runtime, calls, subtitlePosition, pendingSubtitlePosition };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('subtitle runtime schedules and cancels subtitle prefetch refreshes', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const { runtime } = createRuntime({
|
|
||||||
refreshSubtitlePrefetchFromActiveTrack: async () => {
|
|
||||||
calls.push('refresh');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
runtime.scheduleSubtitlePrefetchRefresh(5);
|
|
||||||
runtime.clearScheduledSubtitlePrefetchRefresh();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
||||||
|
|
||||||
assert.deepEqual(calls, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('subtitle runtime times out remote subtitle source fetches', async () => {
|
|
||||||
const { runtime } = createRuntime({
|
|
||||||
fetchImpl: async (_url, init) => {
|
|
||||||
await new Promise<void>((_resolve, reject) => {
|
|
||||||
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
|
|
||||||
});
|
|
||||||
return new Response('');
|
|
||||||
},
|
|
||||||
subtitleSourceFetchTimeoutMs: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
async () => await runtime.loadSubtitleSourceText('https://example.com/subtitles.srt'),
|
|
||||||
/aborted/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('subtitle runtime reuses cached sidebar cues for the same source key', async () => {
|
|
||||||
const subtitlePath = path.join(
|
|
||||||
fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subtitle-cache-')),
|
|
||||||
'episode.ass',
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
subtitlePath,
|
|
||||||
`1
|
|
||||||
00:00:01,000 --> 00:00:02,000
|
|
||||||
Hello`,
|
|
||||||
);
|
|
||||||
|
|
||||||
let loadCount = 0;
|
|
||||||
const { runtime } = createRuntime({
|
|
||||||
getMpvClient: () =>
|
|
||||||
createMpvClient({
|
|
||||||
'current-tracks/sub/external-filename': subtitlePath,
|
|
||||||
'current-tracks/sub': {
|
|
||||||
type: 'sub',
|
|
||||||
id: 3,
|
|
||||||
external: true,
|
|
||||||
'external-filename': subtitlePath,
|
|
||||||
},
|
|
||||||
'track-list': [
|
|
||||||
{
|
|
||||||
type: 'sub',
|
|
||||||
id: 3,
|
|
||||||
external: true,
|
|
||||||
'external-filename': subtitlePath,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
sid: 3,
|
|
||||||
path: '/media/episode.mkv',
|
|
||||||
}),
|
|
||||||
loadSubtitleSourceText: async () => {
|
|
||||||
loadCount += 1;
|
|
||||||
return fs.readFileSync(subtitlePath, 'utf8');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const first = await runtime.getSubtitleSidebarSnapshot();
|
|
||||||
const second = await runtime.getSubtitleSidebarSnapshot();
|
|
||||||
|
|
||||||
assert.equal(loadCount, 1);
|
|
||||||
assert.deepEqual(second.cues, first.cues);
|
|
||||||
assert.equal(second.currentSubtitle.text, 'current subtitle');
|
|
||||||
});
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
import { createSubtitleProcessingController } from '../core/services/subtitle-processing-controller';
|
|
||||||
import {
|
|
||||||
createSubtitlePrefetchService,
|
|
||||||
type SubtitlePrefetchService,
|
|
||||||
type SubtitlePrefetchServiceDeps,
|
|
||||||
} from '../core/services/subtitle-prefetch';
|
|
||||||
import type { SubtitleWebsocketFrequencyOptions } from '../core/services/subtitle-ws';
|
|
||||||
import type { SubtitleCue } from '../core/services/subtitle-cue-parser';
|
|
||||||
import {
|
|
||||||
loadSubtitlePosition as loadSubtitlePositionCore,
|
|
||||||
saveSubtitlePosition as saveSubtitlePositionCore,
|
|
||||||
} from '../core/services/subtitle-position';
|
|
||||||
import type {
|
|
||||||
ResolvedConfig,
|
|
||||||
SubtitleData,
|
|
||||||
SubtitlePosition,
|
|
||||||
SubtitleSidebarSnapshot,
|
|
||||||
} from '../types';
|
|
||||||
import {
|
|
||||||
createLoadSubtitlePositionHandler,
|
|
||||||
createSaveSubtitlePositionHandler,
|
|
||||||
} from './runtime/subtitle-position';
|
|
||||||
import { resolveSubtitleSourcePath } from './runtime/subtitle-prefetch-source';
|
|
||||||
import {
|
|
||||||
createRefreshSubtitlePrefetchFromActiveTrackHandler,
|
|
||||||
createResolveActiveSubtitleSidebarSourceHandler,
|
|
||||||
} from './runtime/subtitle-prefetch-runtime';
|
|
||||||
import { createSubtitlePrefetchInitController } from './runtime/subtitle-prefetch-init';
|
|
||||||
import {
|
|
||||||
createExtractInternalSubtitleTrackToTempFileHandler,
|
|
||||||
createSubtitleSourceLoader,
|
|
||||||
type MpvSubtitleTrackLike,
|
|
||||||
} from './subtitle-runtime-sources';
|
|
||||||
|
|
||||||
type SubtitleBroadcastService = {
|
|
||||||
broadcast: (payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SubtitleRuntimeConfigLike = Pick<
|
|
||||||
ResolvedConfig,
|
|
||||||
'subtitleStyle' | 'subtitleSidebar' | 'subtitlePosition' | 'subsync'
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface SubtitleRuntimeInput {
|
|
||||||
getResolvedConfig: () => SubtitleRuntimeConfigLike;
|
|
||||||
getCurrentMediaPath: () => string | null;
|
|
||||||
getCurrentMediaTitle: () => string | null;
|
|
||||||
getCurrentSubText: () => string;
|
|
||||||
getCurrentSubAssText: () => string;
|
|
||||||
getMpvClient: () => {
|
|
||||||
connected?: boolean;
|
|
||||||
currentSubStart?: number | null;
|
|
||||||
currentSubEnd?: number | null;
|
|
||||||
currentTimePos?: number | null;
|
|
||||||
requestProperty: (name: string) => Promise<unknown>;
|
|
||||||
} | null;
|
|
||||||
subtitleWsService: SubtitleBroadcastService;
|
|
||||||
annotationSubtitleWsService: SubtitleBroadcastService;
|
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
|
||||||
subtitlePositionsDir: string;
|
|
||||||
setSubtitlePosition: (position: SubtitlePosition | null) => void;
|
|
||||||
setPendingSubtitlePosition: (position: SubtitlePosition | null) => void;
|
|
||||||
clearPendingSubtitlePosition: () => void;
|
|
||||||
setCurrentSubtitleData?: (payload: SubtitleData | null) => void;
|
|
||||||
setActiveParsedSubtitleState?: (cues: SubtitleCue[], sourceKey: string | null) => void;
|
|
||||||
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
|
|
||||||
createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService;
|
|
||||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
|
||||||
refreshSubtitlePrefetchFromActiveTrack?: () => Promise<void>;
|
|
||||||
fetchImpl?: typeof fetch;
|
|
||||||
subtitleSourceFetchTimeoutMs?: number;
|
|
||||||
prefetchRefreshDelayMs?: number;
|
|
||||||
seekThresholdSeconds?: number;
|
|
||||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
|
||||||
clearSchedule: (timer: ReturnType<typeof setTimeout>) => void;
|
|
||||||
logDebug: (message: string) => void;
|
|
||||||
logInfo: (message: string) => void;
|
|
||||||
logWarn: (message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubtitleRuntime {
|
|
||||||
setTokenizeSubtitleDeferred: (tokenize: ((text: string) => Promise<SubtitleData>) | null) => void;
|
|
||||||
emitSubtitlePayload: (payload: SubtitleData) => void;
|
|
||||||
refreshCurrentSubtitle: (textOverride?: string) => void;
|
|
||||||
invalidateTokenizationCache: () => void;
|
|
||||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
|
||||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
|
||||||
isCacheFull: () => boolean;
|
|
||||||
onSubtitleChange: (text: string) => void;
|
|
||||||
onCurrentMediaPathChange: (path: string | null) => void;
|
|
||||||
onTimePosUpdate: (time: number) => void;
|
|
||||||
getLastObservedTimePos: () => number;
|
|
||||||
refreshSubtitleSidebarFromSource: (sourcePath: string) => Promise<void>;
|
|
||||||
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
|
|
||||||
cancelPendingSubtitlePrefetchInit: () => void;
|
|
||||||
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => void;
|
|
||||||
clearScheduledSubtitlePrefetchRefresh: () => void;
|
|
||||||
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
|
|
||||||
tokenizeCurrentSubtitle: () => Promise<SubtitleData>;
|
|
||||||
loadSubtitleSourceText: (source: string) => Promise<string>;
|
|
||||||
extractInternalSubtitleTrackToTempFile: (
|
|
||||||
ffmpegPath: string,
|
|
||||||
videoPath: string,
|
|
||||||
track: MpvSubtitleTrackLike,
|
|
||||||
) => Promise<{ path: string; cleanup: () => Promise<void> } | null>;
|
|
||||||
loadSubtitlePosition: () => SubtitlePosition | null;
|
|
||||||
saveSubtitlePosition: (position: SubtitlePosition) => void;
|
|
||||||
getCurrentSubtitleData: () => SubtitleData | null;
|
|
||||||
getActiveParsedSubtitleCues: () => SubtitleCue[];
|
|
||||||
getActiveParsedSubtitleSource: () => string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PREFETCH_REFRESH_DELAY_MS = 500;
|
|
||||||
const SEEK_THRESHOLD_SECONDS = 3;
|
|
||||||
|
|
||||||
export function createSubtitleRuntime(input: SubtitleRuntimeInput): SubtitleRuntime {
|
|
||||||
const loadSubtitleSourceText =
|
|
||||||
input.loadSubtitleSourceText ??
|
|
||||||
createSubtitleSourceLoader({
|
|
||||||
fetchImpl: input.fetchImpl,
|
|
||||||
subtitleSourceFetchTimeoutMs: input.subtitleSourceFetchTimeoutMs,
|
|
||||||
});
|
|
||||||
const extractInternalSubtitleTrackToTempFile =
|
|
||||||
createExtractInternalSubtitleTrackToTempFileHandler();
|
|
||||||
|
|
||||||
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
|
|
||||||
let currentSubtitleData: SubtitleData | null = null;
|
|
||||||
let subtitlePrefetchService: SubtitlePrefetchService | null = null;
|
|
||||||
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let lastObservedTimePos = 0;
|
|
||||||
let activeParsedSubtitleCues: SubtitleCue[] = [];
|
|
||||||
let activeParsedSubtitleSource: string | null = null;
|
|
||||||
|
|
||||||
const setActiveParsedSubtitleState = (
|
|
||||||
cues: SubtitleCue[] | null,
|
|
||||||
sourceKey: string | null,
|
|
||||||
): void => {
|
|
||||||
activeParsedSubtitleCues = cues ?? [];
|
|
||||||
activeParsedSubtitleSource = sourceKey;
|
|
||||||
input.setActiveParsedSubtitleState?.(activeParsedSubtitleCues, activeParsedSubtitleSource);
|
|
||||||
};
|
|
||||||
|
|
||||||
const tokenizationController = createSubtitleProcessingController({
|
|
||||||
tokenizeSubtitle: async (text: string) =>
|
|
||||||
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null },
|
|
||||||
emitSubtitle: (payload) => emitSubtitlePayload(payload),
|
|
||||||
logDebug: (message) => {
|
|
||||||
input.logDebug(`[subtitle-processing] ${message}`);
|
|
||||||
},
|
|
||||||
now: () => Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
|
||||||
getCurrentService: () => subtitlePrefetchService,
|
|
||||||
setCurrentService: (service) => {
|
|
||||||
subtitlePrefetchService = service;
|
|
||||||
},
|
|
||||||
loadSubtitleSourceText,
|
|
||||||
parseSubtitleCues: (content, filename) => input.parseSubtitleCues(content, filename),
|
|
||||||
createSubtitlePrefetchService: (deps) => input.createSubtitlePrefetchService(deps),
|
|
||||||
tokenizeSubtitle: async (text) =>
|
|
||||||
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
|
|
||||||
preCacheTokenization: (text, data) => tokenizationController.preCacheTokenization(text, data),
|
|
||||||
isCacheFull: () => tokenizationController.isCacheFull(),
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
logWarn: (message) => input.logWarn(message),
|
|
||||||
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
|
|
||||||
setActiveParsedSubtitleState(cues, sourceKey);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler(
|
|
||||||
{
|
|
||||||
getFfmpegPath: () => input.getResolvedConfig().subsync.ffmpeg_path.trim() || 'ffmpeg',
|
|
||||||
extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) =>
|
|
||||||
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshSubtitlePrefetchFromActiveTrackHandler =
|
|
||||||
input.refreshSubtitlePrefetchFromActiveTrack ??
|
|
||||||
createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
|
||||||
getMpvClient: () => input.getMpvClient(),
|
|
||||||
getLastObservedTimePos: () => lastObservedTimePos,
|
|
||||||
subtitlePrefetchInitController,
|
|
||||||
resolveActiveSubtitleSidebarSource: (nextInput) =>
|
|
||||||
resolveActiveSubtitleSidebarSourceHandler(nextInput),
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadSubtitlePosition = createLoadSubtitlePositionHandler({
|
|
||||||
loadSubtitlePositionCore: () =>
|
|
||||||
loadSubtitlePositionCore({
|
|
||||||
currentMediaPath: input.getCurrentMediaPath(),
|
|
||||||
fallbackPosition: input.getResolvedConfig().subtitlePosition,
|
|
||||||
subtitlePositionsDir: input.subtitlePositionsDir,
|
|
||||||
}),
|
|
||||||
setSubtitlePosition: (position) => input.setSubtitlePosition(position),
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveSubtitlePosition = createSaveSubtitlePositionHandler({
|
|
||||||
saveSubtitlePositionCore: (position) =>
|
|
||||||
saveSubtitlePositionCore({
|
|
||||||
position,
|
|
||||||
currentMediaPath: input.getCurrentMediaPath(),
|
|
||||||
subtitlePositionsDir: input.subtitlePositionsDir,
|
|
||||||
onQueuePending: (queued) => input.setPendingSubtitlePosition(queued),
|
|
||||||
onPersisted: () => input.clearPendingSubtitlePosition(),
|
|
||||||
}),
|
|
||||||
setSubtitlePosition: (position) => input.setSubtitlePosition(position),
|
|
||||||
});
|
|
||||||
|
|
||||||
const getSubtitleBroadcastOptions = (): SubtitleWebsocketFrequencyOptions => {
|
|
||||||
const config = input.getResolvedConfig().subtitleStyle.frequencyDictionary;
|
|
||||||
return {
|
|
||||||
enabled: config.enabled,
|
|
||||||
topX: config.topX,
|
|
||||||
mode: config.mode,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const withCurrentSubtitleTiming = (payload: SubtitleData): SubtitleData => ({
|
|
||||||
...payload,
|
|
||||||
startTime: input.getMpvClient()?.currentSubStart ?? null,
|
|
||||||
endTime: input.getMpvClient()?.currentSubEnd ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const clearScheduledSubtitlePrefetchRefresh = (): void => {
|
|
||||||
if (subtitlePrefetchRefreshTimer) {
|
|
||||||
input.clearSchedule(subtitlePrefetchRefreshTimer);
|
|
||||||
subtitlePrefetchRefreshTimer = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleSubtitlePrefetchRefresh = (delayMs = 0): void => {
|
|
||||||
clearScheduledSubtitlePrefetchRefresh();
|
|
||||||
subtitlePrefetchRefreshTimer = input.schedule(() => {
|
|
||||||
subtitlePrefetchRefreshTimer = null;
|
|
||||||
void refreshSubtitlePrefetchFromActiveTrackHandler();
|
|
||||||
}, delayMs);
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshSubtitleSidebarFromSource = async (sourcePath: string): Promise<void> => {
|
|
||||||
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
|
|
||||||
if (!normalizedSourcePath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await subtitlePrefetchInitController.initSubtitlePrefetch(
|
|
||||||
normalizedSourcePath,
|
|
||||||
lastObservedTimePos,
|
|
||||||
normalizedSourcePath,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCurrentMediaPathChange = (pathValue: string | null): void => {
|
|
||||||
clearScheduledSubtitlePrefetchRefresh();
|
|
||||||
subtitlePrefetchInitController.cancelPendingInit();
|
|
||||||
if (pathValue) {
|
|
||||||
scheduleSubtitlePrefetchRefresh(
|
|
||||||
input.prefetchRefreshDelayMs ?? DEFAULT_PREFETCH_REFRESH_DELAY_MS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onTimePosUpdate = (time: number): void => {
|
|
||||||
const delta = time - lastObservedTimePos;
|
|
||||||
if (
|
|
||||||
subtitlePrefetchService &&
|
|
||||||
(delta > (input.seekThresholdSeconds ?? SEEK_THRESHOLD_SECONDS) || delta < 0)
|
|
||||||
) {
|
|
||||||
subtitlePrefetchService.onSeek(time);
|
|
||||||
}
|
|
||||||
lastObservedTimePos = time;
|
|
||||||
};
|
|
||||||
|
|
||||||
const emitSubtitlePayload = (payload: SubtitleData): void => {
|
|
||||||
const timedPayload = withCurrentSubtitleTiming(payload);
|
|
||||||
currentSubtitleData = timedPayload;
|
|
||||||
input.setCurrentSubtitleData?.(timedPayload);
|
|
||||||
input.broadcastToOverlayWindows('subtitle:set', timedPayload);
|
|
||||||
const broadcastOptions = getSubtitleBroadcastOptions();
|
|
||||||
input.subtitleWsService.broadcast(timedPayload, broadcastOptions);
|
|
||||||
input.annotationSubtitleWsService.broadcast(timedPayload, broadcastOptions);
|
|
||||||
subtitlePrefetchService?.resume();
|
|
||||||
};
|
|
||||||
|
|
||||||
const tokenizeCurrentSubtitle = async (): Promise<SubtitleData> => {
|
|
||||||
const tokenized = await tokenizationController.consumeCachedSubtitle(input.getCurrentSubText());
|
|
||||||
if (tokenized) {
|
|
||||||
return withCurrentSubtitleTiming(tokenized);
|
|
||||||
}
|
|
||||||
const text = input.getCurrentSubText();
|
|
||||||
const deferred = tokenizeSubtitleDeferred
|
|
||||||
? await tokenizeSubtitleDeferred(text)
|
|
||||||
: { text, tokens: null };
|
|
||||||
return withCurrentSubtitleTiming(deferred);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
setTokenizeSubtitleDeferred: (tokenize) => {
|
|
||||||
tokenizeSubtitleDeferred = tokenize;
|
|
||||||
},
|
|
||||||
emitSubtitlePayload,
|
|
||||||
refreshCurrentSubtitle: (textOverride?: string) => {
|
|
||||||
tokenizationController.refreshCurrentSubtitle(textOverride);
|
|
||||||
},
|
|
||||||
invalidateTokenizationCache: () => {
|
|
||||||
tokenizationController.invalidateTokenizationCache();
|
|
||||||
},
|
|
||||||
preCacheTokenization: (text, data) => {
|
|
||||||
tokenizationController.preCacheTokenization(text, data);
|
|
||||||
},
|
|
||||||
consumeCachedSubtitle: (text) => tokenizationController.consumeCachedSubtitle(text),
|
|
||||||
isCacheFull: () => tokenizationController.isCacheFull(),
|
|
||||||
onSubtitleChange: (text) => {
|
|
||||||
subtitlePrefetchService?.pause();
|
|
||||||
tokenizationController.onSubtitleChange(text);
|
|
||||||
},
|
|
||||||
onCurrentMediaPathChange,
|
|
||||||
onTimePosUpdate,
|
|
||||||
getLastObservedTimePos: () => lastObservedTimePos,
|
|
||||||
refreshSubtitleSidebarFromSource,
|
|
||||||
refreshSubtitlePrefetchFromActiveTrack: async () => {
|
|
||||||
await refreshSubtitlePrefetchFromActiveTrackHandler();
|
|
||||||
},
|
|
||||||
cancelPendingSubtitlePrefetchInit: () => {
|
|
||||||
subtitlePrefetchInitController.cancelPendingInit();
|
|
||||||
},
|
|
||||||
scheduleSubtitlePrefetchRefresh,
|
|
||||||
clearScheduledSubtitlePrefetchRefresh,
|
|
||||||
getSubtitleSidebarSnapshot: async (): Promise<SubtitleSidebarSnapshot> => {
|
|
||||||
const currentSubtitle = {
|
|
||||||
text: input.getCurrentSubText(),
|
|
||||||
startTime: input.getMpvClient()?.currentSubStart ?? null,
|
|
||||||
endTime: input.getMpvClient()?.currentSubEnd ?? null,
|
|
||||||
};
|
|
||||||
const currentTimeSec = input.getMpvClient()?.currentTimePos ?? null;
|
|
||||||
const config = input.getResolvedConfig().subtitleSidebar;
|
|
||||||
const client = input.getMpvClient();
|
|
||||||
if (!client?.connected) {
|
|
||||||
return {
|
|
||||||
cues: activeParsedSubtitleCues,
|
|
||||||
currentTimeSec,
|
|
||||||
currentSubtitle,
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [currentExternalFilenameRaw, currentTrackRaw, trackListRaw, sidRaw, videoPathRaw] =
|
|
||||||
await Promise.all([
|
|
||||||
client.requestProperty('current-tracks/sub/external-filename').catch(() => null),
|
|
||||||
client.requestProperty('current-tracks/sub').catch(() => null),
|
|
||||||
client.requestProperty('track-list'),
|
|
||||||
client.requestProperty('sid'),
|
|
||||||
client.requestProperty('path'),
|
|
||||||
]);
|
|
||||||
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
|
|
||||||
if (!videoPath) {
|
|
||||||
return {
|
|
||||||
cues: activeParsedSubtitleCues,
|
|
||||||
currentTimeSec,
|
|
||||||
currentSubtitle,
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({
|
|
||||||
currentExternalFilenameRaw,
|
|
||||||
currentTrackRaw,
|
|
||||||
trackListRaw,
|
|
||||||
sidRaw,
|
|
||||||
videoPath,
|
|
||||||
});
|
|
||||||
if (!resolvedSource) {
|
|
||||||
return {
|
|
||||||
cues: activeParsedSubtitleCues,
|
|
||||||
currentTimeSec,
|
|
||||||
currentSubtitle,
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (activeParsedSubtitleSource === resolvedSource.sourceKey) {
|
|
||||||
return {
|
|
||||||
cues: activeParsedSubtitleCues,
|
|
||||||
currentTimeSec,
|
|
||||||
currentSubtitle,
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = await loadSubtitleSourceText(resolvedSource.path);
|
|
||||||
const cues = input.parseSubtitleCues(content, resolvedSource.path);
|
|
||||||
setActiveParsedSubtitleState(cues, resolvedSource.sourceKey);
|
|
||||||
return {
|
|
||||||
cues,
|
|
||||||
currentTimeSec,
|
|
||||||
currentSubtitle,
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
await resolvedSource.cleanup?.();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
cues: activeParsedSubtitleCues,
|
|
||||||
currentTimeSec,
|
|
||||||
currentSubtitle,
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tokenizeCurrentSubtitle,
|
|
||||||
loadSubtitleSourceText,
|
|
||||||
extractInternalSubtitleTrackToTempFile,
|
|
||||||
loadSubtitlePosition,
|
|
||||||
saveSubtitlePosition,
|
|
||||||
getCurrentSubtitleData: () => currentSubtitleData,
|
|
||||||
getActiveParsedSubtitleCues: () => activeParsedSubtitleCues,
|
|
||||||
getActiveParsedSubtitleSource: () => activeParsedSubtitleSource,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
|
||||||
|
|
||||||
import type { YomitanExtensionLoaderDeps } from '../core/services/yomitan-extension-loader';
|
|
||||||
import type { ResolvedConfig } from '../types';
|
|
||||||
import { createYomitanProfilePolicy } from './runtime/yomitan-profile-policy';
|
|
||||||
import { createYomitanRuntime, type YomitanRuntime } from './yomitan-runtime';
|
|
||||||
|
|
||||||
export interface YomitanRuntimeBootstrapInput {
|
|
||||||
userDataPath: string;
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
appState: {
|
|
||||||
yomitanParserWindow: BrowserWindow | null;
|
|
||||||
yomitanParserReadyPromise: Promise<void> | null;
|
|
||||||
yomitanParserInitPromise: Promise<boolean> | null;
|
|
||||||
yomitanExt: Extension | null;
|
|
||||||
yomitanSession: Session | null;
|
|
||||||
yomitanSettingsWindow: BrowserWindow | null;
|
|
||||||
};
|
|
||||||
loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise<Extension | null>;
|
|
||||||
getLoadInFlight: () => Promise<Extension | null> | null;
|
|
||||||
setLoadInFlight: (promise: Promise<Extension | null> | null) => void;
|
|
||||||
openYomitanSettingsWindow: (params: {
|
|
||||||
yomitanExt: Extension | null;
|
|
||||||
getExistingWindow: () => BrowserWindow | null;
|
|
||||||
setWindow: (window: BrowserWindow | null) => void;
|
|
||||||
yomitanSession?: Session | null;
|
|
||||||
onWindowClosed?: () => void;
|
|
||||||
}) => void;
|
|
||||||
logInfo: (message: string) => void;
|
|
||||||
logWarn: (message: string) => void;
|
|
||||||
logError: (message: string, error: unknown) => void;
|
|
||||||
showMpvOsd: (message: string) => void;
|
|
||||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface YomitanRuntimeBootstrap {
|
|
||||||
yomitan: YomitanRuntime;
|
|
||||||
yomitanProfilePolicy: ReturnType<typeof createYomitanProfilePolicy>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createYomitanRuntimeBootstrap(
|
|
||||||
input: YomitanRuntimeBootstrapInput,
|
|
||||||
): YomitanRuntimeBootstrap {
|
|
||||||
const yomitanProfilePolicy = createYomitanProfilePolicy({
|
|
||||||
externalProfilePath: input.getResolvedConfig().yomitan.externalProfilePath,
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const yomitan = createYomitanRuntime({
|
|
||||||
userDataPath: input.userDataPath,
|
|
||||||
externalProfilePath: yomitanProfilePolicy.externalProfilePath,
|
|
||||||
loadYomitanExtensionCore: input.loadYomitanExtensionCore,
|
|
||||||
getYomitanParserWindow: () => input.appState.yomitanParserWindow,
|
|
||||||
setYomitanParserWindow: (window) => {
|
|
||||||
input.appState.yomitanParserWindow = window;
|
|
||||||
},
|
|
||||||
getYomitanParserReadyPromise: () => input.appState.yomitanParserReadyPromise,
|
|
||||||
setYomitanParserReadyPromise: (promise) => {
|
|
||||||
input.appState.yomitanParserReadyPromise = promise;
|
|
||||||
},
|
|
||||||
getYomitanParserInitPromise: () => input.appState.yomitanParserInitPromise,
|
|
||||||
setYomitanParserInitPromise: (promise) => {
|
|
||||||
input.appState.yomitanParserInitPromise = promise;
|
|
||||||
},
|
|
||||||
getYomitanExtension: () => input.appState.yomitanExt,
|
|
||||||
setYomitanExtension: (extension) => {
|
|
||||||
input.appState.yomitanExt = extension;
|
|
||||||
},
|
|
||||||
getYomitanSession: () => input.appState.yomitanSession,
|
|
||||||
setYomitanSession: (session) => {
|
|
||||||
input.appState.yomitanSession = session;
|
|
||||||
},
|
|
||||||
getLoadInFlight: () => input.getLoadInFlight(),
|
|
||||||
setLoadInFlight: (promise) => {
|
|
||||||
input.setLoadInFlight(promise);
|
|
||||||
},
|
|
||||||
getResolvedConfig: () => input.getResolvedConfig(),
|
|
||||||
openYomitanSettingsWindow: (params) =>
|
|
||||||
input.openYomitanSettingsWindow({
|
|
||||||
yomitanExt: params.yomitanExt,
|
|
||||||
getExistingWindow: params.getExistingWindow,
|
|
||||||
setWindow: params.setWindow,
|
|
||||||
yomitanSession: params.yomitanSession,
|
|
||||||
onWindowClosed: params.onWindowClosed,
|
|
||||||
}),
|
|
||||||
getExistingSettingsWindow: () => input.appState.yomitanSettingsWindow,
|
|
||||||
setSettingsWindow: (window) => {
|
|
||||||
input.appState.yomitanSettingsWindow = window;
|
|
||||||
},
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
logWarn: (message) => input.logWarn(message),
|
|
||||||
logError: (message, error) => input.logError(message, error),
|
|
||||||
showMpvOsd: (message) => input.showMpvOsd(message),
|
|
||||||
showDesktopNotification: (title, options) => input.showDesktopNotification(title, options),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
yomitan,
|
|
||||||
yomitanProfilePolicy,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
|
||||||
|
|
||||||
import { clearYomitanParserCachesForWindow, syncYomitanDefaultAnkiServer } from '../core/services';
|
|
||||||
import type { YomitanExtensionLoaderDeps } from '../core/services/yomitan-extension-loader';
|
|
||||||
import type { ResolvedConfig } from '../types';
|
|
||||||
import { createYomitanExtensionRuntime } from './runtime/yomitan-extension-runtime';
|
|
||||||
import { createYomitanProfilePolicy } from './runtime/yomitan-profile-policy';
|
|
||||||
import { createYomitanSettingsRuntime } from './runtime/yomitan-settings-runtime';
|
|
||||||
import {
|
|
||||||
getPreferredYomitanAnkiServerUrl,
|
|
||||||
shouldForceOverrideYomitanAnkiServer,
|
|
||||||
} from './runtime/yomitan-anki-server';
|
|
||||||
export interface YomitanParserRuntimeDeps {
|
|
||||||
getYomitanExt: () => Extension | null;
|
|
||||||
getYomitanSession: () => Session | null;
|
|
||||||
getYomitanParserWindow: () => BrowserWindow | null;
|
|
||||||
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
|
||||||
getYomitanParserReadyPromise: () => Promise<void> | null;
|
|
||||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
|
||||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
|
||||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface YomitanRuntimeInput {
|
|
||||||
userDataPath: string;
|
|
||||||
externalProfilePath: string;
|
|
||||||
loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise<Extension | null>;
|
|
||||||
getYomitanParserWindow: () => BrowserWindow | null;
|
|
||||||
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
|
||||||
getYomitanParserReadyPromise: () => Promise<void> | null;
|
|
||||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
|
||||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
|
||||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
|
||||||
getYomitanExtension: () => Extension | null;
|
|
||||||
setYomitanExtension: (extension: Extension | null) => void;
|
|
||||||
getYomitanSession: () => Session | null;
|
|
||||||
setYomitanSession: (session: Session | null) => void;
|
|
||||||
getLoadInFlight: () => Promise<Extension | null> | null;
|
|
||||||
setLoadInFlight: (promise: Promise<Extension | null> | null) => void;
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
openYomitanSettingsWindow: (params: {
|
|
||||||
yomitanExt: Extension | null;
|
|
||||||
getExistingWindow: () => BrowserWindow | null;
|
|
||||||
setWindow: (window: BrowserWindow | null) => void;
|
|
||||||
yomitanSession?: Session | null;
|
|
||||||
onWindowClosed?: () => void;
|
|
||||||
}) => void;
|
|
||||||
getExistingSettingsWindow: () => BrowserWindow | null;
|
|
||||||
setSettingsWindow: (window: BrowserWindow | null) => void;
|
|
||||||
logInfo: (message: string) => void;
|
|
||||||
logWarn: (message: string) => void;
|
|
||||||
logError: (message: string, error: unknown) => void;
|
|
||||||
showMpvOsd: (message: string) => void;
|
|
||||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface YomitanRuntime {
|
|
||||||
loadYomitanExtension: () => Promise<Extension | null>;
|
|
||||||
ensureYomitanExtensionLoaded: () => Promise<Extension | null>;
|
|
||||||
openYomitanSettings: () => boolean;
|
|
||||||
syncDefaultProfileAnkiServer: () => Promise<void>;
|
|
||||||
getParserRuntimeDeps: () => YomitanParserRuntimeDeps;
|
|
||||||
getPreferredAnkiServerUrl: () => string;
|
|
||||||
isExternalReadOnlyMode: () => boolean;
|
|
||||||
isCharacterDictionaryEnabled: () => boolean;
|
|
||||||
getCharacterDictionaryDisabledReason: () => string | null;
|
|
||||||
clearParserCachesForWindow: (window: BrowserWindow) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createYomitanRuntime(input: YomitanRuntimeInput): YomitanRuntime {
|
|
||||||
const profilePolicy = createYomitanProfilePolicy({
|
|
||||||
externalProfilePath: input.externalProfilePath,
|
|
||||||
logInfo: (message) => input.logInfo(message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const extensionRuntime = createYomitanExtensionRuntime({
|
|
||||||
loadYomitanExtensionCore: input.loadYomitanExtensionCore,
|
|
||||||
userDataPath: input.userDataPath,
|
|
||||||
externalProfilePath: profilePolicy.externalProfilePath,
|
|
||||||
getYomitanParserWindow: () => input.getYomitanParserWindow(),
|
|
||||||
setYomitanParserWindow: (window) => input.setYomitanParserWindow(window),
|
|
||||||
setYomitanParserReadyPromise: (promise) => input.setYomitanParserReadyPromise(promise),
|
|
||||||
setYomitanParserInitPromise: (promise) => input.setYomitanParserInitPromise(promise),
|
|
||||||
setYomitanExtension: (extension) => input.setYomitanExtension(extension),
|
|
||||||
setYomitanSession: (session) => input.setYomitanSession(session),
|
|
||||||
getYomitanExtension: () => input.getYomitanExtension(),
|
|
||||||
getLoadInFlight: () => input.getLoadInFlight(),
|
|
||||||
setLoadInFlight: (promise) => input.setLoadInFlight(promise),
|
|
||||||
});
|
|
||||||
|
|
||||||
const settingsRuntime = createYomitanSettingsRuntime({
|
|
||||||
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
|
|
||||||
openYomitanSettingsWindow: (params) =>
|
|
||||||
input.openYomitanSettingsWindow({
|
|
||||||
yomitanExt: params.yomitanExt as Extension | null,
|
|
||||||
getExistingWindow: () => params.getExistingWindow() as BrowserWindow | null,
|
|
||||||
setWindow: (window) => params.setWindow(window),
|
|
||||||
yomitanSession: (params.yomitanSession as Session | null | undefined) ?? null,
|
|
||||||
onWindowClosed: () => {
|
|
||||||
input.setSettingsWindow(null);
|
|
||||||
params.onWindowClosed?.();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
getExistingWindow: () => input.getExistingSettingsWindow(),
|
|
||||||
setWindow: (window) => input.setSettingsWindow(window as BrowserWindow | null),
|
|
||||||
getYomitanSession: () => input.getYomitanSession(),
|
|
||||||
logWarn: (message) => input.logWarn(message),
|
|
||||||
logError: (message, error) => input.logError(message, error),
|
|
||||||
});
|
|
||||||
|
|
||||||
let lastSyncedYomitanAnkiServer: string | null = null;
|
|
||||||
|
|
||||||
const getParserRuntimeDeps = (): YomitanParserRuntimeDeps => ({
|
|
||||||
getYomitanExt: () => input.getYomitanExtension(),
|
|
||||||
getYomitanSession: () => input.getYomitanSession(),
|
|
||||||
getYomitanParserWindow: () => input.getYomitanParserWindow(),
|
|
||||||
setYomitanParserWindow: (window) => input.setYomitanParserWindow(window),
|
|
||||||
getYomitanParserReadyPromise: () => input.getYomitanParserReadyPromise(),
|
|
||||||
setYomitanParserReadyPromise: (promise) => input.setYomitanParserReadyPromise(promise),
|
|
||||||
getYomitanParserInitPromise: () => input.getYomitanParserInitPromise(),
|
|
||||||
setYomitanParserInitPromise: (promise) => input.setYomitanParserInitPromise(promise),
|
|
||||||
});
|
|
||||||
|
|
||||||
const syncDefaultProfileAnkiServer = async (): Promise<void> => {
|
|
||||||
if (profilePolicy.isExternalReadOnlyMode()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetUrl = getPreferredYomitanAnkiServerUrl(
|
|
||||||
input.getResolvedConfig().ankiConnect,
|
|
||||||
).trim();
|
|
||||||
if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const synced = await syncYomitanDefaultAnkiServer(
|
|
||||||
targetUrl,
|
|
||||||
getParserRuntimeDeps(),
|
|
||||||
{
|
|
||||||
error: (message, ...args) => {
|
|
||||||
input.logError(message, args[0]);
|
|
||||||
},
|
|
||||||
info: (message, ...args) => {
|
|
||||||
input.logInfo([message, ...args].join(' '));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
forceOverride: shouldForceOverrideYomitanAnkiServer(input.getResolvedConfig().ankiConnect),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (synced) {
|
|
||||||
lastSyncedYomitanAnkiServer = targetUrl;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadYomitanExtension = async (): Promise<Extension | null> => {
|
|
||||||
const extension = await extensionRuntime.loadYomitanExtension();
|
|
||||||
if (extension && !profilePolicy.isExternalReadOnlyMode()) {
|
|
||||||
await syncDefaultProfileAnkiServer();
|
|
||||||
}
|
|
||||||
return extension;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureYomitanExtensionLoaded = async (): Promise<Extension | null> => {
|
|
||||||
const extension = await extensionRuntime.ensureYomitanExtensionLoaded();
|
|
||||||
if (extension && !profilePolicy.isExternalReadOnlyMode()) {
|
|
||||||
await syncDefaultProfileAnkiServer();
|
|
||||||
}
|
|
||||||
return extension;
|
|
||||||
};
|
|
||||||
|
|
||||||
const openYomitanSettings = (): boolean => {
|
|
||||||
if (profilePolicy.isExternalReadOnlyMode()) {
|
|
||||||
const message = 'Yomitan settings unavailable while using read-only external-profile mode.';
|
|
||||||
input.logWarn(
|
|
||||||
'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.',
|
|
||||||
);
|
|
||||||
input.showDesktopNotification('SubMiner', { body: message });
|
|
||||||
input.showMpvOsd(message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
settingsRuntime.openYomitanSettings();
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
loadYomitanExtension,
|
|
||||||
ensureYomitanExtensionLoaded,
|
|
||||||
openYomitanSettings,
|
|
||||||
syncDefaultProfileAnkiServer,
|
|
||||||
getParserRuntimeDeps,
|
|
||||||
getPreferredAnkiServerUrl: () =>
|
|
||||||
getPreferredYomitanAnkiServerUrl(input.getResolvedConfig().ankiConnect),
|
|
||||||
isExternalReadOnlyMode: () => profilePolicy.isExternalReadOnlyMode(),
|
|
||||||
isCharacterDictionaryEnabled: () => profilePolicy.isCharacterDictionaryEnabled(),
|
|
||||||
getCharacterDictionaryDisabledReason: () =>
|
|
||||||
profilePolicy.getCharacterDictionaryDisabledReason(),
|
|
||||||
clearParserCachesForWindow: (window) => clearYomitanParserCachesForWindow(window),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
|
||||||
import type { WindowGeometry } from '../types';
|
|
||||||
import type { YoutubeRuntimeInput } from './youtube-runtime';
|
|
||||||
import { createWaitForMpvConnectedHandler } from './runtime/jellyfin-remote-connection';
|
|
||||||
import { createPrepareYoutubePlaybackInMpvHandler } from './runtime/youtube-playback-launch';
|
|
||||||
import { openYoutubeTrackPicker } from './runtime/youtube-picker-open';
|
|
||||||
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './runtime/windows-mpv-launch';
|
|
||||||
|
|
||||||
type MpvClientLike = {
|
|
||||||
connected: boolean;
|
|
||||||
currentVideoPath?: string | null;
|
|
||||||
connect: () => void;
|
|
||||||
requestProperty: (name: string) => Promise<unknown>;
|
|
||||||
send: (payload: { command: Array<string | boolean> }) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AnkiIntegrationLike = {
|
|
||||||
waitUntilReady: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WindowTrackerLike = {
|
|
||||||
getGeometry: () => WindowGeometry | null;
|
|
||||||
isTargetWindowFocused: () => boolean;
|
|
||||||
isTracking: () => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OverlayMainWindowLike = {
|
|
||||||
isDestroyed: () => boolean;
|
|
||||||
isFocused: () => boolean;
|
|
||||||
focus: () => void;
|
|
||||||
setIgnoreMouseEvents: (ignore: boolean) => void;
|
|
||||||
webContents: {
|
|
||||||
isFocused: () => boolean;
|
|
||||||
focus: () => void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type OverlayUiLike = {
|
|
||||||
sendToActiveOverlayWindow: (
|
|
||||||
channel: string,
|
|
||||||
payload?: unknown,
|
|
||||||
runtimeOptions?: {
|
|
||||||
restoreOnModalClose?: OverlayHostedModal;
|
|
||||||
preferModalWindow?: boolean;
|
|
||||||
},
|
|
||||||
) => boolean;
|
|
||||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
|
||||||
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OverlayGeometryLike = {
|
|
||||||
geometryMatches: (left: WindowGeometry | null, right: WindowGeometry | null) => boolean;
|
|
||||||
getLastOverlayWindowGeometry: () => WindowGeometry | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SubtitleRuntimeLike = {
|
|
||||||
refreshCurrentSubtitle: (text: string) => void;
|
|
||||||
refreshSubtitleSidebarFromSource: (sourcePath: string) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TokenizationGateLike = {
|
|
||||||
waitUntilReady: (mediaPath: string | null) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface YoutubeRuntimeBootstrapInput {
|
|
||||||
appState: {
|
|
||||||
getMpvClient: () => MpvClientLike | null;
|
|
||||||
getCurrentMediaPath: () => string | null;
|
|
||||||
getPlaybackPaused: () => boolean | null;
|
|
||||||
getWindowTracker: () => WindowTrackerLike | null;
|
|
||||||
getAnkiIntegration: () => AnkiIntegrationLike | null;
|
|
||||||
};
|
|
||||||
overlay: {
|
|
||||||
getOverlayUi: () => OverlayUiLike | null;
|
|
||||||
getMainWindow: () => OverlayMainWindowLike | null;
|
|
||||||
getOverlayGeometry: () => OverlayGeometryLike;
|
|
||||||
broadcastYoutubePickerCancel: () => void;
|
|
||||||
};
|
|
||||||
getSubtitle: () => SubtitleRuntimeLike;
|
|
||||||
tokenization: {
|
|
||||||
startTokenizationWarmups: () => Promise<void>;
|
|
||||||
getGate: () => TokenizationGateLike;
|
|
||||||
};
|
|
||||||
appReady: {
|
|
||||||
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
|
|
||||||
};
|
|
||||||
services: {
|
|
||||||
probeYoutubeTracks: YoutubeRuntimeInput['flow']['probeYoutubeTracks'];
|
|
||||||
acquireYoutubeSubtitleTrack: YoutubeRuntimeInput['flow']['acquireYoutubeSubtitleTrack'];
|
|
||||||
acquireYoutubeSubtitleTracks: YoutubeRuntimeInput['flow']['acquireYoutubeSubtitleTracks'];
|
|
||||||
resolveYoutubePlaybackUrl: YoutubeRuntimeInput['playback']['resolveYoutubePlaybackUrl'];
|
|
||||||
sendMpvCommand: YoutubeRuntimeInput['flow']['sendMpvCommand'];
|
|
||||||
showMpvOsd: YoutubeRuntimeInput['showMpvOsd'];
|
|
||||||
showDesktopNotification: YoutubeRuntimeInput['showDesktopNotification'];
|
|
||||||
showErrorBox: (title: string, content: string) => void;
|
|
||||||
logInfo: (message: string) => void;
|
|
||||||
logWarn: (message: string, error?: unknown) => void;
|
|
||||||
logDebug: (message: string) => void;
|
|
||||||
};
|
|
||||||
config: {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
directPlaybackFormat: string;
|
|
||||||
mpvYtdlFormat: string;
|
|
||||||
autoLaunchTimeoutMs: number;
|
|
||||||
connectTimeoutMs: number;
|
|
||||||
logPath: string;
|
|
||||||
getSocketPath: () => string;
|
|
||||||
getNotificationType: () => YoutubeRuntimeInput['getNotificationType'] extends () => infer T
|
|
||||||
? T
|
|
||||||
: string;
|
|
||||||
getPrimarySubtitleLanguages: () => string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createYoutubeRuntimeInput(
|
|
||||||
input: YoutubeRuntimeBootstrapInput,
|
|
||||||
): YoutubeRuntimeInput {
|
|
||||||
const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
|
|
||||||
requestPath: async () => {
|
|
||||||
const client = input.appState.getMpvClient();
|
|
||||||
if (!client) return null;
|
|
||||||
const value = await client.requestProperty('path').catch(() => null);
|
|
||||||
return typeof value === 'string' ? value : null;
|
|
||||||
},
|
|
||||||
requestProperty: async (name) => {
|
|
||||||
const client = input.appState.getMpvClient();
|
|
||||||
if (!client) return null;
|
|
||||||
return await client.requestProperty(name);
|
|
||||||
},
|
|
||||||
sendMpvCommand: (command) => {
|
|
||||||
input.services.sendMpvCommand(command);
|
|
||||||
},
|
|
||||||
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
||||||
});
|
|
||||||
|
|
||||||
const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({
|
|
||||||
getMpvClient: () => input.appState.getMpvClient(),
|
|
||||||
now: () => Date.now(),
|
|
||||||
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
flow: {
|
|
||||||
probeYoutubeTracks: (url) => input.services.probeYoutubeTracks(url),
|
|
||||||
acquireYoutubeSubtitleTrack: (request) => input.services.acquireYoutubeSubtitleTrack(request),
|
|
||||||
acquireYoutubeSubtitleTracks: (request) =>
|
|
||||||
input.services.acquireYoutubeSubtitleTracks(request),
|
|
||||||
openPicker: async (payload) =>
|
|
||||||
await openYoutubeTrackPicker(
|
|
||||||
{
|
|
||||||
sendToActiveOverlayWindow: (channel, nextPayload, runtimeOptions) =>
|
|
||||||
input.overlay
|
|
||||||
.getOverlayUi()
|
|
||||||
?.sendToActiveOverlayWindow(channel, nextPayload, runtimeOptions) ?? false,
|
|
||||||
waitForModalOpen: (modal, timeoutMs) =>
|
|
||||||
input.overlay.getOverlayUi()?.waitForModalOpen(modal, timeoutMs) ??
|
|
||||||
Promise.resolve(false),
|
|
||||||
logWarn: (message) => input.services.logWarn(message),
|
|
||||||
},
|
|
||||||
payload,
|
|
||||||
),
|
|
||||||
pauseMpv: () => {
|
|
||||||
input.services.sendMpvCommand(['set_property', 'pause', 'yes']);
|
|
||||||
},
|
|
||||||
resumeMpv: () => {
|
|
||||||
input.services.sendMpvCommand(['set_property', 'pause', 'no']);
|
|
||||||
},
|
|
||||||
sendMpvCommand: (command) => {
|
|
||||||
input.services.sendMpvCommand(command);
|
|
||||||
},
|
|
||||||
requestMpvProperty: async (name) => {
|
|
||||||
const client = input.appState.getMpvClient();
|
|
||||||
if (!client) return null;
|
|
||||||
return await client.requestProperty(name);
|
|
||||||
},
|
|
||||||
refreshCurrentSubtitle: (text) => {
|
|
||||||
input.getSubtitle().refreshCurrentSubtitle(text);
|
|
||||||
},
|
|
||||||
refreshSubtitleSidebarSource: async (sourcePath) => {
|
|
||||||
await input.getSubtitle().refreshSubtitleSidebarFromSource(sourcePath);
|
|
||||||
},
|
|
||||||
startTokenizationWarmups: async () => {
|
|
||||||
await input.tokenization.startTokenizationWarmups();
|
|
||||||
},
|
|
||||||
waitForTokenizationReady: async () => {
|
|
||||||
const currentMediaPath =
|
|
||||||
input.appState.getCurrentMediaPath()?.trim() ||
|
|
||||||
input.appState.getMpvClient()?.currentVideoPath?.trim() ||
|
|
||||||
null;
|
|
||||||
await input.tokenization.getGate().waitUntilReady(currentMediaPath);
|
|
||||||
},
|
|
||||||
waitForAnkiReady: async () => {
|
|
||||||
const integration = input.appState.getAnkiIntegration();
|
|
||||||
if (!integration) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await Promise.race([
|
|
||||||
integration.waitUntilReady(),
|
|
||||||
new Promise<never>((_, reject) => {
|
|
||||||
setTimeout(
|
|
||||||
() => reject(new Error('Timed out waiting for AnkiConnect integration')),
|
|
||||||
2500,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
input.services.logWarn(
|
|
||||||
'Continuing YouTube playback before AnkiConnect integration reported ready:',
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
||||||
waitForPlaybackWindowReady: async () => {
|
|
||||||
const deadline = Date.now() + 4000;
|
|
||||||
let stableGeometry: WindowGeometry | null = null;
|
|
||||||
let stableSinceMs = 0;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const tracker = input.appState.getWindowTracker();
|
|
||||||
const trackerGeometry = tracker?.getGeometry() ?? null;
|
|
||||||
const mediaPath =
|
|
||||||
input.appState.getCurrentMediaPath()?.trim() ||
|
|
||||||
input.appState.getMpvClient()?.currentVideoPath?.trim() ||
|
|
||||||
'';
|
|
||||||
const trackerFocused = tracker?.isTargetWindowFocused() ?? false;
|
|
||||||
if (tracker && tracker.isTracking() && trackerGeometry && trackerFocused && mediaPath) {
|
|
||||||
if (
|
|
||||||
!input.overlay.getOverlayGeometry().geometryMatches(stableGeometry, trackerGeometry)
|
|
||||||
) {
|
|
||||||
stableGeometry = trackerGeometry;
|
|
||||||
stableSinceMs = Date.now();
|
|
||||||
} else if (Date.now() - stableSinceMs >= 200) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stableGeometry = null;
|
|
||||||
stableSinceMs = 0;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
}
|
|
||||||
input.services.logWarn(
|
|
||||||
'Timed out waiting for tracked playback window focus/media readiness before opening YouTube subtitle picker.',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
waitForOverlayGeometryReady: async () => {
|
|
||||||
const deadline = Date.now() + 4000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const trackerGeometry = input.appState.getWindowTracker()?.getGeometry() ?? null;
|
|
||||||
if (
|
|
||||||
trackerGeometry &&
|
|
||||||
input.overlay
|
|
||||||
.getOverlayGeometry()
|
|
||||||
.geometryMatches(
|
|
||||||
input.overlay.getOverlayGeometry().getLastOverlayWindowGeometry(),
|
|
||||||
trackerGeometry,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
}
|
|
||||||
input.services.logWarn(
|
|
||||||
'Timed out waiting for overlay geometry to match tracked playback window.',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
focusOverlayWindow: () => {
|
|
||||||
const mainWindow = input.overlay.getMainWindow();
|
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mainWindow.setIgnoreMouseEvents(false);
|
|
||||||
if (!mainWindow.isFocused()) {
|
|
||||||
mainWindow.focus();
|
|
||||||
}
|
|
||||||
if (!mainWindow.webContents.isFocused()) {
|
|
||||||
mainWindow.webContents.focus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
showMpvOsd: (text) => input.services.showMpvOsd(text),
|
|
||||||
warn: (message) => input.services.logWarn(message),
|
|
||||||
log: (message) => input.services.logInfo(message),
|
|
||||||
getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'),
|
|
||||||
},
|
|
||||||
playback: {
|
|
||||||
platform: input.config.platform,
|
|
||||||
directPlaybackFormat: input.config.directPlaybackFormat,
|
|
||||||
mpvYtdlFormat: input.config.mpvYtdlFormat,
|
|
||||||
autoLaunchTimeoutMs: input.config.autoLaunchTimeoutMs,
|
|
||||||
connectTimeoutMs: input.config.connectTimeoutMs,
|
|
||||||
getSocketPath: () => input.config.getSocketPath(),
|
|
||||||
getMpvConnected: () => Boolean(input.appState.getMpvClient()?.connected),
|
|
||||||
ensureYoutubePlaybackRuntimeReady: async () => {
|
|
||||||
await input.appReady.ensureYoutubePlaybackRuntimeReady();
|
|
||||||
},
|
|
||||||
resolveYoutubePlaybackUrl: (url, format) =>
|
|
||||||
input.services.resolveYoutubePlaybackUrl(url, format),
|
|
||||||
launchWindowsMpv: (playbackUrl, args) =>
|
|
||||||
launchWindowsMpv(
|
|
||||||
[playbackUrl],
|
|
||||||
createWindowsMpvLaunchDeps({
|
|
||||||
showError: (title, content) => input.services.showErrorBox(title, content),
|
|
||||||
}),
|
|
||||||
[...args, `--log-file=${input.config.logPath}`],
|
|
||||||
),
|
|
||||||
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
|
||||||
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
|
||||||
logInfo: (message) => input.services.logInfo(message),
|
|
||||||
logWarn: (message) => input.services.logWarn(message),
|
|
||||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
||||||
clearScheduled: (timer) => clearTimeout(timer),
|
|
||||||
},
|
|
||||||
autoplay: {
|
|
||||||
getCurrentMediaPath: () => input.appState.getCurrentMediaPath(),
|
|
||||||
getCurrentVideoPath: () => input.appState.getMpvClient()?.currentVideoPath ?? null,
|
|
||||||
getPlaybackPaused: () => input.appState.getPlaybackPaused(),
|
|
||||||
getMpvClient: () => input.appState.getMpvClient(),
|
|
||||||
signalPluginAutoplayReady: () => {
|
|
||||||
input.services.sendMpvCommand(['script-message', 'subminer-autoplay-ready']);
|
|
||||||
},
|
|
||||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
||||||
logDebug: (message) => input.services.logDebug(message),
|
|
||||||
},
|
|
||||||
notification: {
|
|
||||||
getPrimarySubtitleLanguages: () => input.config.getPrimarySubtitleLanguages(),
|
|
||||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
||||||
clearSchedule: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
|
|
||||||
},
|
|
||||||
getNotificationType: () => input.config.getNotificationType(),
|
|
||||||
getCurrentMediaPath: () => input.appState.getCurrentMediaPath(),
|
|
||||||
getCurrentVideoPath: () => input.appState.getMpvClient()?.currentVideoPath ?? null,
|
|
||||||
showMpvOsd: (message) => input.services.showMpvOsd(message),
|
|
||||||
showDesktopNotification: (title, options) =>
|
|
||||||
input.services.showDesktopNotification(title, options),
|
|
||||||
broadcastYoutubePickerCancel: () => {
|
|
||||||
input.overlay.broadcastYoutubePickerCancel();
|
|
||||||
},
|
|
||||||
closeYoutubePickerModal: () => {
|
|
||||||
input.overlay.getOverlayUi()?.handleOverlayModalClosed('youtube-track-picker');
|
|
||||||
},
|
|
||||||
logWarn: (message) => input.services.logWarn(message),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
import { IPC_CHANNELS } from '../shared/ipc/contracts';
|
|
||||||
import {
|
|
||||||
acquireYoutubeSubtitleTrack,
|
|
||||||
acquireYoutubeSubtitleTracks,
|
|
||||||
} from '../core/services/youtube/generate';
|
|
||||||
import { resolveYoutubePlaybackUrl } from '../core/services/youtube/playback-resolve';
|
|
||||||
import { probeYoutubeTracks } from '../core/services/youtube/track-probe';
|
|
||||||
import type { ResolvedConfig } from '../types';
|
|
||||||
import type { AppState } from './state';
|
|
||||||
import type { OverlayGeometryRuntime } from './overlay-geometry-runtime';
|
|
||||||
import type { OverlayUiRuntime } from './overlay-ui-runtime';
|
|
||||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
|
||||||
import { createYoutubeRuntime } from './youtube-runtime';
|
|
||||||
import { createYoutubeRuntimeInput } from './youtube-runtime-bootstrap';
|
|
||||||
|
|
||||||
export interface YoutubeRuntimeCoordinatorInput {
|
|
||||||
appState: {
|
|
||||||
getMpvClient: () => Parameters<
|
|
||||||
typeof createYoutubeRuntimeInput
|
|
||||||
>[0]['appState']['getMpvClient'] extends () => infer T
|
|
||||||
? T
|
|
||||||
: never;
|
|
||||||
getCurrentMediaPath: () => string | null;
|
|
||||||
getPlaybackPaused: () => boolean | null;
|
|
||||||
getWindowTracker: () => Parameters<
|
|
||||||
typeof createYoutubeRuntimeInput
|
|
||||||
>[0]['appState']['getWindowTracker'] extends () => infer T
|
|
||||||
? T
|
|
||||||
: never;
|
|
||||||
getAnkiIntegration: () => Parameters<
|
|
||||||
typeof createYoutubeRuntimeInput
|
|
||||||
>[0]['appState']['getAnkiIntegration'] extends () => infer T
|
|
||||||
? T
|
|
||||||
: never;
|
|
||||||
getSocketPath: () => string;
|
|
||||||
};
|
|
||||||
overlay: {
|
|
||||||
getOverlayUi: () => Parameters<
|
|
||||||
typeof createYoutubeRuntimeInput
|
|
||||||
>[0]['overlay']['getOverlayUi'] extends () => infer T
|
|
||||||
? T
|
|
||||||
: never;
|
|
||||||
getMainWindow: () => Parameters<
|
|
||||||
typeof createYoutubeRuntimeInput
|
|
||||||
>[0]['overlay']['getMainWindow'] extends () => infer T
|
|
||||||
? T
|
|
||||||
: never;
|
|
||||||
getOverlayGeometry: () => Parameters<
|
|
||||||
typeof createYoutubeRuntimeInput
|
|
||||||
>[0]['overlay']['getOverlayGeometry'] extends () => infer T
|
|
||||||
? T
|
|
||||||
: never;
|
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
|
||||||
};
|
|
||||||
subtitle: {
|
|
||||||
getSubtitle: Parameters<typeof createYoutubeRuntimeInput>[0]['getSubtitle'];
|
|
||||||
};
|
|
||||||
tokenization: {
|
|
||||||
startTokenizationWarmups: () => Promise<void>;
|
|
||||||
getGate: Parameters<typeof createYoutubeRuntimeInput>[0]['tokenization']['getGate'];
|
|
||||||
};
|
|
||||||
appReady: {
|
|
||||||
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
|
|
||||||
};
|
|
||||||
services: {
|
|
||||||
sendMpvCommand: (command: (string | number)[]) => void;
|
|
||||||
showMpvOsd: (message: string) => void;
|
|
||||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
|
||||||
showErrorBox: (title: string, content: string) => void;
|
|
||||||
logInfo: (message: string) => void;
|
|
||||||
logWarn: (message: string, error?: unknown) => void;
|
|
||||||
logDebug: (message: string) => void;
|
|
||||||
};
|
|
||||||
config: {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
directPlaybackFormat: string;
|
|
||||||
mpvYtdlFormat: string;
|
|
||||||
autoLaunchTimeoutMs: number;
|
|
||||||
connectTimeoutMs: number;
|
|
||||||
logPath: string;
|
|
||||||
getNotificationType: () => string;
|
|
||||||
getPrimarySubtitleLanguages: () => string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createYoutubeRuntimeCoordinator(input: YoutubeRuntimeCoordinatorInput) {
|
|
||||||
return createYoutubeRuntime(
|
|
||||||
createYoutubeRuntimeInput({
|
|
||||||
appState: {
|
|
||||||
getMpvClient: () => input.appState.getMpvClient(),
|
|
||||||
getCurrentMediaPath: () => input.appState.getCurrentMediaPath(),
|
|
||||||
getPlaybackPaused: () => input.appState.getPlaybackPaused(),
|
|
||||||
getWindowTracker: () => input.appState.getWindowTracker(),
|
|
||||||
getAnkiIntegration: () => input.appState.getAnkiIntegration(),
|
|
||||||
},
|
|
||||||
overlay: {
|
|
||||||
getOverlayUi: () => input.overlay.getOverlayUi(),
|
|
||||||
getMainWindow: () => input.overlay.getMainWindow(),
|
|
||||||
getOverlayGeometry: () => input.overlay.getOverlayGeometry(),
|
|
||||||
broadcastYoutubePickerCancel: () => {
|
|
||||||
input.overlay.broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
getSubtitle: () => input.subtitle.getSubtitle(),
|
|
||||||
tokenization: {
|
|
||||||
startTokenizationWarmups: () => input.tokenization.startTokenizationWarmups(),
|
|
||||||
getGate: () => input.tokenization.getGate(),
|
|
||||||
},
|
|
||||||
appReady: {
|
|
||||||
ensureYoutubePlaybackRuntimeReady: () => input.appReady.ensureYoutubePlaybackRuntimeReady(),
|
|
||||||
},
|
|
||||||
services: {
|
|
||||||
probeYoutubeTracks: (url) => probeYoutubeTracks(url),
|
|
||||||
acquireYoutubeSubtitleTrack: (request) => acquireYoutubeSubtitleTrack(request),
|
|
||||||
acquireYoutubeSubtitleTracks: (request) => acquireYoutubeSubtitleTracks(request),
|
|
||||||
resolveYoutubePlaybackUrl: (url, format) => resolveYoutubePlaybackUrl(url, format),
|
|
||||||
sendMpvCommand: (command) => input.services.sendMpvCommand(command),
|
|
||||||
showMpvOsd: (message) => input.services.showMpvOsd(message),
|
|
||||||
showDesktopNotification: (title, options) =>
|
|
||||||
input.services.showDesktopNotification(title, options),
|
|
||||||
showErrorBox: (title, content) => input.services.showErrorBox(title, content),
|
|
||||||
logInfo: (message) => input.services.logInfo(message),
|
|
||||||
logWarn: (message, error) => input.services.logWarn(message, error),
|
|
||||||
logDebug: (message) => input.services.logDebug(message),
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
platform: input.config.platform,
|
|
||||||
directPlaybackFormat: input.config.directPlaybackFormat,
|
|
||||||
mpvYtdlFormat: input.config.mpvYtdlFormat,
|
|
||||||
autoLaunchTimeoutMs: input.config.autoLaunchTimeoutMs,
|
|
||||||
connectTimeoutMs: input.config.connectTimeoutMs,
|
|
||||||
logPath: input.config.logPath,
|
|
||||||
getSocketPath: () => input.appState.getSocketPath(),
|
|
||||||
getNotificationType: () => input.config.getNotificationType(),
|
|
||||||
getPrimarySubtitleLanguages: () => input.config.getPrimarySubtitleLanguages(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface YoutubeRuntimeFromMainStateInput {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
directPlaybackFormat: string;
|
|
||||||
mpvYtdlFormat: string;
|
|
||||||
autoLaunchTimeoutMs: number;
|
|
||||||
connectTimeoutMs: number;
|
|
||||||
logPath: string;
|
|
||||||
appState: Pick<
|
|
||||||
AppState,
|
|
||||||
| 'mpvClient'
|
|
||||||
| 'currentMediaPath'
|
|
||||||
| 'playbackPaused'
|
|
||||||
| 'windowTracker'
|
|
||||||
| 'ankiIntegration'
|
|
||||||
| 'mpvSocketPath'
|
|
||||||
>;
|
|
||||||
overlay: {
|
|
||||||
getOverlayUi: () => OverlayUiRuntime<Electron.BrowserWindow> | null;
|
|
||||||
getMainWindow: () => Electron.BrowserWindow | null;
|
|
||||||
getOverlayGeometry: () => OverlayGeometryRuntime<Electron.BrowserWindow>;
|
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
|
||||||
};
|
|
||||||
subtitle: {
|
|
||||||
getSubtitle: () => SubtitleRuntime;
|
|
||||||
};
|
|
||||||
tokenization: {
|
|
||||||
startTokenizationWarmups: () => Promise<void>;
|
|
||||||
getGate: Parameters<typeof createYoutubeRuntimeInput>[0]['tokenization']['getGate'];
|
|
||||||
};
|
|
||||||
appReady: {
|
|
||||||
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
|
|
||||||
};
|
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
|
||||||
notifications: {
|
|
||||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
|
||||||
showErrorBox: (title: string, content: string) => void;
|
|
||||||
};
|
|
||||||
mpv: {
|
|
||||||
sendMpvCommand: (command: (string | number)[]) => void;
|
|
||||||
showMpvOsd: (message: string) => void;
|
|
||||||
};
|
|
||||||
logger: {
|
|
||||||
info: (message: string) => void;
|
|
||||||
warn: (message: string, error?: unknown) => void;
|
|
||||||
debug: (message: string) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createYoutubeRuntimeFromMainState(input: YoutubeRuntimeFromMainStateInput) {
|
|
||||||
return createYoutubeRuntimeCoordinator({
|
|
||||||
appState: {
|
|
||||||
getMpvClient: () => input.appState.mpvClient,
|
|
||||||
getCurrentMediaPath: () => input.appState.currentMediaPath,
|
|
||||||
getPlaybackPaused: () => input.appState.playbackPaused,
|
|
||||||
getWindowTracker: () => input.appState.windowTracker,
|
|
||||||
getAnkiIntegration: () => input.appState.ankiIntegration,
|
|
||||||
getSocketPath: () => input.appState.mpvSocketPath,
|
|
||||||
},
|
|
||||||
overlay: {
|
|
||||||
getOverlayUi: () => input.overlay.getOverlayUi(),
|
|
||||||
getMainWindow: () => input.overlay.getMainWindow(),
|
|
||||||
getOverlayGeometry: () => input.overlay.getOverlayGeometry(),
|
|
||||||
broadcastToOverlayWindows: (channel, payload) => {
|
|
||||||
input.overlay.broadcastToOverlayWindows(channel, payload);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
getSubtitle: () => input.subtitle.getSubtitle(),
|
|
||||||
},
|
|
||||||
tokenization: {
|
|
||||||
startTokenizationWarmups: () => input.tokenization.startTokenizationWarmups(),
|
|
||||||
getGate: () => input.tokenization.getGate(),
|
|
||||||
},
|
|
||||||
appReady: {
|
|
||||||
ensureYoutubePlaybackRuntimeReady: () => input.appReady.ensureYoutubePlaybackRuntimeReady(),
|
|
||||||
},
|
|
||||||
services: {
|
|
||||||
sendMpvCommand: (command) => input.mpv.sendMpvCommand(command),
|
|
||||||
showMpvOsd: (message) => input.mpv.showMpvOsd(message),
|
|
||||||
showDesktopNotification: (title, options) =>
|
|
||||||
input.notifications.showDesktopNotification(title, options),
|
|
||||||
showErrorBox: (title, content) => input.notifications.showErrorBox(title, content),
|
|
||||||
logInfo: (message) => input.logger.info(message),
|
|
||||||
logWarn: (message, error) => input.logger.warn(message, error),
|
|
||||||
logDebug: (message) => input.logger.debug(message),
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
platform: input.platform,
|
|
||||||
directPlaybackFormat: input.directPlaybackFormat,
|
|
||||||
mpvYtdlFormat: input.mpvYtdlFormat,
|
|
||||||
autoLaunchTimeoutMs: input.autoLaunchTimeoutMs,
|
|
||||||
connectTimeoutMs: input.connectTimeoutMs,
|
|
||||||
logPath: input.logPath,
|
|
||||||
getNotificationType: () => input.getResolvedConfig().ankiConnect.behavior.notificationType,
|
|
||||||
getPrimarySubtitleLanguages: () => input.getResolvedConfig().youtube.primarySubLanguages,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createYoutubeRuntime } from './youtube-runtime';
|
|
||||||
|
|
||||||
function createRuntime(overrides: Partial<Parameters<typeof createYoutubeRuntime>[0]> = {}) {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const runtime = createYoutubeRuntime({
|
|
||||||
flow: {
|
|
||||||
probeYoutubeTracks: async () => ({ videoId: 'demo', title: 'Demo', tracks: [] }),
|
|
||||||
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/primary.vtt' }),
|
|
||||||
acquireYoutubeSubtitleTracks: async () => new Map<string, string>(),
|
|
||||||
openPicker: async () => true,
|
|
||||||
pauseMpv: () => {},
|
|
||||||
resumeMpv: () => {},
|
|
||||||
sendMpvCommand: () => {},
|
|
||||||
requestMpvProperty: async () => null,
|
|
||||||
refreshCurrentSubtitle: () => {},
|
|
||||||
startTokenizationWarmups: async () => {},
|
|
||||||
waitForTokenizationReady: async () => {},
|
|
||||||
waitForAnkiReady: async () => {},
|
|
||||||
wait: async () => {},
|
|
||||||
waitForPlaybackWindowReady: async () => {},
|
|
||||||
waitForOverlayGeometryReady: async () => {},
|
|
||||||
focusOverlayWindow: () => {},
|
|
||||||
showMpvOsd: (message) => {
|
|
||||||
calls.push(`flow-osd:${message}`);
|
|
||||||
},
|
|
||||||
warn: (message) => {
|
|
||||||
calls.push(`warn:${message}`);
|
|
||||||
},
|
|
||||||
log: (message) => {
|
|
||||||
calls.push(`log:${message}`);
|
|
||||||
},
|
|
||||||
getYoutubeOutputDir: () => '/tmp',
|
|
||||||
},
|
|
||||||
playback: {
|
|
||||||
platform: 'linux',
|
|
||||||
directPlaybackFormat: 'b',
|
|
||||||
mpvYtdlFormat: 'best',
|
|
||||||
autoLaunchTimeoutMs: 1000,
|
|
||||||
connectTimeoutMs: 1000,
|
|
||||||
getSocketPath: () => '/tmp/mpv.sock',
|
|
||||||
getMpvConnected: () => true,
|
|
||||||
ensureYoutubePlaybackRuntimeReady: async () => {},
|
|
||||||
resolveYoutubePlaybackUrl: async (url) => url,
|
|
||||||
launchWindowsMpv: () => ({ ok: false }),
|
|
||||||
waitForYoutubeMpvConnected: async () => true,
|
|
||||||
prepareYoutubePlaybackInMpv: async () => true,
|
|
||||||
logInfo: () => {},
|
|
||||||
logWarn: () => {},
|
|
||||||
schedule: (callback) => setTimeout(callback, 0),
|
|
||||||
clearScheduled: (timer) => clearTimeout(timer),
|
|
||||||
},
|
|
||||||
autoplay: {
|
|
||||||
getCurrentMediaPath: () => null,
|
|
||||||
getCurrentVideoPath: () => null,
|
|
||||||
getPlaybackPaused: () => true,
|
|
||||||
getMpvClient: () => null,
|
|
||||||
signalPluginAutoplayReady: () => {
|
|
||||||
calls.push('autoplay-ready');
|
|
||||||
},
|
|
||||||
schedule: (callback) => setTimeout(callback, 0),
|
|
||||||
logDebug: () => {},
|
|
||||||
},
|
|
||||||
notification: {
|
|
||||||
getPrimarySubtitleLanguages: () => ['ja'],
|
|
||||||
schedule: (callback) => setTimeout(callback, 0),
|
|
||||||
clearSchedule: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
|
|
||||||
},
|
|
||||||
getNotificationType: () => 'osd',
|
|
||||||
getCurrentMediaPath: () => null,
|
|
||||||
getCurrentVideoPath: () => null,
|
|
||||||
showMpvOsd: (message) => {
|
|
||||||
calls.push(`osd:${message}`);
|
|
||||||
},
|
|
||||||
showDesktopNotification: (_title, options) => {
|
|
||||||
calls.push(`notify:${options.body}`);
|
|
||||||
},
|
|
||||||
broadcastYoutubePickerCancel: () => {
|
|
||||||
calls.push('picker-cancel');
|
|
||||||
},
|
|
||||||
closeYoutubePickerModal: () => {
|
|
||||||
calls.push('close-modal');
|
|
||||||
},
|
|
||||||
logWarn: (message) => {
|
|
||||||
calls.push(`warn:${message}`);
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
runtime,
|
|
||||||
calls,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('youtube runtime gates manual picker availability by playback context', async () => {
|
|
||||||
const inactive = createRuntime({
|
|
||||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
|
||||||
getCurrentVideoPath: () => null,
|
|
||||||
});
|
|
||||||
|
|
||||||
await inactive.runtime.openYoutubeTrackPickerFromPlayback();
|
|
||||||
assert.ok(
|
|
||||||
inactive.calls.includes(
|
|
||||||
'osd:YouTube subtitle picker is only available during YouTube playback.',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const active = createRuntime({
|
|
||||||
getCurrentMediaPath: () => 'https://www.youtube.com/watch?v=demo',
|
|
||||||
getCurrentVideoPath: () => null,
|
|
||||||
createFlowRuntime: () => ({
|
|
||||||
runYoutubePlaybackFlow: async () => {},
|
|
||||||
openManualPicker: async ({ url }) => {
|
|
||||||
active.calls.push(`manual-picker:${url}`);
|
|
||||||
},
|
|
||||||
resolveActivePicker: async () => ({ ok: true, message: 'resolved' }),
|
|
||||||
cancelActivePicker: () => true,
|
|
||||||
hasActiveSession: () => false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
await active.runtime.openYoutubeTrackPickerFromPlayback();
|
|
||||||
assert.ok(active.calls.includes('manual-picker:https://www.youtube.com/watch?v=demo'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('youtube runtime cancels active picker on mpv disconnect', () => {
|
|
||||||
const harness = createRuntime({
|
|
||||||
createFlowRuntime: () => ({
|
|
||||||
runYoutubePlaybackFlow: async () => {},
|
|
||||||
openManualPicker: async () => {},
|
|
||||||
resolveActivePicker: async () => ({ ok: true, message: 'resolved' }),
|
|
||||||
cancelActivePicker: () => {
|
|
||||||
harness.calls.push('cancel-active');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
hasActiveSession: () => true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
harness.runtime.handleMpvConnectionChange(false);
|
|
||||||
|
|
||||||
assert.deepEqual(harness.calls, ['cancel-active', 'picker-cancel', 'close-modal']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('youtube runtime delegates picker resolution to flow runtime', async () => {
|
|
||||||
const harness = createRuntime({
|
|
||||||
createFlowRuntime: () => ({
|
|
||||||
runYoutubePlaybackFlow: async () => {},
|
|
||||||
openManualPicker: async () => {},
|
|
||||||
resolveActivePicker: async (request) => ({ request, ok: true, message: 'resolved' }),
|
|
||||||
cancelActivePicker: () => true,
|
|
||||||
hasActiveSession: () => false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const request = {
|
|
||||||
sessionId: 'session-1',
|
|
||||||
action: 'use-selected' as const,
|
|
||||||
primaryTrackId: 'ja',
|
|
||||||
secondaryTrackId: null,
|
|
||||||
};
|
|
||||||
const result = await harness.runtime.resolveActivePicker(request);
|
|
||||||
assert.deepEqual(result, { request, ok: true, message: 'resolved' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('youtube runtime routes subtitle failures through configured notification channels', () => {
|
|
||||||
const harness = createRuntime({
|
|
||||||
getNotificationType: () => 'both',
|
|
||||||
});
|
|
||||||
|
|
||||||
harness.runtime.reportYoutubeSubtitleFailure('Primary subtitles failed');
|
|
||||||
|
|
||||||
assert.ok(harness.calls.includes('osd:Primary subtitles failed'));
|
|
||||||
assert.ok(harness.calls.includes('notify:Primary subtitles failed'));
|
|
||||||
});
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
import type { CliArgs, CliCommandSource } from '../cli/args';
|
|
||||||
import type {
|
|
||||||
SubtitleData,
|
|
||||||
YoutubePickerOpenPayload,
|
|
||||||
YoutubePickerResolveRequest,
|
|
||||||
YoutubePickerResolveResult,
|
|
||||||
} from '../types';
|
|
||||||
import type {
|
|
||||||
YoutubeTrackOption,
|
|
||||||
YoutubeTrackProbeResult,
|
|
||||||
} from '../core/services/youtube/track-probe';
|
|
||||||
import { createAutoplayReadyGate } from './runtime/autoplay-ready-gate';
|
|
||||||
import { createYoutubeFlowRuntime } from './runtime/youtube-flow';
|
|
||||||
import { createYoutubePlaybackRuntime } from './runtime/youtube-playback-runtime';
|
|
||||||
import {
|
|
||||||
clearYoutubePrimarySubtitleNotificationTimer,
|
|
||||||
createYoutubePrimarySubtitleNotificationRuntime,
|
|
||||||
} from './runtime/youtube-primary-subtitle-notification';
|
|
||||||
import { isYoutubePlaybackActive } from './runtime/youtube-playback';
|
|
||||||
|
|
||||||
type YoutubeFlowRuntimeLike = {
|
|
||||||
runYoutubePlaybackFlow: (request: {
|
|
||||||
url: string;
|
|
||||||
mode: 'download' | 'generate';
|
|
||||||
}) => Promise<void>;
|
|
||||||
openManualPicker: (request: { url: string }) => Promise<void>;
|
|
||||||
resolveActivePicker: (
|
|
||||||
request: YoutubePickerResolveRequest,
|
|
||||||
) => Promise<YoutubePickerResolveResult>;
|
|
||||||
cancelActivePicker: () => boolean;
|
|
||||||
hasActiveSession: () => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type YoutubePlaybackRuntimeLike = {
|
|
||||||
clearYoutubePlayQuitOnDisconnectArmTimer: () => void;
|
|
||||||
getQuitOnDisconnectArmed: () => boolean;
|
|
||||||
runYoutubePlaybackFlow: (request: {
|
|
||||||
url: string;
|
|
||||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
|
||||||
source: CliCommandSource;
|
|
||||||
}) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type YoutubeAutoplayGateLike = {
|
|
||||||
getAutoPlayReadySignalMediaPath: () => string | null;
|
|
||||||
invalidatePendingAutoplayReadyFallbacks: () => void;
|
|
||||||
maybeSignalPluginAutoplayReady: (
|
|
||||||
payload: SubtitleData,
|
|
||||||
options?: { forceWhilePaused?: boolean },
|
|
||||||
) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type YoutubePrimarySubtitleNotificationRuntimeLike = {
|
|
||||||
handleMediaPathChange: (path: string | null) => void;
|
|
||||||
handleSubtitleTrackChange: (sid: number | null) => void;
|
|
||||||
handleSubtitleTrackListChange: (trackList: unknown[] | null) => void;
|
|
||||||
setAppOwnedFlowInFlight: (inFlight: boolean) => void;
|
|
||||||
isAppOwnedFlowInFlight: () => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface YoutubeFlowRuntimeInput {
|
|
||||||
probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>;
|
|
||||||
acquireYoutubeSubtitleTrack: (input: {
|
|
||||||
targetUrl: string;
|
|
||||||
outputDir: string;
|
|
||||||
track: YoutubeTrackOption;
|
|
||||||
}) => Promise<{ path: string }>;
|
|
||||||
acquireYoutubeSubtitleTracks: (input: {
|
|
||||||
targetUrl: string;
|
|
||||||
outputDir: string;
|
|
||||||
tracks: YoutubeTrackOption[];
|
|
||||||
}) => Promise<Map<string, string>>;
|
|
||||||
openPicker: (payload: YoutubePickerOpenPayload) => Promise<boolean>;
|
|
||||||
pauseMpv: () => void;
|
|
||||||
resumeMpv: () => void;
|
|
||||||
sendMpvCommand: (command: Array<string | number>) => void;
|
|
||||||
requestMpvProperty: (name: string) => Promise<unknown>;
|
|
||||||
refreshCurrentSubtitle: (text: string) => void;
|
|
||||||
refreshSubtitleSidebarSource?: (sourcePath: string) => Promise<void>;
|
|
||||||
startTokenizationWarmups: () => Promise<void>;
|
|
||||||
waitForTokenizationReady: () => Promise<void>;
|
|
||||||
waitForAnkiReady: () => Promise<void>;
|
|
||||||
wait: (ms: number) => Promise<void>;
|
|
||||||
waitForPlaybackWindowReady: () => Promise<void>;
|
|
||||||
waitForOverlayGeometryReady: () => Promise<void>;
|
|
||||||
focusOverlayWindow: () => void;
|
|
||||||
showMpvOsd: (text: string) => void;
|
|
||||||
warn: (message: string) => void;
|
|
||||||
log: (message: string) => void;
|
|
||||||
getYoutubeOutputDir: () => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface YoutubePlaybackRuntimeInput {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
directPlaybackFormat: string;
|
|
||||||
mpvYtdlFormat: string;
|
|
||||||
autoLaunchTimeoutMs: number;
|
|
||||||
connectTimeoutMs: number;
|
|
||||||
getSocketPath: () => string;
|
|
||||||
getMpvConnected: () => boolean;
|
|
||||||
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
|
|
||||||
resolveYoutubePlaybackUrl: (url: string, format: string) => Promise<string>;
|
|
||||||
launchWindowsMpv: (
|
|
||||||
playbackUrl: string,
|
|
||||||
args: string[],
|
|
||||||
) => {
|
|
||||||
ok: boolean;
|
|
||||||
mpvPath?: string;
|
|
||||||
};
|
|
||||||
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
|
|
||||||
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
|
|
||||||
logInfo: (message: string) => void;
|
|
||||||
logWarn: (message: string) => void;
|
|
||||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
|
||||||
clearScheduled: (timer: ReturnType<typeof setTimeout>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface YoutubeAutoplayRuntimeInput {
|
|
||||||
getCurrentMediaPath: () => string | null;
|
|
||||||
getCurrentVideoPath: () => string | null;
|
|
||||||
getPlaybackPaused: () => boolean | null;
|
|
||||||
getMpvClient: () => {
|
|
||||||
connected?: boolean;
|
|
||||||
requestProperty: (property: string) => Promise<unknown>;
|
|
||||||
send: (payload: { command: Array<string | boolean> }) => void;
|
|
||||||
} | null;
|
|
||||||
signalPluginAutoplayReady: () => void;
|
|
||||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
|
||||||
logDebug: (message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface YoutubeNotificationRuntimeInput {
|
|
||||||
getPrimarySubtitleLanguages: () => string[];
|
|
||||||
schedule: (
|
|
||||||
callback: () => void,
|
|
||||||
delayMs: number,
|
|
||||||
) => ReturnType<typeof setTimeout> | { id: number };
|
|
||||||
clearSchedule: (timer: ReturnType<typeof setTimeout> | { id: number } | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface YoutubeRuntimeInput {
|
|
||||||
flow: YoutubeFlowRuntimeInput;
|
|
||||||
playback: YoutubePlaybackRuntimeInput;
|
|
||||||
autoplay: YoutubeAutoplayRuntimeInput;
|
|
||||||
notification: YoutubeNotificationRuntimeInput;
|
|
||||||
getNotificationType: () => 'osd' | 'system' | 'both' | string;
|
|
||||||
getCurrentMediaPath: () => string | null;
|
|
||||||
getCurrentVideoPath: () => string | null;
|
|
||||||
showMpvOsd: (message: string) => void;
|
|
||||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
|
||||||
broadcastYoutubePickerCancel: () => void;
|
|
||||||
closeYoutubePickerModal: () => void;
|
|
||||||
logWarn: (message: string) => void;
|
|
||||||
createFlowRuntime?: (
|
|
||||||
input: YoutubeFlowRuntimeInput & { reportSubtitleFailure: (message: string) => void },
|
|
||||||
) => YoutubeFlowRuntimeLike;
|
|
||||||
createPlaybackRuntime?: (
|
|
||||||
input: YoutubePlaybackRuntimeInput & {
|
|
||||||
invalidatePendingAutoplayReadyFallbacks: () => void;
|
|
||||||
setAppOwnedFlowInFlight: (next: boolean) => void;
|
|
||||||
runYoutubePlaybackFlow: (request: {
|
|
||||||
url: string;
|
|
||||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
|
||||||
}) => Promise<void>;
|
|
||||||
},
|
|
||||||
) => YoutubePlaybackRuntimeLike;
|
|
||||||
createAutoplayGate?: (
|
|
||||||
input: {
|
|
||||||
isAppOwnedFlowInFlight: () => boolean;
|
|
||||||
} & YoutubeAutoplayRuntimeInput,
|
|
||||||
) => YoutubeAutoplayGateLike;
|
|
||||||
createPrimarySubtitleNotificationRuntime?: (
|
|
||||||
input: {
|
|
||||||
notifyFailure: (message: string) => void;
|
|
||||||
} & YoutubeNotificationRuntimeInput,
|
|
||||||
) => YoutubePrimarySubtitleNotificationRuntimeLike;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface YoutubeRuntime {
|
|
||||||
runYoutubePlaybackFlow: (request: {
|
|
||||||
url: string;
|
|
||||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
|
||||||
source: CliCommandSource;
|
|
||||||
}) => Promise<void>;
|
|
||||||
openYoutubeTrackPickerFromPlayback: () => Promise<void>;
|
|
||||||
resolveActivePicker: (
|
|
||||||
request: YoutubePickerResolveRequest,
|
|
||||||
) => Promise<YoutubePickerResolveResult>;
|
|
||||||
handleMpvConnectionChange: (connected: boolean) => void;
|
|
||||||
reportYoutubeSubtitleFailure: (message: string) => void;
|
|
||||||
handleMediaPathChange: (path: string | null) => void;
|
|
||||||
handleSubtitleTrackChange: (sid: number | null) => void;
|
|
||||||
handleSubtitleTrackListChange: (trackList: unknown[] | null) => void;
|
|
||||||
maybeSignalPluginAutoplayReady: (
|
|
||||||
payload: SubtitleData,
|
|
||||||
options?: { forceWhilePaused?: boolean },
|
|
||||||
) => void;
|
|
||||||
invalidatePendingAutoplayReadyFallbacks: () => void;
|
|
||||||
getAutoPlayReadySignalMediaPath: () => string | null;
|
|
||||||
clearYoutubePlayQuitOnDisconnectArmTimer: () => void;
|
|
||||||
getQuitOnDisconnectArmed: () => boolean;
|
|
||||||
isAppOwnedFlowInFlight: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createYoutubeRuntime(input: YoutubeRuntimeInput): YoutubeRuntime {
|
|
||||||
const reportYoutubeSubtitleFailure = (message: string): void => {
|
|
||||||
const type = input.getNotificationType();
|
|
||||||
if (type === 'osd' || type === 'both') {
|
|
||||||
input.showMpvOsd(message);
|
|
||||||
}
|
|
||||||
if (type === 'system' || type === 'both') {
|
|
||||||
try {
|
|
||||||
input.showDesktopNotification('SubMiner', { body: message });
|
|
||||||
} catch {
|
|
||||||
input.logWarn(`Unable to show desktop notification: ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const notificationRuntime = (
|
|
||||||
input.createPrimarySubtitleNotificationRuntime ??
|
|
||||||
((deps) => createYoutubePrimarySubtitleNotificationRuntime(deps))
|
|
||||||
)({
|
|
||||||
...input.notification,
|
|
||||||
notifyFailure: (message) => reportYoutubeSubtitleFailure(message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const autoplayGate = (input.createAutoplayGate ?? ((deps) => createAutoplayReadyGate(deps)))({
|
|
||||||
...input.autoplay,
|
|
||||||
isAppOwnedFlowInFlight: () => notificationRuntime.isAppOwnedFlowInFlight(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const flowRuntime = (
|
|
||||||
input.createFlowRuntime ?? ((deps) => createYoutubeFlowRuntime(deps as never))
|
|
||||||
)({
|
|
||||||
...input.flow,
|
|
||||||
reportSubtitleFailure: (message) => reportYoutubeSubtitleFailure(message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const playbackRuntime = (
|
|
||||||
input.createPlaybackRuntime ?? ((deps) => createYoutubePlaybackRuntime(deps))
|
|
||||||
)({
|
|
||||||
...input.playback,
|
|
||||||
invalidatePendingAutoplayReadyFallbacks: () =>
|
|
||||||
autoplayGate.invalidatePendingAutoplayReadyFallbacks(),
|
|
||||||
setAppOwnedFlowInFlight: (next) => {
|
|
||||||
notificationRuntime.setAppOwnedFlowInFlight(next);
|
|
||||||
},
|
|
||||||
runYoutubePlaybackFlow: (request) => flowRuntime.runYoutubePlaybackFlow(request),
|
|
||||||
});
|
|
||||||
|
|
||||||
const isYoutubePlaybackActiveNow = (): boolean =>
|
|
||||||
isYoutubePlaybackActive(input.getCurrentMediaPath(), input.getCurrentVideoPath());
|
|
||||||
|
|
||||||
const openYoutubeTrackPickerFromPlayback = async (): Promise<void> => {
|
|
||||||
if (flowRuntime.hasActiveSession()) {
|
|
||||||
input.showMpvOsd('YouTube subtitle flow already in progress.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentMediaPath =
|
|
||||||
input.getCurrentMediaPath()?.trim() || input.getCurrentVideoPath()?.trim() || '';
|
|
||||||
if (!isYoutubePlaybackActiveNow() || !currentMediaPath) {
|
|
||||||
input.showMpvOsd('YouTube subtitle picker is only available during YouTube playback.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await flowRuntime.openManualPicker({
|
|
||||||
url: currentMediaPath,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMpvConnectionChange = (connected: boolean): void => {
|
|
||||||
if (connected || !flowRuntime.hasActiveSession()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
flowRuntime.cancelActivePicker();
|
|
||||||
input.broadcastYoutubePickerCancel();
|
|
||||||
input.closeYoutubePickerModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
runYoutubePlaybackFlow: (request) => playbackRuntime.runYoutubePlaybackFlow(request),
|
|
||||||
openYoutubeTrackPickerFromPlayback,
|
|
||||||
resolveActivePicker: (request) => flowRuntime.resolveActivePicker(request),
|
|
||||||
handleMpvConnectionChange,
|
|
||||||
reportYoutubeSubtitleFailure,
|
|
||||||
handleMediaPathChange: (path) => notificationRuntime.handleMediaPathChange(path),
|
|
||||||
handleSubtitleTrackChange: (sid) => notificationRuntime.handleSubtitleTrackChange(sid),
|
|
||||||
handleSubtitleTrackListChange: (trackList) =>
|
|
||||||
notificationRuntime.handleSubtitleTrackListChange(trackList),
|
|
||||||
maybeSignalPluginAutoplayReady: (payload, options) =>
|
|
||||||
autoplayGate.maybeSignalPluginAutoplayReady(payload, options),
|
|
||||||
invalidatePendingAutoplayReadyFallbacks: () =>
|
|
||||||
autoplayGate.invalidatePendingAutoplayReadyFallbacks(),
|
|
||||||
getAutoPlayReadySignalMediaPath: () => autoplayGate.getAutoPlayReadySignalMediaPath(),
|
|
||||||
clearYoutubePlayQuitOnDisconnectArmTimer: () =>
|
|
||||||
playbackRuntime.clearYoutubePlayQuitOnDisconnectArmTimer(),
|
|
||||||
getQuitOnDisconnectArmed: () => playbackRuntime.getQuitOnDisconnectArmed(),
|
|
||||||
isAppOwnedFlowInFlight: () => notificationRuntime.isAppOwnedFlowInFlight(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { clearYoutubePrimarySubtitleNotificationTimer };
|
|
||||||
Reference in New Issue
Block a user