From 6d3425e79fa98e7629620e255322932ac7ad1662 Mon Sep 17 00:00:00 2001 From: Flowie Date: Thu, 3 Jul 2025 11:13:53 +0300 Subject: [PATCH 1/8] List custom created sinks. Restructure frontend --- .../RunConfigurations/DevAppRun.run.xml | 2 +- PenguinWave/src-tauri/src/lib.rs | 11 +- PenguinWave/src-tauri/src/system/pipewire.rs | 95 +++- PenguinWave/src/App.css | 121 +++++ PenguinWave/src/App.tsx | 413 +----------------- .../src/components/GreetingSection.tsx | 43 ++ .../src/components/HidDeviceManager.tsx | 83 ++++ PenguinWave/src/components/PortLinker.tsx | 85 ++++ .../src/components/VirtualSinkManager.tsx | 102 +++++ PenguinWave/src/components/index.ts | 4 + PenguinWave/src/hooks/useAudio.ts | 130 ++++++ PenguinWave/src/hooks/useHid.ts | 62 +++ PenguinWave/src/models/audio.ts | 25 ++ PenguinWave/src/models/hid.ts | 8 + PenguinWave/src/services/audioService.ts | 38 ++ PenguinWave/src/services/greetingService.ts | 7 + PenguinWave/src/services/hidService.ts | 21 + 17 files changed, 841 insertions(+), 409 deletions(-) create mode 100644 PenguinWave/src/components/GreetingSection.tsx create mode 100644 PenguinWave/src/components/HidDeviceManager.tsx create mode 100644 PenguinWave/src/components/PortLinker.tsx create mode 100644 PenguinWave/src/components/VirtualSinkManager.tsx create mode 100644 PenguinWave/src/components/index.ts create mode 100644 PenguinWave/src/hooks/useAudio.ts create mode 100644 PenguinWave/src/hooks/useHid.ts create mode 100644 PenguinWave/src/models/audio.ts create mode 100644 PenguinWave/src/models/hid.ts create mode 100644 PenguinWave/src/services/audioService.ts create mode 100644 PenguinWave/src/services/greetingService.ts create mode 100644 PenguinWave/src/services/hidService.ts 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/src-tauri/src/lib.rs b/PenguinWave/src-tauri/src/lib.rs index 7068814..19459cd 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::PipeWireManager; +use crate::system::pipewire::{PipeWireManager, PipeWireNode}; use crate::utils::hardware_utils::*; use crate::utils::state::AppStateManager; use serde::{Deserialize, Serialize}; @@ -37,7 +37,7 @@ fn greet(name: &str) -> String { ); // Verify if game/chat sinks exist, if not create them and link with the devices - + if let Ok(sinks) = PipeWireManager::list_sinks() { if !sinks.contains(&GAME_SINK.to_string()) && !sinks.contains(&CHAT_SINK.to_string()) { @@ -133,6 +133,12 @@ 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()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -163,6 +169,7 @@ pub fn run() { list_nodes, get_output_devices, link_ports, + get_custom_virtual_sinks ]) .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 ba328f7..5f0ff0e 100644 --- a/PenguinWave/src-tauri/src/system/pipewire.rs +++ b/PenguinWave/src-tauri/src/system/pipewire.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::process::Command; use serde::{Serialize, Deserialize}; use anyhow::{Result, anyhow}; @@ -8,6 +9,7 @@ pub struct PipeWireNode { pub name: String, pub description: String, pub node_type: String, + pub module_id: Option } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -35,7 +37,7 @@ impl PipeWireManager { .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))); } @@ -54,7 +56,55 @@ 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_line(detail_stdout, sink_id){ + nodes.push(node); + } + } + + Ok(nodes) + } + /// Create a new virtual sink pub fn create_virtual_sink(sink_name: &str, description: &str) -> Result { let output = Command::new("pactl") @@ -124,7 +174,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 { @@ -157,6 +206,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, }); } @@ -195,6 +245,7 @@ impl PipeWireManager { name, description: current_desc.unwrap_or_default(), node_type: current_type.unwrap_or_default(), + module_id: None, }); } @@ -311,4 +362,42 @@ impl PipeWireManager { Ok(()) } + + fn parse_sink_details_from_line(detail_output: Cow, 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.starts_with("media.class = \"") { + if let Some(media_class) = line.strip_prefix("media.class = \"") { + if let Some(end) = media_class.find('"') { + node_type = media_class[..end].to_string(); + } + } + } else if line.starts_with("Owner Module: ") { + module_id = Some(line.strip_prefix("Owner Module: ").unwrap_or("").to_string()); + } + } + + 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 + } } \ No newline at end of file diff --git a/PenguinWave/src/App.css b/PenguinWave/src/App.css index 85f7a4a..1bff30f 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,40 @@ 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; + } } diff --git a/PenguinWave/src/App.tsx b/PenguinWave/src/App.tsx index 83bf08d..3552cb0 100644 --- a/PenguinWave/src/App.tsx +++ b/PenguinWave/src/App.tsx @@ -1,411 +1,18 @@ -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, +} from "./components"; 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 })); - } - 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

- ) : ( -
    - {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)} -
  • - ))} -
- )} -
+ + + +
); } 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/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/VirtualSinkManager.tsx b/PenguinWave/src/components/VirtualSinkManager.tsx new file mode 100644 index 0000000..5d37797 --- /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..4a19d3e --- /dev/null +++ b/PenguinWave/src/components/index.ts @@ -0,0 +1,4 @@ +export { GreetingSection } from "./GreetingSection"; +export { VirtualSinkManager } from "./VirtualSinkManager"; +export { PortLinker } from "./PortLinker"; +export { HidDeviceManager } from "./HidDeviceManager"; \ 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..04bc0a7 --- /dev/null +++ b/PenguinWave/src/hooks/useAudio.ts @@ -0,0 +1,130 @@ +import { useState, useEffect } from "react"; +import { AudioService } from "../services/audioService"; +import { PipeWireNode, AudioDevice, VirtualSink } from "../models/audio"; + +export const useAudio = () => { + const [nodes, setNodes] = useState([]); + const [outputDevices, setOutputDevices] = useState([]); + const [createdSinks, setCreatedSinks] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + 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()]); + } 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); + } + }; + + useEffect(() => { + refreshAll(); + }, []); + + return { + nodes, + outputDevices, + createdSinks, + loading, + error, + refreshNodes, + refreshOutputDevices, + refreshAll, + createSink, + deleteSink, + linkPorts, + }; +}; \ 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..edba974 --- /dev/null +++ b/PenguinWave/src/models/audio.ts @@ -0,0 +1,25 @@ +export interface PipeWireNode { + id: number; + name: string; + description: string; + node_type: string; + module_id: string; +} + +export interface AudioPort { + name: string; + description: string; + direction: string; +} + +export interface AudioDevice { + id: number; + name: string; + description: string; + ports: AudioPort[]; +} + +export interface VirtualSink { + id: number; + name: string; +} \ 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..4e24464 --- /dev/null +++ b/PenguinWave/src/services/audioService.ts @@ -0,0 +1,38 @@ +import { invoke } from "@tauri-apps/api/core"; +import { PipeWireNode, AudioDevice } 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, + }, + }); + } +} \ 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 From f9bd32d4817a2da969996a99645e5dba1101597d Mon Sep 17 00:00:00 2001 From: Flowie Date: Thu, 3 Jul 2025 11:42:06 +0300 Subject: [PATCH 2/8] fix sink deletion --- PenguinWave/src/components/VirtualSinkManager.tsx | 2 +- PenguinWave/src/models/audio.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/PenguinWave/src/components/VirtualSinkManager.tsx b/PenguinWave/src/components/VirtualSinkManager.tsx index 5d37797..5a7f9d8 100644 --- a/PenguinWave/src/components/VirtualSinkManager.tsx +++ b/PenguinWave/src/components/VirtualSinkManager.tsx @@ -87,7 +87,7 @@ export const VirtualSinkManager = () => {
  • {sink.name} (ID: {sink.id}) + + ); +}; + +function SinkColumn({ id, label, inputs, loading, activeId }: { id: number; label: string; inputs: SinkInput[]; loading: boolean; activeId: number | null }) { + const { setNodeRef } = useDroppable({ id }); + return ( +
    +
    {label}
    +
    + {inputs.filter(input => input.id !== activeId).map((input) => ( + + ))} +
    +
    + ); +} + +function StreamDraggable({ input, disabled }: { input: SinkInput; 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/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/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f44de4c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,87 @@ +{ + "name": "PenguinWave", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@dnd-kit/core": "^6.3.1" + } + }, + "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/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT", + "peer": true + }, + "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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4054c2a --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@dnd-kit/core": "^6.3.1" + } +} From fb12bb5a942e9bed55c36c4911a1ebeb48d54508 Mon Sep 17 00:00:00 2001 From: Flowie Date: Mon, 7 Jul 2025 16:19:27 +0300 Subject: [PATCH 7/8] fix package lock locations --- PenguinWave/package-lock.json | 46 ++++++++++++++++++ PenguinWave/package.json | 1 + package-lock.json | 87 ----------------------------------- package.json | 5 -- 4 files changed, 47 insertions(+), 92 deletions(-) delete mode 100644 package-lock.json delete mode 100644 package.json 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/package-lock.json b/package-lock.json deleted file mode 100644 index f44de4c..0000000 --- a/package-lock.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "name": "PenguinWave", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@dnd-kit/core": "^6.3.1" - } - }, - "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/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.26.0" - }, - "peerDependencies": { - "react": "^19.1.0" - } - }, - "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT", - "peer": true - }, - "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" - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 4054c2a..0000000 --- a/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "dependencies": { - "@dnd-kit/core": "^6.3.1" - } -} From 05ee3b53232a1fe6533ffac24f705b334d8f91ce Mon Sep 17 00:00:00 2001 From: Flowie Date: Tue, 8 Jul 2025 13:56:30 +0300 Subject: [PATCH 8/8] Refactor (rename sink_input to application_stream), fix bug where not all streams are listed --- PenguinWave/src-tauri/src/lib.rs | 8 ++-- PenguinWave/src-tauri/src/system/pipewire.rs | 44 +++++++++++-------- .../src/components/PlaybackStreamsManager.tsx | 43 +++++++++--------- PenguinWave/src/hooks/useAudio.ts | 16 +++---- PenguinWave/src/models/audio.ts | 2 +- PenguinWave/src/services/audioService.ts | 6 +-- 6 files changed, 63 insertions(+), 56 deletions(-) diff --git a/PenguinWave/src-tauri/src/lib.rs b/PenguinWave/src-tauri/src/lib.rs index e52bca4..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, PipeWireNode, SinkInput}; +use crate::system::pipewire::{init_virtual_channels, PipeWireManager, PipeWireNode, ApplicationStream}; use crate::utils::hardware_utils::*; use crate::utils::state::AppStateManager; use serde::{Deserialize, Serialize}; @@ -127,8 +127,8 @@ fn get_custom_virtual_sinks() -> Result, String> { } #[tauri::command] -fn get_sink_inputs() -> Result, String> { - PipeWireManager::list_sink_inputs() +fn get_application_streams() -> Result, String> { + PipeWireManager::list_application_streams() .map_err(|e| e.to_string()) } @@ -170,7 +170,7 @@ pub fn run() { get_output_devices, link_ports, get_custom_virtual_sinks, - get_sink_inputs, + get_application_streams, move_application_to_sink ]) .run(tauri::generate_context!()) diff --git a/PenguinWave/src-tauri/src/system/pipewire.rs b/PenguinWave/src-tauri/src/system/pipewire.rs index 580d994..bb131b4 100644 --- a/PenguinWave/src-tauri/src/system/pipewire.rs +++ b/PenguinWave/src-tauri/src/system/pipewire.rs @@ -29,7 +29,7 @@ pub struct AudioPort { } #[derive(Debug, Serialize, Deserialize, Clone)] -pub struct SinkInput{ +pub struct ApplicationStream { pub id: u32, pub name: String, pub icon: String, @@ -41,7 +41,7 @@ pub struct PipeWireManager; impl PipeWireManager { /// list applications that play sounds (playback streams) - pub fn list_sink_inputs() -> Result>{ + pub fn list_application_streams() -> Result>{ let output = Command::new("pactl") .args(&["list", "sink-inputs"]) .output()?; @@ -53,7 +53,7 @@ impl PipeWireManager { let stdout = String::from_utf8_lossy(&output.stdout); - Self::parse_sink_input_details_from_output(&stdout) + Self::parse_application_stream_details_from_output(&stdout) } pub fn list_sinks() -> Result> { @@ -140,7 +140,7 @@ impl PipeWireManager { .output()?; if !output.status.success() { - return Err(anyhow!("Failed to create virtual sink: {}", + return Err(anyhow!("Failed to move application to virtual sink: {}", String::from_utf8_lossy(&output.stderr))); } @@ -438,14 +438,16 @@ impl PipeWireManager { None } - fn parse_sink_input_details_from_output(details_output: &str) -> Result>{ - let mut sink_inputs = Vec::new(); + 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; - for line in details_output.lines() { + 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 @@ -466,20 +468,24 @@ impl PipeWireManager { icon = value; } } - } - - if !name.is_empty() && id != 0 { - let sink_input = SinkInput{ - id, - name, - icon, - assigned_sink_id - }; + + // 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 + }; - sink_inputs.push(sink_input); + application_streams.push(application_stream); + } + } } - - Ok(sink_inputs) + + Ok(application_streams) } pub fn get_default_sink() -> Result { diff --git a/PenguinWave/src/components/PlaybackStreamsManager.tsx b/PenguinWave/src/components/PlaybackStreamsManager.tsx index 69776af..fc7ec09 100644 --- a/PenguinWave/src/components/PlaybackStreamsManager.tsx +++ b/PenguinWave/src/components/PlaybackStreamsManager.tsx @@ -8,38 +8,39 @@ import { DragEndEvent, DragOverlay, } from "@dnd-kit/core"; -import { SinkInput } from "../models/audio"; +import { ApplicationStream } from "../models/audio"; export const PlaybackStreamsManager = () => { - const {createdSinks, moveApplicationToSink} = useAudio(); - const [sinkInputs, setSinkInputs] = useState([]); + const {createdSinks, moveApplicationToSink} = useAudio(); + const [applicationStreams, setApplicationStreams] = useState([]); const [loading, setLoading] = useState(false); const [activeId, setActiveId] = useState(null); useEffect(() => { - fetchSinkInputs(); + fetchApplicationStreams(); }, [createdSinks.length]); - const fetchSinkInputs = async () => { + const fetchApplicationStreams = async () => { setLoading(true); try { - const inputs = await AudioService.getSinkInputs(); - setSinkInputs(inputs); + const applicationStreams = await AudioService.getApplicationStreams(); + console.log(applicationStreams); + setApplicationStreams(applicationStreams); } finally { setLoading(false); } }; - // Group sinkInputs by assigned_sink_id - const groupedInputs: Record = {}; - for (const input of sinkInputs) { + // Group applicationStreams by assigned_sink_id + const groupedApplicationStreams: Record = {}; + for (const input of applicationStreams) { const groupId = (input as any).assigned_sink_id ?? 0; - if (!groupedInputs[groupId]) groupedInputs[groupId] = []; - groupedInputs[groupId].push(input); + if (!groupedApplicationStreams[groupId]) groupedApplicationStreams[groupId] = []; + groupedApplicationStreams[groupId].push(input); } // Unassigned inputs (not in any created sink) - const unassignedInputs = sinkInputs.filter( + const unassignedApplicationStreams = applicationStreams.filter( (input) => !createdSinks.some((sink) => sink.id === (input as any).assigned_sink_id) ); @@ -53,13 +54,13 @@ export const PlaybackStreamsManager = () => { if (!over) return setActiveId(null); const inputId = Number(active.id); const sinkId = Number(over.id); - const input = sinkInputs.find((i) => i.id === inputId); + 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 fetchSinkInputs(); + await fetchApplicationStreams(); } finally { setTimeout(() => setActiveId(null), 50); // Delay to prevent snap-back setLoading(false); @@ -76,7 +77,7 @@ export const PlaybackStreamsManager = () => { key={sink.id} id={sink.id} label={`${sink.name} (ID: ${sink.id})`} - inputs={groupedInputs[sink.id] || []} + inputs={groupedApplicationStreams[sink.id] || []} loading={loading} activeId={activeId} /> @@ -85,7 +86,7 @@ export const PlaybackStreamsManager = () => { @@ -93,20 +94,20 @@ export const PlaybackStreamsManager = () => { {activeId != null && ( i.id === activeId)!} + input={applicationStreams.find(i => i.id === activeId)!} disabled={true} /> )} - ); }; -function SinkColumn({ id, label, inputs, loading, activeId }: { id: number; label: string; inputs: SinkInput[]; loading: boolean; activeId: number | null }) { +function SinkColumn({ id, label, inputs, loading, activeId }: { id: number; label: string; inputs: ApplicationStream[]; loading: boolean; activeId: number | null }) { const { setNodeRef } = useDroppable({ id }); return (
    { const [nodes, setNodes] = useState([]); const [outputDevices, setOutputDevices] = useState([]); const [createdSinks, setCreatedSinks] = useState([]); - const [sinkInputs, setSinkInputs] = useState([]); + const [applicationStreams, setApplicationStreams] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const refreshSinkInputs = async () => { + const refreshApplicationStreams = async () => { try { setLoading(true); setError(null); - const sinkInputsData = await AudioService.getSinkInputs(); - setSinkInputs(sinkInputsData); + const applicationStreamsData = await AudioService.getApplicationStreams(); + setApplicationStreams(applicationStreamsData); } catch (error) { console.error("Error getting sink inputs:", error); setError("Failed to load sink inputs"); @@ -70,7 +70,7 @@ export const useAudio = () => { try { setLoading(true); setError(null); - await Promise.all([refreshNodes(), refreshOutputDevices(), refreshCustomVirtualSinks(), refreshSinkInputs()]); + await Promise.all([refreshNodes(), refreshOutputDevices(), refreshCustomVirtualSinks(), refreshApplicationStreams()]); } catch (error) { console.error("Error refreshing data:", error); setError("Failed to refresh data"); @@ -129,7 +129,7 @@ export const useAudio = () => { try { setLoading(true); setError(null); - console.log("Move from {} to {}", applicationId, sinkId); + console.log("Move app {} to {}", applicationId, sinkId); await AudioService.moveApplicationToSink(applicationId, sinkId); } catch (error) { console.error("Error moving application to sink:", error); @@ -148,7 +148,7 @@ export const useAudio = () => { nodes, outputDevices, createdSinks, - sinkInputs, + applicationStreams, loading, error, refreshNodes, diff --git a/PenguinWave/src/models/audio.ts b/PenguinWave/src/models/audio.ts index 5fb9052..81d3e4b 100644 --- a/PenguinWave/src/models/audio.ts +++ b/PenguinWave/src/models/audio.ts @@ -19,7 +19,7 @@ export interface AudioDevice { ports: AudioPort[]; } -export interface SinkInput { +export interface ApplicationStream { id: number; name: string; icon: string; diff --git a/PenguinWave/src/services/audioService.ts b/PenguinWave/src/services/audioService.ts index c3f672c..aa914c7 100644 --- a/PenguinWave/src/services/audioService.ts +++ b/PenguinWave/src/services/audioService.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import { PipeWireNode, AudioDevice, SinkInput } from "../models/audio"; +import { PipeWireNode, AudioDevice, ApplicationStream } from "../models/audio"; export class AudioService { static async listNodes(): Promise { @@ -36,8 +36,8 @@ export class AudioService { }); } - static async getSinkInputs(): Promise { - return await invoke("get_sink_inputs"); + static async getApplicationStreams(): Promise { + return await invoke("get_application_streams"); } static async moveApplicationToSink(applicationId: number, sinkId: number): Promise {