mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-11 04:46:07 -08:00
refactor(main): move IPC, CLI and app-ready wiring into assemblies (#311)
This commit is contained in:
1 parent
655510cf2e
commit
696462a82e
33 files changed
+3719
-2101
No files matched your search
@@ -24,7 +24,7 @@ src/
|
||||
preload*.ts # preload bridges (overlay, settings, stats, sync, Jellyfin setup)
|
||||
main/ # main-process runtime modules and IPC/CLI wiring
|
||||
boot/ # pre-ready boot helpers
|
||||
assemblies/ # per-domain assemblies main.ts builds once (overlay, playback, first-run setup, YouTube, Jellyfin, AniList, settings, sync, dictionaries, overlay modals)
|
||||
assemblies/ # per-domain assemblies main.ts builds once (startup, overlay, playback, mining, stats, Yomitan, shortcuts, tray, first-run setup, YouTube, Jellyfin, AniList, settings, sync, dictionaries, overlay modals)
|
||||
runtime/composers/ # larger runtime clusters assembled for main.ts
|
||||
runtime/domains/ # domain barrels (startup, overlay, mpv, ipc, shortcuts, anilist, jellyfin, mining)
|
||||
core/services/ # focused services: mpv client, overlay, tokenizer, mining, integrations, stats
|
||||
@@ -139,7 +139,7 @@ Runtime code uses dependency injection:
|
||||
2. Build its runtime dependencies in a `src/main/` module. Pass simple dependencies inline; extract an adapter only when it adds behavior or gets reused.
|
||||
3. Call the service from lifecycle or command wiring.
|
||||
|
||||
`main.ts` gets domain handlers through `createMainRuntimeRegistry()` (`src/main/runtime/registry.ts`), which exposes the barrels in `src/main/runtime/domains/`. Larger clusters, such as app-ready startup, mpv, Jellyfin, AniList tracking, shortcuts, and IPC, are assembled by composers in `src/main/runtime/composers/`. A domain whose runtimes share state, such as the overlay windows, mpv playback and subtitle state, YouTube, Jellyfin, AniList, config settings, sync, or the character dictionary, is grouped into one assembly in `src/main/assemblies/` that owns that state and returns its runtimes. Handlers take their dependencies as a plain object passed straight to the handler factory, which keeps side effects out of the unit under test.
|
||||
Domain handlers live in the barrels under `src/main/runtime/domains/`. Larger clusters, such as app-ready startup, mpv, Jellyfin, AniList tracking, shortcuts, and IPC, are assembled by composers in `src/main/runtime/composers/`. A domain whose runtimes share state, such as app startup, the overlay windows, mpv playback and subtitle state, card mining, stats, YouTube, Jellyfin, AniList, config settings, sync, or the character dictionary, is grouped into one assembly in `src/main/assemblies/` that owns that state and returns its runtimes. An assembly that answers renderer IPC or CLI commands also returns typed `ipc`/`cli` slices, and `main.ts` registers the handlers by combining those slices. Handlers take their dependencies as a plain object passed straight to the handler factory, which keeps side effects out of the unit under test.
|
||||
|
||||
Composers declare their inputs with `ComposerInputs<T>` and results with `ComposerOutputs<T>` from `src/main/runtime/composers/contracts.ts`. A missing dependency then fails at compile time.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Domain Ownership
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-07-15
|
||||
Last verified: 2026-10-31
|
||||
Owner: Kyle Yasuda
|
||||
Read when: you need to find the owner module for a behavior or test surface
|
||||
|
||||
@@ -20,18 +20,29 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
`src/config/resolve/anki-connect.ts` from focused resolvers in
|
||||
`src/config/resolve/anki-connect/`
|
||||
Config hot-reload and the settings window: `src/main/assemblies/settings.ts`
|
||||
- App startup: app-ready sequence, headless known-word refresh, overlay startup prerequisites and
|
||||
overlay runtime bootstrap: `src/main/assemblies/startup.ts`
|
||||
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
|
||||
Overlay windows, visibility, and Linux fullscreen handling: `src/main/assemblies/overlay.ts`
|
||||
Overlay windows, visibility, Linux fullscreen handling, and the overlay-window IPC handlers
|
||||
(modal open/close, pointer interaction, content measurement): `src/main/assemblies/overlay.ts`
|
||||
Overlay-hosted modal openers: `src/main/assemblies/overlay-modals.ts`
|
||||
- MPV runtime and protocol: `src/core/services/mpv*.ts`
|
||||
mpv event wiring, subtitle prefetch, and subtitle sidebar state: `src/main/assemblies/playback.ts`
|
||||
mpv client creation, mpv event wiring, AniSkip, subtitle prefetch, and subtitle sidebar state: `src/main/assemblies/playback.ts`
|
||||
- Keyboard input outside the renderer (overlay, global and numeric shortcuts, session bindings):
|
||||
`src/main/assemblies/shortcuts.ts`
|
||||
- System tray: `src/main/assemblies/tray.ts`
|
||||
- Yomitan/Hachidori extension loading, parser runtime deps, and dictionary settings windows:
|
||||
`src/main/assemblies/yomitan.ts`
|
||||
Windows executable lookup and detached process creation are shared in `src/main/runtime/mpv-process.ts`. The Windows launcher and Jellyfin handlers retain their own playback and connection workflows.
|
||||
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
|
||||
JLPT and frequency annotation lookups: `src/main/jlpt-runtime.ts`, `src/main/frequency-dictionary-runtime.ts`, assembled by `src/main/assemblies/dictionaries.ts`
|
||||
- First-run setup: `src/main/runtime/first-run-setup-*.ts`, assembled by `src/main/assemblies/first-run-setup.ts`
|
||||
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
||||
Card mining handlers, the pending lookup context, field grouping, and media timing review wiring:
|
||||
`src/main/assemblies/mining.ts`
|
||||
- Media timing review: `src/main/runtime/media-timing-review.ts` owns the review and hidden `MediaTimingPreviewSession`. The session waits for mpv readiness and acknowledges playback commands; its `time-pos` observations reach the modal through `media-timing-review:preview-position`. The renderer places the cursor from those timestamps and uses an inactivity timeout instead of timing the clip itself.
|
||||
- Immersion tracking: `src/core/services/immersion-tracker/`
|
||||
- Immersion tracking: `src/core/services/immersion-tracker/`; tracker startup, the stats server,
|
||||
the stats overlay window options, and the `--stats` command are assembled by `src/main/assemblies/stats.ts`
|
||||
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
|
||||
Library-entry identity aliases and merge recommendations are persisted alongside this schema; the stats HTTP and SPA layers only expose and present those domain decisions.
|
||||
`delete-maintenance-scheduler.ts` coalesces and serializes stats deletes; the expensive work runs in `delete-maintenance-worker-thread.ts` while the tracker queues playback writes. Each batch uses one transaction, lexical update, rollup refresh, and incremental lifetime subtraction (`planLifetimeRemovals`/`applyLifetimeRemovals` in `lifetime.ts`). Merges, moves, AniList reassignments, and `stats cleanup -l` use `repairLifetimeSummariesFromMedia` (recompute from the per-video media ledger). The full lifetime rebuild survives only as the empty-table bootstrap; anywhere else it would collapse lifetime totals to the session retention window.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Layering Rules
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-10-31
|
||||
Owner: Kyle Yasuda
|
||||
Read when: deciding whether a dependency direction is acceptable
|
||||
|
||||
@@ -28,6 +28,14 @@ Renderer, launcher, plugin, and stats each keep their own local layering and sho
|
||||
object, and returns its runtimes; `main.ts` builds it once and calls through the result.
|
||||
Siblings built later in `main.ts` are passed as getters or callbacks, and construction must
|
||||
not call them.
|
||||
- An assembly that answers renderer IPC or CLI commands returns them as typed slices
|
||||
(`IpcSlice<...>` / `CliSlice<...>` from `src/main/assemblies/slices.ts`). `main.ts`
|
||||
spreads the slices into the registration and checks the result with
|
||||
`satisfies Required<...>`, so a missing handler fails typecheck. Only app-level handlers
|
||||
with no owning assembly stay inline in `main.ts`.
|
||||
- Electron modules that value-import named Electron APIs (`dialog`, `screen`, `shell`,
|
||||
`clipboard`, `globalShortcut`, `Tray`, ...) cannot load under `bun test`. Pass those APIs
|
||||
into an assembly as deps instead of importing them there.
|
||||
- Prefer injecting dependencies from `src/main/` instead of reaching outward from core services.
|
||||
- Keep side effects explicit and close to composition boundaries.
|
||||
- Put reusable business logic in focused services, not in top-level lifecycle files.
|
||||
|
||||
@@ -54,7 +54,7 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('startBackgroundWarmups');
|
||||
},
|
||||
texthookerOnlyMode: false,
|
||||
isTexthookerOnlyMode: () => false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`setVisibleOverlayVisible:${visible}`),
|
||||
initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'),
|
||||
@@ -180,7 +180,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
|
||||
|
||||
test('runAppReadyRuntime keeps websocket startup in texthooker-only mode but skips overlay window', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
texthookerOnlyMode: true,
|
||||
isTexthookerOnlyMode: () => true,
|
||||
reloadConfig: () => calls.push('reloadConfig'),
|
||||
handleInitialArgs: () => calls.push('handleInitialArgs'),
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ function createAppReadyDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||
handleFirstRunSetup: recordAsync('first-run'),
|
||||
prewarmSubtitleDictionaries: recordAsync('prewarm'),
|
||||
startBackgroundWarmups: record('warmups'),
|
||||
texthookerOnlyMode: false,
|
||||
isTexthookerOnlyMode: () => false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: record('visible-overlay'),
|
||||
initializeOverlayRuntime: record('init-overlay'),
|
||||
|
||||
@@ -148,7 +148,8 @@ export interface AppReadyRuntimeDeps {
|
||||
handleFirstRunSetup: () => Promise<void>;
|
||||
prewarmSubtitleDictionaries?: () => Promise<void>;
|
||||
startBackgroundWarmups: () => void;
|
||||
texthookerOnlyMode: boolean;
|
||||
/** Read when app-ready reaches the overlay decision, after startup state is applied. */
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
initializeOverlayRuntime: () => void;
|
||||
@@ -351,7 +352,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
deps.log('Runtime ready: immersion tracker dependency is missing.');
|
||||
}
|
||||
|
||||
if (deps.texthookerOnlyMode) {
|
||||
if (deps.isTexthookerOnlyMode()) {
|
||||
deps.log('Texthooker-only mode enabled; skipping overlay window.');
|
||||
startBackgroundWarmupsOnce();
|
||||
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
|
||||
|
||||
+295
-1954
File diff suppressed because it is too large.
Load diff
@@ -52,7 +52,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
|
||||
handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup'];
|
||||
prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
|
||||
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
|
||||
texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode'];
|
||||
isTexthookerOnlyMode: AppReadyRuntimeDeps['isTexthookerOnlyMode'];
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
|
||||
setVisibleOverlayVisible: AppReadyRuntimeDeps['setVisibleOverlayVisible'];
|
||||
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
|
||||
@@ -123,7 +123,7 @@ export function createAppReadyRuntimeDeps(
|
||||
handleFirstRunSetup: params.handleFirstRunSetup,
|
||||
prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
|
||||
startBackgroundWarmups: params.startBackgroundWarmups,
|
||||
texthookerOnlyMode: params.texthookerOnlyMode,
|
||||
isTexthookerOnlyMode: params.isTexthookerOnlyMode,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig:
|
||||
params.shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||
setVisibleOverlayVisible: params.setVisibleOverlayVisible,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '../runtime/domains/anilist';
|
||||
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
||||
import { createCreateAnilistSetupWindowHandler } from '../runtime/setup-window-factory';
|
||||
import type { CliSlice, IpcSlice } from './slices';
|
||||
import {
|
||||
type AnilistMediaGuessRuntimeState,
|
||||
type AppState,
|
||||
@@ -507,7 +508,39 @@ export function createAnilistAssembly(deps: AnilistAssemblyDeps) {
|
||||
);
|
||||
};
|
||||
|
||||
const ipc: IpcSlice<
|
||||
| 'getAnilistStatus'
|
||||
| 'clearAnilistToken'
|
||||
| 'openAnilistSetup'
|
||||
| 'getAnilistQueueStatus'
|
||||
| 'retryAnilistQueueNow'
|
||||
| 'runAnilistPostWatchUpdateOnManualMark'
|
||||
> = {
|
||||
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
|
||||
clearAnilistToken: () => anilistStateRuntime.clearTokenState(),
|
||||
openAnilistSetup: () => openAnilistSetupWindowHandler(),
|
||||
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
|
||||
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
|
||||
runAnilistPostWatchUpdateOnManualMark: () => maybeRunAnilistPostWatchUpdate({ force: true }),
|
||||
};
|
||||
|
||||
const cli: CliSlice<
|
||||
| 'getAnilistStatus'
|
||||
| 'clearAnilistToken'
|
||||
| 'openAnilistSetupWindow'
|
||||
| 'getAnilistQueueStatus'
|
||||
| 'processNextAnilistRetryUpdate'
|
||||
> = {
|
||||
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
|
||||
clearAnilistToken: () => anilistStateRuntime.clearTokenState(),
|
||||
openAnilistSetupWindow: () => openAnilistSetupWindowHandler(),
|
||||
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
|
||||
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
|
||||
};
|
||||
|
||||
return {
|
||||
ipc,
|
||||
cli,
|
||||
stateRuntime: anilistStateRuntime,
|
||||
openSetupWindow: openAnilistSetupWindowHandler,
|
||||
handleAnilistSetupProtocolUrl,
|
||||
|
||||
@@ -38,6 +38,7 @@ function makeDeps(): CharacterDictionaryAssemblyDeps {
|
||||
refreshCurrentSubtitle: lazy('refreshCurrentSubtitle'),
|
||||
refreshSubtitlePrefetch: lazy('refreshSubtitlePrefetch'),
|
||||
refreshCurrentSubtitleAnnotations: lazy('refreshCurrentSubtitleAnnotations'),
|
||||
resetAnilistMediaGuessState: lazy('resetAnilistMediaGuessState'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,3 +47,21 @@ test('createCharacterDictionaryAssembly does not call its deps while constructin
|
||||
const characterDictionary = createCharacterDictionaryAssembly(makeDeps());
|
||||
assert.equal(characterDictionary.autoSync.getCurrentMediaId(), null);
|
||||
});
|
||||
|
||||
test('character dictionary generation from the CLI refuses while the dictionary is disabled', async () => {
|
||||
const deps = makeDeps();
|
||||
const characterDictionary = createCharacterDictionaryAssembly({
|
||||
...deps,
|
||||
getYomitanProfilePolicy: () => ({
|
||||
isCharacterDictionaryEnabled: () => false,
|
||||
getCharacterDictionaryDisabledReason: () => 'external profile is read-only',
|
||||
isExternalReadOnlyMode: () => true,
|
||||
logSkippedWrite: () => {},
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
characterDictionary.cli.generateCharacterDictionary(),
|
||||
/external profile is read-only/,
|
||||
);
|
||||
});
|
||||
@@ -11,7 +11,14 @@ import type { ResolvedConfig } from '../../types';
|
||||
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
|
||||
import { createCharacterDictionaryImageLookup } from '../character-dictionary-runtime/image-lookup';
|
||||
import { createCharacterNameCandidateLookup } from '../character-dictionary-runtime/name-candidates';
|
||||
import { createCharacterDictionaryAutoSyncRuntimeService } from '../runtime/character-dictionary-auto-sync';
|
||||
import { applyCharacterDictionarySelection } from '../character-dictionary-selection';
|
||||
import {
|
||||
createCharacterDictionaryAutoSyncRuntimeService,
|
||||
getCharacterDictionaryManagerSnapshot,
|
||||
moveCharacterDictionaryManagedEntry,
|
||||
removeCharacterDictionaryManagedEntry,
|
||||
replaceCharacterDictionaryManagedEntry,
|
||||
} from '../runtime/character-dictionary-auto-sync';
|
||||
import { handleCharacterDictionaryAutoSyncComplete } from '../runtime/character-dictionary-auto-sync-completion';
|
||||
import {
|
||||
notifyCharacterDictionaryAutoSyncStatus,
|
||||
@@ -22,6 +29,7 @@ import { assertYomitanDictionaryMutationSafe } from '../runtime/yomitan-dictiona
|
||||
import type { createYomitanProfilePolicy } from '../runtime/yomitan-profile-policy';
|
||||
import { formatSkippedYomitanWriteAction } from '../runtime/yomitan-read-only-log';
|
||||
import type { AppState } from '../state';
|
||||
import type { CliSlice, IpcSlice } from './slices';
|
||||
|
||||
type YomitanParserRuntimeDeps = Parameters<typeof getYomitanDictionaryInfo>[0];
|
||||
|
||||
@@ -46,7 +54,10 @@ export interface CharacterDictionaryAssemblyDeps {
|
||||
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
||||
getYomitanProfilePolicy: () => Pick<
|
||||
ReturnType<typeof createYomitanProfilePolicy>,
|
||||
'isCharacterDictionaryEnabled' | 'isExternalReadOnlyMode' | 'logSkippedWrite'
|
||||
| 'isCharacterDictionaryEnabled'
|
||||
| 'getCharacterDictionaryDisabledReason'
|
||||
| 'isExternalReadOnlyMode'
|
||||
| 'logSkippedWrite'
|
||||
>;
|
||||
isYoutubePlaybackActive: () => boolean;
|
||||
waitForMediaTokenizationReady: (mediaPath: string | null) => Promise<void>;
|
||||
@@ -63,6 +74,8 @@ export interface CharacterDictionaryAssemblyDeps {
|
||||
/** Re-runs subtitle prefetch from the current playback position. */
|
||||
refreshSubtitlePrefetch: () => void;
|
||||
refreshCurrentSubtitleAnnotations: () => void;
|
||||
/** A manual selection invalidates AniList's guess for the current media. */
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
}
|
||||
|
||||
export type CharacterDictionaryAssembly = ReturnType<typeof createCharacterDictionaryAssembly>;
|
||||
@@ -243,5 +256,94 @@ export function createCharacterDictionaryAssembly(deps: CharacterDictionaryAssem
|
||||
getCurrentMediaId: () => autoSync.getCurrentMediaId(),
|
||||
});
|
||||
|
||||
return { runtime, autoSync, imageLookup, nameCandidates };
|
||||
const selectionDeps = {
|
||||
setManualSelection: (request: Parameters<typeof runtime.setManualSelection>[0]) =>
|
||||
runtime.setManualSelection(request),
|
||||
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
|
||||
runSyncNow: () => autoSync.runSyncNow(),
|
||||
warn: (message: string, error?: unknown) => logger.warn(message, error),
|
||||
};
|
||||
|
||||
/** Manager edits that change the merged dictionary rebuild it; a failed rebuild is logged. */
|
||||
async function rebuildAfterManagerChange<T extends { ok: boolean; rebuildRequired?: boolean }>(
|
||||
result: T,
|
||||
change: 'override' | 'removal' | 'reorder',
|
||||
): Promise<T> {
|
||||
if (result.ok && result.rebuildRequired) {
|
||||
try {
|
||||
await autoSync.runSyncNow();
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to rebuild character dictionary after manager ${change}:`, error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const ipc: IpcSlice<
|
||||
| 'getCharacterDictionarySelection'
|
||||
| 'setCharacterDictionarySelection'
|
||||
| 'getCharacterDictionaryManagerSnapshot'
|
||||
| 'removeCharacterDictionaryManagedEntry'
|
||||
| 'moveCharacterDictionaryManagedEntry'
|
||||
> = {
|
||||
getCharacterDictionarySelection: (searchTitle?: string) =>
|
||||
runtime.getManualSelectionSnapshot(undefined, searchTitle),
|
||||
setCharacterDictionarySelection: async (
|
||||
mediaId: number,
|
||||
replaceManagedMediaId?: number,
|
||||
mediaTitle?: string,
|
||||
) => {
|
||||
if (replaceManagedMediaId !== undefined && mediaTitle) {
|
||||
return rebuildAfterManagerChange(
|
||||
replaceCharacterDictionaryManagedEntry(deps.userDataPath, replaceManagedMediaId, {
|
||||
mediaId,
|
||||
mediaTitle,
|
||||
}),
|
||||
'override',
|
||||
);
|
||||
}
|
||||
return await applyCharacterDictionarySelection({ mediaId }, selectionDeps);
|
||||
},
|
||||
getCharacterDictionaryManagerSnapshot: async () =>
|
||||
getCharacterDictionaryManagerSnapshot(deps.userDataPath, autoSync.getCurrentMediaId()),
|
||||
removeCharacterDictionaryManagedEntry: async (mediaId: number) =>
|
||||
rebuildAfterManagerChange(
|
||||
removeCharacterDictionaryManagedEntry(
|
||||
deps.userDataPath,
|
||||
mediaId,
|
||||
autoSync.getCurrentMediaId(),
|
||||
),
|
||||
'removal',
|
||||
),
|
||||
moveCharacterDictionaryManagedEntry: async (mediaId: number, direction: 1 | -1) =>
|
||||
rebuildAfterManagerChange(
|
||||
moveCharacterDictionaryManagedEntry(
|
||||
deps.userDataPath,
|
||||
mediaId,
|
||||
direction,
|
||||
autoSync.getCurrentMediaId(),
|
||||
),
|
||||
'reorder',
|
||||
),
|
||||
};
|
||||
|
||||
const cli: CliSlice<
|
||||
| 'generateCharacterDictionary'
|
||||
| 'getCharacterDictionarySelection'
|
||||
| 'setCharacterDictionarySelection'
|
||||
> = {
|
||||
generateCharacterDictionary: async (targetPath?: string) => {
|
||||
const disabledReason = deps.getYomitanProfilePolicy().getCharacterDictionaryDisabledReason();
|
||||
if (disabledReason) {
|
||||
throw new Error(disabledReason);
|
||||
}
|
||||
return await runtime.generateForCurrentMedia(targetPath);
|
||||
},
|
||||
getCharacterDictionarySelection: async (targetPath?: string) =>
|
||||
runtime.getManualSelectionSnapshot(targetPath),
|
||||
setCharacterDictionarySelection: async (request) =>
|
||||
applyCharacterDictionarySelection(request, selectionDeps),
|
||||
};
|
||||
|
||||
return { runtime, autoSync, imageLookup, nameCandidates, ipc, cli };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { SubtitleMiningContext } from '../../types';
|
||||
import type { AppState } from '../state';
|
||||
import { createMiningAssembly, type MiningAssemblyDeps } from './mining';
|
||||
|
||||
type AnkiIntegrationLike = NonNullable<AppState['ankiIntegration']>;
|
||||
type MpvClientLike = NonNullable<AppState['mpvClient']>;
|
||||
|
||||
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
|
||||
function makeDeps(overrides: Partial<MiningAssemblyDeps> = {}): MiningAssemblyDeps {
|
||||
const lazy = (name: string) => (): never => {
|
||||
throw new Error(`${name} called unexpectedly`);
|
||||
};
|
||||
return {
|
||||
appState: {
|
||||
mpvClient: null,
|
||||
currentMediaPath: null,
|
||||
activeParsedSubtitleCues: [],
|
||||
ankiIntegration: null,
|
||||
subtitleTimingTracker: null,
|
||||
fieldGroupingResolver: null,
|
||||
fieldGroupingResolverSequence: 0,
|
||||
},
|
||||
getConfig: lazy('getConfig'),
|
||||
logger: { warn: lazy('warn'), error: lazy('error') },
|
||||
overlayManager: {
|
||||
getMainWindow: lazy('getMainWindow'),
|
||||
getModalWindow: lazy('getModalWindow'),
|
||||
getVisibleOverlayVisible: lazy('getVisibleOverlayVisible'),
|
||||
},
|
||||
overlayModalRuntime: {
|
||||
getRestoreVisibleOverlayOnModalClose: lazy('getRestoreVisibleOverlayOnModalClose'),
|
||||
waitForModalOpen: lazy('waitForModalOpen'),
|
||||
handleOverlayModalClosed: lazy('handleOverlayModalClosed'),
|
||||
sendToActiveOverlayWindow: lazy('sendToActiveOverlayWindow'),
|
||||
},
|
||||
youtube: {
|
||||
getCachedMediaPathForCurrentPlayback: lazy('getCachedMediaPathForCurrentPlayback'),
|
||||
shouldRequireMediaCacheForCurrentPlayback: lazy('shouldRequireMediaCacheForCurrentPlayback'),
|
||||
},
|
||||
clipboard: { readText: lazy('readText'), writeText: lazy('writeText') },
|
||||
getOverlayNotifications: lazy('getOverlayNotifications'),
|
||||
getOverlayModals: lazy('getOverlayModals'),
|
||||
setVisibleOverlayVisible: lazy('setVisibleOverlayVisible'),
|
||||
ensureOverlayStartupPrereqs: lazy('ensureOverlayStartupPrereqs'),
|
||||
ensureOverlayWindowsReadyForVisibilityActions: lazy(
|
||||
'ensureOverlayWindowsReadyForVisibilityActions',
|
||||
),
|
||||
ankiIntegrationCallbacks: {
|
||||
recordCardsMined: lazy('recordCardsMined'),
|
||||
getMiningProvenance: lazy('getMiningProvenance'),
|
||||
onCardsReady: lazy('onCardsReady'),
|
||||
onKnownWordCacheUpdated: lazy('onKnownWordCacheUpdated'),
|
||||
generateSentenceFurigana: lazy('generateSentenceFurigana'),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const lookupContext: SubtitleMiningContext = {
|
||||
source: 'overlay',
|
||||
text: '今日はいい天気',
|
||||
startTime: 3,
|
||||
endTime: 5,
|
||||
capturedAtMs: 1,
|
||||
};
|
||||
|
||||
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
|
||||
test('createMiningAssembly does not call its deps while constructing', () => {
|
||||
const mining = createMiningAssembly(makeDeps());
|
||||
assert.equal(typeof mining.mineSentenceCard, 'function');
|
||||
assert.equal(typeof mining.attachToAnkiIntegration, 'function');
|
||||
});
|
||||
|
||||
test('a recorded lookup context reaches exactly one mined card', () => {
|
||||
let consumeContext: (() => SubtitleMiningContext | null) | null = null;
|
||||
const setters: string[] = [];
|
||||
const integration = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, name: string) => (callback: unknown) => {
|
||||
setters.push(name);
|
||||
if (name === 'setSubtitleMiningContextConsumer') {
|
||||
consumeContext = callback as () => SubtitleMiningContext | null;
|
||||
}
|
||||
},
|
||||
},
|
||||
) as AnkiIntegrationLike;
|
||||
const mining = createMiningAssembly(makeDeps());
|
||||
|
||||
mining.attachToAnkiIntegration(integration);
|
||||
mining.recordSubtitleMiningContext(lookupContext);
|
||||
|
||||
assert.equal(setters.length, 7);
|
||||
assert.ok(consumeContext);
|
||||
const consume = consumeContext as () => SubtitleMiningContext | null;
|
||||
assert.deepEqual(consume(), lookupContext);
|
||||
assert.equal(consume(), null);
|
||||
});
|
||||
|
||||
test('mining without parsed cues falls back to the live mpv line on the video clock', () => {
|
||||
const mpvClient = {
|
||||
currentSubText: 'こんにちは',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentSubDelay: 0.5,
|
||||
currentTimePos: 11,
|
||||
} as MpvClientLike;
|
||||
const mining = createMiningAssembly(
|
||||
makeDeps({ appState: { ...makeDeps().appState, mpvClient } }),
|
||||
);
|
||||
|
||||
const context = mining.captureCurrentPrimarySubtitleMiningContext();
|
||||
|
||||
assert.equal(context?.text, 'こんにちは');
|
||||
assert.equal(context?.startTime, 10.5);
|
||||
assert.equal(context?.endTime, 12.5);
|
||||
});
|
||||
@@ -0,0 +1,406 @@
|
||||
import { resolveMediaGenerationInput } from '../../anki-integration/media-source';
|
||||
import {
|
||||
captureLiveSubtitleMiningContext,
|
||||
copyCurrentSubtitle as copyCurrentSubtitleCore,
|
||||
createFieldGroupingOverlayRuntime,
|
||||
handleMineSentenceDigit as handleMineSentenceDigitCore,
|
||||
handleMultiCopyDigit as handleMultiCopyDigitCore,
|
||||
markLastCardAsAudioCard as markLastCardAsAudioCardCore,
|
||||
mineSentenceCard as mineSentenceCardCore,
|
||||
shiftSubtitleRangeToVideoClock,
|
||||
toSubtitleClock,
|
||||
triggerFieldGrouping as triggerFieldGroupingCore,
|
||||
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
||||
type createOverlayManager,
|
||||
} from '../../core/services';
|
||||
import { createMediaTimingFrameExtractor } from '../../core/services/media-timing-frame';
|
||||
import { MediaTimingPreviewSession } from '../../core/services/media-timing-preview';
|
||||
import { generateSpeechWaveform } from '../../core/services/media-timing-waveform';
|
||||
import { getSharedRemoteMediaWindowCache } from '../../core/services/remote-media-window-cache';
|
||||
import type { Logger } from '../../logger';
|
||||
import type { PluginMinedNote, PluginMiningProvenance } from '../../plugin-api';
|
||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import type { ResolvedConfig, SubtitleMiningContext } from '../../types';
|
||||
import type { OverlayModalRuntime } from '../overlay-runtime';
|
||||
import {
|
||||
createCopyCurrentSubtitleHandler,
|
||||
createHandleMineSentenceDigitHandler,
|
||||
createHandleMultiCopyDigitHandler,
|
||||
createMarkLastCardAsAudioCardHandler,
|
||||
createMineSentenceCardHandler,
|
||||
createRefreshKnownWordCacheHandler,
|
||||
createTriggerFieldGroupingHandler,
|
||||
createUpdateLastCardFromClipboardHandler,
|
||||
} from '../runtime/domains/mining';
|
||||
import {
|
||||
createGetFieldGroupingResolverHandler,
|
||||
createSetFieldGroupingResolverHandler,
|
||||
} from '../runtime/domains/overlay';
|
||||
import {
|
||||
collectMediaTimingContextLines,
|
||||
createMediaTimingReviewRuntime,
|
||||
} from '../runtime/media-timing-review';
|
||||
import { openMediaTimingReviewModal } from '../runtime/media-timing-review-open';
|
||||
import { findMpvExecutablePath } from '../runtime/mpv-process';
|
||||
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
||||
import { resolvePrimarySubtitle } from '../runtime/primary-subtitle-text';
|
||||
import type { AppState } from '../state';
|
||||
import type { OverlayModalsAssembly } from './overlay-modals';
|
||||
import type { CliSlice, IpcSlice } from './slices';
|
||||
import type { YoutubeAssembly } from './youtube';
|
||||
|
||||
type OverlayManager = ReturnType<typeof createOverlayManager>;
|
||||
type AnkiIntegrationLike = NonNullable<AppState['ankiIntegration']>;
|
||||
|
||||
/**
|
||||
* What card mining needs from the rest of the app. Siblings built after this assembly in
|
||||
* `main.ts` are reached through callbacks.
|
||||
*/
|
||||
export interface MiningAssemblyDeps {
|
||||
appState: Pick<
|
||||
AppState,
|
||||
| 'mpvClient'
|
||||
| 'currentMediaPath'
|
||||
| 'activeParsedSubtitleCues'
|
||||
| 'ankiIntegration'
|
||||
| 'subtitleTimingTracker'
|
||||
| 'fieldGroupingResolver'
|
||||
| 'fieldGroupingResolverSequence'
|
||||
>;
|
||||
getConfig: () => ResolvedConfig;
|
||||
logger: Pick<Logger, 'warn' | 'error'>;
|
||||
overlayManager: Pick<
|
||||
OverlayManager,
|
||||
'getMainWindow' | 'getModalWindow' | 'getVisibleOverlayVisible'
|
||||
>;
|
||||
overlayModalRuntime: Pick<
|
||||
OverlayModalRuntime,
|
||||
| 'getRestoreVisibleOverlayOnModalClose'
|
||||
| 'waitForModalOpen'
|
||||
| 'handleOverlayModalClosed'
|
||||
| 'sendToActiveOverlayWindow'
|
||||
>;
|
||||
youtube: Pick<
|
||||
YoutubeAssembly,
|
||||
'getCachedMediaPathForCurrentPlayback' | 'shouldRequireMediaCacheForCurrentPlayback'
|
||||
>;
|
||||
/** Electron's clipboard, injected so the assembly loads under `bun test`. */
|
||||
clipboard: { readText: () => string; writeText: (text: string) => void };
|
||||
getOverlayNotifications: () => Pick<
|
||||
ReturnType<typeof createOverlayNotificationsRuntime>,
|
||||
'showConfiguredStatusNotification' | 'showConfiguredPlaybackFeedback'
|
||||
>;
|
||||
getOverlayModals: () => Pick<OverlayModalsAssembly, 'createOverlayHostedModalOpenDeps'>;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
/** Callbacks the Anki integration reports back through once mining finishes. */
|
||||
ankiIntegrationCallbacks: {
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
getMiningProvenance: () => PluginMiningProvenance;
|
||||
onCardsReady: (noteIds: number[], notes: PluginMinedNote[]) => void;
|
||||
onKnownWordCacheUpdated: () => void;
|
||||
generateSentenceFurigana: (text: string, highlightedText?: string) => Promise<string | null>;
|
||||
};
|
||||
}
|
||||
|
||||
export type MiningAssembly = ReturnType<typeof createMiningAssembly>;
|
||||
|
||||
/**
|
||||
* Owns card mining: the subtitle context captured for the next card, the mine/copy/update
|
||||
* handlers behind shortcuts, CLI and session actions, field grouping, and the media timing
|
||||
* review the Anki integration opens before writing media.
|
||||
*/
|
||||
export function createMiningAssembly(deps: MiningAssemblyDeps) {
|
||||
const { appState, logger, overlayManager, overlayModalRuntime } = deps;
|
||||
const showStatus = (text: string) =>
|
||||
deps.getOverlayNotifications().showConfiguredStatusNotification(text);
|
||||
const showPlaybackFeedback = (text: string) =>
|
||||
deps.getOverlayNotifications().showConfiguredPlaybackFeedback(text);
|
||||
let pendingSubtitleMiningContext: SubtitleMiningContext | null = null;
|
||||
|
||||
function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | null {
|
||||
// Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active
|
||||
// event, so a finished caption row lingering beside a fresh line would end up on the
|
||||
// card. The parsed view also carries the cue's own timings for the clip range.
|
||||
// Cues sit on the subtitle clock, so the lookup undoes `sub-delay` first.
|
||||
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
|
||||
const resolved = resolvePrimarySubtitle({
|
||||
liveText: appState.mpvClient?.currentSubText ?? '',
|
||||
currentTimeSec: toSubtitleClock(Number(appState.mpvClient?.currentTimePos), subDelay),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
});
|
||||
// Same validity bar as the live capture path: an unusable resolved span must fall
|
||||
// back rather than hand mining an empty line or an inverted range.
|
||||
const resolvedText = resolved?.text.replace(/\n{2,}/g, '\n').trim();
|
||||
if (
|
||||
!resolved ||
|
||||
!resolvedText ||
|
||||
!Number.isFinite(resolved.startTime) ||
|
||||
!Number.isFinite(resolved.endTime) ||
|
||||
resolved.endTime <= resolved.startTime
|
||||
) {
|
||||
return captureLiveSubtitleMiningContext(appState.mpvClient);
|
||||
}
|
||||
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
|
||||
{
|
||||
source: 'overlay',
|
||||
text: resolvedText,
|
||||
startTime: resolved.startTime,
|
||||
endTime: resolved.endTime,
|
||||
capturedAtMs: Date.now(),
|
||||
},
|
||||
subDelay,
|
||||
);
|
||||
}
|
||||
|
||||
const recordSubtitleMiningContext = (context: SubtitleMiningContext | null): void => {
|
||||
pendingSubtitleMiningContext = context;
|
||||
};
|
||||
/** Hands the recorded context to exactly one card, then forgets it. */
|
||||
const consumePendingSubtitleMiningContext = (): SubtitleMiningContext | null => {
|
||||
const context = pendingSubtitleMiningContext;
|
||||
pendingSubtitleMiningContext = null;
|
||||
return context;
|
||||
};
|
||||
|
||||
const getFieldGroupingResolver = createGetFieldGroupingResolverHandler({
|
||||
getResolver: () => appState.fieldGroupingResolver,
|
||||
});
|
||||
|
||||
const setFieldGroupingResolver = createSetFieldGroupingResolverHandler({
|
||||
setResolver: (resolver) => {
|
||||
appState.fieldGroupingResolver = resolver;
|
||||
},
|
||||
nextSequence: () => {
|
||||
appState.fieldGroupingResolverSequence += 1;
|
||||
return appState.fieldGroupingResolverSequence;
|
||||
},
|
||||
getSequence: () => appState.fieldGroupingResolverSequence,
|
||||
});
|
||||
|
||||
const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHostedModal>({
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
setVisibleOverlayVisible: (visible) => deps.setVisibleOverlayVisible(visible),
|
||||
getResolver: () => getFieldGroupingResolver(),
|
||||
setResolver: (resolver) => setFieldGroupingResolver(resolver),
|
||||
getRestoreVisibleOverlayOnModalClose: () =>
|
||||
overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(),
|
||||
// These are optional on the runtime options, so a missing forward compiles silently, but
|
||||
// dropping them leaves the field grouping modal with no modal-open ack/retry, no teardown on
|
||||
// failure, and no warn logging.
|
||||
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
|
||||
handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () =>
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions(),
|
||||
sendToVisibleOverlay: (channel, payload, runtimeOptions) =>
|
||||
overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
});
|
||||
|
||||
const sendToOverlayWindows = (channel: string, payload: unknown) => {
|
||||
// The review may live in either overlay window; the renderer ignores foreign review ids.
|
||||
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
|
||||
if (window && !window.isDestroyed()) {
|
||||
window.webContents.send(channel, payload);
|
||||
}
|
||||
}
|
||||
};
|
||||
const getMediaGenerationOptions = () => ({
|
||||
getCachedMediaPath: (currentVideoPath: string, kind: 'audio' | 'video') =>
|
||||
deps.youtube.getCachedMediaPathForCurrentPlayback(currentVideoPath, kind),
|
||||
remoteCacheMode: deps.youtube.shouldRequireMediaCacheForCurrentPlayback()
|
||||
? ('required' as const)
|
||||
: ('optional' as const),
|
||||
});
|
||||
|
||||
const mediaTimingFrameExtractor = createMediaTimingFrameExtractor();
|
||||
const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getCurrentMediaPath: () =>
|
||||
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
|
||||
// An invalid configured path is passed through so the preview fails instead of using PATH mpv.
|
||||
getMpvExecutablePath: () => {
|
||||
const configuredPath = deps.getConfig().mpv.executablePath;
|
||||
return findMpvExecutablePath(configuredPath) || configuredPath;
|
||||
},
|
||||
createPreviewSession: () => new MediaTimingPreviewSession(),
|
||||
generateWaveform: (options) => generateSpeechWaveform(options),
|
||||
generateFrame: (options) => mediaTimingFrameExtractor.generate(options),
|
||||
clearFrameCache: () => mediaTimingFrameExtractor.clear(),
|
||||
resolveVideoSource: () =>
|
||||
resolveMediaGenerationInput(appState.mpvClient, 'video', getMediaGenerationOptions()),
|
||||
resolveMediaSource: async () => {
|
||||
const resolved = await resolveMediaGenerationInput(
|
||||
appState.mpvClient,
|
||||
'audio',
|
||||
getMediaGenerationOptions(),
|
||||
);
|
||||
return resolved
|
||||
? {
|
||||
path: resolved.path,
|
||||
...(resolved.inputOptions ? { inputOptions: resolved.inputOptions } : {}),
|
||||
singleResolvedStream: resolved.singleResolvedStream,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
|
||||
getSubtitleContextLines: (range) =>
|
||||
collectMediaTimingContextLines({
|
||||
// Review ranges and tracker entries are on the video clock; parsed cues are not.
|
||||
cues: appState.activeParsedSubtitleCues.map((cue) =>
|
||||
shiftSubtitleRangeToVideoClock(cue, appState.mpvClient?.currentSubDelay ?? 0),
|
||||
),
|
||||
fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [],
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
}),
|
||||
openModal: (payload, signal) =>
|
||||
openMediaTimingReviewModal(
|
||||
deps.getOverlayModals().createOverlayHostedModalOpenDeps(),
|
||||
payload,
|
||||
signal,
|
||||
),
|
||||
onPreviewEnded: (reviewId) => {
|
||||
sendToOverlayWindows(IPC_CHANNELS.event.mediaTimingReviewPreviewEnded, reviewId);
|
||||
},
|
||||
onPreviewPosition: (position) => {
|
||||
sendToOverlayWindows(IPC_CHANNELS.event.mediaTimingReviewPreviewPosition, position);
|
||||
},
|
||||
showStatus: (message) =>
|
||||
deps
|
||||
.getOverlayNotifications()
|
||||
.showConfiguredStatusNotification(message, { variant: 'warning' }),
|
||||
});
|
||||
|
||||
const updateLastCardFromClipboard = createUpdateLastCardFromClipboardHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
readClipboardText: () => deps.clipboard.readText(),
|
||||
showMpvOsd: showStatus,
|
||||
updateLastCardFromClipboardCore,
|
||||
});
|
||||
|
||||
const refreshKnownWordCache = createRefreshKnownWordCacheHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
missingIntegrationMessage: 'AnkiConnect integration not enabled',
|
||||
});
|
||||
|
||||
const triggerFieldGrouping = createTriggerFieldGroupingHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
showMpvOsd: showStatus,
|
||||
triggerFieldGroupingCore,
|
||||
});
|
||||
|
||||
const markLastCardAsAudioCard = createMarkLastCardAsAudioCardHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
showMpvOsd: showStatus,
|
||||
markLastCardAsAudioCardCore,
|
||||
});
|
||||
|
||||
const mineSentenceCard = createMineSentenceCardHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(),
|
||||
showMpvOsd: showStatus,
|
||||
mineSentenceCardCore,
|
||||
});
|
||||
|
||||
const copySubtitleCount = createHandleMultiCopyDigitHandler({
|
||||
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||
writeClipboardText: (text) => deps.clipboard.writeText(text),
|
||||
showMpvOsd: showPlaybackFeedback,
|
||||
handleMultiCopyDigitCore,
|
||||
});
|
||||
|
||||
const copyCurrentSubtitle = createCopyCurrentSubtitleHandler({
|
||||
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||
writeClipboardText: (text) => deps.clipboard.writeText(text),
|
||||
showMpvOsd: showStatus,
|
||||
copyCurrentSubtitleCore,
|
||||
});
|
||||
|
||||
const mineSentenceCount = createHandleMineSentenceDigitHandler({
|
||||
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined,
|
||||
showMpvOsd: showStatus,
|
||||
logError: (message, err) => {
|
||||
logger.error(message, err);
|
||||
},
|
||||
handleMineSentenceDigitCore,
|
||||
});
|
||||
|
||||
/** Points a freshly created Anki integration at mining, stats, plugins and furigana. */
|
||||
function attachToAnkiIntegration(integration: AnkiIntegrationLike): void {
|
||||
const callbacks = deps.ankiIntegrationCallbacks;
|
||||
integration.setRecordCardsMinedCallback(callbacks.recordCardsMined);
|
||||
integration.setMiningProvenanceProvider(callbacks.getMiningProvenance);
|
||||
integration.setCardsReadyCallback(callbacks.onCardsReady);
|
||||
integration.setKnownWordCacheUpdatedCallback(callbacks.onKnownWordCacheUpdated);
|
||||
integration.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
||||
integration.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview);
|
||||
integration.setSentenceFuriganaGenerator(callbacks.generateSentenceFurigana);
|
||||
}
|
||||
|
||||
const ipc: IpcSlice<
|
||||
| 'recordSubtitleMiningContext'
|
||||
| 'previewMediaTimingReview'
|
||||
| 'getMediaTimingReviewFrame'
|
||||
| 'getMediaTimingReviewWaveform'
|
||||
| 'stopMediaTimingReviewPreview'
|
||||
| 'resolveMediaTimingReview'
|
||||
> = {
|
||||
// Overlay lookups carry no cue context of their own; fall back to snapshotting the
|
||||
// live mpv sub timings at lookup time so media generation clips the mined line even
|
||||
// when extraction finishes long after playback has moved on.
|
||||
recordSubtitleMiningContext: (context) =>
|
||||
recordSubtitleMiningContext(
|
||||
context
|
||||
? shiftSubtitleRangeToVideoClock(context, appState.mpvClient?.currentSubDelay ?? 0)
|
||||
: captureCurrentPrimarySubtitleMiningContext(),
|
||||
),
|
||||
previewMediaTimingReview: (request) => mediaTimingReviewRuntime.previewRange(request),
|
||||
getMediaTimingReviewFrame: (request) => mediaTimingReviewRuntime.getFrame(request),
|
||||
getMediaTimingReviewWaveform: (request) => mediaTimingReviewRuntime.getWaveform(request),
|
||||
stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId),
|
||||
resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request),
|
||||
};
|
||||
|
||||
const cli: CliSlice<
|
||||
| 'copyCurrentSubtitle'
|
||||
| 'mineSentenceCard'
|
||||
| 'updateLastCardFromClipboard'
|
||||
| 'refreshKnownWordCache'
|
||||
| 'triggerFieldGrouping'
|
||||
| 'markLastCardAsAudioCard'
|
||||
> = {
|
||||
copyCurrentSubtitle: () => copyCurrentSubtitle(),
|
||||
mineSentenceCard: () => mineSentenceCard(),
|
||||
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
|
||||
refreshKnownWordCache: () => refreshKnownWordCache(),
|
||||
triggerFieldGrouping: () => triggerFieldGrouping(),
|
||||
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
|
||||
};
|
||||
|
||||
return {
|
||||
ipc,
|
||||
cli,
|
||||
captureCurrentPrimarySubtitleMiningContext,
|
||||
recordSubtitleMiningContext,
|
||||
getFieldGroupingResolver,
|
||||
setFieldGroupingResolver,
|
||||
createFieldGroupingCallback: fieldGroupingOverlayRuntime.createFieldGroupingCallback,
|
||||
mediaTimingReviewRuntime,
|
||||
attachToAnkiIntegration,
|
||||
updateLastCardFromClipboard,
|
||||
refreshKnownWordCache,
|
||||
triggerFieldGrouping,
|
||||
markLastCardAsAudioCard,
|
||||
mineSentenceCard,
|
||||
mineSentenceCount,
|
||||
copyCurrentSubtitle,
|
||||
copySubtitleCount,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test, { type TestContext } from 'node:test';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { HyprlandPlacementStatus } from '../../core/services/hyprland-window-placement';
|
||||
import type { WindowGeometry } from '../../types';
|
||||
import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
|
||||
import { createAppState } from '../state';
|
||||
import { createOverlayAssembly, type OverlayAssemblyDeps } from './overlay';
|
||||
|
||||
@@ -31,8 +31,15 @@ type FixtureState = {
|
||||
trackedGeometry: WindowGeometry | null;
|
||||
/** Window returned by window creation; null makes creation throw after recording options. */
|
||||
window: BrowserWindow | null;
|
||||
/** The overlay manager's current main window. */
|
||||
mainWindow: BrowserWindow | null;
|
||||
/** Whether the measurement store accepts a reported content measurement. */
|
||||
measurementAccepted: boolean;
|
||||
/** Whether the plugin panel recognizes a closed panel id as the one showing. */
|
||||
pluginPanelCurrent: boolean;
|
||||
};
|
||||
|
||||
const ACCEPTED_MEASUREMENT = {} as OverlayContentMeasurement;
|
||||
const WINDOW_CREATION_STOPPED = 'window creation stopped by test';
|
||||
const NO_PLACEMENT: HyprlandPlacementStatus = {
|
||||
applicable: false,
|
||||
@@ -56,7 +63,7 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
|
||||
warn: spy('logger.warn', noop),
|
||||
},
|
||||
overlayManager: {
|
||||
getMainWindow: spy('overlay.getMainWindow', () => null),
|
||||
getMainWindow: spy('overlay.getMainWindow', () => state.mainWindow),
|
||||
setMainWindow: spy('overlay.setMainWindow', noop),
|
||||
getModalWindow: spy('overlay.getModalWindow', () => null),
|
||||
setModalWindow: spy('overlay.setModalWindow', noop),
|
||||
@@ -68,6 +75,15 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
|
||||
setModalWindowBounds: spy('overlay.setModalWindowBounds', () => NO_PLACEMENT),
|
||||
},
|
||||
overlayModalInputState: { getModalInputExclusive: spy('modal.getInputExclusive', () => false) },
|
||||
overlayModalRuntime: {
|
||||
notifyOverlayModalOpened: spy('modal.notifyOpened', noop),
|
||||
handleOverlayModalClosed: spy('modal.handleClosed', noop),
|
||||
},
|
||||
overlayContentMeasurementStore: {
|
||||
report: spy('measurement.report', () =>
|
||||
state.measurementAccepted ? ACCEPTED_MEASUREMENT : null,
|
||||
),
|
||||
},
|
||||
autoplayReadyGate: {
|
||||
markCurrentMediaAutoplayReady: spy('autoplay.markCurrentMediaReady', noop),
|
||||
flushPendingAutoplayReadySignal: spy('autoplay.flushPending', noop),
|
||||
@@ -89,6 +105,14 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
|
||||
getLinuxOverlayInputShapeActive: spy('interaction.getLinuxInputShapeActive', () => false),
|
||||
getMacOSVisibleOverlayForegroundProbeActive: spy('interaction.getMacProbe', () => false),
|
||||
getVisibleOverlayInteractionActive: spy('interaction.getActive', () => false),
|
||||
setVisibleOverlayInteractionActive: spy('interaction.setActive', noop),
|
||||
setLinuxOverlayInteractiveHint: spy('interaction.setLinuxInteractiveHint', noop),
|
||||
applyLinuxOverlayInputShapeFromLatestMeasurement: spy(
|
||||
'interaction.applyLinuxShape',
|
||||
() => false,
|
||||
),
|
||||
tickLinuxOverlayPointerInteractionNow: spy('interaction.tickLinuxNow', noop),
|
||||
tickWindowsOverlayPointerInteractionNow: spy('interaction.tickWindowsNow', noop),
|
||||
getWindowsNativeWindowHandleNumber: spy('interaction.getWindowsHandle', () => 0),
|
||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement: spy(
|
||||
'interaction.primePointerAfterFirstMeasurement',
|
||||
@@ -141,7 +165,13 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
|
||||
getAutoplaySubtitlePriming: spy('getAutoplaySubtitlePriming', () => ({
|
||||
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint: spy('priming.cancelFirstPaint', noop),
|
||||
primeCurrentSubtitleForVisibleOverlay: spy('priming.prime', resolved),
|
||||
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint: spy('priming.scheduleFirstPaint', noop),
|
||||
})),
|
||||
getPluginPanel: spy('getPluginPanel', () => ({
|
||||
handleModalClosed: spy('pluginPanel.handleModalClosed', () => state.pluginPanelCurrent),
|
||||
})),
|
||||
setSubtitleSidebarRequestedOpen: spy('setSubtitleSidebarRequestedOpen', noop),
|
||||
stealAppFocus: spy('stealAppFocus', noop),
|
||||
ensureOverlayMpvSubtitlesHidden: spy('ensureOverlayMpvSubtitlesHidden', resolved),
|
||||
syncOverlayMpvSubtitleSuppression: spy('syncOverlayMpvSubtitleSuppression', noop),
|
||||
initializeOverlayRuntime: spy('initializeOverlayRuntime', noop),
|
||||
@@ -161,6 +191,9 @@ function setup(t: TestContext, options: { platform?: NodeJS.Platform; visible?:
|
||||
visible: options.visible ?? false,
|
||||
trackedGeometry: null,
|
||||
window: null,
|
||||
mainWindow: null,
|
||||
measurementAccepted: true,
|
||||
pluginPanelCurrent: true,
|
||||
};
|
||||
const { deps, windowOptions } = makeDeps(recorder, state);
|
||||
const overlay = createOverlayAssembly(deps);
|
||||
@@ -329,3 +362,54 @@ for (const trackedGeometry of [null, { x: 10, y: 20, width: 1280, height: 720 }]
|
||||
assert.ok(!names.includes('geometry.getCurrentOverlayGeometry'));
|
||||
});
|
||||
}
|
||||
|
||||
test('an accepted content measurement refreshes pointer input, then releases autoplay, then schedules the first-paint refresh', (t) => {
|
||||
const { recorder, overlay } = setup(t);
|
||||
|
||||
overlay.ipc.reportOverlayContentBounds({ layer: 'visible' });
|
||||
|
||||
assertCalledInOrder(recorder.names(), [
|
||||
'measurement.report',
|
||||
'interaction.tickLinuxNow',
|
||||
'interaction.tickWindowsNow',
|
||||
'interaction.primePointerAfterFirstMeasurement',
|
||||
'autoplay.flushPending',
|
||||
'priming.scheduleFirstPaint',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a rejected content measurement changes nothing', (t) => {
|
||||
const { recorder, state, overlay } = setup(t);
|
||||
state.measurementAccepted = false;
|
||||
|
||||
overlay.ipc.reportOverlayContentBounds({ layer: 'visible' });
|
||||
|
||||
assert.deepEqual(recorder.names(), ['measurement.report']);
|
||||
});
|
||||
|
||||
test('only the main overlay window opens and closes the subtitle sidebar state', (t) => {
|
||||
const { recorder, state, overlay } = setup(t);
|
||||
const mainWindow = { isDestroyed: () => false } as BrowserWindow;
|
||||
const otherWindow = { isDestroyed: () => false } as BrowserWindow;
|
||||
state.mainWindow = mainWindow;
|
||||
const sidebarStates = () =>
|
||||
recorder.calls
|
||||
.filter((call) => call.name === 'setSubtitleSidebarRequestedOpen')
|
||||
.map((call) => call.args[0]);
|
||||
|
||||
overlay.ipc.onOverlayModalOpened('subtitle-sidebar', otherWindow);
|
||||
overlay.ipc.onOverlayModalOpened('subtitle-sidebar', mainWindow);
|
||||
overlay.ipc.onOverlayModalClosed('subtitle-sidebar', otherWindow);
|
||||
overlay.ipc.onOverlayModalClosed('subtitle-sidebar', mainWindow);
|
||||
|
||||
assert.deepEqual(sidebarStates(), [true, false]);
|
||||
});
|
||||
|
||||
test('a late close for a replaced plugin panel leaves the showing panel open', (t) => {
|
||||
const { recorder, state, overlay } = setup(t);
|
||||
state.pluginPanelCurrent = false;
|
||||
|
||||
overlay.ipc.onOverlayModalClosed('plugin-panel', null, 7);
|
||||
|
||||
assert.equal(recorder.names().includes('modal.handleClosed'), false);
|
||||
});
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { BrowserWindow, Screen, Session } from 'electron';
|
||||
import {
|
||||
type createOverlayContentMeasurementStore,
|
||||
type createOverlayWindow as createOverlayWindowCoreFn,
|
||||
sendMpvCommandRuntime,
|
||||
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
||||
type createOverlayManager,
|
||||
} from '../../core/services';
|
||||
import { applyOverlayClickThrough } from '../../core/services/overlay-click-through';
|
||||
import type { Logger } from '../../logger';
|
||||
import type { WindowGeometry } from '../../types';
|
||||
import { ensureWindowsOverlayTransparency } from '../../window-trackers/windows-helper';
|
||||
import type { createOverlayShortcutsRuntimeService } from '../overlay-shortcuts-runtime';
|
||||
import type { OverlayModalRuntime } from '../overlay-runtime';
|
||||
import { createOverlayVisibilityRuntimeService } from '../overlay-visibility-runtime';
|
||||
import type { createAutoplayReadyGate } from '../runtime/autoplay-ready-gate';
|
||||
import type { composeOverlayVisibilityRuntime } from '../runtime/composers';
|
||||
@@ -20,6 +23,7 @@ import {
|
||||
} from '../runtime/linux-mpv-fullscreen-overlay-refresh';
|
||||
import { createLinuxOverlayModeRuntime } from '../runtime/linux-overlay-mode-runtime';
|
||||
import { shouldRunLinuxOverlayZOrderKeepAlive } from '../runtime/linux-overlay-zorder-keepalive';
|
||||
import { focusMacOSOverlayWindow } from '../runtime/macos-overlay-window-focus';
|
||||
import type { createMediaTimingReviewRuntime } from '../runtime/media-timing-review';
|
||||
import type { createOverlayModalInputState } from '../runtime/overlay-modal-input-state';
|
||||
import type { createOverlayGeometryRuntime } from '../runtime/overlay-geometry-runtime';
|
||||
@@ -28,6 +32,7 @@ import { createOverlayWindowRuntimeHandlers } from '../runtime/overlay-window-ru
|
||||
import type { VisibleOverlayInteractionRuntime } from '../runtime/visible-overlay-interaction-runtime';
|
||||
import type { AppState } from '../state';
|
||||
import type { PlaybackAssembly } from './playback';
|
||||
import type { IpcSlice } from './slices';
|
||||
|
||||
type OverlayManager = ReturnType<typeof createOverlayManager>;
|
||||
|
||||
@@ -54,6 +59,14 @@ export interface OverlayAssemblyDeps {
|
||||
ReturnType<typeof createOverlayModalInputState>,
|
||||
'getModalInputExclusive'
|
||||
>;
|
||||
overlayModalRuntime: Pick<
|
||||
OverlayModalRuntime,
|
||||
'notifyOverlayModalOpened' | 'handleOverlayModalClosed'
|
||||
>;
|
||||
overlayContentMeasurementStore: Pick<
|
||||
ReturnType<typeof createOverlayContentMeasurementStore>,
|
||||
'report'
|
||||
>;
|
||||
autoplayReadyGate: Pick<
|
||||
ReturnType<typeof createAutoplayReadyGate>,
|
||||
'markCurrentMediaAutoplayReady' | 'flushPendingAutoplayReadySignal'
|
||||
@@ -69,6 +82,11 @@ export interface OverlayAssemblyDeps {
|
||||
| 'getLinuxOverlayInputShapeActive'
|
||||
| 'getMacOSVisibleOverlayForegroundProbeActive'
|
||||
| 'getVisibleOverlayInteractionActive'
|
||||
| 'setVisibleOverlayInteractionActive'
|
||||
| 'setLinuxOverlayInteractiveHint'
|
||||
| 'applyLinuxOverlayInputShapeFromLatestMeasurement'
|
||||
| 'tickLinuxOverlayPointerInteractionNow'
|
||||
| 'tickWindowsOverlayPointerInteractionNow'
|
||||
| 'getWindowsNativeWindowHandleNumber'
|
||||
| 'primeLinuxOverlayPointerInteractionAfterFirstMeasurement'
|
||||
| 'requestLinuxOverlayZOrderFollow'
|
||||
@@ -113,8 +131,16 @@ export interface OverlayAssemblyDeps {
|
||||
>;
|
||||
getAutoplaySubtitlePriming: () => Pick<
|
||||
PlaybackAssembly['autoplaySubtitlePrimingRuntime'],
|
||||
'cancelVisibleOverlaySubtitleRefreshAfterFirstPaint' | 'primeCurrentSubtitleForVisibleOverlay'
|
||||
| 'cancelVisibleOverlaySubtitleRefreshAfterFirstPaint'
|
||||
| 'primeCurrentSubtitleForVisibleOverlay'
|
||||
| 'scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint'
|
||||
>;
|
||||
/** A late close for a replaced plugin panel reports false and must be ignored. */
|
||||
getPluginPanel: () => { handleModalClosed: (panelId: number) => boolean };
|
||||
/** The renderer reports the subtitle sidebar as a modal; playback remembers its state. */
|
||||
setSubtitleSidebarRequestedOpen: (open: boolean) => void;
|
||||
/** Brings SubMiner to the foreground (`app.focus({ steal: true })`). */
|
||||
stealAppFocus: () => void;
|
||||
ensureOverlayMpvSubtitlesHidden: () => Promise<void>;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
initializeOverlayRuntime: () => void;
|
||||
@@ -426,7 +452,122 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
}
|
||||
|
||||
/** Renderer reports about overlay windows: focus, modal lifecycle, pointer and measurement. */
|
||||
const ipc: IpcSlice<
|
||||
| 'getMainWindow'
|
||||
| 'focusMainWindow'
|
||||
| 'activatePlaybackWindowForOverlayInteraction'
|
||||
| 'onOverlayModalClosed'
|
||||
| 'onOverlayModalOpened'
|
||||
| 'onOverlayMouseInteractionChanged'
|
||||
| 'onOverlayInteractiveHint'
|
||||
| 'reportOverlayContentBounds'
|
||||
> = {
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
focusMainWindow: () => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
if (process.platform === 'darwin') {
|
||||
focusMacOSOverlayWindow({
|
||||
platform: process.platform,
|
||||
getOverlayWindow: () => mainWindow,
|
||||
stealAppFocus: () => deps.stealAppFocus(),
|
||||
warn: (message, details) => logger.warn(message, details),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!mainWindow.isFocused()) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
},
|
||||
activatePlaybackWindowForOverlayInteraction: async () => {
|
||||
try {
|
||||
const raised = (await appState.windowTracker?.raiseTargetWindow?.()) ?? false;
|
||||
deps.getOverlayGeometry().enforceOverlayLayerOrder();
|
||||
return raised;
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
'Failed to raise tracked mpv window for overlay interaction:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
deps.getOverlayGeometry().enforceOverlayLayerOrder();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onOverlayModalClosed: (modal, senderWindow, instanceId) => {
|
||||
// A late close for a replaced plugin panel must not hide the panel now showing.
|
||||
if (
|
||||
modal === 'plugin-panel' &&
|
||||
instanceId !== undefined &&
|
||||
!deps.getPluginPanel().handleModalClosed(instanceId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (modal === 'media-timing-review') {
|
||||
void deps.getMediaTimingReviewRuntime().dispose();
|
||||
}
|
||||
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
|
||||
deps.setSubtitleSidebarRequestedOpen(false);
|
||||
}
|
||||
const modalWindow = overlayManager.getModalWindow();
|
||||
if (
|
||||
senderWindow &&
|
||||
modalWindow &&
|
||||
senderWindow === modalWindow &&
|
||||
!senderWindow.isDestroyed()
|
||||
) {
|
||||
applyOverlayClickThrough(senderWindow);
|
||||
senderWindow.hide();
|
||||
}
|
||||
deps.overlayModalRuntime.handleOverlayModalClosed(modal);
|
||||
},
|
||||
onOverlayModalOpened: (modal, senderWindow) => {
|
||||
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
|
||||
deps.setSubtitleSidebarRequestedOpen(true);
|
||||
}
|
||||
deps.overlayModalRuntime.notifyOverlayModalOpened(modal);
|
||||
},
|
||||
onOverlayMouseInteractionChanged: (active, senderWindow) => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || senderWindow !== mainWindow) {
|
||||
return;
|
||||
}
|
||||
const interaction = deps.getVisibleOverlayInteraction();
|
||||
const previousActive = interaction.getVisibleOverlayInteractionActive();
|
||||
interaction.setVisibleOverlayInteractionActive(active);
|
||||
if (previousActive === active) {
|
||||
if (active && process.platform === 'darwin' && !mainWindow.isFocused()) {
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
}
|
||||
return;
|
||||
}
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
},
|
||||
onOverlayInteractiveHint: (interactive, senderWindow) => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || senderWindow !== mainWindow) {
|
||||
return;
|
||||
}
|
||||
const interaction = deps.getVisibleOverlayInteraction();
|
||||
interaction.setLinuxOverlayInteractiveHint(interactive);
|
||||
interaction.applyLinuxOverlayInputShapeFromLatestMeasurement();
|
||||
},
|
||||
// An accepted measurement refreshes pointer passthrough at once, then releases autoplay,
|
||||
// then schedules the first-paint subtitle refresh.
|
||||
reportOverlayContentBounds: (payload: unknown) => {
|
||||
if (deps.overlayContentMeasurementStore.report(payload)) {
|
||||
const interaction = deps.getVisibleOverlayInteraction();
|
||||
interaction.tickLinuxOverlayPointerInteractionNow();
|
||||
interaction.tickWindowsOverlayPointerInteractionNow();
|
||||
interaction.primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
|
||||
deps.autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
deps.getAutoplaySubtitlePriming().scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
ipc,
|
||||
linuxOverlayModeRuntime,
|
||||
overlayVisibilityRuntime,
|
||||
createMainWindow,
|
||||
|
||||
@@ -88,10 +88,13 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
|
||||
},
|
||||
overlayNotificationsRuntime: {
|
||||
showConfiguredStatusNotification: spy('notifications.showStatus', noop),
|
||||
showConfiguredPlaybackFeedback: spy('notifications.showPlaybackFeedback', noop),
|
||||
getConfiguredStatusNotificationType: spy('notifications.getType', () => 'osd' as const),
|
||||
},
|
||||
overlayModalRuntime: { handleOverlayModalClosed: spy('overlayModal.handleClosed', noop) },
|
||||
youtube: {
|
||||
isPlaybackActiveNow: spy('youtube.isPlaybackActiveNow', () => false),
|
||||
shouldQuitWhenMpvCloses: spy('youtube.shouldQuitWhenMpvCloses', () => false),
|
||||
playback: { getQuitOnDisconnectArmed: spy('youtube.getQuitOnDisconnectArmed', () => false) },
|
||||
primarySubtitleNotification: {
|
||||
handleMediaPathChange: spy('youtube.notification.handleMediaPathChange', noop),
|
||||
@@ -99,8 +102,16 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
|
||||
handleSubtitleTrackListChange: spy('youtube.notification.handleTrackListChange', noop),
|
||||
},
|
||||
mediaCachePlayback: { handleMediaPathChange: spy('youtube.mediaCache', resolved) },
|
||||
browserPlayback: { handleMediaPathChange: spy('youtube.browser', noop) },
|
||||
flow: { handleMediaPathChange: spy('youtube.flow', noop) },
|
||||
browserPlayback: {
|
||||
handleMediaPathChange: spy('youtube.browser', noop),
|
||||
handleMpvDisconnected: spy('youtube.browser.handleMpvDisconnected', noop),
|
||||
},
|
||||
flow: {
|
||||
handleMediaPathChange: spy('youtube.flow', noop),
|
||||
cancelWhisperGeneration: spy('youtube.flow.cancelWhisperGeneration', noop),
|
||||
hasActiveSession: spy('youtube.flow.hasActiveSession', () => false),
|
||||
cancelActivePicker: spy('youtube.flow.cancelActivePicker', () => false),
|
||||
},
|
||||
},
|
||||
jellyfin: {
|
||||
getQuitOnDisconnectArmed: spy('jellyfin.getQuitOnDisconnectArmed', () => false),
|
||||
@@ -133,6 +144,7 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
|
||||
getSubtitleGenerationRuntime: spy('getSubtitleGenerationRuntime', () => ({
|
||||
handleMediaPathChange: noop,
|
||||
})),
|
||||
getPluginHost: spy('getPluginHost', () => ({ attachMpvClient: noop })),
|
||||
isCharacterDictionaryEnabled: spy('isCharacterDictionaryEnabled', () => false),
|
||||
hasTray: spy('hasTray', () => false),
|
||||
loadSubtitleSourceText: spy('loadSubtitleSourceText', async () => ''),
|
||||
@@ -145,7 +157,6 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
|
||||
refreshMpvSessionBindings: spy('refreshMpvSessionBindings', resolved),
|
||||
ensureImmersionTrackerStarted: spy('ensureImmersionTrackerStarted', noop),
|
||||
restoreOverlayMpvSubtitles: spy('restoreOverlayMpvSubtitles', noop),
|
||||
resetSubtitleSidebarEmbeddedLayoutRuntime: spy('resetSubtitleSidebarLayout', noop),
|
||||
syncOverlayMpvSubtitleSuppression: spy('syncOverlayMpvSubtitleSuppression', noop),
|
||||
setOverlayVisible: spy('setOverlayVisible', noop),
|
||||
ensureYomitanExtensionLoaded: spy('ensureYomitanExtensionLoaded', async () => null),
|
||||
@@ -393,3 +404,42 @@ test('warm tokenization releases autoplay with the current subtitle when it is s
|
||||
const signals = recorder.calls.filter((call) => call.name === 'autoplay.maybeSignal');
|
||||
assert.deepEqual(signals.at(-1)?.args, [current, { forceWhilePaused: true }]);
|
||||
});
|
||||
|
||||
/** An mpv client that answers the sidebar's property reads from a table. */
|
||||
function fakeSidebarMpvClient(properties: Record<string, unknown>) {
|
||||
return {
|
||||
connected: true,
|
||||
requestProperty: async (name: string) => properties[name] ?? null,
|
||||
} as unknown as NonNullable<ReturnType<typeof setup>['appState']['mpvClient']>;
|
||||
}
|
||||
|
||||
test('sidebar snapshot keeps cached YouTube cues instead of re-resolving the subtitle source', async () => {
|
||||
const { appState, playback, recorder } = setup();
|
||||
const cues = [{ startTime: 1, endTime: 2, text: '字幕' }];
|
||||
appState.activeParsedSubtitleCues = cues;
|
||||
appState.activeParsedSubtitleMediaPath = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||
appState.mpvClient = fakeSidebarMpvClient({
|
||||
path: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30',
|
||||
// Without the YouTube cache check this source would be re-read and re-parsed.
|
||||
'current-tracks/sub/external-filename': '/tmp/youtube-subs/dQw4w9WgXcQ.ja.vtt',
|
||||
'track-list': [],
|
||||
sid: 1,
|
||||
});
|
||||
|
||||
const snapshot = await playback.ipc.getSubtitleSidebarSnapshot();
|
||||
|
||||
assert.equal(snapshot.cues, cues);
|
||||
assert.equal(recorder.names().includes('loadSubtitleSourceText'), false);
|
||||
});
|
||||
|
||||
test('sidebar snapshot falls back to cached cues while mpv is disconnected', async () => {
|
||||
const { appState, playback, recorder } = setup();
|
||||
const cues = [{ startTime: 1, endTime: 2, text: '字幕' }];
|
||||
appState.activeParsedSubtitleCues = cues;
|
||||
|
||||
const snapshot = await playback.ipc.getSubtitleSidebarSnapshot();
|
||||
|
||||
assert.equal(snapshot.cues, cues);
|
||||
assert.equal(snapshot.config, DEFAULT_CONFIG.subtitleSidebar);
|
||||
assert.equal(recorder.names().includes('loadSubtitleSourceText'), false);
|
||||
});
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
||||
} from '../../core/services';
|
||||
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
||||
import { resolveAniSkipMetadataForFile } from '../runtime/aniskip-metadata';
|
||||
import { createAniSkipRuntime } from '../runtime/aniskip-runtime';
|
||||
import { resolveCurrentSubtitleForRenderer } from '../runtime/current-subtitle-snapshot';
|
||||
import { resolveFreshPlaybackPaused } from '../runtime/playback-paused-state';
|
||||
import { createAutoplaySubtitlePrimingRuntime } from '../runtime/autoplay-subtitle-priming-runtime';
|
||||
import { createAutoplayTokenizationWarmRelease } from '../runtime/autoplay-tokenization-warm-release';
|
||||
import { composeMpvRuntimeHandlers } from '../runtime/composers';
|
||||
@@ -28,8 +32,13 @@ import {
|
||||
createResolveActiveSubtitleSidebarSourceHandler,
|
||||
} from '../runtime/subtitle-prefetch-runtime';
|
||||
import { createPrefetchAwareSubtitleProcessingController } from '../runtime/subtitle-processing-runtime';
|
||||
import { isSameYoutubeMediaPath, isYoutubeMediaPath } from '../runtime/youtube-playback';
|
||||
import {
|
||||
isSameYoutubeMediaPath,
|
||||
isYoutubeMediaPath,
|
||||
shouldUseCachedYoutubeParsedCues,
|
||||
} from '../runtime/youtube-playback';
|
||||
import { MecabTokenizer } from '../../mecab-tokenizer';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import type {
|
||||
MpvSubtitleRenderMetrics,
|
||||
SubtitleCue,
|
||||
@@ -56,11 +65,13 @@ import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notif
|
||||
import type { createStartupOsdSequencer } from '../runtime/startup-osd-sequencer';
|
||||
import type { createSubtitleGenerationRuntime } from '../runtime/subtitle-generation-runtime';
|
||||
import type { RuntimeOptionId } from '../../types/runtime-options';
|
||||
import type { OverlayModalRuntime } from '../overlay-runtime';
|
||||
import type { AppState } from '../state';
|
||||
import type { AnilistAssembly } from './anilist';
|
||||
import type { CharacterDictionaryAssembly } from './character-dictionary';
|
||||
import type { DictionariesAssembly } from './dictionaries';
|
||||
import type { JellyfinAssembly } from './jellyfin';
|
||||
import type { IpcSlice } from './slices';
|
||||
import type { YoutubeAssembly } from './youtube';
|
||||
|
||||
type SubtitleWsBroadcaster = Pick<SubtitleWebSocket, 'broadcast'>;
|
||||
@@ -128,17 +139,29 @@ export interface PlaybackAssemblyDeps {
|
||||
>;
|
||||
overlayNotificationsRuntime: Pick<
|
||||
OverlayNotificationsRuntime,
|
||||
'showConfiguredStatusNotification' | 'getConfiguredStatusNotificationType'
|
||||
| 'showConfiguredStatusNotification'
|
||||
| 'showConfiguredPlaybackFeedback'
|
||||
| 'getConfiguredStatusNotificationType'
|
||||
>;
|
||||
youtube: Pick<YoutubeAssembly, 'isPlaybackActiveNow'> & {
|
||||
overlayModalRuntime: Pick<OverlayModalRuntime, 'handleOverlayModalClosed'>;
|
||||
youtube: Pick<YoutubeAssembly, 'isPlaybackActiveNow' | 'shouldQuitWhenMpvCloses'> & {
|
||||
playback: Pick<YoutubeAssembly['playback'], 'getQuitOnDisconnectArmed'>;
|
||||
primarySubtitleNotification: Pick<
|
||||
YoutubeAssembly['primarySubtitleNotification'],
|
||||
'handleMediaPathChange' | 'handleSubtitleTrackChange' | 'handleSubtitleTrackListChange'
|
||||
>;
|
||||
mediaCachePlayback: Pick<YoutubeAssembly['mediaCachePlayback'], 'handleMediaPathChange'>;
|
||||
browserPlayback: Pick<YoutubeAssembly['browserPlayback'], 'handleMediaPathChange'>;
|
||||
flow: Pick<YoutubeAssembly['flow'], 'handleMediaPathChange'>;
|
||||
browserPlayback: Pick<
|
||||
YoutubeAssembly['browserPlayback'],
|
||||
'handleMediaPathChange' | 'handleMpvDisconnected'
|
||||
>;
|
||||
flow: Pick<
|
||||
YoutubeAssembly['flow'],
|
||||
| 'handleMediaPathChange'
|
||||
| 'cancelWhisperGeneration'
|
||||
| 'hasActiveSession'
|
||||
| 'cancelActivePicker'
|
||||
>;
|
||||
};
|
||||
jellyfin: Pick<
|
||||
JellyfinAssembly,
|
||||
@@ -171,6 +194,8 @@ export interface PlaybackAssemblyDeps {
|
||||
ReturnType<typeof createSubtitleGenerationRuntime>,
|
||||
'handleMediaPathChange'
|
||||
>;
|
||||
/** Plugins observe every mpv client the app creates. */
|
||||
getPluginHost: () => { attachMpvClient: (client: MpvIpcClient) => void };
|
||||
isCharacterDictionaryEnabled: () => boolean;
|
||||
/** Whether the tray icon exists; mpv shutdown keeps the app alive when it does. */
|
||||
hasTray: () => boolean;
|
||||
@@ -184,7 +209,6 @@ export interface PlaybackAssemblyDeps {
|
||||
refreshMpvSessionBindings: () => Promise<void>;
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
restoreOverlayMpvSubtitles: () => void;
|
||||
resetSubtitleSidebarEmbeddedLayoutRuntime: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
setOverlayVisible: (visible: boolean) => void;
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
||||
@@ -229,6 +253,13 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
|
||||
} = deps;
|
||||
let backgroundWarmupsStarted = false;
|
||||
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
|
||||
// Set from the renderer's modal open/close reports; replacement overlay windows restore it.
|
||||
let subtitleSidebarRequestedOpen = false;
|
||||
|
||||
function resetSubtitleSidebarEmbeddedLayout(): void {
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-margin-ratio-right', 0]);
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-pan-x', 0]);
|
||||
}
|
||||
|
||||
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
||||
const canonical = resolveCanonicalPrimarySubtitle({
|
||||
@@ -664,7 +695,7 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
|
||||
deps.restoreOverlayMpvSubtitles();
|
||||
},
|
||||
resetSubtitleSidebarEmbeddedLayout: () => {
|
||||
deps.resetSubtitleSidebarEmbeddedLayoutRuntime();
|
||||
resetSubtitleSidebarEmbeddedLayout();
|
||||
},
|
||||
getCurrentAnilistMediaKey: () => anilist.getCurrentAnilistMediaKey(),
|
||||
resetAnilistMediaTracking: (mediaKey) => {
|
||||
@@ -955,7 +986,171 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
|
||||
});
|
||||
tokenizeSubtitleDeferred = tokenizeSubtitle;
|
||||
|
||||
const aniSkipRuntime = createAniSkipRuntime({
|
||||
getAniSkipConfig: () => ({
|
||||
aniskipEnabled: configService.getConfig().mpv.aniskipEnabled,
|
||||
aniskipButtonKey: configService.getConfig().mpv.aniskipButtonKey,
|
||||
}),
|
||||
resolveMetadataForFile: (mediaPath) => resolveAniSkipMetadataForFile(mediaPath),
|
||||
sendMpvCommand: (command) => {
|
||||
appState.mpvClient?.send({ command });
|
||||
},
|
||||
requestMpvProperty: (name) => {
|
||||
if (!appState.mpvClient) {
|
||||
return Promise.reject(new Error('MPV not connected'));
|
||||
}
|
||||
return appState.mpvClient.requestProperty(name);
|
||||
},
|
||||
isMpvConnected: () => appState.mpvClient?.connected === true,
|
||||
getCurrentTimePos: () => appState.mpvClient?.currentTimePos ?? Number.NaN,
|
||||
showMpvOsd: (text, durationMs) => {
|
||||
appState.mpvClient?.send({ command: ['show-text', text, durationMs] });
|
||||
},
|
||||
showPlaybackFeedback: (text) =>
|
||||
overlayNotificationsRuntime.showConfiguredPlaybackFeedback(text),
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
logDebug: (message) => logger.debug(message),
|
||||
});
|
||||
|
||||
/** Creates the app's mpv client with YouTube, AniSkip and plugin listeners attached. */
|
||||
function createMpvClient(): MpvIpcClient {
|
||||
const client = createMpvClientRuntimeServiceHandler() as MpvIpcClient;
|
||||
client.on('connection-change', ({ connected }) => {
|
||||
if (connected) {
|
||||
return;
|
||||
}
|
||||
youtube.browserPlayback.handleMpvDisconnected();
|
||||
youtube.flow.cancelWhisperGeneration();
|
||||
deps.getSubtitleGenerationRuntime().handleMediaPathChange(null);
|
||||
if (youtube.shouldQuitWhenMpvCloses()) {
|
||||
deps.requestAppQuit();
|
||||
return;
|
||||
}
|
||||
if (!youtube.flow.hasActiveSession()) {
|
||||
return;
|
||||
}
|
||||
youtube.flow.cancelActivePicker();
|
||||
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null);
|
||||
deps.overlayModalRuntime.handleOverlayModalClosed('youtube-track-picker');
|
||||
});
|
||||
client.on('connection-change', aniSkipRuntime.handleConnectionChange);
|
||||
client.on('media-path-change', aniSkipRuntime.handleMediaPathChange);
|
||||
client.on('time-pos-change', aniSkipRuntime.handleTimePosChange);
|
||||
client.on('client-message', aniSkipRuntime.handleClientMessage);
|
||||
deps.getPluginHost().attachMpvClient(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
const ipc: IpcSlice<
|
||||
| 'tokenizeCurrentSubtitle'
|
||||
| 'getCurrentSubtitleRaw'
|
||||
| 'getSubtitleSidebarOpen'
|
||||
| 'getSubtitleSidebarPlaybackState'
|
||||
| 'getSubtitleSidebarSnapshot'
|
||||
| 'getPlaybackPaused'
|
||||
| 'getMpvClient'
|
||||
> = {
|
||||
tokenizeCurrentSubtitle: async () => {
|
||||
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
|
||||
return resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: appState.currentSubText,
|
||||
currentSubtitleData: appState.currentSubtitleData,
|
||||
withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload),
|
||||
tokenizeUncached: false,
|
||||
tokenizeSubtitle: tokenizeSubtitleForCurrent
|
||||
? (text) => tokenizeSubtitleForCurrent(text)
|
||||
: undefined,
|
||||
onResolvedSubtitle: (payload) => {
|
||||
appState.currentSubtitleData = payload;
|
||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true });
|
||||
},
|
||||
});
|
||||
},
|
||||
getCurrentSubtitleRaw: () => appState.currentSubText,
|
||||
getSubtitleSidebarOpen: () => subtitleSidebarRequestedOpen,
|
||||
getSubtitleSidebarPlaybackState: () => getSubtitleSidebarPlaybackState(),
|
||||
// Full snapshot: resolves the active source over mpv IPC and re-parses it when it changed.
|
||||
// The renderer fetches it on open, on source/cue changes, and on a slow fallback cadence.
|
||||
getSubtitleSidebarSnapshot: async () => {
|
||||
const config = configService.getConfig().subtitleSidebar;
|
||||
const cachedSnapshot = () => ({
|
||||
...getSubtitleSidebarPlaybackState(),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
config,
|
||||
});
|
||||
const client = appState.mpvClient;
|
||||
if (!client?.connected) {
|
||||
return cachedSnapshot();
|
||||
}
|
||||
|
||||
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 cachedSnapshot();
|
||||
}
|
||||
if (
|
||||
shouldUseCachedYoutubeParsedCues({
|
||||
videoPath,
|
||||
cachedMediaPath: appState.activeParsedSubtitleMediaPath,
|
||||
cachedCueCount: appState.activeParsedSubtitleCues.length,
|
||||
})
|
||||
) {
|
||||
return cachedSnapshot();
|
||||
}
|
||||
|
||||
const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({
|
||||
currentExternalFilenameRaw,
|
||||
currentTrackRaw,
|
||||
trackListRaw,
|
||||
sidRaw,
|
||||
videoPath,
|
||||
});
|
||||
if (!resolvedSource) {
|
||||
return cachedSnapshot();
|
||||
}
|
||||
|
||||
try {
|
||||
if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) {
|
||||
return cachedSnapshot();
|
||||
}
|
||||
|
||||
const content = await loadSubtitleSourceText(resolvedSource.path);
|
||||
const cues = parseSubtitleCues(content, resolvedSource.path);
|
||||
appState.activeParsedSubtitleCues = cues;
|
||||
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
|
||||
appState.activeParsedSubtitleMediaPath = videoPath || null;
|
||||
return cachedSnapshot();
|
||||
} finally {
|
||||
await resolvedSource.cleanup?.();
|
||||
}
|
||||
} catch {
|
||||
return cachedSnapshot();
|
||||
}
|
||||
},
|
||||
getPlaybackPaused: () =>
|
||||
resolveFreshPlaybackPaused({
|
||||
getCachedPlaybackPaused: () => appState.playbackPaused,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
}),
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
};
|
||||
|
||||
return {
|
||||
ipc,
|
||||
createMpvClient,
|
||||
aniSkipRuntime,
|
||||
setSubtitleSidebarRequestedOpen: (open: boolean): void => {
|
||||
subtitleSidebarRequestedOpen = open;
|
||||
},
|
||||
subtitleProcessingController,
|
||||
autoplaySubtitlePrimingRuntime,
|
||||
secondarySubtitleTrackController,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { GlobalShortcut } from 'electron';
|
||||
import { DEFAULT_CONFIG } from '../../config/definitions';
|
||||
import { createShortcutsAssembly, type ShortcutsAssemblyDeps } from './shortcuts';
|
||||
|
||||
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
|
||||
function makeDeps(overrides: Partial<ShortcutsAssemblyDeps> = {}): ShortcutsAssemblyDeps {
|
||||
const lazy = (name: string) => (): never => {
|
||||
throw new Error(`${name} called unexpectedly`);
|
||||
};
|
||||
return {
|
||||
appState: {
|
||||
shortcutsRegistered: false,
|
||||
overlayRuntimeInitialized: false,
|
||||
windowTracker: null,
|
||||
keybindings: [],
|
||||
mpvClient: null,
|
||||
sessionBindings: [],
|
||||
sessionBindingsInitialized: false,
|
||||
},
|
||||
getConfig: lazy('getConfig'),
|
||||
logger: { warn: lazy('warn') },
|
||||
configDir: '/tmp/subminer-shortcuts-assembly-test',
|
||||
isDev: false,
|
||||
overlayManager: {
|
||||
getMainWindow: lazy('getMainWindow'),
|
||||
getVisibleOverlayVisible: lazy('getVisibleOverlayVisible'),
|
||||
broadcastToOverlayWindows: lazy('broadcastToOverlayWindows'),
|
||||
},
|
||||
globalShortcut: new Proxy({}, { get: (_target, name: string) => lazy(name) }) as GlobalShortcut,
|
||||
getOverlayNotifications: lazy('getOverlayNotifications'),
|
||||
getOverlayModals: lazy('getOverlayModals'),
|
||||
getMining: lazy('getMining'),
|
||||
triggerSubsyncFromConfig: lazy('triggerSubsyncFromConfig'),
|
||||
cycleSecondarySubMode: lazy('cycleSecondarySubMode'),
|
||||
toggleVisibleOverlay: lazy('toggleVisibleOverlay'),
|
||||
openDictionarySettings: lazy('openDictionarySettings'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
|
||||
test('createShortcutsAssembly does not call its deps while constructing', () => {
|
||||
const shortcuts = createShortcutsAssembly(makeDeps());
|
||||
assert.equal(typeof shortcuts.registerGlobalShortcuts, 'function');
|
||||
assert.equal(typeof shortcuts.syncOverlayShortcutsForModal, 'function');
|
||||
});
|
||||
|
||||
test('opening a modal releases the registered overlay shortcuts', () => {
|
||||
const deps = makeDeps({ getConfig: () => DEFAULT_CONFIG });
|
||||
deps.appState.shortcutsRegistered = true;
|
||||
const shortcuts = createShortcutsAssembly(deps);
|
||||
|
||||
shortcuts.syncOverlayShortcutsForModal(true);
|
||||
|
||||
assert.equal(deps.appState.shortcutsRegistered, false);
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import type { GlobalShortcut } from 'electron';
|
||||
import { DEFAULT_CONFIG } from '../../config/definitions';
|
||||
import {
|
||||
registerGlobalShortcuts as registerGlobalShortcutsCore,
|
||||
type createOverlayManager,
|
||||
} from '../../core/services';
|
||||
import { resolveConfiguredShortcuts } from '../../core/utils';
|
||||
import type { Logger } from '../../logger';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import { createOverlayShortcutsRuntimeService } from '../overlay-shortcuts-runtime';
|
||||
import { composeShortcutRuntimes } from '../runtime/composers';
|
||||
import { tryBeginVisibleOverlayNumericSelection } from '../runtime/overlay-numeric-selection';
|
||||
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
||||
import { readMpvInputBindings } from '../runtime/mpv-input-bindings';
|
||||
import { createSessionBindingsRuntime } from '../runtime/session-bindings-runtime';
|
||||
import type { AppState } from '../state';
|
||||
import type { MiningAssembly } from './mining';
|
||||
import type { OverlayModalsAssembly } from './overlay-modals';
|
||||
import type { CliSlice, IpcSlice } from './slices';
|
||||
|
||||
type OverlayManager = ReturnType<typeof createOverlayManager>;
|
||||
|
||||
/**
|
||||
* What shortcut handling needs from the rest of the app. Siblings built after this assembly
|
||||
* in `main.ts` are reached through callbacks.
|
||||
*/
|
||||
export interface ShortcutsAssemblyDeps {
|
||||
appState: Pick<
|
||||
AppState,
|
||||
| 'shortcutsRegistered'
|
||||
| 'overlayRuntimeInitialized'
|
||||
| 'windowTracker'
|
||||
| 'keybindings'
|
||||
| 'mpvClient'
|
||||
| 'sessionBindings'
|
||||
| 'sessionBindingsInitialized'
|
||||
>;
|
||||
getConfig: () => ResolvedConfig;
|
||||
logger: Pick<Logger, 'warn'>;
|
||||
configDir: string;
|
||||
isDev: boolean;
|
||||
overlayManager: Pick<
|
||||
OverlayManager,
|
||||
'getMainWindow' | 'getVisibleOverlayVisible' | 'broadcastToOverlayWindows'
|
||||
>;
|
||||
/** Electron's globalShortcut, injected so the assembly loads under `bun test`. */
|
||||
globalShortcut: GlobalShortcut;
|
||||
getOverlayNotifications: () => Pick<
|
||||
ReturnType<typeof createOverlayNotificationsRuntime>,
|
||||
'showConfiguredStatusNotification' | 'showOverlayNotification'
|
||||
>;
|
||||
getOverlayModals: () => Pick<
|
||||
OverlayModalsAssembly,
|
||||
| 'openRuntimeOptionsPalette'
|
||||
| 'openCharacterDictionaryManagerOverlay'
|
||||
| 'openJimakuOverlay'
|
||||
| 'openTsukihimeOverlay'
|
||||
>;
|
||||
getMining: () => Pick<
|
||||
MiningAssembly,
|
||||
| 'markLastCardAsAudioCard'
|
||||
| 'copyCurrentSubtitle'
|
||||
| 'copySubtitleCount'
|
||||
| 'updateLastCardFromClipboard'
|
||||
| 'triggerFieldGrouping'
|
||||
| 'mineSentenceCard'
|
||||
| 'mineSentenceCount'
|
||||
>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
cycleSecondarySubMode: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
openDictionarySettings: () => void;
|
||||
}
|
||||
|
||||
export type ShortcutsAssembly = ReturnType<typeof createShortcutsAssembly>;
|
||||
|
||||
/**
|
||||
* Owns keyboard input outside the renderer: the overlay's local shortcuts, global shortcuts,
|
||||
* the numeric multi-copy/multi-mine sessions, and the session bindings shared with mpv.
|
||||
*/
|
||||
export function createShortcutsAssembly(deps: ShortcutsAssemblyDeps) {
|
||||
const { appState, overlayManager } = deps;
|
||||
const showStatus = (text: string) =>
|
||||
deps.getOverlayNotifications().showConfiguredStatusNotification(text);
|
||||
|
||||
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
getShortcutsRegistered: () => appState.shortcutsRegistered,
|
||||
setShortcutsRegistered: (registered: boolean) => {
|
||||
appState.shortcutsRegistered = registered;
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
isOverlayShortcutContextActive: () => {
|
||||
if (process.platform !== 'win32') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!overlayManager.getVisibleOverlayVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const windowTracker = appState.windowTracker;
|
||||
if (!windowTracker || !windowTracker.isTracking()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return windowTracker.isTargetWindowFocused();
|
||||
},
|
||||
showMpvOsd: showStatus,
|
||||
openRuntimeOptionsPalette: () => {
|
||||
deps.getOverlayModals().openRuntimeOptionsPalette();
|
||||
},
|
||||
openCharacterDictionaryManager: () => {
|
||||
deps.getOverlayModals().openCharacterDictionaryManagerOverlay();
|
||||
},
|
||||
openJimaku: () => {
|
||||
deps.getOverlayModals().openJimakuOverlay();
|
||||
},
|
||||
openTsukihime: () => {
|
||||
deps.getOverlayModals().openTsukihimeOverlay();
|
||||
},
|
||||
markAudioCard: () => deps.getMining().markLastCardAsAudioCard(),
|
||||
copySubtitleMultiple: (timeoutMs: number) => {
|
||||
startPendingMultiCopy(timeoutMs);
|
||||
},
|
||||
copySubtitle: () => {
|
||||
deps.getMining().copyCurrentSubtitle();
|
||||
},
|
||||
toggleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||
updateLastCardFromClipboard: () => deps.getMining().updateLastCardFromClipboard(),
|
||||
triggerFieldGrouping: () => deps.getMining().triggerFieldGrouping(),
|
||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||
mineSentenceCard: () => deps.getMining().mineSentenceCard(),
|
||||
mineSentenceMultiple: (timeoutMs: number) => {
|
||||
startPendingMineSentenceMultiple(timeoutMs);
|
||||
},
|
||||
cancelPendingMultiCopy: () => {
|
||||
cancelPendingMultiCopy();
|
||||
},
|
||||
cancelPendingMineSentenceMultiple: () => {
|
||||
cancelPendingMineSentenceMultiple();
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
getConfiguredShortcuts,
|
||||
registerGlobalShortcuts,
|
||||
refreshGlobalAndOverlayShortcuts,
|
||||
cancelPendingMultiCopy,
|
||||
startPendingMultiCopy,
|
||||
cancelPendingMineSentenceMultiple,
|
||||
startPendingMineSentenceMultiple,
|
||||
syncOverlayShortcuts,
|
||||
refreshOverlayShortcuts,
|
||||
} = composeShortcutRuntimes({
|
||||
globalShortcuts: {
|
||||
getConfiguredShortcutsMainDeps: {
|
||||
getResolvedConfig: () => deps.getConfig(),
|
||||
defaultConfig: DEFAULT_CONFIG,
|
||||
resolveConfiguredShortcuts,
|
||||
},
|
||||
buildRegisterGlobalShortcutsMainDeps: (getConfiguredShortcutsHandler) => ({
|
||||
getConfiguredShortcuts: () => getConfiguredShortcutsHandler(),
|
||||
registerGlobalShortcutsCore,
|
||||
onToggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
|
||||
onOpenYomitanSettings: () => deps.openDictionarySettings(),
|
||||
isDev: deps.isDev,
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
}),
|
||||
buildRefreshGlobalAndOverlayShortcutsMainDeps: (registerGlobalShortcutsHandler) => ({
|
||||
unregisterAllGlobalShortcuts: () => deps.globalShortcut.unregisterAll(),
|
||||
registerGlobalShortcuts: () => registerGlobalShortcutsHandler(),
|
||||
syncOverlayShortcuts: () => syncOverlayShortcuts(),
|
||||
}),
|
||||
},
|
||||
numericShortcutRuntimeMainDeps: {
|
||||
globalShortcut: deps.globalShortcut,
|
||||
showMpvOsd: showStatus,
|
||||
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
|
||||
clearTimer: (timer) => clearTimeout(timer),
|
||||
},
|
||||
numericSessions: {
|
||||
onMultiCopyDigit: (count) => deps.getMining().copySubtitleCount(count),
|
||||
onMineSentenceDigit: (count) => deps.getMining().mineSentenceCount(count),
|
||||
tryBeginMultiCopyOverlaySelection: (timeoutMs) =>
|
||||
tryBeginVisibleOverlayNumericSelection({
|
||||
actionId: 'copySubtitleMultiple',
|
||||
timeoutMs,
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
}),
|
||||
tryBeginMineSentenceOverlaySelection: (timeoutMs) =>
|
||||
tryBeginVisibleOverlayNumericSelection({
|
||||
actionId: 'mineSentenceMultiple',
|
||||
timeoutMs,
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
}),
|
||||
},
|
||||
overlayShortcutsRuntimeMainDeps: {
|
||||
overlayShortcutsRuntime,
|
||||
},
|
||||
});
|
||||
|
||||
const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings } =
|
||||
createSessionBindingsRuntime({
|
||||
configDir: deps.configDir,
|
||||
getKeybindings: () => appState.keybindings,
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
getResolvedConfig: () => deps.getConfig(),
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
setSessionBindings: (bindings) => {
|
||||
appState.sessionBindings = bindings;
|
||||
},
|
||||
setSessionBindingsInitialized: (initialized) => {
|
||||
appState.sessionBindingsInitialized = initialized;
|
||||
},
|
||||
logWarn: (message) => deps.logger.warn(message),
|
||||
onBindingsChanged: (bindings) =>
|
||||
overlayManager.broadcastToOverlayWindows(
|
||||
IPC_CHANNELS.event.sessionBindingsChanged,
|
||||
bindings,
|
||||
),
|
||||
onMpvInputBindingsChanged: () =>
|
||||
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.mpvInputBindingsChanged),
|
||||
onWarning: (warning) => {
|
||||
if (warning.kind !== 'conflict') return;
|
||||
deps.getOverlayNotifications().showOverlayNotification({
|
||||
id: `session-binding-conflict:${warning.path}`,
|
||||
title: 'Shortcut conflict',
|
||||
body: warning.message,
|
||||
variant: 'warning',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const ipc: IpcSlice<'getMpvInputBindings' | 'getSessionBindings' | 'getConfiguredShortcuts'> = {
|
||||
getMpvInputBindings: async () => {
|
||||
await refreshMpvSessionBindings();
|
||||
return readMpvInputBindings({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getConfiguredKeybindings: () => deps.getConfig().keybindings ?? [],
|
||||
platform:
|
||||
process.platform === 'darwin'
|
||||
? 'darwin'
|
||||
: process.platform === 'win32'
|
||||
? 'win32'
|
||||
: 'linux',
|
||||
});
|
||||
},
|
||||
getSessionBindings: async () => {
|
||||
await refreshMpvSessionBindings();
|
||||
return appState.sessionBindings;
|
||||
},
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
};
|
||||
|
||||
const cli: CliSlice<
|
||||
'startPendingMultiCopy' | 'startPendingMineSentenceMultiple' | 'getMultiCopyTimeoutMs'
|
||||
> = {
|
||||
startPendingMultiCopy: (timeoutMs) => startPendingMultiCopy(timeoutMs),
|
||||
startPendingMineSentenceMultiple: (timeoutMs) => startPendingMineSentenceMultiple(timeoutMs),
|
||||
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
};
|
||||
|
||||
return {
|
||||
ipc,
|
||||
cli,
|
||||
overlayShortcutsRuntime,
|
||||
getConfiguredShortcuts,
|
||||
registerGlobalShortcuts,
|
||||
refreshGlobalAndOverlayShortcuts,
|
||||
startPendingMultiCopy,
|
||||
startPendingMineSentenceMultiple,
|
||||
refreshOverlayShortcuts,
|
||||
persistSessionBindings,
|
||||
refreshCurrentSessionBindings,
|
||||
refreshMpvSessionBindings,
|
||||
/** Overlay shortcuts are released while a modal owns keyboard input. */
|
||||
syncOverlayShortcutsForModal: (isActive: boolean): void => {
|
||||
if (isActive) {
|
||||
overlayShortcutsRuntime.unregisterOverlayShortcuts();
|
||||
} else {
|
||||
overlayShortcutsRuntime.syncOverlayShortcuts();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { RegisterIpcRuntimeServicesParams } from '../ipc-runtime';
|
||||
import type { CliCommandContextMainDeps } from '../runtime/cli-command-context-factory';
|
||||
|
||||
/** Renderer IPC handlers, minus the two the IPC composer builds itself. */
|
||||
export type IpcHandlerDeps = Omit<
|
||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
||||
'handleMpvCommand' | 'runSubsyncManual'
|
||||
>;
|
||||
|
||||
/**
|
||||
* The IPC handlers one assembly provides. `Required` because most handlers are optional on
|
||||
* the registration params, and a slice must not drop one silently.
|
||||
*/
|
||||
export type IpcSlice<K extends keyof IpcHandlerDeps> = Required<Pick<IpcHandlerDeps, K>>;
|
||||
|
||||
/** The CLI command handlers one assembly provides. */
|
||||
export type CliSlice<K extends keyof CliCommandContextMainDeps> = Required<
|
||||
Pick<CliCommandContextMainDeps, K>
|
||||
>;
|
||||
@@ -0,0 +1,181 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { DEFAULT_CONFIG } from '../../config/definitions';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import { createAppState } from '../state';
|
||||
import { createStartupAssembly, type StartupAssemblyDeps } from './startup';
|
||||
|
||||
type MpvClient = NonNullable<ReturnType<typeof createAppState>['mpvClient']>;
|
||||
|
||||
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
|
||||
function lazy(name: string) {
|
||||
return (): never => {
|
||||
throw new Error(`${name} called unexpectedly`);
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds deps whose every function throws unless a test overrides it. */
|
||||
function makeDeps(overrides: Partial<StartupAssemblyDeps> = {}): StartupAssemblyDeps {
|
||||
const thrower = <T extends object>(name: string): T =>
|
||||
new Proxy({}, { get: (_target, key: string) => lazy(`${name}.${key}`) }) as T;
|
||||
return {
|
||||
appState: createAppState({ mpvSocketPath: '/tmp/subminer-startup.sock', texthookerPort: 0 }),
|
||||
configService: thrower('configService'),
|
||||
logger: thrower('logger'),
|
||||
logConfigWarning: lazy('logConfigWarning'),
|
||||
configDir: '/tmp/subminer-startup-assembly-test',
|
||||
userDataPath: '/tmp/subminer-startup-assembly-test',
|
||||
defaultTexthookerPort: 5174,
|
||||
showErrorBox: lazy('showErrorBox'),
|
||||
reportFatalError: lazy('reportFatalError'),
|
||||
requestAppQuit: lazy('requestAppQuit'),
|
||||
logging: {
|
||||
setLogLevel: lazy('setLogLevel'),
|
||||
setLogRotation: lazy('setLogRotation'),
|
||||
setLogFileToggles: lazy('setLogFileToggles'),
|
||||
},
|
||||
overlayManager: thrower('overlayManager'),
|
||||
overlayModalRuntime: thrower('overlayModalRuntime'),
|
||||
subtitleWsService: thrower('subtitleWsService'),
|
||||
annotationSubtitleWsService: thrower('annotationSubtitleWsService'),
|
||||
texthookerService: thrower('texthookerService'),
|
||||
configDerivedRuntime: thrower('configDerivedRuntime'),
|
||||
hasMpvWebsocketPlugin: lazy('hasMpvWebsocketPlugin'),
|
||||
loadSubtitlePosition: lazy('loadSubtitlePosition'),
|
||||
setSecondarySubMode: lazy('setSecondarySubMode'),
|
||||
syncOverlayMpvSubtitleSuppression: lazy('syncOverlayMpvSubtitleSuppression'),
|
||||
yomitan: thrower('yomitan'),
|
||||
stats: { ensureImmersionTrackerStarted: lazy('ensureImmersionTrackerStarted') } as never,
|
||||
firstRunSetup: thrower('firstRunSetup'),
|
||||
youtube: thrower('youtube'),
|
||||
overlay: thrower('overlay'),
|
||||
shortcuts: thrower('shortcuts'),
|
||||
settings: { hotReload: thrower('settings.hotReload') },
|
||||
mining: thrower('mining'),
|
||||
getAnilist: lazy('getAnilist'),
|
||||
getJellyfin: lazy('getJellyfin'),
|
||||
getPlayback: lazy('getPlayback'),
|
||||
getPluginHost: lazy('getPluginHost'),
|
||||
getOverlayNotifications: lazy('getOverlayNotifications'),
|
||||
getOverlayVisibilityComposer: lazy('getOverlayVisibilityComposer'),
|
||||
getVisibleOverlayInteraction: lazy('getVisibleOverlayInteraction'),
|
||||
getOverlayGeometry: lazy('getOverlayGeometry'),
|
||||
handleInitialArgs: lazy('handleInitialArgs'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Deps for the overlay prerequisites, recording what each step does. */
|
||||
function makePrereqDeps(calls: string[]): Partial<StartupAssemblyDeps> {
|
||||
const record = (name: string) => () => {
|
||||
calls.push(name);
|
||||
};
|
||||
return {
|
||||
configService: { getConfig: () => DEFAULT_CONFIG } as StartupAssemblyDeps['configService'],
|
||||
loadSubtitlePosition: record('loadSubtitlePosition'),
|
||||
shortcuts: {
|
||||
refreshCurrentSessionBindings: record('refreshCurrentSessionBindings'),
|
||||
refreshOverlayShortcuts: record('refreshOverlayShortcuts'),
|
||||
} as unknown as StartupAssemblyDeps['shortcuts'],
|
||||
getPlayback: () =>
|
||||
({
|
||||
createMpvClient: () => {
|
||||
calls.push('createMpvClient');
|
||||
return {} as MpvClient;
|
||||
},
|
||||
subtitleProcessingController: {
|
||||
invalidateTokenizationCache: record('invalidateTokenizationCache'),
|
||||
},
|
||||
getSubtitlePrefetchService: () => ({ onSeek: record('prefetch.onSeek') }),
|
||||
getLastObservedTimePos: () => 12,
|
||||
}) as unknown as ReturnType<StartupAssemblyDeps['getPlayback']>,
|
||||
getOverlayVisibilityComposer: () => ({
|
||||
broadcastRuntimeOptionsChanged: record('broadcastRuntimeOptionsChanged'),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
|
||||
test('createStartupAssembly does not call its deps while constructing', () => {
|
||||
const startup = createStartupAssembly(makeDeps());
|
||||
assert.equal(typeof startup.runAppReady, 'function');
|
||||
});
|
||||
|
||||
test('overlay startup prerequisites are created once', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = makeDeps(makePrereqDeps(calls));
|
||||
const startup = createStartupAssembly(deps);
|
||||
|
||||
startup.ensureOverlayStartupPrereqs();
|
||||
deps.appState.subtitlePosition = { yPercent: 10 };
|
||||
startup.ensureOverlayStartupPrereqs();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'loadSubtitlePosition',
|
||||
'refreshCurrentSessionBindings',
|
||||
'createMpvClient',
|
||||
'refreshCurrentSessionBindings',
|
||||
]);
|
||||
assert.ok(deps.appState.runtimeOptionsManager);
|
||||
assert.ok(deps.appState.subtitleTimingTracker);
|
||||
});
|
||||
|
||||
test('a runtime option change refreshes tokenization, prefetch, overlay state and shortcuts', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = makeDeps(makePrereqDeps(calls));
|
||||
createStartupAssembly(deps).ensureOverlayStartupPrereqs();
|
||||
calls.length = 0;
|
||||
|
||||
const manager = deps.appState.runtimeOptionsManager;
|
||||
assert.ok(manager);
|
||||
manager.setOptionValue('subtitle.annotation.jlpt', true);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'invalidateTokenizationCache',
|
||||
'prefetch.onSeek',
|
||||
'broadcastRuntimeOptionsChanged',
|
||||
'refreshOverlayShortcuts',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a headless launch without --refresh-known-words runs the initial CLI command', async () => {
|
||||
let handled = 0;
|
||||
const deps = makeDeps({
|
||||
handleInitialArgs: () => {
|
||||
handled += 1;
|
||||
},
|
||||
});
|
||||
|
||||
await createStartupAssembly(deps).runHeadlessInitialCommand();
|
||||
|
||||
assert.equal(handled, 1);
|
||||
});
|
||||
|
||||
test('a headless known-word refresh fails and quits when AnkiConnect is disabled', async (t) => {
|
||||
const previousExitCode = process.exitCode;
|
||||
t.after(() => {
|
||||
process.exitCode = previousExitCode ?? 0;
|
||||
});
|
||||
const config: ResolvedConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
ankiConnect: { ...DEFAULT_CONFIG.ankiConnect, enabled: false },
|
||||
};
|
||||
const errors: string[] = [];
|
||||
let quits = 0;
|
||||
const deps = makeDeps({
|
||||
configService: { getConfig: () => config } as StartupAssemblyDeps['configService'],
|
||||
logger: { error: (message: string) => errors.push(message) } as never,
|
||||
requestAppQuit: () => {
|
||||
quits += 1;
|
||||
},
|
||||
});
|
||||
deps.appState.initialArgs = { refreshKnownWords: true } as never;
|
||||
|
||||
await createStartupAssembly(deps).runHeadlessInitialCommand();
|
||||
|
||||
assert.deepEqual(errors, [
|
||||
'Headless known-word refresh failed: AnkiConnect integration not enabled',
|
||||
]);
|
||||
assert.equal(process.exitCode, 1);
|
||||
assert.equal(quits, 1);
|
||||
});
|
||||
@@ -0,0 +1,518 @@
|
||||
import path from 'node:path';
|
||||
import { AnkiIntegration } from '../../anki-integration';
|
||||
import { isHeadlessInitialCommand } from '../../cli/args';
|
||||
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS, generateConfigTemplate } from '../../config';
|
||||
import type { ConfigService } from '../../config/service';
|
||||
import {
|
||||
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
||||
type createOverlayManager,
|
||||
type SubtitleWebSocket,
|
||||
type Texthooker,
|
||||
} from '../../core/services';
|
||||
import { resolveKeybindings, showDesktopNotification } from '../../core/utils';
|
||||
import type { Logger } from '../../logger';
|
||||
import { RuntimeOptionsManager } from '../../runtime-options';
|
||||
import { ensureDefaultConfigBootstrap, getDefaultConfigFilePaths } from '../../shared/setup-state';
|
||||
import { SubtitleTimingTracker } from '../../subtitle-timing-tracker';
|
||||
import type { SecondarySubMode, WindowGeometry } from '../../types';
|
||||
import { mergeAiConfig } from '../../ai/config';
|
||||
import type { OverlayModalRuntime } from '../overlay-runtime';
|
||||
import {
|
||||
composeAppReadyRuntime,
|
||||
type AppReadyComposerOptions,
|
||||
type composeOverlayVisibilityRuntime,
|
||||
} from '../runtime/composers';
|
||||
import type { createConfigDerivedRuntime } from '../runtime/domains/startup';
|
||||
import { shouldAutoOpenFirstRunSetup } from '../runtime/first-run-setup-service';
|
||||
import type { createOverlayGeometryRuntime } from '../runtime/overlay-geometry-runtime';
|
||||
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
||||
import { createOverlayRuntimeBootstrapHandlers } from '../runtime/overlay-runtime-bootstrap-handlers';
|
||||
import {
|
||||
getStartupModeFlags,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
|
||||
shouldRefreshAnilistOnConfigReload,
|
||||
} from '../runtime/startup-mode-flags';
|
||||
import type { VisibleOverlayInteractionRuntime } from '../runtime/visible-overlay-interaction-runtime';
|
||||
import type { AppState } from '../state';
|
||||
import type { AnilistAssembly } from './anilist';
|
||||
import type { FirstRunSetupAssembly } from './first-run-setup';
|
||||
import type { JellyfinAssembly } from './jellyfin';
|
||||
import type { MiningAssembly } from './mining';
|
||||
import type { OverlayAssembly } from './overlay';
|
||||
import type { PlaybackAssembly } from './playback';
|
||||
import type { SettingsAssembly } from './settings';
|
||||
import type { ShortcutsAssembly } from './shortcuts';
|
||||
import type { StatsAssembly } from './stats';
|
||||
import type { YomitanAssembly } from './yomitan';
|
||||
import type { YoutubeAssembly } from './youtube';
|
||||
|
||||
type AppReadyMainDeps = AppReadyComposerOptions['appReadyRuntimeMainDeps'];
|
||||
type OverlayManager = ReturnType<typeof createOverlayManager>;
|
||||
type FatalErrorReporter = (error: unknown, options: { title: string; context: string }) => void;
|
||||
|
||||
/**
|
||||
* What startup needs from the rest of the app. It touches nearly every domain, so most deps are
|
||||
* sibling assemblies; those built after this one are reached through getters.
|
||||
*/
|
||||
export interface StartupAssemblyDeps {
|
||||
appState: AppState;
|
||||
configService: Pick<
|
||||
ConfigService,
|
||||
'getConfig' | 'getConfigPath' | 'getWarnings' | 'reloadConfigStrict'
|
||||
>;
|
||||
logger: Pick<Logger, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
logConfigWarning: AppReadyMainDeps['logConfigWarning'];
|
||||
configDir: string;
|
||||
userDataPath: string;
|
||||
defaultTexthookerPort: number;
|
||||
/** Electron's `dialog.showErrorBox`, injected so the assembly loads under `bun test`. */
|
||||
showErrorBox: (title: string, content: string) => void;
|
||||
reportFatalError: FatalErrorReporter;
|
||||
requestAppQuit: () => void;
|
||||
logging: Pick<AppReadyMainDeps, 'setLogLevel' | 'setLogRotation' | 'setLogFileToggles'>;
|
||||
overlayManager: Pick<OverlayManager, 'getVisibleOverlayVisible' | 'getOverlayWindows'>;
|
||||
overlayModalRuntime: Pick<OverlayModalRuntime, 'primeModalWindow'>;
|
||||
subtitleWsService: Pick<SubtitleWebSocket, 'start'>;
|
||||
annotationSubtitleWsService: Pick<SubtitleWebSocket, 'start'>;
|
||||
texthookerService: Pick<Texthooker, 'isRunning' | 'start'>;
|
||||
configDerivedRuntime: Pick<
|
||||
ReturnType<typeof createConfigDerivedRuntime>,
|
||||
'shouldAutoInitializeOverlayRuntimeFromConfig'
|
||||
>;
|
||||
hasMpvWebsocketPlugin: () => boolean;
|
||||
loadSubtitlePosition: () => void;
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
yomitan: Pick<YomitanAssembly, 'loadExtension' | 'ensureExtensionLoaded'>;
|
||||
stats: Pick<StatsAssembly, 'ensureImmersionTrackerStarted' | 'immersionTrackerStartupDeps'>;
|
||||
firstRunSetup: Pick<FirstRunSetupAssembly, 'service' | 'openWindow'>;
|
||||
youtube: Pick<
|
||||
YoutubeAssembly,
|
||||
| 'getCachedMediaPathForCurrentPlayback'
|
||||
| 'shouldRequireMediaCacheForCurrentPlayback'
|
||||
| 'getCurrentMediaCacheSourceUrl'
|
||||
>;
|
||||
overlay: Pick<
|
||||
OverlayAssembly,
|
||||
| 'overlayVisibilityRuntime'
|
||||
| 'createMainWindow'
|
||||
| 'setVisibleOverlayVisible'
|
||||
| 'ensureOverlayWindowsReadyForVisibilityActions'
|
||||
>;
|
||||
shortcuts: Pick<
|
||||
ShortcutsAssembly,
|
||||
| 'overlayShortcutsRuntime'
|
||||
| 'refreshCurrentSessionBindings'
|
||||
| 'registerGlobalShortcuts'
|
||||
| 'refreshOverlayShortcuts'
|
||||
>;
|
||||
settings: { hotReload: Pick<SettingsAssembly['hotReload'], 'start'> };
|
||||
mining: Pick<MiningAssembly, 'attachToAnkiIntegration' | 'createFieldGroupingCallback'>;
|
||||
getAnilist: () => Pick<AnilistAssembly, 'refreshClientSecretStateIfEnabled'>;
|
||||
getJellyfin: () => Pick<JellyfinAssembly, 'startJellyfinRemoteSession'>;
|
||||
getPlayback: () => Pick<
|
||||
PlaybackAssembly,
|
||||
| 'createMpvClient'
|
||||
| 'createMecabTokenizerAndCheck'
|
||||
| 'prewarmSubtitleDictionaries'
|
||||
| 'startBackgroundWarmups'
|
||||
| 'subtitleProcessingController'
|
||||
| 'getSubtitlePrefetchService'
|
||||
| 'getLastObservedTimePos'
|
||||
>;
|
||||
getPluginHost: () => { loadAll: () => Promise<unknown> };
|
||||
getOverlayNotifications: () => Pick<
|
||||
ReturnType<typeof createOverlayNotificationsRuntime>,
|
||||
'showOverlayNotification' | 'dismissOverlayNotification'
|
||||
>;
|
||||
getOverlayVisibilityComposer: () => Pick<
|
||||
ReturnType<typeof composeOverlayVisibilityRuntime>,
|
||||
'broadcastRuntimeOptionsChanged'
|
||||
>;
|
||||
/** Pointer interaction and geometry stay in `main.ts`: both import Electron at module load. */
|
||||
getVisibleOverlayInteraction: () => Pick<
|
||||
VisibleOverlayInteractionRuntime,
|
||||
'createOverlayWindowTracker' | 'bindVisibleOverlayOwner' | 'releaseVisibleOverlayOwner'
|
||||
>;
|
||||
getOverlayGeometry: () => Pick<
|
||||
ReturnType<typeof createOverlayGeometryRuntime>,
|
||||
'updateVisibleOverlayBounds'
|
||||
>;
|
||||
/** Runs the CLI command the app was launched with. */
|
||||
handleInitialArgs: () => void;
|
||||
}
|
||||
|
||||
export type StartupAssembly = ReturnType<typeof createStartupAssembly>;
|
||||
|
||||
/**
|
||||
* Owns how the app starts: the app-ready sequence, the headless known-word refresh, and bringing
|
||||
* up the overlay runtime and its prerequisites on demand (CLI commands, YouTube playback, the
|
||||
* tray). `main.ts` hands `runAppReady` to the app lifecycle.
|
||||
*/
|
||||
export function createStartupAssembly(deps: StartupAssemblyDeps) {
|
||||
const { appState, configService, logger } = deps;
|
||||
const isHeadlessLaunch = (): boolean =>
|
||||
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs));
|
||||
const knownWordCacheStatePath = path.join(deps.userDataPath, 'known-words-cache.json');
|
||||
|
||||
function createRuntimeOptionsManager(): RuntimeOptionsManager {
|
||||
return new RuntimeOptionsManager(() => configService.getConfig().ankiConnect, {
|
||||
applyAnkiPatch: (patch) => {
|
||||
if (appState.ankiIntegration) {
|
||||
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
|
||||
}
|
||||
},
|
||||
getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle,
|
||||
onOptionsChanged: () => {
|
||||
const playback = deps.getPlayback();
|
||||
playback.subtitleProcessingController.invalidateTokenizationCache();
|
||||
playback.getSubtitlePrefetchService()?.onSeek(playback.getLastObservedTimePos());
|
||||
deps.getOverlayVisibilityComposer().broadcastRuntimeOptionsChanged();
|
||||
deps.shortcuts.refreshOverlayShortcuts();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSessionKeybindings(): void {
|
||||
appState.keybindings = resolveKeybindings(configService.getConfig(), DEFAULT_KEYBINDINGS);
|
||||
deps.shortcuts.refreshCurrentSessionBindings();
|
||||
}
|
||||
|
||||
/** What the subtitle websockets send a client that connects mid-line. */
|
||||
function getCurrentSubtitleForWebsocket() {
|
||||
return (
|
||||
appState.currentSubtitleData ??
|
||||
(appState.currentSubText
|
||||
? {
|
||||
text: appState.currentSubText,
|
||||
tokens: null,
|
||||
startTime: appState.mpvClient?.currentSubStart ?? null,
|
||||
endTime: appState.mpvClient?.currentSubEnd ?? null,
|
||||
}
|
||||
: null)
|
||||
);
|
||||
}
|
||||
function getFrequencyOptionsForWebsocket() {
|
||||
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
|
||||
return {
|
||||
enabled: frequencyDictionary.enabled,
|
||||
topX: frequencyDictionary.topX,
|
||||
mode: frequencyDictionary.mode,
|
||||
};
|
||||
}
|
||||
|
||||
const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
createOverlayRuntimeBootstrapHandlers({
|
||||
initializeOverlayRuntimeMainDeps: {
|
||||
appState,
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () =>
|
||||
deps.overlay.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
},
|
||||
refreshCurrentSubtitle: () => {
|
||||
deps
|
||||
.getPlayback()
|
||||
.subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||
},
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => deps.shortcuts.overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||
},
|
||||
createMainWindow: () => {
|
||||
if (isHeadlessLaunch()) {
|
||||
return;
|
||||
}
|
||||
deps.overlay.createMainWindow();
|
||||
},
|
||||
registerGlobalShortcuts: () => {
|
||||
if (isHeadlessLaunch()) {
|
||||
return;
|
||||
}
|
||||
deps.shortcuts.registerGlobalShortcuts();
|
||||
},
|
||||
createWindowTracker: (override, targetMpvSocketPath) =>
|
||||
deps
|
||||
.getVisibleOverlayInteraction()
|
||||
.createOverlayWindowTracker(override, targetMpvSocketPath),
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
deps.getOverlayGeometry().updateVisibleOverlayBounds(geometry),
|
||||
bindOverlayOwner: () => deps.getVisibleOverlayInteraction().bindVisibleOverlayOwner(),
|
||||
releaseOverlayOwner: () => deps.getVisibleOverlayInteraction().releaseVisibleOverlayOwner(),
|
||||
getOverlayWindows: () => deps.overlayManager.getOverlayWindows(),
|
||||
getResolvedConfig: () => configService.getConfig(),
|
||||
showDesktopNotification,
|
||||
showOverlayNotification: (payload) =>
|
||||
deps.getOverlayNotifications().showOverlayNotification(payload),
|
||||
dismissOverlayNotification: (id) =>
|
||||
deps.getOverlayNotifications().dismissOverlayNotification(id),
|
||||
createFieldGroupingCallback: () => deps.mining.createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () => knownWordCacheStatePath,
|
||||
getCachedMediaPath: (currentVideoPath, kind) =>
|
||||
deps.youtube.getCachedMediaPathForCurrentPlayback(currentVideoPath, kind),
|
||||
shouldRequireRemoteMediaCache: () =>
|
||||
deps.youtube.shouldRequireMediaCacheForCurrentPlayback(),
|
||||
getYoutubeMediaSourceUrl: () => deps.youtube.getCurrentMediaCacheSourceUrl(),
|
||||
shouldStartAnkiIntegration: () => !isHeadlessLaunch(),
|
||||
},
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
initializeOverlayRuntimeCore,
|
||||
setOverlayRuntimeInitialized: (initialized) => {
|
||||
appState.overlayRuntimeInitialized = initialized;
|
||||
},
|
||||
startBackgroundWarmups: () => {
|
||||
if (isHeadlessLaunch()) {
|
||||
return;
|
||||
}
|
||||
deps.getPlayback().startBackgroundWarmups();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Creates the overlay windows and runtime once, then wires the Anki integration it built. */
|
||||
function initializeOverlayRuntime(): void {
|
||||
initializeOverlayRuntimeHandler();
|
||||
if (!isHeadlessLaunch()) {
|
||||
deps.overlayModalRuntime.primeModalWindow();
|
||||
}
|
||||
if (appState.ankiIntegration) {
|
||||
deps.mining.attachToAnkiIntegration(appState.ankiIntegration);
|
||||
}
|
||||
deps.syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
/** Creates whatever overlay commands need that app-ready skipped on a deferred start. */
|
||||
function ensureOverlayStartupPrereqs(): void {
|
||||
if (appState.subtitlePosition === null) {
|
||||
deps.loadSubtitlePosition();
|
||||
}
|
||||
if (appState.keybindings.length === 0) {
|
||||
resolveSessionKeybindings();
|
||||
} else if (!appState.sessionBindingsInitialized) {
|
||||
deps.shortcuts.refreshCurrentSessionBindings();
|
||||
}
|
||||
if (!appState.mpvClient) {
|
||||
appState.mpvClient = deps.getPlayback().createMpvClient();
|
||||
}
|
||||
if (!appState.runtimeOptionsManager) {
|
||||
appState.runtimeOptionsManager = createRuntimeOptionsManager();
|
||||
}
|
||||
if (!appState.subtitleTimingTracker) {
|
||||
appState.subtitleTimingTracker = new SubtitleTimingTracker();
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureYoutubePlaybackRuntimeReady(): Promise<void> {
|
||||
ensureOverlayStartupPrereqs();
|
||||
await deps.yomitan.ensureExtensionLoaded();
|
||||
if (!appState.overlayRuntimeInitialized) {
|
||||
initializeOverlayRuntime();
|
||||
return;
|
||||
}
|
||||
deps.overlay.ensureOverlayWindowsReadyForVisibilityActions();
|
||||
}
|
||||
|
||||
/** `--refresh-known-words` without the overlay: refresh the cache, then quit. */
|
||||
async function runHeadlessInitialCommand(): Promise<void> {
|
||||
if (!appState.initialArgs?.refreshKnownWords) {
|
||||
deps.handleInitialArgs();
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedConfig = configService.getConfig();
|
||||
if (resolvedConfig.ankiConnect.enabled !== true) {
|
||||
logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled');
|
||||
process.exitCode = 1;
|
||||
deps.requestAppQuit();
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveAnkiConfig =
|
||||
appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ??
|
||||
resolvedConfig.ankiConnect;
|
||||
const integration = new AnkiIntegration(
|
||||
effectiveAnkiConfig,
|
||||
new SubtitleTimingTracker(),
|
||||
{ send: () => undefined } as never,
|
||||
undefined,
|
||||
undefined,
|
||||
async () => ({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: false,
|
||||
cancelled: true,
|
||||
}),
|
||||
knownWordCacheStatePath,
|
||||
mergeAiConfig(resolvedConfig.ai, resolvedConfig.ankiConnect?.ai),
|
||||
);
|
||||
|
||||
try {
|
||||
await integration.refreshKnownWordCache();
|
||||
} catch (error) {
|
||||
logger.error('Headless known-word refresh failed:', error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
integration.stop();
|
||||
deps.requestAppQuit();
|
||||
}
|
||||
}
|
||||
|
||||
const failHandlers = {
|
||||
logError: (details: string) => logger.error(details),
|
||||
showErrorBox: (title: string, details: string) => deps.showErrorBox(title, details),
|
||||
quit: () => deps.requestAppQuit(),
|
||||
};
|
||||
|
||||
const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
reloadConfigMainDeps: {
|
||||
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
||||
logInfo: (message) => logger.info(message),
|
||||
logDebug: (message) => logger.debug(message),
|
||||
logWarning: (message) => logger.warn(message),
|
||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||
startConfigHotReload: () => deps.settings.hotReload.start(),
|
||||
shouldRefreshAnilistClientSecretState: () =>
|
||||
shouldRefreshAnilistOnConfigReload(appState.initialArgs),
|
||||
refreshAnilistClientSecretState: (options) =>
|
||||
deps.getAnilist().refreshClientSecretStateIfEnabled(options),
|
||||
failHandlers,
|
||||
},
|
||||
criticalConfigErrorMainDeps: {
|
||||
getConfigPath: () => configService.getConfigPath(),
|
||||
failHandlers,
|
||||
},
|
||||
appReadyRuntimeMainDeps: {
|
||||
ensureDefaultConfigBootstrap: () => {
|
||||
ensureDefaultConfigBootstrap({
|
||||
configDir: deps.configDir,
|
||||
configFilePaths: getDefaultConfigFilePaths(deps.configDir),
|
||||
generateTemplate: () => generateConfigTemplate(DEFAULT_CONFIG),
|
||||
});
|
||||
},
|
||||
loadSubtitlePosition: () => deps.loadSubtitlePosition(),
|
||||
resolveKeybindings: () => resolveSessionKeybindings(),
|
||||
createMpvClient: () => {
|
||||
appState.mpvClient = deps.getPlayback().createMpvClient();
|
||||
},
|
||||
startPlugins: () => {
|
||||
void deps.getPluginHost().loadAll();
|
||||
},
|
||||
getResolvedConfig: () => configService.getConfig(),
|
||||
getConfigWarnings: () => configService.getWarnings(),
|
||||
logConfigWarning: (warning) => deps.logConfigWarning(warning),
|
||||
...deps.logging,
|
||||
initRuntimeOptionsManager: () => {
|
||||
appState.runtimeOptionsManager = createRuntimeOptionsManager();
|
||||
},
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => {
|
||||
deps.setSecondarySubMode(mode);
|
||||
},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
|
||||
defaultAnnotationWebsocketPort: DEFAULT_CONFIG.annotationWebsocket.port,
|
||||
defaultTexthookerPort: deps.defaultTexthookerPort,
|
||||
hasMpvWebsocketPlugin: () => deps.hasMpvWebsocketPlugin(),
|
||||
startSubtitleWebsocket: (port: number) => {
|
||||
deps.subtitleWsService.start(
|
||||
port,
|
||||
getCurrentSubtitleForWebsocket,
|
||||
getFrequencyOptionsForWebsocket,
|
||||
);
|
||||
},
|
||||
startAnnotationWebsocket: (port: number) => {
|
||||
deps.annotationSubtitleWsService.start(
|
||||
port,
|
||||
getCurrentSubtitleForWebsocket,
|
||||
getFrequencyOptionsForWebsocket,
|
||||
);
|
||||
},
|
||||
startTexthooker: (port: number, websocketUrl?: string) => {
|
||||
if (!deps.texthookerService.isRunning()) {
|
||||
deps.texthookerService.start(port, websocketUrl);
|
||||
}
|
||||
},
|
||||
log: (message) => logger.info(message),
|
||||
createMecabTokenizerAndCheck: async () => {
|
||||
await deps.getPlayback().createMecabTokenizerAndCheck();
|
||||
},
|
||||
createSubtitleTimingTracker: () => {
|
||||
appState.subtitleTimingTracker = new SubtitleTimingTracker();
|
||||
},
|
||||
loadYomitanExtension: async () => {
|
||||
await deps.yomitan.loadExtension();
|
||||
},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
await deps.yomitan.ensureExtensionLoaded();
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
const snapshot = await deps.firstRunSetup.service.ensureSetupStateInitialized();
|
||||
appState.firstRunSetupCompleted = snapshot.state.status === 'completed';
|
||||
const args = appState.initialArgs;
|
||||
if (args && shouldAutoOpenFirstRunSetup(args)) {
|
||||
const force = Boolean(args.setup);
|
||||
if (force || snapshot.state.status !== 'completed') {
|
||||
deps.firstRunSetup.openWindow(force);
|
||||
}
|
||||
}
|
||||
},
|
||||
startJellyfinRemoteSession: async () => {
|
||||
await deps.getJellyfin().startJellyfinRemoteSession();
|
||||
},
|
||||
prewarmSubtitleDictionaries: async () => {
|
||||
await deps.getPlayback().prewarmSubtitleDictionaries();
|
||||
},
|
||||
startBackgroundWarmups: () => {
|
||||
deps.getPlayback().startBackgroundWarmups();
|
||||
},
|
||||
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
||||
appState.backgroundMode
|
||||
? false
|
||||
: deps.configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
|
||||
setVisibleOverlayVisible: (visible: boolean) =>
|
||||
deps.overlay.setVisibleOverlayVisible(visible),
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
runHeadlessInitialCommand: () => runHeadlessInitialCommand(),
|
||||
handleInitialArgs: () => deps.handleInitialArgs(),
|
||||
shouldRunHeadlessInitialCommand: () => isHeadlessLaunch(),
|
||||
shouldUseMinimalStartup: () =>
|
||||
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: () =>
|
||||
getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () =>
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup(appState.initialArgs),
|
||||
createImmersionTracker: () => {
|
||||
deps.stats.ensureImmersionTrackerStarted();
|
||||
},
|
||||
logDebug: (message: string) => {
|
||||
logger.debug(message);
|
||||
},
|
||||
now: () => Date.now(),
|
||||
},
|
||||
immersionTrackerStartupMainDeps: deps.stats.immersionTrackerStartupDeps,
|
||||
});
|
||||
|
||||
/** The app-ready sequence; a failure is reported as fatal and quits the app. */
|
||||
async function runAppReady(): Promise<void> {
|
||||
try {
|
||||
await appReadyRuntimeRunner();
|
||||
} catch (error) {
|
||||
deps.reportFatalError(error, {
|
||||
title: 'SubMiner startup failed',
|
||||
context: 'SubMiner failed during app-ready startup.',
|
||||
});
|
||||
process.exitCode = 1;
|
||||
deps.requestAppQuit();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
runAppReady,
|
||||
runHeadlessInitialCommand,
|
||||
initializeOverlayRuntime,
|
||||
ensureOverlayStartupPrereqs,
|
||||
ensureYoutubePlaybackRuntimeReady,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { DEFAULT_CONFIG } from '../../config/definitions';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import type { AppState } from '../state';
|
||||
import { createStatsAssembly, type StatsAssemblyDeps } from './stats';
|
||||
|
||||
const trackingDisabledConfig: ResolvedConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
immersionTracking: { ...DEFAULT_CONFIG.immersionTracking, enabled: false },
|
||||
};
|
||||
|
||||
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
|
||||
function makeDeps(overrides: Partial<StatsAssemblyDeps> = {}): StatsAssemblyDeps {
|
||||
const lazy = (name: string) => (): never => {
|
||||
throw new Error(`${name} called unexpectedly`);
|
||||
};
|
||||
return {
|
||||
appState: {
|
||||
immersionTracker: null,
|
||||
statsServer: null,
|
||||
statsStartupInProgress: false,
|
||||
mpvClient: null,
|
||||
mpvSocketPath: '/tmp/subminer-stats-test.sock',
|
||||
mecabTokenizer: null,
|
||||
ankiIntegration: null,
|
||||
currentMediaPath: null,
|
||||
currentMediaTitle: null,
|
||||
},
|
||||
getConfig: lazy('getConfig'),
|
||||
logger: { info: lazy('info'), warn: lazy('warn'), error: lazy('error'), debug: lazy('debug') },
|
||||
userDataPath: '/tmp/subminer-stats-assembly-test',
|
||||
mainDirname: '/tmp/subminer-stats-assembly-test/dist',
|
||||
defaultImmersionDbPath: '/tmp/subminer-stats-assembly-test/immersion.sqlite',
|
||||
anilistRateLimiter: { acquire: lazy('acquire'), recordResponse: lazy('recordResponse') },
|
||||
statsWindow: { registerToggle: lazy('registerToggle'), toggle: lazy('toggle') },
|
||||
yomitanParserRuntimeDeps: {
|
||||
getYomitanExt: lazy('getYomitanExt'),
|
||||
getYomitanSession: lazy('getYomitanSession'),
|
||||
getYomitanParserWindow: lazy('getYomitanParserWindow'),
|
||||
setYomitanParserWindow: lazy('setYomitanParserWindow'),
|
||||
getYomitanParserReadyPromise: lazy('getYomitanParserReadyPromise'),
|
||||
setYomitanParserReadyPromise: lazy('setYomitanParserReadyPromise'),
|
||||
getYomitanParserInitPromise: lazy('getYomitanParserInitPromise'),
|
||||
setYomitanParserInitPromise: lazy('setYomitanParserInitPromise'),
|
||||
},
|
||||
getYomitanAnkiDeckName: lazy('getYomitanAnkiDeckName'),
|
||||
getTokenizeSubtitle: lazy('getTokenizeSubtitle'),
|
||||
createMecabTokenizerAndCheck: lazy('createMecabTokenizerAndCheck'),
|
||||
getOverlayNotifications: lazy('getOverlayNotifications'),
|
||||
getOverlayBounds: lazy('getOverlayBounds'),
|
||||
onStatsOverlayVisibilityChanged: lazy('onStatsOverlayVisibilityChanged'),
|
||||
runAnilistPostWatchUpdate: lazy('runAnilistPostWatchUpdate'),
|
||||
openExternal: lazy('openExternal'),
|
||||
requestAppQuit: lazy('requestAppQuit'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function withTracker(
|
||||
deps: StatsAssemblyDeps,
|
||||
markActiveVideoWatched: () => Promise<boolean>,
|
||||
): StatsAssemblyDeps {
|
||||
const tracker = { markActiveVideoWatched } as unknown as NonNullable<
|
||||
AppState['immersionTracker']
|
||||
>;
|
||||
return { ...deps, appState: { ...deps.appState, immersionTracker: tracker } };
|
||||
}
|
||||
|
||||
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
|
||||
test('createStatsAssembly does not call its deps while constructing', () => {
|
||||
const stats = createStatsAssembly(makeDeps());
|
||||
assert.equal(typeof stats.ensureImmersionTrackerStarted, 'function');
|
||||
assert.equal(typeof stats.toggleStatsOverlay, 'function');
|
||||
});
|
||||
|
||||
test('a manual watched mark tries to start the immersion tracker first', async () => {
|
||||
const infoLogs: string[] = [];
|
||||
const stats = createStatsAssembly(
|
||||
makeDeps({
|
||||
getConfig: () => trackingDisabledConfig,
|
||||
logger: {
|
||||
info: (message: string) => {
|
||||
infoLogs.push(message);
|
||||
},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// With tracking disabled, the startup attempt logs and leaves no tracker to mark.
|
||||
assert.equal(await stats.markActiveVideoWatched(), false);
|
||||
assert.deepEqual(infoLogs, ['Immersion tracking disabled in config']);
|
||||
});
|
||||
|
||||
for (const c of [
|
||||
{ name: 'runs the AniList update after a successful mark', marked: true, anilistRuns: 1 },
|
||||
{ name: 'skips the AniList update when nothing was marked', marked: false, anilistRuns: 0 },
|
||||
]) {
|
||||
test(`a manual watched mark ${c.name}`, async () => {
|
||||
let anilistRuns = 0;
|
||||
const stats = createStatsAssembly(
|
||||
withTracker(
|
||||
makeDeps({
|
||||
runAnilistPostWatchUpdate: async () => {
|
||||
anilistRuns += 1;
|
||||
},
|
||||
}),
|
||||
async () => c.marked,
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(await stats.markActiveVideoWatched(), c.marked);
|
||||
assert.equal(anilistRuns, c.anilistRuns);
|
||||
});
|
||||
}
|
||||
|
||||
test('a failed AniList update does not undo a manual watched mark', async () => {
|
||||
const warnings: string[] = [];
|
||||
const stats = createStatsAssembly(
|
||||
withTracker(
|
||||
makeDeps({
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: (message: string) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
},
|
||||
runAnilistPostWatchUpdate: async () => {
|
||||
throw new Error('AniList unavailable');
|
||||
},
|
||||
}),
|
||||
async () => true,
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(await stats.markActiveVideoWatched(), true);
|
||||
assert.deepEqual(warnings, [
|
||||
'Failed to run AniList post-watch update after manual watched mark:',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
import path from 'node:path';
|
||||
import { ImmersionTrackerService } from '../../core/services';
|
||||
import { createCoverArtFetcher } from '../../core/services/anilist/cover-art-fetcher';
|
||||
import type { createAnilistRateLimiter } from '../../core/services/anilist/rate-limiter';
|
||||
import { resolveLegacyVocabularyPosFromTokens } from '../../core/services/immersion-tracker/legacy-vocabulary-pos';
|
||||
import type { StatsWindowOptions } from '../../core/services/stats-window';
|
||||
import { readBundledTmdbApiKey } from '../../core/services/tmdb/bundled-api-key';
|
||||
import { createLiveActionMetadataResolver } from '../../core/services/tmdb/live-action-resolver';
|
||||
import { createTmdbApiKeyResolver, createTmdbClient } from '../../core/services/tmdb/tmdb-client';
|
||||
import { createLogger, type Logger } from '../../logger';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import { createEnsureBackgroundStatsServerHandler } from '../runtime/background-stats-startup';
|
||||
import { createImmersionMediaRuntime } from '../runtime/domains/startup';
|
||||
import {
|
||||
createImmersionTrackerStartupHandler,
|
||||
type ImmersionTrackerStartupDeps,
|
||||
} from '../runtime/immersion-startup';
|
||||
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
||||
import {
|
||||
createRunStatsCliCommandHandler,
|
||||
writeStatsCliCommandResponse,
|
||||
} from '../runtime/stats-cli-command';
|
||||
import {
|
||||
createStatsServerRuntime,
|
||||
type StatsServerRuntimeDeps,
|
||||
} from '../runtime/stats-server-runtime';
|
||||
import type { AppState } from '../state';
|
||||
import type { PlaybackAssembly } from './playback';
|
||||
import type { CliSlice, IpcSlice } from './slices';
|
||||
import type { YomitanAssembly } from './yomitan';
|
||||
|
||||
/**
|
||||
* What the stats domain needs from the rest of the app. Siblings built after this assembly in
|
||||
* `main.ts` are reached through callbacks.
|
||||
*/
|
||||
export interface StatsAssemblyDeps {
|
||||
appState: Pick<
|
||||
AppState,
|
||||
| 'immersionTracker'
|
||||
| 'statsServer'
|
||||
| 'statsStartupInProgress'
|
||||
| 'mpvClient'
|
||||
| 'mpvSocketPath'
|
||||
| 'mecabTokenizer'
|
||||
| 'ankiIntegration'
|
||||
| 'currentMediaPath'
|
||||
| 'currentMediaTitle'
|
||||
>;
|
||||
getConfig: () => ResolvedConfig;
|
||||
logger: Pick<Logger, 'info' | 'warn' | 'error' | 'debug'>;
|
||||
userDataPath: string;
|
||||
/** Compiled main-process directory; the stats bundle, preload and TMDB key sit beside it. */
|
||||
mainDirname: string;
|
||||
defaultImmersionDbPath: string;
|
||||
anilistRateLimiter: ReturnType<typeof createAnilistRateLimiter>;
|
||||
/** The stats window module reaches Electron's `dialog`, so it is injected. */
|
||||
statsWindow: {
|
||||
registerToggle: (options: StatsWindowOptions) => void;
|
||||
toggle: (options: StatsWindowOptions) => Promise<void>;
|
||||
};
|
||||
yomitanParserRuntimeDeps: ReturnType<YomitanAssembly['getParserRuntimeDeps']>;
|
||||
getYomitanAnkiDeckName: () => Promise<string>;
|
||||
getTokenizeSubtitle: PlaybackAssembly['getTokenizeSubtitleDeferred'];
|
||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
||||
getOverlayNotifications: () => Pick<
|
||||
ReturnType<typeof createOverlayNotificationsRuntime>,
|
||||
'showConfiguredStatusNotification'
|
||||
>;
|
||||
getOverlayBounds: () => ReturnType<StatsWindowOptions['resolveBounds']>;
|
||||
onStatsOverlayVisibilityChanged: (visible: boolean) => void;
|
||||
/** Forced AniList post-watch update after a manual watched mark. */
|
||||
runAnilistPostWatchUpdate: () => Promise<void>;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
requestAppQuit: () => void;
|
||||
}
|
||||
|
||||
export type StatsAssembly = ReturnType<typeof createStatsAssembly>;
|
||||
|
||||
/**
|
||||
* Owns the immersion tracker's startup, the stats HTTP server (in-process or background), the
|
||||
* stats overlay window options, and the `--stats` CLI command.
|
||||
*/
|
||||
export function createStatsAssembly(deps: StatsAssemblyDeps) {
|
||||
const { appState, logger } = deps;
|
||||
// Release builds stage a project TMDB key next to the compiled main process.
|
||||
const bundledTmdbApiKey = readBundledTmdbApiKey(deps.mainDirname);
|
||||
let hasAttemptedImmersionTrackerStartup = false;
|
||||
|
||||
async function resolveSentenceSearchHeadwords(term: string): Promise<string[]> {
|
||||
const fallback = term.trim() ? [term.trim()] : [];
|
||||
try {
|
||||
const tokenizeSubtitleDeferred = deps.getTokenizeSubtitle();
|
||||
const tokenized = tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(term) : null;
|
||||
const tokens = tokenized?.tokens ?? [];
|
||||
if (tokens.length === 0) return fallback;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const headwords: string[] = [];
|
||||
for (const token of tokens) {
|
||||
const headword = (token.headword || token.surface).trim();
|
||||
if (!headword || seen.has(headword)) continue;
|
||||
seen.add(headword);
|
||||
headwords.push(headword);
|
||||
}
|
||||
return headwords.length > 0 ? headwords : fallback;
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
'Failed to resolve sentence-search headwords:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const statsServerRuntime = createStatsServerRuntime({
|
||||
userDataPath: deps.userDataPath,
|
||||
statsDistPath: path.join(deps.mainDirname, '..', 'stats', 'dist'),
|
||||
getResolvedConfig: () => deps.getConfig(),
|
||||
getImmersionTracker: () => appState.immersionTracker,
|
||||
setAppStateStatsServer: (server) => {
|
||||
appState.statsServer = server;
|
||||
},
|
||||
getMpvSocketPath: () => appState.mpvSocketPath,
|
||||
...(deps.yomitanParserRuntimeDeps satisfies Pick<
|
||||
StatsServerRuntimeDeps,
|
||||
keyof ReturnType<YomitanAssembly['getParserRuntimeDeps']>
|
||||
>),
|
||||
getYomitanAnkiDeckName: () => deps.getYomitanAnkiDeckName(),
|
||||
getAnilistRateLimiter: () => deps.anilistRateLimiter,
|
||||
getBundledTmdbApiKey: () => bundledTmdbApiKey,
|
||||
resolveAnkiNoteId: (noteId) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
|
||||
trackDuplicateNoteIdsForNote: (noteId, duplicateNoteIds) => {
|
||||
appState.ankiIntegration?.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds);
|
||||
},
|
||||
resolveSentenceSearchHeadwords: (term) => resolveSentenceSearchHeadwords(term),
|
||||
ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(),
|
||||
setStatsStartupInProgress: (inProgress) => {
|
||||
appState.statsStartupInProgress = inProgress;
|
||||
},
|
||||
});
|
||||
const {
|
||||
stopStatsServer,
|
||||
ensureStatsServerStarted,
|
||||
ensureBackgroundStatsServerStarted,
|
||||
stopBackgroundStatsServer,
|
||||
} = statsServerRuntime;
|
||||
|
||||
const immersionMediaRuntime = createImmersionMediaRuntime({
|
||||
getResolvedConfig: () => deps.getConfig(),
|
||||
defaultImmersionDbPath: deps.defaultImmersionDbPath,
|
||||
getTracker: () => appState.immersionTracker,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||
getCurrentMediaTitle: () => appState.currentMediaTitle,
|
||||
logDebug: (message) => logger.debug(message),
|
||||
logInfo: (message) => logger.info(message),
|
||||
});
|
||||
|
||||
const coverArtFetcher = createCoverArtFetcher(
|
||||
deps.anilistRateLimiter,
|
||||
createLogger('main:stats-cover-art'),
|
||||
{
|
||||
liveAction: createLiveActionMetadataResolver(
|
||||
createTmdbClient({
|
||||
resolveApiKey: createTmdbApiKeyResolver(
|
||||
() => deps.getConfig().tmdb,
|
||||
() => bundledTmdbApiKey,
|
||||
),
|
||||
}),
|
||||
createLogger('main:tmdb'),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
// One options object serves the overlay's IPC toggle and the session-action toggle.
|
||||
const statsOverlayOptions: StatsWindowOptions = {
|
||||
preloadPath: path.join(deps.mainDirname, 'preload-stats.js'),
|
||||
getApiBaseUrl: async () => (await ensureStatsServerStarted()).url,
|
||||
onStartupError: (error) =>
|
||||
deps
|
||||
.getOverlayNotifications()
|
||||
.showConfiguredStatusNotification(
|
||||
`Stats server startup failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ title: 'Stats' },
|
||||
),
|
||||
getToggleKey: () => deps.getConfig().stats.toggleKey,
|
||||
resolveBounds: () => deps.getOverlayBounds(),
|
||||
onVisibilityChanged: (visible) => {
|
||||
deps.onStatsOverlayVisibilityChanged(visible);
|
||||
},
|
||||
};
|
||||
|
||||
const resolveLegacyVocabularyPos = async (row: {
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string | null;
|
||||
}) => {
|
||||
const tokenizer = 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);
|
||||
const resolved = resolveLegacyVocabularyPosFromTokens(lookupText, tokens);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const immersionTrackerStartupDeps: ImmersionTrackerStartupDeps = {
|
||||
getResolvedConfig: () => deps.getConfig(),
|
||||
getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(),
|
||||
createTrackerService: (params) =>
|
||||
new ImmersionTrackerService({
|
||||
...params,
|
||||
resolveLegacyVocabularyPos,
|
||||
}),
|
||||
setTracker: (tracker) => {
|
||||
const trackerHasChanged =
|
||||
appState.immersionTracker !== null && appState.immersionTracker !== tracker;
|
||||
if (trackerHasChanged && appState.statsServer) {
|
||||
void stopStatsServer().catch((error: unknown) => {
|
||||
logger.warn('Failed to stop stats server while replacing immersion tracker.', error);
|
||||
});
|
||||
appState.statsServer = null;
|
||||
}
|
||||
|
||||
appState.immersionTracker = tracker as ImmersionTrackerService | null;
|
||||
appState.immersionTracker?.setCoverArtFetcher(coverArtFetcher);
|
||||
if (tracker) {
|
||||
// Start HTTP stats server
|
||||
if (!appState.statsServer) {
|
||||
const config = deps.getConfig();
|
||||
if (config.stats.autoStartServer) {
|
||||
void ensureStatsServerStarted().catch((error: unknown) => {
|
||||
logger.warn('Failed to auto-start stats server.', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Register stats overlay toggle IPC handler (idempotent)
|
||||
deps.statsWindow.registerToggle(statsOverlayOptions);
|
||||
}
|
||||
},
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
shouldAutoConnectMpv: () => !appState.statsStartupInProgress,
|
||||
seedTrackerFromCurrentMedia: () => {
|
||||
void immersionMediaRuntime.seedFromCurrentMedia();
|
||||
},
|
||||
logInfo: (message) => logger.info(message),
|
||||
logDebug: (message) => logger.debug(message),
|
||||
logWarn: (message, details) => logger.warn(message, details),
|
||||
};
|
||||
const createImmersionTrackerStartup = createImmersionTrackerStartupHandler(
|
||||
immersionTrackerStartupDeps,
|
||||
);
|
||||
|
||||
/** Starts the tracker at most once per process; later calls are no-ops. */
|
||||
function ensureImmersionTrackerStarted(): void {
|
||||
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
|
||||
return;
|
||||
}
|
||||
hasAttemptedImmersionTrackerStartup = true;
|
||||
createImmersionTrackerStartup();
|
||||
}
|
||||
|
||||
// Stats startup must not auto-connect mpv, so the tracker starts under this flag.
|
||||
function ensureImmersionTrackerStartedForStats(): void {
|
||||
appState.statsStartupInProgress = true;
|
||||
try {
|
||||
ensureImmersionTrackerStarted();
|
||||
} finally {
|
||||
appState.statsStartupInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
const ensureBackgroundStatsServer = createEnsureBackgroundStatsServerHandler({
|
||||
isStatsAutoStartEnabled: () => deps.getConfig().stats.autoStartServer,
|
||||
isImmersionTrackingEnabled: () => deps.getConfig().immersionTracking?.enabled !== false,
|
||||
ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(),
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
});
|
||||
|
||||
const runStatsCliCommand = createRunStatsCliCommandHandler({
|
||||
getResolvedConfig: () => deps.getConfig(),
|
||||
ensureImmersionTrackerStarted: () => ensureImmersionTrackerStartedForStats(),
|
||||
ensureVocabularyCleanupTokenizerReady: async () => {
|
||||
await deps.createMecabTokenizerAndCheck();
|
||||
},
|
||||
getImmersionTracker: () => appState.immersionTracker,
|
||||
ensureStatsServerStarted: async () => (await ensureStatsServerStarted()).url,
|
||||
ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(),
|
||||
stopBackgroundStatsServer: () => stopBackgroundStatsServer(),
|
||||
openExternal: (url: string) => deps.openExternal(url),
|
||||
writeResponse: (responsePath, payload) => {
|
||||
writeStatsCliCommandResponse(responsePath, payload);
|
||||
},
|
||||
exitAppWithCode: (code) => {
|
||||
process.exitCode = code;
|
||||
deps.requestAppQuit();
|
||||
},
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
logError: (message, error) => logger.error(message, error),
|
||||
});
|
||||
|
||||
const ipc: IpcSlice<'getImmersionTracker'> = {
|
||||
getImmersionTracker: () => appState.immersionTracker,
|
||||
};
|
||||
|
||||
const cli: CliSlice<'runStatsCommand' | 'ensureBackgroundStatsServer'> = {
|
||||
runStatsCommand: (args, source) => runStatsCliCommand(args, source),
|
||||
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
|
||||
};
|
||||
|
||||
return {
|
||||
ipc,
|
||||
cli,
|
||||
immersionMediaRuntime,
|
||||
immersionTrackerStartupDeps,
|
||||
ensureImmersionTrackerStarted,
|
||||
stopStatsServer,
|
||||
ensureBackgroundStatsServer,
|
||||
runStatsCliCommand,
|
||||
toggleStatsOverlay: () => deps.statsWindow.toggle(statsOverlayOptions),
|
||||
recordCardsMined: (count: number, noteIds?: number[]): void => {
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordCardsMined(count, noteIds);
|
||||
},
|
||||
/** Marks the active video watched, then forces the AniList post-watch update. */
|
||||
markActiveVideoWatched: async (): Promise<boolean> => {
|
||||
ensureImmersionTrackerStarted();
|
||||
const marked = (await appState.immersionTracker?.markActiveVideoWatched()) ?? false;
|
||||
if (marked) {
|
||||
try {
|
||||
await deps.runAnilistPostWatchUpdate();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to run AniList post-watch update after manual watched mark:', error);
|
||||
}
|
||||
}
|
||||
return marked;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { Menu, NativeImage, Tray } from 'electron';
|
||||
import { createTrayAssembly, type TrayAssemblyDeps, type TrayMenuActions } from './tray';
|
||||
|
||||
const MENU_ACTIONS = [
|
||||
'openSessionHelpModal',
|
||||
'openChangelogModal',
|
||||
'openTexthookerInBrowser',
|
||||
'showTexthookerPage',
|
||||
'showFirstRunSetup',
|
||||
'openFirstRunSetupWindow',
|
||||
'getDictionaryBackend',
|
||||
'openHachidoriSettings',
|
||||
'openYomitanSettings',
|
||||
'openConfigSettingsWindow',
|
||||
'openSyncUiWindow',
|
||||
'openYoutubeBrowserWindow',
|
||||
'exportLogs',
|
||||
'openJellyfinSetupWindow',
|
||||
'isJellyfinConfigured',
|
||||
'isJellyfinDiscoveryActive',
|
||||
'toggleJellyfinDiscovery',
|
||||
'openAnilistSetupWindow',
|
||||
'checkForUpdates',
|
||||
'quitApp',
|
||||
] satisfies Array<keyof TrayMenuActions>;
|
||||
|
||||
function menuActions(makeAction: (name: string) => () => unknown): TrayMenuActions {
|
||||
return Object.fromEntries(
|
||||
MENU_ACTIONS.map((name) => [name, makeAction(name)]),
|
||||
) as unknown as TrayMenuActions;
|
||||
}
|
||||
|
||||
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
|
||||
function makeDeps(overrides: Partial<TrayAssemblyDeps> = {}): TrayAssemblyDeps {
|
||||
const lazy = (name: string) => (): never => {
|
||||
throw new Error(`${name} called unexpectedly`);
|
||||
};
|
||||
return {
|
||||
appState: { overlayRuntimeInitialized: false },
|
||||
logger: { warn: lazy('warn') },
|
||||
iconRoots: { resourcesPath: '/missing', appPath: '/missing', mainDirname: '/missing' },
|
||||
electron: {
|
||||
createTray: lazy('createTray'),
|
||||
createImageFromPath: lazy('createImageFromPath'),
|
||||
createEmptyImage: lazy('createEmptyImage'),
|
||||
buildMenuFromTemplate: lazy('buildMenuFromTemplate'),
|
||||
},
|
||||
menu: menuActions(lazy),
|
||||
initializeOverlayRuntime: lazy('initializeOverlayRuntime'),
|
||||
setVisibleOverlayVisible: lazy('setVisibleOverlayVisible'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
|
||||
test('createTrayAssembly does not call its deps while constructing', () => {
|
||||
const tray = createTrayAssembly(makeDeps());
|
||||
assert.equal(tray.hasTray(), false);
|
||||
});
|
||||
|
||||
test('refreshMenu rebuilds the menu only once the tray exists', () => {
|
||||
let menusBuilt = 0;
|
||||
let menusSet = 0;
|
||||
const fakeTray = {
|
||||
setToolTip: () => {},
|
||||
setContextMenu: () => {
|
||||
menusSet += 1;
|
||||
},
|
||||
on: () => {},
|
||||
} as unknown as Tray;
|
||||
const tray = createTrayAssembly(
|
||||
makeDeps({
|
||||
logger: { warn: () => {} },
|
||||
electron: {
|
||||
createTray: () => fakeTray,
|
||||
createImageFromPath: () => ({ isEmpty: () => true }) as NativeImage,
|
||||
createEmptyImage: () => ({ isEmpty: () => true }) as NativeImage,
|
||||
buildMenuFromTemplate: () => {
|
||||
menusBuilt += 1;
|
||||
return {} as Menu;
|
||||
},
|
||||
},
|
||||
menu: menuActions(() => () => false),
|
||||
}),
|
||||
);
|
||||
|
||||
tray.refreshMenu();
|
||||
assert.equal(menusBuilt, 0);
|
||||
|
||||
tray.ensureTray();
|
||||
assert.equal(tray.hasTray(), true);
|
||||
const menusAfterCreate = menusSet;
|
||||
tray.refreshMenu();
|
||||
assert.equal(menusSet, menusAfterCreate + 1);
|
||||
assert.equal(menusBuilt, menusSet);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import type { Menu, MenuItemConstructorOptions, NativeImage, Tray } from 'electron';
|
||||
import type { Logger } from '../../logger';
|
||||
import {
|
||||
buildTrayMenuTemplateRuntime,
|
||||
resolveTrayIconPathRuntime,
|
||||
} from '../runtime/domains/overlay';
|
||||
import type { createBuildTrayMenuTemplateHandler } from '../runtime/tray-main-actions';
|
||||
import { createTrayRuntimeHandlers } from '../runtime/tray-runtime-handlers';
|
||||
import type { AppState } from '../state';
|
||||
|
||||
type TrayMenuTemplateDeps = Parameters<
|
||||
typeof createBuildTrayMenuTemplateHandler<MenuItemConstructorOptions>
|
||||
>[0];
|
||||
|
||||
/** The tray menu's entries; each one calls into the domain that owns it. */
|
||||
export type TrayMenuActions = Omit<
|
||||
TrayMenuTemplateDeps,
|
||||
| 'buildTrayMenuTemplateRuntime'
|
||||
| 'platform'
|
||||
| 'initializeOverlayRuntime'
|
||||
| 'isOverlayRuntimeInitialized'
|
||||
| 'showWindowsMpvLauncherSetup'
|
||||
>;
|
||||
|
||||
export interface TrayAssemblyDeps {
|
||||
appState: Pick<AppState, 'overlayRuntimeInitialized'>;
|
||||
logger: Pick<Logger, 'warn'>;
|
||||
/** Where the packaged and dev builds keep the tray icon. */
|
||||
iconRoots: { resourcesPath: string; appPath: string; mainDirname: string };
|
||||
/** Electron tray primitives, injected so the assembly loads under `bun test`. */
|
||||
electron: {
|
||||
createTray: (icon: NativeImage) => Tray;
|
||||
createImageFromPath: (iconPath: string) => NativeImage;
|
||||
createEmptyImage: () => NativeImage;
|
||||
buildMenuFromTemplate: (template: MenuItemConstructorOptions[]) => Menu;
|
||||
};
|
||||
menu: TrayMenuActions;
|
||||
initializeOverlayRuntime: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export type TrayAssembly = ReturnType<typeof createTrayAssembly>;
|
||||
|
||||
/**
|
||||
* Owns the system tray icon and its menu. The tray exists in background mode and for managed
|
||||
* playback; clicking it shows the overlay.
|
||||
*/
|
||||
export function createTrayAssembly(deps: TrayAssemblyDeps) {
|
||||
const { appState } = deps;
|
||||
let tray: Tray | null = null;
|
||||
|
||||
const { ensureTray, destroyTray } = createTrayRuntimeHandlers({
|
||||
resolveTrayIconPathDeps: {
|
||||
resolveTrayIconPathRuntime,
|
||||
platform: process.platform,
|
||||
resourcesPath: deps.iconRoots.resourcesPath,
|
||||
appPath: deps.iconRoots.appPath,
|
||||
dirname: deps.iconRoots.mainDirname,
|
||||
joinPath: (...parts) => path.join(...parts),
|
||||
fileExists: (candidate) => fs.existsSync(candidate),
|
||||
},
|
||||
buildTrayMenuTemplateDeps: {
|
||||
...deps.menu,
|
||||
buildTrayMenuTemplateRuntime,
|
||||
platform: process.platform,
|
||||
initializeOverlayRuntime: () => deps.initializeOverlayRuntime(),
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||
},
|
||||
ensureTrayDeps: {
|
||||
getTray: () => tray,
|
||||
setTray: (nextTray) => {
|
||||
tray = nextTray as Tray | null;
|
||||
},
|
||||
createImageFromPath: (iconPath) => deps.electron.createImageFromPath(iconPath),
|
||||
createEmptyImage: () => deps.electron.createEmptyImage(),
|
||||
createTray: (icon) => deps.electron.createTray(icon as NativeImage),
|
||||
trayTooltip: 'SubMiner',
|
||||
platform: process.platform,
|
||||
logWarn: (message) => deps.logger.warn(message),
|
||||
initializeOverlayRuntime: () => deps.initializeOverlayRuntime(),
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
setVisibleOverlayVisible: (visible) => deps.setVisibleOverlayVisible(visible),
|
||||
},
|
||||
destroyTrayDeps: {
|
||||
getTray: () => tray,
|
||||
setTray: (nextTray) => {
|
||||
tray = nextTray as Tray | null;
|
||||
},
|
||||
},
|
||||
buildMenuFromTemplate: (template) => deps.electron.buildMenuFromTemplate(template),
|
||||
});
|
||||
|
||||
return {
|
||||
ensureTray,
|
||||
destroyTray,
|
||||
hasTray: (): boolean => Boolean(tray),
|
||||
/** Rebuilds the menu so state-dependent entries (setup, Jellyfin) stay current. */
|
||||
refreshMenu: (): void => {
|
||||
if (tray) {
|
||||
ensureTray();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { BrowserWindow, IpcMainInvokeEvent } from 'electron';
|
||||
import { DEFAULT_CONFIG } from '../../config/definitions';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
import { createYomitanAssembly, type YomitanAssemblyDeps } from './yomitan';
|
||||
|
||||
type InvokeHandler = (event: IpcMainInvokeEvent, value: unknown) => Promise<void>;
|
||||
|
||||
function configWithExternalProfile(externalProfilePath: string): ResolvedConfig {
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
yomitan: { ...DEFAULT_CONFIG.yomitan, externalProfilePath },
|
||||
};
|
||||
}
|
||||
|
||||
// Every callback except getConfig throws, so a test fails if the assembly calls a dep it
|
||||
// should only call later. getConfig is read once during construction for the profile policy.
|
||||
function makeDeps(
|
||||
overrides: Partial<YomitanAssemblyDeps> & { backend?: DictionaryBackend } = {},
|
||||
): YomitanAssemblyDeps {
|
||||
const lazy = (name: string) => (): never => {
|
||||
throw new Error(`${name} called unexpectedly`);
|
||||
};
|
||||
const { backend = 'yomitan', ...rest } = overrides;
|
||||
return {
|
||||
appState: {
|
||||
yomitanExt: null,
|
||||
yomitanSession: null,
|
||||
yomitanParserWindow: null,
|
||||
yomitanParserReadyPromise: null,
|
||||
yomitanParserInitPromise: null,
|
||||
yomitanSettingsWindow: null,
|
||||
},
|
||||
getConfig: () => DEFAULT_CONFIG,
|
||||
logger: { info: lazy('info'), warn: lazy('warn'), error: lazy('error'), debug: lazy('debug') },
|
||||
userDataPath: '/tmp/subminer-yomitan-assembly-test',
|
||||
dictionaryBackend: backend,
|
||||
hachidori: { ensureLoaded: lazy('hachidori.ensureLoaded'), getSession: lazy('getSession') },
|
||||
getOverlayWindows: lazy('getOverlayWindows'),
|
||||
getOverlayNotifications: lazy('getOverlayNotifications'),
|
||||
ipcMain: { handle: lazy('ipcMain.handle') },
|
||||
openExternal: lazy('openExternal'),
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
test('createYomitanAssembly only reads config while constructing', () => {
|
||||
const yomitan = createYomitanAssembly(makeDeps());
|
||||
assert.equal(typeof yomitan.ensureExtensionLoaded, 'function');
|
||||
assert.equal(yomitan.profilePolicy.isExternalReadOnlyMode(), false);
|
||||
});
|
||||
|
||||
test('an external Yomitan profile is read-only for the Yomitan backend only', () => {
|
||||
const getConfig = () => configWithExternalProfile('/profiles/main');
|
||||
assert.equal(
|
||||
createYomitanAssembly(makeDeps({ getConfig })).profilePolicy.isExternalReadOnlyMode(),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
createYomitanAssembly(
|
||||
makeDeps({ getConfig, backend: 'hachidori' }),
|
||||
).profilePolicy.isExternalReadOnlyMode(),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('Yomitan settings refuse to open in read-only external-profile mode', () => {
|
||||
const notifications: string[] = [];
|
||||
const yomitan = createYomitanAssembly(
|
||||
makeDeps({
|
||||
getConfig: () => configWithExternalProfile('/profiles/main'),
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
||||
getOverlayNotifications: () => ({
|
||||
showConfiguredStatusNotification: (message: string) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(yomitan.openDictionarySettings(), false);
|
||||
assert.deepEqual(notifications, [
|
||||
'Yomitan settings unavailable while using read-only external-profile mode.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('dictionary links open only from the main frame of an overlay window', async () => {
|
||||
const overlaySender = { mainFrame: 'overlay-main-frame' };
|
||||
const overlayWindow = { webContents: overlaySender } as unknown as BrowserWindow;
|
||||
let handler: InvokeHandler | null = null;
|
||||
const opened: string[] = [];
|
||||
const yomitan = createYomitanAssembly(
|
||||
makeDeps({
|
||||
backend: 'hachidori',
|
||||
getOverlayWindows: () => [overlayWindow],
|
||||
ipcMain: {
|
||||
handle: (_channel: string, listener: InvokeHandler) => {
|
||||
handler = listener;
|
||||
},
|
||||
} as YomitanAssemblyDeps['ipcMain'],
|
||||
openExternal: async (url) => {
|
||||
opened.push(url);
|
||||
},
|
||||
}),
|
||||
);
|
||||
yomitan.registerDictionaryExternalLinkHandler();
|
||||
assert.ok(handler);
|
||||
const invoke = handler as InvokeHandler;
|
||||
const event = (sender: unknown, senderFrame: unknown) =>
|
||||
({ sender, senderFrame }) as unknown as IpcMainInvokeEvent;
|
||||
|
||||
await invoke(event(overlaySender, 'overlay-main-frame'), 'https://example.com/entry');
|
||||
await assert.rejects(invoke(event(overlaySender, 'child-frame'), 'https://example.com/x'));
|
||||
await assert.rejects(
|
||||
invoke(event({ mainFrame: 'other' }, 'other'), 'https://example.com/x'),
|
||||
/only available from the active overlay/,
|
||||
);
|
||||
assert.deepEqual(opened, ['https://example.com/entry']);
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
import type { BrowserWindow, Extension, IpcMain, Session } from 'electron';
|
||||
import {
|
||||
clearYomitanParserCachesForWindow,
|
||||
destroyYomitanSettingsWindow,
|
||||
getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore,
|
||||
loadYomitanExtension as loadYomitanExtensionCore,
|
||||
openYomitanSettingsWindow,
|
||||
} from '../../core/services';
|
||||
import { generateSentenceFurigana } from '../../core/services/tokenizer/sentence-furigana';
|
||||
import type { Logger } from '../../logger';
|
||||
import {
|
||||
DICTIONARY_EXTERNAL_LINK_CHANNEL,
|
||||
parseDictionaryExternalUrl,
|
||||
} from '../../shared/dictionary-external-link';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
||||
import { createYomitanAnkiServerSyncRuntime } from '../runtime/yomitan-anki-server-sync';
|
||||
import { reloadOverlayWindowsForYomitanContentScripts } from '../runtime/yomitan-extension-overlay-reload';
|
||||
import { createYomitanExtensionRuntime } from '../runtime/yomitan-extension-runtime';
|
||||
import { createYomitanProfilePolicy } from '../runtime/yomitan-profile-policy';
|
||||
import { createYomitanSettingsRuntime } from '../runtime/yomitan-settings-runtime';
|
||||
import type { AppState } from '../state';
|
||||
|
||||
/** The bundled Hachidori extension. Injected because it reaches Electron's session API. */
|
||||
export interface HachidoriExtensionLike {
|
||||
ensureLoaded: () => Promise<Extension>;
|
||||
getSession: () => Session;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the dictionary-extension domain needs from the rest of the app. Siblings built after
|
||||
* this assembly in `main.ts` are reached through callbacks.
|
||||
*/
|
||||
export interface YomitanAssemblyDeps {
|
||||
appState: Pick<
|
||||
AppState,
|
||||
| 'yomitanExt'
|
||||
| 'yomitanSession'
|
||||
| 'yomitanParserWindow'
|
||||
| 'yomitanParserReadyPromise'
|
||||
| 'yomitanParserInitPromise'
|
||||
| 'yomitanSettingsWindow'
|
||||
>;
|
||||
getConfig: () => ResolvedConfig;
|
||||
logger: Pick<Logger, 'info' | 'warn' | 'error' | 'debug'>;
|
||||
userDataPath: string;
|
||||
/** Fixed for the process lifetime; backend changes apply on restart. */
|
||||
dictionaryBackend: DictionaryBackend;
|
||||
hachidori: HachidoriExtensionLike;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
getOverlayNotifications: () => Pick<
|
||||
ReturnType<typeof createOverlayNotificationsRuntime>,
|
||||
'showConfiguredStatusNotification'
|
||||
>;
|
||||
ipcMain: Pick<IpcMain, 'handle'>;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export type YomitanAssembly = ReturnType<typeof createYomitanAssembly>;
|
||||
|
||||
/**
|
||||
* Owns the Yomitan/Hachidori extension: loading, the parser runtime deps every tokenizer
|
||||
* caller shares, the external-profile policy, and the dictionary settings windows. The
|
||||
* inactive backend's settings window stays openable so users can manage both.
|
||||
*/
|
||||
export function createYomitanAssembly(deps: YomitanAssemblyDeps) {
|
||||
const { appState, logger, dictionaryBackend, hachidori } = deps;
|
||||
let yomitanLoadInFlight: Promise<Extension | null> | null = null;
|
||||
let hachidoriSettingsWindow: BrowserWindow | null = null;
|
||||
let inactiveYomitanExtension: Extension | null = null;
|
||||
let inactiveYomitanSettingsWindow: BrowserWindow | null = null;
|
||||
let inactiveYomitanLoad: Promise<Extension | null> | null = null;
|
||||
|
||||
const profilePolicy = createYomitanProfilePolicy({
|
||||
externalProfilePath:
|
||||
dictionaryBackend === 'yomitan' ? deps.getConfig().yomitan.externalProfilePath : '',
|
||||
logInfo: (message) => logger.info(message),
|
||||
});
|
||||
|
||||
function getParserRuntimeDeps() {
|
||||
return {
|
||||
getYomitanExt: () => appState.yomitanExt,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||
setYomitanParserWindow: (window: BrowserWindow | null) => {
|
||||
appState.yomitanParserWindow = window;
|
||||
},
|
||||
getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise,
|
||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => {
|
||||
appState.yomitanParserReadyPromise = promise;
|
||||
},
|
||||
getYomitanParserInitPromise: () => appState.yomitanParserInitPromise,
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => {
|
||||
appState.yomitanParserInitPromise = promise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const extensionRuntime = createYomitanExtensionRuntime({
|
||||
loadYomitanExtensionCore: async (loadDeps) => {
|
||||
if (dictionaryBackend === 'yomitan') return loadYomitanExtensionCore(loadDeps);
|
||||
try {
|
||||
const extension = await hachidori.ensureLoaded();
|
||||
loadDeps.setYomitanExtension(extension);
|
||||
loadDeps.setYomitanSession(hachidori.getSession());
|
||||
return extension;
|
||||
} catch (error) {
|
||||
logger.error('Failed to load Hachidori extension:', error);
|
||||
loadDeps.setYomitanExtension(null);
|
||||
loadDeps.setYomitanSession(null);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
userDataPath: deps.userDataPath,
|
||||
externalProfilePath: profilePolicy.externalProfilePath,
|
||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||
setYomitanParserWindow: (window) => {
|
||||
appState.yomitanParserWindow = window as BrowserWindow | null;
|
||||
},
|
||||
setYomitanParserReadyPromise: (promise) => {
|
||||
appState.yomitanParserReadyPromise = promise;
|
||||
},
|
||||
setYomitanParserInitPromise: (promise) => {
|
||||
appState.yomitanParserInitPromise = promise;
|
||||
},
|
||||
setYomitanExtension: (extension) => {
|
||||
appState.yomitanExt = extension;
|
||||
},
|
||||
setYomitanSession: (nextSession) => {
|
||||
appState.yomitanSession = nextSession;
|
||||
},
|
||||
getYomitanExtension: () => appState.yomitanExt,
|
||||
getLoadInFlight: () => yomitanLoadInFlight,
|
||||
setLoadInFlight: (promise) => {
|
||||
yomitanLoadInFlight = promise;
|
||||
},
|
||||
onYomitanExtensionLoaded: () => {
|
||||
const reloaded = reloadOverlayWindowsForYomitanContentScripts(
|
||||
deps.getOverlayWindows(),
|
||||
(message, error) => logger.warn(message, error),
|
||||
);
|
||||
if (reloaded > 0) {
|
||||
logger.debug(`Reloaded ${reloaded} overlay window(s) after Yomitan extension load.`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { syncYomitanDefaultProfileAnkiServer } = createYomitanAnkiServerSyncRuntime({
|
||||
isExternalReadOnlyMode: () => profilePolicy.isExternalReadOnlyMode(),
|
||||
getResolvedConfig: () => deps.getConfig(),
|
||||
getYomitanParserRuntimeDeps: () => getParserRuntimeDeps(),
|
||||
logError: (message, ...args) => {
|
||||
logger.error(message, ...args);
|
||||
},
|
||||
logInfo: (message, ...args) => {
|
||||
logger.info(message, ...args);
|
||||
},
|
||||
});
|
||||
|
||||
async function loadExtension(): Promise<Extension | null> {
|
||||
const extension = await extensionRuntime.loadYomitanExtension();
|
||||
if (extension && !profilePolicy.isExternalReadOnlyMode()) {
|
||||
await syncYomitanDefaultProfileAnkiServer();
|
||||
}
|
||||
return extension;
|
||||
}
|
||||
|
||||
async function ensureExtensionLoaded(): Promise<Extension | null> {
|
||||
const extension = await extensionRuntime.ensureYomitanExtensionLoaded();
|
||||
if (extension && !profilePolicy.isExternalReadOnlyMode()) {
|
||||
await syncYomitanDefaultProfileAnkiServer();
|
||||
}
|
||||
return extension;
|
||||
}
|
||||
|
||||
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
|
||||
ensureYomitanExtensionLoaded: () => ensureExtensionLoaded(),
|
||||
getYomitanExtension: () => appState.yomitanExt,
|
||||
getYomitanExtensionLoadInFlight: () => yomitanLoadInFlight,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => {
|
||||
openYomitanSettingsWindow({
|
||||
yomitanExt: yomitanExt as Extension,
|
||||
getExistingWindow: () => getExistingWindow() as BrowserWindow | null,
|
||||
setWindow: (window) => setWindow(window as BrowserWindow | null),
|
||||
yomitanSession: (yomitanSession as Session | null | undefined) ?? appState.yomitanSession,
|
||||
onWindowClosed: () => {
|
||||
if (appState.yomitanParserWindow) {
|
||||
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
getExistingWindow: () => appState.yomitanSettingsWindow,
|
||||
setWindow: (window) => {
|
||||
appState.yomitanSettingsWindow = window as BrowserWindow | null;
|
||||
},
|
||||
logWarn: (message) => logger.warn(message),
|
||||
logError: (message, error) => logger.error(message, error),
|
||||
});
|
||||
|
||||
function openYomitanSettings(): boolean {
|
||||
if (dictionaryBackend === 'hachidori') {
|
||||
if (deps.getConfig().yomitan.externalProfilePath.trim()) {
|
||||
logger.warn('Yomitan settings unavailable while using read-only external-profile mode.');
|
||||
return false;
|
||||
}
|
||||
inactiveYomitanLoad ??= inactiveYomitanExtension
|
||||
? Promise.resolve(inactiveYomitanExtension)
|
||||
: loadYomitanExtensionCore({
|
||||
userDataPath: deps.userDataPath,
|
||||
getYomitanParserWindow: () => null,
|
||||
setYomitanParserWindow: () => {},
|
||||
setYomitanParserReadyPromise: () => {},
|
||||
setYomitanParserInitPromise: () => {},
|
||||
setYomitanExtension: (extension) => {
|
||||
inactiveYomitanExtension = extension;
|
||||
},
|
||||
setYomitanSession: () => {},
|
||||
}).finally(() => {
|
||||
inactiveYomitanLoad = null;
|
||||
});
|
||||
void (
|
||||
inactiveYomitanExtension ? Promise.resolve(inactiveYomitanExtension) : inactiveYomitanLoad
|
||||
)
|
||||
.then((extension) =>
|
||||
openYomitanSettingsWindow({
|
||||
yomitanExt: extension,
|
||||
getExistingWindow: () => inactiveYomitanSettingsWindow,
|
||||
setWindow: (window) => {
|
||||
inactiveYomitanSettingsWindow = window;
|
||||
},
|
||||
}),
|
||||
)
|
||||
.catch((error: unknown) => logger.error('Failed to open Yomitan settings', error));
|
||||
return true;
|
||||
}
|
||||
if (profilePolicy.isExternalReadOnlyMode()) {
|
||||
const message = 'Yomitan settings unavailable while using read-only external-profile mode.';
|
||||
logger.warn(
|
||||
'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.',
|
||||
);
|
||||
deps
|
||||
.getOverlayNotifications()
|
||||
.showConfiguredStatusNotification(message, { variant: 'warning' });
|
||||
return false;
|
||||
}
|
||||
openYomitanSettingsHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
function openHachidoriSettings(): void {
|
||||
void (dictionaryBackend === 'hachidori' ? ensureExtensionLoaded() : hachidori.ensureLoaded())
|
||||
.then((extension) =>
|
||||
openYomitanSettingsWindow({
|
||||
backend: 'hachidori',
|
||||
yomitanExt: extension,
|
||||
yomitanSession: hachidori.getSession(),
|
||||
getExistingWindow: () => hachidoriSettingsWindow,
|
||||
setWindow: (window) => {
|
||||
hachidoriSettingsWindow = window;
|
||||
},
|
||||
onWindowClosed: () => {
|
||||
if (dictionaryBackend === 'hachidori' && appState.yomitanParserWindow) {
|
||||
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
.catch((error: unknown) => logger.error('Failed to open Hachidori settings', error));
|
||||
}
|
||||
|
||||
function openDictionarySettings(): boolean {
|
||||
if (dictionaryBackend === 'hachidori') {
|
||||
openHachidoriSettings();
|
||||
return true;
|
||||
}
|
||||
return openYomitanSettings();
|
||||
}
|
||||
|
||||
async function getCurrentAnkiDeckName(): Promise<string> {
|
||||
await extensionRuntime.ensureYomitanExtensionLoaded();
|
||||
return getYomitanCurrentAnkiDeckNameCore(getParserRuntimeDeps(), {
|
||||
error: (message, ...args) => {
|
||||
logger.error(message, ...args);
|
||||
},
|
||||
info: (message, ...args) => {
|
||||
logger.info(message, ...args);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
profilePolicy,
|
||||
getParserRuntimeDeps,
|
||||
loadExtension,
|
||||
ensureExtensionLoaded,
|
||||
syncYomitanDefaultProfileAnkiServer,
|
||||
getCurrentAnkiDeckName,
|
||||
openYomitanSettings,
|
||||
openHachidoriSettings,
|
||||
openDictionarySettings,
|
||||
generateSentenceFurigana: (text: string, highlightedText?: string): Promise<string | null> =>
|
||||
generateSentenceFurigana(text, highlightedText, getParserRuntimeDeps(), logger),
|
||||
/** The session overlay windows load the active backend's content scripts from. */
|
||||
getOverlaySession: (): Session | null =>
|
||||
dictionaryBackend === 'hachidori' ? hachidori.getSession() : appState.yomitanSession,
|
||||
/** Dictionary settings windows the overlay must not cover. */
|
||||
getSettingsWindows: (): Array<BrowserWindow | null> => [
|
||||
appState.yomitanSettingsWindow,
|
||||
hachidoriSettingsWindow,
|
||||
inactiveYomitanSettingsWindow,
|
||||
],
|
||||
destroySettingsWindows: (): void => {
|
||||
destroyYomitanSettingsWindow(hachidoriSettingsWindow);
|
||||
destroyYomitanSettingsWindow(inactiveYomitanSettingsWindow);
|
||||
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
|
||||
appState.yomitanSettingsWindow = null;
|
||||
},
|
||||
/** Hachidori dictionary entries link out; only the active overlay's main frame may ask. */
|
||||
registerDictionaryExternalLinkHandler: (): void => {
|
||||
deps.ipcMain.handle(DICTIONARY_EXTERNAL_LINK_CHANNEL, async (event, value: unknown) => {
|
||||
if (
|
||||
dictionaryBackend !== 'hachidori' ||
|
||||
event.senderFrame !== event.sender.mainFrame ||
|
||||
!deps.getOverlayWindows().some((window) => window.webContents === event.sender)
|
||||
) {
|
||||
throw new Error('Dictionary links are only available from the active overlay');
|
||||
}
|
||||
await deps.openExternal(parseDictionaryExternalUrl(value));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -36,7 +36,6 @@ function baseParams() {
|
||||
createAnilistUpdateQueue: (targetPath: string) => ({ targetPath }),
|
||||
createSubtitleWebSocket: (payloadMode: 'plain' | 'annotated') => ({ payloadMode }),
|
||||
createLogger: () => ({ warn: () => {}, info: () => {}, error: () => {} }),
|
||||
createMainRuntimeRegistry: () => ({}),
|
||||
createOverlayManager: () => ({ getMainWindow: () => null, getModalWindow: () => null }),
|
||||
createOverlayModalInputState: () => ({
|
||||
getModalInputExclusive: () => false,
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface MainBootServicesParams<
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager,
|
||||
TOverlayModalInputState,
|
||||
TOverlayContentMeasurementStore,
|
||||
@@ -71,7 +70,6 @@ export interface MainBootServicesParams<
|
||||
info: (message: string) => void;
|
||||
error: (message: string, details?: unknown) => void;
|
||||
};
|
||||
createMainRuntimeRegistry: () => TRuntimeRegistry;
|
||||
createOverlayManager: () => TOverlayManager;
|
||||
createOverlayModalInputState: (params: {
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
@@ -99,7 +97,6 @@ export interface MainBootServicesResult<
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager,
|
||||
TOverlayModalInputState,
|
||||
TOverlayContentMeasurementStore,
|
||||
@@ -118,7 +115,6 @@ export interface MainBootServicesResult<
|
||||
subtitleWsService: TSubtitleWebSocket;
|
||||
annotationSubtitleWsService: TSubtitleWebSocket;
|
||||
logger: TLogger;
|
||||
runtimeRegistry: TRuntimeRegistry;
|
||||
overlayManager: TOverlayManager;
|
||||
overlayModalInputState: TOverlayModalInputState;
|
||||
overlayContentMeasurementStore: TOverlayContentMeasurementStore;
|
||||
@@ -134,7 +130,6 @@ export function createMainBootServices<
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager extends {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
@@ -152,7 +147,6 @@ export function createMainBootServices<
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager,
|
||||
TOverlayModalInputState,
|
||||
TOverlayContentMeasurementStore,
|
||||
@@ -167,7 +161,6 @@ export function createMainBootServices<
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager,
|
||||
TOverlayModalInputState,
|
||||
TOverlayContentMeasurementStore,
|
||||
@@ -211,7 +204,6 @@ export function createMainBootServices<
|
||||
const subtitleWsService = params.createSubtitleWebSocket('plain');
|
||||
const annotationSubtitleWsService = params.createSubtitleWebSocket('annotated');
|
||||
const logger = params.createLogger('main');
|
||||
const runtimeRegistry = params.createMainRuntimeRegistry();
|
||||
const overlayManager = params.createOverlayManager();
|
||||
const overlayModalInputState = params.createOverlayModalInputState({
|
||||
getModalWindow: () => overlayManager.getModalWindow(),
|
||||
@@ -292,7 +284,6 @@ export function createMainBootServices<
|
||||
subtitleWsService,
|
||||
annotationSubtitleWsService,
|
||||
logger,
|
||||
runtimeRegistry,
|
||||
overlayManager,
|
||||
overlayModalInputState,
|
||||
overlayContentMeasurementStore,
|
||||
|
||||
@@ -15,20 +15,6 @@ function readSource(relPath: string): string {
|
||||
return fs.readFileSync(path.join(process.cwd(), relPath), 'utf8');
|
||||
}
|
||||
|
||||
test('manual watched session action starts immersion tracker before marking watched', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/markActiveVideoWatched:\s*async\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\}\s*,/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(actionBlock, /ensureImmersionTrackerStarted\(\);/);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('ensureImmersionTrackerStarted();') <
|
||||
actionBlock.indexOf('markActiveVideoWatched()'),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
|
||||
const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
|
||||
const actionBlock = source.match(
|
||||
@@ -71,10 +57,6 @@ test('startup autoplay release is tied to visible overlay measurement readiness'
|
||||
const gateBlock = source.match(
|
||||
/const autoplayReadyGate = createAutoplayReadyGate\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||
)?.groups?.body;
|
||||
const measurementBlock = source.match(
|
||||
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(gateBlock);
|
||||
assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/);
|
||||
// Untokenized signals are filtered by the gate's isTokenizationReady dep, not
|
||||
@@ -88,71 +70,6 @@ test('startup autoplay release is tied to visible overlay measurement readiness'
|
||||
assert.doesNotMatch(signalTargetReadyBlock, /isTokenizationWarmupReady\(\)/);
|
||||
assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/);
|
||||
assert.match(gateBlock, /getLatestVisibleMeasurement:/);
|
||||
|
||||
assert.ok(measurementBlock);
|
||||
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
|
||||
assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
|
||||
});
|
||||
|
||||
test('visible overlay first measurement flushes autoplay before scheduling the first-paint refresh', () => {
|
||||
const source = readMainSource();
|
||||
const measurementBlock = source.match(
|
||||
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(measurementBlock);
|
||||
assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
|
||||
assert.match(measurementBlock, /scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint\(\)/);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('autoplayReadyGate.flushPendingAutoplayReadySignal();') <
|
||||
measurementBlock.indexOf('scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();'),
|
||||
);
|
||||
});
|
||||
|
||||
test('accepted visible overlay measurement immediately refreshes pointer interaction', () => {
|
||||
const source = readMainSource();
|
||||
const measurementBlock = source.match(
|
||||
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(measurementBlock);
|
||||
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
|
||||
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
|
||||
assert.match(measurementBlock, /tickWindowsOverlayPointerInteractionNow\(\)/);
|
||||
assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
|
||||
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();'),
|
||||
);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') <
|
||||
measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();'),
|
||||
);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();') <
|
||||
measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar open state is restored for replacement visible overlay windows', () => {
|
||||
const source = readMainSource();
|
||||
const openedBlock = source.match(
|
||||
/onOverlayModalOpened:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
const closedBlock = source.match(
|
||||
/onOverlayModalClosed:\s*\(modal,\s*senderWindow(?:,\s*instanceId)?\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
const depsBlock = source.match(/getSubtitleSidebarOpen:\s*\(\)\s*=>\s*(?<body>[^\n,]+)/)?.groups
|
||||
?.body;
|
||||
|
||||
assert.ok(openedBlock);
|
||||
assert.ok(closedBlock);
|
||||
assert.ok(depsBlock);
|
||||
assert.match(openedBlock, /if \(modal === 'subtitle-sidebar'/);
|
||||
assert.match(openedBlock, /subtitleSidebarRequestedOpen = true;/);
|
||||
assert.match(closedBlock, /if \(modal === 'subtitle-sidebar'/);
|
||||
assert.match(closedBlock, /subtitleSidebarRequestedOpen = false;/);
|
||||
assert.match(depsBlock, /subtitleSidebarRequestedOpen/);
|
||||
});
|
||||
|
||||
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
|
||||
@@ -229,19 +146,3 @@ test('Linux visible overlay bounds refresh restores X11 shape after applying mpv
|
||||
afterBoundsBlock.indexOf('ensureOverlayWindowLevel(mainWindow);'),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active-source parsing', () => {
|
||||
const source = readMainSource();
|
||||
const snapshotBlock = source.match(
|
||||
/getSubtitleSidebarSnapshot:\s*async\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler)/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(snapshotBlock);
|
||||
assert.match(snapshotBlock, /shouldUseCachedYoutubeParsedCues\(/);
|
||||
assert.match(snapshotBlock, /cachedMediaPath:\s*appState\.activeParsedSubtitleMediaPath/);
|
||||
assert.match(snapshotBlock, /cachedCueCount:\s*appState\.activeParsedSubtitleCues\.length/);
|
||||
assert.ok(
|
||||
snapshotBlock.indexOf('shouldUseCachedYoutubeParsedCues(') <
|
||||
snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'),
|
||||
);
|
||||
});
|
||||
@@ -28,7 +28,7 @@ export interface AniSkipRuntimeDeps {
|
||||
logDebug: (message: string) => void;
|
||||
}
|
||||
|
||||
interface AniSkipIntroWindow {
|
||||
export interface AniSkipIntroWindow {
|
||||
start: number;
|
||||
end: number;
|
||||
malId: number | null;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import * as domains from './domains';
|
||||
|
||||
export type MainRuntimeRegistry = typeof domains;
|
||||
|
||||
export function createMainRuntimeRegistry(): MainRuntimeRegistry {
|
||||
return domains;
|
||||
}
|
||||
Reference in new issue
Block a user