Files
pipewire-soundpad/src/gui/mod.rs
T
2026-05-15 21:41:12 +03:00

297 lines
8.8 KiB
Rust

mod draw;
mod input;
mod update;
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::{
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<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) {
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<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) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = self.app_state.listed_files.iter().cloned().collect();
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 {
a.cmp(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())),
}
}