- 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:
Gurpreet Kait
2026-03-17 16:05:23 +05:30
parent db2fa00a49
commit a95f32a095
8 changed files with 934 additions and 382 deletions
+1
View File
@@ -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,
+163
View File
@@ -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
})
+3
View File
@@ -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
View File
@@ -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,
+6
View File
@@ -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);
+212 -31
View File
@@ -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;
}
+495 -341
View File
@@ -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">&#10003;</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>
);
}
+51 -7
View File
@@ -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,