mod input; mod update; mod views; use anyhow::{Result, anyhow}; use eframe::{HardwareAcceleration, 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, config::HotkeyConfig, gui::{AppState, AudioPlayerState}, socket::Request, }, utils::{ daemon::get_daemon_config, gui::{get_gui_config, make_request_async, make_request_sync, start_app_state_thread}, }, }; use rfd::FileDialog; use std::{ cmp::Ordering, fs, path::{Path, PathBuf}, sync::{Arc, Mutex}, }; 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", ]; struct SoundpadGui { pub app_state: AppState, pub config: GuiConfig, pub audio_player_state: AudioPlayerState, pub audio_player_state_shared: Arc>, } 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) { self.app_state.current_dir = Some(path.clone()); match path.read_dir() { Ok(read_dir) => { self.app_state.listed_files = read_dir .filter_map(|res| res.ok()) .map(|entry| entry.path()) .collect(); } Err(e) => { eprintln!("Failed to read directory {:?}: {}", path, e); self.app_state.listed_files.clear(); } } } 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 mut daemon_config = get_daemon_config(); daemon_config.default_input_name = Some(name); daemon_config.save_to_file().ok(); } } pub fn toggle_loop(&mut self, id: Option) { make_request_async(Request::toggle_loop(id)); } pub fn pause(&mut self, id: Option) { make_request_async(Request::pause(id)); } pub fn resume(&mut self, id: Option) { make_request_async(Request::resume(id)); } pub fn stop(&mut self, id: Option) { 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) -> Vec { let mut files: Vec = 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 search_query = self.app_state.search_query.to_lowercase(); let search_query = search_query.trim(); files .into_iter() .filter(|entry_path| { if entry_path.is_dir() { return true; } if !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() .to_string(); 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_override: 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 { vsync: true, centered: true, hardware_acceleration: HardwareAcceleration::Preferred, 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 filtered = gui.get_filtered_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(); 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(); assert_eq!(filtered_desc.len(), 2); assert_eq!(filtered_desc[0], file_c); assert_eq!(filtered_desc[1], file_b); } }