много правок и переработка библиотеки тревов
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,2 @@
|
||||
/target
|
||||
/.idea
|
||||
src.zip
|
||||
/.idea
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -986,6 +986,7 @@ dependencies = [
|
||||
"bincode",
|
||||
"crossterm",
|
||||
"dirs",
|
||||
"encoding_rs",
|
||||
"lofty",
|
||||
"ratatui",
|
||||
"rodio",
|
||||
|
||||
@@ -13,4 +13,5 @@ lofty = "0.22"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
rustfft = "6"
|
||||
bincode = "1.3"
|
||||
bincode = "1.3"
|
||||
encoding_rs = "0.8"
|
||||
221
src/app.rs
221
src/app.rs
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
|
||||
use crate::audio::AudioPlayer;
|
||||
use crate::library::{Library, RightRow, SortMode};
|
||||
use crate::state::{RepeatMode, SavedState};
|
||||
use crate::track::{scan_dir, Track};
|
||||
use crate::track::{scan_source, Track};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Panel {
|
||||
@@ -28,20 +28,16 @@ pub struct App {
|
||||
pub active_panel: Panel,
|
||||
pub input_mode: InputMode,
|
||||
pub search_buffer: String,
|
||||
/// Буфер командного режима (после ":")
|
||||
pub command_buffer: String,
|
||||
/// Флаг запроса выхода (выставляется командой :q)
|
||||
pub request_quit: bool,
|
||||
raw_tracks: Vec<Track>,
|
||||
music_dir: String,
|
||||
/// Дополнительные директории, добавленные через :add
|
||||
extra_dirs: Vec<String>,
|
||||
/// Пути, удалённые из списка (не с диска)
|
||||
default_dir: String,
|
||||
/// Единый список источников: папки и плейлисты.
|
||||
sources: Vec<String>,
|
||||
deleted_paths: HashSet<PathBuf>,
|
||||
pub repeat: RepeatMode,
|
||||
pub shuffle: bool,
|
||||
pub show_help: bool,
|
||||
/// Показывать окно управления директориями
|
||||
pub show_dirs: bool,
|
||||
status_set_at: Instant,
|
||||
}
|
||||
@@ -51,21 +47,9 @@ const STATUS_RESET_AFTER: Duration = Duration::from_secs(2);
|
||||
impl App {
|
||||
pub fn new(music_dir: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let saved = crate::state::load();
|
||||
// Загружаем кэш
|
||||
let cached_tracks = crate::state::load_cache();
|
||||
let tracks = match cached_tracks {
|
||||
Some(cached) if !cached.is_empty() => cached,
|
||||
_ => {
|
||||
let scanned = scan_dir(music_dir);
|
||||
crate::state::save_cache(&scanned);
|
||||
scanned
|
||||
}
|
||||
};
|
||||
|
||||
let library = Library::build(tracks.clone(), SortMode::ByArtist);
|
||||
|
||||
let mut app = Self {
|
||||
library,
|
||||
library: Library::build(Vec::new(), SortMode::ByArtist),
|
||||
player: AudioPlayer::new()?,
|
||||
status: "F1 - помощь, : - команда".into(),
|
||||
playing_index: None,
|
||||
@@ -74,9 +58,9 @@ impl App {
|
||||
search_buffer: String::new(),
|
||||
command_buffer: String::new(),
|
||||
request_quit: false,
|
||||
raw_tracks: tracks,
|
||||
music_dir: music_dir.to_string(),
|
||||
extra_dirs: saved.extra_dirs.clone(),
|
||||
raw_tracks: Vec::new(),
|
||||
default_dir: music_dir.to_string(),
|
||||
sources: saved.sources.clone(),
|
||||
deleted_paths: HashSet::new(),
|
||||
repeat: RepeatMode::Off,
|
||||
shuffle: false,
|
||||
@@ -84,18 +68,29 @@ impl App {
|
||||
show_dirs: false,
|
||||
status_set_at: Instant::now(),
|
||||
};
|
||||
// ВАЖНО: Если есть extra_dirs, но в кэше нет треков из них,
|
||||
// пересобираем библиотеку с нуля (сканируем все директории).
|
||||
if !app.extra_dirs.is_empty() {
|
||||
// Проверяем, есть ли в кэше треки не из базовой директории
|
||||
let base_dir = std::path::Path::new(music_dir);
|
||||
let has_extra_tracks = app.raw_tracks.iter().any(|t| !t.path.starts_with(base_dir));
|
||||
if !has_extra_tracks {
|
||||
// Кэш не содержит extra_dirs — пересканируем всё
|
||||
app.rebuild_from_dirs();
|
||||
}
|
||||
|
||||
// Если sources пуст — первый запуск, добавляем базовую директорию
|
||||
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 {
|
||||
app.player.set_volume(vol);
|
||||
}
|
||||
@@ -209,15 +204,17 @@ impl App {
|
||||
let args = parts.next().unwrap_or("").trim();
|
||||
|
||||
match name {
|
||||
"add" => self.add_dir(args),
|
||||
"add" => self.add_source(args),
|
||||
"load" => self.load_source(args),
|
||||
"reload" => self.reload_library(),
|
||||
"save" => self.save_now(),
|
||||
"savepl" => self.save_playlist(args),
|
||||
"dirs" => {
|
||||
self.show_dirs = true;
|
||||
self.set_status("Список директорий".into());
|
||||
self.set_status("Источники библиотеки".into());
|
||||
}
|
||||
"rmdir" => self.rmdir(args),
|
||||
"clear" => self.clear_extra_dirs(),
|
||||
"rm" => self.remove_source(args),
|
||||
"clear" => self.clear_sources(),
|
||||
"help" => {
|
||||
self.show_help = true;
|
||||
self.set_status("Справка".into());
|
||||
@@ -231,7 +228,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn add_dir(&mut self, raw_path: &str) {
|
||||
fn add_source(&mut self, raw_path: &str) {
|
||||
if raw_path.is_empty() {
|
||||
self.set_status("Использование: :add <путь>".into());
|
||||
return;
|
||||
@@ -245,13 +242,8 @@ impl App {
|
||||
return;
|
||||
}
|
||||
|
||||
if !path.is_dir() {
|
||||
self.set_status(format!("Не директория: {}", expanded));
|
||||
return;
|
||||
}
|
||||
|
||||
self.set_status(format!("Сканирование {}...", expanded));
|
||||
let new_tracks = scan_dir(&expanded);
|
||||
let new_tracks = scan_source(&path);
|
||||
|
||||
if new_tracks.is_empty() {
|
||||
self.set_status(format!("Нет треков в {}", expanded));
|
||||
@@ -267,8 +259,8 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
if !self.extra_dirs.contains(&expanded) {
|
||||
self.extra_dirs.push(expanded.clone());
|
||||
if !self.sources.contains(&expanded) {
|
||||
self.sources.push(expanded.clone());
|
||||
}
|
||||
|
||||
self.rebuild_library();
|
||||
@@ -276,50 +268,77 @@ impl App {
|
||||
self.set_status(format!("Добавлено {} треков из {}", added, expanded));
|
||||
}
|
||||
|
||||
fn rmdir(&mut self, arg: &str) {
|
||||
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("Использование: :rmdir <индекс>".into());
|
||||
self.set_status("Использование: :rm <индекс>".into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if idx == 0 {
|
||||
self.set_status("Нельзя удалить базовую директорию".into());
|
||||
if idx >= self.sources.len() {
|
||||
self.set_status(format!("Нет источника с индексом {}", idx));
|
||||
return;
|
||||
}
|
||||
|
||||
let extra_idx = idx - 1;
|
||||
if extra_idx >= self.extra_dirs.len() {
|
||||
self.set_status(format!("Нет директории с индексом {}", idx));
|
||||
return;
|
||||
}
|
||||
|
||||
let removed = self.extra_dirs.remove(extra_idx);
|
||||
self.set_status(format!("Удалена директория: {}", removed));
|
||||
self.rebuild_from_dirs();
|
||||
let removed = self.sources.remove(idx);
|
||||
self.set_status(format!("Источник удалён: {}", removed));
|
||||
self.reload_library();
|
||||
}
|
||||
|
||||
fn clear_extra_dirs(&mut self) {
|
||||
if self.extra_dirs.is_empty() {
|
||||
self.set_status("extra_dirs уже пуст".into());
|
||||
fn clear_sources(&mut self) {
|
||||
if self.sources.is_empty() {
|
||||
self.set_status("Список источников уже пуст".into());
|
||||
return;
|
||||
}
|
||||
self.extra_dirs.clear();
|
||||
self.rebuild_from_dirs();
|
||||
self.set_status("Все дополнительные директории удалены".into());
|
||||
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_dirs(&mut self) {
|
||||
let mut dirs = vec![self.music_dir.clone()];
|
||||
dirs.extend(self.extra_dirs.clone());
|
||||
|
||||
fn rebuild_from_sources(&mut self) {
|
||||
let mut all = Vec::new();
|
||||
for dir in &dirs {
|
||||
let expanded = expand_tilde(dir);
|
||||
all.extend(scan_dir(&expanded));
|
||||
for src in &self.sources {
|
||||
let path = PathBuf::from(expand_tilde(src));
|
||||
all.extend(scan_source(&path));
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
@@ -329,23 +348,52 @@ impl App {
|
||||
self.deleted_paths.clear();
|
||||
|
||||
crate::state::save_cache(&self.raw_tracks);
|
||||
|
||||
self.rebuild_library();
|
||||
}
|
||||
|
||||
pub fn reload_library(&mut self) {
|
||||
self.rebuild_from_dirs();
|
||||
self.rebuild_from_sources();
|
||||
self.set_status(format!("Пересканировано {} треков", self.raw_tracks.len()));
|
||||
}
|
||||
|
||||
/// Принудительно сохранить кэш библиотеки и настройки.
|
||||
/// Тот же `persist()`, но с подтверждением в статусе.
|
||||
|
||||
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 {
|
||||
@@ -496,8 +544,6 @@ impl App {
|
||||
}
|
||||
|
||||
fn persist(&self) {
|
||||
// Сохраняем кэш треков - чтобы при следующем запуске
|
||||
// не сканировать директории заново.
|
||||
crate::state::save_cache(&self.raw_tracks);
|
||||
|
||||
let playing_path = self
|
||||
@@ -512,14 +558,14 @@ impl App {
|
||||
};
|
||||
|
||||
let state = SavedState {
|
||||
last_path: Some(self.music_dir.clone()),
|
||||
last_path: Some(self.default_dir.clone()),
|
||||
volume: Some(self.player.volume()),
|
||||
sort_mode: Some(self.library.sort_mode),
|
||||
repeat: Some(self.repeat),
|
||||
shuffle: Some(self.shuffle),
|
||||
last_playing_path: playing_path,
|
||||
last_playing_pos_secs: playing_pos,
|
||||
extra_dirs: self.extra_dirs.clone(),
|
||||
sources: self.sources.clone(),
|
||||
};
|
||||
crate::state::save(&state);
|
||||
}
|
||||
@@ -859,16 +905,11 @@ impl App {
|
||||
|
||||
// ── Геттеры для UI ─────────────────────────────────────────
|
||||
|
||||
pub fn base_dir(&self) -> &str {
|
||||
&self.music_dir
|
||||
}
|
||||
|
||||
pub fn extra_dirs(&self) -> &[String] {
|
||||
&self.extra_dirs
|
||||
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() {
|
||||
|
||||
@@ -56,13 +56,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn handle_key(key: KeyEvent, app: &mut App) -> bool {
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
|
||||
// F1 переключает справку в любом режиме
|
||||
if key.code == KeyCode::F(1) {
|
||||
app.toggle_help();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Если открыта справка — только Esc её закрывает
|
||||
if app.show_help {
|
||||
if key.code == KeyCode::Esc {
|
||||
app.toggle_help();
|
||||
@@ -70,7 +68,6 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Если открыто окно директорий — Esc его закрывает
|
||||
if app.show_dirs {
|
||||
if key.code == KeyCode::Esc {
|
||||
app.show_dirs = false;
|
||||
@@ -78,7 +75,6 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ввод команды
|
||||
if app.input_mode == InputMode::Command {
|
||||
match key.code {
|
||||
KeyCode::Esc => app.cancel_command(),
|
||||
@@ -90,7 +86,6 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl-комбинации
|
||||
if ctrl {
|
||||
match key.code {
|
||||
KeyCode::Right => {
|
||||
@@ -116,7 +111,6 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Ввод поиска
|
||||
if app.input_mode == InputMode::Search {
|
||||
match key.code {
|
||||
KeyCode::Esc => app.cancel_search(),
|
||||
@@ -128,13 +122,11 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete
|
||||
if key.code == KeyCode::Delete {
|
||||
app.delete_selected();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Обычные клавиши
|
||||
match key.code {
|
||||
KeyCode::Char(':') => {
|
||||
app.start_command();
|
||||
|
||||
@@ -15,9 +15,9 @@ pub struct SavedState {
|
||||
pub shuffle: Option<bool>,
|
||||
pub last_playing_path: Option<String>,
|
||||
pub last_playing_pos_secs: Option<u64>,
|
||||
/// Дополнительные директории, добавленные через :add
|
||||
/// Единый список источников: папки и плейлисты.
|
||||
#[serde(default)]
|
||||
pub extra_dirs: Vec<String>,
|
||||
pub sources: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)]
|
||||
@@ -58,9 +58,6 @@ pub fn save(state: &SavedState) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Кэш плейлиста (bincode) ────────────────────────────────────
|
||||
|
||||
/// Кэш просто хранит все известные треки. Без привязки к конкретной директории.
|
||||
pub fn load_cache() -> Option<Vec<Track>> {
|
||||
let path = cache_path()?;
|
||||
let bytes = fs::read(&path).ok()?;
|
||||
|
||||
110
src/track.rs
110
src/track.rs
@@ -1,4 +1,4 @@
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use lofty::file::{AudioFile, TaggedFileExt};
|
||||
@@ -91,4 +91,112 @@ pub fn scan_dir(dir: &str) -> Vec<Track> {
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
102
src/ui.rs
102
src/ui.rs
@@ -1,6 +1,6 @@
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::{
|
||||
Bar, BarChart, BarGroup, Block, Borders, Clear, List, ListItem, Paragraph,
|
||||
Bar, BarChart, BarGroup, Block, Borders, Clear, List, ListItem, Paragraph, Wrap,
|
||||
};
|
||||
|
||||
use crate::app::{App, InputMode, Panel};
|
||||
@@ -70,8 +70,8 @@ fn draw_left_panel(f: &mut Frame, app: &mut App, area: Rect) {
|
||||
SortMode::ByAlbum => " Альбомы ",
|
||||
SortMode::ByFilename => " Источник ",
|
||||
};
|
||||
let album_color = Color::Rgb(100, 149, 237);
|
||||
let title = Span::styled(title_text, Style::default().fg(album_color));
|
||||
let album_color = Color::Rgb(100, 149, 237);
|
||||
let title = Span::styled(title_text, Style::default().fg(album_color));
|
||||
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
@@ -133,7 +133,7 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
|
||||
}
|
||||
RightRow::Track { index_in_library } => {
|
||||
let is_playing = Some(*index_in_library) == app.playing_index;
|
||||
let marker = if is_playing { "* " } else { " " };
|
||||
let marker = if is_playing { "♪ " } else { " " };
|
||||
|
||||
let (title, num, dur) = match app.library.track(*index_in_library) {
|
||||
Some(t) => (
|
||||
@@ -151,15 +151,15 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
|
||||
} else {
|
||||
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);
|
||||
.saturating_sub(left_len + dur_len)
|
||||
.max(1);
|
||||
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(left, style),
|
||||
@@ -206,8 +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 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 {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
@@ -300,11 +300,11 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) {
|
||||
|
||||
let status = Paragraph::new(status_text)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(status_title)
|
||||
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
|
||||
);
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(status_title)
|
||||
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
|
||||
);
|
||||
f.render_widget(status, inner[0]);
|
||||
|
||||
let (pos, total) = app.progress();
|
||||
@@ -318,11 +318,11 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) {
|
||||
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)))
|
||||
);
|
||||
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) {
|
||||
@@ -338,7 +338,7 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) {
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Pежим ")
|
||||
.title(" Режим ")
|
||||
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
|
||||
);
|
||||
f.render_widget(mode, inner[2]);
|
||||
@@ -356,13 +356,15 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) {
|
||||
}
|
||||
|
||||
fn draw_dirs(f: &mut Frame, app: &App, screen: Rect) {
|
||||
let width = 70u16.min(screen.width.saturating_sub(4));
|
||||
let total_lines = app.extra_dirs().len() + 8;
|
||||
let desired_height = (total_lines + 2) as u16; // +2 на рамки
|
||||
let count = app.sources().len();
|
||||
|
||||
// Ограничиваем высотой экрана
|
||||
let max_h = screen.height.saturating_sub(2);
|
||||
let height = desired_height.min(max_h).max(5); // минимум 5 строк
|
||||
// Строки: заголовок (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;
|
||||
@@ -378,40 +380,34 @@ fn draw_dirs(f: &mut Frame, app: &App, screen: Rect) {
|
||||
|
||||
let mut lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" Директории библиотеки ",
|
||||
" Источники библиотеки ",
|
||||
title_color.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(" Базовые", header)),
|
||||
Line::from(vec![
|
||||
Span::styled(" [0] ", dim),
|
||||
Span::raw(app.base_dir()),
|
||||
]),
|
||||
Line::from(Span::styled(" Список", header)),
|
||||
];
|
||||
|
||||
if !app.extra_dirs().is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(" Дополнительные (:add)", header)));
|
||||
for (i, dir) in app.extra_dirs().iter().enumerate() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" [{}] ", i + 1), dim),
|
||||
Span::raw(dir.clone()),
|
||||
]));
|
||||
}
|
||||
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(
|
||||
" :rmdir <N> — удалить, :clear — очистить всё, Esc — закрыть",
|
||||
" :rm <N> — удалить, Esc — закрыть",
|
||||
dim,
|
||||
)));
|
||||
|
||||
let para = Paragraph::new(lines).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(title_color)
|
||||
.title(" Библиотека "),
|
||||
);
|
||||
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);
|
||||
}
|
||||
@@ -459,10 +455,14 @@ fn draw_help(f: &mut Frame, screen: Rect) {
|
||||
Line::from(" 1..9 перейти к 10%..90% трека"),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(" Команды", header)),
|
||||
Line::from(" :add <путь> добавить директорию"),
|
||||
Line::from(" :add <путь> добавить в сессию"),
|
||||
Line::from(" :load <путь> заменить сессию"),
|
||||
Line::from(" :reload пересканировать всё"),
|
||||
Line::from(" :save сохранить библиотеку"),
|
||||
Line::from(" :dirs управление директориями"),
|
||||
Line::from(" :savepl <файл> экспорт в M3U/PLS"),
|
||||
Line::from(" :dirs список источников"),
|
||||
Line::from(" :rm <N> удалить источник"),
|
||||
Line::from(" :clear очистить источники"),
|
||||
Line::from(" :help эта справка"),
|
||||
Line::from(" :q выход"),
|
||||
Line::from(""),
|
||||
|
||||
Reference in New Issue
Block a user