mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 08:15:42 +00:00
Merge branch 'webadderallorg:main' into main
This commit is contained in:
Vendored
+9
-5
@@ -50,7 +50,11 @@ interface UpdateToastState {
|
||||
delayMs: number;
|
||||
isPreview?: boolean;
|
||||
progressPercent?: number;
|
||||
primaryAction?: "download-update" | "install-update" | "retry-check";
|
||||
transferredBytes?: number;
|
||||
totalBytes?: number;
|
||||
remainingBytes?: number;
|
||||
bytesPerSecond?: number;
|
||||
primaryAction?: "install-and-restart" | "retry-check";
|
||||
}
|
||||
|
||||
interface UpdateStatusSummary {
|
||||
@@ -286,9 +290,7 @@ interface Window {
|
||||
error?: string;
|
||||
}>;
|
||||
discardExportedTemp: (tempPath: string) => Promise<{ success: boolean; error?: string }>;
|
||||
getVideoAudioFallbackPaths: (
|
||||
videoPath: string,
|
||||
) => Promise<{
|
||||
getVideoAudioFallbackPaths: (videoPath: string) => Promise<{
|
||||
success: boolean;
|
||||
paths: string[];
|
||||
startDelayMsByPath?: Record<string, number>;
|
||||
@@ -486,7 +488,9 @@ interface Window {
|
||||
error?: string;
|
||||
}>;
|
||||
installDownloadedUpdate: () => Promise<{ success: boolean }>;
|
||||
downloadAvailableUpdate: () => Promise<{ success: boolean; message?: string }>;
|
||||
downloadAvailableUpdate: (
|
||||
installAfterDownload?: boolean,
|
||||
) => Promise<{ success: boolean; message?: string }>;
|
||||
deferDownloadedUpdate: (delayMs?: number) => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { appendSyncedAudioFilter, getAudioSyncAdjustment } from "./filters";
|
||||
import {
|
||||
appendSyncedAudioFilter,
|
||||
applyRecordedAudioStartDelay,
|
||||
getAudioSyncAdjustment,
|
||||
} from "./filters";
|
||||
|
||||
describe("getAudioSyncAdjustment", () => {
|
||||
it("does not speed up longer audio tracks that would advance speech", () => {
|
||||
@@ -30,6 +34,15 @@ describe("getAudioSyncAdjustment", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("pads trailing silence instead of prepending extreme delay for very short audio tracks", () => {
|
||||
expect(getAudioSyncAdjustment(600, 480)).toEqual({
|
||||
mode: "pad",
|
||||
delayMs: 0,
|
||||
tempoRatio: 1,
|
||||
durationDeltaMs: 120000,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inject atempo when longer audio stays on the anchored path", () => {
|
||||
const filterParts: string[] = [];
|
||||
appendSyncedAudioFilter(filterParts, "[1:a]", "aout", getAudioSyncAdjustment(120, 122.5));
|
||||
@@ -47,4 +60,77 @@ describe("getAudioSyncAdjustment", () => {
|
||||
"[1:a]atempo=0.975000,aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]",
|
||||
]);
|
||||
});
|
||||
|
||||
it("pads the tail for very short audio tracks", () => {
|
||||
const filterParts: string[] = [];
|
||||
appendSyncedAudioFilter(filterParts, "[1:a]", "aout", getAudioSyncAdjustment(600, 480));
|
||||
|
||||
expect(filterParts).toEqual([
|
||||
"[1:a]apad=pad_dur=120.000,aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]",
|
||||
]);
|
||||
});
|
||||
|
||||
it("can add a small gain boost before resampling", () => {
|
||||
const filterParts: string[] = [];
|
||||
appendSyncedAudioFilter(
|
||||
filterParts,
|
||||
"[1:a]",
|
||||
"aout",
|
||||
getAudioSyncAdjustment(120, 120),
|
||||
1.4,
|
||||
);
|
||||
|
||||
expect(filterParts).toEqual([
|
||||
"[1:a]volume=1.400,aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]",
|
||||
]);
|
||||
});
|
||||
|
||||
it("can prepend mic normalization filters before sync handling", () => {
|
||||
const filterParts: string[] = [];
|
||||
appendSyncedAudioFilter(filterParts, "[1:a]", "aout", getAudioSyncAdjustment(120, 120), {
|
||||
preFilters: ["loudnorm=I=-16:TP=-1.5:LRA=11"],
|
||||
});
|
||||
|
||||
expect(filterParts).toEqual([
|
||||
"[1:a]loudnorm=I=-16:TP=-1.5:LRA=11,aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyRecordedAudioStartDelay", () => {
|
||||
it("pads the tail when recorded metadata says audio started on time", () => {
|
||||
expect(applyRecordedAudioStartDelay(getAudioSyncAdjustment(120, 110), 0)).toEqual({
|
||||
mode: "pad",
|
||||
delayMs: 0,
|
||||
tempoRatio: 1,
|
||||
durationDeltaMs: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers a measured start delay over a tempo-only heuristic", () => {
|
||||
expect(applyRecordedAudioStartDelay(getAudioSyncAdjustment(120, 119.8), 275)).toEqual({
|
||||
mode: "delay",
|
||||
delayMs: 275,
|
||||
tempoRatio: 1,
|
||||
durationDeltaMs: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies a measured start delay even when durations already match", () => {
|
||||
expect(applyRecordedAudioStartDelay(getAudioSyncAdjustment(120, 120), 275)).toEqual({
|
||||
mode: "delay",
|
||||
delayMs: 275,
|
||||
tempoRatio: 1,
|
||||
durationDeltaMs: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves tempo correction alone when recorded metadata says there was no late start", () => {
|
||||
expect(applyRecordedAudioStartDelay(getAudioSyncAdjustment(120, 117), 0)).toEqual({
|
||||
mode: "tempo",
|
||||
delayMs: 0,
|
||||
tempoRatio: 0.975,
|
||||
durationDeltaMs: 3000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { AudioSyncAdjustment, PauseSegment } from "../types";
|
||||
|
||||
const MAX_AUDIO_SYNC_DELAY_MS = 15000;
|
||||
|
||||
export function buildAtempoFilters(tempoRatio: number): string[] {
|
||||
if (!Number.isFinite(tempoRatio) || tempoRatio <= 0) {
|
||||
return [];
|
||||
@@ -58,16 +60,54 @@ export function getAudioSyncAdjustment(
|
||||
return { mode: "tempo", delayMs: 0, tempoRatio, durationDeltaMs };
|
||||
}
|
||||
|
||||
if (durationDeltaMs > MAX_AUDIO_SYNC_DELAY_MS) {
|
||||
return { mode: "pad", delayMs: 0, tempoRatio: 1, durationDeltaMs };
|
||||
}
|
||||
|
||||
return { mode: "delay", delayMs: durationDeltaMs, tempoRatio: 1, durationDeltaMs };
|
||||
}
|
||||
|
||||
export function applyRecordedAudioStartDelay(
|
||||
adjustment: AudioSyncAdjustment,
|
||||
recordedStartDelayMs?: number | null,
|
||||
): AudioSyncAdjustment {
|
||||
if (!Number.isFinite(recordedStartDelayMs) || (recordedStartDelayMs ?? 0) < 0) {
|
||||
return adjustment;
|
||||
}
|
||||
|
||||
const delayMs = Math.max(0, Math.round(recordedStartDelayMs ?? 0));
|
||||
if (delayMs > 20) {
|
||||
return {
|
||||
mode: "delay",
|
||||
delayMs,
|
||||
tempoRatio: 1,
|
||||
durationDeltaMs: adjustment.durationDeltaMs,
|
||||
};
|
||||
}
|
||||
|
||||
if (adjustment.mode !== "delay" && adjustment.mode !== "pad") {
|
||||
return adjustment;
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "pad",
|
||||
delayMs: 0,
|
||||
tempoRatio: 1,
|
||||
durationDeltaMs: adjustment.durationDeltaMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendSyncedAudioFilter(
|
||||
filterParts: string[],
|
||||
inputLabel: string,
|
||||
outputLabel: string,
|
||||
adjustment: AudioSyncAdjustment,
|
||||
options: number | { volumeMultiplier?: number; preFilters?: string[] } = 1,
|
||||
) {
|
||||
const filters: string[] = [];
|
||||
const volumeMultiplier =
|
||||
typeof options === "number" ? options : (options.volumeMultiplier ?? 1);
|
||||
const preFilters = typeof options === "number" ? [] : (options.preFilters ?? []);
|
||||
const filters: string[] = [...preFilters];
|
||||
|
||||
if (adjustment.mode === "delay" && adjustment.delayMs > 0) {
|
||||
filters.push(`adelay=${adjustment.delayMs}|${adjustment.delayMs}`);
|
||||
@@ -77,6 +117,18 @@ export function appendSyncedAudioFilter(
|
||||
filters.push(...buildAtempoFilters(adjustment.tempoRatio));
|
||||
}
|
||||
|
||||
if (adjustment.mode === "pad" && adjustment.durationDeltaMs > 0) {
|
||||
filters.push(`apad=pad_dur=${formatFfmpegSeconds(adjustment.durationDeltaMs)}`);
|
||||
}
|
||||
|
||||
if (
|
||||
Number.isFinite(volumeMultiplier) &&
|
||||
volumeMultiplier > 0 &&
|
||||
Math.abs(volumeMultiplier - 1) > 0.0005
|
||||
) {
|
||||
filters.push(`volume=${volumeMultiplier.toFixed(3)}`);
|
||||
}
|
||||
|
||||
filters.push("aresample=async=1:first_pts=0", "asetpts=PTS-STARTPTS");
|
||||
filterParts.push(`${inputLabel}${filters.join(",")}[${outputLabel}]`);
|
||||
}
|
||||
|
||||
@@ -166,6 +166,18 @@ describe("getCompanionAudioFallbackPaths", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores invalid sidecar timing metadata values", async () => {
|
||||
const micPath = path.join(tempRoot, "recording.mic.wav");
|
||||
await Promise.all([
|
||||
fs.writeFile(micPath, "mic"),
|
||||
fs.writeFile(`${micPath}.json`, JSON.stringify({ startDelayMs: -250 })),
|
||||
]);
|
||||
|
||||
const { getCompanionAudioStartDelayMs } = await import("./diagnostics");
|
||||
|
||||
await expect(getCompanionAudioStartDelayMs(micPath)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects tiny MP4 container-only outputs before they reach the editor", async () => {
|
||||
const videoPath = path.join(tempRoot, "recording-123.mp4");
|
||||
await fs.writeFile(videoPath, Buffer.alloc(261));
|
||||
|
||||
@@ -119,6 +119,16 @@ async function readCompanionAudioTimingMetadata(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCompanionAudioStartDelayMs(companionPath: string) {
|
||||
const metadata = await readCompanionAudioTimingMetadata(companionPath);
|
||||
const startDelayMs = metadata?.startDelayMs;
|
||||
if (!Number.isFinite(startDelayMs) || (startDelayMs ?? 0) < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round(startDelayMs ?? 0);
|
||||
}
|
||||
|
||||
export async function hasEmbeddedAudioStream(videoPath: string) {
|
||||
const ffmpegPath = getFfmpegBinaryPath();
|
||||
let stderr = "";
|
||||
@@ -172,22 +182,19 @@ export async function getCompanionAudioFallbackInfo(videoPath: string) {
|
||||
|
||||
const metadataEntries = await Promise.all(
|
||||
paths.map(async (audioPath) => {
|
||||
const metadata = await readCompanionAudioTimingMetadata(audioPath);
|
||||
const startDelayMs = metadata?.startDelayMs;
|
||||
if (!Number.isFinite(startDelayMs) || (startDelayMs ?? 0) < 0) {
|
||||
const startDelayMs = await getCompanionAudioStartDelayMs(audioPath);
|
||||
if (!Number.isFinite(startDelayMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [audioPath, Math.round(startDelayMs ?? 0)] as const;
|
||||
return [audioPath, startDelayMs] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
paths,
|
||||
startDelayMsByPath: Object.fromEntries(
|
||||
metadataEntries.filter(
|
||||
(entry): entry is readonly [string, number] => entry !== null,
|
||||
),
|
||||
metadataEntries.filter((entry): entry is readonly [string, number] => entry !== null),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,27 +7,35 @@ import { BrowserWindow } from "electron";
|
||||
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
|
||||
import {
|
||||
appendSyncedAudioFilter,
|
||||
applyRecordedAudioStartDelay,
|
||||
buildPausedAudioFilter,
|
||||
getAudioSyncAdjustment,
|
||||
normalizePauseSegments,
|
||||
} from "../ffmpeg/filters";
|
||||
import { getWindowsCaptureExePath } from "../paths/binaries";
|
||||
import {
|
||||
selectedSource,
|
||||
setWindowsCaptureProcess,
|
||||
setWindowsCaptureStopRequested,
|
||||
setWindowsNativeCaptureActive,
|
||||
windowsCaptureOutputBuffer,
|
||||
windowsCaptureStopRequested,
|
||||
windowsCaptureTargetPath,
|
||||
windowsNativeCaptureActive,
|
||||
setWindowsNativeCaptureActive,
|
||||
windowsCaptureStopRequested,
|
||||
setWindowsCaptureStopRequested,
|
||||
selectedSource,
|
||||
} from "../state";
|
||||
import type { AudioSyncAdjustment, PauseSegment } from "../types";
|
||||
import { moveFileWithOverwrite } from "../utils";
|
||||
import { probeMediaDurationSeconds, validateRecordedVideo } from "./diagnostics";
|
||||
import {
|
||||
getCompanionAudioStartDelayMs,
|
||||
probeMediaDurationSeconds,
|
||||
validateRecordedVideo,
|
||||
} from "./diagnostics";
|
||||
import { emitRecordingInterrupted } from "./events";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
// Match the browser path's "usable speech level" intent with a standard
|
||||
// loudness pass on native Windows mic audio instead of a fixed tiny boost.
|
||||
const WINDOWS_NATIVE_MIC_PRE_FILTERS = ["loudnorm=I=-16:TP=-1.5:LRA=11"];
|
||||
|
||||
export async function isNativeWindowsCaptureAvailable(): Promise<boolean> {
|
||||
if (process.platform !== "win32") return false;
|
||||
@@ -191,9 +199,21 @@ export async function muxNativeWindowsVideoWithAudio(
|
||||
if (videoDuration > 0) {
|
||||
for (let i = 0; i < audioFilePaths.length; i++) {
|
||||
const audioDuration = await probeMediaDurationSeconds(audioFilePaths[i]);
|
||||
const adjustment = getAudioSyncAdjustment(videoDuration, audioDuration);
|
||||
const recordedStartDelayMs = await getCompanionAudioStartDelayMs(audioFilePaths[i]);
|
||||
const adjustment = applyRecordedAudioStartDelay(
|
||||
getAudioSyncAdjustment(videoDuration, audioDuration),
|
||||
recordedStartDelayMs,
|
||||
);
|
||||
audioAdjustments.set(audioInputs[i], adjustment);
|
||||
if (adjustment.mode === "tempo") {
|
||||
if (Number.isFinite(recordedStartDelayMs) && adjustment.mode === "delay") {
|
||||
console.log(
|
||||
`[mux-win] ${audioInputs[i]} audio recorded a start delay of ${adjustment.delayMs}ms`,
|
||||
);
|
||||
} else if (Number.isFinite(recordedStartDelayMs) && adjustment.mode === "pad") {
|
||||
console.log(
|
||||
`[mux-win] ${audioInputs[i]} audio started on time but ends ${adjustment.durationDeltaMs}ms early — padding trailing silence`,
|
||||
);
|
||||
} else if (adjustment.mode === "tempo") {
|
||||
console.log(
|
||||
`[mux-win] ${audioInputs[i]} audio differs from video by ${adjustment.durationDeltaMs}ms — applying tempo ratio ${adjustment.tempoRatio.toFixed(6)}`,
|
||||
);
|
||||
@@ -201,6 +221,10 @@ export async function muxNativeWindowsVideoWithAudio(
|
||||
console.log(
|
||||
`[mux-win] ${audioInputs[i]} audio appears to start late by ${adjustment.delayMs}ms — adding leading silence`,
|
||||
);
|
||||
} else if (adjustment.mode === "pad" && adjustment.durationDeltaMs > 0) {
|
||||
console.log(
|
||||
`[mux-win] ${audioInputs[i]} audio is much shorter than video by ${adjustment.durationDeltaMs}ms — padding trailing silence`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,7 +269,9 @@ export async function muxNativeWindowsVideoWithAudio(
|
||||
const micLabel = micPauseFilter ? "[mic_trimmed]" : "[2:a]";
|
||||
|
||||
appendSyncedAudioFilter(filterParts, systemLabel, "s", systemAdjustment);
|
||||
appendSyncedAudioFilter(filterParts, micLabel, "m", micAdjustment);
|
||||
appendSyncedAudioFilter(filterParts, micLabel, "m", micAdjustment, {
|
||||
preFilters: WINDOWS_NATIVE_MIC_PRE_FILTERS,
|
||||
});
|
||||
filterParts.push("[s][m]amix=inputs=2:duration=longest:normalize=0[aout]");
|
||||
|
||||
await execFileAsync(
|
||||
@@ -291,7 +317,13 @@ export async function muxNativeWindowsVideoWithAudio(
|
||||
filterParts.push(pauseFilter);
|
||||
}
|
||||
const srcLabel = pauseFilter ? "[trimmed_audio]" : "[1:a]";
|
||||
appendSyncedAudioFilter(filterParts, srcLabel, "aout", singleAdjustment);
|
||||
appendSyncedAudioFilter(
|
||||
filterParts,
|
||||
srcLabel,
|
||||
"aout",
|
||||
singleAdjustment,
|
||||
audioInputs[0] === "mic" ? { preFilters: WINDOWS_NATIVE_MIC_PRE_FILTERS } : 1,
|
||||
);
|
||||
|
||||
await execFileAsync(
|
||||
ffmpegPath,
|
||||
@@ -326,7 +358,10 @@ export async function muxNativeWindowsVideoWithAudio(
|
||||
|
||||
for (const audioPath of [systemAudioPath, micAudioPath]) {
|
||||
if (audioPath) {
|
||||
await fs.rm(audioPath, { force: true }).catch(() => undefined);
|
||||
await Promise.all([
|
||||
fs.rm(audioPath, { force: true }).catch(() => undefined),
|
||||
fs.rm(`${audioPath}.json`, { force: true }).catch(() => undefined),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ export type UiohookModuleNamespace = {
|
||||
};
|
||||
|
||||
export type AudioSyncAdjustment = {
|
||||
mode: "none" | "tempo" | "delay";
|
||||
mode: "none" | "tempo" | "delay" | "pad";
|
||||
delayMs: number;
|
||||
tempoRatio: number;
|
||||
durationDeltaMs: number;
|
||||
|
||||
+20
-8
@@ -496,13 +496,15 @@ function getUpdateNotificationTitle(payload: UpdateToastPayload) {
|
||||
function getUpdateNotificationBody(payload: UpdateToastPayload) {
|
||||
switch (payload.phase) {
|
||||
case "available":
|
||||
return "Click to download the update.";
|
||||
return "Click to install the update and restart Recordly.";
|
||||
case "downloading":
|
||||
return "Recordly is downloading the update in the foreground.";
|
||||
return "Recordly is downloading the update and will restart when it is ready.";
|
||||
case "ready":
|
||||
return "Click to install the downloaded update.";
|
||||
return "Click to install the downloaded update and restart.";
|
||||
case "error":
|
||||
return "Click to retry checking for updates.";
|
||||
return payload.primaryAction === "install-and-restart"
|
||||
? "Click to try the install again."
|
||||
: "Click to retry checking for updates.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,13 +553,21 @@ function sendUpdateToastToWindows(channel: "update-toast-state", payload: unknow
|
||||
focusOrCreateMainWindow();
|
||||
switch (updatePayload.phase) {
|
||||
case "available":
|
||||
void downloadAvailableUpdate(sendUpdateToastToWindows);
|
||||
void downloadAvailableUpdate(sendUpdateToastToWindows, {
|
||||
installAfterDownload: true,
|
||||
});
|
||||
break;
|
||||
case "ready":
|
||||
installDownloadedUpdateNow(sendUpdateToastToWindows);
|
||||
break;
|
||||
case "error":
|
||||
void checkForAppUpdates(getUpdateDialogWindow, { manual: true });
|
||||
if (updatePayload.primaryAction === "install-and-restart") {
|
||||
void downloadAvailableUpdate(sendUpdateToastToWindows, {
|
||||
installAfterDownload: true,
|
||||
});
|
||||
} else {
|
||||
void checkForAppUpdates(getUpdateDialogWindow, { manual: true });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -625,8 +635,10 @@ ipcMain.handle("install-downloaded-update", () => {
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("download-available-update", () => {
|
||||
return downloadAvailableUpdate(sendUpdateToastToWindows);
|
||||
ipcMain.handle("download-available-update", (_event, installAfterDownload?: boolean) => {
|
||||
return downloadAvailableUpdate(sendUpdateToastToWindows, {
|
||||
installAfterDownload: Boolean(installAfterDownload),
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("defer-downloaded-update", (_event, delayMs?: number) => {
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"version": 1,
|
||||
"platform": "win32",
|
||||
"arch": "x64",
|
||||
"helpers": {
|
||||
"wgc-capture": {
|
||||
"binaryName": "wgc-capture.exe",
|
||||
"binarySha256": "013164c0a1391d334e5aa2fe0ff2e47b507407e36c7e44e7333a70a84b944643",
|
||||
"sourceDir": "electron/native/wgc-capture",
|
||||
"sourceFingerprint": "9e9bce082266ca5968cf5f0b535b469d47c4ec3a775a93726171c0dfdbcdaa44",
|
||||
"updatedAt": "2026-03-29T02:15:34.516Z"
|
||||
},
|
||||
"cursor-monitor": {
|
||||
"binaryName": "cursor-monitor.exe",
|
||||
"binarySha256": "b0732abc06998a40c3e95078465ad750a6169901944571c39cfd7996effe39c0",
|
||||
"sourceDir": "electron/native/cursor-monitor",
|
||||
"sourceFingerprint": "6ad1b8b50bb336f2a48937b06f5ec56d90b6ab4a3e56a4bca278cf67a5d3e52e",
|
||||
"updatedAt": "2026-03-29T02:15:38.286Z"
|
||||
}
|
||||
}
|
||||
"version": 1,
|
||||
"platform": "win32",
|
||||
"arch": "x64",
|
||||
"helpers": {
|
||||
"wgc-capture": {
|
||||
"binaryName": "wgc-capture.exe",
|
||||
"binarySha256": "bb4c2aa4141e81e1a05b54bd49dbb114eb3a5ff4a666bf7d26525a9da585128b",
|
||||
"sourceDir": "electron/native/wgc-capture",
|
||||
"sourceFingerprint": "5bb02a69049a02909d38188cae1dfdfb94fd49465b51ed6ab78a98092b7520cc",
|
||||
"updatedAt": "2026-04-25T09:17:16.237Z"
|
||||
},
|
||||
"cursor-monitor": {
|
||||
"binaryName": "cursor-monitor.exe",
|
||||
"binarySha256": "b0732abc06998a40c3e95078465ad750a6169901944571c39cfd7996effe39c0",
|
||||
"sourceDir": "electron/native/cursor-monitor",
|
||||
"sourceFingerprint": "6ad1b8b50bb336f2a48937b06f5ec56d90b6ab4a3e56a4bca278cf67a5d3e52e",
|
||||
"updatedAt": "2026-03-29T02:15:38.286Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -14,6 +14,7 @@
|
||||
#include <condition_variable>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
|
||||
static std::atomic<bool> g_stopRequested{false};
|
||||
static std::atomic<bool> g_pauseRequested{false};
|
||||
@@ -156,6 +157,43 @@ static std::wstring utf8ToWide(const std::string& str) {
|
||||
return wstr;
|
||||
}
|
||||
|
||||
static void writeCompanionAudioTimingMetadata(
|
||||
const std::string& audioPath,
|
||||
int64_t firstVideoTimestampHns,
|
||||
const WasapiCapture& capture
|
||||
) {
|
||||
if (audioPath.empty() || firstVideoTimestampHns < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int64_t firstPacketQpcHns = capture.firstPacketQpcHns();
|
||||
if (firstPacketQpcHns < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t startDelayMs = (firstPacketQpcHns - firstVideoTimestampHns + 5000) / 10000;
|
||||
if (startDelayMs < 0) {
|
||||
startDelayMs = 0;
|
||||
}
|
||||
|
||||
std::ofstream metadataFile(audioPath + ".json", std::ios::trunc);
|
||||
if (!metadataFile) {
|
||||
std::cerr << "WARNING: Failed to write audio timing metadata for " << audioPath << std::endl;
|
||||
return;
|
||||
}
|
||||
|
||||
metadataFile << "{\"startDelayMs\":" << startDelayMs;
|
||||
const uint32_t discontinuityCount = capture.dataDiscontinuityCount();
|
||||
if (discontinuityCount > 0) {
|
||||
metadataFile << ",\"dataDiscontinuityCount\":" << discontinuityCount;
|
||||
}
|
||||
const uint32_t timestampErrorCount = capture.timestampErrorCount();
|
||||
if (timestampErrorCount > 0) {
|
||||
metadataFile << ",\"timestampErrorCount\":" << timestampErrorCount;
|
||||
}
|
||||
metadataFile << "}";
|
||||
}
|
||||
|
||||
static void stdinListenerThread() {
|
||||
std::string line;
|
||||
while (std::getline(std::cin, line)) {
|
||||
@@ -250,9 +288,12 @@ int main(int argc, char* argv[]) {
|
||||
|
||||
// Set up frame callback
|
||||
std::atomic<int64_t> frameCount{0};
|
||||
std::atomic<int64_t> firstVideoTimestampHns{-1};
|
||||
std::atomic<bool> recordingStartedAnnounced{false};
|
||||
session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) {
|
||||
g_lastFrameTimestampHns = timestampHns;
|
||||
int64_t expectedFirstVideoTimestampHns = -1;
|
||||
firstVideoTimestampHns.compare_exchange_strong(expectedFirstVideoTimestampHns, timestampHns);
|
||||
if (g_stopRequested) return;
|
||||
|
||||
if (g_pauseRequested) return;
|
||||
@@ -337,6 +378,19 @@ int main(int argc, char* argv[]) {
|
||||
if (audioActive) loopback.stop();
|
||||
if (micActive) micCapture.stop();
|
||||
|
||||
if (audioActive) {
|
||||
writeCompanionAudioTimingMetadata(
|
||||
config.audioOutputPath,
|
||||
firstVideoTimestampHns.load(),
|
||||
loopback);
|
||||
}
|
||||
if (micActive) {
|
||||
writeCompanionAudioTimingMetadata(
|
||||
config.micOutputPath,
|
||||
firstVideoTimestampHns.load(),
|
||||
micCapture);
|
||||
}
|
||||
|
||||
if (frameCount.load() <= 0) {
|
||||
std::cerr << "ERROR: No video frames were captured before stop" << std::endl;
|
||||
DeleteFileW(outputPathW.c_str());
|
||||
@@ -350,9 +404,19 @@ int main(int argc, char* argv[]) {
|
||||
|
||||
std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl;
|
||||
if (audioActive) {
|
||||
if (loopback.dataDiscontinuityCount() > 0 || loopback.timestampErrorCount() > 0) {
|
||||
std::cerr << "WARNING: System audio timing metadata includes discontinuities="
|
||||
<< loopback.dataDiscontinuityCount()
|
||||
<< " timestampErrors=" << loopback.timestampErrorCount() << std::endl;
|
||||
}
|
||||
std::cout << "Audio path: " << config.audioOutputPath << std::endl;
|
||||
}
|
||||
if (micActive) {
|
||||
if (micCapture.dataDiscontinuityCount() > 0 || micCapture.timestampErrorCount() > 0) {
|
||||
std::cerr << "WARNING: Microphone timing metadata includes discontinuities="
|
||||
<< micCapture.dataDiscontinuityCount()
|
||||
<< " timestampErrors=" << micCapture.timestampErrorCount() << std::endl;
|
||||
}
|
||||
std::cout << "Mic path: " << config.micOutputPath << std::endl;
|
||||
}
|
||||
std::cout.flush();
|
||||
|
||||
@@ -137,6 +137,9 @@ bool WasapiCapture::start() {
|
||||
}
|
||||
|
||||
totalDataBytes_ = 0;
|
||||
firstPacketQpcHns_ = -1;
|
||||
dataDiscontinuityCount_ = 0;
|
||||
timestampErrorCount_ = 0;
|
||||
writeWavHeader(outputFile_, 0);
|
||||
|
||||
HRESULT hr = audioClient_->Start();
|
||||
@@ -250,13 +253,37 @@ void WasapiCapture::captureThread() {
|
||||
BYTE* data = nullptr;
|
||||
UINT32 numFrames = 0;
|
||||
DWORD flags = 0;
|
||||
UINT64 devicePosition = 0;
|
||||
UINT64 qpcPosition = 0;
|
||||
|
||||
hr = captureClient_->GetBuffer(&data, &numFrames, &flags, nullptr, nullptr);
|
||||
hr = captureClient_->GetBuffer(
|
||||
&data,
|
||||
&numFrames,
|
||||
&flags,
|
||||
&devicePosition,
|
||||
&qpcPosition);
|
||||
if (FAILED(hr)) {
|
||||
std::cerr << "WASAPI: GetBuffer failed hr=0x" << std::hex << hr << std::dec << std::endl;
|
||||
break;
|
||||
}
|
||||
|
||||
if ((flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) != 0) {
|
||||
dataDiscontinuityCount_.fetch_add(1);
|
||||
}
|
||||
if ((flags & AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR) != 0) {
|
||||
timestampErrorCount_.fetch_add(1);
|
||||
}
|
||||
if (
|
||||
numFrames > 0 &&
|
||||
qpcPosition > 0 &&
|
||||
(flags & AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR) == 0
|
||||
) {
|
||||
int64_t expected = -1;
|
||||
firstPacketQpcHns_.compare_exchange_strong(
|
||||
expected,
|
||||
static_cast<int64_t>(qpcPosition));
|
||||
}
|
||||
|
||||
UINT32 totalSamples = numFrames * channels;
|
||||
|
||||
if (flags & AUDCLNT_BUFFERFLAGS_SILENT) {
|
||||
|
||||
@@ -19,6 +19,9 @@ public:
|
||||
bool pause();
|
||||
bool resume();
|
||||
void stop();
|
||||
int64_t firstPacketQpcHns() const { return firstPacketQpcHns_.load(); }
|
||||
uint32_t dataDiscontinuityCount() const { return dataDiscontinuityCount_.load(); }
|
||||
uint32_t timestampErrorCount() const { return timestampErrorCount_.load(); }
|
||||
|
||||
private:
|
||||
bool initializeCommon();
|
||||
@@ -41,4 +44,7 @@ private:
|
||||
DWORD streamFlags_ = 0;
|
||||
|
||||
UINT32 bufferFrameCount_ = 0;
|
||||
std::atomic<int64_t> firstPacketQpcHns_{-1};
|
||||
std::atomic<uint32_t> dataDiscontinuityCount_{0};
|
||||
std::atomic<uint32_t> timestampErrorCount_{0};
|
||||
};
|
||||
|
||||
+12
-4
@@ -504,8 +504,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
installDownloadedUpdate: () => {
|
||||
return ipcRenderer.invoke("install-downloaded-update");
|
||||
},
|
||||
downloadAvailableUpdate: () => {
|
||||
return ipcRenderer.invoke("download-available-update");
|
||||
downloadAvailableUpdate: (installAfterDownload?: boolean) => {
|
||||
return ipcRenderer.invoke("download-available-update", installAfterDownload);
|
||||
},
|
||||
deferDownloadedUpdate: (delayMs?: number) => {
|
||||
return ipcRenderer.invoke("defer-downloaded-update", delayMs);
|
||||
@@ -537,7 +537,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
delayMs: number;
|
||||
isPreview?: boolean;
|
||||
progressPercent?: number;
|
||||
primaryAction?: "download-update" | "install-update" | "retry-check";
|
||||
transferredBytes?: number;
|
||||
totalBytes?: number;
|
||||
remainingBytes?: number;
|
||||
bytesPerSecond?: number;
|
||||
primaryAction?: "install-and-restart" | "retry-check";
|
||||
} | null,
|
||||
) => void,
|
||||
) => {
|
||||
@@ -550,7 +554,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
delayMs: number;
|
||||
isPreview?: boolean;
|
||||
progressPercent?: number;
|
||||
primaryAction?: "download-update" | "install-update" | "retry-check";
|
||||
transferredBytes?: number;
|
||||
totalBytes?: number;
|
||||
remainingBytes?: number;
|
||||
bytesPerSecond?: number;
|
||||
primaryAction?: "install-and-restart" | "retry-check";
|
||||
} | null,
|
||||
) => callback(payload);
|
||||
ipcRenderer.on("update-toast-state", listener);
|
||||
|
||||
+109
-34
@@ -17,6 +17,7 @@ const UPDATER_LOG_PATH =
|
||||
const DEV_UPDATE_PREVIEW_VERSION = "9.9.9";
|
||||
const DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS = 300;
|
||||
const DEV_UPDATE_PREVIEW_PROGRESS_INCREMENT = 20;
|
||||
const ONE_MEGABYTE = 1024 * 1024;
|
||||
|
||||
export type UpdateToastPhase = "available" | "downloading" | "ready" | "error";
|
||||
|
||||
@@ -43,7 +44,18 @@ export interface UpdateToastPayload {
|
||||
delayMs: number;
|
||||
isPreview?: boolean;
|
||||
progressPercent?: number;
|
||||
primaryAction?: "download-update" | "install-update" | "retry-check";
|
||||
transferredBytes?: number;
|
||||
totalBytes?: number;
|
||||
remainingBytes?: number;
|
||||
bytesPerSecond?: number;
|
||||
primaryAction?: "install-and-restart" | "retry-check";
|
||||
}
|
||||
|
||||
interface DownloadProgressSnapshot {
|
||||
progressPercent?: number;
|
||||
transferredBytes?: number;
|
||||
totalBytes?: number;
|
||||
bytesPerSecond?: number;
|
||||
}
|
||||
|
||||
type UpdateToastSender = (
|
||||
@@ -64,6 +76,7 @@ let downloadInProgress = false;
|
||||
let downloadToastDismissed = false;
|
||||
let skippedVersion: string | null = null;
|
||||
let updateCheckErrorHandled = false;
|
||||
let installAfterDownloadRequested = false;
|
||||
let activeUpdateToastSender: UpdateToastSender | undefined;
|
||||
let updateStatusSummary: UpdateStatusSummary = {
|
||||
status: "idle",
|
||||
@@ -180,26 +193,54 @@ function createAvailableUpdateToastPayload(version: string): UpdateToastPayload
|
||||
return {
|
||||
version,
|
||||
phase: "available",
|
||||
detail: "A new version is available. Download it now, or wait and we will remind you again in 3 hours.",
|
||||
detail: "Install the latest version now, or remind yourself to come back to it later.",
|
||||
delayMs: UPDATE_REMINDER_DELAY_MS,
|
||||
primaryAction: "download-update",
|
||||
primaryAction: "install-and-restart",
|
||||
};
|
||||
}
|
||||
|
||||
function createDownloadingUpdateToastPayload(
|
||||
version: string,
|
||||
progressPercent = 0,
|
||||
progress: DownloadProgressSnapshot = {},
|
||||
): UpdateToastPayload {
|
||||
const normalizedProgress = Math.max(0, Math.min(100, progressPercent));
|
||||
const normalizedProgress = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(progress.progressPercent ?? 0)),
|
||||
);
|
||||
const transferredBytes =
|
||||
typeof progress.transferredBytes === "number" && Number.isFinite(progress.transferredBytes)
|
||||
? Math.max(0, progress.transferredBytes)
|
||||
: undefined;
|
||||
const totalBytes =
|
||||
typeof progress.totalBytes === "number" && Number.isFinite(progress.totalBytes)
|
||||
? Math.max(0, progress.totalBytes)
|
||||
: undefined;
|
||||
const remainingBytes =
|
||||
totalBytes !== undefined && transferredBytes !== undefined
|
||||
? Math.max(totalBytes - transferredBytes, 0)
|
||||
: undefined;
|
||||
const bytesPerSecond =
|
||||
typeof progress.bytesPerSecond === "number" && Number.isFinite(progress.bytesPerSecond)
|
||||
? Math.max(0, progress.bytesPerSecond)
|
||||
: undefined;
|
||||
const remainingMb =
|
||||
remainingBytes !== undefined ? Math.max(0, remainingBytes / ONE_MEGABYTE) : null;
|
||||
return {
|
||||
version,
|
||||
phase: "downloading",
|
||||
detail:
|
||||
normalizedProgress >= 100
|
||||
? "Finishing the update download. You can keep using Recordly while this completes."
|
||||
: `Downloading the update in the foreground: ${normalizedProgress.toFixed(0)}% complete.`,
|
||||
? "Finishing the update download. Recordly will restart as soon as the installer is ready."
|
||||
: remainingMb !== null
|
||||
? `${remainingMb.toFixed(1)} MB left before Recordly restarts.`
|
||||
: "Downloading the update now. Recordly will restart when it finishes.",
|
||||
delayMs: UPDATE_REMINDER_DELAY_MS,
|
||||
progressPercent: normalizedProgress,
|
||||
transferredBytes,
|
||||
totalBytes,
|
||||
remainingBytes,
|
||||
bytesPerSecond,
|
||||
primaryAction: "install-and-restart",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,9 +248,9 @@ function createDownloadedUpdateToastPayload(version: string): UpdateToastPayload
|
||||
return {
|
||||
version,
|
||||
phase: "ready",
|
||||
detail: "Install now to restart into the new version, or wait and we will remind you again in 3 hours.",
|
||||
detail: "The update is ready. Install and restart now, or remind yourself later.",
|
||||
delayMs: UPDATE_REMINDER_DELAY_MS,
|
||||
primaryAction: "install-update",
|
||||
primaryAction: "install-and-restart",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -217,9 +258,9 @@ function createUpdateErrorToastPayload(version: string, error: unknown): UpdateT
|
||||
return {
|
||||
version,
|
||||
phase: "error",
|
||||
detail: `The update download failed. ${String(error)}`,
|
||||
detail: `The update could not be downloaded. ${String(error)}`,
|
||||
delayMs: UPDATE_REMINDER_DELAY_MS,
|
||||
primaryAction: "download-update",
|
||||
primaryAction: "install-and-restart",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -276,6 +317,7 @@ function resetDevPreviewState(sendToRenderer?: UpdateToastSender) {
|
||||
downloadInProgress = false;
|
||||
downloadToastDismissed = false;
|
||||
skippedVersion = null;
|
||||
installAfterDownloadRequested = false;
|
||||
clearVisibleUpdateToast(sendToRenderer);
|
||||
}
|
||||
|
||||
@@ -289,7 +331,12 @@ function simulateDevPreviewDownload(sendToRenderer?: UpdateToastSender) {
|
||||
|
||||
let progressPercent = 0;
|
||||
emitUpdateToastState(sendToRenderer, {
|
||||
...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, progressPercent),
|
||||
...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, {
|
||||
progressPercent,
|
||||
transferredBytes: 0,
|
||||
totalBytes: 20 * ONE_MEGABYTE,
|
||||
bytesPerSecond: 5 * ONE_MEGABYTE,
|
||||
}),
|
||||
isPreview: true,
|
||||
});
|
||||
|
||||
@@ -313,7 +360,12 @@ function simulateDevPreviewDownload(sendToRenderer?: UpdateToastSender) {
|
||||
}
|
||||
|
||||
emitUpdateToastState(sendToRenderer, {
|
||||
...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, progressPercent),
|
||||
...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, {
|
||||
progressPercent,
|
||||
transferredBytes: (progressPercent / 100) * 20 * ONE_MEGABYTE,
|
||||
totalBytes: 20 * ONE_MEGABYTE,
|
||||
bytesPerSecond: 5 * ONE_MEGABYTE,
|
||||
}),
|
||||
isPreview: true,
|
||||
});
|
||||
}, DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS);
|
||||
@@ -331,6 +383,7 @@ export function dismissUpdateToast(
|
||||
}
|
||||
|
||||
if (downloadInProgress) {
|
||||
installAfterDownloadRequested = false;
|
||||
downloadToastDismissed = true;
|
||||
clearVisibleUpdateToast(sendToRenderer);
|
||||
return { success: true };
|
||||
@@ -360,13 +413,17 @@ export function installDownloadedUpdateNow(sendToRenderer?: UpdateToastSender) {
|
||||
|
||||
clearDeferredReminderTimer();
|
||||
downloadToastDismissed = false;
|
||||
installAfterDownloadRequested = false;
|
||||
clearVisibleUpdateToast(sendToRenderer);
|
||||
setUpdateStatusSummary({ status: "ready", availableVersion: pendingDownloadedVersion });
|
||||
writeUpdaterLog("Installing downloaded update.");
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
|
||||
export async function downloadAvailableUpdate(sendToRenderer?: UpdateToastSender) {
|
||||
export async function downloadAvailableUpdate(
|
||||
sendToRenderer?: UpdateToastSender,
|
||||
options?: { installAfterDownload?: boolean },
|
||||
) {
|
||||
if (currentToastPayload?.isPreview) {
|
||||
return simulateDevPreviewDownload(sendToRenderer);
|
||||
}
|
||||
@@ -386,12 +443,20 @@ export async function downloadAvailableUpdate(sendToRenderer?: UpdateToastSender
|
||||
clearDeferredReminderTimer();
|
||||
downloadInProgress = true;
|
||||
downloadToastDismissed = false;
|
||||
installAfterDownloadRequested =
|
||||
Boolean(options?.installAfterDownload) || installAfterDownloadRequested;
|
||||
setUpdateStatusSummary({
|
||||
status: "downloading",
|
||||
availableVersion,
|
||||
detail: `Downloading Recordly ${availableVersion}`,
|
||||
});
|
||||
emitUpdateToastState(sendToRenderer, createDownloadingUpdateToastPayload(availableVersion, 0));
|
||||
emitUpdateToastState(
|
||||
sendToRenderer,
|
||||
createDownloadingUpdateToastPayload(availableVersion, {
|
||||
progressPercent: 0,
|
||||
transferredBytes: 0,
|
||||
}),
|
||||
);
|
||||
writeUpdaterLog(`Starting update download for ${availableVersion}.`);
|
||||
|
||||
try {
|
||||
@@ -425,6 +490,7 @@ export function deferUpdateReminder(
|
||||
}
|
||||
|
||||
clearDeferredReminderTimer();
|
||||
installAfterDownloadRequested = false;
|
||||
clearVisibleUpdateToast(sendToRenderer);
|
||||
deferredReminderTimer = setTimeout(() => {
|
||||
const nextPayload = getReminderPayload();
|
||||
@@ -462,6 +528,7 @@ export function skipAvailableUpdateVersion(sendToRenderer?: UpdateToastSender) {
|
||||
}
|
||||
downloadInProgress = false;
|
||||
downloadToastDismissed = false;
|
||||
installAfterDownloadRequested = false;
|
||||
clearDeferredReminderTimer();
|
||||
clearVisibleUpdateToast(sendToRenderer);
|
||||
|
||||
@@ -475,6 +542,7 @@ export function previewUpdateToast(sendToRenderer: UpdateToastSender) {
|
||||
pendingDownloadedVersion = null;
|
||||
downloadInProgress = false;
|
||||
downloadToastDismissed = false;
|
||||
installAfterDownloadRequested = false;
|
||||
return emitUpdateToastState(sendToRenderer, {
|
||||
version: DEV_UPDATE_PREVIEW_VERSION,
|
||||
phase: "available",
|
||||
@@ -493,24 +561,19 @@ async function showAvailableUpdateDialog(
|
||||
type: "info",
|
||||
title: "Update Available",
|
||||
message: `Recordly ${version} is available.`,
|
||||
detail: "Download now, remind me in 3 hours, or skip this version.",
|
||||
buttons: ["Download Update", "Remind Me in 3 Hours", "Skip This Version"],
|
||||
detail: "Install and restart now, or remind me later.",
|
||||
buttons: ["Install & Restart", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
await downloadAvailableUpdate(sendToRenderer);
|
||||
await downloadAvailableUpdate(sendToRenderer, { installAfterDownload: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.response === 1) {
|
||||
deferUpdateReminder(getMainWindow, sendToRenderer, UPDATE_REMINDER_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
skipAvailableUpdateVersion(sendToRenderer);
|
||||
deferUpdateReminder(getMainWindow, sendToRenderer, UPDATE_REMINDER_DELAY_MS);
|
||||
}
|
||||
|
||||
async function showDownloadedUpdateDialog(
|
||||
@@ -527,8 +590,8 @@ async function showDownloadedUpdateDialog(
|
||||
: `Recordly ${version} has been downloaded.`,
|
||||
detail: isPreview
|
||||
? "Development preview of the native update prompt. No real update will be installed."
|
||||
: "Install now, remind me in 3 hours, or skip this version.",
|
||||
buttons: ["Install Update", "Remind Me in 3 Hours", "Skip This Version"],
|
||||
: "Install and restart now, or remind me later.",
|
||||
buttons: ["Install & Restart", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
@@ -558,14 +621,7 @@ async function showDownloadedUpdateDialog(
|
||||
}
|
||||
|
||||
deferUpdateReminder(getMainWindow, undefined, UPDATE_REMINDER_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
skipAvailableUpdateVersion();
|
||||
}
|
||||
|
||||
export async function checkForAppUpdates(
|
||||
@@ -665,6 +721,7 @@ export function setupAutoUpdates(
|
||||
pendingDownloadedVersion = null;
|
||||
downloadInProgress = false;
|
||||
downloadToastDismissed = false;
|
||||
installAfterDownloadRequested = false;
|
||||
setUpdateStatusSummary({
|
||||
status: "available",
|
||||
availableVersion: info.version,
|
||||
@@ -694,6 +751,7 @@ export function setupAutoUpdates(
|
||||
pendingDownloadedVersion = null;
|
||||
downloadInProgress = false;
|
||||
downloadToastDismissed = false;
|
||||
installAfterDownloadRequested = false;
|
||||
setUpdateStatusSummary({
|
||||
status: "up-to-date",
|
||||
availableVersion: null,
|
||||
@@ -727,7 +785,12 @@ export function setupAutoUpdates(
|
||||
|
||||
emitUpdateToastState(
|
||||
sendToRenderer,
|
||||
createDownloadingUpdateToastPayload(availableVersion, progress.percent),
|
||||
createDownloadingUpdateToastPayload(availableVersion, {
|
||||
progressPercent: progress.percent,
|
||||
transferredBytes: progress.transferred,
|
||||
totalBytes: progress.total,
|
||||
bytesPerSecond: progress.bytesPerSecond,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -748,6 +811,7 @@ export function setupAutoUpdates(
|
||||
if (downloadInProgress && availableVersion) {
|
||||
downloadInProgress = false;
|
||||
downloadToastDismissed = false;
|
||||
installAfterDownloadRequested = false;
|
||||
emitUpdateToastState(
|
||||
sendToRenderer,
|
||||
createUpdateErrorToastPayload(availableVersion, error),
|
||||
@@ -767,6 +831,7 @@ export function setupAutoUpdates(
|
||||
downloadInProgress = false;
|
||||
downloadToastDismissed = false;
|
||||
if (skippedVersion === info.version) {
|
||||
installAfterDownloadRequested = false;
|
||||
return;
|
||||
}
|
||||
availableVersion = info.version;
|
||||
@@ -778,6 +843,16 @@ export function setupAutoUpdates(
|
||||
});
|
||||
clearDeferredReminderTimer();
|
||||
|
||||
if (installAfterDownloadRequested && !currentToastPayload?.isPreview) {
|
||||
installAfterDownloadRequested = false;
|
||||
clearVisibleUpdateToast(sendToRenderer);
|
||||
writeUpdaterLog(`Auto-installing downloaded update: version=${info.version}`);
|
||||
setImmediate(() => {
|
||||
installDownloadedUpdateNow(sendToRenderer);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
emitUpdateToastState(sendToRenderer, createDownloadedUpdateToastPayload(info.version))
|
||||
) {
|
||||
|
||||
+2
-2
@@ -34,8 +34,8 @@ const HUD_SHADOW_BLEED_DIP = 36;
|
||||
const HUD_MIN_WINDOW_WIDTH = 560;
|
||||
const HUD_COMPACT_HEIGHT = 96;
|
||||
const HUD_MIN_EXPANDED_HEIGHT = 520 + HUD_SHADOW_BLEED_DIP;
|
||||
const UPDATE_TOAST_WIDTH = 420;
|
||||
const UPDATE_TOAST_HEIGHT = 212;
|
||||
const UPDATE_TOAST_WIDTH = 456;
|
||||
const UPDATE_TOAST_HEIGHT = 252;
|
||||
const UPDATE_TOAST_GAP_DIP = 18;
|
||||
|
||||
let hudOverlayExpanded = false;
|
||||
|
||||
Generated
+1
-789
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -22,7 +22,7 @@
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format --write .",
|
||||
"preview": "vite preview",
|
||||
"rebuild:native": "node ./node_modules/electron-rebuild/lib/src/cli.js --force --only uiohook-napi",
|
||||
"rebuild:native": "node ./node_modules/@electron/rebuild/lib/cli.js --force --only uiohook-napi",
|
||||
"build:native-helpers": "node scripts/build-native-helpers.mjs",
|
||||
"build:whisper-runtime": "node scripts/build-whisper-runtime.mjs",
|
||||
"build:platform-native-helpers": "npm run build:native-helpers && npm run build:windows-capture && npm run build:cursor-monitor && npm run build:whisper-runtime",
|
||||
@@ -74,7 +74,7 @@
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.7.0",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"@electron/rebuild": "^4.0.3",
|
||||
"emoji-picker-react": "^4.16.1",
|
||||
"fast-check": "^4.5.2",
|
||||
"fix-webm-duration": "^1.0.6",
|
||||
|
||||
@@ -35,66 +35,6 @@
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.updateBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.updateBadge:disabled {
|
||||
opacity: 0.72;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.updateBadgeQuiet {
|
||||
color: #a5b4c7;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.updateBadgeQuiet:hover:not(:disabled) {
|
||||
color: #d7dee8;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.updateBadgeHot {
|
||||
color: #f8fbff;
|
||||
border-color: rgba(125, 211, 252, 0.24);
|
||||
background: linear-gradient(180deg, rgba(125, 211, 252, 0.12), rgba(125, 211, 252, 0.04));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.updateBadgeHot:hover:not(:disabled) {
|
||||
color: #ffffff;
|
||||
border-color: rgba(125, 211, 252, 0.36);
|
||||
background: linear-gradient(180deg, rgba(125, 211, 252, 0.17), rgba(125, 211, 252, 0.07));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.updateBadgeSpin {
|
||||
animation: updateBadgeSpin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes updateBadgeSpin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.barState {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -135,11 +75,11 @@
|
||||
}
|
||||
|
||||
.ibActive {
|
||||
color: #6360f5;
|
||||
color: #3d8bff;
|
||||
}
|
||||
|
||||
.ibActive:hover {
|
||||
color: #7b78ff;
|
||||
color: #62a4ff;
|
||||
}
|
||||
|
||||
.ibRed {
|
||||
@@ -274,7 +214,44 @@
|
||||
}
|
||||
|
||||
.ddItemSelected {
|
||||
color: #6360f5;
|
||||
color: #3d8bff;
|
||||
}
|
||||
|
||||
.finalizingState {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
min-width: 238px;
|
||||
color: #eeeef2;
|
||||
}
|
||||
|
||||
.finalizingSpin {
|
||||
color: #3d8bff;
|
||||
animation: finalizingSpin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.finalizingCopy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.finalizingCopy small {
|
||||
color: #8a8a96;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@keyframes finalizingSpin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.recBtn {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
AppWindow,
|
||||
ArrowCircleUp as ArrowUpCircle,
|
||||
CheckCircle as CheckCircle2,
|
||||
CaretUp as ChevronUp,
|
||||
Eye,
|
||||
EyeSlash as EyeOff,
|
||||
@@ -165,6 +163,7 @@ export function LaunchWindow() {
|
||||
const {
|
||||
recording,
|
||||
paused,
|
||||
finalizing,
|
||||
countdownActive,
|
||||
toggleRecording,
|
||||
pauseRecording,
|
||||
@@ -208,24 +207,6 @@ export function LaunchWindow() {
|
||||
>(null);
|
||||
const [platform, setPlatform] = useState<string | null>(null);
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null);
|
||||
const [updateStatus, setUpdateStatus] = useState<{
|
||||
status:
|
||||
| "idle"
|
||||
| "checking"
|
||||
| "up-to-date"
|
||||
| "available"
|
||||
| "downloading"
|
||||
| "ready"
|
||||
| "error";
|
||||
currentVersion: string;
|
||||
availableVersion: string | null;
|
||||
detail?: string;
|
||||
}>({
|
||||
status: "idle",
|
||||
currentVersion: "",
|
||||
availableVersion: null,
|
||||
});
|
||||
const [updateActionPending, setUpdateActionPending] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const hudContentRef = useRef<HTMLDivElement>(null);
|
||||
const hudBarRef = useRef<HTMLDivElement>(null);
|
||||
@@ -714,31 +695,6 @@ export function LaunchWindow() {
|
||||
void preparePermissions({ startup: true });
|
||||
}, [preparePermissions]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const refreshUpdateStatus = async () => {
|
||||
try {
|
||||
const summary = await window.electronAPI.getUpdateStatusSummary();
|
||||
if (mounted) {
|
||||
setUpdateStatus(summary);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load update status summary:", error);
|
||||
}
|
||||
};
|
||||
|
||||
void refreshUpdateStatus();
|
||||
const pollTimer = window.setInterval(() => {
|
||||
void refreshUpdateStatus();
|
||||
}, 2500);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
window.clearInterval(pollTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadVersion = async () => {
|
||||
@@ -1009,74 +965,6 @@ export function LaunchWindow() {
|
||||
toggleDropdown("webcam");
|
||||
};
|
||||
|
||||
const updateButtonLabel =
|
||||
updateStatus.status === "up-to-date"
|
||||
? t("recording.update.updated")
|
||||
: t("recording.update.update");
|
||||
const updateButtonTitle = (() => {
|
||||
switch (updateStatus.status) {
|
||||
case "up-to-date":
|
||||
return t("recording.update.upToDateTitle", "Recordly {{version}} is up to date.", {
|
||||
version: updateStatus.currentVersion,
|
||||
});
|
||||
case "available":
|
||||
case "ready":
|
||||
return updateStatus.availableVersion
|
||||
? t("recording.update.availableTitle", "Recordly {{version}} is available.", {
|
||||
version: updateStatus.availableVersion,
|
||||
})
|
||||
: t("recording.update.availableGenericTitle");
|
||||
case "downloading":
|
||||
return updateStatus.detail ?? t("recording.update.downloadingTitle");
|
||||
case "checking":
|
||||
return t("recording.update.checkingTitle");
|
||||
case "error":
|
||||
return updateStatus.detail ?? t("recording.update.errorTitle");
|
||||
default:
|
||||
return t("recording.update.idleTitle");
|
||||
}
|
||||
})();
|
||||
const updateButtonClassName = `${styles.updateBadge} ${updateStatus.status === "up-to-date" ? styles.updateBadgeQuiet : styles.updateBadgeHot} ${styles.electronNoDrag}`;
|
||||
const updateButtonIcon = (() => {
|
||||
switch (updateStatus.status) {
|
||||
case "up-to-date":
|
||||
return <CheckCircle2 size={14} />;
|
||||
case "checking":
|
||||
case "downloading":
|
||||
return <RefreshCw size={14} className={styles.updateBadgeSpin} />;
|
||||
default:
|
||||
return <ArrowUpCircle size={14} />;
|
||||
}
|
||||
})();
|
||||
|
||||
const handleUpdateButtonClick = async () => {
|
||||
if (updateActionPending || updateStatus.status === "downloading") {
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdateActionPending(true);
|
||||
try {
|
||||
switch (updateStatus.status) {
|
||||
case "available":
|
||||
await window.electronAPI.downloadAvailableUpdate();
|
||||
break;
|
||||
case "ready":
|
||||
await window.electronAPI.installDownloadedUpdate();
|
||||
break;
|
||||
default:
|
||||
await window.electronAPI.checkForAppUpdates();
|
||||
break;
|
||||
}
|
||||
|
||||
const summary = await window.electronAPI.getUpdateStatusSummary();
|
||||
setUpdateStatus(summary);
|
||||
} catch (error) {
|
||||
console.error("Failed to handle update button action:", error);
|
||||
} finally {
|
||||
setUpdateActionPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const recordingControls = (
|
||||
<>
|
||||
<div className="flex items-center gap-[5px]">
|
||||
@@ -1238,6 +1126,18 @@ export function LaunchWindow() {
|
||||
</>
|
||||
);
|
||||
|
||||
const finalizingControls = (
|
||||
<div className={styles.finalizingState}>
|
||||
<RefreshCw size={15} className={styles.finalizingSpin} />
|
||||
<div className={styles.finalizingCopy}>
|
||||
<span>{t("recording.preparing", "Preparing recording")}</span>
|
||||
<small>{t("recording.preparingSubtitle", "Opening the editor in a moment")}</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const hudMode = finalizing ? "finalizing" : recording ? "recording" : "idle";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full flex items-end justify-center bg-transparent overflow-visible pb-5"
|
||||
@@ -1640,23 +1540,10 @@ export function LaunchWindow() {
|
||||
<RxDragHandleDots2 size={14} className="text-[#6b6b78]" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleUpdateButtonClick();
|
||||
}}
|
||||
className={updateButtonClassName}
|
||||
title={updateButtonTitle}
|
||||
disabled={updateActionPending}
|
||||
>
|
||||
{updateButtonIcon}
|
||||
<span>{updateButtonLabel}</span>
|
||||
</button>
|
||||
|
||||
<div className={styles.barStateViewport}>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={recording ? "recording" : "idle"}
|
||||
key={hudMode}
|
||||
layout={!showRecordingWebcamPreview}
|
||||
className={styles.barState}
|
||||
initial={{
|
||||
@@ -1679,7 +1566,11 @@ export function LaunchWindow() {
|
||||
}}
|
||||
transition={hudStateTransition}
|
||||
>
|
||||
{recording ? recordingControls : idleControls}
|
||||
{finalizing
|
||||
? finalizingControls
|
||||
: recording
|
||||
? recordingControls
|
||||
: idleControls}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Spinner as LoaderCircle,
|
||||
Rocket,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type UpdateToastPayload = {
|
||||
version: string;
|
||||
@@ -13,61 +13,73 @@ type UpdateToastPayload = {
|
||||
delayMs: number;
|
||||
isPreview?: boolean;
|
||||
progressPercent?: number;
|
||||
primaryAction?: "download-update" | "install-update" | "retry-check";
|
||||
transferredBytes?: number;
|
||||
totalBytes?: number;
|
||||
remainingBytes?: number;
|
||||
bytesPerSecond?: number;
|
||||
primaryAction?: "install-and-restart" | "retry-check";
|
||||
};
|
||||
|
||||
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_REMINDER_DELAY_MS = 3 * 60 * 60 * 1000;
|
||||
const REMINDER_OPTIONS = [
|
||||
{ label: "1 hour", value: 1 * 60 * 60 * 1000 },
|
||||
{ label: "3 hours", value: 3 * 60 * 60 * 1000 },
|
||||
{ label: "Tomorrow", value: 24 * 60 * 60 * 1000 },
|
||||
{ label: "3 days", value: 3 * 24 * 60 * 60 * 1000 },
|
||||
];
|
||||
|
||||
function formatDelayHours(delayMs: number) {
|
||||
const hours = Math.max(1, Math.round(delayMs / (60 * 60 * 1000)));
|
||||
return `${hours}h`;
|
||||
function formatBytes(value: number | undefined) {
|
||||
if (value === undefined || !Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const megabytes = value / (1024 * 1024);
|
||||
if (megabytes >= 1024) {
|
||||
return `${(megabytes / 1024).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
return `${megabytes.toFixed(megabytes >= 100 ? 0 : 1)} MB`;
|
||||
}
|
||||
|
||||
function getToastTitle(payload: UpdateToastPayload) {
|
||||
if (payload.isPreview) {
|
||||
return "Update Toast Preview";
|
||||
return "Update Prompt Preview";
|
||||
}
|
||||
|
||||
switch (payload.phase) {
|
||||
case "available":
|
||||
return `Recordly ${payload.version} is available`;
|
||||
case "downloading":
|
||||
return `Downloading Recordly ${payload.version}`;
|
||||
return `Installing Recordly ${payload.version}`;
|
||||
case "ready":
|
||||
return `Recordly ${payload.version} is ready`;
|
||||
case "error":
|
||||
return `Recordly ${payload.version} needs attention`;
|
||||
return payload.primaryAction === "retry-check"
|
||||
? "Could not check for updates"
|
||||
: `Recordly ${payload.version} needs attention`;
|
||||
}
|
||||
}
|
||||
|
||||
function getPrimaryActionLabel(payload: UpdateToastPayload) {
|
||||
switch (payload.primaryAction) {
|
||||
case "download-update":
|
||||
return "Download Update";
|
||||
case "install-update":
|
||||
return "Install Update";
|
||||
case "retry-check":
|
||||
return "Retry Check";
|
||||
default:
|
||||
return null;
|
||||
function getPrimaryButtonLabel(payload: UpdateToastPayload) {
|
||||
return payload.primaryAction === "retry-check" ? "Try Again" : "Install & Restart";
|
||||
}
|
||||
|
||||
function getPhaseIcon(payload: UpdateToastPayload) {
|
||||
switch (payload.phase) {
|
||||
case "available":
|
||||
return <Download size={20} />;
|
||||
case "downloading":
|
||||
return <LoaderCircle size={20} className="animate-spin" />;
|
||||
case "ready":
|
||||
return <Rocket size={20} />;
|
||||
case "error":
|
||||
return <AlertCircle size={20} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function UpdateToastWindow() {
|
||||
const [payload, setPayload] = useState<UpdateToastPayload | null>(null);
|
||||
const [dragOffsetX, setDragOffsetX] = useState(0);
|
||||
const dragResetKey = payload
|
||||
? `${payload.phase}:${payload.version}:${payload.progressPercent ?? ""}:${payload.detail}:${payload.delayMs}:${payload.isPreview ? "1" : "0"}:${payload.primaryAction ?? ""}`
|
||||
: "empty";
|
||||
const dragState = useRef<{
|
||||
pointerId: number | null;
|
||||
startX: number;
|
||||
active: boolean;
|
||||
}>({
|
||||
pointerId: null,
|
||||
startX: 0,
|
||||
active: false,
|
||||
});
|
||||
const [reminderDelayMs, setReminderDelayMs] = useState(DEFAULT_REMINDER_DELAY_MS);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -81,11 +93,9 @@ export function UpdateToastWindow() {
|
||||
|
||||
pollTimer = setInterval(() => {
|
||||
void window.electronAPI.getCurrentUpdateToastPayload().then((nextPayload) => {
|
||||
if (!mounted || !nextPayload) {
|
||||
return;
|
||||
if (mounted) {
|
||||
setPayload(nextPayload);
|
||||
}
|
||||
|
||||
setPayload((currentPayload) => currentPayload ?? nextPayload);
|
||||
});
|
||||
}, 750);
|
||||
|
||||
@@ -103,214 +113,160 @@ export function UpdateToastWindow() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragResetKey) {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDragOffsetX(0);
|
||||
dragState.current = {
|
||||
pointerId: null,
|
||||
startX: 0,
|
||||
active: false,
|
||||
};
|
||||
}, [dragResetKey]);
|
||||
setReminderDelayMs(payload.delayMs || DEFAULT_REMINDER_DELAY_MS);
|
||||
}, [payload]);
|
||||
|
||||
const cardStyle = {
|
||||
background: "#0d1117",
|
||||
border: "1px solid rgba(125, 211, 252, 0.22)",
|
||||
boxShadow: "0 24px 48px rgba(0, 0, 0, 0.45)",
|
||||
borderRadius: 24,
|
||||
padding: 16,
|
||||
color: "#ffffff",
|
||||
width: "100%",
|
||||
maxWidth: 404,
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
alignItems: "flex-start",
|
||||
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
|
||||
} as const;
|
||||
const normalizedProgress = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(payload?.progressPercent ?? 0)),
|
||||
);
|
||||
const downloadedLabel = formatBytes(payload?.transferredBytes);
|
||||
const totalLabel = formatBytes(payload?.totalBytes);
|
||||
const remainingLabel = formatBytes(payload?.remainingBytes);
|
||||
const speedLabel = formatBytes(payload?.bytesPerSecond);
|
||||
const phaseStats: Array<{ label: string; value: string }> = [];
|
||||
if (payload?.phase === "downloading") {
|
||||
if (downloadedLabel && totalLabel) {
|
||||
phaseStats.push({ label: "Downloaded", value: `${downloadedLabel} / ${totalLabel}` });
|
||||
} else if (downloadedLabel) {
|
||||
phaseStats.push({ label: "Downloaded", value: downloadedLabel });
|
||||
}
|
||||
if (remainingLabel) {
|
||||
phaseStats.push({ label: "Left", value: remainingLabel });
|
||||
}
|
||||
if (speedLabel) {
|
||||
phaseStats.push({ label: "Speed", value: `${speedLabel}/s` });
|
||||
}
|
||||
}
|
||||
|
||||
const isMacOS = /mac/i.test(navigator.platform);
|
||||
const wrapperStyle = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: 8,
|
||||
padding: 10,
|
||||
boxSizing: "border-box",
|
||||
background: "transparent",
|
||||
background: isMacOS ? "transparent" : "#0b1220",
|
||||
} as const;
|
||||
const secondaryTextStyle = {
|
||||
color: "rgba(255, 255, 255, 0.74)",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.45,
|
||||
margin: "4px 0 0 0",
|
||||
} as const;
|
||||
const titleStyle = {
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.2,
|
||||
margin: 0,
|
||||
const cardStyle = {
|
||||
width: "100%",
|
||||
maxWidth: 440,
|
||||
display: "flex",
|
||||
gap: 14,
|
||||
alignItems: "flex-start",
|
||||
padding: "18px 18px 16px",
|
||||
borderRadius: 24,
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(12, 19, 34, 0.98) 0%, rgba(10, 17, 30, 0.98) 100%)",
|
||||
border: "1px solid rgba(37, 99, 235, 0.24)",
|
||||
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.5), inset 0 1px 0 rgba(148, 163, 184, 0.08)",
|
||||
color: "#ffffff",
|
||||
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
|
||||
} as const;
|
||||
const iconBoxStyle = {
|
||||
width: 40,
|
||||
height: 40,
|
||||
minWidth: 40,
|
||||
width: 42,
|
||||
height: 42,
|
||||
minWidth: 42,
|
||||
borderRadius: 16,
|
||||
background: "rgba(125, 211, 252, 0.15)",
|
||||
color: "#7dd3fc",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 2,
|
||||
background: "rgba(37, 99, 235, 0.16)",
|
||||
color: "#60a5fa",
|
||||
boxShadow: "inset 0 0 0 1px rgba(37, 99, 235, 0.18)",
|
||||
} as const;
|
||||
const rowStyle = {
|
||||
display: "flex",
|
||||
flexWrap: "wrap" as const,
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
const titleStyle = {
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.25,
|
||||
margin: 0,
|
||||
color: "#f8fafc",
|
||||
} as const;
|
||||
const secondaryTextStyle = {
|
||||
color: "rgba(226, 232, 240, 0.78)",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
margin: "6px 0 0 0",
|
||||
} as const;
|
||||
const subtleButtonStyle = {
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
background: "rgba(255, 255, 255, 0.05)",
|
||||
color: "rgba(255, 255, 255, 0.92)",
|
||||
height: 38,
|
||||
borderRadius: 12,
|
||||
padding: "8px 12px",
|
||||
fontSize: 12,
|
||||
padding: "0 14px",
|
||||
border: "1px solid rgba(148, 163, 184, 0.16)",
|
||||
background: "rgba(15, 23, 42, 0.72)",
|
||||
color: "#e2e8f0",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
} as const;
|
||||
const primaryButtonStyle = {
|
||||
...subtleButtonStyle,
|
||||
background: "#7dd3fc",
|
||||
color: "#031a2c",
|
||||
border: "none",
|
||||
background: "linear-gradient(180deg, #3b82f6 0%, #2563eb 100%)",
|
||||
color: "#ffffff",
|
||||
boxShadow: "0 12px 24px rgba(37, 99, 235, 0.26)",
|
||||
} as const;
|
||||
const ghostButtonStyle = {
|
||||
...subtleButtonStyle,
|
||||
background: "transparent",
|
||||
color: "rgba(255, 255, 255, 0.72)",
|
||||
border: "1px solid rgba(125, 211, 252, 0.16)",
|
||||
const selectStyle = {
|
||||
height: 38,
|
||||
borderRadius: 12,
|
||||
padding: "0 34px 0 12px",
|
||||
border: "1px solid rgba(37, 99, 235, 0.22)",
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(18, 29, 51, 0.96) 0%, rgba(12, 22, 42, 0.96) 100%)",
|
||||
color: "#dbeafe",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
outline: "none",
|
||||
boxShadow: "inset 0 0 0 1px rgba(37, 99, 235, 0.06)",
|
||||
cursor: "pointer",
|
||||
} as const;
|
||||
|
||||
if (!payload) {
|
||||
return (
|
||||
<div style={wrapperStyle}>
|
||||
<div style={{ ...cardStyle, alignItems: "center" }}>
|
||||
<div style={iconBoxStyle}>
|
||||
<LoaderCircle size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p style={titleStyle}>Checking for updates</p>
|
||||
<p style={secondaryTextStyle}>
|
||||
Waiting for updater state from the main process.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedProgress = Math.max(0, Math.min(100, Math.round(payload.progressPercent ?? 0)));
|
||||
const primaryActionLabel = getPrimaryActionLabel(payload);
|
||||
const swipeThreshold = 96;
|
||||
const handleSwipeDismiss = async () => {
|
||||
setDragOffsetX(0);
|
||||
dragState.current = {
|
||||
pointerId: null,
|
||||
startX: 0,
|
||||
active: false,
|
||||
};
|
||||
await window.electronAPI.dismissUpdateToast();
|
||||
};
|
||||
|
||||
const handlePrimaryAction = async () => {
|
||||
switch (payload.primaryAction) {
|
||||
case "download-update":
|
||||
await window.electronAPI.downloadAvailableUpdate();
|
||||
return;
|
||||
case "install-update":
|
||||
await window.electronAPI.installDownloadedUpdate();
|
||||
return;
|
||||
case "retry-check":
|
||||
await window.electronAPI.checkForAppUpdates();
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
if (!payload || payload.phase === "downloading") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.primaryAction === "retry-check") {
|
||||
await window.electronAPI.checkForAppUpdates();
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.phase === "ready") {
|
||||
await window.electronAPI.installDownloadedUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
await window.electronAPI.downloadAvailableUpdate(true);
|
||||
};
|
||||
|
||||
const handleLater = async () => {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.isPreview) {
|
||||
await window.electronAPI.dismissUpdateToast();
|
||||
return;
|
||||
}
|
||||
|
||||
await window.electronAPI.deferDownloadedUpdate(reminderDelayMs);
|
||||
};
|
||||
|
||||
if (!payload) {
|
||||
return <div style={wrapperStyle} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle}>
|
||||
<div
|
||||
className="pointer-events-auto select-none"
|
||||
style={{
|
||||
...cardStyle,
|
||||
transform: `translateX(${dragOffsetX}px) rotate(${dragOffsetX / 30}deg)`,
|
||||
opacity: Math.max(0.35, 1 - Math.min(1, Math.abs(dragOffsetX) / 180)),
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest("button")) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragState.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
active: true,
|
||||
};
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (
|
||||
!dragState.current.active ||
|
||||
dragState.current.pointerId !== event.pointerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDragOffsetX(event.clientX - dragState.current.startX);
|
||||
}}
|
||||
onPointerUp={async (event) => {
|
||||
if (
|
||||
!dragState.current.active ||
|
||||
dragState.current.pointerId !== event.pointerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOffset = event.clientX - dragState.current.startX;
|
||||
dragState.current = {
|
||||
pointerId: null,
|
||||
startX: 0,
|
||||
active: false,
|
||||
};
|
||||
|
||||
if (Math.abs(nextOffset) >= swipeThreshold) {
|
||||
await handleSwipeDismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
setDragOffsetX(0);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
dragState.current = {
|
||||
pointerId: null,
|
||||
startX: 0,
|
||||
active: false,
|
||||
};
|
||||
setDragOffsetX(0);
|
||||
}}
|
||||
>
|
||||
<div style={iconBoxStyle}>
|
||||
{payload.phase === "available" ? <Download size={20} /> : null}
|
||||
{payload.phase === "downloading" ? (
|
||||
<LoaderCircle size={20} className="animate-spin" />
|
||||
) : null}
|
||||
{payload.phase === "ready" ? <Rocket size={20} /> : null}
|
||||
{payload.phase === "error" ? <AlertCircle size={20} /> : null}
|
||||
</div>
|
||||
<div style={cardStyle}>
|
||||
<div style={iconBoxStyle}>{getPhaseIcon(payload)}</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<p style={titleStyle}>{getToastTitle(payload)}</p>
|
||||
@@ -318,14 +274,14 @@ export function UpdateToastWindow() {
|
||||
<span
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(125, 211, 252, 0.2)",
|
||||
background: "rgba(125, 211, 252, 0.1)",
|
||||
padding: "2px 8px",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.18em",
|
||||
textTransform: "uppercase",
|
||||
color: "#bae6fd",
|
||||
color: "#93c5fd",
|
||||
background: "rgba(37, 99, 235, 0.14)",
|
||||
border: "1px solid rgba(37, 99, 235, 0.18)",
|
||||
}}
|
||||
>
|
||||
Dev
|
||||
@@ -335,104 +291,102 @@ export function UpdateToastWindow() {
|
||||
<p style={secondaryTextStyle}>{payload.detail}</p>
|
||||
|
||||
{payload.phase === "downloading" ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div
|
||||
style={{
|
||||
height: 8,
|
||||
height: 10,
|
||||
overflow: "hidden",
|
||||
borderRadius: 999,
|
||||
background: "rgba(255, 255, 255, 0.1)",
|
||||
background: "rgba(148, 163, 184, 0.14)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
background: "#7dd3fc",
|
||||
width: `${normalizedProgress}%`,
|
||||
borderRadius: 999,
|
||||
background:
|
||||
"linear-gradient(90deg, #60a5fa 0%, #2563eb 45%, #1d4ed8 100%)",
|
||||
boxShadow: "0 0 22px rgba(37, 99, 235, 0.38)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
<div
|
||||
style={{
|
||||
margin: "8px 0 0 0",
|
||||
color: "rgba(224, 242, 254, 0.9)",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 8,
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
{normalizedProgress}% downloaded
|
||||
</p>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "#dbeafe",
|
||||
}}
|
||||
>
|
||||
{normalizedProgress}% complete
|
||||
</span>
|
||||
{phaseStats.map((stat) => (
|
||||
<span
|
||||
key={stat.label}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "rgba(191, 219, 254, 0.9)",
|
||||
background: "rgba(37, 99, 235, 0.12)",
|
||||
borderRadius: 999,
|
||||
padding: "4px 8px",
|
||||
border: "1px solid rgba(37, 99, 235, 0.16)",
|
||||
}}
|
||||
>
|
||||
{stat.label}: {stat.value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={rowStyle}>
|
||||
{primaryActionLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrimaryAction}
|
||||
style={primaryButtonStyle}
|
||||
>
|
||||
{primaryActionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{payload.phase === "downloading" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await window.electronAPI.dismissUpdateToast();
|
||||
}}
|
||||
style={subtleButtonStyle}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 10,
|
||||
marginTop: 14,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{payload.phase !== "downloading" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (payload.isPreview) {
|
||||
await window.electronAPI.dismissUpdateToast();
|
||||
return;
|
||||
}
|
||||
|
||||
await window.electronAPI.deferDownloadedUpdate(payload.delayMs);
|
||||
}}
|
||||
style={subtleButtonStyle}
|
||||
>
|
||||
Later ({formatDelayHours(payload.delayMs)})
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{payload.phase !== "downloading" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (payload.isPreview) {
|
||||
await window.electronAPI.dismissUpdateToast();
|
||||
return;
|
||||
}
|
||||
|
||||
await window.electronAPI.deferDownloadedUpdate(THREE_DAYS_MS);
|
||||
}}
|
||||
style={subtleButtonStyle}
|
||||
>
|
||||
Later (3 days)
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{!payload.isPreview && payload.phase !== "downloading" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await window.electronAPI.skipUpdateVersion();
|
||||
}}
|
||||
style={ghostButtonStyle}
|
||||
>
|
||||
Skip This Version
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrimaryAction}
|
||||
style={primaryButtonStyle}
|
||||
>
|
||||
{getPrimaryButtonLabel(payload)}
|
||||
</button>
|
||||
<select
|
||||
value={String(reminderDelayMs)}
|
||||
onChange={(event) => {
|
||||
setReminderDelayMs(Number.parseInt(event.target.value, 10));
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
{REMINDER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLater}
|
||||
style={subtleButtonStyle}
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,6 +149,7 @@ import {
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_WEBCAM_OVERLAY,
|
||||
DEFAULT_WEBCAM_TIME_OFFSET_MS,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
DEFAULT_ZOOM_IN_DURATION_MS,
|
||||
DEFAULT_ZOOM_IN_EASING,
|
||||
@@ -1569,6 +1570,7 @@ export default function VideoEditor() {
|
||||
await window.electronAPI.setCurrentRecordingSession?.({
|
||||
videoPath: sourcePath,
|
||||
webcamPath: normalizedEditor.webcam.sourcePath,
|
||||
timeOffsetMs: normalizedEditor.webcam.timeOffsetMs,
|
||||
});
|
||||
} else {
|
||||
await window.electronAPI.setCurrentVideoPath(sourcePath);
|
||||
@@ -1695,7 +1697,7 @@ export default function VideoEditor() {
|
||||
}, [currentPersistedEditorState, currentSourcePath, lastSavedSnapshot?.projectId]);
|
||||
|
||||
const syncRecordingSessionWebcam = useCallback(
|
||||
async (webcamPath: string | null) => {
|
||||
async (webcamPath: string | null, timeOffsetMs?: number) => {
|
||||
if (!currentSourcePath || !window.electronAPI.setCurrentRecordingSession) {
|
||||
return;
|
||||
}
|
||||
@@ -1703,9 +1705,15 @@ export default function VideoEditor() {
|
||||
await window.electronAPI.setCurrentRecordingSession({
|
||||
videoPath: currentSourcePath,
|
||||
webcamPath,
|
||||
timeOffsetMs:
|
||||
webcamPath && Number.isFinite(timeOffsetMs)
|
||||
? (timeOffsetMs ?? DEFAULT_WEBCAM_TIME_OFFSET_MS)
|
||||
: webcamPath
|
||||
? webcam.timeOffsetMs
|
||||
: DEFAULT_WEBCAM_TIME_OFFSET_MS,
|
||||
});
|
||||
},
|
||||
[currentSourcePath],
|
||||
[currentSourcePath, webcam.timeOffsetMs],
|
||||
);
|
||||
|
||||
const syncActiveVideoSource = useCallback(
|
||||
@@ -1714,13 +1722,14 @@ export default function VideoEditor() {
|
||||
await window.electronAPI.setCurrentRecordingSession?.({
|
||||
videoPath: sourcePath,
|
||||
webcamPath,
|
||||
timeOffsetMs: webcam.timeOffsetMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await window.electronAPI.setCurrentVideoPath(sourcePath);
|
||||
},
|
||||
[],
|
||||
[webcam.timeOffsetMs],
|
||||
);
|
||||
|
||||
const handleUploadWebcam = useCallback(async () => {
|
||||
@@ -1733,9 +1742,10 @@ export default function VideoEditor() {
|
||||
...prev,
|
||||
enabled: true,
|
||||
sourcePath: result.path ?? null,
|
||||
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
|
||||
}));
|
||||
|
||||
await syncRecordingSessionWebcam(result.path);
|
||||
await syncRecordingSessionWebcam(result.path, DEFAULT_WEBCAM_TIME_OFFSET_MS);
|
||||
toast.success(t("settings.effects.webcamFootageAdded"));
|
||||
}, [syncRecordingSessionWebcam, t]);
|
||||
|
||||
@@ -1744,6 +1754,7 @@ export default function VideoEditor() {
|
||||
...prev,
|
||||
enabled: false,
|
||||
sourcePath: null,
|
||||
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
|
||||
}));
|
||||
|
||||
await syncRecordingSessionWebcam(null);
|
||||
@@ -1838,6 +1849,7 @@ export default function VideoEditor() {
|
||||
...prev,
|
||||
enabled: !!smokeWebcamSourcePath,
|
||||
sourcePath: smokeWebcamSourcePath,
|
||||
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
|
||||
shadow:
|
||||
smokeExportConfig.webcamShadow === undefined
|
||||
? prev.shadow
|
||||
@@ -1896,6 +1908,8 @@ export default function VideoEditor() {
|
||||
...prev,
|
||||
enabled: Boolean(sessionResult.session?.webcamPath),
|
||||
sourcePath: sessionResult.session?.webcamPath ?? null,
|
||||
timeOffsetMs:
|
||||
sessionResult.session?.timeOffsetMs ?? DEFAULT_WEBCAM_TIME_OFFSET_MS,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@@ -1913,6 +1927,7 @@ export default function VideoEditor() {
|
||||
...prev,
|
||||
enabled: false,
|
||||
sourcePath: null,
|
||||
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
|
||||
}));
|
||||
} else {
|
||||
setError("No video to load. Please record or select a video.");
|
||||
@@ -3594,14 +3609,14 @@ export default function VideoEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
for (const audioPath of previewSourceAudioFallbackPaths) {
|
||||
let audio = existing.get(audioPath);
|
||||
if (!audio) {
|
||||
audio = new Audio();
|
||||
audio.preload = "auto";
|
||||
existing.set(audioPath, audio);
|
||||
}
|
||||
audio.dataset.sourceAudioPath = audioPath;
|
||||
for (const audioPath of previewSourceAudioFallbackPaths) {
|
||||
let audio = existing.get(audioPath);
|
||||
if (!audio) {
|
||||
audio = new Audio();
|
||||
audio.preload = "auto";
|
||||
existing.set(audioPath, audio);
|
||||
}
|
||||
audio.dataset.sourceAudioPath = audioPath;
|
||||
|
||||
if (sourceAudioElementResourcesRef.current.get(audioPath) !== audioPath) {
|
||||
audio.pause();
|
||||
@@ -5476,7 +5491,8 @@ export default function VideoEditor() {
|
||||
audioRegions.length > 0
|
||||
? Math.max(
|
||||
...audioRegions.map(
|
||||
(region) => region.trackIndex ?? 0,
|
||||
(region) =>
|
||||
region.trackIndex ?? 0,
|
||||
),
|
||||
) + 1
|
||||
: 0;
|
||||
|
||||
@@ -101,6 +101,7 @@ import {
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_SMOOTHING,
|
||||
DEFAULT_CURSOR_SWAY,
|
||||
DEFAULT_PADDING,
|
||||
DEFAULT_WEBCAM_CORNER_RADIUS,
|
||||
DEFAULT_WEBCAM_REACT_TO_ZOOM,
|
||||
DEFAULT_WEBCAM_SHADOW,
|
||||
@@ -110,7 +111,6 @@ import {
|
||||
DEFAULT_ZOOM_IN_OVERLAP_MS,
|
||||
DEFAULT_ZOOM_OUT_DURATION_MS,
|
||||
DEFAULT_ZOOM_OUT_EASING,
|
||||
DEFAULT_PADDING,
|
||||
getDefaultCaptionFontFamily,
|
||||
} from "./types";
|
||||
import {
|
||||
@@ -124,6 +124,7 @@ import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focu
|
||||
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
|
||||
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
|
||||
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
|
||||
import { getWebcamPreviewTargetTimeSeconds } from "./videoPlayback/webcamSync";
|
||||
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
|
||||
import {
|
||||
applyZoomTransform,
|
||||
@@ -1232,10 +1233,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTime = clampMediaTimeToDuration(
|
||||
const targetTime = getWebcamPreviewTargetTimeSeconds({
|
||||
currentTime,
|
||||
Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null,
|
||||
);
|
||||
webcamDuration: Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null,
|
||||
timeOffsetMs: webcam.timeOffsetMs,
|
||||
});
|
||||
|
||||
const activeSpeedRegion = speedRegionsRef.current.find(
|
||||
(region) => targetTime * 1000 >= region.startMs && targetTime * 1000 < region.endMs,
|
||||
@@ -1470,7 +1472,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
layoutVideoContent();
|
||||
video.pause();
|
||||
|
||||
const { handlePlay, handlePause, handleSeeked, handleSeeking } =
|
||||
const { handlePlay, handlePause, handleSeeked, handleSeeking, dispose } =
|
||||
createVideoEventHandlers({
|
||||
video,
|
||||
isSeekingRef,
|
||||
@@ -1496,10 +1498,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
video.removeEventListener("ended", handlePause);
|
||||
video.removeEventListener("seeked", handleSeeked);
|
||||
video.removeEventListener("seeking", handleSeeking);
|
||||
|
||||
if (timeUpdateAnimationRef.current) {
|
||||
cancelAnimationFrame(timeUpdateAnimationRef.current);
|
||||
}
|
||||
dispose();
|
||||
|
||||
if (videoSprite) {
|
||||
videoContainer.removeChild(videoSprite);
|
||||
@@ -1749,9 +1748,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
// through an intermediate RenderTexture at renderer resolution, which
|
||||
// downsamples the native video and degrades preview quality.
|
||||
// Hysteresis prevents flickering when motionIntensity oscillates near threshold.
|
||||
const filtersActive = Array.isArray(videoContainer.filters) && videoContainer.filters.length > 0;
|
||||
const cameraIsMoving = filtersActive ? motionIntensity > 0.002 : motionIntensity > 0.008;
|
||||
const needsFilters = zoomMotionBlurRef.current > 0 && isPlayingRef.current && cameraIsMoving;
|
||||
const filtersActive =
|
||||
Array.isArray(videoContainer.filters) && videoContainer.filters.length > 0;
|
||||
const cameraIsMoving = filtersActive
|
||||
? motionIntensity > 0.002
|
||||
: motionIntensity > 0.008;
|
||||
const needsFilters =
|
||||
zoomMotionBlurRef.current > 0 && isPlayingRef.current && cameraIsMoving;
|
||||
if (needsFilters && !filtersActive && motionBlurFilterRef.current) {
|
||||
videoContainer.filters = [motionBlurFilterRef.current];
|
||||
} else if (!needsFilters && filtersActive) {
|
||||
@@ -2451,12 +2454,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{/* Keep the source video off-screen instead of display:none so the
|
||||
browser continues producing presented frames for Pixi and preview sync. */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoPath}
|
||||
className="hidden"
|
||||
className="pointer-events-none absolute left-0 top-0 h-px w-px opacity-0"
|
||||
preload="metadata"
|
||||
playsInline
|
||||
aria-hidden="true"
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onDurationChange={(e) => {
|
||||
onDurationChange(e.currentTarget.duration);
|
||||
|
||||
@@ -350,10 +350,10 @@ export interface Padding {
|
||||
}
|
||||
|
||||
export const DEFAULT_PADDING: Padding = {
|
||||
top: 50,
|
||||
bottom: 50,
|
||||
left: 50,
|
||||
right: 50,
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
linked: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/extensions", () => ({
|
||||
extensionHost: {
|
||||
emitEvent: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { extensionHost } from "@/lib/extensions";
|
||||
import { createVideoEventHandlers } from "./videoEventHandlers";
|
||||
|
||||
type PresentedFrameCallback = (now: DOMHighResTimeStamp, metadata: { mediaTime?: number }) => void;
|
||||
|
||||
type MockVideo = HTMLVideoElement & {
|
||||
requestVideoFrameCallback?: (callback: PresentedFrameCallback) => number;
|
||||
cancelVideoFrameCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
function createMutableRef<T>(value: T) {
|
||||
return { current: value };
|
||||
}
|
||||
|
||||
function createMockVideo(overrides: Partial<MockVideo> = {}): MockVideo {
|
||||
const video = {
|
||||
currentTime: 0.5,
|
||||
duration: 10,
|
||||
paused: false,
|
||||
ended: false,
|
||||
playbackRate: 1,
|
||||
pause: vi.fn(),
|
||||
} as unknown as MockVideo;
|
||||
|
||||
return Object.assign(video, overrides);
|
||||
}
|
||||
|
||||
describe("createVideoEventHandlers", () => {
|
||||
const emitEventMock = vi.mocked(extensionHost.emitEvent);
|
||||
let requestAnimationFrameMock: ReturnType<typeof vi.fn>;
|
||||
let cancelAnimationFrameMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
requestAnimationFrameMock = vi.fn(() => 11);
|
||||
cancelAnimationFrameMock = vi.fn();
|
||||
vi.stubGlobal("requestAnimationFrame", requestAnimationFrameMock);
|
||||
vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrameMock);
|
||||
emitEventMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("prefers requestVideoFrameCallback mediaTime when available", () => {
|
||||
let presentedFrameCallback: PresentedFrameCallback | null = null;
|
||||
const video = createMockVideo({
|
||||
requestVideoFrameCallback: vi.fn((callback) => {
|
||||
presentedFrameCallback = callback;
|
||||
return 7;
|
||||
}),
|
||||
cancelVideoFrameCallback: vi.fn(),
|
||||
});
|
||||
const onPlayStateChange = vi.fn();
|
||||
const onTimeUpdate = vi.fn();
|
||||
const currentTimeRef = createMutableRef(0);
|
||||
const timeUpdateAnimationRef = createMutableRef<number | null>(null);
|
||||
|
||||
const handlers = createVideoEventHandlers({
|
||||
video,
|
||||
isSeekingRef: createMutableRef(false),
|
||||
isPlayingRef: createMutableRef(false),
|
||||
allowPlaybackRef: createMutableRef(true),
|
||||
currentTimeRef,
|
||||
timeUpdateAnimationRef,
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
trimRegionsRef: createMutableRef([]),
|
||||
speedRegionsRef: createMutableRef([]),
|
||||
});
|
||||
|
||||
handlers.handlePlay();
|
||||
expect(onPlayStateChange).toHaveBeenCalledWith(true);
|
||||
expect(video.requestVideoFrameCallback).toHaveBeenCalledTimes(1);
|
||||
expect(requestAnimationFrameMock).not.toHaveBeenCalled();
|
||||
|
||||
presentedFrameCallback?.(0, { mediaTime: 1.25 });
|
||||
|
||||
expect(onTimeUpdate).toHaveBeenCalledWith(1.25);
|
||||
expect(currentTimeRef.current).toBe(1250);
|
||||
expect(emitEventMock).toHaveBeenLastCalledWith({
|
||||
type: "playback:timeupdate",
|
||||
timeMs: 1250,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to requestAnimationFrame when requestVideoFrameCallback is unavailable", () => {
|
||||
let animationFrameCallback: FrameRequestCallback | null = null;
|
||||
requestAnimationFrameMock.mockImplementation((callback: FrameRequestCallback) => {
|
||||
animationFrameCallback = callback;
|
||||
return 19;
|
||||
});
|
||||
const video = createMockVideo({ currentTime: 0.75 });
|
||||
const onTimeUpdate = vi.fn();
|
||||
|
||||
const handlers = createVideoEventHandlers({
|
||||
video,
|
||||
isSeekingRef: createMutableRef(false),
|
||||
isPlayingRef: createMutableRef(false),
|
||||
allowPlaybackRef: createMutableRef(true),
|
||||
currentTimeRef: createMutableRef(0),
|
||||
timeUpdateAnimationRef: createMutableRef<number | null>(null),
|
||||
onPlayStateChange: vi.fn(),
|
||||
onTimeUpdate,
|
||||
trimRegionsRef: createMutableRef([]),
|
||||
speedRegionsRef: createMutableRef([]),
|
||||
});
|
||||
|
||||
handlers.handlePlay();
|
||||
expect(requestAnimationFrameMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
video.paused = true;
|
||||
animationFrameCallback?.(0);
|
||||
|
||||
expect(onTimeUpdate).toHaveBeenCalledWith(0.75);
|
||||
});
|
||||
|
||||
it("cancels a pending requestVideoFrameCallback on pause and dispose", () => {
|
||||
const cancelVideoFrameCallback = vi.fn();
|
||||
const video = createMockVideo({
|
||||
requestVideoFrameCallback: vi.fn(() => 23),
|
||||
cancelVideoFrameCallback,
|
||||
});
|
||||
const handlers = createVideoEventHandlers({
|
||||
video,
|
||||
isSeekingRef: createMutableRef(false),
|
||||
isPlayingRef: createMutableRef(false),
|
||||
allowPlaybackRef: createMutableRef(true),
|
||||
currentTimeRef: createMutableRef(0),
|
||||
timeUpdateAnimationRef: createMutableRef<number | null>(null),
|
||||
onPlayStateChange: vi.fn(),
|
||||
onTimeUpdate: vi.fn(),
|
||||
trimRegionsRef: createMutableRef([]),
|
||||
speedRegionsRef: createMutableRef([]),
|
||||
});
|
||||
|
||||
handlers.handlePlay();
|
||||
handlers.handlePause();
|
||||
expect(cancelVideoFrameCallback).toHaveBeenCalledWith(23);
|
||||
|
||||
cancelVideoFrameCallback.mockClear();
|
||||
handlers.handlePlay();
|
||||
handlers.dispose();
|
||||
expect(cancelVideoFrameCallback).toHaveBeenCalledWith(23);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,17 @@ import type React from "react";
|
||||
import { extensionHost } from "@/lib/extensions";
|
||||
import type { SpeedRegion, TrimRegion } from "../types";
|
||||
|
||||
interface PresentedFrameMetadata {
|
||||
mediaTime?: number;
|
||||
}
|
||||
|
||||
type PresentedFrameVideoElement = HTMLVideoElement & {
|
||||
requestVideoFrameCallback?: (
|
||||
callback: (now: DOMHighResTimeStamp, metadata: PresentedFrameMetadata) => void,
|
||||
) => number;
|
||||
cancelVideoFrameCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
interface VideoEventHandlersParams {
|
||||
video: HTMLVideoElement;
|
||||
isSeekingRef: React.MutableRefObject<boolean>;
|
||||
@@ -28,6 +39,8 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
trimRegionsRef,
|
||||
speedRegionsRef,
|
||||
} = params;
|
||||
const presentedFrameVideo = video as PresentedFrameVideoElement;
|
||||
let videoFrameRequestId: number | null = null;
|
||||
|
||||
const emitTime = (timeValue: number) => {
|
||||
currentTimeRef.current = timeValue * 1000;
|
||||
@@ -66,10 +79,54 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
}
|
||||
};
|
||||
|
||||
function updateTime() {
|
||||
const cancelScheduledUpdate = () => {
|
||||
if (timeUpdateAnimationRef.current !== null) {
|
||||
cancelAnimationFrame(timeUpdateAnimationRef.current);
|
||||
timeUpdateAnimationRef.current = null;
|
||||
}
|
||||
|
||||
if (
|
||||
videoFrameRequestId !== null &&
|
||||
typeof presentedFrameVideo.cancelVideoFrameCallback === "function"
|
||||
) {
|
||||
presentedFrameVideo.cancelVideoFrameCallback(videoFrameRequestId);
|
||||
videoFrameRequestId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleNextUpdate = () => {
|
||||
if (video.paused || video.ended) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Align editor state with the frame Chromium actually presented instead of
|
||||
// polling `currentTime` on a generic animation frame.
|
||||
if (typeof presentedFrameVideo.requestVideoFrameCallback === "function") {
|
||||
videoFrameRequestId = presentedFrameVideo.requestVideoFrameCallback(
|
||||
(_now, metadata) => {
|
||||
videoFrameRequestId = null;
|
||||
updateTime(metadata);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
timeUpdateAnimationRef.current = requestAnimationFrame(() => {
|
||||
timeUpdateAnimationRef.current = null;
|
||||
updateTime();
|
||||
});
|
||||
};
|
||||
|
||||
function getPresentedTime(metadata?: PresentedFrameMetadata): number {
|
||||
const mediaTime = metadata?.mediaTime;
|
||||
return Number.isFinite(mediaTime) ? (mediaTime ?? 0) : video.currentTime;
|
||||
}
|
||||
|
||||
function updateTime(metadata?: PresentedFrameMetadata) {
|
||||
if (!video) return;
|
||||
|
||||
const currentTimeMs = video.currentTime * 1000;
|
||||
const presentedTime = getPresentedTime(metadata);
|
||||
const currentTimeMs = presentedTime * 1000;
|
||||
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
|
||||
|
||||
// If we're in a trim region during playback, skip to the end of it
|
||||
@@ -79,12 +136,10 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
// Apply playback speed from active speed region
|
||||
const activeSpeedRegion = findActiveSpeedRegion(currentTimeMs);
|
||||
video.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
|
||||
emitTime(video.currentTime);
|
||||
emitTime(presentedTime);
|
||||
}
|
||||
|
||||
if (!video.paused && !video.ended) {
|
||||
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
|
||||
}
|
||||
scheduleNextUpdate();
|
||||
}
|
||||
|
||||
const handlePlay = () => {
|
||||
@@ -95,19 +150,14 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
|
||||
isPlayingRef.current = true;
|
||||
onPlayStateChange(true);
|
||||
if (timeUpdateAnimationRef.current) {
|
||||
cancelAnimationFrame(timeUpdateAnimationRef.current);
|
||||
}
|
||||
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
|
||||
cancelScheduledUpdate();
|
||||
scheduleNextUpdate();
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
isPlayingRef.current = false;
|
||||
onPlayStateChange(false);
|
||||
if (timeUpdateAnimationRef.current) {
|
||||
cancelAnimationFrame(timeUpdateAnimationRef.current);
|
||||
timeUpdateAnimationRef.current = null;
|
||||
}
|
||||
cancelScheduledUpdate();
|
||||
emitTime(video.currentTime);
|
||||
};
|
||||
|
||||
@@ -131,6 +181,7 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
};
|
||||
|
||||
return {
|
||||
dispose: cancelScheduledUpdate,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleSeeked,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getWebcamPreviewTargetTimeSeconds } from "./webcamSync";
|
||||
|
||||
describe("getWebcamPreviewTargetTimeSeconds", () => {
|
||||
it("applies positive webcam offsets", () => {
|
||||
expect(
|
||||
getWebcamPreviewTargetTimeSeconds({
|
||||
currentTime: 10,
|
||||
webcamDuration: 20,
|
||||
timeOffsetMs: 250,
|
||||
}),
|
||||
).toBe(10.25);
|
||||
});
|
||||
|
||||
it("clamps negative webcam offsets to zero", () => {
|
||||
expect(
|
||||
getWebcamPreviewTargetTimeSeconds({
|
||||
currentTime: 0.1,
|
||||
webcamDuration: 20,
|
||||
timeOffsetMs: -250,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back to the unshifted time when the offset is invalid", () => {
|
||||
expect(
|
||||
getWebcamPreviewTargetTimeSeconds({
|
||||
currentTime: 3.5,
|
||||
webcamDuration: 20,
|
||||
timeOffsetMs: Number.NaN,
|
||||
}),
|
||||
).toBe(3.5);
|
||||
});
|
||||
|
||||
it("clamps to the webcam duration", () => {
|
||||
expect(
|
||||
getWebcamPreviewTargetTimeSeconds({
|
||||
currentTime: 8.9,
|
||||
webcamDuration: 9,
|
||||
timeOffsetMs: 500,
|
||||
}),
|
||||
).toBe(9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { clampMediaTimeToDuration } from "@/lib/mediaTiming";
|
||||
|
||||
export function getWebcamPreviewTargetTimeSeconds({
|
||||
currentTime,
|
||||
webcamDuration,
|
||||
timeOffsetMs,
|
||||
}: {
|
||||
currentTime: number;
|
||||
webcamDuration?: number | null;
|
||||
timeOffsetMs?: number | null;
|
||||
}): number {
|
||||
const safeOffsetMs = Number.isFinite(timeOffsetMs) ? (timeOffsetMs ?? 0) : 0;
|
||||
const shiftedTime = currentTime + safeOffsetMs / 1000;
|
||||
return clampMediaTimeToDuration(shiftedTime, webcamDuration);
|
||||
}
|
||||
@@ -59,6 +59,7 @@ type DesktopCaptureMediaDevices = {
|
||||
type UseScreenRecorderReturn = {
|
||||
recording: boolean;
|
||||
paused: boolean;
|
||||
finalizing: boolean;
|
||||
countdownActive: boolean;
|
||||
toggleRecording: () => void;
|
||||
pauseRecording: () => void;
|
||||
@@ -114,6 +115,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [finalizing, setFinalizing] = useState(false);
|
||||
const [countdownActive, setCountdownActive] = useState(false);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
||||
@@ -149,36 +151,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const accumulatedPausedDurationMs = useRef(0);
|
||||
const pauseStartedAtMs = useRef<number | null>(null);
|
||||
const pauseSegmentsRef = useRef<PauseSegment[]>([]);
|
||||
const recordingFinalizationToastId = useRef<string | number | null>(null);
|
||||
const micFallbackRecorder = useRef<MediaRecorder | null>(null);
|
||||
const micFallbackChunks = useRef<Blob[]>([]);
|
||||
const micFallbackStartDelayMs = useRef<number | null>(null);
|
||||
|
||||
const showRecordingFinalizationToast = useCallback((message = "Preparing recording...") => {
|
||||
recordingFinalizationToastId.current = toast.loading(message, {
|
||||
id: recordingFinalizationToastId.current ?? undefined,
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
const notifyRecordingFinalizationFailure = useCallback(async (message: string) => {
|
||||
setFinalizing(false);
|
||||
toast.error(message, { duration: 10000 });
|
||||
}, []);
|
||||
|
||||
const clearRecordingFinalizationToast = useCallback(() => {
|
||||
const toastId = recordingFinalizationToastId.current;
|
||||
if (toastId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
recordingFinalizationToastId.current = null;
|
||||
}, []);
|
||||
|
||||
const notifyRecordingFinalizationFailure = useCallback(
|
||||
async (message: string) => {
|
||||
clearRecordingFinalizationToast();
|
||||
toast.error(message, { duration: 10000 });
|
||||
},
|
||||
[clearRecordingFinalizationToast],
|
||||
);
|
||||
|
||||
const logNativeCaptureDiagnostics = useCallback(async (context: string) => {
|
||||
if (typeof window.electronAPI?.getLastNativeCaptureDiagnostics !== "function") {
|
||||
return;
|
||||
@@ -436,10 +417,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
}
|
||||
|
||||
clearRecordingFinalizationToast();
|
||||
setFinalizing(false);
|
||||
await window.electronAPI.switchToEditor();
|
||||
},
|
||||
[clearRecordingFinalizationToast],
|
||||
[],
|
||||
);
|
||||
|
||||
const stopMicFallbackRecorder = useCallback((): Promise<Blob | null> => {
|
||||
@@ -690,9 +671,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
if (nativeScreenRecording.current) {
|
||||
nativeScreenRecording.current = false;
|
||||
setRecording(false);
|
||||
setFinalizing(true);
|
||||
|
||||
void (async () => {
|
||||
showRecordingFinalizationToast();
|
||||
const fallbackStartDelayMs = micFallbackStartDelayMs.current;
|
||||
const micFallbackBlobPromise = stopMicFallbackRecorder();
|
||||
const webcamPath = await stopWebcamRecorder();
|
||||
@@ -787,6 +768,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
cleanupCapturedMedia();
|
||||
recorder.stop();
|
||||
setRecording(false);
|
||||
setFinalizing(true);
|
||||
window.electronAPI?.setRecordingState(false);
|
||||
}
|
||||
});
|
||||
@@ -1341,9 +1323,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
};
|
||||
recorder.onstop = async () => {
|
||||
cleanupCapturedMedia();
|
||||
if (chunks.current.length === 0) return;
|
||||
|
||||
showRecordingFinalizationToast();
|
||||
if (chunks.current.length === 0) {
|
||||
setFinalizing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = getRecordingDurationMs(Date.now());
|
||||
const recordedChunks = chunks.current;
|
||||
@@ -1530,7 +1513,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}, [cleanupCapturedMedia, markRecordingResumed, recording]);
|
||||
|
||||
const toggleRecording = async () => {
|
||||
if (starting || countdownActive) {
|
||||
if (starting || countdownActive || finalizing) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1558,6 +1541,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
return {
|
||||
recording,
|
||||
paused,
|
||||
finalizing,
|
||||
countdownActive,
|
||||
toggleRecording,
|
||||
pauseRecording,
|
||||
|
||||
Reference in New Issue
Block a user