Merge remote-tracking branch 'origin/main' into add-hachidori-backend

This commit is contained in:
2026-09-29 20:27:55 -07:00
4 changed files with 59 additions and 22 deletions
+1 -1
View File
@@ -260,7 +260,7 @@ export function createKeyboardHandlers(
} }
if (binding.actionType === 'mpv-command') { if (binding.actionType === 'mpv-command') {
dispatchConfiguredMpvCommand(binding.command, { void dispatchConfiguredMpvCommand(binding.command, {
getPlaybackPaused: options.getPlaybackPaused, getPlaybackPaused: options.getPlaybackPaused,
sendMpvCommand: (command) => window.electronAPI.sendMpvCommand(command), sendMpvCommand: (command) => window.electronAPI.sendMpvCommand(command),
}); });
@@ -143,11 +143,13 @@ function withFakeDom(
}; };
} }
type HarnessOptions = { failActions?: boolean }; type HarnessOptions = { failActions?: boolean; bindings?: CompiledSessionBinding[] };
function createHarness(options: HarnessOptions) { function createHarness(options: HarnessOptions) {
const mpvCommands: (string | number)[][] = []; const mpvCommands: (string | number)[][] = [];
const sessionActions: string[] = []; const sessionActions: string[] = [];
/** IPC in send order; main destroys the modal window on `closed`, dropping anything after it. */
const ipcLog: string[] = [];
const define = (key: string, value: unknown) => const define = (key: string, value: unknown) =>
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
@@ -163,15 +165,20 @@ function createHarness(options: HarnessOptions) {
electronAPI: { electronAPI: {
focusMainWindow: async () => {}, focusMainWindow: async () => {},
setIgnoreMouseEvents: () => {}, setIgnoreMouseEvents: () => {},
notifyOverlayModalClosed: () => {}, notifyOverlayModalClosed: () => ipcLog.push('closed'),
getSessionBindings: async () => SESSION_BINDINGS, getSessionBindings: async () => options.bindings ?? SESSION_BINDINGS,
getSubtitleStyle: async () => ({}), getSubtitleStyle: async () => ({}),
getMarkWatchedKey: async () => null, getMarkWatchedKey: async () => null,
getSubtitleSidebarSnapshot: async () => ({ config: { toggleKey: null } }), getSubtitleSidebarSnapshot: async () => ({ config: { toggleKey: null } }),
getRuntimeOptions: async () => [], getRuntimeOptions: async () => [],
sendMpvCommand: (command: (string | number)[]) => mpvCommands.push(command), getPlaybackPaused: async () => true,
sendMpvCommand: (command: (string | number)[]) => {
mpvCommands.push(command);
ipcLog.push(`mpv:${command.join(' ')}`);
},
dispatchSessionAction: async (actionId: string) => { dispatchSessionAction: async (actionId: string) => {
sessionActions.push(actionId); sessionActions.push(actionId);
ipcLog.push(`action:${actionId}`);
if (options.failActions) throw new Error('boom'); if (options.failActions) throw new Error('boom');
}, },
}, },
@@ -239,7 +246,7 @@ function createHarness(options: HarnessOptions) {
} }
const rows = () => dom.sessionHelpContent.querySelectorAll('.session-help-item'); const rows = () => dom.sessionHelpContent.querySelectorAll('.session-help-item');
return { dom, state, open, pressEnter, rows, mpvCommands, sessionActions }; return { dom, state, open, pressEnter, rows, mpvCommands, sessionActions, ipcLog };
} }
test('session help rows carry runnable commands except numeric and self-opening actions', () => { test('session help rows carry runnable commands except numeric and self-opening actions', () => {
@@ -294,11 +301,39 @@ test(
harness.pressEnter(); harness.pressEnter();
assert.deepEqual(harness.sessionActions, ['toggleStatsOverlay']); assert.deepEqual(harness.ipcLog, ['action:toggleStatsOverlay', 'closed']);
assert.equal(harness.state.sessionHelpModalOpen, false); assert.equal(harness.state.sessionHelpModalOpen, false);
}), }),
); );
test(
'session help sends a paused subtitle seek before closing',
withFakeDom(
async (harness) => {
await harness.open(true);
// Playback rows live on another tab; a filter searches every tab.
harness.dom.sessionHelpFilter.value = 'next subtitle';
harness.dom.sessionHelpFilter.dispatch('input', {});
harness.dom.sessionHelpContent.dispatch('dblclick', { target: harness.rows()[0] });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(harness.ipcLog, ['mpv:sub-seek 1', 'mpv:set_property pause yes', 'closed']);
},
{
bindings: [
{
sourcePath: 'keybindings[0].key',
originalKey: 'Shift+KeyL',
key: { code: 'KeyL', modifiers: ['shift'] },
actionType: 'mpv-command',
command: ['sub-seek', 1],
},
],
},
),
);
test( test(
'session help ignores Enter when no video is playing', 'session help ignores Enter when no video is playing',
withFakeDom(async (harness) => { withFakeDom(async (harness) => {
+13 -12
View File
@@ -309,29 +309,30 @@ export function createSessionHelpModal(
} }
/** /**
* Closes help first so a command that opens another modal (or needs mpv focus) * Sends the command before closing help. On Linux and Windows, main destroys the
* lands after help has released the overlay. * dedicated modal window once its last modal closes, so IPC sent after the close
* is dropped. Main still handles the command first, so a command that opens another
* modal keeps the modal window alive.
*/ */
function runSelectedCommand(): void { function runSelectedCommand(): void {
if (!commandsEnabled) return; if (!commandsEnabled) return;
const command = visibleRows[ctx.state.sessionHelpSelectedIndex]?.command; const command = visibleRows[ctx.state.sessionHelpSelectedIndex]?.command;
if (!command) return; if (!command) return;
closeSessionHelpModal();
if (command.actionType === 'mpv-command') { if (command.actionType === 'mpv-command') {
dispatchConfiguredMpvCommand(command.command, { void dispatchConfiguredMpvCommand(command.command, {
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(), getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
sendMpvCommand: (mpvCommand) => window.electronAPI.sendMpvCommand(mpvCommand), sendMpvCommand: (mpvCommand) => window.electronAPI.sendMpvCommand(mpvCommand),
}); }).finally(closeSessionHelpModal);
return; return;
} }
// Help is already closed, so surface failures on the mpv OSD. const dispatched = window.electronAPI.dispatchSessionAction(command.actionId, command.payload);
void window.electronAPI closeSessionHelpModal();
.dispatchSessionAction(command.actionId, command.payload) // Help is closed by the time this settles, so surface failures on the mpv OSD.
.catch((error: unknown) => { void dispatched.catch((error: unknown) => {
console.error(`Session help could not run ${command.actionId}`, error); console.error(`Session help could not run ${command.actionId}`, error);
window.electronAPI.sendMpvCommand(['show-text', 'Command failed to run', '3000']); window.electronAPI.sendMpvCommand(['show-text', 'Command failed to run', '3000']);
}); });
} }
function getRowIndex(target: EventTarget | null): number | null { function getRowIndex(target: EventTarget | null): number | null {
+4 -3
View File
@@ -7,6 +7,7 @@ function isSubtitleSeekCommand(command: MpvCommand): command is [string, number]
/** /**
* Sends a configured mpv command. Subtitle seeks re-pause afterwards unless playback * 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. * is known to be running, so stepping lines from a paused video stays paused.
* Resolves once every command has been sent; it never rejects.
*/ */
export function dispatchConfiguredMpvCommand( export function dispatchConfiguredMpvCommand(
command: MpvCommand, command: MpvCommand,
@@ -14,14 +15,14 @@ export function dispatchConfiguredMpvCommand(
getPlaybackPaused: () => Promise<boolean | null>; getPlaybackPaused: () => Promise<boolean | null>;
sendMpvCommand: (command: MpvCommand) => void; sendMpvCommand: (command: MpvCommand) => void;
}, },
): void { ): Promise<void> {
if (!isSubtitleSeekCommand(command)) { if (!isSubtitleSeekCommand(command)) {
deps.sendMpvCommand(command); deps.sendMpvCommand(command);
return; return Promise.resolve();
} }
// The fallback only covers a failed pause lookup, so a failed re-pause never resends the seek. // The fallback only covers a failed pause lookup, so a failed re-pause never resends the seek.
void deps return deps
.getPlaybackPaused() .getPlaybackPaused()
.then( .then(
(paused) => { (paused) => {