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:
@@ -14,8 +14,11 @@
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand-block">
|
||||
<div class="brand-title">SubMiner</div>
|
||||
<div class="brand-subtitle">Anime</div>
|
||||
<img class="brand-logo" src="./SubMiner.png" alt="" width="52" height="52" />
|
||||
<div class="brand-copy">
|
||||
<div class="brand-title">SubMiner</div>
|
||||
<div class="brand-subtitle">Anime</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="search-form" id="search-form" role="search">
|
||||
|
||||
+34
-1
@@ -58,7 +58,24 @@ html[data-embedded='overlay-modal'] .topbar {
|
||||
}
|
||||
|
||||
html[data-embedded='overlay-modal'] .brand-block {
|
||||
display: none;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
html[data-embedded='overlay-modal'] .brand-logo {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
html[data-embedded='overlay-modal'] .brand-copy {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
html[data-embedded='overlay-modal'] .bridge-banner {
|
||||
@@ -96,6 +113,22 @@ body {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex: none;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
filter: drop-shadow(0 5px 9px rgba(0, 0, 0, 0.28));
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -167,6 +167,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.mpv.subminerBinaryPath, '');
|
||||
assert.equal(config.mpv.aniskipEnabled, true);
|
||||
assert.equal(config.mpv.aniskipButtonKey, 'TAB');
|
||||
assert.equal(config.anime.autoOpenJimaku, false);
|
||||
});
|
||||
|
||||
test('rejects invalid mpv volume mirroring values', () => {
|
||||
|
||||
@@ -104,6 +104,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
anime: {
|
||||
// Empty by default: SubMiner ships no extension repositories and performs
|
||||
// no discovery. Sources exist only once the user adds a repo or an APK.
|
||||
autoOpenJimaku: false,
|
||||
extensionsDir: '',
|
||||
repos: [],
|
||||
preferredQuality: '',
|
||||
|
||||
@@ -588,6 +588,13 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.mpv.aniskipButtonKey,
|
||||
description: 'mpv key used to skip the detected intro while the skip prompt is visible.',
|
||||
},
|
||||
{
|
||||
path: 'anime.autoOpenJimaku',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.anime.autoOpenJimaku,
|
||||
description:
|
||||
'Pause Anime Browser playback and open Jimaku when an episode loads. Playback resumes after a subtitle loads or the modal closes.',
|
||||
},
|
||||
{
|
||||
path: 'anime.extensionsDir',
|
||||
kind: 'string',
|
||||
|
||||
@@ -147,7 +147,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
'Anime browser sources. SubMiner ships no extension repositories and bundles no sources;',
|
||||
'add a repository index URL here (or drop .apk files in the extensions directory) to have any.',
|
||||
],
|
||||
notes: ['Hot-reload: anime changes apply the next time the anime browser opens.'],
|
||||
notes: [
|
||||
'Hot-reload: autoOpenJimaku applies to the next episode; other anime changes apply the next time the anime browser opens.',
|
||||
],
|
||||
key: 'anime',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -57,3 +57,32 @@ test('resolveConfig warns for invalid mpv launch mode', () => {
|
||||
message: "Expected one of: 'normal', 'maximized', 'fullscreen'.",
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveConfig parses the Anime Browser Jimaku handoff option', () => {
|
||||
const { resolved, warnings } = resolveConfig({
|
||||
anime: {
|
||||
autoOpenJimaku: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolved.anime.autoOpenJimaku, true);
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test('resolveConfig warns for an invalid Anime Browser Jimaku handoff option', () => {
|
||||
const { resolved, warnings } = resolveConfig({
|
||||
anime: {
|
||||
autoOpenJimaku: 'yes' as never,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolved.anime.autoOpenJimaku, false);
|
||||
assert.deepEqual(warnings, [
|
||||
{
|
||||
path: 'anime.autoOpenJimaku',
|
||||
value: 'yes',
|
||||
fallback: false,
|
||||
message: 'Expected boolean.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -345,6 +345,18 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
if (isObject(src.anime)) {
|
||||
const autoOpenJimaku = asBoolean(src.anime.autoOpenJimaku);
|
||||
if (autoOpenJimaku !== undefined) {
|
||||
resolved.anime.autoOpenJimaku = autoOpenJimaku;
|
||||
} else if (src.anime.autoOpenJimaku !== undefined) {
|
||||
warn(
|
||||
'anime.autoOpenJimaku',
|
||||
src.anime.autoOpenJimaku,
|
||||
resolved.anime.autoOpenJimaku,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const extensionsDir = asString(src.anime.extensionsDir);
|
||||
if (extensionsDir !== undefined) {
|
||||
resolved.anime.extensionsDir = normalizeExternalProfilePath(extensionsDir);
|
||||
|
||||
@@ -55,6 +55,21 @@ test('settings registry groups playback startup controls under playback behavior
|
||||
}
|
||||
});
|
||||
|
||||
test('settings registry groups Anime Browser options under Aniyomi integrations', () => {
|
||||
for (const path of [
|
||||
'anime.autoOpenJimaku',
|
||||
'anime.bridgeDir',
|
||||
'anime.extensionsDir',
|
||||
'anime.preferredQuality',
|
||||
'anime.repos',
|
||||
]) {
|
||||
assert.equal(field(path).category, 'integrations', path);
|
||||
assert.equal(field(path).section, 'Aniyomi', path);
|
||||
}
|
||||
assert.equal(field('anime.autoOpenJimaku').label, 'Auto-open Jimaku');
|
||||
assert.equal(field('anime.autoOpenJimaku').restartBehavior, 'hot-reload');
|
||||
});
|
||||
|
||||
test('settings registry moves AniSkip button key into input shortcuts and hot reload', () => {
|
||||
assert.equal(field('mpv.aniskipButtonKey').category, 'input');
|
||||
assert.equal(field('mpv.aniskipButtonKey').section, 'Overlay Shortcuts');
|
||||
|
||||
@@ -134,6 +134,7 @@ const SECTION_ORDER = new Map<string, number>(
|
||||
'Kiku/Lapis/Senren Features',
|
||||
'Anki AI',
|
||||
'AnkiConnect Proxy',
|
||||
'Aniyomi',
|
||||
'Jimaku',
|
||||
'Subtitle Sync',
|
||||
'MPV Keybindings',
|
||||
@@ -245,6 +246,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
|
||||
'mpv.aniskipEnabled': 'Enable AniSkip',
|
||||
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
||||
'anime.autoOpenJimaku': 'Auto-open Jimaku',
|
||||
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
|
||||
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
||||
};
|
||||
@@ -444,6 +446,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
if (path.startsWith('jimaku.') || path.startsWith('tsukihime.')) {
|
||||
return { category: 'integrations', section: topSection(path) };
|
||||
}
|
||||
if (path.startsWith('anime.')) {
|
||||
return { category: 'integrations', section: 'Aniyomi' };
|
||||
}
|
||||
if (path.startsWith('subsync.')) {
|
||||
return { category: 'integrations', section: topSection(path) };
|
||||
}
|
||||
@@ -724,6 +729,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
path === 'logging.rotation' ||
|
||||
pathStartsWith(path, 'logging.files') ||
|
||||
pathStartsWith(path, 'notifications') ||
|
||||
path === 'anime.autoOpenJimaku' ||
|
||||
path === 'youtube.primarySubLanguages' ||
|
||||
pathStartsWith(path, 'jimaku') ||
|
||||
pathStartsWith(path, 'subsync')
|
||||
|
||||
@@ -13,6 +13,7 @@ interface RuntimeHarness {
|
||||
fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||
tsukihimeFetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||
sentCommands: Array<{ command: (string | number)[] }>;
|
||||
jimakuSubtitleLoaded: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@ function createHarness(): RuntimeHarness {
|
||||
query?: Record<string, unknown>;
|
||||
}>,
|
||||
sentCommands: [] as Array<{ command: (string | number)[] }>,
|
||||
jimakuSubtitleLoaded: 0,
|
||||
};
|
||||
|
||||
const options: AnkiJimakuIpcRuntimeOptions = {
|
||||
@@ -148,6 +150,9 @@ function createHarness(): RuntimeHarness {
|
||||
ok: true,
|
||||
path: `${destPath}:${url}`,
|
||||
}),
|
||||
onJimakuSubtitleLoaded: () => {
|
||||
state.jimakuSubtitleLoaded += 1;
|
||||
},
|
||||
};
|
||||
|
||||
let registered: Record<string, (...args: unknown[]) => unknown> = {};
|
||||
@@ -313,6 +318,7 @@ test('searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to
|
||||
|
||||
registered.onDownloadedSubtitle!('/tmp/subtitle.ass');
|
||||
assert.deepEqual(state.sentCommands, [{ command: ['sub-add', '/tmp/subtitle.ass', 'select'] }]);
|
||||
assert.equal(state.jimakuSubtitleLoaded, 1);
|
||||
});
|
||||
|
||||
test('onDownloadedSecondarySubtitle loads without stealing the primary track', async () => {
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
error: { error: string; code?: number; retryAfter?: number };
|
||||
}
|
||||
>;
|
||||
onJimakuSubtitleLoaded?: () => void;
|
||||
}
|
||||
|
||||
const logger = createLogger('main:anki-jimaku');
|
||||
@@ -289,6 +290,7 @@ export function registerAnkiJimakuIpcRuntime(
|
||||
const mpvClient = options.getMpvClient();
|
||||
if (mpvClient && mpvClient.connected) {
|
||||
mpvClient.send({ command: ['sub-add', pathToSubtitle, 'select'] });
|
||||
options.onJimakuSubtitleLoaded?.();
|
||||
}
|
||||
},
|
||||
onDownloadedSecondarySubtitle: async (pathToSubtitle) => {
|
||||
|
||||
@@ -102,6 +102,29 @@ test('classifyConfigHotReloadDiff keeps unsafe nested siblings restart-required'
|
||||
assert.deepEqual(diff.restartRequiredFields, ['ankiConnect', 'stats']);
|
||||
});
|
||||
|
||||
test('classifyConfigHotReloadDiff treats anime Jimaku auto-open as hot-reloadable', () => {
|
||||
const prev = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const next = deepCloneConfig(DEFAULT_CONFIG);
|
||||
next.anime.autoOpenJimaku = !prev.anime.autoOpenJimaku;
|
||||
|
||||
const diff = classifyConfigHotReloadDiff(prev, next);
|
||||
|
||||
assert.deepEqual(diff.hotReloadFields, ['anime.autoOpenJimaku']);
|
||||
assert.deepEqual(diff.restartRequiredFields, []);
|
||||
});
|
||||
|
||||
test('classifyConfigHotReloadDiff keeps other anime settings restart-required', () => {
|
||||
const prev = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const next = deepCloneConfig(DEFAULT_CONFIG);
|
||||
next.anime.autoOpenJimaku = !prev.anime.autoOpenJimaku;
|
||||
next.anime.preferredQuality = '1080';
|
||||
|
||||
const diff = classifyConfigHotReloadDiff(prev, next);
|
||||
|
||||
assert.deepEqual(diff.hotReloadFields, ['anime.autoOpenJimaku']);
|
||||
assert.deepEqual(diff.restartRequiredFields, ['anime']);
|
||||
});
|
||||
|
||||
test('config hot reload runtime debounces rapid watch events', () => {
|
||||
let watchedChangeCallback: (() => void) | null = null;
|
||||
const pendingTimers = new Map<number, () => void>();
|
||||
|
||||
@@ -65,6 +65,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'logging.rotation',
|
||||
'logging.files',
|
||||
'youtube.primarySubLanguages',
|
||||
'anime.autoOpenJimaku',
|
||||
'jimaku',
|
||||
'subsync',
|
||||
'ankiConnect.deck',
|
||||
|
||||
+37
-4
@@ -544,6 +544,7 @@ import {
|
||||
createCreateSyncUiWindowHandler,
|
||||
} from './main/runtime/setup-window-factory';
|
||||
import { createAnimeBrowserApplicationRuntime } from './main/runtime/anime-browser-application-runtime';
|
||||
import { createAnimeBrowserJimakuAutoOpen } from './main/runtime/anime-browser-jimaku-auto-open';
|
||||
import { openAnimeBrowserModal as openAnimeBrowserModalRuntime } from './main/runtime/anime-browser-open';
|
||||
import {
|
||||
ensureBridgeBinaries,
|
||||
@@ -2513,6 +2514,29 @@ const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions');
|
||||
*/
|
||||
const streamPlaybackMetadata = createStreamPlaybackMetadataStore();
|
||||
|
||||
const animeBrowserJimakuAutoOpen = createAnimeBrowserJimakuAutoOpen({
|
||||
isEnabled: () => configService.getConfig().anime.autoOpenJimaku,
|
||||
isAnimeBrowserMedia: (mediaPath) => streamPlaybackMetadata.match(mediaPath) !== null,
|
||||
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||
getPlaybackPaused: () =>
|
||||
resolveFreshPlaybackPaused({
|
||||
getCachedPlaybackPaused: () => appState.playbackPaused,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
}),
|
||||
setPlaybackPaused: (paused) => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', paused ? 'yes' : 'no']);
|
||||
},
|
||||
closeAnimeBrowserModal: () => {
|
||||
if (!overlayModalRuntime.isModalOpen('anime-browser')) return;
|
||||
overlayModalRuntime.sendToActiveOverlayWindow(IPC_CHANNELS.event.animeBrowserClose, undefined, {
|
||||
restoreOnModalClose: 'anime-browser',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
},
|
||||
openJimakuModal: () => openJimakuOverlay(),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
});
|
||||
|
||||
/** Stream metadata for the requested path, or current media when none was supplied. */
|
||||
function getActiveStreamMetadata(mediaPath: string | null = null) {
|
||||
return matchRequestedStreamPlaybackMetadata(
|
||||
@@ -2545,6 +2569,7 @@ const mediaRuntime = createMediaRuntimeService(
|
||||
setCurrentMediaPath: (nextPath: string | null) => {
|
||||
appState.currentMediaPath = nextPath;
|
||||
animeBrowserApplicationRuntime.publishPlaybackState(nextPath);
|
||||
void animeBrowserJimakuAutoOpen.handleMediaPathChange(nextPath);
|
||||
},
|
||||
clearPendingSubtitlePosition: () => {
|
||||
appState.pendingSubtitlePosition = null;
|
||||
@@ -2944,20 +2969,22 @@ function openOverlayHostedModalWithOsd(
|
||||
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
|
||||
unavailableMessage: string,
|
||||
failureLogMessage: string,
|
||||
): void {
|
||||
void openModal(createOverlayHostedModalOpenDeps())
|
||||
): Promise<boolean> {
|
||||
return openModal(createOverlayHostedModalOpenDeps())
|
||||
.then((opened) => {
|
||||
if (!opened) {
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(unavailableMessage, {
|
||||
variant: 'warning',
|
||||
});
|
||||
}
|
||||
return opened;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(failureLogMessage, error);
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(unavailableMessage, {
|
||||
variant: 'error',
|
||||
});
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2969,8 +2996,8 @@ function openRuntimeOptionsPalette(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function openJimakuOverlay(): void {
|
||||
openOverlayHostedModalWithOsd(
|
||||
function openJimakuOverlay(): Promise<boolean> {
|
||||
return openOverlayHostedModalWithOsd(
|
||||
openJimakuModalRuntime,
|
||||
'Jimaku overlay unavailable.',
|
||||
'Failed to open Jimaku overlay.',
|
||||
@@ -5729,6 +5756,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
applyOverlayClickThrough(senderWindow);
|
||||
senderWindow.hide();
|
||||
}
|
||||
if (modal === 'jimaku') {
|
||||
animeBrowserJimakuAutoOpen.handleJimakuModalClosed();
|
||||
}
|
||||
handleOverlayModalClosedHandler(modal);
|
||||
},
|
||||
onOverlayModalOpened: (modal, senderWindow) => {
|
||||
@@ -6108,6 +6138,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
headers: Record<string, string>,
|
||||
downloadOptions?: { isAllowedRedirect?: (url: URL) => boolean },
|
||||
) => downloadToFile(url, destPath, headers, downloadOptions),
|
||||
onJimakuSubtitleLoaded: () => {
|
||||
animeBrowserJimakuAutoOpen.handleJimakuSubtitleLoaded();
|
||||
},
|
||||
}),
|
||||
registerIpcRuntimeServices,
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -321,6 +321,7 @@ export interface ResolvedConfig {
|
||||
subtitleSidebar: ResolvedSubtitleSidebarConfig;
|
||||
auto_start_overlay: boolean;
|
||||
anime: AnimeConfig & {
|
||||
autoOpenJimaku: boolean;
|
||||
extensionsDir: string;
|
||||
repos: string[];
|
||||
preferredQuality: string;
|
||||
|
||||
@@ -43,6 +43,11 @@ export interface YoutubePickerResolveResult {
|
||||
}
|
||||
|
||||
export interface AnimeConfig {
|
||||
/**
|
||||
* Pause Anime Browser playback and open Jimaku when an episode loads. The
|
||||
* owned pause is released after a Jimaku subtitle loads or the modal closes.
|
||||
*/
|
||||
autoOpenJimaku?: boolean;
|
||||
/**
|
||||
* Directory holding Aniyomi extension `.apk` files. Defaults to
|
||||
* `<userData>/anime-extensions` when unset.
|
||||
|
||||
Reference in New Issue
Block a user