diff --git a/changes/fix-sidebar-space-seek.md b/changes/fix-sidebar-space-seek.md new file mode 100644 index 00000000..4d677d8b --- /dev/null +++ b/changes/fix-sidebar-space-seek.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Clicking a subtitle sidebar cue releases row focus, and Space no longer seeks back to a focused cue. Enter still seeks the focused cue, and Space keeps its configured playback action. diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 28006ca0..9116278e 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -99,6 +99,8 @@ The stats toggle is handled inside the focused visible overlay window. It is con The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source. +In the sidebar, `Enter` seeks the keyboard-focused cue. `Space` keeps its configured playback action, normally pause/resume, even when a cue has focus. + ## Controller shortcuts These overlay-local shortcuts open controller utilities for the Chrome Gamepad API integration. diff --git a/docs-site/subtitle-sidebar.md b/docs-site/subtitle-sidebar.md index 245d926d..60f123b8 100644 --- a/docs-site/subtitle-sidebar.md +++ b/docs-site/subtitle-sidebar.md @@ -10,6 +10,7 @@ When SubMiner parses the active subtitle source into a cue list, the sidebar bec - The active cue is highlighted and kept in view as playback advances (when `autoScroll` is `true`). - Clicking any cue seeks mpv into that line. For overlapping ASS karaoke, SubMiner moves past the previous line's exit animation when the selected cue has enough time remaining. +- Clicking to seek releases row focus. `Enter` seeks a keyboard-focused cue; `Space` keeps its configured playback action, normally pause/resume, without seeking back to a row. - The sidebar and the overlay share one cue list, so a media change or subtitle source switch updates both at once. For typeset ASS karaoke and animated signs, SubMiner collapses generated animation frames and repeated full-line color phases before they reach the sidebar. It recovers a clean complete line from a matching timed authoring comment or from full-line events surrounding generated fragments. Ordinary ASS comments, editor notes, alternate lines, repeated dialogue, and separately positioned signs remain distinct. diff --git a/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts b/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts index 380b2733..e9d141ad 100644 --- a/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts +++ b/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts @@ -1,5 +1,7 @@ import type { ElectronAPI, SubtitleSidebarSnapshot } from '../../types'; import { SUBTITLE_DEFAULT_CONFIG } from '../../config/definitions/defaults-subtitle'; +import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core'; +import { createKeyboardHandlers } from '../handlers/keyboard'; import { createRendererState } from '../state'; import { resolveRendererDom } from '../utils/dom'; import { resolvePlatformInfo } from '../utils/platform'; @@ -37,6 +39,18 @@ export async function setup() { Object.defineProperty(window, 'electronAPI', { value: { getSubtitleSidebarSnapshot: async () => snapshot, + getSessionBindings: async () => [ + { + sourcePath: 'keybindings[0].key', + originalKey: 'Space', + key: { code: 'Space', modifiers: [] }, + actionType: 'mpv-command', + command: ['cycle', 'pause'], + }, + ], + getConfiguredShortcuts: async () => CORE_DEFAULT_CONFIG.shortcuts, + getStatsToggleKey: async () => 'Backquote', + getMarkWatchedKey: async () => '', copySubtitleSidebarSelection: async (text) => { if (!('copyTestSelection' in window) || typeof window.copyTestSelection !== 'function') throw new Error('Missing test clipboard bridge'); @@ -50,6 +64,10 @@ export async function setup() { } satisfies Pick< ElectronAPI, | 'getSubtitleSidebarSnapshot' + | 'getSessionBindings' + | 'getConfiguredShortcuts' + | 'getStatsToggleKey' + | 'getMarkWatchedKey' | 'copySubtitleSidebarSelection' | 'getOverlayLayer' | 'sendMpvCommand' @@ -66,6 +84,24 @@ export async function setup() { }); modal.wireDomEvents(); wireSubtitleSidebarSelection(ctx); + const keyboard = createKeyboardHandlers(ctx, { + handleRuntimeOptionsKeydown: () => false, + handleCharacterDictionaryKeydown: () => false, + handleSubsyncKeydown: () => false, + handleKikuKeydown: () => false, + handleJimakuKeydown: () => false, + handleTsukihimeKeydown: () => false, + handleYoutubePickerKeydown: () => false, + handleMediaTimingReviewKeydown: () => false, + handlePlaylistBrowserKeydown: () => false, + handleControllerSelectKeydown: () => false, + handleControllerDebugKeydown: () => false, + handleSessionHelpKeydown: () => false, + handleChangelogKeydown: () => false, + openSessionHelpModal: () => {}, + getPlaybackPaused: async () => false, + }); + await keyboard.setupMpvInputForwarding(); await modal.openSubtitleSidebarModal(); const list = ctx.dom.subtitleSidebarList; list.style.height = '180px'; @@ -90,6 +126,9 @@ export async function setup() { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c') fallbackCopies += 1; }); return { + takeCommands: () => commands.splice(0), + cueFocused: () => document.activeElement?.matches('.subtitle-sidebar-item') ?? false, + focusCue: () => list.querySelector('.subtitle-sidebar-item')?.focus(), select, selected: () => getSubtitleSidebarSelection(list), buttonVisible: () => !ctx.dom.subtitleSidebarCopy.hidden, diff --git a/src/renderer/modals/subtitle-sidebar-selection.test.ts b/src/renderer/modals/subtitle-sidebar-selection.test.ts index d1a7635d..c8f88059 100644 --- a/src/renderer/modals/subtitle-sidebar-selection.test.ts +++ b/src/renderer/modals/subtitle-sidebar-selection.test.ts @@ -67,7 +67,10 @@ app.whenReady().then(async () => { kind: 'visible', windowVisible: true, input, preventDefault: () => event.preventDefault(), sendKeyboardModeToggleRequested() {}, sendLookupWindowToggleRequested() {}, forwardTabToMpv() {}, - tryHandleOverlayShortcutLocalFallback() { intercepted++; return true; }, + tryHandleOverlayShortcutLocalFallback(input) { + if (input.key.toLowerCase() !== 'c') return false; + intercepted++; return true; + }, })); await window.loadFile(${JSON.stringify(join(dir, 'index.html'))}); const run = (code) => window.webContents.executeJavaScript(code, true); @@ -86,6 +89,22 @@ app.whenReady().then(async () => { await new Promise(resolve => setTimeout(resolve, 100)); await run('checks.clear()'); const [start, end] = await run('checks.dragPoints()'); + await run('checks.takeCommands()'); + window.webContents.sendInputEvent({ type: 'mouseDown', ...start, button: 'left', clickCount: 1 }); + window.webContents.sendInputEvent({ type: 'mouseUp', ...start, button: 'left', clickCount: 1 }); + assert.deepEqual(await run('checks.takeCommands()'), [['seek', 0.08, 'absolute+exact']]); + assert.equal(await run('checks.cueFocused()'), false, 'Click-to-seek releases row focus'); + assert.equal(await run('checks.selected()'), null); + const pressKey = async (keyCode) => { + window.webContents.sendInputEvent({ type: 'keyDown', keyCode }); + window.webContents.sendInputEvent({ type: 'keyUp', keyCode }); + return run('checks.takeCommands()'); + }; + assert.deepEqual(await pressKey('Space'), [['cycle', 'pause']]); + await run('checks.focusCue()'); + assert.deepEqual(await pressKey('Space'), [['cycle', 'pause']]); + assert.deepEqual(await pressKey('Enter'), [['seek', 0.08, 'absolute+exact']]); + assert.equal(await run('checks.cueFocused()'), true, 'Keyboard activation keeps row focus'); window.webContents.sendInputEvent({ type: 'mouseDown', ...start, button: 'left', clickCount: 1 }); window.webContents.sendInputEvent({ type: 'mouseMove', ...end, button: 'left' }); window.webContents.sendInputEvent({ type: 'mouseUp', ...end, button: 'left', clickCount: 1 }); diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index 20d4e8d5..cb8f49bb 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -375,7 +375,7 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback } }); -test('subtitle sidebar rows support keyboard activation', async () => { +test('subtitle sidebar rows seek with Enter and leave Space to playback shortcuts', async () => { const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; const previousWindow = globals.window; const previousDocument = globals.document; @@ -471,6 +471,18 @@ test('subtitle sidebar rows support keyboard activation', async () => { const keydownListeners = firstRow.listeners.get('keydown') ?? []; assert.equal(keydownListeners.length > 0, true); + mpvCommands.length = 0; + let spacePrevented = false; + keydownListeners[0]!({ + key: ' ', + preventDefault: () => { + spacePrevented = true; + }, + }); + + assert.deepEqual(mpvCommands, []); + assert.equal(spacePrevented, false); + keydownListeners[0]!({ key: 'Enter', preventDefault: () => {}, diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index 8825738a..bf527d6f 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -515,7 +515,7 @@ export function createSubtitleSidebarModal( row.setAttribute('role', 'button'); row.setAttribute('aria-label', getCueRowLabel(cue)); row.addEventListener('keydown', (event: KeyboardEvent) => { - if (event.key !== 'Enter' && event.key !== ' ') { + if (event.key !== 'Enter') { return; } event.preventDefault(); @@ -742,6 +742,7 @@ export function createSubtitleSidebarModal( if (!cue) { return; } + row.blur(); seekToCue(cue); }); ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {