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
@@ -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 {
+26 -13
View File
@@ -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 () => {
+9 -7
View File
@@ -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 } },
]);
}
});
+11 -3
View File
@@ -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,
},
),
);
},
},
);
}