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

+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(