diff --git a/README.md b/README.md index e462e1a..47bbf68 100644 --- a/README.md +++ b/README.md @@ -30,15 +30,63 @@ flatpak install camera-x86_64.flatpak ### From Source -#### Dependencies +#### Build Dependencies - Rust (stable) -- GStreamer 1.0 with plugins (base, good, bad, ugly) -- libwayland -- libxkbcommon -- libinput -- libudev -- libseat +- GStreamer 1.0 development libraries +- libwayland-dev +- libxkbcommon-dev +- libinput-dev +- libudev-dev +- libseat-dev + +#### Runtime Dependencies + +| Dependency | Required | Purpose | +|------------|----------|---------| +| PipeWire | Yes | Camera access and audio | +| GStreamer 1.0 | Yes | Video processing pipeline | +| gst-plugins-base | Yes | Core GStreamer elements | +| gst-plugins-good | Yes | Video encoding (x264, vpx) | +| gst-plugins-bad | Yes | Additional encoders | +| gst-plugin-pipewire | Yes | PipeWire integration | +| GPU drivers | Recommended | Hardware-accelerated filters | +| v4l2loopback | Optional | Virtual camera for Discord, Chrome, etc. | + +##### Fedora/RHEL + +```bash +sudo dnf install pipewire gstreamer1 gstreamer1-plugins-base \ + gstreamer1-plugins-good gstreamer1-plugins-bad-free \ + pipewire-gstreamer mesa-dri-drivers + +# Optional: Virtual camera for Discord/Chrome +sudo dnf install v4l2loopback +sudo modprobe v4l2loopback devices=1 video_nr=10 card_label="Camera Virtual" exclusive_caps=1 +``` + +##### Ubuntu/Debian + +```bash +sudo apt install pipewire gstreamer1.0-tools gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good gstreamer1.0-plugins-bad \ + gstreamer1.0-pipewire mesa-utils + +# Optional: Virtual camera for Discord/Chrome +sudo apt install v4l2loopback-dkms v4l2loopback-utils +sudo modprobe v4l2loopback devices=1 video_nr=10 card_label="Camera Virtual" exclusive_caps=1 +``` + +##### Arch Linux + +```bash +sudo pacman -S pipewire gstreamer gst-plugins-base gst-plugins-good \ + gst-plugins-bad gst-plugin-pipewire mesa + +# Optional: Virtual camera for Discord/Chrome +sudo pacman -S v4l2loopback-dkms +sudo modprobe v4l2loopback devices=1 video_nr=10 card_label="Camera Virtual" exclusive_caps=1 +``` #### Build diff --git a/i18n/en/camera.ftl b/i18n/en/camera.ftl index 3339f47..36d5dfc 100644 --- a/i18n/en/camera.ftl +++ b/i18n/en/camera.ftl @@ -15,6 +15,10 @@ mode-virtual = VIRTUAL virtual-camera-title = Virtual camera (experimental) virtual-camera-description = Stream your camera feed to other applications via a virtual camera device. Requires PipeWire. virtual-camera-enable = Enable virtual camera +virtual-camera-output = Output device +virtual-camera-output-pipewire = PipeWire +virtual-camera-output-v4l2loopback = V4L2 Loopback +virtual-camera-output-unavailable = ({ $reason }) streaming-live = LIVE virtual-camera-open-file = Open file virtual-camera-file-filter-name = Images and Videos diff --git a/src/app/handlers/virtual_camera.rs b/src/app/handlers/virtual_camera.rs index c294340..3d93b72 100644 --- a/src/app/handlers/virtual_camera.rs +++ b/src/app/handlers/virtual_camera.rs @@ -8,6 +8,7 @@ use crate::app::state::{ AppModel, FileSource, FilterType, Message, VideoPlaybackCommand, VirtualCameraState, }; +use cosmic::cosmic_config::CosmicConfigEntry; use cosmic::Task; use std::sync::Arc; use tracing::{debug, error, info, warn}; @@ -54,10 +55,12 @@ impl AppModel { let height = format.height; let filter_type = self.selected_filter; + let output_type = self.config.virtual_camera_output; info!( width, height, ?filter_type, + ?output_type, "Starting virtual camera streaming from camera" ); @@ -78,7 +81,7 @@ impl AppModel { use crate::backends::virtual_camera::VirtualCameraManager; // Create and start the virtual camera on this dedicated thread - let mut manager = VirtualCameraManager::new(); + let mut manager = VirtualCameraManager::new(output_type); manager.set_filter(filter_type); let result = (|| { @@ -170,11 +173,13 @@ impl AppModel { self.stop_video_preview_playback(); let filter_type = self.selected_filter; + let output_type = self.config.virtual_camera_output; let is_video = matches!(file_source, FileSource::Video(_)); info!( ?file_source, ?filter_type, + ?output_type, "Starting virtual camera from file source" ); @@ -226,6 +231,7 @@ impl AppModel { FileSource::Image(path) => Self::stream_image_to_virtual_camera( &path, filter_type, + output_type, &mut filter_rx, stop_rx, preview_tx, @@ -233,6 +239,7 @@ impl AppModel { FileSource::Video(path) => Self::stream_video_to_virtual_camera( &path, filter_type, + output_type, &mut filter_rx, stop_rx, preview_tx, @@ -278,6 +285,7 @@ impl AppModel { fn stream_image_to_virtual_camera( path: &std::path::Path, initial_filter: FilterType, + output_type: crate::constants::VirtualCameraOutput, filter_rx: &mut tokio::sync::watch::Receiver, mut stop_rx: tokio::sync::oneshot::Receiver<()>, preview_tx: tokio::sync::mpsc::UnboundedSender< @@ -294,7 +302,7 @@ impl AppModel { let height = frame.height; // Create and start virtual camera manager - let mut manager = VirtualCameraManager::new(); + let mut manager = VirtualCameraManager::new(output_type); manager.set_filter(initial_filter); // File sources should not be mirrored - output exactly as the file content @@ -358,6 +366,7 @@ impl AppModel { fn stream_video_to_virtual_camera( path: &std::path::Path, initial_filter: FilterType, + output_type: crate::constants::VirtualCameraOutput, filter_rx: &mut tokio::sync::watch::Receiver, mut stop_rx: tokio::sync::oneshot::Receiver<()>, preview_tx: tokio::sync::mpsc::UnboundedSender< @@ -383,7 +392,7 @@ impl AppModel { let (width, height) = decoder.dimensions(); // Create and start virtual camera manager - let mut manager = VirtualCameraManager::new(); + let mut manager = VirtualCameraManager::new(output_type); manager.set_filter(initial_filter); // File sources should not be mirrored - output exactly as the file content @@ -558,6 +567,46 @@ impl AppModel { ) } + pub(crate) fn handle_select_virtual_camera_output( + &mut self, + index: usize, + ) -> Task> { + use crate::constants::VirtualCameraOutput; + + let Some(output_type) = VirtualCameraOutput::ALL.get(index).copied() else { + warn!(index, "Invalid virtual camera output index"); + return Task::none(); + }; + + // Check if the selected output is available + if !output_type.is_available() { + warn!( + ?output_type, + reason = ?output_type.unavailable_reason(), + "Selected virtual camera output is not available" + ); + // Don't change the selection if it's not available + return Task::none(); + } + + // Don't change if already selected + if self.config.virtual_camera_output == output_type { + return Task::none(); + } + + info!(?output_type, "Virtual camera output changed"); + self.config.virtual_camera_output = output_type; + + // Save the config change + if let Some(handler) = self.config_handler.as_ref() { + if let Err(err) = self.config.write_entry(handler) { + error!(?err, "Failed to save virtual camera output setting"); + } + } + + Task::none() + } + pub(crate) fn handle_virtual_camera_stopped( &mut self, result: Result<(), String>, diff --git a/src/app/mod.rs b/src/app/mod.rs index f9a5ee5..49606c5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -295,7 +295,18 @@ impl cosmic::Application for AppModel { .map(|p| p.display_name().to_string()) .collect(), theme_dropdown_options: vec![fl!("match-desktop"), fl!("dark"), fl!("light")], + virtual_camera_output_dropdown_options: crate::constants::VirtualCameraOutput::ALL + .iter() + .map(|o| { + if o.is_available() { + o.display_name().to_string() + } else { + format!("{} (unavailable)", o.display_name()) + } + }) + .collect(), device_info_visible: false, + bitrate_info_visible: false, transition_state: crate::app::state::TransitionState::default(), // QR detection enabled by default qr_detection_enabled: true, diff --git a/src/app/settings/view.rs b/src/app/settings/view.rs index 59067f9..1b473a3 100644 --- a/src/app/settings/view.rs +++ b/src/app/settings/view.rs @@ -126,13 +126,29 @@ impl AppModel { ); // Virtual camera section - let virtual_camera_section = widget::settings::section().add( - widget::settings::item::builder(fl!("virtual-camera-title")) - .description(fl!("virtual-camera-description")) - .toggler(self.config.virtual_camera_enabled, |_| { - Message::ToggleVirtualCameraEnabled - }), - ); + // Get current output index for dropdown + let current_output_index = crate::constants::VirtualCameraOutput::ALL + .iter() + .position(|o| *o == self.config.virtual_camera_output) + .unwrap_or(0); + + let virtual_camera_section = widget::settings::section() + .add( + widget::settings::item::builder(fl!("virtual-camera-title")) + .description(fl!("virtual-camera-description")) + .toggler(self.config.virtual_camera_enabled, |_| { + Message::ToggleVirtualCameraEnabled + }), + ) + .add( + widget::settings::item::builder(fl!("virtual-camera-output")).control( + widget::dropdown( + &self.virtual_camera_output_dropdown_options, + Some(current_output_index), + Message::SelectVirtualCameraOutput, + ), + ), + ); // Bug reports section let bug_report_button = widget::button::standard(fl!("settings-report-bug")) diff --git a/src/app/state.rs b/src/app/state.rs index f0b27af..ba0b92e 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -402,8 +402,12 @@ pub struct AppModel { pub bitrate_preset_dropdown_options: Vec, /// Theme dropdown options (Match Desktop, Dark, Light) pub theme_dropdown_options: Vec, + /// Virtual camera output dropdown options (with availability indicators) + pub virtual_camera_output_dropdown_options: Vec, /// Whether the device info panel is visible pub device_info_visible: bool, + /// Whether the bitrate info matrix is visible + pub bitrate_info_visible: bool, /// Transition state for camera/settings changes pub transition_state: TransitionState, @@ -908,6 +912,8 @@ pub enum Message { SelectVideoEncoder(usize), /// Toggle virtual camera feature enabled ToggleVirtualCameraEnabled, + /// Select virtual camera output device type + SelectVirtualCameraOutput(usize), // ===== System & Recovery ===== /// Camera backend recovery started diff --git a/src/app/update.rs b/src/app/update.rs index cd2c541..ae5c108 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -109,6 +109,9 @@ impl AppModel { Message::ClearTransitionBlur => self.handle_clear_transition_blur(), Message::ToggleMirrorPreview => self.handle_toggle_mirror_preview(), Message::ToggleVirtualCameraEnabled => self.handle_toggle_virtual_camera_enabled(), + Message::SelectVirtualCameraOutput(index) => { + self.handle_select_virtual_camera_output(index) + } // ===== Format Selection ===== Message::SetMode(mode) => self.handle_set_mode(mode), diff --git a/src/backends/virtual_camera/mod.rs b/src/backends/virtual_camera/mod.rs index 64d3b01..ec31c0b 100644 --- a/src/backends/virtual_camera/mod.rs +++ b/src/backends/virtual_camera/mod.rs @@ -40,6 +40,7 @@ pub use pipeline::VirtualCameraPipeline; use crate::app::FilterType; use crate::backends::camera::types::{BackendError, BackendResult, CameraFrame}; +use crate::constants::VirtualCameraOutput; use std::sync::Arc; use tokio::sync::Mutex; use tracing::{debug, error, info, warn}; @@ -66,11 +67,13 @@ pub struct VirtualCameraManager { gpu_available: bool, /// Whether to horizontally flip output (for file sources, to counteract app auto-mirroring) flip_horizontal: bool, + /// Output type (PipeWire or V4L2Loopback) + output_type: VirtualCameraOutput, } impl VirtualCameraManager { - /// Create a new virtual camera manager - pub fn new() -> Self { + /// Create a new virtual camera manager with the specified output type + pub fn new(output_type: VirtualCameraOutput) -> Self { Self { pipeline: None, current_filter: FilterType::Standard, @@ -78,6 +81,7 @@ impl VirtualCameraManager { output_size: (1280, 720), gpu_available: false, flip_horizontal: false, + output_type, } } @@ -92,7 +96,10 @@ impl VirtualCameraManager { /// Start streaming to virtual camera /// - /// Creates a PipeWire virtual camera node that will be visible to other applications. + /// Creates a virtual camera output that will be visible to other applications: + /// - PipeWire: Creates a PipeWire virtual camera node + /// - V4L2Loopback: Writes to a /dev/video* loopback device + /// /// Uses GPU-accelerated filtering with software rendering fallback. pub fn start(&mut self, width: u32, height: u32) -> BackendResult<()> { if self.streaming { @@ -101,10 +108,10 @@ impl VirtualCameraManager { )); } - info!(width, height, "Starting virtual camera"); + info!(width, height, ?self.output_type, "Starting virtual camera"); - // Create and start the pipeline - let pipeline = VirtualCameraPipeline::new(width, height)?; + // Create and start the pipeline with the configured output type + let pipeline = VirtualCameraPipeline::new(width, height, self.output_type)?; pipeline.start()?; self.pipeline = Some(pipeline); @@ -310,7 +317,7 @@ impl VirtualCameraManager { impl Default for VirtualCameraManager { fn default() -> Self { - Self::new() + Self::new(VirtualCameraOutput::default()) } } diff --git a/src/backends/virtual_camera/pipeline.rs b/src/backends/virtual_camera/pipeline.rs index 54ced76..1ccd7fa 100644 --- a/src/backends/virtual_camera/pipeline.rs +++ b/src/backends/virtual_camera/pipeline.rs @@ -1,13 +1,14 @@ // SPDX-License-Identifier: GPL-3.0-only -//! GStreamer pipeline for virtual camera output via PipeWire +//! GStreamer pipeline for virtual camera output //! //! Creates a pipeline that: //! 1. Receives RGBA frames from the app (via appsrc) -//! 2. Converts to a format supported by PipeWire (via videoconvert) -//! 3. Outputs to a PipeWire virtual camera node +//! 2. Converts to a format supported by the sink (via videoconvert) +//! 3. Outputs to either a PipeWire virtual camera node or V4L2 loopback device use crate::backends::camera::types::{BackendError, BackendResult}; +use crate::constants::VirtualCameraOutput; use gstreamer::prelude::*; use gstreamer_app::AppSrc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -17,24 +18,30 @@ static FRAME_COUNTER: AtomicU64 = AtomicU64::new(0); /// Virtual camera GStreamer pipeline /// -/// Uses pipewiresink to create a virtual camera device that other -/// applications can use as a video source. Accepts RGBA frames from the app -/// and uses videoconvert for proper format negotiation with PipeWire. +/// Supports two output modes: +/// - PipeWire: Uses pipewiresink to create a virtual camera for PipeWire-aware apps +/// - V4L2Loopback: Uses v4l2sink to write to a v4l2loopback device for wider compatibility +/// +/// Accepts RGBA frames from the app and uses videoconvert for proper format negotiation. pub struct VirtualCameraPipeline { pipeline: gstreamer::Pipeline, appsrc: AppSrc, width: u32, height: u32, + #[allow(dead_code)] // Stored for potential future use (e.g., logging, diagnostics) + output_type: VirtualCameraOutput, } impl VirtualCameraPipeline { /// Create a new virtual camera pipeline /// - /// The pipeline accepts RGBA frames and outputs them to a PipeWire - /// virtual camera node named "Camera (Virtual)". - /// Uses videoconvert for proper format negotiation with PipeWire. - pub fn new(width: u32, height: u32) -> BackendResult { - info!(width, height, "Creating virtual camera pipeline"); + /// The pipeline accepts RGBA frames and outputs them to either: + /// - PipeWire: A virtual camera node named "Camera (Virtual)" + /// - V4L2Loopback: A /dev/video* loopback device + /// + /// Uses videoconvert for proper format negotiation with the sink. + pub fn new(width: u32, height: u32, output_type: VirtualCameraOutput) -> BackendResult { + info!(width, height, ?output_type, "Creating virtual camera pipeline"); // Initialize GStreamer if needed gstreamer::init().map_err(|e| { @@ -52,7 +59,7 @@ impl VirtualCameraPipeline { BackendError::InitializationFailed(format!("Failed to create appsrc: {}", e)) })?; - // videoconvert: handles format negotiation with pipewiresink + // videoconvert: handles format negotiation with sink let videoconvert = gstreamer::ElementFactory::make("videoconvert") .name("virtual_camera_convert") .build() @@ -60,13 +67,8 @@ impl VirtualCameraPipeline { BackendError::InitializationFailed(format!("Failed to create videoconvert: {}", e)) })?; - // pipewiresink: output to PipeWire as a virtual camera - let pipewiresink = gstreamer::ElementFactory::make("pipewiresink") - .name("virtual_camera_sink") - .build() - .map_err(|e| { - BackendError::InitializationFailed(format!("Failed to create pipewiresink: {}", e)) - })?; + // Create sink based on output type + let sink = Self::create_sink(output_type)?; // Configure appsrc let appsrc = appsrc.downcast::().map_err(|_| { @@ -90,43 +92,34 @@ impl VirtualCameraPipeline { // Set stream type to stream for live data appsrc.set_property_from_str("stream-type", "stream"); - // Configure pipewiresink for virtual camera mode - // "provide" mode creates a video source that other applications can use - pipewiresink.set_property_from_str("mode", "provide"); - - // Create stream properties as a GstStructure - // media.role = "Camera" is required for xdg-desktop-portal to recognize this as a camera - let stream_props = gstreamer::Structure::builder("props") - .field("media.class", "Video/Source") - .field("media.role", "Camera") - .field("node.name", "camera-virtual") - .field("node.description", "Camera (Virtual)") - .build(); - pipewiresink.set_property("stream-properties", &stream_props); - // Add elements to pipeline pipeline - .add_many([appsrc.upcast_ref(), &videoconvert, &pipewiresink]) + .add_many([appsrc.upcast_ref(), &videoconvert, &sink]) .map_err(|e| { BackendError::InitializationFailed(format!("Failed to add elements: {}", e)) })?; - // Link elements: appsrc -> videoconvert -> pipewiresink + // Link elements: appsrc -> videoconvert -> sink appsrc.link(&videoconvert).map_err(|e| { BackendError::InitializationFailed(format!( "Failed to link appsrc to videoconvert: {}", e )) })?; - videoconvert.link(&pipewiresink).map_err(|e| { + videoconvert.link(&sink).map_err(|e| { BackendError::InitializationFailed(format!( - "Failed to link videoconvert to pipewiresink: {}", + "Failed to link videoconvert to sink: {}", e )) })?; + let sink_name = match output_type { + VirtualCameraOutput::PipeWire => "pipewiresink", + VirtualCameraOutput::V4L2Loopback => "v4l2sink", + }; info!( - "Virtual camera pipeline created successfully (appsrc -> videoconvert -> pipewiresink)" + "Virtual camera pipeline created successfully (appsrc -> videoconvert -> {})", + sink_name ); Ok(Self { @@ -134,9 +127,72 @@ impl VirtualCameraPipeline { appsrc, width, height, + output_type, }) } + /// Create the appropriate sink element based on output type + fn create_sink(output_type: VirtualCameraOutput) -> BackendResult { + match output_type { + VirtualCameraOutput::PipeWire => Self::create_pipewire_sink(), + VirtualCameraOutput::V4L2Loopback => Self::create_v4l2_sink(), + } + } + + /// Create a PipeWire sink for virtual camera output + fn create_pipewire_sink() -> BackendResult { + let pipewiresink = gstreamer::ElementFactory::make("pipewiresink") + .name("virtual_camera_sink") + .build() + .map_err(|e| { + BackendError::InitializationFailed(format!("Failed to create pipewiresink: {}", e)) + })?; + + // Configure pipewiresink for virtual camera mode + // "provide" mode creates a video source that other applications can use + pipewiresink.set_property_from_str("mode", "provide"); + + // Create stream properties as a GstStructure + // media.role = "Camera" is required for xdg-desktop-portal to recognize this as a camera + let stream_props = gstreamer::Structure::builder("props") + .field("media.class", "Video/Source") + .field("media.role", "Camera") + .field("node.name", "camera-virtual") + .field("node.description", "Camera (Virtual)") + .build(); + pipewiresink.set_property("stream-properties", &stream_props); + + Ok(pipewiresink) + } + + /// Create a V4L2 sink for v4l2loopback device output + fn create_v4l2_sink() -> BackendResult { + // Find the v4l2loopback device + let device_path = VirtualCameraOutput::v4l2loopback_device().ok_or_else(|| { + BackendError::InitializationFailed( + "No v4l2loopback device found. Please load the v4l2loopback kernel module.".into(), + ) + })?; + + info!(device = %device_path, "Using v4l2loopback device"); + + let v4l2sink = gstreamer::ElementFactory::make("v4l2sink") + .name("virtual_camera_sink") + .build() + .map_err(|e| { + BackendError::InitializationFailed(format!("Failed to create v4l2sink: {}", e)) + })?; + + // Set the device path + v4l2sink.set_property("device", &device_path); + + // Enable async mode for better performance + v4l2sink.set_property("async", false); + v4l2sink.set_property("sync", false); + + Ok(v4l2sink) + } + /// Start the pipeline pub fn start(&self) -> BackendResult<()> { debug!("Starting virtual camera pipeline"); diff --git a/src/config.rs b/src/config.rs index 0e2de42..cca6c77 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-only -use crate::constants::BitratePreset; +use crate::constants::{BitratePreset, VirtualCameraOutput}; use cosmic::cosmic_config::{self, CosmicConfigEntry, cosmic_config_derive::CosmicConfigEntry}; use cosmic::{Theme, theme}; use serde::{Deserialize, Serialize}; @@ -76,6 +76,8 @@ pub struct Config { pub bitrate_preset: BitratePreset, /// Virtual camera feature enabled (disabled by default) pub virtual_camera_enabled: bool, + /// Virtual camera output device type (PipeWire or V4L2Loopback) + pub virtual_camera_output: VirtualCameraOutput, } impl Default for Config { @@ -93,6 +95,7 @@ impl Default for Config { mirror_preview: true, // Default to mirrored (selfie mode) bitrate_preset: BitratePreset::default(), // Default to Medium virtual_camera_enabled: false, // Disabled by default + virtual_camera_output: VirtualCameraOutput::default(), // Default to PipeWire } } } diff --git a/src/constants.rs b/src/constants.rs index 797ab64..cb69a48 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -325,6 +325,144 @@ pub mod virtual_camera { pub const DURATION_QUERY_TIMEOUT_SECS: u64 = 5; } +/// Virtual camera output device type +/// +/// Determines which sink to use for virtual camera output: +/// - PipeWire: Modern Linux multimedia framework (default) +/// - V4L2Loopback: Traditional V4L2 loopback device (better app compatibility) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum VirtualCameraOutput { + /// PipeWire virtual camera (pipewiresink) + /// Works with PipeWire-aware applications + #[default] + PipeWire, + /// V4L2 loopback device (v4l2sink) + /// Works with applications that expect /dev/video* devices (e.g., Discord, Chrome) + V4L2Loopback, +} + +impl VirtualCameraOutput { + /// Get all output variants for UI iteration + pub const ALL: [VirtualCameraOutput; 2] = [ + VirtualCameraOutput::PipeWire, + VirtualCameraOutput::V4L2Loopback, + ]; + + /// Get display name for the output type + pub fn display_name(&self) -> &'static str { + match self { + VirtualCameraOutput::PipeWire => "PipeWire", + VirtualCameraOutput::V4L2Loopback => "V4L2 Loopback", + } + } + + /// Check if this output type is available on the system + pub fn is_available(&self) -> bool { + match self { + VirtualCameraOutput::PipeWire => is_pipewire_available(), + VirtualCameraOutput::V4L2Loopback => is_v4l2loopback_available(), + } + } + + /// Get a description of why this output might not be available + pub fn unavailable_reason(&self) -> Option<&'static str> { + if self.is_available() { + return None; + } + match self { + VirtualCameraOutput::PipeWire => Some("PipeWire not running or pipewiresink plugin not found"), + VirtualCameraOutput::V4L2Loopback => Some("v4l2loopback module not loaded"), + } + } + + /// Get the v4l2loopback device path if available + pub fn v4l2loopback_device() -> Option { + find_v4l2loopback_device() + } +} + +/// Check if PipeWire is available (daemon running and GStreamer plugin available) +fn is_pipewire_available() -> bool { + // Check if pipewiresink element is available in GStreamer + if gstreamer::init().is_err() { + return false; + } + gstreamer::ElementFactory::find("pipewiresink").is_some() +} + +/// Check if v4l2loopback is available (module loaded and device exists) +fn is_v4l2loopback_available() -> bool { + find_v4l2loopback_device().is_some() +} + +/// Find a v4l2loopback device +/// +/// Scans /dev/video* devices and checks if any are v4l2loopback devices +/// by checking the driver name via sysfs. Handles Flatpak sandboxing gracefully. +fn find_v4l2loopback_device() -> Option { + use std::fs; + use std::path::Path; + + // Check if v4l2loopback module is loaded (skip if /proc/modules is not accessible) + // In Flatpak, this path might be sandboxed + let modules_path = Path::new("/proc/modules"); + let module_check_passed = if modules_path.exists() { + match fs::read_to_string(modules_path) { + Ok(content) => content.contains("v4l2loopback"), + Err(_) => true, // Can't read, assume it might be available + } + } else { + true // No /proc/modules (might be Flatpak), continue checking devices + }; + + if !module_check_passed { + return None; + } + + // Scan for video devices + let dev_path = Path::new("/dev"); + if !dev_path.exists() { + return None; + } + + // Collect and sort video devices to get consistent results + let mut video_devices: Vec<_> = fs::read_dir(dev_path) + .ok()? + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().starts_with("video")) + .collect(); + video_devices.sort_by_key(|e| e.file_name()); + + // Check each video device + for entry in video_devices { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + let device_path = entry.path(); + + // Try to read the device name from sysfs + // v4l2loopback devices have "Dummy video device" or custom names + let Some(device_num) = name_str.strip_prefix("video") else { + continue; + }; + let sysfs_name = format!("/sys/class/video4linux/video{}/name", device_num); + + if let Ok(device_name) = fs::read_to_string(&sysfs_name) { + let device_name = device_name.trim(); + // v4l2loopback default names or check for "loopback" in name + // Also check for "OBS" which is commonly used for OBS Virtual Camera + if device_name.contains("Dummy video device") + || device_name.to_lowercase().contains("loopback") + || device_name.to_lowercase().contains("virtual") + || device_name.contains("OBS") + { + return Some(device_path.to_string_lossy().to_string()); + } + } + } + + None +} + /// Application information utilities pub mod app_info { use std::path::Path;