Files
pipewire-soundpad/pwsp-gui/src/gui/mod.rs
T
Tarasov AleksandrandClaude Opus 5 785b835237 feat: separate monitoring and output streams (#178)
* feat: split playback into separate monitoring and mic streams

AudioPlayer held a single rodio stream. WirePlumber auto-connected its node to
the default sink (that is the monitoring path) while PWSP explicitly linked the
very same node into pwsp-virtual-mic. Both paths were fed by one Player, so
Player::set_volume scaled them together and they could not be separated — hence
the report of "volume 2 is fine on the other side but deafening locally".

There are now two independent streams with independent gains:

  mic stream        --ensure_route()--> pwsp-virtual-mic
  monitoring stream --ensure_route()--> selected Audio/Sink (or WirePlumber's
                                        choice when no device is pinned)

PipeWire side:
- DeviceType::Sink, so sinks are discoverable like inputs already were, plus the
  playback_* port mapping that Audio/Sink nodes need (monitor_* stays unmapped:
  linking a stream there would be a feedback loop).
- Link globals are tracked in the registry listener; GetLinks/DestroyGlobal make
  ensure_route() possible. It points a node at one target and prunes every other
  link leaving it, creating before pruning so the node is never left unlinked —
  otherwise WirePlumber's autoconnect would re-attach it behind our back.
  Replaces link_player_to_virtual_mic().

Audio side:
- PlayerPair wraps the two rodio Players of a track so transport controls reach
  both from one place instead of being fanned out at every call site.
- Streams are identified by diffing our own stream nodes before and after opening
  one, not by indexing a sorted list: PipeWire reuses freed node ids, so the
  stream opened second can end up with the lower id. Only nodes that look like
  ours are ever considered — pruning a stranger's node would silence another
  application. Opens are therefore sequential, and node discovery checks the
  graph before its first sleep to stay off the play latency path.
- Streams are dropped once nothing is playing, as before: an open stream keeps
  the audio device busy and stops laptops from suspending. The routing is
  rebuilt on the next play.
- Each track decodes twice, once per path. rodio's Buffered is the only shareable
  source and it cannot seek, which the position slider depends on.
- effective_gain() is the single home of the master * track * multiplier math and
  collapses non-finite or negative gains to silence.

Protocol, config and GUI:
- get/set_monitoring_volume, get/set_mic_volume, get/set_output, get_outputs.
- DaemonConfig gains default_output_name, default_monitoring_volume and
  default_mic_volume.
- Volumes arriving over IPC are validated: hotkeys store raw Request JSON, so a
  bad value can reach the daemon without passing through the CLI.
- The footer carries a monitoring slider and a mic slider, both running to 200%,
  plus an output combo box; with four widgets it no longer fits on one line and
  is laid out as two rows.
- SliderLatch owns the "local value wins while the user is interacting" logic
  every slider needs — the daemon is polled at 60 Hz, so without it a slider
  fights the user mid-drag and snaps back on release. That was previously
  copy-pasted per slider as a value/dragged/ignore-until triple.

Fixes along the way: setting the volume multiplier no longer overwrites the
master volume, and get_volume(Some(id)) reports the track's own volume instead of
the product of all three factors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(cli): add monitoring-volume, mic-volume and output commands

get/set volume keeps its old meaning — without --id it moves both masters at
once, which is the "make everything quieter" shortcut. The new commands address
one path each, and values above 1.0 pass through on purpose: amplifying what
goes into the microphone without deafening yourself is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gui): move device selection into settings

The footer tried to hold two combo boxes, two sliders and two buttons on one
line. Their labels alone run ~300px, so at the 800px minimum window size the
sliders and buttons were pushed off the edge and simply vanished.

Device pickers are set-once controls and belong next to the theme selector, so
they move to the settings screen. The footer keeps the two volume sliders, which
are the ones worth reaching for mid-call, and now fits at the minimum width.

The right-edge spacer is also clamped at zero: computed negative, it used to
shove the buttons out of view rather than merely crowding them.

Verified with screenshots at 800px and 1200px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gui): align the footer volume icons and sliders

The speaker icon and its slider sat a couple of pixels above the microphone
pair. A horizontal layout centres each widget against the row height known when
that widget is placed, so a row that grows while being filled leaves whatever
was added first sitting too high.

Every footer element is now allocated the same box height, which makes the
centring independent of placement order, and the row is given that height up
front. Measured from screenshots at 800px: icon ink centres were 776.5 and
780.5, now both 776.5; slider rails were 2.5px apart, now 0.5px, which is
sub-pixel rounding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gui): let the output device be unpinned again

The output picker listed devices but no way back, so choosing one was a one-way
door. It now offers "system default" as the first entry, which sends set_output
with an empty name; the daemon takes that as "stop pinning".

Unpinning deliberately leaves the existing link alone rather than tearing it
down. WirePlumber's autoconnect only runs when a node first appears, so removing
the link would strand the monitoring stream with no output at all. Streams close
as soon as playback stops, so the next sound opens a fresh node that gets routed
like any other application's.

Picking the default target ourselves was tried and reverted: the global
default.audio.sink is only a fallback, and a per-stream target.node overrides it.
On this machine that meant monitoring would have gone straight to the hardware
sink, bypassing the user's EasyEffects chain that WirePlumber routes it through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: trim comments and drop an abstraction that earned nothing

Removes the RouteTarget trait and its two unit-struct implementations. They
existed to let route() resolve a target lazily, but both call sites resolve one
line earlier just as well, so a trait, two types and an impl block collapse into
one parameter.

Narrows should_sync, commit and prune_links_from to private: all three were
public but only ever called from within their own module.

Comment pass: drops the decorative section dividers that this file never had,
and rewrites the ones that leaned on a particular machine's setup or narrated a
past bug. What is left explains things the code cannot: why streams close when
idle, why they are opened one at a time, why a link is created before stale ones
are pruned, why playback_* maps to inputs while monitor_* stays unmapped, and
why the footer row height is pinned up front.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:36:10 +03:00

502 lines
17 KiB
Rust

mod input;
mod update;
mod views;
use anyhow::{Result, anyhow};
use eframe::{NativeOptions, icon_data::from_png_bytes, run_native};
use egui::{Context, FontData, FontDefinitions, FontFamily, FontTweak, Vec2, ViewportBuilder};
use itertools::Itertools;
use pwsp_lib::{
types::{
audio_player::PlayerState,
config::{GuiConfig, HotkeyConfig},
gui::{AppState, AudioPlayerState},
socket::Request,
},
utils::gui::{
get_daemon_config, get_gui_config, make_request_async, make_request_sync,
start_app_state_thread, update_daemon_config,
},
};
use rfd::FileDialog;
use std::{
cmp::Ordering,
collections::{HashMap, HashSet},
fs,
hash::{DefaultHasher, Hash, Hasher},
path::{Path, PathBuf},
sync::{Arc, Mutex},
thread,
};
use system_fonts::{FontStyle, FoundFontSource, find_for_locale};
const SUPPORTED_EXTENSIONS: [&str; 13] = [
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi", "opus",
];
fn get_cache_file_for(path: &Path) -> PathBuf {
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
let hash = hasher.finish();
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("pwsp")
.join(format!("{:x}.json", hash))
}
struct SoundpadGui {
pub app_state: AppState,
pub config: GuiConfig,
pub audio_player_state: AudioPlayerState,
pub audio_player_state_shared: Arc<Mutex<AudioPlayerState>>,
}
impl SoundpadGui {
fn new(ctx: &Context) -> Self {
let audio_player_state = Arc::new(Mutex::new(AudioPlayerState::default()));
start_app_state_thread(audio_player_state.clone());
let config = get_gui_config();
ctx.set_zoom_factor(config.scale_factor);
let mut soundpad_gui = SoundpadGui {
app_state: AppState::default(),
config: config.clone(),
audio_player_state: AudioPlayerState::default(),
audio_player_state_shared: audio_player_state.clone(),
};
soundpad_gui.app_state.dirs = config.dirs;
soundpad_gui.app_state.hotkey_config = HotkeyConfig::load().unwrap_or_default();
soundpad_gui
}
pub fn play_toggle(&mut self) {
let (new_state, request) = {
let guard = self
.audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
match guard.state {
PlayerState::Playing => (Some(PlayerState::Paused), Some(Request::pause(None))),
PlayerState::Paused => (Some(PlayerState::Playing), Some(Request::resume(None))),
PlayerState::Stopped => (None, None),
}
};
if let Some(req) = request {
make_request_async(req);
}
if let Some(state) = new_state {
let mut guard = self
.audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.new_state = Some(state.clone());
guard.state = state;
}
}
pub fn open_file(&mut self) {
let file_dialog = FileDialog::new().add_filter("Audio File", &SUPPORTED_EXTENSIONS);
if let Some(path) = file_dialog.pick_file() {
self.play_file(&path, false);
}
}
pub fn add_dirs(&mut self) {
let file_dialog = FileDialog::new();
if let Some(paths) = file_dialog.pick_folders() {
for path in paths {
self.app_state.dirs.push(path);
}
self.app_state.dirs = self.app_state.dirs.iter().unique().cloned().collect();
self.config.dirs = self.app_state.dirs.clone();
self.config.save_to_file().ok();
}
}
pub fn open_dir(&mut self, path: &PathBuf, ctx: Option<Context>, force_rescan: bool) {
self.app_state.current_dir = Some(path.clone());
if !self.app_state.dir_cache.contains_key(path) || force_rescan {
match fs::read_dir(path) {
Ok(read_dir) => {
let files = read_dir
.filter_map(|res| res.ok())
.map(|entry| entry.path())
.collect::<Vec<_>>();
self.app_state.listed_files = files.iter().cloned().collect();
self.app_state.dir_cache.insert(path.clone(), files);
}
Err(e) => {
eprintln!("Failed to read directory {:?}: {}", path, e);
self.app_state.listed_files.clear();
}
}
} else {
self.app_state.listed_files = self
.app_state
.dir_cache
.get(path)
.unwrap()
.clone()
.into_iter()
.collect();
}
let is_scanning = self.app_state.scanning_dirs.lock().unwrap().contains(path);
let is_cached = self.app_state.recursive_files_cache.contains_key(path);
let has_scanned_this_session = self.app_state.scanned_this_session.contains(path);
let needs_scan = force_rescan || !has_scanned_this_session || !is_cached;
if !is_scanning && needs_scan {
self.app_state
.scanning_dirs
.lock()
.unwrap()
.insert(path.clone());
self.app_state.scanned_this_session.insert(path.clone());
let finished_scans = self.app_state.finished_scans.clone();
let scanning_dirs = self.app_state.scanning_dirs.clone();
let path_clone = path.clone();
thread::spawn(move || {
let cache_file = get_cache_file_for(&path_clone);
// 1. Try to load from disk cache if we don't have it in memory yet
if !is_cached
&& let Ok(data) = fs::read_to_string(&cache_file)
&& let Ok((all_files, dir_updates)) = serde_json::from_str::<(
Vec<PathBuf>,
HashMap<PathBuf, Vec<PathBuf>>,
)>(&data)
{
finished_scans.lock().unwrap().push((
path_clone.clone(),
all_files,
dir_updates,
));
if let Some(ctx) = ctx.as_ref() {
ctx.request_repaint();
}
}
// 2. Scan recursively
let mut all_files = Vec::new();
let mut dir_updates = HashMap::new();
let mut dirs_to_visit = vec![path_clone.clone()];
while let Some(dir) = dirs_to_visit.pop() {
if let Ok(entries) = fs::read_dir(&dir) {
let mut children = Vec::new();
for entry in entries.filter_map(|e| e.ok()) {
let p = entry.path();
if p.is_dir() {
dirs_to_visit.push(p.clone());
children.push(p);
} else if crate::gui::SUPPORTED_EXTENSIONS.contains(
&p.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
) {
all_files.push(p.clone());
children.push(p);
}
}
dir_updates.insert(dir, children);
}
}
// 3. Save to disk cache
if let Some(parent) = cache_file.parent() {
fs::create_dir_all(parent).ok();
}
if let Ok(json) = serde_json::to_string(&(all_files.clone(), dir_updates.clone())) {
fs::write(&cache_file, json).ok();
}
// 4. Send to UI
finished_scans
.lock()
.unwrap()
.push((path_clone.clone(), all_files, dir_updates));
scanning_dirs.lock().unwrap().remove(&path_clone);
if let Some(ctx) = ctx {
ctx.request_repaint();
}
});
}
}
pub fn play_file(&mut self, path: &Path, concurrent: bool) {
make_request_async(Request::play(&path.to_string_lossy(), concurrent));
}
pub fn set_input(&mut self, name: String) {
make_request_async(Request::set_input(&name));
if self.config.save_input
&& let Ok(mut daemon_config) = get_daemon_config()
{
daemon_config.default_input_name = Some(name);
update_daemon_config(&daemon_config).ok();
}
}
pub fn set_output(&mut self, name: String) {
make_request_async(Request::set_output(&name));
if self.config.save_input
&& let Ok(mut daemon_config) = get_daemon_config()
{
// Empty means "follow the system default", which is stored as no device.
daemon_config.default_output_name = Some(name).filter(|n| !n.is_empty());
update_daemon_config(&daemon_config).ok();
}
}
pub fn toggle_loop(&mut self, id: Option<u32>) {
make_request_async(Request::toggle_loop(id));
}
pub fn pause(&mut self, id: Option<u32>) {
make_request_async(Request::pause(id));
}
pub fn resume(&mut self, id: Option<u32>) {
make_request_async(Request::resume(id));
}
pub fn stop(&mut self, id: Option<u32>) {
make_request_async(Request::stop(id));
}
pub fn play_hotkey_slot(&mut self, slot: &str) {
make_request_async(Request::play_hotkey(slot));
}
pub fn get_filtered_files(
&self,
matching_dirs: &HashSet<PathBuf>,
matching_files: &HashSet<PathBuf>,
) -> Vec<PathBuf> {
let search_query = self.app_state.search_query.to_lowercase();
let search_query = search_query.trim();
let mut files: Vec<PathBuf> = self.app_state.listed_files.iter().cloned().collect();
let sort_order = self
.app_state
.current_dir
.as_ref()
.map(|d| self.config.get_sort_order(d))
.unwrap_or_default();
files.sort_by(|a, b| {
let a_is_dir = a.is_dir();
let b_is_dir = b.is_dir();
if a_is_dir && !b_is_dir {
Ordering::Less
} else if !a_is_dir && b_is_dir {
Ordering::Greater
} else {
sort_order.compare(a, b)
}
});
let is_cached = self
.app_state
.current_dir
.as_ref()
.is_some_and(|d| self.app_state.recursive_files_cache.contains_key(d));
if !search_query.is_empty() && is_cached {
files
.into_iter()
.filter(|p| {
if p.is_dir() {
matching_dirs.contains(p)
} else {
matching_files.contains(p)
}
})
.collect()
} else {
files
.into_iter()
.filter(|entry_path| {
if !entry_path.is_dir()
&& !SUPPORTED_EXTENSIONS.contains(
&entry_path
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
)
{
return false;
}
if !search_query.is_empty() {
let file_name =
entry_path.file_name().unwrap_or_default().to_string_lossy();
if !file_name.to_lowercase().contains(search_query) {
return false;
}
}
true
})
.collect()
}
}
}
fn add_font(font_name: &str, font_bytes: &[u8], fonts: &mut FontDefinitions) -> Result<()> {
let font_data = FontData::from_owned(font_bytes.to_vec()).tweak(FontTweak {
scale: 1.0,
hinting: Some(true),
..Default::default()
});
fonts
.font_data
.insert(font_name.to_owned(), font_data.into());
fonts
.families
.entry(FontFamily::Proportional)
.or_default()
.insert(0, font_name.to_owned());
fonts
.families
.entry(FontFamily::Monospace)
.or_default()
.insert(0, font_name.to_owned());
Ok(())
}
fn load_system_fonts(fonts: &mut FontDefinitions) -> Result<()> {
let (_, en_sans) = find_for_locale("en", FontStyle::Sans);
let (_, en_serif) = find_for_locale("en", FontStyle::Serif);
let (_, ja_sans) = find_for_locale("ja", FontStyle::Sans);
let (_, ar_sans) = find_for_locale("ar", FontStyle::Sans);
let system_fonts = [en_sans, en_serif, ja_sans, ar_sans].concat();
for font in system_fonts.iter().rev() {
let font_bytes = match &font.source {
FoundFontSource::Path(path) => fs::read(path)?,
FoundFontSource::Bytes(bytes) => bytes.to_vec(),
};
add_font(&font.key, &font_bytes, fonts)?;
}
Ok(())
}
pub async fn run() -> Result<()> {
const ICON: &[u8] = include_bytes!("../../assets/icon.png");
let options = NativeOptions {
centered: true,
viewport: ViewportBuilder::default()
.with_app_id("ru.arabianq.pwsp")
.with_inner_size(Vec2::new(1200.0, 800.0))
.with_min_inner_size(Vec2::new(800.0, 600.0))
.with_icon(from_png_bytes(ICON)?),
..Default::default()
};
match run_native(
"Pipewire Soundpad",
options,
Box::new(|cc| {
egui_material_icons::initialize(&cc.egui_ctx);
let mut fonts = FontDefinitions::default();
load_system_fonts(&mut fonts).ok();
cc.egui_ctx.set_fonts(fonts);
Ok(Box::new(SoundpadGui::new(&cc.egui_ctx)))
}),
) {
Ok(_) => {
let config = get_gui_config();
if config.pause_on_exit {
make_request_sync(Request::pause(None)).ok();
}
Ok(())
}
Err(e) => Err(anyhow!(e.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_filtered_files() {
let mut gui = SoundpadGui {
app_state: AppState::default(),
config: GuiConfig::default(),
audio_player_state: AudioPlayerState::default(),
audio_player_state_shared: Arc::new(Mutex::new(AudioPlayerState::default())),
};
// Create some dummy paths
// We will mock path properties using standard Rust PathBuf
let dir_a = PathBuf::from("a_dir");
let file_b = PathBuf::from("b_file.mp3");
let file_c = PathBuf::from("c_file.wav");
let file_txt = PathBuf::from("invalid.txt");
gui.app_state.listed_files.insert(dir_a.clone());
gui.app_state.listed_files.insert(file_b.clone());
gui.app_state.listed_files.insert(file_c.clone());
gui.app_state.listed_files.insert(file_txt.clone());
// Note: is_dir() check in get_filtered_files relies on physical filesystem properties.
// On the real OS filesystem, these paths don't exist, so they are treated as files.
// Unsupported extensions (like .txt) will be filtered out.
// So we expect only file_b and file_c, sorted alphabetically.
let empty_dirs = HashSet::new();
let empty_files = HashSet::new();
let filtered = gui.get_filtered_files(&empty_dirs, &empty_files);
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0], file_b);
assert_eq!(filtered[1], file_c);
// Test search query
gui.app_state.search_query = "c_fi".to_string();
let filtered_search = gui.get_filtered_files(&empty_dirs, &empty_files);
assert_eq!(filtered_search.len(), 1);
assert_eq!(filtered_search[0], file_c);
// Test sort order descending
gui.app_state.current_dir = Some(PathBuf::from("dummy_dir"));
gui.config.dirs_settings.insert(
PathBuf::from("dummy_dir"),
pwsp_lib::types::config::DirSettings {
sort_order: pwsp_lib::types::config::SortOrder::AlphabeticalDesc,
},
);
gui.app_state.search_query = String::new();
let filtered_desc = gui.get_filtered_files(&empty_dirs, &empty_files);
assert_eq!(filtered_desc.len(), 2);
assert_eq!(filtered_desc[0], file_c);
assert_eq!(filtered_desc[1], file_b);
}
}