mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
feat(jimaku): auto-open modal when a video has no Japanese subtitle (#319)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Ivan Melck <ivan@spec-kitty.ai>
This commit is contained in:
24 files changed
+907
-22
No files matched your search
@@ -260,3 +260,69 @@ test('autoplay ready gate releases tokenized signals while tokenization warmup i
|
||||
|
||||
assert.deepEqual(h.pluginSignals(), [PLUGIN_READY]);
|
||||
});
|
||||
|
||||
test('autoplay ready gate defers readiness while held and releases it when flushed after the hold', async () => {
|
||||
let held = true;
|
||||
const h = createGate({ isPlaybackHeld: () => held });
|
||||
|
||||
// The one-shot warm release: nothing re-sends it, so it must survive the hold.
|
||||
await h.signal('__warm__');
|
||||
await h.runScheduled();
|
||||
assert.deepEqual(h.pluginSignals(), []);
|
||||
assert.equal(h.unpauseCount(), 0);
|
||||
|
||||
held = false;
|
||||
h.gate.flushPendingAutoplayReadySignal();
|
||||
await settle();
|
||||
assert.deepEqual(h.pluginSignals(), [PLUGIN_READY]);
|
||||
assert.equal(h.mpv.paused, false);
|
||||
});
|
||||
|
||||
test('autoplay ready gate stops pending release retries once playback becomes held', async () => {
|
||||
let held = false;
|
||||
let connected = false;
|
||||
const commands: MpvCommand[] = [];
|
||||
const h = createGate({
|
||||
isPlaybackHeld: () => held,
|
||||
getMpvClient: () => ({
|
||||
connected,
|
||||
requestProperty: async () => true,
|
||||
send: ({ command }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await h.signal('字幕');
|
||||
held = true;
|
||||
connected = true;
|
||||
await h.runScheduled();
|
||||
|
||||
assert.equal(commands.filter(isUnpause).length, 0);
|
||||
});
|
||||
|
||||
test('autoplay ready gate does not unpause when a hold starts while the pause read is in flight', async () => {
|
||||
let held = false;
|
||||
let resolvePauseRead: (paused: boolean) => void = () => {};
|
||||
const commands: MpvCommand[] = [];
|
||||
const h = createGate({
|
||||
isPlaybackHeld: () => held,
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: () =>
|
||||
new Promise<unknown>((resolve) => {
|
||||
resolvePauseRead = resolve;
|
||||
}),
|
||||
send: ({ command }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await h.signal('字幕');
|
||||
held = true;
|
||||
resolvePauseRead(true);
|
||||
await settle();
|
||||
|
||||
assert.equal(commands.filter(isUnpause).length, 0);
|
||||
});
|
||||
@@ -27,6 +27,8 @@ export type AutoplayReadyGateDeps = {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
signalPluginAutoplayReady: () => void;
|
||||
isTokenizationReady?: () => boolean;
|
||||
/** True while another feature keeps playback paused on purpose; readiness must not resume it. */
|
||||
isPlaybackHeld?: () => boolean;
|
||||
requestOverlayPointerRecovery?: () => void;
|
||||
onAutoplayReadyReleased?: (signal: AutoplayReadySignal) => void;
|
||||
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
|
||||
@@ -169,6 +171,13 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
if (!shouldUnpause) {
|
||||
return;
|
||||
}
|
||||
// Checked after the pause read: a hold can start while that request is in flight.
|
||||
if (deps.isPlaybackHeld?.()) {
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] stopped release retries while playback is held for media ${mediaPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
mpvClient.send({ command: ['set_property', 'pause', false] });
|
||||
releaseUnpauseSent = true;
|
||||
@@ -193,6 +202,16 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
pendingAutoplayReadySignal = null;
|
||||
return;
|
||||
}
|
||||
if (deps.isPlaybackHeld?.()) {
|
||||
// Deferred, not dropped: readiness may never be signalled again (the warm release fires
|
||||
// once, and a video without subtitles emits nothing), so the hold's owner flushes it after.
|
||||
if (setPendingAutoplayReadySignal(signal)) {
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] deferred while playback is held for media ${signal.mediaPath}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isSignalTargetReady(signal)) {
|
||||
const pendingSignalChanged = setPendingAutoplayReadySignal(signal);
|
||||
schedulePendingAutoplayReadyRetry();
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createJimakuAutoOpenRuntime } from './jimaku-auto-open';
|
||||
|
||||
const videoTrack = { type: 'video', id: 1, selected: true };
|
||||
const englishOnlyTrackList = [
|
||||
videoTrack,
|
||||
{ type: 'sub', id: 1, lang: 'eng', title: 'English', external: false, selected: true },
|
||||
];
|
||||
const japaneseTrackList = [
|
||||
...englishOnlyTrackList,
|
||||
{ type: 'sub', id: 2, lang: 'jpn', title: 'Japanese', external: false },
|
||||
];
|
||||
const unlabeledSidecarTrackList = [
|
||||
...englishOnlyTrackList,
|
||||
{ type: 'sub', id: 2, title: 'srt', external: true },
|
||||
];
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
function makeHarness(
|
||||
overrides: {
|
||||
enabled?: boolean;
|
||||
mediaPath?: string | null;
|
||||
trackList?: unknown;
|
||||
trackListError?: boolean;
|
||||
primaryLanguages?: string[];
|
||||
openJimaku?: () => Promise<boolean>;
|
||||
} = {},
|
||||
) {
|
||||
const state = {
|
||||
enabled: overrides.enabled ?? true,
|
||||
mediaPath: overrides.mediaPath === undefined ? '/videos/episode-01.mkv' : overrides.mediaPath,
|
||||
trackList: 'trackList' in overrides ? overrides.trackList : englishOnlyTrackList,
|
||||
paused: false as boolean | null,
|
||||
};
|
||||
const events: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
|
||||
const runtime = createJimakuAutoOpenRuntime({
|
||||
isEnabled: () => state.enabled,
|
||||
getCurrentMediaPath: () => state.mediaPath,
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name: string) => {
|
||||
if (name !== 'track-list' || overrides.trackListError) {
|
||||
throw new Error(`Unexpected property: ${name}`);
|
||||
}
|
||||
return state.trackList;
|
||||
},
|
||||
}),
|
||||
getPrimarySubtitleLanguages: () => overrides.primaryLanguages ?? ['ja', 'jpn'],
|
||||
isPlaybackPaused: () => state.paused,
|
||||
pausePlayback: () => {
|
||||
state.paused = true;
|
||||
events.push('pause');
|
||||
},
|
||||
endHold: ({ undoPause }) => {
|
||||
events.push('end-hold');
|
||||
if (undoPause) {
|
||||
state.paused = false;
|
||||
events.push('resume');
|
||||
}
|
||||
},
|
||||
holdPluginAutoplayGate: () => events.push('plugin-hold'),
|
||||
openJimaku: () => {
|
||||
events.push('open');
|
||||
return overrides.openJimaku?.() ?? Promise.resolve(true);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return scheduled.length as never;
|
||||
},
|
||||
clearScheduled: (timer) => {
|
||||
scheduled[(timer as unknown as number) - 1] = () => {};
|
||||
},
|
||||
});
|
||||
|
||||
const runScheduled = async (): Promise<void> => {
|
||||
for (const callback of scheduled.splice(0)) {
|
||||
callback();
|
||||
}
|
||||
await settle();
|
||||
};
|
||||
// Reports a media load and runs the delayed check to completion.
|
||||
const load = async (mediaPath = state.mediaPath): Promise<void> => {
|
||||
state.mediaPath = mediaPath;
|
||||
runtime.handleMediaPathChange(mediaPath);
|
||||
await runScheduled();
|
||||
};
|
||||
const count = (event: string) => events.filter((entry) => entry === event).length;
|
||||
|
||||
return { runtime, state, events, load, runScheduled, count };
|
||||
}
|
||||
|
||||
test('jimaku auto-open pauses, then opens the modal, for local video with no Japanese track', async () => {
|
||||
const h = makeHarness();
|
||||
|
||||
await h.load();
|
||||
|
||||
assert.ok(h.events.indexOf('pause') < h.events.indexOf('open'));
|
||||
assert.equal(h.state.paused, true);
|
||||
assert.equal(h.count('plugin-hold'), 1);
|
||||
assert.equal(h.runtime.isHoldingPlayback(), true);
|
||||
});
|
||||
|
||||
for (const c of [
|
||||
{ name: 'a Japanese track exists', overrides: { trackList: japaneseTrackList } },
|
||||
{
|
||||
name: 'a single unlabeled sidecar subtitle exists',
|
||||
overrides: { trackList: unlabeledSidecarTrackList },
|
||||
},
|
||||
{ name: 'the configured primary language matches', overrides: { primaryLanguages: ['eng'] } },
|
||||
{ name: 'the toggle is off', overrides: { enabled: false } },
|
||||
{ name: 'media is remote', overrides: { mediaPath: 'https://example.com/episode-01.mkv' } },
|
||||
{ name: 'the track list cannot be read', overrides: { trackListError: true } },
|
||||
{ name: 'mpv returns no track list', overrides: { trackList: null } },
|
||||
{
|
||||
name: 'the file is audio with cover art',
|
||||
overrides: {
|
||||
trackList: [
|
||||
{ type: 'audio', id: 1, selected: true },
|
||||
{ type: 'video', id: 1, albumart: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]) {
|
||||
test(`jimaku auto-open does nothing when ${c.name}`, async () => {
|
||||
const h = makeHarness(c.overrides);
|
||||
|
||||
await h.load();
|
||||
|
||||
assert.deepEqual(h.events, []);
|
||||
assert.equal(h.runtime.isHoldingPlayback(), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('jimaku auto-open checks each media load once and skips media that changed before the check', async () => {
|
||||
const h = makeHarness();
|
||||
|
||||
h.runtime.handleMediaPathChange('/videos/episode-01.mkv');
|
||||
await h.load('/videos/episode-02.mkv');
|
||||
await h.load('/videos/episode-02.mkv');
|
||||
|
||||
assert.equal(h.count('open'), 1);
|
||||
});
|
||||
|
||||
test('closing the auto-opened modal ends the hold and undoes its pause', async () => {
|
||||
const h = makeHarness();
|
||||
await h.load();
|
||||
|
||||
h.runtime.handleJimakuModalClosed();
|
||||
|
||||
assert.equal(h.runtime.isHoldingPlayback(), false);
|
||||
assert.equal(h.count('end-hold'), 1);
|
||||
assert.equal(h.state.paused, false);
|
||||
});
|
||||
|
||||
test('a pause that was already there is left to autoplay readiness when the modal closes', async () => {
|
||||
const h = makeHarness();
|
||||
h.state.paused = true; // e.g. the plugin's pause-until-ready gate, or the user
|
||||
|
||||
await h.load();
|
||||
assert.equal(h.count('pause'), 0);
|
||||
assert.equal(h.runtime.isHoldingPlayback(), true);
|
||||
|
||||
h.runtime.handleJimakuModalClosed();
|
||||
assert.equal(h.count('end-hold'), 1);
|
||||
assert.equal(h.state.paused, true);
|
||||
});
|
||||
|
||||
test('changing media ends the hold without resuming the new media', async () => {
|
||||
const h = makeHarness();
|
||||
await h.load();
|
||||
|
||||
h.state.trackList = japaneseTrackList;
|
||||
await h.load('/videos/episode-02.mkv');
|
||||
|
||||
assert.equal(h.runtime.isHoldingPlayback(), false);
|
||||
assert.equal(h.count('resume'), 0);
|
||||
});
|
||||
|
||||
for (const c of [
|
||||
{ name: 'resolves false', openJimaku: () => Promise.resolve(false) },
|
||||
{ name: 'rejects', openJimaku: () => Promise.reject(new Error('overlay gone')) },
|
||||
]) {
|
||||
test(`when opening ${c.name}, jimaku auto-open undoes its own pause and leaves the plugin gate alone`, async () => {
|
||||
const h = makeHarness({ openJimaku: c.openJimaku });
|
||||
|
||||
await h.load();
|
||||
|
||||
assert.equal(h.runtime.isHoldingPlayback(), false);
|
||||
assert.equal(h.state.paused, false);
|
||||
assert.equal(h.count('plugin-hold'), 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('a failed open keeps a pause that was already there', async () => {
|
||||
const h = makeHarness({ openJimaku: () => Promise.resolve(false) });
|
||||
h.state.paused = true;
|
||||
|
||||
await h.load();
|
||||
|
||||
assert.equal(h.count('resume'), 0);
|
||||
assert.equal(h.state.paused, true);
|
||||
});
|
||||
|
||||
test('a modal that acknowledges after the open timed out re-applies the hold', async () => {
|
||||
const h = makeHarness({ openJimaku: () => Promise.resolve(false) });
|
||||
await h.load();
|
||||
assert.equal(h.state.paused, false);
|
||||
|
||||
h.runtime.handleJimakuModalOpened();
|
||||
|
||||
assert.equal(h.runtime.isHoldingPlayback(), true);
|
||||
assert.equal(h.state.paused, true);
|
||||
assert.equal(h.count('plugin-hold'), 1);
|
||||
});
|
||||
|
||||
test("a Jimaku modal opened after the late-ack window is the user's own and does not hold", async () => {
|
||||
const h = makeHarness({ openJimaku: () => Promise.resolve(false) });
|
||||
await h.load();
|
||||
await h.runScheduled(); // the late-ack window expires
|
||||
|
||||
h.runtime.handleJimakuModalOpened();
|
||||
|
||||
assert.equal(h.runtime.isHoldingPlayback(), false);
|
||||
assert.equal(h.count('plugin-hold'), 0);
|
||||
});
|
||||
|
||||
test('a Jimaku modal opened without a pending auto-open does not hold playback', async () => {
|
||||
const h = makeHarness({ trackList: japaneseTrackList });
|
||||
await h.load();
|
||||
|
||||
h.runtime.handleJimakuModalOpened();
|
||||
|
||||
assert.equal(h.runtime.isHoldingPlayback(), false);
|
||||
assert.deepEqual(h.events, []);
|
||||
});
|
||||
|
||||
test('jimaku auto-open ignores a late open result for media that already changed', async () => {
|
||||
const pendingOpens: Array<(opened: boolean) => void> = [];
|
||||
const h = makeHarness({
|
||||
openJimaku: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
pendingOpens.push(resolve);
|
||||
}),
|
||||
});
|
||||
|
||||
await h.load();
|
||||
await h.load('/videos/episode-02.mkv');
|
||||
const resumesBefore = h.count('resume');
|
||||
|
||||
pendingOpens[0]?.(false);
|
||||
await settle();
|
||||
|
||||
assert.equal(h.runtime.isHoldingPlayback(), true);
|
||||
assert.equal(h.count('resume'), resumesBefore);
|
||||
});
|
||||
|
||||
test('a close reported before the auto-open is acknowledged belongs to the previous modal', async () => {
|
||||
const pendingOpens: Array<(opened: boolean) => void> = [];
|
||||
const h = makeHarness({
|
||||
openJimaku: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
pendingOpens.push(resolve);
|
||||
}),
|
||||
});
|
||||
await h.load(); // open sent, not yet acknowledged
|
||||
|
||||
h.runtime.handleJimakuModalClosed();
|
||||
assert.equal(h.runtime.isHoldingPlayback(), true);
|
||||
assert.equal(h.count('end-hold'), 0);
|
||||
|
||||
h.runtime.handleJimakuModalOpened();
|
||||
pendingOpens[0]?.(true);
|
||||
await settle();
|
||||
h.runtime.handleJimakuModalClosed();
|
||||
assert.equal(h.runtime.isHoldingPlayback(), false);
|
||||
assert.equal(h.state.paused, false);
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
normalizeLocalMediaPath,
|
||||
resolveManagedLocalSubtitleSelection,
|
||||
} from './local-subtitle-selection';
|
||||
|
||||
// mpv lists sidecar and embedded subtitle tracks right after the file loads; the delay lets that
|
||||
// track list settle before deciding nothing matches.
|
||||
const DEFAULT_CHECK_DELAY_MS = 1000;
|
||||
// How long after a failed open a late modal ack still counts as the auto-open showing up.
|
||||
const LATE_OPEN_ACK_WINDOW_MS = 10_000;
|
||||
|
||||
// Audio files report cover art as a video track; only real video means there is something to watch.
|
||||
function hasVideoTrack(trackList: unknown[]): boolean {
|
||||
return trackList.some((entry) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const track = entry as { type?: unknown; albumart?: unknown; image?: unknown };
|
||||
return track.type === 'video' && track.albumart !== true && track.image !== true;
|
||||
});
|
||||
}
|
||||
|
||||
type PlaybackHold = {
|
||||
pausedByHold: boolean;
|
||||
// Set once the renderer acknowledges this hold's open. The renderer reports closes and opens in
|
||||
// order, so a close arriving before that acknowledgement belongs to an earlier modal session.
|
||||
acknowledged: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the Jimaku modal for local media that has no subtitle track in the primary subtitle
|
||||
* languages (Japanese by default), checking once per media load.
|
||||
*
|
||||
* While the auto-opened modal is up, playback is held: paused, and autoplay readiness is deferred
|
||||
* instead of resuming it (`isHoldingPlayback`). Closing the modal, or failing to open it, ends the
|
||||
* hold: deferred readiness is released and a pause this runtime added is undone, so playback
|
||||
* resumes whether or not a subtitle was picked, but a pause made by someone else is kept. A modal
|
||||
* that acknowledges shortly after a failed open re-applies the hold.
|
||||
*/
|
||||
export function createJimakuAutoOpenRuntime(deps: {
|
||||
isEnabled: () => boolean;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getMpvClient: () => {
|
||||
connected?: boolean;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
getPrimarySubtitleLanguages: () => string[];
|
||||
isPlaybackPaused: () => boolean | null;
|
||||
pausePlayback: () => void;
|
||||
/** Releases readiness deferred during the hold, and unpauses when `undoPause` is set. */
|
||||
endHold: (options: { undoPause: boolean }) => void;
|
||||
/** Keeps the mpv plugin's pause-until-ready timeout from resuming playback. */
|
||||
holdPluginAutoplayGate: () => void;
|
||||
/** Resolves to whether the modal acknowledged opening. */
|
||||
openJimaku: () => Promise<boolean>;
|
||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
clearScheduled: (timer: ReturnType<typeof setTimeout>) => void;
|
||||
delayMs?: number;
|
||||
}) {
|
||||
const delayMs = deps.delayMs ?? DEFAULT_CHECK_DELAY_MS;
|
||||
let currentMediaPath: string | null = null;
|
||||
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Media whose auto-opened modal has not closed yet; a late modal ack only counts for it.
|
||||
let autoOpenedMediaPath: string | null = null;
|
||||
// A fresh object per hold, so a late open failure only ends the hold it started.
|
||||
let activeHold: PlaybackHold | null = null;
|
||||
let lateAckTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearPendingTimer = (): void => {
|
||||
if (!pendingTimer) {
|
||||
return;
|
||||
}
|
||||
deps.clearScheduled(pendingTimer);
|
||||
pendingTimer = null;
|
||||
};
|
||||
|
||||
const clearLateAckTimer = (): void => {
|
||||
if (!lateAckTimer) {
|
||||
return;
|
||||
}
|
||||
deps.clearScheduled(lateAckTimer);
|
||||
lateAckTimer = null;
|
||||
};
|
||||
|
||||
const endHold = (hold: PlaybackHold): void => {
|
||||
activeHold = null;
|
||||
deps.endHold({ undoPause: hold.pausedByHold });
|
||||
};
|
||||
|
||||
const startHold = (): PlaybackHold => {
|
||||
const hold = { pausedByHold: deps.isPlaybackPaused() !== true, acknowledged: false };
|
||||
activeHold = hold;
|
||||
if (hold.pausedByHold) {
|
||||
deps.pausePlayback();
|
||||
}
|
||||
return hold;
|
||||
};
|
||||
|
||||
const openForMedia = async (mediaPath: string): Promise<void> => {
|
||||
autoOpenedMediaPath = mediaPath;
|
||||
const hold = startHold();
|
||||
const opened = await deps.openJimaku().catch(() => false);
|
||||
if (activeHold !== hold) {
|
||||
return;
|
||||
}
|
||||
if (opened) {
|
||||
hold.acknowledged = true;
|
||||
// Only once the modal is up: if opening fails, the plugin's own timeout must still resume.
|
||||
deps.holdPluginAutoplayGate();
|
||||
return;
|
||||
}
|
||||
endHold(hold);
|
||||
// A modal that loads slowly can still show up; past this window an open is the user's own.
|
||||
lateAckTimer = deps.schedule(() => {
|
||||
lateAckTimer = null;
|
||||
if (!activeHold) {
|
||||
autoOpenedMediaPath = null;
|
||||
}
|
||||
}, LATE_OPEN_ACK_WINDOW_MS);
|
||||
};
|
||||
|
||||
const checkMediaPath = async (mediaPath: string): Promise<void> => {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected || !client.requestProperty) {
|
||||
return;
|
||||
}
|
||||
let trackList: unknown;
|
||||
try {
|
||||
trackList = await client.requestProperty('track-list');
|
||||
} catch {
|
||||
// Never open the modal on an unreadable track list; a missing track is not proven.
|
||||
return;
|
||||
}
|
||||
if (
|
||||
mediaPath !== currentMediaPath ||
|
||||
normalizeLocalMediaPath(deps.getCurrentMediaPath()) !== mediaPath ||
|
||||
!deps.isEnabled() ||
|
||||
!Array.isArray(trackList) ||
|
||||
!hasVideoTrack(trackList)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { hasPrimaryMatch } = resolveManagedLocalSubtitleSelection({
|
||||
trackList,
|
||||
primaryLanguages: deps.getPrimarySubtitleLanguages(),
|
||||
secondaryLanguages: [],
|
||||
});
|
||||
if (!hasPrimaryMatch) {
|
||||
await openForMedia(mediaPath);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleMediaPathChange: (mediaPath: string | null | undefined): void => {
|
||||
const normalizedPath = normalizeLocalMediaPath(mediaPath);
|
||||
if (normalizedPath === currentMediaPath) {
|
||||
return;
|
||||
}
|
||||
clearPendingTimer();
|
||||
clearLateAckTimer();
|
||||
// The gate drops readiness for the old media on its own; the new media is not paused by us.
|
||||
activeHold = null;
|
||||
autoOpenedMediaPath = null;
|
||||
currentMediaPath = normalizedPath;
|
||||
if (!normalizedPath || !deps.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
pendingTimer = deps.schedule(() => {
|
||||
pendingTimer = null;
|
||||
void checkMediaPath(normalizedPath);
|
||||
}, delayMs);
|
||||
},
|
||||
/** The renderer acknowledged the Jimaku modal; re-holds when a timed-out auto-open shows up late. */
|
||||
handleJimakuModalOpened: (): void => {
|
||||
if (activeHold) {
|
||||
activeHold.acknowledged = true;
|
||||
return;
|
||||
}
|
||||
if (!autoOpenedMediaPath || autoOpenedMediaPath !== currentMediaPath) {
|
||||
return;
|
||||
}
|
||||
clearLateAckTimer();
|
||||
startHold().acknowledged = true;
|
||||
deps.holdPluginAutoplayGate();
|
||||
},
|
||||
/** The Jimaku modal closed, or the window hosting it went away. */
|
||||
handleJimakuModalClosed: (): void => {
|
||||
if (activeHold && !activeHold.acknowledged) {
|
||||
// A previous modal's close (e.g. the last episode's, reported just as this one opens).
|
||||
// If this open never lands, its failure path ends the hold.
|
||||
return;
|
||||
}
|
||||
clearLateAckTimer();
|
||||
autoOpenedMediaPath = null;
|
||||
if (activeHold) {
|
||||
endHold(activeHold);
|
||||
}
|
||||
},
|
||||
isHoldingPlayback: (): boolean => activeHold !== null,
|
||||
};
|
||||
}
|
||||
@@ -174,7 +174,7 @@ export function resolveManagedLocalSubtitleSelection(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLocalMediaPath(mediaPath: string | null | undefined): string | null {
|
||||
export function normalizeLocalMediaPath(mediaPath: string | null | undefined): string | null {
|
||||
if (typeof mediaPath !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user