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

This commit is contained in:
2026-09-29 18:49:49 -07:00
32 changed files with 1122 additions and 117 deletions
+5 -5
View File
@@ -21,13 +21,13 @@
"@electron/asar": "3.4.1", "@electron/asar": "3.4.1",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"electron": "42.6.0", "electron": "42.10.0",
"electron-builder": "26.16.1", "electron-builder": "26.16.1",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"eslint": "^10.8.0", "eslint": "^10.8.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"undici": "7.29.0", "undici": "7.29.1",
}, },
}, },
}, },
@@ -48,7 +48,7 @@
"picomatch": "4.0.4", "picomatch": "4.0.4",
"tar": "7.5.21", "tar": "7.5.21",
"tmp": "0.2.7", "tmp": "0.2.7",
"undici": "7.29.0", "undici": "7.29.1",
}, },
"packages": { "packages": {
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], "@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=="], "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=="], "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=="], "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=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+2
View File
@@ -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. 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 | | Action | How |
| ------------------------ | --------------------------------------------------------------------- | | ------------------------ | --------------------------------------------------------------------- |
| Trim | Drag either edge, or click the waveform to move the nearer edge there | | Trim | Drag either edge, or click the waveform to move the nearer edge there |
+2
View File
@@ -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).
+2
View File
@@ -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. 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/). 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 ### KDE Plasma and other Wayland compositors
+1
View File
@@ -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. 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/` - 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` - 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/` - 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. 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. 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.
+3 -3
View File
@@ -99,7 +99,7 @@
"picomatch": "4.0.4", "picomatch": "4.0.4",
"tar": "7.5.21", "tar": "7.5.21",
"tmp": "0.2.7", "tmp": "0.2.7",
"undici": "7.29.0" "undici": "7.29.1"
}, },
"keywords": [ "keywords": [
"anki", "anki",
@@ -130,13 +130,13 @@
"@electron/asar": "3.4.1", "@electron/asar": "3.4.1",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"electron": "42.6.0", "electron": "42.10.0",
"electron-builder": "26.16.1", "electron-builder": "26.16.1",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"eslint": "^10.8.0", "eslint": "^10.8.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"undici": "7.29.0" "undici": "7.29.1"
}, },
"build": { "build": {
"appId": "com.sudacode.SubMiner", "appId": "com.sudacode.SubMiner",
+95 -1
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { ChildProcess } from 'node:child_process';
import net from 'node:net'; import net from 'node:net';
import { describe, test } from 'node:test'; import { describe, test } from 'node:test';
import { buildMediaTimingPreviewArgs, MediaTimingPreviewSession } from './media-timing-preview'; import { buildMediaTimingPreviewArgs, MediaTimingPreviewSession } from './media-timing-preview';
@@ -217,6 +218,15 @@ function createFakeSocket() {
socket.destroyed = false; socket.destroyed = false;
socket.write = (data) => { socket.write = (data) => {
writes.push(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; return true;
}; };
socket.end = () => undefined; socket.end = () => undefined;
@@ -226,6 +236,85 @@ function createFakeSocket() {
return { socket, writes }; 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 () => { test('preview session plays once to the clip end and reports when mpv has drained it', async () => {
const { socket, writes } = createFakeSocket(); const { socket, writes } = createFakeSocket();
const child = new EventEmitter() as EventEmitter & { kill: () => boolean }; 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', 1, 'eof-reached'],
['observe_property', 2, 'pause'], ['observe_property', 2, 'pause'],
['observe_property', 3, 'time-pos'],
], ],
); );
// The observers' initial replies describe the idle paused player, not a finished preview. // The observers' initial replies describe the idle paused player, not a finished preview.
socket.emit('data', property('eof-reached', false) + property('pause', true)); socket.emit('data', property('eof-reached', false) + property('pause', true));
assert.equal(endedCount, 0); assert.equal(endedCount, 0);
socket.emit(
'data',
`${JSON.stringify({ event: 'property-change', name: 'time-pos', data: 0 })}\n`,
);
writes.length = 0; writes.length = 0;
await session.play(12.25, 14.5); 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), writes.map((line) => JSON.parse(line).command),
[ [
['set_property', 'pause', true], ['set_property', 'pause', true],
['seek', 12.25, 'absolute+exact'],
['set_property', 'end', '14.500'], ['set_property', 'end', '14.500'],
['seek', 12.25, 'absolute+exact'],
['set_property', 'pause', false], ['set_property', 'pause', false],
], ],
); );
+121 -6
View File
@@ -15,6 +15,8 @@ const CONNECT_RETRY_MS = 40;
*/ */
const EOF_OBSERVER_ID = 1; const EOF_OBSERVER_ID = 1;
const PAUSE_OBSERVER_ID = 2; const PAUSE_OBSERVER_ID = 2;
const POSITION_OBSERVER_ID = 3;
const PLAYER_RESPONSE_TIMEOUT_MS = 15_000;
export interface MediaTimingPreviewStartOptions { export interface MediaTimingPreviewStartOptions {
mediaPath: string; mediaPath: string;
@@ -23,6 +25,7 @@ export interface MediaTimingPreviewStartOptions {
volume?: number; volume?: number;
/** The file keeps source timestamps (a cached remote window); seek with the original times. */ /** The file keeps source timestamps (a cached remote window); seek with the original times. */
absoluteTimestamps?: boolean; absoluteTimestamps?: boolean;
onPlaybackPosition?: (time: number) => void;
} }
type PreviewProcess = Pick<ChildProcess, 'kill' | 'once'>; type PreviewProcess = Pick<ChildProcess, 'kill' | 'once'>;
@@ -103,8 +106,21 @@ export class MediaTimingPreviewSession {
private disposed = false; private disposed = false;
private readBuffer = ''; private readBuffer = '';
private playing = false; private playing = false;
private playSequence = 0;
private eofReached = false; private eofReached = false;
private paused = true; 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>(); private readonly endedListeners = new Set<() => void>();
constructor(deps: Partial<MediaTimingPreviewDeps> = {}) { constructor(deps: Partial<MediaTimingPreviewDeps> = {}) {
@@ -141,6 +157,8 @@ export class MediaTimingPreviewSession {
private async startOnce(options: MediaTimingPreviewStartOptions): Promise<void> { private async startOnce(options: MediaTimingPreviewStartOptions): Promise<void> {
const mediaPath = options.mediaPath.trim(); const mediaPath = options.mediaPath.trim();
if (!mediaPath) throw new Error('No media source is available for preview'); if (!mediaPath) throw new Error('No media source is available for preview');
this.mediaReady = false;
this.onPlaybackPosition = options.onPlaybackPosition;
const socketPath = this.deps.createSocketPath(); const socketPath = this.deps.createSocketPath();
this.socketPath = socketPath; this.socketPath = socketPath;
@@ -186,19 +204,35 @@ export class MediaTimingPreviewSession {
} }
this.playing = false; this.playing = false;
this.send(['set_property', 'pause', true]); const sequence = ++this.playSequence;
this.send(['seek', startTime, 'absolute+exact']); 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`. // The option parser wants a time string; a raw JSON number is not accepted for `end`.
this.send(['set_property', 'end', endTime.toFixed(3)]); const commands = [
this.send(['set_property', 'pause', false]); ['set_property', 'pause', true],
// Only the seek's eof-reached=false and the later keep-open pause count for this play. ['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.eofReached = false;
this.paused = false; this.paused = false;
this.playing = true; this.playing = true;
} }
async stop(): Promise<void> { async stop(): Promise<void> {
this.playSequence += 1;
this.playing = false; this.playing = false;
this.rejectPendingWaits();
if (!this.socket || this.socket.destroyed) return; if (!this.socket || this.socket.destroyed) return;
this.send(['set_property', 'pause', true]); this.send(['set_property', 'pause', true]);
} }
@@ -227,6 +261,23 @@ export class MediaTimingPreviewSession {
} catch { } catch {
continue; 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 ( if (
typeof message === 'object' && typeof message === 'object' &&
message !== null && message !== null &&
@@ -241,6 +292,13 @@ export class MediaTimingPreviewSession {
} }
private handlePropertyChange(name: unknown, data: unknown): void { 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; if (name === 'eof-reached') this.eofReached = data === true;
else if (name === 'pause') this.paused = data === true; else if (name === 'pause') this.paused = data === true;
else return; else return;
@@ -255,6 +313,8 @@ export class MediaTimingPreviewSession {
private releaseResources(): void { private releaseResources(): void {
this.cancelRetryWait(); this.cancelRetryWait();
this.playing = false;
this.rejectPendingWaits();
try { try {
this.send(['quit']); this.send(['quit']);
} catch { } catch {
@@ -283,6 +343,57 @@ export class MediaTimingPreviewSession {
this.socket.write(`${JSON.stringify({ command })}\n`); this.socket.write(`${JSON.stringify({ command })}\n`);
} }
private waitForPlayer(
subscribe: (resolve: () => void, reject: (error: Error) => void) => () => void,
): Promise<void> {
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<string | number | boolean>): Promise<void> {
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<void> { private async connectWithRetry(socketPath: string): Promise<void> {
const deadline = this.deps.now() + CONNECT_TIMEOUT_MS; const deadline = this.deps.now() + CONNECT_TIMEOUT_MS;
while (!this.disposed && this.deps.now() < deadline) { while (!this.disposed && this.deps.now() < deadline) {
@@ -305,9 +416,13 @@ export class MediaTimingPreviewSession {
socket.on('data', (chunk: Buffer | string) => { socket.on('data', (chunk: Buffer | string) => {
if (this.socket === socket) this.handleSocketData(chunk); 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', EOF_OBSERVER_ID, 'eof-reached']);
this.send(['observe_property', PAUSE_OBSERVER_ID, 'pause']); this.send(['observe_property', PAUSE_OBSERVER_ID, 'pause']);
this.send(['observe_property', POSITION_OBSERVER_ID, 'time-pos']);
return; return;
} catch { } catch {
if (this.disposed) { if (this.disposed) {
+14 -1
View File
@@ -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) => showStatus: (message) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }), 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 { 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.',
); );
@@ -1,11 +1,72 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test, { beforeEach, afterEach } from 'node:test';
import { import {
clearLinuxMpvFullscreenOverlayRefreshTimeouts, clearLinuxMpvFullscreenOverlayRefreshTimeouts,
updateLinuxMpvFullscreenOverlayRefreshBurst, updateLinuxMpvFullscreenOverlayRefreshBurst,
scheduleLinuxVisibleOverlayFullscreenRefreshBurst, scheduleLinuxVisibleOverlayFullscreenRefreshBurst,
} from './linux-mpv-fullscreen-overlay-refresh'; } 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 () => { test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work on linux', async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { Object.defineProperty(process, 'platform', {
@@ -52,8 +52,12 @@ function refreshLinuxVisibleOverlayAfterMpvFullscreenChange(
return; return;
} }
// 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.hide();
mainWindow.showInactive(); mainWindow.showInactive();
}
if (deps.getOverlayInteractionActive?.() === true) { if (deps.getOverlayInteractionActive?.() === true) {
mainWindow.setIgnoreMouseEvents(false); mainWindow.setIgnoreMouseEvents(false);
} else { } else {
+20 -7
View File
@@ -413,14 +413,17 @@ test('media timing review downloads one window of a remote stream for the wavefo
endTime: 14.5, endTime: 14.5,
}, },
]); ]);
assert.deepEqual(previewStarts, [ assert.deepEqual(
previewStarts.map(({ onPlaybackPosition, ...options }) => options),
[
{ {
mediaPath: '/tmp/window-7.5-14.5.mkv', mediaPath: '/tmp/window-7.5-14.5.mkv',
executablePath: 'mpv', executablePath: 'mpv',
volume: 60, volume: 60,
absoluteTimestamps: true, absoluteTimestamps: true,
}, },
]); ],
);
assert.deepEqual(previewPlays, [['/tmp/window-7.5-14.5.mkv', 9.5, 12.5]]); assert.deepEqual(previewPlays, [['/tmp/window-7.5-14.5.mkv', 9.5, 12.5]]);
assert.deepEqual(disposed, ['/tmp/window-7.5-14.5.mkv']); 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, audioStreamIndex: 2,
}, },
]); ]);
assert.deepEqual(previewStarts, [ assert.deepEqual(
{ mediaPath: REMOTE_STREAM_URL, executablePath: 'mpv', volume: 60, audioTrackId: 3 }, 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]]); 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 () => { test('media timing review forwards the hidden player finishing a preview to the modal', async () => {
const endedReviewIds: string[] = []; 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; let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => { const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
publishPayload = resolve; publishPayload = resolve;
@@ -1099,7 +1104,9 @@ test('media timing review forwards the hidden player finishing a preview to the
getMpvExecutablePath: () => 'mpv', getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [], generateWaveform: async () => [],
createPreviewSession: () => ({ createPreviewSession: () => ({
start: async () => undefined, start: async (options) => {
playback.position = options.onPlaybackPosition;
},
play: async () => undefined, play: async () => undefined,
stop: async () => undefined, stop: async () => undefined,
onPlaybackEnded: (listener) => { onPlaybackEnded: (listener) => {
@@ -1114,6 +1121,7 @@ test('media timing review forwards the hidden player finishing a preview to the
onPreviewEnded: (reviewId) => { onPreviewEnded: (reviewId) => {
endedReviewIds.push(reviewId); endedReviewIds.push(reviewId);
}, },
onPreviewPosition: (position) => positions.push(position),
showStatus: () => undefined, showStatus: () => undefined,
}); });
const pendingDecision = runtime.requestReview({ 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.ended);
assert.ok(playback.position);
playback.position(11);
assert.deepEqual(positions, [{ reviewId: payload.reviewId, time: 11 }]);
playback.ended(); playback.ended();
assert.deepEqual(endedReviewIds, [payload.reviewId]); assert.deepEqual(endedReviewIds, [payload.reviewId]);
@@ -1140,6 +1151,8 @@ test('media timing review forwards the hidden player finishing a preview to the
await pendingDecision; await pendingDecision;
playback.ended(); playback.ended();
assert.deepEqual(endedReviewIds, [payload.reviewId]); 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 () => { test('preview reports a stale review when the review ends during playback', async () => {
+9 -7
View File
@@ -5,6 +5,7 @@ import type {
MediaTimingReviewDecision, MediaTimingReviewDecision,
MediaTimingReviewOpenPayload, MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewRequest, MediaTimingReviewPreviewRequest,
MediaTimingReviewPreviewPosition,
MediaTimingReviewRequest, MediaTimingReviewRequest,
MediaTimingReviewResolveRequest, MediaTimingReviewResolveRequest,
MediaTimingReviewFrameRequest, MediaTimingReviewFrameRequest,
@@ -13,6 +14,7 @@ import type {
MediaTimingReviewWaveformResult, MediaTimingReviewWaveformResult,
} from '../../types/anki'; } from '../../types/anki';
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform'; 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 type { MediaTimingFrameOptions } from '../../core/services/media-timing-frame';
import { import {
isRemoteMediaWindowSourcePath, isRemoteMediaWindowSourcePath,
@@ -36,13 +38,7 @@ interface ReviewMpvClient {
} }
interface PreviewSession { interface PreviewSession {
start(options: { start(options: MediaTimingPreviewStartOptions): Promise<void>;
mediaPath: string;
executablePath?: string;
audioTrackId?: number;
volume?: number;
absoluteTimestamps?: boolean;
}): Promise<void>;
play(startTime: number, endTime: number): Promise<void>; play(startTime: number, endTime: number): Promise<void>;
stop(): Promise<void>; stop(): Promise<void>;
/** Fires when the player reaches the end of the clip started by play(). */ /** 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<boolean>; openModal: (payload: MediaTimingReviewOpenPayload, signal: AbortSignal) => Promise<boolean>;
/** Tells the modal that the hidden player finished the previewed clip. */ /** Tells the modal that the hidden player finished the previewed clip. */
onPreviewEnded?: (reviewId: string) => void; onPreviewEnded?: (reviewId: string) => void;
onPreviewPosition?: (position: MediaTimingReviewPreviewPosition) => void;
showStatus: (message: string) => void; showStatus: (message: string) => void;
} }
@@ -388,6 +385,11 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
} }
await session.start({ await session.start({
mediaPath, mediaPath,
onPlaybackPosition: (time) => {
if (active === review && review.preview?.session === started) {
deps.onPreviewPosition?.({ reviewId: review.payload.reviewId, time });
}
},
...previewOptions, ...previewOptions,
// A cached window keeps one audio stream, so mpv's track id from the source no longer applies. // A cached window keeps one audio stream, so mpv's track id from the source no longer applies.
...(window ...(window
@@ -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 } },
]);
}
});
+11 -3
View File
@@ -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,
}, },
), );
},
}, },
); );
} }
+31 -1
View File
@@ -99,6 +99,7 @@ import type {
ControllerConfigUpdate, ControllerConfigUpdate,
ControllerPreferenceUpdate, ControllerPreferenceUpdate,
ResolvedControllerConfig, ResolvedControllerConfig,
SessionHelpOpenPayload,
SessionNumericSelectionStartPayload, SessionNumericSelectionStartPayload,
SubtitleMiningContext, SubtitleMiningContext,
YoutubePickerOpenPayload, YoutubePickerOpenPayload,
@@ -109,6 +110,7 @@ import type {
ChangelogSnapshot, ChangelogSnapshot,
MediaTimingReviewActionResult, MediaTimingReviewActionResult,
MediaTimingReviewOpenPayload, MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewPosition,
MediaTimingReviewPreviewRequest, MediaTimingReviewPreviewRequest,
MediaTimingReviewResolveRequest, MediaTimingReviewResolveRequest,
MediaTimingReviewFrameRequest, MediaTimingReviewFrameRequest,
@@ -214,7 +216,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,
@@ -549,6 +559,26 @@ const electronAPI: ElectronAPI = {
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent, onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
onOpenMediaTimingReview: onOpenMediaTimingReviewEvent, onOpenMediaTimingReview: onOpenMediaTimingReviewEvent,
onMediaTimingReviewPreviewEnded: onMediaTimingReviewPreviewEndedEvent, 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: ( previewMediaTimingReview: (
request: MediaTimingReviewPreviewRequest, request: MediaTimingReviewPreviewRequest,
): Promise<MediaTimingReviewActionResult> => ): Promise<MediaTimingReviewActionResult> =>
+5 -26
View File
@@ -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 {
registerDictionaryPopupVisibilityListener, registerDictionaryPopupVisibilityListener,
YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_COMMAND_EVENT,
@@ -259,7 +260,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;
} }
@@ -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 = { type ScanModifierState = {
shiftKey?: boolean; shiftKey?: boolean;
ctrlKey?: boolean; ctrlKey?: boolean;
+36 -15
View File
@@ -3,6 +3,7 @@ import type {
MediaTimingReviewContextLine, MediaTimingReviewContextLine,
MediaTimingReviewDecision, MediaTimingReviewDecision,
MediaTimingReviewOpenPayload, MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewPosition,
} from '../../types/anki'; } from '../../types/anki';
import type { ModalStateReader, RendererContext } from '../context'; import type { ModalStateReader, RendererContext } from '../context';
import { createModalFocusGuard } from './modal-focus-guard'; import { createModalFocusGuard } from './modal-focus-guard';
@@ -20,8 +21,8 @@ const LINE_REVEAL_MARGIN_SECONDS = 1;
const SPEECH_LEVEL_THRESHOLD = 0.3; const SPEECH_LEVEL_THRESHOLD = 0.3;
const SPEECH_TAIL_SECONDS = 0.15; const SPEECH_TAIL_SECONDS = 0.15;
const MINIMUM_TRAILING_TRIM_SECONDS = 0.1; 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. */ /** Allow buffering and output-device delays without cutting off an advancing preview. */
const PREVIEW_END_GRACE_MS = 2_500; const PREVIEW_STALL_TIMEOUT_MS = 15_000;
function clamp(value: number, minimum: number, maximum: number): number { function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value)); return Math.min(maximum, Math.max(minimum, value));
@@ -303,30 +304,49 @@ export function createMediaTimingReviewModal(
previewTimer = null; previewTimer = null;
} }
/** function armPreviewTimeout(): void {
* Drives the play button label plus the playhead sweep that mirrors the hidden audio player. clearPreviewTimer();
* mpv reports when the clip actually finishes (see handlePreviewEnded), which accounts for previewTimer = setTimeout(() => {
* output latency such as Bluetooth headphones; the timer only covers a player that never does. stopPreview();
*/ setStatus('Audio preview stopped responding. Try playing the selection again.', true);
function setPreviewPlaying(playing: boolean): void { }, 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; previewPlaying = playing;
ctx.dom.mediaTimingReviewPlayLabel.textContent = playing ? 'Stop preview' : 'Play selection'; ctx.dom.mediaTimingReviewPlayLabel.textContent = playing ? 'Stop preview' : 'Play selection';
ctx.dom.mediaTimingReviewPlay.classList.toggle('is-playing', playing); ctx.dom.mediaTimingReviewPlay.classList.toggle('is-playing', playing);
clearPreviewTimer(); clearPreviewTimer();
const track = ctx.dom.mediaTimingReviewSelectionTrack; 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; if (!playing) return;
const clipSeconds = Math.max(MINIMUM_CLIP_SECONDS, selectionEnd - selectionStart); track.style.setProperty('--playhead-position', 'var(--selection-start)');
track.style.setProperty('--playhead-duration', `${clipSeconds}s`); armPreviewTimeout();
void track.offsetWidth; }
track.classList.add('is-previewing');
previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000 + PREVIEW_END_GRACE_MS); 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. */ /** The hidden player reached the end of the clip and paused itself. */
function handlePreviewEnded(reviewId: string): void { function handlePreviewEnded(reviewId: string): void {
if (!payload || payload.reviewId !== reviewId || !previewPlaying) return; if (!payload || payload.reviewId !== reviewId || !previewPlaying) return;
setPreviewPlaying(false); setPreviewPlaying(false, true);
setStatus(''); setStatus('');
} }
@@ -1026,6 +1046,7 @@ export function createMediaTimingReviewModal(
return { return {
openMediaTimingReviewModal, openMediaTimingReviewModal,
handlePreviewEnded, handlePreviewEnded,
handlePreviewPosition,
requestCancel, requestCancel,
handleMediaTimingReviewKeydown, handleMediaTimingReviewKeydown,
wireDomEvents, wireDomEvents,
@@ -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);
}),
);
+15 -2
View File
@@ -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' },
}); });
} }
+88 -12
View File
@@ -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', () => {
+12 -3
View File
@@ -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');
}); });
}); });
@@ -625,6 +631,9 @@ function registerModalOpenHandlers(): void {
mediaTimingReviewModal.handlePreviewEnded(reviewId); mediaTimingReviewModal.handlePreviewEnded(reviewId);
}); });
}); });
window.electronAPI.onMediaTimingReviewPreviewPosition((position) => {
mediaTimingReviewModal.handlePreviewPosition(position);
});
window.electronAPI.onOpenPlaylistBrowser(() => { window.electronAPI.onOpenPlaylistBrowser(() => {
runGuardedAsync('playlist-browser:open', async () => { runGuardedAsync('playlist-browser:open', async () => {
await playlistBrowserModal.openPlaylistBrowserModal(); await playlistBrowserModal.openPlaylistBrowserModal();
+19 -15
View File
@@ -1687,7 +1687,7 @@ body:focus-visible,
--selection-end: 80%; --selection-end: 80%;
--original-start: 25%; --original-start: 25%;
--original-end: 75%; --original-end: 75%;
--playhead-duration: 1s; --playhead-position: var(--selection-start);
position: relative; position: relative;
height: 52px; height: 52px;
@@ -1803,10 +1803,10 @@ body:focus-visible,
.media-timing-review-playhead { .media-timing-review-playhead {
position: absolute; position: absolute;
z-index: 5; z-index: 7;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: var(--selection-start); left: clamp(0px, var(--playhead-position), calc(100% - 2px));
width: 2px; width: 2px;
background: var(--ctp-yellow); background: var(--ctp-yellow);
box-shadow: 0 0 12px color-mix(in srgb, var(--ctp-yellow) 75%, transparent); 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 { .media-timing-review-track.is-previewing .media-timing-review-playhead {
opacity: 1; 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. */ /* 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; animation: none;
} }
.media-timing-review-track.is-loading::after, .media-timing-review-track.is-loading::after {
.media-timing-review-track.is-previewing .media-timing-review-playhead {
animation: none; animation: none;
} }
} }
@@ -4136,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));
}
+1
View File
@@ -167,6 +167,7 @@ export const IPC_CHANNELS = {
youtubePickerOpen: 'youtube:picker-open', youtubePickerOpen: 'youtube:picker-open',
mediaTimingReviewOpen: 'media-timing-review:open', mediaTimingReviewOpen: 'media-timing-review:open',
mediaTimingReviewPreviewEnded: 'media-timing-review:preview-ended', mediaTimingReviewPreviewEnded: 'media-timing-review:preview-ended',
mediaTimingReviewPreviewPosition: 'media-timing-review:preview-position',
youtubePickerCancel: 'youtube:picker-cancel', youtubePickerCancel: 'youtube:picker-cancel',
playlistBrowserOpen: 'playlist-browser:open', playlistBrowserOpen: 'playlist-browser:open',
sessionNumericSelectionStart: 'session:numeric-selection-start', sessionNumericSelectionStart: 'session:numeric-selection-start',
+5
View File
@@ -82,6 +82,11 @@ export interface MediaTimingReviewPreviewRequest {
endTime: number; endTime: number;
} }
export interface MediaTimingReviewPreviewPosition {
reviewId: string;
time: number;
}
export interface MediaTimingReviewWaveformRequest { export interface MediaTimingReviewWaveformRequest {
reviewId: string; reviewId: string;
startTime: number; startTime: number;
+10 -1
View File
@@ -6,6 +6,7 @@ import type {
KikuMergePreviewResponse, KikuMergePreviewResponse,
MediaTimingReviewActionResult, MediaTimingReviewActionResult,
MediaTimingReviewOpenPayload, MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewPosition,
MediaTimingReviewPreviewRequest, MediaTimingReviewPreviewRequest,
MediaTimingReviewResolveRequest, MediaTimingReviewResolveRequest,
MediaTimingReviewFrameRequest, MediaTimingReviewFrameRequest,
@@ -433,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;
@@ -546,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;
@@ -556,6 +562,9 @@ export interface ElectronAPI {
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void; onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
onOpenMediaTimingReview: (callback: (payload: MediaTimingReviewOpenPayload) => void) => void; onOpenMediaTimingReview: (callback: (payload: MediaTimingReviewOpenPayload) => void) => void;
onMediaTimingReviewPreviewEnded: (callback: (reviewId: string) => void) => void; onMediaTimingReviewPreviewEnded: (callback: (reviewId: string) => void) => void;
onMediaTimingReviewPreviewPosition: (
callback: (position: MediaTimingReviewPreviewPosition) => void,
) => void;
previewMediaTimingReview: ( previewMediaTimingReview: (
request: MediaTimingReviewPreviewRequest, request: MediaTimingReviewPreviewRequest,
) => Promise<MediaTimingReviewActionResult>; ) => Promise<MediaTimingReviewActionResult>;