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:
2026-09-02 23:04:39 -07:00
parent 18beac13f4
commit 516fadd530
27 changed files with 403 additions and 11 deletions
+4
View File
@@ -145,6 +145,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
resolveJimakuApiKey: AnkiJimakuIpcRuntimeOptions['resolveJimakuApiKey'];
isRemoteMediaPath: AnkiJimakuIpcRuntimeOptions['isRemoteMediaPath'];
downloadToFile: AnkiJimakuIpcRuntimeOptions['downloadToFile'];
onJimakuSubtitleLoaded?: AnkiJimakuIpcRuntimeOptions['onJimakuSubtitleLoaded'];
}
export interface CliCommandRuntimeServiceDepsParams {
@@ -350,6 +351,9 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
resolveJimakuApiKey: params.resolveJimakuApiKey,
isRemoteMediaPath: params.isRemoteMediaPath,
downloadToFile: params.downloadToFile,
...(params.onJimakuSubtitleLoaded
? { onJimakuSubtitleLoaded: params.onJimakuSubtitleLoaded }
: {}),
};
}
@@ -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),
};
}