mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-06 19:57:26 -08:00
feat(renderer): add optional yomitan popup auto-pause
This commit is contained in:
@@ -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`).
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,6 +5,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
|
||||
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',
|
||||
|
||||
@@ -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',
|
||||
]) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<string>();
|
||||
@@ -28,6 +32,12 @@ function createDeferred<T>() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function waitForNextTick(): Promise<void> {
|
||||
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<string, Array<() => 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<string, Array<() => 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<string, Array<() => 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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,13 +14,68 @@ export function createMouseHandlers(
|
||||
getCurrentYPercent: () => number;
|
||||
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
|
||||
getSubtitleHoverAutoPauseEnabled: () => boolean;
|
||||
getYomitanPopupAutoPauseEnabled: () => boolean;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -290,6 +290,7 @@ export interface SubtitleStyleConfig {
|
||||
enableJlpt?: boolean;
|
||||
preserveLineBreaks?: boolean;
|
||||
autoPauseVideoOnHover?: boolean;
|
||||
autoPauseVideoOnYomitanPopup?: boolean;
|
||||
hoverTokenColor?: string;
|
||||
hoverTokenBackgroundColor?: string;
|
||||
fontFamily?: string;
|
||||
|
||||
Reference in New Issue
Block a user