Compare commits

...

12 Commits

10 changed files with 1276 additions and 272 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/target /target
/.idea

13
Cargo.lock generated
View File

@@ -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"
@@ -972,10 +981,12 @@ dependencies = [
[[package]] [[package]]
name = "rumus" name = "rumus"
version = "0.1.0" version = "0.5.0"
dependencies = [ dependencies = [
"bincode",
"crossterm", "crossterm",
"dirs", "dirs",
"encoding_rs",
"lofty", "lofty",
"ratatui", "ratatui",
"rodio", "rodio",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "rumus" name = "rumus"
version = "0.1.0" version = "0.5.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@@ -13,3 +13,5 @@ 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"
encoding_rs = "0.8"

View File

@@ -1,9 +1,11 @@
use std::time::Duration; use std::collections::HashSet;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use crate::audio::AudioPlayer; use crate::audio::AudioPlayer;
use crate::library::{Library, SortMode}; use crate::library::{Library, RightRow, SortMode};
use crate::state::{RepeatMode, SavedState}; use crate::state::{RepeatMode, SavedState};
use crate::track::scan_dir; use crate::track::{scan_source, Track};
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq)]
pub enum Panel { pub enum Panel {
@@ -15,6 +17,7 @@ pub enum Panel {
pub enum InputMode { pub enum InputMode {
Normal, Normal,
Search, Search,
Command,
} }
pub struct App { pub struct App {
@@ -25,35 +28,69 @@ 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,
/// Исходный (неотфильтрованный) список треков — для пересборки библиотеки pub command_buffer: String,
raw_tracks: Vec<crate::track::Track>, pub request_quit: bool,
/// Директория с музыкой — чтобы сохранить в state raw_tracks: Vec<Track>,
music_dir: String, default_dir: String,
/// Единый список источников: папки и плейлисты.
sources: Vec<String>,
deleted_paths: HashSet<PathBuf>,
pub repeat: RepeatMode, pub repeat: RepeatMode,
pub shuffle: bool, pub shuffle: bool,
pub show_help: bool,
pub show_libs: 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 saved = crate::state::load();
let library = Library::build(tracks.clone(), SortMode::ByArtist);
let mut app = Self { let mut app = Self {
library, library: Library::build(Vec::new(), SortMode::ByArtist),
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,
search_buffer: String::new(), search_buffer: String::new(),
raw_tracks: tracks, command_buffer: String::new(),
music_dir: music_dir.to_string(), request_quit: false,
raw_tracks: Vec::new(),
default_dir: music_dir.to_string(),
sources: saved.sources.clone(),
deleted_paths: HashSet::new(),
repeat: RepeatMode::Off, repeat: RepeatMode::Off,
shuffle: false, shuffle: false,
show_help: false,
show_libs: false,
status_set_at: Instant::now(),
}; };
// Восстанавливаем сохранённое состояние // Если sources пуст — первый запуск, добавляем базовую директорию
let saved = crate::state::load(); if app.sources.is_empty() {
app.sources.push(music_dir.to_string());
}
// Пробуем загрузить кэш
let cached = crate::state::load_cache();
app.raw_tracks = match cached {
Some(c) if !c.is_empty() => c,
_ => {
let mut tracks = Vec::new();
for src in &app.sources {
let path = PathBuf::from(src);
tracks.extend(scan_source(&path));
}
crate::state::save_cache(&tracks);
tracks
}
};
app.rebuild_library();
if let Some(vol) = saved.volume { if let Some(vol) = saved.volume {
app.player.set_volume(vol); app.player.set_volume(vol);
} }
@@ -63,20 +100,472 @@ 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::Normal {
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 start_command(&mut self) {
self.input_mode = InputMode::Command;
self.command_buffer.clear();
self.status_set_at = Instant::now();
}
pub fn cancel_command(&mut self) {
self.input_mode = InputMode::Normal;
self.command_buffer.clear();
self.set_status("Команда отменена".into());
}
pub fn command_push(&mut self, c: char) {
self.command_buffer.push(c);
self.status_set_at = Instant::now();
}
pub fn command_pop(&mut self) {
self.command_buffer.pop();
self.status_set_at = Instant::now();
}
pub fn commit_command(&mut self) {
let cmd = self.command_buffer.trim().to_string();
self.input_mode = InputMode::Normal;
self.command_buffer.clear();
if cmd.is_empty() {
return;
}
let mut parts = cmd.splitn(2, char::is_whitespace);
let name = parts.next().unwrap_or("");
let args = parts.next().unwrap_or("").trim();
match name {
"add" => self.add_source(args),
"load" => self.load_source(args),
"reload" => self.reload_library(),
"save" => self.save_now(),
"savepl" => self.save_playlist(args),
"libs" => {
self.show_libs = true;
self.set_status("Источники библиотеки".into());
}
"rm" => self.remove_source(args),
"clear" => self.clear_sources(),
"help" => {
self.show_help = true;
self.set_status("Справка".into());
}
"q" | "quit" => {
self.request_quit = true;
}
_ => {
self.set_status(format!("Неизвестная команда: {}", name));
}
}
}
fn add_source(&mut self, raw_path: &str) {
if raw_path.is_empty() {
self.set_status("Использование: :add <путь>".into());
return;
}
let expanded = expand_tilde(raw_path);
let path = PathBuf::from(&expanded);
if !path.exists() {
self.set_status(format!("Путь не существует: {}", expanded));
return;
}
self.set_status(format!("Сканирование {}...", expanded));
let new_tracks = scan_source(&path);
if new_tracks.is_empty() {
self.set_status(format!("Нет треков в {}", expanded));
return;
}
let existing: HashSet<PathBuf> = self.raw_tracks.iter().map(|t| t.path.clone()).collect();
let mut added = 0;
for track in new_tracks {
if !existing.contains(&track.path) && !self.deleted_paths.contains(&track.path) {
self.raw_tracks.push(track);
added += 1;
}
}
if !self.sources.contains(&expanded) {
self.sources.push(expanded.clone());
}
self.rebuild_library();
crate::state::save_cache(&self.raw_tracks);
self.set_status(format!("Добавлено {} треков из {}", added, expanded));
}
fn load_source(&mut self, raw_path: &str) {
if raw_path.is_empty() {
self.set_status("Использование: :load <путь>".into());
return;
}
let expanded = expand_tilde(raw_path);
let path = PathBuf::from(&expanded);
if !path.exists() {
self.set_status(format!("Путь не существует: {}", expanded));
return;
}
self.set_status(format!("Загрузка {}...", expanded));
let new_tracks = scan_source(&path);
if new_tracks.is_empty() {
self.set_status(format!("Нет треков в {}", expanded));
return;
}
// Очищаем текущую сессию
self.raw_tracks = new_tracks;
self.sources = vec![expanded.clone()];
self.deleted_paths.clear();
self.rebuild_library();
crate::state::save_cache(&self.raw_tracks);
self.set_status(format!("Загружено {} треков из {}", self.raw_tracks.len(), expanded));
}
fn remove_source(&mut self, arg: &str) {
let idx: usize = match arg.parse() {
Ok(i) => i,
Err(_) => {
self.set_status("Использование: :rm <индекс>".into());
return;
}
};
if idx >= self.sources.len() {
self.set_status(format!("Нет источника с индексом {}", idx));
return;
}
let removed = self.sources.remove(idx);
self.set_status(format!("Источник удалён: {}", removed));
self.reload_library();
}
fn clear_sources(&mut self) {
if self.sources.is_empty() {
self.set_status("Список источников уже пуст".into());
return;
}
self.sources.clear();
self.raw_tracks.clear();
self.deleted_paths.clear();
self.playing_index = None;
self.player.stop();
self.rebuild_library();
crate::state::save_cache(&self.raw_tracks);
self.set_status("Все источники удалены".into());
}
fn rebuild_from_sources(&mut self) {
let mut all = Vec::new();
for src in &self.sources {
let path = PathBuf::from(expand_tilde(src));
all.extend(scan_source(&path));
}
let mut seen = HashSet::new();
all.retain(|t| seen.insert(t.path.clone()));
self.raw_tracks = all;
self.deleted_paths.clear();
crate::state::save_cache(&self.raw_tracks);
self.rebuild_library();
}
pub fn reload_library(&mut self) {
self.rebuild_from_sources();
self.set_status(format!("Пересканировано {} треков", self.raw_tracks.len()));
}
pub fn save_now(&mut self) {
self.persist();
let msg = format!("Сохранено {} треков", self.raw_tracks.len());
self.set_status(msg);
}
fn save_playlist(&mut self, raw_path: &str) {
if raw_path.is_empty() {
self.set_status("Использование: :savepl <файл.m3u|.pls>".into());
return;
}
let path = PathBuf::from(expand_tilde(raw_path));
let is_pls = path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.eq_ignore_ascii_case("pls"))
.unwrap_or(false);
let tracks: Vec<Track> = self
.raw_tracks
.iter()
.filter(|t| !self.deleted_paths.contains(&t.path))
.cloned()
.collect();
let res = if is_pls {
crate::track::save_playlist_pls(&path, &tracks)
} else {
crate::track::save_playlist_m3u(&path, &tracks)
};
match res {
Ok(_) => self.set_status(format!("Плейлист сохранён: {}", path.display())),
Err(e) => self.set_status(format!("Ошибка сохранения: {}", e)),
}
}
// ── Удаление из списка (клавиша Del) ───────────────────────
pub fn delete_selected(&mut self) {
match self.active_panel {
Panel::Left => self.delete_current_left_group(),
Panel::Right => self.delete_current_right_item(),
}
self.rebuild_library();
}
fn delete_current_left_group(&mut self) {
let Some(group) = self.library.artists.get(self.library.artist_selected) else {
return;
};
let group_name = group.name.clone();
let to_remove: Vec<PathBuf> = group
.rows
.iter()
.filter_map(|r| match r {
RightRow::Track { index_in_library } => {
self.library.track(*index_in_library).map(|t| t.path.clone())
}
_ => None,
})
.collect();
let count = to_remove.len();
for p in &to_remove {
self.deleted_paths.insert(p.clone());
}
self.raw_tracks
.retain(|t| !self.deleted_paths.contains(&t.path));
if let Some(idx) = self.playing_index {
if let Some(t) = self.library.track(idx) {
if to_remove.contains(&t.path) {
self.player.stop();
self.playing_index = None;
}
}
}
self.set_status(format!("Удалено: {} ({} треков)", group_name, count));
}
fn delete_current_right_item(&mut self) {
let rows = self.library.current_right_rows();
let Some(row) = rows.get(self.library.right_selected).cloned() else {
return;
};
match row {
RightRow::Track { index_in_library } => {
if let Some(track) = self.library.track(index_in_library).cloned() {
let name = format!("{} — {}", track.artist, track.title);
self.deleted_paths.insert(track.path.clone());
self.raw_tracks.retain(|t| t.path != track.path);
if Some(index_in_library) == self.playing_index {
self.player.stop();
self.playing_index = None;
}
self.set_status(format!("Удалено: {}", name));
}
}
RightRow::AlbumHeader { name } => {
let start = self.library.right_selected + 1;
let mut to_remove: Vec<PathBuf> = Vec::new();
for r in rows.iter().skip(start) {
match r {
RightRow::Track { index_in_library } => {
if let Some(t) = self.library.track(*index_in_library) {
to_remove.push(t.path.clone());
}
}
RightRow::AlbumHeader { .. } => break,
}
}
let count = to_remove.len();
for p in &to_remove {
self.deleted_paths.insert(p.clone());
}
self.raw_tracks
.retain(|t| !self.deleted_paths.contains(&t.path));
if let Some(idx) = self.playing_index {
if let Some(t) = self.library.track(idx) {
if to_remove.contains(&t.path) {
self.player.stop();
self.playing_index = None;
}
}
}
self.set_status(format!("Удалено: {} ({} треков)", name, count));
}
}
}
// ── Переключение треков ────────────────────────────────────
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) {
crate::state::save_cache(&self.raw_tracks);
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.default_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,
sources: self.sources.clone(),
}; };
crate::state::save(&state); crate::state::save(&state);
} }
@@ -90,44 +579,47 @@ impl App {
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) {
let mode = self.library.sort_mode; let mode = self.library.sort_mode;
self.library = Library::build_with_filter( let filtered: Vec<Track> = self
self.raw_tracks.clone(), .raw_tracks
mode, .iter()
self.search_buffer.clone(), .filter(|t| !self.deleted_paths.contains(&t.path))
); .cloned()
.collect();
self.library = Library::build_with_filter(filtered, mode, self.search_buffer.clone());
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);
} }
@@ -154,27 +646,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,6 +678,9 @@ 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(),
@@ -196,12 +693,12 @@ impl App {
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 +706,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 +738,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 +750,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 +768,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 +812,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 +837,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 +865,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,
}; };
@@ -378,8 +875,15 @@ 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 filtered: Vec<Track> = self
.raw_tracks
.iter()
.filter(|t| !self.deleted_paths.contains(&t.path))
.cloned()
.collect();
self.library = Library::build_with_filter( self.library = Library::build_with_filter(
self.raw_tracks.clone(), filtered,
new_mode, new_mode,
self.search_buffer.clone(), self.search_buffer.clone(),
); );
@@ -391,9 +895,31 @@ 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);
}
// ── Геттеры для UI ─────────────────────────────────────────
pub fn sources(&self) -> &[String] {
&self.sources
} }
} }
fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest).to_string_lossy().to_string();
}
}
if path == "~" {
if let Some(home) = dirs::home_dir() {
return home.to_string_lossy().to_string();
}
}
path.to_string()
}

View File

@@ -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(())
} }

View File

@@ -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,8 @@ 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.right_state = ListState::default();
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));
} }
@@ -219,6 +277,7 @@ impl Library {
new_pos = len - 1; new_pos = len - 1;
} else if new_pos >= len { } else if new_pos >= len {
new_pos = 0; new_pos = 0;
self.right_state = ListState::default();
} }
self.right_selected = track_rows[new_pos as usize]; self.right_selected = track_rows[new_pos as usize];
@@ -255,8 +314,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()

View File

@@ -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};
@@ -26,7 +26,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut app = App::new(&music_dir)?; let mut app = App::new(&music_dir)?;
loop { loop {
if app.request_quit {
app.on_exit();
break;
}
app.maybe_advance(); app.maybe_advance();
app.tick_status();
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 +41,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 +53,66 @@ 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 app.show_libs {
if key.code == KeyCode::Esc {
app.show_libs = false;
}
return false;
}
if app.input_mode == InputMode::Command {
match key.code {
KeyCode::Esc => app.cancel_command(),
KeyCode::Enter => app.commit_command(),
KeyCode::Backspace => app.command_pop(),
KeyCode::Char(c) => app.command_push(c),
_ => {}
}
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 +122,16 @@ fn handle_key(code: KeyCode, app: &mut App) -> bool {
return false; return false;
} }
// Обычный режим if key.code == KeyCode::Delete {
match code { app.delete_selected();
return false;
}
match key.code {
KeyCode::Char(':') => {
app.start_command();
return false;
}
KeyCode::Char('+') | KeyCode::Char('=') => { KeyCode::Char('+') | KeyCode::Char('=') => {
app.volume_up(); app.volume_up();
return false; return false;
@@ -101,7 +171,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 +187,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 {

View File

@@ -3,13 +3,21 @@ 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>,
/// Единый список источников: папки и плейлисты.
#[serde(default)]
pub sources: Vec<String>,
} }
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)] #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)]
@@ -20,10 +28,14 @@ pub enum RepeatMode {
One, One,
} }
fn config_path() -> Option<PathBuf> { pub 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 +57,21 @@ pub fn save(state: &SavedState) {
let _ = fs::write(path, text); let _ = fs::write(path, text);
} }
} }
pub fn load_cache() -> Option<Vec<Track>> {
let path = cache_path()?;
let bytes = fs::read(&path).ok()?;
bincode::deserialize(&bytes).ok()
}
pub fn save_cache(tracks: &[Track]) {
let Some(path) = cache_path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Ok(bytes) = bincode::serialize(&tracks.to_vec()) {
let _ = fs::write(path, bytes);
}
}

View File

@@ -1,10 +1,11 @@
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::time::Duration; 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();
@@ -92,3 +92,111 @@ pub fn scan_dir(dir: &str) -> Vec<Track> {
} }
tracks tracks
} }
/// Универсальный сканер источника: папка или файл плейлиста.
pub fn scan_source(path: &Path) -> Vec<Track> {
if path.is_dir() {
scan_dir(&path.to_string_lossy())
} else if path.is_file() {
read_playlist(path)
.into_iter()
.map(|p| Track::from_path(&p))
.collect()
} else {
Vec::new()
}
}
// ── Плейлисты (M3U / M3U8 / PLS) ───────────────────────────────
pub fn read_playlist(path: &Path) -> Vec<PathBuf> {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(_) => return Vec::new(),
};
let content = match std::str::from_utf8(&bytes) {
Ok(s) => s.to_string(),
Err(_) => {
let (decoded, _, _) = encoding_rs::WINDOWS_1251.decode(&bytes);
decoded.into_owned()
}
};
let content = content.trim_start_matches('\u{FEFF}');
let parent = path.parent().unwrap_or(Path::new(""));
let is_pls = path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.eq_ignore_ascii_case("pls"))
.unwrap_or(false);
if is_pls {
parse_pls(content, parent)
} else {
parse_m3u(content, parent)
}
}
fn parse_m3u(content: &str, parent: &Path) -> Vec<PathBuf> {
content
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| resolve_path(l, parent))
.filter(|p| p.exists())
.collect()
}
fn parse_pls(content: &str, parent: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.to_lowercase().starts_with("file") {
if let Some((_, val)) = line.split_once('=') {
let p = resolve_path(val.trim(), parent);
if p.exists() {
files.push(p);
}
}
}
}
files
}
fn resolve_path(s: &str, parent: &Path) -> PathBuf {
let p = PathBuf::from(s);
if p.is_relative() {
parent.join(p)
} else {
p
}
}
pub fn save_playlist_m3u(path: &Path, tracks: &[Track]) -> std::io::Result<()> {
let mut out = String::from("#EXTM3U\n");
for t in tracks {
let dur = t.duration.as_secs();
out.push_str(&format!(
"#EXTINF:{},{} - {}\n{}\n",
dur,
t.artist,
t.title,
t.path.display()
));
}
std::fs::write(path, out)
}
pub fn save_playlist_pls(path: &Path, tracks: &[Track]) -> std::io::Result<()> {
let mut out = String::from("[playlist]\n");
for (i, t) in tracks.iter().enumerate() {
let num = i + 1;
out.push_str(&format!("File{}={}\n", num, t.path.display()));
out.push_str(&format!("Title{}={} - {}\n", num, t.artist, t.title));
out.push_str(&format!("Length{}={}\n", num, t.duration.as_secs()));
}
out.push_str(&format!("NumberOfEntries={}\nVersion=2\n", tracks.len()));
std::fs::write(path, out)
}

351
src/ui.rs
View File

@@ -1,6 +1,6 @@
use ratatui::prelude::*; use ratatui::prelude::*;
use ratatui::widgets::{ use ratatui::widgets::{
Bar, BarChart, BarGroup, Block, Borders, Gauge, List, ListItem, Paragraph, Bar, BarChart, BarGroup, Block, Borders, Clear, List, ListItem, Paragraph, Wrap,
}; };
use crate::app::{App, InputMode, Panel}; use crate::app::{App, InputMode, Panel};
@@ -12,9 +12,7 @@ pub fn draw(f: &mut Frame, app: &mut App) {
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Min(1), Constraint::Min(1),
Constraint::Length(6), // эквалайзер Constraint::Length(3),
Constraint::Length(3), // прогресс
Constraint::Length(3), // статус + vol
]) ])
.split(f.area()); .split(f.area());
@@ -23,14 +21,30 @@ pub fn draw(f: &mut Frame, app: &mut App) {
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(chunks[0]); .split(chunks[0]);
draw_left_panel(f, app, top[0]); let left_col = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1),
Constraint::Length(8),
])
.split(top[0]);
draw_left_panel(f, app, left_col[0]);
draw_equalizer(f, app, left_col[1]);
draw_right_panel(f, app, top[1]); draw_right_panel(f, app, top[1]);
draw_equalizer(f, app, chunks[1]); draw_status(f, app, chunks[1]);
draw_progress(f, app, chunks[2]);
draw_status(f, app, chunks[3]); if app.show_help {
draw_help(f, f.area());
}
if app.show_libs {
draw_dirs(f, app, 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
@@ -44,7 +58,6 @@ fn draw_left_panel(f: &mut Frame, app: &mut App, area: Rect) {
Style::default().add_modifier(Modifier::DIM) Style::default().add_modifier(Modifier::DIM)
}; };
// Обновляем selected в сохранённом состоянии (не создаём новый ListState)
let sel = if app.library.artists.is_empty() { let sel = if app.library.artists.is_empty() {
None None
} else { } else {
@@ -52,8 +65,16 @@ 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_text = match app.library.sort_mode {
SortMode::ByArtist => " Исполнители ",
SortMode::ByAlbum => " Альбомы ",
SortMode::ByFilename => " Источник ",
};
let album_color = Color::Rgb(100, 149, 237);
let title = Span::styled(title_text, Style::default().fg(album_color));
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("> ");
@@ -69,6 +90,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
@@ -92,7 +115,7 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
let inner_width = area.width.saturating_sub(6) as usize; let inner_width = area.width.saturating_sub(6) as usize;
let pad = inner_width let pad = inner_width
.saturating_sub(name.len() + total_str.len() + 3) .saturating_sub(name.len() + total_str.len() + 4)
.max(1); .max(1);
let line = format!("{} ] {} {}", name, "─".repeat(pad), total_str); let line = format!("{} ] {} {}", name, "─".repeat(pad), total_str);
@@ -129,32 +152,49 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
Style::default() Style::default()
}; };
let left = format!(" {}{}{}", marker, num, title);
let inner_width = area.width.saturating_sub(4) as usize;
let left_len = left.chars().count();
let dur_len = dur.chars().count();
let pad = inner_width
.saturating_sub(left_len + dur_len)
.max(1);
ListItem::new(Line::from(vec![ ListItem::new(Line::from(vec![
Span::raw(" "), Span::styled(left, style),
Span::raw(marker), Span::raw(" ".repeat(pad)),
Span::styled(format!("{}{}", num, title), style), Span::styled(dur, style),
Span::raw(" "),
Span::styled(dur, Style::default().fg(Color::DarkGray)),
])) ]))
} }
}) })
.collect(); .collect();
// Заголовок с индикатором активного фильтра let selected_name = app
let title = match app.library.sort_mode { .library
.artists
.get(app.library.artist_selected)
.map(|a| a.name.as_str())
.unwrap_or("—");
let title_text = 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
) )
} }
} }
@@ -166,6 +206,8 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
} }
} }
}; };
let album_color = Color::Rgb(100, 149, 237);
let title = Span::styled(title_text, Style::default().fg(album_color));
let highlight = if app.active_panel == Panel::Right { let highlight = if app.active_panel == Panel::Right {
Style::default().add_modifier(Modifier::REVERSED) Style::default().add_modifier(Modifier::REVERSED)
@@ -188,38 +230,35 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
f.render_stateful_widget(list, area, &mut app.library.right_state); f.render_stateful_widget(list, area, &mut app.library.right_state);
} }
/// Цветной эквалайзер: 32 столбика с градиентом от синего к красному
fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) { fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) {
let inner_width = area.width.saturating_sub(1) as usize;
if inner_width == 0 {
return;
}
let num_bars = (inner_width / 2).max(4);
let bars_data = app.player.spectrum.bars(); let bars_data = app.player.spectrum.bars();
let num_bars = 32; let step = (bars_data.len() / num_bars).max(1);
let mut display: Vec<f32> = Vec::with_capacity(num_bars); let mut display: Vec<f32> = Vec::with_capacity(num_bars);
for i in 0..num_bars { for i in 0..num_bars {
let a = bars_data[i * 2]; let start = i * step;
let b = bars_data[i * 2 + 1]; let end = (start + step).min(bars_data.len());
display.push((a + b) / 2.0); if start >= bars_data.len() {
display.push(0.0);
continue;
}
let sum: f32 = bars_data[start..end].iter().sum();
display.push(sum / (end - start).max(1) as f32);
} }
let bars: Vec<Bar> = display let bars: Vec<Bar> = display
.iter() .iter()
.enumerate() .map(|&v| {
.map(|(i, &v)| {
let ratio = i as f64 / (num_bars - 1).max(1) as f64;
let color = if ratio < 0.2 {
Color::Rgb(0, 100, 255)
} else if ratio < 0.4 {
Color::Rgb(0, 200, 255)
} else if ratio < 0.6 {
Color::Rgb(0, 255, 100)
} else if ratio < 0.8 {
Color::Rgb(255, 200, 0)
} else {
Color::Rgb(255, 50, 50)
};
Bar::default() Bar::default()
.value((v * 100.0) as u64) .value((v * 100.0) as u64)
.style(Style::default().fg(color)) .style(Style::default().fg(Color::Rgb(100, 149, 237)))
}) })
.collect(); .collect();
@@ -239,52 +278,53 @@ fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) {
f.render_widget(chart, area); f.render_widget(chart, area);
} }
fn draw_progress(f: &mut Frame, app: &App, area: Rect) {
let (pos, total) = app.progress();
let ratio = if total.as_secs_f64() > 0.0 {
(pos.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0)
} else {
0.0
};
let gauge = Gauge::default()
.block(Block::default().borders(Borders::ALL).title(" Прогресс "))
.gauge_style(Style::default().fg(Color::Rgb(70, 130, 180)))
.ratio(ratio)
.label(format!(
"{:02}:{:02} / {:02}:{:02}",
pos.as_secs() / 60,
pos.as_secs() % 60,
total.as_secs() / 60,
total.as_secs() % 60,
));
f.render_widget(gauge, area);
}
fn draw_status(f: &mut Frame, app: &App, area: Rect) { fn draw_status(f: &mut Frame, app: &App, area: Rect) {
let inner = Layout::default() let inner = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([ .constraints([
Constraint::Min(1), Constraint::Min(1),
Constraint::Length(18),
Constraint::Length(16), Constraint::Length(16),
Constraint::Length(12), Constraint::Length(12),
]) ])
.split(area); .split(area);
// Если поиск активен — показываем строку ввода. let (status_text, status_title) = match app.input_mode {
// Иначе — статус с индикатором паузы. InputMode::Search => (format!(">{}", app.search_buffer), " Поиск "),
let (status_text, status_title) = if app.input_mode == InputMode::Search { InputMode::Command => (format!(":{}", app.command_buffer), " Команда "),
(format!("> {}", app.search_buffer), " Поиск ") InputMode::Normal => {
} else { let prefix = if app.player.is_paused() { "|| " } else { " " };
let prefix = if app.player.is_paused() { "⏸ " } else { "" }; (format!("{}{}", prefix, app.status), " Статус ")
(format!("{}{}", prefix, app.status), " Статус ") }
}; };
let status = Paragraph::new(status_text) let status = Paragraph::new(status_text)
.block(Block::default().borders(Borders::ALL).title(status_title)); .block(
Block::default()
.borders(Borders::ALL)
.title(status_title)
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
);
f.render_widget(status, inner[0]); f.render_widget(status, inner[0]);
let (pos, total) = app.progress();
let time_text = format!(
"{:02}:{:02} / {:02}:{:02}",
pos.as_secs() / 60,
pos.as_secs() % 60,
total.as_secs() / 60,
total.as_secs() % 60,
);
let time = Paragraph::new(time_text)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Время ")
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
);
f.render_widget(time, inner[1]);
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(),
@@ -295,12 +335,157 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) {
}; };
let mode = Paragraph::new(mode_str) let mode = Paragraph::new(mode_str)
.alignment(Alignment::Center) .alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title(" Mode ")); .block(
f.render_widget(mode, inner[1]); Block::default()
.borders(Borders::ALL)
.title(" Режим ")
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
);
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(
f.render_widget(vol, inner[2]); Block::default()
.borders(Borders::ALL)
.title(" Звук ")
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
);
f.render_widget(vol, inner[3]);
}
fn draw_dirs(f: &mut Frame, app: &App, screen: Rect) {
let count = app.sources().len();
// Строки: заголовок (1) + пустая (1) + "Список" (1) + N + пустая (1) + подсказка (1)
let content_lines = 5 + count;
let desired_height = (content_lines + 2) as u16;
let width = 70u16.min(screen.width.saturating_sub(4));
let max_h = screen.height.saturating_sub(4);
let height = desired_height.min(max_h).max(5);
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 title_color = Style::default().fg(Color::Rgb(100, 149, 237));
let header = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let dim = Style::default().fg(Color::DarkGray);
let mut lines = vec![
Line::from(Span::styled(
" Источники библиотеки ",
title_color.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(" Список", header)),
];
for (i, src) in app.sources().iter().enumerate() {
lines.push(Line::from(vec![
Span::styled(format!(" [{}] ", i), dim),
Span::raw(src.clone()),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" :rm <N> — удалить, Esc — закрыть",
dim,
)));
let para = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(title_color)
.title(" Библиотека "),
)
.wrap(Wrap { trim: false });
f.render_widget(para, area);
}
fn draw_help(f: &mut Frame, screen: Rect) {
let width = 62u16.min(screen.width.saturating_sub(4));
let height = 44u16.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(" :add <путь> добавить в сессию"),
Line::from(" :load <путь> заменить сессию"),
Line::from(" :reload пересканировать всё"),
Line::from(" :save сохранить библиотеку"),
Line::from(" :savepl <файл> экспорт в M3U/PLS"),
Line::from(" :libs список источников"),
Line::from(" :rm <N> удалить источник"),
Line::from(" :clear очистить источники"),
Line::from(" :help эта справка"),
Line::from(" :q выход"),
Line::from(""),
Line::from(Span::styled(" Звук и прочее", header)),
Line::from(" + / - громкость"),
Line::from(" / поиск"),
Line::from(" Del удалить из списка"),
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);
} }