feat: add primary subtitle bar toggle

This commit is contained in:
2026-04-25 17:09:42 -07:00
parent 055bd76718
commit c9df5b7624
37 changed files with 255 additions and 0 deletions

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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`
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -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.

View File

@@ -5,6 +5,7 @@
**Changed** **Changed**
- Overlay: Added configurable overlay shortcuts for session help, controller select, and controller debug actions. - 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 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: 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. - 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. - Stats: Sessions are rolled up per episode within each day, with a bulk delete that wipes every session in the group.

View File

@@ -41,11 +41,14 @@ All keybindings use a `y` chord prefix — press `y`, then the second key:
| `y-s` | Start overlay | | `y-s` | Start overlay |
| `y-S` | Stop overlay | | `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay | | `y-t` | Toggle visible overlay |
| `v` | Toggle primary subtitle bar visibility |
| `y-o` | Open settings window | | `y-o` | Open settings window |
| `y-r` | Restart overlay | | `y-r` | Restart overlay |
| `y-c` | Check status | | `y-c` | Check status |
| `y-k` | Skip intro (AniSkip) | | `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 ## Menu
Press `y-y` to open an interactive menu in mpv's OSD: Press `y-y` to open an interactive menu in mpv's OSD:

View File

@@ -38,6 +38,7 @@ These control playback and subtitle display. They require overlay window focus.
| Shortcut | Action | | Shortcut | Action |
| -------------------- | --------------------------------------------------- | | -------------------- | --------------------------------------------------- |
| `Space` | Toggle mpv pause | | `Space` | Toggle mpv pause |
| `V` | Toggle primary subtitle bar visibility |
| `J` | Cycle primary subtitle track | | `J` | Cycle primary subtitle track |
| `Shift+J` | Cycle secondary subtitle track | | `Shift+J` | Cycle secondary subtitle track |
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue | | `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` | Start overlay |
| `y-S` | Stop overlay | | `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay | | `y-t` | Toggle visible overlay |
| `v` | Toggle primary subtitle bar visibility |
| `y-o` | Open Yomitan settings | | `y-o` | Open Yomitan settings |
| `y-r` | Restart overlay | | `y-r` | Restart overlay |
| `y-c` | Check overlay status | | `y-c` | Check overlay status |
| `y-h` | Open session help | | `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). When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper).
## Drag-and-Drop ## Drag-and-Drop

View File

@@ -114,6 +114,7 @@ SubMiner.AppImage --stop # Stop overlay
SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
SubMiner.AppImage --show-visible-overlay # Force show visible overlay SubMiner.AppImage --show-visible-overlay # Force show visible overlay
SubMiner.AppImage --hide-visible-overlay # Force hide 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 --dev # Enable app/dev mode only
SubMiner.AppImage --start --debug # Alias for --dev SubMiner.AppImage --start --debug # Alias for --dev
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode 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. 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. `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. 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.

View File

@@ -462,6 +462,21 @@ function M.create(ctx)
end) end)
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() local function open_options()
if not binary.ensure_binary_available() then if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found") subminer_log("error", "binary", "SubMiner binary not found")
@@ -552,6 +567,7 @@ function M.create(ctx)
stop_overlay = stop_overlay, stop_overlay = stop_overlay,
hide_visible_overlay = hide_visible_overlay, hide_visible_overlay = hide_visible_overlay,
toggle_overlay = toggle_overlay, toggle_overlay = toggle_overlay,
toggle_primary_subtitle_bar = toggle_primary_subtitle_bar,
open_options = open_options, open_options = open_options,
restart_overlay = restart_overlay, restart_overlay = restart_overlay,
check_status = check_status, check_status = check_status,

View File

@@ -80,6 +80,9 @@ function M.create(ctx)
mp.add_key_binding("y-t", "subminer-toggle", function() mp.add_key_binding("y-t", "subminer-toggle", function()
process.toggle_overlay() process.toggle_overlay()
end) 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-y", "subminer-menu", show_menu)
mp.add_key_binding("y-o", "subminer-options", function() mp.add_key_binding("y-o", "subminer-options", function()
process.open_options() process.open_options()

View File

@@ -151,6 +151,14 @@ local function run_plugin_scenario(config)
fn = fn, fn = fn,
} }
end 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) function mp.register_event(name, fn)
if recorded.events[name] == nil then if recorded.events[name] == nil then
recorded.events[name] = {} recorded.events[name] = {}
@@ -537,6 +545,39 @@ do
) )
end 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 do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",

View File

@@ -79,6 +79,7 @@ test('parseArgs captures session action forwarding flags', () => {
'--open-jimaku', '--open-jimaku',
'--open-youtube-picker', '--open-youtube-picker',
'--open-playlist-browser', '--open-playlist-browser',
'--toggle-primary-subtitle-bar',
'--replay-current-subtitle', '--replay-current-subtitle',
'--play-next-subtitle', '--play-next-subtitle',
'--shift-sub-delay-prev-line', '--shift-sub-delay-prev-line',
@@ -94,6 +95,7 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(args.openJimaku, true); assert.equal(args.openJimaku, true);
assert.equal(args.openYoutubePicker, true); assert.equal(args.openYoutubePicker, true);
assert.equal(args.openPlaylistBrowser, true); assert.equal(args.openPlaylistBrowser, true);
assert.equal(args.togglePrimarySubtitleBar, true);
assert.equal(args.replayCurrentSubtitle, true); assert.equal(args.replayCurrentSubtitle, true);
assert.equal(args.playNextSubtitle, true); assert.equal(args.playNextSubtitle, true);
assert.equal(args.shiftSubDelayPrevLine, true); assert.equal(args.shiftSubDelayPrevLine, true);

View File

@@ -8,6 +8,7 @@ export interface CliArgs {
stop: boolean; stop: boolean;
toggle: boolean; toggle: boolean;
toggleVisibleOverlay: boolean; toggleVisibleOverlay: boolean;
togglePrimarySubtitleBar: boolean;
settings: boolean; settings: boolean;
setup: boolean; setup: boolean;
show: boolean; show: boolean;
@@ -106,6 +107,7 @@ export function parseArgs(argv: string[]): CliArgs {
stop: false, stop: false,
toggle: false, toggle: false,
toggleVisibleOverlay: false, toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
settings: false, settings: false,
setup: false, setup: false,
show: false, show: false,
@@ -219,6 +221,7 @@ export function parseArgs(argv: string[]): CliArgs {
} else if (arg === '--stop') args.stop = true; } else if (arg === '--stop') args.stop = true;
else if (arg === '--toggle') args.toggle = true; else if (arg === '--toggle') args.toggle = true;
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = 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 === '--settings' || arg === '--yomitan') args.settings = true;
else if (arg === '--setup') args.setup = true; else if (arg === '--setup') args.setup = true;
else if (arg === '--show') args.show = true; else if (arg === '--show') args.show = true;
@@ -456,6 +459,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.stop || args.stop ||
args.toggle || args.toggle ||
args.toggleVisibleOverlay || args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.settings || args.settings ||
args.setup || args.setup ||
args.show || args.show ||
@@ -526,6 +530,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.stop && !args.stop &&
!args.toggle && !args.toggle &&
!args.toggleVisibleOverlay && !args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.settings && !args.settings &&
!args.setup && !args.setup &&
!args.show && !args.show &&
@@ -591,6 +596,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.launchMpv || args.launchMpv ||
args.toggle || args.toggle ||
args.toggleVisibleOverlay || args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.settings || args.settings ||
args.setup || args.setup ||
args.copySubtitle || args.copySubtitle ||
@@ -644,6 +650,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.stop && !args.stop &&
!args.toggle && !args.toggle &&
!args.toggleVisibleOverlay && !args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.show && !args.show &&
!args.hide && !args.hide &&
!args.setup && !args.setup &&
@@ -707,6 +714,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
return ( return (
args.toggle || args.toggle ||
args.toggleVisibleOverlay || args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.show || args.show ||
args.hide || args.hide ||
args.showVisibleOverlay || args.showVisibleOverlay ||

View File

@@ -19,6 +19,7 @@ ${B}Session${R}
${B}Overlay${R} ${B}Overlay${R}
--toggle-visible-overlay Toggle subtitle overlay --toggle-visible-overlay Toggle subtitle overlay
--toggle-primary-subtitle-bar Toggle primary subtitle bar
--show-visible-overlay Show subtitle overlay --show-visible-overlay Show subtitle overlay
--hide-visible-overlay Hide subtitle overlay --hide-visible-overlay Hide subtitle overlay
--settings Open Yomitan settings window --settings Open Yomitan settings window

View File

@@ -12,6 +12,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
stop: false, stop: false,
toggle: false, toggle: false,
toggleVisibleOverlay: false, toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
settings: false, settings: false,
setup: false, setup: false,
show: false, show: false,

View File

@@ -40,6 +40,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openJimaku: false, openJimaku: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,
togglePrimarySubtitleBar: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false, shiftSubDelayPrevLine: false,
@@ -119,6 +120,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
toggleVisibleOverlay: () => { toggleVisibleOverlay: () => {
calls.push('toggleVisibleOverlay'); calls.push('toggleVisibleOverlay');
}, },
togglePrimarySubtitleBar: () => {
calls.push('togglePrimarySubtitleBar');
},
openYomitanSettingsDelayed: (delayMs) => { openYomitanSettingsDelayed: (delayMs) => {
calls.push(`openYomitanSettingsDelayed:${delayMs}`); calls.push(`openYomitanSettingsDelayed:${delayMs}`);
}, },
@@ -533,6 +537,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
expected: 'startPendingMineSentenceMultiple:2500', expected: 'startPendingMineSentenceMultiple:2500',
}, },
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' }, { args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
{ args: { togglePrimarySubtitleBar: true }, expected: 'togglePrimarySubtitleBar' },
{ args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' }, { args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' },
{ {
args: { openRuntimeOptions: true }, args: { openRuntimeOptions: true },

View File

@@ -40,6 +40,7 @@ export interface CliCommandServiceDeps {
isOverlayRuntimeInitialized: () => boolean; isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntime: () => void; initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: () => void; openFirstRunSetup: () => void;
openYomitanSettingsDelayed: (delayMs: number) => void; openYomitanSettingsDelayed: (delayMs: number) => void;
setVisibleOverlayVisible: (visible: boolean) => void; setVisibleOverlayVisible: (visible: boolean) => void;
@@ -138,6 +139,7 @@ interface OverlayCliRuntime {
isInitialized: () => boolean; isInitialized: () => boolean;
initialize: () => void; initialize: () => void;
toggleVisible: () => void; toggleVisible: () => void;
togglePrimarySubtitleBar: () => void;
setVisible: (visible: boolean) => void; setVisible: (visible: boolean) => void;
} }
@@ -244,6 +246,7 @@ export function createCliCommandDepsRuntime(
isOverlayRuntimeInitialized: options.overlay.isInitialized, isOverlayRuntimeInitialized: options.overlay.isInitialized,
initializeOverlayRuntime: options.overlay.initialize, initializeOverlayRuntime: options.overlay.initialize,
toggleVisibleOverlay: options.overlay.toggleVisible, toggleVisibleOverlay: options.overlay.toggleVisible,
togglePrimarySubtitleBar: options.overlay.togglePrimarySubtitleBar,
openFirstRunSetup: options.ui.openFirstRunSetup, openFirstRunSetup: options.ui.openFirstRunSetup,
openYomitanSettingsDelayed: (delayMs) => { openYomitanSettingsDelayed: (delayMs) => {
options.schedule(() => { options.schedule(() => {
@@ -369,6 +372,8 @@ export function handleCliCommand(
if (args.toggle || args.toggleVisibleOverlay) { if (args.toggle || args.toggleVisibleOverlay) {
deps.toggleVisibleOverlay(); deps.toggleVisibleOverlay();
} else if (args.togglePrimarySubtitleBar) {
deps.togglePrimarySubtitleBar();
} else if (args.setup) { } else if (args.setup) {
deps.openFirstRunSetup(); deps.openFirstRunSetup();
deps.log('Opened first-run setup flow.'); deps.log('Opened first-run setup flow.');

View File

@@ -12,6 +12,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
stop: false, stop: false,
toggle: false, toggle: false,
toggleVisibleOverlay: false, toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
settings: false, settings: false,
setup: false, setup: false,
show: false, show: false,

View File

@@ -4345,6 +4345,10 @@ function toggleSubtitleSidebar(): void {
broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle); broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle);
} }
function togglePrimarySubtitleBar(): void {
broadcastToOverlayWindows(IPC_CHANNELS.event.primarySubtitleBarToggle);
}
async function triggerSubsyncFromConfig(): Promise<void> { async function triggerSubsyncFromConfig(): Promise<void> {
await subsyncRuntime.triggerFromConfig(); await subsyncRuntime.triggerFromConfig();
} }
@@ -4919,6 +4923,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
showMpvOsd: (text: string) => showMpvOsd(text), showMpvOsd: (text: string) => showMpvOsd(text),
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(), openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => copyCurrentSubtitle(), copyCurrentSubtitle: () => copyCurrentSubtitle(),

View File

@@ -19,6 +19,7 @@ export interface CliCommandRuntimeServiceContext {
isOverlayInitialized: () => boolean; isOverlayInitialized: () => boolean;
initializeOverlay: () => void; initializeOverlay: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: () => void; openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void; setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void; copyCurrentSubtitle: () => void;
@@ -83,6 +84,7 @@ function createCliCommandDepsFromContext(
isInitialized: context.isOverlayInitialized, isInitialized: context.isOverlayInitialized,
initialize: context.initializeOverlay, initialize: context.initializeOverlay,
toggleVisible: context.toggleVisibleOverlay, toggleVisible: context.toggleVisibleOverlay,
togglePrimarySubtitleBar: context.togglePrimarySubtitleBar,
setVisible: context.setVisibleOverlay, setVisible: context.setVisibleOverlay,
}, },
mining: { mining: {

View File

@@ -149,6 +149,7 @@ export interface CliCommandRuntimeServiceDepsParams {
isInitialized: CliCommandDepsRuntimeOptions['overlay']['isInitialized']; isInitialized: CliCommandDepsRuntimeOptions['overlay']['isInitialized'];
initialize: CliCommandDepsRuntimeOptions['overlay']['initialize']; initialize: CliCommandDepsRuntimeOptions['overlay']['initialize'];
toggleVisible: CliCommandDepsRuntimeOptions['overlay']['toggleVisible']; toggleVisible: CliCommandDepsRuntimeOptions['overlay']['toggleVisible'];
togglePrimarySubtitleBar: CliCommandDepsRuntimeOptions['overlay']['togglePrimarySubtitleBar'];
setVisible: CliCommandDepsRuntimeOptions['overlay']['setVisible']; setVisible: CliCommandDepsRuntimeOptions['overlay']['setVisible'];
}; };
mining: { mining: {
@@ -325,6 +326,7 @@ export function createCliCommandRuntimeServiceDeps(
isInitialized: params.overlay.isInitialized, isInitialized: params.overlay.isInitialized,
initialize: params.overlay.initialize, initialize: params.overlay.initialize,
toggleVisible: params.overlay.toggleVisible, toggleVisible: params.overlay.toggleVisible,
togglePrimarySubtitleBar: params.overlay.togglePrimarySubtitleBar,
setVisible: params.overlay.setVisible, setVisible: params.overlay.setVisible,
}, },
mining: { mining: {

View File

@@ -19,6 +19,7 @@ test('build cli command context deps maps handlers and values', () => {
isOverlayInitialized: () => true, isOverlayInitialized: () => true,
initializeOverlay: () => calls.push('init'), initializeOverlay: () => calls.push('init'),
toggleVisibleOverlay: () => calls.push('toggle-visible'), toggleVisibleOverlay: () => calls.push('toggle-visible'),
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
openFirstRunSetup: () => calls.push('setup'), openFirstRunSetup: () => calls.push('setup'),
setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`), setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy'), copyCurrentSubtitle: () => calls.push('copy'),

View File

@@ -17,6 +17,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
isOverlayInitialized: () => boolean; isOverlayInitialized: () => boolean;
initializeOverlay: () => void; initializeOverlay: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: () => void; openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void; setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void; copyCurrentSubtitle: () => void;
@@ -69,6 +70,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
isOverlayInitialized: deps.isOverlayInitialized, isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay, initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay, toggleVisibleOverlay: deps.toggleVisibleOverlay,
togglePrimarySubtitleBar: deps.togglePrimarySubtitleBar,
openFirstRunSetup: deps.openFirstRunSetup, openFirstRunSetup: deps.openFirstRunSetup,
setVisibleOverlay: deps.setVisibleOverlay, setVisibleOverlay: deps.setVisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle, copyCurrentSubtitle: deps.copyCurrentSubtitle,

View File

@@ -26,6 +26,7 @@ test('cli command context factory composes main deps and context handlers', () =
showMpvOsd: (text) => calls.push(`osd:${text}`), showMpvOsd: (text) => calls.push(`osd:${text}`),
initializeOverlayRuntime: () => calls.push('init-overlay'), initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'), toggleVisibleOverlay: () => calls.push('toggle-visible'),
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
openFirstRunSetupWindow: () => calls.push('setup'), openFirstRunSetupWindow: () => calls.push('setup'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'), copyCurrentSubtitle: () => calls.push('copy-sub'),

View File

@@ -29,6 +29,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
initializeOverlayRuntime: () => calls.push('init-overlay'), initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'), toggleVisibleOverlay: () => calls.push('toggle-visible'),
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
openFirstRunSetupWindow: () => calls.push('open-setup'), openFirstRunSetupWindow: () => calls.push('open-setup'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),

View File

@@ -27,6 +27,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
initializeOverlayRuntime: () => void; initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetupWindow: () => void; openFirstRunSetupWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void; setVisibleOverlayVisible: (visible: boolean) => void;
@@ -94,6 +95,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized, isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized,
initializeOverlay: () => deps.initializeOverlayRuntime(), initializeOverlay: () => deps.initializeOverlayRuntime(),
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(), toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(),
openFirstRunSetup: () => deps.openFirstRunSetupWindow(), openFirstRunSetup: () => deps.openFirstRunSetupWindow(),
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible), setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(), copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),

View File

@@ -25,6 +25,7 @@ function createDeps() {
isOverlayInitialized: () => true, isOverlayInitialized: () => true,
initializeOverlay: () => {}, initializeOverlay: () => {},
toggleVisibleOverlay: () => {}, toggleVisibleOverlay: () => {},
togglePrimarySubtitleBar: () => {},
openFirstRunSetup: () => {}, openFirstRunSetup: () => {},
setVisibleOverlay: () => {}, setVisibleOverlay: () => {},
copyCurrentSubtitle: () => {}, copyCurrentSubtitle: () => {},

View File

@@ -22,6 +22,7 @@ export type CliCommandContextFactoryDeps = {
isOverlayInitialized: () => boolean; isOverlayInitialized: () => boolean;
initializeOverlay: () => void; initializeOverlay: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: () => void; openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void; setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void; copyCurrentSubtitle: () => void;
@@ -81,6 +82,7 @@ export function createCliCommandContext(
isOverlayInitialized: deps.isOverlayInitialized, isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay, initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay, toggleVisibleOverlay: deps.toggleVisibleOverlay,
togglePrimarySubtitleBar: deps.togglePrimarySubtitleBar,
openFirstRunSetup: deps.openFirstRunSetup, openFirstRunSetup: deps.openFirstRunSetup,
setVisibleOverlay: deps.setVisibleOverlay, setVisibleOverlay: deps.setVisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle, copyCurrentSubtitle: deps.copyCurrentSubtitle,

View File

@@ -19,6 +19,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
showMpvOsd: () => {}, showMpvOsd: () => {},
initializeOverlayRuntime: () => {}, initializeOverlayRuntime: () => {},
toggleVisibleOverlay: () => {}, toggleVisibleOverlay: () => {},
togglePrimarySubtitleBar: () => {},
openFirstRunSetupWindow: () => {}, openFirstRunSetupWindow: () => {},
setVisibleOverlayVisible: () => {}, setVisibleOverlayVisible: () => {},
copyCurrentSubtitle: () => {}, copyCurrentSubtitle: () => {},

View File

@@ -26,6 +26,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
stop: false, stop: false,
toggle: false, toggle: false,
toggleVisibleOverlay: false, toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
settings: false, settings: false,
setup: false, setup: false,
show: false, show: false,

View File

@@ -60,6 +60,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
return Boolean( return Boolean(
args.toggle || args.toggle ||
args.toggleVisibleOverlay || args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.launchMpv || args.launchMpv ||
args.settings || args.settings ||
args.show || args.show ||

View File

@@ -153,6 +153,9 @@ const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload<SubsyncManua
const onSubtitleSidebarToggleEvent = createQueuedIpcListener( const onSubtitleSidebarToggleEvent = createQueuedIpcListener(
IPC_CHANNELS.event.subtitleSidebarToggle, IPC_CHANNELS.event.subtitleSidebarToggle,
); );
const onPrimarySubtitleBarToggleEvent = createQueuedIpcListener(
IPC_CHANNELS.event.primarySubtitleBarToggle,
);
const onKikuFieldGroupingRequestEvent = const onKikuFieldGroupingRequestEvent =
createQueuedIpcListenerWithPayload<KikuFieldGroupingRequestData>( createQueuedIpcListenerWithPayload<KikuFieldGroupingRequestData>(
IPC_CHANNELS.event.kikuFieldGroupingRequest, IPC_CHANNELS.event.kikuFieldGroupingRequest,
@@ -345,6 +348,7 @@ const electronAPI: ElectronAPI = {
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent, onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
onOpenCharacterDictionary: onOpenCharacterDictionaryEvent, onOpenCharacterDictionary: onOpenCharacterDictionaryEvent,
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent, onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent,
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,

View File

@@ -330,6 +330,7 @@ function installKeyboardTestGlobals() {
function createKeyboardHandlerHarness() { function createKeyboardHandlerHarness() {
const testGlobals = installKeyboardTestGlobals(); const testGlobals = installKeyboardTestGlobals();
const subtitleRootClassList = createClassList(); const subtitleRootClassList = createClassList();
const subtitleContainerClassList = createClassList();
let controllerSelectKeydownCount = 0; let controllerSelectKeydownCount = 0;
let openControllerSelectCount = 0; let openControllerSelectCount = 0;
let openControllerDebugCount = 0; let openControllerDebugCount = 0;
@@ -349,6 +350,7 @@ function createKeyboardHandlerHarness() {
querySelectorAll: () => wordNodes, querySelectorAll: () => wordNodes,
}, },
subtitleContainer: { subtitleContainer: {
classList: subtitleContainerClassList,
contains: () => false, contains: () => false,
}, },
overlay: testGlobals.overlay, 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 () => { test('session help chord resolver follows remapped session bindings', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness(); const { handlers, testGlobals } = createKeyboardHandlerHarness();

View File

@@ -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<void> { async function handleMarkWatched(): Promise<void> {
const marked = await window.electronAPI.markActiveVideoWatched(); const marked = await window.electronAPI.markActiveVideoWatched();
if (marked) { if (marked) {
@@ -1065,6 +1086,12 @@ export function createKeyboardHandlers(
return; return;
} }
if (isPrimarySubtitleVisibilityToggle(e)) {
e.preventDefault();
togglePrimarySubtitleBarVisibility();
return;
}
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) { if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
if (handleYomitanPopupKeybind(e)) { if (handleYomitanPopupKeybind(e)) {
e.preventDefault(); e.preventDefault();
@@ -1152,6 +1179,7 @@ export function createKeyboardHandlers(
updateSessionBindings, updateSessionBindings,
syncKeyboardTokenSelection, syncKeyboardTokenSelection,
handleSubtitleContentUpdated, handleSubtitleContentUpdated,
togglePrimarySubtitleBarVisibility,
handleKeyboardModeToggleRequested, handleKeyboardModeToggleRequested,
handleLookupWindowToggleRequested, handleLookupWindowToggleRequested,
closeLookupWindow, closeLookupWindow,

View File

@@ -532,6 +532,12 @@ function registerKeyboardCommandHandlers(): void {
await subtitleSidebarModal.toggleSubtitleSidebarModal(); await subtitleSidebarModal.toggleSubtitleSidebarModal();
}); });
}); });
window.electronAPI.onPrimarySubtitleBarToggle(() => {
runGuarded('primary-subtitle-bar:toggle', () => {
keyboardHandlers.togglePrimarySubtitleBarVisibility();
});
});
} }
function runGuarded(action: string, fn: () => void): void { function runGuarded(action: string, fn: () => void): void {

View File

@@ -134,6 +134,7 @@ export type RendererState = {
keyboardSelectionVisible: boolean; keyboardSelectionVisible: boolean;
keyboardSelectedWordIndex: number | null; keyboardSelectedWordIndex: number | null;
yomitanPopupVisible: boolean; yomitanPopupVisible: boolean;
primarySubtitleBarVisible: boolean;
}; };
export function createRendererState(): RendererState { export function createRendererState(): RendererState {
@@ -244,5 +245,6 @@ export function createRendererState(): RendererState {
keyboardSelectionVisible: false, keyboardSelectionVisible: false,
keyboardSelectedWordIndex: null, keyboardSelectedWordIndex: null,
yomitanPopupVisible: false, yomitanPopupVisible: false,
primarySubtitleBarVisible: true,
}; };
} }

View File

@@ -678,6 +678,11 @@ body.subtitle-sidebar-embedded-open #subtitleContainer {
display: none; display: none;
} }
#subtitleContainer.primary-sub-hidden {
display: none;
pointer-events: none;
}
body.settings-modal-open #subtitleContainer { body.settings-modal-open #subtitleContainer {
display: none !important; display: none !important;
pointer-events: none !important; pointer-events: none !important;

View File

@@ -120,6 +120,7 @@ export const IPC_CHANNELS = {
controllerSelectOpen: 'controller-select:open', controllerSelectOpen: 'controller-select:open',
controllerDebugOpen: 'controller-debug:open', controllerDebugOpen: 'controller-debug:open',
subtitleSidebarToggle: 'subtitle-sidebar:toggle', subtitleSidebarToggle: 'subtitle-sidebar:toggle',
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
configHotReload: 'config:hot-reload', configHotReload: 'config:hot-reload',
}, },
} as const; } as const;

View File

@@ -433,6 +433,7 @@ export interface ElectronAPI {
onOpenPlaylistBrowser: (callback: () => void) => void; onOpenPlaylistBrowser: (callback: () => void) => void;
onOpenCharacterDictionary: (callback: () => void) => void; onOpenCharacterDictionary: (callback: () => void) => void;
onSubtitleSidebarToggle: (callback: () => void) => void; onSubtitleSidebarToggle: (callback: () => void) => void;
onPrimarySubtitleBarToggle: (callback: () => void) => void;
onCancelYoutubeTrackPicker: (callback: () => void) => void; onCancelYoutubeTrackPicker: (callback: () => void) => void;
onKeyboardModeToggleRequested: (callback: () => void) => void; onKeyboardModeToggleRequested: (callback: () => void) => void;
onLookupWindowToggleRequested: (callback: () => void) => void; onLookupWindowToggleRequested: (callback: () => void) => void;