mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-12 16:46:07 -08:00
feat(jimaku): auto-open modal when a video has no Japanese subtitle (#319)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Ivan Melck <ivan@spec-kitty.ai>
This commit is contained in:
24 files changed
+907
-22
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
type: added
|
||||||
|
area: jimaku
|
||||||
|
|
||||||
|
- Added `jimaku.autoOpenWhenPrimarySubtitleMissing` (off by default). When a local video loads without a Japanese (primary-language) subtitle track, SubMiner pauses playback and opens the Jimaku modal; playback resumes when the modal closes.
|
||||||
|
- Opening Jimaku while it is already open now reloads the title and episode if the video changed, instead of keeping the previous episode's search.
|
||||||
@@ -992,7 +992,8 @@
|
|||||||
"apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.
|
"apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.
|
||||||
"apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
|
"apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
|
||||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
"maxEntryResults": 10, // Maximum Jimaku search results returned.
|
||||||
|
"autoOpenWhenPrimarySubtitleMissing": false // Pause playback and open the Jimaku modal when a local video has no subtitle track in youtube.primarySubLanguages (Japanese by default). Values: true | false
|
||||||
}, // Jimaku API configuration and defaults.
|
}, // Jimaku API configuration and defaults.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -471,13 +471,14 @@ When SubMiner fills the sentence on a word card, it sets one card-type flag and
|
|||||||
|
|
||||||
Search and download Japanese subtitles from [Jimaku](https://jimaku.cc). See [Jimaku integration](/jimaku-integration).
|
Search and download Japanese subtitles from [Jimaku](https://jimaku.cc). See [Jimaku integration](/jimaku-integration).
|
||||||
|
|
||||||
| Key | Default | What it does |
|
| Key | Default | What it does |
|
||||||
| --------------------------- | --------------------- | ---------------------------------------------------------- |
|
| ------------------------------------------- | --------------------- | --------------------------------------------------------------------------------- |
|
||||||
| `jimaku.apiKey` | `""` | API key. Optional, but raises your rate limit |
|
| `jimaku.apiKey` | `""` | API key. Optional, but raises your rate limit |
|
||||||
| `jimaku.apiKeyCommand` | `""` | Shell command that prints the key. Use instead of `apiKey` |
|
| `jimaku.apiKeyCommand` | `""` | Shell command that prints the key. Use instead of `apiKey` |
|
||||||
| `jimaku.apiBaseUrl` | `"https://jimaku.cc"` | API base URL |
|
| `jimaku.apiBaseUrl` | `"https://jimaku.cc"` | API base URL |
|
||||||
| `jimaku.languagePreference` | `"ja"` | Preferred language: `ja`, `en`, or `none` |
|
| `jimaku.languagePreference` | `"ja"` | Preferred language: `ja`, `en`, or `none` |
|
||||||
| `jimaku.maxEntryResults` | `10` | Maximum search results |
|
| `jimaku.maxEntryResults` | `10` | Maximum search results |
|
||||||
|
| `jimaku.autoOpenWhenPrimarySubtitleMissing` | `false` | Pause and open the Jimaku modal when a local video has no Japanese subtitle track |
|
||||||
|
|
||||||
### TsukiHime
|
### TsukiHime
|
||||||
|
|
||||||
|
|||||||
@@ -37,17 +37,32 @@ The file is saved next to the video (or to a temp directory for streams) and loa
|
|||||||
|
|
||||||
You can also open the modal with `subminer app --open-jimaku`, or change the shortcut with `shortcuts.openJimaku`.
|
You can also open the modal with `subminer app --open-jimaku`, or change the shortcut with `shortcuts.openJimaku`.
|
||||||
|
|
||||||
|
### Open automatically when a video has no Japanese subtitles
|
||||||
|
|
||||||
|
Set `jimaku.autoOpenWhenPrimarySubtitleMissing` to `true` to skip the shortcut. When a local video loads without a subtitle track in `youtube.primarySubLanguages` (Japanese by default), SubMiner pauses playback about a second after the video loads and opens the Jimaku modal, so you can pick a subtitle before watching. Playback stays paused while the modal is open and resumes when it closes, whether or not you picked a subtitle. On a cold start it resumes once subtitle processing is ready, as usual, and a pause you made yourself before the modal opened is kept.
|
||||||
|
|
||||||
|
A single unlabeled sidecar file (such as `episode.srt` next to the video) counts as a Japanese track, matching how SubMiner auto-selects local subtitles. An embedded track with no language tag does not, so the modal still opens for it. Audio files, streams, YouTube, and Jellyfin playback are not checked.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"jimaku": {
|
||||||
|
"autoOpenWhenPrimarySubtitleMissing": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
The file name parser understands `S01E03`, `1x03`, `E03`, `EP03`, and `Title - 03 -` patterns, and reads the season from a parent folder such as `Season 2`. It ignores bracket tags like `[SubGroup]` and year tags like `(2024)`.
|
The file name parser understands `S01E03`, `1x03`, `E03`, `EP03`, and `Title - 03 -` patterns, and reads the season from a parent folder such as `Season 2`. It ignores bracket tags like `[SubGroup]` and year tags like `(2024)`.
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Key | What it does |
|
| Key | What it does |
|
||||||
| --------------------------- | ------------------------------------------------------------------- |
|
| ------------------------------------------- | -------------------------------------------------------------------- |
|
||||||
| `jimaku.apiKey` | API key in plain text. |
|
| `jimaku.apiKey` | API key in plain text. |
|
||||||
| `jimaku.apiKeyCommand` | Shell command that prints the API key. |
|
| `jimaku.apiKeyCommand` | Shell command that prints the API key. |
|
||||||
| `jimaku.languagePreference` | Sorts files tagged with this language first: `ja`, `en`, or `none`. |
|
| `jimaku.languagePreference` | Sorts files tagged with this language first: `ja`, `en`, or `none`. |
|
||||||
| `jimaku.maxEntryResults` | Maximum entries per search. |
|
| `jimaku.maxEntryResults` | Maximum entries per search. |
|
||||||
| `jimaku.apiBaseUrl` | API address. Change only for a mirror. |
|
| `jimaku.apiBaseUrl` | API address. Change only for a mirror. |
|
||||||
|
| `jimaku.autoOpenWhenPrimarySubtitleMissing` | Pauses and opens the modal when a local video has no Japanese track. |
|
||||||
|
|
||||||
See [Configuration](/configuration#jimaku) for defaults.
|
See [Configuration](/configuration#jimaku) for defaults.
|
||||||
|
|
||||||
|
|||||||
@@ -992,7 +992,8 @@
|
|||||||
"apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.
|
"apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.
|
||||||
"apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
|
"apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
|
||||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
"maxEntryResults": 10, // Maximum Jimaku search results returned.
|
||||||
|
"autoOpenWhenPrimarySubtitleMissing": false // Pause playback and open the Jimaku modal when a local video has no subtitle track in youtube.primarySubLanguages (Japanese by default). Values: true | false
|
||||||
}, // Jimaku API configuration and defaults.
|
}, // Jimaku API configuration and defaults.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
apiKeyCommand: '',
|
apiKeyCommand: '',
|
||||||
languagePreference: 'ja',
|
languagePreference: 'ja',
|
||||||
maxEntryResults: 10,
|
maxEntryResults: 10,
|
||||||
|
autoOpenWhenPrimarySubtitleMissing: false,
|
||||||
},
|
},
|
||||||
tsukihime: {
|
tsukihime: {
|
||||||
apiBaseUrl: 'https://api.tsukihime.org/v1',
|
apiBaseUrl: 'https://api.tsukihime.org/v1',
|
||||||
|
|||||||
@@ -467,6 +467,13 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.jimaku.maxEntryResults,
|
defaultValue: defaultConfig.jimaku.maxEntryResults,
|
||||||
description: 'Maximum Jimaku search results returned.',
|
description: 'Maximum Jimaku search results returned.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'jimaku.autoOpenWhenPrimarySubtitleMissing',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.jimaku.autoOpenWhenPrimarySubtitleMissing,
|
||||||
|
description:
|
||||||
|
'Pause playback and open the Jimaku modal when a local video has no subtitle track in youtube.primarySubLanguages (Japanese by default).',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'tsukihime.apiBaseUrl',
|
path: 'tsukihime.apiBaseUrl',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
|
|||||||
@@ -57,3 +57,18 @@ 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 Jimaku auto-open toggle and warns on non-boolean values', () => {
|
||||||
|
const enabled = resolveConfig({ jimaku: { autoOpenWhenPrimarySubtitleMissing: true } });
|
||||||
|
assert.equal(enabled.resolved.jimaku.autoOpenWhenPrimarySubtitleMissing, true);
|
||||||
|
assert.deepEqual(enabled.warnings, []);
|
||||||
|
|
||||||
|
const invalid = resolveConfig({
|
||||||
|
jimaku: { autoOpenWhenPrimarySubtitleMissing: 'sometimes' as never },
|
||||||
|
});
|
||||||
|
assert.equal(invalid.resolved.jimaku.autoOpenWhenPrimarySubtitleMissing, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
invalid.warnings.map((warning) => warning.path),
|
||||||
|
['jimaku.autoOpenWhenPrimarySubtitleMissing'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -90,6 +90,18 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
'Expected positive number.',
|
'Expected positive number.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoOpen = asBoolean(src.jimaku.autoOpenWhenPrimarySubtitleMissing);
|
||||||
|
if (autoOpen !== undefined) {
|
||||||
|
resolved.jimaku.autoOpenWhenPrimarySubtitleMissing = autoOpen;
|
||||||
|
} else if (src.jimaku.autoOpenWhenPrimarySubtitleMissing !== undefined) {
|
||||||
|
warn(
|
||||||
|
'jimaku.autoOpenWhenPrimarySubtitleMissing',
|
||||||
|
src.jimaku.autoOpenWhenPrimarySubtitleMissing,
|
||||||
|
resolved.jimaku.autoOpenWhenPrimarySubtitleMissing,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTsukihimeSource = isObject(src.tsukihime) ? src.tsukihime : null;
|
const currentTsukihimeSource = isObject(src.tsukihime) ? src.tsukihime : null;
|
||||||
|
|||||||
+28
@@ -188,6 +188,7 @@ import {
|
|||||||
} from './main/runtime/autoplay-ready-gate';
|
} from './main/runtime/autoplay-ready-gate';
|
||||||
import { isVisibleOverlayAutoplayTargetReady } from './main/runtime/visible-overlay-autoplay-readiness';
|
import { isVisibleOverlayAutoplayTargetReady } from './main/runtime/visible-overlay-autoplay-readiness';
|
||||||
import { createManagedLocalSubtitleSelectionRuntime } from './main/runtime/local-subtitle-selection';
|
import { createManagedLocalSubtitleSelectionRuntime } from './main/runtime/local-subtitle-selection';
|
||||||
|
import { createJimakuAutoOpenRuntime } from './main/runtime/jimaku-auto-open';
|
||||||
import { createWindowsMpvPluginDetectionRuntime } from './main/runtime/windows-mpv-plugin-detection';
|
import { createWindowsMpvPluginDetectionRuntime } from './main/runtime/windows-mpv-plugin-detection';
|
||||||
import { refreshManagedCommandLineLauncher } from './main/runtime/command-line-launcher';
|
import { refreshManagedCommandLineLauncher } from './main/runtime/command-line-launcher';
|
||||||
import { applyLoginShellPath } from './main/runtime/login-shell-path';
|
import { applyLoginShellPath } from './main/runtime/login-shell-path';
|
||||||
@@ -739,6 +740,8 @@ const autoplayReadyGate = createAutoplayReadyGate({
|
|||||||
},
|
},
|
||||||
// Deferred: isTokenizationWarmupReady is assigned during composeMpvRuntimeHandlers below.
|
// Deferred: isTokenizationWarmupReady is assigned during composeMpvRuntimeHandlers below.
|
||||||
isTokenizationReady: () => isTokenizationWarmupReady(),
|
isTokenizationReady: () => isTokenizationWarmupReady(),
|
||||||
|
// Deferred: jimakuAutoOpenRuntime is created right after this gate.
|
||||||
|
isPlaybackHeld: () => jimakuAutoOpenRuntime.isHoldingPlayback(),
|
||||||
requestOverlayPointerRecovery: () => {
|
requestOverlayPointerRecovery: () => {
|
||||||
if (process.platform !== 'darwin' || !overlayManager.getVisibleOverlayVisible()) {
|
if (process.platform !== 'darwin' || !overlayManager.getVisibleOverlayVisible()) {
|
||||||
return;
|
return;
|
||||||
@@ -785,6 +788,29 @@ const managedLocalSubtitleSelectionRuntime = createManagedLocalSubtitleSelection
|
|||||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||||
clearScheduled: (timer) => clearTimeout(timer),
|
clearScheduled: (timer) => clearTimeout(timer),
|
||||||
});
|
});
|
||||||
|
const jimakuAutoOpenRuntime = createJimakuAutoOpenRuntime({
|
||||||
|
isEnabled: () => configService.getConfig().jimaku.autoOpenWhenPrimarySubtitleMissing,
|
||||||
|
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||||
|
getMpvClient: () => appState.mpvClient,
|
||||||
|
getPrimarySubtitleLanguages: () => configService.getConfig().youtube.primarySubLanguages,
|
||||||
|
isPlaybackPaused: () => appState.playbackPaused,
|
||||||
|
pausePlayback: () => {
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'yes']);
|
||||||
|
},
|
||||||
|
holdPluginAutoplayGate: () => {
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-hold']);
|
||||||
|
},
|
||||||
|
endHold: ({ undoPause }) => {
|
||||||
|
// Readiness deferred during the hold goes through the normal release (plugin + unpause).
|
||||||
|
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||||
|
if (undoPause) {
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'no']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openJimaku: () => overlayModals.openJimakuOverlay(),
|
||||||
|
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||||
|
clearScheduled: (timer) => clearTimeout(timer),
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
resolveBundledMpvRuntimePluginEntrypoint,
|
resolveBundledMpvRuntimePluginEntrypoint,
|
||||||
@@ -1034,6 +1060,7 @@ const overlay = createOverlayAssembly({
|
|||||||
getOverlayNotifications: () => overlayNotificationsRuntime,
|
getOverlayNotifications: () => overlayNotificationsRuntime,
|
||||||
getOverlayVisibilityComposer: () => overlayVisibilityComposer,
|
getOverlayVisibilityComposer: () => overlayVisibilityComposer,
|
||||||
getMediaTimingReviewRuntime: () => mining.mediaTimingReviewRuntime,
|
getMediaTimingReviewRuntime: () => mining.mediaTimingReviewRuntime,
|
||||||
|
getJimakuAutoOpenRuntime: () => jimakuAutoOpenRuntime,
|
||||||
getSubtitleProcessingController: () => subtitleProcessingController,
|
getSubtitleProcessingController: () => subtitleProcessingController,
|
||||||
getAutoplaySubtitlePriming: () => autoplaySubtitlePrimingRuntime,
|
getAutoplaySubtitlePriming: () => autoplaySubtitlePrimingRuntime,
|
||||||
ensureOverlayMpvSubtitlesHidden: () => ensureOverlayMpvSubtitlesHidden(),
|
ensureOverlayMpvSubtitlesHidden: () => ensureOverlayMpvSubtitlesHidden(),
|
||||||
@@ -1768,6 +1795,7 @@ const playback = createPlaybackAssembly({
|
|||||||
currentMediaTokenizationGate,
|
currentMediaTokenizationGate,
|
||||||
startupOsdSequencer,
|
startupOsdSequencer,
|
||||||
managedLocalSubtitleSelectionRuntime,
|
managedLocalSubtitleSelectionRuntime,
|
||||||
|
jimakuAutoOpenRuntime,
|
||||||
mediaTimingReviewRuntime: mining.mediaTimingReviewRuntime,
|
mediaTimingReviewRuntime: mining.mediaTimingReviewRuntime,
|
||||||
discordPresenceRuntime,
|
discordPresenceRuntime,
|
||||||
immersionMediaRuntime: stats.immersionMediaRuntime,
|
immersionMediaRuntime: stats.immersionMediaRuntime,
|
||||||
|
|||||||
@@ -69,20 +69,22 @@ export function createOverlayModalsAssembly(deps: OverlayModalsAssemblyDeps) {
|
|||||||
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) {
|
||||||
deps.getOverlayNotifications().showConfiguredStatusNotification(unavailableMessage, {
|
deps.getOverlayNotifications().showConfiguredStatusNotification(unavailableMessage, {
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return opened;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error(failureLogMessage, error);
|
logger.error(failureLogMessage, error);
|
||||||
deps.getOverlayNotifications().showConfiguredStatusNotification(unavailableMessage, {
|
deps.getOverlayNotifications().showConfiguredStatusNotification(unavailableMessage, {
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
});
|
});
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,8 +96,9 @@ export function createOverlayModalsAssembly(deps: OverlayModalsAssemblyDeps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openJimakuOverlay(): void {
|
/** Resolves to whether the modal acknowledged opening; failures are already reported. */
|
||||||
openOverlayHostedModalWithOsd(
|
function openJimakuOverlay(): Promise<boolean> {
|
||||||
|
return openOverlayHostedModalWithOsd(
|
||||||
openJimakuModalRuntime,
|
openJimakuModalRuntime,
|
||||||
'Jimaku overlay unavailable.',
|
'Jimaku overlay unavailable.',
|
||||||
'Failed to open Jimaku overlay.',
|
'Failed to open Jimaku overlay.',
|
||||||
|
|||||||
@@ -159,6 +159,10 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
|
|||||||
getMediaTimingReviewRuntime: spy('getMediaTimingReviewRuntime', () => ({
|
getMediaTimingReviewRuntime: spy('getMediaTimingReviewRuntime', () => ({
|
||||||
dispose: spy('mediaTimingReview.dispose', resolved),
|
dispose: spy('mediaTimingReview.dispose', resolved),
|
||||||
})),
|
})),
|
||||||
|
getJimakuAutoOpenRuntime: spy('getJimakuAutoOpenRuntime', () => ({
|
||||||
|
handleJimakuModalOpened: spy('jimakuAutoOpen.handleJimakuModalOpened', noop),
|
||||||
|
handleJimakuModalClosed: spy('jimakuAutoOpen.handleJimakuModalClosed', noop),
|
||||||
|
})),
|
||||||
getSubtitleProcessingController: spy('getSubtitleProcessingController', () => ({
|
getSubtitleProcessingController: spy('getSubtitleProcessingController', () => ({
|
||||||
refreshCurrentSubtitle: spy('subtitle.refreshCurrent', () => true),
|
refreshCurrentSubtitle: spy('subtitle.refreshCurrent', () => true),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { createLinuxOverlayModeRuntime } from '../runtime/linux-overlay-mode-run
|
|||||||
import { shouldRunLinuxOverlayZOrderKeepAlive } from '../runtime/linux-overlay-zorder-keepalive';
|
import { shouldRunLinuxOverlayZOrderKeepAlive } from '../runtime/linux-overlay-zorder-keepalive';
|
||||||
import { focusMacOSOverlayWindow } from '../runtime/macos-overlay-window-focus';
|
import { focusMacOSOverlayWindow } from '../runtime/macos-overlay-window-focus';
|
||||||
import type { createMediaTimingReviewRuntime } from '../runtime/media-timing-review';
|
import type { createMediaTimingReviewRuntime } from '../runtime/media-timing-review';
|
||||||
|
import type { createJimakuAutoOpenRuntime } from '../runtime/jimaku-auto-open';
|
||||||
import type { createOverlayModalInputState } from '../runtime/overlay-modal-input-state';
|
import type { createOverlayModalInputState } from '../runtime/overlay-modal-input-state';
|
||||||
import type { createOverlayGeometryRuntime } from '../runtime/overlay-geometry-runtime';
|
import type { createOverlayGeometryRuntime } from '../runtime/overlay-geometry-runtime';
|
||||||
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
||||||
@@ -125,6 +126,10 @@ export interface OverlayAssemblyDeps {
|
|||||||
ReturnType<typeof createMediaTimingReviewRuntime>,
|
ReturnType<typeof createMediaTimingReviewRuntime>,
|
||||||
'dispose'
|
'dispose'
|
||||||
>;
|
>;
|
||||||
|
getJimakuAutoOpenRuntime: () => Pick<
|
||||||
|
ReturnType<typeof createJimakuAutoOpenRuntime>,
|
||||||
|
'handleJimakuModalOpened' | 'handleJimakuModalClosed'
|
||||||
|
>;
|
||||||
getSubtitleProcessingController: () => Pick<
|
getSubtitleProcessingController: () => Pick<
|
||||||
PlaybackAssembly['subtitleProcessingController'],
|
PlaybackAssembly['subtitleProcessingController'],
|
||||||
'refreshCurrentSubtitle'
|
'refreshCurrentSubtitle'
|
||||||
@@ -331,6 +336,8 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void deps.getMediaTimingReviewRuntime().dispose();
|
void deps.getMediaTimingReviewRuntime().dispose();
|
||||||
|
// A destroyed modal window never reports its modals closed.
|
||||||
|
deps.getJimakuAutoOpenRuntime().handleJimakuModalClosed();
|
||||||
overlayManager.setModalWindow(null);
|
overlayManager.setModalWindow(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -506,6 +513,9 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
|||||||
if (modal === 'media-timing-review') {
|
if (modal === 'media-timing-review') {
|
||||||
void deps.getMediaTimingReviewRuntime().dispose();
|
void deps.getMediaTimingReviewRuntime().dispose();
|
||||||
}
|
}
|
||||||
|
if (modal === 'jimaku') {
|
||||||
|
deps.getJimakuAutoOpenRuntime().handleJimakuModalClosed();
|
||||||
|
}
|
||||||
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
|
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
|
||||||
deps.setSubtitleSidebarRequestedOpen(false);
|
deps.setSubtitleSidebarRequestedOpen(false);
|
||||||
}
|
}
|
||||||
@@ -525,6 +535,9 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
|||||||
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
|
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
|
||||||
deps.setSubtitleSidebarRequestedOpen(true);
|
deps.setSubtitleSidebarRequestedOpen(true);
|
||||||
}
|
}
|
||||||
|
if (modal === 'jimaku') {
|
||||||
|
deps.getJimakuAutoOpenRuntime().handleJimakuModalOpened();
|
||||||
|
}
|
||||||
deps.overlayModalRuntime.notifyOverlayModalOpened(modal);
|
deps.overlayModalRuntime.notifyOverlayModalOpened(modal);
|
||||||
},
|
},
|
||||||
onOverlayMouseInteractionChanged: (active, senderWindow) => {
|
onOverlayMouseInteractionChanged: (active, senderWindow) => {
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
|
|||||||
handleMediaPathChange: spy('localSubtitles.handleMediaPathChange', noop),
|
handleMediaPathChange: spy('localSubtitles.handleMediaPathChange', noop),
|
||||||
handleSubtitleTrackListChange: spy('localSubtitles.handleSubtitleTrackListChange', noop),
|
handleSubtitleTrackListChange: spy('localSubtitles.handleSubtitleTrackListChange', noop),
|
||||||
},
|
},
|
||||||
|
jimakuAutoOpenRuntime: {
|
||||||
|
handleMediaPathChange: spy('jimakuAutoOpen.handleMediaPathChange', noop),
|
||||||
|
},
|
||||||
mediaTimingReviewRuntime: {
|
mediaTimingReviewRuntime: {
|
||||||
deferPlaybackResume: spy('timingReview.deferPlaybackResume', () => false),
|
deferPlaybackResume: spy('timingReview.deferPlaybackResume', () => false),
|
||||||
cancelPlaybackResume: spy('timingReview.cancelPlaybackResume', noop),
|
cancelPlaybackResume: spy('timingReview.cancelPlaybackResume', noop),
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import type { createDiscordPresenceRuntime } from '../runtime/discord-presence-r
|
|||||||
import type { createImmersionMediaRuntime } from '../runtime/domains/startup';
|
import type { createImmersionMediaRuntime } from '../runtime/domains/startup';
|
||||||
import type { loadSubtitleSourceText } from '../runtime/internal-subtitle-extraction';
|
import type { loadSubtitleSourceText } from '../runtime/internal-subtitle-extraction';
|
||||||
import type { createManagedLocalSubtitleSelectionRuntime } from '../runtime/local-subtitle-selection';
|
import type { createManagedLocalSubtitleSelectionRuntime } from '../runtime/local-subtitle-selection';
|
||||||
|
import type { createJimakuAutoOpenRuntime } from '../runtime/jimaku-auto-open';
|
||||||
import type { createMediaTimingReviewRuntime } from '../runtime/media-timing-review';
|
import type { createMediaTimingReviewRuntime } from '../runtime/media-timing-review';
|
||||||
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
||||||
import type { createStartupOsdSequencer } from '../runtime/startup-osd-sequencer';
|
import type { createStartupOsdSequencer } from '../runtime/startup-osd-sequencer';
|
||||||
@@ -122,6 +123,10 @@ export interface PlaybackAssemblyDeps {
|
|||||||
ReturnType<typeof createManagedLocalSubtitleSelectionRuntime>,
|
ReturnType<typeof createManagedLocalSubtitleSelectionRuntime>,
|
||||||
'handleMediaPathChange' | 'handleSubtitleTrackListChange'
|
'handleMediaPathChange' | 'handleSubtitleTrackListChange'
|
||||||
>;
|
>;
|
||||||
|
jimakuAutoOpenRuntime: Pick<
|
||||||
|
ReturnType<typeof createJimakuAutoOpenRuntime>,
|
||||||
|
'handleMediaPathChange'
|
||||||
|
>;
|
||||||
mediaTimingReviewRuntime: Pick<
|
mediaTimingReviewRuntime: Pick<
|
||||||
ReturnType<typeof createMediaTimingReviewRuntime>,
|
ReturnType<typeof createMediaTimingReviewRuntime>,
|
||||||
'deferPlaybackResume' | 'cancelPlaybackResume'
|
'deferPlaybackResume' | 'cancelPlaybackResume'
|
||||||
@@ -240,6 +245,7 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
|
|||||||
currentMediaTokenizationGate,
|
currentMediaTokenizationGate,
|
||||||
startupOsdSequencer,
|
startupOsdSequencer,
|
||||||
managedLocalSubtitleSelectionRuntime,
|
managedLocalSubtitleSelectionRuntime,
|
||||||
|
jimakuAutoOpenRuntime,
|
||||||
mediaTimingReviewRuntime,
|
mediaTimingReviewRuntime,
|
||||||
discordPresenceRuntime,
|
discordPresenceRuntime,
|
||||||
immersionMediaRuntime,
|
immersionMediaRuntime,
|
||||||
@@ -683,6 +689,7 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
|
|||||||
}
|
}
|
||||||
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
||||||
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
|
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
|
||||||
|
jimakuAutoOpenRuntime.handleMediaPathChange(path);
|
||||||
startupOsdSequencer.reset();
|
startupOsdSequencer.reset();
|
||||||
subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh();
|
subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh();
|
||||||
if (!preserveParsedSubtitleCues) {
|
if (!preserveParsedSubtitleCues) {
|
||||||
|
|||||||
@@ -260,3 +260,69 @@ test('autoplay ready gate releases tokenized signals while tokenization warmup i
|
|||||||
|
|
||||||
assert.deepEqual(h.pluginSignals(), [PLUGIN_READY]);
|
assert.deepEqual(h.pluginSignals(), [PLUGIN_READY]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('autoplay ready gate defers readiness while held and releases it when flushed after the hold', async () => {
|
||||||
|
let held = true;
|
||||||
|
const h = createGate({ isPlaybackHeld: () => held });
|
||||||
|
|
||||||
|
// The one-shot warm release: nothing re-sends it, so it must survive the hold.
|
||||||
|
await h.signal('__warm__');
|
||||||
|
await h.runScheduled();
|
||||||
|
assert.deepEqual(h.pluginSignals(), []);
|
||||||
|
assert.equal(h.unpauseCount(), 0);
|
||||||
|
|
||||||
|
held = false;
|
||||||
|
h.gate.flushPendingAutoplayReadySignal();
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(h.pluginSignals(), [PLUGIN_READY]);
|
||||||
|
assert.equal(h.mpv.paused, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('autoplay ready gate stops pending release retries once playback becomes held', async () => {
|
||||||
|
let held = false;
|
||||||
|
let connected = false;
|
||||||
|
const commands: MpvCommand[] = [];
|
||||||
|
const h = createGate({
|
||||||
|
isPlaybackHeld: () => held,
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected,
|
||||||
|
requestProperty: async () => true,
|
||||||
|
send: ({ command }) => {
|
||||||
|
commands.push(command);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await h.signal('字幕');
|
||||||
|
held = true;
|
||||||
|
connected = true;
|
||||||
|
await h.runScheduled();
|
||||||
|
|
||||||
|
assert.equal(commands.filter(isUnpause).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('autoplay ready gate does not unpause when a hold starts while the pause read is in flight', async () => {
|
||||||
|
let held = false;
|
||||||
|
let resolvePauseRead: (paused: boolean) => void = () => {};
|
||||||
|
const commands: MpvCommand[] = [];
|
||||||
|
const h = createGate({
|
||||||
|
isPlaybackHeld: () => held,
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
requestProperty: () =>
|
||||||
|
new Promise<unknown>((resolve) => {
|
||||||
|
resolvePauseRead = resolve;
|
||||||
|
}),
|
||||||
|
send: ({ command }) => {
|
||||||
|
commands.push(command);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await h.signal('字幕');
|
||||||
|
held = true;
|
||||||
|
resolvePauseRead(true);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
assert.equal(commands.filter(isUnpause).length, 0);
|
||||||
|
});
|
||||||
@@ -27,6 +27,8 @@ export type AutoplayReadyGateDeps = {
|
|||||||
getMpvClient: () => MpvClientLike | null;
|
getMpvClient: () => MpvClientLike | null;
|
||||||
signalPluginAutoplayReady: () => void;
|
signalPluginAutoplayReady: () => void;
|
||||||
isTokenizationReady?: () => boolean;
|
isTokenizationReady?: () => boolean;
|
||||||
|
/** True while another feature keeps playback paused on purpose; readiness must not resume it. */
|
||||||
|
isPlaybackHeld?: () => boolean;
|
||||||
requestOverlayPointerRecovery?: () => void;
|
requestOverlayPointerRecovery?: () => void;
|
||||||
onAutoplayReadyReleased?: (signal: AutoplayReadySignal) => void;
|
onAutoplayReadyReleased?: (signal: AutoplayReadySignal) => void;
|
||||||
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
|
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
|
||||||
@@ -169,6 +171,13 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
if (!shouldUnpause) {
|
if (!shouldUnpause) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Checked after the pause read: a hold can start while that request is in flight.
|
||||||
|
if (deps.isPlaybackHeld?.()) {
|
||||||
|
deps.logDebug(
|
||||||
|
`[autoplay-ready] stopped release retries while playback is held for media ${mediaPath}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
mpvClient.send({ command: ['set_property', 'pause', false] });
|
mpvClient.send({ command: ['set_property', 'pause', false] });
|
||||||
releaseUnpauseSent = true;
|
releaseUnpauseSent = true;
|
||||||
@@ -193,6 +202,16 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
pendingAutoplayReadySignal = null;
|
pendingAutoplayReadySignal = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (deps.isPlaybackHeld?.()) {
|
||||||
|
// Deferred, not dropped: readiness may never be signalled again (the warm release fires
|
||||||
|
// once, and a video without subtitles emits nothing), so the hold's owner flushes it after.
|
||||||
|
if (setPendingAutoplayReadySignal(signal)) {
|
||||||
|
deps.logDebug(
|
||||||
|
`[autoplay-ready] deferred while playback is held for media ${signal.mediaPath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isSignalTargetReady(signal)) {
|
if (!isSignalTargetReady(signal)) {
|
||||||
const pendingSignalChanged = setPendingAutoplayReadySignal(signal);
|
const pendingSignalChanged = setPendingAutoplayReadySignal(signal);
|
||||||
schedulePendingAutoplayReadyRetry();
|
schedulePendingAutoplayReadyRetry();
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createJimakuAutoOpenRuntime } from './jimaku-auto-open';
|
||||||
|
|
||||||
|
const videoTrack = { type: 'video', id: 1, selected: true };
|
||||||
|
const englishOnlyTrackList = [
|
||||||
|
videoTrack,
|
||||||
|
{ type: 'sub', id: 1, lang: 'eng', title: 'English', external: false, selected: true },
|
||||||
|
];
|
||||||
|
const japaneseTrackList = [
|
||||||
|
...englishOnlyTrackList,
|
||||||
|
{ type: 'sub', id: 2, lang: 'jpn', title: 'Japanese', external: false },
|
||||||
|
];
|
||||||
|
const unlabeledSidecarTrackList = [
|
||||||
|
...englishOnlyTrackList,
|
||||||
|
{ type: 'sub', id: 2, title: 'srt', external: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
function makeHarness(
|
||||||
|
overrides: {
|
||||||
|
enabled?: boolean;
|
||||||
|
mediaPath?: string | null;
|
||||||
|
trackList?: unknown;
|
||||||
|
trackListError?: boolean;
|
||||||
|
primaryLanguages?: string[];
|
||||||
|
openJimaku?: () => Promise<boolean>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const state = {
|
||||||
|
enabled: overrides.enabled ?? true,
|
||||||
|
mediaPath: overrides.mediaPath === undefined ? '/videos/episode-01.mkv' : overrides.mediaPath,
|
||||||
|
trackList: 'trackList' in overrides ? overrides.trackList : englishOnlyTrackList,
|
||||||
|
paused: false as boolean | null,
|
||||||
|
};
|
||||||
|
const events: string[] = [];
|
||||||
|
const scheduled: Array<() => void> = [];
|
||||||
|
|
||||||
|
const runtime = createJimakuAutoOpenRuntime({
|
||||||
|
isEnabled: () => state.enabled,
|
||||||
|
getCurrentMediaPath: () => state.mediaPath,
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name !== 'track-list' || overrides.trackListError) {
|
||||||
|
throw new Error(`Unexpected property: ${name}`);
|
||||||
|
}
|
||||||
|
return state.trackList;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getPrimarySubtitleLanguages: () => overrides.primaryLanguages ?? ['ja', 'jpn'],
|
||||||
|
isPlaybackPaused: () => state.paused,
|
||||||
|
pausePlayback: () => {
|
||||||
|
state.paused = true;
|
||||||
|
events.push('pause');
|
||||||
|
},
|
||||||
|
endHold: ({ undoPause }) => {
|
||||||
|
events.push('end-hold');
|
||||||
|
if (undoPause) {
|
||||||
|
state.paused = false;
|
||||||
|
events.push('resume');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
holdPluginAutoplayGate: () => events.push('plugin-hold'),
|
||||||
|
openJimaku: () => {
|
||||||
|
events.push('open');
|
||||||
|
return overrides.openJimaku?.() ?? Promise.resolve(true);
|
||||||
|
},
|
||||||
|
schedule: (callback) => {
|
||||||
|
scheduled.push(callback);
|
||||||
|
return scheduled.length as never;
|
||||||
|
},
|
||||||
|
clearScheduled: (timer) => {
|
||||||
|
scheduled[(timer as unknown as number) - 1] = () => {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const runScheduled = async (): Promise<void> => {
|
||||||
|
for (const callback of scheduled.splice(0)) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
await settle();
|
||||||
|
};
|
||||||
|
// Reports a media load and runs the delayed check to completion.
|
||||||
|
const load = async (mediaPath = state.mediaPath): Promise<void> => {
|
||||||
|
state.mediaPath = mediaPath;
|
||||||
|
runtime.handleMediaPathChange(mediaPath);
|
||||||
|
await runScheduled();
|
||||||
|
};
|
||||||
|
const count = (event: string) => events.filter((entry) => entry === event).length;
|
||||||
|
|
||||||
|
return { runtime, state, events, load, runScheduled, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('jimaku auto-open pauses, then opens the modal, for local video with no Japanese track', async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
|
||||||
|
await h.load();
|
||||||
|
|
||||||
|
assert.ok(h.events.indexOf('pause') < h.events.indexOf('open'));
|
||||||
|
assert.equal(h.state.paused, true);
|
||||||
|
assert.equal(h.count('plugin-hold'), 1);
|
||||||
|
assert.equal(h.runtime.isHoldingPlayback(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const c of [
|
||||||
|
{ name: 'a Japanese track exists', overrides: { trackList: japaneseTrackList } },
|
||||||
|
{
|
||||||
|
name: 'a single unlabeled sidecar subtitle exists',
|
||||||
|
overrides: { trackList: unlabeledSidecarTrackList },
|
||||||
|
},
|
||||||
|
{ name: 'the configured primary language matches', overrides: { primaryLanguages: ['eng'] } },
|
||||||
|
{ name: 'the toggle is off', overrides: { enabled: false } },
|
||||||
|
{ name: 'media is remote', overrides: { mediaPath: 'https://example.com/episode-01.mkv' } },
|
||||||
|
{ name: 'the track list cannot be read', overrides: { trackListError: true } },
|
||||||
|
{ name: 'mpv returns no track list', overrides: { trackList: null } },
|
||||||
|
{
|
||||||
|
name: 'the file is audio with cover art',
|
||||||
|
overrides: {
|
||||||
|
trackList: [
|
||||||
|
{ type: 'audio', id: 1, selected: true },
|
||||||
|
{ type: 'video', id: 1, albumart: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
test(`jimaku auto-open does nothing when ${c.name}`, async () => {
|
||||||
|
const h = makeHarness(c.overrides);
|
||||||
|
|
||||||
|
await h.load();
|
||||||
|
|
||||||
|
assert.deepEqual(h.events, []);
|
||||||
|
assert.equal(h.runtime.isHoldingPlayback(), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('jimaku auto-open checks each media load once and skips media that changed before the check', async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
|
||||||
|
h.runtime.handleMediaPathChange('/videos/episode-01.mkv');
|
||||||
|
await h.load('/videos/episode-02.mkv');
|
||||||
|
await h.load('/videos/episode-02.mkv');
|
||||||
|
|
||||||
|
assert.equal(h.count('open'), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closing the auto-opened modal ends the hold and undoes its pause', async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
await h.load();
|
||||||
|
|
||||||
|
h.runtime.handleJimakuModalClosed();
|
||||||
|
|
||||||
|
assert.equal(h.runtime.isHoldingPlayback(), false);
|
||||||
|
assert.equal(h.count('end-hold'), 1);
|
||||||
|
assert.equal(h.state.paused, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pause that was already there is left to autoplay readiness when the modal closes', async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.state.paused = true; // e.g. the plugin's pause-until-ready gate, or the user
|
||||||
|
|
||||||
|
await h.load();
|
||||||
|
assert.equal(h.count('pause'), 0);
|
||||||
|
assert.equal(h.runtime.isHoldingPlayback(), true);
|
||||||
|
|
||||||
|
h.runtime.handleJimakuModalClosed();
|
||||||
|
assert.equal(h.count('end-hold'), 1);
|
||||||
|
assert.equal(h.state.paused, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing media ends the hold without resuming the new media', async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
await h.load();
|
||||||
|
|
||||||
|
h.state.trackList = japaneseTrackList;
|
||||||
|
await h.load('/videos/episode-02.mkv');
|
||||||
|
|
||||||
|
assert.equal(h.runtime.isHoldingPlayback(), false);
|
||||||
|
assert.equal(h.count('resume'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const c of [
|
||||||
|
{ name: 'resolves false', openJimaku: () => Promise.resolve(false) },
|
||||||
|
{ name: 'rejects', openJimaku: () => Promise.reject(new Error('overlay gone')) },
|
||||||
|
]) {
|
||||||
|
test(`when opening ${c.name}, jimaku auto-open undoes its own pause and leaves the plugin gate alone`, async () => {
|
||||||
|
const h = makeHarness({ openJimaku: c.openJimaku });
|
||||||
|
|
||||||
|
await h.load();
|
||||||
|
|
||||||
|
assert.equal(h.runtime.isHoldingPlayback(), false);
|
||||||
|
assert.equal(h.state.paused, false);
|
||||||
|
assert.equal(h.count('plugin-hold'), 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a failed open keeps a pause that was already there', async () => {
|
||||||
|
const h = makeHarness({ openJimaku: () => Promise.resolve(false) });
|
||||||
|
h.state.paused = true;
|
||||||
|
|
||||||
|
await h.load();
|
||||||
|
|
||||||
|
assert.equal(h.count('resume'), 0);
|
||||||
|
assert.equal(h.state.paused, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a modal that acknowledges after the open timed out re-applies the hold', async () => {
|
||||||
|
const h = makeHarness({ openJimaku: () => Promise.resolve(false) });
|
||||||
|
await h.load();
|
||||||
|
assert.equal(h.state.paused, false);
|
||||||
|
|
||||||
|
h.runtime.handleJimakuModalOpened();
|
||||||
|
|
||||||
|
assert.equal(h.runtime.isHoldingPlayback(), true);
|
||||||
|
assert.equal(h.state.paused, true);
|
||||||
|
assert.equal(h.count('plugin-hold'), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a Jimaku modal opened after the late-ack window is the user's own and does not hold", async () => {
|
||||||
|
const h = makeHarness({ openJimaku: () => Promise.resolve(false) });
|
||||||
|
await h.load();
|
||||||
|
await h.runScheduled(); // the late-ack window expires
|
||||||
|
|
||||||
|
h.runtime.handleJimakuModalOpened();
|
||||||
|
|
||||||
|
assert.equal(h.runtime.isHoldingPlayback(), false);
|
||||||
|
assert.equal(h.count('plugin-hold'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Jimaku modal opened without a pending auto-open does not hold playback', async () => {
|
||||||
|
const h = makeHarness({ trackList: japaneseTrackList });
|
||||||
|
await h.load();
|
||||||
|
|
||||||
|
h.runtime.handleJimakuModalOpened();
|
||||||
|
|
||||||
|
assert.equal(h.runtime.isHoldingPlayback(), false);
|
||||||
|
assert.deepEqual(h.events, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jimaku auto-open ignores a late open result for media that already changed', async () => {
|
||||||
|
const pendingOpens: Array<(opened: boolean) => void> = [];
|
||||||
|
const h = makeHarness({
|
||||||
|
openJimaku: () =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
pendingOpens.push(resolve);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await h.load();
|
||||||
|
await h.load('/videos/episode-02.mkv');
|
||||||
|
const resumesBefore = h.count('resume');
|
||||||
|
|
||||||
|
pendingOpens[0]?.(false);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
assert.equal(h.runtime.isHoldingPlayback(), true);
|
||||||
|
assert.equal(h.count('resume'), resumesBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a close reported before the auto-open is acknowledged belongs to the previous modal', async () => {
|
||||||
|
const pendingOpens: Array<(opened: boolean) => void> = [];
|
||||||
|
const h = makeHarness({
|
||||||
|
openJimaku: () =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
pendingOpens.push(resolve);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await h.load(); // open sent, not yet acknowledged
|
||||||
|
|
||||||
|
h.runtime.handleJimakuModalClosed();
|
||||||
|
assert.equal(h.runtime.isHoldingPlayback(), true);
|
||||||
|
assert.equal(h.count('end-hold'), 0);
|
||||||
|
|
||||||
|
h.runtime.handleJimakuModalOpened();
|
||||||
|
pendingOpens[0]?.(true);
|
||||||
|
await settle();
|
||||||
|
h.runtime.handleJimakuModalClosed();
|
||||||
|
assert.equal(h.runtime.isHoldingPlayback(), false);
|
||||||
|
assert.equal(h.state.paused, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import {
|
||||||
|
normalizeLocalMediaPath,
|
||||||
|
resolveManagedLocalSubtitleSelection,
|
||||||
|
} from './local-subtitle-selection';
|
||||||
|
|
||||||
|
// mpv lists sidecar and embedded subtitle tracks right after the file loads; the delay lets that
|
||||||
|
// track list settle before deciding nothing matches.
|
||||||
|
const DEFAULT_CHECK_DELAY_MS = 1000;
|
||||||
|
// How long after a failed open a late modal ack still counts as the auto-open showing up.
|
||||||
|
const LATE_OPEN_ACK_WINDOW_MS = 10_000;
|
||||||
|
|
||||||
|
// Audio files report cover art as a video track; only real video means there is something to watch.
|
||||||
|
function hasVideoTrack(trackList: unknown[]): boolean {
|
||||||
|
return trackList.some((entry) => {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const track = entry as { type?: unknown; albumart?: unknown; image?: unknown };
|
||||||
|
return track.type === 'video' && track.albumart !== true && track.image !== true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaybackHold = {
|
||||||
|
pausedByHold: boolean;
|
||||||
|
// Set once the renderer acknowledges this hold's open. The renderer reports closes and opens in
|
||||||
|
// order, so a close arriving before that acknowledgement belongs to an earlier modal session.
|
||||||
|
acknowledged: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the Jimaku modal for local media that has no subtitle track in the primary subtitle
|
||||||
|
* languages (Japanese by default), checking once per media load.
|
||||||
|
*
|
||||||
|
* While the auto-opened modal is up, playback is held: paused, and autoplay readiness is deferred
|
||||||
|
* instead of resuming it (`isHoldingPlayback`). Closing the modal, or failing to open it, ends the
|
||||||
|
* hold: deferred readiness is released and a pause this runtime added is undone, so playback
|
||||||
|
* resumes whether or not a subtitle was picked, but a pause made by someone else is kept. A modal
|
||||||
|
* that acknowledges shortly after a failed open re-applies the hold.
|
||||||
|
*/
|
||||||
|
export function createJimakuAutoOpenRuntime(deps: {
|
||||||
|
isEnabled: () => boolean;
|
||||||
|
getCurrentMediaPath: () => string | null;
|
||||||
|
getMpvClient: () => {
|
||||||
|
connected?: boolean;
|
||||||
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
|
} | null;
|
||||||
|
getPrimarySubtitleLanguages: () => string[];
|
||||||
|
isPlaybackPaused: () => boolean | null;
|
||||||
|
pausePlayback: () => void;
|
||||||
|
/** Releases readiness deferred during the hold, and unpauses when `undoPause` is set. */
|
||||||
|
endHold: (options: { undoPause: boolean }) => void;
|
||||||
|
/** Keeps the mpv plugin's pause-until-ready timeout from resuming playback. */
|
||||||
|
holdPluginAutoplayGate: () => void;
|
||||||
|
/** Resolves to whether the modal acknowledged opening. */
|
||||||
|
openJimaku: () => Promise<boolean>;
|
||||||
|
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||||
|
clearScheduled: (timer: ReturnType<typeof setTimeout>) => void;
|
||||||
|
delayMs?: number;
|
||||||
|
}) {
|
||||||
|
const delayMs = deps.delayMs ?? DEFAULT_CHECK_DELAY_MS;
|
||||||
|
let currentMediaPath: string | null = null;
|
||||||
|
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
// Media whose auto-opened modal has not closed yet; a late modal ack only counts for it.
|
||||||
|
let autoOpenedMediaPath: string | null = null;
|
||||||
|
// A fresh object per hold, so a late open failure only ends the hold it started.
|
||||||
|
let activeHold: PlaybackHold | null = null;
|
||||||
|
let lateAckTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const clearPendingTimer = (): void => {
|
||||||
|
if (!pendingTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.clearScheduled(pendingTimer);
|
||||||
|
pendingTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearLateAckTimer = (): void => {
|
||||||
|
if (!lateAckTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.clearScheduled(lateAckTimer);
|
||||||
|
lateAckTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const endHold = (hold: PlaybackHold): void => {
|
||||||
|
activeHold = null;
|
||||||
|
deps.endHold({ undoPause: hold.pausedByHold });
|
||||||
|
};
|
||||||
|
|
||||||
|
const startHold = (): PlaybackHold => {
|
||||||
|
const hold = { pausedByHold: deps.isPlaybackPaused() !== true, acknowledged: false };
|
||||||
|
activeHold = hold;
|
||||||
|
if (hold.pausedByHold) {
|
||||||
|
deps.pausePlayback();
|
||||||
|
}
|
||||||
|
return hold;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openForMedia = async (mediaPath: string): Promise<void> => {
|
||||||
|
autoOpenedMediaPath = mediaPath;
|
||||||
|
const hold = startHold();
|
||||||
|
const opened = await deps.openJimaku().catch(() => false);
|
||||||
|
if (activeHold !== hold) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (opened) {
|
||||||
|
hold.acknowledged = true;
|
||||||
|
// Only once the modal is up: if opening fails, the plugin's own timeout must still resume.
|
||||||
|
deps.holdPluginAutoplayGate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
endHold(hold);
|
||||||
|
// A modal that loads slowly can still show up; past this window an open is the user's own.
|
||||||
|
lateAckTimer = deps.schedule(() => {
|
||||||
|
lateAckTimer = null;
|
||||||
|
if (!activeHold) {
|
||||||
|
autoOpenedMediaPath = null;
|
||||||
|
}
|
||||||
|
}, LATE_OPEN_ACK_WINDOW_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkMediaPath = async (mediaPath: string): Promise<void> => {
|
||||||
|
const client = deps.getMpvClient();
|
||||||
|
if (!client?.connected || !client.requestProperty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let trackList: unknown;
|
||||||
|
try {
|
||||||
|
trackList = await client.requestProperty('track-list');
|
||||||
|
} catch {
|
||||||
|
// Never open the modal on an unreadable track list; a missing track is not proven.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
mediaPath !== currentMediaPath ||
|
||||||
|
normalizeLocalMediaPath(deps.getCurrentMediaPath()) !== mediaPath ||
|
||||||
|
!deps.isEnabled() ||
|
||||||
|
!Array.isArray(trackList) ||
|
||||||
|
!hasVideoTrack(trackList)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { hasPrimaryMatch } = resolveManagedLocalSubtitleSelection({
|
||||||
|
trackList,
|
||||||
|
primaryLanguages: deps.getPrimarySubtitleLanguages(),
|
||||||
|
secondaryLanguages: [],
|
||||||
|
});
|
||||||
|
if (!hasPrimaryMatch) {
|
||||||
|
await openForMedia(mediaPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleMediaPathChange: (mediaPath: string | null | undefined): void => {
|
||||||
|
const normalizedPath = normalizeLocalMediaPath(mediaPath);
|
||||||
|
if (normalizedPath === currentMediaPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearPendingTimer();
|
||||||
|
clearLateAckTimer();
|
||||||
|
// The gate drops readiness for the old media on its own; the new media is not paused by us.
|
||||||
|
activeHold = null;
|
||||||
|
autoOpenedMediaPath = null;
|
||||||
|
currentMediaPath = normalizedPath;
|
||||||
|
if (!normalizedPath || !deps.isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingTimer = deps.schedule(() => {
|
||||||
|
pendingTimer = null;
|
||||||
|
void checkMediaPath(normalizedPath);
|
||||||
|
}, delayMs);
|
||||||
|
},
|
||||||
|
/** The renderer acknowledged the Jimaku modal; re-holds when a timed-out auto-open shows up late. */
|
||||||
|
handleJimakuModalOpened: (): void => {
|
||||||
|
if (activeHold) {
|
||||||
|
activeHold.acknowledged = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!autoOpenedMediaPath || autoOpenedMediaPath !== currentMediaPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearLateAckTimer();
|
||||||
|
startHold().acknowledged = true;
|
||||||
|
deps.holdPluginAutoplayGate();
|
||||||
|
},
|
||||||
|
/** The Jimaku modal closed, or the window hosting it went away. */
|
||||||
|
handleJimakuModalClosed: (): void => {
|
||||||
|
if (activeHold && !activeHold.acknowledged) {
|
||||||
|
// A previous modal's close (e.g. the last episode's, reported just as this one opens).
|
||||||
|
// If this open never lands, its failure path ends the hold.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearLateAckTimer();
|
||||||
|
autoOpenedMediaPath = null;
|
||||||
|
if (activeHold) {
|
||||||
|
endHold(activeHold);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isHoldingPlayback: (): boolean => activeHold !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -174,7 +174,7 @@ export function resolveManagedLocalSubtitleSelection(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLocalMediaPath(mediaPath: string | null | undefined): string | null {
|
export function normalizeLocalMediaPath(mediaPath: string | null | undefined): string | null {
|
||||||
if (typeof mediaPath !== 'string') {
|
if (typeof mediaPath !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -573,3 +573,166 @@ test('media info arriving after the modal closed does not fill inputs or search'
|
|||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type MediaInfoFixture = { filename: string; season: number; episode: number };
|
||||||
|
|
||||||
|
const mediaInfo = (info: MediaInfoFixture) => ({
|
||||||
|
...info,
|
||||||
|
title: 'Show',
|
||||||
|
confidence: 'medium',
|
||||||
|
rawTitle: info.filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A Jimaku modal whose media-info replies are either immediate (`current`) or held until a test
|
||||||
|
// resolves them (`deferReplies`), to drive re-open and reply-ordering races.
|
||||||
|
async function withReopenHarness(
|
||||||
|
run: (h: {
|
||||||
|
modal: ReturnType<typeof createJimakuModal>;
|
||||||
|
episodeInput: { value: string };
|
||||||
|
setCurrent: (info: MediaInfoFixture) => void;
|
||||||
|
deferReplies: () => void;
|
||||||
|
pendingReplies: Array<(info: MediaInfoFixture) => void>;
|
||||||
|
pressEnter: () => void;
|
||||||
|
}) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
const previousDocument = globals.document;
|
||||||
|
|
||||||
|
let current = mediaInfo({ filename: '01.mkv', season: 1, episode: 1 });
|
||||||
|
let deferred = false;
|
||||||
|
const pendingReplies: Array<(info: MediaInfoFixture) => void> = [];
|
||||||
|
const electronAPI = {
|
||||||
|
getJimakuMediaInfo: () =>
|
||||||
|
deferred
|
||||||
|
? new Promise((resolve) => pendingReplies.push((info) => resolve(mediaInfo(info))))
|
||||||
|
: Promise.resolve(current),
|
||||||
|
jimakuSearchEntries: async () => ({ ok: true, data: [] }),
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
} as unknown as ElectronAPI;
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: { electronAPI } });
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: { activeElement: null, createElement: () => createElementStub() },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const episodeInput = { value: '' };
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: createClassList() },
|
||||||
|
jimakuModal: { classList: createClassList(['hidden']), setAttribute: () => {} },
|
||||||
|
jimakuTitleInput: { value: '' },
|
||||||
|
jimakuSeasonInput: { value: '' },
|
||||||
|
jimakuEpisodeInput: episodeInput,
|
||||||
|
jimakuSearchButton: { addEventListener: () => {} },
|
||||||
|
jimakuCloseButton: { addEventListener: () => {} },
|
||||||
|
jimakuStatus: { textContent: '', style: { color: '' } },
|
||||||
|
jimakuEntriesSection: { classList: createClassList(['hidden']) },
|
||||||
|
jimakuEntriesList: createListStub(),
|
||||||
|
jimakuFilesSection: { classList: createClassList(['hidden']) },
|
||||||
|
jimakuFilesList: createListStub(),
|
||||||
|
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
|
||||||
|
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
|
||||||
|
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
|
||||||
|
},
|
||||||
|
state: createRendererState(),
|
||||||
|
};
|
||||||
|
const modal = createJimakuModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await run({
|
||||||
|
modal,
|
||||||
|
episodeInput,
|
||||||
|
setCurrent: (info) => {
|
||||||
|
current = mediaInfo(info);
|
||||||
|
},
|
||||||
|
deferReplies: () => {
|
||||||
|
deferred = true;
|
||||||
|
},
|
||||||
|
pendingReplies,
|
||||||
|
// Enter with no results and no focused input starts a search with the current inputs.
|
||||||
|
pressEnter: () => {
|
||||||
|
modal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of [
|
||||||
|
{
|
||||||
|
name: 'reloads media info when the video changed',
|
||||||
|
next: { filename: '02.mkv', season: 1, episode: 2 },
|
||||||
|
reloads: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'reloads media info for the same file name in another season',
|
||||||
|
next: { filename: '01.mkv', season: 2, episode: 1 },
|
||||||
|
reloads: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'keeps the current search for the same video',
|
||||||
|
next: { filename: '01.mkv', season: 1, episode: 1 },
|
||||||
|
reloads: false,
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
test(`re-opening the open Jimaku modal ${c.name}`, async () => {
|
||||||
|
await withReopenHarness(async (h) => {
|
||||||
|
h.modal.openJimakuModal();
|
||||||
|
await flushAsyncWork();
|
||||||
|
h.episodeInput.value = '7'; // the user's own edit
|
||||||
|
h.setCurrent(c.next);
|
||||||
|
h.modal.openJimakuModal();
|
||||||
|
await flushAsyncWork();
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
assert.equal(h.episodeInput.value, c.reloads ? String(c.next.episode) : '7');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a search started during a changed-video refresh does not cancel the refresh', async () => {
|
||||||
|
await withReopenHarness(async (h) => {
|
||||||
|
h.modal.openJimakuModal();
|
||||||
|
await flushAsyncWork();
|
||||||
|
h.deferReplies();
|
||||||
|
|
||||||
|
h.modal.openJimakuModal(); // refresh request now pending
|
||||||
|
h.pressEnter();
|
||||||
|
await flushAsyncWork();
|
||||||
|
h.pendingReplies.shift()?.({ filename: '02.mkv', season: 1, episode: 2 });
|
||||||
|
await flushAsyncWork();
|
||||||
|
h.pendingReplies.shift()?.({ filename: '02.mkv', season: 1, episode: 2 }); // the reload
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
assert.equal(h.episodeInput.value, '2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale media-info reply from an earlier open does not make the current video look changed', async () => {
|
||||||
|
await withReopenHarness(async (h) => {
|
||||||
|
h.deferReplies();
|
||||||
|
h.modal.openJimakuModal(); // load for episode 1 stays pending
|
||||||
|
h.modal.closeJimakuModal();
|
||||||
|
h.modal.openJimakuModal(); // load for episode 2
|
||||||
|
h.pendingReplies[1]?.({ filename: '02.mkv', season: 1, episode: 2 });
|
||||||
|
await flushAsyncWork();
|
||||||
|
h.pendingReplies[0]?.({ filename: '01.mkv', season: 1, episode: 1 }); // stale
|
||||||
|
await flushAsyncWork();
|
||||||
|
h.episodeInput.value = '7'; // the user's own edit
|
||||||
|
|
||||||
|
h.modal.openJimakuModal(); // same video: must keep the user's search
|
||||||
|
h.pendingReplies[2]?.({ filename: '02.mkv', season: 1, episode: 2 });
|
||||||
|
await flushAsyncWork();
|
||||||
|
h.pendingReplies[3]?.({ filename: '02.mkv', season: 1, episode: 2 }); // a reload, if any
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
assert.equal(h.pendingReplies.length, 3);
|
||||||
|
assert.equal(h.episodeInput.value, '7');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -294,15 +294,46 @@ export function createJimakuModal(
|
|||||||
return tag === 'input' || tag === 'textarea';
|
return tag === 'input' || tag === 'textarea';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Identity of the video this modal session was opened for, so a re-open can tell whether the
|
||||||
|
// video changed. The file name alone repeats across seasons (`Season 2/01.mkv`).
|
||||||
|
let loadedMediaKey: string | null = null;
|
||||||
|
const mediaKey = (info: JimakuMediaInfo): string =>
|
||||||
|
JSON.stringify([info.filename, info.title, info.season, info.episode]);
|
||||||
|
// Media-info replies are tracked apart from searchGeneration: a search the user starts must not
|
||||||
|
// cancel a changed-video refresh or let an older load overwrite loadedMediaKey.
|
||||||
|
let modalSession = 0; // bumped on open and close
|
||||||
|
let mediaLoadToken = 0; // bumped per media-info load; only the latest load records its key
|
||||||
|
|
||||||
function openJimakuModal(): void {
|
function openJimakuModal(): void {
|
||||||
if (ctx.state.jimakuModalOpen) return;
|
if (ctx.state.jimakuModalOpen) {
|
||||||
|
refreshMediaInfoIfChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.state.jimakuModalOpen = true;
|
ctx.state.jimakuModalOpen = true;
|
||||||
options.syncSettingsModalSubtitleSuppression();
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
ctx.dom.overlay.classList.add('interactive');
|
ctx.dom.overlay.classList.add('interactive');
|
||||||
ctx.dom.jimakuModal.classList.remove('hidden');
|
ctx.dom.jimakuModal.classList.remove('hidden');
|
||||||
ctx.dom.jimakuModal.setAttribute('aria-hidden', 'false');
|
ctx.dom.jimakuModal.setAttribute('aria-hidden', 'false');
|
||||||
|
modalSession += 1;
|
||||||
|
loadMediaInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
// An open request while already open (e.g. the next episode auto-opening Jimaku) must not
|
||||||
|
// leave the previous episode's search on screen. The same video keeps the user's search.
|
||||||
|
function refreshMediaInfoIfChanged(): void {
|
||||||
|
const session = modalSession;
|
||||||
|
window.electronAPI
|
||||||
|
.getJimakuMediaInfo()
|
||||||
|
.then((info: JimakuMediaInfo) => {
|
||||||
|
if (session !== modalSession || !ctx.state.jimakuModalOpen) return;
|
||||||
|
if (mediaKey(info) === loadedMediaKey) return;
|
||||||
|
loadMediaInfo();
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMediaInfo(): void {
|
||||||
setJimakuStatus('Loading media info...');
|
setJimakuStatus('Loading media info...');
|
||||||
resetJimakuLists();
|
resetJimakuLists();
|
||||||
renderTabs();
|
renderTabs();
|
||||||
@@ -310,9 +341,13 @@ export function createJimakuModal(
|
|||||||
// Media info can resolve after the user already closed the modal or
|
// Media info can resolve after the user already closed the modal or
|
||||||
// started their own search; a stale reply must not touch the inputs.
|
// started their own search; a stale reply must not touch the inputs.
|
||||||
const generation = searchGeneration;
|
const generation = searchGeneration;
|
||||||
|
const loadToken = ++mediaLoadToken;
|
||||||
window.electronAPI
|
window.electronAPI
|
||||||
.getJimakuMediaInfo()
|
.getJimakuMediaInfo()
|
||||||
.then((info: JimakuMediaInfo) => {
|
.then((info: JimakuMediaInfo) => {
|
||||||
|
if (loadToken !== mediaLoadToken) return;
|
||||||
|
// Recorded even when the user already started their own search, which skips the fill.
|
||||||
|
loadedMediaKey = mediaKey(info);
|
||||||
if (generation !== searchGeneration) return;
|
if (generation !== searchGeneration) return;
|
||||||
ctx.dom.jimakuTitleInput.value = info.title || '';
|
ctx.dom.jimakuTitleInput.value = info.title || '';
|
||||||
ctx.dom.jimakuSeasonInput.value = info.season ? String(info.season) : '';
|
ctx.dom.jimakuSeasonInput.value = info.season ? String(info.season) : '';
|
||||||
@@ -337,6 +372,7 @@ export function createJimakuModal(
|
|||||||
if (!ctx.state.jimakuModalOpen) return;
|
if (!ctx.state.jimakuModalOpen) return;
|
||||||
|
|
||||||
ctx.state.jimakuModalOpen = false;
|
ctx.state.jimakuModalOpen = false;
|
||||||
|
modalSession += 1;
|
||||||
options.syncSettingsModalSubtitleSuppression();
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
ctx.dom.jimakuModal.classList.add('hidden');
|
ctx.dom.jimakuModal.classList.add('hidden');
|
||||||
ctx.dom.jimakuModal.setAttribute('aria-hidden', 'true');
|
ctx.dom.jimakuModal.setAttribute('aria-hidden', 'true');
|
||||||
|
|||||||
@@ -353,6 +353,7 @@ export interface ResolvedConfig {
|
|||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
languagePreference: JimakuLanguagePreference;
|
languagePreference: JimakuLanguagePreference;
|
||||||
maxEntryResults: number;
|
maxEntryResults: number;
|
||||||
|
autoOpenWhenPrimarySubtitleMissing: boolean;
|
||||||
};
|
};
|
||||||
tsukihime: TsukihimeConfig & {
|
tsukihime: TsukihimeConfig & {
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface JimakuConfig {
|
|||||||
apiBaseUrl?: string;
|
apiBaseUrl?: string;
|
||||||
languagePreference?: JimakuLanguagePreference;
|
languagePreference?: JimakuLanguagePreference;
|
||||||
maxEntryResults?: number;
|
maxEntryResults?: number;
|
||||||
|
autoOpenWhenPrimarySubtitleMissing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AnilistCharacterDictionaryProfileScope = 'all' | 'active';
|
export type AnilistCharacterDictionaryProfileScope = 'all' | 'active';
|
||||||
|
|||||||
Reference in new issue
Block a user