diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 4cdf2ddf..1e836c84 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -53,7 +53,7 @@ tauri-plugin-fs = "2.0.0-rc.0" futures-intrusive = "0.5.0" anyhow.workspace = true mp4 = "0.14.0" -futures = "0.3.30" +futures = "0.3" axum = { version = "0.7.5", features = ["ws"] } tracing = "0.1.40" indexmap = "2.5.0" diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 96fd02ba..e14d899a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1540,7 +1540,7 @@ fn show_previous_recordings_window(app: AppHandle) { let state = app.state::(); loop { - sleep(Duration::from_millis(1000 / 60)).await; + sleep(Duration::from_millis(1000 / 10)).await; let map = state.0.read().await; let Some(windows) = map.get("prev-recordings") else { @@ -1549,7 +1549,7 @@ fn show_previous_recordings_window(app: AppHandle) { }; let window_position = window.outer_position().unwrap(); - let mouse_position = window.cursor_position().unwrap(); // TODO(Ilya): Panics on Windows + let mouse_position = window.cursor_position().unwrap(); let scale_factor = window.scale_factor().unwrap(); let mut ignore = true; @@ -1572,6 +1572,16 @@ fn show_previous_recordings_window(app: AppHandle) { } window.set_ignore_cursor_events(ignore).ok(); + + if !ignore { + if !window.is_focused().unwrap_or(false) { + window.set_focus().ok(); + } + } else { + if window.is_focused().unwrap_or(false) { + window.set_ignore_cursor_events(true).ok(); + } + } } }); } @@ -1729,6 +1739,13 @@ async fn open_settings_window(app: AppHandle, page: String) { CapWindow::Settings { page: Some(page) }.show(&app); } +#[derive(Serialize, Type, tauri_specta::Event, Debug, Clone)] +pub struct UploadProgress { + stage: String, + progress: f64, + message: String, +} + #[tauri::command] #[specta::specta] async fn upload_rendered_video( @@ -1738,7 +1755,6 @@ async fn upload_rendered_video( pre_created_video: Option, ) -> Result { let Ok(Some(mut auth)) = AuthStore::get(&app) else { - // Sign out and redirect to sign in AuthStore::set(&app, None).map_err(|e| e.to_string())?; return Ok(UploadResult::NotAuthenticated); }; @@ -1777,60 +1793,34 @@ async fn upload_rendered_video( let editor_instance = upsert_editor_instance(&app, video_id.clone()).await; let mut meta = editor_instance.meta(); - let share_link = if let Some(sharing) = meta.sharing { + if let Some(sharing) = meta.sharing { notifications::send_notification( &app, notifications::NotificationType::ShareableLinkCopied, ); - sharing.link - } else if let Some(pre_created) = pre_created_video { - // Use the pre-created video information - let output_path = match get_rendered_video_impl(editor_instance.clone(), project).await { - Ok(path) => path, - Err(e) => return Err(format!("Failed to get rendered video: {}", e)), - }; - - match upload_video( - &app, - video_id.clone(), - output_path, - false, - Some(pre_created.config), - ) - .await - { - Ok(_) => { - meta.sharing = Some(SharingMeta { - link: pre_created.link.clone(), - id: pre_created.id.clone(), - }); - meta.save_for_project(); - RecordingMetaChanged { id: video_id }.emit(&app).ok(); + Ok(UploadResult::Success(sharing.link)) + } else { + // Emit initial rendering progress + UploadProgress { + stage: "rendering".to_string(), + progress: 0.0, + message: "Preparing video...".to_string(), + } + .emit(&app) + .ok(); - // Don't send notification here if it was pre-created - let general_settings = GeneralSettingsStore::get(&app)?; - if !general_settings - .map(|settings| settings.auto_create_shareable_link) - .unwrap_or(false) - { - notifications::send_notification( - &app, - notifications::NotificationType::ShareableLinkCopied, - ); + let output_path = match get_rendered_video_impl(editor_instance.clone(), project).await { + Ok(path) => { + // Emit rendering complete + UploadProgress { + stage: "rendering".to_string(), + progress: 1.0, + message: "Rendering complete".to_string(), } - pre_created.link - } - Err(e) => { - notifications::send_notification( - &app, - notifications::NotificationType::UploadFailed, - ); - return Err(e); + .emit(&app) + .ok(); + path } - } - } else { - let output_path = match get_rendered_video_impl(editor_instance.clone(), project).await { - Ok(path) => path, Err(e) => { notifications::send_notification( &app, @@ -1840,8 +1830,34 @@ async fn upload_rendered_video( } }; - match upload_video(&app, video_id.clone(), output_path, false, None).await { + // Start upload progress + UploadProgress { + stage: "uploading".to_string(), + progress: 0.0, + message: "Starting upload...".to_string(), + } + .emit(&app) + .ok(); + + let result = match upload_video( + &app, + video_id.clone(), + output_path, + false, + pre_created_video.map(|v| v.config), + ) + .await + { Ok(uploaded_video) => { + // Emit upload complete + UploadProgress { + stage: "uploading".to_string(), + progress: 1.0, + message: "Upload complete!".to_string(), + } + .emit(&app) + .ok(); + meta.sharing = Some(SharingMeta { link: uploaded_video.link.clone(), id: uploaded_video.id.clone(), @@ -1853,22 +1869,23 @@ async fn upload_rendered_video( &app, notifications::NotificationType::ShareableLinkCopied, ); - uploaded_video.link + + #[cfg(target_os = "macos")] + platform::write_string_to_pasteboard(&uploaded_video.link); + + Ok(UploadResult::Success(uploaded_video.link)) } Err(e) => { notifications::send_notification( &app, notifications::NotificationType::UploadFailed, ); - return Err(e); + Err(e) } - } - }; - - #[cfg(target_os = "macos")] - platform::write_string_to_pasteboard(&share_link); + }; - Ok(UploadResult::Success(share_link)) + result + } } #[tauri::command] @@ -2468,7 +2485,8 @@ pub async fn run() { RequestOpenSettings, NewNotification, AuthenticationInvalid, - audio_meter::AudioInputLevelChange + audio_meter::AudioInputLevelChange, + UploadProgress, ]) .error_handling(tauri_specta::ErrorHandlingMode::Throw) .typ::() diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 3917e7fc..46ccdcc0 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -1,14 +1,17 @@ // credit @filleduchaos +use futures::stream; use image::codecs::jpeg::JpegEncoder; use image::ImageReader; use reqwest::{multipart::Form, StatusCode}; use std::path::PathBuf; use tauri::AppHandle; +use tauri_specta::Event; use tokio::task; use crate::web_api::{self, ManagerExt}; +use crate::UploadProgress; use serde::{Deserialize, Serialize}; use specta::Type; @@ -140,11 +143,51 @@ pub async fn upload_video( let file_bytes = tokio::fs::read(&file_path) .await .map_err(|e| format!("Failed to read file: {}", e))?; - let file_part = reqwest::multipart::Part::bytes(file_bytes) - .file_name(file_name.clone()) - .mime_str("video/mp4") - .map_err(|e| format!("Error setting MIME type: {}", e))?; - form = form.part("file", file_part); + + let total_size = file_bytes.len() as f64; + + // Wrap file_bytes in an Arc for shared ownership + let file_bytes = std::sync::Arc::new(file_bytes); + + // Create a stream that reports progress + let file_part = + { + let progress_counter = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let app_handle = app.clone(); + let file_bytes = file_bytes.clone(); + + let stream = stream::iter((0..file_bytes.len()).step_by(1024 * 1024).map( + move |start| { + let end = (start + 1024 * 1024).min(file_bytes.len()); + let chunk = file_bytes[start..end].to_vec(); + + let current = progress_counter + .fetch_add(chunk.len() as u64, std::sync::atomic::Ordering::SeqCst) + as f64; + + // Emit progress every chunk + UploadProgress { + stage: "uploading".to_string(), + progress: current / total_size, + message: format!("{:.0}%", (current / total_size * 100.0)), + } + .emit(&app_handle) + .ok(); + + Ok::, std::io::Error>(chunk) + }, + )); + + reqwest::multipart::Part::stream_with_length( + reqwest::Body::wrap_stream(stream), + total_size as u64, + ) + .file_name(file_name.clone()) + .mime_str("video/mp4") + .map_err(|e| format!("Error setting MIME type: {}", e))? + }; + + let mut form = form.part("file", file_part); // Prepare screenshot upload let screenshot_path = file_path @@ -182,6 +225,15 @@ pub async fn upload_video( video_upload.map_err(|e| format!("Failed to send upload file request: {}", e))?; if response.status().is_success() { + // Final progress update + UploadProgress { + stage: "uploading".to_string(), + progress: 1.0, + message: "100%".to_string(), + } + .emit(app) + .ok(); + println!("Video uploaded successfully"); if let Some(Ok(screenshot_response)) = screenshot_result { diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index d0c6260f..d6417923 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -275,6 +275,7 @@ impl CapWindow { .shadow(false) .always_on_top(true) .visible_on_all_workspaces(true) + .accept_first_mouse(true) .content_protected(true) .inner_size( 350.0, diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index a253ee33..76bb0fa0 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -115,7 +115,9 @@ function ExportButton() { { - if (!o) setState(reconcile({ ...state, open: false })); + if (state.type !== "inProgress" && !o) { + setState(reconcile({ ...state, open: false })); + } }} > ([]), { name: "screenshots-store" } ); + const [progressState, setProgressState] = createStore({ + type: "idle", + }); + + onMount(async () => { + const unlisten = await events.uploadProgress.listen((event) => { + if (progressState.type === "uploading") { + if (event.payload.stage === "rendering") { + setProgressState({ + type: "uploading", + renderProgress: Math.round(event.payload.progress * 100), + uploadProgress: 0, + message: event.payload.message, + mediaPath: progressState.mediaPath, + stage: "rendering", + }); + } else { + setProgressState({ + type: "uploading", + renderProgress: 100, + uploadProgress: Math.round(event.payload.progress * 100), + message: event.payload.message, + mediaPath: progressState.mediaPath, + stage: "uploading", + }); + + if (event.payload.progress === 1) { + setTimeout(() => { + setProgressState({ type: "idle" }); + }, 1000); + } + } + } + }); + + onCleanup(() => { + unlisten(); + }); + }); const addMediaEntry = (path: string, type?: "recording" | "screenshot") => { const setMedia = type === "screenshot" ? setScreenshots : setRecordings; @@ -125,86 +185,173 @@ export default function () { const copyMedia = createMutation(() => ({ mutationFn: async () => { - if (isRecording) { - await commands.copyRenderedVideoToClipboard( - mediaId, - presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG - ); - } else { - await commands.copyScreenshotToClipboard(media.path); + setProgressState({ + type: "copying", + progress: 0, + message: "Preparing to render...", + mediaPath: media.path, + stage: "rendering", + }); + + try { + if (isRecording) { + await commands.getRenderedVideo( + mediaId, + presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG + ); + + await commands.copyRenderedVideoToClipboard( + mediaId, + presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG + ); + } else { + await commands.copyScreenshotToClipboard(media.path); + } + + setProgressState({ + type: "copying", + progress: 100, + message: "Copied successfully!", + mediaPath: media.path, + }); + + setTimeout(() => { + setProgressState({ type: "idle" }); + }, 1000); + } catch (error) { + setProgressState({ type: "idle" }); + throw error; } }, })); const saveMedia = createMutation(() => ({ mutationFn: async () => { - const meta = recordingMeta.data; - if (!meta) { - throw new Error("Recording metadata not available"); - } + setProgressState({ + type: "saving", + progress: 0, + message: "Preparing to render...", + mediaPath: media.path, + stage: "rendering", + }); + + try { + const meta = recordingMeta.data; + if (!meta) { + throw new Error("Recording metadata not available"); + } - const defaultName = isRecording - ? "Cap Recording" - : media.path.split(".cap/")[1]; - const suggestedName = meta.pretty_name || defaultName; + const defaultName = isRecording + ? "Cap Recording" + : media.path.split(".cap/")[1]; + const suggestedName = meta.pretty_name || defaultName; - const fileType = isRecording ? "recording" : "screenshot"; - const extension = isRecording ? ".mp4" : ".png"; + const fileType = isRecording ? "recording" : "screenshot"; + const extension = isRecording ? ".mp4" : ".png"; - const fullFileName = suggestedName.endsWith(extension) - ? suggestedName - : `${suggestedName}${extension}`; + const fullFileName = suggestedName.endsWith(extension) + ? suggestedName + : `${suggestedName}${extension}`; - const savePath = await commands.saveFileDialog( - fullFileName, - fileType - ); + const savePath = await commands.saveFileDialog( + fullFileName, + fileType + ); - if (!savePath) { - return false; - } + if (!savePath) { + setProgressState({ type: "idle" }); + return false; + } - if (isRecording) { - const renderedPath = await commands.getRenderedVideo( - mediaId, - presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG - ); + if (isRecording) { + const renderedPath = await commands.getRenderedVideo( + mediaId, + presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG + ); + + if (!renderedPath) { + throw new Error("Failed to get rendered video path"); + } - if (!renderedPath) { - throw new Error("Failed to get rendered video path"); + await commands.copyFileToPath(renderedPath, savePath); + } else { + await commands.copyFileToPath(media.path, savePath); } - await commands.copyFileToPath(renderedPath, savePath); - } else { - await commands.copyFileToPath(media.path, savePath); - } + setProgressState({ + type: "saving", + progress: 100, + message: "Saved successfully!", + mediaPath: media.path, + }); - return true; + setTimeout(() => { + setProgressState({ type: "idle" }); + }, 1000); + + return true; + } catch (error) { + setProgressState({ type: "idle" }); + throw error; + } }, })); const uploadMedia = createMutation(() => ({ mutationFn: async () => { - let res: UploadResult; - if (isRecording) { - res = await commands.uploadRenderedVideo( - mediaId, - presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG, - null - ); - } else { - res = await commands.uploadScreenshot(media.path); + if (recordingMeta.data?.sharing) { + setProgressState({ + type: "copying", + progress: 100, + message: "Link copied to clipboard!", + mediaPath: media.path, + }); + + setTimeout(() => { + setProgressState({ type: "idle" }); + }, 1000); + + return; } - switch (res) { - case "NotAuthenticated": - throw new Error("Not authenticated"); - case "PlanCheckFailed": - throw new Error("Plan check failed"); - case "UpgradeRequired": - throw new Error("Upgrade required"); - default: - // Success case, do nothing - break; + setProgressState({ + type: "uploading", + renderProgress: 0, + uploadProgress: 0, + message: "Preparing to render...", + mediaPath: media.path, + stage: "rendering", + }); + + try { + let res: UploadResult; + if (isRecording) { + await commands.getRenderedVideo( + mediaId, + presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG + ); + + res = await commands.uploadRenderedVideo( + mediaId, + presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG, + null + ); + } else { + res = await commands.uploadScreenshot(media.path); + } + + switch (res) { + case "NotAuthenticated": + throw new Error("Not authenticated"); + case "PlanCheckFailed": + throw new Error("Plan check failed"); + case "UpgradeRequired": + throw new Error("Upgrade required"); + default: + break; + } + } catch (error) { + setProgressState({ type: "idle" }); + throw error; } }, onSuccess: () => @@ -260,6 +407,7 @@ export default function () { )} style={{ "border-color": "rgba(255, 255, 255, 0.2)", + "pointer-events": "auto", }} > setImageExists(false)} /> + + +
+
+

+ + + {progressState.stage === "rendering" + ? "Rendering video" + : "Copying to clipboard"} + + + {progressState.stage === "rendering" + ? "Rendering video" + : "Saving file"} + + + {progressState.stage === "rendering" + ? "Rendering video" + : "Creating shareable link"} + + +

+ +
+
+
+ +

+ {progressState.message} +

+
+
+ +
{ + const setMedia = isRecording + ? setRecordings + : setScreenshots; + setMedia( + produce((state) => { + const index = state.findIndex( + (entry) => entry.path === media.path + ); + if (index !== -1) { + state.splice(index, 1); + } + }) + ); commands.openEditor(mediaId); }} > @@ -367,7 +583,7 @@ export default function () { { uploadMedia.mutate(undefined, { @@ -535,7 +749,7 @@ const TooltipIconButton = ( color: "white", "border-radius": "8px", "font-size": "12px", - "z-index": "1000", + "z-index": "15", }} > {props.tooltipText} diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 577a59c2..82fc6d6a 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -187,7 +187,8 @@ requestOpenSettings: RequestOpenSettings, requestRestartRecording: RequestRestartRecording, requestStartRecording: RequestStartRecording, requestStopRecording: RequestStopRecording, -showCapturesPanel: ShowCapturesPanel +showCapturesPanel: ShowCapturesPanel, +uploadProgress: UploadProgress }>({ audioInputLevelChange: "audio-input-level-change", authenticationInvalid: "authentication-invalid", @@ -206,7 +207,8 @@ requestOpenSettings: "request-open-settings", requestRestartRecording: "request-restart-recording", requestStartRecording: "request-start-recording", requestStopRecording: "request-stop-recording", -showCapturesPanel: "show-captures-panel" +showCapturesPanel: "show-captures-panel", +uploadProgress: "upload-progress" }) /** user-defined constants **/ @@ -278,6 +280,7 @@ export type SharingMeta = { id: string; link: string } export type ShowCapturesPanel = null export type TimelineConfiguration = { segments: TimelineSegment[]; zoomSegments?: ZoomSegment[] } export type TimelineSegment = { timescale: number; start: number; end: number } +export type UploadProgress = { stage: string; progress: number; message: string } export type UploadResult = { Success: string } | "NotAuthenticated" | "PlanCheckFailed" | "UpgradeRequired" export type Video = { duration: number; width: number; height: number } export type VideoType = "screen" | "output"