diff --git a/PenguinWave/RunConfigurations/DevAppRun.run.xml b/PenguinWave/RunConfigurations/DevAppRun.run.xml index 149f085..063abaa 100644 --- a/PenguinWave/RunConfigurations/DevAppRun.run.xml +++ b/PenguinWave/RunConfigurations/DevAppRun.run.xml @@ -1,6 +1,6 @@ - + diff --git a/PenguinWave/package-lock.json b/PenguinWave/package-lock.json index 73b317a..bad8944 100644 --- a/PenguinWave/package-lock.json +++ b/PenguinWave/package-lock.json @@ -8,6 +8,7 @@ "name": "penguinwave", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-shell": "^2.2.1", @@ -309,6 +310,45 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz", @@ -1863,6 +1903,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", diff --git a/PenguinWave/package.json b/PenguinWave/package.json index 62abef7..1fe3600 100644 --- a/PenguinWave/package.json +++ b/PenguinWave/package.json @@ -11,6 +11,7 @@ "tauri:build": "ARCH=x86_64 NO_STRIP=1 npx tauri build" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-shell": "^2.2.1", diff --git a/PenguinWave/src-tauri/src/lib.rs b/PenguinWave/src-tauri/src/lib.rs index c5637a9..903ba77 100644 --- a/PenguinWave/src-tauri/src/lib.rs +++ b/PenguinWave/src-tauri/src/lib.rs @@ -4,7 +4,7 @@ mod headsets; mod system; mod utils; use crate::event::chatmix_listener::{init_chatmix_monitor, ChatMixMonitoringState}; -use crate::system::pipewire::{init_virtual_channels, PipeWireManager}; +use crate::system::pipewire::{init_virtual_channels, PipeWireManager, PipeWireNode, ApplicationStream}; use crate::utils::hardware_utils::*; use crate::utils::state::AppStateManager; use serde::{Deserialize, Serialize}; @@ -37,8 +37,7 @@ fn greet(name: &str) -> String { for (i, device_list) in devices.iter().enumerate() { let battery = device_list.device.request_battery(); let chatmix = device_list.device.request_chatmix(); - //let volume = device_list.device.set_microphone_volume(255).unwrap(); - //let response = device_list.device.set_sidetone(0).unwrap(); + device_info.push_str(&format!( "Device #{}: VID: 0x{:04X}, PID: 0x{:04X} #{:?} #{} Chatmix #{}\n", i + 1, @@ -73,6 +72,13 @@ struct LinkRequest { target_port: String, } +#[derive(Debug, Serialize, Deserialize)] +struct MoveStreamRequest { + application_id: u32, + sink_id: u32, +} + + // Struct for HID device lookup #[derive(Debug, Serialize, Deserialize)] struct HidDeviceRequest { @@ -114,6 +120,24 @@ fn link_ports(request: LinkRequest) -> Result<(), String> { .map_err(|e| e.to_string()) } +#[tauri::command] +fn get_custom_virtual_sinks() -> Result, String> { + PipeWireManager::get_custom_virtual_sinks() + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn get_application_streams() -> Result, String> { + PipeWireManager::list_application_streams() + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn move_application_to_sink(request: MoveStreamRequest) -> Result<(), String> { + PipeWireManager::move_application_to_sink(request.application_id, request.sink_id) + .map_err(|e| e.to_string()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -145,6 +169,9 @@ pub fn run() { list_nodes, get_output_devices, link_ports, + get_custom_virtual_sinks, + get_application_streams, + move_application_to_sink ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/PenguinWave/src-tauri/src/system/pipewire.rs b/PenguinWave/src-tauri/src/system/pipewire.rs index cd3c476..bb131b4 100644 --- a/PenguinWave/src-tauri/src/system/pipewire.rs +++ b/PenguinWave/src-tauri/src/system/pipewire.rs @@ -10,6 +10,7 @@ pub struct PipeWireNode { pub name: String, pub description: String, pub node_type: String, + pub module_id: Option } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -27,17 +28,41 @@ pub struct AudioPort { pub direction: String, // "input" or "output" } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ApplicationStream { + pub id: u32, + pub name: String, + pub icon: String, + pub assigned_sink_id: u32 +} + pub struct PipeWireManager; impl PipeWireManager { + /// list applications that play sounds (playback streams) + pub fn list_application_streams() -> Result>{ + let output = Command::new("pactl") + .args(&["list", "sink-inputs"]) + .output()?; + + if !output.status.success() { + return Err(anyhow!("Failed to list sink inputs: {}", + String::from_utf8_lossy(&output.stderr))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + Self::parse_application_stream_details_from_output(&stdout) + } + pub fn list_sinks() -> Result> { let output = Command::new("pactl") .args(&["list", "sinks", "short"]) .output()?; if !output.status.success() { - return Err(anyhow!("Failed to list sinks: {}", + return Err(anyhow!("Failed to list sinks: {}", String::from_utf8_lossy(&output.stderr))); } @@ -56,7 +81,72 @@ impl PipeWireManager { Ok(sinks) } - + + /// List custom created sinks, along with their module_id + pub fn get_custom_virtual_sinks() -> Result> { + // First, get the custom sink IDs using the short format with filtering + let short_output = Command::new("sh") + .args(&["-c", "pactl list sinks short | grep -v -E \"(auto_null|alsa_output|bluez_output|alsa_card)\" | awk '{print $1}'"]) + .output()?; + + if !short_output.status.success() { + return Err(anyhow!("Failed to get custom sink IDs: {}", + String::from_utf8_lossy(&short_output.stderr))); + } + + let sink_ids_str = String::from_utf8_lossy(&short_output.stdout); + let sink_ids: Vec<&str> = sink_ids_str.trim().lines().collect(); + + if sink_ids.is_empty() { + return Ok(Vec::new()); + } + + let mut nodes: Vec = Vec::new(); + + for sink_id in sink_ids { + let sink_id = sink_id.trim(); + if sink_id.is_empty() { + continue; + } + + // Get detailed info for this specific sink + let detail_cmd = format!("pactl list sinks | awk \"/^Sink #{}$/,/^$/\"", sink_id); + let detail_output = Command::new("sh") + .args(&["-c", &detail_cmd]) + .output()?; + + if !detail_output.status.success() { + continue; // Skip this sink if we can't get details + } + + let detail_stdout = String::from_utf8_lossy(&detail_output.stdout); + + if let Some(node) = Self::parse_sink_details_from_output(&detail_stdout, sink_id){ + nodes.push(node); + } + } + + Ok(nodes) + } + + /// Move playback stream to a sink + pub fn move_application_to_sink(application_id: u32, sink_id: u32) -> Result<()>{ + let output = Command::new("pactl") + .args(&[ + "move-sink-input", + &format!("{}", application_id), + &format!("{}", sink_id) + ]) + .output()?; + + if !output.status.success() { + return Err(anyhow!("Failed to move application to virtual sink: {}", + String::from_utf8_lossy(&output.stderr))); + } + + Ok(()) + } + /// Create a new virtual sink pub fn create_virtual_sink(sink_name: &str, description: &str) -> Result { let output = Command::new("pactl") @@ -126,7 +216,6 @@ impl PipeWireManager { //pw-link virtual_sink:monitor_FL alsa_output.usb-SteelSeries_Arctis_Nova_7-00.analog-stereo:playback_FL } - /// Parse the output of pw-cli ls Node to extract node information /// Parse the output of pw-cli ls Node to extract node information fn extract_quoted_value(line: &str, prefix: &str) -> Option { @@ -159,6 +248,7 @@ impl PipeWireManager { name: String::from(name), description: current_desc.clone().unwrap_or_default(), node_type: current_type.clone().unwrap_or_default(), + module_id: None, }); } @@ -197,15 +287,13 @@ impl PipeWireManager { name, description: current_desc.unwrap_or_default(), node_type: current_type.unwrap_or_default(), + module_id: None, }); } Ok(nodes) } - /// Helper function to extract quoted values - - /// Get audio devices suitable for output pub fn get_output_devices() -> Result> { let nodes = Self::list_nodes()?; @@ -313,6 +401,93 @@ impl PipeWireManager { Ok(()) } + + fn parse_sink_details_from_output(detail_output: &str, sink_id: &str) -> Option{ + // Parse the detailed output + let mut name = String::new(); + let mut description = String::new(); + let mut node_type = String::new(); + let mut module_id: Option = None; + + for line in detail_output.lines() { + let line = line.trim(); + if line.starts_with("Name: ") { + name = line.strip_prefix("Name: ").unwrap_or("").to_string(); + } else if line.starts_with("Description: ") { + description = line.strip_prefix("Description: ").unwrap_or("").to_string(); + } else if line.contains("media.class = ") { + if let Some(value) = Self::extract_quoted_value(line, "media.class = ") { + node_type = value; + } + } else if line.starts_with("Owner Module: ") { + module_id = Some(line.strip_prefix("Owner Module: ").unwrap_or("").parse::().unwrap_or(0)); + } + } + + if module_id.is_some() && !name.is_empty() { + let node = PipeWireNode { + id: sink_id.parse::().unwrap_or(0), + name, + description, + node_type, + module_id + }; + + return Some(node); + } + None + } + + fn parse_application_stream_details_from_output(details_output: &str) -> Result>{ + let mut application_streams = Vec::new(); + let mut id: u32 = 0; + let mut name = String::new(); + let mut icon = String::new(); + let mut assigned_sink_id : u32 = 0; + + let detailed_inputs = format!("{}\n", details_output); + + for line in detailed_inputs.lines() { + let line = line.trim(); + if line.starts_with("Sink: ") { + assigned_sink_id = line + .strip_prefix("Sink: ") + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + } + else if line.contains("object.serial = ") { + if let Some(value) = Self::extract_quoted_value(line, "object.serial = ") { + id = value.parse::().unwrap_or(0); + } + } else if line.contains("application.name = ") { + if let Some(value) = Self::extract_quoted_value(line, "application.name = ") { + name = value; + } + } else if line.contains("application.icon_name = ") { + if let Some(value) = Self::extract_quoted_value(line, "application.icon_name = ") { + icon = value; + } + } + + // after every empty line, a new object starts, so we need to add the parsed one to the list + + if line.is_empty() { + if !name.is_empty() && id != 0 { + let application_stream = ApplicationStream { + id, + name: name.clone(), + icon: icon.clone(), + assigned_sink_id + }; + + application_streams.push(application_stream); + } + } + } + + Ok(application_streams) + } + pub fn get_default_sink() -> Result { let output = Command::new("pactl") .args(&["get-default-sink"]) diff --git a/PenguinWave/src/App.css b/PenguinWave/src/App.css index 85f7a4a..c88dd7c 100644 --- a/PenguinWave/src/App.css +++ b/PenguinWave/src/App.css @@ -28,6 +28,7 @@ flex-direction: column; justify-content: center; text-align: center; + gap: 2rem; } .logo { @@ -86,6 +87,11 @@ button:active { background-color: #e8e8e8; } +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + input, button { outline: none; @@ -95,6 +101,85 @@ button { margin-right: 5px; } +/* Card styles */ +.card { + background: #ffffff; + border-radius: 12px; + padding: 1.5rem; + margin: 1rem 0; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + text-align: left; +} + +.card h2 { + margin-top: 0; + color: #333; + border-bottom: 2px solid #f0f0f0; + padding-bottom: 0.5rem; +} + +.card label { + display: block; + margin: 1rem 0 0.5rem 0; + font-weight: 500; + color: #555; +} + +.card input, +.card select { + width: 100%; + margin-bottom: 1rem; +} + +.card button { + margin: 0.5rem 0.5rem 0.5rem 0; +} + +/* Error styles */ +.error { + color: #d32f2f; + background-color: #ffebee; + border: 1px solid #ffcdd2; + border-radius: 4px; + padding: 0.75rem; + margin: 1rem 0; + font-weight: 500; +} + +/* Device list styles */ +.device-list { + list-style: none; + padding: 0; + margin: 1rem 0; +} + +.device-list li { + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 8px; + padding: 1rem; + margin: 0.5rem 0; + font-size: 0.9rem; +} + +.selected-device { + background: #e3f2fd; + border: 1px solid #bbdefb; + border-radius: 8px; + padding: 1rem; + margin: 1rem 0; +} + +.selected-device h3 { + margin-top: 0; + color: #1976d2; +} + +.selected-device p { + margin: 0.5rem 0; + font-family: monospace; +} + @media (prefers-color-scheme: dark) { :root { color: #f6f6f6; @@ -113,4 +198,205 @@ button { button:active { background-color: #0f0f0f69; } + + .card { + background: #3a3a3a; + color: #f6f6f6; + } + + .card h2 { + color: #f6f6f6; + border-bottom-color: #555; + } + + .card label { + color: #ccc; + } + + .error { + background-color: #3e2723; + border-color: #5d4037; + color: #ffcdd2; + } + + .device-list li { + background: #424242; + border-color: #616161; + color: #f6f6f6; + } + + .selected-device { + background: #1a237e; + border-color: #3949ab; + color: #f6f6f6; + } + + .selected-device h3 { + color: #90caf9; + } +} + +.app-layout { + display: flex; + height: 100vh; +} + +.sidebar { + width: 200px; + background: #23272f; + display: flex; + flex-direction: column; + align-items: stretch; + padding: 2rem 0 2rem 0; + box-shadow: 2px 0 8px rgba(0,0,0,0.07); + gap: 1rem; +} + +.sidebar button { + background: none; + border: none; + color: #fff; + font-size: 1.1rem; + padding: 1rem 2rem; + text-align: left; + cursor: pointer; + transition: background 0.2s; +} + +.sidebar button.active, +.sidebar button:hover { + background: #394150; + color: #61dafb; +} + +.main-content { + flex: 1; + padding: 2rem 2rem 2rem 2rem; + overflow-y: auto; + background: #f6f6f6; +} + +@media (prefers-color-scheme: dark) { + .main-content { + background: #23272f; + } + .sidebar { + background: #181c22; + } + .sidebar button.active, + .sidebar button:hover { + background: #23272f; + color: #61dafb; + } +} + +.streams-manager { + width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.streams-columns { + display: flex; + gap: 2rem; + margin: 2rem 0; + align-items: flex-start; +} + +.streams-column { + background: #fff; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(0,0,0,0.07); + min-width: 220px; + min-height: 300px; + padding: 1rem; + flex: 1 1 220px; + display: flex; + flex-direction: column; +} + +.streams-column-header { + font-weight: bold; + margin-bottom: 1rem; + color: #1976d2; + text-align: center; +} + +.streams-dropzone { + flex: 1; + min-height: 200px; + background: #f4f7fa; + border-radius: 8px; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.stream-draggable { + background: #e3f2fd; + border: 1px solid #90caf9; + border-radius: 6px; + padding: 0.75rem 1rem; + cursor: grab; + font-size: 1rem; + transition: box-shadow 0.2s; + box-shadow: 0 1px 2px rgba(25, 118, 210, 0.08); +} + +.stream-draggable:active { + box-shadow: 0 2px 8px #1976d233; +} + +@media (prefers-color-scheme: dark) { + .streams-column { + background: #23272f; + color: #f6f6f6; + } + .streams-dropzone { + background: #181c22; + } + .streams-column-header { + color: #61dafb; + } + .stream-draggable { + background: #394150; + border-color: #61dafb; + color: #fff; + } +} + +.sidebar-toggle-btn { + background: none; + border: none; + color: #fff; + font-size: 1.5rem; + padding: 0.5rem 1rem; + cursor: pointer; + margin-bottom: 1rem; + align-self: flex-end; + transition: background 0.2s; +} + +.sidebar-toggle-btn:hover { + background: #394150; +} + +.sidebar-toggle-collapsed { + width: 48px; + background: #23272f; + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 2rem 0 2rem 0.5rem; + box-shadow: 2px 0 8px rgba(0,0,0,0.07); +} + +.app-layout.sidebar-hidden .sidebar { + display: none; +} + +.app-layout.sidebar-hidden .main-content { + padding-left: 0 !important; } diff --git a/PenguinWave/src/App.tsx b/PenguinWave/src/App.tsx index 83bf08d..2f9dfb6 100644 --- a/PenguinWave/src/App.tsx +++ b/PenguinWave/src/App.tsx @@ -1,409 +1,38 @@ -import { useState, useEffect } from "react"; import "./App.css"; -import {invoke} from "@tauri-apps/api/core"; - -// Types from our Rust backend -interface PipeWireNode { - id: number; - name: string; - description: string; - node_type: string; -} - -interface AudioPort { - name: string; - description: string; - direction: string; -} - -interface AudioDevice { - id: number; - name: string; - description: string; - ports: AudioPort[]; -} - -interface HidDeviceInfo { - vendor_id: number; - product_id: number; - serial_number: string | null; - manufacturer_string: string | null; - product_string: string | null; - path: string; -} +import { + GreetingSection, + VirtualSinkManager, + PortLinker, + HidDeviceManager, + Sidebar, + PlaybackStreamsManager, +} from "./components"; +import { useState } from "react"; function App() { - // State for virtual sink - const [sinkName, setSinkName] = useState(""); - const [sinkDescription, setSinkDescription] = useState(""); - const [createdSinks, setCreatedSinks] = useState<{ id: number; name: string }[]>([]); - - // State for audio devices and nodes - const [nodes, setNodes] = useState([]); - const [outputDevices, setOutputDevices] = useState([]); - - // State for linking ports - const [sourcePort, setSourcePort] = useState(""); - const [targetPort, setTargetPort] = useState(""); - - // State for HID devices - const [hidDevices, setHidDevices] = useState([]); - const [selectedDevice, setSelectedDevice] = useState(null); - - // For filter and search - const [vendorId, setVendorId] = useState(""); - const [productId, setProductId] = useState(""); - - // Load data on component mount - useEffect(() => { - refreshAll(); - }, []); - - // Refresh all data - const refreshAll = async () => { - try { - await refreshNodes(); - await refreshOutputDevices(); - } catch (error) { - console.error("Error refreshing data:", error); - } - }; - - // Refresh audio nodes - const refreshNodes = async () => { - try { - console.log("hereee") - const nodesData = await invoke("list_nodes"); - setNodes(nodesData); - console.log("aici", nodes) - } catch (error) { - console.error("Error listing nodes:", error); - } - }; - - // Refresh output devices - const refreshOutputDevices = async () => { - try { - const devicesData = await invoke("get_output_devices"); - setOutputDevices(devicesData); - console.log("audio", outputDevices) - } catch (error) { - console.error("Error getting output devices:", error); - } - }; - - // Refresh HID devices - const refreshHidDevices = async () => { - try { - await invoke("refresh_hid_devices"); - const devices = await invoke("list_hid_devices"); - setHidDevices(devices); - } catch (error) { - console.error("Error refreshing HID devices:", error); - } - }; - - // Create a virtual sink - const createSink = async () => { - if (!sinkName) { - alert("Please enter a sink name"); - return; - } - - try { - const moduleId = await invoke("create_virtual_sink", { - request: { - name: sinkName, - description: sinkDescription || sinkName, - }, - }); - - setCreatedSinks([...createdSinks, { id: moduleId, name: sinkName }]); - setSinkName(""); - setSinkDescription(""); - - // Refresh nodes after creating a sink - await refreshNodes(); - await refreshOutputDevices(); - } catch (error) { - console.error("Error creating virtual sink:", error); - alert(`Error creating sink: ${error}`); - } - }; - - // Delete a virtual sink - const deleteSink = async (moduleId: number, name: string) => { - try { - await invoke("delete_virtual_sink", { moduleId }); - setCreatedSinks(createdSinks.filter((sink) => sink.id !== moduleId)); - - // Refresh nodes after deleting a sink - await refreshNodes(); - await refreshOutputDevices(); - } catch (error) { - console.error(`Error deleting sink ${name}:`, error); - alert(`Error deleting sink ${name}: ${error}`); - } - }; - - // Link two audio ports - const linkPorts = async () => { - if (!sourcePort || !targetPort) { - alert("Please select both source and target ports"); - return; - } - - try { - await invoke("link_ports", { - request: { - source_port: sourcePort, - target_port: targetPort, - }, - }); - - alert("Ports linked successfully!"); - } catch (error) { - console.error("Error linking ports:", error); - alert(`Error linking ports: ${error}`); - } - }; - - // Find a specific HID device - const findHidDevice = async () => { - if (!vendorId || !productId) { - alert("Please enter both Vendor ID and Product ID"); - return; - } - - try { - const vId = parseInt(vendorId, 16); - const pId = parseInt(productId, 16); - - const device = await invoke("find_hid_device", { - request: { - vendor_id: vId, - product_id: pId, - }, - }); - - if (device) { - setSelectedDevice(device); - } else { - alert("Device not found"); - setSelectedDevice(null); - } - } catch (error) { - console.error("Error finding HID device:", error); - alert(`Error finding device: ${error}`); - } - }; - - const [greetMsg, setGreetMsg] = useState(""); - const [name, setName] = useState(""); - - async function greet() { - // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ - setGreetMsg(await invoke("greet", { name })); - } - + const [page, setPage] = useState(0); + const [sidebarHidden, setSidebarHidden] = useState(false); return ( -
-

Welcome to Tauri + React

- - -

Click on the Tauri, Vite, and React logos to learn more.

- -
{ - e.preventDefault(); - greet(); - }} - > - setName(e.currentTarget.value)} - placeholder="Enter a name..." - /> - -
-

{greetMsg}

- -

PipeWire Virtual Sink Manager

- - {/* Refresh Controls */} -
- -
- - {/* Create Virtual Sink */} -
-

Create Virtual Sink

-
- -
-
- -
- -
- - {/* Created Sinks */} -
-

Created Sinks

- {createdSinks.length === 0 ? ( -

No sinks created yet

- ) : ( -
    - {createdSinks.map((sink) => ( -
  • - {sink.name} (ID: {sink.id}) - -
  • - ))} -
- )} -
- - {/* Link Ports */} -
-

Link Audio Ports

-
- -
-
- -
- -
- - {/* HID Devices */} -
-

HID Devices

- - -
- -
-
- -
- - - {selectedDevice && ( -
-

Selected Device:

-

Vendor ID: 0x{selectedDevice.vendor_id.toString(16)}

-

Product ID: 0x{selectedDevice.product_id.toString(16)}

-

Manufacturer: {selectedDevice.manufacturer_string || "N/A"}

-

Product: {selectedDevice.product_string || "N/A"}

-

Serial: {selectedDevice.serial_number || "N/A"}

-
- )} - -

All HID Devices

- {hidDevices.length === 0 ? ( -

No HID devices found

+
+
diff --git a/PenguinWave/src/components/GreetingSection.tsx b/PenguinWave/src/components/GreetingSection.tsx new file mode 100644 index 0000000..b75afa9 --- /dev/null +++ b/PenguinWave/src/components/GreetingSection.tsx @@ -0,0 +1,43 @@ +import { useState } from "react"; +import { GreetingService } from "../services/greetingService"; + +export const GreetingSection = () => { + const [greetMsg, setGreetMsg] = useState(""); + const [name, setName] = useState(""); + + const handleGreet = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const message = await GreetingService.greet(name); + setGreetMsg(message); + } catch (error) { + console.error("Error greeting:", error); + } + }; + + return ( +
+

Welcome to Tauri + React

+ + +

Click on the Tauri, Vite, and React logos to learn more.

+ +
+ setName(e.currentTarget.value)} + placeholder="Enter a name..." + /> + +
+

{greetMsg}

+
+ ); +}; \ No newline at end of file diff --git a/PenguinWave/src/components/HidDeviceManager.tsx b/PenguinWave/src/components/HidDeviceManager.tsx new file mode 100644 index 0000000..cbd3751 --- /dev/null +++ b/PenguinWave/src/components/HidDeviceManager.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { useHid } from "../hooks/useHid"; + +export const HidDeviceManager = () => { + const [vendorId, setVendorId] = useState(""); + const [productId, setProductId] = useState(""); + const { + hidDevices, + selectedDevice, + loading, + error, + refreshHidDevices, + findHidDevice, + } = useHid(); + + const handleFindDevice = async () => { + await findHidDevice(vendorId, productId); + }; + + return ( +
+

HID Devices

+ + {error &&

{error}

} + +
+ +
+
+ +
+ + + {selectedDevice && ( +
+

Selected Device:

+

Vendor ID: 0x{selectedDevice.vendor_id.toString(16)}

+

Product ID: 0x{selectedDevice.product_id.toString(16)}

+

Manufacturer: {selectedDevice.manufacturer_string || "N/A"}

+

Product: {selectedDevice.product_string || "N/A"}

+

Serial: {selectedDevice.serial_number || "N/A"}

+
+ )} + +

All HID Devices

+ {hidDevices.length === 0 ? ( +

No HID devices found

+ ) : ( +
    + {hidDevices.map((device, index) => ( +
  • + {device.product_string || "Unknown Device"} ( + {device.manufacturer_string || "Unknown Manufacturer"}) +
    + VID: 0x{device.vendor_id.toString(16)}, PID: 0x + {device.product_id.toString(16)} +
  • + ))} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/PenguinWave/src/components/PlaybackStreamsManager.tsx b/PenguinWave/src/components/PlaybackStreamsManager.tsx new file mode 100644 index 0000000..fc7ec09 --- /dev/null +++ b/PenguinWave/src/components/PlaybackStreamsManager.tsx @@ -0,0 +1,150 @@ +import { useEffect, useState } from "react"; +import { useAudio } from "../hooks/useAudio"; +import { AudioService } from "../services/audioService"; +import { + DndContext, + useDraggable, + useDroppable, + DragEndEvent, + DragOverlay, +} from "@dnd-kit/core"; +import { ApplicationStream } from "../models/audio"; + +export const PlaybackStreamsManager = () => { + const {createdSinks, moveApplicationToSink} = useAudio(); + const [applicationStreams, setApplicationStreams] = useState([]); + const [loading, setLoading] = useState(false); + const [activeId, setActiveId] = useState(null); + + useEffect(() => { + fetchApplicationStreams(); + }, [createdSinks.length]); + + const fetchApplicationStreams = async () => { + setLoading(true); + try { + const applicationStreams = await AudioService.getApplicationStreams(); + console.log(applicationStreams); + setApplicationStreams(applicationStreams); + } finally { + setLoading(false); + } + }; + + // Group applicationStreams by assigned_sink_id + const groupedApplicationStreams: Record = {}; + for (const input of applicationStreams) { + const groupId = (input as any).assigned_sink_id ?? 0; + if (!groupedApplicationStreams[groupId]) groupedApplicationStreams[groupId] = []; + groupedApplicationStreams[groupId].push(input); + } + + // Unassigned inputs (not in any created sink) + const unassignedApplicationStreams = applicationStreams.filter( + (input) => !createdSinks.some((sink) => sink.id === (input as any).assigned_sink_id) + ); + + // DnD handlers + const handleDragStart = (event: any) => { + setActiveId(Number(event.active.id)); + }; + + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + if (!over) return setActiveId(null); + const inputId = Number(active.id); + const sinkId = Number(over.id); + const input = applicationStreams.find((i) => i.id === inputId); + if (!input) return setActiveId(null); + if ((input as any).assigned_sink_id === sinkId) return setActiveId(null); + setLoading(true); + try { + await moveApplicationToSink(inputId, sinkId); + await fetchApplicationStreams(); + } finally { + setTimeout(() => setActiveId(null), 50); // Delay to prevent snap-back + setLoading(false); + } + }; + + return ( +
+

Playback Streams Manager

+ +
+ {createdSinks.map((sink) => ( + + ))} + {/* Unassigned inputs column */} + +
+ + {activeId != null && ( + i.id === activeId)!} + disabled={true} + /> + )} + +
+ +
+ ); +}; + +function SinkColumn({ id, label, inputs, loading, activeId }: { id: number; label: string; inputs: ApplicationStream[]; loading: boolean; activeId: number | null }) { + const { setNodeRef } = useDroppable({ id }); + return ( +
+
{label}
+
+ {inputs.filter(input => input.id !== activeId).map((input) => ( + + ))} +
+
+ ); +} + +function StreamDraggable({ input, disabled }: { input: ApplicationStream; disabled: boolean }) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: input.id, + disabled, + }); + return ( +
+ {input.icon && ( + {input.icon} + )} + {input.name || `App ${input.id}`} +
+ ); +} \ No newline at end of file diff --git a/PenguinWave/src/components/PortLinker.tsx b/PenguinWave/src/components/PortLinker.tsx new file mode 100644 index 0000000..a078f79 --- /dev/null +++ b/PenguinWave/src/components/PortLinker.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import { useAudio } from "../hooks/useAudio"; + +export const PortLinker = () => { + const [sourcePort, setSourcePort] = useState(""); + const [targetPort, setTargetPort] = useState(""); + const { nodes, outputDevices, loading, error, linkPorts } = useAudio(); + + const handleLinkPorts = async () => { + if (!sourcePort || !targetPort) { + alert("Please select both source and target ports"); + return; + } + + try { + await linkPorts(sourcePort, targetPort); + alert("Ports linked successfully!"); + } catch (error) { + alert(`Error linking ports: ${error}`); + } + }; + + return ( +
+

Link Audio Ports

+ {error &&

{error}

} + +
+ +
+
+ +
+ +
+ ); +}; \ No newline at end of file diff --git a/PenguinWave/src/components/Sidebar.tsx b/PenguinWave/src/components/Sidebar.tsx new file mode 100644 index 0000000..e7c0d1f --- /dev/null +++ b/PenguinWave/src/components/Sidebar.tsx @@ -0,0 +1,43 @@ +interface SidebarProps { + onSelect: (page: number) => void; + selected: number; + hidden: boolean; + onToggle: () => void; +} + +export const Sidebar = ({ onSelect, selected, hidden, onToggle }: SidebarProps) => { + if (hidden) { + return ( +
+ +
+ ); + } + return ( +
+ + + + +
+ ); +}; \ No newline at end of file diff --git a/PenguinWave/src/components/VirtualSinkManager.tsx b/PenguinWave/src/components/VirtualSinkManager.tsx new file mode 100644 index 0000000..ae89af2 --- /dev/null +++ b/PenguinWave/src/components/VirtualSinkManager.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { useAudio } from "../hooks/useAudio"; + +export const VirtualSinkManager = () => { + const [sinkName, setSinkName] = useState(""); + const [sinkDescription, setSinkDescription] = useState(""); + const { + createdSinks, + loading, + error, + refreshAll, + createSink, + deleteSink, + } = useAudio(); + + const handleCreateSink = async () => { + if (!sinkName) { + alert("Please enter a sink name"); + return; + } + + try { + await createSink(sinkName, sinkDescription); + setSinkName(""); + setSinkDescription(""); + } catch (error) { + alert(`Error creating sink: ${error}`); + } + }; + + const handleDeleteSink = async (moduleId: number, name: string) => { + try { + await deleteSink(moduleId, name); + } catch (error) { + alert(`Error deleting sink ${name}: ${error}`); + } + }; + + return ( +
+

PipeWire Virtual Sink Manager

+ + {/* Refresh Controls */} +
+ + {error &&

{error}

} +
+ + {/* Create Virtual Sink */} +
+

Create Virtual Sink

+
+ +
+
+ +
+ +
+ + {/* Created Sinks */} +
+

Created Sinks

+ {createdSinks.length === 0 ? ( +

No sinks created yet

+ ) : ( +
    + {createdSinks.map((sink) => ( +
  • + {sink.name} (ID: {sink.id}) + +
  • + ))} +
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/PenguinWave/src/components/index.ts b/PenguinWave/src/components/index.ts new file mode 100644 index 0000000..f7c8b85 --- /dev/null +++ b/PenguinWave/src/components/index.ts @@ -0,0 +1,6 @@ +export { GreetingSection } from "./GreetingSection"; +export { VirtualSinkManager } from "./VirtualSinkManager"; +export { PortLinker } from "./PortLinker"; +export { HidDeviceManager } from "./HidDeviceManager"; +export { Sidebar } from "./Sidebar"; +export { PlaybackStreamsManager } from "./PlaybackStreamsManager"; \ No newline at end of file diff --git a/PenguinWave/src/hooks/useAudio.ts b/PenguinWave/src/hooks/useAudio.ts new file mode 100644 index 0000000..d360a3c --- /dev/null +++ b/PenguinWave/src/hooks/useAudio.ts @@ -0,0 +1,162 @@ +import { useState, useEffect } from "react"; +import { AudioService } from "../services/audioService"; +import { PipeWireNode, AudioDevice, ApplicationStream } from "../models/audio"; + +export const useAudio = () => { + const [nodes, setNodes] = useState([]); + const [outputDevices, setOutputDevices] = useState([]); + const [createdSinks, setCreatedSinks] = useState([]); + const [applicationStreams, setApplicationStreams] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refreshApplicationStreams = async () => { + try { + setLoading(true); + setError(null); + const applicationStreamsData = await AudioService.getApplicationStreams(); + setApplicationStreams(applicationStreamsData); + } catch (error) { + console.error("Error getting sink inputs:", error); + setError("Failed to load sink inputs"); + } finally { + setLoading(false); + } + }; + + const refreshNodes = async () => { + try { + setLoading(true); + setError(null); + const nodesData = await AudioService.listNodes(); + setNodes(nodesData); + } catch (error) { + console.error("Error listing nodes:", error); + setError("Failed to load nodes"); + } finally { + setLoading(false); + } + }; + + const refreshCustomVirtualSinks = async () => { + try { + setLoading(true); + setError(null); + const sinksData = await AudioService.getCustomVirtualSinks(); + setCreatedSinks(sinksData); + } catch (error) { + console.error("Error listing nodes:", error); + setError("Failed to load nodes"); + } finally { + setLoading(false); + } + }; + + const refreshOutputDevices = async () => { + try { + setLoading(true); + setError(null); + const devicesData = await AudioService.getOutputDevices(); + setOutputDevices(devicesData); + } catch (error) { + console.error("Error getting output devices:", error); + setError("Failed to load output devices"); + } finally { + setLoading(false); + } + }; + + const refreshAll = async () => { + try { + setLoading(true); + setError(null); + await Promise.all([refreshNodes(), refreshOutputDevices(), refreshCustomVirtualSinks(), refreshApplicationStreams()]); + } catch (error) { + console.error("Error refreshing data:", error); + setError("Failed to refresh data"); + } finally { + setLoading(false); + } + }; + + const createSink = async (name: string, description: string) => { + try { + setLoading(true); + setError(null); + const moduleId = await AudioService.createVirtualSink(name, description); + await refreshAll(); + return moduleId; + } catch (error) { + console.error("Error creating virtual sink:", error); + setError("Failed to create sink"); + throw error; + } finally { + setLoading(false); + } + }; + + const deleteSink = async (moduleId: number, name: string) => { + try { + setLoading(true); + setError(null); + await AudioService.deleteVirtualSink(moduleId); + setCreatedSinks(createdSinks.filter((sink) => sink.id !== moduleId)); + await refreshAll(); + } catch (error) { + console.error(`Error deleting sink ${name}:`, error); + setError(`Failed to delete sink ${name}`); + throw error; + } finally { + setLoading(false); + } + }; + + const linkPorts = async (sourcePort: string, targetPort: string) => { + try { + setLoading(true); + setError(null); + await AudioService.linkPorts(sourcePort, targetPort); + } catch (error) { + console.error("Error linking ports:", error); + setError("Failed to link ports"); + throw error; + } finally { + setLoading(false); + } + }; + + const moveApplicationToSink = async (applicationId: number, sinkId: number) => { + try { + setLoading(true); + setError(null); + console.log("Move app {} to {}", applicationId, sinkId); + await AudioService.moveApplicationToSink(applicationId, sinkId); + } catch (error) { + console.error("Error moving application to sink:", error); + setError("Failed to move application to sink"); + throw error; + } finally { + setLoading(false); + } + }; + + useEffect(() => { + refreshAll(); + }, []); + + return { + nodes, + outputDevices, + createdSinks, + applicationStreams, + loading, + error, + refreshNodes, + refreshOutputDevices, + refreshAll, + createSink, + deleteSink, + linkPorts, + moveApplicationToSink, + }; +}; \ No newline at end of file diff --git a/PenguinWave/src/hooks/useHid.ts b/PenguinWave/src/hooks/useHid.ts new file mode 100644 index 0000000..980ceb6 --- /dev/null +++ b/PenguinWave/src/hooks/useHid.ts @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { HidService } from "../services/hidService"; +import { HidDeviceInfo } from "../models/hid"; + +export const useHid = () => { + const [hidDevices, setHidDevices] = useState([]); + const [selectedDevice, setSelectedDevice] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refreshHidDevices = async () => { + try { + setLoading(true); + setError(null); + await HidService.refreshHidDevices(); + const devices = await HidService.listHidDevices(); + setHidDevices(devices); + } catch (error) { + console.error("Error refreshing HID devices:", error); + setError("Failed to refresh HID devices"); + } finally { + setLoading(false); + } + }; + + const findHidDevice = async (vendorId: string, productId: string) => { + if (!vendorId || !productId) { + setError("Please enter both Vendor ID and Product ID"); + return; + } + + try { + setLoading(true); + setError(null); + const vId = parseInt(vendorId, 16); + const pId = parseInt(productId, 16); + + const device = await HidService.findHidDevice(vId, pId); + + if (device) { + setSelectedDevice(device); + } else { + setError("Device not found"); + setSelectedDevice(null); + } + } catch (error) { + console.error("Error finding HID device:", error); + setError("Failed to find device"); + } finally { + setLoading(false); + } + }; + + return { + hidDevices, + selectedDevice, + loading, + error, + refreshHidDevices, + findHidDevice, + }; +}; \ No newline at end of file diff --git a/PenguinWave/src/models/audio.ts b/PenguinWave/src/models/audio.ts new file mode 100644 index 0000000..81d3e4b --- /dev/null +++ b/PenguinWave/src/models/audio.ts @@ -0,0 +1,27 @@ +export interface PipeWireNode { + id: number; + name: string; + description: string; + node_type: string; + module_id: number; +} + +export interface AudioPort { + name: string; + description: string; + direction: string; +} + +export interface AudioDevice { + id: number; + name: string; + description: string; + ports: AudioPort[]; +} + +export interface ApplicationStream { + id: number; + name: string; + icon: string; + assigned_sink_id: number; +} \ No newline at end of file diff --git a/PenguinWave/src/models/hid.ts b/PenguinWave/src/models/hid.ts new file mode 100644 index 0000000..b9148b6 --- /dev/null +++ b/PenguinWave/src/models/hid.ts @@ -0,0 +1,8 @@ +export interface HidDeviceInfo { + vendor_id: number; + product_id: number; + serial_number: string | null; + manufacturer_string: string | null; + product_string: string | null; + path: string; +} \ No newline at end of file diff --git a/PenguinWave/src/services/audioService.ts b/PenguinWave/src/services/audioService.ts new file mode 100644 index 0000000..aa914c7 --- /dev/null +++ b/PenguinWave/src/services/audioService.ts @@ -0,0 +1,51 @@ +import { invoke } from "@tauri-apps/api/core"; +import { PipeWireNode, AudioDevice, ApplicationStream } from "../models/audio"; + +export class AudioService { + static async listNodes(): Promise { + return await invoke("list_nodes"); + } + + static async getOutputDevices(): Promise { + return await invoke("get_output_devices"); + } + + static async getCustomVirtualSinks(): Promise{ + return await invoke("get_custom_virtual_sinks"); + } + + static async createVirtualSink(name: string, description: string): Promise { + return await invoke("create_virtual_sink", { + request: { + name, + description: description || name, + }, + }); + } + + static async deleteVirtualSink(moduleId: number): Promise { + await invoke("delete_virtual_sink", { moduleId }); + } + + static async linkPorts(sourcePort: string, targetPort: string): Promise { + await invoke("link_ports", { + request: { + source_port: sourcePort, + target_port: targetPort, + }, + }); + } + + static async getApplicationStreams(): Promise { + return await invoke("get_application_streams"); + } + + static async moveApplicationToSink(applicationId: number, sinkId: number): Promise { + await invoke("move_application_to_sink", { + request: { + application_id: applicationId, + sink_id: sinkId, + }, + }); + } +} \ No newline at end of file diff --git a/PenguinWave/src/services/greetingService.ts b/PenguinWave/src/services/greetingService.ts new file mode 100644 index 0000000..184acba --- /dev/null +++ b/PenguinWave/src/services/greetingService.ts @@ -0,0 +1,7 @@ +import { invoke } from "@tauri-apps/api/core"; + +export class GreetingService { + static async greet(name: string): Promise { + return await invoke("greet", { name }); + } +} \ No newline at end of file diff --git a/PenguinWave/src/services/hidService.ts b/PenguinWave/src/services/hidService.ts new file mode 100644 index 0000000..578dad3 --- /dev/null +++ b/PenguinWave/src/services/hidService.ts @@ -0,0 +1,21 @@ +import { invoke } from "@tauri-apps/api/core"; +import { HidDeviceInfo } from "../models/hid"; + +export class HidService { + static async refreshHidDevices(): Promise { + await invoke("refresh_hid_devices"); + } + + static async listHidDevices(): Promise { + return await invoke("list_hid_devices"); + } + + static async findHidDevice(vendorId: number, productId: number): Promise { + return await invoke("find_hid_device", { + request: { + vendor_id: vendorId, + product_id: productId, + }, + }); + } +} \ No newline at end of file