Compare commits

3 Commits

13 changed files with 1186 additions and 787 deletions

12
Cargo.lock generated
View File

@@ -981,7 +981,7 @@ dependencies = [
[[package]] [[package]]
name = "rumus" name = "rumus"
version = "0.5.0" version = "0.5.1"
dependencies = [ dependencies = [
"bincode", "bincode",
"crossterm", "crossterm",
@@ -992,6 +992,7 @@ dependencies = [
"rodio", "rodio",
"rustfft", "rustfft",
"serde", "serde",
"sys-locale",
"toml", "toml",
"walkdir", "walkdir",
] ]
@@ -1401,6 +1402,15 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "rumus" name = "rumus"
version = "0.5.0" version = "0.5.1"
edition = "2024" edition = "2021"
[dependencies] [dependencies]
ratatui = "0.29" ratatui = "0.29"
@@ -15,3 +15,4 @@ toml = "0.8"
rustfft = "6" rustfft = "6"
bincode = "1.3" bincode = "1.3"
encoding_rs = "0.8" encoding_rs = "0.8"
sys-locale = "0.3"

1
build.bat Normal file
View File

@@ -0,0 +1 @@
cargo build --release

113
locales/en_US.toml Normal file
View File

@@ -0,0 +1,113 @@
# ── Panels ─────────────────────────────────────────────────────
"menu.artists" = "Artists"
"menu.albums" = "Albums"
"menu.source" = "Source"
"menu.all_files" = "All files"
# ── Status-bar ─────────────────────────────────────────────────
"status.label" = "Status"
"status.search" = "Search"
"status.command" = "Command"
"status.time" = "Time"
"status.mode" = "Mode"
"status.volume" = "Volume"
"status.equalizer" = "Equalizer"
"status.library" = "Library"
"status.help" = "Help"
# ── Window :libs ───────────────────────────────────────────────
"libs.sources" = "Sources"
"libs.delete" = "Delete"
"libs.close" = "Close"
# ── Help ───────────────────────────────────────────────────────
"help.title" = " Player Controls "
"help.nav" = " Navigation"
"help.line_up" = " w / ↑ Move up"
"help.line_down" = " s / ↓ Move down"
"help.line_tab" = " Tab Switch panel"
"help.line_goto" = " g / G To start / to end"
"help.line_f" = " f Jump to playing track"
"help.playback" = " Playback"
"help.line_enter" = " Enter Play selected track"
"help.line_space" = " Space Pause / resume"
"help.line_z" = " z Stop"
"help.line_ctrl_right" = " Ctrl+→ / Ctrl+d Next track"
"help.line_ctrl_left" = " Ctrl+← / Ctrl+a Previous track"
"help.line_r" = " r Repeat: off → all → one"
"help.line_x" = " x Toggle shuffle"
"help.seeking" = " Seeking"
"help.line_a" = " a / ← −5 seconds"
"help.line_d" = " d / → +5 seconds"
"help.line_1_9" = " 1..9 Jump to 10%..90% of track"
"help.commands" = " Commands"
"help.line_add" = " :add <path> Add to session"
"help.line_load" = " :load <path> Replace session"
"help.line_reload" = " :reload Rescan everything"
"help.line_save" = " :save Save library"
"help.line_savepl" = " :savepl <file> Export to M3U/PLS"
"help.line_libs" = " :libs List sources"
"help.line_rm" = " :rm <N> Remove source"
"help.line_clear" = " :clear Clear sources"
"help.line_lang" = " :lang <code> Change language"
"help.line_help" = " :help This help"
"help.line_q" = " :q Quit"
"help.etc" = " Audio & Misc"
"help.line_vol" = " + / - Volume"
"help.line_search" = " / Search"
"help.line_del" = " Del Delete from list"
"help.line_t" = " t Sort: artist → album → file"
"help.line_f1" = " F1 This help"
"help.line_quit" = " q / Esc Quit"
"help.close" = " F1 / Esc - Close"
# ── Dynamic statuses ────────────────────────────────────────
"msg.restored" = "Restored"
"msg.cancelled" = "Cancelled"
"msg.usage_add" = "Usage: :add <path>"
"msg.usage_load" = "Usage: :load <path>"
"msg.usage_rm" = "Usage: :rm <index>"
"msg.usage_savepl" = "Usage: :savepl <file.m3u|.pls>"
"msg.path_not_exist" = "Path does not exist"
"msg.no_tracks_in" = "No tracks in"
"msg.scanning" = "Scanning"
"msg.loading" = "Loading"
"msg.added" = "Added"
"msg.loaded" = "Loaded"
"msg.tracks" = "tracks"
"msg.rescanned" = "Rescanned"
"msg.removed" = "Removed"
"msg.no_source_with_index" = "No source with index"
"msg.already_empty" = "Already empty"
"msg.all_sources_cleared" = "All sources cleared"
"msg.library_saved" = "Library saved"
"msg.playlist_saved" = "Playlist saved"
"msg.save_error" = "Save error"
"msg.deleted" = "Deleted"
"msg.unknown_command" = "Unknown command"
"msg.languages" = "Languages"
"msg.language" = "Language"
"msg.not_found" = "Not found"
"msg.library_empty" = "Library is empty"
"msg.end_of_playlist" = "End of playlist"
"msg.first_track" = "First track"
"msg.nothing_playing" = "Nothing is playing"
"msg.jumped_to_playing" = "Jumped to playing track"
"msg.selected_group" = "Selected group header"
"msg.stopped" = "Stopped"
"msg.repeat_off" = "Repeat: off"
"msg.repeat_all" = "Repeat: all"
"msg.repeat_one" = "Repeat: one"
"msg.shuffle_on" = "Shuffle: on"
"msg.shuffle_off" = "Shuffle: off"
"msg.seek" = "Seek"
"msg.seek_unavailable" = "Seek unavailable"
"msg.unknown_duration" = "Unknown duration"
"msg.error" = "Error"
"msg.filter" = "Filter"
"msg.filter_reset" = "Filter reset"
"msg.search_cancelled" = "Search cancelled"
"msg.left_panel" = "Left panel active"
"msg.right_panel" = "Right panel active"
"msg.sort_artist" = "Sort: artist → album → file"
"msg.sort_album" = "Sort: album → artist → file"
"msg.sort_file" = "Sort: by filename"
"msg.unknown_artist" = "Unknown Artist"
"msg.unknown_album" = "Unknown Album"

116
locales/ru_RU.toml Normal file
View File

@@ -0,0 +1,116 @@
# ── Панели ─────────────────────────────────────────────────────
"menu.artists" = "Исполнители"
"menu.albums" = "Альбомы"
"menu.source" = "Источник"
"menu.all_files" = "Все файлы"
# ── Статус-бар ─────────────────────────────────────────────────
"status.label" = "Статус"
"status.search" = "Поиск"
"status.command" = "Команда"
"status.time" = "Время"
"status.mode" = "Режим"
"status.volume" = "Звук"
"status.equalizer" = "Эквалайзер"
"status.library" = "Библиотека"
"status.help" = "Помощь"
# ── Окно :libs ─────────────────────────────────────────────────
"libs.sources" = "Источники"
"libs.delete" = "Удалить"
"libs.close" = "Закрыть"
# ── Справка ────────────────────────────────────────────────────
"help.title" = " Управление плеером "
"help.nav" = " Навигация"
"help.line_up" = " w / ↑ вверх по списку"
"help.line_down" = " s / ↓ вниз по списку"
"help.line_tab" = " Tab переключить панель"
"help.line_goto" = " g / G в начало / в конец"
"help.line_f" = " f к играющему треку"
"help.playback" = " Воспроизведение"
"help.line_enter" = " Enter играть выделенный трек"
"help.line_space" = " Space пауза / продолжить"
"help.line_z" = " z стоп"
"help.line_ctrl_right" = " Ctrl+→ / Ctrl+d следующий трек"
"help.line_ctrl_left" = " Ctrl+← / Ctrl+a предыдущий трек"
"help.line_r" = " r повтор: выкл → все → один"
"help.line_x" = " x перемешивание вкл/выкл"
"help.seeking" = " Перемотка"
"help.line_a" = " a / ← −5 секунд"
"help.line_d" = " d / → +5 секунд"
"help.line_1_9" = " 1..9 перейти к 10%..90% трека"
"help.commands" = " Команды"
"help.line_add" = " :add <путь> добавить в сессию"
"help.line_load" = " :load <путь> заменить сессию"
"help.line_reload" = " :reload пересканировать всё"
"help.line_save" = " :save сохранить библиотеку"
"help.line_savepl" = " :savepl <файл> экспорт в M3U/PLS"
"help.line_libs" = " :libs список источников"
"help.line_rm" = " :rm <N> удалить источник"
"help.line_clear" = " :clear очистить источники"
"help.line_lang" = " :lang <код> сменить язык"
"help.line_help" = " :help эта справка"
"help.line_q" = " :q выход"
"help.etc" = " Звук и прочее"
"help.line_vol" = " + / - громкость"
"help.line_search" = " / поиск"
"help.line_del" = " Del удалить из списка"
"help.line_t" = " t сортировка: артист → альбом → файл"
"help.line_f1" = " F1 эта справка"
"help.line_quit" = " q / Esc выход"
"help.close" = " F1 / Esc - закрыть"
# ── Динамические статусы ───────────────────────────────────────
"msg.restored" = "Восстановлен"
"msg.cancelled" = "Отменено"
"msg.usage_add" = "Использование: :add <путь>"
"msg.usage_load" = "Использование: :load <путь>"
"msg.usage_rm" = "Использование: :rm <индекс>"
"msg.usage_savepl" = "Использование: :savepl <файл.m3u|.pls>"
"msg.path_not_exist" = "Путь не существует"
"msg.no_tracks_in" = "Нет треков в"
"msg.scanning" = "Сканирование"
"msg.loading" = "Загрузка"
"msg.added" = "Добавлено"
"msg.loaded" = "Загружено"
"msg.tracks" = "треков"
"msg.rescanned" = "Пересканировано"
"msg.removed" = "Удалён источник"
"msg.no_source_with_index" = "Нет источника с индексом"
"msg.already_empty" = "Список источников уже пуст"
"msg.all_sources_cleared" = "Все источники удалены"
"msg.library_saved" = "Библиотека сохранена"
"msg.playlist_saved" = "Плейлист сохранён"
"msg.save_error" = "Ошибка сохранения"
"msg.deleted" = "Удалено"
"msg.unknown_command" = "Неизвестная команда"
"msg.languages" = "Доступные языки"
"msg.language" = "Язык"
"msg.not_found" = "Не найден"
"msg.library_empty" = "Библиотека пуста"
"msg.end_of_playlist" = "Конец плейлиста"
"msg.first_track" = "Это первый трек"
"msg.nothing_playing" = "Ничего не играет"
"msg.jumped_to_playing" = "→ к играющему треку"
"msg.selected_group" = "Выделен заголовок группы"
"msg.stopped" = "Остановлено"
"msg.repeat_off" = "Повтор: выкл"
"msg.repeat_all" = "Повтор: все"
"msg.repeat_one" = "Повтор: один"
"msg.shuffle_on" = "Перемешивание: вкл"
"msg.shuffle_off" = "Перемешивание: выкл"
"msg.seek" = "Перемотка"
"msg.seek_unavailable" = "Перемотка недоступна"
"msg.unknown_duration" = "Длительность неизвестна"
"msg.error" = "Ошибка"
"msg.filter" = "Фильтр"
"msg.filter_reset" = "Фильтр сброшен"
"msg.search_cancelled" = "Поиск отменён"
"msg.left_panel" = "Активна: левая панель"
"msg.right_panel" = "Активна: правая панель"
"msg.sort_artist" = "Сортировка: артист → альбом → файл"
"msg.sort_album" = "Сортировка: альбом → артист → файл"
"msg.sort_file" = "Сортировка: по имени файла"
"msg.unknown_artist" = "Неизвестный исполнитель"
"msg.unknown_album" = "Неизвестный альбом"

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,8 @@ use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink, Source}; use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink, Source};
use rustfft::num_complex::Complex;
use rustfft::{Fft, FftPlanner};
const FFT_SIZE: usize = 1024; const FFT_SIZE: usize = 1024;
const NUM_BARS: usize = 64; const NUM_BARS: usize = 64;
@@ -13,13 +15,17 @@ const NUM_BARS: usize = 64;
pub struct SpectrumBus { pub struct SpectrumBus {
buffer: Arc<Mutex<VecDeque<f32>>>, buffer: Arc<Mutex<VecDeque<f32>>>,
bars: Arc<Mutex<[f32; NUM_BARS]>>, bars: Arc<Mutex<[f32; NUM_BARS]>>,
fft_plan: Arc<dyn Fft<f32>>,
} }
impl SpectrumBus { impl SpectrumBus {
pub fn new() -> Self { pub fn new() -> Self {
let mut planner = FftPlanner::<f32>::new();
let fft_plan = planner.plan_fft_forward(FFT_SIZE);
Self { Self {
buffer: Arc::new(Mutex::new(VecDeque::with_capacity(FFT_SIZE * 2))), buffer: Arc::new(Mutex::new(VecDeque::with_capacity(FFT_SIZE * 2))),
bars: Arc::new(Mutex::new([0.0; NUM_BARS])), bars: Arc::new(Mutex::new([0.0; NUM_BARS])),
fft_plan,
} }
} }
@@ -32,22 +38,36 @@ impl SpectrumBus {
} }
} }
/// Очистить буфер (при seek, чтобы спектр не «дёргался»).
pub fn clear(&self) {
if let Ok(mut buf) = self.buffer.lock() {
buf.clear();
}
}
pub fn bars(&self) -> [f32; NUM_BARS] { pub fn bars(&self) -> [f32; NUM_BARS] {
self.bars.lock().map(|b| *b).unwrap_or([0.0; NUM_BARS]) self.bars.lock().map(|b| *b).unwrap_or([0.0; NUM_BARS])
} }
pub fn recompute(&self) { pub fn recompute(&self) {
use rustfft::num_complex::Complex; // Быстрая проверка длины до клонирования
use rustfft::FftPlanner; {
let buf = match self.buffer.lock() {
Ok(b) => b,
Err(_) => return,
};
if buf.len() < FFT_SIZE {
return;
}
}
let samples: Vec<f32> = match self.buffer.lock() { let samples: Vec<f32> = match self.buffer.lock() {
Ok(buf) => buf.iter().rev().take(FFT_SIZE).rev().copied().collect(), Ok(buf) => buf.iter().rev().take(FFT_SIZE).rev().copied().collect(),
Err(_) => return, Err(_) => return,
}; };
if samples.len() < FFT_SIZE {
return;
}
// Переиспользуем один и тот же Vec<Complex<f32>> — но он маленький (1024),
// аллокация дешёвая. Оставим создание per-call для простоты.
let mut data: Vec<Complex<f32>> = samples let mut data: Vec<Complex<f32>> = samples
.iter() .iter()
.enumerate() .enumerate()
@@ -59,9 +79,7 @@ impl SpectrumBus {
}) })
.collect(); .collect();
let mut planner = FftPlanner::<f32>::new(); self.fft_plan.process(&mut data);
let fft = planner.plan_fft_forward(FFT_SIZE);
fft.process(&mut data);
let half = FFT_SIZE / 2; let half = FFT_SIZE / 2;
let mut bars = [0.0f32; NUM_BARS]; let mut bars = [0.0f32; NUM_BARS];
@@ -125,6 +143,7 @@ where
self.inner.total_duration() self.inner.total_duration()
} }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> { fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
self.bus.clear(); // сброс спектра, чтобы не «дёргался»
self.inner.try_seek(pos) self.inner.try_seek(pos)
} }
} }
@@ -149,8 +168,6 @@ impl AudioPlayer {
}) })
} }
/// Запускает трек, опционально перематывая в `start_pos` ДО начала воспроизведения.
/// Перемотка делается на декодере — это предотвращает щелчок на старте.
pub fn play( pub fn play(
&mut self, &mut self,
path: &PathBuf, path: &PathBuf,
@@ -173,7 +190,6 @@ impl AudioPlayer {
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(())
} }

420
src/i18n.rs Normal file
View File

@@ -0,0 +1,420 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
#[derive(Clone)]
pub struct Translations {
// Панели
pub artists: String,
pub albums: String,
pub source: String,
pub all_files: String,
// Статус
pub status: String,
pub search: String,
pub command: String,
pub time: String,
pub mode: String,
pub volume: String,
pub equalizer: String,
pub library: String,
pub help: String,
// :libs
pub sources: String,
pub delete: String,
pub close: String,
// Справка: заголовки
pub help_title: String,
pub help_nav: String,
pub help_playback: String,
pub help_seeking: String,
pub help_commands: String,
pub help_audio_misc: String,
// Справка: строки
pub help_line_up: String,
pub help_line_down: String,
pub help_line_tab: String,
pub help_line_goto: String,
pub help_line_f: String,
pub help_line_enter: String,
pub help_line_space: String,
pub help_line_z: String,
pub help_line_ctrl_right: String,
pub help_line_ctrl_left: String,
pub help_line_r: String,
pub help_line_x: String,
pub help_line_a: String,
pub help_line_d: String,
pub help_line_1_9: String,
pub help_line_add: String,
pub help_line_load: String,
pub help_line_reload: String,
pub help_line_save: String,
pub help_line_savepl: String,
pub help_line_libs: String,
pub help_line_rm: String,
pub help_line_clear: String,
pub help_line_lang: String,
pub help_line_help: String,
pub help_line_q: String,
pub help_line_vol: String,
pub help_line_search: String,
pub help_line_del: String,
pub help_line_t: String,
pub help_line_f1: String,
pub help_line_quit: String,
pub help_close: String,
// Динамические статусы
pub restored: String,
pub cancelled: String,
pub usage_add: String,
pub usage_load: String,
pub usage_rm: String,
pub usage_savepl: String,
pub path_not_exist: String,
pub no_tracks_in: String,
pub scanning: String,
pub loading: String,
pub added: String,
pub loaded: String,
pub tracks: String,
pub rescanned: String,
pub removed: String,
pub no_source_with_index: String,
pub already_empty: String,
pub all_sources_cleared: String,
pub library_saved: String,
pub playlist_saved: String,
pub save_error: String,
pub deleted: String,
pub unknown_command: String,
pub languages: String,
pub language: String,
pub not_found: String,
pub library_empty: String,
pub end_of_playlist: String,
pub first_track: String,
pub nothing_playing: String,
pub jumped_to_playing: String,
pub selected_group: String,
pub stopped: String,
pub repeat_off: String,
pub repeat_all: String,
pub repeat_one: String,
pub shuffle_on: String,
pub shuffle_off: String,
pub seek: String,
pub seek_unavailable: String,
pub unknown_duration: String,
pub error: String,
pub filter: String,
pub filter_reset: String,
pub search_cancelled: String,
pub left_panel: String,
pub right_panel: String,
pub sort_artist: String,
pub sort_album: String,
pub sort_file: String,
// Маркеры
pub unknown_artist: String,
pub unknown_album: String,
}
pub struct Locales {
pub maps: HashMap<String, HashMap<String, String>>,
pub current: String,
pub t: Translations,
}
impl Locales {
pub fn load_all() -> Self {
let mut maps = HashMap::new();
let exe_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.join("locales")));
let cwd_dir = PathBuf::from("locales");
let locales_dir = match exe_dir {
Some(dir) if dir.exists() => Some(dir),
_ => if cwd_dir.exists() { Some(cwd_dir) } else { None },
};
if let Some(dir) = locales_dir {
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("toml") {
if let Ok(content) = fs::read_to_string(&path) {
match toml::from_str::<HashMap<String, String>>(&content) {
Ok(dict) => {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
maps.insert(stem.to_string(), dict);
}
}
Err(e) => eprintln!("[i18n] Ошибка в {:?}: {}", path, e),
}
}
}
}
}
}
if maps.is_empty() {
eprintln!("[i18n] locales пуст. Fallback en_US.");
maps.insert("en_US".to_string(), HashMap::new());
}
let current = "en_US".to_string();
let mut locales = Locales { maps, current, t: empty_translations() };
locales.rebuild_cache();
locales
}
#[allow(dead_code)]
pub fn tr<'a>(&'a self, key: &'a str) -> Cow<'a, str> {
if let Some(dict) = self.maps.get(&self.current) {
if let Some(value) = dict.get(key) {
return Cow::Borrowed(value);
}
}
Cow::Borrowed(key)
}
pub fn display_artist<'a>(&'a self, raw: &'a str) -> Cow<'a, str> {
if raw == "Unknown Artist" {
Cow::Owned(self.t.unknown_artist.clone())
} else {
Cow::Borrowed(raw)
}
}
pub fn display_album<'a>(&'a self, raw: &'a str) -> Cow<'a, str> {
if raw == "Unknown Album" {
Cow::Owned(self.t.unknown_album.clone())
} else {
Cow::Borrowed(raw)
}
}
pub fn rebuild_cache(&mut self) {
let dict = self.maps.get(&self.current).cloned().unwrap_or_default();
let get = |key: &str| -> String {
dict.get(key).cloned().unwrap_or_else(|| key.to_string())
};
self.t = Translations {
artists: get("menu.artists"),
albums: get("menu.albums"),
source: get("menu.source"),
all_files: get("menu.all_files"),
status: get("status.label"),
search: get("status.search"),
command: get("status.command"),
time: get("status.time"),
mode: get("status.mode"),
volume: get("status.volume"),
equalizer: get("status.equalizer"),
library: get("status.library"),
help: get("status.help"),
sources: get("libs.sources"),
delete: get("libs.delete"),
close: get("libs.close"),
help_title: get("help.title"),
help_nav: get("help.nav"),
help_playback: get("help.playback"),
help_seeking: get("help.seeking"),
help_commands: get("help.commands"),
help_audio_misc: get("help.etc"),
help_line_up: get("help.line_up"),
help_line_down: get("help.line_down"),
help_line_tab: get("help.line_tab"),
help_line_goto: get("help.line_goto"),
help_line_f: get("help.line_f"),
help_line_enter: get("help.line_enter"),
help_line_space: get("help.line_space"),
help_line_z: get("help.line_z"),
help_line_ctrl_right: get("help.line_ctrl_right"),
help_line_ctrl_left: get("help.line_ctrl_left"),
help_line_r: get("help.line_r"),
help_line_x: get("help.line_x"),
help_line_a: get("help.line_a"),
help_line_d: get("help.line_d"),
help_line_1_9: get("help.line_1_9"),
help_line_add: get("help.line_add"),
help_line_load: get("help.line_load"),
help_line_reload: get("help.line_reload"),
help_line_save: get("help.line_save"),
help_line_savepl: get("help.line_savepl"),
help_line_libs: get("help.line_libs"),
help_line_rm: get("help.line_rm"),
help_line_clear: get("help.line_clear"),
help_line_lang: get("help.line_lang"),
help_line_help: get("help.line_help"),
help_line_q: get("help.line_q"),
help_line_vol: get("help.line_vol"),
help_line_search: get("help.line_search"),
help_line_del: get("help.line_del"),
help_line_t: get("help.line_t"),
help_line_f1: get("help.line_f1"),
help_line_quit: get("help.line_quit"),
help_close: get("help.close"),
restored: get("msg.restored"),
cancelled: get("msg.cancelled"),
usage_add: get("msg.usage_add"),
usage_load: get("msg.usage_load"),
usage_rm: get("msg.usage_rm"),
usage_savepl: get("msg.usage_savepl"),
path_not_exist: get("msg.path_not_exist"),
no_tracks_in: get("msg.no_tracks_in"),
scanning: get("msg.scanning"),
loading: get("msg.loading"),
added: get("msg.added"),
loaded: get("msg.loaded"),
tracks: get("msg.tracks"),
rescanned: get("msg.rescanned"),
removed: get("msg.removed"),
no_source_with_index: get("msg.no_source_with_index"),
already_empty: get("msg.already_empty"),
all_sources_cleared: get("msg.all_sources_cleared"),
library_saved: get("msg.library_saved"),
playlist_saved: get("msg.playlist_saved"),
save_error: get("msg.save_error"),
deleted: get("msg.deleted"),
unknown_command: get("msg.unknown_command"),
languages: get("msg.languages"),
language: get("msg.language"),
not_found: get("msg.not_found"),
library_empty: get("msg.library_empty"),
end_of_playlist: get("msg.end_of_playlist"),
first_track: get("msg.first_track"),
nothing_playing: get("msg.nothing_playing"),
jumped_to_playing: get("msg.jumped_to_playing"),
selected_group: get("msg.selected_group"),
stopped: get("msg.stopped"),
repeat_off: get("msg.repeat_off"),
repeat_all: get("msg.repeat_all"),
repeat_one: get("msg.repeat_one"),
shuffle_on: get("msg.shuffle_on"),
shuffle_off: get("msg.shuffle_off"),
seek: get("msg.seek"),
seek_unavailable: get("msg.seek_unavailable"),
unknown_duration: get("msg.unknown_duration"),
error: get("msg.error"),
filter: get("msg.filter"),
filter_reset: get("msg.filter_reset"),
search_cancelled: get("msg.search_cancelled"),
left_panel: get("msg.left_panel"),
right_panel: get("msg.right_panel"),
sort_artist: get("msg.sort_artist"),
sort_album: get("msg.sort_album"),
sort_file: get("msg.sort_file"),
unknown_artist: get("msg.unknown_artist"),
unknown_album: get("msg.unknown_album"),
};
}
pub fn set_lang(&mut self, code: &str) -> bool {
if self.maps.contains_key(code) {
self.current = code.to_string();
self.rebuild_cache();
true
} else {
false
}
}
pub fn available(&self) -> Vec<String> {
let mut v: Vec<String> = self.maps.keys().cloned().collect();
v.sort();
v
}
}
fn empty_translations() -> Translations {
let s = |k: &str| k.to_string();
Translations {
artists: s("Artists"), albums: s("Albums"), source: s("Source"),
all_files: s("All files"),
status: s("Status"), search: s("Search"), command: s("Command"),
time: s("Time"), mode: s("Mode"), volume: s("Volume"),
equalizer: s("Equalizer"), library: s("Library"), help: s("Help"),
sources: s("Sources"), delete: s("Delete"), close: s("Close"),
help_title: s(" Player Controls "),
help_nav: s(" Navigation"),
help_playback: s(" Playback"),
help_seeking: s(" Seeking"),
help_commands: s(" Commands"),
help_audio_misc: s(" Audio & Misc"),
help_line_up: s(" w / ↑ Move up"),
help_line_down: s(" s / ↓ Move down"),
help_line_tab: s(" Tab Switch panel"),
help_line_goto: s(" g / G To start / to end"),
help_line_f: s(" f Jump to playing track"),
help_line_enter: s(" Enter Play selected track"),
help_line_space: s(" Space Pause / resume"),
help_line_z: s(" z Stop"),
help_line_ctrl_right: s(" Ctrl+→ / Ctrl+d Next track"),
help_line_ctrl_left: s(" Ctrl+← / Ctrl+a Previous track"),
help_line_r: s(" r Repeat: off → all → one"),
help_line_x: s(" x Toggle shuffle"),
help_line_a: s(" a / ← −5 seconds"),
help_line_d: s(" d / → +5 seconds"),
help_line_1_9: s(" 1..9 Jump to 10%..90% of track"),
help_line_add: s(" :add <path> Add to session"),
help_line_load: s(" :load <path> Replace session"),
help_line_reload: s(" :reload Rescan everything"),
help_line_save: s(" :save Save library"),
help_line_savepl: s(" :savepl <file> Export to M3U/PLS"),
help_line_libs: s(" :libs List sources"),
help_line_rm: s(" :rm <N> Remove source"),
help_line_clear: s(" :clear Clear sources"),
help_line_lang: s(" :lang <code> Change language"),
help_line_help: s(" :help This help"),
help_line_q: s(" :q Quit"),
help_line_vol: s(" + / - Volume"),
help_line_search: s(" / Search"),
help_line_del: s(" Del Delete from list"),
help_line_t: s(" t Sort: artist → album → file"),
help_line_f1: s(" F1 This help"),
help_line_quit: s(" q / Esc Quit"),
help_close: s(" F1 / Esc — Close"),
restored: s("Restored"), cancelled: s("Cancelled"),
usage_add: s("Usage: :add <path>"), usage_load: s("Usage: :load <path>"),
usage_rm: s("Usage: :rm <index>"),
usage_savepl: s("Usage: :savepl <file.m3u|.pls>"),
path_not_exist: s("Path does not exist"),
no_tracks_in: s("No tracks in"),
scanning: s("Scanning"), loading: s("Loading"),
added: s("Added"), loaded: s("Loaded"), tracks: s("tracks"),
rescanned: s("Rescanned"), removed: s("Removed"),
no_source_with_index: s("No source with index"),
already_empty: s("Already empty"),
all_sources_cleared: s("All sources cleared"),
library_saved: s("Library saved"), playlist_saved: s("Playlist saved"),
save_error: s("Save error"), deleted: s("Deleted"),
unknown_command: s("Unknown command"),
languages: s("Languages"), language: s("Language"),
not_found: s("Not found"),
library_empty: s("Library is empty"),
end_of_playlist: s("End of playlist"),
first_track: s("First track"),
nothing_playing: s("Nothing is playing"),
jumped_to_playing: s("Jumped to playing track"),
selected_group: s("Selected group header"),
stopped: s("Stopped"),
repeat_off: s("Repeat: off"), repeat_all: s("Repeat: all"),
repeat_one: s("Repeat: one"),
shuffle_on: s("Shuffle: on"), shuffle_off: s("Shuffle: off"),
seek: s("Seek"), seek_unavailable: s("Seek unavailable"),
unknown_duration: s("Unknown duration"),
error: s("Error"),
filter: s("Filter"), filter_reset: s("Filter reset"),
search_cancelled: s("Search cancelled"),
left_panel: s("Left panel active"), right_panel: s("Right panel active"),
sort_artist: s("Sort: artist → album → file"),
sort_album: s("Sort: album → artist → file"),
sort_file: s("Sort: by filename"),
unknown_artist: s("Unknown Artist"),
unknown_album: s("Unknown Album"),
}
}

View File

@@ -5,9 +5,7 @@ use crate::track::Track;
#[derive(Clone)] #[derive(Clone)]
pub enum RightRow { pub enum RightRow {
/// Заголовок группы: в режиме ByArtist - название альбома, AlbumHeader { name: String, is_artist: bool },
/// в режиме ByAlbum - имя исполнителя.
AlbumHeader { name: String },
Track { index_in_library: usize }, Track { index_in_library: usize },
} }
@@ -35,14 +33,15 @@ pub struct Library {
} }
impl Library { impl Library {
pub fn build(tracks: Vec<Track>, mode: SortMode) -> Self { pub fn build(tracks: Vec<Track>, mode: SortMode, all_files_label: String) -> Self {
Self::build_with_filter(tracks, mode, String::new()) Self::build_with_filter(tracks, mode, String::new(), all_files_label)
} }
pub fn build_with_filter( pub fn build_with_filter(
mut tracks: Vec<Track>, mut tracks: Vec<Track>,
mode: SortMode, mode: SortMode,
filter: String, filter: String,
all_files_label: String,
) -> Self { ) -> Self {
if !filter.is_empty() { if !filter.is_empty() {
let f = filter.to_lowercase(); let f = filter.to_lowercase();
@@ -56,17 +55,12 @@ impl Library {
match mode { match mode {
SortMode::ByArtist => Self::build_by_artist(tracks, mode, filter), SortMode::ByArtist => Self::build_by_artist(tracks, mode, filter),
SortMode::ByAlbum => Self::build_by_album(tracks, mode, filter), SortMode::ByAlbum => Self::build_by_album(tracks, mode, filter),
SortMode::ByFilename => Self::build_by_filename(tracks, mode, filter), SortMode::ByFilename => Self::build_by_filename(tracks, mode, filter, all_files_label),
} }
} }
fn build_by_artist( fn build_by_artist(mut tracks: Vec<Track>, mode: SortMode, filter: String) -> Self {
mut tracks: Vec<Track>,
mode: SortMode,
filter: String,
) -> Self {
use std::collections::BTreeMap; use std::collections::BTreeMap;
let mut map: BTreeMap<String, BTreeMap<String, Vec<Track>>> = BTreeMap::new(); let mut map: BTreeMap<String, BTreeMap<String, Vec<Track>>> = BTreeMap::new();
for t in tracks.drain(..) { for t in tracks.drain(..) {
map.entry(t.artist.clone()) map.entry(t.artist.clone())
@@ -81,43 +75,25 @@ impl Library {
for (artist_name, albums_map) in map { for (artist_name, albums_map) in map {
let mut rows = Vec::new(); let mut rows = Vec::new();
for (album_name, mut album_tracks) in albums_map { for (album_name, mut album_tracks) in albums_map {
album_tracks.sort_by(|a, b| { album_tracks.sort_by(|a, b| {
a.track_number a.track_number.unwrap_or(u32::MAX).cmp(&b.track_number.unwrap_or(u32::MAX))
.unwrap_or(u32::MAX)
.cmp(&b.track_number.unwrap_or(u32::MAX))
.then(a.title.cmp(&b.title)) .then(a.title.cmp(&b.title))
}); });
rows.push(RightRow::AlbumHeader { name: album_name, is_artist: false });
rows.push(RightRow::AlbumHeader { name: album_name });
for t in album_tracks { for t in album_tracks {
let idx = all_tracks.len(); let idx = all_tracks.len();
all_tracks.push(t); all_tracks.push(t);
rows.push(RightRow::Track { rows.push(RightRow::Track { index_in_library: idx });
index_in_library: idx,
});
} }
} }
artists.push(ArtistView { name: artist_name, rows });
artists.push(ArtistView {
name: artist_name,
rows,
});
} }
Self::finalize(all_tracks, artists, mode, filter) Self::finalize(all_tracks, artists, mode, filter)
} }
fn build_by_album( fn build_by_album(mut tracks: Vec<Track>, mode: SortMode, filter: String) -> Self {
mut tracks: Vec<Track>,
mode: SortMode,
filter: String,
) -> Self {
use std::collections::BTreeMap; use std::collections::BTreeMap;
// Album -> Artist -> Vec<Track>
let mut map: BTreeMap<String, BTreeMap<String, Vec<Track>>> = BTreeMap::new(); let mut map: BTreeMap<String, BTreeMap<String, Vec<Track>>> = BTreeMap::new();
for t in tracks.drain(..) { for t in tracks.drain(..) {
map.entry(t.album.clone()) map.entry(t.album.clone())
@@ -132,33 +108,20 @@ impl Library {
for (album_name, artists_map) in map { for (album_name, artists_map) in map {
let mut rows = Vec::new(); let mut rows = Vec::new();
for (artist_name, mut artist_tracks) in artists_map { for (artist_name, mut artist_tracks) in artists_map {
artist_tracks.sort_by(|a, b| { artist_tracks.sort_by(|a, b| {
a.track_number a.track_number.unwrap_or(u32::MAX).cmp(&b.track_number.unwrap_or(u32::MAX))
.unwrap_or(u32::MAX)
.cmp(&b.track_number.unwrap_or(u32::MAX))
.then(a.title.cmp(&b.title)) .then(a.title.cmp(&b.title))
}); });
rows.push(RightRow::AlbumHeader { name: artist_name, is_artist: true });
// Заголовок группы = имя исполнителя
rows.push(RightRow::AlbumHeader { name: artist_name });
for t in artist_tracks { for t in artist_tracks {
let idx = all_tracks.len(); let idx = all_tracks.len();
all_tracks.push(t); all_tracks.push(t);
rows.push(RightRow::Track { rows.push(RightRow::Track { index_in_library: idx });
index_in_library: idx,
});
} }
} }
albums.push(ArtistView { name: album_name, rows });
albums.push(ArtistView {
name: album_name,
rows,
});
} }
Self::finalize(all_tracks, albums, mode, filter) Self::finalize(all_tracks, albums, mode, filter)
} }
@@ -166,29 +129,21 @@ impl Library {
mut tracks: Vec<Track>, mut tracks: Vec<Track>,
mode: SortMode, mode: SortMode,
filter: String, filter: String,
all_files_label: String,
) -> Self { ) -> Self {
tracks.sort_by(|a, b| a.path.cmp(&b.path)); tracks.sort_by(|a, b| a.path.cmp(&b.path));
let all_tracks = tracks; let all_tracks = tracks;
let mut rows = Vec::new(); let mut rows = Vec::new();
rows.push(RightRow::AlbumHeader { rows.push(RightRow::AlbumHeader { name: all_files_label, is_artist: false });
name: "Все файлы".into(),
});
for i in 0..all_tracks.len() { for i in 0..all_tracks.len() {
rows.push(RightRow::Track { rows.push(RightRow::Track { index_in_library: i });
index_in_library: i,
});
} }
let groups = vec![ArtistView { let groups = vec![ArtistView { name: String::new(), rows }];
name: "Все файлы".into(),
rows,
}];
Self::finalize(all_tracks, groups, mode, filter) Self::finalize(all_tracks, groups, mode, filter)
} }
/// Общая инициализация ListState и позиций курсора.
fn finalize( fn finalize(
all_tracks: Vec<Track>, all_tracks: Vec<Track>,
groups: Vec<ArtistView>, groups: Vec<ArtistView>,
@@ -293,10 +248,7 @@ impl Library {
} }
fn first_track_row(&self) -> usize { fn first_track_row(&self) -> usize {
self.current_right_rows() first_track_row_in(self.artists.get(self.artist_selected))
.iter()
.position(|r| matches!(r, RightRow::Track { .. }))
.unwrap_or(0)
} }
pub fn sync_with_playing(&mut self, index_in_library: usize) { pub fn sync_with_playing(&mut self, index_in_library: usize) {
@@ -316,10 +268,6 @@ impl Library {
fn first_track_row_in(group: Option<&ArtistView>) -> usize { fn first_track_row_in(group: Option<&ArtistView>) -> usize {
group group
.and_then(|a| { .and_then(|a| a.rows.iter().position(|r| matches!(r, RightRow::Track { .. })))
a.rows
.iter()
.position(|r| matches!(r, RightRow::Track { .. }))
})
.unwrap_or(0) .unwrap_or(0)
} }

View File

@@ -1,5 +1,6 @@
mod app; mod app;
mod audio; mod audio;
mod i18n;
mod library; mod library;
mod state; mod state;
mod track; mod track;
@@ -26,10 +27,7 @@ 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 { if app.request_quit { app.on_exit(); break; }
app.on_exit();
break;
}
app.maybe_advance(); app.maybe_advance();
app.tick_status(); app.tick_status();
@@ -38,13 +36,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if event::poll(Duration::from_millis(50))? { if event::poll(Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? { if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Release { if key.kind == KeyEventKind::Release { continue; }
continue; if handle_key(key, &mut app) { app.on_exit(); break; }
}
if handle_key(key, &mut app) {
app.on_exit();
break;
}
} }
} }
} }
@@ -54,28 +47,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
fn handle_key(key: KeyEvent, app: &mut App) -> bool { fn handle_key(key: KeyEvent, app: &mut App) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); // 1. F1 — всегда
if key.code == KeyCode::F(1) { app.toggle_help(); return false; }
// 2. Модальные окна
if app.show_help { return handle_modal(key, app, true); }
if app.show_libs { return handle_modal(key, app, false); }
// 3. Командный режим
if app.input_mode == InputMode::Command { return handle_command(key, app); }
// 4. Поиск
if app.input_mode == InputMode::Search { return handle_search(key, app); }
// 5. Обычный режим
handle_normal(key, app)
}
if key.code == KeyCode::F(1) { fn handle_modal(key: KeyEvent, app: &mut App, is_help: bool) -> bool {
app.toggle_help();
return false;
}
if app.show_help {
if key.code == KeyCode::Esc { if key.code == KeyCode::Esc {
app.toggle_help(); if is_help { app.toggle_help(); } else { app.show_libs = false; }
}
return false;
} }
false
}
if app.show_libs { fn handle_command(key: KeyEvent, app: &mut App) -> bool {
if key.code == KeyCode::Esc {
app.show_libs = false;
}
return false;
}
if app.input_mode == InputMode::Command {
match key.code { match key.code {
KeyCode::Esc => app.cancel_command(), KeyCode::Esc => app.cancel_command(),
KeyCode::Enter => app.commit_command(), KeyCode::Enter => app.commit_command(),
@@ -83,35 +75,10 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool {
KeyCode::Char(c) => app.command_push(c), KeyCode::Char(c) => app.command_push(c),
_ => {} _ => {}
} }
return false; false
} }
if ctrl { fn handle_search(key: KeyEvent, app: &mut App) -> bool {
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 {
match key.code { match key.code {
KeyCode::Esc => app.cancel_search(), KeyCode::Esc => app.cancel_search(),
KeyCode::Enter => app.commit_search(), KeyCode::Enter => app.commit_search(),
@@ -119,64 +86,42 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool {
KeyCode::Char(c) => app.search_push(c), KeyCode::Char(c) => app.search_push(c),
_ => {} _ => {}
} }
return false; false
} }
if key.code == KeyCode::Delete { fn handle_normal(key: KeyEvent, app: &mut App) -> bool {
app.delete_selected(); let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
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 key.code == KeyCode::Delete { app.delete_selected(); return false; }
match key.code { match key.code {
KeyCode::Char(':') => { KeyCode::Char(':') => { app.start_command(); return false; }
app.start_command(); KeyCode::Char('+') | KeyCode::Char('=') => { app.volume_up(); return false; }
return false; KeyCode::Char('-') | KeyCode::Char('_') => { app.volume_down(); return false; }
} KeyCode::Up => { app.up(); return false; }
KeyCode::Char('+') | KeyCode::Char('=') => { KeyCode::Down => { app.down(); return false; }
app.volume_up(); KeyCode::Left => { app.seek(-5); return false; }
return false; KeyCode::Right => { app.seek(5); return false; }
} KeyCode::Enter => { app.play_selected(); return false; }
KeyCode::Char('-') | KeyCode::Char('_') => { KeyCode::Char(' ') => { app.toggle_pause(); return false; }
app.volume_down(); KeyCode::Tab => { app.toggle_panel(); return false; }
return false;
}
KeyCode::Up => {
app.up();
return false;
}
KeyCode::Down => {
app.down();
return false;
}
KeyCode::Left => {
app.seek(-5);
return false;
}
KeyCode::Right => {
app.seek(5);
return false;
}
KeyCode::Enter => {
app.play_selected();
return false;
}
KeyCode::Char(' ') => {
app.toggle_pause();
return false;
}
KeyCode::Tab => {
app.toggle_panel();
return false;
}
_ => {} _ => {}
} }
if let KeyCode::Char(c) = key.code { if let KeyCode::Char(c) = key.code {
match c { match c {
'1'..='9' => { '1'..='9' => { app.seek_percent((c as u8 - b'0') * 10); }
let pct = (c as u8 - b'0') * 10;
app.seek_percent(pct);
}
_ => match c.to_ascii_lowercase() { _ => match c.to_ascii_lowercase() {
'q' => return true, 'q' => return true,
'w' => app.up(), 'w' => app.up(),
@@ -186,13 +131,7 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool {
'z' => app.stop(), 'z' => app.stop(),
't' => app.toggle_sort(), 't' => app.toggle_sort(),
'f' => app.jump_to_playing(), 'f' => app.jump_to_playing(),
'g' => { 'g' => if c == 'G' { app.goto_end(); } else { app.goto_start(); },
if c == 'G' {
app.goto_end();
} else {
app.goto_start();
}
}
'r' => app.cycle_repeat(), 'r' => app.cycle_repeat(),
'x' => app.toggle_shuffle(), 'x' => app.toggle_shuffle(),
'/' => app.start_search(), '/' => app.start_search(),

View File

@@ -15,9 +15,9 @@ pub struct SavedState {
pub shuffle: Option<bool>, pub shuffle: Option<bool>,
pub last_playing_path: Option<String>, pub last_playing_path: Option<String>,
pub last_playing_pos_secs: Option<u64>, pub last_playing_pos_secs: Option<u64>,
/// Единый список источников: папки и плейлисты.
#[serde(default)] #[serde(default)]
pub sources: Vec<String>, pub sources: Vec<String>,
pub lang: Option<String>,
} }
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)] #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)]
@@ -41,7 +41,13 @@ pub fn load() -> SavedState {
return SavedState::default(); return SavedState::default();
}; };
match fs::read_to_string(&path) { match fs::read_to_string(&path) {
Ok(text) => toml::from_str(&text).unwrap_or_default(), Ok(text) => match toml::from_str(&text) {
Ok(s) => s,
Err(e) => {
eprintln!("[state] TOML error in {:?}: {}", path, e);
SavedState::default()
}
},
Err(_) => SavedState::default(), Err(_) => SavedState::default(),
} }
} }
@@ -54,7 +60,11 @@ pub fn save(state: &SavedState) {
let _ = fs::create_dir_all(parent); let _ = fs::create_dir_all(parent);
} }
if let Ok(text) = toml::to_string_pretty(state) { if let Ok(text) = toml::to_string_pretty(state) {
let _ = fs::write(path, text); // Атомарная запись: tmp + rename.
let tmp = path.with_extension("toml.tmp");
if fs::write(&tmp, text).is_ok() {
let _ = fs::rename(&tmp, &path);
}
} }
} }

View File

@@ -1,3 +1,4 @@
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
@@ -70,6 +71,12 @@ impl Track {
} }
} }
/// Быстрая проверка расширения без аллокаций.
fn is_audio_ext(ext: &str) -> bool {
const EXTS: &[&str] = &["mp3", "flac", "ogg", "wav", "m4a"];
EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e))
}
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();
@@ -81,10 +88,7 @@ pub fn scan_dir(dir: &str) -> Vec<Track> {
let path = entry.path(); let path = entry.path();
if path.is_file() { if path.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) { if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if matches!( if is_audio_ext(ext) {
ext.to_lowercase().as_str(),
"mp3" | "flac" | "ogg" | "wav" | "m4a"
) {
tracks.push(Track::from_path(&path.to_path_buf())); tracks.push(Track::from_path(&path.to_path_buf()));
} }
} }
@@ -93,7 +97,6 @@ pub fn scan_dir(dir: &str) -> Vec<Track> {
tracks tracks
} }
/// Универсальный сканер источника: папка или файл плейлиста.
pub fn scan_source(path: &Path) -> Vec<Track> { pub fn scan_source(path: &Path) -> Vec<Track> {
if path.is_dir() { if path.is_dir() {
scan_dir(&path.to_string_lossy()) scan_dir(&path.to_string_lossy())
@@ -107,7 +110,7 @@ pub fn scan_source(path: &Path) -> Vec<Track> {
} }
} }
// ── Плейлисты (M3U / M3U8 / PLS) ─────────────────────────────── // ── Плейлисты ──────────────────────────────────────────────────
pub fn read_playlist(path: &Path) -> Vec<PathBuf> { pub fn read_playlist(path: &Path) -> Vec<PathBuf> {
let bytes = match std::fs::read(path) { let bytes = match std::fs::read(path) {
@@ -174,29 +177,30 @@ fn resolve_path(s: &str, parent: &Path) -> PathBuf {
} }
} }
/// Запись через BufWriter — без промежуточной String на весь файл.
pub fn save_playlist_m3u(path: &Path, tracks: &[Track]) -> std::io::Result<()> { pub fn save_playlist_m3u(path: &Path, tracks: &[Track]) -> std::io::Result<()> {
let mut out = String::from("#EXTM3U\n"); let file = std::fs::File::create(path)?;
let mut w = BufWriter::new(file);
writeln!(w, "#EXTM3U")?;
for t in tracks { for t in tracks {
let dur = t.duration.as_secs(); let dur = t.duration.as_secs();
out.push_str(&format!( writeln!(w, "#EXTINF:{},{} - {}", dur, t.artist, t.title)?;
"#EXTINF:{},{} - {}\n{}\n", writeln!(w, "{}", t.path.display())?;
dur,
t.artist,
t.title,
t.path.display()
));
} }
std::fs::write(path, out) Ok(())
} }
pub fn save_playlist_pls(path: &Path, tracks: &[Track]) -> std::io::Result<()> { pub fn save_playlist_pls(path: &Path, tracks: &[Track]) -> std::io::Result<()> {
let mut out = String::from("[playlist]\n"); let file = std::fs::File::create(path)?;
let mut w = BufWriter::new(file);
writeln!(w, "[playlist]")?;
for (i, t) in tracks.iter().enumerate() { for (i, t) in tracks.iter().enumerate() {
let num = i + 1; let n = i + 1;
out.push_str(&format!("File{}={}\n", num, t.path.display())); writeln!(w, "File{}={}", n, t.path.display())?;
out.push_str(&format!("Title{}={} - {}\n", num, t.artist, t.title)); writeln!(w, "Title{}={} - {}", n, t.artist, t.title)?;
out.push_str(&format!("Length{}={}\n", num, t.duration.as_secs())); writeln!(w, "Length{}={}", n, t.duration.as_secs())?;
} }
out.push_str(&format!("NumberOfEntries={}\nVersion=2\n", tracks.len())); writeln!(w, "NumberOfEntries={}", tracks.len())?;
std::fs::write(path, out) writeln!(w, "Version=2")?;
Ok(())
} }

357
src/ui.rs
View File

@@ -7,13 +7,15 @@ use crate::app::{App, InputMode, Panel};
use crate::library::{RightRow, SortMode}; use crate::library::{RightRow, SortMode};
use crate::state::RepeatMode; use crate::state::RepeatMode;
const ACCENT: Color = Color::Rgb(100, 149, 237);
const HEADER: Color = Color::Yellow;
const DIM: Color = Color::DarkGray;
const BORDER_AND_MARKER: u16 = 6; // рамка (2) + "> " (2) + "[ " (2)
pub fn draw(f: &mut Frame, app: &mut App) { pub fn draw(f: &mut Frame, app: &mut App) {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([Constraint::Min(1), Constraint::Length(3)])
Constraint::Min(1),
Constraint::Length(3),
])
.split(f.area()); .split(f.area());
let top = Layout::default() let top = Layout::default()
@@ -23,10 +25,7 @@ pub fn draw(f: &mut Frame, app: &mut App) {
let left_col = Layout::default() let left_col = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([Constraint::Min(1), Constraint::Length(8)])
Constraint::Min(1),
Constraint::Length(8),
])
.split(top[0]); .split(top[0]);
draw_left_panel(f, app, left_col[0]); draw_left_panel(f, app, left_col[0]);
@@ -35,10 +34,10 @@ pub fn draw(f: &mut Frame, app: &mut App) {
draw_status(f, app, chunks[1]); draw_status(f, app, chunks[1]);
if app.show_help { if app.show_help {
draw_help(f, f.area()); draw_help(f, app, f.area());
} }
if app.show_libs { if app.show_libs {
draw_dirs(f, app, f.area()); draw_libs(f, app, f.area());
} }
} }
@@ -49,7 +48,7 @@ fn draw_left_panel(f: &mut Frame, app: &mut App, area: Rect) {
.library .library
.artists .artists
.iter() .iter()
.map(|a| ListItem::new(a.name.clone())) .map(|a| ListItem::new(app.locales.display_artist(&a.name).to_string()))
.collect(); .collect();
let highlight = if app.active_panel == Panel::Left { let highlight = if app.active_panel == Panel::Left {
@@ -66,12 +65,11 @@ 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 { let title_text = match app.library.sort_mode {
SortMode::ByArtist => " Исполнители ", SortMode::ByArtist => &app.locales.t.artists,
SortMode::ByAlbum => " Альбомы ", SortMode::ByAlbum => &app.locales.t.albums,
SortMode::ByFilename => " Источник ", SortMode::ByFilename => &app.locales.t.source,
}; };
let album_color = Color::Rgb(100, 149, 237); let title = Span::styled(format!(" {} ", title_text), Style::default().fg(ACCENT));
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(title)) .block(Block::default().borders(Borders::ALL).title(title))
@@ -89,53 +87,50 @@ fn fmt_dur(d: std::time::Duration) -> String {
} }
} }
fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) { fn make_album_header(
f.render_widget(Clear, area); app: &App,
idx: usize,
name: &str,
is_artist: bool,
rows: &[RightRow],
area_width: u16,
) -> ListItem<'static> {
let display_name = if is_artist {
app.locales.display_artist(name).into_owned()
} else {
app.locales.display_album(name).into_owned()
};
let rows = app.library.current_right_rows(); let mut total = std::time::Duration::ZERO;
let items: Vec<ListItem> = rows
.iter()
.enumerate()
.map(|(idx, row)| match row {
RightRow::AlbumHeader { name } => {
let mut album_total = std::time::Duration::ZERO;
let mut i = idx + 1; let mut i = idx + 1;
while i < rows.len() { while i < rows.len() {
if let RightRow::Track { index_in_library } = rows[i] { if let RightRow::Track { index_in_library } = rows[i] {
if let Some(t) = app.library.track(index_in_library) { if let Some(t) = app.library.track(index_in_library) {
album_total += t.duration; total += t.duration;
} }
i += 1; i += 1;
} else { } else {
break; break;
} }
} }
let total_str = fmt_dur(album_total); let total_str = fmt_dur(total);
let inner_width = area_width.saturating_sub(BORDER_AND_MARKER) 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() + 4) .saturating_sub(display_name.len() + total_str.len() + 4)
.max(1); .max(1);
let line = format!("{} ] {} {}", name, "─".repeat(pad), total_str); let line = format!("{} ] {} {}", display_name, "─".repeat(pad), total_str);
let album_color = Color::Rgb(100, 149, 237);
ListItem::new(Line::from(vec![ ListItem::new(Line::from(vec![
Span::styled("[ ", Style::default().fg(album_color)), Span::styled("[ ", Style::default().fg(ACCENT)),
Span::styled( Span::styled(line, Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)),
line,
Style::default()
.fg(album_color)
.add_modifier(Modifier::BOLD),
),
])) ]))
} }
RightRow::Track { index_in_library } => {
let is_playing = Some(*index_in_library) == app.playing_index;
let marker = if is_playing { "♪ " } else { " " };
let (title, num, dur) = match app.library.track(*index_in_library) { fn make_track_item(app: &App, index_in_library: usize, area_width: u16) -> ListItem<'static> {
let is_playing = Some(index_in_library) == app.playing_index;
let marker = if is_playing { "* " } else { " " };
let (title, num, dur) = match app.library.track(index_in_library) {
Some(t) => ( Some(t) => (
t.title.clone(), t.title.clone(),
t.track_number t.track_number
@@ -147,18 +142,15 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
}; };
let style = if is_playing { let style = if is_playing {
Style::default().fg(Color::Green) Style::default().fg(Color::Rgb(173, 255, 47))
} else { } else {
Style::default() Style::default()
}; };
let left = format!(" {}{}{}", marker, num, title); let left = format!(" {}{}{}", marker, num, title);
let inner_width = area.width.saturating_sub(4) as usize; 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 let pad = inner_width
.saturating_sub(left_len + dur_len) .saturating_sub(left.chars().count() + dur.chars().count())
.max(1); .max(1);
ListItem::new(Line::from(vec![ ListItem::new(Line::from(vec![
@@ -166,6 +158,22 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
Span::raw(" ".repeat(pad)), Span::raw(" ".repeat(pad)),
Span::styled(dur, style), Span::styled(dur, style),
])) ]))
}
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 items: Vec<ListItem> = rows
.iter()
.enumerate()
.map(|(idx, row)| match row {
RightRow::AlbumHeader { name, is_artist } => {
make_album_header(app, idx, name, *is_artist, rows, area.width)
}
RightRow::Track { index_in_library } => {
make_track_item(app, *index_in_library, area.width)
} }
}) })
.collect(); .collect();
@@ -174,40 +182,34 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
.library .library
.artists .artists
.get(app.library.artist_selected) .get(app.library.artist_selected)
.map(|a| a.name.as_str()) .map(|a| app.locales.display_artist(&a.name).to_string())
.unwrap_or("—"); .unwrap_or_else(|| "—".to_string());
let t = &app.locales.t;
let title_text = match app.library.sort_mode { let title_text = match app.library.sort_mode {
SortMode::ByArtist => { SortMode::ByArtist => {
if app.library.filter.is_empty() { if app.library.filter.is_empty() {
format!(" Альбомы и треки - {} ", selected_name) format!(" {} - {} ", t.albums, selected_name)
} else { } else {
format!( format!(" {} - {} [{}] ", t.albums, selected_name, app.library.filter)
" Альбомы и треки - {} [{}] ",
selected_name, app.library.filter
)
} }
} }
SortMode::ByAlbum => { SortMode::ByAlbum => {
if app.library.filter.is_empty() { if app.library.filter.is_empty() {
format!(" Исполнители и треки - {} ", selected_name) format!(" {} - {} ", t.artists, selected_name)
} else { } else {
format!( format!(" {} - {} [{}] ", t.artists, selected_name, app.library.filter)
" Исполнители и треки - {} [{}] ",
selected_name, app.library.filter
)
} }
} }
SortMode::ByFilename => { SortMode::ByFilename => {
if app.library.filter.is_empty() { if app.library.filter.is_empty() {
" Альбомы и треки ".into() format!(" {} ", t.source)
} else { } else {
format!(" Альбомы и треки [{}] ", app.library.filter) format!(" {} [{}] ", t.source, app.library.filter)
} }
} }
}; };
let album_color = Color::Rgb(100, 149, 237); let title = Span::styled(title_text, Style::default().fg(ACCENT));
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)
@@ -237,7 +239,6 @@ fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) {
} }
let num_bars = (inner_width / 2).max(4); let num_bars = (inner_width / 2).max(4);
let bars_data = app.player.spectrum.bars(); let bars_data = app.player.spectrum.bars();
let step = (bars_data.len() / num_bars).max(1); let step = (bars_data.len() / num_bars).max(1);
@@ -258,7 +259,7 @@ fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) {
.map(|&v| { .map(|&v| {
Bar::default() Bar::default()
.value((v * 100.0) as u64) .value((v * 100.0) as u64)
.style(Style::default().fg(Color::Rgb(100, 149, 237))) .style(Style::default().fg(ACCENT))
}) })
.collect(); .collect();
@@ -271,13 +272,20 @@ fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) {
.block( .block(
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.title(" Эквалайзер ") .title(format!(" {} ", app.locales.t.equalizer))
.title_style(Style::default().fg(Color::Rgb(100, 149, 237))), .title_style(Style::default().fg(ACCENT)),
); );
f.render_widget(chart, area); f.render_widget(chart, area);
} }
fn bordered(title: impl Into<Line<'static>>) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.title(title)
.title_style(Style::default().fg(ACCENT))
}
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)
@@ -289,22 +297,17 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) {
]) ])
.split(area); .split(area);
let t = &app.locales.t;
let (status_text, status_title) = match app.input_mode { let (status_text, status_title) = match app.input_mode {
InputMode::Search => (format!(">{}", app.search_buffer), " Поиск "), InputMode::Search => (format!(">{}", app.search_buffer), format!(" {} ", t.search)),
InputMode::Command => (format!(":{}", app.command_buffer), " Команда "), InputMode::Command => (format!(":{}", app.command_buffer), format!(" {} ", t.command)),
InputMode::Normal => { InputMode::Normal => {
let prefix = if app.player.is_paused() { "|| " } else { " " }; let prefix = if app.player.is_paused() { "|| " } else { " " };
(format!("{}{}", prefix, app.status), " Статус ") (format!("{}{}", prefix, app.status), format!(" {} ", t.status))
} }
}; };
let status = Paragraph::new(status_text) let status = Paragraph::new(status_text).block(bordered(Line::from(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 (pos, total) = app.progress();
@@ -317,12 +320,7 @@ 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)
.block( .block(bordered(Line::from(format!(" {} ", t.time))));
Block::default()
.borders(Borders::ALL)
.title(" Время ")
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
);
f.render_widget(time, inner[1]); f.render_widget(time, inner[1]);
let mode_str = match (app.repeat, app.shuffle) { let mode_str = match (app.repeat, app.shuffle) {
@@ -335,56 +333,52 @@ 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(bordered(Line::from(format!(" {} ", t.mode))));
Block::default()
.borders(Borders::ALL)
.title(" Режим ")
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
);
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!("{}: {}%", t.volume, (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(bordered(Line::from(format!(" {} ", t.volume))));
Block::default()
.borders(Borders::ALL)
.title(" Звук ")
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
);
f.render_widget(vol, inner[3]); f.render_widget(vol, inner[3]);
} }
fn draw_dirs(f: &mut Frame, app: &App, screen: Rect) { /// Общий хелпер для модальных окон.
let count = app.sources().len(); fn draw_modal(f: &mut Frame, screen: Rect, title: String, lines: Vec<Line<'static>>, width: u16) {
let content_h = (lines.len() + 2) as u16;
// Строки: заголовок (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 max_h = screen.height.saturating_sub(4);
let height = desired_height.min(max_h).max(5); let height = content_h.min(max_h).max(5);
let width = width.min(screen.width.saturating_sub(4));
let x = (screen.width.saturating_sub(width)) / 2; let x = (screen.width.saturating_sub(width)) / 2;
let y = (screen.height.saturating_sub(height)) / 2; let y = (screen.height.saturating_sub(height)) / 2;
let area = Rect::new(x, y, width, height); let area = Rect::new(x, y, width, height);
f.render_widget(Clear, area); f.render_widget(Clear, area);
let title_color = Style::default().fg(Color::Rgb(100, 149, 237)); let para = Paragraph::new(lines)
let header = Style::default() .block(
.fg(Color::Yellow) Block::default()
.add_modifier(Modifier::BOLD); .borders(Borders::ALL)
let dim = Style::default().fg(Color::DarkGray); .border_style(Style::default().fg(ACCENT))
.title(title),
)
.wrap(Wrap { trim: false });
let mut lines = vec![ f.render_widget(para, area);
}
fn draw_libs(f: &mut Frame, app: &App, screen: Rect) {
let t = &app.locales.t;
let header = Style::default().fg(HEADER).add_modifier(Modifier::BOLD);
let dim = Style::default().fg(DIM);
let mut lines: Vec<Line<'static>> = vec![
Line::from(Span::styled( Line::from(Span::styled(
" Источники библиотеки ", format!(" {} ", t.library),
title_color.add_modifier(Modifier::BOLD), Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
)), )),
Line::from(""), Line::from(""),
Line::from(Span::styled(" Список", header)), Line::from(Span::styled(format!(" {}", t.sources), header)),
]; ];
for (i, src) in app.sources().iter().enumerate() { for (i, src) in app.sources().iter().enumerate() {
@@ -396,96 +390,67 @@ fn draw_dirs(f: &mut Frame, app: &App, screen: Rect) {
lines.push(Line::from("")); lines.push(Line::from(""));
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
" :rm <N> — удалить, Esc — закрыть", format!(" :rm <N> — {}, Esc — {}", t.delete, t.close),
dim, dim,
))); )));
let para = Paragraph::new(lines) draw_modal(f, screen, format!(" {} ", t.library), lines, 70);
.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) { fn draw_help(f: &mut Frame, app: &App, screen: Rect) {
let width = 62u16.min(screen.width.saturating_sub(4)); let t = &app.locales.t;
let height = 44u16.min(screen.height.saturating_sub(4)); let header = Style::default().fg(HEADER).add_modifier(Modifier::BOLD);
let x = (screen.width.saturating_sub(width)) / 2; let lines: Vec<Line<'static>> = vec![
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( Line::from(Span::styled(
" Управление плеером ", t.help_title.clone(),
title_color.add_modifier(Modifier::BOLD), Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
)), )),
Line::from(""), Line::from(""),
Line::from(Span::styled(" Навигация", header)), Line::from(Span::styled(t.help_nav.clone(), header)),
Line::from(" w / ↑ вверх по списку"), Line::from(t.help_line_up.clone()),
Line::from(" s / ↓ вниз по списку"), Line::from(t.help_line_down.clone()),
Line::from(" Tab переключить панель"), Line::from(t.help_line_tab.clone()),
Line::from(" g / G в начало / в конец списка"), Line::from(t.help_line_goto.clone()),
Line::from(" f к играющему треку"), Line::from(t.help_line_f.clone()),
Line::from(""), Line::from(""),
Line::from(Span::styled(" Воспроизведение", header)), Line::from(Span::styled(t.help_playback.clone(), header)),
Line::from(" Enter играть выделенный трек"), Line::from(t.help_line_enter.clone()),
Line::from(" Space пауза / продолжить"), Line::from(t.help_line_space.clone()),
Line::from(" z стоп"), Line::from(t.help_line_z.clone()),
Line::from(" Ctrl+→ / Ctrl+d следующий трек"), Line::from(t.help_line_ctrl_right.clone()),
Line::from(" Ctrl+← / Ctrl+a предыдущий трек"), Line::from(t.help_line_ctrl_left.clone()),
Line::from(" r repeat: off → all → one"), Line::from(t.help_line_r.clone()),
Line::from(" x shuffle вкл/выкл"), Line::from(t.help_line_x.clone()),
Line::from(""), Line::from(""),
Line::from(Span::styled(" Перемотка", header)), Line::from(Span::styled(t.help_seeking.clone(), header)),
Line::from(" a / ← −5 секунд"), Line::from(t.help_line_a.clone()),
Line::from(" d / → +5 секунд"), Line::from(t.help_line_d.clone()),
Line::from(" 1..9 перейти к 10%..90% трека"), Line::from(t.help_line_1_9.clone()),
Line::from(""), Line::from(""),
Line::from(Span::styled(" Команды", header)), Line::from(Span::styled(t.help_commands.clone(), header)),
Line::from(" :add <путь> добавить в сессию"), Line::from(t.help_line_add.clone()),
Line::from(" :load <путь> заменить сессию"), Line::from(t.help_line_load.clone()),
Line::from(" :reload пересканировать всё"), Line::from(t.help_line_reload.clone()),
Line::from(" :save сохранить библиотеку"), Line::from(t.help_line_save.clone()),
Line::from(" :savepl <файл> экспорт в M3U/PLS"), Line::from(t.help_line_savepl.clone()),
Line::from(" :libs список источников"), Line::from(t.help_line_libs.clone()),
Line::from(" :rm <N> удалить источник"), Line::from(t.help_line_rm.clone()),
Line::from(" :clear очистить источники"), Line::from(t.help_line_clear.clone()),
Line::from(" :help эта справка"), Line::from(t.help_line_lang.clone()),
Line::from(" :q выход"), Line::from(t.help_line_help.clone()),
Line::from(t.help_line_q.clone()),
Line::from(""), Line::from(""),
Line::from(Span::styled(" Звук и прочее", header)), Line::from(Span::styled(t.help_audio_misc.clone(), header)),
Line::from(" + / - громкость"), Line::from(t.help_line_vol.clone()),
Line::from(" / поиск"), Line::from(t.help_line_search.clone()),
Line::from(" Del удалить из списка"), Line::from(t.help_line_del.clone()),
Line::from(" t сортировка: артист → альбом → файл"), Line::from(t.help_line_t.clone()),
Line::from(" F1 эта справка"), Line::from(t.help_line_f1.clone()),
Line::from(" q / Esc выход"), Line::from(t.help_line_quit.clone()),
Line::from(""), Line::from(""),
Line::from(Span::styled( Line::from(Span::styled(t.help_close.clone(), Style::default().fg(DIM))),
" F1 или Esc - закрыть ",
Style::default().fg(Color::DarkGray),
)),
]; ];
let help = Paragraph::new(help_text).block( draw_modal(f, screen, format!(" {} ", t.help), lines, 62);
Block::default()
.borders(Borders::ALL)
.border_style(title_color)
.title(" Помощь "),
);
f.render_widget(help, area);
} }