обновление
This commit is contained in:
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -48,6 +48,15 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bincode"
|
||||||
|
version = "1.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@@ -974,6 +983,7 @@ dependencies = [
|
|||||||
name = "rumus"
|
name = "rumus"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bincode",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"dirs",
|
"dirs",
|
||||||
"lofty",
|
"lofty",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rumus"
|
name = "rumus"
|
||||||
version = "0.1.0"
|
version = "0.5.0"
|
||||||
edition = "2024"
|
edition = "2026"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ratatui = "0.29"
|
ratatui = "0.29"
|
||||||
@@ -13,3 +13,4 @@ lofty = "0.22"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
rustfft = "6"
|
rustfft = "6"
|
||||||
|
bincode = "1.3"
|
||||||
259
src/app.rs
259
src/app.rs
@@ -1,9 +1,9 @@
|
|||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
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::state::{RepeatMode, SavedState};
|
||||||
use crate::track::scan_dir;
|
use crate::track::{scan_dir, Track};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
pub enum Panel {
|
pub enum Panel {
|
||||||
@@ -25,23 +25,33 @@ pub struct App {
|
|||||||
pub active_panel: Panel,
|
pub active_panel: Panel,
|
||||||
pub input_mode: InputMode,
|
pub input_mode: InputMode,
|
||||||
pub search_buffer: String,
|
pub search_buffer: String,
|
||||||
/// Исходный (неотфильтрованный) список треков — для пересборки библиотеки
|
raw_tracks: Vec<Track>,
|
||||||
raw_tracks: Vec<crate::track::Track>,
|
|
||||||
/// Директория с музыкой — чтобы сохранить в state
|
|
||||||
music_dir: String,
|
music_dir: String,
|
||||||
pub repeat: RepeatMode,
|
pub repeat: RepeatMode,
|
||||||
pub shuffle: bool,
|
pub shuffle: bool,
|
||||||
|
pub show_help: bool,
|
||||||
|
status_set_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_RESET_AFTER: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
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 = match crate::state::load_cache(music_dir) {
|
||||||
|
Some(cached) => cached,
|
||||||
|
None => {
|
||||||
|
let scanned = scan_dir(music_dir);
|
||||||
|
crate::state::save_cache(music_dir, &scanned);
|
||||||
|
scanned
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let library = Library::build(tracks.clone(), SortMode::ByArtist);
|
let library = Library::build(tracks.clone(), SortMode::ByArtist);
|
||||||
|
|
||||||
let mut app = Self {
|
let mut app = Self {
|
||||||
library,
|
library,
|
||||||
player: AudioPlayer::new()?,
|
player: AudioPlayer::new()?,
|
||||||
status: "? — help".into(),
|
status: "F1 — помощь".into(),
|
||||||
playing_index: None,
|
playing_index: None,
|
||||||
active_panel: Panel::Left,
|
active_panel: Panel::Left,
|
||||||
input_mode: InputMode::Normal,
|
input_mode: InputMode::Normal,
|
||||||
@@ -50,9 +60,10 @@ impl App {
|
|||||||
music_dir: music_dir.to_string(),
|
music_dir: music_dir.to_string(),
|
||||||
repeat: RepeatMode::Off,
|
repeat: RepeatMode::Off,
|
||||||
shuffle: false,
|
shuffle: false,
|
||||||
|
show_help: false,
|
||||||
|
status_set_at: Instant::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Восстанавливаем сохранённое состояние
|
|
||||||
let saved = crate::state::load();
|
let saved = crate::state::load();
|
||||||
if let Some(vol) = saved.volume {
|
if let Some(vol) = saved.volume {
|
||||||
app.player.set_volume(vol);
|
app.player.set_volume(vol);
|
||||||
@@ -63,20 +74,138 @@ impl App {
|
|||||||
if let Some(sh) = saved.shuffle {
|
if let Some(sh) = saved.shuffle {
|
||||||
app.shuffle = sh;
|
app.shuffle = sh;
|
||||||
}
|
}
|
||||||
if saved.sort_by_filename == Some(true) {
|
if let Some(mode) = saved.sort_mode {
|
||||||
app.library = Library::build(app.raw_tracks.clone(), SortMode::ByFilename);
|
if mode != SortMode::ByArtist {
|
||||||
|
app.library = Library::build(app.raw_tracks.clone(), mode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.restore_playing(&saved);
|
||||||
Ok(app)
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_status(&mut self, text: String) {
|
||||||
|
self.status = text;
|
||||||
|
self.status_set_at = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_status(&self) -> String {
|
||||||
|
match self.playing_index.and_then(|i| self.library.track(i)) {
|
||||||
|
Some(t) => format!("▶ {} — {}", t.artist, t.title),
|
||||||
|
None => "F1 — помощь".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tick_status(&mut self) {
|
||||||
|
if self.input_mode == InputMode::Search {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.status_set_at.elapsed() < STATUS_RESET_AFTER {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let default = self.default_status();
|
||||||
|
if self.status != default {
|
||||||
|
self.status = default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_playing(&mut self, saved: &SavedState) {
|
||||||
|
let Some(path_str) = &saved.last_playing_path else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_path = std::path::PathBuf::from(path_str);
|
||||||
|
let Some(idx) = self
|
||||||
|
.library
|
||||||
|
.all_tracks
|
||||||
|
.iter()
|
||||||
|
.position(|t| t.path == target_path)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let start_pos = saved.last_playing_pos_secs.map(Duration::from_secs);
|
||||||
|
|
||||||
|
if let Some(track) = self.library.track(idx).cloned() {
|
||||||
|
if self.player.play(&track.path, start_pos).is_ok() {
|
||||||
|
self.playing_index = Some(idx);
|
||||||
|
self.library.sync_with_playing(idx);
|
||||||
|
self.player.toggle_pause();
|
||||||
|
self.set_status(format!("Восстановлен: {} — {}", track.artist, track.title));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_help(&mut self) {
|
||||||
|
self.show_help = !self.show_help;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_track(&mut self) {
|
||||||
|
let total = self.library.all_tracks.len();
|
||||||
|
if total == 0 {
|
||||||
|
self.set_status("Библиотека пуста".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = match self.playing_index {
|
||||||
|
Some(i) if i + 1 < total => i + 1,
|
||||||
|
Some(_) => {
|
||||||
|
if self.repeat == RepeatMode::Off {
|
||||||
|
self.set_status("Конец плейлиста".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.play_index(next);
|
||||||
|
self.library.sync_with_playing(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_track(&mut self) {
|
||||||
|
let total = self.library.all_tracks.len();
|
||||||
|
if total == 0 {
|
||||||
|
self.set_status("Библиотека пуста".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prev = match self.playing_index {
|
||||||
|
Some(0) => {
|
||||||
|
if self.repeat == RepeatMode::Off {
|
||||||
|
self.set_status("Это первый трек".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
total - 1
|
||||||
|
}
|
||||||
|
Some(i) => i - 1,
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.play_index(prev);
|
||||||
|
self.library.sync_with_playing(prev);
|
||||||
|
}
|
||||||
|
|
||||||
fn persist(&self) {
|
fn persist(&self) {
|
||||||
|
let playing_path = self
|
||||||
|
.playing_index
|
||||||
|
.and_then(|i| self.library.track(i))
|
||||||
|
.map(|t| t.path.to_string_lossy().to_string());
|
||||||
|
|
||||||
|
let playing_pos = if self.playing_index.is_some() {
|
||||||
|
Some(self.player.position().as_secs())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let state = SavedState {
|
let state = SavedState {
|
||||||
last_path: Some(self.music_dir.clone()),
|
last_path: Some(self.music_dir.clone()),
|
||||||
volume: Some(self.player.volume()),
|
volume: Some(self.player.volume()),
|
||||||
sort_by_filename: Some(self.library.sort_mode == SortMode::ByFilename),
|
sort_mode: Some(self.library.sort_mode),
|
||||||
repeat: Some(self.repeat),
|
repeat: Some(self.repeat),
|
||||||
shuffle: Some(self.shuffle),
|
shuffle: Some(self.shuffle),
|
||||||
|
last_playing_path: playing_path,
|
||||||
|
last_playing_pos_secs: playing_pos,
|
||||||
};
|
};
|
||||||
crate::state::save(&state);
|
crate::state::save(&state);
|
||||||
}
|
}
|
||||||
@@ -85,40 +214,39 @@ impl App {
|
|||||||
self.persist();
|
self.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Поиск ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn start_search(&mut self) {
|
pub fn start_search(&mut self) {
|
||||||
self.input_mode = InputMode::Search;
|
self.input_mode = InputMode::Search;
|
||||||
self.search_buffer.clear();
|
self.search_buffer.clear();
|
||||||
self.status = "Поиск: ".into();
|
self.status_set_at = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cancel_search(&mut self) {
|
pub fn cancel_search(&mut self) {
|
||||||
self.input_mode = InputMode::Normal;
|
self.input_mode = InputMode::Normal;
|
||||||
self.search_buffer.clear();
|
self.search_buffer.clear();
|
||||||
self.rebuild_library();
|
self.rebuild_library();
|
||||||
self.status = "Поиск отменён".into();
|
self.set_status("Поиск отменён".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn commit_search(&mut self) {
|
pub fn commit_search(&mut self) {
|
||||||
self.input_mode = InputMode::Normal;
|
self.input_mode = InputMode::Normal;
|
||||||
self.status = if self.search_buffer.is_empty() {
|
let msg = if self.search_buffer.is_empty() {
|
||||||
"Фильтр сброшен".into()
|
"Фильтр сброшен".to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("Фильтр: {}", self.search_buffer)
|
format!("Фильтр: {}", self.search_buffer)
|
||||||
};
|
};
|
||||||
|
self.set_status(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn search_push(&mut self, c: char) {
|
pub fn search_push(&mut self, c: char) {
|
||||||
self.search_buffer.push(c);
|
self.search_buffer.push(c);
|
||||||
self.rebuild_library();
|
self.rebuild_library();
|
||||||
self.status = format!("Поиск: {}", self.search_buffer);
|
self.status_set_at = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn search_pop(&mut self) {
|
pub fn search_pop(&mut self) {
|
||||||
self.search_buffer.pop();
|
self.search_buffer.pop();
|
||||||
self.rebuild_library();
|
self.rebuild_library();
|
||||||
self.status = format!("Поиск: {}", self.search_buffer);
|
self.status_set_at = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rebuild_library(&mut self) {
|
fn rebuild_library(&mut self) {
|
||||||
@@ -133,8 +261,6 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Навигация ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn up(&mut self) {
|
pub fn up(&mut self) {
|
||||||
match self.active_panel {
|
match self.active_panel {
|
||||||
Panel::Left => self.library.artist_move(-1),
|
Panel::Left => self.library.artist_move(-1),
|
||||||
@@ -154,27 +280,29 @@ impl App {
|
|||||||
Panel::Left => Panel::Right,
|
Panel::Left => Panel::Right,
|
||||||
Panel::Right => Panel::Left,
|
Panel::Right => Panel::Left,
|
||||||
};
|
};
|
||||||
self.status = match self.active_panel {
|
let msg = match self.active_panel {
|
||||||
Panel::Left => "Активна: исполнители".into(),
|
Panel::Left => "Активна: левая панель".to_string(),
|
||||||
Panel::Right => "Активна: альбомы/треки".into(),
|
Panel::Right => "Активна: правая панель".to_string(),
|
||||||
};
|
};
|
||||||
|
self.set_status(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// f — прыжок к играющему треку
|
|
||||||
pub fn jump_to_playing(&mut self) {
|
pub fn jump_to_playing(&mut self) {
|
||||||
if let Some(i) = self.playing_index {
|
if let Some(i) = self.playing_index {
|
||||||
self.library.sync_with_playing(i);
|
self.library.sync_with_playing(i);
|
||||||
self.active_panel = Panel::Right;
|
self.active_panel = Panel::Right;
|
||||||
self.status = "→ к играющему треку".into();
|
self.set_status("→ к играющему треку".into());
|
||||||
} else {
|
} else {
|
||||||
self.status = "Ничего не играет".into();
|
self.set_status("Ничего не играет".into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// g — в начало, G — в конец
|
|
||||||
pub fn goto_start(&mut self) {
|
pub fn goto_start(&mut self) {
|
||||||
match self.active_panel {
|
match self.active_panel {
|
||||||
Panel::Left => self.library.artist_selected = 0,
|
Panel::Left => {
|
||||||
|
self.library.artist_selected = 0;
|
||||||
|
self.library.left_state.select(Some(0));
|
||||||
|
}
|
||||||
Panel::Right => self.library.right_goto_start(),
|
Panel::Right => self.library.right_goto_start(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,24 +312,25 @@ impl App {
|
|||||||
Panel::Left => {
|
Panel::Left => {
|
||||||
if !self.library.artists.is_empty() {
|
if !self.library.artists.is_empty() {
|
||||||
self.library.artist_selected = self.library.artists.len() - 1;
|
self.library.artist_selected = self.library.artists.len() - 1;
|
||||||
|
self.library
|
||||||
|
.left_state
|
||||||
|
.select(Some(self.library.artist_selected));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Panel::Right => self.library.right_goto_end(),
|
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;
|
||||||
};
|
};
|
||||||
match self.player.play(&track.path) {
|
match self.player.play(&track.path, None) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.status = format!("▶ {} — {}", track.artist, track.title);
|
|
||||||
self.playing_index = Some(index_in_library);
|
self.playing_index = Some(index_in_library);
|
||||||
|
self.set_status(format!("▶ {} — {}", track.artist, track.title));
|
||||||
}
|
}
|
||||||
Err(e) => self.status = format!("Ошибка: {}", e),
|
Err(e) => self.set_status(format!("Ошибка: {}", e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,27 +338,25 @@ impl App {
|
|||||||
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 {
|
} else {
|
||||||
self.status = "Выделен заголовок альбома".into();
|
self.set_status("Выделен заголовок группы".into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop(&mut self) {
|
pub fn stop(&mut self) {
|
||||||
self.player.stop();
|
self.player.stop();
|
||||||
self.playing_index = None;
|
self.playing_index = None;
|
||||||
self.status = "Остановлено".into();
|
self.set_status("Остановлено".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_pause(&mut self) {
|
pub fn toggle_pause(&mut self) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repeat One — играем тот же трек
|
|
||||||
if self.repeat == RepeatMode::One {
|
if self.repeat == RepeatMode::One {
|
||||||
if let Some(i) = self.playing_index {
|
if let Some(i) = self.playing_index {
|
||||||
self.play_index(i);
|
self.play_index(i);
|
||||||
@@ -243,7 +370,6 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let next = if self.shuffle {
|
let next = if self.shuffle {
|
||||||
// Псевдослучайный выбор без rand-крейта
|
|
||||||
let seed = std::time::SystemTime::now()
|
let seed = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_nanos() as usize)
|
.map(|d| d.as_nanos() as usize)
|
||||||
@@ -256,8 +382,7 @@ impl App {
|
|||||||
if self.repeat == RepeatMode::All {
|
if self.repeat == RepeatMode::All {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
// Off — останавливаемся
|
self.set_status("Конец плейлиста".into());
|
||||||
self.status = "Конец плейлиста".into();
|
|
||||||
self.playing_index = None;
|
self.playing_index = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -275,37 +400,39 @@ impl App {
|
|||||||
RepeatMode::All => RepeatMode::One,
|
RepeatMode::All => RepeatMode::One,
|
||||||
RepeatMode::One => RepeatMode::Off,
|
RepeatMode::One => RepeatMode::Off,
|
||||||
};
|
};
|
||||||
self.status = match self.repeat {
|
let msg = match self.repeat {
|
||||||
RepeatMode::Off => "Repeat: off".into(),
|
RepeatMode::Off => "Repeat: off".to_string(),
|
||||||
RepeatMode::All => "Repeat: all".into(),
|
RepeatMode::All => "Repeat: all".to_string(),
|
||||||
RepeatMode::One => "Repeat: one".into(),
|
RepeatMode::One => "Repeat: one".to_string(),
|
||||||
};
|
};
|
||||||
|
self.set_status(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_shuffle(&mut self) {
|
pub fn toggle_shuffle(&mut self) {
|
||||||
self.shuffle = !self.shuffle;
|
self.shuffle = !self.shuffle;
|
||||||
self.status = if self.shuffle {
|
let msg = if self.shuffle {
|
||||||
"Shuffle: on".into()
|
"Shuffle: on".to_string()
|
||||||
} else {
|
} else {
|
||||||
"Shuffle: off".into()
|
"Shuffle: off".to_string()
|
||||||
};
|
};
|
||||||
|
self.set_status(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Громкость, перемотка ───────────────────────────────────
|
|
||||||
|
|
||||||
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);
|
let msg = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32);
|
||||||
|
self.set_status(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn volume_down(&mut self) {
|
pub fn volume_down(&mut self) {
|
||||||
self.player.change_volume(-0.05);
|
self.player.change_volume(-0.05);
|
||||||
self.status = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32);
|
let msg = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32);
|
||||||
|
self.set_status(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn seek(&mut self, delta_secs: i64) {
|
pub fn seek(&mut self, delta_secs: i64) {
|
||||||
if self.playing_index.is_none() {
|
if self.playing_index.is_none() {
|
||||||
self.status = "Ничего не воспроизводится".into();
|
self.set_status("Ничего не воспроизводится".into());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let total = self
|
let total = self
|
||||||
@@ -317,22 +444,22 @@ impl App {
|
|||||||
match self.player.seek_by(delta_secs, total) {
|
match self.player.seek_by(delta_secs, total) {
|
||||||
Ok(new_pos) => {
|
Ok(new_pos) => {
|
||||||
let sign = if delta_secs >= 0 { "+" } else { "" };
|
let sign = if delta_secs >= 0 { "+" } else { "" };
|
||||||
self.status = format!(
|
let msg = format!(
|
||||||
"Перемотка {}{}с → {:02}:{:02}",
|
"Перемотка {}{}с → {:02}:{:02}",
|
||||||
sign,
|
sign,
|
||||||
delta_secs,
|
delta_secs,
|
||||||
new_pos.as_secs() / 60,
|
new_pos.as_secs() / 60,
|
||||||
new_pos.as_secs() % 60,
|
new_pos.as_secs() % 60,
|
||||||
);
|
);
|
||||||
|
self.set_status(msg);
|
||||||
}
|
}
|
||||||
Err(_) => self.status = "Перемотка недоступна".into(),
|
Err(_) => self.set_status("Перемотка недоступна".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Перемотка по цифрам 1–9 (10%..90%)
|
|
||||||
pub fn seek_percent(&mut self, percent: u8) {
|
pub fn seek_percent(&mut self, percent: u8) {
|
||||||
if self.playing_index.is_none() {
|
if self.playing_index.is_none() {
|
||||||
self.status = "Ничего не воспроизводится".into();
|
self.set_status("Ничего не воспроизводится".into());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let total = match self
|
let total = match self
|
||||||
@@ -342,21 +469,22 @@ impl App {
|
|||||||
{
|
{
|
||||||
Some(d) if !d.is_zero() => d,
|
Some(d) if !d.is_zero() => d,
|
||||||
_ => {
|
_ => {
|
||||||
self.status = "Длительность неизвестна".into();
|
self.set_status("Длительность неизвестна".into());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let target = total.mul_f32(percent as f32 / 100.0);
|
let target = total.mul_f32(percent as f32 / 100.0);
|
||||||
match self.player.seek_to(target) {
|
match self.player.seek_to(target) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.status = format!(
|
let msg = format!(
|
||||||
"→ {}% ({:02}:{:02})",
|
"→ {}% ({:02}:{:02})",
|
||||||
percent,
|
percent,
|
||||||
target.as_secs() / 60,
|
target.as_secs() / 60,
|
||||||
target.as_secs() % 60,
|
target.as_secs() % 60,
|
||||||
);
|
);
|
||||||
|
self.set_status(msg);
|
||||||
}
|
}
|
||||||
Err(_) => self.status = "Перемотка недоступна".into(),
|
Err(_) => self.set_status("Перемотка недоступна".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,7 +497,8 @@ impl App {
|
|||||||
|
|
||||||
pub fn toggle_sort(&mut self) {
|
pub fn toggle_sort(&mut self) {
|
||||||
let new_mode = match self.library.sort_mode {
|
let new_mode = match self.library.sort_mode {
|
||||||
SortMode::ByArtist => SortMode::ByFilename,
|
SortMode::ByArtist => SortMode::ByAlbum,
|
||||||
|
SortMode::ByAlbum => SortMode::ByFilename,
|
||||||
SortMode::ByFilename => SortMode::ByArtist,
|
SortMode::ByFilename => SortMode::ByArtist,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -391,9 +520,11 @@ impl App {
|
|||||||
self.library.sync_with_playing(i);
|
self.library.sync_with_playing(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.status = match new_mode {
|
let msg = match new_mode {
|
||||||
SortMode::ByArtist => "Сортировка: по артисту/альбому".into(),
|
SortMode::ByArtist => "Сортировка: по артисту/альбому".to_string(),
|
||||||
SortMode::ByFilename => "Сортировка: по имени файла".into(),
|
SortMode::ByAlbum => "Сортировка: по альбому/артисту".to_string(),
|
||||||
|
SortMode::ByFilename => "Сортировка: по имени файла".to_string(),
|
||||||
};
|
};
|
||||||
|
self.set_status(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
21
src/audio.rs
21
src/audio.rs
@@ -124,7 +124,6 @@ where
|
|||||||
fn total_duration(&self) -> Option<Duration> {
|
fn total_duration(&self) -> Option<Duration> {
|
||||||
self.inner.total_duration()
|
self.inner.total_duration()
|
||||||
}
|
}
|
||||||
// ВАЖНО: пробрасываем try_seek, иначе Sink::try_seek вернёт NotSupported
|
|
||||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||||
self.inner.try_seek(pos)
|
self.inner.try_seek(pos)
|
||||||
}
|
}
|
||||||
@@ -150,17 +149,31 @@ impl AudioPlayer {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn play(&mut self, path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
/// Запускает трек, опционально перематывая в `start_pos` ДО начала воспроизведения.
|
||||||
|
/// Перемотка делается на декодере — это предотвращает щелчок на старте.
|
||||||
|
pub fn play(
|
||||||
|
&mut self,
|
||||||
|
path: &PathBuf,
|
||||||
|
start_pos: Option<Duration>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
self.sink.stop();
|
self.sink.stop();
|
||||||
self.paused_pos = None;
|
self.paused_pos = None;
|
||||||
|
|
||||||
let file = File::open(path)?;
|
let file = File::open(path)?;
|
||||||
// Decoder::try_from автоматически настраивает seekable и byte_len[citation:1][citation:19]
|
let mut decoder = Decoder::try_from(file)?;
|
||||||
let decoder = Decoder::try_from(file)?;
|
|
||||||
|
if let Some(pos) = start_pos {
|
||||||
|
if !pos.is_zero() {
|
||||||
|
let _ = decoder.try_seek(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let tapped = TapSource {
|
let tapped = TapSource {
|
||||||
inner: decoder,
|
inner: decoder,
|
||||||
bus: self.spectrum.clone(),
|
bus: self.spectrum.clone(),
|
||||||
};
|
};
|
||||||
self.sink.append(tapped);
|
self.sink.append(tapped);
|
||||||
|
// ВАЖНО: явно снимаем паузу — sink.stop() не сбрасывает состояние pause.
|
||||||
self.sink.play();
|
self.sink.play();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
246
src/library.rs
246
src/library.rs
@@ -1,16 +1,20 @@
|
|||||||
use ratatui::widgets::ListState;
|
use ratatui::widgets::ListState;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::track::Track;
|
use crate::track::Track;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum RightRow {
|
pub enum RightRow {
|
||||||
|
/// Заголовок группы: в режиме ByArtist — название альбома,
|
||||||
|
/// в режиме ByAlbum — имя исполнителя.
|
||||||
AlbumHeader { name: String },
|
AlbumHeader { name: String },
|
||||||
Track { index_in_library: usize },
|
Track { index_in_library: usize },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum SortMode {
|
pub enum SortMode {
|
||||||
ByArtist,
|
ByArtist,
|
||||||
|
ByAlbum,
|
||||||
ByFilename,
|
ByFilename,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,11 +29,8 @@ pub struct Library {
|
|||||||
pub artist_selected: usize,
|
pub artist_selected: usize,
|
||||||
pub right_selected: usize,
|
pub right_selected: usize,
|
||||||
pub sort_mode: SortMode,
|
pub sort_mode: SortMode,
|
||||||
/// Активный фильтр поиска (пусто — без фильтра)
|
|
||||||
pub filter: String,
|
pub filter: String,
|
||||||
/// Состояние скролла левой панели (сохраняется между кадрами)
|
|
||||||
pub left_state: ListState,
|
pub left_state: ListState,
|
||||||
/// Состояние скролла правой панели
|
|
||||||
pub right_state: ListState,
|
pub right_state: ListState,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +44,6 @@ impl Library {
|
|||||||
mode: SortMode,
|
mode: SortMode,
|
||||||
filter: String,
|
filter: String,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
// Применяем фильтр
|
|
||||||
if !filter.is_empty() {
|
if !filter.is_empty() {
|
||||||
let f = filter.to_lowercase();
|
let f = filter.to_lowercase();
|
||||||
tracks.retain(|t| {
|
tracks.retain(|t| {
|
||||||
@@ -54,108 +54,165 @@ impl Library {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match mode {
|
match mode {
|
||||||
SortMode::ByArtist => {
|
SortMode::ByArtist => Self::build_by_artist(tracks, mode, filter),
|
||||||
use std::collections::BTreeMap;
|
SortMode::ByAlbum => Self::build_by_album(tracks, mode, filter),
|
||||||
|
SortMode::ByFilename => Self::build_by_filename(tracks, mode, filter),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut map: BTreeMap<String, BTreeMap<String, Vec<Track>>> = BTreeMap::new();
|
fn build_by_artist(
|
||||||
for t in tracks.drain(..) {
|
mut tracks: Vec<Track>,
|
||||||
map.entry(t.artist.clone())
|
mode: SortMode,
|
||||||
.or_default()
|
filter: String,
|
||||||
.entry(t.album.clone())
|
) -> Self {
|
||||||
.or_default()
|
use std::collections::BTreeMap;
|
||||||
.push(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut all_tracks = Vec::new();
|
let mut map: BTreeMap<String, BTreeMap<String, Vec<Track>>> = BTreeMap::new();
|
||||||
let mut artists = Vec::new();
|
for t in tracks.drain(..) {
|
||||||
|
map.entry(t.artist.clone())
|
||||||
|
.or_default()
|
||||||
|
.entry(t.album.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
for (artist_name, albums_map) in map {
|
let mut all_tracks = Vec::new();
|
||||||
let mut rows = Vec::new();
|
let mut artists = Vec::new();
|
||||||
|
|
||||||
for (album_name, mut album_tracks) in albums_map {
|
for (artist_name, albums_map) in map {
|
||||||
album_tracks.sort_by(|a, b| {
|
let mut rows = Vec::new();
|
||||||
a.track_number
|
|
||||||
.unwrap_or(u32::MAX)
|
|
||||||
.cmp(&b.track_number.unwrap_or(u32::MAX))
|
|
||||||
.then(a.title.cmp(&b.title))
|
|
||||||
});
|
|
||||||
|
|
||||||
rows.push(RightRow::AlbumHeader { name: album_name });
|
for (album_name, mut album_tracks) in albums_map {
|
||||||
|
album_tracks.sort_by(|a, b| {
|
||||||
for t in album_tracks {
|
a.track_number
|
||||||
let idx = all_tracks.len();
|
.unwrap_or(u32::MAX)
|
||||||
all_tracks.push(t);
|
.cmp(&b.track_number.unwrap_or(u32::MAX))
|
||||||
rows.push(RightRow::Track {
|
.then(a.title.cmp(&b.title))
|
||||||
index_in_library: idx,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
artists.push(ArtistView {
|
|
||||||
name: artist_name,
|
|
||||||
rows,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вычисляем right_selected ДО перемещения artists
|
|
||||||
let right_selected = first_track_row_in(artists.first());
|
|
||||||
|
|
||||||
let mut left_state = ListState::default();
|
|
||||||
let mut right_state = ListState::default();
|
|
||||||
if !artists.is_empty() {
|
|
||||||
left_state.select(Some(0));
|
|
||||||
}
|
|
||||||
right_state.select(Some(right_selected));
|
|
||||||
|
|
||||||
Library {
|
|
||||||
all_tracks,
|
|
||||||
artists,
|
|
||||||
artist_selected: 0,
|
|
||||||
right_selected,
|
|
||||||
sort_mode: mode,
|
|
||||||
filter,
|
|
||||||
left_state,
|
|
||||||
right_state,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SortMode::ByFilename => {
|
|
||||||
tracks.sort_by(|a, b| a.path.cmp(&b.path));
|
|
||||||
let all_tracks = tracks;
|
|
||||||
|
|
||||||
let mut rows = Vec::new();
|
|
||||||
rows.push(RightRow::AlbumHeader {
|
|
||||||
name: "Все файлы".into(),
|
|
||||||
});
|
});
|
||||||
for i in 0..all_tracks.len() {
|
|
||||||
|
rows.push(RightRow::AlbumHeader { name: album_name });
|
||||||
|
|
||||||
|
for t in album_tracks {
|
||||||
|
let idx = all_tracks.len();
|
||||||
|
all_tracks.push(t);
|
||||||
rows.push(RightRow::Track {
|
rows.push(RightRow::Track {
|
||||||
index_in_library: i,
|
index_in_library: idx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let artists = vec![ArtistView {
|
artists.push(ArtistView {
|
||||||
name: "Все файлы".into(),
|
name: artist_name,
|
||||||
rows,
|
rows,
|
||||||
}];
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let right_selected = first_track_row_in(artists.first());
|
Self::finalize(all_tracks, artists, mode, filter)
|
||||||
|
}
|
||||||
|
|
||||||
let mut left_state = ListState::default();
|
fn build_by_album(
|
||||||
left_state.select(Some(0));
|
mut tracks: Vec<Track>,
|
||||||
let mut right_state = ListState::default();
|
mode: SortMode,
|
||||||
right_state.select(Some(right_selected));
|
filter: String,
|
||||||
|
) -> Self {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
Library {
|
// Album -> Artist -> Vec<Track>
|
||||||
all_tracks,
|
let mut map: BTreeMap<String, BTreeMap<String, Vec<Track>>> = BTreeMap::new();
|
||||||
artists,
|
for t in tracks.drain(..) {
|
||||||
artist_selected: 0,
|
map.entry(t.album.clone())
|
||||||
right_selected,
|
.or_default()
|
||||||
sort_mode: mode,
|
.entry(t.artist.clone())
|
||||||
filter,
|
.or_default()
|
||||||
left_state,
|
.push(t);
|
||||||
right_state,
|
}
|
||||||
|
|
||||||
|
let mut all_tracks = Vec::new();
|
||||||
|
let mut albums = Vec::new();
|
||||||
|
|
||||||
|
for (album_name, artists_map) in map {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
|
||||||
|
for (artist_name, mut artist_tracks) in artists_map {
|
||||||
|
artist_tracks.sort_by(|a, b| {
|
||||||
|
a.track_number
|
||||||
|
.unwrap_or(u32::MAX)
|
||||||
|
.cmp(&b.track_number.unwrap_or(u32::MAX))
|
||||||
|
.then(a.title.cmp(&b.title))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Заголовок группы = имя исполнителя
|
||||||
|
rows.push(RightRow::AlbumHeader { name: artist_name });
|
||||||
|
|
||||||
|
for t in artist_tracks {
|
||||||
|
let idx = all_tracks.len();
|
||||||
|
all_tracks.push(t);
|
||||||
|
rows.push(RightRow::Track {
|
||||||
|
index_in_library: idx,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
albums.push(ArtistView {
|
||||||
|
name: album_name,
|
||||||
|
rows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::finalize(all_tracks, albums, mode, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_by_filename(
|
||||||
|
mut tracks: Vec<Track>,
|
||||||
|
mode: SortMode,
|
||||||
|
filter: String,
|
||||||
|
) -> Self {
|
||||||
|
tracks.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
|
let all_tracks = tracks;
|
||||||
|
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
rows.push(RightRow::AlbumHeader {
|
||||||
|
name: "Все файлы".into(),
|
||||||
|
});
|
||||||
|
for i in 0..all_tracks.len() {
|
||||||
|
rows.push(RightRow::Track {
|
||||||
|
index_in_library: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let groups = vec![ArtistView {
|
||||||
|
name: "Все файлы".into(),
|
||||||
|
rows,
|
||||||
|
}];
|
||||||
|
|
||||||
|
Self::finalize(all_tracks, groups, mode, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Общая инициализация ListState и позиций курсора.
|
||||||
|
fn finalize(
|
||||||
|
all_tracks: Vec<Track>,
|
||||||
|
groups: Vec<ArtistView>,
|
||||||
|
mode: SortMode,
|
||||||
|
filter: String,
|
||||||
|
) -> Self {
|
||||||
|
let right_selected = first_track_row_in(groups.first());
|
||||||
|
|
||||||
|
let mut left_state = ListState::default();
|
||||||
|
let mut right_state = ListState::default();
|
||||||
|
if !groups.is_empty() {
|
||||||
|
left_state.select(Some(0));
|
||||||
|
}
|
||||||
|
right_state.select(Some(right_selected));
|
||||||
|
|
||||||
|
Library {
|
||||||
|
all_tracks,
|
||||||
|
artists: groups,
|
||||||
|
artist_selected: 0,
|
||||||
|
right_selected,
|
||||||
|
sort_mode: mode,
|
||||||
|
filter,
|
||||||
|
left_state,
|
||||||
|
right_state,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +248,6 @@ impl Library {
|
|||||||
self.artist_selected = new as usize;
|
self.artist_selected = new as usize;
|
||||||
self.right_selected = self.first_track_row();
|
self.right_selected = self.first_track_row();
|
||||||
|
|
||||||
// Синхронизируем ListState — иначе ratatui не знает о смене позиции
|
|
||||||
self.left_state.select(Some(self.artist_selected));
|
self.left_state.select(Some(self.artist_selected));
|
||||||
self.right_state.select(Some(self.right_selected));
|
self.right_state.select(Some(self.right_selected));
|
||||||
}
|
}
|
||||||
@@ -255,8 +311,8 @@ impl Library {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn first_track_row_in(artist: Option<&ArtistView>) -> usize {
|
fn first_track_row_in(group: Option<&ArtistView>) -> usize {
|
||||||
artist
|
group
|
||||||
.and_then(|a| {
|
.and_then(|a| {
|
||||||
a.rows
|
a.rows
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
55
src/main.rs
55
src/main.rs
@@ -7,7 +7,7 @@ mod ui;
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
|
|
||||||
use crate::app::{App, InputMode};
|
use crate::app::{App, InputMode};
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
app.maybe_advance();
|
app.maybe_advance();
|
||||||
|
app.tick_status(); // ← автосброс статуса через 2 секунды
|
||||||
app.player.spectrum.recompute();
|
app.player.spectrum.recompute();
|
||||||
terminal.draw(|f| ui::draw(f, &mut app))?;
|
terminal.draw(|f| ui::draw(f, &mut app))?;
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if key.kind == KeyEventKind::Release {
|
if key.kind == KeyEventKind::Release {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if handle_key(key.code, &mut app) {
|
if handle_key(key, &mut app) {
|
||||||
app.on_exit();
|
app.on_exit();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -47,10 +48,48 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_key(code: KeyCode, app: &mut App) -> bool {
|
fn handle_key(key: KeyEvent, app: &mut App) -> bool {
|
||||||
// Режим поиска
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
|
||||||
|
if key.code == KeyCode::F(1) {
|
||||||
|
app.toggle_help();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.show_help {
|
||||||
|
if key.code == KeyCode::Esc {
|
||||||
|
app.toggle_help();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctrl {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Right => {
|
||||||
|
app.next_track();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
app.prev_track();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => match c.to_ascii_lowercase() {
|
||||||
|
'd' => {
|
||||||
|
app.next_track();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
'a' => {
|
||||||
|
app.prev_track();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if app.input_mode == InputMode::Search {
|
if app.input_mode == InputMode::Search {
|
||||||
match code {
|
match key.code {
|
||||||
KeyCode::Esc => app.cancel_search(),
|
KeyCode::Esc => app.cancel_search(),
|
||||||
KeyCode::Enter => app.commit_search(),
|
KeyCode::Enter => app.commit_search(),
|
||||||
KeyCode::Backspace => app.search_pop(),
|
KeyCode::Backspace => app.search_pop(),
|
||||||
@@ -60,8 +99,7 @@ fn handle_key(code: KeyCode, app: &mut App) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обычный режим
|
match key.code {
|
||||||
match code {
|
|
||||||
KeyCode::Char('+') | KeyCode::Char('=') => {
|
KeyCode::Char('+') | KeyCode::Char('=') => {
|
||||||
app.volume_up();
|
app.volume_up();
|
||||||
return false;
|
return false;
|
||||||
@@ -101,7 +139,7 @@ fn handle_key(code: KeyCode, app: &mut App) -> bool {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let KeyCode::Char(c) = code {
|
if let KeyCode::Char(c) = key.code {
|
||||||
match c {
|
match c {
|
||||||
'1'..='9' => {
|
'1'..='9' => {
|
||||||
let pct = (c as u8 - b'0') * 10;
|
let pct = (c as u8 - b'0') * 10;
|
||||||
@@ -117,7 +155,6 @@ fn handle_key(code: KeyCode, app: &mut App) -> bool {
|
|||||||
't' => app.toggle_sort(),
|
't' => app.toggle_sort(),
|
||||||
'f' => app.jump_to_playing(),
|
'f' => app.jump_to_playing(),
|
||||||
'g' => {
|
'g' => {
|
||||||
// 'g' (строчная) — в начало, 'G' (заглавная) — в конец
|
|
||||||
if c == 'G' {
|
if c == 'G' {
|
||||||
app.goto_end();
|
app.goto_end();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
46
src/state.rs
46
src/state.rs
@@ -3,13 +3,18 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::library::SortMode;
|
||||||
|
use crate::track::Track;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default)]
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
pub struct SavedState {
|
pub struct SavedState {
|
||||||
pub last_path: Option<String>,
|
pub last_path: Option<String>,
|
||||||
pub volume: Option<f32>,
|
pub volume: Option<f32>,
|
||||||
pub sort_by_filename: Option<bool>,
|
pub sort_mode: Option<SortMode>,
|
||||||
pub repeat: Option<RepeatMode>,
|
pub repeat: Option<RepeatMode>,
|
||||||
pub shuffle: Option<bool>,
|
pub shuffle: Option<bool>,
|
||||||
|
pub last_playing_path: Option<String>,
|
||||||
|
pub last_playing_pos_secs: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)]
|
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)]
|
||||||
@@ -24,6 +29,10 @@ fn config_path() -> Option<PathBuf> {
|
|||||||
dirs::config_dir().map(|p| p.join("rumus").join("state.toml"))
|
dirs::config_dir().map(|p| p.join("rumus").join("state.toml"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cache_path() -> Option<PathBuf> {
|
||||||
|
dirs::data_dir().map(|p| p.join("rumus").join("library.bin"))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load() -> SavedState {
|
pub fn load() -> SavedState {
|
||||||
let Some(path) = config_path() else {
|
let Some(path) = config_path() else {
|
||||||
return SavedState::default();
|
return SavedState::default();
|
||||||
@@ -45,3 +54,38 @@ pub fn save(state: &SavedState) {
|
|||||||
let _ = fs::write(path, text);
|
let _ = fs::write(path, text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Кэш плейлиста (bincode) ────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct PlaylistCache {
|
||||||
|
pub source_dir: String,
|
||||||
|
pub tracks: Vec<Track>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_cache(expected_dir: &str) -> Option<Vec<Track>> {
|
||||||
|
let path = cache_path()?;
|
||||||
|
let bytes = fs::read(&path).ok()?;
|
||||||
|
let cache: PlaylistCache = bincode::deserialize(&bytes).ok()?;
|
||||||
|
if cache.source_dir == expected_dir {
|
||||||
|
Some(cache.tracks)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_cache(source_dir: &str, tracks: &[Track]) {
|
||||||
|
let Some(path) = cache_path() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let cache = PlaylistCache {
|
||||||
|
source_dir: source_dir.to_string(),
|
||||||
|
tracks: tracks.to_vec(),
|
||||||
|
};
|
||||||
|
if let Ok(bytes) = bincode::serialize(&cache) {
|
||||||
|
let _ = fs::write(path, bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use lofty::file::{AudioFile, TaggedFileExt};
|
use lofty::file::{AudioFile, TaggedFileExt};
|
||||||
use lofty::tag::Accessor;
|
use lofty::tag::Accessor;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct Track {
|
pub struct Track {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
@@ -69,7 +70,6 @@ impl Track {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Собирает все аудиофайлы в директории (без сортировки)
|
|
||||||
pub fn scan_dir(dir: &str) -> Vec<Track> {
|
pub fn scan_dir(dir: &str) -> Vec<Track> {
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
let mut tracks = Vec::new();
|
let mut tracks = Vec::new();
|
||||||
|
|||||||
119
src/ui.rs
119
src/ui.rs
@@ -1,6 +1,6 @@
|
|||||||
use ratatui::prelude::*;
|
use ratatui::prelude::*;
|
||||||
use ratatui::widgets::{
|
use ratatui::widgets::{
|
||||||
Bar, BarChart, BarGroup, Block, Borders, List, ListItem, Paragraph,
|
Bar, BarChart, BarGroup, Block, Borders, Clear, List, ListItem, Paragraph,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::app::{App, InputMode, Panel};
|
use crate::app::{App, InputMode, Panel};
|
||||||
@@ -26,9 +26,15 @@ pub fn draw(f: &mut Frame, app: &mut App) {
|
|||||||
draw_right_panel(f, app, top[1]);
|
draw_right_panel(f, app, top[1]);
|
||||||
draw_equalizer(f, app, chunks[1]);
|
draw_equalizer(f, app, chunks[1]);
|
||||||
draw_status(f, app, chunks[2]);
|
draw_status(f, app, chunks[2]);
|
||||||
|
|
||||||
|
if app.show_help {
|
||||||
|
draw_help(f, f.area());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_left_panel(f: &mut Frame, app: &mut App, area: Rect) {
|
fn draw_left_panel(f: &mut Frame, app: &mut App, area: Rect) {
|
||||||
|
f.render_widget(Clear, area);
|
||||||
|
|
||||||
let items: Vec<ListItem> = app
|
let items: Vec<ListItem> = app
|
||||||
.library
|
.library
|
||||||
.artists
|
.artists
|
||||||
@@ -49,8 +55,14 @@ fn draw_left_panel(f: &mut Frame, app: &mut App, area: Rect) {
|
|||||||
};
|
};
|
||||||
app.library.left_state.select(sel);
|
app.library.left_state.select(sel);
|
||||||
|
|
||||||
|
let title = match app.library.sort_mode {
|
||||||
|
SortMode::ByArtist => " Исполнители ",
|
||||||
|
SortMode::ByAlbum => " Альбомы ",
|
||||||
|
SortMode::ByFilename => " Источник ",
|
||||||
|
};
|
||||||
|
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
.block(Block::default().borders(Borders::ALL).title(" Исполнители "))
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
.highlight_style(highlight)
|
.highlight_style(highlight)
|
||||||
.highlight_symbol("> ");
|
.highlight_symbol("> ");
|
||||||
|
|
||||||
@@ -66,6 +78,8 @@ fn fmt_dur(d: std::time::Duration) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
|
fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
|
||||||
|
f.render_widget(Clear, area);
|
||||||
|
|
||||||
let rows = app.library.current_right_rows();
|
let rows = app.library.current_right_rows();
|
||||||
|
|
||||||
let items: Vec<ListItem> = rows
|
let items: Vec<ListItem> = rows
|
||||||
@@ -137,20 +151,31 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let selected_name = app
|
||||||
|
.library
|
||||||
|
.artists
|
||||||
|
.get(app.library.artist_selected)
|
||||||
|
.map(|a| a.name.as_str())
|
||||||
|
.unwrap_or("—");
|
||||||
|
|
||||||
let title = match app.library.sort_mode {
|
let title = match app.library.sort_mode {
|
||||||
SortMode::ByArtist => {
|
SortMode::ByArtist => {
|
||||||
let artist_name = app
|
|
||||||
.library
|
|
||||||
.artists
|
|
||||||
.get(app.library.artist_selected)
|
|
||||||
.map(|a| a.name.as_str())
|
|
||||||
.unwrap_or("—");
|
|
||||||
if app.library.filter.is_empty() {
|
if app.library.filter.is_empty() {
|
||||||
format!(" Альбомы и треки — {} ", artist_name)
|
format!(" Альбомы и треки — {} ", selected_name)
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
" Альбомы и треки — {} [{}] ",
|
" Альбомы и треки — {} [{}] ",
|
||||||
artist_name, app.library.filter
|
selected_name, app.library.filter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SortMode::ByAlbum => {
|
||||||
|
if app.library.filter.is_empty() {
|
||||||
|
format!(" Исполнители и треки — {} ", selected_name)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
" Исполнители и треки — {} [{}] ",
|
||||||
|
selected_name, app.library.filter
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,8 +260,6 @@ fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw_status(f: &mut Frame, app: &App, area: Rect) {
|
fn draw_status(f: &mut Frame, app: &App, area: Rect) {
|
||||||
// Раскладка нижней строки:
|
|
||||||
// [ Статус ................. ] [ 01:23 / 03:45 ] [ Mode ] [ vol: NN% ]
|
|
||||||
let inner = Layout::default()
|
let inner = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([
|
.constraints([
|
||||||
@@ -247,7 +270,6 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
// ── Статус / поиск / пауза ────────────────────────────────
|
|
||||||
let (status_text, status_title) = if app.input_mode == InputMode::Search {
|
let (status_text, status_title) = if app.input_mode == InputMode::Search {
|
||||||
(format!("> {}", app.search_buffer), " Поиск ")
|
(format!("> {}", app.search_buffer), " Поиск ")
|
||||||
} else {
|
} else {
|
||||||
@@ -259,7 +281,6 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
.block(Block::default().borders(Borders::ALL).title(status_title));
|
.block(Block::default().borders(Borders::ALL).title(status_title));
|
||||||
f.render_widget(status, inner[0]);
|
f.render_widget(status, inner[0]);
|
||||||
|
|
||||||
// ── Время: текущее / полное ───────────────────────────────
|
|
||||||
let (pos, total) = app.progress();
|
let (pos, total) = app.progress();
|
||||||
let time_text = format!(
|
let time_text = format!(
|
||||||
"{:02}:{:02} / {:02}:{:02}",
|
"{:02}:{:02} / {:02}:{:02}",
|
||||||
@@ -270,11 +291,9 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
);
|
);
|
||||||
let time = Paragraph::new(time_text)
|
let time = Paragraph::new(time_text)
|
||||||
.alignment(Alignment::Center)
|
.alignment(Alignment::Center)
|
||||||
.style(Style::default().fg(Color::Rgb(70, 130, 180)))
|
|
||||||
.block(Block::default().borders(Borders::ALL).title(" Время "));
|
.block(Block::default().borders(Borders::ALL).title(" Время "));
|
||||||
f.render_widget(time, inner[1]);
|
f.render_widget(time, inner[1]);
|
||||||
|
|
||||||
// ── Repeat / Shuffle ──────────────────────────────────────
|
|
||||||
let mode_str = match (app.repeat, app.shuffle) {
|
let mode_str = match (app.repeat, app.shuffle) {
|
||||||
(RepeatMode::Off, false) => "".to_string(),
|
(RepeatMode::Off, false) => "".to_string(),
|
||||||
(RepeatMode::Off, true) => "SHUF".to_string(),
|
(RepeatMode::Off, true) => "SHUF".to_string(),
|
||||||
@@ -288,10 +307,74 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
.block(Block::default().borders(Borders::ALL).title(" Mode "));
|
.block(Block::default().borders(Borders::ALL).title(" Mode "));
|
||||||
f.render_widget(mode, inner[2]);
|
f.render_widget(mode, inner[2]);
|
||||||
|
|
||||||
// ── Громкость ─────────────────────────────────────────────
|
|
||||||
let vol_text = format!("vol: {}%", (app.player.volume() * 100.0) as u32);
|
let vol_text = format!("vol: {}%", (app.player.volume() * 100.0) as u32);
|
||||||
let vol = Paragraph::new(vol_text)
|
let vol = Paragraph::new(vol_text)
|
||||||
.alignment(Alignment::Center)
|
.alignment(Alignment::Center)
|
||||||
.block(Block::default().borders(Borders::ALL).title(" Звук "));
|
.block(Block::default().borders(Borders::ALL).title(" "));
|
||||||
f.render_widget(vol, inner[3]);
|
f.render_widget(vol, inner[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_help(f: &mut Frame, screen: Rect) {
|
||||||
|
let width = 60u16.min(screen.width.saturating_sub(4));
|
||||||
|
let height = 34u16.min(screen.height.saturating_sub(4));
|
||||||
|
|
||||||
|
let x = (screen.width.saturating_sub(width)) / 2;
|
||||||
|
let y = (screen.height.saturating_sub(height)) / 2;
|
||||||
|
let area = Rect::new(x, y, width, height);
|
||||||
|
|
||||||
|
f.render_widget(Clear, area);
|
||||||
|
|
||||||
|
let header = Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let title_color = Style::default().fg(Color::Rgb(100, 149, 237));
|
||||||
|
|
||||||
|
let help_text = vec![
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" Управление плеером ",
|
||||||
|
title_color.add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(" Навигация", header)),
|
||||||
|
Line::from(" w / ↑ вверх по списку"),
|
||||||
|
Line::from(" s / ↓ вниз по списку"),
|
||||||
|
Line::from(" Tab переключить панель"),
|
||||||
|
Line::from(" g / G в начало / в конец списка"),
|
||||||
|
Line::from(" f к играющему треку"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(" Воспроизведение", header)),
|
||||||
|
Line::from(" Enter играть выделенный трек"),
|
||||||
|
Line::from(" Space пауза / продолжить"),
|
||||||
|
Line::from(" z стоп"),
|
||||||
|
Line::from(" Ctrl+→ / Ctrl+d следующий трек"),
|
||||||
|
Line::from(" Ctrl+← / Ctrl+a предыдущий трек"),
|
||||||
|
Line::from(" r repeat: off → all → one"),
|
||||||
|
Line::from(" x shuffle вкл/выкл"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(" Перемотка", header)),
|
||||||
|
Line::from(" a / ← −5 секунд"),
|
||||||
|
Line::from(" d / → +5 секунд"),
|
||||||
|
Line::from(" 1..9 перейти к 10%..90% трека"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(" Звук и прочее", header)),
|
||||||
|
Line::from(" + / - громкость"),
|
||||||
|
Line::from(" / поиск"),
|
||||||
|
Line::from(" t сортировка: артист → альбом → файл"),
|
||||||
|
Line::from(" F1 эта справка"),
|
||||||
|
Line::from(" q / Esc выход"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" F1 или Esc — закрыть ",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let help = Paragraph::new(help_text).block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(title_color)
|
||||||
|
.title(" Помощь "),
|
||||||
|
);
|
||||||
|
|
||||||
|
f.render_widget(help, area);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user