обновление
This commit is contained in:
96
Cargo.lock
generated
96
Cargo.lock
generated
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -66,6 +72,12 @@ version = "1.25.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.1"
|
version = "1.12.1"
|
||||||
@@ -169,6 +181,15 @@ dependencies = [
|
|||||||
"windows",
|
"windows",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossterm"
|
name = "crossterm"
|
||||||
version = "0.28.1"
|
version = "0.28.1"
|
||||||
@@ -234,6 +255,12 @@ version = "0.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "5.0.1"
|
version = "5.0.1"
|
||||||
@@ -307,6 +334,17 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
"zlib-rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -515,6 +553,32 @@ dependencies = [
|
|||||||
"scopeguard",
|
"scopeguard",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lofty"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"data-encoding",
|
||||||
|
"flate2",
|
||||||
|
"lofty_attr",
|
||||||
|
"log",
|
||||||
|
"ogg_pager",
|
||||||
|
"paste",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lofty_attr"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.34"
|
version = "0.4.34"
|
||||||
@@ -545,6 +609,16 @@ version = "2.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.4"
|
version = "1.2.4"
|
||||||
@@ -736,6 +810,15 @@ dependencies = [
|
|||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ogg_pager"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -875,6 +958,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"lofty",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"rodio",
|
"rodio",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
@@ -971,6 +1055,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simdutf8"
|
name = "simdutf8"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1704,3 +1794,9 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zlib-rs"
|
||||||
|
version = "0.6.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ ratatui = "0.29"
|
|||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
rodio = { version = "0.21", features = ["symphonia-all"] }
|
rodio = { version = "0.21", features = ["symphonia-all"] }
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
|
lofty = "0.22"
|
||||||
|
|||||||
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;
|
mod app;
|
||||||
use std::io::BufReader;
|
mod audio;
|
||||||
use std::path::PathBuf;
|
mod track;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
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
|
use crate::app::App;
|
||||||
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>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Папка с музыкой — можно передать аргументом, по умолчанию ~/Music
|
let music_dir = std::env::args().nth(1).unwrap_or_else(|| {
|
||||||
let music_dir = std::env::args()
|
dirs::home_dir()
|
||||||
.nth(1)
|
.map(|h| h.join("Music").to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| "./music".into())
|
||||||
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 terminal = ratatui::init();
|
||||||
let mut app = App::new(&music_dir)?;
|
let mut app = App::new(&music_dir)?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|f| {
|
app.maybe_advance();
|
||||||
let chunks = Layout::default()
|
terminal.draw(|f| ui::draw(f, &mut app))?;
|
||||||
.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 event::poll(Duration::from_millis(100))? {
|
||||||
if let Event::Key(key) = event::read()? {
|
if let Event::Key(key) = event::read()? {
|
||||||
if key.kind != KeyEventKind::Press { continue; }
|
if key.kind != KeyEventKind::Press {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('q') | KeyCode::Esc => break,
|
//навигация
|
||||||
KeyCode::Up | KeyCode::Char('k') => app.prev(),
|
KeyCode::Char('q') | KeyCode::Esc => break,
|
||||||
KeyCode::Down | KeyCode::Char('j') => app.next(),
|
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::Enter => app.play_selected(),
|
||||||
KeyCode::Char(' ') => app.player.toggle_pause(),
|
KeyCode::Char(' ') => app.toggle_pause(),
|
||||||
KeyCode::Char('s') => app.player.stop(),
|
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