refactor(main): move core layers into assemblies (#310)

* refactor(main): move first-run setup into an assembly

* refactor(main): move playback and subtitle pipeline state into an assembly

Moves the mpv runtime composer wiring together with the subtitle processing, prefetch, sidebar playback-state, seek-intent, secondary-subtitle and autoplay-priming state it mutates into createPlaybackAssembly. Replaces the main-wiring regex tests for the moved code with behaviour tests that drive real mpv events through a fake client.

* refactor(main): move overlay windows and visibility into an assembly

* refactor(main): read Electron screen lazily in the overlay assembly deps

* docs: describe overlay, playback, and first-run setup assemblies

* test(playback): clear scheduled prefetch refresh in warm-tokenization tests
This commit is contained in:
sudacode authored and GitHub committed 2026-10-31 22:04:42 -07:00
1 parent 804fb8933e
commit af44735bb8
11 files changed
+2726 -1689

No files matched your search

+2 -2
View File
@@ -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 (YouTube, Jellyfin, AniList, settings, sync, dictionaries, overlay modals)
assemblies/ # per-domain assemblies main.ts builds once (overlay, playback, 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 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.
`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.
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
View File
@@ -21,11 +21,14 @@ Read when: you need to find the owner module for a behavior or test surface
`src/config/resolve/anki-connect/`
Config hot-reload and the settings window: `src/main/assemblies/settings.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-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`
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`
- 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/`
+4
View File
@@ -47,3 +47,7 @@ good test looks like here.
- `src/main/main-wiring.test.ts` and `src/renderer/renderer-init-order.test.ts` still
regex `main.ts` / `renderer.ts` to guard startup ordering. Replace each test with a
behavioral one as the logic moves out of `main.ts` closures.
- `src/main/runtime/overlay-geometry-runtime.ts` and
`src/main/runtime/visible-overlay-interaction-runtime.ts` value-import Electron, so they
cannot load under `bun test` and stay outside `src/main/assemblies/overlay.ts`. Inject
`screen`/`dialog` to make them testable and movable.
+129 -1372
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
createDefaultSetupState,
getSetupStatePath,
writeSetupState,
} from '../../shared/setup-state';
import { createFirstRunSetupAssembly, type FirstRunSetupAssemblyDeps } from './first-run-setup';
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
function makeDeps(configDir: string): FirstRunSetupAssemblyDeps {
const lazy = (name: string) => (): never => {
throw new Error(`${name} called unexpectedly`);
};
return {
appState: {
firstRunSetupCompleted: false,
firstRunSetupWindow: null,
backgroundMode: false,
initialArgs: null,
},
configService: { getConfig: lazy('getConfig'), patchRawConfig: lazy('patchRawConfig') },
logger: { error: lazy('error'), info: lazy('info') },
configDir,
userDataPath: configDir,
dictionaryBackend: 'yomitan',
ensureYomitanExtensionLoaded: lazy('ensureYomitanExtensionLoaded'),
getYomitanParserRuntimeDeps: lazy('getYomitanParserRuntimeDeps'),
createCommandLineLauncherRuntimeOptions: lazy('createCommandLineLauncherRuntimeOptions'),
getAppPath: lazy('getAppPath'),
trashItem: lazy('trashItem'),
writeShortcutLink: lazy('writeShortcutLink'),
createBrowserWindow: lazy('createBrowserWindow'),
openDictionarySettings: lazy('openDictionarySettings'),
openConfigSettingsWindow: lazy('openConfigSettingsWindow'),
onSetupStateChanged: lazy('onSetupStateChanged'),
requestAppQuit: lazy('requestAppQuit'),
};
}
function makeConfigDir(t: { after: (fn: () => void) => void }): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-assembly-test-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
return dir;
}
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
test('createFirstRunSetupAssembly does not call its deps while constructing', (t) => {
const firstRunSetup = createFirstRunSetupAssembly(makeDeps(makeConfigDir(t)));
assert.equal(typeof firstRunSetup.service.isSetupCompleted, 'function');
assert.equal(typeof firstRunSetup.openWindow, 'function');
});
test('openWindow skips a completed setup unless forced', (t) => {
const configDir = makeConfigDir(t);
writeSetupState(getSetupStatePath(configDir), {
...createDefaultSetupState(),
status: 'completed',
});
const firstRunSetup = createFirstRunSetupAssembly(makeDeps(configDir));
firstRunSetup.openWindow();
assert.throws(() => firstRunSetup.openWindow(true), /createBrowserWindow called unexpectedly/);
});
+371
View File
@@ -0,0 +1,371 @@
import os from 'node:os';
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
import type { ConfigService } from '../../config/service';
import { getYomitanDictionaryInfo } from '../../core/services';
import { requestHachidoriSharing } from '../../core/services/tokenizer/yomitan-parser-runtime';
import type { Logger } from '../../logger';
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
import type { DictionaryBackend } from '../../types/config';
import {
detectCommandLineLauncher,
installBun as installCommandLineBun,
installLauncher as installCommandLineLauncher,
} from '../runtime/command-line-launcher';
import {
detectInstalledFirstRunPlugin,
detectInstalledFirstRunPluginCandidates,
removeLegacyMpvPluginCandidates,
} from '../runtime/first-run-setup-plugin';
import {
createFirstRunSetupService,
getFirstRunSetupCompletionMessage,
isStandaloneFirstRunSetupCommand,
} from '../runtime/first-run-setup-service';
import {
buildFirstRunSetupHtml,
createMaybeFocusExistingFirstRunSetupWindowHandler,
createOpenFirstRunSetupWindowHandler,
parseFirstRunSetupSubmissionUrl,
type FirstRunSetupSubmission,
} from '../runtime/first-run-setup-window';
import { createCreateFirstRunSetupWindowHandler } from '../runtime/setup-window-factory';
import { getConfiguredWindowsMpvPathStatus } from '../runtime/windows-mpv-launch';
import {
applyWindowsMpvShortcuts,
detectWindowsMpvShortcuts,
resolveWindowsMpvShortcutPaths,
} from '../runtime/windows-mpv-shortcuts';
import { observeYomitanDictionaryCount } from '../runtime/yomitan-dictionary-integrity';
import type { AppState } from '../state';
type YomitanParserRuntimeDeps = Parameters<typeof requestHachidoriSharing>[1];
type CommandLineLauncherRuntimeOptions = NonNullable<
Parameters<typeof detectCommandLineLauncher>[0]
>;
/**
* What the first-run setup flow needs from the rest of the app. Siblings built after this
* assembly in `main.ts` are reached through callbacks.
*/
export interface FirstRunSetupAssemblyDeps {
appState: Pick<
AppState,
'firstRunSetupCompleted' | 'firstRunSetupWindow' | 'backgroundMode' | 'initialArgs'
>;
configService: Pick<ConfigService, 'getConfig' | 'patchRawConfig'>;
logger: Pick<Logger, 'error' | 'info'>;
configDir: string;
userDataPath: string;
/** Fixed for the process lifetime; backend changes apply on restart. */
dictionaryBackend: DictionaryBackend;
ensureYomitanExtensionLoaded: () => Promise<unknown>;
getYomitanParserRuntimeDeps: () => YomitanParserRuntimeDeps;
/** Resolves the launcher options after the login-shell PATH is applied. */
createCommandLineLauncherRuntimeOptions: () => Promise<CommandLineLauncherRuntimeOptions>;
getAppPath: (name: 'appData' | 'desktop') => string;
trashItem: (targetPath: string) => Promise<void>;
writeShortcutLink: Parameters<typeof applyWindowsMpvShortcuts>[0]['writeShortcutLink'];
createBrowserWindow: (options: BrowserWindowConstructorOptions) => BrowserWindow;
openDictionarySettings: () => boolean;
openConfigSettingsWindow: () => boolean;
/** Called when setup state changes, so the tray menu can refresh its setup entry. */
onSetupStateChanged: () => void;
requestAppQuit: () => void;
}
export type FirstRunSetupAssembly = ReturnType<typeof createFirstRunSetupAssembly>;
/**
* Owns the first-run setup service and its window, plus the status message shown in that
* window. `main.ts` initializes setup state during app-ready and opens the window from there,
* the tray and the CLI.
*/
export function createFirstRunSetupAssembly(deps: FirstRunSetupAssemblyDeps) {
const { appState, configService, logger } = deps;
let firstRunSetupMessage: string | null = null;
const resolveWindowsMpvShortcutRuntimePaths = () =>
resolveWindowsMpvShortcutPaths({
appDataDir: deps.getAppPath('appData'),
desktopDir: deps.getAppPath('desktop'),
});
const firstRunSetupService = createFirstRunSetupService({
getDictionaryBackend: () => deps.dictionaryBackend,
getHachidoriHostStatus: async () => {
await deps.ensureYomitanExtensionLoaded();
return requestHachidoriSharing(
{ type: 'hd_sharing_status' },
deps.getYomitanParserRuntimeDeps(),
logger,
);
},
platform: process.platform,
configDir: deps.configDir,
getYomitanDictionaryCount: async () => {
await deps.ensureYomitanExtensionLoaded();
const dictionaries = await getYomitanDictionaryInfo(deps.getYomitanParserRuntimeDeps(), {
error: (message, ...args) => logger.error(message, ...args),
info: (message, ...args) => logger.info(message, ...args),
});
const integrity = observeYomitanDictionaryCount(deps.userDataPath, dictionaries.length);
if (!integrity.safe) {
logger.error(`[dictionary:integrity] ${integrity.message}`);
}
return dictionaries.length;
},
isExternalYomitanConfigured: () =>
configService.getConfig().yomitan.externalProfilePath.trim().length > 0,
detectPluginInstalled: () => {
const candidates = detectInstalledFirstRunPluginCandidates({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: deps.getAppPath('appData'),
mpvExecutablePath: configService.getConfig().mpv.executablePath,
});
if (candidates.length > 0) {
return true;
}
const installPaths = resolveDefaultMpvInstallPaths(
process.platform,
os.homedir(),
process.env.XDG_CONFIG_HOME,
);
return detectInstalledFirstRunPlugin(installPaths);
},
detectLegacyMpvPluginCandidates: () =>
detectInstalledFirstRunPluginCandidates({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: deps.getAppPath('appData'),
mpvExecutablePath: configService.getConfig().mpv.executablePath,
}),
removeLegacyMpvPlugins: (candidates) =>
removeLegacyMpvPluginCandidates({
candidates,
trashItem: (candidatePath) => deps.trashItem(candidatePath),
}),
detectWindowsMpvShortcuts: () => {
if (process.platform !== 'win32') {
return {
startMenuInstalled: false,
desktopInstalled: false,
};
}
return detectWindowsMpvShortcuts(resolveWindowsMpvShortcutRuntimePaths());
},
applyWindowsMpvShortcuts: async (preferences) => {
if (process.platform !== 'win32') {
return {
ok: true,
status: 'unknown' as const,
message: '',
};
}
return applyWindowsMpvShortcuts({
preferences,
paths: resolveWindowsMpvShortcutRuntimePaths(),
exePath: process.execPath,
writeShortcutLink: (shortcutPath, operation, details) =>
deps.writeShortcutLink(shortcutPath, operation, details),
});
},
detectCommandLineLauncher: async () =>
detectCommandLineLauncher(await deps.createCommandLineLauncherRuntimeOptions()),
installBun: async () => {
const snapshot = await installCommandLineBun(
await deps.createCommandLineLauncherRuntimeOptions(),
);
return {
ok: snapshot.status === 'ready',
message:
snapshot.message ??
(snapshot.status === 'ready'
? 'Bun is ready. Open a new terminal.'
: 'Bun installation failed.'),
};
},
installCommandLineLauncher: async () => {
const snapshot = await installCommandLineLauncher(
await deps.createCommandLineLauncherRuntimeOptions(),
);
const ok = snapshot.status === 'ready' || snapshot.status === 'not_on_path';
return {
ok,
installPath: snapshot.installPath,
message:
snapshot.message ??
(ok
? 'Command-line launcher installed. Open a new terminal.'
: 'Command-line launcher installation failed.'),
};
},
onStateChanged: (state) => {
appState.firstRunSetupCompleted = state.status === 'completed';
deps.onSetupStateChanged();
},
});
const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({
getSetupWindow: () => appState.firstRunSetupWindow,
});
const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: maybeFocusExistingFirstRunSetupWindow,
createSetupWindow: createCreateFirstRunSetupWindowHandler({
createBrowserWindow: (options) => deps.createBrowserWindow(options),
}),
getSetupSnapshot: async () => {
const snapshot = await firstRunSetupService.getSetupStatus();
const mpvExecutablePath = configService.getConfig().mpv.executablePath;
return {
configReady: snapshot.configReady,
dictionaryCount: snapshot.dictionaryCount,
dictionaryBackend: snapshot.dictionaryBackend,
hachidoriHost: snapshot.hachidoriHost,
canFinish: snapshot.canFinish,
externalYomitanConfigured: snapshot.externalYomitanConfigured,
pluginStatus: snapshot.pluginStatus,
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
legacyMpvPluginPaths: snapshot.legacyMpvPluginPaths,
mpvExecutablePath,
mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath),
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
commandLineLauncher: snapshot.commandLineLauncher,
message: firstRunSetupMessage,
};
},
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
handleAction: async (submission: FirstRunSetupSubmission) => {
if (
submission.action === 'link-hachidori-host' ||
submission.action === 'unlink-hachidori-host'
) {
try {
if (deps.dictionaryBackend !== 'hachidori')
throw new Error('Select Hachidori and restart SubMiner before linking a host.');
await deps.ensureYomitanExtensionLoaded();
const status = await requestHachidoriSharing(
submission.action === 'link-hachidori-host'
? { type: 'hd_sharing_client_link', address: submission.address }
: { type: 'hd_sharing_client_unlink' },
deps.getYomitanParserRuntimeDeps(),
logger,
);
firstRunSetupMessage =
status.kind === 'local'
? 'Using dictionaries installed in SubMiner.'
: status.kind === 'connected'
? `Linked to ${status.name}. Anki mining stays in SubMiner.`
: status.message;
} catch (error) {
firstRunSetupMessage =
error instanceof Error ? error.message : 'Could not update the dictionary host.';
}
return;
}
if (submission.action === 'remove-legacy-plugin') {
const snapshot = await firstRunSetupService.removeLegacyMpvPlugin();
firstRunSetupMessage = snapshot.message;
return;
}
if (submission.action === 'configure-mpv-executable-path') {
const mpvExecutablePath = submission.mpvExecutablePath?.trim() ?? '';
const pathStatus = getConfiguredWindowsMpvPathStatus(mpvExecutablePath);
configService.patchRawConfig({
mpv: {
executablePath: mpvExecutablePath,
},
});
firstRunSetupMessage =
pathStatus === 'invalid'
? `Saved mpv executable path, but the file was not found: ${mpvExecutablePath}`
: mpvExecutablePath
? `Saved mpv executable path: ${mpvExecutablePath}`
: 'Cleared mpv executable path. SubMiner will auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.';
return;
}
if (submission.action === 'configure-windows-mpv-shortcuts') {
const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({
startMenuEnabled: submission.startMenuEnabled === true,
desktopEnabled: submission.desktopEnabled === true,
});
firstRunSetupMessage = snapshot.message;
return;
}
if (submission.action === 'install-bun') {
const snapshot = await firstRunSetupService.installBun();
firstRunSetupMessage = snapshot.message;
return;
}
if (submission.action === 'install-command-line-launcher') {
const snapshot = await firstRunSetupService.installCommandLineLauncher();
firstRunSetupMessage = snapshot.message;
return;
}
if (submission.action === 'open-yomitan-settings') {
firstRunSetupMessage = deps.openDictionarySettings()
? `Opened ${deps.dictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings. Install dictionaries, then refresh status.`
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
return;
}
if (submission.action === 'open-config-settings') {
const opened = deps.openConfigSettingsWindow();
firstRunSetupMessage = opened
? 'Opened SubMiner settings.'
: 'SubMiner settings are unavailable.';
if (opened) {
return { skipRender: true };
}
return;
}
if (submission.action === 'refresh') {
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
firstRunSetupMessage = snapshot.message;
return;
}
const snapshot = await firstRunSetupService.markSetupCompleted();
if (snapshot.state.status === 'completed') {
firstRunSetupMessage = null;
return { closeWindow: true };
}
firstRunSetupMessage =
getFirstRunSetupCompletionMessage(snapshot) ??
'Finish setup requires the mpv plugin and Yomitan dictionaries.';
return;
},
markSetupInProgress: async () => {
firstRunSetupMessage = null;
await firstRunSetupService.markSetupInProgress();
},
markSetupCancelled: async () => {
firstRunSetupMessage = null;
await firstRunSetupService.markSetupCancelled();
},
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode,
shouldQuitWhenClosedCompleted: () =>
Boolean(appState.initialArgs && isStandaloneFirstRunSetupCommand(appState.initialArgs)),
quitApp: () => deps.requestAppQuit(),
clearSetupWindow: () => {
appState.firstRunSetupWindow = null;
},
setSetupWindow: (window) => {
appState.firstRunSetupWindow = window as BrowserWindow;
},
encodeURIComponent: (value) => encodeURIComponent(value),
logError: (message, error) => logger.error(message, error),
});
/** Opens the setup window; without `force` it is a no-op once setup has completed. */
function openWindow(force = false): void {
if (!force && firstRunSetupService.isSetupCompleted()) {
return;
}
openFirstRunSetupWindowHandler();
}
return { service: firstRunSetupService, openWindow };
}
+331
View File
@@ -0,0 +1,331 @@
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 { createAppState } from '../state';
import { createOverlayAssembly, type OverlayAssemblyDeps } from './overlay';
type Call = { name: string; args: unknown[] };
type OverlayWindowOptions = Parameters<OverlayAssemblyDeps['createOverlayWindowCore']>[1];
type Overlay = ReturnType<typeof createOverlayAssembly>;
/** Records every dep call by name, in order; each dep still returns an inert value. */
function createRecorder() {
const calls: Call[] = [];
const spy =
<A extends unknown[], R>(name: string, impl: (...args: A) => R) =>
(...args: A): R => {
calls.push({ name, args });
return impl(...args);
};
const names = () => calls.map((call) => call.name);
return { calls, spy, names };
}
type Recorder = ReturnType<typeof createRecorder>;
/** Test-controlled state the fakes read from. */
type FixtureState = {
visible: boolean;
trackedGeometry: WindowGeometry | null;
/** Window returned by window creation; null makes creation throw after recording options. */
window: BrowserWindow | null;
};
const WINDOW_CREATION_STOPPED = 'window creation stopped by test';
const NO_PLACEMENT: HyprlandPlacementStatus = {
applicable: false,
clientFound: false,
dispatched: false,
};
function makeDeps(recorder: Recorder, state: FixtureState) {
const { spy } = recorder;
const noop = () => {};
const resolved = async () => {};
const windowOptions: OverlayWindowOptions[] = [];
const deps: OverlayAssemblyDeps = {
appState: createAppState({
mpvSocketPath: '/tmp/subminer-overlay-test.sock',
texthookerPort: 0,
}),
logger: {
debug: spy('logger.debug', noop),
info: spy('logger.info', noop),
warn: spy('logger.warn', noop),
},
overlayManager: {
getMainWindow: spy('overlay.getMainWindow', () => null),
setMainWindow: spy('overlay.setMainWindow', noop),
getModalWindow: spy('overlay.getModalWindow', () => null),
setModalWindow: spy('overlay.setModalWindow', noop),
getVisibleOverlayVisible: spy('overlay.getVisibleOverlayVisible', () => state.visible),
setVisibleOverlayVisible: spy('overlay.setVisibleOverlayVisible', (visible: boolean) => {
state.visible = visible;
}),
setOverlayWindowBounds: spy('overlay.setOverlayWindowBounds', () => NO_PLACEMENT),
setModalWindowBounds: spy('overlay.setModalWindowBounds', () => NO_PLACEMENT),
},
overlayModalInputState: { getModalInputExclusive: spy('modal.getInputExclusive', () => false) },
autoplayReadyGate: {
markCurrentMediaAutoplayReady: spy('autoplay.markCurrentMediaReady', noop),
flushPendingAutoplayReadySignal: spy('autoplay.flushPending', noop),
},
screen: {
getCursorScreenPoint: spy('screen.getCursorScreenPoint', () => ({ x: 0, y: 0 })),
getDisplayNearestPoint: spy('screen.getDisplayNearestPoint', (): never => {
throw new Error('display fallback used');
}),
},
isDev: false,
createOverlayWindowCore: spy('createOverlayWindowCore', (_kind, options): BrowserWindow => {
windowOptions.push(options);
if (!state.window) throw new Error(WINDOW_CREATION_STOPPED);
return state.window;
}),
getYomitanSession: spy('getYomitanSession', () => null),
getVisibleOverlayInteraction: spy('getVisibleOverlayInteraction', () => ({
getLinuxOverlayInputShapeActive: spy('interaction.getLinuxInputShapeActive', () => false),
getMacOSVisibleOverlayForegroundProbeActive: spy('interaction.getMacProbe', () => false),
getVisibleOverlayInteractionActive: spy('interaction.getActive', () => false),
getWindowsNativeWindowHandleNumber: spy('interaction.getWindowsHandle', () => 0),
primeLinuxOverlayPointerInteractionAfterFirstMeasurement: spy(
'interaction.primePointerAfterFirstMeasurement',
noop,
),
requestLinuxOverlayZOrderFollow: spy('interaction.requestLinuxZOrderFollow', noop),
requestWindowsVisibleOverlayZOrderSync: spy('interaction.requestWindowsZOrderSync', noop),
resetLinuxVisibleOverlayStartupInputPrimer: spy('interaction.resetStartupPrimer', noop),
resetVisibleOverlayInputState: spy('interaction.resetInputState', noop),
restoreVisibleOverlayWindowShapeForShow: spy('interaction.restoreShapeForShow', noop),
scheduleVisibleOverlayBlurRefresh: spy('interaction.scheduleBlurRefresh', noop),
startLinuxVisibleOverlayStartupInputGrace: spy('interaction.startStartupGrace', noop),
})),
getOverlayGeometry: spy('getOverlayGeometry', () => ({
enforceOverlayLayerOrder: spy('geometry.enforceLayerOrder', noop),
ensureOverlayWindowLevel: spy('geometry.ensureWindowLevel', noop),
getCurrentOverlayGeometry: spy('geometry.getCurrentOverlayGeometry', () => ({
x: 0,
y: 0,
width: 1920,
height: 1080,
})),
getCurrentTrackedOverlayGeometry: spy(
'geometry.getCurrentTrackedOverlayGeometry',
() => state.trackedGeometry,
),
syncPrimaryOverlayWindowLayer: spy('geometry.syncPrimaryLayer', noop),
updateVisibleOverlayBounds: spy('geometry.updateVisibleBounds', () => 'not-applicable'),
})),
getOverlayShortcuts: spy('getOverlayShortcuts', () => ({
syncOverlayShortcuts: spy('shortcuts.sync', noop),
tryHandleOverlayShortcutLocalFallback: spy('shortcuts.localFallback', () => false),
})),
getOverlayNotifications: spy('getOverlayNotifications', () => ({
showOverlayLoadingStatusNotification: spy('notifications.showLoading', noop),
dismissOverlayLoadingStatusNotification: spy('notifications.dismissLoading', noop),
flushQueuedOverlayNotifications: spy('notifications.flushQueued', noop),
maybeStartOverlayLoadingOsd: spy('notifications.maybeStartLoadingOsd', noop),
})),
getOverlayVisibilityComposer: spy('getOverlayVisibilityComposer', () => ({
broadcastRuntimeOptionsChanged: spy('composer.broadcastRuntimeOptions', noop),
setOverlayDebugVisualizationEnabled: spy('composer.setDebugVisualization', noop),
})),
getMediaTimingReviewRuntime: spy('getMediaTimingReviewRuntime', () => ({
dispose: spy('mediaTimingReview.dispose', resolved),
})),
getSubtitleProcessingController: spy('getSubtitleProcessingController', () => ({
refreshCurrentSubtitle: spy('subtitle.refreshCurrent', () => true),
})),
getAutoplaySubtitlePriming: spy('getAutoplaySubtitlePriming', () => ({
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint: spy('priming.cancelFirstPaint', noop),
primeCurrentSubtitleForVisibleOverlay: spy('priming.prime', resolved),
})),
ensureOverlayMpvSubtitlesHidden: spy('ensureOverlayMpvSubtitlesHidden', resolved),
syncOverlayMpvSubtitleSuppression: spy('syncOverlayMpvSubtitleSuppression', noop),
initializeOverlayRuntime: spy('initializeOverlayRuntime', noop),
};
return { deps, windowOptions };
}
/** Builds the assembly, optionally with `process.platform` overridden for the test. */
function setup(t: TestContext, options: { platform?: NodeJS.Platform; visible?: boolean } = {}) {
if (options.platform) {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: options.platform, configurable: true });
t.after(() => Object.defineProperty(process, 'platform', { value: originalPlatform }));
}
const recorder = createRecorder();
const state: FixtureState = {
visible: options.visible ?? false,
trackedGeometry: null,
window: null,
};
const { deps, windowOptions } = makeDeps(recorder, state);
const overlay = createOverlayAssembly(deps);
return { recorder, state, overlay, windowOptions };
}
/** Asserts each name was called, in this relative order. */
function assertCalledInOrder(names: string[], expected: string[]): void {
let previous = -1;
for (const name of expected) {
const index = names.indexOf(name, previous + 1);
assert.ok(index > previous, `${name} was not called after the previous step`);
previous = index;
}
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
test('createOverlayAssembly does not call its deps while constructing', (t) => {
const { recorder, overlay } = setup(t);
assert.deepEqual(recorder.names(), []);
assert.equal(typeof overlay.setVisibleOverlayVisible, 'function');
});
const hidePaths: Array<{ label: string; hide: (overlay: Overlay) => void; order: string[] }> = [
{
label: 'setVisibleOverlayVisible(false)',
hide: (overlay) => overlay.setVisibleOverlayVisible(false),
order: [
'autoplay.markCurrentMediaReady',
'priming.cancelFirstPaint',
'interaction.resetInputState',
],
},
{
label: 'toggleVisibleOverlay() while shown',
hide: (overlay) => overlay.toggleVisibleOverlay(),
order: [
'autoplay.markCurrentMediaReady',
'priming.cancelFirstPaint',
'interaction.resetInputState',
],
},
{
label: 'setOverlayVisible(false)',
hide: (overlay) => overlay.setOverlayVisible(false),
order: [
'priming.cancelFirstPaint',
'interaction.resetInputState',
'autoplay.markCurrentMediaReady',
],
},
];
for (const { label, hide, order } of hidePaths) {
test(`${label} dismisses the loading OSD, releases autoplay and clears input state`, (t) => {
const { recorder, overlay } = setup(t, { visible: true });
hide(overlay);
const names = recorder.names();
assertCalledInOrder(names, ['notifications.dismissLoading', ...order]);
assert.ok(!names.includes('priming.prime'));
});
test(`${label} cancels a pending Linux fullscreen refresh burst`, async (t) => {
const { recorder, overlay } = setup(t, { platform: 'linux', visible: true });
overlay.handleMpvFullscreenChange(true);
hide(overlay);
const afterHide = recorder.calls.length;
await sleep(20);
// A burst that survived the hide would re-sync the overlay on its 0ms tick.
assert.equal(recorder.calls.length, afterHide);
});
}
// Control for the cancellation tests above: the same tick does run when nothing cancels it.
test('an uncancelled Linux fullscreen refresh burst runs on its first tick', async (t) => {
const { recorder, overlay } = setup(t, { platform: 'linux' });
t.after(() => overlay.clearLinuxFullscreenOverlayRefresh());
overlay.handleMpvFullscreenChange(true);
const afterSchedule = recorder.calls.length;
await sleep(20);
assert.ok(recorder.calls.length > afterSchedule);
});
const showPaths: Array<{ label: string; show: (overlay: Overlay) => void }> = [
{
label: 'setVisibleOverlayVisible(true)',
show: (overlay) => overlay.setVisibleOverlayVisible(true),
},
{
label: 'toggleVisibleOverlay() while hidden',
show: (overlay) => overlay.toggleVisibleOverlay(),
},
{ label: 'setOverlayVisible(true)', show: (overlay) => overlay.setOverlayVisible(true) },
];
for (const { label, show } of showPaths) {
test(`${label} starts the loading OSD and input grace before priming, keeping autoplay held`, (t) => {
const { recorder, overlay } = setup(t);
show(overlay);
const names = recorder.names();
assertCalledInOrder(names, [
'notifications.maybeStartLoadingOsd',
'interaction.resetStartupPrimer',
'interaction.startStartupGrace',
'priming.prime',
]);
assert.ok(!names.includes('autoplay.markCurrentMediaReady'));
assert.ok(!names.includes('interaction.resetInputState'));
});
}
test('visible overlay content-ready flushes notifications and autoplay without tokenizing', (t) => {
const { recorder, overlay, windowOptions } = setup(t, { visible: true });
assert.throws(() => overlay.createMainWindow(), new RegExp(WINDOW_CREATION_STOPPED));
const onContentReady = windowOptions[0]?.onWindowContentReady;
assert.ok(onContentReady);
const before = recorder.calls.length;
onContentReady();
const names = recorder.names().slice(before);
assertCalledInOrder(names, [
'notifications.dismissLoading',
'notifications.flushQueued',
'interaction.primePointerAfterFirstMeasurement',
'autoplay.flushPending',
]);
// The first subtitle waits for the first content measurement, not for content-ready.
assert.ok(!names.includes('subtitle.refreshCurrent'));
});
for (const trackedGeometry of [null, { x: 10, y: 20, width: 1280, height: 720 }]) {
test(`Linux fullscreen recreation resets input first and ${trackedGeometry ? 'applies tracked mpv bounds' : 'keeps bounds without tracked geometry'}`, (t) => {
const { recorder, state, overlay } = setup(t, { platform: 'linux', visible: true });
state.trackedGeometry = trackedGeometry;
// An opaque token: the assembly only hands the created window to the overlay manager.
state.window = {} as BrowserWindow;
overlay.linuxOverlayModeRuntime.sync(true);
const names = recorder.names();
assertCalledInOrder(names, [
'interaction.resetInputState',
'createOverlayWindowCore',
'geometry.getCurrentTrackedOverlayGeometry',
'ensureOverlayMpvSubtitlesHidden',
]);
const boundsCalls = recorder.calls.filter(
(call) => call.name === 'overlay.setOverlayWindowBounds',
);
assert.deepEqual(
boundsCalls.map((call) => call.args[0]),
trackedGeometry ? [trackedGeometry] : [],
);
// Recreation must never fall back to display bounds before mpv geometry is known.
assert.ok(!names.includes('geometry.getCurrentOverlayGeometry'));
});
}
+441
View File
@@ -0,0 +1,441 @@
import type { BrowserWindow, Screen, Session } from 'electron';
import {
type createOverlayWindow as createOverlayWindowCoreFn,
sendMpvCommandRuntime,
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
type createOverlayManager,
} from '../../core/services';
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 { createOverlayVisibilityRuntimeService } from '../overlay-visibility-runtime';
import type { createAutoplayReadyGate } from '../runtime/autoplay-ready-gate';
import type { composeOverlayVisibilityRuntime } from '../runtime/composers';
import { createOverlayVisibilityRuntime } from '../runtime/domains/overlay';
import {
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
updateLinuxMpvFullscreenOverlayRefreshBurst,
} from '../runtime/linux-mpv-fullscreen-overlay-refresh';
import { createLinuxOverlayModeRuntime } from '../runtime/linux-overlay-mode-runtime';
import { shouldRunLinuxOverlayZOrderKeepAlive } from '../runtime/linux-overlay-zorder-keepalive';
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';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import { createOverlayWindowRuntimeHandlers } from '../runtime/overlay-window-runtime-handlers';
import type { VisibleOverlayInteractionRuntime } from '../runtime/visible-overlay-interaction-runtime';
import type { AppState } from '../state';
import type { PlaybackAssembly } from './playback';
type OverlayManager = ReturnType<typeof createOverlayManager>;
/**
* What overlay core needs from the rest of `main.ts`. Everything here except the getters and
* callbacks is built before the assembly; siblings built later are reached through getters, and
* construction calls none of them.
*/
export interface OverlayAssemblyDeps {
appState: AppState;
logger: Pick<Logger, 'debug' | 'info' | 'warn'>;
overlayManager: Pick<
OverlayManager,
| 'getMainWindow'
| 'setMainWindow'
| 'getModalWindow'
| 'setModalWindow'
| 'getVisibleOverlayVisible'
| 'setVisibleOverlayVisible'
| 'setOverlayWindowBounds'
| 'setModalWindowBounds'
>;
overlayModalInputState: Pick<
ReturnType<typeof createOverlayModalInputState>,
'getModalInputExclusive'
>;
autoplayReadyGate: Pick<
ReturnType<typeof createAutoplayReadyGate>,
'markCurrentMediaAutoplayReady' | 'flushPendingAutoplayReadySignal'
>;
screen: Pick<Screen, 'getCursorScreenPoint' | 'getDisplayNearestPoint'>;
isDev: boolean;
/** Builds an Electron overlay window; injected so the assembly stays free of Electron calls. */
createOverlayWindowCore: typeof createOverlayWindowCoreFn;
getYomitanSession: () => Session | null;
/** Pointer interaction and geometry stay in `main.ts`: both import Electron at module load. */
getVisibleOverlayInteraction: () => Pick<
VisibleOverlayInteractionRuntime,
| 'getLinuxOverlayInputShapeActive'
| 'getMacOSVisibleOverlayForegroundProbeActive'
| 'getVisibleOverlayInteractionActive'
| 'getWindowsNativeWindowHandleNumber'
| 'primeLinuxOverlayPointerInteractionAfterFirstMeasurement'
| 'requestLinuxOverlayZOrderFollow'
| 'requestWindowsVisibleOverlayZOrderSync'
| 'resetLinuxVisibleOverlayStartupInputPrimer'
| 'resetVisibleOverlayInputState'
| 'restoreVisibleOverlayWindowShapeForShow'
| 'scheduleVisibleOverlayBlurRefresh'
| 'startLinuxVisibleOverlayStartupInputGrace'
>;
getOverlayGeometry: () => Pick<
ReturnType<typeof createOverlayGeometryRuntime>,
| 'enforceOverlayLayerOrder'
| 'ensureOverlayWindowLevel'
| 'getCurrentOverlayGeometry'
| 'getCurrentTrackedOverlayGeometry'
| 'syncPrimaryOverlayWindowLayer'
| 'updateVisibleOverlayBounds'
>;
getOverlayShortcuts: () => Pick<
ReturnType<typeof createOverlayShortcutsRuntimeService>,
'syncOverlayShortcuts' | 'tryHandleOverlayShortcutLocalFallback'
>;
getOverlayNotifications: () => Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
| 'showOverlayLoadingStatusNotification'
| 'dismissOverlayLoadingStatusNotification'
| 'flushQueuedOverlayNotifications'
| 'maybeStartOverlayLoadingOsd'
>;
getOverlayVisibilityComposer: () => Pick<
ReturnType<typeof composeOverlayVisibilityRuntime>,
'broadcastRuntimeOptionsChanged' | 'setOverlayDebugVisualizationEnabled'
>;
getMediaTimingReviewRuntime: () => Pick<
ReturnType<typeof createMediaTimingReviewRuntime>,
'dispose'
>;
getSubtitleProcessingController: () => Pick<
PlaybackAssembly['subtitleProcessingController'],
'refreshCurrentSubtitle'
>;
getAutoplaySubtitlePriming: () => Pick<
PlaybackAssembly['autoplaySubtitlePrimingRuntime'],
'cancelVisibleOverlaySubtitleRefreshAfterFirstPaint' | 'primeCurrentSubtitleForVisibleOverlay'
>;
ensureOverlayMpvSubtitlesHidden: () => Promise<void>;
syncOverlayMpvSubtitleSuppression: () => void;
initializeOverlayRuntime: () => void;
}
export type OverlayAssembly = ReturnType<typeof createOverlayAssembly>;
/**
* Owns overlay core: the visible/modal windows, their visibility, the Linux fullscreen window
* mode and refresh burst, and the show/hide paths. `main.ts` builds it once and reaches the
* runtimes through the result; overlay bootstrap, pointer interaction and geometry stay there.
*/
export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
const { appState, logger, overlayManager, overlayModalInputState, autoplayReadyGate } = deps;
let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null =
null;
const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime({
isEnabled: shouldRunLinuxOverlayZOrderKeepAlive,
isVisible: () => overlayManager.getVisibleOverlayVisible(),
getWindow: () => overlayManager.getMainWindow(),
clearWindow: () => overlayManager.setMainWindow(null),
createWindow: () => {
deps.getVisibleOverlayInteraction().resetVisibleOverlayInputState();
createMainWindow();
},
refreshWindow: () => {
const trackedGeometry = deps.getOverlayGeometry().getCurrentTrackedOverlayGeometry();
if (trackedGeometry) overlayManager.setOverlayWindowBounds(trackedGeometry);
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
void deps.ensureOverlayMpvSubtitlesHidden();
if (appState.currentSubText.trim()) {
deps.getSubtitleProcessingController().refreshCurrentSubtitle(appState.currentSubText);
}
},
now: Date.now,
logDebug: (message) => logger.debug(message),
});
function cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(): void {
cancelLinuxMpvFullscreenOverlayRefreshBurst?.();
cancelLinuxMpvFullscreenOverlayRefreshBurst = null;
}
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService({
getMainWindow: () => overlayManager.getMainWindow(),
getModalActive: () => overlayModalInputState.getModalInputExclusive(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getForceMousePassthrough: () => appState.statsOverlayVisible,
getNonNativeInputRegionActive: () =>
process.platform === 'linux' &&
deps.getVisibleOverlayInteraction().getLinuxOverlayInputShapeActive(),
getSuspendVisibleOverlay: () => appState.statsOverlayVisible,
getOverlayInteractionActive: () =>
deps.getVisibleOverlayInteraction().getVisibleOverlayInteractionActive(),
getWindowTracker: () => appState.windowTracker,
getMacOSForegroundProbeActive: () =>
deps.getVisibleOverlayInteraction().getMacOSVisibleOverlayForegroundProbeActive(),
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown: boolean) => {
appState.trackerNotReadyWarningShown = shown;
},
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.getOverlayGeometry().updateVisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window) => {
deps.getOverlayGeometry().ensureOverlayWindowLevel(window);
},
syncWindowsOverlayToMpvZOrder: (_window) => {
deps.getVisibleOverlayInteraction().requestWindowsVisibleOverlayZOrderSync();
},
syncPrimaryOverlayWindowLayer: (layer) => {
deps.getOverlayGeometry().syncPrimaryOverlayWindowLayer(layer);
},
enforceOverlayLayerOrder: () => {
deps.getOverlayGeometry().enforceOverlayLayerOrder();
},
syncOverlayShortcuts: () => {
deps.getOverlayShortcuts().syncOverlayShortcuts();
},
isMacOSPlatform: () => process.platform === 'darwin',
isWindowsPlatform: () => process.platform === 'win32',
showOverlayLoadingOsd: (_message: string) => {
deps.getOverlayNotifications().showOverlayLoadingStatusNotification();
},
dismissOverlayLoadingOsd: () => {
deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification();
},
hideNonNativeOverlayWhenTargetUnfocused: () =>
shouldRunLinuxOverlayZOrderKeepAlive() &&
linuxOverlayModeRuntime.mode === 'fullscreen-override',
resolveFallbackBounds: () => {
const cursorPoint = deps.screen.getCursorScreenPoint();
const display = deps.screen.getDisplayNearestPoint(cursorPoint);
const fallbackBounds = display.workArea;
return {
x: fallbackBounds.x,
y: fallbackBounds.y,
width: fallbackBounds.width,
height: fallbackBounds.height,
};
},
});
function createModalWindow(): BrowserWindow {
const existingWindow = overlayManager.getModalWindow();
if (existingWindow && !existingWindow.isDestroyed()) {
return existingWindow;
}
const window = createModalWindowHandler();
overlayManager.setModalWindowBounds(deps.getOverlayGeometry().getCurrentOverlayGeometry());
return window;
}
function createMainWindow(): BrowserWindow {
const window = createMainWindowHandler();
if (process.platform === 'win32') {
const overlayHwnd = deps
.getVisibleOverlayInteraction()
.getWindowsNativeWindowHandleNumber(window);
if (!ensureWindowsOverlayTransparency(overlayHwnd)) {
logger.warn('Failed to eagerly extend Windows overlay transparency via koffi');
}
}
return window;
}
const {
setVisibleOverlayVisible: setVisibleOverlayVisibleHandler,
toggleVisibleOverlay: toggleVisibleOverlayHandler,
setOverlayVisible: setOverlayVisibleHandler,
} = createOverlayVisibilityRuntime({
setVisibleOverlayVisibleDeps: {
setVisibleOverlayVisibleCore,
setVisibleOverlayVisibleState: (nextVisible) => {
overlayManager.setVisibleOverlayVisible(nextVisible);
},
updateVisibleOverlayVisibility: () =>
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
},
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
});
const { createMainWindow: createMainWindowHandler, createModalWindow: createModalWindowHandler } =
createOverlayWindowRuntimeHandlers<BrowserWindow>({
createOverlayWindowDeps: {
createOverlayWindowCore: (kind, options) => deps.createOverlayWindowCore(kind, options),
isDev: deps.isDev,
ensureOverlayWindowLevel: (window) =>
deps.getOverlayGeometry().ensureOverlayWindowLevel(window),
onRuntimeOptionsChanged: () =>
deps.getOverlayVisibilityComposer().broadcastRuntimeOptionsChanged(),
setOverlayDebugVisualizationEnabled: (enabled) =>
deps.getOverlayVisibilityComposer().setOverlayDebugVisualizationEnabled(enabled),
isOverlayVisible: (windowKind) =>
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
getYomitanSession: () => deps.getYomitanSession(),
tryHandleOverlayShortcutLocalFallback: (input) =>
deps.getOverlayShortcuts().tryHandleOverlayShortcutLocalFallback(input),
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
getLinuxX11FullscreenOverlay: () =>
shouldRunLinuxOverlayZOrderKeepAlive() &&
linuxOverlayModeRuntime.fullscreen &&
linuxOverlayModeRuntime.mode === 'fullscreen-override',
onVisibleWindowBlurred: () =>
deps.getVisibleOverlayInteraction().scheduleVisibleOverlayBlurRefresh(),
onVisibleWindowFocused: () =>
deps.getVisibleOverlayInteraction().requestLinuxOverlayZOrderFollow(),
onWindowDidFinishLoad: () => {
deps.getOverlayNotifications().flushQueuedOverlayNotifications();
},
onWindowContentReady: () => {
deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification();
deps.getOverlayNotifications().flushQueuedOverlayNotifications();
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
deps
.getVisibleOverlayInteraction()
.primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
autoplayReadyGate.flushPendingAutoplayReadySignal();
},
onWindowClosed: (windowKind, window) => {
if (windowKind === 'visible') {
if (overlayManager.getMainWindow() !== window) {
return;
}
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
overlayManager.setMainWindow(null);
} else {
if (overlayManager.getModalWindow() !== window) {
return;
}
void deps.getMediaTimingReviewRuntime().dispose();
overlayManager.setModalWindow(null);
}
},
},
getMainWindow: () => overlayManager.getMainWindow(),
isWindowDestroyed: (window) => window.isDestroyed(),
setMainWindow: (window) => overlayManager.setMainWindow(window),
setModalWindow: (window) => overlayManager.setModalWindow(window),
});
function ensureOverlayWindowsReadyForVisibilityActions(): void {
if (!appState.overlayRuntimeInitialized) {
deps.initializeOverlayRuntime();
return;
}
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
createMainWindow();
}
}
function notifyMpvPluginVisibleOverlayVisibility(visible: boolean): void {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
visible ? 'subminer-visible-overlay-shown' : 'subminer-visible-overlay-hidden',
]);
}
function setVisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions();
if (!visible) {
deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification();
autoplayReadyGate.markCurrentMediaAutoplayReady();
deps.getAutoplaySubtitlePriming().cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
deps.getVisibleOverlayInteraction().resetVisibleOverlayInputState();
}
if (visible) {
deps.getOverlayNotifications().maybeStartOverlayLoadingOsd();
deps.getVisibleOverlayInteraction().resetLinuxVisibleOverlayStartupInputPrimer();
deps.getVisibleOverlayInteraction().startLinuxVisibleOverlayStartupInputGrace();
deps.getVisibleOverlayInteraction().restoreVisibleOverlayWindowShapeForShow();
void deps.ensureOverlayMpvSubtitlesHidden();
void deps.getAutoplaySubtitlePriming().primeCurrentSubtitleForVisibleOverlay();
}
setVisibleOverlayVisibleHandler(visible);
notifyMpvPluginVisibleOverlayVisibility(visible);
deps.syncOverlayMpvSubtitleSuppression();
}
function toggleVisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions();
const nextVisible = !overlayManager.getVisibleOverlayVisible();
if (!nextVisible) {
deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification();
autoplayReadyGate.markCurrentMediaAutoplayReady();
deps.getAutoplaySubtitlePriming().cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
deps.getVisibleOverlayInteraction().resetVisibleOverlayInputState();
} else {
deps.getOverlayNotifications().maybeStartOverlayLoadingOsd();
deps.getVisibleOverlayInteraction().resetLinuxVisibleOverlayStartupInputPrimer();
deps.getVisibleOverlayInteraction().startLinuxVisibleOverlayStartupInputGrace();
deps.getVisibleOverlayInteraction().restoreVisibleOverlayWindowShapeForShow();
void deps.ensureOverlayMpvSubtitlesHidden();
void deps.getAutoplaySubtitlePriming().primeCurrentSubtitleForVisibleOverlay();
}
toggleVisibleOverlayHandler();
notifyMpvPluginVisibleOverlayVisibility(nextVisible);
deps.syncOverlayMpvSubtitleSuppression();
}
function setOverlayVisible(visible: boolean): void {
if (!visible) {
deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification();
deps.getAutoplaySubtitlePriming().cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
deps.getVisibleOverlayInteraction().resetVisibleOverlayInputState();
autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
}
if (visible) {
deps.getOverlayNotifications().maybeStartOverlayLoadingOsd();
deps.getVisibleOverlayInteraction().resetLinuxVisibleOverlayStartupInputPrimer();
deps.getVisibleOverlayInteraction().startLinuxVisibleOverlayStartupInputGrace();
deps.getVisibleOverlayInteraction().restoreVisibleOverlayWindowShapeForShow();
void deps.ensureOverlayMpvSubtitlesHidden();
void deps.getAutoplaySubtitlePriming().primeCurrentSubtitleForVisibleOverlay();
}
setOverlayVisibleHandler(visible);
notifyMpvPluginVisibleOverlayVisibility(visible);
deps.syncOverlayMpvSubtitleSuppression();
}
function handleMpvFullscreenChange(fullscreen: boolean): void {
cancelLinuxMpvFullscreenOverlayRefreshBurst = updateLinuxMpvFullscreenOverlayRefreshBurst(
fullscreen,
{
overlayManager: {
getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
},
overlayVisibilityRuntime,
syncVisibleOverlayMpvFullscreenMode: (nextFullscreen) =>
linuxOverlayModeRuntime.sync(nextFullscreen),
getOverlayInteractionActive: () =>
deps.getVisibleOverlayInteraction().getVisibleOverlayInteractionActive() ||
deps.getVisibleOverlayInteraction().getLinuxOverlayInputShapeActive(),
ensureOverlayWindowLevel: (window) =>
deps.getOverlayGeometry().ensureOverlayWindowLevel(window),
},
cancelLinuxMpvFullscreenOverlayRefreshBurst,
);
}
/** Quit cleanup: drops any pending Linux fullscreen transition and refresh burst. */
function clearLinuxFullscreenOverlayRefresh(): void {
linuxOverlayModeRuntime.cancelPendingTransition();
cancelLinuxMpvFullscreenOverlayRefreshBurst = null;
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
}
return {
linuxOverlayModeRuntime,
overlayVisibilityRuntime,
createMainWindow,
createModalWindow,
ensureOverlayWindowsReadyForVisibilityActions,
setVisibleOverlayVisible,
toggleVisibleOverlay,
setOverlayVisible,
handleMpvFullscreenChange,
clearLinuxFullscreenOverlayRefresh,
};
}
+395
View File
@@ -0,0 +1,395 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_CONFIG } from '../../config/definitions';
import type { SubtitlePrefetchService } from '../../core/services/subtitle-prefetch';
import type { SubtitleData } from '../../types';
import { createAppState } from '../state';
import { createPlaybackAssembly, type PlaybackAssemblyDeps } from './playback';
type Call = { name: string; args: unknown[] };
/** Records every dep call by name, in order; each dep still returns an inert value. */
function createRecorder() {
const calls: Call[] = [];
const spy =
<A extends unknown[], R>(name: string, impl: (...args: A) => R) =>
(...args: A): R => {
calls.push({ name, args });
return impl(...args);
};
const names = () => calls.map((call) => call.name);
return { calls, spy, names };
}
type Recorder = ReturnType<typeof createRecorder>;
function createFakePrefetchService(recorder: Recorder): SubtitlePrefetchService {
const { spy } = recorder;
return {
start: spy('prefetch.start', () => {}),
stop: spy('prefetch.stop', () => {}),
onSeek: spy('prefetch.onSeek', () => {}),
pause: spy('prefetch.pause', () => {}),
resume: spy('prefetch.resume', () => {}),
};
}
function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
const { spy } = recorder;
const noop = () => {};
const resolved = async () => {};
return {
appState: createAppState({
mpvSocketPath: '/tmp/subminer-playback-test.sock',
texthookerPort: 0,
}),
configService: { getConfig: spy('getConfig', () => DEFAULT_CONFIG) },
logger: {
debug: spy('logger.debug', noop),
info: spy('logger.info', noop),
warn: spy('logger.warn', noop),
error: spy('logger.error', noop),
},
overlayManager: {
broadcastToOverlayWindows: spy('overlay.broadcast', noop),
getVisibleOverlayVisible: spy('overlay.getVisibleOverlayVisible', () => false),
},
subtitleWsService: { broadcast: spy('subtitleWs.broadcast', noop) },
annotationSubtitleWsService: { broadcast: spy('annotationWs.broadcast', noop) },
autoplayReadyGate: {
maybeSignalPluginAutoplayReady: spy('autoplay.maybeSignal', noop),
flushPendingAutoplayReadySignal: spy('autoplay.flushPending', noop),
invalidatePendingAutoplayReadyFallbacks: spy('autoplay.invalidateFallbacks', noop),
},
currentMediaTokenizationGate: {
updateCurrentMediaPath: spy('tokenizationGate.updateCurrentMediaPath', noop),
markReady: spy('tokenizationGate.markReady', noop),
},
startupOsdSequencer: {
reset: spy('startupOsd.reset', noop),
markTokenizationReady: spy('startupOsd.markTokenizationReady', noop),
showTokenizationLoading: spy('startupOsd.showTokenizationLoading', noop),
showAnnotationLoading: spy('startupOsd.showAnnotationLoading', noop),
markAnnotationLoadingComplete: spy('startupOsd.markAnnotationLoadingComplete', noop),
},
managedLocalSubtitleSelectionRuntime: {
handleMediaPathChange: spy('localSubtitles.handleMediaPathChange', noop),
handleSubtitleTrackListChange: spy('localSubtitles.handleSubtitleTrackListChange', noop),
},
mediaTimingReviewRuntime: {
deferPlaybackResume: spy('timingReview.deferPlaybackResume', () => false),
cancelPlaybackResume: spy('timingReview.cancelPlaybackResume', noop),
},
discordPresenceRuntime: { publishDiscordPresence: spy('discord.publish', noop) },
immersionMediaRuntime: { syncFromCurrentMediaState: spy('immersion.sync', noop) },
mediaRuntime: {
updateCurrentMediaPath: spy('media.updateCurrentMediaPath', noop),
updateCurrentMediaTitle: spy('media.updateCurrentMediaTitle', noop),
},
overlayNotificationsRuntime: {
showConfiguredStatusNotification: spy('notifications.showStatus', noop),
getConfiguredStatusNotificationType: spy('notifications.getType', () => 'osd' as const),
},
youtube: {
isPlaybackActiveNow: spy('youtube.isPlaybackActiveNow', () => false),
playback: { getQuitOnDisconnectArmed: spy('youtube.getQuitOnDisconnectArmed', () => false) },
primarySubtitleNotification: {
handleMediaPathChange: spy('youtube.notification.handleMediaPathChange', noop),
handleSubtitleTrackChange: spy('youtube.notification.handleSubtitleTrackChange', noop),
handleSubtitleTrackListChange: spy('youtube.notification.handleTrackListChange', noop),
},
mediaCachePlayback: { handleMediaPathChange: spy('youtube.mediaCache', resolved) },
browserPlayback: { handleMediaPathChange: spy('youtube.browser', noop) },
flow: { handleMediaPathChange: spy('youtube.flow', noop) },
},
jellyfin: {
getQuitOnDisconnectArmed: spy('jellyfin.getQuitOnDisconnectArmed', () => false),
reportJellyfinRemoteStopped: spy('jellyfin.reportStopped', resolved),
reportJellyfinRemoteProgress: spy('jellyfin.reportProgress', resolved),
getActivePlayback: spy('jellyfin.getActivePlayback', () => null),
startJellyfinRemoteSession: spy('jellyfin.startRemoteSession', resolved),
},
anilist: {
maybeRunAnilistPostWatchUpdate: spy('anilist.postWatch', resolved),
recordAnilistMediaDuration: spy('anilist.recordDuration', noop),
getCurrentAnilistMediaKey: spy('anilist.getMediaKey', () => null),
resetAnilistMediaTracking: spy('anilist.resetTracking', noop),
maybeProbeAnilistDuration: spy('anilist.probeDuration', async () => null),
ensureAnilistMediaGuess: spy('anilist.ensureGuess', async () => null),
resetAnilistMediaGuessState: spy('anilist.resetGuess', noop),
},
characterDictionary: {
autoSync: {
scheduleSync: spy('characterDictionary.scheduleSync', noop),
getCurrentMediaId: spy('characterDictionary.getCurrentMediaId', () => null),
},
imageLookup: { get: spy('characterDictionary.imageLookup', () => null) },
nameCandidates: { get: spy('characterDictionary.nameCandidates', () => null) },
},
dictionaries: {
jlpt: { ensureJlptDictionaryLookup: spy('dictionaries.jlpt', resolved) },
frequency: { ensureFrequencyDictionaryLookup: spy('dictionaries.frequency', resolved) },
},
getSubtitleGenerationRuntime: spy('getSubtitleGenerationRuntime', () => ({
handleMediaPathChange: noop,
})),
isCharacterDictionaryEnabled: spy('isCharacterDictionaryEnabled', () => false),
hasTray: spy('hasTray', () => false),
loadSubtitleSourceText: spy('loadSubtitleSourceText', async () => ''),
createSubtitlePrefetchService: spy('createSubtitlePrefetchService', () =>
createFakePrefetchService(recorder),
),
requestAppQuit: spy('requestAppQuit', noop),
maybeStartOverlayLoadingOsd: spy('maybeStartOverlayLoadingOsd', noop),
flushQueuedMpvOsdNotifications: spy('flushQueuedMpvOsdNotifications', noop),
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),
shouldInitializeMecabForAnnotations: spy('shouldInitializeMecabForAnnotations', () => false),
// Every annotation off, so tokenization warmups need no MeCab or dictionaries.
getRuntimeBooleanOption: spy('getRuntimeBooleanOption', () => false),
onFullscreenChange: spy('onFullscreenChange', noop),
};
}
/** Binds the assembly's mpv event handlers to a fake client; returns an `emit` driver. */
function bindMpvEvents(playback: ReturnType<typeof createPlaybackAssembly>) {
const handlers = new Map<string, (payload: unknown) => void>();
playback.bindMpvClientEventHandlers({
on: (event, handler) => {
handlers.set(event, handler as (payload: unknown) => void);
},
});
return (event: string, payload: unknown): void => {
const handler = handlers.get(event);
assert.ok(handler, `no handler bound for ${event}`);
handler(payload);
};
}
function setup(overrides: Partial<PlaybackAssemblyDeps> = {}) {
const recorder = createRecorder();
const deps = { ...makeDeps(recorder), ...overrides };
const playback = createPlaybackAssembly(deps);
const emit = bindMpvEvents(playback);
return { recorder, deps, appState: deps.appState, playback, emit };
}
/** Sets up with a fake prefetch service, installed by a real prefetch init over one SRT cue. */
async function setupWithFakePrefetch() {
const ctx = setup({
loadSubtitleSourceText: async () => '1\n00:00:01,000 --> 00:00:02,000\n一行目\n',
});
await ctx.playback.subtitlePrefetchRuntime.initSubtitlePrefetch('/subs/episode.srt', 0);
assert.ok(ctx.playback.getSubtitlePrefetchService());
ctx.recorder.calls.length = 0;
return ctx;
}
const flushAsync = async (): Promise<void> => {
for (let i = 0; i < 5; i += 1) await new Promise((resolve) => setTimeout(resolve, 0));
};
// main.ts builds the assembly before some siblings exist, so construction must stay lazy.
test('createPlaybackAssembly does not call its deps while constructing', () => {
const recorder = createRecorder();
createPlaybackAssembly(makeDeps(recorder));
assert.deepEqual(recorder.names(), []);
});
test('mpv connection starts the loading OSD and flushes queued OSD notifications before session work', () => {
const { recorder, emit } = setup();
emit('connection-change', { connected: true });
const names = recorder.names();
const osdIndex = names.indexOf('maybeStartOverlayLoadingOsd');
assert.ok(osdIndex >= 0);
assert.deepEqual(recorder.calls[osdIndex]?.args, []);
assert.ok(osdIndex < names.indexOf('flushQueuedMpvOsdNotifications'));
assert.ok(
names.indexOf('flushQueuedMpvOsdNotifications') < names.indexOf('refreshMpvSessionBindings'),
);
});
test('media path change starts the loading OSD, then clears rendered subtitle state before broadcasting', (t) => {
const { recorder, deps, appState, playback, emit } = setup();
t.after(() => playback.subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh());
appState.currentMediaPath = '/videos/a.mkv';
appState.currentSubText = 'old line';
appState.currentSubtitleData = { text: 'old line', tokens: null };
appState.activeParsedSubtitleCues = [{ startTime: 1, endTime: 2, text: 'old line' }];
appState.activeParsedSubtitleSource = '/subs/a.srt';
appState.activeParsedSubtitleMediaPath = '/videos/a.mkv';
emit('time-pos-change', { time: 42 });
assert.equal(playback.getLastObservedTimePos(), 42);
let subtitleDataAtBroadcast: SubtitleData | null | undefined;
deps.overlayManager.broadcastToOverlayWindows = (channel) => {
if (channel === 'subtitle:set') subtitleDataAtBroadcast = appState.currentSubtitleData;
};
recorder.calls.length = 0;
emit('media-path-change', { path: '/videos/b.mkv' });
assert.deepEqual(recorder.calls[0], {
name: 'maybeStartOverlayLoadingOsd',
args: ['/videos/b.mkv'],
});
assert.equal(subtitleDataAtBroadcast, null);
assert.equal(appState.currentSubText, '');
assert.equal(appState.currentSubtitleData, null);
assert.deepEqual(appState.activeParsedSubtitleCues, []);
assert.equal(appState.activeParsedSubtitleSource, null);
assert.equal(appState.activeParsedSubtitleMediaPath, null);
assert.equal(playback.getLastObservedTimePos(), 0);
const resetPayload = { text: '', tokens: null };
const wsCalls = recorder.calls.filter((call) => call.name.endsWith('Ws.broadcast'));
assert.deepEqual(
wsCalls.map((call) => [call.name, call.args[0]]),
[
['subtitleWs.broadcast', resetPayload],
['annotationWs.broadcast', resetPayload],
],
);
});
test('media path change to the same YouTube video keeps its parsed cues', (t) => {
const { appState, playback, emit } = setup();
t.after(() => playback.subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh());
const cues = [{ startTime: 1, endTime: 2, text: '字幕' }];
appState.currentMediaPath = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
appState.currentSubText = '字幕';
appState.activeParsedSubtitleCues = cues;
appState.activeParsedSubtitleSource = 'youtube:ja';
appState.activeParsedSubtitleMediaPath = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
emit('media-path-change', { path: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30' });
assert.equal(appState.currentSubText, '');
assert.equal(appState.activeParsedSubtitleCues, cues);
assert.equal(appState.activeParsedSubtitleSource, 'youtube:ja');
assert.equal(
appState.activeParsedSubtitleMediaPath,
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
);
});
test('same media path update keeps autoplay fallbacks; a new path invalidates them first', (t) => {
const { recorder, appState, playback, emit } = setup();
t.after(() => playback.subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh());
appState.currentMediaPath = '/videos/a.mkv';
emit('media-path-change', { path: '/videos/a.mkv' });
assert.ok(!recorder.names().includes('autoplay.invalidateFallbacks'));
assert.ok(recorder.names().includes('tokenizationGate.updateCurrentMediaPath'));
recorder.calls.length = 0;
emit('media-path-change', { path: '/videos/b.mkv' });
const names = recorder.names();
assert.ok(names.includes('autoplay.invalidateFallbacks'));
assert.ok(
names.indexOf('autoplay.invalidateFallbacks') <
names.indexOf('tokenizationGate.updateCurrentMediaPath'),
);
});
test('subtitle change pauses prefetch without reseeking it, and resumes on a repeated line', async () => {
const ctx = await setupWithFakePrefetch();
const prefetchCalls = () => ctx.recorder.names().filter((name) => name.startsWith('prefetch.'));
// The controller already holds the empty line, so nothing will settle to resume prefetch.
ctx.emit('subtitle-change', { text: '' });
assert.deepEqual(prefetchCalls(), ['prefetch.pause', 'prefetch.resume']);
ctx.recorder.calls.length = 0;
ctx.emit('subtitle-change', { text: '新しい行' });
// A new line is still tokenizing, so its settle (not this handler) resumes prefetch.
assert.deepEqual(prefetchCalls(), ['prefetch.pause']);
await flushAsync();
});
test('annotation refresh invalidates cached tokenizations and reseeks prefetch', async () => {
const ctx = await setupWithFakePrefetch();
const generation = ctx.playback.subtitleProcessingController.getCacheGeneration();
ctx.playback.refreshCurrentSubtitleAnnotations();
assert.equal(ctx.playback.subtitleProcessingController.getCacheGeneration(), generation + 1);
// No current line: nothing will settle, so the refresh resumes prefetch itself.
assert.deepEqual(
ctx.recorder.calls.filter((call) => call.name.startsWith('prefetch.')),
[
{ name: 'prefetch.onSeek', args: [0] },
{ name: 'prefetch.resume', args: [] },
],
);
ctx.recorder.calls.length = 0;
ctx.appState.currentSubText = '現在の行';
ctx.playback.refreshCurrentSubtitleAnnotations();
assert.deepEqual(
ctx.recorder.names().filter((name) => name.startsWith('prefetch.')),
['prefetch.pause', 'prefetch.onSeek'],
);
await flushAsync();
});
test('annotation upgrade of the shown line skips the duplicate plain websocket event', () => {
const { recorder, appState, playback, emit } = setup();
// Emitted payloads always carry their timing, and an upgrade must match it.
appState.currentSubtitleData = { text: '同じ行', tokens: null, startTime: null, endTime: null };
playback.subtitleProcessingController.preCacheTokenization('同じ行', {
text: '同じ行',
tokens: [],
});
emit('subtitle-change', { text: '同じ行' });
const names = recorder.names();
assert.ok(!names.includes('subtitleWs.broadcast'));
assert.equal(names.filter((name) => name === 'annotationWs.broadcast').length, 1);
assert.equal(
recorder.calls.filter(
(call) => call.name === 'overlay.broadcast' && call.args[0] === 'subtitle:set',
).length,
1,
);
assert.deepEqual(appState.currentSubtitleData?.tokens, []);
});
test('warm tokenization releases autoplay with a warm signal before the first subtitle', async (t) => {
const { recorder, appState, playback, emit } = setup();
t.after(() => playback.subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh());
appState.currentMediaPath = '/videos/a.mkv';
appState.currentSubText = '';
emit('media-path-change', { path: '/videos/a.mkv' });
await flushAsync();
const signals = recorder.calls.filter((call) => call.name === 'autoplay.maybeSignal');
assert.deepEqual(signals.at(-1)?.args, [
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
]);
const names = recorder.names();
assert.ok(names.lastIndexOf('autoplay.flushPending') < names.lastIndexOf('autoplay.maybeSignal'));
});
test('warm tokenization releases autoplay with the current subtitle when it is shown', async (t) => {
const { recorder, appState, playback, emit } = setup();
t.after(() => playback.subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh());
const current: SubtitleData = { text: '最初の行', tokens: null };
appState.currentMediaPath = '/videos/a.mkv';
appState.currentSubText = '最初の行';
appState.currentSubtitleData = current;
emit('media-path-change', { path: '/videos/a.mkv' });
await flushAsync();
const signals = recorder.calls.filter((call) => call.name === 'autoplay.maybeSignal');
assert.deepEqual(signals.at(-1)?.args, [current, { forceWhilePaused: true }]);
});
+981
View File
@@ -0,0 +1,981 @@
import {
MpvIpcClient,
applyMpvSubtitleRenderMetricsPatch,
createTokenizerDepsRuntime,
isSubtitleAnnotationUpgrade,
resolveSanitizedSubtitleSeekCommand,
sendMpvCommandRuntime,
toSubtitleClock,
tokenizeSubtitle as tokenizeSubtitleCore,
} from '../../core/services';
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import { createAutoplaySubtitlePrimingRuntime } from '../runtime/autoplay-subtitle-priming-runtime';
import { createAutoplayTokenizationWarmRelease } from '../runtime/autoplay-tokenization-warm-release';
import { composeMpvRuntimeHandlers } from '../runtime/composers';
import { createCachedInternalSubtitleTrackExtractor } from '../runtime/internal-subtitle-extraction';
import {
markJellyfinRemotePlaybackLoaded as markJellyfinRemotePlaybackLoadedState,
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
} from '../runtime/jellyfin-remote-playback';
import { buildSubtitleTrackDiagnostics } from '../runtime/mpv-track-diagnostics';
import { createRemoteMediaPathDetector } from '../runtime/network-media-path';
import { resolveCanonicalPrimarySubtitle } from '../runtime/primary-subtitle-text';
import { createSecondarySubtitleTrackController } from '../runtime/secondary-subtitle-track';
import { shouldQuitOnMpvShutdownForTrayState } from '../runtime/startup-tray-policy';
import { createSubtitlePrefetchInitController } from '../runtime/subtitle-prefetch-init';
import {
createRefreshSubtitlePrefetchFromActiveTrackHandler,
createResolveActiveSubtitleSidebarSourceHandler,
} from '../runtime/subtitle-prefetch-runtime';
import { createPrefetchAwareSubtitleProcessingController } from '../runtime/subtitle-processing-runtime';
import { isSameYoutubeMediaPath, isYoutubeMediaPath } from '../runtime/youtube-playback';
import { MecabTokenizer } from '../../mecab-tokenizer';
import type {
MpvSubtitleRenderMetrics,
SubtitleCue,
SubtitleData,
SubtitleSidebarPlaybackState,
} from '../../types';
import type { BrowserWindow } from 'electron';
import type { ConfigService } from '../../config/service';
import type { createOverlayManager, SubtitleWebSocket } from '../../core/services';
import type {
SubtitlePrefetchService,
SubtitlePrefetchServiceDeps,
} from '../../core/services/subtitle-prefetch';
import type { Logger } from '../../logger';
import type { createMediaRuntimeService } from '../media-runtime';
import type { createAutoplayReadyGate } from '../runtime/autoplay-ready-gate';
import type { createCurrentMediaTokenizationGate } from '../runtime/current-media-tokenization-gate';
import type { createDiscordPresenceRuntime } from '../runtime/discord-presence-runtime';
import type { createImmersionMediaRuntime } from '../runtime/domains/startup';
import type { loadSubtitleSourceText } from '../runtime/internal-subtitle-extraction';
import type { createManagedLocalSubtitleSelectionRuntime } from '../runtime/local-subtitle-selection';
import type { createMediaTimingReviewRuntime } from '../runtime/media-timing-review';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
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 { 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 { YoutubeAssembly } from './youtube';
type SubtitleWsBroadcaster = Pick<SubtitleWebSocket, 'broadcast'>;
type OverlayNotificationsRuntime = ReturnType<typeof createOverlayNotificationsRuntime>;
type AnnotationRuntimeOptionId = Extract<
RuntimeOptionId,
| 'subtitle.annotation.knownWords.highlightEnabled'
| 'subtitle.annotation.nPlusOne'
| 'subtitle.annotation.jlpt'
| 'subtitle.annotation.frequency'
>;
/**
* What the playback assembly needs from the rest of `main.ts`. Everything here except the
* getters is built before the assembly; siblings built later are reached through getters, and
* construction calls none of them.
*/
export interface PlaybackAssemblyDeps {
appState: AppState;
configService: Pick<ConfigService, 'getConfig'>;
logger: Pick<Logger, 'debug' | 'info' | 'warn' | 'error'>;
overlayManager: Pick<
ReturnType<typeof createOverlayManager>,
'broadcastToOverlayWindows' | 'getVisibleOverlayVisible'
>;
subtitleWsService: SubtitleWsBroadcaster;
annotationSubtitleWsService: SubtitleWsBroadcaster;
autoplayReadyGate: Pick<
ReturnType<typeof createAutoplayReadyGate>,
| 'maybeSignalPluginAutoplayReady'
| 'flushPendingAutoplayReadySignal'
| 'invalidatePendingAutoplayReadyFallbacks'
>;
currentMediaTokenizationGate: Pick<
ReturnType<typeof createCurrentMediaTokenizationGate>,
'updateCurrentMediaPath' | 'markReady'
>;
startupOsdSequencer: Pick<
ReturnType<typeof createStartupOsdSequencer>,
| 'reset'
| 'markTokenizationReady'
| 'showTokenizationLoading'
| 'showAnnotationLoading'
| 'markAnnotationLoadingComplete'
>;
managedLocalSubtitleSelectionRuntime: Pick<
ReturnType<typeof createManagedLocalSubtitleSelectionRuntime>,
'handleMediaPathChange' | 'handleSubtitleTrackListChange'
>;
mediaTimingReviewRuntime: Pick<
ReturnType<typeof createMediaTimingReviewRuntime>,
'deferPlaybackResume' | 'cancelPlaybackResume'
>;
discordPresenceRuntime: Pick<
ReturnType<typeof createDiscordPresenceRuntime>,
'publishDiscordPresence'
>;
immersionMediaRuntime: Pick<
ReturnType<typeof createImmersionMediaRuntime>,
'syncFromCurrentMediaState'
>;
mediaRuntime: Pick<
ReturnType<typeof createMediaRuntimeService>,
'updateCurrentMediaPath' | 'updateCurrentMediaTitle'
>;
overlayNotificationsRuntime: Pick<
OverlayNotificationsRuntime,
'showConfiguredStatusNotification' | 'getConfiguredStatusNotificationType'
>;
youtube: Pick<YoutubeAssembly, 'isPlaybackActiveNow'> & {
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'>;
};
jellyfin: Pick<
JellyfinAssembly,
| 'getQuitOnDisconnectArmed'
| 'reportJellyfinRemoteStopped'
| 'reportJellyfinRemoteProgress'
| 'getActivePlayback'
| 'startJellyfinRemoteSession'
>;
anilist: Pick<
AnilistAssembly,
| 'maybeRunAnilistPostWatchUpdate'
| 'recordAnilistMediaDuration'
| 'getCurrentAnilistMediaKey'
| 'resetAnilistMediaTracking'
| 'maybeProbeAnilistDuration'
| 'ensureAnilistMediaGuess'
| 'resetAnilistMediaGuessState'
>;
characterDictionary: {
autoSync: Pick<CharacterDictionaryAssembly['autoSync'], 'scheduleSync' | 'getCurrentMediaId'>;
imageLookup: Pick<CharacterDictionaryAssembly['imageLookup'], 'get'>;
nameCandidates: Pick<CharacterDictionaryAssembly['nameCandidates'], 'get'>;
};
dictionaries: {
jlpt: Pick<DictionariesAssembly['jlpt'], 'ensureJlptDictionaryLookup'>;
frequency: Pick<DictionariesAssembly['frequency'], 'ensureFrequencyDictionaryLookup'>;
};
getSubtitleGenerationRuntime: () => Pick<
ReturnType<typeof createSubtitleGenerationRuntime>,
'handleMediaPathChange'
>;
isCharacterDictionaryEnabled: () => boolean;
/** Whether the tray icon exists; mpv shutdown keeps the app alive when it does. */
hasTray: () => boolean;
loadSubtitleSourceText: typeof loadSubtitleSourceText;
createSubtitlePrefetchService: (
serviceDeps: SubtitlePrefetchServiceDeps,
) => SubtitlePrefetchService;
requestAppQuit: () => void;
maybeStartOverlayLoadingOsd: OverlayNotificationsRuntime['maybeStartOverlayLoadingOsd'];
flushQueuedMpvOsdNotifications: OverlayNotificationsRuntime['flushQueuedMpvOsdNotifications'];
refreshMpvSessionBindings: () => Promise<void>;
ensureImmersionTrackerStarted: () => void;
restoreOverlayMpvSubtitles: () => void;
resetSubtitleSidebarEmbeddedLayoutRuntime: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
setOverlayVisible: (visible: boolean) => void;
ensureYomitanExtensionLoaded: () => Promise<unknown>;
shouldInitializeMecabForAnnotations: () => boolean;
getRuntimeBooleanOption: (id: AnnotationRuntimeOptionId, fallback: boolean) => boolean;
/** Linux fullscreen overlay refresh lives with the overlay window code in `main.ts`. */
onFullscreenChange: (fullscreen: boolean) => void;
}
export type PlaybackAssembly = ReturnType<typeof createPlaybackAssembly>;
/**
* Owns the subtitle pipeline around mpv playback: the mpv client runtime and its event
* handlers, subtitle tokenization and warmups, subtitle prefetch, the subtitle sidebar's cached
* playback state, secondary subtitles and autoplay subtitle priming. `main.ts` builds it once and
* starts warmups and the mpv client from its startup code.
*/
export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
const {
appState,
configService,
logger,
overlayManager,
subtitleWsService,
annotationSubtitleWsService,
autoplayReadyGate,
currentMediaTokenizationGate,
startupOsdSequencer,
managedLocalSubtitleSelectionRuntime,
mediaTimingReviewRuntime,
discordPresenceRuntime,
immersionMediaRuntime,
mediaRuntime,
overlayNotificationsRuntime,
youtube,
jellyfin,
anilist,
characterDictionary,
dictionaries,
loadSubtitleSourceText,
createSubtitlePrefetchService,
} = deps;
let backgroundWarmupsStarted = false;
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
const canonical = resolveCanonicalPrimarySubtitle({
liveText: payload.text,
currentTimeSec: toSubtitleClock(
Number(appState.mpvClient?.currentTimePos),
appState.mpvClient?.currentSubDelay ?? 0,
),
cues: appState.activeParsedSubtitleCues,
});
return {
...payload,
startTime: canonical?.startTime ?? appState.mpvClient?.currentSubStart ?? null,
endTime: canonical?.endTime ?? appState.mpvClient?.currentSubEnd ?? null,
};
}
function emitSubtitlePayload(
payload: SubtitleData,
options?: { resumePrefetch?: boolean },
): void {
const timedPayload = withCurrentSubtitleTiming(payload);
const currentSubtitleData = appState.currentSubtitleData;
const isAnnotationUpgrade = isSubtitleAnnotationUpgrade(currentSubtitleData, timedPayload);
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
const frequencyOptions = {
enabled: frequencyDictionary.enabled,
topX: frequencyDictionary.topX,
mode: frequencyDictionary.mode,
};
appState.currentSubtitleData = timedPayload;
overlayManager.broadcastToOverlayWindows('subtitle:set', timedPayload);
if (!isAnnotationUpgrade) {
subtitleWsService.broadcast(timedPayload, frequencyOptions);
}
annotationSubtitleWsService.broadcast(timedPayload, frequencyOptions);
autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true });
// resumePrefetch: false marks an emit that is not the end of the work for
// this line; prefetch stays paused until the subtitle processing controller
// settles so it does not compete with the on-screen line for the single
// Yomitan parser window.
if (options?.resumePrefetch !== false) {
subtitlePrefetchService?.resume();
}
}
function getCurrentAutoplaySubtitlePayload(): SubtitleData | null {
const payload = appState.currentSubtitleData;
if (!payload?.text.trim()) {
return null;
}
if (payload.text !== appState.currentSubText) {
return null;
}
return payload;
}
function signalCurrentSubtitleAutoplayReady(): void {
autoplayReadyGate.flushPendingAutoplayReadySignal();
const payload = getCurrentAutoplaySubtitlePayload();
if (payload) {
autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true });
return;
}
if (!appState.currentSubText.trim()) {
autoplayReadyGate.maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
);
}
}
const subtitleProcessingController = createPrefetchAwareSubtitleProcessingController({
tokenizeSubtitle: async (text: string) =>
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null },
emitSubtitlePayload: (payload, options) => emitSubtitlePayload(payload, options),
resumePrefetch: () => {
subtitlePrefetchService?.resume();
},
logDebug: (message) => {
logger.debug(`[subtitle-processing] ${message}`);
},
now: () => Date.now(),
});
let subtitlePrefetchService: SubtitlePrefetchService | null = null;
let lastObservedTimePos = 0;
let lastObservedPrimarySubtitleTrackId: number | null = null;
let subtitleSidebarCuesRef: SubtitleCue[] | null = null;
let subtitleSidebarCuesRevision = 0;
/**
* Cheap sidebar state polled ~12x/s while the sidebar is open. Reads only cached state, no
* mpv round-trips. `cuesRevision` bumps whenever a new parsed cue array is swapped in, so a
* re-parse of the same source still tells the renderer to fetch the full snapshot.
*/
function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState {
if (appState.activeParsedSubtitleCues !== subtitleSidebarCuesRef) {
subtitleSidebarCuesRef = appState.activeParsedSubtitleCues;
subtitleSidebarCuesRevision += 1;
}
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cuesRevision: subtitleSidebarCuesRevision,
currentTimeSec:
appState.mpvClient?.currentTimePos === undefined
? null
: toSubtitleClock(appState.mpvClient.currentTimePos, subDelay),
subDelay,
currentSubtitle: {
text: appState.currentSubText,
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
},
};
}
const SEEK_THRESHOLD_SECONDS = 3;
const EXPLICIT_SEEK_INTENT_TTL_MS = 2000;
let explicitSeekIntentExpiresAtMs = 0;
function isExplicitMpvSeekCommand(command: readonly (string | number)[]): boolean {
return command[0] === 'seek' || command[0] === 'sub-seek';
}
function getMpvPauseCommandValue(command: readonly (string | number)[]): 'yes' | 'no' | null {
if ((command[0] !== 'set_property' && command[0] !== 'set') || command[1] !== 'pause') {
return null;
}
return command[2] === 'yes' || command[2] === 'no' ? command[2] : null;
}
function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
// Overlay auto-pause releases (popup closed, hover left) must not resume playback
// behind an open timing review; the review applies them when it closes. A pause
// request during the review keeps playback paused after it closes.
const pauseValue = getMpvPauseCommandValue(rawCommand);
if (pauseValue === 'no' && mediaTimingReviewRuntime.deferPlaybackResume()) {
return;
}
if (pauseValue === 'yes') {
mediaTimingReviewRuntime.cancelPlaybackResume();
}
const command =
resolveSanitizedSubtitleSeekCommand(
rawCommand,
appState.activeParsedSubtitleCues,
appState.mpvClient?.currentTimePos ?? Number.NaN,
appState.mpvClient?.currentSubDelay ?? 0,
) ?? rawCommand;
if (isExplicitMpvSeekCommand(command)) {
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
}
sendMpvCommandRuntime(appState.mpvClient, command);
}
function consumeExplicitSeekIntent(): boolean {
const pending = explicitSeekIntentExpiresAtMs >= Date.now();
explicitSeekIntentExpiresAtMs = 0;
return pending;
}
const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
getCurrentMediaPath: () => appState.currentMediaPath,
getMpvClient: () => appState.mpvClient,
setCurrentSubText: (text) => {
appState.currentSubText = text;
},
getCurrentSubText: () => appState.currentSubText,
getCurrentSubtitleData: () => appState.currentSubtitleData,
getActiveParsedSubtitleCues: () => appState.activeParsedSubtitleCues,
setActiveParsedSubtitleMediaPath: (mediaPath) => {
appState.activeParsedSubtitleMediaPath = mediaPath;
},
subtitleProcessingController,
emitSubtitlePayload: (payload, options) => emitSubtitlePayload(payload, options),
getSubtitlePrefetchService: () => subtitlePrefetchService,
getLastObservedTimePos: () => lastObservedTimePos,
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
emitSecondarySubtitle: (text) => {
secondarySubtitleTrackController.handleLiveText(text);
},
initSubtitlePrefetch: (sourcePath, currentTimePos, sourceKey) =>
subtitlePrefetchInitController.initSubtitlePrefetch(sourcePath, currentTimePos, sourceKey),
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
logDebug: (message) => {
logger.debug(message);
},
});
const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
getCurrentService: () => subtitlePrefetchService,
setCurrentService: (service) => {
subtitlePrefetchService = service;
},
loadSubtitleSourceText,
parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename),
createSubtitlePrefetchService: (serviceDeps) => createSubtitlePrefetchService(serviceDeps),
tokenizeSubtitle: async (text) =>
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
preCacheTokenization: (text, data) => {
subtitleProcessingController.preCacheTokenization(text, data);
},
hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text),
getCacheGeneration: () => subtitleProcessingController.getCacheGeneration(),
logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message),
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
appState.activeParsedSubtitleCues = cues ?? [];
appState.activeParsedSubtitleSource = sourceKey;
if (!cues?.length) {
appState.activeParsedSubtitleMediaPath = null;
}
const mediaPath = autoplaySubtitlePrimingRuntime.getCurrentAutoplayMediaPath();
if (mediaPath && cues?.length) {
void autoplaySubtitlePrimingRuntime
.primeAutoplaySubtitleFromParsedCues(mediaPath, cues)
.catch((error) => {
logger.debug(
`[autoplay-subtitle-prime] failed to prime from parsed cues: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
}
},
});
const cachedInternalSubtitleTrackExtractor = createCachedInternalSubtitleTrackExtractor();
const detectRemoteMediaPath = createRemoteMediaPathDetector();
const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler(
{
getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg',
extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) =>
cachedInternalSubtitleTrackExtractor.extract(ffmpegPath, videoPath, track),
logDebug: (message) => logger.debug(message),
},
);
const secondarySubtitleTrackController = createSecondarySubtitleTrackController({
getMpvClient: () => appState.mpvClient,
getCurrentTimePos: () => appState.mpvClient?.currentTimePos ?? lastObservedTimePos,
resolveSubtitleSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
loadSubtitleSourceText,
parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename),
setCurrentSecondaryText: (text) => {
if (appState.mpvClient) {
appState.mpvClient.currentSecondarySubText = text;
}
},
broadcastSecondaryText: (text) => {
overlayManager.broadcastToOverlayWindows('secondary-subtitle:set', text);
},
logDebug: (message) => logger.debug(message),
logWarn: (message, error) => logger.warn(message, error),
});
const refreshSubtitlePrefetchFromActiveTrackHandler =
createRefreshSubtitlePrefetchFromActiveTrackHandler({
getMpvClient: () => appState.mpvClient,
getLastObservedTimePos: () => lastObservedTimePos,
// Remote media has no extractable on-disk track to fall back to, so a transient
// resolve miss (sid briefly 'no', a cycle onto an embedded stream track) would
// otherwise drop a working cue list for the rest of the episode.
shouldKeepExistingCuesOnMissingSource: async (videoPath) =>
isYoutubeMediaPath(videoPath) || (await detectRemoteMediaPath(videoPath)),
subtitlePrefetchInitController,
resolveActiveSubtitleSidebarSource: (input) =>
resolveActiveSubtitleSidebarSourceHandler(input),
logDebug: (message) => logger.debug(message),
logWarn: (message) => logger.warn(message),
});
const subtitlePrefetchRuntime = {
cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(),
initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch,
refreshSubtitleSidebarFromSource: (sourcePath: string, mediaPath?: string) =>
autoplaySubtitlePrimingRuntime.refreshSubtitleSidebarFromSource(sourcePath, mediaPath),
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
scheduleSubtitlePrefetchRefresh: (delayMs?: number) =>
autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(delayMs),
clearScheduledSubtitlePrefetchRefresh: () =>
autoplaySubtitlePrimingRuntime.clearScheduledSubtitlePrefetchRefresh(),
} as const;
function refreshCurrentSubtitleAnnotations(): void {
const hasCurrentSubtitle = appState.currentSubText.trim().length > 0;
if (hasCurrentSubtitle) {
subtitlePrefetchService?.pause();
}
subtitleProcessingController.invalidateTokenizationCache();
subtitlePrefetchService?.onSeek(lastObservedTimePos);
if (!subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText)) {
// Idle controller: no settle is coming to release the pause above.
subtitlePrefetchService?.resume();
}
}
let signalAutoplayReadyFromWarmTokenization: ((path: string | null | undefined) => void) | null =
null;
const {
bindMpvClientEventHandlers,
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
tokenizeSubtitle: tokenizeSubtitleRuntime,
createMecabTokenizerAndCheck,
prewarmSubtitleDictionaries,
startBackgroundWarmups,
startTokenizationWarmups,
isTokenizationWarmupReady,
} = composeMpvRuntimeHandlers<
MpvIpcClient,
ReturnType<typeof createTokenizerDepsRuntime>,
SubtitleData
>({
bindMpvMainEventHandlersMainDeps: {
appState,
getQuitOnDisconnectArmed: () =>
jellyfin.getQuitOnDisconnectArmed() || youtube.playback.getQuitOnDisconnectArmed(),
scheduleQuitCheck: (callback) => {
setTimeout(callback, 500);
},
quitApp: () => deps.requestAppQuit(),
reportJellyfinRemoteStopped: () => {
void jellyfin.reportJellyfinRemoteStopped();
},
onMpvConnected: () => {
deps.maybeStartOverlayLoadingOsd();
deps.flushQueuedMpvOsdNotifications();
secondarySubtitleTrackController.scheduleRefresh(0);
void deps.refreshMpvSessionBindings();
if (appState.sessionBindingsInitialized) {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
'subminer-reload-session-bindings',
]);
}
if (appState.currentSubText.trim()) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
},
maybeRunAnilistPostWatchUpdate: (options) => anilist.maybeRunAnilistPostWatchUpdate(options),
recordAnilistMediaDuration: (durationSec) => {
anilist.recordAnilistMediaDuration(durationSec);
},
logSubtitleTimingError: (message, error) => logger.error(message, error),
broadcastToOverlayWindows: (channel, payload) => {
overlayManager.broadcastToOverlayWindows(channel, payload);
},
onSecondarySubtitleChange: (text) => {
secondarySubtitleTrackController.handleLiveText(text);
},
getImmediateSubtitlePayload: (text) =>
subtitleProcessingController.consumeCachedSubtitle(text),
emitImmediateSubtitle: (payload) => {
emitSubtitlePayload(payload);
},
onSubtitleChange: (text) => {
// Pause only; restarting the prefetch run here would discard in-flight
// tokenization work on every line. Real seeks restart via onTimePosUpdate.
subtitlePrefetchService?.pause();
if (!subtitleProcessingController.onSubtitleChange(text)) {
// Repeat of the current text: the controller is idle, so no settle is
// coming to release the pause. Resume now instead of idling prefetch
// for the rest of the cue.
subtitlePrefetchService?.resume();
}
},
refreshDiscordPresence: () => {
discordPresenceRuntime.publishDiscordPresence();
},
logSubtitleProcessingDebug: (message: string) => logger.debug(message),
ensureImmersionTrackerInitialized: () => {
deps.ensureImmersionTrackerStarted();
},
tokenizeSubtitleForImmersion: async (text): Promise<SubtitleData | null> =>
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
updateCurrentMediaPath: (path) => {
const normalizedPath = path.trim();
deps.maybeStartOverlayLoadingOsd(normalizedPath);
const previousPath = appState.currentMediaPath?.trim() || null;
const preserveParsedSubtitleCues = isSameYoutubeMediaPath(
normalizedPath,
appState.activeParsedSubtitleMediaPath,
);
if ((normalizedPath || null) !== previousPath) {
cachedInternalSubtitleTrackExtractor.clear();
secondarySubtitleTrackController.reset();
const resetSubtitlePayload = { text: '', tokens: null };
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
const frequencyOptions = {
enabled: frequencyDictionary.enabled,
topX: frequencyDictionary.topX,
mode: frequencyDictionary.mode,
};
autoplaySubtitlePrimingRuntime.resetAutoplaySubtitlePrime();
lastObservedTimePos = 0;
appState.currentSubText = '';
appState.currentSubtitleData = null;
if (!preserveParsedSubtitleCues) {
appState.activeParsedSubtitleCues = [];
appState.activeParsedSubtitleSource = null;
appState.activeParsedSubtitleMediaPath = null;
}
overlayManager.broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
}
currentMediaTokenizationGate.updateCurrentMediaPath(path);
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
startupOsdSequencer.reset();
subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh();
if (!preserveParsedSubtitleCues) {
subtitlePrefetchRuntime.cancelPendingInit();
}
youtube.primarySubtitleNotification.handleMediaPathChange(path);
void youtube.mediaCachePlayback.handleMediaPathChange(path);
youtube.browserPlayback.handleMediaPathChange(path);
youtube.flow.handleMediaPathChange(path);
deps.getSubtitleGenerationRuntime().handleMediaPathChange(path);
if (path) {
deps.ensureImmersionTrackerStarted();
secondarySubtitleTrackController.scheduleRefresh();
void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack();
// Retry after a short delay because MPV can populate track-list after path.
subtitlePrefetchRuntime.scheduleSubtitlePrefetchRefresh(500);
}
mediaRuntime.updateCurrentMediaPath(path);
},
restoreMpvSubVisibility: () => {
deps.restoreOverlayMpvSubtitles();
},
resetSubtitleSidebarEmbeddedLayout: () => {
deps.resetSubtitleSidebarEmbeddedLayoutRuntime();
},
getCurrentAnilistMediaKey: () => anilist.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => {
anilist.resetAnilistMediaTracking(mediaKey);
},
maybeProbeAnilistDuration: (mediaKey) => {
void anilist.maybeProbeAnilistDuration(mediaKey);
},
ensureAnilistMediaGuess: (mediaKey) => {
void anilist.ensureAnilistMediaGuess(mediaKey);
},
syncImmersionMediaState: () => {
immersionMediaRuntime.syncFromCurrentMediaState();
},
signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path),
markJellyfinRemotePlaybackLoaded: (path) => {
markJellyfinRemotePlaybackLoadedState(jellyfin.getActivePlayback(), path);
},
scheduleCharacterDictionarySync: () => {
if (
!configService.getConfig().subtitleStyle.nameMatchEnabled ||
!deps.isCharacterDictionaryEnabled() ||
youtube.isPlaybackActiveNow()
) {
return;
}
characterDictionary.autoSync.scheduleSync();
},
updateCurrentMediaTitle: (title) => {
mediaRuntime.updateCurrentMediaTitle(title);
},
resetAnilistMediaGuessState: () => {
anilist.resetAnilistMediaGuessState();
},
reportJellyfinRemoteProgress: (forceImmediate) => {
void jellyfin.reportJellyfinRemoteProgress(forceImmediate);
},
consumeExplicitSeek: () => consumeExplicitSeekIntent(),
onTimePosUpdate: (time) => {
const delta = time - lastObservedTimePos;
if (subtitlePrefetchService && (delta > SEEK_THRESHOLD_SECONDS || delta < 0)) {
subtitlePrefetchService.onSeek(time);
}
lastObservedTimePos = time;
secondarySubtitleTrackController.handleTimePos(time);
},
onFullscreenChange: (fullscreen) => deps.onFullscreenChange(fullscreen),
onSubtitleTrackChange: (sid) => {
lastObservedPrimarySubtitleTrackId = sid;
logger.info('[mpv-subtitles] primary subtitle track changed', { sid });
autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh();
youtube.primarySubtitleNotification.handleSubtitleTrackChange(sid);
},
onSecondarySubtitleTrackChange: () => {
secondarySubtitleTrackController.handleTrackChange();
secondarySubtitleTrackController.scheduleRefresh(0);
},
onSecondarySubtitleDelayChange: (delay) => {
secondarySubtitleTrackController.handleDelayChange(delay);
},
onSubtitleTrackListChange: (trackList) => {
const diagnostics = buildSubtitleTrackDiagnostics(
lastObservedPrimarySubtitleTrackId,
trackList,
);
if (!diagnostics.trackListReadable) {
logger.warn(
'[mpv-subtitles] mpv reported an unreadable subtitle track list',
diagnostics,
);
} else if (diagnostics.subtitleTrackCount === 0) {
logger.warn('[mpv-subtitles] mpv reported no subtitle tracks', diagnostics);
} else {
logger.info('[mpv-subtitles] subtitle track list updated', diagnostics);
}
managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList);
secondarySubtitleTrackController.scheduleRefresh(0);
autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh();
youtube.primarySubtitleNotification.handleSubtitleTrackListChange(trackList);
},
updateSubtitleRenderMetrics: (patch) => {
updateMpvSubtitleRenderMetricsHandler(patch as Partial<MpvSubtitleRenderMetrics>);
},
syncOverlayMpvSubtitleSuppression: () => {
deps.syncOverlayMpvSubtitleSuppression();
},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: MpvIpcClient,
getSocketPath: () => appState.mpvSocketPath,
getResolvedConfig: () => configService.getConfig(),
isAutoStartOverlayEnabled: () => appState.autoStartOverlay,
setOverlayVisible: (visible: boolean) => deps.setOverlayVisible(visible),
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getReconnectTimer: () => appState.reconnectTimer,
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
appState.reconnectTimer = timer;
},
shouldAutoLoadSecondarySubTrack: (path: string) =>
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(jellyfin.getActivePlayback(), path),
shouldQuitOnMpvShutdown: () =>
shouldQuitOnMpvShutdownForTrayState({
managedPlayback: appState.initialArgs?.managedPlayback === true,
backgroundMode: appState.backgroundMode,
hasTray: deps.hasTray(),
}),
requestAppQuit: () => deps.requestAppQuit(),
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => appState.mpvSubtitleRenderMetrics,
setCurrentMetrics: (metrics) => {
appState.mpvSubtitleRenderMetrics = metrics;
},
applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch),
broadcastMetrics: () => {
// no renderer consumer for subtitle render metrics updates at present
},
},
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => appState.yomitanExt,
getYomitanSession: () => appState.yomitanSession,
getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window) => {
appState.yomitanParserWindow = window as BrowserWindow | null;
},
getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise,
setYomitanParserReadyPromise: (promise) => {
appState.yomitanParserReadyPromise = promise;
},
getYomitanParserInitPromise: () => appState.yomitanParserInitPromise,
setYomitanParserInitPromise: (promise) => {
appState.yomitanParserInitPromise = promise;
},
isKnownWord: (text, reading, options) =>
Boolean(appState.ankiIntegration?.isKnownWord(text, reading, options)),
getKnownWordTier: (text, reading, options) =>
appState.ankiIntegration?.getKnownWordTier(text, reading, options) ?? null,
recordLookup: (hit) => {
deps.ensureImmersionTrackerStarted();
appState.immersionTracker?.recordLookup(hit);
},
getKnownWordMatchMode: () =>
appState.ankiIntegration?.getKnownWordMatchMode() ??
configService.getConfig().ankiConnect.knownWords.matchMode,
getKnownWordsEnabled: () =>
deps.getRuntimeBooleanOption(
'subtitle.annotation.knownWords.highlightEnabled',
configService.getConfig().ankiConnect.knownWords.highlightEnabled,
),
getNPlusOneEnabled: () =>
deps.getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
configService.getConfig().ankiConnect.nPlusOne.enabled,
),
getMinSentenceWordsForNPlusOne: () =>
configService.getConfig().ankiConnect.nPlusOne.minSentenceWords,
getJlptLevel: (text) => appState.jlptLevelLookup(text),
getJlptEnabled: () =>
deps.getRuntimeBooleanOption(
'subtitle.annotation.jlpt',
configService.getConfig().subtitleStyle.enableJlpt,
),
getCharacterDictionaryEnabled: () =>
configService.getConfig().subtitleStyle.nameMatchEnabled &&
deps.isCharacterDictionaryEnabled() &&
!youtube.isPlaybackActiveNow(),
getNameMatchEnabled: () => configService.getConfig().subtitleStyle.nameMatchEnabled,
getNameMatchImagesEnabled: () =>
configService.getConfig().subtitleStyle.nameMatchImagesEnabled,
getCharacterNameImage: (term) => characterDictionary.imageLookup.get(term),
getCurrentCharacterDictionaryMediaId: () =>
characterDictionary.autoSync.getCurrentMediaId(),
getCharacterNameCandidates: () => characterDictionary.nameCandidates.get(),
getFrequencyDictionaryEnabled: () =>
deps.getRuntimeBooleanOption(
'subtitle.annotation.frequency',
configService.getConfig().subtitleStyle.frequencyDictionary.enabled,
),
getFrequencyDictionaryMatchMode: () =>
configService.getConfig().subtitleStyle.frequencyDictionary.matchMode,
getFrequencyRank: (text) => appState.frequencyRankLookup(text),
getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled,
getMecabTokenizer: () => appState.mecabTokenizer,
onTokenizationReady: () => {
currentMediaTokenizationGate.markReady(
appState.currentMediaPath?.trim() ||
appState.mpvClient?.currentVideoPath?.trim() ||
null,
);
startupOsdSequencer.markTokenizationReady();
},
},
createTokenizerRuntimeDeps: (tokenizerDeps) => createTokenizerDepsRuntime(tokenizerDeps),
tokenizeSubtitle: (text, tokenizerDeps) => tokenizeSubtitleCore(text, tokenizerDeps),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => appState.mecabTokenizer,
setMecabTokenizer: (tokenizer) => {
appState.mecabTokenizer = tokenizer as MecabTokenizer | null;
},
createMecabTokenizer: () => new MecabTokenizer(),
checkAvailability: async (tokenizer) => (tokenizer as MecabTokenizer).checkAvailability(),
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: () => dictionaries.jlpt.ensureJlptDictionaryLookup(),
ensureFrequencyDictionaryLookup: () =>
dictionaries.frequency.ensureFrequencyDictionaryLookup(),
showMpvOsd: (message: string) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(message),
showLoadingOsd: (message: string) => startupOsdSequencer.showAnnotationLoading(message),
showLoadedOsd: (message: string) =>
startupOsdSequencer.markAnnotationLoadingComplete(message),
shouldShowOsdNotification: () => {
const type = overlayNotificationsRuntime.getConfiguredStatusNotificationType();
return type === 'osd' || type === 'osd-system';
},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => Date.now(),
logDebug: (message) => logger.debug(message),
logWarn: (message) => logger.warn(message),
},
startBackgroundWarmupsMainDeps: {
getStarted: () => backgroundWarmupsStarted,
setStarted: (started) => {
backgroundWarmupsStarted = started;
},
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
ensureYomitanExtensionLoaded: () => deps.ensureYomitanExtensionLoaded().then(() => {}),
shouldWarmupMecab: () => {
const startupWarmups = configService.getConfig().startupWarmups;
if (startupWarmups.lowPowerMode) {
return false;
}
if (!startupWarmups.mecab) {
return false;
}
return deps.shouldInitializeMecabForAnnotations();
},
shouldWarmupYomitanExtension: () =>
configService.getConfig().startupWarmups.yomitanExtension,
shouldWarmupSubtitleDictionaries: () => {
const startupWarmups = configService.getConfig().startupWarmups;
if (startupWarmups.lowPowerMode) {
return false;
}
return startupWarmups.subtitleDictionaries;
},
shouldWarmupJellyfinRemoteSession: () => {
const startupWarmups = configService.getConfig().startupWarmups;
if (startupWarmups.lowPowerMode) {
return false;
}
return startupWarmups.jellyfinRemoteSession;
},
shouldAutoConnectJellyfinRemote: () => {
const jellyfin = configService.getConfig().jellyfin;
return (
jellyfin.enabled && jellyfin.remoteControlEnabled && jellyfin.remoteControlAutoConnect
);
},
startJellyfinRemoteSession: () => jellyfin.startJellyfinRemoteSession(),
logDebug: (message) => logger.debug(message),
},
},
});
async function tokenizeSubtitle(text: string): Promise<SubtitleData> {
if (!isTokenizationWarmupReady()) {
startupOsdSequencer.showTokenizationLoading('Loading subtitle tokenization...');
}
return await tokenizeSubtitleRuntime(text);
}
signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => isTokenizationWarmupReady(),
startTokenizationWarmups: async () => {
await startTokenizationWarmups();
},
getCurrentMediaPath: () =>
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
primeCurrentSubtitle: (mediaPath) =>
autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForAutoplay(mediaPath),
signalAutoplayReady: () => signalCurrentSubtitleAutoplayReady(),
warn: (message, error) => logger.warn(message, error),
});
tokenizeSubtitleDeferred = tokenizeSubtitle;
return {
subtitleProcessingController,
autoplaySubtitlePrimingRuntime,
secondarySubtitleTrackController,
subtitlePrefetchRuntime,
cachedInternalSubtitleTrackExtractor,
resolveActiveSubtitleSidebarSourceHandler,
withCurrentSubtitleTiming,
getSubtitleSidebarPlaybackState,
sendRendererMpvCommand,
refreshCurrentSubtitleAnnotations,
tokenizeSubtitle,
bindMpvClientEventHandlers,
createMpvClientRuntimeServiceHandler,
createMecabTokenizerAndCheck,
prewarmSubtitleDictionaries,
startBackgroundWarmups,
startTokenizationWarmups,
isTokenizationWarmupReady,
getSubtitlePrefetchService: () => subtitlePrefetchService,
getLastObservedTimePos: () => lastObservedTimePos,
getTokenizeSubtitleDeferred: () => tokenizeSubtitleDeferred,
};
}
+2 -315
View File
@@ -29,129 +29,6 @@ test('manual watched session action starts immersion tracker before marking watc
);
});
test('media path changes clear rendered subtitle state without clearing same-youtube parsed cues', () => {
const source = readMainSource();
const actionBlock = source.match(
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /appState\.currentSubText = '';/);
assert.match(actionBlock, /appState\.currentSubtitleData = null;/);
assert.match(actionBlock, /isSameYoutubeMediaPath\(/);
assert.match(actionBlock, /if \(!preserveParsedSubtitleCues\)/);
assert.match(actionBlock, /appState\.activeParsedSubtitleCues = \[\];/);
assert.match(actionBlock, /appState\.activeParsedSubtitleSource = null;/);
assert.match(actionBlock, /appState\.activeParsedSubtitleMediaPath = null;/);
assert.match(actionBlock, /lastObservedTimePos = 0;/);
assert.match(actionBlock, /broadcastToOverlayWindows\('subtitle:set',/);
assert.match(actionBlock, /subtitleWsService\.broadcast\(/);
assert.match(actionBlock, /annotationSubtitleWsService\.broadcast\(/);
assert.ok(
actionBlock.indexOf('appState.currentSubtitleData = null;') <
actionBlock.indexOf("broadcastToOverlayWindows('subtitle:set'"),
);
});
test('same media path updates do not reset autoplay ready fallback state', () => {
const source = readMainSource();
const actionBlock = source.match(
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(
actionBlock,
/annotationSubtitleWsService\.broadcast\(resetSubtitlePayload, frequencyOptions\);\s+autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);\s+\}\s+currentMediaTokenizationGate\.updateCurrentMediaPath\(path\);/,
);
});
test('mpv startup signals start overlay loading OSD before readiness work', () => {
const source = readMainSource();
const connectedBlock = source.match(
/onMpvConnected:\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n maybeRunAnilistPostWatchUpdate:/,
)?.groups?.body;
const mediaPathBlock = source.match(
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
)?.groups?.body;
const setVisibleBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(connectedBlock);
assert.ok(mediaPathBlock);
assert.ok(setVisibleBlock);
assert.match(connectedBlock, /maybeStartOverlayLoadingOsd\(\);/);
assert.match(
mediaPathBlock,
/const normalizedPath = path\.trim\(\);\s+maybeStartOverlayLoadingOsd\(normalizedPath\);/,
);
assert.match(setVisibleBlock, /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);/);
assert.match(
source,
/function toggleVisibleOverlay\(\): void \{[\s\S]*?else \{\s+maybeStartOverlayLoadingOsd\(\);/,
);
assert.match(
source,
/function setOverlayVisible\(visible: boolean\): void \{[\s\S]*?if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);/,
);
});
test('manual visible overlay toggles only release current-media autoplay when hiding', () => {
const source = readMainSource();
const actionBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(
actionBlock,
/if \(!nextVisible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
);
});
test('all visible overlay hide paths clear stale overlay input state', () => {
const source = readMainSource();
const setVisibleBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const toggleBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const setOverlayBlock = source.match(
/function setOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(setVisibleBlock);
assert.ok(toggleBlock);
assert.ok(setOverlayBlock);
assert.match(
setVisibleBlock,
/if \(!visible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);[\s\S]*?resetVisibleOverlayInputState\(\);/,
);
assert.match(
toggleBlock,
/if \(!nextVisible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);[\s\S]*?resetVisibleOverlayInputState\(\);/,
);
assert.match(
setOverlayBlock,
/if \(!visible\) \{[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?resetVisibleOverlayInputState\(\);[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
);
// Hiding must also dismiss the loading OSD, or it lingers over mpv.
assert.match(
setVisibleBlock,
/if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
);
assert.match(
toggleBlock,
/if \(!nextVisible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
);
assert.match(
setOverlayBlock,
/if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
);
});
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(
@@ -169,30 +46,6 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
);
});
test('subtitle change pauses prefetch without restarting its run before tokenizing current line', () => {
const source = readMainSource();
const actionBlock = source.match(
/onSubtitleChange:\s*\(text\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n refreshDiscordPresence:/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /subtitlePrefetchService\?\.pause\(\);/);
// Restarting the run per line (onSeek) discards in-flight prefetch work;
// only real seeks restart via onTimePosUpdate.
assert.doesNotMatch(actionBlock, /subtitlePrefetchService\?\.onSeek\(/);
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\)/);
assert.ok(
actionBlock.indexOf('subtitlePrefetchService?.pause();') <
actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text)'),
);
// A repeated subtitle emits nothing, so the pause has to be released here or
// prefetching idles until the next distinct line.
assert.match(
actionBlock,
/if \(!subtitleProcessingController\.onSubtitleChange\(text\)\) \{[\s\S]*?subtitlePrefetchService\?\.resume\(\);/,
);
});
test('autoplay subtitle prime emits cached annotations and avoids raw fallback overlay flashes', () => {
const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
const actionBlock = source.match(
@@ -241,28 +94,12 @@ test('startup autoplay release is tied to visible overlay measurement readiness'
assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
});
test('visible overlay content-ready does not tokenize before first measurement', () => {
test('visible overlay first measurement flushes autoplay before scheduling the first-paint refresh', () => {
const source = readMainSource();
const contentReadyBlock = source.match(
/onWindowContentReady:\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
const measurementBlock = source.match(
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(contentReadyBlock);
assert.doesNotMatch(contentReadyBlock, /subtitleProcessingController\.refreshCurrentSubtitle/);
assert.match(contentReadyBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
assert.match(contentReadyBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
assert.ok(
contentReadyBlock.indexOf('overlayVisibilityRuntime.updateVisibleOverlayVisibility();') <
contentReadyBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
);
assert.ok(
contentReadyBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();') <
contentReadyBlock.indexOf('autoplayReadyGate.flushPendingAutoplayReadySignal();'),
);
assert.ok(measurementBlock);
assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
assert.match(measurementBlock, /scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint\(\)/);
@@ -318,35 +155,6 @@ test('subtitle sidebar open state is restored for replacement visible overlay wi
assert.match(depsBlock, /subtitleSidebarRequestedOpen/);
});
test('warm tokenization release can signal readiness before the first subtitle appears', () => {
const source = readMainSource();
const warmReleaseBlock = source.match(
/signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease\(\{(?<body>[\s\S]*?)\n\}\);/,
)?.groups?.body;
const signalBlock = source.match(
/function signalCurrentSubtitleAutoplayReady\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const currentPayloadBlock = source.match(
/function getCurrentAutoplaySubtitlePayload\(\): SubtitleData \| null \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(warmReleaseBlock);
assert.match(
warmReleaseBlock,
/signalAutoplayReady: \(\) => signalCurrentSubtitleAutoplayReady\(\)/,
);
assert.ok(signalBlock);
assert.match(signalBlock, /const payload = getCurrentAutoplaySubtitlePayload\(\);/);
assert.match(signalBlock, /if \(payload\) \{/);
assert.match(signalBlock, /if \(!appState\.currentSubText\.trim\(\)\) \{/);
assert.match(signalBlock, /text: '__warm__'/);
assert.ok(currentPayloadBlock);
assert.match(currentPayloadBlock, /appState\.currentSubtitleData/);
assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/);
});
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
const source = readSource('src/main/runtime/stats-server-runtime.ts');
const statsServerConfigBlock = source.match(
@@ -365,104 +173,14 @@ test('stats server Yomitan note creation honors configured Anki server override
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
});
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
const source = readMainSource();
test('visible overlay input reset clears the visible content measurement', () => {
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
const actionBlock = source.match(
/const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime\(\{[\s\S]*?createWindow: \(\) => \{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
const resetBlock = runtimeSource.match(
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body;
assert.ok(actionBlock);
assert.ok(resetBlock);
assert.match(actionBlock, /resetVisibleOverlayInputState\(\);/);
assert.match(resetBlock, /overlayContentMeasurementStore\.clear\('visible'\);/);
assert.ok(
actionBlock.indexOf('resetVisibleOverlayInputState();') <
actionBlock.indexOf('createMainWindow();'),
);
});
test('Linux visible overlay recreation avoids display fallback before tracked geometry exists', () => {
const source = readMainSource();
const actionBlock = source.match(
/const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime\(\{[\s\S]*?refreshWindow: \(\) => \{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(
actionBlock,
/const trackedGeometry = overlayGeometryRuntime\.getCurrentTrackedOverlayGeometry\(\);/,
);
assert.match(
actionBlock,
/if \(trackedGeometry\) overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/,
);
assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/);
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
});
test('subtitle annotation updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
const source = readMainSource();
const actionBlock = source.match(
/function refreshCurrentSubtitleAnnotations\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /subtitleProcessingController\.invalidateTokenizationCache\(\);/);
assert.match(actionBlock, /subtitlePrefetchService\?\.onSeek\(lastObservedTimePos\);/);
assert.match(
actionBlock,
/if \(!subtitleProcessingController\.refreshCurrentSubtitle\(appState\.currentSubText\)\) \{[\s\S]*?subtitlePrefetchService\?\.resume\(\);/,
);
assert.ok(
actionBlock.indexOf('subtitleProcessingController.invalidateTokenizationCache();') <
actionBlock.indexOf(
'subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText)',
),
);
});
test('annotation upgrades skip the duplicate basic websocket event', () => {
const source = readMainSource();
const emitBlock = source.match(
/function emitSubtitlePayload\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(emitBlock);
assert.match(
emitBlock,
/const isAnnotationUpgrade = isSubtitleAnnotationUpgrade\(currentSubtitleData, timedPayload\);/,
);
assert.match(
emitBlock,
/if \(!isAnnotationUpgrade\) \{\s+subtitleWsService\.broadcast\(timedPayload, frequencyOptions\);\s+\}/,
);
assert.equal(
(emitBlock.match(/overlayManager\.broadcastToOverlayWindows\('subtitle:set'/g) ?? []).length,
1,
);
assert.equal(
(
emitBlock.match(
/annotationSubtitleWsService\.broadcast\(timedPayload, frequencyOptions\)/g,
) ?? []
).length,
1,
);
});
test('mpv connection flushes queued configured OSD notifications', () => {
const source = readMainSource();
const connectedBlock = source.match(
/onMpvConnected:\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n maybeRunAnilistPostWatchUpdate:/,
)?.groups?.body;
assert.ok(connectedBlock);
assert.match(source, /flushQueuedMpvOsdNotifications/);
assert.match(connectedBlock, /flushQueuedMpvOsdNotifications\(\);/);
});
test('Linux visible overlay startup reapplies passive passthrough after input reset', () => {
@@ -498,37 +216,6 @@ test('Linux visible overlay startup reapplies passive passthrough after input re
);
});
test('Linux visible overlay show starts input grace before first measurement', () => {
const source = readMainSource();
const setVisibleBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const toggleBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const setOverlayBlock = source.match(
/function setOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
for (const block of [setVisibleBlock, toggleBlock, setOverlayBlock]) {
assert.ok(block);
const resetIndex = block.indexOf(
'visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer();',
);
const graceIndex = block.indexOf(
'visibleOverlayInteractionRuntime.startLinuxVisibleOverlayStartupInputGrace();',
);
const primeIndex = block.indexOf(
'void autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay();',
);
assert.ok(resetIndex >= 0);
assert.ok(graceIndex >= 0);
assert.ok(primeIndex >= 0);
assert.ok(resetIndex < graceIndex);
assert.ok(graceIndex < primeIndex);
}
});
test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => {
const source = readSource('src/main/runtime/overlay-geometry-runtime.ts');
const afterBoundsBlock = source.match(