From d611c3db27e953d489e0309d736ae6e42568fd26 Mon Sep 17 00:00:00 2001 From: Veer Gadodia Date: Mon, 10 Nov 2025 20:59:06 -0600 Subject: [PATCH 01/24] Add version_id support to S3 upload metadata and links Introduces optional version_id to S3UploadMeta in Rust and TypeScript, updates video upload and sharing logic to use version_id for links when available. This enables more precise referencing of uploaded video versions and improves shareable link generation. --- apps/desktop/src-tauri/src/lib.rs | 2 ++ apps/desktop/src-tauri/src/recording.rs | 10 +++++++++- apps/desktop/src-tauri/src/upload.rs | 10 +++++++++- apps/desktop/src/utils/tauri.ts | 2 +- crates/project/src/meta.rs | 2 ++ 5 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index b2152ba350..67a8c637d9 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1197,6 +1197,7 @@ async fn upload_exported_video( screenshot_path, metadata, Some(channel.clone()), + s3_config.version_id.clone(), ) .await { @@ -2568,6 +2569,7 @@ async fn resume_uploads(app: AppHandle) -> Result<(), String> { screenshot_path, meta, None, + None, ) .await .map_err(|error| { diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index e999788c23..33b1e0d548 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -354,7 +354,14 @@ pub async fn start_recording( } }; - let link = app.make_app_url(format!("/s/{}", s3_config.id)).await; + // Use version_id for link if available, otherwise fall back to recording id + let link_id = s3_config.version_id.as_ref().unwrap_or(&s3_config.id); + let link_path = if s3_config.version_id.is_some() { + format!("/v/{}", link_id) + } else { + format!("/s/{}", link_id) + }; + let link = app.make_app_url(link_path).await; info!("Pre-created shareable link: {}", link); Some(VideoUploadInfo { @@ -1091,6 +1098,7 @@ async fn handle_recording_finish( display_screenshot.clone(), meta, None, + video_upload_info.config.version_id.clone(), ) .await .map(|_| info!("Final video upload with screenshot completed successfully")) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index f426ea2d9a..76f21d9772 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -66,6 +66,7 @@ pub async fn upload_video( screenshot_path: PathBuf, meta: S3VideoMeta, channel: Option>, + version_id: Option, ) -> Result { info!("Uploading video {video_id}..."); @@ -146,8 +147,15 @@ pub async fn upload_video( let _ = (video_result?, thumbnail_result?); + // Use version_id for link if available, otherwise fall back to recording id + let (link_path, link_id) = if let Some(ref v_id) = version_id { + (format!("/v/{}", v_id), v_id.clone()) + } else { + (format!("/s/{}", video_id), video_id.clone()) + }; + Ok(UploadedItem { - link: app.make_app_url(format!("/s/{video_id}")).await, + link: app.make_app_url(link_path).await, id: video_id, }) } diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 9f2c25ff4c..c7787a16ab 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -462,7 +462,7 @@ export type RequestOpenRecordingPicker = { target_mode: RecordingTargetMode | nu export type RequestOpenSettings = { page: string } export type RequestScreenCapturePrewarm = { force?: boolean } export type RequestStartRecording = { mode: RecordingMode } -export type S3UploadMeta = { id: string } +export type S3UploadMeta = { id: string; version_id?: string | null } export type SceneMode = "default" | "cameraOnly" | "hideCamera" export type SceneSegment = { start: number; end: number; mode?: SceneMode } export type ScreenCaptureTarget = { variant: "window"; id: WindowId } | { variant: "display"; id: DisplayId } | { variant: "area"; screen: DisplayId; bounds: LogicalBounds } diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index 496ac6d5dd..bbddc128f0 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -79,6 +79,8 @@ pub struct RecordingMeta { #[derive(Deserialize, Serialize, Clone, Type, Debug)] pub struct S3UploadMeta { pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version_id: Option, } #[derive(Clone, Serialize, Deserialize, specta::Type, Debug)] From b4398c1d6e5fc34c91dead538a167e4b0495573d Mon Sep 17 00:00:00 2001 From: Veer Gadodia Date: Wed, 12 Nov 2025 00:20:30 -0600 Subject: [PATCH 02/24] Workspace selection + Updated recording controls UI (#1) * Add workspace support to recording and upload flows Introduces workspace support throughout the desktop app, including API, authentication, recording settings, and upload logic. Updates Rust backend and frontend components to handle workspace IDs alongside organization IDs, enabling workspace selection and association for recordings and uploads. * Refactor useOptions to use createEffect for updates Replaces mergeProps with createEffect in useOptions to update organizationId and workspaceId reactively based on available organizations and workspaces. This improves reactivity and ensures options are updated when data changes. * Show workspace avatar in selection overlay Adds an avatar image next to the workspace name in the workspace selection overlay if the selected workspace has an avatarUrl. Also adjusts the gap between items for improved layout. * Formatting * Comment out unused UI elements and warnings Temporarily disables several UI components and warnings, including ShowCapFreeWarning, settings menu, and mode toggle prompts. Also updates spacing and adds an 'Adjust area' button for window selection. These changes streamline the overlay and recording controls, likely for testing or UI simplification. * Update recording controls UI and add new icons Added DoubleArrowSwitcher, ArrowUpRight, and RecordFill icons. Updated the recording controls UI for improved appearance and clarity, including new button styles and conditional rendering for login state. Commented out organization selection logic in target-select-overlay to simplify workspace selection. --- apps/desktop/src-tauri/src/api.rs | 36 + apps/desktop/src-tauri/src/auth.rs | 75 +- .../desktop/src-tauri/src/deeplink_actions.rs | 1 + apps/desktop/src-tauri/src/lib.rs | 4 + apps/desktop/src-tauri/src/recording.rs | 3 + .../src-tauri/src/recording_settings.rs | 1 + apps/desktop/src-tauri/src/upload.rs | 7 +- apps/desktop/src/icons.tsx | 58 + .../routes/(window-chrome)/new-main/index.tsx | 205 +- .../(window-chrome)/settings/recordings.tsx | 725 +++---- .../src/routes/editor/ExportDialog.tsx | 1928 ++++++++--------- .../desktop/src/routes/editor/ShareButton.tsx | 543 +++-- .../desktop/src/routes/recordings-overlay.tsx | 1 + .../src/routes/target-select-overlay.tsx | 317 +-- apps/desktop/src/utils/queries.ts | 66 +- apps/desktop/src/utils/tauri.ts | 11 +- 16 files changed, 1843 insertions(+), 2138 deletions(-) diff --git a/apps/desktop/src-tauri/src/api.rs b/apps/desktop/src-tauri/src/api.rs index c9fdeaa1ce..00a9a1347b 100644 --- a/apps/desktop/src-tauri/src/api.rs +++ b/apps/desktop/src-tauri/src/api.rs @@ -284,3 +284,39 @@ pub async fn fetch_organizations(app: &AppHandle) -> Result, A .await .map_err(|err| format!("api/fetch_organizations/response: {err}").into()) } + +#[derive(Serialize, Deserialize, Type, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Workspace { + pub id: String, + pub name: String, + pub avatar_url: Option, +} + +pub async fn fetch_workspaces(app: &AppHandle) -> Result, AuthedApiError> { + #[derive(Deserialize)] + struct Response { + workspaces: Vec, + } + + let resp = app + .authed_api_request("/api/desktop/workspaces", |client, url| client.get(url)) + .await + .map_err(|err| format!("api/fetch_workspaces/request: {err}"))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let error_body = resp + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(format!("api/fetch_workspaces/{status}: {error_body}").into()); + } + + let response: Response = resp + .json() + .await + .map_err(|err| format!("api/fetch_workspaces/response: {err}"))?; + + Ok(response.workspaces) +} diff --git a/apps/desktop/src-tauri/src/auth.rs b/apps/desktop/src-tauri/src/auth.rs index 750e88e720..df63f6803a 100644 --- a/apps/desktop/src-tauri/src/auth.rs +++ b/apps/desktop/src-tauri/src/auth.rs @@ -7,7 +7,7 @@ use tauri_plugin_store::StoreExt; use web_api::ManagerExt; use crate::{ - api::{self, Organization}, + api::{self, Organization, Workspace}, web_api, }; @@ -19,6 +19,8 @@ pub struct AuthStore { pub intercom_hash: Option, #[serde(default)] pub organizations: Vec, + #[serde(default)] + pub workspaces: Vec, } #[derive(Serialize, Deserialize, Type, Debug)] @@ -69,39 +71,46 @@ impl AuthStore { } let mut auth = auth; - println!( - "Fetching plan for user {}", - auth.user_id.as_deref().unwrap_or("unknown") - ); - let response = app - .authed_api_request("/api/desktop/plan", |client, url| client.get(url)) - .await - .map_err(|e| { - println!("Failed to fetch plan: {e}"); - e.to_string() - })?; - println!("Plan fetch response status: {}", response.status()); - - if !response.status().is_success() { - let error_msg = format!("Failed to fetch plan: {}", response.status()); - return Err(error_msg); - } - - #[derive(Deserialize)] - struct Response { - upgraded: bool, - intercom_hash: Option, - } - - let plan_response: Response = response.json().await.map_err(|e| e.to_string())?; - auth.plan = Some(Plan { - upgraded: plan_response.upgraded, - last_checked: chrono::Utc::now().timestamp() as i32, - manual: auth.plan.as_ref().is_some_and(|p| p.manual), - }); - auth.intercom_hash = Some(plan_response.intercom_hash.unwrap_or_default()); - auth.organizations = api::fetch_organizations(app) + // Commented out plan fetch - not implemented yet + // println!( + // "Fetching plan for user {}", + // auth.user_id.as_deref().unwrap_or("unknown") + // ); + // let response = app + // .authed_api_request("/api/desktop/plan", |client, url| client.get(url)) + // .await + // .map_err(|e| { + // println!("Failed to fetch plan: {e}"); + // e.to_string() + // })?; + // println!("Plan fetch response status: {}", response.status()); + + // if !response.status().is_success() { + // let error_msg = format!("Failed to fetch plan: {}", response.status()); + // return Err(error_msg); + // } + + // #[derive(Deserialize)] + // struct Response { + // upgraded: bool, + // intercom_hash: Option, + // } + + // let plan_response: Response = response.json().await.map_err(|e| e.to_string())?; + + // auth.plan = Some(Plan { + // upgraded: plan_response.upgraded, + // last_checked: chrono::Utc::now().timestamp() as i32, + // manual: auth.plan.as_ref().is_some_and(|p| p.manual), + // }); + // auth.intercom_hash = Some(plan_response.intercom_hash.unwrap_or_default()); + + // Commented out organizations fetch - not implemented + // auth.organizations = api::fetch_organizations(app) + // .await + // .map_err(|e| e.to_string())?; + auth.workspaces = api::fetch_workspaces(app) .await .map_err(|e| e.to_string())?; diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index dbd90f667f..e381c63397 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -137,6 +137,7 @@ impl DeepLinkAction { capture_target, capture_system_audio, organization_id: None, + workspace_id: None, }; crate::recording::start_recording(app.clone(), state, inputs) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 67a8c637d9..533d390060 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1121,6 +1121,7 @@ async fn upload_exported_video( mode: UploadMode, channel: Channel, organization_id: Option, + workspace_id: Option, ) -> Result { let Ok(Some(auth)) = AuthStore::get(&app) else { AuthStore::set(&app, None).map_err(|e| e.to_string())?; @@ -1168,6 +1169,7 @@ async fn upload_exported_video( Some(meta.pretty_name.clone()), Some(metadata.clone()), organization_id, + workspace_id, ) .await } @@ -1570,6 +1572,7 @@ async fn check_upgraded_and_update(app: AppHandle) -> Result { last_checked: chrono::Utc::now().timestamp() as i32, }), organizations: auth.organizations, + workspaces: auth.workspaces, }; println!("Updating auth store with new pro status"); AuthStore::set(&app, Some(updated_auth)).map_err(|e| e.to_string())?; @@ -2246,6 +2249,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { mode: event.mode, capture_system_audio: settings.system_audio, organization_id: settings.organization_id, + workspace_id: settings.workspace_id, } }) .await; diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 33b1e0d548..3ac65be30a 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -261,6 +261,8 @@ pub struct StartRecordingInputs { pub mode: RecordingMode, #[serde(default)] pub organization_id: Option, + #[serde(default)] + pub workspace_id: Option, } #[derive(tauri_specta::Event, specta::Type, Clone, Debug, serde::Serialize)] @@ -338,6 +340,7 @@ pub async fn start_recording( )), None, inputs.organization_id.clone(), + inputs.workspace_id.clone(), ) .await { diff --git a/apps/desktop/src-tauri/src/recording_settings.rs b/apps/desktop/src-tauri/src/recording_settings.rs index 9b62c4f57d..1796b7212c 100644 --- a/apps/desktop/src-tauri/src/recording_settings.rs +++ b/apps/desktop/src-tauri/src/recording_settings.rs @@ -21,6 +21,7 @@ pub struct RecordingSettingsStore { pub mode: Option, pub system_audio: bool, pub organization_id: Option, + pub workspace_id: Option, } impl RecordingSettingsStore { diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 76f21d9772..285d6bff90 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -185,7 +185,7 @@ pub async fn upload_image( .ok_or("Invalid file path")? .to_string(); - let s3_config = create_or_get_video(app, true, None, None, None, None).await?; + let s3_config = create_or_get_video(app, true, None, None, None, None, None).await?; let (stream, total_size) = file_reader_stream(file_path).await?; singlepart_uploader( @@ -215,6 +215,7 @@ pub async fn create_or_get_video( name: Option, meta: Option, organization_id: Option, + workspace_id: Option, ) -> Result { let mut s3_config_url = if let Some(id) = video_id { format!("/api/desktop/video/create?recordingMode=desktopMP4&videoId={id}") @@ -241,6 +242,10 @@ pub async fn create_or_get_video( s3_config_url.push_str(&format!("&orgId={}", org_id)); } + if let Some(ws_id) = workspace_id { + s3_config_url.push_str(&format!("&workspaceId={}", ws_id)); + } + let response = app .authed_api_request(s3_config_url, |client, url| client.get(url)) .await?; diff --git a/apps/desktop/src/icons.tsx b/apps/desktop/src/icons.tsx index 08cbfb816a..830d61295f 100644 --- a/apps/desktop/src/icons.tsx +++ b/apps/desktop/src/icons.tsx @@ -88,3 +88,61 @@ export const Flip = (props: { class: string }) => { ); }; + +export const DoubleArrowSwitcher = (props: { class: string }) => { + return ( + + + + ); +}; + +export const ArrowUpRight = (props: { class: string }) => { + return ( + + + + ); +}; + +export const RecordFill = (props: { class: string }) => { + return ( + + + + + ); +}; diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index bc38363267..d769d3ec84 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -3,29 +3,13 @@ import { createEventListener } from "@solid-primitives/event-listener"; import { useNavigate } from "@solidjs/router"; import { createMutation, useQuery } from "@tanstack/solid-query"; import { listen } from "@tauri-apps/api/event"; -import { - getAllWebviewWindows, - WebviewWindow, -} from "@tauri-apps/api/webviewWindow"; -import { - getCurrentWindow, - LogicalSize, - primaryMonitor, -} from "@tauri-apps/api/window"; +import { getAllWebviewWindows, WebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { getCurrentWindow, LogicalSize, primaryMonitor } from "@tauri-apps/api/window"; import * as dialog from "@tauri-apps/plugin-dialog"; import { type as ostype } from "@tauri-apps/plugin-os"; import * as updater from "@tauri-apps/plugin-updater"; import { cx } from "cva"; -import { - createEffect, - createMemo, - createSignal, - ErrorBoundary, - onCleanup, - onMount, - Show, - Suspense, -} from "solid-js"; +import { createEffect, createMemo, createSignal, ErrorBoundary, onCleanup, onMount, Show, Suspense } from "solid-js"; import { reconcile } from "solid-js/store"; // Removed solid-motionone in favor of solid-transition-group import { Transition } from "solid-transition-group"; @@ -60,10 +44,7 @@ import IconLucideSearch from "~icons/lucide/search"; import IconMaterialSymbolsScreenshotFrame2Rounded from "~icons/material-symbols/screenshot-frame-2-rounded"; import IconMdiMonitor from "~icons/mdi/monitor"; import { WindowChromeHeader } from "../Context"; -import { - RecordingOptionsProvider, - useRecordingOptions, -} from "../OptionsContext"; +import { RecordingOptionsProvider, useRecordingOptions } from "../OptionsContext"; import CameraSelect from "./CameraSelect"; import ChangelogButton from "./ChangeLogButton"; import MicrophoneSelect from "./MicrophoneSelect"; @@ -82,20 +63,13 @@ function getWindowSize() { const findCamera = (cameras: CameraInfo[], id: DeviceOrModelID) => { return cameras.find((c) => { if (!id) return false; - return "DeviceID" in id - ? id.DeviceID === c.device_id - : id.ModelID === c.model_id; + return "DeviceID" in id ? id.DeviceID === c.device_id : id.ModelID === c.model_id; }); }; -type WindowListItem = Pick< - CaptureWindow, - "id" | "owner_name" | "name" | "bounds" | "refresh_rate" ->; +type WindowListItem = Pick; -const createWindowSignature = ( - list?: readonly WindowListItem[], -): string | undefined => { +const createWindowSignature = (list?: readonly WindowListItem[]): string | undefined => { if (!list) return undefined; return list @@ -117,14 +91,10 @@ const createWindowSignature = ( type DisplayListItem = Pick; -const createDisplaySignature = ( - list?: readonly DisplayListItem[], -): string | undefined => { +const createDisplaySignature = (list?: readonly DisplayListItem[]): string | undefined => { if (!list) return undefined; - return list - .map((item) => [item.id, item.name, item.refresh_rate].join(":")) - .join("|"); + return list.map((item) => [item.id, item.name, item.refresh_rate].join(":")).join("|"); }; type TargetMenuPanelProps = @@ -150,28 +120,19 @@ function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) { const [search, setSearch] = createSignal(""); const trimmedSearch = createMemo(() => search().trim()); const normalizedQuery = createMemo(() => trimmedSearch().toLowerCase()); - const placeholder = - props.variant === "display" ? "Search displays" : "Search windows"; - const noResultsMessage = - props.variant === "display" - ? "No matching displays" - : "No matching windows"; - - const filteredDisplayTargets = createMemo( - () => { - if (props.variant !== "display") return []; - const query = normalizedQuery(); - const targets = props.targets ?? []; - if (!query) return targets; - - const matchesQuery = (value?: string | null) => - !!value && value.toLowerCase().includes(query); - - return targets.filter( - (target) => matchesQuery(target.name) || matchesQuery(target.id), - ); - }, - ); + const placeholder = props.variant === "display" ? "Search displays" : "Search windows"; + const noResultsMessage = props.variant === "display" ? "No matching displays" : "No matching windows"; + + const filteredDisplayTargets = createMemo(() => { + if (props.variant !== "display") return []; + const query = normalizedQuery(); + const targets = props.targets ?? []; + if (!query) return targets; + + const matchesQuery = (value?: string | null) => !!value && value.toLowerCase().includes(query); + + return targets.filter((target) => matchesQuery(target.name) || matchesQuery(target.id)); + }); const filteredWindowTargets = createMemo(() => { if (props.variant !== "window") return []; @@ -179,14 +140,10 @@ function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) { const targets = props.targets ?? []; if (!query) return targets; - const matchesQuery = (value?: string | null) => - !!value && value.toLowerCase().includes(query); + const matchesQuery = (value?: string | null) => !!value && value.toLowerCase().includes(query); return targets.filter( - (target) => - matchesQuery(target.name) || - matchesQuery(target.owner_name) || - matchesQuery(target.id), + (target) => matchesQuery(target.name) || matchesQuery(target.owner_name) || matchesQuery(target.id) ); }); @@ -225,10 +182,7 @@ function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) {
-
+
{props.variant === "display" ? ( - !hasDisplayTargetsData() && - (displayTargets.status === "pending" || - displayTargets.fetchStatus === "fetching"); + !hasDisplayTargetsData() && (displayTargets.status === "pending" || displayTargets.fetchStatus === "fetching"); const windowMenuLoading = () => - !hasWindowTargetsData() && - (windowTargets.status === "pending" || - windowTargets.fetchStatus === "fetching"); + !hasWindowTargetsData() && (windowTargets.status === "pending" || windowTargets.fetchStatus === "fetching"); const displayErrorMessage = () => { if (!displayTargets.error) return undefined; @@ -378,10 +328,7 @@ function Page() { }; const selectDisplayTarget = (target: CaptureDisplayWithThumbnail) => { - setOptions( - "captureTarget", - reconcile({ variant: "display", id: target.id }), - ); + setOptions("captureTarget", reconcile({ variant: "display", id: target.id })); setOptions("targetMode", "display"); commands.openTargetSelectOverlays(rawOptions.captureTarget); setDisplayMenuOpen(false); @@ -389,10 +336,7 @@ function Page() { }; const selectWindowTarget = async (target: CaptureWindowWithThumbnail) => { - setOptions( - "captureTarget", - reconcile({ variant: "window", id: target.id }), - ); + setOptions("captureTarget", reconcile({ variant: "window", id: target.id })); setOptions("targetMode", "window"); commands.openTargetSelectOverlays(rawOptions.captureTarget); setWindowMenuOpen(false); @@ -424,15 +368,13 @@ function Page() { const size = getWindowSize(); currentWindow.setSize(new LogicalSize(size.width, size.height)); - const unlistenFocus = currentWindow.onFocusChanged( - ({ payload: focused }) => { - if (focused) { - const size = getWindowSize(); + const unlistenFocus = currentWindow.onFocusChanged(({ payload: focused }) => { + if (focused) { + const size = getWindowSize(); - currentWindow.setSize(new LogicalSize(size.width, size.height)); - } - }, - ); + currentWindow.setSize(new LogicalSize(size.width, size.height)); + } + }); const unlistenResize = currentWindow.onResized(() => { const size = getWindowSize(); @@ -454,16 +396,10 @@ function Page() { const cameras = useQuery(() => listVideoDevices); const mics = useQuery(() => listAudioDevices); - const windowListSignature = createMemo(() => - createWindowSignature(windows.data), - ); - const displayListSignature = createMemo(() => - createDisplaySignature(screens.data), - ); - const [windowThumbnailsSignature, setWindowThumbnailsSignature] = - createSignal(); - const [displayThumbnailsSignature, setDisplayThumbnailsSignature] = - createSignal(); + const windowListSignature = createMemo(() => createWindowSignature(windows.data)); + const displayListSignature = createMemo(() => createDisplaySignature(screens.data)); + const [windowThumbnailsSignature, setWindowThumbnailsSignature] = createSignal(); + const [displayThumbnailsSignature, setDisplayThumbnailsSignature] = createSignal(); createEffect(() => { if (windowTargets.status !== "success") return; @@ -514,12 +450,10 @@ function Page() { if (rawOptions.captureTarget.variant === "display") { const screenId = rawOptions.captureTarget.id; - screen = - screens.data?.find((s) => s.id === screenId) ?? screens.data?.[0]; + screen = screens.data?.find((s) => s.id === screenId) ?? screens.data?.[0]; } else if (rawOptions.captureTarget.variant === "area") { const screenId = rawOptions.captureTarget.screen; - screen = - screens.data?.find((s) => s.id === screenId) ?? screens.data?.[0]; + screen = screens.data?.find((s) => s.id === screenId) ?? screens.data?.[0]; } return screen; @@ -571,10 +505,7 @@ function Page() { if (!screen) return; if (target.variant === "window" && windows.data?.length === 0) { - setOptions( - "captureTarget", - reconcile({ variant: "display", id: screen.id }), - ); + setOptions("captureTarget", reconcile({ variant: "display", id: screen.id })); } }); @@ -613,10 +544,8 @@ function Page() { /> setMicInput.mutate(v)} /> @@ -638,8 +567,7 @@ function Page() {
{ if (isRecording()) return; - setOptions("targetMode", (v) => - v === "display" ? null : "display", - ); - if (rawOptions.targetMode) - commands.openTargetSelectOverlays(null); + setOptions("targetMode", (v) => (v === "display" ? null : "display")); + if (rawOptions.targetMode) commands.openTargetSelectOverlays(null); else commands.closeTargetSelectOverlays(); }} name="Display" @@ -661,7 +586,7 @@ function Page() { (displayTriggerRef = el)} disabled={isRecording()} @@ -683,8 +608,7 @@ function Page() {
{ if (isRecording()) return; - setOptions("targetMode", (v) => - v === "window" ? null : "window", - ); - if (rawOptions.targetMode) - commands.openTargetSelectOverlays(null); + setOptions("targetMode", (v) => (v === "window" ? null : "window")); + if (rawOptions.targetMode) commands.openTargetSelectOverlays(null); else commands.closeTargetSelectOverlays(); }} name="Window" @@ -706,7 +627,7 @@ function Page() { (windowTriggerRef = el)} disabled={isRecording()} @@ -732,8 +653,7 @@ function Page() { onClick={() => { if (isRecording()) return; setOptions("targetMode", (v) => (v === "area" ? null : "area")); - if (rawOptions.targetMode) - commands.openTargetSelectOverlays(null); + if (rawOptions.targetMode) commands.openTargetSelectOverlays(null); else commands.closeTargetSelectOverlays(); }} name="Area" @@ -770,10 +690,7 @@ function Page() { >
@@ -816,9 +733,7 @@ function Page() { )}
- {ostype() === "macos" && ( -
- )} + {ostype() === "macos" &&
} }> - {license.data?.type === "commercial" - ? "Commercial" - : license.data?.type === "pro" - ? "Pro" - : "Personal"} + {license.data?.type === "commercial" ? "Commercial" : license.data?.type === "pro" ? "Pro" : "Personal"} diff --git a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx index 75f24dff38..d7d745b74c 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx @@ -1,440 +1,385 @@ import { ProgressCircle } from "@cap/ui-solid"; import Tooltip from "@corvu/tooltip"; -import { - createMutation, - createQuery, - queryOptions, - useQueryClient, -} from "@tanstack/solid-query"; +import { createMutation, createQuery, queryOptions, useQueryClient } from "@tanstack/solid-query"; import { Channel, convertFileSrc } from "@tauri-apps/api/core"; import { ask, confirm } from "@tauri-apps/plugin-dialog"; import { remove } from "@tauri-apps/plugin-fs"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import * as shell from "@tauri-apps/plugin-shell"; import { cx } from "cva"; -import { - createMemo, - createSignal, - For, - type JSX, - type ParentProps, - Show, -} from "solid-js"; +import { createMemo, createSignal, For, type JSX, type ParentProps, Show } from "solid-js"; import { createStore, produce, reconcile } from "solid-js/store"; import CapTooltip from "~/components/Tooltip"; import { trackEvent } from "~/utils/analytics"; import { createTauriEventListener } from "~/utils/createEventListener"; -import { - commands, - events, - type RecordingMetaWithMetadata, - type UploadProgress, -} from "~/utils/tauri"; +import { commands, events, type RecordingMetaWithMetadata, type UploadProgress } from "~/utils/tauri"; type Recording = { - meta: RecordingMetaWithMetadata; - path: string; - prettyName: string; - thumbnailPath: string; + meta: RecordingMetaWithMetadata; + path: string; + prettyName: string; + thumbnailPath: string; }; const Tabs = [ - { - id: "all", - label: "Show all", - }, - { - id: "instant", - icon: , - label: "Instant", - }, - { - id: "studio", - icon: , - label: "Studio", - }, + { + id: "all", + label: "Show all", + }, + { + id: "instant", + icon: , + label: "Instant", + }, + { + id: "studio", + icon: , + label: "Studio", + }, ] satisfies { id: string; label: string; icon?: JSX.Element }[]; const recordingsQuery = queryOptions({ - queryKey: ["recordings"], - queryFn: async () => { - const result = await commands.listRecordings().catch(() => [] as const); + queryKey: ["recordings"], + queryFn: async () => { + const result = await commands.listRecordings().catch(() => [] as const); - const recordings = await Promise.all( - result.map(async (file) => { - const [path, meta] = file; - const thumbnailPath = `${path}/screenshots/display.jpg`; + const recordings = await Promise.all( + result.map(async (file) => { + const [path, meta] = file; + const thumbnailPath = `${path}/screenshots/display.jpg`; - return { - meta, - path, - prettyName: meta.pretty_name, - thumbnailPath, - }; - }), - ); - return recordings; - }, - reconcile: (old, n) => reconcile(n)(old), - // This will ensure any changes to the upload status in the project meta are reflected. - refetchInterval: 2000, + return { + meta, + path, + prettyName: meta.pretty_name, + thumbnailPath, + }; + }) + ); + return recordings; + }, + reconcile: (old, n) => reconcile(n)(old), + // This will ensure any changes to the upload status in the project meta are reflected. + refetchInterval: 2000, }); export default function Recordings() { - const [activeTab, setActiveTab] = createSignal<(typeof Tabs)[number]["id"]>( - Tabs[0].id, - ); - const [uploadProgress, setUploadProgress] = createStore< - Record - >({}); - const recordings = createQuery(() => recordingsQuery); + const [activeTab, setActiveTab] = createSignal<(typeof Tabs)[number]["id"]>(Tabs[0].id); + const [uploadProgress, setUploadProgress] = createStore>({}); + const recordings = createQuery(() => recordingsQuery); - createTauriEventListener(events.uploadProgressEvent, (e) => { - setUploadProgress(e.video_id, (Number(e.uploaded) / Number(e.total)) * 100); - if (e.uploaded === e.total) - setUploadProgress( - produce((s) => { - delete s[e.video_id]; - }), - ); - }); + createTauriEventListener(events.uploadProgressEvent, (e) => { + setUploadProgress(e.video_id, (Number(e.uploaded) / Number(e.total)) * 100); + if (e.uploaded === e.total) + setUploadProgress( + produce((s) => { + delete s[e.video_id]; + }) + ); + }); - createTauriEventListener(events.recordingDeleted, () => recordings.refetch()); + createTauriEventListener(events.recordingDeleted, () => recordings.refetch()); - const filteredRecordings = createMemo(() => { - if (!recordings.data) { - return []; - } - if (activeTab() === "all") { - return recordings.data; - } - return recordings.data.filter( - (recording) => recording.meta.mode === activeTab(), - ); - }); + const filteredRecordings = createMemo(() => { + if (!recordings.data) { + return []; + } + if (activeTab() === "all") { + return recordings.data; + } + return recordings.data.filter((recording) => recording.meta.mode === activeTab()); + }); - const handleRecordingClick = (recording: Recording) => { - trackEvent("recording_view_clicked"); - events.newStudioRecordingAdded.emit({ path: recording.path }); - }; + const handleRecordingClick = (recording: Recording) => { + trackEvent("recording_view_clicked"); + events.newStudioRecordingAdded.emit({ path: recording.path }); + }; - const handleOpenFolder = (path: string) => { - trackEvent("recording_folder_clicked"); - commands.openFilePath(path); - }; + const handleOpenFolder = (path: string) => { + trackEvent("recording_folder_clicked"); + commands.openFilePath(path); + }; - const handleCopyVideoToClipboard = (path: string) => { - trackEvent("recording_copy_clicked"); - commands.copyVideoToClipboard(path); - }; + const handleCopyVideoToClipboard = (path: string) => { + trackEvent("recording_copy_clicked"); + commands.copyVideoToClipboard(path); + }; - const handleOpenEditor = (path: string) => { - trackEvent("recording_editor_clicked"); - commands.showWindow({ - Editor: { project_path: path }, - }); - }; + const handleOpenEditor = (path: string) => { + trackEvent("recording_editor_clicked"); + commands.showWindow({ + Editor: { project_path: path }, + }); + }; - return ( -
-
-

Previous Recordings

-

- Manage your recordings and perform actions. -

-
- 0} - fallback={ -

- No recordings found -

- } - > -
- - {(tab) => ( -
setActiveTab(tab.id)} - > - {tab.icon && tab.icon} -

{tab.label}

-
- )} -
-
+ return ( +
+
+

Previous Recordings

+

Manage your recordings and perform actions.

+
+ 0} + fallback={ +

+ No recordings found +

+ } + > +
+ + {(tab) => ( +
setActiveTab(tab.id)} + > + {tab.icon && tab.icon} +

{tab.label}

+
+ )} +
+
-
- -

- No {activeTab()} recordings -

-
-
    - - {(recording) => ( - handleRecordingClick(recording)} - onOpenFolder={() => handleOpenFolder(recording.path)} - onOpenEditor={() => handleOpenEditor(recording.path)} - onCopyVideoToClipboard={() => - handleCopyVideoToClipboard(recording.path) - } - uploadProgress={ - recording.meta.upload && - (recording.meta.upload.state === "MultipartUpload" || - recording.meta.upload.state === "SinglePartUpload") - ? uploadProgress[recording.meta.upload.video_id] - : undefined - } - /> - )} - -
-
-
-
- ); +
+ +

+ No {activeTab()} recordings +

+
+
    + + {(recording) => ( + handleRecordingClick(recording)} + onOpenFolder={() => handleOpenFolder(recording.path)} + onOpenEditor={() => handleOpenEditor(recording.path)} + onCopyVideoToClipboard={() => handleCopyVideoToClipboard(recording.path)} + uploadProgress={ + recording.meta.upload && + (recording.meta.upload.state === "MultipartUpload" || + recording.meta.upload.state === "SinglePartUpload") + ? uploadProgress[recording.meta.upload.video_id] + : undefined + } + /> + )} + +
+
+
+
+ ); } function RecordingItem(props: { - recording: Recording; - onClick: () => void; - onOpenFolder: () => void; - onOpenEditor: () => void; - onCopyVideoToClipboard: () => void; - uploadProgress: number | undefined; + recording: Recording; + onClick: () => void; + onOpenFolder: () => void; + onOpenEditor: () => void; + onCopyVideoToClipboard: () => void; + uploadProgress: number | undefined; }) { - const [imageExists, setImageExists] = createSignal(true); - const mode = () => props.recording.meta.mode; - const firstLetterUpperCase = () => - mode().charAt(0).toUpperCase() + mode().slice(1); + const [imageExists, setImageExists] = createSignal(true); + const mode = () => props.recording.meta.mode; + const firstLetterUpperCase = () => mode().charAt(0).toUpperCase() + mode().slice(1); - const queryClient = useQueryClient(); - const studioCompleteCheck = () => - mode() === "studio" && props.recording.meta.status.status === "Complete"; + const queryClient = useQueryClient(); + const studioCompleteCheck = () => mode() === "studio" && props.recording.meta.status.status === "Complete"; - return ( -
  • { - if (studioCompleteCheck()) { - props.onOpenEditor(); - } - }} - class={cx( - "flex flex-row justify-between p-3 [&:not(:last-child)]:border-b [&:not(:last-child)]:border-gray-3 items-center w-full transition-colors duration-200", - studioCompleteCheck() - ? "cursor-pointer hover:bg-gray-3" - : "cursor-default", - )} - > -
    - } - > - Recording thumbnail setImageExists(false)} - /> - -
    - {props.recording.prettyName} -
    -
    - {mode() === "instant" ? ( - - ) : ( - - )} -

    {firstLetterUpperCase()}

    -
    + return ( +
  • { + if (studioCompleteCheck()) { + props.onOpenEditor(); + } + }} + class={cx( + "flex flex-row justify-between p-3 [&:not(:last-child)]:border-b [&:not(:last-child)]:border-gray-3 items-center w-full transition-colors duration-200", + studioCompleteCheck() ? "cursor-pointer hover:bg-gray-3" : "cursor-default" + )} + > +
    + }> + Recording thumbnail setImageExists(false)} + /> + +
    + {props.recording.prettyName} +
    +
    + {mode() === "instant" ? ( + + ) : ( + + )} +

    {firstLetterUpperCase()}

    +
    - -
    - -

    Recording in progress

    -
    -
    + +
    + +

    Recording in progress

    +
    +
    - - - {props.recording.meta.status.status === "Failed" - ? props.recording.meta.status.error - : ""} - - } - > -
    - -

    Recording failed

    -
    -
    -
    -
    -
    -
    -
    - - - - - - - - {(sharing) => ( - shell.open(sharing().link)} - > - - - )} - - { - if ( - props.recording.meta.status.status === "Failed" && - !(await confirm( - "The recording failed so this file may have issues in the editor! If your having issues recovering the file please reach out to support!", - { - title: "Recording is potentially corrupted", - kind: "warning", - }, - )) - ) - return; - props.onOpenEditor(); - }} - disabled={props.recording.meta.status.status === "InProgress"} - > - - - - - {(_) => { - const reupload = createMutation(() => ({ - mutationFn: () => - commands.uploadExportedVideo( - props.recording.path, - "Reupload", - new Channel((progress) => {}), - null, - ), - })); + + + {props.recording.meta.status.status === "Failed" ? props.recording.meta.status.error : ""} + + } + > +
    + +

    Recording failed

    +
    +
    +
    +
    +
  • +
    +
    + + + + + + + + {(sharing) => ( + shell.open(sharing().link)}> + + + )} + + { + if ( + props.recording.meta.status.status === "Failed" && + !(await confirm( + "The recording failed so this file may have issues in the editor! If your having issues recovering the file please reach out to support!", + { + title: "Recording is potentially corrupted", + kind: "warning", + } + )) + ) + return; + props.onOpenEditor(); + }} + disabled={props.recording.meta.status.status === "InProgress"} + > + + + + + {(_) => { + const reupload = createMutation(() => ({ + mutationFn: () => + commands.uploadExportedVideo( + props.recording.path, + "Reupload", + new Channel((progress) => {}), + null, + null + ), + })); - return ( - <> - reupload.mutate()} - > - - - } - > - - + return ( + <> + reupload.mutate()}> + + + } + > + + - - {(sharing) => ( - shell.open(sharing().link)} - > - - - )} - - - ); - }} - - revealItemInDir(`${props.recording.path}/`)} - > - - - { - if (!(await ask("Are you sure you want to delete this recording?"))) - return; - await remove(props.recording.path, { recursive: true }); + + {(sharing) => ( + shell.open(sharing().link)}> + + + )} + + + ); + }} + + revealItemInDir(`${props.recording.path}/`)} + > + + + { + if (!(await ask("Are you sure you want to delete this recording?"))) return; + await remove(props.recording.path, { recursive: true }); - queryClient.refetchQueries(recordingsQuery); - }} - > - - -
    - - ); + queryClient.refetchQueries(recordingsQuery); + }} + > + + +
    + + ); } function TooltipIconButton( - props: ParentProps<{ - onClick: () => void; - tooltipText: string; - disabled?: boolean; - }>, + props: ParentProps<{ + onClick: () => void; + tooltipText: string; + disabled?: boolean; + }> ) { - return ( - - { - e.stopPropagation(); - props.onClick(); - }} - disabled={props.disabled} - class="p-2.5 opacity-70 will-change-transform hover:opacity-100 rounded-full transition-all duration-200 hover:bg-gray-3 dark:hover:bg-gray-5 disabled:pointer-events-none disabled:opacity-45 disabled:hover:opacity-45" - > - {props.children} - - - - {props.tooltipText} - - - - ); + return ( + + { + e.stopPropagation(); + props.onClick(); + }} + disabled={props.disabled} + class="p-2.5 opacity-70 will-change-transform hover:opacity-100 rounded-full transition-all duration-200 hover:bg-gray-3 dark:hover:bg-gray-5 disabled:pointer-events-none disabled:opacity-45 disabled:hover:opacity-45" + > + {props.children} + + + + {props.tooltipText} + + + + ); } diff --git a/apps/desktop/src/routes/editor/ExportDialog.tsx b/apps/desktop/src/routes/editor/ExportDialog.tsx index ba63e1f25a..a2ba07131d 100644 --- a/apps/desktop/src/routes/editor/ExportDialog.tsx +++ b/apps/desktop/src/routes/editor/ExportDialog.tsx @@ -1,28 +1,24 @@ import { Button } from "@cap/ui-solid"; import { Select as KSelect } from "@kobalte/core/select"; import { makePersisted } from "@solid-primitives/storage"; -import { - createMutation, - createQuery, - keepPreviousData, -} from "@tanstack/solid-query"; +import { createMutation, createQuery, keepPreviousData } from "@tanstack/solid-query"; import { Channel } from "@tauri-apps/api/core"; import { CheckMenuItem, Menu } from "@tauri-apps/api/menu"; import { save as saveDialog } from "@tauri-apps/plugin-dialog"; import { cx } from "cva"; import { - createEffect, - createRoot, - createSignal, - For, - type JSX, - Match, - mergeProps, - on, - Show, - Suspense, - Switch, - type ValidComponent, + createEffect, + createRoot, + createSignal, + For, + type JSX, + Match, + mergeProps, + on, + Show, + Suspense, + Switch, + type ValidComponent, } from "solid-js"; import { createStore, produce, reconcile } from "solid-js/store"; import toast from "solid-toast"; @@ -34,1090 +30,886 @@ import { createSignInMutation } from "~/utils/auth"; import { exportVideo } from "~/utils/export"; import { createOrganizationsQuery } from "~/utils/queries"; import { - commands, - type ExportCompression, - type ExportSettings, - type FramesRendered, - type UploadProgress, + commands, + type ExportCompression, + type ExportSettings, + type FramesRendered, + type UploadProgress, } from "~/utils/tauri"; import { type RenderState, useEditorContext } from "./context"; import { RESOLUTION_OPTIONS } from "./Header"; -import { - Dialog, - DialogContent, - MenuItem, - MenuItemList, - PopperContent, - topSlideAnimateClasses, -} from "./ui"; +import { Dialog, DialogContent, MenuItem, MenuItemList, PopperContent, topSlideAnimateClasses } from "./ui"; class SilentError extends Error {} export const COMPRESSION_OPTIONS: Array<{ - label: string; - value: ExportCompression; + label: string; + value: ExportCompression; }> = [ - { label: "Minimal", value: "Minimal" }, - { label: "Social Media", value: "Social" }, - { label: "Web", value: "Web" }, - { label: "Potato", value: "Potato" }, + { label: "Minimal", value: "Minimal" }, + { label: "Social Media", value: "Social" }, + { label: "Web", value: "Web" }, + { label: "Potato", value: "Potato" }, ]; export const FPS_OPTIONS = [ - { label: "15 FPS", value: 15 }, - { label: "30 FPS", value: 30 }, - { label: "60 FPS", value: 60 }, + { label: "15 FPS", value: 15 }, + { label: "30 FPS", value: 30 }, + { label: "60 FPS", value: 60 }, ] satisfies Array<{ label: string; value: number }>; export const GIF_FPS_OPTIONS = [ - { label: "10 FPS", value: 10 }, - { label: "15 FPS", value: 15 }, - { label: "20 FPS", value: 20 }, - { label: "25 FPS", value: 25 }, - { label: "30 FPS", value: 30 }, + { label: "10 FPS", value: 10 }, + { label: "15 FPS", value: 15 }, + { label: "20 FPS", value: 20 }, + { label: "25 FPS", value: 25 }, + { label: "30 FPS", value: 30 }, ] satisfies Array<{ label: string; value: number }>; export const EXPORT_TO_OPTIONS = [ - { - label: "File", - value: "file", - icon: , - }, - { - label: "Clipboard", - value: "clipboard", - icon: , - }, - { - label: "Shareable link", - value: "link", - icon: , - }, + { + label: "File", + value: "file", + icon: , + }, + { + label: "Clipboard", + value: "clipboard", + icon: , + }, + { + label: "Shareable link", + value: "link", + icon: , + }, ] as const; type ExportFormat = ExportSettings["format"]; const FORMAT_OPTIONS = [ - { label: "MP4", value: "Mp4" }, - { label: "GIF", value: "Gif" }, + { label: "MP4", value: "Mp4" }, + { label: "GIF", value: "Gif" }, ] as { label: string; value: ExportFormat; disabled?: boolean }[]; type ExportToOption = (typeof EXPORT_TO_OPTIONS)[number]["value"]; interface Settings { - format: ExportFormat; - fps: number; - exportTo: ExportToOption; - resolution: { label: string; value: string; width: number; height: number }; - compression: ExportCompression; - organizationId?: string | null; + format: ExportFormat; + fps: number; + exportTo: ExportToOption; + resolution: { label: string; value: string; width: number; height: number }; + compression: ExportCompression; + organizationId?: string | null; + workspaceId?: string | null; } export function ExportDialog() { - const { - dialog, - setDialog, - editorInstance, - setExportState, - exportState, - meta, - refetchMeta, - } = useEditorContext(); - - const auth = authStore.createQuery(); - const organisations = createOrganizationsQuery(); - - const hasTransparentBackground = () => { - const backgroundSource = - editorInstance.savedProjectConfig.background.source; - return ( - backgroundSource.type === "color" && - backgroundSource.alpha !== undefined && - backgroundSource.alpha < 255 - ); - }; - - const [_settings, setSettings] = makePersisted( - createStore({ - format: "Mp4", - fps: 30, - exportTo: "file", - resolution: { label: "720p", value: "720p", width: 1280, height: 720 }, - compression: "Minimal", - }), - { name: "export_settings" }, - ); - - const settings = mergeProps(_settings, () => { - const ret: Partial = {}; - if (hasTransparentBackground() && _settings.format === "Mp4") - ret.format = "Gif"; - // Ensure GIF is not selected when exportTo is "link" - else if (_settings.format === "Gif" && _settings.exportTo === "link") - ret.format = "Mp4"; - else if (!["Mp4", "Gif"].includes(_settings.format)) ret.format = "Mp4"; - - Object.defineProperty(ret, "organizationId", { - get() { - if (!_settings.organizationId && organisations().length > 0) - return organisations()[0].id; - - return _settings.organizationId; - }, - }); - - return ret; - }); - - const exportWithSettings = (onProgress: (progress: FramesRendered) => void) => - exportVideo( - projectPath, - settings.format === "Mp4" - ? { - format: "Mp4", - fps: settings.fps, - resolution_base: { - x: settings.resolution.width, - y: settings.resolution.height, - }, - compression: settings.compression, - } - : { - format: "Gif", - fps: settings.fps, - resolution_base: { - x: settings.resolution.width, - y: settings.resolution.height, - }, - quality: null, - }, - onProgress, - ); - - const [outputPath, setOutputPath] = createSignal(null); - - const projectPath = editorInstance.path; - - const exportEstimates = createQuery(() => ({ - // prevents flicker when modifying settings - placeholderData: keepPreviousData, - queryKey: [ - "exportEstimates", - { - resolution: { - x: settings.resolution.width, - y: settings.resolution.height, - }, - fps: settings.fps, - }, - ] as const, - queryFn: ({ queryKey: [_, { resolution, fps }] }) => - commands.getExportEstimates(projectPath, resolution, fps), - })); - - const exportButtonIcon: Record<"file" | "clipboard" | "link", JSX.Element> = { - file: , - clipboard: , - link: , - }; - - const copy = createMutation(() => ({ - mutationFn: async () => { - if (exportState.type !== "idle") return; - setExportState(reconcile({ action: "copy", type: "starting" })); - - const outputPath = await exportWithSettings((progress) => { - setExportState({ type: "rendering", progress }); - }); - - setExportState({ type: "copying" }); - - await commands.copyVideoToClipboard(outputPath); - }, - onError: (error) => { - commands.globalMessageDialog( - error instanceof Error ? error.message : "Failed to copy recording", - ); - setExportState(reconcile({ type: "idle" })); - }, - onSuccess() { - setExportState({ type: "done" }); - - if (dialog().open) { - createRoot((dispose) => { - createEffect( - on( - () => dialog().open, - () => { - dispose(); - }, - { defer: true }, - ), - ); - }); - } else - toast.success( - `${ - settings.format === "Gif" ? "GIF" : "Recording" - } exported to clipboard`, - ); - }, - })); - - const save = createMutation(() => ({ - mutationFn: async () => { - if (exportState.type !== "idle") return; - - const extension = settings.format === "Gif" ? "gif" : "mp4"; - const savePath = await saveDialog({ - filters: [ - { - name: `${extension.toUpperCase()} filter`, - extensions: [extension], - }, - ], - defaultPath: `~/Desktop/${meta().prettyName}.${extension}`, - }); - if (!savePath) { - setExportState(reconcile({ type: "idle" })); - return; - } - - setExportState(reconcile({ action: "save", type: "starting" })); - - setOutputPath(savePath); - - trackEvent("export_started", { - resolution: settings.resolution, - fps: settings.fps, - path: savePath, - }); - - const videoPath = await exportWithSettings((progress) => { - setExportState({ type: "rendering", progress }); - }); - - setExportState({ type: "copying" }); - - await commands.copyFileToPath(videoPath, savePath); - - setExportState({ type: "done" }); - }, - onError: (error) => { - commands.globalMessageDialog( - error instanceof Error - ? error.message - : `Failed to export recording: ${error}`, - ); - setExportState({ type: "idle" }); - }, - onSuccess() { - if (dialog().open) { - createRoot((dispose) => { - createEffect( - on( - () => dialog().open, - () => { - dispose(); - }, - { defer: true }, - ), - ); - }); - } else - toast.success( - `${settings.format === "Gif" ? "GIF" : "Recording"} exported to file`, - ); - }, - })); - - const upload = createMutation(() => ({ - mutationFn: async () => { - if (exportState.type !== "idle") return; - setExportState(reconcile({ action: "upload", type: "starting" })); - - // Check authentication first - const existingAuth = await authStore.get(); - if (!existingAuth) createSignInMutation(); - trackEvent("create_shareable_link_clicked", { - resolution: settings.resolution, - fps: settings.fps, - has_existing_auth: !!existingAuth, - }); - - const metadata = await commands.getVideoMetadata(projectPath); - const plan = await commands.checkUpgradedAndUpdate(); - const canShare = { - allowed: plan || metadata.duration < 300, - reason: !plan && metadata.duration >= 300 ? "upgrade_required" : null, - }; - - if (!canShare.allowed) { - if (canShare.reason === "upgrade_required") { - await commands.showWindow("Upgrade"); - // The window takes a little to show and this prevents the user seeing it glitch - await new Promise((resolve) => setTimeout(resolve, 1000)); - throw new SilentError(); - } - } - - const uploadChannel = new Channel((progress) => { - console.log("Upload progress:", progress); - setExportState( - produce((state) => { - if (state.type !== "uploading") return; - - state.progress = Math.round(progress.progress * 100); - }), - ); - }); - - await exportWithSettings((progress) => - setExportState({ type: "rendering", progress }), - ); - - setExportState({ type: "uploading", progress: 0 }); - - console.log({ organizationId: settings.organizationId }); - - // Now proceed with upload - const result = meta().sharing - ? await commands.uploadExportedVideo( - projectPath, - "Reupload", - uploadChannel, - settings.organizationId ?? null, - ) - : await commands.uploadExportedVideo( - projectPath, - { Initial: { pre_created_video: null } }, - uploadChannel, - settings.organizationId ?? null, - ); - - if (result === "NotAuthenticated") - throw new Error("You need to sign in to share recordings"); - else if (result === "PlanCheckFailed") - throw new Error("Failed to verify your subscription status"); - else if (result === "UpgradeRequired") - throw new Error("This feature requires an upgraded plan"); - }, - onSuccess: async () => { - const d = dialog(); - if ("type" in d && d.type === "export") setDialog({ ...d, open: true }); - - await refetchMeta(); - - console.log(meta().sharing); - - setExportState({ type: "done" }); - }, - onError: (error) => { - console.error(error); - if (!(error instanceof SilentError)) { - commands.globalMessageDialog( - error instanceof Error ? error.message : "Failed to upload recording", - ); - } - - setExportState(reconcile({ type: "idle" })); - }, - })); - - return ( - <> - - - {exportButtonIcon[settings.exportTo]} - Sign in to share - - ) : ( - - ) - } - leftFooterContent={ -
    - - - {(est) => ( -

    - - - {(() => { - const totalSeconds = Math.round( - est().duration_seconds, - ); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor( - (totalSeconds % 3600) / 60, - ); - const seconds = totalSeconds % 60; - - if (hours > 0) { - return `${hours}:${minutes - .toString() - .padStart(2, "0")}:${seconds - .toString() - .padStart(2, "0")}`; - } - return `${minutes}:${seconds - .toString() - .padStart(2, "0")}`; - })()} - - - - {settings.resolution.width}×{settings.resolution.height} - - - - {est().estimated_size_mb.toFixed(2)} MB - - - - {(() => { - const totalSeconds = Math.round( - est().estimated_time_seconds, - ); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor( - (totalSeconds % 3600) / 60, - ); - const seconds = totalSeconds % 60; - - if (hours > 0) { - return `~${hours}:${minutes - .toString() - .padStart(2, "0")}:${seconds - .toString() - .padStart(2, "0")}`; - } - return `~${minutes}:${seconds - .toString() - .padStart(2, "0")}`; - })()} - -

    - )} -
    -
    -
    - } - > -
    - {/* Export to */} -
    -
    -
    -

    Export to

    - - 1 - } - > -
    { - const menu = await Menu.new({ - items: await Promise.all( - organisations().map((org) => - CheckMenuItem.new({ - text: org.name, - action: () => { - setSettings("organizationId", org.id); - }, - checked: settings.organizationId === org.id, - }), - ), - ), - }); - menu.popup(); - }} - > - Organization: - - { - ( - organisations().find( - (o) => o.id === settings.organizationId, - ) ?? organisations()[0] - )?.name - } - - -
    -
    -
    -
    -
    - - {(option) => ( - - )} - -
    -
    -
    - {/* Format */} -
    -
    -

    Format

    -
    - - {(option) => { - const disabledReason = () => { - if ( - option.value === "Mp4" && - hasTransparentBackground() - ) - return "MP4 format does not support transparent backgrounds"; - if ( - option.value === "Gif" && - settings.exportTo === "link" - ) - return "Shareable links cannot be made from GIFs"; - }; - - return ( - - - - ); - }} - -
    -
    -
    - {/* Frame rate */} -
    -
    -

    Frame rate

    - - options={ - settings.format === "Gif" ? GIF_FPS_OPTIONS : FPS_OPTIONS - } - optionValue="value" - optionTextValue="label" - placeholder="Select FPS" - value={(settings.format === "Gif" - ? GIF_FPS_OPTIONS - : FPS_OPTIONS - ).find((opt) => opt.value === settings.fps)} - onChange={(option) => { - const value = - option?.value ?? (settings.format === "Gif" ? 10 : 30); - trackEvent("export_fps_changed", { - fps: value, - }); - setSettings("fps", value); - }} - itemComponent={(props) => ( - - as={KSelect.Item} - item={props.item} - > - - {props.item.rawValue.label} - - - )} - > - - class="flex-1 text-sm text-left truncate tabular-nums text-[--gray-500]"> - {(state) => {state.selectedOption()?.label}} - - - as={(props) => ( - - )} - /> - - - - as={KSelect.Content} - class={cx(topSlideAnimateClasses, "z-50")} - > - - class="max-h-32 custom-scroll" - as={KSelect.Listbox} - /> - - - -
    -
    - {/* Compression */} -
    -
    -

    Compression

    -
    - - {(option) => ( - - )} - -
    -
    -
    - {/* Resolution */} -
    -
    -

    Resolution

    -
    - - {(option) => ( - - )} - -
    -
    -
    -
    -
    -
    - - {(exportState) => { - const [copyPressed, setCopyPressed] = createSignal(false); - const [clipboardCopyPressed, setClipboardCopyPressed] = - createSignal(false); - const [showCompletionScreen, setShowCompletionScreen] = createSignal( - exportState.type === "done" && exportState.action === "save", - ); - - createEffect(() => { - if (exportState.type === "done" && exportState.action === "save") { - setShowCompletionScreen(true); - } - }); - - return ( - <> - -
    - Export -
    setDialog((d) => ({ ...d, open: false }))} - class="flex justify-center items-center p-1 rounded-full transition-colors cursor-pointer hover:bg-gray-3" - > - -
    -
    -
    - -
    - - - {(copyState) => ( -
    -

    - {copyState.type === "starting" - ? "Preparing..." - : copyState.type === "rendering" - ? settings.format === "Gif" - ? "Rendering GIF..." - : "Rendering video..." - : copyState.type === "copying" - ? "Copying to clipboard..." - : "Copied to clipboard"} -

    - - {(copyState) => ( - - )} - -
    - )} -
    - - {(saveState) => ( -
    - -

    - {saveState.type === "starting" - ? "Preparing..." - : saveState.type === "rendering" - ? settings.format === "Gif" - ? "Rendering GIF..." - : "Rendering video..." - : saveState.type === "copying" - ? "Exporting to file..." - : "Export completed"} -

    - - {(copyState) => ( - - )} - - - } - > -
    -
    -
    - -
    -
    -

    - Export Completed -

    -

    - Your{" "} - {settings.format === "Gif" - ? "GIF" - : "video"}{" "} - has successfully been exported -

    -
    -
    -
    -
    -
    - )} -
    - - {(uploadState) => ( - - - {(uploadState) => ( -
    -

    - Uploading Cap... -

    - - - {(uploadState) => ( - - )} - - - {(renderState) => ( - - )} - - -
    - )} -
    - -
    -
    -

    - Upload Complete -

    -

    - Your Cap has been uploaded successfully -

    -
    -
    -
    -
    - )} -
    -
    -
    -
    - - - - - - -
    - - -
    -
    -
    - - ); - }} -
    - - ); + const { dialog, setDialog, editorInstance, setExportState, exportState, meta, refetchMeta } = useEditorContext(); + + const auth = authStore.createQuery(); + const organisations = createOrganizationsQuery(); + + const hasTransparentBackground = () => { + const backgroundSource = editorInstance.savedProjectConfig.background.source; + return backgroundSource.type === "color" && backgroundSource.alpha !== undefined && backgroundSource.alpha < 255; + }; + + const [_settings, setSettings] = makePersisted( + createStore({ + format: "Mp4", + fps: 30, + exportTo: "file", + resolution: { label: "720p", value: "720p", width: 1280, height: 720 }, + compression: "Minimal", + }), + { name: "export_settings" } + ); + + const settings = mergeProps(_settings, () => { + const ret: Partial = {}; + if (hasTransparentBackground() && _settings.format === "Mp4") ret.format = "Gif"; + // Ensure GIF is not selected when exportTo is "link" + else if (_settings.format === "Gif" && _settings.exportTo === "link") ret.format = "Mp4"; + else if (!["Mp4", "Gif"].includes(_settings.format)) ret.format = "Mp4"; + + Object.defineProperty(ret, "organizationId", { + get() { + if (!_settings.organizationId && organisations().length > 0) return organisations()[0].id; + + return _settings.organizationId; + }, + }); + + return ret; + }); + + const exportWithSettings = (onProgress: (progress: FramesRendered) => void) => + exportVideo( + projectPath, + settings.format === "Mp4" + ? { + format: "Mp4", + fps: settings.fps, + resolution_base: { + x: settings.resolution.width, + y: settings.resolution.height, + }, + compression: settings.compression, + } + : { + format: "Gif", + fps: settings.fps, + resolution_base: { + x: settings.resolution.width, + y: settings.resolution.height, + }, + quality: null, + }, + onProgress + ); + + const [outputPath, setOutputPath] = createSignal(null); + + const projectPath = editorInstance.path; + + const exportEstimates = createQuery(() => ({ + // prevents flicker when modifying settings + placeholderData: keepPreviousData, + queryKey: [ + "exportEstimates", + { + resolution: { + x: settings.resolution.width, + y: settings.resolution.height, + }, + fps: settings.fps, + }, + ] as const, + queryFn: ({ queryKey: [_, { resolution, fps }] }) => commands.getExportEstimates(projectPath, resolution, fps), + })); + + const exportButtonIcon: Record<"file" | "clipboard" | "link", JSX.Element> = { + file: , + clipboard: , + link: , + }; + + const copy = createMutation(() => ({ + mutationFn: async () => { + if (exportState.type !== "idle") return; + setExportState(reconcile({ action: "copy", type: "starting" })); + + const outputPath = await exportWithSettings((progress) => { + setExportState({ type: "rendering", progress }); + }); + + setExportState({ type: "copying" }); + + await commands.copyVideoToClipboard(outputPath); + }, + onError: (error) => { + commands.globalMessageDialog(error instanceof Error ? error.message : "Failed to copy recording"); + setExportState(reconcile({ type: "idle" })); + }, + onSuccess() { + setExportState({ type: "done" }); + + if (dialog().open) { + createRoot((dispose) => { + createEffect( + on( + () => dialog().open, + () => { + dispose(); + }, + { defer: true } + ) + ); + }); + } else toast.success(`${settings.format === "Gif" ? "GIF" : "Recording"} exported to clipboard`); + }, + })); + + const save = createMutation(() => ({ + mutationFn: async () => { + if (exportState.type !== "idle") return; + + const extension = settings.format === "Gif" ? "gif" : "mp4"; + const savePath = await saveDialog({ + filters: [ + { + name: `${extension.toUpperCase()} filter`, + extensions: [extension], + }, + ], + defaultPath: `~/Desktop/${meta().prettyName}.${extension}`, + }); + if (!savePath) { + setExportState(reconcile({ type: "idle" })); + return; + } + + setExportState(reconcile({ action: "save", type: "starting" })); + + setOutputPath(savePath); + + trackEvent("export_started", { + resolution: settings.resolution, + fps: settings.fps, + path: savePath, + }); + + const videoPath = await exportWithSettings((progress) => { + setExportState({ type: "rendering", progress }); + }); + + setExportState({ type: "copying" }); + + await commands.copyFileToPath(videoPath, savePath); + + setExportState({ type: "done" }); + }, + onError: (error) => { + commands.globalMessageDialog(error instanceof Error ? error.message : `Failed to export recording: ${error}`); + setExportState({ type: "idle" }); + }, + onSuccess() { + if (dialog().open) { + createRoot((dispose) => { + createEffect( + on( + () => dialog().open, + () => { + dispose(); + }, + { defer: true } + ) + ); + }); + } else toast.success(`${settings.format === "Gif" ? "GIF" : "Recording"} exported to file`); + }, + })); + + const upload = createMutation(() => ({ + mutationFn: async () => { + if (exportState.type !== "idle") return; + setExportState(reconcile({ action: "upload", type: "starting" })); + + // Check authentication first + const existingAuth = await authStore.get(); + if (!existingAuth) createSignInMutation(); + trackEvent("create_shareable_link_clicked", { + resolution: settings.resolution, + fps: settings.fps, + has_existing_auth: !!existingAuth, + }); + + const metadata = await commands.getVideoMetadata(projectPath); + const plan = await commands.checkUpgradedAndUpdate(); + const canShare = { + allowed: plan || metadata.duration < 300, + reason: !plan && metadata.duration >= 300 ? "upgrade_required" : null, + }; + + if (!canShare.allowed) { + if (canShare.reason === "upgrade_required") { + await commands.showWindow("Upgrade"); + // The window takes a little to show and this prevents the user seeing it glitch + await new Promise((resolve) => setTimeout(resolve, 1000)); + throw new SilentError(); + } + } + + const uploadChannel = new Channel((progress) => { + console.log("Upload progress:", progress); + setExportState( + produce((state) => { + if (state.type !== "uploading") return; + + state.progress = Math.round(progress.progress * 100); + }) + ); + }); + + await exportWithSettings((progress) => setExportState({ type: "rendering", progress })); + + setExportState({ type: "uploading", progress: 0 }); + + console.log({ organizationId: settings.organizationId }); + + // Now proceed with upload + const result = meta().sharing + ? await commands.uploadExportedVideo( + projectPath, + "Reupload", + uploadChannel, + settings.organizationId ?? null, + settings.workspaceId ?? null + ) + : await commands.uploadExportedVideo( + projectPath, + { Initial: { pre_created_video: null } }, + uploadChannel, + settings.organizationId ?? null, + settings.workspaceId ?? null + ); + + if (result === "NotAuthenticated") throw new Error("You need to sign in to share recordings"); + else if (result === "PlanCheckFailed") throw new Error("Failed to verify your subscription status"); + else if (result === "UpgradeRequired") throw new Error("This feature requires an upgraded plan"); + }, + onSuccess: async () => { + const d = dialog(); + if ("type" in d && d.type === "export") setDialog({ ...d, open: true }); + + await refetchMeta(); + + console.log(meta().sharing); + + setExportState({ type: "done" }); + }, + onError: (error) => { + console.error(error); + if (!(error instanceof SilentError)) { + commands.globalMessageDialog(error instanceof Error ? error.message : "Failed to upload recording"); + } + + setExportState(reconcile({ type: "idle" })); + }, + })); + + return ( + <> + + + {exportButtonIcon[settings.exportTo]} + Sign in to share + + ) : ( + + ) + } + leftFooterContent={ +
    + + + {(est) => ( +

    + + + {(() => { + const totalSeconds = Math.round(est().duration_seconds); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds + .toString() + .padStart(2, "0")}`; + } + return `${minutes}:${seconds.toString().padStart(2, "0")}`; + })()} + + + + {settings.resolution.width}×{settings.resolution.height} + + + + {est().estimated_size_mb.toFixed(2)} MB + + + + {(() => { + const totalSeconds = Math.round(est().estimated_time_seconds); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `~${hours}:${minutes.toString().padStart(2, "0")}:${seconds + .toString() + .padStart(2, "0")}`; + } + return `~${minutes}:${seconds.toString().padStart(2, "0")}`; + })()} + +

    + )} +
    +
    +
    + } + > +
    + {/* Export to */} +
    +
    +
    +

    Export to

    + + 1}> +
    { + const menu = await Menu.new({ + items: await Promise.all( + organisations().map((org) => + CheckMenuItem.new({ + text: org.name, + action: () => { + setSettings("organizationId", org.id); + }, + checked: settings.organizationId === org.id, + }) + ) + ), + }); + menu.popup(); + }} + > + Organization: + + {(organisations().find((o) => o.id === settings.organizationId) ?? organisations()[0])?.name} + + +
    +
    +
    +
    +
    + + {(option) => ( + + )} + +
    +
    +
    + {/* Format */} +
    +
    +

    Format

    +
    + + {(option) => { + const disabledReason = () => { + if (option.value === "Mp4" && hasTransparentBackground()) + return "MP4 format does not support transparent backgrounds"; + if (option.value === "Gif" && settings.exportTo === "link") + return "Shareable links cannot be made from GIFs"; + }; + + return ( + + + + ); + }} + +
    +
    +
    + {/* Frame rate */} +
    +
    +

    Frame rate

    + + options={settings.format === "Gif" ? GIF_FPS_OPTIONS : FPS_OPTIONS} + optionValue="value" + optionTextValue="label" + placeholder="Select FPS" + value={(settings.format === "Gif" ? GIF_FPS_OPTIONS : FPS_OPTIONS).find( + (opt) => opt.value === settings.fps + )} + onChange={(option) => { + const value = option?.value ?? (settings.format === "Gif" ? 10 : 30); + trackEvent("export_fps_changed", { + fps: value, + }); + setSettings("fps", value); + }} + itemComponent={(props) => ( + as={KSelect.Item} item={props.item}> + {props.item.rawValue.label} + + )} + > + + class="flex-1 text-sm text-left truncate tabular-nums text-[--gray-500]"> + {(state) => {state.selectedOption()?.label}} + + + as={(props) => ( + + )} + /> + + + + as={KSelect.Content} + class={cx(topSlideAnimateClasses, "z-50")} + > + class="max-h-32 custom-scroll" as={KSelect.Listbox} /> + + + +
    +
    + {/* Compression */} +
    +
    +

    Compression

    +
    + + {(option) => ( + + )} + +
    +
    +
    + {/* Resolution */} +
    +
    +

    Resolution

    +
    + + {(option) => ( + + )} + +
    +
    +
    +
    +
    +
    + + {(exportState) => { + const [copyPressed, setCopyPressed] = createSignal(false); + const [clipboardCopyPressed, setClipboardCopyPressed] = createSignal(false); + const [showCompletionScreen, setShowCompletionScreen] = createSignal( + exportState.type === "done" && exportState.action === "save" + ); + + createEffect(() => { + if (exportState.type === "done" && exportState.action === "save") { + setShowCompletionScreen(true); + } + }); + + return ( + <> + +
    + Export +
    setDialog((d) => ({ ...d, open: false }))} + class="flex justify-center items-center p-1 rounded-full transition-colors cursor-pointer hover:bg-gray-3" + > + +
    +
    +
    + +
    + + + {(copyState) => ( +
    +

    + {copyState.type === "starting" + ? "Preparing..." + : copyState.type === "rendering" + ? settings.format === "Gif" + ? "Rendering GIF..." + : "Rendering video..." + : copyState.type === "copying" + ? "Copying to clipboard..." + : "Copied to clipboard"} +

    + + {(copyState) => } + +
    + )} +
    + + {(saveState) => ( +
    + +

    + {saveState.type === "starting" + ? "Preparing..." + : saveState.type === "rendering" + ? settings.format === "Gif" + ? "Rendering GIF..." + : "Rendering video..." + : saveState.type === "copying" + ? "Exporting to file..." + : "Export completed"} +

    + + {(copyState) => } + + + } + > +
    +
    +
    + +
    +
    +

    Export Completed

    +

    + Your {settings.format === "Gif" ? "GIF" : "video"} has successfully been exported +

    +
    +
    +
    +
    +
    + )} +
    + + {(uploadState) => ( + + + {(uploadState) => ( +
    +

    Uploading Cap...

    + + + {(uploadState) => ( + + )} + + + {(renderState) => } + + +
    + )} +
    + +
    +
    +

    Upload Complete

    +

    Your Cap has been uploaded successfully

    +
    +
    +
    +
    + )} +
    +
    +
    +
    + + + + + + +
    + + +
    +
    +
    + + ); + }} +
    + + ); } function RenderProgress(props: { state: RenderState; format?: ExportFormat }) { - return ( - - ); + return ( + + ); } function ProgressView(props: { amount: number; label?: string }) { - return ( - <> -
    -
    -
    -

    {props.label}

    - - ); + return ( + <> +
    +
    +
    +

    {props.label}

    + + ); } diff --git a/apps/desktop/src/routes/editor/ShareButton.tsx b/apps/desktop/src/routes/editor/ShareButton.tsx index 03793ff3a4..4f7c3bc8d0 100644 --- a/apps/desktop/src/routes/editor/ShareButton.tsx +++ b/apps/desktop/src/routes/editor/ShareButton.tsx @@ -11,333 +11,278 @@ import { exportVideo } from "~/utils/export"; import { commands, events, type UploadProgress } from "~/utils/tauri"; import { useEditorContext } from "./context"; import { RESOLUTION_OPTIONS } from "./Header"; -import { - Dialog, - DialogContent, - MenuItem, - MenuItemList, - PopperContent, - topLeftAnimateClasses, -} from "./ui"; +import { Dialog, DialogContent, MenuItem, MenuItemList, PopperContent, topLeftAnimateClasses } from "./ui"; function ShareButton() { - const { editorInstance, meta, customDomain, editorState, setEditorState } = - useEditorContext(); - const projectPath = editorInstance.path; + const { editorInstance, meta, customDomain, editorState, setEditorState } = useEditorContext(); + const projectPath = editorInstance.path; - const upload = createMutation(() => ({ - mutationFn: async () => { - setUploadState({ type: "idle" }); + const upload = createMutation(() => ({ + mutationFn: async () => { + setUploadState({ type: "idle" }); - console.log("Starting upload process..."); + console.log("Starting upload process..."); - // Check authentication first - const existingAuth = await authStore.get(); - if (!existingAuth) { - throw new Error("You need to sign in to share recordings"); - } + // Check authentication first + const existingAuth = await authStore.get(); + if (!existingAuth) { + throw new Error("You need to sign in to share recordings"); + } - const metadata = await commands.getVideoMetadata(projectPath); - const plan = await commands.checkUpgradedAndUpdate(); - const canShare = { - allowed: plan || metadata.duration < 300, - reason: !plan && metadata.duration >= 300 ? "upgrade_required" : null, - }; + const metadata = await commands.getVideoMetadata(projectPath); + const plan = await commands.checkUpgradedAndUpdate(); + const canShare = { + allowed: plan || metadata.duration < 300, + reason: !plan && metadata.duration >= 300 ? "upgrade_required" : null, + }; - if (!canShare.allowed) { - if (canShare.reason === "upgrade_required") { - await commands.showWindow("Upgrade"); - throw new Error( - "Upgrade required to share recordings longer than 5 minutes", - ); - } - } + if (!canShare.allowed) { + if (canShare.reason === "upgrade_required") { + await commands.showWindow("Upgrade"); + throw new Error("Upgrade required to share recordings longer than 5 minutes"); + } + } - const uploadChannel = new Channel((progress) => { - console.log("Upload progress:", progress); - setUploadState( - produce((state) => { - if (state.type !== "uploading") return; + const uploadChannel = new Channel((progress) => { + console.log("Upload progress:", progress); + setUploadState( + produce((state) => { + if (state.type !== "uploading") return; - state.progress = Math.round(progress.progress * 100); - }), - ); - }); + state.progress = Math.round(progress.progress * 100); + }) + ); + }); - setUploadState({ type: "starting" }); + setUploadState({ type: "starting" }); - // Setup progress listener before starting upload + // Setup progress listener before starting upload - console.log("Starting actual upload..."); + console.log("Starting actual upload..."); - await exportVideo( - projectPath, - { - format: "Mp4", - fps: 30, - resolution_base: { - x: RESOLUTION_OPTIONS._1080p.width, - y: RESOLUTION_OPTIONS._1080p.height, - }, - compression: "Web", - }, - (msg) => { - setUploadState( - reconcile({ - type: "rendering", - renderedFrames: msg.renderedCount, - totalFrames: msg.totalFrames, - }), - ); - }, - ); + await exportVideo( + projectPath, + { + format: "Mp4", + fps: 30, + resolution_base: { + x: RESOLUTION_OPTIONS._1080p.width, + y: RESOLUTION_OPTIONS._1080p.height, + }, + compression: "Web", + }, + (msg) => { + setUploadState( + reconcile({ + type: "rendering", + renderedFrames: msg.renderedCount, + totalFrames: msg.totalFrames, + }) + ); + } + ); - setUploadState({ type: "uploading", progress: 0 }); + setUploadState({ type: "uploading", progress: 0 }); - // Now proceed with upload - const result = meta().sharing - ? await commands.uploadExportedVideo( - projectPath, - "Reupload", - uploadChannel, - null, - ) - : await commands.uploadExportedVideo( - projectPath, - { - Initial: { pre_created_video: null }, - }, - uploadChannel, - null, - ); + // Now proceed with upload + const result = meta().sharing + ? await commands.uploadExportedVideo(projectPath, "Reupload", uploadChannel, null, null) + : await commands.uploadExportedVideo( + projectPath, + { + Initial: { pre_created_video: null }, + }, + uploadChannel, + null, + null + ); - if (result === "NotAuthenticated") { - throw new Error("You need to sign in to share recordings"); - } else if (result === "PlanCheckFailed") - throw new Error("Failed to verify your subscription status"); - else if (result === "UpgradeRequired") - throw new Error("This feature requires an upgraded plan"); + if (result === "NotAuthenticated") { + throw new Error("You need to sign in to share recordings"); + } else if (result === "PlanCheckFailed") throw new Error("Failed to verify your subscription status"); + else if (result === "UpgradeRequired") throw new Error("This feature requires an upgraded plan"); - setUploadState({ type: "link-copied" }); + setUploadState({ type: "link-copied" }); - return result; - }, - onError: (error) => { - console.error(error); - commands.globalMessageDialog( - error instanceof Error ? error.message : "Failed to upload recording", - ); - }, - onSettled() { - setTimeout(() => { - setUploadState({ type: "idle" }); - upload.reset(); - }, 2000); - }, - })); + return result; + }, + onError: (error) => { + console.error(error); + commands.globalMessageDialog(error instanceof Error ? error.message : "Failed to upload recording"); + }, + onSettled() { + setTimeout(() => { + setUploadState({ type: "idle" }); + upload.reset(); + }, 2000); + }, + })); - const [uploadState, setUploadState] = createStore< - | { type: "idle" } - | { type: "starting" } - | { type: "rendering"; renderedFrames: number; totalFrames: number } - | { type: "uploading"; progress: number } - | { type: "link-copied" } - >({ type: "idle" }); + const [uploadState, setUploadState] = createStore< + | { type: "idle" } + | { type: "starting" } + | { type: "rendering"; renderedFrames: number; totalFrames: number } + | { type: "uploading"; progress: number } + | { type: "link-copied" } + >({ type: "idle" }); - createProgressBar(() => { - if (uploadState.type === "starting") return 0; - if (uploadState.type === "rendering") - return (uploadState.renderedFrames / uploadState.totalFrames) * 100; - if (uploadState.type === "uploading") return uploadState.progress; - }); + createProgressBar(() => { + if (uploadState.type === "starting") return 0; + if (uploadState.type === "rendering") return (uploadState.renderedFrames / uploadState.totalFrames) * 100; + if (uploadState.type === "uploading") return uploadState.progress; + }); - return ( -
    - - {(sharing) => { - const normalUrl = () => new URL(sharing().link); - const customUrl = () => - customDomain.data?.custom_domain - ? new URL( - customDomain.data?.custom_domain + `/s/${meta().sharing?.id}`, - ) - : null; + return ( +
    + + {(sharing) => { + const normalUrl = () => new URL(sharing().link); + const customUrl = () => + customDomain.data?.custom_domain + ? new URL(customDomain.data?.custom_domain + `/s/${meta().sharing?.id}`) + : null; - const normalLink = `${normalUrl().host}${normalUrl().pathname}`; - const customLink = `${customUrl()?.host}${customUrl()?.pathname}`; + const normalLink = `${normalUrl().host}${normalUrl().pathname}`; + const customLink = `${customUrl()?.host}${customUrl()?.pathname}`; - const copyLinks = { - normal: sharing().link, - custom: customUrl()?.href, - }; + const copyLinks = { + normal: sharing().link, + custom: customUrl()?.href, + }; - const [linkToDisplay, setLinkToDisplay] = createSignal( - customDomain.data?.custom_domain && - customDomain.data?.domain_verified - ? customLink - : normalLink, - ); + const [linkToDisplay, setLinkToDisplay] = createSignal( + customDomain.data?.custom_domain && customDomain.data?.domain_verified ? customLink : normalLink + ); - const [copyPressed, setCopyPressed] = createSignal(false); + const [copyPressed, setCopyPressed] = createSignal(false); - const copyLink = () => { - navigator.clipboard.writeText(linkToDisplay() || sharing().link); - setCopyPressed(true); - setTimeout(() => { - setCopyPressed(false); - }, 2000); - }; + const copyLink = () => { + navigator.clipboard.writeText(linkToDisplay() || sharing().link); + setCopyPressed(true); + setTimeout(() => { + setCopyPressed(false); + }, 2000); + }; - return ( -
    - - - - -
    - - {linkToDisplay()} - - {/** Dropdown */} - - - value && setLinkToDisplay(value)} - options={[customLink, normalLink].filter( - (link) => link !== linkToDisplay(), - )} - multiple={false} - itemComponent={(props) => ( - - as={KSelect.Item} - item={props.item} - > - - {props.item.rawValue} - - - )} - placement="bottom-end" - gutter={4} - > - - - - - - - - as={KSelect.Content} - class={topLeftAnimateClasses} - > - - as={KSelect.Listbox} - class="w-[236px]" - /> - - - - - - {/** Copy button */} - -
    - {!copyPressed() ? ( - - ) : ( - - )} -
    -
    -
    -
    -
    - ); - }} -
    - - } - close={<>} - class="text-gray-12 dark:text-gray-12" - > -
    -
    -
    -
    + return ( +
    + + + + +
    + + {linkToDisplay()} + + {/** Dropdown */} + + + value && setLinkToDisplay(value)} + options={[customLink, normalLink].filter((link) => link !== linkToDisplay())} + multiple={false} + itemComponent={(props) => ( + as={KSelect.Item} item={props.item}> + {props.item.rawValue} + + )} + placement="bottom-end" + gutter={4} + > + + + + + + + as={KSelect.Content} class={topLeftAnimateClasses}> + as={KSelect.Listbox} class="w-[236px]" /> + + + + + + {/** Copy button */} + +
    + {!copyPressed() ? ( + + ) : ( + + )} +
    +
    +
    +
    +
    + ); + }} + + + } + close={<>} + class="text-gray-12 dark:text-gray-12" + > +
    +
    +
    +
    -

    - {uploadState.type === "idle" || uploadState.type === "starting" - ? "Preparing to render..." - : uploadState.type === "rendering" - ? `Rendering video (${uploadState.renderedFrames}/${uploadState.totalFrames} frames)` - : uploadState.type === "uploading" - ? `Uploading - ${Math.floor(uploadState.progress)}%` - : "Link copied to clipboard!"} -

    -
    - - -
    - ); +

    + {uploadState.type === "idle" || uploadState.type === "starting" + ? "Preparing to render..." + : uploadState.type === "rendering" + ? `Rendering video (${uploadState.renderedFrames}/${uploadState.totalFrames} frames)` + : uploadState.type === "uploading" + ? `Uploading - ${Math.floor(uploadState.progress)}%` + : "Link copied to clipboard!"} +

    +
    + + +
    + ); } export default ShareButton; diff --git a/apps/desktop/src/routes/recordings-overlay.tsx b/apps/desktop/src/routes/recordings-overlay.tsx index fd20488659..77c8e90caa 100644 --- a/apps/desktop/src/routes/recordings-overlay.tsx +++ b/apps/desktop/src/routes/recordings-overlay.tsx @@ -775,6 +775,7 @@ function createRecordingMutations( { Initial: { pre_created_video: null } }, uploadChannel, null, + null, ); } else { setActionState({ diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 55b9cec228..7f2bfa1b30 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -6,23 +6,9 @@ import { useSearchParams } from "@solidjs/router"; import { useQuery } from "@tanstack/solid-query"; import { LogicalPosition } from "@tauri-apps/api/dpi"; import { emit } from "@tauri-apps/api/event"; -import { - CheckMenuItem, - Menu, - MenuItem, - PredefinedMenuItem, -} from "@tauri-apps/api/menu"; +import { CheckMenuItem, Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu"; import { type as ostype } from "@tauri-apps/plugin-os"; -import { - createMemo, - createSignal, - Match, - mergeProps, - onCleanup, - Show, - Suspense, - Switch, -} from "solid-js"; +import { createEffect, createMemo, createSignal, Match, mergeProps, onCleanup, Show, Suspense, Switch } from "solid-js"; import { createStore, reconcile } from "solid-js/store"; import { CROP_ZERO, @@ -34,18 +20,10 @@ import { } from "~/components/Cropper"; import ModeSelect from "~/components/ModeSelect"; import { authStore, generalSettingsStore } from "~/store"; -import { createOptionsQuery, createOrganizationsQuery } from "~/utils/queries"; -import { - commands, - type DisplayId, - events, - type ScreenCaptureTarget, - type TargetUnderCursor, -} from "~/utils/tauri"; -import { - RecordingOptionsProvider, - useRecordingOptions, -} from "./(window-chrome)/OptionsContext"; +import { createOptionsQuery, createOrganizationsQuery, createWorkspacesQuery } from "~/utils/queries"; +import { commands, type DisplayId, events, type ScreenCaptureTarget, type TargetUnderCursor } from "~/utils/tauri"; +import { RecordingOptionsProvider, useRecordingOptions } from "./(window-chrome)/OptionsContext"; +import { ArrowUpRight, DoubleArrowSwitcher, RecordFill } from "~/icons"; const MIN_SIZE = { width: 150, height: 150 }; @@ -64,22 +42,30 @@ export default function () { function useOptions() { const { rawOptions: _rawOptions, setOptions } = createOptionsQuery(); - const organizations = createOrganizationsQuery(); - const options = mergeProps(_rawOptions, () => { - const ret: Partial = {}; + // const organizations = createOrganizationsQuery(); + const workspaces = createWorkspacesQuery(); + + createEffect(() => { + // if ( + // (!_rawOptions.organizationId && organizations().length > 0) || + // (_rawOptions.organizationId && + // organizations().every((o) => o.id !== _rawOptions.organizationId) && + // organizations().length > 0) + // ) { + // setOptions("organizationId", organizations()[0]?.id); + // } if ( - (!_rawOptions.organizationId && organizations().length > 0) || - (_rawOptions.organizationId && - organizations().every((o) => o.id !== _rawOptions.organizationId) && - organizations().length > 0) - ) - ret.organizationId = organizations()[0]?.id; - - return ret; + (!_rawOptions.workspaceId && workspaces().length > 0) || + (_rawOptions.workspaceId && + workspaces().every((w) => w.id !== _rawOptions.workspaceId) && + workspaces().length > 0) + ) { + setOptions("workspaceId", workspaces()[0]?.id); + } }); - return [options, setOptions] as const; + return [_rawOptions, setOptions] as const; } function Inner() { @@ -91,11 +77,10 @@ function Inner() { const [toggleModeSelect, setToggleModeSelect] = createSignal(false); - const [targetUnderCursor, setTargetUnderCursor] = - createStore({ - display_id: null, - window: null, - }); + const [targetUnderCursor, setTargetUnderCursor] = createStore({ + display_id: null, + window: null, + }); const unsubTargetUnderCursor = events.targetUnderCursor.listen((event) => { setTargetUnderCursor(reconcile(event.payload)); @@ -106,9 +91,7 @@ function Inner() { queryKey: ["windowIcon", targetUnderCursor.window?.id], queryFn: async () => { if (!targetUnderCursor.window?.id) return null; - return await commands.getWindowIcon( - targetUnderCursor.window.id.toString(), - ); + return await commands.getWindowIcon(targetUnderCursor.window.id.toString()); }, enabled: !!targetUnderCursor.window?.id, staleTime: 5 * 60 * 1000, // Cache for 5 minutes @@ -131,9 +114,7 @@ function Inner() { const [crop, setCrop] = createSignal(CROP_ZERO); - const [initialAreaBounds, setInitialAreaBounds] = createSignal< - CropBounds | undefined - >(undefined); + const [initialAreaBounds, setInitialAreaBounds] = createSignal(undefined); const unsubOnEscapePress = events.onEscapePress.listen(() => { setOptions("targetMode", null); @@ -158,9 +139,7 @@ function Inner() { {(display) => ( <> - - {display.name || "Monitor"} - + {display.name || "Monitor"} {(size) => ( @@ -174,30 +153,19 @@ function Inner() { {/* Transparent overlay to capture outside clicks */} -
    setToggleModeSelect(false)} - /> - setToggleModeSelect(false)} - /> +
    setToggleModeSelect(false)} /> + setToggleModeSelect(false)} /> - + {/* */}
    )} - + {(windowUnderCursor) => (
    )}
    - - {windowUnderCursor.app_name} - - + {windowUnderCursor.app_name} + {`${windowUnderCursor.bounds.size.width}x${windowUnderCursor.bounds.size.height}`}
    + + - - + */} + {/* */}
    )} @@ -273,16 +256,13 @@ function Inner() { let cropperRef: CropperRef | undefined; const [aspect, setAspect] = createSignal(null); - const [snapToRatioEnabled, setSnapToRatioEnabled] = - createSignal(true); + const [snapToRatioEnabled, setSnapToRatioEnabled] = createSignal(true); const scheduled = createScheduled((fn) => debounce(fn, 30)); const isValid = createMemo((p: boolean = true) => { const b = crop(); - return scheduled() - ? b.width >= MIN_SIZE.width && b.height >= MIN_SIZE.height - : p; + return scheduled() ? b.width >= MIN_SIZE.width && b.height >= MIN_SIZE.height : p; }); async function showCropOptionsMenu(e: UIEvent) { @@ -367,10 +347,7 @@ function Inner() { const finalX = Math.max( SIDE_MARGIN, - Math.min( - centerX - size.width / 2, - window.innerWidth - size.width - SIDE_MARGIN, - ), + Math.min(centerX - size.width / 2, window.innerWidth - size.width - SIDE_MARGIN) ); return { @@ -380,11 +357,7 @@ function Inner() { return (
    -
    +
    - + {/* */}
    @@ -443,7 +414,30 @@ function RecordingControls(props: { const auth = authStore.createQuery(); const { setOptions, rawOptions } = useRecordingOptions(); - const generalSetings = generalSettingsStore.createQuery(); + // const generalSetings = generalSettingsStore.createQuery(); + + const workspaces = createMemo(() => auth.data?.workspaces ?? []); + const selectedWorkspace = createMemo(() => { + if (!rawOptions.workspaceId && workspaces().length > 0) { + return workspaces()[0]; + } + return workspaces().find((w) => w.id === rawOptions.workspaceId) ?? workspaces()[0]; + }); + + const workspacesMenu = async () => + await Menu.new({ + items: await Promise.all( + workspaces().map((workspace) => + CheckMenuItem.new({ + text: workspace.name, + action: () => { + setOptions("workspaceId", workspace.id); + }, + checked: selectedWorkspace()?.id === workspace.id, + }) + ) + ), + }); const menuModes = async () => await Menu.new({ @@ -465,42 +459,40 @@ function RecordingControls(props: { ], }); - const countdownItems = async () => [ - await CheckMenuItem.new({ - text: "Off", - action: () => generalSettingsStore.set({ recordingCountdown: 0 }), - checked: - !generalSetings.data?.recordingCountdown || - generalSetings.data?.recordingCountdown === 0, - }), - await CheckMenuItem.new({ - text: "3 seconds", - action: () => generalSettingsStore.set({ recordingCountdown: 3 }), - checked: generalSetings.data?.recordingCountdown === 3, - }), - await CheckMenuItem.new({ - text: "5 seconds", - action: () => generalSettingsStore.set({ recordingCountdown: 5 }), - checked: generalSetings.data?.recordingCountdown === 5, - }), - await CheckMenuItem.new({ - text: "10 seconds", - action: () => generalSettingsStore.set({ recordingCountdown: 10 }), - checked: generalSetings.data?.recordingCountdown === 10, - }), - ]; - - const preRecordingMenu = async () => { - return await Menu.new({ - items: [ - await MenuItem.new({ - text: "Recording Countdown", - enabled: false, - }), - ...(await countdownItems()), - ], - }); - }; + // const countdownItems = async () => [ + // await CheckMenuItem.new({ + // text: "Off", + // action: () => generalSettingsStore.set({ recordingCountdown: 0 }), + // checked: !generalSetings.data?.recordingCountdown || generalSetings.data?.recordingCountdown === 0, + // }), + // await CheckMenuItem.new({ + // text: "3 seconds", + // action: () => generalSettingsStore.set({ recordingCountdown: 3 }), + // checked: generalSetings.data?.recordingCountdown === 3, + // }), + // await CheckMenuItem.new({ + // text: "5 seconds", + // action: () => generalSettingsStore.set({ recordingCountdown: 5 }), + // checked: generalSetings.data?.recordingCountdown === 5, + // }), + // await CheckMenuItem.new({ + // text: "10 seconds", + // action: () => generalSettingsStore.set({ recordingCountdown: 10 }), + // checked: generalSetings.data?.recordingCountdown === 10, + // }), + // ]; + + // const preRecordingMenu = async () => { + // return await Menu.new({ + // items: [ + // await MenuItem.new({ + // text: "Recording Countdown", + // enabled: false, + // }), + // ...(await countdownItems()), + // ], + // }); + // }; function showMenu(menu: Promise, e: UIEvent) { e.stopPropagation(); @@ -510,8 +502,8 @@ function RecordingControls(props: { return ( <> -
    -
    + {/*
    { setOptions("targetMode", null); commands.closeTargetSelectOverlays(); @@ -519,10 +511,26 @@ function RecordingControls(props: { class="flex justify-center items-center rounded-full transition-opacity bg-gray-12 size-9 hover:opacity-80" > -
    +
    */} + 0}> +
    showMenu(workspacesMenu(), e)} + onClick={(e) => showMenu(workspacesMenu(), e)} + > + + + + {selectedWorkspace()?.name} + +
    +
    + + Log In to Record +
    { if (rawOptions.mode === "instant" && !auth.data) { emit("start-sign-in"); @@ -533,20 +541,22 @@ function RecordingControls(props: { capture_target: props.target, mode: rawOptions.mode, capture_system_audio: rawOptions.captureSystemAudio, + workspace_id: rawOptions.workspaceId, }); }} > -
    - {rawOptions.mode === "studio" ? ( - - ) : ( - - )} +
    + {auth.data && } +
    + {!auth.data ? "Open Inflight" : "Start Recording"} +
    + {!auth.data && } +
    + {/*
    + {rawOptions.mode === "studio" ? : }
    - {rawOptions.mode === "instant" && !auth.data - ? "Sign In To Use" - : "Start Recording"} + {rawOptions.mode === "instant" && !auth.data ? "Sign In To Use" : "Start Recording"} {`${capitalize(rawOptions.mode)} Mode`} @@ -559,17 +569,17 @@ function RecordingControls(props: { onClick={(e) => showMenu(menuModes(), e)} > -
    +
    */}
    -
    showMenu(preRecordingMenu(), e)} onClick={(e) => showMenu(preRecordingMenu(), e)} > -
    +
    */}
    -
    + {/*
    props.setToggleModeSelect?.(true)} class="flex gap-1 justify-center items-center self-center mb-5 transition-opacity duration-200 w-fit hover:opacity-60" @@ -585,7 +595,7 @@ function RecordingControls(props: { {capitalize(rawOptions.mode)} Mode?

    -
    +
    */} ); } @@ -598,10 +608,7 @@ function ShowCapFreeWarning(props: { isInstantMode: boolean }) {

    Instant Mode recordings are limited to 5 mins,{" "} -

    diff --git a/apps/desktop/src/utils/queries.ts b/apps/desktop/src/utils/queries.ts index a70c79a2bd..20be07f231 100644 --- a/apps/desktop/src/utils/queries.ts +++ b/apps/desktop/src/utils/queries.ts @@ -1,28 +1,13 @@ import { createEventListener } from "@solid-primitives/event-listener"; import { makePersisted } from "@solid-primitives/storage"; -import { - createQuery, - queryOptions, - useMutation, - useQuery, -} from "@tanstack/solid-query"; +import { createQuery, queryOptions, useMutation, useQuery } from "@tanstack/solid-query"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { createEffect, createMemo } from "solid-js"; import { createStore, reconcile } from "solid-js/store"; import { useRecordingOptions } from "~/routes/(window-chrome)/OptionsContext"; -import { - authStore, - generalSettingsStore, - recordingSettingsStore, -} from "~/store"; +import { authStore, generalSettingsStore, recordingSettingsStore } from "~/store"; import { createQueryInvalidate } from "./events"; -import { - type CameraInfo, - commands, - type DeviceOrModelID, - type RecordingMode, - type ScreenCaptureTarget, -} from "./tauri"; +import { type CameraInfo, commands, type DeviceOrModelID, type RecordingMode, type ScreenCaptureTarget } from "./tauri"; import { apiClient, orgCustomDomainClient, protectedHeaders } from "./web-api"; export const listWindows = queryOptions({ @@ -30,11 +15,7 @@ export const listWindows = queryOptions({ queryFn: async () => { const w = await commands.listCaptureWindows(); - w.sort( - (a, b) => - a.owner_name.localeCompare(b.owner_name) || - a.name.localeCompare(b.name), - ); + w.sort((a, b) => a.owner_name.localeCompare(b.owner_name) || a.name.localeCompare(b.name)); return w; }, @@ -54,11 +35,7 @@ export const listWindowsWithThumbnails = queryOptions({ queryFn: async () => { const w = await commands.listWindowsWithThumbnails(); - w.sort( - (a, b) => - a.owner_name.localeCompare(b.owner_name) || - a.name.localeCompare(b.name), - ); + w.sort((a, b) => a.owner_name.localeCompare(b.owner_name) || a.name.localeCompare(b.name)); return w; }, @@ -128,6 +105,7 @@ export function createOptionsQuery() { targetMode?: "display" | "window" | "area" | null; cameraID?: DeviceOrModelID | null; organizationId?: string | null; + workspaceId?: string | null; /** @deprecated */ cameraLabel: string | null; }>({ @@ -136,6 +114,7 @@ export function createOptionsQuery() { cameraLabel: null, mode: "studio", organizationId: null, + workspaceId: null, }); createEventListener(window, "storage", (e) => { @@ -150,6 +129,7 @@ export function createOptionsQuery() { mode: _state.mode, systemAudio: _state.captureSystemAudio, organizationId: _state.organizationId, + workspaceId: _state.workspaceId, }); }); @@ -216,15 +196,12 @@ export function createCameraMutation() { mutationFn: rawMutate, })); - return new Proxy( - setCameraInput as typeof setCameraInput & { rawMutate: typeof rawMutate }, - { - get(target, key) { - if (key === "rawMutate") return rawMutate; - return Reflect.get(target, key); - }, + return new Proxy(setCameraInput as typeof setCameraInput & { rawMutate: typeof rawMutate }, { + get(target, key) { + if (key === "rawMutate") return rawMutate; + return Reflect.get(target, key); }, - ); + }); } export function createCustomDomainQuery() { @@ -253,13 +230,22 @@ export function createOrganizationsQuery() { // Refresh organizations if they're missing createEffect(() => { - if ( - auth.data?.user_id && - (!auth.data?.organizations || auth.data.organizations.length === 0) - ) { + if (auth.data?.user_id && (!auth.data?.organizations || auth.data.organizations.length === 0)) { commands.updateAuthPlan().catch(console.error); } }); return () => auth.data?.organizations ?? []; } + +export function createWorkspacesQuery() { + const auth = authStore.createQuery(); + + createEffect(() => { + if (auth.data?.user_id && (!auth.data?.workspaces || auth.data.workspaces.length === 0)) { + commands.updateAuthPlan().catch(console.error); + } + }); + + return () => auth.data?.workspaces ?? []; +} diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index c7787a16ab..b7cb73cf2c 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -125,8 +125,8 @@ async doPermissionsCheck(initialCheck: boolean) : Promise { async requestPermission(permission: OSPermission) : Promise { await TAURI_INVOKE("request_permission", { permission }); }, -async uploadExportedVideo(path: string, mode: UploadMode, channel: TAURI_CHANNEL, organizationId: string | null) : Promise { - return await TAURI_INVOKE("upload_exported_video", { path, mode, channel, organizationId }); +async uploadExportedVideo(path: string, mode: UploadMode, channel: TAURI_CHANNEL, organizationId: string | null, workspaceId: string | null) : Promise { + return await TAURI_INVOKE("upload_exported_video", { path, mode, channel, organizationId, workspaceId }); }, async uploadScreenshot(screenshotPath: string) : Promise { return await TAURI_INVOKE("upload_screenshot", { screenshotPath }); @@ -357,7 +357,7 @@ export type AudioMeta = { path: string; */ start_time?: number | null } export type AuthSecret = { api_key: string } | { token: string; expires: number } -export type AuthStore = { secret: AuthSecret; user_id: string | null; plan: Plan | null; intercom_hash: string | null; organizations?: Organization[] } +export type AuthStore = { secret: AuthSecret; user_id: string | null; plan: Plan | null; intercom_hash: string | null; organizations?: Organization[]; workspaces?: Workspace[] } export type BackgroundConfiguration = { source: BackgroundSource; blur: number; padding: number; rounding: number; roundingType?: CornerStyle; inset: number; crop: Crop | null; shadow?: number; advancedShadow?: ShadowConfiguration | null; border?: BorderConfiguration | null } export type BackgroundSource = { type: "wallpaper"; path: string | null } | { type: "image"; path: string | null } | { type: "color"; value: [number, number, number]; alpha?: number } | { type: "gradient"; from: [number, number, number]; to: [number, number, number]; angle?: number } export type BorderConfiguration = { enabled: boolean; width: number; color: [number, number, number]; opacity: number } @@ -453,7 +453,7 @@ export type RecordingMeta = (StudioRecordingMeta | InstantRecordingMeta) & { pla export type RecordingMetaWithMetadata = ((StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadMeta | null }) & { mode: RecordingMode; status: StudioRecordingStatus } export type RecordingMode = "studio" | "instant" export type RecordingOptionsChanged = null -export type RecordingSettingsStore = { target: ScreenCaptureTarget | null; micName: string | null; cameraId: DeviceOrModelID | null; mode: RecordingMode | null; systemAudio: boolean; organizationId: string | null } +export type RecordingSettingsStore = { target: ScreenCaptureTarget | null; micName: string | null; cameraId: DeviceOrModelID | null; mode: RecordingMode | null; systemAudio: boolean; organizationId: string | null; workspaceId: string | null } export type RecordingStarted = null export type RecordingStopped = null export type RecordingTargetMode = "display" | "window" | "area" @@ -473,7 +473,7 @@ export type ShadowConfiguration = { size: number; opacity: number; blur: number export type SharingMeta = { id: string; link: string } export type ShowCapWindow = "Setup" | { Main: { init_target_mode: RecordingTargetMode | null } } | { Settings: { page: string | null } } | { Editor: { project_path: string } } | "RecordingsOverlay" | { WindowCaptureOccluder: { screen_id: DisplayId } } | { TargetSelectOverlay: { display_id: DisplayId } } | { CaptureArea: { screen_id: DisplayId } } | "Camera" | { InProgressRecording: { countdown: number | null } } | "Upgrade" | "ModeSelect" export type SingleSegment = { display: VideoMeta; camera?: VideoMeta | null; audio?: AudioMeta | null; cursor?: string | null } -export type StartRecordingInputs = { capture_target: ScreenCaptureTarget; capture_system_audio?: boolean; mode: RecordingMode; organization_id?: string | null } +export type StartRecordingInputs = { capture_target: ScreenCaptureTarget; capture_system_audio?: boolean; mode: RecordingMode; organization_id?: string | null; workspace_id?: string | null } export type StereoMode = "stereo" | "monoL" | "monoR" export type StudioRecordingMeta = { segment: SingleSegment } | { inner: MultipleSegments } export type StudioRecordingStatus = { status: "InProgress" } | { status: "Failed"; error: string } | { status: "Complete" } @@ -496,6 +496,7 @@ export type VideoUploadInfo = { id: string; link: string; config: S3UploadMeta } export type WindowExclusion = { bundleIdentifier?: string | null; ownerName?: string | null; windowTitle?: string | null } export type WindowId = string export type WindowUnderCursor = { id: WindowId; app_name: string; bounds: LogicalBounds } +export type Workspace = { id: string; name: string; avatarUrl: string | null } export type XY = { x: T; y: T } export type ZoomMode = "auto" | { manual: { x: number; y: number } } export type ZoomSegment = { start: number; end: number; amount: number; mode: ZoomMode } From 801e41ab49e7ddb862921e0ec26db460b9ffe97f Mon Sep 17 00:00:00 2001 From: Veer Gadodia Date: Thu, 13 Nov 2025 13:48:57 -0600 Subject: [PATCH 03/24] Hide macOS window buttons in ShowCapWindow Window close, minimize, and zoom buttons are now hidden for ShowCapWindow on macOS by running code on the main thread. The window level is set only if new_recording_flow is true. --- apps/desktop/src-tauri/src/windows.rs | 36 ++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index ba6d5a71f6..dc1529c53d 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -275,9 +275,39 @@ impl ShowCapWindow { )) .build()?; - if new_recording_flow { - #[cfg(target_os = "macos")] - crate::platform::set_window_level(window.as_ref().window(), 50); + #[cfg(target_os = "macos")] + { + window + .run_on_main_thread({ + let win = window.as_ref().window(); + move || unsafe { + let ns_win = + win.ns_window().unwrap() as *const objc2_app_kit::NSWindow; + + // Hide all window buttons - using the correct constant names + if let Some(button) = (*ns_win).standardWindowButton( + objc2_app_kit::NSWindowButton::CloseButton, + ) { + button.setHidden(true); + } + if let Some(button) = (*ns_win).standardWindowButton( + objc2_app_kit::NSWindowButton::MiniaturizeButton, + ) { + button.setHidden(true); + } + if let Some(button) = (*ns_win) + .standardWindowButton(objc2_app_kit::NSWindowButton::ZoomButton) + { + button.setHidden(true); + } + } + }) + .ok(); + + // Set window level if needed + if new_recording_flow { + crate::platform::set_window_level(window.as_ref().window(), 50); + } } #[cfg(target_os = "macos")] From 0e7a0f4f7efbc9bc573c4b1741013675841937e7 Mon Sep 17 00:00:00 2001 From: Veer Gadodia Date: Thu, 13 Nov 2025 13:52:22 -0600 Subject: [PATCH 04/24] Add new icons and update window chrome UI Introduces InflightLogo, CloseIcon, and SettingsIcon components in icons.tsx. Refactors window chrome header layout and styles for improved appearance and usability, including new close and settings buttons in new-main/index.tsx. Cleans up formatting and removes unused code in (window-chrome).tsx. --- apps/desktop/src/icons.tsx | 65 ++++++++++++------- apps/desktop/src/routes/(window-chrome).tsx | 18 ++--- .../routes/(window-chrome)/new-main/index.tsx | 30 ++++++--- 3 files changed, 66 insertions(+), 47 deletions(-) diff --git a/apps/desktop/src/icons.tsx b/apps/desktop/src/icons.tsx index 830d61295f..65ad4e26da 100644 --- a/apps/desktop/src/icons.tsx +++ b/apps/desktop/src/icons.tsx @@ -91,14 +91,7 @@ export const Flip = (props: { class: string }) => { export const DoubleArrowSwitcher = (props: { class: string }) => { return ( - + { export const ArrowUpRight = (props: { class: string }) => { return ( - + { export const RecordFill = (props: { class: string }) => { return ( - + - + + + ); +}; + +export const InflightLogo = (props: { class: string }) => { + return ( + + + + ); +}; + +export const CloseIcon = (props: { class: string }) => { + return ( + + + + ); +}; + +export const SettingsIcon = (props: { class: string }) => { + return ( + + ); }; diff --git a/apps/desktop/src/routes/(window-chrome).tsx b/apps/desktop/src/routes/(window-chrome).tsx index 6483767acb..fdec1688c4 100644 --- a/apps/desktop/src/routes/(window-chrome).tsx +++ b/apps/desktop/src/routes/(window-chrome).tsx @@ -8,10 +8,7 @@ import { onCleanup, onMount, type ParentProps, Suspense } from "solid-js"; import { AbsoluteInsetLoader } from "~/components/Loader"; import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; import { initializeTitlebar } from "~/utils/titlebar-state"; -import { - useWindowChromeContext, - WindowChromeContext, -} from "./(window-chrome)/Context"; +import { useWindowChromeContext, WindowChromeContext } from "./(window-chrome)/Context"; export const route = { info: { @@ -34,7 +31,7 @@ export default function (props: RouteSectionProps) { return ( -
    +
    {/* breaks sometimes */} @@ -79,10 +76,7 @@ function Header() { return (
    {ctx.state()?.items} @@ -96,9 +90,5 @@ function Inner(props: ParentProps) { if (location.pathname !== "/") getCurrentWindow().show(); }); - return ( -
    - {props.children} -
    - ); + return
    {props.children}
    ; } diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index d769d3ec84..ff185caeef 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -52,6 +52,7 @@ import SystemAudio from "./SystemAudio"; import TargetDropdownButton from "./TargetDropdownButton"; import TargetMenuGrid from "./TargetMenuGrid"; import TargetTypeButton from "./TargetTypeButton"; +import { CloseIcon, InflightLogo, SettingsIcon } from "~/icons"; function getWindowSize() { return { @@ -690,9 +691,21 @@ function Page() { >
    +
    { + getCurrentWindow().close(); + }} + > + +
    +
    + +
    Settings}> - Previous Recordings}> + {/* Previous Recordings}> - )} + )} */}
    - {ostype() === "macos" &&
    } - }> + {/* {ostype() === "macos" &&
    } */} + + {/* }> { @@ -753,7 +767,7 @@ function Page() { {license.data?.type === "commercial" ? "Commercial" : license.data?.type === "pro" ? "Pro" : "Personal"} - + */}
    From 3144dc06bf31edf8f03e8ec4fae7596edba058c8 Mon Sep 17 00:00:00 2001 From: Veer Gadodia Date: Thu, 13 Nov 2025 15:57:53 -0600 Subject: [PATCH 05/24] Revamped main window UI (#2) * Revamp UI components and add new icons Introduces new SVG icons for camera, microphone, system audio, display, window, and crop. Adds HorizontalTargetButton component and refactors input and target selection controls for improved layout and styling. Updates window transparency and border radius for a modern look. Refactors InfoPill for new variants and updates usage in SystemAudio. Adjusts window size and layout in main page for better usability. * Update index.tsx --- apps/desktop/src-tauri/src/windows.rs | 2 + apps/desktop/src/icons.tsx | 75 ++++++ apps/desktop/src/routes/(window-chrome).tsx | 15 +- .../(window-chrome)/new-main/CameraSelect.tsx | 28 +-- .../new-main/HorizontalTargetButton.tsx | 41 ++++ .../(window-chrome)/new-main/InfoPill.tsx | 18 +- .../new-main/MicrophoneSelect.tsx | 37 ++- .../(window-chrome)/new-main/SystemAudio.tsx | 35 +-- .../new-main/TargetTypeButton.tsx | 17 +- .../routes/(window-chrome)/new-main/index.tsx | 219 +++++++----------- 10 files changed, 271 insertions(+), 216 deletions(-) create mode 100644 apps/desktop/src/routes/(window-chrome)/new-main/HorizontalTargetButton.tsx diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index dc1529c53d..f8aed4c97a 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -265,6 +265,7 @@ impl ShowCapWindow { .visible_on_all_workspaces(true) .content_protected(should_protect) .center() + .transparent(true) .initialization_script(format!( " window.__CAP__ = window.__CAP__ ?? {{}}; @@ -428,6 +429,7 @@ impl ShowCapWindow { .resizable(true) .maximized(false) .center() + .transparent(true) .build()? } Self::Editor { .. } => { diff --git a/apps/desktop/src/icons.tsx b/apps/desktop/src/icons.tsx index 65ad4e26da..0c5d645219 100644 --- a/apps/desktop/src/icons.tsx +++ b/apps/desktop/src/icons.tsx @@ -161,3 +161,78 @@ export const SettingsIcon = (props: { class: string }) => { ); }; + +export const CameraIcon = (props: { class: string }) => { + return ( + + + + ); +}; + +export const MicrophoneIcon = (props: { class: string }) => { + return ( + + + + ); +}; + +export const SystemAudioIcon = (props: { class: string }) => { + return ( + + + + ); +}; + +export const DisplayIcon = (props: { class?: string }) => { + return ( + + + + ); +}; + +export const WindowIcon = (props: { class?: string }) => { + return ( + + + + ); +}; + +export const CropIcon = (props: { class?: string }) => { + return ( + + + + + + + + + + + ); +}; diff --git a/apps/desktop/src/routes/(window-chrome).tsx b/apps/desktop/src/routes/(window-chrome).tsx index fdec1688c4..cdbe7f21ab 100644 --- a/apps/desktop/src/routes/(window-chrome).tsx +++ b/apps/desktop/src/routes/(window-chrome).tsx @@ -31,7 +31,18 @@ export default function (props: RouteSectionProps) { return ( -
    +
    {/* breaks sometimes */} @@ -76,7 +87,7 @@ function Header() { return (
    {ctx.state()?.items} diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx index ab95e90f45..3c06b8bbf3 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx @@ -7,6 +7,7 @@ import type { CameraInfo } from "~/utils/tauri"; import InfoPill from "./InfoPill"; import TargetSelectInfoPill from "./TargetSelectInfoPill"; import useRequestPermission from "./useRequestPermission"; +import { CameraIcon } from "~/icons"; const NO_CAMERA = "No Camera"; @@ -20,8 +21,8 @@ export default function CameraSelect(props: { ); } @@ -31,9 +32,7 @@ export function CameraSelectBase(props: { options: CameraInfo[]; value: CameraInfo | null; onChange: (camera: CameraInfo | null) => void; - PillComponent: Component< - ComponentProps<"button"> & { variant: "blue" | "red" } - >; + PillComponent: Component & { variant: "blue" | "red" }>; class: string; iconClass: string; }) { @@ -41,13 +40,10 @@ export function CameraSelectBase(props: { const permissions = createQuery(() => getPermissions); const requestPermission = useRequestPermission(); - const permissionGranted = () => - permissions?.data?.camera === "granted" || - permissions?.data?.camera === "notNeeded"; + const permissionGranted = () => permissions?.data?.camera === "granted" || permissions?.data?.camera === "notNeeded"; const onChange = (cameraLabel: CameraInfo | null) => { - if (!cameraLabel && !permissionGranted()) - return requestPermission("camera"); + if (!cameraLabel && !permissionGranted()) return requestPermission("camera"); props.onChange(cameraLabel); @@ -80,7 +76,7 @@ export function CameraSelectBase(props: { text: o.display_name, checked: o === props.value, action: () => onChange(o), - }), + }) ), ]) .then((items) => Menu.new({ items })) @@ -90,11 +86,9 @@ export function CameraSelectBase(props: { }} class={props.class} > - -

    - {props.value?.display_name ?? NO_CAMERA} -

    - +

    {props.value?.display_name ?? NO_CAMERA}

    + {/* + /> */}
    ); diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/HorizontalTargetButton.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/HorizontalTargetButton.tsx new file mode 100644 index 0000000000..aacf86cd2f --- /dev/null +++ b/apps/desktop/src/routes/(window-chrome)/new-main/HorizontalTargetButton.tsx @@ -0,0 +1,41 @@ +import { cx } from "cva"; +import { type Component, type ComponentProps, splitProps } from "solid-js"; + +type HorizontalTargetButtonProps = { + selected: boolean; + Component: Component>; + name: string; + disabled?: boolean; +} & ComponentProps<"button">; + +function HorizontalTargetButton(props: HorizontalTargetButtonProps) { + const [local, rest] = splitProps(props, ["selected", "Component", "name", "disabled", "class"]); + + return ( + + ); +} + +export default HorizontalTargetButton; diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/InfoPill.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/InfoPill.tsx index f6ab0811c2..8fd3064c02 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/InfoPill.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/InfoPill.tsx @@ -1,16 +1,24 @@ import { cx } from "cva"; import type { ComponentProps } from "solid-js"; -export default function InfoPill( - props: ComponentProps<"button"> & { variant: "blue" | "red" }, -) { +export default function InfoPill(props: ComponentProps<"button"> & { variant: "blue" | "red" }) { + return ( +
    ); diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/SystemAudio.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/SystemAudio.tsx index 786c15781e..b8849d9d40 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/SystemAudio.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/SystemAudio.tsx @@ -2,41 +2,35 @@ import { createQuery } from "@tanstack/solid-query"; import type { Component, ComponentProps, JSX } from "solid-js"; import { Dynamic } from "solid-js/web"; -import { - createCurrentRecordingQuery, - isSystemAudioSupported, -} from "~/utils/queries"; +import { createCurrentRecordingQuery, isSystemAudioSupported } from "~/utils/queries"; import { useRecordingOptions } from "../OptionsContext"; -import InfoPill from "./InfoPill"; +import InfoPill, { InfoPillNew } from "./InfoPill"; +import { SystemAudioIcon } from "~/icons"; export default function SystemAudio() { return ( } + class="flex flex-row gap-2 items-center px-2 w-full h-9 rounded-lg transition-colors cursor-default disabled:opacity-70 cursor-pointer hover:bg-white/[0.03] disabled:text-gray-11 text-neutral-300 hover:text-white KSelect" + PillComponent={InfoPillNew} + icon={} /> ); } export function SystemAudioToggleRoot( - props: Omit< - ComponentProps<"button">, - "onClick" | "disabled" | "title" | "type" | "children" - > & { + props: Omit, "onClick" | "disabled" | "title" | "type" | "children"> & { PillComponent: Component<{ - variant: "blue" | "red"; + variant: "on" | "off"; children: JSX.Element; }>; icon: JSX.Element; - }, + } ) { const { rawOptions, setOptions } = useRecordingOptions(); const currentRecording = createCurrentRecordingQuery(); const systemAudioSupported = createQuery(() => isSystemAudioSupported); - const isDisabled = () => - !!currentRecording.data || systemAudioSupported.data === false; + const isDisabled = () => !!currentRecording.data || systemAudioSupported.data === false; const tooltipMessage = () => { if (systemAudioSupported.data === false) { return "System audio capture requires macOS 13.0 or later"; @@ -57,14 +51,9 @@ export function SystemAudioToggleRoot( > {props.icon}

    - {rawOptions.captureSystemAudio - ? "Record System Audio" - : "No System Audio"} + {rawOptions.captureSystemAudio ? "Record System Audio" : "No System Audio"}

    - + {rawOptions.captureSystemAudio ? "On" : "Off"} diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/TargetTypeButton.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/TargetTypeButton.tsx index 6664e1e402..6e24c60df9 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/TargetTypeButton.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/TargetTypeButton.tsx @@ -9,13 +9,7 @@ type TargetTypeButtonProps = { } & ComponentProps<"button">; function TargetTypeButton(props: TargetTypeButtonProps) { - const [local, rest] = splitProps(props, [ - "selected", - "Component", - "name", - "disabled", - "class", - ]); + const [local, rest] = splitProps(props, ["selected", "Component", "name", "disabled", "class"]); return ( ); diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index ff185caeef..f2e313f9f7 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -52,12 +52,13 @@ import SystemAudio from "./SystemAudio"; import TargetDropdownButton from "./TargetDropdownButton"; import TargetMenuGrid from "./TargetMenuGrid"; import TargetTypeButton from "./TargetTypeButton"; -import { CloseIcon, InflightLogo, SettingsIcon } from "~/icons"; +import HorizontalTargetButton from "./HorizontalTargetButton"; +import { CloseIcon, CropIcon, DisplayIcon, InflightLogo, SettingsIcon, WindowIcon } from "~/icons"; function getWindowSize() { return { - width: 270, - height: 256, + width: 272, + height: 386, }; } @@ -532,24 +533,71 @@ function Page() { const signIn = createSignInMutation(); const BaseControls = () => ( -
    - { - if (!c) setCamera.mutate(null); - else if (c.model_id) setCamera.mutate({ ModelID: c.model_id }); - else setCamera.mutate({ DeviceID: c.device_id }); - }} - /> - setMicInput.mutate(v)} - /> - +
    +

    Select inputs

    +
    + { + if (!c) setCamera.mutate(null); + else if (c.model_id) setCamera.mutate({ ModelID: c.model_id }); + else setCamera.mutate({ DeviceID: c.device_id }); + }} + /> + setMicInput.mutate(v)} + /> + +
    +
    + ); + + const RecordingControls = () => ( +
    +

    Select what to record

    +
    + { + if (isRecording()) return; + setOptions("targetMode", (v) => (v === "display" ? null : "display")); + if (rawOptions.targetMode) commands.openTargetSelectOverlays(null); + else commands.closeTargetSelectOverlays(); + }} + name="Display" + /> + { + if (isRecording()) return; + setOptions("targetMode", (v) => (v === "window" ? null : "window")); + if (rawOptions.targetMode) commands.openTargetSelectOverlays(null); + else commands.closeTargetSelectOverlays(); + }} + name="Window" + /> + { + if (isRecording()) return; + setOptions("targetMode", (v) => (v === "area" ? null : "area")); + if (rawOptions.targetMode) commands.openTargetSelectOverlays(null); + else commands.closeTargetSelectOverlays(); + }} + name="Area" + /> +
    ); @@ -563,104 +611,9 @@ function Page() { exitClass="scale-100" exitToClass="scale-95" > -
    -
    -
    - { - if (isRecording()) return; - setOptions("targetMode", (v) => (v === "display" ? null : "display")); - if (rawOptions.targetMode) commands.openTargetSelectOverlays(null); - else commands.closeTargetSelectOverlays(); - }} - name="Display" - class="flex-1 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0" - /> - (displayTriggerRef = el)} - disabled={isRecording()} - expanded={displayMenuOpen()} - onClick={() => { - setDisplayMenuOpen((prev) => { - const next = !prev; - if (next) { - setWindowMenuOpen(false); - setHasOpenedDisplayMenu(true); - } - return next; - }); - }} - aria-haspopup="menu" - aria-label="Choose display" - /> -
    -
    - { - if (isRecording()) return; - setOptions("targetMode", (v) => (v === "window" ? null : "window")); - if (rawOptions.targetMode) commands.openTargetSelectOverlays(null); - else commands.closeTargetSelectOverlays(); - }} - name="Window" - class="flex-1 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0" - /> - (windowTriggerRef = el)} - disabled={isRecording()} - expanded={windowMenuOpen()} - onClick={() => { - setWindowMenuOpen((prev) => { - const next = !prev; - if (next) { - setDisplayMenuOpen(false); - setHasOpenedWindowMenu(true); - } - return next; - }); - }} - aria-haspopup="menu" - aria-label="Choose window" - /> -
    - { - if (isRecording()) return; - setOptions("targetMode", (v) => (v === "area" ? null : "area")); - if (rawOptions.targetMode) commands.openTargetSelectOverlays(null); - else commands.closeTargetSelectOverlays(); - }} - name="Area" - /> -
    +
    +
    ); @@ -687,25 +640,30 @@ function Page() {
    -
    { +
    -
    - + + + +
    +
    +
    Settings}> {/* Previous Recordings}> @@ -746,8 +704,7 @@ function Page() { )} */}
    - {/* {ostype() === "macos" &&
    } */} - + {/* {ostype() === "macos" &&
    } */} {/* }> Date: Thu, 13 Nov 2025 16:14:03 -0600 Subject: [PATCH 06/24] Add ArrowsOutIcon and update camera controls UI Introduces a new ArrowsOutIcon to the icons library and updates the camera preview controls UI for improved appearance and usability. Refactors control button components, applies new styling, and streamlines code formatting and logic for better maintainability. --- apps/desktop/src/icons.tsx | 31 ++++++ apps/desktop/src/routes/camera.tsx | 145 ++++++++++++----------------- 2 files changed, 90 insertions(+), 86 deletions(-) diff --git a/apps/desktop/src/icons.tsx b/apps/desktop/src/icons.tsx index 0c5d645219..17046b124d 100644 --- a/apps/desktop/src/icons.tsx +++ b/apps/desktop/src/icons.tsx @@ -236,3 +236,34 @@ export const CropIcon = (props: { class?: string }) => { ); }; + +export const ArrowsOutIcon = (props: { class?: string }) => { + return ( + + + + + + + ); +}; diff --git a/apps/desktop/src/routes/camera.tsx b/apps/desktop/src/routes/camera.tsx index c1d7e9f4b5..42d40dc258 100644 --- a/apps/desktop/src/routes/camera.tsx +++ b/apps/desktop/src/routes/camera.tsx @@ -1,11 +1,6 @@ import { ToggleButton as KToggleButton } from "@kobalte/core/toggle-button"; import { makePersisted } from "@solid-primitives/storage"; -import { - currentMonitor, - getCurrentWindow, - LogicalPosition, - LogicalSize, -} from "@tauri-apps/api/window"; +import { currentMonitor, getCurrentWindow, LogicalPosition, LogicalSize } from "@tauri-apps/api/window"; import { cx } from "cva"; import { type ComponentProps, @@ -23,10 +18,8 @@ import { generalSettingsStore } from "~/store"; import { createCameraMutation } from "~/utils/queries"; import { createImageDataWS, createLazySignal } from "~/utils/socket"; import { commands } from "~/utils/tauri"; -import { - RecordingOptionsProvider, - useRecordingOptions, -} from "./(window-chrome)/OptionsContext"; +import { RecordingOptionsProvider, useRecordingOptions } from "./(window-chrome)/OptionsContext"; +import { ArrowsOutIcon, CloseIcon } from "~/icons"; namespace CameraWindow { export type Size = "sm" | "lg"; @@ -42,15 +35,11 @@ export default function () { document.documentElement.classList.toggle("dark", true); const generalSettings = generalSettingsStore.createQuery(); - const isNativePreviewEnabled = - generalSettings.data?.enableNativeCameraPreview || false; + const isNativePreviewEnabled = generalSettings.data?.enableNativeCameraPreview || false; return ( - } - > + }> @@ -64,56 +53,56 @@ function NativeCameraPreviewPage() { shape: "round", mirrored: false, }), - { name: "cameraWindowState" }, + { name: "cameraWindowState" } ); createEffect(() => commands.setCameraPreviewState(state)); - const [cameraPreviewReady] = createResource(() => - commands.awaitCameraPreviewReady(), - ); + const [cameraPreviewReady] = createResource(() => commands.awaitCameraPreviewReady()); const setCamera = createCameraMutation(); return ( -
    +
    -
    - setCamera.mutate(null)}> - - - + setCamera.mutate(null)}> + + + { setState("size", (s) => (s === "sm" ? "lg" : "sm")); }} > - - - + + - setState("shape", (s) => - s === "round" ? "square" : s === "square" ? "full" : "round", - ) - } + onClick={() => setState("shape", (s) => (s === "round" ? "square" : s === "square" ? "full" : "round"))} > {state.shape === "round" && } {state.shape === "square" && } - {state.shape === "full" && ( - - )} - - } + + {/* setState("mirrored", (m) => !m)} > - + */}
    @@ -128,20 +117,28 @@ function NativeCameraPreviewPage() { ); } -function ControlButton( +function ControlButtonWithBackground( props: Omit, "type" | "class"> & { active?: boolean; - }, + } ) { return ( ); } +function ControlButton( + props: Omit, "type" | "class"> & { + active?: boolean; + } +) { + return ; +} + // Legacy stuff below function LegacyCameraPreviewPage() { @@ -153,7 +150,7 @@ function LegacyCameraPreviewPage() { shape: "round", mirrored: false, }), - { name: "cameraWindowState" }, + { name: "cameraWindowState" } ); const [latestFrame, setLatestFrame] = createLazySignal<{ @@ -186,20 +183,14 @@ function LegacyCameraPreviewPage() { } const { cameraWsPort } = (window as any).__CAP__; - const [ws, isConnected] = createImageDataWS( - `ws://localhost:${cameraWsPort}`, - imageDataHandler, - ); + const [ws, isConnected] = createImageDataWS(`ws://localhost:${cameraWsPort}`, imageDataHandler); const reconnectInterval = setInterval(() => { if (!isConnected()) { console.log("Attempting to reconnect..."); ws.close(); - const newWs = createImageDataWS( - `ws://localhost:${cameraWsPort}`, - imageDataHandler, - ); + const newWs = createImageDataWS(`ws://localhost:${cameraWsPort}`, imageDataHandler); Object.assign(ws, newWs[0]); } }, 5000); @@ -210,23 +201,15 @@ function LegacyCameraPreviewPage() { }); const [windowSize] = createResource( - () => - [ - state.size, - state.shape, - frameDimensions()?.width, - frameDimensions()?.height, - ] as const, + () => [state.size, state.shape, frameDimensions()?.width, frameDimensions()?.height] as const, async ([size, shape, frameWidth, frameHeight]) => { const monitor = await currentMonitor(); const BAR_HEIGHT = 56; const base = size === "sm" ? 230 : 400; const aspect = frameWidth && frameHeight ? frameWidth / frameHeight : 1; - const windowWidth = - shape === "full" ? (aspect >= 1 ? base * aspect : base) : base; - const windowHeight = - shape === "full" ? (aspect >= 1 ? base : base / aspect) : base; + const windowWidth = shape === "full" ? (aspect >= 1 ? base * aspect : base) : base; + const windowHeight = shape === "full" ? (aspect >= 1 ? base : base / aspect) : base; const totalHeight = windowHeight + BAR_HEIGHT; if (!monitor) return; @@ -240,12 +223,12 @@ function LegacyCameraPreviewPage() { currentWindow.setPosition( new LogicalPosition( width + monitor.position.toLogical(scalingFactor).x, - height + monitor.position.toLogical(scalingFactor).y, - ), + height + monitor.position.toLogical(scalingFactor).y + ) ); return { width, height, size: base, windowWidth, windowHeight }; - }, + } ); let cameraCanvasRef: HTMLCanvasElement | undefined; @@ -258,8 +241,8 @@ function LegacyCameraPreviewPage() { (label) => { if (label === null) getCurrentWindow().close(); }, - { defer: true }, - ), + { defer: true } + ) ); onMount(() => getCurrentWindow().show()); @@ -286,22 +269,13 @@ function LegacyCameraPreviewPage() { - setState("shape", (s) => - s === "round" ? "square" : s === "square" ? "full" : "round", - ) - } + onClick={() => setState("shape", (s) => (s === "round" ? "square" : s === "square" ? "full" : "round"))} > {state.shape === "round" && } {state.shape === "square" && } - {state.shape === "full" && ( - - )} + {state.shape === "full" && } - setState("mirrored", (m) => !m)} - > + setState("mirrored", (m) => !m)}>
    @@ -310,7 +284,7 @@ function LegacyCameraPreviewPage() {
    @@ -318,8 +292,7 @@ function LegacyCameraPreviewPage() { {(latestFrame) => { const style = () => { - const aspectRatio = - latestFrame().data.width / latestFrame().data.height; + const aspectRatio = latestFrame().data.width / latestFrame().data.height; const base = windowSize.latest?.size ?? 0; const winWidth = windowSize.latest?.windowWidth ?? base; From a918081b9f4811b61b4f024f5af25986376ac0e7 Mon Sep 17 00:00:00 2001 From: Veer Gadodia Date: Thu, 13 Nov 2025 17:02:57 -0600 Subject: [PATCH 07/24] Update UI components and rename Cap to Inflight Added ChevronDown icon and hover effects to CameraSelect and MicrophoneSelect components. Improved HorizontalTargetButton with new hover visuals. Refactored setup and startup screens to rename Cap to Inflight and streamlined code formatting for readability. --- apps/desktop/src-tauri/src/windows.rs | 1 + apps/desktop/src/icons.tsx | 13 ++ .../(window-chrome)/new-main/CameraSelect.tsx | 8 +- .../new-main/HorizontalTargetButton.tsx | 24 +++- .../new-main/MicrophoneSelect.tsx | 8 +- .../src/routes/(window-chrome)/setup.tsx | 130 +++++------------- 6 files changed, 77 insertions(+), 107 deletions(-) diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index f8aed4c97a..6f4eccf2fb 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -240,6 +240,7 @@ impl ShowCapWindow { .focused(true) .maximizable(false) .shadow(true) + .transparent(true) .build()?, Self::Main { init_target_mode } => { if !permissions::do_permissions_check(false).necessary_granted() { diff --git a/apps/desktop/src/icons.tsx b/apps/desktop/src/icons.tsx index 17046b124d..707a462931 100644 --- a/apps/desktop/src/icons.tsx +++ b/apps/desktop/src/icons.tsx @@ -267,3 +267,16 @@ export const ArrowsOutIcon = (props: { class?: string }) => { ); }; + +export const ChevronDown = (props: { class?: string }) => { + return ( + + + + ); +}; diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx index 3c06b8bbf3..44955df85f 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx @@ -7,7 +7,7 @@ import type { CameraInfo } from "~/utils/tauri"; import InfoPill from "./InfoPill"; import TargetSelectInfoPill from "./TargetSelectInfoPill"; import useRequestPermission from "./useRequestPermission"; -import { CameraIcon } from "~/icons"; +import { CameraIcon, ChevronDown } from "~/icons"; const NO_CAMERA = "No Camera"; @@ -21,7 +21,7 @@ export default function CameraSelect(props: { ); @@ -88,6 +88,10 @@ export function CameraSelectBase(props: { >

    {props.value?.display_name ?? NO_CAMERA}

    + +
    + +
    {/* - + /> */} +
    + + +

    {local.name}

    ); diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/MicrophoneSelect.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/MicrophoneSelect.tsx index 0be89ba6a0..66742b2a19 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/MicrophoneSelect.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/MicrophoneSelect.tsx @@ -9,7 +9,7 @@ import { events } from "~/utils/tauri"; import InfoPill from "./InfoPill"; import TargetSelectInfoPill from "./TargetSelectInfoPill"; import useRequestPermission from "./useRequestPermission"; -import { MicrophoneIcon } from "~/icons"; +import { ChevronDown, MicrophoneIcon } from "~/icons"; const NO_MICROPHONE = "No Microphone"; @@ -22,7 +22,7 @@ export default function MicrophoneSelect(props: { return (

    {props.value ?? NO_MICROPHONE}

    + +
    + +
    {/* - commands.doPermissionsCheck(initialCheck()), - ); - const [currentStep, setCurrentStep] = createSignal<"permissions" | "mode">( - "permissions", - ); + const [check, checkActions] = createResource(() => commands.doPermissionsCheck(initialCheck())); + const [currentStep, setCurrentStep] = createSignal<"permissions" | "mode">("permissions"); createEffect(() => { if (!initialCheck()) { - createTimer( - () => startTransition(() => checkActions.refetch()), - 250, - setInterval, - ); + createTimer(() => startTransition(() => checkActions.refetch()), 250, setInterval); } }); @@ -78,7 +65,7 @@ export default function () { generalSettingsStore.get().then((s) => { if (s === undefined) return true; return !s.hasCompletedStartup; - }), + }) ); const handleContinue = () => { @@ -101,10 +88,8 @@ export default function () {
    -

    - Permissions Required -

    -

    Cap needs permissions to run properly.

    +

    Permissions Required

    +

    Inflight needs permissions to run properly.

      @@ -116,12 +101,8 @@ export default function () {
    • - - {permission.name} Permission - - - {permission.description} - + {permission.name} Permission + {permission.description}
    • @@ -148,11 +129,8 @@ export default function () { @@ -161,12 +139,8 @@ export default function () {
      -

      - Select Recording Mode -

      -

      - Choose how you want to record with Cap. You can change this later. -

      +

      Select Recording Mode

      +

      Choose how you want to record with Inflight. You can change this later.

      @@ -174,7 +148,7 @@ export default function () {
    @@ -192,10 +166,7 @@ import cloud3 from "../../assets/illustrations/cloud-3.png"; import startupAudio from "../../assets/tears-and-fireflies-adi-goldstein.mp3"; function Startup(props: { onClose: () => void }) { - const [audioState, setAudioState] = makePersisted( - createStore({ isMuted: false }), - { name: "audioSettings" }, - ); + const [audioState, setAudioState] = makePersisted(createStore({ isMuted: false }), { name: "audioSettings" }); const [isExiting, setIsExiting] = createSignal(false); @@ -246,30 +217,22 @@ function Startup(props: { onClose: () => void }) { // Top right cloud - gentle diagonal movement cloud1Animation = cloud1El?.animate( - [ - { transform: "translate(0, 0)" }, - { transform: "translate(-20px, 10px)" }, - { transform: "translate(0, 0)" }, - ], + [{ transform: "translate(0, 0)" }, { transform: "translate(-20px, 10px)" }, { transform: "translate(0, 0)" }], { duration: 30000, iterations: Infinity, easing: "linear", - }, + } ); // Top left cloud - gentle diagonal movement cloud2Animation = cloud2El?.animate( - [ - { transform: "translate(0, 0)" }, - { transform: "translate(20px, 10px)" }, - { transform: "translate(0, 0)" }, - ], + [{ transform: "translate(0, 0)" }, { transform: "translate(20px, 10px)" }, { transform: "translate(0, 0)" }], { duration: 35000, iterations: Infinity, easing: "linear", - }, + } ); // Bottom cloud - slow rise up with subtle horizontal movement @@ -284,7 +247,7 @@ function Startup(props: { onClose: () => void }) { iterations: 1, easing: "cubic-bezier(0.4, 0, 0.2, 1)", fill: "forwards", - }, + } ); }); @@ -297,29 +260,19 @@ function Startup(props: { onClose: () => void }) { return (
    -
    +
    {ostype() === "windows" && }
    @@ -421,7 +374,7 @@ function Startup(props: { onClose: () => void }) { style={{ "transition-duration": "600ms" }} class={cx( "flex flex-col h-screen custom-bg relative overflow-hidden transition-opacity text-solid-white", - isExiting() && "exiting opacity-0", + isExiting() && "exiting opacity-0" )} >
    @@ -433,11 +386,7 @@ function Startup(props: { onClose: () => void }) { isExiting() ? "exiting" : "" }`} > - Cloud One + Cloud One
    void }) { isExiting() ? "exiting" : "" }`} > - Cloud Two + Cloud Two
    void }) { isExiting() ? "exiting" : "" }`} > - Cloud Three + Cloud Three
    {/* Main content */} @@ -471,17 +412,14 @@ function Startup(props: { onClose: () => void }) { }`} >
    -
    +

    - Welcome to Cap + Welcome to Inflight

    Beautiful screen recordings, owned by you. @@ -511,7 +449,7 @@ function Startup(props: { onClose: () => void }) { getCurrentWindow().close(); }} > - Continue to Cap + Continue to Inflight From 0c3401ebcf73948f31ea374dd644954a50e4afda Mon Sep 17 00:00:00 2001 From: Veer Gadodia Date: Thu, 13 Nov 2025 17:49:38 -0600 Subject: [PATCH 08/24] Revamp recording controls UI and add debug button Updated the in-progress recording controls with new icons, improved styling, and a grab handle for window movement. Added several new SVG icons. Adjusted window size for recording controls and refined camera preview header styling. Also added a debug button in development mode to open a debug webview. --- apps/desktop/src-tauri/src/windows.rs | 4 +- apps/desktop/src/icons.tsx | 72 ++++++++ .../routes/(window-chrome)/new-main/index.tsx | 11 ++ apps/desktop/src/routes/camera.tsx | 6 +- .../src/routes/in-progress-recording.tsx | 171 +++++++----------- 5 files changed, 156 insertions(+), 108 deletions(-) diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 6f4eccf2fb..903204e530 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -658,8 +658,8 @@ impl ShowCapWindow { window } Self::InProgressRecording { countdown } => { - let width = 250.0; - let height = 40.0; + let width = 208.0; + let height = 48.0; let title = CapWindowId::RecordingControls.title(); let should_protect = should_protect_window(app, &title); diff --git a/apps/desktop/src/icons.tsx b/apps/desktop/src/icons.tsx index 707a462931..844405d368 100644 --- a/apps/desktop/src/icons.tsx +++ b/apps/desktop/src/icons.tsx @@ -280,3 +280,75 @@ export const ChevronDown = (props: { class?: string }) => { ); }; + +export const StopIcon = (props: { class?: string }) => { + return ( + + + + ); +}; + +export const TrashIcon = (props: { class?: string }) => { + return ( + + + + ); +}; + +export const RestartIcon = (props: { class?: string }) => { + return ( + + + + ); +}; + +export const PauseIcon = (props: { class?: string }) => { + return ( + + + + ); +}; + +export const PlayIcon = (props: { class?: string }) => { + return ( + + + + ); +}; + +export const GrabHandleIcon = (props: { class?: string }) => { + return ( + + + + ); +}; diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index f2e313f9f7..9c7b510384 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -677,6 +677,17 @@ function Page() { + {import.meta.env.DEV && ( + + )} {/* Previous Recordings}> -

    -
    +
    + {/*
    {optionsQuery.rawOptions.micName != null ? ( <> @@ -251,47 +232,37 @@ export default function () {
    ) : ( - + )} -
    +
    */} - {(currentRecording.data?.mode === "studio" || - ostype() === "macos") && ( - togglePause.mutate()} - > - {state().variant === "paused" ? ( - - ) : ( - - )} + {(currentRecording.data?.mode === "studio" || ostype() === "macos") && ( + togglePause.mutate()}> + {state().variant === "paused" ? : } )} - restartRecording.mutate()} - > - + restartRecording.mutate()}> + - deleteRecording.mutate()} - > - + deleteRecording.mutate()}> + + +
    + +
    -
    -
    +
    */}
    ); } @@ -302,10 +273,10 @@ function ActionButton(props: ComponentProps<"button">) { {...props} class={cx( "p-[0.25rem] rounded-lg transition-all", - "text-gray-11", + "text-white hover:bg-white/5", "h-8 w-8 flex items-center justify-center", "disabled:opacity-50 disabled:cursor-not-allowed", - props.class, + props.class )} type="button" /> @@ -327,10 +298,7 @@ function createAudioInputLevel() { const DB_MAX = 0; const dbValue = dbs ?? DB_MIN; - const normalizedLevel = Math.max( - 0, - Math.min(1, (dbValue - DB_MIN) / (DB_MAX - DB_MIN)), - ); + const normalizedLevel = Math.max(0, Math.min(1, (dbValue - DB_MIN) / (DB_MAX - DB_MIN))); setLevel(normalizedLevel); }); @@ -342,20 +310,21 @@ function Countdown(props: { from: number; current: number }) { setTimeout(() => setAnimation(0), 10); return ( -
    +
    Recording starting...
    - + - - {props.current} - + {props.current}
    From 893082e54234ade6685a3e23dc73a4c46967ca8e Mon Sep 17 00:00:00 2001 From: Veer Gadodia Date: Fri, 14 Nov 2025 14:09:06 -0600 Subject: [PATCH 09/24] Revamp settings UI and add workspace selection Updated the settings UI for a more modern look, refactored settings navigation, and added workspace selection for authenticated users. Improved excluded windows management, adjusted hotkey and recording settings layouts, and updated branding references from Cap to Inflight. Also added support for default workspace ID in general settings and improved traffic light positioning for the settings window. --- .../desktop/src-tauri/src/general_settings.rs | 3 + apps/desktop/src-tauri/src/tray.rs | 2 +- apps/desktop/src-tauri/src/windows.rs | 1 + apps/desktop/src/routes/(window-chrome).tsx | 18 +- .../src/routes/(window-chrome)/settings.tsx | 262 ++-- .../(window-chrome)/settings/Setting.tsx | 17 +- .../(window-chrome)/settings/general.tsx | 379 ++--- .../(window-chrome)/settings/hotkeys.tsx | 63 +- .../(window-chrome)/settings/recordings.tsx | 668 ++++----- apps/desktop/src/utils/tauri.ts | 1230 ++++++++++------- packages/ui-solid/src/Button.tsx | 23 +- 11 files changed, 1494 insertions(+), 1172 deletions(-) diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index fbd6189853..3b5d96753d 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -120,6 +120,8 @@ pub struct GeneralSettingsStore { pub delete_instant_recordings_after_upload: bool, #[serde(default = "default_instant_mode_max_resolution")] pub instant_mode_max_resolution: u32, + #[serde(default)] + pub default_workspace_id: Option, } fn default_enable_native_camera_preview() -> bool { @@ -184,6 +186,7 @@ impl Default for GeneralSettingsStore { excluded_windows: default_excluded_windows(), delete_instant_recordings_after_upload: false, instant_mode_max_resolution: 1920, + default_workspace_id: None, } } } diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index d07632be35..80e1156213 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -81,7 +81,7 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { &MenuItem::with_id( app, "version", - format!("Cap v{}", env!("CARGO_PKG_VERSION")), + format!("Inflight v{}", env!("CARGO_PKG_VERSION")), false, None::<&str>, )?, diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 903204e530..ea28b0b3b4 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -154,6 +154,7 @@ impl CapWindowId { pub fn traffic_lights_position(&self) -> Option>> { match self { Self::Editor { .. } => Some(Some(LogicalPosition::new(20.0, 32.0))), + Self::Settings => Some(Some(LogicalPosition::new(16.0, 28.0))), Self::RecordingControls => Some(Some(LogicalPosition::new(-100.0, -100.0))), Self::Camera | Self::WindowCaptureOccluder { .. } diff --git a/apps/desktop/src/routes/(window-chrome).tsx b/apps/desktop/src/routes/(window-chrome).tsx index cdbe7f21ab..0a0015ae3e 100644 --- a/apps/desktop/src/routes/(window-chrome).tsx +++ b/apps/desktop/src/routes/(window-chrome).tsx @@ -29,19 +29,23 @@ export default function (props: RouteSectionProps) { unlistenResize?.(); }); + const isSettings = location.pathname.startsWith("/settings"); + return (
    @@ -85,9 +89,15 @@ function Header() { const isWindows = ostype() === "windows"; + const isSettings = location.pathname.startsWith("/settings"); + return (
    {ctx.state()?.items} diff --git a/apps/desktop/src/routes/(window-chrome)/settings.tsx b/apps/desktop/src/routes/(window-chrome)/settings.tsx index 0dfb55e176..ad56308a02 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings.tsx @@ -1,105 +1,93 @@ -import { Button } from "@cap/ui-solid"; -import { A, type RouteSectionProps } from "@solidjs/router"; -import { getVersion } from "@tauri-apps/api/app"; -import "@total-typescript/ts-reset/filter-boolean"; -import { createResource, For, Show, Suspense } from "solid-js"; +import { A, type RouteSectionProps, useLocation } from "@solidjs/router"; +import { createEffect, createSignal, For, onMount, Suspense } from "solid-js"; import { CapErrorBoundary } from "~/components/CapErrorBoundary"; -import { SignInButton } from "~/components/SignInButton"; +import IconCapSettings from "~icons/cap/settings"; +import IconCapHotkeys from "~icons/cap/hotkeys"; +import IconLucideSquarePlay from "~icons/lucide/square-play"; -import { authStore } from "~/store"; -import { trackEvent } from "~/utils/analytics"; +const TABS = [ + { + href: "general", + name: "Settings", + icon: IconCapSettings, + }, + { + href: "hotkeys", + name: "Shortcuts", + icon: IconCapHotkeys, + }, + { + href: "recordings", + name: "Previous Recordings", + icon: IconLucideSquarePlay, + }, +]; export default function Settings(props: RouteSectionProps) { - const auth = authStore.createQuery(); - const [version] = createResource(() => getVersion()); + const location = useLocation(); + const [indicatorStyle, setIndicatorStyle] = createSignal<{ + left: string; + width: string; + }>({ left: "0px", width: "0px" }); + let tabRefs: HTMLAnchorElement[] = []; - const handleAuth = async () => { - if (auth.data) { - trackEvent("user_signed_out", { platform: "desktop" }); - authStore.set(undefined); + const updateIndicator = () => { + const currentPath = location.pathname.split("/").pop(); + const activeIndex = TABS.findIndex((tab) => tab.href === currentPath); + + if (activeIndex !== -1 && tabRefs[activeIndex]) { + const activeTab = tabRefs[activeIndex]; + const parentRect = activeTab.parentElement?.parentElement?.getBoundingClientRect(); + const tabRect = activeTab.getBoundingClientRect(); + + if (parentRect) { + setIndicatorStyle({ + left: `${tabRect.left - parentRect.left}px`, + width: `${tabRect.width}px`, + }); + } } }; + onMount(() => { + updateIndicator(); + }); + + createEffect(() => { + location.pathname; + updateIndicator(); + }); + return ( -
    -
    -