mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-30 17:40:53 -07:00
feat(overlay): run session help commands with Enter or double-click (#275)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
type: added
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Session help can run the highlighted command during playback: press `Enter` or double-click a row. Hovering a row with the mouse makes it the `Enter` target. Help opened from the tray with no video loaded stays read-only.
|
||||||
@@ -88,6 +88,8 @@ Built into the overlay, not configurable:
|
|||||||
| `Alt+C` | Open controller setup and remapping | `shortcuts.openControllerSelect` |
|
| `Alt+C` | Open controller setup and remapping | `shortcuts.openControllerSelect` |
|
||||||
| `Alt+Shift+C` | Open controller debug view | `shortcuts.openControllerDebug` |
|
| `Alt+Shift+C` | Open controller debug view | `shortcuts.openControllerDebug` |
|
||||||
|
|
||||||
|
While a video is playing, session help can also run commands: highlight a row with the arrow keys or the mouse, then press `Enter` or double-click it. Fixed overlay controls, global shortcuts, `y` chords, the multi-line copy and mine actions, Open session help, and the color legend are reference only. Help opened from the tray with no video loaded is read-only.
|
||||||
|
|
||||||
The sidebar key has a separate mpv-side binding, `shortcuts.toggleSubtitleSidebar`. The sidebar only opens when SubMiner has parsed the active subtitle file. In the sidebar, `Enter` seeks to the focused line.
|
The sidebar key has a separate mpv-side binding, `shortcuts.toggleSubtitleSidebar`. The sidebar only opens when SubMiner has parsed the active subtitle file. In the sidebar, `Enter` seeks to the focused line.
|
||||||
|
|
||||||
The subtitle picker (`g` then `s`) is off until you turn it on in **Settings, Behavior, Subtitle Selection**. Press the second key within one second. If `g` already has an action in SubMiner or mpv, the sequence is disabled and a warning is shown. See [subtitle selection](/configuration#subtitle-selection).
|
The subtitle picker (`g` then `s`) is off until you turn it on in **Settings, Behavior, Subtitle Selection**. Press the second key within one second. If `g` already has an action in SubMiner or mpv, the sequence is disabled and a warning is shown. See [subtitle selection](/configuration#subtitle-selection).
|
||||||
|
|||||||
+7
-1
@@ -3073,9 +3073,15 @@ function openSubtitleGenerationOverlay(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentMediaPath follows every mpv path change and is cleared on disconnect; the
|
||||||
|
// client's currentVideoPath outlives the connection, so a reconnect could reuse it.
|
||||||
|
function isMediaPlaybackActive(): boolean {
|
||||||
|
return appState.mpvClient?.connected === true && Boolean(appState.currentMediaPath?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
function openSessionHelpOverlay(): void {
|
function openSessionHelpOverlay(): void {
|
||||||
openOverlayHostedModalWithOsd(
|
openOverlayHostedModalWithOsd(
|
||||||
openSessionHelpModalRuntime,
|
(deps) => openSessionHelpModalRuntime({ ...deps, isMediaPlaybackActive }),
|
||||||
'Session help overlay unavailable.',
|
'Session help overlay unavailable.',
|
||||||
'Failed to open session help overlay.',
|
'Failed to open session help overlay.',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
|
import { openSessionHelpModal } from './session-help-open';
|
||||||
|
|
||||||
|
test('session help open tells the renderer whether commands can run', async () => {
|
||||||
|
for (const playing of [true, false]) {
|
||||||
|
const sent: Array<{ channel: string; payload: unknown }> = [];
|
||||||
|
const opened = await openSessionHelpModal({
|
||||||
|
ensureOverlayStartupPrereqs: () => {},
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||||
|
sendToActiveOverlayWindow: (channel, payload) => {
|
||||||
|
sent.push({ channel, payload });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
waitForModalOpen: async () => true,
|
||||||
|
logWarn: () => {},
|
||||||
|
isMediaPlaybackActive: () => playing,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(opened, true);
|
||||||
|
assert.deepEqual(sent, [
|
||||||
|
{ channel: IPC_CHANNELS.event.sessionHelpOpen, payload: { commandsEnabled: playing } },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
|
import type { SessionHelpOpenPayload } from '../../types/runtime';
|
||||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||||
|
|
||||||
const SESSION_HELP_MODAL: OverlayHostedModal = 'session-help';
|
const SESSION_HELP_MODAL: OverlayHostedModal = 'session-help';
|
||||||
@@ -18,6 +19,8 @@ export async function openSessionHelpModal(deps: {
|
|||||||
) => boolean;
|
) => boolean;
|
||||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||||
logWarn: (message: string) => void;
|
logWarn: (message: string) => void;
|
||||||
|
/** True when mpv has a video loaded; gates running commands from help rows. */
|
||||||
|
isMediaPlaybackActive: () => boolean;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
return await retryOverlayModalOpen(
|
return await retryOverlayModalOpen(
|
||||||
{
|
{
|
||||||
@@ -29,8 +32,11 @@ export async function openSessionHelpModal(deps: {
|
|||||||
timeoutMs: SESSION_HELP_OPEN_TIMEOUT_MS,
|
timeoutMs: SESSION_HELP_OPEN_TIMEOUT_MS,
|
||||||
retryWarning:
|
retryWarning:
|
||||||
'Session help modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
'Session help modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||||
sendOpen: () =>
|
sendOpen: () => {
|
||||||
openOverlayHostedModal(
|
const payload: SessionHelpOpenPayload = {
|
||||||
|
commandsEnabled: deps.isMediaPlaybackActive(),
|
||||||
|
};
|
||||||
|
return openOverlayHostedModal(
|
||||||
{
|
{
|
||||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||||
ensureOverlayWindowsReadyForVisibilityActions:
|
ensureOverlayWindowsReadyForVisibilityActions:
|
||||||
@@ -40,9 +46,11 @@ export async function openSessionHelpModal(deps: {
|
|||||||
{
|
{
|
||||||
channel: IPC_CHANNELS.event.sessionHelpOpen,
|
channel: IPC_CHANNELS.event.sessionHelpOpen,
|
||||||
modal: SESSION_HELP_MODAL,
|
modal: SESSION_HELP_MODAL,
|
||||||
|
payload,
|
||||||
preferModalWindow: true,
|
preferModalWindow: true,
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -61,6 +61,7 @@ import type {
|
|||||||
ControllerConfigUpdate,
|
ControllerConfigUpdate,
|
||||||
ControllerPreferenceUpdate,
|
ControllerPreferenceUpdate,
|
||||||
ResolvedControllerConfig,
|
ResolvedControllerConfig,
|
||||||
|
SessionHelpOpenPayload,
|
||||||
SessionNumericSelectionStartPayload,
|
SessionNumericSelectionStartPayload,
|
||||||
SubtitleMiningContext,
|
SubtitleMiningContext,
|
||||||
YoutubePickerOpenPayload,
|
YoutubePickerOpenPayload,
|
||||||
@@ -177,7 +178,15 @@ function createLatestValueIpcListenerWithPayload<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
||||||
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
|
const onOpenSessionHelpEvent = createQueuedIpcListenerWithPayload<SessionHelpOpenPayload>(
|
||||||
|
IPC_CHANNELS.event.sessionHelpOpen,
|
||||||
|
(payload) => ({
|
||||||
|
commandsEnabled:
|
||||||
|
typeof payload === 'object' &&
|
||||||
|
payload !== null &&
|
||||||
|
(payload as Partial<SessionHelpOpenPayload>).commandsEnabled === true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
|
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
|
||||||
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
|
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
|
||||||
IPC_CHANNELS.event.characterDictionaryManagerOpen,
|
IPC_CHANNELS.event.characterDictionaryManagerOpen,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { CompiledSessionBinding, PrimarySubMode, ShortcutsConfig } from '../../types';
|
import type { CompiledSessionBinding, PrimarySubMode, ShortcutsConfig } from '../../types';
|
||||||
import type { RendererContext } from '../context';
|
import type { RendererContext } from '../context';
|
||||||
import { createMpvInputForwarding } from './mpv-input-forwarding';
|
import { createMpvInputForwarding } from './mpv-input-forwarding';
|
||||||
|
import { dispatchConfiguredMpvCommand } from '../utils/mpv-command-dispatch';
|
||||||
import {
|
import {
|
||||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
YOMITAN_POPUP_SHOWN_EVENT,
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
@@ -257,7 +258,10 @@ export function createKeyboardHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (binding.actionType === 'mpv-command') {
|
if (binding.actionType === 'mpv-command') {
|
||||||
dispatchConfiguredMpvCommand(binding.command);
|
dispatchConfiguredMpvCommand(binding.command, {
|
||||||
|
getPlaybackPaused: options.getPlaybackPaused,
|
||||||
|
sendMpvCommand: (command) => window.electronAPI.sendMpvCommand(command),
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,31 +570,6 @@ export function createKeyboardHandlers(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSubtitleSeekCommand(
|
|
||||||
command: (string | number)[] | undefined,
|
|
||||||
): command is [string, number] {
|
|
||||||
return Array.isArray(command) && command[0] === 'sub-seek' && typeof command[1] === 'number';
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispatchConfiguredMpvCommand(command: (string | number)[]): void {
|
|
||||||
if (!isSubtitleSeekCommand(command)) {
|
|
||||||
window.electronAPI.sendMpvCommand(command);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void options
|
|
||||||
.getPlaybackPaused()
|
|
||||||
.then((paused) => {
|
|
||||||
window.electronAPI.sendMpvCommand(command);
|
|
||||||
if (paused !== false) {
|
|
||||||
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
window.electronAPI.sendMpvCommand(command);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
type ScanModifierState = {
|
type ScanModifierState = {
|
||||||
shiftKey?: boolean;
|
shiftKey?: boolean;
|
||||||
ctrlKey?: boolean;
|
ctrlKey?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import type { CompiledSessionBinding } from '../../types.js';
|
||||||
|
import { createRendererState } from '../state.js';
|
||||||
|
import { buildSessionHelpSections, createSessionHelpModal } from './session-help.js';
|
||||||
|
|
||||||
|
/** Just enough DOM for the help modal to render rows and route row events. */
|
||||||
|
class FakeElement {
|
||||||
|
children: FakeElement[] = [];
|
||||||
|
parent: FakeElement | null = null;
|
||||||
|
dataset: Record<string, string> = {};
|
||||||
|
style: Record<string, string> = {};
|
||||||
|
textContent = '';
|
||||||
|
value = '';
|
||||||
|
tabIndex = 0;
|
||||||
|
type = '';
|
||||||
|
private classes = new Set<string>();
|
||||||
|
private listeners = new Map<string, Array<(event: unknown) => void>>();
|
||||||
|
|
||||||
|
classList = {
|
||||||
|
add: (...tokens: string[]) => tokens.forEach((token) => this.classes.add(token)),
|
||||||
|
remove: (...tokens: string[]) => tokens.forEach((token) => this.classes.delete(token)),
|
||||||
|
toggle: (token: string, force?: boolean) => {
|
||||||
|
const on = force ?? !this.classes.has(token);
|
||||||
|
if (on) this.classes.add(token);
|
||||||
|
else this.classes.delete(token);
|
||||||
|
return on;
|
||||||
|
},
|
||||||
|
contains: (token: string) => this.classes.has(token),
|
||||||
|
};
|
||||||
|
|
||||||
|
set className(value: string) {
|
||||||
|
this.classes = new Set(value.split(/\s+/).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
get className(): string {
|
||||||
|
return [...this.classes].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
set innerHTML(_value: string) {
|
||||||
|
this.children = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
appendChild(child: FakeElement): FakeElement {
|
||||||
|
child.parent = this;
|
||||||
|
this.children.push(child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
insertBefore(child: FakeElement, ref: FakeElement): FakeElement {
|
||||||
|
child.parent = this;
|
||||||
|
const index = this.children.indexOf(ref);
|
||||||
|
this.children.splice(index < 0 ? this.children.length : index, 0, child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: (event: unknown) => void): void {
|
||||||
|
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(type: string, event: Record<string, unknown>): void {
|
||||||
|
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(): void {
|
||||||
|
(globalThis.document as unknown as { activeElement: unknown }).activeElement = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
contains(node: unknown): boolean {
|
||||||
|
for (let current = node as FakeElement | null; current; current = current.parent) {
|
||||||
|
if (current === this) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
closest(selector: string): FakeElement | null {
|
||||||
|
for (let current: FakeElement | null = this; current; current = current.parent) {
|
||||||
|
if (current.classList.contains(selector.slice(1))) return current;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelectorAll(selector: string): FakeElement[] {
|
||||||
|
const matches: FakeElement[] = [];
|
||||||
|
const visit = (node: FakeElement) => {
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.classList.contains(selector.slice(1))) matches.push(child);
|
||||||
|
visit(child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(this);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribute(): void {}
|
||||||
|
removeEventListener(): void {}
|
||||||
|
select(): void {}
|
||||||
|
scrollIntoView(): void {}
|
||||||
|
getClientRects(): unknown[] {
|
||||||
|
return [{}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_BINDINGS: CompiledSessionBinding[] = [
|
||||||
|
{
|
||||||
|
sourcePath: 'stats.toggleKey',
|
||||||
|
originalKey: 'Backquote',
|
||||||
|
key: { code: 'Backquote', modifiers: [] },
|
||||||
|
actionType: 'session-action',
|
||||||
|
actionId: 'toggleStatsOverlay',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourcePath: 'shortcuts.toggleVisibleOverlayGlobal',
|
||||||
|
originalKey: 'KeyO',
|
||||||
|
key: { code: 'KeyO', modifiers: ['alt'] },
|
||||||
|
actionType: 'session-action',
|
||||||
|
actionId: 'toggleVisibleOverlay',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function withFakeDom(
|
||||||
|
run: (harness: ReturnType<typeof createHarness>) => Promise<void>,
|
||||||
|
options: HarnessOptions = {},
|
||||||
|
) {
|
||||||
|
return async () => {
|
||||||
|
const globals = globalThis as Record<string, unknown>;
|
||||||
|
const saved = ['window', 'document', 'HTMLElement', 'Element'].map((key) => [
|
||||||
|
key,
|
||||||
|
globals[key],
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
await run(createHarness(options));
|
||||||
|
} finally {
|
||||||
|
for (const [key, value] of saved) {
|
||||||
|
Object.defineProperty(globalThis, key as string, {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type HarnessOptions = { failActions?: boolean };
|
||||||
|
|
||||||
|
function createHarness(options: HarnessOptions) {
|
||||||
|
const mpvCommands: (string | number)[][] = [];
|
||||||
|
const sessionActions: string[] = [];
|
||||||
|
const define = (key: string, value: unknown) =>
|
||||||
|
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||||
|
|
||||||
|
define('HTMLElement', FakeElement);
|
||||||
|
define('Element', FakeElement);
|
||||||
|
define('document', {
|
||||||
|
activeElement: null,
|
||||||
|
createElement: () => new FakeElement(),
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
});
|
||||||
|
define('window', {
|
||||||
|
electronAPI: {
|
||||||
|
focusMainWindow: async () => {},
|
||||||
|
setIgnoreMouseEvents: () => {},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
getSessionBindings: async () => SESSION_BINDINGS,
|
||||||
|
getSubtitleStyle: async () => ({}),
|
||||||
|
getMarkWatchedKey: async () => null,
|
||||||
|
getSubtitleSidebarSnapshot: async () => ({ config: { toggleKey: null } }),
|
||||||
|
getRuntimeOptions: async () => [],
|
||||||
|
sendMpvCommand: (command: (string | number)[]) => mpvCommands.push(command),
|
||||||
|
dispatchSessionAction: async (actionId: string) => {
|
||||||
|
sessionActions.push(actionId);
|
||||||
|
if (options.failActions) throw new Error('boom');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focus: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
setTimeout: (callback: () => void) => setTimeout(callback, 0),
|
||||||
|
clearTimeout: (id: unknown) => clearTimeout(id as ReturnType<typeof setTimeout>),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dom = {
|
||||||
|
overlay: new FakeElement(),
|
||||||
|
sessionHelpModal: new FakeElement(),
|
||||||
|
sessionHelpFilter: new FakeElement(),
|
||||||
|
sessionHelpContent: new FakeElement(),
|
||||||
|
sessionHelpClose: new FakeElement(),
|
||||||
|
sessionHelpShortcut: new FakeElement(),
|
||||||
|
sessionHelpWarning: new FakeElement(),
|
||||||
|
sessionHelpStatus: new FakeElement(),
|
||||||
|
};
|
||||||
|
const state = createRendererState();
|
||||||
|
const modal = createSessionHelpModal(
|
||||||
|
{
|
||||||
|
state,
|
||||||
|
dom,
|
||||||
|
platform: {
|
||||||
|
overlayLayer: 'modal',
|
||||||
|
isModalLayer: true,
|
||||||
|
isLinuxPlatform: true,
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
shouldToggleMouseIgnore: false,
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
modal.wireDomEvents();
|
||||||
|
|
||||||
|
async function open(commandsEnabled: boolean): Promise<void> {
|
||||||
|
modal.openSessionHelpModal(
|
||||||
|
{ bindingKey: 'KeyH', fallbackUsed: false, fallbackUnavailable: false },
|
||||||
|
{ commandsEnabled },
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keydown targets the focused element, as in the browser; returns whether default was prevented. */
|
||||||
|
function pressEnter(target = (document as unknown as { activeElement: unknown }).activeElement) {
|
||||||
|
let prevented = false;
|
||||||
|
modal.handleSessionHelpKeydown({
|
||||||
|
key: 'Enter',
|
||||||
|
target,
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
altKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
preventDefault: () => {
|
||||||
|
prevented = true;
|
||||||
|
},
|
||||||
|
} as KeyboardEvent);
|
||||||
|
return prevented;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = () => dom.sessionHelpContent.querySelectorAll('.session-help-item');
|
||||||
|
return { dom, state, open, pressEnter, rows, mpvCommands, sessionActions };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('session help rows carry runnable commands except numeric and self-opening actions', () => {
|
||||||
|
const sections = buildSessionHelpSections({
|
||||||
|
sessionBindings: [
|
||||||
|
{
|
||||||
|
sourcePath: 'keybindings[0].key',
|
||||||
|
originalKey: 'Space',
|
||||||
|
key: { code: 'Space', modifiers: [] },
|
||||||
|
actionType: 'mpv-command',
|
||||||
|
command: ['cycle', 'pause'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourcePath: 'shortcuts.copySubtitleMultiple',
|
||||||
|
originalKey: 'Shift+KeyC',
|
||||||
|
key: { code: 'KeyC', modifiers: ['shift'] },
|
||||||
|
actionType: 'session-action',
|
||||||
|
actionId: 'copySubtitleMultiple',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourcePath: 'shortcuts.openSessionHelp',
|
||||||
|
originalKey: 'Slash',
|
||||||
|
key: { code: 'Slash', modifiers: [] },
|
||||||
|
actionType: 'session-action',
|
||||||
|
actionId: 'openSessionHelp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
markWatchedKey: 'KeyW',
|
||||||
|
subtitleStyle: {},
|
||||||
|
});
|
||||||
|
const rows = sections.flatMap((section) => section.rows);
|
||||||
|
const commandFor = (action: string) => rows.find((row) => row.action === action)?.command;
|
||||||
|
|
||||||
|
assert.deepEqual(commandFor('Toggle playback'), {
|
||||||
|
actionType: 'mpv-command',
|
||||||
|
command: ['cycle', 'pause'],
|
||||||
|
});
|
||||||
|
assert.deepEqual(commandFor('Mark video watched'), {
|
||||||
|
actionType: 'session-action',
|
||||||
|
actionId: 'markWatched',
|
||||||
|
});
|
||||||
|
assert.equal(commandFor('Copy subtitle (multi)'), undefined);
|
||||||
|
assert.equal(commandFor('Open session help'), undefined);
|
||||||
|
assert.equal(commandFor('Toggle primary subtitle bar visibility'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'session help runs the selected command on Enter during playback and closes',
|
||||||
|
withFakeDom(async (harness) => {
|
||||||
|
await harness.open(true);
|
||||||
|
assert.ok(harness.rows()[0]?.classList.contains('session-help-item-runnable'));
|
||||||
|
|
||||||
|
harness.pressEnter();
|
||||||
|
|
||||||
|
assert.deepEqual(harness.sessionActions, ['toggleStatsOverlay']);
|
||||||
|
assert.equal(harness.state.sessionHelpModalOpen, false);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'session help ignores Enter when no video is playing',
|
||||||
|
withFakeDom(async (harness) => {
|
||||||
|
await harness.open(false);
|
||||||
|
assert.equal(harness.rows()[0]?.classList.contains('session-help-item-runnable'), false);
|
||||||
|
|
||||||
|
harness.pressEnter();
|
||||||
|
|
||||||
|
assert.deepEqual(harness.sessionActions, []);
|
||||||
|
assert.equal(harness.state.sessionHelpModalOpen, true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'session help hover picks the Enter target and double-click runs the row',
|
||||||
|
withFakeDom(async (harness) => {
|
||||||
|
await harness.open(true);
|
||||||
|
const [statsRow, overlayRow, fixedRow] = harness.rows();
|
||||||
|
|
||||||
|
harness.dom.sessionHelpContent.dispatch('mousemove', { target: overlayRow });
|
||||||
|
assert.equal(harness.state.sessionHelpSelectedIndex, 1);
|
||||||
|
|
||||||
|
harness.dom.sessionHelpContent.dispatch('dblclick', { target: fixedRow });
|
||||||
|
assert.equal(harness.state.sessionHelpModalOpen, true);
|
||||||
|
|
||||||
|
harness.dom.sessionHelpContent.dispatch('dblclick', { target: statsRow?.children[0] });
|
||||||
|
assert.deepEqual(harness.sessionActions, ['toggleStatsOverlay']);
|
||||||
|
assert.equal(harness.state.sessionHelpModalOpen, false);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'session help reports a failed session action on the mpv OSD',
|
||||||
|
withFakeDom(
|
||||||
|
async (harness) => {
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
console.error = () => {};
|
||||||
|
try {
|
||||||
|
await harness.open(true);
|
||||||
|
harness.pressEnter();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
} finally {
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(harness.mpvCommands, [['show-text', 'Command failed to run', '3000']]);
|
||||||
|
},
|
||||||
|
{ failActions: true },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'session help does not run a command from Enter pressed in the filter',
|
||||||
|
withFakeDom(async (harness) => {
|
||||||
|
await harness.open(true);
|
||||||
|
// The filter's own listener already moved focus to a row before this bubbles up.
|
||||||
|
harness.rows()[0]?.focus();
|
||||||
|
|
||||||
|
harness.pressEnter(harness.dom.sessionHelpFilter);
|
||||||
|
|
||||||
|
assert.deepEqual(harness.sessionActions, []);
|
||||||
|
assert.equal(harness.state.sessionHelpModalOpen, true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'session help leaves Enter on the close button to its native activation',
|
||||||
|
withFakeDom(async (harness) => {
|
||||||
|
await harness.open(true);
|
||||||
|
|
||||||
|
const prevented = harness.pressEnter(harness.dom.sessionHelpClose);
|
||||||
|
|
||||||
|
assert.equal(prevented, false);
|
||||||
|
assert.deepEqual(harness.sessionActions, []);
|
||||||
|
assert.equal(harness.state.sessionHelpModalOpen, true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { SessionHelpItem, SessionHelpSection } from './session-help-sections';
|
import type { SessionHelpItem, SessionHelpSection } from './session-help-sections';
|
||||||
|
|
||||||
function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButtonElement {
|
function createShortcutRow(
|
||||||
|
row: SessionHelpItem,
|
||||||
|
globalIndex: number,
|
||||||
|
commandsEnabled: boolean,
|
||||||
|
): HTMLButtonElement {
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.className = 'session-help-item';
|
button.className = 'session-help-item';
|
||||||
@@ -28,6 +32,14 @@ function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButto
|
|||||||
right.insertBefore(dot, action);
|
right.insertBefore(dot, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (commandsEnabled && row.command) {
|
||||||
|
button.classList.add('session-help-item-runnable');
|
||||||
|
const hint = document.createElement('span');
|
||||||
|
hint.className = 'session-help-run-hint';
|
||||||
|
hint.textContent = 'Run ↵';
|
||||||
|
right.appendChild(hint);
|
||||||
|
}
|
||||||
|
|
||||||
button.appendChild(left);
|
button.appendChild(left);
|
||||||
button.appendChild(right);
|
button.appendChild(right);
|
||||||
return button;
|
return button;
|
||||||
@@ -54,6 +66,7 @@ export function createSessionHelpSectionNode(
|
|||||||
section: SessionHelpSection,
|
section: SessionHelpSection,
|
||||||
sectionIndex: number,
|
sectionIndex: number,
|
||||||
globalIndexMap: number[],
|
globalIndexMap: number[],
|
||||||
|
commandsEnabled: boolean,
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
const sectionNode = document.createElement('section');
|
const sectionNode = document.createElement('section');
|
||||||
sectionNode.className = 'session-help-section';
|
sectionNode.className = 'session-help-section';
|
||||||
@@ -69,7 +82,7 @@ export function createSessionHelpSectionNode(
|
|||||||
|
|
||||||
section.rows.forEach((row, rowIndex) => {
|
section.rows.forEach((row, rowIndex) => {
|
||||||
const globalIndex = (globalIndexMap[sectionIndex] ?? 0) + rowIndex;
|
const globalIndex = (globalIndexMap[sectionIndex] ?? 0) + rowIndex;
|
||||||
const button = createShortcutRow(row, globalIndex);
|
const button = createShortcutRow(row, globalIndex, commandsEnabled);
|
||||||
list.appendChild(button);
|
list.appendChild(button);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
|
CompiledMpvCommandBinding,
|
||||||
|
CompiledSessionActionBinding,
|
||||||
CompiledSessionBinding,
|
CompiledSessionBinding,
|
||||||
SessionActionId,
|
SessionActionId,
|
||||||
SessionKeyModifier,
|
SessionKeyModifier,
|
||||||
@@ -7,10 +9,17 @@ import type {
|
|||||||
import { SPECIAL_COMMANDS } from '../../config/definitions/shared';
|
import { SPECIAL_COMMANDS } from '../../config/definitions/shared';
|
||||||
import { buildColorSection, type SessionHelpSubtitleStyle } from './session-help-colors';
|
import { buildColorSection, type SessionHelpSubtitleStyle } from './session-help-colors';
|
||||||
|
|
||||||
|
/** What running a help row does; compiled session bindings satisfy this shape directly. */
|
||||||
|
export type SessionHelpCommand =
|
||||||
|
| Pick<CompiledMpvCommandBinding, 'actionType' | 'command'>
|
||||||
|
| Pick<CompiledSessionActionBinding, 'actionType' | 'actionId' | 'payload'>;
|
||||||
|
|
||||||
export type SessionHelpItem = {
|
export type SessionHelpItem = {
|
||||||
shortcut: string;
|
shortcut: string;
|
||||||
action: string;
|
action: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
/** Set when the help modal can run this row during playback. */
|
||||||
|
command?: SessionHelpCommand;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SessionHelpSection = {
|
export type SessionHelpSection = {
|
||||||
@@ -287,6 +296,21 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Numeric-count actions wait for a follow-up digit in the overlay, and reopening help is a no-op.
|
||||||
|
const HELP_UNRUNNABLE_ACTIONS: ReadonlySet<SessionActionId> = new Set([
|
||||||
|
'copySubtitleMultiple',
|
||||||
|
'mineSentenceMultiple',
|
||||||
|
'openSessionHelp',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function toSessionHelpCommand(binding: CompiledSessionBinding): SessionHelpCommand | undefined {
|
||||||
|
if (binding.actionType === 'mpv-command') {
|
||||||
|
return { actionType: 'mpv-command', command: binding.command };
|
||||||
|
}
|
||||||
|
if (HELP_UNRUNNABLE_ACTIONS.has(binding.actionId)) return undefined;
|
||||||
|
return { actionType: 'session-action', actionId: binding.actionId, payload: binding.payload };
|
||||||
|
}
|
||||||
|
|
||||||
function buildSessionBindingSections(
|
function buildSessionBindingSections(
|
||||||
sessionBindings: CompiledSessionBinding[],
|
sessionBindings: CompiledSessionBinding[],
|
||||||
): SessionHelpSection[] {
|
): SessionHelpSection[] {
|
||||||
@@ -294,12 +318,14 @@ function buildSessionBindingSections(
|
|||||||
|
|
||||||
for (const binding of sessionBindings) {
|
for (const binding of sessionBindings) {
|
||||||
const section = sectionForSessionBinding(binding);
|
const section = sectionForSessionBinding(binding);
|
||||||
|
const command = toSessionHelpCommand(binding);
|
||||||
const row: SessionHelpItem = {
|
const row: SessionHelpItem = {
|
||||||
shortcut: formatSessionKeySpec(binding.key),
|
shortcut: formatSessionKeySpec(binding.key),
|
||||||
action:
|
action:
|
||||||
binding.actionType === 'mpv-command'
|
binding.actionType === 'mpv-command'
|
||||||
? describeCommand(binding.command)
|
? describeCommand(binding.command)
|
||||||
: describeSessionAction(binding.actionId, binding.payload),
|
: describeSessionAction(binding.actionId, binding.payload),
|
||||||
|
...(command ? { command } : {}),
|
||||||
};
|
};
|
||||||
grouped.set(section, [...(grouped.get(section) ?? []), row]);
|
grouped.set(section, [...(grouped.get(section) ?? []), row]);
|
||||||
}
|
}
|
||||||
@@ -337,6 +363,7 @@ function buildConfiguredOverlaySections(input: {
|
|||||||
statsRows.push({
|
statsRows.push({
|
||||||
shortcut: formatKeybinding(input.markWatchedKey),
|
shortcut: formatKeybinding(input.markWatchedKey),
|
||||||
action: 'Mark video watched',
|
action: 'Mark video watched',
|
||||||
|
command: { actionType: 'session-action', actionId: 'markWatched' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +372,7 @@ function buildConfiguredOverlaySections(input: {
|
|||||||
overlayRows.push({
|
overlayRows.push({
|
||||||
shortcut: formatKeybinding(input.subtitleSidebarToggleKey),
|
shortcut: formatKeybinding(input.subtitleSidebarToggleKey),
|
||||||
action: 'Toggle subtitle sidebar',
|
action: 'Toggle subtitle sidebar',
|
||||||
|
command: { actionType: 'session-action', actionId: 'toggleSubtitleSidebar' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import type { ModalStateReader, RendererContext } from '../context';
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
|
import type { SessionHelpOpenPayload } from '../../types/runtime';
|
||||||
import type { RuntimeOptionId, RuntimeOptionState } from '../../types/runtime-options';
|
import type { RuntimeOptionId, RuntimeOptionState } from '../../types/runtime-options';
|
||||||
import {
|
import {
|
||||||
buildSessionHelpSections,
|
buildSessionHelpSections,
|
||||||
|
type SessionHelpItem,
|
||||||
type SessionHelpSection,
|
type SessionHelpSection,
|
||||||
type SessionHelpTabId,
|
type SessionHelpTabId,
|
||||||
} from './session-help-sections';
|
} from './session-help-sections';
|
||||||
import { createSessionHelpSectionNode } from './session-help-render';
|
import { createSessionHelpSectionNode } from './session-help-render';
|
||||||
import { buildVisibleSessionHelpSections, createSessionHelpTabBar } from './session-help-tabs';
|
import { buildVisibleSessionHelpSections, createSessionHelpTabBar } from './session-help-tabs';
|
||||||
import { createModalFocusGuard } from './modal-focus-guard';
|
import { createModalFocusGuard } from './modal-focus-guard';
|
||||||
|
import { dispatchConfiguredMpvCommand } from '../utils/mpv-command-dispatch';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
buildSessionHelpSections,
|
buildSessionHelpSections,
|
||||||
@@ -70,6 +73,9 @@ export function createSessionHelpModal(
|
|||||||
let helpFilterValue = '';
|
let helpFilterValue = '';
|
||||||
let helpSections: SessionHelpSection[] = [];
|
let helpSections: SessionHelpSection[] = [];
|
||||||
let activeTabId: SessionHelpTabId = 'essentials';
|
let activeTabId: SessionHelpTabId = 'essentials';
|
||||||
|
// Rows backed by a command run on Enter / double-click, but only while a video is playing.
|
||||||
|
let commandsEnabled = false;
|
||||||
|
let visibleRows: SessionHelpItem[] = [];
|
||||||
|
|
||||||
function getItems(): HTMLButtonElement[] {
|
function getItems(): HTMLButtonElement[] {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
@@ -77,7 +83,8 @@ export function createSessionHelpModal(
|
|||||||
) as HTMLButtonElement[];
|
) as HTMLButtonElement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSelected(index: number): void {
|
/** Pointer selection skips scrolling and leaves filter typing focus alone. */
|
||||||
|
function setSelected(index: number, fromPointer = false): void {
|
||||||
const items = getItems();
|
const items = getItems();
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
|
|
||||||
@@ -91,6 +98,10 @@ export function createSessionHelpModal(
|
|||||||
});
|
});
|
||||||
const activeItem = items[next];
|
const activeItem = items[next];
|
||||||
if (!activeItem) return;
|
if (!activeItem) return;
|
||||||
|
if (fromPointer) {
|
||||||
|
if (!isFilterInputFocused()) activeItem.focus({ preventScroll: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
activeItem.focus({ preventScroll: true });
|
activeItem.focus({ preventScroll: true });
|
||||||
activeItem.scrollIntoView({
|
activeItem.scrollIntoView({
|
||||||
block: 'nearest',
|
block: 'nearest',
|
||||||
@@ -117,6 +128,7 @@ export function createSessionHelpModal(
|
|||||||
|
|
||||||
function applyFilterAndRender(): void {
|
function applyFilterAndRender(): void {
|
||||||
const sections = buildVisibleSessionHelpSections(helpSections, activeTabId, helpFilterValue);
|
const sections = buildVisibleSessionHelpSections(helpSections, activeTabId, helpFilterValue);
|
||||||
|
visibleRows = sections.flatMap((section) => section.rows);
|
||||||
const indexOffsets: number[] = [];
|
const indexOffsets: number[] = [];
|
||||||
let running = 0;
|
let running = 0;
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
@@ -134,7 +146,12 @@ export function createSessionHelpModal(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
sections.forEach((section, sectionIndex) => {
|
sections.forEach((section, sectionIndex) => {
|
||||||
const sectionNode = createSessionHelpSectionNode(section, sectionIndex, indexOffsets);
|
const sectionNode = createSessionHelpSectionNode(
|
||||||
|
section,
|
||||||
|
sectionIndex,
|
||||||
|
indexOffsets,
|
||||||
|
commandsEnabled,
|
||||||
|
);
|
||||||
ctx.dom.sessionHelpContent.appendChild(sectionNode);
|
ctx.dom.sessionHelpContent.appendChild(sectionNode);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,6 +173,7 @@ export function createSessionHelpModal(
|
|||||||
|
|
||||||
function showRenderError(message: string): void {
|
function showRenderError(message: string): void {
|
||||||
helpSections = [];
|
helpSections = [];
|
||||||
|
visibleRows = [];
|
||||||
helpFilterValue = '';
|
helpFilterValue = '';
|
||||||
activeTabId = 'essentials';
|
activeTabId = 'essentials';
|
||||||
ctx.dom.sessionHelpFilter.value = '';
|
ctx.dom.sessionHelpFilter.value = '';
|
||||||
@@ -199,12 +217,17 @@ export function createSessionHelpModal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSessionHelpModal(opening: SessionHelpBindingInfo): void {
|
function openSessionHelpModal(
|
||||||
|
opening: SessionHelpBindingInfo,
|
||||||
|
payload: SessionHelpOpenPayload = { commandsEnabled: false },
|
||||||
|
): void {
|
||||||
openBinding = opening;
|
openBinding = opening;
|
||||||
|
commandsEnabled = payload.commandsEnabled;
|
||||||
priorFocus = document.activeElement;
|
priorFocus = document.activeElement;
|
||||||
|
|
||||||
ctx.state.sessionHelpModalOpen = true;
|
ctx.state.sessionHelpModalOpen = true;
|
||||||
helpSections = [];
|
helpSections = [];
|
||||||
|
visibleRows = [];
|
||||||
helpFilterValue = '';
|
helpFilterValue = '';
|
||||||
options.syncSettingsModalSubtitleSuppression();
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
ctx.dom.overlay.classList.add('interactive');
|
ctx.dom.overlay.classList.add('interactive');
|
||||||
@@ -238,8 +261,9 @@ export function createSessionHelpModal(
|
|||||||
void render().then((dataLoaded) => {
|
void render().then((dataLoaded) => {
|
||||||
if (!ctx.state.sessionHelpModalOpen) return;
|
if (!ctx.state.sessionHelpModalOpen) return;
|
||||||
if (dataLoaded) {
|
if (dataLoaded) {
|
||||||
ctx.dom.sessionHelpStatus.textContent =
|
ctx.dom.sessionHelpStatus.textContent = commandsEnabled
|
||||||
'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.';
|
? 'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Enter or double-click runs a command. Esc closes.'
|
||||||
|
: 'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.';
|
||||||
} else {
|
} else {
|
||||||
ctx.dom.sessionHelpStatus.textContent =
|
ctx.dom.sessionHelpStatus.textContent =
|
||||||
'Session help data is unavailable right now. Press Esc to close.';
|
'Session help data is unavailable right now. Press Esc to close.';
|
||||||
@@ -284,10 +308,46 @@ export function createSessionHelpModal(
|
|||||||
window.focus();
|
window.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes help first so a command that opens another modal (or needs mpv focus)
|
||||||
|
* lands after help has released the overlay.
|
||||||
|
*/
|
||||||
|
function runSelectedCommand(): void {
|
||||||
|
if (!commandsEnabled) return;
|
||||||
|
const command = visibleRows[ctx.state.sessionHelpSelectedIndex]?.command;
|
||||||
|
if (!command) return;
|
||||||
|
|
||||||
|
closeSessionHelpModal();
|
||||||
|
if (command.actionType === 'mpv-command') {
|
||||||
|
dispatchConfiguredMpvCommand(command.command, {
|
||||||
|
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
||||||
|
sendMpvCommand: (mpvCommand) => window.electronAPI.sendMpvCommand(mpvCommand),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Help is already closed, so surface failures on the mpv OSD.
|
||||||
|
void window.electronAPI
|
||||||
|
.dispatchSessionAction(command.actionId, command.payload)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error(`Session help could not run ${command.actionId}`, error);
|
||||||
|
window.electronAPI.sendMpvCommand(['show-text', 'Command failed to run', '3000']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowIndex(target: EventTarget | null): number | null {
|
||||||
|
if (!(target instanceof Element)) return null;
|
||||||
|
const row = target.closest('.session-help-item') as HTMLElement | null;
|
||||||
|
if (!row) return null;
|
||||||
|
const index = Number.parseInt(row.dataset.sessionHelpIndex ?? '', 10);
|
||||||
|
return Number.isFinite(index) ? index : null;
|
||||||
|
}
|
||||||
|
|
||||||
function handleSessionHelpKeydown(e: KeyboardEvent): boolean {
|
function handleSessionHelpKeydown(e: KeyboardEvent): boolean {
|
||||||
if (!ctx.state.sessionHelpModalOpen) return false;
|
if (!ctx.state.sessionHelpModalOpen) return false;
|
||||||
|
|
||||||
if (isFilterInputFocused()) {
|
// The filter's own Enter listener moves focus to the list before this bubbled
|
||||||
|
// handler runs, so check the event target too or filter Enter would run a command.
|
||||||
|
if (isFilterInputFocused() || e.target === ctx.dom.sessionHelpFilter) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!helpFilterValue) {
|
if (!helpFilterValue) {
|
||||||
@@ -333,6 +393,13 @@ export function createSessionHelpModal(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only rows run commands; Enter on the close or tab buttons keeps its native activation.
|
||||||
|
if (e.key === 'Enter' && getRowIndex(e.target) !== null) {
|
||||||
|
e.preventDefault();
|
||||||
|
runSelectedCommand();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,13 +417,22 @@ export function createSessionHelpModal(
|
|||||||
});
|
});
|
||||||
|
|
||||||
ctx.dom.sessionHelpContent.addEventListener('click', (event: MouseEvent) => {
|
ctx.dom.sessionHelpContent.addEventListener('click', (event: MouseEvent) => {
|
||||||
const target = event.target;
|
const index = getRowIndex(event.target);
|
||||||
if (!(target instanceof Element)) return;
|
if (index !== null) setSelected(index);
|
||||||
const row = target.closest('.session-help-item') as HTMLElement | null;
|
});
|
||||||
if (!row) return;
|
|
||||||
const index = Number.parseInt(row.dataset.sessionHelpIndex ?? '', 10);
|
// Hovering a row makes it the Enter target.
|
||||||
if (!Number.isFinite(index)) return;
|
ctx.dom.sessionHelpContent.addEventListener('mousemove', (event: MouseEvent) => {
|
||||||
|
const index = getRowIndex(event.target);
|
||||||
|
if (index === null || index === ctx.state.sessionHelpSelectedIndex) return;
|
||||||
|
setSelected(index, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.dom.sessionHelpContent.addEventListener('dblclick', (event: MouseEvent) => {
|
||||||
|
const index = getRowIndex(event.target);
|
||||||
|
if (index === null) return;
|
||||||
setSelected(index);
|
setSelected(index);
|
||||||
|
runSelectedCommand();
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.dom.sessionHelpClose.addEventListener('click', () => {
|
ctx.dom.sessionHelpClose.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -316,7 +316,13 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
|||||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||||
handleChangelogKeydown: changelogModal.handleChangelogKeydown,
|
handleChangelogKeydown: changelogModal.handleChangelogKeydown,
|
||||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
// Route through main so it can gate help commands on mpv playback state;
|
||||||
|
// onOpenSessionHelp re-resolves the chord hint when the open event arrives.
|
||||||
|
openSessionHelpModal: () => {
|
||||||
|
void window.electronAPI.dispatchSessionAction('openSessionHelp').catch((error: unknown) => {
|
||||||
|
console.error('Could not open session help', error);
|
||||||
|
});
|
||||||
|
},
|
||||||
openControllerSelectModal: () => {
|
openControllerSelectModal: () => {
|
||||||
if (controllerSelectModal.openControllerSelectModal()) {
|
if (controllerSelectModal.openControllerSelectModal()) {
|
||||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||||
@@ -572,9 +578,9 @@ function registerModalOpenHandlers(): void {
|
|||||||
await characterDictionaryModal.openCharacterDictionaryManagerModal();
|
await characterDictionaryModal.openCharacterDictionaryManagerModal();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
window.electronAPI.onOpenSessionHelp(() => {
|
window.electronAPI.onOpenSessionHelp((payload) => {
|
||||||
runGuarded('session-help:open', () => {
|
runGuarded('session-help:open', () => {
|
||||||
sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo());
|
sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo(), payload);
|
||||||
window.electronAPI.notifyOverlayModalOpened('session-help');
|
window.electronAPI.notifyOverlayModalOpened('session-help');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4125,6 +4125,21 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Runnable rows reveal their hint only on the row Enter would run. */
|
||||||
|
.session-help-run-hint {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ctp-blue);
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-help-item-runnable.active .session-help-run-hint {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.session-help-color-dot {
|
.session-help-color-dot {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { dispatchConfiguredMpvCommand } from './mpv-command-dispatch';
|
||||||
|
|
||||||
|
async function dispatch(command: (string | number)[], paused: boolean | null | Error) {
|
||||||
|
const sent: (string | number)[][] = [];
|
||||||
|
dispatchConfiguredMpvCommand(command, {
|
||||||
|
getPlaybackPaused: async () => {
|
||||||
|
if (paused instanceof Error) throw paused;
|
||||||
|
return paused;
|
||||||
|
},
|
||||||
|
sendMpvCommand: (mpvCommand) => sent.push(mpvCommand),
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('subtitle seeks keep paused or unknown playback paused', async () => {
|
||||||
|
const repaused = [
|
||||||
|
['sub-seek', 1],
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
];
|
||||||
|
assert.deepEqual(await dispatch(['sub-seek', 1], true), repaused);
|
||||||
|
assert.deepEqual(await dispatch(['sub-seek', 1], null), repaused);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle seeks leave running playback alone', async () => {
|
||||||
|
assert.deepEqual(await dispatch(['sub-seek', -1], false), [['sub-seek', -1]]);
|
||||||
|
assert.deepEqual(await dispatch(['sub-seek', -1], new Error('ipc down')), [['sub-seek', -1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('other mpv commands are sent as-is', async () => {
|
||||||
|
assert.deepEqual(await dispatch(['cycle', 'pause'], true), [['cycle', 'pause']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed re-pause does not resend the subtitle seek', async () => {
|
||||||
|
const sent: (string | number)[][] = [];
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
console.error = () => {};
|
||||||
|
try {
|
||||||
|
dispatchConfiguredMpvCommand(['sub-seek', 1], {
|
||||||
|
getPlaybackPaused: async () => true,
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
sent.push(command);
|
||||||
|
if (command[0] === 'set_property') throw new Error('ipc closed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
} finally {
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(sent, [
|
||||||
|
['sub-seek', 1],
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
type MpvCommand = (string | number)[];
|
||||||
|
|
||||||
|
function isSubtitleSeekCommand(command: MpvCommand): command is [string, number] {
|
||||||
|
return command[0] === 'sub-seek' && typeof command[1] === 'number';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a configured mpv command. Subtitle seeks re-pause afterwards unless playback
|
||||||
|
* is known to be running, so stepping lines from a paused video stays paused.
|
||||||
|
*/
|
||||||
|
export function dispatchConfiguredMpvCommand(
|
||||||
|
command: MpvCommand,
|
||||||
|
deps: {
|
||||||
|
getPlaybackPaused: () => Promise<boolean | null>;
|
||||||
|
sendMpvCommand: (command: MpvCommand) => void;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
if (!isSubtitleSeekCommand(command)) {
|
||||||
|
deps.sendMpvCommand(command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fallback only covers a failed pause lookup, so a failed re-pause never resends the seek.
|
||||||
|
void deps
|
||||||
|
.getPlaybackPaused()
|
||||||
|
.then(
|
||||||
|
(paused) => {
|
||||||
|
deps.sendMpvCommand(command);
|
||||||
|
if (paused !== false) {
|
||||||
|
deps.sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
deps.sendMpvCommand(command);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.catch((error: unknown) => console.error('Could not send mpv command', error));
|
||||||
|
}
|
||||||
@@ -434,6 +434,11 @@ export type CharacterDictionaryManagerMutationResult =
|
|||||||
| (CharacterDictionaryManagerSnapshot & { ok: true; rebuildRequired?: boolean })
|
| (CharacterDictionaryManagerSnapshot & { ok: true; rebuildRequired?: boolean })
|
||||||
| { ok: false; message: string; entries: CharacterDictionaryManagerEntry[] };
|
| { ok: false; message: string; entries: CharacterDictionaryManagerEntry[] };
|
||||||
|
|
||||||
|
/** Sent with session-help:open; commands only run when a video is loaded in mpv. */
|
||||||
|
export interface SessionHelpOpenPayload {
|
||||||
|
commandsEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionNumericSelectionStartPayload {
|
export interface SessionNumericSelectionStartPayload {
|
||||||
actionId: Extract<SessionActionId, 'copySubtitleMultiple' | 'mineSentenceMultiple'>;
|
actionId: Extract<SessionActionId, 'copySubtitleMultiple' | 'mineSentenceMultiple'>;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
@@ -547,7 +552,7 @@ export interface ElectronAPI {
|
|||||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => Promise<RuntimeOptionApplyResult>;
|
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => Promise<RuntimeOptionApplyResult>;
|
||||||
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
|
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
|
||||||
onOpenRuntimeOptions: (callback: () => void) => void;
|
onOpenRuntimeOptions: (callback: () => void) => void;
|
||||||
onOpenSessionHelp: (callback: () => void) => void;
|
onOpenSessionHelp: (callback: (payload: SessionHelpOpenPayload) => void) => void;
|
||||||
onOpenChangelog: (callback: () => void) => void;
|
onOpenChangelog: (callback: () => void) => void;
|
||||||
getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||||
onOpenControllerSelect: (callback: () => void) => void;
|
onOpenControllerSelect: (callback: () => void) => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user