diff --git a/changes/jimaku-auto-open-missing-subtitle.md b/changes/jimaku-auto-open-missing-subtitle.md new file mode 100644 index 00000000..42a7e9cd --- /dev/null +++ b/changes/jimaku-auto-open-missing-subtitle.md @@ -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. diff --git a/config.example.jsonc b/config.example.jsonc index 5a3999e3..067e6de1 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -992,7 +992,8 @@ "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. "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. // ========================================== diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 936aabf6..bd0693c9 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -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). -| Key | Default | What it does | -| --------------------------- | --------------------- | ---------------------------------------------------------- | -| `jimaku.apiKey` | `""` | API key. Optional, but raises your rate limit | -| `jimaku.apiKeyCommand` | `""` | Shell command that prints the key. Use instead of `apiKey` | -| `jimaku.apiBaseUrl` | `"https://jimaku.cc"` | API base URL | -| `jimaku.languagePreference` | `"ja"` | Preferred language: `ja`, `en`, or `none` | -| `jimaku.maxEntryResults` | `10` | Maximum search results | +| Key | Default | What it does | +| ------------------------------------------- | --------------------- | --------------------------------------------------------------------------------- | +| `jimaku.apiKey` | `""` | API key. Optional, but raises your rate limit | +| `jimaku.apiKeyCommand` | `""` | Shell command that prints the key. Use instead of `apiKey` | +| `jimaku.apiBaseUrl` | `"https://jimaku.cc"` | API base URL | +| `jimaku.languagePreference` | `"ja"` | Preferred language: `ja`, `en`, or `none` | +| `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 diff --git a/docs-site/jimaku-integration.md b/docs-site/jimaku-integration.md index 51af5f7a..03c2acda 100644 --- a/docs-site/jimaku-integration.md +++ b/docs-site/jimaku-integration.md @@ -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`. +### 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)`. ## Options -| Key | What it does | -| --------------------------- | ------------------------------------------------------------------- | -| `jimaku.apiKey` | API key in plain text. | -| `jimaku.apiKeyCommand` | Shell command that prints the API key. | -| `jimaku.languagePreference` | Sorts files tagged with this language first: `ja`, `en`, or `none`. | -| `jimaku.maxEntryResults` | Maximum entries per search. | -| `jimaku.apiBaseUrl` | API address. Change only for a mirror. | +| Key | What it does | +| ------------------------------------------- | -------------------------------------------------------------------- | +| `jimaku.apiKey` | API key in plain text. | +| `jimaku.apiKeyCommand` | Shell command that prints the API key. | +| `jimaku.languagePreference` | Sorts files tagged with this language first: `ja`, `en`, or `none`. | +| `jimaku.maxEntryResults` | Maximum entries per search. | +| `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. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 5a3999e3..067e6de1 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -992,7 +992,8 @@ "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. "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. // ========================================== diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 5d60cef5..9cded309 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -108,6 +108,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< apiKeyCommand: '', languagePreference: 'ja', maxEntryResults: 10, + autoOpenWhenPrimarySubtitleMissing: false, }, tsukihime: { apiBaseUrl: 'https://api.tsukihime.org/v1', diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index d0840ba9..43312262 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -467,6 +467,13 @@ export function buildIntegrationConfigOptionRegistry( defaultValue: defaultConfig.jimaku.maxEntryResults, 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', kind: 'string', diff --git a/src/config/resolve/integrations.test.ts b/src/config/resolve/integrations.test.ts index 377e4e78..1deb1f63 100644 --- a/src/config/resolve/integrations.test.ts +++ b/src/config/resolve/integrations.test.ts @@ -57,3 +57,18 @@ test('resolveConfig warns for invalid mpv launch mode', () => { 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'], + ); +}); diff --git a/src/config/resolve/subtitle-domains.ts b/src/config/resolve/subtitle-domains.ts index 0c92e882..1148d648 100644 --- a/src/config/resolve/subtitle-domains.ts +++ b/src/config/resolve/subtitle-domains.ts @@ -90,6 +90,18 @@ export function applySubtitleDomainConfig(context: ResolveContext): void { '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; diff --git a/src/main.ts b/src/main.ts index 13d54621..7257c908 100644 --- a/src/main.ts +++ b/src/main.ts @@ -188,6 +188,7 @@ import { } from './main/runtime/autoplay-ready-gate'; import { isVisibleOverlayAutoplayTargetReady } from './main/runtime/visible-overlay-autoplay-readiness'; 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 { refreshManagedCommandLineLauncher } from './main/runtime/command-line-launcher'; import { applyLoginShellPath } from './main/runtime/login-shell-path'; @@ -739,6 +740,8 @@ const autoplayReadyGate = createAutoplayReadyGate({ }, // Deferred: isTokenizationWarmupReady is assigned during composeMpvRuntimeHandlers below. isTokenizationReady: () => isTokenizationWarmupReady(), + // Deferred: jimakuAutoOpenRuntime is created right after this gate. + isPlaybackHeld: () => jimakuAutoOpenRuntime.isHoldingPlayback(), requestOverlayPointerRecovery: () => { if (process.platform !== 'darwin' || !overlayManager.getVisibleOverlayVisible()) { return; @@ -785,6 +788,29 @@ const managedLocalSubtitleSelectionRuntime = createManagedLocalSubtitleSelection schedule: (callback, delayMs) => setTimeout(callback, delayMs), 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 { resolveBundledMpvRuntimePluginEntrypoint, @@ -1034,6 +1060,7 @@ const overlay = createOverlayAssembly({ getOverlayNotifications: () => overlayNotificationsRuntime, getOverlayVisibilityComposer: () => overlayVisibilityComposer, getMediaTimingReviewRuntime: () => mining.mediaTimingReviewRuntime, + getJimakuAutoOpenRuntime: () => jimakuAutoOpenRuntime, getSubtitleProcessingController: () => subtitleProcessingController, getAutoplaySubtitlePriming: () => autoplaySubtitlePrimingRuntime, ensureOverlayMpvSubtitlesHidden: () => ensureOverlayMpvSubtitlesHidden(), @@ -1768,6 +1795,7 @@ const playback = createPlaybackAssembly({ currentMediaTokenizationGate, startupOsdSequencer, managedLocalSubtitleSelectionRuntime, + jimakuAutoOpenRuntime, mediaTimingReviewRuntime: mining.mediaTimingReviewRuntime, discordPresenceRuntime, immersionMediaRuntime: stats.immersionMediaRuntime, diff --git a/src/main/assemblies/overlay-modals.ts b/src/main/assemblies/overlay-modals.ts index bd1b5bbf..59078232 100644 --- a/src/main/assemblies/overlay-modals.ts +++ b/src/main/assemblies/overlay-modals.ts @@ -69,20 +69,22 @@ export function createOverlayModalsAssembly(deps: OverlayModalsAssemblyDeps) { openModal: (deps: ReturnType) => Promise, unavailableMessage: string, failureLogMessage: string, - ): void { - void openModal(createOverlayHostedModalOpenDeps()) + ): Promise { + return openModal(createOverlayHostedModalOpenDeps()) .then((opened) => { if (!opened) { deps.getOverlayNotifications().showConfiguredStatusNotification(unavailableMessage, { variant: 'warning', }); } + return opened; }) .catch((error) => { logger.error(failureLogMessage, error); deps.getOverlayNotifications().showConfiguredStatusNotification(unavailableMessage, { variant: 'error', }); + return false; }); } @@ -94,8 +96,9 @@ export function createOverlayModalsAssembly(deps: OverlayModalsAssemblyDeps) { ); } - function openJimakuOverlay(): void { - openOverlayHostedModalWithOsd( + /** Resolves to whether the modal acknowledged opening; failures are already reported. */ + function openJimakuOverlay(): Promise { + return openOverlayHostedModalWithOsd( openJimakuModalRuntime, 'Jimaku overlay unavailable.', 'Failed to open Jimaku overlay.', diff --git a/src/main/assemblies/overlay.test.ts b/src/main/assemblies/overlay.test.ts index 76f8356d..d41e58b9 100644 --- a/src/main/assemblies/overlay.test.ts +++ b/src/main/assemblies/overlay.test.ts @@ -159,6 +159,10 @@ function makeDeps(recorder: Recorder, state: FixtureState) { getMediaTimingReviewRuntime: spy('getMediaTimingReviewRuntime', () => ({ dispose: spy('mediaTimingReview.dispose', resolved), })), + getJimakuAutoOpenRuntime: spy('getJimakuAutoOpenRuntime', () => ({ + handleJimakuModalOpened: spy('jimakuAutoOpen.handleJimakuModalOpened', noop), + handleJimakuModalClosed: spy('jimakuAutoOpen.handleJimakuModalClosed', noop), + })), getSubtitleProcessingController: spy('getSubtitleProcessingController', () => ({ refreshCurrentSubtitle: spy('subtitle.refreshCurrent', () => true), })), diff --git a/src/main/assemblies/overlay.ts b/src/main/assemblies/overlay.ts index 86e78594..5cb9db9e 100644 --- a/src/main/assemblies/overlay.ts +++ b/src/main/assemblies/overlay.ts @@ -25,6 +25,7 @@ import { createLinuxOverlayModeRuntime } from '../runtime/linux-overlay-mode-run import { shouldRunLinuxOverlayZOrderKeepAlive } from '../runtime/linux-overlay-zorder-keepalive'; import { focusMacOSOverlayWindow } from '../runtime/macos-overlay-window-focus'; 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 { createOverlayGeometryRuntime } from '../runtime/overlay-geometry-runtime'; import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime'; @@ -125,6 +126,10 @@ export interface OverlayAssemblyDeps { ReturnType, 'dispose' >; + getJimakuAutoOpenRuntime: () => Pick< + ReturnType, + 'handleJimakuModalOpened' | 'handleJimakuModalClosed' + >; getSubtitleProcessingController: () => Pick< PlaybackAssembly['subtitleProcessingController'], 'refreshCurrentSubtitle' @@ -331,6 +336,8 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) { return; } void deps.getMediaTimingReviewRuntime().dispose(); + // A destroyed modal window never reports its modals closed. + deps.getJimakuAutoOpenRuntime().handleJimakuModalClosed(); overlayManager.setModalWindow(null); } }, @@ -506,6 +513,9 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) { if (modal === 'media-timing-review') { void deps.getMediaTimingReviewRuntime().dispose(); } + if (modal === 'jimaku') { + deps.getJimakuAutoOpenRuntime().handleJimakuModalClosed(); + } if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) { deps.setSubtitleSidebarRequestedOpen(false); } @@ -525,6 +535,9 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) { if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) { deps.setSubtitleSidebarRequestedOpen(true); } + if (modal === 'jimaku') { + deps.getJimakuAutoOpenRuntime().handleJimakuModalOpened(); + } deps.overlayModalRuntime.notifyOverlayModalOpened(modal); }, onOverlayMouseInteractionChanged: (active, senderWindow) => { diff --git a/src/main/assemblies/playback.test.ts b/src/main/assemblies/playback.test.ts index 9287da3b..a6051f94 100644 --- a/src/main/assemblies/playback.test.ts +++ b/src/main/assemblies/playback.test.ts @@ -76,6 +76,9 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps { handleMediaPathChange: spy('localSubtitles.handleMediaPathChange', noop), handleSubtitleTrackListChange: spy('localSubtitles.handleSubtitleTrackListChange', noop), }, + jimakuAutoOpenRuntime: { + handleMediaPathChange: spy('jimakuAutoOpen.handleMediaPathChange', noop), + }, mediaTimingReviewRuntime: { deferPlaybackResume: spy('timingReview.deferPlaybackResume', () => false), cancelPlaybackResume: spy('timingReview.cancelPlaybackResume', noop), diff --git a/src/main/assemblies/playback.ts b/src/main/assemblies/playback.ts index e13138e3..ae2a52e4 100644 --- a/src/main/assemblies/playback.ts +++ b/src/main/assemblies/playback.ts @@ -61,6 +61,7 @@ import type { createDiscordPresenceRuntime } from '../runtime/discord-presence-r import type { createImmersionMediaRuntime } from '../runtime/domains/startup'; import type { loadSubtitleSourceText } from '../runtime/internal-subtitle-extraction'; 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 { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime'; import type { createStartupOsdSequencer } from '../runtime/startup-osd-sequencer'; @@ -122,6 +123,10 @@ export interface PlaybackAssemblyDeps { ReturnType, 'handleMediaPathChange' | 'handleSubtitleTrackListChange' >; + jimakuAutoOpenRuntime: Pick< + ReturnType, + 'handleMediaPathChange' + >; mediaTimingReviewRuntime: Pick< ReturnType, 'deferPlaybackResume' | 'cancelPlaybackResume' @@ -240,6 +245,7 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) { currentMediaTokenizationGate, startupOsdSequencer, managedLocalSubtitleSelectionRuntime, + jimakuAutoOpenRuntime, mediaTimingReviewRuntime, discordPresenceRuntime, immersionMediaRuntime, @@ -683,6 +689,7 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) { } currentMediaTokenizationGate.updateCurrentMediaPath(path); managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path); + jimakuAutoOpenRuntime.handleMediaPathChange(path); startupOsdSequencer.reset(); subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh(); if (!preserveParsedSubtitleCues) { diff --git a/src/main/runtime/autoplay-ready-gate.test.ts b/src/main/runtime/autoplay-ready-gate.test.ts index acf997a6..953370c7 100644 --- a/src/main/runtime/autoplay-ready-gate.test.ts +++ b/src/main/runtime/autoplay-ready-gate.test.ts @@ -260,3 +260,69 @@ test('autoplay ready gate releases tokenized signals while tokenization warmup i 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((resolve) => { + resolvePauseRead = resolve; + }), + send: ({ command }) => { + commands.push(command); + }, + }), + }); + + await h.signal('字幕'); + held = true; + resolvePauseRead(true); + await settle(); + + assert.equal(commands.filter(isUnpause).length, 0); +}); diff --git a/src/main/runtime/autoplay-ready-gate.ts b/src/main/runtime/autoplay-ready-gate.ts index 704c8f90..e3cdbb1e 100644 --- a/src/main/runtime/autoplay-ready-gate.ts +++ b/src/main/runtime/autoplay-ready-gate.ts @@ -27,6 +27,8 @@ export type AutoplayReadyGateDeps = { getMpvClient: () => MpvClientLike | null; signalPluginAutoplayReady: () => void; isTokenizationReady?: () => boolean; + /** True while another feature keeps playback paused on purpose; readiness must not resume it. */ + isPlaybackHeld?: () => boolean; requestOverlayPointerRecovery?: () => void; onAutoplayReadyReleased?: (signal: AutoplayReadySignal) => void; isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean; @@ -169,6 +171,13 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { if (!shouldUnpause) { 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] }); releaseUnpauseSent = true; @@ -193,6 +202,16 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { pendingAutoplayReadySignal = null; 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)) { const pendingSignalChanged = setPendingAutoplayReadySignal(signal); schedulePendingAutoplayReadyRetry(); diff --git a/src/main/runtime/jimaku-auto-open.test.ts b/src/main/runtime/jimaku-auto-open.test.ts new file mode 100644 index 00000000..9fb2c0b2 --- /dev/null +++ b/src/main/runtime/jimaku-auto-open.test.ts @@ -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; + } = {}, +) { + 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 => { + 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 => { + 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((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((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); +}); diff --git a/src/main/runtime/jimaku-auto-open.ts b/src/main/runtime/jimaku-auto-open.ts new file mode 100644 index 00000000..c32c9817 --- /dev/null +++ b/src/main/runtime/jimaku-auto-open.ts @@ -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; + } | 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; + schedule: (callback: () => void, delayMs: number) => ReturnType; + clearScheduled: (timer: ReturnType) => void; + delayMs?: number; +}) { + const delayMs = deps.delayMs ?? DEFAULT_CHECK_DELAY_MS; + let currentMediaPath: string | null = null; + let pendingTimer: ReturnType | 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 | 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 => { + 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 => { + 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, + }; +} diff --git a/src/main/runtime/local-subtitle-selection.ts b/src/main/runtime/local-subtitle-selection.ts index 53e389ef..42d10d43 100644 --- a/src/main/runtime/local-subtitle-selection.ts +++ b/src/main/runtime/local-subtitle-selection.ts @@ -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') { return null; } diff --git a/src/renderer/modals/jimaku.test.ts b/src/renderer/modals/jimaku.test.ts index e8e59ed7..4dc1a727 100644 --- a/src/renderer/modals/jimaku.test.ts +++ b/src/renderer/modals/jimaku.test.ts @@ -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 }); } }); + +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; + episodeInput: { value: string }; + setCurrent: (info: MediaInfoFixture) => void; + deferReplies: () => void; + pendingReplies: Array<(info: MediaInfoFixture) => void>; + pressEnter: () => void; + }) => Promise, +): Promise { + 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'); + }); +}); diff --git a/src/renderer/modals/jimaku.ts b/src/renderer/modals/jimaku.ts index 086baf92..552901f3 100644 --- a/src/renderer/modals/jimaku.ts +++ b/src/renderer/modals/jimaku.ts @@ -294,15 +294,46 @@ export function createJimakuModal( 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 { - if (ctx.state.jimakuModalOpen) return; + if (ctx.state.jimakuModalOpen) { + refreshMediaInfoIfChanged(); + return; + } ctx.state.jimakuModalOpen = true; options.syncSettingsModalSubtitleSuppression(); ctx.dom.overlay.classList.add('interactive'); ctx.dom.jimakuModal.classList.remove('hidden'); 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...'); resetJimakuLists(); renderTabs(); @@ -310,9 +341,13 @@ export function createJimakuModal( // Media info can resolve after the user already closed the modal or // started their own search; a stale reply must not touch the inputs. const generation = searchGeneration; + const loadToken = ++mediaLoadToken; window.electronAPI .getJimakuMediaInfo() .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; ctx.dom.jimakuTitleInput.value = info.title || ''; ctx.dom.jimakuSeasonInput.value = info.season ? String(info.season) : ''; @@ -337,6 +372,7 @@ export function createJimakuModal( if (!ctx.state.jimakuModalOpen) return; ctx.state.jimakuModalOpen = false; + modalSession += 1; options.syncSettingsModalSubtitleSuppression(); ctx.dom.jimakuModal.classList.add('hidden'); ctx.dom.jimakuModal.setAttribute('aria-hidden', 'true'); diff --git a/src/types/config.ts b/src/types/config.ts index 419d6dd1..9b9ec482 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -353,6 +353,7 @@ export interface ResolvedConfig { apiBaseUrl: string; languagePreference: JimakuLanguagePreference; maxEntryResults: number; + autoOpenWhenPrimarySubtitleMissing: boolean; }; tsukihime: TsukihimeConfig & { apiBaseUrl: string; diff --git a/src/types/integrations.ts b/src/types/integrations.ts index c028c974..c8db34dd 100644 --- a/src/types/integrations.ts +++ b/src/types/integrations.ts @@ -48,6 +48,7 @@ export interface JimakuConfig { apiBaseUrl?: string; languagePreference?: JimakuLanguagePreference; maxEntryResults?: number; + autoOpenWhenPrimarySubtitleMissing?: boolean; } export type AnilistCharacterDictionaryProfileScope = 'all' | 'active';