Added audiobooks and podcasts
This commit is contained in:
parent
3578f3b08d
commit
4fb1ea911f
21 changed files with 3745 additions and 246 deletions
55
Cargo.lock
generated
55
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ members = [
|
|||
"crates/player",
|
||||
"crates/library",
|
||||
"crates/tui",
|
||||
"crates/nsm",
|
||||
"crates/nm",
|
||||
"crates/textfix",
|
||||
"crates/smbfs",
|
||||
]
|
||||
|
|
|
|||
26
README.en.md
26
README.en.md
|
|
@ -11,12 +11,18 @@ A terminal music player written in Rust — something like [cmus](https://cmus.g
|
|||
- **Transport**: shuffle, repeat (off/all/single track), playback speed (0.25×–1.75×), mute, real seeking with position tracking
|
||||
- **`.cue` sheets**: a single physical file (e.g. a whole album as one FLAC) is correctly recognized as multiple tracks with proper titles and boundaries
|
||||
- **Equalizer**: 8 bands, custom biquad filters
|
||||
- **Independent speed and pitch**: changing speed doesn't change the voice's timbre (pitch-preserving time-stretch with overlap-add, no clicks at chunk boundaries — verified numerically), pitch can be shifted separately and independently
|
||||
- **Volume boost**: up to 300% above nominal, with soft (not hard) peak limiting — for quiet recordings
|
||||
- **Smart rewind after pause**: the longer the pause, the bigger the rewind on resume — so you don't lose your place
|
||||
- **Silence skip**: automatically fast-forwards through drawn-out silence, leaves short pauses in speech alone
|
||||
- **Track technical info**: codec, sample rate, channels, bit depth, bitrate (honestly computed from file size and duration — correct for VBR too), file size — right under the equalizer
|
||||
- **Cover art**: rendered directly in the terminal (kitty/sixel/iTerm2 protocols, falling back to block graphics)
|
||||
- **Spectrum analyzer**: real-time FFT (not a fake animation) — toggle cover/spectrum with `v`, expand to fullscreen with `z` (transport controls — play/pause, next/prev, seek, mute — still work while fullscreen)
|
||||
- **Fade-out at track end**: the last 4 seconds smoothly fade to silence — toggle with `F`, off by default
|
||||
- **Loudness normalization**: ReplayGain tag takes priority if present in the file, otherwise an adaptive leveler (AGC) adjusts volume on the fly — toggle with `G`, off by default
|
||||
- **Internet radio**: add your own stations by URL, a curated built-in list (SomaFM, Radio Paradise), station search via the open [Radio Browser API](https://api.radio-browser.info) (auto-fails over between several mirrors if one is down), ICY metadata ("now playing" straight from the stream)
|
||||
- **SMB shares**: connect to network folders (including anonymous/guest access) — a pure-Rust SMB client (the `smb` crate), no system dependency on `gio`/gvfs
|
||||
- **Podcasts**: subscribe by RSS URL, browse episodes, stream directly from the URL (with seeking — via real HTTP Range requests, if the server honestly supports them) or download to disk. Correctly honors the encoding declared in the feed (`<?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 |
|
||||
|
|
|
|||
52
README.md
52
README.md
|
|
@ -1,4 +1,4 @@
|
|||
# nm
|
||||
# nsm
|
||||
|
||||
Терминальный музыкальный плеер на Rust — что-то вроде [cmus](https://cmus.github.io/), но с нуля, с эквалайзером, поддержкой cue-листов, интернет-радио и SMB-шар.
|
||||
|
||||
|
|
@ -11,12 +11,19 @@
|
|||
- **Транспорт**: shuffle, повтор (выкл/один трек/всё), изменение скорости воспроизведения (0.25×–1.75×), mute, честный seek (перемотка) с сохранением позиции
|
||||
- **`.cue`-листы**: один физический файл (например, целый альбом одним FLAC) корректно распознаётся как несколько треков с правильными названиями и границами
|
||||
- **Эквалайзер**: 8 полос, свои биквадратные фильтры
|
||||
- **Раздельная скорость и высота голоса**: изменение скорости не меняет тембр (pitch-preserving time-stretch с overlap-add, без щелчков на стыках кусков — проверено численно), высоту можно двигать отдельно и независимо
|
||||
- **Усиление громкости**: до 300% сверх номинальной, с мягким (не жёстким) ограничением пиков — для тихих записей
|
||||
- **Умный откат после паузы**: чем дольше пауза, тем больше откат назад при возобновлении — не теряется нить разговора
|
||||
- **Пропуск тишины**: автоматически проматывает затянувшуюся тишину, короткие паузы в речи не трогает
|
||||
- **Техническая информация о треке**: кодек, частота дискретизации, каналы, битность, битрейт (честно посчитанный из размера файла и длительности — корректно и для VBR), размер файла — прямо под эквалайзером
|
||||
- **Обложка**: отображается прямо в терминале (kitty/sixel/iTerm2-протоколы с fallback на блочную графику)
|
||||
- **Анализатор спектра**: настоящий FFT в реальном времени (не анимация) — переключение обложка/спектр клавишей `v`, разворот на весь экран клавишей `z` (транспорт — пробел/next/prev/seek/mute — работает и в полноэкранном режиме)
|
||||
- **Затухание в конце трека**: последние 4 секунды перед концом плавно уводятся в тишину — включается клавишей `F`, по умолчанию выключено
|
||||
- **Выравнивание громкости**: тег ReplayGain в приоритете (если есть в файле), иначе — адаптивный левеллер (AGC), подстраивающий громкость на лету — клавиша `G`, по умолчанию выключено
|
||||
- **Интернет-радио**: свои станции по URL, встроенный список (SomaFM, Radio Paradise), поиск станций через открытый [Radio Browser API](https://api.radio-browser.info) (с автопереключением между несколькими зеркалами, если одно недоступно), ICY-метаданные ("сейчас играет" прямо из потока)
|
||||
- **SMB-шары**: подключение к сетевым папкам (в т.ч. анонимно/гостевой доступ) — свой SMB-клиент на чистом Rust (крейт `smb`), без системных зависимостей вроде `gio`/gvfs
|
||||
- **Подкасты**: подписка по RSS-ссылке, список эпизодов, стриминг напрямую по ссылке (с перемоткой — через настоящие HTTP Range-запросы, если сервер их честно поддерживает) или скачивание на диск. Честно уважает заявленную в фиде кодировку (`<?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
|
||||
|
||||
| Клавиша | Действие |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
670
crates/library/src/audiobook.rs
Normal file
670
crates/library/src/audiobook.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
394
crates/library/src/podcast.rs
Normal file
394
crates/library/src/podcast.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ ureq = "2"
|
|||
realfft = "3"
|
||||
textfix = { path = "../textfix" }
|
||||
smbfs = { path = "../smbfs" }
|
||||
timestretch = "0.11"
|
||||
|
||||
[dev-dependencies]
|
||||
encoding_rs = "0.8"
|
||||
|
|
|
|||
|
|
@ -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. Воспроизводит точный
|
||||
|
|
@ -522,4 +778,4 @@ mod tests {
|
|||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,84 +1045,126 @@ fn decode_and_push(
|
|||
};
|
||||
|
||||
let packet_result = next_packet_samples(track);
|
||||
|
||||
// Для радио сетевая ошибка (обрыв соединения) приходит как Err, а не Ok(None) —
|
||||
// трактуем это так же, как конец потока.
|
||||
let packet_result = match packet_result {
|
||||
Ok(v) => Ok(v),
|
||||
Err(e) if state.is_radio => {
|
||||
let _ = evt_tx.send(PlayerEvent::Error(format!("radio stream error: {e}")));
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let (packet_result, network_err_msg) =
|
||||
classify_packet_result(packet_result, state.is_network_source);
|
||||
if let Some(msg) = network_err_msg {
|
||||
let _ = evt_tx.send(PlayerEvent::Error(msg));
|
||||
}
|
||||
|
||||
match packet_result? {
|
||||
Some(mut samples) => {
|
||||
let frames = samples.len() / track.spec.channels.count();
|
||||
Some(samples) => {
|
||||
let native_channels = track.spec.channels.count().max(1);
|
||||
let frames = samples.len() / native_channels;
|
||||
state.frames_decoded_native += frames as u64;
|
||||
|
||||
// Приводим к числу каналов устройства СРАЗУ — EQ/resampler/stretcher/
|
||||
// pitch_resampler ниже все сконструированы под device_channels, а декодированный
|
||||
// пакет пришёл в формате самого трека (может быть моно, даже если устройство
|
||||
// стерео — частое дело для подкастов).
|
||||
let mut samples = convert_channels(&samples, native_channels, state.device_channels);
|
||||
|
||||
if let Some(eq) = state.eq.as_mut() {
|
||||
eq.process_interleaved(&mut samples);
|
||||
}
|
||||
|
||||
let channels = track.spec.channels.count().max(1);
|
||||
let channels = state.device_channels;
|
||||
|
||||
if state.loudness_norm_enabled {
|
||||
if let Some(gain) = state.current_track_replay_gain {
|
||||
// Точное значение из тега ReplayGain — просто статическое усиление.
|
||||
for s in samples.iter_mut() {
|
||||
*s *= gain;
|
||||
}
|
||||
// Пропуск тишины меряем на пост-EQ сигнале (то, что реально будет слышно, если
|
||||
// не пропустим) — до нормализации/буста, честно на "как записано".
|
||||
let mut skip_output = false;
|
||||
if state.silence_skip_enabled {
|
||||
let rms = compute_rms(&samples);
|
||||
let packet_duration = Duration::from_secs_f64(frames as f64 / track.spec.rate as f64);
|
||||
if rms < SILENCE_RMS_THRESHOLD {
|
||||
state.silent_run += packet_duration;
|
||||
} else {
|
||||
// Тега нет — используем адаптивный левеллер (AGC) как fallback.
|
||||
leveler.process(&mut samples, channels, state.native_rate as f32);
|
||||
state.silent_run = Duration::ZERO;
|
||||
}
|
||||
skip_output = state.silent_run >= SILENCE_GRACE_PERIOD;
|
||||
}
|
||||
|
||||
if state.fade_out_enabled {
|
||||
if let Some(fade) = fade_gain(state) {
|
||||
if fade < 1.0 {
|
||||
if !skip_output {
|
||||
if state.loudness_norm_enabled {
|
||||
if let Some(gain) = state.current_track_replay_gain {
|
||||
// Точное значение из тега ReplayGain — просто статическое усиление.
|
||||
for s in samples.iter_mut() {
|
||||
*s *= fade;
|
||||
*s *= gain;
|
||||
}
|
||||
} else {
|
||||
// Тега нет — используем адаптивный левеллер (AGC) как fallback.
|
||||
leveler.process(&mut samples, channels, state.native_rate as f32);
|
||||
}
|
||||
}
|
||||
|
||||
if state.fade_out_enabled {
|
||||
if let Some(fade) = fade_gain(state) {
|
||||
if fade < 1.0 {
|
||||
for s in samples.iter_mut() {
|
||||
*s *= fade;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Копим моно-микс пост-EQ сэмплов (то, что реально слышит пользователь) для
|
||||
// визуализатора спектра — считаем FFT не чаще, чем раз в SPECTRUM_SEND_INTERVAL,
|
||||
// чтобы не заваливать канал событий чаще, чем это осмысленно для отрисовки.
|
||||
state
|
||||
.spectrum_buffer
|
||||
.extend(samples.chunks(channels).map(|frame| {
|
||||
frame.iter().sum::<f32>() / channels as f32
|
||||
}));
|
||||
if state.spectrum_buffer.len() >= FFT_SIZE
|
||||
&& state.last_spectrum_send.elapsed() >= SPECTRUM_SEND_INTERVAL
|
||||
{
|
||||
let start = state.spectrum_buffer.len() - FFT_SIZE;
|
||||
let bands = spectrum_analyzer.compute(&state.spectrum_buffer[start..], state.native_rate as f32);
|
||||
let _ = evt_tx.send(PlayerEvent::Spectrum(bands.to_vec()));
|
||||
state.spectrum_buffer.clear();
|
||||
state.last_spectrum_send = Instant::now();
|
||||
}
|
||||
// не даём буферу расти бесконечно, если по какой-то причине долго не отправляли
|
||||
if state.spectrum_buffer.len() > FFT_SIZE * 8 {
|
||||
state.spectrum_buffer.clear();
|
||||
}
|
||||
|
||||
let out = if let Some(r) = resampler.as_mut() {
|
||||
r.push_interleaved(&samples)?
|
||||
} else {
|
||||
samples
|
||||
};
|
||||
|
||||
for s in out {
|
||||
// если буфер полон, немного подождём, чтобы не терять сэмплы
|
||||
while producer.push(s).is_err() {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
if state.boost_gain > 1.0 {
|
||||
apply_boost(&mut samples, state.boost_gain);
|
||||
}
|
||||
|
||||
// Копим моно-микс пост-EQ сэмплов (то, что реально слышит пользователь) для
|
||||
// визуализатора спектра — считаем FFT не чаще, чем раз в SPECTRUM_SEND_INTERVAL,
|
||||
// чтобы не заваливать канал событий чаще, чем это осмысленно для отрисовки.
|
||||
state
|
||||
.spectrum_buffer
|
||||
.extend(samples.chunks(channels).map(|frame| {
|
||||
frame.iter().sum::<f32>() / channels as f32
|
||||
}));
|
||||
if state.spectrum_buffer.len() >= FFT_SIZE
|
||||
&& state.last_spectrum_send.elapsed() >= SPECTRUM_SEND_INTERVAL
|
||||
{
|
||||
let start = state.spectrum_buffer.len() - FFT_SIZE;
|
||||
let bands = spectrum_analyzer.compute(&state.spectrum_buffer[start..], state.native_rate as f32);
|
||||
let _ = evt_tx.send(PlayerEvent::Spectrum(bands.to_vec()));
|
||||
state.spectrum_buffer.clear();
|
||||
state.last_spectrum_send = Instant::now();
|
||||
}
|
||||
// не даём буферу расти бесконечно, если по какой-то причине долго не отправляли
|
||||
if state.spectrum_buffer.len() > FFT_SIZE * 8 {
|
||||
state.spectrum_buffer.clear();
|
||||
}
|
||||
|
||||
let pitched = if let Some(pr) = state.pitch_resampler.as_mut() {
|
||||
pr.push_interleaved(&samples)?
|
||||
} else {
|
||||
samples
|
||||
};
|
||||
|
||||
let stretched = if let Some(s) = state.stretcher.as_mut() {
|
||||
s.process(&pitched)
|
||||
} else {
|
||||
pitched
|
||||
};
|
||||
|
||||
let out = if let Some(r) = resampler.as_mut() {
|
||||
r.push_interleaved(&stretched)?
|
||||
} else {
|
||||
stretched
|
||||
};
|
||||
|
||||
for s in out {
|
||||
// если буфер полон, немного подождём, чтобы не терять сэмплы
|
||||
while producer.push(s).is_err() {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
// Во время пропуска тишины позицию всё равно двигаем (см. выше,
|
||||
// frames_decoded_native уже посчитан) — но события Position шлём только тут,
|
||||
// при обычном проигрывании; на пропуске тишины отдельно шлём ниже, чтобы
|
||||
// прогресс-бар не "замирал" на весь пропускаемый участок.
|
||||
} else if state.silent_run.as_millis() % 500 < 50 {
|
||||
// Не на каждый пакет (их много в секунду) — периодически всё же шлём Position,
|
||||
// чтобы UI видел, что мы куда-то продвигаемся, а не просто зависли.
|
||||
let _ = evt_tx.send(PlayerEvent::Position(current_position(state)));
|
||||
}
|
||||
|
||||
// Достигли конца виртуального (cue) трека внутри файла — переходим дальше,
|
||||
|
|
@ -678,12 +1179,35 @@ fn decode_and_push(
|
|||
None => {
|
||||
// физический конец файла — переходим дальше согласно repeat/shuffle (для файлов)
|
||||
// либо просто останавливаемся (для радио: следующего трека не существует)
|
||||
// Сначала дожимаем pitch_resampler (его хвост идёт через process(), как обычные
|
||||
// сэмплы), затем сам stretcher (его собственный кроссфейд-хвост) — иначе
|
||||
// потеряется последний фрагмент трека. Получившееся отдаём через device resampler.
|
||||
let pitch_tail = state
|
||||
.pitch_resampler
|
||||
.as_mut()
|
||||
.and_then(|pr| pr.flush().ok())
|
||||
.unwrap_or_default();
|
||||
let mut combined_tail = state
|
||||
.stretcher
|
||||
.as_mut()
|
||||
.map(|s| s.process(&pitch_tail))
|
||||
.unwrap_or(pitch_tail);
|
||||
combined_tail.extend(state.stretcher.as_mut().map(|s| s.finish()).unwrap_or_default());
|
||||
if let Some(r) = resampler.as_mut() {
|
||||
if let Ok(head) = r.push_interleaved(&combined_tail) {
|
||||
for s in head {
|
||||
let _ = producer.push(s);
|
||||
}
|
||||
}
|
||||
if let Ok(tail) = r.flush() {
|
||||
for s in tail {
|
||||
let _ = producer.push(s);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for s in combined_tail {
|
||||
let _ = producer.push(s);
|
||||
}
|
||||
}
|
||||
advance_or_finish(state, current_track, resampler, paused_flag, evt_tx);
|
||||
Ok(())
|
||||
|
|
@ -714,4 +1238,4 @@ fn sibling_album_tracks(path: &PathBuf) -> anyhow::Result<Vec<PathBuf>> {
|
|||
.collect();
|
||||
entries.sort();
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
232
crates/player/src/stretch.rs
Normal file
232
crates/player/src/stretch.rs
Normal 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, ¶ms) 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); // тихая "гитара" — база устаканивается низко
|
||||
fn short_pause_no_rewind() {
|
||||
assert_eq!(smart_rewind_amount(Duration::from_secs(3)), Duration::ZERO);
|
||||
assert_eq!(smart_rewind_amount(Duration::from_secs(9)), Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_pause_small_rewind() {
|
||||
assert_eq!(smart_rewind_amount(Duration::from_secs(10)), Duration::from_secs(3));
|
||||
assert_eq!(smart_rewind_amount(Duration::from_secs(45)), Duration::from_secs(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_pause_bigger_rewind() {
|
||||
assert_eq!(smart_rewind_amount(Duration::from_secs(60)), Duration::from_secs(10));
|
||||
assert_eq!(smart_rewind_amount(Duration::from_secs(300)), Duration::from_secs(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn very_long_pause_max_rewind() {
|
||||
assert_eq!(smart_rewind_amount(Duration::from_secs(600)), Duration::from_secs(30));
|
||||
assert_eq!(smart_rewind_amount(Duration::from_secs(3600)), Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_is_monotonically_non_decreasing_with_pause_length() {
|
||||
// Чем дольше пауза, тем откат не должен уменьшаться — иначе получилось бы странно:
|
||||
// отошёл на 11 минут — откат меньше, чем если бы отошёл на 9.
|
||||
let samples = [0, 5, 9, 10, 30, 59, 60, 300, 599, 600, 3600];
|
||||
let mut prev = Duration::ZERO;
|
||||
for s in samples {
|
||||
let r = smart_rewind_amount(Duration::from_secs(s));
|
||||
assert!(r >= prev, "rewind decreased at {s}s: {r:?} < {prev:?}");
|
||||
prev = r;
|
||||
}
|
||||
assert!(!is_bass_hit(0.05, ema), "ровный тихий уровень не должен считаться ударом");
|
||||
|
||||
// резкий удар баса поверх этой тихой базы
|
||||
assert!(is_bass_hit(0.4, ema), "резкий всплеск над тихой базой должен сработать");
|
||||
}
|
||||
|
||||
/// Трек, где бас стабильно громкий (тяжёлый метал) — после того, как ema устаканится
|
||||
/// на этом уровне, ровный громкий бас НЕ должен постоянно триггерить "удар" на каждом
|
||||
/// кадре — иначе десантники будут просто дёргаться без остановки, а не танцевать в такт.
|
||||
#[test]
|
||||
fn steady_loud_bass_does_not_trigger_constantly_once_settled() {
|
||||
let mut ema = 0.0;
|
||||
for _ in 0..50 {
|
||||
ema = update_bass_ema(ema, 0.5, 0.15);
|
||||
}
|
||||
assert!(
|
||||
!is_bass_hit(0.5, ema),
|
||||
"устаканившийся ровный громкий бас не должен считаться 'ударом' каждый кадр"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ema_moves_toward_target_but_not_instantly() {
|
||||
let ema = update_bass_ema(0.0, 1.0, 0.15);
|
||||
assert!(ema > 0.0 && ema < 1.0, "ema={ema}");
|
||||
assert!((ema - 0.15).abs() < 0.001);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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('/') => {
|
||||
|
|
@ -702,4 +1010,4 @@ fn handle_online_results_key(app: &mut App, code: KeyCode) {
|
|||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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] = [
|
||||
" ▄██▄ ",
|
||||
" ◢█◉◉█◣ ",
|
||||
" █▀▀▀▀█ ",
|
||||
"▟████████▙ ╭─◯ ))) ",
|
||||
" █▓▓▓▓█ ╱ ",
|
||||
" █▓▓▓▓█ ╱ ",
|
||||
" ▐▓▓▓▓▌ ╱ ",
|
||||
" █ █ ",
|
||||
" ▟█▙▟█▙ ",
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const KB: f64 = 1024.0;
|
||||
const MB: f64 = KB * 1024.0;
|
||||
const GB: f64 = MB * 1024.0;
|
||||
let b = bytes as f64;
|
||||
if b >= GB {
|
||||
format!("{:.2} GB", b / GB)
|
||||
} else if b >= MB {
|
||||
format!("{:.2} MB", b / MB)
|
||||
} else if b >= KB {
|
||||
format!("{:.1} KB", b / KB)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
fn channels_label(n: u16) -> String {
|
||||
match n {
|
||||
1 => "1 (mono)".to_string(),
|
||||
2 => "2 (stereo)".to_string(),
|
||||
n => format!("{n} (multichannel)"),
|
||||
}
|
||||
}
|
||||
|
||||
let mut lines = vec![
|
||||
Line::from(format!(
|
||||
"Codec: {}",
|
||||
np.codec.as_deref().unwrap_or("unknown")
|
||||
)),
|
||||
Line::from(format!(
|
||||
"Sample rate: {}",
|
||||
np.sample_rate.map(|r| format!("{r} Hz")).unwrap_or_else(|| "unknown".to_string())
|
||||
)),
|
||||
Line::from(format!(
|
||||
"Channels: {}",
|
||||
np.channels.map(channels_label).unwrap_or_else(|| "unknown".to_string())
|
||||
)),
|
||||
];
|
||||
const FRAME_B: [&str; 9] = [
|
||||
" ▄██▄ ",
|
||||
" ◢█◉◉█◣ ",
|
||||
" █▀▀▀▀█ ",
|
||||
"▟████████▙ ",
|
||||
" █▓▓▓▓█ ╲ ",
|
||||
" █▓▓▓▓█ ╲ ",
|
||||
" ▐▓▓▓▓▌╭─◯ ))) ",
|
||||
" ▟█▙ █ ",
|
||||
" █ ▟█▙ ",
|
||||
];
|
||||
let sprite = if pose_a { &FRAME_A } else { &FRAME_B };
|
||||
|
||||
const MARINE_WIDTH: usize = 24;
|
||||
let count = ((inner.width as usize) / MARINE_WIDTH).clamp(1, 4);
|
||||
|
||||
let lines: Vec<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)));
|
||||
}
|
||||
Line::from(s)
|
||||
})
|
||||
.collect();
|
||||
if let Some(bits) = np.bits_per_sample {
|
||||
lines.push(Line::from(format!("Bit depth: {bits}-bit")));
|
||||
}
|
||||
lines.push(Line::from(format!(
|
||||
"Bitrate: {}",
|
||||
np.bitrate_kbps
|
||||
.map(|b| format!("{b} kbps (avg)"))
|
||||
.unwrap_or_else(|| "unknown (streaming)".to_string())
|
||||
)));
|
||||
lines.push(Line::from(format!(
|
||||
"File size: {}",
|
||||
np.file_size.map(format_size).unwrap_or_else(|| "n/a (streaming)".to_string())
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(format!(
|
||||
"Duration: {}",
|
||||
np.duration
|
||||
.map(|d| format!("{}:{:02}", d.as_secs() / 60, d.as_secs() % 60))
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
)));
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.style(Style::default().fg(Color::Magenta))
|
||||
.alignment(Alignment::Center),
|
||||
Paragraph::new(lines).style(Style::default().fg(Color::Gray)),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
|
@ -841,6 +1119,26 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
|
|||
"CHECK ARTIST: {} (Enter: check, Esc: cancel)",
|
||||
app.new_release_artist_input
|
||||
),
|
||||
Focus::PodcastUrlInput => format!(
|
||||
"SUBSCRIBE TO PODCAST (RSS URL): {} (Enter: subscribe, Esc: cancel)",
|
||||
app.new_podcast_url
|
||||
),
|
||||
Focus::AudiobookPathInput => format!(
|
||||
"ADD AUDIOBOOK FOLDER: {} (Enter: scan, Esc: cancel)",
|
||||
app.new_audiobook_path_input
|
||||
),
|
||||
Focus::AudiobookSearch => format!(
|
||||
"SEARCH AUDIOBOOKS: {} (Enter: apply, Esc: clear)",
|
||||
app.audiobook_search_query
|
||||
),
|
||||
Focus::AudiobookEditTitle => format!(
|
||||
"EDIT TITLE: {} (Enter: next — author, Esc: cancel)",
|
||||
app.audiobook_edit_title_input
|
||||
),
|
||||
Focus::AudiobookEditAuthor => format!(
|
||||
"EDIT AUTHOR: {} (Enter: save, Esc: cancel)",
|
||||
app.audiobook_edit_author_input
|
||||
),
|
||||
_ => app.status_message.clone(),
|
||||
};
|
||||
frame.render_widget(Paragraph::new(text), area);
|
||||
|
|
@ -849,4 +1147,4 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
|
|||
fn fmt_time(d: std::time::Duration) -> String {
|
||||
let secs = d.as_secs();
|
||||
format!("{:02}:{:02}", secs / 60, secs % 60)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue