refactor(main): move plugins, session actions and subtitle generation into assemblies (#327)

* refactor(main): build overlay geometry and pointer interaction in the overlay assembly

Inject Electron's screen (and the stats-window promotion) into the overlay geometry and
visible-overlay interaction runtimes so they load under bun test, then construct both in
createOverlayAssembly together with the overlay visibility composer and the mpv subtitle
suppression handlers. Replace the three regex tests that pinned the moved Linux input and
shape code with behaviour tests on the runtimes.

* refactor(main): move the autoplay-ready gate into the playback assembly

The autoplay-ready gate, its post-warm subtitle prime, the current-media tokenization gate and
managed local subtitle selection all read playback state, and playback signals through the
gate, so createPlaybackAssembly now builds them. The overlay reaches the gate through a getter.

Replace the last four regex guards with behaviour tests: the gate holds the release until the
visible overlay has measured the line and ignores untokenized lines during warmup (playback),
cached autoplay priming never paints raw first and the sidebar media path is tagged only after
prefetch loads (priming runtime), and dashboard mining overrides the Hachidori Anki server only
when the proxy is on (stats server runtime). main-wiring.test.ts is now empty and removed.

* refactor(main): move the plugin host, panel and windows into a plugins assembly

* refactor(main): move session action dispatch and renderer mpv commands into an assembly

createSessionActionsAssembly owns the session action dispatcher, the secondary-subtitle mode cycle, queueing the clipboard's video, and the mpv commands the overlay renderer sends. The runtime option cycle both paths used is now one function.

* refactor(main): move Whisper subtitle generation into an assembly

* refactor(main): resolve subtitle annotation options in a runtime module

The runtime-option-over-config resolution that the texthooker page and the MeCab startup decision share moves out of main.ts into createAnnotationOptions.

* test(main): cover a non-boolean runtime annotation option value

* docs: describe the plugins, session actions and subtitle generation assemblies

Update ownership for overlay geometry and pointer interaction, the autoplay-ready gate, and annotation options; point the overlay priming entry points at their new modules; the main-wiring regex debt is gone.

* build: export MpvClientEvents so declaration emit can name the plugins assembly's return type
This commit is contained in:
sudacode authored and GitHub committed 2026-11-04 21:54:49 -08:00
1 parent 14ef7e823c
commit f89f78b861
28 files changed
+2005 -1147

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ const DEFAULT_WINDOW_HEIGHT = 720;
export const PLUGIN_DEACTIVATE_TIMEOUT_MS = 2_000;
export const PLUGIN_ACTIVATE_TIMEOUT_MS = 10_000;
interface MpvClientEvents {
export interface MpvClientEvents {
currentVideoPath?: string;
currentMediaTitle?: string | null;
on: <E extends PluginMpvEventName>(
+95 -691
View File
File diff suppressed because it is too large. Load diff
+78 -52
View File
@@ -3,6 +3,7 @@ import test, { type TestContext } from 'node:test';
import type { BrowserWindow } from 'electron';
import type { HyprlandPlacementStatus } from '../../core/services/hyprland-window-placement';
import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
import { stopLinuxOverlayZOrderKeepAliveLoop } from '../runtime/linux-overlay-zorder-keepalive';
import { createAppState } from '../state';
import { createOverlayAssembly, type OverlayAssemblyDeps } from './overlay';
@@ -73,26 +74,36 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
}),
setOverlayWindowBounds: spy('overlay.setOverlayWindowBounds', () => NO_PLACEMENT),
setModalWindowBounds: spy('overlay.setModalWindowBounds', () => NO_PLACEMENT),
getOverlayWindows: spy('overlay.getOverlayWindows', () => []),
broadcastToOverlayWindows: spy('overlay.broadcast', noop),
},
overlayModalInputState: { getModalInputExclusive: spy('modal.getInputExclusive', () => false) },
overlayModalRuntime: {
notifyOverlayModalOpened: spy('modal.notifyOpened', noop),
handleOverlayModalClosed: spy('modal.handleClosed', noop),
sendToActiveOverlayWindow: spy('modal.sendToActive', () => false),
openRuntimeOptionsPalette: spy('modal.openRuntimeOptionsPalette', noop),
},
overlayContentMeasurementStore: {
report: spy('measurement.report', () =>
state.measurementAccepted ? ACCEPTED_MEASUREMENT : null,
),
clear: spy('measurement.clear', noop),
getLatestByLayer: spy('measurement.getLatest', () => null),
},
autoplayReadyGate: {
getAutoplayReadyGate: spy('getAutoplayReadyGate', () => ({
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');
}),
getDisplayMatching: spy('screen.getDisplayMatching', (): never => {
throw new Error('display matching used');
}),
screenToDipPoint: spy('screen.screenToDipPoint', (point: { x: number; y: number }) => point),
},
isDev: false,
createOverlayWindowCore: spy('createOverlayWindowCore', (_kind, options): BrowserWindow => {
@@ -101,47 +112,6 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
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),
setVisibleOverlayInteractionActive: spy('interaction.setActive', noop),
setLinuxOverlayInteractiveHint: spy('interaction.setLinuxInteractiveHint', noop),
applyLinuxOverlayInputShapeFromLatestMeasurement: spy(
'interaction.applyLinuxShape',
() => false,
),
tickLinuxOverlayPointerInteractionNow: spy('interaction.tickLinuxNow', noop),
tickWindowsOverlayPointerInteractionNow: spy('interaction.tickWindowsNow', noop),
getWindowsNativeWindowHandleNumber: spy('interaction.getWindowsHandle', () => 0),
primeLinuxOverlayPointerInteractionAfterFirstMeasurement: spy(
'interaction.primePointerAfterFirstMeasurement',
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),
@@ -152,10 +122,9 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
flushQueuedOverlayNotifications: spy('notifications.flushQueued', noop),
maybeStartOverlayLoadingOsd: spy('notifications.maybeStartLoadingOsd', noop),
})),
getOverlayVisibilityComposer: spy('getOverlayVisibilityComposer', () => ({
broadcastRuntimeOptionsChanged: spy('composer.broadcastRuntimeOptions', noop),
setOverlayDebugVisualizationEnabled: spy('composer.setDebugVisualization', noop),
})),
getRuntimeOptionsState: spy('getRuntimeOptionsState', () => []),
getOverlayForegroundSeparateWindows: spy('getOverlayForegroundSeparateWindows', () => []),
promoteStatsOverlayAbovePlayback: spy('promoteStatsOverlayAbovePlayback', noop),
getMediaTimingReviewRuntime: spy('getMediaTimingReviewRuntime', () => ({
dispose: spy('mediaTimingReview.dispose', resolved),
})),
@@ -176,8 +145,6 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
})),
setSubtitleSidebarRequestedOpen: spy('setSubtitleSidebarRequestedOpen', noop),
stealAppFocus: spy('stealAppFocus', noop),
ensureOverlayMpvSubtitlesHidden: spy('ensureOverlayMpvSubtitlesHidden', resolved),
syncOverlayMpvSubtitleSuppression: spy('syncOverlayMpvSubtitleSuppression', noop),
initializeOverlayRuntime: spy('initializeOverlayRuntime', noop),
};
return { deps, windowOptions };
@@ -201,7 +168,61 @@ function setup(t: TestContext, options: { platform?: NodeJS.Platform; visible?:
};
const { deps, windowOptions } = makeDeps(recorder, state);
const overlay = createOverlayAssembly(deps);
return { recorder, state, overlay, windowOptions };
// Building the assembly on Linux starts the shared z-order keep-alive loop.
t.after(() => stopLinuxOverlayZOrderKeepAliveLoop());
stubRuntimeMethods(recorder, state, overlay);
return { recorder, state, overlay, windowOptions, deps };
}
/**
* Replaces the assembly's own pointer-interaction and geometry methods with recording stubs,
* so these tests pin the order of the assembly's calls into them, not the runtimes' internals.
* The assembly looks these methods up at call time, so stubbing after construction works.
*/
function stubRuntimeMethods(recorder: Recorder, state: FixtureState, overlay: Overlay): void {
const { spy } = recorder;
const noop = () => {};
Object.assign(overlay.interaction, {
getLinuxOverlayInputShapeActive: spy('interaction.getLinuxInputShapeActive', () => false),
getMacOSVisibleOverlayForegroundProbeActive: spy('interaction.getMacProbe', () => false),
getVisibleOverlayInteractionActive: spy('interaction.getActive', () => false),
setVisibleOverlayInteractionActive: spy('interaction.setActive', noop),
setLinuxOverlayInteractiveHint: spy('interaction.setLinuxInteractiveHint', noop),
applyLinuxOverlayInputShapeFromLatestMeasurement: spy(
'interaction.applyLinuxShape',
() => false,
),
tickLinuxOverlayPointerInteractionNow: spy('interaction.tickLinuxNow', noop),
tickWindowsOverlayPointerInteractionNow: spy('interaction.tickWindowsNow', noop),
getWindowsNativeWindowHandleNumber: spy('interaction.getWindowsHandle', () => 0),
primeLinuxOverlayPointerInteractionAfterFirstMeasurement: spy(
'interaction.primePointerAfterFirstMeasurement',
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),
} satisfies Partial<Overlay['interaction']>);
Object.assign(overlay.geometry, {
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', noop),
} satisfies Partial<Overlay['geometry']>);
}
/** Asserts each name was called, in this relative order. */
@@ -341,8 +362,13 @@ test('visible overlay content-ready flushes notifications and autoplay without t
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 });
const { recorder, state, overlay, deps } = setup(t, { platform: 'linux', visible: true });
state.trackedGeometry = trackedGeometry;
deps.appState.mpvClient = {
connected: true,
requestProperty: async () => 'yes',
setSubVisibility: recorder.spy('mpv.setSubVisibility', () => {}),
} as unknown as typeof deps.appState.mpvClient;
// An opaque token: the assembly only hands the created window to the overlay manager.
state.window = {} as BrowserWindow;
@@ -353,7 +379,7 @@ for (const trackedGeometry of [null, { x: 10, y: 20, width: 1280, height: 720 }]
'interaction.resetInputState',
'createOverlayWindowCore',
'geometry.getCurrentTrackedOverlayGeometry',
'ensureOverlayMpvSubtitlesHidden',
'mpv.setSubVisibility',
]);
const boundsCalls = recorder.calls.filter(
(call) => call.name === 'overlay.setOverlayWindowBounds',
+245 -109
View File
@@ -1,8 +1,11 @@
import type { BrowserWindow, Screen, Session } from 'electron';
import {
broadcastRuntimeOptionsChangedRuntime,
type createOverlayContentMeasurementStore,
type createOverlayWindow as createOverlayWindowCoreFn,
sendMpvCommandRuntime,
setMpvSubVisibilityRuntime,
setOverlayDebugVisualizationEnabledRuntime,
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
type createOverlayManager,
} from '../../core/services';
@@ -14,7 +17,7 @@ import type { createOverlayShortcutsRuntimeService } from '../overlay-shortcuts-
import type { OverlayModalRuntime } from '../overlay-runtime';
import { createOverlayVisibilityRuntimeService } from '../overlay-visibility-runtime';
import type { createAutoplayReadyGate } from '../runtime/autoplay-ready-gate';
import type { composeOverlayVisibilityRuntime } from '../runtime/composers';
import { composeOverlayVisibilityRuntime } from '../runtime/composers';
import { createOverlayVisibilityRuntime } from '../runtime/domains/overlay';
import {
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
@@ -27,10 +30,14 @@ import { focusMacOSOverlayWindow } from '../runtime/macos-overlay-window-focus';
import type { createMediaTimingReviewRuntime } from '../runtime/media-timing-review';
import type { createJimakuAutoOpenRuntime } from '../runtime/jimaku-auto-open';
import type { createOverlayModalInputState } from '../runtime/overlay-modal-input-state';
import type { createOverlayGeometryRuntime } from '../runtime/overlay-geometry-runtime';
import {
createEnsureOverlayMpvSubtitlesHiddenHandler,
createRestoreOverlayMpvSubtitlesHandler,
} from '../runtime/overlay-mpv-sub-visibility';
import { 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 { createVisibleOverlayInteractionRuntime } from '../runtime/visible-overlay-interaction-runtime';
import type { AppState } from '../state';
import type { PlaybackAssembly } from './playback';
import type { IpcSlice } from './slices';
@@ -55,6 +62,8 @@ export interface OverlayAssemblyDeps {
| 'setVisibleOverlayVisible'
| 'setOverlayWindowBounds'
| 'setModalWindowBounds'
| 'getOverlayWindows'
| 'broadcastToOverlayWindows'
>;
overlayModalInputState: Pick<
ReturnType<typeof createOverlayModalInputState>,
@@ -62,55 +71,35 @@ export interface OverlayAssemblyDeps {
>;
overlayModalRuntime: Pick<
OverlayModalRuntime,
'notifyOverlayModalOpened' | 'handleOverlayModalClosed'
| 'notifyOverlayModalOpened'
| 'handleOverlayModalClosed'
| 'sendToActiveOverlayWindow'
| 'openRuntimeOptionsPalette'
>;
overlayContentMeasurementStore: Pick<
ReturnType<typeof createOverlayContentMeasurementStore>,
'report'
'report' | 'clear' | 'getLatestByLayer'
>;
autoplayReadyGate: Pick<
getAutoplayReadyGate: () => Pick<
ReturnType<typeof createAutoplayReadyGate>,
'markCurrentMediaAutoplayReady' | 'flushPendingAutoplayReadySignal'
>;
screen: Pick<Screen, 'getCursorScreenPoint' | 'getDisplayNearestPoint'>;
screen: Pick<
Screen,
'getCursorScreenPoint' | 'getDisplayNearestPoint' | 'getDisplayMatching' | 'screenToDipPoint'
>;
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'
| 'setVisibleOverlayInteractionActive'
| 'setLinuxOverlayInteractiveHint'
| 'applyLinuxOverlayInputShapeFromLatestMeasurement'
| 'tickLinuxOverlayPointerInteractionNow'
| 'tickWindowsOverlayPointerInteractionNow'
| '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'
>;
/** Settings/setup windows the overlay must not raise above. */
getOverlayForegroundSeparateWindows: () => BrowserWindow[];
/** Keeps the stats window above playback after the overlay re-asserts its level. */
promoteStatsOverlayAbovePlayback: () => void;
getOverlayNotifications: () => Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
| 'showOverlayLoadingStatusNotification'
@@ -118,9 +107,11 @@ export interface OverlayAssemblyDeps {
| 'flushQueuedOverlayNotifications'
| 'maybeStartOverlayLoadingOsd'
>;
getOverlayVisibilityComposer: () => Pick<
ReturnType<typeof composeOverlayVisibilityRuntime>,
'broadcastRuntimeOptionsChanged' | 'setOverlayDebugVisualizationEnabled'
/** Runtime option snapshot broadcast to overlay windows when an option changes. */
getRuntimeOptionsState: () => ReturnType<
Parameters<
typeof composeOverlayVisibilityRuntime
>[0]['broadcastRuntimeOptionsChangedMainDeps']['getRuntimeOptionsState']
>;
getMediaTimingReviewRuntime: () => Pick<
ReturnType<typeof createMediaTimingReviewRuntime>,
@@ -146,20 +137,19 @@ export interface OverlayAssemblyDeps {
setSubtitleSidebarRequestedOpen: (open: boolean) => void;
/** Brings SubMiner to the foreground (`app.focus({ steal: true })`). */
stealAppFocus: () => void;
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.
* Owns overlay core: the visible/modal windows, their visibility, geometry and pointer
* interaction, the Linux fullscreen window mode and refresh burst, mpv subtitle suppression
* while the overlay shows, and the show/hide paths. `main.ts` builds it once and reaches the
* runtimes through the result; overlay bootstrap stays in the startup assembly.
*/
export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
const { appState, logger, overlayManager, overlayModalInputState, autoplayReadyGate } = deps;
const { appState, logger, overlayManager, overlayModalInputState } = deps;
let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null =
null;
@@ -169,14 +159,14 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
getWindow: () => overlayManager.getMainWindow(),
clearWindow: () => overlayManager.setMainWindow(null),
createWindow: () => {
deps.getVisibleOverlayInteraction().resetVisibleOverlayInputState();
interaction.resetVisibleOverlayInputState();
createMainWindow();
},
refreshWindow: () => {
const trackedGeometry = deps.getOverlayGeometry().getCurrentTrackedOverlayGeometry();
const trackedGeometry = geometry.getCurrentTrackedOverlayGeometry();
if (trackedGeometry) overlayManager.setOverlayWindowBounds(trackedGeometry);
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
void deps.ensureOverlayMpvSubtitlesHidden();
void ensureOverlayMpvSubtitlesHidden();
if (appState.currentSubText.trim()) {
deps.getSubtitleProcessingController().refreshCurrentSubtitle(appState.currentSubText);
}
@@ -195,31 +185,28 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getForceMousePassthrough: () => appState.statsOverlayVisible,
getNonNativeInputRegionActive: () =>
process.platform === 'linux' &&
deps.getVisibleOverlayInteraction().getLinuxOverlayInputShapeActive(),
process.platform === 'linux' && interaction.getLinuxOverlayInputShapeActive(),
getSuspendVisibleOverlay: () => appState.statsOverlayVisible,
getOverlayInteractionActive: () =>
deps.getVisibleOverlayInteraction().getVisibleOverlayInteractionActive(),
getOverlayInteractionActive: () => interaction.getVisibleOverlayInteractionActive(),
getWindowTracker: () => appState.windowTracker,
getMacOSForegroundProbeActive: () =>
deps.getVisibleOverlayInteraction().getMacOSVisibleOverlayForegroundProbeActive(),
getMacOSForegroundProbeActive: () => interaction.getMacOSVisibleOverlayForegroundProbeActive(),
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown: boolean) => {
appState.trackerNotReadyWarningShown = shown;
},
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.getOverlayGeometry().updateVisibleOverlayBounds(geometry),
updateVisibleOverlayBounds: (bounds: WindowGeometry) =>
geometry.updateVisibleOverlayBounds(bounds),
ensureOverlayWindowLevel: (window) => {
deps.getOverlayGeometry().ensureOverlayWindowLevel(window);
geometry.ensureOverlayWindowLevel(window);
},
syncWindowsOverlayToMpvZOrder: (_window) => {
deps.getVisibleOverlayInteraction().requestWindowsVisibleOverlayZOrderSync();
interaction.requestWindowsVisibleOverlayZOrderSync();
},
syncPrimaryOverlayWindowLayer: (layer) => {
deps.getOverlayGeometry().syncPrimaryOverlayWindowLayer(layer);
geometry.syncPrimaryOverlayWindowLayer(layer);
},
enforceOverlayLayerOrder: () => {
deps.getOverlayGeometry().enforceOverlayLayerOrder();
geometry.enforceOverlayLayerOrder();
},
syncOverlayShortcuts: () => {
deps.getOverlayShortcuts().syncOverlayShortcuts();
@@ -254,16 +241,14 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
return existingWindow;
}
const window = createModalWindowHandler();
overlayManager.setModalWindowBounds(deps.getOverlayGeometry().getCurrentOverlayGeometry());
overlayManager.setModalWindowBounds(geometry.getCurrentOverlayGeometry());
return window;
}
function createMainWindow(): BrowserWindow {
const window = createMainWindowHandler();
if (process.platform === 'win32') {
const overlayHwnd = deps
.getVisibleOverlayInteraction()
.getWindowsNativeWindowHandleNumber(window);
const overlayHwnd = interaction.getWindowsNativeWindowHandleNumber(window);
if (!ensureWindowsOverlayTransparency(overlayHwnd)) {
logger.warn('Failed to eagerly extend Windows overlay transparency via koffi');
}
@@ -292,12 +277,10 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
createOverlayWindowDeps: {
createOverlayWindowCore: (kind, options) => deps.createOverlayWindowCore(kind, options),
isDev: deps.isDev,
ensureOverlayWindowLevel: (window) =>
deps.getOverlayGeometry().ensureOverlayWindowLevel(window),
onRuntimeOptionsChanged: () =>
deps.getOverlayVisibilityComposer().broadcastRuntimeOptionsChanged(),
ensureOverlayWindowLevel: (window) => geometry.ensureOverlayWindowLevel(window),
onRuntimeOptionsChanged: () => visibilityComposer.broadcastRuntimeOptionsChanged(),
setOverlayDebugVisualizationEnabled: (enabled) =>
deps.getOverlayVisibilityComposer().setOverlayDebugVisualizationEnabled(enabled),
visibilityComposer.setOverlayDebugVisualizationEnabled(enabled),
isOverlayVisible: (windowKind) =>
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
getYomitanSession: () => deps.getYomitanSession(),
@@ -308,10 +291,8 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
shouldRunLinuxOverlayZOrderKeepAlive() &&
linuxOverlayModeRuntime.fullscreen &&
linuxOverlayModeRuntime.mode === 'fullscreen-override',
onVisibleWindowBlurred: () =>
deps.getVisibleOverlayInteraction().scheduleVisibleOverlayBlurRefresh(),
onVisibleWindowFocused: () =>
deps.getVisibleOverlayInteraction().requestLinuxOverlayZOrderFollow(),
onVisibleWindowBlurred: () => interaction.scheduleVisibleOverlayBlurRefresh(),
onVisibleWindowFocused: () => interaction.requestLinuxOverlayZOrderFollow(),
onWindowDidFinishLoad: () => {
deps.getOverlayNotifications().flushQueuedOverlayNotifications();
},
@@ -319,10 +300,8 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification();
deps.getOverlayNotifications().flushQueuedOverlayNotifications();
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
deps
.getVisibleOverlayInteraction()
.primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
autoplayReadyGate.flushPendingAutoplayReadySignal();
interaction.primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
deps.getAutoplayReadyGate().flushPendingAutoplayReadySignal();
},
onWindowClosed: (windowKind, window) => {
if (windowKind === 'visible') {
@@ -371,22 +350,22 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
ensureOverlayWindowsReadyForVisibilityActions();
if (!visible) {
deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification();
autoplayReadyGate.markCurrentMediaAutoplayReady();
deps.getAutoplayReadyGate().markCurrentMediaAutoplayReady();
deps.getAutoplaySubtitlePriming().cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
deps.getVisibleOverlayInteraction().resetVisibleOverlayInputState();
interaction.resetVisibleOverlayInputState();
}
if (visible) {
deps.getOverlayNotifications().maybeStartOverlayLoadingOsd();
deps.getVisibleOverlayInteraction().resetLinuxVisibleOverlayStartupInputPrimer();
deps.getVisibleOverlayInteraction().startLinuxVisibleOverlayStartupInputGrace();
deps.getVisibleOverlayInteraction().restoreVisibleOverlayWindowShapeForShow();
void deps.ensureOverlayMpvSubtitlesHidden();
interaction.resetLinuxVisibleOverlayStartupInputPrimer();
interaction.startLinuxVisibleOverlayStartupInputGrace();
interaction.restoreVisibleOverlayWindowShapeForShow();
void ensureOverlayMpvSubtitlesHidden();
void deps.getAutoplaySubtitlePriming().primeCurrentSubtitleForVisibleOverlay();
}
setVisibleOverlayVisibleHandler(visible);
notifyMpvPluginVisibleOverlayVisibility(visible);
deps.syncOverlayMpvSubtitleSuppression();
syncOverlayMpvSubtitleSuppression();
}
function toggleVisibleOverlay(): void {
@@ -394,41 +373,41 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
const nextVisible = !overlayManager.getVisibleOverlayVisible();
if (!nextVisible) {
deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification();
autoplayReadyGate.markCurrentMediaAutoplayReady();
deps.getAutoplayReadyGate().markCurrentMediaAutoplayReady();
deps.getAutoplaySubtitlePriming().cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
deps.getVisibleOverlayInteraction().resetVisibleOverlayInputState();
interaction.resetVisibleOverlayInputState();
} else {
deps.getOverlayNotifications().maybeStartOverlayLoadingOsd();
deps.getVisibleOverlayInteraction().resetLinuxVisibleOverlayStartupInputPrimer();
deps.getVisibleOverlayInteraction().startLinuxVisibleOverlayStartupInputGrace();
deps.getVisibleOverlayInteraction().restoreVisibleOverlayWindowShapeForShow();
void deps.ensureOverlayMpvSubtitlesHidden();
interaction.resetLinuxVisibleOverlayStartupInputPrimer();
interaction.startLinuxVisibleOverlayStartupInputGrace();
interaction.restoreVisibleOverlayWindowShapeForShow();
void ensureOverlayMpvSubtitlesHidden();
void deps.getAutoplaySubtitlePriming().primeCurrentSubtitleForVisibleOverlay();
}
toggleVisibleOverlayHandler();
notifyMpvPluginVisibleOverlayVisibility(nextVisible);
deps.syncOverlayMpvSubtitleSuppression();
syncOverlayMpvSubtitleSuppression();
}
function setOverlayVisible(visible: boolean): void {
if (!visible) {
deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification();
deps.getAutoplaySubtitlePriming().cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
deps.getVisibleOverlayInteraction().resetVisibleOverlayInputState();
autoplayReadyGate.markCurrentMediaAutoplayReady();
interaction.resetVisibleOverlayInputState();
deps.getAutoplayReadyGate().markCurrentMediaAutoplayReady();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
}
if (visible) {
deps.getOverlayNotifications().maybeStartOverlayLoadingOsd();
deps.getVisibleOverlayInteraction().resetLinuxVisibleOverlayStartupInputPrimer();
deps.getVisibleOverlayInteraction().startLinuxVisibleOverlayStartupInputGrace();
deps.getVisibleOverlayInteraction().restoreVisibleOverlayWindowShapeForShow();
void deps.ensureOverlayMpvSubtitlesHidden();
interaction.resetLinuxVisibleOverlayStartupInputPrimer();
interaction.startLinuxVisibleOverlayStartupInputGrace();
interaction.restoreVisibleOverlayWindowShapeForShow();
void ensureOverlayMpvSubtitlesHidden();
void deps.getAutoplaySubtitlePriming().primeCurrentSubtitleForVisibleOverlay();
}
setOverlayVisibleHandler(visible);
notifyMpvPluginVisibleOverlayVisibility(visible);
deps.syncOverlayMpvSubtitleSuppression();
syncOverlayMpvSubtitleSuppression();
}
function handleMpvFullscreenChange(fullscreen: boolean): void {
@@ -443,10 +422,9 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
syncVisibleOverlayMpvFullscreenMode: (nextFullscreen) =>
linuxOverlayModeRuntime.sync(nextFullscreen),
getOverlayInteractionActive: () =>
deps.getVisibleOverlayInteraction().getVisibleOverlayInteractionActive() ||
deps.getVisibleOverlayInteraction().getLinuxOverlayInputShapeActive(),
ensureOverlayWindowLevel: (window) =>
deps.getOverlayGeometry().ensureOverlayWindowLevel(window),
interaction.getVisibleOverlayInteractionActive() ||
interaction.getLinuxOverlayInputShapeActive(),
ensureOverlayWindowLevel: (window) => geometry.ensureOverlayWindowLevel(window),
},
cancelLinuxMpvFullscreenOverlayRefreshBurst,
);
@@ -459,6 +437,162 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
}
const geometry = createOverlayGeometryRuntime({
screen: deps.screen,
promoteStatsOverlayAbovePlayback: () => deps.promoteStatsOverlayAbovePlayback(),
overlayManager: {
getMainWindow: () => overlayManager.getMainWindow(),
getModalWindow: () => overlayManager.getModalWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
setOverlayWindowBounds: (bounds) => overlayManager.setOverlayWindowBounds(bounds),
setModalWindowBounds: (bounds) => overlayManager.setModalWindowBounds(bounds),
},
getTrackedWindowGeometry: () => appState.windowTracker?.getGeometry() ?? null,
getTrackedWindowMediaSourceId: () => appState.windowTracker?.getTargetWindowMediaSourceId?.(),
getTrackedWindowNativeId: () => appState.windowTracker?.getTargetWindowNativeId?.(),
getStatsOverlayVisible: () => appState.statsOverlayVisible,
getOverlayForegroundSeparateWindows: () => deps.getOverlayForegroundSeparateWindows(),
getLinuxVisibleOverlayWindowMode: () => linuxOverlayModeRuntime.mode,
getLinuxTrackedMpvFullscreen: () => linuxOverlayModeRuntime.fullscreen,
getLinuxTrackedMpvFullscreenChangedAtMs: () => linuxOverlayModeRuntime.fullscreenChangedAtMs,
syncLinuxVisibleOverlayMpvFullscreenMode: (fullscreen) =>
linuxOverlayModeRuntime.sync(fullscreen),
getLinuxVisibleOverlayOwnerBindingKey: () => linuxOverlayModeRuntime.ownerBindingKey,
setLinuxVisibleOverlayOwnerBindingKey: (key) => {
linuxOverlayModeRuntime.ownerBindingKey = key;
},
clearVisibleOverlayX11OwnerBinding: (window) =>
interaction.clearVisibleOverlayX11OwnerBinding(window),
getNativeWindowHandleDecimal: (window) => interaction.getNativeWindowHandleDecimal(window),
enqueueVisibleOverlayX11OwnerBindingOperation: (window, args, onError) =>
interaction.enqueueVisibleOverlayX11OwnerBindingOperation(window, args, onError),
scheduleWindowsVisibleOverlayZOrderSyncBurst: () =>
interaction.scheduleWindowsVisibleOverlayZOrderSyncBurst(),
logDebug: (message, ...args) => logger.debug(message, ...args),
});
// Starts the Windows foreground poll and Linux z-order keep-alive loops on those platforms.
const interaction = createVisibleOverlayInteractionRuntime({
screen: deps.screen,
overlayManager: {
getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
},
overlayContentMeasurementStore: {
clear: (layer) => deps.overlayContentMeasurementStore.clear(layer),
getLatestByLayer: (layer) => deps.overlayContentMeasurementStore.getLatestByLayer(layer),
},
logger: {
info: (message, ...args) => logger.info(message, ...args),
warn: (message, ...args) => logger.warn(message, ...args),
debug: (message, ...args) => logger.debug(message, ...args),
},
updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
getModalInputExclusive: () => overlayModalInputState.getModalInputExclusive(),
getStatsOverlayVisible: () => appState.statsOverlayVisible,
setStatsOverlayVisible: (visible) => {
appState.statsOverlayVisible = visible;
},
getWindowTracker: () => appState.windowTracker,
setWindowTracker: (tracker) => {
appState.windowTracker = tracker;
},
setTrackerNotReadyWarningShown: (shown) => {
appState.trackerNotReadyWarningShown = shown;
},
getMpvSocketPath: () => appState.mpvSocketPath,
getBackendOverride: () => appState.backendOverride,
getInitialArgs: () => appState.initialArgs,
getOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
getLinuxVisibleOverlayWindowMode: () => linuxOverlayModeRuntime.mode,
setLinuxVisibleOverlayOwnerBindingKey: (key) => {
linuxOverlayModeRuntime.ownerBindingKey = key;
},
bindVisibleOverlayToTrackedX11Window: (window) =>
geometry.bindVisibleOverlayToTrackedX11Window(window),
updateVisibleOverlayBounds: (bounds) => geometry.updateVisibleOverlayBounds(bounds),
refreshCurrentSubtitle: () => {
deps.getSubtitleProcessingController().refreshCurrentSubtitle(appState.currentSubText);
},
getOverlayWindows: () => overlayManager.getOverlayWindows(),
syncOverlayShortcuts: () => deps.getOverlayShortcuts().syncOverlayShortcuts(),
resetLastOverlayWindowGeometry: () => geometry.resetLastOverlayWindowGeometry(),
enforceOverlayLayerOrder: () => {
geometry.enforceOverlayLayerOrder();
},
getOverlayForegroundSeparateWindows: () => deps.getOverlayForegroundSeparateWindows(),
});
const visibilityComposer = composeOverlayVisibilityRuntime({
overlayVisibilityRuntime,
restorePreviousSecondarySubVisibilityMainDeps: {
getMpvClient: () => appState.mpvClient,
},
broadcastRuntimeOptionsChangedMainDeps: {
broadcastRuntimeOptionsChangedRuntime,
getRuntimeOptionsState: () => deps.getRuntimeOptionsState(),
broadcastToOverlayWindows: (channel, ...args) =>
overlayManager.broadcastToOverlayWindows(channel, ...args),
},
sendToActiveOverlayWindowMainDeps: {
sendToActiveOverlayWindowRuntime: (channel, payload, runtimeOptions) =>
deps.overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
},
setOverlayDebugVisualizationEnabledMainDeps: {
setOverlayDebugVisualizationEnabledRuntime,
getCurrentEnabled: () => appState.overlayDebugVisualizationEnabled,
setCurrentEnabled: (next) => {
appState.overlayDebugVisualizationEnabled = next;
},
},
openRuntimeOptionsPaletteMainDeps: {
openRuntimeOptionsPaletteRuntime: () => deps.overlayModalRuntime.openRuntimeOptionsPalette(),
},
});
// mpv's own subtitles stay hidden while the visible overlay shows and come back when it hides.
const ensureOverlayMpvSubtitlesHidden = createEnsureOverlayMpvSubtitlesHiddenHandler({
getMpvClient: () => appState.mpvClient,
getSavedSubVisibility: () => appState.overlaySavedMpvSubVisibility,
setSavedSubVisibility: (visible) => {
appState.overlaySavedMpvSubVisibility = visible;
},
getRevision: () => appState.overlayMpvSubVisibilityRevision,
setRevision: (revision) => {
appState.overlayMpvSubVisibilityRevision = revision;
},
setMpvSubVisibility: (visible) => {
setMpvSubVisibilityRuntime(appState.mpvClient, visible);
},
logWarn: (message, error) => {
logger.warn(message, error);
},
});
const restoreOverlayMpvSubtitles = createRestoreOverlayMpvSubtitlesHandler({
getSavedSubVisibility: () => appState.overlaySavedMpvSubVisibility,
setSavedSubVisibility: (visible) => {
appState.overlaySavedMpvSubVisibility = visible;
},
getRevision: () => appState.overlayMpvSubVisibilityRevision,
setRevision: (revision) => {
appState.overlayMpvSubVisibilityRevision = revision;
},
isMpvConnected: () => Boolean(appState.mpvClient?.connected),
shouldKeepSuppressedFromVisibleOverlayBinding: () => overlayManager.getVisibleOverlayVisible(),
setMpvSubVisibility: (visible) => {
setMpvSubVisibilityRuntime(appState.mpvClient, visible);
},
});
function syncOverlayMpvSubtitleSuppression(): void {
if (overlayManager.getVisibleOverlayVisible()) {
void ensureOverlayMpvSubtitlesHidden();
return;
}
restoreOverlayMpvSubtitles();
}
/** Renderer reports about overlay windows: focus, modal lifecycle, pointer and measurement. */
const ipc: IpcSlice<
| 'getMainWindow'
@@ -490,14 +624,14 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
activatePlaybackWindowForOverlayInteraction: async () => {
try {
const raised = (await appState.windowTracker?.raiseTargetWindow?.()) ?? false;
deps.getOverlayGeometry().enforceOverlayLayerOrder();
geometry.enforceOverlayLayerOrder();
return raised;
} catch (error) {
logger.debug(
'Failed to raise tracked mpv window for overlay interaction:',
error instanceof Error ? error.message : String(error),
);
deps.getOverlayGeometry().enforceOverlayLayerOrder();
geometry.enforceOverlayLayerOrder();
return false;
}
},
@@ -545,7 +679,6 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
if (!mainWindow || senderWindow !== mainWindow) {
return;
}
const interaction = deps.getVisibleOverlayInteraction();
const previousActive = interaction.getVisibleOverlayInteractionActive();
interaction.setVisibleOverlayInteractionActive(active);
if (previousActive === active) {
@@ -561,7 +694,6 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
if (!mainWindow || senderWindow !== mainWindow) {
return;
}
const interaction = deps.getVisibleOverlayInteraction();
interaction.setLinuxOverlayInteractiveHint(interactive);
interaction.applyLinuxOverlayInputShapeFromLatestMeasurement();
},
@@ -569,11 +701,10 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
// then schedules the first-paint subtitle refresh.
reportOverlayContentBounds: (payload: unknown) => {
if (deps.overlayContentMeasurementStore.report(payload)) {
const interaction = deps.getVisibleOverlayInteraction();
interaction.tickLinuxOverlayPointerInteractionNow();
interaction.tickWindowsOverlayPointerInteractionNow();
interaction.primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
deps.autoplayReadyGate.flushPendingAutoplayReadySignal();
deps.getAutoplayReadyGate().flushPendingAutoplayReadySignal();
deps.getAutoplaySubtitlePriming().scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();
}
},
@@ -581,6 +712,11 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
return {
ipc,
geometry,
interaction,
visibilityComposer,
restoreOverlayMpvSubtitles,
syncOverlayMpvSubtitleSuppression,
linuxOverlayModeRuntime,
overlayVisibilityRuntime,
createMainWindow,
+109 -18
View File
@@ -1,8 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_CONFIG } from '../../config/definitions';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../../core/services/overlay-window-flags';
import type { SubtitlePrefetchService } from '../../core/services/subtitle-prefetch';
import type { SubtitleData } from '../../types';
import type { OverlayContentMeasurement, SubtitleData } from '../../types';
import { createAppState } from '../state';
import { createPlaybackAssembly, type PlaybackAssemblyDeps } from './playback';
@@ -53,18 +54,13 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
overlayManager: {
broadcastToOverlayWindows: spy('overlay.broadcast', noop),
getVisibleOverlayVisible: spy('overlay.getVisibleOverlayVisible', () => false),
getMainWindow: spy('overlay.getMainWindow', () => null),
},
overlayContentMeasurementStore: {
getLatestByLayer: spy('measurement.getLatest', () => null),
},
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),
@@ -72,13 +68,6 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
showAnnotationLoading: spy('startupOsd.showAnnotationLoading', noop),
markAnnotationLoadingComplete: spy('startupOsd.markAnnotationLoadingComplete', noop),
},
managedLocalSubtitleSelectionRuntime: {
handleMediaPathChange: spy('localSubtitles.handleMediaPathChange', noop),
handleSubtitleTrackListChange: spy('localSubtitles.handleSubtitleTrackListChange', noop),
},
jimakuAutoOpenRuntime: {
handleMediaPathChange: spy('jimakuAutoOpen.handleMediaPathChange', noop),
},
mediaTimingReviewRuntime: {
deferPlaybackResume: spy('timingReview.deferPlaybackResume', () => false),
cancelPlaybackResume: spy('timingReview.cancelPlaybackResume', noop),
@@ -103,6 +92,7 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
handleMediaPathChange: spy('youtube.notification.handleMediaPathChange', noop),
handleSubtitleTrackChange: spy('youtube.notification.handleSubtitleTrackChange', noop),
handleSubtitleTrackListChange: spy('youtube.notification.handleTrackListChange', noop),
isAppOwnedFlowInFlight: spy('youtube.notification.isAppOwnedFlowInFlight', () => false),
},
mediaCachePlayback: { handleMediaPathChange: spy('youtube.mediaCache', resolved) },
browserPlayback: {
@@ -168,6 +158,8 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
// Every annotation off, so tokenization warmups need no MeCab or dictionaries.
getRuntimeBooleanOption: spy('getRuntimeBooleanOption', () => false),
onFullscreenChange: spy('onFullscreenChange', noop),
stealAppFocus: spy('stealAppFocus', noop),
openJimaku: spy('overlayModals.openJimaku', async () => false),
};
}
@@ -186,10 +178,32 @@ function bindMpvEvents(playback: ReturnType<typeof createPlaybackAssembly>) {
};
}
function setup(overrides: Partial<PlaybackAssemblyDeps> = {}) {
/**
* Replaces the assembly's autoplay-ready and tokenization gates with recording stubs, so tests
* pin playback's calls into them. Playback looks these methods up at call time.
*/
function stubGates(recorder: Recorder, playback: ReturnType<typeof createPlaybackAssembly>) {
const { spy } = recorder;
const noop = () => {};
Object.assign(playback.autoplayReadyGate, {
maybeSignalPluginAutoplayReady: spy('autoplay.maybeSignal', noop),
flushPendingAutoplayReadySignal: spy('autoplay.flushPending', noop),
invalidatePendingAutoplayReadyFallbacks: spy('autoplay.invalidateFallbacks', noop),
} satisfies Partial<typeof playback.autoplayReadyGate>);
Object.assign(playback.currentMediaTokenizationGate, {
updateCurrentMediaPath: spy('tokenizationGate.updateCurrentMediaPath', noop),
markReady: spy('tokenizationGate.markReady', noop),
} satisfies Partial<typeof playback.currentMediaTokenizationGate>);
}
function setup(
overrides: Partial<PlaybackAssemblyDeps> = {},
options: { realGates?: boolean } = {},
) {
const recorder = createRecorder();
const deps = { ...makeDeps(recorder), ...overrides };
const playback = createPlaybackAssembly(deps);
if (!options.realGates) stubGates(recorder, playback);
const emit = bindMpvEvents(playback);
return { recorder, deps, appState: deps.appState, playback, emit };
}
@@ -451,3 +465,80 @@ test('sidebar snapshot falls back to cached cues while mpv is disconnected', asy
assert.equal(snapshot.config, DEFAULT_CONFIG.subtitleSidebar);
assert.equal(recorder.names().includes('loadSubtitleSourceText'), false);
});
/**
* Real autoplay gate over a paused, connected mpv client. `overlayVisible` and `measurement`
* control the visible-overlay readiness the gate waits for.
*/
function setupAutoplayGate(fixture: {
overlayVisible: boolean;
measurement?: OverlayContentMeasurement | null;
}) {
const overlayWindow = {
isDestroyed: () => false,
// Focused already, so the macOS pointer recovery on release does not steal focus.
isVisible: () => true,
isFocused: () => true,
[OVERLAY_WINDOW_CONTENT_READY_FLAG]: true,
};
const ctx = setup(
{
overlayManager: {
broadcastToOverlayWindows: () => {},
getVisibleOverlayVisible: () => fixture.overlayVisible,
getMainWindow: () => overlayWindow as unknown as Electron.BrowserWindow,
},
overlayContentMeasurementStore: { getLatestByLayer: () => fixture.measurement ?? null },
},
{ realGates: true },
);
let paused = true;
const scriptMessages: string[] = [];
ctx.appState.currentMediaPath = '/videos/a.mkv';
ctx.appState.mpvClient = {
connected: true,
requestProperty: async (name: string) => (name === 'pause' ? paused : null),
send: ({ command }: { command: unknown[] }) => {
if (command[0] === 'script-message') scriptMessages.push(String(command[1]));
if (command[0] === 'set_property' && command[1] === 'pause') paused = command[2] === true;
},
} as unknown as typeof ctx.appState.mpvClient;
const released = () => scriptMessages.includes('subminer-autoplay-ready');
return { ...ctx, released };
}
const TOKENIZED_LINE: SubtitleData = { text: '一行目', tokens: [] };
test('autoplay stays paused until the visible overlay has measured the subtitle', () => {
const fixture: { overlayVisible: boolean; measurement: OverlayContentMeasurement | null } = {
overlayVisible: true,
measurement: null,
};
const { playback, released } = setupAutoplayGate(fixture);
playback.autoplayReadyGate.maybeSignalPluginAutoplayReady(TOKENIZED_LINE);
assert.equal(released(), false);
fixture.measurement = {
layer: 'visible',
measuredAtMs: Date.now() + 1_000,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 100, y: 900, width: 600, height: 60 },
} as OverlayContentMeasurement;
playback.autoplayReadyGate.flushPendingAutoplayReadySignal();
assert.equal(released(), true);
});
// Untokenized lines (startup priming, tokenizer fallbacks) must not release the startup pause
// while tokenization warms up; a tokenized line must not wait on the warmup flag.
test('autoplay ignores an untokenized line during tokenization warmup but releases a tokenized one', () => {
const { playback, released } = setupAutoplayGate({ overlayVisible: false });
assert.equal(playback.isTokenizationWarmupReady(), false);
playback.autoplayReadyGate.maybeSignalPluginAutoplayReady({ text: '一行目', tokens: null });
playback.autoplayReadyGate.flushPendingAutoplayReadySignal();
assert.equal(released(), false);
playback.autoplayReadyGate.maybeSignalPluginAutoplayReady(TOKENIZED_LINE);
assert.equal(released(), true);
});
+151 -39
View File
@@ -2,6 +2,7 @@ import {
MpvIpcClient,
applyMpvSubtitleRenderMetricsPatch,
createTokenizerDepsRuntime,
isOverlayWindowContentReady,
isSubtitleAnnotationUpgrade,
resolveSanitizedSubtitleSeekCommand,
sendMpvCommandRuntime,
@@ -14,14 +15,19 @@ import { resolveAniSkipMetadataForFile } from '../runtime/aniskip-metadata';
import { createAniSkipRuntime } from '../runtime/aniskip-runtime';
import { resolveCurrentSubtitleForRenderer } from '../runtime/current-subtitle-snapshot';
import { resolveFreshPlaybackPaused } from '../runtime/playback-paused-state';
import { createAutoplayReadyGate, type AutoplayReadySignal } from '../runtime/autoplay-ready-gate';
import { createAutoplaySubtitlePrimingRuntime } from '../runtime/autoplay-subtitle-priming-runtime';
import { createAutoplayTokenizationWarmRelease } from '../runtime/autoplay-tokenization-warm-release';
import { composeMpvRuntimeHandlers } from '../runtime/composers';
import { createCurrentMediaTokenizationGate } from '../runtime/current-media-tokenization-gate';
import { createCachedInternalSubtitleTrackExtractor } from '../runtime/internal-subtitle-extraction';
import {
markJellyfinRemotePlaybackLoaded as markJellyfinRemotePlaybackLoadedState,
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
} from '../runtime/jellyfin-remote-playback';
import { createJimakuAutoOpenRuntime } from '../runtime/jimaku-auto-open';
import { createManagedLocalSubtitleSelectionRuntime } from '../runtime/local-subtitle-selection';
import { focusMacOSOverlayWindow } from '../runtime/macos-overlay-window-focus';
import { buildSubtitleTrackDiagnostics } from '../runtime/mpv-track-diagnostics';
import { createRemoteMediaPathDetector } from '../runtime/network-media-path';
import { resolveCanonicalPrimarySubtitle } from '../runtime/primary-subtitle-text';
@@ -33,6 +39,7 @@ import {
createResolveActiveSubtitleSidebarSourceHandler,
} from '../runtime/subtitle-prefetch-runtime';
import { createPrefetchAwareSubtitleProcessingController } from '../runtime/subtitle-processing-runtime';
import { isVisibleOverlayAutoplayTargetReady } from '../runtime/visible-overlay-autoplay-readiness';
import {
isSameYoutubeMediaPath,
isYoutubeMediaPath,
@@ -48,25 +55,25 @@ import type {
} from '../../types';
import type { BrowserWindow } from 'electron';
import type { ConfigService } from '../../config/service';
import type { createOverlayManager, SubtitleWebSocket } from '../../core/services';
import type {
createOverlayContentMeasurementStore,
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 { createJimakuAutoOpenRuntime } from '../runtime/jimaku-auto-open';
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 { AnnotationRuntimeOptionId } from '../runtime/annotation-options';
import type { OverlayModalRuntime } from '../overlay-runtime';
import type { AppState } from '../state';
import type { AnilistAssembly } from './anilist';
@@ -78,13 +85,6 @@ 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
@@ -97,20 +97,14 @@ export interface PlaybackAssemblyDeps {
logger: Pick<Logger, 'debug' | 'info' | 'warn' | 'error'>;
overlayManager: Pick<
ReturnType<typeof createOverlayManager>,
'broadcastToOverlayWindows' | 'getVisibleOverlayVisible'
'broadcastToOverlayWindows' | 'getVisibleOverlayVisible' | 'getMainWindow'
>;
overlayContentMeasurementStore: Pick<
ReturnType<typeof createOverlayContentMeasurementStore>,
'getLatestByLayer'
>;
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'
@@ -119,14 +113,6 @@ export interface PlaybackAssemblyDeps {
| 'showAnnotationLoading'
| 'markAnnotationLoadingComplete'
>;
managedLocalSubtitleSelectionRuntime: Pick<
ReturnType<typeof createManagedLocalSubtitleSelectionRuntime>,
'handleMediaPathChange' | 'handleSubtitleTrackListChange'
>;
jimakuAutoOpenRuntime: Pick<
ReturnType<typeof createJimakuAutoOpenRuntime>,
'handleMediaPathChange'
>;
mediaTimingReviewRuntime: Pick<
ReturnType<typeof createMediaTimingReviewRuntime>,
'deferPlaybackResume' | 'cancelPlaybackResume'
@@ -154,7 +140,10 @@ export interface PlaybackAssemblyDeps {
playback: Pick<YoutubeAssembly['playback'], 'getQuitOnDisconnectArmed'>;
primarySubtitleNotification: Pick<
YoutubeAssembly['primarySubtitleNotification'],
'handleMediaPathChange' | 'handleSubtitleTrackChange' | 'handleSubtitleTrackListChange'
| 'handleMediaPathChange'
| 'handleSubtitleTrackChange'
| 'handleSubtitleTrackListChange'
| 'isAppOwnedFlowInFlight'
>;
mediaCachePlayback: Pick<YoutubeAssembly['mediaCachePlayback'], 'handleMediaPathChange'>;
browserPlayback: Pick<
@@ -223,6 +212,10 @@ export interface PlaybackAssemblyDeps {
getRuntimeBooleanOption: (id: AnnotationRuntimeOptionId, fallback: boolean) => boolean;
/** Linux fullscreen overlay refresh lives with the overlay window code in `main.ts`. */
onFullscreenChange: (fullscreen: boolean) => void;
/** Brings SubMiner to the foreground (`app.focus({ steal: true })`). */
stealAppFocus: () => void;
/** Opens the Jimaku modal; resolves to whether the modal acknowledged opening. */
openJimaku: () => Promise<boolean>;
}
export type PlaybackAssembly = ReturnType<typeof createPlaybackAssembly>;
@@ -230,8 +223,9 @@ 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.
* playback state, secondary subtitles, managed local subtitle selection, Jimaku auto-open, and the
* autoplay-ready gate that releases mpv's startup pause with its subtitle priming. `main.ts` builds
* it once and starts warmups and the mpv client from its startup code.
*/
export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
const {
@@ -241,11 +235,7 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
overlayManager,
subtitleWsService,
annotationSubtitleWsService,
autoplayReadyGate,
currentMediaTokenizationGate,
startupOsdSequencer,
managedLocalSubtitleSelectionRuntime,
jimakuAutoOpenRuntime,
mediaTimingReviewRuntime,
discordPresenceRuntime,
immersionMediaRuntime,
@@ -269,6 +259,125 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-pan-x', 0]);
}
const currentMediaTokenizationGate = createCurrentMediaTokenizationGate();
const managedLocalSubtitleSelectionRuntime = createManagedLocalSubtitleSelectionRuntime({
getCurrentMediaPath: () => appState.currentMediaPath,
getMpvClient: () => appState.mpvClient,
getPrimarySubtitleLanguages: () => configService.getConfig().youtube.primarySubLanguages,
getSecondarySubtitleLanguages: () =>
configService.getConfig().secondarySub.secondarySubLanguages,
sendMpvCommand: (command) => {
sendMpvCommandRuntime(appState.mpvClient, command);
},
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
clearScheduled: (timer) => clearTimeout(timer),
});
const POST_WARM_AUTOPLAY_SUBTITLE_PRIME_DELAYS_MS = [0, 75, 200, 500, 1_000] as const;
/** After a warm-tokenization release, re-primes the current line while the overlay shows it. */
function schedulePostWarmAutoplaySubtitlePrime(signal: AutoplayReadySignal): void {
if (signal.payload.text.trim() !== '__warm__') {
return;
}
const mediaPath = signal.mediaPath;
for (const delayMs of POST_WARM_AUTOPLAY_SUBTITLE_PRIME_DELAYS_MS) {
const timer = setTimeout(() => {
const currentMediaPath =
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
if (currentMediaPath !== mediaPath || !overlayManager.getVisibleOverlayVisible()) {
return;
}
void autoplaySubtitlePrimingRuntime
.primeCurrentSubtitleForAutoplay(mediaPath)
.catch((error) => {
logger.debug(
`[autoplay-subtitle-prime] failed to prime current subtitle after warm readiness: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
}, delayMs);
timer.unref?.();
}
}
const autoplayReadyGate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => youtube.primarySubtitleNotification.isAppOwnedFlowInFlight(),
getCurrentMediaPath: () => appState.currentMediaPath,
getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath ?? null,
getPlaybackPaused: () => appState.playbackPaused,
getMpvClient: () => appState.mpvClient,
signalPluginAutoplayReady: () => {
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']);
},
// Untokenized signals wait for warmup here; the target-readiness predicate below must stay
// warmup-free so warm and tokenized releases are never deferred behind the warmup flag.
isTokenizationReady: () => isTokenizationWarmupReady(),
// Deferred: jimakuAutoOpenRuntime is created right after this gate.
isPlaybackHeld: () => jimakuAutoOpenRuntime.isHoldingPlayback(),
requestOverlayPointerRecovery: () => {
if (process.platform !== 'darwin' || !overlayManager.getVisibleOverlayVisible()) {
return;
}
// Renderer-side recovery alone cannot wake subtitle hover on macOS: the overlay only
// receives mouse-moved events while it is the key window of the frontmost app, and mpv
// is frontmost once playback starts. Activate the overlay window first so the broadcast's
// setIgnoreMouseEvents toggling actually takes effect, mirroring a manual subtitle click.
focusMacOSOverlayWindow({
platform: process.platform,
getOverlayWindow: () => overlayManager.getMainWindow(),
stealAppFocus: () => deps.stealAppFocus(),
warn: (message, details) => logger.warn(message, details),
});
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest);
},
onAutoplayReadyReleased: (signal) => {
schedulePostWarmAutoplaySubtitlePrime(signal);
},
// Hold the release until the visible overlay has painted and measured its content.
isSignalTargetReady: (signal) =>
isVisibleOverlayAutoplayTargetReady(
{
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
isOverlayWindowReady: () => {
const overlayWindow = overlayManager.getMainWindow();
return Boolean(overlayWindow && isOverlayWindowContentReady(overlayWindow));
},
getLatestVisibleMeasurement: () =>
deps.overlayContentMeasurementStore.getLatestByLayer('visible'),
},
signal,
),
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
logDebug: (message) => logger.debug(message),
});
const jimakuAutoOpenRuntime = createJimakuAutoOpenRuntime({
isEnabled: () => configService.getConfig().jimaku.autoOpenWhenPrimarySubtitleMissing,
getCurrentMediaPath: () => appState.currentMediaPath,
getMpvClient: () => appState.mpvClient,
getPrimarySubtitleLanguages: () => configService.getConfig().youtube.primarySubLanguages,
isPlaybackPaused: () => appState.playbackPaused,
pausePlayback: () => {
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'yes']);
},
holdPluginAutoplayGate: () => {
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-hold']);
},
endHold: ({ undoPause }) => {
// Readiness deferred during the hold goes through the normal release (plugin + unpause).
autoplayReadyGate.flushPendingAutoplayReadySignal();
if (undoPause) {
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'no']);
}
},
openJimaku: () => deps.openJimaku(),
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
clearScheduled: (timer) => clearTimeout(timer),
});
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
const canonical = resolveCanonicalPrimarySubtitle({
liveText: payload.text,
@@ -1164,6 +1273,9 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
return {
ipc,
autoplayReadyGate,
currentMediaTokenizationGate,
jimakuAutoOpenRuntime,
createMpvClient,
aniSkipRuntime,
setSubtitleSidebarRequestedOpen: (open: boolean): void => {
+83
View File
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAppState } from '../state';
import { createPluginsAssembly, type PluginsAssemblyDeps } from './plugins';
type MpvClient = NonNullable<ReturnType<typeof createAppState>['mpvClient']>;
function lazy(name: string) {
return (): never => {
throw new Error(`${name} called unexpectedly`);
};
}
/** Builds deps whose every function throws, so construction must not call any of them. */
function makeDeps(): PluginsAssemblyDeps {
const thrower = <T extends object>(name: string): T =>
new Proxy({}, { get: (_target, key: string) => lazy(`${name}.${key}`) }) as T;
return {
appState: createAppState({ mpvSocketPath: '/tmp/subminer-plugins.sock', texthookerPort: 0 }),
configService: thrower('configService'),
overlayManager: thrower('overlayManager'),
logger: thrower('logger'),
createLogger: lazy('createLogger'),
configDir: '/tmp/subminer-plugins-test',
userDataPath: '/tmp/subminer-plugins-test',
overlayModalRuntime: thrower('overlayModalRuntime'),
overlayModals: thrower('overlayModals'),
getOverlayNotifications: lazy('getOverlayNotifications'),
createBrowserWindow: lazy('createBrowserWindow'),
promoteAboveOverlay: lazy('promoteAboveOverlay'),
ensureMpvRunning: lazy('ensureMpvRunning'),
dispatchSessionAction: lazy('dispatchSessionAction'),
};
}
test('createPluginsAssembly does not call its deps while constructing', () => {
const plugins = createPluginsAssembly(makeDeps());
assert.equal(typeof plugins.host.loadAll, 'function');
});
test('plugins see the current line with its parsed cue timing', () => {
const deps = makeDeps();
deps.appState.currentSubText = '一行目';
deps.appState.activeParsedSubtitleCues = [{ startTime: 10, endTime: 12, text: '一行目' }];
deps.appState.mpvClient = {
currentSubText: '一行目',
currentTimePos: 11,
currentSubDelay: 0,
} as MpvClient;
assert.deepEqual(createPluginsAssembly(deps).getCurrentSubtitle(), {
text: '一行目',
startTime: 10,
endTime: 12,
});
});
test('without parsed cues plugins get the live line, timed only while mpv reports a span', () => {
const deps = makeDeps();
const plugins = createPluginsAssembly(deps);
deps.appState.currentSubText = 'ライブ';
deps.appState.mpvClient = {
currentSubText: 'ライブ',
currentSubStart: 3,
currentSubEnd: 5,
} as MpvClient;
assert.deepEqual(plugins.getCurrentSubtitle(), { text: 'ライブ', startTime: 3, endTime: 5 });
// A cleared sub-start reads as zero; a span that does not move forward is not a timing.
deps.appState.mpvClient = {
currentSubText: 'ライブ',
currentSubStart: 0,
currentSubEnd: 0,
} as MpvClient;
assert.deepEqual(plugins.getCurrentSubtitle(), {
text: 'ライブ',
startTime: null,
endTime: null,
});
deps.appState.currentSubText = ' ';
assert.equal(plugins.getCurrentSubtitle(), null);
});
+167
View File
@@ -0,0 +1,167 @@
import path from 'node:path';
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
import { AnkiConnectClient } from '../../anki-connect';
import type { ConfigService } from '../../config/service';
import {
sendMpvCommandRuntime,
toSubtitleClock,
type createOverlayManager,
} from '../../core/services';
import { createPluginHost } from '../../core/services/plugins/plugin-host';
import { buildPluginSubtitleContext } from '../../core/services/plugins/subtitle-context';
import type { PluginSubtitleLine } from '../../core/services/plugins/types';
import type { Logger } from '../../logger';
import type { SessionActionDispatchRequest } from '../../types/runtime';
import type { OverlayModalRuntime } from '../overlay-runtime';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import { createPluginPanelRuntime, openPluginPanelModal } from '../runtime/plugin-panel-runtime';
import { createPluginWindowRuntime } from '../runtime/plugin-window-runtime';
import { resolvePrimarySubtitle } from '../runtime/primary-subtitle-text';
import type { AppState } from '../state';
import type { OverlayModalsAssembly } from './overlay-modals';
type OverlayManager = ReturnType<typeof createOverlayManager>;
/**
* What the plugin host needs from the rest of `main.ts`. `appState`, `configService` and
* `overlayManager` are also handed to plugins whole as the `unstable` API.
*/
export interface PluginsAssemblyDeps {
appState: AppState;
configService: ConfigService;
overlayManager: OverlayManager;
logger: Pick<Logger, 'error'>;
createLogger: (scope: string) => Logger;
configDir: string;
userDataPath: string;
overlayModalRuntime: Pick<OverlayModalRuntime, 'handleOverlayModalClosed'>;
overlayModals: Pick<OverlayModalsAssembly, 'createOverlayHostedModalOpenDeps'>;
getOverlayNotifications: () => Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
'showConfiguredStatusNotification'
>;
createBrowserWindow: (options: BrowserWindowConstructorOptions) => BrowserWindow;
/** Keeps a plugin window above the overlay, like the settings windows. */
promoteAboveOverlay: (window: BrowserWindow) => void;
/** Starts mpv for a plugin that needs playback (the Jellyfin launch path). */
ensureMpvRunning: () => Promise<boolean>;
dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>;
}
export type PluginsAssembly = ReturnType<typeof createPluginsAssembly>;
/**
* Owns user plugins: the plugin host, the overlay-hosted plugin panel and plugin windows.
* `main.ts` builds it once; startup loads the plugins and quit cleanup unloads them.
*/
export function createPluginsAssembly(deps: PluginsAssemblyDeps) {
const { appState, configService, overlayManager, logger } = deps;
const panel = createPluginPanelRuntime({
openModal: (getSnapshot, signal) =>
openPluginPanelModal(
deps.overlayModals.createOverlayHostedModalOpenDeps(),
getSnapshot,
signal,
),
closeModal: () => deps.overlayModalRuntime.handleOverlayModalClosed('plugin-panel'),
// The panel may live in either window; broadcastToOverlayWindows only reaches the main one.
broadcast: (channel, payload) => {
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
if (window && !window.isDestroyed()) window.webContents.send(channel, payload);
}
},
onOpenFailed: () =>
deps.getOverlayNotifications().showConfiguredStatusNotification('Plugin panel unavailable.', {
variant: 'warning',
}),
});
/** The line on screen, with cue timings on the subtitle file's timeline (as mining sees it). */
function getCurrentSubtitle(): PluginSubtitleLine | null {
const text = appState.currentSubText.trim();
if (!text) return null;
const resolved = resolvePrimarySubtitle({
liveText: appState.mpvClient?.currentSubText ?? '',
currentTimeSec: toSubtitleClock(
Number(appState.mpvClient?.currentTimePos),
appState.mpvClient?.currentSubDelay ?? 0,
),
cues: appState.activeParsedSubtitleCues,
});
const resolvedText = resolved?.text.replace(/\n{2,}/g, '\n').trim();
if (
resolved &&
resolvedText &&
Number.isFinite(resolved.startTime) &&
Number.isFinite(resolved.endTime) &&
resolved.endTime > resolved.startTime
) {
return { text: resolvedText, startTime: resolved.startTime, endTime: resolved.endTime };
}
const client = appState.mpvClient;
// The client reports a cleared sub-start as zero, so only trust timing for a live line.
const hasTiming = client !== null && client.currentSubEnd > client.currentSubStart;
return {
text,
startTime: hasTiming ? client.currentSubStart : null,
endTime: hasTiming ? client.currentSubEnd : null,
};
}
const windows = createPluginWindowRuntime({
createBrowserWindow: (options) => deps.createBrowserWindow(options),
promoteAboveOverlay: (window) => deps.promoteAboveOverlay(window),
logError: (message) => logger.error(message),
});
const host = createPluginHost({
pluginsDir: path.join(deps.configDir, 'plugins'),
pluginDataDir: path.join(deps.userDataPath, 'plugin-data'),
getPluginConfig: () => configService.getConfig().plugins,
createLogger: deps.createLogger,
isMpvConnected: () => appState.mpvClient?.connected === true,
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
requestMpvProperty: async (name) => {
if (!appState.mpvClient) throw new Error('mpv is not connected.');
return await appState.mpvClient.requestProperty(name);
},
requestMpvCommand: async (command) => {
if (!appState.mpvClient) throw new Error('mpv is not connected.');
return await appState.mpvClient.requestCommand(command);
},
ensureMpvRunning: () => deps.ensureMpvRunning(),
invokeAnkiConnect: (action, params, options) => {
const ankiConfig = configService.getConfig().ankiConnect;
const url = (
appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(ankiConfig) ?? ankiConfig
).url;
if (!url) return Promise.reject(new Error('ankiConnect.url is not configured.'));
// No retries: a retried `sync` or `addNote` could run twice.
return new AnkiConnectClient(url).invoke(action, params, {
timeout: options.timeoutMs,
maxRetries: 0,
});
},
dispatchSessionAction: (request) => deps.dispatchSessionAction(request),
getCurrentSubtitle: () => getCurrentSubtitle(),
getSubtitleContext: (options) =>
buildPluginSubtitleContext({
cues: appState.activeParsedSubtitleCues,
current: getCurrentSubtitle(),
recentLines: (appState.subtitleTimingTracker?.getRecentEntries(40) ?? []).map((entry) => ({
text: entry.displayText,
startTime: entry.startTime,
endTime: entry.endTime,
})),
...options,
}),
notify: (message, variant) =>
deps.getOverlayNotifications().showConfiguredStatusNotification(message, { variant }),
openPanel: (content) => panel.open(content),
openWindow: (request) => windows.open(request),
unstable: { appState, configService, overlayManager },
});
return { host, panel, getCurrentSubtitle };
}
+106
View File
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_CONFIG } from '../../config/definitions';
import type { ResolvedConfig } from '../../types';
import { createAppState } from '../state';
import { createSessionActionsAssembly, type SessionActionsAssemblyDeps } from './session-actions';
function lazy(name: string) {
return (): never => {
throw new Error(`${name} called unexpectedly`);
};
}
/** Builds deps whose every function throws unless a test overrides it. */
function makeDeps(overrides: Partial<SessionActionsAssemblyDeps> = {}): SessionActionsAssemblyDeps {
const thrower = <T extends object>(name: string): T =>
new Proxy({}, { get: (_target, key: string) => lazy(`${name}.${key}`) }) as T;
return {
appState: createAppState({ mpvSocketPath: '/tmp/subminer-session.sock', texthookerPort: 0 }),
getConfig: lazy('getConfig'),
logger: thrower('logger'),
overlayManager: thrower('overlayManager'),
getOverlayNotifications: lazy('getOverlayNotifications'),
stats: thrower('stats'),
mining: thrower('mining'),
overlayModals: thrower('overlayModals'),
youtube: thrower('youtube'),
plugins: { host: thrower('plugins.host') },
toggleVisibleOverlay: lazy('toggleVisibleOverlay'),
triggerSubsyncFromConfig: lazy('triggerSubsyncFromConfig'),
readClipboardText: lazy('readClipboardText'),
showMpvOsd: lazy('showMpvOsd'),
sendRendererMpvCommand: lazy('sendRendererMpvCommand'),
...overrides,
};
}
function configWithSubtitleSelection(enabled: boolean): ResolvedConfig {
return {
...DEFAULT_CONFIG,
subtitleSelection: { ...DEFAULT_CONFIG.subtitleSelection, enabled },
};
}
test('createSessionActionsAssembly does not call its deps while constructing', () => {
const sessionActions = createSessionActionsAssembly(makeDeps());
assert.equal(typeof sessionActions.dispatchSessionAction, 'function');
});
test('the subtitle selection action opens its modal only when subtitle selection is enabled', async () => {
let enabled = false;
const opened: string[] = [];
const sessionActions = createSessionActionsAssembly(
makeDeps({
getConfig: () => configWithSubtitleSelection(enabled),
overlayModals: {
openOverlayHostedModalWithOsd: (_open: unknown, unavailableMessage: string) => {
opened.push(unavailableMessage);
},
} as unknown as SessionActionsAssemblyDeps['overlayModals'],
}),
);
await sessionActions.dispatchSessionAction({ actionId: 'openSubtitleSelection' });
assert.deepEqual(opened, []);
enabled = true;
await sessionActions.dispatchSessionAction({ actionId: 'openSubtitleSelection' });
assert.deepEqual(opened, ['Subtitle selection overlay unavailable.']);
});
test('an unregistered plugin action warns and tells the user it is unavailable', async () => {
const warnings: string[] = [];
const feedback: string[] = [];
const sessionActions = createSessionActionsAssembly(
makeDeps({
logger: { warn: (message: string) => warnings.push(message) },
getOverlayNotifications: () =>
({
showConfiguredPlaybackFeedback: (text: string) => feedback.push(text),
}) as unknown as ReturnType<SessionActionsAssemblyDeps['getOverlayNotifications']>,
plugins: { host: { runAction: async () => false } },
}),
);
await sessionActions.dispatchSessionAction({
actionId: 'runPluginAction',
payload: { pluginActionId: 'example.missing' },
});
assert.deepEqual(warnings, ['Plugin action is not registered: example.missing']);
assert.deepEqual(feedback, ['Plugin action not available: example.missing']);
});
test('renderer mpv commands cycle runtime options only once the options manager exists', () => {
const sessionActions = createSessionActionsAssembly(makeDeps());
assert.equal(sessionActions.mpvCommandDeps.hasRuntimeOptionsManager(), false);
assert.deepEqual(
sessionActions.mpvCommandDeps.cycleRuntimeOption('subtitle.annotation.jlpt', 1),
{
ok: false,
error: 'Runtime options manager unavailable',
},
);
});
+205
View File
@@ -0,0 +1,205 @@
import {
cycleSecondarySubMode as cycleSecondarySubModeCore,
playNextSubtitleRuntime,
replayCurrentSubtitleRuntime,
sendMpvCommandRuntime,
type createOverlayManager,
} from '../../core/services';
import { applyRuntimeOptionResultRuntime } from '../../core/services/runtime-options-ipc';
import { dispatchSessionAction as dispatchSessionActionCore } from '../../core/services/session-actions';
import type { Logger } from '../../logger';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { ResolvedConfig, SecondarySubMode } from '../../types';
import type { RuntimeOptionApplyResult, RuntimeOptionId } from '../../types/runtime-options';
import type { SessionActionDispatchRequest } from '../../types/runtime';
import type { MpvCommandFromIpcRuntimeDeps } from '../ipc-mpv-command';
import { appendClipboardVideoToQueueRuntime } from '../runtime/clipboard-queue';
import { createAppendClipboardVideoToQueueHandler } from '../runtime/overlay-main-actions';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import { createCycleSecondarySubModeRuntimeHandler } from '../runtime/secondary-sub-mode-runtime-handler';
import { openSubtitleSelectionModal } from '../runtime/subtitle-selection';
import type { AppState } from '../state';
import type { MiningAssembly } from './mining';
import type { OverlayModalsAssembly } from './overlay-modals';
import type { PluginsAssembly } from './plugins';
import type { StatsAssembly } from './stats';
import type { YoutubeAssembly } from './youtube';
/** What session actions call into; every sibling here is built before the assembly. */
export interface SessionActionsAssemblyDeps {
appState: AppState;
getConfig: () => ResolvedConfig;
logger: Pick<Logger, 'warn'>;
overlayManager: Pick<ReturnType<typeof createOverlayManager>, 'broadcastToOverlayWindows'>;
getOverlayNotifications: () => Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
| 'showConfiguredPlaybackFeedback'
| 'showConfiguredStatusNotification'
| 'toggleNotificationHistoryPanel'
>;
stats: Pick<StatsAssembly, 'toggleStatsOverlay' | 'markActiveVideoWatched'>;
mining: Pick<
MiningAssembly,
| 'copyCurrentSubtitle'
| 'copySubtitleCount'
| 'updateLastCardFromClipboard'
| 'triggerFieldGrouping'
| 'mineSentenceCard'
| 'mineSentenceCount'
| 'markLastCardAsAudioCard'
>;
overlayModals: Pick<
OverlayModalsAssembly,
| 'openRuntimeOptionsPalette'
| 'openJimakuOverlay'
| 'openTsukihimeOverlay'
| 'openSessionHelpOverlay'
| 'openOverlayHostedModalWithOsd'
| 'openSubtitleGenerationOverlay'
| 'openCharacterDictionaryManagerOverlay'
| 'openControllerSelectOverlay'
| 'openControllerDebugOverlay'
| 'openPlaylistBrowser'
>;
youtube: Pick<YoutubeAssembly, 'openTrackPickerFromPlayback'>;
plugins: { host: Pick<PluginsAssembly['host'], 'runAction'> };
toggleVisibleOverlay: () => void;
triggerSubsyncFromConfig: () => Promise<void>;
readClipboardText: () => string;
/** Raw mpv OSD, bypassing the configured notification type. */
showMpvOsd: (text: string) => void;
/** Renderer-issued mpv commands; playback sanitizes subtitle seeks. */
sendRendererMpvCommand: (command: (string | number)[]) => void;
}
export type SessionActionsAssembly = ReturnType<typeof createSessionActionsAssembly>;
/**
* Owns what a shortcut, controller button, CLI command or plugin can trigger in the running
* session: the session action dispatcher, the secondary-subtitle mode cycle, queueing the
* clipboard's video, and the mpv commands the overlay renderer sends.
*/
export function createSessionActionsAssembly(deps: SessionActionsAssemblyDeps) {
const { appState, overlayManager, overlayModals, mining } = deps;
function setSecondarySubMode(mode: SecondarySubMode): void {
appState.secondarySubMode = mode;
}
const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
cycleSecondarySubModeMainDeps: {
getSecondarySubMode: () => appState.secondarySubMode,
setSecondarySubMode: (mode: SecondarySubMode) => {
setSecondarySubMode(mode);
},
getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs,
setLastSecondarySubToggleAtMs: (timestampMs: number) => {
appState.lastSecondarySubToggleAtMs = timestampMs;
},
broadcastSecondarySubMode: (mode) => {
overlayManager.broadcastToOverlayWindows('secondary-subtitle:mode', mode);
},
showMpvOsd: (text: string) =>
deps.getOverlayNotifications().showConfiguredPlaybackFeedback(text),
},
cycleSecondarySubMode: (cycleDeps) => cycleSecondarySubModeCore(cycleDeps),
});
const appendClipboardVideoToQueue = createAppendClipboardVideoToQueueHandler({
appendClipboardVideoToQueueRuntime,
getMpvClient: () => appState.mpvClient,
readClipboardText: () => deps.readClipboardText(),
showMpvOsd: (text) => deps.getOverlayNotifications().showConfiguredStatusNotification(text),
sendMpvCommand: (command) => {
sendMpvCommandRuntime(appState.mpvClient, command);
},
});
/** Steps a runtime option and reports the new value as playback feedback. */
function cycleRuntimeOption(id: RuntimeOptionId, direction: 1 | -1): RuntimeOptionApplyResult {
if (!appState.runtimeOptionsManager) {
return { ok: false, error: 'Runtime options manager unavailable' };
}
return applyRuntimeOptionResultRuntime(
appState.runtimeOptionsManager.cycleOption(id, direction),
(text) => deps.getOverlayNotifications().showConfiguredPlaybackFeedback(text),
);
}
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
await dispatchSessionActionCore(request, {
toggleStatsOverlay: async () => await deps.stats.toggleStatsOverlay(),
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
copyCurrentSubtitle: () => mining.copyCurrentSubtitle(),
copySubtitleCount: (count) => mining.copySubtitleCount(count),
updateLastCardFromClipboard: () => mining.updateLastCardFromClipboard(),
triggerFieldGrouping: () => mining.triggerFieldGrouping(),
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
mineSentenceCard: () => mining.mineSentenceCard(),
mineSentenceCount: (count) => mining.mineSentenceCount(count),
toggleSecondarySub: () => cycleSecondarySubMode(),
toggleSubtitleSidebar: () =>
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle),
toggleNotificationHistory: () =>
deps.getOverlayNotifications().toggleNotificationHistoryPanel(),
appendClipboardVideoToQueue: () => {
appendClipboardVideoToQueue();
},
markLastCardAsAudioCard: () => mining.markLastCardAsAudioCard(),
markActiveVideoWatched: () => deps.stats.markActiveVideoWatched(),
openRuntimeOptionsPalette: () => overlayModals.openRuntimeOptionsPalette(),
openJimaku: () => overlayModals.openJimakuOverlay(),
openTsukihime: () => overlayModals.openTsukihimeOverlay(),
openSessionHelp: () => overlayModals.openSessionHelpOverlay(),
openSubtitleSelection: () => {
if (!deps.getConfig().subtitleSelection.enabled) return;
overlayModals.openOverlayHostedModalWithOsd(
openSubtitleSelectionModal,
'Subtitle selection overlay unavailable.',
'Failed to open subtitle selection overlay.',
);
},
openSubtitleGeneration: () => overlayModals.openSubtitleGenerationOverlay(),
openCharacterDictionaryManager: () => overlayModals.openCharacterDictionaryManagerOverlay(),
openControllerSelect: () => overlayModals.openControllerSelectOverlay(),
openControllerDebug: () => overlayModals.openControllerDebugOverlay(),
openYoutubeTrackPicker: () => deps.youtube.openTrackPickerFromPlayback(),
openPlaylistBrowser: () => overlayModals.openPlaylistBrowser(),
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
cycleRuntimeOption,
playNextPlaylistItem: () =>
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
runPluginAction: async (pluginActionId) => {
const ran = await deps.plugins.host.runAction(pluginActionId);
if (!ran) deps.logger.warn(`Plugin action is not registered: ${pluginActionId}`);
return ran;
},
showMpvOsd: (text) => deps.getOverlayNotifications().showConfiguredPlaybackFeedback(text),
});
}
/** The mpv commands the overlay renderer sends over IPC (`mpv:command`). */
const mpvCommandDeps: MpvCommandFromIpcRuntimeDeps = {
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => overlayModals.openRuntimeOptionsPalette(),
openJimaku: () => overlayModals.openJimakuOverlay(),
openTsukihime: () => overlayModals.openTsukihimeOverlay(),
openYoutubeTrackPicker: () => deps.youtube.openTrackPickerFromPlayback(),
openPlaylistBrowser: () => overlayModals.openPlaylistBrowser(),
cycleRuntimeOption,
showMpvOsd: (text: string) =>
deps.getOverlayNotifications().showConfiguredStatusNotification(text),
showRawMpvOsd: (text: string) => deps.showMpvOsd(text),
showPlaybackFeedback: (text: string) =>
deps.getOverlayNotifications().showConfiguredPlaybackFeedback(text),
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
sendMpvCommand: (rawCommand: (string | number)[]) => deps.sendRendererMpvCommand(rawCommand),
getMpvClient: () => appState.mpvClient,
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null,
};
return { dispatchSessionAction, cycleSecondarySubMode, setSecondarySubMode, mpvCommandDeps };
}
+5 -7
View File
@@ -43,7 +43,6 @@ function makeDeps(overrides: Partial<StartupAssemblyDeps> = {}): StartupAssembly
hasMpvWebsocketPlugin: lazy('hasMpvWebsocketPlugin'),
loadSubtitlePosition: lazy('loadSubtitlePosition'),
setSecondarySubMode: lazy('setSecondarySubMode'),
syncOverlayMpvSubtitleSuppression: lazy('syncOverlayMpvSubtitleSuppression'),
yomitan: thrower('yomitan'),
stats: { ensureImmersionTrackerStarted: lazy('ensureImmersionTrackerStarted') } as never,
firstRunSetup: thrower('firstRunSetup'),
@@ -57,9 +56,6 @@ function makeDeps(overrides: Partial<StartupAssemblyDeps> = {}): StartupAssembly
getPlayback: lazy('getPlayback'),
getPluginHost: lazy('getPluginHost'),
getOverlayNotifications: lazy('getOverlayNotifications'),
getOverlayVisibilityComposer: lazy('getOverlayVisibilityComposer'),
getVisibleOverlayInteraction: lazy('getVisibleOverlayInteraction'),
getOverlayGeometry: lazy('getOverlayGeometry'),
handleInitialArgs: lazy('handleInitialArgs'),
...overrides,
};
@@ -89,9 +85,11 @@ function makePrereqDeps(calls: string[]): Partial<StartupAssemblyDeps> {
getSubtitlePrefetchService: () => ({ onSeek: record('prefetch.onSeek') }),
getLastObservedTimePos: () => 12,
}) as unknown as ReturnType<StartupAssemblyDeps['getPlayback']>,
getOverlayVisibilityComposer: () => ({
broadcastRuntimeOptionsChanged: record('broadcastRuntimeOptionsChanged'),
}),
overlay: {
visibilityComposer: {
broadcastRuntimeOptionsChanged: record('broadcastRuntimeOptionsChanged'),
},
} as unknown as StartupAssemblyDeps['overlay'],
};
}
+19 -30
View File
@@ -17,14 +17,9 @@ import { SubtitleTimingTracker } from '../../subtitle-timing-tracker';
import type { SecondarySubMode, WindowGeometry } from '../../types';
import { mergeAiConfig } from '../../ai/config';
import type { OverlayModalRuntime } from '../overlay-runtime';
import {
composeAppReadyRuntime,
type AppReadyComposerOptions,
type composeOverlayVisibilityRuntime,
} from '../runtime/composers';
import { composeAppReadyRuntime, type AppReadyComposerOptions } from '../runtime/composers';
import type { createConfigDerivedRuntime } from '../runtime/domains/startup';
import { shouldAutoOpenFirstRunSetup } from '../runtime/first-run-setup-service';
import type { createOverlayGeometryRuntime } from '../runtime/overlay-geometry-runtime';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import { createOverlayRuntimeBootstrapHandlers } from '../runtime/overlay-runtime-bootstrap-handlers';
import {
@@ -32,7 +27,6 @@ import {
shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
shouldRefreshAnilistOnConfigReload,
} from '../runtime/startup-mode-flags';
import type { VisibleOverlayInteractionRuntime } from '../runtime/visible-overlay-interaction-runtime';
import type { AppState } from '../state';
import type { AnilistAssembly } from './anilist';
import type { FirstRunSetupAssembly } from './first-run-setup';
@@ -82,7 +76,6 @@ export interface StartupAssemblyDeps {
hasMpvWebsocketPlugin: () => boolean;
loadSubtitlePosition: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
syncOverlayMpvSubtitleSuppression: () => void;
yomitan: Pick<YomitanAssembly, 'loadExtension' | 'ensureExtensionLoaded'>;
stats: Pick<StatsAssembly, 'ensureImmersionTrackerStarted' | 'immersionTrackerStartupDeps'>;
firstRunSetup: Pick<FirstRunSetupAssembly, 'service' | 'openWindow'>;
@@ -98,7 +91,18 @@ export interface StartupAssemblyDeps {
| 'createMainWindow'
| 'setVisibleOverlayVisible'
| 'ensureOverlayWindowsReadyForVisibilityActions'
>;
| 'syncOverlayMpvSubtitleSuppression'
> & {
visibilityComposer: Pick<
OverlayAssembly['visibilityComposer'],
'broadcastRuntimeOptionsChanged'
>;
interaction: Pick<
OverlayAssembly['interaction'],
'createOverlayWindowTracker' | 'bindVisibleOverlayOwner' | 'releaseVisibleOverlayOwner'
>;
geometry: Pick<OverlayAssembly['geometry'], 'updateVisibleOverlayBounds'>;
};
shortcuts: Pick<
ShortcutsAssembly,
| 'overlayShortcutsRuntime'
@@ -125,19 +129,6 @@ export interface StartupAssemblyDeps {
ReturnType<typeof createOverlayNotificationsRuntime>,
'showOverlayNotification' | 'dismissOverlayNotification'
>;
getOverlayVisibilityComposer: () => Pick<
ReturnType<typeof composeOverlayVisibilityRuntime>,
'broadcastRuntimeOptionsChanged'
>;
/** Pointer interaction and geometry stay in `main.ts`: both import Electron at module load. */
getVisibleOverlayInteraction: () => Pick<
VisibleOverlayInteractionRuntime,
'createOverlayWindowTracker' | 'bindVisibleOverlayOwner' | 'releaseVisibleOverlayOwner'
>;
getOverlayGeometry: () => Pick<
ReturnType<typeof createOverlayGeometryRuntime>,
'updateVisibleOverlayBounds'
>;
/** Runs the CLI command the app was launched with. */
handleInitialArgs: () => void;
}
@@ -167,7 +158,7 @@ export function createStartupAssembly(deps: StartupAssemblyDeps) {
const playback = deps.getPlayback();
playback.subtitleProcessingController.invalidateTokenizationCache();
playback.getSubtitlePrefetchService()?.onSeek(playback.getLastObservedTimePos());
deps.getOverlayVisibilityComposer().broadcastRuntimeOptionsChanged();
deps.overlay.visibilityComposer.broadcastRuntimeOptionsChanged();
deps.shortcuts.refreshOverlayShortcuts();
},
});
@@ -233,13 +224,11 @@ export function createStartupAssembly(deps: StartupAssemblyDeps) {
deps.shortcuts.registerGlobalShortcuts();
},
createWindowTracker: (override, targetMpvSocketPath) =>
deps
.getVisibleOverlayInteraction()
.createOverlayWindowTracker(override, targetMpvSocketPath),
deps.overlay.interaction.createOverlayWindowTracker(override, targetMpvSocketPath),
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.getOverlayGeometry().updateVisibleOverlayBounds(geometry),
bindOverlayOwner: () => deps.getVisibleOverlayInteraction().bindVisibleOverlayOwner(),
releaseOverlayOwner: () => deps.getVisibleOverlayInteraction().releaseVisibleOverlayOwner(),
deps.overlay.geometry.updateVisibleOverlayBounds(geometry),
bindOverlayOwner: () => deps.overlay.interaction.bindVisibleOverlayOwner(),
releaseOverlayOwner: () => deps.overlay.interaction.releaseVisibleOverlayOwner(),
getOverlayWindows: () => deps.overlayManager.getOverlayWindows(),
getResolvedConfig: () => configService.getConfig(),
showDesktopNotification,
@@ -280,7 +269,7 @@ export function createStartupAssembly(deps: StartupAssemblyDeps) {
if (appState.ankiIntegration) {
deps.mining.attachToAnkiIntegration(appState.ankiIntegration);
}
deps.syncOverlayMpvSubtitleSuppression();
deps.overlay.syncOverlayMpvSubtitleSuppression();
}
/** Creates whatever overlay commands need that app-ready skipped on a deferred start. */
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { BrowserWindow, IpcMainInvokeEvent, WebContents } from 'electron';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { createAppState } from '../state';
import {
createSubtitleGenerationAssembly,
type SubtitleGenerationAssemblyDeps,
} from './subtitle-generation';
type Handler = (event: IpcMainInvokeEvent, payload: unknown) => unknown;
function lazy(name: string) {
return (): never => {
throw new Error(`${name} called unexpectedly`);
};
}
function makeDeps(overrides: Partial<SubtitleGenerationAssemblyDeps> = {}) {
const thrower = <T extends object>(name: string): T =>
new Proxy({}, { get: (_target, key: string) => lazy(`${name}.${key}`) }) as T;
const deps: SubtitleGenerationAssemblyDeps = {
appState: createAppState({ mpvSocketPath: '/tmp/subminer-subgen.sock', texthookerPort: 0 }),
getConfig: lazy('getConfig'),
getModelDirectory: lazy('getModelDirectory'),
overlayManager: thrower('overlayManager'),
overlayModals: thrower('overlayModals'),
youtube: {
whisperSubtitles: thrower('youtube.whisperSubtitles'),
flow: thrower('youtube.flow'),
playback: thrower('youtube.playback'),
},
jellyfin: thrower('jellyfin'),
ipcMain: thrower('ipcMain'),
...overrides,
};
return deps;
}
test('createSubtitleGenerationAssembly does not call its deps while constructing', () => {
const subtitleGeneration = createSubtitleGenerationAssembly(makeDeps());
assert.equal(typeof subtitleGeneration.registerIpc, 'function');
});
test('subtitle generation IPC answers the overlay windows and refuses other senders', () => {
const overlayContents = {} as WebContents;
const overlayWindow = {
isDestroyed: () => false,
webContents: overlayContents,
} as unknown as BrowserWindow;
const handlers = new Map<string, Handler>();
const subtitleGeneration = createSubtitleGenerationAssembly(
makeDeps({
overlayManager: { getMainWindow: () => overlayWindow, getModalWindow: () => null },
ipcMain: {
handle: (channel: string, handler: Handler) => {
handlers.set(channel, handler);
},
},
}),
);
subtitleGeneration.registerIpc();
const cancel = handlers.get(IPC_CHANNELS.request.cancelSubtitleGeneration);
assert.ok(cancel);
assert.throws(
() => cancel({ sender: {} as WebContents } as IpcMainInvokeEvent, undefined),
/only available from the overlay/,
);
assert.doesNotThrow(() => cancel({ sender: overlayContents } as IpcMainInvokeEvent, undefined));
});
@@ -0,0 +1,94 @@
import path from 'node:path';
import type { IpcMain } from 'electron';
import { toJellyfinOriginalStreamUrl, type createOverlayManager } from '../../core/services';
import type { ResolvedConfig } from '../../types';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { registerSubtitleGenerationIpc } from '../runtime/subtitle-generation-ipc';
import { openSubtitleGenerationModal } from '../runtime/subtitle-generation-open';
import { createSubtitleGenerationRuntime } from '../runtime/subtitle-generation-runtime';
import type { AppState } from '../state';
import type { JellyfinAssembly } from './jellyfin';
import type { OverlayModalsAssembly } from './overlay-modals';
import type { YoutubeAssembly } from './youtube';
export interface SubtitleGenerationAssemblyDeps {
appState: Pick<AppState, 'mpvClient'>;
getConfig: () => ResolvedConfig;
/** Managed Whisper models live next to the config file. */
getModelDirectory: () => string;
overlayManager: Pick<ReturnType<typeof createOverlayManager>, 'getMainWindow' | 'getModalWindow'>;
overlayModals: Pick<OverlayModalsAssembly, 'createOverlayHostedModalOpenDeps'>;
youtube: {
whisperSubtitles: Pick<YoutubeAssembly['whisperSubtitles'], 'generate'>;
flow: Pick<YoutubeAssembly['flow'], 'createSubtitleTempDir'>;
playback: Pick<YoutubeAssembly['playback'], 'getYoutubeSourceUrlForStream'>;
};
jellyfin: Pick<JellyfinAssembly, 'getActivePlayback'>;
ipcMain: Pick<IpcMain, 'handle'>;
}
export type SubtitleGenerationAssembly = ReturnType<typeof createSubtitleGenerationAssembly>;
/**
* Owns Whisper subtitle generation for the playing media (local files, YouTube and Jellyfin
* streams): the generation runtime, its overlay modal and its renderer IPC.
*/
export function createSubtitleGenerationAssembly(deps: SubtitleGenerationAssemblyDeps) {
const { appState, overlayManager, youtube } = deps;
function getOverlayWindows() {
return [overlayManager.getMainWindow(), overlayManager.getModalWindow()];
}
const runtime = createSubtitleGenerationRuntime({
getConfig: () => deps.getConfig().subtitleGeneration,
getModelDirectory: () => deps.getModelDirectory(),
generateYoutube: async ({ url, config, signal, onProgress }) =>
youtube.whisperSubtitles.generate({
url,
config,
signal,
onProgress,
outputPath: path.join(await youtube.flow.createSubtitleTempDir(), 'youtube-whisper.ja.srt'),
}),
resolveYoutubeSourceUrl: (mediaPath) =>
youtube.playback.getYoutubeSourceUrlForStream(mediaPath),
resolveJellyfinMedia: (mediaPath) => {
const playback = deps.jellyfin.getActivePlayback();
const sourceUrl = playback && toJellyfinOriginalStreamUrl(mediaPath, playback.itemId);
return sourceUrl
? {
sourceUrl,
transcoded: playback.playMethod === 'Transcode',
audioStreamIndex: playback.audioStreamIndex ?? null,
}
: null;
},
getMpvClient: () => appState.mpvClient,
onProgress: (progress) => {
for (const window of getOverlayWindows()) {
if (window && !window.isDestroyed())
window.webContents.send(IPC_CHANNELS.event.subtitleGenerationProgress, progress);
}
},
});
function openModal(): Promise<boolean> {
return openSubtitleGenerationModal(deps.overlayModals.createOverlayHostedModalOpenDeps());
}
/** Registers the generation IPC; only the overlay windows may call it. */
function registerIpc(): void {
registerSubtitleGenerationIpc({
ipc: deps.ipcMain,
isAllowedSender: (sender) =>
getOverlayWindows().some(
(window) => window && !window.isDestroyed() && window.webContents === sender,
),
runtime,
openModal,
});
}
return { runtime, openModal, registerIpc };
}
-148
View File
@@ -1,148 +0,0 @@
// Interim source-text guards: each test regexes src/main.ts (or a runtime file) to
// pin a startup/overlay invariant that main.ts closures make hard to exercise.
// Replace each with a behavioral test as the logic moves out of main.ts closures
// (see docs/workflow/testing.md, Known Debt). Do not add new tests here.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
function readMainSource(): string {
return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8');
}
function readSource(relPath: string): string {
return fs.readFileSync(path.join(process.cwd(), relPath), 'utf8');
}
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(
/async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise<void> \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(
actionBlock,
/const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/,
);
assert.ok(
actionBlock.indexOf('deps.initSubtitlePrefetch(') <
actionBlock.indexOf('deps.setActiveParsedSubtitleMediaPath(nextMediaPath);'),
);
});
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(
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(
actionBlock,
/const cachedPayload = subtitleProcessingController\.consumeCachedSubtitle\(text\);/,
);
assert.match(actionBlock, /if \(cachedPayload\) \{/);
assert.match(actionBlock, /emitSubtitlePayload\(cachedPayload\);/);
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\);/);
assert.ok(
actionBlock.indexOf('consumeCachedSubtitle(text)') <
actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text);'),
);
});
test('startup autoplay release is tied to visible overlay measurement readiness', () => {
const source = readMainSource();
const gateBlock = source.match(
/const autoplayReadyGate = createAutoplayReadyGate\(\{(?<body>[\s\S]*?)\n\}\);/,
)?.groups?.body;
assert.ok(gateBlock);
assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/);
// Untokenized signals are filtered by the gate's isTokenizationReady dep, not
// by the target-readiness predicate (which must stay warmup-free so warm and
// tokenized releases are never deferred behind the global warmup flag).
assert.match(gateBlock, /isTokenizationReady:\s*\(\) => isTokenizationWarmupReady\(\)/);
const signalTargetReadyBlock = gateBlock.match(
/isSignalTargetReady:\s*\(signal\) =>(?<body>[\s\S]*?)\n schedule:/,
)?.groups?.body;
assert.ok(signalTargetReadyBlock);
assert.doesNotMatch(signalTargetReadyBlock, /isTokenizationWarmupReady\(\)/);
assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/);
assert.match(gateBlock, /getLatestVisibleMeasurement:/);
});
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(
/const buildStatsServerConfig[\s\S]*?return \{(?<body>[\s\S]*?)\n \};\n \};/,
)?.groups?.body;
const addYomitanNoteBlock = statsServerConfigBlock?.match(
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(addYomitanNoteBlock);
assert.match(
addYomitanNoteBlock,
/const ankiConnectConfig = deps\.getResolvedConfig\(\)\.ankiConnect;/,
);
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
});
test('visible overlay input reset clears the visible content measurement', () => {
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
const resetBlock = runtimeSource.match(
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body;
assert.ok(resetBlock);
assert.match(resetBlock, /overlayContentMeasurementStore\.clear\('visible'\);/);
});
test('Linux visible overlay startup reapplies passive passthrough after input reset', () => {
const pointerSource = readSource('src/main/runtime/linux-overlay-pointer-interaction.ts');
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
const resetPrimerBlock = runtimeSource.match(
/function resetLinuxVisibleOverlayStartupInputPrimer\(\): void \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body;
const startGraceBlock = runtimeSource.match(
/function startLinuxVisibleOverlayStartupInputGrace\(\): void \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body;
const depsBlock = runtimeSource.match(
/const linuxOverlayPointerInteractionDeps = \{(?<body>[\s\S]*?)\n \};/,
)?.groups?.body;
assert.ok(resetPrimerBlock);
assert.ok(startGraceBlock);
assert.ok(depsBlock);
assert.match(resetPrimerBlock, /visibleOverlayInteractionActive = false;/);
assert.match(resetPrimerBlock, /linuxOverlayPointerInteractionStateApplied = false;/);
assert.match(
startGraceBlock,
/linuxVisibleOverlayStartupInputGraceUntilMs =\s+Date\.now\(\) \+ LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;/,
);
assert.match(startGraceBlock, /linuxOverlayPointerInteractionStateApplied = false;/);
assert.match(
depsBlock,
/isInteractionStateApplied:\s*\(\) => linuxOverlayPointerInteractionStateApplied/,
);
assert.match(
pointerSource,
/deps\.getInteractionActive\(\) === desired && deps\.isInteractionStateApplied\?\.\(\) !== false/,
);
});
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(
/afterSetOverlayWindowBounds:\s*\(\) => \{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(afterBoundsBlock);
assert.match(afterBoundsBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
assert.ok(
afterBoundsBlock.indexOf('restoreLinuxOverlayWindowShape(mainWindow);') <
afterBoundsBlock.indexOf('ensureOverlayWindowLevel(mainWindow);'),
);
});
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_CONFIG } from '../../config/definitions';
import type { ResolvedConfig } from '../../types';
import type { RuntimeOptionValue } from '../../types/runtime-options';
import { createAnnotationOptions, type AnnotationRuntimeOptionId } from './annotation-options';
/** Every annotation off in config; `runtime` holds the options the user has cycled. */
function setup(runtime: Partial<Record<AnnotationRuntimeOptionId, RuntimeOptionValue>> = {}) {
const config: ResolvedConfig = {
...DEFAULT_CONFIG,
ankiConnect: {
...DEFAULT_CONFIG.ankiConnect,
knownWords: { ...DEFAULT_CONFIG.ankiConnect.knownWords, highlightEnabled: false },
nPlusOne: { ...DEFAULT_CONFIG.ankiConnect.nPlusOne, enabled: false },
},
subtitleStyle: {
...DEFAULT_CONFIG.subtitleStyle,
enableJlpt: false,
nameMatchEnabled: true,
frequencyDictionary: { ...DEFAULT_CONFIG.subtitleStyle.frequencyDictionary, enabled: false },
},
};
return createAnnotationOptions({
getConfig: () => config,
getRuntimeOptionValue: (id) => runtime[id],
isCharacterDictionaryEnabled: () => false,
});
}
test('MeCab starts only when some annotation, from config or a runtime option, needs tokens', () => {
assert.equal(setup().shouldInitializeMecabForAnnotations(), false);
assert.equal(
setup({ 'subtitle.annotation.jlpt': true }).shouldInitializeMecabForAnnotations(),
true,
);
});
test('a runtime option overrides config only when it holds a boolean', () => {
const options = setup({ 'subtitle.annotation.nPlusOne': true, 'subtitle.annotation.jlpt': 'on' });
assert.equal(options.getRuntimeBooleanOption('subtitle.annotation.nPlusOne', false), true);
assert.equal(options.getRuntimeBooleanOption('subtitle.annotation.jlpt', false), false);
assert.equal(options.getRuntimeBooleanOption('subtitle.annotation.frequency', true), true);
});
test('the texthooker colors names only while the character dictionary is enabled', () => {
const settings = setup({
'subtitle.annotation.knownWords.highlightEnabled': true,
}).getTexthookerBootstrapSettings();
assert.equal(settings.enableKnownWordColoring, true);
assert.equal(settings.enableNPlusOneColoring, false);
assert.equal(settings.characterDictionaryEnabled, false);
assert.equal(settings.enableNameMatchColoring, false);
});
+98
View File
@@ -0,0 +1,98 @@
import type { TexthookerBootstrapSettings } from '../../core/services/texthooker';
import type { ResolvedConfig } from '../../types';
import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-options';
/** The subtitle annotation toggles the user can flip at runtime from the options palette. */
export type AnnotationRuntimeOptionId = Extract<
RuntimeOptionId,
| 'subtitle.annotation.knownWords.highlightEnabled'
| 'subtitle.annotation.nPlusOne'
| 'subtitle.annotation.jlpt'
| 'subtitle.annotation.frequency'
>;
export interface AnnotationOptionsDeps {
getConfig: () => ResolvedConfig;
/** The runtime option's current value; undefined before the options manager exists. */
getRuntimeOptionValue: (id: AnnotationRuntimeOptionId) => RuntimeOptionValue | undefined;
isCharacterDictionaryEnabled: () => boolean;
}
/**
* Resolves which subtitle annotations are on. A runtime option the user cycled wins over its
* config value; the texthooker page and the MeCab startup decision both read through here.
*/
export function createAnnotationOptions(deps: AnnotationOptionsDeps) {
function getRuntimeBooleanOption(id: AnnotationRuntimeOptionId, fallback: boolean): boolean {
const value = deps.getRuntimeOptionValue(id);
return typeof value === 'boolean' ? value : fallback;
}
/** MeCab is only worth starting when at least one annotation needs tokens. */
function shouldInitializeMecabForAnnotations(): boolean {
const config = deps.getConfig();
const knownWordsEnabled = getRuntimeBooleanOption(
'subtitle.annotation.knownWords.highlightEnabled',
config.ankiConnect.knownWords.highlightEnabled,
);
const nPlusOneEnabled = getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
config.ankiConnect.nPlusOne.enabled,
);
const jlptEnabled = getRuntimeBooleanOption(
'subtitle.annotation.jlpt',
config.subtitleStyle.enableJlpt,
);
const frequencyEnabled = getRuntimeBooleanOption(
'subtitle.annotation.frequency',
config.subtitleStyle.frequencyDictionary.enabled,
);
return knownWordsEnabled || nPlusOneEnabled || jlptEnabled || frequencyEnabled;
}
/** Annotation colors and toggles the texthooker page renders with. */
function getTexthookerBootstrapSettings(): TexthookerBootstrapSettings {
const config = deps.getConfig();
const characterDictionaryEnabled =
config.subtitleStyle.nameMatchEnabled && deps.isCharacterDictionaryEnabled();
const knownWordColoringEnabled = getRuntimeBooleanOption(
'subtitle.annotation.knownWords.highlightEnabled',
config.ankiConnect.knownWords.highlightEnabled,
);
const nPlusOneColoringEnabled = getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
config.ankiConnect.nPlusOne.enabled,
);
return {
enableKnownWordColoring: knownWordColoringEnabled,
enableNPlusOneColoring: nPlusOneColoringEnabled,
enableNameMatchColoring: config.subtitleStyle.nameMatchEnabled && characterDictionaryEnabled,
enableFrequencyColoring: getRuntimeBooleanOption(
'subtitle.annotation.frequency',
config.subtitleStyle.frequencyDictionary.enabled,
),
enableJlptColoring: getRuntimeBooleanOption(
'subtitle.annotation.jlpt',
config.subtitleStyle.enableJlpt,
),
characterDictionaryEnabled,
knownWordColor: config.subtitleStyle.knownWordColor,
nPlusOneColor: config.subtitleStyle.nPlusOneColor,
nameMatchColor: config.subtitleStyle.nameMatchColor,
hoverTokenColor: config.subtitleStyle.hoverTokenColor,
hoverTokenBackgroundColor: config.subtitleStyle.hoverTokenBackgroundColor,
jlptColors: config.subtitleStyle.jlptColors,
frequencyDictionary: {
singleColor: config.subtitleStyle.frequencyDictionary.singleColor,
bandedColors: config.subtitleStyle.frequencyDictionary.bandedColors,
},
};
}
return {
getRuntimeBooleanOption,
shouldInitializeMecabForAnnotations,
getTexthookerBootstrapSettings,
};
}
@@ -465,3 +465,121 @@ test('prefetch stays paused until tokenization of an uncached line completes', a
'prefetch:resume',
]);
});
/** Deps whose calls append to `calls`; tests override what they exercise. */
function makeRecordingDeps(
calls: string[],
overrides: Partial<Parameters<typeof createAutoplaySubtitlePrimingRuntime>[0]> = {},
): Parameters<typeof createAutoplaySubtitlePrimingRuntime>[0] {
let currentSubText = '';
return {
getCurrentMediaPath: () => '/media/video.mkv',
getMpvClient: () => null,
setCurrentSubText: (text) => {
currentSubText = text;
calls.push(`set:${text}`);
},
getCurrentSubText: () => currentSubText,
getCurrentSubtitleData: () => null,
getActiveParsedSubtitleCues: () => [],
setActiveParsedSubtitleMediaPath: (mediaPath) => calls.push(`tag:${mediaPath}`),
subtitleProcessingController: {
consumeCachedSubtitle: (text) => {
calls.push(`consume:${text}`);
return null;
},
onSubtitleChange: (text) => {
calls.push(`change:${text}`);
return true;
},
refreshCurrentSubtitle: (text) => {
calls.push(`refresh:${text}`);
return true;
},
notePlainSubtitleEmitted: () => {},
},
emitSubtitlePayload: (payload) =>
calls.push(`emit:${payload.text}:${payload.tokens ? 'annotated' : 'raw'}`),
getSubtitlePrefetchService: () => null,
getLastObservedTimePos: () => 12,
getVisibleOverlayVisible: () => true,
emitSecondarySubtitle: () => {},
initSubtitlePrefetch: async (sourcePath) => {
calls.push(`init:${sourcePath}`);
},
refreshSubtitlePrefetchFromActiveTrack: async () => {},
logDebug: () => {},
...overrides,
};
}
test('a cached line primes with its annotated payload and never paints it raw first', async () => {
const calls: string[] = [];
const mediaPath = '/media/video.mkv';
const cached: SubtitleData = { text: '起動字幕', tokens: [] };
const runtime = createAutoplaySubtitlePrimingRuntime(
makeRecordingDeps(calls, {
getMpvClient: () => ({
connected: true,
currentVideoPath: mediaPath,
requestProperty: async (name) => (name === 'sub-text' ? '起動字幕' : null),
}),
subtitleProcessingController: {
consumeCachedSubtitle: (text) => {
calls.push(`consume:${text}`);
return cached;
},
onSubtitleChange: (text) => {
calls.push(`change:${text}`);
return false;
},
refreshCurrentSubtitle: () => true,
notePlainSubtitleEmitted: () => {},
},
}),
);
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
assert.deepEqual(calls, [
'set:起動字幕',
// Consumed before the controller hears about the line, which would otherwise schedule
// its own tokenization of it.
'consume:起動字幕',
'change:起動字幕',
'emit:起動字幕:annotated',
]);
});
test('the subtitle sidebar tags its media path only once prefetch has loaded the source', async () => {
const calls: string[] = [];
const runtime = createAutoplaySubtitlePrimingRuntime(makeRecordingDeps(calls));
await runtime.refreshSubtitleSidebarFromSource('/subs/episode.srt', ' /media/other.mkv ');
await runtime.refreshSubtitleSidebarFromSource('/subs/episode.srt');
assert.deepEqual(calls, [
'init:/subs/episode.srt',
'tag:/media/other.mkv',
'init:/subs/episode.srt',
// Without an explicit media path the tag falls back to the current media.
'tag:/media/video.mkv',
]);
});
test('a failed subtitle sidebar prefetch leaves the media path untagged', async () => {
const calls: string[] = [];
const runtime = createAutoplaySubtitlePrimingRuntime(
makeRecordingDeps(calls, {
initSubtitlePrefetch: async () => {
throw new Error('unreadable subtitle file');
},
}),
);
await assert.rejects(
runtime.refreshSubtitleSidebarFromSource('/subs/episode.srt', '/media/other.mkv'),
/unreadable subtitle file/,
);
assert.deepEqual(calls, []);
});
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import type { BrowserWindow, Display } from 'electron';
import type { HyprlandPlacementStatus } from '../../core/services/hyprland-window-placement';
import {
createOverlayGeometryRuntime,
type OverlayGeometryRuntimeDeps,
} from './overlay-geometry-runtime';
const NO_PLACEMENT: HyprlandPlacementStatus = {
applicable: false,
clientFound: false,
dispatched: false,
};
const DISPLAY = { bounds: { x: 0, y: 0, width: 1920, height: 1080 } } as Display;
/** Builds the runtime with `process.platform` set and a visible overlay window that logs calls. */
function setup(t: TestContext, platform: NodeJS.Platform) {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
t.after(() => Object.defineProperty(process, 'platform', { value: originalPlatform }));
const windowCalls: string[] = [];
const record =
(name: string) =>
(..._args: unknown[]) => {
windowCalls.push(name);
};
const mainWindow = {
isDestroyed: () => false,
getTitle: () => 'SubMiner Overlay',
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
setShape: record('setShape'),
setAlwaysOnTop: record('setAlwaysOnTop'),
setVisibleOnAllWorkspaces: record('setVisibleOnAllWorkspaces'),
setFullScreenable: record('setFullScreenable'),
moveTop: record('moveTop'),
} as unknown as BrowserWindow;
const noop = () => {};
const deps: OverlayGeometryRuntimeDeps = {
screen: {
getCursorScreenPoint: () => ({ x: 0, y: 0 }),
getDisplayNearestPoint: () => DISPLAY,
getDisplayMatching: () => DISPLAY,
},
promoteStatsOverlayAbovePlayback: noop,
overlayManager: {
getMainWindow: () => mainWindow,
getModalWindow: () => null,
getVisibleOverlayVisible: () => true,
setOverlayWindowBounds: () => NO_PLACEMENT,
setModalWindowBounds: () => NO_PLACEMENT,
},
getTrackedWindowGeometry: () => null,
getTrackedWindowMediaSourceId: () => null,
getTrackedWindowNativeId: () => null,
getStatsOverlayVisible: () => false,
getOverlayForegroundSeparateWindows: () => [],
getLinuxVisibleOverlayWindowMode: () => 'managed',
getLinuxTrackedMpvFullscreen: () => false,
getLinuxTrackedMpvFullscreenChangedAtMs: () => 0,
syncLinuxVisibleOverlayMpvFullscreenMode: noop,
getLinuxVisibleOverlayOwnerBindingKey: () => null,
setLinuxVisibleOverlayOwnerBindingKey: noop,
clearVisibleOverlayX11OwnerBinding: noop,
getNativeWindowHandleDecimal: () => '0',
enqueueVisibleOverlayX11OwnerBindingOperation: noop,
scheduleWindowsVisibleOverlayZOrderSyncBurst: noop,
logDebug: noop,
};
return { geometry: createOverlayGeometryRuntime(deps), windowCalls };
}
// New mpv bounds can leave the X11 input shape sized for the old window; restore it to the full
// window before re-asserting the overlay's level so the whole overlay stays clickable.
test('a Linux bounds update restores the overlay window shape before raising it', (t) => {
const { geometry, windowCalls } = setup(t, 'linux');
geometry.updateVisibleOverlayBounds({ x: 10, y: 20, width: 1280, height: 720 });
const shapeIndex = windowCalls.indexOf('setShape');
assert.ok(shapeIndex >= 0, 'window shape was not restored');
assert.ok(shapeIndex < windowCalls.indexOf('setAlwaysOnTop'));
});
test('a macOS bounds update raises the overlay without touching the window shape', (t) => {
const { geometry, windowCalls } = setup(t, 'darwin');
geometry.updateVisibleOverlayBounds({ x: 10, y: 20, width: 1280, height: 720 });
assert.ok(windowCalls.includes('setAlwaysOnTop'));
assert.ok(!windowCalls.includes('setShape'));
});
+10 -7
View File
@@ -1,4 +1,4 @@
import { type BrowserWindow, screen } from 'electron';
import type { BrowserWindow, Screen } from 'electron';
import type { WindowGeometry } from '../../types';
import { hasHyprlandWindowPlacementBoundsMismatch } from '../../core/services/hyprland-window-placement';
import { normalizeOverlayWindowBoundsForPlatform } from '../../core/services/overlay-window-bounds';
@@ -7,7 +7,6 @@ import {
ensureOverlayWindowLevel as ensureOverlayWindowLevelCore,
syncOverlayWindowLayer,
} from '../../core/services/overlay-window';
import { promoteStatsOverlayAbovePlayback } from '../../core/services/stats-window.js';
import { restoreLinuxOverlayWindowShape } from './linux-overlay-window-shape';
import { shouldRunLinuxOverlayZOrderKeepAlive } from './linux-overlay-zorder-keepalive';
import {
@@ -25,6 +24,9 @@ import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './settings-w
const LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS = 1_200;
export interface OverlayGeometryRuntimeDeps {
/** Injected so this module loads without Electron (and under `bun test`). */
screen: Pick<Screen, 'getCursorScreenPoint' | 'getDisplayNearestPoint' | 'getDisplayMatching'>;
promoteStatsOverlayAbovePlayback: () => void;
overlayManager: {
getMainWindow: () => BrowserWindow | null;
getModalWindow: () => BrowserWindow | null;
@@ -60,8 +62,8 @@ export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) {
let lastOverlayWindowGeometry: WindowGeometry | null = null;
function getOverlayGeometryFallback(): WindowGeometry {
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
const cursorPoint = deps.screen.getCursorScreenPoint();
const display = deps.screen.getDisplayNearestPoint(cursorPoint);
const bounds = display.workArea;
return {
x: bounds.x,
@@ -106,7 +108,7 @@ export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) {
return false;
}
const displayBounds = screen.getDisplayMatching(geometry).bounds;
const displayBounds = deps.screen.getDisplayMatching(geometry).bounds;
return shouldExitFullscreenOverrideForTrackedGeometry({
currentMode: deps.getLinuxVisibleOverlayWindowMode(),
trackedFullscreen: deps.getLinuxTrackedMpvFullscreen(),
@@ -137,7 +139,8 @@ export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) {
}
return hasHyprlandWindowPlacementBoundsMismatch({
title: window.getTitle(),
bounds: normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen, window),
// Linux bounds come from the window's content insets; the DIP converter is Windows-only.
bounds: normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, null, window),
});
});
}
@@ -190,7 +193,7 @@ export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) {
if (mainWindow && !mainWindow.isDestroyed()) {
moveVisibleOverlayAboveTrackedPlaybackWindow(mainWindow);
}
promoteStatsOverlayAbovePlayback();
deps.promoteStatsOverlayAbovePlayback();
},
});
+32 -14
View File
@@ -73,7 +73,10 @@ function createRuntimeHarness(
return { runtime, appStateValues };
}
test('dashboard word mining keeps the configured proxy as Hachidori Anki endpoint', async () => {
/** Mines a word from the stats dashboard; returns the Anki settings sent to Hachidori. */
async function mineDashboardWord(
ankiConnect: Partial<(typeof DEFAULT_CONFIG)['ankiConnect']>,
): Promise<Record<string, unknown>> {
const settings: unknown[] = [];
const context = vm.createContext({
chrome: {},
@@ -95,25 +98,40 @@ test('dashboard word mining keeps the configured proxy as Hachidori Anki endpoin
...createDeps(async (script) => structuredClone(await vm.runInContext(script, context))),
getResolvedConfig: () => ({
...DEFAULT_CONFIG,
ankiConnect: {
...DEFAULT_CONFIG.ankiConnect,
enabled: true,
url: 'http://127.0.0.1:8765',
proxy: {
...DEFAULT_CONFIG.ankiConnect.proxy,
enabled: true,
host: '127.0.0.1',
port: 8766,
},
},
ankiConnect: { ...DEFAULT_CONFIG.ankiConnect, ...ankiConnect },
}),
},
);
await runtime.ensureStatsServerStarted();
assert.equal(await configs[0]?.addYomitanNote?.('入れる'), 123);
assert.ok(settings[0] && typeof settings[0] === 'object' && 'server' in settings[0]);
assert.equal(settings[0].server, 'http://127.0.0.1:8766');
await runtime.stopStatsServer();
const sent = settings[0];
assert.ok(sent && typeof sent === 'object');
return sent as Record<string, unknown>;
}
test('dashboard word mining keeps the configured proxy as Hachidori Anki endpoint', async () => {
const sent = await mineDashboardWord({
enabled: true,
url: 'http://127.0.0.1:8765',
proxy: { ...DEFAULT_CONFIG.ankiConnect.proxy, enabled: true, host: '127.0.0.1', port: 8766 },
});
assert.equal(sent.server, 'http://127.0.0.1:8766');
assert.equal(sent.forceOverride, true);
});
// Without the proxy SubMiner does not own the endpoint, so a server the user set in
// Yomitan/Hachidori must survive dashboard mining.
test('dashboard word mining without the proxy does not override the Hachidori Anki server', async () => {
const sent = await mineDashboardWord({
enabled: true,
url: 'http://127.0.0.1:8765',
proxy: { ...DEFAULT_CONFIG.ankiConnect.proxy, enabled: false },
});
assert.equal(sent.server, 'http://127.0.0.1:8765');
assert.equal(sent.forceOverride, false);
});
test('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => {
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import type { BrowserWindow } from 'electron';
import { stopLinuxOverlayPointerInteractionLoop } from './linux-overlay-pointer-interaction';
import { stopLinuxOverlayZOrderKeepAliveLoop } from './linux-overlay-zorder-keepalive';
import {
createVisibleOverlayInteractionRuntime,
type VisibleOverlayInteractionRuntimeDeps,
} from './visible-overlay-interaction-runtime';
type MouseEventsCall = { ignore: boolean; options?: { forward?: boolean } };
/** Builds the runtime with `process.platform` set, a visible overlay and a fake main window. */
function setup(t: TestContext, platform: NodeJS.Platform) {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
t.after(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
// Construction on Linux starts the shared pointer poll and z-order keep-alive loops.
stopLinuxOverlayPointerInteractionLoop();
stopLinuxOverlayZOrderKeepAliveLoop();
});
const mouseEvents: MouseEventsCall[] = [];
const clearedLayers: string[] = [];
const mainWindow = {
isDestroyed: () => false,
isVisible: () => true,
isFocused: () => true,
getBounds: () => ({ x: 0, y: 0, width: 1920, height: 1080 }),
setShape: () => {},
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
mouseEvents.push({ ignore, options });
},
} as unknown as BrowserWindow;
const noop = () => {};
const deps: VisibleOverlayInteractionRuntimeDeps = {
screen: {
getCursorScreenPoint: () => ({ x: 5, y: 5 }),
screenToDipPoint: (point) => point,
},
overlayManager: {
getMainWindow: () => mainWindow,
getVisibleOverlayVisible: () => true,
},
overlayContentMeasurementStore: {
clear: (layer) => {
clearedLayers.push(layer);
},
getLatestByLayer: () => null,
},
logger: { info: noop, warn: noop, debug: noop },
updateVisibleOverlayVisibility: noop,
getModalInputExclusive: () => false,
getStatsOverlayVisible: () => false,
setStatsOverlayVisible: noop,
getWindowTracker: () => null,
setWindowTracker: noop,
setTrackerNotReadyWarningShown: noop,
getMpvSocketPath: () => '/tmp/subminer-interaction-test.sock',
getBackendOverride: () => null,
getInitialArgs: () => null,
getOverlayRuntimeInitialized: () => true,
getLinuxVisibleOverlayWindowMode: () => 'managed',
setLinuxVisibleOverlayOwnerBindingKey: noop,
bindVisibleOverlayToTrackedX11Window: noop,
updateVisibleOverlayBounds: noop,
refreshCurrentSubtitle: noop,
getOverlayWindows: () => [mainWindow],
syncOverlayShortcuts: noop,
resetLastOverlayWindowGeometry: noop,
enforceOverlayLayerOrder: noop,
getOverlayForegroundSeparateWindows: () => [],
};
const runtime = createVisibleOverlayInteractionRuntime(deps);
return { runtime, mouseEvents, clearedLayers };
}
test('resetting visible overlay input clears the visible content measurement', (t) => {
const { runtime, clearedLayers } = setup(t, 'darwin');
runtime.resetVisibleOverlayInputState();
assert.deepEqual(clearedLayers, ['visible']);
});
// With no subtitle under the cursor the poll wants passthrough. An input reset leaves the
// window's real state unknown, so the next poll must reapply it even though the desired state
// matches the remembered one. Hiding resets all input; showing resets only the startup primer.
for (const reset of [
'resetVisibleOverlayInputState',
'resetLinuxVisibleOverlayStartupInputPrimer',
] as const) {
test(`the Linux pointer poll reapplies passive passthrough after ${reset}`, (t) => {
const { runtime, mouseEvents } = setup(t, 'linux');
const passthrough: MouseEventsCall = { ignore: true, options: { forward: true } };
runtime.tickLinuxOverlayPointerInteractionNow();
runtime.tickLinuxOverlayPointerInteractionNow();
assert.deepEqual(mouseEvents, [passthrough]);
runtime[reset]();
runtime.tickLinuxOverlayPointerInteractionNow();
runtime.tickLinuxOverlayPointerInteractionNow();
assert.deepEqual(mouseEvents, [passthrough, passthrough]);
});
}
test('the Linux startup input grace makes the overlay interactive on the next poll', (t) => {
const { runtime, mouseEvents } = setup(t, 'linux');
runtime.tickLinuxOverlayPointerInteractionNow();
runtime.startLinuxVisibleOverlayStartupInputGrace();
runtime.tickLinuxOverlayPointerInteractionNow();
assert.deepEqual(mouseEvents.at(-1), { ignore: false, options: undefined });
assert.equal(runtime.getVisibleOverlayInteractionActive(), true);
});
test('the Linux startup input grace reasserts input on an overlay that is already interactive', (t) => {
const { runtime, mouseEvents } = setup(t, 'linux');
const interactive: MouseEventsCall = { ignore: false, options: undefined };
runtime.setLinuxOverlayInteractiveHint(true);
runtime.tickLinuxOverlayPointerInteractionNow();
runtime.tickLinuxOverlayPointerInteractionNow();
assert.deepEqual(mouseEvents, [interactive]);
runtime.startLinuxVisibleOverlayStartupInputGrace();
runtime.tickLinuxOverlayPointerInteractionNow();
assert.deepEqual(mouseEvents, [interactive, interactive]);
});
@@ -1,4 +1,4 @@
import { type BrowserWindow, screen } from 'electron';
import type { BrowserWindow, Screen } from 'electron';
import { execFile } from 'node:child_process';
import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services';
import { applyOverlayClickThrough } from '../../core/services/overlay-click-through';
@@ -36,6 +36,8 @@ import { hasLiveSeparateWindow } from './settings-window-z-order';
import { tickWindowsOverlayPointerInteraction } from './windows-overlay-pointer-interaction';
export interface VisibleOverlayInteractionRuntimeDeps {
/** Injected so this module loads without Electron (and under `bun test`). */
screen: Pick<Screen, 'getCursorScreenPoint' | 'screenToDipPoint'>;
overlayManager: {
getMainWindow: () => BrowserWindow | null;
getVisibleOverlayVisible: () => boolean;
@@ -289,7 +291,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
return null;
}
return createWindowTrackerCore(override, targetMpvSocketPath, (point) =>
screen.screenToDipPoint(point),
deps.screen.screenToDipPoint(point),
);
}
@@ -597,7 +599,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
const windowsOverlayPointerInteractionDeps = {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
getCursorScreenPoint: () => screen.getCursorScreenPoint(),
getCursorScreenPoint: () => deps.screen.getCursorScreenPoint(),
getSubtitleMeasurement: () => overlayContentMeasurementStore.getLatestByLayer('visible'),
shouldSuspend: shouldSuspendWindowsOverlayPointerInteraction,
getInteractionActive: () => windowsOverlayPointerInteractionActive,
@@ -617,7 +619,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
ensureWindowsVisibleOverlayForegroundPollLoop();
const linuxX11CursorPointReader = createLinuxX11CursorPointReader({
screenToDipPoint: (point) => screen.screenToDipPoint(point),
screenToDipPoint: (point) => deps.screen.screenToDipPoint(point),
});
function getLinuxOverlayPointerMeasurement() {
@@ -805,7 +807,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
getCursorScreenPoint: () =>
linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()),
linuxX11CursorPointReader.getCursorScreenPoint(deps.screen.getCursorScreenPoint()),
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
getRendererInteractiveHint: () =>
linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(),