Honor configured controller shortcuts and clean up modal opens

This commit is contained in:
2026-04-11 00:52:18 -07:00
parent 29b85fd084
commit 1bd696ef11
49 changed files with 944 additions and 211 deletions

View File

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

View File

@@ -173,7 +173,11 @@
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options 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. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================

View File

@@ -536,7 +536,11 @@ See `config.example.jsonc` for detailed configuration options.
"mineSentenceMultiple": "CommandOrControl+Shift+S", "mineSentenceMultiple": "CommandOrControl+Shift+S",
"markAudioCard": "CommandOrControl+Shift+A", "markAudioCard": "CommandOrControl+Shift+A",
"openRuntimeOptions": "CommandOrControl+Shift+O", "openRuntimeOptions": "CommandOrControl+Shift+O",
"openSessionHelp": "CommandOrControl+Shift+H",
"openControllerSelect": "Alt+C",
"openControllerDebug": "Alt+Shift+C",
"openJimaku": "Ctrl+Shift+J", "openJimaku": "Ctrl+Shift+J",
"toggleSubtitleSidebar": "\\",
"multiCopyTimeoutMs": 3000 "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"`) | | `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"`) | | `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"`) | | `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"`) | | `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. **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. - Controller input is only active while keyboard-only mode is enabled.
- Keyboard-only mode continues to work normally without a controller. - Keyboard-only mode continues to work normally without a controller.
- By default SubMiner uses the first connected 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. - 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`. - `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. - 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. - 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 ### 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 `/`: You can filter the modal quickly with `/`:

View File

@@ -173,7 +173,11 @@
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options 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. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================

View File

@@ -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+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | | `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+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | | `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | | `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 ## 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 | | Shortcut | Action | Configurable |
| ------------- | ------------------------------ | ------------ | | ------------- | ------------------------------------ | -------------------------------- |
| `Alt+C` | Open controller config + remap modal | Fixed | | `Alt+C` | Open controller config + remap modal | `shortcuts.openControllerSelect` |
| `Alt+Shift+C` | Open controller debug modal | Fixed | | `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. 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-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 |
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).

View File

@@ -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. 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. 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. 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. 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. 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 ### 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. 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. 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 ### Drag-and-Drop

View File

@@ -133,6 +133,8 @@ function M.create(ctx)
return { "--mine-sentence-count", tostring(payload and payload.count or 1) } return { "--mine-sentence-count", tostring(payload and payload.count or 1) }
elseif action_id == "toggleSecondarySub" then elseif action_id == "toggleSecondarySub" then
return { "--toggle-secondary-sub" } return { "--toggle-secondary-sub" }
elseif action_id == "toggleSubtitleSidebar" then
return { "--toggle-subtitle-sidebar" }
elseif action_id == "markAudioCard" then elseif action_id == "markAudioCard" then
return { "--mark-audio-card" } return { "--mark-audio-card" }
elseif action_id == "openRuntimeOptions" then elseif action_id == "openRuntimeOptions" then
@@ -141,6 +143,12 @@ function M.create(ctx)
return { "--open-jimaku" } return { "--open-jimaku" }
elseif action_id == "openYoutubePicker" then elseif action_id == "openYoutubePicker" then
return { "--open-youtube-picker" } 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 elseif action_id == "openPlaylistBrowser" then
return { "--open-playlist-browser" } return { "--open-playlist-browser" }
elseif action_id == "replayCurrentSubtitle" then elseif action_id == "replayCurrentSubtitle" then

View File

@@ -90,6 +90,12 @@ function M.create(ctx)
mp.add_key_binding("y-c", "subminer-status", function() mp.add_key_binding("y-c", "subminer-status", function()
process.check_status() process.check_status()
end) 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 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() mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", function()
aniskip.skip_intro_now() aniskip.skip_intro_now()

View File

@@ -25,7 +25,11 @@ export interface CliArgs {
triggerSubsync: boolean; triggerSubsync: boolean;
markAudioCard: boolean; markAudioCard: boolean;
toggleStatsOverlay: boolean; toggleStatsOverlay: boolean;
toggleSubtitleSidebar: boolean;
openRuntimeOptions: boolean; openRuntimeOptions: boolean;
openSessionHelp: boolean;
openControllerSelect: boolean;
openControllerDebug: boolean;
openJimaku: boolean; openJimaku: boolean;
openYoutubePicker: boolean; openYoutubePicker: boolean;
openPlaylistBrowser: boolean; openPlaylistBrowser: boolean;
@@ -115,7 +119,11 @@ export function parseArgs(argv: string[]): CliArgs {
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
toggleStatsOverlay: false, toggleStatsOverlay: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openSessionHelp: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false, openJimaku: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,
@@ -218,7 +226,11 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--trigger-subsync') args.triggerSubsync = true; else if (arg === '--trigger-subsync') args.triggerSubsync = true;
else if (arg === '--mark-audio-card') args.markAudioCard = true; else if (arg === '--mark-audio-card') args.markAudioCard = true;
else if (arg === '--toggle-stats-overlay') args.toggleStatsOverlay = 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-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-jimaku') args.openJimaku = true;
else if (arg === '--open-youtube-picker') args.openYoutubePicker = true; else if (arg === '--open-youtube-picker') args.openYoutubePicker = true;
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true; else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
@@ -442,7 +454,11 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.triggerSubsync || args.triggerSubsync ||
args.markAudioCard || args.markAudioCard ||
args.toggleStatsOverlay || args.toggleStatsOverlay ||
args.toggleSubtitleSidebar ||
args.openRuntimeOptions || args.openRuntimeOptions ||
args.openSessionHelp ||
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku || args.openJimaku ||
args.openYoutubePicker || args.openYoutubePicker ||
args.openPlaylistBrowser || args.openPlaylistBrowser ||
@@ -505,7 +521,11 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.triggerSubsync && !args.triggerSubsync &&
!args.markAudioCard && !args.markAudioCard &&
!args.toggleStatsOverlay && !args.toggleStatsOverlay &&
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions && !args.openRuntimeOptions &&
!args.openSessionHelp &&
!args.openControllerSelect &&
!args.openControllerDebug &&
!args.openJimaku && !args.openJimaku &&
!args.openYoutubePicker && !args.openYoutubePicker &&
!args.openPlaylistBrowser && !args.openPlaylistBrowser &&
@@ -559,7 +579,11 @@ export function shouldStartApp(args: CliArgs): boolean {
args.triggerSubsync || args.triggerSubsync ||
args.markAudioCard || args.markAudioCard ||
args.toggleStatsOverlay || args.toggleStatsOverlay ||
args.toggleSubtitleSidebar ||
args.openRuntimeOptions || args.openRuntimeOptions ||
args.openSessionHelp ||
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku || args.openJimaku ||
args.openYoutubePicker || args.openYoutubePicker ||
args.openPlaylistBrowser || args.openPlaylistBrowser ||
@@ -608,7 +632,11 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.triggerSubsync && !args.triggerSubsync &&
!args.markAudioCard && !args.markAudioCard &&
!args.toggleStatsOverlay && !args.toggleStatsOverlay &&
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions && !args.openRuntimeOptions &&
!args.openSessionHelp &&
!args.openControllerSelect &&
!args.openControllerDebug &&
!args.openJimaku && !args.openJimaku &&
!args.openYoutubePicker && !args.openYoutubePicker &&
!args.openPlaylistBrowser && !args.openPlaylistBrowser &&
@@ -658,10 +686,14 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.mineSentenceMultiple || args.mineSentenceMultiple ||
args.updateLastCardFromClipboard || args.updateLastCardFromClipboard ||
args.toggleSecondarySub || args.toggleSecondarySub ||
args.toggleSubtitleSidebar ||
args.triggerFieldGrouping || args.triggerFieldGrouping ||
args.triggerSubsync || args.triggerSubsync ||
args.markAudioCard || args.markAudioCard ||
args.openRuntimeOptions || args.openRuntimeOptions ||
args.openSessionHelp ||
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku || args.openJimaku ||
args.openYoutubePicker || args.openYoutubePicker ||
args.openPlaylistBrowser || args.openPlaylistBrowser ||

View File

@@ -35,7 +35,11 @@ ${B}Mining${R}
--trigger-field-grouping Run Kiku field grouping --trigger-field-grouping Run Kiku field grouping
--trigger-subsync Run subtitle sync --trigger-subsync Run subtitle sync
--toggle-secondary-sub Cycle secondary subtitle mode --toggle-secondary-sub Cycle secondary subtitle mode
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
--open-runtime-options Open runtime options palette --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} ${B}AniList${R}
--anilist-setup Open AniList authentication flow --anilist-setup Open AniList authentication flow

View File

@@ -88,6 +88,10 @@ export const CORE_DEFAULT_CONFIG: Pick<
markAudioCard: 'CommandOrControl+Shift+A', markAudioCard: 'CommandOrControl+Shift+A',
openRuntimeOptions: 'CommandOrControl+Shift+O', openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J', openJimaku: 'Ctrl+Shift+J',
openSessionHelp: 'CommandOrControl+Shift+H',
openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C',
toggleSubtitleSidebar: '\\',
}, },
secondarySub: { secondarySub: {
secondarySubLanguages: [], secondarySubLanguages: [],

View File

@@ -29,7 +29,11 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
toggleStatsOverlay: false, toggleStatsOverlay: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openSessionHelp: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false, openJimaku: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,

View File

@@ -30,8 +30,12 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
toggleStatsOverlay: false, toggleStatsOverlay: false,
toggleSubtitleSidebar: false,
refreshKnownWords: false, refreshKnownWords: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openSessionHelp: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false, openJimaku: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,

View File

@@ -402,8 +402,32 @@ export function handleCliCommand(
'toggleStatsOverlay', 'toggleStatsOverlay',
'Stats toggle failed', 'Stats toggle failed',
); );
} else if (args.toggleSubtitleSidebar) {
dispatchCliSessionAction(
{ actionId: 'toggleSubtitleSidebar' },
'toggleSubtitleSidebar',
'Subtitle sidebar toggle failed',
);
} else if (args.openRuntimeOptions) { } else if (args.openRuntimeOptions) {
deps.openRuntimeOptionsPalette(); 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) { } else if (args.openJimaku) {
dispatchCliSessionAction({ actionId: 'openJimaku' }, 'openJimaku', 'Open jimaku failed'); dispatchCliSessionAction({ actionId: 'openJimaku' }, 'openJimaku', 'Open jimaku failed');
} else if (args.openYoutubePicker) { } else if (args.openYoutubePicker) {

View File

@@ -27,6 +27,10 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
markAudioCard: null, markAudioCard: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
...overrides, ...overrides,
}; };
} }

View File

@@ -22,6 +22,10 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
markAudioCard: null, markAudioCard: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
...overrides, ...overrides,
}; };
} }

View File

@@ -13,8 +13,12 @@ export interface SessionActionExecutorDeps {
mineSentenceCard: () => Promise<void>; mineSentenceCard: () => Promise<void>;
mineSentenceCount: (count: number) => void; mineSentenceCount: (count: number) => void;
toggleSecondarySub: () => void; toggleSecondarySub: () => void;
toggleSubtitleSidebar: () => void;
markLastCardAsAudioCard: () => Promise<void>; markLastCardAsAudioCard: () => Promise<void>;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openSessionHelp: () => void;
openControllerSelect: () => void;
openControllerDebug: () => void;
openJimaku: () => void; openJimaku: () => void;
openYoutubeTrackPicker: () => void | Promise<void>; openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>; openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
@@ -65,12 +69,24 @@ export async function dispatchSessionAction(
case 'toggleSecondarySub': case 'toggleSecondarySub':
deps.toggleSecondarySub(); deps.toggleSecondarySub();
return; return;
case 'toggleSubtitleSidebar':
deps.toggleSubtitleSidebar();
return;
case 'markAudioCard': case 'markAudioCard':
await deps.markLastCardAsAudioCard(); await deps.markLastCardAsAudioCard();
return; return;
case 'openRuntimeOptions': case 'openRuntimeOptions':
deps.openRuntimeOptionsPalette(); deps.openRuntimeOptionsPalette();
return; return;
case 'openSessionHelp':
deps.openSessionHelp();
return;
case 'openControllerSelect':
deps.openControllerSelect();
return;
case 'openControllerDebug':
deps.openControllerDebug();
return;
case 'openJimaku': case 'openJimaku':
deps.openJimaku(); deps.openJimaku();
return; return;

View File

@@ -20,6 +20,10 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
markAudioCard: null, markAudioCard: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
...overrides, ...overrides,
}; };
} }
@@ -33,6 +37,7 @@ test('compileSessionBindings merges shortcuts and keybindings into one canonical
shortcuts: createShortcuts({ shortcuts: createShortcuts({
toggleVisibleOverlayGlobal: 'Alt+Shift+O', toggleVisibleOverlayGlobal: 'Alt+Shift+O',
openJimaku: 'Ctrl+Shift+J', openJimaku: 'Ctrl+Shift+J',
openControllerSelect: 'Alt+C',
}), }),
keybindings: [ keybindings: [
createKeybinding('KeyF', ['cycle', 'fullscreen']), createKeybinding('KeyF', ['cycle', 'fullscreen']),
@@ -68,6 +73,13 @@ test('compileSessionBindings merges shortcuts and keybindings into one canonical
modifiers: ['ctrl', 'shift'], modifiers: ['ctrl', 'shift'],
target: 'openYoutubePicker', target: 'openYoutubePicker',
}, },
{
actionType: 'session-action',
sourcePath: 'shortcuts.openControllerSelect',
code: 'KeyC',
modifiers: ['alt'],
target: 'openControllerSelect',
},
{ {
actionType: 'session-action', actionType: 'session-action',
sourcePath: 'shortcuts.openJimaku', sourcePath: 'shortcuts.openJimaku',

View File

@@ -45,6 +45,10 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
{ key: 'markAudioCard', actionId: 'markAudioCard' }, { key: 'markAudioCard', actionId: 'markAudioCard' },
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' }, { key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
{ key: 'openJimaku', actionId: 'openJimaku' }, { 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[] { function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] {

View File

@@ -29,7 +29,11 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
toggleStatsOverlay: false, toggleStatsOverlay: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openSessionHelp: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false, openJimaku: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,

View File

@@ -14,6 +14,10 @@ export interface ConfiguredShortcuts {
markAudioCard: string | null | undefined; markAudioCard: string | null | undefined;
openRuntimeOptions: string | null | undefined; openRuntimeOptions: string | null | undefined;
openJimaku: 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( export function resolveConfiguredShortcuts(
@@ -78,5 +82,17 @@ export function resolveConfiguredShortcuts(
openJimaku: normalizeShortcut( openJimaku: normalizeShortcut(
config.shortcuts?.openJimaku ?? defaultConfig.shortcuts?.openJimaku, 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,
),
}; };
} }

View File

@@ -453,6 +453,10 @@ import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state'; import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open'; import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-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 { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact'; import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact';
import { openOverlayHostedModal } from './main/runtime/overlay-hosted-modal-open'; import { openOverlayHostedModal } from './main/runtime/overlay-hosted-modal-open';
@@ -2211,8 +2215,21 @@ function setOverlayDebugVisualizationEnabled(enabled: boolean): void {
overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled); overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled);
} }
function openRuntimeOptionsPalette(): void { function createOverlayHostedModalOpenDeps(): {
void openRuntimeOptionsModalRuntime({ ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
} {
return {
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
ensureOverlayWindowsReadyForVisibilityActions: () => ensureOverlayWindowsReadyForVisibilityActions: () =>
ensureOverlayWindowsReadyForVisibilityActions(), ensureOverlayWindowsReadyForVisibilityActions(),
@@ -2220,33 +2237,62 @@ function openRuntimeOptionsPalette(): void {
sendToActiveOverlayWindow(channel, payload, runtimeOptions), sendToActiveOverlayWindow(channel, payload, runtimeOptions),
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs), waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
logWarn: (message) => logger.warn(message), logWarn: (message) => logger.warn(message),
}).then((opened) => { };
}
function openOverlayHostedModalWithOsd(
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
unavailableMessage: string,
failureLogMessage: string,
): void {
void openModal(createOverlayHostedModalOpenDeps()).then((opened) => {
if (!opened) { if (!opened) {
showMpvOsd('Runtime options overlay unavailable.'); showMpvOsd(unavailableMessage);
} }
}).catch((error) => { }).catch((error) => {
logger.error('Failed to open runtime options overlay.', error); logger.error(failureLogMessage, error);
showMpvOsd('Runtime options overlay unavailable.'); showMpvOsd(unavailableMessage);
}); });
} }
function openJimakuOverlay(): void { function openRuntimeOptionsPalette(): void {
const opened = openOverlayHostedModal( openOverlayHostedModalWithOsd(
{ openRuntimeOptionsModalRuntime,
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), 'Runtime options overlay unavailable.',
ensureOverlayWindowsReadyForVisibilityActions: () => 'Failed to open runtime options overlay.',
ensureOverlayWindowsReadyForVisibilityActions(), );
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => }
sendToActiveOverlayWindow(channel, payload, runtimeOptions),
}, function openJimakuOverlay(): void {
{ openOverlayHostedModalWithOsd(
channel: IPC_CHANNELS.event.jimakuOpen, openJimakuModalRuntime,
modal: 'jimaku', '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 { function openPlaylistBrowser(): void {
@@ -2254,16 +2300,11 @@ function openPlaylistBrowser(): void {
showMpvOsd('Playlist browser requires active playback.'); showMpvOsd('Playlist browser requires active playback.');
return; return;
} }
const opened = openPlaylistBrowserRuntime({ openOverlayHostedModalWithOsd(
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), openPlaylistBrowserRuntime,
ensureOverlayWindowsReadyForVisibilityActions: () => 'Playlist browser overlay unavailable.',
ensureOverlayWindowsReadyForVisibilityActions(), 'Failed to open playlist browser overlay.',
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => );
sendToActiveOverlayWindow(channel, payload, runtimeOptions),
});
if (!opened) {
showMpvOsd('Playlist browser overlay unavailable.');
}
} }
function getResolvedConfig() { function getResolvedConfig() {
@@ -4278,6 +4319,10 @@ function handleCycleSecondarySubMode(): void {
cycleSecondarySubMode(); cycleSecondarySubMode();
} }
function toggleSubtitleSidebar(): void {
broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle);
}
async function triggerSubsyncFromConfig(): Promise<void> { async function triggerSubsyncFromConfig(): Promise<void> {
await subsyncRuntime.triggerFromConfig(); await subsyncRuntime.triggerFromConfig();
} }
@@ -4562,9 +4607,13 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
mineSentenceCard: () => mineSentenceCard(), mineSentenceCard: () => mineSentenceCard(),
mineSentenceCount: (count) => handleMineSentenceDigit(count), mineSentenceCount: (count) => handleMineSentenceDigit(count),
toggleSecondarySub: () => handleCycleSecondarySubMode(), toggleSecondarySub: () => handleCycleSecondarySubMode(),
toggleSubtitleSidebar: () => toggleSubtitleSidebar(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(), markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => openJimakuOverlay(), openJimaku: () => openJimakuOverlay(),
openSessionHelp: () => openSessionHelpOverlay(),
openControllerSelect: () => openControllerSelectOverlay(),
openControllerDebug: () => openControllerDebugOverlay(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
openPlaylistBrowser: () => openPlaylistBrowser(), openPlaylistBrowser: () => openPlaylistBrowser(),
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient), replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),

View File

@@ -23,7 +23,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
{ kind: string }, { kind: string },
{ scope: string; warn: () => void; info: () => void; error: () => void }, { scope: string; warn: () => void; info: () => void; error: () => void },
{ registry: boolean }, { registry: boolean },
{ getModalWindow: () => null }, { getMainWindow: () => null; getModalWindow: () => null },
{ {
inputState: boolean; inputState: boolean;
getModalInputExclusive: () => boolean; getModalInputExclusive: () => boolean;
@@ -82,6 +82,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
}) as const, }) as const,
createMainRuntimeRegistry: () => ({ registry: true }), createMainRuntimeRegistry: () => ({ registry: true }),
createOverlayManager: () => ({ createOverlayManager: () => ({
getMainWindow: () => null,
getModalWindow: () => null, getModalWindow: () => null,
}), }),
createOverlayModalInputState: () => ({ createOverlayModalInputState: () => ({

View File

@@ -74,6 +74,7 @@ export interface MainBootServicesParams<
getModalWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null;
syncOverlayShortcutsForModal: (isActive: boolean) => void; syncOverlayShortcutsForModal: (isActive: boolean) => void;
syncOverlayVisibilityForModal: () => void; syncOverlayVisibilityForModal: () => void;
restoreMainWindowFocus?: () => void;
}) => TOverlayModalInputState; }) => TOverlayModalInputState;
createOverlayContentMeasurementStore: (params: { createOverlayContentMeasurementStore: (params: {
logger: TLogger; logger: TLogger;
@@ -131,7 +132,7 @@ export function createMainBootServices<
TSubtitleWebSocket, TSubtitleWebSocket,
TLogger, TLogger,
TRuntimeRegistry, TRuntimeRegistry,
TOverlayManager extends { getModalWindow: () => BrowserWindow | null }, TOverlayManager extends { getMainWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null },
TOverlayModalInputState extends OverlayModalInputStateShape, TOverlayModalInputState extends OverlayModalInputStateShape,
TOverlayContentMeasurementStore, TOverlayContentMeasurementStore,
TOverlayModalRuntime, TOverlayModalRuntime,
@@ -212,6 +213,26 @@ export function createMainBootServices<
syncOverlayVisibilityForModal: () => { syncOverlayVisibilityForModal: () => {
params.getSyncOverlayVisibilityForModal()(); 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({ const overlayContentMeasurementStore = params.createOverlayContentMeasurementStore({
logger, logger,

View File

@@ -31,6 +31,7 @@ function createMockWindow(): MockWindow & {
getHideCount: () => number; getHideCount: () => number;
show: () => void; show: () => void;
hide: () => void; hide: () => void;
destroy: () => void;
focus: () => void; focus: () => void;
once: (event: 'ready-to-show', cb: () => void) => void; once: (event: 'ready-to-show', cb: () => void) => void;
webContents: { webContents: {
@@ -81,6 +82,10 @@ function createMockWindow(): MockWindow & {
state.visible = false; state.visible = false;
state.hideCount += 1; state.hideCount += 1;
}, },
destroy: () => {
state.destroyed = true;
state.visible = false;
},
focus: () => { focus: () => {
state.focused = true; state.focused = true;
}, },
@@ -302,10 +307,10 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
); );
runtime.handleOverlayModalClosed('runtime-options'); runtime.handleOverlayModalClosed('runtime-options');
assert.equal(window.getHideCount(), 0); assert.equal(window.isDestroyed(), false);
runtime.handleOverlayModalClosed('subsync'); runtime.handleOverlayModalClosed('subsync');
assert.equal(window.getHideCount(), 1); assert.equal(window.isDestroyed(), true);
}); });
test('sendToActiveOverlayWindow prefers visible main overlay window for modal open', () => { 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, []); 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 window = createMockWindow();
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
@@ -538,11 +543,11 @@ test('handleOverlayModalClosed hides modal window for single kiku modal', () =>
); );
runtime.handleOverlayModalClosed('kiku'); runtime.handleOverlayModalClosed('kiku');
assert.equal(window.getHideCount(), 1); assert.equal(window.isDestroyed(), true);
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0); 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 window = createMockWindow();
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
@@ -563,16 +568,15 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open
}); });
assert.equal(sent, true); assert.equal(sent, true);
assert.equal(window.ignoreMouseEvents, false);
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
setTimeout(resolve, 260); setTimeout(resolve, 260);
}); });
assert.equal(window.getShowCount(), 1); assert.equal(window.getShowCount(), 0);
assert.equal(window.ignoreMouseEvents, false);
runtime.notifyOverlayModalOpened('jimaku'); runtime.notifyOverlayModalOpened('jimaku');
assert.equal(window.getShowCount(), 1);
assert.equal(window.ignoreMouseEvents, false); 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']]); assert.deepEqual(window.sent, [['runtime-options:open']]);
}); });
test('warm modal window reopen becomes interactive immediately on the second open', () => { test('modal reopen creates a fresh window after close destroys the previous one', () => {
const window = createMockWindow(); const firstWindow = createMockWindow();
const secondWindow = createMockWindow();
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
getModalWindow: () => window as never, getModalWindow: () =>
createModalWindow: () => window as never, currentModal && !currentModal.isDestroyed() ? (currentModal as never) : null,
createModalWindow: () => {
currentModal = secondWindow;
return secondWindow as never;
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {}, setModalWindowBounds: () => {},
}); });
@@ -622,19 +633,84 @@ test('warm modal window reopen becomes interactive immediately on the second ope
runtime.notifyOverlayModalOpened('runtime-options'); runtime.notifyOverlayModalOpened('runtime-options');
runtime.handleOverlayModalClosed('runtime-options'); runtime.handleOverlayModalClosed('runtime-options');
window.ignoreMouseEvents = true; assert.equal(firstWindow.isDestroyed(), true);
window.focused = false;
window.webContentsFocused = false; 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<typeof createMockWindow> | 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, { runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options', 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.ignoreMouseEvents, false);
assert.equal(window.isFocused(), true); assert.equal(window.isFocused(), true);
assert.equal(window.webContentsFocused, 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 () => { test('waitForModalOpen resolves true after modal acknowledgement', async () => {

View File

@@ -5,6 +5,26 @@ import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-wind
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250; 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 { export interface OverlayWindowResolver {
getMainWindow: () => BrowserWindow | null; getMainWindow: () => BrowserWindow | null;
getModalWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null;
@@ -142,6 +162,8 @@ export function createOverlayModalRuntimeService(
passThroughMouseEvents: boolean; passThroughMouseEvents: boolean;
} = { passThroughMouseEvents: false }, } = { passThroughMouseEvents: false },
): void => { ): void => {
setWindowFocusable(window);
requestOverlayApplicationFocus();
if (!window.isVisible()) { if (!window.isVisible()) {
window.show(); window.show();
} }
@@ -158,15 +180,14 @@ export function createOverlayModalRuntimeService(
}; };
const ensureModalWindowInteractive = (window: BrowserWindow): void => { const ensureModalWindowInteractive = (window: BrowserWindow): void => {
setWindowFocusable(window);
requestOverlayApplicationFocus();
window.setIgnoreMouseEvents(false);
elevateModalWindow(window);
if (window.isVisible()) { if (window.isVisible()) {
window.setIgnoreMouseEvents(false); window.focus();
if (!window.isFocused()) { window.webContents.focus();
window.focus();
}
if (!window.webContents.isFocused()) {
window.webContents.focus();
}
elevateModalWindow(window);
return; return;
} }
@@ -251,6 +272,9 @@ export function createOverlayModalRuntimeService(
if (!targetWindow || targetWindow.isDestroyed() || targetWindow.isVisible()) { if (!targetWindow || targetWindow.isDestroyed() || targetWindow.isVisible()) {
return; return;
} }
if (!isWindowReadyForIpc(targetWindow)) {
return;
}
showModalWindow(targetWindow, { passThroughMouseEvents: false }); showModalWindow(targetWindow, { passThroughMouseEvents: false });
}, MODAL_REVEAL_FALLBACK_DELAY_MS); }, MODAL_REVEAL_FALLBACK_DELAY_MS);
}; };
@@ -306,6 +330,9 @@ export function createOverlayModalRuntimeService(
} }
sendOrQueueForWindow(modalWindow, (window) => { sendOrQueueForWindow(modalWindow, (window) => {
if (window.isVisible()) {
ensureModalWindowInteractive(window);
}
if (payload === undefined) { if (payload === undefined) {
window.webContents.send(channel); window.webContents.send(channel);
} else { } else {
@@ -346,9 +373,9 @@ export function createOverlayModalRuntimeService(
if (restoreVisibleOverlayOnModalClose.size === 0) { if (restoreVisibleOverlayOnModalClose.size === 0) {
clearPendingModalWindowReveal(); clearPendingModalWindowReveal();
if (modalWindow && !modalWindow.isDestroyed()) { if (modalWindow && !modalWindow.isDestroyed()) {
modalWindow.hide(); modalWindow.destroy();
} }
modalWindowPrimedForImmediateShow = true; modalWindowPrimedForImmediateShow = false;
mainWindowMousePassthroughForcedByModal = false; mainWindowMousePassthroughForcedByModal = false;
mainWindowHiddenByModal = false; mainWindowHiddenByModal = false;
notifyModalStateChange(false); notifyModalStateChange(false);
@@ -376,14 +403,7 @@ export function createOverlayModalRuntimeService(
} }
if (targetWindow.isVisible()) { if (targetWindow.isVisible()) {
targetWindow.setIgnoreMouseEvents(false); ensureModalWindowInteractive(targetWindow);
elevateModalWindow(targetWindow);
if (!targetWindow.isFocused()) {
targetWindow.focus();
}
if (!targetWindow.webContents.isFocused()) {
targetWindow.webContents.focus();
}
return; return;
} }

View File

@@ -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<boolean>;
logWarn: (message: string) => void;
}): Promise<boolean> {
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,
},
),
},
);
}

View File

@@ -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<boolean>;
logWarn: (message: string) => void;
}): Promise<boolean> {
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,
},
),
},
);
}

View File

@@ -43,7 +43,11 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
toggleStatsOverlay: false, toggleStatsOverlay: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openSessionHelp: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false, openJimaku: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,

View File

@@ -18,6 +18,10 @@ function createShortcuts(): ConfiguredShortcuts {
markAudioCard: null, markAudioCard: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
}; };
} }

View File

@@ -22,6 +22,10 @@ function createShortcuts(): ConfiguredShortcuts {
markAudioCard: null, markAudioCard: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
}; };
} }

View File

@@ -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<boolean>;
logWarn: (message: string) => void;
}): Promise<boolean> {
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,
},
),
},
);
}

View File

@@ -27,3 +27,31 @@ export function openOverlayHostedModal(
preferModalWindow: input.preferModalWindow, preferModalWindow: input.preferModalWindow,
}); });
} }
export async function retryOverlayModalOpen(
deps: {
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
},
input: {
modal: OverlayHostedModal;
timeoutMs: number;
retryWarning: string;
sendOpen: () => boolean;
},
): Promise<boolean> {
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);
}

View File

@@ -23,6 +23,9 @@ function createModalWindow() {
setIgnoreMouseEvents: (ignore: boolean) => { setIgnoreMouseEvents: (ignore: boolean) => {
calls.push(`ignore:${ignore}`); calls.push(`ignore:${ignore}`);
}, },
setFocusable: (focusable: boolean) => {
calls.push(`focusable:${focusable}`);
},
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => { setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
calls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`); 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.equal(state.getModalInputExclusive(), true);
assert.deepEqual(modalWindow.calls, [ assert.deepEqual(modalWindow.calls, [
'focusable:true',
'ignore:false', 'ignore:false',
'top:true:screen-saver:1', 'top:true:screen-saver:1',
'focus', 'focus',
@@ -66,6 +70,25 @@ test('overlay modal input state activates modal window interactivity and syncs d
assert.deepEqual(calls, ['shortcuts:true', 'visibility']); 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', () => { test('overlay modal input state is idempotent for unchanged state', () => {
const calls: string[] = []; const calls: string[] = [];
const state = createOverlayModalInputState({ const state = createOverlayModalInputState({

View File

@@ -1,9 +1,30 @@
import type { BrowserWindow } from 'electron'; 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 = { export type OverlayModalInputStateDeps = {
getModalWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null;
syncOverlayShortcutsForModal: (isActive: boolean) => void; syncOverlayShortcutsForModal: (isActive: boolean) => void;
syncOverlayVisibilityForModal: () => void; syncOverlayVisibilityForModal: () => void;
restoreMainWindowFocus?: () => void;
}; };
export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) { export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
@@ -18,6 +39,8 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
if (isActive) { if (isActive) {
const modalWindow = deps.getModalWindow(); const modalWindow = deps.getModalWindow();
if (modalWindow && !modalWindow.isDestroyed()) { if (modalWindow && !modalWindow.isDestroyed()) {
setWindowFocusable(modalWindow);
requestOverlayApplicationFocus();
modalWindow.setIgnoreMouseEvents(false); modalWindow.setIgnoreMouseEvents(false);
modalWindow.setAlwaysOnTop(true, 'screen-saver', 1); modalWindow.setAlwaysOnTop(true, 'screen-saver', 1);
modalWindow.focus(); modalWindow.focus();
@@ -29,6 +52,9 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
deps.syncOverlayShortcutsForModal(isActive); deps.syncOverlayShortcutsForModal(isActive);
deps.syncOverlayVisibilityForModal(); deps.syncOverlayVisibilityForModal();
if (!isActive) {
deps.restoreMainWindowFocus?.();
}
}; };
return { return {

View File

@@ -3,10 +3,10 @@ import test from 'node:test';
import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openPlaylistBrowser } from './playlist-browser-open'; 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 calls: string[] = [];
const opened = openPlaylistBrowser({ const opened = await openPlaylistBrowser({
ensureOverlayStartupPrereqs: () => { ensureOverlayStartupPrereqs: () => {
calls.push('prereqs'); calls.push('prereqs');
}, },
@@ -18,11 +18,31 @@ test('playlist browser open bootstraps overlay runtime before dispatching the mo
assert.equal(payload, undefined); assert.equal(payload, undefined);
assert.deepEqual(runtimeOptions, { assert.deepEqual(runtimeOptions, {
restoreOnModalClose: 'playlist-browser', restoreOnModalClose: 'playlist-browser',
preferModalWindow: true,
}); });
return true; return true;
}, },
waitForModalOpen: async () => true,
logWarn: () => {},
}); });
assert.equal(opened, true); assert.equal(opened, true);
assert.deepEqual(calls, ['prereqs', 'windows', `send:${IPC_CHANNELS.event.playlistBrowserOpen}`]); 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);
});

View File

@@ -1,9 +1,11 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts'; import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import { IPC_CHANNELS } 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_MODAL: OverlayHostedModal = 'playlist-browser';
const PLAYLIST_BROWSER_OPEN_TIMEOUT_MS = 1500;
export function openPlaylistBrowser(deps: { export async function openPlaylistBrowser(deps: {
ensureOverlayStartupPrereqs: () => void; ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void; ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: ( sendToActiveOverlayWindow: (
@@ -14,10 +16,33 @@ export function openPlaylistBrowser(deps: {
preferModalWindow?: boolean; preferModalWindow?: boolean;
}, },
) => boolean; ) => boolean;
}): boolean { waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
deps.ensureOverlayStartupPrereqs(); logWarn: (message: string) => void;
deps.ensureOverlayWindowsReadyForVisibilityActions(); }): Promise<boolean> {
return deps.sendToActiveOverlayWindow(IPC_CHANNELS.event.playlistBrowserOpen, undefined, { return await retryOverlayModalOpen(
restoreOnModalClose: PLAYLIST_BROWSER_MODAL, {
}); 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,
},
),
},
);
} }

View File

@@ -1,5 +1,5 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts'; 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_MODAL: OverlayHostedModal = 'runtime-options';
const RUNTIME_OPTIONS_OPEN_TIMEOUT_MS = 1500; const RUNTIME_OPTIONS_OPEN_TIMEOUT_MS = 1500;
@@ -18,36 +18,30 @@ export async function openRuntimeOptionsModal(deps: {
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>; waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void; logWarn: (message: string) => void;
}): Promise<boolean> { }): Promise<boolean> {
const sendOpen = (): boolean => { return await retryOverlayModalOpen(
return openOverlayHostedModal( {
{ waitForModalOpen: deps.waitForModalOpen,
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, logWarn: deps.logWarn,
ensureOverlayWindowsReadyForVisibilityActions: },
deps.ensureOverlayWindowsReadyForVisibilityActions, {
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, modal: RUNTIME_OPTIONS_MODAL,
}, timeoutMs: RUNTIME_OPTIONS_OPEN_TIMEOUT_MS,
{ retryWarning:
channel: 'runtime-options:open', 'Runtime options modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
modal: RUNTIME_OPTIONS_MODAL, sendOpen: () =>
preferModalWindow: true, openOverlayHostedModal(
}, {
); ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
}; ensureOverlayWindowsReadyForVisibilityActions:
deps.ensureOverlayWindowsReadyForVisibilityActions,
if (!sendOpen()) { sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
return false; },
} {
channel: 'runtime-options:open',
if (await deps.waitForModalOpen(RUNTIME_OPTIONS_MODAL, RUNTIME_OPTIONS_OPEN_TIMEOUT_MS)) { modal: RUNTIME_OPTIONS_MODAL,
return true; preferModalWindow: true,
} },
),
deps.logWarn( },
'Runtime options modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
); );
if (!sendOpen()) {
return false;
}
return await deps.waitForModalOpen(RUNTIME_OPTIONS_MODAL, RUNTIME_OPTIONS_OPEN_TIMEOUT_MS);
} }

View File

@@ -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<boolean>;
logWarn: (message: string) => void;
}): Promise<boolean> {
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,
},
),
},
);
}

View File

@@ -1,5 +1,6 @@
import type { YoutubePickerOpenPayload } from '../../types'; import type { YoutubePickerOpenPayload } from '../../types';
import type { OverlayHostedModal } from '../../shared/ipc/contracts'; 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_MODAL: OverlayHostedModal = 'youtube-track-picker';
const YOUTUBE_PICKER_OPEN_TIMEOUT_MS = 1500; const YOUTUBE_PICKER_OPEN_TIMEOUT_MS = 1500;
@@ -19,24 +20,21 @@ export async function openYoutubeTrackPicker(
}, },
payload: YoutubePickerOpenPayload, payload: YoutubePickerOpenPayload,
): Promise<boolean> { ): Promise<boolean> {
const sendPickerOpen = (): boolean => return await retryOverlayModalOpen(
deps.sendToActiveOverlayWindow('youtube:picker-open', payload, { {
restoreOnModalClose: YOUTUBE_PICKER_MODAL, waitForModalOpen: deps.waitForModalOpen,
preferModalWindow: true, logWarn: deps.logWarn,
}); },
{
if (!sendPickerOpen()) { modal: YOUTUBE_PICKER_MODAL,
return false; timeoutMs: YOUTUBE_PICKER_OPEN_TIMEOUT_MS,
} retryWarning:
if (await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS)) { 'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.',
return true; sendOpen: () =>
} deps.sendToActiveOverlayWindow('youtube:picker-open', payload, {
restoreOnModalClose: YOUTUBE_PICKER_MODAL,
deps.logWarn( preferModalWindow: true,
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.', }),
},
); );
if (!sendPickerOpen()) {
return false;
}
return await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS);
} }

View File

@@ -123,6 +123,9 @@ function createQueuedIpcListenerWithPayload<T>(
} }
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen); 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 onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen);
const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<YoutubePickerOpenPayload>( const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<YoutubePickerOpenPayload>(
IPC_CHANNELS.event.youtubePickerOpen, IPC_CHANNELS.event.youtubePickerOpen,
@@ -142,6 +145,9 @@ const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload<SubsyncManua
IPC_CHANNELS.event.subsyncOpenManual, IPC_CHANNELS.event.subsyncOpenManual,
(payload) => payload as SubsyncManualPayload, (payload) => payload as SubsyncManualPayload,
); );
const onSubtitleSidebarToggleEvent = createQueuedIpcListener(
IPC_CHANNELS.event.subtitleSidebarToggle,
);
const onKikuFieldGroupingRequestEvent = const onKikuFieldGroupingRequestEvent =
createQueuedIpcListenerWithPayload<KikuFieldGroupingRequestData>( createQueuedIpcListenerWithPayload<KikuFieldGroupingRequestData>(
IPC_CHANNELS.event.kikuFieldGroupingRequest, IPC_CHANNELS.event.kikuFieldGroupingRequest,
@@ -326,9 +332,13 @@ const electronAPI: ElectronAPI = {
); );
}, },
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent, onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
onOpenSessionHelp: onOpenSessionHelpEvent,
onOpenControllerSelect: onOpenControllerSelectEvent,
onOpenControllerDebug: onOpenControllerDebugEvent,
onOpenJimaku: onOpenJimakuEvent, onOpenJimaku: onOpenJimakuEvent,
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent, onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent, onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,

View File

@@ -69,6 +69,10 @@ function installKeyboardTestGlobals() {
markAudioCard: '', markAudioCard: '',
openRuntimeOptions: 'CommandOrControl+Shift+O', openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J', openJimaku: 'Ctrl+Shift+J',
openSessionHelp: 'CommandOrControl+Shift+H',
openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C',
toggleSubtitleSidebar: '',
toggleVisibleOverlayGlobal: '', toggleVisibleOverlayGlobal: '',
}; };
let markActiveVideoWatchedResult = true; let markActiveVideoWatchedResult = true;
@@ -321,8 +325,6 @@ function installKeyboardTestGlobals() {
function createKeyboardHandlerHarness() { function createKeyboardHandlerHarness() {
const testGlobals = installKeyboardTestGlobals(); const testGlobals = installKeyboardTestGlobals();
const subtitleRootClassList = createClassList(); const subtitleRootClassList = createClassList();
let controllerSelectOpenCount = 0;
let controllerDebugOpenCount = 0;
let controllerSelectKeydownCount = 0; let controllerSelectKeydownCount = 0;
let playlistBrowserKeydownCount = 0; let playlistBrowserKeydownCount = 0;
@@ -373,20 +375,12 @@ function createKeyboardHandlerHarness() {
openSessionHelpModal: () => {}, openSessionHelpModal: () => {},
appendClipboardVideoToQueue: () => {}, appendClipboardVideoToQueue: () => {},
getPlaybackPaused: () => testGlobals.getPlaybackPaused(), getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
openControllerSelectModal: () => {
controllerSelectOpenCount += 1;
},
openControllerDebugModal: () => {
controllerDebugOpenCount += 1;
},
}); });
return { return {
ctx, ctx,
handlers, handlers,
testGlobals, testGlobals,
controllerSelectOpenCount: () => controllerSelectOpenCount,
controllerDebugOpenCount: () => controllerDebugOpenCount,
controllerSelectKeydownCount: () => controllerSelectKeydownCount, controllerSelectKeydownCount: () => controllerSelectKeydownCount,
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount, playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
setWordCount: (count: number) => { 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 () => { test('keyboard mode: configured controller debug binding dispatches session action', async () => {
const { testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness(); const { testGlobals, handlers } = createKeyboardHandlerHarness();
try { try {
await handlers.setupMpvInputForwarding(); 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({ testGlobals.dispatchKeydown({
key: 'C', key: 'D',
code: 'KeyC', code: 'KeyD',
altKey: true, altKey: true,
shiftKey: true, shiftKey: true,
}); });
assert.equal(controllerDebugOpenCount(), 1); assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'openControllerDebug', payload: undefined }]);
} finally { } finally {
testGlobals.restore(); testGlobals.restore();
} }
}); });
test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup is visible', async () => { test('keyboard mode: configured controller debug binding is not swallowed while popup is visible', async () => {
const { ctx, testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness(); const { ctx, testGlobals, handlers } = createKeyboardHandlerHarness();
try { try {
await handlers.setupMpvInputForwarding(); await handlers.setupMpvInputForwarding();
ctx.state.yomitanPopupVisible = true; 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({ testGlobals.dispatchKeydown({
key: 'C', key: 'C',
@@ -692,7 +733,7 @@ test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup i
shiftKey: true, shiftKey: true,
}); });
assert.equal(controllerDebugOpenCount(), 1); assert.deepEqual(testGlobals.sessionActions, []);
} finally { } finally {
testGlobals.restore(); testGlobals.restore();
} }

View File

@@ -27,8 +27,6 @@ export function createKeyboardHandlers(
}) => void; }) => void;
appendClipboardVideoToQueue: () => void; appendClipboardVideoToQueue: () => void;
getPlaybackPaused: () => Promise<boolean | null>; getPlaybackPaused: () => Promise<boolean | null>;
openControllerSelectModal: () => void;
openControllerDebugModal: () => void;
toggleSubtitleSidebarModal?: () => void; toggleSubtitleSidebarModal?: () => void;
}, },
) { ) {
@@ -298,10 +296,6 @@ export function createKeyboardHandlers(
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat; 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 { function isSubtitleSidebarToggle(e: KeyboardEvent): boolean {
const toggleKey = ctx.state.subtitleSidebarToggleKey; const toggleKey = ctx.state.subtitleSidebarToggleKey;
if (!toggleKey) return false; if (!toggleKey) return false;
@@ -1040,10 +1034,7 @@ export function createKeyboardHandlers(
return; return;
} }
if ( if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
!isControllerModalShortcut(e)
) {
if (handleYomitanPopupKeybind(e)) { if (handleYomitanPopupKeybind(e)) {
e.preventDefault(); e.preventDefault();
return; return;
@@ -1100,16 +1091,6 @@ export function createKeyboardHandlers(
return; return;
} }
if (isControllerModalShortcut(e)) {
e.preventDefault();
if (e.shiftKey) {
options.openControllerDebugModal();
} else {
options.openControllerSelectModal();
}
return;
}
const keyString = keyEventToString(e); const keyString = keyEventToString(e);
const binding = ctx.state.sessionBindingMap.get(keyString); const binding = ctx.state.sessionBindingMap.get(keyString);
if (binding) { if (binding) {

View File

@@ -586,31 +586,10 @@ export function createSessionHelpModal(
} }
} }
async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise<void> { function openSessionHelpModal(opening: SessionHelpBindingInfo): void {
openBinding = opening; openBinding = opening;
priorFocus = document.activeElement; 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; ctx.state.sessionHelpModalOpen = true;
options.syncSettingsModalSubtitleSuppression(); options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive'); ctx.dom.overlay.classList.add('interactive');
@@ -623,6 +602,17 @@ export function createSessionHelpModal(
window.electronAPI.setIgnoreMouseEvents(false); 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) { if (focusGuard === null) {
focusGuard = (event: FocusEvent) => { focusGuard = (event: FocusEvent) => {
if (!ctx.state.sessionHelpModalOpen) return; if (!ctx.state.sessionHelpModalOpen) return;
@@ -639,6 +629,19 @@ export function createSessionHelpModal(
requestOverlayFocus(); requestOverlayFocus();
window.focus(); window.focus();
enforceModalFocus(); 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 { function closeSessionHelpModal(): void {
@@ -648,6 +651,7 @@ export function createSessionHelpModal(
options.syncSettingsModalSubtitleSuppression(); options.syncSettingsModalSubtitleSuppression();
ctx.dom.sessionHelpModal.classList.add('hidden'); ctx.dom.sessionHelpModal.classList.add('hidden');
ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'true'); ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('session-help');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive'); ctx.dom.overlay.classList.remove('interactive');
} }

View File

@@ -178,14 +178,6 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
void window.electronAPI.appendClipboardVideoToQueue(); void window.electronAPI.appendClipboardVideoToQueue();
}, },
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(), getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
openControllerSelectModal: () => {
controllerSelectModal.openControllerSelectModal();
window.electronAPI.notifyOverlayModalOpened('controller-select');
},
openControllerDebugModal: () => {
controllerDebugModal.openControllerDebugModal();
window.electronAPI.notifyOverlayModalOpened('controller-debug');
},
toggleSubtitleSidebarModal: () => { toggleSubtitleSidebarModal: () => {
void subtitleSidebarModal.toggleSubtitleSidebarModal(); void subtitleSidebarModal.toggleSubtitleSidebarModal();
}, },
@@ -437,6 +429,28 @@ function registerModalOpenHandlers(): void {
window.electronAPI.notifyOverlayModalOpened('runtime-options'); 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(() => { window.electronAPI.onOpenJimaku(() => {
runGuarded('jimaku:open', () => { runGuarded('jimaku:open', () => {
jimakuModal.openJimakuModal(); jimakuModal.openJimakuModal();
@@ -492,6 +506,12 @@ function registerKeyboardCommandHandlers(): void {
keyboardHandlers.handleLookupWindowToggleRequested(); keyboardHandlers.handleLookupWindowToggleRequested();
}); });
}); });
window.electronAPI.onSubtitleSidebarToggle(() => {
runGuarded('subtitle-sidebar:toggle', () => {
void subtitleSidebarModal.toggleSubtitleSidebarModal();
});
});
} }
function runGuarded(action: string, fn: () => void): void { function runGuarded(action: string, fn: () => void): void {

View File

@@ -11,6 +11,7 @@ export const OVERLAY_HOSTED_MODALS = [
'controller-select', 'controller-select',
'controller-debug', 'controller-debug',
'subtitle-sidebar', 'subtitle-sidebar',
'session-help',
] as const; ] as const;
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number]; export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
@@ -111,6 +112,10 @@ export const IPC_CHANNELS = {
playlistBrowserOpen: 'playlist-browser:open', playlistBrowserOpen: 'playlist-browser:open',
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested', keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
lookupWindowToggleRequested: 'lookup-window-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', configHotReload: 'config:hot-reload',
}, },
} as const; } as const;

View File

@@ -89,6 +89,10 @@ export interface ShortcutsConfig {
markAudioCard?: string | null; markAudioCard?: string | null;
openRuntimeOptions?: string | null; openRuntimeOptions?: string | null;
openJimaku?: string | null; openJimaku?: string | null;
openSessionHelp?: string | null;
openControllerSelect?: string | null;
openControllerDebug?: string | null;
toggleSubtitleSidebar?: string | null;
} }
export interface Config { export interface Config {

View File

@@ -399,9 +399,13 @@ export interface ElectronAPI {
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => Promise<RuntimeOptionApplyResult>; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => Promise<RuntimeOptionApplyResult>;
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void; onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
onOpenRuntimeOptions: (callback: () => void) => void; onOpenRuntimeOptions: (callback: () => void) => void;
onOpenSessionHelp: (callback: () => void) => void;
onOpenControllerSelect: (callback: () => void) => void;
onOpenControllerDebug: (callback: () => void) => void;
onOpenJimaku: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void;
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void; onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
onOpenPlaylistBrowser: (callback: () => void) => void; onOpenPlaylistBrowser: (callback: () => void) => void;
onSubtitleSidebarToggle: (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;
@@ -427,7 +431,8 @@ export interface ElectronAPI {
| 'kiku' | 'kiku'
| 'controller-select' | 'controller-select'
| 'controller-debug' | 'controller-debug'
| 'subtitle-sidebar', | 'subtitle-sidebar'
| 'session-help',
) => void; ) => void;
notifyOverlayModalOpened: ( notifyOverlayModalOpened: (
modal: modal:
@@ -439,7 +444,8 @@ export interface ElectronAPI {
| 'kiku' | 'kiku'
| 'controller-select' | 'controller-select'
| 'controller-debug' | 'controller-debug'
| 'subtitle-sidebar', | 'subtitle-sidebar'
| 'session-help',
) => void; ) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;

View File

@@ -11,8 +11,12 @@ export type SessionActionId =
| 'mineSentence' | 'mineSentence'
| 'mineSentenceMultiple' | 'mineSentenceMultiple'
| 'toggleSecondarySub' | 'toggleSecondarySub'
| 'toggleSubtitleSidebar'
| 'markAudioCard' | 'markAudioCard'
| 'openRuntimeOptions' | 'openRuntimeOptions'
| 'openSessionHelp'
| 'openControllerSelect'
| 'openControllerDebug'
| 'openJimaku' | 'openJimaku'
| 'openYoutubePicker' | 'openYoutubePicker'
| 'openPlaylistBrowser' | 'openPlaylistBrowser'