Unify Raw browsing, render 4:3 previews, and move HUD settings to dashboard

This commit is contained in:
webadderall committed 2026-09-22 20:45:02 +10:00
1 parent 343151008b
commit b7c8273b0d
37 files changed
+638 -609

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

+1
View File
@@ -237,6 +237,7 @@ interface Window {
) => Promise<{ success: boolean; enabled: boolean }>;
getAssetBasePath: () => Promise<string | null>;
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
showProjectDashboard: () => Promise<void>;
switchToEditor: () => Promise<void>;
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource>;
+3
View File
@@ -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();
+1
View File
@@ -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");
},
@@ -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();
});
+3 -3
View File
@@ -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();
+53 -82
View File
@@ -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 (
<LaunchPopoverCoordinatorProvider>
@@ -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<HTMLDivElement>(null);
const hudBarRef = useRef<HTMLDivElement>(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 = (
<Button
variant="ghost"
size="icon"
iconSize="lg"
aria-label="Home"
title="Home"
onClick={openHome}
>
<House weight="fill" className="size-5" />
</Button>
);
const recordingControls = (
<RecordingControls
onHome={openHome}
paused={paused}
microphoneEnabled={microphoneEnabled}
elapsed={elapsed}
@@ -235,7 +232,11 @@ function LaunchWindowContent() {
className={` ${styles.electronNoDrag} group gap-2 px-3 min-w-0 max-w-[180px] shrink-0 ${openId === "sources" ? "border-[var(--launch-border-strong)] bg-[var(--launch-hover)]" : ""} `}
title={selectedSource}
>
<MonitorIcon size={16} className="shrink-0" />
<MonitorIcon
weight={openId === "sources" ? "fill" : "regular"}
size={18}
className="size-5 shrink-0"
/>
<div className="flex-1 min-w-0 overflow-hidden">
<MarqueeText text={selectedSource} />
</div>
@@ -280,9 +281,13 @@ function LaunchWindowContent() {
className={microphoneEnabled ? "text-accent" : ""}
>
{microphoneEnabled ? (
<MicrophoneIcon size={18} />
<MicrophoneIcon
weight={microphoneEnabled ? "fill" : "regular"}
className="size-5"
size={18}
/>
) : (
<MicrophoneSlashIcon size={18} />
<MicrophoneSlashIcon className="size-5" />
)}
</Button>
}
@@ -320,9 +325,13 @@ function LaunchWindowContent() {
className={webcamEnabled ? "text-accent" : ""}
>
{webcamEnabled ? (
<VideoCameraIcon size={18} />
<VideoCameraIcon
weight={webcamEnabled ? "fill" : "regular"}
className="size-5"
size={18}
/>
) : (
<VideoCameraSlashIcon size={18} />
<VideoCameraSlashIcon className="size-5" />
)}
</Button>
}
@@ -339,7 +348,10 @@ function LaunchWindowContent() {
title={t("recording.countdownDelay")}
className={countdownDelay > 0 ? "text-accent" : ""}
>
<TimerIcon size={18} />
<TimerIcon
weight={openId === "countdown" ? "fill" : "regular"}
className="size-5"
/>
</Button>
}
/>
@@ -365,52 +377,7 @@ function LaunchWindowContent() {
<Separator orientation="vertical" className="mx-[5px] h-6 self-center" />
<div className="relative w-0 h-0">
<ProjectPopover
entries={projectLibraryEntries}
onOpenProject={openProjectFromLibrary}
trigger={
<Button
aria-hidden="true"
tabIndex={-1}
variant="ghost"
className="absolute size-px min-w-0 p-0 pointer-events-none opacity-0"
/>
}
/>
</div>
<MorePopover
supportsHudCaptureProtection={hudCaptureProtectionSupported}
hideHudFromCapture={hideHudFromCapture}
onToggleHudCaptureProtection={() => {
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={
<Button variant="ghost" size="icon" iconSize="lg" title={t("recording.more")}>
<DotsThreeVerticalIcon size={18} />
</Button>
}
/>
{homeButton}
<Button
variant="ghost"
@@ -419,7 +386,7 @@ function LaunchWindowContent() {
onClick={() => window.electronAPI?.hudOverlayHide?.()}
title={t("recording.hideHud")}
>
<MinusIcon size={16} />
<MinusIcon className="size-5" />
</Button>
<Button
@@ -429,7 +396,7 @@ function LaunchWindowContent() {
onClick={() => window.electronAPI?.hudOverlayClose?.()}
title={t("recording.closeApp")}
>
<XIcon size={16} />
<XIcon className="size-5" />
</Button>
</>
);
@@ -488,7 +455,11 @@ function LaunchWindowContent() {
onPointerUp={handleHudBarPointerUp}
onPointerCancel={handleHudBarPointerUp}
>
<RxDragHandleDots2 size={14} className="text-[#6b6b78]" />
<DotsThreeVerticalIcon
weight="fill"
size={18}
className="text-[#6b6b78]"
/>
</div>
<div className={styles.barStateViewport}>
@@ -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 (
<div role="group" aria-label="Recording controls" className="flex items-center gap-2">
<Button
isIconOnly
variant="ghost"
className={actionClass}
aria-label="Home"
onPress={onHome}
>
<House weight="fill" className="size-4" />
</Button>
<div
className="flex items-center gap-3 px-2"
role="status"
@@ -1,36 +1,17 @@
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
export function useLaunchWindowSystemState(
preparePermissions: (args: { startup?: boolean }) => Promise<unknown>,
) {
const [recordingsDirectory, setRecordingsDirectory] = useState<string | null>(null);
const [hudOverlayMousePassthroughSupported, setHudOverlayMousePassthroughSupported] = useState<
boolean | null
>(null);
const [platform, setPlatform] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string | null>(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 };
}
@@ -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<string, string> = {
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 (
<HudPopover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
requestClose(POPOVER_ID);
return;
}
requestOpen(POPOVER_ID);
}}
trigger={trigger}
align="end"
>
{supportsHudCaptureProtection && (
<DropdownItem
icon={hideHudFromCapture ? <EyeSlashIcon size={16} /> : <EyeIcon size={16} />}
selected={hideHudFromCapture}
onClick={onToggleHudCaptureProtection}
>
{hideHudFromCapture
? t("recording.hideHudFromVideo")
: t("recording.showHudInVideo")}
</DropdownItem>
)}
<DropdownItem
icon={<FolderOpenIcon size={16} />}
onClick={() => {
requestClose(POPOVER_ID);
onChooseRecordingsDirectory();
}}
>
{t("recording.recordingsFolder")}
</DropdownItem>
<DropdownItem
icon={<VideoCameraIcon size={16} />}
onClick={() => {
requestClose(POPOVER_ID);
onOpenVideoFile();
}}
>
{t("recording.openVideoFile")}
</DropdownItem>
<DropdownItem
icon={<FolderOpenIcon size={16} />}
onClick={() => {
requestClose(POPOVER_ID);
onOpenProjectBrowser();
}}
>
{t("recording.openProject")}
</DropdownItem>
{showDevUpdatePreview ? (
<DropdownItem
icon={<ArrowClockwiseIcon size={16} />}
onClick={() => {
requestClose(POPOVER_ID);
onPreviewUpdateUi();
}}
>
{t("recording.previewUpdateUi", "Preview Update UI")}
</DropdownItem>
) : null}
<div className={styles.ddLabel} style={{ marginTop: 4 }}>
{t("recording.appearance", "Appearance")}
</div>
<DropdownItem
icon={<SunIcon size={16} />}
selected={preference === "light"}
onClick={() => {
setPreference("light");
requestClose(POPOVER_ID);
}}
>
{t("common.light", "Light")}
</DropdownItem>
<DropdownItem
icon={<MoonIcon size={16} />}
selected={preference === "dark"}
onClick={() => {
setPreference("dark");
requestClose(POPOVER_ID);
}}
>
{t("common.dark", "Dark")}
</DropdownItem>
<DropdownItem
icon={<DesktopIcon size={16} />}
selected={preference === "system"}
onClick={() => {
setPreference("system");
requestClose(POPOVER_ID);
}}
>
{t("common.system", "System")}
</DropdownItem>
<div className={styles.ddLabel} style={{ marginTop: 4 }}>
{t("recording.language")}
</div>
{SUPPORTED_LOCALES.map((code) => (
<DropdownItem
key={code}
icon={<TranslateIcon size={16} />}
selected={locale === code}
onClick={() => {
setLocale(code as AppLocale);
requestClose(POPOVER_ID);
}}
>
{LOCALE_LABELS[code] ?? code}
</DropdownItem>
))}
{appVersion && (
<div
style={{
marginTop: 8,
padding: "4px 12px",
fontSize: 11,
color: "var(--launch-text-muted)",
textAlign: "center",
userSelect: "text",
}}
>
v{appVersion}
</div>
)}
</HudPopover>
);
}
@@ -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 = (
<>
<span className="shrink-0">{icon}</span>
<span className="shrink-0">
{isValidElement(icon)
? cloneElement(icon as ReactElement<{ weight?: string }>, {
weight: selected ? "fill" : "regular",
})
: icon}
</span>
<span className="truncate">{children}</span>
{trailing}
</>
@@ -68,7 +74,11 @@ export function MicDeviceRow({
onClick={onSelect}
>
<span className="shrink-0">
{selected ? <MicrophoneIcon size={16} /> : <MicrophoneSlashIcon size={16} />}
{selected ? (
<MicrophoneIcon weight="fill" size={16} />
) : (
<MicrophoneSlashIcon size={16} />
)}
</span>
<span className="truncate flex-1">{device.label}</span>
<AudioLevelMeter level={level} className="w-16 shrink-0" />
+4 -4
View File
@@ -57,10 +57,10 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(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,
+3 -3
View File
@@ -9,7 +9,7 @@ type IconProps = SVGProps<SVGSVGElement> & {
const solar =
(Linear: ComponentType<SolarIconProps>, Bold: ComponentType<SolarIconProps>, 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"}
>
<Icon size={24} />
<path
@@ -35,7 +35,7 @@ const solar =
<Icon
{...props}
size={size}
data-icon-style={weight === "fill" ? "bold" : "linear"}
data-icon-style={weight === "fill" || weight === "bold" ? "bold" : "linear"}
style={style}
/>
);
@@ -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;
+2 -18
View File
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(
{
videoPath,
autoPlay = false,
fillPreview = false,
onDurationChange,
onPreviewReadyChange,
onTimeUpdate,
@@ -2370,17 +2364,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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",
@@ -21,15 +21,13 @@ export function Dashboard(props: DashboardProps) {
>
<DashboardSidebar {...view} />
<Card className="my-3 mr-3 flex min-h-0 min-w-0 flex-1 flex-col gap-0 overflow-hidden rounded-2xl bg-background p-0 shadow-sm">
{model.section !== "settings" &&
model.section !== "shared" &&
model.section !== "raw" && (
<>
<DashboardAnnouncements />
<DashboardToolbar {...view} />
<DashboardFilters {...view} />
</>
)}
{model.section !== "settings" && model.section !== "shared" && (
<>
<DashboardAnnouncements />
<DashboardToolbar {...view} />
<DashboardFilters {...view} />
</>
)}
<DashboardGrid {...view} />
</Card>
</Modal.Dialog>
@@ -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({
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>
Delete {selected.length} project{selected.length === 1 ? "" : "s"}?
{isRaw ? "Remove" : "Delete"} {selected.length}{" "}
{isRaw ? "raw file" : "project"}
{selected.length === 1 ? "" : "s"}?
</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
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."}
</p>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
@@ -71,7 +77,7 @@ export function DashboardDialogs({
})
}
>
Move to Trash
{isRaw ? "Remove from library" : "Move to Trash"}
</Button>
</DialogFooter>
</DialogContent>
@@ -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({
<Button
variant="ghost"
size="sm"
aria-label="Sort projects"
aria-label={isRaw ? "Sort raw files" : "Sort projects"}
className="h-7 gap-2 text-xs text-muted-foreground"
>
{sort === "recent"
? "Last edited"
? isRaw
? "Last created"
: "Last edited"
: sort === "created"
? "Last created"
: "Name"}
<CaretDown className="size-3" />
</Button>
<Dropdown.Popover>
<Dropdown.Menu aria-label="Sort projects">
<Dropdown.Item id="recent" onAction={() => setSort("recent")}>
Last edited
</Dropdown.Item>
<Dropdown.Menu aria-label={isRaw ? "Sort raw files" : "Sort projects"}>
{!isRaw && (
<Dropdown.Item id="recent" onAction={() => setSort("recent")}>
Last edited
</Dropdown.Item>
)}
<Dropdown.Item id="created" onAction={() => setSort("created")}>
Last created
</Dropdown.Item>
@@ -110,7 +118,7 @@ export function DashboardFilters({
disabled={!selected.length || busy}
onClick={() => setConfirmDelete(true)}
>
Delete
{isRaw ? "Remove" : "Delete"}
</Button>
<Button
variant="ghost"
@@ -1,4 +1,4 @@
import { RawRecordings } from "./RawRecordings";
import { RawPreview } from "./RawRecordings";
import { Cloud, ImageSquare } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
@@ -11,6 +11,13 @@ import type { DashboardProps } from "./types";
import type { DashboardModel } from "./useDashboardModel";
export function DashboardGrid({
onImportFile,
isRaw,
rawPreview,
setRawPreview,
rawLoading,
rawError,
refreshRaw,
error,
section,
accountLabel,
@@ -31,6 +38,13 @@ export function DashboardGrid({
run,
}: Pick<
DashboardProps & DashboardModel,
| "onImportFile"
| "isRaw"
| "rawPreview"
| "setRawPreview"
| "rawLoading"
| "rawError"
| "refreshRaw"
| "error"
| "section"
| "accountLabel"
@@ -59,9 +73,7 @@ export function DashboardGrid({
</p>
)}
{section === "settings" ? (
<DashboardSettings />
) : section === "raw" ? (
<RawRecordings />
<DashboardSettings onImportFile={onImportFile} />
) : section === "shared" ? (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<Cloud weight="fill" className="size-8 opacity-40" />
@@ -76,7 +88,7 @@ export function DashboardGrid({
</div>
) : visible.length ? (
<ul
aria-label="Your projects"
aria-label={isRaw ? "Raw files" : "Your projects"}
className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-x-7 gap-y-10 lg:gap-x-9"
>
{visible.map((entry) => (
@@ -103,7 +115,19 @@ export function DashboardGrid({
) : (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<ImageSquare weight="fill" className="size-8 opacity-30" />
<p>{query ? "No matching projects" : "No projects yet"}</p>
<p>
{isRaw
? rawLoading
? "Loading recordings…"
: rawError ||
(query ? "No matching raw files" : "No raw recordings yet")
: query
? "No matching projects"
: "No projects yet"}
</p>
{isRaw && rawError && (
<Button onClick={() => void refreshRaw()}>Retry</Button>
)}
{query && (
<Button variant="ghost" size="sm" onClick={() => setQuery("")}>
Clear search
@@ -111,6 +135,7 @@ export function DashboardGrid({
)}
</div>
)}
<RawPreview entry={rawPreview} onClose={() => setRawPreview(null)} />
</main>
</>
);
@@ -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<ReactNode>(null);
export function DashboardSettings() {
export function DashboardSettings({ onImportFile }: { onImportFile: () => Promise<void> }) {
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<void>) => {
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 (
<section aria-label="Dashboard settings" className="dashboard-settings max-w-2xl py-10">
<h1 className="mb-8 text-lg font-semibold">Settings</h1>
<div className="space-y-8">
{settingsContent}
<div className="flex items-center justify-between gap-8">
<p className="text-sm">Open video or project</p>
<Button
variant="secondary"
disabled={busy}
onClick={() => void run(onImportFile)}
>
Open file
</Button>
</div>
<div className="flex items-center justify-between gap-8">
<div className="min-w-0">
<p className="text-sm">Recordings folder</p>
<p
className="mt-1 truncate text-xs text-muted-foreground"
title={recordings}
>
{recordings}
</p>
</div>
<Button
variant="secondary"
disabled={busy}
onClick={() =>
void run(async () => {
const result = await window.electronAPI.chooseRecordingsDirectory();
if (result.canceled) return;
if (!result.success || !result.path)
throw Error("Could not change recordings folder");
setRecordings(result.path);
})
}
>
Change folder
</Button>
</div>
{captureSupported && (
<div className="flex items-center justify-between gap-8">
<div>
<p className="text-sm">Hide HUD from recordings</p>
<p className="mt-1 text-xs text-muted-foreground">
Only while recording. The idle HUD stays visible in captures.
</p>
</div>
<Switch
aria-label="Hide HUD from recordings"
checked={hideHud}
disabled={busy}
onCheckedChange={(enabled) =>
void run(async () => {
const result =
await window.electronAPI.setHudOverlayCaptureProtection(
enabled,
);
if (!result.success)
throw Error("Could not update capture protection");
setHideHud(result.enabled);
})
}
/>
</div>
)}
{import.meta.env.DEV && (
<div className="flex items-center justify-between gap-8">
<p className="text-sm">Preview update UI</p>
<Button
variant="secondary"
disabled={busy}
onClick={() =>
void run(async () => {
await window.electronAPI.previewUpdateToast();
})
}
>
Preview
</Button>
</div>
)}
<div className="flex items-center justify-between gap-8">
<div>
<p className="text-sm">Projects folder</p>
@@ -66,10 +66,7 @@ export function DashboardSidebar({
aria-current={section === "projects" ? "page" : undefined}
onClick={() => setSection("projects")}
>
<House
weight={section === "projects" ? "fill" : "regular"}
className="size-[18px]"
/>
<House weight="fill" className="size-[18px]" />
Home
</Button>
<Button
@@ -9,11 +9,12 @@ import type { DashboardProps } from "./types";
import type { DashboardModel } from "./useDashboardModel";
export function DashboardToolbar({
isRaw,
query,
setQuery,
run,
busy,
}: Pick<DashboardProps & DashboardModel, "query" | "setQuery" | "run" | "busy">) {
}: Pick<DashboardProps & DashboardModel, "query" | "setQuery" | "run" | "busy" | "isRaw">) {
return (
<>
<header
@@ -28,8 +29,8 @@ export function DashboardToolbar({
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Search projects"
placeholder="Search projects…"
aria-label={isRaw ? "Search raw files" : "Search projects"}
placeholder={isRaw ? "Search raw files…" : "Search projects…"}
className="h-11 w-full border-0 bg-default/30 pl-10 shadow-none"
/>
</div>
@@ -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"
>
<ProjectThumbnail
key={`${entry.thumbnailPath}-${entry.updatedAt}`}
revision={entry.updatedAt}
path={entry.thumbnailPath}
projectPath={entry.path}
previewActive={hovering && !selecting && !busy}
/>
{entry.rawSource ? (
<RawThumbnail
entry={entry.rawSource}
active={hovering && !selecting && !busy}
/>
) : (
<ProjectThumbnail
key={`${entry.thumbnailPath}-${entry.updatedAt}`}
revision={entry.updatedAt}
path={entry.thumbnailPath}
projectPath={entry.path}
previewActive={hovering && !selecting && !busy}
/>
)}
{selecting && (
<span
className={`absolute right-2 top-2 flex size-5 items-center justify-center rounded-md ${selected.includes(entry.path) ? "bg-accent text-white" : "bg-background/90"}`}
@@ -110,7 +118,7 @@ export function ProjectCard({
>
<input
autoFocus
aria-label="Project name"
aria-label={entry.rawSource ? "Raw file name" : "Project name"}
className="inline-project-name h-5 w-full text-[12px] font-medium"
value={name}
disabled={busy}
@@ -221,7 +229,7 @@ export function ProjectCard({
<Dropdown.Popover>
<Dropdown.Menu aria-label="Project options">
<Dropdown.Item id="open" onAction={() => openEntry(entry)}>
Open project
{entry.rawSource ? "Preview file" : "Open project"}
</Dropdown.Item>
<Dropdown.Item
id="rename"
@@ -232,19 +240,24 @@ export function ProjectCard({
>
Rename
</Dropdown.Item>
{!entry.rawSource && (
<Dropdown.Item
id="share"
onAction={() =>
void run(async () => {
if (shareUrl)
await window.electronAPI.openExternalUrl(shareUrl);
else await onShareProject(entry.path);
})
}
>
{shareUrl ? "View in web" : "Share"}
</Dropdown.Item>
)}
<Dropdown.Item
id="share"
onAction={() =>
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"}
</Dropdown.Item>
<Dropdown.Item
id="reveal"
onAction={() =>
void run(async () => {
@@ -81,7 +81,6 @@ export function ProjectHoverPreview({
}, [onFinish]);
return (
<div
style={{ containerType: "size" }}
data-project-hover-preview
className={`pointer-events-none absolute inset-0 overflow-hidden rounded-xl ${playing ? "opacity-100" : "opacity-0"}`}
aria-hidden="true"
@@ -89,7 +88,7 @@ export function ProjectHoverPreview({
<VideoPlayback
{...editor}
autoPlay
fillPreview
aspectRatio="4:3"
ref={playback}
videoPath={data.videoUrl}
webcamVideoPath={data.webcamUrl}
@@ -1,142 +1,104 @@
import { Button, Input, Modal } from "@heroui/react";
import { useCallback, useEffect, useState } from "react";
import { File, FolderOpen } from "@/components/ui/icons";
import { toast } from "@/components/ui/toast";
import { Modal } from "@heroui/react";
import { useEffect, useRef, useState } from "react";
import { MusicNotes } from "@/components/ui/icons";
import type { ProjectLibraryEntry } from "../ProjectBrowserDialog";
import type { RecordingLibraryEntry } from "@/types/recordingLibrary";
export function RawRecordings() {
const [entries, setEntries] = useState<RecordingLibraryEntry[]>([]);
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [preview, setPreview] = useState<RecordingLibraryEntry | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const result = await window.electronAPI.listRecordings(true);
if (!result.success) throw Error(result.error);
setEntries(result.value);
} catch (error) {
setError(error instanceof Error ? error.message : "Could not load raw recordings");
} finally {
setLoading(false);
}
}, []);
const isAudio = (name: string) => /\.(wav|m4a|mp3|ogg|flac)$/i.test(name);
export function RawThumbnail({ entry, active }: { entry: RecordingLibraryEntry; active: boolean }) {
const video = useRef<HTMLVideoElement>(null);
const [poster, setPoster] = useState<string>();
useEffect(() => {
void refresh();
}, [refresh]);
const reveal = async (path?: string) => {
try {
const result = path
? { success: true, path }
: await window.electronAPI.getRecordingsDirectory();
if (!result.success || !result.path) throw Error("Could not open recordings folder");
await window.electronAPI.revealInFolder(result.path);
} catch {
toast.error("Could not show recordings in folder");
let active = true;
if (!isAudio(entry.name))
void window.electronAPI
.getRecordingThumbnail(entry.path)
.then((result) => {
if (active && result.success) setPoster(result.value);
})
.catch(() => {});
return () => {
active = false;
};
}, [entry.path, entry.name]);
useEffect(() => {
const element = video.current;
if (!element) return;
if (!active || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
element.pause();
element.currentTime = 0;
return;
}
};
const visible = entries.filter((entry) =>
entry.name.toLowerCase().includes(query.trim().toLowerCase()),
);
const timer = window.setTimeout(() => {
element.currentTime = 0;
void element.play().catch(() => {});
}, 300);
const stop = () => {
if (document.hidden) element.pause();
};
document.addEventListener("visibilitychange", stop);
return () => {
clearTimeout(timer);
element.pause();
document.removeEventListener("visibilitychange", stop);
};
}, [active]);
return (
<section aria-label="Raw recordings" className="py-10">
<header className="mb-8 flex items-center justify-between gap-5">
<div>
<h1 className="text-lg font-semibold">Raw</h1>
<p className="mt-2 text-sm text-muted-foreground">
Original screen recordings, camera footage, and audio files.
</p>
</div>
<Button variant="secondary" onPress={() => void reveal()}>
<FolderOpen />
Show folder
</Button>
</header>
<Input
aria-label="Search raw files"
placeholder="Search raw files…"
value={query}
onChange={(event) => setQuery(event.target.value)}
className="mb-6 w-full"
/>
{loading ? (
<p role="status" className="py-12 text-sm text-muted-foreground">
Loading recordings…
</p>
) : error ? (
<div role="alert">
<p>{error}</p>
<Button variant="secondary" onPress={() => void refresh()}>
Retry
</Button>
</div>
) : visible.length ? (
<ul aria-label="Raw files" className="space-y-3">
{visible.map((entry) => (
<li
key={entry.path}
className="flex items-center gap-4 rounded-xl bg-default/20 p-4"
>
<File className="size-6 shrink-0 text-muted-foreground" />
<Button
variant="ghost"
className="h-auto min-w-0 flex-1 justify-start p-0 text-left"
onPress={() => setPreview(entry)}
>
<span className="min-w-0">
<span className="block truncate text-sm">{entry.name}</span>
<span className="mt-1 block text-xs text-muted-foreground">
{new Date(entry.createdAt).toLocaleDateString()} ·{" "}
{(entry.bytes / 1048576).toFixed(1)} MB
</span>
</span>
</Button>
<Button
isIconOnly
variant="ghost"
aria-label={`Show ${entry.name} in folder`}
onPress={() => void reveal(entry.path)}
>
<FolderOpen className="size-4" />
</Button>
</li>
))}
</ul>
<div className="relative flex aspect-[4/3] w-full items-center justify-center overflow-hidden rounded-xl bg-default/60">
{isAudio(entry.name) ? (
<MusicNotes weight="fill" className="size-8 text-muted-foreground/40" />
) : (
<p className="py-12 text-center text-sm text-muted-foreground">
{query ? "No matching raw files" : "No raw recordings yet"}
</p>
<video
ref={video}
poster={poster}
src={entry.url}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover"
onTimeUpdate={(event) => {
if (event.currentTarget.currentTime >= 5) event.currentTarget.pause();
}}
/>
)}
<Modal
isOpen={!!preview}
onOpenChange={(open) => {
if (!open) setPreview(null);
}}
>
<Modal.Backdrop>
<Modal.Container size="lg">
<Modal.Dialog aria-label="Raw file preview">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading>{preview?.name}</Modal.Heading>
</Modal.Header>
<Modal.Body>
{preview &&
(/\.(wav|m4a|mp3|ogg|flac)$/i.test(preview.name) ? (
<audio controls src={preview.url} className="w-full" />
) : (
<video
controls
src={preview.url}
className="max-h-[65vh] w-full rounded-xl"
/>
))}
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</section>
</div>
);
}
export function RawPreview({
entry,
onClose,
}: {
entry: ProjectLibraryEntry | null;
onClose: () => void;
}) {
return (
<Modal
isOpen={!!entry}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<Modal.Backdrop>
<Modal.Container size="lg">
<Modal.Dialog aria-label="Raw file preview">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading>{entry?.name}</Modal.Heading>
</Modal.Header>
<Modal.Body>
{entry?.rawSource &&
(isAudio(entry.rawSource.name) ? (
<audio controls src={entry.rawSource.url} className="w-full" />
) : (
<video
controls
src={entry.rawSource.url}
className="max-h-[65vh] w-full rounded-xl"
/>
))}
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
);
}
@@ -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<string[]>([]);
@@ -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<ProjectLibraryEntry | null>(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<void>) => {
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,
@@ -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<string, string> {
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<ProjectLibraryEntry[]>([]);
const [error, setError] = useState<string | null>(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 };
}
@@ -91,7 +91,7 @@ export function RecordingLibraryPanel({
textValue="Open recordings folder"
onAction={() => void window.electronAPI.openRecordingsFolder()}
>
<FolderOpen className="size-4" />
<FolderOpen weight="fill" className="size-4" />
<Label>Open recordings folder</Label>
</Dropdown.Item>
<Dropdown.Item
@@ -199,6 +199,17 @@ export function useProjectOpenActions({
project.error,
]);
useEffect(() => {
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,
+15
View File
@@ -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 () => ({
+16 -1
View File
@@ -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];
+42 -4
View File
@@ -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();