diff --git a/backlog/tasks/task-295 - Add-primary-subtitle-visibility-keybinding.md b/backlog/tasks/task-295 - Add-primary-subtitle-visibility-keybinding.md new file mode 100644 index 00000000..844b22d2 --- /dev/null +++ b/backlog/tasks/task-295 - Add-primary-subtitle-visibility-keybinding.md @@ -0,0 +1,66 @@ +--- +id: TASK-295 +title: Add primary subtitle visibility keybinding +status: Done +assignee: + - Codex +created_date: '2026-04-25 23:09' +updated_date: '2026-04-25 23:45' +labels: + - renderer + - keybindings + - subtitles +dependencies: [] +priority: medium +--- + +## Description + + +Add a `v` keybinding that overrides mpv's default `v` subtitle visibility toggle and instead toggles SubMiner's primary subtitle bar visibility on and off. Secondary subtitle hover behavior is out of scope. + + +## Acceptance Criteria + +- [x] #1 Pressing `v` toggles the primary subtitle bar from visible to hidden. +- [x] #2 Pressing `v` again restores the primary subtitle bar visibility. +- [x] #3 The keybinding does not add or change secondary subtitle hover behavior. +- [x] #4 Relevant automated coverage verifies the toggle behavior. +- [x] #5 Pressing `v` in the mpv/plugin keybinding path also toggles the primary subtitle bar visibility instead of mpv native subtitle visibility. + + +## Implementation Plan + + +1. Inspect existing renderer keybinding and subtitle bar visibility code, including current local edits in touched files. +2. Add a focused failing test for `v` toggling primary subtitle bar visibility without changing secondary hover behavior. +3. Implement the minimal renderer/keybinding change. +4. Run targeted tests and update acceptance criteria/final notes. + + +## Implementation Notes + + +Implemented renderer-local `KeyV` handling before session/mpv binding dispatch so mpv `sub-visibility` is not touched. Visibility state is stored in renderer state and applied via `primary-sub-hidden` class on the primary subtitle container. + +Scope updated after user clarified the toggle must work when focus is in mpv as well as in the overlay renderer. Added a forced mpv plugin binding for `v` that runs `--toggle-primary-subtitle-bar`, then broadcasts a renderer IPC toggle event and reuses the same primary subtitle bar toggle path. + + +## Final Summary + + +Summary: +- Added a renderer-local `v` key handler that toggles primary subtitle bar visibility by adding/removing `primary-sub-hidden` on the primary subtitle container. +- Added renderer state for the toggle so repeated presses restore the bar without issuing mpv `sub-visibility` commands. +- Added a forced mpv plugin `v` binding that invokes `--toggle-primary-subtitle-bar` and broadcasts the same renderer toggle event. +- Added CSS for the hidden primary subtitle bar state and regression coverage for both overlay and mpv/plugin entry points. + +Tests: +- `bun test src/renderer/handlers/keyboard.test.ts --test-name-pattern "primary subtitle visibility key"` +- `bun test src/cli/args.test.ts --test-name-pattern "session action"` +- `bun test src/core/services/cli-command.test.ts --test-name-pattern "visibility and utility"` +- `bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/main/runtime/cli-command-context.test.ts src/main/runtime/cli-command-context-deps.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/cli-command-context-factory.test.ts src/main/runtime/composers/cli-startup-composer.test.ts src/main/runtime/first-run-setup-service.test.ts` +- `lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-lua-compat.lua` +- `bun run typecheck` +- `bun run test:fast` + diff --git a/changes/295-primary-subtitle-bar-toggle.md b/changes/295-primary-subtitle-bar-toggle.md new file mode 100644 index 00000000..50ccca36 --- /dev/null +++ b/changes/295-primary-subtitle-bar-toggle.md @@ -0,0 +1,4 @@ +type: added +area: overlay + +- Added a `V` shortcut and mpv plugin binding to toggle the SubMiner primary subtitle bar without changing mpv native subtitle visibility. diff --git a/docs-site/changelog.md b/docs-site/changelog.md index a95b4718..46f91472 100644 --- a/docs-site/changelog.md +++ b/docs-site/changelog.md @@ -5,6 +5,7 @@ **Changed** - Overlay: Added configurable overlay shortcuts for session help, controller select, and controller debug actions. - Overlay: Added mpv/plugin and CLI routing for session help, controller utilities, and subtitle sidebar toggling through the shared session-action path. +- Overlay: Added a `V` shortcut and mpv plugin binding to toggle the SubMiner primary subtitle bar instead of mpv's native primary subtitle visibility. - Overlay: Improved dedicated overlay modal retry and focus handling for runtime options, Jimaku, session help, controller tools, and the playlist browser. - Overlay: Fixed controller configuration and controller debug shortcut opens so configured bindings bring up their modals again instead of tripping renderer recovery. - Stats: Sessions are rolled up per episode within each day, with a bulk delete that wipes every session in the group. diff --git a/docs-site/mpv-plugin.md b/docs-site/mpv-plugin.md index 8b24476c..46703204 100644 --- a/docs-site/mpv-plugin.md +++ b/docs-site/mpv-plugin.md @@ -41,11 +41,14 @@ All keybindings use a `y` chord prefix — press `y`, then the second key: | `y-s` | Start overlay | | `y-S` | Stop overlay | | `y-t` | Toggle visible overlay | +| `v` | Toggle primary subtitle bar visibility | | `y-o` | Open settings window | | `y-r` | Restart overlay | | `y-c` | Check status | | `y-k` | Skip intro (AniSkip) | +The bare `v` binding is a forced mpv binding. It overrides mpv's default primary subtitle visibility toggle and routes the action to SubMiner's primary subtitle bar instead. + ## Menu Press `y-y` to open an interactive menu in mpv's OSD: diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index d841ec45..b37a0e8c 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -38,6 +38,7 @@ These control playback and subtitle display. They require overlay window focus. | Shortcut | Action | | -------------------- | --------------------------------------------------- | | `Space` | Toggle mpv pause | +| `V` | Toggle primary subtitle bar visibility | | `J` | Cycle primary subtitle track | | `Shift+J` | Cycle secondary subtitle track | | `Ctrl+Alt+P` | Open playlist browser for current directory + queue | @@ -100,11 +101,14 @@ When the mpv plugin is installed, all commands use a `y` chord prefix — press | `y-s` | Start overlay | | `y-S` | Stop overlay | | `y-t` | Toggle visible overlay | +| `v` | Toggle primary subtitle bar visibility | | `y-o` | Open Yomitan settings | | `y-r` | Restart overlay | | `y-c` | Check overlay status | | `y-h` | Open session help | +The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so the SubMiner primary subtitle bar is hidden or restored instead. + When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper). ## Drag-and-Drop diff --git a/docs-site/usage.md b/docs-site/usage.md index 8dccb06b..6f8b9f9e 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -114,6 +114,7 @@ SubMiner.AppImage --stop # Stop overlay SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility SubMiner.AppImage --show-visible-overlay # Force show visible overlay SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay +SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle bar visibility SubMiner.AppImage --start --dev # Enable app/dev mode only SubMiner.AppImage --start --debug # Alias for --dev SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode @@ -327,6 +328,8 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback. +Press `V` to hide or restore the primary SubMiner subtitle bar. The mpv plugin also binds bare `v` to the same action, overriding mpv's native primary subtitle visibility toggle. + `Ctrl/Cmd+Shift+H` opens the session help modal with the current overlay and mpv keybindings. If you use the mpv plugin, the same help view is also available through the `y-h` chord. Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior. diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index e3046187..eb4e125d 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -462,6 +462,21 @@ function M.create(ctx) end) end + local function toggle_primary_subtitle_bar() + if not binary.ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + + run_control_command_async("toggle-primary-subtitle-bar", nil, function(ok) + if not ok then + subminer_log("warn", "process", "Primary subtitle bar toggle command failed") + show_osd("Primary subtitle toggle failed") + end + end) + end + local function open_options() if not binary.ensure_binary_available() then subminer_log("error", "binary", "SubMiner binary not found") @@ -552,6 +567,7 @@ function M.create(ctx) stop_overlay = stop_overlay, hide_visible_overlay = hide_visible_overlay, toggle_overlay = toggle_overlay, + toggle_primary_subtitle_bar = toggle_primary_subtitle_bar, open_options = open_options, restart_overlay = restart_overlay, check_status = check_status, diff --git a/plugin/subminer/ui.lua b/plugin/subminer/ui.lua index 92cbabb5..6514aa6d 100644 --- a/plugin/subminer/ui.lua +++ b/plugin/subminer/ui.lua @@ -80,6 +80,9 @@ function M.create(ctx) mp.add_key_binding("y-t", "subminer-toggle", function() process.toggle_overlay() end) + mp.add_forced_key_binding("v", "subminer-toggle-primary-subtitle-bar", function() + process.toggle_primary_subtitle_bar() + end) mp.add_key_binding("y-y", "subminer-menu", show_menu) mp.add_key_binding("y-o", "subminer-options", function() process.open_options() diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 7f64814a..67893c0f 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -151,6 +151,14 @@ local function run_plugin_scenario(config) fn = fn, } end + function mp.add_forced_key_binding(keys, name, fn) + recorded.key_bindings[#recorded.key_bindings + 1] = { + keys = keys, + name = name, + fn = fn, + forced = true, + } + end function mp.register_event(name, fn) if recorded.events[name] == nil then recorded.events[name] = {} @@ -537,6 +545,39 @@ do ) end +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "no", + }, + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for primary subtitle bar binding scenario: " .. tostring(err)) + local binding = nil + for _, candidate in ipairs(recorded.key_bindings) do + if candidate.name == "subminer-toggle-primary-subtitle-bar" then + binding = candidate + break + end + end + assert_true(binding ~= nil, "primary subtitle bar v binding should be registered") + assert_true(binding.keys == "v", "primary subtitle bar binding should use bare v") + assert_true(binding.forced == true, "primary subtitle bar binding should override mpv's built-in v binding") + binding.fn() + assert_true( + count_control_calls(recorded.async_calls, "--toggle-primary-subtitle-bar") == 1, + "primary subtitle bar binding should issue primary subtitle toggle command" + ) + assert_true( + count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0, + "primary subtitle bar binding should not toggle the whole visible overlay" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 7a67b7c5..58942e16 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -79,6 +79,7 @@ test('parseArgs captures session action forwarding flags', () => { '--open-jimaku', '--open-youtube-picker', '--open-playlist-browser', + '--toggle-primary-subtitle-bar', '--replay-current-subtitle', '--play-next-subtitle', '--shift-sub-delay-prev-line', @@ -94,6 +95,7 @@ test('parseArgs captures session action forwarding flags', () => { assert.equal(args.openJimaku, true); assert.equal(args.openYoutubePicker, true); assert.equal(args.openPlaylistBrowser, true); + assert.equal(args.togglePrimarySubtitleBar, true); assert.equal(args.replayCurrentSubtitle, true); assert.equal(args.playNextSubtitle, true); assert.equal(args.shiftSubDelayPrevLine, true); diff --git a/src/cli/args.ts b/src/cli/args.ts index 473a830e..77aa079d 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -8,6 +8,7 @@ export interface CliArgs { stop: boolean; toggle: boolean; toggleVisibleOverlay: boolean; + togglePrimarySubtitleBar: boolean; settings: boolean; setup: boolean; show: boolean; @@ -106,6 +107,7 @@ export function parseArgs(argv: string[]): CliArgs { stop: false, toggle: false, toggleVisibleOverlay: false, + togglePrimarySubtitleBar: false, settings: false, setup: false, show: false, @@ -219,6 +221,7 @@ export function parseArgs(argv: string[]): CliArgs { } else if (arg === '--stop') args.stop = true; else if (arg === '--toggle') args.toggle = true; else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true; + else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true; else if (arg === '--settings' || arg === '--yomitan') args.settings = true; else if (arg === '--setup') args.setup = true; else if (arg === '--show') args.show = true; @@ -456,6 +459,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.stop || args.toggle || args.toggleVisibleOverlay || + args.togglePrimarySubtitleBar || args.settings || args.setup || args.show || @@ -526,6 +530,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.stop && !args.toggle && !args.toggleVisibleOverlay && + !args.togglePrimarySubtitleBar && !args.settings && !args.setup && !args.show && @@ -591,6 +596,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.launchMpv || args.toggle || args.toggleVisibleOverlay || + args.togglePrimarySubtitleBar || args.settings || args.setup || args.copySubtitle || @@ -644,6 +650,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.stop && !args.toggle && !args.toggleVisibleOverlay && + !args.togglePrimarySubtitleBar && !args.show && !args.hide && !args.setup && @@ -707,6 +714,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean { return ( args.toggle || args.toggleVisibleOverlay || + args.togglePrimarySubtitleBar || args.show || args.hide || args.showVisibleOverlay || diff --git a/src/cli/help.ts b/src/cli/help.ts index 203627f5..83e70992 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -19,6 +19,7 @@ ${B}Session${R} ${B}Overlay${R} --toggle-visible-overlay Toggle subtitle overlay + --toggle-primary-subtitle-bar Toggle primary subtitle bar --show-visible-overlay Show subtitle overlay --hide-visible-overlay Hide subtitle overlay --settings Open Yomitan settings window diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index 5df865c6..d01b921b 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -12,6 +12,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { stop: false, toggle: false, toggleVisibleOverlay: false, + togglePrimarySubtitleBar: false, settings: false, setup: false, show: false, diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 2e3e4268..098d9024 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -40,6 +40,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { openJimaku: false, openYoutubePicker: false, openPlaylistBrowser: false, + togglePrimarySubtitleBar: false, replayCurrentSubtitle: false, playNextSubtitle: false, shiftSubDelayPrevLine: false, @@ -119,6 +120,9 @@ function createDeps(overrides: Partial = {}) { toggleVisibleOverlay: () => { calls.push('toggleVisibleOverlay'); }, + togglePrimarySubtitleBar: () => { + calls.push('togglePrimarySubtitleBar'); + }, openYomitanSettingsDelayed: (delayMs) => { calls.push(`openYomitanSettingsDelayed:${delayMs}`); }, @@ -533,6 +537,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () => expected: 'startPendingMineSentenceMultiple:2500', }, { args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' }, + { args: { togglePrimarySubtitleBar: true }, expected: 'togglePrimarySubtitleBar' }, { args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' }, { args: { openRuntimeOptions: true }, diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 6c2974b6..ea3402cf 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -40,6 +40,7 @@ export interface CliCommandServiceDeps { isOverlayRuntimeInitialized: () => boolean; initializeOverlayRuntime: () => void; toggleVisibleOverlay: () => void; + togglePrimarySubtitleBar: () => void; openFirstRunSetup: () => void; openYomitanSettingsDelayed: (delayMs: number) => void; setVisibleOverlayVisible: (visible: boolean) => void; @@ -138,6 +139,7 @@ interface OverlayCliRuntime { isInitialized: () => boolean; initialize: () => void; toggleVisible: () => void; + togglePrimarySubtitleBar: () => void; setVisible: (visible: boolean) => void; } @@ -244,6 +246,7 @@ export function createCliCommandDepsRuntime( isOverlayRuntimeInitialized: options.overlay.isInitialized, initializeOverlayRuntime: options.overlay.initialize, toggleVisibleOverlay: options.overlay.toggleVisible, + togglePrimarySubtitleBar: options.overlay.togglePrimarySubtitleBar, openFirstRunSetup: options.ui.openFirstRunSetup, openYomitanSettingsDelayed: (delayMs) => { options.schedule(() => { @@ -369,6 +372,8 @@ export function handleCliCommand( if (args.toggle || args.toggleVisibleOverlay) { deps.toggleVisibleOverlay(); + } else if (args.togglePrimarySubtitleBar) { + deps.togglePrimarySubtitleBar(); } else if (args.setup) { deps.openFirstRunSetup(); deps.log('Opened first-run setup flow.'); diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index 4edf10ff..639aa338 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -12,6 +12,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { stop: false, toggle: false, toggleVisibleOverlay: false, + togglePrimarySubtitleBar: false, settings: false, setup: false, show: false, diff --git a/src/main.ts b/src/main.ts index bdbe427b..2e115b7d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4345,6 +4345,10 @@ function toggleSubtitleSidebar(): void { broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle); } +function togglePrimarySubtitleBar(): void { + broadcastToOverlayWindows(IPC_CHANNELS.event.primarySubtitleBarToggle); +} + async function triggerSubsyncFromConfig(): Promise { await subsyncRuntime.triggerFromConfig(); } @@ -4919,6 +4923,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ showMpvOsd: (text: string) => showMpvOsd(text), initializeOverlayRuntime: () => initializeOverlayRuntime(), toggleVisibleOverlay: () => toggleVisibleOverlay(), + togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(), openFirstRunSetupWindow: () => openFirstRunSetupWindow(), setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), copyCurrentSubtitle: () => copyCurrentSubtitle(), diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index ae579c30..4d687ea1 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -19,6 +19,7 @@ export interface CliCommandRuntimeServiceContext { isOverlayInitialized: () => boolean; initializeOverlay: () => void; toggleVisibleOverlay: () => void; + togglePrimarySubtitleBar: () => void; openFirstRunSetup: () => void; setVisibleOverlay: (visible: boolean) => void; copyCurrentSubtitle: () => void; @@ -83,6 +84,7 @@ function createCliCommandDepsFromContext( isInitialized: context.isOverlayInitialized, initialize: context.initializeOverlay, toggleVisible: context.toggleVisibleOverlay, + togglePrimarySubtitleBar: context.togglePrimarySubtitleBar, setVisible: context.setVisibleOverlay, }, mining: { diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index a2ff3f02..24672ea8 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -149,6 +149,7 @@ export interface CliCommandRuntimeServiceDepsParams { isInitialized: CliCommandDepsRuntimeOptions['overlay']['isInitialized']; initialize: CliCommandDepsRuntimeOptions['overlay']['initialize']; toggleVisible: CliCommandDepsRuntimeOptions['overlay']['toggleVisible']; + togglePrimarySubtitleBar: CliCommandDepsRuntimeOptions['overlay']['togglePrimarySubtitleBar']; setVisible: CliCommandDepsRuntimeOptions['overlay']['setVisible']; }; mining: { @@ -325,6 +326,7 @@ export function createCliCommandRuntimeServiceDeps( isInitialized: params.overlay.isInitialized, initialize: params.overlay.initialize, toggleVisible: params.overlay.toggleVisible, + togglePrimarySubtitleBar: params.overlay.togglePrimarySubtitleBar, setVisible: params.overlay.setVisible, }, mining: { diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts index d6bfe9c7..3be3aeeb 100644 --- a/src/main/runtime/cli-command-context-deps.test.ts +++ b/src/main/runtime/cli-command-context-deps.test.ts @@ -19,6 +19,7 @@ test('build cli command context deps maps handlers and values', () => { isOverlayInitialized: () => true, initializeOverlay: () => calls.push('init'), toggleVisibleOverlay: () => calls.push('toggle-visible'), + togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'), openFirstRunSetup: () => calls.push('setup'), setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`), copyCurrentSubtitle: () => calls.push('copy'), diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index 380489c7..37d4fd0d 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -17,6 +17,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { isOverlayInitialized: () => boolean; initializeOverlay: () => void; toggleVisibleOverlay: () => void; + togglePrimarySubtitleBar: () => void; openFirstRunSetup: () => void; setVisibleOverlay: (visible: boolean) => void; copyCurrentSubtitle: () => void; @@ -69,6 +70,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { isOverlayInitialized: deps.isOverlayInitialized, initializeOverlay: deps.initializeOverlay, toggleVisibleOverlay: deps.toggleVisibleOverlay, + togglePrimarySubtitleBar: deps.togglePrimarySubtitleBar, openFirstRunSetup: deps.openFirstRunSetup, setVisibleOverlay: deps.setVisibleOverlay, copyCurrentSubtitle: deps.copyCurrentSubtitle, diff --git a/src/main/runtime/cli-command-context-factory.test.ts b/src/main/runtime/cli-command-context-factory.test.ts index cb826a4c..763a80d6 100644 --- a/src/main/runtime/cli-command-context-factory.test.ts +++ b/src/main/runtime/cli-command-context-factory.test.ts @@ -26,6 +26,7 @@ test('cli command context factory composes main deps and context handlers', () = showMpvOsd: (text) => calls.push(`osd:${text}`), initializeOverlayRuntime: () => calls.push('init-overlay'), toggleVisibleOverlay: () => calls.push('toggle-visible'), + togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'), openFirstRunSetupWindow: () => calls.push('setup'), setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), copyCurrentSubtitle: () => calls.push('copy-sub'), diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index 6644283f..604c997a 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -29,6 +29,7 @@ test('cli command context main deps builder maps state and callbacks', async () initializeOverlayRuntime: () => calls.push('init-overlay'), toggleVisibleOverlay: () => calls.push('toggle-visible'), + togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'), openFirstRunSetupWindow: () => calls.push('open-setup'), setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index 4ec2913b..8c51bd77 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -27,6 +27,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { initializeOverlayRuntime: () => void; toggleVisibleOverlay: () => void; + togglePrimarySubtitleBar: () => void; openFirstRunSetupWindow: () => void; setVisibleOverlayVisible: (visible: boolean) => void; @@ -94,6 +95,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized, initializeOverlay: () => deps.initializeOverlayRuntime(), toggleVisibleOverlay: () => deps.toggleVisibleOverlay(), + togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(), openFirstRunSetup: () => deps.openFirstRunSetupWindow(), setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible), copyCurrentSubtitle: () => deps.copyCurrentSubtitle(), diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts index 209a8b79..995d4271 100644 --- a/src/main/runtime/cli-command-context.test.ts +++ b/src/main/runtime/cli-command-context.test.ts @@ -25,6 +25,7 @@ function createDeps() { isOverlayInitialized: () => true, initializeOverlay: () => {}, toggleVisibleOverlay: () => {}, + togglePrimarySubtitleBar: () => {}, openFirstRunSetup: () => {}, setVisibleOverlay: () => {}, copyCurrentSubtitle: () => {}, diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index b924b3eb..83750d3b 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -22,6 +22,7 @@ export type CliCommandContextFactoryDeps = { isOverlayInitialized: () => boolean; initializeOverlay: () => void; toggleVisibleOverlay: () => void; + togglePrimarySubtitleBar: () => void; openFirstRunSetup: () => void; setVisibleOverlay: (visible: boolean) => void; copyCurrentSubtitle: () => void; @@ -81,6 +82,7 @@ export function createCliCommandContext( isOverlayInitialized: deps.isOverlayInitialized, initializeOverlay: deps.initializeOverlay, toggleVisibleOverlay: deps.toggleVisibleOverlay, + togglePrimarySubtitleBar: deps.togglePrimarySubtitleBar, openFirstRunSetup: deps.openFirstRunSetup, setVisibleOverlay: deps.setVisibleOverlay, copyCurrentSubtitle: deps.copyCurrentSubtitle, diff --git a/src/main/runtime/composers/cli-startup-composer.test.ts b/src/main/runtime/composers/cli-startup-composer.test.ts index 50c1cab4..57c2d1a8 100644 --- a/src/main/runtime/composers/cli-startup-composer.test.ts +++ b/src/main/runtime/composers/cli-startup-composer.test.ts @@ -19,6 +19,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => { showMpvOsd: () => {}, initializeOverlayRuntime: () => {}, toggleVisibleOverlay: () => {}, + togglePrimarySubtitleBar: () => {}, openFirstRunSetupWindow: () => {}, setVisibleOverlayVisible: () => {}, copyCurrentSubtitle: () => {}, diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index a56d0da7..204225de 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -26,6 +26,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { stop: false, toggle: false, toggleVisibleOverlay: false, + togglePrimarySubtitleBar: false, settings: false, setup: false, show: false, diff --git a/src/main/runtime/first-run-setup-service.ts b/src/main/runtime/first-run-setup-service.ts index 737c801b..b3384c80 100644 --- a/src/main/runtime/first-run-setup-service.ts +++ b/src/main/runtime/first-run-setup-service.ts @@ -60,6 +60,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean { return Boolean( args.toggle || args.toggleVisibleOverlay || + args.togglePrimarySubtitleBar || args.launchMpv || args.settings || args.show || diff --git a/src/preload.ts b/src/preload.ts index 7ce9dda1..8972dda9 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -153,6 +153,9 @@ const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload( IPC_CHANNELS.event.kikuFieldGroupingRequest, @@ -345,6 +348,7 @@ const electronAPI: ElectronAPI = { onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent, onOpenCharacterDictionary: onOpenCharacterDictionaryEvent, onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent, + onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent, onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 322d84bd..13addd2b 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -330,6 +330,7 @@ function installKeyboardTestGlobals() { function createKeyboardHandlerHarness() { const testGlobals = installKeyboardTestGlobals(); const subtitleRootClassList = createClassList(); + const subtitleContainerClassList = createClassList(); let controllerSelectKeydownCount = 0; let openControllerSelectCount = 0; let openControllerDebugCount = 0; @@ -349,6 +350,7 @@ function createKeyboardHandlerHarness() { querySelectorAll: () => wordNodes, }, subtitleContainer: { + classList: subtitleContainerClassList, contains: () => false, }, overlay: testGlobals.overlay, @@ -405,6 +407,26 @@ function createKeyboardHandlerHarness() { }; } +test('primary subtitle visibility key hides and restores the subtitle bar without mpv sub-visibility', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + + testGlobals.dispatchKeydown({ key: 'v', code: 'KeyV' }); + assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hidden'), true); + + testGlobals.dispatchKeydown({ key: 'v', code: 'KeyV' }); + assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hidden'), false); + assert.equal( + testGlobals.mpvCommands.some((command) => command.includes('sub-visibility')), + false, + ); + } finally { + testGlobals.restore(); + } +}); + test('session help chord resolver follows remapped session bindings', async () => { const { handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index dfef40c3..66833f42 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -361,6 +361,27 @@ export function createKeyboardHandlers( ); } + function isPrimarySubtitleVisibilityToggle(e: KeyboardEvent): boolean { + return ( + e.code === 'KeyV' && + !e.ctrlKey && + !e.altKey && + !e.metaKey && + !e.shiftKey && + !e.repeat + ); + } + + function togglePrimarySubtitleBarVisibility(): void { + const visible = !ctx.state.primarySubtitleBarVisible; + ctx.state.primarySubtitleBarVisible = visible; + if (visible) { + ctx.dom.subtitleContainer.classList.remove('primary-sub-hidden'); + } else { + ctx.dom.subtitleContainer.classList.add('primary-sub-hidden'); + } + } + async function handleMarkWatched(): Promise { const marked = await window.electronAPI.markActiveVideoWatched(); if (marked) { @@ -1065,6 +1086,12 @@ export function createKeyboardHandlers( return; } + if (isPrimarySubtitleVisibilityToggle(e)) { + e.preventDefault(); + togglePrimarySubtitleBarVisibility(); + return; + } + if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) { if (handleYomitanPopupKeybind(e)) { e.preventDefault(); @@ -1152,6 +1179,7 @@ export function createKeyboardHandlers( updateSessionBindings, syncKeyboardTokenSelection, handleSubtitleContentUpdated, + togglePrimarySubtitleBarVisibility, handleKeyboardModeToggleRequested, handleLookupWindowToggleRequested, closeLookupWindow, diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 3298468b..5c50f3b5 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -532,6 +532,12 @@ function registerKeyboardCommandHandlers(): void { await subtitleSidebarModal.toggleSubtitleSidebarModal(); }); }); + + window.electronAPI.onPrimarySubtitleBarToggle(() => { + runGuarded('primary-subtitle-bar:toggle', () => { + keyboardHandlers.togglePrimarySubtitleBarVisibility(); + }); + }); } function runGuarded(action: string, fn: () => void): void { diff --git a/src/renderer/state.ts b/src/renderer/state.ts index d80bddf2..47806d2c 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -134,6 +134,7 @@ export type RendererState = { keyboardSelectionVisible: boolean; keyboardSelectedWordIndex: number | null; yomitanPopupVisible: boolean; + primarySubtitleBarVisible: boolean; }; export function createRendererState(): RendererState { @@ -244,5 +245,6 @@ export function createRendererState(): RendererState { keyboardSelectionVisible: false, keyboardSelectedWordIndex: null, yomitanPopupVisible: false, + primarySubtitleBarVisible: true, }; } diff --git a/src/renderer/style.css b/src/renderer/style.css index ac1441e4..cbf7202d 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -678,6 +678,11 @@ body.subtitle-sidebar-embedded-open #subtitleContainer { display: none; } +#subtitleContainer.primary-sub-hidden { + display: none; + pointer-events: none; +} + body.settings-modal-open #subtitleContainer { display: none !important; pointer-events: none !important; diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 576deda2..8fab85ea 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -120,6 +120,7 @@ export const IPC_CHANNELS = { controllerSelectOpen: 'controller-select:open', controllerDebugOpen: 'controller-debug:open', subtitleSidebarToggle: 'subtitle-sidebar:toggle', + primarySubtitleBarToggle: 'primary-subtitle-bar:toggle', configHotReload: 'config:hot-reload', }, } as const; diff --git a/src/types/runtime.ts b/src/types/runtime.ts index a51c6472..022a77c9 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -433,6 +433,7 @@ export interface ElectronAPI { onOpenPlaylistBrowser: (callback: () => void) => void; onOpenCharacterDictionary: (callback: () => void) => void; onSubtitleSidebarToggle: (callback: () => void) => void; + onPrimarySubtitleBarToggle: (callback: () => void) => void; onCancelYoutubeTrackPicker: (callback: () => void) => void; onKeyboardModeToggleRequested: (callback: () => void) => void; onLookupWindowToggleRequested: (callback: () => void) => void;