diff --git a/bun.lock b/bun.lock index fc7c7b92..db9ed5d5 100644 --- a/bun.lock +++ b/bun.lock @@ -21,13 +21,13 @@ "@electron/asar": "3.4.1", "@types/node": "^24.10.0", "@types/ws": "^8.18.1", - "electron": "42.6.0", + "electron": "42.10.0", "electron-builder": "26.16.1", "esbuild": "^0.25.12", "eslint": "^10.8.0", "prettier": "^3.8.1", "typescript": "^5.9.3", - "undici": "7.29.0", + "undici": "7.29.1", }, }, }, @@ -48,7 +48,7 @@ "picomatch": "4.0.4", "tar": "7.5.21", "tmp": "0.2.7", - "undici": "7.29.0", + "undici": "7.29.1", }, "packages": { "@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], @@ -347,7 +347,7 @@ "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - "electron": ["electron@42.6.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-axGNgd+yCTg+vi1VEGrQqAj9WVWkePKwbICSAvMiT2eTaxhij9a/xhBHD6rXV8wrlW9ZfJzE5+xg752ImxrmTw=="], + "electron": ["electron@42.10.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-yBWnZO8tKId0ADvGa4+imPmm3+8Kfs8MtkUPhQQJz1AjTnUUDwCTWkQEU0CrArVVL97Jo4Q/0mVcLHciicBDGQ=="], "electron-builder": ["electron-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.16.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg=="], @@ -717,7 +717,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], diff --git a/changes/hyprland-fullscreen-refresh.md b/changes/hyprland-fullscreen-refresh.md new file mode 100644 index 00000000..d6d7e97c --- /dev/null +++ b/changes/hyprland-fullscreen-refresh.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Keep the subtitle overlay mapped during fullscreen transitions on Hyprland, avoiding focus changes from repeated hide/show calls that can cancel mpv fullscreen entry. Preserve the hide/show refresh on Sway. diff --git a/changes/patched-electron-undici.md b/changes/patched-electron-undici.md new file mode 100644 index 00000000..65cca7fd --- /dev/null +++ b/changes/patched-electron-undici.md @@ -0,0 +1,4 @@ +type: fixed +area: dependencies + +- Updated Electron to 42.10.0 and undici to 7.29.1 to resolve the security advisories blocking CI. diff --git a/changes/session-help-run-commands.md b/changes/session-help-run-commands.md new file mode 100644 index 00000000..7b7d8351 --- /dev/null +++ b/changes/session-help-run-commands.md @@ -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. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index 07afd3aa..0ab2d13c 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -142,6 +142,8 @@ Playback stays paused while the dialog is open, even if the popup or hover that The dialog shows the clip over a speech waveform. When the waveform loads, an untouched clip end moves back to just after the last speech in the line. The orange bars still mark the subtitle's original start and end. +During preview, the yellow cursor follows the audio player's position through the selected range and stays at the end when playback finishes. Buffering pauses the cursor too. If the player stops reporting progress for 15 seconds, the preview stops so you can retry. + | Action | How | | ------------------------ | --------------------------------------------------------------------- | | Trim | Drag either edge, or click the waveform to move the nearer edge there | diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 4a0d64f4..75a613a8 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -88,6 +88,8 @@ Built into the overlay, not configurable: | `Alt+C` | Open controller setup and remapping | `shortcuts.openControllerSelect` | | `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 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). diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index 34b6f857..aefd9c1a 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -225,6 +225,8 @@ bind = ALT SHIFT, Y, pass, class:^(SubMiner)$ If the overlay stays behind fullscreen mpv, check that the mpv socket is connected and that `hyprctl -j clients` works from the environment that launched SubMiner. +SubMiner restacks the existing overlay during fullscreen transitions on Hyprland. It keeps the overlay mapped so repeated hide/show calls do not steal focus and cancel mpv's fullscreen entry. Sway retains the hide/show refresh because SubMiner has no Sway-specific restacking implementation. + See the Hyprland wiki on [global keybinds](https://wiki.hypr.land/Configuring/Binds/#global-keybinds) and [window rules](https://wiki.hypr.land/Configuring/Window-Rules/). ### KDE Plasma and other Wayland compositors diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index 1cb956e9..d5091148 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -24,6 +24,7 @@ Read when: you need to find the owner module for a behavior or test surface Windows executable lookup and detached process creation are shared in `src/main/runtime/mpv-process.ts`. The Windows launcher and Jellyfin handlers retain their own playback and connection workflows. - Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/` - Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts` +- Media timing review: `src/main/runtime/media-timing-review.ts` owns the review and hidden `MediaTimingPreviewSession`. The session waits for mpv readiness and acknowledges playback commands; its `time-pos` observations reach the modal through `media-timing-review:preview-position`. The renderer places the cursor from those timestamps and uses an inactivity timeout instead of timing the clip itself. - Immersion tracking: `src/core/services/immersion-tracker/` Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata. Library-entry identity aliases and merge recommendations are persisted alongside this schema; the stats HTTP and SPA layers only expose and present those domain decisions. diff --git a/package.json b/package.json index 5195dc4c..e1b613be 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "picomatch": "4.0.4", "tar": "7.5.21", "tmp": "0.2.7", - "undici": "7.29.0" + "undici": "7.29.1" }, "keywords": [ "anki", @@ -130,13 +130,13 @@ "@electron/asar": "3.4.1", "@types/node": "^24.10.0", "@types/ws": "^8.18.1", - "electron": "42.6.0", + "electron": "42.10.0", "electron-builder": "26.16.1", "esbuild": "^0.25.12", "eslint": "^10.8.0", "prettier": "^3.8.1", "typescript": "^5.9.3", - "undici": "7.29.0" + "undici": "7.29.1" }, "build": { "appId": "com.sudacode.SubMiner", diff --git a/src/core/services/media-timing-preview.test.ts b/src/core/services/media-timing-preview.test.ts index c13cb61d..eea91f60 100644 --- a/src/core/services/media-timing-preview.test.ts +++ b/src/core/services/media-timing-preview.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; +import { ChildProcess } from 'node:child_process'; import net from 'node:net'; import { describe, test } from 'node:test'; import { buildMediaTimingPreviewArgs, MediaTimingPreviewSession } from './media-timing-preview'; @@ -217,6 +218,15 @@ function createFakeSocket() { socket.destroyed = false; socket.write = (data) => { writes.push(data); + const message = JSON.parse(data); + if (message.request_id !== undefined) { + queueMicrotask(() => + socket.emit( + 'data', + `${JSON.stringify({ request_id: message.request_id, error: 'success' })}\n`, + ), + ); + } return true; }; socket.end = () => undefined; @@ -226,6 +236,85 @@ function createFakeSocket() { return { socket, writes }; } +test('preview waits for loaded media before seeking and reports the actual playback position', async () => { + const { socket, writes } = createFakeSocket(); + const child = new ChildProcess(); + child.kill = () => true; + const positions: number[] = []; + const session = new MediaTimingPreviewSession({ + spawnProcess: () => child, + connectSocket: () => { + queueMicrotask(() => socket.emit('connect')); + return socket as never; + }, + removeSocketFile: () => undefined, + createSocketPath: () => '/tmp/review.sock', + }); + const position = (data: number) => + socket.emit( + 'data', + `${JSON.stringify({ event: 'property-change', name: 'time-pos', data })}\n`, + ); + try { + await session.start({ + mediaPath: '/video/show.mkv', + onPlaybackPosition: (time) => positions.push(time), + }); + const playing = session.play(36, 42); + await Promise.resolve(); + assert.equal( + writes.some((line) => JSON.parse(line).command[0] === 'seek'), + false, + ); + position(0); + await playing; + position(36); + position(39); + // No advancing position means no fabricated progress, even while audio is stalled. + assert.deepEqual(positions, [36, 39]); + position(41.9); + assert.deepEqual(positions, [36, 39, 41.9]); + await session.stop(); + position(42); + assert.deepEqual(positions, [36, 39, 41.9]); + } finally { + session.dispose(); + } +}); + +for (const action of ['stop', 'dispose'] as const) { + test(`${action} cancels a preview still waiting for media to load`, async () => { + const { socket, writes } = createFakeSocket(); + const child = new ChildProcess(); + child.kill = () => true; + const session = new MediaTimingPreviewSession({ + spawnProcess: () => child, + connectSocket: () => { + queueMicrotask(() => socket.emit('connect')); + return socket as never; + }, + removeSocketFile: () => undefined, + createSocketPath: () => '/tmp/review.sock', + }); + try { + await session.start({ mediaPath: '/video/show.mkv' }); + const pending = assert.rejects(session.play(36, 42), /closed|cancelled/); + await session[action](); + await pending; + socket.emit( + 'data', + `${JSON.stringify({ event: 'property-change', name: 'time-pos', data: 0 })}\n`, + ); + assert.equal( + writes.some((line) => JSON.parse(line).command[0] === 'seek'), + false, + ); + } finally { + session.dispose(); + } + }); +} + test('preview session plays once to the clip end and reports when mpv has drained it', async () => { const { socket, writes } = createFakeSocket(); const child = new EventEmitter() as EventEmitter & { kill: () => boolean }; @@ -253,11 +342,16 @@ test('preview session plays once to the clip end and reports when mpv has draine [ ['observe_property', 1, 'eof-reached'], ['observe_property', 2, 'pause'], + ['observe_property', 3, 'time-pos'], ], ); // The observers' initial replies describe the idle paused player, not a finished preview. socket.emit('data', property('eof-reached', false) + property('pause', true)); assert.equal(endedCount, 0); + socket.emit( + 'data', + `${JSON.stringify({ event: 'property-change', name: 'time-pos', data: 0 })}\n`, + ); writes.length = 0; await session.play(12.25, 14.5); @@ -265,8 +359,8 @@ test('preview session plays once to the clip end and reports when mpv has draine writes.map((line) => JSON.parse(line).command), [ ['set_property', 'pause', true], - ['seek', 12.25, 'absolute+exact'], ['set_property', 'end', '14.500'], + ['seek', 12.25, 'absolute+exact'], ['set_property', 'pause', false], ], ); diff --git a/src/core/services/media-timing-preview.ts b/src/core/services/media-timing-preview.ts index 60f60863..e2d2066e 100644 --- a/src/core/services/media-timing-preview.ts +++ b/src/core/services/media-timing-preview.ts @@ -15,6 +15,8 @@ const CONNECT_RETRY_MS = 40; */ const EOF_OBSERVER_ID = 1; const PAUSE_OBSERVER_ID = 2; +const POSITION_OBSERVER_ID = 3; +const PLAYER_RESPONSE_TIMEOUT_MS = 15_000; export interface MediaTimingPreviewStartOptions { mediaPath: string; @@ -23,6 +25,7 @@ export interface MediaTimingPreviewStartOptions { volume?: number; /** The file keeps source timestamps (a cached remote window); seek with the original times. */ absoluteTimestamps?: boolean; + onPlaybackPosition?: (time: number) => void; } type PreviewProcess = Pick; @@ -103,8 +106,21 @@ export class MediaTimingPreviewSession { private disposed = false; private readBuffer = ''; private playing = false; + private playSequence = 0; private eofReached = false; private paused = true; + private mediaReady = false; + private onPlaybackPosition: MediaTimingPreviewStartOptions['onPlaybackPosition']; + private nextRequestId = 0; + private readonly pendingCommands = new Map< + number, + { + resolve: () => void; + reject: (error: Error) => void; + } + >(); + private readonly readyListeners = new Set<() => void>(); + private readonly pendingWaits = new Set<(error: Error) => void>(); private readonly endedListeners = new Set<() => void>(); constructor(deps: Partial = {}) { @@ -141,6 +157,8 @@ export class MediaTimingPreviewSession { private async startOnce(options: MediaTimingPreviewStartOptions): Promise { const mediaPath = options.mediaPath.trim(); if (!mediaPath) throw new Error('No media source is available for preview'); + this.mediaReady = false; + this.onPlaybackPosition = options.onPlaybackPosition; const socketPath = this.deps.createSocketPath(); this.socketPath = socketPath; @@ -186,19 +204,35 @@ export class MediaTimingPreviewSession { } this.playing = false; - this.send(['set_property', 'pause', true]); - this.send(['seek', startTime, 'absolute+exact']); + const sequence = ++this.playSequence; + if (!this.mediaReady) { + await this.waitForPlayer((resolve) => { + this.readyListeners.add(resolve); + return () => this.readyListeners.delete(resolve); + }); + } // The option parser wants a time string; a raw JSON number is not accepted for `end`. - this.send(['set_property', 'end', endTime.toFixed(3)]); - this.send(['set_property', 'pause', false]); - // Only the seek's eof-reached=false and the later keep-open pause count for this play. + const commands = [ + ['set_property', 'pause', true], + ['set_property', 'end', endTime.toFixed(3)], + ['seek', startTime, 'absolute+exact'], + ['set_property', 'pause', false], + ]; + for (const command of commands) { + if (sequence !== this.playSequence) throw new Error('Preview playback was cancelled'); + await this.command(command); + } + if (sequence !== this.playSequence) throw new Error('Preview playback was cancelled'); + // Discard paused/EOF observations from the previous clip before arming this playback. this.eofReached = false; this.paused = false; this.playing = true; } async stop(): Promise { + this.playSequence += 1; this.playing = false; + this.rejectPendingWaits(); if (!this.socket || this.socket.destroyed) return; this.send(['set_property', 'pause', true]); } @@ -227,6 +261,23 @@ export class MediaTimingPreviewSession { } catch { continue; } + if ( + typeof message === 'object' && + message !== null && + 'request_id' in message && + typeof message.request_id === 'number' + ) { + const pending = this.pendingCommands.get(message.request_id); + if (pending) { + if ('error' in message && message.error === 'success') pending.resolve(); + else + pending.reject( + new Error( + `Preview command failed: ${'error' in message ? String(message.error) : 'unknown error'}`, + ), + ); + } + } if ( typeof message === 'object' && message !== null && @@ -241,6 +292,13 @@ export class MediaTimingPreviewSession { } private handlePropertyChange(name: unknown, data: unknown): void { + if (name === 'time-pos') { + if (typeof data !== 'number' || !Number.isFinite(data)) return; + this.mediaReady = true; + for (const listener of this.readyListeners) listener(); + if (this.playing) this.onPlaybackPosition?.(data); + return; + } if (name === 'eof-reached') this.eofReached = data === true; else if (name === 'pause') this.paused = data === true; else return; @@ -255,6 +313,8 @@ export class MediaTimingPreviewSession { private releaseResources(): void { this.cancelRetryWait(); + this.playing = false; + this.rejectPendingWaits(); try { this.send(['quit']); } catch { @@ -283,6 +343,57 @@ export class MediaTimingPreviewSession { this.socket.write(`${JSON.stringify({ command })}\n`); } + private waitForPlayer( + subscribe: (resolve: () => void, reject: (error: Error) => void) => () => void, + ): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let unsubscribe = () => {}; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + this.deps.cancelSchedule(timeout); + unsubscribe(); + this.pendingWaits.delete(fail); + if (error) reject(error); + else resolve(); + }; + const fail = (error: Error) => finish(error); + const timeout = this.deps.schedule( + () => fail(new Error('Timed out waiting for the preview player')), + PLAYER_RESPONSE_TIMEOUT_MS, + ); + this.pendingWaits.add(fail); + try { + unsubscribe = subscribe(() => finish(), fail); + if (settled) unsubscribe(); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + private command(command: Array): Promise { + if (!this.socket || this.socket.destroyed) + return Promise.reject(new Error('Preview player is not ready')); + const socket = this.socket; + const requestId = ++this.nextRequestId; + return this.waitForPlayer((resolve, reject) => { + this.pendingCommands.set(requestId, { resolve, reject }); + try { + socket.write(`${JSON.stringify({ command, request_id: requestId })}\n`); + } catch (error) { + this.pendingCommands.delete(requestId); + throw error; + } + return () => this.pendingCommands.delete(requestId); + }); + } + + private rejectPendingWaits(): void { + for (const reject of this.pendingWaits) reject(new Error('Preview player is closed')); + } + private async connectWithRetry(socketPath: string): Promise { const deadline = this.deps.now() + CONNECT_TIMEOUT_MS; while (!this.disposed && this.deps.now() < deadline) { @@ -305,9 +416,13 @@ export class MediaTimingPreviewSession { socket.on('data', (chunk: Buffer | string) => { if (this.socket === socket) this.handleSocketData(chunk); }); - socket.once('close', () => this.finishPlayback()); + socket.once('close', () => { + this.rejectPendingWaits(); + this.finishPlayback(); + }); this.send(['observe_property', EOF_OBSERVER_ID, 'eof-reached']); this.send(['observe_property', PAUSE_OBSERVER_ID, 'pause']); + this.send(['observe_property', POSITION_OBSERVER_ID, 'time-pos']); return; } catch { if (this.disposed) { diff --git a/src/main.ts b/src/main.ts index bafbdd0e..b66852f5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3097,6 +3097,13 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({ } } }, + onPreviewPosition: (position) => { + for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) { + if (window && !window.isDestroyed()) { + window.webContents.send(IPC_CHANNELS.event.mediaTimingReviewPreviewPosition, position); + } + } + }, showStatus: (message) => overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }), }); @@ -3154,9 +3161,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 { openOverlayHostedModalWithOsd( - openSessionHelpModalRuntime, + (deps) => openSessionHelpModalRuntime({ ...deps, isMediaPlaybackActive }), 'Session help overlay unavailable.', 'Failed to open session help overlay.', ); diff --git a/src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts b/src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts index 7c01eacc..71f29e9f 100644 --- a/src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts +++ b/src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts @@ -1,11 +1,72 @@ import assert from 'node:assert/strict'; -import test from 'node:test'; +import test, { beforeEach, afterEach } from 'node:test'; import { clearLinuxMpvFullscreenOverlayRefreshTimeouts, updateLinuxMpvFullscreenOverlayRefreshBurst, scheduleLinuxVisibleOverlayFullscreenRefreshBurst, } from './linux-mpv-fullscreen-overlay-refresh'; +const compositorEnvKeys = [ + 'HYPRLAND_INSTANCE_SIGNATURE', + 'SWAYSOCK', + 'XDG_CURRENT_DESKTOP', + 'XDG_SESSION_DESKTOP', +] as const; +const originalCompositorEnv = compositorEnvKeys.map((key) => [key, process.env[key]] as const); +beforeEach(() => { + for (const key of compositorEnvKeys) delete process.env[key]; +}); +afterEach(() => { + for (const [key, value] of originalCompositorEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +for (const { compositorKey, expectedRefreshCalls } of [ + { + compositorKey: 'HYPRLAND_INSTANCE_SIGNATURE', + expectedRefreshCalls: ['mode', 'visibility', 'mouse', 'restack'], + }, + { + compositorKey: 'SWAYSOCK', + expectedRefreshCalls: ['mode', 'visibility', 'hide', 'showInactive', 'mouse', 'restack'], + }, +]) { + test(`${compositorKey} fullscreen refresh uses compositor-specific restacking`, async () => { + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }); + process.env[compositorKey] = 'fullscreen-refresh-test'; + const calls: string[] = []; + try { + scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, { + overlayManager: { + getMainWindow: () => ({ + hide: () => calls.push('hide'), + showInactive: () => calls.push('showInactive'), + isDestroyed: () => false, + isVisible: () => true, + setIgnoreMouseEvents: () => calls.push('mouse'), + }), + getVisibleOverlayVisible: () => true, + }, + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => calls.push('visibility'), + }, + syncVisibleOverlayMpvFullscreenMode: () => calls.push('mode'), + ensureOverlayWindowLevel: () => calls.push('restack'), + }); + await new Promise((resolve) => setTimeout(resolve, 700)); + assert.deepEqual(calls, Array.from({ length: 5 }, () => expectedRefreshCalls).flat()); + } finally { + clearLinuxMpvFullscreenOverlayRefreshTimeouts(); + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor); + } + } + }); +} + test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work on linux', async () => { const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); Object.defineProperty(process, 'platform', { diff --git a/src/main/runtime/linux-mpv-fullscreen-overlay-refresh.ts b/src/main/runtime/linux-mpv-fullscreen-overlay-refresh.ts index 213e6883..9340cf4a 100644 --- a/src/main/runtime/linux-mpv-fullscreen-overlay-refresh.ts +++ b/src/main/runtime/linux-mpv-fullscreen-overlay-refresh.ts @@ -52,8 +52,12 @@ function refreshLinuxVisibleOverlayAfterMpvFullscreenChange( return; } - mainWindow.hide(); - mainWindow.showInactive(); + // Hyprland placement can restack in place. Remapping the overlay can + // take focus from mpv and make Hyprland cancel the fullscreen transition. + if (!process.env.HYPRLAND_INSTANCE_SIGNATURE) { + mainWindow.hide(); + mainWindow.showInactive(); + } if (deps.getOverlayInteractionActive?.() === true) { mainWindow.setIgnoreMouseEvents(false); } else { diff --git a/src/main/runtime/media-timing-review.test.ts b/src/main/runtime/media-timing-review.test.ts index d557a01d..8099756f 100644 --- a/src/main/runtime/media-timing-review.test.ts +++ b/src/main/runtime/media-timing-review.test.ts @@ -413,14 +413,17 @@ test('media timing review downloads one window of a remote stream for the wavefo endTime: 14.5, }, ]); - assert.deepEqual(previewStarts, [ - { - mediaPath: '/tmp/window-7.5-14.5.mkv', - executablePath: 'mpv', - volume: 60, - absoluteTimestamps: true, - }, - ]); + assert.deepEqual( + previewStarts.map(({ onPlaybackPosition, ...options }) => options), + [ + { + mediaPath: '/tmp/window-7.5-14.5.mkv', + executablePath: 'mpv', + volume: 60, + absoluteTimestamps: true, + }, + ], + ); assert.deepEqual(previewPlays, [['/tmp/window-7.5-14.5.mkv', 9.5, 12.5]]); assert.deepEqual(disposed, ['/tmp/window-7.5-14.5.mkv']); }); @@ -515,9 +518,10 @@ test('media timing review falls back to the remote stream after one failed windo audioStreamIndex: 2, }, ]); - assert.deepEqual(previewStarts, [ - { mediaPath: REMOTE_STREAM_URL, executablePath: 'mpv', volume: 60, audioTrackId: 3 }, - ]); + assert.deepEqual( + previewStarts.map(({ onPlaybackPosition, ...options }) => options), + [{ mediaPath: REMOTE_STREAM_URL, executablePath: 'mpv', volume: 60, audioTrackId: 3 }], + ); assert.deepEqual(previewPlays, [[REMOTE_STREAM_URL, 9.5, 12.5]]); }); @@ -1083,7 +1087,8 @@ test('disposing owns a preview session whose startup is still pending', async () test('media timing review forwards the hidden player finishing a preview to the modal', async () => { const endedReviewIds: string[] = []; - const playback: { ended?: () => void } = {}; + const positions: Array<{ reviewId: string; time: number }> = []; + const playback: { ended?: () => void; position?: (time: number) => void } = {}; let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void; const openedPayload = new Promise((resolve) => { publishPayload = resolve; @@ -1099,7 +1104,9 @@ test('media timing review forwards the hidden player finishing a preview to the getMpvExecutablePath: () => 'mpv', generateWaveform: async () => [], createPreviewSession: () => ({ - start: async () => undefined, + start: async (options) => { + playback.position = options.onPlaybackPosition; + }, play: async () => undefined, stop: async () => undefined, onPlaybackEnded: (listener) => { @@ -1114,6 +1121,7 @@ test('media timing review forwards the hidden player finishing a preview to the onPreviewEnded: (reviewId) => { endedReviewIds.push(reviewId); }, + onPreviewPosition: (position) => positions.push(position), showStatus: () => undefined, }); const pendingDecision = runtime.requestReview({ @@ -1133,6 +1141,9 @@ test('media timing review forwards the hidden player finishing a preview to the }, ); assert.ok(playback.ended); + assert.ok(playback.position); + playback.position(11); + assert.deepEqual(positions, [{ reviewId: payload.reviewId, time: 11 }]); playback.ended(); assert.deepEqual(endedReviewIds, [payload.reviewId]); @@ -1140,6 +1151,8 @@ test('media timing review forwards the hidden player finishing a preview to the await pendingDecision; playback.ended(); assert.deepEqual(endedReviewIds, [payload.reviewId]); + playback.position(12); + assert.deepEqual(positions, [{ reviewId: payload.reviewId, time: 11 }]); }); test('preview reports a stale review when the review ends during playback', async () => { diff --git a/src/main/runtime/media-timing-review.ts b/src/main/runtime/media-timing-review.ts index b585c817..0e6b1715 100644 --- a/src/main/runtime/media-timing-review.ts +++ b/src/main/runtime/media-timing-review.ts @@ -5,6 +5,7 @@ import type { MediaTimingReviewDecision, MediaTimingReviewOpenPayload, MediaTimingReviewPreviewRequest, + MediaTimingReviewPreviewPosition, MediaTimingReviewRequest, MediaTimingReviewResolveRequest, MediaTimingReviewFrameRequest, @@ -13,6 +14,7 @@ import type { MediaTimingReviewWaveformResult, } from '../../types/anki'; import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform'; +import type { MediaTimingPreviewStartOptions } from '../../core/services/media-timing-preview'; import type { MediaTimingFrameOptions } from '../../core/services/media-timing-frame'; import { isRemoteMediaWindowSourcePath, @@ -36,13 +38,7 @@ interface ReviewMpvClient { } interface PreviewSession { - start(options: { - mediaPath: string; - executablePath?: string; - audioTrackId?: number; - volume?: number; - absoluteTimestamps?: boolean; - }): Promise; + start(options: MediaTimingPreviewStartOptions): Promise; play(startTime: number, endTime: number): Promise; stop(): Promise; /** Fires when the player reaches the end of the clip started by play(). */ @@ -113,6 +109,7 @@ export interface MediaTimingReviewRuntimeDeps { openModal: (payload: MediaTimingReviewOpenPayload, signal: AbortSignal) => Promise; /** Tells the modal that the hidden player finished the previewed clip. */ onPreviewEnded?: (reviewId: string) => void; + onPreviewPosition?: (position: MediaTimingReviewPreviewPosition) => void; showStatus: (message: string) => void; } @@ -388,6 +385,11 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep } await session.start({ mediaPath, + onPlaybackPosition: (time) => { + if (active === review && review.preview?.session === started) { + deps.onPreviewPosition?.({ reviewId: review.payload.reviewId, time }); + } + }, ...previewOptions, // A cached window keeps one audio stream, so mpv's track id from the source no longer applies. ...(window diff --git a/src/main/runtime/session-help-open.test.ts b/src/main/runtime/session-help-open.test.ts new file mode 100644 index 00000000..ab84021a --- /dev/null +++ b/src/main/runtime/session-help-open.test.ts @@ -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 } }, + ]); + } +}); diff --git a/src/main/runtime/session-help-open.ts b/src/main/runtime/session-help-open.ts index 86b7b59f..c2c26806 100644 --- a/src/main/runtime/session-help-open.ts +++ b/src/main/runtime/session-help-open.ts @@ -1,5 +1,6 @@ import type { OverlayHostedModal } 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'; const SESSION_HELP_MODAL: OverlayHostedModal = 'session-help'; @@ -18,6 +19,8 @@ export async function openSessionHelpModal(deps: { ) => boolean; waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; logWarn: (message: string) => void; + /** True when mpv has a video loaded; gates running commands from help rows. */ + isMediaPlaybackActive: () => boolean; }): Promise { return await retryOverlayModalOpen( { @@ -29,8 +32,11 @@ export async function openSessionHelpModal(deps: { timeoutMs: SESSION_HELP_OPEN_TIMEOUT_MS, retryWarning: 'Session help modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', - sendOpen: () => - openOverlayHostedModal( + sendOpen: () => { + const payload: SessionHelpOpenPayload = { + commandsEnabled: deps.isMediaPlaybackActive(), + }; + return openOverlayHostedModal( { ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, ensureOverlayWindowsReadyForVisibilityActions: @@ -40,9 +46,11 @@ export async function openSessionHelpModal(deps: { { channel: IPC_CHANNELS.event.sessionHelpOpen, modal: SESSION_HELP_MODAL, + payload, preferModalWindow: true, }, - ), + ); + }, }, ); } diff --git a/src/preload.ts b/src/preload.ts index 8ea3ccf4..0b3e1474 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -99,6 +99,7 @@ import type { ControllerConfigUpdate, ControllerPreferenceUpdate, ResolvedControllerConfig, + SessionHelpOpenPayload, SessionNumericSelectionStartPayload, SubtitleMiningContext, YoutubePickerOpenPayload, @@ -109,6 +110,7 @@ import type { ChangelogSnapshot, MediaTimingReviewActionResult, MediaTimingReviewOpenPayload, + MediaTimingReviewPreviewPosition, MediaTimingReviewPreviewRequest, MediaTimingReviewResolveRequest, MediaTimingReviewFrameRequest, @@ -214,7 +216,15 @@ function createLatestValueIpcListenerWithPayload( } const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen); -const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen); +const onOpenSessionHelpEvent = createQueuedIpcListenerWithPayload( + IPC_CHANNELS.event.sessionHelpOpen, + (payload) => ({ + commandsEnabled: + typeof payload === 'object' && + payload !== null && + (payload as Partial).commandsEnabled === true, + }), +); const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen); const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener( IPC_CHANNELS.event.characterDictionaryManagerOpen, @@ -549,6 +559,26 @@ const electronAPI: ElectronAPI = { onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent, onOpenMediaTimingReview: onOpenMediaTimingReviewEvent, onMediaTimingReviewPreviewEnded: onMediaTimingReviewPreviewEndedEvent, + onMediaTimingReviewPreviewPosition: ( + callback: (position: MediaTimingReviewPreviewPosition) => void, + ): void => { + ipcRenderer.on( + IPC_CHANNELS.event.mediaTimingReviewPreviewPosition, + (_event, payload: unknown) => { + if ( + typeof payload !== 'object' || + payload === null || + !('reviewId' in payload) || + typeof payload.reviewId !== 'string' || + !('time' in payload) || + typeof payload.time !== 'number' || + !Number.isFinite(payload.time) + ) + return; + callback({ reviewId: payload.reviewId, time: payload.time }); + }, + ); + }, previewMediaTimingReview: ( request: MediaTimingReviewPreviewRequest, ): Promise => diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 1e3f8a03..1324e3e4 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -1,6 +1,7 @@ import type { CompiledSessionBinding, PrimarySubMode, ShortcutsConfig } from '../../types'; import type { RendererContext } from '../context'; import { createMpvInputForwarding } from './mpv-input-forwarding'; +import { dispatchConfiguredMpvCommand } from '../utils/mpv-command-dispatch'; import { registerDictionaryPopupVisibilityListener, YOMITAN_POPUP_COMMAND_EVENT, @@ -259,7 +260,10 @@ export function createKeyboardHandlers( } if (binding.actionType === 'mpv-command') { - dispatchConfiguredMpvCommand(binding.command); + dispatchConfiguredMpvCommand(binding.command, { + getPlaybackPaused: options.getPlaybackPaused, + sendMpvCommand: (command) => window.electronAPI.sendMpvCommand(command), + }); return; } @@ -568,31 +572,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 = { shiftKey?: boolean; ctrlKey?: boolean; diff --git a/src/renderer/modals/media-timing-review.ts b/src/renderer/modals/media-timing-review.ts index 68bcaf8a..6526c236 100644 --- a/src/renderer/modals/media-timing-review.ts +++ b/src/renderer/modals/media-timing-review.ts @@ -3,6 +3,7 @@ import type { MediaTimingReviewContextLine, MediaTimingReviewDecision, MediaTimingReviewOpenPayload, + MediaTimingReviewPreviewPosition, } from '../../types/anki'; import type { ModalStateReader, RendererContext } from '../context'; import { createModalFocusGuard } from './modal-focus-guard'; @@ -20,8 +21,8 @@ const LINE_REVEAL_MARGIN_SECONDS = 1; const SPEECH_LEVEL_THRESHOLD = 0.3; const SPEECH_TAIL_SECONDS = 0.15; const MINIMUM_TRAILING_TRIM_SECONDS = 0.1; -/** Slack past the clip length before the UI gives up waiting for mpv's end-of-clip signal. */ -const PREVIEW_END_GRACE_MS = 2_500; +/** Allow buffering and output-device delays without cutting off an advancing preview. */ +const PREVIEW_STALL_TIMEOUT_MS = 15_000; function clamp(value: number, minimum: number, maximum: number): number { return Math.min(maximum, Math.max(minimum, value)); @@ -303,30 +304,49 @@ export function createMediaTimingReviewModal( previewTimer = null; } - /** - * Drives the play button label plus the playhead sweep that mirrors the hidden audio player. - * mpv reports when the clip actually finishes (see handlePreviewEnded), which accounts for - * output latency such as Bluetooth headphones; the timer only covers a player that never does. - */ - function setPreviewPlaying(playing: boolean): void { + function armPreviewTimeout(): void { + clearPreviewTimer(); + previewTimer = setTimeout(() => { + stopPreview(); + setStatus('Audio preview stopped responding. Try playing the selection again.', true); + }, PREVIEW_STALL_TIMEOUT_MS); + } + + /** Playback position comes from mpv, so stalls and output latency cannot outrun the cursor. */ + function setPreviewPlaying(playing: boolean, finished = false): void { previewPlaying = playing; ctx.dom.mediaTimingReviewPlayLabel.textContent = playing ? 'Stop preview' : 'Play selection'; ctx.dom.mediaTimingReviewPlay.classList.toggle('is-playing', playing); clearPreviewTimer(); const track = ctx.dom.mediaTimingReviewSelectionTrack; - track.classList.remove('is-previewing'); + track.classList.toggle('is-previewing', playing || finished); + if (finished) track.style.setProperty('--playhead-position', 'var(--selection-end)'); if (!playing) return; - const clipSeconds = Math.max(MINIMUM_CLIP_SECONDS, selectionEnd - selectionStart); - track.style.setProperty('--playhead-duration', `${clipSeconds}s`); - void track.offsetWidth; - track.classList.add('is-previewing'); - previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000 + PREVIEW_END_GRACE_MS); + track.style.setProperty('--playhead-position', 'var(--selection-start)'); + armPreviewTimeout(); + } + + function handlePreviewPosition(position: MediaTimingReviewPreviewPosition): void { + if ( + !payload || + payload.reviewId !== position.reviewId || + !previewPlaying || + !Number.isFinite(position.time) + ) + return; + const time = clamp(position.time, selectionStart, selectionEnd); + const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart); + ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty( + '--playhead-position', + `${clamp(((time - timelineStart) / span) * 100, 0, 100)}%`, + ); + armPreviewTimeout(); } /** The hidden player reached the end of the clip and paused itself. */ function handlePreviewEnded(reviewId: string): void { if (!payload || payload.reviewId !== reviewId || !previewPlaying) return; - setPreviewPlaying(false); + setPreviewPlaying(false, true); setStatus(''); } @@ -1026,6 +1046,7 @@ export function createMediaTimingReviewModal( return { openMediaTimingReviewModal, handlePreviewEnded, + handlePreviewPosition, requestCancel, handleMediaTimingReviewKeydown, wireDomEvents, diff --git a/src/renderer/modals/session-help-commands.test.ts b/src/renderer/modals/session-help-commands.test.ts new file mode 100644 index 00000000..3c2c9536 --- /dev/null +++ b/src/renderer/modals/session-help-commands.test.ts @@ -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 = {}; + style: Record = {}; + textContent = ''; + value = ''; + tabIndex = 0; + type = ''; + private classes = new Set(); + private listeners = new Map 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): 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) => Promise, + options: HarnessOptions = {}, +) { + return async () => { + const globals = globalThis as Record; + 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), + }); + + 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 { + 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); + }), +); diff --git a/src/renderer/modals/session-help-render.ts b/src/renderer/modals/session-help-render.ts index ea61a843..da2b9aa9 100644 --- a/src/renderer/modals/session-help-render.ts +++ b/src/renderer/modals/session-help-render.ts @@ -1,6 +1,10 @@ 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'); button.type = 'button'; button.className = 'session-help-item'; @@ -28,6 +32,14 @@ function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButto 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(right); return button; @@ -54,6 +66,7 @@ export function createSessionHelpSectionNode( section: SessionHelpSection, sectionIndex: number, globalIndexMap: number[], + commandsEnabled: boolean, ): HTMLElement { const sectionNode = document.createElement('section'); sectionNode.className = 'session-help-section'; @@ -69,7 +82,7 @@ export function createSessionHelpSectionNode( section.rows.forEach((row, rowIndex) => { const globalIndex = (globalIndexMap[sectionIndex] ?? 0) + rowIndex; - const button = createShortcutRow(row, globalIndex); + const button = createShortcutRow(row, globalIndex, commandsEnabled); list.appendChild(button); }); diff --git a/src/renderer/modals/session-help-sections.ts b/src/renderer/modals/session-help-sections.ts index 6993eab0..23266186 100644 --- a/src/renderer/modals/session-help-sections.ts +++ b/src/renderer/modals/session-help-sections.ts @@ -1,4 +1,6 @@ import type { + CompiledMpvCommandBinding, + CompiledSessionActionBinding, CompiledSessionBinding, SessionActionId, SessionKeyModifier, @@ -7,10 +9,17 @@ import type { import { SPECIAL_COMMANDS } from '../../config/definitions/shared'; 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 + | Pick; + export type SessionHelpItem = { shortcut: string; action: string; color?: string; + /** Set when the help modal can run this row during playback. */ + command?: SessionHelpCommand; }; 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 = 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( sessionBindings: CompiledSessionBinding[], ): SessionHelpSection[] { @@ -294,12 +318,14 @@ function buildSessionBindingSections( for (const binding of sessionBindings) { const section = sectionForSessionBinding(binding); + const command = toSessionHelpCommand(binding); const row: SessionHelpItem = { shortcut: formatSessionKeySpec(binding.key), action: binding.actionType === 'mpv-command' ? describeCommand(binding.command) : describeSessionAction(binding.actionId, binding.payload), + ...(command ? { command } : {}), }; grouped.set(section, [...(grouped.get(section) ?? []), row]); } @@ -337,6 +363,7 @@ function buildConfiguredOverlaySections(input: { statsRows.push({ shortcut: formatKeybinding(input.markWatchedKey), action: 'Mark video watched', + command: { actionType: 'session-action', actionId: 'markWatched' }, }); } @@ -345,6 +372,7 @@ function buildConfiguredOverlaySections(input: { overlayRows.push({ shortcut: formatKeybinding(input.subtitleSidebarToggleKey), action: 'Toggle subtitle sidebar', + command: { actionType: 'session-action', actionId: 'toggleSubtitleSidebar' }, }); } diff --git a/src/renderer/modals/session-help.ts b/src/renderer/modals/session-help.ts index b66fba6d..c7f08f3c 100644 --- a/src/renderer/modals/session-help.ts +++ b/src/renderer/modals/session-help.ts @@ -1,13 +1,16 @@ import type { ModalStateReader, RendererContext } from '../context'; +import type { SessionHelpOpenPayload } from '../../types/runtime'; import type { RuntimeOptionId, RuntimeOptionState } from '../../types/runtime-options'; import { buildSessionHelpSections, + type SessionHelpItem, type SessionHelpSection, type SessionHelpTabId, } from './session-help-sections'; import { createSessionHelpSectionNode } from './session-help-render'; import { buildVisibleSessionHelpSections, createSessionHelpTabBar } from './session-help-tabs'; import { createModalFocusGuard } from './modal-focus-guard'; +import { dispatchConfiguredMpvCommand } from '../utils/mpv-command-dispatch'; export { buildSessionHelpSections, @@ -70,6 +73,9 @@ export function createSessionHelpModal( let helpFilterValue = ''; let helpSections: SessionHelpSection[] = []; 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[] { return Array.from( @@ -77,7 +83,8 @@ export function createSessionHelpModal( ) 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(); if (items.length === 0) return; @@ -91,6 +98,10 @@ export function createSessionHelpModal( }); const activeItem = items[next]; if (!activeItem) return; + if (fromPointer) { + if (!isFilterInputFocused()) activeItem.focus({ preventScroll: true }); + return; + } activeItem.focus({ preventScroll: true }); activeItem.scrollIntoView({ block: 'nearest', @@ -117,6 +128,7 @@ export function createSessionHelpModal( function applyFilterAndRender(): void { const sections = buildVisibleSessionHelpSections(helpSections, activeTabId, helpFilterValue); + visibleRows = sections.flatMap((section) => section.rows); const indexOffsets: number[] = []; let running = 0; for (const section of sections) { @@ -134,7 +146,12 @@ export function createSessionHelpModal( ); } sections.forEach((section, sectionIndex) => { - const sectionNode = createSessionHelpSectionNode(section, sectionIndex, indexOffsets); + const sectionNode = createSessionHelpSectionNode( + section, + sectionIndex, + indexOffsets, + commandsEnabled, + ); ctx.dom.sessionHelpContent.appendChild(sectionNode); }); @@ -156,6 +173,7 @@ export function createSessionHelpModal( function showRenderError(message: string): void { helpSections = []; + visibleRows = []; helpFilterValue = ''; activeTabId = 'essentials'; 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; + commandsEnabled = payload.commandsEnabled; priorFocus = document.activeElement; ctx.state.sessionHelpModalOpen = true; helpSections = []; + visibleRows = []; helpFilterValue = ''; options.syncSettingsModalSubtitleSuppression(); ctx.dom.overlay.classList.add('interactive'); @@ -238,8 +261,9 @@ export function createSessionHelpModal( void render().then((dataLoaded) => { if (!ctx.state.sessionHelpModalOpen) return; if (dataLoaded) { - ctx.dom.sessionHelpStatus.textContent = - 'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.'; + ctx.dom.sessionHelpStatus.textContent = commandsEnabled + ? '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 { ctx.dom.sessionHelpStatus.textContent = 'Session help data is unavailable right now. Press Esc to close.'; @@ -284,10 +308,46 @@ export function createSessionHelpModal( 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 { 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') { e.preventDefault(); if (!helpFilterValue) { @@ -333,6 +393,13 @@ export function createSessionHelpModal( 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; } @@ -350,13 +417,22 @@ export function createSessionHelpModal( }); ctx.dom.sessionHelpContent.addEventListener('click', (event: MouseEvent) => { - const target = event.target; - if (!(target instanceof Element)) return; - const row = target.closest('.session-help-item') as HTMLElement | null; - if (!row) return; - const index = Number.parseInt(row.dataset.sessionHelpIndex ?? '', 10); - if (!Number.isFinite(index)) return; + const index = getRowIndex(event.target); + if (index !== null) setSelected(index); + }); + + // Hovering a row makes it the Enter target. + 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); + runSelectedCommand(); }); ctx.dom.sessionHelpClose.addEventListener('click', () => { diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 5932c686..c52e593a 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -316,7 +316,13 @@ const keyboardHandlers = createKeyboardHandlers(ctx, { handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown, handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown, 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: () => { if (controllerSelectModal.openControllerSelectModal()) { window.electronAPI.notifyOverlayModalOpened('controller-select'); @@ -572,9 +578,9 @@ function registerModalOpenHandlers(): void { await characterDictionaryModal.openCharacterDictionaryManagerModal(); }); }); - window.electronAPI.onOpenSessionHelp(() => { + window.electronAPI.onOpenSessionHelp((payload) => { runGuarded('session-help:open', () => { - sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo()); + sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo(), payload); window.electronAPI.notifyOverlayModalOpened('session-help'); }); }); @@ -625,6 +631,9 @@ function registerModalOpenHandlers(): void { mediaTimingReviewModal.handlePreviewEnded(reviewId); }); }); + window.electronAPI.onMediaTimingReviewPreviewPosition((position) => { + mediaTimingReviewModal.handlePreviewPosition(position); + }); window.electronAPI.onOpenPlaylistBrowser(() => { runGuardedAsync('playlist-browser:open', async () => { await playlistBrowserModal.openPlaylistBrowserModal(); diff --git a/src/renderer/style.css b/src/renderer/style.css index 57631067..ae6cb15a 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -1687,7 +1687,7 @@ body:focus-visible, --selection-end: 80%; --original-start: 25%; --original-end: 75%; - --playhead-duration: 1s; + --playhead-position: var(--selection-start); position: relative; height: 52px; @@ -1803,10 +1803,10 @@ body:focus-visible, .media-timing-review-playhead { position: absolute; - z-index: 5; + z-index: 7; top: 0; bottom: 0; - left: var(--selection-start); + left: clamp(0px, var(--playhead-position), calc(100% - 2px)); width: 2px; background: var(--ctp-yellow); box-shadow: 0 0 12px color-mix(in srgb, var(--ctp-yellow) 75%, transparent); @@ -1816,16 +1816,6 @@ body:focus-visible, .media-timing-review-track.is-previewing .media-timing-review-playhead { opacity: 1; - animation: media-timing-review-playhead var(--playhead-duration) linear forwards; -} - -@keyframes media-timing-review-playhead { - from { - left: var(--selection-start); - } - to { - left: var(--selection-end); - } } /* A thin line centered on the clip edge, so the line itself is the trim boundary. */ @@ -2128,8 +2118,7 @@ body:focus-visible, animation: none; } - .media-timing-review-track.is-loading::after, - .media-timing-review-track.is-previewing .media-timing-review-playhead { + .media-timing-review-track.is-loading::after { animation: none; } } @@ -4136,6 +4125,21 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent { 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 { width: 10px; height: 10px; diff --git a/src/renderer/utils/mpv-command-dispatch.test.ts b/src/renderer/utils/mpv-command-dispatch.test.ts new file mode 100644 index 00000000..75b45288 --- /dev/null +++ b/src/renderer/utils/mpv-command-dispatch.test.ts @@ -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'], + ]); +}); diff --git a/src/renderer/utils/mpv-command-dispatch.ts b/src/renderer/utils/mpv-command-dispatch.ts new file mode 100644 index 00000000..b6f61228 --- /dev/null +++ b/src/renderer/utils/mpv-command-dispatch.ts @@ -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; + 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)); +} diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 9d35a1f9..b1a929a1 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -167,6 +167,7 @@ export const IPC_CHANNELS = { youtubePickerOpen: 'youtube:picker-open', mediaTimingReviewOpen: 'media-timing-review:open', mediaTimingReviewPreviewEnded: 'media-timing-review:preview-ended', + mediaTimingReviewPreviewPosition: 'media-timing-review:preview-position', youtubePickerCancel: 'youtube:picker-cancel', playlistBrowserOpen: 'playlist-browser:open', sessionNumericSelectionStart: 'session:numeric-selection-start', diff --git a/src/types/anki.ts b/src/types/anki.ts index 1ddcc732..20c1484d 100644 --- a/src/types/anki.ts +++ b/src/types/anki.ts @@ -82,6 +82,11 @@ export interface MediaTimingReviewPreviewRequest { endTime: number; } +export interface MediaTimingReviewPreviewPosition { + reviewId: string; + time: number; +} + export interface MediaTimingReviewWaveformRequest { reviewId: string; startTime: number; diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 8ff84287..ae886205 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -6,6 +6,7 @@ import type { KikuMergePreviewResponse, MediaTimingReviewActionResult, MediaTimingReviewOpenPayload, + MediaTimingReviewPreviewPosition, MediaTimingReviewPreviewRequest, MediaTimingReviewResolveRequest, MediaTimingReviewFrameRequest, @@ -433,6 +434,11 @@ export type CharacterDictionaryManagerMutationResult = | (CharacterDictionaryManagerSnapshot & { ok: true; rebuildRequired?: boolean }) | { 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 { actionId: Extract; timeoutMs: number; @@ -546,7 +552,7 @@ export interface ElectronAPI { cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => Promise; onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void; onOpenRuntimeOptions: (callback: () => void) => void; - onOpenSessionHelp: (callback: () => void) => void; + onOpenSessionHelp: (callback: (payload: SessionHelpOpenPayload) => void) => void; onOpenChangelog: (callback: () => void) => void; getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise; onOpenControllerSelect: (callback: () => void) => void; @@ -556,6 +562,9 @@ export interface ElectronAPI { onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void; onOpenMediaTimingReview: (callback: (payload: MediaTimingReviewOpenPayload) => void) => void; onMediaTimingReviewPreviewEnded: (callback: (reviewId: string) => void) => void; + onMediaTimingReviewPreviewPosition: ( + callback: (position: MediaTimingReviewPreviewPosition) => void, + ) => void; previewMediaTimingReview: ( request: MediaTimingReviewPreviewRequest, ) => Promise;