diff --git a/Cargo.lock b/Cargo.lock index 308a1cd..66bc7b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,6 +48,15 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -974,6 +983,7 @@ dependencies = [ name = "rumus" version = "0.1.0" dependencies = [ + "bincode", "crossterm", "dirs", "lofty", diff --git a/Cargo.toml b/Cargo.toml index 3e97ca4..f2c172d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "rumus" -version = "0.1.0" -edition = "2024" +version = "0.5.0" +edition = "2026" [dependencies] ratatui = "0.29" @@ -12,4 +12,5 @@ dirs = "5" lofty = "0.22" serde = { version = "1", features = ["derive"] } toml = "0.8" -rustfft = "6" \ No newline at end of file +rustfft = "6" +bincode = "1.3" \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index dd5f2df..3660dbc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,9 +1,9 @@ -use std::time::Duration; +use std::time::{Duration, Instant}; use crate::audio::AudioPlayer; use crate::library::{Library, SortMode}; use crate::state::{RepeatMode, SavedState}; -use crate::track::scan_dir; +use crate::track::{scan_dir, Track}; #[derive(Clone, Copy, PartialEq)] pub enum Panel { @@ -25,23 +25,33 @@ pub struct App { pub active_panel: Panel, pub input_mode: InputMode, pub search_buffer: String, - /// Исходный (неотфильтрованный) список треков — для пересборки библиотеки - raw_tracks: Vec, - /// Директория с музыкой — чтобы сохранить в state + raw_tracks: Vec, music_dir: String, pub repeat: RepeatMode, pub shuffle: bool, + pub show_help: bool, + status_set_at: Instant, } +const STATUS_RESET_AFTER: Duration = Duration::from_secs(2); + impl App { pub fn new(music_dir: &str) -> Result> { - let tracks = scan_dir(music_dir); + let tracks = match crate::state::load_cache(music_dir) { + Some(cached) => cached, + None => { + let scanned = scan_dir(music_dir); + crate::state::save_cache(music_dir, &scanned); + scanned + } + }; + let library = Library::build(tracks.clone(), SortMode::ByArtist); let mut app = Self { library, player: AudioPlayer::new()?, - status: "? — help".into(), + status: "F1 — помощь".into(), playing_index: None, active_panel: Panel::Left, input_mode: InputMode::Normal, @@ -50,9 +60,10 @@ impl App { music_dir: music_dir.to_string(), 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); @@ -63,20 +74,138 @@ impl App { if let Some(sh) = saved.shuffle { app.shuffle = sh; } - if saved.sort_by_filename == Some(true) { - app.library = Library::build(app.raw_tracks.clone(), SortMode::ByFilename); + if let Some(mode) = saved.sort_mode { + if mode != SortMode::ByArtist { + app.library = Library::build(app.raw_tracks.clone(), mode); + } } + app.restore_playing(&saved); Ok(app) } + fn set_status(&mut self, text: String) { + self.status = text; + self.status_set_at = Instant::now(); + } + + 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(), + } + } + + pub fn tick_status(&mut self) { + if self.input_mode == InputMode::Search { + return; + } + if self.status_set_at.elapsed() < STATUS_RESET_AFTER { + return; + } + let default = self.default_status(); + if self.status != default { + self.status = default; + } + } + + fn restore_playing(&mut self, saved: &SavedState) { + let Some(path_str) = &saved.last_playing_path else { + return; + }; + + let target_path = std::path::PathBuf::from(path_str); + let Some(idx) = self + .library + .all_tracks + .iter() + .position(|t| t.path == target_path) + else { + return; + }; + + let start_pos = saved.last_playing_pos_secs.map(Duration::from_secs); + + if let Some(track) = self.library.track(idx).cloned() { + if self.player.play(&track.path, start_pos).is_ok() { + self.playing_index = Some(idx); + self.library.sync_with_playing(idx); + self.player.toggle_pause(); + self.set_status(format!("Восстановлен: {} — {}", track.artist, track.title)); + } + } + } + + pub fn toggle_help(&mut self) { + self.show_help = !self.show_help; + } + + pub fn next_track(&mut self) { + let total = self.library.all_tracks.len(); + if total == 0 { + self.set_status("Библиотека пуста".into()); + return; + } + + let next = match self.playing_index { + Some(i) if i + 1 < total => i + 1, + Some(_) => { + if self.repeat == RepeatMode::Off { + self.set_status("Конец плейлиста".into()); + return; + } + 0 + } + None => 0, + }; + + self.play_index(next); + self.library.sync_with_playing(next); + } + + pub fn prev_track(&mut self) { + let total = self.library.all_tracks.len(); + if total == 0 { + self.set_status("Библиотека пуста".into()); + return; + } + + let prev = match self.playing_index { + Some(0) => { + if self.repeat == RepeatMode::Off { + self.set_status("Это первый трек".into()); + return; + } + total - 1 + } + Some(i) => i - 1, + None => 0, + }; + + self.play_index(prev); + self.library.sync_with_playing(prev); + } + fn persist(&self) { + let playing_path = self + .playing_index + .and_then(|i| self.library.track(i)) + .map(|t| t.path.to_string_lossy().to_string()); + + let playing_pos = if self.playing_index.is_some() { + Some(self.player.position().as_secs()) + } else { + None + }; + let state = SavedState { last_path: Some(self.music_dir.clone()), volume: Some(self.player.volume()), - sort_by_filename: Some(self.library.sort_mode == SortMode::ByFilename), + sort_mode: Some(self.library.sort_mode), repeat: Some(self.repeat), shuffle: Some(self.shuffle), + last_playing_path: playing_path, + last_playing_pos_secs: playing_pos, }; crate::state::save(&state); } @@ -85,40 +214,39 @@ impl App { self.persist(); } - // ── Поиск ────────────────────────────────────────────────── - pub fn start_search(&mut self) { self.input_mode = InputMode::Search; self.search_buffer.clear(); - self.status = "Поиск: ".into(); + self.status_set_at = Instant::now(); } pub fn cancel_search(&mut self) { self.input_mode = InputMode::Normal; self.search_buffer.clear(); self.rebuild_library(); - self.status = "Поиск отменён".into(); + self.set_status("Поиск отменён".into()); } pub fn commit_search(&mut self) { self.input_mode = InputMode::Normal; - self.status = if self.search_buffer.is_empty() { - "Фильтр сброшен".into() + let msg = if self.search_buffer.is_empty() { + "Фильтр сброшен".to_string() } else { format!("Фильтр: {}", self.search_buffer) }; + self.set_status(msg); } pub fn search_push(&mut self, c: char) { self.search_buffer.push(c); self.rebuild_library(); - self.status = format!("Поиск: {}", self.search_buffer); + self.status_set_at = Instant::now(); } pub fn search_pop(&mut self) { self.search_buffer.pop(); self.rebuild_library(); - self.status = format!("Поиск: {}", self.search_buffer); + self.status_set_at = Instant::now(); } fn rebuild_library(&mut self) { @@ -133,8 +261,6 @@ impl App { } } - // ── Навигация ────────────────────────────────────────────── - pub fn up(&mut self) { match self.active_panel { Panel::Left => self.library.artist_move(-1), @@ -154,27 +280,29 @@ impl App { Panel::Left => Panel::Right, Panel::Right => Panel::Left, }; - self.status = match self.active_panel { - Panel::Left => "Активна: исполнители".into(), - Panel::Right => "Активна: альбомы/треки".into(), + let msg = match self.active_panel { + Panel::Left => "Активна: левая панель".to_string(), + Panel::Right => "Активна: правая панель".to_string(), }; + self.set_status(msg); } - /// f — прыжок к играющему треку pub fn jump_to_playing(&mut self) { if let Some(i) = self.playing_index { self.library.sync_with_playing(i); self.active_panel = Panel::Right; - self.status = "→ к играющему треку".into(); + self.set_status("→ к играющему треку".into()); } else { - self.status = "Ничего не играет".into(); + self.set_status("Ничего не играет".into()); } } - /// g — в начало, G — в конец pub fn goto_start(&mut self) { match self.active_panel { - Panel::Left => self.library.artist_selected = 0, + Panel::Left => { + self.library.artist_selected = 0; + self.library.left_state.select(Some(0)); + } Panel::Right => self.library.right_goto_start(), } } @@ -184,24 +312,25 @@ impl App { Panel::Left => { if !self.library.artists.is_empty() { self.library.artist_selected = self.library.artists.len() - 1; + self.library + .left_state + .select(Some(self.library.artist_selected)); } } Panel::Right => self.library.right_goto_end(), } } - // ── Playback ─────────────────────────────────────────────── - pub fn play_index(&mut self, index_in_library: usize) { let Some(track) = self.library.track(index_in_library).cloned() else { return; }; - match self.player.play(&track.path) { + match self.player.play(&track.path, None) { Ok(_) => { - self.status = format!("▶ {} — {}", track.artist, track.title); self.playing_index = Some(index_in_library); + self.set_status(format!("▶ {} — {}", track.artist, track.title)); } - Err(e) => self.status = format!("Ошибка: {}", e), + Err(e) => self.set_status(format!("Ошибка: {}", e)), } } @@ -209,27 +338,25 @@ impl App { if let Some(idx) = self.library.right_track_index() { self.play_index(idx); } else { - self.status = "Выделен заголовок альбома".into(); + self.set_status("Выделен заголовок группы".into()); } } pub fn stop(&mut self) { self.player.stop(); self.playing_index = None; - self.status = "Остановлено".into(); + self.set_status("Остановлено".into()); } pub fn toggle_pause(&mut self) { self.player.toggle_pause(); } - /// Автопереход с учётом repeat и shuffle pub fn maybe_advance(&mut self) { if self.playing_index.is_none() || !self.player.is_finished() { return; } - // Repeat One — играем тот же трек if self.repeat == RepeatMode::One { if let Some(i) = self.playing_index { self.play_index(i); @@ -243,7 +370,6 @@ impl App { } let next = if self.shuffle { - // Псевдослучайный выбор без rand-крейта let seed = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos() as usize) @@ -256,8 +382,7 @@ impl App { if self.repeat == RepeatMode::All { 0 } else { - // Off — останавливаемся - self.status = "Конец плейлиста".into(); + self.set_status("Конец плейлиста".into()); self.playing_index = None; return; } @@ -275,37 +400,39 @@ impl App { RepeatMode::All => RepeatMode::One, RepeatMode::One => RepeatMode::Off, }; - self.status = match self.repeat { - RepeatMode::Off => "Repeat: off".into(), - RepeatMode::All => "Repeat: all".into(), - RepeatMode::One => "Repeat: one".into(), + let msg = match self.repeat { + RepeatMode::Off => "Repeat: off".to_string(), + RepeatMode::All => "Repeat: all".to_string(), + RepeatMode::One => "Repeat: one".to_string(), }; + self.set_status(msg); } pub fn toggle_shuffle(&mut self) { self.shuffle = !self.shuffle; - self.status = if self.shuffle { - "Shuffle: on".into() + let msg = if self.shuffle { + "Shuffle: on".to_string() } else { - "Shuffle: off".into() + "Shuffle: off".to_string() }; + self.set_status(msg); } - // ── Громкость, перемотка ─────────────────────────────────── - pub fn volume_up(&mut self) { self.player.change_volume(0.05); - self.status = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32); + let msg = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32); + self.set_status(msg); } pub fn volume_down(&mut self) { self.player.change_volume(-0.05); - self.status = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32); + let msg = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32); + self.set_status(msg); } pub fn seek(&mut self, delta_secs: i64) { if self.playing_index.is_none() { - self.status = "Ничего не воспроизводится".into(); + self.set_status("Ничего не воспроизводится".into()); return; } let total = self @@ -317,22 +444,22 @@ impl App { match self.player.seek_by(delta_secs, total) { Ok(new_pos) => { let sign = if delta_secs >= 0 { "+" } else { "" }; - self.status = format!( + let msg = format!( "Перемотка {}{}с → {:02}:{:02}", sign, delta_secs, new_pos.as_secs() / 60, new_pos.as_secs() % 60, ); + self.set_status(msg); } - Err(_) => self.status = "Перемотка недоступна".into(), + Err(_) => self.set_status("Перемотка недоступна".into()), } } - /// Перемотка по цифрам 1–9 (10%..90%) pub fn seek_percent(&mut self, percent: u8) { if self.playing_index.is_none() { - self.status = "Ничего не воспроизводится".into(); + self.set_status("Ничего не воспроизводится".into()); return; } let total = match self @@ -342,21 +469,22 @@ impl App { { Some(d) if !d.is_zero() => d, _ => { - self.status = "Длительность неизвестна".into(); + self.set_status("Длительность неизвестна".into()); return; } }; let target = total.mul_f32(percent as f32 / 100.0); match self.player.seek_to(target) { Ok(_) => { - self.status = format!( + let msg = format!( "→ {}% ({:02}:{:02})", percent, target.as_secs() / 60, target.as_secs() % 60, ); + self.set_status(msg); } - Err(_) => self.status = "Перемотка недоступна".into(), + Err(_) => self.set_status("Перемотка недоступна".into()), } } @@ -369,7 +497,8 @@ impl App { pub fn toggle_sort(&mut self) { let new_mode = match self.library.sort_mode { - SortMode::ByArtist => SortMode::ByFilename, + SortMode::ByArtist => SortMode::ByAlbum, + SortMode::ByAlbum => SortMode::ByFilename, SortMode::ByFilename => SortMode::ByArtist, }; @@ -391,9 +520,11 @@ impl App { self.library.sync_with_playing(i); } - self.status = match new_mode { - SortMode::ByArtist => "Сортировка: по артисту/альбому".into(), - SortMode::ByFilename => "Сортировка: по имени файла".into(), + let msg = match new_mode { + SortMode::ByArtist => "Сортировка: по артисту/альбому".to_string(), + SortMode::ByAlbum => "Сортировка: по альбому/артисту".to_string(), + SortMode::ByFilename => "Сортировка: по имени файла".to_string(), }; + self.set_status(msg); } } \ No newline at end of file diff --git a/src/audio.rs b/src/audio.rs index 3dc6a18..074c8b2 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -124,7 +124,6 @@ where fn total_duration(&self) -> Option { self.inner.total_duration() } - // ВАЖНО: пробрасываем try_seek, иначе Sink::try_seek вернёт NotSupported fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> { self.inner.try_seek(pos) } @@ -150,17 +149,31 @@ impl AudioPlayer { }) } - pub fn play(&mut self, path: &PathBuf) -> Result<(), Box> { + /// Запускает трек, опционально перематывая в `start_pos` ДО начала воспроизведения. + /// Перемотка делается на декодере — это предотвращает щелчок на старте. + pub fn play( + &mut self, + path: &PathBuf, + start_pos: Option, + ) -> Result<(), Box> { self.sink.stop(); self.paused_pos = None; + let file = File::open(path)?; - // Decoder::try_from автоматически настраивает seekable и byte_len[citation:1][citation:19] - let decoder = Decoder::try_from(file)?; + let mut decoder = Decoder::try_from(file)?; + + if let Some(pos) = start_pos { + if !pos.is_zero() { + let _ = decoder.try_seek(pos); + } + } + let tapped = TapSource { inner: decoder, bus: self.spectrum.clone(), }; self.sink.append(tapped); + // ВАЖНО: явно снимаем паузу — sink.stop() не сбрасывает состояние pause. self.sink.play(); Ok(()) } diff --git a/src/library.rs b/src/library.rs index aecebbb..fb16fb8 100644 --- a/src/library.rs +++ b/src/library.rs @@ -1,16 +1,20 @@ use ratatui::widgets::ListState; +use serde::{Deserialize, Serialize}; use crate::track::Track; #[derive(Clone)] pub enum RightRow { + /// Заголовок группы: в режиме ByArtist — название альбома, + /// в режиме ByAlbum — имя исполнителя. AlbumHeader { name: String }, Track { index_in_library: usize }, } -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] pub enum SortMode { ByArtist, + ByAlbum, ByFilename, } @@ -25,11 +29,8 @@ pub struct Library { pub artist_selected: usize, pub right_selected: usize, pub sort_mode: SortMode, - /// Активный фильтр поиска (пусто — без фильтра) pub filter: String, - /// Состояние скролла левой панели (сохраняется между кадрами) pub left_state: ListState, - /// Состояние скролла правой панели pub right_state: ListState, } @@ -43,7 +44,6 @@ impl Library { mode: SortMode, filter: String, ) -> Self { - // Применяем фильтр if !filter.is_empty() { let f = filter.to_lowercase(); tracks.retain(|t| { @@ -54,108 +54,165 @@ impl Library { } match mode { - SortMode::ByArtist => { - use std::collections::BTreeMap; + SortMode::ByArtist => Self::build_by_artist(tracks, mode, filter), + SortMode::ByAlbum => Self::build_by_album(tracks, mode, filter), + SortMode::ByFilename => Self::build_by_filename(tracks, mode, filter), + } + } - let mut map: BTreeMap>> = BTreeMap::new(); - for t in tracks.drain(..) { - map.entry(t.artist.clone()) - .or_default() - .entry(t.album.clone()) - .or_default() - .push(t); - } + fn build_by_artist( + mut tracks: Vec, + mode: SortMode, + filter: String, + ) -> Self { + use std::collections::BTreeMap; - let mut all_tracks = Vec::new(); - let mut artists = Vec::new(); + let mut map: BTreeMap>> = BTreeMap::new(); + for t in tracks.drain(..) { + map.entry(t.artist.clone()) + .or_default() + .entry(t.album.clone()) + .or_default() + .push(t); + } - for (artist_name, albums_map) in map { - let mut rows = Vec::new(); + let mut all_tracks = Vec::new(); + let mut artists = Vec::new(); - for (album_name, mut album_tracks) in albums_map { - album_tracks.sort_by(|a, b| { - a.track_number - .unwrap_or(u32::MAX) - .cmp(&b.track_number.unwrap_or(u32::MAX)) - .then(a.title.cmp(&b.title)) - }); + for (artist_name, albums_map) in map { + let mut rows = Vec::new(); - rows.push(RightRow::AlbumHeader { name: album_name }); - - for t in album_tracks { - let idx = all_tracks.len(); - all_tracks.push(t); - rows.push(RightRow::Track { - index_in_library: idx, - }); - } - } - - artists.push(ArtistView { - name: artist_name, - rows, - }); - } - - // Вычисляем right_selected ДО перемещения artists - let right_selected = first_track_row_in(artists.first()); - - let mut left_state = ListState::default(); - let mut right_state = ListState::default(); - if !artists.is_empty() { - left_state.select(Some(0)); - } - right_state.select(Some(right_selected)); - - Library { - all_tracks, - artists, - artist_selected: 0, - right_selected, - sort_mode: mode, - filter, - left_state, - right_state, - } - } - - SortMode::ByFilename => { - tracks.sort_by(|a, b| a.path.cmp(&b.path)); - let all_tracks = tracks; - - let mut rows = Vec::new(); - rows.push(RightRow::AlbumHeader { - name: "Все файлы".into(), + for (album_name, mut album_tracks) in albums_map { + album_tracks.sort_by(|a, b| { + a.track_number + .unwrap_or(u32::MAX) + .cmp(&b.track_number.unwrap_or(u32::MAX)) + .then(a.title.cmp(&b.title)) }); - for i in 0..all_tracks.len() { + + rows.push(RightRow::AlbumHeader { name: album_name }); + + for t in album_tracks { + let idx = all_tracks.len(); + all_tracks.push(t); rows.push(RightRow::Track { - index_in_library: i, + index_in_library: idx, }); } + } - let artists = vec![ArtistView { - name: "Все файлы".into(), - rows, - }]; + artists.push(ArtistView { + name: artist_name, + rows, + }); + } - let right_selected = first_track_row_in(artists.first()); + Self::finalize(all_tracks, artists, mode, filter) + } - let mut left_state = ListState::default(); - left_state.select(Some(0)); - let mut right_state = ListState::default(); - right_state.select(Some(right_selected)); + fn build_by_album( + mut tracks: Vec, + mode: SortMode, + filter: String, + ) -> Self { + use std::collections::BTreeMap; - Library { - all_tracks, - artists, - artist_selected: 0, - right_selected, - sort_mode: mode, - filter, - left_state, - right_state, + // Album -> Artist -> Vec + let mut map: BTreeMap>> = BTreeMap::new(); + for t in tracks.drain(..) { + map.entry(t.album.clone()) + .or_default() + .entry(t.artist.clone()) + .or_default() + .push(t); + } + + let mut all_tracks = Vec::new(); + let mut albums = Vec::new(); + + for (album_name, artists_map) in map { + let mut rows = Vec::new(); + + for (artist_name, mut artist_tracks) in artists_map { + artist_tracks.sort_by(|a, b| { + a.track_number + .unwrap_or(u32::MAX) + .cmp(&b.track_number.unwrap_or(u32::MAX)) + .then(a.title.cmp(&b.title)) + }); + + // Заголовок группы = имя исполнителя + rows.push(RightRow::AlbumHeader { name: artist_name }); + + for t in artist_tracks { + let idx = all_tracks.len(); + all_tracks.push(t); + rows.push(RightRow::Track { + index_in_library: idx, + }); } } + + albums.push(ArtistView { + name: album_name, + rows, + }); + } + + Self::finalize(all_tracks, albums, mode, filter) + } + + fn build_by_filename( + mut tracks: Vec, + mode: SortMode, + filter: String, + ) -> Self { + tracks.sort_by(|a, b| a.path.cmp(&b.path)); + let all_tracks = tracks; + + let mut rows = Vec::new(); + rows.push(RightRow::AlbumHeader { + name: "Все файлы".into(), + }); + for i in 0..all_tracks.len() { + rows.push(RightRow::Track { + index_in_library: i, + }); + } + + let groups = vec![ArtistView { + name: "Все файлы".into(), + rows, + }]; + + Self::finalize(all_tracks, groups, mode, filter) + } + + /// Общая инициализация ListState и позиций курсора. + fn finalize( + all_tracks: Vec, + groups: Vec, + mode: SortMode, + filter: String, + ) -> Self { + let right_selected = first_track_row_in(groups.first()); + + let mut left_state = ListState::default(); + let mut right_state = ListState::default(); + if !groups.is_empty() { + left_state.select(Some(0)); + } + right_state.select(Some(right_selected)); + + Library { + all_tracks, + artists: groups, + artist_selected: 0, + right_selected, + sort_mode: mode, + filter, + left_state, + right_state, } } @@ -191,7 +248,6 @@ impl Library { self.artist_selected = new as usize; self.right_selected = self.first_track_row(); - // Синхронизируем ListState — иначе ratatui не знает о смене позиции self.left_state.select(Some(self.artist_selected)); self.right_state.select(Some(self.right_selected)); } @@ -255,8 +311,8 @@ impl Library { } } -fn first_track_row_in(artist: Option<&ArtistView>) -> usize { - artist +fn first_track_row_in(group: Option<&ArtistView>) -> usize { + group .and_then(|a| { a.rows .iter() diff --git a/src/main.rs b/src/main.rs index 369c30d..42e915f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ mod ui; use std::time::Duration; -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crate::app::{App, InputMode}; @@ -27,6 +27,7 @@ fn main() -> Result<(), Box> { loop { app.maybe_advance(); + app.tick_status(); // ← автосброс статуса через 2 секунды app.player.spectrum.recompute(); terminal.draw(|f| ui::draw(f, &mut app))?; @@ -35,7 +36,7 @@ fn main() -> Result<(), Box> { if key.kind == KeyEventKind::Release { continue; } - if handle_key(key.code, &mut app) { + if handle_key(key, &mut app) { app.on_exit(); break; } @@ -47,10 +48,48 @@ fn main() -> Result<(), Box> { Ok(()) } -fn handle_key(code: KeyCode, app: &mut App) -> bool { - // Режим поиска +fn handle_key(key: KeyEvent, app: &mut App) -> bool { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + if key.code == KeyCode::F(1) { + app.toggle_help(); + return false; + } + + if app.show_help { + if key.code == KeyCode::Esc { + app.toggle_help(); + } + return false; + } + + if ctrl { + match key.code { + KeyCode::Right => { + app.next_track(); + return false; + } + KeyCode::Left => { + app.prev_track(); + return false; + } + KeyCode::Char(c) => match c.to_ascii_lowercase() { + 'd' => { + app.next_track(); + return false; + } + 'a' => { + app.prev_track(); + return false; + } + _ => {} + }, + _ => {} + } + } + if app.input_mode == InputMode::Search { - match code { + match key.code { KeyCode::Esc => app.cancel_search(), KeyCode::Enter => app.commit_search(), KeyCode::Backspace => app.search_pop(), @@ -60,8 +99,7 @@ fn handle_key(code: KeyCode, app: &mut App) -> bool { return false; } - // Обычный режим - match code { + match key.code { KeyCode::Char('+') | KeyCode::Char('=') => { app.volume_up(); return false; @@ -101,7 +139,7 @@ fn handle_key(code: KeyCode, app: &mut App) -> bool { _ => {} } - if let KeyCode::Char(c) = code { + if let KeyCode::Char(c) = key.code { match c { '1'..='9' => { let pct = (c as u8 - b'0') * 10; @@ -117,7 +155,6 @@ fn handle_key(code: KeyCode, app: &mut App) -> bool { 't' => app.toggle_sort(), 'f' => app.jump_to_playing(), 'g' => { - // 'g' (строчная) — в начало, 'G' (заглавная) — в конец if c == 'G' { app.goto_end(); } else { diff --git a/src/state.rs b/src/state.rs index 583330a..e433881 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3,13 +3,18 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use crate::library::SortMode; +use crate::track::Track; + #[derive(Serialize, Deserialize, Default)] pub struct SavedState { pub last_path: Option, pub volume: Option, - pub sort_by_filename: Option, + pub sort_mode: Option, pub repeat: Option, pub shuffle: Option, + pub last_playing_path: Option, + pub last_playing_pos_secs: Option, } #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)] @@ -24,6 +29,10 @@ fn config_path() -> Option { dirs::config_dir().map(|p| p.join("rumus").join("state.toml")) } +fn cache_path() -> Option { + dirs::data_dir().map(|p| p.join("rumus").join("library.bin")) +} + pub fn load() -> SavedState { let Some(path) = config_path() else { return SavedState::default(); @@ -44,4 +53,39 @@ pub fn save(state: &SavedState) { if let Ok(text) = toml::to_string_pretty(state) { let _ = fs::write(path, text); } +} + +// ── Кэш плейлиста (bincode) ──────────────────────────────────── + +#[derive(Serialize, Deserialize)] +pub struct PlaylistCache { + pub source_dir: String, + pub tracks: Vec, +} + +pub fn load_cache(expected_dir: &str) -> 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 + } +} + +pub fn save_cache(source_dir: &str, 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) { + let _ = fs::write(path, bytes); + } } \ No newline at end of file diff --git a/src/track.rs b/src/track.rs index bd12ed3..53a250c 100644 --- a/src/track.rs +++ b/src/track.rs @@ -3,8 +3,9 @@ use std::time::Duration; use lofty::file::{AudioFile, TaggedFileExt}; use lofty::tag::Accessor; +use serde::{Deserialize, Serialize}; -#[derive(Clone)] +#[derive(Clone, Serialize, Deserialize)] pub struct Track { pub path: PathBuf, pub title: String, @@ -69,7 +70,6 @@ impl Track { } } -/// Собирает все аудиофайлы в директории (без сортировки) pub fn scan_dir(dir: &str) -> Vec { use walkdir::WalkDir; let mut tracks = Vec::new(); diff --git a/src/ui.rs b/src/ui.rs index f065f4c..f2c60cd 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,6 +1,6 @@ use ratatui::prelude::*; use ratatui::widgets::{ - Bar, BarChart, BarGroup, Block, Borders, List, ListItem, Paragraph, + Bar, BarChart, BarGroup, Block, Borders, Clear, List, ListItem, Paragraph, }; use crate::app::{App, InputMode, Panel}; @@ -26,9 +26,15 @@ pub fn draw(f: &mut Frame, app: &mut App) { draw_right_panel(f, app, top[1]); draw_equalizer(f, app, chunks[1]); draw_status(f, app, chunks[2]); + + if app.show_help { + draw_help(f, f.area()); + } } fn draw_left_panel(f: &mut Frame, app: &mut App, area: Rect) { + f.render_widget(Clear, area); + let items: Vec = app .library .artists @@ -49,8 +55,14 @@ 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 { + SortMode::ByArtist => " Исполнители ", + SortMode::ByAlbum => " Альбомы ", + SortMode::ByFilename => " Источник ", + }; + let list = List::new(items) - .block(Block::default().borders(Borders::ALL).title(" Исполнители ")) + .block(Block::default().borders(Borders::ALL).title(title)) .highlight_style(highlight) .highlight_symbol("> "); @@ -66,6 +78,8 @@ fn fmt_dur(d: std::time::Duration) -> String { } fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) { + f.render_widget(Clear, area); + let rows = app.library.current_right_rows(); let items: Vec = rows @@ -137,20 +151,31 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) { }) .collect(); + let selected_name = app + .library + .artists + .get(app.library.artist_selected) + .map(|a| a.name.as_str()) + .unwrap_or("—"); + let title = match app.library.sort_mode { SortMode::ByArtist => { - let artist_name = app - .library - .artists - .get(app.library.artist_selected) - .map(|a| a.name.as_str()) - .unwrap_or("—"); if app.library.filter.is_empty() { - format!(" Альбомы и треки — {} ", artist_name) + format!(" Альбомы и треки — {} ", selected_name) } else { format!( " Альбомы и треки — {} [{}] ", - artist_name, app.library.filter + selected_name, app.library.filter + ) + } + } + SortMode::ByAlbum => { + if app.library.filter.is_empty() { + format!(" Исполнители и треки — {} ", selected_name) + } else { + format!( + " Исполнители и треки — {} [{}] ", + selected_name, app.library.filter ) } } @@ -235,8 +260,6 @@ fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) { } fn draw_status(f: &mut Frame, app: &App, area: Rect) { - // Раскладка нижней строки: - // [ Статус ................. ] [ 01:23 / 03:45 ] [ Mode ] [ vol: NN% ] let inner = Layout::default() .direction(Direction::Horizontal) .constraints([ @@ -247,7 +270,6 @@ 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 { @@ -259,7 +281,6 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) { .block(Block::default().borders(Borders::ALL).title(status_title)); f.render_widget(status, inner[0]); - // ── Время: текущее / полное ─────────────────────────────── let (pos, total) = app.progress(); let time_text = format!( "{:02}:{:02} / {:02}:{:02}", @@ -270,11 +291,9 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) { ); let time = Paragraph::new(time_text) .alignment(Alignment::Center) - .style(Style::default().fg(Color::Rgb(70, 130, 180))) .block(Block::default().borders(Borders::ALL).title(" Время ")); f.render_widget(time, inner[1]); - // ── Repeat / Shuffle ────────────────────────────────────── let mode_str = match (app.repeat, app.shuffle) { (RepeatMode::Off, false) => "".to_string(), (RepeatMode::Off, true) => "SHUF".to_string(), @@ -288,10 +307,74 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) { .block(Block::default().borders(Borders::ALL).title(" Mode ")); 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(" ")); f.render_widget(vol, inner[3]); +} + +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 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 header = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let title_color = Style::default().fg(Color::Rgb(100, 149, 237)); + + let help_text = vec![ + Line::from(Span::styled( + " Управление плеером ", + title_color.add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled(" Навигация", header)), + Line::from(" w / ↑ вверх по списку"), + Line::from(" s / ↓ вниз по списку"), + Line::from(" Tab переключить панель"), + Line::from(" g / G в начало / в конец списка"), + Line::from(" f к играющему треку"), + Line::from(""), + Line::from(Span::styled(" Воспроизведение", header)), + Line::from(" Enter играть выделенный трек"), + Line::from(" Space пауза / продолжить"), + Line::from(" z стоп"), + Line::from(" Ctrl+→ / Ctrl+d следующий трек"), + Line::from(" Ctrl+← / Ctrl+a предыдущий трек"), + Line::from(" r repeat: off → all → one"), + Line::from(" x shuffle вкл/выкл"), + Line::from(""), + Line::from(Span::styled(" Перемотка", header)), + Line::from(" a / ← −5 секунд"), + Line::from(" d / → +5 секунд"), + Line::from(" 1..9 перейти к 10%..90% трека"), + Line::from(""), + Line::from(Span::styled(" Звук и прочее", header)), + Line::from(" + / - громкость"), + Line::from(" / поиск"), + Line::from(" t сортировка: артист → альбом → файл"), + Line::from(" F1 эта справка"), + Line::from(" q / Esc выход"), + Line::from(""), + Line::from(Span::styled( + " F1 или Esc — закрыть ", + Style::default().fg(Color::DarkGray), + )), + ]; + + let help = Paragraph::new(help_text).block( + Block::default() + .borders(Borders::ALL) + .border_style(title_color) + .title(" Помощь "), + ); + + f.render_widget(help, area); } \ No newline at end of file