Unify Raw browsing, render 4:3 previews, and move HUD settings to dashboard
No files matched your search
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 87 KiB |
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
|
||||