mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
refactor(main): move IPC, CLI and app-ready wiring into assemblies (#311)
This commit is contained in:
1 parent
655510cf2e
commit
696462a82e
33 files changed
+3719
-2101
No files matched your search
@@ -0,0 +1,406 @@
|
||||
import { resolveMediaGenerationInput } from '../../anki-integration/media-source';
|
||||
import {
|
||||
captureLiveSubtitleMiningContext,
|
||||
copyCurrentSubtitle as copyCurrentSubtitleCore,
|
||||
createFieldGroupingOverlayRuntime,
|
||||
handleMineSentenceDigit as handleMineSentenceDigitCore,
|
||||
handleMultiCopyDigit as handleMultiCopyDigitCore,
|
||||
markLastCardAsAudioCard as markLastCardAsAudioCardCore,
|
||||
mineSentenceCard as mineSentenceCardCore,
|
||||
shiftSubtitleRangeToVideoClock,
|
||||
toSubtitleClock,
|
||||
triggerFieldGrouping as triggerFieldGroupingCore,
|
||||
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
||||
type createOverlayManager,
|
||||
} from '../../core/services';
|
||||
import { createMediaTimingFrameExtractor } from '../../core/services/media-timing-frame';
|
||||
import { MediaTimingPreviewSession } from '../../core/services/media-timing-preview';
|
||||
import { generateSpeechWaveform } from '../../core/services/media-timing-waveform';
|
||||
import { getSharedRemoteMediaWindowCache } from '../../core/services/remote-media-window-cache';
|
||||
import type { Logger } from '../../logger';
|
||||
import type { PluginMinedNote, PluginMiningProvenance } from '../../plugin-api';
|
||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import type { ResolvedConfig, SubtitleMiningContext } from '../../types';
|
||||
import type { OverlayModalRuntime } from '../overlay-runtime';
|
||||
import {
|
||||
createCopyCurrentSubtitleHandler,
|
||||
createHandleMineSentenceDigitHandler,
|
||||
createHandleMultiCopyDigitHandler,
|
||||
createMarkLastCardAsAudioCardHandler,
|
||||
createMineSentenceCardHandler,
|
||||
createRefreshKnownWordCacheHandler,
|
||||
createTriggerFieldGroupingHandler,
|
||||
createUpdateLastCardFromClipboardHandler,
|
||||
} from '../runtime/domains/mining';
|
||||
import {
|
||||
createGetFieldGroupingResolverHandler,
|
||||
createSetFieldGroupingResolverHandler,
|
||||
} from '../runtime/domains/overlay';
|
||||
import {
|
||||
collectMediaTimingContextLines,
|
||||
createMediaTimingReviewRuntime,
|
||||
} from '../runtime/media-timing-review';
|
||||
import { openMediaTimingReviewModal } from '../runtime/media-timing-review-open';
|
||||
import { findMpvExecutablePath } from '../runtime/mpv-process';
|
||||
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
||||
import { resolvePrimarySubtitle } from '../runtime/primary-subtitle-text';
|
||||
import type { AppState } from '../state';
|
||||
import type { OverlayModalsAssembly } from './overlay-modals';
|
||||
import type { CliSlice, IpcSlice } from './slices';
|
||||
import type { YoutubeAssembly } from './youtube';
|
||||
|
||||
type OverlayManager = ReturnType<typeof createOverlayManager>;
|
||||
type AnkiIntegrationLike = NonNullable<AppState['ankiIntegration']>;
|
||||
|
||||
/**
|
||||
* What card mining needs from the rest of the app. Siblings built after this assembly in
|
||||
* `main.ts` are reached through callbacks.
|
||||
*/
|
||||
export interface MiningAssemblyDeps {
|
||||
appState: Pick<
|
||||
AppState,
|
||||
| 'mpvClient'
|
||||
| 'currentMediaPath'
|
||||
| 'activeParsedSubtitleCues'
|
||||
| 'ankiIntegration'
|
||||
| 'subtitleTimingTracker'
|
||||
| 'fieldGroupingResolver'
|
||||
| 'fieldGroupingResolverSequence'
|
||||
>;
|
||||
getConfig: () => ResolvedConfig;
|
||||
logger: Pick<Logger, 'warn' | 'error'>;
|
||||
overlayManager: Pick<
|
||||
OverlayManager,
|
||||
'getMainWindow' | 'getModalWindow' | 'getVisibleOverlayVisible'
|
||||
>;
|
||||
overlayModalRuntime: Pick<
|
||||
OverlayModalRuntime,
|
||||
| 'getRestoreVisibleOverlayOnModalClose'
|
||||
| 'waitForModalOpen'
|
||||
| 'handleOverlayModalClosed'
|
||||
| 'sendToActiveOverlayWindow'
|
||||
>;
|
||||
youtube: Pick<
|
||||
YoutubeAssembly,
|
||||
'getCachedMediaPathForCurrentPlayback' | 'shouldRequireMediaCacheForCurrentPlayback'
|
||||
>;
|
||||
/** Electron's clipboard, injected so the assembly loads under `bun test`. */
|
||||
clipboard: { readText: () => string; writeText: (text: string) => void };
|
||||
getOverlayNotifications: () => Pick<
|
||||
ReturnType<typeof createOverlayNotificationsRuntime>,
|
||||
'showConfiguredStatusNotification' | 'showConfiguredPlaybackFeedback'
|
||||
>;
|
||||
getOverlayModals: () => Pick<OverlayModalsAssembly, 'createOverlayHostedModalOpenDeps'>;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
/** Callbacks the Anki integration reports back through once mining finishes. */
|
||||
ankiIntegrationCallbacks: {
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
getMiningProvenance: () => PluginMiningProvenance;
|
||||
onCardsReady: (noteIds: number[], notes: PluginMinedNote[]) => void;
|
||||
onKnownWordCacheUpdated: () => void;
|
||||
generateSentenceFurigana: (text: string, highlightedText?: string) => Promise<string | null>;
|
||||
};
|
||||
}
|
||||
|
||||
export type MiningAssembly = ReturnType<typeof createMiningAssembly>;
|
||||
|
||||
/**
|
||||
* Owns card mining: the subtitle context captured for the next card, the mine/copy/update
|
||||
* handlers behind shortcuts, CLI and session actions, field grouping, and the media timing
|
||||
* review the Anki integration opens before writing media.
|
||||
*/
|
||||
export function createMiningAssembly(deps: MiningAssemblyDeps) {
|
||||
const { appState, logger, overlayManager, overlayModalRuntime } = deps;
|
||||
const showStatus = (text: string) =>
|
||||
deps.getOverlayNotifications().showConfiguredStatusNotification(text);
|
||||
const showPlaybackFeedback = (text: string) =>
|
||||
deps.getOverlayNotifications().showConfiguredPlaybackFeedback(text);
|
||||
let pendingSubtitleMiningContext: SubtitleMiningContext | null = null;
|
||||
|
||||
function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | null {
|
||||
// Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active
|
||||
// event, so a finished caption row lingering beside a fresh line would end up on the
|
||||
// card. The parsed view also carries the cue's own timings for the clip range.
|
||||
// Cues sit on the subtitle clock, so the lookup undoes `sub-delay` first.
|
||||
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
|
||||
const resolved = resolvePrimarySubtitle({
|
||||
liveText: appState.mpvClient?.currentSubText ?? '',
|
||||
currentTimeSec: toSubtitleClock(Number(appState.mpvClient?.currentTimePos), subDelay),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
});
|
||||
// Same validity bar as the live capture path: an unusable resolved span must fall
|
||||
// back rather than hand mining an empty line or an inverted range.
|
||||
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 captureLiveSubtitleMiningContext(appState.mpvClient);
|
||||
}
|
||||
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
|
||||
{
|
||||
source: 'overlay',
|
||||
text: resolvedText,
|
||||
startTime: resolved.startTime,
|
||||
endTime: resolved.endTime,
|
||||
capturedAtMs: Date.now(),
|
||||
},
|
||||
subDelay,
|
||||
);
|
||||
}
|
||||
|
||||
const recordSubtitleMiningContext = (context: SubtitleMiningContext | null): void => {
|
||||
pendingSubtitleMiningContext = context;
|
||||
};
|
||||
/** Hands the recorded context to exactly one card, then forgets it. */
|
||||
const consumePendingSubtitleMiningContext = (): SubtitleMiningContext | null => {
|
||||
const context = pendingSubtitleMiningContext;
|
||||
pendingSubtitleMiningContext = null;
|
||||
return context;
|
||||
};
|
||||
|
||||
const getFieldGroupingResolver = createGetFieldGroupingResolverHandler({
|
||||
getResolver: () => appState.fieldGroupingResolver,
|
||||
});
|
||||
|
||||
const setFieldGroupingResolver = createSetFieldGroupingResolverHandler({
|
||||
setResolver: (resolver) => {
|
||||
appState.fieldGroupingResolver = resolver;
|
||||
},
|
||||
nextSequence: () => {
|
||||
appState.fieldGroupingResolverSequence += 1;
|
||||
return appState.fieldGroupingResolverSequence;
|
||||
},
|
||||
getSequence: () => appState.fieldGroupingResolverSequence,
|
||||
});
|
||||
|
||||
const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHostedModal>({
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
setVisibleOverlayVisible: (visible) => deps.setVisibleOverlayVisible(visible),
|
||||
getResolver: () => getFieldGroupingResolver(),
|
||||
setResolver: (resolver) => setFieldGroupingResolver(resolver),
|
||||
getRestoreVisibleOverlayOnModalClose: () =>
|
||||
overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(),
|
||||
// These are optional on the runtime options, so a missing forward compiles silently, but
|
||||
// dropping them leaves the field grouping modal with no modal-open ack/retry, no teardown on
|
||||
// failure, and no warn logging.
|
||||
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
|
||||
handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () =>
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions(),
|
||||
sendToVisibleOverlay: (channel, payload, runtimeOptions) =>
|
||||
overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
});
|
||||
|
||||
const sendToOverlayWindows = (channel: string, payload: unknown) => {
|
||||
// The review may live in either overlay window; the renderer ignores foreign review ids.
|
||||
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
|
||||
if (window && !window.isDestroyed()) {
|
||||
window.webContents.send(channel, payload);
|
||||
}
|
||||
}
|
||||
};
|
||||
const getMediaGenerationOptions = () => ({
|
||||
getCachedMediaPath: (currentVideoPath: string, kind: 'audio' | 'video') =>
|
||||
deps.youtube.getCachedMediaPathForCurrentPlayback(currentVideoPath, kind),
|
||||
remoteCacheMode: deps.youtube.shouldRequireMediaCacheForCurrentPlayback()
|
||||
? ('required' as const)
|
||||
: ('optional' as const),
|
||||
});
|
||||
|
||||
const mediaTimingFrameExtractor = createMediaTimingFrameExtractor();
|
||||
const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getCurrentMediaPath: () =>
|
||||
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
|
||||
// An invalid configured path is passed through so the preview fails instead of using PATH mpv.
|
||||
getMpvExecutablePath: () => {
|
||||
const configuredPath = deps.getConfig().mpv.executablePath;
|
||||
return findMpvExecutablePath(configuredPath) || configuredPath;
|
||||
},
|
||||
createPreviewSession: () => new MediaTimingPreviewSession(),
|
||||
generateWaveform: (options) => generateSpeechWaveform(options),
|
||||
generateFrame: (options) => mediaTimingFrameExtractor.generate(options),
|
||||
clearFrameCache: () => mediaTimingFrameExtractor.clear(),
|
||||
resolveVideoSource: () =>
|
||||
resolveMediaGenerationInput(appState.mpvClient, 'video', getMediaGenerationOptions()),
|
||||
resolveMediaSource: async () => {
|
||||
const resolved = await resolveMediaGenerationInput(
|
||||
appState.mpvClient,
|
||||
'audio',
|
||||
getMediaGenerationOptions(),
|
||||
);
|
||||
return resolved
|
||||
? {
|
||||
path: resolved.path,
|
||||
...(resolved.inputOptions ? { inputOptions: resolved.inputOptions } : {}),
|
||||
singleResolvedStream: resolved.singleResolvedStream,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
|
||||
getSubtitleContextLines: (range) =>
|
||||
collectMediaTimingContextLines({
|
||||
// Review ranges and tracker entries are on the video clock; parsed cues are not.
|
||||
cues: appState.activeParsedSubtitleCues.map((cue) =>
|
||||
shiftSubtitleRangeToVideoClock(cue, appState.mpvClient?.currentSubDelay ?? 0),
|
||||
),
|
||||
fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [],
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
}),
|
||||
openModal: (payload, signal) =>
|
||||
openMediaTimingReviewModal(
|
||||
deps.getOverlayModals().createOverlayHostedModalOpenDeps(),
|
||||
payload,
|
||||
signal,
|
||||
),
|
||||
onPreviewEnded: (reviewId) => {
|
||||
sendToOverlayWindows(IPC_CHANNELS.event.mediaTimingReviewPreviewEnded, reviewId);
|
||||
},
|
||||
onPreviewPosition: (position) => {
|
||||
sendToOverlayWindows(IPC_CHANNELS.event.mediaTimingReviewPreviewPosition, position);
|
||||
},
|
||||
showStatus: (message) =>
|
||||
deps
|
||||
.getOverlayNotifications()
|
||||
.showConfiguredStatusNotification(message, { variant: 'warning' }),
|
||||
});
|
||||
|
||||
const updateLastCardFromClipboard = createUpdateLastCardFromClipboardHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
readClipboardText: () => deps.clipboard.readText(),
|
||||
showMpvOsd: showStatus,
|
||||
updateLastCardFromClipboardCore,
|
||||
});
|
||||
|
||||
const refreshKnownWordCache = createRefreshKnownWordCacheHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
missingIntegrationMessage: 'AnkiConnect integration not enabled',
|
||||
});
|
||||
|
||||
const triggerFieldGrouping = createTriggerFieldGroupingHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
showMpvOsd: showStatus,
|
||||
triggerFieldGroupingCore,
|
||||
});
|
||||
|
||||
const markLastCardAsAudioCard = createMarkLastCardAsAudioCardHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
showMpvOsd: showStatus,
|
||||
markLastCardAsAudioCardCore,
|
||||
});
|
||||
|
||||
const mineSentenceCard = createMineSentenceCardHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(),
|
||||
showMpvOsd: showStatus,
|
||||
mineSentenceCardCore,
|
||||
});
|
||||
|
||||
const copySubtitleCount = createHandleMultiCopyDigitHandler({
|
||||
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||
writeClipboardText: (text) => deps.clipboard.writeText(text),
|
||||
showMpvOsd: showPlaybackFeedback,
|
||||
handleMultiCopyDigitCore,
|
||||
});
|
||||
|
||||
const copyCurrentSubtitle = createCopyCurrentSubtitleHandler({
|
||||
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||
writeClipboardText: (text) => deps.clipboard.writeText(text),
|
||||
showMpvOsd: showStatus,
|
||||
copyCurrentSubtitleCore,
|
||||
});
|
||||
|
||||
const mineSentenceCount = createHandleMineSentenceDigitHandler({
|
||||
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined,
|
||||
showMpvOsd: showStatus,
|
||||
logError: (message, err) => {
|
||||
logger.error(message, err);
|
||||
},
|
||||
handleMineSentenceDigitCore,
|
||||
});
|
||||
|
||||
/** Points a freshly created Anki integration at mining, stats, plugins and furigana. */
|
||||
function attachToAnkiIntegration(integration: AnkiIntegrationLike): void {
|
||||
const callbacks = deps.ankiIntegrationCallbacks;
|
||||
integration.setRecordCardsMinedCallback(callbacks.recordCardsMined);
|
||||
integration.setMiningProvenanceProvider(callbacks.getMiningProvenance);
|
||||
integration.setCardsReadyCallback(callbacks.onCardsReady);
|
||||
integration.setKnownWordCacheUpdatedCallback(callbacks.onKnownWordCacheUpdated);
|
||||
integration.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
||||
integration.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview);
|
||||
integration.setSentenceFuriganaGenerator(callbacks.generateSentenceFurigana);
|
||||
}
|
||||
|
||||
const ipc: IpcSlice<
|
||||
| 'recordSubtitleMiningContext'
|
||||
| 'previewMediaTimingReview'
|
||||
| 'getMediaTimingReviewFrame'
|
||||
| 'getMediaTimingReviewWaveform'
|
||||
| 'stopMediaTimingReviewPreview'
|
||||
| 'resolveMediaTimingReview'
|
||||
> = {
|
||||
// Overlay lookups carry no cue context of their own; fall back to snapshotting the
|
||||
// live mpv sub timings at lookup time so media generation clips the mined line even
|
||||
// when extraction finishes long after playback has moved on.
|
||||
recordSubtitleMiningContext: (context) =>
|
||||
recordSubtitleMiningContext(
|
||||
context
|
||||
? shiftSubtitleRangeToVideoClock(context, appState.mpvClient?.currentSubDelay ?? 0)
|
||||
: captureCurrentPrimarySubtitleMiningContext(),
|
||||
),
|
||||
previewMediaTimingReview: (request) => mediaTimingReviewRuntime.previewRange(request),
|
||||
getMediaTimingReviewFrame: (request) => mediaTimingReviewRuntime.getFrame(request),
|
||||
getMediaTimingReviewWaveform: (request) => mediaTimingReviewRuntime.getWaveform(request),
|
||||
stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId),
|
||||
resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request),
|
||||
};
|
||||
|
||||
const cli: CliSlice<
|
||||
| 'copyCurrentSubtitle'
|
||||
| 'mineSentenceCard'
|
||||
| 'updateLastCardFromClipboard'
|
||||
| 'refreshKnownWordCache'
|
||||
| 'triggerFieldGrouping'
|
||||
| 'markLastCardAsAudioCard'
|
||||
> = {
|
||||
copyCurrentSubtitle: () => copyCurrentSubtitle(),
|
||||
mineSentenceCard: () => mineSentenceCard(),
|
||||
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
|
||||
refreshKnownWordCache: () => refreshKnownWordCache(),
|
||||
triggerFieldGrouping: () => triggerFieldGrouping(),
|
||||
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
|
||||
};
|
||||
|
||||
return {
|
||||
ipc,
|
||||
cli,
|
||||
captureCurrentPrimarySubtitleMiningContext,
|
||||
recordSubtitleMiningContext,
|
||||
getFieldGroupingResolver,
|
||||
setFieldGroupingResolver,
|
||||
createFieldGroupingCallback: fieldGroupingOverlayRuntime.createFieldGroupingCallback,
|
||||
mediaTimingReviewRuntime,
|
||||
attachToAnkiIntegration,
|
||||
updateLastCardFromClipboard,
|
||||
refreshKnownWordCache,
|
||||
triggerFieldGrouping,
|
||||
markLastCardAsAudioCard,
|
||||
mineSentenceCard,
|
||||
mineSentenceCount,
|
||||
copyCurrentSubtitle,
|
||||
copySubtitleCount,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user