Compare commits

...
3 Commits
Author SHA1 Message Date
sudacode 5f635f3042 fix(e2e): disable mpv direct rendering on macOS to avoid loadfile hang
- Pass `--vd-lavc-dr=no` alongside `--vo=libmpv` on darwin
- cocoa-cb answers DR image requests only from its main queue, which can stall on CI runners and freeze mpv's core and IPC with no timeout
2026-11-03 23:49:06 -08:00
sudacode 7c637149dd fix(controller): keep popup pause and press handling reliable (#324) 2026-11-03 23:13:46 -08:00
33f5f890d5 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>
2026-11-03 23:12:46 -08:00
33 changed files with 1124 additions and 52 deletions

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.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: overlay
- On macOS and Windows, one controller press no longer runs its action twice, which cancelled out toggles like mpv pause.
- On macOS, the press that brings the overlay forward from mpv no longer runs its action a second time.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed controller and keyboard lookups sometimes pausing playback without a popup, leaving playback paused after the popup closed until the mouse moved, or not pausing when the popup opened.
+2 -1
View File
@@ -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.
// ==========================================
+8 -7
View File
@@ -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
+22 -7
View File
@@ -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.
+2 -1
View File
@@ -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.
// ==========================================
+4 -1
View File
@@ -21,7 +21,10 @@ function videoOutputArgs(display: E2eDisplay): string[] {
// gpu-next's Vulkan path (libplacebo over MoltenVK) segfaults on the first frame
// on GitHub's paravirtual macOS GPU with libplacebo 7.387. `--vo=libmpv` is mpv's
// cocoa-cb OpenGL output, which avoids it and still opens the window the overlay tracks.
if (process.platform === 'darwin') return ['--vo=libmpv'];
// Each new decoder asks that render thread for a direct-rendering image and waits with no
// timeout. cocoa-cb only answers from its main queue, which can stall on this runner, so a
// `loadfile` could freeze mpv's core and its IPC for good. `--vd-lavc-dr=no` skips the request.
if (process.platform === 'darwin') return ['--vo=libmpv', '--vd-lavc-dr=no'];
return [];
}
+59 -1
View File
@@ -15,7 +15,7 @@ let started: Promise<Started> | undefined;
function useSession(): Promise<Started> {
started ??= (async () => {
const session = await startE2eSession();
const session = await startE2eSession({ config: { controller: { enabled: true } } });
try {
return { session, overlay: await session.openPage(OVERLAY_PAGE) };
} catch (error) {
@@ -273,6 +273,64 @@ test(
},
);
// macOS and Windows keep a hidden modal renderer alive, and hidden windows keep polling because
// background throttling is off. A fake pad goes to every renderer, as a real pad would.
// Linux has no such window, and under Xvfb without a window manager the overlay reports hidden,
// so requestAnimationFrame and with it the controller poll never run. Bun's node:test ignores the
// `skip` option, so this picks `test.skip` instead.
const testOutsideLinux = process.platform === 'linux' ? test.skip : test;
testOutsideLinux(
'one controller press toggles mpv pause once across every window',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session } = await useSession();
const START_BUTTON = 9;
const targets = (await listCdpTargets(session.cdpPort)).filter((target) =>
target.url.includes('renderer/index.html'),
);
const renderers = await Promise.all(targets.map(connectCdpPage));
const everyRenderer = (expression: string) =>
Promise.all(renderers.map((renderer) => renderer.evaluate(expression)));
const nextPolls = `new Promise((done) =>
requestAnimationFrame(() => requestAnimationFrame(() => done(true))))`;
try {
await everyRenderer(`(() => {
window.__fakePadPressed = false;
Object.defineProperty(navigator, 'getGamepads', {
configurable: true,
value: () => [{
id: 'E2E Pad (STANDARD GAMEPAD)',
index: 0,
connected: true,
mapping: 'standard',
axes: [0, 0, 0, 0],
buttons: Array.from({ length: 17 }, (_, index) => {
const pressed = window.__fakePadPressed && index === ${START_BUTTON};
return { value: pressed ? 1 : 0, pressed, touched: pressed };
}),
}],
});
return true;
})()`);
await everyRenderer(nextPolls);
await session.mpv.command('set_property', 'pause', true);
await everyRenderer('window.__fakePadPressed = true');
await everyRenderer(nextPolls);
await everyRenderer('window.__fakePadPressed = false');
await everyRenderer(nextPolls);
// Long enough for a second `cycle pause` to reach mpv and cancel the first.
await new Promise((done) => setTimeout(done, 300));
assert.equal(await session.mpv.command('get_property', 'pause'), false);
} finally {
await everyRenderer('delete navigator.getGamepads');
renderers.forEach((renderer) => renderer.close());
await session.mpv.command('set_property', 'pause', true);
}
},
);
// Runs last: it leaves the lookup popup open over the overlay.
test(
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
@@ -108,6 +108,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
apiKeyCommand: '',
languagePreference: 'ja',
maxEntryResults: 10,
autoOpenWhenPrimarySubtitleMissing: false,
},
tsukihime: {
apiBaseUrl: 'https://api.tsukihime.org/v1',
@@ -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',
+15
View File
@@ -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'],
);
});
+12
View File
@@ -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;
+28
View File
@@ -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,
+7 -4
View File
@@ -69,20 +69,22 @@ export function createOverlayModalsAssembly(deps: OverlayModalsAssemblyDeps) {
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
unavailableMessage: string,
failureLogMessage: string,
): void {
void openModal(createOverlayHostedModalOpenDeps())
): Promise<boolean> {
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<boolean> {
return openOverlayHostedModalWithOsd(
openJimakuModalRuntime,
'Jimaku overlay unavailable.',
'Failed to open Jimaku overlay.',
+4
View File
@@ -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),
})),
+13
View File
@@ -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<typeof createMediaTimingReviewRuntime>,
'dispose'
>;
getJimakuAutoOpenRuntime: () => Pick<
ReturnType<typeof createJimakuAutoOpenRuntime>,
'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) => {
+3
View File
@@ -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),
+7
View File
@@ -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<typeof createManagedLocalSubtitleSelectionRuntime>,
'handleMediaPathChange' | 'handleSubtitleTrackListChange'
>;
jimakuAutoOpenRuntime: Pick<
ReturnType<typeof createJimakuAutoOpenRuntime>,
'handleMediaPathChange'
>;
mediaTimingReviewRuntime: Pick<
ReturnType<typeof createMediaTimingReviewRuntime>,
'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) {
@@ -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<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);
});
+19
View File
@@ -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();
+282
View File
@@ -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);
});
+201
View File
@@ -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,
};
}
+1 -1
View File
@@ -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;
}
@@ -20,12 +20,39 @@ function pad(pressed: number[] = [], mapping = 'standard'): GamepadLike {
const pressedIndices = (gamepad: GamepadLike | null | undefined): number[] =>
gamepad?.buttons.flatMap((button, index) => (button.pressed ? [index] : [])) ?? [];
test('a queued press reads as pressed for exactly one poll', () => {
test('a queued press the pad never reports is released after the window', () => {
const replay = createControllerPressReplay();
replay.queue(0, 1000);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1010)[0]), [0]);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1026)[0]), []);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1026)[0]), [0]);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1499)[0]), [0]);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1500)[0]), []);
});
// The helper's IPC beats Chromium, which only starts reading the pad once the overlay has focus.
// Going unpressed before the late report would give the controller a second press edge.
test('a press the pad reports late reads as one continuous press', () => {
const replay = createControllerPressReplay();
replay.queue(11, 1000);
const pressedAt = (real: number[], now: number) =>
pressedIndices(replay.apply([pad(real)], now)[0]);
assert.deepEqual(pressedAt([], 1016), [11]);
assert.deepEqual(pressedAt([], 1032), [11]);
assert.deepEqual(pressedAt([11], 1048), [11]);
assert.deepEqual(pressedAt([11], 1064), [11]);
assert.deepEqual(pressedAt([], 1080), []);
});
test('a fresh press after the replayed one was released reads as a new press', () => {
const replay = createControllerPressReplay();
replay.queue(11, 1000);
assert.deepEqual(pressedIndices(replay.apply([pad([11])], 1016)[0]), [11]);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1032)[0]), []);
assert.deepEqual(pressedIndices(replay.apply([pad([11])], 1048)[0]), [11]);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1064)[0]), []);
});
test('a press the Gamepad API already reported is not replayed', () => {
@@ -1,16 +1,18 @@
import type { GamepadLike } from './gamepad-controller.js';
/**
* How far back a press the Gamepad API reported itself still counts as the same press the
* main process is replaying. Covers the helper -> main -> renderer IPC hop.
* How far apart the helper's report and the Gamepad API's report of one physical press can be.
* The Gamepad API can lead (the helper -> main -> renderer IPC hop) or lag (Chromium only reads
* the pad once the overlay has focus).
*/
const SAME_PRESS_WINDOW_MS = 500;
/**
* Replays macOS background presses (see `src/main/runtime/macos-controller-watch.ts`) into the
* controller poll. A queued button reads as pressed for one poll, so the controller sees a
* normal press edge; the next poll reads the real pad again, so a button still held does not
* fire twice. A press the Gamepad API already reported is dropped.
* controller poll. A queued button reads as pressed until the Gamepad API has reported that
* press and its release, or until the window passes if it never does. Holding it across the gap
* before a late report keeps one physical press to one press edge. A press the Gamepad API
* already reported is dropped.
*
* Only a lone standard-mapping pad gets the replay: the press carries no pad identity, so with
* several visible it could run an action on the wrong one. With none visible yet, or a pad
@@ -18,6 +20,7 @@ const SAME_PRESS_WINDOW_MS = 500;
*/
export function createControllerPressReplay() {
const pendingPresses: Array<{ buttonIndex: number; queuedAt: number }> = [];
const heldPresses = new Map<number, { releaseAt: number; realPressSeen: boolean }>();
const lastRealPressAt = new Map<number, number>();
function recordRealPresses(gamepads: GamepadLike[], now: number): void {
@@ -55,20 +58,37 @@ export function createControllerPressReplay() {
Boolean(gamepad?.connected),
);
recordRealPresses(connected, now);
if (pendingPresses.length === 0) return gamepads;
if (pendingPresses.length === 0 && heldPresses.size === 0) return gamepads;
const replayed = pendingPresses
.filter(({ buttonIndex, queuedAt }) => {
const seenAt = lastRealPressAt.get(buttonIndex);
return seenAt === undefined || seenAt < queuedAt - SAME_PRESS_WINDOW_MS;
})
.map(({ buttonIndex }) => buttonIndex);
for (const { buttonIndex, queuedAt } of pendingPresses) {
const seenAt = lastRealPressAt.get(buttonIndex);
if (seenAt === undefined || seenAt < queuedAt - SAME_PRESS_WINDOW_MS) {
heldPresses.set(buttonIndex, {
releaseAt: queuedAt + SAME_PRESS_WINDOW_MS,
realPressSeen: false,
});
}
}
pendingPresses.length = 0;
const onlyPad = connected.length === 1 ? connected[0] : undefined;
if (replayed.length === 0 || onlyPad?.mapping !== 'standard') return gamepads;
const onlyPad = connected.length === 1 ? connected[0] : undefined;
if (onlyPad?.mapping !== 'standard') {
heldPresses.clear();
return gamepads;
}
for (const [buttonIndex, hold] of heldPresses) {
if (onlyPad.buttons[buttonIndex]?.pressed) {
hold.realPressSeen = true;
} else if (hold.realPressSeen || now >= hold.releaseAt) {
heldPresses.delete(buttonIndex);
}
}
if (heldPresses.size === 0) return gamepads;
const held = [...heldPresses.keys()];
return gamepads.map((gamepad) =>
gamepad === onlyPad ? withPressed(onlyPad, replayed) : gamepad,
gamepad === onlyPad ? withPressed(onlyPad, held) : gamepad,
);
},
};
+64 -1
View File
@@ -147,7 +147,7 @@ function createMouseHarness(
/** Moves the tracked pointer onto `hovered` and fires a document mousemove. */
move: (hovered: HoverTarget, clientX = 120, clientY = 240) => {
harness.hovered = hovered;
harness.fire('document', 'mousemove', { clientX, clientY });
harness.fire('document', 'mousemove', { clientX, clientY, isTrusted: true });
},
isInteractive: () => overlayClassList.contains('interactive'),
isSecondaryHoverActive: () =>
@@ -357,6 +357,69 @@ test('popup open pauses and popup close resumes when yomitan popup auto-pause is
assert.deepEqual(h.mpvCommands, [PAUSE, RESUME]);
});
// The lookup's fallback scan dispatches untrusted mouse events on the selected word, and
// those bubble to the pointer tracker while the real cursor is elsewhere.
test('popup close resumes after a fallback scan event lands on the subtitle', async (t) => {
const h = createMouseHarness(t, { hoverPause: true, popupPause: true });
h.handlers.setupPointerTracking();
h.handlers.setupYomitanObserver();
h.hovered = 'primary';
h.fire('document', 'pointermove', { clientX: 120, clientY: 240, isTrusted: false });
h.fire('document', 'mousemove', { clientX: 120, clientY: 240, isTrusted: false });
await waitForNextTick();
assert.equal(h.ctx.state.isOverSubtitle, false);
h.fire('window', YOMITAN_POPUP_SHOWN_EVENT);
await waitForNextTick();
h.fire('window', YOMITAN_POPUP_HIDDEN_EVENT);
assert.deepEqual(h.mpvCommands, [PAUSE, RESUME]);
});
// Controller and keyboard pause toggles change playback without touching the hover state.
test('popup open pauses when the user resumed playback during a hover pause', async (t) => {
let paused = false;
const h = createMouseHarness(t, {
hoverPause: true,
popupPause: true,
getPlaybackPaused: async () => paused,
});
h.handlers.setupYomitanObserver();
// `paused` stays false: the pause button resumed playback after this hover pause.
await h.handlers.handleMouseEnter();
assert.deepEqual(h.mpvCommands, [PAUSE]);
// The lookup event lands while the shown event's pause check is still in flight.
h.popupHostVisible = true;
h.fire('window', YOMITAN_POPUP_SHOWN_EVENT);
h.fire('window', YOMITAN_LOOKUP_EVENT);
await waitForNextTick();
assert.deepEqual(h.mpvCommands, [PAUSE, PAUSE]);
});
test('popup taking over a hover pause leaves playback paused without a second command', async (t) => {
let paused = false;
const h = createMouseHarness(t, {
hoverPause: true,
popupPause: true,
getPlaybackPaused: async () => paused,
});
h.handlers.setupYomitanObserver();
await h.handlers.handleMouseEnter();
paused = true;
h.fire('window', YOMITAN_POPUP_SHOWN_EVENT);
await waitForNextTick();
await h.handlers.handleMouseLeave();
assert.deepEqual(h.mpvCommands, [PAUSE]);
h.fire('window', YOMITAN_POPUP_HIDDEN_EVENT);
assert.deepEqual(h.mpvCommands, [PAUSE, RESUME]);
});
for (const c of [
{
name: 'nested popup close reasserts interactive state and focus when another popup remains visible on Windows',
+11 -9
View File
@@ -275,13 +275,14 @@ export function createMouseHandlers(
return;
}
const requestId = ++popupPauseRequestId;
if (pausedByYomitanPopup) return;
const requestId = ++popupPauseRequestId;
// The popup takes over the hover pause so that closing it owns the resume. A pause toggle
// can resume playback without clearing the hover flag, so mpv still decides below.
if (pausedBySubtitleHover) {
pausedBySubtitleHover = false;
pausedByYomitanPopup = true;
return;
}
let paused: boolean | null = null;
@@ -442,16 +443,17 @@ export function createMouseHandlers(
}
function setupPointerTracking(): void {
document.addEventListener('mousemove', (event: MouseEvent) => {
// The lookup's fallback scan dispatches untrusted events on the selected word, which say
// nothing about where the cursor is. Counting them as hover pauses playback until the next
// real pointer move.
const trackPointer = (event: MouseEvent | PointerEvent): void => {
if (!event.isTrusted) return;
updatePointerPosition(event);
syncHoverStateFromTrackedPointer(event);
maybeResyncPointerHoverState(event);
});
document.addEventListener('pointermove', (event: PointerEvent) => {
updatePointerPosition(event);
syncHoverStateFromTrackedPointer(event);
maybeResyncPointerHoverState(event);
});
};
document.addEventListener('mousemove', trackPointer);
document.addEventListener('pointermove', trackPointer);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') {
return;
+163
View File
@@ -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<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');
});
});
+37 -1
View File
@@ -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');
+6 -1
View File
@@ -657,7 +657,12 @@ function startControllerPolling(): void {
: ctx.state.kikuModalOpen
? fieldGroupingControllerLayout
: null,
getSuspended: () => ctx.state.controllerSuspendedByModalWindow,
// macOS and Windows keep the modal window's renderer alive while hidden, and it keeps
// polling. It only owns the controller with a modal open; otherwise every press would run
// here and in the main overlay.
getSuspended: () =>
ctx.state.controllerSuspendedByModalWindow ||
(ctx.platform.isModalLayer && !isAnyModalOpen()),
});
const poll = (now: number): void => {
+1
View File
@@ -353,6 +353,7 @@ export interface ResolvedConfig {
apiBaseUrl: string;
languagePreference: JimakuLanguagePreference;
maxEntryResults: number;
autoOpenWhenPrimarySubtitleMissing: boolean;
};
tsukihime: TsukihimeConfig & {
apiBaseUrl: string;
+1
View File
@@ -48,6 +48,7 @@ export interface JimakuConfig {
apiBaseUrl?: string;
languagePreference?: JimakuLanguagePreference;
maxEntryResults?: number;
autoOpenWhenPrimarySubtitleMissing?: boolean;
}
export type AnilistCharacterDictionaryProfileScope = 'all' | 'active';