обновление
This commit is contained in:
128
Cargo.lock
generated
128
Cargo.lock
generated
@@ -676,6 +676,15 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-complex"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-derive"
|
name = "num-derive"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -872,13 +881,22 @@ version = "0.3.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "primal-check"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"toml_edit",
|
"toml_edit 0.25.17+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -961,9 +979,26 @@ dependencies = [
|
|||||||
"lofty",
|
"lofty",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"rodio",
|
"rodio",
|
||||||
|
"rustfft",
|
||||||
|
"serde",
|
||||||
|
"toml",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustfft"
|
||||||
|
version = "6.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
|
||||||
|
dependencies = [
|
||||||
|
"num-complex",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
"primal-check",
|
||||||
|
"strength_reduce",
|
||||||
|
"transpose",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "0.38.44"
|
version = "0.38.44"
|
||||||
@@ -1004,6 +1039,16 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_core"
|
name = "serde_core"
|
||||||
version = "1.0.229"
|
version = "1.0.229"
|
||||||
@@ -1024,6 +1069,15 @@ dependencies = [
|
|||||||
"syn 3.0.9",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook"
|
name = "signal-hook"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
@@ -1085,6 +1139,12 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strength_reduce"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1359,6 +1419,27 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime 0.6.11",
|
||||||
|
"toml_edit 0.22.27",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.2+spec-1.1.0"
|
version = "1.1.2+spec-1.1.0"
|
||||||
@@ -1368,6 +1449,20 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime 0.6.11",
|
||||||
|
"toml_write",
|
||||||
|
"winnow 0.7.15",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_edit"
|
name = "toml_edit"
|
||||||
version = "0.25.17+spec-1.1.0"
|
version = "0.25.17+spec-1.1.0"
|
||||||
@@ -1375,9 +1470,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
|
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"toml_datetime",
|
"toml_datetime 1.1.2+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"winnow",
|
"winnow 1.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1386,7 +1481,23 @@ version = "1.1.5+spec-1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow",
|
"winnow 1.0.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "transpose"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"strength_reduce",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1786,6 +1897,15 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ rodio = { version = "0.21", features = ["symphonia-all"] }
|
|||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
lofty = "0.22"
|
lofty = "0.22"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
toml = "0.8"
|
||||||
|
rustfft = "6"
|
||||||
257
src/app.rs
257
src/app.rs
@@ -2,48 +2,149 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crate::audio::AudioPlayer;
|
use crate::audio::AudioPlayer;
|
||||||
use crate::library::{Library, SortMode};
|
use crate::library::{Library, SortMode};
|
||||||
|
use crate::state::{RepeatMode, SavedState};
|
||||||
use crate::track::scan_dir;
|
use crate::track::scan_dir;
|
||||||
|
|
||||||
/// Какая панель активна
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
pub enum Panel {
|
pub enum Panel {
|
||||||
Left,
|
Left,
|
||||||
Right,
|
Right,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub enum InputMode {
|
||||||
|
Normal,
|
||||||
|
Search,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub library: Library,
|
pub library: Library,
|
||||||
pub player: AudioPlayer,
|
pub player: AudioPlayer,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub playing_index: Option<usize>,
|
pub playing_index: Option<usize>,
|
||||||
pub active_panel: Panel,
|
pub active_panel: Panel,
|
||||||
|
pub input_mode: InputMode,
|
||||||
|
pub search_buffer: String,
|
||||||
|
/// Исходный (неотфильтрованный) список треков — для пересборки библиотеки
|
||||||
|
raw_tracks: Vec<crate::track::Track>,
|
||||||
|
/// Директория с музыкой — чтобы сохранить в state
|
||||||
|
music_dir: String,
|
||||||
|
pub repeat: RepeatMode,
|
||||||
|
pub shuffle: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(music_dir: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
pub fn new(music_dir: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let tracks = scan_dir(music_dir);
|
let tracks = scan_dir(music_dir);
|
||||||
let library = Library::build(tracks, SortMode::ByArtist);
|
let library = Library::build(tracks.clone(), SortMode::ByArtist);
|
||||||
|
|
||||||
Ok(Self {
|
let mut app = Self {
|
||||||
library,
|
library,
|
||||||
player: AudioPlayer::new()?,
|
player: AudioPlayer::new()?,
|
||||||
status: "Enter — play, t — сортировка, Tab — панель".into(),
|
status: "? — help".into(),
|
||||||
playing_index: None,
|
playing_index: None,
|
||||||
active_panel: Panel::Left,
|
active_panel: Panel::Left,
|
||||||
})
|
input_mode: InputMode::Normal,
|
||||||
|
search_buffer: String::new(),
|
||||||
|
raw_tracks: tracks,
|
||||||
|
music_dir: music_dir.to_string(),
|
||||||
|
repeat: RepeatMode::Off,
|
||||||
|
shuffle: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Восстанавливаем сохранённое состояние
|
||||||
|
let saved = crate::state::load();
|
||||||
|
if let Some(vol) = saved.volume {
|
||||||
|
app.player.set_volume(vol);
|
||||||
|
}
|
||||||
|
if let Some(rep) = saved.repeat {
|
||||||
|
app.repeat = rep;
|
||||||
|
}
|
||||||
|
if let Some(sh) = saved.shuffle {
|
||||||
|
app.shuffle = sh;
|
||||||
|
}
|
||||||
|
if saved.sort_by_filename == Some(true) {
|
||||||
|
app.library = Library::build(app.raw_tracks.clone(), SortMode::ByFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Навигация вверх с учётом активной панели
|
fn persist(&self) {
|
||||||
|
let state = SavedState {
|
||||||
|
last_path: Some(self.music_dir.clone()),
|
||||||
|
volume: Some(self.player.volume()),
|
||||||
|
sort_by_filename: Some(self.library.sort_mode == SortMode::ByFilename),
|
||||||
|
repeat: Some(self.repeat),
|
||||||
|
shuffle: Some(self.shuffle),
|
||||||
|
};
|
||||||
|
crate::state::save(&state);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_exit(&self) {
|
||||||
|
self.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Поиск ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn start_search(&mut self) {
|
||||||
|
self.input_mode = InputMode::Search;
|
||||||
|
self.search_buffer.clear();
|
||||||
|
self.status = "Поиск: ".into();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel_search(&mut self) {
|
||||||
|
self.input_mode = InputMode::Normal;
|
||||||
|
self.search_buffer.clear();
|
||||||
|
self.rebuild_library();
|
||||||
|
self.status = "Поиск отменён".into();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commit_search(&mut self) {
|
||||||
|
self.input_mode = InputMode::Normal;
|
||||||
|
self.status = if self.search_buffer.is_empty() {
|
||||||
|
"Фильтр сброшен".into()
|
||||||
|
} else {
|
||||||
|
format!("Фильтр: {}", self.search_buffer)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn search_push(&mut self, c: char) {
|
||||||
|
self.search_buffer.push(c);
|
||||||
|
self.rebuild_library();
|
||||||
|
self.status = format!("Поиск: {}", self.search_buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn search_pop(&mut self) {
|
||||||
|
self.search_buffer.pop();
|
||||||
|
self.rebuild_library();
|
||||||
|
self.status = format!("Поиск: {}", self.search_buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_library(&mut self) {
|
||||||
|
let mode = self.library.sort_mode;
|
||||||
|
self.library = Library::build_with_filter(
|
||||||
|
self.raw_tracks.clone(),
|
||||||
|
mode,
|
||||||
|
self.search_buffer.clone(),
|
||||||
|
);
|
||||||
|
if let Some(i) = self.playing_index {
|
||||||
|
self.library.sync_with_playing(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Навигация ──────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn up(&mut self) {
|
pub fn up(&mut self) {
|
||||||
match self.active_panel {
|
match self.active_panel {
|
||||||
Panel::Left => self.library.left_move(-1),
|
Panel::Left => self.library.artist_move(-1),
|
||||||
Panel::Right => self.library.right_move(-1),
|
Panel::Right => self.library.right_move(-1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn down(&mut self) {
|
pub fn down(&mut self) {
|
||||||
match self.active_panel {
|
match self.active_panel {
|
||||||
Panel::Left => self.library.left_move(1),
|
Panel::Left => self.library.artist_move(1),
|
||||||
Panel::Right => self.library.right_move(1),
|
Panel::Right => self.library.right_move(1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,11 +155,43 @@ impl App {
|
|||||||
Panel::Right => Panel::Left,
|
Panel::Right => Panel::Left,
|
||||||
};
|
};
|
||||||
self.status = match self.active_panel {
|
self.status = match self.active_panel {
|
||||||
Panel::Left => "Активна: левая панель".into(),
|
Panel::Left => "Активна: исполнители".into(),
|
||||||
Panel::Right => "Активна: правая панель".into(),
|
Panel::Right => "Активна: альбомы/треки".into(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// f — прыжок к играющему треку
|
||||||
|
pub fn jump_to_playing(&mut self) {
|
||||||
|
if let Some(i) = self.playing_index {
|
||||||
|
self.library.sync_with_playing(i);
|
||||||
|
self.active_panel = Panel::Right;
|
||||||
|
self.status = "→ к играющему треку".into();
|
||||||
|
} else {
|
||||||
|
self.status = "Ничего не играет".into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// g — в начало, G — в конец
|
||||||
|
pub fn goto_start(&mut self) {
|
||||||
|
match self.active_panel {
|
||||||
|
Panel::Left => self.library.artist_selected = 0,
|
||||||
|
Panel::Right => self.library.right_goto_start(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn goto_end(&mut self) {
|
||||||
|
match self.active_panel {
|
||||||
|
Panel::Left => {
|
||||||
|
if !self.library.artists.is_empty() {
|
||||||
|
self.library.artist_selected = self.library.artists.len() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Panel::Right => self.library.right_goto_end(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Playback ───────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn play_index(&mut self, index_in_library: usize) {
|
pub fn play_index(&mut self, index_in_library: usize) {
|
||||||
let Some(track) = self.library.track(index_in_library).cloned() else {
|
let Some(track) = self.library.track(index_in_library).cloned() else {
|
||||||
return;
|
return;
|
||||||
@@ -75,6 +208,8 @@ impl App {
|
|||||||
pub fn play_selected(&mut self) {
|
pub fn play_selected(&mut self) {
|
||||||
if let Some(idx) = self.library.right_track_index() {
|
if let Some(idx) = self.library.right_track_index() {
|
||||||
self.play_index(idx);
|
self.play_index(idx);
|
||||||
|
} else {
|
||||||
|
self.status = "Выделен заголовок альбома".into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,18 +223,76 @@ impl App {
|
|||||||
self.player.toggle_pause();
|
self.player.toggle_pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Автопереход с учётом repeat и shuffle
|
||||||
pub fn maybe_advance(&mut self) {
|
pub fn maybe_advance(&mut self) {
|
||||||
if self.playing_index.is_none() || !self.player.is_finished() {
|
if self.playing_index.is_none() || !self.player.is_finished() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let next = match self.playing_index {
|
|
||||||
Some(i) if i + 1 < self.library.all_tracks.len() => i + 1,
|
// Repeat One — играем тот же трек
|
||||||
_ => 0,
|
if self.repeat == RepeatMode::One {
|
||||||
|
if let Some(i) = self.playing_index {
|
||||||
|
self.play_index(i);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = self.library.all_tracks.len();
|
||||||
|
if total == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = if self.shuffle {
|
||||||
|
// Псевдослучайный выбор без rand-крейта
|
||||||
|
let seed = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as usize)
|
||||||
|
.unwrap_or(0);
|
||||||
|
seed % total
|
||||||
|
} else {
|
||||||
|
match self.playing_index {
|
||||||
|
Some(i) if i + 1 < total => i + 1,
|
||||||
|
_ => {
|
||||||
|
if self.repeat == RepeatMode::All {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
// Off — останавливаемся
|
||||||
|
self.status = "Конец плейлиста".into();
|
||||||
|
self.playing_index = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
self.play_index(next);
|
self.play_index(next);
|
||||||
self.library.sync_with_playing(next);
|
self.library.sync_with_playing(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn cycle_repeat(&mut self) {
|
||||||
|
self.repeat = match self.repeat {
|
||||||
|
RepeatMode::Off => RepeatMode::All,
|
||||||
|
RepeatMode::All => RepeatMode::One,
|
||||||
|
RepeatMode::One => RepeatMode::Off,
|
||||||
|
};
|
||||||
|
self.status = match self.repeat {
|
||||||
|
RepeatMode::Off => "Repeat: off".into(),
|
||||||
|
RepeatMode::All => "Repeat: all".into(),
|
||||||
|
RepeatMode::One => "Repeat: one".into(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_shuffle(&mut self) {
|
||||||
|
self.shuffle = !self.shuffle;
|
||||||
|
self.status = if self.shuffle {
|
||||||
|
"Shuffle: on".into()
|
||||||
|
} else {
|
||||||
|
"Shuffle: off".into()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Громкость, перемотка ───────────────────────────────────
|
||||||
|
|
||||||
pub fn volume_up(&mut self) {
|
pub fn volume_up(&mut self) {
|
||||||
self.player.change_volume(0.05);
|
self.player.change_volume(0.05);
|
||||||
self.status = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32);
|
self.status = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32);
|
||||||
@@ -136,6 +329,37 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Перемотка по цифрам 1–9 (10%..90%)
|
||||||
|
pub fn seek_percent(&mut self, percent: u8) {
|
||||||
|
if self.playing_index.is_none() {
|
||||||
|
self.status = "Ничего не воспроизводится".into();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let total = match self
|
||||||
|
.playing_index
|
||||||
|
.and_then(|i| self.library.track(i))
|
||||||
|
.map(|t| t.duration)
|
||||||
|
{
|
||||||
|
Some(d) if !d.is_zero() => d,
|
||||||
|
_ => {
|
||||||
|
self.status = "Длительность неизвестна".into();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let target = total.mul_f32(percent as f32 / 100.0);
|
||||||
|
match self.player.seek_to(target) {
|
||||||
|
Ok(_) => {
|
||||||
|
self.status = format!(
|
||||||
|
"→ {}% ({:02}:{:02})",
|
||||||
|
percent,
|
||||||
|
target.as_secs() / 60,
|
||||||
|
target.as_secs() % 60,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(_) => self.status = "Перемотка недоступна".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn progress(&self) -> (Duration, Duration) {
|
pub fn progress(&self) -> (Duration, Duration) {
|
||||||
match self.playing_index.and_then(|i| self.library.track(i)) {
|
match self.playing_index.and_then(|i| self.library.track(i)) {
|
||||||
Some(t) => (self.player.position(), t.duration),
|
Some(t) => (self.player.position(), t.duration),
|
||||||
@@ -154,8 +378,11 @@ impl App {
|
|||||||
.and_then(|i| self.library.track(i))
|
.and_then(|i| self.library.track(i))
|
||||||
.map(|t| t.path.clone());
|
.map(|t| t.path.clone());
|
||||||
|
|
||||||
let all = self.library.all_tracks.clone();
|
self.library = Library::build_with_filter(
|
||||||
self.library = Library::build(all, new_mode);
|
self.raw_tracks.clone(),
|
||||||
|
new_mode,
|
||||||
|
self.search_buffer.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
self.playing_index = playing_path
|
self.playing_index = playing_path
|
||||||
.and_then(|p| self.library.all_tracks.iter().position(|t| t.path == p));
|
.and_then(|p| self.library.all_tracks.iter().position(|t| t.path == p));
|
||||||
|
|||||||
163
src/audio.rs
163
src/audio.rs
@@ -1,14 +1,140 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::BufReader;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink};
|
use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink, Source};
|
||||||
|
|
||||||
|
const FFT_SIZE: usize = 1024;
|
||||||
|
const NUM_BARS: usize = 64;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SpectrumBus {
|
||||||
|
buffer: Arc<Mutex<VecDeque<f32>>>,
|
||||||
|
bars: Arc<Mutex<[f32; NUM_BARS]>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpectrumBus {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
buffer: Arc::new(Mutex::new(VecDeque::with_capacity(FFT_SIZE * 2))),
|
||||||
|
bars: Arc::new(Mutex::new([0.0; NUM_BARS])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_sample(&self, s: f32) {
|
||||||
|
if let Ok(mut buf) = self.buffer.lock() {
|
||||||
|
buf.push_back(s);
|
||||||
|
if buf.len() > FFT_SIZE * 2 {
|
||||||
|
buf.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bars(&self) -> [f32; NUM_BARS] {
|
||||||
|
self.bars.lock().map(|b| *b).unwrap_or([0.0; NUM_BARS])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recompute(&self) {
|
||||||
|
use rustfft::num_complex::Complex;
|
||||||
|
use rustfft::FftPlanner;
|
||||||
|
|
||||||
|
let samples: Vec<f32> = match self.buffer.lock() {
|
||||||
|
Ok(buf) => buf.iter().rev().take(FFT_SIZE).rev().copied().collect(),
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
if samples.len() < FFT_SIZE {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut data: Vec<Complex<f32>> = samples
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &s)| {
|
||||||
|
let w = 0.5
|
||||||
|
* (1.0
|
||||||
|
- (2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE - 1) as f32).cos());
|
||||||
|
Complex::new(s * w, 0.0)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut planner = FftPlanner::<f32>::new();
|
||||||
|
let fft = planner.plan_fft_forward(FFT_SIZE);
|
||||||
|
fft.process(&mut data);
|
||||||
|
|
||||||
|
let half = FFT_SIZE / 2;
|
||||||
|
let mut bars = [0.0f32; NUM_BARS];
|
||||||
|
for i in 0..NUM_BARS {
|
||||||
|
let lo = ((i as f32 / NUM_BARS as f32).powi(2) * half as f32) as usize;
|
||||||
|
let hi = (((i + 1) as f32 / NUM_BARS as f32).powi(2) * half as f32) as usize;
|
||||||
|
let hi = hi.max(lo + 1).min(half);
|
||||||
|
|
||||||
|
let mut sum = 0.0;
|
||||||
|
for k in lo..hi {
|
||||||
|
sum += data[k].norm();
|
||||||
|
}
|
||||||
|
let avg = sum / (hi - lo) as f32;
|
||||||
|
let v = (avg / FFT_SIZE as f32).sqrt() * 4.0;
|
||||||
|
bars[i] = v.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut current) = self.bars.lock() {
|
||||||
|
for i in 0..NUM_BARS {
|
||||||
|
if bars[i] > current[i] {
|
||||||
|
current[i] = bars[i];
|
||||||
|
} else {
|
||||||
|
current[i] = current[i] * 0.7 + bars[i] * 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TapSource<S> {
|
||||||
|
inner: S,
|
||||||
|
bus: SpectrumBus,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> Iterator for TapSource<S>
|
||||||
|
where
|
||||||
|
S: Source<Item = f32>,
|
||||||
|
{
|
||||||
|
type Item = f32;
|
||||||
|
fn next(&mut self) -> Option<f32> {
|
||||||
|
let s = self.inner.next()?;
|
||||||
|
self.bus.push_sample(s);
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> Source for TapSource<S>
|
||||||
|
where
|
||||||
|
S: Source<Item = f32>,
|
||||||
|
{
|
||||||
|
fn current_span_len(&self) -> Option<usize> {
|
||||||
|
self.inner.current_span_len()
|
||||||
|
}
|
||||||
|
fn channels(&self) -> u16 {
|
||||||
|
self.inner.channels()
|
||||||
|
}
|
||||||
|
fn sample_rate(&self) -> u32 {
|
||||||
|
self.inner.sample_rate()
|
||||||
|
}
|
||||||
|
fn total_duration(&self) -> Option<Duration> {
|
||||||
|
self.inner.total_duration()
|
||||||
|
}
|
||||||
|
// ВАЖНО: пробрасываем try_seek, иначе Sink::try_seek вернёт NotSupported
|
||||||
|
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||||
|
self.inner.try_seek(pos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AudioPlayer {
|
pub struct AudioPlayer {
|
||||||
_stream: OutputStream,
|
_stream: OutputStream,
|
||||||
sink: Sink,
|
sink: Sink,
|
||||||
paused_pos: Option<Duration>,
|
paused_pos: Option<Duration>,
|
||||||
|
pub spectrum: SpectrumBus,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioPlayer {
|
impl AudioPlayer {
|
||||||
@@ -20,15 +146,21 @@ impl AudioPlayer {
|
|||||||
_stream: stream_handle,
|
_stream: stream_handle,
|
||||||
sink,
|
sink,
|
||||||
paused_pos: None,
|
paused_pos: None,
|
||||||
|
spectrum: SpectrumBus::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn play(&mut self, path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn play(&mut self, path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
self.sink.stop();
|
self.sink.stop();
|
||||||
self.paused_pos = None;
|
self.paused_pos = None;
|
||||||
let file = BufReader::new(File::open(path)?);
|
let file = File::open(path)?;
|
||||||
let source = Decoder::new(file)?;
|
// Decoder::try_from автоматически настраивает seekable и byte_len[citation:1][citation:19]
|
||||||
self.sink.append(source);
|
let decoder = Decoder::try_from(file)?;
|
||||||
|
let tapped = TapSource {
|
||||||
|
inner: decoder,
|
||||||
|
bus: self.spectrum.clone(),
|
||||||
|
};
|
||||||
|
self.sink.append(tapped);
|
||||||
self.sink.play();
|
self.sink.play();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -41,6 +173,10 @@ impl AudioPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_paused(&self) -> bool {
|
||||||
|
self.sink.is_paused()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn toggle_pause(&mut self) {
|
pub fn toggle_pause(&mut self) {
|
||||||
if self.sink.is_paused() {
|
if self.sink.is_paused() {
|
||||||
self.sink.play();
|
self.sink.play();
|
||||||
@@ -64,11 +200,23 @@ impl AudioPlayer {
|
|||||||
self.sink.volume()
|
self.sink.volume()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_volume(&mut self, v: f32) {
|
||||||
|
self.sink.set_volume(v.clamp(0.0, 2.0));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn change_volume(&mut self, delta: f32) {
|
pub fn change_volume(&mut self, delta: f32) {
|
||||||
let v = (self.sink.volume() + delta).clamp(0.0, 2.0);
|
let v = (self.sink.volume() + delta).clamp(0.0, 2.0);
|
||||||
self.sink.set_volume(v);
|
self.sink.set_volume(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn seek_to(&mut self, pos: Duration) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
self.sink.try_seek(pos)?;
|
||||||
|
if self.sink.is_paused() {
|
||||||
|
self.paused_pos = Some(pos);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn seek_by(
|
pub fn seek_by(
|
||||||
&mut self,
|
&mut self,
|
||||||
delta: i64,
|
delta: i64,
|
||||||
@@ -80,10 +228,7 @@ impl AudioPlayer {
|
|||||||
if !total.is_zero() && new_pos >= total {
|
if !total.is_zero() && new_pos >= total {
|
||||||
new_pos = total.saturating_sub(Duration::from_millis(100));
|
new_pos = total.saturating_sub(Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
self.sink.try_seek(new_pos)?;
|
self.seek_to(new_pos)?;
|
||||||
if self.sink.is_paused() {
|
|
||||||
self.paused_pos = Some(new_pos);
|
|
||||||
}
|
|
||||||
Ok(new_pos)
|
Ok(new_pos)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
204
src/library.rs
204
src/library.rs
@@ -1,13 +1,8 @@
|
|||||||
use crate::track::Track;
|
use crate::track::Track;
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub enum LeftRow {
|
|
||||||
Artist { name: String },
|
|
||||||
Album { name: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum RightRow {
|
pub enum RightRow {
|
||||||
|
AlbumHeader { name: String },
|
||||||
Track { index_in_library: usize },
|
Track { index_in_library: usize },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,21 +12,41 @@ pub enum SortMode {
|
|||||||
ByFilename,
|
ByFilename,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct ArtistView {
|
||||||
|
pub name: String,
|
||||||
|
pub rows: Vec<RightRow>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Library {
|
pub struct Library {
|
||||||
pub all_tracks: Vec<Track>,
|
pub all_tracks: Vec<Track>,
|
||||||
/// Левая панель: артисты и альбомы
|
pub artists: Vec<ArtistView>,
|
||||||
pub left_rows: Vec<LeftRow>,
|
pub artist_selected: usize,
|
||||||
/// Для каждой строки левой панели — список индексов треков в all_tracks
|
|
||||||
pub left_to_tracks: Vec<Vec<usize>>,
|
|
||||||
/// Индекс выбранной строки левой панели
|
|
||||||
pub left_selected: usize,
|
|
||||||
/// Индекс выбранного трека в правой панели (внутри текущего набора)
|
|
||||||
pub right_selected: usize,
|
pub right_selected: usize,
|
||||||
pub sort_mode: SortMode,
|
pub sort_mode: SortMode,
|
||||||
|
/// Активный фильтр поиска (пусто — без фильтра)
|
||||||
|
pub filter: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Library {
|
impl Library {
|
||||||
pub fn build(mut tracks: Vec<Track>, mode: SortMode) -> Self {
|
pub fn build(tracks: Vec<Track>, mode: SortMode) -> Self {
|
||||||
|
Self::build_with_filter(tracks, mode, String::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_with_filter(
|
||||||
|
mut tracks: Vec<Track>,
|
||||||
|
mode: SortMode,
|
||||||
|
filter: String,
|
||||||
|
) -> Self {
|
||||||
|
// Применяем фильтр
|
||||||
|
if !filter.is_empty() {
|
||||||
|
let f = filter.to_lowercase();
|
||||||
|
tracks.retain(|t| {
|
||||||
|
t.title.to_lowercase().contains(&f)
|
||||||
|
|| t.artist.to_lowercase().contains(&f)
|
||||||
|
|| t.album.to_lowercase().contains(&f)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
match mode {
|
match mode {
|
||||||
SortMode::ByArtist => {
|
SortMode::ByArtist => {
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
@@ -46,13 +61,10 @@ impl Library {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut all_tracks = Vec::new();
|
let mut all_tracks = Vec::new();
|
||||||
let mut left_rows = Vec::new();
|
let mut artists = Vec::new();
|
||||||
let mut left_to_tracks: Vec<Vec<usize>> = Vec::new();
|
|
||||||
|
|
||||||
for (artist_name, albums_map) in map {
|
for (artist_name, albums_map) in map {
|
||||||
// Строка артиста: содержит все треки всех его альбомов
|
let mut rows = Vec::new();
|
||||||
let mut artist_track_indices = Vec::new();
|
|
||||||
let mut album_entries = Vec::new();
|
|
||||||
|
|
||||||
for (album_name, mut album_tracks) in albums_map {
|
for (album_name, mut album_tracks) in albums_map {
|
||||||
album_tracks.sort_by(|a, b| {
|
album_tracks.sort_by(|a, b| {
|
||||||
@@ -62,36 +74,30 @@ impl Library {
|
|||||||
.then(a.title.cmp(&b.title))
|
.then(a.title.cmp(&b.title))
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut album_indices = Vec::new();
|
rows.push(RightRow::AlbumHeader { name: album_name });
|
||||||
|
|
||||||
for t in album_tracks {
|
for t in album_tracks {
|
||||||
let idx = all_tracks.len();
|
let idx = all_tracks.len();
|
||||||
all_tracks.push(t);
|
all_tracks.push(t);
|
||||||
album_indices.push(idx);
|
rows.push(RightRow::Track {
|
||||||
artist_track_indices.push(idx);
|
index_in_library: idx,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
album_entries.push((album_name, album_indices));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) строка артиста
|
artists.push(ArtistView {
|
||||||
left_rows.push(LeftRow::Artist {
|
|
||||||
name: artist_name,
|
name: artist_name,
|
||||||
|
rows,
|
||||||
});
|
});
|
||||||
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 {
|
Library {
|
||||||
all_tracks,
|
all_tracks,
|
||||||
left_rows,
|
artists,
|
||||||
left_to_tracks,
|
artist_selected: 0,
|
||||||
left_selected: 0,
|
|
||||||
right_selected: 0,
|
right_selected: 0,
|
||||||
sort_mode: mode,
|
sort_mode: mode,
|
||||||
|
filter,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,18 +105,28 @@ impl Library {
|
|||||||
tracks.sort_by(|a, b| a.path.cmp(&b.path));
|
tracks.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
let all_tracks = tracks;
|
let all_tracks = tracks;
|
||||||
|
|
||||||
let left_rows = vec![LeftRow::Album {
|
let mut rows = Vec::new();
|
||||||
|
rows.push(RightRow::AlbumHeader {
|
||||||
name: "Все файлы".into(),
|
name: "Все файлы".into(),
|
||||||
|
});
|
||||||
|
for i in 0..all_tracks.len() {
|
||||||
|
rows.push(RightRow::Track {
|
||||||
|
index_in_library: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let artists = vec![ArtistView {
|
||||||
|
name: "Все файлы".into(),
|
||||||
|
rows,
|
||||||
}];
|
}];
|
||||||
let left_to_tracks = vec![(0..all_tracks.len()).collect()];
|
|
||||||
|
|
||||||
Library {
|
Library {
|
||||||
all_tracks,
|
all_tracks,
|
||||||
left_rows,
|
artists,
|
||||||
left_to_tracks,
|
artist_selected: 0,
|
||||||
left_selected: 0,
|
|
||||||
right_selected: 0,
|
right_selected: 0,
|
||||||
sort_mode: mode,
|
sort_mode: mode,
|
||||||
|
filter,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,82 +136,86 @@ impl Library {
|
|||||||
self.all_tracks.get(index)
|
self.all_tracks.get(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Индексы треков, относящихся к текущей выделенной строке левой панели
|
pub fn current_right_rows(&self) -> &[RightRow] {
|
||||||
pub fn current_right_indices(&self) -> &[usize] {
|
self.artists
|
||||||
self.left_to_tracks
|
.get(self.artist_selected)
|
||||||
.get(self.left_selected)
|
.map(|a| a.rows.as_slice())
|
||||||
.map(|v| v.as_slice())
|
|
||||||
.unwrap_or(&[])
|
.unwrap_or(&[])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Индекс трека в all_tracks, выделенного в правой панели
|
|
||||||
pub fn right_track_index(&self) -> Option<usize> {
|
pub fn right_track_index(&self) -> Option<usize> {
|
||||||
self.current_right_indices()
|
match self.current_right_rows().get(self.right_selected)? {
|
||||||
.get(self.right_selected)
|
RightRow::Track { index_in_library } => Some(*index_in_library),
|
||||||
.copied()
|
RightRow::AlbumHeader { .. } => None,
|
||||||
}
|
|
||||||
|
|
||||||
/// Найти строку левой панели, к которой относится трек (для синхронизации)
|
|
||||||
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 artist_move(&mut self, delta: isize) {
|
||||||
pub fn left_move(&mut self, delta: isize) {
|
if self.artists.is_empty() {
|
||||||
if self.left_rows.is_empty() {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let len = self.left_rows.len() as isize;
|
let len = self.artists.len() as isize;
|
||||||
let mut new = self.left_selected as isize + delta;
|
let mut new = self.artist_selected as isize + delta;
|
||||||
if new < 0 {
|
if new < 0 {
|
||||||
new = len - 1;
|
new = len - 1;
|
||||||
} else if new >= len {
|
} else if new >= len {
|
||||||
new = 0;
|
new = 0;
|
||||||
}
|
}
|
||||||
self.left_selected = new as usize;
|
self.artist_selected = new as usize;
|
||||||
self.right_selected = 0;
|
self.right_selected = self.first_track_row();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Передвинуть курсор правой панели вверх/вниз
|
|
||||||
pub fn right_move(&mut self, delta: isize) {
|
pub fn right_move(&mut self, delta: isize) {
|
||||||
let len = self.current_right_indices().len() as isize;
|
let track_rows: Vec<usize> = self
|
||||||
if len == 0 {
|
.current_right_rows()
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(i, r)| matches!(r, RightRow::Track { .. }).then_some(i))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if track_rows.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut new = self.right_selected as isize + delta;
|
|
||||||
if new < 0 {
|
let current_pos = track_rows
|
||||||
new = len - 1;
|
.iter()
|
||||||
} else if new >= len {
|
.position(|&i| i == self.right_selected)
|
||||||
new = 0;
|
.unwrap_or(0) as isize;
|
||||||
|
|
||||||
|
let len = track_rows.len() as isize;
|
||||||
|
let mut new_pos = current_pos + delta;
|
||||||
|
if new_pos < 0 {
|
||||||
|
new_pos = len - 1;
|
||||||
|
} else if new_pos >= len {
|
||||||
|
new_pos = 0;
|
||||||
}
|
}
|
||||||
self.right_selected = new as usize;
|
|
||||||
|
self.right_selected = track_rows[new_pos as usize];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn right_goto_start(&mut self) {
|
||||||
|
self.right_move(-1000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn right_goto_end(&mut self) {
|
||||||
|
self.right_move(1000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_track_row(&self) -> usize {
|
||||||
|
self.current_right_rows()
|
||||||
|
.iter()
|
||||||
|
.position(|r| matches!(r, RightRow::Track { .. }))
|
||||||
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Синхронизировать правую панель с играющим треком
|
|
||||||
pub fn sync_with_playing(&mut self, index_in_library: usize) {
|
pub fn sync_with_playing(&mut self, index_in_library: usize) {
|
||||||
if let Some(row) = self.left_row_of_track(index_in_library) {
|
for (ai, artist) in self.artists.iter().enumerate() {
|
||||||
self.left_selected = row;
|
if let Some(pos) = artist.rows.iter().position(|r| {
|
||||||
if let Some(pos) = self
|
matches!(r, RightRow::Track { index_in_library: i } if *i == index_in_library)
|
||||||
.left_to_tracks
|
}) {
|
||||||
.get(row)
|
self.artist_selected = ai;
|
||||||
.and_then(|v| v.iter().position(|&i| i == index_in_library))
|
|
||||||
{
|
|
||||||
self.right_selected = pos;
|
self.right_selected = pos;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
70
src/main.rs
70
src/main.rs
@@ -1,6 +1,7 @@
|
|||||||
mod app;
|
mod app;
|
||||||
mod audio;
|
mod audio;
|
||||||
mod library;
|
mod library;
|
||||||
|
mod state;
|
||||||
mod track;
|
mod track;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
@@ -8,28 +9,34 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::{App, InputMode};
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let music_dir = std::env::args().nth(1).unwrap_or_else(|| {
|
let saved = state::load();
|
||||||
dirs::home_dir()
|
let music_dir = std::env::args()
|
||||||
.map(|h| h.join("Music").to_string_lossy().to_string())
|
.nth(1)
|
||||||
.unwrap_or_else(|| "./music".into())
|
.or_else(|| saved.last_path.clone())
|
||||||
});
|
.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 terminal = ratatui::init();
|
||||||
let mut app = App::new(&music_dir)?;
|
let mut app = App::new(&music_dir)?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
app.maybe_advance();
|
app.maybe_advance();
|
||||||
|
app.player.spectrum.recompute();
|
||||||
terminal.draw(|f| ui::draw(f, &mut app))?;
|
terminal.draw(|f| ui::draw(f, &mut app))?;
|
||||||
|
|
||||||
if event::poll(Duration::from_millis(100))? {
|
if event::poll(Duration::from_millis(50))? {
|
||||||
if let Event::Key(key) = event::read()? {
|
if let Event::Key(key) = event::read()? {
|
||||||
if key.kind == KeyEventKind::Release {
|
if key.kind == KeyEventKind::Release {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if handle_key(key.code, &mut app) {
|
if handle_key(key.code, &mut app) {
|
||||||
|
app.on_exit();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,6 +48,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_key(code: KeyCode, app: &mut App) -> bool {
|
fn handle_key(code: KeyCode, app: &mut App) -> bool {
|
||||||
|
// Режим поиска
|
||||||
|
if app.input_mode == InputMode::Search {
|
||||||
|
match code {
|
||||||
|
KeyCode::Esc => app.cancel_search(),
|
||||||
|
KeyCode::Enter => app.commit_search(),
|
||||||
|
KeyCode::Backspace => app.search_pop(),
|
||||||
|
KeyCode::Char(c) => app.search_push(c),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычный режим
|
||||||
match code {
|
match code {
|
||||||
KeyCode::Char('+') | KeyCode::Char('=') => {
|
KeyCode::Char('+') | KeyCode::Char('=') => {
|
||||||
app.volume_up();
|
app.volume_up();
|
||||||
@@ -82,15 +102,33 @@ fn handle_key(code: KeyCode, app: &mut App) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let KeyCode::Char(c) = code {
|
if let KeyCode::Char(c) = code {
|
||||||
match c.to_ascii_lowercase() {
|
match c {
|
||||||
'q' => return true,
|
'1'..='9' => {
|
||||||
'w' => app.up(),
|
let pct = (c as u8 - b'0') * 10;
|
||||||
's' => app.down(),
|
app.seek_percent(pct);
|
||||||
'a' => app.seek(-5),
|
}
|
||||||
'd' => app.seek(5),
|
_ => match c.to_ascii_lowercase() {
|
||||||
'z' => app.stop(),
|
'q' => return true,
|
||||||
't' => app.toggle_sort(),
|
'w' => app.up(),
|
||||||
_ => {}
|
's' => app.down(),
|
||||||
|
'a' => app.seek(-5),
|
||||||
|
'd' => app.seek(5),
|
||||||
|
'z' => app.stop(),
|
||||||
|
't' => app.toggle_sort(),
|
||||||
|
'f' => app.jump_to_playing(),
|
||||||
|
'g' => {
|
||||||
|
// 'g' (строчная) — в начало, 'G' (заглавная) — в конец
|
||||||
|
if c == 'G' {
|
||||||
|
app.goto_end();
|
||||||
|
} else {
|
||||||
|
app.goto_start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'r' => app.cycle_repeat(),
|
||||||
|
'x' => app.toggle_shuffle(),
|
||||||
|
'/' => app.start_search(),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
|
|||||||
47
src/state.rs
Normal file
47
src/state.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
|
pub struct SavedState {
|
||||||
|
pub last_path: Option<String>,
|
||||||
|
pub volume: Option<f32>,
|
||||||
|
pub sort_by_filename: Option<bool>,
|
||||||
|
pub repeat: Option<RepeatMode>,
|
||||||
|
pub shuffle: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)]
|
||||||
|
pub enum RepeatMode {
|
||||||
|
#[default]
|
||||||
|
Off,
|
||||||
|
All,
|
||||||
|
One,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|p| p.join("rumus").join("state.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> SavedState {
|
||||||
|
let Some(path) = config_path() else {
|
||||||
|
return SavedState::default();
|
||||||
|
};
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(text) => toml::from_str(&text).unwrap_or_default(),
|
||||||
|
Err(_) => SavedState::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(state: &SavedState) {
|
||||||
|
let Some(path) = config_path() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
if let Ok(text) = toml::to_string_pretty(state) {
|
||||||
|
let _ = fs::write(path, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
299
src/ui.rs
299
src/ui.rs
@@ -1,69 +1,164 @@
|
|||||||
use ratatui::prelude::*;
|
use ratatui::prelude::*;
|
||||||
use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, Paragraph};
|
use ratatui::widgets::{
|
||||||
|
Bar, BarChart, BarGroup, Block, Borders, Gauge, List, ListItem, ListState, Paragraph,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::app::{App, Panel};
|
use crate::app::{App, InputMode, Panel};
|
||||||
use crate::library::{LeftRow, SortMode};
|
use crate::library::{RightRow, SortMode};
|
||||||
|
use crate::state::RepeatMode;
|
||||||
|
|
||||||
pub fn draw(f: &mut Frame, app: &mut App) {
|
pub fn draw(f: &mut Frame, app: &mut App) {
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Min(1),
|
Constraint::Min(1),
|
||||||
Constraint::Length(3),
|
Constraint::Length(6), // эквалайзер
|
||||||
Constraint::Length(3),
|
Constraint::Length(3), // прогресс
|
||||||
Constraint::Length(3),
|
Constraint::Length(3), // статус + vol
|
||||||
])
|
])
|
||||||
.split(f.area());
|
.split(f.area());
|
||||||
|
|
||||||
// Горизонтальное разделение верхней области: 40% слева, 60% справа
|
|
||||||
let top = Layout::default()
|
let top = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
|
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||||
.split(chunks[0]);
|
.split(chunks[0]);
|
||||||
|
|
||||||
draw_left_panel(f, app, top[0]);
|
draw_left_panel(f, app, top[0]);
|
||||||
draw_right_panel(f, app, top[1]);
|
draw_right_panel(f, app, top[1]);
|
||||||
draw_progress(f, app, chunks[1]);
|
draw_equalizer(f, app, chunks[1]);
|
||||||
draw_volume(f, app, chunks[2]);
|
draw_progress(f, app, chunks[2]);
|
||||||
draw_status(f, app, chunks[3]);
|
draw_status(f, app, chunks[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_left_panel(f: &mut Frame, app: &App, area: Rect) {
|
fn draw_left_panel(f: &mut Frame, app: &App, area: Rect) {
|
||||||
let items: Vec<ListItem> = app
|
let items: Vec<ListItem> = app
|
||||||
.library
|
.library
|
||||||
.left_rows
|
.artists
|
||||||
.iter()
|
.iter()
|
||||||
.map(|row| match row {
|
.map(|a| ListItem::new(a.name.clone()))
|
||||||
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();
|
.collect();
|
||||||
|
|
||||||
let title = match app.library.sort_mode {
|
|
||||||
SortMode::ByArtist => " Артисты / Альбомы ",
|
|
||||||
SortMode::ByFilename => " Источник ",
|
|
||||||
};
|
|
||||||
|
|
||||||
let highlight = if app.active_panel == Panel::Left {
|
let highlight = if app.active_panel == Panel::Left {
|
||||||
Style::default().add_modifier(Modifier::REVERSED)
|
Style::default().add_modifier(Modifier::REVERSED)
|
||||||
} else {
|
} else {
|
||||||
Style::default().add_modifier(Modifier::DIM)
|
Style::default().add_modifier(Modifier::DIM)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut list_state = ratatui::widgets::ListState::default();
|
let mut list_state = ListState::default();
|
||||||
if !app.library.left_rows.is_empty() {
|
if !app.library.artists.is_empty() {
|
||||||
list_state.select(Some(app.library.left_selected));
|
list_state.select(Some(app.library.artist_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 fmt_dur(d: std::time::Duration) -> String {
|
||||||
|
if d.is_zero() {
|
||||||
|
"--:--".into()
|
||||||
|
} else {
|
||||||
|
format!("{}:{:02}", d.as_secs() / 60, d.as_secs() % 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_right_panel(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let rows = app.library.current_right_rows();
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, row)| match row {
|
||||||
|
RightRow::AlbumHeader { name } => {
|
||||||
|
let mut album_total = std::time::Duration::ZERO;
|
||||||
|
let mut i = idx + 1;
|
||||||
|
while i < rows.len() {
|
||||||
|
if let RightRow::Track { index_in_library } = rows[i] {
|
||||||
|
if let Some(t) = app.library.track(index_in_library) {
|
||||||
|
album_total += t.duration;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let total_str = fmt_dur(album_total);
|
||||||
|
|
||||||
|
let inner_width = area.width.saturating_sub(6) as usize;
|
||||||
|
let pad = inner_width
|
||||||
|
.saturating_sub(name.len() + total_str.len() + 3)
|
||||||
|
.max(1);
|
||||||
|
let line = format!("{} {} {}", name, "─".repeat(pad), total_str);
|
||||||
|
|
||||||
|
let album_color = Color::Rgb(100, 149, 237);
|
||||||
|
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::styled("▶ ", Style::default().fg(album_color)),
|
||||||
|
Span::styled(
|
||||||
|
line,
|
||||||
|
Style::default()
|
||||||
|
.fg(album_color)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
RightRow::Track { index_in_library } => {
|
||||||
|
let is_playing = Some(*index_in_library) == app.playing_index;
|
||||||
|
let marker = if is_playing { "♪ " } else { " " };
|
||||||
|
|
||||||
|
let (title, num, dur) = match app.library.track(*index_in_library) {
|
||||||
|
Some(t) => (
|
||||||
|
t.title.clone(),
|
||||||
|
t.track_number
|
||||||
|
.map(|n| format!("{:02}. ", n))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
fmt_dur(t.duration),
|
||||||
|
),
|
||||||
|
None => ("<unknown>".into(), String::new(), "--:--".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let style = if is_playing {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::raw(marker),
|
||||||
|
Span::styled(format!("{}{}", num, title), style),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(dur, Style::default().fg(Color::DarkGray)),
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let title = match app.library.sort_mode {
|
||||||
|
SortMode::ByArtist => {
|
||||||
|
let artist_name = app
|
||||||
|
.library
|
||||||
|
.artists
|
||||||
|
.get(app.library.artist_selected)
|
||||||
|
.map(|a| a.name.as_str())
|
||||||
|
.unwrap_or("—");
|
||||||
|
format!(" Альбомы и треки — {} ", artist_name)
|
||||||
|
}
|
||||||
|
SortMode::ByFilename => " Альбомы и треки ".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = ListState::default();
|
||||||
|
if !rows.is_empty() {
|
||||||
|
list_state.select(Some(app.library.right_selected));
|
||||||
}
|
}
|
||||||
|
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
@@ -74,55 +169,55 @@ fn draw_left_panel(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
f.render_stateful_widget(list, area, &mut list_state);
|
f.render_stateful_widget(list, area, &mut list_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_right_panel(f: &mut Frame, app: &App, area: Rect) {
|
/// Цветной эквалайзер: 32 столбика с градиентом от синего к красному
|
||||||
let indices = app.library.current_right_indices();
|
fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let bars_data = app.player.spectrum.bars();
|
||||||
|
let num_bars = 32;
|
||||||
|
|
||||||
let items: Vec<ListItem> = indices
|
let mut display: Vec<f32> = Vec::with_capacity(num_bars);
|
||||||
|
for i in 0..num_bars {
|
||||||
|
let a = bars_data[i * 2];
|
||||||
|
let b = bars_data[i * 2 + 1];
|
||||||
|
display.push((a + b) / 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bars: Vec<Bar> = display
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&idx| {
|
.enumerate()
|
||||||
let is_playing = Some(idx) == app.playing_index;
|
.map(|(i, &v)| {
|
||||||
let marker = if is_playing { "♪ " } else { " " };
|
let ratio = i as f64 / (num_bars - 1).max(1) as f64;
|
||||||
|
let color = if ratio < 0.2 {
|
||||||
let (title, num) = match app.library.track(idx) {
|
Color::Rgb(0, 100, 255)
|
||||||
Some(t) => (
|
} else if ratio < 0.4 {
|
||||||
t.title.clone(),
|
Color::Rgb(0, 200, 255)
|
||||||
t.track_number
|
} else if ratio < 0.6 {
|
||||||
.map(|n| format!("{:02}. ", n))
|
Color::Rgb(0, 255, 100)
|
||||||
.unwrap_or_default(),
|
} else if ratio < 0.8 {
|
||||||
),
|
Color::Rgb(255, 200, 0)
|
||||||
None => ("<unknown>".into(), String::new()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let style = if is_playing {
|
|
||||||
Style::default().fg(Color::Green)
|
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
Color::Rgb(255, 50, 50)
|
||||||
};
|
};
|
||||||
|
|
||||||
ListItem::new(Line::from(vec![
|
Bar::default()
|
||||||
Span::raw(marker),
|
.value((v * 100.0) as u64)
|
||||||
Span::styled(format!("{}{}", num, title), style),
|
.style(Style::default().fg(color))
|
||||||
]))
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let highlight = if app.active_panel == Panel::Right {
|
let chart = BarChart::default()
|
||||||
Style::default().add_modifier(Modifier::REVERSED)
|
.data(BarGroup::default().bars(&bars))
|
||||||
} else {
|
.bar_width(1)
|
||||||
Style::default().add_modifier(Modifier::DIM)
|
.bar_gap(1)
|
||||||
};
|
.max(100)
|
||||||
|
.bar_set(ratatui::symbols::bar::NINE_LEVELS)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Эквалайзер ")
|
||||||
|
.title_style(Style::default().fg(Color::Rgb(100, 149, 237))),
|
||||||
|
);
|
||||||
|
|
||||||
let mut list_state = ratatui::widgets::ListState::default();
|
f.render_widget(chart, area);
|
||||||
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) {
|
fn draw_progress(f: &mut Frame, app: &App, area: Rect) {
|
||||||
@@ -148,32 +243,42 @@ fn draw_progress(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
f.render_widget(gauge, area);
|
f.render_widget(gauge, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_volume(f: &mut Frame, app: &App, area: Rect) {
|
fn draw_status(f: &mut Frame, app: &App, area: Rect) {
|
||||||
let vol = app.player.volume();
|
let inner = Layout::default()
|
||||||
let ratio = (vol as f64 / 2.0).clamp(0.0, 1.0);
|
.direction(Direction::Horizontal)
|
||||||
let color = if vol > 1.0 {
|
.constraints([
|
||||||
Color::Yellow
|
Constraint::Min(1),
|
||||||
} else if vol > 0.5 {
|
Constraint::Length(16),
|
||||||
Color::Cyan
|
Constraint::Length(12),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let (status_text, status_title) = if app.input_mode == InputMode::Search {
|
||||||
|
(format!("> {}", app.search_buffer), " Поиск ")
|
||||||
} else {
|
} else {
|
||||||
Color::Blue
|
(app.status.clone(), " Статус ")
|
||||||
};
|
};
|
||||||
|
|
||||||
let gauge = Gauge::default()
|
let status = Paragraph::new(status_text)
|
||||||
.block(
|
.block(Block::default().borders(Borders::ALL).title(status_title));
|
||||||
Block::default()
|
f.render_widget(status, inner[0]);
|
||||||
.borders(Borders::ALL)
|
|
||||||
.title(" Громкость (+/-) "),
|
|
||||||
)
|
|
||||||
.gauge_style(Style::default().fg(color))
|
|
||||||
.ratio(ratio)
|
|
||||||
.label(format!("{}%", (vol * 100.0) as u32));
|
|
||||||
|
|
||||||
f.render_widget(gauge, area);
|
let mode_str = match (app.repeat, app.shuffle) {
|
||||||
}
|
(RepeatMode::Off, false) => "".to_string(),
|
||||||
|
(RepeatMode::Off, true) => "SHUF".to_string(),
|
||||||
|
(RepeatMode::All, false) => "RPT ALL".to_string(),
|
||||||
|
(RepeatMode::One, false) => "RPT ONE".to_string(),
|
||||||
|
(RepeatMode::All, true) => "RPT+SHUF".to_string(),
|
||||||
|
(RepeatMode::One, true) => "ONE+SHUF".to_string(),
|
||||||
|
};
|
||||||
|
let mode = Paragraph::new(mode_str)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" Mode "));
|
||||||
|
f.render_widget(mode, inner[1]);
|
||||||
|
|
||||||
fn draw_status(f: &mut Frame, app: &App, area: Rect) {
|
let vol_text = format!("vol: {}%", (app.player.volume() * 100.0) as u32);
|
||||||
let status = Paragraph::new(app.status.as_str())
|
let vol = Paragraph::new(vol_text)
|
||||||
.block(Block::default().borders(Borders::ALL).title(" Статус "));
|
.alignment(Alignment::Center)
|
||||||
f.render_widget(status, area);
|
.block(Block::default().borders(Borders::ALL).title(" Звук "));
|
||||||
|
f.render_widget(vol, inner[2]);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user