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; type AnkiIntegrationLike = NonNullable; /** * 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; 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, 'showConfiguredStatusNotification' | 'showConfiguredPlaybackFeedback' >; getOverlayModals: () => Pick; 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; }; } export type MiningAssembly = ReturnType; /** * 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( { 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({ 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, }; }