feat(editor): polish launch and export UX

This commit is contained in:
webadderall
2026-04-24 13:01:10 +10:00
parent a1bce5c740
commit 3acfff3f2b
16 changed files with 435 additions and 545 deletions
+37 -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,13 @@
}
.ibActive {
color: #6360f5;
color: #60a5fa;
background: rgba(37, 99, 235, 0.12);
}
.ibActive:hover {
color: #7b78ff;
color: #93c5fd;
background: rgba(37, 99, 235, 0.18);
}
.ibRed {
@@ -274,7 +216,39 @@
}
.ddItemSelected {
color: #6360f5;
color: #60a5fa;
background: rgba(37, 99, 235, 0.12);
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.18);
}
.ddItemSelected:hover {
background: rgba(37, 99, 235, 0.18);
color: #bfdbfe;
}
.finalizingBadge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 10px;
background: rgba(37, 99, 235, 0.14);
color: #60a5fa;
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.18);
}
.finalizingSpinner {
animation: finalizingSpin 0.9s linear infinite;
}
@keyframes finalizingSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.recBtn {
+33 -133
View File
@@ -1,27 +1,25 @@
import {
AppWindow,
ArrowCircleUp as ArrowUpCircle,
ArrowClockwise as RefreshCw,
CaretUp as ChevronUp,
CheckCircle as CheckCircle2,
DotsThreeVertical as MoreVertical,
Eye,
EyeSlash as EyeOff,
FolderOpen,
Translate as Languages,
Microphone as Mic,
MicrophoneSlash as MicOff,
Minus,
Monitor,
DotsThreeVertical as MoreVertical,
Pause,
Play,
SpeakerHigh as Volume2,
SpeakerX as VolumeX,
ArrowClockwise as RefreshCw,
Stop as Square,
Timer,
Translate as Languages,
VideoCamera as Video,
VideoCamera as VideoIcon,
VideoCameraSlash as VideoOff,
SpeakerHigh as Volume2,
SpeakerX as VolumeX,
X,
} from "@phosphor-icons/react";
import { AnimatePresence, motion } from "motion/react";
@@ -157,6 +155,7 @@ export function LaunchWindow() {
const {
recording,
paused,
finalizing,
countdownActive,
toggleRecording,
pauseRecording,
@@ -197,24 +196,6 @@ export function LaunchWindow() {
const [recordingHudOffset, setRecordingHudOffset] = useState(DEFAULT_RECORDING_HUD_OFFSET);
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);
@@ -661,31 +642,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 () => {
@@ -956,74 +912,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]">
@@ -1091,6 +979,25 @@ export function LaunchWindow() {
</>
);
const finalizingControls = (
<div className="flex items-center gap-3 pr-2 text-[#eeeef2]">
<div className={styles.finalizingBadge}>
<RefreshCw size={14} className={styles.finalizingSpinner} />
</div>
<div className="flex flex-col leading-tight">
<span className="text-[12px] font-semibold tracking-[0.01em]">
{t("recording.preparing", "Preparing recording...")}
</span>
<span className="text-[10px] text-[#9aa7bd]">
{t(
"recording.preparingSubtitle",
"Recordly will open the editor when it is ready.",
)}
</span>
</div>
</div>
);
const idleControls = (
<>
{platform !== "linux" && (
@@ -1185,6 +1092,8 @@ export function LaunchWindow() {
</>
);
const hudMode = finalizing ? "finalizing" : recording ? "recording" : "idle";
return (
<div
className="w-full flex items-end justify-center bg-transparent overflow-visible pb-5"
@@ -1573,23 +1482,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={{
@@ -1612,7 +1508,11 @@ export function LaunchWindow() {
}}
transition={hudStateTransition}
>
{recording ? recordingControls : idleControls}
{hudMode === "recording"
? recordingControls
: hudMode === "finalizing"
? finalizingControls
: idleControls}
</motion.div>
</AnimatePresence>
</div>
+246 -287
View File
@@ -4,7 +4,7 @@ import {
Spinner as LoaderCircle,
Rocket,
} from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, 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,165 @@ 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 = useMemo(() => {
if (!payload || payload.phase !== "downloading") {
return [];
}
const stats: Array<{ label: string; value: string }> = [];
if (downloadedLabel && totalLabel) {
stats.push({ label: "Downloaded", value: `${downloadedLabel} / ${totalLabel}` });
} else if (downloadedLabel) {
stats.push({ label: "Downloaded", value: downloadedLabel });
}
if (remainingLabel) {
stats.push({ label: "Left", value: remainingLabel });
}
if (speedLabel) {
stats.push({ label: "Speed", value: `${speedLabel}/s` });
}
return stats;
}, [downloadedLabel, payload, remainingLabel, speedLabel, totalLabel]);
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 +279,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 +296,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>
+7 -15
View File
@@ -1,4 +1,4 @@
import { Link, LinkBreak, Palette, Trash as Trash2, UploadSimple as Upload, X } from "@phosphor-icons/react";
import { Palette, Trash as Trash2, UploadSimple as Upload, X } from "@phosphor-icons/react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -1838,23 +1838,15 @@ export function SettingsPanel({
<button
type="button"
onClick={togglePaddingLink}
className={cn(
"p-1 rounded-md transition-colors",
padding.linked !== false
? "text-[#2563EB] bg-[#2563EB]/10"
: "text-muted-foreground hover:bg-foreground/[0.05]",
)}
aria-pressed={padding.linked === false}
className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80"
title={
padding.linked !== false
? tSettings("effects.paddingLinked", "Linked (Uniform)")
: tSettings("effects.paddingUnlinked", "Unlinked (Asymmetrical)")
padding.linked === false
? tSettings("effects.paddingAdvancedHide", "Hide advanced padding controls")
: tSettings("effects.paddingAdvancedShow", "Show advanced padding controls")
}
>
{padding.linked !== false ? (
<Link size={12} weight="bold" />
) : (
<LinkBreak size={12} weight="bold" />
)}
{tSettings("effects.paddingAdvanced", "Advanced")}
</button>
</div>
+50 -37
View File
@@ -63,6 +63,7 @@ import {
VideoExporter,
} from "@/lib/exporter";
import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource";
import { extensionHost } from "@/lib/extensions";
import {
clampMediaTimeToDuration,
estimateCompanionAudioStartDelaySeconds,
@@ -75,31 +76,10 @@ import {
getAspectRatioLabel,
getAspectRatioValue,
} from "@/utils/aspectRatioUtils";
import { ExtensionIcon } from "./ExtensionIcon";
const PhCursorFill = (props: { className?: string; weight?: "fill" | "regular" }) => (
<Cursor weight="fill" className={props.className} />
);
const PhCamera = (props: { className?: string; weight?: "fill" | "regular" }) => (
<PhCameraRegular weight={props.weight ?? "regular"} className={props.className} />
);
const PhCaptions = (props: { className?: string; weight?: "fill" | "regular" }) => (
<ClosedCaptioning weight={props.weight ?? "regular"} className={props.className} />
);
const PhPuzzle = (props: { className?: string; weight?: "fill" | "regular" }) => (
<PuzzlePiece weight={props.weight ?? "regular"} className={props.className} />
);
const PhSparkle = (props: { className?: string; weight?: "fill" | "regular" }) => (
<Sparkle weight={props.weight ?? "regular"} className={props.className} />
);
const PhSettings = (props: { className?: string; weight?: "fill" | "regular" }) => (
<Gear weight={props.weight ?? "regular"} className={props.className} />
);
import { extensionHost } from "@/lib/extensions";
import { resolveAutoCaptionSourcePath } from "./autoCaptionSource";
import { CropControl } from "./CropControl";
import { ExportSettingsMenu } from "./ExportSettingsMenu";
import { ExtensionIcon } from "./ExtensionIcon";
import ExtensionManager from "./ExtensionManager";
import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences";
import ProjectBrowserDialog, { type ProjectLibraryEntry } from "./ProjectBrowserDialog";
@@ -156,6 +136,7 @@ import {
type EditorEffectSection,
type FigureData,
getClipSourceEndMs,
type Padding,
type PlaybackSpeed,
type SpeedRegion,
type TrimRegion,
@@ -172,6 +153,25 @@ import {
getDisplayedTimelineWindowMs,
} from "./videoPlayback/cursorLoopTelemetry";
const PhCursorFill = (props: { className?: string; weight?: "fill" | "regular" }) => (
<Cursor weight="fill" className={props.className} />
);
const PhCamera = (props: { className?: string; weight?: "fill" | "regular" }) => (
<PhCameraRegular weight={props.weight ?? "regular"} className={props.className} />
);
const PhCaptions = (props: { className?: string; weight?: "fill" | "regular" }) => (
<ClosedCaptioning weight={props.weight ?? "regular"} className={props.className} />
);
const PhPuzzle = (props: { className?: string; weight?: "fill" | "regular" }) => (
<PuzzlePiece weight={props.weight ?? "regular"} className={props.className} />
);
const PhSparkle = (props: { className?: string; weight?: "fill" | "regular" }) => (
<Sparkle weight={props.weight ?? "regular"} className={props.className} />
);
const PhSettings = (props: { className?: string; weight?: "fill" | "regular" }) => (
<Gear weight={props.weight ?? "regular"} className={props.className} />
);
type EditorHistorySnapshot = {
zoomRegions: ZoomRegion[];
clipRegions: ClipRegion[];
@@ -750,7 +750,9 @@ export default function VideoEditor() {
}
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
const editorBgHsl = getComputedStyle(document.documentElement).getPropertyValue("--editor-bg").trim();
const editorBgHsl = getComputedStyle(document.documentElement)
.getPropertyValue("--editor-bg")
.trim();
context.fillStyle = editorBgHsl ? `hsl(${editorBgHsl})` : "#111113";
context.fillRect(0, 0, targetWidth, targetHeight);
@@ -786,7 +788,9 @@ export default function VideoEditor() {
padding,
cropRegion,
webcam,
webcamUrl: resolvedWebcamVideoUrl ?? (webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null),
webcamUrl:
resolvedWebcamVideoUrl ??
(webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null),
videoWidth: previewVideo.videoWidth,
videoHeight: previewVideo.videoHeight,
annotationRegions,
@@ -1170,7 +1174,7 @@ export default function VideoEditor() {
cursorClickBounceDuration: number;
cursorSway: number;
borderRadius: number;
padding: number;
padding: Padding;
frame: string | null;
cropRegion: CropRegion;
webcam: WebcamOverlaySettings;
@@ -1196,8 +1200,7 @@ export default function VideoEditor() {
gifSizePreset: GifSizePreset;
}>,
) => {
const { cropRegion: _cropRegion, ...persistedEditor } = editor;
return persistedEditor;
return editor;
},
[],
);
@@ -1531,14 +1534,14 @@ export default function VideoEditor() {
setBorderRadius(normalizedEditor.borderRadius);
setPadding(normalizedEditor.padding);
setFrame(normalizedEditor.frame);
setCropRegion(DEFAULT_CROP_REGION);
setCropRegion(normalizedEditor.cropRegion);
setWebcam(normalizedEditor.webcam);
setZoomRegions(normalizedEditor.zoomRegions);
setTrimRegions(normalizedEditor.trimRegions);
setClipRegions(normalizedEditor.clipRegions);
clipInitializedRef.current = normalizedEditor.clipRegions.length > 0;
autoFullTrackClipIdRef.current = normalizedEditor.autoFullTrackClipId ?? null;
autoFullTrackClipEndMsRef.current = normalizedEditor.autoFullTrackClipEndMs ?? null;
autoFullTrackClipIdRef.current = normalizedEditor.autoFullTrackClipId ?? null;
autoFullTrackClipEndMsRef.current = normalizedEditor.autoFullTrackClipEndMs ?? null;
setSpeedRegions(normalizedEditor.speedRegions);
setAnnotationRegions(normalizedEditor.annotationRegions);
setAudioRegions(normalizedEditor.audioRegions);
@@ -2139,7 +2142,7 @@ export default function VideoEditor() {
currentSourcePath,
currentPersistedEditorState,
lastSavedSnapshot?.projectId ?? null,
);
);
const fileNameBase =
currentSourcePath
@@ -2254,7 +2257,7 @@ export default function VideoEditor() {
currentSourcePath,
currentPersistedEditorState,
lastSavedSnapshot?.projectId ?? null,
);
);
const thumbnailDataUrl = await captureProjectThumbnail();
const result = await window.electronAPI.saveProjectFileNamed(
projectData,
@@ -2938,7 +2941,9 @@ export default function VideoEditor() {
regions.filter(
(region) =>
!removedSegments.some(
(segment) => region.startMs < segment.endMs && region.endMs > segment.startMs,
(segment) =>
region.startMs < segment.endMs &&
region.endMs > segment.startMs,
),
);
setZoomRegions((prev) => removeTrimmedRegions(prev));
@@ -3767,7 +3772,9 @@ export default function VideoEditor() {
videoPadding: padding,
cropRegion,
webcam,
webcamUrl: resolvedWebcamVideoUrl ?? (webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null),
webcamUrl:
resolvedWebcamVideoUrl ??
(webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null),
annotationRegions,
autoCaptions,
autoCaptionSettings,
@@ -3936,7 +3943,9 @@ export default function VideoEditor() {
padding,
cropRegion,
webcam,
webcamUrl: resolvedWebcamVideoUrl ?? (webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null),
webcamUrl:
resolvedWebcamVideoUrl ??
(webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null),
annotationRegions,
autoCaptions,
autoCaptionSettings,
@@ -4690,7 +4699,10 @@ export default function VideoEditor() {
</p>
{isRenderingAudio ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
{t("editor.export.processingAudioEdits", "Processing audio with speed/overlay edits")}
{t(
"editor.export.processingAudioEdits",
"Processing audio with speed/overlay edits",
)}
</p>
) : exportRenderSpeedLabel ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
@@ -5239,7 +5251,8 @@ export default function VideoEditor() {
audioRegions.length > 0
? Math.max(
...audioRegions.map(
(region) => region.trackIndex ?? 0,
(region) =>
region.trackIndex ?? 0,
),
) + 1
: 0;
@@ -240,7 +240,7 @@ describe("editorPreferences", () => {
cursorClickBounceDuration: 350,
cursorSway: 1.5,
borderRadius: 18,
padding: 30,
padding: { top: 30, bottom: 30, left: 30, right: 30, linked: true },
frame: DEFAULT_EDITOR_PREFERENCES.frame,
aspectRatio: "4:5",
exportEncodingMode: "quality",
@@ -282,7 +282,7 @@ describe("editorPreferences", () => {
cursorClickBounceDuration: 350,
cursorSway: 1.5,
borderRadius: 18,
padding: 30,
padding: { top: 30, bottom: 30, left: 30, right: 30, linked: true },
frame: DEFAULT_EDITOR_PREFERENCES.frame,
aspectRatio: "4:5",
exportEncodingMode: "quality",
+4 -4
View File
@@ -321,10 +321,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,
};
+3
View File
@@ -91,6 +91,9 @@
"radius": "Radius",
"roundness": "Roundness",
"padding": "Padding",
"paddingAdvanced": "Advanced",
"paddingAdvancedShow": "Show advanced padding controls",
"paddingAdvancedHide": "Hide advanced padding controls",
"paddingLinked": "Linked (Uniform)",
"paddingUnlinked": "Unlinked (Asymmetrical)",
"paddingTop": "Top",
+9
View File
@@ -91,6 +91,15 @@
"radius": "Radio",
"roundness": "Redondez",
"padding": "Relleno",
"paddingAdvanced": "Avanzado",
"paddingAdvancedShow": "Mostrar controles avanzados de relleno",
"paddingAdvancedHide": "Ocultar controles avanzados de relleno",
"paddingLinked": "Vinculado (uniforme)",
"paddingUnlinked": "No vinculado (asimétrico)",
"paddingTop": "Superior",
"paddingBottom": "Inferior",
"paddingLeft": "Izquierda",
"paddingRight": "Derecha",
"removeBackground": "Quitar fondo"
},
"sections": {
+9
View File
@@ -91,6 +91,15 @@
"radius": "Rayon",
"roundness": "Arrondi",
"padding": "Marge intérieure",
"paddingAdvanced": "Avancé",
"paddingAdvancedShow": "Afficher les contrôles avancés de marge intérieure",
"paddingAdvancedHide": "Masquer les contrôles avancés de marge intérieure",
"paddingLinked": "Lié (uniforme)",
"paddingUnlinked": "Non lié (asymétrique)",
"paddingTop": "Haut",
"paddingBottom": "Bas",
"paddingLeft": "Gauche",
"paddingRight": "Droite",
"removeBackground": "Supprimer larrière-plan"
},
"sections": {
+9
View File
@@ -91,6 +91,15 @@
"radius": "반경",
"roundness": "둥글기",
"padding": "여백",
"paddingAdvanced": "고급",
"paddingAdvancedShow": "고급 여백 컨트롤 표시",
"paddingAdvancedHide": "고급 여백 컨트롤 숨기기",
"paddingLinked": "연결됨 (균일)",
"paddingUnlinked": "연결 해제됨 (비대칭)",
"paddingTop": "위",
"paddingBottom": "아래",
"paddingLeft": "왼쪽",
"paddingRight": "오른쪽",
"removeBackground": "배경 제거"
},
"sections": {
+2
View File
@@ -56,6 +56,8 @@
"loadingSources": "Bronnen laden...",
"screens": "Schermen",
"windows": "Vensters",
"noScreensAvailable": "Geen schermen beschikbaar",
"noWindowsAvailable": "Geen vensters beschikbaar",
"windowsNote": "Alleen zichtbare (niet-geminimaliseerde) vensters kunnen worden opgenomen.",
"windowPlaceholder": "Venster",
"cancel": "Annuleren",
+9
View File
@@ -91,6 +91,15 @@
"radius": "Straal",
"roundness": "Afronding",
"padding": "Opvulling",
"paddingAdvanced": "Geavanceerd",
"paddingAdvancedShow": "Geavanceerde opvulcontroles tonen",
"paddingAdvancedHide": "Geavanceerde opvulcontroles verbergen",
"paddingLinked": "Gekoppeld (uniform)",
"paddingUnlinked": "Ontkoppeld (asymmetrisch)",
"paddingTop": "Boven",
"paddingBottom": "Onder",
"paddingLeft": "Links",
"paddingRight": "Rechts",
"removeBackground": "Achtergrond verwijderen"
},
"sections": {
+9
View File
@@ -91,6 +91,15 @@
"radius": "圆角半径",
"roundness": "圆角",
"padding": "内边距",
"paddingAdvanced": "高级",
"paddingAdvancedShow": "显示高级内边距控件",
"paddingAdvancedHide": "隐藏高级内边距控件",
"paddingLinked": "联动(统一)",
"paddingUnlinked": "取消联动(非对称)",
"paddingTop": "上",
"paddingBottom": "下",
"paddingLeft": "左",
"paddingRight": "右",
"removeBackground": "移除背景"
},
"sections": {
+3 -2
View File
@@ -6,6 +6,7 @@ import type {
CropRegion,
CursorStyle,
CursorTelemetryPoint,
Padding,
SpeedRegion,
TrimRegion,
WebcamOverlaySettings,
@@ -51,8 +52,8 @@ interface GifExporterConfig {
zoomOutEasing?: ZoomTransitionEasing;
connectedZoomEasing?: ZoomTransitionEasing;
borderRadius?: number;
padding?: number;
videoPadding?: number;
padding?: Padding | number;
videoPadding?: Padding | number;
cropRegion: CropRegion;
webcam?: WebcamOverlaySettings;
webcamUrl?: string | null;
+3 -2
View File
@@ -6,6 +6,7 @@ import type {
CropRegion,
CursorStyle,
CursorTelemetryPoint,
Padding,
SpeedRegion,
TrimRegion,
WebcamOverlaySettings,
@@ -66,8 +67,8 @@ interface VideoExporterConfig extends ExportConfig {
zoomOutEasing?: ZoomTransitionEasing;
connectedZoomEasing?: ZoomTransitionEasing;
borderRadius?: number;
padding?: number;
videoPadding?: number;
padding?: Padding | number;
videoPadding?: Padding | number;
cropRegion: CropRegion;
webcam?: WebcamOverlaySettings;
webcamUrl?: string | null;