diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 230f24b3..ccfe6754 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -35,66 +35,6 @@ padding-right: 2px; } -.updateBadge { - display: inline-flex; - align-items: center; - gap: 7px; - height: 34px; - padding: 0 12px; - border-radius: 11px; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.03); - font-size: 12px; - font-weight: 700; - letter-spacing: 0.01em; - transition: all 0.15s ease; - cursor: pointer; - flex-shrink: 0; -} - -.updateBadge:disabled { - opacity: 0.72; - cursor: default; -} - -.updateBadgeQuiet { - color: #a5b4c7; - border-color: rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.035); -} - -.updateBadgeQuiet:hover:not(:disabled) { - color: #d7dee8; - background: rgba(255, 255, 255, 0.06); -} - -.updateBadgeHot { - color: #f8fbff; - border-color: rgba(125, 211, 252, 0.24); - background: linear-gradient(180deg, rgba(125, 211, 252, 0.12), rgba(125, 211, 252, 0.04)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); -} - -.updateBadgeHot:hover:not(:disabled) { - color: #ffffff; - border-color: rgba(125, 211, 252, 0.36); - background: linear-gradient(180deg, rgba(125, 211, 252, 0.17), rgba(125, 211, 252, 0.07)); - transform: translateY(-1px); -} - -.updateBadgeSpin { - animation: updateBadgeSpin 0.9s linear infinite; -} - -@keyframes updateBadgeSpin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - .barState { display: flex; align-items: center; @@ -135,11 +75,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 { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 2c4e6146..657cd379 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -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(null); const [appVersion, setAppVersion] = useState(null); - const [updateStatus, setUpdateStatus] = useState<{ - status: - | "idle" - | "checking" - | "up-to-date" - | "available" - | "downloading" - | "ready" - | "error"; - currentVersion: string; - availableVersion: string | null; - detail?: string; - }>({ - status: "idle", - currentVersion: "", - availableVersion: null, - }); - const [updateActionPending, setUpdateActionPending] = useState(false); const dropdownRef = useRef(null); const hudContentRef = useRef(null); const hudBarRef = useRef(null); @@ -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 ; - case "checking": - case "downloading": - return ; - default: - return ; - } - })(); - - const handleUpdateButtonClick = async () => { - if (updateActionPending || updateStatus.status === "downloading") { - return; - } - - setUpdateActionPending(true); - try { - switch (updateStatus.status) { - case "available": - await window.electronAPI.downloadAvailableUpdate(); - break; - case "ready": - await window.electronAPI.installDownloadedUpdate(); - break; - default: - await window.electronAPI.checkForAppUpdates(); - break; - } - - const summary = await window.electronAPI.getUpdateStatusSummary(); - setUpdateStatus(summary); - } catch (error) { - console.error("Failed to handle update button action:", error); - } finally { - setUpdateActionPending(false); - } - }; - const recordingControls = ( <>
@@ -1091,6 +979,25 @@ export function LaunchWindow() { ); + const finalizingControls = ( +
+
+ +
+
+ + {t("recording.preparing", "Preparing recording...")} + + + {t( + "recording.preparingSubtitle", + "Recordly will open the editor when it is ready.", + )} + +
+
+ ); + const idleControls = ( <> {platform !== "linux" && ( @@ -1185,6 +1092,8 @@ export function LaunchWindow() { ); + const hudMode = finalizing ? "finalizing" : recording ? "recording" : "idle"; + return (
- -
- {recording ? recordingControls : idleControls} + {hudMode === "recording" + ? recordingControls + : hudMode === "finalizing" + ? finalizingControls + : idleControls}
diff --git a/src/components/launch/UpdateToastWindow.tsx b/src/components/launch/UpdateToastWindow.tsx index da37bfa1..2abeab4d 100644 --- a/src/components/launch/UpdateToastWindow.tsx +++ b/src/components/launch/UpdateToastWindow.tsx @@ -4,7 +4,7 @@ import { Spinner as LoaderCircle, Rocket, } from "@phosphor-icons/react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, 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 ; + case "downloading": + return ; + case "ready": + return ; + case "error": + return ; } } export function UpdateToastWindow() { const [payload, setPayload] = useState(null); - const [dragOffsetX, setDragOffsetX] = useState(0); - const dragResetKey = payload - ? `${payload.phase}:${payload.version}:${payload.progressPercent ?? ""}:${payload.detail}:${payload.delayMs}:${payload.isPreview ? "1" : "0"}:${payload.primaryAction ?? ""}` - : "empty"; - const dragState = useRef<{ - pointerId: number | null; - startX: number; - active: boolean; - }>({ - pointerId: null, - startX: 0, - active: false, - }); + const [reminderDelayMs, setReminderDelayMs] = useState(DEFAULT_REMINDER_DELAY_MS); useEffect(() => { let mounted = true; @@ -81,11 +93,9 @@ export function UpdateToastWindow() { pollTimer = setInterval(() => { void window.electronAPI.getCurrentUpdateToastPayload().then((nextPayload) => { - if (!mounted || !nextPayload) { - return; + if (mounted) { + setPayload(nextPayload); } - - setPayload((currentPayload) => currentPayload ?? nextPayload); }); }, 750); @@ -103,214 +113,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 ( -
-
-
- -
-
-

Checking for updates

-

- Waiting for updater state from the main process. -

-
-
-
- ); - } - - const normalizedProgress = Math.max(0, Math.min(100, Math.round(payload.progressPercent ?? 0))); - const primaryActionLabel = getPrimaryActionLabel(payload); - const swipeThreshold = 96; - const handleSwipeDismiss = async () => { - setDragOffsetX(0); - dragState.current = { - pointerId: null, - startX: 0, - active: false, - }; - await window.electronAPI.dismissUpdateToast(); - }; - const handlePrimaryAction = async () => { - switch (payload.primaryAction) { - case "download-update": - await window.electronAPI.downloadAvailableUpdate(); - return; - case "install-update": - await window.electronAPI.installDownloadedUpdate(); - return; - case "retry-check": - await window.electronAPI.checkForAppUpdates(); - return; - default: - return; + if (!payload || payload.phase === "downloading") { + return; } + + if (payload.primaryAction === "retry-check") { + await window.electronAPI.checkForAppUpdates(); + return; + } + + if (payload.phase === "ready") { + await window.electronAPI.installDownloadedUpdate(); + return; + } + + await window.electronAPI.downloadAvailableUpdate(true); }; + const handleLater = async () => { + if (!payload) { + return; + } + + if (payload.isPreview) { + await window.electronAPI.dismissUpdateToast(); + return; + } + + await window.electronAPI.deferDownloadedUpdate(reminderDelayMs); + }; + + if (!payload) { + return
; + } + return (
-
{ - const target = event.target as HTMLElement | null; - if (target?.closest("button")) { - return; - } - - dragState.current = { - pointerId: event.pointerId, - startX: event.clientX, - active: true, - }; - event.currentTarget.setPointerCapture(event.pointerId); - }} - onPointerMove={(event) => { - if ( - !dragState.current.active || - dragState.current.pointerId !== event.pointerId - ) { - return; - } - - setDragOffsetX(event.clientX - dragState.current.startX); - }} - onPointerUp={async (event) => { - if ( - !dragState.current.active || - dragState.current.pointerId !== event.pointerId - ) { - return; - } - - const nextOffset = event.clientX - dragState.current.startX; - dragState.current = { - pointerId: null, - startX: 0, - active: false, - }; - - if (Math.abs(nextOffset) >= swipeThreshold) { - await handleSwipeDismiss(); - return; - } - - setDragOffsetX(0); - }} - onPointerCancel={() => { - dragState.current = { - pointerId: null, - startX: 0, - active: false, - }; - setDragOffsetX(0); - }} - > -
- {payload.phase === "available" ? : null} - {payload.phase === "downloading" ? ( - - ) : null} - {payload.phase === "ready" ? : null} - {payload.phase === "error" ? : null} -
+
+
{getPhaseIcon(payload)}

{getToastTitle(payload)}

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

{payload.detail}

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

- {normalizedProgress}% downloaded -

+ + {normalizedProgress}% complete + + {phaseStats.map((stat) => ( + + {stat.label}: {stat.value} + + ))} +
) : null} -
- {primaryActionLabel ? ( - - ) : null} - - {payload.phase === "downloading" ? ( - - ) : null} - +
{payload.phase !== "downloading" ? ( - - ) : null} - - {payload.phase !== "downloading" ? ( - - ) : null} - - {!payload.isPreview && payload.phase !== "downloading" ? ( - + <> + + + + ) : null}
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 66ca23b5..28e9c00f 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -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({
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 8f6aeddd..3a8103a7 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -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" }) => ( - -); -const PhCamera = (props: { className?: string; weight?: "fill" | "regular" }) => ( - -); -const PhCaptions = (props: { className?: string; weight?: "fill" | "regular" }) => ( - -); -const PhPuzzle = (props: { className?: string; weight?: "fill" | "regular" }) => ( - -); -const PhSparkle = (props: { className?: string; weight?: "fill" | "regular" }) => ( - -); -const PhSettings = (props: { className?: string; weight?: "fill" | "regular" }) => ( - -); - -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" }) => ( + +); +const PhCamera = (props: { className?: string; weight?: "fill" | "regular" }) => ( + +); +const PhCaptions = (props: { className?: string; weight?: "fill" | "regular" }) => ( + +); +const PhPuzzle = (props: { className?: string; weight?: "fill" | "regular" }) => ( + +); +const PhSparkle = (props: { className?: string; weight?: "fill" | "regular" }) => ( + +); +const PhSettings = (props: { className?: string; weight?: "fill" | "regular" }) => ( + +); + 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() {

{isRenderingAudio ? (

- {t("editor.export.processingAudioEdits", "Processing audio with speed/overlay edits")} + {t( + "editor.export.processingAudioEdits", + "Processing audio with speed/overlay edits", + )}

) : exportRenderSpeedLabel ? (

@@ -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; diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts index edd0c012..a2f7691c 100644 --- a/src/components/video-editor/editorPreferences.test.ts +++ b/src/components/video-editor/editorPreferences.test.ts @@ -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", diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 7fbddb7b..4cf5cb78 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -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, }; diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 03d1cde0..c3915077 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -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", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 6515aada..43c24897 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -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": { diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index e1b3fe42..e8d24845 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -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": { diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 76646b52..1bb5841e 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -91,6 +91,15 @@ "radius": "반경", "roundness": "둥글기", "padding": "여백", + "paddingAdvanced": "고급", + "paddingAdvancedShow": "고급 여백 컨트롤 표시", + "paddingAdvancedHide": "고급 여백 컨트롤 숨기기", + "paddingLinked": "연결됨 (균일)", + "paddingUnlinked": "연결 해제됨 (비대칭)", + "paddingTop": "위", + "paddingBottom": "아래", + "paddingLeft": "왼쪽", + "paddingRight": "오른쪽", "removeBackground": "배경 제거" }, "sections": { diff --git a/src/i18n/locales/nl/launch.json b/src/i18n/locales/nl/launch.json index 33729275..9f88a446 100644 --- a/src/i18n/locales/nl/launch.json +++ b/src/i18n/locales/nl/launch.json @@ -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", diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json index 78b3b33d..dfd3c615 100644 --- a/src/i18n/locales/nl/settings.json +++ b/src/i18n/locales/nl/settings.json @@ -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": { diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 32e94e5c..b7ad52e3 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -91,6 +91,15 @@ "radius": "圆角半径", "roundness": "圆角", "padding": "内边距", + "paddingAdvanced": "高级", + "paddingAdvancedShow": "显示高级内边距控件", + "paddingAdvancedHide": "隐藏高级内边距控件", + "paddingLinked": "联动(统一)", + "paddingUnlinked": "取消联动(非对称)", + "paddingTop": "上", + "paddingBottom": "下", + "paddingLeft": "左", + "paddingRight": "右", "removeBackground": "移除背景" }, "sections": { diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 789e9dff..34b66ad6 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -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; diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index fea3219a..5a0bec3e 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -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;