Merge pull request #344 from webadderallorg/fix/hud-update-prompt-ui

Improve HUD and update prompt UI
This commit is contained in:
webadderall
2026-04-25 21:53:27 +10:00
committed by GitHub
10 changed files with 471 additions and 566 deletions
+9 -5
View File
@@ -50,7 +50,11 @@ interface UpdateToastState {
delayMs: number;
isPreview?: boolean;
progressPercent?: number;
primaryAction?: "download-update" | "install-update" | "retry-check";
transferredBytes?: number;
totalBytes?: number;
remainingBytes?: number;
bytesPerSecond?: number;
primaryAction?: "install-and-restart" | "retry-check";
}
interface UpdateStatusSummary {
@@ -286,9 +290,7 @@ interface Window {
error?: string;
}>;
discardExportedTemp: (tempPath: string) => Promise<{ success: boolean; error?: string }>;
getVideoAudioFallbackPaths: (
videoPath: string,
) => Promise<{
getVideoAudioFallbackPaths: (videoPath: string) => Promise<{
success: boolean;
paths: string[];
startDelayMsByPath?: Record<string, number>;
@@ -486,7 +488,9 @@ interface Window {
error?: string;
}>;
installDownloadedUpdate: () => Promise<{ success: boolean }>;
downloadAvailableUpdate: () => Promise<{ success: boolean; message?: string }>;
downloadAvailableUpdate: (
installAfterDownload?: boolean,
) => Promise<{ success: boolean; message?: string }>;
deferDownloadedUpdate: (delayMs?: number) => Promise<{
success: boolean;
message?: string;
+20 -8
View File
@@ -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) => {
+12 -4
View File
@@ -504,8 +504,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
installDownloadedUpdate: () => {
return ipcRenderer.invoke("install-downloaded-update");
},
downloadAvailableUpdate: () => {
return ipcRenderer.invoke("download-available-update");
downloadAvailableUpdate: (installAfterDownload?: boolean) => {
return ipcRenderer.invoke("download-available-update", installAfterDownload);
},
deferDownloadedUpdate: (delayMs?: number) => {
return ipcRenderer.invoke("defer-downloaded-update", delayMs);
@@ -537,7 +537,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
delayMs: number;
isPreview?: boolean;
progressPercent?: number;
primaryAction?: "download-update" | "install-update" | "retry-check";
transferredBytes?: number;
totalBytes?: number;
remainingBytes?: number;
bytesPerSecond?: number;
primaryAction?: "install-and-restart" | "retry-check";
} | null,
) => void,
) => {
@@ -550,7 +554,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
delayMs: number;
isPreview?: boolean;
progressPercent?: number;
primaryAction?: "download-update" | "install-update" | "retry-check";
transferredBytes?: number;
totalBytes?: number;
remainingBytes?: number;
bytesPerSecond?: number;
primaryAction?: "install-and-restart" | "retry-check";
} | null,
) => callback(payload);
ipcRenderer.on("update-toast-state", listener);
+109 -34
View File
@@ -17,6 +17,7 @@ const UPDATER_LOG_PATH =
const DEV_UPDATE_PREVIEW_VERSION = "9.9.9";
const DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS = 300;
const DEV_UPDATE_PREVIEW_PROGRESS_INCREMENT = 20;
const ONE_MEGABYTE = 1024 * 1024;
export type UpdateToastPhase = "available" | "downloading" | "ready" | "error";
@@ -43,7 +44,18 @@ export interface UpdateToastPayload {
delayMs: number;
isPreview?: boolean;
progressPercent?: number;
primaryAction?: "download-update" | "install-update" | "retry-check";
transferredBytes?: number;
totalBytes?: number;
remainingBytes?: number;
bytesPerSecond?: number;
primaryAction?: "install-and-restart" | "retry-check";
}
interface DownloadProgressSnapshot {
progressPercent?: number;
transferredBytes?: number;
totalBytes?: number;
bytesPerSecond?: number;
}
type UpdateToastSender = (
@@ -64,6 +76,7 @@ let downloadInProgress = false;
let downloadToastDismissed = false;
let skippedVersion: string | null = null;
let updateCheckErrorHandled = false;
let installAfterDownloadRequested = false;
let activeUpdateToastSender: UpdateToastSender | undefined;
let updateStatusSummary: UpdateStatusSummary = {
status: "idle",
@@ -180,26 +193,54 @@ function createAvailableUpdateToastPayload(version: string): UpdateToastPayload
return {
version,
phase: "available",
detail: "A new version is available. Download it now, or wait and we will remind you again in 3 hours.",
detail: "Install the latest version now, or remind yourself to come back to it later.",
delayMs: UPDATE_REMINDER_DELAY_MS,
primaryAction: "download-update",
primaryAction: "install-and-restart",
};
}
function createDownloadingUpdateToastPayload(
version: string,
progressPercent = 0,
progress: DownloadProgressSnapshot = {},
): UpdateToastPayload {
const normalizedProgress = Math.max(0, Math.min(100, progressPercent));
const normalizedProgress = Math.max(
0,
Math.min(100, Math.round(progress.progressPercent ?? 0)),
);
const transferredBytes =
typeof progress.transferredBytes === "number" && Number.isFinite(progress.transferredBytes)
? Math.max(0, progress.transferredBytes)
: undefined;
const totalBytes =
typeof progress.totalBytes === "number" && Number.isFinite(progress.totalBytes)
? Math.max(0, progress.totalBytes)
: undefined;
const remainingBytes =
totalBytes !== undefined && transferredBytes !== undefined
? Math.max(totalBytes - transferredBytes, 0)
: undefined;
const bytesPerSecond =
typeof progress.bytesPerSecond === "number" && Number.isFinite(progress.bytesPerSecond)
? Math.max(0, progress.bytesPerSecond)
: undefined;
const remainingMb =
remainingBytes !== undefined ? Math.max(0, remainingBytes / ONE_MEGABYTE) : null;
return {
version,
phase: "downloading",
detail:
normalizedProgress >= 100
? "Finishing the update download. You can keep using Recordly while this completes."
: `Downloading the update in the foreground: ${normalizedProgress.toFixed(0)}% complete.`,
? "Finishing the update download. Recordly will restart as soon as the installer is ready."
: remainingMb !== null
? `${remainingMb.toFixed(1)} MB left before Recordly restarts.`
: "Downloading the update now. Recordly will restart when it finishes.",
delayMs: UPDATE_REMINDER_DELAY_MS,
progressPercent: normalizedProgress,
transferredBytes,
totalBytes,
remainingBytes,
bytesPerSecond,
primaryAction: "install-and-restart",
};
}
@@ -207,9 +248,9 @@ function createDownloadedUpdateToastPayload(version: string): UpdateToastPayload
return {
version,
phase: "ready",
detail: "Install now to restart into the new version, or wait and we will remind you again in 3 hours.",
detail: "The update is ready. Install and restart now, or remind yourself later.",
delayMs: UPDATE_REMINDER_DELAY_MS,
primaryAction: "install-update",
primaryAction: "install-and-restart",
};
}
@@ -217,9 +258,9 @@ function createUpdateErrorToastPayload(version: string, error: unknown): UpdateT
return {
version,
phase: "error",
detail: `The update download failed. ${String(error)}`,
detail: `The update could not be downloaded. ${String(error)}`,
delayMs: UPDATE_REMINDER_DELAY_MS,
primaryAction: "download-update",
primaryAction: "install-and-restart",
};
}
@@ -276,6 +317,7 @@ function resetDevPreviewState(sendToRenderer?: UpdateToastSender) {
downloadInProgress = false;
downloadToastDismissed = false;
skippedVersion = null;
installAfterDownloadRequested = false;
clearVisibleUpdateToast(sendToRenderer);
}
@@ -289,7 +331,12 @@ function simulateDevPreviewDownload(sendToRenderer?: UpdateToastSender) {
let progressPercent = 0;
emitUpdateToastState(sendToRenderer, {
...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, progressPercent),
...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, {
progressPercent,
transferredBytes: 0,
totalBytes: 20 * ONE_MEGABYTE,
bytesPerSecond: 5 * ONE_MEGABYTE,
}),
isPreview: true,
});
@@ -313,7 +360,12 @@ function simulateDevPreviewDownload(sendToRenderer?: UpdateToastSender) {
}
emitUpdateToastState(sendToRenderer, {
...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, progressPercent),
...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, {
progressPercent,
transferredBytes: (progressPercent / 100) * 20 * ONE_MEGABYTE,
totalBytes: 20 * ONE_MEGABYTE,
bytesPerSecond: 5 * ONE_MEGABYTE,
}),
isPreview: true,
});
}, DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS);
@@ -331,6 +383,7 @@ export function dismissUpdateToast(
}
if (downloadInProgress) {
installAfterDownloadRequested = false;
downloadToastDismissed = true;
clearVisibleUpdateToast(sendToRenderer);
return { success: true };
@@ -360,13 +413,17 @@ export function installDownloadedUpdateNow(sendToRenderer?: UpdateToastSender) {
clearDeferredReminderTimer();
downloadToastDismissed = false;
installAfterDownloadRequested = false;
clearVisibleUpdateToast(sendToRenderer);
setUpdateStatusSummary({ status: "ready", availableVersion: pendingDownloadedVersion });
writeUpdaterLog("Installing downloaded update.");
autoUpdater.quitAndInstall();
}
export async function downloadAvailableUpdate(sendToRenderer?: UpdateToastSender) {
export async function downloadAvailableUpdate(
sendToRenderer?: UpdateToastSender,
options?: { installAfterDownload?: boolean },
) {
if (currentToastPayload?.isPreview) {
return simulateDevPreviewDownload(sendToRenderer);
}
@@ -386,12 +443,20 @@ export async function downloadAvailableUpdate(sendToRenderer?: UpdateToastSender
clearDeferredReminderTimer();
downloadInProgress = true;
downloadToastDismissed = false;
installAfterDownloadRequested =
Boolean(options?.installAfterDownload) || installAfterDownloadRequested;
setUpdateStatusSummary({
status: "downloading",
availableVersion,
detail: `Downloading Recordly ${availableVersion}`,
});
emitUpdateToastState(sendToRenderer, createDownloadingUpdateToastPayload(availableVersion, 0));
emitUpdateToastState(
sendToRenderer,
createDownloadingUpdateToastPayload(availableVersion, {
progressPercent: 0,
transferredBytes: 0,
}),
);
writeUpdaterLog(`Starting update download for ${availableVersion}.`);
try {
@@ -425,6 +490,7 @@ export function deferUpdateReminder(
}
clearDeferredReminderTimer();
installAfterDownloadRequested = false;
clearVisibleUpdateToast(sendToRenderer);
deferredReminderTimer = setTimeout(() => {
const nextPayload = getReminderPayload();
@@ -462,6 +528,7 @@ export function skipAvailableUpdateVersion(sendToRenderer?: UpdateToastSender) {
}
downloadInProgress = false;
downloadToastDismissed = false;
installAfterDownloadRequested = false;
clearDeferredReminderTimer();
clearVisibleUpdateToast(sendToRenderer);
@@ -475,6 +542,7 @@ export function previewUpdateToast(sendToRenderer: UpdateToastSender) {
pendingDownloadedVersion = null;
downloadInProgress = false;
downloadToastDismissed = false;
installAfterDownloadRequested = false;
return emitUpdateToastState(sendToRenderer, {
version: DEV_UPDATE_PREVIEW_VERSION,
phase: "available",
@@ -493,24 +561,19 @@ async function showAvailableUpdateDialog(
type: "info",
title: "Update Available",
message: `Recordly ${version} is available.`,
detail: "Download now, remind me in 3 hours, or skip this version.",
buttons: ["Download Update", "Remind Me in 3 Hours", "Skip This Version"],
detail: "Install and restart now, or remind me later.",
buttons: ["Install & Restart", "Later"],
defaultId: 0,
cancelId: 1,
noLink: true,
});
if (result.response === 0) {
await downloadAvailableUpdate(sendToRenderer);
await downloadAvailableUpdate(sendToRenderer, { installAfterDownload: true });
return;
}
if (result.response === 1) {
deferUpdateReminder(getMainWindow, sendToRenderer, UPDATE_REMINDER_DELAY_MS);
return;
}
skipAvailableUpdateVersion(sendToRenderer);
deferUpdateReminder(getMainWindow, sendToRenderer, UPDATE_REMINDER_DELAY_MS);
}
async function showDownloadedUpdateDialog(
@@ -527,8 +590,8 @@ async function showDownloadedUpdateDialog(
: `Recordly ${version} has been downloaded.`,
detail: isPreview
? "Development preview of the native update prompt. No real update will be installed."
: "Install now, remind me in 3 hours, or skip this version.",
buttons: ["Install Update", "Remind Me in 3 Hours", "Skip This Version"],
: "Install and restart now, or remind me later.",
buttons: ["Install & Restart", "Later"],
defaultId: 0,
cancelId: 1,
noLink: true,
@@ -558,14 +621,7 @@ async function showDownloadedUpdateDialog(
}
deferUpdateReminder(getMainWindow, undefined, UPDATE_REMINDER_DELAY_MS);
return;
}
if (isPreview) {
return;
}
skipAvailableUpdateVersion();
}
export async function checkForAppUpdates(
@@ -665,6 +721,7 @@ export function setupAutoUpdates(
pendingDownloadedVersion = null;
downloadInProgress = false;
downloadToastDismissed = false;
installAfterDownloadRequested = false;
setUpdateStatusSummary({
status: "available",
availableVersion: info.version,
@@ -694,6 +751,7 @@ export function setupAutoUpdates(
pendingDownloadedVersion = null;
downloadInProgress = false;
downloadToastDismissed = false;
installAfterDownloadRequested = false;
setUpdateStatusSummary({
status: "up-to-date",
availableVersion: null,
@@ -727,7 +785,12 @@ export function setupAutoUpdates(
emitUpdateToastState(
sendToRenderer,
createDownloadingUpdateToastPayload(availableVersion, progress.percent),
createDownloadingUpdateToastPayload(availableVersion, {
progressPercent: progress.percent,
transferredBytes: progress.transferred,
totalBytes: progress.total,
bytesPerSecond: progress.bytesPerSecond,
}),
);
});
@@ -748,6 +811,7 @@ export function setupAutoUpdates(
if (downloadInProgress && availableVersion) {
downloadInProgress = false;
downloadToastDismissed = false;
installAfterDownloadRequested = false;
emitUpdateToastState(
sendToRenderer,
createUpdateErrorToastPayload(availableVersion, error),
@@ -767,6 +831,7 @@ export function setupAutoUpdates(
downloadInProgress = false;
downloadToastDismissed = false;
if (skippedVersion === info.version) {
installAfterDownloadRequested = false;
return;
}
availableVersion = info.version;
@@ -778,6 +843,16 @@ export function setupAutoUpdates(
});
clearDeferredReminderTimer();
if (installAfterDownloadRequested && !currentToastPayload?.isPreview) {
installAfterDownloadRequested = false;
clearVisibleUpdateToast(sendToRenderer);
writeUpdaterLog(`Auto-installing downloaded update: version=${info.version}`);
setImmediate(() => {
installDownloadedUpdateNow(sendToRenderer);
});
return;
}
if (
emitUpdateToastState(sendToRenderer, createDownloadedUpdateToastPayload(info.version))
) {
+2 -2
View File
@@ -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;
+40 -63
View File
@@ -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 {
+19 -128
View File
@@ -1,7 +1,5 @@
import {
AppWindow,
ArrowCircleUp as ArrowUpCircle,
CheckCircle as CheckCircle2,
CaretUp as ChevronUp,
Eye,
EyeSlash as EyeOff,
@@ -165,6 +163,7 @@ export function LaunchWindow() {
const {
recording,
paused,
finalizing,
countdownActive,
toggleRecording,
pauseRecording,
@@ -208,24 +207,6 @@ export function LaunchWindow() {
>(null);
const [platform, setPlatform] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string | null>(null);
const [updateStatus, setUpdateStatus] = useState<{
status:
| "idle"
| "checking"
| "up-to-date"
| "available"
| "downloading"
| "ready"
| "error";
currentVersion: string;
availableVersion: string | null;
detail?: string;
}>({
status: "idle",
currentVersion: "",
availableVersion: null,
});
const [updateActionPending, setUpdateActionPending] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const hudContentRef = useRef<HTMLDivElement>(null);
const hudBarRef = useRef<HTMLDivElement>(null);
@@ -714,31 +695,6 @@ export function LaunchWindow() {
void preparePermissions({ startup: true });
}, [preparePermissions]);
useEffect(() => {
let mounted = true;
const refreshUpdateStatus = async () => {
try {
const summary = await window.electronAPI.getUpdateStatusSummary();
if (mounted) {
setUpdateStatus(summary);
}
} catch (error) {
console.error("Failed to load update status summary:", error);
}
};
void refreshUpdateStatus();
const pollTimer = window.setInterval(() => {
void refreshUpdateStatus();
}, 2500);
return () => {
mounted = false;
window.clearInterval(pollTimer);
};
}, []);
useEffect(() => {
let cancelled = false;
const loadVersion = async () => {
@@ -1009,74 +965,6 @@ export function LaunchWindow() {
toggleDropdown("webcam");
};
const updateButtonLabel =
updateStatus.status === "up-to-date"
? t("recording.update.updated")
: t("recording.update.update");
const updateButtonTitle = (() => {
switch (updateStatus.status) {
case "up-to-date":
return t("recording.update.upToDateTitle", "Recordly {{version}} is up to date.", {
version: updateStatus.currentVersion,
});
case "available":
case "ready":
return updateStatus.availableVersion
? t("recording.update.availableTitle", "Recordly {{version}} is available.", {
version: updateStatus.availableVersion,
})
: t("recording.update.availableGenericTitle");
case "downloading":
return updateStatus.detail ?? t("recording.update.downloadingTitle");
case "checking":
return t("recording.update.checkingTitle");
case "error":
return updateStatus.detail ?? t("recording.update.errorTitle");
default:
return t("recording.update.idleTitle");
}
})();
const updateButtonClassName = `${styles.updateBadge} ${updateStatus.status === "up-to-date" ? styles.updateBadgeQuiet : styles.updateBadgeHot} ${styles.electronNoDrag}`;
const updateButtonIcon = (() => {
switch (updateStatus.status) {
case "up-to-date":
return <CheckCircle2 size={14} />;
case "checking":
case "downloading":
return <RefreshCw size={14} className={styles.updateBadgeSpin} />;
default:
return <ArrowUpCircle size={14} />;
}
})();
const handleUpdateButtonClick = async () => {
if (updateActionPending || updateStatus.status === "downloading") {
return;
}
setUpdateActionPending(true);
try {
switch (updateStatus.status) {
case "available":
await window.electronAPI.downloadAvailableUpdate();
break;
case "ready":
await window.electronAPI.installDownloadedUpdate();
break;
default:
await window.electronAPI.checkForAppUpdates();
break;
}
const summary = await window.electronAPI.getUpdateStatusSummary();
setUpdateStatus(summary);
} catch (error) {
console.error("Failed to handle update button action:", error);
} finally {
setUpdateActionPending(false);
}
};
const recordingControls = (
<>
<div className="flex items-center gap-[5px]">
@@ -1238,6 +1126,18 @@ export function LaunchWindow() {
</>
);
const finalizingControls = (
<div className={styles.finalizingState}>
<RefreshCw size={15} className={styles.finalizingSpin} />
<div className={styles.finalizingCopy}>
<span>{t("recording.preparing", "Preparing recording")}</span>
<small>{t("recording.preparingSubtitle", "Opening the editor in a moment")}</small>
</div>
</div>
);
const hudMode = finalizing ? "finalizing" : recording ? "recording" : "idle";
return (
<div
className="w-full flex items-end justify-center bg-transparent overflow-visible pb-5"
@@ -1640,23 +1540,10 @@ export function LaunchWindow() {
<RxDragHandleDots2 size={14} className="text-[#6b6b78]" />
</div>
<button
type="button"
onClick={() => {
void handleUpdateButtonClick();
}}
className={updateButtonClassName}
title={updateButtonTitle}
disabled={updateActionPending}
>
{updateButtonIcon}
<span>{updateButtonLabel}</span>
</button>
<div className={styles.barStateViewport}>
<AnimatePresence initial={false} mode="wait">
<motion.div
key={recording ? "recording" : "idle"}
key={hudMode}
layout={!showRecordingWebcamPreview}
className={styles.barState}
initial={{
@@ -1679,7 +1566,11 @@ export function LaunchWindow() {
}}
transition={hudStateTransition}
>
{recording ? recordingControls : idleControls}
{finalizing
? finalizingControls
: recording
? recordingControls
: idleControls}
</motion.div>
</AnimatePresence>
</div>
+241 -287
View File
@@ -4,7 +4,7 @@ import {
Spinner as LoaderCircle,
Rocket,
} from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
type UpdateToastPayload = {
version: string;
@@ -13,61 +13,73 @@ type UpdateToastPayload = {
delayMs: number;
isPreview?: boolean;
progressPercent?: number;
primaryAction?: "download-update" | "install-update" | "retry-check";
transferredBytes?: number;
totalBytes?: number;
remainingBytes?: number;
bytesPerSecond?: number;
primaryAction?: "install-and-restart" | "retry-check";
};
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
const DEFAULT_REMINDER_DELAY_MS = 3 * 60 * 60 * 1000;
const REMINDER_OPTIONS = [
{ label: "1 hour", value: 1 * 60 * 60 * 1000 },
{ label: "3 hours", value: 3 * 60 * 60 * 1000 },
{ label: "Tomorrow", value: 24 * 60 * 60 * 1000 },
{ label: "3 days", value: 3 * 24 * 60 * 60 * 1000 },
];
function formatDelayHours(delayMs: number) {
const hours = Math.max(1, Math.round(delayMs / (60 * 60 * 1000)));
return `${hours}h`;
function formatBytes(value: number | undefined) {
if (value === undefined || !Number.isFinite(value) || value <= 0) {
return null;
}
const megabytes = value / (1024 * 1024);
if (megabytes >= 1024) {
return `${(megabytes / 1024).toFixed(1)} GB`;
}
return `${megabytes.toFixed(megabytes >= 100 ? 0 : 1)} MB`;
}
function getToastTitle(payload: UpdateToastPayload) {
if (payload.isPreview) {
return "Update Toast Preview";
return "Update Prompt Preview";
}
switch (payload.phase) {
case "available":
return `Recordly ${payload.version} is available`;
case "downloading":
return `Downloading Recordly ${payload.version}`;
return `Installing Recordly ${payload.version}`;
case "ready":
return `Recordly ${payload.version} is ready`;
case "error":
return `Recordly ${payload.version} needs attention`;
return payload.primaryAction === "retry-check"
? "Could not check for updates"
: `Recordly ${payload.version} needs attention`;
}
}
function getPrimaryActionLabel(payload: UpdateToastPayload) {
switch (payload.primaryAction) {
case "download-update":
return "Download Update";
case "install-update":
return "Install Update";
case "retry-check":
return "Retry Check";
default:
return null;
function getPrimaryButtonLabel(payload: UpdateToastPayload) {
return payload.primaryAction === "retry-check" ? "Try Again" : "Install & Restart";
}
function getPhaseIcon(payload: UpdateToastPayload) {
switch (payload.phase) {
case "available":
return <Download size={20} />;
case "downloading":
return <LoaderCircle size={20} className="animate-spin" />;
case "ready":
return <Rocket size={20} />;
case "error":
return <AlertCircle size={20} />;
}
}
export function UpdateToastWindow() {
const [payload, setPayload] = useState<UpdateToastPayload | null>(null);
const [dragOffsetX, setDragOffsetX] = useState(0);
const dragResetKey = payload
? `${payload.phase}:${payload.version}:${payload.progressPercent ?? ""}:${payload.detail}:${payload.delayMs}:${payload.isPreview ? "1" : "0"}:${payload.primaryAction ?? ""}`
: "empty";
const dragState = useRef<{
pointerId: number | null;
startX: number;
active: boolean;
}>({
pointerId: null,
startX: 0,
active: false,
});
const [reminderDelayMs, setReminderDelayMs] = useState(DEFAULT_REMINDER_DELAY_MS);
useEffect(() => {
let mounted = true;
@@ -81,11 +93,9 @@ export function UpdateToastWindow() {
pollTimer = setInterval(() => {
void window.electronAPI.getCurrentUpdateToastPayload().then((nextPayload) => {
if (!mounted || !nextPayload) {
return;
if (mounted) {
setPayload(nextPayload);
}
setPayload((currentPayload) => currentPayload ?? nextPayload);
});
}, 750);
@@ -103,214 +113,160 @@ export function UpdateToastWindow() {
}, []);
useEffect(() => {
if (!dragResetKey) {
if (!payload) {
return;
}
setDragOffsetX(0);
dragState.current = {
pointerId: null,
startX: 0,
active: false,
};
}, [dragResetKey]);
setReminderDelayMs(payload.delayMs || DEFAULT_REMINDER_DELAY_MS);
}, [payload]);
const cardStyle = {
background: "#0d1117",
border: "1px solid rgba(125, 211, 252, 0.22)",
boxShadow: "0 24px 48px rgba(0, 0, 0, 0.45)",
borderRadius: 24,
padding: 16,
color: "#ffffff",
width: "100%",
maxWidth: 404,
display: "flex",
gap: 12,
alignItems: "flex-start",
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
} as const;
const normalizedProgress = Math.max(
0,
Math.min(100, Math.round(payload?.progressPercent ?? 0)),
);
const downloadedLabel = formatBytes(payload?.transferredBytes);
const totalLabel = formatBytes(payload?.totalBytes);
const remainingLabel = formatBytes(payload?.remainingBytes);
const speedLabel = formatBytes(payload?.bytesPerSecond);
const phaseStats: Array<{ label: string; value: string }> = [];
if (payload?.phase === "downloading") {
if (downloadedLabel && totalLabel) {
phaseStats.push({ label: "Downloaded", value: `${downloadedLabel} / ${totalLabel}` });
} else if (downloadedLabel) {
phaseStats.push({ label: "Downloaded", value: downloadedLabel });
}
if (remainingLabel) {
phaseStats.push({ label: "Left", value: remainingLabel });
}
if (speedLabel) {
phaseStats.push({ label: "Speed", value: `${speedLabel}/s` });
}
}
const isMacOS = /mac/i.test(navigator.platform);
const wrapperStyle = {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
padding: 8,
padding: 10,
boxSizing: "border-box",
background: "transparent",
background: isMacOS ? "transparent" : "#0b1220",
} as const;
const secondaryTextStyle = {
color: "rgba(255, 255, 255, 0.74)",
fontSize: 14,
lineHeight: 1.45,
margin: "4px 0 0 0",
} as const;
const titleStyle = {
fontSize: 14,
fontWeight: 700,
lineHeight: 1.2,
margin: 0,
const cardStyle = {
width: "100%",
maxWidth: 440,
display: "flex",
gap: 14,
alignItems: "flex-start",
padding: "18px 18px 16px",
borderRadius: 24,
background:
"linear-gradient(180deg, rgba(12, 19, 34, 0.98) 0%, rgba(10, 17, 30, 0.98) 100%)",
border: "1px solid rgba(37, 99, 235, 0.24)",
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.5), inset 0 1px 0 rgba(148, 163, 184, 0.08)",
color: "#ffffff",
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
} as const;
const iconBoxStyle = {
width: 40,
height: 40,
minWidth: 40,
width: 42,
height: 42,
minWidth: 42,
borderRadius: 16,
background: "rgba(125, 211, 252, 0.15)",
color: "#7dd3fc",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginTop: 2,
background: "rgba(37, 99, 235, 0.16)",
color: "#60a5fa",
boxShadow: "inset 0 0 0 1px rgba(37, 99, 235, 0.18)",
} as const;
const rowStyle = {
display: "flex",
flexWrap: "wrap" as const,
gap: 8,
marginTop: 12,
const titleStyle = {
fontSize: 15,
fontWeight: 700,
lineHeight: 1.25,
margin: 0,
color: "#f8fafc",
} as const;
const secondaryTextStyle = {
color: "rgba(226, 232, 240, 0.78)",
fontSize: 13,
lineHeight: 1.5,
margin: "6px 0 0 0",
} as const;
const subtleButtonStyle = {
border: "1px solid rgba(255, 255, 255, 0.1)",
background: "rgba(255, 255, 255, 0.05)",
color: "rgba(255, 255, 255, 0.92)",
height: 38,
borderRadius: 12,
padding: "8px 12px",
fontSize: 12,
padding: "0 14px",
border: "1px solid rgba(148, 163, 184, 0.16)",
background: "rgba(15, 23, 42, 0.72)",
color: "#e2e8f0",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
transition: "all 0.15s ease",
} as const;
const primaryButtonStyle = {
...subtleButtonStyle,
background: "#7dd3fc",
color: "#031a2c",
border: "none",
background: "linear-gradient(180deg, #3b82f6 0%, #2563eb 100%)",
color: "#ffffff",
boxShadow: "0 12px 24px rgba(37, 99, 235, 0.26)",
} as const;
const ghostButtonStyle = {
...subtleButtonStyle,
background: "transparent",
color: "rgba(255, 255, 255, 0.72)",
border: "1px solid rgba(125, 211, 252, 0.16)",
const selectStyle = {
height: 38,
borderRadius: 12,
padding: "0 34px 0 12px",
border: "1px solid rgba(37, 99, 235, 0.22)",
background:
"linear-gradient(180deg, rgba(18, 29, 51, 0.96) 0%, rgba(12, 22, 42, 0.96) 100%)",
color: "#dbeafe",
fontSize: 13,
fontWeight: 600,
outline: "none",
boxShadow: "inset 0 0 0 1px rgba(37, 99, 235, 0.06)",
cursor: "pointer",
} as const;
if (!payload) {
return (
<div style={wrapperStyle}>
<div style={{ ...cardStyle, alignItems: "center" }}>
<div style={iconBoxStyle}>
<LoaderCircle size={20} />
</div>
<div>
<p style={titleStyle}>Checking for updates</p>
<p style={secondaryTextStyle}>
Waiting for updater state from the main process.
</p>
</div>
</div>
</div>
);
}
const normalizedProgress = Math.max(0, Math.min(100, Math.round(payload.progressPercent ?? 0)));
const primaryActionLabel = getPrimaryActionLabel(payload);
const swipeThreshold = 96;
const handleSwipeDismiss = async () => {
setDragOffsetX(0);
dragState.current = {
pointerId: null,
startX: 0,
active: false,
};
await window.electronAPI.dismissUpdateToast();
};
const handlePrimaryAction = async () => {
switch (payload.primaryAction) {
case "download-update":
await window.electronAPI.downloadAvailableUpdate();
return;
case "install-update":
await window.electronAPI.installDownloadedUpdate();
return;
case "retry-check":
await window.electronAPI.checkForAppUpdates();
return;
default:
return;
if (!payload || payload.phase === "downloading") {
return;
}
if (payload.primaryAction === "retry-check") {
await window.electronAPI.checkForAppUpdates();
return;
}
if (payload.phase === "ready") {
await window.electronAPI.installDownloadedUpdate();
return;
}
await window.electronAPI.downloadAvailableUpdate(true);
};
const handleLater = async () => {
if (!payload) {
return;
}
if (payload.isPreview) {
await window.electronAPI.dismissUpdateToast();
return;
}
await window.electronAPI.deferDownloadedUpdate(reminderDelayMs);
};
if (!payload) {
return <div style={wrapperStyle} />;
}
return (
<div style={wrapperStyle}>
<div
className="pointer-events-auto select-none"
style={{
...cardStyle,
transform: `translateX(${dragOffsetX}px) rotate(${dragOffsetX / 30}deg)`,
opacity: Math.max(0.35, 1 - Math.min(1, Math.abs(dragOffsetX) / 180)),
}}
onPointerDown={(event) => {
const target = event.target as HTMLElement | null;
if (target?.closest("button")) {
return;
}
dragState.current = {
pointerId: event.pointerId,
startX: event.clientX,
active: true,
};
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
if (
!dragState.current.active ||
dragState.current.pointerId !== event.pointerId
) {
return;
}
setDragOffsetX(event.clientX - dragState.current.startX);
}}
onPointerUp={async (event) => {
if (
!dragState.current.active ||
dragState.current.pointerId !== event.pointerId
) {
return;
}
const nextOffset = event.clientX - dragState.current.startX;
dragState.current = {
pointerId: null,
startX: 0,
active: false,
};
if (Math.abs(nextOffset) >= swipeThreshold) {
await handleSwipeDismiss();
return;
}
setDragOffsetX(0);
}}
onPointerCancel={() => {
dragState.current = {
pointerId: null,
startX: 0,
active: false,
};
setDragOffsetX(0);
}}
>
<div style={iconBoxStyle}>
{payload.phase === "available" ? <Download size={20} /> : null}
{payload.phase === "downloading" ? (
<LoaderCircle size={20} className="animate-spin" />
) : null}
{payload.phase === "ready" ? <Rocket size={20} /> : null}
{payload.phase === "error" ? <AlertCircle size={20} /> : null}
</div>
<div style={cardStyle}>
<div style={iconBoxStyle}>{getPhaseIcon(payload)}</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<p style={titleStyle}>{getToastTitle(payload)}</p>
@@ -318,14 +274,14 @@ export function UpdateToastWindow() {
<span
style={{
borderRadius: 999,
border: "1px solid rgba(125, 211, 252, 0.2)",
background: "rgba(125, 211, 252, 0.1)",
padding: "2px 8px",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.18em",
textTransform: "uppercase",
color: "#bae6fd",
color: "#93c5fd",
background: "rgba(37, 99, 235, 0.14)",
border: "1px solid rgba(37, 99, 235, 0.18)",
}}
>
Dev
@@ -335,104 +291,102 @@ export function UpdateToastWindow() {
<p style={secondaryTextStyle}>{payload.detail}</p>
{payload.phase === "downloading" ? (
<div style={{ marginTop: 12 }}>
<div style={{ marginTop: 14 }}>
<div
style={{
height: 8,
height: 10,
overflow: "hidden",
borderRadius: 999,
background: "rgba(255, 255, 255, 0.1)",
background: "rgba(148, 163, 184, 0.14)",
}}
>
<div
style={{
height: "100%",
borderRadius: 999,
background: "#7dd3fc",
width: `${normalizedProgress}%`,
borderRadius: 999,
background:
"linear-gradient(90deg, #60a5fa 0%, #2563eb 45%, #1d4ed8 100%)",
boxShadow: "0 0 22px rgba(37, 99, 235, 0.38)",
}}
/>
</div>
<p
<div
style={{
margin: "8px 0 0 0",
color: "rgba(224, 242, 254, 0.9)",
fontSize: 12,
fontWeight: 600,
display: "flex",
flexWrap: "wrap",
gap: 8,
marginTop: 10,
}}
>
{normalizedProgress}% downloaded
</p>
<span
style={{
fontSize: 12,
fontWeight: 700,
color: "#dbeafe",
}}
>
{normalizedProgress}% complete
</span>
{phaseStats.map((stat) => (
<span
key={stat.label}
style={{
fontSize: 11,
fontWeight: 600,
color: "rgba(191, 219, 254, 0.9)",
background: "rgba(37, 99, 235, 0.12)",
borderRadius: 999,
padding: "4px 8px",
border: "1px solid rgba(37, 99, 235, 0.16)",
}}
>
{stat.label}: {stat.value}
</span>
))}
</div>
</div>
) : null}
<div style={rowStyle}>
{primaryActionLabel ? (
<button
type="button"
onClick={handlePrimaryAction}
style={primaryButtonStyle}
>
{primaryActionLabel}
</button>
) : null}
{payload.phase === "downloading" ? (
<button
type="button"
onClick={async () => {
await window.electronAPI.dismissUpdateToast();
}}
style={subtleButtonStyle}
>
Hide
</button>
) : null}
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 10,
marginTop: 14,
alignItems: "center",
}}
>
{payload.phase !== "downloading" ? (
<button
type="button"
onClick={async () => {
if (payload.isPreview) {
await window.electronAPI.dismissUpdateToast();
return;
}
await window.electronAPI.deferDownloadedUpdate(payload.delayMs);
}}
style={subtleButtonStyle}
>
Later ({formatDelayHours(payload.delayMs)})
</button>
) : null}
{payload.phase !== "downloading" ? (
<button
type="button"
onClick={async () => {
if (payload.isPreview) {
await window.electronAPI.dismissUpdateToast();
return;
}
await window.electronAPI.deferDownloadedUpdate(THREE_DAYS_MS);
}}
style={subtleButtonStyle}
>
Later (3 days)
</button>
) : null}
{!payload.isPreview && payload.phase !== "downloading" ? (
<button
type="button"
onClick={async () => {
await window.electronAPI.skipUpdateVersion();
}}
style={ghostButtonStyle}
>
Skip This Version
</button>
<>
<button
type="button"
onClick={handlePrimaryAction}
style={primaryButtonStyle}
>
{getPrimaryButtonLabel(payload)}
</button>
<select
value={String(reminderDelayMs)}
onChange={(event) => {
setReminderDelayMs(Number.parseInt(event.target.value, 10));
}}
style={selectStyle}
>
{REMINDER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button
type="button"
onClick={handleLater}
style={subtleButtonStyle}
>
Later
</button>
</>
) : null}
</div>
</div>
+4 -4
View File
@@ -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,
};
+15 -31
View File
@@ -59,6 +59,7 @@ type DesktopCaptureMediaDevices = {
type UseScreenRecorderReturn = {
recording: boolean;
paused: boolean;
finalizing: boolean;
countdownActive: boolean;
toggleRecording: () => void;
pauseRecording: () => void;
@@ -114,6 +115,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const [recording, setRecording] = useState(false);
const [paused, setPaused] = useState(false);
const [starting, setStarting] = useState(false);
const [finalizing, setFinalizing] = useState(false);
const [countdownActive, setCountdownActive] = useState(false);
const [isMacOS, setIsMacOS] = useState(false);
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
@@ -149,36 +151,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const accumulatedPausedDurationMs = useRef(0);
const pauseStartedAtMs = useRef<number | null>(null);
const pauseSegmentsRef = useRef<PauseSegment[]>([]);
const recordingFinalizationToastId = useRef<string | number | null>(null);
const micFallbackRecorder = useRef<MediaRecorder | null>(null);
const micFallbackChunks = useRef<Blob[]>([]);
const micFallbackStartDelayMs = useRef<number | null>(null);
const showRecordingFinalizationToast = useCallback((message = "Preparing recording...") => {
recordingFinalizationToastId.current = toast.loading(message, {
id: recordingFinalizationToastId.current ?? undefined,
duration: Number.POSITIVE_INFINITY,
});
const notifyRecordingFinalizationFailure = useCallback(async (message: string) => {
setFinalizing(false);
toast.error(message, { duration: 10000 });
}, []);
const clearRecordingFinalizationToast = useCallback(() => {
const toastId = recordingFinalizationToastId.current;
if (toastId === null) {
return;
}
toast.dismiss(toastId);
recordingFinalizationToastId.current = null;
}, []);
const notifyRecordingFinalizationFailure = useCallback(
async (message: string) => {
clearRecordingFinalizationToast();
toast.error(message, { duration: 10000 });
},
[clearRecordingFinalizationToast],
);
const logNativeCaptureDiagnostics = useCallback(async (context: string) => {
if (typeof window.electronAPI?.getLastNativeCaptureDiagnostics !== "function") {
return;
@@ -436,10 +417,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}
clearRecordingFinalizationToast();
setFinalizing(false);
await window.electronAPI.switchToEditor();
},
[clearRecordingFinalizationToast],
[],
);
const stopMicFallbackRecorder = useCallback((): Promise<Blob | null> => {
@@ -690,9 +671,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (nativeScreenRecording.current) {
nativeScreenRecording.current = false;
setRecording(false);
setFinalizing(true);
void (async () => {
showRecordingFinalizationToast();
const fallbackStartDelayMs = micFallbackStartDelayMs.current;
const micFallbackBlobPromise = stopMicFallbackRecorder();
const webcamPath = await stopWebcamRecorder();
@@ -787,6 +768,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
cleanupCapturedMedia();
recorder.stop();
setRecording(false);
setFinalizing(true);
window.electronAPI?.setRecordingState(false);
}
});
@@ -1341,9 +1323,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
};
recorder.onstop = async () => {
cleanupCapturedMedia();
if (chunks.current.length === 0) return;
showRecordingFinalizationToast();
if (chunks.current.length === 0) {
setFinalizing(false);
return;
}
const duration = getRecordingDurationMs(Date.now());
const recordedChunks = chunks.current;
@@ -1530,7 +1513,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}, [cleanupCapturedMedia, markRecordingResumed, recording]);
const toggleRecording = async () => {
if (starting || countdownActive) {
if (starting || countdownActive || finalizing) {
return;
}
@@ -1558,6 +1541,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return {
recording,
paused,
finalizing,
countdownActive,
toggleRecording,
pauseRecording,