diff --git a/.gitignore b/.gitignore index ea8c4bf..40d9aca 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/.idea \ No newline at end of file diff --git a/src.zip b/src.zip new file mode 100644 index 0000000..d700cef Binary files /dev/null and b/src.zip differ diff --git a/src/app.rs b/src/app.rs index a114c81..53f71dd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -41,6 +41,8 @@ pub struct App { pub repeat: RepeatMode, pub shuffle: bool, pub show_help: bool, + /// Показывать окно управления директориями + pub show_dirs: bool, status_set_at: Instant, } @@ -49,9 +51,9 @@ const STATUS_RESET_AFTER: Duration = Duration::from_secs(2); impl App { pub fn new(music_dir: &str) -> Result> { let saved = crate::state::load(); - - // Пробуем загрузить кэш. Если его нет или он пустой - сканируем папку. - let tracks = match crate::state::load_cache() { + // Загружаем кэш + let cached_tracks = crate::state::load_cache(); + let tracks = match cached_tracks { Some(cached) if !cached.is_empty() => cached, _ => { let scanned = scan_dir(music_dir); @@ -79,8 +81,20 @@ impl App { repeat: RepeatMode::Off, shuffle: false, show_help: false, + show_dirs: false, status_set_at: Instant::now(), }; + // ВАЖНО: Если есть extra_dirs, но в кэше нет треков из них, + // пересобираем библиотеку с нуля (сканируем все директории). + if !app.extra_dirs.is_empty() { + // Проверяем, есть ли в кэше треки не из базовой директории + let base_dir = std::path::Path::new(music_dir); + let has_extra_tracks = app.raw_tracks.iter().any(|t| !t.path.starts_with(base_dir)); + if !has_extra_tracks { + // Кэш не содержит extra_dirs — пересканируем всё + app.rebuild_from_dirs(); + } + } if let Some(vol) = saved.volume { app.player.set_volume(vol); @@ -197,7 +211,13 @@ impl App { match name { "add" => self.add_dir(args), "reload" => self.reload_library(), - "save" => self.save_now(), + "save" => self.save_now(), + "dirs" => { + self.show_dirs = true; + self.set_status("Список директорий".into()); + } + "rmdir" => self.rmdir(args), + "clear" => self.clear_extra_dirs(), "help" => { self.show_help = true; self.set_status("Справка".into()); @@ -256,7 +276,43 @@ impl App { self.set_status(format!("Добавлено {} треков из {}", added, expanded)); } - pub fn reload_library(&mut self) { + fn rmdir(&mut self, arg: &str) { + let idx: usize = match arg.parse() { + Ok(i) => i, + Err(_) => { + self.set_status("Использование: :rmdir <индекс>".into()); + return; + } + }; + + if idx == 0 { + self.set_status("Нельзя удалить базовую директорию".into()); + return; + } + + let extra_idx = idx - 1; + if extra_idx >= self.extra_dirs.len() { + self.set_status(format!("Нет директории с индексом {}", idx)); + return; + } + + let removed = self.extra_dirs.remove(extra_idx); + self.set_status(format!("Удалена директория: {}", removed)); + self.rebuild_from_dirs(); + } + + fn clear_extra_dirs(&mut self) { + if self.extra_dirs.is_empty() { + self.set_status("extra_dirs уже пуст".into()); + return; + } + self.extra_dirs.clear(); + self.rebuild_from_dirs(); + self.set_status("Все дополнительные директории удалены".into()); + } + + /// Пересканировать только оставшиеся директории и пересобрать библиотеку + fn rebuild_from_dirs(&mut self) { let mut dirs = vec![self.music_dir.clone()]; dirs.extend(self.extra_dirs.clone()); @@ -271,9 +327,14 @@ impl App { self.raw_tracks = all; self.deleted_paths.clear(); + crate::state::save_cache(&self.raw_tracks); self.rebuild_library(); + } + + pub fn reload_library(&mut self) { + self.rebuild_from_dirs(); self.set_status(format!("Пересканировано {} треков", self.raw_tracks.len())); } @@ -795,6 +856,16 @@ impl App { }; self.set_status(msg); } + + // ── Геттеры для UI ───────────────────────────────────────── + + pub fn base_dir(&self) -> &str { + &self.music_dir + } + + pub fn extra_dirs(&self) -> &[String] { + &self.extra_dirs + } } /// Развернуть `~/...` в путь домашней директории. diff --git a/src/main.rs b/src/main.rs index 4830767..7069781 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,6 @@ fn main() -> Result<(), Box> { let mut app = App::new(&music_dir)?; loop { - // Проверяем флаг выхода, выставленный командой :q if app.request_quit { app.on_exit(); break; @@ -57,11 +56,13 @@ fn main() -> Result<(), Box> { fn handle_key(key: KeyEvent, app: &mut App) -> bool { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + // F1 переключает справку в любом режиме if key.code == KeyCode::F(1) { app.toggle_help(); return false; } + // Если открыта справка — только Esc её закрывает if app.show_help { if key.code == KeyCode::Esc { app.toggle_help(); @@ -69,6 +70,15 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool { return false; } + // Если открыто окно директорий — Esc его закрывает + if app.show_dirs { + if key.code == KeyCode::Esc { + app.show_dirs = false; + } + return false; + } + + // Ввод команды if app.input_mode == InputMode::Command { match key.code { KeyCode::Esc => app.cancel_command(), @@ -80,6 +90,7 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool { return false; } + // Ctrl-комбинации if ctrl { match key.code { KeyCode::Right => { @@ -105,6 +116,7 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool { } } + // Ввод поиска if app.input_mode == InputMode::Search { match key.code { KeyCode::Esc => app.cancel_search(), @@ -116,11 +128,13 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool { return false; } + // Delete if key.code == KeyCode::Delete { app.delete_selected(); return false; } + // Обычные клавиши match key.code { KeyCode::Char(':') => { app.start_command(); diff --git a/src/ui.rs b/src/ui.rs index 6560a94..98f8dae 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -8,27 +8,24 @@ use crate::library::{RightRow, SortMode}; use crate::state::RepeatMode; pub fn draw(f: &mut Frame, app: &mut App) { - // Вертикально: верхняя часть (панели + эквалайзер) и статус let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(1), - Constraint::Length(3), // статус + время + mode + vol + Constraint::Length(3), ]) .split(f.area()); - // Горизонтально: левая колонка (исполнители + эквалайзер) и правая (треки) let top = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) .split(chunks[0]); - // Левая колонка делится вертикально: список сверху, эквалайзер снизу let left_col = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(1), - Constraint::Length(8), // эквалайзер + Constraint::Length(8), ]) .split(top[0]); @@ -40,6 +37,9 @@ pub fn draw(f: &mut Frame, app: &mut App) { if app.show_help { draw_help(f, f.area()); } + if app.show_dirs { + draw_dirs(f, app, f.area()); + } } fn draw_left_panel(f: &mut Frame, app: &mut App, area: Rect) { @@ -65,11 +65,13 @@ fn draw_left_panel(f: &mut Frame, app: &mut App, area: Rect) { }; app.library.left_state.select(sel); - let title = match app.library.sort_mode { + let title_text = match app.library.sort_mode { SortMode::ByArtist => " Исполнители ", SortMode::ByAlbum => " Альбомы ", SortMode::ByFilename => " Источник ", }; + let album_color = Color::Rgb(100, 149, 237); + let title = Span::styled(title_text, Style::default().fg(album_color)); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(title)) @@ -175,7 +177,7 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) { .map(|a| a.name.as_str()) .unwrap_or("—"); - let title = match app.library.sort_mode { + let title_text = match app.library.sort_mode { SortMode::ByArtist => { if app.library.filter.is_empty() { format!(" Альбомы и треки - {} ", selected_name) @@ -204,6 +206,8 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) { } } }; + let album_color = Color::Rgb(100, 149, 237); + let title = Span::styled(title_text, Style::default().fg(album_color)); let highlight = if app.active_panel == Panel::Right { Style::default().add_modifier(Modifier::REVERSED) @@ -227,14 +231,11 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) { } fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) { - // Внутренняя ширина панели (минус 2 символа рамки) - let inner_width = area.width.saturating_sub(2) as usize; + let inner_width = area.width.saturating_sub(1) as usize; if inner_width == 0 { return; } - // Каждый столбик занимает 2 ячейки: 1 символ + 1 gap. - // Значит число столбиков = ширина / 2. let num_bars = (inner_width / 2).max(4); let bars_data = app.player.spectrum.bars(); @@ -298,7 +299,12 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) { }; let status = Paragraph::new(status_text) - .block(Block::default().borders(Borders::ALL).title(status_title)); + .block( + Block::default() + .borders(Borders::ALL) + .title(status_title) + .title_style(Style::default().fg(Color::Rgb(100, 149, 237))) + ); f.render_widget(status, inner[0]); let (pos, total) = app.progress(); @@ -311,7 +317,12 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) { ); let time = Paragraph::new(time_text) .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL).title(" Время ")); + .block( + Block::default() + .borders(Borders::ALL) + .title(" Время ") + .title_style(Style::default().fg(Color::Rgb(100, 149, 237))) + ); f.render_widget(time, inner[1]); let mode_str = match (app.repeat, app.shuffle) { @@ -324,19 +335,90 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) { }; let mode = Paragraph::new(mode_str) .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL).title(" Mode ")); + .block( + Block::default() + .borders(Borders::ALL) + .title(" Pежим ") + .title_style(Style::default().fg(Color::Rgb(100, 149, 237))) + ); f.render_widget(mode, inner[2]); let vol_text = format!("Vol: {}%", (app.player.volume() * 100.0) as u32); let vol = Paragraph::new(vol_text) .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL).title(" ")); + .block( + Block::default() + .borders(Borders::ALL) + .title(" Звук ") + .title_style(Style::default().fg(Color::Rgb(100, 149, 237))) + ); f.render_widget(vol, inner[3]); } +fn draw_dirs(f: &mut Frame, app: &App, screen: Rect) { + let width = 70u16.min(screen.width.saturating_sub(4)); + let total_lines = app.extra_dirs().len() + 8; + let desired_height = (total_lines + 2) as u16; // +2 на рамки + + // Ограничиваем высотой экрана + let max_h = screen.height.saturating_sub(2); + let height = desired_height.min(max_h).max(5); // минимум 5 строк + + let x = (screen.width.saturating_sub(width)) / 2; + let y = (screen.height.saturating_sub(height)) / 2; + let area = Rect::new(x, y, width, height); + + f.render_widget(Clear, area); + + let title_color = Style::default().fg(Color::Rgb(100, 149, 237)); + let header = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let dim = Style::default().fg(Color::DarkGray); + + let mut lines = vec![ + Line::from(Span::styled( + " Директории библиотеки ", + title_color.add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled(" Базовые", header)), + Line::from(vec![ + Span::styled(" [0] ", dim), + Span::raw(app.base_dir()), + ]), + ]; + + if !app.extra_dirs().is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled(" Дополнительные (:add)", header))); + for (i, dir) in app.extra_dirs().iter().enumerate() { + lines.push(Line::from(vec![ + Span::styled(format!(" [{}] ", i + 1), dim), + Span::raw(dir.clone()), + ])); + } + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " :rmdir — удалить, :clear — очистить всё, Esc — закрыть", + dim, + ))); + + let para = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .border_style(title_color) + .title(" Библиотека "), + ); + + f.render_widget(para, area); +} + fn draw_help(f: &mut Frame, screen: Rect) { let width = 62u16.min(screen.width.saturating_sub(4)); - let height = 40u16.min(screen.height.saturating_sub(4)); + let height = 44u16.min(screen.height.saturating_sub(4)); let x = (screen.width.saturating_sub(width)) / 2; let y = (screen.height.saturating_sub(height)) / 2; @@ -380,6 +462,7 @@ fn draw_help(f: &mut Frame, screen: Rect) { Line::from(" :add <путь> добавить директорию"), Line::from(" :reload пересканировать всё"), Line::from(" :save сохранить библиотеку"), + Line::from(" :dirs управление директориями"), Line::from(" :help эта справка"), Line::from(" :q выход"), Line::from(""),