diff --git a/src/app.rs b/src/app.rs index d99faa0..879073f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,68 +1,80 @@ use std::time::Duration; -use ratatui::widgets::ListState; - use crate::audio::AudioPlayer; -use crate::track::{scan_dir, Track}; +use crate::library::{Library, SortMode}; +use crate::track::scan_dir; + +/// Какая панель активна +#[derive(Clone, Copy, PartialEq)] +pub enum Panel { + Left, + Right, +} pub struct App { - pub tracks: Vec, - pub list_state: ListState, + pub library: Library, pub player: AudioPlayer, pub status: String, pub playing_index: Option, + pub active_panel: Panel, } 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)); - } + let library = Library::build(tracks, SortMode::ByArtist); + Ok(Self { - tracks, - list_state, + library, player: AudioPlayer::new()?, - status: "Нажмите Enter для воспроизведения".into(), + status: "Enter — play, t — сортировка, Tab — панель".into(), playing_index: None, + active_panel: Panel::Left, }) } - 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 up(&mut self) { + match self.active_panel { + Panel::Left => self.library.left_move(-1), + Panel::Right => self.library.right_move(-1), + } } - 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 down(&mut self) { + match self.active_panel { + Panel::Left => self.library.left_move(1), + Panel::Right => self.library.right_move(1), + } } - pub fn play_index(&mut self, index: usize) { - let Some(track) = self.tracks.get(index) else { return; }; + pub fn toggle_panel(&mut self) { + self.active_panel = match self.active_panel { + Panel::Left => Panel::Right, + Panel::Right => Panel::Left, + }; + self.status = match self.active_panel { + Panel::Left => "Активна: левая панель".into(), + Panel::Right => "Активна: правая панель".into(), + }; + } + + 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) { Ok(_) => { self.status = format!("▶ {} — {}", track.artist, track.title); - self.playing_index = Some(index); + self.playing_index = Some(index_in_library); } Err(e) => self.status = format!("Ошибка: {}", e), } } pub fn play_selected(&mut self) { - if let Some(i) = self.list_state.selected() { - self.play_index(i); + if let Some(idx) = self.library.right_track_index() { + self.play_index(idx); } } @@ -81,11 +93,11 @@ impl App { return; } let next = match self.playing_index { - Some(i) if i + 1 < self.tracks.len() => i + 1, + Some(i) if i + 1 < self.library.all_tracks.len() => i + 1, _ => 0, }; self.play_index(next); - self.list_state.select(Some(next)); + self.library.sync_with_playing(next); } pub fn volume_up(&mut self) { @@ -105,7 +117,7 @@ impl App { } let total = self .playing_index - .and_then(|i| self.tracks.get(i)) + .and_then(|i| self.library.track(i)) .map(|t| t.duration) .unwrap_or(Duration::ZERO); @@ -114,20 +126,47 @@ impl App { let sign = if delta_secs >= 0 { "+" } else { "" }; self.status = format!( "Перемотка {}{}с → {:02}:{:02}", - sign, delta_secs, + sign, + delta_secs, new_pos.as_secs() / 60, new_pos.as_secs() % 60, ); } - Err(_) => self.status = "Перемотка недоступна для этого формата".into(), + Err(_) => self.status = "Перемотка недоступна".into(), } } - /// Текущая позиция и длина играющего трека (для прогресс-бара) pub fn progress(&self) -> (Duration, Duration) { - match self.playing_index.and_then(|i| self.tracks.get(i)) { + match self.playing_index.and_then(|i| self.library.track(i)) { Some(t) => (self.player.position(), t.duration), None => (Duration::ZERO, Duration::ZERO), } } + + pub fn toggle_sort(&mut self) { + let new_mode = match self.library.sort_mode { + SortMode::ByArtist => SortMode::ByFilename, + SortMode::ByFilename => SortMode::ByArtist, + }; + + let playing_path = self + .playing_index + .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.playing_index = playing_path + .and_then(|p| self.library.all_tracks.iter().position(|t| t.path == p)); + + if let Some(i) = self.playing_index { + self.library.sync_with_playing(i); + } + + self.status = match new_mode { + SortMode::ByArtist => "Сортировка: по артисту/альбому".into(), + SortMode::ByFilename => "Сортировка: по имени файла".into(), + }; + } } \ No newline at end of file diff --git a/src/audio.rs b/src/audio.rs index 6494201..fd95cd0 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -16,7 +16,11 @@ impl AudioPlayer { 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 }) + Ok(Self { + _stream: stream_handle, + sink, + paused_pos: None, + }) } pub fn play(&mut self, path: &PathBuf) -> Result<(), Box> { @@ -65,7 +69,11 @@ impl AudioPlayer { self.sink.set_volume(v); } - pub fn seek_by(&mut self, delta: i64, total: Duration) -> Result> { + 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); diff --git a/src/library.rs b/src/library.rs new file mode 100644 index 0000000..c050b76 --- /dev/null +++ b/src/library.rs @@ -0,0 +1,202 @@ +use crate::track::Track; + +#[derive(Clone)] +pub enum LeftRow { + Artist { name: String }, + Album { name: String }, +} + +#[derive(Clone)] +pub enum RightRow { + Track { index_in_library: usize }, +} + +#[derive(Clone, Copy, PartialEq)] +pub enum SortMode { + ByArtist, + ByFilename, +} + +pub struct Library { + pub all_tracks: Vec, + /// Левая панель: артисты и альбомы + pub left_rows: Vec, + /// Для каждой строки левой панели — список индексов треков в all_tracks + pub left_to_tracks: Vec>, + /// Индекс выбранной строки левой панели + pub left_selected: usize, + /// Индекс выбранного трека в правой панели (внутри текущего набора) + pub right_selected: usize, + pub sort_mode: SortMode, +} + +impl Library { + pub fn build(mut tracks: Vec, mode: SortMode) -> Self { + match mode { + SortMode::ByArtist => { + use std::collections::BTreeMap; + + 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); + } + + let mut all_tracks = Vec::new(); + let mut left_rows = Vec::new(); + let mut left_to_tracks: Vec> = Vec::new(); + + for (artist_name, albums_map) in map { + // Строка артиста: содержит все треки всех его альбомов + let mut artist_track_indices = Vec::new(); + let mut album_entries = 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)) + }); + + let mut album_indices = Vec::new(); + for t in album_tracks { + let idx = all_tracks.len(); + all_tracks.push(t); + album_indices.push(idx); + artist_track_indices.push(idx); + } + album_entries.push((album_name, album_indices)); + } + + // 1) строка артиста + left_rows.push(LeftRow::Artist { + name: artist_name, + }); + 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, + right_selected: 0, + sort_mode: mode, + } + } + + SortMode::ByFilename => { + tracks.sort_by(|a, b| a.path.cmp(&b.path)); + let all_tracks = tracks; + + let left_rows = vec![LeftRow::Album { + name: "Все файлы".into(), + }]; + let left_to_tracks = vec![(0..all_tracks.len()).collect()]; + + Library { + all_tracks, + left_rows, + left_to_tracks, + left_selected: 0, + right_selected: 0, + sort_mode: mode, + } + } + } + } + + pub fn track(&self, index: usize) -> Option<&Track> { + 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()) + .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), + } + } + } + album_match + } + + /// Передвинуть курсор левой панели вверх/вниз, синхронизировать правую + pub fn left_move(&mut self, delta: isize) { + if self.left_rows.is_empty() { + return; + } + let len = self.left_rows.len() as isize; + let mut new = self.left_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; + } + + /// Передвинуть курсор правой панели вверх/вниз + pub fn right_move(&mut self, delta: isize) { + let len = self.current_right_indices().len() as isize; + if len == 0 { + return; + } + let mut new = self.right_selected as isize + delta; + if new < 0 { + new = len - 1; + } else if new >= len { + new = 0; + } + self.right_selected = new as usize; + } + + /// Синхронизировать правую панель с играющим треком + 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)) + { + self.right_selected = pos; + } + } + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 277e7ff..f739138 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod app; mod audio; +mod library; mod track; mod ui; @@ -25,22 +26,11 @@ fn main() -> Result<(), Box> { if event::poll(Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { - if key.kind != KeyEventKind::Press { + if key.kind == KeyEventKind::Release { continue; } - match key.code { - //навигация - 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.toggle_pause(), - KeyCode::Char('z') => app.stop(), - KeyCode::Char('+') | KeyCode::Char('=') => app.volume_up(), - KeyCode::Char('-') | KeyCode::Char('_') => app.volume_down(), - _ => {} + if handle_key(key.code, &mut app) { + break; } } } @@ -48,4 +38,60 @@ fn main() -> Result<(), Box> { ratatui::restore(); Ok(()) +} + +fn handle_key(code: KeyCode, app: &mut App) -> bool { + match code { + KeyCode::Char('+') | KeyCode::Char('=') => { + app.volume_up(); + return false; + } + KeyCode::Char('-') | KeyCode::Char('_') => { + app.volume_down(); + return false; + } + KeyCode::Up => { + app.up(); + return false; + } + KeyCode::Down => { + app.down(); + return false; + } + KeyCode::Left => { + app.seek(-5); + return false; + } + KeyCode::Right => { + app.seek(5); + return false; + } + KeyCode::Enter => { + app.play_selected(); + return false; + } + KeyCode::Char(' ') => { + app.toggle_pause(); + return false; + } + KeyCode::Tab => { + app.toggle_panel(); + return false; + } + _ => {} + } + + 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(), + _ => {} + } + } + false } \ No newline at end of file diff --git a/src/track.rs b/src/track.rs index 0f1e534..bd12ed3 100644 --- a/src/track.rs +++ b/src/track.rs @@ -4,11 +4,13 @@ use std::time::Duration; use lofty::file::{AudioFile, TaggedFileExt}; use lofty::tag::Accessor; +#[derive(Clone)] pub struct Track { pub path: PathBuf, pub title: String, pub artist: String, pub album: String, + pub track_number: Option, pub duration: Duration, } @@ -23,48 +25,70 @@ impl Track { match lofty::read_from_path(path) { Ok(tagged) => { let duration = tagged.properties().duration(); - let (title, artist, album) = match tagged + let (title, artist, album, track_number) = 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()), + 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()), + tag.track(), + ), + None => ( + fallback_name, + "Unknown Artist".into(), + "Unknown Album".into(), + None, ), - None => (fallback_name, "Unknown Artist".into(), "Unknown Album".into()), }; - Track { path: path.clone(), title, artist, album, duration } + Track { + path: path.clone(), + title, + artist, + album, + track_number, + duration, + } } Err(_) => Track { path: path.clone(), title: fallback_name, artist: "Unknown Artist".into(), album: "Unknown Album".into(), + track_number: None, 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()) { + 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") { + 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 index 599ef79..fcdc96b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,7 +1,8 @@ use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, Paragraph}; -use crate::app::App; +use crate::app::{App, Panel}; +use crate::library::{LeftRow, SortMode}; pub fn draw(f: &mut Frame, app: &mut App) { let chunks = Layout::default() @@ -14,29 +15,114 @@ pub fn draw(f: &mut Frame, app: &mut App) { ]) .split(f.area()); - draw_track_list(f, app, chunks[0]); + // Горизонтальное разделение верхней области: 40% слева, 60% справа + let top = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) + .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_status(f, app, chunks[3]); } -fn draw_track_list(f: &mut Frame, app: &mut App, area: Rect) { +fn draw_left_panel(f: &mut Frame, app: &App, area: Rect) { let items: Vec = app - .tracks + .library + .left_rows .iter() - .enumerate() - .map(|(i, t)| { - let marker = if Some(i) == app.playing_index { "♪ " } else { " " }; - ListItem::new(format!("{}{} - {}", marker, t.artist, t.title)) + .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)), + ])), }) .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 list = List::new(items) - .block(Block::default().borders(Borders::ALL).title(" Треки ")) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .block(Block::default().borders(Borders::ALL).title(title)) + .highlight_style(highlight) .highlight_symbol("> "); - f.render_stateful_widget(list, area, &mut app.list_state); + 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(); + + let items: Vec = indices + .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) + } else { + Style::default() + }; + + ListItem::new(Line::from(vec![ + Span::raw(marker), + Span::styled(format!("{}{}", num, title), style), + ])) + }) + .collect(); + + 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 = 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); } fn draw_progress(f: &mut Frame, app: &App, area: Rect) { @@ -53,8 +139,10 @@ fn draw_progress(f: &mut Frame, app: &App, area: Rect) { .ratio(ratio) .label(format!( "{:02}:{:02} / {:02}:{:02}", - pos.as_secs() / 60, pos.as_secs() % 60, - total.as_secs() / 60, total.as_secs() % 60, + pos.as_secs() / 60, + pos.as_secs() % 60, + total.as_secs() / 60, + total.as_secs() % 60, )); f.render_widget(gauge, area); @@ -63,12 +151,20 @@ fn draw_progress(f: &mut Frame, app: &App, area: Rect) { 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 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(" Громкость (+/-) ")) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Громкость (+/-) "), + ) .gauge_style(Style::default().fg(color)) .ratio(ratio) .label(format!("{}%", (vol * 100.0) as u32));