From 255a99ed0f465d08f78f3ef64dba57c624888004 Mon Sep 17 00:00:00 2001 From: AlexL Date: Tue, 13 Oct 2026 16:24:18 +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 --- Cargo.lock | 96 +++++++++++++++++++++++++++ Cargo.toml | 3 +- src/app.rs | 133 +++++++++++++++++++++++++++++++++++++ src/audio.rs | 81 +++++++++++++++++++++++ src/main.rs | 183 ++++++++------------------------------------------- src/track.rs | 70 ++++++++++++++++++++ src/ui.rs | 83 +++++++++++++++++++++++ 7 files changed, 491 insertions(+), 158 deletions(-) create mode 100644 src/app.rs create mode 100644 src/audio.rs create mode 100644 src/track.rs create mode 100644 src/ui.rs diff --git a/Cargo.lock b/Cargo.lock index f5967d3..e67a0bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "allocator-api2" version = "0.2.21" @@ -66,6 +72,12 @@ version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -169,6 +181,15 @@ dependencies = [ "windows", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -234,6 +255,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "dirs" version = "5.0.1" @@ -307,6 +334,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -515,6 +553,32 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lofty" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "log" version = "0.4.34" @@ -545,6 +609,16 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.4" @@ -736,6 +810,15 @@ dependencies = [ "objc2", ] +[[package]] +name = "ogg_pager" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216" +dependencies = [ + "byteorder", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -875,6 +958,7 @@ version = "0.1.0" dependencies = [ "crossterm", "dirs", + "lofty", "ratatui", "rodio", "walkdir", @@ -971,6 +1055,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simdutf8" version = "0.1.5" @@ -1704,3 +1794,9 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" diff --git a/Cargo.toml b/Cargo.toml index da32cba..2c54159 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,4 +8,5 @@ ratatui = "0.29" crossterm = "0.28" rodio = { version = "0.21", features = ["symphonia-all"] } walkdir = "2" -dirs = "5" \ No newline at end of file +dirs = "5" +lofty = "0.22" diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..d99faa0 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,133 @@ +use std::time::Duration; + +use ratatui::widgets::ListState; + +use crate::audio::AudioPlayer; +use crate::track::{scan_dir, Track}; + +pub struct App { + pub tracks: Vec, + pub list_state: ListState, + pub player: AudioPlayer, + pub status: String, + pub playing_index: Option, +} + +impl App { + pub fn new(music_dir: &str) -> Result> { + let tracks = scan_dir(music_dir); + let mut list_state = ListState::default(); + if !tracks.is_empty() { + list_state.select(Some(0)); + } + Ok(Self { + tracks, + list_state, + player: AudioPlayer::new()?, + status: "Нажмите Enter для воспроизведения".into(), + playing_index: None, + }) + } + + pub fn next(&mut self) { + if self.tracks.is_empty() { return; } + let i = match self.list_state.selected() { + Some(i) if i >= self.tracks.len() - 1 => 0, + Some(i) => i + 1, + None => 0, + }; + self.list_state.select(Some(i)); + } + + pub fn prev(&mut self) { + if self.tracks.is_empty() { return; } + let i = match self.list_state.selected() { + Some(0) => self.tracks.len() - 1, + Some(i) => i - 1, + None => 0, + }; + self.list_state.select(Some(i)); + } + + pub fn play_index(&mut self, index: usize) { + let Some(track) = self.tracks.get(index) else { return; }; + match self.player.play(&track.path) { + Ok(_) => { + self.status = format!("▶ {} — {}", track.artist, track.title); + self.playing_index = Some(index); + } + Err(e) => self.status = format!("Ошибка: {}", e), + } + } + + pub fn play_selected(&mut self) { + if let Some(i) = self.list_state.selected() { + self.play_index(i); + } + } + + pub fn stop(&mut self) { + self.player.stop(); + self.playing_index = None; + self.status = "Остановлено".into(); + } + + pub fn toggle_pause(&mut self) { + self.player.toggle_pause(); + } + + 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.tracks.len() => i + 1, + _ => 0, + }; + self.play_index(next); + self.list_state.select(Some(next)); + } + + pub fn volume_up(&mut self) { + self.player.change_volume(0.05); + self.status = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32); + } + + pub fn volume_down(&mut self) { + self.player.change_volume(-0.05); + self.status = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32); + } + + pub fn seek(&mut self, delta_secs: i64) { + if self.playing_index.is_none() { + self.status = "Ничего не воспроизводится".into(); + return; + } + let total = self + .playing_index + .and_then(|i| self.tracks.get(i)) + .map(|t| t.duration) + .unwrap_or(Duration::ZERO); + + match self.player.seek_by(delta_secs, total) { + Ok(new_pos) => { + let sign = if delta_secs >= 0 { "+" } else { "" }; + self.status = format!( + "Перемотка {}{}с → {:02}:{:02}", + sign, delta_secs, + new_pos.as_secs() / 60, + new_pos.as_secs() % 60, + ); + } + Err(_) => self.status = "Перемотка недоступна для этого формата".into(), + } + } + + /// Текущая позиция и длина играющего трека (для прогресс-бара) + pub fn progress(&self) -> (Duration, Duration) { + match self.playing_index.and_then(|i| self.tracks.get(i)) { + Some(t) => (self.player.position(), t.duration), + None => (Duration::ZERO, Duration::ZERO), + } + } +} \ No newline at end of file diff --git a/src/audio.rs b/src/audio.rs new file mode 100644 index 0000000..6494201 --- /dev/null +++ b/src/audio.rs @@ -0,0 +1,81 @@ +use std::fs::File; +use std::io::BufReader; +use std::path::PathBuf; +use std::time::Duration; + +use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink}; + +pub struct AudioPlayer { + _stream: OutputStream, + sink: Sink, + paused_pos: Option, +} + +impl AudioPlayer { + pub fn new() -> Result> { + let stream_handle = OutputStreamBuilder::open_default_stream()?; + let sink = Sink::connect_new(&stream_handle.mixer()); + sink.set_volume(1.0); + Ok(Self { _stream: stream_handle, sink, paused_pos: None }) + } + + 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); + self.sink.play(); + Ok(()) + } + + pub fn position(&self) -> Duration { + if self.sink.is_paused() { + self.paused_pos.unwrap_or_else(|| self.sink.get_pos()) + } else { + self.sink.get_pos() + } + } + + pub fn toggle_pause(&mut self) { + if self.sink.is_paused() { + self.sink.play(); + self.paused_pos = None; + } else { + self.paused_pos = Some(self.sink.get_pos()); + self.sink.pause(); + } + } + + pub fn stop(&mut self) { + self.sink.stop(); + self.paused_pos = None; + } + + pub fn is_finished(&self) -> bool { + self.sink.empty() + } + + pub fn volume(&self) -> f32 { + self.sink.volume() + } + + 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_by(&mut self, delta: i64, total: Duration) -> Result> { + let current = self.position(); + let new_secs = (current.as_secs() as i64 + delta).max(0) as u64; + let mut new_pos = Duration::from_secs(new_secs); + 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); + } + Ok(new_pos) + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 30ecc3e..277e7ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,176 +1,45 @@ -use std::fs::File; -use std::io::BufReader; -use std::path::PathBuf; +mod app; +mod audio; +mod track; +mod ui; + use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; -use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; -use rodio::{Decoder, OutputStreamBuilder, Sink}; -use walkdir::WalkDir; -/// Аудио-плеер на базе rodio -struct AudioPlayer { - // Важно: _stream держим живым, иначе звук обрывается - _stream: rodio::OutputStream, - sink: Sink, -} - -impl AudioPlayer { - fn new() -> Result> { - let stream_handle = rodio::OutputStreamBuilder::open_default_stream()?; - let sink = Sink::connect_new(&stream_handle.mixer()); - Ok(Self { _stream: stream_handle, sink }) - } - - /// Заменить текущий трек и начать воспроизведение - fn play(&self, path: &PathBuf) -> Result<(), Box> { - self.sink.stop(); - let file = BufReader::new(File::open(path)?); - let source = Decoder::new(file)?; - self.sink.append(source); - self.sink.play(); - Ok(()) - } - - fn toggle_pause(&self) { - if self.sink.is_paused() { - self.sink.play(); - } else { - self.sink.pause(); - } - } - - fn stop(&self) { - self.sink.stop(); - } -} - -/// Состояние приложения -struct App { - files: Vec, - list_state: ListState, - player: AudioPlayer, - status: String, -} - -impl App { - fn new(music_dir: &str) -> Result> { - let mut files = Vec::new(); - for entry in WalkDir::new(music_dir) - .follow_links(true) - .into_iter() - .filter_map(|e| e.ok()) - { - let path = entry.path(); - if path.is_file() { - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - if matches!(ext.to_lowercase().as_str(), "mp3" | "flac" | "ogg" | "wav" | "m4a") { - files.push(path.to_path_buf()); - } - } - } - } - files.sort(); - - let mut list_state = ListState::default(); - if !files.is_empty() { - list_state.select(Some(0)); - } - - Ok(Self { - files, - list_state, - player: AudioPlayer::new()?, - status: "Нажмите Enter для воспроизведения".into(), - }) - } - - fn next(&mut self) { - if self.files.is_empty() { return; } - let i = match self.list_state.selected() { - Some(i) => if i >= self.files.len() - 1 { 0 } else { i + 1 }, - None => 0, - }; - self.list_state.select(Some(i)); - } - - fn prev(&mut self) { - if self.files.is_empty() { return; } - let i = match self.list_state.selected() { - Some(i) => if i == 0 { self.files.len() - 1 } else { i - 1 }, - None => 0, - }; - self.list_state.select(Some(i)); - } - - fn play_selected(&mut self) { - if let Some(i) = self.list_state.selected() { - if let Some(path) = self.files.get(i) { - match self.player.play(path) { - Ok(_) => { - let name = path.file_name().unwrap_or_default().to_string_lossy(); - self.status = format!("▶ {}", name); - } - Err(e) => self.status = format!("Ошибка: {}", e), - } - } - } - } -} +use crate::app::App; fn main() -> Result<(), Box> { - // Папка с музыкой — можно передать аргументом, по умолчанию ~/Music - 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 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 mut terminal = ratatui::init(); let mut app = App::new(&music_dir)?; loop { - terminal.draw(|f| { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(3)]) - .split(f.area()); + app.maybe_advance(); + terminal.draw(|f| ui::draw(f, &mut app))?; - // Список файлов - let items: Vec = app.files.iter() - .map(|p| { - let name = p.file_name().unwrap_or_default().to_string_lossy(); - ListItem::new(name.to_string()) - }) - .collect(); - - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).title(" Треки ")) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) - .highlight_symbol("> "); - - f.render_stateful_widget(list, chunks[0], &mut app.list_state); - - // Статус-бар - let status = Paragraph::new(app.status.as_str()) - .block(Block::default().borders(Borders::ALL).title(" Статус ")); - f.render_widget(status, chunks[1]); - })?; - - // Обработка клавиш if event::poll(Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { - if key.kind != KeyEventKind::Press { continue; } + if key.kind != KeyEventKind::Press { + continue; + } match key.code { - KeyCode::Char('q') | KeyCode::Esc => break, - KeyCode::Up | KeyCode::Char('k') => app.prev(), - KeyCode::Down | KeyCode::Char('j') => app.next(), + //навигация + KeyCode::Char('q') | KeyCode::Esc => break, + KeyCode::Up | KeyCode::Char('w') => app.prev(), + KeyCode::Down | KeyCode::Char('s') => app.next(), + KeyCode::Left | KeyCode::Char('a') => app.seek(-5), + KeyCode::Right | KeyCode::Char('d') => app.seek(5), KeyCode::Enter => app.play_selected(), - KeyCode::Char(' ') => app.player.toggle_pause(), - KeyCode::Char('s') => app.player.stop(), + KeyCode::Char(' ') => app.toggle_pause(), + KeyCode::Char('z') => app.stop(), + KeyCode::Char('+') | KeyCode::Char('=') => app.volume_up(), + KeyCode::Char('-') | KeyCode::Char('_') => app.volume_down(), _ => {} } } diff --git a/src/track.rs b/src/track.rs new file mode 100644 index 0000000..0f1e534 --- /dev/null +++ b/src/track.rs @@ -0,0 +1,70 @@ +use std::path::PathBuf; +use std::time::Duration; + +use lofty::file::{AudioFile, TaggedFileExt}; +use lofty::tag::Accessor; + +pub struct Track { + pub path: PathBuf, + pub title: String, + pub artist: String, + pub album: String, + pub duration: Duration, +} + +impl Track { + pub fn from_path(path: &PathBuf) -> Self { + let fallback_name = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + match lofty::read_from_path(path) { + Ok(tagged) => { + let duration = tagged.properties().duration(); + let (title, artist, album) = match tagged + .primary_tag() + .or_else(|| tagged.first_tag()) + { + Some(tag) => ( + tag.title().map(|s| s.to_string()).unwrap_or_else(|| fallback_name.clone()), + tag.artist().map(|s| s.to_string()).unwrap_or_else(|| "Unknown Artist".into()), + tag.album().map(|s| s.to_string()).unwrap_or_else(|| "Unknown Album".into()), + ), + None => (fallback_name, "Unknown Artist".into(), "Unknown Album".into()), + }; + Track { path: path.clone(), title, artist, album, duration } + } + Err(_) => Track { + path: path.clone(), + title: fallback_name, + artist: "Unknown Artist".into(), + album: "Unknown Album".into(), + duration: Duration::ZERO, + }, + } + } +} + +/// Рекурсивно собирает все аудиофайлы в директории +pub fn scan_dir(dir: &str) -> Vec { + use walkdir::WalkDir; + let mut tracks = Vec::new(); + for entry in WalkDir::new(dir).follow_links(true).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + if path.is_file() { + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if matches!(ext.to_lowercase().as_str(), "mp3" | "flac" | "ogg" | "wav" | "m4a") { + tracks.push(Track::from_path(&path.to_path_buf())); + } + } + } + } + tracks.sort_by(|a, b| { + a.artist.cmp(&b.artist) + .then(a.album.cmp(&b.album)) + .then(a.title.cmp(&b.title)) + }); + tracks +} \ No newline at end of file diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..599ef79 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,83 @@ +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, Paragraph}; + +use crate::app::App; + +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), + ]) + .split(f.area()); + + draw_track_list(f, app, chunks[0]); + draw_progress(f, app, chunks[1]); + draw_volume(f, app, chunks[2]); + draw_status(f, app, chunks[3]); +} + +fn draw_track_list(f: &mut Frame, app: &mut App, area: Rect) { + let items: Vec = app + .tracks + .iter() + .enumerate() + .map(|(i, t)| { + let marker = if Some(i) == app.playing_index { "♪ " } else { " " }; + ListItem::new(format!("{}{} - {}", marker, t.artist, t.title)) + }) + .collect(); + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(" Треки ")) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol("> "); + + f.render_stateful_widget(list, area, &mut app.list_state); +} + +fn draw_progress(f: &mut Frame, app: &App, area: Rect) { + let (pos, total) = app.progress(); + let ratio = if total.as_secs_f64() > 0.0 { + (pos.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0) + } else { + 0.0 + }; + + let gauge = Gauge::default() + .block(Block::default().borders(Borders::ALL).title(" Прогресс ")) + .gauge_style(Style::default().fg(Color::Green)) + .ratio(ratio) + .label(format!( + "{:02}:{:02} / {:02}:{:02}", + pos.as_secs() / 60, pos.as_secs() % 60, + total.as_secs() / 60, total.as_secs() % 60, + )); + + 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 } + else { Color::Blue }; + + 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)); + + f.render_widget(gauge, area); +} + +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); +} \ No newline at end of file