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. 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 ## Controller shortcuts
These overlay-local shortcuts open controller utilities for the Chrome Gamepad API integration. 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`). - 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 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. - 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. 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 type { ElectronAPI, SubtitleSidebarSnapshot } from '../../types';
import { SUBTITLE_DEFAULT_CONFIG } from '../../config/definitions/defaults-subtitle'; 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 { createRendererState } from '../state';
import { resolveRendererDom } from '../utils/dom'; import { resolveRendererDom } from '../utils/dom';
import { resolvePlatformInfo } from '../utils/platform'; import { resolvePlatformInfo } from '../utils/platform';
@@ -37,6 +39,18 @@ export async function setup() {
Object.defineProperty(window, 'electronAPI', { Object.defineProperty(window, 'electronAPI', {
value: { value: {
getSubtitleSidebarSnapshot: async () => snapshot, 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) => { copySubtitleSidebarSelection: async (text) => {
if (!('copyTestSelection' in window) || typeof window.copyTestSelection !== 'function') if (!('copyTestSelection' in window) || typeof window.copyTestSelection !== 'function')
throw new Error('Missing test clipboard bridge'); throw new Error('Missing test clipboard bridge');
@@ -50,6 +64,10 @@ export async function setup() {
} satisfies Pick< } satisfies Pick<
ElectronAPI, ElectronAPI,
| 'getSubtitleSidebarSnapshot' | 'getSubtitleSidebarSnapshot'
| 'getSessionBindings'
| 'getConfiguredShortcuts'
| 'getStatsToggleKey'
| 'getMarkWatchedKey'
| 'copySubtitleSidebarSelection' | 'copySubtitleSidebarSelection'
| 'getOverlayLayer' | 'getOverlayLayer'
| 'sendMpvCommand' | 'sendMpvCommand'
@@ -66,6 +84,24 @@ export async function setup() {
}); });
modal.wireDomEvents(); modal.wireDomEvents();
wireSubtitleSidebarSelection(ctx); 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(); await modal.openSubtitleSidebarModal();
const list = ctx.dom.subtitleSidebarList; const list = ctx.dom.subtitleSidebarList;
list.style.height = '180px'; list.style.height = '180px';
@@ -90,6 +126,9 @@ export async function setup() {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c') fallbackCopies += 1; if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c') fallbackCopies += 1;
}); });
return { return {
takeCommands: () => commands.splice(0),
cueFocused: () => document.activeElement?.matches('.subtitle-sidebar-item') ?? false,
focusCue: () => list.querySelector<HTMLElement>('.subtitle-sidebar-item')?.focus(),
select, select,
selected: () => getSubtitleSidebarSelection(list), selected: () => getSubtitleSidebarSelection(list),
buttonVisible: () => !ctx.dom.subtitleSidebarCopy.hidden, buttonVisible: () => !ctx.dom.subtitleSidebarCopy.hidden,
@@ -67,7 +67,10 @@ app.whenReady().then(async () => {
kind: 'visible', windowVisible: true, input, kind: 'visible', windowVisible: true, input,
preventDefault: () => event.preventDefault(), preventDefault: () => event.preventDefault(),
sendKeyboardModeToggleRequested() {}, sendLookupWindowToggleRequested() {}, forwardTabToMpv() {}, 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'))}); await window.loadFile(${JSON.stringify(join(dir, 'index.html'))});
const run = (code) => window.webContents.executeJavaScript(code, true); const run = (code) => window.webContents.executeJavaScript(code, true);
@@ -86,6 +89,22 @@ app.whenReady().then(async () => {
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
await run('checks.clear()'); await run('checks.clear()');
const [start, end] = await run('checks.dragPoints()'); 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: 'mouseDown', ...start, button: 'left', clickCount: 1 });
window.webContents.sendInputEvent({ type: 'mouseMove', ...end, button: 'left' }); window.webContents.sendInputEvent({ type: 'mouseMove', ...end, button: 'left' });
window.webContents.sendInputEvent({ type: 'mouseUp', ...end, button: 'left', clickCount: 1 }); 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 globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window; const previousWindow = globals.window;
const previousDocument = globals.document; const previousDocument = globals.document;
@@ -471,6 +471,18 @@ test('subtitle sidebar rows support keyboard activation', async () => {
const keydownListeners = firstRow.listeners.get('keydown') ?? []; const keydownListeners = firstRow.listeners.get('keydown') ?? [];
assert.equal(keydownListeners.length > 0, true); 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]!({ keydownListeners[0]!({
key: 'Enter', key: 'Enter',
preventDefault: () => {}, preventDefault: () => {},
+2 -1
View File
@@ -515,7 +515,7 @@ export function createSubtitleSidebarModal(
row.setAttribute('role', 'button'); row.setAttribute('role', 'button');
row.setAttribute('aria-label', getCueRowLabel(cue)); row.setAttribute('aria-label', getCueRowLabel(cue));
row.addEventListener('keydown', (event: KeyboardEvent) => { row.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key !== 'Enter' && event.key !== ' ') { if (event.key !== 'Enter') {
return; return;
} }
event.preventDefault(); event.preventDefault();
@@ -742,6 +742,7 @@ export function createSubtitleSidebarModal(
if (!cue) { if (!cue) {
return; return;
} }
row.blur();
seekToCue(cue); seekToCue(cue);
}); });
ctx.dom.subtitleSidebarList.addEventListener('wheel', () => { ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {