From e440785a166dd8797f44da50c0a89d068fbaaffe Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:46:27 +0000 Subject: [PATCH 1/9] Refactor camera and mic input functions --- .../desktop/src-tauri/src/deeplink_actions.rs | 9 ++++++--- apps/desktop/src-tauri/src/lib.rs | 19 +++++++++++++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index dbd90f667f..86b6245cf8 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -6,7 +6,10 @@ use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager, Url}; use tracing::trace; -use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow}; +use crate::{ + App, ArcLock, apply_camera_input, apply_mic_input, recording::StartRecordingInputs, + windows::ShowCapWindow, +}; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -116,8 +119,8 @@ impl DeepLinkAction { } => { let state = app.state::>(); - crate::set_camera_input(app.clone(), state.clone(), camera).await?; - crate::set_mic_input(state.clone(), mic_label).await?; + apply_camera_input(app.clone(), state.clone(), camera).await?; + apply_mic_input(state.clone(), mic_label).await?; let capture_target: ScreenCaptureTarget = match capture_mode { CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays() diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index a63ae7fe01..ec2a2ae79b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -331,6 +331,13 @@ impl App { #[specta::specta] #[instrument(skip(state))] async fn set_mic_input(state: MutableState<'_, App>, label: Option) -> Result<(), String> { + apply_mic_input(state, label).await +} + +pub(crate) async fn apply_mic_input( + state: MutableState<'_, App>, + label: Option, +) -> Result<(), String> { let (mic_feed, studio_handle, current_label) = { let app = state.read().await; let handle = match app.current_recording() { @@ -414,6 +421,14 @@ async fn set_camera_input( app_handle: AppHandle, state: MutableState<'_, App>, id: Option, +) -> Result<(), String> { + apply_camera_input(app_handle, state, id).await +} + +pub(crate) async fn apply_camera_input( + app_handle: AppHandle, + state: MutableState<'_, App>, + id: Option, ) -> Result<(), String> { let app = state.read().await; let camera_feed = app.camera_feed.clone(); @@ -2543,8 +2558,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { .flatten() .unwrap_or_default(); - let _ = set_mic_input(app.state(), settings.mic_name).await; - let _ = set_camera_input(app.clone(), app.state(), settings.camera_id).await; + let _ = apply_mic_input(app.state(), settings.mic_name).await; + let _ = apply_camera_input(app.clone(), app.state(), settings.camera_id).await; let _ = start_recording(app.clone(), app.state(), { recording::StartRecordingInputs { From 1a94a75fd937cdaf2cce886fef522454d585efdf Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:47:01 +0000 Subject: [PATCH 2/9] Add interactive area bounds tracking for recording controls --- .../src/routes/in-progress-recording.tsx | 326 ++++++++++-------- 1 file changed, 179 insertions(+), 147 deletions(-) diff --git a/apps/desktop/src/routes/in-progress-recording.tsx b/apps/desktop/src/routes/in-progress-recording.tsx index d6f8b45af0..1e1eb44f2c 100644 --- a/apps/desktop/src/routes/in-progress-recording.tsx +++ b/apps/desktop/src/routes/in-progress-recording.tsx @@ -1,3 +1,4 @@ +import { createElementBounds } from "@solid-primitives/bounds"; import { createTimer } from "@solid-primitives/timer"; import { createMutation } from "@tanstack/solid-query"; import { LogicalPosition } from "@tauri-apps/api/dpi"; @@ -17,6 +18,7 @@ import { createEffect, createMemo, createSignal, + onCleanup, Show, } from "solid-js"; import { createStore, produce } from "solid-js/store"; @@ -52,6 +54,7 @@ declare global { const MAX_RECORDING_FOR_FREE = 5 * 60 * 1000; const NO_MICROPHONE = "No Microphone"; const NO_WEBCAM = "No Webcam"; +const FAKE_WINDOW_BOUNDS_NAME = "recording-controls-interactive-area"; export default function () { const [state, setState] = createSignal( @@ -80,6 +83,9 @@ export default function () { const [issuePanelVisible, setIssuePanelVisible] = createSignal(false); const [issueKey, setIssueKey] = createSignal(""); const [cameraWindowOpen, setCameraWindowOpen] = createSignal(false); + const [interactiveAreaRef, setInteractiveAreaRef] = + createSignal(null); + const interactiveBounds = createElementBounds(interactiveAreaRef); let settingsButtonRef: HTMLButtonElement | undefined; const hasDisconnectedInput = () => @@ -191,6 +197,30 @@ export default function () { void refreshCameraWindowState(); }); + createEffect(() => { + const element = interactiveAreaRef(); + if (!element) { + void commands.removeFakeWindow(FAKE_WINDOW_BOUNDS_NAME); + return; + } + + const left = interactiveBounds.left ?? 0; + const top = interactiveBounds.top ?? 0; + const width = interactiveBounds.width ?? 0; + const height = interactiveBounds.height ?? 0; + + if (width === 0 || height === 0) return; + + void commands.setFakeWindowBounds(FAKE_WINDOW_BOUNDS_NAME, { + position: { x: left, y: top }, + size: { width, height }, + }); + }); + + onCleanup(() => { + void commands.removeFakeWindow(FAKE_WINDOW_BOUNDS_NAME); + }); + createTimer( () => { void refreshCameraWindowState(); @@ -477,166 +507,168 @@ export default function () { }); return ( -
- -
- -
- {issueMessages().map((message) => ( -

{message}

- ))} +
+
+ +
+ +
+ {issueMessages().map((message) => ( +

{message}

+ ))} +
+
- -
- -
-
- - {(state) => ( -
- -
- )} -
-
-
- - -
+ +
+
+ + {(state) => (
- {optionsQuery.rawOptions.micName != null ? ( - disconnectedInputs.microphone ? ( - + +
+ )} +
+
+
+ + +
+
+ {optionsQuery.rawOptions.micName != null ? ( + disconnectedInputs.microphone ? ( + + ) : ( + <> + +
+
+
+ + ) ) : ( - <> - -
-
-
- - ) - ) : ( - + + )} +
+ + toggleIssuePanel()} + title={issueMessages().join(", ")} + aria-pressed={issuePanelVisible() ? "true" : "false"} + aria-label="Recording issues" + > + + + + + {(currentRecording.data?.mode === "studio" || + ostype() === "macos") && ( + togglePause.mutate()} + title={ + state().variant === "paused" + ? "Resume recording" + : "Pause recording" + } + aria-label={ + state().variant === "paused" + ? "Resume recording" + : "Pause recording" + } + > + {state().variant === "paused" ? ( + + ) : ( + + )} + )} -
- + toggleIssuePanel()} - title={issueMessages().join(", ")} - aria-pressed={issuePanelVisible() ? "true" : "false"} - aria-label="Recording issues" + disabled={restartRecording.isPending} + onClick={() => restartRecording.mutate()} + title="Restart recording" + aria-label="Restart recording" > - + - - - {(currentRecording.data?.mode === "studio" || - ostype() === "macos") && ( togglePause.mutate()} - title={ - state().variant === "paused" - ? "Resume recording" - : "Pause recording" - } - aria-label={ - state().variant === "paused" - ? "Resume recording" - : "Pause recording" - } + disabled={deleteRecording.isPending} + onClick={() => deleteRecording.mutate()} + title="Delete recording" + aria-label="Delete recording" > - {state().variant === "paused" ? ( - - ) : ( - - )} + - )} - - restartRecording.mutate()} - title="Restart recording" - aria-label="Restart recording" - > - - - deleteRecording.mutate()} - title="Delete recording" - aria-label="Delete recording" - > - - - { - settingsButtonRef = el ?? undefined; - }} - onClick={() => { - void openRecordingSettingsMenu(); - }} - title="Recording settings" - aria-label="Recording settings" - > - - + { + settingsButtonRef = el ?? undefined; + }} + onClick={() => { + void openRecordingSettingsMenu(); + }} + title="Recording settings" + aria-label="Recording settings" + > + + +
-
-
- +
+ +
From 5dc3f4411c50fe3b7f44b6ff5ae35ceeca510dc1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:47:08 +0000 Subject: [PATCH 3/9] Add camera stream example for testing --- crates/recording/examples/camera_stream.rs | 56 ++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 crates/recording/examples/camera_stream.rs diff --git a/crates/recording/examples/camera_stream.rs b/crates/recording/examples/camera_stream.rs new file mode 100644 index 0000000000..cfe2765711 --- /dev/null +++ b/crates/recording/examples/camera_stream.rs @@ -0,0 +1,56 @@ +use cap_recording::feeds::camera::{self, CameraFeed, DeviceOrModelID}; +use kameo::Actor as _; +use std::time::Duration; +use tokio::{task::JoinHandle, time::Instant}; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + let camera_info = cap_camera::list_cameras() + .next() + .expect("no cameras detected"); + + println!("Using camera: {}", camera_info.display_name()); + + let camera_feed = CameraFeed::spawn(CameraFeed::default()); + + camera_feed + .ask(camera::SetInput { + id: DeviceOrModelID::from_info(&camera_info), + }) + .await + .expect("failed to request camera") + .await + .expect("failed to initialize camera"); + + let lock = camera_feed.ask(camera::Lock).await.expect("lock failed"); + + let (tx, rx) = flume::bounded(8); + lock.ask(camera::AddSender(tx)) + .await + .expect("add sender failed"); + + let reader: JoinHandle<()> = tokio::spawn(async move { + let start = Instant::now(); + let mut frames = 0usize; + + while start.elapsed() < Duration::from_secs(5) { + match rx.recv_async().await { + Ok(_frame) => { + frames += 1; + } + Err(err) => { + eprintln!("Channel closed: {err}"); + break; + } + } + } + + println!("Captured {frames} frames in 5 seconds"); + }); + + reader.await.expect("reader crashed"); + + drop(lock); +} From 2f52488cdb1e400f4cadc879876844dba84d4690 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:47:23 +0000 Subject: [PATCH 4/9] Restore recording inputs from settings if missing --- apps/desktop/src-tauri/src/recording.rs | 41 +++++++++++++++++++++ apps/desktop/src-tauri/src/windows.rs | 48 +++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index e48f7229ed..be675c41ae 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -47,6 +47,7 @@ use crate::{ App, CurrentRecordingChanged, MutableState, NewStudioRecordingAdded, RecordingState, RecordingStopped, VideoUploadInfo, api::PresignedS3PutRequestMethod, + apply_camera_input, apply_mic_input, audio::AppSounds, auth::AuthStore, create_screenshot, @@ -55,6 +56,7 @@ use crate::{ }, open_external_link, presets::PresetsStore, + recording_settings::RecordingSettingsStore, thumbnails::*, upload::{ InstantMultipartUpload, build_video_meta, compress_image, create_or_get_video, upload_video, @@ -349,6 +351,43 @@ pub enum RecordingAction { UpgradeRequired, } +async fn restore_inputs_from_store_if_missing(app: &AppHandle, state: &MutableState<'_, App>) { + let guard = state.read().await; + let recording_active = !matches!(guard.recording_state, RecordingState::None); + let needs_mic = guard.selected_mic_label.is_none(); + let needs_camera = guard.selected_camera_id.is_none(); + drop(guard); + + if recording_active || (!needs_mic && !needs_camera) { + return; + } + + let settings = match RecordingSettingsStore::get(app) { + Ok(Some(settings)) => settings, + Ok(None) => return, + Err(err) => { + warn!(%err, "Failed to load recording settings while restoring inputs"); + return; + } + }; + + if needs_mic { + if let Some(mic) = settings.mic_name.clone() { + if let Err(err) = apply_mic_input(app.state(), Some(mic)).await { + warn!(%err, "Failed to restore microphone input"); + } + } + } + + if needs_camera { + if let Some(camera) = settings.camera_id.clone() { + if let Err(err) = apply_camera_input(app.clone(), app.state(), Some(camera)).await { + warn!(%err, "Failed to restore camera input"); + } + } + } +} + #[tauri::command] #[specta::specta] #[tracing::instrument(name = "recording", skip_all)] @@ -357,6 +396,8 @@ pub async fn start_recording( state_mtx: MutableState<'_, App>, inputs: StartRecordingInputs, ) -> Result { + restore_inputs_from_store_if_missing(&app, &state_mtx).await; + if !matches!(state_mtx.read().await.recording_state, RecordingState::None) { return Err("Recording already in progress".to_string()); } diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index d6c29095cd..bd1990ec09 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -21,10 +21,10 @@ use tokio::sync::RwLock; use tracing::{debug, error, instrument, warn}; use crate::{ - App, ArcLock, RequestScreenCapturePrewarm, fake_window, + App, ArcLock, RequestScreenCapturePrewarm, apply_camera_input, apply_mic_input, fake_window, general_settings::{self, AppTheme, GeneralSettingsStore}, permissions, - recording_settings::RecordingTargetMode, + recording_settings::{RecordingSettingsStore, RecordingTargetMode}, target_select_overlay::WindowFocusManager, window_exclusion::WindowExclusion, }; @@ -282,6 +282,8 @@ impl ShowCapWindow { crate::platform::set_window_level(window.as_ref().window(), 50); } + restore_recording_inputs_if_idle(app); + #[cfg(target_os = "macos")] { let app_handle = app.clone(); @@ -797,6 +799,48 @@ impl ShowCapWindow { } } +fn restore_recording_inputs_if_idle(app: &AppHandle) { + let settings = match RecordingSettingsStore::get(app) { + Ok(Some(settings)) => settings, + Ok(None) => return, + Err(err) => { + warn!(%err, "Failed to load recording settings while restoring inputs"); + return; + } + }; + + let mic_name = settings.mic_name.clone(); + let camera_id = settings.camera_id.clone(); + + if mic_name.is_none() && camera_id.is_none() { + return; + } + + let app_handle = app.clone(); + let state = app_handle.state::>(); + let app_state = state.inner().clone(); + + tauri::async_runtime::spawn(async move { + if app_state.read().await.is_recording_active_or_pending() { + return; + } + + if let Some(mic) = mic_name { + if let Err(err) = apply_mic_input(app_handle.state(), Some(mic)).await { + warn!(%err, "Failed to restore microphone input"); + } + } + + if let Some(camera) = camera_id { + if let Err(err) = + apply_camera_input(app_handle.clone(), app_handle.state(), Some(camera)).await + { + warn!(%err, "Failed to restore camera input"); + } + } + }); +} + #[cfg(target_os = "macos")] fn add_traffic_lights(window: &WebviewWindow, controls_inset: Option>) { use crate::platform::delegates; From 383d2a65b391e760a8d76e8a67b0e936a5998b82 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:47:32 +0000 Subject: [PATCH 5/9] Improve video muxing and camera error handling --- crates/recording/src/output_pipeline/core.rs | 65 ++++++++++++------- .../recording/src/output_pipeline/ffmpeg.rs | 41 ++++++++++-- crates/recording/src/sources/camera.rs | 35 +++++++++- 3 files changed, 110 insertions(+), 31 deletions(-) diff --git a/crates/recording/src/output_pipeline/core.rs b/crates/recording/src/output_pipeline/core.rs index b68eb5ad2b..c91af5e677 100644 --- a/crates/recording/src/output_pipeline/core.rs +++ b/crates/recording/src/output_pipeline/core.rs @@ -429,7 +429,7 @@ async fn setup_muxer( fn spawn_video_encoder, TVideo: VideoSource>( setup_ctx: &mut SetupCtx, mut video_source: TVideo, - mut video_rx: mpsc::Receiver, + video_rx: mpsc::Receiver, first_tx: oneshot::Sender, stop_token: CancellationToken, muxer: Arc>, @@ -450,34 +450,53 @@ fn spawn_video_encoder, TVideo: V } }); - setup_ctx.tasks().spawn("mux-video", async move { - use futures::StreamExt; - - let mut first_tx = Some(first_tx); + setup_ctx.tasks().spawn("mux-video", { + let stop_token_on_close = stop_token.clone(); + async move { + use futures::StreamExt; - stop_token - .run_until_cancelled(async { - while let Some(frame) = video_rx.next().await { - let timestamp = frame.timestamp(); + let mut first_tx = Some(first_tx); + let cancelled = stop_token.cancelled_owned(); + tokio::pin!(cancelled); + let mut video_rx = video_rx.fuse(); - if let Some(first_tx) = first_tx.take() { - let _ = first_tx.send(timestamp); + loop { + tokio::select! { + _ = &mut cancelled => { + break; + } + maybe_frame = video_rx.next() => { + match maybe_frame { + Some(frame) => { + let timestamp = frame.timestamp(); + + if let Some(first_tx) = first_tx.take() { + let _ = first_tx.send(timestamp); + } + + muxer + .lock() + .await + .send_video_frame(frame, timestamp.duration_since(timestamps)) + .map_err(|e| anyhow!("Error queueing video frame: {e}"))?; + } + None => { + warn!( + video_source = %std::any::type_name::(), + "Video mux channel closed before cancellation; cancelling pipeline" + ); + stop_token_on_close.cancel(); + break; + } + } } - - muxer - .lock() - .await - .send_video_frame(frame, timestamp.duration_since(timestamps)) - .map_err(|e| anyhow!("Error queueing video frame: {e}"))?; } + } - Ok::<(), anyhow::Error>(()) - }) - .await; - - muxer.lock().await.stop(); + muxer.lock().await.stop(); - Ok(()) + Ok(()) + } }); } diff --git a/crates/recording/src/output_pipeline/ffmpeg.rs b/crates/recording/src/output_pipeline/ffmpeg.rs index 8453479de1..a543365778 100644 --- a/crates/recording/src/output_pipeline/ffmpeg.rs +++ b/crates/recording/src/output_pipeline/ffmpeg.rs @@ -28,6 +28,8 @@ pub struct Mp4Muxer { output: ffmpeg::format::context::Output, video_encoder: Option, audio_encoder: Option, + video_frame_duration: Option, + last_video_ts: Option, } impl Muxer for Mp4Muxer { @@ -46,10 +48,16 @@ impl Muxer for Mp4Muxer { { let mut output = ffmpeg::format::output(&output_path)?; - let video_encoder = video_config - .map(|video_config| H264Encoder::builder(video_config).build(&mut output)) - .transpose() - .context("video encoder")?; + let (video_encoder, video_frame_duration) = match video_config { + Some(config) => { + let duration = Self::frame_duration(&config); + let encoder = H264Encoder::builder(config) + .build(&mut output) + .context("video encoder")?; + (Some(encoder), Some(duration)) + } + None => (None, None), + }; let audio_encoder = audio_config .map(|config| AACEncoder::init(config, &mut output)) @@ -62,6 +70,8 @@ impl Muxer for Mp4Muxer { output, video_encoder, audio_encoder, + video_frame_duration, + last_video_ts: None, }) } @@ -96,9 +106,19 @@ impl VideoMuxer for Mp4Muxer { fn send_video_frame( &mut self, frame: Self::VideoFrame, - timestamp: Duration, + mut timestamp: Duration, ) -> anyhow::Result<()> { if let Some(video_encoder) = self.video_encoder.as_mut() { + if let Some(frame_duration) = self.video_frame_duration { + if let Some(last_ts) = self.last_video_ts { + if timestamp <= last_ts { + timestamp = last_ts + frame_duration; + } + } + + self.last_video_ts = Some(timestamp); + } + video_encoder.queue_frame(frame.inner, timestamp, &mut self.output)?; } @@ -106,6 +126,17 @@ impl VideoMuxer for Mp4Muxer { } } +impl Mp4Muxer { + fn frame_duration(info: &VideoInfo) -> Duration { + let num = info.frame_rate.numerator().max(1); + let den = info.frame_rate.denominator().max(1); + + let nanos = ((den as u128 * 1_000_000_000u128) / num as u128).max(1); + + Duration::from_nanos(nanos as u64) + } +} + impl AudioMuxer for Mp4Muxer { fn send_audio_frame(&mut self, frame: AudioFrame, timestamp: Duration) -> anyhow::Result<()> { if let Some(audio_encoder) = self.audio_encoder.as_mut() { diff --git a/crates/recording/src/sources/camera.rs b/crates/recording/src/sources/camera.rs index 48daae8e37..f26a8c5f5c 100644 --- a/crates/recording/src/sources/camera.rs +++ b/crates/recording/src/sources/camera.rs @@ -7,6 +7,7 @@ use anyhow::anyhow; use cap_media_info::VideoInfo; use futures::{SinkExt, channel::mpsc}; use std::sync::Arc; +use tracing::{error, warn}; pub struct Camera(Arc); @@ -29,9 +30,37 @@ impl VideoSource for Camera { .await .map_err(|e| anyhow!("Failed to add camera sender: {e}"))?; - tokio::spawn(async move { - while let Ok(frame) = rx.recv_async().await { - let _ = video_tx.send(frame).await; + tokio::spawn({ + let feed_lock = feed_lock.clone(); + async move { + let mut receiver = rx; + + loop { + match receiver.recv_async().await { + Ok(frame) => { + if let Err(err) = video_tx.send(frame).await { + error!( + ?err, + "Camera pipeline receiver dropped; stopping camera forwarding" + ); + break; + } + } + Err(_) => { + let (tx, new_rx) = flume::bounded(8); + + if let Err(err) = feed_lock.ask(camera::AddSender(tx)).await { + warn!( + ?err, + "Camera sender disconnected and could not be reattached" + ); + break; + } + + receiver = new_rx; + } + } + } } }); From f86e0604b58de576a3910044240771241f4d2a87 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:13:49 +0000 Subject: [PATCH 6/9] Show camera preview window on instant recording --- apps/desktop/src-tauri/src/recording.rs | 16 ++++++++++++++++ apps/desktop/src/routes/camera.tsx | 9 ++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index be675c41ae..11ea9741aa 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -402,6 +402,22 @@ pub async fn start_recording( return Err("Recording already in progress".to_string()); } + let has_camera_selected = { + let guard = state_mtx.read().await; + guard.selected_camera_id.is_some() + }; + let camera_window_open = CapWindowId::Camera.get(&app).is_some(); + let should_open_camera_preview = + matches!(inputs.mode, RecordingMode::Instant) && has_camera_selected && !camera_window_open; + + if should_open_camera_preview { + ShowCapWindow::Camera + .show(&app) + .await + .map_err(|err| error!("Failed to show camera preview window: {err}")) + .ok(); + } + let id = uuid::Uuid::new_v4().to_string(); let general_settings = GeneralSettingsStore::get(&app).ok().flatten(); let general_settings = general_settings.as_ref(); diff --git a/apps/desktop/src/routes/camera.tsx b/apps/desktop/src/routes/camera.tsx index 47f09aa2d4..ba53382b19 100644 --- a/apps/desktop/src/routes/camera.tsx +++ b/apps/desktop/src/routes/camera.tsx @@ -22,7 +22,6 @@ import { import { createStore } from "solid-js/store"; import { generalSettingsStore } from "~/store"; import { createTauriEventListener } from "~/utils/createEventListener"; -import { createCameraMutation } from "~/utils/queries"; import { createImageDataWS, createLazySignal } from "~/utils/socket"; import { commands, events } from "~/utils/tauri"; import { @@ -88,8 +87,6 @@ function NativeCameraPreviewPage(props: { disconnected: Accessor }) { commands.awaitCameraPreviewReady(), ); - const setCamera = createCameraMutation(); - return (
}) {
- setCamera.mutate(null)}> + void getCurrentWindow().close()}> }) { let cameraCanvasRef: HTMLCanvasElement | undefined; - const setCamera = createCameraMutation(); - createEffect( on( () => rawOptions.cameraLabel, @@ -294,7 +289,7 @@ function LegacyCameraPreviewPage(props: { disconnected: Accessor }) {
- setCamera.mutate(null)}> + void getCurrentWindow().close()}> Date: Mon, 17 Nov 2025 16:23:06 +0000 Subject: [PATCH 7/9] Windows Instant Mode pausing --- apps/desktop/src/routes/in-progress-recording.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/routes/in-progress-recording.tsx b/apps/desktop/src/routes/in-progress-recording.tsx index 1e1eb44f2c..351cc6604d 100644 --- a/apps/desktop/src/routes/in-progress-recording.tsx +++ b/apps/desktop/src/routes/in-progress-recording.tsx @@ -87,6 +87,18 @@ export default function () { createSignal(null); const interactiveBounds = createElementBounds(interactiveAreaRef); let settingsButtonRef: HTMLButtonElement | undefined; + const recordingMode = createMemo( + () => currentRecording.data?.mode ?? optionsQuery.rawOptions.mode, + ); + const canPauseRecording = createMemo(() => { + const mode = recordingMode(); + const os = ostype(); + return ( + mode === "studio" || + os === "macos" || + (os === "windows" && mode === "instant") + ); + }); const hasDisconnectedInput = () => disconnectedInputs.microphone || disconnectedInputs.camera; @@ -608,8 +620,7 @@ export default function () { - {(currentRecording.data?.mode === "studio" || - ostype() === "macos") && ( + {canPauseRecording() && ( togglePause.mutate()} From d0d6b10c9c9d31b3aae3e082392830b6c6ff424d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:54:54 +0000 Subject: [PATCH 8/9] Refactor timestamp update logic in Mp4Muxer --- crates/recording/src/output_pipeline/ffmpeg.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/recording/src/output_pipeline/ffmpeg.rs b/crates/recording/src/output_pipeline/ffmpeg.rs index a543365778..37ec3b8269 100644 --- a/crates/recording/src/output_pipeline/ffmpeg.rs +++ b/crates/recording/src/output_pipeline/ffmpeg.rs @@ -110,10 +110,10 @@ impl VideoMuxer for Mp4Muxer { ) -> anyhow::Result<()> { if let Some(video_encoder) = self.video_encoder.as_mut() { if let Some(frame_duration) = self.video_frame_duration { - if let Some(last_ts) = self.last_video_ts { - if timestamp <= last_ts { - timestamp = last_ts + frame_duration; - } + if let Some(last_ts) = self.last_video_ts + && timestamp <= last_ts + { + timestamp = last_ts + frame_duration; } self.last_video_ts = Some(timestamp); From c644e6d4b817141dd6b999160e4f69efaae8b11d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:06:46 +0000 Subject: [PATCH 9/9] Refactor input restoration error handling --- apps/desktop/src-tauri/src/recording.rs | 18 ++++++++---------- apps/desktop/src-tauri/src/windows.rs | 12 ++++++------ 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 11ea9741aa..3a2ea626d8 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -371,19 +371,17 @@ async fn restore_inputs_from_store_if_missing(app: &AppHandle, state: &MutableSt } }; - if needs_mic { - if let Some(mic) = settings.mic_name.clone() { - if let Err(err) = apply_mic_input(app.state(), Some(mic)).await { - warn!(%err, "Failed to restore microphone input"); - } + if let Some(mic) = settings.mic_name.clone().filter(|_| needs_mic) { + match apply_mic_input(app.state(), Some(mic)).await { + Err(err) => warn!(%err, "Failed to restore microphone input"), + Ok(_) => {} } } - if needs_camera { - if let Some(camera) = settings.camera_id.clone() { - if let Err(err) = apply_camera_input(app.clone(), app.state(), Some(camera)).await { - warn!(%err, "Failed to restore camera input"); - } + if let Some(camera) = settings.camera_id.clone().filter(|_| needs_camera) { + match apply_camera_input(app.clone(), app.state(), Some(camera)).await { + Err(err) => warn!(%err, "Failed to restore camera input"), + Ok(_) => {} } } } diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index bd1990ec09..3148ad0b85 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -826,16 +826,16 @@ fn restore_recording_inputs_if_idle(app: &AppHandle) { } if let Some(mic) = mic_name { - if let Err(err) = apply_mic_input(app_handle.state(), Some(mic)).await { - warn!(%err, "Failed to restore microphone input"); + match apply_mic_input(app_handle.state(), Some(mic)).await { + Err(err) => warn!(%err, "Failed to restore microphone input"), + Ok(_) => {} } } if let Some(camera) = camera_id { - if let Err(err) = - apply_camera_input(app_handle.clone(), app_handle.state(), Some(camera)).await - { - warn!(%err, "Failed to restore camera input"); + match apply_camera_input(app_handle.clone(), app_handle.state(), Some(camera)).await { + Err(err) => warn!(%err, "Failed to restore camera input"), + Ok(_) => {} } } });