mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-16 05:16:21 -07:00
fix(sidebar): preserve Space playback after cue seeking (#247)
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user