mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-09 15:13:32 -07:00
501304e451
- Send synthetic `__warm__` payload when no current subtitle exists so the gate can release without waiting for a subtitle event that can't fire while paused - Visible-overlay readiness accepts `__warm__` once the overlay is content-ready, rejects it otherwise - Autoplay gate self-retries via scheduled polling when signal target isn't ready, removing reliance on an external flush event - Skip duplicate desktop notification when overlay or startup sequencer already delivered it
251 lines
8.2 KiB
TypeScript
251 lines
8.2 KiB
TypeScript
import type { SubtitleData } from '../../types';
|
|
import { resolveAutoplayReadyMaxReleaseAttempts } from './startup-autoplay-release-policy';
|
|
|
|
const PENDING_AUTOPLAY_READY_RETRY_DELAY_MS = 200;
|
|
const MAX_PENDING_AUTOPLAY_READY_RETRY_ATTEMPTS = 75;
|
|
|
|
type MpvClientLike = {
|
|
connected?: boolean;
|
|
requestProperty: (property: string) => Promise<unknown>;
|
|
send: (payload: { command: Array<string | boolean> }) => void;
|
|
};
|
|
|
|
type AutoplayReadyOptions = { forceWhilePaused?: boolean };
|
|
|
|
export type AutoplayReadySignal = {
|
|
mediaPath: string;
|
|
payload: SubtitleData;
|
|
requestedAtMs: number;
|
|
options?: AutoplayReadyOptions;
|
|
};
|
|
|
|
export type AutoplayReadyGateDeps = {
|
|
isAppOwnedFlowInFlight: () => boolean;
|
|
getCurrentMediaPath: () => string | null;
|
|
getCurrentVideoPath: () => string | null;
|
|
getPlaybackPaused: () => boolean | null;
|
|
getMpvClient: () => MpvClientLike | null;
|
|
signalPluginAutoplayReady: () => void;
|
|
requestOverlayPointerRecovery?: () => void;
|
|
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
|
|
now?: () => number;
|
|
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;
|
|
let pendingAutoplayReadySignal: AutoplayReadySignal | null = null;
|
|
let pendingAutoplayReadyRetryToken = 0;
|
|
let pendingAutoplayReadyRetryAttempts = 0;
|
|
let scheduledPendingAutoplayReadyRetryToken: number | null = null;
|
|
const now = deps.now ?? (() => Date.now());
|
|
|
|
const invalidatePendingAutoplayReadyRetry = (): void => {
|
|
pendingAutoplayReadyRetryToken += 1;
|
|
pendingAutoplayReadyRetryAttempts = 0;
|
|
scheduledPendingAutoplayReadyRetryToken = null;
|
|
};
|
|
|
|
const invalidatePendingAutoplayReadyFallbacks = (): void => {
|
|
autoPlayReadySignalMediaPath = null;
|
|
pendingAutoplayReadySignal = null;
|
|
autoPlayReadySignalGeneration += 1;
|
|
invalidatePendingAutoplayReadyRetry();
|
|
};
|
|
|
|
const isSignalTargetReady = (signal: AutoplayReadySignal): boolean =>
|
|
deps.isSignalTargetReady?.(signal) ?? true;
|
|
|
|
const getSignalMediaPath = (): string =>
|
|
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
|
|
|
|
const markCurrentMediaAutoplayReady = (): void => {
|
|
pendingAutoplayReadySignal = null;
|
|
autoPlayReadySignalMediaPath = getSignalMediaPath();
|
|
autoPlayReadySignalGeneration += 1;
|
|
invalidatePendingAutoplayReadyRetry();
|
|
};
|
|
|
|
const setPendingAutoplayReadySignal = (signal: AutoplayReadySignal): boolean => {
|
|
if (
|
|
pendingAutoplayReadySignal &&
|
|
pendingAutoplayReadySignal.mediaPath === signal.mediaPath &&
|
|
pendingAutoplayReadySignal.payload.text === signal.payload.text &&
|
|
pendingAutoplayReadySignal.requestedAtMs <= signal.requestedAtMs
|
|
) {
|
|
return false;
|
|
}
|
|
pendingAutoplayReadySignal = signal;
|
|
pendingAutoplayReadyRetryAttempts = 0;
|
|
return true;
|
|
};
|
|
|
|
const schedulePendingAutoplayReadyRetry = (): void => {
|
|
if (scheduledPendingAutoplayReadyRetryToken === pendingAutoplayReadyRetryToken) {
|
|
return;
|
|
}
|
|
if (pendingAutoplayReadyRetryAttempts >= MAX_PENDING_AUTOPLAY_READY_RETRY_ATTEMPTS) {
|
|
return;
|
|
}
|
|
|
|
const retryToken = pendingAutoplayReadyRetryToken;
|
|
pendingAutoplayReadyRetryAttempts += 1;
|
|
scheduledPendingAutoplayReadyRetryToken = retryToken;
|
|
deps.schedule(() => {
|
|
if (scheduledPendingAutoplayReadyRetryToken === retryToken) {
|
|
scheduledPendingAutoplayReadyRetryToken = null;
|
|
}
|
|
if (retryToken !== pendingAutoplayReadyRetryToken || !pendingAutoplayReadySignal) {
|
|
return;
|
|
}
|
|
flushPendingAutoplayReadySignal();
|
|
}, PENDING_AUTOPLAY_READY_RETRY_DELAY_MS);
|
|
};
|
|
|
|
const releaseAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
|
|
const mediaPath = signal.mediaPath;
|
|
const releaseRetryDelayMs = 200;
|
|
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
|
|
forceWhilePaused: signal.options?.forceWhilePaused === true,
|
|
retryDelayMs: releaseRetryDelayMs,
|
|
});
|
|
let releaseUnpauseSent = false;
|
|
|
|
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;
|
|
}
|
|
|
|
if (releaseUnpauseSent && deps.getPlaybackPaused() === true) {
|
|
deps.logDebug(
|
|
`[autoplay-ready] stopped release retries after playback paused again for media ${mediaPath}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const shouldUnpause = await isPlaybackPaused(mpvClient);
|
|
if (!shouldUnpause) {
|
|
return;
|
|
}
|
|
|
|
mpvClient.send({ command: ['set_property', 'pause', false] });
|
|
releaseUnpauseSent = true;
|
|
if (attempt < maxReleaseAttempts) {
|
|
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
|
|
}
|
|
})();
|
|
};
|
|
|
|
pendingAutoplayReadySignal = null;
|
|
invalidatePendingAutoplayReadyRetry();
|
|
autoPlayReadySignalMediaPath = mediaPath;
|
|
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
|
deps.signalPluginAutoplayReady();
|
|
deps.requestOverlayPointerRecovery?.();
|
|
attemptRelease(playbackGeneration, 0);
|
|
};
|
|
|
|
const maybeReleaseAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
|
|
if (autoPlayReadySignalMediaPath === signal.mediaPath) {
|
|
pendingAutoplayReadySignal = null;
|
|
return;
|
|
}
|
|
if (!isSignalTargetReady(signal)) {
|
|
const pendingSignalChanged = setPendingAutoplayReadySignal(signal);
|
|
schedulePendingAutoplayReadyRetry();
|
|
if (pendingSignalChanged) {
|
|
deps.logDebug(
|
|
`[autoplay-ready] deferred until signal target is ready for media ${signal.mediaPath}`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
releaseAutoplayReadySignal(signal);
|
|
};
|
|
|
|
const maybeSignalPluginAutoplayReady = (
|
|
payload: SubtitleData,
|
|
options?: AutoplayReadyOptions,
|
|
): void => {
|
|
if (deps.isAppOwnedFlowInFlight()) {
|
|
deps.logDebug('[autoplay-ready] suppressed while app-owned YouTube flow is active');
|
|
return;
|
|
}
|
|
if (!payload.text.trim()) {
|
|
return;
|
|
}
|
|
|
|
maybeReleaseAutoplayReadySignal({
|
|
mediaPath: getSignalMediaPath(),
|
|
payload,
|
|
requestedAtMs: now(),
|
|
options,
|
|
});
|
|
};
|
|
|
|
const flushPendingAutoplayReadySignal = (): void => {
|
|
if (!pendingAutoplayReadySignal) {
|
|
return;
|
|
}
|
|
|
|
const pendingSignal = pendingAutoplayReadySignal;
|
|
if (getSignalMediaPath() !== pendingSignal.mediaPath) {
|
|
pendingAutoplayReadySignal = null;
|
|
deps.logDebug(
|
|
`[autoplay-ready] dropped deferred signal for stale media ${pendingSignal.mediaPath}`,
|
|
);
|
|
return;
|
|
}
|
|
maybeReleaseAutoplayReadySignal(pendingSignal);
|
|
};
|
|
|
|
return {
|
|
flushPendingAutoplayReadySignal,
|
|
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
|
|
invalidatePendingAutoplayReadyFallbacks,
|
|
markCurrentMediaAutoplayReady,
|
|
maybeSignalPluginAutoplayReady,
|
|
};
|
|
}
|