Compare commits

..

2 Commits

4 changed files with 192 additions and 22 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
/target /target
/.idea
src.zip

View File

@@ -41,6 +41,8 @@ pub struct App {
pub repeat: RepeatMode, pub repeat: RepeatMode,
pub shuffle: bool, pub shuffle: bool,
pub show_help: bool, pub show_help: bool,
/// Показывать окно управления директориями
pub show_dirs: bool,
status_set_at: Instant, status_set_at: Instant,
} }
@@ -49,9 +51,9 @@ const STATUS_RESET_AFTER: Duration = Duration::from_secs(2);
impl App { impl App {
pub fn new(music_dir: &str) -> Result<Self, Box<dyn std::error::Error>> { pub fn new(music_dir: &str) -> Result<Self, Box<dyn std::error::Error>> {
let saved = crate::state::load(); let saved = crate::state::load();
// Загружаем кэш
// Пробуем загрузить кэш. Если его нет или он пустой - сканируем папку. let cached_tracks = crate::state::load_cache();
let tracks = match crate::state::load_cache() { let tracks = match cached_tracks {
Some(cached) if !cached.is_empty() => cached, Some(cached) if !cached.is_empty() => cached,
_ => { _ => {
let scanned = scan_dir(music_dir); let scanned = scan_dir(music_dir);
@@ -79,8 +81,20 @@ impl App {
repeat: RepeatMode::Off, repeat: RepeatMode::Off,
shuffle: false, shuffle: false,
show_help: false, show_help: false,
show_dirs: false,
status_set_at: Instant::now(), 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 { if let Some(vol) = saved.volume {
app.player.set_volume(vol); app.player.set_volume(vol);
@@ -198,6 +212,12 @@ impl App {
"add" => self.add_dir(args), "add" => self.add_dir(args),
"reload" => self.reload_library(), "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" => { "help" => {
self.show_help = true; self.show_help = true;
self.set_status("Справка".into()); self.set_status("Справка".into());
@@ -256,7 +276,43 @@ impl App {
self.set_status(format!("Добавлено {} треков из {}", added, expanded)); 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()]; let mut dirs = vec![self.music_dir.clone()];
dirs.extend(self.extra_dirs.clone()); dirs.extend(self.extra_dirs.clone());
@@ -271,9 +327,14 @@ impl App {
self.raw_tracks = all; self.raw_tracks = all;
self.deleted_paths.clear(); self.deleted_paths.clear();
crate::state::save_cache(&self.raw_tracks); crate::state::save_cache(&self.raw_tracks);
self.rebuild_library(); self.rebuild_library();
}
pub fn reload_library(&mut self) {
self.rebuild_from_dirs();
self.set_status(format!("Пересканировано {} треков", self.raw_tracks.len())); self.set_status(format!("Пересканировано {} треков", self.raw_tracks.len()));
} }
@@ -795,6 +856,16 @@ impl App {
}; };
self.set_status(msg); self.set_status(msg);
} }
// ── Геттеры для UI ─────────────────────────────────────────
pub fn base_dir(&self) -> &str {
&self.music_dir
}
pub fn extra_dirs(&self) -> &[String] {
&self.extra_dirs
}
} }
/// Развернуть `~/...` в путь домашней директории. /// Развернуть `~/...` в путь домашней директории.

View File

@@ -26,7 +26,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut app = App::new(&music_dir)?; let mut app = App::new(&music_dir)?;
loop { loop {
// Проверяем флаг выхода, выставленный командой :q
if app.request_quit { if app.request_quit {
app.on_exit(); app.on_exit();
break; break;
@@ -57,11 +56,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
fn handle_key(key: KeyEvent, app: &mut App) -> bool { fn handle_key(key: KeyEvent, app: &mut App) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
// F1 переключает справку в любом режиме
if key.code == KeyCode::F(1) { if key.code == KeyCode::F(1) {
app.toggle_help(); app.toggle_help();
return false; return false;
} }
// Если открыта справка — только Esc её закрывает
if app.show_help { if app.show_help {
if key.code == KeyCode::Esc { if key.code == KeyCode::Esc {
app.toggle_help(); app.toggle_help();
@@ -69,6 +70,15 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool {
return false; return false;
} }
// Если открыто окно директорий — Esc его закрывает
if app.show_dirs {
if key.code == KeyCode::Esc {
app.show_dirs = false;
}
return false;
}
// Ввод команды
if app.input_mode == InputMode::Command { if app.input_mode == InputMode::Command {
match key.code { match key.code {
KeyCode::Esc => app.cancel_command(), KeyCode::Esc => app.cancel_command(),
@@ -80,6 +90,7 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool {
return false; return false;
} }
// Ctrl-комбинации
if ctrl { if ctrl {
match key.code { match key.code {
KeyCode::Right => { KeyCode::Right => {
@@ -105,6 +116,7 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool {
} }
} }
// Ввод поиска
if app.input_mode == InputMode::Search { if app.input_mode == InputMode::Search {
match key.code { match key.code {
KeyCode::Esc => app.cancel_search(), KeyCode::Esc => app.cancel_search(),
@@ -116,11 +128,13 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool {
return false; return false;
} }
// Delete
if key.code == KeyCode::Delete { if key.code == KeyCode::Delete {
app.delete_selected(); app.delete_selected();
return false; return false;
} }
// Обычные клавиши
match key.code { match key.code {
KeyCode::Char(':') => { KeyCode::Char(':') => {
app.start_command(); app.start_command();

115
src/ui.rs
View File

@@ -8,27 +8,24 @@ use crate::library::{RightRow, SortMode};
use crate::state::RepeatMode; use crate::state::RepeatMode;
pub fn draw(f: &mut Frame, app: &mut App) { pub fn draw(f: &mut Frame, app: &mut App) {
// Вертикально: верхняя часть (панели + эквалайзер) и статус
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Min(1), Constraint::Min(1),
Constraint::Length(3), // статус + время + mode + vol Constraint::Length(3),
]) ])
.split(f.area()); .split(f.area());
// Горизонтально: левая колонка (исполнители + эквалайзер) и правая (треки)
let top = Layout::default() let top = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(chunks[0]); .split(chunks[0]);
// Левая колонка делится вертикально: список сверху, эквалайзер снизу
let left_col = Layout::default() let left_col = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Min(1), Constraint::Min(1),
Constraint::Length(8), // эквалайзер Constraint::Length(8),
]) ])
.split(top[0]); .split(top[0]);
@@ -40,6 +37,9 @@ pub fn draw(f: &mut Frame, app: &mut App) {
if app.show_help { if app.show_help {
draw_help(f, f.area()); 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) { 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); app.library.left_state.select(sel);
let title = match app.library.sort_mode { let title_text = match app.library.sort_mode {
SortMode::ByArtist => " Исполнители ", SortMode::ByArtist => " Исполнители ",
SortMode::ByAlbum => " Альбомы ", SortMode::ByAlbum => " Альбомы ",
SortMode::ByFilename => " Источник ", 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) let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(title)) .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()) .map(|a| a.name.as_str())
.unwrap_or("—"); .unwrap_or("—");
let title = match app.library.sort_mode { let title_text = match app.library.sort_mode {
SortMode::ByArtist => { SortMode::ByArtist => {
if app.library.filter.is_empty() { if app.library.filter.is_empty() {
format!(" Альбомы и треки - {} ", selected_name) 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 { let highlight = if app.active_panel == Panel::Right {
Style::default().add_modifier(Modifier::REVERSED) 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) { fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) {
// Внутренняя ширина панели (минус 2 символа рамки) let inner_width = area.width.saturating_sub(1) as usize;
let inner_width = area.width.saturating_sub(2) as usize;
if inner_width == 0 { if inner_width == 0 {
return; return;
} }
// Каждый столбик занимает 2 ячейки: 1 символ + 1 gap.
// Значит число столбиков = ширина / 2.
let num_bars = (inner_width / 2).max(4); let num_bars = (inner_width / 2).max(4);
let bars_data = app.player.spectrum.bars(); 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) 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]); f.render_widget(status, inner[0]);
let (pos, total) = app.progress(); 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) let time = Paragraph::new(time_text)
.alignment(Alignment::Center) .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]); f.render_widget(time, inner[1]);
let mode_str = match (app.repeat, app.shuffle) { 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) let mode = Paragraph::new(mode_str)
.alignment(Alignment::Center) .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]); f.render_widget(mode, inner[2]);
let vol_text = format!("Vol: {}%", (app.player.volume() * 100.0) as u32); let vol_text = format!("Vol: {}%", (app.player.volume() * 100.0) as u32);
let vol = Paragraph::new(vol_text) let vol = Paragraph::new(vol_text)
.alignment(Alignment::Center) .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]); 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 <N> — удалить, :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) { fn draw_help(f: &mut Frame, screen: Rect) {
let width = 62u16.min(screen.width.saturating_sub(4)); 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 x = (screen.width.saturating_sub(width)) / 2;
let y = (screen.height.saturating_sub(height)) / 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(" :add <путь> добавить директорию"),
Line::from(" :reload пересканировать всё"), Line::from(" :reload пересканировать всё"),
Line::from(" :save сохранить библиотеку"), Line::from(" :save сохранить библиотеку"),
Line::from(" :dirs управление директориями"),
Line::from(" :help эта справка"), Line::from(" :help эта справка"),
Line::from(" :q выход"), Line::from(" :q выход"),
Line::from(""), Line::from(""),