From e870fb18d19be549088de681793b7e27155945d5 Mon Sep 17 00:00:00 2001 From: AlexL Date: Sat, 24 Oct 2026 15:27:17 +0200 Subject: [PATCH] =?UTF-8?q?=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BE=D0=BA=20=D0=B8=20=D0=BE=D0=BF=D1=82=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D0=B7=D0=B0=D1=86=D0=B8,=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 12 +- Cargo.toml | 7 +- build.bat | 1 + locales/en_US.toml | 117 +++++++++++ locales/ru_RU.toml | 116 +++++++++++ src/app.rs | 500 ++++++++++++++++----------------------------- src/audio.rs | 38 +++- src/i18n.rs | 420 +++++++++++++++++++++++++++++++++++++ src/library.rs | 96 ++------- src/main.rs | 189 ++++++----------- src/state.rs | 16 +- src/track.rs | 48 +++-- src/ui.rs | 417 +++++++++++++++++-------------------- 13 files changed, 1190 insertions(+), 787 deletions(-) create mode 100644 build.bat create mode 100644 locales/en_US.toml create mode 100644 locales/ru_RU.toml create mode 100644 src/i18n.rs diff --git a/Cargo.lock b/Cargo.lock index d6ce65b..4b2d5fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -981,7 +981,7 @@ dependencies = [ [[package]] name = "rumus" -version = "0.5.0" +version = "0.5.1" dependencies = [ "bincode", "crossterm", @@ -992,6 +992,7 @@ dependencies = [ "rodio", "rustfft", "serde", + "sys-locale", "toml", "walkdir", ] @@ -1401,6 +1402,15 @@ dependencies = [ "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]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index 506443b..9d57929 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "rumus" -version = "0.5.0" -edition = "2024" +version = "0.5.1" +edition = "2021" [dependencies] ratatui = "0.29" @@ -14,4 +14,5 @@ serde = { version = "1", features = ["derive"] } toml = "0.8" rustfft = "6" bincode = "1.3" -encoding_rs = "0.8" \ No newline at end of file +encoding_rs = "0.8" +sys-locale = "0.3" \ No newline at end of file diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..7f1b114 --- /dev/null +++ b/build.bat @@ -0,0 +1 @@ +cargo build --release \ No newline at end of file diff --git a/locales/en_US.toml b/locales/en_US.toml new file mode 100644 index 0000000..6f9cd82 --- /dev/null +++ b/locales/en_US.toml @@ -0,0 +1,117 @@ +# ── Панели ───────────────────────────────────────────────────── +"menu.artists" = "Artists" +"menu.albums" = "Albums" +"menu.source" = "Source" +"menu.all_files" = "All files" + +# ── Статус-бар ───────────────────────────────────────────────── +"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" + +# ── Окно :libs ───────────────────────────────────────────────── +"libs.sources" = "Sources" +"libs.delete" = "Delete" +"libs.close" = "Close" + +# ── Справка ──────────────────────────────────────────────────── +"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 Add to session" +"help.line_load" = " :load Replace session" +"help.line_reload" = " :reload Rescan everything" +"help.line_save" = " :save Save library" +"help.line_savepl" = " :savepl Export to M3U/PLS" +"help.line_libs" = " :libs List sources" +"help.line_rm" = " :rm Remove source" +"help.line_clear" = " :clear Clear sources" +"help.line_lang" = " :lang 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" + +# ── Динамические статусы ─────────────────────────────────────── +"msg.restored" = "Restored" +"msg.cancelled" = "Cancelled" +"msg.usage_add" = "Usage: :add " +"msg.usage_load" = "Usage: :load " +"msg.usage_rm" = "Usage: :rm " +"msg.usage_savepl" = "Usage: :savepl " +"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" \ No newline at end of file diff --git a/locales/ru_RU.toml b/locales/ru_RU.toml new file mode 100644 index 0000000..99c343f --- /dev/null +++ b/locales/ru_RU.toml @@ -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 удалить источник" +"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" = "Неизвестный альбом" \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 413b108..24456db 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,22 +3,16 @@ use std::path::PathBuf; use std::time::{Duration, Instant}; use crate::audio::AudioPlayer; +use crate::i18n::Locales; use crate::library::{Library, RightRow, SortMode}; use crate::state::{RepeatMode, SavedState}; use crate::track::{scan_source, Track}; #[derive(Clone, Copy, PartialEq)] -pub enum Panel { - Left, - Right, -} +pub enum Panel { Left, Right } #[derive(Clone, Copy, PartialEq)] -pub enum InputMode { - Normal, - Search, - Command, -} +pub enum InputMode { Normal, Search, Command } pub struct App { pub library: Library, @@ -30,9 +24,9 @@ pub struct App { pub search_buffer: String, pub command_buffer: String, pub request_quit: bool, + pub locales: Locales, raw_tracks: Vec, default_dir: String, - /// Единый список источников: папки и плейлисты. sources: Vec, deleted_paths: HashSet, pub repeat: RepeatMode, @@ -40,6 +34,7 @@ pub struct App { pub show_help: bool, pub show_libs: bool, status_set_at: Instant, + shuffle_seed: u64, } const STATUS_RESET_AFTER: Duration = Duration::from_secs(2); @@ -47,17 +42,25 @@ const STATUS_RESET_AFTER: Duration = Duration::from_secs(2); impl App { pub fn new(music_dir: &str) -> Result> { let saved = crate::state::load(); + let mut locales = Locales::load_all(); + if let Some(code) = &saved.lang { + if locales.maps.contains_key(code) { + locales.set_lang(code); + } + } + let all_files = locales.t.all_files.clone(); let mut app = Self { - library: Library::build(Vec::new(), SortMode::ByArtist), + library: Library::build(Vec::new(), SortMode::ByArtist, all_files.clone()), player: AudioPlayer::new()?, - status: "F1 - помощь, : - команда".into(), + status: String::new(), playing_index: None, active_panel: Panel::Left, input_mode: InputMode::Normal, search_buffer: String::new(), command_buffer: String::new(), request_quit: false, + locales, raw_tracks: Vec::new(), default_dir: music_dir.to_string(), sources: saved.sources.clone(), @@ -67,22 +70,25 @@ impl App { show_help: false, show_libs: false, status_set_at: Instant::now(), + shuffle_seed: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(12345), }; - // Если sources пуст — первый запуск, добавляем базовую директорию + app.status = app.locales.t.help.clone(); + 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)); + tracks.extend(scan_source(&PathBuf::from(src))); } crate::state::save_cache(&tracks); tracks @@ -91,18 +97,12 @@ impl App { app.rebuild_library(); - if let Some(vol) = saved.volume { - app.player.set_volume(vol); - } - if let Some(rep) = saved.repeat { - app.repeat = rep; - } - if let Some(sh) = saved.shuffle { - app.shuffle = sh; - } + if let Some(vol) = saved.volume { app.player.set_volume(vol); } + if let Some(rep) = saved.repeat { app.repeat = rep; } + if let Some(sh) = saved.shuffle { app.shuffle = sh; } if let Some(mode) = saved.sort_mode { if mode != SortMode::ByArtist { - app.library = Library::build(app.raw_tracks.clone(), mode); + app.library = Library::build(app.raw_tracks.clone(), mode, all_files); } } @@ -117,18 +117,14 @@ impl App { 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(), + Some(t) => format!("{} — {}", self.locales.display_artist(&t.artist), t.title), + None => self.locales.t.help.clone(), } } pub fn tick_status(&mut self) { - if self.input_mode != InputMode::Normal { - return; - } - if self.status_set_at.elapsed() < STATUS_RESET_AFTER { - return; - } + 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; @@ -136,55 +132,40 @@ impl App { } 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 Some(path_str) = &saved.last_playing_path else { return; }; + let target_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)); + let msg = format!("{}: {} — {}", + self.locales.t.restored, + self.locales.display_artist(&track.artist), + track.title); + self.set_status(msg); } } } - pub fn toggle_help(&mut self) { - self.show_help = !self.show_help; - } - - // ── Командный режим (:) ──────────────────────────────────── + 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()); + self.set_status(self.locales.t.cancelled.clone()); } - 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(); @@ -194,10 +175,7 @@ impl App { let cmd = self.command_buffer.trim().to_string(); self.input_mode = InputMode::Normal; self.command_buffer.clear(); - - if cmd.is_empty() { - return; - } + if cmd.is_empty() { return; } let mut parts = cmd.splitn(2, char::is_whitespace); let name = parts.next().unwrap_or(""); @@ -209,47 +187,51 @@ impl App { "reload" => self.reload_library(), "save" => self.save_now(), "savepl" => self.save_playlist(args), - "libs" => { - self.show_libs = true; - self.set_status("Источники библиотеки".into()); - } + "libs" => { self.show_libs = true; self.set_status(self.locales.t.library.clone()); } "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)); - } + "lang" => self.set_lang(args), + "help" => { self.show_help = true; self.set_status(self.locales.t.help.clone()); } + "q" | "quit" => { self.request_quit = true; } + _ => { let msg = format!("{}: {}", self.locales.t.unknown_command, name); self.set_status(msg); } + } + } + + fn set_lang(&mut self, arg: &str) { + if arg.is_empty() { + let list = self.locales.available().join(", "); + let msg = format!("{}: {}", self.locales.t.languages, list); + self.set_status(msg); + return; + } + if self.locales.set_lang(arg) { + let msg = format!("{}: {}", self.locales.t.language, arg); + self.set_status(msg); + } else { + let msg = format!("{}: {}", self.locales.t.not_found, arg); + self.set_status(msg); } } fn add_source(&mut self, raw_path: &str) { if raw_path.is_empty() { - self.set_status("Использование: :add <путь>".into()); + self.set_status(self.locales.t.usage_add.clone()); return; } - let expanded = expand_tilde(raw_path); let path = PathBuf::from(&expanded); - if !path.exists() { - self.set_status(format!("Путь не существует: {}", expanded)); + let msg = format!("{}: {}", self.locales.t.path_not_exist, expanded); + self.set_status(msg); return; } - - self.set_status(format!("Сканирование {}...", expanded)); + self.set_status(format!("{} {}...", self.locales.t.scanning, expanded)); let new_tracks = scan_source(&path); - if new_tracks.is_empty() { - self.set_status(format!("Нет треков в {}", expanded)); + let msg = format!("{} {}", self.locales.t.no_tracks_in, expanded); + self.set_status(msg); return; } - let existing: HashSet = self.raw_tracks.iter().map(|t| t.path.clone()).collect(); let mut added = 0; for track in new_tracks { @@ -258,70 +240,58 @@ impl App { added += 1; } } - - if !self.sources.contains(&expanded) { - self.sources.push(expanded.clone()); - } - + 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)); + self.set_status(format!("{} {} {}", self.locales.t.added, added, self.locales.t.tracks)); } fn load_source(&mut self, raw_path: &str) { if raw_path.is_empty() { - self.set_status("Использование: :load <путь>".into()); + self.set_status(self.locales.t.usage_load.clone()); return; } - let expanded = expand_tilde(raw_path); let path = PathBuf::from(&expanded); - if !path.exists() { - self.set_status(format!("Путь не существует: {}", expanded)); + let msg = format!("{}: {}", self.locales.t.path_not_exist, expanded); + self.set_status(msg); return; } - - self.set_status(format!("Загрузка {}...", expanded)); + self.set_status(format!("{} {}...", self.locales.t.loading, expanded)); let new_tracks = scan_source(&path); - if new_tracks.is_empty() { - self.set_status(format!("Нет треков в {}", expanded)); + let msg = format!("{} {}", self.locales.t.no_tracks_in, expanded); + self.set_status(msg); 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)); + self.set_status(format!("{} {} {}", self.locales.t.loaded, self.raw_tracks.len(), self.locales.t.tracks)); } fn remove_source(&mut self, arg: &str) { let idx: usize = match arg.parse() { Ok(i) => i, - Err(_) => { - self.set_status("Использование: :rm <индекс>".into()); - return; - } + Err(_) => { self.set_status(self.locales.t.usage_rm.clone()); return; } }; - if idx >= self.sources.len() { - self.set_status(format!("Нет источника с индексом {}", idx)); + let msg = format!("{}: {}", self.locales.t.no_source_with_index, idx); + self.set_status(msg); return; } - let removed = self.sources.remove(idx); - self.set_status(format!("Источник удалён: {}", removed)); + let msg = format!("{}: {}", self.locales.t.removed, removed); + self.set_status(msg); self.reload_library(); } fn clear_sources(&mut self) { if self.sources.is_empty() { - self.set_status("Список источников уже пуст".into()); + self.set_status(self.locales.t.already_empty.clone()); return; } self.sources.clear(); @@ -331,70 +301,58 @@ impl App { self.player.stop(); self.rebuild_library(); crate::state::save_cache(&self.raw_tracks); - self.set_status("Все источники удалены".into()); + self.set_status(self.locales.t.all_sources_cleared.clone()); } 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)); + all.extend(scan_source(&PathBuf::from(expand_tilde(src)))); } - 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())); + let msg = format!("{} {} {}", self.locales.t.rescanned, self.raw_tracks.len(), self.locales.t.tracks); + self.set_status(msg); } pub fn save_now(&mut self) { self.persist(); - let msg = format!("Сохранено {} треков", self.raw_tracks.len()); - self.set_status(msg); + self.set_status(self.locales.t.library_saved.clone()); } fn save_playlist(&mut self, raw_path: &str) { if raw_path.is_empty() { - self.set_status("Использование: :savepl <файл.m3u|.pls>".into()); + self.set_status(self.locales.t.usage_savepl.clone()); return; } - let path = PathBuf::from(expand_tilde(raw_path)); - let is_pls = path - .extension() + 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 = self - .raw_tracks - .iter() + let tracks: Vec = 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)), + Ok(_) => self.set_status(format!("{}: {}", self.locales.t.playlist_saved, path.display())), + Err(e) => self.set_status(format!("{}: {}", self.locales.t.save_error, e)), } } - // ── Удаление из списка (клавиша Del) ─────────────────────── pub fn delete_selected(&mut self) { match self.active_panel { Panel::Left => self.delete_current_left_group(), @@ -404,14 +362,9 @@ impl App { } fn delete_current_left_group(&mut self) { - let Some(group) = self.library.artists.get(self.library.artist_selected) else { - return; - }; + let Some(group) = self.library.artists.get(self.library.artist_selected) else { return; }; let group_name = group.name.clone(); - - let to_remove: Vec = group - .rows - .iter() + let to_remove: Vec = group.rows.iter() .filter_map(|r| match r { RightRow::Track { index_in_library } => { self.library.track(*index_in_library).map(|t| t.path.clone()) @@ -419,14 +372,9 @@ impl App { _ => 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)); - + 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) { @@ -435,32 +383,28 @@ impl App { } } } - - self.set_status(format!("Удалено: {} ({} треков)", group_name, count)); + let msg = format!("{}: {} ({} {})", self.locales.t.deleted, group_name, count, self.locales.t.tracks); + self.set_status(msg); } 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; - }; - + 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); + let name = format!("{} — {}", self.locales.display_artist(&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)); + let msg = format!("{}: {}", self.locales.t.deleted, name); + self.set_status(msg); } } - RightRow::AlbumHeader { name } => { + RightRow::AlbumHeader { name, .. } => { let start = self.library.right_selected + 1; let mut to_remove: Vec = Vec::new(); for r in rows.iter().skip(start) { @@ -473,14 +417,9 @@ impl App { 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)); - + 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) { @@ -489,48 +428,37 @@ impl App { } } } - - self.set_status(format!("Удалено: {} ({} треков)", name, count)); + let msg = format!("{}: {} ({} {})", self.locales.t.deleted, name, count, self.locales.t.tracks); + self.set_status(msg); } } } - // ── Переключение треков ──────────────────────────────────── - pub fn next_track(&mut self) { let total = self.library.all_tracks.len(); - if total == 0 { - self.set_status("Библиотека пуста".into()); - return; - } - + if total == 0 { self.set_status(self.locales.t.library_empty.clone()); 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()); + self.set_status(self.locales.t.end_of_playlist.clone()); 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; - } - + if total == 0 { self.set_status(self.locales.t.library_empty.clone()); return; } let prev = match self.playing_index { Some(0) => { if self.repeat == RepeatMode::Off { - self.set_status("Это первый трек".into()); + self.set_status(self.locales.t.first_track.clone()); return; } total - 1 @@ -538,25 +466,17 @@ impl App { Some(i) => i - 1, None => 0, }; - self.play_index(prev); self.library.sync_with_playing(prev); } fn persist(&self) { crate::state::save_cache(&self.raw_tracks); - - let playing_path = self - .playing_index - .and_then(|i| self.library.track(i)) + 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 - }; - + } else { None }; let state = SavedState { last_path: Some(self.default_dir.clone()), volume: Some(self.player.volume()), @@ -566,45 +486,38 @@ impl App { last_playing_path: playing_path, last_playing_pos_secs: playing_pos, sources: self.sources.clone(), + lang: Some(self.locales.current.clone()), }; crate::state::save(&state); } - pub fn on_exit(&self) { - self.persist(); - } - - // ── Поиск ────────────────────────────────────────────────── + pub fn on_exit(&self) { self.persist(); } pub fn start_search(&mut self) { self.input_mode = InputMode::Search; self.search_buffer.clear(); self.status_set_at = Instant::now(); } - pub fn cancel_search(&mut self) { self.input_mode = InputMode::Normal; self.search_buffer.clear(); self.rebuild_library(); - self.set_status("Поиск отменён".into()); + self.set_status(self.locales.t.search_cancelled.clone()); } - pub fn commit_search(&mut self) { self.input_mode = InputMode::Normal; let msg = if self.search_buffer.is_empty() { - "Фильтр сброшен".to_string() + self.locales.t.filter_reset.clone() } else { - format!("Фильтр: {}", self.search_buffer) + format!("{}: {}", self.locales.t.filter, self.search_buffer) }; self.set_status(msg); } - pub fn search_push(&mut self, c: char) { self.search_buffer.push(c); self.rebuild_library(); self.status_set_at = Instant::now(); } - pub fn search_pop(&mut self) { self.search_buffer.pop(); self.rebuild_library(); @@ -613,56 +526,56 @@ impl App { fn rebuild_library(&mut self) { let mode = self.library.sort_mode; + // Передаём по владению — без лишнего .cloned() → Vec<_> let filtered: Vec = self .raw_tracks .iter() .filter(|t| !self.deleted_paths.contains(&t.path)) .cloned() .collect(); - self.library = Library::build_with_filter(filtered, mode, self.search_buffer.clone()); + self.library = Library::build_with_filter( + filtered, + mode, + self.search_buffer.clone(), + self.locales.t.all_files.clone(), + ); if let Some(i) = self.playing_index { self.library.sync_with_playing(i); } } - // ── Навигация ────────────────────────────────────────────── - pub fn up(&mut self) { match self.active_panel { Panel::Left => self.library.artist_move(-1), Panel::Right => self.library.right_move(-1), } } - pub fn down(&mut self) { match self.active_panel { Panel::Left => self.library.artist_move(1), Panel::Right => self.library.right_move(1), } } - pub fn toggle_panel(&mut self) { self.active_panel = match self.active_panel { Panel::Left => Panel::Right, Panel::Right => Panel::Left, }; let msg = match self.active_panel { - Panel::Left => "Активна: левая панель".to_string(), - Panel::Right => "Активна: правая панель".to_string(), + Panel::Left => self.locales.t.left_panel.clone(), + Panel::Right => self.locales.t.right_panel.clone(), }; self.set_status(msg); } - pub fn jump_to_playing(&mut self) { if let Some(i) = self.playing_index { self.library.sync_with_playing(i); self.active_panel = Panel::Right; - self.set_status("→ к играющему треку".into()); + self.set_status(self.locales.t.jumped_to_playing.clone()); } else { - self.set_status("Ничего не играет".into()); + self.set_status(self.locales.t.nothing_playing.clone()); } } - pub fn goto_start(&mut self) { match self.active_panel { Panel::Left => { @@ -672,33 +585,29 @@ impl App { Panel::Right => self.library.right_goto_start(), } } - pub fn goto_end(&mut self) { match self.active_panel { Panel::Left => { if !self.library.artists.is_empty() { self.library.artist_selected = self.library.artists.len() - 1; - self.library - .left_state - .select(Some(self.library.artist_selected)); + self.library.left_state.select(Some(self.library.artist_selected)); } } Panel::Right => self.library.right_goto_end(), } } - // ── Playback ─────────────────────────────────────────────── - pub fn play_index(&mut self, index_in_library: usize) { - let Some(track) = self.library.track(index_in_library).cloned() else { - return; - }; + let Some(track) = self.library.track(index_in_library).cloned() else { return; }; match self.player.play(&track.path, None) { Ok(_) => { self.playing_index = Some(index_in_library); - self.set_status(format!("{} — {}", track.artist, track.title)); + self.set_status(format!("{} — {}", self.locales.display_artist(&track.artist), track.title)); + } + Err(e) => { + let msg = format!("{}: {}", self.locales.t.error, e); + self.set_status(msg); } - Err(e) => self.set_status(format!("Ошибка: {}", e)), } } @@ -706,51 +615,36 @@ impl App { if let Some(idx) = self.library.right_track_index() { self.play_index(idx); } else { - self.set_status("Выделен заголовок группы".into()); + self.set_status(self.locales.t.selected_group.clone()); } } - pub fn stop(&mut self) { self.player.stop(); self.playing_index = None; - self.set_status("Остановлено".into()); - } - - pub fn toggle_pause(&mut self) { - self.player.toggle_pause(); + self.set_status(self.locales.t.stopped.clone()); } + pub fn toggle_pause(&mut self) { self.player.toggle_pause(); } pub fn maybe_advance(&mut self) { - if self.playing_index.is_none() || !self.player.is_finished() { - return; - } - + if self.playing_index.is_none() || !self.player.is_finished() { return; } if self.repeat == RepeatMode::One { - if let Some(i) = self.playing_index { - self.play_index(i); - } + if let Some(i) = self.playing_index { self.play_index(i); } return; } - let total = self.library.all_tracks.len(); - if total == 0 { - return; - } + if total == 0 { return; } let next = if self.shuffle { - let seed = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos() as usize) - .unwrap_or(0); - seed % total + // LCG вместо SystemTime — без сисвызова + self.shuffle_seed = self.shuffle_seed.wrapping_mul(6364136223846793005).wrapping_add(1); + (self.shuffle_seed >> 33) as usize % total } else { match self.playing_index { Some(i) if i + 1 < total => i + 1, _ => { - if self.repeat == RepeatMode::All { - 0 - } else { - self.set_status("Конец плейлиста".into()); + if self.repeat == RepeatMode::All { 0 } + else { + self.set_status(self.locales.t.end_of_playlist.clone()); self.playing_index = None; return; } @@ -769,9 +663,9 @@ impl App { RepeatMode::One => RepeatMode::Off, }; let msg = match self.repeat { - RepeatMode::Off => "Repeat: off".to_string(), - RepeatMode::All => "Repeat: all".to_string(), - RepeatMode::One => "Repeat: one".to_string(), + RepeatMode::Off => self.locales.t.repeat_off.clone(), + RepeatMode::All => self.locales.t.repeat_all.clone(), + RepeatMode::One => self.locales.t.repeat_one.clone(), }; self.set_status(msg); } @@ -779,80 +673,58 @@ impl App { pub fn toggle_shuffle(&mut self) { self.shuffle = !self.shuffle; let msg = if self.shuffle { - "Shuffle: on".to_string() + self.locales.t.shuffle_on.clone() } else { - "Shuffle: off".to_string() + self.locales.t.shuffle_off.clone() }; self.set_status(msg); } pub fn volume_up(&mut self) { self.player.change_volume(0.05); - let msg = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32); - self.set_status(msg); + self.set_status(format!("{}: {}%", self.locales.t.volume, (self.player.volume() * 100.0) as u32)); } - pub fn volume_down(&mut self) { self.player.change_volume(-0.05); - let msg = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32); - self.set_status(msg); + self.set_status(format!("{}: {}%", self.locales.t.volume, (self.player.volume() * 100.0) as u32)); } pub fn seek(&mut self, delta_secs: i64) { if self.playing_index.is_none() { - self.set_status("Ничего не воспроизводится".into()); + self.set_status(self.locales.t.nothing_playing.clone()); return; } - let total = self - .playing_index - .and_then(|i| self.library.track(i)) - .map(|t| t.duration) - .unwrap_or(Duration::ZERO); - + let total = self.playing_index.and_then(|i| self.library.track(i)) + .map(|t| t.duration).unwrap_or(Duration::ZERO); match self.player.seek_by(delta_secs, total) { Ok(new_pos) => { let sign = if delta_secs >= 0 { "+" } else { "" }; - let msg = format!( - "Перемотка {}{}с → {:02}:{:02}", - sign, - delta_secs, - new_pos.as_secs() / 60, - new_pos.as_secs() % 60, - ); + let msg = format!("{} {}{}с → {:02}:{:02}", + self.locales.t.seek, sign, delta_secs, + new_pos.as_secs() / 60, new_pos.as_secs() % 60); self.set_status(msg); } - Err(_) => self.set_status("Перемотка недоступна".into()), + Err(_) => self.set_status(self.locales.t.seek_unavailable.clone()), } } pub fn seek_percent(&mut self, percent: u8) { if self.playing_index.is_none() { - self.set_status("Ничего не воспроизводится".into()); + self.set_status(self.locales.t.nothing_playing.clone()); return; } - let total = match self - .playing_index - .and_then(|i| self.library.track(i)) - .map(|t| t.duration) - { + let total = match self.playing_index.and_then(|i| self.library.track(i)).map(|t| t.duration) { Some(d) if !d.is_zero() => d, - _ => { - self.set_status("Длительность неизвестна".into()); - return; - } + _ => { self.set_status(self.locales.t.unknown_duration.clone()); return; } }; let target = total.mul_f32(percent as f32 / 100.0); match self.player.seek_to(target) { Ok(_) => { - let msg = format!( - "→ {}% ({:02}:{:02})", - percent, - target.as_secs() / 60, - target.as_secs() % 60, - ); + let msg = format!("→ {}% ({:02}:{:02})", percent, + target.as_secs() / 60, target.as_secs() % 60); self.set_status(msg); } - Err(_) => self.set_status("Перемотка недоступна".into()), + Err(_) => self.set_status(self.locales.t.seek_unavailable.clone()), } } @@ -869,45 +741,29 @@ impl App { SortMode::ByAlbum => SortMode::ByFilename, SortMode::ByFilename => SortMode::ByArtist, }; - - let playing_path = self - .playing_index - .and_then(|i| self.library.track(i)) + let playing_path = self.playing_index.and_then(|i| self.library.track(i)) .map(|t| t.path.clone()); - - let filtered: Vec = self - .raw_tracks - .iter() + let filtered: Vec = self.raw_tracks.iter() .filter(|t| !self.deleted_paths.contains(&t.path)) .cloned() .collect(); - self.library = Library::build_with_filter( filtered, new_mode, self.search_buffer.clone(), + self.locales.t.all_files.clone(), ); - - self.playing_index = playing_path - .and_then(|p| self.library.all_tracks.iter().position(|t| t.path == p)); - - if let Some(i) = self.playing_index { - self.library.sync_with_playing(i); - } - + self.playing_index = playing_path.and_then(|p| self.library.all_tracks.iter().position(|t| t.path == p)); + if let Some(i) = self.playing_index { self.library.sync_with_playing(i); } let msg = match new_mode { - SortMode::ByArtist => "Сортировка: по артисту/альбому".to_string(), - SortMode::ByAlbum => "Сортировка: по альбому/артисту".to_string(), - SortMode::ByFilename => "Сортировка: по имени файла".to_string(), + SortMode::ByArtist => self.locales.t.sort_artist.clone(), + SortMode::ByAlbum => self.locales.t.sort_album.clone(), + SortMode::ByFilename => self.locales.t.sort_file.clone(), }; self.set_status(msg); } - // ── Геттеры для UI ───────────────────────────────────────── - - pub fn sources(&self) -> &[String] { - &self.sources - } + pub fn sources(&self) -> &[String] { &self.sources } } fn expand_tilde(path: &str) -> String { diff --git a/src/audio.rs b/src/audio.rs index 074c8b2..b386f22 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -5,6 +5,8 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink, Source}; +use rustfft::num_complex::Complex; +use rustfft::{Fft, FftPlanner}; const FFT_SIZE: usize = 1024; const NUM_BARS: usize = 64; @@ -13,13 +15,17 @@ const NUM_BARS: usize = 64; pub struct SpectrumBus { buffer: Arc>>, bars: Arc>, + fft_plan: Arc>, } impl SpectrumBus { pub fn new() -> Self { + let mut planner = FftPlanner::::new(); + let fft_plan = planner.plan_fft_forward(FFT_SIZE); Self { buffer: Arc::new(Mutex::new(VecDeque::with_capacity(FFT_SIZE * 2))), 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] { self.bars.lock().map(|b| *b).unwrap_or([0.0; NUM_BARS]) } pub fn recompute(&self) { - use rustfft::num_complex::Complex; - use rustfft::FftPlanner; + // Быстрая проверка длины до клонирования + { + let buf = match self.buffer.lock() { + Ok(b) => b, + Err(_) => return, + }; + if buf.len() < FFT_SIZE { + return; + } + } let samples: Vec = match self.buffer.lock() { Ok(buf) => buf.iter().rev().take(FFT_SIZE).rev().copied().collect(), Err(_) => return, }; - if samples.len() < FFT_SIZE { - return; - } + // Переиспользуем один и тот же Vec> — но он маленький (1024), + // аллокация дешёвая. Оставим создание per-call для простоты. let mut data: Vec> = samples .iter() .enumerate() @@ -59,9 +79,7 @@ impl SpectrumBus { }) .collect(); - let mut planner = FftPlanner::::new(); - let fft = planner.plan_fft_forward(FFT_SIZE); - fft.process(&mut data); + self.fft_plan.process(&mut data); let half = FFT_SIZE / 2; let mut bars = [0.0f32; NUM_BARS]; @@ -125,6 +143,7 @@ where self.inner.total_duration() } fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> { + self.bus.clear(); // сброс спектра, чтобы не «дёргался» self.inner.try_seek(pos) } } @@ -149,8 +168,6 @@ impl AudioPlayer { }) } - /// Запускает трек, опционально перематывая в `start_pos` ДО начала воспроизведения. - /// Перемотка делается на декодере — это предотвращает щелчок на старте. pub fn play( &mut self, path: &PathBuf, @@ -173,7 +190,6 @@ impl AudioPlayer { bus: self.spectrum.clone(), }; self.sink.append(tapped); - // ВАЖНО: явно снимаем паузу — sink.stop() не сбрасывает состояние pause. self.sink.play(); Ok(()) } diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..72799b4 --- /dev/null +++ b/src/i18n.rs @@ -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>, + 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::>(&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 { + let mut v: Vec = 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 Add to session"), + help_line_load: s(" :load Replace session"), + help_line_reload: s(" :reload Rescan everything"), + help_line_save: s(" :save Save library"), + help_line_savepl: s(" :savepl Export to M3U/PLS"), + help_line_libs: s(" :libs List sources"), + help_line_rm: s(" :rm Remove source"), + help_line_clear: s(" :clear Clear sources"), + help_line_lang: s(" :lang 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 "), usage_load: s("Usage: :load "), + usage_rm: s("Usage: :rm "), + usage_savepl: s("Usage: :savepl "), + 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"), + } +} \ No newline at end of file diff --git a/src/library.rs b/src/library.rs index cdc31cd..2c73b18 100644 --- a/src/library.rs +++ b/src/library.rs @@ -5,9 +5,7 @@ use crate::track::Track; #[derive(Clone)] pub enum RightRow { - /// Заголовок группы: в режиме ByArtist - название альбома, - /// в режиме ByAlbum - имя исполнителя. - AlbumHeader { name: String }, + AlbumHeader { name: String, is_artist: bool }, Track { index_in_library: usize }, } @@ -35,14 +33,15 @@ pub struct Library { } impl Library { - pub fn build(tracks: Vec, mode: SortMode) -> Self { - Self::build_with_filter(tracks, mode, String::new()) + pub fn build(tracks: Vec, mode: SortMode, all_files_label: String) -> Self { + Self::build_with_filter(tracks, mode, String::new(), all_files_label) } pub fn build_with_filter( mut tracks: Vec, mode: SortMode, filter: String, + all_files_label: String, ) -> Self { if !filter.is_empty() { let f = filter.to_lowercase(); @@ -56,17 +55,12 @@ impl Library { match mode { SortMode::ByArtist => Self::build_by_artist(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( - mut tracks: Vec, - mode: SortMode, - filter: String, - ) -> Self { + fn build_by_artist(mut tracks: Vec, mode: SortMode, filter: String) -> Self { use std::collections::BTreeMap; - let mut map: BTreeMap>> = BTreeMap::new(); for t in tracks.drain(..) { map.entry(t.artist.clone()) @@ -81,43 +75,25 @@ impl Library { for (artist_name, albums_map) in map { let mut rows = Vec::new(); - for (album_name, mut album_tracks) in albums_map { album_tracks.sort_by(|a, b| { - a.track_number - .unwrap_or(u32::MAX) - .cmp(&b.track_number.unwrap_or(u32::MAX)) + 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 }); - + rows.push(RightRow::AlbumHeader { name: album_name, is_artist: false }); for t in album_tracks { let idx = all_tracks.len(); all_tracks.push(t); - rows.push(RightRow::Track { - index_in_library: idx, - }); + rows.push(RightRow::Track { 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) } - fn build_by_album( - mut tracks: Vec, - mode: SortMode, - filter: String, - ) -> Self { + fn build_by_album(mut tracks: Vec, mode: SortMode, filter: String) -> Self { use std::collections::BTreeMap; - - // Album -> Artist -> Vec let mut map: BTreeMap>> = BTreeMap::new(); for t in tracks.drain(..) { map.entry(t.album.clone()) @@ -132,33 +108,20 @@ impl Library { 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)) + 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 }); - + rows.push(RightRow::AlbumHeader { name: artist_name, is_artist: true }); for t in artist_tracks { let idx = all_tracks.len(); all_tracks.push(t); - rows.push(RightRow::Track { - index_in_library: idx, - }); + rows.push(RightRow::Track { 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) } @@ -166,29 +129,21 @@ impl Library { mut tracks: Vec, mode: SortMode, filter: String, + all_files_label: 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(), - }); + rows.push(RightRow::AlbumHeader { name: all_files_label, is_artist: false }); for i in 0..all_tracks.len() { - rows.push(RightRow::Track { - index_in_library: i, - }); + rows.push(RightRow::Track { index_in_library: i }); } - let groups = vec![ArtistView { - name: "Все файлы".into(), - rows, - }]; - + let groups = vec![ArtistView { name: String::new(), rows }]; Self::finalize(all_tracks, groups, mode, filter) } - /// Общая инициализация ListState и позиций курсора. fn finalize( all_tracks: Vec, groups: Vec, @@ -277,7 +232,7 @@ impl Library { new_pos = len - 1; } else if new_pos >= len { new_pos = 0; - self.right_state = ListState::default(); + self.right_state = ListState::default(); } self.right_selected = track_rows[new_pos as usize]; @@ -293,10 +248,7 @@ impl Library { } fn first_track_row(&self) -> usize { - self.current_right_rows() - .iter() - .position(|r| matches!(r, RightRow::Track { .. })) - .unwrap_or(0) + first_track_row_in(self.artists.get(self.artist_selected)) } 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 { group - .and_then(|a| { - a.rows - .iter() - .position(|r| matches!(r, RightRow::Track { .. })) - }) + .and_then(|a| a.rows.iter().position(|r| matches!(r, RightRow::Track { .. }))) .unwrap_or(0) } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 430701f..34d817d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod app; mod audio; +mod i18n; mod library; mod state; mod track; @@ -26,10 +27,7 @@ fn main() -> Result<(), Box> { let mut app = App::new(&music_dir)?; loop { - if app.request_quit { - app.on_exit(); - break; - } + if app.request_quit { app.on_exit(); break; } app.maybe_advance(); app.tick_status(); @@ -38,13 +36,8 @@ fn main() -> Result<(), Box> { if event::poll(Duration::from_millis(50))? { if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Release { - continue; - } - if handle_key(key, &mut app) { - app.on_exit(); - break; - } + if key.kind == KeyEventKind::Release { continue; } + if handle_key(key, &mut app) { app.on_exit(); break; } } } } @@ -54,129 +47,81 @@ fn main() -> Result<(), Box> { } fn handle_key(key: KeyEvent, app: &mut App) -> bool { + // 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) +} + +fn handle_modal(key: KeyEvent, app: &mut App, is_help: bool) -> bool { + if key.code == KeyCode::Esc { + if is_help { app.toggle_help(); } else { app.show_libs = false; } + } + false +} + +fn handle_command(key: KeyEvent, app: &mut App) -> bool { + 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), + _ => {} + } + false +} + +fn handle_search(key: KeyEvent, app: &mut App) -> bool { + match key.code { + KeyCode::Esc => app.cancel_search(), + KeyCode::Enter => app.commit_search(), + KeyCode::Backspace => app.search_pop(), + KeyCode::Char(c) => app.search_push(c), + _ => {} + } + false +} + +fn handle_normal(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::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; - } + 'd' => { app.next_track(); return false; } + 'a' => { app.prev_track(); return false; } _ => {} }, _ => {} } } - - if app.input_mode == InputMode::Search { - match key.code { - KeyCode::Esc => app.cancel_search(), - KeyCode::Enter => app.commit_search(), - KeyCode::Backspace => app.search_pop(), - KeyCode::Char(c) => app.search_push(c), - _ => {} - } - return false; - } - - if key.code == KeyCode::Delete { - app.delete_selected(); - return false; - } + if key.code == KeyCode::Delete { app.delete_selected(); return false; } match key.code { - KeyCode::Char(':') => { - app.start_command(); - return false; - } - KeyCode::Char('+') | KeyCode::Char('=') => { - app.volume_up(); - return false; - } - KeyCode::Char('-') | KeyCode::Char('_') => { - app.volume_down(); - 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; - } + KeyCode::Char(':') => { app.start_command(); return false; } + KeyCode::Char('+') | KeyCode::Char('=') => { app.volume_up(); return false; } + KeyCode::Char('-') | KeyCode::Char('_') => { app.volume_down(); 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 { match c { - '1'..='9' => { - let pct = (c as u8 - b'0') * 10; - app.seek_percent(pct); - } + '1'..='9' => { app.seek_percent((c as u8 - b'0') * 10); } _ => match c.to_ascii_lowercase() { 'q' => return true, 'w' => app.up(), @@ -186,13 +131,7 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool { 'z' => app.stop(), 't' => app.toggle_sort(), 'f' => app.jump_to_playing(), - 'g' => { - if c == 'G' { - app.goto_end(); - } else { - app.goto_start(); - } - } + 'g' => if c == 'G' { app.goto_end(); } else { app.goto_start(); }, 'r' => app.cycle_repeat(), 'x' => app.toggle_shuffle(), '/' => app.start_search(), diff --git a/src/state.rs b/src/state.rs index 47565cc..1fab9a3 100644 --- a/src/state.rs +++ b/src/state.rs @@ -15,9 +15,9 @@ pub struct SavedState { pub shuffle: Option, pub last_playing_path: Option, pub last_playing_pos_secs: Option, - /// Единый список источников: папки и плейлисты. #[serde(default)] pub sources: Vec, + pub lang: Option, } #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)] @@ -41,7 +41,13 @@ pub fn load() -> SavedState { return SavedState::default(); }; 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(), } } @@ -54,7 +60,11 @@ pub fn save(state: &SavedState) { let _ = fs::create_dir_all(parent); } 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); + } } } diff --git a/src/track.rs b/src/track.rs index 2b08e45..7b45b4d 100644 --- a/src/track.rs +++ b/src/track.rs @@ -1,3 +1,4 @@ +use std::io::{BufWriter, Write}; use std::path::{Path, PathBuf}; 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 { use walkdir::WalkDir; let mut tracks = Vec::new(); @@ -81,10 +88,7 @@ pub fn scan_dir(dir: &str) -> Vec { let path = entry.path(); if path.is_file() { if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - if matches!( - ext.to_lowercase().as_str(), - "mp3" | "flac" | "ogg" | "wav" | "m4a" - ) { + if is_audio_ext(ext) { tracks.push(Track::from_path(&path.to_path_buf())); } } @@ -93,7 +97,6 @@ pub fn scan_dir(dir: &str) -> Vec { tracks } -/// Универсальный сканер источника: папка или файл плейлиста. pub fn scan_source(path: &Path) -> Vec { if path.is_dir() { scan_dir(&path.to_string_lossy()) @@ -107,7 +110,7 @@ pub fn scan_source(path: &Path) -> Vec { } } -// ── Плейлисты (M3U / M3U8 / PLS) ─────────────────────────────── +// ── Плейлисты ────────────────────────────────────────────────── pub fn read_playlist(path: &Path) -> Vec { 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<()> { - 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 { let dur = t.duration.as_secs(); - out.push_str(&format!( - "#EXTINF:{},{} - {}\n{}\n", - dur, - t.artist, - t.title, - t.path.display() - )); + writeln!(w, "#EXTINF:{},{} - {}", dur, t.artist, t.title)?; + writeln!(w, "{}", t.path.display())?; } - std::fs::write(path, out) + Ok(()) } 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() { - 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())); + let n = i + 1; + writeln!(w, "File{}={}", n, t.path.display())?; + writeln!(w, "Title{}={} - {}", n, t.artist, t.title)?; + writeln!(w, "Length{}={}", n, t.duration.as_secs())?; } - out.push_str(&format!("NumberOfEntries={}\nVersion=2\n", tracks.len())); - std::fs::write(path, out) + writeln!(w, "NumberOfEntries={}", tracks.len())?; + writeln!(w, "Version=2")?; + Ok(()) } \ No newline at end of file diff --git a/src/ui.rs b/src/ui.rs index ac00430..fb4eb70 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,13 +7,15 @@ use crate::app::{App, InputMode, Panel}; use crate::library::{RightRow, SortMode}; 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) { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Min(1), - Constraint::Length(3), - ]) + .constraints([Constraint::Min(1), Constraint::Length(3)]) .split(f.area()); let top = Layout::default() @@ -23,10 +25,7 @@ pub fn draw(f: &mut Frame, app: &mut App) { let left_col = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Min(1), - Constraint::Length(8), - ]) + .constraints([Constraint::Min(1), Constraint::Length(8)]) .split(top[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]); if app.show_help { - draw_help(f, f.area()); + draw_help(f, app, f.area()); } 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 .artists .iter() - .map(|a| ListItem::new(a.name.clone())) + .map(|a| ListItem::new(app.locales.display_artist(&a.name).to_string())) .collect(); 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); let title_text = match app.library.sort_mode { - SortMode::ByArtist => " Исполнители ", - SortMode::ByAlbum => " Альбомы ", - SortMode::ByFilename => " Источник ", + SortMode::ByArtist => &app.locales.t.artists, + SortMode::ByAlbum => &app.locales.t.albums, + SortMode::ByFilename => &app.locales.t.source, }; - let album_color = Color::Rgb(100, 149, 237); - let title = Span::styled(title_text, Style::default().fg(album_color)); + let title = Span::styled(format!(" {} ", title_text), Style::default().fg(ACCENT)); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(title)) @@ -89,6 +87,79 @@ fn fmt_dur(d: std::time::Duration) -> String { } } +fn make_album_header( + 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 mut total = std::time::Duration::ZERO; + let mut i = idx + 1; + while i < rows.len() { + if let RightRow::Track { index_in_library } = rows[i] { + if let Some(t) = app.library.track(index_in_library) { + total += t.duration; + } + i += 1; + } else { + break; + } + } + let total_str = fmt_dur(total); + let inner_width = area_width.saturating_sub(BORDER_AND_MARKER) as usize; + let pad = inner_width + .saturating_sub(display_name.len() + total_str.len() + 4) + .max(1); + let line = format!("{} ] {} {}", display_name, "─".repeat(pad), total_str); + + ListItem::new(Line::from(vec![ + Span::styled("[ ", Style::default().fg(ACCENT)), + Span::styled(line, Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)), + ])) +} + +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) => ( + t.title.clone(), + t.track_number + .map(|n| format!("{:02}. ", n)) + .unwrap_or_default(), + fmt_dur(t.duration), + ), + None => ("".into(), String::new(), "--:--".into()), + }; + + let style = if is_playing { + Style::default().fg(Color::Green) + } else { + Style::default() + }; + + let left = format!(" {}{}{}", marker, num, title); + let inner_width = area_width.saturating_sub(4) as usize; + let pad = inner_width + .saturating_sub(left.chars().count() + dur.chars().count()) + .max(1); + + ListItem::new(Line::from(vec![ + Span::styled(left, style), + Span::raw(" ".repeat(pad)), + Span::styled(dur, style), + ])) +} + fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) { f.render_widget(Clear, area); @@ -98,74 +169,11 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) { .iter() .enumerate() .map(|(idx, row)| match row { - RightRow::AlbumHeader { name } => { - let mut album_total = std::time::Duration::ZERO; - let mut i = idx + 1; - while i < rows.len() { - if let RightRow::Track { index_in_library } = rows[i] { - if let Some(t) = app.library.track(index_in_library) { - album_total += t.duration; - } - i += 1; - } else { - break; - } - } - let total_str = fmt_dur(album_total); - - let inner_width = area.width.saturating_sub(6) as usize; - let pad = inner_width - .saturating_sub(name.len() + total_str.len() + 4) - .max(1); - let line = format!("{} ] {} {}", name, "─".repeat(pad), total_str); - - let album_color = Color::Rgb(100, 149, 237); - - ListItem::new(Line::from(vec![ - Span::styled("[ ", Style::default().fg(album_color)), - Span::styled( - line, - Style::default() - .fg(album_color) - .add_modifier(Modifier::BOLD), - ), - ])) + RightRow::AlbumHeader { name, is_artist } => { + make_album_header(app, idx, name, *is_artist, rows, area.width) } RightRow::Track { index_in_library } => { - let is_playing = Some(*index_in_library) == app.playing_index; - let marker = if is_playing { "♪ " } else { " " }; - - let (title, num, dur) = match app.library.track(*index_in_library) { - Some(t) => ( - t.title.clone(), - t.track_number - .map(|n| format!("{:02}. ", n)) - .unwrap_or_default(), - fmt_dur(t.duration), - ), - None => ("".into(), String::new(), "--:--".into()), - }; - - let style = if is_playing { - Style::default().fg(Color::Green) - } 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); - - ListItem::new(Line::from(vec![ - Span::styled(left, style), - Span::raw(" ".repeat(pad)), - Span::styled(dur, style), - ])) + make_track_item(app, *index_in_library, area.width) } }) .collect(); @@ -174,40 +182,34 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) { .library .artists .get(app.library.artist_selected) - .map(|a| a.name.as_str()) - .unwrap_or("—"); + .map(|a| app.locales.display_artist(&a.name).to_string()) + .unwrap_or_else(|| "—".to_string()); + let t = &app.locales.t; let title_text = match app.library.sort_mode { SortMode::ByArtist => { if app.library.filter.is_empty() { - format!(" Альбомы и треки - {} ", selected_name) + format!(" {} - {} ", t.albums, selected_name) } else { - format!( - " Альбомы и треки - {} [{}] ", - selected_name, app.library.filter - ) + format!(" {} - {} [{}] ", t.albums, selected_name, app.library.filter) } } SortMode::ByAlbum => { if app.library.filter.is_empty() { - format!(" Исполнители и треки - {} ", selected_name) + format!(" {} - {} ", t.artists, selected_name) } else { - format!( - " Исполнители и треки - {} [{}] ", - selected_name, app.library.filter - ) + format!(" {} - {} [{}] ", t.artists, selected_name, app.library.filter) } } SortMode::ByFilename => { if app.library.filter.is_empty() { - " Альбомы и треки ".into() + format!(" {} ", t.source) } 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(album_color)); + let title = Span::styled(title_text, Style::default().fg(ACCENT)); let highlight = if app.active_panel == Panel::Right { 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 bars_data = app.player.spectrum.bars(); 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| { Bar::default() .value((v * 100.0) as u64) - .style(Style::default().fg(Color::Rgb(100, 149, 237))) + .style(Style::default().fg(ACCENT)) }) .collect(); @@ -271,13 +272,20 @@ fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) { .block( Block::default() .borders(Borders::ALL) - .title(" Эквалайзер ") - .title_style(Style::default().fg(Color::Rgb(100, 149, 237))), + .title(format!(" {} ", app.locales.t.equalizer)) + .title_style(Style::default().fg(ACCENT)), ); f.render_widget(chart, area); } +fn bordered(title: impl Into>) -> 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) { let inner = Layout::default() .direction(Direction::Horizontal) @@ -289,22 +297,17 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) { ]) .split(area); + let t = &app.locales.t; let (status_text, status_title) = match app.input_mode { - InputMode::Search => (format!(">{}", app.search_buffer), " Поиск "), - InputMode::Command => (format!(":{}", app.command_buffer), " Команда "), + InputMode::Search => (format!(">{}", app.search_buffer), format!(" {} ", t.search)), + InputMode::Command => (format!(":{}", app.command_buffer), format!(" {} ", t.command)), InputMode::Normal => { 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) - .block( - Block::default() - .borders(Borders::ALL) - .title(status_title) - .title_style(Style::default().fg(Color::Rgb(100, 149, 237))) - ); + let status = Paragraph::new(status_text).block(bordered(Line::from(status_title))); f.render_widget(status, inner[0]); 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) .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Время ") - .title_style(Style::default().fg(Color::Rgb(100, 149, 237))) - ); + .block(bordered(Line::from(format!(" {} ", t.time)))); f.render_widget(time, inner[1]); 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) .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Режим ") - .title_style(Style::default().fg(Color::Rgb(100, 149, 237))) - ); + .block(bordered(Line::from(format!(" {} ", t.mode)))); 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) .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Звук ") - .title_style(Style::default().fg(Color::Rgb(100, 149, 237))) - ); + .block(bordered(Line::from(format!(" {} ", t.volume)))); 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)); +/// Общий хелпер для модальных окон. +fn draw_modal(f: &mut Frame, screen: Rect, title: String, lines: Vec>, width: u16) { + let content_h = (lines.len() + 2) as u16; 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 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 para = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .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> = vec![ Line::from(Span::styled( - " Источники библиотеки ", - title_color.add_modifier(Modifier::BOLD), + format!(" {} ", t.library), + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), )), Line::from(""), - Line::from(Span::styled(" Список", header)), + Line::from(Span::styled(format!(" {}", t.sources), header)), ]; 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(Span::styled( - " :rm — удалить, Esc — закрыть", + format!(" :rm — {}, Esc — {}", t.delete, t.close), 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); + draw_modal(f, screen, format!(" {} ", t.library), lines, 70); } -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)); +fn draw_help(f: &mut Frame, app: &App, screen: Rect) { + let t = &app.locales.t; + let header = Style::default().fg(HEADER).add_modifier(Modifier::BOLD); - 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![ + let lines: Vec> = vec![ Line::from(Span::styled( - " Управление плеером ", - title_color.add_modifier(Modifier::BOLD), + t.help_title.clone(), + Style::default().fg(ACCENT).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(Span::styled(t.help_nav.clone(), header)), + Line::from(t.help_line_up.clone()), + Line::from(t.help_line_down.clone()), + Line::from(t.help_line_tab.clone()), + Line::from(t.help_line_goto.clone()), + Line::from(t.help_line_f.clone()), 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(Span::styled(t.help_playback.clone(), header)), + Line::from(t.help_line_enter.clone()), + Line::from(t.help_line_space.clone()), + Line::from(t.help_line_z.clone()), + Line::from(t.help_line_ctrl_right.clone()), + Line::from(t.help_line_ctrl_left.clone()), + Line::from(t.help_line_r.clone()), + Line::from(t.help_line_x.clone()), Line::from(""), - Line::from(Span::styled(" Перемотка", header)), - Line::from(" a / ← −5 секунд"), - Line::from(" d / → +5 секунд"), - Line::from(" 1..9 перейти к 10%..90% трека"), + Line::from(Span::styled(t.help_seeking.clone(), header)), + Line::from(t.help_line_a.clone()), + Line::from(t.help_line_d.clone()), + Line::from(t.help_line_1_9.clone()), 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 удалить источник"), - Line::from(" :clear очистить источники"), - Line::from(" :help эта справка"), - Line::from(" :q выход"), + Line::from(Span::styled(t.help_commands.clone(), header)), + Line::from(t.help_line_add.clone()), + Line::from(t.help_line_load.clone()), + Line::from(t.help_line_reload.clone()), + Line::from(t.help_line_save.clone()), + Line::from(t.help_line_savepl.clone()), + Line::from(t.help_line_libs.clone()), + Line::from(t.help_line_rm.clone()), + Line::from(t.help_line_clear.clone()), + Line::from(t.help_line_lang.clone()), + Line::from(t.help_line_help.clone()), + Line::from(t.help_line_q.clone()), 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(Span::styled(t.help_audio_misc.clone(), header)), + Line::from(t.help_line_vol.clone()), + Line::from(t.help_line_search.clone()), + Line::from(t.help_line_del.clone()), + Line::from(t.help_line_t.clone()), + Line::from(t.help_line_f1.clone()), + Line::from(t.help_line_quit.clone()), Line::from(""), - Line::from(Span::styled( - " F1 или Esc - закрыть ", - Style::default().fg(Color::DarkGray), - )), + Line::from(Span::styled(t.help_close.clone(), Style::default().fg(DIM))), ]; - let help = Paragraph::new(help_text).block( - Block::default() - .borders(Borders::ALL) - .border_style(title_color) - .title(" Помощь "), - ); - - f.render_widget(help, area); + draw_modal(f, screen, format!(" {} ", t.help), lines, 62); } \ No newline at end of file