Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1111,6 +1111,27 @@ async fn set_mic_input(state: MutableState<'_, App>, label: Option<String>) -> 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(_))
{
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<String> {
pub(crate) fn find_mic_by_label_or_fuzzy(
devices: &[String],
selected_label: &str,
) -> Option<String> {
if devices.iter().any(|name| name == selected_label) {
return Some(selected_label.to_string());
}
Expand Down Expand Up @@ -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",
Expand Down
52 changes: 51 additions & 1 deletion apps/desktop/src-tauri/src/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1900,7 +1923,34 @@ pub async fn start_recording(

let (done_fut, health_rx) = loop {
let actor_result: Result<InProgressRecording, anyhow::Error> = 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(
&microphone::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));
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src-tauri/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 16 additions & 3 deletions apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = () =>
Expand All @@ -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;

Expand All @@ -127,7 +134,12 @@ export default function CameraSelect(props: {
aria-haspopup="menu"
>
<IconCapCamera class={DEVICE_ROW_ICON_CLASS} />
<p class={DEVICE_ROW_LABEL_CLASS}>{label()}</p>
<p
class={cx(DEVICE_ROW_LABEL_CLASS, notConnected() && "text-gray-10")}
title={notConnected() ? "Not connected" : undefined}
>
{label()}
</p>
<div class={DEVICE_ROW_TRAILING_CLASS}>
<Show when={showHiddenIndicator()}>
<button
Expand Down Expand Up @@ -160,6 +172,7 @@ export default function CameraSelect(props: {
<TargetSelectInfoPill
PillComponent={InfoPill}
value={hasSelection() ? true : null}
disconnected={notConnected()}
permissionGranted={permissionGranted()}
requestPermission={() =>
requestPermission("camera", props.permissions?.camera)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default function MicrophoneSelect(props: {
disabled?: boolean;
options: string[];
value: string | null;
disconnected?: boolean;
onChange: (micName: string | null) => void;
permissions?: OSPermissionsCheck;
onOpen?: () => void;
Expand All @@ -43,6 +44,8 @@ export default function MicrophoneSelect(props: {
props.permissions.microphone === "granted" ||
props.permissions.microphone === "notNeeded";

const notConnected = () => !!props.disconnected && permissionGranted();

const handleMicrophoneChange = async (name: string | null) => {
if (!props.options) return;
props.onChange(name);
Expand All @@ -62,10 +65,14 @@ export default function MicrophoneSelect(props: {
const audioLevel = () =>
(1 - Math.max((dbs() ?? 0) + DB_SCALE, 0) / DB_SCALE) ** 0.5;

const showLevel = () => props.value !== null && dbs() !== undefined;
const showLevel = () =>
props.value !== null && dbs() !== undefined && !notConnected();

const showSettingsShortcut = () =>
props.value !== null && permissionGranted() && !!props.onOpenSettings;
props.value !== null &&
permissionGranted() &&
!!props.onOpenSettings &&
!notConnected();

const isDisabled = () => !!currentRecording.data || props.disabled;

Expand Down Expand Up @@ -95,7 +102,12 @@ export default function MicrophoneSelect(props: {
/>
</Show>
<IconCapMicrophone class={DEVICE_ROW_ICON_CLASS} />
<p class={DEVICE_ROW_LABEL_CLASS}>{props.value ?? NO_MICROPHONE}</p>
<p
class={cx(DEVICE_ROW_LABEL_CLASS, notConnected() && "text-gray-10")}
title={notConnected() ? "Not connected" : undefined}
>
{props.value ?? NO_MICROPHONE}
</p>
<div class={DEVICE_ROW_TRAILING_CLASS}>
<Show when={showSettingsShortcut()}>
<button
Expand All @@ -116,6 +128,7 @@ export default function MicrophoneSelect(props: {
<TargetSelectInfoPill
PillComponent={InfoPill}
value={props.value}
disconnected={notConnected()}
permissionGranted={permissionGranted()}
requestPermission={() =>
requestPermission("microphone", props.permissions?.microphone)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { InfoPillVariant } from "./InfoPill";
export default function TargetSelectInfoPill<T>(props: {
value: T | null;
permissionGranted: boolean;
disconnected?: boolean;
requestPermission: () => void;
onClick: (e: MouseEvent) => void;
PillComponent: Component<
Expand All @@ -13,6 +14,7 @@ export default function TargetSelectInfoPill<T>(props: {
}) {
const variant = (): InfoPillVariant => {
if (!props.permissionGranted) return "red";
if (props.disconnected) return "gray";
return props.value !== null ? "blue" : "gray";
};

Expand All @@ -35,7 +37,13 @@ export default function TargetSelectInfoPill<T>(props: {
props.onClick(e);
}}
>
{!props.permissionGranted ? "Allow" : props.value !== null ? "On" : "Off"}
{!props.permissionGranted
? "Allow"
: props.disconnected
? "Not connected"
: props.value !== null
? "On"
: "Off"}
</Dynamic>
);
}
Loading
Loading