обновление

This commit is contained in:
2026-10-13 17:38:18 +02:00
parent 255a99ed0f
commit 08a8a7cce5
6 changed files with 502 additions and 87 deletions

View File

@@ -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<Track>,
pub list_state: ListState,
pub library: Library,
pub player: AudioPlayer,
pub status: String,
pub playing_index: Option<usize>,
pub active_panel: Panel,
}
impl App {
pub fn new(music_dir: &str) -> Result<Self, Box<dyn std::error::Error>> {
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(),
};
}
}

View File

@@ -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<dyn std::error::Error>> {
@@ -65,7 +69,11 @@ impl AudioPlayer {
self.sink.set_volume(v);
}
pub fn seek_by(&mut self, delta: i64, total: Duration) -> Result<Duration, Box<dyn std::error::Error>> {
pub fn seek_by(
&mut self,
delta: i64,
total: Duration,
) -> Result<Duration, Box<dyn std::error::Error>> {
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);

202
src/library.rs Normal file
View File

@@ -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<Track>,
/// Левая панель: артисты и альбомы
pub left_rows: Vec<LeftRow>,
/// Для каждой строки левой панели — список индексов треков в all_tracks
pub left_to_tracks: Vec<Vec<usize>>,
/// Индекс выбранной строки левой панели
pub left_selected: usize,
/// Индекс выбранного трека в правой панели (внутри текущего набора)
pub right_selected: usize,
pub sort_mode: SortMode,
}
impl Library {
pub fn build(mut tracks: Vec<Track>, mode: SortMode) -> Self {
match mode {
SortMode::ByArtist => {
use std::collections::BTreeMap;
let mut map: BTreeMap<String, BTreeMap<String, Vec<Track>>> = 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<usize>> = 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<usize> {
self.current_right_indices()
.get(self.right_selected)
.copied()
}
/// Найти строку левой панели, к которой относится трек (для синхронизации)
pub fn left_row_of_track(&self, index_in_library: usize) -> Option<usize> {
// Приоритет — альбом (более точное совпадение)
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;
}
}
}
}

View File

@@ -1,5 +1,6 @@
mod app;
mod audio;
mod library;
mod track;
mod ui;
@@ -25,22 +26,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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
}

View File

@@ -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<u32>,
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<Track> {
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
}

130
src/ui.rs
View File

@@ -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<ListItem> = 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<ListItem> = 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 => ("<unknown>".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));