mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-16 15:13:31 -07:00
294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
import type { SubtitleCue, SubtitleData } from '../../types';
|
|
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
|
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
|
|
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
|
|
|
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
|
|
const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100;
|
|
|
|
type AutoplaySubtitlePrimingMpvClient = {
|
|
connected?: boolean;
|
|
requestProperty: (name: string) => Promise<unknown>;
|
|
currentVideoPath?: string;
|
|
currentTimePos?: number;
|
|
currentSecondarySubText?: string;
|
|
setCurrentSecondarySubText?: (text: string) => void;
|
|
};
|
|
|
|
type AutoplaySubtitlePrimingPrefetchService = {
|
|
pause: () => void;
|
|
onSeek: (timePos: number) => void;
|
|
};
|
|
|
|
export interface AutoplaySubtitlePrimingRuntimeDeps {
|
|
getCurrentMediaPath: () => string | null | undefined;
|
|
getMpvClient: () => AutoplaySubtitlePrimingMpvClient | null;
|
|
setCurrentSubText: (text: string) => void;
|
|
getCurrentSubText: () => string;
|
|
getCurrentSubtitleData: () => SubtitleData | null;
|
|
getActiveParsedSubtitleCues: () => SubtitleCue[];
|
|
setActiveParsedSubtitleMediaPath: (mediaPath: string | null) => void;
|
|
subtitleProcessingController: {
|
|
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
|
onSubtitleChange: (text: string) => void;
|
|
refreshCurrentSubtitle: (text: string) => void;
|
|
};
|
|
emitSubtitlePayload: (payload: SubtitleData) => void;
|
|
getSubtitlePrefetchService: () => AutoplaySubtitlePrimingPrefetchService | null;
|
|
getLastObservedTimePos: () => number;
|
|
getVisibleOverlayVisible: () => boolean;
|
|
emitSecondarySubtitle: (text: string) => void;
|
|
initSubtitlePrefetch: (
|
|
sourcePath: string,
|
|
currentTimePos: number,
|
|
sourceKey?: string,
|
|
) => Promise<void>;
|
|
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
|
|
logDebug: (message: string) => void;
|
|
}
|
|
|
|
export function setMpvCurrentSecondarySubText(
|
|
client: Pick<
|
|
AutoplaySubtitlePrimingMpvClient,
|
|
'currentSecondarySubText' | 'setCurrentSecondarySubText'
|
|
>,
|
|
text: string,
|
|
): void {
|
|
if (typeof client.setCurrentSecondarySubText === 'function') {
|
|
client.setCurrentSecondarySubText(text);
|
|
return;
|
|
}
|
|
client.currentSecondarySubText = text;
|
|
}
|
|
|
|
export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimingRuntimeDeps) {
|
|
const { subtitleProcessingController, emitSubtitlePayload } = deps;
|
|
|
|
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let autoplaySubtitlePrimedMediaPath: string | null = null;
|
|
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | null =
|
|
null;
|
|
|
|
function getCurrentAutoplayMediaPath(): string | null {
|
|
return (
|
|
deps.getCurrentMediaPath()?.trim() || deps.getMpvClient()?.currentVideoPath?.trim() || null
|
|
);
|
|
}
|
|
|
|
function isCurrentAutoplayMediaPath(mediaPath: string): boolean {
|
|
return getCurrentAutoplayMediaPath() === mediaPath;
|
|
}
|
|
|
|
function markAutoplaySubtitlePrimeConsumed(mediaPath: string): boolean {
|
|
if (autoplaySubtitlePrimedMediaPath === mediaPath) {
|
|
return false;
|
|
}
|
|
autoplaySubtitlePrimedMediaPath = mediaPath;
|
|
return true;
|
|
}
|
|
|
|
function resetAutoplaySubtitlePrime(): void {
|
|
autoplaySubtitlePrimedMediaPath = null;
|
|
}
|
|
|
|
function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
|
|
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
|
|
return false;
|
|
}
|
|
if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
|
|
return false;
|
|
}
|
|
|
|
deps.setCurrentSubText(text);
|
|
deps.getSubtitlePrefetchService()?.pause();
|
|
const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text);
|
|
if (cachedPayload) {
|
|
subtitleProcessingController.onSubtitleChange(text);
|
|
emitSubtitlePayload(cachedPayload);
|
|
return true;
|
|
}
|
|
|
|
emitSubtitlePayload({ text, tokens: null });
|
|
subtitleProcessingController.onSubtitleChange(text);
|
|
return true;
|
|
}
|
|
|
|
async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise<void> {
|
|
const client = deps.getMpvClient();
|
|
if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) {
|
|
return;
|
|
}
|
|
|
|
const subTextRaw = await client.requestProperty('sub-text').catch((error) => {
|
|
deps.logDebug(
|
|
`[autoplay-subtitle-prime] failed to read sub-text: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
return null;
|
|
});
|
|
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
|
|
if (emitAutoplayPrimedSubtitle(mediaPath, text)) {
|
|
return;
|
|
}
|
|
|
|
if (!text.trim() && isCurrentAutoplayMediaPath(mediaPath)) {
|
|
await deps.refreshSubtitlePrefetchFromActiveTrack().catch((error) => {
|
|
deps.logDebug(
|
|
`[autoplay-subtitle-prime] active subtitle refresh failed after empty sub-text: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
});
|
|
await primeAutoplaySubtitleFromParsedCues(mediaPath, deps.getActiveParsedSubtitleCues());
|
|
}
|
|
}
|
|
|
|
async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
|
|
await primeVisibleOverlaySubtitleFromMpv({
|
|
getMpvClient: () => deps.getMpvClient(),
|
|
setCurrentSubText: (text) => {
|
|
deps.setCurrentSubText(text);
|
|
},
|
|
getCurrentSubtitleData: () => deps.getCurrentSubtitleData(),
|
|
consumeCachedSubtitle: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
|
|
onSubtitleChange: (text) => {
|
|
deps.getSubtitlePrefetchService()?.pause();
|
|
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
|
subtitleProcessingController.onSubtitleChange(text);
|
|
},
|
|
refreshCurrentSubtitle: (text) => {
|
|
deps.getSubtitlePrefetchService()?.pause();
|
|
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
|
subtitleProcessingController.refreshCurrentSubtitle(text);
|
|
},
|
|
deferUncachedRefresh: true,
|
|
emitSubtitle: (payload) => emitSubtitlePayload(payload),
|
|
setCurrentSecondarySubText: (text) => {
|
|
const client = deps.getMpvClient();
|
|
if (client) {
|
|
setMpvCurrentSecondarySubText(client, text);
|
|
}
|
|
},
|
|
emitSecondarySubtitle: (text) => {
|
|
deps.emitSecondarySubtitle(text);
|
|
},
|
|
logDebug: (message) => {
|
|
deps.logDebug(message);
|
|
},
|
|
});
|
|
}
|
|
|
|
function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
|
|
if (!visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
|
|
return;
|
|
}
|
|
clearTimeout(visibleOverlaySubtitleRefreshAfterFirstPaintTimer);
|
|
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
|
|
}
|
|
|
|
function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
|
|
if (visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
|
|
return;
|
|
}
|
|
if (!deps.getVisibleOverlayVisible() || !deps.getCurrentSubText().trim()) {
|
|
return;
|
|
}
|
|
|
|
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = setTimeout(() => {
|
|
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
|
|
if (!deps.getVisibleOverlayVisible()) {
|
|
return;
|
|
}
|
|
const text = deps.getCurrentSubText();
|
|
if (!text.trim()) {
|
|
return;
|
|
}
|
|
deps.getSubtitlePrefetchService()?.pause();
|
|
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
|
subtitleProcessingController.refreshCurrentSubtitle(text);
|
|
}, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS);
|
|
visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.();
|
|
}
|
|
|
|
async function primeAutoplaySubtitleFromParsedCues(
|
|
mediaPath: string,
|
|
cues: SubtitleCue[],
|
|
): Promise<void> {
|
|
if (
|
|
cues.length === 0 ||
|
|
autoplaySubtitlePrimedMediaPath === mediaPath ||
|
|
!isCurrentAutoplayMediaPath(mediaPath)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const client = deps.getMpvClient();
|
|
const timePosRaw = await client?.requestProperty('time-pos').catch(() => null);
|
|
const currentTimeSeconds = Number(
|
|
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
|
);
|
|
const cue = selectAutoplayStartupCue(
|
|
cues,
|
|
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
|
|
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
|
|
);
|
|
if (!cue) {
|
|
return;
|
|
}
|
|
|
|
emitAutoplayPrimedSubtitle(mediaPath, cue.text);
|
|
}
|
|
|
|
function clearScheduledSubtitlePrefetchRefresh(): void {
|
|
if (subtitlePrefetchRefreshTimer) {
|
|
clearTimeout(subtitlePrefetchRefreshTimer);
|
|
subtitlePrefetchRefreshTimer = null;
|
|
}
|
|
}
|
|
|
|
async function refreshSubtitleSidebarFromSource(
|
|
sourcePath: string,
|
|
mediaPath?: string,
|
|
): Promise<void> {
|
|
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
|
|
if (!normalizedSourcePath) {
|
|
return;
|
|
}
|
|
const nextMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
|
|
await deps.initSubtitlePrefetch(
|
|
normalizedSourcePath,
|
|
deps.getLastObservedTimePos(),
|
|
normalizedSourcePath,
|
|
);
|
|
deps.setActiveParsedSubtitleMediaPath(nextMediaPath);
|
|
}
|
|
|
|
function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
|
|
clearScheduledSubtitlePrefetchRefresh();
|
|
subtitlePrefetchRefreshTimer = setTimeout(() => {
|
|
subtitlePrefetchRefreshTimer = null;
|
|
void deps.refreshSubtitlePrefetchFromActiveTrack().catch((error) => {
|
|
deps.logDebug(
|
|
`[autoplay-subtitle-prime] subtitle prefetch refresh failed: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
});
|
|
}, delayMs);
|
|
}
|
|
|
|
return {
|
|
getCurrentAutoplayMediaPath,
|
|
resetAutoplaySubtitlePrime,
|
|
primeCurrentSubtitleForAutoplay,
|
|
primeCurrentSubtitleForVisibleOverlay,
|
|
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint,
|
|
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint,
|
|
primeAutoplaySubtitleFromParsedCues,
|
|
clearScheduledSubtitlePrefetchRefresh,
|
|
refreshSubtitleSidebarFromSource,
|
|
scheduleSubtitlePrefetchRefresh,
|
|
};
|
|
}
|