From 33007b3f40051d1c2b6ab7d73fed7818d0aa5aee Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 28 Feb 2026 21:57:44 -0800 Subject: [PATCH] feat: auto pause video when hovering subtitles --- config.example.jsonc | 1 + docs/public/config.example.jsonc | 1 + package.json | 4 +- src/config/config.test.ts | 39 ++++ src/config/definitions/defaults-subtitle.ts | 1 + src/config/definitions/options-subtitle.ts | 7 + src/config/resolve/subtitle-domains.ts | 20 ++ src/config/resolve/subtitle-style.test.ts | 19 ++ src/core/services/ipc.test.ts | 8 + src/core/services/ipc.ts | 7 + src/main.ts | 1 + src/main/dependencies.ts | 2 + .../composers/ipc-runtime-composer.test.ts | 1 + src/preload.ts | 2 + src/renderer/handlers/mouse.test.ts | 172 ++++++++++++++++++ src/renderer/handlers/mouse.ts | 34 +++- src/renderer/renderer.ts | 5 + src/renderer/state.ts | 2 + src/renderer/subtitle-render.ts | 1 + src/shared/ipc/contracts.ts | 1 + src/types.ts | 2 + 21 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 src/renderer/handlers/mouse.test.ts diff --git a/config.example.jsonc b/config.example.jsonc index 4901676..a883e11 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -106,6 +106,7 @@ "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 "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/public/config.example.jsonc b/docs/public/config.example.jsonc index b6b2d4f..91849f8 100644 --- a/docs/public/config.example.jsonc +++ b/docs/public/config.example.jsonc @@ -106,6 +106,7 @@ "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 "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/package.json b/package.json index 2c11027..4cfd6e6 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "test:plugin:src": "lua scripts/test-plugin-start-gate.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", - "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", + "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", + "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 0ce7b5d..458c5d2 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -32,6 +32,7 @@ test('loads defaults when config is missing', () => { assert.equal(config.discordPresence.updateIntervalMs, 3_000); 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.hoverTokenColor, '#f4dbd6'); assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)'); assert.equal( @@ -118,6 +119,44 @@ test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () = ); }); +test('parses subtitleStyle.autoPauseVideoOnHover and warns on invalid values', () => { + const validDir = makeTempDir(); + fs.writeFileSync( + path.join(validDir, 'config.jsonc'), + `{ + "subtitleStyle": { + "autoPauseVideoOnHover": true + } + }`, + 'utf-8', + ); + + const validService = new ConfigService(validDir); + assert.equal(validService.getConfig().subtitleStyle.autoPauseVideoOnHover, true); + + const invalidDir = makeTempDir(); + fs.writeFileSync( + path.join(invalidDir, 'config.jsonc'), + `{ + "subtitleStyle": { + "autoPauseVideoOnHover": "yes" + } + }`, + 'utf-8', + ); + + const invalidService = new ConfigService(invalidDir); + assert.equal( + invalidService.getConfig().subtitleStyle.autoPauseVideoOnHover, + DEFAULT_CONFIG.subtitleStyle.autoPauseVideoOnHover, + ); + assert.ok( + invalidService + .getWarnings() + .some((warning) => warning.path === 'subtitleStyle.autoPauseVideoOnHover'), + ); +}); + 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 89220da..da28ec2 100644 --- a/src/config/definitions/defaults-subtitle.ts +++ b/src/config/definitions/defaults-subtitle.ts @@ -4,6 +4,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick = { subtitleStyle: { enableJlpt: false, preserveLineBreaks: false, + autoPauseVideoOnHover: true, 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/options-subtitle.ts b/src/config/definitions/options-subtitle.ts index 1e428d1..03973f5 100644 --- a/src/config/definitions/options-subtitle.ts +++ b/src/config/definitions/options-subtitle.ts @@ -21,6 +21,13 @@ export function buildSubtitleConfigOptionRegistry( 'Preserve line breaks in visible overlay subtitle rendering. ' + 'When false, line breaks are flattened to spaces for a single-line flow.', }, + { + path: 'subtitleStyle.autoPauseVideoOnHover', + kind: 'boolean', + defaultValue: defaultConfig.subtitleStyle.autoPauseVideoOnHover, + description: + 'Automatically pause mpv playback while hovering subtitle text, then resume on leave.', + }, { path: 'subtitleStyle.hoverTokenColor', kind: 'string', diff --git a/src/config/resolve/subtitle-domains.ts b/src/config/resolve/subtitle-domains.ts index cb3020d..e65a2d4 100644 --- a/src/config/resolve/subtitle-domains.ts +++ b/src/config/resolve/subtitle-domains.ts @@ -99,6 +99,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void { if (isObject(src.subtitleStyle)) { const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt; const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks; + const fallbackSubtitleStyleAutoPauseVideoOnHover = + resolved.subtitleStyle.autoPauseVideoOnHover; const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor; const fallbackSubtitleStyleHoverTokenBackgroundColor = resolved.subtitleStyle.hoverTokenBackgroundColor; @@ -153,6 +155,24 @@ export function applySubtitleDomainConfig(context: ResolveContext): void { ); } + const autoPauseVideoOnHover = asBoolean( + (src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover, + ); + if (autoPauseVideoOnHover !== undefined) { + resolved.subtitleStyle.autoPauseVideoOnHover = autoPauseVideoOnHover; + } else if ( + (src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover !== + undefined + ) { + resolved.subtitleStyle.autoPauseVideoOnHover = fallbackSubtitleStyleAutoPauseVideoOnHover; + warn( + 'subtitleStyle.autoPauseVideoOnHover', + (src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover, + resolved.subtitleStyle.autoPauseVideoOnHover, + '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 c077cfc..2fdd623 100644 --- a/src/config/resolve/subtitle-style.test.ts +++ b/src/config/resolve/subtitle-style.test.ts @@ -28,6 +28,25 @@ test('subtitleStyle preserveLineBreaks falls back while merge is preserved', () ); }); +test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => { + const { context, warnings } = createResolveContext({ + subtitleStyle: { + autoPauseVideoOnHover: 'invalid' as unknown as boolean, + }, + }); + + applySubtitleDomainConfig(context); + + assert.equal(context.resolved.subtitleStyle.autoPauseVideoOnHover, true); + assert.ok( + warnings.some( + (warning) => + warning.path === 'subtitleStyle.autoPauseVideoOnHover' && + warning.message === 'Expected boolean.', + ), + ); +}); + test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => { const valid = createResolveContext({ subtitleStyle: { diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index af75aaf..fac8ff5 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -45,6 +45,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { tokenizeCurrentSubtitle: async () => null, getCurrentSubtitleRaw: () => '', getCurrentSubtitleAss: () => '', + getPlaybackPaused: () => true, getSubtitlePosition: () => null, getSubtitleStyle: () => null, saveSubtitlePosition: () => {}, @@ -89,6 +90,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { message: 'done', }); assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']); + assert.equal(deps.getPlaybackPaused(), true); }); test('registerIpcHandlers rejects malformed runtime-option payloads', async () => { @@ -106,6 +108,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = tokenizeCurrentSubtitle: async () => null, getCurrentSubtitleRaw: () => '', getCurrentSubtitleAss: () => '', + getPlaybackPaused: () => null, getSubtitlePosition: () => null, getSubtitleStyle: () => null, saveSubtitlePosition: () => {}, @@ -166,6 +169,10 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = }); await cycleHandler!({}, 'anki.kikuFieldGrouping', -1); assert.deepEqual(cycles, [{ id: 'anki.kikuFieldGrouping', direction: -1 }]); + + const getPlaybackPausedHandler = handlers.handle.get(IPC_CHANNELS.request.getPlaybackPaused); + assert.ok(getPlaybackPausedHandler); + assert.equal(getPlaybackPausedHandler!({}), null); }); test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { @@ -189,6 +196,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { tokenizeCurrentSubtitle: async () => null, getCurrentSubtitleRaw: () => '', getCurrentSubtitleAss: () => '', + getPlaybackPaused: () => false, getSubtitlePosition: () => null, getSubtitleStyle: () => null, saveSubtitlePosition: (position) => { diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 81f7e07..636c3e4 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -29,6 +29,7 @@ export interface IpcServiceDeps { tokenizeCurrentSubtitle: () => Promise; getCurrentSubtitleRaw: () => string; getCurrentSubtitleAss: () => string; + getPlaybackPaused: () => boolean | null; getSubtitlePosition: () => unknown; getSubtitleStyle: () => unknown; saveSubtitlePosition: (position: SubtitlePosition) => void; @@ -96,6 +97,7 @@ export interface IpcDepsRuntimeOptions { tokenizeCurrentSubtitle: () => Promise; getCurrentSubtitleRaw: () => string; getCurrentSubtitleAss: () => string; + getPlaybackPaused: () => boolean | null; getSubtitlePosition: () => unknown; getSubtitleStyle: () => unknown; saveSubtitlePosition: (position: SubtitlePosition) => void; @@ -136,6 +138,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle, getCurrentSubtitleRaw: options.getCurrentSubtitleRaw, getCurrentSubtitleAss: options.getCurrentSubtitleAss, + getPlaybackPaused: options.getPlaybackPaused, getSubtitlePosition: options.getSubtitlePosition, getSubtitleStyle: options.getSubtitleStyle, saveSubtitlePosition: options.saveSubtitlePosition, @@ -232,6 +235,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return deps.getCurrentSubtitleAss(); }); + ipc.handle(IPC_CHANNELS.request.getPlaybackPaused, () => { + return deps.getPlaybackPaused(); + }); + ipc.handle(IPC_CHANNELS.request.getSubtitlePosition, () => { return deps.getSubtitlePosition(); }); diff --git a/src/main.ts b/src/main.ts index af9ea30..aa6b036 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2880,6 +2880,7 @@ const { tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText), getCurrentSubtitleRaw: () => appState.currentSubText, getCurrentSubtitleAss: () => appState.currentSubAssText, + getPlaybackPaused: () => appState.playbackPaused, getSubtitlePosition: () => loadSubtitlePosition(), getSubtitleStyle: () => { const resolvedConfig = getResolvedConfig(); diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 7260dbf..2d4d9fe 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -63,6 +63,7 @@ export interface MainIpcRuntimeServiceDepsParams { tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle']; getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw']; getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss']; + getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused']; focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow']; getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition']; getSubtitleStyle: IpcDepsRuntimeOptions['getSubtitleStyle']; @@ -198,6 +199,7 @@ export function createMainIpcRuntimeServiceDeps( tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle, getCurrentSubtitleRaw: params.getCurrentSubtitleRaw, getCurrentSubtitleAss: params.getCurrentSubtitleAss, + getPlaybackPaused: params.getPlaybackPaused, getSubtitlePosition: params.getSubtitlePosition, getSubtitleStyle: params.getSubtitleStyle, saveSubtitlePosition: params.saveSubtitlePosition, diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts index b862b89..b664824 100644 --- a/src/main/runtime/composers/ipc-runtime-composer.test.ts +++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts @@ -42,6 +42,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b tokenizeCurrentSubtitle: async () => null, getCurrentSubtitleRaw: () => '', getCurrentSubtitleAss: () => '', + getPlaybackPaused: () => null, getSubtitlePosition: () => ({}) as never, getSubtitleStyle: () => ({}) as never, saveSubtitlePosition: () => {}, diff --git a/src/preload.ts b/src/preload.ts index 47a1e47..7789ad3 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -160,6 +160,8 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw), getCurrentSubtitleAss: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss), + getPlaybackPaused: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getPlaybackPaused), onSubtitleAss: (callback: (assText: string) => void) => { ipcRenderer.on( IPC_CHANNELS.event.subtitleAssSet, diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts new file mode 100644 index 0000000..56d2c10 --- /dev/null +++ b/src/renderer/handlers/mouse.test.ts @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createMouseHandlers } from './mouse.js'; + +function createClassList() { + const classes = new Set(); + return { + add: (...tokens: string[]) => { + for (const token of tokens) { + classes.add(token); + } + }, + remove: (...tokens: string[]) => { + for (const token of tokens) { + classes.delete(token); + } + }, + contains: (token: string) => classes.has(token), + }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function createMouseTestContext() { + const overlayClassList = createClassList(); + const subtitleRootClassList = createClassList(); + const subtitleContainerClassList = createClassList(); + + const ctx = { + dom: { + overlay: { + classList: overlayClassList, + }, + subtitleRoot: { + classList: subtitleRootClassList, + }, + subtitleContainer: { + classList: subtitleContainerClassList, + style: { cursor: '' }, + addEventListener: () => {}, + }, + secondarySubContainer: { + addEventListener: () => {}, + }, + }, + platform: { + shouldToggleMouseIgnore: false, + isMacOSPlatform: false, + }, + state: { + isOverSubtitle: false, + isDragging: false, + dragStartY: 0, + startYPercent: 0, + }, + }; + + return ctx; +} + +test('auto-pause on subtitle hover pauses on enter and resumes on leave when enabled', async () => { + const ctx = createMouseTestContext(); + const mpvCommands: Array<(string | number)[]> = []; + + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => true, + getPlaybackPaused: async () => false, + sendMpvCommand: (command) => { + mpvCommands.push(command); + }, + }); + + await handlers.handleMouseEnter(); + handlers.handleMouseLeave(); + + assert.deepEqual(mpvCommands, [ + ['set_property', 'pause', 'yes'], + ['set_property', 'pause', 'no'], + ]); +}); + +test('auto-pause on subtitle hover does not unpause when playback was already paused', async () => { + const ctx = createMouseTestContext(); + const mpvCommands: Array<(string | number)[]> = []; + + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => true, + getPlaybackPaused: async () => true, + sendMpvCommand: (command) => { + mpvCommands.push(command); + }, + }); + + await handlers.handleMouseEnter(); + handlers.handleMouseLeave(); + + assert.deepEqual(mpvCommands, []); +}); + +test('auto-pause on subtitle hover is skipped when disabled in config', async () => { + const ctx = createMouseTestContext(); + const mpvCommands: Array<(string | number)[]> = []; + + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: (command) => { + mpvCommands.push(command); + }, + }); + + await handlers.handleMouseEnter(); + handlers.handleMouseLeave(); + + assert.deepEqual(mpvCommands, []); +}); + +test('pending hover pause check is ignored when mouse leaves before pause state resolves', async () => { + const ctx = createMouseTestContext(); + const mpvCommands: Array<(string | number)[]> = []; + const deferred = createDeferred(); + + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => true, + getPlaybackPaused: async () => deferred.promise, + sendMpvCommand: (command) => { + mpvCommands.push(command); + }, + }); + + const enterPromise = handlers.handleMouseEnter(); + handlers.handleMouseLeave(); + deferred.resolve(false); + await enterPromise; + + assert.deepEqual(mpvCommands, []); +}); diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index 5459a6a..a3a6a22 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -13,9 +13,14 @@ export function createMouseHandlers( applyYPercent: (yPercent: number) => void; getCurrentYPercent: () => number; persistSubtitlePositionPatch: (patch: { yPercent: number }) => void; + getSubtitleHoverAutoPauseEnabled: () => boolean; + getPlaybackPaused: () => Promise; + sendMpvCommand: (command: (string | number)[]) => void; }, ) { let yomitanPopupVisible = false; + let hoverPauseRequestId = 0; + let pausedBySubtitleHover = false; function enablePopupInteraction(): void { yomitanPopupVisible = true; @@ -29,7 +34,7 @@ export function createMouseHandlers( } function disablePopupInteractionIfIdle(): void { - if (hasYomitanPopupIframe(document)) { + if (typeof document !== 'undefined' && hasYomitanPopupIframe(document)) { yomitanPopupVisible = true; return; } @@ -43,16 +48,41 @@ export function createMouseHandlers( } } - function handleMouseEnter(): void { + async function handleMouseEnter(): Promise { ctx.state.isOverSubtitle = true; ctx.dom.overlay.classList.add('interactive'); if (ctx.platform.shouldToggleMouseIgnore) { window.electronAPI.setIgnoreMouseEvents(false); } + + if (!options.getSubtitleHoverAutoPauseEnabled()) { + return; + } + + const requestId = ++hoverPauseRequestId; + let paused: boolean | null = null; + try { + paused = await options.getPlaybackPaused(); + } catch { + return; + } + if (requestId !== hoverPauseRequestId || !ctx.state.isOverSubtitle) { + return; + } + if (paused !== false) { + return; + } + options.sendMpvCommand(['set_property', 'pause', 'yes']); + pausedBySubtitleHover = true; } function handleMouseLeave(): void { ctx.state.isOverSubtitle = false; + hoverPauseRequestId += 1; + if (pausedBySubtitleHover) { + options.sendMpvCommand(['set_property', 'pause', 'no']); + pausedBySubtitleHover = false; + } if (yomitanPopupVisible) return; disablePopupInteractionIfIdle(); } diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 98f0a33..258c850 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -120,6 +120,11 @@ const mouseHandlers = createMouseHandlers(ctx, { applyYPercent: positioning.applyYPercent, getCurrentYPercent: positioning.getCurrentYPercent, persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch, + getSubtitleHoverAutoPauseEnabled: () => ctx.state.autoPauseVideoOnSubtitleHover, + getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(), + sendMpvCommand: (command) => { + window.electronAPI.sendMpvCommand(command); + }, }); let lastSubtitlePreview = ''; diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 9bde4fe..9da0480 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -64,6 +64,7 @@ export type RendererState = { jlptN4Color: string; jlptN5Color: string; preserveSubtitleLineBreaks: boolean; + autoPauseVideoOnSubtitleHover: boolean; frequencyDictionaryEnabled: boolean; frequencyDictionaryTopX: number; frequencyDictionaryMode: 'single' | 'banded'; @@ -126,6 +127,7 @@ export function createRendererState(): RendererState { jlptN4Color: '#a6e3a1', jlptN5Color: '#8aadf4', preserveSubtitleLineBreaks: false, + autoPauseVideoOnSubtitleHover: false, frequencyDictionaryEnabled: false, frequencyDictionaryTopX: 1000, frequencyDictionaryMode: 'single', diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index 45bad5d..48c149a 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -613,6 +613,7 @@ export function createSubtitleRenderer(ctx: RendererContext) { ctx.state.jlptN4Color = jlptColors.N4; ctx.state.jlptN5Color = jlptColors.N5; ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false; + ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? 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/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 7c4d392..16d0a22 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -26,6 +26,7 @@ export const IPC_CHANNELS = { getCurrentSubtitle: 'get-current-subtitle', getCurrentSubtitleRaw: 'get-current-subtitle-raw', getCurrentSubtitleAss: 'get-current-subtitle-ass', + getPlaybackPaused: 'get-playback-paused', getSubtitlePosition: 'get-subtitle-position', getSubtitleStyle: 'get-subtitle-style', getMecabStatus: 'get-mecab-status', diff --git a/src/types.ts b/src/types.ts index 1505d14..8d28650 100644 --- a/src/types.ts +++ b/src/types.ts @@ -287,6 +287,7 @@ export interface AnkiConnectConfig { export interface SubtitleStyleConfig { enableJlpt?: boolean; preserveLineBreaks?: boolean; + autoPauseVideoOnHover?: boolean; hoverTokenColor?: string; hoverTokenBackgroundColor?: string; fontFamily?: string; @@ -798,6 +799,7 @@ export interface ElectronAPI { getCurrentSubtitle: () => Promise; getCurrentSubtitleRaw: () => Promise; getCurrentSubtitleAss: () => Promise; + getPlaybackPaused: () => Promise; onSubtitleAss: (callback: (assText: string) => void) => void; setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; openYomitanSettings: () => void;