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
+5 -2
View File
@@ -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
View File
@@ -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;
+1
View File
@@ -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',
+3 -1
View File
@@ -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',
},
{
+29
View File
@@ -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.',
},
]);
});
+12
View File
@@ -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);
+15
View File
@@ -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');
+6
View File
@@ -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')
+6
View File
@@ -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 () => {
+2
View File
@@ -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>();
+1
View File
@@ -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
View File
@@ -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,
},
+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),
};
}
+1
View File
@@ -321,6 +321,7 @@ export interface ResolvedConfig {
subtitleSidebar: ResolvedSubtitleSidebarConfig;
auto_start_overlay: boolean;
anime: AnimeConfig & {
autoOpenJimaku: boolean;
extensionsDir: string;
repos: string[];
preferredQuality: string;
+5
View File
@@ -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.