mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-17 17:16:23 -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.
|
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.
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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: () => {},
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user