fix(sidebar): preserve Space playback after cue seeking (#247)

This commit is contained in:
2026-09-15 21:35:10 -07:00
committed by GitHub
parent 7c1eac03dc
commit 6841a37a22
7 changed files with 81 additions and 3 deletions
+4
View File
@@ -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.
+2
View File
@@ -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.
+1
View File
@@ -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.
@@ -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<HTMLElement>('.subtitle-sidebar-item')?.focus(),
select,
selected: () => getSubtitleSidebarSelection(list),
buttonVisible: () => !ctx.dom.subtitleSidebarCopy.hidden,
@@ -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 });
+13 -1
View File
@@ -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: () => {},
+2 -1
View File
@@ -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', () => {