mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 07:45:34 +00:00
- Redesign HUD overlay bar with dark glassmorphism aesthetic and inline source dropdown
- Add pause, resume, and cancel recording controls with elapsed timer - Move folder, language, and system audio into three-dots overflow menu with source highlight wave
This commit is contained in:
Vendored
+1
@@ -28,6 +28,7 @@ interface Window {
|
||||
switchToEditor: () => Promise<void>
|
||||
openSourceSelector: () => Promise<void>
|
||||
selectSource: (source: any) => Promise<any>
|
||||
showSourceHighlight: (source: any) => Promise<{ success: boolean }>
|
||||
getSelectedSource: () => Promise<any>
|
||||
startNativeScreenRecording: (
|
||||
source: any,
|
||||
|
||||
@@ -1715,6 +1715,169 @@ export function registerIpcHandlers(
|
||||
return selectedSource
|
||||
})
|
||||
|
||||
ipcMain.handle('show-source-highlight', async (_, source: SelectedSource) => {
|
||||
try {
|
||||
const isWindow = source.id?.startsWith('window:')
|
||||
const windowId = isWindow ? parseWindowId(source.id) : null
|
||||
|
||||
// ── 1. Bring window to front & get its bounds via AppleScript ──
|
||||
let asBounds: { x: number; y: number; width: number; height: number } | null = null
|
||||
|
||||
if (isWindow && process.platform === 'darwin') {
|
||||
const appName = source.appName || source.name?.split(' — ')[0]?.trim()
|
||||
if (appName) {
|
||||
// Single AppleScript: activate AND return window bounds
|
||||
try {
|
||||
const { stdout } = await execFileAsync('osascript', ['-e',
|
||||
`tell application "${appName}"\n` +
|
||||
` activate\n` +
|
||||
`end tell\n` +
|
||||
`delay 0.3\n` +
|
||||
`tell application "System Events"\n` +
|
||||
` tell process "${appName}"\n` +
|
||||
` set frontWindow to front window\n` +
|
||||
` set {x1, y1} to position of frontWindow\n` +
|
||||
` set {w1, h1} to size of frontWindow\n` +
|
||||
` return (x1 as text) & "," & (y1 as text) & "," & (w1 as text) & "," & (h1 as text)\n` +
|
||||
` end tell\n` +
|
||||
`end tell`
|
||||
], { timeout: 4000 })
|
||||
const parts = stdout.trim().split(',').map(Number)
|
||||
if (parts.length === 4 && parts.every(n => Number.isFinite(n))) {
|
||||
asBounds = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
|
||||
}
|
||||
} catch {
|
||||
// Fallback: just activate without bounds
|
||||
try {
|
||||
await execFileAsync('osascript', ['-e',
|
||||
`tell application "${appName}" to activate`
|
||||
], { timeout: 2000 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 350))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} else if (windowId && process.platform === 'linux') {
|
||||
try {
|
||||
await execFileAsync('wmctrl', ['-i', '-a', `0x${windowId.toString(16)}`], { timeout: 1500 })
|
||||
} catch {
|
||||
try {
|
||||
await execFileAsync('xdotool', ['windowactivate', String(windowId)], { timeout: 1500 })
|
||||
} catch { /* not available */ }
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
|
||||
// ── 2. Resolve bounds ──
|
||||
let bounds = asBounds
|
||||
|
||||
if (!bounds) {
|
||||
if (source.id?.startsWith('screen:')) {
|
||||
bounds = getDisplayBoundsForSource(source)
|
||||
} else if (isWindow) {
|
||||
if (process.platform === 'darwin') {
|
||||
bounds = await resolveMacWindowBounds(source)
|
||||
} else if (process.platform === 'linux') {
|
||||
bounds = await resolveLinuxWindowBounds(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
|
||||
bounds = getDisplayBoundsForSource(source)
|
||||
}
|
||||
|
||||
// ── 3. Show traveling wave highlight ──
|
||||
const pad = 6
|
||||
const highlightWin = new BrowserWindow({
|
||||
x: bounds.x - pad,
|
||||
y: bounds.y - pad,
|
||||
width: bounds.width + pad * 2,
|
||||
height: bounds.height + pad * 2,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
resizable: false,
|
||||
focusable: false,
|
||||
webPreferences: { nodeIntegration: false, contextIsolation: true },
|
||||
})
|
||||
|
||||
highlightWin.setIgnoreMouseEvents(true)
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head><style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:transparent;overflow:hidden;width:100vw;height:100vh}
|
||||
|
||||
.border-wrap{
|
||||
position:fixed;inset:0;border-radius:10px;padding:3px;
|
||||
background:conic-gradient(from var(--angle,0deg),
|
||||
transparent 0%,
|
||||
transparent 60%,
|
||||
rgba(99,96,245,.15) 70%,
|
||||
rgba(99,96,245,.9) 80%,
|
||||
rgba(123,120,255,1) 85%,
|
||||
rgba(99,96,245,.9) 90%,
|
||||
rgba(99,96,245,.15) 95%,
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite:xor;
|
||||
mask-composite:exclude;
|
||||
animation:spin 1.2s linear forwards, fadeAll 1.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.glow-wrap{
|
||||
position:fixed;inset:-4px;border-radius:14px;padding:6px;
|
||||
background:conic-gradient(from var(--angle,0deg),
|
||||
transparent 0%,
|
||||
transparent 65%,
|
||||
rgba(99,96,245,.3) 78%,
|
||||
rgba(123,120,255,.5) 85%,
|
||||
rgba(99,96,245,.3) 92%,
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite:xor;
|
||||
mask-composite:exclude;
|
||||
filter:blur(8px);
|
||||
animation:spin 1.2s linear forwards, fadeAll 1.6s ease-out forwards;
|
||||
}
|
||||
|
||||
@property --angle{
|
||||
syntax:'<angle>';
|
||||
initial-value:0deg;
|
||||
inherits:false;
|
||||
}
|
||||
|
||||
@keyframes spin{
|
||||
0%{--angle:0deg}
|
||||
100%{--angle:360deg}
|
||||
}
|
||||
|
||||
@keyframes fadeAll{
|
||||
0%,60%{opacity:1}
|
||||
100%{opacity:0}
|
||||
}
|
||||
</style></head><body>
|
||||
<div class="glow-wrap"></div>
|
||||
<div class="border-wrap"></div>
|
||||
</body></html>`
|
||||
|
||||
await highlightWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
|
||||
|
||||
setTimeout(() => {
|
||||
if (!highlightWin.isDestroyed()) highlightWin.close()
|
||||
}, 1700)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Failed to show source highlight:', error)
|
||||
return { success: false }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('get-selected-source', () => {
|
||||
return selectedSource
|
||||
})
|
||||
|
||||
@@ -26,6 +26,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
selectSource: (source: any) => {
|
||||
return ipcRenderer.invoke('select-source', source)
|
||||
},
|
||||
showSourceHighlight: (source: any) => {
|
||||
return ipcRenderer.invoke('show-source-highlight', source)
|
||||
},
|
||||
getSelectedSource: () => {
|
||||
return ipcRenderer.invoke('get-selected-source')
|
||||
},
|
||||
|
||||
+3
-3
@@ -29,7 +29,7 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
|
||||
|
||||
const windowWidth = 600;
|
||||
const windowHeight = 155;
|
||||
const windowHeight = 450;
|
||||
|
||||
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
|
||||
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
|
||||
@@ -39,8 +39,8 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
height: windowHeight,
|
||||
minWidth: 600,
|
||||
maxWidth: 600,
|
||||
minHeight: 155,
|
||||
maxHeight: 155,
|
||||
minHeight: 450,
|
||||
maxHeight: 450,
|
||||
x: x,
|
||||
y: y,
|
||||
frame: false,
|
||||
|
||||
@@ -22,6 +22,12 @@ export default function App() {
|
||||
document.getElementById('root')?.style.setProperty('background', 'transparent');
|
||||
}
|
||||
|
||||
if (type === 'hud-overlay') {
|
||||
document.documentElement.style.overflow = 'visible';
|
||||
document.body.style.overflow = 'visible';
|
||||
document.getElementById('root')?.style.setProperty('overflow', 'visible');
|
||||
}
|
||||
|
||||
// Load custom fonts on app initialization
|
||||
loadAllCustomFonts().catch((error) => {
|
||||
console.error('Failed to load custom fonts:', error);
|
||||
|
||||
@@ -1,51 +1,232 @@
|
||||
.electronDrag {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.hudBar {
|
||||
isolation: isolate;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.electronNoDrag {
|
||||
-webkit-app-region: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
|
||||
.folderButton {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(18, 18, 24, 0.97);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 16px;
|
||||
padding: 7px 10px;
|
||||
box-shadow:
|
||||
0 8px 40px rgba(0, 0, 0, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.folderText {
|
||||
color: #cbd5e1;
|
||||
transition: text-decoration 0.15s;
|
||||
.sep {
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: #2a2a34;
|
||||
margin: 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folderButton:hover .folderText {
|
||||
text-decoration: underline;
|
||||
.ib {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #6b6b78;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hudOverlayButton {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
.ib:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: #eeeef2;
|
||||
}
|
||||
.hudOverlayButton:hover {
|
||||
opacity: 0.7;
|
||||
background: none !important;
|
||||
|
||||
.ibActive {
|
||||
color: #6360f5;
|
||||
}
|
||||
|
||||
.ibActive:hover {
|
||||
color: #7b78ff;
|
||||
}
|
||||
|
||||
.ibRed {
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
.ibRed:hover {
|
||||
color: #ff5a75;
|
||||
}
|
||||
|
||||
.ibGreen {
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.ibGreen:hover {
|
||||
color: #4eeeb0;
|
||||
}
|
||||
|
||||
.screenSel {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 36px;
|
||||
padding: 0 12px 0 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2a2a34;
|
||||
background: #1a1a22;
|
||||
color: #eeeef2;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.screenSel:hover {
|
||||
border-color: #3e3e4c;
|
||||
background: #20202a;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
width: 260px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
background: rgba(22, 22, 30, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
margin-bottom: 8px;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5);
|
||||
animation: dropdownIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes dropdownIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.dropdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dropdown::-webkit-scrollbar-thumb {
|
||||
background: #2a2a34;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.ddLabel {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: #6b6b78;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 6px 10px 4px;
|
||||
}
|
||||
|
||||
.ddItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: #6b6b78;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ddItem:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #eeeef2;
|
||||
}
|
||||
|
||||
.ddItemSelected {
|
||||
color: #6360f5;
|
||||
}
|
||||
|
||||
.recBtn {
|
||||
position: relative;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #f43f5e;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 0 0 rgba(244, 63, 94, 0.3);
|
||||
}
|
||||
|
||||
.recBtn:hover {
|
||||
background: #ff5a75;
|
||||
box-shadow: 0 0 0 6px rgba(244, 63, 94, 0.15);
|
||||
}
|
||||
|
||||
.recBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.recBtn:disabled:hover {
|
||||
background: #f43f5e;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.recDot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.recDotBlink {
|
||||
animation: blink 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
.micSelect {
|
||||
color-scheme: dark;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.micSelect option {
|
||||
background-color: #131722;
|
||||
color: #e5e7eb;
|
||||
background-color: #1a1a22;
|
||||
color: #eeeef2;
|
||||
}
|
||||
|
||||
@@ -1,355 +1,509 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { BsRecordCircle } from "react-icons/bs";
|
||||
import { FaRegStopCircle } from "react-icons/fa";
|
||||
import { FaFolderOpen } from "react-icons/fa6";
|
||||
import { FiMinus, FiX } from "react-icons/fi";
|
||||
import { MdMic, MdMicOff, MdMonitor, MdVideoFile, MdVolumeOff, MdVolumeUp } from "react-icons/md";
|
||||
import { Languages } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Monitor,
|
||||
Mic,
|
||||
MicOff,
|
||||
ChevronUp,
|
||||
Pause,
|
||||
Square,
|
||||
X,
|
||||
Play,
|
||||
Minus,
|
||||
MoreVertical,
|
||||
FolderOpen,
|
||||
VideoIcon,
|
||||
Languages,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
AppWindow,
|
||||
} from "lucide-react";
|
||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||
import { useScopedT } from "../../contexts/I18nContext";
|
||||
import { Button } from "../ui/button";
|
||||
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
||||
import { ContentClamp } from "../ui/content-clamp";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { useI18n } from "@/contexts/I18nContext";
|
||||
import { SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import type { AppLocale } from "@/i18n/config";
|
||||
import styles from "./LaunchWindow.module.css";
|
||||
|
||||
export function LaunchWindow() {
|
||||
const { locale, setLocale } = useI18n();
|
||||
const t = useScopedT('launch');
|
||||
|
||||
const LOCALE_LABELS: Record<string, string> = { en: "EN", es: "ES", "zh-CN": "中文" };
|
||||
const {
|
||||
recording,
|
||||
toggleRecording,
|
||||
microphoneEnabled,
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
setMicrophoneDeviceId,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
} = useScreenRecorder();
|
||||
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const showMicControls = microphoneEnabled && !recording;
|
||||
const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices(microphoneEnabled);
|
||||
const { level } = useAudioLevelMeter({
|
||||
enabled: showMicControls,
|
||||
deviceId: microphoneDeviceId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDeviceId && selectedDeviceId !== "default") {
|
||||
setMicrophoneDeviceId(selectedDeviceId);
|
||||
}
|
||||
}, [selectedDeviceId, setMicrophoneDeviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
if (recording) {
|
||||
if (!recordingStart) setRecordingStart(Date.now());
|
||||
timer = setInterval(() => {
|
||||
if (recordingStart) {
|
||||
setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
setRecordingStart(null);
|
||||
setElapsed(0);
|
||||
if (timer) clearInterval(timer);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [recording, recordingStart]);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, "0");
|
||||
const s = (seconds % 60).toString().padStart(2, "0");
|
||||
return `${m}:${s}`;
|
||||
};
|
||||
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
const [recordingsDirectory, setRecordingsDirectory] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSelectedSource = async () => {
|
||||
if (window.electronAPI) {
|
||||
const source = await window.electronAPI.getSelectedSource();
|
||||
if (source) {
|
||||
setSelectedSource(source.name);
|
||||
setHasSelectedSource(true);
|
||||
} else {
|
||||
setSelectedSource("Screen");
|
||||
setHasSelectedSource(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void checkSelectedSource();
|
||||
const interval = setInterval(checkSelectedSource, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const openSourceSelector = () => {
|
||||
window.electronAPI?.openSourceSelector();
|
||||
};
|
||||
|
||||
const openVideoFile = async () => {
|
||||
const result = await window.electronAPI.openVideoFilePicker();
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success && result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
await window.electronAPI.switchToEditor();
|
||||
}
|
||||
};
|
||||
|
||||
const openProjectFile = async () => {
|
||||
const result = await window.electronAPI.loadProjectFile();
|
||||
if (result.canceled || !result.success) {
|
||||
return;
|
||||
}
|
||||
await window.electronAPI.switchToEditor();
|
||||
};
|
||||
|
||||
const sendHudOverlayHide = () => {
|
||||
window.electronAPI?.hudOverlayHide?.();
|
||||
};
|
||||
|
||||
const sendHudOverlayClose = () => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-end justify-center bg-transparent overflow-hidden">
|
||||
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
|
||||
{showMicControls && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-full border border-white/15 bg-[rgba(18,18,26,0.92)] px-3 py-2 shadow-xl backdrop-blur-xl ${styles.electronNoDrag}`}
|
||||
>
|
||||
<select
|
||||
value={microphoneDeviceId || selectedDeviceId}
|
||||
onChange={(event) => {
|
||||
setSelectedDeviceId(event.target.value);
|
||||
setMicrophoneDeviceId(event.target.value);
|
||||
}}
|
||||
className={`max-w-[230px] rounded-full border border-white/15 bg-[#131722] px-3 py-1 text-xs text-slate-100 outline-none ${styles.micSelect}`}
|
||||
>
|
||||
{devices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<AudioLevelMeter level={level} className="w-24" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`w-full mx-auto flex items-center gap-1.5 px-3 py-2 ${styles.electronDrag} ${styles.hudBar}`}
|
||||
style={{
|
||||
borderRadius: 9999,
|
||||
background: "linear-gradient(135deg, rgba(28,28,36,0.97) 0%, rgba(18,18,26,0.96) 100%)",
|
||||
backdropFilter: "blur(16px) saturate(140%)",
|
||||
WebkitBackdropFilter: "blur(16px) saturate(140%)",
|
||||
border: "1px solid rgba(80,80,120,0.25)",
|
||||
minHeight: 48,
|
||||
}}
|
||||
>
|
||||
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
||||
<RxDragHandleDots2 size={16} className="text-white/35" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className={`gap-1 text-white/80 bg-transparent hover:bg-transparent px-0 text-xs ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
disabled={recording}
|
||||
title={selectedSource}
|
||||
>
|
||||
<MdMonitor size={14} className="text-white/80" />
|
||||
<ContentClamp truncateLength={6}>{selectedSource}</ContentClamp>
|
||||
</Button>
|
||||
|
||||
<div className={dividerClass} />
|
||||
|
||||
<div className={`flex items-center gap-1 ${styles.electronNoDrag}`}>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
|
||||
disabled={recording}
|
||||
title={systemAudioEnabled ? t('recording.disableSystemAudio') : t('recording.enableSystemAudio')}
|
||||
className="text-white/80 hover:bg-transparent"
|
||||
>
|
||||
{systemAudioEnabled ? <MdVolumeUp size={16} className="text-[#2563EB]" /> : <MdVolumeOff size={16} className="text-white/35" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={toggleMicrophone}
|
||||
disabled={recording}
|
||||
title={microphoneEnabled ? t('recording.disableMicrophone') : t('recording.enableMicrophone')}
|
||||
className="text-white/80 hover:bg-transparent"
|
||||
>
|
||||
{microphoneEnabled ? <MdMic size={16} className="text-[#2563EB]" /> : <MdMicOff size={16} className="text-white/35" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={dividerClass} />
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
|
||||
disabled={!hasSelectedSource && !recording}
|
||||
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 text-xs ${styles.electronNoDrag}`}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
<FaRegStopCircle size={14} className="text-red-400" />
|
||||
<span className="text-red-400 font-medium tabular-nums">{formatTime(elapsed)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BsRecordCircle size={14} className={hasSelectedSource ? "text-white/85" : "text-white/35"} />
|
||||
<span className={hasSelectedSource ? "text-white/80" : "text-white/35"}>{t('recording.record')}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={chooseRecordingsDirectory}
|
||||
disabled={recording}
|
||||
title={recordingsDirectory ? t('recording.recordingFolder', undefined, { path: recordingsDirectory }) : t('recording.chooseRecordingsFolder')}
|
||||
className={`text-white/75 hover:bg-transparent px-1 text-[11px] underline decoration-white/45 underline-offset-2 ${styles.electronNoDrag}`}
|
||||
>
|
||||
<ContentClamp truncateLength={18}>{t('recording.folderPath', undefined, { name: recordingsDirectoryName })}</ContentClamp>
|
||||
</Button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<div className={dividerClass} />
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={openVideoFile}
|
||||
disabled={recording}
|
||||
title={t('recording.openVideoFile')}
|
||||
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
|
||||
>
|
||||
<MdVideoFile size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={openProjectFile}
|
||||
disabled={recording}
|
||||
title={t('recording.openProject')}
|
||||
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
|
||||
>
|
||||
<FaFolderOpen size={14} />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
title="Language"
|
||||
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
|
||||
>
|
||||
<Languages size={14} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="end"
|
||||
className="min-w-[90px] bg-[rgba(28,28,36,0.97)] border-white/15 text-white/90 backdrop-blur-xl"
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((code) => (
|
||||
<DropdownMenuItem
|
||||
key={code}
|
||||
onSelect={() => setLocale(code as AppLocale)}
|
||||
className={`text-xs cursor-pointer ${
|
||||
locale === code ? "text-white font-medium" : "text-white/60"
|
||||
}`}
|
||||
>
|
||||
{LOCALE_LABELS[code] ?? code}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className={dividerClass} />
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={sendHudOverlayHide}
|
||||
title={t('recording.hideHud')}
|
||||
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
|
||||
>
|
||||
<FiMinus size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={sendHudOverlayClose}
|
||||
title={t('recording.closeApp')}
|
||||
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
|
||||
>
|
||||
<FiX size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
interface DesktopSource {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnail: string | null;
|
||||
display_id: string;
|
||||
appIcon: string | null;
|
||||
sourceType?: "screen" | "window";
|
||||
appName?: string;
|
||||
windowTitle?: string;
|
||||
}
|
||||
|
||||
const LOCALE_LABELS: Record<string, string> = {
|
||||
en: "EN",
|
||||
es: "ES",
|
||||
"zh-CN": "中文",
|
||||
};
|
||||
|
||||
function IconButton({
|
||||
onClick,
|
||||
title,
|
||||
className = "",
|
||||
children,
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.ib} ${styles.electronNoDrag} ${className}`}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownItem({
|
||||
onClick,
|
||||
selected,
|
||||
icon,
|
||||
children,
|
||||
trailing,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
selected?: boolean;
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.ddItem} ${selected ? styles.ddItemSelected : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="shrink-0">{icon}</span>
|
||||
<span className="truncate">{children}</span>
|
||||
{trailing}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Separator() {
|
||||
return <div className={styles.sep} />;
|
||||
}
|
||||
|
||||
export function LaunchWindow() {
|
||||
const { locale, setLocale } = useI18n();
|
||||
const t = useScopedT("launch");
|
||||
|
||||
const {
|
||||
recording,
|
||||
paused,
|
||||
toggleRecording,
|
||||
pauseRecording,
|
||||
resumeRecording,
|
||||
cancelRecording,
|
||||
microphoneEnabled,
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
setMicrophoneDeviceId,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
} = useScreenRecorder();
|
||||
|
||||
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [pausedAt, setPausedAt] = useState<number | null>(null);
|
||||
const [pausedTotal, setPausedTotal] = useState(0);
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
const [recordingsDirectory, setRecordingsDirectory] = useState<string | null>(null);
|
||||
const [activeDropdown, setActiveDropdown] = useState<"none" | "sources" | "more">("none");
|
||||
const [sources, setSources] = useState<DesktopSource[]>([]);
|
||||
const [sourcesLoading, setSourcesLoading] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const showMicControls = microphoneEnabled && !recording;
|
||||
const { devices, selectedDeviceId, setSelectedDeviceId } =
|
||||
useMicrophoneDevices(microphoneEnabled);
|
||||
const { level } = useAudioLevelMeter({
|
||||
enabled: showMicControls,
|
||||
deviceId: microphoneDeviceId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDeviceId && selectedDeviceId !== "default") {
|
||||
setMicrophoneDeviceId(selectedDeviceId);
|
||||
}
|
||||
}, [selectedDeviceId, setMicrophoneDeviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
if (recording) {
|
||||
if (!recordingStart) {
|
||||
setRecordingStart(Date.now());
|
||||
setPausedTotal(0);
|
||||
}
|
||||
if (paused) {
|
||||
if (!pausedAt) setPausedAt(Date.now());
|
||||
if (timer) clearInterval(timer);
|
||||
} else {
|
||||
if (pausedAt) {
|
||||
setPausedTotal((prev) => prev + (Date.now() - pausedAt));
|
||||
setPausedAt(null);
|
||||
}
|
||||
timer = setInterval(() => {
|
||||
if (recordingStart) {
|
||||
setElapsed(Math.floor((Date.now() - recordingStart - pausedTotal) / 1000));
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
setRecordingStart(null);
|
||||
setElapsed(0);
|
||||
setPausedAt(null);
|
||||
setPausedTotal(0);
|
||||
if (timer) clearInterval(timer);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [recording, recordingStart, paused, pausedAt, pausedTotal]);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, "0");
|
||||
const s = (seconds % 60).toString().padStart(2, "0");
|
||||
return `${m}:${s}`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkSelectedSource = async () => {
|
||||
if (!window.electronAPI) return;
|
||||
const source = await window.electronAPI.getSelectedSource();
|
||||
if (source) {
|
||||
setSelectedSource(source.name);
|
||||
setHasSelectedSource(true);
|
||||
} else {
|
||||
setSelectedSource("Screen");
|
||||
setHasSelectedSource(false);
|
||||
}
|
||||
};
|
||||
void checkSelectedSource();
|
||||
const interval = setInterval(checkSelectedSource, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const result = await window.electronAPI.getRecordingsDirectory();
|
||||
if (result.success) setRecordingsDirectory(result.path);
|
||||
};
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setActiveDropdown("none");
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
const fetchSources = useCallback(async () => {
|
||||
if (!window.electronAPI) return;
|
||||
setSourcesLoading(true);
|
||||
try {
|
||||
const rawSources = await window.electronAPI.getSources({
|
||||
types: ["screen", "window"],
|
||||
thumbnailSize: { width: 160, height: 90 },
|
||||
fetchWindowIcons: true,
|
||||
});
|
||||
setSources(
|
||||
rawSources.map((s) => {
|
||||
const isWindow = s.id.startsWith("window:");
|
||||
const type = s.sourceType ?? (isWindow ? "window" : "screen");
|
||||
let displayName = s.name;
|
||||
let appName = s.appName;
|
||||
if (isWindow && !appName && s.name.includes(" — ")) {
|
||||
const parts = s.name.split(" — ");
|
||||
appName = parts[0]?.trim();
|
||||
displayName = parts.slice(1).join(" — ").trim() || s.name;
|
||||
} else if (isWindow && s.windowTitle) {
|
||||
displayName = s.windowTitle;
|
||||
}
|
||||
return {
|
||||
id: s.id,
|
||||
name: displayName,
|
||||
thumbnail: s.thumbnail,
|
||||
display_id: s.display_id,
|
||||
appIcon: s.appIcon,
|
||||
sourceType: type,
|
||||
appName,
|
||||
windowTitle: s.windowTitle ?? displayName,
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch sources:", error);
|
||||
} finally {
|
||||
setSourcesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleDropdown = (which: "sources" | "more") => {
|
||||
setActiveDropdown(activeDropdown === which ? "none" : which);
|
||||
if (activeDropdown !== which && which === "sources") fetchSources();
|
||||
};
|
||||
|
||||
const handleSourceSelect = async (source: DesktopSource) => {
|
||||
await window.electronAPI.selectSource(source);
|
||||
setSelectedSource(source.name);
|
||||
setHasSelectedSource(true);
|
||||
setActiveDropdown("none");
|
||||
window.electronAPI.showSourceHighlight?.({
|
||||
...source,
|
||||
name: source.appName ? `${source.appName} — ${source.name}` : source.name,
|
||||
appName: source.appName,
|
||||
});
|
||||
};
|
||||
|
||||
const openVideoFile = async () => {
|
||||
setActiveDropdown("none");
|
||||
const result = await window.electronAPI.openVideoFilePicker();
|
||||
if (result.canceled) return;
|
||||
if (result.success && result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
await window.electronAPI.switchToEditor();
|
||||
}
|
||||
};
|
||||
|
||||
const openProjectFile = async () => {
|
||||
setActiveDropdown("none");
|
||||
const result = await window.electronAPI.loadProjectFile();
|
||||
if (result.canceled || !result.success) return;
|
||||
await window.electronAPI.switchToEditor();
|
||||
};
|
||||
|
||||
const chooseRecordingsDirectory = async () => {
|
||||
setActiveDropdown("none");
|
||||
const result = await window.electronAPI.chooseRecordingsDirectory();
|
||||
if (result.canceled) return;
|
||||
if (result.success && result.path) setRecordingsDirectory(result.path);
|
||||
};
|
||||
|
||||
const toggleMicrophone = () => {
|
||||
if (!recording) setMicrophoneEnabled(!microphoneEnabled);
|
||||
};
|
||||
|
||||
const screenSources = sources.filter((s) => s.sourceType === "screen");
|
||||
const windowSources = sources.filter((s) => s.sourceType === "window");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex flex-col justify-end items-center bg-transparent"
|
||||
ref={dropdownRef}
|
||||
>
|
||||
{activeDropdown !== "none" && (
|
||||
<div className={`${styles.dropdown} ${styles.electronNoDrag}`}>
|
||||
{activeDropdown === "sources" && (
|
||||
<>
|
||||
{sourcesLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-[#6b6b78]" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{screenSources.length > 0 && (
|
||||
<>
|
||||
<div className={styles.ddLabel}>Screens</div>
|
||||
{screenSources.map((source) => (
|
||||
<DropdownItem
|
||||
key={source.id}
|
||||
icon={<Monitor size={16} />}
|
||||
selected={selectedSource === source.name}
|
||||
onClick={() => handleSourceSelect(source)}
|
||||
>
|
||||
{source.name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{windowSources.length > 0 && (
|
||||
<>
|
||||
<div className={styles.ddLabel} style={screenSources.length > 0 ? { marginTop: 4 } : undefined}>
|
||||
Windows
|
||||
</div>
|
||||
{windowSources.map((source) => (
|
||||
<DropdownItem
|
||||
key={source.id}
|
||||
icon={<AppWindow size={16} />}
|
||||
selected={selectedSource === source.name}
|
||||
onClick={() => handleSourceSelect(source)}
|
||||
>
|
||||
{source.appName && source.appName !== source.name
|
||||
? `${source.appName} — ${source.name}`
|
||||
: source.name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{screenSources.length === 0 && windowSources.length === 0 && (
|
||||
<div className="text-center text-xs text-[#6b6b78] py-4">
|
||||
No sources found
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeDropdown === "more" && (
|
||||
<>
|
||||
<DropdownItem
|
||||
icon={systemAudioEnabled ? <Volume2 size={16} className="text-[#6360f5]" /> : <VolumeX size={16} />}
|
||||
onClick={() => setSystemAudioEnabled(!systemAudioEnabled)}
|
||||
trailing={systemAudioEnabled ? <span className="ml-auto text-[#6360f5] text-xs">✓</span> : undefined}
|
||||
>
|
||||
System Audio
|
||||
</DropdownItem>
|
||||
<DropdownItem icon={<FolderOpen size={16} />} onClick={chooseRecordingsDirectory}>
|
||||
Recordings Folder
|
||||
</DropdownItem>
|
||||
<DropdownItem icon={<VideoIcon size={16} />} onClick={openVideoFile}>
|
||||
{t("recording.openVideoFile")}
|
||||
</DropdownItem>
|
||||
<DropdownItem icon={<FolderOpen size={16} />} onClick={openProjectFile}>
|
||||
{t("recording.openProject")}
|
||||
</DropdownItem>
|
||||
<div className={styles.ddLabel} style={{ marginTop: 4 }}>Language</div>
|
||||
{SUPPORTED_LOCALES.map((code) => (
|
||||
<DropdownItem
|
||||
key={code}
|
||||
icon={<Languages size={16} />}
|
||||
selected={locale === code}
|
||||
onClick={() => { setLocale(code as AppLocale); setActiveDropdown("none"); }}
|
||||
>
|
||||
{LOCALE_LABELS[code] ?? code}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMicControls && (
|
||||
<div className={`flex items-center gap-2 mb-2 rounded-xl border border-white/[0.07] bg-[rgba(18,18,24,0.97)] px-3 py-2 shadow-xl ${styles.electronNoDrag}`}>
|
||||
<select
|
||||
value={microphoneDeviceId || selectedDeviceId}
|
||||
onChange={(e) => { setSelectedDeviceId(e.target.value); setMicrophoneDeviceId(e.target.value); }}
|
||||
className={`max-w-[230px] rounded-lg border border-[#2a2a34] bg-[#1a1a22] px-3 py-1 text-xs text-[#eeeef2] outline-none ${styles.micSelect}`}
|
||||
>
|
||||
{devices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>{device.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<AudioLevelMeter level={level} className="w-24" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${styles.bar} ${styles.electronDrag} mb-2`}>
|
||||
<div className={`flex items-center px-0.5 ${styles.electronDrag}`}>
|
||||
<RxDragHandleDots2 size={14} className="text-[#6b6b78]" />
|
||||
</div>
|
||||
|
||||
{recording ? (
|
||||
<>
|
||||
<div className="flex items-center gap-[5px]">
|
||||
<div className={`w-[7px] h-[7px] rounded-full ${paused ? "bg-[#fbbf24]" : `bg-[#f43f5e] ${styles.recDotBlink}`}`} />
|
||||
<span className={`text-[10px] font-bold tracking-[0.06em] ${paused ? "text-[#fbbf24]" : "text-[#f43f5e]"}`}>
|
||||
{paused ? "PAUSED" : "REC"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className={`font-mono text-xs font-semibold min-w-[52px] text-center tracking-[0.02em] ${paused ? "text-[#fbbf24]" : "text-[#eeeef2]"}`}>
|
||||
{formatTime(elapsed)}
|
||||
</span>
|
||||
|
||||
<Separator />
|
||||
|
||||
<IconButton title={microphoneEnabled ? t("recording.disableMicrophone") : t("recording.enableMicrophone")} className={microphoneEnabled ? styles.ibActive : ""}>
|
||||
{microphoneEnabled ? <Mic size={18} /> : <MicOff size={18} />}
|
||||
</IconButton>
|
||||
|
||||
<Separator />
|
||||
|
||||
<IconButton onClick={paused ? resumeRecording : pauseRecording} title={paused ? "Resume" : "Pause"} className={paused ? styles.ibGreen : ""}>
|
||||
{paused ? <Play size={18} fill="currentColor" strokeWidth={0} /> : <Pause size={18} />}
|
||||
</IconButton>
|
||||
|
||||
<IconButton onClick={toggleRecording} title="Stop" className={styles.ibRed}>
|
||||
<Square size={16} fill="currentColor" strokeWidth={0} />
|
||||
</IconButton>
|
||||
|
||||
<IconButton onClick={cancelRecording} title="Cancel">
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.screenSel} ${styles.electronNoDrag}`}
|
||||
onClick={() => toggleDropdown("sources")}
|
||||
title={selectedSource}
|
||||
>
|
||||
<Monitor size={16} />
|
||||
<ContentClamp truncateLength={10}>{selectedSource}</ContentClamp>
|
||||
<ChevronUp size={10} className={`text-[#6b6b78] ml-0.5 transition-transform duration-200 ${activeDropdown === "sources" ? "" : "rotate-180"}`} />
|
||||
</button>
|
||||
|
||||
<Separator />
|
||||
|
||||
<IconButton
|
||||
onClick={toggleMicrophone}
|
||||
title={microphoneEnabled ? t("recording.disableMicrophone") : t("recording.enableMicrophone")}
|
||||
className={microphoneEnabled ? styles.ibActive : ""}
|
||||
>
|
||||
{microphoneEnabled ? <Mic size={18} /> : <MicOff size={18} />}
|
||||
</IconButton>
|
||||
|
||||
<Separator />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.recBtn} ${styles.electronNoDrag}`}
|
||||
onClick={hasSelectedSource ? toggleRecording : () => toggleDropdown("sources")}
|
||||
title={t("recording.record")}
|
||||
>
|
||||
<div className={styles.recDot} />
|
||||
</button>
|
||||
|
||||
<Separator />
|
||||
|
||||
<IconButton onClick={() => toggleDropdown("more")} title="More">
|
||||
<MoreVertical size={18} />
|
||||
</IconButton>
|
||||
|
||||
<IconButton onClick={() => window.electronAPI?.hudOverlayHide?.()} title={t("recording.hideHud")}>
|
||||
<Minus size={16} />
|
||||
</IconButton>
|
||||
|
||||
<IconButton onClick={() => window.electronAPI?.hudOverlayClose?.()} title={t("recording.closeApp")}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,11 @@ const MIC_GAIN_BOOST = 1.4;
|
||||
|
||||
type UseScreenRecorderReturn = {
|
||||
recording: boolean;
|
||||
paused: boolean;
|
||||
toggleRecording: () => void;
|
||||
pauseRecording: () => void;
|
||||
resumeRecording: () => void;
|
||||
cancelRecording: () => void;
|
||||
preparePermissions: (options?: { startup?: boolean }) => Promise<boolean>;
|
||||
isMacOS: boolean;
|
||||
microphoneEnabled: boolean;
|
||||
@@ -41,6 +45,7 @@ type UseScreenRecorderReturn = {
|
||||
|
||||
export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
||||
@@ -150,6 +155,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}, []);
|
||||
|
||||
const stopRecording = useRef(() => {
|
||||
setPaused(false);
|
||||
if (nativeScreenRecording.current) {
|
||||
nativeScreenRecording.current = false;
|
||||
setRecording(false);
|
||||
@@ -303,13 +309,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
microphoneLabel: micLabel,
|
||||
});
|
||||
if (!nativeResult.success) {
|
||||
if (useWgcCapture) {
|
||||
console.warn("WGC capture failed, falling back to browser capture:", nativeResult.error ?? nativeResult.message);
|
||||
} else {
|
||||
throw new Error(
|
||||
nativeResult.error ?? nativeResult.message ?? "Failed to start native screen recording",
|
||||
);
|
||||
}
|
||||
console.warn("Native capture failed, falling back to browser capture:", nativeResult.error ?? nativeResult.message);
|
||||
}
|
||||
|
||||
if (nativeResult.success) {
|
||||
@@ -548,6 +548,46 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
};
|
||||
|
||||
const pauseRecording = useCallback(() => {
|
||||
if (!recording || paused) return;
|
||||
if (mediaRecorder.current?.state === "recording") {
|
||||
mediaRecorder.current.pause();
|
||||
setPaused(true);
|
||||
}
|
||||
}, [recording, paused]);
|
||||
|
||||
const resumeRecording = useCallback(() => {
|
||||
if (!recording || !paused) return;
|
||||
if (mediaRecorder.current?.state === "paused") {
|
||||
mediaRecorder.current.resume();
|
||||
setPaused(false);
|
||||
}
|
||||
}, [recording, paused]);
|
||||
|
||||
const cancelRecording = useCallback(() => {
|
||||
if (!recording) return;
|
||||
setPaused(false);
|
||||
|
||||
if (nativeScreenRecording.current) {
|
||||
nativeScreenRecording.current = false;
|
||||
wgcRecording.current = false;
|
||||
setRecording(false);
|
||||
window.electronAPI?.setRecordingState(false);
|
||||
void window.electronAPI.stopNativeScreenRecording();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaRecorder.current) {
|
||||
chunks.current = [];
|
||||
cleanupCapturedMedia();
|
||||
if (mediaRecorder.current.state !== "inactive") {
|
||||
mediaRecorder.current.stop();
|
||||
}
|
||||
setRecording(false);
|
||||
window.electronAPI?.setRecordingState(false);
|
||||
}
|
||||
}, [recording, cleanupCapturedMedia]);
|
||||
|
||||
const toggleRecording = () => {
|
||||
if (starting) {
|
||||
return;
|
||||
@@ -558,7 +598,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
|
||||
return {
|
||||
recording,
|
||||
paused,
|
||||
toggleRecording,
|
||||
pauseRecording,
|
||||
resumeRecording,
|
||||
cancelRecording,
|
||||
preparePermissions,
|
||||
isMacOS,
|
||||
microphoneEnabled,
|
||||
|
||||
Reference in New Issue
Block a user