mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-02 23:54:29 -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:
@@ -11,6 +11,7 @@ area: anime
|
|||||||
- The source picker offers "All sources", which searches every installed source at once. Results stream in as each source answers, with per-source progress in the status bar. Results are tagged with their source, failures do not blank the grid, and **Load more** appends later pages without duplicating streamed entries.
|
- The source picker offers "All sources", which searches every installed source at once. Results stream in as each source answers, with per-source progress in the status bar. Results are tagged with their source, failures do not blank the grid, and **Load more** appends later pages without duplicating streamed entries.
|
||||||
- The Extensions tab opens with an Installed section listing every extension on disk with Remove, including ones added by hand or whose repository has since been removed. It compares APK and repository version codes, enables Update only for newer builds, marks current extensions as Up to date, and offers Update all when multiple updates are waiting.
|
- The Extensions tab opens with an Installed section listing every extension on disk with Remove, including ones added by hand or whose repository has since been removed. It compares APK and repository version codes, enables Update only for newer builds, marks current extensions as Up to date, and offers Update all when multiple updates are waiting.
|
||||||
- Added `anime.repos`, `anime.extensionsDir`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery.
|
- Added `anime.repos`, `anime.extensionsDir`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery.
|
||||||
|
- The settings app now groups Anime Browser config under an **Aniyomi** section in Integrations. A new `anime.autoOpenJimaku` option pauses newly loaded Anime Browser episodes, closes the in-player browser, opens Jimaku with the episode details filled in, and resumes after a subtitle loads or the modal closes without overriding playback that was already paused.
|
||||||
- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
|
- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
|
||||||
- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English` → `en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead.
|
- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English` → `en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead.
|
||||||
- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments and gives disguised segment URLs (`.image`, `.jpg`, `.css`, and other rotating fake extensions) a media-safe local alias, so those streams play in mpv and support Anki audio and image extraction with current ffmpeg releases. Upstream responses use identity encoding so playlists remain readable for URL rewriting.
|
- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments and gives disguised segment URLs (`.image`, `.jpg`, `.css`, and other rotating fake extensions) a media-safe local alias, so those streams play in mpv and support Anki audio and image extraction with current ffmpeg releases. Upstream responses use identity encoding so playlists remain readable for URL rewriting.
|
||||||
@@ -28,6 +29,6 @@ area: anime
|
|||||||
- Right-clicking an episode opens a menu for marking it watched or unwatched by hand, plus "Mark this and N below watched/unwatched" for the episode and every episode listed under it. Sources list newest first, so a span covers the back catalogue, which is how a series watched elsewhere gets caught up. A filter never narrows what a span covers, and the status bar reports how many episodes were touched.
|
- Right-clicking an episode opens a menu for marking it watched or unwatched by hand, plus "Mark this and N below watched/unwatched" for the episode and every episode listed under it. Sources list newest first, so a span covers the back catalogue, which is how a series watched elsewhere gets caught up. A filter never narrows what a span covers, and the status bar reports how many episodes were touched.
|
||||||
- Marking an episode that was never played creates its stats row, carrying the same series, season and episode fields playback would have recorded. Both stats library views join the lifetime tables, so a manual mark does not show up there as watch time nobody spent, and clearing a mark creates nothing.
|
- Marking an episode that was never played creates its stats row, carrying the same series, season and episode fields playback would have recorded. Both stats library views join the lifetime tables, so a manual mark does not show up there as watch time nobody spent, and clearing a mark creates nothing.
|
||||||
- Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays.
|
- Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays.
|
||||||
- Added an in-player Anime Browser modal on `Ctrl+Alt+A`. The shortcut toggles it without losing its page or scroll position. It stays within the player bounds and shares the active episode, playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state. The modal validates its embedded page before changing overlay state, so a load setup failure leaves it closed rather than revealing a broken modal.
|
- Added an in-player Anime Browser modal on `Ctrl+Alt+A`. The shortcut toggles it without losing its page or scroll position. It stays within the player bounds and shares the active episode, playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state. The modal validates its embedded page before changing overlay state, so a load setup failure leaves it closed rather than revealing a broken modal. The SubMiner logo identifies the Anime Browser in both the modal and standalone window.
|
||||||
- The bridge is reused from a package-manager install when one exists: on Arch the AUR `mangatan-extension-server` package (shared with Mangatan) is picked up from `/usr/share/mangatan/extension_server`, so nothing is downloaded and pacman keeps it current. `anime.bridgeDir` points SubMiner at a bundle anywhere else. `subminer-bin` lists the package as an optional dependency.
|
- The bridge is reused from a package-manager install when one exists: on Arch the AUR `mangatan-extension-server` package (shared with Mangatan) is picked up from `/usr/share/mangatan/extension_server`, so nothing is downloaded and pacman keeps it current. `anime.bridgeDir` points SubMiner at a bundle anywhere else. `subminer-bin` lists the package as an optional dependency.
|
||||||
- SubMiner records which bridge release it installed and, once the bridge is running, checks GitHub for a newer one; when there is, the banner offers an **Update to vX** button. The new release is downloaded beside the running bridge, then the bridge restarts on it. The updater waits for a bridge still starting to stop and keeps the previous bundle until the replacement is active. The Extensions tab shows the bridge version, where it lives, and who updates it without describing an unchecked install as current.
|
- SubMiner records which bridge release it installed and, once the bridge is running, checks GitHub for a newer one; when there is, the banner offers an **Update to vX** button. The new release is downloaded beside the running bridge, then the bridge restarts on it. The updater waits for a bridge still starting to stop and keeps the previous bundle until the replacement is active. The Extensions tab shows the bridge version, where it lives, and who updates it without describing an unchecked install as current.
|
||||||
|
|||||||
@@ -626,9 +626,10 @@
|
|||||||
// Anime Browser
|
// Anime Browser
|
||||||
// Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
// 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.
|
// add a repository index URL here (or drop .apk files in the extensions directory) to have any.
|
||||||
// Hot-reload: anime changes apply the next time the anime browser opens.
|
// Hot-reload: autoOpenJimaku applies to the next episode; other anime changes apply the next time the anime browser opens.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"anime": {
|
"anime": {
|
||||||
|
"autoOpenJimaku": false, // Pause Anime Browser playback and open Jimaku when an episode loads. Playback resumes after a subtitle loads or the modal closes. Values: true | false
|
||||||
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
|
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
|
||||||
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
|
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
|
||||||
"preferredQuality": "", // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
|
"preferredQuality": "", // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
|
||||||
|
|||||||
@@ -202,11 +202,18 @@ there is nowhere to write them and the status bar says so.
|
|||||||
|
|
||||||
| Key | Purpose |
|
| Key | Purpose |
|
||||||
| ------------------------ | ----------------------------------------------------------------------- |
|
| ------------------------ | ----------------------------------------------------------------------- |
|
||||||
|
| `anime.autoOpenJimaku` | Pause new episodes and open Jimaku for Japanese subtitles. |
|
||||||
| `anime.repos` | Repository index URLs. Empty by default. |
|
| `anime.repos` | Repository index URLs. Empty by default. |
|
||||||
| `anime.extensionsDir` | Where APKs are read from. Empty uses `<userData>/anime-extensions`. |
|
| `anime.extensionsDir` | Where APKs are read from. Empty uses `<userData>/anime-extensions`. |
|
||||||
| `anime.preferredQuality` | Preferred stream label, matched as a substring (for example `1080`). |
|
| `anime.preferredQuality` | Preferred stream label, matched as a substring (for example `1080`). |
|
||||||
| `anime.bridgeDir` | A bridge bundle to run instead of the downloaded one. Empty by default. |
|
| `anime.bridgeDir` | A bridge bundle to run instead of the downloaded one. Empty by default. |
|
||||||
|
|
||||||
|
Enable `anime.autoOpenJimaku` to hand each newly loaded Anime Browser episode
|
||||||
|
to Jimaku. SubMiner pauses playback, closes the in-player browser if it is open,
|
||||||
|
and opens Jimaku with the source title, season, and episode already filled in.
|
||||||
|
Playback resumes after the selected subtitle loads. Closing Jimaku also releases
|
||||||
|
the automatic pause, while playback that was already paused stays paused.
|
||||||
|
|
||||||
## Source settings
|
## Source settings
|
||||||
|
|
||||||
Most extensions need configuration before they return anything — a server
|
Most extensions need configuration before they return anything — a server
|
||||||
|
|||||||
@@ -1165,6 +1165,7 @@ Sources for the [anime browser](/anime-browser). SubMiner ships no extension rep
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"anime": {
|
"anime": {
|
||||||
|
"autoOpenJimaku": false,
|
||||||
"extensionsDir": "",
|
"extensionsDir": "",
|
||||||
"repos": [],
|
"repos": [],
|
||||||
"preferredQuality": ""
|
"preferredQuality": ""
|
||||||
@@ -1174,6 +1175,7 @@ Sources for the [anime browser](/anime-browser). SubMiner ships no extension rep
|
|||||||
|
|
||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
| ------------------------ | ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------ | ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `anime.autoOpenJimaku` | `boolean` | `false` | Pause Anime Browser playback and open Jimaku when an episode loads. Playback resumes after a subtitle loads or the modal closes. Playback that was already paused stays paused. |
|
||||||
| `anime.extensionsDir` | `string` | `""` | Directory holding Aniyomi extension `.apk` files. Empty uses `<userData>/anime-extensions`. |
|
| `anime.extensionsDir` | `string` | `""` | Directory holding Aniyomi extension `.apk` files. Empty uses `<userData>/anime-extensions`. |
|
||||||
| `anime.repos` | `string[]` | `[]` | Extension repository index URLs. Any `https` URL ending in `.json` works; `index.min.json` is only the common name. |
|
| `anime.repos` | `string[]` | `[]` | Extension repository index URLs. Any `https` URL ending in `.json` works; `index.min.json` is only the common name. |
|
||||||
| `anime.preferredQuality` | `string` | `""` | Preferred stream quality label, matched as a substring (for example `1080`). Empty keeps the source's own order. A Japanese-audio entry always outranks a higher-quality dub. |
|
| `anime.preferredQuality` | `string` | `""` | Preferred stream quality label, matched as a substring (for example `1080`). Empty keeps the source's own order. A Japanese-audio entry always outranks a higher-quality dub. |
|
||||||
|
|||||||
@@ -626,9 +626,10 @@
|
|||||||
// Anime Browser
|
// Anime Browser
|
||||||
// Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
// 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.
|
// add a repository index URL here (or drop .apk files in the extensions directory) to have any.
|
||||||
// Hot-reload: anime changes apply the next time the anime browser opens.
|
// Hot-reload: autoOpenJimaku applies to the next episode; other anime changes apply the next time the anime browser opens.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"anime": {
|
"anime": {
|
||||||
|
"autoOpenJimaku": false, // Pause Anime Browser playback and open Jimaku when an episode loads. Playback resumes after a subtitle loads or the modal closes. Values: true | false
|
||||||
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
|
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
|
||||||
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
|
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
|
||||||
"preferredQuality": "", // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
|
"preferredQuality": "", // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
|
|
||||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(scriptDir, '..');
|
const repoRoot = path.resolve(scriptDir, '..');
|
||||||
|
const assetsSourceDir = path.join(repoRoot, 'assets');
|
||||||
const rendererSourceDir = path.join(repoRoot, 'src', 'renderer');
|
const rendererSourceDir = path.join(repoRoot, 'src', 'renderer');
|
||||||
const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer');
|
const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer');
|
||||||
const settingsSourceDir = path.join(repoRoot, 'src', 'settings');
|
const settingsSourceDir = path.join(repoRoot, 'src', 'settings');
|
||||||
@@ -58,6 +59,7 @@ function copyAnimeUiAssets() {
|
|||||||
'detail.css',
|
'detail.css',
|
||||||
'panels.css',
|
'panels.css',
|
||||||
]);
|
]);
|
||||||
|
copyFile(path.join(assetsSourceDir, 'SubMiner.png'), path.join(animeUiOutputDir, 'SubMiner.png'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackToMacosSource() {
|
function fallbackToMacosSource() {
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ test('anime UI stylesheet files exist and are all staged', () => {
|
|||||||
source,
|
source,
|
||||||
/copyAssets\(animeUiSourceDir, animeUiOutputDir, 'animeui', \[\s*'style\.css',\s*'detail\.css',\s*'panels\.css',?\s*\]\)/,
|
/copyAssets\(animeUiSourceDir, animeUiOutputDir, 'animeui', \[\s*'style\.css',\s*'detail\.css',\s*'panels\.css',?\s*\]\)/,
|
||||||
);
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/copyFile\(\s*path\.join\(assetsSourceDir, 'SubMiner\.png'\),\s*path\.join\(animeUiOutputDir, 'SubMiner\.png'\),?\s*\)/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regression guard for #213: an untargeted swiftc stamps the build machine's OS
|
// Regression guard for #213: an untargeted swiftc stamps the build machine's OS
|
||||||
|
|||||||
@@ -14,9 +14,12 @@
|
|||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand-block">
|
<div class="brand-block">
|
||||||
|
<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-title">SubMiner</div>
|
||||||
<div class="brand-subtitle">Anime</div>
|
<div class="brand-subtitle">Anime</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form class="search-form" id="search-form" role="search">
|
<form class="search-form" id="search-form" role="search">
|
||||||
<input
|
<input
|
||||||
|
|||||||
+34
-1
@@ -58,7 +58,24 @@ html[data-embedded='overlay-modal'] .topbar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html[data-embedded='overlay-modal'] .brand-block {
|
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 {
|
html[data-embedded='overlay-modal'] .bridge-banner {
|
||||||
@@ -96,6 +113,22 @@ body {
|
|||||||
flex: none;
|
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 {
|
.brand-title {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.mpv.subminerBinaryPath, '');
|
assert.equal(config.mpv.subminerBinaryPath, '');
|
||||||
assert.equal(config.mpv.aniskipEnabled, true);
|
assert.equal(config.mpv.aniskipEnabled, true);
|
||||||
assert.equal(config.mpv.aniskipButtonKey, 'TAB');
|
assert.equal(config.mpv.aniskipButtonKey, 'TAB');
|
||||||
|
assert.equal(config.anime.autoOpenJimaku, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects invalid mpv volume mirroring values', () => {
|
test('rejects invalid mpv volume mirroring values', () => {
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
anime: {
|
anime: {
|
||||||
// Empty by default: SubMiner ships no extension repositories and performs
|
// Empty by default: SubMiner ships no extension repositories and performs
|
||||||
// no discovery. Sources exist only once the user adds a repo or an APK.
|
// no discovery. Sources exist only once the user adds a repo or an APK.
|
||||||
|
autoOpenJimaku: false,
|
||||||
extensionsDir: '',
|
extensionsDir: '',
|
||||||
repos: [],
|
repos: [],
|
||||||
preferredQuality: '',
|
preferredQuality: '',
|
||||||
|
|||||||
@@ -588,6 +588,13 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.mpv.aniskipButtonKey,
|
defaultValue: defaultConfig.mpv.aniskipButtonKey,
|
||||||
description: 'mpv key used to skip the detected intro while the skip prompt is visible.',
|
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',
|
path: 'anime.extensionsDir',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
|
|||||||
@@ -147,7 +147,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
'Anime browser sources. SubMiner ships no extension repositories and bundles no sources;',
|
'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.',
|
'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',
|
key: 'anime',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -57,3 +57,32 @@ test('resolveConfig warns for invalid mpv launch mode', () => {
|
|||||||
message: "Expected one of: 'normal', 'maximized', 'fullscreen'.",
|
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)) {
|
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);
|
const extensionsDir = asString(src.anime.extensionsDir);
|
||||||
if (extensionsDir !== undefined) {
|
if (extensionsDir !== undefined) {
|
||||||
resolved.anime.extensionsDir = normalizeExternalProfilePath(extensionsDir);
|
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', () => {
|
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').category, 'input');
|
||||||
assert.equal(field('mpv.aniskipButtonKey').section, 'Overlay Shortcuts');
|
assert.equal(field('mpv.aniskipButtonKey').section, 'Overlay Shortcuts');
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ const SECTION_ORDER = new Map<string, number>(
|
|||||||
'Kiku/Lapis/Senren Features',
|
'Kiku/Lapis/Senren Features',
|
||||||
'Anki AI',
|
'Anki AI',
|
||||||
'AnkiConnect Proxy',
|
'AnkiConnect Proxy',
|
||||||
|
'Aniyomi',
|
||||||
'Jimaku',
|
'Jimaku',
|
||||||
'Subtitle Sync',
|
'Subtitle Sync',
|
||||||
'MPV Keybindings',
|
'MPV Keybindings',
|
||||||
@@ -245,6 +246,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
|
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
|
||||||
'mpv.aniskipEnabled': 'Enable AniSkip',
|
'mpv.aniskipEnabled': 'Enable AniSkip',
|
||||||
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
||||||
|
'anime.autoOpenJimaku': 'Auto-open Jimaku',
|
||||||
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
|
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
|
||||||
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
||||||
};
|
};
|
||||||
@@ -444,6 +446,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
if (path.startsWith('jimaku.') || path.startsWith('tsukihime.')) {
|
if (path.startsWith('jimaku.') || path.startsWith('tsukihime.')) {
|
||||||
return { category: 'integrations', section: topSection(path) };
|
return { category: 'integrations', section: topSection(path) };
|
||||||
}
|
}
|
||||||
|
if (path.startsWith('anime.')) {
|
||||||
|
return { category: 'integrations', section: 'Aniyomi' };
|
||||||
|
}
|
||||||
if (path.startsWith('subsync.')) {
|
if (path.startsWith('subsync.')) {
|
||||||
return { category: 'integrations', section: topSection(path) };
|
return { category: 'integrations', section: topSection(path) };
|
||||||
}
|
}
|
||||||
@@ -724,6 +729,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
path === 'logging.rotation' ||
|
path === 'logging.rotation' ||
|
||||||
pathStartsWith(path, 'logging.files') ||
|
pathStartsWith(path, 'logging.files') ||
|
||||||
pathStartsWith(path, 'notifications') ||
|
pathStartsWith(path, 'notifications') ||
|
||||||
|
path === 'anime.autoOpenJimaku' ||
|
||||||
path === 'youtube.primarySubLanguages' ||
|
path === 'youtube.primarySubLanguages' ||
|
||||||
pathStartsWith(path, 'jimaku') ||
|
pathStartsWith(path, 'jimaku') ||
|
||||||
pathStartsWith(path, 'subsync')
|
pathStartsWith(path, 'subsync')
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface RuntimeHarness {
|
|||||||
fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||||
tsukihimeFetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
tsukihimeFetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||||
sentCommands: Array<{ command: (string | number)[] }>;
|
sentCommands: Array<{ command: (string | number)[] }>;
|
||||||
|
jimakuSubtitleLoaded: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ function createHarness(): RuntimeHarness {
|
|||||||
query?: Record<string, unknown>;
|
query?: Record<string, unknown>;
|
||||||
}>,
|
}>,
|
||||||
sentCommands: [] as Array<{ command: (string | number)[] }>,
|
sentCommands: [] as Array<{ command: (string | number)[] }>,
|
||||||
|
jimakuSubtitleLoaded: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const options: AnkiJimakuIpcRuntimeOptions = {
|
const options: AnkiJimakuIpcRuntimeOptions = {
|
||||||
@@ -148,6 +150,9 @@ function createHarness(): RuntimeHarness {
|
|||||||
ok: true,
|
ok: true,
|
||||||
path: `${destPath}:${url}`,
|
path: `${destPath}:${url}`,
|
||||||
}),
|
}),
|
||||||
|
onJimakuSubtitleLoaded: () => {
|
||||||
|
state.jimakuSubtitleLoaded += 1;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let registered: Record<string, (...args: unknown[]) => unknown> = {};
|
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');
|
registered.onDownloadedSubtitle!('/tmp/subtitle.ass');
|
||||||
assert.deepEqual(state.sentCommands, [{ command: ['sub-add', '/tmp/subtitle.ass', 'select'] }]);
|
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 () => {
|
test('onDownloadedSecondarySubtitle loads without stealing the primary track', async () => {
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
|||||||
error: { error: string; code?: number; retryAfter?: number };
|
error: { error: string; code?: number; retryAfter?: number };
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
onJimakuSubtitleLoaded?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logger = createLogger('main:anki-jimaku');
|
const logger = createLogger('main:anki-jimaku');
|
||||||
@@ -289,6 +290,7 @@ export function registerAnkiJimakuIpcRuntime(
|
|||||||
const mpvClient = options.getMpvClient();
|
const mpvClient = options.getMpvClient();
|
||||||
if (mpvClient && mpvClient.connected) {
|
if (mpvClient && mpvClient.connected) {
|
||||||
mpvClient.send({ command: ['sub-add', pathToSubtitle, 'select'] });
|
mpvClient.send({ command: ['sub-add', pathToSubtitle, 'select'] });
|
||||||
|
options.onJimakuSubtitleLoaded?.();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDownloadedSecondarySubtitle: async (pathToSubtitle) => {
|
onDownloadedSecondarySubtitle: async (pathToSubtitle) => {
|
||||||
|
|||||||
@@ -102,6 +102,29 @@ test('classifyConfigHotReloadDiff keeps unsafe nested siblings restart-required'
|
|||||||
assert.deepEqual(diff.restartRequiredFields, ['ankiConnect', 'stats']);
|
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', () => {
|
test('config hot reload runtime debounces rapid watch events', () => {
|
||||||
let watchedChangeCallback: (() => void) | null = null;
|
let watchedChangeCallback: (() => void) | null = null;
|
||||||
const pendingTimers = new Map<number, () => void>();
|
const pendingTimers = new Map<number, () => void>();
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
|||||||
'logging.rotation',
|
'logging.rotation',
|
||||||
'logging.files',
|
'logging.files',
|
||||||
'youtube.primarySubLanguages',
|
'youtube.primarySubLanguages',
|
||||||
|
'anime.autoOpenJimaku',
|
||||||
'jimaku',
|
'jimaku',
|
||||||
'subsync',
|
'subsync',
|
||||||
'ankiConnect.deck',
|
'ankiConnect.deck',
|
||||||
|
|||||||
+37
-4
@@ -544,6 +544,7 @@ import {
|
|||||||
createCreateSyncUiWindowHandler,
|
createCreateSyncUiWindowHandler,
|
||||||
} from './main/runtime/setup-window-factory';
|
} from './main/runtime/setup-window-factory';
|
||||||
import { createAnimeBrowserApplicationRuntime } from './main/runtime/anime-browser-application-runtime';
|
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 { openAnimeBrowserModal as openAnimeBrowserModalRuntime } from './main/runtime/anime-browser-open';
|
||||||
import {
|
import {
|
||||||
ensureBridgeBinaries,
|
ensureBridgeBinaries,
|
||||||
@@ -2513,6 +2514,29 @@ const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions');
|
|||||||
*/
|
*/
|
||||||
const streamPlaybackMetadata = createStreamPlaybackMetadataStore();
|
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. */
|
/** Stream metadata for the requested path, or current media when none was supplied. */
|
||||||
function getActiveStreamMetadata(mediaPath: string | null = null) {
|
function getActiveStreamMetadata(mediaPath: string | null = null) {
|
||||||
return matchRequestedStreamPlaybackMetadata(
|
return matchRequestedStreamPlaybackMetadata(
|
||||||
@@ -2545,6 +2569,7 @@ const mediaRuntime = createMediaRuntimeService(
|
|||||||
setCurrentMediaPath: (nextPath: string | null) => {
|
setCurrentMediaPath: (nextPath: string | null) => {
|
||||||
appState.currentMediaPath = nextPath;
|
appState.currentMediaPath = nextPath;
|
||||||
animeBrowserApplicationRuntime.publishPlaybackState(nextPath);
|
animeBrowserApplicationRuntime.publishPlaybackState(nextPath);
|
||||||
|
void animeBrowserJimakuAutoOpen.handleMediaPathChange(nextPath);
|
||||||
},
|
},
|
||||||
clearPendingSubtitlePosition: () => {
|
clearPendingSubtitlePosition: () => {
|
||||||
appState.pendingSubtitlePosition = null;
|
appState.pendingSubtitlePosition = null;
|
||||||
@@ -2944,20 +2969,22 @@ function openOverlayHostedModalWithOsd(
|
|||||||
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
|
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
|
||||||
unavailableMessage: string,
|
unavailableMessage: string,
|
||||||
failureLogMessage: string,
|
failureLogMessage: string,
|
||||||
): void {
|
): Promise<boolean> {
|
||||||
void openModal(createOverlayHostedModalOpenDeps())
|
return openModal(createOverlayHostedModalOpenDeps())
|
||||||
.then((opened) => {
|
.then((opened) => {
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
overlayNotificationsRuntime.showConfiguredStatusNotification(unavailableMessage, {
|
overlayNotificationsRuntime.showConfiguredStatusNotification(unavailableMessage, {
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return opened;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error(failureLogMessage, error);
|
logger.error(failureLogMessage, error);
|
||||||
overlayNotificationsRuntime.showConfiguredStatusNotification(unavailableMessage, {
|
overlayNotificationsRuntime.showConfiguredStatusNotification(unavailableMessage, {
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
});
|
});
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2969,8 +2996,8 @@ function openRuntimeOptionsPalette(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openJimakuOverlay(): void {
|
function openJimakuOverlay(): Promise<boolean> {
|
||||||
openOverlayHostedModalWithOsd(
|
return openOverlayHostedModalWithOsd(
|
||||||
openJimakuModalRuntime,
|
openJimakuModalRuntime,
|
||||||
'Jimaku overlay unavailable.',
|
'Jimaku overlay unavailable.',
|
||||||
'Failed to open Jimaku overlay.',
|
'Failed to open Jimaku overlay.',
|
||||||
@@ -5729,6 +5756,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
applyOverlayClickThrough(senderWindow);
|
applyOverlayClickThrough(senderWindow);
|
||||||
senderWindow.hide();
|
senderWindow.hide();
|
||||||
}
|
}
|
||||||
|
if (modal === 'jimaku') {
|
||||||
|
animeBrowserJimakuAutoOpen.handleJimakuModalClosed();
|
||||||
|
}
|
||||||
handleOverlayModalClosedHandler(modal);
|
handleOverlayModalClosedHandler(modal);
|
||||||
},
|
},
|
||||||
onOverlayModalOpened: (modal, senderWindow) => {
|
onOverlayModalOpened: (modal, senderWindow) => {
|
||||||
@@ -6108,6 +6138,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
downloadOptions?: { isAllowedRedirect?: (url: URL) => boolean },
|
downloadOptions?: { isAllowedRedirect?: (url: URL) => boolean },
|
||||||
) => downloadToFile(url, destPath, headers, downloadOptions),
|
) => downloadToFile(url, destPath, headers, downloadOptions),
|
||||||
|
onJimakuSubtitleLoaded: () => {
|
||||||
|
animeBrowserJimakuAutoOpen.handleJimakuSubtitleLoaded();
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
registerIpcRuntimeServices,
|
registerIpcRuntimeServices,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
|
|||||||
resolveJimakuApiKey: AnkiJimakuIpcRuntimeOptions['resolveJimakuApiKey'];
|
resolveJimakuApiKey: AnkiJimakuIpcRuntimeOptions['resolveJimakuApiKey'];
|
||||||
isRemoteMediaPath: AnkiJimakuIpcRuntimeOptions['isRemoteMediaPath'];
|
isRemoteMediaPath: AnkiJimakuIpcRuntimeOptions['isRemoteMediaPath'];
|
||||||
downloadToFile: AnkiJimakuIpcRuntimeOptions['downloadToFile'];
|
downloadToFile: AnkiJimakuIpcRuntimeOptions['downloadToFile'];
|
||||||
|
onJimakuSubtitleLoaded?: AnkiJimakuIpcRuntimeOptions['onJimakuSubtitleLoaded'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CliCommandRuntimeServiceDepsParams {
|
export interface CliCommandRuntimeServiceDepsParams {
|
||||||
@@ -350,6 +351,9 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
|
|||||||
resolveJimakuApiKey: params.resolveJimakuApiKey,
|
resolveJimakuApiKey: params.resolveJimakuApiKey,
|
||||||
isRemoteMediaPath: params.isRemoteMediaPath,
|
isRemoteMediaPath: params.isRemoteMediaPath,
|
||||||
downloadToFile: params.downloadToFile,
|
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;
|
subtitleSidebar: ResolvedSubtitleSidebarConfig;
|
||||||
auto_start_overlay: boolean;
|
auto_start_overlay: boolean;
|
||||||
anime: AnimeConfig & {
|
anime: AnimeConfig & {
|
||||||
|
autoOpenJimaku: boolean;
|
||||||
extensionsDir: string;
|
extensionsDir: string;
|
||||||
repos: string[];
|
repos: string[];
|
||||||
preferredQuality: string;
|
preferredQuality: string;
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ export interface YoutubePickerResolveResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AnimeConfig {
|
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
|
* Directory holding Aniyomi extension `.apk` files. Defaults to
|
||||||
* `<userData>/anime-extensions` when unset.
|
* `<userData>/anime-extensions` when unset.
|
||||||
|
|||||||
Reference in New Issue
Block a user