diff --git a/docs/pr/project-browser/idle-hud.png b/docs/pr/project-browser/idle-hud.png new file mode 100644 index 00000000..ffc39810 Binary files /dev/null and b/docs/pr/project-browser/idle-hud.png differ diff --git a/docs/pr/project-browser/projects-dark.png b/docs/pr/project-browser/projects-dark.png index 99b313b0..1492b412 100644 Binary files a/docs/pr/project-browser/projects-dark.png and b/docs/pr/project-browser/projects-dark.png differ diff --git a/docs/pr/project-browser/projects.png b/docs/pr/project-browser/projects.png index 8f3077b3..71008fbc 100644 Binary files a/docs/pr/project-browser/projects.png and b/docs/pr/project-browser/projects.png differ diff --git a/docs/pr/project-browser/raw.png b/docs/pr/project-browser/raw.png index 5e8893c3..4655ee2d 100644 Binary files a/docs/pr/project-browser/raw.png and b/docs/pr/project-browser/raw.png differ diff --git a/docs/pr/project-browser/recording-hud.png b/docs/pr/project-browser/recording-hud.png index ffcd5dc1..b236e2f8 100644 Binary files a/docs/pr/project-browser/recording-hud.png and b/docs/pr/project-browser/recording-hud.png differ diff --git a/docs/pr/project-browser/settings.png b/docs/pr/project-browser/settings.png new file mode 100644 index 00000000..e3b6b8df Binary files /dev/null and b/docs/pr/project-browser/settings.png differ diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index bb155348..55b4594f 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -237,6 +237,7 @@ interface Window { ) => Promise<{ success: boolean; enabled: boolean }>; getAssetBasePath: () => Promise; getSources: (opts: Electron.SourcesOptions) => Promise; + showProjectDashboard: () => Promise; switchToEditor: () => Promise; openSourceSelector: () => Promise; selectSource: (source: ProcessedDesktopSource) => Promise; diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index 25dac821..f470b865 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -605,6 +605,9 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} createHudOverlayWindow(); } }); + ipcMain.handle("show-project-dashboard", () => { + recordingNavigation.open(false); + }); ipcMain.handle("switch-to-editor", () => { console.log("[switch-to-editor] Opening editor window"); const sourceSelectorWin = getSourceSelectorWindow(); diff --git a/electron/preload.ts b/electron/preload.ts index 068d52a5..cbba00ff 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -519,6 +519,7 @@ contextBridge.exposeInMainWorld("electronAPI", { renameLibraryProject: (path: string, name: string) => ipcRenderer.invoke("rename-library-project", path, name), trashProjectFiles: (paths: string[]) => ipcRenderer.invoke("trash-project-files", paths), + showProjectDashboard: () => ipcRenderer.invoke("show-project-dashboard"), switchToEditor: () => { return ipcRenderer.invoke("switch-to-editor"); }, diff --git a/electron/recordingEditorNavigation.test.ts b/electron/recordingEditorNavigation.test.ts index ab5860d1..e5848d8d 100644 --- a/electron/recordingEditorNavigation.test.ts +++ b/electron/recordingEditorNavigation.test.ts @@ -29,3 +29,22 @@ it("opens an editor if the originating dashboard has closed", () => { navigation.open(); expect(create).toHaveBeenCalledOnce(); }); + +it("opens Home without reloading and retains the destination for recording completion", () => { + const create = vi.fn(); + const target = { + isDestroyed: () => false, + isMinimized: () => false, + reload: vi.fn(), + show: vi.fn(), + focus: vi.fn(), + }; + const navigation = createRecordingEditorNavigation(create); + navigation.setReturnWindow(target as unknown as BrowserWindow); + navigation.open(false); + expect(target.reload).not.toHaveBeenCalled(); + expect(target.show).toHaveBeenCalledOnce(); + navigation.open(); + expect(target.reload).toHaveBeenCalledOnce(); + expect(create).not.toHaveBeenCalled(); +}); diff --git a/electron/recordingEditorNavigation.ts b/electron/recordingEditorNavigation.ts index a241de53..bcf3afc7 100644 --- a/electron/recordingEditorNavigation.ts +++ b/electron/recordingEditorNavigation.ts @@ -6,16 +6,16 @@ export function createRecordingEditorNavigation(createEditor: () => void) { setReturnWindow(window: BrowserWindow | null) { returnWindow = window; }, - open() { + open(reload = true) { const target = returnWindow; - returnWindow = null; + if (reload) returnWindow = null; if (!target || target.isDestroyed()) { createEditor(); return; } // Home flushed this editor's project before launching the HUD. Reload // from the finalized recording session, clearing the previous project UI. - target.reload(); + if (reload) target.reload(); if (target.isMinimized()) target.restore(); target.show(); target.focus(); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 5a28bee0..1e92d63b 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,6 +1,7 @@ import { ArrowClockwiseIcon, CaretUpIcon, + House, DotsThreeVerticalIcon, MicrophoneIcon, MicrophoneSlashIcon, @@ -13,13 +14,11 @@ import { } from "@/components/ui/icons"; import { AnimatePresence, motion } from "motion/react"; import { useEffect, useRef } from "react"; -import { RxDragHandleDots2 } from "react-icons/rx"; import { Separator } from "@/components/ui/separator"; import { useScopedT } from "../../contexts/I18nContext"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { useVideoDevices } from "../../hooks/useVideoDevices"; -import { supportsHudCaptureProtection } from "../../lib/hudCaptureProtection"; import { Button } from "../ui/button"; import { HudInteractionContext } from "./contexts/HudInteractionContext"; import { canToggleFloatingWebcamPreview } from "./floatingWebcamPreview"; @@ -37,14 +36,10 @@ import { useLaunchPopoverCoordinator, } from "./popovers/LaunchPopoverCoordinator"; import { MicPopover } from "./popovers/MicPopover"; -import { MorePopover } from "./popovers/MorePopover"; -import { ProjectPopover } from "./popovers/ProjectPopover"; import { SourcePopover } from "./popovers/SourcePopover"; import { WebcamPopover } from "./popovers/WebcamPopover"; import { RecordingControls } from "./RecordingControls"; -const SHOW_DEV_UPDATE_PREVIEW = import.meta.env.DEV; - export function LaunchWindow() { return ( @@ -55,7 +50,7 @@ export function LaunchWindow() { function LaunchWindowContent() { const t = useScopedT("launch"); - const { openId, requestClose, requestOpen } = useLaunchPopoverCoordinator(); + const { openId, requestOpen } = useLaunchPopoverCoordinator(); const { recording, @@ -85,16 +80,8 @@ function LaunchWindowContent() { const hudContentRef = useRef(null); const hudBarRef = useRef(null); - const { - selectedSource, - hasSelectedSource, - projectLibraryEntries, - handleSourceSelect, - openVideoFile, - openProjectFromLibrary, - syncSelectedSource, - refreshProjectLibrary, - } = useLaunchWindowActions(); + const { selectedSource, hasSelectedSource, handleSourceSelect, syncSelectedSource } = + useLaunchWindowActions(); const showWebcamControls = webcamEnabled && !recording; const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices( @@ -107,16 +94,8 @@ function LaunchWindowContent() { setSelectedDeviceId: setSelectedVideoDeviceId, } = useVideoDevices(webcamEnabled || openId === "webcam"); - const { - hudOverlayMousePassthroughSupported, - platform, - appVersion, - hideHudFromCapture, - chooseRecordingsDirectory, - toggleHudCaptureProtection, - } = useLaunchWindowSystemState(preparePermissions); - - const hudCaptureProtectionSupported = supportsHudCaptureProtection(platform ?? ""); + const { hudOverlayMousePassthroughSupported, platform } = + useLaunchWindowSystemState(preparePermissions); useEffect(() => { if (!selectedDeviceId) { @@ -207,8 +186,26 @@ function LaunchWindowContent() { ease: [0.22, 1, 0.36, 1] as const, }; + const openHome = () => { + localStorage.setItem("recordly.open-dashboard", String(Date.now())); + void window.electronAPI.showProjectDashboard(); + }; + const homeButton = ( + + ); + const recordingControls = ( - +
@@ -280,9 +281,13 @@ function LaunchWindowContent() { className={microphoneEnabled ? "text-accent" : ""} > {microphoneEnabled ? ( - + ) : ( - + )} } @@ -320,9 +325,13 @@ function LaunchWindowContent() { className={webcamEnabled ? "text-accent" : ""} > {webcamEnabled ? ( - + ) : ( - + )} } @@ -339,7 +348,10 @@ function LaunchWindowContent() { title={t("recording.countdownDelay")} className={countdownDelay > 0 ? "text-accent" : ""} > - + } /> @@ -365,52 +377,7 @@ function LaunchWindowContent() { -
-
- - { - void toggleHudCaptureProtection(); - }} - onChooseRecordingsDirectory={() => { - void chooseRecordingsDirectory(); - }} - onOpenVideoFile={() => { - void openVideoFile(); - }} - onOpenProjectBrowser={() => { - refreshProjectLibrary().then(() => { - requestOpen("projects"); - }); - }} - showDevUpdatePreview={SHOW_DEV_UPDATE_PREVIEW} - onPreviewUpdateUi={() => { - if (openId) requestClose(openId); - void window.electronAPI.previewUpdateToast().catch((error) => { - console.warn("Failed to preview update toast:", error); - }); - }} - appVersion={appVersion} - trigger={ - - } - /> + {homeButton} ); @@ -488,7 +455,11 @@ function LaunchWindowContent() { onPointerUp={handleHudBarPointerUp} onPointerCancel={handleHudBarPointerUp} > - +
diff --git a/src/components/launch/RecordingControls.tsx b/src/components/launch/RecordingControls.tsx index df0bf9fc..2e0f9b57 100644 --- a/src/components/launch/RecordingControls.tsx +++ b/src/components/launch/RecordingControls.tsx @@ -1,4 +1,5 @@ import { + House, MicrophoneIcon, MicrophoneSlashIcon, MinusIcon, @@ -11,6 +12,7 @@ import { Button, Separator, Tooltip } from "@heroui/react"; import styles from "./LaunchWindow.module.css"; interface RecordingControlsProps { + onHome: () => void; paused: boolean; microphoneEnabled: boolean; elapsed: number; @@ -21,6 +23,7 @@ interface RecordingControlsProps { formatTime: (seconds: number) => string; } export function RecordingControls({ + onHome, paused, microphoneEnabled, elapsed, @@ -34,6 +37,15 @@ export function RecordingControls({ const actionClass = `size-9 min-w-9 rounded-full ${styles.electronNoDrag}`; return (
+
Promise, ) { - const [recordingsDirectory, setRecordingsDirectory] = useState(null); const [hudOverlayMousePassthroughSupported, setHudOverlayMousePassthroughSupported] = useState< boolean | null >(null); const [platform, setPlatform] = useState(null); - const [appVersion, setAppVersion] = useState(null); - const [hideHudFromCapture, setHideHudFromCapture] = useState(true); useEffect(() => { window.electronAPI?.hudOverlayRendererReady?.(); }, []); - useEffect(() => { - let cancelled = false; - const load = async () => { - try { - const result = await window.electronAPI.getRecordingsDirectory(); - if (!cancelled && result.success) setRecordingsDirectory(result.path); - } catch (error) { - console.error("Failed to load recordings directory:", error); - } - }; - void load(); - return () => { - cancelled = true; - }; - }, []); - useEffect(() => { let cancelled = false; const loadPlatform = async () => { @@ -69,74 +50,5 @@ export function useLaunchWindowSystemState( void preparePermissions({ startup: true }); }, [preparePermissions]); - useEffect(() => { - let cancelled = false; - const loadVersion = async () => { - try { - const version = await window.electronAPI.getAppVersion(); - if (!cancelled) setAppVersion(version); - } catch (error) { - console.error("Failed to load app version:", error); - } - }; - void loadVersion(); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - let cancelled = false; - const loadCaptureProtection = async () => { - try { - const result = await window.electronAPI.getHudOverlayCaptureProtection(); - if (!cancelled && result.success) { - setHideHudFromCapture(result.enabled); - } - } catch (error) { - console.error("Failed to load HUD capture protection state:", error); - } - }; - void loadCaptureProtection(); - return () => { - cancelled = true; - }; - }, []); - - const chooseRecordingsDirectory = useCallback(async () => { - try { - const result = await window.electronAPI.chooseRecordingsDirectory(); - if (result.canceled) return; - if (result.success && result.path) setRecordingsDirectory(result.path); - } catch (error) { - console.error("Failed to choose recordings directory:", error); - } - }, []); - - const toggleHudCaptureProtection = useCallback(async () => { - const nextValue = !hideHudFromCapture; - setHideHudFromCapture(nextValue); - try { - const result = await window.electronAPI.setHudOverlayCaptureProtection(nextValue); - if (!result.success) { - setHideHudFromCapture(!nextValue); - return; - } - setHideHudFromCapture(result.enabled); - } catch (error) { - console.error("Failed to update HUD capture protection:", error); - setHideHudFromCapture(!nextValue); - } - }, [hideHudFromCapture]); - - return { - recordingsDirectory, - hudOverlayMousePassthroughSupported, - platform, - appVersion, - hideHudFromCapture, - setHideHudFromCapture, - chooseRecordingsDirectory, - toggleHudCaptureProtection, - }; + return { hudOverlayMousePassthroughSupported, platform }; } diff --git a/src/components/launch/popovers/MorePopover.tsx b/src/components/launch/popovers/MorePopover.tsx deleted file mode 100644 index ebe41780..00000000 --- a/src/components/launch/popovers/MorePopover.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { - EyeIcon, - EyeSlashIcon, - FolderOpenIcon, - TranslateIcon, - VideoCameraIcon, - ArrowClockwiseIcon, - SunIcon, - MoonIcon, - DesktopIcon, -} from "@/components/ui/icons"; -import type { ReactElement } from "react"; -import { useI18n } from "@/contexts/I18nContext"; -import { useScopedT } from "@/contexts/I18nContext"; -import { useTheme } from "@/contexts/ThemeContext"; -import type { AppLocale } from "@/i18n/config"; -import { SUPPORTED_LOCALES } from "@/i18n/config"; -import styles from "../LaunchWindow.module.css"; -import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; -import { DropdownItem, HudPopover } from "./PopoverScaffold"; - -const POPOVER_ID = "more"; - -const LOCALE_LABELS: Record = { - en: "English", - es: "Español", - fr: "Français", - it: "Italiano", - nl: "Nederlands", - ko: "한국어", - "pt-BR": "Português", - "zh-CN": "簡體中文", - "zh-TW": "繁體中文", -}; - -export function MorePopover({ - trigger, - supportsHudCaptureProtection, - hideHudFromCapture, - onToggleHudCaptureProtection, - onChooseRecordingsDirectory, - onOpenVideoFile, - onOpenProjectBrowser, - showDevUpdatePreview, - onPreviewUpdateUi, - appVersion, -}: { - trigger: ReactElement; - supportsHudCaptureProtection: boolean; - hideHudFromCapture: boolean; - onToggleHudCaptureProtection: () => void; - onChooseRecordingsDirectory: () => void; - onOpenVideoFile: () => void; - onOpenProjectBrowser: () => void; - showDevUpdatePreview: boolean; - onPreviewUpdateUi: () => void; - appVersion: string | null; -}) { - const t = useScopedT("launch"); - const { locale, setLocale } = useI18n(); - const { preference, setPreference } = useTheme(); - const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); - const open = isOpen(POPOVER_ID); - - return ( - { - if (!nextOpen) { - requestClose(POPOVER_ID); - return; - } - requestOpen(POPOVER_ID); - }} - trigger={trigger} - align="end" - > - {supportsHudCaptureProtection && ( - : } - selected={hideHudFromCapture} - onClick={onToggleHudCaptureProtection} - > - {hideHudFromCapture - ? t("recording.hideHudFromVideo") - : t("recording.showHudInVideo")} - - )} - } - onClick={() => { - requestClose(POPOVER_ID); - onChooseRecordingsDirectory(); - }} - > - {t("recording.recordingsFolder")} - - } - onClick={() => { - requestClose(POPOVER_ID); - onOpenVideoFile(); - }} - > - {t("recording.openVideoFile")} - - } - onClick={() => { - requestClose(POPOVER_ID); - onOpenProjectBrowser(); - }} - > - {t("recording.openProject")} - - {showDevUpdatePreview ? ( - } - onClick={() => { - requestClose(POPOVER_ID); - onPreviewUpdateUi(); - }} - > - {t("recording.previewUpdateUi", "Preview Update UI")} - - ) : null} -
- {t("recording.appearance", "Appearance")} -
- } - selected={preference === "light"} - onClick={() => { - setPreference("light"); - requestClose(POPOVER_ID); - }} - > - {t("common.light", "Light")} - - } - selected={preference === "dark"} - onClick={() => { - setPreference("dark"); - requestClose(POPOVER_ID); - }} - > - {t("common.dark", "Dark")} - - } - selected={preference === "system"} - onClick={() => { - setPreference("system"); - requestClose(POPOVER_ID); - }} - > - {t("common.system", "System")} - -
- {t("recording.language")} -
- {SUPPORTED_LOCALES.map((code) => ( - } - selected={locale === code} - onClick={() => { - setLocale(code as AppLocale); - requestClose(POPOVER_ID); - }} - > - {LOCALE_LABELS[code] ?? code} - - ))} - {appVersion && ( -
- v{appVersion} -
- )} -
- ); -} diff --git a/src/components/launch/popovers/PopoverScaffold.tsx b/src/components/launch/popovers/PopoverScaffold.tsx index e72546cd..3dda610f 100644 --- a/src/components/launch/popovers/PopoverScaffold.tsx +++ b/src/components/launch/popovers/PopoverScaffold.tsx @@ -1,7 +1,7 @@ import { ToggleButton } from "@heroui/react"; import { Button } from "@/components/ui/button"; import { MicrophoneIcon, MicrophoneSlashIcon } from "@/components/ui/icons"; -import type { ReactElement, ReactNode } from "react"; +import { cloneElement, isValidElement, type ReactElement, type ReactNode } from "react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useAudioLevelMeter } from "@/hooks/useAudioLevelMeter"; import { AudioLevelMeter } from "@/components/ui/audio-level-meter"; @@ -25,7 +25,13 @@ export function DropdownItem({ }) { const content = ( <> - {icon} + + {isValidElement(icon) + ? cloneElement(icon as ReactElement<{ weight?: string }>, { + weight: selected ? "fill" : "regular", + }) + : icon} + {children} {trailing} @@ -68,7 +74,11 @@ export function MicDeviceRow({ onClick={onSelect} > - {selected ? : } + {selected ? ( + + ) : ( + + )} {device.label} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 901306cb..769dd2c5 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -57,10 +57,10 @@ export const Button = forwardRef(function Button isIconOnly: size === "icon", className: cn( { - "[&_svg]:size-4": iconSize === "default", - "[&_svg]:size-3.5": iconSize === "sm", - "[&_svg]:size-5": iconSize === "lg", - "[&_svg]:size-6": iconSize === "xl", + "[&_svg:not([class*=size-])]:size-4": iconSize === "default", + "[&_svg:not([class*=size-])]:size-3.5": iconSize === "sm", + "[&_svg:not([class*=size-])]:size-5": iconSize === "lg", + "[&_svg:not([class*=size-])]:size-6": iconSize === "xl", }, variant === "link" && "h-auto p-0 underline underline-offset-4", className, diff --git a/src/components/ui/icons.tsx b/src/components/ui/icons.tsx index bbc4015f..3249110d 100644 --- a/src/components/ui/icons.tsx +++ b/src/components/ui/icons.tsx @@ -9,7 +9,7 @@ type IconProps = SVGProps & { const solar = (Linear: ComponentType, Bold: ComponentType, slash = false) => ({ weight, mirrored, size, ...props }: IconProps) => { - const Icon = weight === "fill" ? Bold : Linear; + const Icon = weight === "fill" || weight === "bold" ? Bold : Linear; const style = { ...props.style, ...(mirrored ? { transform: "scaleX(-1)" } : {}) }; if (slash) return ( @@ -20,7 +20,7 @@ const solar = viewBox="0 0 24 24" fill="none" style={style} - data-icon-style={weight === "fill" ? "bold" : "linear"} + data-icon-style={weight === "fill" || weight === "bold" ? "bold" : "linear"} > ); diff --git a/src/components/video-editor/ProjectBrowserDialog.tsx b/src/components/video-editor/ProjectBrowserDialog.tsx index 95763048..2fa7fcd6 100644 --- a/src/components/video-editor/ProjectBrowserDialog.tsx +++ b/src/components/video-editor/ProjectBrowserDialog.tsx @@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button"; import { toFileUrl } from "./projectPersistence"; export type ProjectLibraryEntry = { + rawSource?: import("@/types/recordingLibrary").RecordingLibraryEntry; path: string; name: string; createdAt?: number; diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 956581e5..90a8533a 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -29,11 +29,7 @@ import { DEFAULT_WALLPAPER_RELATIVE_PATH, isVideoWallpaperSource, } from "@/lib/wallpapers"; -import { - type AspectRatio, - getAspectRatioValue, - formatAspectRatioForCSS, -} from "@/utils/aspectRatioUtils"; +import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils"; import { AnnotationOverlay } from "./AnnotationOverlay"; import { type CaptionEditTarget, normalizeCaptionEditText } from "./captionEditing"; import { buildActiveCaptionLayout } from "./captionLayout"; @@ -222,7 +218,6 @@ function getEffectiveNativeAspectRatio( interface VideoPlaybackProps { autoPlay?: boolean; - fillPreview?: boolean; clipRegions: ClipRegion[]; videoPath: string; onDurationChange: (duration: number) => void; @@ -310,7 +305,6 @@ const VideoPlayback = forwardRef( { videoPath, autoPlay = false, - fillPreview = false, onDurationChange, onPreviewReadyChange, onTimeUpdate, @@ -2370,17 +2364,7 @@ const VideoPlayback = forwardRef( ref={previewFrameRef} className="relative overflow-hidden" style={{ - width: fillPreview - ? `max(100cqw, calc(100cqh * ${getAspectRatioValue(aspectRatio, nativeAspectRatio)}))` - : "100%", - ...(fillPreview - ? { - position: "absolute" as const, - left: "50%", - top: "50%", - transform: "translate(-50%, -50%)", - } - : {}), + width: "100%", aspectRatio: formatAspectRatioForCSS(aspectRatio, nativeAspectRatio), borderRadius: 0, clipPath: "none", diff --git a/src/components/video-editor/dashboard/Dashboard.tsx b/src/components/video-editor/dashboard/Dashboard.tsx index 129ab1d6..fbd063d4 100644 --- a/src/components/video-editor/dashboard/Dashboard.tsx +++ b/src/components/video-editor/dashboard/Dashboard.tsx @@ -21,15 +21,13 @@ export function Dashboard(props: DashboardProps) { > - {model.section !== "settings" && - model.section !== "shared" && - model.section !== "raw" && ( - <> - - - - - )} + {model.section !== "settings" && model.section !== "shared" && ( + <> + + + + + )} diff --git a/src/components/video-editor/dashboard/DashboardDialogs.tsx b/src/components/video-editor/dashboard/DashboardDialogs.tsx index 1cc306b3..b0cc4e3b 100644 --- a/src/components/video-editor/dashboard/DashboardDialogs.tsx +++ b/src/components/video-editor/dashboard/DashboardDialogs.tsx @@ -13,6 +13,7 @@ import type { DashboardProps } from "./types"; import type { DashboardModel } from "./useDashboardModel"; export function DashboardDialogs({ + isRaw, folders, busy, confirmDelete, @@ -25,6 +26,7 @@ export function DashboardDialogs({ setSelecting, }: Pick< DashboardProps & DashboardModel, + | "isRaw" | "folders" | "busy" | "confirmDelete" @@ -42,11 +44,15 @@ export function DashboardDialogs({ - Delete {selected.length} project{selected.length === 1 ? "" : "s"}? + {isRaw ? "Remove" : "Delete"} {selected.length}{" "} + {isRaw ? "raw file" : "project"} + {selected.length === 1 ? "" : "s"}?

- Project files move to Trash. Source recordings are kept. + {isRaw + ? "Remove these files from the library. Original files stay on disk so existing projects keep working." + : "Project files move to Trash. Source recordings are kept."}

diff --git a/src/components/video-editor/dashboard/DashboardFilters.tsx b/src/components/video-editor/dashboard/DashboardFilters.tsx index 849ee9be..5aaeb4de 100644 --- a/src/components/video-editor/dashboard/DashboardFilters.tsx +++ b/src/components/video-editor/dashboard/DashboardFilters.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import type { DashboardModel } from "./useDashboardModel"; export function DashboardFilters({ + isRaw, period, setPeriod, selecting, @@ -19,6 +20,7 @@ export function DashboardFilters({ setConfirmDelete, }: Pick< DashboardModel, + | "isRaw" | "period" | "setPeriod" | "selecting" @@ -55,7 +57,9 @@ export function DashboardFilters({ variant="ghost" size="icon" className="size-7 min-w-7 text-danger" - aria-label="Select projects to delete" + aria-label={ + isRaw ? "Select raw files to remove" : "Select projects to delete" + } aria-pressed={selecting} onClick={() => { setSelecting(!selecting); @@ -69,21 +73,25 @@ export function DashboardFilters({ - - setSort("recent")}> - Last edited - + + {!isRaw && ( + setSort("recent")}> + Last edited + + )} setSort("created")}> Last created @@ -110,7 +118,7 @@ export function DashboardFilters({ disabled={!selected.length || busy} onClick={() => setConfirmDelete(true)} > - Delete + {isRaw ? "Remove" : "Delete"} + )} {query && (
)} + setRawPreview(null)} /> ); diff --git a/src/components/video-editor/dashboard/DashboardSettings.tsx b/src/components/video-editor/dashboard/DashboardSettings.tsx index 0fd96e45..aef8ddde 100644 --- a/src/components/video-editor/dashboard/DashboardSettings.tsx +++ b/src/components/video-editor/dashboard/DashboardSettings.tsx @@ -1,15 +1,127 @@ -import { createContext, useContext, useState, type ReactNode } from "react"; +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import { Switch } from "@/components/ui/switch"; +import { supportsHudCaptureProtection } from "@/lib/hudCaptureProtection"; import { Button } from "@/components/ui/button"; import { toast } from "@/components/ui/toast"; export const DashboardSettingsContext = createContext(null); -export function DashboardSettings() { +export function DashboardSettings({ onImportFile }: { onImportFile: () => Promise }) { const settingsContent = useContext(DashboardSettingsContext); const [directory, setDirectory] = useState(""); + const [recordings, setRecordings] = useState(""); + const [hideHud, setHideHud] = useState(true); + const [captureSupported, setCaptureSupported] = useState(false); + const [busy, setBusy] = useState(false); + const run = async (action: () => Promise) => { + setBusy(true); + try { + await action(); + } catch (error) { + toast.error(String(error)); + } finally { + setBusy(false); + } + }; + useEffect(() => { + let active = true; + void Promise.all([ + window.electronAPI.getRecordingsDirectory(), + window.electronAPI.getHudOverlayCaptureProtection(), + window.electronAPI.getPlatform(), + ]) + .then(([directory, protection, platform]) => { + if (!active) return; + if (directory.success) setRecordings(directory.path); + if (protection.success) setHideHud(protection.enabled); + setCaptureSupported(supportsHudCaptureProtection(platform)); + }) + .catch((error) => toast.error(String(error))); + return () => { + active = false; + }; + }, []); return (

Settings

{settingsContent} +
+

Open video or project

+ +
+
+
+

Recordings folder

+

+ {recordings} +

+
+ +
+ {captureSupported && ( +
+
+

Hide HUD from recordings

+

+ Only while recording. The idle HUD stays visible in captures. +

+
+ + void run(async () => { + const result = + await window.electronAPI.setHudOverlayCaptureProtection( + enabled, + ); + if (!result.success) + throw Error("Could not update capture protection"); + setHideHud(result.enabled); + }) + } + /> +
+ )} + {import.meta.env.DEV && ( +
+

Preview update UI

+ +
+ )}

Projects folder

diff --git a/src/components/video-editor/dashboard/DashboardSidebar.tsx b/src/components/video-editor/dashboard/DashboardSidebar.tsx index 13dfc8ff..be5d3a25 100644 --- a/src/components/video-editor/dashboard/DashboardSidebar.tsx +++ b/src/components/video-editor/dashboard/DashboardSidebar.tsx @@ -66,10 +66,7 @@ export function DashboardSidebar({ aria-current={section === "projects" ? "page" : undefined} onClick={() => setSection("projects")} > - + Home
diff --git a/src/components/video-editor/dashboard/ProjectCard.tsx b/src/components/video-editor/dashboard/ProjectCard.tsx index 80ecfa47..84ba17b7 100644 --- a/src/components/video-editor/dashboard/ProjectCard.tsx +++ b/src/components/video-editor/dashboard/ProjectCard.tsx @@ -1,3 +1,4 @@ +import { RawThumbnail } from "./RawRecordings"; import { AccountAvatar } from "@/components/ui/account-avatar"; import { Dropdown } from "@heroui/react"; import { Check, DotsThree, FolderSimple, Plus } from "@/components/ui/icons"; @@ -83,13 +84,20 @@ export function ProjectCard({ aria-pressed={selecting ? selected.includes(entry.path) : undefined} className="relative block h-auto w-full min-w-0 rounded-xl p-0" > - + {entry.rawSource ? ( + + ) : ( + + )} {selecting && ( openEntry(entry)}> - Open project + {entry.rawSource ? "Preview file" : "Open project"} Rename + {!entry.rawSource && ( + + void run(async () => { + if (shareUrl) + await window.electronAPI.openExternalUrl(shareUrl); + else await onShareProject(entry.path); + }) + } + > + {shareUrl ? "View in web" : "Share"} + + )} - void run(async () => { - if (shareUrl) - await window.electronAPI.openExternalUrl(shareUrl); - else await onShareProject(entry.path); - }) + aria-label={ + entry.rawSource ? `Show ${entry.name} in folder` : undefined } - > - {shareUrl ? "View in web" : "Share"} - - void run(async () => { diff --git a/src/components/video-editor/dashboard/ProjectHoverPreview.tsx b/src/components/video-editor/dashboard/ProjectHoverPreview.tsx index caa6dc30..bc52c199 100644 --- a/src/components/video-editor/dashboard/ProjectHoverPreview.tsx +++ b/src/components/video-editor/dashboard/ProjectHoverPreview.tsx @@ -81,7 +81,6 @@ export function ProjectHoverPreview({ }, [onFinish]); return ( + ); +} +export function RawPreview({ + entry, + onClose, +}: { + entry: ProjectLibraryEntry | null; + onClose: () => void; +}) { + return ( + { + if (!open) onClose(); + }} + > + + + + + + {entry?.name} + + + {entry?.rawSource && + (isAudio(entry.rawSource.name) ? ( + + + + + ); } diff --git a/src/components/video-editor/dashboard/useDashboardModel.ts b/src/components/video-editor/dashboard/useDashboardModel.ts index 5ced875e..515c4903 100644 --- a/src/components/video-editor/dashboard/useDashboardModel.ts +++ b/src/components/video-editor/dashboard/useDashboardModel.ts @@ -1,10 +1,18 @@ +import { useRawLibrary } from "./useRawLibrary"; import { useMemo, useState } from "react"; import { toast } from "@/components/ui/toast"; import type { ProjectLibraryEntry } from "../ProjectBrowserDialog"; import type { DashboardProps } from "./types"; import { useDashboardMetadata } from "./useDashboardMetadata"; import { useProjectFolders } from "./useProjectFolders"; -export function useDashboardModel({ entries, onOpenChange, onOpenProject }: DashboardProps) { +export function useDashboardModel({ + entries, + onOpenChange, + onOpenProject, + onRenameProject, + onDeleteProjects, + open, +}: DashboardProps) { const { metadata, update } = useDashboardMetadata(); const [selecting, setSelecting] = useState(false); const [selected, setSelected] = useState([]); @@ -30,12 +38,24 @@ export function useDashboardModel({ entries, onOpenChange, onOpenProject }: Dash const [query, setQuery] = useState(""); const [period, setPeriod] = useState("all"); const [sort, setSort] = useState("recent"); - const [section, setSection] = useState("projects"); + const [section, changeSection] = useState("projects"); + const [library, setLibrary] = useState("projects"); + const raw = useRawLibrary(open && library === "raw"); + const [rawPreview, setRawPreview] = useState(null); + const isRaw = library === "raw"; + const setSection = (next: string) => { + if (next === "projects" || next === "raw") setLibrary(next); + changeSection(next); + setQuery(""); + setSelecting(false); + setSelected([]); + }; + const libraryEntries = isRaw ? raw.entries : entries; const [busy, setBusy] = useState(false); const { folders, save } = useProjectFolders(); const visible = useMemo( () => - entries + libraryEntries .filter((entry) => { const cutoff = period === "week" @@ -57,7 +77,7 @@ export function useDashboardModel({ entries, onOpenChange, onOpenProject }: Dash ? (b.createdAt ?? b.updatedAt) - (a.createdAt ?? a.updatedAt) : b.updatedAt - a.updatedAt, ), - [entries, query, period, sort, folders, section], + [libraryEntries, query, period, sort, folders, section], ); const run = async (action: () => Promise) => { if (busy) return; @@ -71,15 +91,25 @@ export function useDashboardModel({ entries, onOpenChange, onOpenProject }: Dash } }; const openEntry = (entry: ProjectLibraryEntry) => - entry.isCurrent - ? onOpenChange(false) - : void run(async () => { - await onOpenProject(entry.path); - }); + entry.rawSource + ? setRawPreview(entry) + : entry.isCurrent + ? onOpenChange(false) + : void run(async () => { + await onOpenProject(entry.path); + }); const navClass = (active: boolean) => `h-10 w-full justify-start gap-3 px-3 text-[13px] ${active ? "bg-default/50 font-medium" : "text-muted-foreground"}`; return { + isRaw, + rawPreview, + setRawPreview, + rawLoading: raw.loading, + rawError: raw.error, + refreshRaw: raw.refresh, + onRenameProject: isRaw ? raw.rename : onRenameProject, + onDeleteProjects: isRaw ? raw.remove : onDeleteProjects, metadata, update, selecting, diff --git a/src/components/video-editor/dashboard/useRawLibrary.ts b/src/components/video-editor/dashboard/useRawLibrary.ts new file mode 100644 index 00000000..134b3543 --- /dev/null +++ b/src/components/video-editor/dashboard/useRawLibrary.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useState } from "react"; +import type { ProjectLibraryEntry } from "../ProjectBrowserDialog"; + +const HIDDEN_KEY = "recordly.raw-hidden.v1"; +function loadHidden(): string[] { + try { + const value = JSON.parse(localStorage.getItem(HIDDEN_KEY) || "[]"); + return Array.isArray(value) + ? value.filter((path): path is string => typeof path === "string") + : []; + } catch { + return []; + } +} +const NAMES_KEY = "recordly.raw-names.v1"; +function loadNames(): Record { + try { + const value = JSON.parse(localStorage.getItem(NAMES_KEY) || "{}"); + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + } catch { + return {}; + } +} +export function useRawLibrary(enabled: boolean) { + const [entries, setEntries] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const refresh = useCallback(async () => { + setLoading(true); + try { + const result = await window.electronAPI.listRecordings(true); + if (!result.success) throw Error(result.error); + const names = loadNames(); + setEntries( + result.value + .filter((entry) => !loadHidden().includes(entry.path)) + .map((rawSource) => ({ + path: rawSource.path, + name: names[rawSource.path] || rawSource.name, + createdAt: rawSource.createdAt, + updatedAt: rawSource.createdAt, + thumbnailPath: null, + isCurrent: false, + isInProjectsDirectory: false, + rawSource, + })), + ); + setError(null); + } catch (error) { + setError(String(error)); + } finally { + setLoading(false); + } + }, []); + useEffect(() => { + if (enabled) void refresh(); + }, [enabled, refresh]); + const rename = async (path: string, name: string) => { + localStorage.setItem(NAMES_KEY, JSON.stringify({ ...loadNames(), [path]: name })); + setEntries((previous) => + previous.map((entry) => (entry.path === path ? { ...entry, name } : entry)), + ); + return path; + }; + const remove = async (paths: string[]) => { + localStorage.setItem(HIDDEN_KEY, JSON.stringify([...new Set([...loadHidden(), ...paths])])); + setEntries((previous) => previous.filter((entry) => !paths.includes(entry.path))); + return paths; + }; + return { entries, error, loading, refresh, rename, remove }; +} diff --git a/src/components/video-editor/library/RecordingLibraryPanel.tsx b/src/components/video-editor/library/RecordingLibraryPanel.tsx index 4aa70f95..c68521f8 100644 --- a/src/components/video-editor/library/RecordingLibraryPanel.tsx +++ b/src/components/video-editor/library/RecordingLibraryPanel.tsx @@ -91,7 +91,7 @@ export function RecordingLibraryPanel({ textValue="Open recordings folder" onAction={() => void window.electronAPI.openRecordingsFolder()} > - + { + const openRequestedDashboard = () => { + if (!localStorage.getItem("recordly.open-dashboard")) return; + localStorage.removeItem("recordly.open-dashboard"); + if (!project.projectBrowserOpen) void handleOpenProjectBrowser(); + }; + openRequestedDashboard(); + window.addEventListener("storage", openRequestedDashboard); + return () => window.removeEventListener("storage", openRequestedDashboard); + }, [handleOpenProjectBrowser, project.projectBrowserOpen]); + useEffect(() => { const removeLoad = window.electronAPI.onMenuLoadProject( () => void handleOpenProjectBrowser(), @@ -225,16 +236,17 @@ export function useProjectOpenActions({ }, [project, refreshProjectLibrary], ); - const handleRenameLibraryProject = async (path: string, name: string) => { - const result = await window.electronAPI.renameLibraryProject(path, name); - if (!result.success || !result.path) throw new Error(result.error || "Could not rename project"); - if (project.currentProjectPath === path) project.setCurrentProjectPath(result.path); - await refreshProjectLibrary(); - return result.path; - }; + const handleRenameLibraryProject = async (path: string, name: string) => { + const result = await window.electronAPI.renameLibraryProject(path, name); + if (!result.success || !result.path) + throw new Error(result.error || "Could not rename project"); + if (project.currentProjectPath === path) project.setCurrentProjectPath(result.path); + await refreshProjectLibrary(); + return result.path; + }; return { - handleRenameLibraryProject, + handleRenameLibraryProject, handleOpenProjectFromLibrary, handleImportMediaOrProject, handleOpenProjectBrowser, diff --git a/tests/ui/bridge.ts b/tests/ui/bridge.ts index 6c9c6adc..c0bcf8c7 100644 --- a/tests/ui/bridge.ts +++ b/tests/ui/bridge.ts @@ -12,6 +12,21 @@ export async function installDesktopBridge(page: Page, videoFixture = "preview.m Object.assign(window, { electronAPI: { getAppSetting: () => null, + showProjectDashboard: async () => { + document.documentElement.dataset.dashboardOpened = "true"; + }, + chooseRecordingsDirectory: async () => ({ + success: true, + path: "/new-recordings", + canceled: false, + }), + setHudOverlayCaptureProtection: async (enabled: boolean) => { + document.documentElement.dataset.hideHud = String(enabled); + return { success: true, enabled }; + }, + previewUpdateToast: async () => ({ success: true }), + setRecordingsRemoved: async () => ({ success: true, value: null }), + getRecordingThumbnail: async () => ({ success: false, error: "Unavailable" }), setAppSetting: () => true, getExperimentalUpdatesEnabled: async () => false, getScreenRecordingPermissionStatus: async () => ({ diff --git a/tests/ui/hud-layout.spec.ts b/tests/ui/hud-layout.spec.ts index 51e03146..8cf8b07d 100644 --- a/tests/ui/hud-layout.spec.ts +++ b/tests/ui/hud-layout.spec.ts @@ -14,7 +14,21 @@ test("HUD dividers are vertically centered", async ({ page }) => { }), ); for (const offset of offsets) expect(offset).toBeLessThanOrEqual(1); - await page.screenshot({ path: "test-results/hud-dividers.png", animations: "disabled" }); + const home = page.getByRole("button", { name: "Home", exact: true }); + await expect(home.locator("svg")).toHaveAttribute("data-icon-style", "bold"); + await expect(page.getByRole("button", { name: "More", exact: true })).toHaveCount(0); + const icon = await home + .locator("svg") + .evaluate((element) => ({ + width: element.getBoundingClientRect().width, + height: element.getBoundingClientRect().height, + })); + expect(icon).toEqual({ width: 20, height: 20 }); + await page.screenshot({ path: "test-results/hud-idle.png", animations: "disabled" }); + await home.click(); + await expect(page.locator("html")).toHaveAttribute("data-dashboard-opened", "true"); + await page.goto("/?windowType=editor"); + await expect(page.getByRole("dialog", { name: "Projects dashboard" })).toBeVisible(); }); test("recording HUD uses uniform controls and a readable timer", async ({ page }) => { @@ -32,6 +46,7 @@ test("recording HUD uses uniform controls and a readable timer", async ({ page } const controls = page.getByRole("group", { name: "Recording controls" }); await expect(controls).toBeVisible(); await expect(controls.getByRole("status")).toContainText("00:00"); + await expect(controls.getByRole("button", { name: "Home", exact: true })).toBeVisible(); const sizes = await controls.getByRole("button").evaluateAll((buttons) => buttons.map((button) => { return [(button as HTMLElement).offsetWidth, (button as HTMLElement).offsetHeight]; diff --git a/tests/ui/project-dashboard.spec.ts b/tests/ui/project-dashboard.spec.ts index 5f74b485..26649b4c 100644 --- a/tests/ui/project-dashboard.spec.ts +++ b/tests/ui/project-dashboard.spec.ts @@ -345,6 +345,14 @@ test("dashboard supports creation sort, independent folders, shared settings and await expect(home.getByRole("row", { name: "Dark", exact: true })).toBeVisible(); await expect(home.getByRole("switch", { name: "Experimental updates" })).toBeVisible(); await expect(home.getByRole("switch", { name: "Connect Zooms" })).toBeVisible(); + await expect(home.getByText("Recordings folder", { exact: true })).toBeVisible(); + await home.getByRole("button", { name: "Change folder" }).click(); + await expect(home.getByText("/new-recordings", { exact: true })).toBeVisible(); + const capture = home.getByRole("switch", { name: "Hide HUD from recordings" }); + await capture.press("Space"); + await expect(page.locator("html")).toHaveAttribute("data-hide-hud", "true"); + await expect(home.getByRole("button", { name: "Open file", exact: true })).toBeVisible(); + await expect(home.getByText("Preview update UI", { exact: true })).toBeVisible(); await page.screenshot({ path: "test-results/dashboard-settings.png" }); }); @@ -371,8 +379,8 @@ test("Solar navigation selection, circular initials, and Raw sources are consist return { success: true }; }; window.electronAPI.getRecordingThumbnail = async () => ({ - success: false, - error: "No thumbnail", + success: true, + value: `${location.origin}/tests/ui/fixtures/recording-thumbnail.jpg`, }); }); await page.goto("/?windowType=editor"); @@ -396,12 +404,40 @@ test("Solar navigation selection, circular initials, and Raw sources are consist await expect(home.getByRole("textbox", { name: "Search projects" })).toHaveCount(0); await home.getByRole("textbox", { name: "Search raw files" }).fill("mic"); await expect(home.getByRole("list", { name: "Raw files" }).locator("li")).toHaveCount(1); - await home.getByRole("button", { name: "Show screen.mic.wav in folder" }).click(); + await home.getByRole("button", { name: "Options for screen.mic.wav" }).click(); + await page.getByRole("menuitem", { name: "Show screen.mic.wav in folder" }).click(); await expect(page.locator("html")).toHaveAttribute( "data-revealed", "/recordings/screen.mic.wav", ); - await page.screenshot({ path: "test-results/dashboard-raw.png" }); + await home.getByRole("textbox", { name: "Search raw files" }).fill(""); + await home.getByRole("button", { name: "Sort raw files" }).click(); + await page.getByRole("menuitem", { name: "Name", exact: true }).click(); + await expect( + home.getByRole("list", { name: "Raw files" }).locator("li > button").first(), + ).toHaveAccessibleName("screen.mic.wav"); + await expect(page.getByRole("menu", { name: "Sort raw files" })).toHaveCount(0); + await page.mouse.move(200, 40); + await page.screenshot({ path: "test-results/dashboard-raw.png", animations: "disabled" }); + await home.getByRole("button", { name: "New folder", exact: true }).click(); + await home.getByRole("button", { name: "Add folder to screen.mp4", exact: true }).click(); + await page.getByRole("menuitem", { name: "Untitled folder", exact: true }).click(); + await home.getByRole("button", { name: "Untitled folder", exact: true }).click(); + await expect(home.getByRole("list", { name: "Raw files" }).locator("li")).toHaveCount(1); + await home.getByRole("button", { name: "Options for screen.mp4" }).click(); + await page.getByRole("menuitem", { name: "Rename", exact: true }).click(); + await home.getByRole("textbox", { name: "Raw file name" }).fill("Original take"); + await home.getByRole("textbox", { name: "Raw file name" }).press("Enter"); + await expect(home.getByRole("button", { name: "Original take", exact: true })).toBeVisible(); + await home.getByRole("button", { name: "Original take", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Raw file preview" })).toBeVisible(); + await page.keyboard.press("Escape"); + await home.getByRole("button", { name: "Select raw files to remove" }).click(); + await home.getByRole("button", { name: "Original take", exact: true }).click(); + await home.getByRole("button", { name: "Remove", exact: true }).click(); + await page.getByRole("button", { name: "Remove from library", exact: true }).click(); + await expect(home.getByRole("button", { name: "Original take", exact: true })).toHaveCount(0); + await home.getByRole("button", { name: "Raw", exact: true }).click(); }); test("project hover plays a muted five-second preview and stops on exit", async ({ page }) => { @@ -477,6 +513,8 @@ test("project hover plays a muted five-second preview and stops on exit", async }; }); expect(fill, JSON.stringify(fill)).toMatchObject({ covers: true }); + expect(fill.frame.width / fill.frame.height).toBeCloseTo(4 / 3, 2); + expect(fill.frame.width).toBeCloseTo(fill.card.width, 0); await expect(preview).toHaveCount(0, { timeout: 8000 }); await page.mouse.move(0, 0); await card.hover();