Files
SubMiner/src/main/runtime/autoplay-ready-gate.ts

121 lines
3.8 KiB
TypeScript

import type { SubtitleData } from '../../types';
import { resolveAutoplayReadyMaxReleaseAttempts } from './startup-autoplay-release-policy';
type MpvClientLike = {
connected?: boolean;
requestProperty: (property: string) => Promise<unknown>;
send: (payload: { command: Array<string | boolean> }) => void;
};
export type AutoplayReadyGateDeps = {
isAppOwnedFlowInFlight: () => boolean;
getCurrentMediaPath: () => string | null;
getCurrentVideoPath: () => string | null;
getPlaybackPaused: () => boolean | null;
getMpvClient: () => MpvClientLike | null;
signalPluginAutoplayReady: () => void;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logDebug: (message: string) => void;
};
export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
let autoPlayReadySignalMediaPath: string | null = null;
let autoPlayReadySignalGeneration = 0;
const invalidatePendingAutoplayReadyFallbacks = (): void => {
autoPlayReadySignalMediaPath = null;
autoPlayReadySignalGeneration += 1;
};
const maybeSignalPluginAutoplayReady = (
payload: SubtitleData,
options?: { forceWhilePaused?: boolean },
): void => {
if (deps.isAppOwnedFlowInFlight()) {
deps.logDebug('[autoplay-ready] suppressed while app-owned YouTube flow is active');
return;
}
if (!payload.text.trim()) {
return;
}
const mediaPath =
deps.getCurrentMediaPath()?.trim() ||
deps.getCurrentVideoPath()?.trim() ||
'__unknown__';
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
const releaseRetryDelayMs = 200;
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
forceWhilePaused: options?.forceWhilePaused === true,
retryDelayMs: releaseRetryDelayMs,
});
const isPlaybackPaused = async (client: MpvClientLike): Promise<boolean> => {
try {
const pauseProperty = await client.requestProperty('pause');
if (typeof pauseProperty === 'boolean') {
return pauseProperty;
}
if (typeof pauseProperty === 'string') {
return pauseProperty.toLowerCase() !== 'no' && pauseProperty !== '0';
}
if (typeof pauseProperty === 'number') {
return pauseProperty !== 0;
}
} catch (error) {
deps.logDebug(
`[autoplay-ready] failed to read pause property for media ${mediaPath}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
return true;
};
const attemptRelease = (playbackGeneration: number, attempt: number): void => {
void (async () => {
if (
autoPlayReadySignalMediaPath !== mediaPath ||
playbackGeneration !== autoPlayReadySignalGeneration
) {
return;
}
const mpvClient = deps.getMpvClient();
if (!mpvClient?.connected) {
if (attempt < maxReleaseAttempts) {
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
}
return;
}
const shouldUnpause = await isPlaybackPaused(mpvClient);
if (!shouldUnpause) {
return;
}
mpvClient.send({ command: ['set_property', 'pause', false] });
if (attempt < maxReleaseAttempts) {
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
}
})();
};
if (duplicateMediaSignal) {
return;
}
autoPlayReadySignalMediaPath = mediaPath;
const playbackGeneration = ++autoPlayReadySignalGeneration;
deps.signalPluginAutoplayReady();
attemptRelease(playbackGeneration, 0);
};
return {
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
invalidatePendingAutoplayReadyFallbacks,
maybeSignalPluginAutoplayReady,
};
}