From 624c3047beb3656b8127b8ab821e85bd88636a27 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:34:55 +1000 Subject: [PATCH] Improve HUD and update prompt UI --- electron/electron-env.d.ts | 14 +- electron/main.ts | 28 +- electron/preload.ts | 16 +- electron/updater.ts | 143 +++-- electron/windows.ts | 4 +- src/components/launch/LaunchWindow.module.css | 103 ++-- src/components/launch/LaunchWindow.tsx | 147 +---- src/components/launch/UpdateToastWindow.tsx | 528 ++++++++---------- src/components/video-editor/types.ts | 8 +- src/hooks/useScreenRecorder.ts | 46 +- 10 files changed, 471 insertions(+), 566 deletions(-) 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/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/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/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/types.ts b/src/components/video-editor/types.ts index 56750da3..170d48f3 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -350,10 +350,10 @@ export interface Padding { } export const DEFAULT_PADDING: Padding = { - top: 50, - bottom: 50, - left: 50, - right: 50, + top: 20, + bottom: 20, + left: 20, + right: 20, linked: true, }; diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index cb41930e..3709fa84 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -59,6 +59,7 @@ type DesktopCaptureMediaDevices = { type UseScreenRecorderReturn = { recording: boolean; paused: boolean; + finalizing: boolean; countdownActive: boolean; toggleRecording: () => void; pauseRecording: () => void; @@ -114,6 +115,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const [recording, setRecording] = useState(false); const [paused, setPaused] = useState(false); const [starting, setStarting] = useState(false); + const [finalizing, setFinalizing] = useState(false); const [countdownActive, setCountdownActive] = useState(false); const [isMacOS, setIsMacOS] = useState(false); const [microphoneEnabled, setMicrophoneEnabled] = useState(false); @@ -149,36 +151,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const accumulatedPausedDurationMs = useRef(0); const pauseStartedAtMs = useRef(null); const pauseSegmentsRef = useRef([]); - const recordingFinalizationToastId = useRef(null); const micFallbackRecorder = useRef(null); const micFallbackChunks = useRef([]); const micFallbackStartDelayMs = useRef(null); - const showRecordingFinalizationToast = useCallback((message = "Preparing recording...") => { - recordingFinalizationToastId.current = toast.loading(message, { - id: recordingFinalizationToastId.current ?? undefined, - duration: Number.POSITIVE_INFINITY, - }); + const notifyRecordingFinalizationFailure = useCallback(async (message: string) => { + setFinalizing(false); + toast.error(message, { duration: 10000 }); }, []); - const clearRecordingFinalizationToast = useCallback(() => { - const toastId = recordingFinalizationToastId.current; - if (toastId === null) { - return; - } - - toast.dismiss(toastId); - recordingFinalizationToastId.current = null; - }, []); - - const notifyRecordingFinalizationFailure = useCallback( - async (message: string) => { - clearRecordingFinalizationToast(); - toast.error(message, { duration: 10000 }); - }, - [clearRecordingFinalizationToast], - ); - const logNativeCaptureDiagnostics = useCallback(async (context: string) => { if (typeof window.electronAPI?.getLastNativeCaptureDiagnostics !== "function") { return; @@ -436,10 +417,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } - clearRecordingFinalizationToast(); + setFinalizing(false); await window.electronAPI.switchToEditor(); }, - [clearRecordingFinalizationToast], + [], ); const stopMicFallbackRecorder = useCallback((): Promise => { @@ -690,9 +671,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (nativeScreenRecording.current) { nativeScreenRecording.current = false; setRecording(false); + setFinalizing(true); void (async () => { - showRecordingFinalizationToast(); const fallbackStartDelayMs = micFallbackStartDelayMs.current; const micFallbackBlobPromise = stopMicFallbackRecorder(); const webcamPath = await stopWebcamRecorder(); @@ -787,6 +768,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { cleanupCapturedMedia(); recorder.stop(); setRecording(false); + setFinalizing(true); window.electronAPI?.setRecordingState(false); } }); @@ -1341,9 +1323,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; recorder.onstop = async () => { cleanupCapturedMedia(); - if (chunks.current.length === 0) return; - - showRecordingFinalizationToast(); + if (chunks.current.length === 0) { + setFinalizing(false); + return; + } const duration = getRecordingDurationMs(Date.now()); const recordedChunks = chunks.current; @@ -1530,7 +1513,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }, [cleanupCapturedMedia, markRecordingResumed, recording]); const toggleRecording = async () => { - if (starting || countdownActive) { + if (starting || countdownActive || finalizing) { return; } @@ -1558,6 +1541,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return { recording, paused, + finalizing, countdownActive, toggleRecording, pauseRecording,