Compare commits
17 Commits
f554de7908
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e7338b252d | |||
| 53f2cca1f4 | |||
| e870fb18d1 | |||
| 4f62c491ed | |||
| f735992a32 | |||
| d3de401488 | |||
| 3006fdbcc4 | |||
| 52ca495a6e | |||
| 4c742ea210 | |||
| b63cfe2a21 | |||
| 16a82fd936 | |||
| 9cdd4b099d | |||
| 1211165228 | |||
| 07baff09c4 | |||
| 4c7f46e105 | |||
| 121715777e | |||
| ccdc470c60 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
/target
|
/target
|
||||||
|
/.idea
|
||||||
23
Cargo.lock
generated
23
Cargo.lock
generated
@@ -48,6 +48,15 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bincode"
|
||||||
|
version = "1.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@@ -972,15 +981,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rumus"
|
name = "rumus"
|
||||||
version = "0.1.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bincode",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"encoding_rs",
|
||||||
"lofty",
|
"lofty",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"rodio",
|
"rodio",
|
||||||
"rustfft",
|
"rustfft",
|
||||||
"serde",
|
"serde",
|
||||||
|
"sys-locale",
|
||||||
"toml",
|
"toml",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
@@ -1390,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"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rumus"
|
name = "rumus"
|
||||||
version = "0.1.0"
|
version = "0.5.1"
|
||||||
edition = "2024"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ratatui = "0.29"
|
ratatui = "0.29"
|
||||||
@@ -12,4 +12,7 @@ dirs = "5"
|
|||||||
lofty = "0.22"
|
lofty = "0.22"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
rustfft = "6"
|
rustfft = "6"
|
||||||
|
bincode = "1.3"
|
||||||
|
encoding_rs = "0.8"
|
||||||
|
sys-locale = "0.3"
|
||||||
113
locales/en_US.toml
Normal file
113
locales/en_US.toml
Normal 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
116
locales/ru_RU.toml
Normal 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" = "Неизвестный альбом"
|
||||||
722
src/app.rs
722
src/app.rs
@@ -1,21 +1,18 @@
|
|||||||
use std::time::Duration;
|
use std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::audio::AudioPlayer;
|
use crate::audio::AudioPlayer;
|
||||||
use crate::library::{Library, SortMode};
|
use crate::i18n::Locales;
|
||||||
|
use crate::library::{Library, RightRow, SortMode};
|
||||||
use crate::state::{RepeatMode, SavedState};
|
use crate::state::{RepeatMode, SavedState};
|
||||||
use crate::track::scan_dir;
|
use crate::track::{scan_source, Track};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
pub enum Panel {
|
pub enum Panel { Left, Right }
|
||||||
Left,
|
|
||||||
Right,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
pub enum InputMode {
|
pub enum InputMode { Normal, Search, Command }
|
||||||
Normal,
|
|
||||||
Search,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub library: Library,
|
pub library: Library,
|
||||||
@@ -25,183 +22,592 @@ pub struct App {
|
|||||||
pub active_panel: Panel,
|
pub active_panel: Panel,
|
||||||
pub input_mode: InputMode,
|
pub input_mode: InputMode,
|
||||||
pub search_buffer: String,
|
pub search_buffer: String,
|
||||||
/// Исходный (неотфильтрованный) список треков — для пересборки библиотеки
|
pub command_buffer: String,
|
||||||
raw_tracks: Vec<crate::track::Track>,
|
pub request_quit: bool,
|
||||||
/// Директория с музыкой — чтобы сохранить в state
|
pub locales: Locales,
|
||||||
music_dir: String,
|
raw_tracks: Vec<Track>,
|
||||||
|
default_dir: String,
|
||||||
|
sources: Vec<String>,
|
||||||
|
deleted_paths: HashSet<PathBuf>,
|
||||||
pub repeat: RepeatMode,
|
pub repeat: RepeatMode,
|
||||||
pub shuffle: bool,
|
pub shuffle: bool,
|
||||||
|
pub show_help: bool,
|
||||||
|
pub show_libs: bool,
|
||||||
|
status_set_at: Instant,
|
||||||
|
shuffle_seed: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_RESET_AFTER: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(music_dir: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
pub fn new(music_dir: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let tracks = scan_dir(music_dir);
|
let saved = crate::state::load();
|
||||||
let library = Library::build(tracks.clone(), SortMode::ByArtist);
|
let mut 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 {
|
let mut app = Self {
|
||||||
library,
|
library: Library::build(Vec::new(), SortMode::ByArtist, all_files.clone()),
|
||||||
player: AudioPlayer::new()?,
|
player: AudioPlayer::new()?,
|
||||||
status: "? — help".into(),
|
status: String::new(),
|
||||||
playing_index: None,
|
playing_index: None,
|
||||||
active_panel: Panel::Left,
|
active_panel: Panel::Left,
|
||||||
input_mode: InputMode::Normal,
|
input_mode: InputMode::Normal,
|
||||||
search_buffer: String::new(),
|
search_buffer: String::new(),
|
||||||
raw_tracks: tracks,
|
command_buffer: String::new(),
|
||||||
music_dir: music_dir.to_string(),
|
request_quit: false,
|
||||||
|
locales,
|
||||||
|
raw_tracks: Vec::new(),
|
||||||
|
default_dir: music_dir.to_string(),
|
||||||
|
sources: saved.sources.clone(),
|
||||||
|
deleted_paths: HashSet::new(),
|
||||||
repeat: RepeatMode::Off,
|
repeat: RepeatMode::Off,
|
||||||
shuffle: false,
|
shuffle: false,
|
||||||
|
show_help: false,
|
||||||
|
show_libs: false,
|
||||||
|
status_set_at: Instant::now(),
|
||||||
|
shuffle_seed: std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(12345),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Восстанавливаем сохранённое состояние
|
app.status = app.locales.t.help.clone();
|
||||||
let saved = crate::state::load();
|
|
||||||
if let Some(vol) = saved.volume {
|
if app.sources.is_empty() {
|
||||||
app.player.set_volume(vol);
|
app.sources.push(music_dir.to_string());
|
||||||
}
|
|
||||||
if let Some(rep) = saved.repeat {
|
|
||||||
app.repeat = rep;
|
|
||||||
}
|
|
||||||
if let Some(sh) = saved.shuffle {
|
|
||||||
app.shuffle = sh;
|
|
||||||
}
|
|
||||||
if saved.sort_by_filename == Some(true) {
|
|
||||||
app.library = Library::build(app.raw_tracks.clone(), SortMode::ByFilename);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
tracks.extend(scan_source(&PathBuf::from(src)));
|
||||||
|
}
|
||||||
|
crate::state::save_cache(&tracks);
|
||||||
|
tracks
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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(mode) = saved.sort_mode {
|
||||||
|
if mode != SortMode::ByArtist {
|
||||||
|
app.library = Library::build(app.raw_tracks.clone(), mode, all_files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.restore_playing(&saved);
|
||||||
Ok(app)
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_status(&mut self, text: String) {
|
||||||
|
self.status = text;
|
||||||
|
self.status_set_at = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_status(&self) -> String {
|
||||||
|
match self.playing_index.and_then(|i| self.library.track(i)) {
|
||||||
|
Some(t) => format!("{} - {}", 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; }
|
||||||
|
let default = self.default_status();
|
||||||
|
if self.status != default {
|
||||||
|
self.status = default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_playing(&mut self, saved: &SavedState) {
|
||||||
|
let Some(path_str) = &saved.last_playing_path else { return; };
|
||||||
|
let target_path = 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();
|
||||||
|
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 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(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();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commit_command(&mut self) {
|
||||||
|
let cmd = self.command_buffer.trim().to_string();
|
||||||
|
self.input_mode = InputMode::Normal;
|
||||||
|
self.command_buffer.clear();
|
||||||
|
if cmd.is_empty() { return; }
|
||||||
|
|
||||||
|
let mut parts = cmd.splitn(2, char::is_whitespace);
|
||||||
|
let name = parts.next().unwrap_or("");
|
||||||
|
let args = parts.next().unwrap_or("").trim();
|
||||||
|
|
||||||
|
match name {
|
||||||
|
"add" => self.add_source(args),
|
||||||
|
"load" => self.load_source(args),
|
||||||
|
"reload" => self.reload_library(),
|
||||||
|
"save" => self.save_now(),
|
||||||
|
"savepl" => self.save_playlist(args),
|
||||||
|
"libs" => { self.show_libs = true; self.set_status(self.locales.t.library.clone()); }
|
||||||
|
"rm" => self.remove_source(args),
|
||||||
|
"clear" => self.clear_sources(),
|
||||||
|
"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(self.locales.t.usage_add.clone());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let expanded = expand_tilde(raw_path);
|
||||||
|
let path = PathBuf::from(&expanded);
|
||||||
|
if !path.exists() {
|
||||||
|
let msg = format!("{}: {}", self.locales.t.path_not_exist, expanded);
|
||||||
|
self.set_status(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.set_status(format!("{} {}...", self.locales.t.scanning, expanded));
|
||||||
|
let new_tracks = scan_source(&path);
|
||||||
|
if new_tracks.is_empty() {
|
||||||
|
let msg = format!("{} {}", self.locales.t.no_tracks_in, expanded);
|
||||||
|
self.set_status(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let existing: HashSet<PathBuf> = self.raw_tracks.iter().map(|t| t.path.clone()).collect();
|
||||||
|
let mut added = 0;
|
||||||
|
for track in new_tracks {
|
||||||
|
if !existing.contains(&track.path) && !self.deleted_paths.contains(&track.path) {
|
||||||
|
self.raw_tracks.push(track);
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !self.sources.contains(&expanded) { self.sources.push(expanded.clone()); }
|
||||||
|
self.rebuild_library();
|
||||||
|
crate::state::save_cache(&self.raw_tracks);
|
||||||
|
self.set_status(format!("{} {} {}", 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(self.locales.t.usage_load.clone());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let expanded = expand_tilde(raw_path);
|
||||||
|
let path = PathBuf::from(&expanded);
|
||||||
|
if !path.exists() {
|
||||||
|
let msg = format!("{}: {}", self.locales.t.path_not_exist, expanded);
|
||||||
|
self.set_status(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.set_status(format!("{} {}...", self.locales.t.loading, expanded));
|
||||||
|
let new_tracks = scan_source(&path);
|
||||||
|
if new_tracks.is_empty() {
|
||||||
|
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.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(self.locales.t.usage_rm.clone()); return; }
|
||||||
|
};
|
||||||
|
if idx >= self.sources.len() {
|
||||||
|
let msg = format!("{}: {}", self.locales.t.no_source_with_index, idx);
|
||||||
|
self.set_status(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let removed = self.sources.remove(idx);
|
||||||
|
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(self.locales.t.already_empty.clone());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.sources.clear();
|
||||||
|
self.raw_tracks.clear();
|
||||||
|
self.deleted_paths.clear();
|
||||||
|
self.playing_index = None;
|
||||||
|
self.player.stop();
|
||||||
|
self.rebuild_library();
|
||||||
|
crate::state::save_cache(&self.raw_tracks);
|
||||||
|
self.set_status(self.locales.t.all_sources_cleared.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_from_sources(&mut self) {
|
||||||
|
let mut all = Vec::new();
|
||||||
|
for src in &self.sources {
|
||||||
|
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();
|
||||||
|
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();
|
||||||
|
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(self.locales.t.usage_savepl.clone());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let path = PathBuf::from(expand_tilde(raw_path));
|
||||||
|
let is_pls = path.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|s| s.eq_ignore_ascii_case("pls"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let tracks: Vec<Track> = self.raw_tracks.iter()
|
||||||
|
.filter(|t| !self.deleted_paths.contains(&t.path))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let res = if is_pls {
|
||||||
|
crate::track::save_playlist_pls(&path, &tracks)
|
||||||
|
} else {
|
||||||
|
crate::track::save_playlist_m3u(&path, &tracks)
|
||||||
|
};
|
||||||
|
match res {
|
||||||
|
Ok(_) => self.set_status(format!("{}: {}", self.locales.t.playlist_saved, path.display())),
|
||||||
|
Err(e) => self.set_status(format!("{}: {}", self.locales.t.save_error, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_selected(&mut self) {
|
||||||
|
match self.active_panel {
|
||||||
|
Panel::Left => self.delete_current_left_group(),
|
||||||
|
Panel::Right => self.delete_current_right_item(),
|
||||||
|
}
|
||||||
|
self.rebuild_library();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_current_left_group(&mut self) {
|
||||||
|
let Some(group) = self.library.artists.get(self.library.artist_selected) else { return; };
|
||||||
|
let group_name = group.name.clone();
|
||||||
|
let to_remove: Vec<PathBuf> = group.rows.iter()
|
||||||
|
.filter_map(|r| match r {
|
||||||
|
RightRow::Track { index_in_library } => {
|
||||||
|
self.library.track(*index_in_library).map(|t| t.path.clone())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let count = to_remove.len();
|
||||||
|
for p in &to_remove { self.deleted_paths.insert(p.clone()); }
|
||||||
|
self.raw_tracks.retain(|t| !self.deleted_paths.contains(&t.path));
|
||||||
|
if let Some(idx) = self.playing_index {
|
||||||
|
if let Some(t) = self.library.track(idx) {
|
||||||
|
if to_remove.contains(&t.path) {
|
||||||
|
self.player.stop();
|
||||||
|
self.playing_index = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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; };
|
||||||
|
match row {
|
||||||
|
RightRow::Track { index_in_library } => {
|
||||||
|
if let Some(track) = self.library.track(index_in_library).cloned() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
let msg = format!("{}: {}", self.locales.t.deleted, name);
|
||||||
|
self.set_status(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RightRow::AlbumHeader { name, .. } => {
|
||||||
|
let start = self.library.right_selected + 1;
|
||||||
|
let mut to_remove: Vec<PathBuf> = Vec::new();
|
||||||
|
for r in rows.iter().skip(start) {
|
||||||
|
match r {
|
||||||
|
RightRow::Track { index_in_library } => {
|
||||||
|
if let Some(t) = self.library.track(*index_in_library) {
|
||||||
|
to_remove.push(t.path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RightRow::AlbumHeader { .. } => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let count = to_remove.len();
|
||||||
|
for p in &to_remove { self.deleted_paths.insert(p.clone()); }
|
||||||
|
self.raw_tracks.retain(|t| !self.deleted_paths.contains(&t.path));
|
||||||
|
if let Some(idx) = self.playing_index {
|
||||||
|
if let Some(t) = self.library.track(idx) {
|
||||||
|
if to_remove.contains(&t.path) {
|
||||||
|
self.player.stop();
|
||||||
|
self.playing_index = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(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(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(self.locales.t.library_empty.clone()); return; }
|
||||||
|
let prev = match self.playing_index {
|
||||||
|
Some(0) => {
|
||||||
|
if self.repeat == RepeatMode::Off {
|
||||||
|
self.set_status(self.locales.t.first_track.clone());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
total - 1
|
||||||
|
}
|
||||||
|
Some(i) => i - 1,
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
self.play_index(prev);
|
||||||
|
self.library.sync_with_playing(prev);
|
||||||
|
}
|
||||||
|
|
||||||
fn persist(&self) {
|
fn persist(&self) {
|
||||||
|
crate::state::save_cache(&self.raw_tracks);
|
||||||
|
let playing_path = self.playing_index.and_then(|i| self.library.track(i))
|
||||||
|
.map(|t| t.path.to_string_lossy().to_string());
|
||||||
|
let playing_pos = if self.playing_index.is_some() {
|
||||||
|
Some(self.player.position().as_secs())
|
||||||
|
} else { None };
|
||||||
let state = SavedState {
|
let state = SavedState {
|
||||||
last_path: Some(self.music_dir.clone()),
|
last_path: Some(self.default_dir.clone()),
|
||||||
volume: Some(self.player.volume()),
|
volume: Some(self.player.volume()),
|
||||||
sort_by_filename: Some(self.library.sort_mode == SortMode::ByFilename),
|
sort_mode: Some(self.library.sort_mode),
|
||||||
repeat: Some(self.repeat),
|
repeat: Some(self.repeat),
|
||||||
shuffle: Some(self.shuffle),
|
shuffle: Some(self.shuffle),
|
||||||
|
last_playing_path: playing_path,
|
||||||
|
last_playing_pos_secs: playing_pos,
|
||||||
|
sources: self.sources.clone(),
|
||||||
|
lang: Some(self.locales.current.clone()),
|
||||||
};
|
};
|
||||||
crate::state::save(&state);
|
crate::state::save(&state);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_exit(&self) {
|
pub fn on_exit(&self) { self.persist(); }
|
||||||
self.persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Поиск ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn start_search(&mut self) {
|
pub fn start_search(&mut self) {
|
||||||
self.input_mode = InputMode::Search;
|
self.input_mode = InputMode::Search;
|
||||||
self.search_buffer.clear();
|
self.search_buffer.clear();
|
||||||
self.status = "Поиск: ".into();
|
self.status_set_at = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cancel_search(&mut self) {
|
pub fn cancel_search(&mut self) {
|
||||||
self.input_mode = InputMode::Normal;
|
self.input_mode = InputMode::Normal;
|
||||||
self.search_buffer.clear();
|
self.search_buffer.clear();
|
||||||
self.rebuild_library();
|
self.rebuild_library();
|
||||||
self.status = "Поиск отменён".into();
|
self.set_status(self.locales.t.search_cancelled.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn commit_search(&mut self) {
|
pub fn commit_search(&mut self) {
|
||||||
self.input_mode = InputMode::Normal;
|
self.input_mode = InputMode::Normal;
|
||||||
self.status = if self.search_buffer.is_empty() {
|
let msg = if self.search_buffer.is_empty() {
|
||||||
"Фильтр сброшен".into()
|
self.locales.t.filter_reset.clone()
|
||||||
} else {
|
} 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) {
|
pub fn search_push(&mut self, c: char) {
|
||||||
self.search_buffer.push(c);
|
self.search_buffer.push(c);
|
||||||
self.rebuild_library();
|
self.rebuild_library();
|
||||||
self.status = format!("Поиск: {}", self.search_buffer);
|
self.status_set_at = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn search_pop(&mut self) {
|
pub fn search_pop(&mut self) {
|
||||||
self.search_buffer.pop();
|
self.search_buffer.pop();
|
||||||
self.rebuild_library();
|
self.rebuild_library();
|
||||||
self.status = format!("Поиск: {}", self.search_buffer);
|
self.status_set_at = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rebuild_library(&mut self) {
|
fn rebuild_library(&mut self) {
|
||||||
let mode = self.library.sort_mode;
|
let mode = self.library.sort_mode;
|
||||||
|
// Передаём по владению — без лишнего .cloned() → Vec<_>
|
||||||
|
let filtered: Vec<Track> = self
|
||||||
|
.raw_tracks
|
||||||
|
.iter()
|
||||||
|
.filter(|t| !self.deleted_paths.contains(&t.path))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
self.library = Library::build_with_filter(
|
self.library = Library::build_with_filter(
|
||||||
self.raw_tracks.clone(),
|
filtered,
|
||||||
mode,
|
mode,
|
||||||
self.search_buffer.clone(),
|
self.search_buffer.clone(),
|
||||||
|
self.locales.t.all_files.clone(),
|
||||||
);
|
);
|
||||||
if let Some(i) = self.playing_index {
|
if let Some(i) = self.playing_index {
|
||||||
self.library.sync_with_playing(i);
|
self.library.sync_with_playing(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Навигация ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn up(&mut self) {
|
pub fn up(&mut self) {
|
||||||
match self.active_panel {
|
match self.active_panel {
|
||||||
Panel::Left => self.library.artist_move(-1),
|
Panel::Left => self.library.artist_move(-1),
|
||||||
Panel::Right => self.library.right_move(-1),
|
Panel::Right => self.library.right_move(-1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn down(&mut self) {
|
pub fn down(&mut self) {
|
||||||
match self.active_panel {
|
match self.active_panel {
|
||||||
Panel::Left => self.library.artist_move(1),
|
Panel::Left => self.library.artist_move(1),
|
||||||
Panel::Right => self.library.right_move(1),
|
Panel::Right => self.library.right_move(1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_panel(&mut self) {
|
pub fn toggle_panel(&mut self) {
|
||||||
self.active_panel = match self.active_panel {
|
self.active_panel = match self.active_panel {
|
||||||
Panel::Left => Panel::Right,
|
Panel::Left => Panel::Right,
|
||||||
Panel::Right => Panel::Left,
|
Panel::Right => Panel::Left,
|
||||||
};
|
};
|
||||||
self.status = match self.active_panel {
|
let msg = match self.active_panel {
|
||||||
Panel::Left => "Активна: исполнители".into(),
|
Panel::Left => self.locales.t.left_panel.clone(),
|
||||||
Panel::Right => "Активна: альбомы/треки".into(),
|
Panel::Right => self.locales.t.right_panel.clone(),
|
||||||
};
|
};
|
||||||
|
self.set_status(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// f — прыжок к играющему треку
|
|
||||||
pub fn jump_to_playing(&mut self) {
|
pub fn jump_to_playing(&mut self) {
|
||||||
if let Some(i) = self.playing_index {
|
if let Some(i) = self.playing_index {
|
||||||
self.library.sync_with_playing(i);
|
self.library.sync_with_playing(i);
|
||||||
self.active_panel = Panel::Right;
|
self.active_panel = Panel::Right;
|
||||||
self.status = "→ к играющему треку".into();
|
self.set_status(self.locales.t.jumped_to_playing.clone());
|
||||||
} else {
|
} else {
|
||||||
self.status = "Ничего не играет".into();
|
self.set_status(self.locales.t.nothing_playing.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// g — в начало, G — в конец
|
|
||||||
pub fn goto_start(&mut self) {
|
pub fn goto_start(&mut self) {
|
||||||
match self.active_panel {
|
match self.active_panel {
|
||||||
Panel::Left => self.library.artist_selected = 0,
|
Panel::Left => {
|
||||||
|
self.library.artist_selected = 0;
|
||||||
|
self.library.left_state.select(Some(0));
|
||||||
|
}
|
||||||
Panel::Right => self.library.right_goto_start(),
|
Panel::Right => self.library.right_goto_start(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn goto_end(&mut self) {
|
pub fn goto_end(&mut self) {
|
||||||
match self.active_panel {
|
match self.active_panel {
|
||||||
Panel::Left => {
|
Panel::Left => {
|
||||||
if !self.library.artists.is_empty() {
|
if !self.library.artists.is_empty() {
|
||||||
self.library.artist_selected = self.library.artists.len() - 1;
|
self.library.artist_selected = self.library.artists.len() - 1;
|
||||||
|
self.library.left_state.select(Some(self.library.artist_selected));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Panel::Right => self.library.right_goto_end(),
|
Panel::Right => self.library.right_goto_end(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Playback ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn play_index(&mut self, index_in_library: usize) {
|
pub fn play_index(&mut self, index_in_library: usize) {
|
||||||
let Some(track) = self.library.track(index_in_library).cloned() else {
|
let Some(track) = self.library.track(index_in_library).cloned() else { return; };
|
||||||
return;
|
match self.player.play(&track.path, None) {
|
||||||
};
|
|
||||||
match self.player.play(&track.path) {
|
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.status = format!("▶ {} — {}", track.artist, track.title);
|
|
||||||
self.playing_index = Some(index_in_library);
|
self.playing_index = Some(index_in_library);
|
||||||
|
self.set_status(format!("{} - {}", 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.status = format!("Ошибка: {}", e),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,55 +615,36 @@ impl App {
|
|||||||
if let Some(idx) = self.library.right_track_index() {
|
if let Some(idx) = self.library.right_track_index() {
|
||||||
self.play_index(idx);
|
self.play_index(idx);
|
||||||
} else {
|
} else {
|
||||||
self.status = "Выделен заголовок альбома".into();
|
self.set_status(self.locales.t.selected_group.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop(&mut self) {
|
pub fn stop(&mut self) {
|
||||||
self.player.stop();
|
self.player.stop();
|
||||||
self.playing_index = None;
|
self.playing_index = None;
|
||||||
self.status = "Остановлено".into();
|
self.set_status(self.locales.t.stopped.clone());
|
||||||
}
|
}
|
||||||
|
pub fn toggle_pause(&mut self) { self.player.toggle_pause(); }
|
||||||
|
|
||||||
pub fn toggle_pause(&mut self) {
|
|
||||||
self.player.toggle_pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Автопереход с учётом repeat и shuffle
|
|
||||||
pub fn maybe_advance(&mut self) {
|
pub fn maybe_advance(&mut self) {
|
||||||
if self.playing_index.is_none() || !self.player.is_finished() {
|
if self.playing_index.is_none() || !self.player.is_finished() { return; }
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Repeat One — играем тот же трек
|
|
||||||
if self.repeat == RepeatMode::One {
|
if self.repeat == RepeatMode::One {
|
||||||
if let Some(i) = self.playing_index {
|
if let Some(i) = self.playing_index { self.play_index(i); }
|
||||||
self.play_index(i);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let total = self.library.all_tracks.len();
|
let total = self.library.all_tracks.len();
|
||||||
if total == 0 {
|
if total == 0 { return; }
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let next = if self.shuffle {
|
let next = if self.shuffle {
|
||||||
// Псевдослучайный выбор без rand-крейта
|
// LCG вместо SystemTime — без сисвызова
|
||||||
let seed = std::time::SystemTime::now()
|
self.shuffle_seed = self.shuffle_seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
(self.shuffle_seed >> 33) as usize % total
|
||||||
.map(|d| d.as_nanos() as usize)
|
|
||||||
.unwrap_or(0);
|
|
||||||
seed % total
|
|
||||||
} else {
|
} else {
|
||||||
match self.playing_index {
|
match self.playing_index {
|
||||||
Some(i) if i + 1 < total => i + 1,
|
Some(i) if i + 1 < total => i + 1,
|
||||||
_ => {
|
_ => {
|
||||||
if self.repeat == RepeatMode::All {
|
if self.repeat == RepeatMode::All { 0 }
|
||||||
0
|
else {
|
||||||
} else {
|
self.set_status(self.locales.t.end_of_playlist.clone());
|
||||||
// Off — останавливаемся
|
|
||||||
self.status = "Конец плейлиста".into();
|
|
||||||
self.playing_index = None;
|
self.playing_index = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -275,88 +662,69 @@ impl App {
|
|||||||
RepeatMode::All => RepeatMode::One,
|
RepeatMode::All => RepeatMode::One,
|
||||||
RepeatMode::One => RepeatMode::Off,
|
RepeatMode::One => RepeatMode::Off,
|
||||||
};
|
};
|
||||||
self.status = match self.repeat {
|
let msg = match self.repeat {
|
||||||
RepeatMode::Off => "Repeat: off".into(),
|
RepeatMode::Off => self.locales.t.repeat_off.clone(),
|
||||||
RepeatMode::All => "Repeat: all".into(),
|
RepeatMode::All => self.locales.t.repeat_all.clone(),
|
||||||
RepeatMode::One => "Repeat: one".into(),
|
RepeatMode::One => self.locales.t.repeat_one.clone(),
|
||||||
};
|
};
|
||||||
|
self.set_status(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_shuffle(&mut self) {
|
pub fn toggle_shuffle(&mut self) {
|
||||||
self.shuffle = !self.shuffle;
|
self.shuffle = !self.shuffle;
|
||||||
self.status = if self.shuffle {
|
let msg = if self.shuffle {
|
||||||
"Shuffle: on".into()
|
self.locales.t.shuffle_on.clone()
|
||||||
} else {
|
} else {
|
||||||
"Shuffle: off".into()
|
self.locales.t.shuffle_off.clone()
|
||||||
};
|
};
|
||||||
|
self.set_status(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Громкость, перемотка ───────────────────────────────────
|
|
||||||
|
|
||||||
pub fn volume_up(&mut self) {
|
pub fn volume_up(&mut self) {
|
||||||
self.player.change_volume(0.05);
|
self.player.change_volume(0.05);
|
||||||
self.status = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32);
|
self.set_status(format!("{}: {}%", self.locales.t.volume, (self.player.volume() * 100.0) as u32));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn volume_down(&mut self) {
|
pub fn volume_down(&mut self) {
|
||||||
self.player.change_volume(-0.05);
|
self.player.change_volume(-0.05);
|
||||||
self.status = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32);
|
self.set_status(format!("{}: {}%", self.locales.t.volume, (self.player.volume() * 100.0) as u32));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn seek(&mut self, delta_secs: i64) {
|
pub fn seek(&mut self, delta_secs: i64) {
|
||||||
if self.playing_index.is_none() {
|
if self.playing_index.is_none() {
|
||||||
self.status = "Ничего не воспроизводится".into();
|
self.set_status(self.locales.t.nothing_playing.clone());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let total = self
|
let total = self.playing_index.and_then(|i| self.library.track(i))
|
||||||
.playing_index
|
.map(|t| t.duration).unwrap_or(Duration::ZERO);
|
||||||
.and_then(|i| self.library.track(i))
|
|
||||||
.map(|t| t.duration)
|
|
||||||
.unwrap_or(Duration::ZERO);
|
|
||||||
|
|
||||||
match self.player.seek_by(delta_secs, total) {
|
match self.player.seek_by(delta_secs, total) {
|
||||||
Ok(new_pos) => {
|
Ok(new_pos) => {
|
||||||
let sign = if delta_secs >= 0 { "+" } else { "" };
|
let sign = if delta_secs >= 0 { "+" } else { "" };
|
||||||
self.status = format!(
|
let msg = format!("{} {}{} → {:02}:{:02}",
|
||||||
"Перемотка {}{}с → {:02}:{:02}",
|
self.locales.t.seek, sign, delta_secs,
|
||||||
sign,
|
new_pos.as_secs() / 60, new_pos.as_secs() % 60);
|
||||||
delta_secs,
|
self.set_status(msg);
|
||||||
new_pos.as_secs() / 60,
|
|
||||||
new_pos.as_secs() % 60,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Err(_) => self.status = "Перемотка недоступна".into(),
|
Err(_) => self.set_status(self.locales.t.seek_unavailable.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Перемотка по цифрам 1–9 (10%..90%)
|
|
||||||
pub fn seek_percent(&mut self, percent: u8) {
|
pub fn seek_percent(&mut self, percent: u8) {
|
||||||
if self.playing_index.is_none() {
|
if self.playing_index.is_none() {
|
||||||
self.status = "Ничего не воспроизводится".into();
|
self.set_status(self.locales.t.nothing_playing.clone());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let total = match self
|
let total = match self.playing_index.and_then(|i| self.library.track(i)).map(|t| t.duration) {
|
||||||
.playing_index
|
|
||||||
.and_then(|i| self.library.track(i))
|
|
||||||
.map(|t| t.duration)
|
|
||||||
{
|
|
||||||
Some(d) if !d.is_zero() => d,
|
Some(d) if !d.is_zero() => d,
|
||||||
_ => {
|
_ => { self.set_status(self.locales.t.unknown_duration.clone()); return; }
|
||||||
self.status = "Длительность неизвестна".into();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let target = total.mul_f32(percent as f32 / 100.0);
|
let target = total.mul_f32(percent as f32 / 100.0);
|
||||||
match self.player.seek_to(target) {
|
match self.player.seek_to(target) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.status = format!(
|
let msg = format!("→ {}% ({:02}:{:02})", percent,
|
||||||
"→ {}% ({:02}:{:02})",
|
target.as_secs() / 60, target.as_secs() % 60);
|
||||||
percent,
|
self.set_status(msg);
|
||||||
target.as_secs() / 60,
|
|
||||||
target.as_secs() % 60,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Err(_) => self.status = "Перемотка недоступна".into(),
|
Err(_) => self.set_status(self.locales.t.seek_unavailable.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,31 +737,45 @@ impl App {
|
|||||||
|
|
||||||
pub fn toggle_sort(&mut self) {
|
pub fn toggle_sort(&mut self) {
|
||||||
let new_mode = match self.library.sort_mode {
|
let new_mode = match self.library.sort_mode {
|
||||||
SortMode::ByArtist => SortMode::ByFilename,
|
SortMode::ByArtist => SortMode::ByAlbum,
|
||||||
|
SortMode::ByAlbum => SortMode::ByFilename,
|
||||||
SortMode::ByFilename => SortMode::ByArtist,
|
SortMode::ByFilename => SortMode::ByArtist,
|
||||||
};
|
};
|
||||||
|
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());
|
.map(|t| t.path.clone());
|
||||||
|
let filtered: Vec<Track> = self.raw_tracks.iter()
|
||||||
|
.filter(|t| !self.deleted_paths.contains(&t.path))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
self.library = Library::build_with_filter(
|
self.library = Library::build_with_filter(
|
||||||
self.raw_tracks.clone(),
|
filtered,
|
||||||
new_mode,
|
new_mode,
|
||||||
self.search_buffer.clone(),
|
self.search_buffer.clone(),
|
||||||
|
self.locales.t.all_files.clone(),
|
||||||
);
|
);
|
||||||
|
self.playing_index = playing_path.and_then(|p| self.library.all_tracks.iter().position(|t| t.path == p));
|
||||||
self.playing_index = playing_path
|
if let Some(i) = self.playing_index { self.library.sync_with_playing(i); }
|
||||||
.and_then(|p| self.library.all_tracks.iter().position(|t| t.path == p));
|
let msg = match new_mode {
|
||||||
|
SortMode::ByArtist => self.locales.t.sort_artist.clone(),
|
||||||
if let Some(i) = self.playing_index {
|
SortMode::ByAlbum => self.locales.t.sort_album.clone(),
|
||||||
self.library.sync_with_playing(i);
|
SortMode::ByFilename => self.locales.t.sort_file.clone(),
|
||||||
}
|
|
||||||
|
|
||||||
self.status = match new_mode {
|
|
||||||
SortMode::ByArtist => "Сортировка: по артисту/альбому".into(),
|
|
||||||
SortMode::ByFilename => "Сортировка: по имени файла".into(),
|
|
||||||
};
|
};
|
||||||
|
self.set_status(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn sources(&self) -> &[String] { &self.sources }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_tilde(path: &str) -> String {
|
||||||
|
if let Some(rest) = path.strip_prefix("~/") {
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
return home.join(rest).to_string_lossy().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if path == "~" {
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
return home.to_string_lossy().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.to_string()
|
||||||
}
|
}
|
||||||
53
src/audio.rs
53
src/audio.rs
@@ -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];
|
||||||
@@ -124,8 +142,8 @@ where
|
|||||||
fn total_duration(&self) -> Option<Duration> {
|
fn total_duration(&self) -> Option<Duration> {
|
||||||
self.inner.total_duration()
|
self.inner.total_duration()
|
||||||
}
|
}
|
||||||
// ВАЖНО: пробрасываем try_seek, иначе Sink::try_seek вернёт NotSupported
|
|
||||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||||
|
self.bus.clear(); // сброс спектра, чтобы не «дёргался»
|
||||||
self.inner.try_seek(pos)
|
self.inner.try_seek(pos)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,12 +168,23 @@ impl AudioPlayer {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn play(&mut self, path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn play(
|
||||||
|
&mut self,
|
||||||
|
path: &PathBuf,
|
||||||
|
start_pos: Option<Duration>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
self.sink.stop();
|
self.sink.stop();
|
||||||
self.paused_pos = None;
|
self.paused_pos = None;
|
||||||
|
|
||||||
let file = File::open(path)?;
|
let file = File::open(path)?;
|
||||||
// Decoder::try_from автоматически настраивает seekable и byte_len[citation:1][citation:19]
|
let mut decoder = Decoder::try_from(file)?;
|
||||||
let decoder = Decoder::try_from(file)?;
|
|
||||||
|
if let Some(pos) = start_pos {
|
||||||
|
if !pos.is_zero() {
|
||||||
|
let _ = decoder.try_seek(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let tapped = TapSource {
|
let tapped = TapSource {
|
||||||
inner: decoder,
|
inner: decoder,
|
||||||
bus: self.spectrum.clone(),
|
bus: self.spectrum.clone(),
|
||||||
|
|||||||
420
src/i18n.rs
Normal file
420
src/i18n.rs
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
221
src/library.rs
221
src/library.rs
@@ -1,14 +1,18 @@
|
|||||||
|
use ratatui::widgets::ListState;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::track::Track;
|
use crate::track::Track;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum RightRow {
|
pub enum RightRow {
|
||||||
AlbumHeader { name: String },
|
AlbumHeader { name: String, is_artist: bool },
|
||||||
Track { index_in_library: usize },
|
Track { index_in_library: usize },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum SortMode {
|
pub enum SortMode {
|
||||||
ByArtist,
|
ByArtist,
|
||||||
|
ByAlbum,
|
||||||
ByFilename,
|
ByFilename,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,21 +27,22 @@ pub struct Library {
|
|||||||
pub artist_selected: usize,
|
pub artist_selected: usize,
|
||||||
pub right_selected: usize,
|
pub right_selected: usize,
|
||||||
pub sort_mode: SortMode,
|
pub sort_mode: SortMode,
|
||||||
/// Активный фильтр поиска (пусто — без фильтра)
|
|
||||||
pub filter: String,
|
pub filter: String,
|
||||||
|
pub left_state: ListState,
|
||||||
|
pub right_state: ListState,
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
tracks.retain(|t| {
|
tracks.retain(|t| {
|
||||||
@@ -48,87 +53,121 @@ impl Library {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match mode {
|
match mode {
|
||||||
SortMode::ByArtist => {
|
SortMode::ByArtist => Self::build_by_artist(tracks, mode, filter),
|
||||||
use std::collections::BTreeMap;
|
SortMode::ByAlbum => Self::build_by_album(tracks, mode, filter),
|
||||||
|
SortMode::ByFilename => Self::build_by_filename(tracks, mode, filter, all_files_label),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut map: BTreeMap<String, BTreeMap<String, Vec<Track>>> = BTreeMap::new();
|
fn build_by_artist(mut tracks: Vec<Track>, mode: SortMode, filter: String) -> Self {
|
||||||
for t in tracks.drain(..) {
|
use std::collections::BTreeMap;
|
||||||
map.entry(t.artist.clone())
|
let mut map: BTreeMap<String, BTreeMap<String, Vec<Track>>> = BTreeMap::new();
|
||||||
.or_default()
|
for t in tracks.drain(..) {
|
||||||
.entry(t.album.clone())
|
map.entry(t.artist.clone())
|
||||||
.or_default()
|
.or_default()
|
||||||
.push(t);
|
.entry(t.album.clone())
|
||||||
}
|
.or_default()
|
||||||
|
.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
let mut all_tracks = Vec::new();
|
let mut all_tracks = Vec::new();
|
||||||
let mut artists = Vec::new();
|
let mut artists = Vec::new();
|
||||||
|
|
||||||
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.unwrap_or(u32::MAX).cmp(&b.track_number.unwrap_or(u32::MAX))
|
||||||
a.track_number
|
.then(a.title.cmp(&b.title))
|
||||||
.unwrap_or(u32::MAX)
|
|
||||||
.cmp(&b.track_number.unwrap_or(u32::MAX))
|
|
||||||
.then(a.title.cmp(&b.title))
|
|
||||||
});
|
|
||||||
|
|
||||||
rows.push(RightRow::AlbumHeader { name: album_name });
|
|
||||||
|
|
||||||
for t in album_tracks {
|
|
||||||
let idx = all_tracks.len();
|
|
||||||
all_tracks.push(t);
|
|
||||||
rows.push(RightRow::Track {
|
|
||||||
index_in_library: idx,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
artists.push(ArtistView {
|
|
||||||
name: artist_name,
|
|
||||||
rows,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Library {
|
|
||||||
all_tracks,
|
|
||||||
artists,
|
|
||||||
artist_selected: 0,
|
|
||||||
right_selected: 0,
|
|
||||||
sort_mode: mode,
|
|
||||||
filter,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SortMode::ByFilename => {
|
|
||||||
tracks.sort_by(|a, b| a.path.cmp(&b.path));
|
|
||||||
let all_tracks = tracks;
|
|
||||||
|
|
||||||
let mut rows = Vec::new();
|
|
||||||
rows.push(RightRow::AlbumHeader {
|
|
||||||
name: "Все файлы".into(),
|
|
||||||
});
|
});
|
||||||
for i in 0..all_tracks.len() {
|
rows.push(RightRow::AlbumHeader { name: album_name, is_artist: false });
|
||||||
rows.push(RightRow::Track {
|
for t in album_tracks {
|
||||||
index_in_library: i,
|
let idx = all_tracks.len();
|
||||||
});
|
all_tracks.push(t);
|
||||||
}
|
rows.push(RightRow::Track { index_in_library: idx });
|
||||||
|
|
||||||
let artists = vec![ArtistView {
|
|
||||||
name: "Все файлы".into(),
|
|
||||||
rows,
|
|
||||||
}];
|
|
||||||
|
|
||||||
Library {
|
|
||||||
all_tracks,
|
|
||||||
artists,
|
|
||||||
artist_selected: 0,
|
|
||||||
right_selected: 0,
|
|
||||||
sort_mode: mode,
|
|
||||||
filter,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
artists.push(ArtistView { name: artist_name, rows });
|
||||||
|
}
|
||||||
|
Self::finalize(all_tracks, artists, mode, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_by_album(mut tracks: Vec<Track>, mode: SortMode, filter: String) -> Self {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
let mut map: BTreeMap<String, BTreeMap<String, Vec<Track>>> = BTreeMap::new();
|
||||||
|
for t in tracks.drain(..) {
|
||||||
|
map.entry(t.album.clone())
|
||||||
|
.or_default()
|
||||||
|
.entry(t.artist.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut all_tracks = Vec::new();
|
||||||
|
let mut albums = Vec::new();
|
||||||
|
|
||||||
|
for (album_name, artists_map) in map {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for (artist_name, mut artist_tracks) in artists_map {
|
||||||
|
artist_tracks.sort_by(|a, b| {
|
||||||
|
a.track_number.unwrap_or(u32::MAX).cmp(&b.track_number.unwrap_or(u32::MAX))
|
||||||
|
.then(a.title.cmp(&b.title))
|
||||||
|
});
|
||||||
|
rows.push(RightRow::AlbumHeader { name: artist_name, 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
albums.push(ArtistView { name: album_name, rows });
|
||||||
|
}
|
||||||
|
Self::finalize(all_tracks, albums, mode, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_by_filename(
|
||||||
|
mut tracks: Vec<Track>,
|
||||||
|
mode: SortMode,
|
||||||
|
filter: String,
|
||||||
|
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: all_files_label, is_artist: false });
|
||||||
|
for i in 0..all_tracks.len() {
|
||||||
|
rows.push(RightRow::Track { index_in_library: i });
|
||||||
|
}
|
||||||
|
|
||||||
|
let groups = vec![ArtistView { name: String::new(), rows }];
|
||||||
|
Self::finalize(all_tracks, groups, mode, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize(
|
||||||
|
all_tracks: Vec<Track>,
|
||||||
|
groups: Vec<ArtistView>,
|
||||||
|
mode: SortMode,
|
||||||
|
filter: String,
|
||||||
|
) -> Self {
|
||||||
|
let right_selected = first_track_row_in(groups.first());
|
||||||
|
|
||||||
|
let mut left_state = ListState::default();
|
||||||
|
let mut right_state = ListState::default();
|
||||||
|
if !groups.is_empty() {
|
||||||
|
left_state.select(Some(0));
|
||||||
|
}
|
||||||
|
right_state.select(Some(right_selected));
|
||||||
|
|
||||||
|
Library {
|
||||||
|
all_tracks,
|
||||||
|
artists: groups,
|
||||||
|
artist_selected: 0,
|
||||||
|
right_selected,
|
||||||
|
sort_mode: mode,
|
||||||
|
filter,
|
||||||
|
left_state,
|
||||||
|
right_state,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +202,11 @@ impl Library {
|
|||||||
}
|
}
|
||||||
self.artist_selected = new as usize;
|
self.artist_selected = new as usize;
|
||||||
self.right_selected = self.first_track_row();
|
self.right_selected = self.first_track_row();
|
||||||
|
|
||||||
|
self.right_state = ListState::default();
|
||||||
|
|
||||||
|
self.left_state.select(Some(self.artist_selected));
|
||||||
|
self.right_state.select(Some(self.right_selected));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn right_move(&mut self, delta: isize) {
|
pub fn right_move(&mut self, delta: isize) {
|
||||||
@@ -188,9 +232,11 @@ impl Library {
|
|||||||
new_pos = len - 1;
|
new_pos = len - 1;
|
||||||
} else if new_pos >= len {
|
} else if new_pos >= len {
|
||||||
new_pos = 0;
|
new_pos = 0;
|
||||||
|
self.right_state = ListState::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
self.right_selected = track_rows[new_pos as usize];
|
self.right_selected = track_rows[new_pos as usize];
|
||||||
|
self.right_state.select(Some(self.right_selected));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn right_goto_start(&mut self) {
|
pub fn right_goto_start(&mut self) {
|
||||||
@@ -202,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) {
|
||||||
@@ -215,8 +258,16 @@ impl Library {
|
|||||||
}) {
|
}) {
|
||||||
self.artist_selected = ai;
|
self.artist_selected = ai;
|
||||||
self.right_selected = pos;
|
self.right_selected = pos;
|
||||||
|
self.left_state.select(Some(ai));
|
||||||
|
self.right_state.select(Some(pos));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_track_row_in(group: Option<&ArtistView>) -> usize {
|
||||||
|
group
|
||||||
|
.and_then(|a| a.rows.iter().position(|r| matches!(r, RightRow::Track { .. })))
|
||||||
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
144
src/main.rs
144
src/main.rs
@@ -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;
|
||||||
@@ -7,7 +8,7 @@ mod ui;
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
|
|
||||||
use crate::app::{App, InputMode};
|
use crate::app::{App, InputMode};
|
||||||
|
|
||||||
@@ -26,19 +27,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let mut app = App::new(&music_dir)?;
|
let mut app = App::new(&music_dir)?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
if app.request_quit { app.on_exit(); break; }
|
||||||
|
|
||||||
app.maybe_advance();
|
app.maybe_advance();
|
||||||
|
app.tick_status();
|
||||||
app.player.spectrum.recompute();
|
app.player.spectrum.recompute();
|
||||||
terminal.draw(|f| ui::draw(f, &mut app))?;
|
terminal.draw(|f| ui::draw(f, &mut app))?;
|
||||||
|
|
||||||
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.code, &mut app) {
|
|
||||||
app.on_exit();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,66 +46,82 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_key(code: KeyCode, app: &mut App) -> bool {
|
fn handle_key(key: KeyEvent, app: &mut App) -> bool {
|
||||||
// Режим поиска
|
// 1. F1 — всегда
|
||||||
if app.input_mode == InputMode::Search {
|
if key.code == KeyCode::F(1) { app.toggle_help(); return false; }
|
||||||
match code {
|
// 2. Модальные окна
|
||||||
KeyCode::Esc => app.cancel_search(),
|
if app.show_help { return handle_modal(key, app, true); }
|
||||||
KeyCode::Enter => app.commit_search(),
|
if app.show_libs { return handle_modal(key, app, false); }
|
||||||
KeyCode::Backspace => app.search_pop(),
|
// 3. Командный режим
|
||||||
KeyCode::Char(c) => app.search_push(c),
|
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 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; }
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
if key.code == KeyCode::Delete { app.delete_selected(); return false; }
|
||||||
|
|
||||||
// Обычный режим
|
match key.code {
|
||||||
match code {
|
KeyCode::Char(':') => { app.start_command(); return false; }
|
||||||
KeyCode::Char('+') | KeyCode::Char('=') => {
|
KeyCode::Char('+') | KeyCode::Char('=') => { app.volume_up(); return false; }
|
||||||
app.volume_up();
|
KeyCode::Char('-') | KeyCode::Char('_') => { app.volume_down(); return false; }
|
||||||
return false;
|
KeyCode::Up => { app.up(); return false; }
|
||||||
}
|
KeyCode::Down => { app.down(); return false; }
|
||||||
KeyCode::Char('-') | KeyCode::Char('_') => {
|
KeyCode::Left => { app.seek(-5); return false; }
|
||||||
app.volume_down();
|
KeyCode::Right => { app.seek(5); return false; }
|
||||||
return false;
|
KeyCode::Enter => { app.play_selected(); return false; }
|
||||||
}
|
KeyCode::Char(' ') => { app.toggle_pause(); return false; }
|
||||||
KeyCode::Up => {
|
KeyCode::Tab => { app.toggle_panel(); return false; }
|
||||||
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) = 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(),
|
||||||
@@ -116,14 +131,7 @@ fn handle_key(code: KeyCode, 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(); },
|
||||||
// 'g' (строчная) — в начало, 'G' (заглавная) — в конец
|
|
||||||
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(),
|
||||||
|
|||||||
48
src/state.rs
48
src/state.rs
@@ -3,13 +3,21 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::library::SortMode;
|
||||||
|
use crate::track::Track;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default)]
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
pub struct SavedState {
|
pub struct SavedState {
|
||||||
pub last_path: Option<String>,
|
pub last_path: Option<String>,
|
||||||
pub volume: Option<f32>,
|
pub volume: Option<f32>,
|
||||||
pub sort_by_filename: Option<bool>,
|
pub sort_mode: Option<SortMode>,
|
||||||
pub repeat: Option<RepeatMode>,
|
pub repeat: Option<RepeatMode>,
|
||||||
pub shuffle: Option<bool>,
|
pub shuffle: Option<bool>,
|
||||||
|
pub last_playing_path: Option<String>,
|
||||||
|
pub last_playing_pos_secs: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sources: Vec<String>,
|
||||||
|
pub lang: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)]
|
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)]
|
||||||
@@ -20,16 +28,26 @@ pub enum RepeatMode {
|
|||||||
One,
|
One,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_path() -> Option<PathBuf> {
|
pub fn config_path() -> Option<PathBuf> {
|
||||||
dirs::config_dir().map(|p| p.join("rumus").join("state.toml"))
|
dirs::config_dir().map(|p| p.join("rumus").join("state.toml"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cache_path() -> Option<PathBuf> {
|
||||||
|
dirs::data_dir().map(|p| p.join("rumus").join("library.bin"))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load() -> SavedState {
|
pub fn load() -> SavedState {
|
||||||
let Some(path) = config_path() else {
|
let Some(path) = config_path() else {
|
||||||
return SavedState::default();
|
return SavedState::default();
|
||||||
};
|
};
|
||||||
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,6 +60,28 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_cache() -> Option<Vec<Track>> {
|
||||||
|
let path = cache_path()?;
|
||||||
|
let bytes = fs::read(&path).ok()?;
|
||||||
|
bincode::deserialize(&bytes).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_cache(tracks: &[Track]) {
|
||||||
|
let Some(path) = cache_path() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
if let Ok(bytes) = bincode::serialize(&tracks.to_vec()) {
|
||||||
|
let _ = fs::write(path, bytes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
126
src/track.rs
126
src/track.rs
@@ -1,10 +1,12 @@
|
|||||||
use std::path::PathBuf;
|
use std::io::{BufWriter, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use lofty::file::{AudioFile, TaggedFileExt};
|
use lofty::file::{AudioFile, TaggedFileExt};
|
||||||
use lofty::tag::Accessor;
|
use lofty::tag::Accessor;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct Track {
|
pub struct Track {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
@@ -69,7 +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,14 +88,119 @@ 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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracks
|
tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scan_source(path: &Path) -> Vec<Track> {
|
||||||
|
if path.is_dir() {
|
||||||
|
scan_dir(&path.to_string_lossy())
|
||||||
|
} else if path.is_file() {
|
||||||
|
read_playlist(path)
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| Track::from_path(&p))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Плейлисты ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn read_playlist(path: &Path) -> Vec<PathBuf> {
|
||||||
|
let bytes = match std::fs::read(path) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = match std::str::from_utf8(&bytes) {
|
||||||
|
Ok(s) => s.to_string(),
|
||||||
|
Err(_) => {
|
||||||
|
let (decoded, _, _) = encoding_rs::WINDOWS_1251.decode(&bytes);
|
||||||
|
decoded.into_owned()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = content.trim_start_matches('\u{FEFF}');
|
||||||
|
|
||||||
|
let parent = path.parent().unwrap_or(Path::new(""));
|
||||||
|
let is_pls = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|s| s.eq_ignore_ascii_case("pls"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if is_pls {
|
||||||
|
parse_pls(content, parent)
|
||||||
|
} else {
|
||||||
|
parse_m3u(content, parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_m3u(content: &str, parent: &Path) -> Vec<PathBuf> {
|
||||||
|
content
|
||||||
|
.lines()
|
||||||
|
.map(|l| l.trim())
|
||||||
|
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||||
|
.map(|l| resolve_path(l, parent))
|
||||||
|
.filter(|p| p.exists())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_pls(content: &str, parent: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.to_lowercase().starts_with("file") {
|
||||||
|
if let Some((_, val)) = line.split_once('=') {
|
||||||
|
let p = resolve_path(val.trim(), parent);
|
||||||
|
if p.exists() {
|
||||||
|
files.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_path(s: &str, parent: &Path) -> PathBuf {
|
||||||
|
let p = PathBuf::from(s);
|
||||||
|
if p.is_relative() {
|
||||||
|
parent.join(p)
|
||||||
|
} else {
|
||||||
|
p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Запись через BufWriter — без промежуточной String на весь файл.
|
||||||
|
pub fn save_playlist_m3u(path: &Path, tracks: &[Track]) -> std::io::Result<()> {
|
||||||
|
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();
|
||||||
|
writeln!(w, "#EXTINF:{},{} - {}", dur, t.artist, t.title)?;
|
||||||
|
writeln!(w, "{}", t.path.display())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_playlist_pls(path: &Path, tracks: &[Track]) -> std::io::Result<()> {
|
||||||
|
let file = std::fs::File::create(path)?;
|
||||||
|
let mut w = BufWriter::new(file);
|
||||||
|
writeln!(w, "[playlist]")?;
|
||||||
|
for (i, t) in tracks.iter().enumerate() {
|
||||||
|
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())?;
|
||||||
|
}
|
||||||
|
writeln!(w, "NumberOfEntries={}", tracks.len())?;
|
||||||
|
writeln!(w, "Version=2")?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
468
src/ui.rs
468
src/ui.rs
@@ -1,21 +1,21 @@
|
|||||||
use ratatui::prelude::*;
|
use ratatui::prelude::*;
|
||||||
use ratatui::widgets::{
|
use ratatui::widgets::{
|
||||||
Bar, BarChart, BarGroup, Block, Borders, Gauge, List, ListItem, ListState, Paragraph,
|
Bar, BarChart, BarGroup, Block, Borders, Clear, List, ListItem, Paragraph, Wrap,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::app::{App, InputMode, Panel};
|
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(6), // эквалайзер
|
|
||||||
Constraint::Length(3), // прогресс
|
|
||||||
Constraint::Length(3), // статус + vol
|
|
||||||
])
|
|
||||||
.split(f.area());
|
.split(f.area());
|
||||||
|
|
||||||
let top = Layout::default()
|
let top = Layout::default()
|
||||||
@@ -23,19 +23,32 @@ pub fn draw(f: &mut Frame, app: &mut App) {
|
|||||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||||
.split(chunks[0]);
|
.split(chunks[0]);
|
||||||
|
|
||||||
draw_left_panel(f, app, top[0]);
|
let left_col = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Min(1), Constraint::Length(8)])
|
||||||
|
.split(top[0]);
|
||||||
|
|
||||||
|
draw_left_panel(f, app, left_col[0]);
|
||||||
|
draw_equalizer(f, app, left_col[1]);
|
||||||
draw_right_panel(f, app, top[1]);
|
draw_right_panel(f, app, top[1]);
|
||||||
draw_equalizer(f, app, chunks[1]);
|
draw_status(f, app, chunks[1]);
|
||||||
draw_progress(f, app, chunks[2]);
|
|
||||||
draw_status(f, app, chunks[3]);
|
if app.show_help {
|
||||||
|
draw_help(f, app, f.area());
|
||||||
|
}
|
||||||
|
if app.show_libs {
|
||||||
|
draw_libs(f, app, f.area());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_left_panel(f: &mut Frame, app: &App, area: Rect) {
|
fn draw_left_panel(f: &mut Frame, app: &mut App, area: Rect) {
|
||||||
|
f.render_widget(Clear, area);
|
||||||
|
|
||||||
let items: Vec<ListItem> = app
|
let items: Vec<ListItem> = app
|
||||||
.library
|
.library
|
||||||
.artists
|
.artists
|
||||||
.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 {
|
||||||
@@ -44,17 +57,26 @@ fn draw_left_panel(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
Style::default().add_modifier(Modifier::DIM)
|
Style::default().add_modifier(Modifier::DIM)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut list_state = ListState::default();
|
let sel = if app.library.artists.is_empty() {
|
||||||
if !app.library.artists.is_empty() {
|
None
|
||||||
list_state.select(Some(app.library.artist_selected));
|
} else {
|
||||||
}
|
Some(app.library.artist_selected)
|
||||||
|
};
|
||||||
|
app.library.left_state.select(sel);
|
||||||
|
|
||||||
|
let title_text = match app.library.sort_mode {
|
||||||
|
SortMode::ByArtist => &app.locales.t.artists,
|
||||||
|
SortMode::ByAlbum => &app.locales.t.albums,
|
||||||
|
SortMode::ByFilename => &app.locales.t.source,
|
||||||
|
};
|
||||||
|
let title = Span::styled(format!(" {} ", title_text), Style::default().fg(ACCENT));
|
||||||
|
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
.block(Block::default().borders(Borders::ALL).title(" Исполнители "))
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
.highlight_style(highlight)
|
.highlight_style(highlight)
|
||||||
.highlight_symbol("> ");
|
.highlight_symbol("> ");
|
||||||
|
|
||||||
f.render_stateful_widget(list, area, &mut list_state);
|
f.render_stateful_widget(list, area, &mut app.library.left_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fmt_dur(d: std::time::Duration) -> String {
|
fn fmt_dur(d: std::time::Duration) -> String {
|
||||||
@@ -65,90 +87,129 @@ fn fmt_dur(d: std::time::Duration) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_right_panel(f: &mut Frame, app: &App, area: Rect) {
|
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 => ("<unknown>".into(), String::new(), "--:--".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let style = if is_playing {
|
||||||
|
Style::default().fg(Color::Rgb(173, 255, 47))
|
||||||
|
} 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);
|
||||||
|
|
||||||
let rows = app.library.current_right_rows();
|
let rows = app.library.current_right_rows();
|
||||||
|
|
||||||
let items: Vec<ListItem> = rows
|
let items: Vec<ListItem> = rows
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(idx, row)| match row {
|
.map(|(idx, row)| match row {
|
||||||
RightRow::AlbumHeader { name } => {
|
RightRow::AlbumHeader { name, is_artist } => {
|
||||||
let mut album_total = std::time::Duration::ZERO;
|
make_album_header(app, idx, name, *is_artist, rows, area.width)
|
||||||
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() + 3)
|
|
||||||
.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::Track { index_in_library } => {
|
RightRow::Track { index_in_library } => {
|
||||||
let is_playing = Some(*index_in_library) == app.playing_index;
|
make_track_item(app, *index_in_library, area.width)
|
||||||
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 => ("<unknown>".into(), String::new(), "--:--".into()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let style = if is_playing {
|
|
||||||
Style::default().fg(Color::Green)
|
|
||||||
} else {
|
|
||||||
Style::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
ListItem::new(Line::from(vec![
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::raw(marker),
|
|
||||||
Span::styled(format!("{}{}", num, title), style),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(dur, Style::default().fg(Color::DarkGray)),
|
|
||||||
]))
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let title = match app.library.sort_mode {
|
let selected_name = app
|
||||||
|
.library
|
||||||
|
.artists
|
||||||
|
.get(app.library.artist_selected)
|
||||||
|
.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 => {
|
SortMode::ByArtist => {
|
||||||
let artist_name = app
|
if app.library.filter.is_empty() {
|
||||||
.library
|
format!(" {} - {} ", t.albums, selected_name)
|
||||||
.artists
|
} else {
|
||||||
.get(app.library.artist_selected)
|
format!(" {} - {} [{}] ", t.albums, selected_name, app.library.filter)
|
||||||
.map(|a| a.name.as_str())
|
}
|
||||||
.unwrap_or("—");
|
}
|
||||||
format!(" Альбомы и треки — {} ", artist_name)
|
SortMode::ByAlbum => {
|
||||||
|
if app.library.filter.is_empty() {
|
||||||
|
format!(" {} - {} ", t.artists, selected_name)
|
||||||
|
} else {
|
||||||
|
format!(" {} - {} [{}] ", t.artists, selected_name, app.library.filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SortMode::ByFilename => {
|
||||||
|
if app.library.filter.is_empty() {
|
||||||
|
format!(" {} ", t.source)
|
||||||
|
} else {
|
||||||
|
format!(" {} [{}] ", t.source, app.library.filter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SortMode::ByFilename => " Альбомы и треки ".into(),
|
|
||||||
};
|
};
|
||||||
|
let title = Span::styled(title_text, Style::default().fg(ACCENT));
|
||||||
|
|
||||||
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)
|
||||||
@@ -156,51 +217,49 @@ fn draw_right_panel(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
Style::default().add_modifier(Modifier::DIM)
|
Style::default().add_modifier(Modifier::DIM)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut list_state = ListState::default();
|
let sel = if rows.is_empty() {
|
||||||
if !rows.is_empty() {
|
None
|
||||||
list_state.select(Some(app.library.right_selected));
|
} else {
|
||||||
}
|
Some(app.library.right_selected)
|
||||||
|
};
|
||||||
|
app.library.right_state.select(sel);
|
||||||
|
|
||||||
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))
|
||||||
.highlight_style(highlight)
|
.highlight_style(highlight)
|
||||||
.highlight_symbol("> ");
|
.highlight_symbol("> ");
|
||||||
|
|
||||||
f.render_stateful_widget(list, area, &mut list_state);
|
f.render_stateful_widget(list, area, &mut app.library.right_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Цветной эквалайзер: 32 столбика с градиентом от синего к красному
|
|
||||||
fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) {
|
fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let inner_width = area.width.saturating_sub(1) as usize;
|
||||||
|
if inner_width == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let num_bars = (inner_width / 2).max(4);
|
||||||
let bars_data = app.player.spectrum.bars();
|
let bars_data = app.player.spectrum.bars();
|
||||||
let num_bars = 32;
|
let step = (bars_data.len() / num_bars).max(1);
|
||||||
|
|
||||||
let mut display: Vec<f32> = Vec::with_capacity(num_bars);
|
let mut display: Vec<f32> = Vec::with_capacity(num_bars);
|
||||||
for i in 0..num_bars {
|
for i in 0..num_bars {
|
||||||
let a = bars_data[i * 2];
|
let start = i * step;
|
||||||
let b = bars_data[i * 2 + 1];
|
let end = (start + step).min(bars_data.len());
|
||||||
display.push((a + b) / 2.0);
|
if start >= bars_data.len() {
|
||||||
|
display.push(0.0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sum: f32 = bars_data[start..end].iter().sum();
|
||||||
|
display.push(sum / (end - start).max(1) as f32);
|
||||||
}
|
}
|
||||||
|
|
||||||
let bars: Vec<Bar> = display
|
let bars: Vec<Bar> = display
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.map(|&v| {
|
||||||
.map(|(i, &v)| {
|
|
||||||
let ratio = i as f64 / (num_bars - 1).max(1) as f64;
|
|
||||||
let color = if ratio < 0.2 {
|
|
||||||
Color::Rgb(0, 100, 255)
|
|
||||||
} else if ratio < 0.4 {
|
|
||||||
Color::Rgb(0, 200, 255)
|
|
||||||
} else if ratio < 0.6 {
|
|
||||||
Color::Rgb(0, 255, 100)
|
|
||||||
} else if ratio < 0.8 {
|
|
||||||
Color::Rgb(255, 200, 0)
|
|
||||||
} else {
|
|
||||||
Color::Rgb(255, 50, 50)
|
|
||||||
};
|
|
||||||
|
|
||||||
Bar::default()
|
Bar::default()
|
||||||
.value((v * 100.0) as u64)
|
.value((v * 100.0) as u64)
|
||||||
.style(Style::default().fg(color))
|
.style(Style::default().fg(ACCENT))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -213,34 +272,18 @@ 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 draw_progress(f: &mut Frame, app: &App, area: Rect) {
|
fn bordered(title: impl Into<Line<'static>>) -> Block<'static> {
|
||||||
let (pos, total) = app.progress();
|
Block::default()
|
||||||
let ratio = if total.as_secs_f64() > 0.0 {
|
.borders(Borders::ALL)
|
||||||
(pos.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0)
|
.title(title)
|
||||||
} else {
|
.title_style(Style::default().fg(ACCENT))
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
let gauge = Gauge::default()
|
|
||||||
.block(Block::default().borders(Borders::ALL).title(" Прогресс "))
|
|
||||||
.gauge_style(Style::default().fg(Color::Green))
|
|
||||||
.ratio(ratio)
|
|
||||||
.label(format!(
|
|
||||||
"{:02}:{:02} / {:02}:{:02}",
|
|
||||||
pos.as_secs() / 60,
|
|
||||||
pos.as_secs() % 60,
|
|
||||||
total.as_secs() / 60,
|
|
||||||
total.as_secs() % 60,
|
|
||||||
));
|
|
||||||
|
|
||||||
f.render_widget(gauge, area);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_status(f: &mut Frame, app: &App, area: Rect) {
|
fn draw_status(f: &mut Frame, app: &App, area: Rect) {
|
||||||
@@ -248,21 +291,38 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Min(1),
|
Constraint::Min(1),
|
||||||
|
Constraint::Length(18),
|
||||||
Constraint::Length(16),
|
Constraint::Length(16),
|
||||||
Constraint::Length(12),
|
Constraint::Length(12),
|
||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
let (status_text, status_title) = if app.input_mode == InputMode::Search {
|
let t = &app.locales.t;
|
||||||
(format!("> {}", app.search_buffer), " Поиск ")
|
let (status_text, status_title) = match app.input_mode {
|
||||||
} else {
|
InputMode::Search => (format!(">{}", app.search_buffer), format!(" {} ", t.search)),
|
||||||
(app.status.clone(), " Статус ")
|
InputMode::Command => (format!(":{}", app.command_buffer), format!(" {} ", t.command)),
|
||||||
|
InputMode::Normal => {
|
||||||
|
let prefix = if app.player.is_paused() { "|| " } else { " " };
|
||||||
|
(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));
|
|
||||||
f.render_widget(status, inner[0]);
|
f.render_widget(status, inner[0]);
|
||||||
|
|
||||||
|
let (pos, total) = app.progress();
|
||||||
|
let time_text = format!(
|
||||||
|
"{:02}:{:02} / {:02}:{:02}",
|
||||||
|
pos.as_secs() / 60,
|
||||||
|
pos.as_secs() % 60,
|
||||||
|
total.as_secs() / 60,
|
||||||
|
total.as_secs() % 60,
|
||||||
|
);
|
||||||
|
let time = Paragraph::new(time_text)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(bordered(Line::from(format!(" {} ", t.time))));
|
||||||
|
f.render_widget(time, inner[1]);
|
||||||
|
|
||||||
let mode_str = match (app.repeat, app.shuffle) {
|
let mode_str = match (app.repeat, app.shuffle) {
|
||||||
(RepeatMode::Off, false) => "".to_string(),
|
(RepeatMode::Off, false) => "".to_string(),
|
||||||
(RepeatMode::Off, true) => "SHUF".to_string(),
|
(RepeatMode::Off, true) => "SHUF".to_string(),
|
||||||
@@ -273,12 +333,124 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
};
|
};
|
||||||
let mode = Paragraph::new(mode_str)
|
let mode = Paragraph::new(mode_str)
|
||||||
.alignment(Alignment::Center)
|
.alignment(Alignment::Center)
|
||||||
.block(Block::default().borders(Borders::ALL).title(" Mode "));
|
.block(bordered(Line::from(format!(" {} ", t.mode))));
|
||||||
f.render_widget(mode, inner[1]);
|
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::default().borders(Borders::ALL).title(" Звук "));
|
.block(bordered(Line::from(format!(" {} ", t.volume))));
|
||||||
f.render_widget(vol, inner[2]);
|
f.render_widget(vol, inner[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Общий хелпер для модальных окон.
|
||||||
|
fn draw_modal(f: &mut Frame, screen: Rect, title: String, lines: Vec<Line<'static>>, width: u16) {
|
||||||
|
let content_h = (lines.len() + 2) as u16;
|
||||||
|
let max_h = screen.height.saturating_sub(4);
|
||||||
|
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 para = Paragraph::new(lines)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(ACCENT))
|
||||||
|
.title(title),
|
||||||
|
)
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
|
||||||
|
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(
|
||||||
|
format!(" {} ", t.library),
|
||||||
|
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(format!(" {}", t.sources), header)),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (i, src) in app.sources().iter().enumerate() {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!(" [{}] ", i), dim),
|
||||||
|
Span::raw(src.clone()),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" :rm <N> — {}, Esc — {}", t.delete, t.close),
|
||||||
|
dim,
|
||||||
|
)));
|
||||||
|
|
||||||
|
draw_modal(f, screen, format!(" {} ", t.library), lines, 70);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 lines: Vec<Line<'static>> = vec![
|
||||||
|
Line::from(Span::styled(
|
||||||
|
t.help_title.clone(),
|
||||||
|
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
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(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(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(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(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(t.help_close.clone(), Style::default().fg(DIM))),
|
||||||
|
];
|
||||||
|
|
||||||
|
draw_modal(f, screen, format!(" {} ", t.help), lines, 62);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user