mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-01 06:12:07 -07:00
refactor: split main.ts into domain runtimes
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
id: TASK-238.8
|
||||||
|
title: Refactor src/main.ts composition root into domain runtimes
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-31 06:28'
|
||||||
|
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 -->
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
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 -->
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
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 -->
|
||||||
6
changes/260-main-runtime-refactor.md
Normal file
6
changes/260-main-runtime-refactor.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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-26
|
Last verified: 2026-03-31
|
||||||
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,6 +24,27 @@ 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.
|
||||||
|
|||||||
4803
src/main.ts
4803
src/main.ts
File diff suppressed because it is too large
Load Diff
110
src/main/anilist-runtime-coordinator.ts
Normal file
110
src/main/anilist-runtime-coordinator.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
192
src/main/anilist-runtime.test.ts
Normal file
192
src/main/anilist-runtime.test.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
495
src/main/anilist-runtime.ts
Normal file
495
src/main/anilist-runtime.ts
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
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 };
|
||||||
270
src/main/app-ready-runtime.test.ts
Normal file
270
src/main/app-ready-runtime.test.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
264
src/main/app-ready-runtime.ts
Normal file
264
src/main/app-ready-runtime.ts
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
94
src/main/cli-startup-runtime.test.ts
Normal file
94
src/main/cli-startup-runtime.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
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']);
|
||||||
|
});
|
||||||
220
src/main/cli-startup-runtime.ts
Normal file
220
src/main/cli-startup-runtime.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/main/default-socket-path.ts
Normal file
12
src/main/default-socket-path.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import {
|
||||||
|
createBuildGetDefaultSocketPathMainDepsHandler,
|
||||||
|
createGetDefaultSocketPathHandler,
|
||||||
|
} from './runtime/domains/jellyfin';
|
||||||
|
|
||||||
|
export function createDefaultSocketPathResolver(platform: NodeJS.Platform) {
|
||||||
|
return createGetDefaultSocketPathHandler(
|
||||||
|
createBuildGetDefaultSocketPathMainDepsHandler({
|
||||||
|
platform,
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}
|
||||||
205
src/main/dictionary-support-runtime-input.ts
Normal file
205
src/main/dictionary-support-runtime-input.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
215
src/main/dictionary-support-runtime.test.ts
Normal file
215
src/main/dictionary-support-runtime.test.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
306
src/main/dictionary-support-runtime.ts
Normal file
306
src/main/dictionary-support-runtime.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
55
src/main/discord-presence-lifecycle-runtime.test.ts
Normal file
55
src/main/discord-presence-lifecycle-runtime.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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']);
|
||||||
|
});
|
||||||
90
src/main/discord-presence-lifecycle-runtime.ts
Normal file
90
src/main/discord-presence-lifecycle-runtime.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
92
src/main/first-run-runtime-coordinator.ts
Normal file
92
src/main/first-run-runtime-coordinator.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
155
src/main/first-run-runtime.test.ts
Normal file
155
src/main/first-run-runtime.test.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
235
src/main/first-run-runtime.ts
Normal file
235
src/main/first-run-runtime.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
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';
|
import { createFrequencyDictionaryLookup } from '../core/services/frequency-dictionary';
|
||||||
|
|
||||||
export interface FrequencyDictionarySearchPathDeps {
|
export interface FrequencyDictionarySearchPathDeps {
|
||||||
getDictionaryRoots: () => string[];
|
getDictionaryRoots: () => string[];
|
||||||
|
|||||||
55
src/main/headless-known-word-refresh.ts
Normal file
55
src/main/headless-known-word-refresh.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/main/headless-startup-runtime.test.ts
Normal file
131
src/main/headless-startup-runtime.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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']);
|
||||||
|
});
|
||||||
106
src/main/headless-startup-runtime.ts
Normal file
106
src/main/headless-startup-runtime.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
253
src/main/ipc-runtime-bootstrap.ts
Normal file
253
src/main/ipc-runtime-bootstrap.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
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 { 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;
|
||||||
|
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'
|
||||||
|
>;
|
||||||
|
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(),
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
43
src/main/ipc-runtime-services.ts
Normal file
43
src/main/ipc-runtime-services.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
176
src/main/ipc-runtime.test.ts
Normal file
176
src/main/ipc-runtime.test.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
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: () => {},
|
||||||
|
},
|
||||||
|
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: () => {},
|
||||||
|
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: {
|
||||||
|
handleMpvCommand: unknown;
|
||||||
|
runSubsyncManual: (payload: unknown) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
assert.equal(registration.runtimeOptions.showMpvOsd !== undefined, 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: {
|
||||||
|
handleMpvCommand: unknown;
|
||||||
|
runSubsyncManual: (payload: unknown) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
assert.equal(registration.runtimeOptions.showMpvOsd !== undefined, true);
|
||||||
|
assert.equal(registration.mainDeps.handleMpvCommand instanceof Function, true);
|
||||||
|
assert.deepEqual(
|
||||||
|
await registration.mainDeps.runSubsyncManual({ payload: null } as never),
|
||||||
|
input.manualResult,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
import {
|
import type {
|
||||||
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;
|
||||||
@@ -19,28 +17,131 @@ export interface RegisterIpcRuntimeServicesParams {
|
|||||||
ankiJimakuDeps: AnkiJimakuIpcRuntimeServiceDepsParams;
|
ankiJimakuDeps: AnkiJimakuIpcRuntimeServiceDepsParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerMainIpcRuntimeServices(params: MainIpcRuntimeServiceDepsParams): void {
|
export interface IpcRuntimeMainInput {
|
||||||
registerIpcHandlers(createIpcDepsRuntime(createMainIpcRuntimeServiceDeps(params)));
|
window: Pick<
|
||||||
|
RegisterIpcRuntimeServicesParams['mainDeps'],
|
||||||
|
| 'getMainWindow'
|
||||||
|
| 'getVisibleOverlayVisibility'
|
||||||
|
| 'focusMainWindow'
|
||||||
|
| 'onOverlayModalClosed'
|
||||||
|
| 'onOverlayModalOpened'
|
||||||
|
| 'onYoutubePickerResolve'
|
||||||
|
| 'openYomitanSettings'
|
||||||
|
| 'quitApp'
|
||||||
|
| 'toggleVisibleOverlay'
|
||||||
|
>;
|
||||||
|
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 function registerAnkiJimakuIpcRuntimeServices(
|
export interface IpcRuntimeRegistrationInput {
|
||||||
params: AnkiJimakuIpcRuntimeServiceDepsParams,
|
runtimeOptions: RuntimeOptionsIpcDepsParams;
|
||||||
): void {
|
main: IpcRuntimeMainInput;
|
||||||
registerAnkiJimakuIpcRuntime(
|
ankiJimaku: AnkiJimakuIpcRuntimeServiceDepsParams;
|
||||||
createAnkiJimakuIpcRuntimeServiceDeps(params),
|
registerIpcRuntimeServices: (params: RegisterIpcRuntimeServicesParams) => void;
|
||||||
registerAnkiJimakuIpcHandlers,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerIpcRuntimeServices(params: RegisterIpcRuntimeServicesParams): void {
|
export interface IpcRuntimeInput {
|
||||||
const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({
|
mpv: {
|
||||||
getRuntimeOptionsManager: params.runtimeOptions.getRuntimeOptionsManager,
|
mainDeps: MpvCommandFromIpcRuntimeDeps;
|
||||||
showMpvOsd: params.runtimeOptions.showMpvOsd,
|
handleMpvCommandFromIpcRuntime: (
|
||||||
});
|
command: (string | number)[],
|
||||||
registerMainIpcRuntimeServices({
|
deps: MpvCommandFromIpcRuntimeDeps,
|
||||||
...params.mainDeps,
|
) => void;
|
||||||
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
|
runSubsyncManualFromIpc: MainIpcRuntimeServiceDepsParams['runSubsyncManual'];
|
||||||
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
|
};
|
||||||
});
|
registration: IpcRuntimeRegistrationInput;
|
||||||
registerAnkiJimakuIpcRuntimeServices(params.ankiJimakuDeps);
|
}
|
||||||
|
|
||||||
|
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.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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
registerIpcRuntimeHandlers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
160
src/main/jellyfin-runtime-coordinator.ts
Normal file
160
src/main/jellyfin-runtime-coordinator.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
95
src/main/jellyfin-runtime.test.ts
Normal file
95
src/main/jellyfin-runtime.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
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());
|
||||||
|
});
|
||||||
423
src/main/jellyfin-runtime.ts
Normal file
423
src/main/jellyfin-runtime.ts
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
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';
|
import { createJlptVocabularyLookup } from '../core/services/jlpt-vocab';
|
||||||
|
|
||||||
export interface JlptDictionarySearchPathDeps {
|
export interface JlptDictionarySearchPathDeps {
|
||||||
getDictionaryRoots: () => string[];
|
getDictionaryRoots: () => string[];
|
||||||
|
|||||||
169
src/main/main-boot-runtime.ts
Normal file
169
src/main/main-boot-runtime.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
114
src/main/main-boot-services-bootstrap.test.ts
Normal file
114
src/main/main-boot-services-bootstrap.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
173
src/main/main-boot-services-bootstrap.ts
Normal file
173
src/main/main-boot-services-bootstrap.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
253
src/main/main-early-runtime.ts
Normal file
253
src/main/main-early-runtime.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
129
src/main/main-playback-runtime.ts
Normal file
129
src/main/main-playback-runtime.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
54
src/main/main-startup-bootstrap-types.ts
Normal file
54
src/main/main-startup-bootstrap-types.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
505
src/main/main-startup-bootstrap.ts
Normal file
505
src/main/main-startup-bootstrap.ts
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
262
src/main/main-startup-runtime-bootstrap.ts
Normal file
262
src/main/main-startup-runtime-bootstrap.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
370
src/main/main-startup-runtime-coordinator.ts
Normal file
370
src/main/main-startup-runtime-coordinator.ts
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
260
src/main/main-startup-runtime.test.ts
Normal file
260
src/main/main-startup-runtime.test.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
43
src/main/main-startup-runtime.ts
Normal file
43
src/main/main-startup-runtime.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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';
|
import { updateCurrentMediaPath } from '../core/services/subtitle-position';
|
||||||
|
|
||||||
import type { SubtitlePosition } from '../types';
|
import type { SubtitlePosition } from '../types';
|
||||||
|
|
||||||
|
|||||||
163
src/main/mining-runtime.ts
Normal file
163
src/main/mining-runtime.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
285
src/main/mpv-runtime-bootstrap.ts
Normal file
285
src/main/mpv-runtime-bootstrap.ts
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
504
src/main/mpv-runtime.ts
Normal file
504
src/main/mpv-runtime.ts
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
63
src/main/overlay-geometry-accessors.ts
Normal file
63
src/main/overlay-geometry-accessors.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
75
src/main/overlay-geometry-runtime.test.ts
Normal file
75
src/main/overlay-geometry-runtime.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
135
src/main/overlay-geometry-runtime.ts
Normal file
135
src/main/overlay-geometry-runtime.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
251
src/main/overlay-ui-bootstrap-from-main-state.ts
Normal file
251
src/main/overlay-ui-bootstrap-from-main-state.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
133
src/main/overlay-ui-bootstrap-runtime-input.test.ts
Normal file
133
src/main/overlay-ui-bootstrap-runtime-input.test.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
87
src/main/overlay-ui-bootstrap-runtime-input.ts
Normal file
87
src/main/overlay-ui-bootstrap-runtime-input.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
503
src/main/overlay-ui-bootstrap-runtime.ts
Normal file
503
src/main/overlay-ui-bootstrap-runtime.ts
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
92
src/main/overlay-ui-runtime-input.ts
Normal file
92
src/main/overlay-ui-runtime-input.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
461
src/main/overlay-ui-runtime.test.ts
Normal file
461
src/main/overlay-ui-runtime.test.ts
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
408
src/main/overlay-ui-runtime.ts
Normal file
408
src/main/overlay-ui-runtime.ts
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
128
src/main/overlay-ui-visibility.ts
Normal file
128
src/main/overlay-ui-visibility.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
41
src/main/runtime-option-helpers.ts
Normal file
41
src/main/runtime-option-helpers.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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,3 +1,7 @@
|
|||||||
|
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;
|
||||||
@@ -72,3 +76,59 @@ 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,10 +98,62 @@ 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
77
src/main/shortcuts-runtime.test.ts
Normal file
77
src/main/shortcuts-runtime.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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']);
|
||||||
|
});
|
||||||
278
src/main/shortcuts-runtime.ts
Normal file
278
src/main/shortcuts-runtime.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
57
src/main/startup-flags.ts
Normal file
57
src/main/startup-flags.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
41
src/main/startup-lifecycle-runtime.ts
Normal file
41
src/main/startup-lifecycle-runtime.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
155
src/main/startup-sequence-runtime.test.ts
Normal file
155
src/main/startup-sequence-runtime.test.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
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, []);
|
||||||
|
});
|
||||||
96
src/main/startup-sequence-runtime.ts
Normal file
96
src/main/startup-sequence-runtime.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
171
src/main/startup-support-coordinator.ts
Normal file
171
src/main/startup-support-coordinator.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
235
src/main/startup-support-runtime.ts
Normal file
235
src/main/startup-support-runtime.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
185
src/main/stats-runtime-coordinator.ts
Normal file
185
src/main/stats-runtime-coordinator.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
131
src/main/stats-runtime.test.ts
Normal file
131
src/main/stats-runtime.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
469
src/main/stats-runtime.ts
Normal file
469
src/main/stats-runtime.ts
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
451
src/main/subtitle-dictionary-runtime.ts
Normal file
451
src/main/subtitle-dictionary-runtime.ts
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
131
src/main/subtitle-runtime-sources.ts
Normal file
131
src/main/subtitle-runtime-sources.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
207
src/main/subtitle-runtime.test.ts
Normal file
207
src/main/subtitle-runtime.test.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
423
src/main/subtitle-runtime.ts
Normal file
423
src/main/subtitle-runtime.ts
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
101
src/main/yomitan-runtime-bootstrap.ts
Normal file
101
src/main/yomitan-runtime-bootstrap.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
202
src/main/yomitan-runtime.ts
Normal file
202
src/main/yomitan-runtime.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
346
src/main/youtube-runtime-bootstrap.ts
Normal file
346
src/main/youtube-runtime-bootstrap.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
238
src/main/youtube-runtime-coordinator.ts
Normal file
238
src/main/youtube-runtime-coordinator.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
179
src/main/youtube-runtime.test.ts
Normal file
179
src/main/youtube-runtime.test.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
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'));
|
||||||
|
});
|
||||||
304
src/main/youtube-runtime.ts
Normal file
304
src/main/youtube-runtime.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
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