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

+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 };
}