diff --git a/Cargo.lock b/Cargo.lock index e67a0bc..308a1cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -676,6 +676,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -872,13 +881,22 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.17+spec-1.1.0", ] [[package]] @@ -961,9 +979,26 @@ dependencies = [ "lofty", "ratatui", "rodio", + "rustfft", + "serde", + "toml", "walkdir", ] +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rustix" version = "0.38.44" @@ -1004,6 +1039,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -1024,6 +1069,15 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "signal-hook" version = "0.3.18" @@ -1085,6 +1139,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "strsim" version = "0.11.1" @@ -1359,6 +1419,27 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "1.1.2+spec-1.1.0" @@ -1368,6 +1449,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.17+spec-1.1.0" @@ -1375,9 +1470,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -1386,7 +1481,23 @@ version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", ] [[package]] @@ -1786,6 +1897,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 2c54159..3e97ca4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,6 @@ rodio = { version = "0.21", features = ["symphonia-all"] } walkdir = "2" dirs = "5" lofty = "0.22" +serde = { version = "1", features = ["derive"] } +toml = "0.8" +rustfft = "6" \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 879073f..dd5f2df 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,48 +2,149 @@ use std::time::Duration; use crate::audio::AudioPlayer; use crate::library::{Library, SortMode}; +use crate::state::{RepeatMode, SavedState}; use crate::track::scan_dir; -/// Какая панель активна #[derive(Clone, Copy, PartialEq)] pub enum Panel { Left, Right, } +#[derive(Clone, Copy, PartialEq)] +pub enum InputMode { + Normal, + Search, +} + pub struct App { pub library: Library, pub player: AudioPlayer, pub status: String, pub playing_index: Option, pub active_panel: Panel, + pub input_mode: InputMode, + pub search_buffer: String, + /// Исходный (неотфильтрованный) список треков — для пересборки библиотеки + raw_tracks: Vec, + /// Директория с музыкой — чтобы сохранить в state + music_dir: String, + pub repeat: RepeatMode, + pub shuffle: bool, } impl App { pub fn new(music_dir: &str) -> Result> { let tracks = scan_dir(music_dir); - let library = Library::build(tracks, SortMode::ByArtist); + let library = Library::build(tracks.clone(), SortMode::ByArtist); - Ok(Self { + let mut app = Self { library, player: AudioPlayer::new()?, - status: "Enter — play, t — сортировка, Tab — панель".into(), + status: "? — help".into(), playing_index: None, active_panel: Panel::Left, - }) + input_mode: InputMode::Normal, + search_buffer: String::new(), + raw_tracks: tracks, + music_dir: music_dir.to_string(), + repeat: RepeatMode::Off, + shuffle: false, + }; + + // Восстанавливаем сохранённое состояние + let saved = crate::state::load(); + if let Some(vol) = saved.volume { + app.player.set_volume(vol); + } + if let Some(rep) = saved.repeat { + app.repeat = rep; + } + 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); + } + + Ok(app) } - /// Навигация вверх с учётом активной панели + fn persist(&self) { + 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), + repeat: Some(self.repeat), + shuffle: Some(self.shuffle), + }; + crate::state::save(&state); + } + + pub fn on_exit(&self) { + self.persist(); + } + + // ── Поиск ────────────────────────────────────────────────── + + pub fn start_search(&mut self) { + self.input_mode = InputMode::Search; + self.search_buffer.clear(); + self.status = "Поиск: ".into(); + } + + pub fn cancel_search(&mut self) { + self.input_mode = InputMode::Normal; + self.search_buffer.clear(); + self.rebuild_library(); + self.status = "Поиск отменён".into(); + } + + pub fn commit_search(&mut self) { + self.input_mode = InputMode::Normal; + self.status = if self.search_buffer.is_empty() { + "Фильтр сброшен".into() + } else { + format!("Фильтр: {}", self.search_buffer) + }; + } + + pub fn search_push(&mut self, c: char) { + self.search_buffer.push(c); + self.rebuild_library(); + self.status = format!("Поиск: {}", self.search_buffer); + } + + pub fn search_pop(&mut self) { + self.search_buffer.pop(); + self.rebuild_library(); + self.status = format!("Поиск: {}", self.search_buffer); + } + + 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(), + ); + 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.left_move(-1), + Panel::Left => self.library.artist_move(-1), Panel::Right => self.library.right_move(-1), } } pub fn down(&mut self) { match self.active_panel { - Panel::Left => self.library.left_move(1), + Panel::Left => self.library.artist_move(1), Panel::Right => self.library.right_move(1), } } @@ -54,11 +155,43 @@ impl App { Panel::Right => Panel::Left, }; self.status = match self.active_panel { - Panel::Left => "Активна: левая панель".into(), - Panel::Right => "Активна: правая панель".into(), + Panel::Left => "Активна: исполнители".into(), + Panel::Right => "Активна: альбомы/треки".into(), }; } + /// 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(); + } else { + self.status = "Ничего не играет".into(); + } + } + + /// g — в начало, G — в конец + pub fn goto_start(&mut self) { + match self.active_panel { + Panel::Left => self.library.artist_selected = 0, + Panel::Right => self.library.right_goto_start(), + } + } + + pub fn goto_end(&mut self) { + match self.active_panel { + Panel::Left => { + if !self.library.artists.is_empty() { + self.library.artist_selected = self.library.artists.len() - 1; + } + } + 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; @@ -75,6 +208,8 @@ impl App { pub fn play_selected(&mut self) { if let Some(idx) = self.library.right_track_index() { self.play_index(idx); + } else { + self.status = "Выделен заголовок альбома".into(); } } @@ -88,18 +223,76 @@ impl App { self.player.toggle_pause(); } + /// Автопереход с учётом repeat и shuffle pub fn maybe_advance(&mut self) { if self.playing_index.is_none() || !self.player.is_finished() { return; } - let next = match self.playing_index { - Some(i) if i + 1 < self.library.all_tracks.len() => i + 1, - _ => 0, + + // Repeat One — играем тот же трек + if self.repeat == RepeatMode::One { + if let Some(i) = self.playing_index { + self.play_index(i); + } + return; + } + + let total = self.library.all_tracks.len(); + if total == 0 { + return; + } + + 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) + .unwrap_or(0); + seed % total + } else { + match self.playing_index { + Some(i) if i + 1 < total => i + 1, + _ => { + if self.repeat == RepeatMode::All { + 0 + } else { + // Off — останавливаемся + self.status = "Конец плейлиста".into(); + self.playing_index = None; + return; + } + } + } }; + self.play_index(next); self.library.sync_with_playing(next); } + pub fn cycle_repeat(&mut self) { + self.repeat = match self.repeat { + RepeatMode::Off => RepeatMode::All, + 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(), + }; + } + + pub fn toggle_shuffle(&mut self) { + self.shuffle = !self.shuffle; + self.status = if self.shuffle { + "Shuffle: on".into() + } else { + "Shuffle: off".into() + }; + } + + // ── Громкость, перемотка ─────────────────────────────────── + pub fn volume_up(&mut self) { self.player.change_volume(0.05); self.status = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32); @@ -136,6 +329,37 @@ impl App { } } + /// Перемотка по цифрам 1–9 (10%..90%) + pub fn seek_percent(&mut self, percent: u8) { + if self.playing_index.is_none() { + self.status = "Ничего не воспроизводится".into(); + return; + } + let total = match self + .playing_index + .and_then(|i| self.library.track(i)) + .map(|t| t.duration) + { + Some(d) if !d.is_zero() => d, + _ => { + self.status = "Длительность неизвестна".into(); + return; + } + }; + let target = total.mul_f32(percent as f32 / 100.0); + match self.player.seek_to(target) { + Ok(_) => { + self.status = format!( + "→ {}% ({:02}:{:02})", + percent, + target.as_secs() / 60, + target.as_secs() % 60, + ); + } + Err(_) => self.status = "Перемотка недоступна".into(), + } + } + pub fn progress(&self) -> (Duration, Duration) { match self.playing_index.and_then(|i| self.library.track(i)) { Some(t) => (self.player.position(), t.duration), @@ -154,8 +378,11 @@ impl App { .and_then(|i| self.library.track(i)) .map(|t| t.path.clone()); - let all = self.library.all_tracks.clone(); - self.library = Library::build(all, new_mode); + self.library = Library::build_with_filter( + self.raw_tracks.clone(), + new_mode, + self.search_buffer.clone(), + ); self.playing_index = playing_path .and_then(|p| self.library.all_tracks.iter().position(|t| t.path == p)); diff --git a/src/audio.rs b/src/audio.rs index fd95cd0..3dc6a18 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -1,14 +1,140 @@ +use std::collections::VecDeque; use std::fs::File; -use std::io::BufReader; use std::path::PathBuf; +use std::sync::{Arc, Mutex}; use std::time::Duration; -use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink}; +use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink, Source}; + +const FFT_SIZE: usize = 1024; +const NUM_BARS: usize = 64; + +#[derive(Clone)] +pub struct SpectrumBus { + buffer: Arc>>, + bars: Arc>, +} + +impl SpectrumBus { + pub fn new() -> Self { + Self { + buffer: Arc::new(Mutex::new(VecDeque::with_capacity(FFT_SIZE * 2))), + bars: Arc::new(Mutex::new([0.0; NUM_BARS])), + } + } + + fn push_sample(&self, s: f32) { + if let Ok(mut buf) = self.buffer.lock() { + buf.push_back(s); + if buf.len() > FFT_SIZE * 2 { + buf.pop_front(); + } + } + } + + pub fn bars(&self) -> [f32; NUM_BARS] { + self.bars.lock().map(|b| *b).unwrap_or([0.0; NUM_BARS]) + } + + pub fn recompute(&self) { + use rustfft::num_complex::Complex; + use rustfft::FftPlanner; + + let samples: Vec = match self.buffer.lock() { + Ok(buf) => buf.iter().rev().take(FFT_SIZE).rev().copied().collect(), + Err(_) => return, + }; + if samples.len() < FFT_SIZE { + return; + } + + let mut data: Vec> = samples + .iter() + .enumerate() + .map(|(i, &s)| { + let w = 0.5 + * (1.0 + - (2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE - 1) as f32).cos()); + Complex::new(s * w, 0.0) + }) + .collect(); + + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(FFT_SIZE); + fft.process(&mut data); + + let half = FFT_SIZE / 2; + let mut bars = [0.0f32; NUM_BARS]; + for i in 0..NUM_BARS { + let lo = ((i as f32 / NUM_BARS as f32).powi(2) * half as f32) as usize; + let hi = (((i + 1) as f32 / NUM_BARS as f32).powi(2) * half as f32) as usize; + let hi = hi.max(lo + 1).min(half); + + let mut sum = 0.0; + for k in lo..hi { + sum += data[k].norm(); + } + let avg = sum / (hi - lo) as f32; + let v = (avg / FFT_SIZE as f32).sqrt() * 4.0; + bars[i] = v.clamp(0.0, 1.0); + } + + if let Ok(mut current) = self.bars.lock() { + for i in 0..NUM_BARS { + if bars[i] > current[i] { + current[i] = bars[i]; + } else { + current[i] = current[i] * 0.7 + bars[i] * 0.3; + } + } + } + } +} + +struct TapSource { + inner: S, + bus: SpectrumBus, +} + +impl Iterator for TapSource +where + S: Source, +{ + type Item = f32; + fn next(&mut self) -> Option { + let s = self.inner.next()?; + self.bus.push_sample(s); + Some(s) + } +} + +impl Source for TapSource +where + S: Source, +{ + fn current_span_len(&self) -> Option { + self.inner.current_span_len() + } + fn channels(&self) -> u16 { + self.inner.channels() + } + fn sample_rate(&self) -> u32 { + self.inner.sample_rate() + } + 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) + } +} pub struct AudioPlayer { _stream: OutputStream, sink: Sink, paused_pos: Option, + pub spectrum: SpectrumBus, } impl AudioPlayer { @@ -20,15 +146,21 @@ impl AudioPlayer { _stream: stream_handle, sink, paused_pos: None, + spectrum: SpectrumBus::new(), }) } pub fn play(&mut self, path: &PathBuf) -> Result<(), Box> { self.sink.stop(); self.paused_pos = None; - let file = BufReader::new(File::open(path)?); - let source = Decoder::new(file)?; - self.sink.append(source); + let file = File::open(path)?; + // Decoder::try_from автоматически настраивает seekable и byte_len[citation:1][citation:19] + let decoder = Decoder::try_from(file)?; + let tapped = TapSource { + inner: decoder, + bus: self.spectrum.clone(), + }; + self.sink.append(tapped); self.sink.play(); Ok(()) } @@ -41,6 +173,10 @@ impl AudioPlayer { } } + pub fn is_paused(&self) -> bool { + self.sink.is_paused() + } + pub fn toggle_pause(&mut self) { if self.sink.is_paused() { self.sink.play(); @@ -64,11 +200,23 @@ impl AudioPlayer { self.sink.volume() } + pub fn set_volume(&mut self, v: f32) { + self.sink.set_volume(v.clamp(0.0, 2.0)); + } + pub fn change_volume(&mut self, delta: f32) { let v = (self.sink.volume() + delta).clamp(0.0, 2.0); self.sink.set_volume(v); } + pub fn seek_to(&mut self, pos: Duration) -> Result<(), Box> { + self.sink.try_seek(pos)?; + if self.sink.is_paused() { + self.paused_pos = Some(pos); + } + Ok(()) + } + pub fn seek_by( &mut self, delta: i64, @@ -80,10 +228,7 @@ impl AudioPlayer { if !total.is_zero() && new_pos >= total { new_pos = total.saturating_sub(Duration::from_millis(100)); } - self.sink.try_seek(new_pos)?; - if self.sink.is_paused() { - self.paused_pos = Some(new_pos); - } + self.seek_to(new_pos)?; Ok(new_pos) } } \ No newline at end of file diff --git a/src/library.rs b/src/library.rs index c050b76..801215b 100644 --- a/src/library.rs +++ b/src/library.rs @@ -1,13 +1,8 @@ use crate::track::Track; -#[derive(Clone)] -pub enum LeftRow { - Artist { name: String }, - Album { name: String }, -} - #[derive(Clone)] pub enum RightRow { + AlbumHeader { name: String }, Track { index_in_library: usize }, } @@ -17,21 +12,41 @@ pub enum SortMode { ByFilename, } +pub struct ArtistView { + pub name: String, + pub rows: Vec, +} + pub struct Library { pub all_tracks: Vec, - /// Левая панель: артисты и альбомы - pub left_rows: Vec, - /// Для каждой строки левой панели — список индексов треков в all_tracks - pub left_to_tracks: Vec>, - /// Индекс выбранной строки левой панели - pub left_selected: usize, - /// Индекс выбранного трека в правой панели (внутри текущего набора) + pub artists: Vec, + pub artist_selected: usize, pub right_selected: usize, pub sort_mode: SortMode, + /// Активный фильтр поиска (пусто — без фильтра) + pub filter: String, } impl Library { - pub fn build(mut tracks: Vec, mode: SortMode) -> Self { + pub fn build(tracks: Vec, mode: SortMode) -> Self { + Self::build_with_filter(tracks, mode, String::new()) + } + + pub fn build_with_filter( + mut tracks: Vec, + mode: SortMode, + filter: String, + ) -> Self { + // Применяем фильтр + if !filter.is_empty() { + let f = filter.to_lowercase(); + tracks.retain(|t| { + t.title.to_lowercase().contains(&f) + || t.artist.to_lowercase().contains(&f) + || t.album.to_lowercase().contains(&f) + }); + } + match mode { SortMode::ByArtist => { use std::collections::BTreeMap; @@ -46,13 +61,10 @@ impl Library { } let mut all_tracks = Vec::new(); - let mut left_rows = Vec::new(); - let mut left_to_tracks: Vec> = Vec::new(); + let mut artists = Vec::new(); for (artist_name, albums_map) in map { - // Строка артиста: содержит все треки всех его альбомов - let mut artist_track_indices = Vec::new(); - let mut album_entries = Vec::new(); + let mut rows = Vec::new(); for (album_name, mut album_tracks) in albums_map { album_tracks.sort_by(|a, b| { @@ -62,36 +74,30 @@ impl Library { .then(a.title.cmp(&b.title)) }); - let mut album_indices = Vec::new(); + rows.push(RightRow::AlbumHeader { name: album_name }); + for t in album_tracks { let idx = all_tracks.len(); all_tracks.push(t); - album_indices.push(idx); - artist_track_indices.push(idx); + rows.push(RightRow::Track { + index_in_library: idx, + }); } - album_entries.push((album_name, album_indices)); } - // 1) строка артиста - left_rows.push(LeftRow::Artist { + artists.push(ArtistView { name: artist_name, + rows, }); - left_to_tracks.push(artist_track_indices); - - // 2) строки альбомов - for (album_name, indices) in album_entries { - left_rows.push(LeftRow::Album { name: album_name }); - left_to_tracks.push(indices); - } } Library { all_tracks, - left_rows, - left_to_tracks, - left_selected: 0, + artists, + artist_selected: 0, right_selected: 0, sort_mode: mode, + filter, } } @@ -99,18 +105,28 @@ impl Library { tracks.sort_by(|a, b| a.path.cmp(&b.path)); let all_tracks = tracks; - let left_rows = vec![LeftRow::Album { + 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 artists = vec![ArtistView { + name: "Все файлы".into(), + rows, }]; - let left_to_tracks = vec![(0..all_tracks.len()).collect()]; Library { all_tracks, - left_rows, - left_to_tracks, - left_selected: 0, + artists, + artist_selected: 0, right_selected: 0, sort_mode: mode, + filter, } } } @@ -120,82 +136,86 @@ impl Library { self.all_tracks.get(index) } - /// Индексы треков, относящихся к текущей выделенной строке левой панели - pub fn current_right_indices(&self) -> &[usize] { - self.left_to_tracks - .get(self.left_selected) - .map(|v| v.as_slice()) + pub fn current_right_rows(&self) -> &[RightRow] { + self.artists + .get(self.artist_selected) + .map(|a| a.rows.as_slice()) .unwrap_or(&[]) } - /// Индекс трека в all_tracks, выделенного в правой панели pub fn right_track_index(&self) -> Option { - self.current_right_indices() - .get(self.right_selected) - .copied() - } - - /// Найти строку левой панели, к которой относится трек (для синхронизации) - pub fn left_row_of_track(&self, index_in_library: usize) -> Option { - // Приоритет — альбом (более точное совпадение) - let mut album_match = None; - for (i, row) in self.left_rows.iter().enumerate() { - let contains = self - .left_to_tracks - .get(i) - .map(|v| v.contains(&index_in_library)) - .unwrap_or(false); - if contains { - match row { - LeftRow::Album { .. } => return Some(i), - LeftRow::Artist { .. } => album_match = Some(i), - } - } + match self.current_right_rows().get(self.right_selected)? { + RightRow::Track { index_in_library } => Some(*index_in_library), + RightRow::AlbumHeader { .. } => None, } - album_match } - /// Передвинуть курсор левой панели вверх/вниз, синхронизировать правую - pub fn left_move(&mut self, delta: isize) { - if self.left_rows.is_empty() { + pub fn artist_move(&mut self, delta: isize) { + if self.artists.is_empty() { return; } - let len = self.left_rows.len() as isize; - let mut new = self.left_selected as isize + delta; + let len = self.artists.len() as isize; + let mut new = self.artist_selected as isize + delta; if new < 0 { new = len - 1; } else if new >= len { new = 0; } - self.left_selected = new as usize; - self.right_selected = 0; + self.artist_selected = new as usize; + self.right_selected = self.first_track_row(); } - /// Передвинуть курсор правой панели вверх/вниз pub fn right_move(&mut self, delta: isize) { - let len = self.current_right_indices().len() as isize; - if len == 0 { + let track_rows: Vec = self + .current_right_rows() + .iter() + .enumerate() + .filter_map(|(i, r)| matches!(r, RightRow::Track { .. }).then_some(i)) + .collect(); + + if track_rows.is_empty() { return; } - let mut new = self.right_selected as isize + delta; - if new < 0 { - new = len - 1; - } else if new >= len { - new = 0; + + let current_pos = track_rows + .iter() + .position(|&i| i == self.right_selected) + .unwrap_or(0) as isize; + + let len = track_rows.len() as isize; + let mut new_pos = current_pos + delta; + if new_pos < 0 { + new_pos = len - 1; + } else if new_pos >= len { + new_pos = 0; } - self.right_selected = new as usize; + + self.right_selected = track_rows[new_pos as usize]; + } + + pub fn right_goto_start(&mut self) { + self.right_move(-1000000); + } + + pub fn right_goto_end(&mut self) { + self.right_move(1000000); + } + + fn first_track_row(&self) -> usize { + self.current_right_rows() + .iter() + .position(|r| matches!(r, RightRow::Track { .. })) + .unwrap_or(0) } - /// Синхронизировать правую панель с играющим треком pub fn sync_with_playing(&mut self, index_in_library: usize) { - if let Some(row) = self.left_row_of_track(index_in_library) { - self.left_selected = row; - if let Some(pos) = self - .left_to_tracks - .get(row) - .and_then(|v| v.iter().position(|&i| i == index_in_library)) - { + for (ai, artist) in self.artists.iter().enumerate() { + if let Some(pos) = artist.rows.iter().position(|r| { + matches!(r, RightRow::Track { index_in_library: i } if *i == index_in_library) + }) { + self.artist_selected = ai; self.right_selected = pos; + return; } } } diff --git a/src/main.rs b/src/main.rs index f739138..369c30d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod app; mod audio; mod library; +mod state; mod track; mod ui; @@ -8,28 +9,34 @@ use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; -use crate::app::App; +use crate::app::{App, InputMode}; fn main() -> Result<(), Box> { - let music_dir = std::env::args().nth(1).unwrap_or_else(|| { - dirs::home_dir() - .map(|h| h.join("Music").to_string_lossy().to_string()) - .unwrap_or_else(|| "./music".into()) - }); + let saved = state::load(); + let music_dir = std::env::args() + .nth(1) + .or_else(|| saved.last_path.clone()) + .unwrap_or_else(|| { + dirs::home_dir() + .map(|h| h.join("Music").to_string_lossy().to_string()) + .unwrap_or_else(|| "./music".into()) + }); let mut terminal = ratatui::init(); let mut app = App::new(&music_dir)?; loop { app.maybe_advance(); + app.player.spectrum.recompute(); terminal.draw(|f| ui::draw(f, &mut app))?; - if event::poll(Duration::from_millis(100))? { + if event::poll(Duration::from_millis(50))? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Release { continue; } if handle_key(key.code, &mut app) { + app.on_exit(); break; } } @@ -41,6 +48,19 @@ fn main() -> Result<(), Box> { } fn handle_key(code: KeyCode, app: &mut App) -> bool { + // Режим поиска + if app.input_mode == InputMode::Search { + match code { + KeyCode::Esc => app.cancel_search(), + KeyCode::Enter => app.commit_search(), + KeyCode::Backspace => app.search_pop(), + KeyCode::Char(c) => app.search_push(c), + _ => {} + } + return false; + } + + // Обычный режим match code { KeyCode::Char('+') | KeyCode::Char('=') => { app.volume_up(); @@ -82,15 +102,33 @@ fn handle_key(code: KeyCode, app: &mut App) -> bool { } if let KeyCode::Char(c) = code { - match c.to_ascii_lowercase() { - 'q' => return true, - 'w' => app.up(), - 's' => app.down(), - 'a' => app.seek(-5), - 'd' => app.seek(5), - 'z' => app.stop(), - 't' => app.toggle_sort(), - _ => {} + match c { + '1'..='9' => { + let pct = (c as u8 - b'0') * 10; + app.seek_percent(pct); + } + _ => match c.to_ascii_lowercase() { + 'q' => return true, + 'w' => app.up(), + 's' => app.down(), + 'a' => app.seek(-5), + 'd' => app.seek(5), + 'z' => app.stop(), + 't' => app.toggle_sort(), + 'f' => app.jump_to_playing(), + 'g' => { + // 'g' (строчная) — в начало, 'G' (заглавная) — в конец + if c == 'G' { + app.goto_end(); + } else { + app.goto_start(); + } + } + 'r' => app.cycle_repeat(), + 'x' => app.toggle_shuffle(), + '/' => app.start_search(), + _ => {} + }, } } false diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..583330a --- /dev/null +++ b/src/state.rs @@ -0,0 +1,47 @@ +use std::fs; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Default)] +pub struct SavedState { + pub last_path: Option, + pub volume: Option, + pub sort_by_filename: Option, + pub repeat: Option, + pub shuffle: Option, +} + +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)] +pub enum RepeatMode { + #[default] + Off, + All, + One, +} + +fn config_path() -> Option { + dirs::config_dir().map(|p| p.join("rumus").join("state.toml")) +} + +pub fn load() -> SavedState { + let Some(path) = config_path() else { + return SavedState::default(); + }; + match fs::read_to_string(&path) { + Ok(text) => toml::from_str(&text).unwrap_or_default(), + Err(_) => SavedState::default(), + } +} + +pub fn save(state: &SavedState) { + let Some(path) = config_path() else { + return; + }; + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(text) = toml::to_string_pretty(state) { + let _ = fs::write(path, text); + } +} \ No newline at end of file diff --git a/src/ui.rs b/src/ui.rs index fcdc96b..85f39fb 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,69 +1,164 @@ use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, Paragraph}; +use ratatui::widgets::{ + Bar, BarChart, BarGroup, Block, Borders, Gauge, List, ListItem, ListState, Paragraph, +}; -use crate::app::{App, Panel}; -use crate::library::{LeftRow, SortMode}; +use crate::app::{App, InputMode, Panel}; +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), - Constraint::Length(3), - Constraint::Length(3), + Constraint::Length(6), // эквалайзер + Constraint::Length(3), // прогресс + Constraint::Length(3), // статус + vol ]) .split(f.area()); - // Горизонтальное разделение верхней области: 40% слева, 60% справа let top = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) + .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) .split(chunks[0]); draw_left_panel(f, app, top[0]); draw_right_panel(f, app, top[1]); - draw_progress(f, app, chunks[1]); - draw_volume(f, app, chunks[2]); + draw_equalizer(f, app, chunks[1]); + draw_progress(f, app, chunks[2]); draw_status(f, app, chunks[3]); } fn draw_left_panel(f: &mut Frame, app: &App, area: Rect) { let items: Vec = app .library - .left_rows + .artists .iter() - .map(|row| match row { - LeftRow::Artist { name } => ListItem::new(Line::from(vec![ - Span::styled("▶ ", Style::default().fg(Color::Yellow)), - Span::styled( - name.clone(), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ), - ])), - LeftRow::Album { name } => ListItem::new(Line::from(vec![ - Span::raw(" "), - Span::styled(name.clone(), Style::default().fg(Color::Cyan)), - ])), - }) + .map(|a| ListItem::new(a.name.clone())) .collect(); - let title = match app.library.sort_mode { - SortMode::ByArtist => " Артисты / Альбомы ", - SortMode::ByFilename => " Источник ", - }; - let highlight = if app.active_panel == Panel::Left { Style::default().add_modifier(Modifier::REVERSED) } else { Style::default().add_modifier(Modifier::DIM) }; - let mut list_state = ratatui::widgets::ListState::default(); - if !app.library.left_rows.is_empty() { - list_state.select(Some(app.library.left_selected)); + let mut list_state = ListState::default(); + if !app.library.artists.is_empty() { + list_state.select(Some(app.library.artist_selected)); + } + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(" Исполнители ")) + .highlight_style(highlight) + .highlight_symbol("> "); + + f.render_stateful_widget(list, area, &mut list_state); +} + +fn fmt_dur(d: std::time::Duration) -> String { + if d.is_zero() { + "--:--".into() + } else { + format!("{}:{:02}", d.as_secs() / 60, d.as_secs() % 60) + } +} + +fn draw_right_panel(f: &mut Frame, app: &App, area: Rect) { + let rows = app.library.current_right_rows(); + + let items: Vec = rows + .iter() + .enumerate() + .map(|(idx, row)| match row { + RightRow::AlbumHeader { name } => { + let mut album_total = std::time::Duration::ZERO; + let mut i = idx + 1; + while i < rows.len() { + if let RightRow::Track { index_in_library } = rows[i] { + if let Some(t) = app.library.track(index_in_library) { + album_total += t.duration; + } + i += 1; + } else { + break; + } + } + let total_str = fmt_dur(album_total); + + let inner_width = area.width.saturating_sub(6) as usize; + let pad = inner_width + .saturating_sub(name.len() + total_str.len() + 3) + .max(1); + let line = format!("{} {} {}", name, "─".repeat(pad), total_str); + + let album_color = Color::Rgb(100, 149, 237); + + ListItem::new(Line::from(vec![ + Span::styled("▶ ", Style::default().fg(album_color)), + Span::styled( + line, + Style::default() + .fg(album_color) + .add_modifier(Modifier::BOLD), + ), + ])) + } + RightRow::Track { index_in_library } => { + let is_playing = Some(*index_in_library) == app.playing_index; + let marker = if is_playing { "♪ " } else { " " }; + + let (title, num, dur) = match app.library.track(*index_in_library) { + Some(t) => ( + t.title.clone(), + t.track_number + .map(|n| format!("{:02}. ", n)) + .unwrap_or_default(), + fmt_dur(t.duration), + ), + None => ("".into(), String::new(), "--:--".into()), + }; + + let style = if is_playing { + Style::default().fg(Color::Green) + } else { + Style::default() + }; + + 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)), + ])) + } + }) + .collect(); + + 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("—"); + format!(" Альбомы и треки — {} ", artist_name) + } + SortMode::ByFilename => " Альбомы и треки ".into(), + }; + + let highlight = if app.active_panel == Panel::Right { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default().add_modifier(Modifier::DIM) + }; + + let mut list_state = ListState::default(); + if !rows.is_empty() { + list_state.select(Some(app.library.right_selected)); } let list = List::new(items) @@ -74,55 +169,55 @@ fn draw_left_panel(f: &mut Frame, app: &App, area: Rect) { f.render_stateful_widget(list, area, &mut list_state); } -fn draw_right_panel(f: &mut Frame, app: &App, area: Rect) { - let indices = app.library.current_right_indices(); +/// Цветной эквалайзер: 32 столбика с градиентом от синего к красному +fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) { + let bars_data = app.player.spectrum.bars(); + let num_bars = 32; - let items: Vec = indices + let mut display: Vec = Vec::with_capacity(num_bars); + for i in 0..num_bars { + let a = bars_data[i * 2]; + let b = bars_data[i * 2 + 1]; + display.push((a + b) / 2.0); + } + + let bars: Vec = display .iter() - .map(|&idx| { - let is_playing = Some(idx) == app.playing_index; - let marker = if is_playing { "♪ " } else { " " }; - - let (title, num) = match app.library.track(idx) { - Some(t) => ( - t.title.clone(), - t.track_number - .map(|n| format!("{:02}. ", n)) - .unwrap_or_default(), - ), - None => ("".into(), String::new()), - }; - - let style = if is_playing { - Style::default().fg(Color::Green) + .enumerate() + .map(|(i, &v)| { + let ratio = i as f64 / (num_bars - 1).max(1) as f64; + let color = if ratio < 0.2 { + Color::Rgb(0, 100, 255) + } else if ratio < 0.4 { + Color::Rgb(0, 200, 255) + } else if ratio < 0.6 { + Color::Rgb(0, 255, 100) + } else if ratio < 0.8 { + Color::Rgb(255, 200, 0) } else { - Style::default() + Color::Rgb(255, 50, 50) }; - ListItem::new(Line::from(vec![ - Span::raw(marker), - Span::styled(format!("{}{}", num, title), style), - ])) + Bar::default() + .value((v * 100.0) as u64) + .style(Style::default().fg(color)) }) .collect(); - let highlight = if app.active_panel == Panel::Right { - Style::default().add_modifier(Modifier::REVERSED) - } else { - Style::default().add_modifier(Modifier::DIM) - }; + let chart = BarChart::default() + .data(BarGroup::default().bars(&bars)) + .bar_width(1) + .bar_gap(1) + .max(100) + .bar_set(ratatui::symbols::bar::NINE_LEVELS) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Эквалайзер ") + .title_style(Style::default().fg(Color::Rgb(100, 149, 237))), + ); - let mut list_state = ratatui::widgets::ListState::default(); - if !indices.is_empty() { - list_state.select(Some(app.library.right_selected)); - } - - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).title(" Треки ")) - .highlight_style(highlight) - .highlight_symbol("> "); - - f.render_stateful_widget(list, area, &mut list_state); + f.render_widget(chart, area); } fn draw_progress(f: &mut Frame, app: &App, area: Rect) { @@ -148,32 +243,42 @@ fn draw_progress(f: &mut Frame, app: &App, area: Rect) { f.render_widget(gauge, area); } -fn draw_volume(f: &mut Frame, app: &App, area: Rect) { - let vol = app.player.volume(); - let ratio = (vol as f64 / 2.0).clamp(0.0, 1.0); - let color = if vol > 1.0 { - Color::Yellow - } else if vol > 0.5 { - Color::Cyan +fn draw_status(f: &mut Frame, app: &App, area: Rect) { + let inner = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(1), + Constraint::Length(16), + Constraint::Length(12), + ]) + .split(area); + + let (status_text, status_title) = if app.input_mode == InputMode::Search { + (format!("> {}", app.search_buffer), " Поиск ") } else { - Color::Blue + (app.status.clone(), " Статус ") }; - let gauge = Gauge::default() - .block( - Block::default() - .borders(Borders::ALL) - .title(" Громкость (+/-) "), - ) - .gauge_style(Style::default().fg(color)) - .ratio(ratio) - .label(format!("{}%", (vol * 100.0) as u32)); + let status = Paragraph::new(status_text) + .block(Block::default().borders(Borders::ALL).title(status_title)); + f.render_widget(status, inner[0]); - f.render_widget(gauge, area); -} + let mode_str = match (app.repeat, app.shuffle) { + (RepeatMode::Off, false) => "".to_string(), + (RepeatMode::Off, true) => "SHUF".to_string(), + (RepeatMode::All, false) => "RPT ALL".to_string(), + (RepeatMode::One, false) => "RPT ONE".to_string(), + (RepeatMode::All, true) => "RPT+SHUF".to_string(), + (RepeatMode::One, true) => "ONE+SHUF".to_string(), + }; + let mode = Paragraph::new(mode_str) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL).title(" Mode ")); + f.render_widget(mode, inner[1]); -fn draw_status(f: &mut Frame, app: &App, area: Rect) { - let status = Paragraph::new(app.status.as_str()) - .block(Block::default().borders(Borders::ALL).title(" Статус ")); - f.render_widget(status, area); + 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(" Звук ")); + f.render_widget(vol, inner[2]); } \ No newline at end of file