From 16a82fd936a2654e8c5a6902218b9b25ba7f2847 Mon Sep 17 00:00:00 2001 From: AlexL Date: Thu, 15 Oct 2026 23:02:17 +0200 Subject: [PATCH] =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.rs | 313 ++++++++++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 28 ++++- src/state.rs | 29 ++--- src/ui.rs | 56 +++++---- 4 files changed, 370 insertions(+), 56 deletions(-) diff --git a/src/app.rs b/src/app.rs index e9edb26..39267fa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,9 @@ +use std::collections::HashSet; +use std::path::PathBuf; use std::time::{Duration, Instant}; use crate::audio::AudioPlayer; -use crate::library::{Library, SortMode}; +use crate::library::{Library, RightRow, SortMode}; use crate::state::{RepeatMode, SavedState}; use crate::track::{scan_dir, Track}; @@ -15,6 +17,7 @@ pub enum Panel { pub enum InputMode { Normal, Search, + Command, } pub struct App { @@ -25,8 +28,16 @@ pub struct App { pub active_panel: Panel, pub input_mode: InputMode, pub search_buffer: String, + /// Буфер командного режима (после ":") + pub command_buffer: String, + /// Флаг запроса выхода (выставляется командой :q) + pub request_quit: bool, raw_tracks: Vec, music_dir: String, + /// Дополнительные директории, добавленные через :add + extra_dirs: Vec, + /// Пути, удалённые из списка (не с диска) + deleted_paths: HashSet, pub repeat: RepeatMode, pub shuffle: bool, pub show_help: bool, @@ -37,11 +48,14 @@ const STATUS_RESET_AFTER: Duration = Duration::from_secs(2); impl App { pub fn new(music_dir: &str) -> Result> { - let tracks = match crate::state::load_cache(music_dir) { - Some(cached) => cached, - None => { + let saved = crate::state::load(); + + // Пробуем загрузить кэш. Если его нет или он пустой — сканируем папку. + let tracks = match crate::state::load_cache() { + Some(cached) if !cached.is_empty() => cached, + _ => { let scanned = scan_dir(music_dir); - crate::state::save_cache(music_dir, &scanned); + crate::state::save_cache(&scanned); scanned } }; @@ -51,20 +65,23 @@ impl App { let mut app = Self { library, player: AudioPlayer::new()?, - status: "F1 — помощь".into(), + status: "F1 — помощь, : — команда".into(), playing_index: None, active_panel: Panel::Left, input_mode: InputMode::Normal, search_buffer: String::new(), + command_buffer: String::new(), + request_quit: false, raw_tracks: tracks, music_dir: music_dir.to_string(), + extra_dirs: saved.extra_dirs.clone(), + deleted_paths: HashSet::new(), repeat: RepeatMode::Off, shuffle: false, show_help: false, status_set_at: Instant::now(), }; - let saved = crate::state::load(); if let Some(vol) = saved.volume { app.player.set_volume(vol); } @@ -92,12 +109,12 @@ impl App { fn default_status(&self) -> String { match self.playing_index.and_then(|i| self.library.track(i)) { Some(t) => format!("{} — {}", t.artist, t.title), - None => "F1 — помощь".into(), + None => "F1 — помощь, : — команда".into(), } } pub fn tick_status(&mut self) { - if self.input_mode == InputMode::Search { + if self.input_mode != InputMode::Normal { return; } if self.status_set_at.elapsed() < STATUS_RESET_AFTER { @@ -140,6 +157,237 @@ impl App { self.show_help = !self.show_help; } + // ── Командный режим (:) ──────────────────────────────────── + + pub fn start_command(&mut self) { + self.input_mode = InputMode::Command; + self.command_buffer.clear(); + self.status_set_at = Instant::now(); + } + + pub fn cancel_command(&mut self) { + self.input_mode = InputMode::Normal; + self.command_buffer.clear(); + self.set_status("Команда отменена".into()); + } + + pub fn command_push(&mut self, c: char) { + self.command_buffer.push(c); + self.status_set_at = Instant::now(); + } + + pub fn command_pop(&mut self) { + self.command_buffer.pop(); + self.status_set_at = Instant::now(); + } + + pub fn commit_command(&mut self) { + let cmd = self.command_buffer.trim().to_string(); + self.input_mode = InputMode::Normal; + self.command_buffer.clear(); + + if cmd.is_empty() { + return; + } + + let mut parts = cmd.splitn(2, char::is_whitespace); + let name = parts.next().unwrap_or(""); + let args = parts.next().unwrap_or("").trim(); + + match name { + "add" => self.add_dir(args), + "reload" => self.reload_library(), + "save" => self.save_now(), + "help" => { + self.show_help = true; + self.set_status("Справка".into()); + } + "q" | "quit" => { + self.request_quit = true; + } + _ => { + self.set_status(format!("Неизвестная команда: {}", name)); + } + } + } + + fn add_dir(&mut self, raw_path: &str) { + if raw_path.is_empty() { + self.set_status("Использование: :add <путь>".into()); + return; + } + + let expanded = expand_tilde(raw_path); + let path = PathBuf::from(&expanded); + + if !path.exists() { + self.set_status(format!("Путь не существует: {}", expanded)); + return; + } + + if !path.is_dir() { + self.set_status(format!("Не директория: {}", expanded)); + return; + } + + self.set_status(format!("Сканирование {}...", expanded)); + let new_tracks = scan_dir(&expanded); + + if new_tracks.is_empty() { + self.set_status(format!("Нет треков в {}", expanded)); + return; + } + + let existing: HashSet = self.raw_tracks.iter().map(|t| t.path.clone()).collect(); + let mut added = 0; + for track in new_tracks { + if !existing.contains(&track.path) && !self.deleted_paths.contains(&track.path) { + self.raw_tracks.push(track); + added += 1; + } + } + + if !self.extra_dirs.contains(&expanded) { + self.extra_dirs.push(expanded.clone()); + } + + self.rebuild_library(); + crate::state::save_cache(&self.raw_tracks); + self.set_status(format!("Добавлено {} треков из {}", added, expanded)); + } + + pub fn reload_library(&mut self) { + let mut dirs = vec![self.music_dir.clone()]; + dirs.extend(self.extra_dirs.clone()); + + let mut all = Vec::new(); + for dir in &dirs { + let expanded = expand_tilde(dir); + all.extend(scan_dir(&expanded)); + } + + let mut seen = HashSet::new(); + all.retain(|t| seen.insert(t.path.clone())); + + self.raw_tracks = all; + self.deleted_paths.clear(); + crate::state::save_cache(&self.raw_tracks); + + self.rebuild_library(); + self.set_status(format!("Пересканировано {} треков", self.raw_tracks.len())); + } + + /// Принудительно сохранить кэш библиотеки и настройки. + /// Тот же `persist()`, но с подтверждением в статусе. + pub fn save_now(&mut self) { + self.persist(); + let msg = format!("Сохранено {} треков", self.raw_tracks.len()); + self.set_status(msg); + } + + // ── Удаление из списка (клавиша Del) ─────────────────────── + pub fn delete_selected(&mut self) { + match self.active_panel { + Panel::Left => self.delete_current_left_group(), + Panel::Right => self.delete_current_right_item(), + } + self.rebuild_library(); + } + + fn delete_current_left_group(&mut self) { + let Some(group) = self.library.artists.get(self.library.artist_selected) else { + return; + }; + let group_name = group.name.clone(); + + let to_remove: Vec = group + .rows + .iter() + .filter_map(|r| match r { + RightRow::Track { index_in_library } => { + self.library.track(*index_in_library).map(|t| t.path.clone()) + } + _ => None, + }) + .collect(); + + let count = to_remove.len(); + for p in &to_remove { + self.deleted_paths.insert(p.clone()); + } + self.raw_tracks + .retain(|t| !self.deleted_paths.contains(&t.path)); + + if let Some(idx) = self.playing_index { + if let Some(t) = self.library.track(idx) { + if to_remove.contains(&t.path) { + self.player.stop(); + self.playing_index = None; + } + } + } + + self.set_status(format!("Удалено: {} ({} треков)", group_name, count)); + } + + fn delete_current_right_item(&mut self) { + let rows = self.library.current_right_rows(); + let Some(row) = rows.get(self.library.right_selected).cloned() else { + return; + }; + + match row { + RightRow::Track { index_in_library } => { + if let Some(track) = self.library.track(index_in_library).cloned() { + let name = format!("{} — {}", track.artist, track.title); + self.deleted_paths.insert(track.path.clone()); + self.raw_tracks.retain(|t| t.path != track.path); + + if Some(index_in_library) == self.playing_index { + self.player.stop(); + self.playing_index = None; + } + + self.set_status(format!("Удалено: {}", name)); + } + } + RightRow::AlbumHeader { name } => { + let start = self.library.right_selected + 1; + let mut to_remove: Vec = Vec::new(); + for r in rows.iter().skip(start) { + match r { + RightRow::Track { index_in_library } => { + if let Some(t) = self.library.track(*index_in_library) { + to_remove.push(t.path.clone()); + } + } + RightRow::AlbumHeader { .. } => break, + } + } + + let count = to_remove.len(); + for p in &to_remove { + self.deleted_paths.insert(p.clone()); + } + self.raw_tracks + .retain(|t| !self.deleted_paths.contains(&t.path)); + + if let Some(idx) = self.playing_index { + if let Some(t) = self.library.track(idx) { + if to_remove.contains(&t.path) { + self.player.stop(); + self.playing_index = None; + } + } + } + + self.set_status(format!("Удалено: {} ({} треков)", name, count)); + } + } + } + + // ── Переключение треков ──────────────────────────────────── + pub fn next_track(&mut self) { let total = self.library.all_tracks.len(); if total == 0 { @@ -187,6 +435,10 @@ impl App { } fn persist(&self) { + // Сохраняем кэш треков — чтобы при следующем запуске + // не сканировать директории заново. + crate::state::save_cache(&self.raw_tracks); + let playing_path = self .playing_index .and_then(|i| self.library.track(i)) @@ -206,6 +458,7 @@ impl App { shuffle: Some(self.shuffle), last_playing_path: playing_path, last_playing_pos_secs: playing_pos, + extra_dirs: self.extra_dirs.clone(), }; crate::state::save(&state); } @@ -214,6 +467,8 @@ impl App { self.persist(); } + // ── Поиск ────────────────────────────────────────────────── + pub fn start_search(&mut self) { self.input_mode = InputMode::Search; self.search_buffer.clear(); @@ -251,16 +506,20 @@ impl App { fn rebuild_library(&mut self) { let mode = self.library.sort_mode; - self.library = Library::build_with_filter( - self.raw_tracks.clone(), - mode, - self.search_buffer.clone(), - ); + let filtered: Vec = self + .raw_tracks + .iter() + .filter(|t| !self.deleted_paths.contains(&t.path)) + .cloned() + .collect(); + self.library = Library::build_with_filter(filtered, mode, self.search_buffer.clone()); if let Some(i) = self.playing_index { self.library.sync_with_playing(i); } } + // ── Навигация ────────────────────────────────────────────── + pub fn up(&mut self) { match self.active_panel { Panel::Left => self.library.artist_move(-1), @@ -321,6 +580,8 @@ impl App { } } + // ── Playback ─────────────────────────────────────────────── + pub fn play_index(&mut self, index_in_library: usize) { let Some(track) = self.library.track(index_in_library).cloned() else { return; @@ -507,8 +768,15 @@ impl App { .and_then(|i| self.library.track(i)) .map(|t| t.path.clone()); + let filtered: Vec = self + .raw_tracks + .iter() + .filter(|t| !self.deleted_paths.contains(&t.path)) + .cloned() + .collect(); + self.library = Library::build_with_filter( - self.raw_tracks.clone(), + filtered, new_mode, self.search_buffer.clone(), ); @@ -527,4 +795,19 @@ impl App { }; self.set_status(msg); } +} + +/// Развернуть `~/...` в путь домашней директории. +fn expand_tilde(path: &str) -> String { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest).to_string_lossy().to_string(); + } + } + if path == "~" { + if let Some(home) = dirs::home_dir() { + return home.to_string_lossy().to_string(); + } + } + path.to_string() } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 42e915f..4830767 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,8 +26,14 @@ fn main() -> Result<(), Box> { let mut app = App::new(&music_dir)?; loop { + // Проверяем флаг выхода, выставленный командой :q + if app.request_quit { + app.on_exit(); + break; + } + app.maybe_advance(); - app.tick_status(); // ← автосброс статуса через 2 секунды + app.tick_status(); app.player.spectrum.recompute(); terminal.draw(|f| ui::draw(f, &mut app))?; @@ -63,6 +69,17 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool { return false; } + if app.input_mode == InputMode::Command { + match key.code { + KeyCode::Esc => app.cancel_command(), + KeyCode::Enter => app.commit_command(), + KeyCode::Backspace => app.command_pop(), + KeyCode::Char(c) => app.command_push(c), + _ => {} + } + return false; + } + if ctrl { match key.code { KeyCode::Right => { @@ -99,7 +116,16 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool { return false; } + if key.code == KeyCode::Delete { + app.delete_selected(); + return false; + } + match key.code { + KeyCode::Char(':') => { + app.start_command(); + return false; + } KeyCode::Char('+') | KeyCode::Char('=') => { app.volume_up(); return false; diff --git a/src/state.rs b/src/state.rs index e433881..cb2f2fd 100644 --- a/src/state.rs +++ b/src/state.rs @@ -15,6 +15,9 @@ pub struct SavedState { pub shuffle: Option, pub last_playing_path: Option, pub last_playing_pos_secs: Option, + /// Дополнительные директории, добавленные через :add + #[serde(default)] + pub extra_dirs: Vec, } #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)] @@ -25,7 +28,7 @@ pub enum RepeatMode { One, } -fn config_path() -> Option { +pub fn config_path() -> Option { dirs::config_dir().map(|p| p.join("rumus").join("state.toml")) } @@ -57,35 +60,21 @@ pub fn save(state: &SavedState) { // ── Кэш плейлиста (bincode) ──────────────────────────────────── -#[derive(Serialize, Deserialize)] -pub struct PlaylistCache { - pub source_dir: String, - pub tracks: Vec, -} - -pub fn load_cache(expected_dir: &str) -> Option> { +/// Кэш просто хранит все известные треки. Без привязки к конкретной директории. +pub fn load_cache() -> Option> { let path = cache_path()?; let bytes = fs::read(&path).ok()?; - let cache: PlaylistCache = bincode::deserialize(&bytes).ok()?; - if cache.source_dir == expected_dir { - Some(cache.tracks) - } else { - None - } + bincode::deserialize(&bytes).ok() } -pub fn save_cache(source_dir: &str, tracks: &[Track]) { +pub fn save_cache(tracks: &[Track]) { let Some(path) = cache_path() else { return; }; if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); } - let cache = PlaylistCache { - source_dir: source_dir.to_string(), - tracks: tracks.to_vec(), - }; - if let Ok(bytes) = bincode::serialize(&cache) { + if let Ok(bytes) = bincode::serialize(&tracks.to_vec()) { let _ = fs::write(path, bytes); } } \ No newline at end of file diff --git a/src/ui.rs b/src/ui.rs index 973fefe..484db1a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -149,14 +149,20 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) { } else { Style::default() }; + + let left = format!(" {}{}{}", marker, num, title); + let inner_width = area.width.saturating_sub(4) as usize; + + let left_len = left.chars().count(); + let dur_len = dur.chars().count(); + let pad = inner_width + .saturating_sub(left_len + dur_len) + .max(1); ListItem::new(Line::from(vec![ - Span::raw(" "), - Span::raw(marker), - Span::styled(format!("{}{}", num, title), style), - Span::raw(" "), - // цвет таймера - Span::styled(dur, Style::default().fg(Color::DarkGray)), + Span::styled(left, style), + Span::raw(" ".repeat(pad)), + Span::styled(dur, style), ])) } }) @@ -247,13 +253,13 @@ fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) { } let bars: Vec = display - .iter() - .map(|&v| { - Bar::default() - .value((v * 100.0) as u64) - .style(Style::default().fg(Color::Rgb(100, 149, 237))) - }) - .collect(); + .iter() + .map(|&v| { + Bar::default() + .value((v * 100.0) as u64) + .style(Style::default().fg(Color::Rgb(100, 149, 237))) + }) + .collect(); let chart = BarChart::default() .data(BarGroup::default().bars(&bars)) @@ -282,11 +288,13 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) { ]) .split(area); - let (status_text, status_title) = if app.input_mode == InputMode::Search { - (format!("> {}", app.search_buffer), " Поиск ") - } else { - let prefix = if app.player.is_paused() { "|| " } else { " " }; - (format!("{}{}", prefix, app.status), " Статус ") + let (status_text, status_title) = match app.input_mode { + InputMode::Search => (format!("> {}", app.search_buffer), " Поиск "), + InputMode::Command => (format!(":{}", app.command_buffer), " Команда "), + InputMode::Normal => { + let prefix = if app.player.is_paused() { "|| " } else { " " }; + (format!("{}{}", prefix, app.status), " Статус ") + } }; let status = Paragraph::new(status_text) @@ -327,8 +335,8 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) { } fn draw_help(f: &mut Frame, screen: Rect) { - let width = 60u16.min(screen.width.saturating_sub(4)); - let height = 34u16.min(screen.height.saturating_sub(4)); + let width = 62u16.min(screen.width.saturating_sub(4)); + let height = 40u16.min(screen.height.saturating_sub(4)); let x = (screen.width.saturating_sub(width)) / 2; let y = (screen.height.saturating_sub(height)) / 2; @@ -368,9 +376,17 @@ fn draw_help(f: &mut Frame, screen: Rect) { Line::from(" d / → +5 секунд"), Line::from(" 1..9 перейти к 10%..90% трека"), Line::from(""), + Line::from(Span::styled(" Команды", header)), + Line::from(" :add <путь> добавить директорию"), + Line::from(" :reload пересканировать всё"), + Line::from(" :save сохранить библиотеку"), + Line::from(" :help эта справка"), + Line::from(" :q выход"), + Line::from(""), Line::from(Span::styled(" Звук и прочее", header)), Line::from(" + / - громкость"), Line::from(" / поиск"), + Line::from(" Del удалить из списка"), Line::from(" t сортировка: артист → альбом → файл"), Line::from(" F1 эта справка"), Line::from(" q / Esc выход"),