diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 6a57a662496..9c58541d522 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1111,6 +1111,27 @@ async fn set_mic_input(state: MutableState<'_, App>, label: Option) -> R let mut app = state.write().await; app.ensure_mic_feed_alive().await?; + // A selected microphone that isn't connected is an expected state + // (e.g. undocked laptop), not an error: remember the selection so the + // device can be reclaimed when it reappears, and leave the feed empty. + if let Some(label) = desired_label.as_ref() + && !matches!(app.recording_state, RecordingState::Active(_)) + && find_mic_by_label_or_fuzzy(&MicrophoneFeed::list_names(), label).is_none() + { + info!( + "Selected microphone '{label}' is not connected; keeping selection with no input" + ); + app.selected_mic_label = desired_label.clone(); + let mic_feed = app.mic_feed.clone(); + drop(app); + // Best-effort: the feed may be locked by a recording that is still + // spinning up (Pending), in which case it must keep its input. + if let Err(err) = mic_feed.ask(microphone::RemoveInput).await { + warn!("Failed to release microphone input for absent device: {err}"); + } + return Ok(()); + } + if desired_label == app.selected_mic_label { if desired_label.is_some() && !matches!(app.recording_state, RecordingState::Active(_)) { @@ -1283,6 +1304,45 @@ async fn set_camera_input( .map_err(|e| e.to_string())?; } + // A selected camera that isn't connected is an expected state (e.g. undocked + // laptop), not an error: tear down like a deselect but remember the selection + // so the device can be reclaimed when it reappears. Running the init/retry + // loop instead would flash the preview window and toast an error on every + // launch and picker-open while the device is away. + if let Some(id) = &id + && !is_camera_available(id) + { + info!(camera = ?id, "Selected camera is not connected; keeping selection with no input"); + let shutdown_rx = { + let app = &mut *state.write().await; + app.camera_in_use = false; + app.selected_camera_id = Some(id.clone()); + app.camera_cleanup_done = true; + if skip_camera_window { + app.camera_preview.begin_shutdown() + } else { + app.camera_preview.pause(); + None + } + }; + + // Best-effort: the feed may be locked by a recording that is still + // spinning up (Pending), in which case it must keep its input. + if let Err(err) = camera_feed.ask(feeds::camera::RemoveInput).await { + warn!("Failed to release camera input for absent device: {err}"); + } + + if let Some(rx) = shutdown_rx { + let _ = tokio::time::timeout(Duration::from_millis(500), rx).await; + } + + if !skip_camera_window && let Some(window) = CapWindowId::Camera.get(&app_handle) { + let _ = window.hide(); + } + + return Ok(()); + } + match &id { None => { let shutdown_rx = { @@ -2162,7 +2222,10 @@ pub async fn request_app_exit(app: AppHandle) { finalize_app_exit(&app, 0); } -fn find_mic_by_label_or_fuzzy(devices: &[String], selected_label: &str) -> Option { +pub(crate) fn find_mic_by_label_or_fuzzy( + devices: &[String], + selected_label: &str, +) -> Option { if devices.iter().any(|name| name == selected_label) { return Some(selected_label.to_string()); } @@ -2313,7 +2376,7 @@ fn spawn_camera_watcher(app_handle: AppHandle) { }); } -fn is_camera_available(id: &DeviceOrModelID) -> bool { +pub(crate) fn is_camera_available(id: &DeviceOrModelID) -> bool { let cameras: Vec<_> = cap_camera::list_cameras().collect(); debug!( "is_camera_available: looking for {:?} in {} cameras", diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index a12f05431d6..6f8fc6c5ee7 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1817,6 +1817,29 @@ pub async fn start_recording( ) }; + // A remembered camera that isn't connected must not abort the + // recording (camera-only mode excepted, where it's required): + // degrade to no-camera and tell the user once. + let selected_camera_id = match selected_camera_id { + Some(id) + if !matches!(inputs.capture_target, ScreenCaptureTarget::CameraOnly) + && !crate::is_camera_available(&id) => + { + warn!( + camera = %camera_id_label(&id), + "Selected camera is not connected; recording without camera" + ); + let _ = crate::NewNotification { + title: "Recording without camera".to_string(), + body: "The selected camera is not connected, so this recording won't include it.".to_string(), + is_error: true, + } + .emit(&app_handle); + None + } + other => other, + }; + let camera_feed = lock_selected_camera( &camera_feed_actor, selected_camera_id, @@ -1900,7 +1923,34 @@ pub async fn start_recording( let (done_fut, health_rx) = loop { let actor_result: Result = async { - let selected_mic_label = state.selected_mic_label.clone(); + // Resolve the remembered microphone against the connected + // devices (fuzzy-matching Bluetooth profile renames like the + // reconnect watcher does). A missing microphone must not + // abort the recording: degrade and tell the user once. + let selected_mic_label = match state.selected_mic_label.clone() { + Some(label) => { + let matched = crate::find_mic_by_label_or_fuzzy( + µphone::MicrophoneFeed::list_names(), + &label, + ); + if matched.is_none() { + warn!( + mic = %label, + "Selected microphone is not connected; recording without microphone" + ); + let _ = crate::NewNotification { + title: "Recording without microphone".to_string(), + body: format!( + "Microphone '{label}' is not connected, so this recording won't include it." + ), + is_error: true, + } + .emit(&app_handle); + } + matched + } + None => None, + }; let selected_mic_settings = selected_mic_label .as_ref() .and_then(|label| state.microphone_settings_for_label(label)); diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 4dec972b3ac..5deba345c4e 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -397,7 +397,11 @@ pub(crate) async fn restore_main_window_inputs(app: &AppHandle) { None } }) - .unwrap_or(None); + .unwrap_or(None) + // A remembered camera that isn't connected must not run the init/retry + // loop below: it would flash the preview window and toast an error on + // every main-window reveal while the device is away. + .filter(crate::is_camera_available); if let Some(camera_id) = camera_to_restore { emit_camera_preview_clear(app); 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 0f3a09e95b8..4321547bb6d 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx @@ -35,6 +35,7 @@ export default function CameraSelect(props: { value: CameraInfo | null; selectedLabel?: string | null; isSelected?: boolean; + disconnected?: boolean; onChange: (camera: CameraInfo | null) => void; permissions?: OSPermissionsCheck; hidePreviewButton?: boolean; @@ -93,6 +94,8 @@ export default function CameraSelect(props: { props.permissions.camera === "granted" || props.permissions.camera === "notNeeded"; + const notConnected = () => !!props.disconnected && permissionGranted(); + const hasSelection = () => props.isSelected ?? props.value !== null; const label = () => @@ -104,10 +107,14 @@ export default function CameraSelect(props: { props.value !== null && permissionGranted() && !cameraWindowOpen() && - !props.hidePreviewButton; + !props.hidePreviewButton && + !notConnected(); const showSettingsShortcut = () => - hasSelection() && permissionGranted() && !!props.onOpenSettings; + hasSelection() && + permissionGranted() && + !!props.onOpenSettings && + !notConnected(); const isDisabled = () => !!currentRecording.data || props.disabled; @@ -127,7 +134,12 @@ export default function CameraSelect(props: { aria-haspopup="menu" > -

{label()}

+

+ {label()} +