mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 06:46:09 +00:00
Resolve countdown cancellation and enable webcam preview from New
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { BrowserWindow } from "electron";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { createCountdownController } from "./countdownController";
|
||||
function fixture(loading = false) {
|
||||
const contents = Object.assign(new EventEmitter(), {
|
||||
isLoadingMainFrame: () => loading,
|
||||
isDestroyed: () => false,
|
||||
send: vi.fn(),
|
||||
});
|
||||
const win = Object.assign(new EventEmitter(), {
|
||||
webContents: contents,
|
||||
isDestroyed: () => false,
|
||||
close: vi.fn(),
|
||||
});
|
||||
const state = vi.fn();
|
||||
const controller = createCountdownController(() => win as unknown as BrowserWindow, state);
|
||||
return { controller, win, contents, state };
|
||||
}
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
it("cancels an active countdown immediately and allows another start", async () => {
|
||||
const { controller, state } = fixture();
|
||||
const pending = controller.start(3);
|
||||
controller.cancel();
|
||||
await expect(pending).resolves.toEqual({ success: false, cancelled: true });
|
||||
expect(state).toHaveBeenLastCalledWith(null);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
const next = controller.start(1);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await expect(next).resolves.toEqual({ success: true });
|
||||
});
|
||||
it("settles cancellation before the countdown renderer finishes loading", async () => {
|
||||
const { controller, contents } = fixture(true);
|
||||
const pending = controller.start(3);
|
||||
controller.cancel();
|
||||
await expect(pending).resolves.toEqual({ success: false, cancelled: true });
|
||||
contents.emit("did-finish-load");
|
||||
expect(contents.send).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
it.each([
|
||||
"closed",
|
||||
"render-process-gone",
|
||||
])("settles when the window exits through %s", async (event) => {
|
||||
const { controller, win, contents } = fixture(true);
|
||||
const pending = controller.start(3);
|
||||
(event === "closed" ? win : contents).emit(event);
|
||||
await expect(pending).resolves.toEqual({ success: false, cancelled: true });
|
||||
});
|
||||
it("settles loading failures and releases the countdown lock", async () => {
|
||||
const { controller, contents } = fixture(true);
|
||||
const pending = controller.start(3);
|
||||
contents.emit("did-fail-load");
|
||||
await expect(pending).resolves.toMatchObject({ success: false, error: expect.any(String) });
|
||||
const next = controller.start(1);
|
||||
contents.emit("did-finish-load");
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await expect(next).resolves.toEqual({ success: true });
|
||||
});
|
||||
it("rejects concurrent starts and emits ticks until normal completion", async () => {
|
||||
const { controller, contents } = fixture();
|
||||
const pending = controller.start(2);
|
||||
await expect(controller.start(3)).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await expect(pending).resolves.toEqual({ success: true });
|
||||
expect(contents.send.mock.calls).toEqual([
|
||||
["countdown-tick", 2],
|
||||
["countdown-tick", 1],
|
||||
]);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
it("resets state if creating the countdown window fails", async () => {
|
||||
const state = vi.fn();
|
||||
const controller = createCountdownController(() => {
|
||||
throw Error("window unavailable");
|
||||
}, state);
|
||||
await expect(controller.start(3)).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
});
|
||||
expect(state).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { BrowserWindow } from "electron";
|
||||
|
||||
type Result = { success: boolean; cancelled?: boolean; error?: string };
|
||||
/** Every countdown exit settles the IPC request, including cancellation during window load. */
|
||||
export function createCountdownController(
|
||||
createWindow: () => BrowserWindow,
|
||||
onRemaining: (seconds: number | null) => void,
|
||||
) {
|
||||
let finishActive: ((result: Result) => void) | null = null;
|
||||
return {
|
||||
start(seconds: number): Promise<Result> {
|
||||
if (finishActive)
|
||||
return Promise.resolve({ success: false, error: "Countdown already in progress" });
|
||||
if (!Number.isFinite(seconds) || seconds < 0)
|
||||
return Promise.resolve({ success: false, error: "Invalid countdown delay" });
|
||||
return new Promise((resolve) => {
|
||||
let win: BrowserWindow | undefined;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let settled = false;
|
||||
let started = false;
|
||||
let remaining = Math.ceil(seconds);
|
||||
const finish = (result: Result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearInterval(timer);
|
||||
finishActive = null;
|
||||
onRemaining(null);
|
||||
if (win) {
|
||||
win.removeListener("closed", cancel);
|
||||
win.webContents.removeListener("did-finish-load", begin);
|
||||
win.webContents.removeListener("did-fail-load", fail);
|
||||
win.webContents.removeListener("render-process-gone", cancel);
|
||||
}
|
||||
resolve(result);
|
||||
if (win && !win.isDestroyed()) win.close();
|
||||
};
|
||||
const cancel = () => finish({ success: false, cancelled: true });
|
||||
const fail = () =>
|
||||
finish({ success: false, error: "Countdown window failed to load" });
|
||||
const tick = () => {
|
||||
if (!win || win.isDestroyed() || win.webContents.isDestroyed()) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
onRemaining(remaining);
|
||||
win.webContents.send("countdown-tick", remaining);
|
||||
};
|
||||
const begin = () => {
|
||||
if (settled || started) return;
|
||||
started = true;
|
||||
if (remaining === 0) {
|
||||
finish({ success: true });
|
||||
return;
|
||||
}
|
||||
tick();
|
||||
if (settled) return;
|
||||
timer = setInterval(() => {
|
||||
remaining--;
|
||||
if (remaining <= 0) finish({ success: true });
|
||||
else tick();
|
||||
}, 1000);
|
||||
};
|
||||
finishActive = finish;
|
||||
onRemaining(remaining);
|
||||
try {
|
||||
win = createWindow();
|
||||
win.once("closed", cancel);
|
||||
win.webContents.once("did-fail-load", fail);
|
||||
win.webContents.once("render-process-gone", cancel);
|
||||
if (win.webContents.isLoadingMainFrame())
|
||||
win.webContents.once("did-finish-load", begin);
|
||||
else begin();
|
||||
} catch (error) {
|
||||
finish({ success: false, error: String(error) });
|
||||
}
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
finishActive?.({ success: false, cancelled: true });
|
||||
return { success: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { isHudInEditorMode } from "./hudEditorMode";
|
||||
it("allows the webcam when New prepares a recording despite an existing editor", () => {
|
||||
expect(isHudInEditorMode(1, false, false)).toBe(true);
|
||||
expect(isHudInEditorMode(1, true, false)).toBe(false);
|
||||
expect(isHudInEditorMode(1, true, true)).toBe(false);
|
||||
// Cancelling leaves the HUD ready to retry, including its webcam preview.
|
||||
expect(isHudInEditorMode(1, true, false)).toBe(false);
|
||||
// Returning Home stops an idle camera, but does not stop an active capture preview.
|
||||
expect(isHudInEditorMode(1, false, false)).toBe(true);
|
||||
expect(isHudInEditorMode(1, false, true)).toBe(false);
|
||||
expect(isHudInEditorMode(0, false, false)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
/** An editor may stay open behind the HUD while a new capture is prepared. */
|
||||
export function isHudInEditorMode(
|
||||
editorCount: number,
|
||||
preparingRecording: boolean,
|
||||
recording: boolean,
|
||||
) {
|
||||
return editorCount > 0 && !preparingRecording && !recording;
|
||||
}
|
||||
@@ -1,22 +1,19 @@
|
||||
import { createCountdownController } from "../../countdownController";
|
||||
import fs from "node:fs/promises";
|
||||
import { app, BrowserWindow, ipcMain } from "electron";
|
||||
import { hasAppSetting, readAppSettingsStore, writeAppSettingsStore } from "../../appSettingsStore";
|
||||
import { hideCursor } from "../../cursorHider";
|
||||
import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from "../../windows";
|
||||
import { createCountdownWindow } from "../../windows";
|
||||
import { COUNTDOWN_SETTINGS_FILE, RECORDINGS_SETTINGS_FILE, SHORTCUTS_FILE } from "../constants";
|
||||
import {
|
||||
createRecordingPreferencesStore,
|
||||
type RecordingPreferencesPatch,
|
||||
} from "../settings/recordingPreferencesStore";
|
||||
import {
|
||||
countdownCancelled,
|
||||
countdownInProgress,
|
||||
countdownRemaining,
|
||||
countdownTimer,
|
||||
setCountdownCancelled,
|
||||
setCountdownInProgress,
|
||||
setCountdownRemaining,
|
||||
setCountdownTimer,
|
||||
} from "../state";
|
||||
import { parseJsonWithByteOrderMark } from "../utils";
|
||||
|
||||
@@ -43,7 +40,10 @@ function getBrowserMicrophoneProfileFromEnv() {
|
||||
}
|
||||
|
||||
export function registerSettingsHandlers() {
|
||||
ipcMain.handle("get-window-fullscreen", (event) => BrowserWindow.fromWebContents(event.sender)?.isFullScreen() ?? false);
|
||||
ipcMain.handle(
|
||||
"get-window-fullscreen",
|
||||
(event) => BrowserWindow.fromWebContents(event.sender)?.isFullScreen() ?? false,
|
||||
);
|
||||
ipcMain.handle("app:getVersion", () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
@@ -197,79 +197,12 @@ export function registerSettingsHandlers() {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("start-countdown", async (_, seconds: number) => {
|
||||
if (countdownInProgress) {
|
||||
return { success: false, error: "Countdown already in progress" };
|
||||
}
|
||||
|
||||
setCountdownInProgress(true);
|
||||
setCountdownCancelled(false);
|
||||
setCountdownRemaining(seconds);
|
||||
|
||||
const countdownWin = createCountdownWindow();
|
||||
|
||||
if (countdownWin.webContents.isLoadingMainFrame()) {
|
||||
await new Promise<void>((resolve) => {
|
||||
countdownWin.webContents.once("did-finish-load", () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise<{ success: boolean; cancelled?: boolean }>((resolve) => {
|
||||
let remaining = seconds;
|
||||
setCountdownRemaining(remaining);
|
||||
|
||||
countdownWin.webContents.send("countdown-tick", remaining);
|
||||
|
||||
setCountdownTimer(
|
||||
setInterval(() => {
|
||||
if (countdownCancelled) {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
setCountdownTimer(null);
|
||||
}
|
||||
closeCountdownWindow();
|
||||
setCountdownInProgress(false);
|
||||
setCountdownRemaining(null);
|
||||
resolve({ success: false, cancelled: true });
|
||||
return;
|
||||
}
|
||||
|
||||
remaining--;
|
||||
setCountdownRemaining(remaining);
|
||||
|
||||
if (remaining <= 0) {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
setCountdownTimer(null);
|
||||
}
|
||||
closeCountdownWindow();
|
||||
setCountdownInProgress(false);
|
||||
setCountdownRemaining(null);
|
||||
resolve({ success: true });
|
||||
} else {
|
||||
const win = getCountdownWindow();
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send("countdown-tick", remaining);
|
||||
}
|
||||
}
|
||||
}, 1000),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("cancel-countdown", () => {
|
||||
setCountdownCancelled(true);
|
||||
setCountdownInProgress(false);
|
||||
setCountdownRemaining(null);
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
setCountdownTimer(null);
|
||||
}
|
||||
closeCountdownWindow();
|
||||
return { success: true };
|
||||
const countdown = createCountdownController(createCountdownWindow, (remaining) => {
|
||||
setCountdownRemaining(remaining);
|
||||
setCountdownInProgress(remaining !== null);
|
||||
});
|
||||
ipcMain.handle("start-countdown", (_, seconds: number) => countdown.start(seconds));
|
||||
ipcMain.handle("cancel-countdown", () => countdown.cancel());
|
||||
|
||||
ipcMain.handle("get-active-countdown", () => {
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { app, BrowserWindow, desktopCapturer, ipcMain, systemPreferences } from "electron";
|
||||
import {
|
||||
setHudRecordingPreparationActive,
|
||||
createHudOverlayWindow,
|
||||
getHudOverlayWindow,
|
||||
reassertHudOverlayMousePassthrough,
|
||||
@@ -596,6 +597,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
|
||||
createSourceSelectorWindow();
|
||||
});
|
||||
ipcMain.handle("show-recording-hud", (event) => {
|
||||
setHudRecordingPreparationActive(true);
|
||||
recordingNavigation.setReturnWindow(BrowserWindow.fromWebContents(event.sender));
|
||||
const hud = getHudOverlayWindow();
|
||||
if (hud && !hud.isDestroyed()) {
|
||||
@@ -606,9 +608,11 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
|
||||
}
|
||||
});
|
||||
ipcMain.handle("show-project-dashboard", () => {
|
||||
setHudRecordingPreparationActive(false);
|
||||
recordingNavigation.open(false);
|
||||
});
|
||||
ipcMain.handle("switch-to-editor", () => {
|
||||
setHudRecordingPreparationActive(false);
|
||||
console.log("[switch-to-editor] Opening editor window");
|
||||
const sourceSelectorWin = getSourceSelectorWindow();
|
||||
if (sourceSelectorWin && !sourceSelectorWin.isDestroyed()) {
|
||||
|
||||
+17
-2
@@ -1,3 +1,4 @@
|
||||
import { isHudInEditorMode } from "./hudEditorMode";
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
@@ -448,12 +449,24 @@ ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean)
|
||||
});
|
||||
|
||||
const editorWindows = new Set<BrowserWindow>();
|
||||
let recordingPreparationActive = false;
|
||||
function getHudEditorMode() {
|
||||
return isHudInEditorMode(
|
||||
editorWindows.size,
|
||||
recordingPreparationActive,
|
||||
hudOverlayRecordingActive,
|
||||
);
|
||||
}
|
||||
export function setHudRecordingPreparationActive(active: boolean) {
|
||||
recordingPreparationActive = active;
|
||||
notifyEditorMode();
|
||||
}
|
||||
function notifyEditorMode() {
|
||||
if (hudOverlayWindow && !hudOverlayWindow.webContents.isDestroyed()) {
|
||||
hudOverlayWindow.webContents.send("editor-mode-changed", editorWindows.size > 0);
|
||||
hudOverlayWindow.webContents.send("editor-mode-changed", getHudEditorMode());
|
||||
}
|
||||
}
|
||||
ipcMain.handle("get-editor-mode", () => editorWindows.size > 0);
|
||||
ipcMain.handle("get-editor-mode", getHudEditorMode);
|
||||
|
||||
export function createHudOverlayWindow(): BrowserWindow {
|
||||
const perfStart = Date.now();
|
||||
@@ -691,6 +704,7 @@ export function reassertHudOverlayMousePassthrough(): void {
|
||||
|
||||
export function setHudOverlayRecordingActive(recording: boolean): void {
|
||||
hudOverlayRecordingActive = Boolean(recording);
|
||||
notifyEditorMode();
|
||||
hudOverlayFallbackExpanded = false;
|
||||
applyHudOverlayBounds();
|
||||
reassertHudOverlayCaptureProtection();
|
||||
@@ -947,6 +961,7 @@ export function createEditorWindow(): BrowserWindow {
|
||||
},
|
||||
});
|
||||
|
||||
recordingPreparationActive = false;
|
||||
editorWindows.add(win);
|
||||
notifyEditorMode();
|
||||
win.once("closed", () => {
|
||||
|
||||
@@ -17,12 +17,10 @@ test("HUD dividers are vertically centered", async ({ page }) => {
|
||||
const home = page.getByRole("button", { name: "Home", exact: true });
|
||||
await expect(home.locator("svg")).toHaveAttribute("data-icon-style", "bold");
|
||||
await expect(page.getByRole("button", { name: "More", exact: true })).toHaveCount(0);
|
||||
const icon = await home
|
||||
.locator("svg")
|
||||
.evaluate((element) => ({
|
||||
width: element.getBoundingClientRect().width,
|
||||
height: element.getBoundingClientRect().height,
|
||||
}));
|
||||
const icon = await home.locator("svg").evaluate((element) => ({
|
||||
width: element.getBoundingClientRect().width,
|
||||
height: element.getBoundingClientRect().height,
|
||||
}));
|
||||
expect(icon).toEqual({ width: 20, height: 20 });
|
||||
await page.screenshot({ path: "test-results/hud-idle.png", animations: "disabled" });
|
||||
await home.click();
|
||||
@@ -55,3 +53,58 @@ test("recording HUD uses uniform controls and a readable timer", async ({ page }
|
||||
for (const size of sizes) expect(size).toEqual([36, 36]);
|
||||
await page.screenshot({ path: "test-results/hud-recording.png" });
|
||||
});
|
||||
|
||||
test("New recording mode starts the webcam with an editor still open and releases it on Home", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installDesktopBridge(page);
|
||||
await page.addInitScript(() => {
|
||||
window.electronAPI.getEditorMode = async () => true;
|
||||
window.electronAPI.onEditorModeChanged = (callback) => {
|
||||
const listener = (event: Event) => callback((event as CustomEvent<boolean>).detail);
|
||||
window.addEventListener("test-editor-mode", listener);
|
||||
return () => window.removeEventListener("test-editor-mode", listener);
|
||||
};
|
||||
window.electronAPI.getRecordingPreferences = async () => ({
|
||||
success: true,
|
||||
microphoneEnabled: false,
|
||||
webcamEnabled: true,
|
||||
systemAudioEnabled: false,
|
||||
});
|
||||
navigator.mediaDevices.getUserMedia = async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 320;
|
||||
canvas.height = 320;
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.fillStyle = "#2874ff";
|
||||
context.fillRect(0, 0, 320, 320);
|
||||
const stream = canvas.captureStream(24);
|
||||
setInterval(() => context.fillRect(0, 0, 320, 320), 100);
|
||||
return stream;
|
||||
};
|
||||
navigator.mediaDevices.enumerateDevices = async () => [];
|
||||
});
|
||||
await page.goto("/?windowType=hud-overlay");
|
||||
const preview = page.locator("video").first();
|
||||
await expect(preview).toBeVisible();
|
||||
await expect
|
||||
.poll(() => preview.evaluate((video: HTMLVideoElement) => video.srcObject === null))
|
||||
.toBe(true);
|
||||
await page.evaluate(() =>
|
||||
window.dispatchEvent(new CustomEvent("test-editor-mode", { detail: false })),
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
preview.evaluate(
|
||||
(video: HTMLVideoElement) =>
|
||||
!!video.srcObject && !video.paused && video.videoWidth === 320,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.evaluate(() =>
|
||||
window.dispatchEvent(new CustomEvent("test-editor-mode", { detail: true })),
|
||||
);
|
||||
await expect
|
||||
.poll(() => preview.evaluate((video: HTMLVideoElement) => video.srcObject === null))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user