Added audiobooks and podcasts

This commit is contained in:
Magnus Root 2026-08-17 15:18:43 +03:00
parent 3578f3b08d
commit 4fb1ea911f
21 changed files with 3745 additions and 246 deletions

55
Cargo.lock generated
View file

@ -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"

View file

@ -4,7 +4,7 @@ members = [
"crates/player",
"crates/library",
"crates/tui",
"crates/nsm",
"crates/nm",
"crates/textfix",
"crates/smbfs",
]

View file

@ -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 (`<?xml encoding="windows-1251"?>`) — 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/<podcast title>/<episode title>.<ext>` 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 |

View file

@ -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-запросы, если сервер их честно поддерживает) или скачивание на диск. Честно уважает заявленную в фиде кодировку (`<?xml encoding="windows-1251"?>`) — многие старые русскоязычные подкасты именно так и оформлены
- **Аудиокниги**: отдельная библиотека — сама распознаёт структуру Автор/Книга (и глубже, любую вложенность), автоматически объединяет части книги (CD1/CD2, Part 1/Part 2 и т.п.) в одну, но не путает с этим разные книги в одной папке автора. Прогресс (файл+позиция) сохраняется между запусками; вкладки Новая/Слушаю/Прослушано переключаются автоматически по проценту прослушанного, без ручной пометки. Пересканирование идемпотентно — повторное указание той же папки обновляет уже известные книги, а не плодит дубли; книги с пропавшими папками убираются из списка сами. Главы — через уже существующие cue-листы. Автора/название можно поправить вручную (частая проблема аудиокниг — в тегах путают чтеца с автором)
- **История прослушиваний**: при первом запуске спрашивает, запоминать ли прослушанное; статистика (артист/трек/дата) — диаграммой по часам за сегодня, по дням за неделю/месяц; включается/выключается в любой момент, сбрасывается с подтверждением
- **Новые релизы**: проверка новых альбомов/EP/синглов **у артистов, уже имеющихся в твоей библиотеке**, через открытую базу [MusicBrainz](https://musicbrainz.org) (без ключей API) — не глобальный чарт, а именно "что вышло нового у тех, кого ты слушаешь"
- **Раскладка**: хоткеи работают одинаково что на английской, что на русской раскладке
@ -63,11 +70,11 @@ xcode-select --install
```bash
git clone <URL-этого-репозитория>
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
| Клавиша | Действие |

View file

@ -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"

View file

@ -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<Duration>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AudiobookFile {
pub path: PathBuf,
pub duration: Option<Duration>,
/// Главы из cue-листа, если он был рядом с файлом. Если cue не было — один элемент на
/// весь файл (title = имя файла), чтобы UI мог единообразно показывать "главу" всегда.
pub chapters: Vec<Chapter>,
}
#[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<PathBuf>,
pub files: Vec<AudiobookFile>,
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<PathBuf>,
pub books: Vec<Audiobook>,
}
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<AudiobookLibrary> {
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<AudiobookLibrary> {
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<AudiobookLibrary> {
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<PathBuf>,
files: Vec<AudiobookFile>,
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<DiscoveredBook>) -> anyhow::Result<()> {
let mut direct_audio: Vec<PathBuf> = Vec::new();
let mut subdirs: Vec<PathBuf> = 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<PathBuf> = 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<PathBuf>,
audio_files: &[PathBuf],
book_dir: &Path,
scan_root: &Path,
) -> anyhow::Result<DiscoveredBook> {
let mut files = Vec::new();
let mut total_duration = Duration::ZERO;
// .cue-листы в папке — сопоставляем с аудиофайлами по имени, чтобы получить главы.
let cue_files: Vec<PathBuf> = 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<Duration> {
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<String> {
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));
}
}

View file

@ -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<Vec<u8>> {
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 {

View file

@ -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()

View file

@ -0,0 +1,394 @@
//! Подкасты: подписка по RSS-ссылке, список эпизодов, воспроизведение стримом или скачивание
//! на диск. Многие старые (особенно русскоязычные) подкасты публикуют RSS в кодировке
//! Windows-1251, а не UTF-8 — мы честно уважаем заявленную в `<?xml encoding="..."?>`
//! кодировку при разборе, а не считаем всё 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<String>,
/// Как есть из RSS (`pubDate`), без парсинга в полноценную дату — форматы в диких фидах
/// не всегда стандартны, а для отображения "как есть" сойдёт.
pub pub_date: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PodcastFeed {
pub feed_url: String,
pub title: String,
pub description: Option<String>,
pub episodes: Vec<PodcastEpisode>,
}
/// Список подписок (просто URL RSS-фидов), персистится на диске.
pub struct PodcastStore {
path: PathBuf,
}
impl PodcastStore {
pub fn new(path: PathBuf) -> Self {
PodcastStore { path }
}
pub fn load(&self) -> anyhow::Result<Vec<String>> {
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<PodcastFeed> {
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<String> {
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<PodcastFeed> {
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<PodcastFeed> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut path: Vec<String> = Vec::new();
let mut feed_title = String::new();
let mut feed_description: Option<String> = None;
let mut episodes = Vec::new();
let mut cur_title = String::new();
let mut cur_description: Option<String> = None;
let mut cur_enclosure_url: Option<String> = None;
let mut cur_pub_date: Option<String> = 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)) => {
// Самозакрывающиеся теги вроде <enclosure .../> не дают отдельных 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<anyhow::Result<PodcastFeed>> {
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<anyhow::Result<PathBuf>> {
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#"<?xml version="1.0" encoding="windows-1251"?><rss></rss>"#;
assert_eq!(
detect_xml_declared_encoding(xml),
Some("windows-1251".to_string())
);
}
#[test]
fn returns_none_without_declaration() {
let xml = br#"<rss><channel></channel></rss>"#;
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#"<?xml version="1.0" encoding="windows-1251"?><rss><channel><title>"#
.to_vec();
xml.extend_from_slice(&cp1251_bytes);
xml.extend_from_slice(b"</title></channel></rss>");
let decoded = decode_feed_bytes(&xml);
assert!(decoded.contains("Подкаст"), "decoded={decoded:?}");
}
#[test]
fn parses_basic_feed_with_episodes() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Test Podcast</title>
<description>A test feed</description>
<item>
<title>Episode One</title>
<description>First episode</description>
<enclosure url="http://example.com/ep1.mp3" type="audio/mpeg" length="100"/>
<pubDate>Mon, 01 Jan 2020 00:00:00 +0000</pubDate>
</item>
<item>
<title>Episode Two</title>
<enclosure url="http://example.com/ep2.mp3" type="audio/mpeg" length="200"/>
</item>
</channel>
</rss>"#;
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#"<?xml version="1.0" encoding="windows-1251"?>
<rss version="2.0">
<channel>
<title>{title}</title>
<item>
<title>{ep_title}</title>
<enclosure url="http://example.com/ep1.mp3" type="audio/mpeg" length="123"/>
</item>
</channel>
</rss>"#
);
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");
}
}

View file

@ -15,6 +15,7 @@ ureq = "2"
realfft = "3"
textfix = { path = "../textfix" }
smbfs = { path = "../smbfs" }
timestretch = "0.11"
[dev-dependencies]
encoding_rs = "0.8"

View file

@ -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<u64>,
seekable: bool,
/// Текущее открытое HTTP-соединение (тело ответа) и абсолютная позиция, с которой оно
/// начинается — читаем из него последовательно, пока не понадобится реальный скачок
/// позиции (после Seek), тогда переоткрываем новый Range-запрос лениво, при следующем read().
stream: Option<Box<dyn Read + Send + Sync>>,
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<Self> {
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::<u64>().ok())
} else {
response
.header("Content-Length")
.and_then(|s| s.parse::<u64>().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<usize> {
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<u64> {
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<u64> {
self.total_len
}
}
/// Оборачивает `smbfs::SmbFile` (Read+Seek) в symphonia-совместимый `MediaSource` —
/// позволяет декодировать аудио прямо с SMB-шары точно так же, как с локального файла,
/// без единого системного вызова вроде gio/gvfs.
@ -47,9 +172,12 @@ impl MediaSource for SmbMediaSource {
/// Строит symphonia-источник (Box<dyn MediaSource>) для локального файла или SMB-пути —
/// единая точка входа, дальше `open_track`/`read_tags` работают одинаково для обоих случаев.
fn open_media_source(path: &Path) -> anyhow::Result<Box<dyn MediaSource>> {
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<TrackInfo> {
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<TrackInfo> {
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. Воспроизводит точный

View file

@ -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<f32>,
@ -62,6 +67,24 @@ struct EngineState {
loudness_norm_enabled: bool,
/// Линейный коэффициент усиления из тега ReplayGain текущего трека, если есть.
current_track_replay_gain: Option<f32>,
/// Ручное усиление громкости (1.0 = 100%, без изменений; до 3.0 = 300%). Применяется
/// после нормализации/fade — это финальный "бустер" поверх всего остального.
boost_gain: f32,
/// Изменение скорости с сохранением высоты голоса (в отличие от `resampler`, который
/// меняет и то, и другое разом — тот теперь используется ТОЛЬКО для подгонки под частоту
/// устройства воспроизведения, без всякой связи с пользовательской скоростью). `None` до
/// загрузки первого трека — пересоздаётся под его частоту/каналы, как и `resampler`.
stretcher: Option<crate::stretch::StreamStretcher>,
/// Отдельная высота голоса (1.0 = без изменений), независимая от скорости. Реализована
/// как обычный ресемплинг (меняет и высоту, и длительность — как магнитофон), а
/// `stretcher` затем компенсирует изменение длительности, оставляя только сдвиг высоты.
pitch: f32,
pitch_resampler: Option<SpeedResampler>,
/// Пропуск затянувшейся тишины — не режем короткие паузы в речи (важны для восприятия),
/// только действительно долгую тишину (дефекты записи, длинные паузы монтажа и т.п.).
silence_skip_enabled: bool,
/// Сколько подряд идёт тишина прямо сейчас — сбрасывается любым не-тихим пакетом.
silent_run: Duration,
}
fn run(cmd_rx: Receiver<PlayerCommand>, evt_tx: Sender<PlayerEvent>) -> anyhow::Result<()> {
@ -132,11 +155,18 @@ fn run(cmd_rx: Receiver<PlayerCommand>, evt_tx: Sender<PlayerEvent>) -> 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<DecodedTrack> = 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<Option<Vec<f32>>>,
is_network_source: bool,
) -> (anyhow::Result<Option<Vec<f32>>>, Option<String>) {
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<f32> {
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::<f32>() / 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<Option<Vec<f32>>> = 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<Option<Vec<f32>>> = 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<Option<Vec<f32>>> = 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<f32> = (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<f32> = (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<Duration>, absolute_pos: Duration) -> Option<f32> {
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,29 +1045,45 @@ 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)
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));
}
Err(e) => Err(e),
};
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;
// Пропуск тишины меряем на пост-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 {
state.silent_run = Duration::ZERO;
}
skip_output = state.silent_run >= SILENCE_GRACE_PERIOD;
}
if !skip_output {
if state.loudness_norm_enabled {
if let Some(gain) = state.current_track_replay_gain {
// Точное значение из тега ReplayGain — просто статическое усиление.
@ -631,6 +1106,10 @@ fn decode_and_push(
}
}
if state.boost_gain > 1.0 {
apply_boost(&mut samples, state.boost_gain);
}
// Копим моно-микс пост-EQ сэмплов (то, что реально слышит пользователь) для
// визуализатора спектра — считаем FFT не чаще, чем раз в SPECTRUM_SEND_INTERVAL,
// чтобы не заваливать канал событий чаще, чем это осмысленно для отрисовки.
@ -653,12 +1132,24 @@ fn decode_and_push(
state.spectrum_buffer.clear();
}
let out = if let Some(r) = resampler.as_mut() {
r.push_interleaved(&samples)?
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() {
@ -666,6 +1157,16 @@ fn decode_and_push(
}
}
// Во время пропуска тишины позицию всё равно двигаем (см. выше,
// 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) трека внутри файла — переходим дальше,
// не дожидаясь физического EOF файла.
if let Some(end) = state.current_track_end {
@ -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(())

View file

@ -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)
}

View file

@ -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<Self> {
/// device_rate / track_rate — соотношение под частоту устройства воспроизведения.
/// Больше не занимается пользовательской скоростью — за неё отвечает отдельный
/// pitch-preserving `StreamStretcher`, включённый в цепочку раньше этого ресемплера.
pub fn new(channels: usize, ratio: f64) -> anyhow::Result<Self> {
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::<f32>::new(ratio, 2.0, params, chunk_size, channels)?;
Ok(SpeedResampler {
channels,

View file

@ -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<f32>,
/// Хвост предыдущего растянутого чанка, ждущий кроссфейда со следующим (interleaved).
pending_tail: Option<Vec<f32>>,
}
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<f32> {
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<f32> = self.input_buffer.drain(..chunk_samples).collect();
self.stretch_and_append(&chunk, &mut output);
}
output
}
/// Дообрабатывает остаток во внутреннем буфере (даже если он меньше полного чанка) и
/// выталкивает висящий хвост — вызывать на конце трека/при остановке, иначе последний
/// кусочек звука так и останется недоигранным внутри буфера.
pub fn finish(&mut self) -> Vec<f32> {
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<f32>) {
let params = StretchParams::new(self.ratio)
.with_sample_rate(self.sample_rate)
.with_channels(self.channels as u32);
let Ok(stretched) = timestretch::stretch(chunk, &params) else {
return; // не должно происходить при валидных параметрах, но не паникуем на аудио-потоке
};
self.append_with_crossfade(output, stretched);
}
fn append_with_crossfade(&mut self, output: &mut Vec<f32>, stretched: Vec<f32>) {
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<f32> {
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");
}
}

View file

@ -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<Duration>,
pub cover: Option<Vec<u8>>,
/// Человекочитаемое имя кодека (mp3, flac, vorbis, ...), если удалось определить.
pub codec: Option<String>,
pub sample_rate: Option<u32>,
pub channels: Option<u16>,
pub bits_per_sample: Option<u32>,
/// Размер файла в байтах — None для потоковых источников (радио), где размер неизвестен.
pub file_size: Option<u64>,
/// Средний битрейт в kbps, посчитанный как file_size*8/duration — честная оценка и для
/// постоянного, и для переменного битрейта (VBR), а не только заявленное в заголовке.
pub bitrate_kbps: Option<u32>,
}
#[derive(Debug, Clone)]
@ -173,6 +191,9 @@ pub enum PlayerEvent {
Spectrum(Vec<f32>),
FadeOutChanged(bool),
LoudnessNormChanged(bool),
BoostChanged(f32),
PitchChanged(f32),
SilenceSkipChanged(bool),
QueueFinished,
Error(String),
}

View file

@ -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<String>,
pub codec: Option<String>,
pub sample_rate: Option<u32>,
pub channels: Option<u16>,
pub bits_per_sample: Option<u32>,
pub file_size: Option<u64>,
pub bitrate_kbps: Option<u32>,
}
/// Режим фокуса ввода: обычная навигация по спискам, ввод текста поиска, либо эквалайзер.
@ -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<ProtocolType> {
}
}
/// Фильтр по вкладкам статуса на экране аудиокниг — в отличие от `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<String>, // URL текущей играющей станции, для подсветки
/// id аудиокниги, которая сейчас играет — если Some, события Position/QueueIndexChanged
/// периодически сохраняют прогресс обратно в AudiobookStore.
pub current_audiobook_id: Option<String>,
current_audiobook_file_index: usize,
last_audiobook_progress_save: std::time::Instant,
/// Момент, когда была нажата пауза — используется для "умного отката": чем дольше
/// пауза, тем больше откат назад при возобновлении (чтобы не потерять нить разговора).
/// `None`, если сейчас не на паузе (или ещё ничего не играло).
paused_at: Option<std::time::Instant>,
/// Результаты последнего онлайн-поиска через Radio Browser API. Пока не пусто —
/// вкладка Radio показывает их вместо основного списка (curated+custom).
pub online_results: Vec<Station>,
@ -251,6 +324,42 @@ pub struct App {
smb_connect_rx: Option<std::sync::mpsc::Receiver<(String, String, anyhow::Result<Vec<library::Track>>)>>,
pub smb_connecting: bool,
// --- Podcasts tab ---
pub podcast_store: library::podcast::PodcastStore,
pub podcast_cache: library::podcast::PodcastCache,
/// Подписки + последнее известное состояние (из кэша, обновляется по запросу) —
/// список фидов верхнего уровня.
pub podcasts: Vec<library::podcast::PodcastFeed>,
pub podcast_selected: usize,
/// Если Some(i) — раскрыт i-й подкаст, показываем список его эпизодов вместо списка подписок.
pub podcast_expanded: Option<usize>,
pub podcast_episode_selected: usize,
pub new_podcast_url: String,
pub podcast_fetching: bool,
podcast_fetch_rx: Option<std::sync::mpsc::Receiver<anyhow::Result<library::podcast::PodcastFeed>>>,
/// URL эпизода, который сейчас скачивается (для отображения прогресса в статусе) — не
/// поддерживаем параллельные скачивания, чтобы не путаться, скачивание одно за раз.
pub podcast_downloading_url: Option<String>,
podcast_download_rx: Option<std::sync::mpsc::Receiver<anyhow::Result<PathBuf>>>,
// --- 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<String>,
pub audiobook_file_selected: usize,
pub audiobook_search_query: String,
/// id книги, отмеченной для объединения — следующая книга, на которой нажмут ту же
/// клавишу, объединится с этой.
pub audiobook_merge_pending: Option<String>,
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<std::sync::mpsc::Receiver<anyhow::Result<library::audiobook::AudiobookLibrary>>>,
// --- Now playing / transport ---
pub now_playing: Option<NowPlaying>,
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<f32>,
/// Скользящее среднее басовой энергии — для адаптивного "детектора удара баса"
/// (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<library::podcast::PodcastFeed> = 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<PathBuf>) {
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<QueueItem> = 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<PathBuf> = book.files.iter().map(|f| f.path.clone()).collect();
if files.is_empty() {
return;
}
let queue: Vec<QueueItem> = 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::<f32>() / 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); // тихая "гитара" — база устаканивается низко
}
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),
"устаканившийся ровный громкий бас не должен считаться 'ударом' каждый кадр"
);
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 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);
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;
}
}
}

View file

@ -81,6 +81,9 @@ fn event_loop<B: ratatui::backend::Backend>(
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<B: ratatui::backend::Backend>(
}
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('/') => {

View file

@ -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<ListItem> = 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<ListItem> = 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<ListItem> = 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<ListItem> = 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::<f32>() / 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] = [
" ▄██▄ ",
" ◢█◉◉█◣ ",
" █▀▀▀▀█ ",
"▟████████▙ ╭─◯ ))) ",
" █▓▓▓▓█ ",
" █▓▓▓▓█ ",
" ▐▓▓▓▓▌ ",
" █ █ ",
" ▟█▙▟█▙ ",
];
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<Line> = 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)));
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")
}
Line::from(s)
})
.collect();
}
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())
)),
];
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);