From d83239188dc739f8371bc308fce08aa83ec4a491 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 3 May 2026 22:27:04 -0700 Subject: [PATCH 1/5] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 04e4642ea4..f4a45b3626 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ scripts/releases-backfill-data.txt # SEO agent state (machine-local, contains sensitive ranking data) /seo/ /docs/ +/.growth-agent/ # OpenCode commands (local) .opencode/ From 38ad2641c1080f0c744d3e58678efb246d0bc6e7 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 3 May 2026 22:43:16 -0700 Subject: [PATCH 2/5] fix: handle macOS capture control stops --- crates/recording/src/instant_recording.rs | 9 +- crates/recording/src/output_pipeline/core.rs | 95 +++++++++++++++++-- .../src/sources/screen_capture/macos.rs | 37 ++++++++ 3 files changed, 129 insertions(+), 12 deletions(-) diff --git a/crates/recording/src/instant_recording.rs b/crates/recording/src/instant_recording.rs index 99480acbc1..3e805162b6 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -111,6 +111,7 @@ impl Drop for ActorHandle { #[derive(kameo::Actor)] pub struct Actor { recording_dir: PathBuf, + output_dir: PathBuf, capture_target: ScreenCaptureTarget, video_info: VideoInfo, state: ActorState, @@ -151,10 +152,6 @@ impl Message for Actor { type Reply = anyhow::Result; async fn handle(&mut self, _: Stop, _: &mut Context) -> Self::Reply { - if matches!(self.state, ActorState::Stopped) { - return Err(anyhow::anyhow!("Recording already stopped")); - } - if let Some(pause_start) = self.pause_started_at.take() { let pause_elapsed = current_time_f64() - pause_start; if pause_elapsed > 0.0 { @@ -167,7 +164,7 @@ impl Message for Actor { let result = match &state { ActorState::Recording { pipeline, .. } | ActorState::Paused { pipeline, .. } => pipeline.segments_dir.clone(), - ActorState::Stopped => self.recording_dir.join("content").join("display"), + ActorState::Stopped => self.output_dir.clone(), }; (result, state) }); @@ -610,10 +607,12 @@ pub async fn spawn_instant_recording_actor( trace!("spawning recording actor"); let segment_rx = pipeline.segment_rx.take(); + let output_dir = pipeline.segments_dir.clone(); let done_fut = pipeline.video.done_fut(); let health_rx = pipeline.video.take_health_rx(); let actor_ref = Actor::spawn(Actor { recording_dir, + output_dir, capture_target: inputs.capture_target.clone(), video_info, state: ActorState::Recording { diff --git a/crates/recording/src/output_pipeline/core.rs b/crates/recording/src/output_pipeline/core.rs index 269d1a9bee..36b97f6056 100644 --- a/crates/recording/src/output_pipeline/core.rs +++ b/crates/recording/src/output_pipeline/core.rs @@ -1265,14 +1265,23 @@ pub struct SetupCtx { tasks: TaskPool, health_tx: HealthSender, master_clock: Arc, + stop_token: CancellationToken, + stop_signal: PipelineStopSignal, } impl SetupCtx { - fn new(health_tx: HealthSender, master_clock: Arc) -> Self { + fn new( + health_tx: HealthSender, + master_clock: Arc, + stop_token: CancellationToken, + stop_signal: PipelineStopSignal, + ) -> Self { Self { tasks: TaskPool::default(), health_tx, master_clock, + stop_token, + stop_signal, } } @@ -1287,6 +1296,14 @@ impl SetupCtx { pub fn master_clock(&self) -> &Arc { &self.master_clock } + + pub fn stop_token(&self) -> CancellationToken { + self.stop_token.clone() + } + + pub fn stop_signal(&self) -> PipelineStopSignal { + self.stop_signal.clone() + } } type AudioSourceSetupFn = Box< @@ -1427,7 +1444,12 @@ impl OutputPipelineBuilder> { let build_ctx = BuildCtx::new(); let master_clock = master_clock .unwrap_or_else(|| MasterClock::new(timestamps, AudioMixer::INFO.rate() as u32)); - let mut setup_ctx = SetupCtx::new(build_ctx.health_tx.clone(), master_clock.clone()); + let mut setup_ctx = SetupCtx::new( + build_ctx.health_tx.clone(), + master_clock.clone(), + build_ctx.stop_token.clone(), + build_ctx.stop_signal.clone(), + ); let (video_source, video_rx) = setup_video_source::(video.config, &mut setup_ctx).await?; @@ -1486,6 +1508,7 @@ impl OutputPipelineBuilder> { shared_pause, true, video_start_gate, + build_ctx.stop_signal, ) .await?; @@ -1523,7 +1546,12 @@ impl OutputPipelineBuilder { let build_ctx = BuildCtx::new(); let master_clock = master_clock .unwrap_or_else(|| MasterClock::new(timestamps, AudioMixer::INFO.rate() as u32)); - let mut setup_ctx = SetupCtx::new(build_ctx.health_tx.clone(), master_clock.clone()); + let mut setup_ctx = SetupCtx::new( + build_ctx.health_tx.clone(), + master_clock.clone(), + build_ctx.stop_token.clone(), + build_ctx.stop_signal.clone(), + ); let (first_tx, first_rx) = oneshot::channel(); @@ -1560,6 +1588,7 @@ impl OutputPipelineBuilder { shared_pause, false, None, + build_ctx.stop_signal, ) .await?; @@ -1584,6 +1613,7 @@ struct BuildCtx { pause_flag: Arc, health_tx: HealthSender, health_rx: HealthReceiver, + stop_signal: PipelineStopSignal, } impl BuildCtx { @@ -1592,6 +1622,7 @@ impl BuildCtx { let (done_tx, done_rx) = oneshot::channel(); let (health_tx, health_rx) = new_health_channel(); + let stop_signal = PipelineStopSignal::default(); Self { stop_token, @@ -1607,6 +1638,7 @@ impl BuildCtx { pause_flag: Arc::new(AtomicBool::new(false)), health_tx, health_rx, + stop_signal, } } } @@ -1624,6 +1656,7 @@ async fn finish_build( shared_pause: SharedWallClockPause, has_video: bool, video_start_gate: Option, + stop_signal: PipelineStopSignal, ) -> anyhow::Result<()> { if let Some(audio) = audio { audio.configure( @@ -1667,7 +1700,7 @@ async fn finish_build( .then(async move |res| { let muxer_res = muxer.lock().await.finish(timestamps.instant().elapsed()); - let _ = done_tx.send(resolve_pipeline_completion(res, muxer_res)); + let _ = done_tx.send(resolve_pipeline_completion(res, muxer_res, &stop_signal)); }), ); @@ -1679,9 +1712,11 @@ async fn finish_build( fn resolve_pipeline_completion( task_result: anyhow::Result<()>, muxer_result: anyhow::Result>, + stop_signal: &PipelineStopSignal, ) -> anyhow::Result<()> { match (task_result, muxer_result) { (Err(error), _) | (_, Err(error)) => Err(error), + (_, Ok(Ok(()))) if stop_signal.user_stopped() => Err(anyhow!(PipelineStoppedByUser)), (_, Ok(Ok(()))) => Ok(()), (_, Ok(Err(error))) => Err(anyhow!("Muxer finish failed: {error:#}")), } @@ -2473,6 +2508,25 @@ pub struct FinishedOutputPipeline { pub video_frame_count: u64, } +#[derive(Clone, Default)] +pub struct PipelineStopSignal { + user_stopped: Arc, +} + +impl PipelineStopSignal { + pub fn mark_user_stopped(&self) { + self.user_stopped.store(true, Ordering::Release); + } + + fn user_stopped(&self) -> bool { + self.user_stopped.load(Ordering::Acquire) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("Screen capture stopped from macOS sharing controls")] +pub struct PipelineStoppedByUser; + #[derive(Clone, Debug)] pub struct PipelineDoneError(Arc); @@ -2488,6 +2542,17 @@ impl std::error::Error for PipelineDoneError { } } +impl PipelineDoneError { + pub fn is_caused_by(&self) -> bool + where + T: std::error::Error + 'static, + { + self.0 + .chain() + .any(|cause| cause.downcast_ref::().is_some()) + } +} + impl OutputPipeline { pub fn path(&self) -> &PathBuf { &self.path @@ -2498,6 +2563,7 @@ impl OutputPipeline { const PIPELINE_STOP_TIMEOUT: Duration = Duration::from_secs(10); match tokio::time::timeout(PIPELINE_STOP_TIMEOUT, self.done_fut.clone()).await { + Ok(Err(error)) if error.is_caused_by::() => {} Ok(res) => res?, Err(_) => { return Err(anyhow!( @@ -3265,6 +3331,7 @@ mod tests { let result = resolve_pipeline_completion( Ok(()), Ok(Err(anyhow!("fragmented audio trailer write failed"))), + &PipelineStopSignal::default(), ); let error = result.expect_err("inner muxer failure should fail the pipeline"); @@ -3278,8 +3345,11 @@ mod tests { #[test] fn preserves_task_failure_over_muxer_finish_success() { - let result = - resolve_pipeline_completion(Err(anyhow!("capture-video failed")), Ok(Ok(()))); + let result = resolve_pipeline_completion( + Err(anyhow!("capture-video failed")), + Ok(Ok(())), + &PipelineStopSignal::default(), + ); let error = result.expect_err("task failure should fail the pipeline"); assert!( @@ -3290,9 +3360,20 @@ mod tests { #[test] fn succeeds_only_when_tasks_and_muxer_finish_succeed() { - resolve_pipeline_completion(Ok(()), Ok(Ok(()))) + resolve_pipeline_completion(Ok(()), Ok(Ok(())), &PipelineStopSignal::default()) .expect("pipeline should succeed when all work succeeds"); } + + #[test] + fn surfaces_user_stop_after_clean_finish() { + let signal = PipelineStopSignal::default(); + signal.mark_user_stopped(); + + let error = resolve_pipeline_completion(Ok(()), Ok(Ok(())), &signal) + .expect_err("user stop should surface as a typed pipeline completion"); + + assert!(error.is::()); + } } mod pipeline_mux_send_failures { diff --git a/crates/recording/src/sources/screen_capture/macos.rs b/crates/recording/src/sources/screen_capture/macos.rs index 575d872cc7..77e97e6b54 100644 --- a/crates/recording/src/sources/screen_capture/macos.rs +++ b/crates/recording/src/sources/screen_capture/macos.rs @@ -608,6 +608,8 @@ impl output_pipeline::VideoSource for VideoSource { } = config; let monitor_cancel = cancel_token.clone(); + let pipeline_cancel = ctx.stop_token(); + let stop_signal = ctx.stop_signal(); let health_tx = ctx.health_tx().clone(); if let Some(mut stall_rx) = stall_health_rx { @@ -645,6 +647,24 @@ impl output_pipeline::VideoSource for VideoSource { } }; + if is_user_stop_error(err.as_ref()) { + if let Ok(guard) = active_capturer.lock() { + match guard.as_ref() { + Some(c) => c.mark_stopped(), + None => original_capturer.mark_stopped(), + } + } else { + original_capturer.mark_stopped(); + } + + info!( + "Screen capture stream stopped from macOS sharing controls" + ); + stop_signal.mark_user_stopped(); + pipeline_cancel.cancel(); + break Ok(()); + } + if is_system_stop_error(err.as_ref()) { if monitor_cancel.is_cancelled() { break Ok(()); @@ -858,6 +878,11 @@ fn is_system_stop_error(err: &ns::Error) -> bool { && err.domain().to_string() == sc::error::domain().to_string() } +fn is_user_stop_error(err: &ns::Error) -> bool { + err.code() == sc::error::code::USER_STOPPED as ns::Integer + && err.domain().to_string() == sc::error::domain().to_string() +} + fn system_stop_message() -> &'static str { "Screen capture stopped because macOS made the display unavailable. This commonly happens when the lid is closed or the display sleeps." } @@ -1165,6 +1190,9 @@ impl output_pipeline::AudioSource for SystemAudioSource { ) = config; let cancel_token = CancellationToken::new(); + let pipeline_cancel = ctx.stop_token(); + let stop_signal = ctx.stop_signal(); + let capturer_for_monitor = capturer.clone(); ctx.tasks().spawn("system-audio", { let cancel = cancel_token.child_token(); @@ -1178,6 +1206,15 @@ impl output_pipeline::AudioSource for SystemAudioSource { result = error_rx.recv() => { match result { Ok(err) => { + if is_user_stop_error(err.as_ref()) { + capturer_for_monitor.mark_stopped(); + info!( + "Screen capture audio stream stopped from macOS sharing controls" + ); + stop_signal.mark_user_stopped(); + pipeline_cancel.cancel(); + break; + } if is_system_stop_error(err.as_ref()) { system_stop_count += 1; if system_stop_count > MAX_CAPTURE_RESTARTS { From 5084e097339774d671ced85e557df69e6e6d7903 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 3 May 2026 22:43:21 -0700 Subject: [PATCH 3/5] feat: add recording device format settings --- .../src-tauri/src/recording_settings.rs | 39 +++++- apps/desktop/src/utils/tauri.ts | 7 +- crates/cap-test/src/suites/recording.rs | 6 +- .../cap-test/src/suites/recording_helpers.rs | 5 +- crates/cap-test/src/suites/scenarios.rs | 6 +- crates/recording/examples/camera-benchmark.rs | 2 + .../examples/camera-lifecycle-stress.rs | 4 + .../examples/camera-preview-benchmark.rs | 2 + crates/recording/examples/camera.rs | 1 + crates/recording/examples/cpu-profile-test.rs | 3 + .../examples/instant-mode-profile.rs | 7 +- .../examples/memory-leak-detector.rs | 3 + .../recording/examples/memory-stress-test.rs | 14 ++- .../examples/real-device-test-runner.rs | 2 + .../recording/examples/recording-benchmark.rs | 1 + crates/recording/src/feeds/camera.rs | 115 +++++++++++++++++- crates/recording/src/feeds/microphone.rs | 67 ++++++++-- crates/recording/src/sources/microphone.rs | 5 +- .../tests/hardware_instant_recording.rs | 1 + 19 files changed, 267 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording_settings.rs b/apps/desktop/src-tauri/src/recording_settings.rs index 29327d085e..c893528981 100644 --- a/apps/desktop/src-tauri/src/recording_settings.rs +++ b/apps/desktop/src-tauri/src/recording_settings.rs @@ -1,6 +1,12 @@ use cap_recording::{ - RecordingMode, feeds::camera::DeviceOrModelID, sources::screen_capture::ScreenCaptureTarget, + RecordingMode, + feeds::{ + camera::{CameraDeviceSettings, DeviceOrModelID}, + microphone::MicrophoneDeviceSettings, + }, + sources::screen_capture::ScreenCaptureTarget, }; +use std::collections::HashMap; use tauri::{AppHandle, Wry}; use tauri_plugin_store::StoreExt; @@ -24,6 +30,8 @@ pub struct RecordingSettingsStore { pub mode: Option, pub system_audio: bool, pub organization_id: Option, + pub camera_device_settings: HashMap, + pub microphone_device_settings: HashMap, } impl RecordingSettingsStore { @@ -48,6 +56,35 @@ impl RecordingSettingsStore { store.set(Self::KEY, serde_json::json!(settings)); store.save().map_err(|e| e.to_string()) } + + pub fn camera_settings_for( + app: &AppHandle, + id: &DeviceOrModelID, + ) -> Option { + Self::get(app).ok().flatten().and_then(|settings| { + settings + .camera_device_settings + .get(&camera_key(id)) + .copied() + }) + } + + pub fn microphone_settings_for( + app: &AppHandle, + label: &str, + ) -> Option { + Self::get(app) + .ok() + .flatten() + .and_then(|settings| settings.microphone_device_settings.get(label).copied()) + } +} + +pub fn camera_key(id: &DeviceOrModelID) -> String { + match id { + DeviceOrModelID::DeviceID(device_id) => format!("device:{device_id}"), + DeviceOrModelID::ModelID(model_id) => format!("model:{model_id}"), + } } #[tauri::command] diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 4d83991a27..22b6d2acf8 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -445,6 +445,7 @@ export type BackgroundConfiguration = { source: BackgroundSource; blur: number; export type BackgroundSource = { type: "wallpaper"; path: string | null } | { type: "image"; path: string | null } | { type: "color"; value: [number, number, number]; alpha?: number } | { type: "gradient"; from: [number, number, number]; to: [number, number, number]; angle?: number; noise_intensity?: number | null; noise_scale?: number | null; animated?: boolean | null; animation_speed?: number | null } export type BorderConfiguration = { enabled: boolean; width: number; color: [number, number, number]; opacity: number } export type Camera = { hide: boolean; mirror: boolean; position: CameraPosition; size: number; zoomSize: number | null; rounding: number; shadow: number; advancedShadow: ShadowConfiguration | null; shape: CameraShape; roundingType: CornerStyle; scaleDuringZoom?: number; backgroundBlur?: BackgroundBlurConfig } +export type CameraDeviceSettings = { width: number | null; height: number | null; frameRate: number | null } export type CameraFormatInfo = { width: number; height: number; frameRate: number } export type CameraInfo = { device_id: string; model_id: ModelIDType | null; display_name: string } export type CameraPosition = { x: CameraXPosition; y: CameraYPosition } @@ -530,7 +531,9 @@ export type MaskScalarKeyframe = { time: number; value: number } export type MaskSegment = { start: number; end: number; track?: number; enabled?: boolean; maskType: MaskKind; center: XY; size: XY; feather?: number; opacity?: number; pixelation?: number; darkness?: number; fadeDuration?: number; keyframes?: MaskKeyframes } export type MaskType = "blur" | "pixelate" export type MaskVectorKeyframe = { time: number; x: number; y: number } -export type MicrophoneInfo = { name: string; sampleRate: number; channels: number } +export type MicrophoneDeviceSettings = { sampleRate: number | null; channels: number | null } +export type MicrophoneFormatInfo = { sampleRate: number; channels: number } +export type MicrophoneInfo = { name: string; sampleRate: number; channels: number; formats: MicrophoneFormatInfo[] } export type ModelIDType = string export type MovExportSettings = { fps: number; resolution_base: XY; cursor_only?: boolean } export type Mp4ExportSettings = { fps: number; resolution_base: XY; compression: ExportCompression; custom_bpp: number | null; force_ffmpeg_decoder?: boolean; optimize_filesize?: boolean } @@ -561,7 +564,7 @@ export type RecordingMeta = (StudioRecordingMeta | InstantRecordingMeta) & { pla export type RecordingMetaWithMetadata = ((StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadMeta | null }) & { mode: RecordingMode; status: StudioRecordingStatus } export type RecordingMode = "studio" | "instant" | "screenshot" export type RecordingOptionsChanged = null -export type RecordingSettingsStore = { target: ScreenCaptureTarget | null; micName: string | null; cameraId: DeviceOrModelID | null; mode: RecordingMode | null; systemAudio: boolean; organizationId: string | null } +export type RecordingSettingsStore = { target: ScreenCaptureTarget | null; micName: string | null; cameraId: DeviceOrModelID | null; mode: RecordingMode | null; systemAudio: boolean; organizationId: string | null; cameraDeviceSettings: { [key in string]: CameraDeviceSettings }; microphoneDeviceSettings: { [key in string]: MicrophoneDeviceSettings } } export type RecordingStarted = null export type RecordingStatus = "pending" | "recording" export type RecordingStopped = null diff --git a/crates/cap-test/src/suites/recording.rs b/crates/cap-test/src/suites/recording.rs index 17f2b887b5..788c0940bd 100644 --- a/crates/cap-test/src/suites/recording.rs +++ b/crates/cap-test/src/suites/recording.rs @@ -97,7 +97,10 @@ impl RecordingTestRunner { if let Some((label, _, _)) = MicrophoneFeed::default_device() { let mic_feed = MicrophoneFeed::spawn(MicrophoneFeed::new(error_tx.clone())); mic_feed - .ask(cap_recording::feeds::microphone::SetInput { label }) + .ask(cap_recording::feeds::microphone::SetInput { + label, + settings: None, + }) .await? .await?; tokio::time::sleep(Duration::from_millis(100)).await; @@ -117,6 +120,7 @@ impl RecordingTestRunner { let camera_feed = CameraFeed::spawn(CameraFeed::default()); camera_feed .ask(cap_recording::feeds::camera::SetInput { + settings: None, id: cap_recording::feeds::camera::DeviceOrModelID::from_info(&camera_info), }) .await? diff --git a/crates/cap-test/src/suites/recording_helpers.rs b/crates/cap-test/src/suites/recording_helpers.rs index ab023b6ea5..7985738236 100644 --- a/crates/cap-test/src/suites/recording_helpers.rs +++ b/crates/cap-test/src/suites/recording_helpers.rs @@ -80,7 +80,10 @@ pub async fn record_studio_at_path( if let Some((label, _, _)) = MicrophoneFeed::default_device() { let mic_feed = MicrophoneFeed::spawn(MicrophoneFeed::new(error_tx.clone())); mic_feed - .ask(cap_recording::feeds::microphone::SetInput { label }) + .ask(cap_recording::feeds::microphone::SetInput { + label, + settings: None, + }) .await? .await?; tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/crates/cap-test/src/suites/scenarios.rs b/crates/cap-test/src/suites/scenarios.rs index 940e160935..a15347b3db 100644 --- a/crates/cap-test/src/suites/scenarios.rs +++ b/crates/cap-test/src/suites/scenarios.rs @@ -168,7 +168,10 @@ impl ScenarioRunner { if let Some((label, _, _)) = MicrophoneFeed::default_device() { let mic_feed = MicrophoneFeed::spawn(MicrophoneFeed::new(error_tx.clone())); mic_feed - .ask(cap_recording::feeds::microphone::SetInput { label }) + .ask(cap_recording::feeds::microphone::SetInput { + label, + settings: None, + }) .await? .await?; tokio::time::sleep(Duration::from_millis(100)).await; @@ -188,6 +191,7 @@ impl ScenarioRunner { let camera_feed = CameraFeed::spawn(CameraFeed::default()); camera_feed .ask(cap_recording::feeds::camera::SetInput { + settings: None, id: cap_recording::feeds::camera::DeviceOrModelID::from_info(&camera_info), }) .await? diff --git a/crates/recording/examples/camera-benchmark.rs b/crates/recording/examples/camera-benchmark.rs index 5b0f35d7e3..e4fa608082 100644 --- a/crates/recording/examples/camera-benchmark.rs +++ b/crates/recording/examples/camera-benchmark.rs @@ -50,6 +50,7 @@ async fn run_camera_frame_rate_test( let feed = CameraFeed::spawn(CameraFeed::default()); feed.ask(camera::SetInput { + settings: None, id: DeviceOrModelID::from_info(camera_info), }) .await @@ -106,6 +107,7 @@ async fn run_camera_encoding_benchmark( let feed = CameraFeed::spawn(CameraFeed::default()); feed.ask(camera::SetInput { + settings: None, id: DeviceOrModelID::from_info(camera_info), }) .await diff --git a/crates/recording/examples/camera-lifecycle-stress.rs b/crates/recording/examples/camera-lifecycle-stress.rs index 581dcb2240..5c49024d38 100644 --- a/crates/recording/examples/camera-lifecycle-stress.rs +++ b/crates/recording/examples/camera-lifecycle-stress.rs @@ -201,6 +201,7 @@ async fn run_lifecycle_stress( .expect("AddSender failed"); feed.ask(camera::SetInput { + settings: None, id: camera_id.clone(), }) .await @@ -291,6 +292,7 @@ async fn run_rapid_toggle(toggles: usize) -> Result<(), Box Result<(), Box Result<(), Box Vec { .expect("AddSender failed"); feed.ask(camera::SetInput { + settings: None, id: DeviceOrModelID::from_info(&camera_info), }) .await @@ -395,6 +396,7 @@ async fn profile_live_preview(duration_secs: u64, output_width: u32) { .expect("AddSender failed"); feed.ask(camera::SetInput { + settings: None, id: DeviceOrModelID::from_info(&camera_info), }) .await diff --git a/crates/recording/examples/camera.rs b/crates/recording/examples/camera.rs index 4b418f3434..36c45ac0fc 100644 --- a/crates/recording/examples/camera.rs +++ b/crates/recording/examples/camera.rs @@ -18,6 +18,7 @@ async fn main() { let feed = CameraFeed::spawn(CameraFeed::default()); feed.ask(camera::SetInput { + settings: None, id: DeviceOrModelID::from_info(&device.0), }) .await diff --git a/crates/recording/examples/cpu-profile-test.rs b/crates/recording/examples/cpu-profile-test.rs index 48cc43a94d..6f847e3cc7 100644 --- a/crates/recording/examples/cpu-profile-test.rs +++ b/crates/recording/examples/cpu-profile-test.rs @@ -44,6 +44,7 @@ async fn profile_recording( println!("Camera: {}", camera_info.display_name()); let feed = CameraFeed::spawn(CameraFeed::default()); feed.ask(camera::SetInput { + settings: None, id: DeviceOrModelID::from_info(&camera_info), }) .await @@ -63,6 +64,7 @@ async fn profile_recording( let mic_feed = MicrophoneFeed::spawn(MicrophoneFeed::new(error_sender)); mic_feed .ask(microphone::SetInput { + settings: None, label: mic_name.clone(), }) .await @@ -165,6 +167,7 @@ async fn profile_idle_with_camera(duration_secs: u64) { .expect("AddSender failed"); feed.ask(camera::SetInput { + settings: None, id: DeviceOrModelID::from_info(&camera_info), }) .await diff --git a/crates/recording/examples/instant-mode-profile.rs b/crates/recording/examples/instant-mode-profile.rs index 27c9808a23..efc10139d3 100644 --- a/crates/recording/examples/instant-mode-profile.rs +++ b/crates/recording/examples/instant-mode-profile.rs @@ -174,6 +174,7 @@ async fn profile_instant_recording( println!("Camera: {}", camera_info.display_name()); let feed = CameraFeed::spawn(CameraFeed::default()); feed.ask(camera::SetInput { + settings: None, id: DeviceOrModelID::from_info(&camera_info), }) .await @@ -193,6 +194,7 @@ async fn profile_instant_recording( let mic_feed = MicrophoneFeed::spawn(MicrophoneFeed::new(error_sender)); mic_feed .ask(microphone::SetInput { + settings: None, label: mic_name.clone(), }) .await @@ -348,7 +350,10 @@ async fn profile_sustained_instant(duration_secs: u64, include_mic: bool) { let error_sender = flume::unbounded().0; let mic_feed = MicrophoneFeed::spawn(MicrophoneFeed::new(error_sender)); mic_feed - .ask(microphone::SetInput { label: mic_name }) + .ask(microphone::SetInput { + label: mic_name, + settings: None, + }) .await .expect("mic SetInput send failed") .await diff --git a/crates/recording/examples/memory-leak-detector.rs b/crates/recording/examples/memory-leak-detector.rs index 043d926a1f..9ad254f92f 100644 --- a/crates/recording/examples/memory-leak-detector.rs +++ b/crates/recording/examples/memory-leak-detector.rs @@ -207,6 +207,7 @@ async fn run_memory_test( let feed = CameraFeed::spawn(CameraFeed::default()); feed.ask(camera::SetInput { + settings: None, id: DeviceOrModelID::from_info(&camera_info), }) .await? @@ -230,6 +231,7 @@ async fn run_memory_test( mic_feed .ask(microphone::SetInput { + settings: None, label: mic_name.clone(), }) .await? @@ -313,6 +315,7 @@ async fn run_camera_only_test(duration_secs: u64) -> Result<(), Box CycleTestResult { .expect("AddSender failed"); feed.ask(camera::SetInput { + settings: None, id: camera_id.clone(), }) .await @@ -117,6 +118,7 @@ async fn test_microphone_cycles(config: &CycleTestConfig) -> CycleTestResult { mic_feed .ask(microphone::SetInput { + settings: None, label: mic_name.clone(), }) .await @@ -187,6 +189,7 @@ async fn test_recording_cycles( if include_camera && let Some(camera_info) = cap_camera::list_cameras().next() { let feed = CameraFeed::spawn(CameraFeed::default()); feed.ask(camera::SetInput { + settings: None, id: DeviceOrModelID::from_info(&camera_info), }) .await @@ -204,7 +207,10 @@ async fn test_recording_cycles( let error_sender = flume::unbounded().0; let mic_feed = MicrophoneFeed::spawn(MicrophoneFeed::new(error_sender)); mic_feed - .ask(microphone::SetInput { label: mic_name }) + .ask(microphone::SetInput { + label: mic_name, + settings: None, + }) .await .expect("mic SetInput send failed") .await @@ -286,6 +292,7 @@ async fn test_sustained_recording(duration_secs: u64, include_camera: bool, incl println!("Camera: {}", camera_info.display_name()); let feed = CameraFeed::spawn(CameraFeed::default()); feed.ask(camera::SetInput { + settings: None, id: DeviceOrModelID::from_info(&camera_info), }) .await @@ -303,7 +310,10 @@ async fn test_sustained_recording(duration_secs: u64, include_camera: bool, incl let error_sender = flume::unbounded().0; let mic_feed = MicrophoneFeed::spawn(MicrophoneFeed::new(error_sender)); mic_feed - .ask(microphone::SetInput { label: mic_name }) + .ask(microphone::SetInput { + label: mic_name, + settings: None, + }) .await .expect("mic SetInput send failed") .await diff --git a/crates/recording/examples/real-device-test-runner.rs b/crates/recording/examples/real-device-test-runner.rs index 3b748df2e0..f90dbb4c52 100644 --- a/crates/recording/examples/real-device-test-runner.rs +++ b/crates/recording/examples/real-device-test-runner.rs @@ -1363,6 +1363,7 @@ async fn execute_recording( let mic_feed = MicrophoneFeed::spawn(MicrophoneFeed::new(error_tx)); mic_feed .ask(microphone::SetInput { + settings: None, label: mic_label.clone(), }) .await? @@ -1379,6 +1380,7 @@ async fn execute_recording( let camera_feed = CameraFeed::spawn(CameraFeed::default()); camera_feed .ask(camera::SetInput { + settings: None, id: camera::DeviceOrModelID::from_info(camera_info), }) .await? diff --git a/crates/recording/examples/recording-benchmark.rs b/crates/recording/examples/recording-benchmark.rs index 4051be3eef..55f7531df9 100644 --- a/crates/recording/examples/recording-benchmark.rs +++ b/crates/recording/examples/recording-benchmark.rs @@ -36,6 +36,7 @@ async fn run_recording_benchmark( let feed = CameraFeed::spawn(CameraFeed::default()); feed.ask(camera::SetInput { + settings: None, id: DeviceOrModelID::from_info(&camera_info), }) .await? diff --git a/crates/recording/src/feeds/camera.rs b/crates/recording/src/feeds/camera.rs index 6ad140194f..798caf2b47 100644 --- a/crates/recording/src/feeds/camera.rs +++ b/crates/recording/src/feeds/camera.rs @@ -223,10 +223,21 @@ impl DeviceOrModelID { } } +#[derive( + serde::Serialize, serde::Deserialize, specta::Type, Clone, Copy, Debug, PartialEq, Default, +)] +#[serde(rename_all = "camelCase")] +pub struct CameraDeviceSettings { + pub width: Option, + pub height: Option, + pub frame_rate: Option, +} + // Public Requests pub struct SetInput { pub id: DeviceOrModelID, + pub settings: Option, } pub struct RemoveInput; @@ -287,6 +298,7 @@ struct FinalizePendingRelease { fn spawn_camera_setup( id: DeviceOrModelID, + settings: Option, actor_ref: ActorRef, new_frame_recipient: Recipient, native_frame_recipient: Recipient, @@ -314,7 +326,8 @@ fn spawn_camera_setup( .expect("Failed to build camera tokio runtime"); LocalSet::new().block_on(&runtime, async move { - let setup_result = setup_camera(&id, new_frame_recipient, native_frame_recipient).await; + let setup_result = + setup_camera(&id, settings, new_frame_recipient, native_frame_recipient).await; let handle = match setup_result { Ok(result) => { @@ -456,27 +469,117 @@ static CAMERA_CALLBACK_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic const TARGET_CAMERA_WIDTH: u32 = 1280; const TARGET_CAMERA_HEIGHT: u32 = 720; const TARGET_CAMERA_FRAME_RATE: f32 = 30.0; +const PREFERRED_CAMERA_FRAME_RATE: f32 = 29.0; const MIN_CAMERA_FRAME_RATE: f32 = 24.0; +fn select_preferred_camera_format( + formats: &[cap_camera::Format], + settings: CameraDeviceSettings, +) -> Option { + let mut matches = formats + .iter() + .filter(|format| { + settings.width.is_none_or(|width| format.width() == width) + && settings + .height + .is_none_or(|height| format.height() == height) + && settings + .frame_rate + .is_none_or(|frame_rate| (format.frame_rate() - frame_rate).abs() < 0.5) + }) + .cloned() + .collect::>(); + + if matches.is_empty() && settings.width.is_some() && settings.height.is_some() { + matches = formats + .iter() + .filter(|format| { + settings.width.is_none_or(|width| format.width() == width) + && settings + .height + .is_none_or(|height| format.height() == height) + }) + .cloned() + .collect(); + } + + matches.sort_by(|a, b| { + let target_rate = settings.frame_rate.unwrap_or(TARGET_CAMERA_FRAME_RATE); + let fr_cmp_a = (a.frame_rate() - target_rate).abs(); + let fr_cmp_b = (b.frame_rate() - target_rate).abs(); + fr_cmp_a + .partial_cmp(&fr_cmp_b) + .unwrap_or(Ordering::Equal) + .then((b.width() * b.height()).cmp(&(a.width() * a.height()))) + }); + + matches.into_iter().next() +} + fn select_camera_format( camera: &cap_camera::CameraInfo, + settings: Option, ) -> Result { let formats = camera.formats().ok_or(SetInputError::InvalidFormat)?; if formats.is_empty() { return Err(SetInputError::InvalidFormat); } + if let Some(settings) = settings + && let Some(format) = select_preferred_camera_format(&formats, settings) + { + return Ok(format); + } + let mut ideal_formats = formats .clone() .into_iter() .filter(|f| { - f.frame_rate() >= MIN_CAMERA_FRAME_RATE + f.frame_rate() >= PREFERRED_CAMERA_FRAME_RATE && f.frame_rate() <= TARGET_CAMERA_FRAME_RATE && f.width() <= TARGET_CAMERA_WIDTH && f.height() <= TARGET_CAMERA_HEIGHT }) .collect::>(); + if ideal_formats.is_empty() { + ideal_formats = formats + .clone() + .into_iter() + .filter(|f| { + f.frame_rate() >= PREFERRED_CAMERA_FRAME_RATE + && f.frame_rate() <= TARGET_CAMERA_FRAME_RATE + && f.width() < 2000 + && f.height() < 2000 + }) + .collect::>(); + } + + if ideal_formats.is_empty() { + ideal_formats = formats + .clone() + .into_iter() + .filter(|f| { + f.frame_rate() >= PREFERRED_CAMERA_FRAME_RATE + && f.width() < 2000 + && f.height() < 2000 + }) + .collect::>(); + } + + if ideal_formats.is_empty() { + ideal_formats = formats + .clone() + .into_iter() + .filter(|f| { + f.frame_rate() >= MIN_CAMERA_FRAME_RATE + && f.frame_rate() <= TARGET_CAMERA_FRAME_RATE + && f.width() <= TARGET_CAMERA_WIDTH + && f.height() <= TARGET_CAMERA_HEIGHT + }) + .collect::>(); + } + if ideal_formats.is_empty() { ideal_formats = formats .clone() @@ -531,11 +634,12 @@ fn select_camera_format( #[cfg(target_os = "macos")] async fn setup_camera( id: &DeviceOrModelID, + settings: Option, recipient: Recipient, native_recipient: Recipient, ) -> Result { let camera = find_camera(id).ok_or(SetInputError::DeviceNotFound)?; - let format = select_camera_format(&camera)?; + let format = select_camera_format(&camera, settings)?; let frame_rate = format.frame_rate().round().max(1.0) as u32; let (ready_tx, ready_rx) = oneshot::channel(); @@ -605,11 +709,12 @@ async fn setup_camera( #[cfg(not(target_os = "macos"))] async fn setup_camera( id: &DeviceOrModelID, + settings: Option, recipient: Recipient, native_recipient: Recipient, ) -> Result { let camera = find_camera(id).ok_or(SetInputError::DeviceNotFound)?; - let format = select_camera_format(&camera)?; + let format = select_camera_format(&camera, settings)?; let frame_rate = format.frame_rate().round().max(1.0) as u32; let (ready_tx, ready_rx) = oneshot::channel(); @@ -739,6 +844,7 @@ impl Message for CameraFeed { let (ready, _done_tx, join_handle) = spawn_camera_setup( id.clone(), + msg.settings, actor_ref, new_frame_recipient, native_frame_recipient, @@ -767,6 +873,7 @@ impl Message for CameraFeed { let (ready, _done_tx, join_handle) = spawn_camera_setup( msg.id.clone(), + msg.settings, actor_ref, new_frame_recipient, native_frame_recipient, diff --git a/crates/recording/src/feeds/microphone.rs b/crates/recording/src/feeds/microphone.rs index a74d95873d..adb3064992 100644 --- a/crates/recording/src/feeds/microphone.rs +++ b/crates/recording/src/feeds/microphone.rs @@ -27,6 +27,15 @@ pub type MicrophonesMap = IndexMap; type StreamReadyFuture = BoxFuture<'static, Result<(SupportedStreamConfig, Option), SetInputError>>; +#[derive( + serde::Serialize, serde::Deserialize, specta::Type, Clone, Copy, Debug, PartialEq, Eq, Default, +)] +#[serde(rename_all = "camelCase")] +pub struct MicrophoneDeviceSettings { + pub sample_rate: Option, + pub channels: Option, +} + #[derive(Clone)] pub struct MicrophoneSamples { pub data: Vec, @@ -172,22 +181,30 @@ impl MicrophoneFeed { pub fn default_device() -> Option<(String, Device, SupportedStreamConfig)> { let host = cpal::default_host(); - host.default_input_device().and_then(get_usable_device) + host.default_input_device() + .and_then(|device| get_usable_device(device, None)) } pub fn list() -> MicrophonesMap { + Self::list_with_settings(None) + } + + pub fn list_with_settings(settings: Option<&MicrophoneDeviceSettings>) -> MicrophonesMap { let host = cpal::default_host(); let mut device_map = IndexMap::new(); - if let Some((name, device, config)) = - host.default_input_device().and_then(get_usable_device) + if let Some((name, device, config)) = host + .default_input_device() + .and_then(|device| get_usable_device(device, settings)) { device_map.insert(name, (device, config)); } match host.input_devices() { Ok(devices) => { - for (name, device, config) in devices.filter_map(get_usable_device) { + for (name, device, config) in + devices.filter_map(|device| get_usable_device(device, settings)) + { device_map.entry(name).or_insert((device, config)); } } @@ -339,7 +356,10 @@ impl MicrophoneFeed { } } -fn get_usable_device(device: Device) -> Option<(String, Device, SupportedStreamConfig)> { +fn get_usable_device( + device: Device, + settings: Option<&MicrophoneDeviceSettings>, +) -> Option<(String, Device, SupportedStreamConfig)> { let device_name_for_logging = device.name().ok(); let preferred_rate = cpal::SampleRate(48_000); @@ -364,8 +384,12 @@ fn get_usable_device(device: Device) -> Option<(String, Device, SupportedStreamC .then(b.max_sample_rate().cmp(&a.max_sample_rate())) }); - // First try to find a config that natively supports 48 kHz so we - // don't have to rely on resampling later. + if let Some(settings) = settings + && let Some(config) = select_preferred_config(&configs, settings) + { + return Some(config); + } + if let Some(config) = configs.iter().find(|config| { ffmpeg_sample_format_for(config.sample_format()).is_some() && config.min_sample_rate().0 <= preferred_rate.0 @@ -383,6 +407,26 @@ fn get_usable_device(device: Device) -> Option<(String, Device, SupportedStreamC result.and_then(|config| device.name().ok().map(|name| (name, device, config))) } +fn select_preferred_config( + configs: &[SupportedStreamConfigRange], + settings: &MicrophoneDeviceSettings, +) -> Option { + let rate = settings.sample_rate.map(cpal::SampleRate); + + configs + .iter() + .find(|config| { + ffmpeg_sample_format_for(config.sample_format()).is_some() + && settings + .channels + .is_none_or(|channels| config.channels() == channels) + && rate.is_none_or(|rate| { + config.min_sample_rate().0 <= rate.0 && config.max_sample_rate().0 >= rate.0 + }) + }) + .map(|config| config.with_sample_rate(rate.unwrap_or_else(|| select_sample_rate(config)))) +} + fn select_sample_rate(config: &SupportedStreamConfigRange) -> cpal::SampleRate { const PREFERRED_RATES: [u32; 2] = [48_000, 44_100]; @@ -519,6 +563,7 @@ impl Drop for MicrophoneFeedLock { pub struct SetInput { pub label: String, + pub settings: Option, } pub struct RemoveInput; @@ -624,7 +669,9 @@ impl Message for MicrophoneFeed { self.input_id_counter += 1; let label = msg.label.clone(); - let Some((device, config)) = Self::list().swap_remove(&label) else { + let Some((device, config)) = + Self::list_with_settings(msg.settings.as_ref()).swap_remove(&label) + else { return Err(SetInputError::DeviceNotFound); }; @@ -709,7 +756,9 @@ impl Message for MicrophoneFeed { } let label = msg.label.clone(); - let Some((device, config)) = Self::list().swap_remove(&label) else { + let Some((device, config)) = + Self::list_with_settings(msg.settings.as_ref()).swap_remove(&label) + else { return Err(SetInputError::DeviceNotFound); }; diff --git a/crates/recording/src/sources/microphone.rs b/crates/recording/src/sources/microphone.rs index 71740c4f6d..81d0bdf624 100644 --- a/crates/recording/src/sources/microphone.rs +++ b/crates/recording/src/sources/microphone.rs @@ -363,7 +363,10 @@ impl AudioSource for Microphone { let in_flight = reconnect_in_flight.clone(); tokio::spawn(async move { let ready = match feed - .ask(microphone::SetInput { label: name }) + .ask(microphone::SetInput { + label: name, + settings: None, + }) .await { Ok(r) => r, diff --git a/crates/recording/tests/hardware_instant_recording.rs b/crates/recording/tests/hardware_instant_recording.rs index daec15ce39..ead4d9f3cd 100644 --- a/crates/recording/tests/hardware_instant_recording.rs +++ b/crates/recording/tests/hardware_instant_recording.rs @@ -51,6 +51,7 @@ async fn instant_record_with_real_mic_and_screen() { let ready_future = mic_actor .ask(cap_recording::feeds::microphone::SetInput { + settings: None, label: label.clone(), }) .await From 93df82ee68ef33b1195b2d7ef690ed4c900eda42 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 3 May 2026 22:43:26 -0700 Subject: [PATCH 4/5] improve: refresh desktop recorder controls --- apps/desktop/src/App.tsx | 3 +- apps/desktop/src/routes/(window-chrome).tsx | 17 +- .../(window-chrome)/new-main/CameraSelect.tsx | 73 +- .../(window-chrome)/new-main/InfoPill.tsx | 11 +- .../new-main/MicrophoneSelect.tsx | 100 +- .../(window-chrome)/new-main/SystemAudio.tsx | 30 +- .../new-main/TargetSelectInfoPill.tsx | 16 +- .../new-main/deviceRowStyles.ts | 12 + .../routes/(window-chrome)/new-main/index.tsx | 863 +++++++++++++++--- apps/desktop/src/utils/devices.ts | 67 +- packages/ui-solid/src/auto-imports.d.ts | 1 + 11 files changed, 976 insertions(+), 217 deletions(-) create mode 100644 apps/desktop/src/routes/(window-chrome)/new-main/deviceRowStyles.ts diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index b021e7cbf0..748f758119 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -139,7 +139,8 @@ function Inner() { if (match.route.info?.AUTO_SHOW_WINDOW === false) return; } - if (location.pathname !== "/camera") currentWindow.show(); + if (location.pathname !== "/" && location.pathname !== "/camera") + currentWindow.show(); }); return {props.children}; diff --git a/apps/desktop/src/routes/(window-chrome).tsx b/apps/desktop/src/routes/(window-chrome).tsx index 7ab785b379..ae8d945aae 100644 --- a/apps/desktop/src/routes/(window-chrome).tsx +++ b/apps/desktop/src/routes/(window-chrome).tsx @@ -1,6 +1,5 @@ import type { RouteSectionProps } from "@solidjs/router"; import type { UnlistenFn } from "@tauri-apps/api/event"; -import { emit } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; @@ -20,19 +19,9 @@ export default function (props: RouteSectionProps) { onMount(async () => { console.log("window chrome mounted"); - unlistenResize = await initializeTitlebar(); - const { __CAP__ } = window as typeof window & { - __CAP__?: { initialTargetMode?: unknown }; - }; - const hasInitialTargetMode = __CAP__?.initialTargetMode != null; - const currentWindow = getCurrentWindow(); - if (location.pathname === "/") { - void emit("main-window-ready"); - } - if (location.pathname === "/" && !hasInitialTargetMode) { - await currentWindow.show(); - await currentWindow.setFocus(); - } + void initializeTitlebar().then((unlisten) => { + unlistenResize = unlisten; + }); }); const handleKeyDown = (e: KeyboardEvent) => { diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx index 559002ea78..59d874fd2b 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx @@ -1,5 +1,6 @@ import { createTimer } from "@solid-primitives/timer"; import { CheckMenuItem, Menu, PredefinedMenuItem } from "@tauri-apps/api/menu"; +import { cx } from "cva"; import { type Component, type ComponentProps, @@ -15,6 +16,13 @@ import { type DeviceOrModelID, type OSPermissionsCheck, } from "~/utils/tauri"; +import { + DEVICE_ROW_CLASS, + DEVICE_ROW_ICON_CLASS, + DEVICE_ROW_LABEL_CLASS, + DEVICE_ROW_TRAILING_CLASS, + DEVICE_SHORTCUT_BUTTON_CLASS, +} from "./deviceRowStyles"; import InfoPill from "./InfoPill"; import TargetSelectInfoPill from "./TargetSelectInfoPill"; import useRequestPermission from "./useRequestPermission"; @@ -25,10 +33,13 @@ export default function CameraSelect(props: { disabled?: boolean; options: CameraInfo[]; value: CameraInfo | null; + selectedLabel?: string | null; + isSelected?: boolean; onChange: (camera: CameraInfo | null) => void; permissions?: OSPermissionsCheck; hidePreviewButton?: boolean; onOpen?: () => void; + onOpenSettings?: () => void; }) { const currentRecording = createCurrentRecordingQuery(); const requestPermission = useRequestPermission(); @@ -74,8 +85,16 @@ export default function CameraSelect(props: { }; const permissionGranted = () => - props.permissions?.camera === "granted" || - props.permissions?.camera === "notNeeded"; + props.permissions === undefined || + props.permissions.camera === "granted" || + props.permissions.camera === "notNeeded"; + + const hasSelection = () => props.isSelected ?? props.value !== null; + + const label = () => + props.value?.display_name ?? + props.selectedLabel ?? + (hasSelection() ? "Camera" : NO_CAMERA); const showHiddenIndicator = () => props.value !== null && @@ -83,11 +102,16 @@ export default function CameraSelect(props: { !cameraWindowOpen() && !props.hidePreviewButton; + const showSettingsShortcut = () => + props.value !== null && permissionGranted() && !!props.onOpenSettings; + + const isDisabled = () => !!currentRecording.data || props.disabled; + return ( -
+
+ + + requestPermission("camera", props.permissions?.camera) } onClick={(e) => { if (!props.options) return; - if (props.value !== null) { + if (hasSelection()) { e.stopPropagation(); props.onChange(null); } @@ -140,7 +180,7 @@ export function CameraSelectBase(props: { value: CameraInfo | null; onChange: (camera: CameraInfo | null) => void; PillComponent: Component< - ComponentProps<"button"> & { variant: "blue" | "red" } + ComponentProps<"button"> & { variant: "blue" | "red" | "gray" } >; class: string; iconClass: string; @@ -191,8 +231,9 @@ export function CameraSelectBase(props: { }; const permissionGranted = () => - props.permissions?.camera === "granted" || - props.permissions?.camera === "notNeeded"; + props.permissions === undefined || + props.permissions.camera === "granted" || + props.permissions.camera === "notNeeded"; const onChange = (cameraLabel: CameraInfo | null) => { if (!cameraLabel && !permissionGranted()) diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/InfoPill.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/InfoPill.tsx index f6ab0811c2..c2c1314193 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/InfoPill.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/InfoPill.tsx @@ -1,16 +1,21 @@ import { cx } from "cva"; import type { ComponentProps } from "solid-js"; +export type InfoPillVariant = "blue" | "red" | "gray"; + export default function InfoPill( - props: ComponentProps<"button"> & { variant: "blue" | "red" }, + props: ComponentProps<"button"> & { variant: InfoPillVariant }, ) { return ( + + + requestPermission("microphone", props.permissions?.microphone) } - }} - /> + onClick={(e) => { + if (props.value !== null) { + e.stopPropagation(); + void handleMicrophoneChange(null); + } + }} + /> +
); @@ -107,7 +142,7 @@ export function MicrophoneSelectBase(props: { levelIndicatorClass: string; iconClass: string; PillComponent: Component< - ComponentProps<"button"> & { variant: "blue" | "red" } + ComponentProps<"button"> & { variant: "blue" | "red" | "gray" } >; permissions?: OSPermissionsCheck; }) { @@ -120,8 +155,9 @@ export function MicrophoneSelectBase(props: { const requestPermission = useRequestPermission(); const permissionGranted = () => - props.permissions?.microphone === "granted" || - props.permissions?.microphone === "notNeeded"; + props.permissions === undefined || + props.permissions.microphone === "granted" || + props.permissions.microphone === "notNeeded"; type Option = { name: string }; diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/SystemAudio.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/SystemAudio.tsx index 0bec8a9a64..40602015f2 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/SystemAudio.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/SystemAudio.tsx @@ -1,4 +1,5 @@ import { createQuery } from "@tanstack/solid-query"; +import { cx } from "cva"; import type { Component, ComponentProps, JSX } from "solid-js"; import { Dynamic } from "solid-js/web"; @@ -7,14 +8,20 @@ import { isSystemAudioSupported, } from "~/utils/queries"; import { useRecordingOptions } from "../OptionsContext"; +import { + DEVICE_ROW_CLASS, + DEVICE_ROW_ICON_CLASS, + DEVICE_ROW_LABEL_CLASS, + DEVICE_ROW_TRAILING_CLASS, +} from "./deviceRowStyles"; import InfoPill from "./InfoPill"; export default function SystemAudio() { return ( } + icon={} /> ); } @@ -25,7 +32,7 @@ export function SystemAudioToggleRoot( "onClick" | "disabled" | "title" | "type" | "children" > & { PillComponent: Component<{ - variant: "blue" | "red"; + variant: "blue" | "red" | "gray"; children: JSX.Element; }>; icon: JSX.Element; @@ -54,19 +61,22 @@ export function SystemAudioToggleRoot( setOptions({ captureSystemAudio: !rawOptions.captureSystemAudio }); }} disabled={isDisabled()} + aria-pressed={rawOptions.captureSystemAudio ? "true" : "false"} > {props.icon} -

+

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

- - {rawOptions.captureSystemAudio ? "On" : "Off"} - +
+ + {rawOptions.captureSystemAudio ? "On" : "Off"} + +
); } diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/TargetSelectInfoPill.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/TargetSelectInfoPill.tsx index 17ba64db0e..86a8279201 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/TargetSelectInfoPill.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/TargetSelectInfoPill.tsx @@ -1,5 +1,6 @@ import type { Component, ComponentProps } from "solid-js"; import { Dynamic } from "solid-js/web"; +import type { InfoPillVariant } from "./InfoPill"; export default function TargetSelectInfoPill(props: { value: T | null; @@ -7,13 +8,18 @@ export default function TargetSelectInfoPill(props: { requestPermission: () => void; onClick: (e: MouseEvent) => void; PillComponent: Component< - ComponentProps<"button"> & { variant: "blue" | "red" } + ComponentProps<"button"> & { variant: InfoPillVariant } >; }) { + const variant = (): InfoPillVariant => { + if (!props.permissionGranted) return "red"; + return props.value !== null ? "blue" : "gray"; + }; + return ( { if (!props.permissionGranted || props.value === null) return; @@ -29,11 +35,7 @@ export default function TargetSelectInfoPill(props: { props.onClick(e); }} > - {!props.permissionGranted - ? "Request Permission" - : props.value !== null - ? "On" - : "Off"} + {!props.permissionGranted ? "Allow" : props.value !== null ? "On" : "Off"} ); } diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/deviceRowStyles.ts b/apps/desktop/src/routes/(window-chrome)/new-main/deviceRowStyles.ts new file mode 100644 index 0000000000..3dd73d2f07 --- /dev/null +++ b/apps/desktop/src/routes/(window-chrome)/new-main/deviceRowStyles.ts @@ -0,0 +1,12 @@ +export const DEVICE_ROW_CLASS = + "group relative isolate overflow-hidden flex flex-row gap-2.5 items-center pl-3 pr-1.5 w-full h-[42px] rounded-lg border border-gray-5 bg-gray-3 transition-[background-color,border-color,color] cursor-default disabled:opacity-70 disabled:text-gray-11 enabled:hover:bg-gray-4 enabled:hover:border-gray-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-9 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-1"; + +export const DEVICE_ROW_ICON_CLASS = "text-gray-10 size-4 shrink-0"; + +export const DEVICE_ROW_LABEL_CLASS = + "flex-1 min-w-0 text-sm text-left truncate"; + +export const DEVICE_ROW_TRAILING_CLASS = "flex items-center gap-0.5 shrink-0"; + +export const DEVICE_SHORTCUT_BUTTON_CLASS = + "flex size-7 items-center justify-center rounded-md text-gray-10 hover:text-gray-12 hover:bg-gray-5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-9 focus-visible:ring-offset-1 focus-visible:ring-offset-gray-1"; 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 a140812d75..d0ea2d8d78 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -1,13 +1,8 @@ import { Button } from "@cap/ui-solid"; import { useNavigate } from "@solidjs/router"; -import { - createMutation, - queryOptions, - useQuery, - useQueryClient, -} from "@tanstack/solid-query"; +import { createMutation, queryOptions, useQuery } from "@tanstack/solid-query"; import { Channel } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { emit, listen } from "@tauri-apps/api/event"; import { getAllWebviewWindows, WebviewWindow, @@ -34,7 +29,11 @@ import Mode from "~/components/Mode"; import { RecoveryToast } from "~/components/RecoveryToast"; import Tooltip from "~/components/Tooltip"; import { Input } from "~/routes/editor/ui"; -import { authStore } from "~/store"; +import { + authStore, + generalSettingsStore, + recordingSettingsStore, +} from "~/store"; import { createSignInMutation } from "~/utils/auth"; import { createTauriEventListener } from "~/utils/createEventListener"; import { @@ -46,6 +45,7 @@ import { createCameraMutation, createCurrentRecordingQuery, createLicenseQuery, + getPermissions, listDisplaysWithThumbnails, listRecordings, listScreens, @@ -67,7 +67,6 @@ import { } from "~/utils/tauri"; import IconCapLogoFull from "~icons/cap/logo-full"; import IconCapLogoFullDark from "~icons/cap/logo-full-dark"; -import IconCapSettings from "~icons/cap/settings"; import IconLucideAppWindowMac from "~icons/lucide/app-window-mac"; import IconLucideArrowLeft from "~icons/lucide/arrow-left"; import IconLucideBug from "~icons/lucide/bug"; @@ -75,6 +74,7 @@ import IconLucideCircleHelp from "~icons/lucide/circle-help"; import IconLucideImage from "~icons/lucide/image"; import IconLucideImport from "~icons/lucide/import"; import IconLucideSearch from "~icons/lucide/search"; +import IconLucideSettings from "~icons/lucide/settings"; import IconLucideSquarePlay from "~icons/lucide/square-play"; import IconLucideVideo from "~icons/lucide/video"; import IconMaterialSymbolsScreenshotFrame2Rounded from "~icons/material-symbols/screenshot-frame-2-rounded"; @@ -111,6 +111,61 @@ type WindowListItem = Pick< "id" | "owner_name" | "name" | "bounds" | "refresh_rate" >; +type CameraDeviceSettings = { + width?: number; + height?: number; + frameRate?: number; +}; + +type MicrophoneDeviceSettings = { + sampleRate?: number; + channels?: number; +}; + +type RecordingDeviceSettingsStore = { + cameraDeviceSettings?: Record; + microphoneDeviceSettings?: Record; +}; + +const recordingDeviceSettingsStore = recordingSettingsStore as unknown as { + get: () => Promise; + set: (value?: Partial) => Promise; + createQuery: () => ReturnType; +}; + +const cameraSettingsKeys = (camera: CameraWithDetails) => [ + `device:${camera.device_id}`, + ...(camera.model_id ? [`model:${camera.model_id}`] : []), +]; + +const formatCameraSetting = (format: CameraDeviceSettings) => { + const size = + format.width && format.height ? `${format.width}×${format.height}` : "Auto"; + const rate = format.frameRate ? `${Math.round(format.frameRate)}fps` : "Auto"; + return `${size} @ ${rate}`; +}; + +const formatMicrophoneSetting = (setting: MicrophoneDeviceSettings) => { + const rate = setting.sampleRate ? `${setting.sampleRate / 1000}kHz` : "Auto"; + const channels = + setting.channels === 1 + ? "Mono" + : setting.channels === 2 + ? "Stereo" + : setting.channels + ? `${setting.channels}ch` + : "Auto"; + return `${rate} ${channels}`; +}; + +const isHighCameraSetting = (setting: CameraDeviceSettings) => + (setting.width ?? 0) >= 3840 || + (setting.height ?? 0) >= 2160 || + (setting.frameRate ?? 0) > 30; + +const isHighMicrophoneSetting = (setting: MicrophoneDeviceSettings) => + (setting.sampleRate ?? 0) > 48_000 || (setting.channels ?? 0) > 2; + const createWindowSignature = ( list?: readonly WindowListItem[], ): string | undefined => { @@ -178,6 +233,13 @@ type TargetMenuPanelProps = selectedTarget: CameraWithDetails | null; onSelect: (target: CameraWithDetails | null) => void; permissions?: OSPermissionsCheck; + deviceSettings?: RecordingDeviceSettingsStore; + onCameraSettingsChange: ( + camera: CameraWithDetails, + settings: CameraDeviceSettings, + ) => void; + compatibilityStudioMode: boolean; + initialSettingsTarget?: CameraWithDetails | null; } | { variant: "microphone"; @@ -185,6 +247,13 @@ type TargetMenuPanelProps = selectedTarget: MicrophoneWithDetails | null; onSelect: (target: MicrophoneWithDetails | null) => void; permissions?: OSPermissionsCheck; + deviceSettings?: RecordingDeviceSettingsStore; + onMicrophoneSettingsChange: ( + key: string, + settings: MicrophoneDeviceSettings, + ) => void; + compatibilityStudioMode: boolean; + initialSettingsTarget?: MicrophoneWithDetails | null; }; type SharedTargetMenuProps = { @@ -205,6 +274,13 @@ type DeviceListPanelProps = disabled?: boolean; emptyMessage?: string; permissions?: OSPermissionsCheck; + deviceSettings?: RecordingDeviceSettingsStore; + onCameraSettingsChange: ( + camera: CameraWithDetails, + settings: CameraDeviceSettings, + ) => void; + compatibilityStudioMode: boolean; + initialSettingsTarget?: CameraWithDetails | null; } | { variant: "microphone"; @@ -216,6 +292,13 @@ type DeviceListPanelProps = disabled?: boolean; emptyMessage?: string; permissions?: OSPermissionsCheck; + deviceSettings?: RecordingDeviceSettingsStore; + onMicrophoneSettingsChange: ( + key: string, + settings: MicrophoneDeviceSettings, + ) => void; + compatibilityStudioMode: boolean; + initialSettingsTarget?: MicrophoneWithDetails | null; }; function CameraListItem(props: { @@ -224,22 +307,21 @@ function CameraListItem(props: { isFocused: boolean; disabled?: boolean; onSelect: () => void; + onSettings: () => void; ref?: (el: HTMLButtonElement) => void; + settingsLabel?: string; }) { const formatDetails = () => { + if (props.settingsLabel) return props.settingsLabel; if (!props.camera.bestFormat) return null; const { width, height, frameRate } = props.camera.bestFormat; return `${width}×${height} @ ${Math.round(frameRate)}fps`; }; return ( - + + > + + + ); } @@ -277,10 +387,13 @@ function MicrophoneListItem(props: { isFocused: boolean; disabled?: boolean; onSelect: () => void; + onSettings: () => void; ref?: (el: HTMLButtonElement) => void; audioLevel?: number; + settingsLabel?: string; }) { const formatDetails = () => { + if (props.settingsLabel) return props.settingsLabel; if (!props.mic.sampleRate) return null; const channels = props.mic.channels === 1 @@ -292,13 +405,9 @@ function MicrophoneListItem(props: { }; return ( - + + > + + + + ); +} + +function CameraSettingsPanel(props: { + camera: CameraWithDetails; + value?: CameraDeviceSettings; + onChange: (settings: CameraDeviceSettings) => void; + onBack: () => void; + compatibilityStudioMode: boolean; +}) { + const formats = createMemo(() => { + const formats = props.camera.formats ?? []; + const seen = new Set(); + return formats + .filter((format) => { + const key = `${format.width}:${format.height}:${Math.round(format.frameRate)}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort( + (a, b) => + b.width * b.height - a.width * a.height || b.frameRate - a.frameRate, + ); + }); + + const defaultSetting = createMemo(() => { + if (props.camera.bestFormat) { + const { width, height, frameRate } = props.camera.bestFormat; + return { width, height, frameRate }; + } + const first = formats()[0]; + if (!first) return undefined; + return { + width: first.width, + height: first.height, + frameRate: first.frameRate, + }; + }); + + const isDefaultSelected = () => { + const value = props.value; + return ( + !value || + (value.width === undefined && + value.height === undefined && + value.frameRate === undefined) + ); + }; + + const isSelected = (format: CameraDeviceSettings) => + props.value?.width === format.width && + props.value?.height === format.height && + Math.round(props.value?.frameRate ?? 0) === + Math.round(format.frameRate ?? 0); + + return ( +
+
+ + +
+
+ {props.camera.display_name} +
+
Camera settings
+
+
+
+ + 0}> + + {(format) => { + const setting = () => ({ + width: format.width, + height: format.height, + frameRate: format.frameRate, + }); + const high = () => isHighCameraSetting(setting()); + return ( + + ); + }} + + +
+
+ ); +} + +function MicrophoneSettingsPanel(props: { + mic: MicrophoneWithDetails; + value?: MicrophoneDeviceSettings; + onChange: (settings: MicrophoneDeviceSettings) => void; + onBack: () => void; + compatibilityStudioMode: boolean; +}) { + const formats = createMemo(() => { + const formats = + props.mic.formats && props.mic.formats.length > 0 + ? props.mic.formats + : props.mic.sampleRate && props.mic.channels + ? [{ sampleRate: props.mic.sampleRate, channels: props.mic.channels }] + : []; + const seen = new Set(); + return formats + .filter((format) => { + const key = `${format.sampleRate}:${format.channels}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort((a, b) => b.sampleRate - a.sampleRate || b.channels - a.channels); + }); + + const defaultSetting = createMemo( + () => { + if (props.mic.sampleRate && props.mic.channels) { + return { + sampleRate: props.mic.sampleRate, + channels: props.mic.channels, + }; + } + const first = formats()[0]; + if (!first) return undefined; + return { sampleRate: first.sampleRate, channels: first.channels }; + }, + ); + + const isDefaultSelected = () => { + const value = props.value; + return ( + !value || (value.sampleRate === undefined && value.channels === undefined) + ); + }; + + const isSelected = (format: MicrophoneDeviceSettings) => + props.value?.sampleRate === format.sampleRate && + props.value?.channels === format.channels; + + return ( +
+
+ + +
+
+ {props.mic.name} +
+
Microphone settings
+
+
+
+ + 0}> + + {(format) => { + const setting = () => ({ + sampleRate: format.sampleRate, + channels: format.channels, + }); + const high = () => isHighMicrophoneSetting(setting()); + return ( + + ); + }} + + +
+
); } @@ -344,6 +779,9 @@ function DeviceListPanel(props: DeviceListPanelProps) { const requestPermission = useRequestPermission(); const [focusedIndex, setFocusedIndex] = createSignal(-1); const [dbs, setDbs] = createSignal(); + const [settingsTarget, setSettingsTarget] = createSignal< + CameraWithDetails | MicrophoneWithDetails | null + >(null); const itemRefs: HTMLButtonElement[] = []; let containerRef: HTMLDivElement | undefined; @@ -380,6 +818,10 @@ function DeviceListPanel(props: DeviceListPanelProps) { }; onMount(() => { + const initialTarget = props.initialSettingsTarget; + if (initialTarget) { + setSettingsTarget(initialTarget); + } setFocusedIndex(getInitialFocusIndex()); setTimeout(() => containerRef?.focus(), 50); }); @@ -392,6 +834,7 @@ function DeviceListPanel(props: DeviceListPanelProps) { }); const permissionGranted = () => { + if (props.permissions === undefined) return true; if (props.variant === "camera") { return ( props.permissions?.camera === "granted" || @@ -478,6 +921,53 @@ function DeviceListPanel(props: DeviceListPanelProps) { return target !== null && mic.name === target.name; }; + const cameraSettingFor = (camera: CameraWithDetails) => { + if (props.variant !== "camera") return undefined; + const settings = props.deviceSettings?.cameraDeviceSettings; + return cameraSettingsKeys(camera) + .map((key) => settings?.[key]) + .find((setting) => setting); + }; + + const microphoneSettingFor = (mic: MicrophoneWithDetails) => { + if (props.variant !== "microphone") return undefined; + return props.deviceSettings?.microphoneDeviceSettings?.[mic.name]; + }; + + const currentSettingsTarget = () => settingsTarget(); + + const renderSettingsPanel = () => { + const target = currentSettingsTarget(); + if (!target) return null; + if (props.variant === "camera" && "device_id" in target) { + return ( + + props.onCameraSettingsChange(target, settings) + } + onBack={() => setSettingsTarget(null)} + compatibilityStudioMode={props.compatibilityStudioMode} + /> + ); + } + if (props.variant === "microphone" && "name" in target) { + return ( + + props.onMicrophoneSettingsChange(target.name, settings) + } + onBack={() => setSettingsTarget(null)} + compatibilityStudioMode={props.compatibilityStudioMode} + /> + ); + } + return null; + }; + return (
Loading...
+ {renderSettingsPanel()} 0 || true) } > @@ -544,6 +1036,12 @@ function DeviceListPanel(props: DeviceListPanelProps) { isFocused={focusedIndex() === index() + 1} disabled={props.disabled} onSelect={() => handleSelect(camera)} + onSettings={() => setSettingsTarget(camera)} + settingsLabel={ + cameraSettingFor(camera) + ? formatCameraSetting(cameraSettingFor(camera) ?? {}) + : undefined + } /> )} @@ -561,7 +1059,13 @@ function DeviceListPanel(props: DeviceListPanelProps) { isFocused={focusedIndex() === index() + 1} disabled={props.disabled} onSelect={() => handleSelect(mic)} + onSettings={() => setSettingsTarget(mic)} audioLevel={isMicSelected(mic) ? audioLevel() : undefined} + settingsLabel={ + microphoneSettingFor(mic) + ? formatMicrophoneSetting(microphoneSettingFor(mic) ?? {}) + : undefined + } /> )} @@ -874,6 +1378,10 @@ function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) { trimmedSearch() ? noResultsMessage : "No cameras found" } permissions={props.permissions} + deviceSettings={props.deviceSettings} + onCameraSettingsChange={props.onCameraSettingsChange} + compatibilityStudioMode={props.compatibilityStudioMode} + initialSettingsTarget={props.initialSettingsTarget} /> ) : props.variant === "microphone" ? ( ) : ( setTimeout(res, 1000)); + await new Promise((res) => setTimeout(res, 10_000)); let update: updater.Update | undefined; try { @@ -982,16 +1494,52 @@ function Page() { const isActivelyRecording = () => currentRecording.data?.status === "recording"; const auth = authStore.createQuery(); + const recordingSettingsQuery = recordingDeviceSettingsStore.createQuery(); + const generalSettings = generalSettingsStore.createQuery(); + const deviceSettings = createMemo( + () => recordingSettingsQuery.data as RecordingDeviceSettingsStore | null, + ); + const compatibilityStudioMode = () => + rawOptions.mode === "studio" && + generalSettings.data?.studioRecordingQuality === "compatibility"; + + const setCameraDeviceSettings = async ( + camera: CameraWithDetails, + settings: CameraDeviceSettings, + ) => { + const current = (await recordingDeviceSettingsStore.get()) ?? {}; + const next = { ...(current.cameraDeviceSettings ?? {}) }; + for (const key of cameraSettingsKeys(camera)) { + next[key] = settings; + } + await recordingDeviceSettingsStore.set({ + cameraDeviceSettings: next, + }); + }; + + const setMicrophoneDeviceSettings = async ( + key: string, + settings: MicrophoneDeviceSettings, + ) => { + const current = (await recordingDeviceSettingsStore.get()) ?? {}; + await recordingDeviceSettingsStore.set({ + microphoneDeviceSettings: { + ...(current.microphoneDeviceSettings ?? {}), + [key]: settings, + }, + }); + }; const [hasHiddenMainWindowForPicker, setHasHiddenMainWindowForPicker] = createSignal(false); + const [canRevealMainWindow, setCanRevealMainWindow] = createSignal(false); createEffect(() => { const pickerActive = rawOptions.targetMode != null; const hasHidden = hasHiddenMainWindowForPicker(); if (pickerActive && !hasHidden) { setHasHiddenMainWindowForPicker(true); void getCurrentWindow().hide(); - } else if (!pickerActive && hasHidden) { + } else if (!pickerActive && hasHidden && canRevealMainWindow()) { setHasHiddenMainWindowForPicker(false); const currentWindow = getCurrentWindow(); void currentWindow.show(); @@ -1015,6 +1563,10 @@ function Page() { const [modeInfoMenuOpen, setModeInfoMenuOpen] = createSignal(false); const [cameraMenuOpen, setCameraMenuOpen] = createSignal(false); const [microphoneMenuOpen, setMicrophoneMenuOpen] = createSignal(false); + const [cameraInitialSettings, setCameraInitialSettings] = + createSignal(null); + const [microphoneInitialSettings, setMicrophoneInitialSettings] = + createSignal(null); const activeMenu = createMemo< | "display" | "window" @@ -1036,11 +1588,19 @@ function Page() { }); const [hasOpenedDisplayMenu, setHasOpenedDisplayMenu] = createSignal(false); const [hasOpenedWindowMenu, setHasOpenedWindowMenu] = createSignal(false); + const [enableDeviceQueries, setEnableDeviceQueries] = createSignal(false); + const [enableCaptureLists, setEnableCaptureLists] = createSignal(false); - const queryClient = useQueryClient(); - onMount(() => { - queryClient.prefetchQuery(listDisplaysWithThumbnails); - queryClient.prefetchQuery(listWindowsWithThumbnails); + createEffect(() => { + if (cameraMenuOpen() || microphoneMenuOpen()) { + setEnableDeviceQueries(true); + } + }); + + createEffect(() => { + if (displayMenuOpen() || windowMenuOpen()) { + setEnableCaptureLists(true); + } }); let displayTriggerRef: HTMLButtonElement | undefined; @@ -1058,7 +1618,10 @@ function Page() { enabled: hasOpenedWindowMenu(), })); - const recordings = useQuery(() => listRecordings); + const recordings = useQuery(() => ({ + ...listRecordings, + enabled: recordingsMenuOpen(), + })); const [uploadProgress, setUploadProgress] = createStore< Record @@ -1116,7 +1679,8 @@ function Page() { ([path, meta]) => ({ ...meta, path }) as ScreenshotWithPath, ); }, - refetchInterval: 10_000, + enabled: screenshotsMenuOpen(), + refetchInterval: screenshotsMenuOpen() ? 10_000 : false, staleTime: 5_000, reconcile: (old, next) => reconcile(next)(old), initialData: [], @@ -1124,8 +1688,15 @@ function Page() { }), ); - const screens = useQuery(() => listScreens); - const windows = useQuery(() => listWindows); + const screens = useQuery(() => ({ + ...listScreens, + enabled: enableCaptureLists(), + refetchInterval: enableCaptureLists() ? listScreens.refetchInterval : false, + })); + const windows = useQuery(() => ({ + ...listWindows, + enabled: enableCaptureLists(), + })); const hasDisplayTargetsData = () => displayTargets.status === "success"; const hasWindowTargetsData = () => windowTargets.status === "success"; @@ -1249,6 +1820,8 @@ function Page() { setModeInfoMenuOpen(false); setCameraMenuOpen(false); setMicrophoneMenuOpen(false); + setCameraInitialSettings(null); + setMicrophoneInitialSettings(null); }); createUpdateCheck(); @@ -1275,6 +1848,12 @@ function Page() { currentWindow.setSize( new LogicalSize(WINDOW_SIZE.width, WINDOW_SIZE.height), ); + if (!targetMode) { + await currentWindow.show(); + await currentWindow.setFocus(); + } + setCanRevealMainWindow(true); + void emit("main-window-ready"); const unlistenFocus = currentWindow.onFocusChanged( ({ payload: focused }) => { @@ -1319,7 +1898,11 @@ function Page() { }); }); - const devices = createStableDevicesQuery(); + const devices = createStableDevicesQuery(enableDeviceQueries); + const permissions = useQuery(() => getPermissions); + const currentPermissions = createMemo( + () => devices.permissions ?? permissions.data, + ); const windowListSignature = createMemo(() => createWindowSignature(windows.data), @@ -1344,7 +1927,6 @@ function Page() { if (signature !== undefined) setDisplayThumbnailsSignature(signature); }); - // Refetch thumbnails only when the cheaper lists detect a change. createEffect(() => { if (!hasOpenedWindowMenu()) return; const signature = windowListSignature(); @@ -1365,12 +1947,23 @@ function Page() { createEffect(() => { const cameraList = devices.cameras; - if (rawOptions.cameraID && findCamera(cameraList, rawOptions.cameraID)) { - setOptions("cameraLabel", null); + if (!rawOptions.cameraID) return; + const camera = findCamera(cameraList, rawOptions.cameraID); + if (camera && rawOptions.cameraLabel !== camera.display_name) { + setOptions("cameraLabel", camera.display_name); } }); createEffect(() => { + if (!enableDeviceQueries() || devices.isPending || devices.isLoading) + return; + const permissions = currentPermissions(); + if ( + permissions?.microphone !== "granted" && + permissions?.microphone !== "notNeeded" + ) { + return; + } const micList = devices.microphones; if ( rawOptions.micName && @@ -1493,20 +2086,6 @@ function Page() { const setCamera = createCameraMutation(); - onMount(() => { - if (rawOptions.micName) { - setMicInput - .mutateAsync(rawOptions.micName) - .catch((error) => console.error("Failed to set mic input:", error)); - } - - if (rawOptions.cameraID && "ModelID" in rawOptions.cameraID) - setCamera.mutate({ model: { ModelID: rawOptions.cameraID.ModelID } }); - else if (rawOptions.cameraID && "DeviceID" in rawOptions.cameraID) - setCamera.mutate({ model: { DeviceID: rawOptions.cameraID.DeviceID } }); - else setCamera.mutate({ model: null }); - }); - const license = createLicenseQuery(); const signIn = createSignInMutation(); @@ -1525,44 +2104,68 @@ function Page() { }, })); + const openCameraMenu = (initialSettings: CameraWithDetails | null) => { + setEnableDeviceQueries(true); + setCameraInitialSettings(initialSettings); + setCameraMenuOpen(true); + setDisplayMenuOpen(false); + setWindowMenuOpen(false); + setRecordingsMenuOpen(false); + setScreenshotsMenuOpen(false); + setModeInfoMenuOpen(false); + setMicrophoneMenuOpen(false); + }; + + const openMicrophoneMenu = ( + initialSettings: MicrophoneWithDetails | null, + ) => { + setEnableDeviceQueries(true); + setMicrophoneInitialSettings(initialSettings); + setMicrophoneMenuOpen(true); + setDisplayMenuOpen(false); + setWindowMenuOpen(false); + setRecordingsMenuOpen(false); + setScreenshotsMenuOpen(false); + setModeInfoMenuOpen(false); + setCameraMenuOpen(false); + }; + const BaseControls = () => (
{ - if (!c) setCamera.mutate({ model: null }); - else if (c.model_id) + if (!c) { + setOptions("cameraLabel", null); + setCamera.mutate({ model: null }); + } else if (c.model_id) { + setOptions("cameraLabel", c.display_name); setCamera.mutate({ model: { ModelID: c.model_id } }); - else setCamera.mutate({ model: { DeviceID: c.device_id } }); - }} - permissions={devices.permissions} - onOpen={() => { - setCameraMenuOpen(true); - setDisplayMenuOpen(false); - setWindowMenuOpen(false); - setRecordingsMenuOpen(false); - setScreenshotsMenuOpen(false); - setModeInfoMenuOpen(false); - setMicrophoneMenuOpen(false); + } else { + setOptions("cameraLabel", c.display_name); + setCamera.mutate({ model: { DeviceID: c.device_id } }); + } }} + permissions={currentPermissions()} + onOpen={() => openCameraMenu(null)} + onOpenSettings={() => openCameraMenu(options.camera() ?? null)} /> m.name)} - value={options.micName()?.name ?? null} + value={rawOptions.micName ?? null} onChange={(v) => setMicInput.mutate(v)} - permissions={devices.permissions} - onOpen={() => { - setMicrophoneMenuOpen(true); - setDisplayMenuOpen(false); - setWindowMenuOpen(false); - setRecordingsMenuOpen(false); - setScreenshotsMenuOpen(false); - setModeInfoMenuOpen(false); - setCameraMenuOpen(false); - }} + permissions={currentPermissions()} + onOpen={() => openMicrophoneMenu(null)} + onOpenSettings={ + options.micName() + ? () => openMicrophoneMenu(options.micName() ?? null) + : undefined + } />
@@ -1610,6 +2213,7 @@ function Page() { setDisplayMenuOpen((prev) => { const next = !prev; if (next) { + setEnableCaptureLists(true); setWindowMenuOpen(false); setHasOpenedDisplayMenu(true); screens.refetch(); @@ -1651,6 +2255,7 @@ function Page() { setWindowMenuOpen((prev) => { const next = !prev; if (next) { + setEnableCaptureLists(true); setDisplayMenuOpen(false); setHasOpenedWindowMenu(true); windows.refetch(); @@ -1732,9 +2337,9 @@ function Page() { await commands.showWindow({ Settings: { page: "general" } }); getCurrentWindow().hide(); }} - class="flex items-center justify-center size-5 -ml-[1.5px] focus:outline-none" + class="flex items-center justify-center size-5 focus:outline-none" > - + Screenshots}> @@ -1973,17 +2578,31 @@ function Page() { selectedTarget={options.camera() ?? null} isLoading={devices.isPending} onSelect={(c) => { - if (!c) setCamera.mutate({ model: null }); - else if (c.model_id) + if (!c) { + setOptions("cameraLabel", null); + setCamera.mutate({ model: null }); + } else if (c.model_id) { + setOptions("cameraLabel", c.display_name); setCamera.mutate({ model: { ModelID: c.model_id } }); - else setCamera.mutate({ model: { DeviceID: c.device_id } }); + } else { + setOptions("cameraLabel", c.display_name); + setCamera.mutate({ model: { DeviceID: c.device_id } }); + } setCameraMenuOpen(false); + setCameraInitialSettings(null); }} disabled={isRecording()} onBack={() => { setCameraMenuOpen(false); + setCameraInitialSettings(null); + }} + permissions={currentPermissions()} + deviceSettings={deviceSettings() ?? undefined} + onCameraSettingsChange={(camera, settings) => { + void setCameraDeviceSettings(camera, settings); }} - permissions={devices.permissions} + compatibilityStudioMode={compatibilityStudioMode()} + initialSettingsTarget={cameraInitialSettings()} /> ) : variant === "microphone" ? ( { setMicInput.mutate(v?.name ?? null); setMicrophoneMenuOpen(false); + setMicrophoneInitialSettings(null); }} disabled={isRecording()} onBack={() => { setMicrophoneMenuOpen(false); + setMicrophoneInitialSettings(null); + }} + permissions={currentPermissions()} + deviceSettings={deviceSettings() ?? undefined} + onMicrophoneSettingsChange={(key, settings) => { + void setMicrophoneDeviceSettings(key, settings); }} - permissions={devices.permissions} + compatibilityStudioMode={compatibilityStudioMode()} + initialSettingsTarget={microphoneInitialSettings()} /> ) : ( devicesSnapshot); +export function createDevicesQuery(enabled: Accessor = () => true) { + const query = useQuery(() => ({ + ...devicesSnapshot, + enabled: enabled(), + refetchInterval: enabled() ? devicesSnapshot.refetchInterval : false, + })); createEffect(() => { const unlisten = events.devicesUpdated.listen(() => { + if (!enabled()) return; query.refetch(); }); @@ -49,9 +68,12 @@ export function createDevicesQuery() { type CameraDetailsCache = Record< string, - { width: number; height: number; frameRate: number } + { bestFormat?: CameraFormatInfo; formats?: CameraFormatInfo[] } +>; +type MicDetailsCache = Record< + string, + { sampleRate: number; channels: number; formats?: MicrophoneFormatInfo[] } >; -type MicDetailsCache = Record; function cameraListChanged( oldList: CameraWithDetails[], @@ -71,8 +93,10 @@ function micListChanged( return newList.some((name) => !oldNames.has(name)); } -export function createStableDevicesQuery() { - const query = createDevicesQuery(); +export function createStableDevicesQuery( + enabled: Accessor = () => true, +) { + const query = createDevicesQuery(enabled); const [cameras, setCameras] = createStore([]); const [microphones, setMicrophones] = createStore( @@ -92,13 +116,15 @@ export function createStableDevicesQuery() { if (hasListChanged) { const existingMap = new Map( - currentCameras.map((c) => [c.device_id, c.bestFormat]), + currentCameras.map((c) => [ + c.device_id, + { bestFormat: c.bestFormat, formats: c.formats }, + ]), ); const newCameras: CameraWithDetails[] = rawCameras.map((c) => ({ ...c, - bestFormat: - cameraDetailsCache[c.device_id] ?? existingMap.get(c.device_id), + ...(cameraDetailsCache[c.device_id] ?? existingMap.get(c.device_id)), })); setCameras(newCameras); @@ -112,18 +138,18 @@ export function createStableDevicesQuery() { pendingCameraFetches.add(camera.device_id); commands.getCameraFormats(camera.device_id).then((formats) => { pendingCameraFetches.delete(camera.device_id); - if (formats?.bestFormat) { + if (formats) { const details = { - width: formats.bestFormat.width, - height: formats.bestFormat.height, - frameRate: formats.bestFormat.frameRate, + bestFormat: formats.bestFormat ?? undefined, + formats: formats.formats, }; cameraDetailsCache[camera.device_id] = details; setCameras( produce((cams) => { const cam = cams.find((c) => c.device_id === camera.device_id); if (cam) { - cam.bestFormat = details; + cam.bestFormat = details.bestFormat; + cam.formats = details.formats; } }), ); @@ -143,7 +169,11 @@ export function createStableDevicesQuery() { const existingMap = new Map( currentMics.map((m) => [ m.name, - { sampleRate: m.sampleRate, channels: m.channels }, + { + sampleRate: m.sampleRate, + channels: m.channels, + formats: m.formats, + }, ]), ); @@ -161,9 +191,13 @@ export function createStableDevicesQuery() { commands.getMicrophoneInfo(name).then((info) => { pendingMicFetches.delete(name); if (info) { + const extendedInfo = info as typeof info & { + formats?: MicrophoneFormatInfo[]; + }; const details = { sampleRate: info.sampleRate, channels: info.channels, + formats: extendedInfo.formats, }; micDetailsCache[name] = details; setMicrophones( @@ -172,6 +206,7 @@ export function createStableDevicesQuery() { if (mic) { mic.sampleRate = details.sampleRate; mic.channels = details.channels; + mic.formats = details.formats; } }), ); diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 2513f98d22..6c6a4bad98 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -104,6 +104,7 @@ declare global { const IconLucideRotateCw: typeof import('~icons/lucide/rotate-cw.jsx')['default'] const IconLucideSave: typeof import('~icons/lucide/save.jsx')['default'] const IconLucideSearch: typeof import('~icons/lucide/search.jsx')['default'] + const IconLucideSettings: typeof import('~icons/lucide/settings.jsx')['default'] const IconLucideSparkles: typeof import('~icons/lucide/sparkles.jsx')['default'] const IconLucideSquarePlay: typeof import('~icons/lucide/square-play.jsx')['default'] const IconLucideSubtitles: typeof import('~icons/lucide/subtitles.jsx')['default'] From 931f3a9a7122431ddfd90d071a2bfec625b36d53 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 4 May 2026 12:28:28 -0700 Subject: [PATCH 5/5] comments + clippy --- apps/desktop/src-tauri/src/lib.rs | 48 ++++++++++-- apps/desktop/src-tauri/src/recording.rs | 78 ++++++++++++++++++-- apps/desktop/src-tauri/src/windows.rs | 9 ++- apps/desktop/src/utils/devices.ts | 5 +- crates/recording/src/feeds/microphone.rs | 26 +++++-- crates/recording/src/output_pipeline/core.rs | 10 +-- crates/recording/src/sources/microphone.rs | 6 +- 7 files changed, 148 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 58f48cdbc6..80aae9ceb8 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -402,6 +402,20 @@ impl App { } } + fn microphone_settings_for_label( + &self, + label: &str, + ) -> Option { + recording_settings::RecordingSettingsStore::microphone_settings_for(&self.handle, label) + } + + fn camera_settings_for_id( + &self, + id: &DeviceOrModelID, + ) -> Option { + recording_settings::RecordingSettingsStore::camera_settings_for(&self.handle, id) + } + async fn restart_mic_feed(&mut self) -> Result<(), String> { info!("Restarting microphone feed after actor shutdown"); @@ -416,7 +430,8 @@ impl App { .map_err(|e| e.to_string())?; if let Some(label) = self.selected_mic_label.clone() { - match mic_feed.ask(microphone::SetInput { label }).await { + let settings = self.microphone_settings_for_label(&label); + match mic_feed.ask(microphone::SetInput { label, settings }).await { Ok(ready) => { if let Err(err) = ready.await { if matches!(err, microphone::SetInputError::DeviceNotFound) { @@ -536,9 +551,10 @@ impl App { async fn ensure_selected_mic_ready(&mut self) -> Result<(), String> { if let Some(label) = self.selected_mic_label.clone() { + let settings = self.microphone_settings_for_label(&label); let ready = self .mic_feed - .ask(feeds::microphone::SetInput { label }) + .ask(feeds::microphone::SetInput { label, settings }) .await .map_err(|e| e.to_string())?; @@ -550,9 +566,13 @@ impl App { async fn ensure_selected_camera_ready(&mut self) -> Result<(), String> { if let Some(id) = self.selected_camera_id.clone() { + let settings = self.camera_settings_for_id(&id); let ready = self .camera_feed - .ask(feeds::camera::SetInput { id: id.clone() }) + .ask(feeds::camera::SetInput { + id: id.clone(), + settings, + }) .await .map_err(|e| e.to_string())?; @@ -569,7 +589,7 @@ impl App { async fn set_mic_input(state: MutableState<'_, App>, label: Option) -> Result<(), String> { let desired_label = label; - let (mic_feed, studio_handle, previous_label) = { + let (mic_feed, studio_handle, previous_label, app_handle) = { let mut app = state.write().await; if desired_label == app.selected_mic_label { return Ok(()); @@ -583,7 +603,12 @@ async fn set_mic_input(state: MutableState<'_, App>, label: Option) -> R let previous_label = app.selected_mic_label.clone(); app.selected_mic_label = desired_label.clone(); - (app.mic_feed.clone(), handle, previous_label) + ( + app.mic_feed.clone(), + handle, + previous_label, + app.handle.clone(), + ) }; let has_studio = studio_handle.is_some(); @@ -609,9 +634,15 @@ async fn set_mic_input(state: MutableState<'_, App>, label: Option) -> R } } Some(label) => { + let settings = + recording_settings::RecordingSettingsStore::microphone_settings_for( + &app_handle, + label, + ); mic_feed .ask(feeds::microphone::SetInput { label: label.clone(), + settings, }) .await .map_err(|e| e.to_string())? @@ -746,6 +777,8 @@ async fn set_camera_input( .map_err(|e| e.to_string())?; } Some(id) => { + let settings = + recording_settings::RecordingSettingsStore::camera_settings_for(&app_handle, id); let (camera_ws_sender, native_preview_active) = { let app = &mut *state.write().await; app.selected_camera_id = Some(id.clone()); @@ -781,7 +814,10 @@ async fn set_camera_input( attempts += 1; let request = camera_feed - .ask(feeds::camera::SetInput { id: id.clone() }) + .ask(feeds::camera::SetInput { + id: id.clone(), + settings, + }) .await .map_err(|e| e.to_string()); diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index d2077f5e08..2ba78b137d 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1,5 +1,6 @@ use anyhow::anyhow; use cap_fail::fail; +use cap_media_info::ffmpeg_sample_format_for; use cap_project::CursorMoveEvent; use cap_project::cursor::SHORT_CURSOR_SHAPE_DEBOUNCE_MS; use cap_project::{ @@ -27,6 +28,7 @@ use cap_recording::{ }; use cap_rendering::ProjectRecordingsMeta; use cap_utils::{ensure_dir, moment_format_to_chrono, spawn_actor}; +use cpal::traits::DeviceTrait; use futures::{FutureExt, stream}; use lazy_static::lazy_static; use regex::Regex; @@ -37,7 +39,7 @@ use std::borrow::Cow; use std::error::Error as StdError; use std::{ any::Any, - collections::{HashMap, VecDeque}, + collections::{BTreeSet, HashMap, VecDeque}, panic::AssertUnwindSafe, path::{Path, PathBuf}, str::FromStr, @@ -401,6 +403,14 @@ pub struct MicrophoneInfo { pub name: String, pub sample_rate: u32, pub channels: u16, + pub formats: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct MicrophoneFormatInfo { + pub sample_rate: u32, + pub channels: u16, } #[tauri::command(async)] @@ -416,11 +426,50 @@ pub fn get_microphone_info(name: String) -> Option { microphone::MicrophoneFeed::list() .into_iter() .find(|(n, _)| *n == name) - .map(|(name, (_device, config))| MicrophoneInfo { - name, - sample_rate: config.sample_rate().0, - channels: config.channels(), + .map(|(name, (device, config))| { + let formats = microphone_format_infos(&device); + MicrophoneInfo { + name, + sample_rate: config.sample_rate().0, + channels: config.channels(), + formats, + } + }) +} + +fn microphone_format_infos(device: &cpal::Device) -> Vec { + let Ok(configs) = device.supported_input_configs() else { + return vec![]; + }; + let mut formats = BTreeSet::new(); + + for config in configs { + if ffmpeg_sample_format_for(config.sample_format()).is_none() { + continue; + } + + for sample_rate in [ + config.min_sample_rate().0, + 44_100, + 48_000, + 96_000, + config.max_sample_rate().0, + ] { + if config.min_sample_rate().0 <= sample_rate + && sample_rate <= config.max_sample_rate().0 + { + formats.insert((sample_rate, config.channels())); + } + } + } + + formats + .into_iter() + .map(|(sample_rate, channels)| MicrophoneFormatInfo { + sample_rate, + channels, }) + .collect() } #[tauri::command] @@ -851,9 +900,19 @@ pub async fn start_recording( use kameo::error::SendError; // Initialize camera if selected but not active - let (camera_feed_actor, selected_camera_id) = { + let (camera_feed_actor, selected_camera_id, selected_camera_settings) = { let state = state_mtx.read().await; - (state.camera_feed.clone(), state.selected_camera_id.clone()) + let selected_camera_settings = state.selected_camera_id.as_ref().and_then(|id| { + crate::recording_settings::RecordingSettingsStore::camera_settings_for( + &state.handle, + id, + ) + }); + ( + state.camera_feed.clone(), + state.selected_camera_id.clone(), + selected_camera_settings, + ) }; let camera_lock_result = camera_feed_actor.ask(camera::Lock).await; @@ -867,7 +926,10 @@ pub async fn start_recording( id ); match camera_feed_actor - .ask(camera::SetInput { id: id.clone() }) + .ask(camera::SetInput { + id: id.clone(), + settings: selected_camera_settings, + }) .await { Ok(fut) => match fut.await { diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index ebd167ddb9..a2e98d8155 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -121,9 +121,13 @@ async fn ensure_camera_input_active(app_state: &mut App) { if let Some(id) = app_state.selected_camera_id.clone() && !app_state.camera_in_use { + let settings = crate::recording_settings::RecordingSettingsStore::camera_settings_for( + &app_state.handle, + &id, + ); match app_state .camera_feed - .ask(feeds::camera::SetInput { id }) + .ask(feeds::camera::SetInput { id, settings }) .await { Ok(ready_future) => { @@ -180,6 +184,8 @@ async fn restore_main_window_inputs(app: &AppHandle) { if let Some(camera_id) = camera_to_restore { let state = app.state::>(); + let settings = + crate::recording_settings::RecordingSettingsStore::camera_settings_for(app, &camera_id); let (camera_feed, camera_ws_sender, native_sender) = { let app_state = &mut *state.write().await; @@ -212,6 +218,7 @@ async fn restore_main_window_inputs(app: &AppHandle) { let request = camera_feed .ask(feeds::camera::SetInput { id: camera_id.clone(), + settings, }) .await .map_err(|e| e.to_string()); diff --git a/apps/desktop/src/utils/devices.ts b/apps/desktop/src/utils/devices.ts index d66686bb24..52c030916f 100644 --- a/apps/desktop/src/utils/devices.ts +++ b/apps/desktop/src/utils/devices.ts @@ -191,13 +191,10 @@ export function createStableDevicesQuery( commands.getMicrophoneInfo(name).then((info) => { pendingMicFetches.delete(name); if (info) { - const extendedInfo = info as typeof info & { - formats?: MicrophoneFormatInfo[]; - }; const details = { sampleRate: info.sampleRate, channels: info.channels, - formats: extendedInfo.formats, + formats: info.formats, }; micDetailsCache[name] = details; setMicrophones( diff --git a/crates/recording/src/feeds/microphone.rs b/crates/recording/src/feeds/microphone.rs index adb3064992..438dba6840 100644 --- a/crates/recording/src/feeds/microphone.rs +++ b/crates/recording/src/feeds/microphone.rs @@ -412,19 +412,29 @@ fn select_preferred_config( settings: &MicrophoneDeviceSettings, ) -> Option { let rate = settings.sample_rate.map(cpal::SampleRate); - - configs + let compatible_configs = configs .iter() - .find(|config| { - ffmpeg_sample_format_for(config.sample_format()).is_some() - && settings - .channels - .is_none_or(|channels| config.channels() == channels) + .filter(|config| ffmpeg_sample_format_for(config.sample_format()).is_some()) + .collect::>(); + + let find_config = |channels: Option, rate: Option| { + compatible_configs.iter().find(|config| { + channels.is_none_or(|channels| config.channels() == channels) && rate.is_none_or(|rate| { config.min_sample_rate().0 <= rate.0 && config.max_sample_rate().0 >= rate.0 }) }) - .map(|config| config.with_sample_rate(rate.unwrap_or_else(|| select_sample_rate(config)))) + }; + + let config = find_config(settings.channels, rate) + .or_else(|| rate.and_then(|rate| find_config(None, Some(rate)))) + .or_else(|| { + settings + .channels + .and_then(|channels| find_config(Some(channels), None)) + })?; + + Some(config.with_sample_rate(rate.unwrap_or_else(|| select_sample_rate(config)))) } fn select_sample_rate(config: &SupportedStreamConfigRange) -> cpal::SampleRate { diff --git a/crates/recording/src/output_pipeline/core.rs b/crates/recording/src/output_pipeline/core.rs index 36b97f6056..c88e8398ee 100644 --- a/crates/recording/src/output_pipeline/core.rs +++ b/crates/recording/src/output_pipeline/core.rs @@ -1716,7 +1716,7 @@ fn resolve_pipeline_completion( ) -> anyhow::Result<()> { match (task_result, muxer_result) { (Err(error), _) | (_, Err(error)) => Err(error), - (_, Ok(Ok(()))) if stop_signal.user_stopped() => Err(anyhow!(PipelineStoppedByUser)), + (_, Ok(Ok(()))) if stop_signal.user_stopped() => Ok(()), (_, Ok(Ok(()))) => Ok(()), (_, Ok(Err(error))) => Err(anyhow!("Muxer finish failed: {error:#}")), } @@ -3365,14 +3365,12 @@ mod tests { } #[test] - fn surfaces_user_stop_after_clean_finish() { + fn treats_user_stop_after_clean_finish_as_success() { let signal = PipelineStopSignal::default(); signal.mark_user_stopped(); - let error = resolve_pipeline_completion(Ok(()), Ok(Ok(())), &signal) - .expect_err("user stop should surface as a typed pipeline completion"); - - assert!(error.is::()); + resolve_pipeline_completion(Ok(()), Ok(Ok(())), &signal) + .expect("user stop should complete cleanly after successful finish"); } } diff --git a/crates/recording/src/sources/microphone.rs b/crates/recording/src/sources/microphone.rs index 81d0bdf624..90332aeff5 100644 --- a/crates/recording/src/sources/microphone.rs +++ b/crates/recording/src/sources/microphone.rs @@ -157,6 +157,10 @@ impl AudioSource for Microphone { .with_channels(MICROPHONE_TARGET_CHANNELS as usize); let is_wireless = source_info.is_wireless_transport; let device_name = feed_lock.device_name().to_string(); + let reconnect_settings = microphone::MicrophoneDeviceSettings { + sample_rate: Some(source_info.sample_rate), + channels: u16::try_from(source_info.channels).ok(), + }; let cancel = CancellationToken::new(); let (tx, rx) = flume::bounded(128); @@ -365,7 +369,7 @@ impl AudioSource for Microphone { let ready = match feed .ask(microphone::SetInput { label: name, - settings: None, + settings: Some(reconnect_settings), }) .await {