mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 15:25:44 +00:00
feat(hud): reshuffle HUD menu and add recordings location picker
- Move recordings path control into HUD menu bar and refine separators/layout - Show underlined Path: /folder/ label next to Record controls - Add native folder picker to change recordings location - Persist selected recordings directory and route recording/project defaults through it
This commit is contained in:
Vendored
+3
-1
@@ -70,6 +70,8 @@ interface Window {
|
||||
getPlatform: () => Promise<string>
|
||||
revealInFolder: (filePath: string) => Promise<{ success: boolean; error?: string; message?: string }>,
|
||||
openRecordingsFolder: () => Promise<{ success: boolean; error?: string; message?: string }>,
|
||||
getRecordingsDirectory: () => Promise<{ success: boolean; path: string; isDefault: boolean; error?: string }>
|
||||
chooseRecordingsDirectory: () => Promise<{ success: boolean; canceled?: boolean; path?: string; isDefault?: boolean; message?: string; error?: string }>
|
||||
getShortcuts: () => Promise<Record<string, unknown> | null>
|
||||
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>
|
||||
hudOverlayHide: () => void;
|
||||
@@ -108,4 +110,4 @@ interface SystemCursorAsset {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
|
||||
|
||||
+103
-15
@@ -15,6 +15,7 @@ const nodeRequire = createRequire(import.meta.url)
|
||||
const PROJECT_FILE_EXTENSION = 'recordly'
|
||||
const LEGACY_PROJECT_FILE_EXTENSIONS = ['openscreen']
|
||||
const SHORTCUTS_FILE = path.join(app.getPath('userData'), 'shortcuts.json')
|
||||
const RECORDINGS_SETTINGS_FILE = path.join(app.getPath('userData'), 'recordings-settings.json')
|
||||
const AUTO_RECORDING_PREFIX = 'recording-'
|
||||
const AUTO_RECORDING_RETENTION_COUNT = 20
|
||||
const AUTO_RECORDING_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000
|
||||
@@ -62,6 +63,8 @@ let ffmpegScreenRecordingActive = false
|
||||
let ffmpegCaptureProcess: ChildProcessWithoutNullStreams | null = null
|
||||
let ffmpegCaptureOutputBuffer = ''
|
||||
let ffmpegCaptureTargetPath: string | null = null
|
||||
let customRecordingsDir: string | null = null
|
||||
let recordingsDirLoaded = false
|
||||
let cachedSystemCursorAssets: Record<string, SystemCursorAsset> | null = null
|
||||
let cachedSystemCursorAssetsSourceMtimeMs: number | null = null
|
||||
|
||||
@@ -121,6 +124,41 @@ function getTelemetryPathForVideo(videoPath: string) {
|
||||
return `${videoPath}.cursor.json`
|
||||
}
|
||||
|
||||
async function loadRecordingsDirectorySetting() {
|
||||
if (recordingsDirLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
recordingsDirLoaded = true
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, 'utf-8')
|
||||
const parsed = JSON.parse(content) as { recordingsDir?: unknown }
|
||||
if (typeof parsed.recordingsDir === 'string' && parsed.recordingsDir.trim()) {
|
||||
customRecordingsDir = path.resolve(parsed.recordingsDir)
|
||||
}
|
||||
} catch {
|
||||
customRecordingsDir = null
|
||||
}
|
||||
}
|
||||
|
||||
async function getRecordingsDir() {
|
||||
await loadRecordingsDirectorySetting()
|
||||
const targetDir = customRecordingsDir ?? RECORDINGS_DIR
|
||||
await fs.mkdir(targetDir, { recursive: true })
|
||||
return targetDir
|
||||
}
|
||||
|
||||
async function persistRecordingsDirectorySetting(nextDir: string) {
|
||||
customRecordingsDir = path.resolve(nextDir)
|
||||
recordingsDirLoaded = true
|
||||
await fs.writeFile(
|
||||
RECORDINGS_SETTINGS_FILE,
|
||||
JSON.stringify({ recordingsDir: customRecordingsDir }, null, 2),
|
||||
'utf-8',
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeVideoPathInput(videoPath: string) {
|
||||
if (!videoPath.startsWith('file:')) {
|
||||
return videoPath
|
||||
@@ -157,18 +195,19 @@ async function hasSiblingProjectFile(videoPath: string) {
|
||||
}
|
||||
|
||||
async function pruneAutoRecordings(exemptPaths: string[] = []) {
|
||||
const recordingsDir = await getRecordingsDir()
|
||||
const exempt = new Set(
|
||||
[currentVideoPath, ...exemptPaths]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => normalizePath(value)),
|
||||
)
|
||||
|
||||
const entries = await fs.readdir(RECORDINGS_DIR, { withFileTypes: true })
|
||||
const entries = await fs.readdir(recordingsDir, { withFileTypes: true })
|
||||
const autoRecordingStats = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile() && /^recording-.*\.(mp4|mov|webm)$/i.test(entry.name))
|
||||
.map(async (entry) => {
|
||||
const filePath = path.join(RECORDINGS_DIR, entry.name)
|
||||
const filePath = path.join(recordingsDir, entry.name)
|
||||
const stats = await fs.stat(filePath)
|
||||
return { filePath, stats }
|
||||
}),
|
||||
@@ -1473,6 +1512,7 @@ export function registerIpcHandlers(
|
||||
}
|
||||
|
||||
try {
|
||||
const recordingsDir = await getRecordingsDir()
|
||||
const appName = normalizeDesktopSourceName(String(source?.appName ?? ''))
|
||||
const ownAppName = normalizeDesktopSourceName(app.getName())
|
||||
if (
|
||||
@@ -1486,11 +1526,11 @@ export function registerIpcHandlers(
|
||||
}
|
||||
|
||||
const helperPath = await ensureNativeCaptureHelperBinary()
|
||||
const outputPath = path.join(RECORDINGS_DIR, `recording-${Date.now()}.mp4`)
|
||||
const outputPath = path.join(recordingsDir, `recording-${Date.now()}.mp4`)
|
||||
const capturesSystemAudio = Boolean(options?.capturesSystemAudio)
|
||||
const capturesMicrophone = Boolean(options?.capturesMicrophone)
|
||||
const microphoneOutputPath = capturesSystemAudio && capturesMicrophone
|
||||
? path.join(RECORDINGS_DIR, `recording-${Date.now()}.mic.m4a`)
|
||||
? path.join(recordingsDir, `recording-${Date.now()}.mic.m4a`)
|
||||
: null
|
||||
const config: Record<string, unknown> = {
|
||||
fps: 60,
|
||||
@@ -1523,7 +1563,7 @@ export function registerIpcHandlers(
|
||||
nativeCaptureMicrophonePath = microphoneOutputPath
|
||||
nativeCaptureStopRequested = false
|
||||
nativeCaptureProcess = spawn(helperPath, [JSON.stringify(config)], {
|
||||
cwd: RECORDINGS_DIR,
|
||||
cwd: recordingsDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
attachNativeCaptureLifecycle(nativeCaptureProcess)
|
||||
@@ -1642,14 +1682,15 @@ export function registerIpcHandlers(
|
||||
}
|
||||
|
||||
try {
|
||||
const recordingsDir = await getRecordingsDir()
|
||||
const ffmpegPath = getFfmpegBinaryPath()
|
||||
const outputPath = path.join(RECORDINGS_DIR, `recording-${Date.now()}.mp4`)
|
||||
const outputPath = path.join(recordingsDir, `recording-${Date.now()}.mp4`)
|
||||
const args = await buildFfmpegCaptureArgs(source, outputPath)
|
||||
|
||||
ffmpegCaptureOutputBuffer = ''
|
||||
ffmpegCaptureTargetPath = outputPath
|
||||
ffmpegCaptureProcess = spawn(ffmpegPath, args, {
|
||||
cwd: RECORDINGS_DIR,
|
||||
cwd: recordingsDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
@@ -1713,7 +1754,8 @@ export function registerIpcHandlers(
|
||||
|
||||
ipcMain.handle('store-recorded-video', async (_, videoData: ArrayBuffer, fileName: string) => {
|
||||
try {
|
||||
const videoPath = path.join(RECORDINGS_DIR, fileName)
|
||||
const recordingsDir = await getRecordingsDir()
|
||||
const videoPath = path.join(recordingsDir, fileName)
|
||||
await fs.writeFile(videoPath, Buffer.from(videoData))
|
||||
return await finalizeStoredVideo(videoPath)
|
||||
} catch (error) {
|
||||
@@ -1730,7 +1772,8 @@ export function registerIpcHandlers(
|
||||
|
||||
ipcMain.handle('get-recorded-video-path', async () => {
|
||||
try {
|
||||
const files = await fs.readdir(RECORDINGS_DIR)
|
||||
const recordingsDir = await getRecordingsDir()
|
||||
const files = await fs.readdir(recordingsDir)
|
||||
const videoFiles = files.filter(file => /\.(webm|mov|mp4)$/i.test(file))
|
||||
|
||||
if (videoFiles.length === 0) {
|
||||
@@ -1738,7 +1781,7 @@ export function registerIpcHandlers(
|
||||
}
|
||||
|
||||
const latestVideo = videoFiles.sort().reverse()[0]
|
||||
const videoPath = path.join(RECORDINGS_DIR, latestVideo)
|
||||
const videoPath = path.join(recordingsDir, latestVideo)
|
||||
|
||||
return { success: true, path: videoPath }
|
||||
} catch (error) {
|
||||
@@ -1986,9 +2029,10 @@ export function registerIpcHandlers(
|
||||
|
||||
ipcMain.handle('open-video-file-picker', async () => {
|
||||
try {
|
||||
const recordingsDir = await getRecordingsDir()
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Select Video File',
|
||||
defaultPath: RECORDINGS_DIR,
|
||||
defaultPath: recordingsDir,
|
||||
filters: [
|
||||
{ name: 'Video Files', extensions: ['webm', 'mp4', 'mov', 'avi', 'mkv'] },
|
||||
{ name: 'All Files', extensions: ['*'] }
|
||||
@@ -2041,8 +2085,8 @@ export function registerIpcHandlers(
|
||||
|
||||
ipcMain.handle('open-recordings-folder', async () => {
|
||||
try {
|
||||
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
|
||||
const openPathResult = await shell.openPath(RECORDINGS_DIR);
|
||||
const recordingsDir = await getRecordingsDir();
|
||||
const openPathResult = await shell.openPath(recordingsDir);
|
||||
if (openPathResult) {
|
||||
return { success: false, error: openPathResult, message: 'Failed to open recordings folder.' };
|
||||
}
|
||||
@@ -2054,8 +2098,51 @@ export function registerIpcHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-recordings-directory', async () => {
|
||||
try {
|
||||
const recordingsDir = await getRecordingsDir()
|
||||
return {
|
||||
success: true,
|
||||
path: recordingsDir,
|
||||
isDefault: recordingsDir === RECORDINGS_DIR,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
path: RECORDINGS_DIR,
|
||||
isDefault: true,
|
||||
error: String(error),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('choose-recordings-directory', async () => {
|
||||
try {
|
||||
const current = await getRecordingsDir()
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Choose recordings folder',
|
||||
defaultPath: current,
|
||||
properties: ['openDirectory', 'createDirectory', 'promptToCreate'],
|
||||
})
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true, path: current }
|
||||
}
|
||||
|
||||
const selectedPath = path.resolve(result.filePaths[0])
|
||||
await fs.mkdir(selectedPath, { recursive: true })
|
||||
await fs.access(selectedPath, fsConstants.W_OK)
|
||||
await persistRecordingsDirectorySetting(selectedPath)
|
||||
|
||||
return { success: true, path: selectedPath, isDefault: selectedPath === RECORDINGS_DIR }
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error), message: 'Failed to set recordings folder' }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
|
||||
try {
|
||||
const recordingsDir = await getRecordingsDir()
|
||||
const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath)
|
||||
? existingProjectPath
|
||||
: null
|
||||
@@ -2077,7 +2164,7 @@ export function registerIpcHandlers(
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: 'Save Recordly Project',
|
||||
defaultPath: path.join(RECORDINGS_DIR, defaultName),
|
||||
defaultPath: path.join(recordingsDir, defaultName),
|
||||
filters: [
|
||||
{ name: 'Recordly Project', extensions: [PROJECT_FILE_EXTENSION] },
|
||||
{ name: 'JSON', extensions: ['json'] }
|
||||
@@ -2113,9 +2200,10 @@ export function registerIpcHandlers(
|
||||
|
||||
ipcMain.handle('load-project-file', async () => {
|
||||
try {
|
||||
const recordingsDir = await getRecordingsDir()
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Open Recordly Project',
|
||||
defaultPath: RECORDINGS_DIR,
|
||||
defaultPath: recordingsDir,
|
||||
filters: [
|
||||
{ name: 'Recordly Project', extensions: [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS] },
|
||||
{ name: 'JSON', extensions: ['json'] },
|
||||
|
||||
@@ -154,6 +154,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
openRecordingsFolder: () => {
|
||||
return ipcRenderer.invoke('open-recordings-folder')
|
||||
},
|
||||
getRecordingsDirectory: () => {
|
||||
return ipcRenderer.invoke('get-recordings-directory')
|
||||
},
|
||||
chooseRecordingsDirectory: () => {
|
||||
return ipcRenderer.invoke('choose-recordings-directory')
|
||||
},
|
||||
getShortcuts: () => {
|
||||
return ipcRenderer.invoke('get-shortcuts')
|
||||
},
|
||||
|
||||
@@ -66,6 +66,7 @@ export function LaunchWindow() {
|
||||
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
const [recordingsDirectory, setRecordingsDirectory] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSelectedSource = async () => {
|
||||
@@ -118,6 +119,32 @@ export function LaunchWindow() {
|
||||
window.electronAPI?.hudOverlayClose?.();
|
||||
};
|
||||
|
||||
const chooseRecordingsDirectory = async () => {
|
||||
const result = await window.electronAPI.chooseRecordingsDirectory();
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
if (result.success && result.path) {
|
||||
setRecordingsDirectory(result.path);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadRecordingsDirectory = async () => {
|
||||
const result = await window.electronAPI.getRecordingsDirectory();
|
||||
if (result.success) {
|
||||
setRecordingsDirectory(result.path);
|
||||
}
|
||||
};
|
||||
|
||||
void loadRecordingsDirectory();
|
||||
}, []);
|
||||
|
||||
const recordingsDirectoryName = recordingsDirectory
|
||||
? recordingsDirectory.split(/[\\/]/).filter(Boolean).pop() || recordingsDirectory
|
||||
: "recordings";
|
||||
const dividerClass = "mx-1 h-5 w-px shrink-0 bg-white/35";
|
||||
|
||||
const toggleMicrophone = () => {
|
||||
if (!recording) {
|
||||
setMicrophoneEnabled(!microphoneEnabled);
|
||||
@@ -176,7 +203,7 @@ export function LaunchWindow() {
|
||||
<ContentClamp truncateLength={6}>{selectedSource}</ContentClamp>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-white/20" />
|
||||
<div className={dividerClass} />
|
||||
|
||||
<div className={`flex items-center gap-1 ${styles.electronNoDrag}`}>
|
||||
<Button
|
||||
@@ -201,7 +228,7 @@ export function LaunchWindow() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-white/20" />
|
||||
<div className={dividerClass} />
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
@@ -223,7 +250,19 @@ export function LaunchWindow() {
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={chooseRecordingsDirectory}
|
||||
disabled={recording}
|
||||
title={recordingsDirectory ? `Recording folder: ${recordingsDirectory}` : "Choose recordings folder"}
|
||||
className={`text-white/75 hover:bg-transparent px-1 text-[11px] underline decoration-white/45 underline-offset-2 ${styles.electronNoDrag}`}
|
||||
>
|
||||
<ContentClamp truncateLength={18}>{`Path: /${recordingsDirectoryName}/`}</ContentClamp>
|
||||
</Button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<div className={dividerClass} />
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
@@ -244,6 +283,7 @@ export function LaunchWindow() {
|
||||
>
|
||||
<FaFolderOpen size={14} />
|
||||
</Button>
|
||||
<div className={dividerClass} />
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
|
||||
Vendored
+3
-1
@@ -143,6 +143,8 @@ interface Window {
|
||||
hideOsCursor: () => Promise<{ success: boolean }>
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<void>) => () => void
|
||||
getRecordingsDirectory: () => Promise<{ success: boolean; path: string; isDefault: boolean; error?: string }>
|
||||
chooseRecordingsDirectory: () => Promise<{ success: boolean; canceled?: boolean; path?: string; isDefault?: boolean; message?: string; error?: string }>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user