use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Mutex as SyncMutex};
use tauri::command;
use tokio::sync::RwLock;
#[cfg(feature = "audio")]
use crate::constants::{AUDIO_BITRATE, AUDIO_CHANNELS, AUDIO_DEVICE_DEFAULT, AUDIO_SAMPLE_RATE};
use crate::constants::{
DEFAULT_CAMERA_ID, RECORDING_QUALITY_PRESET_1080P, RECORDING_QUALITY_PRESET_4K,
RECORDING_QUALITY_PRESET_720P, RECORDING_QUALITY_PRESET_HIGH, RECORDING_QUALITY_PRESET_LOW,
RECORDING_QUALITY_PRESET_MEDIUM, RECORDING_SESSION_PREFIX,
};
use crate::platform::PlatformCamera;
use crate::recording::{Recorder, RecordingConfig, RecordingQuality, RecordingStats};
use crate::types::CameraFormat;
type RecorderRegistry = LazyLock<Arc<RwLock<HashMap<String, Arc<SyncMutex<RecordingSession>>>>>>;
static RECORDER_REGISTRY: RecorderRegistry =
LazyLock::new(|| Arc::new(RwLock::new(HashMap::new())));
struct RecordingSession {
recorder: Option<Recorder>,
camera: Arc<SyncMutex<PlatformCamera>>,
is_running: bool,
}
pub struct RecordingStartOptions {
pub device_id: Option<String>,
pub output_path: String,
pub width: u32,
pub height: u32,
pub fps: f64,
pub quality: Option<String>,
pub title: Option<String>,
#[cfg(feature = "audio")]
pub audio_device_id: Option<String>,
}
#[command]
pub async fn start_recording(options: RecordingStartOptions) -> Result<String, String> {
let RecordingStartOptions {
device_id,
output_path,
width,
height,
fps,
quality,
title,
#[cfg(feature = "audio")]
audio_device_id,
} = options;
let camera_id = device_id.unwrap_or_else(|| DEFAULT_CAMERA_ID.to_string());
#[cfg(feature = "audio")]
{
if let Some(ref audio_id) = audio_device_id {
log::info!(
"Starting recording from camera {camera_id} with audio {audio_id} to {output_path}"
);
} else {
log::info!("Starting recording from camera {camera_id} (no audio) to {output_path}");
}
}
#[cfg(not(feature = "audio"))]
log::info!(
"Starting recording from camera {} to {}",
camera_id,
output_path
);
let recording_quality = match quality.as_deref() {
Some(q) if q == RECORDING_QUALITY_PRESET_LOW || q == RECORDING_QUALITY_PRESET_720P => {
Some(RecordingQuality::Low)
}
Some(q) if q == RECORDING_QUALITY_PRESET_MEDIUM || q == RECORDING_QUALITY_PRESET_1080P => {
Some(RecordingQuality::Medium)
}
Some(q) if q == RECORDING_QUALITY_PRESET_HIGH || q == RECORDING_QUALITY_PRESET_4K => {
Some(RecordingQuality::High)
}
_ => None,
};
let mut config = if let Some(q) = recording_quality {
RecordingConfig::from_quality_with_fps(q, fps)
} else {
RecordingConfig::new(width, height, fps)
};
if let Some(t) = title {
config = config.with_title(t);
}
#[cfg(feature = "audio")]
if let Some(audio_id) = audio_device_id {
config = config.with_audio(crate::recording::AudioConfig {
device_id: if audio_id == AUDIO_DEVICE_DEFAULT {
None
} else {
Some(audio_id)
},
sample_rate: AUDIO_SAMPLE_RATE,
channels: AUDIO_CHANNELS,
bitrate: AUDIO_BITRATE,
});
}
#[allow(clippy::cast_possible_truncation)]
let fps_f32 = fps as f32;
let camera = super::capture::get_or_create_camera(
camera_id.clone(),
CameraFormat::new(config.width, config.height, fps_f32),
)
.await
.map_err(|e| format!("Failed to initialize camera: {e}"))?;
{
let mut cam = camera
.lock()
.map_err(|_| "Camera mutex poisoned".to_string())?;
cam.start_stream()
.map_err(|e| format!("Failed to start camera stream: {e}"))?;
}
let recorder = Recorder::new(&output_path, config)
.map_err(|e| format!("Failed to create recorder: {e}"))?;
let session_id = format!(
"{}{}",
RECORDING_SESSION_PREFIX,
chrono::Utc::now().timestamp_millis()
);
let session = RecordingSession {
recorder: Some(recorder),
camera,
is_running: true,
};
{
let mut registry = RECORDER_REGISTRY.write().await;
registry.insert(session_id.clone(), Arc::new(SyncMutex::new(session)));
}
log::info!("Recording started: session {session_id}");
Ok(session_id)
}
#[command]
pub async fn record_frame(session_id: String) -> Result<u64, String> {
let session_arc = {
let registry = RECORDER_REGISTRY.read().await;
registry
.get(&session_id)
.cloned()
.ok_or_else(|| format!("Recording session not found: {session_id}"))?
};
let mut session = session_arc
.lock()
.map_err(|_| "Mutex poisoned".to_string())?;
if !session.is_running {
return Err("Recording is not running".to_string());
}
let frame = {
let mut camera = session
.camera
.lock()
.map_err(|_| "Mutex poisoned".to_string())?;
camera
.capture_frame()
.map_err(|e| format!("Failed to capture frame: {e}"))?
};
let recorder = session
.recorder
.as_mut()
.ok_or_else(|| "Recorder not available".to_string())?;
recorder
.write_frame(&frame)
.map_err(|e| format!("Failed to write frame: {e}"))?;
Ok(recorder.frame_count())
}
#[command]
pub async fn stop_recording(session_id: String) -> Result<RecordingStats, String> {
let session_arc = {
let mut registry = RECORDER_REGISTRY.write().await;
registry
.remove(&session_id)
.ok_or_else(|| format!("Recording session not found: {session_id}"))?
};
let mut session = session_arc
.lock()
.map_err(|_| "Mutex poisoned".to_string())?;
{
let mut camera = session
.camera
.lock()
.map_err(|_| "Camera mutex poisoned".to_string())?;
let _ = camera.stop_stream();
}
let stats = session
.recorder
.take()
.ok_or_else(|| "Recorder already taken".to_string())?
.finish()
.map_err(|e| format!("Failed to finalize recording: {e}"))?;
log::info!(
"Recording stopped: {} frames, {:.2}s, {} bytes",
stats.video_frames,
stats.duration_secs,
stats.bytes_written
);
Ok(stats)
}
#[command]
pub async fn get_recording_status(session_id: String) -> Result<RecordingStatus, String> {
let session_arc = {
let registry = RECORDER_REGISTRY.read().await;
registry
.get(&session_id)
.cloned()
.ok_or_else(|| format!("Recording session not found: {session_id}"))?
};
let session = session_arc
.lock()
.map_err(|_| "Mutex poisoned".to_string())?;
let recorder = session
.recorder
.as_ref()
.ok_or_else(|| "Recorder not available".to_string())?;
#[cfg(feature = "audio")]
let audio_status = if recorder.audio_enabled() {
Some(AudioStatus {
enabled: true,
failed: recorder.audio_failed(),
})
} else {
None
};
Ok(RecordingStatus {
session_id,
is_running: session.is_running,
frame_count: recorder.frame_count(),
dropped_frames: recorder.dropped_frames(),
duration_secs: recorder.duration(),
#[cfg(feature = "audio")]
audio_status,
})
}
#[command]
pub async fn list_recording_sessions() -> Result<Vec<String>, String> {
let registry = RECORDER_REGISTRY.read().await;
Ok(registry.keys().cloned().collect())
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordingStatus {
pub session_id: String,
pub is_running: bool,
pub frame_count: u64,
pub dropped_frames: u64,
pub duration_secs: f64,
#[cfg(feature = "audio")]
pub audio_status: Option<AudioStatus>,
}
#[cfg(feature = "audio")]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AudioStatus {
pub enabled: bool,
pub failed: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_recording_status_serialization() {
let status = RecordingStatus {
session_id: "test_123".to_string(),
is_running: true,
frame_count: 100,
dropped_frames: 2,
duration_secs: 3.33,
#[cfg(feature = "audio")]
audio_status: Some(AudioStatus {
enabled: true,
failed: false,
}),
};
let json = serde_json::to_string(&status).expect("serialize recording status");
assert!(json.contains("test_123"));
assert!(json.contains("100"));
#[cfg(feature = "audio")]
{
assert!(json.contains("audioStatus"));
}
}
#[tokio::test]
async fn test_write_frame_to_missing_session_returns_error() {
let result = record_frame("nonexistent_session_xyz".to_string()).await;
assert!(result.is_err());
let msg = result.expect_err("missing session error expected");
assert!(
msg.contains("nonexistent_session_xyz"),
"error should identify the missing session, got: {msg}"
);
}
#[tokio::test]
async fn test_get_recording_status_missing_session_returns_error() {
let result = get_recording_status("no_such_session_abc".to_string()).await;
assert!(result.is_err());
let msg = result.expect_err("missing session error expected");
assert!(
msg.contains("no_such_session_abc"),
"error should identify the missing session, got: {msg}"
);
}
#[tokio::test]
async fn test_stop_recording_missing_session_returns_error() {
let result = stop_recording("ghost_session_999".to_string()).await;
assert!(result.is_err());
let msg = result.expect_err("missing session error expected");
assert!(
msg.contains("ghost_session_999"),
"error should identify the missing session, got: {msg}"
);
}
}