релиз первой версии
This commit is contained in:
182
src/main.rs
Normal file
182
src/main.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::path::PathBuf;
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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());
|
||||
|
||||
// Список файлов
|
||||
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; }
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => break,
|
||||
KeyCode::Up | KeyCode::Char('k') => app.prev(),
|
||||
KeyCode::Down | KeyCode::Char('j') => app.next(),
|
||||
KeyCode::Enter => app.play_selected(),
|
||||
KeyCode::Char(' ') => app.player.toggle_pause(),
|
||||
KeyCode::Char('s') => app.player.stop(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ratatui::restore();
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user