diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index cc013c59..5c5bfb47 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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; @@ -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; diff --git a/electron/ipc/ffmpeg/filters.test.ts b/electron/ipc/ffmpeg/filters.test.ts index deab8017..3569d729 100644 --- a/electron/ipc/ffmpeg/filters.test.ts +++ b/electron/ipc/ffmpeg/filters.test.ts @@ -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, + }); + }); }); diff --git a/electron/ipc/ffmpeg/filters.ts b/electron/ipc/ffmpeg/filters.ts index ce973f16..e6fa1253 100644 --- a/electron/ipc/ffmpeg/filters.ts +++ b/electron/ipc/ffmpeg/filters.ts @@ -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}]`); } diff --git a/electron/ipc/recording/diagnostics.test.ts b/electron/ipc/recording/diagnostics.test.ts index 9a775339..dca8f3fc 100644 --- a/electron/ipc/recording/diagnostics.test.ts +++ b/electron/ipc/recording/diagnostics.test.ts @@ -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)); diff --git a/electron/ipc/recording/diagnostics.ts b/electron/ipc/recording/diagnostics.ts index 4588631e..fb7b6c4a 100644 --- a/electron/ipc/recording/diagnostics.ts +++ b/electron/ipc/recording/diagnostics.ts @@ -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), ), }; } diff --git a/electron/ipc/recording/windows.ts b/electron/ipc/recording/windows.ts index 4a32f86c..e0336413 100644 --- a/electron/ipc/recording/windows.ts +++ b/electron/ipc/recording/windows.ts @@ -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 { 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), + ]); } } } diff --git a/electron/ipc/types.ts b/electron/ipc/types.ts index 5922fb0e..7c7de9c1 100644 --- a/electron/ipc/types.ts +++ b/electron/ipc/types.ts @@ -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; diff --git a/electron/main.ts b/electron/main.ts index c792ba3e..7607913b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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) => { diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json index dbae2bae..0ae7bb77 100644 --- a/electron/native/bin/win32-x64/helpers-manifest.json +++ b/electron/native/bin/win32-x64/helpers-manifest.json @@ -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" + } + } } diff --git a/electron/native/bin/win32-x64/wgc-capture.exe b/electron/native/bin/win32-x64/wgc-capture.exe index e5d0df8f..d16a0724 100644 Binary files a/electron/native/bin/win32-x64/wgc-capture.exe and b/electron/native/bin/win32-x64/wgc-capture.exe differ diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index c949e8a2..7fca95b8 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -14,6 +14,7 @@ #include #include #include +#include static std::atomic g_stopRequested{false}; static std::atomic 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 frameCount{0}; + std::atomic firstVideoTimestampHns{-1}; std::atomic 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(); diff --git a/electron/native/wgc-capture/src/wasapi_loopback.cpp b/electron/native/wgc-capture/src/wasapi_loopback.cpp index 1659d013..e4008def 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback.cpp +++ b/electron/native/wgc-capture/src/wasapi_loopback.cpp @@ -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(qpcPosition)); + } + UINT32 totalSamples = numFrames * channels; if (flags & AUDCLNT_BUFFERFLAGS_SILENT) { diff --git a/electron/native/wgc-capture/src/wasapi_loopback.h b/electron/native/wgc-capture/src/wasapi_loopback.h index a4facf13..1792a863 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback.h +++ b/electron/native/wgc-capture/src/wasapi_loopback.h @@ -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 firstPacketQpcHns_{-1}; + std::atomic dataDiscontinuityCount_{0}; + std::atomic timestampErrorCount_{0}; }; diff --git a/electron/preload.ts b/electron/preload.ts index 937e00d0..e6537ec4 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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); diff --git a/electron/updater.ts b/electron/updater.ts index c81ba7bb..23eb65e5 100644 --- a/electron/updater.ts +++ b/electron/updater.ts @@ -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)) ) { diff --git a/electron/windows.ts b/electron/windows.ts index b1b1ae21..6f51f742 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -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; diff --git a/package-lock.json b/package-lock.json index 5500762b..deb22a46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@biomejs/biome": "2.3.13", + "@electron/rebuild": "^4.0.3", "@fix-webm-duration/fix": "^1.0.1", "@pixi/filter-drop-shadow": "^5.2.0", "@pixi/filter-motion-blur": "^5.1.1", @@ -46,7 +47,6 @@ "electron": "^39.2.7", "electron-builder": "^26.7.0", "electron-icon-builder": "^2.0.1", - "electron-rebuild": "^3.2.9", "emoji-picker-react": "^4.16.1", "fast-check": "^4.5.2", "fix-webm-duration": "^1.0.6", @@ -1921,13 +1921,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true, - "license": "MIT" - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2814,35 +2807,6 @@ "node": ">= 14" } }, - "node_modules/@npmcli/fs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", - "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@gar/promisify": "^1.1.3", - "semver": "^7.3.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/@npmcli/move-file": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", - "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", - "deprecated": "This functionality has been moved to @npmcli/fs", - "dev": true, - "license": "MIT", - "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/@phosphor-icons/react": { "version": "2.1.10", "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", @@ -4333,16 +4297,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4806,13 +4760,6 @@ "dev": true, "license": "MIT" }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true, - "license": "ISC" - }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -4851,19 +4798,6 @@ "node": ">= 6.0.0" } }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -5283,28 +5217,6 @@ "node": ">=18" } }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "dev": true, - "license": "ISC" - }, - "node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", - "deprecated": "This package is no longer supported.", - "dev": true, - "license": "ISC", - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -5866,100 +5778,6 @@ "node": ">= 10.0.0" } }, - "node_modules/cacache": { - "version": "16.1.3", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", - "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^2.1.0", - "@npmcli/move-file": "^2.0.0", - "chownr": "^2.0.0", - "fs-minipass": "^2.1.0", - "glob": "^8.0.1", - "infer-owner": "^1.0.4", - "lru-cache": "^7.7.1", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "mkdirp": "^1.0.4", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^9.0.0", - "tar": "^6.1.11", - "unique-filename": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/cacache/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/cacache/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cacache/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cacache/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -6155,16 +5973,6 @@ "node": ">= 6" } }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/chromium-pickle-js": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", @@ -6333,16 +6141,6 @@ "dev": true, "license": "MIT" }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true, - "license": "ISC", - "bin": { - "color-support": "bin.js" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -6432,13 +6230,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "dev": true, - "license": "ISC" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -6670,13 +6461,6 @@ "node": ">=0.4.0" } }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "dev": true, - "license": "MIT" - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -7118,74 +6902,6 @@ "node": ">= 10.0.0" } }, - "node_modules/electron-rebuild": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/electron-rebuild/-/electron-rebuild-3.2.9.tgz", - "integrity": "sha512-FkEZNFViUem3P0RLYbZkUjC8LUFIK+wKq09GHoOITSJjfDAVQv964hwaNseTTWt58sITQX3/5fHNYcTefqaCWw==", - "deprecated": "Please use @electron/rebuild moving forward. There is no API change, just a package name change", - "dev": true, - "license": "MIT", - "dependencies": { - "@malept/cross-spawn-promise": "^2.0.0", - "chalk": "^4.0.0", - "debug": "^4.1.1", - "detect-libc": "^2.0.1", - "fs-extra": "^10.0.0", - "got": "^11.7.0", - "lzma-native": "^8.0.5", - "node-abi": "^3.0.0", - "node-api-version": "^0.1.4", - "node-gyp": "^9.0.0", - "ora": "^5.1.0", - "semver": "^7.3.5", - "tar": "^6.0.5", - "yargs": "^17.0.1" - }, - "bin": { - "electron-rebuild": "lib/src/cli.js" - }, - "engines": { - "node": ">=12.13.0" - } - }, - "node_modules/electron-rebuild/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-rebuild/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-rebuild/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.234", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.234.tgz", @@ -7958,39 +7674,6 @@ "node": ">=6 <7 || >=8" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs-minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -8023,34 +7706,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", - "deprecated": "This package is no longer supported.", - "dev": true, - "license": "ISC", - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/gauge/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -8432,13 +8087,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "dev": true, - "license": "ISC" - }, "node_modules/hasha": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/hasha/-/hasha-2.2.0.tgz", @@ -8506,21 +8154,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/http-response-object": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", @@ -8588,16 +8221,6 @@ "node": ">=10.17.0" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/icon-gen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/icon-gen/-/icon-gen-2.1.0.tgz", @@ -8738,13 +8361,6 @@ "node": ">=8" } }, - "node_modules/infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true, - "license": "ISC" - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -8869,13 +8485,6 @@ "node": ">=8" } }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -9401,32 +9010,6 @@ "yallist": "^3.0.2" } }, - "node_modules/lzma-native": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/lzma-native/-/lzma-native-8.0.6.tgz", - "integrity": "sha512-09xfg67mkL2Lz20PrrDeNYZxzeW7ADtpYFbwSQh9U8+76RIzx5QsJBMy8qikv3hbUPfpy6hqwxt6FcGK81g9AA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^3.1.0", - "node-gyp-build": "^4.2.1", - "readable-stream": "^3.6.0" - }, - "bin": { - "lzmajs": "bin/lzmajs" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/lzma-native/node_modules/node-addon-api": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", - "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", - "dev": true, - "license": "MIT" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -9437,64 +9020,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/make-fetch-happen": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", - "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", - "dev": true, - "license": "ISC", - "dependencies": { - "agentkeepalive": "^4.2.1", - "cacache": "^16.1.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^7.7.1", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^2.0.3", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^7.0.0", - "ssri": "^9.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/make-fetch-happen/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/make-fetch-happen/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/make-fetch-happen/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -9675,77 +9200,6 @@ "node": ">=8" } }, - "node_modules/minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-collect/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-collect/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-fetch": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", - "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.1.6", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-fetch/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-fetch/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/minipass-flush": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", @@ -9845,40 +9299,6 @@ "dev": true, "license": "ISC" }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -9993,29 +9413,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-abi": { - "version": "3.78.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.78.0.tgz", - "integrity": "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-addon-api": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", @@ -10024,42 +9421,6 @@ "license": "MIT", "optional": true }, - "node_modules/node-api-version": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.1.4.tgz", - "integrity": "sha512-KGXihXdUChwJAOHO53bv9/vXcLmdUsZ6jIptbvYvkpKfth+r7jw44JkVxQFA3kX5nQjzjmGu1uAu/xNNLNlI5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - } - }, - "node_modules/node-gyp": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", - "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "glob": "^7.1.4", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^10.0.3", - "nopt": "^6.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^12.13 || ^14.13 || >=16" - } - }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -10078,22 +9439,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nopt": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", - "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^1.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -10169,23 +9514,6 @@ "node": ">=8" } }, - "node_modules/npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", - "deprecated": "This package is no longer supported.", - "dev": true, - "license": "ISC", - "dependencies": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", @@ -11059,13 +10387,6 @@ "node": ">=0.4.0" } }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true, - "license": "ISC" - }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", @@ -12196,21 +11517,6 @@ "npm": ">= 3.0.0" } }, - "node_modules/socks-proxy-agent": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", - "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -12323,39 +11629,6 @@ "node": ">=0.10.0" } }, - "node_modules/ssri": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", - "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.1.1" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/ssri/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ssri/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -12798,31 +12071,6 @@ "tailwindcss": ">=3.0.0 || insiders" } }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/temp": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", @@ -13249,32 +12497,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unique-filename": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", - "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^3.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/unique-slug": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", - "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -14243,16 +13465,6 @@ "node": ">=8" } }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 204d494a..6fac6f9d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 230f24b3..59ab66c1 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -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 { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 33fa5740..8f48fd79 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -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(null); const [appVersion, setAppVersion] = useState(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(null); const hudContentRef = useRef(null); const hudBarRef = useRef(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 ; - case "checking": - case "downloading": - return ; - default: - return ; - } - })(); - - 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 = ( <>
@@ -1238,6 +1126,18 @@ export function LaunchWindow() { ); + const finalizingControls = ( +
+ +
+ {t("recording.preparing", "Preparing recording")} + {t("recording.preparingSubtitle", "Opening the editor in a moment")} +
+
+ ); + + const hudMode = finalizing ? "finalizing" : recording ? "recording" : "idle"; + return (
- -
- {recording ? recordingControls : idleControls} + {finalizing + ? finalizingControls + : recording + ? recordingControls + : idleControls}
diff --git a/src/components/launch/UpdateToastWindow.tsx b/src/components/launch/UpdateToastWindow.tsx index da37bfa1..2f2f89d4 100644 --- a/src/components/launch/UpdateToastWindow.tsx +++ b/src/components/launch/UpdateToastWindow.tsx @@ -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 ; + case "downloading": + return ; + case "ready": + return ; + case "error": + return ; } } export function UpdateToastWindow() { const [payload, setPayload] = useState(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 ( -
-
-
- -
-
-

Checking for updates

-

- Waiting for updater state from the main process. -

-
-
-
- ); - } - - 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
; + } + return (
-
{ - 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); - }} - > -
- {payload.phase === "available" ? : null} - {payload.phase === "downloading" ? ( - - ) : null} - {payload.phase === "ready" ? : null} - {payload.phase === "error" ? : null} -
+
+
{getPhaseIcon(payload)}

{getToastTitle(payload)}

@@ -318,14 +274,14 @@ export function UpdateToastWindow() { Dev @@ -335,104 +291,102 @@ export function UpdateToastWindow() {

{payload.detail}

{payload.phase === "downloading" ? ( -
+
-

- {normalizedProgress}% downloaded -

+ + {normalizedProgress}% complete + + {phaseStats.map((stat) => ( + + {stat.label}: {stat.value} + + ))} +
) : null} -
- {primaryActionLabel ? ( - - ) : null} - - {payload.phase === "downloading" ? ( - - ) : null} - +
{payload.phase !== "downloading" ? ( - - ) : null} - - {payload.phase !== "downloading" ? ( - - ) : null} - - {!payload.isPreview && payload.phase !== "downloading" ? ( - + <> + + + + ) : null}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 794dba45..bc2c01ef 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -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; diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 3cee42fb..edab1ff2 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -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( 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( layoutVideoContent(); video.pause(); - const { handlePlay, handlePause, handleSeeked, handleSeeking } = + const { handlePlay, handlePause, handleSeeked, handleSeeking, dispose } = createVideoEventHandlers({ video, isSeekingRef, @@ -1496,10 +1498,7 @@ const VideoPlayback = forwardRef( 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( // 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( })()}
)} + {/* Keep the source video off-screen instead of display:none so the + browser continues producing presented frames for Pixi and preview sync. */}