diff --git a/changes/honor-configured-controller-shortcuts-and-modal-routing.md b/changes/honor-configured-controller-shortcuts-and-modal-routing.md new file mode 100644 index 00000000..3e4d8afd --- /dev/null +++ b/changes/honor-configured-controller-shortcuts-and-modal-routing.md @@ -0,0 +1,6 @@ +type: changed +area: overlay + +- Added configurable overlay shortcuts for session help, controller select, and controller debug actions. +- Added mpv/plugin and CLI routing for session help, controller utilities, and subtitle sidebar toggling through the shared session-action path. +- Improved dedicated overlay modal retry and focus handling for runtime options, Jimaku, session help, controller tools, and the playlist browser. diff --git a/config.example.jsonc b/config.example.jsonc index efd0fd7d..2d9c1c67 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -173,7 +173,11 @@ "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting. - "openJimaku": "Ctrl+Shift+J" // Open jimaku setting. + "openJimaku": "Ctrl+Shift+J", // Open jimaku setting. + "openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting. + "openControllerSelect": "Alt+C", // Open controller select setting. + "openControllerDebug": "Alt+Shift+C", // Open controller debug setting. + "toggleSubtitleSidebar": "\\" // Toggle subtitle sidebar setting. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. // ========================================== diff --git a/docs-site/configuration.md b/docs-site/configuration.md index dbf6725a..0eb3f330 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -536,7 +536,11 @@ See `config.example.jsonc` for detailed configuration options. "mineSentenceMultiple": "CommandOrControl+Shift+S", "markAudioCard": "CommandOrControl+Shift+A", "openRuntimeOptions": "CommandOrControl+Shift+O", + "openSessionHelp": "CommandOrControl+Shift+H", + "openControllerSelect": "Alt+C", + "openControllerDebug": "Alt+Shift+C", "openJimaku": "Ctrl+Shift+J", + "toggleSubtitleSidebar": "\\", "multiCopyTimeoutMs": 3000 } } @@ -556,7 +560,11 @@ See `config.example.jsonc` for detailed configuration options. | `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) | | `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) | | `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) | +| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Shift+H"`) | +| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) | +| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | | `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) | +| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"\\"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. | **See `config.example.jsonc`** for the complete list of shortcut configuration options. @@ -573,9 +581,10 @@ Important behavior: - Controller input is only active while keyboard-only mode is enabled. - Keyboard-only mode continues to work normally without a controller. - By default SubMiner uses the first connected controller. -- `Alt+C` opens the controller config modal, where you can save the selected controller and remap actions inline. +- `Alt+C` opens the controller config modal by default, and you can remap that shortcut through `shortcuts.openControllerSelect`. - Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action. -- `Alt+Shift+C` opens a live debug modal showing raw axes/button values plus a ready-to-copy `buttonIndices` config block. +- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`. +- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block. - `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`. - Turning keyboard-only mode off clears the keyboard-only token highlight state. - Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active. @@ -694,7 +703,7 @@ These shortcuts are only active when the overlay window is visible and automatic ### Session Help Modal -The session help modal is opened with `Y-H` by default (falls back to `Y-K` if needed) and shows the current session keybindings and color legend. +The session help modal opens from the overlay with `Ctrl/Cmd+Shift+H` by default. The mpv plugin also exposes it through the `Y-H` chord (falling back to `Y-K` if needed). It shows the current session keybindings and color legend. You can filter the modal quickly with `/`: diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index efd0fd7d..2d9c1c67 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -173,7 +173,11 @@ "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting. - "openJimaku": "Ctrl+Shift+J" // Open jimaku setting. + "openJimaku": "Ctrl+Shift+J", // Open jimaku setting. + "openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting. + "openControllerSelect": "Alt+C", // Open controller select setting. + "openControllerDebug": "Alt+Shift+C", // Open controller debug setting. + "toggleSubtitleSidebar": "\\" // Toggle subtitle sidebar setting. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. // ========================================== diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 2310f549..a79ee7a0 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -67,6 +67,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle | ------------------ | -------------------------------------------------------- | ------------------------------ | | `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | | `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | +| `Ctrl/Cmd+Shift+H` | Open session help modal | `shortcuts.openSessionHelp` | | `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | | `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | @@ -79,12 +80,12 @@ The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a ## Controller Shortcuts -These overlay-local shortcuts are fixed and open controller utilities for the Chrome Gamepad API integration. +These overlay-local shortcuts open controller utilities for the Chrome Gamepad API integration. -| Shortcut | Action | Configurable | -| ------------- | ------------------------------ | ------------ | -| `Alt+C` | Open controller config + remap modal | Fixed | -| `Alt+Shift+C` | Open controller debug modal | Fixed | +| Shortcut | Action | Configurable | +| ------------- | ------------------------------------ | -------------------------------- | +| `Alt+C` | Open controller config + remap modal | `shortcuts.openControllerSelect` | +| `Alt+Shift+C` | Open controller debug modal | `shortcuts.openControllerDebug` | Controller input only drives the overlay while keyboard-only mode is enabled. The controller mapping and tuning live under the top-level `controller` config block; keyboard-only mode still works normally without a controller. @@ -101,6 +102,7 @@ When the mpv plugin is installed, all commands use a `y` chord prefix — press | `y-o` | Open Yomitan settings | | `y-r` | Restart overlay | | `y-c` | Check overlay status | +| `y-h` | Open session help | When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper). diff --git a/docs-site/usage.md b/docs-site/usage.md index 96e4b583..0d446d4a 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -272,12 +272,12 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro 1. Connect a controller before or after launching SubMiner. 2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding. -3. Press `Alt+C` in the overlay to pick the controller you want to save and remap any action inline. +3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline. 4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller. 5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps. 6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup. -By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline. `Alt+Shift+C` still opens the live debug modal with raw axes/button values for non-standard pads. +By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline, and `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`. ### Default Button Mapping @@ -321,6 +321,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. +`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. ### Drag-and-Drop diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua index aa4bbaab..de6fdbed 100644 --- a/plugin/subminer/session_bindings.lua +++ b/plugin/subminer/session_bindings.lua @@ -133,6 +133,8 @@ function M.create(ctx) return { "--mine-sentence-count", tostring(payload and payload.count or 1) } elseif action_id == "toggleSecondarySub" then return { "--toggle-secondary-sub" } + elseif action_id == "toggleSubtitleSidebar" then + return { "--toggle-subtitle-sidebar" } elseif action_id == "markAudioCard" then return { "--mark-audio-card" } elseif action_id == "openRuntimeOptions" then @@ -141,6 +143,12 @@ function M.create(ctx) return { "--open-jimaku" } elseif action_id == "openYoutubePicker" then return { "--open-youtube-picker" } + elseif action_id == "openSessionHelp" then + return { "--open-session-help" } + elseif action_id == "openControllerSelect" then + return { "--open-controller-select" } + elseif action_id == "openControllerDebug" then + return { "--open-controller-debug" } elseif action_id == "openPlaylistBrowser" then return { "--open-playlist-browser" } elseif action_id == "replayCurrentSubtitle" then diff --git a/plugin/subminer/ui.lua b/plugin/subminer/ui.lua index f4ff0e4c..92cbabb5 100644 --- a/plugin/subminer/ui.lua +++ b/plugin/subminer/ui.lua @@ -90,6 +90,12 @@ function M.create(ctx) mp.add_key_binding("y-c", "subminer-status", function() process.check_status() end) + mp.add_key_binding("y-h", "subminer-session-help", function() + if not ensure_binary_for_menu() then + return + end + process.run_control_command_async("open-session-help") + end) if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", function() aniskip.skip_intro_now() diff --git a/src/cli/args.ts b/src/cli/args.ts index 186da80f..b02c09b5 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -25,7 +25,11 @@ export interface CliArgs { triggerSubsync: boolean; markAudioCard: boolean; toggleStatsOverlay: boolean; + toggleSubtitleSidebar: boolean; openRuntimeOptions: boolean; + openSessionHelp: boolean; + openControllerSelect: boolean; + openControllerDebug: boolean; openJimaku: boolean; openYoutubePicker: boolean; openPlaylistBrowser: boolean; @@ -115,7 +119,11 @@ export function parseArgs(argv: string[]): CliArgs { triggerSubsync: false, markAudioCard: false, toggleStatsOverlay: false, + toggleSubtitleSidebar: false, openRuntimeOptions: false, + openSessionHelp: false, + openControllerSelect: false, + openControllerDebug: false, openJimaku: false, openYoutubePicker: false, openPlaylistBrowser: false, @@ -218,7 +226,11 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === '--trigger-subsync') args.triggerSubsync = true; else if (arg === '--mark-audio-card') args.markAudioCard = true; else if (arg === '--toggle-stats-overlay') args.toggleStatsOverlay = true; + else if (arg === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true; else if (arg === '--open-runtime-options') args.openRuntimeOptions = true; + else if (arg === '--open-session-help') args.openSessionHelp = true; + else if (arg === '--open-controller-select') args.openControllerSelect = true; + else if (arg === '--open-controller-debug') args.openControllerDebug = true; else if (arg === '--open-jimaku') args.openJimaku = true; else if (arg === '--open-youtube-picker') args.openYoutubePicker = true; else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true; @@ -442,7 +454,11 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.triggerSubsync || args.markAudioCard || args.toggleStatsOverlay || + args.toggleSubtitleSidebar || args.openRuntimeOptions || + args.openSessionHelp || + args.openControllerSelect || + args.openControllerDebug || args.openJimaku || args.openYoutubePicker || args.openPlaylistBrowser || @@ -505,7 +521,11 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.triggerSubsync && !args.markAudioCard && !args.toggleStatsOverlay && + !args.toggleSubtitleSidebar && !args.openRuntimeOptions && + !args.openSessionHelp && + !args.openControllerSelect && + !args.openControllerDebug && !args.openJimaku && !args.openYoutubePicker && !args.openPlaylistBrowser && @@ -559,7 +579,11 @@ export function shouldStartApp(args: CliArgs): boolean { args.triggerSubsync || args.markAudioCard || args.toggleStatsOverlay || + args.toggleSubtitleSidebar || args.openRuntimeOptions || + args.openSessionHelp || + args.openControllerSelect || + args.openControllerDebug || args.openJimaku || args.openYoutubePicker || args.openPlaylistBrowser || @@ -608,7 +632,11 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.triggerSubsync && !args.markAudioCard && !args.toggleStatsOverlay && + !args.toggleSubtitleSidebar && !args.openRuntimeOptions && + !args.openSessionHelp && + !args.openControllerSelect && + !args.openControllerDebug && !args.openJimaku && !args.openYoutubePicker && !args.openPlaylistBrowser && @@ -658,10 +686,14 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean { args.mineSentenceMultiple || args.updateLastCardFromClipboard || args.toggleSecondarySub || + args.toggleSubtitleSidebar || args.triggerFieldGrouping || args.triggerSubsync || args.markAudioCard || args.openRuntimeOptions || + args.openSessionHelp || + args.openControllerSelect || + args.openControllerDebug || args.openJimaku || args.openYoutubePicker || args.openPlaylistBrowser || diff --git a/src/cli/help.ts b/src/cli/help.ts index 7b74700b..ad31ff7e 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -35,7 +35,11 @@ ${B}Mining${R} --trigger-field-grouping Run Kiku field grouping --trigger-subsync Run subtitle sync --toggle-secondary-sub Cycle secondary subtitle mode + --toggle-subtitle-sidebar Toggle subtitle sidebar panel --open-runtime-options Open runtime options palette + --open-session-help Open session help modal + --open-controller-select Open controller select modal + --open-controller-debug Open controller debug modal ${B}AniList${R} --anilist-setup Open AniList authentication flow diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index 9574b67b..e57830bb 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -88,6 +88,10 @@ export const CORE_DEFAULT_CONFIG: Pick< markAudioCard: 'CommandOrControl+Shift+A', openRuntimeOptions: 'CommandOrControl+Shift+O', openJimaku: 'Ctrl+Shift+J', + openSessionHelp: 'CommandOrControl+Shift+H', + openControllerSelect: 'Alt+C', + openControllerDebug: 'Alt+Shift+C', + toggleSubtitleSidebar: '\\', }, secondarySub: { secondarySubLanguages: [], diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index b86a92ef..545d12e9 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -29,7 +29,11 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerSubsync: false, markAudioCard: false, toggleStatsOverlay: false, + toggleSubtitleSidebar: false, openRuntimeOptions: false, + openSessionHelp: false, + openControllerSelect: false, + openControllerDebug: false, openJimaku: false, openYoutubePicker: false, openPlaylistBrowser: false, diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 465ffbca..2bfed141 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -30,8 +30,12 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerSubsync: false, markAudioCard: false, toggleStatsOverlay: false, + toggleSubtitleSidebar: false, refreshKnownWords: false, openRuntimeOptions: false, + openSessionHelp: false, + openControllerSelect: false, + openControllerDebug: false, openJimaku: false, openYoutubePicker: false, openPlaylistBrowser: false, diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index b4a8d567..a263052a 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -402,8 +402,32 @@ export function handleCliCommand( 'toggleStatsOverlay', 'Stats toggle failed', ); + } else if (args.toggleSubtitleSidebar) { + dispatchCliSessionAction( + { actionId: 'toggleSubtitleSidebar' }, + 'toggleSubtitleSidebar', + 'Subtitle sidebar toggle failed', + ); } else if (args.openRuntimeOptions) { deps.openRuntimeOptionsPalette(); + } else if (args.openSessionHelp) { + dispatchCliSessionAction( + { actionId: 'openSessionHelp' }, + 'openSessionHelp', + 'Open session help failed', + ); + } else if (args.openControllerSelect) { + dispatchCliSessionAction( + { actionId: 'openControllerSelect' }, + 'openControllerSelect', + 'Open controller select failed', + ); + } else if (args.openControllerDebug) { + dispatchCliSessionAction( + { actionId: 'openControllerDebug' }, + 'openControllerDebug', + 'Open controller debug failed', + ); } else if (args.openJimaku) { dispatchCliSessionAction({ actionId: 'openJimaku' }, 'openJimaku', 'Open jimaku failed'); } else if (args.openYoutubePicker) { diff --git a/src/core/services/overlay-shortcut-handler.test.ts b/src/core/services/overlay-shortcut-handler.test.ts index 824365ba..f2d80b78 100644 --- a/src/core/services/overlay-shortcut-handler.test.ts +++ b/src/core/services/overlay-shortcut-handler.test.ts @@ -27,6 +27,10 @@ function makeShortcuts(overrides: Partial = {}): Configured markAudioCard: null, openRuntimeOptions: null, openJimaku: null, + openSessionHelp: null, + openControllerSelect: null, + openControllerDebug: null, + toggleSubtitleSidebar: null, ...overrides, }; } diff --git a/src/core/services/overlay-shortcut.test.ts b/src/core/services/overlay-shortcut.test.ts index 565fb1c7..72d30e7e 100644 --- a/src/core/services/overlay-shortcut.test.ts +++ b/src/core/services/overlay-shortcut.test.ts @@ -22,6 +22,10 @@ function createShortcuts(overrides: Partial = {}): Configur markAudioCard: null, openRuntimeOptions: null, openJimaku: null, + openSessionHelp: null, + openControllerSelect: null, + openControllerDebug: null, + toggleSubtitleSidebar: null, ...overrides, }; } diff --git a/src/core/services/session-actions.ts b/src/core/services/session-actions.ts index 02699743..770b41fe 100644 --- a/src/core/services/session-actions.ts +++ b/src/core/services/session-actions.ts @@ -13,8 +13,12 @@ export interface SessionActionExecutorDeps { mineSentenceCard: () => Promise; mineSentenceCount: (count: number) => void; toggleSecondarySub: () => void; + toggleSubtitleSidebar: () => void; markLastCardAsAudioCard: () => Promise; openRuntimeOptionsPalette: () => void; + openSessionHelp: () => void; + openControllerSelect: () => void; + openControllerDebug: () => void; openJimaku: () => void; openYoutubeTrackPicker: () => void | Promise; openPlaylistBrowser: () => boolean | void | Promise; @@ -65,12 +69,24 @@ export async function dispatchSessionAction( case 'toggleSecondarySub': deps.toggleSecondarySub(); return; + case 'toggleSubtitleSidebar': + deps.toggleSubtitleSidebar(); + return; case 'markAudioCard': await deps.markLastCardAsAudioCard(); return; case 'openRuntimeOptions': deps.openRuntimeOptionsPalette(); return; + case 'openSessionHelp': + deps.openSessionHelp(); + return; + case 'openControllerSelect': + deps.openControllerSelect(); + return; + case 'openControllerDebug': + deps.openControllerDebug(); + return; case 'openJimaku': deps.openJimaku(); return; diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts index d91cff0a..f29f3a2a 100644 --- a/src/core/services/session-bindings.test.ts +++ b/src/core/services/session-bindings.test.ts @@ -20,6 +20,10 @@ function createShortcuts(overrides: Partial = {}): Configur markAudioCard: null, openRuntimeOptions: null, openJimaku: null, + openSessionHelp: null, + openControllerSelect: null, + openControllerDebug: null, + toggleSubtitleSidebar: null, ...overrides, }; } @@ -33,6 +37,7 @@ test('compileSessionBindings merges shortcuts and keybindings into one canonical shortcuts: createShortcuts({ toggleVisibleOverlayGlobal: 'Alt+Shift+O', openJimaku: 'Ctrl+Shift+J', + openControllerSelect: 'Alt+C', }), keybindings: [ createKeybinding('KeyF', ['cycle', 'fullscreen']), @@ -68,6 +73,13 @@ test('compileSessionBindings merges shortcuts and keybindings into one canonical modifiers: ['ctrl', 'shift'], target: 'openYoutubePicker', }, + { + actionType: 'session-action', + sourcePath: 'shortcuts.openControllerSelect', + code: 'KeyC', + modifiers: ['alt'], + target: 'openControllerSelect', + }, { actionType: 'session-action', sourcePath: 'shortcuts.openJimaku', diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts index bec9e16a..3d73eaad 100644 --- a/src/core/services/session-bindings.ts +++ b/src/core/services/session-bindings.ts @@ -45,6 +45,10 @@ const SESSION_SHORTCUT_ACTIONS: Array<{ { key: 'markAudioCard', actionId: 'markAudioCard' }, { key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' }, { key: 'openJimaku', actionId: 'openJimaku' }, + { key: 'openSessionHelp', actionId: 'openSessionHelp' }, + { key: 'openControllerSelect', actionId: 'openControllerSelect' }, + { key: 'openControllerDebug', actionId: 'openControllerDebug' }, + { key: 'toggleSubtitleSidebar', actionId: 'toggleSubtitleSidebar' }, ]; function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] { diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index 277027f6..188b6c94 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -29,7 +29,11 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerSubsync: false, markAudioCard: false, toggleStatsOverlay: false, + toggleSubtitleSidebar: false, openRuntimeOptions: false, + openSessionHelp: false, + openControllerSelect: false, + openControllerDebug: false, openJimaku: false, openYoutubePicker: false, openPlaylistBrowser: false, diff --git a/src/core/utils/shortcut-config.ts b/src/core/utils/shortcut-config.ts index 991c1954..25807dc6 100644 --- a/src/core/utils/shortcut-config.ts +++ b/src/core/utils/shortcut-config.ts @@ -14,6 +14,10 @@ export interface ConfiguredShortcuts { markAudioCard: string | null | undefined; openRuntimeOptions: string | null | undefined; openJimaku: string | null | undefined; + openSessionHelp: string | null | undefined; + openControllerSelect: string | null | undefined; + openControllerDebug: string | null | undefined; + toggleSubtitleSidebar: string | null | undefined; } export function resolveConfiguredShortcuts( @@ -78,5 +82,17 @@ export function resolveConfiguredShortcuts( openJimaku: normalizeShortcut( config.shortcuts?.openJimaku ?? defaultConfig.shortcuts?.openJimaku, ), + openSessionHelp: normalizeShortcut( + config.shortcuts?.openSessionHelp ?? defaultConfig.shortcuts?.openSessionHelp, + ), + openControllerSelect: normalizeShortcut( + config.shortcuts?.openControllerSelect ?? defaultConfig.shortcuts?.openControllerSelect, + ), + openControllerDebug: normalizeShortcut( + config.shortcuts?.openControllerDebug ?? defaultConfig.shortcuts?.openControllerDebug, + ), + toggleSubtitleSidebar: normalizeShortcut( + config.shortcuts?.toggleSubtitleSidebar ?? defaultConfig.shortcuts?.toggleSubtitleSidebar, + ), }; } diff --git a/src/main.ts b/src/main.ts index 96abf216..d0cabed8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -453,6 +453,10 @@ import { createOverlayModalRuntimeService } from './main/overlay-runtime'; import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state'; import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open'; import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open'; +import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open'; +import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open'; +import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open'; +import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open'; import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc'; import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact'; import { openOverlayHostedModal } from './main/runtime/overlay-hosted-modal-open'; @@ -2211,8 +2215,21 @@ function setOverlayDebugVisualizationEnabled(enabled: boolean): void { overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled); } -function openRuntimeOptionsPalette(): void { - void openRuntimeOptionsModalRuntime({ +function createOverlayHostedModalOpenDeps(): { + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; +} { + return { ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), ensureOverlayWindowsReadyForVisibilityActions: () => ensureOverlayWindowsReadyForVisibilityActions(), @@ -2220,33 +2237,62 @@ function openRuntimeOptionsPalette(): void { sendToActiveOverlayWindow(channel, payload, runtimeOptions), waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs), logWarn: (message) => logger.warn(message), - }).then((opened) => { + }; +} + +function openOverlayHostedModalWithOsd( + openModal: (deps: ReturnType) => Promise, + unavailableMessage: string, + failureLogMessage: string, +): void { + void openModal(createOverlayHostedModalOpenDeps()).then((opened) => { if (!opened) { - showMpvOsd('Runtime options overlay unavailable.'); + showMpvOsd(unavailableMessage); } }).catch((error) => { - logger.error('Failed to open runtime options overlay.', error); - showMpvOsd('Runtime options overlay unavailable.'); + logger.error(failureLogMessage, error); + showMpvOsd(unavailableMessage); }); } -function openJimakuOverlay(): void { - const opened = openOverlayHostedModal( - { - ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), - ensureOverlayWindowsReadyForVisibilityActions: () => - ensureOverlayWindowsReadyForVisibilityActions(), - sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => - sendToActiveOverlayWindow(channel, payload, runtimeOptions), - }, - { - channel: IPC_CHANNELS.event.jimakuOpen, - modal: 'jimaku', - }, +function openRuntimeOptionsPalette(): void { + openOverlayHostedModalWithOsd( + openRuntimeOptionsModalRuntime, + 'Runtime options overlay unavailable.', + 'Failed to open runtime options overlay.', + ); +} + +function openJimakuOverlay(): void { + openOverlayHostedModalWithOsd( + openJimakuModalRuntime, + 'Jimaku overlay unavailable.', + 'Failed to open Jimaku overlay.', + ); +} + +function openSessionHelpOverlay(): void { + openOverlayHostedModalWithOsd( + openSessionHelpModalRuntime, + 'Session help overlay unavailable.', + 'Failed to open session help overlay.', + ); +} + +function openControllerSelectOverlay(): void { + openOverlayHostedModalWithOsd( + openControllerSelectModalRuntime, + 'Controller select overlay unavailable.', + 'Failed to open controller select overlay.', + ); +} + +function openControllerDebugOverlay(): void { + openOverlayHostedModalWithOsd( + openControllerDebugModalRuntime, + 'Controller debug overlay unavailable.', + 'Failed to open controller debug overlay.', ); - if (!opened) { - showMpvOsd('Jimaku overlay unavailable.'); - } } function openPlaylistBrowser(): void { @@ -2254,16 +2300,11 @@ function openPlaylistBrowser(): void { showMpvOsd('Playlist browser requires active playback.'); return; } - const opened = openPlaylistBrowserRuntime({ - ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), - ensureOverlayWindowsReadyForVisibilityActions: () => - ensureOverlayWindowsReadyForVisibilityActions(), - sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => - sendToActiveOverlayWindow(channel, payload, runtimeOptions), - }); - if (!opened) { - showMpvOsd('Playlist browser overlay unavailable.'); - } + openOverlayHostedModalWithOsd( + openPlaylistBrowserRuntime, + 'Playlist browser overlay unavailable.', + 'Failed to open playlist browser overlay.', + ); } function getResolvedConfig() { @@ -4278,6 +4319,10 @@ function handleCycleSecondarySubMode(): void { cycleSecondarySubMode(); } +function toggleSubtitleSidebar(): void { + broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle); +} + async function triggerSubsyncFromConfig(): Promise { await subsyncRuntime.triggerFromConfig(); } @@ -4562,9 +4607,13 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro mineSentenceCard: () => mineSentenceCard(), mineSentenceCount: (count) => handleMineSentenceDigit(count), toggleSecondarySub: () => handleCycleSecondarySubMode(), + toggleSubtitleSidebar: () => toggleSubtitleSidebar(), markLastCardAsAudioCard: () => markLastCardAsAudioCard(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openJimaku: () => openJimakuOverlay(), + openSessionHelp: () => openSessionHelpOverlay(), + openControllerSelect: () => openControllerSelectOverlay(), + openControllerDebug: () => openControllerDebugOverlay(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), openPlaylistBrowser: () => openPlaylistBrowser(), replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient), diff --git a/src/main/boot/services.test.ts b/src/main/boot/services.test.ts index c469c8b4..f318dc0e 100644 --- a/src/main/boot/services.test.ts +++ b/src/main/boot/services.test.ts @@ -23,7 +23,7 @@ test('createMainBootServices builds boot-phase service bundle', () => { { kind: string }, { scope: string; warn: () => void; info: () => void; error: () => void }, { registry: boolean }, - { getModalWindow: () => null }, + { getMainWindow: () => null; getModalWindow: () => null }, { inputState: boolean; getModalInputExclusive: () => boolean; @@ -82,6 +82,7 @@ test('createMainBootServices builds boot-phase service bundle', () => { }) as const, createMainRuntimeRegistry: () => ({ registry: true }), createOverlayManager: () => ({ + getMainWindow: () => null, getModalWindow: () => null, }), createOverlayModalInputState: () => ({ diff --git a/src/main/boot/services.ts b/src/main/boot/services.ts index 51c4f74f..f4f46582 100644 --- a/src/main/boot/services.ts +++ b/src/main/boot/services.ts @@ -74,6 +74,7 @@ export interface MainBootServicesParams< getModalWindow: () => BrowserWindow | null; syncOverlayShortcutsForModal: (isActive: boolean) => void; syncOverlayVisibilityForModal: () => void; + restoreMainWindowFocus?: () => void; }) => TOverlayModalInputState; createOverlayContentMeasurementStore: (params: { logger: TLogger; @@ -131,7 +132,7 @@ export function createMainBootServices< TSubtitleWebSocket, TLogger, TRuntimeRegistry, - TOverlayManager extends { getModalWindow: () => BrowserWindow | null }, + TOverlayManager extends { getMainWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null }, TOverlayModalInputState extends OverlayModalInputStateShape, TOverlayContentMeasurementStore, TOverlayModalRuntime, @@ -212,6 +213,26 @@ export function createMainBootServices< syncOverlayVisibilityForModal: () => { params.getSyncOverlayVisibilityForModal()(); }, + restoreMainWindowFocus: () => { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) return; + try { + const electron = require('electron') as { + app?: { focus?: (options?: { steal?: boolean }) => void }; + }; + electron.app?.focus?.({ steal: true }); + } catch { + // Ignore in non-Electron environments. + } + const maybeFocusable = mainWindow as typeof mainWindow & { + setFocusable?: (focusable: boolean) => void; + }; + maybeFocusable.setFocusable?.(true); + mainWindow.focus(); + if (!mainWindow.webContents.isFocused()) { + mainWindow.webContents.focus(); + } + }, }); const overlayContentMeasurementStore = params.createOverlayContentMeasurementStore({ logger, diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index 3f2e17dd..cd19356c 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -31,6 +31,7 @@ function createMockWindow(): MockWindow & { getHideCount: () => number; show: () => void; hide: () => void; + destroy: () => void; focus: () => void; once: (event: 'ready-to-show', cb: () => void) => void; webContents: { @@ -81,6 +82,10 @@ function createMockWindow(): MockWindow & { state.visible = false; state.hideCount += 1; }, + destroy: () => { + state.destroyed = true; + state.visible = false; + }, focus: () => { state.focused = true; }, @@ -302,10 +307,10 @@ test('handleOverlayModalClosed hides modal window only after all pending modals ); runtime.handleOverlayModalClosed('runtime-options'); - assert.equal(window.getHideCount(), 0); + assert.equal(window.isDestroyed(), false); runtime.handleOverlayModalClosed('subsync'); - assert.equal(window.getHideCount(), 1); + assert.equal(window.isDestroyed(), true); }); test('sendToActiveOverlayWindow prefers visible main overlay window for modal open', () => { @@ -519,7 +524,7 @@ test('handleOverlayModalClosed is a no-op when no modal window can be targeted', assert.deepEqual(state, []); }); -test('handleOverlayModalClosed hides modal window for single kiku modal', () => { +test('handleOverlayModalClosed destroys modal window for single kiku modal', () => { const window = createMockWindow(); const runtime = createOverlayModalRuntimeService({ getMainWindow: () => null, @@ -538,11 +543,11 @@ test('handleOverlayModalClosed hides modal window for single kiku modal', () => ); runtime.handleOverlayModalClosed('kiku'); - assert.equal(window.getHideCount(), 1); + assert.equal(window.isDestroyed(), true); assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0); }); -test('modal fallback reveal keeps mouse events ignored until modal confirms open', async () => { +test('modal fallback reveal skips showing window when content is not ready', async () => { const window = createMockWindow(); const runtime = createOverlayModalRuntimeService({ getMainWindow: () => null, @@ -563,16 +568,15 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open }); assert.equal(sent, true); - assert.equal(window.ignoreMouseEvents, false); await new Promise((resolve) => { setTimeout(resolve, 260); }); - assert.equal(window.getShowCount(), 1); - assert.equal(window.ignoreMouseEvents, false); + assert.equal(window.getShowCount(), 0); runtime.notifyOverlayModalOpened('jimaku'); + assert.equal(window.getShowCount(), 1); assert.equal(window.ignoreMouseEvents, false); }); @@ -606,12 +610,19 @@ test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering assert.deepEqual(window.sent, [['runtime-options:open']]); }); -test('warm modal window reopen becomes interactive immediately on the second open', () => { - const window = createMockWindow(); +test('modal reopen creates a fresh window after close destroys the previous one', () => { + const firstWindow = createMockWindow(); + const secondWindow = createMockWindow(); + let currentModal: ReturnType | null = firstWindow; + const runtime = createOverlayModalRuntimeService({ getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => window as never, + getModalWindow: () => + currentModal && !currentModal.isDestroyed() ? (currentModal as never) : null, + createModalWindow: () => { + currentModal = secondWindow; + return secondWindow as never; + }, getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), setModalWindowBounds: () => {}, }); @@ -622,19 +633,84 @@ test('warm modal window reopen becomes interactive immediately on the second ope runtime.notifyOverlayModalOpened('runtime-options'); runtime.handleOverlayModalClosed('runtime-options'); - window.ignoreMouseEvents = true; - window.focused = false; - window.webContentsFocused = false; + assert.equal(firstWindow.isDestroyed(), true); + + const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }); + + assert.equal(sent, true); + assert.equal(currentModal, secondWindow); + assert.equal(secondWindow.getShowCount(), 0); +}); + +test('modal reopen after close-destroy notifies state change on fresh window lifecycle', () => { + const firstWindow = createMockWindow(); + const secondWindow = createMockWindow(); + let currentModal: ReturnType | null = firstWindow; + const state: boolean[] = []; + + const runtime = createOverlayModalRuntimeService( + { + getMainWindow: () => null, + getModalWindow: () => + currentModal && !currentModal.isDestroyed() ? (currentModal as never) : null, + createModalWindow: () => { + currentModal = secondWindow; + return secondWindow as never; + }, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }, + { + onModalStateChange: (active: boolean): void => { + state.push(active); + }, + }, + ); runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { restoreOnModalClose: 'runtime-options', }); + runtime.notifyOverlayModalOpened('runtime-options'); + runtime.handleOverlayModalClosed('runtime-options'); - assert.equal(window.isVisible(), true); + assert.deepEqual(state, [true, false]); + assert.equal(firstWindow.isDestroyed(), true); + + runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }); + runtime.notifyOverlayModalOpened('runtime-options'); + + assert.deepEqual(state, [true, false, true]); + assert.equal(currentModal, secondWindow); +}); + +test('visible stale modal window is made interactive again before reopening', () => { + const window = createMockWindow(); + window.visible = true; + window.focused = true; + window.webContentsFocused = false; + window.ignoreMouseEvents = true; + + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => window as never, + createModalWindow: () => window as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }); + + assert.equal(sent, true); assert.equal(window.ignoreMouseEvents, false); assert.equal(window.isFocused(), true); assert.equal(window.webContentsFocused, true); - assert.equal(window.getShowCount(), 2); + assert.deepEqual(window.sent, [['runtime-options:open']]); }); test('waitForModalOpen resolves true after modal acknowledgement', async () => { diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index a14c4adb..0f8312ef 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -5,6 +5,26 @@ import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-wind const MODAL_REVEAL_FALLBACK_DELAY_MS = 250; +function requestOverlayApplicationFocus(): void { + try { + const electron = require('electron') as { + app?: { + focus?: (options?: { steal?: boolean }) => void; + }; + }; + electron.app?.focus?.({ steal: true }); + } catch { + // Ignore focus-steal failures in non-Electron test environments. + } +} + +function setWindowFocusable(window: BrowserWindow): void { + const maybeFocusableWindow = window as BrowserWindow & { + setFocusable?: (focusable: boolean) => void; + }; + maybeFocusableWindow.setFocusable?.(true); +} + export interface OverlayWindowResolver { getMainWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null; @@ -142,6 +162,8 @@ export function createOverlayModalRuntimeService( passThroughMouseEvents: boolean; } = { passThroughMouseEvents: false }, ): void => { + setWindowFocusable(window); + requestOverlayApplicationFocus(); if (!window.isVisible()) { window.show(); } @@ -158,15 +180,14 @@ export function createOverlayModalRuntimeService( }; const ensureModalWindowInteractive = (window: BrowserWindow): void => { + setWindowFocusable(window); + requestOverlayApplicationFocus(); + window.setIgnoreMouseEvents(false); + elevateModalWindow(window); + if (window.isVisible()) { - window.setIgnoreMouseEvents(false); - if (!window.isFocused()) { - window.focus(); - } - if (!window.webContents.isFocused()) { - window.webContents.focus(); - } - elevateModalWindow(window); + window.focus(); + window.webContents.focus(); return; } @@ -251,6 +272,9 @@ export function createOverlayModalRuntimeService( if (!targetWindow || targetWindow.isDestroyed() || targetWindow.isVisible()) { return; } + if (!isWindowReadyForIpc(targetWindow)) { + return; + } showModalWindow(targetWindow, { passThroughMouseEvents: false }); }, MODAL_REVEAL_FALLBACK_DELAY_MS); }; @@ -306,6 +330,9 @@ export function createOverlayModalRuntimeService( } sendOrQueueForWindow(modalWindow, (window) => { + if (window.isVisible()) { + ensureModalWindowInteractive(window); + } if (payload === undefined) { window.webContents.send(channel); } else { @@ -346,9 +373,9 @@ export function createOverlayModalRuntimeService( if (restoreVisibleOverlayOnModalClose.size === 0) { clearPendingModalWindowReveal(); if (modalWindow && !modalWindow.isDestroyed()) { - modalWindow.hide(); + modalWindow.destroy(); } - modalWindowPrimedForImmediateShow = true; + modalWindowPrimedForImmediateShow = false; mainWindowMousePassthroughForcedByModal = false; mainWindowHiddenByModal = false; notifyModalStateChange(false); @@ -376,14 +403,7 @@ export function createOverlayModalRuntimeService( } if (targetWindow.isVisible()) { - targetWindow.setIgnoreMouseEvents(false); - elevateModalWindow(targetWindow); - if (!targetWindow.isFocused()) { - targetWindow.focus(); - } - if (!targetWindow.webContents.isFocused()) { - targetWindow.webContents.focus(); - } + ensureModalWindowInteractive(targetWindow); return; } diff --git a/src/main/runtime/controller-debug-open.ts b/src/main/runtime/controller-debug-open.ts new file mode 100644 index 00000000..622d7143 --- /dev/null +++ b/src/main/runtime/controller-debug-open.ts @@ -0,0 +1,48 @@ +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; + +const CONTROLLER_DEBUG_MODAL: OverlayHostedModal = 'controller-debug'; +const CONTROLLER_DEBUG_OPEN_TIMEOUT_MS = 1500; + +export async function openControllerDebugModal(deps: { + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; +}): Promise { + return await retryOverlayModalOpen( + { + waitForModalOpen: deps.waitForModalOpen, + logWarn: deps.logWarn, + }, + { + modal: CONTROLLER_DEBUG_MODAL, + timeoutMs: CONTROLLER_DEBUG_OPEN_TIMEOUT_MS, + retryWarning: + 'Controller debug modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + sendOpen: () => + openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, + ensureOverlayWindowsReadyForVisibilityActions: + deps.ensureOverlayWindowsReadyForVisibilityActions, + sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, + }, + { + channel: IPC_CHANNELS.event.controllerDebugOpen, + modal: CONTROLLER_DEBUG_MODAL, + preferModalWindow: true, + }, + ), + }, + ); +} diff --git a/src/main/runtime/controller-select-open.ts b/src/main/runtime/controller-select-open.ts new file mode 100644 index 00000000..7af280a0 --- /dev/null +++ b/src/main/runtime/controller-select-open.ts @@ -0,0 +1,48 @@ +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; + +const CONTROLLER_SELECT_MODAL: OverlayHostedModal = 'controller-select'; +const CONTROLLER_SELECT_OPEN_TIMEOUT_MS = 1500; + +export async function openControllerSelectModal(deps: { + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; +}): Promise { + return await retryOverlayModalOpen( + { + waitForModalOpen: deps.waitForModalOpen, + logWarn: deps.logWarn, + }, + { + modal: CONTROLLER_SELECT_MODAL, + timeoutMs: CONTROLLER_SELECT_OPEN_TIMEOUT_MS, + retryWarning: + 'Controller select modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + sendOpen: () => + openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, + ensureOverlayWindowsReadyForVisibilityActions: + deps.ensureOverlayWindowsReadyForVisibilityActions, + sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, + }, + { + channel: IPC_CHANNELS.event.controllerSelectOpen, + modal: CONTROLLER_SELECT_MODAL, + preferModalWindow: true, + }, + ), + }, + ); +} diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index c4cb3ee4..95ed94aa 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -43,7 +43,11 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerSubsync: false, markAudioCard: false, toggleStatsOverlay: false, + toggleSubtitleSidebar: false, openRuntimeOptions: false, + openSessionHelp: false, + openControllerSelect: false, + openControllerDebug: false, openJimaku: false, openYoutubePicker: false, openPlaylistBrowser: false, diff --git a/src/main/runtime/global-shortcuts-runtime-handlers.test.ts b/src/main/runtime/global-shortcuts-runtime-handlers.test.ts index 963d84f8..e192fae7 100644 --- a/src/main/runtime/global-shortcuts-runtime-handlers.test.ts +++ b/src/main/runtime/global-shortcuts-runtime-handlers.test.ts @@ -18,6 +18,10 @@ function createShortcuts(): ConfiguredShortcuts { markAudioCard: null, openRuntimeOptions: null, openJimaku: null, + openSessionHelp: null, + openControllerSelect: null, + openControllerDebug: null, + toggleSubtitleSidebar: null, }; } diff --git a/src/main/runtime/global-shortcuts.test.ts b/src/main/runtime/global-shortcuts.test.ts index a2fe05cf..995c0ce1 100644 --- a/src/main/runtime/global-shortcuts.test.ts +++ b/src/main/runtime/global-shortcuts.test.ts @@ -22,6 +22,10 @@ function createShortcuts(): ConfiguredShortcuts { markAudioCard: null, openRuntimeOptions: null, openJimaku: null, + openSessionHelp: null, + openControllerSelect: null, + openControllerDebug: null, + toggleSubtitleSidebar: null, }; } diff --git a/src/main/runtime/jimaku-open.ts b/src/main/runtime/jimaku-open.ts new file mode 100644 index 00000000..6cd6096c --- /dev/null +++ b/src/main/runtime/jimaku-open.ts @@ -0,0 +1,48 @@ +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; + +const JIMAKU_MODAL: OverlayHostedModal = 'jimaku'; +const JIMAKU_OPEN_TIMEOUT_MS = 1500; + +export async function openJimakuModal(deps: { + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; +}): Promise { + return await retryOverlayModalOpen( + { + waitForModalOpen: deps.waitForModalOpen, + logWarn: deps.logWarn, + }, + { + modal: JIMAKU_MODAL, + timeoutMs: JIMAKU_OPEN_TIMEOUT_MS, + retryWarning: + 'Jimaku modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + sendOpen: () => + openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, + ensureOverlayWindowsReadyForVisibilityActions: + deps.ensureOverlayWindowsReadyForVisibilityActions, + sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, + }, + { + channel: IPC_CHANNELS.event.jimakuOpen, + modal: JIMAKU_MODAL, + preferModalWindow: true, + }, + ), + }, + ); +} diff --git a/src/main/runtime/overlay-hosted-modal-open.ts b/src/main/runtime/overlay-hosted-modal-open.ts index ab850da8..15366ae8 100644 --- a/src/main/runtime/overlay-hosted-modal-open.ts +++ b/src/main/runtime/overlay-hosted-modal-open.ts @@ -27,3 +27,31 @@ export function openOverlayHostedModal( preferModalWindow: input.preferModalWindow, }); } + +export async function retryOverlayModalOpen( + deps: { + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; + }, + input: { + modal: OverlayHostedModal; + timeoutMs: number; + retryWarning: string; + sendOpen: () => boolean; + }, +): Promise { + if (!input.sendOpen()) { + return false; + } + + if (await deps.waitForModalOpen(input.modal, input.timeoutMs)) { + return true; + } + + deps.logWarn(input.retryWarning); + if (!input.sendOpen()) { + return false; + } + + return await deps.waitForModalOpen(input.modal, input.timeoutMs); +} diff --git a/src/main/runtime/overlay-modal-input-state.test.ts b/src/main/runtime/overlay-modal-input-state.test.ts index cda2d194..694f7e1c 100644 --- a/src/main/runtime/overlay-modal-input-state.test.ts +++ b/src/main/runtime/overlay-modal-input-state.test.ts @@ -23,6 +23,9 @@ function createModalWindow() { setIgnoreMouseEvents: (ignore: boolean) => { calls.push(`ignore:${ignore}`); }, + setFocusable: (focusable: boolean) => { + calls.push(`focusable:${focusable}`); + }, setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => { calls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`); }, @@ -58,6 +61,7 @@ test('overlay modal input state activates modal window interactivity and syncs d assert.equal(state.getModalInputExclusive(), true); assert.deepEqual(modalWindow.calls, [ + 'focusable:true', 'ignore:false', 'top:true:screen-saver:1', 'focus', @@ -66,6 +70,25 @@ test('overlay modal input state activates modal window interactivity and syncs d assert.deepEqual(calls, ['shortcuts:true', 'visibility']); }); +test('overlay modal input state restores main window focus on deactivation', () => { + const modalWindow = createModalWindow(); + const calls: string[] = []; + const state = createOverlayModalInputState({ + getModalWindow: () => modalWindow as never, + syncOverlayShortcutsForModal: () => {}, + syncOverlayVisibilityForModal: () => {}, + restoreMainWindowFocus: () => { + calls.push('restore-focus'); + }, + }); + + state.handleModalInputStateChange(true); + assert.deepEqual(calls, []); + + state.handleModalInputStateChange(false); + assert.deepEqual(calls, ['restore-focus']); +}); + test('overlay modal input state is idempotent for unchanged state', () => { const calls: string[] = []; const state = createOverlayModalInputState({ diff --git a/src/main/runtime/overlay-modal-input-state.ts b/src/main/runtime/overlay-modal-input-state.ts index b095ca13..fd49a952 100644 --- a/src/main/runtime/overlay-modal-input-state.ts +++ b/src/main/runtime/overlay-modal-input-state.ts @@ -1,9 +1,30 @@ import type { BrowserWindow } from 'electron'; +function requestOverlayApplicationFocus(): void { + try { + const electron = require('electron') as { + app?: { + focus?: (options?: { steal?: boolean }) => void; + }; + }; + electron.app?.focus?.({ steal: true }); + } catch { + // Ignore focus-steal failures in non-Electron test environments. + } +} + +function setWindowFocusable(window: BrowserWindow): void { + const maybeFocusableWindow = window as BrowserWindow & { + setFocusable?: (focusable: boolean) => void; + }; + maybeFocusableWindow.setFocusable?.(true); +} + export type OverlayModalInputStateDeps = { getModalWindow: () => BrowserWindow | null; syncOverlayShortcutsForModal: (isActive: boolean) => void; syncOverlayVisibilityForModal: () => void; + restoreMainWindowFocus?: () => void; }; export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) { @@ -18,6 +39,8 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) { if (isActive) { const modalWindow = deps.getModalWindow(); if (modalWindow && !modalWindow.isDestroyed()) { + setWindowFocusable(modalWindow); + requestOverlayApplicationFocus(); modalWindow.setIgnoreMouseEvents(false); modalWindow.setAlwaysOnTop(true, 'screen-saver', 1); modalWindow.focus(); @@ -29,6 +52,9 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) { deps.syncOverlayShortcutsForModal(isActive); deps.syncOverlayVisibilityForModal(); + if (!isActive) { + deps.restoreMainWindowFocus?.(); + } }; return { diff --git a/src/main/runtime/playlist-browser-open.test.ts b/src/main/runtime/playlist-browser-open.test.ts index 970d10e9..9fe9ef4e 100644 --- a/src/main/runtime/playlist-browser-open.test.ts +++ b/src/main/runtime/playlist-browser-open.test.ts @@ -3,10 +3,10 @@ import test from 'node:test'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import { openPlaylistBrowser } from './playlist-browser-open'; -test('playlist browser open bootstraps overlay runtime before dispatching the modal event', () => { +test('playlist browser open bootstraps overlay runtime and sends modal event with preferModalWindow', async () => { const calls: string[] = []; - const opened = openPlaylistBrowser({ + const opened = await openPlaylistBrowser({ ensureOverlayStartupPrereqs: () => { calls.push('prereqs'); }, @@ -18,11 +18,31 @@ test('playlist browser open bootstraps overlay runtime before dispatching the mo assert.equal(payload, undefined); assert.deepEqual(runtimeOptions, { restoreOnModalClose: 'playlist-browser', + preferModalWindow: true, }); return true; }, + waitForModalOpen: async () => true, + logWarn: () => {}, }); assert.equal(opened, true); assert.deepEqual(calls, ['prereqs', 'windows', `send:${IPC_CHANNELS.event.playlistBrowserOpen}`]); }); + +test('playlist browser open retries after first attempt timeout', async () => { + let attempt = 0; + const opened = await openPlaylistBrowser({ + ensureOverlayStartupPrereqs: () => {}, + ensureOverlayWindowsReadyForVisibilityActions: () => {}, + sendToActiveOverlayWindow: () => true, + waitForModalOpen: async () => { + attempt += 1; + return attempt >= 2; + }, + logWarn: () => {}, + }); + + assert.equal(opened, true); + assert.equal(attempt, 2); +}); diff --git a/src/main/runtime/playlist-browser-open.ts b/src/main/runtime/playlist-browser-open.ts index ba4ce1f8..db4fe4e0 100644 --- a/src/main/runtime/playlist-browser-open.ts +++ b/src/main/runtime/playlist-browser-open.ts @@ -1,9 +1,11 @@ import type { OverlayHostedModal } from '../../shared/ipc/contracts'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; const PLAYLIST_BROWSER_MODAL: OverlayHostedModal = 'playlist-browser'; +const PLAYLIST_BROWSER_OPEN_TIMEOUT_MS = 1500; -export function openPlaylistBrowser(deps: { +export async function openPlaylistBrowser(deps: { ensureOverlayStartupPrereqs: () => void; ensureOverlayWindowsReadyForVisibilityActions: () => void; sendToActiveOverlayWindow: ( @@ -14,10 +16,33 @@ export function openPlaylistBrowser(deps: { preferModalWindow?: boolean; }, ) => boolean; -}): boolean { - deps.ensureOverlayStartupPrereqs(); - deps.ensureOverlayWindowsReadyForVisibilityActions(); - return deps.sendToActiveOverlayWindow(IPC_CHANNELS.event.playlistBrowserOpen, undefined, { - restoreOnModalClose: PLAYLIST_BROWSER_MODAL, - }); + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; +}): Promise { + return await retryOverlayModalOpen( + { + waitForModalOpen: deps.waitForModalOpen, + logWarn: deps.logWarn, + }, + { + modal: PLAYLIST_BROWSER_MODAL, + timeoutMs: PLAYLIST_BROWSER_OPEN_TIMEOUT_MS, + retryWarning: + 'Playlist browser modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + sendOpen: () => + openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, + ensureOverlayWindowsReadyForVisibilityActions: + deps.ensureOverlayWindowsReadyForVisibilityActions, + sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, + }, + { + channel: IPC_CHANNELS.event.playlistBrowserOpen, + modal: PLAYLIST_BROWSER_MODAL, + preferModalWindow: true, + }, + ), + }, + ); } diff --git a/src/main/runtime/runtime-options-open.ts b/src/main/runtime/runtime-options-open.ts index 3296cfd3..bf73d896 100644 --- a/src/main/runtime/runtime-options-open.ts +++ b/src/main/runtime/runtime-options-open.ts @@ -1,5 +1,5 @@ import type { OverlayHostedModal } from '../../shared/ipc/contracts'; -import { openOverlayHostedModal } from './overlay-hosted-modal-open'; +import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; const RUNTIME_OPTIONS_MODAL: OverlayHostedModal = 'runtime-options'; const RUNTIME_OPTIONS_OPEN_TIMEOUT_MS = 1500; @@ -18,36 +18,30 @@ export async function openRuntimeOptionsModal(deps: { waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; logWarn: (message: string) => void; }): Promise { - const sendOpen = (): boolean => { - return openOverlayHostedModal( - { - ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, - ensureOverlayWindowsReadyForVisibilityActions: - deps.ensureOverlayWindowsReadyForVisibilityActions, - sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, - }, - { - channel: 'runtime-options:open', - modal: RUNTIME_OPTIONS_MODAL, - preferModalWindow: true, - }, - ); - }; - - if (!sendOpen()) { - return false; - } - - if (await deps.waitForModalOpen(RUNTIME_OPTIONS_MODAL, RUNTIME_OPTIONS_OPEN_TIMEOUT_MS)) { - return true; - } - - deps.logWarn( - 'Runtime options modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + return await retryOverlayModalOpen( + { + waitForModalOpen: deps.waitForModalOpen, + logWarn: deps.logWarn, + }, + { + modal: RUNTIME_OPTIONS_MODAL, + timeoutMs: RUNTIME_OPTIONS_OPEN_TIMEOUT_MS, + retryWarning: + 'Runtime options modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + sendOpen: () => + openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, + ensureOverlayWindowsReadyForVisibilityActions: + deps.ensureOverlayWindowsReadyForVisibilityActions, + sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, + }, + { + channel: 'runtime-options:open', + modal: RUNTIME_OPTIONS_MODAL, + preferModalWindow: true, + }, + ), + }, ); - if (!sendOpen()) { - return false; - } - - return await deps.waitForModalOpen(RUNTIME_OPTIONS_MODAL, RUNTIME_OPTIONS_OPEN_TIMEOUT_MS); } diff --git a/src/main/runtime/session-help-open.ts b/src/main/runtime/session-help-open.ts new file mode 100644 index 00000000..86b7b59f --- /dev/null +++ b/src/main/runtime/session-help-open.ts @@ -0,0 +1,48 @@ +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; + +const SESSION_HELP_MODAL: OverlayHostedModal = 'session-help'; +const SESSION_HELP_OPEN_TIMEOUT_MS = 1500; + +export async function openSessionHelpModal(deps: { + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; +}): Promise { + return await retryOverlayModalOpen( + { + waitForModalOpen: deps.waitForModalOpen, + logWarn: deps.logWarn, + }, + { + modal: SESSION_HELP_MODAL, + timeoutMs: SESSION_HELP_OPEN_TIMEOUT_MS, + retryWarning: + 'Session help modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + sendOpen: () => + openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, + ensureOverlayWindowsReadyForVisibilityActions: + deps.ensureOverlayWindowsReadyForVisibilityActions, + sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, + }, + { + channel: IPC_CHANNELS.event.sessionHelpOpen, + modal: SESSION_HELP_MODAL, + preferModalWindow: true, + }, + ), + }, + ); +} diff --git a/src/main/runtime/youtube-picker-open.ts b/src/main/runtime/youtube-picker-open.ts index fe231fe2..aa1b593e 100644 --- a/src/main/runtime/youtube-picker-open.ts +++ b/src/main/runtime/youtube-picker-open.ts @@ -1,5 +1,6 @@ import type { YoutubePickerOpenPayload } from '../../types'; import type { OverlayHostedModal } from '../../shared/ipc/contracts'; +import { retryOverlayModalOpen } from './overlay-hosted-modal-open'; const YOUTUBE_PICKER_MODAL: OverlayHostedModal = 'youtube-track-picker'; const YOUTUBE_PICKER_OPEN_TIMEOUT_MS = 1500; @@ -19,24 +20,21 @@ export async function openYoutubeTrackPicker( }, payload: YoutubePickerOpenPayload, ): Promise { - const sendPickerOpen = (): boolean => - deps.sendToActiveOverlayWindow('youtube:picker-open', payload, { - restoreOnModalClose: YOUTUBE_PICKER_MODAL, - preferModalWindow: true, - }); - - if (!sendPickerOpen()) { - return false; - } - if (await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS)) { - return true; - } - - deps.logWarn( - 'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.', + return await retryOverlayModalOpen( + { + waitForModalOpen: deps.waitForModalOpen, + logWarn: deps.logWarn, + }, + { + modal: YOUTUBE_PICKER_MODAL, + timeoutMs: YOUTUBE_PICKER_OPEN_TIMEOUT_MS, + retryWarning: + 'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.', + sendOpen: () => + deps.sendToActiveOverlayWindow('youtube:picker-open', payload, { + restoreOnModalClose: YOUTUBE_PICKER_MODAL, + preferModalWindow: true, + }), + }, ); - if (!sendPickerOpen()) { - return false; - } - return await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS); } diff --git a/src/preload.ts b/src/preload.ts index faaca5be..7bb846bb 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -123,6 +123,9 @@ function createQueuedIpcListenerWithPayload( } const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen); +const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen); +const onOpenControllerSelectEvent = createQueuedIpcListener(IPC_CHANNELS.event.controllerSelectOpen); +const onOpenControllerDebugEvent = createQueuedIpcListener(IPC_CHANNELS.event.controllerDebugOpen); const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen); const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload( IPC_CHANNELS.event.youtubePickerOpen, @@ -142,6 +145,9 @@ const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload payload as SubsyncManualPayload, ); +const onSubtitleSidebarToggleEvent = createQueuedIpcListener( + IPC_CHANNELS.event.subtitleSidebarToggle, +); const onKikuFieldGroupingRequestEvent = createQueuedIpcListenerWithPayload( IPC_CHANNELS.event.kikuFieldGroupingRequest, @@ -326,9 +332,13 @@ const electronAPI: ElectronAPI = { ); }, onOpenRuntimeOptions: onOpenRuntimeOptionsEvent, + onOpenSessionHelp: onOpenSessionHelpEvent, + onOpenControllerSelect: onOpenControllerSelectEvent, + onOpenControllerDebug: onOpenControllerDebugEvent, onOpenJimaku: onOpenJimakuEvent, onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent, onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent, + onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent, onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 815c0acd..aeb736d6 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -69,6 +69,10 @@ function installKeyboardTestGlobals() { markAudioCard: '', openRuntimeOptions: 'CommandOrControl+Shift+O', openJimaku: 'Ctrl+Shift+J', + openSessionHelp: 'CommandOrControl+Shift+H', + openControllerSelect: 'Alt+C', + openControllerDebug: 'Alt+Shift+C', + toggleSubtitleSidebar: '', toggleVisibleOverlayGlobal: '', }; let markActiveVideoWatchedResult = true; @@ -321,8 +325,6 @@ function installKeyboardTestGlobals() { function createKeyboardHandlerHarness() { const testGlobals = installKeyboardTestGlobals(); const subtitleRootClassList = createClassList(); - let controllerSelectOpenCount = 0; - let controllerDebugOpenCount = 0; let controllerSelectKeydownCount = 0; let playlistBrowserKeydownCount = 0; @@ -373,20 +375,12 @@ function createKeyboardHandlerHarness() { openSessionHelpModal: () => {}, appendClipboardVideoToQueue: () => {}, getPlaybackPaused: () => testGlobals.getPlaybackPaused(), - openControllerSelectModal: () => { - controllerSelectOpenCount += 1; - }, - openControllerDebugModal: () => { - controllerDebugOpenCount += 1; - }, }); return { ctx, handlers, testGlobals, - controllerSelectOpenCount: () => controllerSelectOpenCount, - controllerDebugOpenCount: () => controllerDebugOpenCount, controllerSelectKeydownCount: () => controllerSelectKeydownCount, playlistBrowserKeydownCount: () => playlistBrowserKeydownCount, setWordCount: (count: number) => { @@ -659,31 +653,78 @@ test('keyboard mode: controller helpers dispatch popup audio play/cycle and scro } }); -test('keyboard mode: Alt+Shift+C opens controller debug modal', async () => { - const { testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness(); +test('keyboard mode: configured controller debug binding dispatches session action', async () => { + const { testGlobals, handlers } = createKeyboardHandlerHarness(); try { await handlers.setupMpvInputForwarding(); + handlers.updateSessionBindings([ + { + sourcePath: 'shortcuts.openControllerDebug', + originalKey: 'Alt+Shift+D', + key: { code: 'KeyD', modifiers: ['alt', 'shift'] }, + actionType: 'session-action', + actionId: 'openControllerDebug', + }, + ] as never); testGlobals.dispatchKeydown({ - key: 'C', - code: 'KeyC', + key: 'D', + code: 'KeyD', altKey: true, shiftKey: true, }); - assert.equal(controllerDebugOpenCount(), 1); + assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'openControllerDebug', payload: undefined }]); } finally { testGlobals.restore(); } }); -test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup is visible', async () => { - const { ctx, testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness(); +test('keyboard mode: configured controller debug binding is not swallowed while popup is visible', async () => { + const { ctx, testGlobals, handlers } = createKeyboardHandlerHarness(); try { await handlers.setupMpvInputForwarding(); ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + handlers.updateSessionBindings([ + { + sourcePath: 'shortcuts.openControllerDebug', + originalKey: 'Alt+Shift+D', + key: { code: 'KeyD', modifiers: ['alt', 'shift'] }, + actionType: 'session-action', + actionId: 'openControllerDebug', + }, + ] as never); + + testGlobals.dispatchKeydown({ + key: 'D', + code: 'KeyD', + altKey: true, + shiftKey: true, + }); + + assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'openControllerDebug', payload: undefined }]); + } finally { + testGlobals.restore(); + } +}); + +test('keyboard mode: former fixed Alt+Shift+C does nothing when controller debug is remapped', async () => { + const { testGlobals, handlers } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.updateSessionBindings([ + { + sourcePath: 'shortcuts.openControllerDebug', + originalKey: 'Alt+Shift+D', + key: { code: 'KeyD', modifiers: ['alt', 'shift'] }, + actionType: 'session-action', + actionId: 'openControllerDebug', + }, + ] as never); testGlobals.dispatchKeydown({ key: 'C', @@ -692,7 +733,7 @@ test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup i shiftKey: true, }); - assert.equal(controllerDebugOpenCount(), 1); + assert.deepEqual(testGlobals.sessionActions, []); } finally { testGlobals.restore(); } diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 1e9bc42b..b4ad5dd5 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -27,8 +27,6 @@ export function createKeyboardHandlers( }) => void; appendClipboardVideoToQueue: () => void; getPlaybackPaused: () => Promise; - openControllerSelectModal: () => void; - openControllerDebugModal: () => void; toggleSubtitleSidebarModal?: () => void; }, ) { @@ -298,10 +296,6 @@ export function createKeyboardHandlers( return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat; } - function isControllerModalShortcut(e: KeyboardEvent): boolean { - return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC'; - } - function isSubtitleSidebarToggle(e: KeyboardEvent): boolean { const toggleKey = ctx.state.subtitleSidebarToggleKey; if (!toggleKey) return false; @@ -1040,10 +1034,7 @@ export function createKeyboardHandlers( return; } - if ( - (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) && - !isControllerModalShortcut(e) - ) { + if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) { if (handleYomitanPopupKeybind(e)) { e.preventDefault(); return; @@ -1100,16 +1091,6 @@ export function createKeyboardHandlers( return; } - if (isControllerModalShortcut(e)) { - e.preventDefault(); - if (e.shiftKey) { - options.openControllerDebugModal(); - } else { - options.openControllerSelectModal(); - } - return; - } - const keyString = keyEventToString(e); const binding = ctx.state.sessionBindingMap.get(keyString); if (binding) { diff --git a/src/renderer/modals/session-help.ts b/src/renderer/modals/session-help.ts index 65f10f81..f91210c8 100644 --- a/src/renderer/modals/session-help.ts +++ b/src/renderer/modals/session-help.ts @@ -586,31 +586,10 @@ export function createSessionHelpModal( } } - async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise { + function openSessionHelpModal(opening: SessionHelpBindingInfo): void { openBinding = opening; priorFocus = document.activeElement; - const dataLoaded = await render(); - - ctx.dom.sessionHelpShortcut.textContent = `Session help opened with ${formatBindingHint(openBinding)}`; - if (openBinding.fallbackUnavailable) { - ctx.dom.sessionHelpWarning.textContent = - 'Both Y-H and Y-K are bound; Y-K remains the fallback for this session.'; - } else if (openBinding.fallbackUsed) { - ctx.dom.sessionHelpWarning.textContent = 'Y-H is already bound; using Y-K as fallback.'; - } else { - ctx.dom.sessionHelpWarning.textContent = ''; - } - if (dataLoaded) { - ctx.dom.sessionHelpStatus.textContent = - 'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.'; - } else { - ctx.dom.sessionHelpStatus.textContent = - 'Session help data is unavailable right now. Press Esc to close.'; - ctx.dom.sessionHelpWarning.textContent = - 'Unable to load latest shortcut settings from the runtime.'; - } - ctx.state.sessionHelpModalOpen = true; options.syncSettingsModalSubtitleSuppression(); ctx.dom.overlay.classList.add('interactive'); @@ -623,6 +602,17 @@ export function createSessionHelpModal( window.electronAPI.setIgnoreMouseEvents(false); } + ctx.dom.sessionHelpShortcut.textContent = `Session help opened with ${formatBindingHint(openBinding)}`; + if (openBinding.fallbackUnavailable) { + ctx.dom.sessionHelpWarning.textContent = + 'Both Y-H and Y-K are bound; Y-K remains the fallback for this session.'; + } else if (openBinding.fallbackUsed) { + ctx.dom.sessionHelpWarning.textContent = 'Y-H is already bound; using Y-K as fallback.'; + } else { + ctx.dom.sessionHelpWarning.textContent = ''; + } + ctx.dom.sessionHelpStatus.textContent = 'Loading session help data...'; + if (focusGuard === null) { focusGuard = (event: FocusEvent) => { if (!ctx.state.sessionHelpModalOpen) return; @@ -639,6 +629,19 @@ export function createSessionHelpModal( requestOverlayFocus(); window.focus(); enforceModalFocus(); + + void render().then((dataLoaded) => { + if (!ctx.state.sessionHelpModalOpen) return; + if (dataLoaded) { + ctx.dom.sessionHelpStatus.textContent = + 'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.'; + } else { + ctx.dom.sessionHelpStatus.textContent = + 'Session help data is unavailable right now. Press Esc to close.'; + ctx.dom.sessionHelpWarning.textContent = + 'Unable to load latest shortcut settings from the runtime.'; + } + }); } function closeSessionHelpModal(): void { @@ -648,6 +651,7 @@ export function createSessionHelpModal( options.syncSettingsModalSubtitleSuppression(); ctx.dom.sessionHelpModal.classList.add('hidden'); ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'true'); + window.electronAPI.notifyOverlayModalClosed('session-help'); if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { ctx.dom.overlay.classList.remove('interactive'); } diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 71420299..14b22016 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -178,14 +178,6 @@ const keyboardHandlers = createKeyboardHandlers(ctx, { void window.electronAPI.appendClipboardVideoToQueue(); }, getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(), - openControllerSelectModal: () => { - controllerSelectModal.openControllerSelectModal(); - window.electronAPI.notifyOverlayModalOpened('controller-select'); - }, - openControllerDebugModal: () => { - controllerDebugModal.openControllerDebugModal(); - window.electronAPI.notifyOverlayModalOpened('controller-debug'); - }, toggleSubtitleSidebarModal: () => { void subtitleSidebarModal.toggleSubtitleSidebarModal(); }, @@ -437,6 +429,28 @@ function registerModalOpenHandlers(): void { window.electronAPI.notifyOverlayModalOpened('runtime-options'); }); }); + window.electronAPI.onOpenSessionHelp(() => { + runGuarded('session-help:open', () => { + sessionHelpModal.openSessionHelpModal({ + bindingKey: 'KeyH', + fallbackUsed: false, + fallbackUnavailable: false, + }); + window.electronAPI.notifyOverlayModalOpened('session-help'); + }); + }); + window.electronAPI.onOpenControllerSelect(() => { + runGuarded('controller-select:open', () => { + controllerSelectModal.openControllerSelectModal(); + window.electronAPI.notifyOverlayModalOpened('controller-select'); + }); + }); + window.electronAPI.onOpenControllerDebug(() => { + runGuarded('controller-debug:open', () => { + controllerDebugModal.openControllerDebugModal(); + window.electronAPI.notifyOverlayModalOpened('controller-debug'); + }); + }); window.electronAPI.onOpenJimaku(() => { runGuarded('jimaku:open', () => { jimakuModal.openJimakuModal(); @@ -492,6 +506,12 @@ function registerKeyboardCommandHandlers(): void { keyboardHandlers.handleLookupWindowToggleRequested(); }); }); + + window.electronAPI.onSubtitleSidebarToggle(() => { + runGuarded('subtitle-sidebar:toggle', () => { + void subtitleSidebarModal.toggleSubtitleSidebarModal(); + }); + }); } function runGuarded(action: string, fn: () => void): void { diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 3d703a33..5b83d86c 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -11,6 +11,7 @@ export const OVERLAY_HOSTED_MODALS = [ 'controller-select', 'controller-debug', 'subtitle-sidebar', + 'session-help', ] as const; export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number]; @@ -111,6 +112,10 @@ export const IPC_CHANNELS = { playlistBrowserOpen: 'playlist-browser:open', keyboardModeToggleRequested: 'keyboard-mode-toggle:requested', lookupWindowToggleRequested: 'lookup-window-toggle:requested', + sessionHelpOpen: 'session-help:open', + controllerSelectOpen: 'controller-select:open', + controllerDebugOpen: 'controller-debug:open', + subtitleSidebarToggle: 'subtitle-sidebar:toggle', configHotReload: 'config:hot-reload', }, } as const; diff --git a/src/types/config.ts b/src/types/config.ts index e6cc4208..f6e410c5 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -89,6 +89,10 @@ export interface ShortcutsConfig { markAudioCard?: string | null; openRuntimeOptions?: string | null; openJimaku?: string | null; + openSessionHelp?: string | null; + openControllerSelect?: string | null; + openControllerDebug?: string | null; + toggleSubtitleSidebar?: string | null; } export interface Config { diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 0029fde1..2c9d4b36 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -399,9 +399,13 @@ export interface ElectronAPI { cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => Promise; onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void; onOpenRuntimeOptions: (callback: () => void) => void; + onOpenSessionHelp: (callback: () => void) => void; + onOpenControllerSelect: (callback: () => void) => void; + onOpenControllerDebug: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void; onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void; onOpenPlaylistBrowser: (callback: () => void) => void; + onSubtitleSidebarToggle: (callback: () => void) => void; onCancelYoutubeTrackPicker: (callback: () => void) => void; onKeyboardModeToggleRequested: (callback: () => void) => void; onLookupWindowToggleRequested: (callback: () => void) => void; @@ -427,7 +431,8 @@ export interface ElectronAPI { | 'kiku' | 'controller-select' | 'controller-debug' - | 'subtitle-sidebar', + | 'subtitle-sidebar' + | 'session-help', ) => void; notifyOverlayModalOpened: ( modal: @@ -439,7 +444,8 @@ export interface ElectronAPI { | 'kiku' | 'controller-select' | 'controller-debug' - | 'subtitle-sidebar', + | 'subtitle-sidebar' + | 'session-help', ) => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; diff --git a/src/types/session-bindings.ts b/src/types/session-bindings.ts index 8da90521..9b510bc8 100644 --- a/src/types/session-bindings.ts +++ b/src/types/session-bindings.ts @@ -11,8 +11,12 @@ export type SessionActionId = | 'mineSentence' | 'mineSentenceMultiple' | 'toggleSecondarySub' + | 'toggleSubtitleSidebar' | 'markAudioCard' | 'openRuntimeOptions' + | 'openSessionHelp' + | 'openControllerSelect' + | 'openControllerDebug' | 'openJimaku' | 'openYoutubePicker' | 'openPlaylistBrowser'