mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
Merge remote-tracking branch 'origin/main' into add-hachidori-backend
This commit is contained in:
32 files changed
+1122
-117
No files matched your search
@@ -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],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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<ChildProcess, 'kill' | 'once'>;
|
||||
@@ -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<MediaTimingPreviewDeps> = {}) {
|
||||
@@ -141,6 +157,8 @@ export class MediaTimingPreviewSession {
|
||||
private async startOnce(options: MediaTimingPreviewStartOptions): Promise<void> {
|
||||
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<void> {
|
||||
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<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> {
|
||||
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) {
|
||||
|
||||
+14
-1
@@ -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.',
|
||||
);
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<MediaTimingReviewOpenPayload>((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 () => {
|
||||
|
||||
@@ -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<void>;
|
||||
start(options: MediaTimingPreviewStartOptions): Promise<void>;
|
||||
play(startTime: number, endTime: number): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
/** 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>;
|
||||
/** 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
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openSessionHelpModal } from './session-help-open';
|
||||
|
||||
test('session help open tells the renderer whether commands can run', async () => {
|
||||
for (const playing of [true, false]) {
|
||||
const sent: Array<{ channel: string; payload: unknown }> = [];
|
||||
const opened = await openSessionHelpModal({
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||
sendToActiveOverlayWindow: (channel, payload) => {
|
||||
sent.push({ channel, payload });
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: async () => true,
|
||||
logWarn: () => {},
|
||||
isMediaPlaybackActive: () => playing,
|
||||
});
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(sent, [
|
||||
{ channel: IPC_CHANNELS.event.sessionHelpOpen, payload: { commandsEnabled: playing } },
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { 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<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
/** True when mpv has a video loaded; gates running commands from help rows. */
|
||||
isMediaPlaybackActive: () => boolean;
|
||||
}): Promise<boolean> {
|
||||
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,
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
+31
-1
@@ -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<T>(
|
||||
}
|
||||
|
||||
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 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<MediaTimingReviewActionResult> =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { CompiledSessionBinding } from '../../types.js';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { buildSessionHelpSections, createSessionHelpModal } from './session-help.js';
|
||||
|
||||
/** Just enough DOM for the help modal to render rows and route row events. */
|
||||
class FakeElement {
|
||||
children: FakeElement[] = [];
|
||||
parent: FakeElement | null = null;
|
||||
dataset: Record<string, string> = {};
|
||||
style: Record<string, string> = {};
|
||||
textContent = '';
|
||||
value = '';
|
||||
tabIndex = 0;
|
||||
type = '';
|
||||
private classes = new Set<string>();
|
||||
private listeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
|
||||
classList = {
|
||||
add: (...tokens: string[]) => tokens.forEach((token) => this.classes.add(token)),
|
||||
remove: (...tokens: string[]) => tokens.forEach((token) => this.classes.delete(token)),
|
||||
toggle: (token: string, force?: boolean) => {
|
||||
const on = force ?? !this.classes.has(token);
|
||||
if (on) this.classes.add(token);
|
||||
else this.classes.delete(token);
|
||||
return on;
|
||||
},
|
||||
contains: (token: string) => this.classes.has(token),
|
||||
};
|
||||
|
||||
set className(value: string) {
|
||||
this.classes = new Set(value.split(/\s+/).filter(Boolean));
|
||||
}
|
||||
|
||||
get className(): string {
|
||||
return [...this.classes].join(' ');
|
||||
}
|
||||
|
||||
set innerHTML(_value: string) {
|
||||
this.children = [];
|
||||
}
|
||||
|
||||
appendChild(child: FakeElement): FakeElement {
|
||||
child.parent = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
insertBefore(child: FakeElement, ref: FakeElement): FakeElement {
|
||||
child.parent = this;
|
||||
const index = this.children.indexOf(ref);
|
||||
this.children.splice(index < 0 ? this.children.length : index, 0, child);
|
||||
return child;
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: (event: unknown) => void): void {
|
||||
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
||||
}
|
||||
|
||||
dispatch(type: string, event: Record<string, unknown>): void {
|
||||
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
(globalThis.document as unknown as { activeElement: unknown }).activeElement = this;
|
||||
}
|
||||
|
||||
contains(node: unknown): boolean {
|
||||
for (let current = node as FakeElement | null; current; current = current.parent) {
|
||||
if (current === this) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
closest(selector: string): FakeElement | null {
|
||||
for (let current: FakeElement | null = this; current; current = current.parent) {
|
||||
if (current.classList.contains(selector.slice(1))) return current;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
querySelectorAll(selector: string): FakeElement[] {
|
||||
const matches: FakeElement[] = [];
|
||||
const visit = (node: FakeElement) => {
|
||||
for (const child of node.children) {
|
||||
if (child.classList.contains(selector.slice(1))) matches.push(child);
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(this);
|
||||
return matches;
|
||||
}
|
||||
|
||||
setAttribute(): void {}
|
||||
removeEventListener(): void {}
|
||||
select(): void {}
|
||||
scrollIntoView(): void {}
|
||||
getClientRects(): unknown[] {
|
||||
return [{}];
|
||||
}
|
||||
}
|
||||
|
||||
const SESSION_BINDINGS: CompiledSessionBinding[] = [
|
||||
{
|
||||
sourcePath: 'stats.toggleKey',
|
||||
originalKey: 'Backquote',
|
||||
key: { code: 'Backquote', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'toggleStatsOverlay',
|
||||
},
|
||||
{
|
||||
sourcePath: 'shortcuts.toggleVisibleOverlayGlobal',
|
||||
originalKey: 'KeyO',
|
||||
key: { code: 'KeyO', modifiers: ['alt'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'toggleVisibleOverlay',
|
||||
},
|
||||
];
|
||||
|
||||
function withFakeDom(
|
||||
run: (harness: ReturnType<typeof createHarness>) => Promise<void>,
|
||||
options: HarnessOptions = {},
|
||||
) {
|
||||
return async () => {
|
||||
const globals = globalThis as Record<string, unknown>;
|
||||
const saved = ['window', 'document', 'HTMLElement', 'Element'].map((key) => [
|
||||
key,
|
||||
globals[key],
|
||||
]);
|
||||
try {
|
||||
await run(createHarness(options));
|
||||
} finally {
|
||||
for (const [key, value] of saved) {
|
||||
Object.defineProperty(globalThis, key as string, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type HarnessOptions = { failActions?: boolean };
|
||||
|
||||
function createHarness(options: HarnessOptions) {
|
||||
const mpvCommands: (string | number)[][] = [];
|
||||
const sessionActions: string[] = [];
|
||||
const define = (key: string, value: unknown) =>
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
|
||||
define('HTMLElement', FakeElement);
|
||||
define('Element', FakeElement);
|
||||
define('document', {
|
||||
activeElement: null,
|
||||
createElement: () => new FakeElement(),
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
});
|
||||
define('window', {
|
||||
electronAPI: {
|
||||
focusMainWindow: async () => {},
|
||||
setIgnoreMouseEvents: () => {},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
getSessionBindings: async () => SESSION_BINDINGS,
|
||||
getSubtitleStyle: async () => ({}),
|
||||
getMarkWatchedKey: async () => null,
|
||||
getSubtitleSidebarSnapshot: async () => ({ config: { toggleKey: null } }),
|
||||
getRuntimeOptions: async () => [],
|
||||
sendMpvCommand: (command: (string | number)[]) => mpvCommands.push(command),
|
||||
dispatchSessionAction: async (actionId: string) => {
|
||||
sessionActions.push(actionId);
|
||||
if (options.failActions) throw new Error('boom');
|
||||
},
|
||||
},
|
||||
focus: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
setTimeout: (callback: () => void) => setTimeout(callback, 0),
|
||||
clearTimeout: (id: unknown) => clearTimeout(id as ReturnType<typeof setTimeout>),
|
||||
});
|
||||
|
||||
const dom = {
|
||||
overlay: new FakeElement(),
|
||||
sessionHelpModal: new FakeElement(),
|
||||
sessionHelpFilter: new FakeElement(),
|
||||
sessionHelpContent: new FakeElement(),
|
||||
sessionHelpClose: new FakeElement(),
|
||||
sessionHelpShortcut: new FakeElement(),
|
||||
sessionHelpWarning: new FakeElement(),
|
||||
sessionHelpStatus: new FakeElement(),
|
||||
};
|
||||
const state = createRendererState();
|
||||
const modal = createSessionHelpModal(
|
||||
{
|
||||
state,
|
||||
dom,
|
||||
platform: {
|
||||
overlayLayer: 'modal',
|
||||
isModalLayer: true,
|
||||
isLinuxPlatform: true,
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
shouldToggleMouseIgnore: false,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
},
|
||||
);
|
||||
modal.wireDomEvents();
|
||||
|
||||
async function open(commandsEnabled: boolean): Promise<void> {
|
||||
modal.openSessionHelpModal(
|
||||
{ bindingKey: 'KeyH', fallbackUsed: false, fallbackUnavailable: false },
|
||||
{ commandsEnabled },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
/** Keydown targets the focused element, as in the browser; returns whether default was prevented. */
|
||||
function pressEnter(target = (document as unknown as { activeElement: unknown }).activeElement) {
|
||||
let prevented = false;
|
||||
modal.handleSessionHelpKeydown({
|
||||
key: 'Enter',
|
||||
target,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
} as KeyboardEvent);
|
||||
return prevented;
|
||||
}
|
||||
|
||||
const rows = () => dom.sessionHelpContent.querySelectorAll('.session-help-item');
|
||||
return { dom, state, open, pressEnter, rows, mpvCommands, sessionActions };
|
||||
}
|
||||
|
||||
test('session help rows carry runnable commands except numeric and self-opening actions', () => {
|
||||
const sections = buildSessionHelpSections({
|
||||
sessionBindings: [
|
||||
{
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'Space',
|
||||
key: { code: 'Space', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['cycle', 'pause'],
|
||||
},
|
||||
{
|
||||
sourcePath: 'shortcuts.copySubtitleMultiple',
|
||||
originalKey: 'Shift+KeyC',
|
||||
key: { code: 'KeyC', modifiers: ['shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'copySubtitleMultiple',
|
||||
},
|
||||
{
|
||||
sourcePath: 'shortcuts.openSessionHelp',
|
||||
originalKey: 'Slash',
|
||||
key: { code: 'Slash', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSessionHelp',
|
||||
},
|
||||
],
|
||||
markWatchedKey: 'KeyW',
|
||||
subtitleStyle: {},
|
||||
});
|
||||
const rows = sections.flatMap((section) => section.rows);
|
||||
const commandFor = (action: string) => rows.find((row) => row.action === action)?.command;
|
||||
|
||||
assert.deepEqual(commandFor('Toggle playback'), {
|
||||
actionType: 'mpv-command',
|
||||
command: ['cycle', 'pause'],
|
||||
});
|
||||
assert.deepEqual(commandFor('Mark video watched'), {
|
||||
actionType: 'session-action',
|
||||
actionId: 'markWatched',
|
||||
});
|
||||
assert.equal(commandFor('Copy subtitle (multi)'), undefined);
|
||||
assert.equal(commandFor('Open session help'), undefined);
|
||||
assert.equal(commandFor('Toggle primary subtitle bar visibility'), undefined);
|
||||
});
|
||||
|
||||
test(
|
||||
'session help runs the selected command on Enter during playback and closes',
|
||||
withFakeDom(async (harness) => {
|
||||
await harness.open(true);
|
||||
assert.ok(harness.rows()[0]?.classList.contains('session-help-item-runnable'));
|
||||
|
||||
harness.pressEnter();
|
||||
|
||||
assert.deepEqual(harness.sessionActions, ['toggleStatsOverlay']);
|
||||
assert.equal(harness.state.sessionHelpModalOpen, false);
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
'session help ignores Enter when no video is playing',
|
||||
withFakeDom(async (harness) => {
|
||||
await harness.open(false);
|
||||
assert.equal(harness.rows()[0]?.classList.contains('session-help-item-runnable'), false);
|
||||
|
||||
harness.pressEnter();
|
||||
|
||||
assert.deepEqual(harness.sessionActions, []);
|
||||
assert.equal(harness.state.sessionHelpModalOpen, true);
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
'session help hover picks the Enter target and double-click runs the row',
|
||||
withFakeDom(async (harness) => {
|
||||
await harness.open(true);
|
||||
const [statsRow, overlayRow, fixedRow] = harness.rows();
|
||||
|
||||
harness.dom.sessionHelpContent.dispatch('mousemove', { target: overlayRow });
|
||||
assert.equal(harness.state.sessionHelpSelectedIndex, 1);
|
||||
|
||||
harness.dom.sessionHelpContent.dispatch('dblclick', { target: fixedRow });
|
||||
assert.equal(harness.state.sessionHelpModalOpen, true);
|
||||
|
||||
harness.dom.sessionHelpContent.dispatch('dblclick', { target: statsRow?.children[0] });
|
||||
assert.deepEqual(harness.sessionActions, ['toggleStatsOverlay']);
|
||||
assert.equal(harness.state.sessionHelpModalOpen, false);
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
'session help reports a failed session action on the mpv OSD',
|
||||
withFakeDom(
|
||||
async (harness) => {
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
await harness.open(true);
|
||||
harness.pressEnter();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
|
||||
assert.deepEqual(harness.mpvCommands, [['show-text', 'Command failed to run', '3000']]);
|
||||
},
|
||||
{ failActions: true },
|
||||
),
|
||||
);
|
||||
|
||||
test(
|
||||
'session help does not run a command from Enter pressed in the filter',
|
||||
withFakeDom(async (harness) => {
|
||||
await harness.open(true);
|
||||
// The filter's own listener already moved focus to a row before this bubbles up.
|
||||
harness.rows()[0]?.focus();
|
||||
|
||||
harness.pressEnter(harness.dom.sessionHelpFilter);
|
||||
|
||||
assert.deepEqual(harness.sessionActions, []);
|
||||
assert.equal(harness.state.sessionHelpModalOpen, true);
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
'session help leaves Enter on the close button to its native activation',
|
||||
withFakeDom(async (harness) => {
|
||||
await harness.open(true);
|
||||
|
||||
const prevented = harness.pressEnter(harness.dom.sessionHelpClose);
|
||||
|
||||
assert.equal(prevented, false);
|
||||
assert.deepEqual(harness.sessionActions, []);
|
||||
assert.equal(harness.state.sessionHelpModalOpen, true);
|
||||
}),
|
||||
);
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { SessionHelpItem, SessionHelpSection } from './session-help-sections';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<CompiledMpvCommandBinding, 'actionType' | 'command'>
|
||||
| Pick<CompiledSessionActionBinding, 'actionType' | 'actionId' | 'payload'>;
|
||||
|
||||
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<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(
|
||||
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' },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+19
-15
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -82,6 +82,11 @@ export interface MediaTimingReviewPreviewRequest {
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface MediaTimingReviewPreviewPosition {
|
||||
reviewId: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface MediaTimingReviewWaveformRequest {
|
||||
reviewId: string;
|
||||
startTime: number;
|
||||
|
||||
+10
-1
@@ -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<SessionActionId, 'copySubtitleMultiple' | 'mineSentenceMultiple'>;
|
||||
timeoutMs: number;
|
||||
@@ -546,7 +552,7 @@ export interface ElectronAPI {
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => Promise<RuntimeOptionApplyResult>;
|
||||
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<ChangelogSnapshot>;
|
||||
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<MediaTimingReviewActionResult>;
|
||||
|
||||
Reference in new issue
Block a user