mirror of
https://github.com/arabianq/pipewire-soundpad.git
synced 2026-06-19 12:13:32 +00:00
1096 lines
41 KiB
Rust
1096 lines
41 KiB
Rust
use crate::gui::SoundpadGui;
|
|
use egui::{
|
|
Align, AtomExt, Button, CollapsingHeader, Color32, ComboBox, CursorIcon, FontFamily, Label,
|
|
Layout, RichText, ScrollArea, Sense, Slider, TextEdit, Ui, Vec2,
|
|
};
|
|
use egui_dnd::dnd;
|
|
use egui_extras::{Column, TableBuilder};
|
|
use egui_material_icons::icons::*;
|
|
use pwsp::types::gui::AudioPlayerState;
|
|
use pwsp::types::socket::Request;
|
|
use pwsp::types::{audio_player::TrackInfo, gui::AppState};
|
|
use pwsp::utils::gui::{format_time_pair, make_request_async};
|
|
use rust_i18n::t;
|
|
use std::{
|
|
cmp::Ordering,
|
|
path::{Path, PathBuf},
|
|
time::Instant,
|
|
};
|
|
|
|
enum TrackAction {
|
|
Pause(u32),
|
|
Resume(u32),
|
|
ToggleLoop(u32),
|
|
Stop(u32),
|
|
}
|
|
|
|
enum HotkeyAction {
|
|
Remove(String),
|
|
Capture(String),
|
|
ClearChord(String),
|
|
Play(String),
|
|
}
|
|
|
|
enum FileAction {
|
|
Play(PathBuf, bool),
|
|
StopAndPlay(u32, PathBuf, bool),
|
|
AssignHotkey(PathBuf),
|
|
}
|
|
|
|
impl SoundpadGui {
|
|
fn get_volume_icon(volume: f32) -> &'static str {
|
|
if volume > 0.7 {
|
|
ICON_VOLUME_UP.codepoint
|
|
} else if volume <= 0.0 {
|
|
ICON_VOLUME_OFF.codepoint
|
|
} else if volume < 0.3 {
|
|
ICON_VOLUME_MUTE.codepoint
|
|
} else {
|
|
ICON_VOLUME_DOWN.codepoint
|
|
}
|
|
}
|
|
|
|
pub fn draw(&mut self, ui: &mut Ui) {
|
|
self.draw_header(ui);
|
|
self.draw_body(ui);
|
|
ui.separator();
|
|
self.draw_footer(ui);
|
|
}
|
|
|
|
pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) {
|
|
ui.centered_and_justified(|ui| {
|
|
ui.label(
|
|
RichText::new("Waiting for PWSP daemon to start...")
|
|
.size(34.0)
|
|
.monospace(),
|
|
);
|
|
});
|
|
}
|
|
|
|
pub fn draw_hotkey_capture(&mut self, ui: &mut Ui) {
|
|
ui.vertical_centered(|ui| {
|
|
ui.add_space(ui.available_height() / 3.0);
|
|
ui.label(
|
|
RichText::new(t!("gui.hotkeys.capture.header"))
|
|
.size(18.0)
|
|
.color(Color32::YELLOW)
|
|
.monospace(),
|
|
);
|
|
ui.add_space(10.0);
|
|
let target = if let Some(slot) = &self.app_state.assigning_hotkey_slot {
|
|
format!("{} '{}'", t!("gui.hotkeys.capture.for"), slot)
|
|
} else if let Some(path) = &self.app_state.assigning_hotkey_for_file {
|
|
format!(
|
|
"{} '{}'",
|
|
t!("gui.hotkeys.capture.for"),
|
|
path.file_name().unwrap_or_default().to_string_lossy()
|
|
)
|
|
} else {
|
|
String::new()
|
|
};
|
|
ui.label(RichText::new(target).size(16.0));
|
|
ui.add_space(10.0);
|
|
ui.label(t!("gui.hotkeys.capture.cancel"));
|
|
});
|
|
}
|
|
|
|
pub fn draw_settings(&mut self, ui: &mut Ui) {
|
|
ui.vertical(|ui| {
|
|
ui.spacing_mut().item_spacing.y = 5.0;
|
|
// --------- Back Button and Title ----------
|
|
ui.horizontal_top(|ui| {
|
|
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
|
|
let back_button_response = ui.add(back_button);
|
|
if back_button_response.clicked() {
|
|
self.app_state.show_settings = false;
|
|
}
|
|
|
|
ui.add_space(ui.available_width() / 2.0 - 40.0);
|
|
|
|
ui.label(
|
|
RichText::new(t!("gui.settings.header"))
|
|
.color(Color32::WHITE)
|
|
.monospace(),
|
|
);
|
|
});
|
|
// --------------------------------
|
|
|
|
ui.separator();
|
|
ui.add_space(20.0);
|
|
|
|
// --------- Checkboxes ----------
|
|
let save_volume_response = ui.checkbox(
|
|
&mut self.config.save_volume,
|
|
t!("gui.settings.remember_volume"),
|
|
);
|
|
let save_input_response =
|
|
ui.checkbox(&mut self.config.save_input, t!("gui.settings.remember_mic"));
|
|
let save_scale_response = ui.checkbox(
|
|
&mut self.config.save_scale_factor,
|
|
t!("gui.settings.remember_ui_scale"),
|
|
);
|
|
let pause_on_exit_response = ui.checkbox(
|
|
&mut self.config.pause_on_exit,
|
|
t!("gui.settings.pause_on_window_close"),
|
|
);
|
|
|
|
if save_volume_response.changed()
|
|
|| save_input_response.changed()
|
|
|| save_scale_response.changed()
|
|
|| pause_on_exit_response.changed()
|
|
{
|
|
self.config.save_to_file().ok();
|
|
}
|
|
// --------------------------------
|
|
|
|
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
|
|
ui.label(t!(
|
|
"gui.settings.version",
|
|
version = env!("CARGO_PKG_VERSION")
|
|
));
|
|
});
|
|
});
|
|
}
|
|
|
|
pub fn draw_hotkeys(&mut self, ui: &mut Ui) {
|
|
ui.vertical(|ui| {
|
|
ui.spacing_mut().item_spacing.y = 5.0;
|
|
|
|
self.draw_hotkeys_header(ui);
|
|
ui.separator();
|
|
|
|
self.draw_hotkeys_search(ui);
|
|
ui.separator();
|
|
ui.add_space(5.0);
|
|
|
|
let action = self.draw_hotkeys_table(ui);
|
|
|
|
if let Some(action) = action {
|
|
self.handle_hotkey_action(action);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn draw_hotkeys_header(&mut self, ui: &mut Ui) {
|
|
ui.horizontal(|ui| {
|
|
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
|
|
if ui.add(back_button).clicked() {
|
|
self.app_state.show_hotkeys = false;
|
|
}
|
|
|
|
ui.vertical_centered(|ui| {
|
|
ui.label(
|
|
RichText::new(t!("gui.hotkeys.header"))
|
|
.color(Color32::WHITE)
|
|
.monospace(),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
fn draw_hotkeys_search(&mut self, ui: &mut Ui) {
|
|
ui.horizontal(|ui| {
|
|
ui.menu_button(
|
|
format!(
|
|
"{} {}",
|
|
ICON_ADD.codepoint,
|
|
t!("gui.hotkeys.add_command_select")
|
|
),
|
|
|ui| {
|
|
let mut selected_cmd = None;
|
|
if ui.button(t!("gui.hotkeys.toggle_pause_command")).clicked() {
|
|
selected_cmd = Some(("cmd_toggle_pause", Request::toggle_pause(None)));
|
|
}
|
|
if ui.button(t!("gui.hotkeys.stop_playback_command")).clicked() {
|
|
selected_cmd = Some(("cmd_stop", Request::stop(None)));
|
|
}
|
|
if ui
|
|
.button(t!("gui.hotkeys.pause_playback_command"))
|
|
.clicked()
|
|
{
|
|
selected_cmd = Some(("cmd_pause", Request::pause(None)));
|
|
}
|
|
if ui
|
|
.button(t!("gui.hotkeys.resume_playback_command"))
|
|
.clicked()
|
|
{
|
|
selected_cmd = Some(("cmd_resume", Request::resume(None)));
|
|
}
|
|
if ui.button(t!("gui.hotkeys.toggle_loop_command")).clicked() {
|
|
selected_cmd = Some(("cmd_toggle_loop", Request::toggle_loop(None)));
|
|
}
|
|
|
|
if let Some((slot_name, req)) = selected_cmd {
|
|
make_request_async(Request::set_hotkey_action(slot_name, &req));
|
|
self.app_state
|
|
.hotkey_config
|
|
.set_slot(slot_name.to_string(), req);
|
|
self.app_state.assigning_hotkey_slot = Some(slot_name.to_string());
|
|
self.app_state.hotkey_capture_active = true;
|
|
ui.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
ui.add_space(10.0);
|
|
|
|
ui.add(
|
|
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
|
|
.hint_text(t!("gui.hotkeys.search_placeholder"))
|
|
.desired_width(f32::INFINITY),
|
|
);
|
|
});
|
|
}
|
|
|
|
fn draw_hotkeys_table(&mut self, ui: &mut Ui) -> Option<HotkeyAction> {
|
|
let conflicts = self.app_state.hotkey_config.find_conflicts();
|
|
let conflict_slots: std::collections::HashSet<&str> =
|
|
conflicts.into_iter().flat_map(|(a, b)| [a, b]).collect();
|
|
|
|
let search = self.app_state.hotkey_search_query.to_lowercase();
|
|
let mut action: Option<HotkeyAction> = None;
|
|
|
|
let slots: Vec<_> = self
|
|
.app_state
|
|
.hotkey_config
|
|
.slots
|
|
.iter()
|
|
.filter(|s| {
|
|
if search.is_empty() {
|
|
return true;
|
|
}
|
|
s.slot.to_lowercase().contains(&search)
|
|
|| format!("{:?}", s.action).to_lowercase().contains(&search)
|
|
|| s.key_chord
|
|
.as_deref()
|
|
.unwrap_or("")
|
|
.to_lowercase()
|
|
.contains(&search)
|
|
})
|
|
.collect();
|
|
|
|
let available_width = ui.available_width();
|
|
let col_width = (available_width / 4.0).max(80.0);
|
|
|
|
TableBuilder::new(ui)
|
|
.striped(true)
|
|
.column(Column::exact(col_width).clip(true)) // Slot
|
|
.column(Column::exact(col_width).clip(true)) // Sound / Action name
|
|
.column(Column::exact(col_width).clip(true)) // Key Chord
|
|
.column(Column::exact(col_width).clip(true)) // Actions
|
|
.header(30.0, |mut header| {
|
|
header.col(|ui| {
|
|
ui.label(
|
|
RichText::new(t!("gui.hotkeys.column_slot"))
|
|
.strong()
|
|
.monospace()
|
|
.color(Color32::LIGHT_GRAY),
|
|
);
|
|
});
|
|
header.col(|ui| {
|
|
ui.label(
|
|
RichText::new(t!("gui.hotkeys.column_sound"))
|
|
.strong()
|
|
.monospace()
|
|
.color(Color32::LIGHT_GRAY),
|
|
);
|
|
});
|
|
header.col(|ui| {
|
|
ui.label(
|
|
RichText::new(t!("gui.hotkeys.column_key_chord"))
|
|
.strong()
|
|
.monospace()
|
|
.color(Color32::LIGHT_GRAY),
|
|
);
|
|
});
|
|
header.col(|ui| {
|
|
ui.label(
|
|
RichText::new(t!("gui.hotkeys.column_actions"))
|
|
.strong()
|
|
.monospace()
|
|
.color(Color32::LIGHT_GRAY),
|
|
);
|
|
});
|
|
})
|
|
.body(|mut body| {
|
|
if slots.is_empty() {
|
|
body.row(30.0, |mut row| {
|
|
row.col(|_| {});
|
|
row.col(|ui| {
|
|
ui.label(
|
|
RichText::new(t!("gui.hotkeys.no_hotkeys_configured"))
|
|
.color(Color32::GRAY),
|
|
);
|
|
});
|
|
row.col(|_| {});
|
|
row.col(|_| {});
|
|
});
|
|
return;
|
|
}
|
|
|
|
for slot in &slots {
|
|
body.row(30.0, |mut row| {
|
|
// Column 1: Slot
|
|
row.col(|ui| {
|
|
ui.horizontal(|ui| {
|
|
if conflict_slots.contains(slot.slot.as_str()) {
|
|
ui.label(
|
|
RichText::new(ICON_WARNING.codepoint)
|
|
.color(Color32::from_rgb(255, 165, 0)),
|
|
)
|
|
.on_hover_text("Key chord conflict");
|
|
}
|
|
ui.add(
|
|
Label::new(RichText::new(&slot.slot).monospace()).truncate(),
|
|
);
|
|
});
|
|
});
|
|
|
|
// Column 2: Sound / Action name
|
|
row.col(|ui| {
|
|
let action_name = match slot.action.name.as_str() {
|
|
"play" => {
|
|
if let Some(file_path_str) = slot.action.args.get("file_path") {
|
|
Path::new(file_path_str)
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_string()
|
|
} else {
|
|
"Play".to_string()
|
|
}
|
|
}
|
|
"toggle_pause" => "Toggle Pause".to_string(),
|
|
"pause" => "Pause Playback".to_string(),
|
|
"resume" => "Resume Playback".to_string(),
|
|
"stop" => "Stop Playback".to_string(),
|
|
"toggle_loop" => "Toggle Loop".to_string(),
|
|
other => other.to_string(),
|
|
};
|
|
ui.add(Label::new(RichText::new(action_name).monospace()).truncate());
|
|
});
|
|
|
|
// Column 3: Key Chord
|
|
row.col(|ui| {
|
|
let chord_text = slot.key_chord.as_deref().unwrap_or("(none)");
|
|
ui.add(
|
|
Label::new(RichText::new(chord_text).monospace().color(
|
|
if slot.key_chord.is_some() {
|
|
Color32::from_rgb(100, 200, 100)
|
|
} else {
|
|
Color32::GRAY
|
|
},
|
|
))
|
|
.truncate(),
|
|
);
|
|
});
|
|
|
|
// Column 4: Actions
|
|
row.col(|ui| {
|
|
ui.horizontal(|ui| {
|
|
if ui
|
|
.add(Button::new(ICON_DELETE).frame(false))
|
|
.on_hover_text("Remove slot")
|
|
.clicked()
|
|
{
|
|
action = Some(HotkeyAction::Remove(slot.slot.clone()));
|
|
}
|
|
if ui
|
|
.add(Button::new(ICON_KEYBOARD).frame(false))
|
|
.on_hover_text("Set key chord")
|
|
.clicked()
|
|
{
|
|
action = Some(HotkeyAction::Capture(slot.slot.clone()));
|
|
}
|
|
if slot.key_chord.is_some()
|
|
&& ui
|
|
.add(Button::new(ICON_BACKSPACE).frame(false))
|
|
.on_hover_text("Clear key chord")
|
|
.clicked()
|
|
{
|
|
action = Some(HotkeyAction::ClearChord(slot.slot.clone()));
|
|
}
|
|
if ui
|
|
.add(Button::new(ICON_PLAY_ARROW).frame(false))
|
|
.on_hover_text("Play")
|
|
.clicked()
|
|
{
|
|
action = Some(HotkeyAction::Play(slot.slot.clone()));
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
action
|
|
}
|
|
|
|
fn handle_hotkey_action(&mut self, action: HotkeyAction) {
|
|
match action {
|
|
HotkeyAction::Remove(slot) => {
|
|
make_request_async(Request::clear_hotkey(&slot));
|
|
self.app_state.hotkey_config.remove_slot(&slot);
|
|
}
|
|
HotkeyAction::Capture(slot) => {
|
|
self.app_state.assigning_hotkey_slot = Some(slot);
|
|
self.app_state.hotkey_capture_active = true;
|
|
}
|
|
HotkeyAction::ClearChord(slot) => {
|
|
make_request_async(Request::clear_hotkey_key(&slot));
|
|
self.app_state.hotkey_config.set_key_chord(&slot, None);
|
|
}
|
|
HotkeyAction::Play(slot) => {
|
|
self.play_hotkey_slot(&slot);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn draw_header(&mut self, ui: &mut Ui) {
|
|
ui.vertical_centered_justified(|ui| {
|
|
if self.audio_player_state.tracks.is_empty() {
|
|
ui.label("No tracks playing");
|
|
return;
|
|
}
|
|
|
|
let mut action = None;
|
|
|
|
for track in &self.audio_player_state.tracks {
|
|
CollapsingHeader::new(
|
|
RichText::new(
|
|
track
|
|
.path
|
|
.file_stem()
|
|
.unwrap_or_default()
|
|
.to_str()
|
|
.unwrap_or_default(),
|
|
)
|
|
.color(Color32::WHITE)
|
|
.family(FontFamily::Monospace),
|
|
)
|
|
.default_open(true)
|
|
.show(ui, |ui| {
|
|
if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, track) {
|
|
action = Some(act);
|
|
}
|
|
});
|
|
ui.separator();
|
|
}
|
|
|
|
if let Some(action) = action {
|
|
match action {
|
|
TrackAction::Pause(id) => self.pause(Some(id)),
|
|
TrackAction::Resume(id) => self.resume(Some(id)),
|
|
TrackAction::ToggleLoop(id) => self.toggle_loop(Some(id)),
|
|
TrackAction::Stop(id) => self.stop(Some(id)),
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn draw_playback_controls(ui: &mut Ui, track: &TrackInfo) -> Option<TrackAction> {
|
|
let mut action = None;
|
|
|
|
let play_button = Button::new(if track.paused {
|
|
ICON_PLAY_ARROW
|
|
} else {
|
|
ICON_PAUSE
|
|
})
|
|
.corner_radius(15.0);
|
|
|
|
if ui.add_sized([30.0, 30.0], play_button).clicked() {
|
|
action = Some(if track.paused {
|
|
TrackAction::Resume(track.id)
|
|
} else {
|
|
TrackAction::Pause(track.id)
|
|
});
|
|
}
|
|
|
|
let loop_button = Button::new(
|
|
RichText::new(if track.looped {
|
|
ICON_REPEAT_ONE
|
|
} else {
|
|
ICON_REPEAT
|
|
})
|
|
.size(18.0),
|
|
)
|
|
.frame(false);
|
|
|
|
if ui.add_sized([15.0, 30.0], loop_button).clicked() {
|
|
action = Some(TrackAction::ToggleLoop(track.id));
|
|
}
|
|
|
|
action
|
|
}
|
|
|
|
fn draw_position_control(
|
|
ui: &mut Ui,
|
|
ui_state: &mut pwsp::types::gui::TrackUiState,
|
|
track: &TrackInfo,
|
|
default_slider_width: f32,
|
|
) {
|
|
let duration = track.duration.unwrap_or(1.0);
|
|
let position_slider = Slider::new(&mut ui_state.position_slider_value, 0.0..=duration)
|
|
.show_value(false)
|
|
.step_by(0.01);
|
|
|
|
let position_slider_width = ui.available_width()
|
|
- (30.0 * 3.0)
|
|
- default_slider_width
|
|
- (ui.spacing().item_spacing.x * 6.0);
|
|
|
|
ui.spacing_mut().slider_width = position_slider_width;
|
|
if ui.add_sized([30.0, 30.0], position_slider).drag_stopped() {
|
|
ui_state.position_dragged = true;
|
|
}
|
|
|
|
let time_label =
|
|
Label::new(RichText::new(format_time_pair(track.position, duration)).monospace());
|
|
ui.add_sized([30.0, 30.0], time_label);
|
|
}
|
|
|
|
fn draw_volume_control(
|
|
ui: &mut Ui,
|
|
ui_state: &mut pwsp::types::gui::TrackUiState,
|
|
track: &TrackInfo,
|
|
default_slider_width: f32,
|
|
) {
|
|
let volume_icon = Self::get_volume_icon(track.volume);
|
|
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
|
|
ui.add_sized([30.0, 30.0], volume_label)
|
|
.on_hover_text(format!("Volume: {:.0}%", track.volume * 100.0));
|
|
|
|
let volume_slider = Slider::new(&mut ui_state.volume_slider_value, 0.0..=1.0)
|
|
.show_value(false)
|
|
.step_by(0.01);
|
|
|
|
ui.spacing_mut().slider_width = default_slider_width - 30.0;
|
|
ui.spacing_mut().item_spacing.x = 0.0;
|
|
|
|
if ui.add_sized([30.0, 30.0], volume_slider).drag_stopped() {
|
|
ui_state.volume_dragged = true;
|
|
}
|
|
}
|
|
|
|
fn draw_stop_control(ui: &mut Ui, track: &TrackInfo) -> Option<TrackAction> {
|
|
let stop_button = Button::new(ICON_CLOSE).frame(false);
|
|
if ui.add_sized([30.0, 30.0], stop_button).clicked() {
|
|
Some(TrackAction::Stop(track.id))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn draw_track_control(
|
|
ui: &mut Ui,
|
|
app_state: &mut AppState,
|
|
track: &TrackInfo,
|
|
) -> Option<TrackAction> {
|
|
let ui_state = app_state.track_ui_states.entry(track.id).or_default();
|
|
|
|
let should_update_position = !ui_state.position_dragged
|
|
&& ui_state
|
|
.ignore_position_update_until
|
|
.map(|t| Instant::now() > t)
|
|
.unwrap_or(true);
|
|
|
|
if should_update_position {
|
|
ui_state.position_slider_value = track.position;
|
|
}
|
|
|
|
let should_update_volume = !ui_state.volume_dragged
|
|
&& ui_state
|
|
.ignore_volume_update_until
|
|
.map(|t| Instant::now() > t)
|
|
.unwrap_or(true);
|
|
|
|
if should_update_volume {
|
|
ui_state.volume_slider_value = track.volume;
|
|
}
|
|
|
|
let mut action = None;
|
|
|
|
ui.horizontal_top(|ui| {
|
|
if let Some(act) = Self::draw_playback_controls(ui, track) {
|
|
action = Some(act);
|
|
}
|
|
|
|
let default_slider_width = ui.spacing().slider_width;
|
|
Self::draw_position_control(ui, ui_state, track, default_slider_width);
|
|
Self::draw_volume_control(ui, ui_state, track, default_slider_width);
|
|
|
|
if let Some(act) = Self::draw_stop_control(ui, track) {
|
|
action = Some(act);
|
|
}
|
|
});
|
|
|
|
action
|
|
}
|
|
|
|
fn draw_body(&mut self, ui: &mut Ui) {
|
|
let left_panel_width = self
|
|
.config
|
|
.left_panel_width
|
|
.max(100.0)
|
|
.min(ui.available_width() - 100.0);
|
|
let dirs_size = Vec2::new(left_panel_width, ui.available_height() - 40.0);
|
|
|
|
ui.horizontal(|ui| {
|
|
self.draw_dirs(ui, dirs_size);
|
|
|
|
let (rect, response) = ui.allocate_at_least(
|
|
Vec2::new(ui.spacing().item_spacing.x, ui.available_height()),
|
|
Sense::click_and_drag(),
|
|
);
|
|
|
|
if ui.is_rect_visible(rect) {
|
|
let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
|
|
ui.painter().vline(rect.center().x, rect.y_range(), stroke);
|
|
}
|
|
|
|
let vertical_separator_response =
|
|
response.on_hover_and_drag_cursor(CursorIcon::ResizeHorizontal);
|
|
|
|
if vertical_separator_response.dragged() {
|
|
self.config.left_panel_width += vertical_separator_response.drag_delta().x;
|
|
self.config.left_panel_width = self.config.left_panel_width.clamp(100.0, 500.0);
|
|
}
|
|
|
|
if vertical_separator_response.drag_stopped() {
|
|
self.config.save_to_file().ok();
|
|
}
|
|
|
|
let files_size = Vec2::new(ui.available_width(), ui.available_height() - 40.0);
|
|
self.draw_files(ui, files_size);
|
|
});
|
|
}
|
|
|
|
fn draw_dirs(&mut self, ui: &mut Ui, area_size: Vec2) {
|
|
ui.vertical(|ui| {
|
|
ui.set_min_width(area_size.x);
|
|
ui.set_min_height(area_size.y);
|
|
|
|
ScrollArea::vertical().id_salt(0).show(ui, |ui| {
|
|
ui.set_min_width(area_size.x);
|
|
|
|
let mut dirs = self.app_state.dirs.clone();
|
|
|
|
dnd(ui, "dnd_directories").show_vec(&mut dirs, |ui, item, handle, _state| {
|
|
let path = item.clone();
|
|
ui.horizontal(|ui| {
|
|
handle.ui(ui, |ui| {
|
|
ui.label(ICON_DRAG_INDICATOR.codepoint);
|
|
});
|
|
let name = path
|
|
.file_name()
|
|
.map(|s| s.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
|
|
|
let mut dir_button_text = RichText::new(name.clone());
|
|
if let Some(current_dir) = &self.app_state.current_dir
|
|
&& current_dir.eq(&path)
|
|
{
|
|
dir_button_text = dir_button_text.color(Color32::WHITE);
|
|
}
|
|
|
|
let dir_button =
|
|
Button::new(dir_button_text.atom_max_width(area_size.x)).frame(false);
|
|
|
|
let dir_button_response = ui.add(dir_button);
|
|
if dir_button_response.clicked() {
|
|
self.open_dir(&path);
|
|
}
|
|
|
|
let delete_dir_button = Button::new(ICON_DELETE).frame(false);
|
|
let delete_dir_button_response =
|
|
ui.add_sized([18.0, 18.0], delete_dir_button);
|
|
if delete_dir_button_response.clicked() {
|
|
self.app_state.dirs_to_remove.insert(path.clone());
|
|
}
|
|
|
|
// Context menu
|
|
dir_button_response.context_menu(|ui| {
|
|
if ui
|
|
.button(format!(
|
|
"{} {}",
|
|
ICON_OPEN_IN_NEW.codepoint,
|
|
t!("gui.context.dirs.open")
|
|
))
|
|
.clicked()
|
|
{
|
|
self.open_dir(&path);
|
|
}
|
|
|
|
if ui
|
|
.button(format!(
|
|
"{} {}",
|
|
ICON_OPEN_IN_BROWSER.codepoint,
|
|
t!("gui.context.dirs.open_in_fm")
|
|
))
|
|
.clicked()
|
|
&& let Err(e) = opener::open(&path)
|
|
{
|
|
eprintln!("Failed to open file manager: {}", e);
|
|
}
|
|
|
|
ui.separator();
|
|
|
|
if ui
|
|
.button(format!(
|
|
"{} {}",
|
|
ICON_DELETE.codepoint,
|
|
t!("gui.context.dirs.remove")
|
|
))
|
|
.clicked()
|
|
{
|
|
self.app_state.dirs_to_remove.insert(path.clone());
|
|
}
|
|
});
|
|
});
|
|
});
|
|
self.app_state.dirs = dirs;
|
|
|
|
ui.horizontal(|ui| {
|
|
let add_dirs_button = Button::new(ICON_ADD).frame(false);
|
|
let add_dirs_button_response = ui.add_sized([18.0, 18.0], add_dirs_button);
|
|
if add_dirs_button_response.clicked() {
|
|
self.add_dirs();
|
|
}
|
|
});
|
|
|
|
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
|
|
let play_file_button = Button::new(t!("gui.play_file_button"));
|
|
let play_file_button_response = ui.add(play_file_button);
|
|
if play_file_button_response.clicked() {
|
|
self.open_file();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) {
|
|
ui.vertical(|ui| {
|
|
ui.horizontal(|ui| {
|
|
let search_field_response = ui.add_sized(
|
|
[ui.available_width(), 22.0],
|
|
TextEdit::singleline(&mut self.app_state.search_query)
|
|
.hint_text(t!("gui.search_placeholder")),
|
|
);
|
|
|
|
if self.app_state.force_focus_search {
|
|
search_field_response.request_focus();
|
|
self.app_state.force_focus_search = false;
|
|
}
|
|
|
|
self.app_state.search_field_id = Some(search_field_response.id);
|
|
});
|
|
|
|
ui.separator();
|
|
|
|
ScrollArea::vertical().id_salt(1).show(ui, |ui| {
|
|
ui.set_min_width(area_size.x);
|
|
ui.set_min_height(area_size.y);
|
|
|
|
ui.vertical(|ui| {
|
|
let mut actions = Vec::new();
|
|
let files = self.get_filtered_files();
|
|
for entry_path in files {
|
|
Self::draw_tree_node(
|
|
ui,
|
|
entry_path,
|
|
&mut self.app_state,
|
|
&self.audio_player_state,
|
|
&mut actions,
|
|
);
|
|
}
|
|
|
|
for action in actions {
|
|
match action {
|
|
FileAction::Play(path, concurrent) => self.play_file(&path, concurrent),
|
|
FileAction::StopAndPlay(id, path, concurrent) => {
|
|
self.stop(Some(id));
|
|
self.play_file(&path, concurrent);
|
|
}
|
|
FileAction::AssignHotkey(path) => {
|
|
self.app_state.assigning_hotkey_for_file = Some(path);
|
|
self.app_state.hotkey_capture_active = true;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
fn draw_tree_node(
|
|
ui: &mut Ui,
|
|
path: std::path::PathBuf,
|
|
app_state: &mut AppState,
|
|
audio_player_state: &AudioPlayerState,
|
|
actions: &mut Vec<FileAction>,
|
|
) {
|
|
if path.is_dir() {
|
|
let dir_name = path
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_string();
|
|
CollapsingHeader::new(dir_name)
|
|
.id_salt(&path)
|
|
.show(ui, |ui| {
|
|
let children = if let Some(cached) = app_state.dir_cache.get(&path) {
|
|
cached.clone()
|
|
} else {
|
|
let mut read = Vec::new();
|
|
if let Ok(entries) = std::fs::read_dir(&path) {
|
|
for entry in entries.filter_map(|e| e.ok()) {
|
|
read.push(entry.path());
|
|
}
|
|
}
|
|
read.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)
|
|
}
|
|
});
|
|
app_state.dir_cache.insert(path.clone(), read.clone());
|
|
read
|
|
};
|
|
|
|
let search_query = app_state.search_query.to_lowercase();
|
|
let search_query = search_query.trim();
|
|
|
|
for child in children {
|
|
if !child.is_dir() {
|
|
if !crate::gui::SUPPORTED_EXTENSIONS.contains(
|
|
&child
|
|
.extension()
|
|
.unwrap_or_default()
|
|
.to_str()
|
|
.unwrap_or_default(),
|
|
) {
|
|
continue;
|
|
}
|
|
if !search_query.is_empty() {
|
|
let file_name = child
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_string();
|
|
if !file_name.to_lowercase().contains(search_query) {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
Self::draw_tree_node(ui, child, app_state, audio_player_state, actions);
|
|
}
|
|
});
|
|
} else {
|
|
let file_name = path
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_string();
|
|
|
|
ui.horizontal(|ui| {
|
|
// Hotkey badge
|
|
let mut hotkey_badge = None;
|
|
for slot in &app_state.hotkey_config.slots {
|
|
if slot.action.name == "play"
|
|
&& let Some(file_path_str) = slot.action.args.get("file_path")
|
|
&& Path::new(file_path_str) == path
|
|
{
|
|
if let Some(chord) = &slot.key_chord {
|
|
hotkey_badge = Some(format!("[{}]", chord));
|
|
} else {
|
|
hotkey_badge = Some(format!("[{}]", slot.slot));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if let Some(badge) = &hotkey_badge {
|
|
ui.label(
|
|
RichText::new(badge)
|
|
.small()
|
|
.monospace()
|
|
.color(Color32::from_rgb(100, 200, 100)),
|
|
);
|
|
}
|
|
|
|
let file_button_text = RichText::new(&file_name);
|
|
|
|
let file_button = Button::new(file_button_text).frame(false).truncate();
|
|
let file_button_response = ui.add(file_button);
|
|
if file_button_response.clicked() {
|
|
ui.input(|i| {
|
|
if i.modifiers.ctrl {
|
|
actions.push(FileAction::Play(path.clone(), true));
|
|
} else if i.modifiers.shift
|
|
&& let Some(last_track) = audio_player_state.tracks.last()
|
|
{
|
|
actions.push(FileAction::StopAndPlay(
|
|
last_track.id,
|
|
path.clone(),
|
|
true,
|
|
));
|
|
} else {
|
|
actions.push(FileAction::Play(path.clone(), false));
|
|
}
|
|
});
|
|
}
|
|
|
|
// Context menu
|
|
file_button_response.context_menu(|ui| {
|
|
if ui
|
|
.button(format!(
|
|
"{} {}",
|
|
ICON_BOLT.codepoint,
|
|
t!("gui.context.files.play_solo")
|
|
))
|
|
.clicked()
|
|
{
|
|
actions.push(FileAction::Play(path.clone(), false));
|
|
}
|
|
|
|
if ui
|
|
.button(format!(
|
|
"{} {}",
|
|
ICON_ADD.codepoint,
|
|
t!("gui.context.files.add_new")
|
|
))
|
|
.clicked()
|
|
{
|
|
actions.push(FileAction::Play(path.clone(), true));
|
|
}
|
|
|
|
if ui
|
|
.button(format!(
|
|
"{} {}",
|
|
ICON_SWAP_HORIZ.codepoint,
|
|
t!("gui.context.files.replace_last")
|
|
))
|
|
.clicked()
|
|
&& let Some(last_track) = audio_player_state.tracks.last()
|
|
{
|
|
actions.push(FileAction::StopAndPlay(last_track.id, path.clone(), true));
|
|
}
|
|
|
|
ui.separator();
|
|
|
|
if ui
|
|
.button(format!(
|
|
"{} {}",
|
|
ICON_OPEN_IN_BROWSER.codepoint,
|
|
t!("gui.context.files.show_in_fm")
|
|
))
|
|
.clicked()
|
|
&& let Err(e) = opener::reveal(&path)
|
|
{
|
|
eprintln!("Failed to open file manager: {}", e);
|
|
}
|
|
|
|
ui.separator();
|
|
|
|
if ui
|
|
.button(format!(
|
|
"{} {}",
|
|
ICON_KEYBOARD.codepoint,
|
|
t!("gui.context.files.asign_hotkey")
|
|
))
|
|
.clicked()
|
|
{
|
|
actions.push(FileAction::AssignHotkey(path.clone()));
|
|
ui.close();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
fn draw_footer(&mut self, ui: &mut Ui) {
|
|
ui.add_space(5.0);
|
|
ui.horizontal(|ui| {
|
|
// ---------- Microphone selection ----------
|
|
let mics = &self.audio_player_state.all_inputs_sorted;
|
|
|
|
let mut selected_input = self.audio_player_state.current_input.to_owned();
|
|
let prev_input = selected_input.to_owned();
|
|
ComboBox::from_label(t!("gui.choose_mic_select"))
|
|
.height(30.0)
|
|
.selected_text(
|
|
self.audio_player_state
|
|
.all_inputs
|
|
.get(&selected_input)
|
|
.unwrap_or(&String::new()),
|
|
)
|
|
.show_ui(ui, |ui| {
|
|
for (name, nick) in mics {
|
|
ui.selectable_value(&mut selected_input, name.clone(), nick);
|
|
}
|
|
});
|
|
|
|
if selected_input != prev_input {
|
|
self.set_input(selected_input);
|
|
}
|
|
// --------------------------------
|
|
|
|
// ---------- Master Volume Slider ----------
|
|
let volume_icon = Self::get_volume_icon(self.audio_player_state.volume);
|
|
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
|
|
ui.add_sized([18.0, 18.0], volume_label)
|
|
.on_hover_text(format!(
|
|
"Master Volume: {:.0}%",
|
|
self.audio_player_state.volume * 100.0
|
|
));
|
|
|
|
let should_update_volume = !self.app_state.volume_dragged
|
|
&& self
|
|
.app_state
|
|
.ignore_volume_update_until
|
|
.map(|t| Instant::now() > t)
|
|
.unwrap_or(true);
|
|
|
|
if should_update_volume {
|
|
self.app_state.volume_slider_value = self.audio_player_state.volume;
|
|
}
|
|
|
|
let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0)
|
|
.show_value(false)
|
|
.step_by(0.01);
|
|
let volume_slider_response = ui.add_sized([150.0, 18.0], volume_slider);
|
|
if volume_slider_response.drag_stopped() {
|
|
self.app_state.volume_dragged = true;
|
|
}
|
|
// ------------------------------------------
|
|
|
|
ui.add_space(ui.available_width() - 18.0 * 2.0 - ui.spacing().item_spacing.x * 2.0);
|
|
|
|
// ---------- Hotkeys button ----------
|
|
let hotkeys_button =
|
|
Button::new(ICON_KEYBOARD.atom_size(Vec2::new(18.0, 18.0))).frame(false);
|
|
let hotkeys_button_response = ui.add_sized([18.0, 18.0], hotkeys_button);
|
|
if hotkeys_button_response.clicked() {
|
|
self.app_state.show_hotkeys = true;
|
|
}
|
|
hotkeys_button_response.on_hover_text("Hotkeys (H)");
|
|
// --------------------------------
|
|
|
|
// ---------- Settings button ----------
|
|
let settings_button =
|
|
Button::new(ICON_SETTINGS.atom_size(Vec2::new(18.0, 18.0))).frame(false);
|
|
let settings_button_response = ui.add_sized([18.0, 18.0], settings_button);
|
|
if settings_button_response.clicked() {
|
|
self.app_state.show_settings = true;
|
|
}
|
|
// --------------------------------
|
|
});
|
|
}
|
|
}
|