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>, } 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, 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::>(); 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, HashMap>, )>(&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) { 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, matching_dirs: &HashSet, matching_files: &HashSet, ) -> Vec { let search_query = self.app_state.search_query.to_lowercase(); let search_query = search_query.trim(); 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 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); } }