mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
feat(anime): auto-open Jimaku for Anime Browser playback
- Add hot-reloadable `anime.autoOpenJimaku` setting - Pause and resume playback around subtitle selection - Brand Anime Browser surfaces with the SubMiner logo
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAnimeBrowserJimakuAutoOpen } from './anime-browser-jimaku-auto-open';
|
||||
|
||||
function createHarness(options?: {
|
||||
enabled?: boolean;
|
||||
animeMedia?: boolean | ((mediaPath: string) => boolean);
|
||||
paused?: boolean | null;
|
||||
opened?: boolean;
|
||||
}) {
|
||||
const calls: string[] = [];
|
||||
let currentMediaPath: string | null = null;
|
||||
const runtime = createAnimeBrowserJimakuAutoOpen({
|
||||
isEnabled: () => options?.enabled ?? true,
|
||||
isAnimeBrowserMedia: (mediaPath) =>
|
||||
typeof options?.animeMedia === 'function'
|
||||
? options.animeMedia(mediaPath)
|
||||
: (options?.animeMedia ?? true),
|
||||
getCurrentMediaPath: () => currentMediaPath,
|
||||
getPlaybackPaused: async () => (options?.paused === undefined ? false : options.paused),
|
||||
setPlaybackPaused: (paused) => calls.push(`pause:${paused}`),
|
||||
closeAnimeBrowserModal: () => calls.push('close-anime-browser'),
|
||||
openJimakuModal: async () => {
|
||||
calls.push('open-jimaku');
|
||||
return options?.opened ?? true;
|
||||
},
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
});
|
||||
|
||||
return {
|
||||
calls,
|
||||
runtime,
|
||||
setMediaPath: (mediaPath: string | null) => {
|
||||
currentMediaPath = mediaPath;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('anime browser playback pauses, opens Jimaku, and resumes after subtitle load', async () => {
|
||||
const harness = createHarness();
|
||||
harness.setMediaPath('https://127.0.0.1/stream.m3u8');
|
||||
|
||||
await harness.runtime.handleMediaPathChange('https://127.0.0.1/stream.m3u8');
|
||||
assert.deepEqual(harness.calls, ['pause:true', 'close-anime-browser', 'open-jimaku']);
|
||||
|
||||
harness.runtime.handleJimakuSubtitleLoaded();
|
||||
assert.deepEqual(harness.calls, [
|
||||
'pause:true',
|
||||
'close-anime-browser',
|
||||
'open-jimaku',
|
||||
'pause:false',
|
||||
]);
|
||||
});
|
||||
|
||||
test('anime browser playback that was already paused stays paused after subtitle load', async () => {
|
||||
const harness = createHarness({ paused: true });
|
||||
harness.setMediaPath('https://127.0.0.1/stream.m3u8');
|
||||
|
||||
await harness.runtime.handleMediaPathChange('https://127.0.0.1/stream.m3u8');
|
||||
harness.runtime.handleJimakuSubtitleLoaded();
|
||||
|
||||
assert.deepEqual(harness.calls, ['close-anime-browser', 'open-jimaku']);
|
||||
});
|
||||
|
||||
test('disabled and non-Anime Browser media do not open Jimaku', async () => {
|
||||
const disabled = createHarness({ enabled: false });
|
||||
disabled.setMediaPath('/video.mkv');
|
||||
await disabled.runtime.handleMediaPathChange('/video.mkv');
|
||||
assert.deepEqual(disabled.calls, []);
|
||||
|
||||
const unrelated = createHarness({ animeMedia: false });
|
||||
unrelated.setMediaPath('/video.mkv');
|
||||
await unrelated.runtime.handleMediaPathChange('/video.mkv');
|
||||
assert.deepEqual(unrelated.calls, []);
|
||||
});
|
||||
|
||||
test('closing Jimaku or failing to open it releases an owned pause', async () => {
|
||||
const closed = createHarness();
|
||||
closed.setMediaPath('https://127.0.0.1/one.m3u8');
|
||||
await closed.runtime.handleMediaPathChange('https://127.0.0.1/one.m3u8');
|
||||
closed.runtime.handleJimakuModalClosed();
|
||||
assert.equal(closed.calls.at(-1), 'pause:false');
|
||||
|
||||
const failed = createHarness({ opened: false });
|
||||
failed.setMediaPath('https://127.0.0.1/two.m3u8');
|
||||
await failed.runtime.handleMediaPathChange('https://127.0.0.1/two.m3u8');
|
||||
assert.equal(failed.calls.at(-1), 'pause:false');
|
||||
});
|
||||
|
||||
test('leaving Anime Browser playback releases an owned pause', async () => {
|
||||
const harness = createHarness({
|
||||
animeMedia: (mediaPath) => mediaPath.startsWith('https://127.0.0.1/'),
|
||||
});
|
||||
harness.setMediaPath('https://127.0.0.1/stream.m3u8');
|
||||
await harness.runtime.handleMediaPathChange('https://127.0.0.1/stream.m3u8');
|
||||
|
||||
harness.setMediaPath('/video.mkv');
|
||||
await harness.runtime.handleMediaPathChange('/video.mkv');
|
||||
|
||||
assert.equal(harness.calls.at(-1), 'pause:false');
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
export interface AnimeBrowserJimakuAutoOpenDeps {
|
||||
isEnabled: () => boolean;
|
||||
isAnimeBrowserMedia: (mediaPath: string) => boolean;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
setPlaybackPaused: (paused: boolean) => void;
|
||||
closeAnimeBrowserModal: () => void;
|
||||
openJimakuModal: () => Promise<boolean>;
|
||||
logWarn: (message: string, error?: unknown) => void;
|
||||
}
|
||||
|
||||
export interface AnimeBrowserJimakuAutoOpen {
|
||||
handleMediaPathChange: (mediaPath: string | null) => Promise<void>;
|
||||
handleJimakuSubtitleLoaded: () => void;
|
||||
handleJimakuModalClosed: () => void;
|
||||
}
|
||||
|
||||
interface ActiveFlow {
|
||||
mediaPath: string;
|
||||
ownsPause: boolean;
|
||||
}
|
||||
|
||||
/** Coordinates the pause owned by the Anime Browser to Jimaku handoff. */
|
||||
export function createAnimeBrowserJimakuAutoOpen(
|
||||
deps: AnimeBrowserJimakuAutoOpenDeps,
|
||||
): AnimeBrowserJimakuAutoOpen {
|
||||
let activeFlow: ActiveFlow | null = null;
|
||||
let lastMediaPath: string | null = null;
|
||||
|
||||
const isCurrent = (flow: ActiveFlow): boolean =>
|
||||
activeFlow === flow && deps.getCurrentMediaPath()?.trim() === flow.mediaPath;
|
||||
|
||||
const releaseFlow = (flow: ActiveFlow | null): void => {
|
||||
if (!flow || activeFlow !== flow) return;
|
||||
activeFlow = null;
|
||||
if (flow.ownsPause) {
|
||||
deps.setPlaybackPaused(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMediaPathChange = async (mediaPath: string | null): Promise<void> => {
|
||||
const normalizedPath = mediaPath?.trim() || null;
|
||||
if (normalizedPath === lastMediaPath) return;
|
||||
lastMediaPath = normalizedPath;
|
||||
|
||||
if (!normalizedPath || !deps.isEnabled() || !deps.isAnimeBrowserMedia(normalizedPath)) {
|
||||
releaseFlow(activeFlow);
|
||||
return;
|
||||
}
|
||||
|
||||
const flow: ActiveFlow = {
|
||||
mediaPath: normalizedPath,
|
||||
ownsPause: activeFlow?.ownsPause ?? false,
|
||||
};
|
||||
activeFlow = flow;
|
||||
|
||||
if (!flow.ownsPause) {
|
||||
try {
|
||||
const paused = await deps.getPlaybackPaused();
|
||||
if (!isCurrent(flow)) return;
|
||||
if (paused === false) {
|
||||
deps.setPlaybackPaused(true);
|
||||
flow.ownsPause = true;
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logWarn('Could not read playback state before opening Jimaku.', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCurrent(flow)) return;
|
||||
deps.closeAnimeBrowserModal();
|
||||
|
||||
try {
|
||||
const opened = await deps.openJimakuModal();
|
||||
if (isCurrent(flow) && !opened) {
|
||||
releaseFlow(flow);
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logWarn('Could not open Jimaku for Anime Browser playback.', error);
|
||||
releaseFlow(flow);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleMediaPathChange,
|
||||
handleJimakuSubtitleLoaded: () => releaseFlow(activeFlow),
|
||||
handleJimakuModalClosed: () => releaseFlow(activeFlow),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user