mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
feat(editor): polish launch and export UX
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 l’arrière-plan"
|
||||
},
|
||||
"sections": {
|
||||
|
||||
@@ -91,6 +91,15 @@
|
||||
"radius": "반경",
|
||||
"roundness": "둥글기",
|
||||
"padding": "여백",
|
||||
"paddingAdvanced": "고급",
|
||||
"paddingAdvancedShow": "고급 여백 컨트롤 표시",
|
||||
"paddingAdvancedHide": "고급 여백 컨트롤 숨기기",
|
||||
"paddingLinked": "연결됨 (균일)",
|
||||
"paddingUnlinked": "연결 해제됨 (비대칭)",
|
||||
"paddingTop": "위",
|
||||
"paddingBottom": "아래",
|
||||
"paddingLeft": "왼쪽",
|
||||
"paddingRight": "오른쪽",
|
||||
"removeBackground": "배경 제거"
|
||||
},
|
||||
"sections": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -91,6 +91,15 @@
|
||||
"radius": "圆角半径",
|
||||
"roundness": "圆角",
|
||||
"padding": "内边距",
|
||||
"paddingAdvanced": "高级",
|
||||
"paddingAdvancedShow": "显示高级内边距控件",
|
||||
"paddingAdvancedHide": "隐藏高级内边距控件",
|
||||
"paddingLinked": "联动(统一)",
|
||||
"paddingUnlinked": "取消联动(非对称)",
|
||||
"paddingTop": "上",
|
||||
"paddingBottom": "下",
|
||||
"paddingLeft": "左",
|
||||
"paddingRight": "右",
|
||||
"removeBackground": "移除背景"
|
||||
},
|
||||
"sections": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user