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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
48 changes: 42 additions & 6 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,20 @@ impl App {
}
}

fn microphone_settings_for_label(
&self,
label: &str,
) -> Option<microphone::MicrophoneDeviceSettings> {
recording_settings::RecordingSettingsStore::microphone_settings_for(&self.handle, label)
}

fn camera_settings_for_id(
&self,
id: &DeviceOrModelID,
) -> Option<feeds::camera::CameraDeviceSettings> {
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");

Expand All @@ -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) {
Expand Down Expand Up @@ -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())?;

Expand All @@ -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())?;

Expand All @@ -569,7 +589,7 @@ impl App {
async fn set_mic_input(state: MutableState<'_, App>, label: Option<String>) -> 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(());
Expand All @@ -583,7 +603,12 @@ async fn set_mic_input(state: MutableState<'_, App>, label: Option<String>) -> 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();
Expand All @@ -609,9 +634,15 @@ async fn set_mic_input(state: MutableState<'_, App>, label: Option<String>) -> 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())?
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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());

Expand Down
78 changes: 70 additions & 8 deletions apps/desktop/src-tauri/src/recording.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -401,6 +403,14 @@ pub struct MicrophoneInfo {
pub name: String,
pub sample_rate: u32,
pub channels: u16,
pub formats: Vec<MicrophoneFormatInfo>,
}

#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct MicrophoneFormatInfo {
pub sample_rate: u32,
pub channels: u16,
}

#[tauri::command(async)]
Expand All @@ -416,11 +426,50 @@ pub fn get_microphone_info(name: String) -> Option<MicrophoneInfo> {
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<MicrophoneFormatInfo> {
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]
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
39 changes: 38 additions & 1 deletion apps/desktop/src-tauri/src/recording_settings.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -24,6 +30,8 @@ pub struct RecordingSettingsStore {
pub mode: Option<RecordingMode>,
pub system_audio: bool,
pub organization_id: Option<String>,
pub camera_device_settings: HashMap<String, CameraDeviceSettings>,
pub microphone_device_settings: HashMap<String, MicrophoneDeviceSettings>,
}

impl RecordingSettingsStore {
Expand All @@ -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<Wry>,
id: &DeviceOrModelID,
) -> Option<CameraDeviceSettings> {
Self::get(app).ok().flatten().and_then(|settings| {
settings
.camera_device_settings
.get(&camera_key(id))
.copied()
})
}

pub fn microphone_settings_for(
app: &AppHandle<Wry>,
label: &str,
) -> Option<MicrophoneDeviceSettings> {
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]
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src-tauri/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -180,6 +184,8 @@ async fn restore_main_window_inputs(app: &AppHandle) {

if let Some(camera_id) = camera_to_restore {
let state = app.state::<ArcLock<App>>();
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;
Expand Down Expand Up @@ -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());
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Suspense fallback={null}>{props.children}</Suspense>;
Expand Down
17 changes: 3 additions & 14 deletions apps/desktop/src/routes/(window-chrome).tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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) => {
Expand Down
Loading
Loading