diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4cc446f..5638ffc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -97,4 +97,5 @@ jobs: path: | release/**/*.AppImage release/**/*.zsync + release/**/*.deb retention-days: 30 diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 573aee8..b2a3720 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -26,6 +26,8 @@ interface Window { electronAPI: { getSources: (opts: Electron.SourcesOptions) => Promise; switchToEditor: () => Promise; + switchToHud: () => Promise; + startNewRecording: () => Promise<{ success: boolean; error?: string }>; openSourceSelector: () => Promise; selectSource: (source: ProcessedDesktopSource) => Promise; getSelectedSource: () => Promise; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index e43f53c..4cb4875 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -355,7 +355,24 @@ export function registerIpcHandlers( getMainWindow: () => BrowserWindow | null, getSourceSelectorWindow: () => BrowserWindow | null, onRecordingStateChange?: (recording: boolean, sourceName: string) => void, + switchToHud?: () => void, ) { + ipcMain.handle("switch-to-hud", () => { + if (switchToHud) switchToHud(); + }); + ipcMain.handle("start-new-recording", async () => { + try { + setCurrentRecordingSessionState(null); + if (switchToHud) { + switchToHud(); + } + return { success: true }; + } catch (error) { + console.error("Failed to start new recording:", error); + return { success: false, error: String(error) }; + } + }); + ipcMain.handle("get-sources", async (_, opts) => { const sources = await desktopCapturer.getSources(opts); return sources.map((source) => ({ diff --git a/electron/main.ts b/electron/main.ts index 7e19d46..0f06f9e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -371,6 +371,16 @@ app.whenReady().then(async () => { // Ensure recordings directory exists await ensureRecordingsDir(); + function switchToHudWrapper() { + if (mainWindow) { + isForceClosing = true; + mainWindow.close(); + isForceClosing = false; + mainWindow = null; + } + showMainWindow(); + } + registerIpcHandlers( createEditorWindowWrapper, createSourceSelectorWindowWrapper, @@ -384,6 +394,7 @@ app.whenReady().then(async () => { showMainWindow(); } }, + switchToHudWrapper, ); createWindow(); }); diff --git a/electron/preload.ts b/electron/preload.ts index 8f1836b..eeca25c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -18,6 +18,12 @@ contextBridge.exposeInMainWorld("electronAPI", { switchToEditor: () => { return ipcRenderer.invoke("switch-to-editor"); }, + switchToHud: () => { + return ipcRenderer.invoke("switch-to-hud"); + }, + startNewRecording: () => { + return ipcRenderer.invoke("start-new-recording"); + }, openSourceSelector: () => { return ipcRenderer.invoke("open-source-selector"); }, diff --git a/package.json b/package.json index c367f9e..8817372 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,10 @@ "node": "22.22.1", "npm": "10.9.4" }, + "author": { + "name": "Sid", + "email": "svaddem@asu.edu" + }, "scripts": { "dev": "vite", "build": "tsc && vite build && electron-builder", @@ -18,7 +22,7 @@ "preview": "vite preview", "build:mac": "tsc && vite build && electron-builder --mac", "build:win": "tsc && vite build && electron-builder --win", - "build:linux": "tsc && vite build && electron-builder --linux", + "build:linux": "tsc && vite build && electron-builder --linux AppImage deb", "test": "vitest --run", "test:watch": "vitest", "build-vite": "tsc && vite build", diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index c2d66ff..daf5f42 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -56,7 +56,70 @@ import type { ZoomDepth, ZoomFocusMode, } from "./types"; -import { DEFAULT_WEBCAM_SIZE_PRESET, SPEED_OPTIONS } from "./types"; +import { DEFAULT_WEBCAM_SIZE_PRESET, MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types"; + +function CustomSpeedInput({ + value, + onChange, + onError, +}: { + value: number; + onChange: (val: number) => void; + onError: () => void; +}) { + const isPreset = SPEED_OPTIONS.some((o) => o.speed === value); + const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value))); + const [isFocused, setIsFocused] = useState(false); + + const prevValue = useRef(value); + if (!isFocused && prevValue.current !== value) { + prevValue.current = value; + setDraft(isPreset ? "" : String(Math.round(value))); + } + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const digits = e.target.value.replace(/\D/g, ""); + if (digits === "") { + setDraft(""); + return; + } + const num = Number(digits); + if (num > MAX_PLAYBACK_SPEED) { + onError(); + return; + } + setDraft(digits); + if (num >= 1) onChange(num); + }, + [onChange, onError], + ); + + const handleBlur = useCallback(() => { + setIsFocused(false); + if (!draft || Number(draft) < 1) { + setDraft(isPreset ? "" : String(Math.round(value))); + } + }, [draft, isPreset, value]); + + return ( +
+ setIsFocused(true)} + onChange={handleChange} + onBlur={handleBlur} + onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()} + className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-[#d97706] text-center focus:outline-none focus:border-[#d97706]/40" + /> + × +
+ ); +} const WALLPAPER_COUNT = 18; const WALLPAPER_RELATIVE = Array.from( @@ -595,7 +658,7 @@ export function SettingsPanel({ )} -
+
{SPEED_OPTIONS.map((option) => { const isActive = selectedSpeedValue === option.speed; return ( @@ -620,6 +683,29 @@ export function SettingsPanel({ ); })}
+
+
+ + {t("speed.customPlaybackSpeed")} + + {selectedSpeedId ? ( + onSpeedChange?.(val)} + onError={() => toast.error(t("speed.maxSpeedError"))} + /> + ) : ( +
+
+ -- +
+ × +
+ )} +
+
{!selectedSpeedId && (

{t("speed.selectRegion")}

)} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index cbe80f2..88c3aae 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1,8 +1,16 @@ import type { Span } from "dnd-timeline"; -import { FolderOpen, Languages, Save } from "lucide-react"; +import { FolderOpen, Languages, Save, Video } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory"; @@ -118,6 +126,7 @@ export default function VideoEditor() { const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); const [showExportDialog, setShowExportDialog] = useState(false); + const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false); const [exportQuality, setExportQuality] = useState("good"); const [exportFormat, setExportFormat] = useState("mp4"); const [gifFrameRate, setGifFrameRate] = useState(15); @@ -506,6 +515,16 @@ export default function VideoEditor() { await saveProject(true); }, [saveProject]); + const handleNewRecordingConfirm = useCallback(async () => { + const result = await window.electronAPI.startNewRecording(); + if (result.success) { + setShowNewRecordingDialog(false); + } else { + console.error("Failed to start new recording:", result.error); + setError("Failed to start new recording: " + (result.error || "Unknown error")); + } + }, []); + const handleLoadProject = useCallback(async () => { const result = await window.electronAPI.loadProjectFile(); @@ -1514,6 +1533,34 @@ export default function VideoEditor() { return (
+ + + + {t("newRecording.title")} + {t("newRecording.description")} + + + + + + + +
+