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

+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 => {