From 05616c8ce1d402e9786043ba718f895d1ff948bb Mon Sep 17 00:00:00 2001 From: RiceaRaul Date: Mon, 14 Jul 2025 00:23:22 +0300 Subject: [PATCH 1/7] [WIP] Fix the multiple application sinks input. --- PenguinWave/src-tauri/Cargo.lock | 17 ++ PenguinWave/src-tauri/Cargo.toml | 2 + PenguinWave/src-tauri/src/lib.rs | 9 +- PenguinWave/src-tauri/src/system/pipewire.rs | 85 ++++++- .../src/components/PlaybackStreamsManager.tsx | 237 ++++++++++++------ PenguinWave/src/models/audio.ts | 1 + PenguinWave/src/services/audioService.ts | 4 +- 7 files changed, 269 insertions(+), 86 deletions(-) diff --git a/PenguinWave/src-tauri/Cargo.lock b/PenguinWave/src-tauri/Cargo.lock index 0592e36..2a06d6d 100644 --- a/PenguinWave/src-tauri/Cargo.lock +++ b/PenguinWave/src-tauri/Cargo.lock @@ -828,6 +828,12 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "embed-resource" version = "3.0.2" @@ -1786,6 +1792,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "0.4.8" @@ -2501,8 +2516,10 @@ name = "penguinwave" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", "dirs", "hidapi", + "itertools", "lazy_static", "serde", "serde_json", diff --git a/PenguinWave/src-tauri/Cargo.toml b/PenguinWave/src-tauri/Cargo.toml index f110e73..2fe4fb2 100644 --- a/PenguinWave/src-tauri/Cargo.toml +++ b/PenguinWave/src-tauri/Cargo.toml @@ -29,6 +29,8 @@ dirs = "6.0.0" anyhow = "1.0.98" subprocess = "0.2" tauri-plugin-shell = "2" +itertools = "0.14.0" +base64 = "0.22.1" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] diff --git a/PenguinWave/src-tauri/src/lib.rs b/PenguinWave/src-tauri/src/lib.rs index 903ba77..1ac8c96 100644 --- a/PenguinWave/src-tauri/src/lib.rs +++ b/PenguinWave/src-tauri/src/lib.rs @@ -9,6 +9,8 @@ use crate::utils::hardware_utils::*; use crate::utils::state::AppStateManager; use serde::{Deserialize, Serialize}; use tauri::{Manager}; +use std::collections::HashMap; +use itertools::Itertools; #[tauri::command] fn greet(name: &str) -> String { @@ -127,8 +129,13 @@ fn get_custom_virtual_sinks() -> Result, String> { } #[tauri::command] -fn get_application_streams() -> Result, String> { +fn get_application_streams() -> Result>, String> { PipeWireManager::list_application_streams() + .map(|streams| { + streams + .into_iter() + .into_group_map_by(|stream| stream.name.clone()) // Replace 'name' with your actual field + }) .map_err(|e| e.to_string()) } diff --git a/PenguinWave/src-tauri/src/system/pipewire.rs b/PenguinWave/src-tauri/src/system/pipewire.rs index bb131b4..741510d 100644 --- a/PenguinWave/src-tauri/src/system/pipewire.rs +++ b/PenguinWave/src-tauri/src/system/pipewire.rs @@ -1,3 +1,5 @@ +use std::fs; +use std::path::PathBuf; use std::process::Command; use serde::{Serialize, Deserialize}; use anyhow::{Result, anyhow}; @@ -33,7 +35,8 @@ pub struct ApplicationStream { pub id: u32, pub name: String, pub icon: String, - pub assigned_sink_id: u32 + pub assigned_sink_id: u32, + pub icon_path: String } pub struct PipeWireManager; @@ -444,6 +447,7 @@ impl PipeWireManager { let mut name = String::new(); let mut icon = String::new(); let mut assigned_sink_id : u32 = 0; + let mut icon_path = String::new(); let detailed_inputs = format!("{}\n", details_output); @@ -466,6 +470,19 @@ impl PipeWireManager { } else if line.contains("application.icon_name = ") { if let Some(value) = Self::extract_quoted_value(line, "application.icon_name = ") { icon = value; + let icon_path_value = Self::find_icon_file(icon.as_str(), 48) + .map(|path| path.to_string_lossy().to_string()) + .or_else(|| { + // If first method fails, try desktop file method + Self::get_application_icon_from_desktop(icon.as_str()) + .and_then(|icon_name| Self::find_icon_file(&icon_name, 48)) + .map(|path| path.to_string_lossy().to_string()) + }) + .unwrap_or_default(); + + icon_path = Self::get_application_icon_base64(icon_path_value) + .map(|path| path.to_string()) + .unwrap_or_default(); } } @@ -477,10 +494,13 @@ impl PipeWireManager { id, name: name.clone(), icon: icon.clone(), - assigned_sink_id + assigned_sink_id: assigned_sink_id, + icon_path: icon_path.clone() }; application_streams.push(application_stream); + name = String::new(); + icon = String::new(); } } } @@ -508,6 +528,67 @@ impl PipeWireManager { Ok(sink_name) } + + pub fn find_icon_file(icon_name: &str, size: u32) -> Option { + let icon_dirs = vec![ + format!("/usr/share/icons/hicolor/{}x{}/apps", size, size), + format!("/usr/share/icons/Adwaita/{}x{}/apps", size, size), + format!("/usr/share/pixmaps"), + format!("{}/.local/share/icons/hicolor/{}x{}/apps", + std::env::var("HOME").unwrap_or_default(), size, size), + ]; + + let extensions = vec!["png", "svg", "xpm"]; + + for dir in icon_dirs { + for ext in &extensions { + let icon_path = format!("{}/{}.{}", dir, icon_name, ext); + if std::path::Path::new(&icon_path).exists() { + return Some(PathBuf::from(icon_path)); + } + } + } + None + } + + pub fn get_application_icon_from_desktop(app_name: &str) -> Option { + let desktop_dirs = vec![ + "/usr/share/applications".to_string(), + "/usr/local/share/applications".to_string(), + format!("{}/.local/share/applications", std::env::var("HOME").unwrap_or_default()), + ]; + + for dir in desktop_dirs { + let desktop_file = format!("{}/{}.desktop", dir, app_name.to_lowercase()); + if let Ok(content) = fs::read_to_string(&desktop_file) { + for line in content.lines() { + if line.starts_with("Icon=") { + let icon_name = line.strip_prefix("Icon=").unwrap().trim(); + return Some(icon_name.to_string()); + } + } + } + } + None + } + + pub fn get_application_icon_base64(icon_path: String) -> Result { + use base64::{Engine as _, engine::general_purpose}; + + let icon_data = fs::read(&icon_path) + .map_err(|e| format!("Failed to read icon file: {}", e))?; + + let mime_type = if icon_path.ends_with(".svg") { + "image/svg+xml" + } else if icon_path.ends_with(".png") { + "image/png" + } else { + "image/png" // default + }; + + let base64_data = general_purpose::STANDARD.encode(&icon_data); + Ok(format!("data:{};base64,{}", mime_type, base64_data)) + } } pub fn init_virtual_channels(app_handle: &AppHandle) { diff --git a/PenguinWave/src/components/PlaybackStreamsManager.tsx b/PenguinWave/src/components/PlaybackStreamsManager.tsx index fc7ec09..4764519 100644 --- a/PenguinWave/src/components/PlaybackStreamsManager.tsx +++ b/PenguinWave/src/components/PlaybackStreamsManager.tsx @@ -12,9 +12,9 @@ import { ApplicationStream } from "../models/audio"; export const PlaybackStreamsManager = () => { const {createdSinks, moveApplicationToSink} = useAudio(); - const [applicationStreams, setApplicationStreams] = useState([]); + const [applicationStreams, setApplicationStreams] = useState>({}); const [loading, setLoading] = useState(false); - const [activeId, setActiveId] = useState(null); + const [activeId, setActiveId] = useState(null); useEffect(() => { fetchApplicationStreams(); @@ -32,34 +32,54 @@ export const PlaybackStreamsManager = () => { }; // 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); + const groupedApplicationStreams: Record> = {}; + + for (const [appName, streams] of Object.entries(applicationStreams)) { + for (const stream of streams) { + const groupId = (stream as any).assigned_sink_id ?? 0; + if (!groupedApplicationStreams[groupId]) groupedApplicationStreams[groupId] = {}; + if (!groupedApplicationStreams[groupId][appName]) groupedApplicationStreams[groupId][appName] = []; + groupedApplicationStreams[groupId][appName].push(stream); + } } - // Unassigned inputs (not in any created sink) - const unassignedApplicationStreams = applicationStreams.filter( - (input) => !createdSinks.some((sink) => sink.id === (input as any).assigned_sink_id) - ); + // Unassigned applications (not in any created sink) + const unassignedApplicationStreams: Record = {}; + for (const [appName, streams] of Object.entries(applicationStreams)) { + const unassignedStreams = streams.filter( + (stream) => !createdSinks.some((sink) => sink.id === (stream as any).assigned_sink_id) + ); + if (unassignedStreams.length > 0) { + unassignedApplicationStreams[appName] = unassignedStreams; + } + } // DnD handlers const handleDragStart = (event: any) => { - setActiveId(Number(event.active.id)); + setActiveId(event.active.id); }; const handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event; if (!over) return setActiveId(null); - const inputId = Number(active.id); + + const appName = active.id as string; 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); + const appStreams = applicationStreams[appName]; + + if (!appStreams || appStreams.length === 0) return setActiveId(null); + + // Check if any stream is already assigned to this sink + const alreadyAssigned = appStreams.some(stream => (stream as any).assigned_sink_id === sinkId); + if (alreadyAssigned) return setActiveId(null); + setLoading(true); try { - await moveApplicationToSink(inputId, sinkId); + // Move all streams of this application to the target sink + const movePromises = appStreams.map(stream => + moveApplicationToSink(stream.id, sinkId) + ); + await Promise.all(movePromises); await fetchApplicationStreams(); } finally { setTimeout(() => setActiveId(null), 50); // Delay to prevent snap-back @@ -68,83 +88,138 @@ export const PlaybackStreamsManager = () => { }; return ( -
-

Playback Streams Manager

- -
- {createdSinks.map((sink) => ( +
+

Playback Streams Manager

+ +
+ {createdSinks.map((sink) => ( + + ))} + {/* Unassigned applications column */} - ))} - {/* Unassigned inputs column */} - -
- - {activeId != null && ( - i.id === activeId)!} - disabled={true} + id={0} + label="Other Streams" + applications={unassignedApplicationStreams} + loading={loading} + activeId={activeId} /> - )} - -
- -
+
+ + {activeId != null && applicationStreams[activeId] && ( + + )} + +
+ +
); }; -function SinkColumn({ id, label, inputs, loading, activeId }: { id: number; label: string; inputs: ApplicationStream[]; loading: boolean; activeId: number | null }) { +function SinkColumn({ + id, + label, + applications, + loading, + activeId + }: { + id: number; + label: string; + applications: Record; + loading: boolean; + activeId: string | null; +}) { const { setNodeRef } = useDroppable({ id }); + return ( -
-
{label}
-
- {inputs.filter(input => input.id !== activeId).map((input) => ( - - ))} +
+
{label}
+
+ {Object.entries(applications) + .filter(([appName]) => appName !== activeId) + .map(([appName, streams]) => ( + + ))} +
-
); } -function StreamDraggable({ input, disabled }: { input: ApplicationStream; disabled: boolean }) { +function ApplicationDraggable({ + appName, + streams, + disabled + }: { + appName: string; + streams: ApplicationStream[]; + disabled: boolean; +}) { const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ - id: input.id, + id: appName, disabled, }); + + // Use the first stream for display info, or fallback + const displayStream = streams[0]; + const streamCount = streams.length; + return ( -
- {input.icon && ( - {input.icon} - )} - {input.name || `App ${input.id}`} -
+
+
+ {displayStream?.icon && ( + {displayStream.icon} + )} + {appName} +
+
+ {streamCount} stream{streamCount !== 1 ? 's' : ''} +
+ {streamCount > 1 && ( +
+ {streams.map(stream => `#${stream.id}`).join(', ')} +
+ )} +
); -} \ No newline at end of file +} \ No newline at end of file diff --git a/PenguinWave/src/models/audio.ts b/PenguinWave/src/models/audio.ts index 81d3e4b..69f03d4 100644 --- a/PenguinWave/src/models/audio.ts +++ b/PenguinWave/src/models/audio.ts @@ -24,4 +24,5 @@ export interface ApplicationStream { name: string; icon: string; assigned_sink_id: number; + icon_path:string; } \ No newline at end of file diff --git a/PenguinWave/src/services/audioService.ts b/PenguinWave/src/services/audioService.ts index aa914c7..9b507a1 100644 --- a/PenguinWave/src/services/audioService.ts +++ b/PenguinWave/src/services/audioService.ts @@ -36,8 +36,8 @@ export class AudioService { }); } - static async getApplicationStreams(): Promise { - return await invoke("get_application_streams"); + static async getApplicationStreams(): Promise> { + return await invoke>("get_application_streams"); } static async moveApplicationToSink(applicationId: number, sinkId: number): Promise { From f4d42607b46e33f1dd4d08d49635d4ba4cbc7917 Mon Sep 17 00:00:00 2001 From: RiceaRaul Date: Mon, 14 Jul 2025 21:02:41 +0300 Subject: [PATCH 2/7] [WIP] Fix the multiple application sinks input. --- PenguinWave/src/hooks/useAudio.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PenguinWave/src/hooks/useAudio.ts b/PenguinWave/src/hooks/useAudio.ts index d360a3c..5905c17 100644 --- a/PenguinWave/src/hooks/useAudio.ts +++ b/PenguinWave/src/hooks/useAudio.ts @@ -6,7 +6,7 @@ export const useAudio = () => { const [nodes, setNodes] = useState([]); const [outputDevices, setOutputDevices] = useState([]); const [createdSinks, setCreatedSinks] = useState([]); - const [applicationStreams, setApplicationStreams] = useState([]); + const [applicationStreams, setApplicationStreams] = useState>(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); From e8e0823eabd2fe3ab90208a9988253e33478a83f Mon Sep 17 00:00:00 2001 From: Raul202 <55659437+RiceaRaul@users.noreply.github.com> Date: Mon, 14 Jul 2025 22:02:29 +0300 Subject: [PATCH 3/7] Update PenguinWave/src-tauri/src/system/pipewire.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- PenguinWave/src-tauri/src/system/pipewire.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PenguinWave/src-tauri/src/system/pipewire.rs b/PenguinWave/src-tauri/src/system/pipewire.rs index 741510d..46d47b9 100644 --- a/PenguinWave/src-tauri/src/system/pipewire.rs +++ b/PenguinWave/src-tauri/src/system/pipewire.rs @@ -501,6 +501,8 @@ impl PipeWireManager { application_streams.push(application_stream); name = String::new(); icon = String::new(); + assigned_sink_id = 0; + icon_path = String::new(); } } } From 1b1dbf71febdf31be234fc53731d3b548d90417b Mon Sep 17 00:00:00 2001 From: Raul202 <55659437+RiceaRaul@users.noreply.github.com> Date: Mon, 14 Jul 2025 22:02:52 +0300 Subject: [PATCH 4/7] Update PenguinWave/src/components/PlaybackStreamsManager.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- PenguinWave/src/components/PlaybackStreamsManager.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PenguinWave/src/components/PlaybackStreamsManager.tsx b/PenguinWave/src/components/PlaybackStreamsManager.tsx index 4764519..2eec9f0 100644 --- a/PenguinWave/src/components/PlaybackStreamsManager.tsx +++ b/PenguinWave/src/components/PlaybackStreamsManager.tsx @@ -205,7 +205,7 @@ function ApplicationDraggable({
{displayStream?.icon && ( {displayStream.icon} From 5523d332cda12875827e37a5399750564a0e14fe Mon Sep 17 00:00:00 2001 From: Raul202 <55659437+RiceaRaul@users.noreply.github.com> Date: Mon, 14 Jul 2025 22:03:04 +0300 Subject: [PATCH 5/7] Update PenguinWave/src/components/PlaybackStreamsManager.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- PenguinWave/src/components/PlaybackStreamsManager.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PenguinWave/src/components/PlaybackStreamsManager.tsx b/PenguinWave/src/components/PlaybackStreamsManager.tsx index 2eec9f0..aa7fcdb 100644 --- a/PenguinWave/src/components/PlaybackStreamsManager.tsx +++ b/PenguinWave/src/components/PlaybackStreamsManager.tsx @@ -95,7 +95,7 @@ export const PlaybackStreamsManager = () => { {createdSinks.map((sink) => ( Date: Mon, 14 Jul 2025 22:03:12 +0300 Subject: [PATCH 6/7] Update PenguinWave/src/models/audio.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- PenguinWave/src/models/audio.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PenguinWave/src/models/audio.ts b/PenguinWave/src/models/audio.ts index 69f03d4..1ca0ebb 100644 --- a/PenguinWave/src/models/audio.ts +++ b/PenguinWave/src/models/audio.ts @@ -24,5 +24,5 @@ export interface ApplicationStream { name: string; icon: string; assigned_sink_id: number; - icon_path:string; + icon_path: string; } \ No newline at end of file From 0b2c9259d002ffe5a967f97a8289911c3a399f8a Mon Sep 17 00:00:00 2001 From: RiceaRaul Date: Mon, 14 Jul 2025 22:14:04 +0300 Subject: [PATCH 7/7] Fix id string --- PenguinWave/src/components/PlaybackStreamsManager.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PenguinWave/src/components/PlaybackStreamsManager.tsx b/PenguinWave/src/components/PlaybackStreamsManager.tsx index aa7fcdb..2eec9f0 100644 --- a/PenguinWave/src/components/PlaybackStreamsManager.tsx +++ b/PenguinWave/src/components/PlaybackStreamsManager.tsx @@ -95,7 +95,7 @@ export const PlaybackStreamsManager = () => { {createdSinks.map((sink) => (