diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f5bddcb..a891412 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -775,13 +775,26 @@ pub async fn hide_main_window(app: tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("main") { // hide() removes the window in ~1 frame; minimize() plays a ~250ms // macOS genie animation that has to be waited out before capturing. - // Instant hide is what lets the screenshot flow feel snappy. + // Instant hide is what lets the screenshot flow feel snappy. Used where + // the window is restored automatically a moment later (screenshots). window.hide().map_err(|e: tauri::Error| e.to_string())?; println!("[Window] Main window hidden"); } Ok(()) } +/// Minimize (not hide) the main window. Used for recording, where the window +/// stays out of view for the whole session: a minimized window can be brought +/// back from the dock, but a fully-hidden one can't — which locked users out. +#[tauri::command] +pub async fn minimize_main_window(app: tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("main") { + window.minimize().map_err(|e: tauri::Error| e.to_string())?; + println!("[Window] Main window minimized"); + } + Ok(()) +} + #[tauri::command] pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("main") { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8d06c75..1c8f488 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -44,6 +44,7 @@ fn main() { commands::show_countdown, commands::close_countdown, commands::hide_main_window, + commands::minimize_main_window, commands::show_main_window, commands::get_cursor_position, commands::check_permissions, diff --git a/src/islands/media/ScreenRecorder.tsx b/src/islands/media/ScreenRecorder.tsx index 8bfdb02..d579b85 100644 --- a/src/islands/media/ScreenRecorder.tsx +++ b/src/islands/media/ScreenRecorder.tsx @@ -57,6 +57,8 @@ export default function ScreenRecorder() { const extRef = useRef('webm'); const timerRef = useRef | null>(null); const windowHiddenRef = useRef(false); + // Always holds the latest toggle logic so the hotkey callback never goes stale. + const toggleRecordingRef = useRef<() => void>(() => {}); useEffect(() => { // Check if recording is supported (browser or Tauri) @@ -105,41 +107,33 @@ export default function ScreenRecorder() { useEffect(() => () => { if (resultUrl) URL.revokeObjectURL(resultUrl); }, [resultUrl]); - // Register global hotkey for screen recording (Tauri only) + // Register the global recording hotkey ONCE (Tauri only). Registering it in an + // effect keyed on [recording, stopping] re-registered on every start/stop; the + // async unregister/register raced and could hit "already registered", silently + // killing the shortcut. A single stable registration that reads the ref fixes it. useEffect(() => { if (!inTauriApp) return; let hotkeyId: string | null = null; - - const registerHotkey = async () => { - try { - hotkeyId = await hotkeyService.register( - 'CommandOrControl+Shift+R', - () => { - console.log('[ScreenRecorder] Global hotkey triggered'); - // Toggle recording: start if not recording, stop if recording - if (recording) { - stop(); - } else if (!stopping) { - start(); - } - }, - 'Toggle screen recording' - ); - console.log('[ScreenRecorder] Registered global hotkey:', hotkeyId); - } catch (err) { - console.warn('[ScreenRecorder] Failed to register hotkey:', err); - } - }; - - registerHotkey(); + hotkeyService + .register( + 'CommandOrControl+Shift+R', + () => { + console.log('[ScreenRecorder] Recording hotkey triggered'); + toggleRecordingRef.current(); + }, + 'Toggle screen recording', + ) + .then((id) => { + hotkeyId = id; + console.log('[ScreenRecorder] Registered recording hotkey:', id); + }) + .catch((err) => console.warn('[ScreenRecorder] Failed to register hotkey:', err)); return () => { - if (hotkeyId) { - hotkeyService.unregister(hotkeyId).catch(console.warn); - } + if (hotkeyId) hotkeyService.unregister(hotkeyId).catch(console.warn); }; - }, [recording, stopping]); + }, [inTauriApp]); const start = async () => { setError(''); @@ -156,13 +150,16 @@ export default function ScreenRecorder() { if (inTauriApp) { const { invoke } = await import('@tauri-apps/api/core'); - // Hide window if user enabled the option + // Hide window if user enabled the option. + // Use MINIMIZE (not hide) for recording: the window stays hidden for the + // whole session, and a minimized window can be restored from the dock — + // a fully-hidden one can't, which locked the user out mid-recording. if (hideWindow) { - console.log('[ScreenRecorder] Hiding window for cleaner capture'); - await invoke('hide_main_window'); + console.log('[ScreenRecorder] Minimizing window for cleaner capture'); + await invoke('minimize_main_window'); windowHiddenRef.current = true; - // hide() is instant; a couple of frames is enough for the compositor. - await new Promise(resolve => setTimeout(resolve, 60)); + // Let the minimize animation settle before capture begins. + await new Promise(resolve => setTimeout(resolve, 250)); } else { console.log('[ScreenRecorder] Keeping window visible (user preference)'); windowHiddenRef.current = false; @@ -359,6 +356,12 @@ export default function ScreenRecorder() { downloadService.download(result, `screen-recording.${extRef.current}`); }; + // Keep the hotkey's toggle current with live state (runs every render). + toggleRecordingRef.current = () => { + if (recording) stop(); + else if (!stopping) start(); + }; + const mmss = `${String(Math.floor(elapsed / 60)).padStart(2, '0')}:${String(elapsed % 60).padStart(2, '0')}`; if (!supported) {