diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2a0eb78225..28045bbbbc 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2179,11 +2179,18 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { hotkeys::init(&app); general_settings::init(&app); fake_window::init(&app); - app.manage(target_select_overlay::WindowFocusManager::default()); + app.manage(target_select_overlay::State::default()); app.manage(EditorWindowIds::default()); #[cfg(target_os = "macos")] app.manage(crate::platform::ScreenCapturePrewarmer::default()); + tokio::spawn({ + let app = app.clone(); + async move { + target_select_overlay::init(&app).await; + } + }); + tokio::spawn({ let camera_feed = camera_feed.clone(); let app = app.clone(); @@ -2351,7 +2358,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { if let Ok(CapWindowId::TargetSelectOverlay { .. }) = CapWindowId::from_str(&id) { - let _ = window.close(); + let _ = window.hide(); } } @@ -2416,10 +2423,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { } return; } - CapWindowId::TargetSelectOverlay { display_id } => { - app.state::() - .destroy(&display_id, app.global_shortcut()); - } CapWindowId::Camera => { let app = app.clone(); tokio::spawn(async move { diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 10b136e552..5e4ae9236f 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -415,7 +415,7 @@ pub async fn start_recording( .filter_map(|(label, win)| CapWindowId::from_str(label).ok().map(|id| (id, win))) { if matches!(id, CapWindowId::TargetSelectOverlay { .. }) { - win.close().ok(); + win.hide().ok(); } } let _ = ShowCapWindow::InProgressRecording { countdown } diff --git a/apps/desktop/src-tauri/src/target_select_overlay.rs b/apps/desktop/src-tauri/src/target_select_overlay.rs index 31a030d6b7..2771ca3c33 100644 --- a/apps/desktop/src-tauri/src/target_select_overlay.rs +++ b/apps/desktop/src-tauri/src/target_select_overlay.rs @@ -1,5 +1,4 @@ use std::{ - collections::HashMap, str::FromStr, sync::{Mutex, PoisonError}, time::Duration, @@ -7,6 +6,7 @@ use std::{ use base64::prelude::*; use cap_recording::screen_capture::ScreenCaptureTarget; +use futures::future::join_all; use crate::windows::{CapWindowId, ShowCapWindow}; use scap_targets::{ @@ -15,8 +15,8 @@ use scap_targets::{ }; use serde::Serialize; use specta::Type; -use tauri::{AppHandle, Manager, WebviewWindow}; -use tauri_plugin_global_shortcut::{GlobalShortcut, GlobalShortcutExt}; +use tauri::{AppHandle, Manager}; +use tauri_plugin_global_shortcut::GlobalShortcutExt; use tauri_specta::Event; use tokio::task::JoinHandle; use tracing::{error, instrument}; @@ -41,22 +41,67 @@ pub struct DisplayInformation { refresh_rate: String, } +// We create the windows hidden at app launch so they are ready when used. +// Otherwise we have noticed they can take a while to load on first interaction (especially on Windows). +pub async fn init(app: &AppHandle) { + join_all( + scap_targets::Display::list() + .into_iter() + .map(|d| d.id()) + .map(|display_id| { + let app = app.clone(); + + async move { + let result = ShowCapWindow::TargetSelectOverlay { display_id } + .show(&app) + .await + .map_err(|err| error!("Error initializing target select overlay: {err}")); + + if let Ok(window) = result { + window.hide().ok(); + } + } + }), + ) + .await; +} + +#[derive(Default)] +pub struct State(Mutex>>); + #[specta::specta] #[tauri::command] #[instrument(skip(app, state))] pub async fn open_target_select_overlays( app: AppHandle, - state: tauri::State<'_, WindowFocusManager>, + state: tauri::State<'_, State>, focused_target: Option, ) -> Result<(), String> { - let displays = scap_targets::Display::list() - .into_iter() - .map(|d| d.id()) - .collect::>(); - for display_id in displays { - let _ = ShowCapWindow::TargetSelectOverlay { display_id } - .show(&app) - .await; + let windows = join_all( + scap_targets::Display::list() + .into_iter() + .map(|d| d.id()) + .map(|display_id| { + let app = app.clone(); + + async move { + ShowCapWindow::TargetSelectOverlay { display_id } + .show(&app) + .await + .map_err(|err| error!("Error initializing target select overlay: {err}")) + .ok() + } + }), + ) + .await; + + for window in windows { + if let Some(window) = window { + window + .show() + .map_err(|err| error!("Error showing target select overlay: {err}")) + .ok(); + } } let handle = tokio::spawn({ @@ -93,7 +138,7 @@ pub async fn open_target_select_overlays( }); if let Some(task) = state - .task + .0 .lock() .unwrap_or_else(PoisonError::into_inner) .replace(handle) @@ -112,14 +157,33 @@ pub async fn open_target_select_overlays( #[specta::specta] #[tauri::command] -#[instrument(skip(app))] -pub async fn close_target_select_overlays(app: AppHandle) -> Result<(), String> { +#[instrument(skip(app, state))] +pub async fn close_target_select_overlays( + app: AppHandle, + state: tauri::State<'_, State>, +) -> Result<(), String> { for (id, window) in app.webview_windows() { if let Ok(CapWindowId::TargetSelectOverlay { .. }) = CapWindowId::from_str(&id) { - let _ = window.close(); + window + .hide() + .map_err(|err| error!("Error hiding target select overlay: {err}")) + .ok(); } } + if let Some(task) = state + .0 + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take() + { + task.abort(); + app.global_shortcut() + .unregister("Escape") + .map_err(|err| error!("Error unregistering global keyboard shortcut for Escape: {err}")) + .ok(); + } + Ok(()) } @@ -210,85 +274,3 @@ pub async fn focus_window(window_id: WindowId) -> Result<(), String> { Ok(()) } - -// Windows doesn't have a proper concept of window z-index's so we implement them in userspace :( -#[derive(Default)] -pub struct WindowFocusManager { - task: Mutex>>, - tasks: Mutex>>, -} - -impl WindowFocusManager { - /// Called when a window is created to spawn it's task - pub fn spawn(&self, id: &DisplayId, window: WebviewWindow) { - let mut tasks = self.tasks.lock().unwrap_or_else(PoisonError::into_inner); - tasks.insert( - id.to_string(), - tokio::spawn(async move { - let app = window.app_handle(); - loop { - let cap_main = CapWindowId::Main.get(app); - let cap_settings = CapWindowId::Settings.get(app); - - let has_cap_main = cap_main - .as_ref() - .and_then(|v| Some(v.is_minimized().ok()? || !v.is_visible().ok()?)) - .unwrap_or(true); - let has_cap_settings = cap_settings - .and_then(|v| Some(v.is_minimized().ok()? || !v.is_visible().ok()?)) - .unwrap_or(true); - - // Close the overlay if the cap main and settings are not available. - if has_cap_main && has_cap_settings { - window.hide().ok(); - break; - } - - #[cfg(windows)] - if let Some(cap_main) = cap_main { - let should_refocus = cap_main.is_focused().ok().unwrap_or_default() - || window.is_focused().unwrap_or_default(); - - // If a Cap window is not focused we know something is trying to steal the focus. - // We need to move the overlay above it. We don't use `always_on_top` on the overlay because we need the Cap window to stay above it. - if !should_refocus { - window.set_focus().ok(); - } - } - - tokio::time::sleep(std::time::Duration::from_millis(400)).await; - } - }), - ); - } - - /// Called when a specific overlay window is destroyed to cleanup it's resources - pub fn destroy(&self, id: &DisplayId, global_shortcut: &GlobalShortcut) { - let mut tasks = self.tasks.lock().unwrap_or_else(PoisonError::into_inner); - if let Some(task) = tasks.remove(&id.to_string()) { - task.abort(); - } - - // When all overlay windows are closed cleanup shared resources. - if tasks.is_empty() { - // Unregister keyboard shortcut - // This messes with other applications if we don't remove it. - global_shortcut - .unregister("Escape") - .map_err(|err| { - error!("Error unregistering global keyboard shortcut for Escape: {err}") - }) - .ok(); - - // Shutdown the cursor tracking task - if let Some(task) = self - .task - .lock() - .unwrap_or_else(PoisonError::into_inner) - .take() - { - task.abort(); - } - } - } -} diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 47828293ae..9467c77377 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -25,7 +25,6 @@ use crate::{ general_settings::{self, AppTheme, GeneralSettingsStore}, permissions, recording_settings::RecordingTargetMode, - target_select_overlay::WindowFocusManager, window_exclusion::WindowExclusion, }; @@ -366,14 +365,6 @@ impl ShowCapWindow { } } - app.state::() - .spawn(display_id, window.clone()); - - #[cfg(target_os = "macos")] - { - crate::platform::set_window_level(window.as_ref().window(), 45); - } - window } Self::Settings { page } => { diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/BaseControls.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/BaseControls.tsx new file mode 100644 index 0000000000..7bb55c1938 --- /dev/null +++ b/apps/desktop/src/routes/(window-chrome)/new-main/BaseControls.tsx @@ -0,0 +1,54 @@ +import { createMutation } from "@tanstack/solid-query"; +import { onMount } from "solid-js"; +import { createCameraMutation } from "~/utils/queries"; +import { commands } from "~/utils/tauri"; +import { useRecordingOptions } from "../OptionsContext"; +import CameraSelect from "./CameraSelect"; +import MicrophoneSelect from "./MicrophoneSelect"; +import SystemAudio from "./SystemAudio"; +import { useSystemHardwareOptions } from "./useSystemHardwareOptions"; + +export function BaseControls() { + const { rawOptions, setOptions } = useRecordingOptions(); + const { cameras, mics, options } = useSystemHardwareOptions(); + + const setCamera = createCameraMutation(); + const setMicInput = createMutation(() => ({ + mutationFn: async (name: string | null) => { + await commands.setMicInput(name); + setOptions("micName", name); + }, + })); + + onMount(() => { + if (rawOptions.cameraID && "ModelID" in rawOptions.cameraID) + setCamera.mutate({ ModelID: rawOptions.cameraID.ModelID }); + else if (rawOptions.cameraID && "DeviceID" in rawOptions.cameraID) + setCamera.mutate({ DeviceID: rawOptions.cameraID.DeviceID }); + else setCamera.mutate(null); + }); + + return ( +
+ { + 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)} + /> + {/**/} +
+ ); +} 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 de21995239..2c2cfd23cf 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -1,7 +1,7 @@ import { Button } from "@cap/ui-solid"; import { createEventListener } from "@solid-primitives/event-listener"; import { useNavigate } from "@solidjs/router"; -import { createMutation, useQuery } from "@tanstack/solid-query"; +import { useQuery } from "@tanstack/solid-query"; import { listen } from "@tauri-apps/api/event"; import { getAllWebviewWindows, @@ -34,25 +34,17 @@ import { Input } from "~/routes/editor/ui"; import { generalSettingsStore } from "~/store"; import { createSignInMutation } from "~/utils/auth"; import { - createCameraMutation, createCurrentRecordingQuery, createLicenseQuery, - listAudioDevices, listDisplaysWithThumbnails, - listScreens, - listVideoDevices, - listWindows, listWindowsWithThumbnails, } from "~/utils/queries"; import { - type CameraInfo, type CaptureDisplay, type CaptureDisplayWithThumbnail, type CaptureWindow, type CaptureWindowWithThumbnail, commands, - type DeviceOrModelID, - type ScreenCaptureTarget, } from "~/utils/tauri"; import IconLucideAppWindowMac from "~icons/lucide/app-window-mac"; import IconLucideArrowLeft from "~icons/lucide/arrow-left"; @@ -64,13 +56,12 @@ import { RecordingOptionsProvider, useRecordingOptions, } from "../OptionsContext"; -import CameraSelect from "./CameraSelect"; +import { BaseControls } from "./BaseControls"; import ChangelogButton from "./ChangeLogButton"; -import MicrophoneSelect from "./MicrophoneSelect"; -import SystemAudio from "./SystemAudio"; import TargetDropdownButton from "./TargetDropdownButton"; import TargetMenuGrid from "./TargetMenuGrid"; import TargetTypeButton from "./TargetTypeButton"; +import { useSystemHardwareOptions } from "./useSystemHardwareOptions"; function getWindowSize() { return { @@ -79,15 +70,6 @@ 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; - }); -}; - type WindowListItem = Pick< CaptureWindow, "id" | "owner_name" | "name" | "bounds" | "refresh_rate" @@ -326,9 +308,8 @@ function Page() { refetchInterval: false, })); - const screens = useQuery(() => listScreens); - const windows = useQuery(() => listWindows); - + const { screens, windows, cameras, mics, options } = + useSystemHardwareOptions(); const hasDisplayTargetsData = () => displayTargets.status === "success"; const hasWindowTargetsData = () => windowTargets.status === "success"; @@ -449,9 +430,6 @@ function Page() { if (!monitor) return; }); - const cameras = useQuery(() => listVideoDevices); - const mics = useQuery(() => listAudioDevices); - const windowListSignature = createMemo(() => createWindowSignature(windows.data), ); @@ -494,74 +472,6 @@ function Page() { void displayTargets.refetch(); }); - cameras.promise.then((cameras) => { - if (rawOptions.cameraID && findCamera(cameras, rawOptions.cameraID)) { - setOptions("cameraLabel", null); - } - }); - - mics.promise.then((mics) => { - if (rawOptions.micName && !mics.includes(rawOptions.micName)) { - setOptions("micName", null); - } - }); - - const options = { - screen: () => { - let screen; - - if (rawOptions.captureTarget.variant === "display") { - const screenId = rawOptions.captureTarget.id; - 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]; - } - - return screen; - }, - window: () => { - let win; - - if (rawOptions.captureTarget.variant === "window") { - const windowId = rawOptions.captureTarget.id; - win = windows.data?.find((s) => s.id === windowId) ?? windows.data?.[0]; - } - - return win; - }, - camera: () => { - if (!rawOptions.cameraID) return undefined; - return findCamera(cameras.data || [], rawOptions.cameraID); - }, - micName: () => mics.data?.find((name) => name === rawOptions.micName), - target: (): ScreenCaptureTarget | undefined => { - switch (rawOptions.captureTarget.variant) { - case "display": { - const screen = options.screen(); - if (!screen) return; - return { variant: "display", id: screen.id }; - } - case "window": { - const window = options.window(); - if (!window) return; - return { variant: "window", id: window.id }; - } - case "area": { - const screen = options.screen(); - if (!screen) return; - return { - variant: "area", - bounds: rawOptions.captureTarget.bounds, - screen: screen.id, - }; - } - } - }, - }; - createEffect(() => { const target = options.target(); if (!target) return; @@ -576,51 +486,9 @@ function Page() { } }); - const setMicInput = createMutation(() => ({ - mutationFn: async (name: string | null) => { - await commands.setMicInput(name); - setOptions("micName", name); - }, - })); - - const setCamera = createCameraMutation(); - - onMount(() => { - if (rawOptions.cameraID && "ModelID" in rawOptions.cameraID) - setCamera.mutate({ ModelID: rawOptions.cameraID.ModelID }); - else if (rawOptions.cameraID && "DeviceID" in rawOptions.cameraID) - setCamera.mutate({ DeviceID: rawOptions.cameraID.DeviceID }); - else setCamera.mutate(null); - }); - const license = createLicenseQuery(); - 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)} - /> - -
- ); - const TargetSelectionHome = () => ( { + return cameras.find((c) => { + if (!id) return false; + return "DeviceID" in id + ? id.DeviceID === c.device_id + : id.ModelID === c.model_id; + }); +}; + +export function useSystemHardwareOptions() { + const { rawOptions, setOptions } = useRecordingOptions(); + const screens = useQuery(() => listScreens); + const windows = useQuery(() => listWindows); + const cameras = useQuery(() => listVideoDevices); + const mics = useQuery(() => listAudioDevices); + + cameras.promise.then((cameras) => { + if (rawOptions.cameraID && findCamera(cameras, rawOptions.cameraID)) { + setOptions("cameraLabel", null); + } + }); + + mics.promise.then((mics) => { + if (rawOptions.micName && !mics.includes(rawOptions.micName)) { + setOptions("micName", null); + } + }); + + const options = { + screen: () => { + let screen; + + if (rawOptions.captureTarget.variant === "display") { + const screenId = rawOptions.captureTarget.id; + 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]; + } + + return screen; + }, + window: () => { + let win; + + if (rawOptions.captureTarget.variant === "window") { + const windowId = rawOptions.captureTarget.id; + win = windows.data?.find((s) => s.id === windowId) ?? windows.data?.[0]; + } + + return win; + }, + camera: () => { + if (!rawOptions.cameraID) return undefined; + return findCamera(cameras.data || [], rawOptions.cameraID); + }, + micName: () => mics.data?.find((name) => name === rawOptions.micName), + target: (): ScreenCaptureTarget | undefined => { + switch (rawOptions.captureTarget.variant) { + case "display": { + const screen = options.screen(); + if (!screen) return; + return { variant: "display", id: screen.id }; + } + case "window": { + const window = options.window(); + if (!window) return; + return { variant: "window", id: window.id }; + } + case "area": { + const screen = options.screen(); + if (!screen) return; + return { + variant: "area", + bounds: rawOptions.captureTarget.bounds, + screen: screen.id, + }; + } + } + }, + }; + + return { + screens, + windows, + cameras, + mics, + options, + }; +} diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index cecab14f97..5062d05c65 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -35,6 +35,7 @@ import { type ScreenCaptureTarget, type TargetUnderCursor, } from "~/utils/tauri"; +import { BaseControls } from "./(window-chrome)/new-main/BaseControls"; import { RecordingOptionsProvider, useRecordingOptions, @@ -874,77 +875,86 @@ function RecordingControls(props: { } > -
-
{ - setOptions("targetMode", null); - commands.closeTargetSelectOverlays(); - }} - class="flex justify-center items-center rounded-full transition-opacity bg-gray-12 size-9 hover:opacity-80" - > - -
-
{ - if (rawOptions.mode === "instant" && !auth.data) { - emit("start-sign-in"); - return; - } - if (startRecording.isPending) return; - - startRecording.mutate(); - }} - > +
+
{ + setOptions("targetMode", null); + commands.closeTargetSelectOverlays(); + }} + class="flex justify-center items-center rounded-full transition-opacity bg-gray-12 size-9 hover:opacity-80" > - {rawOptions.mode === "studio" ? ( - - ) : ( - - )} -
- - {rawOptions.mode === "instant" && !auth.data - ? "Sign In To Use" - : "Start Recording"} - - - {`${capitalize(rawOptions.mode)} Mode`} - + +
+
{ + if (rawOptions.mode === "instant" && !auth.data) { + emit("start-sign-in"); + return; + } + if (startRecording.isPending) return; + + startRecording.mutate(); + }} + > +
+ {rawOptions.mode === "studio" ? ( + + ) : ( + + )} +
+ + {rawOptions.mode === "instant" && !auth.data + ? "Sign In To Use" + : "Start Recording"} + + + {`${capitalize(rawOptions.mode)} Mode`} + +
+
+
{ + e.stopPropagation(); + menuModes().then((menu) => menu.popup()); + }} + > +
{ e.stopPropagation(); - menuModes().then((menu) => menu.popup()); + preRecordingMenu().then((menu) => menu.popup()); }} + class="flex justify-center items-center rounded-full border transition-opacity bg-gray-6 text-gray-12 size-9 hover:opacity-80" > - +
-
{ - e.stopPropagation(); - preRecordingMenu().then((menu) => menu.popup()); - }} - class="flex justify-center items-center rounded-full border transition-opacity bg-gray-6 text-gray-12 size-9 hover:opacity-80" - > - -
+ +
+ + {/*
+ +
*/} +
props.setToggleModeSelect?.(true)} class="flex gap-1 items-center mb-5 transition-opacity duration-200 hover:opacity-60" @@ -995,9 +1005,3 @@ function ResizeHandle( /> ); } - -function getDisplayId(displayId: string | undefined) { - const id = Number(displayId); - if (Number.isNaN(id)) return 0; - return id; -}