релиз первой версии

This commit is contained in:
2026-10-13 12:41:22 +02:00
commit f1c5deb4bc
4 changed files with 1900 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

1706
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

11
Cargo.toml Normal file
View File

@@ -0,0 +1,11 @@
[package]
name = "rumus"
version = "0.1.0"
edition = "2024"
[dependencies]
ratatui = "0.29"
crossterm = "0.28"
rodio = { version = "0.21", features = ["symphonia-all"] }
walkdir = "2"
dirs = "5"

182
src/main.rs Normal file
View 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(())
}