обновление
This commit is contained in:
133
src/app.rs
Normal file
133
src/app.rs
Normal file
@@ -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<Track>,
|
||||
pub list_state: ListState,
|
||||
pub player: AudioPlayer,
|
||||
pub status: String,
|
||||
pub playing_index: Option<usize>,
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
81
src/audio.rs
Normal file
81
src/audio.rs
Normal file
@@ -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<Duration>,
|
||||
}
|
||||
|
||||
impl AudioPlayer {
|
||||
pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<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);
|
||||
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)
|
||||
}
|
||||
}
|
||||
183
src/main.rs
183
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<Self, Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<PathBuf>,
|
||||
list_state: ListState,
|
||||
player: AudioPlayer,
|
||||
status: String,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(music_dir: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
// Папка с музыкой — можно передать аргументом, по умолчанию ~/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<ListItem> = 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(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
70
src/track.rs
Normal file
70
src/track.rs
Normal file
@@ -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<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()) {
|
||||
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
|
||||
}
|
||||
83
src/ui.rs
Normal file
83
src/ui.rs
Normal file
@@ -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<ListItem> = 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);
|
||||
}
|
||||
Reference in New Issue
Block a user