From f1bff20f5374b2d48bcf0a3d685948703bbbdda9 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 14 Nov 2025 11:50:36 +0000 Subject: [PATCH 01/32] Replace retry crate with manual retry loop in muxer --- crates/recording/src/output_pipeline/macos.rs | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/recording/src/output_pipeline/macos.rs b/crates/recording/src/output_pipeline/macos.rs index d0623014e9..1f1df65368 100644 --- a/crates/recording/src/output_pipeline/macos.rs +++ b/crates/recording/src/output_pipeline/macos.rs @@ -5,7 +5,6 @@ use crate::{ use anyhow::anyhow; use cap_enc_avfoundation::QueueFrameError; use cap_media_info::{AudioInfo, VideoInfo}; -use retry::{OperationResult, delay::Fixed}; use std::{ path::PathBuf, sync::{Arc, Mutex, atomic::AtomicBool}, @@ -77,16 +76,18 @@ impl VideoMuxer for AVFoundationMp4Muxer { mp4.resume(); } - retry::retry(Fixed::from_millis(3).take(3), || { + loop { match mp4.queue_video_frame(frame.sample_buf.clone(), timestamp) { - Ok(v) => OperationResult::Ok(v), + Ok(()) => break, Err(QueueFrameError::NotReadyForMore) => { - OperationResult::Retry(QueueFrameError::NotReadyForMore) + std::thread::sleep(Duration::from_millis(2)); + continue; } - Err(e) => OperationResult::Err(e), + Err(e) => return Err(anyhow!("send_video_frame/{e}")), } - }) - .map_err(|e| anyhow!("send_video_frame/{e}")) + } + + Ok(()) } } @@ -94,15 +95,17 @@ impl AudioMuxer for AVFoundationMp4Muxer { fn send_audio_frame(&mut self, frame: AudioFrame, timestamp: Duration) -> anyhow::Result<()> { let mut mp4 = self.0.lock().map_err(|e| anyhow!("{e}"))?; - retry::retry(Fixed::from_millis(3).take(3), || { + loop { match mp4.queue_audio_frame(&frame.inner, timestamp) { - Ok(v) => OperationResult::Ok(v), + Ok(()) => break, Err(QueueFrameError::NotReadyForMore) => { - OperationResult::Retry(QueueFrameError::NotReadyForMore) + std::thread::sleep(Duration::from_millis(2)); + continue; } - Err(e) => OperationResult::Err(e), + Err(e) => return Err(anyhow!("send_audio_frame/{e}")), } - }) - .map_err(|e| anyhow!("send_audio_frame/{e}")) + } + + Ok(()) } } From f42b27763220d0217578fdfe589fa3c6e7a87b22 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 14 Nov 2025 12:15:43 +0000 Subject: [PATCH 02/32] Improve macOS screen capture error handling --- .../platform/macos/sc_shareable_content.rs | 18 ++++- apps/desktop/src-tauri/src/recording.rs | 68 +++++++++++++++++-- .../src/sources/screen_capture/macos.rs | 2 +- 3 files changed, 79 insertions(+), 9 deletions(-) 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..3cf8f4854e 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}, @@ -99,6 +101,58 @@ 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.downcast_ref::() + .is_some_and(|source_error| matches!(source_error, SourceError::AsContentFilter)) +} + impl InProgressRecording { pub fn capture_target(&self) -> &ScreenCaptureTarget { match self { @@ -486,12 +540,8 @@ pub async fn start_recording( }; #[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 +676,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 diff --git a/crates/recording/src/sources/screen_capture/macos.rs b/crates/recording/src/sources/screen_capture/macos.rs index 0cbab83e48..2f0566a949 100644 --- a/crates/recording/src/sources/screen_capture/macos.rs +++ b/crates/recording/src/sources/screen_capture/macos.rs @@ -45,7 +45,7 @@ impl ScreenCaptureFormat for CMSampleBufferCapture { } #[derive(Debug, thiserror::Error)] -enum SourceError { +pub enum SourceError { #[error("NoDisplay: Id '{0}'")] NoDisplay(DisplayId), #[error("AsContentFilter")] From 983515192920fbdb46024e246043748f0a2035a4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 14 Nov 2025 13:23:19 +0000 Subject: [PATCH 03/32] fmt --- apps/desktop/src-tauri/src/lib.rs | 265 ++++++++++++++++-- apps/desktop/src-tauri/src/recording.rs | 13 + .../src/routes/in-progress-recording.tsx | 202 ++++++++----- apps/desktop/src/utils/tauri.ts | 3 +- packages/ui-solid/src/auto-imports.d.ts | 1 + 5 files changed, 393 insertions(+), 91 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index b2152ba350..5aef236e7e 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,6 +246,66 @@ 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(()); + } + + if matches!(kind, RecordingInputKind::Microphone) { + self.ensure_selected_mic_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(()) + } } #[tauri::command] @@ -261,6 +337,16 @@ async fn set_mic_input(state: MutableState<'_, App>, label: Option) -> R { 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(()) @@ -282,7 +368,7 @@ async fn set_camera_input( ) -> Result<(), String> { let camera_feed = state.read().await.camera_feed.clone(); - match id { + match &id { None => { camera_feed .ask(feeds::camera::RemoveInput) @@ -297,7 +383,7 @@ async fn set_camera_input( .ok(); camera_feed - .ask(feeds::camera::SetInput { id }) + .ask(feeds::camera::SetInput { id: id.clone() }) .await .map_err(|e| e.to_string())? .await @@ -305,19 +391,154 @@ async fn set_camera_input( } } + { + let mut app = state.write().await; + app.selected_camera_id = id; + 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 mut 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}"); + } + + if let Err(restart_err) = app.restart_mic_feed().await { + warn!("Failed to restart microphone feed: {restart_err}"); + } + } + }); +} + +fn spawn_device_watchers(app_handle: AppHandle) { + spawn_microphone_watcher(app_handle.clone()); + spawn_camera_watcher(app_handle); +} + +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 { + if let Some(label) = label { + let available = microphone::MicrophoneFeed::list().contains_key(&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}"); + } + } + } + } - error!("Mic feed actor error: {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 { + if let Some(id) = camera_id { + let available = is_camera_available(&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 +2219,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_feed = MicrophoneFeed::spawn(MicrophoneFeed::new(error_tx)); + let (mic_error_tx, mic_error_rx) = flume::bounded(1); - 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 +2401,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 +2414,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/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 3cf8f4854e..95fa1fcbfa 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -317,6 +317,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 { @@ -324,6 +331,8 @@ pub enum RecordingEvent { Started, Stopped, Failed { error: String }, + InputLost { input: RecordingInputKind }, + InputRestored { input: RecordingInputKind }, } #[derive(Serialize, Type)] @@ -539,6 +548,8 @@ 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 mut shareable_content = acquire_shareable_content_for_target(&inputs.capture_target).await?; @@ -964,6 +975,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/routes/in-progress-recording.tsx b/apps/desktop/src/routes/in-progress-recording.tsx index 9459c4136d..af61a0a8dd 100644 --- a/apps/desktop/src/routes/in-progress-recording.tsx +++ b/apps/desktop/src/routes/in-progress-recording.tsx @@ -20,6 +20,7 @@ import { createOptionsQuery, } from "~/utils/queries"; import { handleRecordingResult } from "~/utils/recording"; +import type { RecordingInputKind } from "~/utils/tauri"; import { commands, events } from "~/utils/tauri"; type State = @@ -28,6 +29,8 @@ type State = | { variant: "paused" } | { variant: "stopped" }; +type RecordingInputState = Record; + declare global { interface Window { COUNTDOWN: number; @@ -53,6 +56,11 @@ export default function () { const auth = authStore.createQuery(); const audioLevel = createAudioInputLevel(); + const [disconnectedInputs, setDisconnectedInputs] = + createStore({ microphone: false, camera: false }); + + const hasDisconnectedInput = () => + disconnectedInputs.microphone || disconnectedInputs.camera; const [pauseResumes, setPauseResumes] = createStore< | [] @@ -63,15 +71,33 @@ export default function () { >([]); createTauriEventListener(events.recordingEvent, (payload) => { - if (payload.variant === "Countdown") { - setState((s) => { - if (s.variant === "countdown") return { ...s, current: payload.value }; - - return s; - }); - } else if (payload.variant === "Started") { - setState({ variant: "recording" }); - setStart(Date.now()); + 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 }); + 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; } }); @@ -218,72 +244,77 @@ export default function () { )} -
- - -
-
- {optionsQuery.rawOptions.micName != null ? ( - <> - -
-
-
- - ) : ( - +
+ + + +
+ + +
+
+ {optionsQuery.rawOptions.micName != null ? ( + <> + +
+
+
+ + ) : ( + + )} +
+ + {(currentRecording.data?.mode === "studio" || + ostype() === "macos") && ( + togglePause.mutate()} + > + {state().variant === "paused" ? ( + + ) : ( + + )} + )} -
- {(currentRecording.data?.mode === "studio" || - ostype() === "macos") && ( togglePause.mutate()} + disabled={restartRecording.isPending} + onClick={() => restartRecording.mutate()} > - {state().variant === "paused" ? ( - - ) : ( - - )} + - )} - - restartRecording.mutate()} - > - - - deleteRecording.mutate()} - > - - + deleteRecording.mutate()} + > + + +
) { ); } +function DisconnectedNotice(props: { inputs: RecordingInputState }) { + const affectedInputs = () => { + const list: string[] = []; + if (props.inputs.microphone) list.push("microphone"); + if (props.inputs.camera) list.push("camera"); + return list; + }; + + const deviceLabel = () => { + const list = affectedInputs(); + if (list.length === 0) return ""; + if (list.length === 1) return `${list[0]} disconnected`; + return `${list.join(" and ")} disconnected`; + }; + + const instructionLabel = () => + affectedInputs().length > 1 ? "these devices" : "this device"; + + return ( +
+ +
+ Recording paused โ€” {deviceLabel()}. + + Reconnect {instructionLabel()} and then resume or stop the recording. + +
+
+ ); +} + function formatTime(secs: number) { const minutes = Math.floor(secs / 60); const seconds = Math.floor(secs % 60); diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 9f2c25ff4c..55e2434a1e 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -448,7 +448,8 @@ export type ProjectConfiguration = { aspectRatio: AspectRatio | null; background export type ProjectRecordingsMeta = { segments: SegmentRecordings[] } export type RecordingAction = "Started" | "InvalidAuthentication" | "UpgradeRequired" export type RecordingDeleted = { path: string } -export type RecordingEvent = { variant: "Countdown"; value: number } | { variant: "Started" } | { variant: "Stopped" } | { variant: "Failed"; error: string } +export type RecordingEvent = { variant: "Countdown"; value: number } | { variant: "Started" } | { variant: "Stopped" } | { variant: "Failed"; error: string } | { variant: "InputLost"; input: RecordingInputKind } | { variant: "InputRestored"; input: RecordingInputKind } +export type RecordingInputKind = "microphone" | "camera" export type RecordingMeta = (StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadMeta | null } 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" diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 57ce5370b4..9770d9c340 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -76,6 +76,7 @@ declare global { const IconHugeiconsEaseCurveControlPoints: typeof import('~icons/hugeicons/ease-curve-control-points.jsx')['default'] const IconIcBaselineMonitor: typeof import("~icons/ic/baseline-monitor.jsx")["default"] const IconIcRoundSearch: typeof import("~icons/ic/round-search.jsx")["default"] + const IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle.jsx')['default'] const IconLucideBell: typeof import('~icons/lucide/bell.jsx')['default'] const IconLucideBug: typeof import('~icons/lucide/bug.jsx')['default'] const IconLucideCamera: typeof import("~icons/lucide/camera.jsx")["default"] From 7ef58fb70022e20611373148c64e77c0aa342455 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 13:55:15 +0000 Subject: [PATCH 04/32] Refactor main window layout and add picker window hide/show --- .../routes/(window-chrome)/new-main/index.tsx | 200 ++++++++++-------- 1 file changed, 117 insertions(+), 83 deletions(-) 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..4131d1a541 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(); + + let hasHiddenMainWindowForPicker = false; + createEffect(() => { + const pickerActive = rawOptions.targetMode != null; + if (pickerActive && !hasHiddenMainWindowForPicker) { + hasHiddenMainWindowForPicker = true; + void getCurrentWindow().hide(); + } else if (!pickerActive && hasHiddenMainWindowForPicker) { + hasHiddenMainWindowForPicker = false; + const currentWindow = getCurrentWindow(); + void currentWindow.show(); + void currentWindow.setFocus(); + } + }); + onCleanup(() => { + if (!hasHiddenMainWindowForPicker) return; + hasHiddenMainWindowForPicker = false; + void getCurrentWindow().show(); + }); const [displayMenuOpen, setDisplayMenuOpen] = createSignal(false); const [windowMenuOpen, setWindowMenuOpen] = createSignal(false); @@ -763,11 +781,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(); + }} + /> + ) + } + + +
); } From 833dce08d2a8bd6c55cdb86eaecbe8ae863c585b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 14:43:16 +0000 Subject: [PATCH 05/32] Set new recording picker to default --- .../desktop/src-tauri/src/general_settings.rs | 12 +- .../(window-chrome)/settings/experimental.tsx | 15 +- .../(window-chrome)/settings/general.tsx | 43 +++- .../src/routes/target-select-overlay.tsx | 198 ++++++++++++------ apps/desktop/src/utils/tauri.ts | 2 +- packages/ui-solid/icons/monitor.svg | 45 ++++ packages/ui-solid/src/auto-imports.d.ts | 1 + 7 files changed, 233 insertions(+), 83 deletions(-) create mode 100644 packages/ui-solid/icons/monitor.svg diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index fbd6189853..ae8800e43a 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,6 +252,11 @@ pub fn init(app: &AppHandle) { } }; + if !store.recording_picker_preference_set { + store.enable_new_recording_flow = true; + store.recording_picker_preference_set = true; + } + store.save(app).unwrap(); println!("GeneralSettingsState managed"); 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) => ( + + ))} +
+
{ 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 ( @@ -153,22 +172,23 @@ 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`} )} - +
)}
@@ -202,7 +222,7 @@ function Inner() { {(windowUnderCursor) => (
-
+
{(icon) => ( @@ -379,7 +399,7 @@ function Inner() { }); return ( -
+
listVideoDevices); + const mics = useQuery(() => listAudioDevices); + const setMicInput = createMutation(() => ({ + mutationFn: async (name: string | null) => { + await commands.setMicInput(name); + setOptions("micName", name); + }, + })); + const setCamera = createCameraMutation(); + + const selectedCamera = createMemo(() => { + if (!rawOptions.cameraID) return null; + return findCamera(cameras.data ?? [], rawOptions.cameraID) ?? null; + }); + + const selectedMicName = createMemo(() => { + if (!rawOptions.micName) return null; + return ( + (mics.data ?? []).find((name) => name === rawOptions.micName) ?? null + ); + }); const menuModes = async () => await Menu.new({ @@ -510,63 +551,92 @@ function RecordingControls(props: { return ( <> -
-
{ - 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; - } - - commands.startRecording({ - capture_target: props.target, - mode: rawOptions.mode, - capture_system_audio: rawOptions.captureSystemAudio, - }); - }} - > -
- {rawOptions.mode === "studio" ? ( - - ) : ( - - )} -
- - {rawOptions.mode === "instant" && !auth.data - ? "Sign In To Use" - : "Start Recording"} - - - {`${capitalize(rawOptions.mode)} Mode`} - +
+
+
+
{ + 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; + } + + commands.startRecording({ + capture_target: props.target, + mode: rawOptions.mode, + capture_system_audio: rawOptions.captureSystemAudio, + }); + }} + > +
+ {rawOptions.mode === "studio" ? ( + + ) : ( + + )} +
+ + {rawOptions.mode === "instant" && !auth.data + ? "Sign In To Use" + : "Start Recording"} + + + {`${capitalize(rawOptions.mode)} Mode`} + +
+
+
showMenu(menuModes(), e)} + onClick={(e) => showMenu(menuModes(), e)} + > + +
+
+
showMenu(preRecordingMenu(), e)} + onClick={(e) => showMenu(preRecordingMenu(), e)} + > +
-
-
showMenu(menuModes(), e)} - onClick={(e) => showMenu(menuModes(), e)} - > -
-
showMenu(preRecordingMenu(), e)} - onClick={(e) => showMenu(preRecordingMenu(), e)} - > - +
+
+ { + if (!camera) setCamera.mutate(null); + else if (camera.model_id) + setCamera.mutate({ ModelID: camera.model_id }); + else setCamera.mutate({ DeviceID: camera.device_id }); + }} + /> + setMicInput.mutate(value)} + /> +
diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 55e2434a1e..57ce4676f9 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -402,7 +402,7 @@ export type ExportSettings = ({ format: "Mp4" } & Mp4ExportSettings) | ({ format export type FileType = "recording" | "screenshot" export type Flags = { captions: boolean } export type FramesRendered = { renderedCount: number; totalFrames: number; type: "FramesRendered" } -export type GeneralSettingsStore = { instanceId?: string; uploadIndividualFiles?: boolean; hideDockIcon?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; commercialLicense?: CommercialLicense | null; lastVersion?: string | null; windowTransparency?: boolean; postStudioRecordingBehaviour?: PostStudioRecordingBehaviour; mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour; custom_cursor_capture2?: boolean; serverUrl?: string; recordingCountdown?: number | null; enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean; enableNewRecordingFlow: boolean; postDeletionBehaviour?: PostDeletionBehaviour; excludedWindows?: WindowExclusion[]; deleteInstantRecordingsAfterUpload?: boolean; instantModeMaxResolution?: number } +export type GeneralSettingsStore = { instanceId?: string; uploadIndividualFiles?: boolean; hideDockIcon?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; commercialLicense?: CommercialLicense | null; lastVersion?: string | null; windowTransparency?: boolean; postStudioRecordingBehaviour?: PostStudioRecordingBehaviour; mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour; custom_cursor_capture2?: boolean; serverUrl?: string; recordingCountdown?: number | null; enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean; enableNewRecordingFlow: boolean; recordingPickerPreferenceSet?: boolean; postDeletionBehaviour?: PostDeletionBehaviour; excludedWindows?: WindowExclusion[]; deleteInstantRecordingsAfterUpload?: boolean; instantModeMaxResolution?: number } export type GifExportSettings = { fps: number; resolution_base: XY; quality: GifQuality | null } export type GifQuality = { /** diff --git a/packages/ui-solid/icons/monitor.svg b/packages/ui-solid/icons/monitor.svg new file mode 100644 index 0000000000..3687f88b1a --- /dev/null +++ b/packages/ui-solid/icons/monitor.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 9770d9c340..a1d9203796 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -46,6 +46,7 @@ declare global { const IconCapLogoFullDark: typeof import('~icons/cap/logo-full-dark.jsx')['default'] const IconCapMessageBubble: typeof import('~icons/cap/message-bubble.jsx')['default'] const IconCapMicrophone: typeof import('~icons/cap/microphone.jsx')['default'] + const IconCapMonitor: typeof import('~icons/cap/monitor.jsx')['default'] const IconCapMoreVertical: typeof import('~icons/cap/more-vertical.jsx')['default'] const IconCapMoveLeft: typeof import("~icons/cap/move-left.jsx")["default"] const IconCapMoveRight: typeof import("~icons/cap/move-right.jsx")["default"] From 34a114870bc7eb60191816f6a3d36669c9b14c6d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 14:45:48 +0000 Subject: [PATCH 06/32] Remove shadow from recording controls button --- apps/desktop/src/routes/target-select-overlay.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index a193a16912..0fe3e178d4 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -184,7 +184,9 @@ function Inner() { {(size) => ( - {`${size().width}x${size().height} ยท ${display.refresh_rate}FPS`} + {`${size().width}x${size().height} ยท ${ + display.refresh_rate + }FPS`} )} @@ -565,7 +567,7 @@ function RecordingControls(props: {
{ if (rawOptions.mode === "instant" && !auth.data) { emit("start-sign-in"); From 04443e5a5009e8a4e37a27a0deb0a38b3a6b3c5b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 16:46:49 +0000 Subject: [PATCH 07/32] Refactor target mode toggling in main window --- .../routes/(window-chrome)/new-main/index.tsx | 1678 ++++++++--------- .../src/routes/target-select-overlay.tsx | 1405 +++++++------- 2 files changed, 1606 insertions(+), 1477 deletions(-) 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 4131d1a541..69ed16f5ae 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -4,27 +4,27 @@ import { useNavigate } from "@solidjs/router"; import { createMutation, useQuery } from "@tanstack/solid-query"; import { listen } from "@tauri-apps/api/event"; import { - getAllWebviewWindows, - WebviewWindow, + getAllWebviewWindows, + WebviewWindow, } from "@tauri-apps/api/webviewWindow"; import { - getCurrentWindow, - LogicalSize, - primaryMonitor, + 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, + 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 @@ -35,25 +35,25 @@ import { Input } from "~/routes/editor/ui"; import { authStore, generalSettingsStore } from "~/store"; import { createSignInMutation } from "~/utils/auth"; import { - createCameraMutation, - createCurrentRecordingQuery, - createLicenseQuery, - listAudioDevices, - listDisplaysWithThumbnails, - listScreens, - listVideoDevices, - listWindows, - listWindowsWithThumbnails, + 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, + 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"; @@ -62,8 +62,8 @@ import IconMaterialSymbolsScreenshotFrame2Rounded from "~icons/material-symbols/ import IconMdiMonitor from "~icons/mdi/monitor"; import { WindowChromeHeader } from "../Context"; import { - RecordingOptionsProvider, - useRecordingOptions, + RecordingOptionsProvider, + useRecordingOptions, } from "../OptionsContext"; import CameraSelect from "./CameraSelect"; import ChangelogButton from "./ChangeLogButton"; @@ -74,862 +74,846 @@ import TargetMenuGrid from "./TargetMenuGrid"; import TargetTypeButton from "./TargetTypeButton"; function getWindowSize() { - return { - width: 290, - height: 310, - }; + return { + width: 290, + height: 310, + }; } 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 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" + CaptureWindow, + "id" | "owner_name" | "name" | "bounds" | "refresh_rate" >; const createWindowSignature = ( - list?: readonly WindowListItem[], + list?: readonly WindowListItem[] ): string | undefined => { - if (!list) return undefined; - - return list - .map((item) => { - const { position, size } = item.bounds; - return [ - item.id, - item.owner_name, - item.name, - position.x, - position.y, - size.width, - size.height, - item.refresh_rate, - ].join(":"); - }) - .join("|"); + if (!list) return undefined; + + return list + .map((item) => { + const { position, size } = item.bounds; + return [ + item.id, + item.owner_name, + item.name, + position.x, + position.y, + size.width, + size.height, + item.refresh_rate, + ].join(":"); + }) + .join("|"); }; type DisplayListItem = Pick; const createDisplaySignature = ( - list?: readonly DisplayListItem[], + list?: readonly DisplayListItem[] ): string | undefined => { - if (!list) return 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 = - | { - variant: "display"; - targets?: CaptureDisplayWithThumbnail[]; - onSelect: (target: CaptureDisplayWithThumbnail) => void; - } - | { - variant: "window"; - targets?: CaptureWindowWithThumbnail[]; - onSelect: (target: CaptureWindowWithThumbnail) => void; - }; + | { + variant: "display"; + targets?: CaptureDisplayWithThumbnail[]; + onSelect: (target: CaptureDisplayWithThumbnail) => void; + } + | { + variant: "window"; + targets?: CaptureWindowWithThumbnail[]; + onSelect: (target: CaptureWindowWithThumbnail) => void; + }; type SharedTargetMenuProps = { - isLoading: boolean; - errorMessage?: string; - disabled: boolean; - onBack: () => void; + isLoading: boolean; + errorMessage?: string; + disabled: boolean; + onBack: () => void; }; 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 filteredWindowTargets = createMemo(() => { - if (props.variant !== "window") 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.owner_name) || - matchesQuery(target.id), - ); - }); - - return ( -
-
-
props.onBack()} - class="flex gap-1 items-center rounded-md px-1.5 text-xs + 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 filteredWindowTargets = createMemo(() => { + if (props.variant !== "window") 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.owner_name) || + matchesQuery(target.id) + ); + }); + + return ( +
+
+
props.onBack()} + class="flex gap-1 items-center rounded-md px-1.5 text-xs text-gray-11 transition-opacity hover:opacity-70 hover:text-gray-12 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-9 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-1" - > - - Back -
-
- - setSearch(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Escape" && search()) { - event.preventDefault(); - setSearch(""); - } - }} - placeholder={placeholder} - autoCapitalize="off" - autocorrect="off" - autocomplete="off" - spellcheck={false} - aria-label={placeholder} - /> -
-
-
-
- {props.variant === "display" ? ( - - ) : ( - - )} -
-
-
- ); + > + + Back +
+
+ + setSearch(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Escape" && search()) { + event.preventDefault(); + setSearch(""); + } + }} + placeholder={placeholder} + autoCapitalize="off" + autocorrect="off" + autocomplete="off" + spellcheck={false} + aria-label={placeholder} + /> +
+
+
+
+ {props.variant === "display" ? ( + + ) : ( + + )} +
+
+
+ ); } export default function () { - const generalSettings = generalSettingsStore.createQuery(); - - const navigate = useNavigate(); - createEventListener(window, "focus", () => { - if (generalSettings.data?.enableNewRecordingFlow === false) navigate("/"); - }); - - return ( - - - - ); + const generalSettings = generalSettingsStore.createQuery(); + + const navigate = useNavigate(); + createEventListener(window, "focus", () => { + if (generalSettings.data?.enableNewRecordingFlow === false) navigate("/"); + }); + + return ( + + + + ); } let hasChecked = false; function createUpdateCheck() { - if (import.meta.env.DEV) return; + if (import.meta.env.DEV) return; - const navigate = useNavigate(); + const navigate = useNavigate(); - onMount(async () => { - if (hasChecked) return; - hasChecked = true; + onMount(async () => { + if (hasChecked) return; + hasChecked = true; - await new Promise((res) => setTimeout(res, 1000)); + await new Promise((res) => setTimeout(res, 1000)); - const update = await updater.check(); - if (!update) return; + const update = await updater.check(); + if (!update) return; - const shouldUpdate = await dialog.confirm( - `Version ${update.version} of Cap is available, would you like to install it?`, - { title: "Update Cap", okLabel: "Update", cancelLabel: "Ignore" }, - ); + const shouldUpdate = await dialog.confirm( + `Version ${update.version} of Cap is available, would you like to install it?`, + { title: "Update Cap", okLabel: "Update", cancelLabel: "Ignore" } + ); - if (!shouldUpdate) return; - navigate("/update"); - }); + if (!shouldUpdate) return; + navigate("/update"); + }); } function Page() { - const { rawOptions, setOptions } = useRecordingOptions(); - const currentRecording = createCurrentRecordingQuery(); - const isRecording = () => !!currentRecording.data; - const auth = authStore.createQuery(); - - let hasHiddenMainWindowForPicker = false; - createEffect(() => { - const pickerActive = rawOptions.targetMode != null; - if (pickerActive && !hasHiddenMainWindowForPicker) { - hasHiddenMainWindowForPicker = true; - void getCurrentWindow().hide(); - } else if (!pickerActive && hasHiddenMainWindowForPicker) { - hasHiddenMainWindowForPicker = false; - const currentWindow = getCurrentWindow(); - void currentWindow.show(); - void currentWindow.setFocus(); - } - }); - onCleanup(() => { - if (!hasHiddenMainWindowForPicker) return; - hasHiddenMainWindowForPicker = false; - void getCurrentWindow().show(); - }); - - const [displayMenuOpen, setDisplayMenuOpen] = createSignal(false); - const [windowMenuOpen, setWindowMenuOpen] = createSignal(false); - const activeMenu = createMemo<"display" | "window" | null>(() => { - if (displayMenuOpen()) return "display"; - if (windowMenuOpen()) return "window"; - return null; - }); - const [hasOpenedDisplayMenu, setHasOpenedDisplayMenu] = createSignal(false); - const [hasOpenedWindowMenu, setHasOpenedWindowMenu] = createSignal(false); - - let displayTriggerRef: HTMLButtonElement | undefined; - let windowTriggerRef: HTMLButtonElement | undefined; - - const displayTargets = useQuery(() => ({ - ...listDisplaysWithThumbnails, - refetchInterval: false, - })); - - const windowTargets = useQuery(() => ({ - ...listWindowsWithThumbnails, - refetchInterval: false, - })); - - const screens = useQuery(() => listScreens); - const windows = useQuery(() => listWindows); - - const hasDisplayTargetsData = () => displayTargets.status === "success"; - const hasWindowTargetsData = () => windowTargets.status === "success"; - - const existingDisplayIds = createMemo(() => { - const currentScreens = screens.data; - if (!currentScreens) return undefined; - return new Set(currentScreens.map((screen) => screen.id)); - }); - - const displayTargetsData = createMemo(() => { - if (!hasDisplayTargetsData()) return undefined; - const ids = existingDisplayIds(); - if (!ids) return displayTargets.data; - return displayTargets.data?.filter((target) => ids.has(target.id)); - }); - - const existingWindowIds = createMemo(() => { - const currentWindows = windows.data; - if (!currentWindows) return undefined; - return new Set(currentWindows.map((win) => win.id)); - }); - - const windowTargetsData = createMemo(() => { - if (!hasWindowTargetsData()) return undefined; - const ids = existingWindowIds(); - if (!ids) return windowTargets.data; - return windowTargets.data?.filter((target) => ids.has(target.id)); - }); - - const displayMenuLoading = () => - !hasDisplayTargetsData() && - (displayTargets.status === "pending" || - displayTargets.fetchStatus === "fetching"); - const windowMenuLoading = () => - !hasWindowTargetsData() && - (windowTargets.status === "pending" || - windowTargets.fetchStatus === "fetching"); - - const displayErrorMessage = () => { - if (!displayTargets.error) return undefined; - return "Unable to load displays. Try using the Display button."; - }; - - const windowErrorMessage = () => { - if (!windowTargets.error) return undefined; - return "Unable to load windows. Try using the Window button."; - }; - - const selectDisplayTarget = (target: CaptureDisplayWithThumbnail) => { - setOptions( - "captureTarget", - reconcile({ variant: "display", id: target.id }), - ); - setOptions("targetMode", "display"); - commands.openTargetSelectOverlays(rawOptions.captureTarget); - setDisplayMenuOpen(false); - displayTriggerRef?.focus(); - }; - - const selectWindowTarget = async (target: CaptureWindowWithThumbnail) => { - setOptions( - "captureTarget", - reconcile({ variant: "window", id: target.id }), - ); - setOptions("targetMode", "window"); - commands.openTargetSelectOverlays(rawOptions.captureTarget); - setWindowMenuOpen(false); - windowTriggerRef?.focus(); - - try { - await commands.focusWindow(target.id); - } catch (error) { - console.error("Failed to focus window:", error); - } - }; - - createEffect(() => { - if (!isRecording()) return; - setDisplayMenuOpen(false); - setWindowMenuOpen(false); - }); - - createUpdateCheck(); - - onMount(async () => { - const targetMode = (window as any).__CAP__.initialTargetMode; - setOptions({ targetMode }); - if (rawOptions.targetMode) commands.openTargetSelectOverlays(null); - else commands.closeTargetSelectOverlays(); - - const currentWindow = getCurrentWindow(); - - const size = getWindowSize(); - currentWindow.setSize(new LogicalSize(size.width, size.height)); - - const unlistenFocus = currentWindow.onFocusChanged( - ({ payload: focused }) => { - if (focused) { - const size = getWindowSize(); - - currentWindow.setSize(new LogicalSize(size.width, size.height)); - } - }, - ); - - const unlistenResize = currentWindow.onResized(() => { - const size = getWindowSize(); - - currentWindow.setSize(new LogicalSize(size.width, size.height)); - }); - - commands.updateAuthPlan(); - - onCleanup(async () => { - (await unlistenFocus)?.(); - (await unlistenResize)?.(); - }); - - const monitor = await primaryMonitor(); - if (!monitor) return; - }); - - 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(); - - createEffect(() => { - if (windowTargets.status !== "success") return; - const signature = createWindowSignature(windowTargets.data); - if (signature !== undefined) setWindowThumbnailsSignature(signature); - }); - - createEffect(() => { - if (displayTargets.status !== "success") return; - const signature = createDisplaySignature(displayTargets.data); - if (signature !== undefined) setDisplayThumbnailsSignature(signature); - }); - - // Refetch thumbnails only when the cheaper lists detect a change. - createEffect(() => { - if (!hasOpenedWindowMenu()) return; - const signature = windowListSignature(); - if (signature === undefined) return; - if (windowTargets.fetchStatus !== "idle") return; - if (windowThumbnailsSignature() === signature) return; - void windowTargets.refetch(); - }); - - createEffect(() => { - if (!hasOpenedDisplayMenu()) return; - const signature = displayListSignature(); - if (signature === undefined) return; - if (displayTargets.fetchStatus !== "idle") return; - if (displayThumbnailsSignature() === signature) return; - 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; - const screen = options.screen(); - if (!screen) return; - - if (target.variant === "window" && windows.data?.length === 0) { - setOptions( - "captureTarget", - reconcile({ variant: "display", id: screen.id }), - ); - } - }); - - 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 = () => ( - -
-
-
- { - 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" - /> -
- -
-
- ); - - const startSignInCleanup = listen("start-sign-in", async () => { - const abort = new AbortController(); - for (const win of await getAllWebviewWindows()) { - if (win.label.startsWith("target-select-overlay")) { - await win.hide(); - } - } - - await signIn.mutateAsync(abort).catch(() => {}); - - for (const win of await getAllWebviewWindows()) { - if (win.label.startsWith("target-select-overlay")) { - await win.show(); - } - } - }); - onCleanup(() => startSignInCleanup.then((cb) => cb())); - - return ( -
- -
-
- Settings}> - - - Previous Recordings}> - - - - {import.meta.env.DEV && ( - - )} -
- {ostype() === "macos" && ( -
- )} -
- - -
-
- - - }> - - { - 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"} - - - -
- -
-
-
- -
-
- Signing In... - - -
-
-
- - }> - {(variant) => - variant === "display" ? ( - { - setDisplayMenuOpen(false); - displayTriggerRef?.focus(); - }} - /> - ) : ( - { - setWindowMenuOpen(false); - windowTriggerRef?.focus(); - }} - /> - ) - } - - -
-
- ); + const { rawOptions, setOptions } = useRecordingOptions(); + const currentRecording = createCurrentRecordingQuery(); + const isRecording = () => !!currentRecording.data; + const auth = authStore.createQuery(); + + let hasHiddenMainWindowForPicker = false; + createEffect(() => { + const pickerActive = rawOptions.targetMode != null; + if (pickerActive && !hasHiddenMainWindowForPicker) { + hasHiddenMainWindowForPicker = true; + void getCurrentWindow().hide(); + } else if (!pickerActive && hasHiddenMainWindowForPicker) { + hasHiddenMainWindowForPicker = false; + const currentWindow = getCurrentWindow(); + void currentWindow.show(); + void currentWindow.setFocus(); + } + }); + onCleanup(() => { + if (!hasHiddenMainWindowForPicker) return; + hasHiddenMainWindowForPicker = false; + void getCurrentWindow().show(); + }); + + const [displayMenuOpen, setDisplayMenuOpen] = createSignal(false); + const [windowMenuOpen, setWindowMenuOpen] = createSignal(false); + const activeMenu = createMemo<"display" | "window" | null>(() => { + if (displayMenuOpen()) return "display"; + if (windowMenuOpen()) return "window"; + return null; + }); + const [hasOpenedDisplayMenu, setHasOpenedDisplayMenu] = createSignal(false); + const [hasOpenedWindowMenu, setHasOpenedWindowMenu] = createSignal(false); + + let displayTriggerRef: HTMLButtonElement | undefined; + let windowTriggerRef: HTMLButtonElement | undefined; + + const displayTargets = useQuery(() => ({ + ...listDisplaysWithThumbnails, + refetchInterval: false, + })); + + const windowTargets = useQuery(() => ({ + ...listWindowsWithThumbnails, + refetchInterval: false, + })); + + const screens = useQuery(() => listScreens); + const windows = useQuery(() => listWindows); + + const hasDisplayTargetsData = () => displayTargets.status === "success"; + const hasWindowTargetsData = () => windowTargets.status === "success"; + + const existingDisplayIds = createMemo(() => { + const currentScreens = screens.data; + if (!currentScreens) return undefined; + return new Set(currentScreens.map((screen) => screen.id)); + }); + + const displayTargetsData = createMemo(() => { + if (!hasDisplayTargetsData()) return undefined; + const ids = existingDisplayIds(); + if (!ids) return displayTargets.data; + return displayTargets.data?.filter((target) => ids.has(target.id)); + }); + + const existingWindowIds = createMemo(() => { + const currentWindows = windows.data; + if (!currentWindows) return undefined; + return new Set(currentWindows.map((win) => win.id)); + }); + + const windowTargetsData = createMemo(() => { + if (!hasWindowTargetsData()) return undefined; + const ids = existingWindowIds(); + if (!ids) return windowTargets.data; + return windowTargets.data?.filter((target) => ids.has(target.id)); + }); + + const displayMenuLoading = () => + !hasDisplayTargetsData() && + (displayTargets.status === "pending" || + displayTargets.fetchStatus === "fetching"); + const windowMenuLoading = () => + !hasWindowTargetsData() && + (windowTargets.status === "pending" || + windowTargets.fetchStatus === "fetching"); + + const displayErrorMessage = () => { + if (!displayTargets.error) return undefined; + return "Unable to load displays. Try using the Display button."; + }; + + const windowErrorMessage = () => { + if (!windowTargets.error) return undefined; + return "Unable to load windows. Try using the Window button."; + }; + + const selectDisplayTarget = (target: CaptureDisplayWithThumbnail) => { + setOptions( + "captureTarget", + reconcile({ variant: "display", id: target.id }) + ); + setOptions("targetMode", "display"); + commands.openTargetSelectOverlays(rawOptions.captureTarget); + setDisplayMenuOpen(false); + displayTriggerRef?.focus(); + }; + + const selectWindowTarget = async (target: CaptureWindowWithThumbnail) => { + setOptions( + "captureTarget", + reconcile({ variant: "window", id: target.id }) + ); + setOptions("targetMode", "window"); + commands.openTargetSelectOverlays(rawOptions.captureTarget); + setWindowMenuOpen(false); + windowTriggerRef?.focus(); + + try { + await commands.focusWindow(target.id); + } catch (error) { + console.error("Failed to focus window:", error); + } + }; + + createEffect(() => { + if (!isRecording()) return; + setDisplayMenuOpen(false); + setWindowMenuOpen(false); + }); + + createUpdateCheck(); + + onMount(async () => { + const targetMode = (window as any).__CAP__.initialTargetMode; + setOptions({ targetMode }); + if (rawOptions.targetMode) commands.openTargetSelectOverlays(null); + else commands.closeTargetSelectOverlays(); + + const currentWindow = getCurrentWindow(); + + const size = getWindowSize(); + currentWindow.setSize(new LogicalSize(size.width, size.height)); + + const unlistenFocus = currentWindow.onFocusChanged( + ({ payload: focused }) => { + if (focused) { + const size = getWindowSize(); + + currentWindow.setSize(new LogicalSize(size.width, size.height)); + } + } + ); + + const unlistenResize = currentWindow.onResized(() => { + const size = getWindowSize(); + + currentWindow.setSize(new LogicalSize(size.width, size.height)); + }); + + commands.updateAuthPlan(); + + onCleanup(async () => { + (await unlistenFocus)?.(); + (await unlistenResize)?.(); + }); + + const monitor = await primaryMonitor(); + if (!monitor) return; + }); + + 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(); + + createEffect(() => { + if (windowTargets.status !== "success") return; + const signature = createWindowSignature(windowTargets.data); + if (signature !== undefined) setWindowThumbnailsSignature(signature); + }); + + createEffect(() => { + if (displayTargets.status !== "success") return; + const signature = createDisplaySignature(displayTargets.data); + if (signature !== undefined) setDisplayThumbnailsSignature(signature); + }); + + // Refetch thumbnails only when the cheaper lists detect a change. + createEffect(() => { + if (!hasOpenedWindowMenu()) return; + const signature = windowListSignature(); + if (signature === undefined) return; + if (windowTargets.fetchStatus !== "idle") return; + if (windowThumbnailsSignature() === signature) return; + void windowTargets.refetch(); + }); + + createEffect(() => { + if (!hasOpenedDisplayMenu()) return; + const signature = displayListSignature(); + if (signature === undefined) return; + if (displayTargets.fetchStatus !== "idle") return; + if (displayThumbnailsSignature() === signature) return; + 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: CaptureDisplay | undefined; + + 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: CaptureWindow | undefined; + + 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, + }; + } + } + }, + }; + + const toggleTargetMode = (mode: "display" | "window" | "area") => { + if (isRecording()) return; + const nextMode = rawOptions.targetMode === mode ? null : mode; + setOptions("targetMode", nextMode); + if (nextMode) commands.openTargetSelectOverlays(rawOptions.captureTarget); + else commands.closeTargetSelectOverlays(); + }; + + createEffect(() => { + const target = options.target(); + if (!target) return; + const screen = options.screen(); + if (!screen) return; + + if (target.variant === "window" && windows.data?.length === 0) { + setOptions( + "captureTarget", + reconcile({ variant: "display", id: screen.id }) + ); + } + }); + + 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 = () => ( + +
+
+
+ toggleTargetMode("display")} + name="Display" + class="flex-1 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0" + /> + { + setDisplayMenuOpen((prev) => { + const next = !prev; + if (next) { + setWindowMenuOpen(false); + setHasOpenedDisplayMenu(true); + } + return next; + }); + }} + aria-haspopup="menu" + aria-label="Choose display" + /> +
+
+ toggleTargetMode("window")} + name="Window" + class="flex-1 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0" + /> + { + setWindowMenuOpen((prev) => { + const next = !prev; + if (next) { + setDisplayMenuOpen(false); + setHasOpenedWindowMenu(true); + } + return next; + }); + }} + aria-haspopup="menu" + aria-label="Choose window" + /> +
+ toggleTargetMode("area")} + name="Area" + /> +
+ +
+
+ ); + + const startSignInCleanup = listen("start-sign-in", async () => { + const abort = new AbortController(); + for (const win of await getAllWebviewWindows()) { + if (win.label.startsWith("target-select-overlay")) { + await win.hide(); + } + } + + await signIn.mutateAsync(abort).catch(() => {}); + + for (const win of await getAllWebviewWindows()) { + if (win.label.startsWith("target-select-overlay")) { + await win.show(); + } + } + }); + onCleanup(() => startSignInCleanup.then((cb) => cb())); + + return ( +
+ +
+
+ Settings}> + + + Previous Recordings}> + + + + {import.meta.env.DEV && ( + + )} +
+ {ostype() === "macos" && ( +
+ )} +
+ + +
+
+ + + }> + + { + 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"} + + + +
+ +
+
+
+ +
+
+ Signing In... + + +
+
+
+ + }> + {(variant) => + variant === "display" ? ( + { + setDisplayMenuOpen(false); + displayTriggerRef?.focus(); + }} + /> + ) : ( + { + setWindowMenuOpen(false); + windowTriggerRef?.focus(); + }} + /> + ) + } + + +
+
+ ); } diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 0fe3e178d4..1976c0b029 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -1,683 +1,828 @@ 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 { createMutation, useQuery } from "@tanstack/solid-query"; import { LogicalPosition } from "@tauri-apps/api/dpi"; import { emit } from "@tauri-apps/api/event"; +import { createEffect } from "solid-js"; import { - CheckMenuItem, - Menu, - MenuItem, - PredefinedMenuItem, + 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, + createMemo, + createSignal, + Match, + mergeProps, + onCleanup, + Show, + Suspense, + Switch, } from "solid-js"; import { createStore, reconcile } from "solid-js/store"; import { - CROP_ZERO, - type CropBounds, - Cropper, - type CropperRef, - createCropOptionsMenuItems, - type Ratio, + CROP_ZERO, + type CropBounds, + Cropper, + type CropperRef, + createCropOptionsMenuItems, + type Ratio, } from "~/components/Cropper"; import ModeSelect from "~/components/ModeSelect"; import { authStore, generalSettingsStore } from "~/store"; import { - createCameraMutation, - createOptionsQuery, - createOrganizationsQuery, - listAudioDevices, - listVideoDevices, + createCameraMutation, + createOptionsQuery, + createOrganizationsQuery, + listAudioDevices, + listVideoDevices, } from "~/utils/queries"; import { - type CameraInfo, - commands, - type DeviceOrModelID, - type DisplayId, - events, - type ScreenCaptureTarget, - type TargetUnderCursor, + 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, + RecordingOptionsProvider, + useRecordingOptions, } from "./(window-chrome)/OptionsContext"; const MIN_SIZE = { width: 150, height: 150 }; const capitalize = (str: string) => { - return str.charAt(0).toUpperCase() + str.slice(1); + 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, - ); + 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 ( - - - - ); + return ( + + + + ); } function useOptions() { - const { rawOptions: _rawOptions, setOptions } = createOptionsQuery(); + const { rawOptions: _rawOptions, setOptions } = createOptionsQuery(); - const organizations = createOrganizationsQuery(); - const options = mergeProps(_rawOptions, () => { - const ret: Partial = {}; + const organizations = createOrganizationsQuery(); + const options = mergeProps(_rawOptions, () => { + const ret: Partial = {}; - if ( - (!_rawOptions.organizationId && organizations().length > 0) || - (_rawOptions.organizationId && - organizations().every((o) => o.id !== _rawOptions.organizationId) && - organizations().length > 0) - ) - ret.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; - }); + return ret; + }); - return [options, setOptions] as const; + return [options, setOptions] as const; } function Inner() { - const [params] = useSearchParams<{ - displayId: DisplayId; - isHoveredDisplay: string; - }>(); - const [options, setOptions] = useOptions(); - - const [toggleModeSelect, setToggleModeSelect] = createSignal(false); - - const [targetUnderCursor, setTargetUnderCursor] = - createStore({ - display_id: null, - window: null, - }); - - const unsubTargetUnderCursor = events.targetUnderCursor.listen((event) => { - setTargetUnderCursor(reconcile(event.payload)); - }); - onCleanup(() => unsubTargetUnderCursor.then((unsub) => unsub())); - - const windowIcon = useQuery(() => ({ - queryKey: ["windowIcon", targetUnderCursor.window?.id], - queryFn: async () => { - if (!targetUnderCursor.window?.id) return null; - return await commands.getWindowIcon( - targetUnderCursor.window.id.toString(), - ); - }, - enabled: !!targetUnderCursor.window?.id, - staleTime: 5 * 60 * 1000, // Cache for 5 minutes - })); - - const displayInformation = useQuery(() => ({ - queryKey: ["displayId", params.displayId], - queryFn: async () => { - if (!params.displayId) return null; - try { - const info = await commands.displayInformation(params.displayId); - return info; - } catch (error) { - console.error("Failed to fetch screen information:", error); - return null; - } - }, - enabled: params.displayId !== undefined && options.targetMode === "display", - })); - - const [crop, setCrop] = createSignal(CROP_ZERO); - - const [initialAreaBounds, setInitialAreaBounds] = createSignal< - CropBounds | undefined - >(undefined); - - const unsubOnEscapePress = events.onEscapePress.listen(() => { - setOptions("targetMode", null); - commands.closeTargetSelectOverlays(); - }); - onCleanup(() => unsubOnEscapePress.then((f) => f())); - - // This prevents browser keyboard shortcuts from firing. - // Eg. on Windows Ctrl+P would open the print dialog without this - createEventListener(document, "keydown", (e) => e.preventDefault()); - - return ( - - - {(displayId) => ( -
-
- - - {(display) => ( -
- - - {display.name || "Monitor"} - - - {(size) => ( - - {`${size().width}x${size().height} ยท ${ - display.refresh_rate - }FPS`} - - )} - -
- )} -
- - - {/* Transparent overlay to capture outside clicks */} -
setToggleModeSelect(false)} - /> - setToggleModeSelect(false)} - /> - - - - -
- )} - - - - {(windowUnderCursor) => ( -
-
-
-
- - - {(icon) => ( - {`${windowUnderCursor.app_name} - )} - - -
- - {windowUnderCursor.app_name} - - - {`${windowUnderCursor.bounds.size.width}x${windowUnderCursor.bounds.size.height}`} - -
- - - - -
-
- )} -
-
- - {(displayId) => { - let controlsEl: HTMLDivElement | undefined; - let cropperRef: CropperRef | undefined; - - const [aspect, setAspect] = createSignal(null); - 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; - }); - - async function showCropOptionsMenu(e: UIEvent) { - e.preventDefault(); - const items = [ - { - text: "Reset selection", - action: () => { - cropperRef?.reset(); - setAspect(null); - }, - }, - await PredefinedMenuItem.new({ - item: "Separator", - }), - ...createCropOptionsMenuItems({ - aspect: aspect(), - snapToRatioEnabled: snapToRatioEnabled(), - onAspectSet: setAspect, - onSnapToRatioSet: setSnapToRatioEnabled, - }), - ]; - const menu = await Menu.new({ items }); - await menu.popup(); - await menu.close(); - } - - // Spacing rules: - // Prefer below the crop (smaller margin) - // If no space below, place above the crop (larger top margin) - // Otherwise, place inside at the top of the crop (small inner margin) - const macos = ostype() === "macos"; - const SIDE_MARGIN = 16; - const MARGIN_BELOW = 16; - const MARGIN_TOP_OUTSIDE = 16; - const MARGIN_TOP_INSIDE = macos ? 40 : 28; - const TOP_SAFE_MARGIN = macos ? 40 : 10; // keep clear of notch on MacBooks - - const controlsSize = createElementSize(() => controlsEl); - const [controllerInside, _setControllerInside] = createSignal(false); - - // This is required due to the use of a ResizeObserver within the createElementSize function - // Otherwise there will be an infinite loop: ResizeObserver loop completed with undelivered notifications. - let raf: number | null = null; - function setControllerInside(value: boolean) { - if (raf) cancelAnimationFrame(raf); - raf = requestAnimationFrame(() => _setControllerInside(value)); - } - onCleanup(() => { - if (raf) cancelAnimationFrame(raf); - }); - - const controlsStyle = createMemo(() => { - const bounds = crop(); - const size = controlsSize; - if (!size?.width || !size?.height) return undefined; - - if (size.width === 0 || bounds.width === 0) { - return { transform: "translate(-1000px, -1000px)" }; // Hide off-screen initially - } - - const centerX = bounds.x + bounds.width / 2; - let finalY: number; - - // Try below the crop - const belowY = bounds.y + bounds.height + MARGIN_BELOW; - if (belowY + size.height <= window.innerHeight) { - finalY = belowY; - setControllerInside(false); - } else { - // Try above the crop with a larger top margin - const aboveY = bounds.y - size.height - MARGIN_TOP_OUTSIDE; - if (aboveY >= TOP_SAFE_MARGIN) { - finalY = aboveY; - setControllerInside(false); - } else { - // Default to inside - finalY = bounds.y + MARGIN_TOP_INSIDE; - setControllerInside(true); - } - } - - const finalX = Math.max( - SIDE_MARGIN, - Math.min( - centerX - size.width / 2, - window.innerWidth - size.width - SIDE_MARGIN, - ), - ); - - return { - transform: `translate(${finalX}px, ${finalY}px)`, - }; - }); - - return ( -
-
- -
-

Minimum size is 150 x 150

- - - {crop().width} x {crop().height} - {" "} - is too small - -
-
- } - > - - - -
- - showCropOptionsMenu(e)} - /> -
- ); - }} - - - ); + const [params] = useSearchParams<{ + displayId: DisplayId; + isHoveredDisplay: string; + }>(); + const [options, setOptions] = useOptions(); + + const [toggleModeSelect, setToggleModeSelect] = createSignal(false); + + const [targetUnderCursor, setTargetUnderCursor] = + createStore({ + display_id: null, + window: null, + }); + + const unsubTargetUnderCursor = events.targetUnderCursor.listen((event) => { + setTargetUnderCursor(reconcile(event.payload)); + }); + onCleanup(() => unsubTargetUnderCursor.then((unsub) => unsub())); + + const windowIcon = useQuery(() => ({ + queryKey: ["windowIcon", targetUnderCursor.window?.id], + queryFn: async () => { + if (!targetUnderCursor.window?.id) return null; + return await commands.getWindowIcon( + targetUnderCursor.window.id.toString() + ); + }, + enabled: !!targetUnderCursor.window?.id, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + })); + + const displayInformation = useQuery(() => ({ + queryKey: ["displayId", params.displayId], + queryFn: async () => { + if (!params.displayId) return null; + try { + const info = await commands.displayInformation(params.displayId); + return info; + } catch (error) { + console.error("Failed to fetch screen information:", error); + return null; + } + }, + enabled: params.displayId !== undefined && options.targetMode === "display", + })); + + const [crop, setCrop] = createSignal(CROP_ZERO); + type AreaTarget = Extract; + const [pendingAreaTarget, setPendingAreaTarget] = + createSignal(null); + + 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); + } + return mode; + }); + + const [initialAreaBounds, setInitialAreaBounds] = createSignal< + CropBounds | undefined + >(undefined); + + createEffect(() => { + const target = options.captureTarget; + if (target.variant !== "area") return; + if (!params.displayId || target.screen !== params.displayId) return; + if (initialAreaBounds() !== undefined) return; + setInitialAreaBounds({ + x: target.bounds.position.x, + y: target.bounds.position.y, + width: target.bounds.size.width, + height: target.bounds.size.height, + }); + }); + + const unsubOnEscapePress = events.onEscapePress.listen(() => { + setOptions("targetMode", null); + commands.closeTargetSelectOverlays(); + }); + onCleanup(() => unsubOnEscapePress.then((f) => f())); + + // This prevents browser keyboard shortcuts from firing. + // Eg. on Windows Ctrl+P would open the print dialog without this + createEventListener(document, "keydown", (e) => e.preventDefault()); + + return ( + + + {(displayId) => ( +
+
+ + + {(display) => ( +
+ + + {display.name || "Monitor"} + + + {(size) => ( + + {`${size().width}x${size().height} ยท ${ + display.refresh_rate + }FPS`} + + )} + +
+ )} +
+ + + {/* Transparent overlay to capture outside clicks */} +
setToggleModeSelect(false)} + /> + setToggleModeSelect(false)} + /> + + + + +
+ )} + + + + {(windowUnderCursor) => ( +
+
+
+
+ + + {(icon) => ( + {`${windowUnderCursor.app_name} + )} + + +
+ + {windowUnderCursor.app_name} + + + {`${windowUnderCursor.bounds.size.width}x${windowUnderCursor.bounds.size.height}`} + +
+ + + + +
+
+ )} +
+
+ + {(displayId) => { + let controlsEl: HTMLDivElement | undefined; + let cropperRef: CropperRef | undefined; + + const [aspect, setAspect] = createSignal(null); + const [snapToRatioEnabled, setSnapToRatioEnabled] = + createSignal(true); + const [isInteracting, setIsInteracting] = createSignal(false); + const [committedCrop, setCommittedCrop] = + createSignal(CROP_ZERO); + + const isValid = createMemo(() => { + const b = crop(); + return b.width >= MIN_SIZE.width && b.height >= MIN_SIZE.height; + }); + const committedIsValid = createMemo(() => { + const b = committedCrop(); + return b.width >= MIN_SIZE.width && b.height >= MIN_SIZE.height; + }); + + createEffect(() => { + if (isInteracting()) return; + setCommittedCrop(crop()); + }); + + async function showCropOptionsMenu(e: UIEvent) { + e.preventDefault(); + const items = [ + { + text: "Reset selection", + action: () => { + cropperRef?.reset(); + setAspect(null); + setPendingAreaTarget(null); + }, + }, + await PredefinedMenuItem.new({ + item: "Separator", + }), + ...createCropOptionsMenuItems({ + aspect: aspect(), + snapToRatioEnabled: snapToRatioEnabled(), + onAspectSet: setAspect, + onSnapToRatioSet: setSnapToRatioEnabled, + }), + ]; + const menu = await Menu.new({ items }); + await menu.popup(); + await menu.close(); + } + + // Spacing rules: + // Prefer below the crop (smaller margin) + // If no space below, place above the crop (larger top margin) + // Otherwise, place inside at the top of the crop (small inner margin) + const macos = ostype() === "macos"; + const SIDE_MARGIN = 16; + const MARGIN_BELOW = 16; + const MARGIN_TOP_OUTSIDE = 16; + const MARGIN_TOP_INSIDE = macos ? 40 : 28; + const TOP_SAFE_MARGIN = macos ? 40 : 10; // keep clear of notch on MacBooks + + const controlsSize = createElementSize(() => controlsEl); + const [controllerInside, _setControllerInside] = createSignal(false); + + // This is required due to the use of a ResizeObserver within the createElementSize function + // Otherwise there will be an infinite loop: ResizeObserver loop completed with undelivered notifications. + let raf: number | null = null; + function setControllerInside(value: boolean) { + if (raf) cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => _setControllerInside(value)); + } + onCleanup(() => { + if (raf) cancelAnimationFrame(raf); + }); + + const controlsStyle = createMemo(() => { + const bounds = crop(); + const size = controlsSize; + if (!size?.width || !size?.height) return undefined; + + if (size.width === 0 || bounds.width === 0) { + return { transform: "translate(-1000px, -1000px)" }; // Hide off-screen initially + } + + const centerX = bounds.x + bounds.width / 2; + let finalY: number; + + // Try below the crop + const belowY = bounds.y + bounds.height + MARGIN_BELOW; + if (belowY + size.height <= window.innerHeight) { + finalY = belowY; + setControllerInside(false); + } else { + // Try above the crop with a larger top margin + const aboveY = bounds.y - size.height - MARGIN_TOP_OUTSIDE; + if (aboveY >= TOP_SAFE_MARGIN) { + finalY = aboveY; + setControllerInside(false); + } else { + // Default to inside + finalY = bounds.y + MARGIN_TOP_INSIDE; + setControllerInside(true); + } + } + + const finalX = Math.max( + SIDE_MARGIN, + Math.min( + centerX - size.width / 2, + window.innerWidth - size.width - SIDE_MARGIN + ) + ); + + return { + transform: `translate(${finalX}px, ${finalY}px)`, + }; + }); + + createEffect(() => { + if (isInteracting()) return; + if (!committedIsValid()) return; + const screenId = displayId(); + if (!screenId) return; + const bounds = committedCrop(); + setPendingAreaTarget({ + variant: "area", + screen: screenId, + bounds: { + position: { x: bounds.x, y: bounds.y }, + size: { width: bounds.width, height: bounds.height }, + }, + }); + }); + + return ( +
+
+
+ + +
+

Minimum size is 150 x 150

+ + + {crop().width} x {crop().height} + {" "} + is too small + +
+
+ + + +
+
+ + showCropOptionsMenu(e)} + /> +
+ ); + }} +
+ + ); } function RecordingControls(props: { - target: ScreenCaptureTarget; - setToggleModeSelect?: (value: boolean) => void; - showBackground?: boolean; + target: ScreenCaptureTarget; + setToggleModeSelect?: (value: boolean) => void; + showBackground?: boolean; + disabled?: boolean; }) { - const auth = authStore.createQuery(); - const { setOptions, rawOptions } = useRecordingOptions(); - - const generalSetings = generalSettingsStore.createQuery(); - const cameras = useQuery(() => listVideoDevices); - const mics = useQuery(() => listAudioDevices); - const setMicInput = createMutation(() => ({ - mutationFn: async (name: string | null) => { - await commands.setMicInput(name); - setOptions("micName", name); - }, - })); - const setCamera = createCameraMutation(); - - const selectedCamera = createMemo(() => { - if (!rawOptions.cameraID) return null; - return findCamera(cameras.data ?? [], rawOptions.cameraID) ?? null; - }); - - const selectedMicName = createMemo(() => { - if (!rawOptions.micName) return null; - return ( - (mics.data ?? []).find((name) => name === rawOptions.micName) ?? null - ); - }); - - const menuModes = async () => - await Menu.new({ - items: [ - await CheckMenuItem.new({ - text: "Studio Mode", - action: () => { - setOptions("mode", "studio"); - }, - checked: rawOptions.mode === "studio", - }), - await CheckMenuItem.new({ - text: "Instant Mode", - action: () => { - setOptions("mode", "instant"); - }, - checked: rawOptions.mode === "instant", - }), - ], - }); - - 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(); - const rect = (e.target as HTMLDivElement).getBoundingClientRect(); - menu.then((menu) => menu.popup(new LogicalPosition(rect.x, rect.y + 40))); - } - - return ( - <> -
-
-
-
{ - 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; - } - - commands.startRecording({ - capture_target: props.target, - mode: rawOptions.mode, - capture_system_audio: rawOptions.captureSystemAudio, - }); - }} - > -
- {rawOptions.mode === "studio" ? ( - - ) : ( - - )} -
- - {rawOptions.mode === "instant" && !auth.data - ? "Sign In To Use" - : "Start Recording"} - - - {`${capitalize(rawOptions.mode)} Mode`} - -
-
-
showMenu(menuModes(), e)} - onClick={(e) => showMenu(menuModes(), e)} - > - -
-
-
showMenu(preRecordingMenu(), e)} - onClick={(e) => showMenu(preRecordingMenu(), e)} - > - -
-
-
-
-
- { - if (!camera) setCamera.mutate(null); - else if (camera.model_id) - setCamera.mutate({ ModelID: camera.model_id }); - else setCamera.mutate({ DeviceID: camera.device_id }); - }} - /> - setMicInput.mutate(value)} - /> -
-
-
-
-
props.setToggleModeSelect?.(true)} - class="flex gap-1 justify-center items-center self-center mb-5 transition-opacity duration-200 w-fit hover:opacity-60" - classList={{ - "bg-black/50 p-2 rounded-lg border border-white/10 hover:bg-black/50 hover:opacity-80": - props.showBackground, - "hover:opacity-60": !props.showBackground, - }} - > - -

- What is - {capitalize(rawOptions.mode)} Mode? -

-
-
- - ); + const auth = authStore.createQuery(); + const { setOptions, rawOptions } = useRecordingOptions(); + + const generalSetings = generalSettingsStore.createQuery(); + const cameras = useQuery(() => listVideoDevices); + const mics = useQuery(() => listAudioDevices); + const setMicInput = createMutation(() => ({ + mutationFn: async (name: string | null) => { + await commands.setMicInput(name); + setOptions("micName", name); + }, + })); + const setCamera = createCameraMutation(); + + const selectedCamera = createMemo(() => { + if (!rawOptions.cameraID) return null; + return findCamera(cameras.data ?? [], rawOptions.cameraID) ?? null; + }); + + const selectedMicName = createMemo(() => { + if (!rawOptions.micName) return null; + return ( + (mics.data ?? []).find((name) => name === rawOptions.micName) ?? null + ); + }); + + const menuModes = async () => + await Menu.new({ + items: [ + await CheckMenuItem.new({ + text: "Studio Mode", + action: () => { + setOptions("mode", "studio"); + }, + checked: rawOptions.mode === "studio", + }), + await CheckMenuItem.new({ + text: "Instant Mode", + action: () => { + setOptions("mode", "instant"); + }, + checked: rawOptions.mode === "instant", + }), + ], + }); + + 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(); + const rect = (e.target as HTMLDivElement).getBoundingClientRect(); + menu.then((menu) => menu.popup(new LogicalPosition(rect.x, rect.y + 40))); + } + + const startDisabled = () => !!props.disabled; + + return ( + <> +
+
+
+
{ + 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 (startDisabled()) return; + + if (props.target.variant === "area") { + setOptions( + "captureTarget", + reconcile({ + variant: "area", + screen: props.target.screen, + bounds: { + position: { + x: props.target.bounds.position.x, + y: props.target.bounds.position.y, + }, + size: { + width: props.target.bounds.size.width, + height: props.target.bounds.size.height, + }, + }, + }) + ); + } + + commands.startRecording({ + capture_target: props.target, + mode: rawOptions.mode, + capture_system_audio: rawOptions.captureSystemAudio, + }); + }} + > +
+ {rawOptions.mode === "studio" ? ( + + ) : ( + + )} +
+ + {rawOptions.mode === "instant" && !auth.data + ? "Sign In To Use" + : "Start Recording"} + + + {`${capitalize(rawOptions.mode)} Mode`} + +
+
+
showMenu(menuModes(), e)} + onClick={(e) => showMenu(menuModes(), e)} + > + +
+
+
showMenu(preRecordingMenu(), e)} + onClick={(e) => showMenu(preRecordingMenu(), e)} + > + +
+
+
+
+
+ { + if (!camera) setCamera.mutate(null); + else if (camera.model_id) + setCamera.mutate({ ModelID: camera.model_id }); + else setCamera.mutate({ DeviceID: camera.device_id }); + }} + /> + setMicInput.mutate(value)} + /> +
+
+
+
+
props.setToggleModeSelect?.(true)} + class="flex gap-1 justify-center items-center self-center mb-5 transition-opacity duration-200 w-fit hover:opacity-60" + classList={{ + "bg-black/50 p-2 rounded-lg border border-white/10 hover:bg-black/50 hover:opacity-80": + props.showBackground, + "hover:opacity-60": !props.showBackground, + }} + > + +

+ What is + {capitalize(rawOptions.mode)} Mode? +

+
+
+ + ); } function ShowCapFreeWarning(props: { isInstantMode: boolean }) { - const auth = authStore.createQuery(); - - return ( - - -

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

-
-
- ); + const auth = authStore.createQuery(); + + return ( + + +

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

+
+
+ ); } From d17a2e6e48cdbf03c1194d738835a9351ab74fdc Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 18:13:07 +0000 Subject: [PATCH 08/32] Add selection hint overlay for area capture --- apps/desktop/src/components/SelectionHint.tsx | 47 + .../routes/(window-chrome)/new-main/index.tsx | 1664 +++++++++-------- apps/desktop/src/routes/capture-area.tsx | 31 +- .../src/routes/target-select-overlay.tsx | 1547 ++++++++------- apps/desktop/src/styles/theme.css | 113 ++ packages/ui-solid/icons/cursor-macos.svg | 18 + packages/ui-solid/icons/cursor-windows.svg | 18 + packages/ui-solid/src/auto-imports.d.ts | 2 + 8 files changed, 1829 insertions(+), 1611 deletions(-) create mode 100644 apps/desktop/src/components/SelectionHint.tsx create mode 100644 packages/ui-solid/icons/cursor-macos.svg create mode 100644 packages/ui-solid/icons/cursor-windows.svg diff --git a/apps/desktop/src/components/SelectionHint.tsx b/apps/desktop/src/components/SelectionHint.tsx new file mode 100644 index 0000000000..f27c72db54 --- /dev/null +++ b/apps/desktop/src/components/SelectionHint.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 69ed16f5ae..327ed98bbe 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -4,27 +4,27 @@ import { useNavigate } from "@solidjs/router"; import { createMutation, useQuery } from "@tanstack/solid-query"; import { listen } from "@tauri-apps/api/event"; import { - getAllWebviewWindows, - WebviewWindow, + getAllWebviewWindows, + WebviewWindow, } from "@tauri-apps/api/webviewWindow"; import { - getCurrentWindow, - LogicalSize, - primaryMonitor, + 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, + 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 @@ -35,25 +35,25 @@ import { Input } from "~/routes/editor/ui"; import { authStore, generalSettingsStore } from "~/store"; import { createSignInMutation } from "~/utils/auth"; import { - createCameraMutation, - createCurrentRecordingQuery, - createLicenseQuery, - listAudioDevices, - listDisplaysWithThumbnails, - listScreens, - listVideoDevices, - listWindows, - listWindowsWithThumbnails, + 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, + 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"; @@ -62,8 +62,8 @@ import IconMaterialSymbolsScreenshotFrame2Rounded from "~icons/material-symbols/ import IconMdiMonitor from "~icons/mdi/monitor"; import { WindowChromeHeader } from "../Context"; import { - RecordingOptionsProvider, - useRecordingOptions, + RecordingOptionsProvider, + useRecordingOptions, } from "../OptionsContext"; import CameraSelect from "./CameraSelect"; import ChangelogButton from "./ChangeLogButton"; @@ -74,846 +74,848 @@ import TargetMenuGrid from "./TargetMenuGrid"; import TargetTypeButton from "./TargetTypeButton"; function getWindowSize() { - return { - width: 290, - height: 310, - }; + return { + width: 290, + height: 310, + }; } 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 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" + CaptureWindow, + "id" | "owner_name" | "name" | "bounds" | "refresh_rate" >; const createWindowSignature = ( - list?: readonly WindowListItem[] + list?: readonly WindowListItem[], ): string | undefined => { - if (!list) return undefined; - - return list - .map((item) => { - const { position, size } = item.bounds; - return [ - item.id, - item.owner_name, - item.name, - position.x, - position.y, - size.width, - size.height, - item.refresh_rate, - ].join(":"); - }) - .join("|"); + if (!list) return undefined; + + return list + .map((item) => { + const { position, size } = item.bounds; + return [ + item.id, + item.owner_name, + item.name, + position.x, + position.y, + size.width, + size.height, + item.refresh_rate, + ].join(":"); + }) + .join("|"); }; type DisplayListItem = Pick; const createDisplaySignature = ( - list?: readonly DisplayListItem[] + list?: readonly DisplayListItem[], ): string | undefined => { - if (!list) return 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 = - | { - variant: "display"; - targets?: CaptureDisplayWithThumbnail[]; - onSelect: (target: CaptureDisplayWithThumbnail) => void; - } - | { - variant: "window"; - targets?: CaptureWindowWithThumbnail[]; - onSelect: (target: CaptureWindowWithThumbnail) => void; - }; + | { + variant: "display"; + targets?: CaptureDisplayWithThumbnail[]; + onSelect: (target: CaptureDisplayWithThumbnail) => void; + } + | { + variant: "window"; + targets?: CaptureWindowWithThumbnail[]; + onSelect: (target: CaptureWindowWithThumbnail) => void; + }; type SharedTargetMenuProps = { - isLoading: boolean; - errorMessage?: string; - disabled: boolean; - onBack: () => void; + isLoading: boolean; + errorMessage?: string; + disabled: boolean; + onBack: () => void; }; 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 filteredWindowTargets = createMemo(() => { - if (props.variant !== "window") 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.owner_name) || - matchesQuery(target.id) - ); - }); - - return ( -
-
-
props.onBack()} - class="flex gap-1 items-center rounded-md px-1.5 text-xs + 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 filteredWindowTargets = createMemo(() => { + if (props.variant !== "window") 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.owner_name) || + matchesQuery(target.id), + ); + }); + + return ( +
+
+
props.onBack()} + class="flex gap-1 items-center rounded-md px-1.5 text-xs text-gray-11 transition-opacity hover:opacity-70 hover:text-gray-12 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-9 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-1" - > - - Back -
-
- - setSearch(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Escape" && search()) { - event.preventDefault(); - setSearch(""); - } - }} - placeholder={placeholder} - autoCapitalize="off" - autocorrect="off" - autocomplete="off" - spellcheck={false} - aria-label={placeholder} - /> -
-
-
-
- {props.variant === "display" ? ( - - ) : ( - - )} -
-
-
- ); + > + + Back +
+
+ + setSearch(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Escape" && search()) { + event.preventDefault(); + setSearch(""); + } + }} + placeholder={placeholder} + autoCapitalize="off" + autocorrect="off" + autocomplete="off" + spellcheck={false} + aria-label={placeholder} + /> +
+
+
+
+ {props.variant === "display" ? ( + + ) : ( + + )} +
+
+
+ ); } export default function () { - const generalSettings = generalSettingsStore.createQuery(); - - const navigate = useNavigate(); - createEventListener(window, "focus", () => { - if (generalSettings.data?.enableNewRecordingFlow === false) navigate("/"); - }); - - return ( - - - - ); + const generalSettings = generalSettingsStore.createQuery(); + + const navigate = useNavigate(); + createEventListener(window, "focus", () => { + if (generalSettings.data?.enableNewRecordingFlow === false) navigate("/"); + }); + + return ( + + + + ); } let hasChecked = false; function createUpdateCheck() { - if (import.meta.env.DEV) return; + if (import.meta.env.DEV) return; - const navigate = useNavigate(); + const navigate = useNavigate(); - onMount(async () => { - if (hasChecked) return; - hasChecked = true; + onMount(async () => { + if (hasChecked) return; + hasChecked = true; - await new Promise((res) => setTimeout(res, 1000)); + await new Promise((res) => setTimeout(res, 1000)); - const update = await updater.check(); - if (!update) return; + const update = await updater.check(); + if (!update) return; - const shouldUpdate = await dialog.confirm( - `Version ${update.version} of Cap is available, would you like to install it?`, - { title: "Update Cap", okLabel: "Update", cancelLabel: "Ignore" } - ); + const shouldUpdate = await dialog.confirm( + `Version ${update.version} of Cap is available, would you like to install it?`, + { title: "Update Cap", okLabel: "Update", cancelLabel: "Ignore" }, + ); - if (!shouldUpdate) return; - navigate("/update"); - }); + if (!shouldUpdate) return; + navigate("/update"); + }); } function Page() { - const { rawOptions, setOptions } = useRecordingOptions(); - const currentRecording = createCurrentRecordingQuery(); - const isRecording = () => !!currentRecording.data; - const auth = authStore.createQuery(); - - let hasHiddenMainWindowForPicker = false; - createEffect(() => { - const pickerActive = rawOptions.targetMode != null; - if (pickerActive && !hasHiddenMainWindowForPicker) { - hasHiddenMainWindowForPicker = true; - void getCurrentWindow().hide(); - } else if (!pickerActive && hasHiddenMainWindowForPicker) { - hasHiddenMainWindowForPicker = false; - const currentWindow = getCurrentWindow(); - void currentWindow.show(); - void currentWindow.setFocus(); - } - }); - onCleanup(() => { - if (!hasHiddenMainWindowForPicker) return; - hasHiddenMainWindowForPicker = false; - void getCurrentWindow().show(); - }); - - const [displayMenuOpen, setDisplayMenuOpen] = createSignal(false); - const [windowMenuOpen, setWindowMenuOpen] = createSignal(false); - const activeMenu = createMemo<"display" | "window" | null>(() => { - if (displayMenuOpen()) return "display"; - if (windowMenuOpen()) return "window"; - return null; - }); - const [hasOpenedDisplayMenu, setHasOpenedDisplayMenu] = createSignal(false); - const [hasOpenedWindowMenu, setHasOpenedWindowMenu] = createSignal(false); - - let displayTriggerRef: HTMLButtonElement | undefined; - let windowTriggerRef: HTMLButtonElement | undefined; - - const displayTargets = useQuery(() => ({ - ...listDisplaysWithThumbnails, - refetchInterval: false, - })); - - const windowTargets = useQuery(() => ({ - ...listWindowsWithThumbnails, - refetchInterval: false, - })); - - const screens = useQuery(() => listScreens); - const windows = useQuery(() => listWindows); - - const hasDisplayTargetsData = () => displayTargets.status === "success"; - const hasWindowTargetsData = () => windowTargets.status === "success"; - - const existingDisplayIds = createMemo(() => { - const currentScreens = screens.data; - if (!currentScreens) return undefined; - return new Set(currentScreens.map((screen) => screen.id)); - }); - - const displayTargetsData = createMemo(() => { - if (!hasDisplayTargetsData()) return undefined; - const ids = existingDisplayIds(); - if (!ids) return displayTargets.data; - return displayTargets.data?.filter((target) => ids.has(target.id)); - }); - - const existingWindowIds = createMemo(() => { - const currentWindows = windows.data; - if (!currentWindows) return undefined; - return new Set(currentWindows.map((win) => win.id)); - }); - - const windowTargetsData = createMemo(() => { - if (!hasWindowTargetsData()) return undefined; - const ids = existingWindowIds(); - if (!ids) return windowTargets.data; - return windowTargets.data?.filter((target) => ids.has(target.id)); - }); - - const displayMenuLoading = () => - !hasDisplayTargetsData() && - (displayTargets.status === "pending" || - displayTargets.fetchStatus === "fetching"); - const windowMenuLoading = () => - !hasWindowTargetsData() && - (windowTargets.status === "pending" || - windowTargets.fetchStatus === "fetching"); - - const displayErrorMessage = () => { - if (!displayTargets.error) return undefined; - return "Unable to load displays. Try using the Display button."; - }; - - const windowErrorMessage = () => { - if (!windowTargets.error) return undefined; - return "Unable to load windows. Try using the Window button."; - }; - - const selectDisplayTarget = (target: CaptureDisplayWithThumbnail) => { - setOptions( - "captureTarget", - reconcile({ variant: "display", id: target.id }) - ); - setOptions("targetMode", "display"); - commands.openTargetSelectOverlays(rawOptions.captureTarget); - setDisplayMenuOpen(false); - displayTriggerRef?.focus(); - }; - - const selectWindowTarget = async (target: CaptureWindowWithThumbnail) => { - setOptions( - "captureTarget", - reconcile({ variant: "window", id: target.id }) - ); - setOptions("targetMode", "window"); - commands.openTargetSelectOverlays(rawOptions.captureTarget); - setWindowMenuOpen(false); - windowTriggerRef?.focus(); - - try { - await commands.focusWindow(target.id); - } catch (error) { - console.error("Failed to focus window:", error); - } - }; - - createEffect(() => { - if (!isRecording()) return; - setDisplayMenuOpen(false); - setWindowMenuOpen(false); - }); - - createUpdateCheck(); - - onMount(async () => { - const targetMode = (window as any).__CAP__.initialTargetMode; - setOptions({ targetMode }); - if (rawOptions.targetMode) commands.openTargetSelectOverlays(null); - else commands.closeTargetSelectOverlays(); - - const currentWindow = getCurrentWindow(); - - const size = getWindowSize(); - currentWindow.setSize(new LogicalSize(size.width, size.height)); - - const unlistenFocus = currentWindow.onFocusChanged( - ({ payload: focused }) => { - if (focused) { - const size = getWindowSize(); - - currentWindow.setSize(new LogicalSize(size.width, size.height)); - } - } - ); - - const unlistenResize = currentWindow.onResized(() => { - const size = getWindowSize(); - - currentWindow.setSize(new LogicalSize(size.width, size.height)); - }); - - commands.updateAuthPlan(); - - onCleanup(async () => { - (await unlistenFocus)?.(); - (await unlistenResize)?.(); - }); - - const monitor = await primaryMonitor(); - if (!monitor) return; - }); - - 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(); - - createEffect(() => { - if (windowTargets.status !== "success") return; - const signature = createWindowSignature(windowTargets.data); - if (signature !== undefined) setWindowThumbnailsSignature(signature); - }); - - createEffect(() => { - if (displayTargets.status !== "success") return; - const signature = createDisplaySignature(displayTargets.data); - if (signature !== undefined) setDisplayThumbnailsSignature(signature); - }); - - // Refetch thumbnails only when the cheaper lists detect a change. - createEffect(() => { - if (!hasOpenedWindowMenu()) return; - const signature = windowListSignature(); - if (signature === undefined) return; - if (windowTargets.fetchStatus !== "idle") return; - if (windowThumbnailsSignature() === signature) return; - void windowTargets.refetch(); - }); - - createEffect(() => { - if (!hasOpenedDisplayMenu()) return; - const signature = displayListSignature(); - if (signature === undefined) return; - if (displayTargets.fetchStatus !== "idle") return; - if (displayThumbnailsSignature() === signature) return; - 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: CaptureDisplay | undefined; - - 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: CaptureWindow | undefined; - - 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, - }; - } - } - }, - }; - - const toggleTargetMode = (mode: "display" | "window" | "area") => { - if (isRecording()) return; - const nextMode = rawOptions.targetMode === mode ? null : mode; - setOptions("targetMode", nextMode); - if (nextMode) commands.openTargetSelectOverlays(rawOptions.captureTarget); - else commands.closeTargetSelectOverlays(); - }; - - createEffect(() => { - const target = options.target(); - if (!target) return; - const screen = options.screen(); - if (!screen) return; - - if (target.variant === "window" && windows.data?.length === 0) { - setOptions( - "captureTarget", - reconcile({ variant: "display", id: screen.id }) - ); - } - }); - - 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 = () => ( - -
-
-
- toggleTargetMode("display")} - name="Display" - class="flex-1 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0" - /> - { - setDisplayMenuOpen((prev) => { - const next = !prev; - if (next) { - setWindowMenuOpen(false); - setHasOpenedDisplayMenu(true); - } - return next; - }); - }} - aria-haspopup="menu" - aria-label="Choose display" - /> -
-
- toggleTargetMode("window")} - name="Window" - class="flex-1 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0" - /> - { - setWindowMenuOpen((prev) => { - const next = !prev; - if (next) { - setDisplayMenuOpen(false); - setHasOpenedWindowMenu(true); - } - return next; - }); - }} - aria-haspopup="menu" - aria-label="Choose window" - /> -
- toggleTargetMode("area")} - name="Area" - /> -
- -
-
- ); - - const startSignInCleanup = listen("start-sign-in", async () => { - const abort = new AbortController(); - for (const win of await getAllWebviewWindows()) { - if (win.label.startsWith("target-select-overlay")) { - await win.hide(); - } - } - - await signIn.mutateAsync(abort).catch(() => {}); - - for (const win of await getAllWebviewWindows()) { - if (win.label.startsWith("target-select-overlay")) { - await win.show(); - } - } - }); - onCleanup(() => startSignInCleanup.then((cb) => cb())); - - return ( -
- -
-
- Settings}> - - - Previous Recordings}> - - - - {import.meta.env.DEV && ( - - )} -
- {ostype() === "macos" && ( -
- )} -
- - -
-
- - - }> - - { - 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"} - - - -
- -
-
-
- -
-
- Signing In... - - -
-
-
- - }> - {(variant) => - variant === "display" ? ( - { - setDisplayMenuOpen(false); - displayTriggerRef?.focus(); - }} - /> - ) : ( - { - setWindowMenuOpen(false); - windowTriggerRef?.focus(); - }} - /> - ) - } - - -
-
- ); + const { rawOptions, setOptions } = useRecordingOptions(); + const currentRecording = createCurrentRecordingQuery(); + const isRecording = () => !!currentRecording.data; + const auth = authStore.createQuery(); + + let hasHiddenMainWindowForPicker = false; + createEffect(() => { + const pickerActive = rawOptions.targetMode != null; + if (pickerActive && !hasHiddenMainWindowForPicker) { + hasHiddenMainWindowForPicker = true; + void getCurrentWindow().hide(); + } else if (!pickerActive && hasHiddenMainWindowForPicker) { + hasHiddenMainWindowForPicker = false; + const currentWindow = getCurrentWindow(); + void currentWindow.show(); + void currentWindow.setFocus(); + } + }); + onCleanup(() => { + if (!hasHiddenMainWindowForPicker) return; + hasHiddenMainWindowForPicker = false; + void getCurrentWindow().show(); + }); + + const [displayMenuOpen, setDisplayMenuOpen] = createSignal(false); + const [windowMenuOpen, setWindowMenuOpen] = createSignal(false); + const activeMenu = createMemo<"display" | "window" | null>(() => { + if (displayMenuOpen()) return "display"; + if (windowMenuOpen()) return "window"; + return null; + }); + const [hasOpenedDisplayMenu, setHasOpenedDisplayMenu] = createSignal(false); + const [hasOpenedWindowMenu, setHasOpenedWindowMenu] = createSignal(false); + + let displayTriggerRef: HTMLButtonElement | undefined; + let windowTriggerRef: HTMLButtonElement | undefined; + + const displayTargets = useQuery(() => ({ + ...listDisplaysWithThumbnails, + refetchInterval: false, + })); + + const windowTargets = useQuery(() => ({ + ...listWindowsWithThumbnails, + refetchInterval: false, + })); + + const screens = useQuery(() => listScreens); + const windows = useQuery(() => listWindows); + + const hasDisplayTargetsData = () => displayTargets.status === "success"; + const hasWindowTargetsData = () => windowTargets.status === "success"; + + const existingDisplayIds = createMemo(() => { + const currentScreens = screens.data; + if (!currentScreens) return undefined; + return new Set(currentScreens.map((screen) => screen.id)); + }); + + const displayTargetsData = createMemo(() => { + if (!hasDisplayTargetsData()) return undefined; + const ids = existingDisplayIds(); + if (!ids) return displayTargets.data; + return displayTargets.data?.filter((target) => ids.has(target.id)); + }); + + const existingWindowIds = createMemo(() => { + const currentWindows = windows.data; + if (!currentWindows) return undefined; + return new Set(currentWindows.map((win) => win.id)); + }); + + const windowTargetsData = createMemo(() => { + if (!hasWindowTargetsData()) return undefined; + const ids = existingWindowIds(); + if (!ids) return windowTargets.data; + return windowTargets.data?.filter((target) => ids.has(target.id)); + }); + + const displayMenuLoading = () => + !hasDisplayTargetsData() && + (displayTargets.status === "pending" || + displayTargets.fetchStatus === "fetching"); + const windowMenuLoading = () => + !hasWindowTargetsData() && + (windowTargets.status === "pending" || + windowTargets.fetchStatus === "fetching"); + + const displayErrorMessage = () => { + if (!displayTargets.error) return undefined; + return "Unable to load displays. Try using the Display button."; + }; + + const windowErrorMessage = () => { + if (!windowTargets.error) return undefined; + return "Unable to load windows. Try using the Window button."; + }; + + const selectDisplayTarget = (target: CaptureDisplayWithThumbnail) => { + setOptions( + "captureTarget", + reconcile({ variant: "display", id: target.id }), + ); + setOptions("targetMode", "display"); + commands.openTargetSelectOverlays(rawOptions.captureTarget); + setDisplayMenuOpen(false); + displayTriggerRef?.focus(); + }; + + const selectWindowTarget = async (target: CaptureWindowWithThumbnail) => { + setOptions( + "captureTarget", + reconcile({ variant: "window", id: target.id }), + ); + setOptions("targetMode", "window"); + commands.openTargetSelectOverlays(rawOptions.captureTarget); + setWindowMenuOpen(false); + windowTriggerRef?.focus(); + + try { + await commands.focusWindow(target.id); + } catch (error) { + console.error("Failed to focus window:", error); + } + }; + + createEffect(() => { + if (!isRecording()) return; + setDisplayMenuOpen(false); + setWindowMenuOpen(false); + }); + + createUpdateCheck(); + + onMount(async () => { + const targetMode = (window as any).__CAP__.initialTargetMode; + setOptions({ targetMode }); + if (rawOptions.targetMode) commands.openTargetSelectOverlays(null); + else commands.closeTargetSelectOverlays(); + + const currentWindow = getCurrentWindow(); + + const size = getWindowSize(); + currentWindow.setSize(new LogicalSize(size.width, size.height)); + + const unlistenFocus = currentWindow.onFocusChanged( + ({ payload: focused }) => { + if (focused) { + const size = getWindowSize(); + + currentWindow.setSize(new LogicalSize(size.width, size.height)); + } + }, + ); + + const unlistenResize = currentWindow.onResized(() => { + const size = getWindowSize(); + + currentWindow.setSize(new LogicalSize(size.width, size.height)); + }); + + commands.updateAuthPlan(); + + onCleanup(async () => { + (await unlistenFocus)?.(); + (await unlistenResize)?.(); + }); + + const monitor = await primaryMonitor(); + if (!monitor) return; + }); + + 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(); + + createEffect(() => { + if (windowTargets.status !== "success") return; + const signature = createWindowSignature(windowTargets.data); + if (signature !== undefined) setWindowThumbnailsSignature(signature); + }); + + createEffect(() => { + if (displayTargets.status !== "success") return; + const signature = createDisplaySignature(displayTargets.data); + if (signature !== undefined) setDisplayThumbnailsSignature(signature); + }); + + // Refetch thumbnails only when the cheaper lists detect a change. + createEffect(() => { + if (!hasOpenedWindowMenu()) return; + const signature = windowListSignature(); + if (signature === undefined) return; + if (windowTargets.fetchStatus !== "idle") return; + if (windowThumbnailsSignature() === signature) return; + void windowTargets.refetch(); + }); + + createEffect(() => { + if (!hasOpenedDisplayMenu()) return; + const signature = displayListSignature(); + if (signature === undefined) return; + if (displayTargets.fetchStatus !== "idle") return; + if (displayThumbnailsSignature() === signature) return; + 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: CaptureDisplay | undefined; + + 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: CaptureWindow | undefined; + + 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, + }; + } + } + }, + }; + + const toggleTargetMode = (mode: "display" | "window" | "area") => { + if (isRecording()) return; + const nextMode = rawOptions.targetMode === mode ? null : mode; + setOptions("targetMode", nextMode); + if (nextMode) commands.openTargetSelectOverlays(rawOptions.captureTarget); + else commands.closeTargetSelectOverlays(); + }; + + createEffect(() => { + const target = options.target(); + if (!target) return; + const screen = options.screen(); + if (!screen) return; + + if (target.variant === "window" && windows.data?.length === 0) { + setOptions( + "captureTarget", + reconcile({ variant: "display", id: screen.id }), + ); + } + }); + + 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 = () => ( + +
+
+
+ toggleTargetMode("display")} + name="Display" + class="flex-1 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0" + /> + { + setDisplayMenuOpen((prev) => { + const next = !prev; + if (next) { + setWindowMenuOpen(false); + setHasOpenedDisplayMenu(true); + } + return next; + }); + }} + aria-haspopup="menu" + aria-label="Choose display" + /> +
+
+ toggleTargetMode("window")} + name="Window" + class="flex-1 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0" + /> + { + setWindowMenuOpen((prev) => { + const next = !prev; + if (next) { + setDisplayMenuOpen(false); + setHasOpenedWindowMenu(true); + } + return next; + }); + }} + aria-haspopup="menu" + aria-label="Choose window" + /> +
+ toggleTargetMode("area")} + name="Area" + /> +
+ +
+
+ ); + + const startSignInCleanup = listen("start-sign-in", async () => { + const abort = new AbortController(); + for (const win of await getAllWebviewWindows()) { + if (win.label.startsWith("target-select-overlay")) { + await win.hide(); + } + } + + await signIn.mutateAsync(abort).catch(() => {}); + + for (const win of await getAllWebviewWindows()) { + if (win.label.startsWith("target-select-overlay")) { + await win.show(); + } + } + }); + onCleanup(() => startSignInCleanup.then((cb) => cb())); + + return ( +
+ +
+
+ Settings}> + + + Previous Recordings}> + + + + {import.meta.env.DEV && ( + + )} +
+ {ostype() === "macos" && ( +
+ )} +
+ + +
+
+ + + }> + + { + 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"} + + + +
+ +
+
+
+ +
+
+ Signing In... + + +
+
+
+ + }> + {(variant) => + variant === "display" ? ( + { + setDisplayMenuOpen(false); + displayTriggerRef?.focus(); + }} + /> + ) : ( + { + setWindowMenuOpen(false); + windowTriggerRef?.focus(); + }} + /> + ) + } + + +
+
+ ); } diff --git a/apps/desktop/src/routes/capture-area.tsx b/apps/desktop/src/routes/capture-area.tsx index d6f724aaeb..75178c2805 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/SelectionHint"; 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 () => { @@ -156,7 +173,7 @@ export default function CaptureArea() { } return ( -
+
+ + { 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/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 1976c0b029..bb1aae24ef 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -5,824 +5,821 @@ import { useSearchParams } from "@solidjs/router"; import { createMutation, useQuery } from "@tanstack/solid-query"; import { LogicalPosition } from "@tauri-apps/api/dpi"; import { emit } from "@tauri-apps/api/event"; -import { createEffect } from "solid-js"; import { - CheckMenuItem, - Menu, - MenuItem, - PredefinedMenuItem, + 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, + createEffect, + createMemo, + createSignal, + Match, + mergeProps, + onCleanup, + Show, + Suspense, + Switch, } from "solid-js"; import { createStore, reconcile } from "solid-js/store"; import { - CROP_ZERO, - type CropBounds, - Cropper, - type CropperRef, - createCropOptionsMenuItems, - type Ratio, + CROP_ZERO, + type CropBounds, + Cropper, + type CropperRef, + createCropOptionsMenuItems, + type Ratio, } from "~/components/Cropper"; import ModeSelect from "~/components/ModeSelect"; +import SelectionHint from "~/components/SelectionHint"; import { authStore, generalSettingsStore } from "~/store"; import { - createCameraMutation, - createOptionsQuery, - createOrganizationsQuery, - listAudioDevices, - listVideoDevices, + createCameraMutation, + createOptionsQuery, + createOrganizationsQuery, + listAudioDevices, + listVideoDevices, } from "~/utils/queries"; import { - type CameraInfo, - commands, - type DeviceOrModelID, - type DisplayId, - events, - type ScreenCaptureTarget, - type TargetUnderCursor, + 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, + RecordingOptionsProvider, + useRecordingOptions, } from "./(window-chrome)/OptionsContext"; const MIN_SIZE = { width: 150, height: 150 }; const capitalize = (str: string) => { - return str.charAt(0).toUpperCase() + str.slice(1); + 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 - ); + 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 ( - - - - ); + return ( + + + + ); } function useOptions() { - const { rawOptions: _rawOptions, setOptions } = createOptionsQuery(); + const { rawOptions: _rawOptions, setOptions } = createOptionsQuery(); - const organizations = createOrganizationsQuery(); - const options = mergeProps(_rawOptions, () => { - const ret: Partial = {}; + const organizations = createOrganizationsQuery(); + const options = mergeProps(_rawOptions, () => { + const ret: Partial = {}; - if ( - (!_rawOptions.organizationId && organizations().length > 0) || - (_rawOptions.organizationId && - organizations().every((o) => o.id !== _rawOptions.organizationId) && - organizations().length > 0) - ) - ret.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; - }); + return ret; + }); - return [options, setOptions] as const; + return [options, setOptions] as const; } function Inner() { - const [params] = useSearchParams<{ - displayId: DisplayId; - isHoveredDisplay: string; - }>(); - const [options, setOptions] = useOptions(); - - const [toggleModeSelect, setToggleModeSelect] = createSignal(false); - - const [targetUnderCursor, setTargetUnderCursor] = - createStore({ - display_id: null, - window: null, - }); - - const unsubTargetUnderCursor = events.targetUnderCursor.listen((event) => { - setTargetUnderCursor(reconcile(event.payload)); - }); - onCleanup(() => unsubTargetUnderCursor.then((unsub) => unsub())); - - const windowIcon = useQuery(() => ({ - queryKey: ["windowIcon", targetUnderCursor.window?.id], - queryFn: async () => { - if (!targetUnderCursor.window?.id) return null; - return await commands.getWindowIcon( - targetUnderCursor.window.id.toString() - ); - }, - enabled: !!targetUnderCursor.window?.id, - staleTime: 5 * 60 * 1000, // Cache for 5 minutes - })); - - const displayInformation = useQuery(() => ({ - queryKey: ["displayId", params.displayId], - queryFn: async () => { - if (!params.displayId) return null; - try { - const info = await commands.displayInformation(params.displayId); - return info; - } catch (error) { - console.error("Failed to fetch screen information:", error); - return null; - } - }, - enabled: params.displayId !== undefined && options.targetMode === "display", - })); - - const [crop, setCrop] = createSignal(CROP_ZERO); - type AreaTarget = Extract; - const [pendingAreaTarget, setPendingAreaTarget] = - createSignal(null); - - 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); - } - return mode; - }); - - const [initialAreaBounds, setInitialAreaBounds] = createSignal< - CropBounds | undefined - >(undefined); - - createEffect(() => { - const target = options.captureTarget; - if (target.variant !== "area") return; - if (!params.displayId || target.screen !== params.displayId) return; - if (initialAreaBounds() !== undefined) return; - setInitialAreaBounds({ - x: target.bounds.position.x, - y: target.bounds.position.y, - width: target.bounds.size.width, - height: target.bounds.size.height, - }); - }); - - const unsubOnEscapePress = events.onEscapePress.listen(() => { - setOptions("targetMode", null); - commands.closeTargetSelectOverlays(); - }); - onCleanup(() => unsubOnEscapePress.then((f) => f())); - - // This prevents browser keyboard shortcuts from firing. - // Eg. on Windows Ctrl+P would open the print dialog without this - createEventListener(document, "keydown", (e) => e.preventDefault()); - - return ( - - - {(displayId) => ( -
-
- - - {(display) => ( -
- - - {display.name || "Monitor"} - - - {(size) => ( - - {`${size().width}x${size().height} ยท ${ - display.refresh_rate - }FPS`} - - )} - -
- )} -
- - - {/* Transparent overlay to capture outside clicks */} -
setToggleModeSelect(false)} - /> - setToggleModeSelect(false)} - /> - - - - -
- )} - - - - {(windowUnderCursor) => ( -
-
-
-
- - - {(icon) => ( - {`${windowUnderCursor.app_name} - )} - - -
- - {windowUnderCursor.app_name} - - - {`${windowUnderCursor.bounds.size.width}x${windowUnderCursor.bounds.size.height}`} - -
- - - - -
-
- )} -
-
- - {(displayId) => { - let controlsEl: HTMLDivElement | undefined; - let cropperRef: CropperRef | undefined; - - const [aspect, setAspect] = createSignal(null); - const [snapToRatioEnabled, setSnapToRatioEnabled] = - createSignal(true); - const [isInteracting, setIsInteracting] = createSignal(false); - const [committedCrop, setCommittedCrop] = - createSignal(CROP_ZERO); - - const isValid = createMemo(() => { - const b = crop(); - return b.width >= MIN_SIZE.width && b.height >= MIN_SIZE.height; - }); - const committedIsValid = createMemo(() => { - const b = committedCrop(); - return b.width >= MIN_SIZE.width && b.height >= MIN_SIZE.height; - }); - - createEffect(() => { - if (isInteracting()) return; - setCommittedCrop(crop()); - }); - - async function showCropOptionsMenu(e: UIEvent) { - e.preventDefault(); - const items = [ - { - text: "Reset selection", - action: () => { - cropperRef?.reset(); - setAspect(null); - setPendingAreaTarget(null); - }, - }, - await PredefinedMenuItem.new({ - item: "Separator", - }), - ...createCropOptionsMenuItems({ - aspect: aspect(), - snapToRatioEnabled: snapToRatioEnabled(), - onAspectSet: setAspect, - onSnapToRatioSet: setSnapToRatioEnabled, - }), - ]; - const menu = await Menu.new({ items }); - await menu.popup(); - await menu.close(); - } - - // Spacing rules: - // Prefer below the crop (smaller margin) - // If no space below, place above the crop (larger top margin) - // Otherwise, place inside at the top of the crop (small inner margin) - const macos = ostype() === "macos"; - const SIDE_MARGIN = 16; - const MARGIN_BELOW = 16; - const MARGIN_TOP_OUTSIDE = 16; - const MARGIN_TOP_INSIDE = macos ? 40 : 28; - const TOP_SAFE_MARGIN = macos ? 40 : 10; // keep clear of notch on MacBooks - - const controlsSize = createElementSize(() => controlsEl); - const [controllerInside, _setControllerInside] = createSignal(false); - - // This is required due to the use of a ResizeObserver within the createElementSize function - // Otherwise there will be an infinite loop: ResizeObserver loop completed with undelivered notifications. - let raf: number | null = null; - function setControllerInside(value: boolean) { - if (raf) cancelAnimationFrame(raf); - raf = requestAnimationFrame(() => _setControllerInside(value)); - } - onCleanup(() => { - if (raf) cancelAnimationFrame(raf); - }); - - const controlsStyle = createMemo(() => { - const bounds = crop(); - const size = controlsSize; - if (!size?.width || !size?.height) return undefined; - - if (size.width === 0 || bounds.width === 0) { - return { transform: "translate(-1000px, -1000px)" }; // Hide off-screen initially - } - - const centerX = bounds.x + bounds.width / 2; - let finalY: number; - - // Try below the crop - const belowY = bounds.y + bounds.height + MARGIN_BELOW; - if (belowY + size.height <= window.innerHeight) { - finalY = belowY; - setControllerInside(false); - } else { - // Try above the crop with a larger top margin - const aboveY = bounds.y - size.height - MARGIN_TOP_OUTSIDE; - if (aboveY >= TOP_SAFE_MARGIN) { - finalY = aboveY; - setControllerInside(false); - } else { - // Default to inside - finalY = bounds.y + MARGIN_TOP_INSIDE; - setControllerInside(true); - } - } - - const finalX = Math.max( - SIDE_MARGIN, - Math.min( - centerX - size.width / 2, - window.innerWidth - size.width - SIDE_MARGIN - ) - ); - - return { - transform: `translate(${finalX}px, ${finalY}px)`, - }; - }); - - createEffect(() => { - if (isInteracting()) return; - if (!committedIsValid()) return; - const screenId = displayId(); - if (!screenId) return; - const bounds = committedCrop(); - setPendingAreaTarget({ - variant: "area", - screen: screenId, - bounds: { - position: { x: bounds.x, y: bounds.y }, - size: { width: bounds.width, height: bounds.height }, - }, - }); - }); - - return ( -
-
-
- - -
-

Minimum size is 150 x 150

- - - {crop().width} x {crop().height} - {" "} - is too small - -
-
- - - -
-
- - showCropOptionsMenu(e)} - /> -
- ); - }} -
- - ); + const [params] = useSearchParams<{ + displayId: DisplayId; + isHoveredDisplay: string; + }>(); + const [options, setOptions] = useOptions(); + + const [toggleModeSelect, setToggleModeSelect] = createSignal(false); + + const [targetUnderCursor, setTargetUnderCursor] = + createStore({ + display_id: null, + window: null, + }); + + const unsubTargetUnderCursor = events.targetUnderCursor.listen((event) => { + setTargetUnderCursor(reconcile(event.payload)); + }); + onCleanup(() => unsubTargetUnderCursor.then((unsub) => unsub())); + + const windowIcon = useQuery(() => ({ + queryKey: ["windowIcon", targetUnderCursor.window?.id], + queryFn: async () => { + if (!targetUnderCursor.window?.id) return null; + return await commands.getWindowIcon( + targetUnderCursor.window.id.toString(), + ); + }, + enabled: !!targetUnderCursor.window?.id, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + })); + + const displayInformation = useQuery(() => ({ + queryKey: ["displayId", params.displayId], + queryFn: async () => { + if (!params.displayId) return null; + try { + const info = await commands.displayInformation(params.displayId); + return info; + } catch (error) { + console.error("Failed to fetch screen information:", error); + return null; + } + }, + enabled: params.displayId !== undefined && options.targetMode === "display", + })); + + 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(); + }); + onCleanup(() => unsubOnEscapePress.then((f) => f())); + + // This prevents browser keyboard shortcuts from firing. + // Eg. on Windows Ctrl+P would open the print dialog without this + createEventListener(document, "keydown", (e) => e.preventDefault()); + + return ( + + + {(displayId) => ( +
+
+ + + {(display) => ( +
+ + + {display.name || "Monitor"} + + + {(size) => ( + + {`${size().width}x${size().height} ยท ${ + display.refresh_rate + }FPS`} + + )} + +
+ )} +
+ + + {/* Transparent overlay to capture outside clicks */} +
setToggleModeSelect(false)} + /> + setToggleModeSelect(false)} + /> + + + + +
+ )} + + + + {(windowUnderCursor) => ( +
+
+
+
+ + + {(icon) => ( + {`${windowUnderCursor.app_name} + )} + + +
+ + {windowUnderCursor.app_name} + + + {`${windowUnderCursor.bounds.size.width}x${windowUnderCursor.bounds.size.height}`} + +
+ + + + +
+
+ )} +
+
+ + {(displayId) => { + let controlsEl: HTMLDivElement | undefined; + let cropperRef: CropperRef | undefined; + + const [aspect, setAspect] = createSignal(null); + const [snapToRatioEnabled, setSnapToRatioEnabled] = + createSignal(true); + const [isInteracting, setIsInteracting] = createSignal(false); + const [committedCrop, setCommittedCrop] = + createSignal(CROP_ZERO); + const shouldShowSelectionHint = createMemo(() => { + if (initialAreaBounds() !== undefined) return false; + const bounds = crop(); + return bounds.width <= 1 && bounds.height <= 1 && !isInteracting(); + }); + + const isValid = createMemo(() => { + const b = crop(); + return b.width >= MIN_SIZE.width && b.height >= MIN_SIZE.height; + }); + const committedIsValid = createMemo(() => { + const b = committedCrop(); + return b.width >= MIN_SIZE.width && b.height >= MIN_SIZE.height; + }); + + createEffect(() => { + if (isInteracting()) return; + setCommittedCrop(crop()); + }); + + async function showCropOptionsMenu(e: UIEvent) { + e.preventDefault(); + const items = [ + { + text: "Reset selection", + action: () => { + cropperRef?.reset(); + setAspect(null); + setPendingAreaTarget(null); + }, + }, + await PredefinedMenuItem.new({ + item: "Separator", + }), + ...createCropOptionsMenuItems({ + aspect: aspect(), + snapToRatioEnabled: snapToRatioEnabled(), + onAspectSet: setAspect, + onSnapToRatioSet: setSnapToRatioEnabled, + }), + ]; + const menu = await Menu.new({ items }); + await menu.popup(); + await menu.close(); + } + + // Spacing rules: + // Prefer below the crop (smaller margin) + // If no space below, place above the crop (larger top margin) + // Otherwise, place inside at the top of the crop (small inner margin) + const macos = ostype() === "macos"; + const SIDE_MARGIN = 16; + const MARGIN_BELOW = 16; + const MARGIN_TOP_OUTSIDE = 16; + const MARGIN_TOP_INSIDE = macos ? 40 : 28; + const TOP_SAFE_MARGIN = macos ? 40 : 10; // keep clear of notch on MacBooks + + const controlsSize = createElementSize(() => controlsEl); + const [controllerInside, _setControllerInside] = createSignal(false); + + // This is required due to the use of a ResizeObserver within the createElementSize function + // Otherwise there will be an infinite loop: ResizeObserver loop completed with undelivered notifications. + let raf: number | null = null; + function setControllerInside(value: boolean) { + if (raf) cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => _setControllerInside(value)); + } + onCleanup(() => { + if (raf) cancelAnimationFrame(raf); + }); + + const controlsStyle = createMemo(() => { + const bounds = crop(); + const size = controlsSize; + if (!size?.width || !size?.height) return undefined; + + if (size.width === 0 || bounds.width === 0) { + return { transform: "translate(-1000px, -1000px)" }; // Hide off-screen initially + } + + const centerX = bounds.x + bounds.width / 2; + let finalY: number; + + // Try below the crop + const belowY = bounds.y + bounds.height + MARGIN_BELOW; + if (belowY + size.height <= window.innerHeight) { + finalY = belowY; + setControllerInside(false); + } else { + // Try above the crop with a larger top margin + const aboveY = bounds.y - size.height - MARGIN_TOP_OUTSIDE; + if (aboveY >= TOP_SAFE_MARGIN) { + finalY = aboveY; + setControllerInside(false); + } else { + // Default to inside + finalY = bounds.y + MARGIN_TOP_INSIDE; + setControllerInside(true); + } + } + + const finalX = Math.max( + SIDE_MARGIN, + Math.min( + centerX - size.width / 2, + window.innerWidth - size.width - SIDE_MARGIN, + ), + ); + + return { + transform: `translate(${finalX}px, ${finalY}px)`, + }; + }); + + createEffect(() => { + if (isInteracting()) return; + if (!committedIsValid()) return; + const screenId = displayId(); + if (!screenId) return; + const bounds = committedCrop(); + setPendingAreaTarget({ + variant: "area", + screen: screenId, + bounds: { + position: { x: bounds.x, y: bounds.y }, + size: { width: bounds.width, height: bounds.height }, + }, + }); + }); + + return ( +
+
+
+ + +
+

Minimum size is 150 x 150

+ + + {crop().width} x {crop().height} + {" "} + is too small + +
+
+ + + +
+
+ + + + initialAreaBounds() ?? CROP_ZERO} + showBounds={isValid()} + aspectRatio={aspect() ?? undefined} + snapToRatioEnabled={snapToRatioEnabled()} + onContextMenu={(e) => showCropOptionsMenu(e)} + /> +
+ ); + }} +
+ + ); } function RecordingControls(props: { - target: ScreenCaptureTarget; - setToggleModeSelect?: (value: boolean) => void; - showBackground?: boolean; - disabled?: boolean; + target: ScreenCaptureTarget; + setToggleModeSelect?: (value: boolean) => void; + showBackground?: boolean; + disabled?: boolean; }) { - const auth = authStore.createQuery(); - const { setOptions, rawOptions } = useRecordingOptions(); - - const generalSetings = generalSettingsStore.createQuery(); - const cameras = useQuery(() => listVideoDevices); - const mics = useQuery(() => listAudioDevices); - const setMicInput = createMutation(() => ({ - mutationFn: async (name: string | null) => { - await commands.setMicInput(name); - setOptions("micName", name); - }, - })); - const setCamera = createCameraMutation(); - - const selectedCamera = createMemo(() => { - if (!rawOptions.cameraID) return null; - return findCamera(cameras.data ?? [], rawOptions.cameraID) ?? null; - }); - - const selectedMicName = createMemo(() => { - if (!rawOptions.micName) return null; - return ( - (mics.data ?? []).find((name) => name === rawOptions.micName) ?? null - ); - }); - - const menuModes = async () => - await Menu.new({ - items: [ - await CheckMenuItem.new({ - text: "Studio Mode", - action: () => { - setOptions("mode", "studio"); - }, - checked: rawOptions.mode === "studio", - }), - await CheckMenuItem.new({ - text: "Instant Mode", - action: () => { - setOptions("mode", "instant"); - }, - checked: rawOptions.mode === "instant", - }), - ], - }); - - 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(); - const rect = (e.target as HTMLDivElement).getBoundingClientRect(); - menu.then((menu) => menu.popup(new LogicalPosition(rect.x, rect.y + 40))); - } - - const startDisabled = () => !!props.disabled; - - return ( - <> -
-
-
-
{ - 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 (startDisabled()) return; - - if (props.target.variant === "area") { - setOptions( - "captureTarget", - reconcile({ - variant: "area", - screen: props.target.screen, - bounds: { - position: { - x: props.target.bounds.position.x, - y: props.target.bounds.position.y, - }, - size: { - width: props.target.bounds.size.width, - height: props.target.bounds.size.height, - }, - }, - }) - ); - } - - commands.startRecording({ - capture_target: props.target, - mode: rawOptions.mode, - capture_system_audio: rawOptions.captureSystemAudio, - }); - }} - > -
- {rawOptions.mode === "studio" ? ( - - ) : ( - - )} -
- - {rawOptions.mode === "instant" && !auth.data - ? "Sign In To Use" - : "Start Recording"} - - - {`${capitalize(rawOptions.mode)} Mode`} - -
-
-
showMenu(menuModes(), e)} - onClick={(e) => showMenu(menuModes(), e)} - > - -
-
-
showMenu(preRecordingMenu(), e)} - onClick={(e) => showMenu(preRecordingMenu(), e)} - > - -
-
-
-
-
- { - if (!camera) setCamera.mutate(null); - else if (camera.model_id) - setCamera.mutate({ ModelID: camera.model_id }); - else setCamera.mutate({ DeviceID: camera.device_id }); - }} - /> - setMicInput.mutate(value)} - /> -
-
-
-
-
props.setToggleModeSelect?.(true)} - class="flex gap-1 justify-center items-center self-center mb-5 transition-opacity duration-200 w-fit hover:opacity-60" - classList={{ - "bg-black/50 p-2 rounded-lg border border-white/10 hover:bg-black/50 hover:opacity-80": - props.showBackground, - "hover:opacity-60": !props.showBackground, - }} - > - -

- What is - {capitalize(rawOptions.mode)} Mode? -

-
-
- - ); + const auth = authStore.createQuery(); + const { setOptions, rawOptions } = useRecordingOptions(); + + const generalSetings = generalSettingsStore.createQuery(); + const cameras = useQuery(() => listVideoDevices); + const mics = useQuery(() => listAudioDevices); + const setMicInput = createMutation(() => ({ + mutationFn: async (name: string | null) => { + await commands.setMicInput(name); + setOptions("micName", name); + }, + })); + const setCamera = createCameraMutation(); + + const selectedCamera = createMemo(() => { + if (!rawOptions.cameraID) return null; + return findCamera(cameras.data ?? [], rawOptions.cameraID) ?? null; + }); + + const selectedMicName = createMemo(() => { + if (!rawOptions.micName) return null; + return ( + (mics.data ?? []).find((name) => name === rawOptions.micName) ?? null + ); + }); + + const menuModes = async () => + await Menu.new({ + items: [ + await CheckMenuItem.new({ + text: "Studio Mode", + action: () => { + setOptions("mode", "studio"); + }, + checked: rawOptions.mode === "studio", + }), + await CheckMenuItem.new({ + text: "Instant Mode", + action: () => { + setOptions("mode", "instant"); + }, + checked: rawOptions.mode === "instant", + }), + ], + }); + + 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(); + const rect = (e.target as HTMLDivElement).getBoundingClientRect(); + menu.then((menu) => menu.popup(new LogicalPosition(rect.x, rect.y + 40))); + } + + const startDisabled = () => !!props.disabled; + + return ( + <> +
+
+
+
{ + 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 (startDisabled()) return; + + if (props.target.variant === "area") { + setOptions( + "captureTarget", + reconcile({ + variant: "area", + screen: props.target.screen, + bounds: { + position: { + x: props.target.bounds.position.x, + y: props.target.bounds.position.y, + }, + size: { + width: props.target.bounds.size.width, + height: props.target.bounds.size.height, + }, + }, + }), + ); + } + + commands.startRecording({ + capture_target: props.target, + mode: rawOptions.mode, + capture_system_audio: rawOptions.captureSystemAudio, + }); + }} + > +
+ {rawOptions.mode === "studio" ? ( + + ) : ( + + )} +
+ + {rawOptions.mode === "instant" && !auth.data + ? "Sign In To Use" + : "Start Recording"} + + + {`${capitalize(rawOptions.mode)} Mode`} + +
+
+
showMenu(menuModes(), e)} + onClick={(e) => showMenu(menuModes(), e)} + > + +
+
+
showMenu(preRecordingMenu(), e)} + onClick={(e) => showMenu(preRecordingMenu(), e)} + > + +
+
+
+
+
+ { + if (!camera) setCamera.mutate(null); + else if (camera.model_id) + setCamera.mutate({ ModelID: camera.model_id }); + else setCamera.mutate({ DeviceID: camera.device_id }); + }} + /> + setMicInput.mutate(value)} + /> +
+
+
+
+
props.setToggleModeSelect?.(true)} + class="flex gap-1 justify-center items-center self-center mb-5 transition-opacity duration-200 w-fit hover:opacity-60" + classList={{ + "bg-black/50 p-2 rounded-lg border border-white/10 hover:bg-black/50 hover:opacity-80": + props.showBackground, + "hover:opacity-60": !props.showBackground, + }} + > + +

+ What is + {capitalize(rawOptions.mode)} Mode? +

+
+
+ + ); } function ShowCapFreeWarning(props: { isInstantMode: boolean }) { - const auth = authStore.createQuery(); - - return ( - - -

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

-
-
- ); + const auth = authStore.createQuery(); + + return ( + + +

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

+
+
+ ); } diff --git a/apps/desktop/src/styles/theme.css b/apps/desktop/src/styles/theme.css index 4d98267241..bf0d0b4165 100644 --- a/apps/desktop/src/styles/theme.css +++ b/apps/desktop/src/styles/theme.css @@ -261,6 +261,119 @@ animation-iteration-count: infinite; } +.cap-selection-hint-monitor { + position: relative; + width: 9.5rem; + height: 6.5rem; +} + +.cap-selection-hint-screen-area { + position: absolute; + top: 4.4%; + left: 3%; + width: 94%; + height: 91%; + overflow: hidden; +} + +.cap-selection-hint-selection { + position: absolute; + top: calc(50% - 26px); + left: calc(50% - 34px); + width: 68px; + height: 52px; + border-radius: 0; + border: 1px solid rgba(255, 255, 255, 0.82); + background: linear-gradient( + 135deg, + rgba(113, 205, 255, 0.4), + rgba(45, 97, 255, 0.2) + ); + box-shadow: 0 16px 35px rgba(0, 0, 0, 0.4); + opacity: 1; + transform-origin: top left; + animation: cap-selection-hint-selection 2.8s cubic-bezier(0.4, 0, 0.2, 1) + infinite; +} + +.cap-selection-hint-cursor { + position: absolute; + top: calc(50% - 26px); + left: calc(50% - 34px); + width: 18px; + height: 26px; + filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.6)); + transform-origin: top left; + animation: cap-selection-hint-cursor 2.8s cubic-bezier(0.4, 0, 0.2, 1) + infinite; +} + +.cap-selection-hint-cursor::after { + content: ""; + position: absolute; + left: 50%; + top: 55%; + width: 20px; + height: 20px; + border-radius: 999px; + border: 2px solid rgba(255, 255, 255, 0.85); + transform: translate(-50%, -50%) scale(0); + opacity: 0; + animation: cap-selection-hint-click 2.8s ease-out infinite; +} + +@keyframes cap-selection-hint-selection { + 0% { + width: 10px; + height: 8px; + } + 50% { + width: 68px; + height: 52px; + } + 100% { + width: 10px; + height: 8px; + } +} + +@keyframes cap-selection-hint-cursor { + 0% { + opacity: 1; + transform: translate3d(0px, 0px, 0) rotate(-6deg); + } + 50% { + transform: translate3d(68px, 52px, 0) rotate(-4deg); + } + 100% { + opacity: 1; + transform: translate3d(0px, 0px, 0) rotate(-6deg); + } +} + +@keyframes cap-selection-hint-click { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0); + } + 10% { + opacity: 0; + transform: translate(-50%, -50%) scale(0); + } + 12% { + opacity: 0.95; + transform: translate(-50%, -50%) scale(0.2); + } + 18% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.85); + } + 100% { + opacity: 0; + transform: translate(-50%, -50%) scale(0); + } +} + /* Tabs container with mask-based fade-out effect */ .tabs-mask-content { diff --git a/packages/ui-solid/icons/cursor-macos.svg b/packages/ui-solid/icons/cursor-macos.svg new file mode 100644 index 0000000000..06d64fb1be --- /dev/null +++ b/packages/ui-solid/icons/cursor-macos.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui-solid/icons/cursor-windows.svg b/packages/ui-solid/icons/cursor-windows.svg new file mode 100644 index 0000000000..81a3415ada --- /dev/null +++ b/packages/ui-solid/icons/cursor-windows.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index a1d9203796..130827bf8b 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -25,6 +25,8 @@ declare global { const IconCapCorners: typeof import('~icons/cap/corners.jsx')['default'] const IconCapCrop: typeof import('~icons/cap/crop.jsx')['default'] const IconCapCursor: typeof import('~icons/cap/cursor.jsx')['default'] + const IconCapCursorMacos: typeof import('~icons/cap/cursor-macos.jsx')['default'] + const IconCapCursorWindows: typeof import('~icons/cap/cursor-windows.jsx')['default'] const IconCapDownload: typeof import('~icons/cap/download.jsx')['default'] const IconCapEditor: typeof import('~icons/cap/editor.jsx')['default'] const IconCapEnlarge: typeof import('~icons/cap/enlarge.jsx')['default'] From 8e4d6605e7553e6b466d9685bf7996893c1ba314 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 18:23:50 +0000 Subject: [PATCH 09/32] Fix target overlay selection and event handling --- .../routes/(window-chrome)/new-main/index.tsx | 6 ++-- .../src/routes/target-select-overlay.tsx | 28 ++++++++++++++----- 2 files changed, 24 insertions(+), 10 deletions(-) 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 327ed98bbe..271d340d77 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -401,7 +401,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(); }; @@ -412,7 +412,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(); @@ -586,7 +586,7 @@ function Page() { if (isRecording()) return; const nextMode = rawOptions.targetMode === mode ? null : mode; setOptions("targetMode", nextMode); - if (nextMode) commands.openTargetSelectOverlays(rawOptions.captureTarget); + if (nextMode) commands.openTargetSelectOverlays(null); else commands.closeTargetSelectOverlays(); }; diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index bb1aae24ef..57c2a0398e 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -290,6 +290,17 @@ function Inner() { left: `${windowUnderCursor.bounds.position.x}px`, top: `${windowUnderCursor.bounds.position.y}px`, }} + onClick={() => { + setOptions( + "captureTarget", + reconcile({ + variant: "window", + id: windowUnderCursor.id, + }), + ); + setOptions("targetMode", null); + commands.closeTargetSelectOverlays(); + }} >
@@ -312,17 +323,20 @@ function Inner() { {`${windowUnderCursor.bounds.size.width}x${windowUnderCursor.bounds.size.height}`}
- +
e.stopPropagation()}> + +
- -
-
- {optionsQuery.rawOptions.micName != null ? ( - <> - -
-
-
- - ) : ( - - )} -
- - {(currentRecording.data?.mode === "studio" || - ostype() === "macos") && ( - togglePause.mutate()} - > - {state().variant === "paused" ? ( - - ) : ( - +
+ +
+
+ + {(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()} - > - - - deleteRecording.mutate()} - > - - + 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" + > + + +
+
+
+
+
-
- -
); } @@ -343,37 +660,6 @@ function ActionButton(props: ComponentProps<"button">) { ); } -function DisconnectedNotice(props: { inputs: RecordingInputState }) { - const affectedInputs = () => { - const list: string[] = []; - if (props.inputs.microphone) list.push("microphone"); - if (props.inputs.camera) list.push("camera"); - return list; - }; - - const deviceLabel = () => { - const list = affectedInputs(); - if (list.length === 0) return ""; - if (list.length === 1) return `${list[0]} disconnected`; - return `${list.join(" and ")} disconnected`; - }; - - const instructionLabel = () => - affectedInputs().length > 1 ? "these devices" : "this device"; - - return ( -
- -
- Recording paused โ€” {deviceLabel()}. - - Reconnect {instructionLabel()} and then resume or stop the recording. - -
-
- ); -} - function formatTime(secs: number) { const minutes = Math.floor(secs / 60); const seconds = Math.floor(secs % 60); @@ -441,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/crates/recording/src/feeds/camera.rs b/crates/recording/src/feeds/camera.rs index 9a5b562c4a..effe43bc04 100644 --- a/crates/recording/src/feeds/camera.rs +++ b/crates/recording/src/feeds/camera.rs @@ -173,6 +173,13 @@ struct InputConnectFailed { id: DeviceOrModelID, } +struct LockedCameraInputReconnected { + id: DeviceOrModelID, + camera_info: cap_camera::CameraInfo, + video_info: VideoInfo, + done_tx: SyncSender<()>, +} + struct NewFrame(FFmpegVideoFrame); struct Unlock; @@ -325,72 +332,141 @@ impl Message for CameraFeed { SetInputError::Initialisation ); - let state = self.state.try_as_open()?; - - let (ready_tx, ready_rx) = oneshot::channel::>(); - let (done_tx, done_rx) = std::sync::mpsc::sync_channel(0); - - let ready = ready_rx - .map(|v| { - v.map_err(|_| SetInputError::BuildStreamCrashed) - .and_then(|v| v) - }) - .shared(); - - state.connecting = Some(ConnectingState { - id: msg.id.clone(), - ready: ready.clone().boxed(), - }); - - let id = msg.id.clone(); - let actor_ref = ctx.actor_ref(); - let new_frame_recipient = actor_ref.clone().recipient(); - - let rt = Runtime::new().expect("Failed to get Tokio runtime!"); - std::thread::spawn(move || { - LocalSet::new().block_on(&rt, async move { - let handle = match setup_camera(&id, new_frame_recipient).await { - Ok(r) => { - let _ = ready_tx.send(Ok(InputConnected { - camera_info: r.camera_info.clone(), - video_info: r.video_info, - done_tx: done_tx.clone(), - })); - - let _ = actor_ref - .ask(InputConnected { - camera_info: r.camera_info.clone(), - video_info: r.video_info, - done_tx: done_tx.clone(), - }) - .await; - - r.handle - } - Err(e) => { - let _ = ready_tx.send(Err(e.clone())); - - let _ = actor_ref.tell(InputConnectFailed { id }).await; - - return; - } - }; - - trace!("Waiting for camera to be done"); - - let _ = done_rx.recv(); - - trace!("Stoppping capture of {:?}", &id); - - let _ = handle.stop_capturing(); - - info!("Stopped capture of {:?}", &id); - }) - }); - - Ok(ready - .map(|v| v.map(|v| (v.camera_info, v.video_info))) - .boxed()) + match &mut self.state { + State::Open(state) => { + let (ready_tx, ready_rx) = + oneshot::channel::>(); + let (done_tx, done_rx) = std::sync::mpsc::sync_channel(0); + + let ready = ready_rx + .map(|v| { + v.map_err(|_| SetInputError::BuildStreamCrashed) + .and_then(|v| v) + }) + .shared(); + + state.connecting = Some(ConnectingState { + id: msg.id.clone(), + ready: ready.clone().boxed(), + }); + + let id = msg.id.clone(); + let actor_ref = ctx.actor_ref(); + let new_frame_recipient = actor_ref.clone().recipient(); + + let rt = Runtime::new().expect("Failed to get Tokio runtime!"); + std::thread::spawn(move || { + LocalSet::new().block_on(&rt, async move { + let handle = match setup_camera(&id, new_frame_recipient).await { + Ok(r) => { + let _ = ready_tx.send(Ok(InputConnected { + camera_info: r.camera_info.clone(), + video_info: r.video_info, + done_tx: done_tx.clone(), + })); + + let _ = actor_ref + .ask(InputConnected { + camera_info: r.camera_info.clone(), + video_info: r.video_info, + done_tx: done_tx.clone(), + }) + .await; + + r.handle + } + Err(e) => { + let _ = ready_tx.send(Err(e.clone())); + + let _ = actor_ref.tell(InputConnectFailed { id }).await; + + return; + } + }; + + trace!("Waiting for camera to be done"); + + let _ = done_rx.recv(); + + trace!("Stoppping capture of {:?}", &id); + + let _ = handle.stop_capturing(); + + info!("Stopped capture of {:?}", &id); + }) + }); + + Ok(ready + .map(|v| v.map(|v| (v.camera_info, v.video_info))) + .boxed()) + } + State::Locked { inner } => { + if inner.id != msg.id { + return Err(SetInputError::Locked(FeedLockedError)); + } + + let (ready_tx, ready_rx) = + oneshot::channel::>(); + let (done_tx, done_rx) = std::sync::mpsc::sync_channel(0); + + let ready = ready_rx + .map(|v| { + v.map_err(|_| SetInputError::BuildStreamCrashed) + .and_then(|v| v) + }) + .shared(); + + let id = msg.id.clone(); + let actor_ref = ctx.actor_ref(); + let new_frame_recipient = actor_ref.clone().recipient(); + + let _ = inner.done_tx.send(()); + + let rt = Runtime::new().expect("Failed to get Tokio runtime!"); + std::thread::spawn(move || { + LocalSet::new().block_on(&rt, async move { + let handle = match setup_camera(&id, new_frame_recipient).await { + Ok(r) => { + let _ = ready_tx.send(Ok(InputConnected { + camera_info: r.camera_info.clone(), + video_info: r.video_info, + done_tx: done_tx.clone(), + })); + + let _ = actor_ref + .tell(LockedCameraInputReconnected { + id: id.clone(), + camera_info: r.camera_info.clone(), + video_info: r.video_info, + done_tx: done_tx.clone(), + }) + .await; + + r.handle + } + Err(e) => { + let _ = ready_tx.send(Err(e.clone())); + return; + } + }; + + trace!("Waiting for camera to be done"); + + let _ = done_rx.recv(); + + trace!("Stoppping capture of {:?}", &id); + + let _ = handle.stop_capturing(); + + info!("Stopped capture of {:?}", &id); + }) + }); + + Ok(ready + .map(|v| v.map(|v| (v.camera_info, v.video_info))) + .boxed()) + } + } } } @@ -585,6 +661,24 @@ impl Message for CameraFeed { } } +impl Message for CameraFeed { + type Reply = (); + + async fn handle( + &mut self, + msg: LockedCameraInputReconnected, + _: &mut Context, + ) -> Self::Reply { + if let State::Locked { inner } = &mut self.state { + if inner.id == msg.id { + inner.camera_info = msg.camera_info; + inner.video_info = msg.video_info; + inner.done_tx = msg.done_tx; + } + } + } +} + impl Message for CameraFeed { type Reply = (); diff --git a/crates/recording/src/feeds/microphone.rs b/crates/recording/src/feeds/microphone.rs index b344ee5729..dcedb967d5 100644 --- a/crates/recording/src/feeds/microphone.rs +++ b/crates/recording/src/feeds/microphone.rs @@ -61,6 +61,7 @@ impl OpenState { { self.attached = Some(AttachedState { id: data.id, + label: data.label.clone(), config: data.config.clone(), buffer_size_frames: data.buffer_size_frames, done_tx: data.done_tx, @@ -76,8 +77,8 @@ struct ConnectingState { } struct AttachedState { - #[allow(dead_code)] id: u32, + label: String, config: SupportedStreamConfig, buffer_size_frames: Option, done_tx: mpsc::SyncSender<()>, @@ -281,11 +282,20 @@ pub struct Lock; struct InputConnected { id: u32, + label: String, config: SupportedStreamConfig, buffer_size_frames: Option, done_tx: SyncSender<()>, } +struct LockedInputReconnected { + id: u32, + label: String, + config: SupportedStreamConfig, + buffer_size_frames: Option, + done_tx: mpsc::SyncSender<()>, +} + struct InputConnectFailed { id: u32, } @@ -320,175 +330,327 @@ impl Message for MicrophoneFeed { async fn handle(&mut self, msg: SetInput, ctx: &mut Context) -> Self::Reply { trace!("MicrophoneFeed.SetInput('{}')", &msg.label); - let state = self.state.try_as_open()?; - - let id = self.input_id_counter; - self.input_id_counter += 1; - - let Some((device, config)) = Self::list().swap_remove(&msg.label) else { - return Err(SetInputError::DeviceNotFound); - }; - - let sample_format = config.sample_format(); - let (stream_config, buffer_size_frames) = stream_config_with_latency(&config); + match &mut self.state { + State::Open(state) => { + let id = self.input_id_counter; + self.input_id_counter += 1; - let (ready_tx, ready_rx) = oneshot::channel::, SetInputError>>(); - let (done_tx, done_rx) = mpsc::sync_channel(0); + let label = msg.label.clone(); + let Some((device, config)) = Self::list().swap_remove(&label) else { + return Err(SetInputError::DeviceNotFound); + }; - let actor_ref = ctx.actor_ref(); - let ready = { - let config_for_ready = config.clone(); - ready_rx - .map(move |v| { - let config = config_for_ready.clone(); - v.map_err(|_| SetInputError::BuildStreamCrashed) - .and_then(|inner| inner) - .map(|buffer_size| (config, buffer_size)) - }) - .shared() - }; - let error_sender = self.error_sender.clone(); - - state.connecting = Some(ConnectingState { - id, - ready: { - let done_tx = done_tx.clone(); - ready - .clone() - .map(move |v| { - v.map(|(config, buffer_size_frames)| InputConnected { - id, - config, - buffer_size_frames, - done_tx, + let sample_format = config.sample_format(); + let (stream_config, buffer_size_frames) = stream_config_with_latency(&config); + + let (ready_tx, ready_rx) = oneshot::channel::, SetInputError>>(); + let (done_tx, done_rx) = mpsc::sync_channel(0); + + let actor_ref = ctx.actor_ref(); + let ready = { + let config_for_ready = config.clone(); + ready_rx + .map(move |v| { + let config = config_for_ready.clone(); + v.map_err(|_| SetInputError::BuildStreamCrashed) + .and_then(|inner| inner) + .map(|buffer_size| (config, buffer_size)) }) - }) - .boxed() - }, - }); + .shared() + }; + let error_sender = self.error_sender.clone(); + + state.connecting = Some(ConnectingState { + id, + ready: { + let done_tx = done_tx.clone(); + ready + .clone() + .map({ + let label = label.clone(); + move |v| { + let label = label.clone(); + v.map(|(config, buffer_size_frames)| InputConnected { + id, + label, + config, + buffer_size_frames, + done_tx, + }) + } + }) + .boxed() + }, + }); + + std::thread::spawn({ + let config = config.clone(); + let stream_config = stream_config.clone(); + let device_name_for_log = device.name().ok(); + move || { + if let Some(ref name) = device_name_for_log { + info!("Device '{}' available configs:", name); + for config in device.supported_input_configs().into_iter().flatten() { + info!( + " Format: {:?}, Min rate: {}, Max rate: {}, Sample size: {}", + config.sample_format(), + config.min_sample_rate().0, + config.max_sample_rate().0, + config.sample_format().sample_size() + ); + } + } + + let buffer_size_description = match &stream_config.buffer_size { + BufferSize::Default => "default".to_string(), + BufferSize::Fixed(frames) => format!( + "{} frames (~{:.1}ms)", + frames, + (*frames as f64 / config.sample_rate().0 as f64) * 1000.0 + ), + }; - std::thread::spawn({ - let config = config.clone(); - let stream_config = stream_config.clone(); - let device_name_for_log = device.name().ok(); - move || { - // Log all configs for debugging - if let Some(ref name) = device_name_for_log { - info!("Device '{}' available configs:", name); - for config in device.supported_input_configs().into_iter().flatten() { info!( - " Format: {:?}, Min rate: {}, Max rate: {}, Sample size: {}", - config.sample_format(), - config.min_sample_rate().0, - config.max_sample_rate().0, - config.sample_format().sample_size() + "๐ŸŽค Building stream for '{:?}' with config: rate={}, channels={}, format={:?}, buffer_size={}", + device_name_for_log, + config.sample_rate().0, + config.channels(), + sample_format, + buffer_size_description ); + + let stream = match device.build_input_stream_raw( + &stream_config, + sample_format, + { + let actor_ref = actor_ref.clone(); + let mut callback_count = 0u64; + move |data, info| { + if callback_count == 0 { + info!( + "๐ŸŽค First audio callback - data size: {} bytes, format: {:?}", + data.bytes().len(), + data.sample_format() + ); + } + callback_count += 1; + + let _ = actor_ref + .tell(MicrophoneSamples { + data: data.bytes().to_vec(), + format: data.sample_format(), + info: info.clone(), + timestamp: Timestamp::from_cpal(info.timestamp().capture), + }) + .try_send(); + } + }, + move |e| { + error!("Microphone stream error: {e}"); + + let _ = error_sender.send(e).is_err(); + }, + None, + ) { + Ok(stream) => stream, + Err(e) => { + let _ = ready_tx.send(Err(SetInputError::BuildStream(e.to_string()))); + return; + } + }; + + if let Err(e) = stream.play() { + let _ = ready_tx.send(Err(SetInputError::PlayStream(e.to_string()))); + return; + } + + let _ = ready_tx.send(Ok(buffer_size_frames)); + + match done_rx.recv() { + Ok(_) => info!("Microphone actor shut down, ending stream"), + Err(_) => info!("Microphone actor unreachable, ending stream"), + } + } + }); + + tokio::spawn({ + let ready = ready.clone(); + let actor = ctx.actor_ref(); + let done_tx = done_tx; + let label = label.clone(); + async move { + match ready.await { + Ok((config, buffer_size_frames)) => { + let _ = actor + .tell(InputConnected { + id, + label, + config, + buffer_size_frames, + done_tx, + }) + .await; + } + Err(_) => { + let _ = actor.tell(InputConnectFailed { id }).await; + } + } } + }); + + let ready_for_return = ready.clone().map(|result| result.map(|(config, _)| config)); + + Ok(ready_for_return.boxed()) + } + State::Locked { inner } => { + if inner.label != msg.label { + return Err(SetInputError::Locked(FeedLockedError)); } - let buffer_size_description = match &stream_config.buffer_size { - BufferSize::Default => "default".to_string(), - BufferSize::Fixed(frames) => format!( - "{} frames (~{:.1}ms)", - frames, - (*frames as f64 / config.sample_rate().0 as f64) * 1000.0 - ), + let label = msg.label.clone(); + let Some((device, config)) = Self::list().swap_remove(&label) else { + return Err(SetInputError::DeviceNotFound); }; - info!( - "๐ŸŽค Building stream for '{:?}' with config: rate={}, channels={}, format={:?}, buffer_size={}", - device_name_for_log, - config.sample_rate().0, - config.channels(), - sample_format, - buffer_size_description - ); - - let stream = match device.build_input_stream_raw( - &stream_config, - sample_format, - { - let actor_ref = actor_ref.clone(); - let mut callback_count = 0u64; - move |data, info| { - if callback_count == 0 { + let sample_format = config.sample_format(); + let (stream_config, buffer_size_frames) = stream_config_with_latency(&config); + + let (ready_tx, ready_rx) = oneshot::channel::, SetInputError>>(); + let (done_tx, done_rx) = mpsc::sync_channel(0); + + let actor_ref = ctx.actor_ref(); + let ready = { + let config_for_ready = config.clone(); + ready_rx + .map(move |v| { + let config = config_for_ready.clone(); + v.map_err(|_| SetInputError::BuildStreamCrashed) + .and_then(|inner| inner) + .map(|buffer_size| (config, buffer_size)) + }) + .shared() + }; + let error_sender = self.error_sender.clone(); + + let new_id = self.input_id_counter; + self.input_id_counter += 1; + + let _ = inner.done_tx.send(()); + + std::thread::spawn({ + let config = config.clone(); + let stream_config = stream_config.clone(); + let device_name_for_log = device.name().ok(); + move || { + if let Some(ref name) = device_name_for_log { + info!("Device '{}' available configs:", name); + for config in device.supported_input_configs().into_iter().flatten() { info!( - "๐ŸŽค First audio callback - data size: {} bytes, format: {:?}", - data.bytes().len(), - data.sample_format() + " Format: {:?}, Min rate: {}, Max rate: {}, Sample size: {}", + config.sample_format(), + config.min_sample_rate().0, + config.max_sample_rate().0, + config.sample_format().sample_size() ); } - callback_count += 1; - - let _ = actor_ref - .tell(MicrophoneSamples { - data: data.bytes().to_vec(), - format: data.sample_format(), - info: info.clone(), - timestamp: Timestamp::from_cpal(info.timestamp().capture), - }) - .try_send(); } - }, - move |e| { - error!("Microphone stream error: {e}"); - let _ = error_sender.send(e).is_err(); - actor_ref.kill(); - }, - None, - ) { - Ok(stream) => stream, - Err(e) => { - let _ = ready_tx.send(Err(SetInputError::BuildStream(e.to_string()))); - return; - } - }; + let buffer_size_description = match &stream_config.buffer_size { + BufferSize::Default => "default".to_string(), + BufferSize::Fixed(frames) => format!( + "{} frames (~{:.1}ms)", + frames, + (*frames as f64 / config.sample_rate().0 as f64) * 1000.0 + ), + }; - if let Err(e) = stream.play() { - let _ = ready_tx.send(Err(SetInputError::PlayStream(e.to_string()))); - return; - } + info!( + "๐ŸŽค Rebuilding stream for '{:?}' with config: rate={}, channels={}, format={:?}, buffer_size={}", + device_name_for_log, + config.sample_rate().0, + config.channels(), + sample_format, + buffer_size_description + ); - let _ = ready_tx.send(Ok(buffer_size_frames)); + let stream = match device.build_input_stream_raw( + &stream_config, + sample_format, + { + let actor_ref = actor_ref.clone(); + let mut callback_count = 0u64; + move |data, info| { + if callback_count == 0 { + info!( + "๐ŸŽค First audio callback - data size: {} bytes, format: {:?}", + data.bytes().len(), + data.sample_format() + ); + } + callback_count += 1; + + let _ = actor_ref + .tell(MicrophoneSamples { + data: data.bytes().to_vec(), + format: data.sample_format(), + info: info.clone(), + timestamp: Timestamp::from_cpal(info.timestamp().capture), + }) + .try_send(); + } + }, + move |e| { + error!("Microphone stream error: {e}"); + let _ = error_sender.send(e).is_err(); + }, + None, + ) { + Ok(stream) => stream, + Err(e) => { + let _ = ready_tx.send(Err(SetInputError::BuildStream(e.to_string()))); + return; + } + }; - match done_rx.recv() { - Ok(_) => { - info!("Microphone actor shut down, ending stream"); - } - Err(_) => { - info!("Microphone actor unreachable, ending stream"); - } - } - } - }); + if let Err(e) = stream.play() { + let _ = ready_tx.send(Err(SetInputError::PlayStream(e.to_string()))); + return; + } - tokio::spawn({ - let ready = ready.clone(); - let actor = ctx.actor_ref(); - let done_tx = done_tx; - async move { - match ready.await { - Ok((config, buffer_size_frames)) => { - let _ = actor - .tell(InputConnected { - id, - config, - buffer_size_frames, - done_tx, - }) - .await; + let _ = ready_tx.send(Ok(buffer_size_frames)); + + match done_rx.recv() { + Ok(_) => info!("Microphone actor shut down, ending stream"), + Err(_) => info!("Microphone actor unreachable, ending stream"), + } } - Err(_) => { - let _ = actor.tell(InputConnectFailed { id }).await; + }); + + tokio::spawn({ + let ready = ready.clone(); + let actor = ctx.actor_ref(); + let done_tx = done_tx.clone(); + let label = label.clone(); + async move { + if let Ok((config, buffer_size_frames)) = ready.await { + let _ = actor + .tell(LockedInputReconnected { + id: new_id, + label, + config, + buffer_size_frames, + done_tx, + }) + .await; + } } - } - } - }); + }); - let ready_for_return = ready.clone().map(|result| result.map(|(config, _)| config)); + let ready_for_return = ready.clone().map(|result| result.map(|(config, _)| config)); - Ok(ready_for_return.boxed()) + Ok(ready_for_return.boxed()) + } + } } } @@ -636,6 +798,25 @@ impl Message for MicrophoneFeed { } } +impl Message for MicrophoneFeed { + type Reply = (); + + async fn handle( + &mut self, + msg: LockedInputReconnected, + _: &mut Context, + ) -> Self::Reply { + if let State::Locked { inner } = &mut self.state { + if inner.label == msg.label { + inner.id = msg.id; + inner.config = msg.config; + inner.buffer_size_frames = msg.buffer_size_frames; + inner.done_tx = msg.done_tx; + } + } + } +} + impl Message for MicrophoneFeed { type Reply = (); From 0ad1ad0182ebb72682fd00ffcda1af217c0a7b09 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 21:22:55 +0000 Subject: [PATCH 14/32] Bump desktop app version to 0.3.84 --- apps/desktop/src-tauri/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From a44779e7ba0f941c0e5ab8edab3f5514084e2317 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 21:24:22 +0000 Subject: [PATCH 15/32] Update Cargo.lock --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From e0fab010a9d95e1a0b0e530f1fe690ad8d5c8c43 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 21:34:20 +0000 Subject: [PATCH 16/32] clippy bits --- crates/recording/src/feeds/camera.rs | 12 ++++++------ crates/recording/src/feeds/microphone.rs | 14 +++++++------- crates/recording/src/sources/audio_mixer.rs | 6 ++---- crates/rendering/src/lib.rs | 4 ++-- crates/rendering/src/main.rs | 2 +- 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/crates/recording/src/feeds/camera.rs b/crates/recording/src/feeds/camera.rs index effe43bc04..80ca51c4a3 100644 --- a/crates/recording/src/feeds/camera.rs +++ b/crates/recording/src/feeds/camera.rs @@ -669,12 +669,12 @@ impl Message for CameraFeed { msg: LockedCameraInputReconnected, _: &mut Context, ) -> Self::Reply { - if let State::Locked { inner } = &mut self.state { - if inner.id == msg.id { - inner.camera_info = msg.camera_info; - inner.video_info = msg.video_info; - inner.done_tx = msg.done_tx; - } + if let State::Locked { inner } = &mut self.state + && inner.id == msg.id + { + inner.camera_info = msg.camera_info; + inner.video_info = msg.video_info; + inner.done_tx = msg.done_tx; } } } diff --git a/crates/recording/src/feeds/microphone.rs b/crates/recording/src/feeds/microphone.rs index dcedb967d5..4700fa162e 100644 --- a/crates/recording/src/feeds/microphone.rs +++ b/crates/recording/src/feeds/microphone.rs @@ -806,13 +806,13 @@ impl Message for MicrophoneFeed { msg: LockedInputReconnected, _: &mut Context, ) -> Self::Reply { - if let State::Locked { inner } = &mut self.state { - if inner.label == msg.label { - inner.id = msg.id; - inner.config = msg.config; - inner.buffer_size_frames = msg.buffer_size_frames; - inner.done_tx = msg.done_tx; - } + if let State::Locked { inner } = &mut self.state + && inner.label == msg.label + { + inner.id = msg.id; + inner.config = msg.config; + inner.buffer_size_frames = msg.buffer_size_frames; + inner.done_tx = msg.done_tx; } } } diff --git a/crates/recording/src/sources/audio_mixer.rs b/crates/recording/src/sources/audio_mixer.rs index 48474d9699..ee312c971e 100644 --- a/crates/recording/src/sources/audio_mixer.rs +++ b/crates/recording/src/sources/audio_mixer.rs @@ -100,8 +100,7 @@ impl AudioMixerBuilder { &ffmpeg::filter::find("aresample").expect("Failed to find aresample filter"), &format!("resample{i}"), &format!( - "out_sample_rate={}:out_sample_fmt={}:out_chlayout=0x{:x}", - target_rate, target_sample_fmt, target_channel_layout_bits + "out_sample_rate={target_rate}:out_sample_fmt={target_sample_fmt}:out_chlayout=0x{target_channel_layout_bits:x}" ), )?; @@ -118,8 +117,7 @@ impl AudioMixerBuilder { )?; let aformat_args = format!( - "sample_fmts={}:sample_rates={}:channel_layouts=0x{:x}", - target_sample_fmt, target_rate, target_channel_layout_bits + "sample_fmts={target_sample_fmt}:sample_rates={target_rate}:channel_layouts=0x{target_channel_layout_bits:x}" ); let mut aformat = filter_graph.add( diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 7ae4b6fbab..4cd230eeda 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -427,7 +427,7 @@ impl MotionBounds { && self.end.coord.y >= other.end.coord.y } - fn to_uv(&self, point: XY) -> XY { + fn point_to_uv(&self, point: XY) -> XY { let size = self.size(); XY::new( ((point.x - self.start.coord.x) / size.x.max(f64::EPSILON)) as f32, @@ -513,7 +513,7 @@ fn analyze_motion(current: &MotionBounds, previous: &MotionBounds) -> MotionAnal analysis.movement_uv = movement_uv; analysis.movement_magnitude = movement_magnitude; analysis.zoom_magnitude = zoom_magnitude; - analysis.zoom_center_uv = current.to_uv(zoom_center_point); + analysis.zoom_center_uv = current.point_to_uv(zoom_center_point); analysis } diff --git a/crates/rendering/src/main.rs b/crates/rendering/src/main.rs index 9f98d94590..afdde2020f 100644 --- a/crates/rendering/src/main.rs +++ b/crates/rendering/src/main.rs @@ -149,7 +149,7 @@ async fn main() -> Result<()> { OutputFormat::Jpeg => "jpg", OutputFormat::Raw => "raw", }; - PathBuf::from(format!("frame.{}", extension)) + PathBuf::from(format!("frame.{extension}")) }); // Ensure output directory exists From cf0f9d64264c79dd330e273e331fe2919c9d9cd1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 21:44:26 +0000 Subject: [PATCH 17/32] Improve error handling in PauseTracker timestamp adjustment --- crates/recording/src/output_pipeline/win.rs | 32 +++++++++++++++------ 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/recording/src/output_pipeline/win.rs b/crates/recording/src/output_pipeline/win.rs index c9a2950e91..77edf9bd8c 100644 --- a/crates/recording/src/output_pipeline/win.rs +++ b/crates/recording/src/output_pipeline/win.rs @@ -34,21 +34,37 @@ impl PauseTracker { } } - fn adjust(&mut self, timestamp: Duration) -> Option { + fn adjust(&mut self, timestamp: Duration) -> anyhow::Result> { if self.flag.load(Ordering::Relaxed) { if self.paused_at.is_none() { self.paused_at = Some(timestamp); } - return None; + return Ok(None); } if let Some(start) = self.paused_at.take() { - if let Some(delta) = timestamp.checked_sub(start) { - self.offset = self.offset.checked_add(delta).unwrap_or(Duration::MAX); - } + let delta = timestamp.checked_sub(start).ok_or_else(|| { + anyhow!( + "Frame timestamp went backward during unpause (resume={start:?}, current={timestamp:?})" + ) + })?; + + self.offset = self.offset.checked_add(delta).ok_or_else(|| { + anyhow!( + "Pause offset overflow (offset={:?}, delta={delta:?})", + self.offset + ) + })?; } - Some(timestamp.checked_sub(self.offset).unwrap_or(Duration::ZERO)) + let adjusted = timestamp.checked_sub(self.offset).ok_or_else(|| { + anyhow!( + "Adjusted timestamp underflow (timestamp={timestamp:?}, offset={:?})", + self.offset + ) + })?; + + Ok(Some(adjusted)) } } @@ -328,7 +344,7 @@ impl VideoMuxer for WindowsMuxer { frame: Self::VideoFrame, timestamp: Duration, ) -> anyhow::Result<()> { - if let Some(timestamp) = self.pause.adjust(timestamp) { + if let Some(timestamp) = self.pause.adjust(timestamp)? { self.video_tx.send(Some((frame.frame, timestamp)))?; } @@ -338,7 +354,7 @@ impl VideoMuxer for WindowsMuxer { impl AudioMuxer for WindowsMuxer { fn send_audio_frame(&mut self, frame: AudioFrame, timestamp: Duration) -> anyhow::Result<()> { - if let Some(timestamp) = self.pause.adjust(timestamp) { + if let Some(timestamp) = self.pause.adjust(timestamp)? { if let Some(encoder) = self.audio_encoder.as_mut() && let Ok(mut output) = self.output.lock() { From 882072924748d6aa7e5a1394cc69d582d28dece8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 21:44:32 +0000 Subject: [PATCH 18/32] Fix state matching in SetMicFeed and SetCameraFeed handlers --- crates/recording/src/studio_recording.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index 31b78c4d21..c6d2e9fa42 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -229,7 +229,7 @@ impl Message for Actor { type Reply = anyhow::Result<()>; async fn handle(&mut self, msg: SetMicFeed, _: &mut Context) -> Self::Reply { - match self.state { + match self.state.as_ref() { Some(ActorState::Recording { .. }) => { bail!("Pause the recording before changing microphone input") } @@ -254,7 +254,7 @@ impl Message for Actor { msg: SetCameraFeed, _: &mut Context, ) -> Self::Reply { - match self.state { + match self.state.as_ref() { Some(ActorState::Recording { .. }) => { bail!("Pause the recording before changing camera input") } From b2a9d8e0b1609377bc4391caacafeb8e0c09b110 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 21:44:45 +0000 Subject: [PATCH 19/32] Add retry limit to video frame queuing --- crates/recording/src/output_pipeline/macos.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/recording/src/output_pipeline/macos.rs b/crates/recording/src/output_pipeline/macos.rs index 1f1df65368..2b3a74de50 100644 --- a/crates/recording/src/output_pipeline/macos.rs +++ b/crates/recording/src/output_pipeline/macos.rs @@ -63,6 +63,8 @@ impl Muxer for AVFoundationMp4Muxer { impl VideoMuxer for AVFoundationMp4Muxer { type VideoFrame = screen_capture::VideoFrame; + const MAX_QUEUE_RETRIES: u32 = 500; + fn send_video_frame( &mut self, frame: Self::VideoFrame, @@ -76,10 +78,18 @@ impl VideoMuxer for AVFoundationMp4Muxer { mp4.resume(); } + let mut retry_count = 0; loop { match mp4.queue_video_frame(frame.sample_buf.clone(), timestamp) { Ok(()) => break, Err(QueueFrameError::NotReadyForMore) => { + retry_count += 1; + if retry_count >= Self::MAX_QUEUE_RETRIES { + return Err(anyhow!( + "send_video_frame/timeout after {} retries", + Self::MAX_QUEUE_RETRIES + )); + } std::thread::sleep(Duration::from_millis(2)); continue; } From ff746c0fafc3efeae2374800f67f421b4d34b339 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 21:56:51 +0000 Subject: [PATCH 20/32] Handle locked camera state changes on reconnect --- crates/recording/src/feeds/camera.rs | 59 ++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/crates/recording/src/feeds/camera.rs b/crates/recording/src/feeds/camera.rs index 80ca51c4a3..84a390f6fc 100644 --- a/crates/recording/src/feeds/camera.rs +++ b/crates/recording/src/feeds/camera.rs @@ -420,29 +420,58 @@ impl Message for CameraFeed { let actor_ref = ctx.actor_ref(); let new_frame_recipient = actor_ref.clone().recipient(); - let _ = inner.done_tx.send(()); - let rt = Runtime::new().expect("Failed to get Tokio runtime!"); std::thread::spawn(move || { LocalSet::new().block_on(&rt, async move { let handle = match setup_camera(&id, new_frame_recipient).await { Ok(r) => { - let _ = ready_tx.send(Ok(InputConnected { - camera_info: r.camera_info.clone(), - video_info: r.video_info, + let SetupCameraResult { + handle, + camera_info, + video_info, + } = r; + + let ready_payload = InputConnected { + camera_info: camera_info.clone(), + video_info, done_tx: done_tx.clone(), - })); + }; - let _ = actor_ref - .tell(LockedCameraInputReconnected { + let reconnect_result = actor_ref + .ask(LockedCameraInputReconnected { id: id.clone(), - camera_info: r.camera_info.clone(), - video_info: r.video_info, + camera_info, + video_info, done_tx: done_tx.clone(), }) .await; - r.handle + match reconnect_result { + Ok(true) => { + let _ = ready_tx.send(Ok(ready_payload)); + handle + } + Ok(false) => { + warn!( + "Locked camera state changed before reconnecting {:?}", + id + ); + let _ = + ready_tx.send(Err(SetInputError::BuildStreamCrashed)); + let _ = handle.stop_capturing(); + return; + } + Err(err) => { + error!( + ?err, + "Failed to update locked camera state for {:?}", id + ); + let _ = + ready_tx.send(Err(SetInputError::BuildStreamCrashed)); + let _ = handle.stop_capturing(); + return; + } + } } Err(e) => { let _ = ready_tx.send(Err(e.clone())); @@ -662,7 +691,7 @@ impl Message for CameraFeed { } impl Message for CameraFeed { - type Reply = (); + type Reply = bool; async fn handle( &mut self, @@ -674,7 +703,11 @@ impl Message for CameraFeed { { inner.camera_info = msg.camera_info; inner.video_info = msg.video_info; - inner.done_tx = msg.done_tx; + let previous_done_tx = std::mem::replace(&mut inner.done_tx, msg.done_tx); + let _ = previous_done_tx.send(()); + true + } else { + false } } } From 1a9b3f233eff855018dbad0089aadffca1ed565a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 22:07:56 +0000 Subject: [PATCH 21/32] Refactor camera feed state management and setup flow --- crates/recording/src/feeds/camera.rs | 385 ++++++++++++++++----------- 1 file changed, 228 insertions(+), 157 deletions(-) diff --git a/crates/recording/src/feeds/camera.rs b/crates/recording/src/feeds/camera.rs index 84a390f6fc..02f4a7fc42 100644 --- a/crates/recording/src/feeds/camera.rs +++ b/crates/recording/src/feeds/camera.rs @@ -3,7 +3,10 @@ use cap_camera_ffmpeg::*; use cap_fail::fail_err; use cap_media_info::VideoInfo; use cap_timestamp::Timestamp; -use futures::{FutureExt, future::BoxFuture}; +use futures::{ + FutureExt, + future::{BoxFuture, Shared}, +}; use kameo::prelude::*; use replace_with::replace_with_or_abort; use std::{ @@ -48,23 +51,23 @@ struct OpenState { } impl OpenState { - fn handle_input_connected(&mut self, data: InputConnected, id: DeviceOrModelID) { + fn handle_input_connected(&mut self, data: InputConnected, id: DeviceOrModelID) -> bool { if let Some(connecting) = &self.connecting && id == connecting.id { - if let Some(attached) = self.attached.take() { - let _ = attached.done_tx.send(()); - } - trace!("Attaching new camera"); - self.attached = Some(AttachedState { - id, - camera_info: data.camera_info, - video_info: data.video_info, - done_tx: data.done_tx, - }); + if let Some(attached) = &mut self.attached { + attached.stage_pending_release(); + attached.overwrite(id, data); + } else { + self.attached = Some(AttachedState::new(id, data)); + } + self.connecting = None; + true + } else { + false } } } @@ -80,6 +83,52 @@ struct AttachedState { camera_info: cap_camera::CameraInfo, video_info: VideoInfo, done_tx: mpsc::SyncSender<()>, + pending_release: Option>, +} + +impl AttachedState { + fn new(id: DeviceOrModelID, data: InputConnected) -> Self { + let InputConnected { + done_tx, + camera_info, + video_info, + } = data; + + Self { + id, + camera_info, + video_info, + done_tx, + pending_release: None, + } + } + + fn overwrite(&mut self, id: DeviceOrModelID, data: InputConnected) { + let InputConnected { + done_tx, + camera_info, + video_info, + } = data; + + self.id = id; + self.camera_info = camera_info; + self.video_info = video_info; + self.done_tx = done_tx; + } + + fn stage_pending_release(&mut self) { + if let Some(pending) = self.pending_release.take() { + let _ = pending.send(()); + } + + self.pending_release = Some(self.done_tx.clone()); + } + + fn finalize_pending_release(&mut self) { + if let Some(pending) = self.pending_release.take() { + let _ = pending.send(()); + } + } } impl Default for CameraFeed { @@ -169,6 +218,14 @@ struct InputConnected { video_info: VideoInfo, } +type ReadyFuture = Shared>>; + +#[derive(Clone, Copy)] +enum CameraSetupFlow { + Open, + Locked, +} + struct InputConnectFailed { id: DeviceOrModelID, } @@ -184,6 +241,101 @@ struct NewFrame(FFmpegVideoFrame); struct Unlock; +struct FinalizePendingRelease { + id: DeviceOrModelID, +} + +fn spawn_camera_setup( + id: DeviceOrModelID, + actor_ref: ActorRef, + new_frame_recipient: Recipient, + flow: CameraSetupFlow, +) -> (ReadyFuture, SyncSender<()>) { + let (ready_tx, ready_rx) = oneshot::channel::>(); + let (done_tx, done_rx) = std::sync::mpsc::sync_channel(0); + + let ready = ready_rx + .map(|v| { + v.map_err(|_| SetInputError::BuildStreamCrashed) + .and_then(|v| v) + }) + .boxed() + .shared(); + + let runtime = Runtime::new().expect("Failed to get Tokio runtime!"); + let done_rx_thread = done_rx; + let done_tx_thread = done_tx.clone(); + let ready_tx_thread = ready_tx; + + std::thread::spawn(move || { + LocalSet::new().block_on(&runtime, async move { + let handle = match setup_camera(&id, new_frame_recipient).await { + Ok(result) => { + let SetupCameraResult { + handle, + camera_info, + video_info, + } = result; + + let ready_payload = InputConnected { + camera_info: camera_info.clone(), + video_info, + done_tx: done_tx_thread.clone(), + }; + + match flow { + CameraSetupFlow::Open => { + let _ = ready_tx_thread.send(Ok(ready_payload.clone())); + let _ = actor_ref.ask(ready_payload).await; + } + CameraSetupFlow::Locked => { + if actor_ref + .tell(LockedCameraInputReconnected { + id: id.clone(), + camera_info, + video_info, + done_tx: done_tx_thread.clone(), + }) + .await + .is_err() + { + let _ = + ready_tx_thread.send(Err(SetInputError::BuildStreamCrashed)); + let _ = handle.stop_capturing(); + return; + } + let _ = ready_tx_thread.send(Ok(ready_payload)); + } + } + + handle + } + Err(e) => { + let _ = ready_tx_thread.send(Err(e.clone())); + + if matches!(flow, CameraSetupFlow::Open) { + let _ = actor_ref.tell(InputConnectFailed { id: id.clone() }).await; + } + + return; + } + }; + + trace!("Waiting for camera to be done"); + + let _ = done_rx_thread.recv(); + + trace!("Stoppping capture of {:?}", &id); + + let _ = handle.stop_capturing(); + + info!("Stopped capture of {:?}", &id); + }) + }); + + (ready, done_tx) +} + // Impls #[derive(Debug, Clone, Copy, thiserror::Error)] @@ -334,66 +486,20 @@ impl Message for CameraFeed { match &mut self.state { State::Open(state) => { - let (ready_tx, ready_rx) = - oneshot::channel::>(); - let (done_tx, done_rx) = std::sync::mpsc::sync_channel(0); - - let ready = ready_rx - .map(|v| { - v.map_err(|_| SetInputError::BuildStreamCrashed) - .and_then(|v| v) - }) - .shared(); - - state.connecting = Some(ConnectingState { - id: msg.id.clone(), - ready: ready.clone().boxed(), - }); - - let id = msg.id.clone(); let actor_ref = ctx.actor_ref(); let new_frame_recipient = actor_ref.clone().recipient(); + let id = msg.id.clone(); - let rt = Runtime::new().expect("Failed to get Tokio runtime!"); - std::thread::spawn(move || { - LocalSet::new().block_on(&rt, async move { - let handle = match setup_camera(&id, new_frame_recipient).await { - Ok(r) => { - let _ = ready_tx.send(Ok(InputConnected { - camera_info: r.camera_info.clone(), - video_info: r.video_info, - done_tx: done_tx.clone(), - })); - - let _ = actor_ref - .ask(InputConnected { - camera_info: r.camera_info.clone(), - video_info: r.video_info, - done_tx: done_tx.clone(), - }) - .await; - - r.handle - } - Err(e) => { - let _ = ready_tx.send(Err(e.clone())); - - let _ = actor_ref.tell(InputConnectFailed { id }).await; - - return; - } - }; - - trace!("Waiting for camera to be done"); - - let _ = done_rx.recv(); - - trace!("Stoppping capture of {:?}", &id); - - let _ = handle.stop_capturing(); + let (ready, _done_tx) = spawn_camera_setup( + id.clone(), + actor_ref, + new_frame_recipient, + CameraSetupFlow::Open, + ); - info!("Stopped capture of {:?}", &id); - }) + state.connecting = Some(ConnectingState { + id, + ready: ready.clone().boxed(), }); Ok(ready @@ -405,91 +511,17 @@ impl Message for CameraFeed { return Err(SetInputError::Locked(FeedLockedError)); } - let (ready_tx, ready_rx) = - oneshot::channel::>(); - let (done_tx, done_rx) = std::sync::mpsc::sync_channel(0); + let _ = inner.done_tx.send(()); - let ready = ready_rx - .map(|v| { - v.map_err(|_| SetInputError::BuildStreamCrashed) - .and_then(|v| v) - }) - .shared(); - - let id = msg.id.clone(); let actor_ref = ctx.actor_ref(); let new_frame_recipient = actor_ref.clone().recipient(); - let rt = Runtime::new().expect("Failed to get Tokio runtime!"); - std::thread::spawn(move || { - LocalSet::new().block_on(&rt, async move { - let handle = match setup_camera(&id, new_frame_recipient).await { - Ok(r) => { - let SetupCameraResult { - handle, - camera_info, - video_info, - } = r; - - let ready_payload = InputConnected { - camera_info: camera_info.clone(), - video_info, - done_tx: done_tx.clone(), - }; - - let reconnect_result = actor_ref - .ask(LockedCameraInputReconnected { - id: id.clone(), - camera_info, - video_info, - done_tx: done_tx.clone(), - }) - .await; - - match reconnect_result { - Ok(true) => { - let _ = ready_tx.send(Ok(ready_payload)); - handle - } - Ok(false) => { - warn!( - "Locked camera state changed before reconnecting {:?}", - id - ); - let _ = - ready_tx.send(Err(SetInputError::BuildStreamCrashed)); - let _ = handle.stop_capturing(); - return; - } - Err(err) => { - error!( - ?err, - "Failed to update locked camera state for {:?}", id - ); - let _ = - ready_tx.send(Err(SetInputError::BuildStreamCrashed)); - let _ = handle.stop_capturing(); - return; - } - } - } - Err(e) => { - let _ = ready_tx.send(Err(e.clone())); - return; - } - }; - - trace!("Waiting for camera to be done"); - - let _ = done_rx.recv(); - - trace!("Stoppping capture of {:?}", &id); - - let _ = handle.stop_capturing(); - - info!("Stopped capture of {:?}", &id); - }) - }); + let (ready, _done_tx) = spawn_camera_setup( + msg.id.clone(), + actor_ref, + new_frame_recipient, + CameraSetupFlow::Locked, + ); Ok(ready .map(|v| v.map(|v| (v.camera_info, v.video_info))) @@ -509,8 +541,9 @@ impl Message for CameraFeed { state.connecting = None; - if let Some(AttachedState { done_tx, .. }) = state.attached.take() { - let _ = done_tx.send(()); + if let Some(mut attached) = state.attached.take() { + attached.finalize_pending_release(); + let _ = attached.done_tx.send(()); } for cb in &self.on_disconnect { @@ -609,7 +642,11 @@ impl Message for CameraFeed { let ready = &mut connecting.ready; let data = ready.await?; - state.handle_input_connected(data, id); + if state.handle_input_connected(data, id) { + if let Some(attached) = &mut state.attached { + attached.finalize_pending_release(); + } + } } let Some(attached) = state.attached.take() else { @@ -656,7 +693,11 @@ impl Message for CameraFeed { let res = ready.await; if let Ok(data) = res { - state.handle_input_connected(data, id); + if state.handle_input_connected(data, id) { + if let Some(attached) = &mut state.attached { + attached.finalize_pending_release(); + } + } } } @@ -701,10 +742,15 @@ impl Message for CameraFeed { if let State::Locked { inner } = &mut self.state && inner.id == msg.id { - inner.camera_info = msg.camera_info; - inner.video_info = msg.video_info; - let previous_done_tx = std::mem::replace(&mut inner.done_tx, msg.done_tx); - let _ = previous_done_tx.send(()); + inner.stage_pending_release(); + inner.overwrite( + msg.id, + InputConnected { + done_tx: msg.done_tx, + camera_info: msg.camera_info, + video_info: msg.video_info, + }, + ); true } else { false @@ -712,6 +758,31 @@ impl Message for CameraFeed { } } +impl Message for CameraFeed { + type Reply = (); + + async fn handle( + &mut self, + msg: FinalizePendingRelease, + _: &mut Context, + ) -> Self::Reply { + match &mut self.state { + State::Open(OpenState { attached, .. }) => { + if let Some(attached) = attached { + if attached.id == msg.id { + attached.finalize_pending_release(); + } + } + } + State::Locked { inner } => { + if inner.id == msg.id { + inner.finalize_pending_release(); + } + } + } + } +} + impl Message for CameraFeed { type Reply = (); From 8a76af75adf5e5fc36cbb5140e926683f0da198b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 22:10:58 +0000 Subject: [PATCH 22/32] Improve locked camera reconnection handling --- crates/recording/src/feeds/camera.rs | 43 ++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/crates/recording/src/feeds/camera.rs b/crates/recording/src/feeds/camera.rs index 02f4a7fc42..f4be4444da 100644 --- a/crates/recording/src/feeds/camera.rs +++ b/crates/recording/src/feeds/camera.rs @@ -289,22 +289,43 @@ fn spawn_camera_setup( let _ = actor_ref.ask(ready_payload).await; } CameraSetupFlow::Locked => { - if actor_ref - .tell(LockedCameraInputReconnected { + let reconnect_result = actor_ref + .ask(LockedCameraInputReconnected { id: id.clone(), camera_info, video_info, done_tx: done_tx_thread.clone(), }) - .await - .is_err() - { - let _ = - ready_tx_thread.send(Err(SetInputError::BuildStreamCrashed)); - let _ = handle.stop_capturing(); - return; + .await; + + match reconnect_result { + Ok(true) => { + let _ = ready_tx_thread.send(Ok(ready_payload)); + let _ = actor_ref + .tell(FinalizePendingRelease { id: id.clone() }) + .await; + } + Ok(false) => { + warn!( + "Locked camera state changed before reconnecting {:?}", + id + ); + let _ = ready_tx_thread + .send(Err(SetInputError::BuildStreamCrashed)); + let _ = handle.stop_capturing(); + return; + } + Err(err) => { + error!( + ?err, + "Failed to update locked camera state for {:?}", id + ); + let _ = ready_tx_thread + .send(Err(SetInputError::BuildStreamCrashed)); + let _ = handle.stop_capturing(); + return; + } } - let _ = ready_tx_thread.send(Ok(ready_payload)); } } @@ -511,8 +532,6 @@ impl Message for CameraFeed { return Err(SetInputError::Locked(FeedLockedError)); } - let _ = inner.done_tx.send(()); - let actor_ref = ctx.actor_ref(); let new_frame_recipient = actor_ref.clone().recipient(); From 5727d9888120b0a97f8ada2b20e25b237f0012cd Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 22:27:12 +0000 Subject: [PATCH 23/32] Move MAX_QUEUE_RETRIES to struct impl block --- crates/recording/src/output_pipeline/macos.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/recording/src/output_pipeline/macos.rs b/crates/recording/src/output_pipeline/macos.rs index 2b3a74de50..6c67459db3 100644 --- a/crates/recording/src/output_pipeline/macos.rs +++ b/crates/recording/src/output_pipeline/macos.rs @@ -17,6 +17,10 @@ pub struct AVFoundationMp4Muxer( Arc, ); +impl AVFoundationMp4Muxer { + const MAX_QUEUE_RETRIES: u32 = 500; +} + #[derive(Default)] pub struct AVFoundationMp4MuxerConfig { pub output_height: Option, @@ -63,8 +67,6 @@ impl Muxer for AVFoundationMp4Muxer { impl VideoMuxer for AVFoundationMp4Muxer { type VideoFrame = screen_capture::VideoFrame; - const MAX_QUEUE_RETRIES: u32 = 500; - fn send_video_frame( &mut self, frame: Self::VideoFrame, From e6e84ce094b850d6a82c508bc1b764651bd994a2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 22:55:19 +0000 Subject: [PATCH 24/32] Refactor conditional logic for attached finalization --- crates/recording/src/feeds/camera.rs | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/crates/recording/src/feeds/camera.rs b/crates/recording/src/feeds/camera.rs index f4be4444da..a7300ad734 100644 --- a/crates/recording/src/feeds/camera.rs +++ b/crates/recording/src/feeds/camera.rs @@ -661,10 +661,10 @@ impl Message for CameraFeed { let ready = &mut connecting.ready; let data = ready.await?; - if state.handle_input_connected(data, id) { - if let Some(attached) = &mut state.attached { - attached.finalize_pending_release(); - } + if state.handle_input_connected(data, id) + && let Some(attached) = &mut state.attached + { + attached.finalize_pending_release(); } } @@ -711,12 +711,11 @@ impl Message for CameraFeed { let ready = &mut connecting.ready; let res = ready.await; - if let Ok(data) = res { - if state.handle_input_connected(data, id) { - if let Some(attached) = &mut state.attached { - attached.finalize_pending_release(); - } - } + if let Ok(data) = res + && state.handle_input_connected(data, id) + && let Some(attached) = &mut state.attached + { + attached.finalize_pending_release(); } } @@ -787,10 +786,10 @@ impl Message for CameraFeed { ) -> Self::Reply { match &mut self.state { State::Open(OpenState { attached, .. }) => { - if let Some(attached) = attached { - if attached.id == msg.id { - attached.finalize_pending_release(); - } + if let Some(attached) = attached + && attached.id == msg.id + { + attached.finalize_pending_release(); } } State::Locked { inner } => { From 51055314302e2ae8ef6a2e3ef3c5966469c18051 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 23:04:57 +0000 Subject: [PATCH 25/32] Handle errors when saving general settings --- apps/desktop/src-tauri/src/general_settings.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index ae8800e43a..1b193e75b3 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -257,7 +257,9 @@ pub fn init(app: &AppHandle) { store.recording_picker_preference_set = true; } - store.save(app).unwrap(); + if let Err(e) = store.save(app) { + error!("Failed to save general settings: {}", e); + } println!("GeneralSettingsState managed"); } From 02b15ac740abaacf4063549a2555d50c4dd57c2c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 23:05:06 +0000 Subject: [PATCH 26/32] Handle microphone restoration after reconnect --- apps/desktop/src-tauri/src/lib.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2577f93290..e355104075 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -508,8 +508,18 @@ fn spawn_mic_error_handler(app_handle: AppHandle, error_rx: flume::Receiver { + 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}"); + } } } }); From 7d842bedd8ddbfc37ba16a22ac80d0ce093411f9 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 23:05:12 +0000 Subject: [PATCH 27/32] Rename SelectionHint.tsx to selection-hint.tsx --- .../src/components/{SelectionHint.tsx => selection-hint.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/desktop/src/components/{SelectionHint.tsx => selection-hint.tsx} (100%) diff --git a/apps/desktop/src/components/SelectionHint.tsx b/apps/desktop/src/components/selection-hint.tsx similarity index 100% rename from apps/desktop/src/components/SelectionHint.tsx rename to apps/desktop/src/components/selection-hint.tsx From e1d53e1dda1a89b66116e68fcf2cd8720f86b3a1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 23:05:20 +0000 Subject: [PATCH 28/32] Refactor picker window hide logic to use signal state --- .../routes/(window-chrome)/new-main/index.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) 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 271d340d77..7e92e7e3b0 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -302,22 +302,24 @@ function Page() { const isRecording = () => !!currentRecording.data; const auth = authStore.createQuery(); - let hasHiddenMainWindowForPicker = false; + const [hasHiddenMainWindowForPicker, setHasHiddenMainWindowForPicker] = + createSignal(false); createEffect(() => { const pickerActive = rawOptions.targetMode != null; - if (pickerActive && !hasHiddenMainWindowForPicker) { - hasHiddenMainWindowForPicker = true; + const hasHidden = hasHiddenMainWindowForPicker(); + if (pickerActive && !hasHidden) { + setHasHiddenMainWindowForPicker(true); void getCurrentWindow().hide(); - } else if (!pickerActive && hasHiddenMainWindowForPicker) { - hasHiddenMainWindowForPicker = false; + } else if (!pickerActive && hasHidden) { + setHasHiddenMainWindowForPicker(false); const currentWindow = getCurrentWindow(); void currentWindow.show(); void currentWindow.setFocus(); } }); onCleanup(() => { - if (!hasHiddenMainWindowForPicker) return; - hasHiddenMainWindowForPicker = false; + if (!hasHiddenMainWindowForPicker()) return; + setHasHiddenMainWindowForPicker(false); void getCurrentWindow().show(); }); From f4bc17a579821a03f291b803e13e21f6242813cc Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 23:05:26 +0000 Subject: [PATCH 29/32] Fix SelectionHint import path casing --- apps/desktop/src/routes/capture-area.tsx | 4 ++-- apps/desktop/src/routes/target-select-overlay.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/routes/capture-area.tsx b/apps/desktop/src/routes/capture-area.tsx index 75178c2805..43037488d0 100644 --- a/apps/desktop/src/routes/capture-area.tsx +++ b/apps/desktop/src/routes/capture-area.tsx @@ -19,7 +19,7 @@ import { createCropOptionsMenuItems, type Ratio, } from "~/components/Cropper"; -import SelectionHint from "~/components/SelectionHint"; +import SelectionHint from "~/components/selection-hint"; import { createOptionsQuery } from "~/utils/queries"; import type { DisplayId } from "~/utils/tauri"; import { emitTo } from "~/utils/tauriSpectaHack"; @@ -173,7 +173,7 @@ export default function CaptureArea() { } return ( -
+
Date: Sun, 16 Nov 2025 23:14:55 +0000 Subject: [PATCH 30/32] Refactor microphone stream spawning logic --- crates/recording/src/feeds/microphone.rs | 413 +++++++++++------------ 1 file changed, 197 insertions(+), 216 deletions(-) diff --git a/crates/recording/src/feeds/microphone.rs b/crates/recording/src/feeds/microphone.rs index 4700fa162e..98d0e07eac 100644 --- a/crates/recording/src/feeds/microphone.rs +++ b/crates/recording/src/feeds/microphone.rs @@ -125,6 +125,139 @@ impl MicrophoneFeed { device_map } + + fn spawn_input_stream( + params: StreamSpawnParams, + ) -> ( + BoxFuture<'static, Result<(SupportedStreamConfig, Option), SetInputError>>, + SyncSender<()>, + ) { + let StreamSpawnParams { + id, + label, + device, + config, + stream_config, + buffer_size_frames, + sample_format, + actor_ref, + error_sender, + log_action, + } = params; + + let (ready_tx, ready_rx) = oneshot::channel::, SetInputError>>(); + let (done_tx, done_rx) = mpsc::sync_channel(0); + + let ready = { + let config_for_ready = config.clone(); + ready_rx + .map(move |v| { + let config = config_for_ready.clone(); + v.map_err(|_| SetInputError::BuildStreamCrashed) + .and_then(|inner| inner) + .map(|buffer_size| (config, buffer_size)) + }) + .boxed() + }; + + std::thread::spawn({ + let stream_config = stream_config.clone(); + let config = config.clone(); + let actor_ref = actor_ref.clone(); + let error_sender = error_sender.clone(); + move || { + let device_name_for_log = device.name().ok(); + + if let Some(ref name) = device_name_for_log { + info!("Device '{}' available configs:", name); + for config in device.supported_input_configs().into_iter().flatten() { + info!( + " Format: {:?}, Min rate: {}, Max rate: {}, Sample size: {}", + config.sample_format(), + config.min_sample_rate().0, + config.max_sample_rate().0, + config.sample_format().sample_size() + ); + } + } + + let buffer_size_description = match &stream_config.buffer_size { + BufferSize::Default => "default".to_string(), + BufferSize::Fixed(frames) => format!( + "{} frames (~{:.1}ms)", + frames, + (*frames as f64 / config.sample_rate().0 as f64) * 1000.0 + ), + }; + + info!( + "๐ŸŽค {} stream (id {}, label '{}') for '{:?}' with config: rate={}, channels={}, format={:?}, buffer_size={}", + log_action.verb(), + id, + label, + device_name_for_log, + config.sample_rate().0, + config.channels(), + sample_format, + buffer_size_description + ); + + let stream = match device.build_input_stream_raw( + &stream_config, + sample_format, + { + let actor_ref = actor_ref.clone(); + let mut callback_count = 0u64; + move |data, info| { + if callback_count == 0 { + info!( + "๐ŸŽค First audio callback - data size: {} bytes, format: {:?}", + data.bytes().len(), + data.sample_format() + ); + } + callback_count += 1; + + let _ = actor_ref + .tell(MicrophoneSamples { + data: data.bytes().to_vec(), + format: data.sample_format(), + info: info.clone(), + timestamp: Timestamp::from_cpal(info.timestamp().capture), + }) + .try_send(); + } + }, + move |e| { + error!("Microphone stream error: {e}"); + + let _ = error_sender.send(e).is_err(); + }, + None, + ) { + Ok(stream) => stream, + Err(e) => { + let _ = ready_tx.send(Err(SetInputError::BuildStream(e.to_string()))); + return; + } + }; + + if let Err(e) = stream.play() { + let _ = ready_tx.send(Err(SetInputError::PlayStream(e.to_string()))); + return; + } + + let _ = ready_tx.send(Ok(buffer_size_frames)); + + match done_rx.recv() { + Ok(_) => info!("Microphone actor shut down, ending stream"), + Err(_) => info!("Microphone actor unreachable, ending stream"), + } + } + }); + + (ready, done_tx) + } } fn get_usable_device(device: Device) -> Option<(String, Device, SupportedStreamConfig)> { @@ -302,6 +435,34 @@ struct InputConnectFailed { struct Unlock; +#[derive(Clone, Copy)] +enum StreamLogAction { + Build, + Rebuild, +} + +impl StreamLogAction { + fn verb(&self) -> &'static str { + match self { + Self::Build => "Building", + Self::Rebuild => "Rebuilding", + } + } +} + +struct StreamSpawnParams { + id: u32, + label: String, + device: Device, + config: SupportedStreamConfig, + stream_config: cpal::StreamConfig, + buffer_size_frames: Option, + sample_format: SampleFormat, + actor_ref: ActorRef, + error_sender: flume::Sender, + log_action: StreamLogAction, +} + // Impls #[derive(Debug, Clone, Copy, thiserror::Error)] @@ -343,22 +504,20 @@ impl Message for MicrophoneFeed { let sample_format = config.sample_format(); let (stream_config, buffer_size_frames) = stream_config_with_latency(&config); - let (ready_tx, ready_rx) = oneshot::channel::, SetInputError>>(); - let (done_tx, done_rx) = mpsc::sync_channel(0); - let actor_ref = ctx.actor_ref(); - let ready = { - let config_for_ready = config.clone(); - ready_rx - .map(move |v| { - let config = config_for_ready.clone(); - v.map_err(|_| SetInputError::BuildStreamCrashed) - .and_then(|inner| inner) - .map(|buffer_size| (config, buffer_size)) - }) - .shared() - }; - let error_sender = self.error_sender.clone(); + let (ready_future, done_tx) = Self::spawn_input_stream(StreamSpawnParams { + id, + label: label.clone(), + device, + config, + stream_config, + buffer_size_frames, + sample_format, + actor_ref: actor_ref.clone(), + error_sender: self.error_sender.clone(), + log_action: StreamLogAction::Build, + }); + let ready = ready_future.shared(); state.connecting = Some(ConnectingState { id, @@ -383,100 +542,10 @@ impl Message for MicrophoneFeed { }, }); - std::thread::spawn({ - let config = config.clone(); - let stream_config = stream_config.clone(); - let device_name_for_log = device.name().ok(); - move || { - if let Some(ref name) = device_name_for_log { - info!("Device '{}' available configs:", name); - for config in device.supported_input_configs().into_iter().flatten() { - info!( - " Format: {:?}, Min rate: {}, Max rate: {}, Sample size: {}", - config.sample_format(), - config.min_sample_rate().0, - config.max_sample_rate().0, - config.sample_format().sample_size() - ); - } - } - - let buffer_size_description = match &stream_config.buffer_size { - BufferSize::Default => "default".to_string(), - BufferSize::Fixed(frames) => format!( - "{} frames (~{:.1}ms)", - frames, - (*frames as f64 / config.sample_rate().0 as f64) * 1000.0 - ), - }; - - info!( - "๐ŸŽค Building stream for '{:?}' with config: rate={}, channels={}, format={:?}, buffer_size={}", - device_name_for_log, - config.sample_rate().0, - config.channels(), - sample_format, - buffer_size_description - ); - - let stream = match device.build_input_stream_raw( - &stream_config, - sample_format, - { - let actor_ref = actor_ref.clone(); - let mut callback_count = 0u64; - move |data, info| { - if callback_count == 0 { - info!( - "๐ŸŽค First audio callback - data size: {} bytes, format: {:?}", - data.bytes().len(), - data.sample_format() - ); - } - callback_count += 1; - - let _ = actor_ref - .tell(MicrophoneSamples { - data: data.bytes().to_vec(), - format: data.sample_format(), - info: info.clone(), - timestamp: Timestamp::from_cpal(info.timestamp().capture), - }) - .try_send(); - } - }, - move |e| { - error!("Microphone stream error: {e}"); - - let _ = error_sender.send(e).is_err(); - }, - None, - ) { - Ok(stream) => stream, - Err(e) => { - let _ = ready_tx.send(Err(SetInputError::BuildStream(e.to_string()))); - return; - } - }; - - if let Err(e) = stream.play() { - let _ = ready_tx.send(Err(SetInputError::PlayStream(e.to_string()))); - return; - } - - let _ = ready_tx.send(Ok(buffer_size_frames)); - - match done_rx.recv() { - Ok(_) => info!("Microphone actor shut down, ending stream"), - Err(_) => info!("Microphone actor unreachable, ending stream"), - } - } - }); - tokio::spawn({ let ready = ready.clone(); - let actor = ctx.actor_ref(); - let done_tx = done_tx; + let actor = actor_ref.clone(); + let done_tx = done_tx.clone(); let label = label.clone(); async move { match ready.await { @@ -498,9 +567,12 @@ impl Message for MicrophoneFeed { } }); - let ready_for_return = ready.clone().map(|result| result.map(|(config, _)| config)); + let ready_for_return = ready + .clone() + .map(|result| result.map(|(config, _)| config)) + .boxed(); - Ok(ready_for_return.boxed()) + Ok(ready_for_return) } State::Locked { inner } => { if inner.label != msg.label { @@ -515,120 +587,29 @@ impl Message for MicrophoneFeed { let sample_format = config.sample_format(); let (stream_config, buffer_size_frames) = stream_config_with_latency(&config); - let (ready_tx, ready_rx) = oneshot::channel::, SetInputError>>(); - let (done_tx, done_rx) = mpsc::sync_channel(0); - - let actor_ref = ctx.actor_ref(); - let ready = { - let config_for_ready = config.clone(); - ready_rx - .map(move |v| { - let config = config_for_ready.clone(); - v.map_err(|_| SetInputError::BuildStreamCrashed) - .and_then(|inner| inner) - .map(|buffer_size| (config, buffer_size)) - }) - .shared() - }; - let error_sender = self.error_sender.clone(); - let new_id = self.input_id_counter; self.input_id_counter += 1; let _ = inner.done_tx.send(()); - std::thread::spawn({ - let config = config.clone(); - let stream_config = stream_config.clone(); - let device_name_for_log = device.name().ok(); - move || { - if let Some(ref name) = device_name_for_log { - info!("Device '{}' available configs:", name); - for config in device.supported_input_configs().into_iter().flatten() { - info!( - " Format: {:?}, Min rate: {}, Max rate: {}, Sample size: {}", - config.sample_format(), - config.min_sample_rate().0, - config.max_sample_rate().0, - config.sample_format().sample_size() - ); - } - } - - let buffer_size_description = match &stream_config.buffer_size { - BufferSize::Default => "default".to_string(), - BufferSize::Fixed(frames) => format!( - "{} frames (~{:.1}ms)", - frames, - (*frames as f64 / config.sample_rate().0 as f64) * 1000.0 - ), - }; - - info!( - "๐ŸŽค Rebuilding stream for '{:?}' with config: rate={}, channels={}, format={:?}, buffer_size={}", - device_name_for_log, - config.sample_rate().0, - config.channels(), - sample_format, - buffer_size_description - ); - - let stream = match device.build_input_stream_raw( - &stream_config, - sample_format, - { - let actor_ref = actor_ref.clone(); - let mut callback_count = 0u64; - move |data, info| { - if callback_count == 0 { - info!( - "๐ŸŽค First audio callback - data size: {} bytes, format: {:?}", - data.bytes().len(), - data.sample_format() - ); - } - callback_count += 1; - - let _ = actor_ref - .tell(MicrophoneSamples { - data: data.bytes().to_vec(), - format: data.sample_format(), - info: info.clone(), - timestamp: Timestamp::from_cpal(info.timestamp().capture), - }) - .try_send(); - } - }, - move |e| { - error!("Microphone stream error: {e}"); - let _ = error_sender.send(e).is_err(); - }, - None, - ) { - Ok(stream) => stream, - Err(e) => { - let _ = ready_tx.send(Err(SetInputError::BuildStream(e.to_string()))); - return; - } - }; - - if let Err(e) = stream.play() { - let _ = ready_tx.send(Err(SetInputError::PlayStream(e.to_string()))); - return; - } - - let _ = ready_tx.send(Ok(buffer_size_frames)); - - match done_rx.recv() { - Ok(_) => info!("Microphone actor shut down, ending stream"), - Err(_) => info!("Microphone actor unreachable, ending stream"), - } - } + let actor_ref = ctx.actor_ref(); + let (ready_future, done_tx) = Self::spawn_input_stream(StreamSpawnParams { + id: new_id, + label: label.clone(), + device, + config, + stream_config, + buffer_size_frames, + sample_format, + actor_ref: actor_ref.clone(), + error_sender: self.error_sender.clone(), + log_action: StreamLogAction::Rebuild, }); + let ready = ready_future.shared(); tokio::spawn({ let ready = ready.clone(); - let actor = ctx.actor_ref(); + let actor = actor_ref; let done_tx = done_tx.clone(); let label = label.clone(); async move { @@ -646,9 +627,9 @@ impl Message for MicrophoneFeed { } }); - let ready_for_return = ready.clone().map(|result| result.map(|(config, _)| config)); + let ready_for_return = ready.map(|result| result.map(|(config, _)| config)).boxed(); - Ok(ready_for_return.boxed()) + Ok(ready_for_return) } } } From d56ff6affa2c9f8c3fdad64b65571f8afd42ecfb Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 23:15:02 +0000 Subject: [PATCH 31/32] Reorder camera preview window logic and improve error handling --- apps/desktop/src-tauri/src/lib.rs | 12 ++++++------ apps/desktop/src/utils/queries.ts | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e355104075..538161de96 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -444,18 +444,18 @@ async fn set_camera_input( .map_err(|e| e.to_string())?; } Some(id) => { - 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: 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(); } } diff --git a/apps/desktop/src/utils/queries.ts b/apps/desktop/src/utils/queries.ts index a70c79a2bd..22096e20aa 100644 --- a/apps/desktop/src/utils/queries.ts +++ b/apps/desktop/src/utils/queries.ts @@ -198,18 +198,26 @@ export function createCameraMutation() { const rawMutate = async (model: DeviceOrModelID | null) => { const before = rawOptions.cameraID ? { ...rawOptions.cameraID } : null; setOptions("cameraID", reconcile(model)); - if (model) { - await commands.showWindow("Camera"); - getCurrentWindow().setFocus(); - } - await commands.setCameraInput(model).catch(async (e) => { + const message = + typeof e === "string" ? e : e instanceof Error ? e.message : String(e); + + if (message.includes("DeviceNotFound")) { + setOptions("cameraID", null); + console.warn("Selected camera is unavailable."); + return; + } + if (JSON.stringify(before) === JSON.stringify(model) || !before) { setOptions("cameraID", null); } else setOptions("cameraID", reconcile(before)); throw e; }); + + if (model) { + getCurrentWindow().setFocus(); + } }; const setCameraInput = useMutation(() => ({ From 7d86c79d974450d8d0f590703a42bad523824ec4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Nov 2025 23:21:55 +0000 Subject: [PATCH 32/32] clippy bits --- apps/desktop/src-tauri/src/lib.rs | 116 +++++++++--------- .../src-tauri/src/target_select_overlay.rs | 1 - crates/recording/src/feeds/microphone.rs | 9 +- 3 files changed, 58 insertions(+), 68 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 538161de96..c45ccc982a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -369,17 +369,17 @@ async fn set_mic_input(state: MutableState<'_, App>, label: Option) -> R } } - if let Some(handle) = studio_handle { - if 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())?; - } + 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())?; } { @@ -459,17 +459,17 @@ async fn set_camera_input( } } - if let Some(handle) = studio_handle { - if 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())?; - } + 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())?; } { @@ -547,26 +547,24 @@ fn spawn_microphone_watcher(app_handle: AppHandle) { ) }; - if should_check { - if let Some(label) = label { - let available = microphone::MicrophoneFeed::list().contains_key(&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}"); - } + 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}"); } } } @@ -594,25 +592,21 @@ fn spawn_camera_watcher(app_handle: AppHandle) { ) }; - if should_check { - if let Some(id) = camera_id { - let available = is_camera_available(&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}"); - } + 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}"); } } } diff --git a/apps/desktop/src-tauri/src/target_select_overlay.rs b/apps/desktop/src-tauri/src/target_select_overlay.rs index 8c44b8a092..c3eca8084d 100644 --- a/apps/desktop/src-tauri/src/target_select_overlay.rs +++ b/apps/desktop/src-tauri/src/target_select_overlay.rs @@ -72,7 +72,6 @@ pub async fn open_target_select_overlays( let handle = tokio::spawn({ let app = app.clone(); - let window_exclusions = window_exclusions; async move { loop { diff --git a/crates/recording/src/feeds/microphone.rs b/crates/recording/src/feeds/microphone.rs index 98d0e07eac..32ba88df3b 100644 --- a/crates/recording/src/feeds/microphone.rs +++ b/crates/recording/src/feeds/microphone.rs @@ -17,6 +17,8 @@ use std::{ use tracing::{debug, error, info, trace, warn}; pub type MicrophonesMap = IndexMap; +type StreamReadyFuture = + BoxFuture<'static, Result<(SupportedStreamConfig, Option), SetInputError>>; #[derive(Clone)] pub struct MicrophoneSamples { @@ -126,12 +128,7 @@ impl MicrophoneFeed { device_map } - fn spawn_input_stream( - params: StreamSpawnParams, - ) -> ( - BoxFuture<'static, Result<(SupportedStreamConfig, Option), SetInputError>>, - SyncSender<()>, - ) { + fn spawn_input_stream(params: StreamSpawnParams) -> (StreamReadyFuture, SyncSender<()>) { let StreamSpawnParams { id, label,