diff --git a/Cargo.lock b/Cargo.lock index 7074489506..c530687aed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,7 +1172,7 @@ dependencies = [ [[package]] name = "cap-desktop" -version = "0.3.83" +version = "0.3.84" dependencies = [ "anyhow", "async-stream", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index a6cf8cfe17..46024fd0ab 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cap-desktop" -version = "0.3.83" +version = "0.3.84" description = "Beautiful screen recordings, owned by you." authors = ["you"] edition = "2024" diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index fbd6189853..1b193e75b3 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -113,6 +113,8 @@ pub struct GeneralSettingsStore { )] pub enable_new_recording_flow: bool, #[serde(default)] + pub recording_picker_preference_set: bool, + #[serde(default)] pub post_deletion_behaviour: PostDeletionBehaviour, #[serde(default = "default_excluded_windows")] pub excluded_windows: Vec, @@ -128,7 +130,7 @@ fn default_enable_native_camera_preview() -> bool { } fn default_enable_new_recording_flow() -> bool { - cfg!(debug_assertions) + true } fn no(_: &bool) -> bool { @@ -180,6 +182,7 @@ impl Default for GeneralSettingsStore { enable_native_camera_preview: default_enable_native_camera_preview(), auto_zoom_on_clicks: false, enable_new_recording_flow: default_enable_new_recording_flow(), + recording_picker_preference_set: false, post_deletion_behaviour: PostDeletionBehaviour::DoNothing, excluded_windows: default_excluded_windows(), delete_instant_recordings_after_upload: false, @@ -240,7 +243,7 @@ impl GeneralSettingsStore { pub fn init(app: &AppHandle) { println!("Initializing GeneralSettingsStore"); - let store = match GeneralSettingsStore::get(app) { + let mut store = match GeneralSettingsStore::get(app) { Ok(Some(store)) => store, Ok(None) => GeneralSettingsStore::default(), Err(e) => { @@ -249,7 +252,14 @@ pub fn init(app: &AppHandle) { } }; - store.save(app).unwrap(); + if !store.recording_picker_preference_set { + store.enable_new_recording_flow = true; + store.recording_picker_preference_set = true; + } + + if let Err(e) = store.save(app) { + error!("Failed to save general settings: {}", e); + } println!("GeneralSettingsState managed"); } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index b2152ba350..c45ccc982a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -56,19 +56,20 @@ use ffmpeg::ffi::AV_TIME_BASE; use general_settings::GeneralSettingsStore; use kameo::{Actor, actor::ActorRef}; use notifications::NotificationType; -use recording::InProgressRecording; +use recording::{InProgressRecording, RecordingEvent, RecordingInputKind}; use scap_targets::{Display, DisplayId, WindowId, bounds::LogicalBounds}; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; use std::{ - collections::BTreeMap, + collections::{BTreeMap, HashSet}, future::Future, marker::PhantomData, path::{Path, PathBuf}, process::Command, str::FromStr, sync::Arc, + time::Duration, }; use tauri::{AppHandle, Manager, State, Window, WindowEvent, ipc::Channel}; use tauri_plugin_deep_link::DeepLinkExt; @@ -112,9 +113,12 @@ pub struct App { mic_feed: ActorRef, mic_meter_sender: flume::Sender, selected_mic_label: Option, + selected_camera_id: Option, + camera_in_use: bool, camera_feed: ActorRef, server_url: String, logs_dir: PathBuf, + disconnected_inputs: HashSet, } #[derive(specta::Type, Serialize, Deserialize, Clone, Debug)] @@ -177,7 +181,7 @@ impl App { let (error_tx, error_rx) = flume::bounded(1); let mic_feed = MicrophoneFeed::spawn(MicrophoneFeed::new(error_tx)); - spawn_mic_error_logger(error_rx); + spawn_mic_error_handler(self.handle.clone(), error_rx); mic_feed .ask(microphone::AddSender(self.mic_meter_sender.clone())) @@ -185,11 +189,23 @@ impl App { .map_err(|e| e.to_string())?; if let Some(label) = self.selected_mic_label.clone() { - let ready = mic_feed - .ask(microphone::SetInput { label }) - .await - .map_err(|e| e.to_string())?; - ready.await.map_err(|e| e.to_string())?; + match mic_feed.ask(microphone::SetInput { label }).await { + Ok(ready) => { + if let Err(err) = ready.await { + if matches!(err, microphone::SetInputError::DeviceNotFound) { + warn!("Selected microphone not available while restarting feed"); + } else { + return Err(err.to_string()); + } + } + } + Err(kameo::error::SendError::HandlerError( + microphone::SetInputError::DeviceNotFound, + )) => { + warn!("Selected microphone not available while restarting feed"); + } + Err(err) => return Err(err.to_string()), + } } self.mic_feed = mic_feed; @@ -230,13 +246,108 @@ impl App { pub fn is_recording_active_or_pending(&self) -> bool { !matches!(self.recording_state, RecordingState::None) } + + async fn handle_input_disconnect(&mut self, kind: RecordingInputKind) -> Result<(), String> { + if !self.disconnected_inputs.insert(kind) { + return Ok(()); + } + + if let Some(recording) = self.current_recording_mut() { + recording.pause().await.map_err(|e| e.to_string())?; + } + + let (title, body) = match kind { + RecordingInputKind::Microphone => ( + "Microphone disconnected", + "Recording paused. Reconnect your microphone, then resume or stop the recording.", + ), + RecordingInputKind::Camera => ( + "Camera disconnected", + "Recording paused. Reconnect your camera, then resume or stop the recording.", + ), + }; + + let _ = NewNotification { + title: title.to_string(), + body: body.to_string(), + is_error: true, + } + .emit(&self.handle); + + let _ = RecordingEvent::InputLost { input: kind }.emit(&self.handle); + + Ok(()) + } + + async fn handle_input_restored(&mut self, kind: RecordingInputKind) -> Result<(), String> { + if !self.disconnected_inputs.remove(&kind) { + return Ok(()); + } + + match kind { + RecordingInputKind::Microphone => { + self.ensure_selected_mic_ready().await.ok(); + } + RecordingInputKind::Camera => { + self.ensure_selected_camera_ready().await.ok(); + } + } + + let _ = RecordingEvent::InputRestored { input: kind }.emit(&self.handle); + + Ok(()) + } + + async fn ensure_selected_mic_ready(&mut self) -> Result<(), String> { + if let Some(label) = self.selected_mic_label.clone() { + let ready = self + .mic_feed + .ask(feeds::microphone::SetInput { label }) + .await + .map_err(|e| e.to_string())?; + + ready.await.map_err(|e| e.to_string())?; + } + + Ok(()) + } + + async fn ensure_selected_camera_ready(&mut self) -> Result<(), String> { + if let Some(id) = self.selected_camera_id.clone() { + let ready = self + .camera_feed + .ask(feeds::camera::SetInput { id: id.clone() }) + .await + .map_err(|e| e.to_string())?; + + ready.await.map_err(|e| e.to_string())?; + } + + Ok(()) + } } #[tauri::command] #[specta::specta] #[instrument(skip(state))] async fn set_mic_input(state: MutableState<'_, App>, label: Option) -> Result<(), String> { - let mic_feed = state.read().await.mic_feed.clone(); + let (mic_feed, studio_handle, current_label) = { + let app = state.read().await; + let handle = match app.current_recording() { + Some(InProgressRecording::Studio { handle, .. }) => Some(handle.clone()), + _ => None, + }; + (app.mic_feed.clone(), handle, app.selected_mic_label.clone()) + }; + + if label == current_label { + return Ok(()); + } + + if let Some(handle) = &studio_handle { + handle.set_mic_feed(None).await.map_err(|e| e.to_string())?; + } + let desired_label = label.clone(); match desired_label.as_ref() { @@ -258,9 +369,32 @@ async fn set_mic_input(state: MutableState<'_, App>, label: Option) -> R } } + if let Some(handle) = studio_handle + && desired_label.is_some() + { + let mic_lock = mic_feed + .ask(microphone::Lock) + .await + .map_err(|e| e.to_string())?; + handle + .set_mic_feed(Some(Arc::new(mic_lock))) + .await + .map_err(|e| e.to_string())?; + } + { let mut app = state.write().await; app.selected_mic_label = desired_label; + let cleared = app + .disconnected_inputs + .remove(&RecordingInputKind::Microphone); + + if cleared { + let _ = RecordingEvent::InputRestored { + input: RecordingInputKind::Microphone, + } + .emit(&app.handle); + } } Ok(()) @@ -275,14 +409,34 @@ async fn upload_logs(app_handle: AppHandle) -> Result<(), String> { #[tauri::command] #[specta::specta] #[instrument(skip(app_handle, state))] +#[allow(unused_mut)] async fn set_camera_input( app_handle: AppHandle, state: MutableState<'_, App>, id: Option, ) -> Result<(), String> { - let camera_feed = state.read().await.camera_feed.clone(); + let app = state.read().await; + let camera_feed = app.camera_feed.clone(); + let studio_handle = match app.current_recording() { + Some(InProgressRecording::Studio { handle, .. }) => Some(handle.clone()), + _ => None, + }; + let current_id = app.selected_camera_id.clone(); + let camera_in_use = app.camera_in_use; + drop(app); - match id { + if id == current_id && camera_in_use { + return Ok(()); + } + + if let Some(handle) = &studio_handle { + handle + .set_camera_feed(None) + .await + .map_err(|e| e.to_string())?; + } + + match &id { None => { camera_feed .ask(feeds::camera::RemoveInput) @@ -290,34 +444,187 @@ async fn set_camera_input( .map_err(|e| e.to_string())?; } Some(id) => { + camera_feed + .ask(feeds::camera::SetInput { id: id.clone() }) + .await + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + ShowCapWindow::Camera .show(&app_handle) .await .map_err(|err| error!("Failed to show camera preview window: {err}")) .ok(); + } + } - camera_feed - .ask(feeds::camera::SetInput { id }) - .await - .map_err(|e| e.to_string())? - .await - .map_err(|e| e.to_string())?; + if let Some(handle) = studio_handle + && id.is_some() + { + let camera_lock = camera_feed + .ask(feeds::camera::Lock) + .await + .map_err(|e| e.to_string())?; + handle + .set_camera_feed(Some(Arc::new(camera_lock))) + .await + .map_err(|e| e.to_string())?; + } + + { + let app = &mut *state.write().await; + app.selected_camera_id = id; + app.camera_in_use = app.selected_camera_id.is_some(); + let cleared = app.disconnected_inputs.remove(&RecordingInputKind::Camera); + + if cleared { + let _ = RecordingEvent::InputRestored { + input: RecordingInputKind::Camera, + } + .emit(&app.handle); } } Ok(()) } -fn spawn_mic_error_logger(error_rx: flume::Receiver) { +fn spawn_mic_error_handler(app_handle: AppHandle, error_rx: flume::Receiver) { tokio::spawn(async move { - let Ok(err) = error_rx.recv_async().await else { - return; - }; + let state = app_handle.state::>(); + let state = state.inner().clone(); + + let error_rx = error_rx; + + while let Ok(err) = error_rx.recv_async().await { + error!("Mic feed actor error: {err}"); + + let mut app = state.write().await; + + if let Err(handle_err) = app + .handle_input_disconnect(RecordingInputKind::Microphone) + .await + { + warn!("Failed to pause recording after mic disconnect: {handle_err}"); + } + + match app.ensure_selected_mic_ready().await { + Ok(()) => { + if let Err(restored_err) = app + .handle_input_restored(RecordingInputKind::Microphone) + .await + { + warn!("Failed to handle mic restoration: {restored_err}"); + } + } + Err(restart_err) => { + warn!("Failed to restart microphone input: {restart_err}"); + } + } + } + }); +} + +fn spawn_device_watchers(app_handle: AppHandle) { + spawn_microphone_watcher(app_handle.clone()); + spawn_camera_watcher(app_handle); +} - error!("Mic feed actor error: {err}"); +fn spawn_microphone_watcher(app_handle: AppHandle) { + tokio::spawn(async move { + let state = app_handle.state::>(); + let state = state.inner().clone(); + + loop { + let (should_check, label, is_marked) = { + let guard = state.read().await; + ( + matches!(guard.recording_state, RecordingState::Active(_)), + guard.selected_mic_label.clone(), + guard + .disconnected_inputs + .contains(&RecordingInputKind::Microphone), + ) + }; + + if should_check && let Some(selected_label) = label { + let available = microphone::MicrophoneFeed::list().contains_key(&selected_label); + + if !available && !is_marked { + let mut app = state.write().await; + if let Err(err) = app + .handle_input_disconnect(RecordingInputKind::Microphone) + .await + { + warn!("Failed to handle mic disconnect: {err}"); + } + } else if available && is_marked { + let mut app = state.write().await; + if let Err(err) = app + .handle_input_restored(RecordingInputKind::Microphone) + .await + { + warn!("Failed to handle mic reconnection: {err}"); + } + } + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } }); } +fn spawn_camera_watcher(app_handle: AppHandle) { + tokio::spawn(async move { + let state = app_handle.state::>(); + let state = state.inner().clone(); + + loop { + let (should_check, camera_id, is_marked) = { + let guard = state.read().await; + ( + matches!(guard.recording_state, RecordingState::Active(_)) + && guard.camera_in_use, + guard.selected_camera_id.clone(), + guard + .disconnected_inputs + .contains(&RecordingInputKind::Camera), + ) + }; + + if should_check && let Some(selected_id) = camera_id { + let available = is_camera_available(&selected_id); + + if !available && !is_marked { + let mut app = state.write().await; + if let Err(err) = app + .handle_input_disconnect(RecordingInputKind::Camera) + .await + { + warn!("Failed to handle camera disconnect: {err}"); + } + } else if available && is_marked { + let mut app = state.write().await; + if let Err(err) = app.handle_input_restored(RecordingInputKind::Camera).await { + warn!("Failed to handle camera reconnection: {err}"); + } + } + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } + }); +} + +fn is_camera_available(id: &DeviceOrModelID) -> bool { + cap_camera::list_cameras().any(|info| match id { + DeviceOrModelID::DeviceID(device_id) => info.device_id() == device_id, + DeviceOrModelID::ModelID(model_id) => { + info.model_id().is_some_and(|existing| existing == model_id) + } + }) +} + #[derive(specta::Type, Serialize, tauri_specta::Event, Clone)] pub struct RecordingOptionsChanged; @@ -1998,12 +2305,10 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { let camera_feed = CameraFeed::spawn(CameraFeed::default()); let _ = camera_feed.ask(feeds::camera::AddSender(camera_tx)).await; - let mic_feed = { - let (error_tx, error_rx) = flume::bounded(1); + let (mic_error_tx, mic_error_rx) = flume::bounded(1); - let mic_feed = MicrophoneFeed::spawn(MicrophoneFeed::new(error_tx)); - - spawn_mic_error_logger(error_rx); + let mic_feed = { + let mic_feed = MicrophoneFeed::spawn(MicrophoneFeed::new(mic_error_tx)); if let Err(err) = mic_feed .ask(feeds::microphone::AddSender(mic_samples_tx)) @@ -2182,9 +2487,12 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { mic_feed, mic_meter_sender, selected_mic_label: None, + selected_camera_id: None, + camera_in_use: false, camera_feed, server_url, logs_dir: logs_dir.clone(), + disconnected_inputs: HashSet::new(), }))); app.manage(Arc::new(RwLock::new( @@ -2192,6 +2500,9 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { ))); } + spawn_mic_error_handler(app.clone(), mic_error_rx); + spawn_device_watchers(app.clone()); + tokio::spawn(check_notification_permissions(app.clone())); println!("Checking startup completion and permissions..."); diff --git a/apps/desktop/src-tauri/src/platform/macos/sc_shareable_content.rs b/apps/desktop/src-tauri/src/platform/macos/sc_shareable_content.rs index 01dba7c59c..03ff5b95b0 100644 --- a/apps/desktop/src-tauri/src/platform/macos/sc_shareable_content.rs +++ b/apps/desktop/src-tauri/src/platform/macos/sc_shareable_content.rs @@ -24,7 +24,17 @@ fn state() -> &'static CacheState { } pub async fn prewarm_shareable_content() -> Result<(), arc::R> { - if state().cache.read().unwrap().is_some() { + prewarm_shareable_content_inner(false).await +} + +pub async fn refresh_shareable_content() -> Result<(), arc::R> { + prewarm_shareable_content_inner(true).await +} + +async fn prewarm_shareable_content_inner(force_refresh: bool) -> Result<(), arc::R> { + if force_refresh { + state().cache.write().unwrap().take(); + } else if state().cache.read().unwrap().is_some() { trace!("ScreenCaptureKit shareable content already warmed"); return Ok(()); } @@ -161,7 +171,11 @@ impl ScreenCapturePrewarmer { } let warm_start = std::time::Instant::now(); - let result = crate::platform::prewarm_shareable_content().await; + let result = if force { + crate::platform::refresh_shareable_content().await + } else { + crate::platform::prewarm_shareable_content().await + }; let mut state = self.state.lock().await; match result { diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index e999788c23..e48f7229ed 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -9,6 +9,8 @@ use cap_project::{ cursor::CursorEvents, }; use cap_recording::feeds::camera::CameraFeedLock; +#[cfg(target_os = "macos")] +use cap_recording::sources::screen_capture::SourceError; use cap_recording::{ RecordingMode, feeds::{camera, microphone}, @@ -28,6 +30,7 @@ use specta::Type; use std::{ any::Any, collections::{HashMap, VecDeque}, + error::Error as StdError, panic::AssertUnwindSafe, path::{Path, PathBuf}, str::FromStr, @@ -99,6 +102,64 @@ unsafe impl Send for SendableShareableContent {} #[cfg(target_os = "macos")] unsafe impl Sync for SendableShareableContent {} +#[cfg(target_os = "macos")] +async fn acquire_shareable_content_for_target( + capture_target: &ScreenCaptureTarget, +) -> anyhow::Result { + let mut refreshed = false; + + loop { + let shareable_content = SendableShareableContent( + crate::platform::get_shareable_content() + .await + .map_err(|e| anyhow!(format!("GetShareableContent: {e}")))? + .ok_or_else(|| anyhow!("GetShareableContent/NotAvailable"))?, + ); + + if !shareable_content_missing_target_display(capture_target, shareable_content.retained()) + .await + { + return Ok(shareable_content); + } + + if refreshed { + return Err(anyhow!("GetShareableContent/DisplayMissing")); + } + + crate::platform::refresh_shareable_content() + .await + .map_err(|e| anyhow!(format!("RefreshShareableContent: {e}")))?; + refreshed = true; + } +} + +#[cfg(target_os = "macos")] +async fn shareable_content_missing_target_display( + capture_target: &ScreenCaptureTarget, + shareable_content: cidre::arc::R, +) -> bool { + match capture_target.display() { + Some(display) => display + .raw_handle() + .as_sc(shareable_content) + .await + .is_none(), + None => false, + } +} + +#[cfg(target_os = "macos")] +fn is_shareable_content_error(err: &anyhow::Error) -> bool { + err.chain().any(|cause| { + let cause: &dyn StdError = cause; + if let Some(source_error) = cause.downcast_ref::() { + matches!(source_error, SourceError::AsContentFilter) + } else { + false + } + }) +} + impl InProgressRecording { pub fn capture_target(&self) -> &ScreenCaptureTarget { match self { @@ -263,6 +324,13 @@ pub struct StartRecordingInputs { pub organization_id: Option, } +#[derive(Deserialize, Type, Serialize, Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +pub enum RecordingInputKind { + Microphone, + Camera, +} + #[derive(tauri_specta::Event, specta::Type, Clone, Debug, serde::Serialize)] #[serde(tag = "variant")] pub enum RecordingEvent { @@ -270,6 +338,8 @@ pub enum RecordingEvent { Started, Stopped, Failed { error: String }, + InputLost { input: RecordingInputKind }, + InputRestored { input: RecordingInputKind }, } #[derive(Serialize, Type)] @@ -485,13 +555,11 @@ pub async fn start_recording( Err(e) => return Err(anyhow!(e.to_string())), }; + state.camera_in_use = camera_feed.is_some(); + #[cfg(target_os = "macos")] - let shareable_content = SendableShareableContent( - crate::platform::get_shareable_content() - .await - .map_err(|e| anyhow!(format!("GetShareableContent: {e}")))? - .ok_or_else(|| anyhow!("GetShareableContent/NotAvailable"))?, - ); + let mut shareable_content = + acquire_shareable_content_for_target(&inputs.capture_target).await?; let common = InProgressRecordingCommon { target_name, @@ -626,6 +694,12 @@ pub async fn start_recording( state.set_current_recording(actor); break done_fut; } + #[cfg(target_os = "macos")] + Err(err) if is_shareable_content_error(&err) => { + shareable_content = + acquire_shareable_content_for_target(&inputs.capture_target).await?; + continue; + } Err(err) if mic_restart_attempts == 0 && mic_actor_not_running(&err) => { mic_restart_attempts += 1; state @@ -908,6 +982,8 @@ async fn handle_recording_end( ) -> Result<(), String> { // Clear current recording, just in case :) app.clear_current_recording(); + app.disconnected_inputs.clear(); + app.camera_in_use = false; let res = match recording { // we delay reporting errors here so that everything else happens first diff --git a/apps/desktop/src-tauri/src/target_select_overlay.rs b/apps/desktop/src-tauri/src/target_select_overlay.rs index d8ccf834d8..c3eca8084d 100644 --- a/apps/desktop/src-tauri/src/target_select_overlay.rs +++ b/apps/desktop/src-tauri/src/target_select_overlay.rs @@ -8,7 +8,11 @@ use std::{ use base64::prelude::*; use cap_recording::screen_capture::ScreenCaptureTarget; -use crate::windows::{CapWindowId, ShowCapWindow}; +use crate::{ + general_settings, + window_exclusion::WindowExclusion, + windows::{CapWindowId, ShowCapWindow}, +}; use scap_targets::{ Display, DisplayId, Window, WindowId, bounds::{LogicalBounds, PhysicalSize}, @@ -59,6 +63,13 @@ pub async fn open_target_select_overlays( .await; } + let window_exclusions = general_settings::GeneralSettingsStore::get(&app) + .ok() + .flatten() + .map_or_else(general_settings::default_excluded_windows, |settings| { + settings.excluded_windows + }); + let handle = tokio::spawn({ let app = app.clone(); @@ -77,6 +88,10 @@ pub async fn open_target_select_overlays( let _ = TargetUnderCursor { display_id: display.map(|d| d.id()), window: window.and_then(|w| { + if should_skip_window(&w, &window_exclusions) { + return None; + } + Some(WindowUnderCursor { id: w.id(), bounds: w.display_relative_logical_bounds()?, @@ -110,6 +125,29 @@ pub async fn open_target_select_overlays( Ok(()) } +fn should_skip_window(window: &Window, exclusions: &[WindowExclusion]) -> bool { + if exclusions.is_empty() { + return false; + } + + let owner_name = window.owner_name(); + let window_title = window.name(); + + #[cfg(target_os = "macos")] + let bundle_identifier = window.raw_handle().bundle_identifier(); + + #[cfg(not(target_os = "macos"))] + let bundle_identifier = None::<&str>; + + exclusions.iter().any(|entry| { + entry.matches( + bundle_identifier.as_deref(), + owner_name.as_deref(), + window_title.as_deref(), + ) + }) +} + #[specta::specta] #[tauri::command] #[instrument(skip(app))] diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index ba6d5a71f6..fc8ab48541 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -625,8 +625,8 @@ impl ShowCapWindow { window } Self::InProgressRecording { countdown } => { - let width = 250.0; - let height = 40.0; + let width = 320.0; + let height = 150.0; let title = CapWindowId::RecordingControls.title(); let should_protect = should_protect_window(app, &title); diff --git a/apps/desktop/src/components/selection-hint.tsx b/apps/desktop/src/components/selection-hint.tsx new file mode 100644 index 0000000000..f27c72db54 --- /dev/null +++ b/apps/desktop/src/components/selection-hint.tsx @@ -0,0 +1,47 @@ +import { type as ostype } from "@tauri-apps/plugin-os"; +import { Match, Show, Switch } from "solid-js"; + +export type SelectionHintProps = { + show: boolean; + message?: string; + class?: string; +}; + +export default function SelectionHint(props: SelectionHintProps) { + const os = ostype(); + + 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..7e92e7e3b0 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -29,9 +29,10 @@ import { import { reconcile } from "solid-js/store"; // Removed solid-motionone in favor of solid-transition-group import { Transition } from "solid-transition-group"; +import Mode from "~/components/Mode"; import Tooltip from "~/components/Tooltip"; import { Input } from "~/routes/editor/ui"; -import { generalSettingsStore } from "~/store"; +import { authStore, generalSettingsStore } from "~/store"; import { createSignInMutation } from "~/utils/auth"; import { createCameraMutation, @@ -74,8 +75,8 @@ import TargetTypeButton from "./TargetTypeButton"; function getWindowSize() { return { - width: 270, - height: 256, + width: 290, + height: 310, }; } @@ -191,7 +192,7 @@ function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) { }); return ( -
+
props.onBack()} @@ -224,11 +225,8 @@ function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) { />
-
-
+
+
{props.variant === "display" ? ( !!currentRecording.data; + const auth = authStore.createQuery(); + + const [hasHiddenMainWindowForPicker, setHasHiddenMainWindowForPicker] = + createSignal(false); + createEffect(() => { + const pickerActive = rawOptions.targetMode != null; + const hasHidden = hasHiddenMainWindowForPicker(); + if (pickerActive && !hasHidden) { + setHasHiddenMainWindowForPicker(true); + void getCurrentWindow().hide(); + } else if (!pickerActive && hasHidden) { + setHasHiddenMainWindowForPicker(false); + const currentWindow = getCurrentWindow(); + void currentWindow.show(); + void currentWindow.setFocus(); + } + }); + onCleanup(() => { + if (!hasHiddenMainWindowForPicker()) return; + setHasHiddenMainWindowForPicker(false); + void getCurrentWindow().show(); + }); const [displayMenuOpen, setDisplayMenuOpen] = createSignal(false); const [windowMenuOpen, setWindowMenuOpen] = createSignal(false); @@ -383,7 +403,7 @@ function Page() { reconcile({ variant: "display", id: target.id }), ); setOptions("targetMode", "display"); - commands.openTargetSelectOverlays(rawOptions.captureTarget); + commands.openTargetSelectOverlays({ variant: "display", id: target.id }); setDisplayMenuOpen(false); displayTriggerRef?.focus(); }; @@ -394,7 +414,7 @@ function Page() { reconcile({ variant: "window", id: target.id }), ); setOptions("targetMode", "window"); - commands.openTargetSelectOverlays(rawOptions.captureTarget); + commands.openTargetSelectOverlays({ variant: "window", id: target.id }); setWindowMenuOpen(false); windowTriggerRef?.focus(); @@ -510,7 +530,7 @@ function Page() { const options = { screen: () => { - let screen; + let screen: CaptureDisplay | undefined; if (rawOptions.captureTarget.variant === "display") { const screenId = rawOptions.captureTarget.id; @@ -525,7 +545,7 @@ function Page() { return screen; }, window: () => { - let win; + let win: CaptureWindow | undefined; if (rawOptions.captureTarget.variant === "window") { const windowId = rawOptions.captureTarget.id; @@ -564,6 +584,14 @@ function Page() { }, }; + const toggleTargetMode = (mode: "display" | "window" | "area") => { + if (isRecording()) return; + const nextMode = rawOptions.targetMode === mode ? null : mode; + setOptions("targetMode", nextMode); + if (nextMode) commands.openTargetSelectOverlays(null); + else commands.closeTargetSelectOverlays(); + }; + createEffect(() => { const target = options.target(); if (!target) return; @@ -646,15 +674,7 @@ function Page() { selected={rawOptions.targetMode === "display"} Component={IconMdiMonitor} disabled={isRecording()} - onClick={() => { - if (isRecording()) return; - setOptions("targetMode", (v) => - v === "display" ? null : "display", - ); - if (rawOptions.targetMode) - commands.openTargetSelectOverlays(null); - else commands.closeTargetSelectOverlays(); - }} + onClick={() => toggleTargetMode("display")} name="Display" class="flex-1 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0" /> @@ -663,7 +683,7 @@ function Page() { "rounded-none border-l border-gray-6 focus-visible:ring-0 focus-visible:ring-offset-0", displayMenuOpen() && "bg-gray-5", )} - ref={(el) => (displayTriggerRef = el)} + ref={displayTriggerRef} disabled={isRecording()} expanded={displayMenuOpen()} onClick={() => { @@ -691,15 +711,7 @@ function Page() { selected={rawOptions.targetMode === "window"} Component={IconLucideAppWindowMac} disabled={isRecording()} - onClick={() => { - if (isRecording()) return; - setOptions("targetMode", (v) => - v === "window" ? null : "window", - ); - if (rawOptions.targetMode) - commands.openTargetSelectOverlays(null); - else commands.closeTargetSelectOverlays(); - }} + onClick={() => toggleTargetMode("window")} name="Window" class="flex-1 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0" /> @@ -708,7 +720,7 @@ function Page() { "rounded-none border-l border-gray-6 focus-visible:ring-0 focus-visible:ring-offset-0", windowMenuOpen() && "bg-gray-5", )} - ref={(el) => (windowTriggerRef = el)} + ref={windowTriggerRef} disabled={isRecording()} expanded={windowMenuOpen()} onClick={() => { @@ -729,13 +741,7 @@ function Page() { selected={rawOptions.targetMode === "area"} Component={IconMaterialSymbolsScreenshotFrame2Rounded} disabled={isRecording()} - onClick={() => { - if (isRecording()) return; - setOptions("targetMode", (v) => (v === "area" ? null : "area")); - if (rawOptions.targetMode) - commands.openTargetSelectOverlays(null); - else commands.closeTargetSelectOverlays(); - }} + onClick={() => toggleTargetMode("area")} name="Area" />
@@ -763,11 +769,7 @@ function Page() { onCleanup(() => startSignInCleanup.then((cb) => cb())); return ( -
+
)} - }> - - { - if (license.data?.type !== "pro") { - await commands.showWindow("Upgrade"); - } - }} - class={cx( - "text-[0.6rem] ml-2 rounded-full px-1.5 py-0.5", - license.data?.type === "pro" - ? "bg-[--blue-300] text-gray-1 dark:text-gray-12" - : "bg-gray-4 cursor-pointer hover:bg-gray-5", - ostype() === "windows" && "ml-2", - )} - > - {license.data?.type === "commercial" - ? "Commercial" - : license.data?.type === "pro" - ? "Pro" - : "Personal"} - - -
- -
-
- Signing In... - - + + }> + + { + if (license.data?.type !== "pro") { + await commands.showWindow("Upgrade"); + } + }} + class={cx( + "text-[0.6rem] ml-2 rounded-lg px-1 py-0.5", + license.data?.type === "pro" + ? "bg-[--blue-400] text-gray-1 dark:text-gray-12" + : "bg-gray-3 cursor-pointer hover:bg-gray-5", + )} + > + {license.data?.type === "commercial" + ? "Commercial" + : license.data?.type === "pro" + ? "Pro" + : "Personal"} + + +
+
- - }> - {(variant) => - variant === "display" ? ( - { - setDisplayMenuOpen(false); - displayTriggerRef?.focus(); - }} - /> - ) : ( - { - setWindowMenuOpen(false); - windowTriggerRef?.focus(); +
+ +
+
+ Signing In... + + +
+
- + + }> + {(variant) => + variant === "display" ? ( + { + setDisplayMenuOpen(false); + displayTriggerRef?.focus(); + }} + /> + ) : ( + { + setWindowMenuOpen(false); + windowTriggerRef?.focus(); + }} + /> + ) + } + + +
); } diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index 2e3573523b..5c8e47264d 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -23,7 +23,7 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { autoCreateShareableLink: false, enableNotifications: true, enableNativeCameraPreview: false, - enableNewRecordingFlow: false, + enableNewRecordingFlow: true, autoZoomOnClicks: false, custom_cursor_capture2: true, }, @@ -83,19 +83,6 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { ); }} /> - { - handleChange("enableNewRecordingFlow", value); - // This is bad code, but I just want the UI to not jank and can't seem to find the issue. - setTimeout( - () => window.scrollTo({ top: 0, behavior: "instant" }), - 5, - ); - }} - />
diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index 52cd6384bd..08b4846400 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -69,7 +69,8 @@ const createDefaultGeneralSettings = (): ExtendedGeneralSettingsStore => ({ autoCreateShareableLink: false, enableNotifications: true, enableNativeCameraPreview: false, - enableNewRecordingFlow: false, + enableNewRecordingFlow: true, + recordingPickerPreferenceSet: false, autoZoomOnClicks: false, custom_cursor_capture2: true, excludedWindows: [], @@ -202,15 +203,26 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { const handleChange = async ( key: K, value: (typeof settings)[K], + extra?: Partial, ) => { console.log(`Handling settings change for ${key}: ${value}`); setSettings(key as keyof GeneralSettingsStore, value); - generalSettingsStore.set({ [key]: value }); + generalSettingsStore.set({ [key]: value, ...(extra ?? {}) }); }; const ostype: OsType = type(); const excludedWindows = createMemo(() => settings.excludedWindows ?? []); + const recordingWindowVariant = () => + settings.enableNewRecordingFlow === false ? "old" : "new"; + + const updateRecordingWindowVariant = (variant: "new" | "old") => { + const shouldUseNew = variant === "new"; + if (settings.enableNewRecordingFlow === shouldUseNew) return; + handleChange("enableNewRecordingFlow", shouldUseNew, { + recordingPickerPreferenceSet: true, + }); + }; const matchesExclusion = ( exclusion: WindowExclusion, @@ -424,6 +436,33 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { )} + +
+ {( + [ + { id: "new", label: "New" }, + { id: "old", label: "Old" }, + ] as const + ).map((option) => ( + + ))} +
+
{ + if (payload.variant === "InputLost" && payload.input === "camera") { + setCameraDisconnected(true); + } else if ( + payload.variant === "InputRestored" && + payload.input === "camera" + ) { + setCameraDisconnected(false); + } + }); + return ( } + fallback={} > - + ); } -function NativeCameraPreviewPage() { +function NativeCameraPreviewPage(props: { disconnected: Accessor }) { const [state, setState] = makePersisted( createStore({ size: "sm", @@ -80,6 +95,9 @@ function NativeCameraPreviewPage() { data-tauri-drag-region class="flex relative flex-col w-screen h-screen cursor-move group" > + + +
@@ -144,7 +162,7 @@ function ControlButton( // Legacy stuff below -function LegacyCameraPreviewPage() { +function LegacyCameraPreviewPage(props: { disconnected: Accessor }) { const { rawOptions } = useRecordingOptions(); const [state, setState] = makePersisted( @@ -270,6 +288,9 @@ function LegacyCameraPreviewPage() { class="flex relative flex-col w-screen h-screen cursor-move group" style={{ "border-radius": cameraBorderRadius(state) }} > + + +
@@ -389,3 +410,16 @@ function cameraBorderRadius(state: CameraWindow.State) { if (state.size === "sm") return "3rem"; return "4rem"; } + +function CameraDisconnectedOverlay() { + return ( +
+

+ Camera disconnected +

+
+ ); +} diff --git a/apps/desktop/src/routes/capture-area.tsx b/apps/desktop/src/routes/capture-area.tsx index d6f724aaeb..43037488d0 100644 --- a/apps/desktop/src/routes/capture-area.tsx +++ b/apps/desktop/src/routes/capture-area.tsx @@ -19,6 +19,7 @@ import { createCropOptionsMenuItems, type Ratio, } from "~/components/Cropper"; +import SelectionHint from "~/components/selection-hint"; import { createOptionsQuery } from "~/utils/queries"; import type { DisplayId } from "~/utils/tauri"; import { emitTo } from "~/utils/tauriSpectaHack"; @@ -70,6 +71,15 @@ export default function CaptureArea() { const { rawOptions, setOptions } = createOptionsQuery(); + const hasStoredSelection = createMemo(() => { + const target = rawOptions.captureTarget; + if (target.variant !== "display") return false; + return ( + state.lastSelectedBounds?.some((entry) => entry.screenId === target.id) ?? + false + ); + }); + async function handleConfirm() { const currentBounds = cropperRef?.bounds(); if (!currentBounds) throw new Error("Cropper not initialized"); @@ -115,6 +125,13 @@ export default function CaptureArea() { } const [visible, setVisible] = createSignal(true); + + const showSelectionHint = createMemo(() => { + if (!visible()) return false; + if (hasStoredSelection()) return false; + const bounds = crop(); + return bounds.width <= 1 && bounds.height <= 1; + }); function close() { setVisible(false); setTimeout(async () => { @@ -255,6 +272,8 @@ export default function CaptureArea() {
+ + { const target = rawOptions.captureTarget; if (target.variant === "display") - return state.lastSelectedBounds?.find( - (m) => m.screenId === target.id, - )?.bounds; - else return undefined; + return ( + state.lastSelectedBounds?.find( + (m) => m.screenId === target.id, + )?.bounds ?? CROP_ZERO + ); + return CROP_ZERO; }} onContextMenu={(e) => showCropOptionsMenu(e, true)} /> diff --git a/apps/desktop/src/routes/in-progress-recording.tsx b/apps/desktop/src/routes/in-progress-recording.tsx index 9459c4136d..d6f8b45af0 100644 --- a/apps/desktop/src/routes/in-progress-recording.tsx +++ b/apps/desktop/src/routes/in-progress-recording.tsx @@ -1,5 +1,13 @@ import { createTimer } from "@solid-primitives/timer"; import { createMutation } from "@tanstack/solid-query"; +import { LogicalPosition } from "@tauri-apps/api/dpi"; +import { + CheckMenuItem, + Menu, + MenuItem, + PredefinedMenuItem, +} from "@tauri-apps/api/menu"; +import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWindow } from "@tauri-apps/api/window"; import * as dialog from "@tauri-apps/plugin-dialog"; import { type as ostype } from "@tauri-apps/plugin-os"; @@ -20,6 +28,11 @@ import { createOptionsQuery, } from "~/utils/queries"; import { handleRecordingResult } from "~/utils/recording"; +import type { + CameraInfo, + DeviceOrModelID, + RecordingInputKind, +} from "~/utils/tauri"; import { commands, events } from "~/utils/tauri"; type State = @@ -28,6 +41,8 @@ type State = | { variant: "paused" } | { variant: "stopped" }; +type RecordingInputState = Record; + declare global { interface Window { COUNTDOWN: number; @@ -35,6 +50,8 @@ declare global { } const MAX_RECORDING_FOR_FREE = 5 * 60 * 1000; +const NO_MICROPHONE = "No Microphone"; +const NO_WEBCAM = "No Webcam"; export default function () { const [state, setState] = createSignal( @@ -50,9 +67,52 @@ export default function () { const [time, setTime] = createSignal(Date.now()); const currentRecording = createCurrentRecordingQuery(); const optionsQuery = createOptionsQuery(); + const startedWithMicrophone = optionsQuery.rawOptions.micName != null; + const startedWithCameraInput = optionsQuery.rawOptions.cameraID != null; const auth = authStore.createQuery(); const audioLevel = createAudioInputLevel(); + const [disconnectedInputs, setDisconnectedInputs] = + createStore({ microphone: false, camera: false }); + const [recordingFailure, setRecordingFailure] = createSignal( + null, + ); + const [issuePanelVisible, setIssuePanelVisible] = createSignal(false); + const [issueKey, setIssueKey] = createSignal(""); + const [cameraWindowOpen, setCameraWindowOpen] = createSignal(false); + let settingsButtonRef: HTMLButtonElement | undefined; + + const hasDisconnectedInput = () => + disconnectedInputs.microphone || disconnectedInputs.camera; + + const issueMessages = createMemo(() => { + const issues: string[] = []; + if (disconnectedInputs.microphone) + issues.push( + "Microphone disconnected. Reconnect it to continue recording.", + ); + if (disconnectedInputs.camera) + issues.push("Camera disconnected. Reconnect it to continue recording."); + const failure = recordingFailure(); + if (failure) issues.push(failure); + return issues; + }); + + const hasRecordingIssue = () => issueMessages().length > 0; + + const toggleIssuePanel = () => { + if (!hasRecordingIssue()) return; + setIssuePanelVisible((visible) => !visible); + }; + + const dismissIssuePanel = () => setIssuePanelVisible(false); + const hasCameraInput = () => optionsQuery.rawOptions.cameraID != null; + const microphoneTitle = createMemo(() => { + if (disconnectedInputs.microphone) return "Microphone disconnected"; + if (optionsQuery.rawOptions.micName) + return `Microphone: ${optionsQuery.rawOptions.micName}`; + return "Microphone not configured"; + }); const [pauseResumes, setPauseResumes] = createStore< | [] @@ -62,16 +122,52 @@ export default function () { ] >([]); - createTauriEventListener(events.recordingEvent, (payload) => { - if (payload.variant === "Countdown") { - setState((s) => { - if (s.variant === "countdown") return { ...s, current: payload.value }; + createEffect(() => { + const messages = issueMessages(); + if (messages.length === 0) { + setIssueKey(""); + setIssuePanelVisible(false); + return; + } + const nextKey = messages.join("||"); + if (nextKey !== issueKey()) { + setIssueKey(nextKey); + setIssuePanelVisible(true); + } + }); - return s; - }); - } else if (payload.variant === "Started") { - setState({ variant: "recording" }); - setStart(Date.now()); + createTauriEventListener(events.recordingEvent, (payload) => { + switch (payload.variant) { + case "Countdown": + setState((s) => { + if (s.variant === "countdown") + return { ...s, current: payload.value }; + + return s; + }); + break; + case "Started": + setDisconnectedInputs({ microphone: false, camera: false }); + setRecordingFailure(null); + setState({ variant: "recording" }); + setStart(Date.now()); + break; + case "InputLost": { + const wasDisconnected = hasDisconnectedInput(); + setDisconnectedInputs(payload.input, () => true); + if (!wasDisconnected && state().variant === "recording") { + setPauseResumes((a) => [...a, { pause: Date.now() }]); + } + setState({ variant: "paused" }); + setTime(Date.now()); + break; + } + case "InputRestored": + setDisconnectedInputs(payload.input, () => false); + break; + case "Failed": + setRecordingFailure(payload.error); + break; } }); @@ -83,6 +179,25 @@ export default function () { 100, setInterval, ); + const refreshCameraWindowState = async () => { + try { + setCameraWindowOpen(await commands.isCameraWindowOpen()); + } catch { + setCameraWindowOpen(false); + } + }; + + createEffect(() => { + void refreshCameraWindowState(); + }); + + createTimer( + () => { + void refreshCameraWindowState(); + }, + 2000, + setInterval, + ); createEffect(() => { if ( @@ -151,6 +266,164 @@ export default function () { }, })); + const toggleCameraPreview = createMutation(() => ({ + mutationFn: async () => { + if (cameraWindowOpen()) { + const cameraWindow = await WebviewWindow.getByLabel("camera"); + if (cameraWindow) await cameraWindow.close(); + } else { + await commands.showWindow("Camera"); + } + await refreshCameraWindowState(); + }, + })); + + const pauseRecordingForDeviceChange = async () => { + if (state().variant !== "recording") return false; + await commands.pauseRecording(); + setPauseResumes((a) => [...a, { pause: Date.now() }]); + setState({ variant: "paused" }); + setTime(Date.now()); + return true; + }; + + const updateMicInput = createMutation(() => ({ + mutationFn: async (name: string | null) => { + if (!startedWithMicrophone && name !== null) return; + const previous = optionsQuery.rawOptions.micName ?? null; + if (previous === name) return; + await pauseRecordingForDeviceChange(); + optionsQuery.setOptions("micName", name); + try { + await commands.setMicInput(name); + } catch (error) { + optionsQuery.setOptions("micName", previous); + throw error; + } + }, + })); + + const updateCameraInput = createMutation(() => ({ + mutationFn: async (camera: CameraInfo | null) => { + if (!startedWithCameraInput && camera != null) return; + const selected = optionsQuery.rawOptions.cameraID ?? null; + if (!camera && selected === null) return; + if (camera && cameraMatchesSelection(camera, selected)) return; + await pauseRecordingForDeviceChange(); + const next = cameraInfoToId(camera); + const previous = cloneDeviceOrModelId(selected); + optionsQuery.setOptions("cameraID", next); + try { + await commands.setCameraInput(next); + if (!next && cameraWindowOpen()) { + const cameraWindow = await WebviewWindow.getByLabel("camera"); + if (cameraWindow) await cameraWindow.close(); + await refreshCameraWindowState(); + } + } catch (error) { + optionsQuery.setOptions("cameraID", previous); + throw error; + } + }, + })); + + const openRecordingSettingsMenu = async () => { + try { + let audioDevices: string[] = []; + let videoDevices: CameraInfo[] = []; + try { + audioDevices = await commands.listAudioDevices(); + } catch { + audioDevices = []; + } + try { + videoDevices = await commands.listCameras(); + } catch { + videoDevices = []; + } + const items: ( + | Awaited> + | Awaited> + | Awaited> + )[] = []; + items.push( + await CheckMenuItem.new({ + text: "Show Camera Preview", + checked: cameraWindowOpen(), + enabled: startedWithCameraInput && hasCameraInput(), + action: () => { + if (!startedWithCameraInput || !hasCameraInput()) return; + toggleCameraPreview.mutate(); + }, + }), + ); + items.push(await PredefinedMenuItem.new({ item: "Separator" })); + items.push( + await MenuItem.new({ + text: startedWithMicrophone + ? "Microphone" + : "Microphone (locked for this recording)", + enabled: false, + }), + ); + items.push( + await CheckMenuItem.new({ + text: NO_MICROPHONE, + checked: optionsQuery.rawOptions.micName == null, + enabled: startedWithMicrophone, + action: () => updateMicInput.mutate(null), + }), + ); + for (const name of audioDevices) { + items.push( + await CheckMenuItem.new({ + text: name, + checked: optionsQuery.rawOptions.micName === name, + enabled: startedWithMicrophone, + action: () => updateMicInput.mutate(name), + }), + ); + } + items.push(await PredefinedMenuItem.new({ item: "Separator" })); + items.push( + await MenuItem.new({ + text: startedWithCameraInput + ? "Webcam" + : "Webcam (locked for this recording)", + enabled: false, + }), + ); + items.push( + await CheckMenuItem.new({ + text: NO_WEBCAM, + checked: !hasCameraInput(), + enabled: startedWithCameraInput, + action: () => updateCameraInput.mutate(null), + }), + ); + for (const camera of videoDevices) { + items.push( + await CheckMenuItem.new({ + text: camera.display_name, + checked: cameraMatchesSelection( + camera, + optionsQuery.rawOptions.cameraID ?? null, + ), + enabled: startedWithCameraInput, + action: () => updateCameraInput.mutate(camera), + }), + ); + } + const menu = await Menu.new({ items }); + const rect = settingsButtonRef?.getBoundingClientRect(); + if (rect) + menu.popup(new LogicalPosition(rect.x, rect.y + rect.height + 4)); + else menu.popup(); + } catch (error) { + console.error("Failed to open recording settings menu", error); + } + }; + const adjustedTime = () => { if (state().variant === "countdown") return 0; let t = time() - start(); @@ -204,94 +477,169 @@ export default function () { }); return ( -
- - {(state) => ( -
- +
+ +
+ +
+ {issueMessages().map((message) => ( +

{message}

+ ))}
- )} + +
-
- - -
-
- {optionsQuery.rawOptions.micName != null ? ( - <> - -
-
-
- - ) : ( - +
+
+ + {(state) => ( +
+ +
)} +
+
+
+ + +
+
+ {optionsQuery.rawOptions.micName != null ? ( + disconnectedInputs.microphone ? ( + + ) : ( + <> + +
+
+
+ + ) + ) : ( + + )} +
+ + toggleIssuePanel()} + title={issueMessages().join(", ")} + aria-pressed={issuePanelVisible() ? "true" : "false"} + aria-label="Recording issues" + > + + + + + {(currentRecording.data?.mode === "studio" || + ostype() === "macos") && ( + togglePause.mutate()} + title={ + state().variant === "paused" + ? "Resume recording" + : "Pause recording" + } + aria-label={ + state().variant === "paused" + ? "Resume recording" + : "Pause recording" + } + > + {state().variant === "paused" ? ( + + ) : ( + + )} + + )} + + restartRecording.mutate()} + title="Restart recording" + aria-label="Restart recording" + > + + + deleteRecording.mutate()} + title="Delete recording" + aria-label="Delete recording" + > + + + { + settingsButtonRef = el ?? undefined; + }} + onClick={() => { + void openRecordingSettingsMenu(); + }} + title="Recording settings" + aria-label="Recording settings" + > + + +
+
- - {(currentRecording.data?.mode === "studio" || - ostype() === "macos") && ( - togglePause.mutate()} - > - {state().variant === "paused" ? ( - - ) : ( - - )} - - )} - - restartRecording.mutate()} - > - - - deleteRecording.mutate()} +
- - + +
-
- -
); } @@ -379,3 +727,26 @@ function Countdown(props: { from: number; current: number }) {
); } + +function cameraMatchesSelection( + camera: CameraInfo, + selected?: DeviceOrModelID | null, +) { + if (!selected) return false; + if ("DeviceID" in selected) return selected.DeviceID === camera.device_id; + return camera.model_id != null && selected.ModelID === camera.model_id; +} + +function cameraInfoToId(camera: CameraInfo | null): DeviceOrModelID | null { + if (!camera) return null; + if (camera.model_id) return { ModelID: camera.model_id }; + return { DeviceID: camera.device_id }; +} + +function cloneDeviceOrModelId( + id: DeviceOrModelID | null, +): DeviceOrModelID | null { + if (!id) return null; + if ("DeviceID" in id) return { DeviceID: id.DeviceID }; + return { ModelID: id.ModelID }; +} diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 55b9cec228..6a08d85c55 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -1,9 +1,8 @@ import { Button } from "@cap/ui-solid"; import { createEventListener } from "@solid-primitives/event-listener"; import { createElementSize } from "@solid-primitives/resize-observer"; -import { createScheduled, debounce } from "@solid-primitives/scheduled"; import { useSearchParams } from "@solidjs/router"; -import { useQuery } from "@tanstack/solid-query"; +import { createMutation, useQuery } from "@tanstack/solid-query"; import { LogicalPosition } from "@tauri-apps/api/dpi"; import { emit } from "@tauri-apps/api/event"; import { @@ -14,6 +13,7 @@ import { } from "@tauri-apps/api/menu"; import { type as ostype } from "@tauri-apps/plugin-os"; import { + createEffect, createMemo, createSignal, Match, @@ -33,15 +33,26 @@ import { type Ratio, } from "~/components/Cropper"; import ModeSelect from "~/components/ModeSelect"; +import SelectionHint from "~/components/selection-hint"; import { authStore, generalSettingsStore } from "~/store"; -import { createOptionsQuery, createOrganizationsQuery } from "~/utils/queries"; import { + createCameraMutation, + createOptionsQuery, + createOrganizationsQuery, + listAudioDevices, + listVideoDevices, +} from "~/utils/queries"; +import { + type CameraInfo, commands, + type DeviceOrModelID, type DisplayId, events, type ScreenCaptureTarget, type TargetUnderCursor, } from "~/utils/tauri"; +import CameraSelect from "./(window-chrome)/new-main/CameraSelect"; +import MicrophoneSelect from "./(window-chrome)/new-main/MicrophoneSelect"; import { RecordingOptionsProvider, useRecordingOptions, @@ -53,6 +64,15 @@ const capitalize = (str: string) => { return str.charAt(0).toUpperCase() + str.slice(1); }; +const findCamera = (cameras: CameraInfo[], id?: DeviceOrModelID | null) => { + if (!id) return undefined; + return cameras.find((camera) => + "DeviceID" in id + ? camera.device_id === id.DeviceID + : camera.model_id === id.ModelID, + ); +}; + export default function () { return ( @@ -130,11 +150,66 @@ function Inner() { })); const [crop, setCrop] = createSignal(CROP_ZERO); - + type AreaTarget = Extract; + const [pendingAreaTarget, setPendingAreaTarget] = + createSignal(null); const [initialAreaBounds, setInitialAreaBounds] = createSignal< CropBounds | undefined >(undefined); + createEffect(() => { + const target = options.captureTarget; + if ( + target.variant === "area" && + params.displayId && + target.screen === params.displayId + ) { + setPendingAreaTarget({ + variant: "area", + screen: target.screen, + bounds: { + position: { + x: target.bounds.position.x, + y: target.bounds.position.y, + }, + size: { + width: target.bounds.size.width, + height: target.bounds.size.height, + }, + }, + }); + } + }); + + createEffect((prevMode: "display" | "window" | "area" | null | undefined) => { + const mode = options.targetMode ?? null; + if (prevMode === "area" && mode !== "area") { + const target = pendingAreaTarget(); + if (target) { + setOptions( + "captureTarget", + reconcile({ + variant: "area", + screen: target.screen, + bounds: { + position: { + x: target.bounds.position.x, + y: target.bounds.position.y, + }, + size: { + width: target.bounds.size.width, + height: target.bounds.size.height, + }, + }, + }), + ); + } + setPendingAreaTarget(null); + setInitialAreaBounds(undefined); + } + return mode; + }); + const unsubOnEscapePress = events.onEscapePress.listen(() => { setOptions("targetMode", null); commands.closeTargetSelectOverlays(); @@ -153,22 +228,25 @@ function Inner() { data-over={targetUnderCursor.display_id === displayId()} class="relative w-screen h-screen flex flex-col items-center justify-center data-[over='true']:bg-blue-600/40 transition-colors" > -
+
{(display) => ( - <> - +
+ + {display.name || "Monitor"} {(size) => ( - - {`${size().width}x${size().height} · ${display.refresh_rate}FPS`} + + {`${size().width}x${size().height} · ${ + display.refresh_rate + }FPS`} )} - +
)}
@@ -202,7 +280,7 @@ function Inner() { {(windowUnderCursor) => (
{ + setOptions( + "captureTarget", + reconcile({ + variant: "window", + id: windowUnderCursor.id, + }), + ); + setOptions("targetMode", null); + commands.closeTargetSelectOverlays(); + }} >
-
+
{(icon) => ( @@ -234,23 +323,43 @@ function Inner() { {`${windowUnderCursor.bounds.size.width}x${windowUnderCursor.bounds.size.height}`}
- +
e.stopPropagation()}> + +