From 69ab87c25ffeb0b61000eda724e5d60711fa52e4 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 4 Mar 2026 11:19:46 -0800 Subject: [PATCH] feat(renderer): add optional yomitan popup auto-pause --- ...-auto-pause-config-and-runtime-behavior.md | 9 +- docs/configuration.md | 3 +- docs/mining-workflow.md | 1 + docs/public/config.example.jsonc | 3 +- docs/shortcuts.md | 13 +- docs/usage.md | 6 +- src/config/config.test.ts | 39 +++ src/config/definitions/defaults-subtitle.ts | 1 + .../definitions/domain-registry.test.ts | 1 + src/config/definitions/options-subtitle.ts | 7 + src/config/resolve/subtitle-domains.ts | 22 ++ src/config/resolve/subtitle-style.test.ts | 19 ++ src/renderer/handlers/mouse.test.ts | 284 ++++++++++++++++++ src/renderer/handlers/mouse.ts | 70 ++++- src/renderer/renderer.ts | 1 + src/renderer/state.ts | 2 + src/renderer/subtitle-render.ts | 1 + src/types.ts | 1 + 18 files changed, 474 insertions(+), 9 deletions(-) diff --git a/backlog/tasks/task-77 - Subtitle-hover-auto-pause-config-and-runtime-behavior.md b/backlog/tasks/task-77 - Subtitle-hover-auto-pause-config-and-runtime-behavior.md index f725a4b..d5a42f4 100644 --- a/backlog/tasks/task-77 - Subtitle-hover-auto-pause-config-and-runtime-behavior.md +++ b/backlog/tasks/task-77 - Subtitle-hover-auto-pause-config-and-runtime-behavior.md @@ -4,7 +4,7 @@ title: 'Subtitle hover: auto-pause playback with config toggle' status: Done assignee: [] created_date: '2026-02-28 22:43' -updated_date: '2026-02-28 22:43' +updated_date: '2026-03-04 12:07' labels: [] dependencies: [] priority: medium @@ -43,4 +43,11 @@ Scope: Implemented `subtitleStyle.autoPauseVideoOnHover` with default `true`, wired through config defaults/resolution/types, renderer state/style, and mouse hover handlers. Added playback pause-state IPC (`getPlaybackPaused`) to avoid false resume when media was already paused. Added renderer hover behavior tests (including race/cancel case) and config/resolve tests. Updated config examples and docs (`README`, usage, shortcuts, mining workflow, configuration) to document default hover pause/resume behavior and disable path. +Follow-up adjustments (2026-03-04): + +- Hover pause now resumes immediately when leaving subtitle text (no Yomitan-popup hover retention). +- Added `subtitleStyle.autoPauseVideoOnYomitanPopup` (default `false`) to optionally keep playback paused while Yomitan popup is open, with auto-resume on close only when SubMiner initiated the popup pause. +- Yomitan popup control keybinds added while popup is open: `J/K` scroll, `M` mine, `P` audio play, `[` previous audio variant, `]` next audio variant (within selected source). +- Extension copy drift detection widened so popup runtime changes are reliably re-copied on launch (`popup.js`, `popup-main.js`, `display.js`, `display-audio.js`). + diff --git a/docs/configuration.md b/docs/configuration.md index d910f85..e4a45cf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -258,7 +258,8 @@ See `config.example.jsonc` for detailed configuration options. | `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) | | `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) | | `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. | -| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). | +| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text; resume after leaving subtitle area (`true` by default). | +| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while Yomitan popup is open; resume when popup closes (`false` by default). | | `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) | | `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) | | `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) | diff --git a/docs/mining-workflow.md b/docs/mining-workflow.md index 90ca1d7..2a52fda 100644 --- a/docs/mining-workflow.md +++ b/docs/mining-workflow.md @@ -34,6 +34,7 @@ The visible overlay renders subtitles as tokenized, clickable word spans. Each w - Word-level click targets for Yomitan lookup - Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`) +- Optional auto-pause while Yomitan popup is open (`subtitleStyle.autoPauseVideoOnYomitanPopup`) - Right-click to pause/resume - Right-click + drag to reposition subtitles - Modal dialogs for Jimaku search, field grouping, subsync, and runtime options diff --git a/docs/public/config.example.jsonc b/docs/public/config.example.jsonc index 995cd87..8bd3d8a 100644 --- a/docs/public/config.example.jsonc +++ b/docs/public/config.example.jsonc @@ -107,7 +107,8 @@ "subtitleStyle": { "enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false - "autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false + "autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text; resume after leaving subtitle area. Values: true | false + "autoPauseVideoOnYomitanPopup": false, // Automatically pause mpv playback while Yomitan popup is open; resume when popup closes. Values: true | false "hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv. "hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv. "fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting. diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 79ba27c..79007dd 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -58,7 +58,18 @@ These control playback and subtitle display. They require overlay window focus. These keybindings can be overridden or disabled via the `keybindings` config array. -Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave). +Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover; resume after leaving subtitle area). Optional popup behavior: set `subtitleStyle.autoPauseVideoOnYomitanPopup` to `true` to keep playback paused while Yomitan popup is open. + +When a Yomitan popup is open, SubMiner also provides popup control shortcuts: + +| Shortcut | Action | +| ------------- | -------------------------------------- | +| `J` | Scroll definitions down | +| `K` | Scroll definitions up | +| `M` | Mine/add selected term | +| `P` | Play selected term audio | +| `[` | Play previous available audio (selected source) | +| `]` | Play next available audio (selected source) | ## Subtitle & Feature Shortcuts diff --git a/docs/usage.md b/docs/usage.md index 50b12f6..573b114 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -209,7 +209,11 @@ Notes: These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization. -By default, hovering over subtitle text pauses mpv playback and leaving the subtitle area resumes playback. Set `subtitleStyle.autoPauseVideoOnHover` to `false` to disable this behavior. +By default, hovering over subtitle text pauses mpv playback. Playback resumes as soon as the cursor leaves subtitle text. Set `subtitleStyle.autoPauseVideoOnHover` to `false` to disable this behavior. + +If you want playback to stay paused while a Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup` to `true`. When enabled, SubMiner auto-resumes on popup close only if SubMiner paused playback for that popup. + +If the Yomitan popup is open, you can control it directly from the overlay: `J/K` scroll definitions, `M` mines/adds the selected term, `P` plays term audio, `[` plays the previous available audio, and `]` plays the next available audio in the selected source. ### Drag-and-drop Queueing diff --git a/src/config/config.test.ts b/src/config/config.test.ts index f744a9a..4ce78ce 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -33,6 +33,7 @@ test('loads defaults when config is missing', () => { assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)'); assert.equal(config.subtitleStyle.preserveLineBreaks, false); assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true); + assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, false); assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6'); assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)'); assert.equal( @@ -160,6 +161,44 @@ test('parses subtitleStyle.autoPauseVideoOnHover and warns on invalid values', ( ); }); +test('parses subtitleStyle.autoPauseVideoOnYomitanPopup and warns on invalid values', () => { + const validDir = makeTempDir(); + fs.writeFileSync( + path.join(validDir, 'config.jsonc'), + `{ + "subtitleStyle": { + "autoPauseVideoOnYomitanPopup": true + } + }`, + 'utf-8', + ); + + const validService = new ConfigService(validDir); + assert.equal(validService.getConfig().subtitleStyle.autoPauseVideoOnYomitanPopup, true); + + const invalidDir = makeTempDir(); + fs.writeFileSync( + path.join(invalidDir, 'config.jsonc'), + `{ + "subtitleStyle": { + "autoPauseVideoOnYomitanPopup": "yes" + } + }`, + 'utf-8', + ); + + const invalidService = new ConfigService(invalidDir); + assert.equal( + invalidService.getConfig().subtitleStyle.autoPauseVideoOnYomitanPopup, + DEFAULT_CONFIG.subtitleStyle.autoPauseVideoOnYomitanPopup, + ); + assert.ok( + invalidService + .getWarnings() + .some((warning) => warning.path === 'subtitleStyle.autoPauseVideoOnYomitanPopup'), + ); +}); + test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => { const validDir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/definitions/defaults-subtitle.ts b/src/config/definitions/defaults-subtitle.ts index 417c94a..2a6efb5 100644 --- a/src/config/definitions/defaults-subtitle.ts +++ b/src/config/definitions/defaults-subtitle.ts @@ -5,6 +5,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick = { enableJlpt: false, preserveLineBreaks: false, autoPauseVideoOnHover: true, + autoPauseVideoOnYomitanPopup: false, hoverTokenColor: '#f4dbd6', hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)', fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP', diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index 864f063..d3bc925 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -20,6 +20,7 @@ test('config option registry includes critical paths and has unique entries', () 'logging.level', 'startupWarmups.lowPowerMode', 'subtitleStyle.enableJlpt', + 'subtitleStyle.autoPauseVideoOnYomitanPopup', 'ankiConnect.enabled', 'immersionTracking.enabled', ]) { diff --git a/src/config/definitions/options-subtitle.ts b/src/config/definitions/options-subtitle.ts index 03973f5..d822835 100644 --- a/src/config/definitions/options-subtitle.ts +++ b/src/config/definitions/options-subtitle.ts @@ -28,6 +28,13 @@ export function buildSubtitleConfigOptionRegistry( description: 'Automatically pause mpv playback while hovering subtitle text, then resume on leave.', }, + { + path: 'subtitleStyle.autoPauseVideoOnYomitanPopup', + kind: 'boolean', + defaultValue: defaultConfig.subtitleStyle.autoPauseVideoOnYomitanPopup, + description: + 'Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes.', + }, { path: 'subtitleStyle.hoverTokenColor', kind: 'string', diff --git a/src/config/resolve/subtitle-domains.ts b/src/config/resolve/subtitle-domains.ts index 478b091..7b51e4a 100644 --- a/src/config/resolve/subtitle-domains.ts +++ b/src/config/resolve/subtitle-domains.ts @@ -100,6 +100,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void { const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt; const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks; const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover; + const fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup = + resolved.subtitleStyle.autoPauseVideoOnYomitanPopup; const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor; const fallbackSubtitleStyleHoverTokenBackgroundColor = resolved.subtitleStyle.hoverTokenBackgroundColor; @@ -171,6 +173,26 @@ export function applySubtitleDomainConfig(context: ResolveContext): void { ); } + const autoPauseVideoOnYomitanPopup = asBoolean( + (src.subtitleStyle as { autoPauseVideoOnYomitanPopup?: unknown }).autoPauseVideoOnYomitanPopup, + ); + if (autoPauseVideoOnYomitanPopup !== undefined) { + resolved.subtitleStyle.autoPauseVideoOnYomitanPopup = autoPauseVideoOnYomitanPopup; + } else if ( + (src.subtitleStyle as { autoPauseVideoOnYomitanPopup?: unknown }) + .autoPauseVideoOnYomitanPopup !== undefined + ) { + resolved.subtitleStyle.autoPauseVideoOnYomitanPopup = + fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup; + warn( + 'subtitleStyle.autoPauseVideoOnYomitanPopup', + (src.subtitleStyle as { autoPauseVideoOnYomitanPopup?: unknown }) + .autoPauseVideoOnYomitanPopup, + resolved.subtitleStyle.autoPauseVideoOnYomitanPopup, + 'Expected boolean.', + ); + } + const hoverTokenColor = asColor( (src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor, ); diff --git a/src/config/resolve/subtitle-style.test.ts b/src/config/resolve/subtitle-style.test.ts index 2fdd623..9678f79 100644 --- a/src/config/resolve/subtitle-style.test.ts +++ b/src/config/resolve/subtitle-style.test.ts @@ -47,6 +47,25 @@ test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => { ); }); +test('subtitleStyle autoPauseVideoOnYomitanPopup falls back on invalid value', () => { + const { context, warnings } = createResolveContext({ + subtitleStyle: { + autoPauseVideoOnYomitanPopup: 'invalid' as unknown as boolean, + }, + }); + + applySubtitleDomainConfig(context); + + assert.equal(context.resolved.subtitleStyle.autoPauseVideoOnYomitanPopup, false); + assert.ok( + warnings.some( + (warning) => + warning.path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' && + warning.message === 'Expected boolean.', + ), + ); +}); + test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => { const valid = createResolveContext({ subtitleStyle: { diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index 56e639a..84b0f8e 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -2,6 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createMouseHandlers } from './mouse.js'; +import { + YOMITAN_POPUP_HIDDEN_EVENT, + YOMITAN_POPUP_SHOWN_EVENT, +} from '../yomitan-popup.js'; function createClassList() { const classes = new Set(); @@ -28,6 +32,12 @@ function createDeferred() { return { promise, resolve }; } +function waitForNextTick(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + function createMouseTestContext() { const overlayClassList = createClassList(); const subtitleRootClassList = createClassList(); @@ -78,6 +88,7 @@ test('auto-pause on subtitle hover pauses on enter and resumes on leave when ena getCurrentYPercent: () => 10, persistSubtitlePositionPatch: () => {}, getSubtitleHoverAutoPauseEnabled: () => true, + getYomitanPopupAutoPauseEnabled: () => false, getPlaybackPaused: async () => false, sendMpvCommand: (command) => { mpvCommands.push(command); @@ -106,6 +117,7 @@ test('auto-pause on subtitle hover skips when playback is already paused', async getCurrentYPercent: () => 10, persistSubtitlePositionPatch: () => {}, getSubtitleHoverAutoPauseEnabled: () => true, + getYomitanPopupAutoPauseEnabled: () => false, getPlaybackPaused: async () => true, sendMpvCommand: (command) => { mpvCommands.push(command); @@ -131,6 +143,7 @@ test('auto-pause on subtitle hover is skipped when disabled in config', async () getCurrentYPercent: () => 10, persistSubtitlePositionPatch: () => {}, getSubtitleHoverAutoPauseEnabled: () => false, + getYomitanPopupAutoPauseEnabled: () => false, getPlaybackPaused: async () => false, sendMpvCommand: (command) => { mpvCommands.push(command); @@ -157,6 +170,7 @@ test('pending hover pause check is ignored when mouse leaves before pause state getCurrentYPercent: () => 10, persistSubtitlePositionPatch: () => {}, getSubtitleHoverAutoPauseEnabled: () => true, + getYomitanPopupAutoPauseEnabled: () => false, getPlaybackPaused: async () => deferred.promise, sendMpvCommand: (command) => { mpvCommands.push(command); @@ -170,3 +184,273 @@ test('pending hover pause check is ignored when mouse leaves before pause state assert.deepEqual(mpvCommands, []); }); + +test('hover pause resumes immediately on subtitle leave even when yomitan popup is visible', async () => { + const ctx = createMouseTestContext(); + const mpvCommands: Array<(string | number)[]> = []; + const previousWindow = (globalThis as { window?: unknown }).window; + const previousDocument = (globalThis as { document?: unknown }).document; + const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver; + const previousNode = (globalThis as { Node?: unknown }).Node; + const windowListeners = new Map void>>(); + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + addEventListener: (type: string, listener: () => void) => { + const bucket = windowListeners.get(type) ?? []; + bucket.push(listener); + windowListeners.set(type, bucket); + }, + electronAPI: { + setIgnoreMouseEvents: () => {}, + }, + focus: () => {}, + innerHeight: 1000, + getSelection: () => null, + setTimeout, + clearTimeout, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + querySelector: () => null, + querySelectorAll: () => [], + body: {}, + elementFromPoint: () => null, + addEventListener: () => {}, + }, + }); + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: class { + observe() {} + }, + }); + Object.defineProperty(globalThis, 'Node', { + configurable: true, + value: { + ELEMENT_NODE: 1, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => true, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: (command) => { + mpvCommands.push(command); + }, + }); + + handlers.setupYomitanObserver(); + for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) { + listener(); + } + await handlers.handleMouseEnter(); + await handlers.handleMouseLeave(); + + assert.deepEqual(mpvCommands, [ + ['set_property', 'pause', 'yes'], + ['set_property', 'pause', 'no'], + ]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: previousMutationObserver, + }); + Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode }); + } +}); + +test('auto-pause still works when yomitan popup is already visible', async () => { + const ctx = createMouseTestContext(); + const mpvCommands: Array<(string | number)[]> = []; + const previousWindow = (globalThis as { window?: unknown }).window; + const previousDocument = (globalThis as { document?: unknown }).document; + const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver; + const previousNode = (globalThis as { Node?: unknown }).Node; + const windowListeners = new Map void>>(); + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + addEventListener: (type: string, listener: () => void) => { + const bucket = windowListeners.get(type) ?? []; + bucket.push(listener); + windowListeners.set(type, bucket); + }, + electronAPI: { + setIgnoreMouseEvents: () => {}, + }, + focus: () => {}, + innerHeight: 1000, + getSelection: () => null, + setTimeout, + clearTimeout, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + querySelector: () => null, + querySelectorAll: () => [], + body: {}, + elementFromPoint: () => null, + addEventListener: () => {}, + }, + }); + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: class { + observe() {} + }, + }); + Object.defineProperty(globalThis, 'Node', { + configurable: true, + value: { + ELEMENT_NODE: 1, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => true, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: (command) => { + mpvCommands.push(command); + }, + }); + + handlers.setupYomitanObserver(); + for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) { + listener(); + } + await handlers.handleMouseEnter(); + await handlers.handleMouseLeave(); + + assert.deepEqual(mpvCommands, [ + ['set_property', 'pause', 'yes'], + ['set_property', 'pause', 'no'], + ]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: previousMutationObserver, + }); + Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode }); + } +}); + +test('popup open pauses and popup close resumes when yomitan popup auto-pause is enabled', async () => { + const ctx = createMouseTestContext(); + const mpvCommands: Array<(string | number)[]> = []; + const previousWindow = (globalThis as { window?: unknown }).window; + const previousDocument = (globalThis as { document?: unknown }).document; + const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver; + const previousNode = (globalThis as { Node?: unknown }).Node; + const windowListeners = new Map void>>(); + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + addEventListener: (type: string, listener: () => void) => { + const bucket = windowListeners.get(type) ?? []; + bucket.push(listener); + windowListeners.set(type, bucket); + }, + electronAPI: { + setIgnoreMouseEvents: () => {}, + }, + focus: () => {}, + innerHeight: 1000, + getSelection: () => null, + setTimeout, + clearTimeout, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + querySelector: () => null, + querySelectorAll: () => [], + body: {}, + elementFromPoint: () => null, + addEventListener: () => {}, + }, + }); + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: class { + observe() {} + }, + }); + Object.defineProperty(globalThis, 'Node', { + configurable: true, + value: { + ELEMENT_NODE: 1, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => true, + getYomitanPopupAutoPauseEnabled: () => true, + getPlaybackPaused: async () => false, + sendMpvCommand: (command: (string | number)[]) => { + mpvCommands.push(command); + }, + }); + + handlers.setupYomitanObserver(); + + for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) { + listener(); + } + await waitForNextTick(); + for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) { + listener(); + } + + assert.deepEqual(mpvCommands, [ + ['set_property', 'pause', 'yes'], + ['set_property', 'pause', 'no'], + ]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: previousMutationObserver, + }); + Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode }); + } +}); diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index fe4d7f5..3b7aa76 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -14,13 +14,68 @@ export function createMouseHandlers( getCurrentYPercent: () => number; persistSubtitlePositionPatch: (patch: { yPercent: number }) => void; getSubtitleHoverAutoPauseEnabled: () => boolean; + getYomitanPopupAutoPauseEnabled: () => boolean; getPlaybackPaused: () => Promise; sendMpvCommand: (command: (string | number)[]) => void; }, ) { let yomitanPopupVisible = false; let hoverPauseRequestId = 0; + let popupPauseRequestId = 0; let pausedBySubtitleHover = false; + let pausedByYomitanPopup = false; + + function maybeResumeHoverPause(): void { + if (!pausedBySubtitleHover) return; + if (pausedByYomitanPopup) return; + if (ctx.state.isOverSubtitle) return; + pausedBySubtitleHover = false; + options.sendMpvCommand(['set_property', 'pause', 'no']); + } + + function maybeResumeYomitanPopupPause(): void { + if (!pausedByYomitanPopup) return; + pausedByYomitanPopup = false; + if (ctx.state.isOverSubtitle && options.getSubtitleHoverAutoPauseEnabled()) { + pausedBySubtitleHover = true; + return; + } + options.sendMpvCommand(['set_property', 'pause', 'no']); + } + + async function maybePauseForYomitanPopup(): Promise { + if (!yomitanPopupVisible || !options.getYomitanPopupAutoPauseEnabled()) { + return; + } + + const requestId = ++popupPauseRequestId; + if (pausedByYomitanPopup) return; + + if (pausedBySubtitleHover) { + pausedBySubtitleHover = false; + pausedByYomitanPopup = true; + return; + } + + let paused: boolean | null = null; + try { + paused = await options.getPlaybackPaused(); + } catch { + return; + } + + if ( + requestId !== popupPauseRequestId || + !yomitanPopupVisible || + !options.getYomitanPopupAutoPauseEnabled() + ) { + return; + } + if (paused !== false) return; + + options.sendMpvCommand(['set_property', 'pause', 'yes']); + pausedByYomitanPopup = true; + } function enablePopupInteraction(): void { yomitanPopupVisible = true; @@ -40,6 +95,9 @@ export function createMouseHandlers( } yomitanPopupVisible = false; + popupPauseRequestId += 1; + maybeResumeYomitanPopupPause(); + maybeResumeHoverPause(); if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { ctx.dom.overlay.classList.remove('interactive'); if (ctx.platform.shouldToggleMouseIgnore) { @@ -55,6 +113,10 @@ export function createMouseHandlers( window.electronAPI.setIgnoreMouseEvents(false); } + if (yomitanPopupVisible && options.getYomitanPopupAutoPauseEnabled()) { + return; + } + if (!options.getSubtitleHoverAutoPauseEnabled()) { return; } @@ -79,10 +141,7 @@ export function createMouseHandlers( async function handleMouseLeave(): Promise { ctx.state.isOverSubtitle = false; hoverPauseRequestId += 1; - if (pausedBySubtitleHover) { - pausedBySubtitleHover = false; - options.sendMpvCommand(['set_property', 'pause', 'no']); - } + maybeResumeHoverPause(); if (yomitanPopupVisible) return; disablePopupInteractionIfIdle(); } @@ -144,9 +203,11 @@ export function createMouseHandlers( function setupYomitanObserver(): void { yomitanPopupVisible = hasYomitanPopupIframe(document); + void maybePauseForYomitanPopup(); window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => { enablePopupInteraction(); + void maybePauseForYomitanPopup(); }); window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => { @@ -160,6 +221,7 @@ export function createMouseHandlers( const element = node as Element; if (isYomitanPopupIframe(element)) { enablePopupInteraction(); + void maybePauseForYomitanPopup(); } }); diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 258c850..7e6149e 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -121,6 +121,7 @@ const mouseHandlers = createMouseHandlers(ctx, { getCurrentYPercent: positioning.getCurrentYPercent, persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch, getSubtitleHoverAutoPauseEnabled: () => ctx.state.autoPauseVideoOnSubtitleHover, + getYomitanPopupAutoPauseEnabled: () => ctx.state.autoPauseVideoOnYomitanPopup, getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(), sendMpvCommand: (command) => { window.electronAPI.sendMpvCommand(command); diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 9da0480..4d7e09c 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -65,6 +65,7 @@ export type RendererState = { jlptN5Color: string; preserveSubtitleLineBreaks: boolean; autoPauseVideoOnSubtitleHover: boolean; + autoPauseVideoOnYomitanPopup: boolean; frequencyDictionaryEnabled: boolean; frequencyDictionaryTopX: number; frequencyDictionaryMode: 'single' | 'banded'; @@ -128,6 +129,7 @@ export function createRendererState(): RendererState { jlptN5Color: '#8aadf4', preserveSubtitleLineBreaks: false, autoPauseVideoOnSubtitleHover: false, + autoPauseVideoOnYomitanPopup: false, frequencyDictionaryEnabled: false, frequencyDictionaryTopX: 1000, frequencyDictionaryMode: 'single', diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index 04782ee..08ee24d 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -610,6 +610,7 @@ export function createSubtitleRenderer(ctx: RendererContext) { ctx.state.jlptN5Color = jlptColors.N5; ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false; ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? false; + ctx.state.autoPauseVideoOnYomitanPopup = style.autoPauseVideoOnYomitanPopup ?? false; ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1); ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2); ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3); diff --git a/src/types.ts b/src/types.ts index 1a3e293..8b00984 100644 --- a/src/types.ts +++ b/src/types.ts @@ -290,6 +290,7 @@ export interface SubtitleStyleConfig { enableJlpt?: boolean; preserveLineBreaks?: boolean; autoPauseVideoOnHover?: boolean; + autoPauseVideoOnYomitanPopup?: boolean; hoverTokenColor?: string; hoverTokenBackgroundColor?: string; fontFamily?: string;