feat: auto pause video when hovering subtitles

This commit is contained in:
2026-02-28 21:57:44 -08:00
parent e78e45b4e7
commit 33007b3f40
21 changed files with 326 additions and 4 deletions

View File

@@ -106,6 +106,7 @@
"subtitleStyle": { "subtitleStyle": {
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false "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 "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. "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. "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. "fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.

View File

@@ -106,6 +106,7 @@
"subtitleStyle": { "subtitleStyle": {
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false "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 "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. "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. "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. "fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.

View File

@@ -22,8 +22,8 @@
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua", "test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "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: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: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/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "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: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:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",

View File

@@ -32,6 +32,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.discordPresence.updateIntervalMs, 3_000); assert.equal(config.discordPresence.updateIntervalMs, 3_000);
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)'); assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
assert.equal(config.subtitleStyle.preserveLineBreaks, false); assert.equal(config.subtitleStyle.preserveLineBreaks, false);
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6'); assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)'); assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
assert.equal( 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', () => { test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
const validDir = makeTempDir(); const validDir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(

View File

@@ -4,6 +4,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
subtitleStyle: { subtitleStyle: {
enableJlpt: false, enableJlpt: false,
preserveLineBreaks: false, preserveLineBreaks: false,
autoPauseVideoOnHover: true,
hoverTokenColor: '#f4dbd6', hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)', hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP', fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',

View File

@@ -21,6 +21,13 @@ export function buildSubtitleConfigOptionRegistry(
'Preserve line breaks in visible overlay subtitle rendering. ' + 'Preserve line breaks in visible overlay subtitle rendering. ' +
'When false, line breaks are flattened to spaces for a single-line flow.', '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', path: 'subtitleStyle.hoverTokenColor',
kind: 'string', kind: 'string',

View File

@@ -99,6 +99,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
if (isObject(src.subtitleStyle)) { if (isObject(src.subtitleStyle)) {
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt; const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks; const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
const fallbackSubtitleStyleAutoPauseVideoOnHover =
resolved.subtitleStyle.autoPauseVideoOnHover;
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor; const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
const fallbackSubtitleStyleHoverTokenBackgroundColor = const fallbackSubtitleStyleHoverTokenBackgroundColor =
resolved.subtitleStyle.hoverTokenBackgroundColor; 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( const hoverTokenColor = asColor(
(src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor, (src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor,
); );

View File

@@ -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', () => { test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
const valid = createResolveContext({ const valid = createResolveContext({
subtitleStyle: { subtitleStyle: {

View File

@@ -45,6 +45,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
tokenizeCurrentSubtitle: async () => null, tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '', getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '', getCurrentSubtitleAss: () => '',
getPlaybackPaused: () => true,
getSubtitlePosition: () => null, getSubtitlePosition: () => null,
getSubtitleStyle: () => null, getSubtitleStyle: () => null,
saveSubtitlePosition: () => {}, saveSubtitlePosition: () => {},
@@ -89,6 +90,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
message: 'done', message: 'done',
}); });
assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']); assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
assert.equal(deps.getPlaybackPaused(), true);
}); });
test('registerIpcHandlers rejects malformed runtime-option payloads', async () => { test('registerIpcHandlers rejects malformed runtime-option payloads', async () => {
@@ -106,6 +108,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
tokenizeCurrentSubtitle: async () => null, tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '', getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '', getCurrentSubtitleAss: () => '',
getPlaybackPaused: () => null,
getSubtitlePosition: () => null, getSubtitlePosition: () => null,
getSubtitleStyle: () => null, getSubtitleStyle: () => null,
saveSubtitlePosition: () => {}, saveSubtitlePosition: () => {},
@@ -166,6 +169,10 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
}); });
await cycleHandler!({}, 'anki.kikuFieldGrouping', -1); await cycleHandler!({}, 'anki.kikuFieldGrouping', -1);
assert.deepEqual(cycles, [{ id: 'anki.kikuFieldGrouping', direction: -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', () => { test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
@@ -189,6 +196,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
tokenizeCurrentSubtitle: async () => null, tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '', getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '', getCurrentSubtitleAss: () => '',
getPlaybackPaused: () => false,
getSubtitlePosition: () => null, getSubtitlePosition: () => null,
getSubtitleStyle: () => null, getSubtitleStyle: () => null,
saveSubtitlePosition: (position) => { saveSubtitlePosition: (position) => {

View File

@@ -29,6 +29,7 @@ export interface IpcServiceDeps {
tokenizeCurrentSubtitle: () => Promise<unknown>; tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string; getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string; getCurrentSubtitleAss: () => string;
getPlaybackPaused: () => boolean | null;
getSubtitlePosition: () => unknown; getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown; getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: SubtitlePosition) => void; saveSubtitlePosition: (position: SubtitlePosition) => void;
@@ -96,6 +97,7 @@ export interface IpcDepsRuntimeOptions {
tokenizeCurrentSubtitle: () => Promise<unknown>; tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string; getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string; getCurrentSubtitleAss: () => string;
getPlaybackPaused: () => boolean | null;
getSubtitlePosition: () => unknown; getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown; getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: SubtitlePosition) => void; saveSubtitlePosition: (position: SubtitlePosition) => void;
@@ -136,6 +138,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle, tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw, getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
getCurrentSubtitleAss: options.getCurrentSubtitleAss, getCurrentSubtitleAss: options.getCurrentSubtitleAss,
getPlaybackPaused: options.getPlaybackPaused,
getSubtitlePosition: options.getSubtitlePosition, getSubtitlePosition: options.getSubtitlePosition,
getSubtitleStyle: options.getSubtitleStyle, getSubtitleStyle: options.getSubtitleStyle,
saveSubtitlePosition: options.saveSubtitlePosition, saveSubtitlePosition: options.saveSubtitlePosition,
@@ -232,6 +235,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getCurrentSubtitleAss(); return deps.getCurrentSubtitleAss();
}); });
ipc.handle(IPC_CHANNELS.request.getPlaybackPaused, () => {
return deps.getPlaybackPaused();
});
ipc.handle(IPC_CHANNELS.request.getSubtitlePosition, () => { ipc.handle(IPC_CHANNELS.request.getSubtitlePosition, () => {
return deps.getSubtitlePosition(); return deps.getSubtitlePosition();
}); });

View File

@@ -2880,6 +2880,7 @@ const {
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText), tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
getCurrentSubtitleRaw: () => appState.currentSubText, getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText, getCurrentSubtitleAss: () => appState.currentSubAssText,
getPlaybackPaused: () => appState.playbackPaused,
getSubtitlePosition: () => loadSubtitlePosition(), getSubtitlePosition: () => loadSubtitlePosition(),
getSubtitleStyle: () => { getSubtitleStyle: () => {
const resolvedConfig = getResolvedConfig(); const resolvedConfig = getResolvedConfig();

View File

@@ -63,6 +63,7 @@ export interface MainIpcRuntimeServiceDepsParams {
tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle']; tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle'];
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw']; getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss']; getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow']; focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition']; getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
getSubtitleStyle: IpcDepsRuntimeOptions['getSubtitleStyle']; getSubtitleStyle: IpcDepsRuntimeOptions['getSubtitleStyle'];
@@ -198,6 +199,7 @@ export function createMainIpcRuntimeServiceDeps(
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle, tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw, getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
getCurrentSubtitleAss: params.getCurrentSubtitleAss, getCurrentSubtitleAss: params.getCurrentSubtitleAss,
getPlaybackPaused: params.getPlaybackPaused,
getSubtitlePosition: params.getSubtitlePosition, getSubtitlePosition: params.getSubtitlePosition,
getSubtitleStyle: params.getSubtitleStyle, getSubtitleStyle: params.getSubtitleStyle,
saveSubtitlePosition: params.saveSubtitlePosition, saveSubtitlePosition: params.saveSubtitlePosition,

View File

@@ -42,6 +42,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
tokenizeCurrentSubtitle: async () => null, tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '', getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '', getCurrentSubtitleAss: () => '',
getPlaybackPaused: () => null,
getSubtitlePosition: () => ({}) as never, getSubtitlePosition: () => ({}) as never,
getSubtitleStyle: () => ({}) as never, getSubtitleStyle: () => ({}) as never,
saveSubtitlePosition: () => {}, saveSubtitlePosition: () => {},

View File

@@ -160,6 +160,8 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw), ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw),
getCurrentSubtitleAss: (): Promise<string> => getCurrentSubtitleAss: (): Promise<string> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss), ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss),
getPlaybackPaused: (): Promise<boolean | null> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaybackPaused),
onSubtitleAss: (callback: (assText: string) => void) => { onSubtitleAss: (callback: (assText: string) => void) => {
ipcRenderer.on( ipcRenderer.on(
IPC_CHANNELS.event.subtitleAssSet, IPC_CHANNELS.event.subtitleAssSet,

View File

@@ -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<string>();
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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<boolean | null>();
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, []);
});

View File

@@ -13,9 +13,14 @@ export function createMouseHandlers(
applyYPercent: (yPercent: number) => void; applyYPercent: (yPercent: number) => void;
getCurrentYPercent: () => number; getCurrentYPercent: () => number;
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void; persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
getSubtitleHoverAutoPauseEnabled: () => boolean;
getPlaybackPaused: () => Promise<boolean | null>;
sendMpvCommand: (command: (string | number)[]) => void;
}, },
) { ) {
let yomitanPopupVisible = false; let yomitanPopupVisible = false;
let hoverPauseRequestId = 0;
let pausedBySubtitleHover = false;
function enablePopupInteraction(): void { function enablePopupInteraction(): void {
yomitanPopupVisible = true; yomitanPopupVisible = true;
@@ -29,7 +34,7 @@ export function createMouseHandlers(
} }
function disablePopupInteractionIfIdle(): void { function disablePopupInteractionIfIdle(): void {
if (hasYomitanPopupIframe(document)) { if (typeof document !== 'undefined' && hasYomitanPopupIframe(document)) {
yomitanPopupVisible = true; yomitanPopupVisible = true;
return; return;
} }
@@ -43,16 +48,41 @@ export function createMouseHandlers(
} }
} }
function handleMouseEnter(): void { async function handleMouseEnter(): Promise<void> {
ctx.state.isOverSubtitle = true; ctx.state.isOverSubtitle = true;
ctx.dom.overlay.classList.add('interactive'); ctx.dom.overlay.classList.add('interactive');
if (ctx.platform.shouldToggleMouseIgnore) { if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false); 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 { function handleMouseLeave(): void {
ctx.state.isOverSubtitle = false; ctx.state.isOverSubtitle = false;
hoverPauseRequestId += 1;
if (pausedBySubtitleHover) {
options.sendMpvCommand(['set_property', 'pause', 'no']);
pausedBySubtitleHover = false;
}
if (yomitanPopupVisible) return; if (yomitanPopupVisible) return;
disablePopupInteractionIfIdle(); disablePopupInteractionIfIdle();
} }

View File

@@ -120,6 +120,11 @@ const mouseHandlers = createMouseHandlers(ctx, {
applyYPercent: positioning.applyYPercent, applyYPercent: positioning.applyYPercent,
getCurrentYPercent: positioning.getCurrentYPercent, getCurrentYPercent: positioning.getCurrentYPercent,
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch, persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
getSubtitleHoverAutoPauseEnabled: () => ctx.state.autoPauseVideoOnSubtitleHover,
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
sendMpvCommand: (command) => {
window.electronAPI.sendMpvCommand(command);
},
}); });
let lastSubtitlePreview = ''; let lastSubtitlePreview = '';

View File

@@ -64,6 +64,7 @@ export type RendererState = {
jlptN4Color: string; jlptN4Color: string;
jlptN5Color: string; jlptN5Color: string;
preserveSubtitleLineBreaks: boolean; preserveSubtitleLineBreaks: boolean;
autoPauseVideoOnSubtitleHover: boolean;
frequencyDictionaryEnabled: boolean; frequencyDictionaryEnabled: boolean;
frequencyDictionaryTopX: number; frequencyDictionaryTopX: number;
frequencyDictionaryMode: 'single' | 'banded'; frequencyDictionaryMode: 'single' | 'banded';
@@ -126,6 +127,7 @@ export function createRendererState(): RendererState {
jlptN4Color: '#a6e3a1', jlptN4Color: '#a6e3a1',
jlptN5Color: '#8aadf4', jlptN5Color: '#8aadf4',
preserveSubtitleLineBreaks: false, preserveSubtitleLineBreaks: false,
autoPauseVideoOnSubtitleHover: false,
frequencyDictionaryEnabled: false, frequencyDictionaryEnabled: false,
frequencyDictionaryTopX: 1000, frequencyDictionaryTopX: 1000,
frequencyDictionaryMode: 'single', frequencyDictionaryMode: 'single',

View File

@@ -613,6 +613,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
ctx.state.jlptN4Color = jlptColors.N4; ctx.state.jlptN4Color = jlptColors.N4;
ctx.state.jlptN5Color = jlptColors.N5; ctx.state.jlptN5Color = jlptColors.N5;
ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false; 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-n1-color', jlptColors.N1);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2); ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3); ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3);

View File

@@ -26,6 +26,7 @@ export const IPC_CHANNELS = {
getCurrentSubtitle: 'get-current-subtitle', getCurrentSubtitle: 'get-current-subtitle',
getCurrentSubtitleRaw: 'get-current-subtitle-raw', getCurrentSubtitleRaw: 'get-current-subtitle-raw',
getCurrentSubtitleAss: 'get-current-subtitle-ass', getCurrentSubtitleAss: 'get-current-subtitle-ass',
getPlaybackPaused: 'get-playback-paused',
getSubtitlePosition: 'get-subtitle-position', getSubtitlePosition: 'get-subtitle-position',
getSubtitleStyle: 'get-subtitle-style', getSubtitleStyle: 'get-subtitle-style',
getMecabStatus: 'get-mecab-status', getMecabStatus: 'get-mecab-status',

View File

@@ -287,6 +287,7 @@ export interface AnkiConnectConfig {
export interface SubtitleStyleConfig { export interface SubtitleStyleConfig {
enableJlpt?: boolean; enableJlpt?: boolean;
preserveLineBreaks?: boolean; preserveLineBreaks?: boolean;
autoPauseVideoOnHover?: boolean;
hoverTokenColor?: string; hoverTokenColor?: string;
hoverTokenBackgroundColor?: string; hoverTokenBackgroundColor?: string;
fontFamily?: string; fontFamily?: string;
@@ -798,6 +799,7 @@ export interface ElectronAPI {
getCurrentSubtitle: () => Promise<SubtitleData>; getCurrentSubtitle: () => Promise<SubtitleData>;
getCurrentSubtitleRaw: () => Promise<string>; getCurrentSubtitleRaw: () => Promise<string>;
getCurrentSubtitleAss: () => Promise<string>; getCurrentSubtitleAss: () => Promise<string>;
getPlaybackPaused: () => Promise<boolean | null>;
onSubtitleAss: (callback: (assText: string) => void) => void; onSubtitleAss: (callback: (assText: string) => void) => void;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
openYomitanSettings: () => void; openYomitanSettings: () => void;