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
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-26
|
||||
Last verified: 2026-03-31
|
||||
Owner: Kyle Yasuda
|
||||
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
|
||||
|
||||
- `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/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
||||
- `src/renderer/` owns overlay rendering and input behavior.
|
||||
|
||||
4847
src/main.ts
4847
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 type { FrequencyDictionaryLookup } from '../types';
|
||||
import { createFrequencyDictionaryLookup } from '../core/services';
|
||||
import { createFrequencyDictionaryLookup } from '../core/services/frequency-dictionary';
|
||||
|
||||
export interface FrequencyDictionarySearchPathDeps {
|
||||
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 {
|
||||
createIpcDepsRuntime,
|
||||
registerAnkiJimakuIpcRuntime,
|
||||
registerIpcHandlers,
|
||||
} from '../core/services';
|
||||
import { registerAnkiJimakuIpcHandlers } from '../core/services/anki-jimaku-ipc';
|
||||
import {
|
||||
createAnkiJimakuIpcRuntimeServiceDeps,
|
||||
import type {
|
||||
AnkiJimakuIpcRuntimeServiceDepsParams,
|
||||
createMainIpcRuntimeServiceDeps,
|
||||
MainIpcRuntimeServiceDepsParams,
|
||||
createRuntimeOptionsIpcDeps,
|
||||
RuntimeOptionsIpcDepsParams,
|
||||
} 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 {
|
||||
runtimeOptions: RuntimeOptionsIpcDepsParams;
|
||||
@@ -19,28 +17,131 @@ export interface RegisterIpcRuntimeServicesParams {
|
||||
ankiJimakuDeps: AnkiJimakuIpcRuntimeServiceDepsParams;
|
||||
}
|
||||
|
||||
export function registerMainIpcRuntimeServices(params: MainIpcRuntimeServiceDepsParams): void {
|
||||
registerIpcHandlers(createIpcDepsRuntime(createMainIpcRuntimeServiceDeps(params)));
|
||||
export interface IpcRuntimeMainInput {
|
||||
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(
|
||||
params: AnkiJimakuIpcRuntimeServiceDepsParams,
|
||||
): void {
|
||||
registerAnkiJimakuIpcRuntime(
|
||||
createAnkiJimakuIpcRuntimeServiceDeps(params),
|
||||
registerAnkiJimakuIpcHandlers,
|
||||
);
|
||||
export interface IpcRuntimeRegistrationInput {
|
||||
runtimeOptions: RuntimeOptionsIpcDepsParams;
|
||||
main: IpcRuntimeMainInput;
|
||||
ankiJimaku: AnkiJimakuIpcRuntimeServiceDepsParams;
|
||||
registerIpcRuntimeServices: (params: RegisterIpcRuntimeServicesParams) => void;
|
||||
}
|
||||
|
||||
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);
|
||||
export interface IpcRuntimeInput {
|
||||
mpv: {
|
||||
mainDeps: MpvCommandFromIpcRuntimeDeps;
|
||||
handleMpvCommandFromIpcRuntime: (
|
||||
command: (string | number)[],
|
||||
deps: MpvCommandFromIpcRuntimeDeps,
|
||||
) => void;
|
||||
runSubsyncManualFromIpc: MainIpcRuntimeServiceDepsParams['runSubsyncManual'];
|
||||
};
|
||||
registration: IpcRuntimeRegistrationInput;
|
||||
}
|
||||
|
||||
export interface IpcRuntime {
|
||||
registerIpcRuntimeHandlers: () => void;
|
||||
}
|
||||
|
||||
export interface IpcRuntimeFromMainStateInput {
|
||||
mpv: {
|
||||
mainDeps: MpvCommandFromIpcRuntimeDeps;
|
||||
runSubsyncManualFromIpc: MainIpcRuntimeServiceDepsParams['runSubsyncManual'];
|
||||
};
|
||||
runtimeOptions: RuntimeOptionsIpcDepsParams;
|
||||
main: IpcRuntimeMainInput;
|
||||
ankiJimaku: AnkiJimakuIpcRuntimeServiceDepsParams;
|
||||
}
|
||||
|
||||
export function createIpcRuntime(input: IpcRuntimeInput): IpcRuntime {
|
||||
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
mpvCommandMainDeps: input.mpv.mainDeps,
|
||||
handleMpvCommandFromIpcRuntime: input.mpv.handleMpvCommandFromIpcRuntime,
|
||||
runSubsyncManualFromIpc: input.mpv.runSubsyncManualFromIpc,
|
||||
registration: {
|
||||
runtimeOptions: input.registration.runtimeOptions,
|
||||
mainDeps: {
|
||||
...input.registration.main.window,
|
||||
...input.registration.main.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 type { JlptLevel } from '../types';
|
||||
|
||||
import { createJlptVocabularyLookup } from '../core/services';
|
||||
import { createJlptVocabularyLookup } from '../core/services/jlpt-vocab';
|
||||
|
||||
export interface JlptDictionarySearchPathDeps {
|
||||
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';
|
||||
|
||||
|
||||
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 = {
|
||||
publish: (snapshot: {
|
||||
mediaTitle: string | null;
|
||||
@@ -72,3 +76,59 @@ export function createDiscordPresenceRuntime(deps: DiscordPresenceRuntimeDeps) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (savedVisibility !== null) {
|
||||
deps.setMpvSubVisibility(savedVisibility);
|
||||
}
|
||||
deps.setMpvSubVisibility(savedVisibility);
|
||||
|
||||
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