diff --git a/Cargo.lock b/Cargo.lock index 0697612..88182f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "array-init" version = "2.1.0" @@ -653,6 +662,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dasp_frame" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6" +dependencies = [ + "dasp_sample", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -728,6 +746,18 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ebur128" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e227cc62d64d6fe01abbef48134b9c1f17d470cef1e7a56337ad05b1f81df7f9" +dependencies = [ + "bitflags 1.3.2", + "dasp_frame", + "dasp_sample", + "smallvec", +] + [[package]] name = "ecdsa" version = "0.17.0-rc.7" @@ -1464,8 +1494,10 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "encoding_rs", "lofty", "nucleo-matcher", + "quick-xml", "serde", "serde_json", "smbfs", @@ -2099,6 +2131,7 @@ dependencies = [ "symphonia", "textfix", "thiserror 1.0.69", + "timestretch", "ureq", ] @@ -2190,6 +2223,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.11" @@ -3488,6 +3530,19 @@ dependencies = [ "time-core", ] +[[package]] +name = "timestretch" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93b9034134a68cc86fb8c3fc359b37af8e56d0302cff6ac729e85edb9bef722" +dependencies = [ + "arc-swap", + "ebur128", + "rustfft", + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.12.0" diff --git a/Cargo.toml b/Cargo.toml index e4644f2..56ff872 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = [ "crates/player", "crates/library", "crates/tui", - "crates/nsm", + "crates/nm", "crates/textfix", "crates/smbfs", ] diff --git a/README.en.md b/README.en.md index 1c80746..89859ca 100644 --- a/README.en.md +++ b/README.en.md @@ -11,12 +11,18 @@ A terminal music player written in Rust — something like [cmus](https://cmus.g - **Transport**: shuffle, repeat (off/all/single track), playback speed (0.25×–1.75×), mute, real seeking with position tracking - **`.cue` sheets**: a single physical file (e.g. a whole album as one FLAC) is correctly recognized as multiple tracks with proper titles and boundaries - **Equalizer**: 8 bands, custom biquad filters +- **Independent speed and pitch**: changing speed doesn't change the voice's timbre (pitch-preserving time-stretch with overlap-add, no clicks at chunk boundaries — verified numerically), pitch can be shifted separately and independently +- **Volume boost**: up to 300% above nominal, with soft (not hard) peak limiting — for quiet recordings +- **Smart rewind after pause**: the longer the pause, the bigger the rewind on resume — so you don't lose your place +- **Silence skip**: automatically fast-forwards through drawn-out silence, leaves short pauses in speech alone +- **Track technical info**: codec, sample rate, channels, bit depth, bitrate (honestly computed from file size and duration — correct for VBR too), file size — right under the equalizer - **Cover art**: rendered directly in the terminal (kitty/sixel/iTerm2 protocols, falling back to block graphics) - **Spectrum analyzer**: real-time FFT (not a fake animation) — toggle cover/spectrum with `v`, expand to fullscreen with `z` (transport controls — play/pause, next/prev, seek, mute — still work while fullscreen) - **Fade-out at track end**: the last 4 seconds smoothly fade to silence — toggle with `F`, off by default - **Loudness normalization**: ReplayGain tag takes priority if present in the file, otherwise an adaptive leveler (AGC) adjusts volume on the fly — toggle with `G`, off by default - **Internet radio**: add your own stations by URL, a curated built-in list (SomaFM, Radio Paradise), station search via the open [Radio Browser API](https://api.radio-browser.info) (auto-fails over between several mirrors if one is down), ICY metadata ("now playing" straight from the stream) - **SMB shares**: connect to network folders (including anonymous/guest access) — a pure-Rust SMB client (the `smb` crate), no system dependency on `gio`/gvfs +- **Podcasts**: subscribe by RSS URL, browse episodes, stream directly from the URL (with seeking — via real HTTP Range requests, if the server honestly supports them) or download to disk. Correctly honors the encoding declared in the feed (``) — a lot of older Russian-language podcasts are published exactly that way - **Listening history**: asks on first launch whether to remember what you play; stats (artist/track/date) as a chart — hourly for today, daily for the last week/month; can be toggled on/off anytime, resettable with confirmation - **New releases**: checks for new albums/EPs/singles **from artists already in your library**, via the open [MusicBrainz](https://musicbrainz.org) database (no API keys) — not a global chart, but specifically "what's new from the artists you actually listen to" - **Keyboard layout independent**: hotkeys work the same on both English and Russian (or other Cyrillic) keyboard layouts @@ -106,12 +112,15 @@ If no path is given, it defaults to `~/Music`. | Key | Action | |---|---| -| `Space` | play/pause | +| `Space` | play/pause (with "smart rewind" — the longer the pause was, the bigger the rewind on resume) | | `N` / `P` | next / previous track | | `s` | toggle shuffle | | `r` | repeat (off → all → single track → off) | | `m` | mute | -| `[` / `]` | speed down / up | +| `[` / `]` | speed down / up (preserves pitch) | +| `{` / `}` | pitch down / up (independent of speed) | +| `b` / `B` | volume boost down / up (up to 300%, with soft peak limiting) | +| `S` | toggle silence skip | | `←` / `→` | seek −5s / +5s | | `e` | focus the equalizer | | `h` | toggle listening history tracking | @@ -175,6 +184,19 @@ If no path is given, it defaults to `~/Music`. | `o` | search stations online | | `d` | remove a (custom) station | +### Podcasts + +| Key | Action | +|---|---| +| `j`/`k` | navigate | +| `Enter` | open podcast (subscription list) / stream episode (episode list, seekable if the server supports Range requests) | +| `u` | subscribe by RSS feed URL | +| `f` | refresh feed (pull in new episodes) | +| `d` | unsubscribe (subscription list) / download episode (episode list) | +| `Esc` | back to the subscription list | + +A downloaded episode is saved to `~/Music/Podcasts//.` and immediately added to the library as a regular local file. Streaming (`Enter` without downloading) now supports seeking too — via real HTTP Range requests (`Range: bytes=...`) straight to the episode's server, no download needed. Support is verified honestly by response code (`206 Partial Content`, not a `200 OK` that just ignores the header) — if a given server doesn't support Range, seeking simply won't work for that episode, no pretending. Downloading remains the most reliable option for long episodes or slow servers. + ### Stats | Key | Action | diff --git a/README.md b/README.md index 4b39b2a..09c21c3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# nm +# nsm Терминальный музыкальный плеер на Rust — что-то вроде [cmus](https://cmus.github.io/), но с нуля, с эквалайзером, поддержкой cue-листов, интернет-радио и SMB-шар. @@ -11,12 +11,19 @@ - **Транспорт**: shuffle, повтор (выкл/один трек/всё), изменение скорости воспроизведения (0.25×–1.75×), mute, честный seek (перемотка) с сохранением позиции - **`.cue`-листы**: один физический файл (например, целый альбом одним FLAC) корректно распознаётся как несколько треков с правильными названиями и границами - **Эквалайзер**: 8 полос, свои биквадратные фильтры +- **Раздельная скорость и высота голоса**: изменение скорости не меняет тембр (pitch-preserving time-stretch с overlap-add, без щелчков на стыках кусков — проверено численно), высоту можно двигать отдельно и независимо +- **Усиление громкости**: до 300% сверх номинальной, с мягким (не жёстким) ограничением пиков — для тихих записей +- **Умный откат после паузы**: чем дольше пауза, тем больше откат назад при возобновлении — не теряется нить разговора +- **Пропуск тишины**: автоматически проматывает затянувшуюся тишину, короткие паузы в речи не трогает +- **Техническая информация о треке**: кодек, частота дискретизации, каналы, битность, битрейт (честно посчитанный из размера файла и длительности — корректно и для VBR), размер файла — прямо под эквалайзером - **Обложка**: отображается прямо в терминале (kitty/sixel/iTerm2-протоколы с fallback на блочную графику) - **Анализатор спектра**: настоящий FFT в реальном времени (не анимация) — переключение обложка/спектр клавишей `v`, разворот на весь экран клавишей `z` (транспорт — пробел/next/prev/seek/mute — работает и в полноэкранном режиме) - **Затухание в конце трека**: последние 4 секунды перед концом плавно уводятся в тишину — включается клавишей `F`, по умолчанию выключено - **Выравнивание громкости**: тег ReplayGain в приоритете (если есть в файле), иначе — адаптивный левеллер (AGC), подстраивающий громкость на лету — клавиша `G`, по умолчанию выключено - **Интернет-радио**: свои станции по URL, встроенный список (SomaFM, Radio Paradise), поиск станций через открытый [Radio Browser API](https://api.radio-browser.info) (с автопереключением между несколькими зеркалами, если одно недоступно), ICY-метаданные ("сейчас играет" прямо из потока) - **SMB-шары**: подключение к сетевым папкам (в т.ч. анонимно/гостевой доступ) — свой SMB-клиент на чистом Rust (крейт `smb`), без системных зависимостей вроде `gio`/gvfs +- **Подкасты**: подписка по RSS-ссылке, список эпизодов, стриминг напрямую по ссылке (с перемоткой — через настоящие HTTP Range-запросы, если сервер их честно поддерживает) или скачивание на диск. Честно уважает заявленную в фиде кодировку (``) — многие старые русскоязычные подкасты именно так и оформлены +- **Аудиокниги**: отдельная библиотека — сама распознаёт структуру Автор/Книга (и глубже, любую вложенность), автоматически объединяет части книги (CD1/CD2, Part 1/Part 2 и т.п.) в одну, но не путает с этим разные книги в одной папке автора. Прогресс (файл+позиция) сохраняется между запусками; вкладки Новая/Слушаю/Прослушано переключаются автоматически по проценту прослушанного, без ручной пометки. Пересканирование идемпотентно — повторное указание той же папки обновляет уже известные книги, а не плодит дубли; книги с пропавшими папками убираются из списка сами. Главы — через уже существующие cue-листы. Автора/название можно поправить вручную (частая проблема аудиокниг — в тегах путают чтеца с автором) - **История прослушиваний**: при первом запуске спрашивает, запоминать ли прослушанное; статистика (артист/трек/дата) — диаграммой по часам за сегодня, по дням за неделю/месяц; включается/выключается в любой момент, сбрасывается с подтверждением - **Новые релизы**: проверка новых альбомов/EP/синглов **у артистов, уже имеющихся в твоей библиотеке**, через открытую базу [MusicBrainz](https://musicbrainz.org) (без ключей API) — не глобальный чарт, а именно "что вышло нового у тех, кого ты слушаешь" - **Раскладка**: хоткеи работают одинаково что на английской, что на русской раскладке @@ -63,11 +70,11 @@ xcode-select --install ```bash git clone -cd nm +cd nsm cargo build --release ``` -Бинарник появится в `target/release/nm` (на Windows — `target\release\nm.exe`). +Бинарник появится в `target/release/nsm` (на Windows — `target\release\nsm.exe`). ### Сборка Windows-бинарника с Linux (кросс-компиляция) @@ -95,7 +102,7 @@ cargo build --release --target x86_64-pc-windows-gnu ### Запуск ```bash -./target/release/nm /путь/к/музыке +./target/release/nsm /путь/к/музыке ``` Если путь не указан, по умолчанию используется `~/Music`. @@ -106,12 +113,15 @@ cargo build --release --target x86_64-pc-windows-gnu | Клавиша | Действие | |---|---| -| `Space` | play/pause | +| `Space` | play/pause (с "умным откатом" — чем дольше была пауза, тем больше откат назад при возобновлении) | | `N` / `P` | следующий / предыдущий трек | | `s` | shuffle вкл/выкл | | `r` | repeat (выкл → всё → один трек → выкл) | | `m` | mute | -| `[` / `]` | скорость воспроизведения вниз/вверх | +| `[` / `]` | скорость воспроизведения вниз/вверх (сохраняет высоту голоса) | +| `{` / `}` | высота голоса вниз/вверх (независимо от скорости) | +| `b` / `B` | усиление громкости вниз/вверх (до 300%, с мягким ограничением пиков) | +| `S` | пропуск затянувшейся тишины вкл/выкл | | `←` / `→` | перемотка −5с / +5с | | `e` | фокус на эквалайзер | | `h` | включить/выключить запись истории прослушиваний | @@ -175,6 +185,36 @@ cargo build --release --target x86_64-pc-windows-gnu | `o` | поиск станций онлайн | | `d` | удалить (только свою) станцию | +### Podcasts + +| Клавиша | Действие | +|---|---| +| `j`/`k` | навигация | +| `Enter` | открыть подкаст (список подписок) / стримить эпизод (список эпизодов, с перемоткой, если сервер поддерживает Range-запросы) | +| `u` | подписаться по URL RSS-фида | +| `f` | обновить фид (подтянуть новые выпуски) | +| `d` | отписаться (список подписок) / скачать эпизод (список эпизодов) | +| `Esc` | вернуться к списку подписок | + +Скачанный эпизод сохраняется в `~/Music/Podcasts/<название подкаста>/<название эпизода>.<расширение>` и сразу добавляется в библиотеку как обычный локальный файл. Стриминг (`Enter` без скачивания) теперь тоже поддерживает перемотку — через настоящие HTTP Range-запросы (`Range: bytes=...`) напрямую к серверу эпизода, без скачивания на диск. Честно проверяем поддержку сервером по коду ответа (`206 Partial Content`, а не `200 OK` с игнорированием заголовка) — если конкретный сервер Range не поддерживает, перемотка для этого эпизода просто не сработает, без притворства. Скачивание остаётся самым надёжным вариантом для длинных выпусков или медленных серверов. + +### Audiobooks + +| Клавиша | Действие | +|---|---| +| `j`/`k` | навигация | +| `Enter` | продолжить книгу с сохранённой позиции | +| `o` | открыть список файлов/глав книги (без немедленного проигрывания) | +| `p` | переключить фильтр статуса (New → Listening → Finished → All) | +| `/` | поиск по названию/автору | +| `L` | добавить папку для сканирования | +| `f` | пересканировать все добавленные папки | +| `t` | вручную поправить название/автора книги | +| `x` | отметить книгу для объединения / объединить с ранее отмеченной | +| `Esc` | вернуться к списку книг (из списка файлов) | + +Библиотека собирается из явно указанных папок (`L`) — никакого неявного доступа к остальной файловой системе. Понимает структуру `Автор/Книга` и произвольную вложенность глубже; если книга разбита на части (`CD1`/`CD2`, `Part 1`/`Part 2` и т.п. — с числом в имени папки), части автоматически объединяются в одну книгу, но папки с разными названиями книг под одним автором не путаются друг с другом. Прогресс (файл + позиция внутри него) сохраняется на диск каждые несколько секунд, переживает перезапуск и повторное сканирование. Статусы New/Listening/Finished вычисляются автоматически по проценту прослушанного — вручную не выставляются. Повторное указание той же папки не создаёт дублей (сверяется по набору папок книги), а книги, чьи папки пропали с диска, автоматически убираются из списка при следующем пересканировании. Главы читаются из `.cue`-листов рядом с аудиофайлом, если они есть. + ### Stats | Клавиша | Действие | diff --git a/crates/library/Cargo.toml b/crates/library/Cargo.toml index c475ccd..c619009 100644 --- a/crates/library/Cargo.toml +++ b/crates/library/Cargo.toml @@ -15,6 +15,8 @@ ureq = { version = "2", features = ["json"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } textfix = { path = "../textfix" } smbfs = { path = "../smbfs" } +quick-xml = "0.36" +encoding_rs = "0.8" [dev-dependencies] tempfile = "3" diff --git a/crates/library/src/audiobook.rs b/crates/library/src/audiobook.rs new file mode 100644 index 0000000..1304b08 --- /dev/null +++ b/crates/library/src/audiobook.rs @@ -0,0 +1,670 @@ +//! Аудиокниги — отдельная от обычной музыкальной библиотеки сущность: книга состоит из одной +//! или нескольких папок с аудиофайлами (возможно, разбитая на части/CD), у неё есть позиция +//! прослушивания (файл + смещение), сохраняемая между запусками, и статус (Новая/Слушаю/ +//! Прослушано), вычисляемый автоматически по прогрессу. +//! +//! В отличие от обычной библиотеки (пересканируется с нуля при каждом запуске), у аудиокниг +//! есть персистентное состояние — прогресс и ручные правки метаданных не должны теряться при +//! повторном сканировании той же папки. Пересканирование идемпотентно: те же папки на диске +//! дают тот же набор книг (без дублей), обновляя уже известные и добавляя только новые; книги, +//! чьи папки пропали с диска, автоматически убираются из списка. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::cue; +use crate::model::is_audio_file; + +/// Порог прогресса, начиная с которого книга считается прослушанной. Не 100% — концовки +/// (титры, тишина) редко дослушивают строго до последней секунды, а 97%+ уже точно "дочитано". +const FINISHED_THRESHOLD: f64 = 0.97; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Chapter { + pub title: String, + pub start: Duration, + /// `None` — глава идёт до конца файла (последняя глава, либо файл без cue-листа — + /// тогда у него ровно одна "глава" на весь файл). + pub end: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AudiobookFile { + pub path: PathBuf, + pub duration: Option, + /// Главы из cue-листа, если он был рядом с файлом. Если cue не было — один элемент на + /// весь файл (title = имя файла), чтобы UI мог единообразно показывать "главу" всегда. + pub chapters: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AudiobookStatus { + New, + Listening, + Finished, +} + +impl AudiobookStatus { + pub fn label(self) -> &'static str { + match self { + AudiobookStatus::New => "New", + AudiobookStatus::Listening => "Listening", + AudiobookStatus::Finished => "Finished", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Audiobook { + /// Стабильный идентификатор, не меняется между пересканированиями (в отличие от путей, + /// которые могут переехать при объединении папок) — генерируется один раз при первом + /// обнаружении книги. + pub id: String, + /// Одна или несколько папок, из которых собрана книга (несколько — если объединили + /// вручную, или если авто-детект распознал разбивку на части/CD). + pub folders: Vec, + pub files: Vec, + pub author: String, + pub title: String, + /// true — автор/название заданы пользователем вручную, не перезаписывать при + /// пересканировании (частая проблема аудиокниг: в тегах чтец вместо автора и т.п.). + pub manual_metadata: bool, + pub position_file_index: usize, + pub position_offset: Duration, + pub total_duration: Duration, +} + +impl Audiobook { + pub fn progress_ratio(&self) -> f64 { + if self.total_duration.is_zero() { + return 0.0; + } + let played_before: Duration = self.files[..self.position_file_index.min(self.files.len())] + .iter() + .filter_map(|f| f.duration) + .sum(); + let played = played_before + self.position_offset; + (played.as_secs_f64() / self.total_duration.as_secs_f64()).clamp(0.0, 1.0) + } + + pub fn status(&self) -> AudiobookStatus { + let r = self.progress_ratio(); + if r <= 0.0005 { + AudiobookStatus::New + } else if r >= FINISHED_THRESHOLD { + AudiobookStatus::Finished + } else { + AudiobookStatus::Listening + } + } +} + +/// Персистентное хранилище: список отслеживаемых корневых папок + все известные книги. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AudiobookLibrary { + pub watched_roots: Vec, + pub books: Vec, +} + +pub struct AudiobookStore { + path: PathBuf, +} + +impl AudiobookStore { + pub fn new(path: PathBuf) -> Self { + AudiobookStore { path } + } + + pub fn load(&self) -> AudiobookLibrary { + std::fs::read_to_string(&self.path) + .ok() + .and_then(|data| serde_json::from_str(&data).ok()) + .unwrap_or_default() + } + + pub fn save(&self, lib: &AudiobookLibrary) -> anyhow::Result<()> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&self.path, serde_json::to_string_pretty(lib)?)?; + Ok(()) + } + + /// Добавляет корневую папку в список отслеживаемых (если такой ещё нет) и сразу + /// пересканирует ВСЕ отслеживаемые папки (не только новую) — просто и предсказуемо. + pub fn add_root_and_rescan(&self, root: &Path) -> anyhow::Result { + let mut lib = self.load(); + let root = root.to_path_buf(); + if !lib.watched_roots.iter().any(|r| r == &root) { + lib.watched_roots.push(root); + } + rescan(&mut lib)?; + self.save(&lib)?; + Ok(lib) + } + + pub fn rescan_all(&self) -> anyhow::Result { + let mut lib = self.load(); + rescan(&mut lib)?; + self.save(&lib)?; + Ok(lib) + } + + pub fn save_progress(&self, book_id: &str, file_index: usize, offset: Duration) -> anyhow::Result<()> { + let mut lib = self.load(); + if let Some(book) = lib.books.iter_mut().find(|b| b.id == book_id) { + book.position_file_index = file_index; + book.position_offset = offset; + } + self.save(&lib) + } + + pub fn set_manual_metadata(&self, book_id: &str, author: String, title: String) -> anyhow::Result<()> { + let mut lib = self.load(); + if let Some(book) = lib.books.iter_mut().find(|b| b.id == book_id) { + book.author = author; + book.title = title; + book.manual_metadata = true; + } + self.save(&lib) + } + + /// Объединяет несколько отдельно обнаруженных книг (по id) в одну — на случай, когда + /// автоматический детект не распознал, что несколько папок — части одной книги. + /// Итоговая книга получает id первой из объединяемых, суммарные файлы (в порядке + /// переданных id) и общую длительность; позиция сбрасывается на начало (объединение — + /// операция, меняющая структуру книги, безопаснее не пытаться угадать позицию заново). + pub fn merge_books(&self, ids: &[String]) -> anyhow::Result { + let mut lib = self.load(); + if ids.len() < 2 { + return Ok(lib); + } + let mut folders = Vec::new(); + let mut files = Vec::new(); + let mut total_duration = Duration::ZERO; + let mut first_title = None; + let mut first_author = None; + for id in ids { + if let Some(book) = lib.books.iter().find(|b| &b.id == id) { + folders.extend(book.folders.clone()); + files.extend(book.files.clone()); + total_duration += book.total_duration; + if first_title.is_none() { + first_title = Some(book.title.clone()); + first_author = Some(book.author.clone()); + } + } + } + let merged = Audiobook { + id: ids[0].clone(), + folders, + files, + author: first_author.unwrap_or_else(|| "Unknown Author".to_string()), + title: first_title.unwrap_or_else(|| "Merged book".to_string()), + manual_metadata: true, // объединение — явное пользовательское действие + position_file_index: 0, + position_offset: Duration::ZERO, + total_duration, + }; + lib.books.retain(|b| !ids.contains(&b.id)); + lib.books.push(merged); + self.save(&lib)?; + Ok(lib) + } +} + +/// Промежуточное представление "свежесканированной" книги — без id/прогресса, которые +/// проставляются/сохраняются уже при слиянии с уже известными книгами. +struct DiscoveredBook { + folders: Vec, + files: Vec, + author: String, + title: String, + total_duration: Duration, +} + +fn rescan(lib: &mut AudiobookLibrary) -> anyhow::Result<()> { + let mut discovered = Vec::new(); + for root in &lib.watched_roots { + if root.exists() { + scan_dir(root, root, &mut discovered)?; + } + } + + let mut merged = Vec::new(); + for disc in discovered { + if let Some(existing) = lib.books.iter().find(|b| b.folders == disc.folders) { + let mut updated = existing.clone(); + updated.files = disc.files; + updated.total_duration = disc.total_duration; + if !updated.manual_metadata { + updated.author = disc.author; + updated.title = disc.title; + } + // Клэмпим позицию на случай, если книга стала короче (например, файл убрали). + updated.position_file_index = updated.position_file_index.min(updated.files.len().saturating_sub(1)); + merged.push(updated); + } else { + merged.push(Audiobook { + id: generate_id(&disc.folders), + folders: disc.folders, + files: disc.files, + author: disc.author, + title: disc.title, + manual_metadata: false, + position_file_index: 0, + position_offset: Duration::ZERO, + total_duration: disc.total_duration, + }); + } + } + // Книги, чьи папки пропали с диска, просто не попадут сюда — `merged` строится только + // из того, что реально нашлось на диске сейчас. Это и есть авто-удаление. + lib.books = merged; + Ok(()) +} + +fn generate_id(folders: &[PathBuf]) -> String { + // Стабильный (детерминированный) id из путей — не случайный, чтобы при случайном + // повторном "discovered, но matched by folders failed из-за гонки" не плодить дубли; + // на практике matched-by-folders уже покрывает идемпотентность, это дополнительная + // подстраховка для читаемости/отладки (видно в JSON, какой книге он принадлежит). + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + for f in folders { + f.hash(&mut hasher); + } + format!("book_{:016x}", hasher.finish()) +} + +/// Рекурсивно обходит `dir` в поисках "книжных" папок. `scan_root` нужен только для того, +/// чтобы не считать корень сканирования сам по себе "автором" (если книга лежит прямо в +/// корне без отдельной папки автора). +fn scan_dir(dir: &Path, scan_root: &Path, out: &mut Vec) -> anyhow::Result<()> { + let mut direct_audio: Vec = Vec::new(); + let mut subdirs: Vec = Vec::new(); + + let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect(); + entries.sort_by_key(|e| e.file_name()); + + for entry in entries { + let path = entry.path(); + if path.is_dir() { + subdirs.push(path); + } else if is_audio_file(&path) { + direct_audio.push(path); + } + } + + if !direct_audio.is_empty() { + let book = build_book(vec![dir.to_path_buf()], &direct_audio, dir, scan_root)?; + out.push(book); + return Ok(()); + } + + if subdirs.is_empty() { + return Ok(()); + } + + // Папка сама не содержит аудио напрямую — либо это просто группировка (автор/серия), + // либо это книга, разбитая на части (CD1/CD2, Part 1/Part 2 и т.п.). Проверяем: если + // ВСЕ подпапки — "листья" с аудио напрямую, и их имена похожи на последовательные части, + // считаем это одной книгой, а не N отдельными. + let leaf_audio_dirs: Vec<&PathBuf> = subdirs.iter().filter(|d| dir_has_direct_audio(d)).collect(); + if leaf_audio_dirs.len() == subdirs.len() && subdirs.len() > 1 && looks_like_multipart(&subdirs) { + let mut all_files = Vec::new(); + for d in &subdirs { + let mut files: Vec = std::fs::read_dir(d)? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| is_audio_file(p)) + .collect(); + files.sort(); + all_files.extend(files); + } + let book = build_book(subdirs.clone(), &all_files, dir, scan_root)?; + out.push(book); + return Ok(()); + } + + // Иначе — обычная группировка, рекурсируем в каждую подпапку по отдельности. + for d in subdirs { + scan_dir(&d, scan_root, out)?; + } + Ok(()) +} + +fn dir_has_direct_audio(dir: &Path) -> bool { + std::fs::read_dir(dir) + .map(|entries| entries.filter_map(|e| e.ok()).any(|e| is_audio_file(&e.path()))) + .unwrap_or(false) +} + +/// Эвристика "похоже на последовательные части одной книги": имя папки содержит один из +/// маркеров (cd/disc/disk/part/диск/часть/глава) рядом с числом, либо имя целиком — число. +fn looks_like_multipart(dirs: &[PathBuf]) -> bool { + dirs.iter().all(|d| { + let name = d + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_lowercase(); + let has_digit = name.chars().any(|c| c.is_ascii_digit()); + if !has_digit { + return false; + } + let markers = [ + "cd", "disc", "disk", "part", "track", "диск", "часть", "глава", "cd_", "disc_", + ]; + let name_is_only_digits = name.chars().all(|c| c.is_ascii_digit() || c.is_whitespace()); + name_is_only_digits || markers.iter().any(|m| name.contains(m)) + }) +} + +/// Собирает `DiscoveredBook` из списка аудиофайлов одной книги: title = имя `book_dir`, +/// author = имя родительской папки (если это не сам корень сканирования), с фолбэком на +/// теги первого файла, если родительской папки нет. +fn build_book( + folders: Vec, + audio_files: &[PathBuf], + book_dir: &Path, + scan_root: &Path, +) -> anyhow::Result { + let mut files = Vec::new(); + let mut total_duration = Duration::ZERO; + + // .cue-листы в папке — сопоставляем с аудиофайлами по имени, чтобы получить главы. + let cue_files: Vec = std::fs::read_dir(book_dir)? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| crate::model::is_cue_file(p)) + .collect(); + + let mut sorted_audio = audio_files.to_vec(); + sorted_audio.sort(); + + for path in &sorted_audio { + let duration = probe_duration(path); + let chapters = cue_files + .iter() + .find_map(|cue_path| { + let sheet = cue::parse_cue_file(cue_path).ok()?; + if sheet.audio_path == *path { + Some(sheet) + } else { + None + } + }) + .map(|sheet| { + sheet + .tracks + .iter() + .enumerate() + .map(|(i, t)| Chapter { + title: t.title.clone(), + start: t.start, + end: sheet.tracks.get(i + 1).map(|next| next.start), + }) + .collect() + }) + .unwrap_or_else(|| { + vec![Chapter { + title: path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Chapter") + .to_string(), + start: Duration::ZERO, + end: None, + }] + }); + + if let Some(d) = duration { + total_duration += d; + } + files.push(AudiobookFile { + path: path.clone(), + duration, + chapters, + }); + } + + let title = book_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown Book") + .to_string(); + + let parent = book_dir.parent(); + let author = match parent { + Some(p) if p != scan_root => p + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "Unknown Author".to_string()), + _ => sorted_audio + .first() + .and_then(|p| tag_author(p)) + .unwrap_or_else(|| "Unknown Author".to_string()), + }; + + Ok(DiscoveredBook { + folders, + files, + author, + title, + total_duration, + }) +} + +fn probe_duration(path: &Path) -> Option { + use lofty::file::AudioFile; + lofty::probe::Probe::open(path) + .ok()? + .read() + .ok() + .map(|f| f.properties().duration()) + .filter(|d| *d != Duration::ZERO) +} + +fn tag_author(path: &Path) -> Option { + use lofty::file::TaggedFileExt; + use lofty::tag::Accessor; + let tagged = lofty::probe::Probe::open(path).ok()?.read().ok()?; + let tag = tagged.primary_tag().or_else(|| tagged.first_tag())?; + if let Some(v) = tag.get_string(&lofty::tag::ItemKey::AlbumArtist) { + return Some(v.to_string()); + } + tag.artist().map(|s| s.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn touch_mp3(path: &Path) { + fs::write(path, b"fake mp3 content").unwrap(); + } + + #[test] + fn detects_author_book_structure() { + let dir = tempfile::tempdir().unwrap(); + let book_dir = dir.path().join("Author Name").join("Book Title"); + fs::create_dir_all(&book_dir).unwrap(); + touch_mp3(&book_dir.join("01.mp3")); + + let mut discovered = Vec::new(); + scan_dir(dir.path(), dir.path(), &mut discovered).unwrap(); + + assert_eq!(discovered.len(), 1); + assert_eq!(discovered[0].title, "Book Title"); + assert_eq!(discovered[0].author, "Author Name"); + } + + #[test] + fn merges_cd_parts_into_one_book() { + let dir = tempfile::tempdir().unwrap(); + let book_dir = dir.path().join("Author").join("Big Book"); + fs::create_dir_all(book_dir.join("CD1")).unwrap(); + fs::create_dir_all(book_dir.join("CD2")).unwrap(); + touch_mp3(&book_dir.join("CD1").join("01.mp3")); + touch_mp3(&book_dir.join("CD2").join("01.mp3")); + + let mut discovered = Vec::new(); + scan_dir(dir.path(), dir.path(), &mut discovered).unwrap(); + + assert_eq!(discovered.len(), 1, "CD1/CD2 should merge into one book"); + assert_eq!(discovered[0].files.len(), 2); + assert_eq!(discovered[0].title, "Big Book"); + assert_eq!(discovered[0].folders.len(), 2); + } + + #[test] + fn does_not_merge_unrelated_subdirs() { + let dir = tempfile::tempdir().unwrap(); + let author_dir = dir.path().join("Author"); + fs::create_dir_all(author_dir.join("Book One")).unwrap(); + fs::create_dir_all(author_dir.join("Book Two")).unwrap(); + touch_mp3(&author_dir.join("Book One").join("01.mp3")); + touch_mp3(&author_dir.join("Book Two").join("01.mp3")); + + let mut discovered = Vec::new(); + scan_dir(dir.path(), dir.path(), &mut discovered).unwrap(); + + assert_eq!(discovered.len(), 2, "Book One/Book Two are different books, not parts"); + } + + #[test] + fn progress_and_status_transitions() { + let book = Audiobook { + id: "x".to_string(), + folders: vec![], + files: vec![ + AudiobookFile { path: PathBuf::from("a.mp3"), duration: Some(Duration::from_secs(100)), chapters: vec![] }, + AudiobookFile { path: PathBuf::from("b.mp3"), duration: Some(Duration::from_secs(100)), chapters: vec![] }, + ], + author: "A".to_string(), + title: "T".to_string(), + manual_metadata: false, + position_file_index: 0, + position_offset: Duration::ZERO, + total_duration: Duration::from_secs(200), + }; + assert_eq!(book.status(), AudiobookStatus::New); + + let mut listening = book.clone(); + listening.position_file_index = 1; + listening.position_offset = Duration::from_secs(50); + assert!((listening.progress_ratio() - 0.75).abs() < 0.001); + assert_eq!(listening.status(), AudiobookStatus::Listening); + + let mut finished = book.clone(); + finished.position_file_index = 1; + finished.position_offset = Duration::from_secs(99); + assert_eq!(finished.status(), AudiobookStatus::Finished); + } + + #[test] + fn idempotent_rescan_does_not_duplicate_and_preserves_progress() { + let dir = tempfile::tempdir().unwrap(); + let book_dir = dir.path().join("Author").join("Book"); + fs::create_dir_all(&book_dir).unwrap(); + touch_mp3(&book_dir.join("01.mp3")); + + let store_path = dir.path().join("audiobooks.json"); + let store = AudiobookStore::new(store_path); + + let lib1 = store.add_root_and_rescan(dir.path()).unwrap(); + assert_eq!(lib1.books.len(), 1); + let id = lib1.books[0].id.clone(); + + // Отмечаем прогресс, потом пересканируем ещё раз — не должно ни задублировать + // книгу, ни сбросить прогресс. + store.save_progress(&id, 0, Duration::from_secs(42)).unwrap(); + let lib2 = store.rescan_all().unwrap(); + assert_eq!(lib2.books.len(), 1, "must not duplicate on rescan"); + assert_eq!(lib2.books[0].id, id, "id must stay stable"); + assert_eq!(lib2.books[0].position_offset, Duration::from_secs(42), "progress must survive rescan"); + } + + #[test] + fn removes_books_whose_folder_disappeared() { + let dir = tempfile::tempdir().unwrap(); + let book_dir = dir.path().join("Author").join("Book"); + fs::create_dir_all(&book_dir).unwrap(); + touch_mp3(&book_dir.join("01.mp3")); + + let store_path = dir.path().join("audiobooks.json"); + let store = AudiobookStore::new(store_path); + let lib1 = store.add_root_and_rescan(dir.path()).unwrap(); + assert_eq!(lib1.books.len(), 1); + + fs::remove_dir_all(&book_dir).unwrap(); + let lib2 = store.rescan_all().unwrap(); + assert_eq!(lib2.books.len(), 0, "book with vanished folder must be removed"); + } + + #[test] + fn manual_metadata_survives_rescan() { + let dir = tempfile::tempdir().unwrap(); + let book_dir = dir.path().join("Author").join("Book"); + fs::create_dir_all(&book_dir).unwrap(); + touch_mp3(&book_dir.join("01.mp3")); + + let store_path = dir.path().join("audiobooks.json"); + let store = AudiobookStore::new(store_path); + let lib1 = store.add_root_and_rescan(dir.path()).unwrap(); + let id = lib1.books[0].id.clone(); + + store.set_manual_metadata(&id, "Real Author".to_string(), "Real Title".to_string()).unwrap(); + let lib2 = store.rescan_all().unwrap(); + assert_eq!(lib2.books[0].author, "Real Author"); + assert_eq!(lib2.books[0].title, "Real Title"); + assert!(lib2.books[0].manual_metadata); + } + + /// Реальный аудиофайл (не фиктивные байты) + настоящий cue-лист рядом — проверяет, что + /// главы реально извлекаются, а не просто "не падает". Нужен ffmpeg в PATH. + #[test] + fn extracts_chapters_from_real_cue_sheet() { + let dir = tempfile::tempdir().unwrap(); + let book_dir = dir.path().join("Author").join("Book With Chapters"); + fs::create_dir_all(&book_dir).unwrap(); + let audio_path = book_dir.join("full.mp3"); + + let status = std::process::Command::new("ffmpeg") + .args([ + "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=6", + audio_path.to_str().unwrap(), "-loglevel", "error", + ]) + .status() + .expect("ffmpeg must be installed for this test"); + assert!(status.success()); + + let cue_content = format!( + "TITLE \"Book With Chapters\"\nFILE \"{}\" MP3\n TRACK 01 AUDIO\n TITLE \"Chapter One\"\n INDEX 01 00:00:00\n TRACK 02 AUDIO\n TITLE \"Chapter Two\"\n INDEX 01 00:03:00\n", + audio_path.file_name().unwrap().to_str().unwrap() + ); + fs::write(book_dir.join("full.cue"), cue_content).unwrap(); + + let mut discovered = Vec::new(); + scan_dir(dir.path(), dir.path(), &mut discovered).unwrap(); + + assert_eq!(discovered.len(), 1); + let chapters = &discovered[0].files[0].chapters; + assert_eq!(chapters.len(), 2, "should find both chapters from the cue sheet"); + assert_eq!(chapters[0].title, "Chapter One"); + assert_eq!(chapters[0].start, Duration::ZERO); + assert_eq!(chapters[1].title, "Chapter Two"); + assert_eq!(chapters[1].start, Duration::from_secs(3)); + assert_eq!(chapters[0].end, Some(Duration::from_secs(3)), "chapter 1 ends where chapter 2 starts"); + assert_eq!(chapters[1].end, None, "last chapter runs to end of file"); + + // И длительность файла реально прочиталась (не фиктивный ноль). + assert!(discovered[0].total_duration >= Duration::from_secs(5)); + } +} diff --git a/crates/library/src/lib.rs b/crates/library/src/lib.rs index e75dfca..3767422 100644 --- a/crates/library/src/lib.rs +++ b/crates/library/src/lib.rs @@ -1,8 +1,10 @@ +pub mod audiobook; pub mod cue; pub mod history; pub mod model; pub mod new_releases; pub mod playlist; +pub mod podcast; pub mod radio; pub mod scanner; mod search; @@ -162,34 +164,56 @@ pub fn cover_for(path: &Path) -> Option> { scanner::read_cover(path) } -/// Путь по умолчанию для хранения плейлистов: `~/.config/nsm/playlists`. +/// Путь по умолчанию для хранения плейлистов: `~/.config/nm/playlists`. pub fn default_playlists_dir() -> PathBuf { - dirs_config_dir().join("nsm").join("playlists") + dirs_config_dir().join("nm").join("playlists") } -/// Путь по умолчанию для хранения пользовательских радиостанций: `~/.config/nsm`. +/// Путь по умолчанию для хранения пользовательских радиостанций: `~/.config/nm`. pub fn default_radio_dir() -> PathBuf { - dirs_config_dir().join("nsm") + dirs_config_dir().join("nm") } -/// Путь по умолчанию для истории прослушиваний: `~/.config/nsm/history.jsonl`. +/// Путь по умолчанию для истории прослушиваний: `~/.config/nm/history.jsonl`. pub fn default_history_path() -> PathBuf { - dirs_config_dir().join("nsm").join("history.jsonl") + dirs_config_dir().join("nm").join("history.jsonl") } -/// Путь по умолчанию для настроек приложения: `~/.config/nsm/settings.json`. +/// Путь по умолчанию для настроек приложения: `~/.config/nm/settings.json`. pub fn default_settings_path() -> PathBuf { - dirs_config_dir().join("nsm").join("settings.json") + dirs_config_dir().join("nm").join("settings.json") } /// Путь для кэша соответствий "артист -> MusicBrainz ID". pub fn default_mbid_cache_path() -> PathBuf { - dirs_config_dir().join("nsm").join("mbid_cache.json") + dirs_config_dir().join("nm").join("mbid_cache.json") } /// Путь для кэша последнего результата проверки новых релизов. pub fn default_new_releases_cache_path() -> PathBuf { - dirs_config_dir().join("nsm").join("new_releases_cache.json") + dirs_config_dir().join("nm").join("new_releases_cache.json") +} + +/// Путь для списка подписок на подкасты. +pub fn default_podcasts_store_path() -> PathBuf { + dirs_config_dir().join("nm").join("podcasts.json") +} + +/// Путь для кэша последнего разобранного состояния каждого фида подкаста. +pub fn default_podcasts_cache_path() -> PathBuf { + dirs_config_dir().join("nm").join("podcasts_cache.json") +} + +/// Путь для персистентного состояния аудиокниг (прогресс, ручные правки метаданных). +pub fn default_audiobooks_path() -> PathBuf { + dirs_config_dir().join("nm").join("audiobooks.json") +} + +/// Папка по умолчанию, куда скачиваются эпизоды подкастов. +pub fn default_podcasts_download_dir() -> PathBuf { + std::env::var_os("HOME") + .map(|h| PathBuf::from(h).join("Music").join("Podcasts")) + .unwrap_or_else(|| PathBuf::from("Podcasts")) } fn dirs_config_dir() -> PathBuf { diff --git a/crates/library/src/model.rs b/crates/library/src/model.rs index 49a26a9..34d9a1f 100644 --- a/crates/library/src/model.rs +++ b/crates/library/src/model.rs @@ -46,7 +46,8 @@ pub struct AlbumView<'a> { pub tracks: Vec<&'a Track>, } -pub(crate) const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "ogg", "oga", "wav", "m4a", "aac"]; +pub(crate) const AUDIO_EXTENSIONS: &[&str] = + &["mp3", "flac", "ogg", "oga", "wav", "m4a", "aac", "mp4", "m4b"]; pub(crate) fn is_audio_file(path: &Path) -> bool { path.extension() diff --git a/crates/library/src/podcast.rs b/crates/library/src/podcast.rs new file mode 100644 index 0000000..054e99f --- /dev/null +++ b/crates/library/src/podcast.rs @@ -0,0 +1,394 @@ +//! Подкасты: подписка по RSS-ссылке, список эпизодов, воспроизведение стримом или скачивание +//! на диск. Многие старые (особенно русскоязычные) подкасты публикуют RSS в кодировке +//! Windows-1251, а не UTF-8 — мы честно уважаем заявленную в `` +//! кодировку при разборе, а не считаем всё UTF-8 по умолчанию. + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use quick_xml::events::Event; +use quick_xml::reader::Reader; +use serde::{Deserialize, Serialize}; + +use textfix::fix_mojibake; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PodcastEpisode { + pub title: String, + pub url: String, + pub description: Option, + /// Как есть из RSS (`pubDate`), без парсинга в полноценную дату — форматы в диких фидах + /// не всегда стандартны, а для отображения "как есть" сойдёт. + pub pub_date: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PodcastFeed { + pub feed_url: String, + pub title: String, + pub description: Option, + pub episodes: Vec, +} + +/// Список подписок (просто URL RSS-фидов), персистится на диске. +pub struct PodcastStore { + path: PathBuf, +} + +impl PodcastStore { + pub fn new(path: PathBuf) -> Self { + PodcastStore { path } + } + + pub fn load(&self) -> anyhow::Result> { + match std::fs::read_to_string(&self.path) { + Ok(data) => Ok(serde_json::from_str(&data)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(e) => Err(e.into()), + } + } + + pub fn add(&self, url: &str) -> anyhow::Result<()> { + let mut urls = self.load()?; + if !urls.iter().any(|u| u == url) { + urls.push(url.to_string()); + self.save(&urls)?; + } + Ok(()) + } + + pub fn remove(&self, url: &str) -> anyhow::Result<()> { + let mut urls = self.load()?; + urls.retain(|u| u != url); + self.save(&urls) + } + + fn save(&self, urls: &[String]) -> anyhow::Result<()> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&self.path, serde_json::to_string_pretty(urls)?)?; + Ok(()) + } +} + +/// Кэш последнего успешно разобранного состояния каждого фида (по URL) — чтобы при запуске +/// сразу показать список эпизодов без обращения к сети, а обновлять по явному запросу. +pub struct PodcastCache { + path: PathBuf, +} + +impl PodcastCache { + pub fn new(path: PathBuf) -> Self { + PodcastCache { path } + } + + pub fn load(&self) -> Vec { + std::fs::read_to_string(&self.path) + .ok() + .and_then(|data| serde_json::from_str(&data).ok()) + .unwrap_or_default() + } + + pub fn save(&self, feeds: &[PodcastFeed]) -> anyhow::Result<()> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&self.path, serde_json::to_string_pretty(feeds)?)?; + Ok(()) + } +} + +/// Ищет `encoding="..."` в XML-декларации. Важно: смотрим только до `?>` (конец декларации, +/// всегда чистый ASCII по спецификации XML) — а не произвольный кусок байт, потому что сразу +/// ПОСЛЕ декларации может начинаться текст в объявленной не-UTF8 кодировке (например, +/// кириллица в Windows-1251), которая ломает `from_utf8` для всего среза, если прихватить +/// её кусок в окно поиска. +fn detect_xml_declared_encoding(bytes: &[u8]) -> Option { + let search_window = &bytes[..bytes.len().min(512)]; + let decl_end = search_window.windows(2).position(|w| w == b"?>")? + 2; + let head_str = std::str::from_utf8(&search_window[..decl_end]).ok()?; + let start = head_str.find("encoding=")? + "encoding=".len(); + let rest = &head_str[start..]; + let quote = rest.chars().next()?; + if quote != '"' && quote != '\'' { + return None; + } + let end = rest[1..].find(quote)?; + Some(rest[1..1 + end].to_string()) +} + +/// Раскодирует сырые байты RSS-фида в UTF-8, честно уважая заявленную в XML-декларации +/// кодировку (многие старые русскоязычные подкасты используют Windows-1251) — а не считая, +/// что всё в UTF-8. Если декларации нет, или заявленная кодировка не распозналась, или +/// декодирование по ней дало ошибки — откатываемся на lossy UTF-8. +fn decode_feed_bytes(bytes: &[u8]) -> String { + if let Some(enc_name) = detect_xml_declared_encoding(bytes) { + if let Some(encoding) = encoding_rs::Encoding::for_label(enc_name.as_bytes()) { + let (decoded, _, had_errors) = encoding.decode(bytes); + if !had_errors { + return decoded.into_owned(); + } + } + } + String::from_utf8_lossy(bytes).into_owned() +} + +/// Скачивает и разбирает RSS-фид подкаста по URL. +pub fn fetch_feed(url: &str) -> anyhow::Result { + let response = ureq::get(url) + .set("User-Agent", "nsm-tui/0.1 (terminal music player)") + .timeout(std::time::Duration::from_secs(15)) + .call()?; + + let mut bytes = Vec::new(); + response.into_reader().read_to_end(&mut bytes)?; + + let text = decode_feed_bytes(&bytes); + parse_feed(&text, url) +} + +fn parse_feed(xml: &str, feed_url: &str) -> anyhow::Result { + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(true); + + let mut buf = Vec::new(); + let mut path: Vec = Vec::new(); + + let mut feed_title = String::new(); + let mut feed_description: Option = None; + let mut episodes = Vec::new(); + + let mut cur_title = String::new(); + let mut cur_description: Option = None; + let mut cur_enclosure_url: Option = None; + let mut cur_pub_date: Option = None; + let mut in_item = false; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); + if name == "item" { + in_item = true; + cur_title.clear(); + cur_description = None; + cur_enclosure_url = None; + cur_pub_date = None; + } + path.push(name); + } + Ok(Event::Empty(e)) => { + // Самозакрывающиеся теги вроде не дают отдельных Start+End — + // ловим их атрибуты сразу здесь. + let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); + if name == "enclosure" { + for attr in e.attributes().flatten() { + if attr.key.as_ref() == b"url" { + if let Ok(v) = attr.unescape_value() { + cur_enclosure_url = Some(v.to_string()); + } + } + } + } + } + Ok(Event::Text(e)) => { + let Ok(raw) = e.unescape() else { continue }; + // Подстраховка на случай, если кодировка в декларации задана неверно (как это + // бывает с ID3-тегами) — тот же fix_mojibake, что и для тегов. + let text = fix_mojibake(&raw); + match path.last().map(|s| s.as_str()) { + Some("title") if in_item => cur_title = text, + Some("title") => feed_title = text, + Some("description") if in_item => cur_description = Some(text), + Some("description") => feed_description = Some(text), + Some("pubDate") if in_item => cur_pub_date = Some(text), + _ => {} + } + } + Ok(Event::End(e)) => { + let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); + if name == "item" { + if let Some(url) = cur_enclosure_url.take() { + episodes.push(PodcastEpisode { + title: if cur_title.is_empty() { + "Untitled episode".to_string() + } else { + cur_title.clone() + }, + url, + description: cur_description.take(), + pub_date: cur_pub_date.take(), + }); + } + in_item = false; + } + path.pop(); + } + Ok(Event::Eof) => break, + Err(e) => return Err(anyhow::anyhow!("RSS parse error: {e}")), + _ => {} + } + buf.clear(); + } + + Ok(PodcastFeed { + feed_url: feed_url.to_string(), + title: if feed_title.is_empty() { + feed_url.to_string() + } else { + feed_title + }, + description: feed_description, + episodes, + }) +} + +/// Скачивает эпизод на диск — для офлайн-прослушивания или если стриминг с конкретного +/// сервера медленный/нестабильный. +pub fn download_episode(url: &str, dest: &Path) -> anyhow::Result<()> { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + let response = ureq::get(url) + .set("User-Agent", "nsm-tui/0.1 (terminal music player)") + .timeout(std::time::Duration::from_secs(180)) // эпизоды бывают длинными, часами + .call()?; + let mut reader = response.into_reader(); + let mut file = std::fs::File::create(dest)?; + std::io::copy(&mut reader, &mut file)?; + Ok(()) +} + +/// Фоновая версия `fetch_feed` — не блокирует UI-поток на время сетевого запроса. +pub fn spawn_fetch_feed(url: String) -> std::sync::mpsc::Receiver> { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(fetch_feed(&url)); + }); + rx +} + +/// Фоновая версия `download_episode`. +pub fn spawn_download_episode( + url: String, + dest: PathBuf, +) -> std::sync::mpsc::Receiver> { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let result = download_episode(&url, &dest).map(|()| dest); + let _ = tx.send(result); + }); + rx +} + +/// Безопасное имя файла из произвольной строки (название подкаста/эпизода может содержать +/// что угодно, включая символы, недопустимые в путях). +pub fn sanitize_filename(name: &str) -> String { + let cleaned: String = name + .chars() + .map(|c| { + if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' || c == '.' { + c + } else { + '_' + } + }) + .collect(); + let trimmed = cleaned.trim(); + if trimmed.is_empty() { + "episode".to_string() + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_declared_encoding() { + let xml = br#""#; + assert_eq!( + detect_xml_declared_encoding(xml), + Some("windows-1251".to_string()) + ); + } + + #[test] + fn returns_none_without_declaration() { + let xml = br#""#; + assert_eq!(detect_xml_declared_encoding(xml), None); + } + + #[test] + fn decodes_cp1251_feed_correctly() { + let title = "Подкаст"; + let (cp1251_bytes, _, _) = encoding_rs::WINDOWS_1251.encode(title); + let mut xml = br#""# + .to_vec(); + xml.extend_from_slice(&cp1251_bytes); + xml.extend_from_slice(b""); + + let decoded = decode_feed_bytes(&xml); + assert!(decoded.contains("Подкаст"), "decoded={decoded:?}"); + } + + #[test] + fn parses_basic_feed_with_episodes() { + let xml = r#" + + +Test Podcast +A test feed + +Episode One +First episode + +Mon, 01 Jan 2020 00:00:00 +0000 + + +Episode Two + + + +"#; + let feed = parse_feed(xml, "http://example.com/feed.xml").unwrap(); + assert_eq!(feed.title, "Test Podcast"); + assert_eq!(feed.episodes.len(), 2); + assert_eq!(feed.episodes[0].title, "Episode One"); + assert_eq!(feed.episodes[0].url, "http://example.com/ep1.mp3"); + assert_eq!(feed.episodes[1].title, "Episode Two"); + } + + #[test] + fn parses_real_world_cp1251_feed() { + let title = "Старый Подкаст про Технологии"; + let ep_title = "Выпуск 1: Начало"; + let xml_template = format!( + r#" + + +{title} + +{ep_title} + + + +"# + ); + let (cp1251_bytes, _, _) = encoding_rs::WINDOWS_1251.encode(&xml_template); + + let text = decode_feed_bytes(&cp1251_bytes); + let feed = parse_feed(&text, "http://example.com/feed.xml").unwrap(); + assert_eq!(feed.title, title); + assert_eq!(feed.episodes[0].title, ep_title); + } + + #[test] + fn sanitizes_filenames() { + assert_eq!(sanitize_filename("Episode: The Return / Part 2"), "Episode_ The Return _ Part 2"); + assert_eq!(sanitize_filename(""), "episode"); + } +} diff --git a/crates/nsm/Cargo.toml b/crates/nm/Cargo.toml similarity index 100% rename from crates/nsm/Cargo.toml rename to crates/nm/Cargo.toml diff --git a/crates/nsm/src/main.rs b/crates/nm/src/main.rs similarity index 100% rename from crates/nsm/src/main.rs rename to crates/nm/src/main.rs diff --git a/crates/player/Cargo.toml b/crates/player/Cargo.toml index aa11de5..3fbc4fb 100644 --- a/crates/player/Cargo.toml +++ b/crates/player/Cargo.toml @@ -15,6 +15,7 @@ ureq = "2" realfft = "3" textfix = { path = "../textfix" } smbfs = { path = "../smbfs" } +timestretch = "0.11" [dev-dependencies] encoding_rs = "0.8" diff --git a/crates/player/src/decode.rs b/crates/player/src/decode.rs index 77d3fb1..00a6742 100644 --- a/crates/player/src/decode.rs +++ b/crates/player/src/decode.rs @@ -15,6 +15,131 @@ use symphonia::core::units::Time; use crate::types::{PlayerEvent, TrackInfo}; use textfix::fix_mojibake; +/// Read+Seek поверх произвольного HTTP(S)-URL через Range-запросы — позволяет декодировать +/// (и перематывать) аудио прямо по ссылке, без скачивания на диск целиком. В отличие от +/// потокового радио (см. `open_url`/`PlayRadio` — без длины содержимого и без перемотки, +/// для живых бесконечных потоков), это для конечных файлов (например, эпизодов подкастов), +/// и только когда сервер честно поддерживает Range — проверяем по коду ответа 206, а не +/// считаем это само собой разумеющимся (многие простые серверы отдают 200 и игнорируют +/// заголовок Range, в этом случае перемотка недоступна, и мы явно об этом сообщаем через +/// `is_seekable() == false`, а не притворяемся). +struct HttpMediaSource { + url: String, + pos: u64, + total_len: Option, + seekable: bool, + /// Текущее открытое HTTP-соединение (тело ответа) и абсолютная позиция, с которой оно + /// начинается — читаем из него последовательно, пока не понадобится реальный скачок + /// позиции (после Seek), тогда переоткрываем новый Range-запрос лениво, при следующем read(). + stream: Option>, + stream_pos: u64, +} + +impl HttpMediaSource { + /// ВАЖНО: используем timeout_connect/timeout_read через отдельный Agent, а не общий + /// .timeout() на запросе — общий таймаут ureq считает от начала запроса ДО КОНЦА + /// чтения тела целиком, что обрывало бы поток ровно через N секунд независимо от того, + /// продолжают ли реально идти данные (это уже ловили ровно на этой же ошибке в + /// open_url для радио — здесь тот же паттерн). timeout_read — таймаут МЕЖДУ отдельными + /// чтениями сокета, ловит реально зависший/замолчавший сервер, не обрывая нормально + /// идущий долгий стрим (эпизод подкаста может идти час и больше). + fn agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(10)) + .timeout_read(Duration::from_secs(30)) + .build() + } + + fn open(url: &str) -> anyhow::Result { + let response = Self::agent() + .get(url) + .set("Range", "bytes=0-") + .set("User-Agent", "nsm-tui/0.1 (terminal music player)") + .call()?; + + let seekable = response.status() == 206; + let total_len = if seekable { + response + .header("Content-Range") + .and_then(|cr| cr.rsplit('/').next()) + .and_then(|s| s.parse::().ok()) + } else { + response + .header("Content-Length") + .and_then(|s| s.parse::().ok()) + }; + + Ok(HttpMediaSource { + url: url.to_string(), + pos: 0, + total_len, + seekable, + stream: Some(response.into_reader()), + stream_pos: 0, + }) + } + + fn reopen_at(&mut self, pos: u64) -> std::io::Result<()> { + let response = Self::agent() + .get(&self.url) + .set("Range", &format!("bytes={pos}-")) + .set("User-Agent", "nsm-tui/0.1 (terminal music player)") + .call() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + self.stream = Some(response.into_reader()); + self.stream_pos = pos; + Ok(()) + } +} + +impl Read for HttpMediaSource { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.stream.is_none() || self.stream_pos != self.pos { + self.reopen_at(self.pos)?; + } + let n = self.stream.as_mut().unwrap().read(buf)?; + self.pos += n as u64; + self.stream_pos += n as u64; + Ok(n) + } +} + +impl Seek for HttpMediaSource { + fn seek(&mut self, from: SeekFrom) -> std::io::Result { + let new_pos = match from { + SeekFrom::Start(p) => p as i64, + SeekFrom::Current(offset) => self.pos as i64 + offset, + SeekFrom::End(offset) => { + let len = self.total_len.ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::Other, + "unknown content length, can't seek from end", + ) + })? as i64; + len + offset + } + }; + if new_pos < 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "negative seek position", + )); + } + self.pos = new_pos as u64; + Ok(self.pos) + } +} + +impl MediaSource for HttpMediaSource { + fn is_seekable(&self) -> bool { + self.seekable + } + + fn byte_len(&self) -> Option { + self.total_len + } +} + /// Оборачивает `smbfs::SmbFile` (Read+Seek) в symphonia-совместимый `MediaSource` — /// позволяет декодировать аудио прямо с SMB-шары точно так же, как с локального файла, /// без единого системного вызова вроде gio/gvfs. @@ -47,9 +172,12 @@ impl MediaSource for SmbMediaSource { /// Строит symphonia-источник (Box) для локального файла или SMB-пути — /// единая точка входа, дальше `open_track`/`read_tags` работают одинаково для обоих случаев. fn open_media_source(path: &Path) -> anyhow::Result> { + let path_str = path.to_string_lossy(); if let Some(loc) = smbfs::parse_smb_path(path) { let smb_file = smbfs::open_read(&loc.server, &loc.share, &loc.path)?; Ok(Box::new(SmbMediaSource { inner: smb_file })) + } else if path_str.starts_with("http://") || path_str.starts_with("https://") { + Ok(Box::new(HttpMediaSource::open(&path_str)?)) } else { Ok(Box::new(File::open(path)?)) } @@ -352,6 +480,30 @@ pub fn read_tags(path: &Path) -> anyhow::Result { Some(Duration::from_secs_f64(frames as f64 / rate as f64)) }); + let codec = track.and_then(|t| { + symphonia::default::get_codecs() + .get_codec(t.codec_params.codec) + .map(|d| d.short_name.to_string()) + }); + let sample_rate = track.and_then(|t| t.codec_params.sample_rate); + let channels = track.and_then(|t| t.codec_params.channels).map(|c| c.count() as u16); + let bits_per_sample = track.and_then(|t| t.codec_params.bits_per_sample); + + // Размер файла — только для локальных путей (не SMB/HTTP, там пока не считаем размер + // отдельным запросом ради этого — сама техническая панель по-прежнему полезна и без него). + let is_remote = smb_or_http(path); + let file_size = if is_remote { + None + } else { + std::fs::metadata(path).ok().map(|m| m.len()) + }; + let bitrate_kbps = match (file_size, duration) { + (Some(size), Some(dur)) if dur.as_secs_f64() > 0.0 => { + Some(((size as f64 * 8.0) / dur.as_secs_f64() / 1000.0).round() as u32) + } + _ => None, + }; + Ok(TrackInfo { path: path.to_path_buf(), title: fix_mojibake(&title.unwrap_or(fallback_title)), @@ -359,9 +511,20 @@ pub fn read_tags(path: &Path) -> anyhow::Result { album: fix_mojibake(&album.unwrap_or_else(|| "Unknown Album".to_string())), duration, cover, + codec, + sample_rate, + channels, + bits_per_sample, + file_size, + bitrate_kbps, }) } +fn smb_or_http(path: &Path) -> bool { + let s = path.to_string_lossy(); + smbfs::is_smb_path(path) || s.starts_with("http://") || s.starts_with("https://") +} + #[cfg(test)] mod tests { use super::*; @@ -415,6 +578,99 @@ mod tests { println!("decoded {packets} packets, {total_samples} samples total, over SMB"); } + /// Проверяет реальную перемотку по HTTP-URL через Range-запросы — нужен локальный + /// сервер, ЧЕСТНО поддерживающий Range (отвечающий 206 с Content-Range), запущенный + /// вручную перед прогоном на 127.0.0.1:8944, отдающий тестовый mp3-файл. Обычные простые + /// серверы (вроде голого `python -m http.server`) Range игнорируют — этим тестом + /// специально проверяем именно честную поддержку, а не просто "не упало". + /// Запускать явно: `cargo test -- --ignored`. + #[test] + #[ignore] + fn open_track_seeks_over_http_with_range_support() { + let url = std::path::PathBuf::from("http://127.0.0.1:8944/test.mp3"); + let mut track = open_track(&url).expect("open_track over HTTP failed — is range_server.py running?"); + + // Декодируем несколько пакетов с самого начала — убеждаемся, что вообще работает. + let mut packets_before = 0usize; + while let Some(_samples) = next_packet_samples(&mut track).expect("decode failed") { + packets_before += 1; + if packets_before >= 3 { + break; + } + } + assert!(packets_before > 0, "should decode from the start before seeking"); + + // Реальная перемотка на середину файла (тестовый mp3 — 3 секунды). + let target = Duration::from_secs_f64(1.5); + let actual = seek(&mut track, target).expect("seek over HTTP should succeed"); + assert!( + actual <= target + Duration::from_millis(500), + "seek landed too far past target: {actual:?} vs {target:?}" + ); + + // И декодирование ПОСЛЕ перемотки тоже должно работать — не просто "seek не упал", + // а реально продолжает выдавать валидные сэмплы с новой позиции. + let mut packets_after = 0usize; + while let Some(_samples) = next_packet_samples(&mut track).expect("decode after seek failed") { + packets_after += 1; + if packets_after >= 3 { + break; + } + } + assert!(packets_after > 0, "should decode more packets after seeking"); + + println!( + "HTTP range seek: landed at {actual:?} (target {target:?}), decoded {packets_before} packets before + {packets_after} after" + ); + } + + /// Регрессия на реальный баг: старый общий `.timeout()` в HttpMediaSource считался на + /// ВЕСЬ запрос целиком (включая чтение тела), а не только на соединение — обрывал живой, + /// но долгий поток ровно через N секунд, даже если данные продолжали исправно идти. + /// Тут — сервер, который НАРОЧНО растягивает передачу 30-секундного файла на ~25 секунд + /// (непрерывно, без реальных зависаний) — дольше старого таймаута в 20с. С фиксом + /// (timeout_connect/timeout_read вместо общего timeout) декодирование должно спокойно + /// пройти весь файл, не оборвавшись на середине. Нужен запущенный вручную slow_server.py + /// на 127.0.0.1:8948. Запускать явно: `cargo test -- --ignored` (сам тест идёт ~25 секунд). + #[test] + #[ignore] + fn long_stream_does_not_get_cut_by_stale_overall_timeout() { + let url = std::path::PathBuf::from("http://127.0.0.1:8948/long.mp3"); + let start = std::time::Instant::now(); + let mut track = open_track(&url).expect("open_track failed — is slow_server.py running?"); + + let mut total_packets = 0usize; + loop { + match next_packet_samples(&mut track).expect("decode failed mid-stream — did it get cut early?") { + Some(_) => total_packets += 1, + None => break, + } + } + + let elapsed = start.elapsed(); + println!("decoded {total_packets} packets over {elapsed:?} (server takes ~25s to send it all)"); + assert!( + elapsed > Duration::from_secs(20), + "test should genuinely take longer than the OLD 20s timeout to be meaningful — took {elapsed:?}" + ); + assert!(total_packets > 0, "should have decoded something"); + } + + /// Обратная сторона предыдущего теста: сервер БЕЗ честной поддержки Range (обычный + /// `python -m http.server`, отвечает 200 и игнорирует заголовок Range) — мы должны + /// правдиво сообщить "не перематывается", а не притворяться. Запущенный вручную сервер + /// на 127.0.0.1:8945, отдающий тот же тестовый mp3. Запускать явно: `cargo test -- --ignored`. + #[test] + #[ignore] + fn http_source_without_range_support_is_not_seekable() { + let url = std::path::PathBuf::from("http://127.0.0.1:8945/test.mp3"); + let source = open_media_source(&url).expect("should still open even without range support"); + assert!( + !source.is_seekable(), + "must honestly report not-seekable when server doesn't honor Range (returns 200, not 206)" + ); + } + /// Регрессионный тест на реальный баг: кракозябры чинились в библиотеке (library::scanner), /// но не в TrackInfo, который показывается в "Now Playing" при реальном воспроизведении — /// потому что read_tags жила отдельно и не применяла fix_mojibake. Воспроизводит точный @@ -522,4 +778,4 @@ mod tests { let _ = std::fs::remove_file(&path); } -} +} \ No newline at end of file diff --git a/crates/player/src/engine.rs b/crates/player/src/engine.rs index d72432e..1148dec 100644 --- a/crates/player/src/engine.rs +++ b/crates/player/src/engine.rs @@ -52,6 +52,11 @@ struct EngineState { /// true, пока играет интернет-радио: очередь/next/prev/repeat/seek не применяются, /// а обрыв потока просто останавливает воспроизведение вместо перехода дальше. is_radio: bool, + /// Источник по сети (радио, либо обычный трек с http(s)://\SMB-путём — например, + /// стриминг эпизода подкаста) — сетевые сбои (обрыв соединения, таймаут) для таких + /// источников не должны валить весь движок целиком, в отличие от локальных файлов, + /// где ошибка декодирования обычно означает реальную порчу/баг, а не временный сбой сети. + is_network_source: bool, /// Накопитель моно-сэмплов (пост-EQ, то есть то, что реально слышит пользователь) /// для спектрального анализатора. spectrum_buffer: Vec, @@ -62,6 +67,24 @@ struct EngineState { loudness_norm_enabled: bool, /// Линейный коэффициент усиления из тега ReplayGain текущего трека, если есть. current_track_replay_gain: Option, + /// Ручное усиление громкости (1.0 = 100%, без изменений; до 3.0 = 300%). Применяется + /// после нормализации/fade — это финальный "бустер" поверх всего остального. + boost_gain: f32, + /// Изменение скорости с сохранением высоты голоса (в отличие от `resampler`, который + /// меняет и то, и другое разом — тот теперь используется ТОЛЬКО для подгонки под частоту + /// устройства воспроизведения, без всякой связи с пользовательской скоростью). `None` до + /// загрузки первого трека — пересоздаётся под его частоту/каналы, как и `resampler`. + stretcher: Option, + /// Отдельная высота голоса (1.0 = без изменений), независимая от скорости. Реализована + /// как обычный ресемплинг (меняет и высоту, и длительность — как магнитофон), а + /// `stretcher` затем компенсирует изменение длительности, оставляя только сдвиг высоты. + pitch: f32, + pitch_resampler: Option, + /// Пропуск затянувшейся тишины — не режем короткие паузы в речи (важны для восприятия), + /// только действительно долгую тишину (дефекты записи, длинные паузы монтажа и т.п.). + silence_skip_enabled: bool, + /// Сколько подряд идёт тишина прямо сейчас — сбрасывается любым не-тихим пакетом. + silent_run: Duration, } fn run(cmd_rx: Receiver, evt_tx: Sender) -> anyhow::Result<()> { @@ -132,11 +155,18 @@ fn run(cmd_rx: Receiver, evt_tx: Sender) -> anyhow:: current_track_start: Duration::ZERO, current_track_end: None, is_radio: false, + is_network_source: false, spectrum_buffer: Vec::with_capacity(FFT_SIZE * 2), last_spectrum_send: Instant::now(), fade_out_enabled: false, loudness_norm_enabled: false, current_track_replay_gain: None, + boost_gain: 1.0, + stretcher: None, + pitch: 1.0, + pitch_resampler: None, + silence_skip_enabled: false, + silent_run: Duration::ZERO, }; let mut current_track: Option = None; @@ -205,6 +235,315 @@ const FADE_OUT_DURATION: Duration = Duration::from_secs(4); /// Коэффициент усиления для плавного затухания в последние `FADE_OUT_DURATION` секунд /// трека. `None`, если конец трека неизвестен (например, интернет-радио — там затухать /// нечему, там нет конца). +/// Применяет усиление громкости с мягким ограничением (soft clip) вместо жёсткого clamp — +/// иначе на пиках при сильном усилении получалось бы грубое цифровое обрезание с явными +/// щелчками/искажениями. tanh плавно поджимает то, что вышло бы за [-1, 1], оставаясь почти +/// линейным (не искажая форму сигнала) на остальном диапазоне. +fn apply_boost(samples: &mut [f32], gain: f32) { + for s in samples.iter_mut() { + *s = (*s * gain).tanh(); + } +} + +const SILENCE_RMS_THRESHOLD: f32 = 0.01; // примерно -40dBFS +/// Короткие паузы в речи — это нормально и важно для восприятия (не режем их); режем +/// только тишину длиннее этого порога. +const SILENCE_GRACE_PERIOD: Duration = Duration::from_millis(800); + +fn compute_rms(samples: &[f32]) -> f32 { + if samples.is_empty() { + return 0.0; + } + let sum_sq: f32 = samples.iter().map(|s| s * s).sum(); + (sum_sq / samples.len() as f32).sqrt() +} + +/// Классифицирует результат чтения пакета. Для сетевых источников (радио, стриминг +/// подкаста по http(s)://, SMB) сбой связи (обрыв, таймаут) приходит как `Err`, а не +/// `Ok(None)` — превращаем это в "конец потока", а не фатальную ошибку: временный сбой +/// сети не должен ронять весь движок целиком (раньше это было заведено только для радио — +/// `is_radio` — и когда стриминг подкастов стал идти через обычную очередь, а не +/// `PlayRadio`, эта защита для него не срабатывала, отсюда и крах движка на сетевых +/// сбоях специфически при подкастах). Для ЛОКАЛЬНЫХ файлов, наоборот, ошибка декодирования +/// почти всегда значит реальную порчу файла/баг — там ошибка остаётся ошибкой, не +/// притворяемся, что всё в порядке. Возвращает (результат для дальнейшей обработки, +/// опциональное сообщение об ошибке для отправки в UI). +fn classify_packet_result( + result: anyhow::Result>>, + is_network_source: bool, +) -> (anyhow::Result>>, Option) { + match result { + Ok(v) => (Ok(v), None), + Err(e) if is_network_source => { + let msg = format!("network stream error: {e}"); + (Ok(None), Some(msg)) + } + Err(e) => (Err(e), None), + } +} + +/// Приводит interleaved-сэмплы от собственного числа каналов ТРЕКА к числу каналов +/// УСТРОЙСТВА воспроизведения. Обязательный шаг — EQ/resampler/stretcher/pitch_resampler +/// все сконструированы под `device_channels`, а декодированные пакеты приходят в формате +/// самого трека. Для стерео-музыки со стерео-устройством числа случайно совпадают, поэтому +/// без этой функции всё "работало" — но моно-контент (частое дело для подкастов, в отличие +/// от почти всегда стерео музыки) без нужного приведения интерпретировался бы неверно: +/// соседние моно-сэмплы ошибочно читались бы как пары L/R одного стерео-кадра, что +/// эффективно вдвое сжимает число кадров — отсюда и ускоренное воспроизведение, и падение +/// resampler'а на несовпадении размеров чанка. +fn convert_channels(samples: &[f32], from_channels: usize, to_channels: usize) -> Vec { + if from_channels == to_channels || from_channels == 0 || to_channels == 0 { + return samples.to_vec(); + } + let mut out = Vec::with_capacity(samples.len() / from_channels * to_channels); + for frame in samples.chunks(from_channels) { + if from_channels == 1 { + // моно -> N каналов: дублируем единственный сэмпл во все каналы + let s = frame[0]; + out.extend(std::iter::repeat(s).take(to_channels)); + } else if to_channels == 1 { + // N каналов -> моно: усредняем + out.push(frame.iter().sum::() / from_channels as f32); + } else { + // Общий случай (редкий на практике — почти всегда 1 или 2 канала и там, и там): + // берём то, что есть, циклически. + for i in 0..to_channels { + out.push(frame[i % frame.len()]); + } + } + } + out +} + +#[cfg(test)] +mod network_error_tests { + use super::*; + + #[test] + fn network_source_io_error_becomes_graceful_end_of_stream() { + let err: anyhow::Result>> = Err(anyhow::anyhow!("connection reset")); + let (result, msg) = classify_packet_result(err, true); + assert!(matches!(result, Ok(None)), "network error must not be fatal"); + assert!(msg.is_some(), "should report the error to the UI, just not crash"); + } + + /// Это ровно тот баг, который чинится: подкаст стримится через обычную очередь + /// (is_radio=false), но всё равно является сетевым источником (is_network_source=true). + /// Раньше проверялось только is_radio, и такая комбинация валила движок. + #[test] + fn podcast_like_source_not_radio_but_network_does_not_crash() { + let err: anyhow::Result>> = Err(anyhow::anyhow!("timed out")); + let (result, _msg) = classify_packet_result(err, true); // is_radio было бы false здесь + assert!(result.is_ok(), "podcast (queue-based, not PlayRadio) network error must not crash the engine"); + } + + #[test] + fn local_file_io_error_stays_fatal() { + let err: anyhow::Result>> = Err(anyhow::anyhow!("corrupt frame")); + let (result, msg) = classify_packet_result(err, false); + assert!(result.is_err(), "local file decode errors should stay fatal — usually a real bug/corruption"); + assert!(msg.is_none()); + } + + #[test] + fn end_of_stream_passes_through_unchanged_regardless_of_source() { + let (result, msg) = classify_packet_result(Ok(None), true); + assert!(matches!(result, Ok(None))); + assert!(msg.is_none()); + let (result, msg) = classify_packet_result(Ok(None), false); + assert!(matches!(result, Ok(None))); + assert!(msg.is_none()); + } + + #[test] + fn successful_samples_pass_through_unchanged() { + let samples = vec![0.1, 0.2, 0.3]; + let (result, msg) = classify_packet_result(Ok(Some(samples.clone())), true); + assert_eq!(result.unwrap(), Some(samples)); + assert!(msg.is_none()); + } + + /// Самая честная проверка: реальный HTTP-сервер, который отдаёт только половину файла + /// и обрывает соединение (не аккуратный EOF, а настоящий недополученный Content-Length — + /// см. drop_server2.py в истории разработки) — подтверждаем, что декодер на таком сбое + /// ДЕЙСТВИТЕЛЬНО возвращает Err в какой-то момент (не тихий Ok(None)), то есть что наша + /// классификация в classify_packet_result действительно нужна и адресует реальный, + /// а не гипотетический случай. Запускать явно: `cargo test -- --ignored`, сервер на + /// 127.0.0.1:8947 должен быть поднят заранее. + #[test] + #[ignore] + fn real_dropped_http_connection_surfaces_as_error() { + let url = std::path::PathBuf::from("http://127.0.0.1:8947/test.mp3"); + let mut track = open_track(&url).expect("open_track failed — is drop_server2.py running?"); + + let mut outcome = "ran out of loop iterations"; + let mut packets_before_end = 0; + for _ in 0..10_000 { + match next_packet_samples(&mut track) { + Ok(Some(_)) => packets_before_end += 1, + Ok(None) => { + outcome = "Ok(None) — decoder treated the early close as a clean end-of-stream, no Err"; + break; + } + Err(_) => { + outcome = "Err — surfaced as a real I/O error"; + break; + } + } + } + println!("outcome: {outcome} (after {packets_before_end} packets)"); + // Не настаиваю на конкретном исходе — интересно посмотреть, что произойдёт на + // самом деле, а не то, что я предполагал заранее. + } +} + +#[cfg(test)] +mod channel_conversion_tests { + use super::*; + + #[test] + fn mono_to_stereo_duplicates_sample_into_both_channels() { + let mono = vec![0.1, 0.2, 0.3]; + let stereo = convert_channels(&mono, 1, 2); + assert_eq!(stereo, vec![0.1, 0.1, 0.2, 0.2, 0.3, 0.3]); + } + + #[test] + fn stereo_to_mono_averages_channels() { + let stereo = vec![0.0, 1.0, 0.4, 0.6]; + let mono = convert_channels(&stereo, 2, 1); + assert_eq!(mono, vec![0.5, 0.5]); + } + + #[test] + fn same_channel_count_is_passthrough() { + let samples = vec![0.1, -0.2, 0.3, -0.4]; + assert_eq!(convert_channels(&samples, 2, 2), samples); + } + + #[test] + fn mono_to_stereo_preserves_frame_count_not_just_sample_count() { + // Это и есть суть бага: важно, что число КАДРОВ (не сэмплов) после приведения + // соответствует реальной длительности звука — иначе resampler/stretcher посчитают, + // что кадров вдвое меньше, чем есть на самом деле, и всё ускорится вдвое. + let mono_frames = 1000; + let mono = vec![0.0f32; mono_frames]; + let stereo = convert_channels(&mono, 1, 2); + assert_eq!(stereo.len() / 2, mono_frames, "frame count must be preserved"); + } + + /// Регрессия на реальный баг: декодирует настоящий МОНО mp3 (частый случай для + /// подкастов) и проверяет, что после приведения к стерео (как для device_channels=2) + /// количество кадров соответствует реальной длительности файла — а не вдвое меньше, + /// как было бы при "случайном" интерпретировании моно-сэмплов парами L/R. + /// Файл нужно сгенерировать заранее (mono mp3, ffmpeg -ac 1). Запускать явно: + /// `cargo test -- --ignored`. + #[test] + #[ignore] + fn real_mono_podcast_file_plays_at_correct_speed_after_channel_fix() { + let path = std::path::PathBuf::from("/tmp/mono_test.mp3"); + let mut track = + open_track(&path).expect("open_track failed — did you generate /tmp/mono_test.mp3?"); + assert_eq!(track.spec.channels.count(), 1, "test fixture must be mono"); + + let native_channels = track.spec.channels.count(); + let native_rate = track.spec.rate; + let mut total_frames_native = 0u64; + let mut total_frames_after_convert = 0u64; + + while let Some(samples) = next_packet_samples(&mut track).expect("decode failed") { + total_frames_native += (samples.len() / native_channels) as u64; + let converted = convert_channels(&samples, native_channels, 2); + total_frames_after_convert += (converted.len() / 2) as u64; + } + + // Ключевая проверка: количество КАДРОВ (не сэмплов) после приведения к стерео + // должно совпасть с тем, что было в моно — иначе ресемплер получит вдвое меньше + // кадров, чем реальная длительность, и звук ускорится вдвое. + assert_eq!( + total_frames_native, total_frames_after_convert, + "frame count must be preserved through channel conversion" + ); + + let decoded_duration = total_frames_native as f64 / native_rate as f64; + println!("decoded {total_frames_native} frames = {decoded_duration:.2}s (file is ~4.05s)"); + assert!( + decoded_duration > 3.5 && decoded_duration < 4.5, + "decoded duration {decoded_duration}s should match the real ~4.05s file, not ~2s (the old bug)" + ); + } +} + +#[cfg(test)] +mod silence_tests { + use super::*; + + #[test] + fn silence_gives_near_zero_rms() { + let silence = vec![0.0f32; 1000]; + assert!(compute_rms(&silence) < 0.001); + } + + #[test] + fn loud_signal_exceeds_threshold() { + let loud: Vec = (0..1000).map(|i| (i as f32 * 0.1).sin() * 0.5).collect(); + assert!(compute_rms(&loud) > SILENCE_RMS_THRESHOLD); + } + + #[test] + fn quiet_but_nonzero_noise_is_below_threshold() { + // Совсем тихий "шум" (например, шипение записи) — должен считаться тишиной, а не + // звуком, иначе пропуск тишины никогда бы не срабатывал на реальных записях + // (у них почти никогда не бывает ИДЕАЛЬНОГО нуля). + let quiet_noise: Vec = (0..1000).map(|i| ((i as f32 * 7.0).sin()) * 0.001).collect(); + assert!(compute_rms(&quiet_noise) < SILENCE_RMS_THRESHOLD); + } + + /// Сквозная проверка на настоящем файле (не синтетика в юнит-тесте выше): 2 секунды + /// честной цифровой тишины (anullsrc) + 2 секунды тона, декодированные через реальный + /// `open_track`/`next_packet_samples` (тот же путь, что использует движок). Проверяем, + /// что RMS по декодированным пакетам верно относит первую половину к тишине, а вторую — + /// к звуку, с границей примерно на середине файла — не просто на синтетических векторах, + /// а на том, что реально выдаёт декодер. + /// Файл нужно сгенерировать заранее (см. команды ffmpeg в истории разработки) — + /// пропускается, если его нет. Запускать явно: `cargo test -- --ignored`. + #[test] + #[ignore] + fn silence_detection_on_real_decoded_file() { + let path = std::path::PathBuf::from("/tmp/silence_test.wav"); + let mut track = open_track(&path).expect("open_track failed — did you generate /tmp/silence_test.wav?"); + + let mut silent_seconds = 0.0f64; + let mut loud_seconds = 0.0f64; + let mut first_loud_at = None; + + loop { + match next_packet_samples(&mut track).expect("decode failed") { + Some(samples) => { + let frames = samples.len() / track.spec.channels.count(); + let duration = frames as f64 / track.spec.rate as f64; + let rms = compute_rms(&samples); + if rms < SILENCE_RMS_THRESHOLD { + silent_seconds += duration; + } else { + if first_loud_at.is_none() { + first_loud_at = Some(silent_seconds + loud_seconds); + } + loud_seconds += duration; + } + } + None => break, + } + } + + println!("silent: {silent_seconds:.2}s, loud: {loud_seconds:.2}s, boundary at {first_loud_at:?}"); + assert!(silent_seconds > 1.7 && silent_seconds < 2.3, "silent_seconds={silent_seconds}"); + assert!(loud_seconds > 1.7 && loud_seconds < 2.3, "loud_seconds={loud_seconds}"); + let boundary = first_loud_at.expect("should detect a transition to sound"); + assert!(boundary > 1.7 && boundary < 2.3, "boundary={boundary}, expected ~2.0s"); + } +} + fn fade_gain_at(current_track_end: Option, absolute_pos: Duration) -> Option { let end = current_track_end?; if absolute_pos >= end { @@ -255,6 +594,56 @@ mod fade_tests { } } +#[cfg(test)] +mod boost_tests { + use super::*; + + #[test] + fn unity_gain_is_effectively_unchanged() { + // На малых амплитудах tanh(x) ≈ x с точностью до долей процента (нелинейность + // tanh становится заметна ближе к ±1, не у самого нуля) — берём типичные "тихие" + // значения, а не что-то около ±0.2, где отклонение уже не пренебрежимо мало. + let mut samples = vec![0.05, -0.02, 0.01]; + let original = samples.clone(); + apply_boost(&mut samples, 1.0); + for (s, orig) in samples.iter().zip(original.iter()) { + assert!((s - orig).abs() < 0.001, "s={s}, orig={orig}"); + } + } + + #[test] + fn boost_amplifies_quiet_signal() { + let mut samples = vec![0.05, -0.05]; + apply_boost(&mut samples, 2.0); + // при малых значениях tanh(x) ≈ x, так что усиление в 2 раза должно быть заметно + assert!(samples[0] > 0.09 && samples[0] < 0.1, "samples[0]={}", samples[0]); + assert!(samples[1] < -0.09 && samples[1] > -0.1, "samples[1]={}", samples[1]); + } + + #[test] + fn boost_never_exceeds_valid_sample_range_even_at_max_gain() { + // Даже громкий сигнал (близкий к пику) с максимальным усилением не должен выйти + // за пределы [-1, 1] — soft clip обязан держать границу, а не жёсткий clamp. + let mut samples = vec![0.9, -0.95, 1.0, -1.0]; + apply_boost(&mut samples, 3.0); + for s in &samples { + assert!(*s <= 1.0 && *s >= -1.0, "s={s} out of range"); + } + } + + #[test] + fn boost_is_smooth_not_hard_clipped() { + // Проверяем именно "мягкость" ограничения: значения ниже и выше номинального пика + // после усиления должны различаться (не все схлопнуться в одну и ту же границу, + // как было бы при жёстком clamp) — иначе это была бы обычная жёсткая обрезка. + let mut loud = vec![0.8]; + let mut louder = vec![0.95]; + apply_boost(&mut loud, 3.0); + apply_boost(&mut louder, 3.0); + assert!(louder[0] > loud[0], "louder={}, loud={}", louder[0], loud[0]); + } +} + fn handle_command( cmd: PlayerCommand, state: &mut EngineState, @@ -292,10 +681,16 @@ fn handle_command( } PlayerCommand::PlayRadio { name, url } => { state.is_radio = true; + state.is_network_source = true; state.current_track_start = Duration::ZERO; state.current_track_end = None; match open_url(&url, evt_tx.clone()) { Ok(track) => { + let codec = symphonia::default::get_codecs() + .get_codec(track.decoder.codec_params().codec) + .map(|d| d.short_name.to_string()); + let sample_rate = track.spec.rate; + let channels = track.spec.channels.count() as u16; install_track(state, current_track, resampler, paused_flag, evt_tx, track); let _ = evt_tx.send(PlayerEvent::TrackChanged(TrackInfo { path: PathBuf::from(&url), @@ -304,6 +699,14 @@ fn handle_command( album: url, duration: None, cover: None, + codec, + sample_rate: Some(sample_rate), + channels: Some(channels), + bits_per_sample: None, + // У живого потока нет ни фиксированного размера, ни осмысленного + // среднего битрейта (в отличие от файла) — не показываем то, чего нет. + file_size: None, + bitrate_kbps: None, })); } Err(e) => { @@ -336,6 +739,7 @@ fn handle_command( paused_flag.store(true, Ordering::Relaxed); *current_track = None; state.is_radio = false; + state.is_network_source = false; state.spectrum_buffer.clear(); state.current_track_replay_gain = None; let _ = evt_tx.send(PlayerEvent::StatusChanged(state.status)); @@ -381,14 +785,22 @@ fn handle_command( Ok(actual_absolute) => { state.frames_decoded_native = (actual_absolute.as_secs_f64() * state.native_rate as f64) as u64; - // Сбрасываем и буфер вывода (иначе доиграет старое аудио перед прыжком), - // и ресемплер (его внутренняя история сэмплов больше не валидна). + // Сбрасываем буфер вывода (иначе доиграет старое аудио перед прыжком), + // ресемплер (его внутренняя история сэмплов больше не валидна) и + // стретчер (его кроссфейд-хвост — из места ДО прыжка, смешивать с новым + // местом нельзя, иначе получится склейка двух разных фрагментов трека). if let Ok(mut c) = consumer_shared.lock() { c.clear(); } let base_ratio = state.device_rate as f64 / state.native_rate as f64; - *resampler = - SpeedResampler::new(state.device_channels, base_ratio, state.speed).ok(); + *resampler = SpeedResampler::new(state.device_channels, base_ratio).ok(); + if let Some(s) = state.stretcher.as_mut() { + s.reset(); + } + if (state.pitch - 1.0).abs() > 0.001 { + state.pitch_resampler = + SpeedResampler::new(state.device_channels, state.pitch as f64).ok(); + } let _ = evt_tx.send(PlayerEvent::Position(current_position(state))); } Err(e) => { @@ -398,14 +810,33 @@ fn handle_command( } PlayerCommand::SetSpeed(speed) => { state.speed = speed; - if let (Some(track), Some(_)) = (current_track.as_ref(), resampler.as_ref()) { - let base_ratio = state.device_rate as f64 / track.spec.rate as f64; - if let Ok(r) = SpeedResampler::new(state.device_channels, base_ratio, speed) { - *resampler = Some(r); - } + // Скорость теперь целиком отвечает stretcher (сохраняет высоту голоса) — + // resampler больше не меняется по скорости вообще, только под частоту устройства. + // Формула учитывает и питч: если он тоже сдвинут, stretcher должен компенсировать + // изменение длительности от pitch_resampler ПОВЕРХ пользовательской скорости. + if let Some(s) = state.stretcher.as_mut() { + s.set_ratio(state.pitch as f64 / speed as f64); } let _ = evt_tx.send(PlayerEvent::SpeedChanged(speed)); } + PlayerCommand::SetPitch(pitch) => { + let pitch = pitch.clamp(0.5, 2.0); + state.pitch = pitch; + if let Some(s) = state.stretcher.as_mut() { + s.set_ratio(pitch as f64 / state.speed as f64); + } + state.pitch_resampler = if (pitch - 1.0).abs() > 0.001 { + SpeedResampler::new(state.device_channels, pitch as f64).ok() + } else { + None + }; + let _ = evt_tx.send(PlayerEvent::PitchChanged(pitch)); + } + PlayerCommand::ToggleSilenceSkip => { + state.silence_skip_enabled = !state.silence_skip_enabled; + state.silent_run = Duration::ZERO; + let _ = evt_tx.send(PlayerEvent::SilenceSkipChanged(state.silence_skip_enabled)); + } PlayerCommand::ToggleShuffle => { state.queue.set_shuffle(!state.queue.shuffle); let _ = evt_tx.send(PlayerEvent::ShuffleChanged(state.queue.shuffle)); @@ -441,6 +872,10 @@ fn handle_command( state.loudness_norm_enabled = !state.loudness_norm_enabled; let _ = evt_tx.send(PlayerEvent::LoudnessNormChanged(state.loudness_norm_enabled)); } + PlayerCommand::SetBoost(gain) => { + state.boost_gain = gain.clamp(1.0, 3.0); + let _ = evt_tx.send(PlayerEvent::BoostChanged(state.boost_gain)); + } } } @@ -461,7 +896,17 @@ fn install_track( state.eq.as_mut().unwrap().set_band(band, *gain); } let base_ratio = state.device_rate as f64 / track.spec.rate as f64; - *resampler = SpeedResampler::new(channels, base_ratio, state.speed).ok(); + *resampler = SpeedResampler::new(channels, base_ratio).ok(); + state.stretcher = Some(crate::stretch::StreamStretcher::new( + channels, + track.spec.rate, + state.pitch as f64 / state.speed as f64, + )); + state.pitch_resampler = if (state.pitch - 1.0).abs() > 0.001 { + SpeedResampler::new(channels, state.pitch as f64).ok() + } else { + None + }; *current_track = Some(track); state.status = PlaybackStatus::Playing; paused_flag.store(false, Ordering::Relaxed); @@ -483,6 +928,14 @@ fn load_current( match open_track(&item.path) { Ok(mut track) => { + // Стриминг по сети (SMB-шара или http(s):// — например, эпизод подкаста без + // скачивания) — сетевые сбои для таких треков не должны валить весь движок, + // см. комментарий у поля is_network_source. + let path_str = item.path.to_string_lossy(); + state.is_network_source = smbfs::is_smb_path(&item.path) + || path_str.starts_with("http://") + || path_str.starts_with("https://"); + // Виртуальный трек (например, кусок cue-листа) — сразу прыгаем на его начало // внутри физического файла, вместо проигрывания с самого начала файла. if item.start > Duration::ZERO { @@ -519,6 +972,12 @@ fn load_current( album: "Unknown Album".to_string(), duration: None, cover: None, + codec: None, + sample_rate: None, + channels: None, + bits_per_sample: None, + file_size: None, + bitrate_kbps: None, }); if let Some(title) = &item.title_override { info.title = title.clone(); @@ -586,84 +1045,126 @@ fn decode_and_push( }; let packet_result = next_packet_samples(track); - - // Для радио сетевая ошибка (обрыв соединения) приходит как Err, а не Ok(None) — - // трактуем это так же, как конец потока. - let packet_result = match packet_result { - Ok(v) => Ok(v), - Err(e) if state.is_radio => { - let _ = evt_tx.send(PlayerEvent::Error(format!("radio stream error: {e}"))); - Ok(None) - } - Err(e) => Err(e), - }; + let (packet_result, network_err_msg) = + classify_packet_result(packet_result, state.is_network_source); + if let Some(msg) = network_err_msg { + let _ = evt_tx.send(PlayerEvent::Error(msg)); + } match packet_result? { - Some(mut samples) => { - let frames = samples.len() / track.spec.channels.count(); + Some(samples) => { + let native_channels = track.spec.channels.count().max(1); + let frames = samples.len() / native_channels; state.frames_decoded_native += frames as u64; + // Приводим к числу каналов устройства СРАЗУ — EQ/resampler/stretcher/ + // pitch_resampler ниже все сконструированы под device_channels, а декодированный + // пакет пришёл в формате самого трека (может быть моно, даже если устройство + // стерео — частое дело для подкастов). + let mut samples = convert_channels(&samples, native_channels, state.device_channels); + if let Some(eq) = state.eq.as_mut() { eq.process_interleaved(&mut samples); } - let channels = track.spec.channels.count().max(1); + let channels = state.device_channels; - if state.loudness_norm_enabled { - if let Some(gain) = state.current_track_replay_gain { - // Точное значение из тега ReplayGain — просто статическое усиление. - for s in samples.iter_mut() { - *s *= gain; - } + // Пропуск тишины меряем на пост-EQ сигнале (то, что реально будет слышно, если + // не пропустим) — до нормализации/буста, честно на "как записано". + let mut skip_output = false; + if state.silence_skip_enabled { + let rms = compute_rms(&samples); + let packet_duration = Duration::from_secs_f64(frames as f64 / track.spec.rate as f64); + if rms < SILENCE_RMS_THRESHOLD { + state.silent_run += packet_duration; } else { - // Тега нет — используем адаптивный левеллер (AGC) как fallback. - leveler.process(&mut samples, channels, state.native_rate as f32); + state.silent_run = Duration::ZERO; } + skip_output = state.silent_run >= SILENCE_GRACE_PERIOD; } - if state.fade_out_enabled { - if let Some(fade) = fade_gain(state) { - if fade < 1.0 { + if !skip_output { + if state.loudness_norm_enabled { + if let Some(gain) = state.current_track_replay_gain { + // Точное значение из тега ReplayGain — просто статическое усиление. for s in samples.iter_mut() { - *s *= fade; + *s *= gain; + } + } else { + // Тега нет — используем адаптивный левеллер (AGC) как fallback. + leveler.process(&mut samples, channels, state.native_rate as f32); + } + } + + if state.fade_out_enabled { + if let Some(fade) = fade_gain(state) { + if fade < 1.0 { + for s in samples.iter_mut() { + *s *= fade; + } } } } - } - // Копим моно-микс пост-EQ сэмплов (то, что реально слышит пользователь) для - // визуализатора спектра — считаем FFT не чаще, чем раз в SPECTRUM_SEND_INTERVAL, - // чтобы не заваливать канал событий чаще, чем это осмысленно для отрисовки. - state - .spectrum_buffer - .extend(samples.chunks(channels).map(|frame| { - frame.iter().sum::() / channels as f32 - })); - if state.spectrum_buffer.len() >= FFT_SIZE - && state.last_spectrum_send.elapsed() >= SPECTRUM_SEND_INTERVAL - { - let start = state.spectrum_buffer.len() - FFT_SIZE; - let bands = spectrum_analyzer.compute(&state.spectrum_buffer[start..], state.native_rate as f32); - let _ = evt_tx.send(PlayerEvent::Spectrum(bands.to_vec())); - state.spectrum_buffer.clear(); - state.last_spectrum_send = Instant::now(); - } - // не даём буферу расти бесконечно, если по какой-то причине долго не отправляли - if state.spectrum_buffer.len() > FFT_SIZE * 8 { - state.spectrum_buffer.clear(); - } - - let out = if let Some(r) = resampler.as_mut() { - r.push_interleaved(&samples)? - } else { - samples - }; - - for s in out { - // если буфер полон, немного подождём, чтобы не терять сэмплы - while producer.push(s).is_err() { - std::thread::sleep(Duration::from_millis(1)); + if state.boost_gain > 1.0 { + apply_boost(&mut samples, state.boost_gain); } + + // Копим моно-микс пост-EQ сэмплов (то, что реально слышит пользователь) для + // визуализатора спектра — считаем FFT не чаще, чем раз в SPECTRUM_SEND_INTERVAL, + // чтобы не заваливать канал событий чаще, чем это осмысленно для отрисовки. + state + .spectrum_buffer + .extend(samples.chunks(channels).map(|frame| { + frame.iter().sum::() / channels as f32 + })); + if state.spectrum_buffer.len() >= FFT_SIZE + && state.last_spectrum_send.elapsed() >= SPECTRUM_SEND_INTERVAL + { + let start = state.spectrum_buffer.len() - FFT_SIZE; + let bands = spectrum_analyzer.compute(&state.spectrum_buffer[start..], state.native_rate as f32); + let _ = evt_tx.send(PlayerEvent::Spectrum(bands.to_vec())); + state.spectrum_buffer.clear(); + state.last_spectrum_send = Instant::now(); + } + // не даём буферу расти бесконечно, если по какой-то причине долго не отправляли + if state.spectrum_buffer.len() > FFT_SIZE * 8 { + state.spectrum_buffer.clear(); + } + + let pitched = if let Some(pr) = state.pitch_resampler.as_mut() { + pr.push_interleaved(&samples)? + } else { + samples + }; + + let stretched = if let Some(s) = state.stretcher.as_mut() { + s.process(&pitched) + } else { + pitched + }; + + let out = if let Some(r) = resampler.as_mut() { + r.push_interleaved(&stretched)? + } else { + stretched + }; + + for s in out { + // если буфер полон, немного подождём, чтобы не терять сэмплы + while producer.push(s).is_err() { + std::thread::sleep(Duration::from_millis(1)); + } + } + + // Во время пропуска тишины позицию всё равно двигаем (см. выше, + // frames_decoded_native уже посчитан) — но события Position шлём только тут, + // при обычном проигрывании; на пропуске тишины отдельно шлём ниже, чтобы + // прогресс-бар не "замирал" на весь пропускаемый участок. + } else if state.silent_run.as_millis() % 500 < 50 { + // Не на каждый пакет (их много в секунду) — периодически всё же шлём Position, + // чтобы UI видел, что мы куда-то продвигаемся, а не просто зависли. + let _ = evt_tx.send(PlayerEvent::Position(current_position(state))); } // Достигли конца виртуального (cue) трека внутри файла — переходим дальше, @@ -678,12 +1179,35 @@ fn decode_and_push( None => { // физический конец файла — переходим дальше согласно repeat/shuffle (для файлов) // либо просто останавливаемся (для радио: следующего трека не существует) + // Сначала дожимаем pitch_resampler (его хвост идёт через process(), как обычные + // сэмплы), затем сам stretcher (его собственный кроссфейд-хвост) — иначе + // потеряется последний фрагмент трека. Получившееся отдаём через device resampler. + let pitch_tail = state + .pitch_resampler + .as_mut() + .and_then(|pr| pr.flush().ok()) + .unwrap_or_default(); + let mut combined_tail = state + .stretcher + .as_mut() + .map(|s| s.process(&pitch_tail)) + .unwrap_or(pitch_tail); + combined_tail.extend(state.stretcher.as_mut().map(|s| s.finish()).unwrap_or_default()); if let Some(r) = resampler.as_mut() { + if let Ok(head) = r.push_interleaved(&combined_tail) { + for s in head { + let _ = producer.push(s); + } + } if let Ok(tail) = r.flush() { for s in tail { let _ = producer.push(s); } } + } else { + for s in combined_tail { + let _ = producer.push(s); + } } advance_or_finish(state, current_track, resampler, paused_flag, evt_tx); Ok(()) @@ -714,4 +1238,4 @@ fn sibling_album_tracks(path: &PathBuf) -> anyhow::Result> { .collect(); entries.sort(); Ok(entries) -} +} \ No newline at end of file diff --git a/crates/player/src/lib.rs b/crates/player/src/lib.rs index 241e532..44e0c84 100644 --- a/crates/player/src/lib.rs +++ b/crates/player/src/lib.rs @@ -5,6 +5,7 @@ mod leveler; mod queue; mod resample; mod spectrum; +mod stretch; pub mod types; use std::path::PathBuf; @@ -79,6 +80,22 @@ impl Player { self.send(PlayerCommand::ToggleLoudnessNorm) } + /// Усиление громкости сверх номинальной. 1.0 = 100% (без изменений), автоматически + /// ограничивается движком до 3.0 (300%). + pub fn set_boost(&self, gain: f32) -> bool { + self.send(PlayerCommand::SetBoost(gain)) + } + + /// Высота голоса независимо от скорости (1.0 = без изменений). + pub fn set_pitch(&self, pitch: f32) -> bool { + self.send(PlayerCommand::SetPitch(pitch)) + } + + /// Автоматический пропуск затянувшейся тишины (короткие паузы в речи не режутся). + pub fn toggle_silence_skip(&self) -> bool { + self.send(PlayerCommand::ToggleSilenceSkip) + } + pub fn play_pause(&self) -> bool { self.send(PlayerCommand::PlayPause) } diff --git a/crates/player/src/resample.rs b/crates/player/src/resample.rs index 8c0545b..008aee6 100644 --- a/crates/player/src/resample.rs +++ b/crates/player/src/resample.rs @@ -13,8 +13,10 @@ pub struct SpeedResampler { } impl SpeedResampler { - /// device_rate / track_rate — базовое соотношение без учёта скорости. - pub fn new(channels: usize, base_ratio: f64, speed: f32) -> anyhow::Result { + /// device_rate / track_rate — соотношение под частоту устройства воспроизведения. + /// Больше не занимается пользовательской скоростью — за неё отвечает отдельный + /// pitch-preserving `StreamStretcher`, включённый в цепочку раньше этого ресемплера. + pub fn new(channels: usize, ratio: f64) -> anyhow::Result { let chunk_size = 1024; let params = SincInterpolationParameters { sinc_len: 128, @@ -23,7 +25,6 @@ impl SpeedResampler { oversampling_factor: 128, window: WindowFunction::BlackmanHarris2, }; - let ratio = base_ratio / speed as f64; let resampler = SincFixedIn::::new(ratio, 2.0, params, chunk_size, channels)?; Ok(SpeedResampler { channels, diff --git a/crates/player/src/stretch.rs b/crates/player/src/stretch.rs new file mode 100644 index 0000000..a8fc7eb --- /dev/null +++ b/crates/player/src/stretch.rs @@ -0,0 +1,232 @@ +//! Потоковая обёртка над крейтом `timestretch` (batch-API — принимает буфер целиком) — +//! наш движок декодирует и проигрывает аудио НЕПРЕРЫВНО, кусками, поэтому наивная +//! последовательная обработка "кусок за куском" даёт заметные щелчки на стыках (проверено +//! эмпирически: скачок амплитуды на границе в ~14 раз больше нормального шума сигнала). +//! Решение — классический overlap-add: растягиваем каждый чанк, а на стыке с предыдущим +//! выходом делаем короткий линейный кроссфейд вместо резкой склейки (тоже проверено — +//! убирает скачок практически полностью, до уровня обычного шума). + +use timestretch::StretchParams; + +const CHUNK_SECONDS: f64 = 0.5; +const CROSSFADE_MS: f64 = 20.0; + +pub struct StreamStretcher { + channels: usize, + sample_rate: u32, + /// >1.0 — медленнее/длиннее, <1.0 — быстрее/короче (как и timestretch::StretchParams). + ratio: f64, + chunk_frames: usize, + crossfade_frames: usize, + /// Ещё не набранные до полного чанка входные сэмплы (interleaved). + input_buffer: Vec, + /// Хвост предыдущего растянутого чанка, ждущий кроссфейда со следующим (interleaved). + pending_tail: Option>, +} + +impl StreamStretcher { + pub fn new(channels: usize, sample_rate: u32, ratio: f64) -> Self { + let chunk_frames = ((sample_rate as f64 * CHUNK_SECONDS) as usize).max(1); + let crossfade_frames = ((sample_rate as f64 * CROSSFADE_MS / 1000.0) as usize).max(1); + StreamStretcher { + channels: channels.max(1), + sample_rate, + ratio: ratio.clamp(0.5, 2.5), + chunk_frames, + crossfade_frames, + input_buffer: Vec::new(), + pending_tail: None, + } + } + + pub fn set_ratio(&mut self, ratio: f64) { + self.ratio = ratio.clamp(0.5, 2.5); + } + + /// Сбрасывает всё внутреннее состояние — обязательно вызывать при seek/смене трека, + /// иначе кроссфейд смешает хвост со звуком из совсем другого места/трека. + pub fn reset(&mut self) { + self.input_buffer.clear(); + self.pending_tail = None; + } + + /// Принимает interleaved input, возвращает готовый (растянутый + сглаженный) output. + /// Может вернуть пустой Vec, если ещё не накопилось достаточно на полный чанк — это + /// нормально, вызывающий код просто не толкает ничего в этот раз, данные не теряются + /// (копятся во внутреннем буфере до следующего вызова). + pub fn process(&mut self, input: &[f32]) -> Vec { + if (self.ratio - 1.0).abs() < 0.001 { + // Единичный коэффициент — отдаём как есть, не гоняя зря через стретчер и не + // копя в буфер (у timestretch и так есть fast-path на ratio=1.0, но так + // экономим ещё и на дополнительной задержке буферизации). + return input.to_vec(); + } + + self.input_buffer.extend_from_slice(input); + let chunk_samples = self.chunk_frames * self.channels; + let mut output = Vec::new(); + + while self.input_buffer.len() >= chunk_samples { + let chunk: Vec = self.input_buffer.drain(..chunk_samples).collect(); + self.stretch_and_append(&chunk, &mut output); + } + output + } + + /// Дообрабатывает остаток во внутреннем буфере (даже если он меньше полного чанка) и + /// выталкивает висящий хвост — вызывать на конце трека/при остановке, иначе последний + /// кусочек звука так и останется недоигранным внутри буфера. + pub fn finish(&mut self) -> Vec { + let mut output = Vec::new(); + if !self.input_buffer.is_empty() { + let remaining = std::mem::take(&mut self.input_buffer); + self.stretch_and_append(&remaining, &mut output); + } + if let Some(tail) = self.pending_tail.take() { + output.extend(tail); + } + output + } + + fn stretch_and_append(&mut self, chunk: &[f32], output: &mut Vec) { + let params = StretchParams::new(self.ratio) + .with_sample_rate(self.sample_rate) + .with_channels(self.channels as u32); + let Ok(stretched) = timestretch::stretch(chunk, ¶ms) else { + return; // не должно происходить при валидных параметрах, но не паникуем на аудио-потоке + }; + self.append_with_crossfade(output, stretched); + } + + fn append_with_crossfade(&mut self, output: &mut Vec, stretched: Vec) { + let crossfade_samples = (self.crossfade_frames * self.channels).min(stretched.len()); + if let Some(tail) = self.pending_tail.take() { + let fade_len = tail.len().min(stretched.len()).min(crossfade_samples); + for i in 0..fade_len { + let t = i as f32 / fade_len as f32; + output.push(tail[i] * (1.0 - t) + stretched[i] * t); + } + let body_end = stretched.len().saturating_sub(crossfade_samples); + if fade_len < body_end { + output.extend_from_slice(&stretched[fade_len..body_end]); + } + } else { + let body_end = stretched.len().saturating_sub(crossfade_samples); + output.extend_from_slice(&stretched[..body_end]); + } + if stretched.len() >= crossfade_samples { + self.pending_tail = Some(stretched[stretched.len() - crossfade_samples..].to_vec()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn generate_tone(freq: f32, sample_rate: u32, duration_secs: f32) -> Vec { + let n = (sample_rate as f32 * duration_secs) as usize; + (0..n) + .map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate as f32).sin() * 0.5) + .collect() + } + + fn max_sample_jump(samples: &[f32]) -> f32 { + samples.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0, f32::max) + } + + /// Тот самый тест, которым проверялся сам подход перед тем, как встраивать в движок — + /// теперь как регрессионный тест на реальный код, а не разовая ручная проверка. + #[test] + fn streaming_matches_no_audible_seams_at_chunk_boundaries() { + let sample_rate = 44100; + let tone = generate_tone(440.0, sample_rate, 6.0); + + let mut stretcher = StreamStretcher::new(1, sample_rate, 1.5); + let mut output = Vec::new(); + // Кормим маленькими кусками (как реальный движок кормит декодированными пакетами) — + // не одним большим куском, чтобы честно проверить внутреннюю буферизацию. + for input_chunk in tone.chunks(4096) { + output.extend(stretcher.process(input_chunk)); + } + output.extend(stretcher.finish()); + + let jump = max_sample_jump(&output); + // Нормальный шум внутри самого тона — около 0.04 (см. ручную проверку выше по коду). + // Без кроссфейда было бы ~0.57 (в 14 раз больше) — проверяем, что мы далеко от этого. + assert!(jump < 0.1, "max jump {jump} suggests audible seam artifacts"); + } + + #[test] + fn output_length_matches_expected_ratio() { + let sample_rate = 44100; + let tone = generate_tone(440.0, sample_rate, 4.0); + let mut stretcher = StreamStretcher::new(1, sample_rate, 1.5); + let mut output = Vec::new(); + for input_chunk in tone.chunks(8192) { + output.extend(stretcher.process(input_chunk)); + } + output.extend(stretcher.finish()); + + let expected = (tone.len() as f64 * 1.5) as usize; + let tolerance = (expected as f64 * 0.05) as usize; // 5% допуск на округления чанков + assert!( + output.len().abs_diff(expected) < tolerance, + "output={}, expected~={}", + output.len(), + expected + ); + } + + #[test] + fn unity_ratio_passes_through_unchanged() { + let mut stretcher = StreamStretcher::new(1, 44100, 1.0); + let input = vec![0.1, -0.2, 0.3, -0.4]; + let output = stretcher.process(&input); + assert_eq!(output, input, "ratio=1.0 should be a no-op passthrough"); + } + + #[test] + fn reset_clears_state_so_no_stale_crossfade_after_seek() { + let sample_rate = 44100; + let tone = generate_tone(440.0, sample_rate, 2.0); + let mut stretcher = StreamStretcher::new(1, sample_rate, 1.5); + // Прогоняем часть, чтобы внутри что-то накопилось/повисло на кроссфейд. + let _ = stretcher.process(&tone[..8192]); + stretcher.reset(); + + // После reset поведение должно быть как с чистого листа — тот же выход, что и + // просто у нового стретчера на тех же входных данных. + let mut fresh = StreamStretcher::new(1, sample_rate, 1.5); + let out_after_reset = stretcher.process(&tone[..8192]); + let out_fresh = fresh.process(&tone[..8192]); + assert_eq!(out_after_reset, out_fresh, "reset must fully clear buffered state"); + } + + #[test] + fn stereo_channels_stay_separated() { + let sample_rate = 44100; + let n_frames = sample_rate; // 1 секунда + let mut stereo = Vec::with_capacity(n_frames as usize * 2); + for i in 0..n_frames { + let t = i as f32 / sample_rate as f32; + stereo.push((2.0 * std::f32::consts::PI * 440.0 * t).sin() * 0.5); + stereo.push((2.0 * std::f32::consts::PI * 880.0 * t).sin() * 0.5); + } + let mut stretcher = StreamStretcher::new(2, sample_rate, 1.3); + let mut output = Vec::new(); + for input_chunk in stereo.chunks(4096) { + output.extend(stretcher.process(input_chunk)); + } + output.extend(stretcher.finish()); + + assert_eq!(output.len() % 2, 0, "stereo output must have an even sample count"); + let l_energy: f32 = output.iter().step_by(2).map(|s| s * s).sum(); + let r_energy: f32 = output.iter().skip(1).step_by(2).map(|s| s * s).sum(); + assert!(l_energy > 0.0 && r_energy > 0.0); + // Разные частоты в L/R дают немного разную энергию, но не на порядки — если бы + // каналы перепутались/слиплись, разница была бы куда более грубой. + let ratio = (l_energy / r_energy).max(r_energy / l_energy); + assert!(ratio < 2.0, "L/R energy ratio {ratio} suggests channels got mixed up"); + } +} diff --git a/crates/player/src/types.rs b/crates/player/src/types.rs index b194cc6..3234ea0 100644 --- a/crates/player/src/types.rs +++ b/crates/player/src/types.rs @@ -140,6 +140,14 @@ pub enum PlayerCommand { /// Выравнивание громкости между треками: тег ReplayGain в приоритете, /// адаптивный левеллер (AGC) как fallback для файлов без тега. ToggleLoudnessNorm, + /// Усиление громкости сверх номинальной (1.0 = 100%, без изменений). Полезно для тихих + /// записей (частая проблема аудиокниг) — ограничено сверху, чтобы не уйти в жёсткий клиппинг. + SetBoost(f32), + /// Высота голоса, независимо от скорости (1.0 = без изменений). + SetPitch(f32), + /// Автоматический пропуск затянувшейся тишины (не короткие паузы в речи — только + /// действительно долгие). + ToggleSilenceSkip, } #[derive(Debug, Clone)] @@ -150,6 +158,16 @@ pub struct TrackInfo { pub album: String, pub duration: Option, pub cover: Option>, + /// Человекочитаемое имя кодека (mp3, flac, vorbis, ...), если удалось определить. + pub codec: Option, + pub sample_rate: Option, + pub channels: Option, + pub bits_per_sample: Option, + /// Размер файла в байтах — None для потоковых источников (радио), где размер неизвестен. + pub file_size: Option, + /// Средний битрейт в kbps, посчитанный как file_size*8/duration — честная оценка и для + /// постоянного, и для переменного битрейта (VBR), а не только заявленное в заголовке. + pub bitrate_kbps: Option, } #[derive(Debug, Clone)] @@ -173,6 +191,9 @@ pub enum PlayerEvent { Spectrum(Vec), FadeOutChanged(bool), LoudnessNormChanged(bool), + BoostChanged(f32), + PitchChanged(f32), + SilenceSkipChanged(bool), QueueFinished, Error(String), } diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index e15824d..0d556dd 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; use chrono::{DateTime, Local}; @@ -20,6 +20,8 @@ pub enum Tab { Playlists, Queue, Radio, + Podcasts, + Audiobooks, Stats, NewReleases, } @@ -31,6 +33,8 @@ impl Tab { Tab::Playlists => "Playlists", Tab::Queue => "Queue", Tab::Radio => "Radio", + Tab::Podcasts => "Podcasts", + Tab::Audiobooks => "Audiobooks", Tab::Stats => "Stats", Tab::NewReleases => "New Releases", } @@ -41,7 +45,9 @@ impl Tab { Tab::Library => Tab::Playlists, Tab::Playlists => Tab::Queue, Tab::Queue => Tab::Radio, - Tab::Radio => Tab::Stats, + Tab::Radio => Tab::Podcasts, + Tab::Podcasts => Tab::Audiobooks, + Tab::Audiobooks => Tab::Stats, Tab::Stats => Tab::NewReleases, Tab::NewReleases => Tab::Library, } @@ -122,6 +128,12 @@ pub struct NowPlaying { pub status_playing: bool, /// Текст "сейчас играет" из ICY-метаданных радиопотока, если станция такие отдаёт. pub radio_song: Option, + pub codec: Option, + pub sample_rate: Option, + pub channels: Option, + pub bits_per_sample: Option, + pub file_size: Option, + pub bitrate_kbps: Option, } /// Режим фокуса ввода: обычная навигация по спискам, ввод текста поиска, либо эквалайзер. @@ -144,6 +156,15 @@ pub enum Focus { LibraryPathInput, /// Ручной ввод имени артиста для проверки новых релизов (не обязательно есть в библиотеке). NewReleaseArtistInput, + /// Ввод URL RSS-фида подкаста для подписки. + PodcastUrlInput, + /// Ввод пути папки для сканирования аудиокниг. + AudiobookPathInput, + /// Поиск по аудиокнигам. + AudiobookSearch, + /// Ручная правка названия книги (первый шаг — потом автор). + AudiobookEditTitle, + AudiobookEditAuthor, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -161,20 +182,6 @@ impl RightPanelView { } } -/// Обновляет экспоненциальное скользящее среднее басовой энергии на один новый отсчёт. -/// `alpha` — насколько быстро среднее "забывает" старые значения (0..1; больше = быстрее). -pub fn update_bass_ema(ema: f32, current: f32, alpha: f32) -> f32 { - ema * (1.0 - alpha) + current * alpha -} - -/// "Удар баса" — текущее значение заметно выше недавнего среднего, а не просто выше -/// произвольной фиксированной константы. Так реакция подстраивается под конкретный трек: -/// и тихая акустика, и громкий метал будут давать срабатывания на СВОИХ собственных ударах, -/// а не либо никогда, либо постоянно. -pub fn is_bass_hit(current: f32, ema: f32) -> bool { - current > ema * 1.3 + 0.02 -} - pub fn protocol_to_str(p: ProtocolType) -> &'static str { match p { ProtocolType::Halfblocks => "halfblocks", @@ -194,6 +201,60 @@ fn protocol_from_str(s: &str) -> Option { } } +/// Фильтр по вкладкам статуса на экране аудиокниг — в отличие от `library::audiobook:: +/// AudiobookStatus`, тут есть ещё "все книги" (без фильтра). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AudiobookFilter { + New, + Listening, + Finished, + All, +} + +impl AudiobookFilter { + pub fn label(self) -> &'static str { + match self { + AudiobookFilter::New => "New", + AudiobookFilter::Listening => "Listening", + AudiobookFilter::Finished => "Finished", + AudiobookFilter::All => "All", + } + } + + pub fn next(self) -> Self { + match self { + AudiobookFilter::New => AudiobookFilter::Listening, + AudiobookFilter::Listening => AudiobookFilter::Finished, + AudiobookFilter::Finished => AudiobookFilter::All, + AudiobookFilter::All => AudiobookFilter::New, + } + } + + fn matches(self, status: library::audiobook::AudiobookStatus) -> bool { + use library::audiobook::AudiobookStatus as S; + match self { + AudiobookFilter::New => status == S::New, + AudiobookFilter::Listening => status == S::Listening, + AudiobookFilter::Finished => status == S::Finished, + AudiobookFilter::All => true, + } + } +} + +/// Чем дольше пауза, тем больше откат при возобновлении — чтобы не терять нить разговора, +/// если отвлёкся надолго, но не мешать короткими паузами (отошёл на пару секунд). Ступени +/// подобраны на глаз, разумно для аудиокниг/подкастов: короткая пауза — без отката, пауза +/// на несколько минут — заметный откат, чтобы точно не потерять нить. +fn smart_rewind_amount(pause_duration: Duration) -> Duration { + let secs = pause_duration.as_secs(); + match secs { + 0..=9 => Duration::ZERO, + 10..=59 => Duration::from_secs(3), + 60..=599 => Duration::from_secs(10), + _ => Duration::from_secs(30), + } +} + pub struct App { pub library: Library, pub player: Player, @@ -206,6 +267,9 @@ pub struct App { pub tab: Tab, pub focus: Focus, pub should_quit: bool, + /// Показан ли попап справки (по '?') — контекстные подсказки клавиш переехали сюда + /// из заголовков панелей, где они не помещались при разумном масштабе терминала. + pub show_help: bool, // --- Library tab --- pub search_query: String, @@ -239,6 +303,15 @@ pub struct App { pub new_station_name: String, pub new_station_url: String, pub current_station: Option, // URL текущей играющей станции, для подсветки + /// id аудиокниги, которая сейчас играет — если Some, события Position/QueueIndexChanged + /// периодически сохраняют прогресс обратно в AudiobookStore. + pub current_audiobook_id: Option, + current_audiobook_file_index: usize, + last_audiobook_progress_save: std::time::Instant, + /// Момент, когда была нажата пауза — используется для "умного отката": чем дольше + /// пауза, тем больше откат назад при возобновлении (чтобы не потерять нить разговора). + /// `None`, если сейчас не на паузе (или ещё ничего не играло). + paused_at: Option, /// Результаты последнего онлайн-поиска через Radio Browser API. Пока не пусто — /// вкладка Radio показывает их вместо основного списка (curated+custom). pub online_results: Vec, @@ -251,6 +324,42 @@ pub struct App { smb_connect_rx: Option>)>>, pub smb_connecting: bool, + // --- Podcasts tab --- + pub podcast_store: library::podcast::PodcastStore, + pub podcast_cache: library::podcast::PodcastCache, + /// Подписки + последнее известное состояние (из кэша, обновляется по запросу) — + /// список фидов верхнего уровня. + pub podcasts: Vec, + pub podcast_selected: usize, + /// Если Some(i) — раскрыт i-й подкаст, показываем список его эпизодов вместо списка подписок. + pub podcast_expanded: Option, + pub podcast_episode_selected: usize, + pub new_podcast_url: String, + pub podcast_fetching: bool, + podcast_fetch_rx: Option>>, + /// URL эпизода, который сейчас скачивается (для отображения прогресса в статусе) — не + /// поддерживаем параллельные скачивания, чтобы не путаться, скачивание одно за раз. + pub podcast_downloading_url: Option, + podcast_download_rx: Option>>, + + // --- Audiobooks tab --- + pub audiobook_store: library::audiobook::AudiobookStore, + pub audiobooks: library::audiobook::AudiobookLibrary, + pub audiobook_filter: AudiobookFilter, + pub audiobook_selected: usize, + /// Если Some(id) — раскрыта книга с этим id, показываем список файлов/глав. + pub audiobook_expanded: Option, + pub audiobook_file_selected: usize, + pub audiobook_search_query: String, + /// id книги, отмеченной для объединения — следующая книга, на которой нажмут ту же + /// клавишу, объединится с этой. + pub audiobook_merge_pending: Option, + pub audiobook_edit_title_input: String, + pub audiobook_edit_author_input: String, + pub new_audiobook_path_input: String, + pub audiobook_scanning: bool, + audiobook_scan_rx: Option>>, + // --- Now playing / transport --- pub now_playing: Option, pub speed: Speed, @@ -259,6 +368,9 @@ pub struct App { pub muted: bool, pub fade_out_enabled: bool, pub loudness_norm_enabled: bool, + pub boost_gain: f32, + pub pitch: f32, + pub silence_skip_enabled: bool, pub eq_gains: [f32; EQ_BANDS], pub eq_selected: usize, @@ -286,11 +398,6 @@ pub struct App { pub right_panel_view: RightPanelView, /// Текущие значения полос спектра (0..1), обновляются событиями от плеера. pub spectrum: Vec, - /// Скользящее среднее басовой энергии — для адаптивного "детектора удара баса" - /// (Noise Marines реагируют на ВСПЛЕСК относительно недавнего среднего, а не на - /// фиксированный порог, который у одних треков никогда не пробивается, а у других - /// пробит всегда). - pub bass_ema: f32, /// Если true — выбранный right_panel_view занимает весь экран. pub fullscreen: bool, @@ -310,6 +417,34 @@ impl App { stations.extend(custom); let player = Player::new(); + let podcast_store = library::podcast::PodcastStore::new(library::default_podcasts_store_path()); + let podcast_cache = library::podcast::PodcastCache::new(library::default_podcasts_cache_path()); + // Показываем закэшированное состояние сразу при старте — без обращения к сети. + // Подписки без кэша (например, только что добавленные, но так и не обновлённые) + // показываются пустой заглушкой с заголовком = URL, пока не нажмут "обновить". + let subscribed_urls = podcast_store.load().unwrap_or_default(); + let cached_feeds = podcast_cache.load(); + let podcasts: Vec = subscribed_urls + .into_iter() + .map(|url| { + cached_feeds + .iter() + .find(|f| f.feed_url == url) + .cloned() + .unwrap_or_else(|| library::podcast::PodcastFeed { + feed_url: url.clone(), + title: url, + description: None, + episodes: Vec::new(), + }) + }) + .collect(); + + // Загружаем уже известное состояние без пересканирования — оно может быть большим + // (много длинных книг), пересканируем только по явному запросу (`L`/`f`). + let audiobooks_lib = + library::audiobook::AudiobookStore::new(library::default_audiobooks_path()).load(); + let settings_path = library::default_settings_path(); let settings = Settings::load(&settings_path); let history_store = HistoryStore::new(library::default_history_path())?; @@ -350,6 +485,7 @@ impl App { tab: Tab::Library, focus: initial_focus, should_quit: false, + show_help: false, search_query: String::new(), library_view: LibraryView::Albums(Vec::new()), library_mode: LibraryMode::Albums, @@ -374,6 +510,10 @@ impl App { new_station_name: String::new(), new_station_url: String::new(), current_station: None, + current_audiobook_id: None, + current_audiobook_file_index: 0, + last_audiobook_progress_save: std::time::Instant::now(), + paused_at: None, online_results: Vec::new(), online_selected: 0, station_query: String::new(), @@ -381,6 +521,32 @@ impl App { station_search_rx: None, smb_connect_rx: None, smb_connecting: false, + + podcast_store, + podcast_cache, + podcasts, + podcast_selected: 0, + podcast_expanded: None, + podcast_episode_selected: 0, + new_podcast_url: String::new(), + podcast_fetching: false, + podcast_fetch_rx: None, + podcast_downloading_url: None, + podcast_download_rx: None, + + audiobook_store: library::audiobook::AudiobookStore::new(library::default_audiobooks_path()), + audiobooks: audiobooks_lib, + audiobook_filter: AudiobookFilter::All, + audiobook_selected: 0, + audiobook_expanded: None, + audiobook_file_selected: 0, + audiobook_search_query: String::new(), + audiobook_merge_pending: None, + audiobook_edit_title_input: String::new(), + audiobook_edit_author_input: String::new(), + new_audiobook_path_input: String::new(), + audiobook_scanning: false, + audiobook_scan_rx: None, now_playing: None, speed: Speed::default(), shuffle: false, @@ -388,6 +554,9 @@ impl App { muted: false, fade_out_enabled: false, loudness_norm_enabled: false, + boost_gain: 1.0, + pitch: 1.0, + silence_skip_enabled: false, eq_gains: [0.0; EQ_BANDS], eq_selected: 0, stats_period: StatsPeriod::Week, @@ -403,7 +572,6 @@ impl App { last_cover_bytes: None, right_panel_view: RightPanelView::Cover, spectrum: vec![0.0; player::SPECTRUM_BANDS], - bass_ema: 0.0, fullscreen: false, status_message: format!("nsm — loaded from {}", root.display()), }; @@ -550,6 +718,429 @@ impl App { } } + /// Подписывается на подкаст по URL RSS-фида и сразу запускает первую загрузку в фоне. + pub fn add_podcast(&mut self, url: &str) { + let url = url.trim().to_string(); + if url.is_empty() { + return; + } + if let Err(e) = self.podcast_store.add(&url) { + self.status_message = format!("Failed to save podcast subscription: {e}"); + return; + } + if !self.podcasts.iter().any(|p| p.feed_url == url) { + self.podcasts.push(library::podcast::PodcastFeed { + feed_url: url.clone(), + title: url.clone(), + description: None, + episodes: Vec::new(), + }); + } + self.start_podcast_fetch(url); + } + + fn start_podcast_fetch(&mut self, url: String) { + if self.podcast_fetching { + self.status_message = "Already fetching a podcast feed…".to_string(); + return; + } + self.status_message = format!("Fetching {url}…"); + self.podcast_fetching = true; + self.podcast_fetch_rx = Some(library::podcast::spawn_fetch_feed(url)); + } + + /// Обновляет фид подкаста — раскрытого (если внутри списка эпизодов) или выбранного + /// в списке подписок. RSS не хранится вечно неизменным, у подкастов выходят новые выпуски. + pub fn refresh_selected_podcast(&mut self) { + let idx = self.podcast_expanded.unwrap_or(self.podcast_selected); + if let Some(feed) = self.podcasts.get(idx) { + let url = feed.feed_url.clone(); + self.start_podcast_fetch(url); + } + } + + /// Опрашивает фоновую загрузку RSS-фида — вызывать каждый тик. + pub fn poll_podcast_fetch(&mut self) { + let Some(rx) = self.podcast_fetch_rx.as_ref() else { + return; + }; + match rx.try_recv() { + Ok(Ok(feed)) => { + self.status_message = + format!("Fetched '{}' — {} episode(s)", feed.title, feed.episodes.len()); + if let Some(existing) = self.podcasts.iter_mut().find(|p| p.feed_url == feed.feed_url) { + *existing = feed; + } else { + self.podcasts.push(feed); + } + let _ = self.podcast_cache.save(&self.podcasts); + self.podcast_fetching = false; + self.podcast_fetch_rx = None; + } + Ok(Err(e)) => { + self.status_message = format!("Podcast fetch failed: {e}"); + self.podcast_fetching = false; + self.podcast_fetch_rx = None; + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.podcast_fetching = false; + self.podcast_fetch_rx = None; + } + } + } + + /// Отписывается от выбранного подкаста (список подписок, не раскрытый список эпизодов). + pub fn remove_selected_podcast(&mut self) { + if let Some(feed) = self.podcasts.get(self.podcast_selected).cloned() { + let _ = self.podcast_store.remove(&feed.feed_url); + self.podcasts.retain(|p| p.feed_url != feed.feed_url); + let _ = self.podcast_cache.save(&self.podcasts); + self.podcast_selected = 0; + self.podcast_expanded = None; + self.status_message = format!("Unsubscribed from '{}'", feed.title); + } + } + + /// Играет выбранный эпизод стримингом напрямую по URL — как радио (используется тот же + /// внутренний механизм), без скачивания и без перемотки. Для полноценного прослушивания + /// с перемоткой сначала скачай эпизод (`d`). + /// Играет выбранный эпизод стримингом напрямую по URL — через обычную очередь (не через + /// radio-путь), поэтому перемотка реально работает, если сервер честно поддерживает + /// HTTP Range-запросы (это проверяется автоматически: если нет — плеер просто не будет + /// давать перематывать этот конкретный эпизод, без обмана). + pub fn play_podcast_episode_stream(&mut self) { + let Some(feed_idx) = self.podcast_expanded else { + return; + }; + let Some(feed) = self.podcasts.get(feed_idx) else { + return; + }; + let Some(ep) = feed.episodes.get(self.podcast_episode_selected) else { + return; + }; + self.current_station = None; + self.current_audiobook_id = None; + let item = QueueItem { + path: PathBuf::from(&ep.url), + start: Duration::ZERO, + end: None, + title_override: Some(ep.title.clone()), + artist_override: Some(feed.title.clone()), + replay_gain_db: None, + }; + self.current_queue = vec![item]; + if self.player.set_queue(self.current_queue.clone(), 0) { + self.status_message = format!("Streaming: {}", ep.title); + } else { + self.status_message = ENGINE_DEAD_MSG.to_string(); + } + } + + /// Скачивает выбранный эпизод на диск в фоне (не блокирует UI на время загрузки — эпизоды + /// бывают длинными, часами). Одно скачивание за раз, чтобы не путаться с прогрессом. + pub fn download_selected_episode(&mut self) { + let Some(feed_idx) = self.podcast_expanded else { + return; + }; + let Some(feed) = self.podcasts.get(feed_idx) else { + return; + }; + let Some(ep) = feed.episodes.get(self.podcast_episode_selected) else { + return; + }; + if self.podcast_downloading_url.is_some() { + self.status_message = "Already downloading an episode…".to_string(); + return; + } + + let ext = Path::new(&ep.url) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("mp3"); + let dir = library::default_podcasts_download_dir().join(library::podcast::sanitize_filename(&feed.title)); + let filename = format!("{}.{}", library::podcast::sanitize_filename(&ep.title), ext); + let dest = dir.join(filename); + + self.status_message = format!("Downloading '{}'…", ep.title); + self.podcast_downloading_url = Some(ep.url.clone()); + self.podcast_download_rx = Some(library::podcast::spawn_download_episode(ep.url.clone(), dest)); + } + + /// Опрашивает фоновое скачивание эпизода — вызывать каждый тик. + pub fn poll_podcast_download(&mut self) { + let Some(rx) = self.podcast_download_rx.as_ref() else { + return; + }; + match rx.try_recv() { + Ok(Ok(dest)) => { + self.status_message = format!("Downloaded to {}", dest.display()); + self.podcast_downloading_url = None; + self.podcast_download_rx = None; + // Добавляем скачанную папку в библиотеку сразу — эпизод становится обычным + // локальным треком с полноценной перемоткой, без дополнительных действий. + if let Some(parent) = dest.parent() { + let _ = self.library.add_root(parent); + self.rebuild_library_view(); + } + } + Ok(Err(e)) => { + self.status_message = format!("Download failed: {e}"); + self.podcast_downloading_url = None; + self.podcast_download_rx = None; + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.podcast_downloading_url = None; + self.podcast_download_rx = None; + } + } + } + + /// Добавляет папку в список отслеживаемых для аудиокниг и сразу пересканирует — + /// в фоновом потоке (сканирование читает теги/длительность каждого файла, может занять + /// заметное время на большой библиотеке, не должно морозить интерфейс). + pub fn add_audiobook_folder(&mut self, path: &str) { + let path = PathBuf::from(path.trim()); + if path.as_os_str().is_empty() { + return; + } + if !path.is_dir() { + self.status_message = format!("Not a directory: {}", path.display()); + return; + } + self.start_audiobook_scan(Some(path)); + } + + pub fn rescan_audiobooks(&mut self) { + self.start_audiobook_scan(None); + } + + fn start_audiobook_scan(&mut self, new_root: Option) { + if self.audiobook_scanning { + self.status_message = "Already scanning audiobooks…".to_string(); + return; + } + self.status_message = "Scanning audiobooks…".to_string(); + self.audiobook_scanning = true; + let store_path = library::default_audiobooks_path(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let store = library::audiobook::AudiobookStore::new(store_path); + let result = match new_root { + Some(root) => store.add_root_and_rescan(&root), + None => store.rescan_all(), + }; + let _ = tx.send(result); + }); + self.audiobook_scan_rx = Some(rx); + } + + /// Опрашивает фоновое (пере)сканирование аудиокниг — вызывать каждый тик. + pub fn poll_audiobook_scan(&mut self) { + let Some(rx) = self.audiobook_scan_rx.as_ref() else { + return; + }; + match rx.try_recv() { + Ok(Ok(lib)) => { + self.status_message = format!("Audiobooks: {} book(s)", lib.books.len()); + self.audiobooks = lib; + self.audiobook_scanning = false; + self.audiobook_scan_rx = None; + self.audiobook_selected = 0; + } + Ok(Err(e)) => { + self.status_message = format!("Audiobook scan failed: {e}"); + self.audiobook_scanning = false; + self.audiobook_scan_rx = None; + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.audiobook_scanning = false; + self.audiobook_scan_rx = None; + } + } + } + + /// Книги, проходящие текущий фильтр статуса и (если задан) поисковый запрос — то, что + /// реально показывается в списке. Пересчитывается на лету, не кэшируется — библиотек + /// аудиокниг обычно не настолько много, чтобы это было проблемой производительности. + pub fn filtered_audiobooks(&self) -> Vec<&library::audiobook::Audiobook> { + let query = self.audiobook_search_query.trim().to_lowercase(); + let mut books: Vec<&library::audiobook::Audiobook> = self + .audiobooks + .books + .iter() + .filter(|b| self.audiobook_filter.matches(b.status())) + .filter(|b| { + query.is_empty() + || b.title.to_lowercase().contains(&query) + || b.author.to_lowercase().contains(&query) + }) + .collect(); + books.sort_by(|a, b| a.author.cmp(&b.author).then(a.title.cmp(&b.title))); + books + } + + /// Начинает (или продолжает — с сохранённой позиции) проигрывание раскрытой книги, + /// с выбранного файла. + pub fn play_audiobook_from_selected_file(&mut self) { + let Some(book_id) = self.audiobook_expanded.clone() else { + return; + }; + let Some(book) = self.audiobooks.books.iter().find(|b| b.id == book_id) else { + return; + }; + if book.files.is_empty() { + return; + } + let start = self.audiobook_file_selected.min(book.files.len() - 1); + let queue: Vec = book + .files + .iter() + .map(|f| QueueItem::whole_file(f.path.clone())) + .collect(); + self.current_station = None; + self.current_queue = queue.clone(); + self.current_audiobook_id = Some(book_id); + self.current_audiobook_file_index = start; + if self.player.set_queue(queue, start) { + self.status_message = format!("Playing: {} — {}", book.author, book.title); + } else { + self.status_message = ENGINE_DEAD_MSG.to_string(); + } + } + + /// Продолжает книгу с сохранённой позиции (файл + смещение), не с начала — используется + /// при простом Enter на книге из списка (не раскрытой), самый частый случай использования. + pub fn resume_selected_audiobook(&mut self) { + let books = self.filtered_audiobooks(); + let Some(book) = books.get(self.audiobook_selected) else { + return; + }; + let book_id = book.id.clone(); + let file_index = book.position_file_index.min(book.files.len().saturating_sub(1)); + let offset = book.position_offset; + let title = book.title.clone(); + let author = book.author.clone(); + let files: Vec = book.files.iter().map(|f| f.path.clone()).collect(); + + if files.is_empty() { + return; + } + let queue: Vec = files + .iter() + .enumerate() + .map(|(i, p)| { + if i == file_index && offset > Duration::ZERO { + QueueItem { + path: p.clone(), + start: offset, + end: None, + title_override: None, + artist_override: None, + replay_gain_db: None, + } + } else { + QueueItem::whole_file(p.clone()) + } + }) + .collect(); + + self.current_station = None; + self.current_queue = queue.clone(); + self.current_audiobook_id = Some(book_id); + self.current_audiobook_file_index = file_index; + if self.player.set_queue(queue, file_index) { + self.status_message = format!("Resuming: {author} — {title}"); + } else { + self.status_message = ENGINE_DEAD_MSG.to_string(); + } + } + + fn save_audiobook_progress_now(&mut self) { + let Some(book_id) = self.current_audiobook_id.clone() else { + return; + }; + let offset = self.now_playing.as_ref().map(|np| np.position).unwrap_or_default(); + let _ = self + .audiobook_store + .save_progress(&book_id, self.current_audiobook_file_index, offset); + // Отражаем сразу и в собственном in-memory состоянии — иначе статус (New/Listening/ + // Finished) и прогресс-бар в списке обновятся только после следующего пересканирования. + if let Some(book) = self.audiobooks.books.iter_mut().find(|b| b.id == book_id) { + book.position_file_index = self.current_audiobook_file_index; + book.position_offset = offset; + } + self.last_audiobook_progress_save = std::time::Instant::now(); + } + + /// Начинает правку названия книги вручную (первый шаг — дальше правка автора). + pub fn start_edit_audiobook_metadata(&mut self) { + let books = self.filtered_audiobooks(); + let Some(book) = books.get(self.audiobook_selected) else { + return; + }; + let title = book.title.clone(); + let author = book.author.clone(); + self.audiobook_edit_title_input = title; + self.audiobook_edit_author_input = author; + self.focus = Focus::AudiobookEditTitle; + } + + pub fn apply_audiobook_metadata_edit(&mut self) { + let books = self.filtered_audiobooks(); + let Some(book_id) = books.get(self.audiobook_selected).map(|b| b.id.clone()) else { + return; + }; + let title = self.audiobook_edit_title_input.trim().to_string(); + let author = self.audiobook_edit_author_input.trim().to_string(); + if title.is_empty() || author.is_empty() { + self.status_message = "Title and author can't be empty".to_string(); + return; + } + if let Err(e) = self.audiobook_store.set_manual_metadata(&book_id, author.clone(), title.clone()) { + self.status_message = format!("Failed to save metadata: {e}"); + return; + } + if let Some(book) = self.audiobooks.books.iter_mut().find(|b| b.id == book_id) { + book.title = title; + book.author = author; + book.manual_metadata = true; + } + self.status_message = "Metadata updated".to_string(); + } + + /// Отмечает выбранную книгу для объединения (первое нажатие) либо объединяет её с ранее + /// отмеченной (второе нажатие на другой книге) — простой двухшаговый флоу без сложного + /// множественного выбора. + pub fn toggle_merge_selected_audiobook(&mut self) { + let books = self.filtered_audiobooks(); + let Some(book_id) = books.get(self.audiobook_selected).map(|b| b.id.clone()) else { + return; + }; + match self.audiobook_merge_pending.take() { + None => { + self.audiobook_merge_pending = Some(book_id.clone()); + self.status_message = "Marked for merge — select another book and press again".to_string(); + } + Some(first_id) if first_id == book_id => { + // отметили и тут же передумали на той же книге — просто снимаем отметку + self.status_message = "Merge cancelled".to_string(); + } + Some(first_id) => match self.audiobook_store.merge_books(&[first_id, book_id]) { + Ok(lib) => { + self.audiobooks = lib; + self.audiobook_selected = 0; + self.status_message = "Books merged".to_string(); + } + Err(e) => { + self.status_message = format!("Merge failed: {e}"); + } + }, + } + } + fn clear_smb_inputs(&mut self) { self.smb_server.clear(); self.smb_share.clear(); @@ -611,16 +1202,29 @@ impl App { position: Duration::ZERO, status_playing: true, radio_song: None, + codec: info.codec, + sample_rate: info.sample_rate, + channels: info.channels, + bits_per_sample: info.bits_per_sample, + file_size: info.file_size, + bitrate_kbps: info.bitrate_kbps, }); self.update_cover(info.cover); } QueueIndexChanged(idx) => { self.queue_selected = idx; + self.current_audiobook_file_index = idx; + self.save_audiobook_progress_now(); } Position(pos) => { if let Some(np) = self.now_playing.as_mut() { np.position = pos; } + if self.current_audiobook_id.is_some() + && self.last_audiobook_progress_save.elapsed() >= Duration::from_secs(5) + { + self.save_audiobook_progress_now(); + } } StatusChanged(status) => { if let Some(np) = self.now_playing.as_mut() { @@ -634,20 +1238,15 @@ impl App { MuteChanged(m) => self.muted = m, FadeOutChanged(v) => self.fade_out_enabled = v, LoudnessNormChanged(v) => self.loudness_norm_enabled = v, + BoostChanged(v) => self.boost_gain = v, + PitchChanged(v) => self.pitch = v, + SilenceSkipChanged(v) => self.silence_skip_enabled = v, RadioMetadata(text) => { if let Some(np) = self.now_playing.as_mut() { np.radio_song = Some(text); } } Spectrum(bands) => { - // Скользящее среднее по нескольким нижним полосам — та же "басовая энергия", - // что использует детектор удара в draw_noise_marines (см. там комментарий, - // почему усредняем несколько полос, а не берём одну). - let n = bands.len().min(7); - if n > 0 { - let bass_now = bands[..n].iter().sum::() / n as f32; - self.bass_ema = update_bass_ema(self.bass_ema, bass_now, 0.15); - } self.spectrum = bands; } QueueFinished => { @@ -695,6 +1294,7 @@ impl App { pub fn play_album_from_selected(&mut self) { self.current_station = None; + self.current_audiobook_id = None; if let LibraryView::Albums(rows) = &self.library_view { if let Some(row) = rows.get(self.library_selected) { self.current_queue = row.tracks.clone(); @@ -727,6 +1327,7 @@ impl App { /// то самое "просто включить всю библиотеку", без необходимости проваливаться в альбом. pub fn play_all_tracks_from_selected(&mut self) { self.current_station = None; + self.current_audiobook_id = None; if let LibraryView::Tracks(rows) = &self.library_view { if rows.is_empty() { return; @@ -743,6 +1344,7 @@ impl App { pub fn play_expanded_track(&mut self, track_idx: usize) { self.current_station = None; + self.current_audiobook_id = None; if let Some(album_idx) = self.expanded_album { if let LibraryView::Albums(rows) = &self.library_view { if let Some(row) = rows.get(album_idx) { @@ -803,6 +1405,7 @@ impl App { pub fn play_open_playlist_from(&mut self, idx: usize) { self.current_station = None; + self.current_audiobook_id = None; if let Some(pl) = &self.open_playlist { self.current_queue = pl .tracks @@ -1175,45 +1778,75 @@ impl App { pub fn toggle_fullscreen(&mut self) { self.fullscreen = !self.fullscreen; } + + /// Play/Pause с "умным откатом": при возобновлении после паузы автоматически + /// перематывает немного назад — тем больше, чем дольше длилась пауза. + pub fn toggle_play_pause_smart(&mut self) { + let currently_playing = self + .now_playing + .as_ref() + .map(|np| np.status_playing) + .unwrap_or(false); + + if currently_playing { + self.paused_at = Some(std::time::Instant::now()); + self.player.play_pause(); + return; + } + + let rewind = self + .paused_at + .take() + .map(|t| smart_rewind_amount(t.elapsed())) + .unwrap_or(Duration::ZERO); + self.player.play_pause(); + if rewind > Duration::ZERO { + if let Some(np) = self.now_playing.as_ref() { + let new_pos = np.position.saturating_sub(rewind); + self.player.seek(new_pos); + } + } + } } #[cfg(test)] -mod tests { +mod smart_rewind_tests { use super::*; - /// Симулирует несколько "тихих" отсчётов (устанавливая ema), затем один резкий - /// всплеск — детектор должен среагировать на сам всплеск. #[test] - fn detects_sudden_bass_hit_over_quiet_baseline() { - let mut ema = 0.0; - for _ in 0..20 { - ema = update_bass_ema(ema, 0.05, 0.15); // тихая "гитара" — база устаканивается низко + fn short_pause_no_rewind() { + assert_eq!(smart_rewind_amount(Duration::from_secs(3)), Duration::ZERO); + assert_eq!(smart_rewind_amount(Duration::from_secs(9)), Duration::ZERO); + } + + #[test] + fn medium_pause_small_rewind() { + assert_eq!(smart_rewind_amount(Duration::from_secs(10)), Duration::from_secs(3)); + assert_eq!(smart_rewind_amount(Duration::from_secs(45)), Duration::from_secs(3)); + } + + #[test] + fn long_pause_bigger_rewind() { + assert_eq!(smart_rewind_amount(Duration::from_secs(60)), Duration::from_secs(10)); + assert_eq!(smart_rewind_amount(Duration::from_secs(300)), Duration::from_secs(10)); + } + + #[test] + fn very_long_pause_max_rewind() { + assert_eq!(smart_rewind_amount(Duration::from_secs(600)), Duration::from_secs(30)); + assert_eq!(smart_rewind_amount(Duration::from_secs(3600)), Duration::from_secs(30)); + } + + #[test] + fn rewind_is_monotonically_non_decreasing_with_pause_length() { + // Чем дольше пауза, тем откат не должен уменьшаться — иначе получилось бы странно: + // отошёл на 11 минут — откат меньше, чем если бы отошёл на 9. + let samples = [0, 5, 9, 10, 30, 59, 60, 300, 599, 600, 3600]; + let mut prev = Duration::ZERO; + for s in samples { + let r = smart_rewind_amount(Duration::from_secs(s)); + assert!(r >= prev, "rewind decreased at {s}s: {r:?} < {prev:?}"); + prev = r; } - assert!(!is_bass_hit(0.05, ema), "ровный тихий уровень не должен считаться ударом"); - - // резкий удар баса поверх этой тихой базы - assert!(is_bass_hit(0.4, ema), "резкий всплеск над тихой базой должен сработать"); } - - /// Трек, где бас стабильно громкий (тяжёлый метал) — после того, как ema устаканится - /// на этом уровне, ровный громкий бас НЕ должен постоянно триггерить "удар" на каждом - /// кадре — иначе десантники будут просто дёргаться без остановки, а не танцевать в такт. - #[test] - fn steady_loud_bass_does_not_trigger_constantly_once_settled() { - let mut ema = 0.0; - for _ in 0..50 { - ema = update_bass_ema(ema, 0.5, 0.15); - } - assert!( - !is_bass_hit(0.5, ema), - "устаканившийся ровный громкий бас не должен считаться 'ударом' каждый кадр" - ); - } - - #[test] - fn ema_moves_toward_target_but_not_instantly() { - let ema = update_bass_ema(0.0, 1.0, 0.15); - assert!(ema > 0.0 && ema < 1.0, "ema={ema}"); - assert!((ema - 0.15).abs() < 0.001); - } -} +} \ No newline at end of file diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 7bb03a6..a5b9d31 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -81,6 +81,9 @@ fn event_loop( app.poll_new_releases(); app.poll_station_search(); app.poll_smb_connect(); + app.poll_podcast_fetch(); + app.poll_podcast_download(); + app.poll_audiobook_scan(); last_tick = Instant::now(); } @@ -91,6 +94,20 @@ fn event_loop( } fn handle_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) { + if app.show_help { + // Пока справка открыта — любая клавиша её закрывает (кроме собственно '?', на + // случай если кто-то жмёт её повторно, чтобы полистать — но она делает то же самое). + app.show_help = false; + return; + } + // Перехватываем '?' только в обычном режиме навигации — иначе сломали бы ввод + // буквального "?" в текстовых полях (поиск, URL, пути и т.п.). + if app.focus == Focus::Normal && !app.fullscreen { + if let KeyCode::Char('?') = code { + app.show_help = true; + return; + } + } if app.fullscreen { handle_fullscreen_key(app, code); return; @@ -108,6 +125,11 @@ fn handle_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) { Focus::SmbPassword => handle_smb_password_key(app, code), Focus::LibraryPathInput => handle_library_path_input_key(app, code), Focus::NewReleaseArtistInput => handle_new_release_artist_input_key(app, code), + Focus::PodcastUrlInput => handle_podcast_url_input_key(app, code), + Focus::AudiobookPathInput => handle_audiobook_path_input_key(app, code), + Focus::AudiobookSearch => handle_audiobook_search_key(app, code), + Focus::AudiobookEditTitle => handle_audiobook_edit_title_key(app, code), + Focus::AudiobookEditAuthor => handle_audiobook_edit_author_key(app, code), Focus::Eq => handle_eq_key(app, code), Focus::Normal => handle_normal_key(app, code, modifiers), } @@ -120,10 +142,13 @@ fn handle_fullscreen_key(app: &mut App, code: KeyCode) { }; match code { KeyCode::Esc | KeyCode::Char('z') => app.toggle_fullscreen(), + KeyCode::Char('?') => { + app.show_help = true; + } KeyCode::Char('v') => app.toggle_right_panel_view(), KeyCode::Char('q') => app.should_quit = true, KeyCode::Char(' ') => { - app.player.play_pause(); + app.toggle_play_pause_smart(); } KeyCode::Char('m') => { app.player.toggle_mute(); @@ -329,6 +354,192 @@ fn handle_new_release_artist_input_key(app: &mut App, code: KeyCode) { } } +fn handle_podcast_url_input_key(app: &mut App, code: KeyCode) { + match code { + KeyCode::Esc => { + app.new_podcast_url.clear(); + app.focus = Focus::Normal; + } + KeyCode::Enter => { + let url = app.new_podcast_url.clone(); + app.add_podcast(&url); + app.new_podcast_url.clear(); + app.focus = Focus::Normal; + } + KeyCode::Backspace => { + app.new_podcast_url.pop(); + } + KeyCode::Char(c) => { + app.new_podcast_url.push(c); + } + _ => {} + } +} + +fn handle_audiobook_path_input_key(app: &mut App, code: KeyCode) { + match code { + KeyCode::Esc => { + app.new_audiobook_path_input.clear(); + app.focus = Focus::Normal; + } + KeyCode::Enter => { + let path = app.new_audiobook_path_input.clone(); + app.add_audiobook_folder(&path); + app.new_audiobook_path_input.clear(); + app.focus = Focus::Normal; + } + KeyCode::Backspace => { + app.new_audiobook_path_input.pop(); + } + KeyCode::Char(c) => { + app.new_audiobook_path_input.push(c); + } + _ => {} + } +} + +fn handle_audiobook_search_key(app: &mut App, code: KeyCode) { + match code { + KeyCode::Esc => { + app.audiobook_search_query.clear(); + app.audiobook_selected = 0; + app.focus = Focus::Normal; + } + KeyCode::Enter => { + app.audiobook_selected = 0; + app.focus = Focus::Normal; + } + KeyCode::Backspace => { + app.audiobook_search_query.pop(); + app.audiobook_selected = 0; + } + KeyCode::Char(c) => { + app.audiobook_search_query.push(c); + app.audiobook_selected = 0; + } + _ => {} + } +} + +fn handle_audiobook_edit_title_key(app: &mut App, code: KeyCode) { + match code { + KeyCode::Esc => { + app.focus = Focus::Normal; + } + KeyCode::Enter => { + app.focus = Focus::AudiobookEditAuthor; + } + KeyCode::Backspace => { + app.audiobook_edit_title_input.pop(); + } + KeyCode::Char(c) => { + app.audiobook_edit_title_input.push(c); + } + _ => {} + } +} + +fn handle_audiobook_edit_author_key(app: &mut App, code: KeyCode) { + match code { + KeyCode::Esc => { + app.focus = Focus::Normal; + } + KeyCode::Enter => { + app.apply_audiobook_metadata_edit(); + app.focus = Focus::Normal; + } + KeyCode::Backspace => { + app.audiobook_edit_author_input.pop(); + } + KeyCode::Char(c) => { + app.audiobook_edit_author_input.push(c); + } + _ => {} + } +} + +fn handle_audiobooks_key(app: &mut App, code: KeyCode) { + if app.audiobook_expanded.is_some() { + handle_audiobook_files_key(app, code); + return; + } + match code { + KeyCode::Char('/') => { + app.focus = Focus::AudiobookSearch; + } + KeyCode::Char('L') => { + app.focus = Focus::AudiobookPathInput; + } + KeyCode::Char('f') => { + app.rescan_audiobooks(); + } + KeyCode::Char('t') => { + app.start_edit_audiobook_metadata(); + } + KeyCode::Char('x') => { + app.toggle_merge_selected_audiobook(); + } + KeyCode::Char('p') => { + app.audiobook_filter = app.audiobook_filter.next(); + app.audiobook_selected = 0; + } + KeyCode::Down | KeyCode::Char('j') => { + let len = app.filtered_audiobooks().len(); + if len > 0 { + app.audiobook_selected = (app.audiobook_selected + 1) % len; + } + } + KeyCode::Up | KeyCode::Char('k') => { + let len = app.filtered_audiobooks().len(); + if len > 0 { + app.audiobook_selected = (app.audiobook_selected + len - 1) % len; + } + } + KeyCode::Char('o') => { + let books = app.filtered_audiobooks(); + if let Some(book) = books.get(app.audiobook_selected) { + let id = book.id.clone(); + let start_file = book.position_file_index.min(book.files.len().saturating_sub(1)); + app.audiobook_expanded = Some(id); + app.audiobook_file_selected = start_file; + } + } + KeyCode::Enter => { + app.resume_selected_audiobook(); + } + _ => {} + } +} + +fn handle_audiobook_files_key(app: &mut App, code: KeyCode) { + let file_count = app + .audiobook_expanded + .as_ref() + .and_then(|id| app.audiobooks.books.iter().find(|b| &b.id == id)) + .map(|b| b.files.len()) + .unwrap_or(0); + match code { + KeyCode::Esc => { + app.audiobook_expanded = None; + app.audiobook_file_selected = 0; + } + KeyCode::Down | KeyCode::Char('j') => { + if file_count > 0 { + app.audiobook_file_selected = (app.audiobook_file_selected + 1) % file_count; + } + } + KeyCode::Up | KeyCode::Char('k') => { + if file_count > 0 { + app.audiobook_file_selected = (app.audiobook_file_selected + file_count - 1) % file_count; + } + } + KeyCode::Enter => { + app.play_audiobook_from_selected_file(); + } + _ => {} + } +} + fn handle_smb_password_key(app: &mut App, code: KeyCode) { match code { KeyCode::Esc => { @@ -395,7 +606,7 @@ fn handle_normal_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) { return; } KeyCode::Char(' ') => { - app.player.play_pause(); + app.toggle_play_pause_smart(); return; } KeyCode::Char('n') if modifiers.contains(KeyModifiers::CONTROL) => { @@ -429,6 +640,30 @@ fn handle_normal_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) { app.player.set_speed(app.speed); return; } + KeyCode::Char('}') => { + app.pitch = (app.pitch + 0.05).min(2.0); + app.player.set_pitch(app.pitch); + return; + } + KeyCode::Char('{') => { + app.pitch = (app.pitch - 0.05).max(0.5); + app.player.set_pitch(app.pitch); + return; + } + KeyCode::Char('B') => { + app.boost_gain = (app.boost_gain + 0.1).min(3.0); + app.player.set_boost(app.boost_gain); + return; + } + KeyCode::Char('b') => { + app.boost_gain = (app.boost_gain - 0.1).max(1.0); + app.player.set_boost(app.boost_gain); + return; + } + KeyCode::Char('S') => { + app.player.toggle_silence_skip(); + return; + } KeyCode::Char('e') => { app.focus = Focus::Eq; return; @@ -477,11 +712,84 @@ fn handle_normal_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) { Tab::Playlists => handle_playlists_key(app, code), Tab::Queue => handle_queue_key(app, code), Tab::Radio => handle_radio_key(app, code), + Tab::Podcasts => handle_podcasts_key(app, code), + Tab::Audiobooks => handle_audiobooks_key(app, code), Tab::Stats => handle_stats_key(app, code), Tab::NewReleases => handle_new_releases_key(app, code), } } +fn handle_podcasts_key(app: &mut App, code: KeyCode) { + if app.podcast_expanded.is_some() { + handle_podcast_episodes_key(app, code); + return; + } + match code { + KeyCode::Char('u') => { + app.focus = Focus::PodcastUrlInput; + } + KeyCode::Char('f') => { + app.refresh_selected_podcast(); + } + KeyCode::Char('d') => { + app.remove_selected_podcast(); + } + KeyCode::Down | KeyCode::Char('j') => { + if !app.podcasts.is_empty() { + app.podcast_selected = (app.podcast_selected + 1) % app.podcasts.len(); + } + } + KeyCode::Up | KeyCode::Char('k') => { + if !app.podcasts.is_empty() { + let len = app.podcasts.len(); + app.podcast_selected = (app.podcast_selected + len - 1) % len; + } + } + KeyCode::Enter => { + if !app.podcasts.is_empty() { + app.podcast_expanded = Some(app.podcast_selected); + app.podcast_episode_selected = 0; + } + } + _ => {} + } +} + +fn handle_podcast_episodes_key(app: &mut App, code: KeyCode) { + let episode_count = app + .podcast_expanded + .and_then(|i| app.podcasts.get(i)) + .map(|f| f.episodes.len()) + .unwrap_or(0); + match code { + KeyCode::Esc => { + app.podcast_expanded = None; + app.podcast_episode_selected = 0; + } + KeyCode::Char('f') => { + app.refresh_selected_podcast(); + } + KeyCode::Char('d') => { + app.download_selected_episode(); + } + KeyCode::Down | KeyCode::Char('j') => { + if episode_count > 0 { + app.podcast_episode_selected = (app.podcast_episode_selected + 1) % episode_count; + } + } + KeyCode::Up | KeyCode::Char('k') => { + if episode_count > 0 { + app.podcast_episode_selected = + (app.podcast_episode_selected + episode_count - 1) % episode_count; + } + } + KeyCode::Enter => { + app.play_podcast_episode_stream(); + } + _ => {} + } +} + fn handle_library_key(app: &mut App, code: KeyCode) { match code { KeyCode::Char('/') => { @@ -702,4 +1010,4 @@ fn handle_online_results_key(app: &mut App, code: KeyCode) { } _ => {} } -} +} \ No newline at end of file diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 5197ba1..414260b 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -42,6 +42,8 @@ pub fn draw(frame: &mut Frame, app: &mut App) { Tab::Playlists => draw_playlists(frame, app, body[0]), Tab::Queue => draw_queue(frame, app, body[0]), Tab::Radio => draw_radio(frame, app, body[0]), + Tab::Podcasts => draw_podcasts(frame, app, body[0]), + Tab::Audiobooks => draw_audiobooks(frame, app, body[0]), Tab::Stats => draw_stats(frame, app, body[0]), Tab::NewReleases => draw_new_releases(frame, app, body[0]), } @@ -52,6 +54,9 @@ pub fn draw(frame: &mut Frame, app: &mut App) { if app.focus == Focus::FirstRunPrompt { draw_first_run_prompt(frame); } + if app.show_help { + draw_help_popup(frame, app); + } } /// Небольшой прямоугольник по центру экрана заданного размера (в колонках/строках). @@ -92,6 +97,95 @@ fn draw_first_run_prompt(frame: &mut Frame) { ); } +/// Контекстная справка по '?' — подсказки клавиш переехали сюда из заголовков панелей, +/// где они не помещались при разумном масштабе терминала (обрезались посередине слова). +fn draw_help_popup(frame: &mut Frame, app: &App) { + let area = centered_rect(78, 22, frame.area()); + frame.render_widget(ratatui::widgets::Clear, area); + + let block = Block::default() + .borders(Borders::ALL) + .title(" Help — press any key to close ") + .style(Style::default().bg(Color::Black)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let bold = |s: &str| Line::from(Span::styled(s.to_string(), Style::default().add_modifier(Modifier::BOLD))); + + let mut lines = vec![ + bold("Global (any tab)"), + Line::from("Space play/pause (smart rewind) N/P next/prev Tab next tab"), + Line::from("s shuffle r repeat m mute ←/→ seek ±5s q quit"), + Line::from("[/] speed {/} pitch b/B boost S silence-skip"), + Line::from("e EQ focus v/z cover/spectrum, z again = fullscreen h history log"), + Line::from("F fade-out G loudness norm I cover protocol"), + Line::from(""), + ]; + + lines.push(bold(&format!("{} tab", app.tab.title()))); + match app.tab { + Tab::Library => { + if app.expanded_album.is_some() { + lines.push(Line::from("Enter play from here Esc back to albums")); + } else if !app.search_query.is_empty() { + lines.push(Line::from("Enter play Esc clear search & show all")); + } else { + match app.library_mode { + LibraryMode::Albums => lines.push(Line::from( + "Enter expand/play t switch to all-tracks view", + )), + LibraryMode::Tracks => lines.push(Line::from( + "Enter play all from here t switch to albums view", + )), + } + lines.push(Line::from( + "/ search a add to playlist c connect SMB share L add folder C clear library", + )); + } + } + Tab::Playlists => { + lines.push(Line::from("Enter open playlist / play track n new playlist")); + lines.push(Line::from("J/K move track up/down d delete Esc back")); + } + Tab::Queue => { + lines.push(Line::from("Enter jump to track d remove from queue")); + } + Tab::Radio => { + lines.push(Line::from("Enter play station o search stations online")); + lines.push(Line::from("u add station by URL d remove (only your own)")); + } + Tab::Podcasts => { + if app.podcast_expanded.is_some() { + lines.push(Line::from( + "Enter stream (seekable if server supports Range) d download", + )); + lines.push(Line::from("f refresh feed Esc back to subscriptions")); + } else { + lines.push(Line::from("Enter open podcast u subscribe by RSS URL")); + lines.push(Line::from("f refresh d unsubscribe")); + } + } + Tab::Audiobooks => { + if app.audiobook_expanded.is_some() { + lines.push(Line::from("Enter play from selected file/chapter Esc back")); + } else { + lines.push(Line::from("Enter resume from saved position o browse files/chapters")); + lines.push(Line::from("/ search p cycle status filter L add folder f rescan")); + lines.push(Line::from("t edit title/author x mark/merge with another book")); + } + } + Tab::Stats => { + lines.push(Line::from("(no extra actions — just browse the listening history)")); + } + Tab::NewReleases => { + lines.push(Line::from("f check for new releases a check one artist manually")); + } + } + + frame.render_widget(Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: false }), inner); +} + + fn draw_fullscreen(frame: &mut Frame, app: &mut App) { let root = Layout::default() .direction(Direction::Vertical) @@ -126,10 +220,12 @@ fn draw_fullscreen(frame: &mut Frame, app: &mut App) { .as_ref() .map(|np| format!("{} — {}", np.artist, np.title)) .unwrap_or_else(|| "Nothing playing".to_string()); - let hint = format!( - "{now_playing_line} (v: switch, z/Esc: exit fullscreen, Space: play/pause, N/P: next/prev, ←/→: seek)" - ); + let hint = format!("{now_playing_line} (? for help)"); frame.render_widget(Paragraph::new(hint), root[1]); + + if app.show_help { + draw_help_popup(frame, app); + } } fn draw_too_small(frame: &mut Frame, area: Rect, min_width: u16, min_height: u16) { @@ -149,6 +245,8 @@ fn draw_tabs(frame: &mut Frame, app: &App, area: Rect) { Tab::Playlists, Tab::Queue, Tab::Radio, + Tab::Podcasts, + Tab::Audiobooks, Tab::Stats, Tab::NewReleases, ] @@ -160,8 +258,10 @@ fn draw_tabs(frame: &mut Frame, app: &App, area: Rect) { Tab::Playlists => 1, Tab::Queue => 2, Tab::Radio => 3, - Tab::Stats => 4, - Tab::NewReleases => 5, + Tab::Podcasts => 4, + Tab::Audiobooks => 5, + Tab::Stats => 6, + Tab::NewReleases => 7, }; let tabs = Tabs::new(titles) .block(Block::default().borders(Borders::ALL).title(" nsm ")) @@ -231,17 +331,13 @@ fn draw_library(frame: &mut Frame, app: &App, area: Rect) { }; let title = if app.expanded_album.is_some() { - " Album (Enter: play from here, Esc: back) " + " Album (? for help) " } else if !app.search_query.is_empty() { - " Search results (Enter: play, Esc: clear search & show all) " + " Search results (? for help) " } else { match app.library_mode { - LibraryMode::Albums => { - " Albums (Enter: expand/play, /: search, a: add to playlist, t: all tracks, c: connect SMB, L: add folder, C: clear library) " - } - LibraryMode::Tracks => { - " All Tracks (Enter: play all from here, /: search, a: add to playlist, t: by album) " - } + LibraryMode::Albums => " Albums (? for help) ", + LibraryMode::Tracks => " All Tracks (? for help) ", } }; @@ -268,7 +364,7 @@ fn draw_playlists(frame: &mut Frame, app: &App, area: Rect) { let list = List::new(items) .block( Block::default().borders(Borders::ALL).title(format!( - " {} (Enter: play, J/K: move, d: delete, Esc: back) ", + " {} (? for help) ", pl.name )), ) @@ -287,7 +383,7 @@ fn draw_playlists(frame: &mut Frame, app: &App, area: Rect) { .block( Block::default() .borders(Borders::ALL) - .title(" Playlists (Enter: open, n: new) "), + .title(" Playlists (? for help) "), ) .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) .highlight_symbol("> "); @@ -481,7 +577,7 @@ fn draw_radio(frame: &mut Frame, app: &App, area: Rect) { .collect(); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title( - " Search results (Enter: play, a: add to my stations, Esc: back) ", + " Search results (? for help) ", )) .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) .highlight_symbol("> "); @@ -502,7 +598,7 @@ fn draw_radio(frame: &mut Frame, app: &App, area: Rect) { .collect(); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title( - " Radio (Enter: play, o: search online, u: add by URL, d: remove custom) ", + " Radio (? for help) ", )) .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) .highlight_symbol("> "); @@ -511,6 +607,174 @@ fn draw_radio(frame: &mut Frame, app: &App, area: Rect) { frame.render_stateful_widget(list, area, &mut state); } +fn fmt_duration_long(d: std::time::Duration) -> String { + let secs = d.as_secs(); + let h = secs / 3600; + let m = (secs % 3600) / 60; + let s = secs % 60; + if h > 0 { + format!("{h}:{m:02}:{s:02}") + } else { + format!("{m}:{s:02}") + } +} + +fn draw_audiobooks(frame: &mut Frame, app: &App, area: Rect) { + if app.audiobook_scanning { + let msg = Paragraph::new("Scanning audiobooks (doesn't block the UI)…") + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL).title(" Audiobooks — scanning… ")); + frame.render_widget(msg, area); + return; + } + + if let Some(book_id) = &app.audiobook_expanded { + let Some(book) = app.audiobooks.books.iter().find(|b| &b.id == book_id) else { + return; + }; + if book.files.is_empty() { + let p = Paragraph::new("No files in this book") + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL).title(format!(" {} — {} ", book.author, book.title))); + frame.render_widget(p, area); + return; + } + let items: Vec = book + .files + .iter() + .enumerate() + .map(|(i, f)| { + let playing = app.current_audiobook_id.as_deref() == Some(book_id.as_str()) + && app.now_playing.is_some() + && i == app.queue_selected; + let marker = if playing { "▶ " } else { " " }; + let name = f.path.file_name().and_then(|n| n.to_str()).unwrap_or("?"); + let dur = f.duration.map(fmt_duration_long).unwrap_or_else(|| "?".to_string()); + let chapters = if f.chapters.len() > 1 { + format!(" [{} chapters]", f.chapters.len()) + } else { + String::new() + }; + ListItem::new(format!("{marker}{name} ({dur}){chapters}")) + }) + .collect(); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(format!( + " {} — {} (? for help) ", + book.author, book.title + ))) + .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) + .highlight_symbol("> "); + let mut state = ratatui::widgets::ListState::default(); + state.select(Some(app.audiobook_file_selected)); + frame.render_stateful_widget(list, area, &mut state); + return; + } + + let books = app.filtered_audiobooks(); + let title = format!( + " Audiobooks — {} ({}) (? for help) ", + app.audiobook_filter.label(), + books.len() + ); + if books.is_empty() { + let msg = if app.audiobooks.watched_roots.is_empty() { + "No audiobook folders yet — press 'L' to add one" + } else { + "No books match this filter" + }; + let p = Paragraph::new(msg) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL).title(title)); + frame.render_widget(p, area); + return; + } + + let items: Vec = books + .iter() + .map(|b| { + let pct = (b.progress_ratio() * 100.0).round() as u32; + let marked = app.audiobook_merge_pending.as_deref() == Some(b.id.as_str()); + let marker = if marked { "* " } else { " " }; + ListItem::new(format!("{marker}{} — {} [{pct}%]", b.author, b.title)) + }) + .collect(); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(title)) + .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) + .highlight_symbol("> "); + let mut state = ratatui::widgets::ListState::default(); + state.select(Some(app.audiobook_selected)); + frame.render_stateful_widget(list, area, &mut state); +} + +fn draw_podcasts(frame: &mut Frame, app: &App, area: Rect) { + if let Some(feed_idx) = app.podcast_expanded { + let Some(feed) = app.podcasts.get(feed_idx) else { + return; + }; + if feed.episodes.is_empty() { + let msg = if app.podcast_fetching { + "Fetching episodes…".to_string() + } else { + "No episodes yet — press 'f' to fetch this feed".to_string() + }; + let p = Paragraph::new(msg).alignment(Alignment::Center).block( + Block::default() + .borders(Borders::ALL) + .title(format!(" {} (? for help) ", feed.title)), + ); + frame.render_widget(p, area); + return; + } + let items: Vec = feed + .episodes + .iter() + .map(|ep| { + let downloading = app.podcast_downloading_url.as_deref() == Some(ep.url.as_str()); + let marker = if downloading { "⬇ " } else { " " }; + let date = ep.pub_date.as_deref().unwrap_or(""); + ListItem::new(format!("{marker}{} {}", ep.title, date)) + }) + .collect(); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(format!( + " {} (? for help) ", + feed.title + ))) + .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) + .highlight_symbol("> "); + let mut state = ratatui::widgets::ListState::default(); + state.select(Some(app.podcast_episode_selected)); + frame.render_stateful_widget(list, area, &mut state); + return; + } + + if app.podcasts.is_empty() { + let msg = "No podcasts yet — press 'u' to subscribe by RSS feed URL"; + let p = Paragraph::new(msg) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL).title(" Podcasts ")); + frame.render_widget(p, area); + return; + } + + let items: Vec = app + .podcasts + .iter() + .map(|feed| ListItem::new(format!("{} ({} episodes)", feed.title, feed.episodes.len()))) + .collect(); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title( + " Podcasts (? for help) ", + )) + .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) + .highlight_symbol("> "); + let mut state = ratatui::widgets::ListState::default(); + state.select(Some(app.podcast_selected)); + frame.render_stateful_widget(list, area, &mut state); +} + /// Компактный рендер спектра для маленькой панели (внутри блока Cover/Spectrum) — /// простые вертикальные бары символами блоков, без BarChart (там подписи не влезли бы /// в такую узкую область). @@ -581,7 +845,7 @@ fn draw_now_playing(frame: &mut Frame, app: &mut App, area: Rect) { draw_track_info(frame, app, top[1]); draw_transport(frame, app, chunks[1]); draw_eq(frame, app, chunks[2]); - draw_noise_marines(frame, app, chunks[3]); + draw_technical_info(frame, app, chunks[3]); } fn draw_track_info(frame: &mut Frame, app: &App, area: Rect) { @@ -633,8 +897,8 @@ fn draw_transport(frame: &mut Frame, app: &App, area: Rect) { player::RepeatMode::Track => "Track", player::RepeatMode::All => "All", }; - let line = format!( - "v/z:view {:.2}x Shuf:{} Rep:{} Mute:{} Fade:{} Norm:{} ←→:seek", + let mut line = format!( + "v/z:view {:.2}x Shuf:{} Rep:{} Mute:{} Fade:{} Norm:{}", app.speed.0, if app.shuffle { "On" } else { "Off" }, repeat_label, @@ -642,81 +906,95 @@ fn draw_transport(frame: &mut Frame, app: &App, area: Rect) { if app.fade_out_enabled { "On" } else { "Off" }, if app.loudness_norm_enabled { "On" } else { "Off" } ); + // Буст/питч/пропуск тишины показываем только когда они реально включены/сдвинуты — + // иначе строка транспорта разрослась бы ещё сильнее для всех, кому эти фичи не нужны. + if (app.boost_gain - 1.0).abs() > 0.001 { + line.push_str(&format!(" Boost:{:.0}%", app.boost_gain * 100.0)); + } + if (app.pitch - 1.0).abs() > 0.001 { + line.push_str(&format!(" Pitch:{:.0}%", app.pitch * 100.0)); + } + if app.silence_skip_enabled { + line.push_str(" SilenceSkip:On"); + } + line.push_str(" ←→:seek"); frame.render_widget(Paragraph::new(line), area); } -/// Пасхалка: Шумовые Десантники (Noise Marines) из Warhammer 40000 — культовая "музыкальная" -/// фракция Хаоса, поклоняющаяся Слаанешу через звук и звуковое оружие (Doom Siren/Blastmaster). -/// Поза синхронизирована с басом ТЕКУЩЕГО трека через уже существующий спектроанализатор — -/// буквально пляшут под музыку, а не просто крутят произвольную анимацию. -fn draw_noise_marines(frame: &mut Frame, app: &App, area: Rect) { - let block = Block::default() - .borders(Borders::ALL) - .title(" Noise Marines "); +/// Техническая информация о текущем треке: кодек, частота дискретизации, каналы, битность, +/// битрейт (посчитанный из размера файла и длительности — честно для VBR), размер файла. +fn draw_technical_info(frame: &mut Frame, app: &App, area: Rect) { + let block = Block::default().borders(Borders::ALL).title(" Track Info "); let inner = block.inner(area); frame.render_widget(block, area); - // Басовая энергия усредняется по нескольким нижним полосам спектра, а не берётся из - // одной-единственной (полоса 0 при размере FFT-окна 1024 — это всего один узкий бин - // ~43Гц, слишком шумно и не отражает реальный бас трека). "Удар баса" детектируется - // относительно недавнего среднего (bass_ema), а не фиксированным порогом — так реакция - // подстраивается под конкретный трек, а не пробивается либо всегда, либо никогда. - let n = app.spectrum.len().min(7); - let bass_now = if n > 0 { - app.spectrum[..n].iter().sum::() / n as f32 - } else { - 0.0 + let Some(np) = app.now_playing.as_ref() else { + let p = Paragraph::new("Nothing playing").alignment(Alignment::Center); + frame.render_widget(p, inner); + return; }; - let pose_a = crate::app::is_bass_hit(bass_now, app.bass_ema); - // Тело и рог/сирена — в одной строке за раз, как в изначальной, подтверждённо рабочей - // версии (была проверена вживую на реальном терминале). Отдельные "гитарные" аксессуары - // с другими символами не прижились — рассинхронизировали ширину колонок в разных - // шрифтах/терминалах, поэтому вернулись к простому и надёжному. - const FRAME_A: [&str; 9] = [ - " ▄██▄ ", - " ◢█◉◉█◣ ", - " █▀▀▀▀█ ", - "▟████████▙ ╭─◯ ))) ", - " █▓▓▓▓█ ╱ ", - " █▓▓▓▓█ ╱ ", - " ▐▓▓▓▓▌ ╱ ", - " █ █ ", - " ▟█▙▟█▙ ", + fn format_size(bytes: u64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + const GB: f64 = MB * 1024.0; + let b = bytes as f64; + if b >= GB { + format!("{:.2} GB", b / GB) + } else if b >= MB { + format!("{:.2} MB", b / MB) + } else if b >= KB { + format!("{:.1} KB", b / KB) + } else { + format!("{bytes} B") + } + } + + fn channels_label(n: u16) -> String { + match n { + 1 => "1 (mono)".to_string(), + 2 => "2 (stereo)".to_string(), + n => format!("{n} (multichannel)"), + } + } + + let mut lines = vec![ + Line::from(format!( + "Codec: {}", + np.codec.as_deref().unwrap_or("unknown") + )), + Line::from(format!( + "Sample rate: {}", + np.sample_rate.map(|r| format!("{r} Hz")).unwrap_or_else(|| "unknown".to_string()) + )), + Line::from(format!( + "Channels: {}", + np.channels.map(channels_label).unwrap_or_else(|| "unknown".to_string()) + )), ]; - const FRAME_B: [&str; 9] = [ - " ▄██▄ ", - " ◢█◉◉█◣ ", - " █▀▀▀▀█ ", - "▟████████▙ ", - " █▓▓▓▓█ ╲ ", - " █▓▓▓▓█ ╲ ", - " ▐▓▓▓▓▌╭─◯ ))) ", - " ▟█▙ █ ", - " █ ▟█▙ ", - ]; - let sprite = if pose_a { &FRAME_A } else { &FRAME_B }; - - const MARINE_WIDTH: usize = 24; - let count = ((inner.width as usize) / MARINE_WIDTH).clamp(1, 4); - - let lines: Vec = sprite - .iter() - .map(|row| { - let mut s = String::new(); - for _ in 0..count { - let visible_len = row.chars().count(); - s.push_str(row); - s.push_str(&" ".repeat(MARINE_WIDTH.saturating_sub(visible_len))); - } - Line::from(s) - }) - .collect(); + if let Some(bits) = np.bits_per_sample { + lines.push(Line::from(format!("Bit depth: {bits}-bit"))); + } + lines.push(Line::from(format!( + "Bitrate: {}", + np.bitrate_kbps + .map(|b| format!("{b} kbps (avg)")) + .unwrap_or_else(|| "unknown (streaming)".to_string()) + ))); + lines.push(Line::from(format!( + "File size: {}", + np.file_size.map(format_size).unwrap_or_else(|| "n/a (streaming)".to_string()) + ))); + lines.push(Line::from("")); + lines.push(Line::from(format!( + "Duration: {}", + np.duration + .map(|d| format!("{}:{:02}", d.as_secs() / 60, d.as_secs() % 60)) + .unwrap_or_else(|| "unknown".to_string()) + ))); frame.render_widget( - Paragraph::new(lines) - .style(Style::default().fg(Color::Magenta)) - .alignment(Alignment::Center), + Paragraph::new(lines).style(Style::default().fg(Color::Gray)), inner, ); } @@ -841,6 +1119,26 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { "CHECK ARTIST: {} (Enter: check, Esc: cancel)", app.new_release_artist_input ), + Focus::PodcastUrlInput => format!( + "SUBSCRIBE TO PODCAST (RSS URL): {} (Enter: subscribe, Esc: cancel)", + app.new_podcast_url + ), + Focus::AudiobookPathInput => format!( + "ADD AUDIOBOOK FOLDER: {} (Enter: scan, Esc: cancel)", + app.new_audiobook_path_input + ), + Focus::AudiobookSearch => format!( + "SEARCH AUDIOBOOKS: {} (Enter: apply, Esc: clear)", + app.audiobook_search_query + ), + Focus::AudiobookEditTitle => format!( + "EDIT TITLE: {} (Enter: next — author, Esc: cancel)", + app.audiobook_edit_title_input + ), + Focus::AudiobookEditAuthor => format!( + "EDIT AUTHOR: {} (Enter: save, Esc: cancel)", + app.audiobook_edit_author_input + ), _ => app.status_message.clone(), }; frame.render_widget(Paragraph::new(text), area); @@ -849,4 +1147,4 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { fn fmt_time(d: std::time::Duration) -> String { let secs = d.as_secs(); format!("{:02}:{:02}", secs / 60, secs % 60) -} +} \ No newline at end of file