mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-20 00:15:27 -07:00
394 lines
17 KiB
TypeScript
394 lines
17 KiB
TypeScript
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
|
|
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
|
|
import { SEEK_LIKE_TIME_DELTA_SECONDS } from './mpv-main-event-actions';
|
|
import {
|
|
resolveCanonicalPrimarySubtitle,
|
|
resolvePrimarySubtitleText,
|
|
stripCanonicalFragmentLines,
|
|
} from './primary-subtitle-text';
|
|
|
|
type AnilistPostWatchRunOptions = {
|
|
watchedSeconds?: number;
|
|
};
|
|
|
|
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|
appState: {
|
|
initialArgs?: {
|
|
jellyfinPlay?: unknown;
|
|
managedPlayback?: unknown;
|
|
youtubePlay?: unknown;
|
|
} | null;
|
|
overlayRuntimeInitialized: boolean;
|
|
mpvClient: {
|
|
connected?: boolean;
|
|
currentSecondarySubText?: string;
|
|
currentTimePos?: number;
|
|
requestProperty?: (name: string) => Promise<unknown>;
|
|
} | null;
|
|
immersionTracker: {
|
|
recordSubtitleLine?: (
|
|
text: string,
|
|
start: number,
|
|
end: number,
|
|
tokens?: MergedToken[] | null,
|
|
secondaryText?: string | null,
|
|
) => void;
|
|
handleMediaTitleUpdate?: (title: string) => void;
|
|
recordPlaybackPosition?: (time: number) => void;
|
|
recordMediaDuration?: (durationSec: number) => void;
|
|
recordPauseState?: (paused: boolean) => void;
|
|
} | null;
|
|
subtitleTimingTracker: {
|
|
recordSubtitle?: (text: string, start: number, end: number, secondaryText?: string) => void;
|
|
} | null;
|
|
activeParsedSubtitleCues?: SubtitleCue[] | null;
|
|
/** Cache key of the source the cues were parsed from; cleared with the cues. */
|
|
activeParsedSubtitleSource?: string | null;
|
|
currentMediaPath?: string | null;
|
|
currentSubText: string;
|
|
currentSubAssText: string;
|
|
currentSubtitleData?: SubtitleData | null;
|
|
playbackPaused: boolean | null;
|
|
previousSecondarySubVisibility: boolean | null;
|
|
};
|
|
getQuitOnDisconnectArmed: () => boolean;
|
|
scheduleQuitCheck: (callback: () => void) => void;
|
|
quitApp: () => void;
|
|
reportJellyfinRemoteStopped: () => void;
|
|
syncOverlayMpvSubtitleSuppression: () => void;
|
|
onMpvConnected?: () => void;
|
|
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
|
recordAnilistMediaDuration?: (durationSec: number) => void;
|
|
logSubtitleTimingError: (message: string, error: unknown) => void;
|
|
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
|
onSecondarySubtitleChange?: (text: string) => void;
|
|
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
|
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
|
onSubtitleChange: (text: string) => void;
|
|
logSubtitleProcessingDebug?: (message: string) => void;
|
|
onSubtitleTrackChange?: (sid: number | null) => void;
|
|
onSecondarySubtitleTrackChange?: (sid: number | null) => void;
|
|
onSecondarySubtitleDelayChange?: (delay: number) => void;
|
|
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
|
updateCurrentMediaPath: (path: string) => void;
|
|
restoreMpvSubVisibility: () => void;
|
|
resetSubtitleSidebarEmbeddedLayout?: () => void;
|
|
getCurrentAnilistMediaKey: () => string | null;
|
|
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
|
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
|
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
|
syncImmersionMediaState: () => void;
|
|
signalAutoplayReadyIfWarm?: (path: string) => void;
|
|
markJellyfinRemotePlaybackLoaded?: (path: string) => void;
|
|
scheduleCharacterDictionarySync?: () => void;
|
|
updateCurrentMediaTitle: (title: string) => void;
|
|
resetAnilistMediaGuessState: () => void;
|
|
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
|
onTimePosUpdate?: (time: number) => void;
|
|
onFullscreenChange?: (fullscreen: boolean) => void;
|
|
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
|
|
refreshDiscordPresence: () => void;
|
|
ensureImmersionTrackerInitialized: () => void;
|
|
tokenizeSubtitleForImmersion?: (text: string) => Promise<SubtitleData | null>;
|
|
}) {
|
|
const writePlaybackPositionFromMpv = (timeSec: unknown): void => {
|
|
const normalizedTimeSec = Number(timeSec);
|
|
if (!Number.isFinite(normalizedTimeSec)) {
|
|
return;
|
|
}
|
|
deps.ensureImmersionTrackerInitialized();
|
|
deps.appState.immersionTracker?.recordPlaybackPosition?.(normalizedTimeSec);
|
|
};
|
|
// mpv reports every animation frame of a typeset line as its own subtitle event, so
|
|
// stats have to collapse bursts the same way the parsed cue list already does.
|
|
const immersionLineDedupGate = createSubtitleLineDedupGate({
|
|
getParsedCues: () => deps.appState.activeParsedSubtitleCues,
|
|
});
|
|
// One seen-set per consumer: canonical cues overlap, so live samples resolve to
|
|
// shifting subsets (A, then A+B, then B). Remembering every recorded cue -- not just
|
|
// the previous sample -- keeps each authored line recorded exactly once per source.
|
|
const recordedImmersionCanonicalKeys = new Set<string>();
|
|
const recordedTimingCanonicalKeys = new Set<string>();
|
|
// Bumped on track/media changes so an immersion record whose tokenization resolves
|
|
// after the change is dropped instead of landing in the next session.
|
|
let subtitleSessionEpoch = 0;
|
|
let lastTimePosForTimingReset: number | null = null;
|
|
const canonicalCueKey = (cue: SubtitleCue): string =>
|
|
`${cue.startTime}|${cue.endTime}|${cue.text}`;
|
|
const resetSubtitleDeduplication = (): void => {
|
|
immersionLineDedupGate.reset();
|
|
recordedImmersionCanonicalKeys.clear();
|
|
recordedTimingCanonicalKeys.clear();
|
|
subtitleSessionEpoch += 1;
|
|
lastTimePosForTimingReset = null;
|
|
};
|
|
const resolveCanonicalSample = (liveText: string, startSec: number) =>
|
|
resolveCanonicalPrimarySubtitle({
|
|
liveText,
|
|
currentTimeSec: startSec,
|
|
cues: deps.appState.activeParsedSubtitleCues,
|
|
});
|
|
// When substitution declined because dialogue shares the screen with a song, record
|
|
// the dialogue alone rather than the combined dialogue-plus-fragments stack.
|
|
const stripFragmentsForRecording = (liveText: string, startSec: number) =>
|
|
stripCanonicalFragmentLines({
|
|
liveText,
|
|
currentTimeSec: startSec,
|
|
cues: deps.appState.activeParsedSubtitleCues,
|
|
});
|
|
const hasInitialPlaybackQuitOnDisconnectArg = (): boolean =>
|
|
Boolean(
|
|
deps.appState.initialArgs?.managedPlayback ||
|
|
deps.appState.initialArgs?.jellyfinPlay ||
|
|
deps.appState.initialArgs?.youtubePlay,
|
|
);
|
|
|
|
return () => ({
|
|
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
|
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
|
onMpvConnected: deps.onMpvConnected ? () => deps.onMpvConnected!() : undefined,
|
|
hasInitialPlaybackQuitOnDisconnectArg,
|
|
isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized,
|
|
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: hasInitialPlaybackQuitOnDisconnectArg,
|
|
isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(),
|
|
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
|
|
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
|
|
quitApp: () => deps.quitApp(),
|
|
resolveSubtitleText: (liveText: string) =>
|
|
resolvePrimarySubtitleText({
|
|
liveText,
|
|
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
|
|
cues: deps.appState.activeParsedSubtitleCues,
|
|
}),
|
|
recordImmersionSubtitleLine: (text: string, start: number, end: number) => {
|
|
deps.ensureImmersionTrackerInitialized();
|
|
const tracker = deps.appState.immersionTracker;
|
|
if (!tracker?.recordSubtitleLine) {
|
|
return;
|
|
}
|
|
const recordLine = (lineText: string, startSec: number, endSec: number): void => {
|
|
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null;
|
|
const cachedTokens =
|
|
deps.appState.currentSubtitleData?.text === lineText
|
|
? deps.appState.currentSubtitleData.tokens
|
|
: null;
|
|
if (cachedTokens) {
|
|
tracker.recordSubtitleLine?.(lineText, startSec, endSec, cachedTokens, secondaryText);
|
|
return;
|
|
}
|
|
if (!deps.tokenizeSubtitleForImmersion) {
|
|
tracker.recordSubtitleLine?.(lineText, startSec, endSec, null, secondaryText);
|
|
return;
|
|
}
|
|
const epochAtRecord = subtitleSessionEpoch;
|
|
void deps
|
|
.tokenizeSubtitleForImmersion(lineText)
|
|
.then((payload) => {
|
|
if (subtitleSessionEpoch !== epochAtRecord) {
|
|
return;
|
|
}
|
|
tracker.recordSubtitleLine?.(
|
|
lineText,
|
|
startSec,
|
|
endSec,
|
|
payload?.tokens ?? null,
|
|
secondaryText,
|
|
);
|
|
})
|
|
.catch(() => {
|
|
if (subtitleSessionEpoch !== epochAtRecord) {
|
|
return;
|
|
}
|
|
tracker.recordSubtitleLine?.(lineText, startSec, endSec, null, secondaryText);
|
|
});
|
|
};
|
|
const canonical = resolveCanonicalSample(text, start);
|
|
if (canonical) {
|
|
for (const cue of canonical.cues) {
|
|
const key = canonicalCueKey(cue);
|
|
if (recordedImmersionCanonicalKeys.has(key)) {
|
|
continue;
|
|
}
|
|
recordedImmersionCanonicalKeys.add(key);
|
|
recordLine(cue.text, cue.startTime, cue.endTime);
|
|
}
|
|
return;
|
|
}
|
|
text = stripFragmentsForRecording(text, start);
|
|
if (!immersionLineDedupGate.shouldRecord({ text, startSec: start, endSec: end })) {
|
|
return;
|
|
}
|
|
recordLine(text, start, end);
|
|
},
|
|
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
|
recordSubtitleTiming: (text: string, start: number, end: number) => {
|
|
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
|
|
const canonical = resolveCanonicalSample(text, start);
|
|
if (!canonical) {
|
|
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
|
stripFragmentsForRecording(text, start),
|
|
start,
|
|
end,
|
|
secondaryText,
|
|
);
|
|
return;
|
|
}
|
|
for (const cue of canonical.cues) {
|
|
const key = canonicalCueKey(cue);
|
|
if (recordedTimingCanonicalKeys.has(key)) {
|
|
continue;
|
|
}
|
|
recordedTimingCanonicalKeys.add(key);
|
|
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
|
cue.text,
|
|
cue.startTime,
|
|
cue.endTime,
|
|
secondaryText,
|
|
);
|
|
}
|
|
},
|
|
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
|
|
deps.maybeRunAnilistPostWatchUpdate(options),
|
|
logSubtitleTimingError: (message: string, error: unknown) =>
|
|
deps.logSubtitleTimingError(message, error),
|
|
setCurrentSubText: (text: string) => {
|
|
deps.appState.currentSubText = text;
|
|
},
|
|
getImmediateSubtitlePayload: deps.getImmediateSubtitlePayload
|
|
? (text: string) => deps.getImmediateSubtitlePayload!(text)
|
|
: undefined,
|
|
emitImmediateSubtitle: deps.emitImmediateSubtitle
|
|
? (payload: SubtitleData) => deps.emitImmediateSubtitle!(payload)
|
|
: undefined,
|
|
broadcastSubtitle: (payload: SubtitleData) =>
|
|
deps.broadcastToOverlayWindows('subtitle:set', payload),
|
|
onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
|
|
logSubtitleProcessingDebug: deps.logSubtitleProcessingDebug
|
|
? (message: string) => deps.logSubtitleProcessingDebug!(message)
|
|
: undefined,
|
|
onSubtitleTrackChange: (sid: number | null) => {
|
|
resetSubtitleDeduplication();
|
|
// The replacement track's cues arrive only after an async re-read and re-parse.
|
|
// Clearing synchronously keeps the previous track's canonical cues from
|
|
// substituting into, or recording against, the new track's live text. The source
|
|
// key is cleared with the cues so cue-list consumers (the sidebar snapshot)
|
|
// re-parse on demand instead of trusting the stale pairing.
|
|
deps.appState.activeParsedSubtitleCues = [];
|
|
deps.appState.activeParsedSubtitleSource = null;
|
|
deps.onSubtitleTrackChange?.(sid);
|
|
},
|
|
onSecondarySubtitleTrackChange: deps.onSecondarySubtitleTrackChange
|
|
? (sid: number | null) => deps.onSecondarySubtitleTrackChange!(sid)
|
|
: undefined,
|
|
onSecondarySubtitleDelayChange: deps.onSecondarySubtitleDelayChange
|
|
? (delay: number) => deps.onSecondarySubtitleDelayChange!(delay)
|
|
: undefined,
|
|
onSubtitleTrackListChange: deps.onSubtitleTrackListChange
|
|
? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList)
|
|
: undefined,
|
|
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
|
setCurrentSubAssText: (text: string) => {
|
|
deps.appState.currentSubAssText = text;
|
|
},
|
|
broadcastSubtitleAss: (text: string) =>
|
|
deps.broadcastToOverlayWindows('subtitle-ass:set', text),
|
|
broadcastSecondarySubtitle: (text: string) => {
|
|
if (deps.onSecondarySubtitleChange) {
|
|
deps.onSecondarySubtitleChange(text);
|
|
return;
|
|
}
|
|
deps.broadcastToOverlayWindows('secondary-subtitle:set', text);
|
|
},
|
|
updateCurrentMediaPath: (path: string) => {
|
|
resetSubtitleDeduplication();
|
|
deps.updateCurrentMediaPath(path);
|
|
},
|
|
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
|
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout?.(),
|
|
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
|
resetAnilistMediaTracking: (mediaKey: string | null) =>
|
|
deps.resetAnilistMediaTracking(mediaKey),
|
|
maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey),
|
|
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
|
|
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
|
|
signalAutoplayReadyIfWarm: (path: string) => deps.signalAutoplayReadyIfWarm?.(path),
|
|
markJellyfinRemotePlaybackLoaded: (path: string) =>
|
|
deps.markJellyfinRemotePlaybackLoaded?.(path),
|
|
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
|
updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title),
|
|
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
|
|
notifyImmersionTitleUpdate: (title: string) => {
|
|
deps.ensureImmersionTrackerInitialized();
|
|
deps.appState.immersionTracker?.handleMediaTitleUpdate?.(title);
|
|
},
|
|
recordPlaybackPosition: (time: number) => {
|
|
deps.ensureImmersionTrackerInitialized();
|
|
deps.appState.immersionTracker?.recordPlaybackPosition?.(time);
|
|
},
|
|
recordMediaDuration: (durationSec: number) => {
|
|
deps.ensureImmersionTrackerInitialized();
|
|
deps.appState.immersionTracker?.recordMediaDuration?.(durationSec);
|
|
deps.recordAnilistMediaDuration?.(durationSec);
|
|
},
|
|
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
|
|
deps.reportJellyfinRemoteProgress(forceImmediate),
|
|
onTimePosUpdate: (time: number) => {
|
|
// Timing history is a viewing log: after a real backward seek, a rewatched
|
|
// canonical line should enter it again. Immersion stats keep their
|
|
// once-per-media deduplication and are not reset here.
|
|
if (
|
|
Number.isFinite(time) &&
|
|
lastTimePosForTimingReset !== null &&
|
|
time <= lastTimePosForTimingReset - SEEK_LIKE_TIME_DELTA_SECONDS
|
|
) {
|
|
recordedTimingCanonicalKeys.clear();
|
|
}
|
|
if (Number.isFinite(time)) {
|
|
lastTimePosForTimingReset = time;
|
|
}
|
|
deps.onTimePosUpdate?.(time);
|
|
},
|
|
onFullscreenChange: deps.onFullscreenChange
|
|
? (fullscreen: boolean) => deps.onFullscreenChange!(fullscreen)
|
|
: undefined,
|
|
recordPauseState: (paused: boolean) => {
|
|
deps.appState.playbackPaused = paused;
|
|
deps.ensureImmersionTrackerInitialized();
|
|
deps.appState.immersionTracker?.recordPauseState?.(paused);
|
|
},
|
|
flushPlaybackPositionOnMediaPathClear: (mediaPath: string) => {
|
|
const mpvClient = deps.appState.mpvClient;
|
|
const currentKnownTime = Number(mpvClient?.currentTimePos);
|
|
writePlaybackPositionFromMpv(currentKnownTime);
|
|
if (!mpvClient?.requestProperty) {
|
|
return;
|
|
}
|
|
void mpvClient
|
|
.requestProperty('time-pos')
|
|
.then((timePos) => {
|
|
const currentPath = (deps.appState.currentMediaPath ?? '').trim();
|
|
if (currentPath.length > 0 && currentPath !== mediaPath) {
|
|
return;
|
|
}
|
|
const resolvedTime = Number(timePos);
|
|
if (
|
|
Number.isFinite(currentKnownTime) &&
|
|
Number.isFinite(resolvedTime) &&
|
|
currentKnownTime === resolvedTime
|
|
) {
|
|
return;
|
|
}
|
|
writePlaybackPositionFromMpv(resolvedTime);
|
|
})
|
|
.catch(() => {
|
|
// mpv can disconnect while clearing media; keep the last cached position.
|
|
});
|
|
},
|
|
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>
|
|
deps.updateSubtitleRenderMetrics(patch),
|
|
setPreviousSecondarySubVisibility: (visible: boolean) => {
|
|
deps.appState.previousSecondarySubVisibility = visible;
|
|
},
|
|
});
|
|
}
|